chrysopedia/frontend/src/api/chat.ts
jlightner 90c24d8bf9 feat: Built ChatPage with SSE streaming client, real-time token display…
- "frontend/src/api/chat.ts"
- "frontend/src/pages/ChatPage.tsx"
- "frontend/src/pages/ChatPage.module.css"
- "frontend/src/App.tsx"

GSD-Task: S03/T02
2026-04-04 05:22:43 +00:00

140 lines
3.7 KiB
TypeScript

/**
* 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<string, string> = {
"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;
}