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>
This commit is contained in:
2026-06-21 19:58:36 -04:00
co-authored by Claude Opus 4.8
commit 5a51a0f112
33 changed files with 5413 additions and 0 deletions
+372
View File
@@ -0,0 +1,372 @@
"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>
);
}