"use client"; import { localeHeader, t } from "./i18n"; import type { Activity, Difficulty } 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; } } /** * 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); } if (!response.body) { throw new ApiError(t("api.empty"), "bad_response"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let deck: StreamedDeck | null = null; let meta: Record = {}; 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: t("api.almost") }); } else if (event.type === "error") { streamError = new ApiError(event.error, event.kind); } }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newline = buffer.indexOf("\n"); while (newline !== -1) { handleLine(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); newline = buffer.indexOf("\n"); } } handleLine(buffer); if (streamError) throw streamError; if (!deck) { throw new ApiError( sawActivity ? 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 }; } 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 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 { 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" }; }