"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: "podcast"; podcast: StreamedPodcast; meta?: Record; } | StreamEventBase; type StreamEventBase = | { 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 }; } /* ------------------------------------------------------------------ */ /* Podcast mode */ /* ------------------------------------------------------------------ */ export type PodcastInput = { images?: string[]; text?: string; /** Target episode length in minutes. */ minutes: number; focus?: string; language?: string; }; export type StreamedPodcast = { title: string; language?: string; summary?: string; sourceText: string; sections: { heading: string; text: string }[]; }; /** Notes → a spoken episode. Same stream shape as the deck generator. */ export async function generatePodcast( input: PodcastInput, options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {}, ): Promise<{ podcast: StreamedPodcast; meta: Record }> { const generation = providersStore.getSnapshot().generation; 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/podcast", { 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 podcast: StreamedPodcast | null = null; let streamError: ApiError | null = null; 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 === "podcast") { podcast = event.podcast as StreamedPodcast; 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; const ready = podcast as StreamedPodcast | null; if (!ready || !Array.isArray(ready.sections) || ready.sections.length === 0) { throw new ApiError(t("api.noPodcast"), "bad_response"); } return { podcast: ready, meta: {} }; } /* ------------------------------------------------------------------ */ /* Natural podcast voices (provider text-to-speech) */ /* ------------------------------------------------------------------ */ /** * One section of the script, spoken by the picked provider. The key rides in * the headers exactly like every other model call; the audio comes back as a * blob URL the player hands to an