- "frontend/src/api/chat.ts" - "frontend/src/pages/ChatPage.tsx" - "frontend/src/pages/ChatPage.module.css" - "frontend/src/App.tsx" GSD-Task: S03/T02
140 lines
3.7 KiB
TypeScript
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;
|
|
}
|