mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 10:51:36 -04:00
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>
This commit is contained in:
parent
320710ea7f
commit
2ff084d4c4
6 changed files with 488 additions and 11 deletions
|
|
@ -3,7 +3,9 @@ import {Route, Routes} from 'react-router';
|
|||
|
||||
import {getAiCatalog, postAiSettings, postDownload} from '../api';
|
||||
import {getServiceStatus, startService, stopService} from '../api/controller';
|
||||
import {AiChatProvider} from '../contexts/AiChatContext';
|
||||
import {StatusContext} from '../contexts/contexts';
|
||||
import {AiChat} from './AiChat';
|
||||
import {APIButton, humanFileSize, InfoMessage, PageContainer, TabLinks, useTitle, WROLModeMessage} from './Common';
|
||||
import {SortableTable} from './SortableTable';
|
||||
import {Button, Divider, Group, Header, NumberInput, Select, Table, Text, Toggle, toast} from './ui';
|
||||
|
|
@ -186,10 +188,6 @@ export function ManageAi() {
|
|||
</>;
|
||||
}
|
||||
|
||||
function ChatPlaceholder() {
|
||||
return <Text>Chat is coming soon. Configure the assistant on the Manage tab.</Text>;
|
||||
}
|
||||
|
||||
export function AiRoute() {
|
||||
useTitle('AI');
|
||||
|
||||
|
|
@ -198,11 +196,14 @@ export function AiRoute() {
|
|||
{text: 'Manage', to: '/ai/manage', key: 'manage'},
|
||||
];
|
||||
|
||||
return <PageContainer>
|
||||
<TabLinks links={links}/>
|
||||
<Routes>
|
||||
<Route path='/' exact element={<ChatPlaceholder/>}/>
|
||||
<Route path='manage' exact element={<ManageAi/>}/>
|
||||
</Routes>
|
||||
</PageContainer>;
|
||||
// The provider wraps both tabs so the conversation survives visiting Manage.
|
||||
return <AiChatProvider>
|
||||
<PageContainer>
|
||||
<TabLinks links={links}/>
|
||||
<Routes>
|
||||
<Route path='/' exact element={<AiChat/>}/>
|
||||
<Route path='manage' exact element={<ManageAi/>}/>
|
||||
</Routes>
|
||||
</PageContainer>
|
||||
</AiChatProvider>;
|
||||
}
|
||||
|
|
|
|||
154
app/src/components/AiChat.js
Normal file
154
app/src/components/AiChat.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
import {getAiCatalog, getChatModes} from '../api';
|
||||
import {useAiChat} from '../contexts/AiChatContext';
|
||||
import {InfoMessage} from './Common';
|
||||
import {Button, Group, Icon, Loader, Text, TextInput} from './ui';
|
||||
|
||||
const MODE_LABELS = {help: 'Help', research: 'Research', system: 'System'};
|
||||
const MODE_DESCRIPTIONS = {
|
||||
help: 'Learn how to use WROLPi',
|
||||
research: 'Find and summarize library content',
|
||||
system: 'Troubleshoot this WROLPi',
|
||||
};
|
||||
|
||||
// Render an assistant message with WROLPi links clickable. The model only ever returns
|
||||
// relative links (/videos/123), which the server computed — never external URLs.
|
||||
export function LinkifiedText({content}) {
|
||||
const parts = String(content || '').split(/(\/[a-zA-Z0-9_\-./?=&%#]+)/g);
|
||||
return <span style={{whiteSpace: 'pre-wrap', overflowWrap: 'anywhere'}}>
|
||||
{parts.map((part, i) => part.startsWith('/') && part.length > 1
|
||||
? <Link key={i} to={part}>{part}</Link>
|
||||
: part)}
|
||||
</span>;
|
||||
}
|
||||
|
||||
function ToolActivity({activity}) {
|
||||
if (!activity || !activity.length) {
|
||||
return null;
|
||||
}
|
||||
return <div>
|
||||
{activity.map((item, i) => <Text key={i} size='sm' c='dimmed'>
|
||||
{item.pending
|
||||
? <Loader size='xs'/>
|
||||
: <Icon name={item.success ? 'check' : 'warning circle'}/>}
|
||||
{' '}using {String(item.tool || '').replace(/_/g, ' ')}
|
||||
</Text>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ChatMessage({message}) {
|
||||
if (message.role === 'user') {
|
||||
return <div style={{textAlign: 'right', margin: '0.5em 0'}}>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '0.5em 0.8em', borderRadius: 8,
|
||||
background: 'var(--panel)', maxWidth: '85%', textAlign: 'left',
|
||||
}}>{message.content}</span>
|
||||
</div>;
|
||||
}
|
||||
return <div style={{margin: '0.5em 0'}}>
|
||||
{message.status && <Text size='sm' c='dimmed'>{message.status}</Text>}
|
||||
<ToolActivity activity={message.activity}/>
|
||||
{message.content && <LinkifiedText content={message.content}/>}
|
||||
{message.streaming && !message.content && !message.activity?.length && <Loader size='xs'/>}
|
||||
{message.error && <Text style={{color: 'var(--danger)'}}>{message.error}</Text>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function AiChat() {
|
||||
const {mode, setMode, messages, sending, send, stop, disabledCode} = useAiChat();
|
||||
const [modes, setModes] = useState([]);
|
||||
const [slowHardware, setSlowHardware] = useState(false);
|
||||
const [aiEnabled, setAiEnabled] = useState(null);
|
||||
const [input, setInput] = useState('');
|
||||
const bottomRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getChatModes().then(i => i && setModes(i)).catch(console.error);
|
||||
getAiCatalog().then(catalog => {
|
||||
if (catalog) {
|
||||
setSlowHardware(catalog.slow_hardware === true);
|
||||
setAiEnabled(Boolean(catalog.enabled && catalog.active_model));
|
||||
}
|
||||
}).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView?.({behavior: 'smooth'});
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async (content) => {
|
||||
setInput('');
|
||||
await send(content);
|
||||
};
|
||||
|
||||
const notReady = aiEnabled === false || disabledCode === 'AI_DISABLED';
|
||||
if (notReady) {
|
||||
return <InfoMessage>
|
||||
The AI assistant is not ready. Download a model and enable it on
|
||||
the <Link to='/ai/manage'>Manage</Link> tab.
|
||||
</InfoMessage>;
|
||||
}
|
||||
|
||||
const suggestions = (modes.find(i => i.name === mode) || {}).suggestions || [];
|
||||
|
||||
return <>
|
||||
{slowHardware && <InfoMessage storageName='hint_ai_chat_slow'>
|
||||
This hardware runs the assistant slowly — answers can take minutes.
|
||||
</InfoMessage>}
|
||||
|
||||
<Group gap='xs'>
|
||||
{(modes.length ? modes.map(i => i.name) : Object.keys(MODE_LABELS)).map(name =>
|
||||
<Button
|
||||
key={name}
|
||||
size='sm'
|
||||
color={mode === name ? 'violet' : undefined}
|
||||
aria-pressed={mode === name}
|
||||
onClick={() => setMode(name)}
|
||||
>
|
||||
{MODE_LABELS[name] || name}
|
||||
</Button>)}
|
||||
{mode && <Text size='sm' c='dimmed'>{MODE_DESCRIPTIONS[mode]}</Text>}
|
||||
</Group>
|
||||
|
||||
{!mode && <Text style={{marginTop: '1em'}}>
|
||||
Pick a mode to start: <b>Help</b> teaches WROLPi, <b>Research</b> searches your
|
||||
library, <b>System</b> troubleshoots this device.
|
||||
</Text>}
|
||||
|
||||
{mode && <>
|
||||
<div style={{minHeight: '40vh', margin: '1em 0'}}>
|
||||
{messages.length === 0 && <Group gap='xs'>
|
||||
{suggestions.map(suggestion =>
|
||||
<Button key={suggestion} size='xs' onClick={() => handleSend(suggestion)}>
|
||||
{suggestion}
|
||||
</Button>)}
|
||||
</Group>}
|
||||
{messages.map((message, i) => <ChatMessage key={i} message={message}/>)}
|
||||
<div ref={bottomRef}/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (!sending) {
|
||||
handleSend(input.trim());
|
||||
}
|
||||
}}>
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<TextInput
|
||||
style={{flexGrow: 1}}
|
||||
placeholder='Ask the assistant…'
|
||||
value={input}
|
||||
disabled={sending}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
aria-label='Chat message'
|
||||
/>
|
||||
{sending
|
||||
? <Button role='cancel' onClick={stop}>Stop</Button>
|
||||
: <Button type='submit' color='violet' disabled={!input.trim()}>Send</Button>}
|
||||
</Group>
|
||||
</form>
|
||||
</>}
|
||||
</>;
|
||||
}
|
||||
121
app/src/components/AiChat.test.js
Normal file
121
app/src/components/AiChat.test.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
|
||||
import {render} from '../test-utils';
|
||||
import {AiChatProvider} from '../contexts/AiChatContext';
|
||||
import {parseSSEBlock} from '../sse';
|
||||
import {AiChat, LinkifiedText} from './AiChat';
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
getChatModes: jest.fn().mockResolvedValue([
|
||||
{name: 'help', suggestions: ['How do I download videos?']},
|
||||
{name: 'research', suggestions: ['Find videos about canning']},
|
||||
{name: 'system', suggestions: ['Why is my drive full?']},
|
||||
]),
|
||||
getAiCatalog: jest.fn().mockResolvedValue({
|
||||
enabled: true, active_model: 'test.gguf', slow_hardware: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../sse', () => ({
|
||||
...jest.requireActual('../sse'),
|
||||
streamChat: jest.fn(),
|
||||
}));
|
||||
|
||||
const {getAiCatalog, getChatModes} = require('../api');
|
||||
const {streamChat} = require('../sse');
|
||||
|
||||
const renderChat = () => render(<AiChatProvider><AiChat/></AiChatProvider>);
|
||||
|
||||
describe('parseSSEBlock', () => {
|
||||
it('parses an event and its JSON data', () => {
|
||||
expect(parseSSEBlock('event: token\ndata: {"content": "hi"}'))
|
||||
.toEqual({event: 'token', data: {content: 'hi'}});
|
||||
expect(parseSSEBlock('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinkifiedText', () => {
|
||||
it('makes relative WROLPi links clickable', () => {
|
||||
render(<LinkifiedText content='See /videos/123 for details'/>);
|
||||
const link = screen.getByRole('link', {name: '/videos/123'});
|
||||
expect(link).toHaveAttribute('href', '/videos/123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiChat', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getChatModes.mockResolvedValue([
|
||||
{name: 'help', suggestions: ['How do I download videos?']},
|
||||
{name: 'research', suggestions: ['Find videos about canning']},
|
||||
{name: 'system', suggestions: ['Why is my drive full?']},
|
||||
]);
|
||||
getAiCatalog.mockResolvedValue({enabled: true, active_model: 'test.gguf', slow_hardware: false});
|
||||
});
|
||||
|
||||
it('starts with the mode picker; picking a mode shows its suggestion chips', async () => {
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
|
||||
expect(screen.getByText(/Pick a mode/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Research'}));
|
||||
await waitFor(() => expect(screen.getByText('Find videos about canning')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('streams tokens and tool activity into the conversation', async () => {
|
||||
streamChat.mockImplementation(async ({mode, messages}, onEvent) => {
|
||||
expect(mode).toBe('research');
|
||||
expect(messages[messages.length - 1]).toEqual({role: 'user', content: 'find canning'});
|
||||
onEvent('tool_call', {tool: 'search_videos', args: {search_str: 'canning'}});
|
||||
onEvent('tool_result', {tool: 'search_videos', success: true});
|
||||
onEvent('token', {content: 'One video: '});
|
||||
onEvent('token', {content: '/videos/123'});
|
||||
onEvent('done', {content: 'One video: /videos/123'});
|
||||
});
|
||||
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Research'}));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'find canning'}});
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/using search videos/)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole('link', {name: '/videos/123'})).toBeInTheDocument());
|
||||
expect(streamChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('switching modes clears the conversation', async () => {
|
||||
streamChat.mockImplementation(async (_, onEvent) => {
|
||||
onEvent('done', {content: 'An answer.'});
|
||||
});
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Help'}));
|
||||
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
await waitFor(() => expect(screen.getByText('An answer.')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'System'}));
|
||||
await waitFor(() => expect(screen.queryByText('An answer.')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows an error event without crashing the conversation', async () => {
|
||||
streamChat.mockImplementation(async (_, onEvent) => {
|
||||
onEvent('error', {message: 'The AI failed to answer.'});
|
||||
});
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Help'}));
|
||||
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
await waitFor(() => expect(screen.getByText('The AI failed to answer.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('points at the Manage tab when AI is not enabled', async () => {
|
||||
getAiCatalog.mockResolvedValue({enabled: false, active_model: '', slow_hardware: false});
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByText(/not ready/)).toBeInTheDocument());
|
||||
expect(screen.getByRole('link', {name: 'Manage'})).toHaveAttribute('href', '/ai/manage');
|
||||
});
|
||||
});
|
||||
|
|
@ -47,6 +47,7 @@ import {
|
|||
import {paletteColorNames} from '../themes/mantine';
|
||||
import {contrastingColor} from './Common';
|
||||
import {AiModelRow} from './Ai';
|
||||
import {ChatMessage} from './AiChat';
|
||||
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
|
||||
import {IconMenu2} from '@tabler/icons-react';
|
||||
|
||||
|
|
@ -752,6 +753,25 @@ export function ThemeSamplePage() {
|
|||
</Table>
|
||||
</Section>
|
||||
|
||||
<Section label='AI chat'>
|
||||
{/* The real ChatMessage from the AI Chat tab, in each of its states. */}
|
||||
<ChatMessage message={{role: 'user', content: 'Find videos about canning'}}/>
|
||||
<ChatMessage message={{
|
||||
role: 'assistant',
|
||||
content: 'I found one video: Pressure Canning Basics — /videos/123',
|
||||
activity: [{tool: 'search_videos', pending: false, success: true}],
|
||||
}}/>
|
||||
<ChatMessage message={{
|
||||
role: 'assistant', content: '',
|
||||
activity: [{tool: 'search_archives', pending: true}], streaming: true,
|
||||
}}/>
|
||||
<ChatMessage message={{
|
||||
role: 'assistant', content: '',
|
||||
activity: [{tool: 'get_video_captions', pending: false, success: false}],
|
||||
error: 'The AI failed to answer. Check the logs.',
|
||||
}}/>
|
||||
</Section>
|
||||
|
||||
<Section label='Form controls'>
|
||||
<Panel>
|
||||
<div style={{display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 12}}>
|
||||
|
|
|
|||
110
app/src/contexts/AiChatContext.js
Normal file
110
app/src/contexts/AiChatContext.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Conversation state for the AI Chat tab.
|
||||
*
|
||||
* A Context (not props) because the conversation must survive switching between the Chat and
|
||||
* Manage tabs, and the chips/mode picker/input are separate components. Browser-only by
|
||||
* design: v1 keeps no server-side chat history.
|
||||
*
|
||||
* The context itself is exported so tests can wrap components with the real provider instead
|
||||
* of mocking the module (see FileWorkerStatusContext.js for why).
|
||||
*/
|
||||
import React, {createContext, useContext, useRef, useState} from 'react';
|
||||
|
||||
import {streamChat} from '../sse';
|
||||
|
||||
export const AiChatContext = createContext(null);
|
||||
|
||||
export function AiChatProvider({children}) {
|
||||
const [mode, setModeState] = useState(null);
|
||||
// [{role: 'user'|'assistant', content, activity: [{tool, success}], error, streaming}]
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [disabledCode, setDisabledCode] = useState(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
// Switching modes starts a fresh conversation: small contexts cannot carry a Help
|
||||
// conversation into Research mode.
|
||||
const setMode = (newMode) => {
|
||||
stop();
|
||||
setModeState(newMode);
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
const updateLast = (updater) => {
|
||||
setMessages(previous => {
|
||||
const next = [...previous];
|
||||
next[next.length - 1] = updater(next[next.length - 1]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const send = async (content) => {
|
||||
if (!content || !mode || sending) {
|
||||
return;
|
||||
}
|
||||
const history = messages
|
||||
.filter(i => !i.error && i.content)
|
||||
.map(i => ({role: i.role, content: i.content}));
|
||||
const userMessage = {role: 'user', content};
|
||||
setMessages(previous => [...previous, userMessage,
|
||||
{role: 'assistant', content: '', activity: [], streaming: true}]);
|
||||
setSending(true);
|
||||
setDisabledCode(null);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
await streamChat({mode, messages: [...history, userMessage]}, (event, data) => {
|
||||
if (event === 'token') {
|
||||
updateLast(m => ({...m, content: m.content + (data?.content || '')}));
|
||||
} else if (event === 'status') {
|
||||
updateLast(m => ({...m, status: data?.message}));
|
||||
} else if (event === 'tool_call') {
|
||||
updateLast(m => ({...m, activity: [...m.activity, {tool: data?.tool, pending: true}]}));
|
||||
} else if (event === 'tool_result') {
|
||||
updateLast(m => ({
|
||||
...m,
|
||||
activity: m.activity.map((a, i) =>
|
||||
i === m.activity.length - 1 ? {...a, pending: false, success: data?.success} : a),
|
||||
}));
|
||||
} else if (event === 'done') {
|
||||
updateLast(m => ({...m, content: data?.content ?? m.content, streaming: false, status: null}));
|
||||
} else if (event === 'error') {
|
||||
updateLast(m => ({...m, error: data?.message, streaming: false, status: null}));
|
||||
}
|
||||
}, controller.signal);
|
||||
updateLast(m => ({...m, streaming: false}));
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
updateLast(m => ({...m, streaming: false, error: 'Stopped.'}));
|
||||
} else {
|
||||
if (e.code === 'AI_DISABLED') {
|
||||
setDisabledCode(e.code);
|
||||
}
|
||||
updateLast(m => ({...m, streaming: false, error: e.message}));
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {mode, setMode, messages, sending, send, stop, disabledCode};
|
||||
return <AiChatContext.Provider value={value}>{children}</AiChatContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAiChat() {
|
||||
const context = useContext(AiChatContext);
|
||||
if (!context) {
|
||||
throw new Error('useAiChat must be used within AiChatProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
71
app/src/sse.js
Normal file
71
app/src/sse.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue