"use client"; // app/settings/page.jsx — choose and configure your AI provider, all stored locally. // Flush two-column "paper form": sticky table of contents + underline-only fields. import { useEffect, useRef, useState } from "react"; import { IconUserCircle, IconCpu, IconSchool, IconAdjustments, IconPhoto, IconRefresh, IconPlug, IconCheck, IconX, } from "@tabler/icons-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", }; const TOC = [ { id: "teacher", label: "Teacher & school", icon: IconUserCircle }, { id: "provider", label: "AI provider", icon: IconCpu }, { id: "canvas", label: "Canvas (LMS)", icon: IconSchool }, { id: "generation", label: "Generation defaults", icon: IconAdjustments }, ]; export default function SettingsPage() { const [s, setS] = useState(null); const [models, setModels] = useState({}); const [modelsBusy, setModelsBusy] = useState(""); const [modelsErr, setModelsErr] = useState({}); const [test, setTest] = useState({}); const [canvasTest, setCanvasTest] = useState(null); const [autoInfo, setAutoInfo] = useState(null); const [saving, setSaving] = useState(false); const [toast, setToast] = useState(""); const [error, setError] = useState(""); const [activeToc, setActiveToc] = useState("teacher"); 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; 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]); // Highlight the TOC entry for the section currently in view. useEffect(() => { if (!s) return; const obs = new IntersectionObserver( (entries) => { entries.forEach((e) => { if (e.isIntersecting) setActiveToc(e.target.id); }); }, { rootMargin: "-20% 0px -70% 0px", threshold: 0 } ); TOC.forEach(({ id }) => { const el = document.getElementById(id); if (el) obs.observe(el); }); return () => obs.disconnect(); }, [s]); 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 } })); } function setCanvas(field, value) { setCanvasTest(null); setS((cur) => ({ ...cur, canvas: { ...(cur.canvas || {}), [field]: value } })); } async function testCanvas() { setCanvasTest({ busy: true }); try { const res = await fetch("/api/canvas", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "test", settings: s }) }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Test failed."); setCanvasTest({ ok: true, message: data.message }); } catch (e) { setCanvasTest({ ok: false, message: String(e.message || e) }); } } 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 ---- */}

Teacher & school

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

Teacher name setProfile("teacherName", e.target.value)} placeholder="e.g. Mr. Drew" />
Class / course setProfile("className", e.target.value)} placeholder="e.g. 7th Grade Science — Period 3" />
School name setProfile("schoolName", e.target.value)} placeholder="e.g. Lincoln Middle School" />
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 ---- */}

AI provider

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

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

{isLocal && (
Server address (base URL) setProviderField(active, "baseUrl", e.target.value)} placeholder={active === "ollama" ? "http://localhost:11434" : "http://localhost:1234"} /> {active === "ollama" ? <>Where this app should find Ollama. Same computer: the default is right. On another machine, enter that machine’s address, e.g. http://192.168.1.50:11434 — and set OLLAMA_HOST=0.0.0.0 there. Use “Test connection” to confirm. : <>Where this app should find LM Studio. Same computer: the default is right. On another machine: enter its address, e.g. http://192.168.1.50:1234 — and in LM Studio’s Developer tab enable “Serve on Local Network”.}
)} {!isLocal && (
API key setProviderField(active, "apiKey", e.target.value)} placeholder="Paste your API key" autoComplete="off" /> Get a key at {KEY_LINKS[active]}. It is stored only on this computer.
)}
Model
{modelList.length > 0 ? ( ) : ( setProviderField(active, "model", e.target.value)} placeholder={active === "ollama" ? "e.g. llama3.1:8b" : "Model name"} style={{ flex: 1, minWidth: 220 }} /> )}
{modelsErr[active] ? {modelsErr[active]} : Accuracy tip: bigger models write noticeably better questions. Locally, prefer an 8B+ model; in the cloud, the default models work well.}
{t && !t.busy && ( {t.ok ? : }{t.message} )}
{/* ---- Canvas LMS ---- */}

Canvas (LMS) integration

Optional. Lets you push a finished assignment straight into a Canvas course from the editor. You can always skip this and use the downloadable Canvas .zip instead.

Canvas web address setCanvas("baseUrl", e.target.value)} placeholder="https://yourschool.instructure.com" autoComplete="off" /> The address you use to log into Canvas — e.g. https://yourschool.instructure.com.
Access token setCanvas("token", e.target.value)} placeholder="Paste your Canvas access token" autoComplete="off" /> In Canvas: Account → Settings → Approved Integrations → + New Access Token. Stored only on this computer. Some schools restrict tokens — if yours does, use the .zip export instead.
{canvasTest && !canvasTest.busy && ( {canvasTest.ok ? : }{canvasTest.message} )}
{/* ---- Generation defaults ---- */}

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}
}
); }