/** * Chat API client * Handles LLM chat interactions via /api/llm/chat */ import { apiPost, fetchCsrfToken, getCsrfToken } from "./client"; import { API_BASE_URL } from "../config"; export interface ChatMessage { role: "system" | "user" | "assistant"; content: string; } export interface ChatRequest { model: string; messages: ChatMessage[]; stream?: boolean; temperature?: number; maxTokens?: number; systemPrompt?: string; } export interface ChatResponse { model: string; message: { role: "assistant"; content: string; }; done: boolean; totalDuration?: number; promptEvalCount?: number; evalCount?: number; } /** * Parsed SSE data chunk from the LLM stream */ interface SseChunk { error?: string; message?: { role: string; content: string; }; model?: string; done?: boolean; } /** * Send a chat message to the LLM */ export async function sendChatMessage(request: ChatRequest): Promise { return apiPost("/api/llm/chat", request); } /** * Get or refresh the CSRF token for streaming requests. */ async function ensureCsrfTokenForStream(): Promise { const existing = getCsrfToken(); if (existing) { return existing; } return fetchCsrfToken(); } /** * Stream a chat message from the LLM using SSE over fetch. * * The backend accepts stream: true in the request body and responds with * Server-Sent Events: * data: {"message":{"content":"token"},...}\n\n for each token * data: [DONE]\n\n when the stream is complete * data: {"error":"message"}\n\n on error * * @param request - Chat request (stream field will be forced to true) * @param onChunk - Called with each token string as it arrives * @param onComplete - Called when the stream finishes successfully * @param onError - Called if the stream encounters an error * @param signal - Optional AbortSignal for cancellation */ export function streamChatMessage( request: ChatRequest, onChunk: (chunk: string) => void, onComplete: () => void, onError: (error: Error) => void, signal?: AbortSignal ): void { void (async (): Promise => { try { const csrfToken = await ensureCsrfTokenForStream(); const response = await fetch(`${API_BASE_URL}/api/llm/chat`, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, }, credentials: "include", body: JSON.stringify({ ...request, stream: true }), signal: signal ?? null, }); if (!response.ok) { const errorText = await response.text().catch(() => response.statusText); throw new Error(`Stream request failed: ${errorText}`); } if (!response.body) { throw new Error("Response body is not readable"); } const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; let readerDone = false; while (!readerDone) { const { done, value } = await reader.read(); readerDone = done; if (done) { break; } buffer += decoder.decode(value, { stream: true }); // SSE messages are separated by double newlines const parts = buffer.split("\n\n"); // Keep the last (potentially incomplete) part buffer = parts.pop() ?? ""; for (const part of parts) { const trimmed = part.trim(); if (!trimmed) continue; for (const line of trimmed.split("\n")) { if (!line.startsWith("data: ")) continue; const data = line.slice("data: ".length).trim(); if (data === "[DONE]") { onComplete(); return; } try { const parsed = JSON.parse(data) as SseChunk; if (parsed.error) { throw new Error(parsed.error); } if (parsed.message?.content) { onChunk(parsed.message.content); } } catch (parseErr) { if (parseErr instanceof SyntaxError) { continue; } throw parseErr; } } } } // Natural end of stream without [DONE] onComplete(); } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { return; } onError(err instanceof Error ? err : new Error(String(err))); } })(); }