Self-contained Dockerized build for end users. Run via docker compose; see README.md for setup. Source-only, no sample data or build artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
373 lines
18 KiB
JavaScript
373 lines
18 KiB
JavaScript
"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 <p className="muted"><span className="spinner" /> Loading…</p>;
|
||
|
||
const active = s.provider;
|
||
const activeCfg = s.providers[active] || {};
|
||
const isLocal = active === "ollama" || active === "lmstudio";
|
||
const modelList = models[active] || [];
|
||
const t = test[active];
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<h1>Settings</h1>
|
||
<p>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 <code>data/db.json</code> file.</p>
|
||
</div>
|
||
|
||
{error && <div className="alert alert-error">{error}</div>}
|
||
|
||
<div className="card">
|
||
<h2>Teacher & school</h2>
|
||
<p className="field-hint" style={{ marginTop: 4 }}>
|
||
Shown in the header of every printed and exported assignment — leave anything blank to omit it.
|
||
</p>
|
||
<div className="row" style={{ marginTop: 14 }}>
|
||
<label className="field">
|
||
<span className="field-label">Teacher name</span>
|
||
<input type="text" value={s.profile?.teacherName || ""}
|
||
onChange={(e) => setProfile("teacherName", e.target.value)}
|
||
placeholder="e.g. Mr. Drew" />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field-label">Class / course</span>
|
||
<input type="text" value={s.profile?.className || ""}
|
||
onChange={(e) => setProfile("className", e.target.value)}
|
||
placeholder="e.g. 7th Grade Science — Period 3" />
|
||
</label>
|
||
<label className="field">
|
||
<span className="field-label">School name</span>
|
||
<input type="text" value={s.profile?.schoolName || ""}
|
||
onChange={(e) => setProfile("schoolName", e.target.value)}
|
||
placeholder="e.g. Lincoln Middle School" />
|
||
</label>
|
||
</div>
|
||
<div className="field">
|
||
<span className="field-label">School logo or mascot (optional)</span>
|
||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||
{s.profile?.logo && (
|
||
<img src={s.profile.logo} alt="School logo preview"
|
||
style={{ height: 52, maxWidth: 140, objectFit: "contain", background: "#fff", border: "1px solid var(--line)", borderRadius: 6, padding: 4 }} />
|
||
)}
|
||
<label className="btn" style={{ cursor: "pointer" }}>
|
||
{s.profile?.logo ? "Replace image" : "Upload image"}
|
||
<input type="file" accept="image/*" onChange={onLogoFile} style={{ display: "none" }} />
|
||
</label>
|
||
{s.profile?.logo && (
|
||
<button className="btn btn-danger" onClick={() => setProfile("logo", "")}>Remove</button>
|
||
)}
|
||
</div>
|
||
<span className="field-hint">Appears beside the school name on printed pages. PNG with transparency looks best; the image is stored locally and shrunk automatically.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<h2>AI provider</h2>
|
||
<div style={{ marginTop: 14 }}>
|
||
{PROVIDERS.map((p) => (
|
||
<label key={p.id} className={"provider-row" + (active === p.id ? " selected" : "")}>
|
||
<input type="radio" name="provider" checked={active === p.id} onChange={() => setS((cur) => ({ ...cur, provider: p.id }))} />
|
||
<span>
|
||
<b>{p.name}</b>
|
||
<small>{p.desc}</small>
|
||
</span>
|
||
{p.local && <span className="chip local-tag">Private · local</span>}
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
<hr className="hr" />
|
||
<h3 style={{ marginBottom: 12 }}>{PROVIDERS.find((p) => p.id === active)?.name} setup</h3>
|
||
|
||
{isLocal && (
|
||
<label className="field">
|
||
<span className="field-label">Server address (base URL)</span>
|
||
<input
|
||
type="text" value={activeCfg.baseUrl || ""}
|
||
onChange={(e) => setProviderField(active, "baseUrl", e.target.value)}
|
||
placeholder={active === "ollama" ? "http://localhost:11434" : "http://localhost:1234"}
|
||
/>
|
||
<span className="field-hint">
|
||
{active === "ollama"
|
||
? <>Where this app should find Ollama. Same computer: the default is right. Ollama on another machine (or this app in Docker on a different box): enter that machine’s address, e.g. <code>http://192.168.1.50:11434</code> — and on that machine set <code>OLLAMA_HOST=0.0.0.0</code> so Ollama accepts network connections. Use “Test connection” below to confirm.</>
|
||
: <>Where this app should find LM Studio. Same computer: the default is right. LM Studio on another machine: enter its address, e.g. <code>http://192.168.1.50:1234</code> — and in LM Studio’s Developer tab enable “Serve on Local Network”. Use “Test connection” below to confirm.</>}
|
||
</span>
|
||
</label>
|
||
)}
|
||
|
||
{!isLocal && (
|
||
<label className="field">
|
||
<span className="field-label">API key</span>
|
||
<input
|
||
type="password" value={activeCfg.apiKey || ""}
|
||
onChange={(e) => setProviderField(active, "apiKey", e.target.value)}
|
||
placeholder="Paste your API key"
|
||
autoComplete="off"
|
||
/>
|
||
<span className="field-hint">
|
||
Get a key at <a href={KEY_LINKS[active]} target="_blank" rel="noreferrer">{KEY_LINKS[active]}</a>. It is stored only on this computer.
|
||
</span>
|
||
</label>
|
||
)}
|
||
|
||
<label className="field">
|
||
<span className="field-label">Model</span>
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||
{modelList.length > 0 ? (
|
||
<select value={activeCfg.model || ""} onChange={(e) => setProviderField(active, "model", e.target.value)} style={{ flex: 1, minWidth: 220 }}>
|
||
{!modelList.includes(activeCfg.model) && activeCfg.model && <option value={activeCfg.model}>{activeCfg.model}</option>}
|
||
{modelList.map((m) => <option key={m} value={m}>{m}</option>)}
|
||
</select>
|
||
) : (
|
||
<input
|
||
type="text" value={activeCfg.model || ""}
|
||
onChange={(e) => setProviderField(active, "model", e.target.value)}
|
||
placeholder={active === "ollama" ? "e.g. llama3.1:8b" : "Model name"}
|
||
style={{ flex: 1, minWidth: 220 }}
|
||
/>
|
||
)}
|
||
<button className="btn" onClick={() => refreshModels(active)} disabled={modelsBusy === active}>
|
||
{modelsBusy === active ? <><span className="spinner" /> Looking…</> : "Refresh models"}
|
||
</button>
|
||
</div>
|
||
{modelsErr[active] && <span className="field-hint" style={{ color: "var(--redpen)" }}>{modelsErr[active]}</span>}
|
||
{!modelsErr[active] && (
|
||
<span className="field-hint">
|
||
Accuracy tip: bigger models write noticeably better questions. Locally, prefer an 8B+ model; in the cloud, the default models work well.
|
||
</span>
|
||
)}
|
||
</label>
|
||
|
||
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
|
||
<button className="btn" onClick={() => testConnection(active)} disabled={t?.busy}>
|
||
{t?.busy ? <><span className="spinner" /> Testing…</> : "Test connection"}
|
||
</button>
|
||
{t && !t.busy && (
|
||
<span className={"small " + (t.ok ? "" : "redpen")} style={t.ok ? { color: "var(--board)", fontWeight: 600 } : { fontWeight: 600 }}>
|
||
{t.ok ? "✓ " : "✕ "}{t.message}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<h2>Generation defaults</h2>
|
||
<label className="check" style={{ marginTop: 14 }}>
|
||
<input type="checkbox" checked={autoOn} onChange={(e) => setGen("auto", e.target.checked)} />
|
||
<span>
|
||
Set size limits automatically (recommended)
|
||
<small>Matches source size and response length to the selected model's real context window — local models get safe limits, large cloud models get room for much longer sources and answers.</small>
|
||
</span>
|
||
</label>
|
||
{autoOn && (
|
||
<p className="field-hint" style={{ margin: "0 0 4px 30px" }}>
|
||
{!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" : ""}).`
|
||
: <><span className="spinner" /> Checking the model's limits…</>}
|
||
</p>
|
||
)}
|
||
<div className="row" style={{ marginTop: 14 }}>
|
||
<label className="field">
|
||
<span className="field-label">Temperature — {Number(s.generation.temperature).toFixed(1)}</span>
|
||
<input
|
||
type="range" min="0" max="1" step="0.1" value={s.generation.temperature}
|
||
onChange={(e) => setGen("temperature", Number(e.target.value))}
|
||
style={{ width: "100%", accentColor: "var(--board)" }}
|
||
/>
|
||
<span className="field-hint">Lower = more precise and literal (best for accuracy). 0.2–0.4 recommended.</span>
|
||
</label>
|
||
{!autoOn && (
|
||
<label className="field">
|
||
<span className="field-label">Max response length (tokens)</span>
|
||
<input type="number" min="1000" max="64000" step="500" value={s.generation.maxTokens}
|
||
onChange={(e) => setGen("maxTokens", Math.max(1000, Number(e.target.value) || 8000))} />
|
||
<span className="field-hint">Raise this if very long assignments come back cut off. Capped to the model's own output limit.</span>
|
||
</label>
|
||
)}
|
||
{!autoOn && (
|
||
<label className="field">
|
||
<span className="field-label">Max source size (characters)</span>
|
||
<input type="number" min="4000" max="300000" step="1000" value={s.generation.maxSourceChars}
|
||
onChange={(e) => setGen("maxSourceChars", Math.max(4000, Number(e.target.value) || 24000))} />
|
||
<span className="field-hint">Longer sources are trimmed to this before being sent to the model. Local models with small context windows do better around 16,000–24,000.</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
<label className="check">
|
||
<input type="checkbox" checked={s.generation.verification !== false} onChange={(e) => setGen("verification", e.target.checked)} />
|
||
<span>Run the accuracy check by default<small>A second pass that verifies every answer against the source. You can still toggle it per assignment.</small></span>
|
||
</label>
|
||
</div>
|
||
|
||
<div style={{ display: "flex", marginTop: 18 }}>
|
||
<span className="spacer" />
|
||
<button className="btn btn-primary btn-lg" onClick={save} disabled={saving}>
|
||
{saving ? "Saving…" : "Save settings"}
|
||
</button>
|
||
</div>
|
||
|
||
{toast && <div className="toast">{toast}</div>}
|
||
</div>
|
||
);
|
||
}
|