Add “Make my homework”, and finish the mobile polish pass
Homework (/homework — entry points in the drawer and on the create screen) - Assignment intake reuses the study pipeline: pasted text, photos (camera/picker, compressed in the browser) and documents (PDF/DOCX/PPTX/ ODT/text) through lib/image + lib/documents, the same vision step and the same language guard. Raw files never leave the device — only text and JPEG data URLs reach the server. - Two large choice cards: “Just make it” returns the finished homework (answer only, plain text, copy + make it again); “Explain how to make this homework” builds an interactive guide. - The guide is one step per screen in the study-feed snap layout: explanation, three progressive hints (nudge -> hint -> the answer) and an optional checkpoint rendered by the existing activity components (graded through /api/grade). The last screen assembles the solution from the step answers, with a copy button. Checkpoints carry xp 0 so the guide stays free of XP/streak chrome. - Results live in freezmo.homework.v1 with a small history (open/delete); “Make it again” re-runs from the stored assignment text (the vision transcription when photos were used) — no original files are stored. - The answer and the guide are written in the assignment's language, never translated into the UI language. Server - POST /api/homework: NDJSON stage events like /api/generate, the same http-guard, auth checked before parsing, and its own RATE_LIMIT_HOMEWORK (documented in .env.example + the README table). - New prompts in lib/prompts.ts and zod schemas in lib/schemas.ts with the same single repair retry; TEXT_MODEL for content, VISION_MODEL only for photos. New shared client pieces: useNotesIntake, lib/homework store, lib/clipboard, and a shared NDJSON reader in lib/client-api. Also finishing the mobile polish pass from the previous request - Phone ice scene: the top-left orb moved behind the glass header (it sat behind the page heading/subtitle). The settings sheet's scroll cue is now a bare chevron inside a content fade (mask) instead of a chip over the status banner, and the API key card's note/disclosure/actions are tightened. - theme-color now follows the in-app theme (SSR metas still fall back to the OS preference); apple-mobile-web-app-status-bar-style stays “default” because iOS fixes the status-bar text colour at launch. Verified: 78/78 homework checks (text/photo/PDF, both modes, EN+CS, dark+light, 360/390px, copy, history persistence, storage privacy, API language, axe), plus the existing suites re-run (mobile 47/47, pixel contrast 0 below AA, i18n 27/27, desktop regression 72/72). Lint + build clean; service worker freezmo-v6.
This commit is contained in:
parent
9fb728d735
commit
ef2a6a3589
35 changed files with 2334 additions and 100 deletions
|
|
@ -41,6 +41,7 @@ TEXT_MODEL=gpt-oss:120b-cloud
|
|||
# RATE_LIMIT_WINDOW_MS=300000
|
||||
# RATE_LIMIT_GENERATE=12
|
||||
# RATE_LIMIT_GRADE=40
|
||||
# RATE_LIMIT_HOMEWORK=12
|
||||
# RATE_LIMIT_HEALTH=90
|
||||
|
||||
# Optional tuning ------------------------------------------------------------
|
||||
|
|
|
|||
50
README.md
50
README.md
|
|
@ -79,6 +79,21 @@ no database, no data leaving your machine except the requests you send to Ollama
|
|||
and **Regenerate deck** — rebuilds the activities from the stored notes so you can keep drilling
|
||||
until you're ready
|
||||
|
||||
**Make my homework**
|
||||
|
||||
- Same intake as a deck — paste the assignment, photograph it, or add a PDF/DOCX/PPTX/ODT/text
|
||||
file; photos go through the same vision model and language guard that notes do
|
||||
- Two modes: **Just make it** returns the finished homework (the answer only, ready to copy), and
|
||||
**Explain how to make this homework** turns it into a guided walkthrough
|
||||
- The guide is one step per screen in the same snap-feed feel as the study feed: what to do and
|
||||
why, **three progressive hints** per step (nudge → hint → the full answer), and a **checkpoint**
|
||||
built from the normal activity components and graded through `/api/grade`
|
||||
- The last screen assembles the solution from the step answers, with a copy button
|
||||
- Results live in `freezmo.homework.v1` with a small history you can reopen or delete; **Make it
|
||||
again** re-runs from the stored assignment text — original files are never saved
|
||||
- The answer and the guide are always written in the **language of the assignment**, never
|
||||
translated into the interface language
|
||||
|
||||
**Activity types** (each one is its own React component, rendered from JSON by a `type` field)
|
||||
|
||||
| Type | Interaction |
|
||||
|
|
@ -335,12 +350,13 @@ the two pipeline steps stay separate either way.
|
|||
|
||||
## Rate limits
|
||||
|
||||
Generation is expensive (two model calls per deck), so the API routes are limited per client IP with
|
||||
a sliding window. Everything is configurable in `.env.local`:
|
||||
Generation is expensive (one to two model calls per request), so the API routes are limited per
|
||||
client IP with a sliding window. Everything is configurable in `.env.local`:
|
||||
|
||||
| Route | Default | Env var |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/generate` | 12 per 5 minutes | `RATE_LIMIT_GENERATE` |
|
||||
| `POST /api/homework` | 12 per 5 minutes | `RATE_LIMIT_HOMEWORK` |
|
||||
| `POST /api/grade` | 40 per 5 minutes | `RATE_LIMIT_GRADE` |
|
||||
| `GET /api/health` | 90 per 5 minutes | `RATE_LIMIT_HEALTH` |
|
||||
| window for all of them | 5 minutes | `RATE_LIMIT_WINDOW_MS` |
|
||||
|
|
@ -581,20 +597,23 @@ app/
|
|||
page.tsx library ("Your icebox")
|
||||
create/page.tsx upload screen
|
||||
deck/[id]/page.tsx study feed for a saved deck
|
||||
homework/page.tsx “Make my homework” (assignment in, homework or guide out)
|
||||
api/generate/route.ts images/text in → NDJSON progress → deck JSON out
|
||||
api/homework/route.ts assignment in → NDJSON progress → homework/guide JSON out
|
||||
api/grade/route.ts short-answer grading
|
||||
api/health/route.ts Ollama reachability + model availability
|
||||
components/
|
||||
activities/ one component per activity type + shared shell
|
||||
Flashcard.tsx MultipleChoice.tsx TrueFalse.tsx FillBlank.tsx
|
||||
Matching.tsx Ordering.tsx ShortAnswer.tsx shared.tsx index.tsx
|
||||
homework/ make-my-homework screens (input, result, guide feed, history)
|
||||
UploadScreen.tsx StudyFeed.tsx ResultsScreen.tsx DeckCard.tsx ApiKeyGate.tsx
|
||||
InstallSheet.tsx TabBar.tsx ServiceWorkerRegistrar.tsx FolderBar.tsx
|
||||
LibraryScreen.tsx SettingsDialog.tsx AppHeader.tsx ThemeToggle.tsx icons.tsx ui.tsx
|
||||
lib/
|
||||
ollama.ts server-only HTTP client (timeouts, auth, error mapping)
|
||||
prompts.ts the vision, generation, grading and repair prompts
|
||||
ai.ts the three pipeline steps with zod validation + retry
|
||||
prompts.ts the vision, generation, grading, homework and repair prompts
|
||||
ai.ts every pipeline step with zod validation + retry
|
||||
schemas.ts zod schemas and shared types
|
||||
config.ts env config, cloud/direct model-name normalisation
|
||||
documents.ts client-side PDF/DOCX/PPTX/ODT/text extraction
|
||||
|
|
@ -607,6 +626,9 @@ lib/
|
|||
language.ts script/language checks that catch translated transcriptions
|
||||
ui-events.ts tiny event bus (any screen can open the settings dialog)
|
||||
storage.ts localStorage deck library, import/export
|
||||
homework.ts localStorage homework results + solution assembly
|
||||
use-notes-intake.ts shared client-side photo/document intake hook
|
||||
clipboard.ts copy helper with a non-secure-context fallback
|
||||
image.ts client-side validation, resize and compression
|
||||
client-api.ts browser client for the API routes (NDJSON reader)
|
||||
xp.ts utils.ts deck-ops.ts confetti.ts
|
||||
|
|
@ -629,10 +651,11 @@ scripts/
|
|||
| Route | Body | Returns |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/generate` | `{ images?: string[], text?, difficulty, count?, focus?, language?, mode?, existing? }` | NDJSON stream: `{type:"stage"}`, `{type:"deck",deck,meta}`, `{type:"error"}` |
|
||||
| `POST /api/homework` | `{ mode: "make" \| "guide", images?: string[], text?, language? }` | NDJSON stream: `{type:"stage"}`, `{type:"homework",homework,meta}`, `{type:"error"}` |
|
||||
| `POST /api/grade` | `{ question, referenceAnswer, keyPoints, userAnswer, language? }` | `{ correct, score, feedback, missing }` |
|
||||
| `GET /api/health` | – | `{ ok, reachable, mode, baseUrl, hasApiKey, vision, text, latencyMs, modelCount, error }` |
|
||||
|
||||
All three routes accept an optional **`x-ollama-key`** header (the browser's own key; required in
|
||||
All four routes accept an optional **`x-ollama-key`** header (the browser's own key; required in
|
||||
`user-key` auth mode). Missing keys answer **401** with `kind: "needs_key"`, a rejected key answers
|
||||
`kind: "auth"` (or `kind: "no_credits"` for HTTP 402), and exceeding a rate limit answers **429**
|
||||
with `Retry-After`. Keys are used for the single request and discarded.
|
||||
|
|
@ -640,9 +663,9 @@ with `Retry-After`. Keys are used for the single request and discarded.
|
|||
Images are sent as base64 data URLs; the server strips the prefix before handing them to Ollama.
|
||||
Uploads are validated for type (photos and the document formats above), count (max 8 photos,
|
||||
6 documents, 12 images) and size (max ~8 MB per image after compression, 40 MB per document) and the
|
||||
client compresses aggressively, so a typical upload is 1–3 MB. `language` is optional on both routes:
|
||||
the vision step fills it in automatically, and the UI passes the stored deck language for grading,
|
||||
regeneration and “add more”.
|
||||
client compresses aggressively, so a typical upload is 1–3 MB. `language` is optional wherever it
|
||||
appears: the vision step fills it in automatically, and the UI passes the stored deck language for
|
||||
grading, regeneration and “add more” (and the assignment language for homework).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -870,6 +893,17 @@ Things I chose while building, with the reasoning:
|
|||
(hide the text → screenshot → worst-case background luminance per glyph rect): 0 runs below AA
|
||||
across library and settings, both themes, both languages.
|
||||
|
||||
35. **“Make my homework” reuses the study pipeline instead of growing its own.** The intake is the
|
||||
same client-side parsing as `/create` (lib/documents + lib/image, wrapped in `useNotesIntake`),
|
||||
the photos go through the same `extractNotes` vision step and language guard, and the guide's
|
||||
checkpoints are ordinary activity JSON rendered by the existing components — just a
|
||||
`checkpoint` field on a step. The route streams NDJSON like `/api/generate` and has its own
|
||||
rate limit. Two things are deliberately different: the finished answer and the guide are
|
||||
written in the *assignment's* language (the interface language never leaks in), and the final
|
||||
solution is assembled on the client from the step answers instead of asking the model for a
|
||||
second copy. Result records store the assignment text — the vision transcription when photos
|
||||
were used — so “Make it again” works without storing a single original file.
|
||||
|
||||
14. **`gpt-oss:120b` does all content work — but it cannot read images** (`this model does not
|
||||
support image input`), so `VISION_MODEL` has to stay a multimodal model (`gemma4:31b-cloud`).
|
||||
Text-only inputs — pasted text, PDFs with a text layer, DOCX/PPTX/ODT — never touch the vision
|
||||
|
|
|
|||
225
app/api/homework/route.ts
Normal file
225
app/api/homework/route.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { buildHomeworkGuide, extractNotes, makeHomework, notesToText, type PipelineStage } from "@/lib/ai";
|
||||
import { authErrorMessage, authErrorKind, getServerConfig, resolveAuth, resolveModelName } from "@/lib/config";
|
||||
import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
|
||||
import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
|
||||
import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
|
||||
import { translate } from "@/lib/i18n-dict";
|
||||
import { homeworkModeSchema, type HomeworkStep } from "@/lib/schemas";
|
||||
import { uid } from "@/lib/utils";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300;
|
||||
|
||||
/** ~8 MB per image after client-side resizing; up to 16 images (photos + PDF pages). */
|
||||
const MAX_IMAGE_CHARS = 8_000_000;
|
||||
/** Generous but finite: ~16 compressed images plus JSON overhead. */
|
||||
const MAX_BODY_BYTES = 33554432;
|
||||
|
||||
const requestSchema = z.object({
|
||||
mode: homeworkModeSchema,
|
||||
images: z.array(z.string().min(32).max(MAX_IMAGE_CHARS)).max(16).optional(),
|
||||
text: z.string().max(120_000).optional(),
|
||||
/** Language of the assignment (usually detected by the vision step). */
|
||||
language: z.string().max(60).optional(),
|
||||
});
|
||||
|
||||
type StreamEvent =
|
||||
| { type: "stage"; stage: PipelineStage; message: string }
|
||||
| { type: "homework"; homework: unknown; meta: Record<string, unknown> }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
|
||||
function encoder() {
|
||||
const enc = new TextEncoder();
|
||||
return (event: StreamEvent) => enc.encode(`${JSON.stringify(event)}\n`);
|
||||
}
|
||||
|
||||
/** Same pipeline shape as /api/generate: vision (photos only) then one model call. */
|
||||
export async function POST(request: Request) {
|
||||
const locale = requestLocale(request);
|
||||
const crossOrigin = rejectCrossOrigin(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
|
||||
if (oversized) return oversized;
|
||||
|
||||
const config = getServerConfig();
|
||||
|
||||
// Cheap requests are rejected before any work happens.
|
||||
const limit = rateLimit(`homework:${clientKey(request)}`, config.rateLimits.homework, config.rateLimits.windowMs);
|
||||
if (!limit.allowed) return rateLimitResponse(limit, locale);
|
||||
|
||||
const auth = resolveAuth(config, request);
|
||||
if ("error" in auth) {
|
||||
return Response.json(
|
||||
{ error: authErrorMessage(locale, auth.error), kind: authErrorKind(auth.error) },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = requestSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return Response.json(
|
||||
{
|
||||
error: translate(locale, "server.invalidRequest", {
|
||||
detail: parsed.error.issues
|
||||
.slice(0, 3)
|
||||
.map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
|
||||
.join("; "),
|
||||
}),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { mode, images, text, language } = parsed.data;
|
||||
const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean);
|
||||
|
||||
if (imageList.length === 0 && !text?.trim()) {
|
||||
return Response.json({ error: translate(locale, "server.needAssignment") }, { status: 400 });
|
||||
}
|
||||
|
||||
const encode = encoder();
|
||||
const abort = new AbortController();
|
||||
// A single hard deadline for the whole pipeline (two sequential model calls).
|
||||
const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2);
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const send = (event: StreamEvent) => {
|
||||
try {
|
||||
controller.enqueue(encode(event));
|
||||
} catch {
|
||||
/* client went away */
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
let assignmentText: string;
|
||||
let detectedLanguage: string | undefined = language;
|
||||
let repaired = false;
|
||||
|
||||
if (imageList.length > 0) {
|
||||
send({ type: "stage", stage: "vision", message: translate(locale, "homework.loading.reading") });
|
||||
const extraction = await extractNotes({
|
||||
model: config.visionModel,
|
||||
repairModel: config.textModel,
|
||||
auth: auth.auth,
|
||||
images: imageList,
|
||||
text,
|
||||
signal: abort.signal,
|
||||
onProgress: (message) => send({ type: "stage", stage: "vision", message }),
|
||||
});
|
||||
assignmentText = notesToText(extraction.notes);
|
||||
detectedLanguage = extraction.notes.language || language;
|
||||
repaired = extraction.repaired || extraction.languageRepaired;
|
||||
} else {
|
||||
assignmentText = text!.trim();
|
||||
}
|
||||
|
||||
send({
|
||||
type: "stage",
|
||||
stage: "generation",
|
||||
message: translate(locale, mode === "make" ? "homework.loading.writing" : "homework.loading.planning"),
|
||||
});
|
||||
|
||||
const meta = {
|
||||
repaired,
|
||||
language: detectedLanguage || null,
|
||||
visionModel: imageList.length ? resolveModelName(config.visionModel, auth.auth.directCloud) : null,
|
||||
textModel: resolveModelName(config.textModel, auth.auth.directCloud),
|
||||
};
|
||||
|
||||
if (mode === "make") {
|
||||
const result = await makeHomework({
|
||||
model: config.textModel,
|
||||
auth: auth.auth,
|
||||
assignment: assignmentText,
|
||||
language: detectedLanguage,
|
||||
signal: abort.signal,
|
||||
});
|
||||
send({
|
||||
type: "homework",
|
||||
homework: {
|
||||
mode: "make",
|
||||
title: result.title,
|
||||
language: result.language || detectedLanguage,
|
||||
answer: result.answer,
|
||||
// The assignment as text (the vision transcription when photos
|
||||
// were used) so the client can store it for “Make it again”.
|
||||
sourceText: assignmentText,
|
||||
},
|
||||
meta,
|
||||
});
|
||||
} else {
|
||||
const guide = await buildHomeworkGuide({
|
||||
model: config.textModel,
|
||||
auth: auth.auth,
|
||||
assignment: assignmentText,
|
||||
language: detectedLanguage,
|
||||
signal: abort.signal,
|
||||
});
|
||||
const steps: HomeworkStep[] = guide.steps.map((step) => ({
|
||||
...step,
|
||||
// xp: 0 keeps the shared activity components from rendering their
|
||||
// "+XP" badge — the guide deliberately has no XP or streak.
|
||||
checkpoint: step.checkpoint ? { ...step.checkpoint, id: uid("act"), xp: 0 } : undefined,
|
||||
}));
|
||||
send({
|
||||
type: "homework",
|
||||
homework: {
|
||||
mode: "guide",
|
||||
title: guide.title,
|
||||
language: guide.language || detectedLanguage,
|
||||
steps,
|
||||
sourceText: assignmentText,
|
||||
},
|
||||
meta,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = friendlyErrorMessage(error, auth.auth.baseUrl, locale);
|
||||
// Never log the request body: it may contain the student's assignment.
|
||||
console.error("[api/homework]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
|
||||
send({
|
||||
type: "error",
|
||||
error: message,
|
||||
kind: error instanceof OllamaError ? error.kind : "unknown",
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(globalTimer);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
clearTimeout(globalTimer);
|
||||
abort.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "application/x-ndjson; charset=utf-8",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stripDataUrlPrefix(value: string): string {
|
||||
const commaIndex = value.indexOf(",");
|
||||
if (value.startsWith("data:") && commaIndex !== -1) return value.slice(commaIndex + 1);
|
||||
return value;
|
||||
}
|
||||
|
|
@ -338,7 +338,9 @@ body {
|
|||
--ice-blob-opacity: 0.2;
|
||||
--ice-orb-opacity: 0.42;
|
||||
}
|
||||
.ice-orb-1 { width: 96px; height: 96px; top: 8%; left: -26px; }
|
||||
/* Tucked up behind the glass header: at top 8% it sat right behind the page
|
||||
heading and subtitle. */
|
||||
.ice-orb-1 { width: 88px; height: 88px; top: -30px; left: -38px; }
|
||||
.ice-orb-2 { width: 72px; height: 72px; left: -32px; }
|
||||
/* Was sitting right behind the bottom-nav labels. */
|
||||
.ice-orb-3 { width: 116px; height: 116px; bottom: 15vh; right: -46px; }
|
||||
|
|
@ -698,10 +700,15 @@ body {
|
|||
}
|
||||
|
||||
/* Settings sheet: contained momentum scrolling; the safe-area padding comes
|
||||
from the pb-safe utility plus a trailing spacer. */
|
||||
from the pb-safe utility plus a trailing spacer. The mask fades the last
|
||||
36px of *content* (not a coloured overlay, so it blends with the glass in
|
||||
both themes) — that is the scroll cue; the trailing spacer keeps real
|
||||
content out of the faded strip once you reach the bottom. */
|
||||
.sheet-scroll {
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent);
|
||||
mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
28
app/homework/page.tsx
Normal file
28
app/homework/page.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { ApiKeyGate } from "@/components/ApiKeyGate";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import { HomeworkScreen } from "@/components/homework/HomeworkScreen";
|
||||
import { MobileSidebar, Sidebar, SidebarOffset } from "@/components/Sidebar";
|
||||
import { TabBar } from "@/components/TabBar";
|
||||
|
||||
export const metadata = {
|
||||
title: "Make my homework",
|
||||
};
|
||||
|
||||
export default function HomeworkPage() {
|
||||
return (
|
||||
<ApiKeyGate>
|
||||
<div className="flex min-h-dvh">
|
||||
<Sidebar />
|
||||
<SidebarOffset />
|
||||
<MobileSidebar />
|
||||
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
|
||||
<AppHeader />
|
||||
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
|
||||
<HomeworkScreen />
|
||||
</div>
|
||||
<TabBar />
|
||||
</main>
|
||||
</div>
|
||||
</ApiKeyGate>
|
||||
);
|
||||
}
|
||||
|
|
@ -55,8 +55,12 @@ export const viewport: Viewport = {
|
|||
],
|
||||
};
|
||||
|
||||
/** Applies the saved theme before paint to avoid a flash. */
|
||||
const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;}catch(e){}})();`;
|
||||
/**
|
||||
* Applies the saved theme before paint to avoid a flash, and keeps the
|
||||
* `theme-color` meta in step with the *app* theme (the SSR metas follow the
|
||||
* OS preference, which is wrong once the user has picked a theme here).
|
||||
*/
|
||||
const themeScript = `(function(){try{var s=localStorage.getItem('freezmo.theme');var d=s?s==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');var l=localStorage.getItem('freezmo.locale.v1');if(l==='cs'||l==='en')document.documentElement.lang=l;var m=document.querySelector('meta[name="theme-color"][data-app]');if(!m){m=document.createElement('meta');m.setAttribute('name','theme-color');m.setAttribute('data-app','1');document.head.insertBefore(m,document.head.firstChild);}m.setAttribute('content',d?'#0e1424':'#f4f7fd');}catch(e){}})();`;
|
||||
|
||||
/**
|
||||
* Captures Chrome's `beforeinstallprompt` (it can fire before React hydrates)
|
||||
|
|
|
|||
|
|
@ -257,23 +257,26 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.keyNote")}
|
||||
</p>
|
||||
|
||||
<details className="group">
|
||||
<summary className="touch-target flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
||||
{t("settings.localTipTitle")}
|
||||
</summary>
|
||||
<p className="pt-1 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.localTipSet")} <code>OLLAMA_AUTH=local</code> {t("settings.keyNoteAfter")}{" "}
|
||||
<code>.env.local</code> {t("settings.keyNoteEnd")} <code>ollama signin</code>
|
||||
{t("settings.keyNoteEnd2")}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.keyNote")}
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||
<details className="group">
|
||||
<summary className="touch-target -mt-1 flex cursor-pointer list-none items-center gap-1.5 text-[11px] font-medium text-[var(--text-muted)] transition hover:text-[var(--text)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 transition-transform group-open:rotate-180" />
|
||||
{t("settings.localTipTitle")}
|
||||
</summary>
|
||||
<p className="pt-0.5 text-[11px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t("settings.localTipSet")} <code>OLLAMA_AUTH=local</code>{" "}
|
||||
{t("settings.keyNoteAfter")} <code>.env.local</code> {t("settings.keyNoteEnd")}{" "}
|
||||
<code>ollama signin</code>
|
||||
{t("settings.keyNoteEnd2")}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
|
|
@ -288,38 +291,37 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
{t("settings.savedTesting")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiKey ? (
|
||||
confirmRemove ? (
|
||||
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
|
||||
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
|
||||
{t("settings.removeKeyConfirm")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
|
||||
{t("settings.removeKeyYes")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="touch-target"
|
||||
onClick={() => setConfirmRemove(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
{apiKey && !confirmRemove ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="touch-target -ml-3 text-rose-700 hover:bg-rose-500/10 dark:text-rose-300"
|
||||
className="touch-target -ml-2.5 text-rose-700 hover:bg-rose-500/10 dark:text-rose-300"
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
>
|
||||
{t("settings.removeKey")}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{apiKey && confirmRemove ? (
|
||||
<div className="rounded-xl border border-rose-300/70 bg-rose-50 p-3 dark:border-rose-400/25 dark:bg-rose-500/10">
|
||||
<p className="text-[12px] leading-relaxed text-rose-900 dark:text-rose-100">
|
||||
{t("settings.removeKeyConfirm")}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="danger" className="touch-target" onClick={forget}>
|
||||
{t("settings.removeKeyYes")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="touch-target"
|
||||
onClick={() => setConfirmRemove(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
|
|
@ -371,22 +373,21 @@ export function SettingsDialog({ open, onClose }: { open: boolean; onClose: () =
|
|||
|
||||
{health ? <HealthPanel health={health} /> : null}
|
||||
|
||||
{/* breathing room for the scroll cue + safe area */}
|
||||
<div className="h-5" aria-hidden />
|
||||
{/* keeps real content out of the 36px fade strip at the bottom */}
|
||||
<div className="h-9" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* visible scroll cue */}
|
||||
{/* scroll cue: a bare chevron inside the fade strip, so it never
|
||||
covers text (the .sheet-scroll mask fades what is underneath) */}
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 bottom-2 flex justify-center transition-opacity duration-200",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 flex h-9 items-end justify-center pb-1.5 transition-opacity duration-200",
|
||||
canScroll ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className="grid size-7 place-items-center rounded-full border border-[var(--hairline)] bg-[var(--surface-strong)] text-[var(--text-muted)] shadow-[0_4px_12px_-6px_rgb(14_21_40/0.5)] backdrop-blur-md">
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 text-[var(--text-subtle)]" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
LayersIcon,
|
||||
MoonIcon,
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
} from "@/components/icons";
|
||||
import { useTheme } from "@/components/ThemeToggle";
|
||||
|
|
@ -146,6 +147,17 @@ function NavContent({
|
|||
{!collapsed ? <span className="flex-1 truncate text-left">{t("common.newDeck")}</span> : null}
|
||||
</Link>
|
||||
|
||||
{/* make my homework: same quiet row --------------------------------- */}
|
||||
<Link
|
||||
href="/homework"
|
||||
onClick={onNavigate}
|
||||
className={cn("nav-row mb-3", collapsed && "justify-center px-2")}
|
||||
title={t("homework.nav")}
|
||||
>
|
||||
<SparklesIcon className="size-4 shrink-0" />
|
||||
{!collapsed ? <span className="flex-1 truncate text-left">{t("homework.nav")}</span> : null}
|
||||
</Link>
|
||||
|
||||
{/* folders --------------------------------------------------------- */}
|
||||
{/* Mobile keeps the original layout: one Folders label above the list. */}
|
||||
{!desktop && !collapsed ? (
|
||||
|
|
|
|||
|
|
@ -8,6 +8,28 @@ import { MoonIcon, SunIcon } from "@/components/icons";
|
|||
|
||||
const STORAGE_KEY = "freezmo.theme";
|
||||
|
||||
const THEME_COLORS = { light: "#f4f7fd", dark: "#0e1424" } as const;
|
||||
|
||||
/**
|
||||
* Keeps the first `theme-color` meta (the one the app owns) in step with the
|
||||
* in-app theme. The SSR metas follow the OS preference; this one always wins
|
||||
* because it is media-less and first in the head.
|
||||
*/
|
||||
export function syncThemeColor(theme: "light" | "dark") {
|
||||
try {
|
||||
let meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"][data-app]');
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "theme-color");
|
||||
meta.setAttribute("data-app", "1");
|
||||
document.head.insertBefore(meta, document.head.firstChild);
|
||||
}
|
||||
meta.setAttribute("content", THEME_COLORS[theme]);
|
||||
} catch {
|
||||
/* private mode / odd embedded browsers */
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
|
|
@ -17,11 +39,13 @@ export function useTheme() {
|
|||
? stored === "dark"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
setTheme(isDark ? "dark" : "light");
|
||||
syncThemeColor(isDark ? "dark" : "light");
|
||||
}, []);
|
||||
|
||||
const apply = (next: "light" | "dark") => {
|
||||
setTheme(next);
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
syncThemeColor(next);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
|
|
@ -32,7 +33,7 @@ import { UNORGANIZED_ID, type Difficulty } from "@/lib/schemas";
|
|||
import { upsertDeck } from "@/lib/storage";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn, uid } from "@/lib/utils";
|
||||
import { CameraIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SunIcon } from "@/components/icons";
|
||||
import { CameraIcon, ChevronRightIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
|
||||
type Phase = "idle" | "preparing" | "loading" | "error";
|
||||
|
|
@ -320,6 +321,24 @@ export function UploadScreen() {
|
|||
</p>
|
||||
</section>
|
||||
|
||||
{/* The other thing you can bring here: an assignment. */}
|
||||
<Link
|
||||
href="/homework"
|
||||
className="card card-hover flex items-center gap-3 p-4 text-left"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-10 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text-muted)]"
|
||||
>
|
||||
<SparklesIcon className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[14px] font-semibold text-[var(--text)]">{t("homework.entry")}</span>
|
||||
<span className="block text-[12.5px] text-[var(--text-subtle)]">{t("homework.nav")} →</span>
|
||||
</span>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
||||
</Link>
|
||||
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function isFillBlankCorrect(userValue: string, answer: string): boolean {
|
|||
});
|
||||
}
|
||||
|
||||
export function FillBlank({ activity, active, onAnswered, onNext, isLast }: ActivityComponentProps<FillBlankActivity>) {
|
||||
export function FillBlank({ activity, active, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FillBlankActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState("");
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
|
|
@ -185,6 +185,7 @@ export function FillBlank({ activity, active, onAnswered, onNext, isLast }: Acti
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
|
||||
type FlashcardActivity = Extract<Activity, { type: "flashcard" }>;
|
||||
|
||||
export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<FlashcardActivity>) {
|
||||
export function Flashcard({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<FlashcardActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
|
|
@ -85,6 +85,7 @@ export function Flashcard({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
headline={result ? t("flashcard.frozenSolid") : t("flashcard.keepPile")}
|
||||
onNext={onNext}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
|
||||
type MatchingActivity = Extract<Activity, { type: "matching" }>;
|
||||
|
||||
export function Matching({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<MatchingActivity>) {
|
||||
export function Matching({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<MatchingActivity>) {
|
||||
const { t, plural } = useI18n();
|
||||
const terms = useMemo(
|
||||
() => shuffle(activity.pairs.map((pair, index) => ({ index, text: pair.term }))),
|
||||
|
|
@ -137,6 +137,7 @@ export function Matching({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
headline={misses === 0 ? t("matching.perfect") : t("matching.slips")}
|
||||
detail={
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export function MultipleChoice({
|
|||
onAnswered,
|
||||
onNext,
|
||||
isLast,
|
||||
nextLabel,
|
||||
}: ActivityComponentProps<MultipleChoiceActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
|
@ -86,6 +87,7 @@ export function MultipleChoice({
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
selected === activity.correctIndex ? null : (
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
|
||||
type OrderingActivity = Extract<Activity, { type: "ordering" }>;
|
||||
|
||||
export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<OrderingActivity>) {
|
||||
export function Ordering({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<OrderingActivity>) {
|
||||
const { t } = useI18n();
|
||||
const initial = useMemo(() => shuffle(activity.items), [activity]);
|
||||
const [items, setItems] = useState<string[]>(initial);
|
||||
|
|
@ -126,6 +126,7 @@ export function Ordering({ activity, onAnswered, onNext, isLast }: ActivityCompo
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ type GradeState =
|
|||
| { status: "error"; message: string }
|
||||
| { status: "self-check" };
|
||||
|
||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||
export function ShortAnswer({ activity, active, language, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<ShortAnswerActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState("");
|
||||
const [state, setState] = useState<GradeState>({ status: "idle" });
|
||||
|
|
@ -182,6 +182,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
|
||||
type TrueFalseActivity = Extract<Activity, { type: "true_false" }>;
|
||||
|
||||
export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
export function TrueFalse({ activity, onAnswered, onNext, isLast, nextLabel }: ActivityComponentProps<TrueFalseActivity>) {
|
||||
const { t } = useI18n();
|
||||
const [result, setResult] = useState<boolean | null>(null);
|
||||
const x = useMotionValue(0);
|
||||
|
|
@ -84,6 +84,7 @@ export function TrueFalse({ activity, onAnswered, onNext, isLast }: ActivityComp
|
|||
explanation={activity.explanation}
|
||||
xp={activity.xp}
|
||||
isLast={isLast}
|
||||
nextLabel={nextLabel}
|
||||
onNext={onNext}
|
||||
detail={
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export type ActivityComponentProps<A extends Activity = Activity> = {
|
|||
/** Scroll the feed to the next slide. */
|
||||
onNext: () => void;
|
||||
isLast: boolean;
|
||||
/** Overrides the feedback button label (the homework guide uses this). */
|
||||
nextLabel?: string;
|
||||
};
|
||||
|
||||
export const TYPE_META: Record<Activity["type"], { labelKey: string; icon: string; tint: string }> = {
|
||||
|
|
@ -133,6 +135,7 @@ export function FeedbackPanel({
|
|||
xp,
|
||||
onNext,
|
||||
isLast,
|
||||
nextLabel,
|
||||
headline,
|
||||
detail,
|
||||
children,
|
||||
|
|
@ -142,6 +145,8 @@ export function FeedbackPanel({
|
|||
xp?: number;
|
||||
onNext: () => void;
|
||||
isLast?: boolean;
|
||||
/** Overrides the default “Next” / “See results” label. */
|
||||
nextLabel?: string;
|
||||
headline?: string;
|
||||
detail?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
|
|
@ -193,7 +198,7 @@ export function FeedbackPanel({
|
|||
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<Button onClick={onNext} size="md" className="min-w-28">
|
||||
{isLast ? t("activity.seeResults") : t("activity.next")}
|
||||
{nextLabel ?? (isLast ? t("activity.seeResults") : t("activity.next"))}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
33
components/homework/CopyButton.tsx
Normal file
33
components/homework/CopyButton.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Copy-to-clipboard with a short “Copied!” state and a manual fallback note. */
|
||||
export function CopyButton({ text, className }: { text: string; className?: string }) {
|
||||
const { t } = useI18n();
|
||||
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
const copy = async () => {
|
||||
const ok = await copyText(text);
|
||||
setState(ok ? "copied" : "failed");
|
||||
window.setTimeout(() => setState("idle"), 2200);
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn("inline-flex flex-col items-start gap-1", className)}>
|
||||
<Button className="touch-target" onClick={() => void copy()}>
|
||||
{state === "copied" ? t("homework.result.copied") : t("homework.result.copy")}
|
||||
</Button>
|
||||
{state === "failed" ? (
|
||||
<span role="status" className="text-[12px] text-rose-700 dark:text-rose-300">
|
||||
{t("homework.result.copyFailed")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
253
components/homework/HomeworkGuide.tsx
Normal file
253
components/homework/HomeworkGuide.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { ActivityRenderer } from "@/components/activities";
|
||||
import { PromptCard, QuestionText } from "@/components/activities/shared";
|
||||
import { XIcon } from "@/components/icons";
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
import { assembleSolution } from "@/lib/homework";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkGuideRecord, HomeworkStep } from "@/lib/schemas";
|
||||
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
/**
|
||||
* Mode 2: one step per screen in the same snap-feed as the study feed.
|
||||
* Each step explains what to do, reveals hints one at a time (nudge → hint →
|
||||
* the full answer), and can carry a checkpoint rendered by the shared activity
|
||||
* components. The last screen assembles the solution from the step answers.
|
||||
*/
|
||||
export function HomeworkGuide({
|
||||
record,
|
||||
onClose,
|
||||
onAgain,
|
||||
onNew,
|
||||
againLoading,
|
||||
}: {
|
||||
record: HomeworkGuideRecord;
|
||||
onClose: () => void;
|
||||
onAgain: () => void;
|
||||
onNew: () => void;
|
||||
againLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const steps = record.steps;
|
||||
const slideCount = steps.length + 1; // + the solution slide
|
||||
const [index, setIndex] = useState(0);
|
||||
const [revealed, setRevealed] = useState<Record<number, number>>({});
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const goTo = useCallback(
|
||||
(target: number) => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const clamped = Math.max(0, Math.min(target, slideCount - 1));
|
||||
container.scrollTo({ top: clamped * container.clientHeight, behavior: "smooth" });
|
||||
},
|
||||
[slideCount],
|
||||
);
|
||||
|
||||
// Track the visible slide (wheel, touch and programmatic scroll).
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
let frame = 0;
|
||||
const onScroll = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const height = container.clientHeight || 1;
|
||||
const current = Math.round(container.scrollTop / height);
|
||||
setIndex((previous) => (previous === current ? previous : current));
|
||||
});
|
||||
};
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", onScroll);
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation, like the study feed.
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable)) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "j") {
|
||||
event.preventDefault();
|
||||
goTo(index + 1);
|
||||
} else if (event.key === "ArrowUp" || event.key === "PageUp" || event.key === "k") {
|
||||
event.preventDefault();
|
||||
goTo(index - 1);
|
||||
} else if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [goTo, index, onClose]);
|
||||
|
||||
const solution = useMemo(() => assembleSolution(record), [record]);
|
||||
const progressValue = Math.min(index + 1, slideCount);
|
||||
const onSolution = index >= steps.length;
|
||||
|
||||
const revealedCount = (stepIndex: number) => revealed[stepIndex] ?? 0;
|
||||
|
||||
const revealNext = (stepIndex: number, hints: string[]) =>
|
||||
setRevealed((current) => ({
|
||||
...current,
|
||||
[stepIndex]: Math.min((current[stepIndex] ?? 0) + 1, hints.length),
|
||||
}));
|
||||
|
||||
const hintButtonLabel = (step: HomeworkStep, stepIndex: number) => {
|
||||
const count = revealedCount(stepIndex);
|
||||
if (count === 0) return t("homework.guide.hint");
|
||||
if (count === step.hints.length - 1) return t("homework.guide.hintAnswer");
|
||||
return t("homework.guide.hintMore");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="frost-bg fixed inset-0 z-[45] flex flex-col">
|
||||
{/* chrome ------------------------------------------------------- */}
|
||||
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
|
||||
<div className="study-chrome mx-auto max-w-xl space-y-1.5 rounded-full px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("homework.guide.close")}
|
||||
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProgressBar
|
||||
value={progressValue}
|
||||
max={slideCount}
|
||||
label={t("homework.guide.stepOf", { n: progressValue, total: slideCount })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-semibold text-[var(--text-muted)]">
|
||||
<span className="min-w-0 truncate">{record.title}</span>
|
||||
<span className="shrink-0">
|
||||
{onSolution ? t("homework.guide.solution") : t("homework.guide.stepOf", { n: index + 1, total: steps.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* slides ------------------------------------------------------- */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="homework-guide"
|
||||
className="no-scrollbar h-full snap-y snap-mandatory overflow-y-scroll overscroll-y-contain [overflow-anchor:none]"
|
||||
>
|
||||
{steps.map((step, stepIndex) => (
|
||||
<section
|
||||
key={stepIndex}
|
||||
aria-label={t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })}
|
||||
className="flex h-full snap-start snap-always items-stretch justify-center overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="tint-sky inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm">
|
||||
{t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PromptCard>
|
||||
<QuestionText>{step.title}</QuestionText>
|
||||
<p className="mt-2 text-[15px] leading-relaxed text-frost-700 dark:text-frost-200">
|
||||
{step.explanation}
|
||||
</p>
|
||||
</PromptCard>
|
||||
|
||||
{/* progressive hints */}
|
||||
<div className="space-y-2">
|
||||
{step.hints.slice(0, revealedCount(stepIndex)).map((hint, hintIndex) => (
|
||||
<motion.div
|
||||
key={hintIndex}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-chip rounded-2xl p-3 text-sm leading-relaxed text-[var(--text)]"
|
||||
>
|
||||
<span className="font-bold">{t("homework.guide.hintN", { n: hintIndex + 1 })}: </span>
|
||||
{hint}
|
||||
</motion.div>
|
||||
))}
|
||||
{revealedCount(stepIndex) < step.hints.length ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="touch-target"
|
||||
onClick={() => revealNext(stepIndex, step.hints)}
|
||||
>
|
||||
{hintButtonLabel(step, stepIndex)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* checkpoint or a plain next button */}
|
||||
{step.checkpoint ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("homework.guide.tryIt")}
|
||||
</p>
|
||||
<ActivityRenderer
|
||||
activity={step.checkpoint}
|
||||
active={index === stepIndex}
|
||||
language={record.language}
|
||||
isLast={stepIndex === steps.length - 1}
|
||||
nextLabel={
|
||||
stepIndex === steps.length - 1 ? t("homework.guide.showSolution") : t("homework.guide.next")
|
||||
}
|
||||
onAnswered={() => {
|
||||
/* checkpoints are for the student, not for scoring */
|
||||
}}
|
||||
onNext={() => goTo(stepIndex + 1)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button className="touch-target self-start" onClick={() => goTo(stepIndex + 1)}>
|
||||
{stepIndex === steps.length - 1 ? t("homework.guide.showSolution") : t("homework.guide.next")}
|
||||
<span aria-hidden>→</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* the assembled solution -------------------------------------- */}
|
||||
<section
|
||||
aria-label={t("homework.guide.solution")}
|
||||
className="h-full snap-start snap-always overflow-y-auto overscroll-y-contain"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="tint-mint inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm">
|
||||
{t("homework.guide.solution")}
|
||||
</span>
|
||||
</div>
|
||||
<PromptCard>
|
||||
<p className="whitespace-pre-wrap break-words text-[14.5px] leading-relaxed text-[var(--text)]">
|
||||
{solution}
|
||||
</p>
|
||||
</PromptCard>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.guide.solutionNote")}</p>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<CopyButton text={solution} />
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.result.again")}
|
||||
</Button>
|
||||
<Button variant="ghost" className="touch-target" onClick={onNew}>
|
||||
{t("homework.result.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
components/homework/HomeworkHistory.tsx
Normal file
89
components/homework/HomeworkHistory.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { ChevronRightIcon, XIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import { homeworkModeLabelKey } from "@/lib/homework";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkRecord } from "@/lib/schemas";
|
||||
|
||||
/** Small history list under the input: open a past result or delete it. */
|
||||
export function HomeworkHistory({
|
||||
records,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
records: HomeworkRecord[];
|
||||
onOpen: (record: HomeworkRecord) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
|
||||
if (records.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
|
||||
{t("homework.history.title")}
|
||||
</h2>
|
||||
<ul className="list-group">
|
||||
{records.map((record) =>
|
||||
confirmId === record.id ? (
|
||||
<li key={record.id} className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5">
|
||||
<span className="text-[12.5px] font-medium text-[var(--text-muted)]">
|
||||
{t("homework.history.deleteConfirm")}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
className="touch-target"
|
||||
onClick={() => {
|
||||
onDelete(record.id);
|
||||
setConfirmId(null);
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="touch-target" onClick={() => setConfirmId(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
) : (
|
||||
<li key={record.id} className="flex items-center gap-1 px-1.5 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(record)}
|
||||
aria-label={t("homework.history.openTitle", { title: record.title })}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-xl px-2 py-1.5 text-left transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13.5px] font-medium text-[var(--text)]">{record.title}</span>
|
||||
<span className="block text-[11.5px] text-[var(--text-subtle)]">
|
||||
{t(homeworkModeLabelKey(record.mode))} ·{" "}
|
||||
{new Date(record.updatedAt).toLocaleDateString(locale === "cs" ? "cs-CZ" : "en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-[var(--text-subtle)]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmId(record.id)}
|
||||
aria-label={`${t("common.delete")} — ${record.title}`}
|
||||
className="touch-target grid size-9 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-rose-600 dark:hover:text-rose-300"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
339
components/homework/HomeworkInput.tsx
Normal file
339
components/homework/HomeworkInput.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { CameraIcon, FolderIcon, PlayIcon, PlusIcon, SparklesIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
DOCUMENT_ACCEPT_ATTR,
|
||||
documentsToText,
|
||||
type ExtractedDocument,
|
||||
} from "@/lib/documents";
|
||||
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
|
||||
import { ACCEPTED_TYPES, formatBytes, imageFilesFromDataTransfer } from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { useNotesIntake } from "@/lib/use-notes-intake";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { HomeworkHistory } from "./HomeworkHistory";
|
||||
|
||||
/** Same wording the create screen uses for an extracted document. */
|
||||
function describeExtraction(
|
||||
document: ExtractedDocument,
|
||||
tr: (key: string, vars?: Record<string, string | number>) => string,
|
||||
pl: (n: number, base: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const parts = [
|
||||
pl(document.words, "upload.words", { n: document.words.toLocaleString() }),
|
||||
document.pages ? pl(document.pages, "upload.pages") : "",
|
||||
document.images.length ? pl(document.images.length, "upload.pageImages") : "",
|
||||
document.truncated ? tr("upload.truncated") : "",
|
||||
];
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
const MODES: Array<{ id: HomeworkMode; icon: React.ReactNode; titleKey: string; subtitleKey: string }> = [
|
||||
{ id: "make", icon: <SparklesIcon className="size-5" />, titleKey: "homework.mode.make.title", subtitleKey: "homework.mode.make.subtitle" },
|
||||
{ id: "guide", icon: <PlayIcon className="size-5" />, titleKey: "homework.mode.guide.title", subtitleKey: "homework.mode.guide.subtitle" },
|
||||
];
|
||||
|
||||
export function HomeworkInput({
|
||||
mode,
|
||||
onModeChange,
|
||||
text,
|
||||
onTextChange,
|
||||
intake,
|
||||
onSubmit,
|
||||
error,
|
||||
history,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
mode: HomeworkMode;
|
||||
onModeChange: (mode: HomeworkMode) => void;
|
||||
text: string;
|
||||
onTextChange: (text: string) => void;
|
||||
intake: ReturnType<typeof useNotesIntake>;
|
||||
onSubmit: () => void;
|
||||
error: { message: string; kind?: string } | null;
|
||||
history: HomeworkRecord[];
|
||||
onOpen: (record: HomeworkRecord) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { t, plural } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Clipboard: screenshots become photos, long copied text becomes the assignment.
|
||||
useEffect(() => {
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const isTextField = target && /^(INPUT|TEXTAREA)$/.test(target.tagName);
|
||||
const files = imageFilesFromDataTransfer(event.clipboardData);
|
||||
if (files.length > 0) {
|
||||
event.preventDefault();
|
||||
void intake.addFiles(files);
|
||||
return;
|
||||
}
|
||||
if (!isTextField) {
|
||||
const pasted = event.clipboardData?.getData("text/plain")?.trim();
|
||||
if (pasted && pasted.length > 40) {
|
||||
event.preventDefault();
|
||||
onTextChange(text ? `${text}\n${pasted}` : pasted);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
}, [intake, onTextChange, text]);
|
||||
|
||||
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
const documentsBusy = intake.documents.some((entry) => entry.status === "extracting");
|
||||
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
|
||||
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
|
||||
const totalBytes = intake.images.reduce((sum, image) => sum + image.bytes, 0);
|
||||
|
||||
const hasContent =
|
||||
intake.images.length > 0 || documentImages.length > 0 || text.trim().length > 0 || documentText.trim().length > 0;
|
||||
const canSubmit = hasContent && !intake.busy && !documentsBusy;
|
||||
const keyError = error?.kind === "needs_key" || error?.kind === "auth";
|
||||
|
||||
return (
|
||||
<div className="relative space-y-6">
|
||||
<section className="space-y-2">
|
||||
<h1 className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[28px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance sm:text-[32px]">
|
||||
<SparklesIcon className="size-8 shrink-0 text-frost-500 dark:text-frost-200" />
|
||||
<span>{t("homework.title")}</span>
|
||||
</h1>
|
||||
<p className="max-w-xl text-[15px] text-frost-600 dark:text-frost-300">{t("homework.subtitle")}</p>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="animate-rise rounded-3xl border-2 border-rose-300 bg-rose-50/95 p-4 text-sm text-rose-900 dark:border-rose-400/40 dark:bg-rose-400/10 dark:text-rose-100"
|
||||
>
|
||||
<p className="font-bold">{keyError ? t("upload.errAuth") : t("homework.error.title")}</p>
|
||||
<p className="mt-1">{error.message || t("homework.error.body")}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{keyError ? (
|
||||
<Button size="sm" onClick={openSettings}>
|
||||
{t("upload.openSettings")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant={keyError ? "secondary" : "primary"} onClick={onSubmit}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ------------------------------------------------ the assignment */}
|
||||
<section className="card p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">
|
||||
{t("homework.source.title")}
|
||||
</h2>
|
||||
<p className="text-[12.5px] text-[var(--text-subtle)]">{t("homework.source.help")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" className="touch-target" onClick={() => fileInputRef.current?.click()}>
|
||||
<FolderIcon className="size-4" /> {t("homework.source.add")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="touch-target" onClick={() => cameraInputRef.current?.click()}>
|
||||
<CameraIcon className="size-4" /> {t("upload.camera")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={`${ACCEPTED_TYPES.join(",")},${DOCUMENT_ACCEPT_ATTR}`}
|
||||
multiple
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void intake.addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
void intake.addFiles(Array.from(event.target.files ?? []));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{intake.images.length === 0 && intake.documents.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]"
|
||||
>
|
||||
<span aria-hidden className="text-frost-500 dark:text-frost-200">
|
||||
<PlusIcon className="size-10" />
|
||||
</span>
|
||||
<span className="text-[14px] font-medium text-[var(--text)]">{t("homework.source.drop")}</span>
|
||||
<span className="text-[12px] text-[var(--text-subtle)]">{t("homework.source.formats")}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{intake.images.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{intake.images.map((image) => (
|
||||
<span key={image.id} className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local thumbnail, no optimisation needed */}
|
||||
<img
|
||||
src={image.dataUrl}
|
||||
alt=""
|
||||
className="size-14 rounded-xl border border-[var(--hairline)] object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => intake.removeImage(image.id)}
|
||||
aria-label={t("upload.remove", { name: image.name })}
|
||||
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full bg-frost-950/80 text-[11px] font-bold text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{intake.documents.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{intake.documents.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] p-2.5"
|
||||
>
|
||||
<span className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 text-[10px] font-bold uppercase text-[var(--text-muted)]">
|
||||
{entry.name.split(".").pop()?.slice(0, 4) ?? "doc"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium text-[var(--text)]">{entry.name}</span>
|
||||
<span className="block text-[11.5px] text-[var(--text-subtle)]">
|
||||
{entry.status === "extracting"
|
||||
? (entry.progress ?? t("upload.reading"))
|
||||
: entry.status === "error"
|
||||
? entry.error
|
||||
: entry.extracted
|
||||
? describeExtraction(entry.extracted, t, plural)
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => intake.removeDocument(entry.id)}
|
||||
aria-label={t("upload.remove", { name: entry.name })}
|
||||
className="touch-target grid size-8 shrink-0 place-items-center rounded-lg text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label
|
||||
htmlFor="homework-assignment"
|
||||
className="mt-4 block text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]"
|
||||
>
|
||||
{t("homework.source.textLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
id="homework-assignment"
|
||||
value={text}
|
||||
onChange={(event) => onTextChange(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
placeholder={t("homework.source.placeholder")}
|
||||
rows={4}
|
||||
className="mt-1.5 w-full resize-y rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2.5 text-sm leading-relaxed text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-500"
|
||||
/>
|
||||
|
||||
{intake.images.length > 0 || documentImages.length > 0 ? (
|
||||
<p className="mt-2 text-[12px] text-[var(--text-subtle)]">
|
||||
{intake.images.length > 0
|
||||
? plural(intake.images.length, "upload.photoSummary", { size: formatBytes(totalBytes) })
|
||||
: ""}
|
||||
{intake.images.length > 0 && documentImages.length > 0 ? " · " : ""}
|
||||
{documentImages.length > 0 ? plural(documentImages.length, "upload.docImages") : ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{intake.busy ? (
|
||||
<p className="mt-2 text-[12.5px] font-medium text-[var(--text-muted)]">{t("upload.compressing")}</p>
|
||||
) : null}
|
||||
{intake.fileError ? (
|
||||
<p role="alert" className="mt-2 text-[12.5px] font-medium text-rose-700 dark:text-rose-300">
|
||||
{intake.fileError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------- mode */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-[15px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.mode.title")}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{MODES.map((option) => {
|
||||
const selected = mode === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onModeChange(option.id)}
|
||||
className={cn(
|
||||
"flex min-h-24 items-start gap-3 rounded-2xl border p-4 text-left transition",
|
||||
selected
|
||||
? "border-accent-400 bg-accent-50/70 ring-2 ring-accent-400/40 dark:bg-accent-500/15"
|
||||
: "border-[var(--hairline)] bg-[var(--surface-strong)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"grid size-10 shrink-0 place-items-center rounded-xl",
|
||||
selected ? "grad-ice text-[#06111f]" : "bg-[var(--surface-sunken)] text-[var(--text-muted)]",
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px] font-semibold leading-snug text-[var(--text)]">
|
||||
{t(option.titleKey)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12.5px] leading-relaxed text-[var(--text-subtle)]">
|
||||
{t(option.subtitleKey)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* -------------------------------------------------------- CTA */}
|
||||
<div className="space-y-2">
|
||||
<Button size="lg" className="touch-target w-full" onClick={onSubmit} disabled={!canSubmit} loading={intake.busy}>
|
||||
{mode === "make" ? t("homework.start.make") : t("homework.start.guide")}
|
||||
</Button>
|
||||
<p className="text-center text-[12px] text-[var(--text-subtle)]">
|
||||
{canSubmit ? t("homework.source.ready") : t("homework.source.need")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HomeworkHistory records={history} onOpen={onOpen} onDelete={onDelete} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
components/homework/HomeworkLoading.tsx
Normal file
51
components/homework/HomeworkLoading.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button, ProgressBar, Spinner } from "@/components/ui";
|
||||
import type { HomeworkProgress } from "@/lib/client-api";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkMode } from "@/lib/schemas";
|
||||
|
||||
/** Staged loading state while the assignment is read and answered. */
|
||||
export function HomeworkLoading({
|
||||
progress,
|
||||
mode,
|
||||
onCancel,
|
||||
}: {
|
||||
progress: HomeworkProgress;
|
||||
mode: HomeworkMode;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const vision = progress.stage === "vision";
|
||||
const keys = vision
|
||||
? ["homework.loading.reading", "homework.loading.vision2"]
|
||||
: mode === "make"
|
||||
? ["homework.loading.writing", "homework.loading.gen2", "homework.loading.gen3"]
|
||||
: ["homework.loading.planning", "homework.loading.gen2", "homework.loading.gen3"];
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setTick((value) => value + 1), 2400);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const message = progress.message && tick === 0 ? progress.message : t(keys[tick % keys.length]);
|
||||
|
||||
return (
|
||||
<div className="animate-rise flex flex-col items-center gap-5 py-16 text-center sm:py-24">
|
||||
<Spinner className="size-8 text-accent-500" />
|
||||
<h1 className="text-[20px] font-semibold tracking-[-0.01em] text-[var(--text)]">{t("homework.loading.title")}</h1>
|
||||
<div className="w-full max-w-sm space-y-2">
|
||||
<ProgressBar value={vision ? 1 : 2} max={2} label={message} />
|
||||
<p className="text-[13.5px] font-medium text-[var(--text-muted)]" role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="touch-target" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
components/homework/HomeworkResult.tsx
Normal file
51
components/homework/HomeworkResult.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkMakeRecord } from "@/lib/schemas";
|
||||
|
||||
import { CopyButton } from "./CopyButton";
|
||||
|
||||
/** Mode 1 result: the finished homework, nothing else. */
|
||||
export function HomeworkResult({
|
||||
record,
|
||||
onAgain,
|
||||
onNew,
|
||||
againLoading,
|
||||
}: {
|
||||
record: HomeworkMakeRecord;
|
||||
onAgain: () => void;
|
||||
onNew: () => void;
|
||||
againLoading: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[var(--text-subtle)]">
|
||||
{t("homework.result.title")}
|
||||
</p>
|
||||
<h1 className="text-[22px] font-semibold leading-tight tracking-[-0.02em] text-[var(--text)] text-balance">
|
||||
{record.title}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<article className="card p-4 sm:p-5">
|
||||
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text)]">
|
||||
{record.answer}
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<CopyButton text={record.answer} />
|
||||
<Button variant="secondary" className="touch-target" onClick={onAgain} loading={againLoading}>
|
||||
{t("homework.result.again")}
|
||||
</Button>
|
||||
<Button variant="ghost" className="touch-target" onClick={onNew}>
|
||||
{t("homework.result.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
components/homework/HomeworkScreen.tsx
Normal file
188
components/homework/HomeworkScreen.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { documentsToText } from "@/lib/documents";
|
||||
import { ApiError, generateHomework, type HomeworkProgress } from "@/lib/client-api";
|
||||
import {
|
||||
deleteHomework,
|
||||
homeworkStore,
|
||||
saveHomework,
|
||||
sortHomework,
|
||||
updateHomework,
|
||||
type NewHomeworkResult,
|
||||
} from "@/lib/homework";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import type { HomeworkMode, HomeworkRecord } from "@/lib/schemas";
|
||||
import { useNotesIntake } from "@/lib/use-notes-intake";
|
||||
|
||||
import { HomeworkGuide } from "./HomeworkGuide";
|
||||
import { HomeworkInput } from "./HomeworkInput";
|
||||
import { HomeworkLoading } from "./HomeworkLoading";
|
||||
import { HomeworkResult } from "./HomeworkResult";
|
||||
|
||||
/** Photos + rendered PDF pages + images embedded in documents. */
|
||||
const MAX_IMAGES_TO_SEND = 12;
|
||||
const MAX_TEXT_TO_SEND = 100_000;
|
||||
|
||||
type View = { kind: "start" } | { kind: "loading" } | { kind: "record"; record: HomeworkRecord };
|
||||
|
||||
/** The /homework screen: give it an assignment, pick a mode, get a result. */
|
||||
export function HomeworkScreen() {
|
||||
const { t } = useI18n();
|
||||
const records = useSyncExternalStore(
|
||||
homeworkStore.subscribe,
|
||||
homeworkStore.getSnapshot,
|
||||
homeworkStore.getServerSnapshot,
|
||||
);
|
||||
const history = useMemo(() => sortHomework(records), [records]);
|
||||
const intake = useNotesIntake({
|
||||
tooManyPhotosKey: "homework.source.tooManyPhotos",
|
||||
tooManyDocsKey: "homework.source.tooManyDocs",
|
||||
});
|
||||
|
||||
const [mode, setMode] = useState<HomeworkMode>("make");
|
||||
const [text, setText] = useState("");
|
||||
const [view, setView] = useState<View>({ kind: "start" });
|
||||
const [progress, setProgress] = useState<HomeworkProgress>({ stage: "vision", message: "" });
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
const [againLoading, setAgainLoading] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
/** Everything the API needs, built from the current input. */
|
||||
const buildPayload = () => {
|
||||
const readyDocuments = intake.documents.filter((entry) => entry.status === "ready" && entry.extracted);
|
||||
const documentImages = readyDocuments.flatMap((entry) => entry.extracted!.images);
|
||||
const documentText = documentsToText(readyDocuments.map((entry) => entry.extracted!));
|
||||
const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND);
|
||||
const images = [
|
||||
...intake.images.map((image) => image.dataUrl),
|
||||
...documentImages.map((image) => image.dataUrl),
|
||||
].slice(0, MAX_IMAGES_TO_SEND);
|
||||
return { mode, images, text: mergedText };
|
||||
};
|
||||
|
||||
const run = useCallback(
|
||||
async (input: { mode: HomeworkMode; images: string[]; text: string; recordId?: string }) => {
|
||||
setError(null);
|
||||
setView({ kind: "loading" });
|
||||
const vision = input.images.length > 0;
|
||||
setProgress({
|
||||
stage: vision ? "vision" : "generation",
|
||||
message: vision
|
||||
? t("homework.loading.reading")
|
||||
: input.mode === "make"
|
||||
? t("homework.loading.writing")
|
||||
: t("homework.loading.planning"),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const { homework } = await generateHomework(
|
||||
{
|
||||
mode: input.mode,
|
||||
images: input.images.length > 0 ? input.images : undefined,
|
||||
text: input.text || undefined,
|
||||
},
|
||||
{ onProgress: setProgress, signal: controller.signal },
|
||||
);
|
||||
|
||||
// The server hands back the assignment as text (the transcription when
|
||||
// photos were used), so “Make it again” works without the files.
|
||||
const sourceText = homework.sourceText ?? input.text;
|
||||
const result: NewHomeworkResult =
|
||||
homework.mode === "make"
|
||||
? {
|
||||
mode: "make",
|
||||
title: homework.title,
|
||||
language: homework.language,
|
||||
sourceText,
|
||||
answer: homework.answer,
|
||||
}
|
||||
: {
|
||||
mode: "guide",
|
||||
title: homework.title,
|
||||
language: homework.language,
|
||||
sourceText,
|
||||
steps: homework.steps,
|
||||
};
|
||||
|
||||
const record = input.recordId ? updateHomework(input.recordId, result) : saveHomework(result);
|
||||
if (!record) throw new ApiError(t("homework.error.body"), "bad_response");
|
||||
setView({ kind: "record", record });
|
||||
} catch (generateError) {
|
||||
if (generateError instanceof ApiError && generateError.kind === "aborted") {
|
||||
setView({ kind: "start" });
|
||||
return;
|
||||
}
|
||||
setError({
|
||||
message: generateError instanceof ApiError ? generateError.message : t("homework.error.body"),
|
||||
kind: generateError instanceof ApiError ? generateError.kind : undefined,
|
||||
});
|
||||
setView({ kind: "start" });
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setAgainLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
const payload = buildPayload();
|
||||
void run(payload);
|
||||
};
|
||||
|
||||
/** Re-run from the stored assignment text (files were never stored). */
|
||||
const again = (record: HomeworkRecord) => {
|
||||
setAgainLoading(true);
|
||||
void run({ mode: record.mode, images: [], text: record.sourceText, recordId: record.id });
|
||||
};
|
||||
|
||||
const newAssignment = () => {
|
||||
intake.clear();
|
||||
setText("");
|
||||
setError(null);
|
||||
setView({ kind: "start" });
|
||||
};
|
||||
|
||||
if (view.kind === "loading") {
|
||||
return <HomeworkLoading progress={progress} mode={mode} onCancel={() => abortRef.current?.abort()} />;
|
||||
}
|
||||
|
||||
if (view.kind === "record") {
|
||||
return view.record.mode === "make" ? (
|
||||
<HomeworkResult
|
||||
record={view.record}
|
||||
onAgain={() => again(view.record)}
|
||||
onNew={newAssignment}
|
||||
againLoading={againLoading}
|
||||
/>
|
||||
) : (
|
||||
<HomeworkGuide
|
||||
record={view.record}
|
||||
onClose={() => setView({ kind: "start" })}
|
||||
onAgain={() => again(view.record)}
|
||||
onNew={newAssignment}
|
||||
againLoading={againLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeworkInput
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
text={text}
|
||||
onTextChange={setText}
|
||||
intake={intake}
|
||||
onSubmit={submit}
|
||||
error={error}
|
||||
history={history}
|
||||
onOpen={(record) => setView({ kind: "record", record })}
|
||||
onDelete={deleteHomework}
|
||||
/>
|
||||
);
|
||||
}
|
||||
109
lib/ai.ts
109
lib/ai.ts
|
|
@ -9,9 +9,13 @@ import { needsLanguageRepair } from "./language";
|
|||
import {
|
||||
buildGenerationUserPrompt,
|
||||
buildGradingUserPrompt,
|
||||
buildHomeworkGuideUserPrompt,
|
||||
buildHomeworkMakeUserPrompt,
|
||||
buildRepairPrompt,
|
||||
GENERATION_SYSTEM_PROMPT,
|
||||
GRADING_SYSTEM_PROMPT,
|
||||
HOMEWORK_GUIDE_SYSTEM_PROMPT,
|
||||
HOMEWORK_MAKE_SYSTEM_PROMPT,
|
||||
VISION_SYSTEM_PROMPT,
|
||||
VISION_USER_INSTRUCTION,
|
||||
} from "./prompts";
|
||||
|
|
@ -19,10 +23,14 @@ import {
|
|||
extractedNotesSchema,
|
||||
generatedActivitySchema,
|
||||
gradeResultSchema,
|
||||
homeworkMakeSchema,
|
||||
homeworkStepSchema,
|
||||
type Difficulty,
|
||||
type ExtractedNotes,
|
||||
type GeneratedActivity,
|
||||
type GradeResult,
|
||||
type HomeworkGuide,
|
||||
type HomeworkMake,
|
||||
} from "./schemas";
|
||||
import { parseJsonLoose } from "./utils";
|
||||
import type { OllamaAuth } from "./config";
|
||||
|
|
@ -391,6 +399,107 @@ export async function gradeShortAnswer(input: {
|
|||
return value;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Make my homework */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const HOMEWORK_MAKE_SHAPE_HINT = `{"title":"...","language":"English name of the language used","answer":"the finished homework as plain text"}`;
|
||||
|
||||
/** Mode 1: the finished homework, with no commentary at all. */
|
||||
export async function makeHomework(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
assignment: string;
|
||||
language?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<HomeworkMake> {
|
||||
const { value } = await runJsonStep<HomeworkMake>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: HOMEWORK_MAKE_SYSTEM_PROMPT,
|
||||
user: buildHomeworkMakeUserPrompt({ assignment: input.assignment, language: input.language }),
|
||||
validate: (parsed) => {
|
||||
const result = homeworkMakeSchema.safeParse(parsed);
|
||||
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
|
||||
if (!result.data.answer.trim()) return { ok: false, error: "The answer field is empty." };
|
||||
return { ok: true, value: result.data };
|
||||
},
|
||||
shapeHint: HOMEWORK_MAKE_SHAPE_HINT,
|
||||
label: "Writing the homework",
|
||||
signal: input.signal,
|
||||
temperature: 0.4,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
const HOMEWORK_GUIDE_SHAPE_HINT = `{"title":"...","language":"English name of the language used","steps":[{"title":"...","explanation":"...","hints":["nudge","hint","answer"],"answer":"...","checkpoint":{"type":"multiple_choice","topic":"...","question":"...","options":["...","...","...","..."],"correctIndex":0,"explanation":"..."}}]}`;
|
||||
|
||||
/**
|
||||
* Mode 2: the interactive guide. Be forgiving with checkpoints — a step with a
|
||||
* broken checkpoint still ships (the checkpoint is simply dropped), because the
|
||||
* explanation, hints and answer are the valuable part.
|
||||
*/
|
||||
export async function buildHomeworkGuide(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
assignment: string;
|
||||
language?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<HomeworkGuide> {
|
||||
const { value } = await runJsonStep<HomeworkGuide>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: HOMEWORK_GUIDE_SYSTEM_PROMPT,
|
||||
user: buildHomeworkGuideUserPrompt({ assignment: input.assignment, language: input.language }),
|
||||
validate: (parsed) => {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { ok: false, error: "Expected a JSON object at the top level." };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
if (!Array.isArray(record.steps)) {
|
||||
return { ok: false, error: `Missing "steps" array. Expected 3-8 step objects.` };
|
||||
}
|
||||
|
||||
const steps: HomeworkGuide["steps"] = [];
|
||||
const rejected: string[] = [];
|
||||
|
||||
record.steps.slice(0, 12).forEach((raw, index) => {
|
||||
const result = homeworkStepSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
rejected.push(`#${index + 1}: ${summariseZodError(result.error)}`);
|
||||
return;
|
||||
}
|
||||
const step = result.data;
|
||||
if (!step.answer.trim() || !step.explanation.trim()) {
|
||||
rejected.push(`#${index + 1}: explanation or answer is empty.`);
|
||||
return;
|
||||
}
|
||||
steps.push({
|
||||
...step,
|
||||
hints: step.hints.map((hint) => hint.trim()).filter(Boolean).slice(0, 4),
|
||||
checkpoint: step.checkpoint ? sanitiseActivity(step.checkpoint) : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
if (steps.length < 2) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Only ${steps.length} usable steps were found, at least 2 are needed. Rejected: ${rejected.slice(0, 3).join("; ") || "none"}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : "Homework guide";
|
||||
const language = typeof record.language === "string" && record.language.trim() ? record.language.trim() : undefined;
|
||||
return { ok: true, value: { title, language, steps } };
|
||||
},
|
||||
shapeHint: HOMEWORK_GUIDE_SHAPE_HINT,
|
||||
label: "Building your guide",
|
||||
signal: input.signal,
|
||||
temperature: 0.5,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { localeHeader, t } from "./i18n";
|
||||
import type { Activity, Difficulty } from "./schemas";
|
||||
import type { Activity, Difficulty, HomeworkMode, HomeworkStep } from "./schemas";
|
||||
import { API_KEY_HEADER, authHeaders } from "./settings";
|
||||
|
||||
export type GenerateInput = {
|
||||
|
|
@ -44,6 +44,41 @@ export class ApiError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an NDJSON response line by line and hands each parsed event to the
|
||||
* callback. Malformed lines are skipped (a half-written line must never break
|
||||
* the stream), and the trailing buffer is flushed at the end.
|
||||
*/
|
||||
async function consumeNdjson(
|
||||
response: Response,
|
||||
handleEvent: (event: unknown) => void,
|
||||
): Promise<void> {
|
||||
if (!response.body) throw new ApiError(t("api.empty"), "bad_response");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const flush = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
try {
|
||||
handleEvent(JSON.parse(line));
|
||||
} catch {
|
||||
/* ignore malformed lines */
|
||||
}
|
||||
};
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
flush(buffer.slice(0, newline));
|
||||
buffer = buffer.slice(newline + 1);
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
flush(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls POST /api/generate and consumes the NDJSON progress stream.
|
||||
* Progress events arrive between the vision and generation steps.
|
||||
|
|
@ -71,55 +106,30 @@ export async function generateDeck(
|
|||
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
|
||||
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new ApiError(t("api.empty"), "bad_response");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let deck: StreamedDeck | null = null;
|
||||
let meta: Record<string, unknown> = {};
|
||||
let streamError: ApiError | null = null;
|
||||
let sawActivity = false;
|
||||
let sawDeck = false;
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let event: StreamEvent;
|
||||
try {
|
||||
event = JSON.parse(line) as StreamEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await consumeNdjson(response, (raw) => {
|
||||
const event = raw as StreamEvent;
|
||||
if (event.type === "stage") {
|
||||
options.onProgress?.({ stage: event.stage, message: event.message });
|
||||
} else if (event.type === "deck") {
|
||||
deck = event.deck;
|
||||
meta = event.meta ?? {};
|
||||
sawActivity = true;
|
||||
sawDeck = true;
|
||||
options.onProgress?.({ stage: "done", message: t("api.almost") });
|
||||
} else if (event.type === "error") {
|
||||
streamError = new ApiError(event.error, event.kind);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
handleLine(buffer.slice(0, newline));
|
||||
buffer = buffer.slice(newline + 1);
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
handleLine(buffer);
|
||||
});
|
||||
|
||||
if (streamError) throw streamError;
|
||||
if (!deck) {
|
||||
throw new ApiError(
|
||||
sawActivity
|
||||
sawDeck
|
||||
? t("api.incomplete")
|
||||
: t("api.noDeck"),
|
||||
"bad_response",
|
||||
|
|
@ -136,6 +146,102 @@ export async function generateDeck(
|
|||
return { deck: { ...(deck as StreamedDeck), activities: valid }, meta };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Make my homework */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type HomeworkInput = {
|
||||
mode: HomeworkMode;
|
||||
images?: string[];
|
||||
text?: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type HomeworkProgress = {
|
||||
stage: "vision" | "generation" | "done";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type StreamedHomework =
|
||||
| { mode: "make"; title: string; language?: string; answer: string; sourceText?: string }
|
||||
| { mode: "guide"; title: string; language?: string; steps: HomeworkStep[]; sourceText?: string };
|
||||
|
||||
type HomeworkStreamEvent =
|
||||
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
|
||||
| { type: "homework"; homework: StreamedHomework; meta: Record<string, unknown> }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
|
||||
/**
|
||||
* Calls POST /api/homework and consumes the NDJSON progress stream. Same shape
|
||||
* as generateDeck: stage events first, then one final payload.
|
||||
*/
|
||||
export async function generateHomework(
|
||||
input: HomeworkInput,
|
||||
options: { onProgress?: (progress: HomeworkProgress) => void; signal?: AbortSignal } = {},
|
||||
): Promise<{ homework: StreamedHomework; meta: Record<string, unknown> }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/homework", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() },
|
||||
body: JSON.stringify(input),
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError(t("api.cancelled"), "aborted");
|
||||
}
|
||||
throw new ApiError(t("api.unreachable"), "network");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
|
||||
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
|
||||
}
|
||||
|
||||
let homework: StreamedHomework | null = null;
|
||||
let meta: Record<string, unknown> = {};
|
||||
let streamError: ApiError | null = null;
|
||||
|
||||
await consumeNdjson(response, (raw) => {
|
||||
const event = raw as HomeworkStreamEvent;
|
||||
if (event.type === "stage") {
|
||||
options.onProgress?.({ stage: event.stage, message: event.message });
|
||||
} else if (event.type === "homework") {
|
||||
homework = event.homework;
|
||||
meta = event.meta ?? {};
|
||||
options.onProgress?.({ stage: "done", message: t("api.almost") });
|
||||
} else if (event.type === "error") {
|
||||
streamError = new ApiError(event.error, event.kind);
|
||||
}
|
||||
});
|
||||
|
||||
if (streamError) throw streamError;
|
||||
if (!homework) {
|
||||
throw new ApiError(t("api.noHomework"), "bad_response");
|
||||
}
|
||||
|
||||
const result = homework as StreamedHomework;
|
||||
if (result.mode === "make") {
|
||||
if (!result.answer?.trim()) throw new ApiError(t("api.noHomework"), "bad_response");
|
||||
} else {
|
||||
const steps = result.steps?.filter((step) => step && typeof step.title === "string");
|
||||
if (!steps || steps.length < 2) throw new ApiError(t("api.noHomework"), "bad_response");
|
||||
// Checkpoints must look like real activities; drop anything malformed
|
||||
// rather than crash the guide.
|
||||
const cleaned = steps.map((step) => ({
|
||||
...step,
|
||||
checkpoint:
|
||||
step.checkpoint && typeof step.checkpoint.type === "string" && typeof step.checkpoint.id === "string"
|
||||
? step.checkpoint
|
||||
: undefined,
|
||||
}));
|
||||
return { homework: { ...result, steps: cleaned }, meta };
|
||||
}
|
||||
|
||||
return { homework: result, meta };
|
||||
}
|
||||
|
||||
export async function gradeAnswer(input: {
|
||||
question: string;
|
||||
referenceAnswer: string;
|
||||
|
|
|
|||
37
lib/clipboard.ts
Normal file
37
lib/clipboard.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard.
|
||||
*
|
||||
* `navigator.clipboard` only exists in a secure context, and Freezmo is often
|
||||
* used over plain http on a LAN, so fall back to the classic hidden-textarea
|
||||
* trick. Returns false when neither path works (the caller can then tell the
|
||||
* user to copy manually).
|
||||
*/
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to the legacy path */
|
||||
}
|
||||
|
||||
try {
|
||||
const area = document.createElement("textarea");
|
||||
area.value = text;
|
||||
area.setAttribute("readonly", "");
|
||||
area.style.position = "fixed";
|
||||
area.style.top = "0";
|
||||
area.style.opacity = "0";
|
||||
document.body.appendChild(area);
|
||||
area.focus();
|
||||
area.select();
|
||||
const ok = document.execCommand("copy");
|
||||
area.remove();
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ export type ServerConfig = {
|
|||
windowMs: number;
|
||||
generate: number;
|
||||
grade: number;
|
||||
homework: number;
|
||||
health: number;
|
||||
};
|
||||
};
|
||||
|
|
@ -95,6 +96,8 @@ export function getServerConfig(): ServerConfig {
|
|||
windowMs: positiveInt(process.env.RATE_LIMIT_WINDOW_MS, 300_000),
|
||||
generate: positiveInt(process.env.RATE_LIMIT_GENERATE, 12),
|
||||
grade: positiveInt(process.env.RATE_LIMIT_GRADE, 40),
|
||||
// Homework is one to two model calls, like a deck.
|
||||
homework: positiveInt(process.env.RATE_LIMIT_HOMEWORK, 12),
|
||||
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
160
lib/homework.ts
Normal file
160
lib/homework.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Homework results backed by localStorage. Same tiny observable store as the
|
||||
* deck library, but deliberately smaller: no folders, no attempts — just the
|
||||
* assignment text (so “Make it again” works) and the result.
|
||||
*
|
||||
* Original photos/documents are never stored; they were reduced to text on the
|
||||
* client before this point.
|
||||
*/
|
||||
|
||||
import {
|
||||
homeworkRecordSchema,
|
||||
type HomeworkGuideRecord,
|
||||
type HomeworkMode,
|
||||
type HomeworkRecord,
|
||||
type HomeworkStep,
|
||||
} from "./schemas";
|
||||
import { uid } from "./utils";
|
||||
|
||||
const STORAGE_KEY = "freezmo.homework.v1";
|
||||
const EMPTY: HomeworkRecord[] = [];
|
||||
/** Enough to regenerate from, while staying well under the storage quota. */
|
||||
export const MAX_STORED_SOURCE = 24_000;
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let records: HomeworkRecord[] | null = null;
|
||||
let cached: HomeworkRecord[] = EMPTY;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function read(): HomeworkRecord[] {
|
||||
if (typeof window === "undefined") return EMPTY;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return EMPTY;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return EMPTY;
|
||||
const valid: HomeworkRecord[] = [];
|
||||
for (const entry of parsed) {
|
||||
const result = homeworkRecordSchema.safeParse(entry);
|
||||
if (result.success) valid.push(result.data as HomeworkRecord);
|
||||
else console.warn("[freezmo] skipping invalid homework record", result.error.issues);
|
||||
}
|
||||
return valid;
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not read the homework history", error);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoaded(): void {
|
||||
if (records !== null) return;
|
||||
records = read();
|
||||
cached = records;
|
||||
}
|
||||
|
||||
function write(next: HomeworkRecord[]): void {
|
||||
records = next;
|
||||
cached = next;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not save the homework history (storage full?)", error);
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const homeworkStore = {
|
||||
subscribe(listener: Listener): () => void {
|
||||
ensureLoaded();
|
||||
listeners.add(listener);
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
records = null;
|
||||
ensureLoaded();
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
},
|
||||
getSnapshot(): HomeworkRecord[] {
|
||||
ensureLoaded();
|
||||
return cached;
|
||||
},
|
||||
getServerSnapshot(): HomeworkRecord[] {
|
||||
return EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
/** Newest first. */
|
||||
export function sortHomework(list: HomeworkRecord[]): HomeworkRecord[] {
|
||||
return [...list].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
export function getHomework(id: string): HomeworkRecord | undefined {
|
||||
return homeworkStore.getSnapshot().find((record) => record.id === id);
|
||||
}
|
||||
|
||||
/** Everything needed to store a result (the route's payload plus the source). */
|
||||
export type NewHomeworkResult =
|
||||
| { mode: "make"; title: string; language?: string; sourceText: string; answer: string }
|
||||
| { mode: "guide"; title: string; language?: string; sourceText: string; steps: HomeworkStep[] };
|
||||
|
||||
function buildRecord(
|
||||
base: { id: string; createdAt: number; updatedAt: number },
|
||||
input: NewHomeworkResult,
|
||||
): HomeworkRecord {
|
||||
const shared = {
|
||||
...base,
|
||||
title: input.title.slice(0, 200),
|
||||
language: input.language,
|
||||
sourceText: input.sourceText.slice(0, MAX_STORED_SOURCE),
|
||||
};
|
||||
if (input.mode === "make") return { ...shared, mode: "make", answer: input.answer };
|
||||
return { ...shared, mode: "guide", steps: input.steps };
|
||||
}
|
||||
|
||||
export function saveHomework(input: NewHomeworkResult): HomeworkRecord {
|
||||
ensureLoaded();
|
||||
const now = Date.now();
|
||||
const record = buildRecord({ id: uid("hw"), createdAt: now, updatedAt: now }, input);
|
||||
write([record, ...homeworkStore.getSnapshot()]);
|
||||
return record;
|
||||
}
|
||||
|
||||
/** “Make it again” keeps the same history row. */
|
||||
export function updateHomework(id: string, input: NewHomeworkResult): HomeworkRecord | undefined {
|
||||
ensureLoaded();
|
||||
const existing = homeworkStore.getSnapshot().find((record) => record.id === id);
|
||||
if (!existing) return undefined;
|
||||
const updated = buildRecord(
|
||||
{ id: existing.id, createdAt: existing.createdAt, updatedAt: Date.now() },
|
||||
input,
|
||||
);
|
||||
write(homeworkStore.getSnapshot().map((record) => (record.id === id ? updated : record)));
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function deleteHomework(id: string): void {
|
||||
ensureLoaded();
|
||||
write(homeworkStore.getSnapshot().filter((record) => record.id !== id));
|
||||
}
|
||||
|
||||
/** The final screen's text: the step answers assembled into one solution. */
|
||||
export function assembleSolution(record: HomeworkGuideRecord): string {
|
||||
const parts = record.steps.map((step, index) => `${index + 1}. ${step.title}\n${step.answer}`);
|
||||
return [record.title, ...parts].join("\n\n");
|
||||
}
|
||||
|
||||
/** Label for a row in the history list. */
|
||||
export function homeworkModeLabelKey(mode: HomeworkMode): string {
|
||||
return mode === "make" ? "homework.history.made" : "homework.history.guide";
|
||||
}
|
||||
|
||||
export type { HomeworkGuideRecord };
|
||||
118
lib/i18n-dict.ts
118
lib/i18n-dict.ts
|
|
@ -167,6 +167,63 @@ const en: Record<string, string> = {
|
|||
"health.createKey.after": "and paste it above. Tip: with the local daemon you can run",
|
||||
"health.createKey.end": "instead and set",
|
||||
|
||||
/* make my homework ------------------------------------------------- */
|
||||
"homework.nav": "Make my homework",
|
||||
"homework.title": "Make my homework",
|
||||
"homework.subtitle":
|
||||
"Paste the assignment, snap a photo or add a document — then choose whether I should just do it, or guide you through it.",
|
||||
"homework.entry": "Got homework instead of notes?",
|
||||
"homework.source.title": "1. The assignment",
|
||||
"homework.source.help": "Paste its text, drop photos, or add a document.",
|
||||
"homework.source.textLabel": "Assignment text",
|
||||
"homework.source.placeholder": "Paste the assignment here…",
|
||||
"homework.source.add": "Add photos or files",
|
||||
"homework.source.drop": "Drop the assignment here",
|
||||
"homework.source.formats": "Text, photos, PDF, DOCX, PPTX, ODT/ODP, TXT, MD",
|
||||
"homework.source.ready": "Ready when you are.",
|
||||
"homework.source.need": "Add the assignment first.",
|
||||
"homework.source.tooManyPhotos": "Up to {n} photos per assignment.",
|
||||
"homework.source.tooManyDocs": "Up to {n} documents per assignment.",
|
||||
"homework.mode.title": "2. What should I do?",
|
||||
"homework.mode.make.title": "Just make it",
|
||||
"homework.mode.make.subtitle": "The finished homework, ready to copy.",
|
||||
"homework.mode.guide.title": "Explain how to make this homework",
|
||||
"homework.mode.guide.subtitle": "A step-by-step guide with hints and quick checks.",
|
||||
"homework.start.make": "Make my homework",
|
||||
"homework.start.guide": "Guide me through it",
|
||||
"homework.loading.title": "Working on the assignment…",
|
||||
"homework.loading.reading": "Reading the assignment…",
|
||||
"homework.loading.vision2": "Transcribing the questions…",
|
||||
"homework.loading.writing": "Writing your homework…",
|
||||
"homework.loading.planning": "Planning the steps…",
|
||||
"homework.loading.gen2": "Checking every part…",
|
||||
"homework.loading.gen3": "Almost done…",
|
||||
"homework.error.title": "That didn't work",
|
||||
"homework.error.body": "Something went wrong while working on the assignment.",
|
||||
"homework.result.title": "Your homework",
|
||||
"homework.result.copy": "Copy",
|
||||
"homework.result.copied": "Copied!",
|
||||
"homework.result.copyFailed": "Copy didn't work — select the text and copy it manually.",
|
||||
"homework.result.again": "Make it again",
|
||||
"homework.result.new": "New assignment",
|
||||
"homework.guide.stepOf": "Step {n} of {total}",
|
||||
"homework.guide.close": "Close the guide",
|
||||
"homework.guide.tryIt": "Try it yourself",
|
||||
"homework.guide.hint": "Show a hint",
|
||||
"homework.guide.hintMore": "Another hint",
|
||||
"homework.guide.hintAnswer": "Show the answer",
|
||||
"homework.guide.hintN": "Hint {n}",
|
||||
"homework.guide.next": "Next step",
|
||||
"homework.guide.showSolution": "Show the solution",
|
||||
"homework.guide.solution": "The full solution",
|
||||
"homework.guide.solutionNote":
|
||||
"Assembled from the steps above — copy it and hand it in.",
|
||||
"homework.history.title": "Recent homework",
|
||||
"homework.history.made": "Made",
|
||||
"homework.history.guide": "Guide",
|
||||
"homework.history.openTitle": "Open “{title}”",
|
||||
"homework.history.deleteConfirm": "Delete this result?",
|
||||
|
||||
/* library --------------------------------------------------------- */
|
||||
"library.title": "Your icebox",
|
||||
"library.subtitle": "Decks frozen from your notes, saved in this browser.",
|
||||
|
|
@ -534,6 +591,8 @@ const en: Record<string, string> = {
|
|||
"server.bodyNotJson": "Request body was not valid JSON.",
|
||||
"server.invalidRequest": "Invalid request: {detail}",
|
||||
"server.needContent": "Add at least one photo of your notes or paste some text to build a deck.",
|
||||
"server.needAssignment":
|
||||
"Add the assignment first — paste its text, photograph it, or add a document.",
|
||||
"server.invalidGrade": "Invalid grading request.",
|
||||
"server.bodyTooLarge": "Request body is too large ({size}). Limit is {limit}.",
|
||||
"server.invalidOrigin": "Invalid Origin header.",
|
||||
|
|
@ -688,6 +747,63 @@ const cs: Record<string, string> = {
|
|||
"health.createKey.after": "a vlož ho výše. Tip: s místním daemonem můžeš spustit",
|
||||
"health.createKey.end": "a nastavit",
|
||||
|
||||
/* make my homework ------------------------------------------------- */
|
||||
"homework.nav": "Udělej mi úkol",
|
||||
"homework.title": "Udělej mi úkol",
|
||||
"homework.subtitle":
|
||||
"Vlož zadání, vyfoť ho nebo přidej dokument — a pak si vyber, jestli to mám rovnou udělat, nebo tě tím provést.",
|
||||
"homework.entry": "Máš místo poznámek úkol?",
|
||||
"homework.source.title": "1. Zadání",
|
||||
"homework.source.help": "Vlož jeho text, přetáhni fotky nebo přidej dokument.",
|
||||
"homework.source.textLabel": "Text zadání",
|
||||
"homework.source.placeholder": "Sem vlož zadání…",
|
||||
"homework.source.add": "Přidat fotky nebo soubory",
|
||||
"homework.source.drop": "Přetáhni zadání sem",
|
||||
"homework.source.formats": "Text, fotky, PDF, DOCX, PPTX, ODT/ODP, TXT, MD",
|
||||
"homework.source.ready": "Můžeme začít.",
|
||||
"homework.source.need": "Nejdřív přidej zadání.",
|
||||
"homework.source.tooManyPhotos": "Až {n} fotek na zadání.",
|
||||
"homework.source.tooManyDocs": "Až {n} dokumentů na zadání.",
|
||||
"homework.mode.title": "2. Co mám udělat?",
|
||||
"homework.mode.make.title": "Jen to udělej",
|
||||
"homework.mode.make.subtitle": "Hotový úkol, připravený ke zkopírování.",
|
||||
"homework.mode.guide.title": "Vysvětli, jak to udělat",
|
||||
"homework.mode.guide.subtitle": "Návod krok za krokem s nápovědami a rychlými kontrolami.",
|
||||
"homework.start.make": "Udělej mi úkol",
|
||||
"homework.start.guide": "Proveď mě tím",
|
||||
"homework.loading.title": "Pracuji na zadání…",
|
||||
"homework.loading.reading": "Čtu zadání…",
|
||||
"homework.loading.vision2": "Přepisuji otázky…",
|
||||
"homework.loading.writing": "Píšu tvůj úkol…",
|
||||
"homework.loading.planning": "Plánuji postup…",
|
||||
"homework.loading.gen2": "Kontroluji každou část…",
|
||||
"homework.loading.gen3": "Už to bude…",
|
||||
"homework.error.title": "To se nepovedlo",
|
||||
"homework.error.body": "Při zpracování zadání se něco pokazilo.",
|
||||
"homework.result.title": "Tvůj úkol",
|
||||
"homework.result.copy": "Kopírovat",
|
||||
"homework.result.copied": "Zkopírováno!",
|
||||
"homework.result.copyFailed": "Kopírování se nepovedlo — vyber text a zkopíruj ho ručně.",
|
||||
"homework.result.again": "Udělej to znovu",
|
||||
"homework.result.new": "Nové zadání",
|
||||
"homework.guide.stepOf": "Krok {n}/{total}",
|
||||
"homework.guide.close": "Zavřít návod",
|
||||
"homework.guide.tryIt": "Zkus si to",
|
||||
"homework.guide.hint": "Ukaž nápovědu",
|
||||
"homework.guide.hintMore": "Další nápověda",
|
||||
"homework.guide.hintAnswer": "Ukaž odpověď",
|
||||
"homework.guide.hintN": "Nápověda {n}",
|
||||
"homework.guide.next": "Další krok",
|
||||
"homework.guide.showSolution": "Ukaž řešení",
|
||||
"homework.guide.solution": "Celé řešení",
|
||||
"homework.guide.solutionNote":
|
||||
"Složeno ze všech kroků — zkopíruj a odevzdej.",
|
||||
"homework.history.title": "Nedávné úkoly",
|
||||
"homework.history.made": "Hotové",
|
||||
"homework.history.guide": "Návod",
|
||||
"homework.history.openTitle": "Otevřít „{title}“",
|
||||
"homework.history.deleteConfirm": "Smazat tento výsledek?",
|
||||
|
||||
/* library --------------------------------------------------------- */
|
||||
"library.title": "Tvoje mraznička",
|
||||
"library.subtitle": "Sady zmrazené z tvých poznámek, uložené v tomto prohlížeči.",
|
||||
|
|
@ -1055,6 +1171,8 @@ const cs: Record<string, string> = {
|
|||
"server.bodyNotJson": "Tělo požadavku nebylo platné JSON.",
|
||||
"server.invalidRequest": "Neplatný požadavek: {detail}",
|
||||
"server.needContent": "Přidej aspoň jednu fotku poznámek nebo vlož text, ze kterého sada vznikne.",
|
||||
"server.needAssignment":
|
||||
"Nejdřív přidej zadání — vlož jeho text, vyfoť ho nebo přidej dokument.",
|
||||
"server.invalidGrade": "Neplatný požadavek na známkování.",
|
||||
"server.bodyTooLarge": "Tělo požadavku je příliš velké ({size}). Limit je {limit}.",
|
||||
"server.invalidOrigin": "Neplatná hlavička Origin.",
|
||||
|
|
|
|||
|
|
@ -196,6 +196,94 @@ export function buildGradingUserPrompt(input: {
|
|||
].join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Make my homework */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const HOMEWORK_BASE_RULES = `WORKING RULES (critical):
|
||||
- Work ONLY from the assignment the student gave you. You may use generally known facts (definitions, formulas, grammar) to solve it, but never invent the assignment, its parts or its numbers. If something needed to solve it is missing, say plainly what is missing instead of guessing.
|
||||
- Be complete: answer every part of the assignment, in the same order the assignment asks for them.
|
||||
- Plain text only: short lines, numbered or dashed lists. No markdown symbols (no **, ##, tables, code fences, links).
|
||||
- Never mention that you are an AI and never mention JSON.`;
|
||||
|
||||
export const HOMEWORK_MAKE_SYSTEM_PROMPT = `You are Freezmo's homework writer. The student gives you an assignment — typed text, a photo of a worksheet, or a document — and you produce the finished homework, ready to hand in.
|
||||
|
||||
${HOMEWORK_BASE_RULES}
|
||||
|
||||
Output the finished homework only:
|
||||
- No greeting, no "here is your answer", no commentary, no explanation of how you worked, no hints, no summary of the assignment.
|
||||
- Where the assignment expects working (maths, physics, chemistry, grammar drills, translation), include the working as part of the answer — a finished piece of homework shows it.
|
||||
- Number the answers so they line up with the assignment's parts.
|
||||
- As long as the assignment needs, and no longer.
|
||||
|
||||
${LANGUAGE_RULES}
|
||||
|
||||
Required JSON shape:
|
||||
{
|
||||
"title": "short title of the assignment, in the assignment's language, max 10 words",
|
||||
"language": "English name of the language you wrote in, e.g. Czech",
|
||||
"answer": "the finished homework as plain text"
|
||||
}`;
|
||||
|
||||
export function buildHomeworkMakeUserPrompt(input: { assignment: string; language?: string }): string {
|
||||
return [
|
||||
"Do this assignment.",
|
||||
input.language
|
||||
? `LANGUAGE: write everything in ${input.language}. Do not translate into English.`
|
||||
: "LANGUAGE: detect the language of the assignment below and write everything in that same language.",
|
||||
"",
|
||||
"=== THE ASSIGNMENT (the only source of what has to be done) ===",
|
||||
input.assignment.slice(0, 48_000),
|
||||
"=== END OF ASSIGNMENT ===",
|
||||
"",
|
||||
"Respond with the JSON object only.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export const HOMEWORK_GUIDE_SYSTEM_PROMPT = `You are Freezmo's homework coach. The student gives you an assignment and you break it into a short, doable guide so that THEY complete it, step by step.
|
||||
|
||||
${HOMEWORK_BASE_RULES}
|
||||
|
||||
Guide rules:
|
||||
- 3 to 8 steps, in the order the student should do them, and the last step must actually finish the assignment.
|
||||
- Each step has:
|
||||
* "title": 2-6 words.
|
||||
* "explanation": what to do in this step AND why it matters, 2-4 sentences, spoken to the student ("First work out…", "Now check…").
|
||||
* "hints": exactly 3 strings that give progressively more away — 1) a small nudge, 2) a real hint, 3) the complete answer for this step.
|
||||
* "answer": the complete answer for this step (the same thing the third hint shows). The app assembles the final solution from these, so each one must be a finished piece.
|
||||
- Add a "checkpoint" only where a quick question genuinely confirms the step landed (about every other step). The checkpoint must be answerable from what the step just explained, and must not simply repeat the assignment question. Leave the field out entirely when it does not help.
|
||||
- Checkpoints use exactly one of these four shapes:
|
||||
{ "type": "multiple_choice", "topic": "short label", "question": "...", "options": ["A","B","C","D"], "correctIndex": 0, "explanation": "why the correct option is right and the others are wrong" }
|
||||
{ "type": "true_false", "topic": "short label", "statement": "...", "answer": true, "explanation": "why it is true or false" }
|
||||
{ "type": "fill_blank", "topic": "short label", "sentence": "a sentence with a ____ blank", "answer": "the missing word", "options": ["...","...","..."], "explanation": "..." }
|
||||
{ "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea that must appear", "second idea"], "explanation": "..." }
|
||||
|
||||
${LANGUAGE_RULES}
|
||||
|
||||
Required JSON shape:
|
||||
{
|
||||
"title": "short title of the assignment, in the assignment's language, max 10 words",
|
||||
"language": "English name of the language you wrote in, e.g. Czech",
|
||||
"steps": [
|
||||
{ "title": "...", "explanation": "...", "hints": ["nudge", "hint", "answer"], "answer": "...", "checkpoint": { "type": "multiple_choice", "topic": "...", "question": "...", "options": ["...","...","...","..."], "correctIndex": 0, "explanation": "..." } }
|
||||
]
|
||||
}`;
|
||||
|
||||
export function buildHomeworkGuideUserPrompt(input: { assignment: string; language?: string }): string {
|
||||
return [
|
||||
"Build a step-by-step guide for this assignment.",
|
||||
input.language
|
||||
? `LANGUAGE: write everything in ${input.language}. Do not translate into English.`
|
||||
: "LANGUAGE: detect the language of the assignment below and write everything in that same language.",
|
||||
"",
|
||||
"=== THE ASSIGNMENT (the only source of what has to be done) ===",
|
||||
input.assignment.slice(0, 48_000),
|
||||
"=== END OF ASSIGNMENT ===",
|
||||
"",
|
||||
"Respond with the JSON object only.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Retry helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
|
|
@ -135,6 +135,95 @@ export const gradeResultSchema = z.object({
|
|||
missing: z.array(z.string().max(200)).max(6).default([]),
|
||||
});export type GradeResult = z.infer<typeof gradeResultSchema>;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Make my homework */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const homeworkModeSchema = z.enum(["make", "guide"]);
|
||||
export type HomeworkMode = z.infer<typeof homeworkModeSchema>;
|
||||
|
||||
/** Mode 1 — the finished homework, nothing else. */
|
||||
export const homeworkMakeSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
/** English name of the language the assignment was written in. */
|
||||
language: z.string().max(60).optional(),
|
||||
answer: z.string().min(1).max(20_000),
|
||||
});
|
||||
export type HomeworkMake = z.infer<typeof homeworkMakeSchema>;
|
||||
|
||||
/**
|
||||
* One step of the interactive guide, as the model returns it. `checkpoint` is
|
||||
* a normal study activity (validated by the same schema as the feed) that the
|
||||
* student answers to check the step landed.
|
||||
*/
|
||||
export const homeworkStepSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
explanation: z.string().min(1).max(1500),
|
||||
/** Progressive help: nudge -> real hint -> the full answer for this step. */
|
||||
hints: z.array(z.string().min(1).max(1500)).min(1).max(4),
|
||||
/** The full answer for this step; the guide assembles the solution from these. */
|
||||
answer: z.string().min(1).max(5000),
|
||||
checkpoint: generatedActivitySchema.optional(),
|
||||
});
|
||||
|
||||
export const homeworkGuideSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
language: z.string().max(60).optional(),
|
||||
steps: z.array(homeworkStepSchema).min(2).max(10),
|
||||
});
|
||||
export type HomeworkGuide = z.infer<typeof homeworkGuideSchema>;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Client-side homework types (checkpoints carry id/xp, plus storage) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const homeworkCheckpointSchema = generatedActivitySchema.and(
|
||||
z.object({ id: z.string(), xp: z.number() }),
|
||||
);
|
||||
export type HomeworkCheckpoint = z.infer<typeof homeworkCheckpointSchema>;
|
||||
|
||||
export type HomeworkStep = {
|
||||
title: string;
|
||||
explanation: string;
|
||||
hints: string[];
|
||||
answer: string;
|
||||
checkpoint?: HomeworkCheckpoint;
|
||||
};
|
||||
|
||||
export const homeworkStepStoredSchema: z.ZodType<HomeworkStep> = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
explanation: z.string().min(1).max(1500),
|
||||
hints: z.array(z.string().min(1).max(1500)).min(1).max(4),
|
||||
answer: z.string().min(1).max(5000),
|
||||
checkpoint: homeworkCheckpointSchema.optional(),
|
||||
});
|
||||
|
||||
const homeworkBaseFields = {
|
||||
id: z.string().min(1).max(80),
|
||||
title: z.string().min(1).max(200),
|
||||
language: z.string().max(60).optional(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
/** The assignment text (photos/documents were reduced to text client-side). */
|
||||
sourceText: z.string().max(40_000),
|
||||
};
|
||||
|
||||
export const homeworkRecordSchema = z.discriminatedUnion("mode", [
|
||||
z.object({
|
||||
...homeworkBaseFields,
|
||||
mode: z.literal("make"),
|
||||
answer: z.string().min(1).max(20_000),
|
||||
}),
|
||||
z.object({
|
||||
...homeworkBaseFields,
|
||||
mode: z.literal("guide"),
|
||||
steps: z.array(homeworkStepStoredSchema).min(2).max(10),
|
||||
}),
|
||||
]);
|
||||
export type HomeworkRecord = z.infer<typeof homeworkRecordSchema>;
|
||||
export type HomeworkMakeRecord = Extract<HomeworkRecord, { mode: "make" }>;
|
||||
export type HomeworkGuideRecord = Extract<HomeworkRecord, { mode: "guide" }>;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Client-side deck types (activity + id/xp, plus storage metadata) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
152
lib/use-notes-intake.ts
Normal file
152
lib/use-notes-intake.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Client-side file intake for an assignment: validates photos and documents,
|
||||
* reads documents in the browser (lib/documents) and compresses photos
|
||||
* (lib/image). The raw files never leave the device — everything reaches the
|
||||
* server as text and JPEG data URLs.
|
||||
*
|
||||
* The same building blocks the /create screen uses, packaged for the homework
|
||||
* screen (which has its own caps and wording).
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
describeDocumentError,
|
||||
documentKindOf,
|
||||
extractDocument,
|
||||
isDocumentFile,
|
||||
type DocumentKind,
|
||||
type ExtractedDocument,
|
||||
} from "./documents";
|
||||
import { MAX_IMAGES, describeFileError, isImageFile, prepareImage, type PreparedImage } from "./image";
|
||||
import { t } from "./i18n";
|
||||
import { uid } from "./utils";
|
||||
|
||||
export type IntakeDocument = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DocumentKind;
|
||||
status: "extracting" | "ready" | "error";
|
||||
progress?: string;
|
||||
extracted?: ExtractedDocument;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function useNotesIntake(options: {
|
||||
maxImages?: number;
|
||||
maxDocuments?: number;
|
||||
/** i18n key for “too many photos”, with an {n} placeholder. */
|
||||
tooManyPhotosKey: string;
|
||||
/** i18n key for “too many documents”, with an {n} placeholder. */
|
||||
tooManyDocsKey: string;
|
||||
}) {
|
||||
const maxImages = options.maxImages ?? MAX_IMAGES;
|
||||
const maxDocuments = options.maxDocuments ?? 6;
|
||||
const tooManyPhotosKey = options.tooManyPhotosKey;
|
||||
const tooManyDocsKey = options.tooManyDocsKey;
|
||||
|
||||
const [images, setImages] = useState<PreparedImage[]>([]);
|
||||
const [documents, setDocuments] = useState<IntakeDocument[]>([]);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** Mirrors of the current lists; React defers state updaters. */
|
||||
const imagesRef = useRef<PreparedImage[]>([]);
|
||||
const documentsRef = useRef<IntakeDocument[]>([]);
|
||||
|
||||
const addFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setFileError(null);
|
||||
setBusy(true);
|
||||
|
||||
const problems: string[] = [];
|
||||
const imageFiles: File[] = [];
|
||||
const documentFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (isImageFile(file)) {
|
||||
const problem = describeFileError(file);
|
||||
if (problem) problems.push(problem);
|
||||
else imageFiles.push(file);
|
||||
} else if (isDocumentFile(file)) {
|
||||
const problem = describeDocumentError(file);
|
||||
if (problem) problems.push(problem);
|
||||
else documentFiles.push(file);
|
||||
} else {
|
||||
problems.push(t("upload.unsupported", { name: file.name }));
|
||||
}
|
||||
}
|
||||
|
||||
const documentRoom = Math.max(0, maxDocuments - documentsRef.current.length);
|
||||
const acceptedDocuments = documentFiles.slice(0, documentRoom);
|
||||
if (documentFiles.length > documentRoom) problems.push(t(tooManyDocsKey, { n: maxDocuments }));
|
||||
|
||||
// Documents are read asynchronously (pdf.js can take a while for scans).
|
||||
for (const file of acceptedDocuments) {
|
||||
const kind = documentKindOf(file);
|
||||
if (!kind) continue;
|
||||
const item: IntakeDocument = { id: uid("doc"), name: file.name, kind, status: "extracting" };
|
||||
documentsRef.current = [...documentsRef.current, item];
|
||||
setDocuments((current) => (current.some((entry) => entry.id === item.id) ? current : [...current, item]));
|
||||
|
||||
const update = (patch: Partial<IntakeDocument>) =>
|
||||
setDocuments((current) =>
|
||||
current.map((entry) => (entry.id === item.id ? { ...entry, ...patch } : entry)),
|
||||
);
|
||||
|
||||
try {
|
||||
const extracted = await extractDocument(file, (progress) => update({ progress: progress.message }));
|
||||
update({ status: "ready", extracted, progress: undefined });
|
||||
} catch (error) {
|
||||
update({
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : t("upload.couldNotReadDoc"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const photoRoom = Math.max(0, maxImages - imagesRef.current.length);
|
||||
const acceptedImages = imageFiles.slice(0, photoRoom);
|
||||
if (imageFiles.length > photoRoom) problems.push(t(tooManyPhotosKey, { n: maxImages }));
|
||||
|
||||
const prepared: PreparedImage[] = [];
|
||||
for (const file of acceptedImages) {
|
||||
try {
|
||||
prepared.push(await prepareImage(file));
|
||||
} catch {
|
||||
problems.push(t("upload.couldNotReadFile", { name: file.name }));
|
||||
}
|
||||
}
|
||||
if (prepared.length > 0) {
|
||||
imagesRef.current = [...imagesRef.current, ...prepared];
|
||||
setImages((current) => [...current, ...prepared]);
|
||||
}
|
||||
|
||||
if (problems.length) setFileError(problems.slice(0, 3).join(" "));
|
||||
setBusy(false);
|
||||
},
|
||||
[maxDocuments, maxImages, tooManyDocsKey, tooManyPhotosKey],
|
||||
);
|
||||
|
||||
const removeImage = useCallback((id: string) => {
|
||||
imagesRef.current = imagesRef.current.filter((image) => image.id !== id);
|
||||
setImages((current) => current.filter((image) => image.id !== id));
|
||||
}, []);
|
||||
|
||||
const removeDocument = useCallback((id: string) => {
|
||||
documentsRef.current = documentsRef.current.filter((entry) => entry.id !== id);
|
||||
setDocuments((current) => current.filter((entry) => entry.id !== id));
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
imagesRef.current = [];
|
||||
documentsRef.current = [];
|
||||
setImages([]);
|
||||
setDocuments([]);
|
||||
setFileError(null);
|
||||
}, []);
|
||||
|
||||
return { images, documents, fileError, busy, addFiles, removeImage, removeDocument, clear };
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v5";
|
||||
const CACHE_VERSION = "freezmo-v6";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue