/** * SSE client for POST /api/v1/chat. * * Uses raw fetch() with ReadableStream to parse Server-Sent Events: * - event: sources — citation metadata array (sent first) * - event: token — streamed text chunk (repeated) * - event: done — completion metadata with cascade_tier * - event: error — error message on failure */ import { BASE, AUTH_TOKEN_KEY } from "./client"; export interface ChatSource { number: number; title: string; slug: string; creator_name: string; topic_category: string; summary: string; section_anchor: string; section_heading: string; } export interface ChatCallbacks { onSources: (sources: ChatSource[]) => void; onToken: (text: string) => void; onDone: (meta: { cascade_tier: string }) => void; onError: (message: string) => void; } /** * Stream a chat response from the API via SSE. * Returns an AbortController so the caller can cancel. */ export function streamChat( query: string, callbacks: ChatCallbacks, creator?: string, ): AbortController { const controller = new AbortController(); const token = (() => { try { return localStorage.getItem(AUTH_TOKEN_KEY); } catch { return null; } })(); const headers: Record = { "Content-Type": "application/json", }; if (token) { headers["Authorization"] = `Bearer ${token}`; } fetch(`${BASE}/chat`, { method: "POST", headers, body: JSON.stringify({ query, creator: creator ?? null }), signal: controller.signal, }) .then(async (res) => { if (!res.ok) { let detail = res.statusText; try { const body = await res.json(); if (body?.detail) { detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail); } } catch { /* body not JSON */ } callbacks.onError(`Request failed: ${detail}`); return; } const reader = res.body?.getReader(); if (!reader) { callbacks.onError("No response body"); return; } const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Parse complete SSE events (separated by double newline) const parts = buffer.split("\n\n"); // Keep the last incomplete part in the buffer buffer = parts.pop() ?? ""; for (const part of parts) { if (!part.trim()) continue; const eventMatch = part.match(/^event:\s*(.+)$/m); const dataMatch = part.match(/^data:\s*(.+)$/m); if (!eventMatch?.[1] || !dataMatch?.[1]) continue; const eventType = eventMatch[1].trim(); const rawData = dataMatch[1]; try { switch (eventType) { case "sources": callbacks.onSources(JSON.parse(rawData) as ChatSource[]); break; case "token": callbacks.onToken(JSON.parse(rawData) as string); break; case "done": callbacks.onDone(JSON.parse(rawData) as { cascade_tier: string }); break; case "error": { const err = JSON.parse(rawData) as { message?: string }; callbacks.onError(err.message ?? "Unknown error"); break; } } } catch { // Malformed JSON — skip this event } } } }) .catch((err: Error) => { if (err.name !== "AbortError") { callbacks.onError(err.message || "Network error"); } }); return controller; }