From 7ec1100f2be24ab91275aaa3da486f75393ac30f Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 22:01:25 -0600 Subject: [PATCH] =?UTF-8?q?ai:=20the=20Chat=20tab=20=E2=80=94=20SSE=20stre?= =?UTF-8?q?aming=20chat=20with=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/src/components/Ai.js | 23 ++-- app/src/components/AiChat.js | 154 ++++++++++++++++++++++++++ app/src/components/AiChat.test.js | 121 ++++++++++++++++++++ app/src/components/ThemeSamplePage.js | 20 ++++ app/src/contexts/AiChatContext.js | 110 ++++++++++++++++++ app/src/sse.js | 71 ++++++++++++ 6 files changed, 488 insertions(+), 11 deletions(-) create mode 100644 app/src/components/AiChat.js create mode 100644 app/src/components/AiChat.test.js create mode 100644 app/src/contexts/AiChatContext.js create mode 100644 app/src/sse.js diff --git a/app/src/components/Ai.js b/app/src/components/Ai.js index 7760b0e9..b5af8916 100644 --- a/app/src/components/Ai.js +++ b/app/src/components/Ai.js @@ -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 Chat is coming soon. Configure the assistant on the Manage tab.; -} - export function AiRoute() { useTitle('AI'); @@ -198,11 +196,14 @@ export function AiRoute() { {text: 'Manage', to: '/ai/manage', key: 'manage'}, ]; - return - - - }/> - }/> - - ; + // The provider wraps both tabs so the conversation survives visiting Manage. + return + + + + }/> + }/> + + + ; } diff --git a/app/src/components/AiChat.js b/app/src/components/AiChat.js new file mode 100644 index 00000000..0438f0ef --- /dev/null +++ b/app/src/components/AiChat.js @@ -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 + {parts.map((part, i) => part.startsWith('/') && part.length > 1 + ? {part} + : part)} + ; +} + +function ToolActivity({activity}) { + if (!activity || !activity.length) { + return null; + } + return
+ {activity.map((item, i) => + {item.pending + ? + : } + {' '}using {String(item.tool || '').replace(/_/g, ' ')} + )} +
; +} + +export function ChatMessage({message}) { + if (message.role === 'user') { + return
+ {message.content} +
; + } + return
+ {message.status && {message.status}} + + {message.content && } + {message.streaming && !message.content && !message.activity?.length && } + {message.error && {message.error}} +
; +} + +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 + The AI assistant is not ready. Download a model and enable it on + the Manage tab. + ; + } + + const suggestions = (modes.find(i => i.name === mode) || {}).suggestions || []; + + return <> + {slowHardware && + This hardware runs the assistant slowly — answers can take minutes. + } + + + {(modes.length ? modes.map(i => i.name) : Object.keys(MODE_LABELS)).map(name => + )} + {mode && {MODE_DESCRIPTIONS[mode]}} + + + {!mode && + Pick a mode to start: Help teaches WROLPi, Research searches your + library, System troubleshoots this device. + } + + {mode && <> +
+ {messages.length === 0 && + {suggestions.map(suggestion => + )} + } + {messages.map((message, i) => )} +
+
+ +
{ + e.preventDefault(); + if (!sending) { + handleSend(input.trim()); + } + }}> + + setInput(e.target.value)} + aria-label='Chat message' + /> + {sending + ? + : } + +
+ } + ; +} diff --git a/app/src/components/AiChat.test.js b/app/src/components/AiChat.test.js new file mode 100644 index 00000000..386206ee --- /dev/null +++ b/app/src/components/AiChat.test.js @@ -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(); + +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(); + 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'); + }); +}); diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index c65f7017..968c0115 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -46,6 +46,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'; @@ -735,6 +736,25 @@ export function ThemeSamplePage() { +
+ {/* The real ChatMessage from the AI Chat tab, in each of its states. */} + + + + +
+
diff --git a/app/src/contexts/AiChatContext.js b/app/src/contexts/AiChatContext.js new file mode 100644 index 00000000..c2772b8a --- /dev/null +++ b/app/src/contexts/AiChatContext.js @@ -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 {children}; +} + +export function useAiChat() { + const context = useContext(AiChatContext); + if (!context) { + throw new Error('useAiChat must be used within AiChatProvider'); + } + return context; +} diff --git a/app/src/sse.js b/app/src/sse.js new file mode 100644 index 00000000..ce09b8e3 --- /dev/null +++ b/app/src/sse.js @@ -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); + } + } + } +}