"use client"; import { localeHeader, t } from "./i18n"; import type { Activity, Difficulty, HomeworkMode, HomeworkStep } from "./schemas"; import { API_KEY_HEADER, 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 } | { 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; } } /** * Reads an NDJSON response line by line and hands each parsed event to the * callback. Malformed lines are skipped (a half-written line must never break * the stream), and the trailing buffer is flushed at the end. */ export async function consumeNdjson( response: Response, handleEvent: (event: unknown) => void, ): Promise { if (!response.body) throw new ApiError(t("api.empty"), "bad_response"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const flush = (line: string) => { if (!line.trim()) return; try { handleEvent(JSON.parse(line)); } catch { /* ignore malformed lines */ } }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newline = buffer.indexOf("\n"); while (newline !== -1) { flush(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); newline = buffer.indexOf("\n"); } } flush(buffer); } /** * Calls POST /api/generate and consumes the NDJSON progress stream. * Progress events arrive between the vision and generation steps. */ export async function generateDeck( input: GenerateInput, options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {}, ): Promise<{ deck: StreamedDeck; meta: Record }> { let response: Response; try { response = await fetch("/api/generate", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, body: JSON.stringify(input), signal: options.signal, }); } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new ApiError(t("api.cancelled"), "aborted"); } throw new ApiError(t("api.unreachable"), "network"); } if (!response.ok) { const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null; throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind); } let deck: StreamedDeck | null = null; let meta: Record = {}; let streamError: ApiError | null = null; let sawDeck = false; await consumeNdjson(response, (raw) => { const event = raw as StreamEvent; if (event.type === "stage") { options.onProgress?.({ stage: event.stage, message: event.message }); } else if (event.type === "deck") { deck = event.deck; meta = event.meta ?? {}; sawDeck = true; options.onProgress?.({ stage: "done", message: t("api.almost") }); } else if (event.type === "error") { streamError = new ApiError(event.error, event.kind); } }); if (streamError) throw streamError; if (!deck) { throw new ApiError( sawDeck ? t("api.incomplete") : t("api.noDeck"), "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(t("api.noActivities"), "bad_response"); } return { deck: { ...(deck as StreamedDeck), activities: valid }, meta }; } /* ------------------------------------------------------------------ */ /* Make my homework */ /* ------------------------------------------------------------------ */ export type HomeworkInput = { mode: HomeworkMode; images?: string[]; text?: string; /** Mode "check": the student's own attempt at the assignment. */ solution?: string; language?: string; }; export type HomeworkProgress = { stage: "vision" | "generation" | "done"; message: string; }; export type StreamedHomework = | { mode: "make"; title: string; language?: string; answer: string; sourceText?: string } | { mode: "guide"; title: string; language?: string; steps: HomeworkStep[]; sourceText?: string } | { mode: "check"; title: string; language?: string; verdict: string; correct: string[]; wrong: Array<{ issue: string; why: string; fix: string }>; corrected: string; sourceText?: string; }; type HomeworkStreamEvent = | { type: "stage"; stage: "vision" | "generation" | "done"; message: string } | { type: "homework"; homework: StreamedHomework; meta: Record } | { type: "error"; error: string; kind?: string }; /** * Calls POST /api/homework and consumes the NDJSON progress stream. Same shape * as generateDeck: stage events first, then one final payload. */ export async function generateHomework( input: HomeworkInput, options: { onProgress?: (progress: HomeworkProgress) => void; signal?: AbortSignal } = {}, ): Promise<{ homework: StreamedHomework; meta: Record }> { let response: Response; try { response = await fetch("/api/homework", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, body: JSON.stringify(input), signal: options.signal, }); } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new ApiError(t("api.cancelled"), "aborted"); } throw new ApiError(t("api.unreachable"), "network"); } if (!response.ok) { const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null; throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind); } let homework: StreamedHomework | null = null; let meta: Record = {}; let streamError: ApiError | null = null; await consumeNdjson(response, (raw) => { const event = raw as HomeworkStreamEvent; if (event.type === "stage") { options.onProgress?.({ stage: event.stage, message: event.message }); } else if (event.type === "homework") { homework = event.homework; meta = event.meta ?? {}; options.onProgress?.({ stage: "done", message: t("api.almost") }); } else if (event.type === "error") { streamError = new ApiError(event.error, event.kind); } }); if (streamError) throw streamError; if (!homework) { throw new ApiError(t("api.noHomework"), "bad_response"); } const result = homework as StreamedHomework; if (result.mode === "make") { if (!result.answer?.trim()) throw new ApiError(t("api.noHomework"), "bad_response"); } else if (result.mode === "check") { if (!result.corrected?.trim() || !result.verdict?.trim()) { throw new ApiError(t("api.noHomework"), "bad_response"); } } else { const steps = result.steps?.filter((step) => step && typeof step.title === "string"); if (!steps || steps.length < 2) throw new ApiError(t("api.noHomework"), "bad_response"); // Checkpoints must look like real activities; drop anything malformed // rather than crash the guide. const cleaned = steps.map((step) => ({ ...step, checkpoint: step.checkpoint && typeof step.checkpoint.type === "string" && typeof step.checkpoint.id === "string" ? step.checkpoint : undefined, })); return { homework: { ...result, steps: cleaned }, meta }; } return { homework: result, meta }; } /** Follow-up on a finished result: shorter, more detail, or explain again. */ export async function refineHomework(input: { instruction: "shorter" | "more" | "explain"; text: string; context?: string; signal?: AbortSignal; }): Promise { let response: Response; try { response = await fetch("/api/homework/refine", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...localeHeader() }, body: JSON.stringify({ instruction: input.instruction, text: input.text, context: input.context }), signal: input.signal, }); } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new ApiError(t("api.cancelled"), "aborted"); } throw new ApiError(t("api.unreachable"), "network"); } const body = (await response.json().catch(() => null)) as { text?: string; error?: string; kind?: string } | null; if (!response.ok) { throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind); } if (!body?.text?.trim()) throw new ApiError(t("api.noHomework"), "bad_response"); return body.text.trim(); } 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(), ...localeHeader() }, body: JSON.stringify(input), signal: input.signal, }); } catch (error) { if (error instanceof Error && error.name === "AbortError") { throw new ApiError(t("api.gradingCancelled"), "aborted"); } throw new ApiError(t("api.unreachableShort"), "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 ?? t("api.gradingFailed", { status: 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 HealthChatModel = { name: string; badge: "text" | "vision" | "both"; available: boolean | null; }; 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; /** Chat model allowlist; optional so older servers/mocks stay valid. */ chatModels?: HealthChatModel[]; latencyMs: number | null; modelCount: number | null; error: string | null; kind?: string; checkedAt: string; }; export async function fetchHealth(): Promise { let response: Response; try { response = await fetch("/api/health", { cache: "no-store", headers: { ...authHeaders(), ...localeHeader() } }); } catch { throw new ApiError(t("api.unreachableShort"), "network"); } const body = (await response.json().catch(() => null)) as (Partial & { error?: string; kind?: string }) | null; if (!response.ok) { throw new ApiError( body?.error ?? t("api.healthFailed", { status: response.status }), body?.kind ?? "http", ); } // Guard against anything that isn't a health payload (e.g. a proxy error // page) so consumers can rely on the shape. if (!body || typeof body !== "object" || !body.vision || !body.text) { throw new ApiError(t("api.healthUnexpected"), "bad_response"); } return body as Health; } export type KeyCheck = | { result: "valid" } | { result: "invalid"; message: string } | { result: "offline"; message: string }; /** * Checks a *draft* key before it is saved anywhere, by asking our own * `/api/health` route (which performs a real one-token chat probe against * Ollama — model listings alone prove nothing). The key travels in the * request header for this single call and is never stored server-side. */ export async function verifyOllamaKey(apiKey: string): Promise { let response: Response; try { response = await fetch("/api/health", { cache: "no-store", headers: { [API_KEY_HEADER]: apiKey, ...localeHeader() }, }); } catch { return { result: "offline", message: t("api.unreachable"), }; } const body = (await response.json().catch(() => null)) as Health | null; if (!response.ok || !body) { return { result: "offline", message: t("api.serverUnexpected"), }; } if (body.keyAccepted === false) { return { result: "invalid", message: body.error ?? t("api.keyRejected"), }; } if (body.keyAccepted === true) return { result: "valid" }; if (!body.reachable) { return { result: "offline", message: body.error ?? t("api.ollamaUnreachable"), }; } // Reachable, but the probe was inconclusive (usually a model-availability // matter rather than a bad key). Let the user in — Settings shows details. return { result: "valid" }; }