wrolpi/app/src/sse.js
Roland 2ff084d4c4
ai: the Chat tab — SSE streaming chat with modes
The app's first streaming client: sse.js reads the /api/chat POST body
with fetch + a stream reader (apiCall's 60s JSON path cannot stream),
parsing SSE events with abort support.  AiChatContext holds the
conversation (browser-only, per design) so it survives visiting the
Manage tab; switching modes starts a fresh conversation.  The Chat tab
has the three-button mode picker, per-mode suggestion chips, streamed
answers with clickable relative WROLPi links, tool-activity indicators,
a slow-hardware banner, and a "not ready" state pointing at Manage.
ChatMessage states are in the theme-sample gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 18:23:22 -06:00

71 lines
2.4 KiB
JavaScript

// The app's only streaming client. `apiCall` is JSON-only with a 60-second timeout, which is
// wrong for a chat answer that streams for minutes — so the AI chat uses fetch + a stream
// reader, parsing Server-Sent Events.
import {API_URI} from './components/Vars';
// Parse one SSE block ("event: name\ndata: {...}") into {event, data}.
export function parseSSEBlock(block) {
let event = null;
let data = null;
for (const line of block.split('\n')) {
if (line.startsWith('event:')) {
event = line.slice(6).trim();
} else if (line.startsWith('data:')) {
try {
data = JSON.parse(line.slice(5).trim());
} catch (e) {
data = null;
}
}
}
return event ? {event, data} : null;
}
/*
* POST /api/chat and stream its SSE events to `onEvent(event, data)`.
*
* Events: status, token, tool_call, tool_result, done, error (see modules/ai/chat.py).
* Throws on a non-OK response (e.g. AI disabled) with the API's message; abort with `signal`.
*/
export async function streamChat({mode, messages}, onEvent, signal = undefined) {
const response = await fetch(`${API_URI}/chat/`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({mode, messages}),
signal,
});
if (!response.ok) {
let message = `Chat failed: HTTP ${response.status}`;
let code = null;
try {
const content = await response.json();
message = content.summary || content.message || message;
code = content.code;
} catch (e) {
// Not JSON; keep the HTTP message.
}
const error = new Error(message);
error.code = code;
throw error;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, {stream: true});
let index;
while ((index = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const parsed = parseSSEBlock(block);
if (parsed) {
onEvent(parsed.event, parsed.data);
}
}
}
}