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:
commit
5a51a0f112
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
data
|
||||
docker
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.png
|
||||
*.pdf
|
||||
*.log
|
||||
README.md
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
data/db.json
|
||||
.DS_Store
|
||||
*.pdf
|
||||
*.png
|
||||
*.log
|
||||
113
README.md
Normal file
113
README.md
Normal file
@ -0,0 +1,113 @@
|
||||
# ✎ Mr. Drew's Assignment Creator
|
||||
|
||||
A local, private, accuracy-first generator for **quizzes, tests, worksheets, discussion questions, and case studies** — for any grade level from kindergarten to college.
|
||||
|
||||
Everything runs in a single Docker container on your own machine. With a local AI (Ollama or LM Studio), nothing you type ever leaves your computer.
|
||||
|
||||
---
|
||||
|
||||
## Why it's accurate
|
||||
|
||||
Most AI assignment tools fire off one giant "make me a quiz" request and hope for the best. This app runs a **three-stage pipeline** instead:
|
||||
|
||||
1. **Analyze** — the AI reads your source material and maps its key concepts, facts, and vocabulary.
|
||||
2. **Generate** — questions are written *strictly from your source*. The AI is forbidden from adding outside facts, and every question carries a `Source:` quote proving where its answer comes from.
|
||||
3. **Verify** — a second, skeptical AI pass re-checks every question and answer against the source. Each is stamped **✓ Verified**, or flagged **⚠ Check this** with a note telling you exactly what to look at.
|
||||
|
||||
You stay in control: every question can be edited, regenerated with a steering note ("make it harder", "focus on causes"), or replaced — and you can re-run the accuracy check any time.
|
||||
|
||||
---
|
||||
|
||||
## What you need
|
||||
|
||||
- **Docker** — [Docker Desktop](https://www.docker.com/products/docker-desktop/) on macOS/Windows, or Docker Engine on Linux. That's the only thing you install.
|
||||
- **One AI provider** (pick any one):
|
||||
- **Ollama** (free, private, local) — <https://ollama.com>
|
||||
- **LM Studio** (free, private, local) — <https://lmstudio.ai>
|
||||
- An **Anthropic**, **OpenAI**, or **Google AI** API key (cloud, pay-per-use) — paste it on the Settings page, no networking setup needed.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### macOS / Windows (Docker Desktop)
|
||||
|
||||
```sh
|
||||
git clone https://git.bizzle.lol/bizzle/mr-drews-assignment-creator.git
|
||||
cd mr-drews-assignment-creator/docker
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open **<http://localhost:3000>**. Done.
|
||||
|
||||
The container automatically reaches the Ollama / LM Studio running on your machine (via `host.docker.internal`). Just make sure one of them is running:
|
||||
- **Ollama** — have the Ollama app open (or run `ollama serve`).
|
||||
- **LM Studio** — open the Developer tab and start the local server.
|
||||
|
||||
### Linux
|
||||
|
||||
```sh
|
||||
git clone https://git.bizzle.lol/bizzle/mr-drews-assignment-creator.git
|
||||
cd mr-drews-assignment-creator/docker
|
||||
docker compose -f docker-compose.linux.yml up -d
|
||||
```
|
||||
|
||||
Open **<http://localhost:3000>**. This variant uses host networking, so the container sees Ollama / LM Studio on plain `localhost` — no extra setup, even if Ollama is bound to `127.0.0.1` (its default).
|
||||
|
||||
> The first `docker compose up` builds the image from source — expect it to take a couple of minutes. Subsequent starts are instant.
|
||||
|
||||
---
|
||||
|
||||
## First run: pick your AI
|
||||
|
||||
1. Open <http://localhost:3000> and go to **Settings**.
|
||||
2. Choose a provider:
|
||||
- **Ollama / LM Studio** — confirm the server address, then click **Test connection**.
|
||||
- **Anthropic / OpenAI / Google** — paste your API key.
|
||||
3. Save. Head back to the home page and generate your first assignment.
|
||||
|
||||
---
|
||||
|
||||
## Where is my data?
|
||||
|
||||
Everything — assignments, settings, API keys, your school logo — lives in a single JSON file inside the `assignment-data` Docker volume. It survives restarts, rebuilds, and image upgrades.
|
||||
|
||||
Back it up any time:
|
||||
|
||||
```sh
|
||||
cd docker
|
||||
docker compose cp assignment-creator:/app/data/db.json ./db-backup.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ollama / LM Studio on a *different* machine
|
||||
|
||||
Common setup: the app runs in Docker on a server or NAS, while Ollama runs on your desktop with the GPU. Two steps:
|
||||
|
||||
1. **On the machine running the LLM**, allow network connections:
|
||||
- **Ollama** — set `OLLAMA_HOST=0.0.0.0` (Ollama app → Settings → "Expose Ollama to the network", or the env var) and restart Ollama.
|
||||
- **LM Studio** — Developer tab → server settings → enable **"Serve on Local Network"**.
|
||||
2. **In the app**, open Settings, select Ollama or LM Studio, and enter that machine's address in **"Server address (base URL)"** — e.g. `http://192.168.1.50:11434`. Click **Test connection**, then Save.
|
||||
|
||||
The saved address always wins over the compose defaults. You can also pre-set defaults for fresh installs via the `OLLAMA_BASE_URL` / `LMSTUDIO_BASE_URL` environment variables in `docker/docker-compose.yml`.
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
```sh
|
||||
cd docker
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Your data volume is untouched by rebuilds.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Could not reach Ollama" on macOS/Windows** — confirm Ollama is running on your machine (`ollama list` in a terminal). The default compose file already points the app at your machine, not at the container.
|
||||
- **"Could not reach Ollama" on Linux with the default compose file** — use `docker-compose.linux.yml` instead, or set `OLLAMA_HOST=0.0.0.0` so Ollama accepts connections from containers.
|
||||
- **Port 3000 already in use** — change the first number in `ports:` in `docker/docker-compose.yml` (e.g. `"8080:3000"`) and open <http://localhost:8080>.
|
||||
- **Stop the app** — `cd docker && docker compose down` (your data volume is kept).
|
||||
27
app/api/assignments/[id]/route.js
Normal file
27
app/api/assignments/[id]/route.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAssignment, updateAssignment, deleteAssignment } from "@/lib/store";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
const a = getAssignment(params.id);
|
||||
if (!a) return NextResponse.json({ error: "Assignment not found." }, { status: 404 });
|
||||
return NextResponse.json(a);
|
||||
}
|
||||
|
||||
export async function PUT(request, { params }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const updated = updateAssignment(params.id, body);
|
||||
if (!updated) return NextResponse.json({ error: "Assignment not found." }, { status: 404 });
|
||||
return NextResponse.json(updated);
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request, { params }) {
|
||||
const ok = deleteAssignment(params.id);
|
||||
if (!ok) return NextResponse.json({ error: "Assignment not found." }, { status: 404 });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
19
app/api/assignments/route.js
Normal file
19
app/api/assignments/route.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { listAssignments, createAssignment } from "@/lib/store";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ assignments: listAssignments() });
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
if (!body || typeof body !== "object") throw new Error("Missing assignment body.");
|
||||
const record = createAssignment(body);
|
||||
return NextResponse.json(record, { status: 201 });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
44
app/api/fetch-url/route.js
Normal file
44
app/api/fetch-url/route.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { htmlToText, extractTitle } from "@/lib/html-to-text";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { url } = await request.json();
|
||||
let target;
|
||||
try {
|
||||
target = new URL(String(url || "").trim());
|
||||
} catch {
|
||||
throw new Error("That doesn't look like a valid URL. Include the full address, e.g. https://example.com/article");
|
||||
}
|
||||
if (!/^https?:$/.test(target.protocol)) throw new Error("Only http and https URLs are supported.");
|
||||
|
||||
const res = await fetch(target.toString(), {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; MrDrewsAssignmentCreator/1.0)",
|
||||
"Accept": "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8",
|
||||
},
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`The page returned an error (HTTP ${res.status}). It may be behind a login or blocking automated access.`);
|
||||
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
const raw = await res.text();
|
||||
let text;
|
||||
if (contentType.includes("text/plain")) {
|
||||
text = raw;
|
||||
} else {
|
||||
text = htmlToText(raw);
|
||||
}
|
||||
text = text.slice(0, 200000);
|
||||
if (text.trim().length < 200) {
|
||||
throw new Error("Very little readable text was found on that page. It may be mostly images or load its content with JavaScript. Try copying the text and pasting it instead.");
|
||||
}
|
||||
return NextResponse.json({ title: extractTitle(raw), text, chars: text.length });
|
||||
} catch (e) {
|
||||
const msg = e?.name === "TimeoutError" ? "Timed out fetching that page (20s)." : String(e.message || e);
|
||||
return NextResponse.json({ error: msg }, { status: 400 });
|
||||
}
|
||||
}
|
||||
99
app/api/generate/route.js
Normal file
99
app/api/generate/route.js
Normal file
@ -0,0 +1,99 @@
|
||||
// app/api/generate/route.js — the generation pipeline.
|
||||
// The client calls this once per stage so the progress UI is honest:
|
||||
// stage "analyze" -> content map of the source
|
||||
// stage "generate" -> full assignment (with one automatic JSON-repair retry)
|
||||
// stage "verify" -> per-question accuracy verdicts
|
||||
// stage "question" -> regenerate one question / add a new one
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/store";
|
||||
import { chat } from "@/lib/providers";
|
||||
import { resolveGeneration } from "@/lib/model-caps";
|
||||
import { extractJson } from "@/lib/json-utils";
|
||||
import { analyzePrompt, generatePrompt, verifyPrompt, questionPrompt } from "@/lib/prompts";
|
||||
import { normalizeAssignment, normalizeQuestion } from "@/lib/schema";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 600;
|
||||
|
||||
async function chatJson(settings, prompt, opts = {}) {
|
||||
const raw = await chat(settings, { ...prompt, expectJson: true, ...opts });
|
||||
try {
|
||||
return extractJson(raw);
|
||||
} catch (firstErr) {
|
||||
// One repair attempt: ask the same model to re-emit valid JSON.
|
||||
const fixed = await chat(settings, {
|
||||
system: "You convert text into strictly valid JSON. Output ONLY the corrected JSON with no commentary and no code fences.",
|
||||
user: "The following was supposed to be a single valid JSON object but is malformed. Re-emit it as strictly valid JSON, preserving all content:\n\n" + String(raw).slice(0, 60000),
|
||||
expectJson: true,
|
||||
temperature: 0,
|
||||
});
|
||||
try {
|
||||
return extractJson(fixed);
|
||||
} catch {
|
||||
throw firstErr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { stage, source, config } = body;
|
||||
const settings = getSettings();
|
||||
// Auto mode sizes the source budget to the selected model's context window.
|
||||
const { maxSourceChars } = await resolveGeneration(settings);
|
||||
|
||||
if (!source || String(source).trim().length < 100) {
|
||||
throw new Error("The source material is too short to build a quality assignment from (minimum ~100 characters).");
|
||||
}
|
||||
|
||||
if (stage === "analyze") {
|
||||
const prompt = analyzePrompt({ source, config, maxSourceChars });
|
||||
const analysis = await chatJson(settings, prompt, { maxTokens: 2500 });
|
||||
return NextResponse.json({ analysis });
|
||||
}
|
||||
|
||||
if (stage === "generate") {
|
||||
const prompt = generatePrompt({ source, analysis: body.analysis, config, maxSourceChars });
|
||||
const raw = await chatJson(settings, prompt);
|
||||
const assignment = normalizeAssignment(raw, config);
|
||||
if (!assignment.questions.length) {
|
||||
throw new Error("The model did not return any usable questions. Try again, or switch to a stronger model in Settings.");
|
||||
}
|
||||
return NextResponse.json({ assignment });
|
||||
}
|
||||
|
||||
if (stage === "verify") {
|
||||
const questions = body.questions || [];
|
||||
if (!questions.length) throw new Error("No questions to verify.");
|
||||
const prompt = verifyPrompt({ source, questions, config, maxSourceChars });
|
||||
const raw = await chatJson(settings, prompt, { temperature: 0.1 });
|
||||
const results = Array.isArray(raw?.results) ? raw.results : [];
|
||||
const byId = {};
|
||||
for (const r of results) {
|
||||
if (!r || !r.id) continue;
|
||||
const verdict = r.verdict === "pass" ? "pass" : "warn";
|
||||
const note = [r.issue, r.suggestedFix ? "Suggested fix: " + r.suggestedFix : ""].filter(Boolean).join(" ").trim();
|
||||
byId[r.id] = { status: verdict, note: verdict === "pass" ? "" : (note || "The reviewer flagged this question — double-check it.") };
|
||||
}
|
||||
return NextResponse.json({ verifications: byId });
|
||||
}
|
||||
|
||||
if (stage === "question") {
|
||||
const { type, note, existingQuestions, replacing } = body;
|
||||
const prompt = questionPrompt({ source, config, existingQuestions, type, note, replacing, maxSourceChars });
|
||||
const raw = await chatJson(settings, prompt, { maxTokens: 2500 });
|
||||
let candidate = raw;
|
||||
if (Array.isArray(raw)) candidate = raw[0];
|
||||
else if (Array.isArray(raw?.questions) && raw.questions.length) candidate = raw.questions[0];
|
||||
else if (raw?.question && typeof raw.question === "object") candidate = raw.question;
|
||||
const question = normalizeQuestion(candidate);
|
||||
if (!question) throw new Error("The model returned a question in an unexpected shape. Try again.");
|
||||
return NextResponse.json({ question });
|
||||
}
|
||||
|
||||
throw new Error("Unknown stage: " + stage);
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
31
app/api/providers/route.js
Normal file
31
app/api/providers/route.js
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { mergeSettings } from "@/lib/store";
|
||||
import { listModels, testConnection } from "@/lib/providers";
|
||||
import { resolveGeneration } from "@/lib/model-caps";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// POST { action: "models" | "test" | "defaults", provider, settings }
|
||||
// Settings come from the client form so you can test before saving.
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { action, provider, settings } = await request.json();
|
||||
const merged = mergeSettings(settings);
|
||||
if (action === "models") {
|
||||
const models = await listModels(merged, provider);
|
||||
return NextResponse.json({ models });
|
||||
}
|
||||
if (action === "test") {
|
||||
const result = await testConnection(merged, provider);
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
if (action === "defaults") {
|
||||
const probe = provider ? { ...merged, provider } : merged;
|
||||
const resolved = await resolveGeneration(probe);
|
||||
return NextResponse.json(resolved);
|
||||
}
|
||||
throw new Error("Unknown action.");
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
18
app/api/settings/route.js
Normal file
18
app/api/settings/route.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings, saveSettings } from "@/lib/store";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(getSettings());
|
||||
}
|
||||
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const saved = saveSettings(body);
|
||||
return NextResponse.json(saved);
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: String(e.message || e) }, { status: 400 });
|
||||
}
|
||||
}
|
||||
367
app/editor/[id]/page.jsx
Normal file
367
app/editor/[id]/page.jsx
Normal file
@ -0,0 +1,367 @@
|
||||
"use client";
|
||||
// app/editor/[id]/page.jsx — review and refine an assignment, then export it.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import QuestionCard from "@/components/QuestionCard";
|
||||
import { QUESTION_TYPES, blankQuestion, totalPoints } from "@/lib/schema";
|
||||
import { exportTxt, exportDoc, exportClipboard, exportPrint } from "@/lib/exporter";
|
||||
|
||||
export default function EditorPage() {
|
||||
const { id } = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [a, setA] = useState(null);
|
||||
const [loadErr, setLoadErr] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState("");
|
||||
const [busyQ, setBusyQ] = useState(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [profile, setProfile] = useState({});
|
||||
const toastTimer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/assignments/" + id)
|
||||
.then(async (r) => {
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || "Could not load this assignment.");
|
||||
setA(data);
|
||||
})
|
||||
.catch((e) => setLoadErr(String(e.message || e)));
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((s) => setProfile(s?.profile || {}))
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
function onBeforeUnload(e) {
|
||||
if (dirty) { e.preventDefault(); e.returnValue = ""; }
|
||||
}
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [dirty]);
|
||||
|
||||
function showToast(msg) {
|
||||
setToast(msg);
|
||||
clearTimeout(toastTimer.current);
|
||||
toastTimer.current = setTimeout(() => setToast(""), 2400);
|
||||
}
|
||||
|
||||
function patch(p) {
|
||||
setA((cur) => ({ ...cur, ...p }));
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function setQuestion(i, q) {
|
||||
setA((cur) => {
|
||||
const questions = [...cur.questions];
|
||||
questions[i] = q;
|
||||
return { ...cur, questions };
|
||||
});
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function moveQuestion(i, dir) {
|
||||
setA((cur) => {
|
||||
const questions = [...cur.questions];
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= questions.length) return cur;
|
||||
[questions[i], questions[j]] = [questions[j], questions[i]];
|
||||
return { ...cur, questions };
|
||||
});
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function deleteQuestion(i) {
|
||||
if (!confirm("Delete question " + (i + 1) + "?")) return;
|
||||
setA((cur) => ({ ...cur, questions: cur.questions.filter((_, j) => j !== i) }));
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
async function save(silent) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/assignments/" + id, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(a),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Save failed.");
|
||||
setA(data);
|
||||
setDirty(false);
|
||||
if (!silent) showToast("Saved");
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateQuestion(i, note) {
|
||||
const q = a.questions[i];
|
||||
setBusyQ(q.id);
|
||||
setError("");
|
||||
try {
|
||||
const data = await postJson("/api/generate", {
|
||||
stage: "question",
|
||||
source: a.source?.text || "",
|
||||
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
|
||||
type: q.type,
|
||||
note,
|
||||
replacing: { question: q.question },
|
||||
existingQuestions: a.questions.filter((_, j) => j !== i).map((x) => ({ question: x.question })),
|
||||
});
|
||||
const next = { ...data.question, points: q.points };
|
||||
setQuestion(i, next);
|
||||
showToast("Question " + (i + 1) + " regenerated");
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
} finally {
|
||||
setBusyQ(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function addQuestion(type, withAI) {
|
||||
setAddOpen(false);
|
||||
if (!withAI) {
|
||||
setA((cur) => ({ ...cur, questions: [...cur.questions, blankQuestion(type)] }));
|
||||
setDirty(true);
|
||||
return;
|
||||
}
|
||||
setBusyQ("__new__");
|
||||
setError("");
|
||||
try {
|
||||
const data = await postJson("/api/generate", {
|
||||
stage: "question",
|
||||
source: a.source?.text || "",
|
||||
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
|
||||
type,
|
||||
existingQuestions: a.questions.map((x) => ({ question: x.question })),
|
||||
});
|
||||
setA((cur) => ({ ...cur, questions: [...cur.questions, data.question] }));
|
||||
setDirty(true);
|
||||
showToast("Question added");
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
} finally {
|
||||
setBusyQ(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function reverify() {
|
||||
setVerifying(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await postJson("/api/generate", {
|
||||
stage: "verify",
|
||||
source: a.source?.text || "",
|
||||
config: a.config || { assignmentType: a.assignmentType, gradeLevel: a.gradeLevel, subject: a.subject, difficulty: a.difficulty },
|
||||
questions: a.questions,
|
||||
});
|
||||
setA((cur) => ({
|
||||
...cur,
|
||||
questions: cur.questions.map((q) =>
|
||||
data.verifications[q.id]
|
||||
? { ...q, verification: data.verifications[q.id] }
|
||||
: { ...q, verification: { status: "unchecked", note: "" } }
|
||||
),
|
||||
}));
|
||||
setDirty(true);
|
||||
const warns = Object.values(data.verifications).filter((v) => v.status === "warn").length;
|
||||
showToast(warns
|
||||
? `Accuracy check done — ${warns} question${warns === 1 ? "" : "s"} flagged`
|
||||
: "Accuracy check done — all clear"
|
||||
);
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
}
|
||||
|
||||
function doExport(kind, who) {
|
||||
setExportOpen(false);
|
||||
const opts = who === "packet" ? { packet: true, profile } : { teacher: who === true, profile };
|
||||
try {
|
||||
if (kind === "txt") { exportTxt(a, opts); showToast("Downloaded .txt"); }
|
||||
if (kind === "doc") { exportDoc(a, opts); showToast("Downloaded Word file"); }
|
||||
if (kind === "print") { exportPrint(a, opts); }
|
||||
if (kind === "copy") { exportClipboard(a, opts).then(() => showToast("Copied to clipboard")); }
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
if (loadErr) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<h3>Couldn’t open that assignment</h3>
|
||||
<p>{loadErr}</p>
|
||||
<button className="btn btn-primary" style={{ marginTop: 12 }} onClick={() => router.push("/library")}>Go to Library</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!a) {
|
||||
return (
|
||||
<div style={{ padding: "40px 0" }}>
|
||||
<div className="card skeleton-card" style={{ marginBottom: 16 }}>
|
||||
<div className="skeleton-line" style={{ width: "60%", height: 28, borderRadius: 6, marginBottom: 12 }} />
|
||||
<div className="skeleton-line short" />
|
||||
</div>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="card skeleton-card" style={{ marginBottom: 14, animationDelay: `${i * 0.15}s` }}>
|
||||
<div className="skeleton-chip" />
|
||||
<div className="skeleton-line full" />
|
||||
<div className="skeleton-line medium" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const warnCount = a.questions.filter((q) => q.verification?.status === "warn").length;
|
||||
const uncheckedCount = a.questions.filter((q) => !q.verification || q.verification.status === "unchecked").length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head" style={{ display: "flex", alignItems: "flex-start", gap: 12, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: 1, minWidth: 260 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={a.title}
|
||||
onChange={(e) => patch({ title: e.target.value })}
|
||||
aria-label="Assignment title"
|
||||
style={{
|
||||
fontFamily: "var(--font-display)", fontSize: "1.6rem", fontWeight: 700,
|
||||
border: "1.5px solid transparent", background: "transparent",
|
||||
padding: "4px 8px", marginLeft: -8, borderRadius: 8, width: "100%",
|
||||
transition: "border-color 0.15s, background 0.15s",
|
||||
}}
|
||||
onFocus={(e) => { e.target.style.borderColor = "var(--line-strong)"; e.target.style.background = "var(--field-bg)"; }}
|
||||
onBlur={(e) => { e.target.style.borderColor = "transparent"; e.target.style.background = "transparent"; }}
|
||||
/>
|
||||
<p className="muted small" style={{ margin: "4px 0 0 2px" }}>
|
||||
{[a.assignmentType?.replace("_", " "), a.gradeLevel, a.subject].filter(Boolean).join(" · ")} · {a.questions.length} questions · {totalPoints(a.questions)} points
|
||||
{a.source?.name ? <> · from <i>{a.source.name}</i></> : null}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button className="btn" onClick={() => { setExportOpen((o) => !o); setAddOpen(false); }}>Export ▾</button>
|
||||
{exportOpen && (
|
||||
<div className="card" style={{ position: "absolute", right: 0, top: "calc(100% + 6px)", zIndex: 30, width: 295, padding: 16, animation: "fade-in-up 0.18s ease" }}>
|
||||
<div className="field-label">Student version</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
|
||||
<button className="btn btn-sm" onClick={() => doExport("print", false)}>Print / PDF</button>
|
||||
<button className="btn btn-sm" onClick={() => doExport("doc", false)}>Word</button>
|
||||
<button className="btn btn-sm" onClick={() => doExport("txt", false)}>Text</button>
|
||||
<button className="btn btn-sm" onClick={() => doExport("copy", false)}>Copy</button>
|
||||
</div>
|
||||
<div className="field-label" style={{ color: "var(--redpen)" }}>Teacher version (answer key)</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 12 }}>
|
||||
<button className="btn btn-sm" onClick={() => doExport("print", true)}>Print / PDF</button>
|
||||
<button className="btn btn-sm" onClick={() => doExport("doc", true)}>Word</button>
|
||||
<button className="btn btn-sm" onClick={() => doExport("txt", true)}>Text</button>
|
||||
<button className="btn btn-sm" onClick={() => doExport("copy", true)}>Copy</button>
|
||||
</div>
|
||||
<div className="field-label">Complete packet — student + answer key</div>
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
||||
<button className="btn btn-sm" onClick={() => doExport("print", "packet")}>Print / PDF</button>
|
||||
<button className="btn btn-sm" onClick={() => doExport("doc", "packet")}>Word</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn" onClick={reverify} disabled={verifying || !a.source?.text}>
|
||||
{verifying ? <><span className="spinner" /> Checking…</> : "Re-run accuracy check"}
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => save(false)} disabled={saving || !dirty}>
|
||||
{saving ? <><span className="spinner" /> Saving…</> : dirty ? "Save" : "Saved ✓"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{warnCount > 0 && (
|
||||
<div className="alert alert-warn">
|
||||
<b>{warnCount} question{warnCount === 1 ? "" : "s"} flagged by the accuracy check.</b> Look for the ⚠ stamps below — each has a reviewer note. Edit or regenerate those questions, then re-run the check.
|
||||
</div>
|
||||
)}
|
||||
{warnCount === 0 && uncheckedCount === 0 && a.questions.length > 0 && (
|
||||
<div className="alert alert-info">✓ Every question passed the accuracy check against your source.</div>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<span className="field-label">Student instructions</span>
|
||||
<textarea rows={2} value={a.instructions || ""} onChange={(e) => patch({ instructions: e.target.value })} placeholder="Instructions students see at the top…" />
|
||||
</label>
|
||||
|
||||
{a.caseStudy ? (
|
||||
<label className="field">
|
||||
<span className="field-label">Case study scenario (students read this first)</span>
|
||||
<textarea rows={8} value={a.caseStudy} onChange={(e) => patch({ caseStudy: e.target.value })} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{a.questions.map((q, i) => (
|
||||
<QuestionCard
|
||||
key={q.id}
|
||||
q={q}
|
||||
index={i}
|
||||
count={a.questions.length}
|
||||
busy={busyQ === q.id}
|
||||
onChange={(next) => setQuestion(i, next)}
|
||||
onMove={(dir) => moveQuestion(i, dir)}
|
||||
onDelete={() => deleteQuestion(i)}
|
||||
onRegenerate={(note) => regenerateQuestion(i, note)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div style={{ marginTop: 18, position: "relative", display: "flex", gap: 10 }}>
|
||||
<button className="btn" onClick={() => { setAddOpen((o) => !o); setExportOpen(false); }} disabled={busyQ === "__new__"}>
|
||||
{busyQ === "__new__" ? <><span className="spinner" /> Writing question…</> : "+ Add question ▾"}
|
||||
</button>
|
||||
{addOpen && (
|
||||
<div className="card" style={{ position: "absolute", left: 0, bottom: "calc(100% + 6px)", zIndex: 30, width: 324, padding: 16, animation: "fade-in-up 0.18s ease" }}>
|
||||
{[...QUESTION_TYPES, { id: "discussion", label: "Discussion prompt" }].map((t) => (
|
||||
<div key={t.id} style={{ display: "flex", alignItems: "center", gap: 8, padding: "5px 0", borderBottom: "1px solid var(--line)" }}>
|
||||
<span style={{ flex: 1, fontSize: "0.92rem", fontWeight: 600 }}>{t.label}</span>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => addQuestion(t.id, true)}
|
||||
disabled={!a.source?.text}
|
||||
title={a.source?.text ? "Generate from your source" : "No source stored with this assignment"}
|
||||
>AI</button>
|
||||
<button className="btn btn-sm" onClick={() => addQuestion(t.id, false)}>Blank</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<span className="spacer" />
|
||||
<span className="muted small" style={{ alignSelf: "center" }}>
|
||||
Total: <b>{totalPoints(a.questions)}</b> points
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
|
||||
return data;
|
||||
}
|
||||
537
app/globals.css
Normal file
537
app/globals.css
Normal file
@ -0,0 +1,537 @@
|
||||
/* Google Fonts — must come before the Tailwind import */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@600;700&display=swap");
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* =============================================================================
|
||||
Mr. Drew's Assignment Creator — design system
|
||||
Identity: a well-kept teacher's desk. Lora serif headings, chalkboard
|
||||
green for primary actions, and the signature: everything answer-key wears
|
||||
red pen — the color teachers actually grade in.
|
||||
============================================================================= */
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* palette */
|
||||
--paper: #f0f4f1;
|
||||
--panel: #ffffff;
|
||||
--ink: #1e2d28;
|
||||
--ink-soft: #58706a;
|
||||
--board: #2f6b58;
|
||||
--board-deep: #245546;
|
||||
--board-tint: #e4eeea;
|
||||
--board-glow: rgba(47, 107, 88, 0.12);
|
||||
--redpen: #b8412f;
|
||||
--redpen-tint: #faece9;
|
||||
--gold: #b98a23;
|
||||
--gold-tint: #faf3e2;
|
||||
--line: #dde4df;
|
||||
--line-strong: #c5d0cb;
|
||||
--field-bg: #ffffff;
|
||||
--hover-bg: #f2f6f4;
|
||||
--tab-track: #e6ecea;
|
||||
--chip-neutral-bg: #eaeeec;
|
||||
--empty-bg: #fafcfb;
|
||||
--shadow-sm: 0 1px 3px rgba(34, 49, 44, 0.07), 0 2px 8px rgba(34, 49, 44, 0.05);
|
||||
--shadow: 0 2px 6px rgba(34, 49, 44, 0.06), 0 6px 20px rgba(34, 49, 44, 0.07);
|
||||
--shadow-lg: 0 8px 32px rgba(34, 49, 44, 0.12), 0 2px 8px rgba(34, 49, 44, 0.06);
|
||||
color-scheme: light;
|
||||
|
||||
/* type */
|
||||
--font-display: "Lora", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "Cascadia Mono", Consolas, Menlo, monospace;
|
||||
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-xs: 5px;
|
||||
--transition: 0.18s ease;
|
||||
--transition-fast: 0.1s ease;
|
||||
}
|
||||
|
||||
/* The same desk after dark */
|
||||
html[data-theme="dark"] {
|
||||
--paper: #111815;
|
||||
--panel: #1b2320;
|
||||
--ink: #e2eae5;
|
||||
--ink-soft: #92aaa2;
|
||||
--board: #4d9c82;
|
||||
--board-deep: #7bc0a8;
|
||||
--board-tint: #1e3028;
|
||||
--board-glow: rgba(77, 156, 130, 0.15);
|
||||
--redpen: #e07a63;
|
||||
--redpen-tint: #38221e;
|
||||
--gold: #d3a94c;
|
||||
--gold-tint: #342d1a;
|
||||
--line: #263028;
|
||||
--line-strong: #374440;
|
||||
--field-bg: #151c18;
|
||||
--hover-bg: #1f2a26;
|
||||
--tab-track: #161d1a;
|
||||
--chip-neutral-bg: #262f2b;
|
||||
--empty-bg: #171e1a;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
--shadow: 0 2px 6px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .alert-error { border-color: #5a2f26; color: #f0a795; }
|
||||
html[data-theme="dark"] .alert-warn { border-color: #564820; color: #e2c47e; }
|
||||
html[data-theme="dark"] .alert-info { border-color: #2d5040; color: #8dcbb5; }
|
||||
html[data-theme="dark"] .toast { background: #e2eae5; color: #111815; }
|
||||
html[data-theme="dark"] .brand-mark { color: #f1f5f2; }
|
||||
html[data-theme="dark"] .nav-scrolled { box-shadow: 0 4px 24px rgba(0,0,0,0.5); }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15.5px;
|
||||
line-height: 1.58;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h1 { font-size: 1.85rem; }
|
||||
h2 { font-size: 1.3rem; }
|
||||
h3 { font-size: 1.08rem; }
|
||||
|
||||
a { color: var(--board); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--board);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation: none !important; transition: none !important; }
|
||||
}
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
KEYFRAME ANIMATIONS
|
||||
===================================================================== */
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translate(-50%, 12px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
@keyframes step-complete {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes progress-fill {
|
||||
from { width: 0%; }
|
||||
to { width: 100%; }
|
||||
}
|
||||
|
||||
@keyframes spin-ring {
|
||||
0% { transform: rotate(0deg); stroke-dashoffset: 60; }
|
||||
50% { stroke-dashoffset: 15; }
|
||||
100% { transform: rotate(360deg); stroke-dashoffset: 60; }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
LAYOUT SHELL
|
||||
===================================================================== */
|
||||
|
||||
@layer components {
|
||||
.shell { max-width: 1020px; margin: 0 auto; padding: 32px 22px 90px; }
|
||||
|
||||
/* ---------- navigation ---------- */
|
||||
.topnav {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
transition: box-shadow var(--transition);
|
||||
}
|
||||
html[data-theme="dark"] .topnav {
|
||||
background: rgba(27, 35, 32, 0.85);
|
||||
}
|
||||
.topnav.nav-scrolled {
|
||||
box-shadow: 0 4px 24px rgba(34, 49, 44, 0.1);
|
||||
border-bottom-color: var(--line-strong);
|
||||
}
|
||||
.topnav-inner {
|
||||
max-width: 1020px; margin: 0 auto; padding: 0 22px;
|
||||
display: flex; align-items: center; gap: 24px; height: 60px;
|
||||
}
|
||||
.brand {
|
||||
font-family: var(--font-display); font-size: 1.08rem; font-weight: 700; color: var(--ink);
|
||||
display: flex; align-items: center; gap: 10px; white-space: nowrap;
|
||||
}
|
||||
.brand:hover { text-decoration: none; }
|
||||
.brand-mark {
|
||||
width: 30px; height: 30px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--board) 0%, var(--board-deep) 100%);
|
||||
color: #fff;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 15px; flex: none;
|
||||
box-shadow: 0 2px 6px rgba(47, 107, 88, 0.35);
|
||||
}
|
||||
.navlinks { display: flex; gap: 2px; margin-left: auto; }
|
||||
.theme-toggle {
|
||||
flex: none; width: 36px; height: 36px; border-radius: 9px;
|
||||
font-size: 1rem; transition: background var(--transition), transform var(--transition-fast);
|
||||
}
|
||||
.theme-toggle:hover { transform: rotate(18deg); }
|
||||
.navlink {
|
||||
padding: 7px 14px; border-radius: var(--radius-sm); color: var(--ink-soft);
|
||||
font-weight: 500; font-size: 0.93rem; transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.navlink:hover { background: var(--hover-bg); color: var(--ink); text-decoration: none; }
|
||||
.navlink.active { background: var(--board-tint); color: var(--board-deep); font-weight: 600; }
|
||||
|
||||
/* ---------- cards & panels ---------- */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 24px;
|
||||
transition: box-shadow var(--transition), border-color var(--transition), transform 0.2s ease;
|
||||
animation: fade-in-up 0.3s ease both;
|
||||
}
|
||||
.card + .card { margin-top: 16px; }
|
||||
.card:hover { box-shadow: var(--shadow); }
|
||||
|
||||
.card-lift:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.page-head { margin: 4px 0 24px; }
|
||||
.page-head p { color: var(--ink-soft); margin: 8px 0 0; max-width: 62ch; font-size: 0.97rem; }
|
||||
|
||||
/* ---------- buttons ---------- */
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--line-strong);
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font: inherit; font-family: var(--font-body); font-weight: 600; font-size: 0.92rem;
|
||||
padding: 9px 17px; border-radius: var(--radius-sm); cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition), transform var(--transition-fast);
|
||||
white-space: nowrap; user-select: none;
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--board);
|
||||
box-shadow: 0 1px 4px var(--board-glow);
|
||||
}
|
||||
.btn:active { transform: translateY(1px); box-shadow: none; }
|
||||
.btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--board);
|
||||
border-color: var(--board);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(47, 107, 88, 0.25);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--board-deep);
|
||||
border-color: var(--board-deep);
|
||||
box-shadow: 0 4px 14px rgba(47, 107, 88, 0.35);
|
||||
}
|
||||
|
||||
.btn-danger { color: var(--redpen); border-color: var(--line-strong); }
|
||||
.btn-danger:hover { background: var(--redpen-tint); border-color: var(--redpen); box-shadow: none; }
|
||||
|
||||
.btn-sm { padding: 5px 11px; font-size: 0.84rem; border-radius: var(--radius-xs); }
|
||||
.btn-lg { padding: 12px 26px; font-size: 1rem; border-radius: var(--radius); }
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
label.field { display: block; margin-bottom: 14px; }
|
||||
.field-label { display: block; font-weight: 600; font-size: 0.87rem; margin-bottom: 5px; color: var(--ink); letter-spacing: 0.01em; }
|
||||
.field-hint { font-size: 0.82rem; color: var(--ink-soft); margin-top: 5px; line-height: 1.5; }
|
||||
|
||||
input[type="text"], input[type="password"], input[type="number"], input[type="url"], select, textarea {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-family: var(--font-body);
|
||||
color: var(--ink);
|
||||
background: var(--field-bg);
|
||||
border: 1.5px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
transition: border-color var(--transition), box-shadow var(--transition), background var(--transition);
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--board);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--board-glow);
|
||||
background: var(--field-bg);
|
||||
}
|
||||
textarea { resize: vertical; min-height: 80px; }
|
||||
|
||||
.row { display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.row > * { flex: 1; min-width: 180px; }
|
||||
|
||||
.check {
|
||||
display: flex; align-items: flex-start; gap: 10px; margin: 10px 0; cursor: pointer;
|
||||
padding: 8px 10px; border-radius: var(--radius-xs); transition: background var(--transition);
|
||||
}
|
||||
.check:hover { background: var(--hover-bg); }
|
||||
.check input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--board); cursor: pointer; flex: none; }
|
||||
.check span { font-size: 0.94rem; }
|
||||
.check small { display: block; color: var(--ink-soft); }
|
||||
|
||||
/* ---------- step tabs (Create flow) ---------- */
|
||||
.steps {
|
||||
display: flex; gap: 0;
|
||||
border-bottom: 1.5px solid var(--line);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.step {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 20px 13px; margin-bottom: -1.5px;
|
||||
border-bottom: 2.5px solid transparent;
|
||||
color: var(--ink-soft); font-weight: 500; font-size: 0.93rem;
|
||||
background: none; border-top: 0; border-left: 0; border-right: 0;
|
||||
cursor: pointer; font-family: var(--font-body);
|
||||
transition: color var(--transition), border-color var(--transition);
|
||||
}
|
||||
.step .step-n {
|
||||
width: 24px; height: 24px; border-radius: 50%; flex: none;
|
||||
border: 2px solid currentColor;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 0.78rem; font-weight: 700;
|
||||
transition: background var(--transition), border-color var(--transition), transform 0.2s ease;
|
||||
}
|
||||
.step.active { color: var(--board-deep); border-bottom-color: var(--board); font-weight: 600; }
|
||||
.step.done { color: var(--board); }
|
||||
.step.done .step-n {
|
||||
background: var(--board); border-color: var(--board); color: #fff;
|
||||
animation: step-complete 0.3s ease;
|
||||
}
|
||||
.step:disabled { cursor: default; opacity: 0.5; }
|
||||
|
||||
/* ---------- choice cards ---------- */
|
||||
.choice-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 10px; }
|
||||
.choice {
|
||||
border: 1.5px solid var(--line-strong); border-radius: var(--radius); background: var(--field-bg);
|
||||
padding: 14px 15px; cursor: pointer; text-align: left; font: inherit; font-family: var(--font-body);
|
||||
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition), transform 0.15s ease;
|
||||
}
|
||||
.choice:hover { border-color: var(--board); box-shadow: 0 2px 10px var(--board-glow); transform: translateY(-1px); }
|
||||
.choice.selected { border-color: var(--board); background: var(--board-tint); box-shadow: 0 2px 10px var(--board-glow); }
|
||||
.choice b { display: block; font-size: 0.93rem; font-weight: 600; }
|
||||
.choice small { color: var(--ink-soft); font-size: 0.79rem; line-height: 1.35; display: block; margin-top: 4px; }
|
||||
|
||||
/* ---------- tabs (source input) ---------- */
|
||||
.tabs {
|
||||
display: inline-flex; background: var(--tab-track);
|
||||
border-radius: var(--radius-sm); padding: 3px; gap: 2px; margin-bottom: 16px;
|
||||
}
|
||||
.tab {
|
||||
border: 0; background: none; font: inherit; font-family: var(--font-body);
|
||||
font-weight: 600; font-size: 0.88rem;
|
||||
padding: 7px 16px; border-radius: 6px; cursor: pointer; color: var(--ink-soft);
|
||||
transition: background var(--transition), color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--panel); color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* ---------- badges & chips ---------- */
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 0.73rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
padding: 3px 10px; border-radius: 99px;
|
||||
background: var(--board-tint); color: var(--board-deep);
|
||||
}
|
||||
.chip-neutral { background: var(--chip-neutral-bg); color: var(--ink-soft); }
|
||||
|
||||
.stamp {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-family: var(--font-mono); font-size: 0.71rem; font-weight: 700;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
padding: 3px 8px; border: 1.5px solid currentColor; border-radius: 4px;
|
||||
transform: rotate(-1.2deg);
|
||||
}
|
||||
.stamp-pass { color: var(--board); background: rgba(47, 107, 88, 0.06); }
|
||||
.stamp-warn { color: var(--gold); background: var(--gold-tint); transform: rotate(1deg); }
|
||||
|
||||
/* ---------- answer key (red pen) ---------- */
|
||||
.answer-key {
|
||||
margin-top: 14px; padding: 13px 15px;
|
||||
background: var(--redpen-tint);
|
||||
border-left: 3px solid var(--redpen);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
.answer-key .ak-label {
|
||||
font-family: var(--font-mono); font-size: 0.69rem; font-weight: 700;
|
||||
letter-spacing: 0.1em; text-transform: uppercase; color: var(--redpen);
|
||||
display: block; margin-bottom: 6px;
|
||||
}
|
||||
.answer-key, .answer-key textarea, .answer-key input { font-size: 0.92rem; }
|
||||
.redpen { color: var(--redpen); font-weight: 600; }
|
||||
|
||||
/* ---------- question cards ---------- */
|
||||
.qcard { position: relative; }
|
||||
.qcard-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
.qnum { font-family: var(--font-display); font-size: 1.15rem; font-weight: 700; color: var(--board-deep); min-width: 28px; }
|
||||
.qcard-actions { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.icon-btn {
|
||||
border: 1px solid var(--line); background: var(--field-bg); border-radius: 7px;
|
||||
width: 32px; height: 32px; cursor: pointer; font-size: 0.93rem; line-height: 1;
|
||||
display: inline-flex; align-items: center; justify-content: center; color: var(--ink-soft);
|
||||
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition-fast);
|
||||
}
|
||||
.icon-btn:hover { border-color: var(--board); color: var(--ink); background: var(--board-tint); transform: scale(1.05); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; transform: none; }
|
||||
.icon-btn.danger:hover { border-color: var(--redpen); color: var(--redpen); background: var(--redpen-tint); }
|
||||
|
||||
.opt-row { display: flex; align-items: center; gap: 9px; margin: 7px 0; }
|
||||
.opt-row input[type="radio"] { accent-color: var(--redpen); width: 16px; height: 16px; flex: none; cursor: pointer; }
|
||||
.opt-letter { font-weight: 700; font-size: 0.84rem; color: var(--ink-soft); width: 18px; flex: none; }
|
||||
.points-input { width: 64px !important; text-align: center; }
|
||||
|
||||
/* ---------- alerts & toasts ---------- */
|
||||
.alert {
|
||||
padding: 13px 16px; border-radius: var(--radius-sm); font-size: 0.92rem;
|
||||
margin: 14px 0; border: 1px solid;
|
||||
animation: fade-in 0.2s ease;
|
||||
}
|
||||
.alert-error { background: var(--redpen-tint); border-color: #e8c5be; color: #8c3022; }
|
||||
.alert-warn { background: var(--gold-tint); border-color: #e9d8a6; color: #7a5a14; }
|
||||
.alert-info { background: var(--board-tint); border-color: #cde0d8; color: var(--board-deep); }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--ink); color: #fff;
|
||||
padding: 11px 22px; border-radius: 99px;
|
||||
font-size: 0.91rem; font-weight: 600;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
animation: slide-up 0.22s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- spinner ---------- */
|
||||
.spinner {
|
||||
width: 16px; height: 16px; border-radius: 50%; flex: none;
|
||||
border: 2px solid rgba(47, 107, 88, 0.22);
|
||||
border-top-color: var(--board);
|
||||
animation: spin 0.7s linear infinite;
|
||||
display: inline-block; vertical-align: -3px;
|
||||
}
|
||||
|
||||
/* ---------- generation progress ---------- */
|
||||
.progress-list { list-style: none; margin: 22px 0 0; padding: 0; }
|
||||
.progress-list li {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 0; font-size: 0.97rem; color: var(--ink-soft);
|
||||
border-bottom: 1px solid var(--line);
|
||||
opacity: 0;
|
||||
animation: fade-in-up 0.35s ease forwards;
|
||||
}
|
||||
.progress-list li:last-child { border-bottom: none; }
|
||||
.progress-list li:nth-child(1) { animation-delay: 0.0s; }
|
||||
.progress-list li:nth-child(2) { animation-delay: 0.08s; }
|
||||
.progress-list li:nth-child(3) { animation-delay: 0.16s; }
|
||||
.progress-list li:nth-child(4) { animation-delay: 0.24s; }
|
||||
.progress-list li.active { color: var(--ink); font-weight: 600; }
|
||||
.progress-list li.done { color: var(--board); }
|
||||
.progress-dot { width: 20px; text-align: center; flex: none; font-size: 1rem; }
|
||||
|
||||
/* ---------- library ---------- */
|
||||
.lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(276px, 1fr)); gap: 16px; }
|
||||
.lib-card { animation: fade-in-up 0.35s ease both; }
|
||||
.lib-card:nth-child(2) { animation-delay: 0.06s; }
|
||||
.lib-card:nth-child(3) { animation-delay: 0.12s; }
|
||||
.lib-card:nth-child(4) { animation-delay: 0.18s; }
|
||||
.lib-card:nth-child(5) { animation-delay: 0.24s; }
|
||||
.lib-card:nth-child(6) { animation-delay: 0.30s; }
|
||||
.lib-card h3 { margin-bottom: 6px; }
|
||||
.lib-meta { color: var(--ink-soft); font-size: 0.83rem; margin: 3px 0 14px; line-height: 1.55; }
|
||||
.lib-actions { display: flex; gap: 7px; }
|
||||
|
||||
/* Skeleton loader */
|
||||
.skeleton-card { animation: skeleton-pulse 1.5s ease-in-out infinite; }
|
||||
.skeleton-line {
|
||||
background: var(--line); border-radius: 5px; height: 14px; margin-bottom: 10px;
|
||||
}
|
||||
.skeleton-line.short { width: 55%; }
|
||||
.skeleton-line.medium { width: 75%; }
|
||||
.skeleton-line.full { width: 100%; }
|
||||
.skeleton-chip { background: var(--line); border-radius: 99px; height: 20px; width: 64px; margin-bottom: 12px; }
|
||||
|
||||
.empty {
|
||||
text-align: center; padding: 60px 24px; color: var(--ink-soft);
|
||||
border: 1.5px dashed var(--line-strong); border-radius: var(--radius);
|
||||
background: var(--empty-bg);
|
||||
animation: fade-in 0.3s ease;
|
||||
}
|
||||
.empty h3 { color: var(--ink); margin-bottom: 8px; }
|
||||
|
||||
/* ---------- provider cards on settings ---------- */
|
||||
.provider-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 13px 15px; border: 1.5px solid var(--line-strong);
|
||||
border-radius: var(--radius); cursor: pointer; background: var(--field-bg);
|
||||
margin-bottom: 10px;
|
||||
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
.provider-row:hover { border-color: var(--board); background: var(--hover-bg); }
|
||||
.provider-row.selected { border-color: var(--board); background: var(--board-tint); box-shadow: 0 2px 8px var(--board-glow); }
|
||||
.provider-row input { accent-color: var(--board); width: 17px; height: 17px; flex: none; }
|
||||
.provider-row b { font-size: 0.96rem; }
|
||||
.provider-row small { color: var(--ink-soft); display: block; }
|
||||
.local-tag { margin-left: auto; }
|
||||
|
||||
/* ---------- misc helpers ---------- */
|
||||
.muted { color: var(--ink-soft); }
|
||||
.small { font-size: 0.84rem; }
|
||||
.spacer { flex: 1; }
|
||||
.hr { border: 0; border-top: 1px solid var(--line); margin: 20px 0; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shell { padding: 20px 15px 74px; }
|
||||
.topnav-inner { gap: 10px; padding: 0 15px; }
|
||||
.brand span.brand-text { display: none; }
|
||||
.card { padding: 18px; }
|
||||
.steps { overflow-x: auto; }
|
||||
h1 { font-size: 1.5rem; }
|
||||
}
|
||||
}
|
||||
27
app/layout.jsx
Normal file
27
app/layout.jsx
Normal file
@ -0,0 +1,27 @@
|
||||
import "./globals.css";
|
||||
import Nav from "@/components/Nav";
|
||||
|
||||
export const metadata = {
|
||||
title: "Mr. Drew's Assignment Creator",
|
||||
description: "Local, private, high-accuracy assignments, tests, quizzes, worksheets, discussion questions, and case studies.",
|
||||
};
|
||||
|
||||
// Applies the saved (or system-preferred) theme before first paint so dark
|
||||
// mode doesn't flash white on load.
|
||||
const THEME_SCRIPT = `try{var t=localStorage.getItem("theme");if(!t)t=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";if(t==="dark")document.documentElement.dataset.theme="dark";}catch(e){}`;
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
</head>
|
||||
<body>
|
||||
<Nav />
|
||||
<main className="shell">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
165
app/library/page.jsx
Normal file
165
app/library/page.jsx
Normal file
@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
// app/library/page.jsx — everything you've made, saved locally in data/db.json.
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const TYPE_LABELS = {
|
||||
quiz: "Quiz",
|
||||
test: "Test",
|
||||
worksheet: "Worksheet",
|
||||
discussion: "Discussion",
|
||||
case_study: "Case study",
|
||||
};
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div className="card skeleton-card" aria-hidden="true">
|
||||
<div className="skeleton-chip" />
|
||||
<div className="skeleton-line medium" />
|
||||
<div className="skeleton-line short" style={{ marginBottom: 18 }} />
|
||||
<div className="skeleton-line full" />
|
||||
<div className="skeleton-line" style={{ width: "40%", marginBottom: 16 }} />
|
||||
<div style={{ display: "flex", gap: 7 }}>
|
||||
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} />
|
||||
<div className="skeleton-line" style={{ width: 80, height: 30, borderRadius: 7, marginBottom: 0 }} />
|
||||
<div className="skeleton-line" style={{ width: 64, height: 30, borderRadius: 7, marginBottom: 0 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LibraryPage() {
|
||||
const router = useRouter();
|
||||
const [items, setItems] = useState(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState("");
|
||||
|
||||
function load() {
|
||||
fetch("/api/assignments")
|
||||
.then((r) => r.json())
|
||||
.then((d) => setItems(d.assignments || []))
|
||||
.catch(() => setError("Could not load your library."));
|
||||
}
|
||||
useEffect(load, []);
|
||||
|
||||
async function duplicate(id) {
|
||||
setBusy(id);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/assignments/" + id);
|
||||
const full = await res.json();
|
||||
if (!res.ok) throw new Error(full.error || "Could not load that assignment.");
|
||||
const { id: _id, createdAt, updatedAt, ...copy } = full;
|
||||
copy.title = (copy.title || "Untitled") + " (copy)";
|
||||
const res2 = await fetch("/api/assignments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(copy),
|
||||
});
|
||||
const created = await res2.json();
|
||||
if (!res2.ok) throw new Error(created.error || "Could not duplicate.");
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id, title) {
|
||||
if (!confirm(`Delete "${title}"? This can't be undone.`)) return;
|
||||
setBusy(id);
|
||||
try {
|
||||
await fetch("/api/assignments/" + id, { method: "DELETE" });
|
||||
load();
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = (items || []).filter((a) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return [a.title, a.subject, a.gradeLevel, TYPE_LABELS[a.assignmentType]]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(q);
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head" style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1>Library</h1>
|
||||
<p>Everything you’ve created, stored locally on this computer.</p>
|
||||
</div>
|
||||
<Link href="/" className="btn btn-primary">✎ New assignment</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{/* Skeleton loading state */}
|
||||
{items === null && (
|
||||
<div className="lib-grid">
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
<SkeletonCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items !== null && items.length === 0 && (
|
||||
<div className="empty">
|
||||
<h3>Nothing here yet</h3>
|
||||
<p>Create your first assignment and it will be saved here automatically.</p>
|
||||
<Link href="/" className="btn btn-primary" style={{ marginTop: 12 }}>Create an assignment</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items !== null && items.length > 0 && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title, subject, grade, or type…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ marginBottom: 18, maxWidth: 440 }}
|
||||
aria-label="Search library"
|
||||
/>
|
||||
{filtered.length === 0 && <p className="muted">No matches for “{query}”.</p>}
|
||||
<div className="lib-grid">
|
||||
{filtered.map((a) => (
|
||||
<div key={a.id} className="card lib-card card-lift">
|
||||
<span className="chip">{TYPE_LABELS[a.assignmentType] || a.assignmentType}</span>
|
||||
<h3 style={{ marginTop: 10 }}>
|
||||
<Link href={"/editor/" + a.id} style={{ color: "inherit" }}>{a.title}</Link>
|
||||
</h3>
|
||||
<p className="lib-meta">
|
||||
{[a.gradeLevel, a.subject].filter(Boolean).join(" · ")}<br />
|
||||
{a.questionCount} question{a.questionCount === 1 ? "" : "s"} · {a.totalPoints} pts · updated {formatDate(a.updatedAt)}
|
||||
</p>
|
||||
<div className="lib-actions">
|
||||
<button className="btn btn-sm btn-primary" onClick={() => router.push("/editor/" + a.id)}>Open</button>
|
||||
<button className="btn btn-sm" disabled={busy === a.id} onClick={() => duplicate(a.id)}>
|
||||
{busy === a.id ? <span className="spinner" /> : "Duplicate"}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-danger" disabled={busy === a.id} onClick={() => remove(a.id, a.title)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
472
app/page.jsx
Normal file
472
app/page.jsx
Normal file
@ -0,0 +1,472 @@
|
||||
"use client";
|
||||
// app/page.jsx — the Create flow: Source -> Configure -> Generate.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ASSIGNMENT_TYPES, QUESTION_TYPES, GRADE_LEVELS, DIFFICULTIES } from "@/lib/schema";
|
||||
|
||||
const MAX_PASTE = 120000;
|
||||
|
||||
// Maps a generation phase to its 0-based order index
|
||||
const PHASE_ORDER = ["analyze", "generate", "verify", "save"];
|
||||
|
||||
function ProgressStep({ phase, label, currentPhase, hasVerify }) {
|
||||
if (phase === "verify" && !hasVerify) return null;
|
||||
const cur = PHASE_ORDER.indexOf(currentPhase);
|
||||
const me = PHASE_ORDER.indexOf(phase);
|
||||
const state = me < cur ? "done" : me === cur ? "active" : "";
|
||||
return (
|
||||
<li className={state} style={{ animationDelay: `${me * 0.08}s` }}>
|
||||
<span className="progress-dot">
|
||||
{state === "done" ? "✓" : state === "active" ? <span className="spinner" /> : "·"}
|
||||
</span>
|
||||
{label}{state === "active" ? "…" : ""}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreatePage() {
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
// --- source state ---
|
||||
const [sourceTab, setSourceTab] = useState("paste");
|
||||
const [text, setText] = useState("");
|
||||
const [sourceName, setSourceName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const fileRef = useRef(null);
|
||||
|
||||
// --- config state ---
|
||||
const [config, setConfig] = useState({
|
||||
assignmentType: "quiz",
|
||||
gradeLevel: "Grade 8",
|
||||
subject: "",
|
||||
questionCount: 10,
|
||||
difficulty: "Mixed",
|
||||
questionTypes: ["multiple_choice", "true_false", "short_answer", "fill_blank"],
|
||||
includeExplanations: true,
|
||||
includeRubrics: true,
|
||||
focusNote: "",
|
||||
verify: true,
|
||||
});
|
||||
|
||||
// --- provider readiness ---
|
||||
const [providerNote, setProviderNote] = useState(null);
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((s) => {
|
||||
const cfg = s.providers?.[s.provider] || {};
|
||||
if (!cfg.model) {
|
||||
setProviderNote({ provider: s.provider, missing: "model" });
|
||||
} else if (["openai", "anthropic", "google"].includes(s.provider) && !cfg.apiKey) {
|
||||
setProviderNote({ provider: s.provider, missing: "key" });
|
||||
} else {
|
||||
setProviderNote(null);
|
||||
}
|
||||
setConfig((c) => ({ ...c, verify: s.generation?.verification !== false }));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// --- generation state ---
|
||||
const [genState, setGenState] = useState(null);
|
||||
const generating = !!genState && !genState.error;
|
||||
|
||||
function update(patch) {
|
||||
setConfig((c) => ({ ...c, ...patch }));
|
||||
}
|
||||
|
||||
function toggleQType(id) {
|
||||
setConfig((c) => {
|
||||
const has = c.questionTypes.includes(id);
|
||||
const next = has ? c.questionTypes.filter((t) => t !== id) : [...c.questionTypes, id];
|
||||
return { ...c, questionTypes: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function onFile(e) {
|
||||
setError("");
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!/\.(txt|md|markdown|text|csv)$/i.test(file.name)) {
|
||||
setError("Please choose a plain-text file (.txt or .md). For Word docs or PDFs, copy the text and paste it instead.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = await file.text();
|
||||
setText(content.slice(0, MAX_PASTE));
|
||||
setSourceName(file.name);
|
||||
setSourceTab("upload");
|
||||
} catch {
|
||||
setError("Could not read that file.");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUrl() {
|
||||
setError("");
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await fetch("/api/fetch-url", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Could not fetch that page.");
|
||||
setText(data.text.slice(0, MAX_PASTE));
|
||||
setSourceName(data.title || url);
|
||||
} catch (e) {
|
||||
setError(String(e.message || e));
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceReady = text.trim().length >= 100;
|
||||
const configReady =
|
||||
config.subject.trim().length > 0 &&
|
||||
(["discussion", "case_study"].includes(config.assignmentType) || config.questionTypes.length > 0);
|
||||
|
||||
async function generate() {
|
||||
setGenState({ phase: "analyze" });
|
||||
const source = text.trim();
|
||||
const cfg = { ...config, subject: config.subject.trim() };
|
||||
try {
|
||||
let analysis = null;
|
||||
try {
|
||||
const r1 = await postJson("/api/generate", { stage: "analyze", source, config: cfg });
|
||||
analysis = r1.analysis;
|
||||
} catch (e) {
|
||||
console.warn("Analysis stage failed, continuing:", e);
|
||||
}
|
||||
|
||||
setGenState({ phase: "generate" });
|
||||
const r2 = await postJson("/api/generate", { stage: "generate", source, analysis, config: cfg });
|
||||
const assignment = r2.assignment;
|
||||
|
||||
if (cfg.verify) {
|
||||
setGenState({ phase: "verify" });
|
||||
try {
|
||||
const r3 = await postJson("/api/generate", {
|
||||
stage: "verify",
|
||||
source,
|
||||
config: cfg,
|
||||
questions: assignment.questions,
|
||||
});
|
||||
for (const q of assignment.questions) {
|
||||
if (r3.verifications[q.id]) q.verification = r3.verifications[q.id];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Verification stage failed, continuing:", e);
|
||||
}
|
||||
}
|
||||
|
||||
setGenState({ phase: "save" });
|
||||
const saved = await postJson("/api/assignments", {
|
||||
...assignment,
|
||||
source: { type: sourceTab, name: sourceName || "Pasted text", text: source },
|
||||
config: cfg,
|
||||
});
|
||||
router.push("/editor/" + saved.id);
|
||||
} catch (e) {
|
||||
setGenState({ phase: null, error: String(e.message || e) });
|
||||
}
|
||||
}
|
||||
|
||||
const isDiscussionOrCase = ["discussion", "case_study"].includes(config.assignmentType);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h1>Create an assignment</h1>
|
||||
<p>Give it your source material, set the parameters, and get a classroom-ready assignment with a verified answer key — all on your own machine.</p>
|
||||
</div>
|
||||
|
||||
{providerNote && (
|
||||
<div className="alert alert-warn">
|
||||
{providerNote.missing === "key"
|
||||
? "Your selected AI provider needs an API key before you can generate."
|
||||
: "No AI model is selected yet."}{" "}
|
||||
<a href="/settings">Open Settings</a> to finish setup.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="steps" role="tablist">
|
||||
{["Source", "Configure", "Generate"].map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
className={`step${step === i ? " active" : ""}${step > i ? " done" : ""}`}
|
||||
onClick={() => {
|
||||
if (i === 0 || (i === 1 && sourceReady) || (i === 2 && sourceReady && configReady)) setStep(i);
|
||||
}}
|
||||
disabled={
|
||||
generating ||
|
||||
(i === 1 && !sourceReady) ||
|
||||
(i === 2 && (!sourceReady || !configReady))
|
||||
}
|
||||
role="tab"
|
||||
aria-selected={step === i}
|
||||
>
|
||||
<span className="step-n">{step > i ? "✓" : i + 1}</span> {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ============ STEP 1: SOURCE ============ */}
|
||||
{step === 0 && (
|
||||
<div className="card">
|
||||
<h2>What should the questions come from?</h2>
|
||||
<p className="muted small" style={{ margin: "6px 0 16px" }}>
|
||||
Questions are grounded strictly in this material — the AI is instructed not to add outside facts.
|
||||
</p>
|
||||
|
||||
<div className="tabs">
|
||||
{[["paste", "Paste text"], ["upload", "Upload file"], ["url", "From a web page"]].map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`tab${sourceTab === id ? " active" : ""}`}
|
||||
onClick={() => { setSourceTab(id); setError(""); }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sourceTab === "upload" && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<input ref={fileRef} type="file" accept=".txt,.md,.markdown,.text,.csv" onChange={onFile} style={{ display: "none" }} />
|
||||
<button className="btn" onClick={() => fileRef.current?.click()}>Choose a .txt or .md file</button>
|
||||
{sourceName && <span className="small muted" style={{ marginLeft: 10 }}>{sourceName}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceTab === "url" && (
|
||||
<div style={{ display: "flex", gap: 10, marginBottom: 14, flexWrap: "wrap" }}>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://example.com/article"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && url.trim()) fetchUrl(); }}
|
||||
style={{ flex: 1, minWidth: 240 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={fetchUrl} disabled={fetching || !url.trim()}>
|
||||
{fetching ? <><span className="spinner" /> Fetching…</> : "Fetch page"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value.slice(0, MAX_PASTE)); if (sourceTab === "paste") setSourceName(""); }}
|
||||
placeholder={
|
||||
sourceTab === "paste"
|
||||
? "Paste your reading passage, chapter, article, or lecture notes here…"
|
||||
: "The file or page content will appear here — you can trim or edit it before generating."
|
||||
}
|
||||
rows={12}
|
||||
aria-label="Source material"
|
||||
/>
|
||||
<div className="small muted" style={{ display: "flex", marginTop: 7 }}>
|
||||
<span>
|
||||
{text.length.toLocaleString()} / {MAX_PASTE.toLocaleString()} characters
|
||||
{sourceReady ? "" : " — at least 100 needed"}
|
||||
</span>
|
||||
<span className="spacer" />
|
||||
{text && <button className="btn btn-sm" onClick={() => { setText(""); setSourceName(""); }}>Clear</button>}
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div style={{ display: "flex", marginTop: 20 }}>
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-primary btn-lg" disabled={!sourceReady} onClick={() => setStep(1)}>
|
||||
Next: Configure →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ STEP 2: CONFIGURE ============ */}
|
||||
{step === 1 && (
|
||||
<div className="card">
|
||||
<h2>Set up the assignment</h2>
|
||||
|
||||
<div style={{ margin: "18px 0" }}>
|
||||
<span className="field-label">Assignment type</span>
|
||||
<div className="choice-grid">
|
||||
{ASSIGNMENT_TYPES.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`choice${config.assignmentType === t.id ? " selected" : ""}`}
|
||||
onClick={() => update({ assignmentType: t.id })}
|
||||
>
|
||||
<b>{t.label}</b>
|
||||
<small>{t.hint}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field-label">Grade level</span>
|
||||
<select value={config.gradeLevel} onChange={(e) => update({ gradeLevel: e.target.value })}>
|
||||
{GRADE_LEVELS.map((g) => <option key={g}>{g}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Subject</span>
|
||||
<input
|
||||
type="text"
|
||||
list="subjects"
|
||||
placeholder="e.g. U.S. History, Biology, English Language Arts"
|
||||
value={config.subject}
|
||||
onChange={(e) => update({ subject: e.target.value })}
|
||||
/>
|
||||
<datalist id="subjects">
|
||||
{["English Language Arts","U.S. History","World History","Civics / Government","Biology","Chemistry","Physics","Earth Science","Mathematics","Geography","Economics","Health","Computer Science","Spanish","Art History"].map((s) => (
|
||||
<option key={s} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
{isDiscussionOrCase ? "Number of prompts/questions" : "Number of questions"} — {config.questionCount}
|
||||
</span>
|
||||
<input
|
||||
type="range" min="1" max="30" value={config.questionCount}
|
||||
onChange={(e) => update({ questionCount: Number(e.target.value) })}
|
||||
style={{ width: "100%", accentColor: "var(--board)" }}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Difficulty</span>
|
||||
<select value={config.difficulty} onChange={(e) => update({ difficulty: e.target.value })}>
|
||||
{DIFFICULTIES.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!isDiscussionOrCase && (
|
||||
<div style={{ margin: "4px 0 12px" }}>
|
||||
<span className="field-label">Question types to include</span>
|
||||
<div className="row" style={{ gap: 4 }}>
|
||||
{QUESTION_TYPES.map((t) => (
|
||||
<label key={t.id} className="check" style={{ minWidth: 150, flex: "0 0 auto" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.questionTypes.includes(t.id)}
|
||||
onChange={() => toggleQType(t.id)}
|
||||
/>
|
||||
<span>{t.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{config.questionTypes.length === 0 && (
|
||||
<div className="field-hint" style={{ color: "var(--redpen)" }}>Pick at least one question type.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={config.includeExplanations} onChange={(e) => update({ includeExplanations: e.target.checked })} />
|
||||
<span>Include explanations in the answer key<small>Why each answer is correct — and for multiple choice, why the others are wrong.</small></span>
|
||||
</label>
|
||||
{!isDiscussionOrCase && (
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={config.includeRubrics} onChange={(e) => update({ includeRubrics: e.target.checked })} />
|
||||
<span>Include rubrics for essay questions<small>Point-based criteria that sum to the question total.</small></span>
|
||||
</label>
|
||||
)}
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={config.verify} onChange={(e) => update({ verify: e.target.checked })} />
|
||||
<span>Run the accuracy check<small>A second AI pass reviews every question and answer against your source. Strongly recommended — adds a little time.</small></span>
|
||||
</label>
|
||||
|
||||
<label className="field" style={{ marginTop: 12 }}>
|
||||
<span className="field-label">Anything to focus on? <span className="muted" style={{ fontWeight: 400 }}>(optional)</span></span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. focus on causes rather than dates; include the vocabulary terms"
|
||||
value={config.focusNote}
|
||||
onChange={(e) => update({ focusNote: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div style={{ display: "flex", marginTop: 20, gap: 10 }}>
|
||||
<button className="btn" onClick={() => setStep(0)}>← Back</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-primary btn-lg" disabled={!configReady} onClick={() => setStep(2)}>
|
||||
Next: Generate →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ============ STEP 3: GENERATE ============ */}
|
||||
{step === 2 && (
|
||||
<div className="card">
|
||||
<h2>Ready to generate</h2>
|
||||
<p className="muted" style={{ margin: "10px 0 4px", fontSize: "0.96rem" }}>
|
||||
<b style={{ color: "var(--ink)" }}>
|
||||
{ASSIGNMENT_TYPES.find((t) => t.id === config.assignmentType)?.label}
|
||||
</b>{" "}
|
||||
· {config.gradeLevel} · {config.subject || "—"} · {config.questionCount} question{config.questionCount === 1 ? "" : "s"} · {config.difficulty} difficulty
|
||||
</p>
|
||||
<p className="muted small">Source: {sourceName || "Pasted text"} ({text.length.toLocaleString()} characters)</p>
|
||||
|
||||
{!genState && (
|
||||
<div style={{ display: "flex", marginTop: 20, gap: 10 }}>
|
||||
<button className="btn" onClick={() => setStep(1)}>← Back</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn btn-primary btn-lg" onClick={generate} disabled={!!providerNote}>
|
||||
✎ Generate assignment
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{genState && !genState.error && (
|
||||
<>
|
||||
<ul className="progress-list" aria-live="polite">
|
||||
<ProgressStep phase="analyze" label="Reading the source and mapping key concepts" currentPhase={genState.phase} hasVerify={config.verify} />
|
||||
<ProgressStep phase="generate" label="Writing questions and the answer key" currentPhase={genState.phase} hasVerify={config.verify} />
|
||||
{config.verify && <ProgressStep phase="verify" label="Checking every answer against the source" currentPhase={genState.phase} hasVerify={config.verify} />}
|
||||
<ProgressStep phase="save" label="Saving and opening the editor" currentPhase={genState.phase} hasVerify={config.verify} />
|
||||
</ul>
|
||||
<p className="small muted" style={{ marginTop: 16 }}>
|
||||
Local models can take a few minutes for large assignments. Leave this tab open.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{genState?.error && (
|
||||
<>
|
||||
<div className="alert alert-error"><b>Generation failed.</b> {genState.error}</div>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<button className="btn" onClick={() => setGenState(null)}>Adjust and retry</button>
|
||||
<button className="btn btn-primary" onClick={generate}>Try again</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function postJson(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
|
||||
return data;
|
||||
}
|
||||
372
app/settings/page.jsx
Normal file
372
app/settings/page.jsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
65
components/Nav.jsx
Normal file
65
components/Nav.jsx
Normal file
@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const LINKS = [
|
||||
{ href: "/", label: "Create" },
|
||||
{ href: "/library", label: "Library" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
const pathname = usePathname();
|
||||
const [theme, setTheme] = useState(null);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(document.documentElement.dataset.theme === "dark" ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setScrolled(window.scrollY > 8);
|
||||
}
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
function toggleTheme() {
|
||||
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
|
||||
document.documentElement.dataset.theme = next;
|
||||
try { localStorage.setItem("theme", next); } catch {}
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={`topnav${scrolled ? " nav-scrolled" : ""}`}>
|
||||
<div className="topnav-inner">
|
||||
<Link href="/" className="brand">
|
||||
<span className="brand-mark" aria-hidden="true">✎</span>
|
||||
<span className="brand-text">Mr. Drew’s Assignment Creator</span>
|
||||
</Link>
|
||||
<nav className="navlinks" aria-label="Main">
|
||||
{LINKS.map((l) => {
|
||||
const active = l.href === "/" ? pathname === "/" : pathname.startsWith(l.href);
|
||||
return (
|
||||
<Link key={l.href} href={l.href} className={`navlink${active ? " active" : ""}`}>
|
||||
{l.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn theme-toggle"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? "☀" : "☾"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
227
components/QuestionCard.jsx
Normal file
227
components/QuestionCard.jsx
Normal file
@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
// components/QuestionCard.jsx — edit any question inline, with the answer key
|
||||
// styled in red pen and grading-stamp verification badges.
|
||||
import { useState } from "react";
|
||||
import { questionTypeLabel } from "@/lib/schema";
|
||||
|
||||
const LETTERS = "ABCDEFGHIJ";
|
||||
|
||||
function AutoTextarea({ value, onChange, rows = 2, ...rest }) {
|
||||
return <textarea value={value || ""} rows={rows} onChange={(e) => onChange(e.target.value)} {...rest} />;
|
||||
}
|
||||
|
||||
export default function QuestionCard({ q, index, count, onChange, onMove, onDelete, onRegenerate, busy }) {
|
||||
const [regenOpen, setRegenOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
function set(patch) {
|
||||
onChange({ ...q, ...patch, verification: patch.verification || { status: "unchecked", note: "" } });
|
||||
}
|
||||
// Editing content invalidates the old verification stamp (set() above resets it),
|
||||
// but pure point changes shouldn't:
|
||||
function setPoints(points) {
|
||||
onChange({ ...q, points });
|
||||
}
|
||||
|
||||
const v = q.verification || { status: "unchecked" };
|
||||
|
||||
return (
|
||||
<div className="card qcard">
|
||||
<div className="qcard-head">
|
||||
<span className="qnum">{index + 1}.</span>
|
||||
<span className="chip chip-neutral">{questionTypeLabel(q.type)}</span>
|
||||
{v.status === "pass" && <span className="stamp stamp-pass" title="The accuracy check confirmed this answer against your source.">✓ Verified</span>}
|
||||
{v.status === "warn" && <span className="stamp stamp-warn" title={v.note}>⚠ Check this</span>}
|
||||
<span className="qcard-actions">
|
||||
<label className="small muted" style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
|
||||
<input className="points-input" type="number" min="0" max="100" value={q.points}
|
||||
onChange={(e) => setPoints(Math.max(0, Number(e.target.value) || 0))} aria-label="Points" />
|
||||
pts
|
||||
</label>
|
||||
<button className="icon-btn" title="Move up" disabled={index === 0 || busy} onClick={() => onMove(-1)}>↑</button>
|
||||
<button className="icon-btn" title="Move down" disabled={index === count - 1 || busy} onClick={() => onMove(1)}>↓</button>
|
||||
<button className="icon-btn" title="Regenerate this question" disabled={busy} onClick={() => setRegenOpen((o) => !o)}>↻</button>
|
||||
<button className="icon-btn danger" title="Delete question" disabled={busy} onClick={onDelete}>✕</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{v.status === "warn" && v.note && (
|
||||
<div className="alert alert-warn" style={{ marginTop: 0 }}><b>Accuracy reviewer:</b> {v.note}</div>
|
||||
)}
|
||||
|
||||
{regenOpen && (
|
||||
<div className="alert alert-info" style={{ marginTop: 0 }}>
|
||||
<div className="field-label">Regenerate this question from your source</div>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<input type="text" placeholder="Optional note to steer it — e.g. make it harder, focus on the causes" value={note}
|
||||
onChange={(e) => setNote(e.target.value)} style={{ flex: 1, minWidth: 200 }} />
|
||||
<button className="btn btn-primary btn-sm" disabled={busy} onClick={() => { onRegenerate(note); setRegenOpen(false); setNote(""); }}>
|
||||
{busy ? <><span className="spinner" /> Working…</> : "Regenerate"}
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => setRegenOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Question prompt */}
|
||||
<AutoTextarea
|
||||
value={q.question}
|
||||
onChange={(question) => set({ question })}
|
||||
rows={q.type === "essay" || q.type === "discussion" ? 3 : 2}
|
||||
aria-label="Question text"
|
||||
placeholder={q.type === "fill_blank" ? "Sentence with ______ (six underscores) for each blank" : "Question text…"}
|
||||
/>
|
||||
|
||||
{/* ---- type-specific bodies ---- */}
|
||||
{q.type === "multiple_choice" && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{(q.options || []).map((opt, i) => (
|
||||
<div className="opt-row" key={i}>
|
||||
<input
|
||||
type="radio" name={"correct-" + q.id} checked={q.correctIndex === i}
|
||||
onChange={() => set({ correctIndex: i })}
|
||||
title="Mark as the correct answer"
|
||||
/>
|
||||
<span className="opt-letter">{LETTERS[i]}.</span>
|
||||
<input type="text" value={opt} onChange={(e) => {
|
||||
const options = [...q.options]; options[i] = e.target.value; set({ options });
|
||||
}} placeholder={"Option " + LETTERS[i]} />
|
||||
<button className="icon-btn danger" title="Remove option" disabled={q.options.length <= 2}
|
||||
onClick={() => {
|
||||
const options = q.options.filter((_, j) => j !== i);
|
||||
let correctIndex = q.correctIndex;
|
||||
if (correctIndex === i) correctIndex = 0;
|
||||
else if (correctIndex > i) correctIndex -= 1;
|
||||
set({ options, correctIndex });
|
||||
}}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
{(q.options || []).length < 6 && (
|
||||
<button className="btn btn-sm" style={{ marginTop: 4 }} onClick={() => set({ options: [...q.options, ""] })}>+ Add option</button>
|
||||
)}
|
||||
<div className="field-hint">The <span className="redpen">red radio</span> marks the correct answer.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "true_false" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key</span>
|
||||
<label className="check" style={{ margin: "2px 0", display: "inline-flex", marginRight: 18 }}>
|
||||
<input type="radio" name={"tf-" + q.id} checked={q.correctAnswer === true} onChange={() => set({ correctAnswer: true })} />
|
||||
<span>True</span>
|
||||
</label>
|
||||
<label className="check" style={{ margin: "2px 0", display: "inline-flex" }}>
|
||||
<input type="radio" name={"tf-" + q.id} checked={q.correctAnswer === false} onChange={() => set({ correctAnswer: false })} />
|
||||
<span>False</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "short_answer" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — sample answer</span>
|
||||
<AutoTextarea value={q.sampleAnswer} onChange={(sampleAnswer) => set({ sampleAnswer })} rows={2} placeholder="A model answer…" />
|
||||
<span className="ak-label" style={{ marginTop: 8 }}>Must-include points (one per line)</span>
|
||||
<AutoTextarea
|
||||
value={(q.keyPoints || []).join("\n")}
|
||||
onChange={(text) => set({ keyPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
||||
rows={2} placeholder={"point 1\npoint 2"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "essay" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — sample response</span>
|
||||
<AutoTextarea value={q.sampleResponse} onChange={(sampleResponse) => set({ sampleResponse })} rows={4} placeholder="A strong model response or outline…" />
|
||||
<span className="ak-label" style={{ marginTop: 8 }}>Rubric</span>
|
||||
{(q.rubric || []).map((r, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", flexWrap: "wrap" }}>
|
||||
<input type="text" value={r.criterion} placeholder="Criterion" style={{ flex: 2, minWidth: 140 }}
|
||||
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, criterion: e.target.value } : x); set({ rubric }); }} />
|
||||
<input type="number" className="points-input" value={r.points} min="0" title="Points"
|
||||
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, points: Math.max(0, Number(e.target.value) || 0) } : x); set({ rubric }); }} />
|
||||
<input type="text" value={r.description} placeholder="What earns full points" style={{ flex: 3, minWidth: 160 }}
|
||||
onChange={(e) => { const rubric = q.rubric.map((x, j) => j === i ? { ...x, description: e.target.value } : x); set({ rubric }); }} />
|
||||
<button className="icon-btn danger" onClick={() => set({ rubric: q.rubric.filter((_, j) => j !== i) })}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-sm" onClick={() => set({ rubric: [...(q.rubric || []), { criterion: "", points: 0, description: "" }] })}>+ Add criterion</button>
|
||||
{(q.rubric || []).length > 0 && (
|
||||
<div className="field-hint">Rubric total: {(q.rubric || []).reduce((s, r) => s + (Number(r.points) || 0), 0)} / question points: {q.points}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "fill_blank" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — one answer per blank, in order</span>
|
||||
{(q.answers || []).map((a, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 7, alignItems: "center", margin: "5px 0" }}>
|
||||
<span className="opt-letter">{i + 1}.</span>
|
||||
<input type="text" value={a} onChange={(e) => { const answers = [...q.answers]; answers[i] = e.target.value; set({ answers }); }} />
|
||||
</div>
|
||||
))}
|
||||
<div className="field-hint">
|
||||
Blanks found in the question: {(String(q.question).match(/_{3,}/g) || []).length}.
|
||||
{" "}
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
const blanks = (String(q.question).match(/_{3,}/g) || []).length || 1;
|
||||
const answers = Array.from({ length: blanks }, (_, i) => q.answers?.[i] || "");
|
||||
set({ answers });
|
||||
}}>Match answer slots to blanks</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "matching" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Answer key — correct pairs (the student copy shuffles the right column)</span>
|
||||
{(q.pairs || []).map((p, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 7, margin: "5px 0", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input type="text" value={p.left} placeholder="Left item" style={{ flex: 1, minWidth: 130 }}
|
||||
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, left: e.target.value } : x); set({ pairs }); }} />
|
||||
<span className="muted">→</span>
|
||||
<input type="text" value={p.right} placeholder="Matches with" style={{ flex: 1, minWidth: 130 }}
|
||||
onChange={(e) => { const pairs = q.pairs.map((x, j) => j === i ? { ...x, right: e.target.value } : x); set({ pairs }); }} />
|
||||
<button className="icon-btn danger" disabled={(q.pairs || []).length <= 2} onClick={() => set({ pairs: q.pairs.filter((_, j) => j !== i) })}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
{(q.pairs || []).length < 10 && (
|
||||
<button className="btn btn-sm" onClick={() => set({ pairs: [...q.pairs, { left: "", right: "" }] })}>+ Add pair</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.type === "discussion" && (
|
||||
<div className="answer-key">
|
||||
<span className="ak-label">Facilitator notes — key talking points (one per line)</span>
|
||||
<AutoTextarea
|
||||
value={(q.talkingPoints || []).join("\n")}
|
||||
onChange={(text) => set({ talkingPoints: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
||||
rows={3}
|
||||
/>
|
||||
<span className="ak-label" style={{ marginTop: 8 }}>Follow-up questions (one per line)</span>
|
||||
<AutoTextarea
|
||||
value={(q.followUps || []).join("\n")}
|
||||
onChange={(text) => set({ followUps: text.split("\n").map((s) => s.trim()).filter(Boolean) })}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Explanation + source ref, present for all types */}
|
||||
{q.type !== "discussion" && (
|
||||
<div className="answer-key" style={{ background: "#fff7f5" }}>
|
||||
<span className="ak-label">Explanation (teacher key)</span>
|
||||
<AutoTextarea value={q.explanation} onChange={(explanation) => set({ explanation })} rows={2} placeholder="Why this answer is correct…" />
|
||||
</div>
|
||||
)}
|
||||
{q.sourceRef ? (
|
||||
<p className="small muted" style={{ margin: "9px 0 0" }}>
|
||||
<b>Source:</b> “{q.sourceRef}”
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
docker/Dockerfile
Normal file
26
docker/Dockerfile
Normal file
@ -0,0 +1,26 @@
|
||||
# Mr. Drew's Assignment Creator — production image.
|
||||
# Build context is the repository root: docker build -f docker/Dockerfile .
|
||||
# (docker-compose.yml in this folder does that for you.)
|
||||
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS run
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
|
||||
# Next.js "standalone" output: a self-contained server, no node_modules needed.
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
# The single-file database lives here — mounted as a volume by compose.
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
18
docker/docker-compose.linux.yml
Normal file
18
docker/docker-compose.linux.yml
Normal file
@ -0,0 +1,18 @@
|
||||
# Linux variant — run: docker compose -f docker-compose.linux.yml up -d
|
||||
#
|
||||
# Uses host networking, so the container sees Ollama / LM Studio on plain
|
||||
# localhost exactly like a native install. No URL or firewall fiddling, and
|
||||
# Ollama can stay bound to 127.0.0.1. (Host networking is a Linux-only
|
||||
# Docker feature; macOS/Windows users should use docker-compose.yml.)
|
||||
services:
|
||||
assignment-creator:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
network_mode: host # app serves on localhost:3000, talks to localhost:11434
|
||||
volumes:
|
||||
- assignment-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
assignment-data:
|
||||
32
docker/docker-compose.yml
Normal file
32
docker/docker-compose.yml
Normal file
@ -0,0 +1,32 @@
|
||||
# Mr. Drew's Assignment Creator — run: docker compose up -d (from this folder)
|
||||
#
|
||||
# Works out of the box with Docker Desktop (macOS / Windows):
|
||||
# the app reaches Ollama / LM Studio running on YOUR machine via
|
||||
# host.docker.internal. On plain Linux, use docker-compose.linux.yml instead.
|
||||
services:
|
||||
assignment-creator:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Default provider URLs point at the Docker HOST (your machine).
|
||||
# You can always change them later on the app's Settings page.
|
||||
- OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
- LMSTUDIO_BASE_URL=http://host.docker.internal:1234
|
||||
extra_hosts:
|
||||
# Makes host.docker.internal resolve on Linux engines too.
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
# All assignments + settings live in one JSON file inside this volume.
|
||||
- assignment-data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/settings"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
assignment-data:
|
||||
5
jsconfig.json
Normal file
5
jsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": { "@/*": ["./*"] }
|
||||
}
|
||||
}
|
||||
241
lib/exporter.js
Normal file
241
lib/exporter.js
Normal file
@ -0,0 +1,241 @@
|
||||
// lib/exporter.js — zero-dependency exports, client-side only.
|
||||
// Student version and teacher version (with red-pen answer key), as:
|
||||
// plain text, Word (.doc via HTML), clipboard, and print (for PDF).
|
||||
import { totalPoints } from "@/lib/schema";
|
||||
|
||||
const LETTERS = "ABCDEFGHIJ";
|
||||
|
||||
function answersLine(q) {
|
||||
switch (q.type) {
|
||||
case "multiple_choice":
|
||||
return `Answer: ${LETTERS[q.correctIndex] || "?"} — ${q.options?.[q.correctIndex] || ""}`;
|
||||
case "true_false":
|
||||
return `Answer: ${q.correctAnswer ? "True" : "False"}`;
|
||||
case "short_answer":
|
||||
return `Sample answer: ${q.sampleAnswer || ""}` + (q.keyPoints?.length ? `\nMust include: ${q.keyPoints.join("; ")}` : "");
|
||||
case "essay":
|
||||
return (q.sampleResponse ? `Sample response: ${q.sampleResponse}` : "") +
|
||||
(q.rubric?.length ? `\nRubric:\n${q.rubric.map((r) => ` • ${r.criterion} (${r.points} pts)${r.description ? " — " + r.description : ""}`).join("\n")}` : "");
|
||||
case "fill_blank":
|
||||
return `Answers: ${(q.answers || []).map((a, i) => `(${i + 1}) ${a}`).join(" ")}`;
|
||||
case "matching":
|
||||
return `Answer key:\n${(q.pairs || []).map((p) => ` ${p.left} → ${p.right}`).join("\n")}`;
|
||||
case "discussion":
|
||||
return (q.talkingPoints?.length ? `Key talking points:\n${q.talkingPoints.map((t) => ` • ${t}`).join("\n")}` : "") +
|
||||
(q.followUps?.length ? `\nFollow-ups:\n${q.followUps.map((t) => ` • ${t}`).join("\n")}` : "") +
|
||||
(q.sampleResponse ? `\nA strong contribution: ${q.sampleResponse}` : "");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle the right column of a matching question deterministically (by text)
|
||||
// so the student version isn't pre-matched but exports are stable.
|
||||
function shuffledRight(pairs) {
|
||||
return [...pairs.map((p) => p.right)].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function questionStudentText(q, n) {
|
||||
const head = `${n}. ${q.type === "matching" ? (q.question || "Match each item on the left with the correct item on the right.") : q.question} (${q.points} pt${q.points === 1 ? "" : "s"})`;
|
||||
let body = "";
|
||||
if (q.type === "multiple_choice") {
|
||||
body = (q.options || []).map((o, i) => ` ${LETTERS[i]}. ${o}`).join("\n");
|
||||
} else if (q.type === "true_false") {
|
||||
body = " True / False";
|
||||
} else if (q.type === "short_answer") {
|
||||
body = " ________________________________________________\n ________________________________________________";
|
||||
} else if (q.type === "essay" || q.type === "discussion") {
|
||||
body = "";
|
||||
} else if (q.type === "matching") {
|
||||
const rights = shuffledRight(q.pairs || []);
|
||||
body = (q.pairs || []).map((p, i) => ` ___ ${i + 1}. ${p.left}`).join("\n") +
|
||||
"\n\n" + rights.map((r, i) => ` ${LETTERS[i]}. ${r}`).join("\n");
|
||||
}
|
||||
return body ? head + "\n" + body : head;
|
||||
}
|
||||
|
||||
export function buildText(assignment, { teacher, profile = {} }) {
|
||||
const lines = [];
|
||||
const teachLine = [profile.teacherName, profile.className].filter(Boolean).join(" — ");
|
||||
if (profile.schoolName) lines.push(profile.schoolName);
|
||||
if (teachLine) lines.push(teachLine);
|
||||
if (profile.schoolName || teachLine) lines.push("");
|
||||
lines.push(assignment.title || "Untitled assignment");
|
||||
const meta = [assignment.subject, assignment.gradeLevel, totalPoints(assignment.questions) + " points"].filter(Boolean).join(" · ");
|
||||
lines.push(meta);
|
||||
if (teacher) lines.push("TEACHER VERSION — ANSWER KEY INCLUDED");
|
||||
lines.push("");
|
||||
if (!teacher) lines.push("Name: ______________________________ Date: ______________", "");
|
||||
if (assignment.instructions) lines.push("Instructions: " + assignment.instructions, "");
|
||||
if (assignment.caseStudy) lines.push("— Read the following —", "", assignment.caseStudy, "");
|
||||
|
||||
assignment.questions.forEach((q, i) => {
|
||||
lines.push(questionStudentText(q, i + 1));
|
||||
if (teacher) {
|
||||
const ans = answersLine(q);
|
||||
if (ans) lines.push(" ✎ " + ans.replace(/\n/g, "\n "));
|
||||
if (q.explanation) lines.push(" ✎ Explanation: " + q.explanation);
|
||||
if (q.sourceRef) lines.push(" ✎ Source: \u201C" + q.sourceRef + "\u201D");
|
||||
if (q.verification?.status === "warn") lines.push(" ⚠ Reviewer note: " + (q.verification.note || "flagged — double-check"));
|
||||
}
|
||||
lines.push("");
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function schoolHeadHtml(profile = {}) {
|
||||
const teachLine = [profile.teacherName, profile.className].filter(Boolean).join(" — ");
|
||||
if (!profile.logo && !profile.schoolName && !teachLine) return "";
|
||||
return `<div class="schoolhead">
|
||||
${profile.logo ? `<img class="schoollogo" src="${profile.logo}" alt="">` : ""}
|
||||
<div class="schoolinfo">
|
||||
${profile.schoolName ? `<div class="schoolname">${esc(profile.schoolName)}</div>` : ""}
|
||||
${teachLine ? `<div class="teachline">${esc(teachLine)}</div>` : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildBodyHtml(assignment, { teacher, profile }) {
|
||||
const qs = assignment.questions || [];
|
||||
const qHtml = qs.map((q, i) => {
|
||||
const n = i + 1;
|
||||
let body = "";
|
||||
if (q.type === "multiple_choice") {
|
||||
body = `<div class="opts">` + (q.options || []).map((o, j) => {
|
||||
const isAns = teacher && j === q.correctIndex;
|
||||
return `<div${isAns ? ' class="ans"' : ""}>${LETTERS[j]}. ${esc(o)}${isAns ? " ✓" : ""}</div>`;
|
||||
}).join("") + `</div>`;
|
||||
} else if (q.type === "true_false") {
|
||||
body = `<div class="opts">True / False${teacher ? ` <span class="ans">✓ ${q.correctAnswer ? "True" : "False"}</span>` : ""}</div>`;
|
||||
} else if (q.type === "short_answer") {
|
||||
body = teacher ? "" : `<div class="writelines"></div><div class="writelines"></div>`;
|
||||
} else if (q.type === "essay" || q.type === "discussion") {
|
||||
body = "";
|
||||
} else if (q.type === "fill_blank") {
|
||||
body = "";
|
||||
} else if (q.type === "matching") {
|
||||
const rights = shuffledRight(q.pairs || []);
|
||||
body = `<table class="match"><tr><td>` +
|
||||
(q.pairs || []).map((p, j) => `<div>___ ${j + 1}. ${esc(p.left)}</div>`).join("") +
|
||||
`</td><td>` + rights.map((r, j) => `<div>${LETTERS[j]}. ${esc(r)}</div>`).join("") +
|
||||
`</td></tr></table>`;
|
||||
}
|
||||
|
||||
let key = "";
|
||||
if (teacher) {
|
||||
const ans = answersLine(q);
|
||||
key = `<div class="key">` +
|
||||
(ans ? `<div>${esc(ans).replace(/\n/g, "<br>")}</div>` : "") +
|
||||
(q.explanation ? `<div><b>Explanation:</b> ${esc(q.explanation)}</div>` : "") +
|
||||
(q.sourceRef ? `<div><b>Source:</b> \u201C${esc(q.sourceRef)}\u201D</div>` : "") +
|
||||
(q.verification?.status === "warn" ? `<div class="warnnote">⚠ Reviewer note: ${esc(q.verification.note || "flagged")}</div>` : "") +
|
||||
`</div>`;
|
||||
}
|
||||
const prompt = q.type === "matching" ? (q.question || "Match each item on the left with the correct item on the right.") : q.question;
|
||||
return `<div class="q"><p class="qp"><b>${n}.</b> ${esc(prompt)} <span class="pts">(${q.points} pt${q.points === 1 ? "" : "s"})</span></p>${body}${key}</div>`;
|
||||
}).join("");
|
||||
|
||||
return `${schoolHeadHtml(profile)}<h1>${esc(assignment.title)}</h1>
|
||||
<div class="meta">${esc([assignment.subject, assignment.gradeLevel].filter(Boolean).join(" · "))} · ${totalPoints(qs)} points</div>
|
||||
${teacher ? `<div class="teacherbar">Teacher version — answer key</div>` : `<div class="nameline">Name: ____________________________________ Date: ________________</div>`}
|
||||
${assignment.instructions ? `<p class="instr"><b>Instructions:</b> ${esc(assignment.instructions)}</p>` : ""}
|
||||
${assignment.caseStudy ? `<div class="case">${esc(assignment.caseStudy).replace(/\n/g, "<br>")}</div>` : ""}
|
||||
<hr>
|
||||
${qHtml}`;
|
||||
}
|
||||
|
||||
const PRINT_STYLES = `
|
||||
body { font-family: Georgia, "Times New Roman", serif; color: #1a1a1a; max-width: 7.2in; margin: 0 auto; padding: 24px; font-size: 12.5pt; line-height: 1.5; }
|
||||
h1 { font-size: 17pt; margin: 0 0 2px; }
|
||||
.meta { color: #555; font-size: 10.5pt; margin-bottom: 4px; }
|
||||
.teacherbar { color: #b8412f; font-weight: bold; font-size: 10.5pt; letter-spacing: 0.06em; text-transform: uppercase; border: 1.5pt solid #b8412f; display: inline-block; padding: 2px 8px; margin: 4px 0 10px; }
|
||||
.nameline { margin: 10px 0 14px; }
|
||||
.instr { margin: 0 0 14px; }
|
||||
.case { border: 1pt solid #999; padding: 12px 14px; margin: 0 0 16px; background: #fafafa; }
|
||||
.q { margin: 0 0 16px; page-break-inside: avoid; }
|
||||
.qp { margin: 0 0 4px; }
|
||||
.pts { color: #666; font-size: 10pt; }
|
||||
.opts { margin-left: 22px; }
|
||||
.opts div { margin: 2px 0; }
|
||||
.writelines { border-bottom: 1pt solid #888; height: 22px; margin: 8px 0 0 22px; }
|
||||
.match td { vertical-align: top; padding-right: 36px; }
|
||||
.match div { margin: 3px 0; }
|
||||
.ans { color: #b8412f; font-weight: bold; }
|
||||
.key { border-left: 2.5pt solid #b8412f; background: #fdf1ee; color: #7c2c1e; padding: 7px 11px; margin: 7px 0 0 22px; font-size: 11pt; }
|
||||
.key div { margin: 2px 0; }
|
||||
.warnnote { color: #8a6414; }
|
||||
hr { border: 0; border-top: 1pt solid #ccc; margin: 14px 0; }
|
||||
.schoolhead { display: flex; align-items: center; gap: 14px; border-bottom: 2pt solid #1a1a1a; padding-bottom: 9px; margin-bottom: 14px; }
|
||||
.schoollogo { height: 46pt; max-width: 130pt; object-fit: contain; flex: none; }
|
||||
.schoolname { font-size: 14pt; font-weight: bold; letter-spacing: 0.02em; }
|
||||
.teachline { font-size: 10.5pt; color: #444; }
|
||||
.pagebreak { page-break-after: always; break-after: page; }
|
||||
@media print { body { padding: 0; } }
|
||||
`;
|
||||
|
||||
// opts: { teacher } for a single version, or { packet: true } for the student
|
||||
// version followed by the answer key in one document (page break between).
|
||||
// { word: true } switches the page-break markup to the form Word understands.
|
||||
export function buildHtml(assignment, opts) {
|
||||
const profile = opts.profile || {};
|
||||
const sep = opts.word
|
||||
? `<br clear="all" style="page-break-before:always">`
|
||||
: `<div class="pagebreak"></div>`;
|
||||
const body = opts.packet
|
||||
? buildBodyHtml(assignment, { teacher: false, profile }) + sep + buildBodyHtml(assignment, { teacher: true, profile })
|
||||
: buildBodyHtml(assignment, { teacher: opts.teacher, profile });
|
||||
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(assignment.title)}</title>
|
||||
<style>${PRINT_STYLES}</style></head><body>
|
||||
${body}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function download(filename, blob) {
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 800);
|
||||
}
|
||||
|
||||
function safeName(title, suffix, ext) {
|
||||
const base = String(title || "assignment").replace(/[^\w\- ]+/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "assignment";
|
||||
return `${base}-${suffix}.${ext}`;
|
||||
}
|
||||
|
||||
function variantSuffix(opts) {
|
||||
return opts.packet ? "packet" : opts.teacher ? "answer-key" : "student";
|
||||
}
|
||||
|
||||
export function exportTxt(assignment, opts) {
|
||||
const text = buildText(assignment, opts);
|
||||
download(safeName(assignment.title, variantSuffix(opts), "txt"), new Blob([text], { type: "text/plain;charset=utf-8" }));
|
||||
}
|
||||
|
||||
export function exportDoc(assignment, opts) {
|
||||
const html = buildHtml(assignment, { ...opts, word: true });
|
||||
download(
|
||||
safeName(assignment.title, variantSuffix(opts), "doc"),
|
||||
new Blob(["\ufeff" + html], { type: "application/msword" })
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportClipboard(assignment, opts) {
|
||||
await navigator.clipboard.writeText(buildText(assignment, opts));
|
||||
}
|
||||
|
||||
export function exportPrint(assignment, opts) {
|
||||
const html = buildHtml(assignment, opts);
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) throw new Error("Your browser blocked the print window. Allow pop-ups for this site and try again.");
|
||||
w.document.open();
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
setTimeout(() => w.print(), 400);
|
||||
}
|
||||
67
lib/html-to-text.js
Normal file
67
lib/html-to-text.js
Normal file
@ -0,0 +1,67 @@
|
||||
// lib/html-to-text.js — turn a fetched web page into clean, LLM-friendly text.
|
||||
// Zero dependencies: pragmatic tag stripping + entity decoding.
|
||||
|
||||
const ENTITIES = {
|
||||
amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ",
|
||||
mdash: "—", ndash: "–", hellip: "…", rsquo: "'", lsquo: "'",
|
||||
rdquo: '"', ldquo: '"', copy: "©", reg: "®", trade: "™",
|
||||
deg: "°", frac12: "½", frac14: "¼", times: "×", divide: "÷",
|
||||
eacute: "é", egrave: "è", agrave: "à", ccedil: "ç", uuml: "ü", ouml: "ö", auml: "ä",
|
||||
};
|
||||
|
||||
function decodeEntities(s) {
|
||||
return s
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, h) => safeChar(parseInt(h, 16)))
|
||||
.replace(/&#(\d+);/g, (_, d) => safeChar(parseInt(d, 10)))
|
||||
.replace(/&([a-z]+);/gi, (m, name) => ENTITIES[name.toLowerCase()] ?? m);
|
||||
}
|
||||
|
||||
function safeChar(code) {
|
||||
try { return String.fromCodePoint(code); } catch { return ""; }
|
||||
}
|
||||
|
||||
export function extractTitle(html) {
|
||||
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (!m) return "";
|
||||
return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 200);
|
||||
}
|
||||
|
||||
export function htmlToText(html) {
|
||||
let s = String(html);
|
||||
|
||||
// Remove non-content blocks entirely
|
||||
s = s.replace(/<!--[\s\S]*?-->/g, " ");
|
||||
for (const tag of ["script", "style", "noscript", "svg", "iframe", "form", "nav", "footer", "header", "aside", "template", "button", "select"]) {
|
||||
s = s.replace(new RegExp(`<${tag}[\\s\\S]*?<\\/${tag}>`, "gi"), " ");
|
||||
}
|
||||
|
||||
// Preserve structure: headings, paragraphs, list items, table cells, breaks
|
||||
s = s.replace(/<\/(h[1-6])>/gi, "\n\n");
|
||||
s = s.replace(/<(h[1-6])[^>]*>/gi, "\n\n## ");
|
||||
s = s.replace(/<\/(p|div|section|article|blockquote|tr|table|ul|ol|figcaption)>/gi, "\n");
|
||||
s = s.replace(/<li[^>]*>/gi, "\n- ");
|
||||
s = s.replace(/<(td|th)[^>]*>/gi, " | ");
|
||||
s = s.replace(/<br\s*\/?>/gi, "\n");
|
||||
|
||||
// Strip all remaining tags
|
||||
s = s.replace(/<[^>]+>/g, " ");
|
||||
|
||||
s = decodeEntities(s);
|
||||
|
||||
// Normalize whitespace
|
||||
s = s.replace(/\r/g, "");
|
||||
s = s.replace(/[ \t]+/g, " ");
|
||||
s = s.replace(/ ?\n ?/g, "\n");
|
||||
s = s.replace(/\n{3,}/g, "\n\n");
|
||||
|
||||
// Drop very short junk lines (menus, single links) when the doc is large
|
||||
const lines = s.split("\n").map((l) => l.trim());
|
||||
const kept = [];
|
||||
for (const line of lines) {
|
||||
if (!line) { kept.push(""); continue; }
|
||||
if (line.length < 3 && !/^[-#\d]/.test(line)) continue;
|
||||
kept.push(line);
|
||||
}
|
||||
s = kept.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
||||
return s;
|
||||
}
|
||||
90
lib/json-utils.js
Normal file
90
lib/json-utils.js
Normal file
@ -0,0 +1,90 @@
|
||||
// lib/json-utils.js — tolerant JSON extraction for LLM responses.
|
||||
// Local models often wrap JSON in prose or code fences, or leave trailing commas.
|
||||
|
||||
export function extractJson(text) {
|
||||
if (text == null) throw new Error("Model returned an empty response.");
|
||||
let s = String(text).trim();
|
||||
|
||||
// Strip code fences anywhere
|
||||
s = s.replace(/```(?:json|javascript|js)?/gi, "```");
|
||||
const fenced = s.match(/```([\s\S]*?)```/);
|
||||
if (fenced && fenced[1].trim().match(/^[\[{]/)) s = fenced[1].trim();
|
||||
|
||||
// Slice from first brace/bracket to its matching end; try whichever starts first, first.
|
||||
const candidates = [];
|
||||
const firstObj = s.indexOf("{");
|
||||
const firstArr = s.indexOf("[");
|
||||
const objSlice = firstObj !== -1 ? s.slice(firstObj, s.lastIndexOf("}") + 1) : null;
|
||||
const arrSlice = firstArr !== -1 ? s.slice(firstArr, s.lastIndexOf("]") + 1) : null;
|
||||
if (firstArr !== -1 && (firstObj === -1 || firstArr < firstObj)) {
|
||||
if (arrSlice) candidates.push(arrSlice);
|
||||
if (objSlice) candidates.push(objSlice);
|
||||
} else {
|
||||
if (objSlice) candidates.push(objSlice);
|
||||
if (arrSlice) candidates.push(arrSlice);
|
||||
}
|
||||
candidates.unshift(s);
|
||||
|
||||
let lastErr = null;
|
||||
for (const c of candidates) {
|
||||
if (!c) continue;
|
||||
for (const attempt of [c, repair(c)]) {
|
||||
try {
|
||||
return JSON.parse(attempt);
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
const preview = s.slice(0, 300).replace(/\s+/g, " ");
|
||||
throw new Error(
|
||||
"Could not read the model's response as JSON. Try again, lower the temperature, or use a stronger model. Response began: " + preview
|
||||
);
|
||||
}
|
||||
|
||||
function repair(s) {
|
||||
let out = s;
|
||||
// Smart quotes -> straight quotes
|
||||
out = out.replace(/[\u201C\u201D]/g, '"').replace(/[\u2018\u2019]/g, "'");
|
||||
// Remove trailing commas before } or ]
|
||||
out = out.replace(/,\s*([}\]])/g, "$1");
|
||||
// Remove JS-style comments
|
||||
out = out.replace(/^\s*\/\/.*$/gm, "");
|
||||
// Control characters inside strings break JSON.parse; replace bare newlines in strings
|
||||
out = sanitizeNewlinesInStrings(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeNewlinesInStrings(s) {
|
||||
let result = "";
|
||||
let inStr = false;
|
||||
let escaped = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inStr) {
|
||||
if (escaped) {
|
||||
result += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
result += ch;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inStr = false;
|
||||
result += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\n") { result += "\\n"; continue; }
|
||||
if (ch === "\r") { continue; }
|
||||
if (ch === "\t") { result += "\\t"; continue; }
|
||||
result += ch;
|
||||
} else {
|
||||
if (ch === '"') inStr = true;
|
||||
result += ch;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
195
lib/model-caps.js
Normal file
195
lib/model-caps.js
Normal file
@ -0,0 +1,195 @@
|
||||
// lib/model-caps.js — sizes generation defaults to the selected model.
|
||||
//
|
||||
// "Auto" mode (generation.auto, on by default) asks the provider for the
|
||||
// model's real limits and budgets maxTokens / maxSourceChars to fit:
|
||||
// Ollama POST /api/show -> model_info.<arch>.context_length
|
||||
// LM Studio GET /api/v0/models -> loaded_context_length / max_context_length
|
||||
// Anthropic GET /v1/models/{id} -> max_input_tokens, max_tokens
|
||||
// Google GET /v1beta/models/{id} -> inputTokenLimit, outputTokenLimit
|
||||
// OpenAI (no limits API) -> pattern table below
|
||||
// Lookups are cached in memory and every failure falls back to safe defaults,
|
||||
// so generation never breaks because a limits lookup did.
|
||||
|
||||
const VERSION_HEADER = { "anthropic-version": "2023-06-01" };
|
||||
|
||||
// English prose averages ~4 chars/token; 3.5 leaves margin for dense text.
|
||||
const CHARS_PER_TOKEN = 3.5;
|
||||
|
||||
const CAPS_TTL_OK = 5 * 60 * 1000;
|
||||
const CAPS_TTL_FAIL = 30 * 1000;
|
||||
const capsCache = new Map(); // "provider|baseUrl|model" -> { caps, at }
|
||||
|
||||
const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio"]);
|
||||
|
||||
// Used when the provider can't be asked (server down, no key, unknown model).
|
||||
const FALLBACK_CAPS = {
|
||||
ollama: { contextTokens: 8192, maxOutputTokens: null },
|
||||
lmstudio: { contextTokens: 8192, maxOutputTokens: null },
|
||||
openai: { contextTokens: 128000, maxOutputTokens: 16384 },
|
||||
anthropic: { contextTokens: 200000, maxOutputTokens: 8192 },
|
||||
google: { contextTokens: 1000000, maxOutputTokens: 8192 },
|
||||
};
|
||||
|
||||
function cleanBase(url, fallback) {
|
||||
let b = (url || fallback || "").trim();
|
||||
if (!b) return fallback;
|
||||
return b.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function clamp(n, lo, hi) {
|
||||
return Math.min(hi, Math.max(lo, n));
|
||||
}
|
||||
|
||||
async function fetchJson(url, init = {}) {
|
||||
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function ollamaCaps(cfg) {
|
||||
const base = cleanBase(cfg.baseUrl, "http://localhost:11434");
|
||||
const data = await fetchJson(base + "/api/show", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: cfg.model }),
|
||||
});
|
||||
const info = data?.model_info || {};
|
||||
const arch = info["general.architecture"];
|
||||
let ctx = Number(arch ? info[`${arch}.context_length`] : 0) || 0;
|
||||
if (!ctx) {
|
||||
const key = Object.keys(info).find((k) => k.endsWith(".context_length"));
|
||||
ctx = Number(key ? info[key] : 0) || 0;
|
||||
}
|
||||
// A num_ctx in the Modelfile is a deliberate (often memory-driven) cap — honor it.
|
||||
const numCtx = Number((String(data?.parameters || "").match(/^num_ctx\s+(\d+)/m) || [])[1] || 0);
|
||||
if (numCtx) ctx = ctx ? Math.min(ctx, numCtx) : numCtx;
|
||||
if (!ctx) throw new Error("no context_length in /api/show response");
|
||||
return { contextTokens: ctx, maxOutputTokens: null };
|
||||
}
|
||||
|
||||
async function lmstudioCaps(cfg) {
|
||||
const base = cleanBase(cfg.baseUrl, "http://localhost:1234");
|
||||
const data = await fetchJson(base + "/api/v0/models");
|
||||
const m = (data?.data || []).find((x) => x.id === cfg.model);
|
||||
if (!m) throw new Error("model not in /api/v0/models");
|
||||
// loaded_context_length is what the server actually honors; a not-yet-loaded
|
||||
// model JIT-loads at its configured default, so stay conservative there.
|
||||
const ctx =
|
||||
Number(m.loaded_context_length) ||
|
||||
Math.min(Number(m.max_context_length) || 8192, 8192);
|
||||
return { contextTokens: ctx, maxOutputTokens: null };
|
||||
}
|
||||
|
||||
async function anthropicCaps(cfg) {
|
||||
if (!cfg.apiKey) throw new Error("no API key");
|
||||
const data = await fetchJson(
|
||||
`https://api.anthropic.com/v1/models/${encodeURIComponent(cfg.model)}`,
|
||||
{ headers: { "x-api-key": cfg.apiKey, ...VERSION_HEADER } }
|
||||
);
|
||||
return {
|
||||
contextTokens: Number(data?.max_input_tokens) || 200000,
|
||||
maxOutputTokens: Number(data?.max_tokens) || 8192,
|
||||
};
|
||||
}
|
||||
|
||||
async function googleCaps(cfg) {
|
||||
if (!cfg.apiKey) throw new Error("no API key");
|
||||
const data = await fetchJson(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}?key=${encodeURIComponent(cfg.apiKey)}`
|
||||
);
|
||||
return {
|
||||
contextTokens: Number(data?.inputTokenLimit) || 1000000,
|
||||
maxOutputTokens: Number(data?.outputTokenLimit) || 8192,
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI's models API doesn't report limits, so match on the model name.
|
||||
function openaiCaps(cfg) {
|
||||
const m = String(cfg.model || "").toLowerCase();
|
||||
const pick = (contextTokens, maxOutputTokens) => ({ contextTokens, maxOutputTokens });
|
||||
if (/^o\d/.test(m)) return pick(200000, 100000);
|
||||
if (m.includes("gpt-5")) return pick(272000, 128000);
|
||||
if (m.includes("gpt-4.1")) return pick(1000000, 32768);
|
||||
if (m.includes("gpt-4o") || m.includes("chatgpt-4o")) return pick(128000, 16384);
|
||||
if (m.includes("gpt-4-turbo")) return pick(128000, 4096);
|
||||
if (m.includes("gpt-4-32k")) return pick(32768, 8192);
|
||||
if (m.includes("gpt-4")) return pick(8192, 4096);
|
||||
if (m.includes("gpt-3.5")) return pick(16385, 4096);
|
||||
return pick(128000, 16384);
|
||||
}
|
||||
|
||||
// getModelCaps(settings) -> { contextTokens, maxOutputTokens|null, source }
|
||||
// source: "live" (asked the provider) | "catalog" (pattern table) | "fallback"
|
||||
export async function getModelCaps(settings) {
|
||||
const provider = settings.provider;
|
||||
const cfg = settings.providers?.[provider] || {};
|
||||
const fallback = { ...(FALLBACK_CAPS[provider] || FALLBACK_CAPS.openai), source: "fallback" };
|
||||
if (!cfg.model) return fallback;
|
||||
|
||||
// Key length is included so a cached "no key" fallback doesn't mask a freshly added key.
|
||||
const key = `${provider}|${cfg.baseUrl || ""}|${cfg.model}|${(cfg.apiKey || "").length}`;
|
||||
const hit = capsCache.get(key);
|
||||
if (hit && Date.now() - hit.at < (hit.caps.source === "fallback" ? CAPS_TTL_FAIL : CAPS_TTL_OK)) {
|
||||
return hit.caps;
|
||||
}
|
||||
|
||||
let caps;
|
||||
try {
|
||||
if (provider === "ollama") caps = { ...(await ollamaCaps(cfg)), source: "live" };
|
||||
else if (provider === "lmstudio") caps = { ...(await lmstudioCaps(cfg)), source: "live" };
|
||||
else if (provider === "anthropic") caps = { ...(await anthropicCaps(cfg)), source: "live" };
|
||||
else if (provider === "google") caps = { ...(await googleCaps(cfg)), source: "live" };
|
||||
else if (provider === "openai") caps = { ...openaiCaps(cfg), source: "catalog" };
|
||||
else caps = fallback;
|
||||
} catch {
|
||||
caps = fallback;
|
||||
}
|
||||
capsCache.set(key, { caps, at: Date.now() });
|
||||
return caps;
|
||||
}
|
||||
|
||||
function autoDefaults(caps, provider) {
|
||||
const ctx = caps.contextTokens;
|
||||
let maxTokens, overhead, sourceCap;
|
||||
if (LOCAL_PROVIDERS.has(provider)) {
|
||||
// Local models: spend at most a quarter of the window on the response and
|
||||
// keep the source moderate — small models lose accuracy when drowned in
|
||||
// text, and a bigger window costs RAM on the user's machine.
|
||||
maxTokens = clamp(Math.floor(ctx / 4), 2000, 8000);
|
||||
overhead = 3500; // system prompt + instructions + question list during verification
|
||||
sourceCap = 32000;
|
||||
} else {
|
||||
// Cloud models: generous response budget (long assignments with rubrics)
|
||||
// and room for much longer source material.
|
||||
maxTokens = Math.min(caps.maxOutputTokens || 16000, 16000);
|
||||
overhead = 3000;
|
||||
sourceCap = 120000;
|
||||
}
|
||||
const sourceTokens = Math.max(1200, ctx - maxTokens - overhead);
|
||||
const maxSourceChars = clamp(
|
||||
Math.floor((sourceTokens * CHARS_PER_TOKEN) / 1000) * 1000,
|
||||
4000,
|
||||
sourceCap
|
||||
);
|
||||
return { maxTokens, maxSourceChars };
|
||||
}
|
||||
|
||||
// resolveGeneration(settings) -> { auto, temperature, maxTokens, maxSourceChars, caps }
|
||||
// In auto mode the limits are computed from the model's capabilities; in
|
||||
// manual mode the user's stored values pass through. Never throws.
|
||||
export async function resolveGeneration(settings) {
|
||||
const gen = settings.generation || {};
|
||||
const caps = await getModelCaps(settings);
|
||||
const auto = gen.auto !== false;
|
||||
const temperature = gen.temperature ?? 0.3;
|
||||
if (!auto) {
|
||||
return {
|
||||
auto,
|
||||
temperature,
|
||||
maxTokens: gen.maxTokens ?? 8000,
|
||||
maxSourceChars: gen.maxSourceChars ?? 24000,
|
||||
caps,
|
||||
};
|
||||
}
|
||||
return { auto, temperature, ...autoDefaults(caps, settings.provider), caps };
|
||||
}
|
||||
278
lib/prompts.js
Normal file
278
lib/prompts.js
Normal file
@ -0,0 +1,278 @@
|
||||
// lib/prompts.js — the accuracy core.
|
||||
// Three-stage pipeline: ANALYZE the source -> GENERATE grounded questions -> VERIFY each one.
|
||||
// Every prompt enforces one rule above all: nothing may be asked or answered
|
||||
// that is not supported by the provided source material.
|
||||
|
||||
function truncateSource(source, maxChars) {
|
||||
const s = String(source || "").trim();
|
||||
if (s.length <= maxChars) return { text: s, truncated: false };
|
||||
// Keep the beginning (usually the core content) plus the tail for conclusions.
|
||||
const head = s.slice(0, Math.floor(maxChars * 0.8));
|
||||
const tail = s.slice(-Math.floor(maxChars * 0.15));
|
||||
return { text: head + "\n\n[... middle of source omitted for length ...]\n\n" + tail, truncated: true };
|
||||
}
|
||||
|
||||
function gradeGuidance(gradeLevel) {
|
||||
return `Write all question text, options, and answer-key material at a reading level appropriate for ${gradeLevel}. Use vocabulary and sentence length that students at this level can read independently. Do not simplify the underlying ideas below what the source supports — adjust the language, not the rigor.`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grade-calibrated difficulty.
|
||||
// Difficulty is always RELATIVE to the grade: "Easy" is the floor of that
|
||||
// grade's ability band and "Hard" is its ceiling, and the whole band slides
|
||||
// upward with grade — a Hard question for Grade 1 should be trivial for a
|
||||
// 12th grader, while a Grade 12 Easy question should out-demand a Grade 1
|
||||
// Hard one. This is what lets one assignment separate struggling, proficient,
|
||||
// and advanced students at any grade.
|
||||
// ---------------------------------------------------------------------------
|
||||
function gradeIndex(gradeLevel) {
|
||||
const g = String(gradeLevel || "").toLowerCase();
|
||||
if (g.includes("kinder")) return 0;
|
||||
const m = g.match(/(\d+)/);
|
||||
if (m) return Math.min(12, Math.max(1, Number(m[1])));
|
||||
if (g.includes("college")) return g.includes("adv") ? 14 : 13;
|
||||
if (g.includes("adult")) return 13;
|
||||
return 8;
|
||||
}
|
||||
|
||||
const RIGOR_BANDS = [
|
||||
{
|
||||
max: 2,
|
||||
expect: "single-step thinking about concrete facts stated plainly in the source",
|
||||
floor: "recalling one clearly stated fact in simple words",
|
||||
ceiling: "connecting two stated facts, putting events in order, or explaining why something happened when the source says so",
|
||||
},
|
||||
{
|
||||
max: 5,
|
||||
expect: "concrete reasoning with beginning inference",
|
||||
floor: "recalling a specific fact, term, or definition from the source",
|
||||
ceiling: "explaining cause and effect, comparing two ideas, or drawing a one-step inference that combines different parts of the source",
|
||||
},
|
||||
{
|
||||
max: 8,
|
||||
expect: "abstract concepts, multi-step reasoning, and real subject vocabulary",
|
||||
floor: "accurate recall of specific facts and subject vocabulary",
|
||||
ceiling: "multi-step inference, applying a concept from the source to a new example, or simple quantitative reasoning when the source includes numbers",
|
||||
},
|
||||
{
|
||||
max: 12,
|
||||
expect: "disciplinary thinking: analysis, evaluation, application, and technical vocabulary used correctly",
|
||||
floor: "command of core concepts and technical vocabulary — not isolated trivia a younger student could guess",
|
||||
ceiling: "synthesizing several parts of the source, applying concepts to unfamiliar scenarios, evaluating trade-offs, and multi-step quantitative problems whenever the source provides numbers, formulas, or processes",
|
||||
},
|
||||
{
|
||||
max: 99,
|
||||
expect: "rigorous, discipline-appropriate reasoning at college level",
|
||||
floor: "precise command of the source's technical concepts and terminology",
|
||||
ceiling: "critical evaluation, synthesis across the entire source, and demanding application problems — quantitative wherever the source supports it",
|
||||
},
|
||||
];
|
||||
|
||||
const DIFFICULTY_TARGETS = {
|
||||
Easy: "Keep every question near the FLOOR of this band — but never below it.",
|
||||
Medium: "Keep questions in the middle of this band: clearly beyond the floor, short of the ceiling.",
|
||||
Hard: "Push every question to the CEILING of this band. No pure-recall items — every question should make even the strongest students think.",
|
||||
Mixed: "Spread questions across the full band — roughly 1/3 near the floor, 1/3 mid-band, 1/3 at the ceiling — ordered easier to harder, so results separate struggling, proficient, and advanced students.",
|
||||
};
|
||||
|
||||
function rigorGuidance(gradeLevel, difficulty) {
|
||||
const i = gradeIndex(gradeLevel);
|
||||
const band = RIGOR_BANDS.find((b) => i <= b.max);
|
||||
const target = DIFFICULTY_TARGETS[difficulty] || DIFFICULTY_TARGETS.Mixed;
|
||||
const belowCheck = i >= 3
|
||||
? `\nIf a typical ${i >= 13 ? "high-school student" : "student two or three grades below"} could answer a question without studying this source, it is below the band — rewrite it harder.`
|
||||
: "";
|
||||
return `DIFFICULTY CALIBRATION — all difficulty is RELATIVE TO ${gradeLevel}:
|
||||
Students at this level handle ${band.expect}.
|
||||
- FLOOR of the band ("easy" at this grade) = ${band.floor}. Nearly every ${gradeLevel} student who studied the source should get floor questions right.
|
||||
- CEILING of the band ("hard" at this grade) = ${band.ceiling}. Only the strongest ${gradeLevel} students should get ceiling questions right — while remaining fully answerable from the source alone.${belowCheck}
|
||||
When the source contains numbers, formulas, processes, or worked examples, ceiling questions must make students USE them (compute, predict, apply, troubleshoot) — not merely recall them.
|
||||
TARGET FOR THIS ASSIGNMENT (${difficulty || "Mixed"} difficulty): ${target}`;
|
||||
}
|
||||
|
||||
const ACCURACY_RULES = `ACCURACY RULES (these override everything else):
|
||||
1. Ground every question in the SOURCE MATERIAL only. Never use outside knowledge to add facts, dates, names, numbers, or claims that do not appear in the source.
|
||||
2. Every answer in the answer key must be verifiably correct according to the source. If you are not certain the source supports an answer, do not write that question.
|
||||
3. Each question must have exactly one defensible correct answer (essays and discussion prompts excepted). No trick questions, no "all of the above", no double negatives.
|
||||
4. For every question, include a "sourceRef": a short quote or close paraphrase (under 25 words) of the exact place in the source that proves the correct answer.
|
||||
5. If the source does not contain enough distinct material for the requested number of questions, write fewer questions rather than inventing content. Never pad with made-up facts.
|
||||
6. Multiple-choice distractors must be plausible to a student who skimmed, but unambiguously wrong according to the source. Distractors must be about the same length and grammatical form as the correct answer. Vary the position of the correct answer across questions.
|
||||
7. Fill-in-the-blank answers must be specific words or short phrases taken from the source, with exactly one sensible answer per blank. Mark each blank as ______ (six underscores).
|
||||
8. True/false statements must be clearly and entirely true or entirely false per the source — never half-true.`;
|
||||
|
||||
const JSON_RULES = `OUTPUT FORMAT:
|
||||
Respond with ONLY a single valid JSON object. No markdown, no code fences, no commentary before or after. Use double quotes for all strings. Escape internal quotes and newlines properly.`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 1 — ANALYZE
|
||||
// ---------------------------------------------------------------------------
|
||||
export function analyzePrompt({ source, config, maxSourceChars }) {
|
||||
const { text, truncated } = truncateSource(source, maxSourceChars);
|
||||
const system = `You are an expert curriculum analyst. You read source material carefully and map exactly what it teaches, so that assessment questions can be grounded in it. You never invent content that is not in the source. ${JSON_RULES}`;
|
||||
const user = `Analyze the following source material for a ${config.gradeLevel} ${config.subject} ${config.assignmentType.replace("_", " ")}.
|
||||
${truncated ? "(Note: the middle of a long source was omitted; analyze what is present.)" : ""}
|
||||
|
||||
Return this JSON shape:
|
||||
{
|
||||
"summary": "2-3 sentence summary of what the source covers",
|
||||
"keyConcepts": ["the 5-12 most important concepts/ideas, most important first"],
|
||||
"keyFacts": ["8-20 specific, testable facts stated in the source (names, definitions, causes, numbers, sequences)"],
|
||||
"vocabulary": ["important terms a ${config.gradeLevel} student should know from this source"],
|
||||
"sufficientFor": <honest integer: how many distinct, non-overlapping questions this source can support>
|
||||
}
|
||||
|
||||
SOURCE MATERIAL:
|
||||
<<<
|
||||
${text}
|
||||
>>>`;
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 2 — GENERATE
|
||||
// ---------------------------------------------------------------------------
|
||||
const TYPE_SCHEMAS = `Question object shapes by "type":
|
||||
- "multiple_choice": {"type":"multiple_choice","question":"...","options":["A text","B text","C text","D text"],"correctIndex":0,"explanation":"why the answer is correct AND why each distractor is wrong","sourceRef":"...","points":2}
|
||||
- "true_false": {"type":"true_false","question":"statement to evaluate","correctAnswer":true,"explanation":"...","sourceRef":"...","points":1}
|
||||
- "short_answer": {"type":"short_answer","question":"...","sampleAnswer":"a model answer in 1-3 sentences","keyPoints":["points a correct answer must include"],"explanation":"grading guidance","sourceRef":"...","points":3}
|
||||
- "essay": {"type":"essay","question":"...","sampleResponse":"a strong model response (1-2 paragraphs or a detailed outline)","rubric":[{"criterion":"Thesis & focus","points":3,"description":"..."},{"criterion":"Use of evidence from the text","points":4,"description":"..."},{"criterion":"Organization & clarity","points":3,"description":"..."}],"sourceRef":"...","points":10}
|
||||
- "fill_blank": {"type":"fill_blank","question":"Sentence with ______ for each blank.","answers":["answer for blank 1"],"explanation":"...","sourceRef":"...","points":2}
|
||||
- "matching": {"type":"matching","question":"Match each item on the left with the correct item on the right.","pairs":[{"left":"term","right":"definition"}],"explanation":"...","sourceRef":"...","points":<number of pairs>}
|
||||
- "discussion": {"type":"discussion","question":"open-ended discussion prompt","talkingPoints":["key themes a good discussion should surface"],"followUps":["1-3 follow-up questions to deepen the discussion"],"sampleResponse":"what a thoughtful contribution sounds like","sourceRef":"...","points":5}`;
|
||||
|
||||
export function generatePrompt({ source, analysis, config, maxSourceChars }) {
|
||||
const { text, truncated } = truncateSource(source, maxSourceChars);
|
||||
const isDiscussion = config.assignmentType === "discussion";
|
||||
const isCaseStudy = config.assignmentType === "case_study";
|
||||
|
||||
let typeInstructions;
|
||||
if (isDiscussion) {
|
||||
typeInstructions = `All questions must be of type "discussion". Write open-ended prompts that invite multiple defensible positions, but anchor each prompt in specific content from the source (name the concept, event, or passage being discussed).`;
|
||||
} else if (isCaseStudy) {
|
||||
typeInstructions = `First write a "caseStudy": a realistic, self-contained scenario of 250-500 words (appropriate for ${config.gradeLevel}) that applies the concepts in the source to a concrete situation with named characters or organizations. The scenario must only use ideas, mechanisms, and facts supported by the source — the situation is invented, the underlying content is not.
|
||||
Then write the questions ABOUT the scenario, using types "short_answer" and "essay" (and "discussion" if appropriate). Each question should require applying concepts from the source to the scenario. The sourceRef for each question should point to the source concept being applied.`;
|
||||
} else {
|
||||
const allowed = config.questionTypes && config.questionTypes.length ? config.questionTypes : ["multiple_choice", "true_false", "short_answer", "essay", "fill_blank", "matching"];
|
||||
typeInstructions = `Use ONLY these question types: ${allowed.join(", ")}. Choose a sensible mix for a ${config.assignmentType} (e.g., quizzes lean on quick-check items; tests mix recall with deeper items; worksheets favor practice items like fill_blank and short_answer). Include at most one "matching" question and at most ${config.questionCount >= 10 ? 2 : 1} "essay" question(s) unless only those types are allowed.`;
|
||||
}
|
||||
|
||||
const system = `You are a master teacher and assessment writer with 20 years of experience writing ${config.gradeLevel} ${config.subject} materials. Your assessments are known for being scrupulously accurate to the source text, unambiguous, and pitched perfectly to the grade level.
|
||||
|
||||
${ACCURACY_RULES}
|
||||
|
||||
${JSON_RULES}`;
|
||||
|
||||
const user = `Create a ${config.assignmentType.replace("_", " ")} for ${config.gradeLevel} ${config.subject}, based strictly on the source material below.
|
||||
|
||||
REQUIREMENTS:
|
||||
- Number of questions: ${config.questionCount}${analysis?.sufficientFor ? ` (the source supports about ${analysis.sufficientFor}; if that is fewer, write fewer — never invent)` : ""}
|
||||
- ${gradeGuidance(config.gradeLevel)}
|
||||
- ${typeInstructions}
|
||||
- ${config.includeExplanations ? "Include a clear, teacher-facing explanation for every question." : 'Keep "explanation" very brief (one sentence).'}
|
||||
- ${config.includeRubrics ? "Include a point-based rubric for every essay question (criteria should sum to the question's points)." : "Rubrics optional."}
|
||||
- Write concise student-facing instructions for the whole assignment (1-3 sentences, ${config.gradeLevel} reading level).
|
||||
- Give the assignment a clear, specific title that names the actual topic (not the word "assignment").
|
||||
${config.focusNote ? `- Teacher's focus request: ${config.focusNote}` : ""}
|
||||
|
||||
${rigorGuidance(config.gradeLevel, config.difficulty)}
|
||||
|
||||
${analysis ? `CONTENT MAP (from your earlier analysis — cover the most important concepts first, avoid asking two questions about the same fact):
|
||||
${JSON.stringify({ keyConcepts: analysis.keyConcepts, keyFacts: analysis.keyFacts, vocabulary: analysis.vocabulary }, null, 1)}` : ""}
|
||||
|
||||
${TYPE_SCHEMAS}
|
||||
|
||||
Return this JSON shape:
|
||||
{
|
||||
"title": "...",
|
||||
"instructions": "...",
|
||||
${isCaseStudy ? '"caseStudy": "the 250-500 word scenario",' : ""}
|
||||
"questions": [ ...question objects, in the order students should see them... ]
|
||||
}
|
||||
|
||||
SOURCE MATERIAL:
|
||||
<<<
|
||||
${text}
|
||||
>>>
|
||||
${truncated ? "(Note: the middle of a long source was omitted. Only write questions about content you can actually see.)" : ""}`;
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 3 — VERIFY
|
||||
// ---------------------------------------------------------------------------
|
||||
export function verifyPrompt({ source, questions, config, maxSourceChars }) {
|
||||
const { text } = truncateSource(source, maxSourceChars);
|
||||
const compact = questions.map((q) => {
|
||||
const base = { id: q.id, type: q.type, question: q.question, points: q.points };
|
||||
if (q.type === "multiple_choice") return { ...base, options: q.options, correctIndex: q.correctIndex };
|
||||
if (q.type === "true_false") return { ...base, correctAnswer: q.correctAnswer };
|
||||
if (q.type === "short_answer") return { ...base, sampleAnswer: q.sampleAnswer, keyPoints: q.keyPoints };
|
||||
if (q.type === "essay") return { ...base, sampleResponse: (q.sampleResponse || "").slice(0, 400) };
|
||||
if (q.type === "fill_blank") return { ...base, answers: q.answers };
|
||||
if (q.type === "matching") return { ...base, pairs: q.pairs };
|
||||
if (q.type === "discussion") return { ...base, talkingPoints: q.talkingPoints };
|
||||
return base;
|
||||
});
|
||||
|
||||
const system = `You are a skeptical assessment editor reviewing a ${config.gradeLevel} ${config.subject} ${config.assignmentType.replace("_", " ")} before it goes to students. Your only loyalty is to accuracy. You check every question against the source material and you are not afraid to flag problems. ${JSON_RULES}`;
|
||||
|
||||
const user = `Review each question below against the SOURCE MATERIAL. For each one, check:
|
||||
A. CORRECTNESS — Is the keyed answer actually correct according to the source (not according to general knowledge)?
|
||||
B. GROUNDING — Is the question answerable from the source alone? Flag anything that relies on outside facts.
|
||||
C. SINGLE ANSWER — Could a knowledgeable student defend a different answer? Are any multiple-choice distractors arguably also correct?
|
||||
D. CLARITY — Is the wording unambiguous and readable at a ${config.gradeLevel} level?
|
||||
E. MECHANICS — For fill_blank: does each blank have exactly one sensible answer? For matching: is every pairing unambiguous?
|
||||
F. RIGOR — Is the question pitched at a ${config.gradeLevel} level? Flag any question so simple that a student several grades below could answer it without studying the source.
|
||||
|
||||
Be strict. A question only gets "pass" if it clears all six checks.
|
||||
|
||||
Return this JSON shape:
|
||||
{
|
||||
"results": [
|
||||
{"id": "<question id>", "verdict": "pass" | "warn", "issue": "empty string if pass; otherwise a one-sentence description of the problem", "suggestedFix": "empty string, or a concrete suggested correction"}
|
||||
]
|
||||
}
|
||||
Include every question id exactly once.
|
||||
|
||||
QUESTIONS:
|
||||
${JSON.stringify(compact, null, 1)}
|
||||
|
||||
SOURCE MATERIAL:
|
||||
<<<
|
||||
${text}
|
||||
>>>`;
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single-question regenerate / add
|
||||
// ---------------------------------------------------------------------------
|
||||
export function questionPrompt({ source, config, existingQuestions, type, note, replacing, maxSourceChars }) {
|
||||
const { text } = truncateSource(source, maxSourceChars);
|
||||
const existing = (existingQuestions || []).map((q) => q.question).filter(Boolean);
|
||||
|
||||
const system = `You are a master teacher writing one assessment question for a ${config.gradeLevel} ${config.subject} ${String(config.assignmentType || "quiz").replace("_", " ")}.
|
||||
|
||||
${ACCURACY_RULES}
|
||||
|
||||
${JSON_RULES}`;
|
||||
|
||||
const user = `Write exactly ONE new question of type "${type}", grounded strictly in the source material below.
|
||||
- ${gradeGuidance(config.gradeLevel)}
|
||||
- ${rigorGuidance(config.gradeLevel, config.difficulty).replace(/\n/g, "\n ")}
|
||||
- It must not duplicate or closely overlap any of these existing questions:
|
||||
${existing.length ? existing.map((q, i) => ` ${i + 1}. ${q}`).join("\n") : " (none)"}
|
||||
${replacing ? `- It REPLACES this question, so cover a similar concept unless the teacher's note says otherwise: "${replacing.question}"` : ""}
|
||||
${note ? `- Teacher's note (follow it): ${note}` : ""}
|
||||
- Include a teacher-facing "explanation" and a "sourceRef" (short quote from the source proving the answer).
|
||||
|
||||
${TYPE_SCHEMAS}
|
||||
|
||||
Return ONLY the single question JSON object (not wrapped in an array or any other object).
|
||||
|
||||
SOURCE MATERIAL:
|
||||
<<<
|
||||
${text}
|
||||
>>>`;
|
||||
return { system, user };
|
||||
}
|
||||
423
lib/providers.js
Normal file
423
lib/providers.js
Normal file
@ -0,0 +1,423 @@
|
||||
// lib/providers.js — one interface, five backends.
|
||||
// All calls are made server-side (no CORS issues, keys never touch the browser).
|
||||
import { resolveGeneration } from "./model-caps";
|
||||
|
||||
const VERSION_HEADER = { "anthropic-version": "2023-06-01" };
|
||||
|
||||
function cleanBase(url, fallback) {
|
||||
let b = (url || fallback || "").trim();
|
||||
if (!b) return fallback;
|
||||
return b.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function readError(res) {
|
||||
let detail = "";
|
||||
try {
|
||||
const j = await res.json();
|
||||
detail = j?.error?.message || j?.error || j?.message || JSON.stringify(j).slice(0, 200);
|
||||
} catch {
|
||||
try { detail = (await res.text()).slice(0, 200); } catch {}
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function friendlyConnError(provider, base, err) {
|
||||
const msg = String(err?.message || err);
|
||||
const cause = String(err?.cause?.code || err?.cause?.message || "");
|
||||
// Node's fetch aborts requests that go quiet too long; without this check a
|
||||
// slow (but healthy) local model gets misreported as "not running".
|
||||
if (/timeout/i.test(msg + " " + cause)) {
|
||||
return new Error(
|
||||
`${providerLabel(provider)} took too long to respond and the connection timed out. The model may still be working — for local models, try a smaller/faster model or wait and retry.`
|
||||
);
|
||||
}
|
||||
if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|aborted|network|terminated|socket/i.test(msg + " " + cause)) {
|
||||
if (provider === "ollama")
|
||||
return new Error(`Could not reach Ollama at ${base}. Make sure Ollama is running (open the Ollama app, or run \`ollama serve\`).`);
|
||||
if (provider === "lmstudio")
|
||||
return new Error(`Could not reach LM Studio at ${base}. In LM Studio, open the Developer tab and start the local server.`);
|
||||
return new Error(`Could not reach the ${provider} API. Check your internet connection. (${msg})`);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// chat(settings, opts) -> string
|
||||
// opts: { system, user, temperature, maxTokens, expectJson }
|
||||
// ----------------------------------------------------------------------------
|
||||
export async function chat(settings, opts) {
|
||||
const provider = settings.provider;
|
||||
const cfg = settings.providers?.[provider] || {};
|
||||
const { system = "", user = "", expectJson = false, signal } = opts;
|
||||
|
||||
if (!cfg.model) {
|
||||
throw new Error(`No model selected for ${providerLabel(provider)}. Open Settings, pick a model, and save.`);
|
||||
}
|
||||
|
||||
// Per-model defaults: auto mode sizes these to the model's real limits.
|
||||
const resolved = await resolveGeneration(settings);
|
||||
const temperature = opts.temperature ?? resolved.temperature;
|
||||
let maxTokens = opts.maxTokens ?? resolved.maxTokens;
|
||||
if (resolved.caps.maxOutputTokens) maxTokens = Math.min(maxTokens, resolved.caps.maxOutputTokens);
|
||||
|
||||
switch (provider) {
|
||||
case "ollama":
|
||||
return ollamaChat(cfg, {
|
||||
system, user, temperature, maxTokens, expectJson, signal,
|
||||
// Only size num_ctx when we actually know the model's window.
|
||||
contextTokens: resolved.caps.source === "live" ? resolved.caps.contextTokens : 0,
|
||||
});
|
||||
case "lmstudio":
|
||||
return openaiCompatChat("lmstudio", cleanBase(cfg.baseUrl, "http://localhost:1234") + "/v1", null, cfg.model, { system, user, temperature, maxTokens, expectJson, signal });
|
||||
case "openai":
|
||||
requireKey(cfg, "OpenAI");
|
||||
return openaiCompatChat("openai", "https://api.openai.com/v1", cfg.apiKey, cfg.model, { system, user, temperature, maxTokens, expectJson, signal });
|
||||
case "anthropic":
|
||||
requireKey(cfg, "Anthropic");
|
||||
return anthropicChat(cfg, { system, user, temperature, maxTokens, signal });
|
||||
case "google":
|
||||
requireKey(cfg, "Google");
|
||||
return googleChat(cfg, { system, user, temperature, maxTokens, expectJson, signal });
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireKey(cfg, name) {
|
||||
if (!cfg.apiKey) throw new Error(`No API key set for ${name}. Add it on the Settings page.`);
|
||||
}
|
||||
|
||||
// ---------- streaming helpers ----------
|
||||
// All backends stream and accumulate server-side. Non-streaming requests sit
|
||||
// silent until the full response is ready, and Node's fetch kills any request
|
||||
// whose headers take >5 minutes — which long local generations routinely do.
|
||||
// Streaming returns headers instantly and each chunk resets the idle timer.
|
||||
async function* streamLines(res) {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
for await (const chunk of res.body) {
|
||||
buf += decoder.decode(chunk, { stream: true });
|
||||
let i;
|
||||
while ((i = buf.indexOf("\n")) !== -1) {
|
||||
const line = buf.slice(0, i).trim();
|
||||
buf = buf.slice(i + 1);
|
||||
if (line) yield line;
|
||||
}
|
||||
}
|
||||
const last = (buf + decoder.decode()).trim();
|
||||
if (last) yield last;
|
||||
}
|
||||
|
||||
// Parse server-sent events, invoking onEvent for each JSON data payload.
|
||||
async function readSse(res, onEvent) {
|
||||
for await (const line of streamLines(res)) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(payload); } catch { continue; }
|
||||
onEvent(obj);
|
||||
}
|
||||
}
|
||||
|
||||
export function providerLabel(p) {
|
||||
return { ollama: "Ollama", lmstudio: "LM Studio", openai: "OpenAI", anthropic: "Anthropic", google: "Google AI" }[p] || p;
|
||||
}
|
||||
|
||||
// ---------- Ollama ----------
|
||||
async function ollamaChat(cfg, { system, user, temperature, maxTokens, expectJson, signal, contextTokens }) {
|
||||
const base = cleanBase(cfg.baseUrl, "http://localhost:11434");
|
||||
const options = { temperature, num_predict: maxTokens };
|
||||
// Ollama defaults num_ctx to ~4k and silently truncates longer prompts, so
|
||||
// size the window to this request, bounded by the model's real maximum.
|
||||
// chars/3 over-estimates tokens on purpose; the margin covers chat template.
|
||||
if (contextTokens) {
|
||||
const needed = Math.ceil((system.length + user.length) / 3) + maxTokens + 512;
|
||||
options.num_ctx = Math.min(contextTokens, Math.max(4096, Math.ceil(needed / 1024) * 1024));
|
||||
}
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(base + "/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
model: cfg.model,
|
||||
stream: true,
|
||||
messages: [
|
||||
...(system ? [{ role: "system", content: system }] : []),
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
options,
|
||||
...(expectJson ? { format: "json" } : {}),
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
throw friendlyConnError("ollama", base, e);
|
||||
}
|
||||
if (!res.ok) throw new Error(`Ollama error (${res.status}): ${await readError(res)}`);
|
||||
|
||||
// NDJSON stream: one JSON object per line.
|
||||
let out = "";
|
||||
try {
|
||||
for await (const line of streamLines(res)) {
|
||||
let obj;
|
||||
try { obj = JSON.parse(line); } catch { continue; }
|
||||
if (obj?.error) throw new Error(`Ollama error: ${obj.error}`);
|
||||
if (obj?.message?.content) out += obj.message.content;
|
||||
}
|
||||
} catch (e) {
|
||||
if (/^Ollama error:/.test(String(e?.message))) throw e;
|
||||
throw friendlyConnError("ollama", base, e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- OpenAI-compatible (OpenAI + LM Studio) ----------
|
||||
async function openaiCompatChat(provider, base, apiKey, model, { system, user, temperature, maxTokens, expectJson, signal }) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = "Bearer " + apiKey;
|
||||
|
||||
const body = {
|
||||
model,
|
||||
stream: true,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
messages: [
|
||||
...(system ? [{ role: "system", content: system }] : []),
|
||||
{ role: "user", content: user },
|
||||
],
|
||||
};
|
||||
if (expectJson && provider === "openai") body.response_format = { type: "json_object" };
|
||||
|
||||
const send = () =>
|
||||
fetch(base + "/chat/completions", { method: "POST", headers, signal, body: JSON.stringify(body) });
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await send();
|
||||
} catch (e) {
|
||||
throw friendlyConnError(provider, base, e);
|
||||
}
|
||||
|
||||
// Some models reject response_format, temperature, or streaming; retry once without.
|
||||
if (!res.ok) {
|
||||
const detail = await readError(res);
|
||||
let changed = false;
|
||||
if (/response_format|temperature|unsupported|param/i.test(detail) && (body.response_format || body.temperature !== undefined)) {
|
||||
delete body.response_format;
|
||||
delete body.temperature;
|
||||
body.max_completion_tokens = body.max_tokens;
|
||||
delete body.max_tokens;
|
||||
changed = true;
|
||||
}
|
||||
if (/stream/i.test(detail)) {
|
||||
body.stream = false;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) throw new Error(`${providerLabel(provider)} error (${res.status}): ${detail}`);
|
||||
try {
|
||||
res = await send();
|
||||
} catch (e) {
|
||||
throw friendlyConnError(provider, base, e);
|
||||
}
|
||||
if (!res.ok) throw new Error(`${providerLabel(provider)} error (${res.status}): ${await readError(res)}`);
|
||||
}
|
||||
|
||||
if (!body.stream) {
|
||||
const data = await res.json();
|
||||
return data?.choices?.[0]?.message?.content ?? "";
|
||||
}
|
||||
|
||||
let out = "";
|
||||
try {
|
||||
await readSse(res, (obj) => {
|
||||
const delta = obj?.choices?.[0]?.delta;
|
||||
if (delta?.content) out += delta.content;
|
||||
});
|
||||
} catch (e) {
|
||||
throw friendlyConnError(provider, base, e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- Anthropic ----------
|
||||
async function anthropicChat(cfg, { system, user, temperature, maxTokens, signal }) {
|
||||
const body = {
|
||||
model: cfg.model,
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
stream: true,
|
||||
...(system ? { system } : {}),
|
||||
messages: [{ role: "user", content: user }],
|
||||
};
|
||||
const send = () =>
|
||||
fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "x-api-key": cfg.apiKey, ...VERSION_HEADER },
|
||||
signal,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await send();
|
||||
} catch (e) {
|
||||
throw friendlyConnError("anthropic", "api.anthropic.com", e);
|
||||
}
|
||||
|
||||
// Newer Anthropic models (Opus 4.7+, Fable) reject sampling parameters;
|
||||
// retry once without temperature.
|
||||
if (!res.ok && res.status === 400 && body.temperature !== undefined) {
|
||||
const detail = await readError(res);
|
||||
if (/temperature|top_p|top_k|sampling/i.test(detail)) {
|
||||
delete body.temperature;
|
||||
try {
|
||||
res = await send();
|
||||
} catch (e) {
|
||||
throw friendlyConnError("anthropic", "api.anthropic.com", e);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Anthropic error (400): ${detail}`);
|
||||
}
|
||||
}
|
||||
if (!res.ok) throw new Error(`Anthropic error (${res.status}): ${await readError(res)}`);
|
||||
|
||||
let out = "";
|
||||
try {
|
||||
await readSse(res, (obj) => {
|
||||
if (obj?.type === "content_block_delta" && obj.delta?.type === "text_delta") out += obj.delta.text;
|
||||
if (obj?.type === "error") throw new Error(`Anthropic error: ${obj.error?.message || JSON.stringify(obj.error)}`);
|
||||
});
|
||||
} catch (e) {
|
||||
if (/^Anthropic error:/.test(String(e?.message))) throw e;
|
||||
throw friendlyConnError("anthropic", "api.anthropic.com", e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- Google ----------
|
||||
async function googleChat(cfg, { system, user, temperature, maxTokens, expectJson, signal }) {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(cfg.model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(cfg.apiKey)}`;
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
|
||||
contents: [{ role: "user", parts: [{ text: user }] }],
|
||||
generationConfig: {
|
||||
temperature,
|
||||
maxOutputTokens: maxTokens,
|
||||
...(expectJson ? { responseMimeType: "application/json" } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
throw friendlyConnError("google", "generativelanguage.googleapis.com", e);
|
||||
}
|
||||
if (!res.ok) throw new Error(`Google AI error (${res.status}): ${await readError(res)}`);
|
||||
|
||||
let out = "";
|
||||
try {
|
||||
await readSse(res, (obj) => {
|
||||
if (obj?.error) throw new Error(`Google AI error: ${obj.error?.message || JSON.stringify(obj.error)}`);
|
||||
const parts = obj?.candidates?.[0]?.content?.parts || [];
|
||||
out += parts.map((p) => p.text || "").join("");
|
||||
});
|
||||
} catch (e) {
|
||||
if (/^Google AI error:/.test(String(e?.message))) throw e;
|
||||
throw friendlyConnError("google", "generativelanguage.googleapis.com", e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// listModels(settings, provider) -> string[]
|
||||
// ----------------------------------------------------------------------------
|
||||
export async function listModels(settings, provider) {
|
||||
const cfg = settings.providers?.[provider] || {};
|
||||
const t = AbortSignal.timeout(15000);
|
||||
try {
|
||||
if (provider === "ollama") {
|
||||
const base = cleanBase(cfg.baseUrl, "http://localhost:11434");
|
||||
const res = await fetch(base + "/api/tags", { signal: t }).catch((e) => { throw friendlyConnError("ollama", base, e); });
|
||||
if (!res.ok) throw new Error(`Ollama error (${res.status}): ${await readError(res)}`);
|
||||
const data = await res.json();
|
||||
const models = (data?.models || []).map((m) => m.name).sort();
|
||||
if (!models.length) throw new Error("Ollama is running but has no models installed. Run e.g. `ollama pull llama3.1:8b` first.");
|
||||
return models;
|
||||
}
|
||||
if (provider === "lmstudio") {
|
||||
const base = cleanBase(cfg.baseUrl, "http://localhost:1234");
|
||||
const res = await fetch(base + "/v1/models", { signal: t }).catch((e) => { throw friendlyConnError("lmstudio", base, e); });
|
||||
if (!res.ok) throw new Error(`LM Studio error (${res.status}): ${await readError(res)}`);
|
||||
const data = await res.json();
|
||||
const models = (data?.data || []).map((m) => m.id).sort();
|
||||
if (!models.length) throw new Error("LM Studio server is running but no model is loaded. Load a model in LM Studio first.");
|
||||
return models;
|
||||
}
|
||||
if (provider === "openai") {
|
||||
requireKey(cfg, "OpenAI");
|
||||
const res = await fetch("https://api.openai.com/v1/models", {
|
||||
headers: { Authorization: "Bearer " + cfg.apiKey }, signal: t,
|
||||
}).catch((e) => { throw friendlyConnError("openai", "api.openai.com", e); });
|
||||
if (!res.ok) throw new Error(`OpenAI error (${res.status}): ${await readError(res)}`);
|
||||
const data = await res.json();
|
||||
return (data?.data || [])
|
||||
.map((m) => m.id)
|
||||
.filter((id) => /^(gpt-|o\d|chatgpt-)/.test(id) && !/audio|realtime|tts|whisper|image|embed|moderation|transcribe|search/.test(id))
|
||||
.sort();
|
||||
}
|
||||
if (provider === "anthropic") {
|
||||
requireKey(cfg, "Anthropic");
|
||||
const res = await fetch("https://api.anthropic.com/v1/models?limit=100", {
|
||||
headers: { "x-api-key": cfg.apiKey, ...VERSION_HEADER }, signal: t,
|
||||
}).catch((e) => { throw friendlyConnError("anthropic", "api.anthropic.com", e); });
|
||||
if (!res.ok) throw new Error(`Anthropic error (${res.status}): ${await readError(res)}`);
|
||||
const data = await res.json();
|
||||
return (data?.data || []).map((m) => m.id).sort();
|
||||
}
|
||||
if (provider === "google") {
|
||||
requireKey(cfg, "Google");
|
||||
const res = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models?pageSize=100&key=${encodeURIComponent(cfg.apiKey)}`,
|
||||
{ signal: t }
|
||||
).catch((e) => { throw friendlyConnError("google", "generativelanguage.googleapis.com", e); });
|
||||
if (!res.ok) throw new Error(`Google AI error (${res.status}): ${await readError(res)}`);
|
||||
const data = await res.json();
|
||||
return (data?.models || [])
|
||||
.filter((m) => (m.supportedGenerationMethods || []).includes("generateContent"))
|
||||
.map((m) => String(m.name || "").replace(/^models\//, ""))
|
||||
.sort();
|
||||
}
|
||||
throw new Error("Unknown provider: " + provider);
|
||||
} catch (e) {
|
||||
if (e?.name === "TimeoutError") throw new Error(`Timed out reaching ${providerLabel(provider)}.`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// testConnection(settings, provider) -> { ok, message }
|
||||
// ----------------------------------------------------------------------------
|
||||
export async function testConnection(settings, provider) {
|
||||
const probe = { ...settings, provider };
|
||||
const reply = await chat(probe, {
|
||||
system: "You are a connection test. Reply with exactly: OK",
|
||||
user: "Reply with exactly: OK",
|
||||
temperature: 0,
|
||||
// Reasoning models (o-series, gpt-5, gemini-2.5, Claude thinking) spend tokens
|
||||
// on hidden reasoning before any visible text, so keep a generous budget here
|
||||
// or the reply comes back empty even though the connection is fine.
|
||||
maxTokens: 2048,
|
||||
});
|
||||
if (!reply || !reply.trim()) {
|
||||
throw new Error(
|
||||
"The connection worked but the model returned no text. If this is a reasoning model, it may have used its whole token budget on internal reasoning — try a non-reasoning model, or this is usually safe to ignore."
|
||||
);
|
||||
}
|
||||
return { ok: true, message: `Connected. Model replied: "${reply.trim().slice(0, 40)}"` };
|
||||
}
|
||||
175
lib/schema.js
Normal file
175
lib/schema.js
Normal file
@ -0,0 +1,175 @@
|
||||
// lib/schema.js — shared constants + normalization that repairs whatever the model returns
|
||||
// into a guaranteed-valid assignment structure. Runs on both server and client.
|
||||
|
||||
export const ASSIGNMENT_TYPES = [
|
||||
{ id: "quiz", label: "Quiz", hint: "Short check for understanding" },
|
||||
{ id: "test", label: "Test", hint: "Full exam with mixed sections" },
|
||||
{ id: "worksheet", label: "Worksheet", hint: "Guided practice to work through" },
|
||||
{ id: "discussion", label: "Discussion questions", hint: "Open prompts with facilitation notes" },
|
||||
{ id: "case_study", label: "Case study", hint: "A scenario plus analysis questions" },
|
||||
];
|
||||
|
||||
export const QUESTION_TYPES = [
|
||||
{ id: "multiple_choice", label: "Multiple choice" },
|
||||
{ id: "true_false", label: "True / False" },
|
||||
{ id: "short_answer", label: "Short answer" },
|
||||
{ id: "essay", label: "Essay" },
|
||||
{ id: "fill_blank", label: "Fill in the blank" },
|
||||
{ id: "matching", label: "Matching" },
|
||||
];
|
||||
|
||||
export const GRADE_LEVELS = [
|
||||
"Kindergarten", "Grade 1", "Grade 2", "Grade 3", "Grade 4", "Grade 5",
|
||||
"Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12",
|
||||
"College — introductory", "College — advanced", "Adult education",
|
||||
];
|
||||
|
||||
export const DIFFICULTIES = ["Easy", "Medium", "Hard", "Mixed"];
|
||||
|
||||
export const DEFAULT_POINTS = {
|
||||
multiple_choice: 2,
|
||||
true_false: 1,
|
||||
short_answer: 3,
|
||||
essay: 10,
|
||||
fill_blank: 2,
|
||||
matching: 4,
|
||||
discussion: 5,
|
||||
};
|
||||
|
||||
export function questionTypeLabel(type) {
|
||||
if (type === "discussion") return "Discussion prompt";
|
||||
return QUESTION_TYPES.find((t) => t.id === type)?.label || type;
|
||||
}
|
||||
|
||||
export function newId() {
|
||||
return "q_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
function str(v, fallback = "") {
|
||||
if (v == null) return fallback;
|
||||
return String(v).trim() || fallback;
|
||||
}
|
||||
|
||||
function num(v, fallback) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
}
|
||||
|
||||
function arr(v) {
|
||||
return Array.isArray(v) ? v : [];
|
||||
}
|
||||
|
||||
// Normalize one question object from the model (or the editor) into a valid shape.
|
||||
// Returns null if the question is hopeless and should be dropped.
|
||||
export function normalizeQuestion(raw) {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
let type = str(raw.type).toLowerCase().replace(/[\s-]+/g, "_");
|
||||
const aliases = {
|
||||
multiplechoice: "multiple_choice", mcq: "multiple_choice", multiple_choice_question: "multiple_choice",
|
||||
truefalse: "true_false", tf: "true_false",
|
||||
shortanswer: "short_answer", short_response: "short_answer",
|
||||
fillblank: "fill_blank", fill_in_the_blank: "fill_blank", fitb: "fill_blank", cloze: "fill_blank",
|
||||
match: "matching", open_ended: "discussion", discussion_prompt: "discussion",
|
||||
};
|
||||
type = aliases[type] || type;
|
||||
const known = ["multiple_choice", "true_false", "short_answer", "essay", "fill_blank", "matching", "discussion"];
|
||||
if (!known.includes(type)) return null;
|
||||
|
||||
const q = {
|
||||
id: str(raw.id) || newId(),
|
||||
type,
|
||||
question: str(raw.question || raw.prompt || raw.text),
|
||||
points: num(raw.points, DEFAULT_POINTS[type]),
|
||||
explanation: str(raw.explanation),
|
||||
sourceRef: str(raw.sourceRef || raw.source_ref || raw.source),
|
||||
verification: raw.verification && typeof raw.verification === "object"
|
||||
? { status: raw.verification.status === "warn" ? "warn" : raw.verification.status === "pass" ? "pass" : "unchecked", note: str(raw.verification.note) }
|
||||
: { status: "unchecked", note: "" },
|
||||
};
|
||||
if (!q.question) return null;
|
||||
|
||||
if (type === "multiple_choice") {
|
||||
let options = arr(raw.options || raw.choices).map((o) => str(typeof o === "object" ? o?.text : o)).filter(Boolean);
|
||||
if (options.length < 2) return null;
|
||||
options = options.slice(0, 6);
|
||||
let ci = raw.correctIndex ?? raw.correct_index;
|
||||
if (ci == null && raw.correctAnswer != null) {
|
||||
// Model may give the answer as a letter ("B") or the option text.
|
||||
const ca = str(raw.correctAnswer);
|
||||
const letter = ca.match(/^[A-F]$/i);
|
||||
if (letter) ci = letter[0].toUpperCase().charCodeAt(0) - 65;
|
||||
else {
|
||||
const idx = options.findIndex((o) => o.toLowerCase() === ca.toLowerCase());
|
||||
ci = idx >= 0 ? idx : 0;
|
||||
}
|
||||
}
|
||||
ci = Math.min(Math.max(num(ci, 0), 0), options.length - 1);
|
||||
q.options = options;
|
||||
q.correctIndex = ci;
|
||||
} else if (type === "true_false") {
|
||||
let ans = raw.correctAnswer ?? raw.answer;
|
||||
if (typeof ans === "string") ans = /^(t|true|yes)/i.test(ans.trim());
|
||||
q.correctAnswer = Boolean(ans);
|
||||
} else if (type === "short_answer") {
|
||||
q.sampleAnswer = str(raw.sampleAnswer || raw.sample_answer || raw.answer || raw.correctAnswer);
|
||||
q.keyPoints = arr(raw.keyPoints || raw.key_points).map((k) => str(k)).filter(Boolean);
|
||||
} else if (type === "essay") {
|
||||
q.sampleResponse = str(raw.sampleResponse || raw.sample_response || raw.sampleAnswer || raw.answer);
|
||||
q.rubric = arr(raw.rubric).map((r) => {
|
||||
if (typeof r === "string") return { criterion: str(r), points: 0, description: "" };
|
||||
return { criterion: str(r?.criterion || r?.name), points: num(r?.points, 0), description: str(r?.description) };
|
||||
}).filter((r) => r.criterion);
|
||||
} else if (type === "fill_blank") {
|
||||
// Ensure the question text actually contains blanks
|
||||
let text = q.question.replace(/_{2,}/g, "______");
|
||||
let answers = arr(raw.answers || raw.blanks).map((a) => str(typeof a === "object" ? a?.answer : a)).filter(Boolean);
|
||||
if (!answers.length && raw.answer) answers = [str(raw.answer)];
|
||||
if (!answers.length) return null;
|
||||
const blanks = (text.match(/______/g) || []).length;
|
||||
if (blanks === 0) return null;
|
||||
q.question = text;
|
||||
q.answers = answers.slice(0, blanks).concat(Array(Math.max(0, blanks - answers.length)).fill("")).map((a) => a || "(answer missing — edit me)");
|
||||
} else if (type === "matching") {
|
||||
const pairs = arr(raw.pairs || raw.items).map((p) => ({ left: str(p?.left || p?.term), right: str(p?.right || p?.definition || p?.match) }))
|
||||
.filter((p) => p.left && p.right);
|
||||
if (pairs.length < 2) return null;
|
||||
q.pairs = pairs.slice(0, 10);
|
||||
q.points = num(raw.points, q.pairs.length);
|
||||
} else if (type === "discussion") {
|
||||
q.talkingPoints = arr(raw.talkingPoints || raw.talking_points || raw.keyPoints).map((k) => str(k)).filter(Boolean);
|
||||
q.followUps = arr(raw.followUps || raw.follow_ups).map((k) => str(k)).filter(Boolean);
|
||||
q.sampleResponse = str(raw.sampleResponse || raw.sample_response);
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
export function normalizeAssignment(raw, config) {
|
||||
const questions = arr(raw?.questions).map(normalizeQuestion).filter(Boolean);
|
||||
return {
|
||||
title: str(raw?.title, "Untitled assignment"),
|
||||
instructions: str(raw?.instructions),
|
||||
caseStudy: str(raw?.caseStudy || raw?.case_study || raw?.scenario),
|
||||
assignmentType: config.assignmentType,
|
||||
gradeLevel: config.gradeLevel,
|
||||
subject: config.subject,
|
||||
difficulty: config.difficulty,
|
||||
questions,
|
||||
};
|
||||
}
|
||||
|
||||
export function totalPoints(questions) {
|
||||
return (questions || []).reduce((s, q) => s + (Number(q.points) || 0), 0);
|
||||
}
|
||||
|
||||
// Create a blank question of a given type for the "Add question" menu.
|
||||
export function blankQuestion(type) {
|
||||
const base = { id: newId(), type, question: "", points: DEFAULT_POINTS[type] || 2, explanation: "", sourceRef: "", verification: { status: "unchecked", note: "" } };
|
||||
if (type === "multiple_choice") return { ...base, options: ["", "", "", ""], correctIndex: 0 };
|
||||
if (type === "true_false") return { ...base, correctAnswer: true };
|
||||
if (type === "short_answer") return { ...base, sampleAnswer: "", keyPoints: [] };
|
||||
if (type === "essay") return { ...base, sampleResponse: "", rubric: [] };
|
||||
if (type === "fill_blank") return { ...base, question: "______", answers: [""] };
|
||||
if (type === "matching") return { ...base, pairs: [{ left: "", right: "" }, { left: "", right: "" }], points: 2 };
|
||||
if (type === "discussion") return { ...base, talkingPoints: [], followUps: [], sampleResponse: "" };
|
||||
return base;
|
||||
}
|
||||
144
lib/store.js
Normal file
144
lib/store.js
Normal file
@ -0,0 +1,144 @@
|
||||
// lib/store.js — single-file local database (data/db.json) with atomic writes.
|
||||
// Portable: copy data/db.json to move your whole library + settings anywhere.
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const DB_PATH = path.join(DATA_DIR, "db.json");
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
// Shown in the header of printed/exported assignments.
|
||||
profile: {
|
||||
teacherName: "",
|
||||
className: "",
|
||||
schoolName: "",
|
||||
logo: "", // small data-URL image (downscaled client-side before saving)
|
||||
},
|
||||
provider: "ollama",
|
||||
providers: {
|
||||
// Env overrides let the Docker image default to the host machine's
|
||||
// Ollama / LM Studio (host.docker.internal) without touching the UI.
|
||||
ollama: { baseUrl: process.env.OLLAMA_BASE_URL || "http://localhost:11434", model: "" },
|
||||
lmstudio: { baseUrl: process.env.LMSTUDIO_BASE_URL || "http://localhost:1234", model: "" },
|
||||
openai: { apiKey: "", model: "gpt-4o-mini" },
|
||||
anthropic: { apiKey: "", model: "claude-sonnet-4-6" },
|
||||
google: { apiKey: "", model: "gemini-2.0-flash" },
|
||||
},
|
||||
generation: {
|
||||
auto: true, // size maxTokens/maxSourceChars to the selected model (lib/model-caps.js)
|
||||
temperature: 0.3,
|
||||
maxTokens: 8000,
|
||||
maxSourceChars: 24000,
|
||||
verification: true,
|
||||
},
|
||||
};
|
||||
|
||||
function emptyDb() {
|
||||
return { assignments: [], settings: structuredClone(DEFAULT_SETTINGS) };
|
||||
}
|
||||
|
||||
function readDb() {
|
||||
try {
|
||||
if (!fs.existsSync(DB_PATH)) return emptyDb();
|
||||
const raw = fs.readFileSync(DB_PATH, "utf8");
|
||||
const db = JSON.parse(raw);
|
||||
if (!Array.isArray(db.assignments)) db.assignments = [];
|
||||
db.settings = mergeSettings(db.settings);
|
||||
return db;
|
||||
} catch {
|
||||
// Corrupt file: keep a backup, start fresh rather than crash.
|
||||
try { fs.copyFileSync(DB_PATH, DB_PATH + ".corrupt-" + Date.now()); } catch {}
|
||||
return emptyDb();
|
||||
}
|
||||
}
|
||||
|
||||
function writeDb(db) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const tmp = DB_PATH + ".tmp";
|
||||
fs.writeFileSync(tmp, JSON.stringify(db, null, 2), "utf8");
|
||||
fs.renameSync(tmp, DB_PATH);
|
||||
}
|
||||
|
||||
export function mergeSettings(saved) {
|
||||
const base = structuredClone(DEFAULT_SETTINGS);
|
||||
if (!saved || typeof saved !== "object") return base;
|
||||
const out = { ...base, ...saved };
|
||||
out.providers = { ...base.providers };
|
||||
for (const key of Object.keys(base.providers)) {
|
||||
out.providers[key] = { ...base.providers[key], ...(saved.providers?.[key] || {}) };
|
||||
}
|
||||
out.generation = { ...base.generation, ...(saved.generation || {}) };
|
||||
out.profile = { ...base.profile, ...(saved.profile || {}) };
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- Settings ----------
|
||||
export function getSettings() {
|
||||
return readDb().settings;
|
||||
}
|
||||
|
||||
export function saveSettings(settings) {
|
||||
const db = readDb();
|
||||
db.settings = mergeSettings(settings);
|
||||
writeDb(db);
|
||||
return db.settings;
|
||||
}
|
||||
|
||||
// ---------- Assignments ----------
|
||||
export function listAssignments() {
|
||||
const db = readDb();
|
||||
return db.assignments
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title || "Untitled assignment",
|
||||
assignmentType: a.assignmentType,
|
||||
gradeLevel: a.gradeLevel,
|
||||
subject: a.subject,
|
||||
questionCount: (a.questions || []).length,
|
||||
totalPoints: (a.questions || []).reduce((s, q) => s + (Number(q.points) || 0), 0),
|
||||
createdAt: a.createdAt,
|
||||
updatedAt: a.updatedAt,
|
||||
}))
|
||||
.sort((x, y) => String(y.updatedAt).localeCompare(String(x.updatedAt)));
|
||||
}
|
||||
|
||||
export function getAssignment(id) {
|
||||
return readDb().assignments.find((a) => a.id === id) || null;
|
||||
}
|
||||
|
||||
export function createAssignment(assignment) {
|
||||
const db = readDb();
|
||||
const now = new Date().toISOString();
|
||||
const record = {
|
||||
...assignment,
|
||||
id: assignment.id || "a_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
db.assignments.push(record);
|
||||
writeDb(db);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function updateAssignment(id, patch) {
|
||||
const db = readDb();
|
||||
const idx = db.assignments.findIndex((a) => a.id === id);
|
||||
if (idx === -1) return null;
|
||||
db.assignments[idx] = {
|
||||
...db.assignments[idx],
|
||||
...patch,
|
||||
id,
|
||||
createdAt: db.assignments[idx].createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeDb(db);
|
||||
return db.assignments[idx];
|
||||
}
|
||||
|
||||
export function deleteAssignment(id) {
|
||||
const db = readDb();
|
||||
const before = db.assignments.length;
|
||||
db.assignments = db.assignments.filter((a) => a.id !== id);
|
||||
writeDb(db);
|
||||
return db.assignments.length < before;
|
||||
}
|
||||
7
next.config.mjs
Normal file
7
next.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Self-contained server bundle for the Docker image (see docker/).
|
||||
output: "standalone",
|
||||
};
|
||||
export default nextConfig;
|
||||
1084
package-lock.json
generated
Normal file
1084
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "mr-drews-assignment-creator",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Local, private, high-accuracy assignment generator for educators.",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"next": "14.2.18",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tailwindcss": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
5
postcss.config.js
Normal file
5
postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user