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:
Roland 2026-08-25 22:01:25 -06:00
parent 320710ea7f
commit 2ff084d4c4
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
6 changed files with 488 additions and 11 deletions

View file

@ -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>;
}

View 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>
</>}
</>;
}

View 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');
});
});

View file

@ -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}}>

View 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
View 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);
}
}
}
}