"use client"; // app/settings/page.jsx — choose and configure your AI provider, all stored locally. import { useEffect, useRef, useState } from "react"; const PROVIDERS = [ { id: "ollama", name: "Ollama", desc: "Free, private, runs on this computer", local: true }, { id: "lmstudio", name: "LM Studio", desc: "Free, private, runs on this computer", local: true }, { id: "anthropic", name: "Anthropic (Claude)", desc: "Cloud API — needs an API key", local: false }, { id: "openai", name: "OpenAI (GPT)", desc: "Cloud API — needs an API key", local: false }, { id: "google", name: "Google AI (Gemini)", desc: "Cloud API — needs an API key", local: false }, ]; const KEY_LINKS = { anthropic: "https://console.anthropic.com/", openai: "https://platform.openai.com/api-keys", google: "https://aistudio.google.com/apikey", }; export default function SettingsPage() { const [s, setS] = useState(null); const [models, setModels] = useState({}); // provider -> string[] const [modelsBusy, setModelsBusy] = useState(""); const [modelsErr, setModelsErr] = useState({}); // provider -> error const [test, setTest] = useState({}); // provider -> {busy, ok, message} const [autoInfo, setAutoInfo] = useState(null); // resolved auto limits for the active model const [saving, setSaving] = useState(false); const [toast, setToast] = useState(""); const [error, setError] = useState(""); const toastTimer = useRef(null); useEffect(() => { fetch("/api/settings").then((r) => r.json()).then(setS).catch(() => setError("Could not load settings.")); }, []); const activeProvider = s?.provider; const activeModel = s?.providers?.[activeProvider]?.model || ""; const activeKey = s?.providers?.[activeProvider]?.apiKey || ""; const autoOn = s ? s.generation?.auto !== false : true; // Preview the auto-tuned limits whenever the model selection (or key) changes. // Debounced so typing an API key doesn't fire a request per keystroke. useEffect(() => { if (!s || !autoOn || !activeModel) { setAutoInfo(null); return; } let cancelled = false; setAutoInfo(null); const t = setTimeout(() => { fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "defaults", provider: activeProvider, settings: s }), }) .then((r) => r.json()) .then((d) => { if (!cancelled && !d.error && d.maxTokens) setAutoInfo(d); }) .catch(() => {}); }, 500); return () => { cancelled = true; clearTimeout(t); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeProvider, activeModel, activeKey, autoOn]); function showToast(msg) { setToast(msg); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(""), 2400); } function setProviderField(provider, field, value) { setS((cur) => ({ ...cur, providers: { ...cur.providers, [provider]: { ...cur.providers[provider], [field]: value } }, })); } function setGen(field, value) { setS((cur) => ({ ...cur, generation: { ...cur.generation, [field]: value } })); } function setProfile(field, value) { setS((cur) => ({ ...cur, profile: { ...(cur.profile || {}), [field]: value } })); } // Downscale the logo client-side (max 512px) so it stays a small data URL in db.json // while staying crisp at the printed header size. function onLogoFile(e) { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; if (!file.type.startsWith("image/")) { setError("Please choose an image file (PNG, JPG, etc.)."); return; } const url = URL.createObjectURL(file); const img = new Image(); img.onload = () => { URL.revokeObjectURL(url); const scale = Math.min(1, 512 / Math.max(img.width, img.height)); const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(img.width * scale)); canvas.height = Math.max(1, Math.round(img.height * scale)); canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height); setProfile("logo", canvas.toDataURL("image/png")); }; img.onerror = () => { URL.revokeObjectURL(url); setError("Couldn't read that image — try a PNG or JPG."); }; img.src = url; } async function refreshModels(provider) { setModelsBusy(provider); setModelsErr((e) => ({ ...e, [provider]: "" })); try { const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "models", provider, settings: s }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Could not list models."); setModels((m) => ({ ...m, [provider]: data.models })); if (data.models.length && !data.models.includes(s.providers[provider].model)) { setProviderField(provider, "model", data.models[0]); } } catch (e) { setModelsErr((er) => ({ ...er, [provider]: String(e.message || e) })); } finally { setModelsBusy(""); } } async function testConnection(provider) { setTest((t) => ({ ...t, [provider]: { busy: true } })); try { const res = await fetch("/api/providers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "test", provider, settings: s }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Test failed."); setTest((t) => ({ ...t, [provider]: { ok: true, message: data.message } })); } catch (e) { setTest((t) => ({ ...t, [provider]: { ok: false, message: String(e.message || e) } })); } } async function save() { setSaving(true); setError(""); try { const res = await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(s), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Save failed."); setS(data); showToast("Settings saved"); } catch (e) { setError(String(e.message || e)); } finally { setSaving(false); } } if (!s) return

Loading…

; const active = s.provider; const activeCfg = s.providers[active] || {}; const isLocal = active === "ollama" || active === "lmstudio"; const modelList = models[active] || []; const t = test[active]; return (

Settings

Pick the AI that powers generation. Local options keep everything — source material, questions, API traffic — on this computer. Keys and settings are stored only in your local data/db.json file.

{error &&
{error}
}

Teacher & school

Shown in the header of every printed and exported assignment — leave anything blank to omit it.

School logo or mascot (optional)
{s.profile?.logo && ( School logo preview )} {s.profile?.logo && ( )}
Appears beside the school name on printed pages. PNG with transparency looks best; the image is stored locally and shrunk automatically.

AI provider

{PROVIDERS.map((p) => ( ))}

{PROVIDERS.find((p) => p.id === active)?.name} setup

{isLocal && ( )} {!isLocal && ( )}
{t && !t.busy && ( {t.ok ? "✓ " : "✕ "}{t.message} )}

Generation defaults

{autoOn && (

{!activeModel ? "Pick a model above to see its tuned limits." : autoInfo ? `Tuned for ${activeModel}: sources up to ${autoInfo.maxSourceChars.toLocaleString()} characters, responses up to ${autoInfo.maxTokens.toLocaleString()} tokens (context window ≈ ${Math.round(autoInfo.caps.contextTokens / 1000).toLocaleString()}k tokens${autoInfo.caps.source === "fallback" ? ", estimated — couldn't read the model's limits" : ""}).` : <> Checking the model's limits…}

)}
{!autoOn && ( )} {!autoOn && ( )}
{toast &&
{toast}
}
); }