"use client"; import { ApiError, consumeNdjson } from "./client-api"; import { localeHeader, t } from "./i18n"; import { authHeaders } from "./settings"; import { providerHeaders, providersStore } from "./providers-client"; import type { ChatSource } from "./chat"; /** * Client for the streaming chat route. The conversation window and the chosen * context chunks travel with every request (the server never stores either); * the API key only ever goes to our own route in its usual header. */ export type ChatStreamEvent = | { type: "token"; text: string } | { type: "thinking"; text: string } | { type: "done"; model: string } | { type: "error"; error: string; kind?: string }; export type StreamChatInput = { model?: string; /** A cloud provider the model belongs to (Ollama when omitted). */ provider?: string; messages: { role: "user" | "assistant"; content: string }[]; context: (ChatSource & { text: string })[]; /** "teach" runs a Teach it back turn; "teach_summary" asks for the recap JSON. */ mode?: "teach" | "teach_summary"; teach?: { topic: string; difficulty: "friendly" | "skeptical"; /** Exchanges answered so far, and how many the session plans. */ exchange: number; limit: number; /** "[n] question → answer" checklist lines (lib/teach.ts). */ cards: string[]; }; signal?: AbortSignal; onToken: (text: string) => void; onThinking: (text: string) => void; onDone: (model: string) => void; }; export async function streamChat(input: StreamChatInput): Promise { let response: Response; try { response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(input.provider), ...localeHeader() }, body: JSON.stringify({ model: input.model || undefined, messages: input.messages, context: input.context, mode: input.mode, teach: input.teach, }), 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"); } 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 streamError: ApiError | null = null; try { await consumeNdjson(response, (raw) => { const event = raw as ChatStreamEvent; if (event.type === "token") input.onToken(event.text); else if (event.type === "thinking") input.onThinking(event.text); else if (event.type === "done") input.onDone(event.model); else if (event.type === "error") streamError = new ApiError(event.error, event.kind); }); } catch (error) { // Aborting while the body is being read rejects the reader with an // AbortError, not a fetch rejection — treat it as a user stop. if (error instanceof Error && error.name === "AbortError") { throw new ApiError(t("api.cancelled"), "aborted"); } throw error; } if (streamError) throw streamError; } /** * Turns attached photos (and any client-extracted document text) into plain * text through the existing vision pipeline, so the chat model itself never * needs to be multimodal. */ export async function extractChatAttachment( input: { images?: string[]; text?: string }, options: { signal?: AbortSignal } = {}, ): Promise { // Attachment transcription follows the study-generation provider, if set. const generation = providersStore.getSnapshot().generation; let response: Response; try { response = await fetch("/api/chat/extract", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() }, body: JSON.stringify(generation ? { ...input, model: generation.visionModel || generation.textModel } : 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"); } 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("chat.attachEmpty"), "bad_response"); return body.text.trim(); }