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:
Martin Dvořák 2026-10-08 18:30:33 +00:00
commit ef2a6a3589
35 changed files with 2334 additions and 100 deletions

109
lib/ai.ts
View file

@ -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 */
/* ------------------------------------------------------------------ */

View file

@ -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
View 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;
}
}

View file

@ -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
View 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 };

View file

@ -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.",

View file

@ -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 */
/* ------------------------------------------------------------------ */

View file

@ -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
View 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 };
}