Freezmo: self-hosted notes-to-activities study app
Next.js 15 app that turns photos, PDFs, DOCX/PPTX and text into a swipeable feed of AI-generated activities (flashcards, multiple choice, true/false, fill-in-the-blank, matching, ordering, short answer) using the user's own Ollama models. Highlights: - All AI calls run in server route handlers; the browser never talks to Ollama. - Bring-your-own-key mode (default): the key lives only in the browser and is sent per-request; server, local-daemon and shared-key modes also supported. - Client-side document extraction (pdf.js, mammoth, jszip) with scanned-PDF page rendering for the vision model; notes stay in their original language. - ChatGPT-style shell: collapsible sidebar / mobile drawer, neutral token-based light + dark themes, folder organisation, PWA (manifest, SW, offline page). - Strict JSON validation with zod + auto-repair, rate limiting, and request guards (cross-origin, body-size, API-key sanitising). - setup.sh bootstraps deps, .env.local and a production build. Gitignore excludes .env*, the generated pdf.js worker and local scratch files.
This commit is contained in:
parent
3baa0fa8ce
commit
3ea2cea100
83 changed files with 10909 additions and 165 deletions
442
lib/ai.ts
Normal file
442
lib/ai.ts
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
/**
|
||||
* Server-side AI pipeline: vision extraction, activity generation and
|
||||
* short-answer grading. Every step asks for JSON, validates with zod and
|
||||
* retries once with the validation error fed back to the model.
|
||||
*/
|
||||
|
||||
import { OllamaError, ollamaChat, type OllamaMessage } from "./ollama";
|
||||
import { needsLanguageRepair } from "./language";
|
||||
import {
|
||||
buildGenerationUserPrompt,
|
||||
buildGradingUserPrompt,
|
||||
buildRepairPrompt,
|
||||
GENERATION_SYSTEM_PROMPT,
|
||||
GRADING_SYSTEM_PROMPT,
|
||||
VISION_SYSTEM_PROMPT,
|
||||
VISION_USER_INSTRUCTION,
|
||||
} from "./prompts";
|
||||
import {
|
||||
extractedNotesSchema,
|
||||
generatedActivitySchema,
|
||||
gradeResultSchema,
|
||||
type Difficulty,
|
||||
type ExtractedNotes,
|
||||
type GeneratedActivity,
|
||||
type GradeResult,
|
||||
} from "./schemas";
|
||||
import { parseJsonLoose } from "./utils";
|
||||
import type { OllamaAuth } from "./config";
|
||||
|
||||
export type PipelineStage = "vision" | "generation" | "done";
|
||||
|
||||
type JsonStepOptions<T> = {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
system: string;
|
||||
user: string;
|
||||
images?: string[];
|
||||
/** Validates and can also "tighten" the parsed value. */
|
||||
validate: (value: unknown) => { ok: true; value: T } | { ok: false; error: string };
|
||||
shapeHint: string;
|
||||
label: string;
|
||||
signal?: AbortSignal;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
type JsonStepResult<T> = {
|
||||
value: T;
|
||||
/** true when the first attempt failed and the repair round was used. */
|
||||
repaired: boolean;
|
||||
attempts: number;
|
||||
};
|
||||
|
||||
async function runJsonStep<T>({
|
||||
model,
|
||||
auth,
|
||||
system,
|
||||
user,
|
||||
images,
|
||||
validate,
|
||||
shapeHint,
|
||||
label,
|
||||
signal,
|
||||
temperature,
|
||||
}: JsonStepOptions<T>): Promise<JsonStepResult<T>> {
|
||||
const messages: OllamaMessage[] = [
|
||||
{ role: "system", content: system },
|
||||
{ role: "user", content: user, ...(images && images.length ? { images } : {}) },
|
||||
];
|
||||
|
||||
let lastRaw = "";
|
||||
let lastError = "";
|
||||
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
const result = await ollamaChat({
|
||||
model,
|
||||
auth,
|
||||
messages,
|
||||
json: true,
|
||||
signal,
|
||||
temperature: temperature ?? 0.4,
|
||||
});
|
||||
lastRaw = result.content;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseJsonLoose(result.content);
|
||||
} catch (error) {
|
||||
lastError = `Response was not valid JSON: ${error instanceof Error ? error.message : String(error)}`;
|
||||
if (attempt === 1) {
|
||||
messages.push({ role: "assistant", content: result.content });
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: buildRepairPrompt(lastRaw, lastError, shapeHint),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
throw new OllamaError("bad_response", `${label} failed: ${lastError}`);
|
||||
}
|
||||
|
||||
const checked = validate(parsed);
|
||||
if (checked.ok) {
|
||||
return { value: checked.value, repaired: attempt > 1, attempts: attempt };
|
||||
}
|
||||
|
||||
lastError = checked.error;
|
||||
if (attempt === 1) {
|
||||
messages.push({ role: "assistant", content: result.content });
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: buildRepairPrompt(lastRaw, lastError, shapeHint),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new OllamaError(
|
||||
"bad_response",
|
||||
`${label} returned data that did not match the expected format: ${lastError}`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 1 - vision */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const VISION_SHAPE_HINT = `{"title":"...","subject":"...","language":"English name of the notes' language","summary":"...","notes":"plain text transcription","keyConcepts":["..."],"definitions":[{"term":"...","definition":"..."}],"formulas":[{"name":"...","formula":"..."}],"facts":["..."]}`;
|
||||
|
||||
/**
|
||||
* Read the uploaded images (and any pasted text) into structured notes.
|
||||
* Any pasted text is passed through verbatim as additional note content.
|
||||
*
|
||||
* A small vision model can accidentally translate on hard handwriting, so the
|
||||
* result is checked and — when it clearly came back in another language —
|
||||
* restored by the (stronger, multilingual) text model.
|
||||
*/
|
||||
export async function extractNotes(input: {
|
||||
model: string;
|
||||
/** Model used to restore the language if the transcription was translated. */
|
||||
repairModel: string;
|
||||
auth: OllamaAuth;
|
||||
images: string[];
|
||||
text?: string;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ notes: ExtractedNotes; repaired: boolean; languageRepaired: boolean }> {
|
||||
const user = input.text?.trim()
|
||||
? `${VISION_USER_INSTRUCTION}\n\nADDITIONAL TEXT THE STUDENT PASTED (treat as notes too):\n${input.text.slice(0, 12_000)}`
|
||||
: VISION_USER_INSTRUCTION;
|
||||
|
||||
const { value, repaired } = await runJsonStep<ExtractedNotes>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: VISION_SYSTEM_PROMPT,
|
||||
user,
|
||||
images: input.images,
|
||||
validate: (parsed) => {
|
||||
const result = extractedNotesSchema.safeParse(parsed);
|
||||
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
|
||||
if (!result.data.notes.trim()) return { ok: false, error: "The notes field is empty." };
|
||||
return { ok: true, value: result.data };
|
||||
},
|
||||
shapeHint: VISION_SHAPE_HINT,
|
||||
label: "Reading your notes",
|
||||
signal: input.signal,
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
const check = needsLanguageRepair({ language: value.language, text: value.notes });
|
||||
if (!check.suspicious) return { notes: value, repaired, languageRepaired: false };
|
||||
|
||||
console.warn(`[freezmo] restoring the notes' language: ${check.reason}`);
|
||||
input.onProgress?.(`Restoring the notes' language (${value.language ?? "original"})…`);
|
||||
try {
|
||||
const restored = await restoreLanguage({
|
||||
model: input.repairModel,
|
||||
auth: input.auth,
|
||||
language: value.language ?? "the original language of the notes",
|
||||
extraction: value,
|
||||
signal: input.signal,
|
||||
});
|
||||
return { notes: restored, repaired, languageRepaired: true };
|
||||
} catch (error) {
|
||||
// The guard is best-effort: a failure must never break generation.
|
||||
console.warn("[freezmo] language restore failed, using the original transcription", error);
|
||||
return { notes: value, repaired, languageRepaired: false };
|
||||
}
|
||||
}
|
||||
|
||||
const RESTORE_SYSTEM_PROMPT = `You are Freezmo's language restorer. A student's study notes were wrongly translated out of their original language (usually into English) before reaching you.
|
||||
|
||||
Your job: rewrite the JSON you receive back into the ORIGINAL language, keeping every fact, number, formula, name and definition exactly as it is.
|
||||
|
||||
Rules:
|
||||
- Translate ONLY what was wrongly translated. Do not add, remove, summarise or explain anything.
|
||||
- Keep the same JSON keys and the same array structure. Keep the same number of items in every array.
|
||||
- Keep technical terms, units and formulas as the original notes used them.
|
||||
- Keep proper nouns as they are unless the original language would inflect them.
|
||||
- Output JSON only, no markdown fences, no commentary.`;
|
||||
|
||||
/**
|
||||
* Ask the text model to put a translated transcription back into the language
|
||||
* the vision model reported. Returns the original value if the repair is
|
||||
* unusable.
|
||||
*/
|
||||
export async function restoreLanguage(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
language: string;
|
||||
extraction: ExtractedNotes;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ExtractedNotes> {
|
||||
const value = await runJsonStep<ExtractedNotes>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: RESTORE_SYSTEM_PROMPT,
|
||||
user: [
|
||||
`ORIGINAL LANGUAGE: ${input.language}`,
|
||||
"",
|
||||
"JSON TO RESTORE:",
|
||||
JSON.stringify(input.extraction).slice(0, 40_000),
|
||||
"",
|
||||
"Return the same JSON with the text fields written in the original language.",
|
||||
].join("\n"),
|
||||
validate: (parsed) => {
|
||||
const result = extractedNotesSchema.safeParse(parsed);
|
||||
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
|
||||
if (!result.data.notes.trim()) return { ok: false, error: "The notes field is empty." };
|
||||
return { ok: true, value: result.data };
|
||||
},
|
||||
shapeHint: VISION_SHAPE_HINT,
|
||||
label: "Restoring the notes' language",
|
||||
signal: input.signal,
|
||||
temperature: 0.1,
|
||||
});
|
||||
return value.value;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 2 - generation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const GENERATION_SHAPE_HINT = `{"title":"...","subject":"...","language":"English name of the language used","summary":"...","activities":[{"type":"multiple_choice","topic":"...","question":"...","options":["...","...","...","..."],"correctIndex":0,"explanation":"..."}, ...]}`;
|
||||
|
||||
const VALID_TYPES = [
|
||||
"flashcard",
|
||||
"multiple_choice",
|
||||
"true_false",
|
||||
"fill_blank",
|
||||
"matching",
|
||||
"ordering",
|
||||
"short_answer",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Be forgiving: validate each activity on its own and keep the good ones.
|
||||
* Only if too few survive does the step get retried.
|
||||
*/
|
||||
function validateDeck(parsed: unknown, minimum: number, requested: number) {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { ok: false as const, error: "Expected a JSON object at the top level." };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const title = typeof record.title === "string" && record.title.trim() ? record.title.trim() : "Untitled deck";
|
||||
const subject = typeof record.subject === "string" ? record.subject.trim() : undefined;
|
||||
const summary = typeof record.summary === "string" ? record.summary.trim() : undefined;
|
||||
const language = typeof record.language === "string" && record.language.trim() ? record.language.trim() : undefined;
|
||||
|
||||
const rawActivities = Array.isArray(record.activities)
|
||||
? record.activities
|
||||
: Array.isArray(record.items)
|
||||
? record.items
|
||||
: null;
|
||||
|
||||
if (!rawActivities) {
|
||||
return { ok: false as const, error: `Missing "activities" array. Expected an array of ${requested} activity objects.` };
|
||||
}
|
||||
|
||||
const activities: GeneratedActivity[] = [];
|
||||
const rejected: string[] = [];
|
||||
|
||||
rawActivities.slice(0, 24).forEach((item, index) => {
|
||||
const result = generatedActivitySchema.safeParse(item);
|
||||
if (result.success) {
|
||||
activities.push(sanitiseActivity(result.data));
|
||||
} else {
|
||||
const type = (item as { type?: unknown } | null)?.type;
|
||||
rejected.push(`#${index + 1} (${typeof type === "string" ? type : "no type"}): ${summariseZodError(result.error)}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (activities.length < minimum) {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: `Only ${activities.length} valid activities were found, at least ${minimum} are needed. Rejected: ${rejected.slice(0, 3).join("; ") || "none"}. Valid types are: ${VALID_TYPES.join(", ")}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true as const, value: { title, subject, summary, language, activities } };
|
||||
}
|
||||
|
||||
export async function generateActivities(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
notes: string;
|
||||
difficulty: Difficulty;
|
||||
count: number;
|
||||
language?: string;
|
||||
existing?: { title: string; types: string[]; count: number };
|
||||
focus?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{
|
||||
deck: { title: string; subject?: string; summary?: string; language?: string; activities: GeneratedActivity[] };
|
||||
requested: number;
|
||||
}> {
|
||||
const requested = Math.min(Math.max(input.count, 4), 18);
|
||||
|
||||
const { value } = await runJsonStep<{
|
||||
title: string;
|
||||
subject?: string;
|
||||
summary?: string;
|
||||
language?: string;
|
||||
activities: GeneratedActivity[];
|
||||
}>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: GENERATION_SYSTEM_PROMPT,
|
||||
user: buildGenerationUserPrompt({
|
||||
notes: input.notes,
|
||||
difficulty: input.difficulty,
|
||||
count: requested,
|
||||
language: input.language,
|
||||
existing: input.existing,
|
||||
focus: input.focus,
|
||||
}),
|
||||
validate: (parsed) => validateDeck(parsed, Math.max(4, Math.ceil(requested * 0.5)), requested),
|
||||
shapeHint: GENERATION_SHAPE_HINT,
|
||||
label: "Building your activities",
|
||||
signal: input.signal,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return { deck: value, requested };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Grading */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const GRADE_SHAPE_HINT = `{"correct":true,"score":85,"feedback":"short feedback","missing":["key point missed"]}`;
|
||||
|
||||
export async function gradeShortAnswer(input: {
|
||||
model: string;
|
||||
auth: OllamaAuth;
|
||||
question: string;
|
||||
referenceAnswer: string;
|
||||
keyPoints: string[];
|
||||
userAnswer: string;
|
||||
language?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<GradeResult> {
|
||||
const { value } = await runJsonStep<GradeResult>({
|
||||
model: input.model,
|
||||
auth: input.auth,
|
||||
system: GRADING_SYSTEM_PROMPT,
|
||||
user: buildGradingUserPrompt(input),
|
||||
validate: (parsed) => {
|
||||
const result = gradeResultSchema.safeParse(parsed);
|
||||
if (!result.success) return { ok: false, error: summariseZodError(result.error) };
|
||||
return { ok: true, value: result.data };
|
||||
},
|
||||
shapeHint: GRADE_SHAPE_HINT,
|
||||
label: "Grading your answer",
|
||||
signal: input.signal,
|
||||
temperature: 0.1,
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Small fixes so the UI can trust the data: ordering items must be unique
|
||||
* (they are used as React keys and are ambiguous otherwise), fill_blank
|
||||
* sentences must actually contain a blank, and word banks must contain
|
||||
* the answer.
|
||||
*/
|
||||
function sanitiseActivity(activity: GeneratedActivity): GeneratedActivity {
|
||||
switch (activity.type) {
|
||||
case "ordering": {
|
||||
const seen = new Set<string>();
|
||||
const items = activity.items.filter((item) => {
|
||||
const key = item.trim().toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return items.length === activity.items.length ? activity : { ...activity, items };
|
||||
}
|
||||
case "fill_blank": {
|
||||
if (/_{2,}/.test(activity.sentence)) return activity;
|
||||
return { ...activity, sentence: `${activity.sentence.trim()} ____` };
|
||||
}
|
||||
case "matching": {
|
||||
const seen = new Set<string>();
|
||||
const pairs = activity.pairs.filter((pair) => {
|
||||
const key = pair.term.trim().toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
return pairs.length === activity.pairs.length ? activity : { ...activity, pairs };
|
||||
}
|
||||
default:
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact, human-readable zod error for the repair prompt. */
|
||||
function summariseZodError(error: { issues: Array<{ path: PropertyKey[]; message: string }> }): string {
|
||||
return error.issues
|
||||
.slice(0, 6)
|
||||
.map((issue) => `${issue.path.map(String).join(".") || "(root)"}: ${issue.message}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/** Turn the structured extraction into the plain text the generator reads. */
|
||||
export function notesToText(notes: ExtractedNotes): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`TITLE: ${notes.title}`);
|
||||
if (notes.subject) parts.push(`SUBJECT: ${notes.subject}`);
|
||||
if (notes.language) parts.push(`LANGUAGE: ${notes.language}`);
|
||||
if (notes.summary) parts.push(`SUMMARY: ${notes.summary}`);
|
||||
parts.push("", "TRANSCRIPTION:", notes.notes);
|
||||
if (notes.keyConcepts.length) parts.push("", "KEY CONCEPTS:", ...notes.keyConcepts.map((c) => `- ${c}`));
|
||||
if (notes.definitions.length)
|
||||
parts.push("", "DEFINITIONS:", ...notes.definitions.map((d) => `- ${d.term}: ${d.definition}`));
|
||||
if (notes.formulas.length)
|
||||
parts.push("", "FORMULAS:", ...notes.formulas.map((f) => `- ${f.name}: ${f.formula}`));
|
||||
if (notes.facts.length) parts.push("", "FACTS:", ...notes.facts.map((f) => `- ${f}`));
|
||||
return parts.join("\n").slice(0, 48_000);
|
||||
}
|
||||
209
lib/client-api.ts
Normal file
209
lib/client-api.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"use client";
|
||||
|
||||
import type { Activity, Difficulty } from "./schemas";
|
||||
import { authHeaders } from "./settings";
|
||||
|
||||
export type GenerateInput = {
|
||||
images?: string[];
|
||||
text?: string;
|
||||
difficulty: Difficulty;
|
||||
count?: number;
|
||||
focus?: string;
|
||||
language?: string;
|
||||
mode?: "create" | "more";
|
||||
existing?: { title: string; types: string[]; count: number };
|
||||
};
|
||||
|
||||
export type StreamedDeck = {
|
||||
title: string;
|
||||
subject?: string;
|
||||
summary?: string;
|
||||
language?: string;
|
||||
sourceText: string;
|
||||
difficulty: Difficulty;
|
||||
activities: Activity[];
|
||||
};
|
||||
|
||||
export type GenerateProgress = {
|
||||
stage: "vision" | "generation" | "done";
|
||||
message: string;
|
||||
};
|
||||
|
||||
type StreamEvent =
|
||||
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
|
||||
| { type: "deck"; deck: StreamedDeck; meta: Record<string, unknown> }
|
||||
| { type: "error"; error: string; kind?: string };
|
||||
|
||||
export class ApiError extends Error {
|
||||
kind: string;
|
||||
constructor(message: string, kind = "unknown") {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.kind = kind;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls POST /api/generate and consumes the NDJSON progress stream.
|
||||
* Progress events arrive between the vision and generation steps.
|
||||
*/
|
||||
export async function generateDeck(
|
||||
input: GenerateInput,
|
||||
options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {},
|
||||
): Promise<{ deck: StreamedDeck; meta: Record<string, unknown> }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(input),
|
||||
signal: options.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError("Generation was cancelled.", "aborted");
|
||||
}
|
||||
throw new ApiError("Could not reach the Freezmo server. Is it still running?", "network");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
|
||||
throw new ApiError(body?.error ?? `The server returned HTTP ${response.status}.`, body?.kind);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new ApiError("The server sent an empty response.", "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;
|
||||
|
||||
const handleLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let event: StreamEvent;
|
||||
try {
|
||||
event = JSON.parse(line) as StreamEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
options.onProgress?.({ stage: "done", message: "Almost there…" });
|
||||
} 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
|
||||
? "The server finished without sending a deck."
|
||||
: "No deck came back from the server. Check your Ollama settings and try again.",
|
||||
"bad_response",
|
||||
);
|
||||
}
|
||||
|
||||
const valid = (deck as StreamedDeck).activities?.filter(
|
||||
(activity) => activity && typeof activity.type === "string" && typeof activity.id === "string",
|
||||
);
|
||||
if (!valid || valid.length === 0) {
|
||||
throw new ApiError("The generated deck had no usable activities. Try again.", "bad_response");
|
||||
}
|
||||
|
||||
return { deck: { ...(deck as StreamedDeck), activities: valid }, meta };
|
||||
}
|
||||
|
||||
export async function gradeAnswer(input: {
|
||||
question: string;
|
||||
referenceAnswer: string;
|
||||
keyPoints: string[];
|
||||
userAnswer: string;
|
||||
language?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<{ correct: boolean; score: number; feedback: string; missing: string[] }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/grade", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(input),
|
||||
signal: input.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new ApiError("Grading was cancelled.", "aborted");
|
||||
}
|
||||
throw new ApiError("Could not reach the Freezmo server.", "network");
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { correct: boolean; score: number; feedback: string; missing?: string[]; error?: string; kind?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok || !body || body.error) {
|
||||
throw new ApiError(body?.error ?? `Grading failed with HTTP ${response.status}.`, body?.kind);
|
||||
}
|
||||
|
||||
return {
|
||||
correct: Boolean(body.correct),
|
||||
score: Math.max(0, Math.min(100, Math.round(body.score))),
|
||||
feedback: body.feedback,
|
||||
missing: body.missing ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export type HealthModel = {
|
||||
configured: string;
|
||||
resolved: string;
|
||||
available: boolean | null;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type Health = {
|
||||
ok: boolean;
|
||||
reachable: boolean;
|
||||
mode: "local-daemon" | "direct-cloud";
|
||||
authMode: "user-key" | "server" | "local";
|
||||
needsKey: boolean;
|
||||
keyAccepted: boolean | null;
|
||||
baseUrl: string;
|
||||
hasApiKey: boolean;
|
||||
vision: HealthModel;
|
||||
text: HealthModel;
|
||||
latencyMs: number | null;
|
||||
modelCount: number | null;
|
||||
error: string | null;
|
||||
kind?: string;
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export async function fetchHealth(): Promise<Health> {
|
||||
try {
|
||||
const response = await fetch("/api/health", { cache: "no-store", headers: authHeaders() });
|
||||
return (await response.json()) as Health;
|
||||
} catch {
|
||||
throw new ApiError("Could not reach the Freezmo server.", "network");
|
||||
}
|
||||
}
|
||||
56
lib/confetti.ts
Normal file
56
lib/confetti.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
/** Confetti helper that respects prefers-reduced-motion. */
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
export async function celebrate(intensity: "normal" | "big" = "normal"): Promise<void> {
|
||||
if (prefersReducedMotion()) return;
|
||||
try {
|
||||
const confetti = (await import("canvas-confetti")).default;
|
||||
// Muted, low-saturation palette to match the calmer design.
|
||||
const colors = ["#aac6da", "#7ea6c2", "#5c88a8", "#e4e9f0", "#ffffff", "#cfd8e3"];
|
||||
const count = intensity === "big" ? 130 : 60;
|
||||
|
||||
confetti({
|
||||
particleCount: count,
|
||||
spread: intensity === "big" ? 110 : 75,
|
||||
startVelocity: intensity === "big" ? 48 : 38,
|
||||
origin: { y: 0.65 },
|
||||
colors,
|
||||
scalar: 0.95,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
|
||||
if (intensity === "big") {
|
||||
setTimeout(() => {
|
||||
confetti({ particleCount: 45, angle: 60, spread: 55, origin: { x: 0, y: 0.75 }, colors });
|
||||
confetti({ particleCount: 45, angle: 120, spread: 55, origin: { x: 1, y: 0.75 }, colors });
|
||||
}, 180);
|
||||
}
|
||||
} catch {
|
||||
/* confetti is a nice-to-have */
|
||||
}
|
||||
}
|
||||
|
||||
export function burstAt(x: number, y: number): void {
|
||||
if (prefersReducedMotion()) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const confetti = (await import("canvas-confetti")).default;
|
||||
confetti({
|
||||
particleCount: 40,
|
||||
spread: 55,
|
||||
startVelocity: 26,
|
||||
origin: { x: x / window.innerWidth, y: y / window.innerHeight },
|
||||
colors: ["#7ea6c2", "#aac6da", "#cfd8e3", "#ffffff"],
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
}
|
||||
180
lib/config.ts
Normal file
180
lib/config.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Server-side configuration, read from the environment on every request so
|
||||
* that editing `.env.local` only requires a dev-server restart.
|
||||
*
|
||||
* Never import this into a client component: it would leak the API key.
|
||||
*/
|
||||
|
||||
import { sanitiseApiKey } from "./http-guard";
|
||||
|
||||
export type AuthMode = "user-key" | "server" | "local";
|
||||
|
||||
export type ServerConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
authMode: AuthMode;
|
||||
visionModel: string;
|
||||
textModel: string;
|
||||
/** True when talking straight to https://ollama.com (needs an API key). */
|
||||
directCloud: boolean;
|
||||
timeoutMs: number;
|
||||
healthTimeoutMs: number;
|
||||
/** `false` sends `think: false`; `null` omits the field entirely. */
|
||||
think: boolean | null;
|
||||
defaultActivityCount: number;
|
||||
rateLimits: {
|
||||
windowMs: number;
|
||||
generate: number;
|
||||
grade: number;
|
||||
health: number;
|
||||
};
|
||||
};
|
||||
|
||||
/** What a single request should use to talk to Ollama. */
|
||||
export type OllamaAuth = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
directCloud: boolean;
|
||||
authMode: AuthMode;
|
||||
};
|
||||
|
||||
function positiveInt(value: string | undefined, fallback: number): number {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function getServerConfig(): ServerConfig {
|
||||
const baseUrl = trimTrailingSlash(
|
||||
process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434",
|
||||
);
|
||||
const apiKey = process.env.OLLAMA_API_KEY?.trim() || "";
|
||||
|
||||
const rawAuthMode = process.env.OLLAMA_AUTH?.trim().toLowerCase();
|
||||
const authMode: AuthMode =
|
||||
rawAuthMode === "local" || rawAuthMode === "server" || rawAuthMode === "user-key"
|
||||
? rawAuthMode
|
||||
: rawAuthMode === "none"
|
||||
? "local"
|
||||
: "user-key";
|
||||
|
||||
let directCloud = false;
|
||||
try {
|
||||
const host = new URL(baseUrl).hostname.toLowerCase();
|
||||
directCloud = host === "ollama.com" || host.endsWith(".ollama.com");
|
||||
} catch {
|
||||
directCloud = false;
|
||||
}
|
||||
|
||||
const thinkRaw = process.env.OLLAMA_THINK?.trim().toLowerCase();
|
||||
const think =
|
||||
thinkRaw === "false" ? false : thinkRaw === "true" ? true : null;
|
||||
|
||||
const count = Number.parseInt(
|
||||
process.env.FREEZMO_DEFAULT_ACTIVITY_COUNT ?? "",
|
||||
10,
|
||||
);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
authMode,
|
||||
visionModel: process.env.VISION_MODEL?.trim() || "gemma4:31b-cloud",
|
||||
textModel: process.env.TEXT_MODEL?.trim() || "gpt-oss:120b-cloud",
|
||||
directCloud,
|
||||
timeoutMs: Number.parseInt(process.env.OLLAMA_TIMEOUT_MS ?? "", 10) || 300_000,
|
||||
healthTimeoutMs:
|
||||
Number.parseInt(process.env.OLLAMA_HEALTH_TIMEOUT_MS ?? "", 10) || 8_000,
|
||||
think,
|
||||
defaultActivityCount: Number.isFinite(count) ? Math.min(Math.max(count, 6), 20) : 10,
|
||||
rateLimits: {
|
||||
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),
|
||||
health: positiveInt(process.env.RATE_LIMIT_HEALTH, 90),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable explanation for every auth configuration problem, so the UI
|
||||
* never has to show a bare "unauthorised".
|
||||
*/
|
||||
export const AUTH_ERROR_MESSAGES: Record<"missing_key" | "server_key_missing" | "local_with_cloud_url", string> = {
|
||||
missing_key:
|
||||
"Add your Ollama API key first — open the settings dialog (⚙️) and paste a key from ollama.com/settings/keys.",
|
||||
server_key_missing:
|
||||
"This server is configured to use its own Ollama API key (OLLAMA_AUTH=server), but OLLAMA_API_KEY is empty in .env.local.",
|
||||
local_with_cloud_url:
|
||||
"This server is set to OLLAMA_AUTH=local but points at https://ollama.com. A signed-in local daemon can't be used against the cloud URL — either set OLLAMA_AUTH=user-key, or point OLLAMA_BASE_URL at http://localhost:11434.",
|
||||
};
|
||||
|
||||
export function authErrorKind(error: "missing_key" | "server_key_missing" | "local_with_cloud_url"): string {
|
||||
if (error === "missing_key") return "needs_key";
|
||||
return "config";
|
||||
}
|
||||
|
||||
/** Header a browser uses to hand over its own key for a single request. */
|
||||
export const API_KEY_HEADER = "x-ollama-key";
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Works out which credentials a request may use.
|
||||
*
|
||||
* - `user-key` (default): the key must come from the browser. Nothing is
|
||||
* stored server-side, ever — it is used for this request and dropped.
|
||||
* - `server`: the key comes from `.env.local` (single-user installs).
|
||||
* - `local`: neither; a local daemon signed in with `ollama signin`.
|
||||
*/
|
||||
export function resolveAuth(
|
||||
config: ServerConfig,
|
||||
request: Request,
|
||||
): { auth: OllamaAuth } | { error: "missing_key" | "server_key_missing" | "local_with_cloud_url" } {
|
||||
const headerKey = sanitiseApiKey(request.headers.get(API_KEY_HEADER));
|
||||
|
||||
if (config.authMode === "local") {
|
||||
// A daemon without sign-in can't talk to the cloud API: catch the
|
||||
// misconfiguration here instead of returning a confusing 401 later.
|
||||
if (config.directCloud) return { error: "local_with_cloud_url" };
|
||||
return {
|
||||
auth: { baseUrl: config.baseUrl, apiKey: "", directCloud: config.directCloud, authMode: "local" },
|
||||
};
|
||||
}
|
||||
|
||||
if (config.authMode === "server") {
|
||||
if (!config.apiKey) return { error: "server_key_missing" };
|
||||
return {
|
||||
auth: { baseUrl: config.baseUrl, apiKey: config.apiKey, directCloud: config.directCloud, authMode: "server" },
|
||||
};
|
||||
}
|
||||
|
||||
if (!headerKey) return { error: "missing_key" };
|
||||
return {
|
||||
auth: { baseUrl: config.baseUrl, apiKey: headerKey, directCloud: config.directCloud, authMode: "user-key" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloud models are addressed as `<model>:cloud` (or `<model>-cloud`) by a
|
||||
* local daemon, but the direct cloud API uses the plain library name
|
||||
* (`gemma4:31b-cloud` -> `gemma4:31b`, `glm-5.3-flash:cloud` -> `glm-5.3-flash`).
|
||||
* Normalise so the same .env works in both modes.
|
||||
*/
|
||||
export function resolveModelName(model: string, directCloud: boolean): string {
|
||||
if (!directCloud) return model;
|
||||
if (model.endsWith(":cloud")) return model.slice(0, -":cloud".length);
|
||||
if (model.endsWith("-cloud")) return model.slice(0, -"-cloud".length);
|
||||
return model;
|
||||
}
|
||||
|
||||
/** Strip local/daemon routing suffixes so model names can be compared. */
|
||||
export function canonicalModelName(model: string): string {
|
||||
return model
|
||||
.replace(/:cloud$/, "")
|
||||
.replace(/-cloud$/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
18
lib/deck-ops.ts
Normal file
18
lib/deck-ops.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import type { Activity, Difficulty, GeneratedActivity } from "./schemas";
|
||||
import { uid } from "./utils";
|
||||
import { xpForActivity } from "./xp";
|
||||
|
||||
/** Turn freshly generated activities into stored ones (id + XP). */
|
||||
export function materialiseActivities(activities: GeneratedActivity[], difficulty: Difficulty): Activity[] {
|
||||
return activities.map((activity) => ({
|
||||
...activity,
|
||||
id: uid("act"),
|
||||
xp: xpForActivity(activity.type, difficulty),
|
||||
}));
|
||||
}
|
||||
|
||||
export function activityTypes(activities: Activity[]): string[] {
|
||||
return Array.from(new Set(activities.map((activity) => activity.type)));
|
||||
}
|
||||
476
lib/documents.ts
Normal file
476
lib/documents.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Client-side extraction for the note formats people actually use:
|
||||
*
|
||||
* .pdf text layer, or rendered pages when scanned
|
||||
* .docx text + embedded images
|
||||
* .pptx slide text, speaker notes + embedded images
|
||||
* .odt / .odp / .ods LibreOffice documents (content.xml)
|
||||
* .txt .md .csv .rtf-lite plain text
|
||||
*
|
||||
* Everything runs in the browser: the original file never leaves the device,
|
||||
* only the extracted text (and any page images for the vision model) is sent
|
||||
* to the API routes. Photos uploaded as images keep working exactly as before.
|
||||
*/
|
||||
|
||||
import { prepareImageFromBlob, type PreparedImage } from "./image";
|
||||
|
||||
export const MAX_DOCUMENT_BYTES = 40 * 1024 * 1024; // 40 MB per file
|
||||
/** How many rendered page images one document may contribute. */
|
||||
export const MAX_DOCUMENT_IMAGES = 10;
|
||||
/** Characters kept per document (long books get truncated). */
|
||||
export const MAX_DOCUMENT_CHARS = 60_000;
|
||||
/** Pages below this much text are treated as scanned and rendered as images. */
|
||||
const SCANNED_PAGE_CHAR_THRESHOLD = 60;
|
||||
const PDF_RENDER_SCALE = 2;
|
||||
|
||||
export const ACCEPTED_DOCUMENT_EXTENSIONS = [
|
||||
"pdf",
|
||||
"docx",
|
||||
"pptx",
|
||||
"odt",
|
||||
"odp",
|
||||
"ods",
|
||||
"txt",
|
||||
"md",
|
||||
"markdown",
|
||||
"csv",
|
||||
"tsv",
|
||||
"log",
|
||||
] as const;
|
||||
|
||||
export const DOCUMENT_ACCEPT_ATTR = [
|
||||
...ACCEPTED_DOCUMENT_EXTENSIONS.map((extension) => `.${extension}`),
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/vnd.oasis.opendocument.presentation",
|
||||
"application/vnd.oasis.opendocument.spreadsheet",
|
||||
"text/plain",
|
||||
"text/markdown",
|
||||
"text/csv",
|
||||
].join(",");
|
||||
|
||||
export type DocumentKind = "pdf" | "docx" | "pptx" | "opendocument" | "text";
|
||||
|
||||
export type ExtractedDocument = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DocumentKind;
|
||||
/** Extracted text, already truncated to MAX_DOCUMENT_CHARS. */
|
||||
text: string;
|
||||
/** Page-rendered or embedded images (JPEG data URLs, ready to send). */
|
||||
images: PreparedImage[];
|
||||
words: number;
|
||||
characters: number;
|
||||
pages?: number;
|
||||
truncated: boolean;
|
||||
imagesTruncated: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type DocumentProgress = {
|
||||
/** Human readable stage, e.g. "Reading page 4 of 12". */
|
||||
message: string;
|
||||
/** 0..1 where known. */
|
||||
progress?: number;
|
||||
};
|
||||
|
||||
function extensionOf(name: string): string {
|
||||
const index = name.lastIndexOf(".");
|
||||
return index === -1 ? "" : name.slice(index + 1).toLowerCase();
|
||||
}
|
||||
|
||||
export function documentKindOf(file: File): DocumentKind | null {
|
||||
const extension = extensionOf(file.name);
|
||||
switch (extension) {
|
||||
case "pdf":
|
||||
return "pdf";
|
||||
case "docx":
|
||||
return "docx";
|
||||
case "pptx":
|
||||
return "pptx";
|
||||
case "odt":
|
||||
case "odp":
|
||||
case "ods":
|
||||
return "opendocument";
|
||||
case "txt":
|
||||
case "md":
|
||||
case "markdown":
|
||||
case "csv":
|
||||
case "tsv":
|
||||
case "log":
|
||||
return "text";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDocumentFile(file: File): boolean {
|
||||
return documentKindOf(file) !== null;
|
||||
}
|
||||
|
||||
export function describeDocumentError(file: File): string | null {
|
||||
if (!isDocumentFile(file)) {
|
||||
return `"${file.name}" is not a supported document (use PDF, DOCX, PPTX, ODT/ODP or text).`;
|
||||
}
|
||||
if (file.size > MAX_DOCUMENT_BYTES) {
|
||||
return `"${file.name}" is larger than 40 MB.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const KIND_LABELS: Record<DocumentKind, string> = {
|
||||
pdf: "PDF",
|
||||
docx: "Word",
|
||||
pptx: "PowerPoint",
|
||||
opendocument: "OpenDocument",
|
||||
text: "Text",
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function cleanText(raw: string): string {
|
||||
return raw
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.replace(/[ \t]{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
return text.split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
function withLimits(
|
||||
name: string,
|
||||
kind: DocumentKind,
|
||||
text: string,
|
||||
images: PreparedImage[],
|
||||
extra: { pages?: number; warnings?: string[]; imagesTruncated?: boolean },
|
||||
): ExtractedDocument {
|
||||
const cleaned = cleanText(text);
|
||||
const truncated = cleaned.length > MAX_DOCUMENT_CHARS;
|
||||
const finalText = truncated ? cleaned.slice(0, MAX_DOCUMENT_CHARS) : cleaned;
|
||||
return {
|
||||
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name,
|
||||
kind,
|
||||
text: finalText,
|
||||
images,
|
||||
words: countWords(finalText),
|
||||
characters: finalText.length,
|
||||
pages: extra.pages,
|
||||
truncated,
|
||||
imagesTruncated: extra.imagesTruncated ?? false,
|
||||
warnings: extra.warnings ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PDF */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type PdfPage = {
|
||||
getTextContent: () => Promise<{ items: Array<{ str?: string; transform?: number[] }> }>;
|
||||
getViewport: (options: { scale: number }) => { width: number; height: number };
|
||||
render: (options: { canvasContext: CanvasRenderingContext2D; viewport: unknown }) => { promise: Promise<void> };
|
||||
cleanup?: () => void;
|
||||
};
|
||||
|
||||
/** Join pdf.js text items, inserting line breaks when the baseline jumps. */
|
||||
function textFromPdfItems(items: Array<{ str?: string; transform?: number[] }>): string {
|
||||
let output = "";
|
||||
let lastY: number | null = null;
|
||||
for (const item of items) {
|
||||
const value = item.str ?? "";
|
||||
const y = item.transform?.[5];
|
||||
if (lastY !== null && typeof y === "number" && Math.abs(y - lastY) > 2) {
|
||||
output += "\n";
|
||||
}
|
||||
output += value;
|
||||
if (value && !value.endsWith(" ")) output += " ";
|
||||
if (typeof y === "number") lastY = y;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function extractPdf(
|
||||
file: File,
|
||||
onProgress?: (update: DocumentProgress) => void,
|
||||
): Promise<ExtractedDocument> {
|
||||
// Loaded lazily so pdf.js (and its worker) never hits the initial bundle.
|
||||
const pdfjs = await import("pdfjs-dist");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs";
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const document = await pdfjs.getDocument({ data }).promise;
|
||||
|
||||
const pages: string[] = [];
|
||||
const images: PreparedImage[] = [];
|
||||
const warnings: string[] = [];
|
||||
let imagesTruncated = false;
|
||||
|
||||
const totalPages = Math.min(document.numPages, 80);
|
||||
if (document.numPages > totalPages) {
|
||||
warnings.push(`Only the first ${totalPages} of ${document.numPages} pages were read.`);
|
||||
}
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber++) {
|
||||
onProgress?.({
|
||||
message: `Reading page ${pageNumber} of ${totalPages}…`,
|
||||
progress: pageNumber / totalPages,
|
||||
});
|
||||
|
||||
const page = (await document.getPage(pageNumber)) as unknown as PdfPage;
|
||||
let pageText = "";
|
||||
try {
|
||||
const content = await page.getTextContent();
|
||||
pageText = textFromPdfItems(content.items);
|
||||
} catch {
|
||||
warnings.push(`Page ${pageNumber} text could not be extracted.`);
|
||||
}
|
||||
|
||||
if (pageText.trim().length >= SCANNED_PAGE_CHAR_THRESHOLD) {
|
||||
pages.push(`--- page ${pageNumber} ---\n${pageText.trim()}`);
|
||||
} else if (images.length < MAX_DOCUMENT_IMAGES) {
|
||||
// Scanned or image-only page: render it so the vision model can read it.
|
||||
try {
|
||||
const viewport = page.getViewport({ scale: PDF_RENDER_SCALE });
|
||||
const canvas = window.document.createElement("canvas");
|
||||
canvas.width = Math.floor(viewport.width);
|
||||
canvas.height = Math.floor(viewport.height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("no 2d context");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.9));
|
||||
if (blob) {
|
||||
images.push(await prepareImageFromBlob(blob, `${file.name} p${pageNumber}`));
|
||||
}
|
||||
} catch {
|
||||
warnings.push(`Page ${pageNumber} looks scanned but could not be rendered.`);
|
||||
}
|
||||
} else {
|
||||
imagesTruncated = true;
|
||||
}
|
||||
|
||||
if (pageText.trim().length < SCANNED_PAGE_CHAR_THRESHOLD) {
|
||||
// Nothing to add to the text; the image carries the content.
|
||||
imagesTruncated = images.length >= MAX_DOCUMENT_IMAGES;
|
||||
}
|
||||
|
||||
page.cleanup?.();
|
||||
}
|
||||
|
||||
if (images.length > 0) {
|
||||
warnings.push(
|
||||
images.length === 1
|
||||
? "1 scanned page was turned into an image for the vision model."
|
||||
: `${images.length} scanned pages were turned into images for the vision model.`,
|
||||
);
|
||||
}
|
||||
|
||||
return withLimits(file.name, "pdf", pages.join("\n\n"), images, {
|
||||
pages: document.numPages,
|
||||
warnings,
|
||||
imagesTruncated,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* DOCX */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function extractDocx(file: File): Promise<ExtractedDocument> {
|
||||
const mammoth = await import("mammoth");
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const warnings: string[] = [];
|
||||
const images: PreparedImage[] = [];
|
||||
|
||||
const textResult = await mammoth.extractRawText({ arrayBuffer });
|
||||
for (const message of textResult.messages.slice(0, 3)) {
|
||||
if (message.type === "warning") warnings.push(message.message);
|
||||
}
|
||||
|
||||
// Pull out embedded images (diagrams, pasted screenshots) for the vision step.
|
||||
try {
|
||||
const htmlResult = await mammoth.convertToHtml(
|
||||
{ arrayBuffer },
|
||||
{
|
||||
convertImage: mammoth.images.imgElement(async (image: { read: (encoding: string) => Promise<string>; contentType: string }) => {
|
||||
if (images.length >= MAX_DOCUMENT_IMAGES) return { src: "" };
|
||||
try {
|
||||
const base64 = await image.read("base64");
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
const blob = new Blob([bytes], { type: image.contentType || "image/png" });
|
||||
images.push(await prepareImageFromBlob(blob, `${file.name} image ${images.length + 1}`));
|
||||
} catch {
|
||||
/* skip unreadable images */
|
||||
}
|
||||
return { src: "" };
|
||||
}),
|
||||
},
|
||||
);
|
||||
void htmlResult;
|
||||
} catch {
|
||||
warnings.push("Embedded images could not be extracted (text was still read).");
|
||||
}
|
||||
|
||||
const imagesTruncated = images.length >= MAX_DOCUMENT_IMAGES;
|
||||
if (images.length > 0) {
|
||||
warnings.push(`${images.length} embedded image${images.length === 1 ? "" : "s"} sent to the vision model.`);
|
||||
}
|
||||
|
||||
return withLimits(file.name, "docx", textResult.value, images, { warnings, imagesTruncated });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PPTX */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const XML_TEXT_RUN = /<a:t[^>]*>([\s\S]*?)<\/a:t>/g;
|
||||
const XML_PARAGRAPH = /<\/a:p>/g;
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, code: string) => String.fromCodePoint(Number.parseInt(code, 16)))
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function textFromSlideXml(xml: string): string {
|
||||
const withBreaks = xml.replace(XML_PARAGRAPH, "\n");
|
||||
const parts: string[] = [];
|
||||
for (const match of withBreaks.matchAll(XML_TEXT_RUN)) {
|
||||
parts.push(decodeXmlEntities(match[1]));
|
||||
}
|
||||
return cleanText(parts.join(" "));
|
||||
}
|
||||
|
||||
async function extractOoxmlPackage(
|
||||
file: File,
|
||||
kind: "pptx" | "opendocument",
|
||||
): Promise<ExtractedDocument> {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
const warnings: string[] = [];
|
||||
const images: PreparedImage[] = [];
|
||||
const sections: string[] = [];
|
||||
|
||||
if (kind === "pptx") {
|
||||
const slideNames = Object.keys(zip.files)
|
||||
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
||||
.sort((a, b) => {
|
||||
const na = Number(a.match(/(\d+)/)?.[1] ?? 0);
|
||||
const nb = Number(b.match(/(\d+)/)?.[1] ?? 0);
|
||||
return na - nb;
|
||||
});
|
||||
|
||||
for (const [index, name] of slideNames.entries()) {
|
||||
const xml = await zip.file(name)?.async("string");
|
||||
if (!xml) continue;
|
||||
const body = textFromSlideXml(xml);
|
||||
const notesName = `ppt/notesSlides/notesSlide${index + 1}.xml`;
|
||||
const notesXml = zip.file(notesName) ? await zip.file(notesName)?.async("string") : null;
|
||||
const notes = notesXml ? textFromSlideXml(notesXml) : "";
|
||||
const chunk = [`--- slide ${index + 1} ---`, body || "(no text on this slide)"];
|
||||
if (notes) chunk.push(`Speaker notes: ${notes}`);
|
||||
sections.push(chunk.join("\n"));
|
||||
}
|
||||
|
||||
const media = Object.keys(zip.files).filter(
|
||||
(name) => /^ppt\/media\/.+\.(png|jpe?g|gif|webp|bmp)$/i.test(name) && !name.includes("/_rels/"),
|
||||
);
|
||||
for (const name of media) {
|
||||
if (images.length >= MAX_DOCUMENT_IMAGES) break;
|
||||
try {
|
||||
const blob = await zip.file(name)?.async("blob");
|
||||
if (blob) images.push(await prepareImageFromBlob(blob, `${file.name} ${name.split("/").pop()}`));
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (media.length > images.length) {
|
||||
warnings.push(`Only ${images.length} of ${media.length} slide images were kept.`);
|
||||
}
|
||||
} else {
|
||||
const content = await zip.file("content.xml")?.async("string");
|
||||
if (!content) throw new Error("This OpenDocument file has no readable content.");
|
||||
const text = decodeXmlEntities(
|
||||
content
|
||||
.replace(/<text:p[^>]*>/g, "\n")
|
||||
.replace(/<text:h[^>]*>/g, "\n")
|
||||
.replace(/<\/text:p>/g, "\n")
|
||||
.replace(/<\/text:h>/g, "\n")
|
||||
.replace(/<[^>]+>/g, ""),
|
||||
);
|
||||
sections.push(text);
|
||||
}
|
||||
|
||||
return withLimits(file.name, kind, sections.join("\n\n"), images, {
|
||||
pages: kind === "pptx" ? sections.length : undefined,
|
||||
warnings,
|
||||
imagesTruncated: images.length >= MAX_DOCUMENT_IMAGES,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Plain text */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function extractPlainText(file: File): Promise<ExtractedDocument> {
|
||||
const text = await file.text();
|
||||
return withLimits(file.name, "text", text, [], {});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Dispatcher */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function extractDocument(
|
||||
file: File,
|
||||
onProgress?: (update: DocumentProgress) => void,
|
||||
): Promise<ExtractedDocument> {
|
||||
const kind = documentKindOf(file);
|
||||
if (!kind) throw new Error(`"${file.name}" is not a supported document.`);
|
||||
|
||||
switch (kind) {
|
||||
case "pdf":
|
||||
return extractPdf(file, onProgress);
|
||||
case "docx":
|
||||
onProgress?.({ message: "Reading the Word document…" });
|
||||
return extractDocx(file);
|
||||
case "pptx":
|
||||
onProgress?.({ message: "Reading the slides…" });
|
||||
return extractOoxmlPackage(file, "pptx");
|
||||
case "opendocument":
|
||||
onProgress?.({ message: "Reading the OpenDocument file…" });
|
||||
return extractOoxmlPackage(file, "opendocument");
|
||||
case "text":
|
||||
onProgress?.({ message: "Reading the text file…" });
|
||||
return extractPlainText(file);
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge several documents into one text block with file headers. */
|
||||
export function documentsToText(documents: ExtractedDocument[]): string {
|
||||
return documents
|
||||
.filter((document) => document.text.trim().length > 0)
|
||||
.map((document) => `### ${document.name}\n${document.text.trim()}`)
|
||||
.join("\n\n")
|
||||
.slice(0, MAX_DOCUMENT_CHARS * 2);
|
||||
}
|
||||
153
lib/folders.ts
Normal file
153
lib/folders.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Folder library, stored next to the decks in localStorage.
|
||||
*
|
||||
* Folders are pure organisation: deleting one never deletes decks, it just
|
||||
* moves them back to "Unorganized". The virtual folder `UNORGANIZED_ID` is
|
||||
* never persisted — it is simply every deck with no `folderId`.
|
||||
*/
|
||||
|
||||
import { folderSchema, UNORGANIZED_ID, type Folder } from "./schemas";
|
||||
import { uid } from "./utils";
|
||||
|
||||
const STORAGE_KEY = "freezmo.folders.v1";
|
||||
const EMPTY: Folder[] = [];
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let folders: Folder[] | null = null;
|
||||
let cached: Folder[] = EMPTY;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function read(): Folder[] {
|
||||
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: Folder[] = [];
|
||||
for (const entry of parsed) {
|
||||
const result = folderSchema.safeParse(entry);
|
||||
if (result.success) valid.push(result.data);
|
||||
else console.warn("[freezmo] skipping invalid folder", result.error.issues);
|
||||
}
|
||||
return valid;
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not read folders", error);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoaded(): void {
|
||||
if (folders !== null) return;
|
||||
folders = read();
|
||||
cached = folders;
|
||||
}
|
||||
|
||||
function write(next: Folder[]): void {
|
||||
folders = next;
|
||||
cached = next;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not save folders", error);
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const folderStore = {
|
||||
subscribe(listener: Listener): () => void {
|
||||
ensureLoaded();
|
||||
listeners.add(listener);
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
folders = null;
|
||||
ensureLoaded();
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
},
|
||||
getSnapshot(): Folder[] {
|
||||
ensureLoaded();
|
||||
return cached;
|
||||
},
|
||||
getServerSnapshot(): Folder[] {
|
||||
return EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Queries */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function listFolders(): Folder[] {
|
||||
return [...folderStore.getSnapshot()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
export function getFolder(id: string | undefined): Folder | undefined {
|
||||
if (!id || id === UNORGANIZED_ID) return undefined;
|
||||
return folderStore.getSnapshot().find((folder) => folder.id === id);
|
||||
}
|
||||
|
||||
export function folderName(id: string | undefined): string {
|
||||
if (!id || id === UNORGANIZED_ID) return "Unorganized";
|
||||
return getFolder(id)?.name ?? "Unorganized";
|
||||
}
|
||||
|
||||
export function findFolderByName(name: string): Folder | undefined {
|
||||
const wanted = name.trim().toLowerCase();
|
||||
return folderStore.getSnapshot().find((folder) => folder.name.trim().toLowerCase() === wanted);
|
||||
}
|
||||
|
||||
/** Case-insensitive uniqueness; returns the existing folder when it matches. */
|
||||
export function createFolder(name: string): { folder: Folder } | { error: string } {
|
||||
const trimmed = name.trim().replace(/\s+/g, " ");
|
||||
if (!trimmed) return { error: "Give the folder a name." };
|
||||
if (trimmed.length > 60) return { error: "That name is a bit long (60 characters max)." };
|
||||
|
||||
const existing = findFolderByName(trimmed);
|
||||
if (existing) return { folder: existing };
|
||||
|
||||
const folder: Folder = { id: uid("fold"), name: trimmed, createdAt: Date.now() };
|
||||
ensureLoaded();
|
||||
write([...folderStore.getSnapshot(), folder]);
|
||||
return { folder };
|
||||
}
|
||||
|
||||
export function renameFolder(id: string, name: string): { ok: true } | { error: string } {
|
||||
const trimmed = name.trim().replace(/\s+/g, " ");
|
||||
if (!trimmed) return { error: "Give the folder a name." };
|
||||
const clash = folderStore
|
||||
.getSnapshot()
|
||||
.find((folder) => folder.id !== id && folder.name.trim().toLowerCase() === trimmed.toLowerCase());
|
||||
if (clash) return { error: "Another folder already has that name." };
|
||||
|
||||
ensureLoaded();
|
||||
write(folderStore.getSnapshot().map((folder) => (folder.id === id ? { ...folder, name: trimmed } : folder)));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Removes the folder. Decks inside it are handed back to the caller to re-home. */
|
||||
export function removeFolder(id: string): void {
|
||||
ensureLoaded();
|
||||
write(folderStore.getSnapshot().filter((folder) => folder.id !== id));
|
||||
}
|
||||
|
||||
/** Deleting a folder must never delete decks: callers pair this with unfileDecks(). */
|
||||
export function hasFolder(id: string): boolean {
|
||||
return Boolean(getFolder(id));
|
||||
}
|
||||
|
||||
export function emptyFolder(): void {
|
||||
ensureLoaded();
|
||||
write([]);
|
||||
}
|
||||
79
lib/http-guard.ts
Normal file
79
lib/http-guard.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Small server-side guards shared by the API routes.
|
||||
*
|
||||
* Freezmo is a self-hosted app with no accounts, so the realistic threats are
|
||||
* (a) a hostile page in the user's browser poking the local server, (b) huge
|
||||
* bodies exhausting memory, and (c) odd values ending up in outbound headers.
|
||||
*/
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
|
||||
}
|
||||
|
||||
/** Rejects requests whose body is larger than `maxBytes` (Content-Length check). */
|
||||
export function rejectOversizedBody(request: Request, maxBytes: number): Response | null {
|
||||
const header = request.headers.get("content-length");
|
||||
if (!header) return null;
|
||||
const length = Number.parseInt(header, 10);
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
return Response.json(
|
||||
{
|
||||
error: `Request body is too large (${formatBytes(length)}). Limit is ${formatBytes(maxBytes)}.`,
|
||||
kind: "too_large",
|
||||
},
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same-origin check for state-changing requests.
|
||||
*
|
||||
* There is no cookie-based auth here, so CSRF can't steal a session — but a
|
||||
* malicious website could still make the user's browser fire requests at a
|
||||
* LAN-hosted Freezmo (and burn their rate limit or, in `server` auth mode,
|
||||
* their credits). Browsers always send `Origin` on cross-origin POSTs and
|
||||
* cannot forge it, so a mismatch is a reliable signal. Requests without an
|
||||
* `Origin` (curl, scripts, native apps) are allowed.
|
||||
*/
|
||||
export function rejectCrossOrigin(request: Request): Response | null {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) return null;
|
||||
|
||||
let originHost: string;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid Origin header.", kind: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const host = request.headers.get("host");
|
||||
if (host && originHost === host) return null;
|
||||
|
||||
// Next dev/prod behind a proxy may rewrite Host; trust x-forwarded-host too.
|
||||
const forwardedHost = request.headers.get("x-forwarded-host");
|
||||
if (forwardedHost && originHost === forwardedHost) return null;
|
||||
|
||||
// Allow the loopback pair (localhost vs 127.0.0.1) for convenience.
|
||||
const loopback = new Set(["localhost", "127.0.0.1", "[::1]", "0.0.0.0"]);
|
||||
const bare = (value: string) => value.replace(/:\d+$/, "");
|
||||
if (loopback.has(bare(originHost)) && loopback.has(bare(host ?? ""))) return null;
|
||||
|
||||
return Response.json(
|
||||
{ error: "Cross-origin requests are not allowed.", kind: "forbidden" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* API keys are pasted by humans, so clean them before they go into a header:
|
||||
* trim, drop control characters/whitespace (header injection), cap the length.
|
||||
*/
|
||||
export function sanitiseApiKey(value: string | null | undefined): string {
|
||||
if (!value) return "";
|
||||
return value
|
||||
.replace(/[\u0000-\u001F\u007F-\u009F\s]/g, "")
|
||||
.slice(0, 512);
|
||||
}
|
||||
131
lib/image.ts
Normal file
131
lib/image.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
/** Client-side image helpers: validation, resizing and compression. */
|
||||
|
||||
export const MAX_IMAGE_DIMENSION = 1600;
|
||||
export const JPEG_QUALITY = 0.82;
|
||||
export const MAX_FILE_BYTES = 20 * 1024 * 1024; // 20 MB per source file
|
||||
export const MAX_IMAGES = 8;
|
||||
export const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif", "image/heic", "image/heif"];
|
||||
|
||||
export type PreparedImage = {
|
||||
id: string;
|
||||
dataUrl: string;
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
export function isImageFile(file: File): boolean {
|
||||
if (file.type.startsWith("image/")) return true;
|
||||
return /\.(jpe?g|png|webp|gif|heic|heif|bmp)$/i.test(file.name);
|
||||
}
|
||||
|
||||
export function describeFileError(file: File): string | null {
|
||||
if (!isImageFile(file)) return `"${file.name}" is not an image.`;
|
||||
if (file.size > MAX_FILE_BYTES) return `"${file.name}" is larger than 20 MB.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rough byte length of a base64 data URL. */
|
||||
export function dataUrlBytes(dataUrl: string): number {
|
||||
const index = dataUrl.indexOf(",");
|
||||
if (index === -1) return dataUrl.length;
|
||||
return Math.floor(((dataUrl.length - index - 1) * 3) / 4);
|
||||
}
|
||||
|
||||
async function loadBitmap(blob: Blob, fallbackName: string): Promise<{ width: number; height: number; draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => void }> {
|
||||
// Prefer createImageBitmap: it respects EXIF orientation and is fast.
|
||||
if (typeof createImageBitmap === "function") {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(blob, { imageOrientation: "from-image" });
|
||||
return {
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
draw: (ctx, w, h) => ctx.drawImage(bitmap, 0, 0, w, h),
|
||||
};
|
||||
} catch {
|
||||
/* fall through to the <img> path (Safari/HEIC edge cases) */
|
||||
}
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const element = new Image();
|
||||
element.onload = () => resolve(element);
|
||||
element.onerror = () => reject(new Error(`Could not read "${fallbackName}".`));
|
||||
element.src = url;
|
||||
});
|
||||
return {
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height,
|
||||
draw: (ctx, w, h) => ctx.drawImage(image, 0, 0, w, h),
|
||||
};
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize to max 1600px and re-encode as JPEG to keep uploads small.
|
||||
* Accepts any image blob (photos, canvas output from PDF pages, images
|
||||
* embedded in documents).
|
||||
*/
|
||||
export async function prepareImageFromBlob(blob: Blob, name = "image.jpg"): Promise<PreparedImage> {
|
||||
const bitmap = await loadBitmap(blob, name);
|
||||
const scale = Math.min(1, MAX_IMAGE_DIMENSION / Math.max(bitmap.width, bitmap.height));
|
||||
const width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Your browser could not process this image.");
|
||||
ctx.fillStyle = "#ffffff"; // flatten transparency before JPEG encoding
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
bitmap.draw(ctx, width, height);
|
||||
|
||||
const dataUrl = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
|
||||
|
||||
return {
|
||||
id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
dataUrl,
|
||||
name,
|
||||
width,
|
||||
height,
|
||||
bytes: dataUrlBytes(dataUrl),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resize to max 1600px and re-encode as JPEG to keep uploads small. */
|
||||
export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
return prepareImageFromBlob(file, file.name || "photo.jpg");
|
||||
}
|
||||
|
||||
/** Extract image files from a paste or drop event. */
|
||||
export function imageFilesFromDataTransfer(data: DataTransfer | null): File[] {
|
||||
if (!data) return [];
|
||||
const files: File[] = [];
|
||||
if (data.files?.length) {
|
||||
for (const file of Array.from(data.files)) if (isImageFile(file)) files.push(file);
|
||||
}
|
||||
if (data.items?.length) {
|
||||
for (const item of Array.from(data.items)) {
|
||||
if (item.kind !== "file") continue;
|
||||
const file = item.getAsFile();
|
||||
if (file && isImageFile(file) && !files.some((f) => f.name === file.name && f.size === file.size)) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
174
lib/language.ts
Normal file
174
lib/language.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/**
|
||||
* Cheap, dependency-free language checks used to protect the notes' language.
|
||||
*
|
||||
* The vision model is instructed never to translate, but a small model can
|
||||
* still slip into English on hard handwriting. When that happens we re-run the
|
||||
* transcription through the (much stronger, multilingual) text model.
|
||||
*
|
||||
* These helpers are heuristic on purpose: they are only used to decide whether
|
||||
* one extra repair call is worth it, and a false positive costs a single call
|
||||
* while a false negative would hand the student an English deck.
|
||||
*/
|
||||
|
||||
export type Script =
|
||||
| "latin"
|
||||
| "cyrillic"
|
||||
| "greek"
|
||||
| "han"
|
||||
| "kana"
|
||||
| "hangul"
|
||||
| "arabic"
|
||||
| "hebrew"
|
||||
| "devanagari"
|
||||
| "thai"
|
||||
| "unknown";
|
||||
|
||||
const SCRIPT_RANGES: Array<{ script: Script; test: RegExp }> = [
|
||||
{ script: "cyrillic", test: /[\u0400-\u04FF]/ },
|
||||
{ script: "greek", test: /[\u0370-\u03FF\u1F00-\u1FFF]/ },
|
||||
{ script: "hangul", test: /[\uAC00-\uD7AF\u1100-\u11FF]/ },
|
||||
{ script: "kana", test: /[\u3040-\u30FF]/ },
|
||||
{ script: "han", test: /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/ },
|
||||
{ script: "arabic", test: /[\u0600-\u06FF\u0750-\u077F]/ },
|
||||
{ script: "hebrew", test: /[\u0590-\u05FF]/ },
|
||||
{ script: "devanagari", test: /[\u0900-\u097F]/ },
|
||||
{ script: "thai", test: /[\u0E00-\u0E7F]/ },
|
||||
{ script: "latin", test: /[A-Za-z\u00C0-\u024F]/ },
|
||||
];
|
||||
|
||||
/** Language names/codes we know, mapped to the script they are written in. */
|
||||
const LANGUAGE_SCRIPTS: Record<string, Script> = {
|
||||
english: "latin", en: "latin",
|
||||
czech: "latin", cs: "latin",
|
||||
slovak: "latin", sk: "latin",
|
||||
polish: "latin", pl: "latin",
|
||||
german: "latin", de: "latin",
|
||||
french: "latin", fr: "latin",
|
||||
spanish: "latin", es: "latin",
|
||||
italian: "latin", it: "latin",
|
||||
portuguese: "latin", pt: "latin",
|
||||
dutch: "latin", nl: "latin",
|
||||
swedish: "latin", sv: "latin",
|
||||
norwegian: "latin", no: "latin",
|
||||
danish: "latin", da: "latin",
|
||||
finnish: "latin", fi: "latin",
|
||||
hungarian: "latin", hu: "latin",
|
||||
romanian: "latin", ro: "latin",
|
||||
turkish: "latin", tr: "latin",
|
||||
croatian: "latin", hr: "latin",
|
||||
slovenian: "latin", sl: "latin",
|
||||
serbian: "cyrillic", sr: "cyrillic",
|
||||
russian: "cyrillic", ru: "cyrillic",
|
||||
ukrainian: "cyrillic", uk: "cyrillic",
|
||||
bulgarian: "cyrillic", bg: "cyrillic",
|
||||
belarusian: "cyrillic", be: "cyrillic",
|
||||
macedonian: "cyrillic", mk: "cyrillic",
|
||||
greek: "greek", el: "greek",
|
||||
chinese: "han", mandarin: "han", cantonese: "han", zh: "han",
|
||||
japanese: "kana", ja: "kana",
|
||||
korean: "hangul", ko: "hangul",
|
||||
arabic: "arabic", ar: "arabic",
|
||||
persian: "arabic", farsi: "arabic", urdu: "arabic",
|
||||
hebrew: "hebrew", he: "hebrew",
|
||||
hindi: "devanagari", marathi: "devanagari", nepali: "devanagari",
|
||||
thai: "thai",
|
||||
};
|
||||
|
||||
/** Characters that practically always appear in real text of these languages. */
|
||||
const DISTINCTIVE_CHARS: Record<string, RegExp> = {
|
||||
czech: /[ěščřžýáíéúůťďňĚŠČŘŽÝÁÍÉÚŮŤĎŇ]/,
|
||||
slovak: /[ľščťžýáíéúôäňĺ༊ȍŽÝÁÍÉ]/,
|
||||
polish: /[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/,
|
||||
hungarian: /[őűáéíóöüŐŰÁÉÍÓÖÜ]/,
|
||||
romanian: /[ăâîșțĂÂÎȘȚ]/,
|
||||
turkish: /[ğışöüçĞİŞÖÜÇ]/,
|
||||
croatian: /[čćđšžČĆĐŠŽ]/,
|
||||
slovenian: /[蚞ȊŽ]/,
|
||||
german: /[äöüßÄÖÜ]/,
|
||||
french: /[éèêëàâçîïôûùüÿœæÉÈÊËÀÂÇÎÏÔÛÙŸŒÆ]/,
|
||||
spanish: /[áéíóúñü¡¿ÁÉÍÓÚÑÜ]/,
|
||||
portuguese: /[áâãàçéêíóôõúüÁÂÃÀÇÉÊÍÓÔÕÚ]/,
|
||||
italian: /[àèéìòùÀÈÉÌÒÙ]/,
|
||||
swedish: /[åäöÅÄÖ]/,
|
||||
norwegian: /[æøåÆØÅ]/,
|
||||
danish: /[æøåÆØÅ]/,
|
||||
finnish: /[äöåÄÖÅ]/,
|
||||
dutch: /[ëïéèöüËÏÉÈÖÜ]/,
|
||||
vietnamese: /[ăâđêôơưàảãáạằẳẵắặầẩẫấậèẻẽéẹềểễếệ]/,
|
||||
latvian: /[āčēģīķļņšūž]/,
|
||||
lithuanian: /[ąčęėįšųūž]/,
|
||||
estonian: /[õäöüšžÕÄÖÜŠŽ]/,
|
||||
icelandic: /[áéíóúýþæöðÁÉÍÓÚÝÞÆÖÐ]/,
|
||||
};
|
||||
|
||||
const ENGLISH_STOPWORDS =
|
||||
/\b(the|and|that|with|from|this|which|are|was|were|have|has|for|not|but|you|your|they|their|into|than|then)\b/gi;
|
||||
|
||||
/** Normalise "Czech", "cs-CZ", "česky" to a lookup key. */
|
||||
function languageKey(language: string | undefined): string {
|
||||
if (!language) return "";
|
||||
return language.trim().toLowerCase().replace(/[_.]/g, "-").split("-")[0].replace(/[^a-z]/g, "");
|
||||
}
|
||||
|
||||
export function expectedScript(language: string | undefined): Script | null {
|
||||
const key = languageKey(language);
|
||||
if (!key) return null;
|
||||
return LANGUAGE_SCRIPTS[key] ?? null;
|
||||
}
|
||||
|
||||
/** The script that most non-whitespace characters belong to. */
|
||||
export function dominantScript(text: string): Script {
|
||||
const counts = new Map<Script, number>();
|
||||
for (const { script, test } of SCRIPT_RANGES) {
|
||||
const matches = text.match(new RegExp(test.source, "g"));
|
||||
if (matches) counts.set(script, matches.length);
|
||||
}
|
||||
let best: Script = "unknown";
|
||||
let bestCount = 0;
|
||||
for (const [script, count] of counts) {
|
||||
if (count > bestCount) {
|
||||
best = script;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function looksEnglish(text: string, words: number): boolean {
|
||||
const hits = (text.match(ENGLISH_STOPWORDS) ?? []).length;
|
||||
return words >= 40 && hits >= Math.max(4, Math.round(words / 30));
|
||||
}
|
||||
|
||||
export type LanguageCheck = { suspicious: boolean; reason?: string };
|
||||
|
||||
/**
|
||||
* Decides whether the transcription probably got translated away from the
|
||||
* language the vision model reported.
|
||||
*/
|
||||
export function needsLanguageRepair(input: { language?: string; text: string }): LanguageCheck {
|
||||
const { language, text } = input;
|
||||
const key = languageKey(language);
|
||||
const words = text.split(/\s+/).filter(Boolean).length;
|
||||
if (words < 40) return { suspicious: false }; // too short to judge reliably
|
||||
if (key === "english" || key === "en") return { suspicious: false };
|
||||
|
||||
const expected = expectedScript(language);
|
||||
const actual = dominantScript(text);
|
||||
|
||||
if (expected && expected !== "unknown" && actual !== "unknown" && expected !== actual) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: `notes were declared as ${language} (${expected}) but are written in ${actual}`,
|
||||
};
|
||||
}
|
||||
|
||||
const distinctive = DISTINCTIVE_CHARS[key];
|
||||
if (expected === "latin" && distinctive && !distinctive.test(text) && looksEnglish(text, words)) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: `notes were declared as ${language} but read like English`,
|
||||
};
|
||||
}
|
||||
|
||||
return { suspicious: false };
|
||||
}
|
||||
72
lib/nav.ts
Normal file
72
lib/nav.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Folder selection lives outside React so the sidebar (desktop) and the folder
|
||||
* pills (mobile) can drive the same view.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const ALL_FOLDERS = "all";
|
||||
|
||||
let selected = ALL_FOLDERS;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export const folderSelection = {
|
||||
subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
getSnapshot(): string {
|
||||
return selected;
|
||||
},
|
||||
getServerSnapshot(): string {
|
||||
return ALL_FOLDERS;
|
||||
},
|
||||
set(id: string): void {
|
||||
if (selected === id) return;
|
||||
selected = id;
|
||||
listeners.forEach((listener) => listener());
|
||||
},
|
||||
};
|
||||
|
||||
/* Mobile drawer ---------------------------------------------------------- */
|
||||
|
||||
let drawerOpen = false;
|
||||
const drawerListeners = new Set<() => void>();
|
||||
|
||||
export const sidebarOpen = {
|
||||
subscribe(listener: () => void): () => void {
|
||||
drawerListeners.add(listener);
|
||||
return () => drawerListeners.delete(listener);
|
||||
},
|
||||
getSnapshot(): boolean {
|
||||
return drawerOpen;
|
||||
},
|
||||
getServerSnapshot(): boolean {
|
||||
return false;
|
||||
},
|
||||
set(value: boolean): void {
|
||||
drawerOpen = value;
|
||||
drawerListeners.forEach((listener) => listener());
|
||||
},
|
||||
toggle(): void {
|
||||
sidebarOpen.set(!drawerOpen);
|
||||
},
|
||||
};
|
||||
|
||||
export function useSidebarOpen(): boolean {
|
||||
return useSyncExternalStore(
|
||||
sidebarOpen.subscribe,
|
||||
sidebarOpen.getSnapshot,
|
||||
sidebarOpen.getServerSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
export function useSelectedFolder(): string {
|
||||
return useSyncExternalStore(
|
||||
folderSelection.subscribe,
|
||||
folderSelection.getSnapshot,
|
||||
folderSelection.getServerSnapshot,
|
||||
);
|
||||
}
|
||||
327
lib/ollama.ts
Normal file
327
lib/ollama.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
/**
|
||||
* Minimal server-side Ollama HTTP client.
|
||||
*
|
||||
* Works with both supported setups:
|
||||
* 1. a local daemon (http://localhost:11434) that proxies :cloud models
|
||||
* after `ollama signin`
|
||||
* 2. the direct cloud API (https://ollama.com) with a Bearer API key
|
||||
*
|
||||
* This module must only ever be imported from server code (route handlers).
|
||||
*/
|
||||
|
||||
import { getServerConfig, resolveModelName, type OllamaAuth } from "./config";
|
||||
|
||||
export type OllamaMessage = {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
/** Base64 strings (no data: prefix) or full data URLs; Ollama accepts both. */
|
||||
images?: string[];
|
||||
};
|
||||
|
||||
export type OllamaErrorKind =
|
||||
| "unreachable"
|
||||
| "timeout"
|
||||
| "auth"
|
||||
| "no_credits"
|
||||
| "model_missing"
|
||||
| "http"
|
||||
| "bad_response";
|
||||
|
||||
export class OllamaError extends Error {
|
||||
kind: OllamaErrorKind;
|
||||
status?: number;
|
||||
detail?: string;
|
||||
|
||||
constructor(kind: OllamaErrorKind, message: string, options?: { status?: number; detail?: string }) {
|
||||
super(message);
|
||||
this.name = "OllamaError";
|
||||
this.kind = kind;
|
||||
this.status = options?.status;
|
||||
this.detail = options?.detail;
|
||||
}
|
||||
}
|
||||
|
||||
export function friendlyErrorMessage(error: unknown, baseUrl?: string): string {
|
||||
if (error instanceof OllamaError) {
|
||||
switch (error.kind) {
|
||||
case "unreachable":
|
||||
return `Ollama isn't reachable at ${baseUrl ?? "the configured URL"}. Check your settings and make sure the daemon is running.`;
|
||||
case "timeout":
|
||||
return "Ollama took too long to answer. Try fewer/smaller images, a faster model, or retry.";
|
||||
case "auth":
|
||||
return "Ollama rejected the request. If you are using https://ollama.com, check OLLAMA_API_KEY (create one at ollama.com/settings/keys).";
|
||||
case "no_credits":
|
||||
return error.message;
|
||||
case "model_missing":
|
||||
return error.message;
|
||||
default:
|
||||
return error.message || "Ollama returned an unexpected response.";
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Something went wrong while talking to Ollama.";
|
||||
}
|
||||
|
||||
type ChatOptions = {
|
||||
model: string;
|
||||
messages: OllamaMessage[];
|
||||
/** Credentials for this request (browser key, server key or local daemon). */
|
||||
auth: OllamaAuth;
|
||||
/** Ask Ollama to constrain the output to valid JSON. */
|
||||
json?: boolean;
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
export type ChatResult = {
|
||||
content: string;
|
||||
model: string;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
external?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const onAbort = () => controller.abort();
|
||||
external?.addEventListener("abort", onAbort);
|
||||
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
external?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ollamaChat(options: ChatOptions): Promise<ChatResult> {
|
||||
const config = getServerConfig();
|
||||
const { baseUrl, apiKey, directCloud } = options.auth;
|
||||
const model = resolveModelName(options.model, directCloud);
|
||||
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages: options.messages,
|
||||
stream: false,
|
||||
};
|
||||
if (options.json) body.format = "json";
|
||||
if (typeof options.temperature === "number") {
|
||||
body.options = { temperature: options.temperature };
|
||||
}
|
||||
if (config.think !== null) body.think = config.think;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(
|
||||
`${baseUrl}/api/chat`,
|
||||
{ method: "POST", headers, body: JSON.stringify(body), cache: "no-store" },
|
||||
options.timeoutMs ?? config.timeoutMs,
|
||||
options.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OllamaError("timeout", "The request to Ollama timed out.");
|
||||
}
|
||||
throw new OllamaError(
|
||||
"unreachable",
|
||||
`Could not connect to Ollama at ${baseUrl}.`,
|
||||
{ detail: error instanceof Error ? error.message : String(error) },
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new OllamaError("auth", "Ollama refused the request (unauthorised).", {
|
||||
status: response.status,
|
||||
detail: text.slice(0, 500),
|
||||
});
|
||||
}
|
||||
if (response.status === 402) {
|
||||
throw new OllamaError(
|
||||
"no_credits",
|
||||
`Your Ollama account can't use "${model}": this model is not included in your plan. Add usage credits at ollama.com/settings, or switch to a free-tier model in .env.local.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new OllamaError(
|
||||
"model_missing",
|
||||
`Ollama could not find the model "${model}". Run \`ollama pull ${model}\` or pick another model in .env.local.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
throw new OllamaError(
|
||||
"http",
|
||||
`Ollama responded with HTTP ${response.status}.`,
|
||||
{ status: response.status, detail: text.slice(0, 500) },
|
||||
);
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new OllamaError("bad_response", "Ollama sent a response that wasn't JSON.");
|
||||
}
|
||||
|
||||
const content = extractContent(payload);
|
||||
if (content === null) {
|
||||
throw new OllamaError("bad_response", "Ollama sent a response with no message content.", {
|
||||
detail: JSON.stringify(payload).slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
return { content, model, raw: payload };
|
||||
}
|
||||
|
||||
function extractContent(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const message = record.message;
|
||||
if (message && typeof message === "object") {
|
||||
const content = (message as Record<string, unknown>).content;
|
||||
if (typeof content === "string") return content;
|
||||
}
|
||||
if (typeof record.response === "string") return record.response;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type OllamaModelInfo = { name: string; model: string; size?: number };
|
||||
|
||||
/** List the models known to the given Ollama instance (or the cloud). */
|
||||
export async function ollamaListModels(auth: OllamaAuth, timeoutMs?: number): Promise<OllamaModelInfo[]> {
|
||||
const config = getServerConfig();
|
||||
const { baseUrl, apiKey } = auth;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(
|
||||
`${baseUrl}/api/tags`,
|
||||
{ headers, cache: "no-store" },
|
||||
timeoutMs ?? config.healthTimeoutMs,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new OllamaError("timeout", "Timed out while listing Ollama models.");
|
||||
}
|
||||
throw new OllamaError("unreachable", `Could not connect to Ollama at ${baseUrl}.`, {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new OllamaError("auth", "Ollama refused the request (unauthorised).", {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
throw new OllamaError("http", `Ollama responded with HTTP ${response.status} while listing models.`, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { models?: Array<{ name?: string; model?: string; size?: number }> }
|
||||
| null;
|
||||
|
||||
return (payload?.models ?? []).map((entry) => ({
|
||||
name: entry.name ?? entry.model ?? "unknown",
|
||||
model: entry.model ?? entry.name ?? "unknown",
|
||||
size: entry.size,
|
||||
}));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* API key verification */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type KeyProbe = {
|
||||
/** true = accepted, false = rejected, null = could not tell. */
|
||||
valid: boolean | null;
|
||||
reason?: "invalid" | "no_credits" | "model_missing" | "unreachable";
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type CachedProbe = KeyProbe & { at: number };
|
||||
|
||||
const probeCacheStore = globalThis as unknown as { __freezmoKeyProbes?: Map<string, CachedProbe> };
|
||||
const probeCache: Map<string, CachedProbe> = (probeCacheStore.__freezmoKeyProbes ??= new Map());
|
||||
const PROBE_TTL_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* Model listing is public on ollama.com, so the only trustworthy way to check
|
||||
* a key is a real (tiny) chat request: 1 output token, cached for a few minutes.
|
||||
* Interpreted statuses:
|
||||
* 200 key works
|
||||
* 401 / 403 key rejected
|
||||
* 402 key works but the plan has no access to that model
|
||||
* 404 model unknown — the key isn't the problem
|
||||
*/
|
||||
export async function ollamaVerifyKey(auth: OllamaAuth, model: string, timeoutMs = 20_000): Promise<KeyProbe> {
|
||||
if (!auth.apiKey) return { valid: null };
|
||||
if (process.env.OLLAMA_VERIFY_KEY?.trim().toLowerCase() === "false") return { valid: null };
|
||||
|
||||
const cacheKey = `${auth.baseUrl}|${model}|${hashKey(auth.apiKey)}|${auth.authMode}`;
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < PROBE_TTL_MS) {
|
||||
return { valid: cached.valid, reason: cached.reason, detail: cached.detail };
|
||||
}
|
||||
|
||||
const resolved = resolveModelName(model, auth.directCloud);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithTimeout(
|
||||
`${auth.baseUrl}/api/chat`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
model: resolved,
|
||||
messages: [{ role: "user", content: "ping" }],
|
||||
stream: false,
|
||||
options: { num_predict: 1 },
|
||||
}),
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
} catch {
|
||||
return { valid: null, reason: "unreachable" };
|
||||
}
|
||||
|
||||
const detail = (await response.text().catch(() => "")).slice(0, 300);
|
||||
let probe: KeyProbe;
|
||||
if (response.ok) probe = { valid: true };
|
||||
else if (response.status === 401 || response.status === 403) probe = { valid: false, reason: "invalid", detail };
|
||||
else if (response.status === 402) probe = { valid: true, reason: "no_credits", detail };
|
||||
else if (response.status === 404) probe = { valid: null, reason: "model_missing", detail };
|
||||
else probe = { valid: null, detail: `HTTP ${response.status}` };
|
||||
|
||||
probeCache.set(cacheKey, { ...probe, at: Date.now() });
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** The key itself is never kept — only a hash, so the cache can't leak it. */
|
||||
function hashKey(key: string): string {
|
||||
// Small non-cryptographic hash is enough to bucket cache entries.
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash ^= key.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
212
lib/prompts.ts
Normal file
212
lib/prompts.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Every prompt used by Freezmo lives here so they are easy to review and tune.
|
||||
*
|
||||
* Golden rules baked into the prompts:
|
||||
* - only use information found in the student's own notes
|
||||
* - keep everything in the ORIGINAL LANGUAGE of the notes (never translate)
|
||||
* - never invent facts, definitions, formulas or dates
|
||||
* - vary the activity types
|
||||
* - write a short explanation for every activity
|
||||
* - output JSON only, matching the documented shape
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared language rules */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LANGUAGE_RULES = `LANGUAGE RULES (critical):
|
||||
- Detect the language the notes are written in.
|
||||
- Write EVERYTHING you produce in that SAME language: titles, summaries, transcriptions, questions, options, statements, definitions, reference answers, key points, explanations and feedback.
|
||||
- Never translate. A Czech deck stays Czech, a German deck stays German, a mixed deck stays mixed.
|
||||
- Keep diacritics and national characters exactly as they are (e.g. ěščřžýáíé, ü ö ä ß, ą ę ł, á é í ó ú).
|
||||
- Technical terms that are normally kept in English (e.g. "ATP", "machine learning", "Krebs cycle" in a Czech text) stay as the notes wrote them.
|
||||
- If the notes are mostly in one language with a few foreign terms, use the dominant language.
|
||||
- Numerals, units, dates and formulas are language neutral: copy them unchanged.`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 1 - vision / transcription */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const VISION_SYSTEM_PROMPT = `You are Freezmo's study-note transcriber. You receive photos or scans of a student's own study notes (notebooks, slides, whiteboards, printed handouts) and sometimes the text of a document (PDF, Word, PowerPoint).
|
||||
|
||||
Your job:
|
||||
1. Read ALL text in the images, including handwriting, diagrams labels, tables, formulas and margin notes.
|
||||
2. If several images are given, treat them as one set of notes. Do not duplicate content and do not merge unrelated facts.
|
||||
3. Transcribe the material faithfully into clean, structured plain text. Use short headings, bullet lines, and "term: definition" pairs where the notes do that.
|
||||
4. Preserve exact numbers, units, names, dates and formulas. If a symbol is ambiguous, transcribe your best reading and mark it with "(?)".
|
||||
5. Extract the key concepts, definitions, formulas and standalone facts that are good candidates for study activities.
|
||||
|
||||
${LANGUAGE_RULES}
|
||||
|
||||
Hard rules:
|
||||
- ONLY use information that is actually visible in the images or the text the student provided. Never add outside knowledge, never "correct" the notes with facts of your own, never invent a definition.
|
||||
- If an image is unreadable or contains no study material, say so in the summary and leave the arrays empty.
|
||||
- Do not include the student's personal data, doodles or irrelevant text.
|
||||
- Output JSON only. No markdown fences, no commentary before or after.
|
||||
|
||||
Required JSON shape:
|
||||
{
|
||||
"title": "short descriptive deck title, max 10 words, in the notes' language",
|
||||
"subject": "one or two words, e.g. Biology, Calculus, History",
|
||||
"language": "English name of the language the notes are written in, e.g. Czech",
|
||||
"summary": "one or two sentences describing what these notes cover, in the notes' language",
|
||||
"notes": "the full clean transcription as plain text, using newlines and '-' bullets",
|
||||
"keyConcepts": ["concept 1", "concept 2"],
|
||||
"definitions": [{ "term": "term", "definition": "definition exactly as the notes state it" }],
|
||||
"formulas": [{ "name": "what it is for", "formula": "the formula" }],
|
||||
"facts": ["standalone fact worth memorising"]
|
||||
}
|
||||
`
|
||||
|
||||
export const VISION_USER_INSTRUCTION = `Transcribe these notes and extract the study material. Keep the original language of the notes — do not translate. JSON only, findings only from the notes.`;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step 2 - activity generation */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type" field. Exactly these seven types exist:
|
||||
|
||||
1. flashcard - tap to flip
|
||||
{ "type": "flashcard", "topic": "short label", "front": "question or term", "back": "answer or definition", "explanation": "why this matters" }
|
||||
|
||||
2. multiple_choice - exactly 4 options, exactly one correct
|
||||
{ "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" }
|
||||
Put the correct answer at a varied position across the deck (not always 0!).
|
||||
|
||||
3. true_false - one statement
|
||||
{ "type": "true_false", "topic": "short label", "statement": "...", "answer": true, "explanation": "why it is true/false" }
|
||||
Roughly half of them should be false, and false statements must be subtly wrong (a swapped term, a wrong number), never absurd.
|
||||
|
||||
4. fill_blank - the sentence contains the placeholder "____"
|
||||
{ "type": "fill_blank", "topic": "short label", "sentence": "Mitochondria produce ____ for the cell.", "answer": "ATP", "options": ["ATP", "DNA", "glucose", "water"], "explanation": "..." }
|
||||
"options" is optional but preferred: 3-6 plausible choices including the answer.
|
||||
|
||||
5. matching - 3 to 6 term/definition pairs
|
||||
{ "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." }
|
||||
|
||||
6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them)
|
||||
{ "type": "ordering", "topic": "short label", "instruction": "Put the stages of mitosis in order.", "items": ["Prophase", "Metaphase", "Anaphase", "Telophase"], "explanation": "..." }
|
||||
|
||||
7. short_answer - typed answer, graded by AI
|
||||
{ "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea that must appear", "second idea"], "explanation": "..." }
|
||||
|
||||
Every activity must also have "topic" (2-4 words) and "explanation" (one or two sentences, plain language).`;
|
||||
|
||||
export const GENERATION_SYSTEM_PROMPT = `You are Freezmo's activity designer. You turn a student's own notes into a deck of short, playful, interactive study activities.
|
||||
|
||||
${LANGUAGE_RULES}
|
||||
|
||||
Hard rules:
|
||||
- Use ONLY information contained in the provided notes. Never invent facts, definitions, numbers, dates or formulas. If the notes do not contain enough material for the requested number of activities, create fewer - quality over quantity.
|
||||
- Every explanation must be answerable from the notes; do not introduce outside knowledge.
|
||||
- Vary the activity types. Use a healthy mix of all seven types below, and never put more than two activities of the same type in a row.
|
||||
- Cover the whole notes, not just the first paragraph. Spread activities across the different topics, definitions, formulas and facts.
|
||||
- Make the wrong options plausible (common mix-ups from the same notes), never silly filler.
|
||||
- Keep questions short enough to read on a phone screen. No walls of text.
|
||||
- Never reference "the notes", "the text above" or "the student". Speak directly to the learner.
|
||||
- Match the requested difficulty: easy = recall and simple recognition; medium = understanding and application; hard = analysis, edge cases and multi-step reasoning.
|
||||
- Output JSON only. No markdown fences, no commentary before or after.
|
||||
|
||||
${ACTIVITY_JSON_SPEC}
|
||||
|
||||
Required JSON shape:
|
||||
{
|
||||
"title": "short deck title, max 10 words, in the notes' language",
|
||||
"subject": "one or two words",
|
||||
"language": "English name of the language you wrote in",
|
||||
"summary": "one sentence about what this deck covers",
|
||||
"activities": [ ...activity objects... ]
|
||||
}`;
|
||||
|
||||
export type GenerationRequest = {
|
||||
notes: string;
|
||||
difficulty: "easy" | "medium" | "hard";
|
||||
count: number;
|
||||
/** Language the output must be written in (from the vision step or detected). */
|
||||
language?: string;
|
||||
/** Set when adding more activities to an existing deck. */
|
||||
existing?: { title: string; types: string[]; count: number };
|
||||
/** Extra instructions from the student (topic hint). */
|
||||
focus?: string;
|
||||
};
|
||||
|
||||
export function buildGenerationUserPrompt({
|
||||
notes,
|
||||
difficulty,
|
||||
count,
|
||||
language,
|
||||
existing,
|
||||
focus,
|
||||
}: GenerationRequest): string {
|
||||
const lines: string[] = [
|
||||
`Create a study deck with ${count} activities at ${difficulty} difficulty.`,
|
||||
language
|
||||
? `LANGUAGE: write everything in ${language}. Do not translate into English.`
|
||||
: "LANGUAGE: detect the language of the notes below and write everything in that same language.",
|
||||
focus ? `The student especially wants to practise: ${focus}` : "",
|
||||
existing
|
||||
? `This is an addition to the existing deck "${existing.title}" which already has ${existing.count} activities (types used so far: ${existing.types.join(", ") || "none"}). Generate ${count} NEW activities on material that was not already covered, and prefer types that are underrepresented.`
|
||||
: "",
|
||||
"",
|
||||
"=== STUDENT NOTES (the only allowed source of facts) ===",
|
||||
notes.slice(0, 48_000),
|
||||
"=== END OF NOTES ===",
|
||||
"",
|
||||
"Respond with the JSON object only.",
|
||||
];
|
||||
return lines.filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Short-answer grading */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const GRADING_SYSTEM_PROMPT = `You are a fair but strict study grader. You compare a student's typed answer with a reference answer and the required key points.
|
||||
|
||||
Rules:
|
||||
- Grade on meaning, not wording. Synonyms, different but correct order, and minor spelling mistakes are fine.
|
||||
- A correct answer must cover all key points. Partial answers are incorrect (correct: false) but should be encouraged in the feedback.
|
||||
- LANGUAGE: reply in the same language as the QUESTION and the STUDENT ANSWER. Look at the actual words of the question before writing anything. If the question is in English, your feedback is in English; if it is in Czech, your feedback is in Czech; and so on. If a LANGUAGE instruction is given below, it always wins. Never answer in a language that does not appear in the question or the answer.
|
||||
- Never reveal that you are an AI. Never mention JSON.
|
||||
- Feedback: one or two sentences, max 300 characters. If something is missing, name it plainly, in that language.
|
||||
- Output JSON only:
|
||||
{ "correct": true|false, "score": 0-100, "feedback": "short feedback", "missing": ["key point the student missed"] }`;
|
||||
|
||||
export function buildGradingUserPrompt(input: {
|
||||
question: string;
|
||||
referenceAnswer: string;
|
||||
keyPoints: string[];
|
||||
userAnswer: string;
|
||||
language?: string;
|
||||
}): string {
|
||||
return [
|
||||
`QUESTION: ${input.question}`,
|
||||
`REFERENCE ANSWER: ${input.referenceAnswer}`,
|
||||
`KEY POINTS: ${input.keyPoints.join(" | ")}`,
|
||||
`STUDENT ANSWER: ${input.userAnswer}`,
|
||||
input.language
|
||||
? `LANGUAGE: write "feedback" and "missing" in ${input.language}, the language of the question.`
|
||||
: `LANGUAGE: write "feedback" and "missing" in the language of the QUESTION and STUDENT ANSWER above. Do not switch languages.`,
|
||||
"",
|
||||
"Grade the student answer. Output JSON only.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Retry helper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function buildRepairPrompt(raw: string, error: string, shapeHint: string): string {
|
||||
return [
|
||||
"Your previous reply could not be used because it did not match the required JSON shape.",
|
||||
"",
|
||||
`VALIDATION ERROR: ${error}`,
|
||||
"",
|
||||
"PREVIOUS REPLY:",
|
||||
raw.slice(0, 4000),
|
||||
"",
|
||||
`Return a corrected version as JSON only, matching exactly this shape:`,
|
||||
shapeHint,
|
||||
"Do not add markdown fences or any text outside the JSON. Keep the original language of the notes.",
|
||||
].join("\n");
|
||||
}
|
||||
106
lib/rate-limit.ts
Normal file
106
lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* In-memory sliding-window rate limiter.
|
||||
*
|
||||
* Deliberately dependency-free and good enough for a self-hosted, single
|
||||
* instance app: each key (route + client IP) gets N requests per window.
|
||||
* Counters live in memory and reset on restart, and a busy instance cannot
|
||||
* share state with others — fine for Freezmo, documented in the README.
|
||||
*/
|
||||
|
||||
type Hits = { timestamps: number[] };
|
||||
|
||||
/**
|
||||
* Kept on globalThis so dev-mode hot reloads (and any module re-evaluation)
|
||||
* don't silently reset everybody's counters.
|
||||
*/
|
||||
const globalStore = globalThis as unknown as { __freezmoRateLimit?: Map<string, Hits> };
|
||||
const buckets: Map<string, Hits> = (globalStore.__freezmoRateLimit ??= new Map());
|
||||
const MAX_BUCKETS = 5_000;
|
||||
|
||||
export type RateLimitResult = {
|
||||
allowed: boolean;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
/** Seconds until the oldest hit falls out of the window (0 when allowed). */
|
||||
retryAfter: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMs;
|
||||
|
||||
let bucket = buckets.get(key);
|
||||
if (!bucket) {
|
||||
if (buckets.size >= MAX_BUCKETS) prune(now, windowMs);
|
||||
bucket = { timestamps: [] };
|
||||
buckets.set(key, bucket);
|
||||
}
|
||||
|
||||
bucket.timestamps = bucket.timestamps.filter((timestamp) => timestamp > windowStart);
|
||||
|
||||
if (bucket.timestamps.length >= limit) {
|
||||
const oldest = bucket.timestamps[0];
|
||||
const retryAfter = Math.max(1, Math.ceil((oldest + windowMs - now) / 1000));
|
||||
return {
|
||||
allowed: false,
|
||||
limit,
|
||||
remaining: 0,
|
||||
retryAfter,
|
||||
resetAt: oldest + windowMs,
|
||||
};
|
||||
}
|
||||
|
||||
bucket.timestamps.push(now);
|
||||
return {
|
||||
allowed: true,
|
||||
limit,
|
||||
remaining: Math.max(0, limit - bucket.timestamps.length),
|
||||
retryAfter: 0,
|
||||
resetAt: now + windowMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Drop stale buckets so long-running servers don't grow without bound. */
|
||||
function prune(now: number, windowMs: number): void {
|
||||
const cutoff = now - windowMs;
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.timestamps.length === 0 || bucket.timestamps[bucket.timestamps.length - 1] < cutoff) {
|
||||
buckets.delete(key);
|
||||
}
|
||||
if (buckets.size < MAX_BUCKETS / 2) break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort client identity: proxy headers first, then a shared bucket. */
|
||||
export function clientKey(request: Request): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
const first = forwarded.split(",")[0]?.trim();
|
||||
if (first) return first;
|
||||
}
|
||||
return (
|
||||
request.headers.get("x-real-ip") ??
|
||||
request.headers.get("cf-connecting-ip") ??
|
||||
"local"
|
||||
);
|
||||
}
|
||||
|
||||
export function rateLimitResponse(result: RateLimitResult, message?: string): Response {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
message ??
|
||||
`Too many requests — this app allows ${result.limit} per window. Try again in ${result.retryAfter}s.`,
|
||||
kind: "rate_limited",
|
||||
retryAfter: result.retryAfter,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": String(result.retryAfter),
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
193
lib/schemas.ts
Normal file
193
lib/schemas.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod schemas shared by the API routes (server) and the UI (client).
|
||||
* Everything the model returns is validated against these.
|
||||
*/
|
||||
|
||||
export const difficultySchema = z.enum(["easy", "medium", "hard"]);
|
||||
export type Difficulty = z.infer<typeof difficultySchema>;
|
||||
|
||||
const baseActivity = {
|
||||
/** Short label shown in the feed chrome, e.g. "Cell biology". */
|
||||
topic: z.string().min(1).max(120).optional(),
|
||||
explanation: z.string().min(1).max(600),
|
||||
};
|
||||
|
||||
export const flashcardSchema = z.object({
|
||||
type: z.literal("flashcard"),
|
||||
front: z.string().min(1).max(400),
|
||||
back: z.string().min(1).max(1200),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const multipleChoiceSchema = z.object({
|
||||
type: z.literal("multiple_choice"),
|
||||
question: z.string().min(1).max(600),
|
||||
options: z.array(z.string().min(1).max(300)).length(4),
|
||||
correctIndex: z.number().int().min(0).max(3),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const trueFalseSchema = z.object({
|
||||
type: z.literal("true_false"),
|
||||
statement: z.string().min(1).max(600),
|
||||
answer: z.boolean(),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const fillBlankSchema = z.object({
|
||||
type: z.literal("fill_blank"),
|
||||
/** Must contain a `____` placeholder. */
|
||||
sentence: z.string().min(1).max(600),
|
||||
answer: z.string().min(1).max(200),
|
||||
/** Optional word bank; when present it is shown as tappable choices. */
|
||||
options: z.array(z.string().min(1).max(200)).min(2).max(6).optional(),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const matchingSchema = z.object({
|
||||
type: z.literal("matching"),
|
||||
instruction: z.string().min(1).max(300),
|
||||
pairs: z
|
||||
.array(
|
||||
z.object({
|
||||
term: z.string().min(1).max(200),
|
||||
definition: z.string().min(1).max(300),
|
||||
}),
|
||||
)
|
||||
.min(3)
|
||||
.max(6),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const orderingSchema = z.object({
|
||||
type: z.literal("ordering"),
|
||||
instruction: z.string().min(1).max(300),
|
||||
/** Items in the CORRECT order; the UI shuffles them. */
|
||||
items: z.array(z.string().min(1).max(300)).min(3).max(7),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
export const shortAnswerSchema = z.object({
|
||||
type: z.literal("short_answer"),
|
||||
question: z.string().min(1).max(600),
|
||||
referenceAnswer: z.string().min(1).max(1200),
|
||||
/** Ideas the grader should look for. */
|
||||
keyPoints: z.array(z.string().min(1).max(200)).min(1).max(6),
|
||||
...baseActivity,
|
||||
});
|
||||
|
||||
/**
|
||||
* Activities as produced by the model (no id / xp yet - the server adds them).
|
||||
*/
|
||||
export const generatedActivitySchema = z.discriminatedUnion("type", [
|
||||
flashcardSchema,
|
||||
multipleChoiceSchema,
|
||||
trueFalseSchema,
|
||||
fillBlankSchema,
|
||||
matchingSchema,
|
||||
orderingSchema,
|
||||
shortAnswerSchema,
|
||||
]);
|
||||
export type GeneratedActivity = z.infer<typeof generatedActivitySchema>;
|
||||
|
||||
export const generatedDeckSchema = z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
subject: z.string().max(80).optional(),
|
||||
summary: z.string().max(400).optional(),
|
||||
/** Language of the generated content, e.g. "Czech" or "cs". */
|
||||
language: z.string().max(60).optional(),
|
||||
activities: z.array(generatedActivitySchema).min(4).max(20),
|
||||
});
|
||||
export type GeneratedDeck = z.infer<typeof generatedDeckSchema>;
|
||||
|
||||
/** What the vision step must return. */
|
||||
export const extractedNotesSchema = z.object({
|
||||
title: z.string().min(1).max(120),
|
||||
subject: z.string().max(80).optional(),
|
||||
summary: z.string().max(400).optional(),
|
||||
/**
|
||||
* Language of the notes (English name or BCP-47 code). Everything the app
|
||||
* generates afterwards must be written in this language.
|
||||
*/
|
||||
language: z.string().min(2).max(60).default("the same language as the notes"),
|
||||
/** Clean, structured transcription of everything relevant to study. */
|
||||
notes: z.string().min(1),
|
||||
keyConcepts: z.array(z.string().min(1).max(300)).min(1).max(40),
|
||||
definitions: z
|
||||
.array(z.object({ term: z.string().min(1).max(200), definition: z.string().min(1).max(600) }))
|
||||
.max(30)
|
||||
.default([]),
|
||||
formulas: z
|
||||
.array(z.object({ name: z.string().min(1).max(200), formula: z.string().min(1).max(400) }))
|
||||
.max(30)
|
||||
.default([]),
|
||||
facts: z.array(z.string().min(1).max(400)).max(40).default([]),
|
||||
});
|
||||
export type ExtractedNotes = z.infer<typeof extractedNotesSchema>;
|
||||
|
||||
/** Grading response for short answers. */
|
||||
export const gradeResultSchema = z.object({
|
||||
correct: z.boolean(),
|
||||
score: z.number().min(0).max(100),
|
||||
feedback: z.string().min(1).max(600),
|
||||
missing: z.array(z.string().max(200)).max(6).default([]),
|
||||
});export type GradeResult = z.infer<typeof gradeResultSchema>;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Client-side deck types (activity + id/xp, plus storage metadata) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type Activity = GeneratedActivity & { id: string; xp: number };
|
||||
|
||||
export type Folder = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export const folderSchema: z.ZodType<Folder> = z.object({
|
||||
id: z.string().min(1).max(80),
|
||||
name: z.string().min(1).max(60),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
|
||||
/** Virtual folder that holds every deck without a folder of its own. */
|
||||
export const UNORGANIZED_ID = "unorganized";
|
||||
|
||||
export type Deck = {
|
||||
id: string;
|
||||
title: string;
|
||||
subject?: string;
|
||||
summary?: string;
|
||||
/** Language the deck is written in (kept for grading, regenerate, add-more). */
|
||||
language?: string;
|
||||
/** Folder this deck lives in; undefined means "Unorganized". */
|
||||
folderId?: string;
|
||||
/** Text extracted from the user's notes - reused by regenerate/add-more. */
|
||||
sourceText: string;
|
||||
difficulty: Difficulty;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
activities: Activity[];
|
||||
attempts: number;
|
||||
/** Best result as a 0-100 percentage. */
|
||||
bestScore?: number;
|
||||
};
|
||||
|
||||
export const deckSchema: z.ZodType<Deck> = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
subject: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
folderId: z.string().optional(),
|
||||
sourceText: z.string(),
|
||||
difficulty: difficultySchema,
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
activities: z.array(generatedActivitySchema.and(z.object({ id: z.string(), xp: z.number() }))).min(1),
|
||||
attempts: z.number(),
|
||||
bestScore: z.number().optional(),
|
||||
});
|
||||
84
lib/settings.ts
Normal file
84
lib/settings.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Browser-side settings, including the user's own Ollama API key.
|
||||
*
|
||||
* The key lives ONLY here, in this browser's localStorage. It is never sent
|
||||
* anywhere except to Freezmo's own API routes on this server (in the
|
||||
* `x-ollama-key` header), which forward it to Ollama for that single request
|
||||
* and never write it to disk, a log, a cookie or the environment.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "freezmo.settings.v1";
|
||||
|
||||
export type BrowserSettings = {
|
||||
/**
|
||||
* The user's own Ollama API key. Nothing else is stored here — the endpoint
|
||||
* and model names always come from the server's .env, never from the client.
|
||||
*/
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
const EMPTY: BrowserSettings = { apiKey: "" };
|
||||
|
||||
let cached: BrowserSettings | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function read(): BrowserSettings {
|
||||
if (typeof window === "undefined") return EMPTY;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return EMPTY;
|
||||
const parsed = JSON.parse(raw) as Partial<BrowserSettings>;
|
||||
return { apiKey: typeof parsed.apiKey === "string" ? parsed.apiKey.slice(0, 512) : "" };
|
||||
} catch {
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function write(next: BrowserSettings): void {
|
||||
cached = next;
|
||||
try {
|
||||
if (next.apiKey) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ apiKey: next.apiKey }));
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not save settings (storage disabled?)", error);
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const settingsStore = {
|
||||
subscribe(listener: () => void): () => void {
|
||||
if (cached === null) cached = read();
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
getSnapshot(): BrowserSettings {
|
||||
if (cached === null) cached = read();
|
||||
return cached;
|
||||
},
|
||||
getServerSnapshot(): BrowserSettings {
|
||||
return EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
export function getApiKey(): string {
|
||||
return settingsStore.getSnapshot().apiKey;
|
||||
}
|
||||
|
||||
export function saveSettings(next: BrowserSettings): void {
|
||||
write(next);
|
||||
}
|
||||
|
||||
export function clearApiKey(): void {
|
||||
write({ apiKey: "" });
|
||||
}
|
||||
|
||||
/** Header used to hand the key to our own API routes for one request. */
|
||||
export function authHeaders(): Record<string, string> {
|
||||
const { apiKey } = settingsStore.getSnapshot();
|
||||
return apiKey ? { "x-ollama-key": apiKey } : {};
|
||||
}
|
||||
263
lib/storage.ts
Normal file
263
lib/storage.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Deck library backed by localStorage. No accounts, no server state.
|
||||
* Exposes a tiny observable store so every screen stays in sync.
|
||||
*/
|
||||
|
||||
import { deckSchema, UNORGANIZED_ID, type Activity, type Deck } from "./schemas";
|
||||
import { createFolder, findFolderByName, getFolder, removeFolder } from "./folders";
|
||||
import { uid } from "./utils";
|
||||
|
||||
const STORAGE_KEY = "freezmo.decks.v1";
|
||||
const EMPTY: Deck[] = [];
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
let decks: Deck[] | null = null;
|
||||
let cached: Deck[] = EMPTY;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
function read(): Deck[] {
|
||||
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: Deck[] = [];
|
||||
for (const entry of parsed) {
|
||||
const result = deckSchema.safeParse(entry);
|
||||
if (result.success) valid.push(result.data);
|
||||
else console.warn("[freezmo] skipping invalid deck in storage", result.error.issues);
|
||||
}
|
||||
return valid;
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not read the deck library", error);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoaded(): void {
|
||||
if (decks !== null) return;
|
||||
decks = read();
|
||||
cached = decks;
|
||||
}
|
||||
|
||||
function write(next: Deck[]): void {
|
||||
decks = next;
|
||||
cached = next;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn("[freezmo] could not save the deck library (storage full?)", error);
|
||||
}
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export const deckStore = {
|
||||
subscribe(listener: Listener): () => void {
|
||||
ensureLoaded();
|
||||
listeners.add(listener);
|
||||
// Another tab may have changed the library.
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
decks = null;
|
||||
ensureLoaded();
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
},
|
||||
getSnapshot(): Deck[] {
|
||||
ensureLoaded();
|
||||
return cached;
|
||||
},
|
||||
getServerSnapshot(): Deck[] {
|
||||
return EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
/** Newest first. */
|
||||
export function sortDecks(list: Deck[]): Deck[] {
|
||||
return [...list].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
export function getDeck(id: string): Deck | undefined {
|
||||
return deckStore.getSnapshot().find((deck) => deck.id === id);
|
||||
}
|
||||
|
||||
export function upsertDeck(deck: Deck): void {
|
||||
ensureLoaded();
|
||||
const current = deckStore.getSnapshot();
|
||||
const index = current.findIndex((entry) => entry.id === deck.id);
|
||||
const next = index === -1 ? [deck, ...current] : current.map((entry) => (entry.id === deck.id ? deck : entry));
|
||||
write(next);
|
||||
}
|
||||
|
||||
export function renameDeck(id: string, title: string): void {
|
||||
ensureLoaded();
|
||||
write(deckStore.getSnapshot().map((deck) => (deck.id === id ? { ...deck, title, updatedAt: Date.now() } : deck)));
|
||||
}
|
||||
|
||||
export function deleteDeck(id: string): void {
|
||||
ensureLoaded();
|
||||
write(deckStore.getSnapshot().filter((deck) => deck.id !== id));
|
||||
}
|
||||
|
||||
export function recordAttempt(
|
||||
id: string,
|
||||
result: { scorePercent: number; correct: number; total: number },
|
||||
): void {
|
||||
ensureLoaded();
|
||||
write(
|
||||
deckStore.getSnapshot().map((deck) =>
|
||||
deck.id === id
|
||||
? {
|
||||
...deck,
|
||||
attempts: deck.attempts + 1,
|
||||
bestScore: Math.max(deck.bestScore ?? 0, Math.round(result.scorePercent)),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: deck,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Move a deck into a folder (pass undefined/UNORGANIZED_ID for "Unorganized"). */
|
||||
export function moveDeck(id: string, folderId: string | undefined): void {
|
||||
ensureLoaded();
|
||||
const target = !folderId || folderId === UNORGANIZED_ID ? undefined : folderId;
|
||||
write(
|
||||
deckStore.getSnapshot().map((deck) =>
|
||||
deck.id === id ? { ...deck, folderId: target, updatedAt: Date.now() } : deck,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Used when a folder is deleted: every deck inside goes back to Unorganized. */
|
||||
export function unfileDecks(folderId: string): void {
|
||||
ensureLoaded();
|
||||
write(
|
||||
deckStore.getSnapshot().map((deck) =>
|
||||
deck.folderId === folderId ? { ...deck, folderId: undefined, updatedAt: Date.now() } : deck,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Deletes a folder for good — its decks always survive in "Unorganized". */
|
||||
export function deleteFolder(folderId: string): void {
|
||||
unfileDecks(folderId);
|
||||
removeFolder(folderId);
|
||||
}
|
||||
|
||||
export function countDecksInFolder(folderId: string): number {
|
||||
ensureLoaded();
|
||||
if (folderId === UNORGANIZED_ID) return deckStore.getSnapshot().filter((deck) => !deck.folderId).length;
|
||||
return deckStore.getSnapshot().filter((deck) => deck.folderId === folderId).length;
|
||||
}
|
||||
|
||||
export function appendActivities(id: string, activities: Activity[], title?: string): void {
|
||||
ensureLoaded();
|
||||
write(
|
||||
deckStore.getSnapshot().map((deck) =>
|
||||
deck.id === id
|
||||
? {
|
||||
...deck,
|
||||
title: title ?? deck.title,
|
||||
activities: [...deck.activities, ...activities],
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: deck,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Import / export */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type ExportBundle = {
|
||||
app: "freezmo";
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
/** Folder name travels with the deck so imports land in the right place. */
|
||||
folder?: string | null;
|
||||
deck: Deck;
|
||||
};
|
||||
|
||||
export function exportDeck(deck: Deck): string {
|
||||
const bundle: ExportBundle = {
|
||||
app: "freezmo",
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
folder: folderNameOf(deck),
|
||||
deck,
|
||||
};
|
||||
return JSON.stringify(bundle, null, 2);
|
||||
}
|
||||
|
||||
function folderNameOf(deck: Deck): string | null {
|
||||
if (!deck.folderId) return null;
|
||||
return getFolder(deck.folderId)?.name ?? null;
|
||||
}
|
||||
|
||||
export function downloadDeck(deck: Deck): void {
|
||||
const blob = new Blob([exportDeck(deck)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${deck.title.replace(/[^a-z0-9]+/gi, "-").toLowerCase() || "freezmo-deck"}.json`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Accepts a Freezmo export bundle or a bare deck object. */
|
||||
export function importDeck(raw: string): { deck: Deck } | { error: string } {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { error: "That file is not valid JSON." };
|
||||
}
|
||||
|
||||
const bundle = parsed && typeof parsed === "object" && "deck" in parsed ? (parsed as { folder?: unknown }) : null;
|
||||
const candidate = bundle ? (parsed as { deck: unknown }).deck : parsed;
|
||||
|
||||
const result = deckSchema.safeParse(candidate);
|
||||
if (!result.success) {
|
||||
return { error: "That file doesn't look like a Freezmo deck." };
|
||||
}
|
||||
|
||||
// Recreate the exported folder (or reuse an existing one with the same name).
|
||||
let folderId: string | undefined;
|
||||
const folderName = typeof bundle?.folder === "string" ? bundle.folder.trim() : "";
|
||||
if (folderName) {
|
||||
const existing = findFolderByName(folderName);
|
||||
if (existing) {
|
||||
folderId = existing.id;
|
||||
} else {
|
||||
const created = createFolder(folderName);
|
||||
folderId = "folder" in created ? created.folder.id : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const deck: Deck = {
|
||||
...result.data,
|
||||
folderId,
|
||||
id: uid("deck"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
attempts: 0,
|
||||
bestScore: undefined,
|
||||
};
|
||||
upsertDeck(deck);
|
||||
return { deck };
|
||||
}
|
||||
13
lib/ui-events.ts
Normal file
13
lib/ui-events.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Tiny event bus so any screen can open the settings dialog (which lives in
|
||||
* the header) without prop drilling.
|
||||
*/
|
||||
|
||||
export const OPEN_SETTINGS_EVENT = "freezmo:open-settings";
|
||||
|
||||
export function openSettings(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(OPEN_SETTINGS_EVENT));
|
||||
}
|
||||
39
lib/use-health.ts
Normal file
39
lib/use-health.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { fetchHealth, type Health } from "./client-api";
|
||||
import { settingsStore } from "./settings";
|
||||
|
||||
/**
|
||||
* Health status for the current browser (re-checks whenever the API key
|
||||
* changes). Used by the settings dialog and the "add your key" banner.
|
||||
*/
|
||||
export function useHealth() {
|
||||
const apiKey = useSyncExternalStore(
|
||||
settingsStore.subscribe,
|
||||
() => settingsStore.getSnapshot().apiKey,
|
||||
() => "",
|
||||
);
|
||||
const [health, setHealth] = useState<Health | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setHealth(await fetchHealth());
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Could not reach the Freezmo server.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, apiKey]);
|
||||
|
||||
return { health, loading, error, refresh, apiKey };
|
||||
}
|
||||
59
lib/utils.ts
Normal file
59
lib/utils.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function uid(prefix = "id"): string {
|
||||
const rand =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID().slice(0, 8)
|
||||
: Math.random().toString(36).slice(2, 10);
|
||||
return `${prefix}_${Date.now().toString(36)}_${rand}`;
|
||||
}
|
||||
|
||||
/** Deterministic-ish shuffle (Fisher-Yates). Returns a new array. */
|
||||
export function shuffle<T>(input: readonly T[]): T[] {
|
||||
const out = [...input];
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function formatDate(timestamp: number): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(new Date(timestamp));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove ```json fences and stray prose around a JSON object. */
|
||||
export function stripFences(text: string): string {
|
||||
let value = text.trim();
|
||||
const fence = value.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fence) value = fence[1].trim();
|
||||
if (value.startsWith("{") || value.startsWith("[")) return value;
|
||||
const start = value.indexOf("{");
|
||||
const end = value.lastIndexOf("}");
|
||||
if (start !== -1 && end > start) return value.slice(start, end + 1);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseJsonLoose(text: string): unknown {
|
||||
const cleaned = stripFences(text);
|
||||
try {
|
||||
return JSON.parse(cleaned);
|
||||
} catch {
|
||||
// Last resort: tolerate trailing commas from chatty models.
|
||||
return JSON.parse(cleaned.replace(/,\s*([}\]])/g, "$1"));
|
||||
}
|
||||
}
|
||||
|
||||
export function truncate(value: string, max: number): string {
|
||||
return value.length > max ? `${value.slice(0, max - 1)}…` : value;
|
||||
}
|
||||
26
lib/xp.ts
Normal file
26
lib/xp.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { Difficulty } from "./schemas";
|
||||
|
||||
/**
|
||||
* Base XP for a correct answer. Harder decks are worth more, and the
|
||||
* multi-part activity types get a small bonus.
|
||||
*/
|
||||
export const XP_BY_DIFFICULTY: Record<Difficulty, number> = {
|
||||
easy: 10,
|
||||
medium: 15,
|
||||
hard: 20,
|
||||
};
|
||||
|
||||
const TYPE_BONUS: Record<string, number> = {
|
||||
matching: 5,
|
||||
ordering: 5,
|
||||
short_answer: 5,
|
||||
};
|
||||
|
||||
export function xpForActivity(type: string, difficulty: Difficulty): number {
|
||||
return XP_BY_DIFFICULTY[difficulty] + (TYPE_BONUS[type] ?? 0);
|
||||
}
|
||||
|
||||
/** Consecutive-correct streak multiplier (capped at 3x). */
|
||||
export function streakMultiplier(streak: number): number {
|
||||
return Math.min(1 + Math.max(0, streak) * 0.1, 3);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue