"use client"; import { localeHeader, t } from "./i18n"; import type { Activity, Difficulty, HomeworkMode, HomeworkStep } from "./schemas"; import { API_KEY_HEADER, authHeaders } from "./settings"; import { providerHeaders, providersStore } from "./providers-client"; 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; }; /** No response at all for this long: the run is stuck, not slow. */ const RESPONSE_GUARD_MS = 45_000; /** No bytes for this long while streaming: the model stopped talking. The * routes heartbeat every 15s, so 90s of silence really is silence. */ const STREAM_GUARD_MS = 90_000; type StreamEvent = | { type: "stage"; stage: "vision" | "generation" | "done"; message: string } | { type: "deck"; deck: StreamedDeck; meta: Record } | { type: "error"; error: string; kind?: string } | { type: "ping" }; 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, options: { stallMs?: number } = {}, ): Promise { if (!response.body) throw new ApiError(t("api.empty"), "bad_response"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; const stallMs = options.stallMs ?? 0; const flush = (line: string) => { if (!line.trim()) return; try { handleEvent(JSON.parse(line)); } catch { /* ignore malformed lines */ } }; while (true) { let chunk: ReadableStreamReadResult; let timer: ReturnType | null = null; try { if (stallMs > 0) { const stalled = new Promise((_, reject) => { timer = setTimeout(() => reject(new ApiError(t("api.stalled"), "timeout")), stallMs); }); chunk = await Promise.race([reader.read(), stalled]); } else { chunk = await reader.read(); } } catch (error) { // A connection that dropped mid-run used to leak a raw DOMException, and // the wizard answered with its generic "check your Ollama settings" // card for what was really a network blip. Classify everything here. void reader.cancel().catch(() => {}); if (error instanceof ApiError) throw error; if (error instanceof Error && error.name === "AbortError") { throw new ApiError(t("api.cancelled"), "aborted"); } throw new ApiError(t("api.streamLost"), "network"); } finally { if (timer) clearTimeout(timer); } if (chunk.done) break; buffer += decoder.decode(chunk.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 }> { // A provider picked in Settings → Providers runs the generation instead of // the server's Ollama; its key travels with the request only. const generation = providersStore.getSnapshot().generation; // A run that goes quiet is worse than one that fails: the wizard used to sit // on its loading screen until the server's own 2x deadline (ten minutes by // default) when the model hung. The caller's signal is forwarded, and a // no-response guard covers the "nothing at all" phase. const controller = new AbortController(); const forwarded = options.signal; let headersTimedOut = false; if (forwarded) { if (forwarded.aborted) controller.abort(); else forwarded.addEventListener("abort", () => controller.abort(), { once: true }); } const headersTimer = setTimeout(() => { headersTimedOut = true; controller.abort(); }, RESPONSE_GUARD_MS); let response: Response; try { response = await fetch("/api/generate", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() }, body: JSON.stringify( generation ? { ...input, textModel: generation.textModel, visionModel: generation.visionModel } : input, ), signal: controller.signal, }); } catch { if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted"); if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout"); throw new ApiError(t("api.unreachable"), "network"); } finally { clearTimeout(headersTimer); } 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); } }, { stallMs: STREAM_GUARD_MS }); 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; /** Mode "check": photos of the attempt (handwritten work, scanned pages). */ solutionImages?: 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 } | { type: "ping" }; /** * 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 }> { const generation = providersStore.getSnapshot().generation; // Same guards as generateDeck: no infinite loading screen on a hung model. const controller = new AbortController(); const forwarded = options.signal; let headersTimedOut = false; if (forwarded) { if (forwarded.aborted) controller.abort(); else forwarded.addEventListener("abort", () => controller.abort(), { once: true }); } const headersTimer = setTimeout(() => { headersTimedOut = true; controller.abort(); }, RESPONSE_GUARD_MS); let response: Response; try { response = await fetch("/api/homework", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() }, body: JSON.stringify( generation ? { ...input, textModel: generation.textModel, visionModel: generation.visionModel } : input, ), signal: controller.signal, }); } catch { if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted"); if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout"); throw new ApiError(t("api.unreachable"), "network"); } finally { clearTimeout(headersTimer); } 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); } }, { stallMs: STREAM_GUARD_MS }); 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 { const generation = providersStore.getSnapshot().generation; let response: Response; try { response = await fetch("/api/homework/refine", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() }, body: JSON.stringify( generation ? { instruction: input.instruction, text: input.text, context: input.context, model: generation.textModel } : { 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[] }> { const generation = providersStore.getSnapshot().generation; let response: Response; try { response = await fetch("/api/grade", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() }, body: JSON.stringify(generation ? { ...input, model: generation.textModel } : 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" }; }