From 0d5aa5c3ae96fc0490433683c8730231ee8f3f67 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 22:54:48 +0000 Subject: [PATCH] feat: wire chat to backend (#644) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- apps/web/src/lib/api/chat.ts | 116 ++++++++++++++++++++++++++++++----- 1 file changed, 99 insertions(+), 17 deletions(-) diff --git a/apps/web/src/lib/api/chat.ts b/apps/web/src/lib/api/chat.ts index 4530bee..73dcc9c 100644 --- a/apps/web/src/lib/api/chat.ts +++ b/apps/web/src/lib/api/chat.ts @@ -1,6 +1,6 @@ /** * Chat API client - * Handles LLM chat interactions via /api/llm/chat + * Handles LLM chat interactions via /api/chat/stream (streaming) and /api/llm/chat (fallback) */ import { apiPost, fetchCsrfToken, getCsrfToken } from "./client"; @@ -33,9 +33,28 @@ export interface ChatResponse { } /** - * Parsed SSE data chunk from the LLM stream + * Parsed SSE data chunk from OpenAI-compatible stream */ -interface SseChunk { +interface OpenAiSseChunk { + id?: string; + object?: string; + created?: number; + model?: string; + choices?: { + index: number; + delta?: { + role?: string; + content?: string; + }; + finish_reason?: string | null; + }[]; + error?: string; +} + +/** + * Parsed SSE data chunk from legacy /api/llm/chat stream + */ +interface LegacySseChunk { error?: string; message?: { role: string; @@ -46,7 +65,17 @@ interface SseChunk { } /** - * Send a chat message to the LLM + * Parsed SSE data chunk with simple token format + */ +interface SimpleTokenChunk { + token?: string; + done?: boolean; + error?: string; +} + +/** + * Send a chat message to the LLM (non-streaming fallback) + * Uses /api/llm/chat endpoint which supports both streaming and non-streaming */ export async function sendChatMessage(request: ChatRequest): Promise { return apiPost("/api/llm/chat", request); @@ -66,11 +95,20 @@ async function ensureCsrfTokenForStream(): Promise { /** * 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 + * Uses /api/chat/stream endpoint which proxies to OpenClaw. + * The backend responds with Server-Sent Events in one of these formats: + * + * OpenAI-compatible format: + * data: {"choices":[{"delta":{"content":"token"}}],...}\n\n + * data: [DONE]\n\n + * + * Legacy format (from /api/llm/chat): + * data: {"message":{"content":"token"},...}\n\n + * data: [DONE]\n\n + * + * Simple token format: + * data: {"token":"..."}\n\n + * data: {"done":true}\n\n * * @param request - Chat request (stream field will be forced to true) * @param onChunk - Called with each token string as it arrives @@ -89,14 +127,14 @@ export function streamChatMessage( try { const csrfToken = await ensureCsrfTokenForStream(); - const response = await fetch(`${API_BASE_URL}/api/llm/chat`, { + const response = await fetch(`${API_BASE_URL}/api/chat/stream`, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, }, credentials: "include", - body: JSON.stringify({ ...request, stream: true }), + body: JSON.stringify({ messages: request.messages, stream: true }), signal: signal ?? null, }); @@ -132,6 +170,25 @@ export function streamChatMessage( const trimmed = part.trim(); if (!trimmed) continue; + // Handle event: error format + const eventMatch = /^event:\s*(\S+)\n/i.exec(trimmed); + const dataMatch = /^data:\s*(.+)$/im.exec(trimmed); + + if (eventMatch?.[1] === "error" && dataMatch?.[1]) { + try { + const errorData = JSON.parse(dataMatch[1].trim()) as { + error?: string; + }; + throw new Error(errorData.error ?? "Stream error occurred"); + } catch (parseErr) { + if (parseErr instanceof SyntaxError) { + throw new Error("Stream error occurred"); + } + throw parseErr; + } + } + + // Standard SSE format: data: {...} for (const line of trimmed.split("\n")) { if (!line.startsWith("data: ")) continue; @@ -143,14 +200,39 @@ export function streamChatMessage( } try { - const parsed = JSON.parse(data) as SseChunk; + const parsed: unknown = JSON.parse(data); - if (parsed.error) { - throw new Error(parsed.error); + // Handle OpenAI format (from /api/chat/stream via OpenClaw) + const openAiChunk = parsed as OpenAiSseChunk; + if (openAiChunk.choices?.[0]?.delta?.content) { + onChunk(openAiChunk.choices[0].delta.content); + continue; } - if (parsed.message?.content) { - onChunk(parsed.message.content); + // Handle legacy format (from /api/llm/chat) + const legacyChunk = parsed as LegacySseChunk; + if (legacyChunk.message?.content) { + onChunk(legacyChunk.message.content); + continue; + } + + // Handle simple token format + const simpleChunk = parsed as SimpleTokenChunk; + if (simpleChunk.token) { + onChunk(simpleChunk.token); + continue; + } + + // Handle done flag in simple format + if (simpleChunk.done === true) { + onComplete(); + return; + } + + // Handle error in any format + const error = openAiChunk.error ?? legacyChunk.error ?? simpleChunk.error; + if (error) { + throw new Error(error); } } catch (parseErr) { if (parseErr instanceof SyntaxError) { @@ -162,7 +244,7 @@ export function streamChatMessage( } } - // Natural end of stream without [DONE] + // Natural end of stream without [DONE] or done flag onComplete(); } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") {