bizzle 5a51a0f112 Mr. Drew's Assignment Creator — Docker share build
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>
2026-06-21 19:58:36 -04:00

373 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 &amp; 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&rsquo;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 &ldquo;Test connection&rdquo; 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&rsquo;s Developer tab enable &ldquo;Serve on Local Network&rdquo;. Use &ldquo;Test connection&rdquo; 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.20.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,00024,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>
);
}