ai: the AI nav page — Manage tab (model catalog, settings, service)

New AI nav item and /ai route with Chat and Manage tabs (Chat is a
placeholder until the SSE chat lands).  The Manage tab mirrors the
Map/Zim download UX: the model catalog with the RAM-based tier
recommendation and a Pi 4 performance hint, downloads through the
download manager (progress on the Downloads page), active-model
select, enable toggle, idle-unload minutes, and llama-server
start/stop through the Controller (compose service name in docker,
systemd unit natively).  AiModelRow is in the theme-sample gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-08-25 21:56:36 -06:00
parent f86058ee8a
commit 0347c2901b
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
7 changed files with 381 additions and 0 deletions

View file

@ -31,6 +31,7 @@ import {ZimRoute} from "./components/Zim";
import {PlaylistsRoute} from "./components/Playlists";
import {DocsRoute} from "./components/Docs";
import {FlasherRoute} from "./components/Flasher";
import {AiRoute} from "./components/Ai";
import ErrorBoundary from "./components/ErrorBoundary";
import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider";
@ -116,6 +117,7 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
<Route path='playlists/*' element={<ErrorBoundary><PlaylistsRoute/></ErrorBoundary>}/>
<Route path='files/*' element={<ErrorBoundary><FilesRoute/></ErrorBoundary>}/>
<Route path='flasher/*' element={<ErrorBoundary><FlasherRoute/></ErrorBoundary>}/>
<Route path='ai/*' element={<ErrorBoundary><AiRoute/></ErrorBoundary>}/>
</Route>));
export default function App() {

View file

@ -2438,3 +2438,33 @@ export async function setPlaylistTag(playlistId, tagName, directory) {
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
// --- AI assistant ---
export async function getAiCatalog() {
const response = await apiGet(`${API_URI}/ai/manage/catalog`);
if (response.ok) {
return await response.json();
}
const message = await getErrorMessage(response, 'Failed to fetch AI catalog.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
export async function postAiSettings(settings) {
const response = await apiPost(`${API_URI}/ai/manage/settings`, settings);
if (response.ok) {
return await response.json();
}
const message = await getErrorMessage(response, 'Failed to save AI settings.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
export async function getChatModes() {
const response = await apiGet(`${API_URI}/chat/modes`);
if (response.ok) {
return (await response.json())['modes'];
}
const message = await getErrorMessage(response, 'Failed to fetch chat modes.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}

208
app/src/components/Ai.js Normal file
View file

@ -0,0 +1,208 @@
import React, {useContext, useEffect, useState} from 'react';
import {Route, Routes} from 'react-router';
import {getAiCatalog, postAiSettings, postDownload} from '../api';
import {getServiceStatus, startService, stopService} from '../api/controller';
import {StatusContext} from '../contexts/contexts';
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';
import {Downloaders} from './Vars';
// The Controller's name for llama-server: the compose service in docker, the systemd unit natively.
export const llmServiceName = (dockerized) => dockerized ? 'llm' : 'wrolpi-llm';
export function AiModelRow({model, onDownload}) {
const {name, tier, size, description, downloaded, active} = model;
return <Table.Row>
<Table.Cell>
<Text fw={active ? 700 : undefined}>{name}{active ? ' (active)' : ''}</Text>
{description && <Text size='sm' c='dimmed'>{description}</Text>}
</Table.Cell>
<Table.Cell>{tier}</Table.Cell>
<Table.Cell>{humanFileSize(size)}</Table.Cell>
<Table.Cell>
{downloaded
? <Button size='xs' icon='check' disabled role='save'>Downloaded</Button>
: <APIButton size='xs' icon='download' color='violet' onClick={() => onDownload(model)}>
Download
</APIButton>}
</Table.Cell>
</Table.Row>;
}
export function ManageAi() {
const [catalog, setCatalog] = useState(null);
const [serviceStatus, setServiceStatus] = useState(null);
const [pendingSave, setPendingSave] = useState(false);
const {status} = useContext(StatusContext);
const serviceName = llmServiceName(status?.dockerized);
const fetchCatalog = async () => {
try {
const data = await getAiCatalog();
if (data) {
setCatalog(data);
}
} catch (e) {
console.error(e);
}
};
const fetchService = async () => {
try {
setServiceStatus(await getServiceStatus(serviceName));
} catch (e) {
// The service is unknown until AI is installed; not an error worth toasting.
setServiceStatus(null);
}
};
useEffect(() => {
fetchCatalog();
fetchService();
const interval = setInterval(() => {
fetchCatalog();
fetchService();
}, 30000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serviceName]);
const handleDownload = async (model) => {
await postDownload({
urls: [model.url],
downloader: Downloaders.AiModel,
destination: 'ai/models',
});
toast({
type: 'success',
title: 'Download started',
description: 'Monitor progress on the Downloads page.',
time: 5000,
});
};
const saveSettings = async (settings) => {
setPendingSave(true);
try {
await postAiSettings(settings);
await fetchCatalog();
} finally {
setPendingSave(false);
}
};
const handleServiceButton = async () => {
if (serviceStatus?.status === 'running') {
await stopService(serviceName);
} else {
await startService(serviceName);
}
await fetchService();
};
if (!catalog) {
return <WROLModeMessage content='Cannot modify AI'/>;
}
const {models, recommended_tier, total_ram, slow_hardware, disk_usage, enabled, active_model} = catalog;
const downloadedModels = models.filter(i => i.downloaded);
const modelHeaders = [
{key: 'name', text: 'Model', sortBy: i => i.name.toLowerCase()},
{key: 'tier', text: 'Tier', sortBy: i => i.tier},
{key: 'size', text: 'Size', sortBy: i => i.size || 0},
{key: 'actions', text: '', sortBy: null},
];
return <>
<WROLModeMessage content='Cannot modify AI'/>
{slow_hardware && <InfoMessage storageName='hint_ai_slow_hardware'>
This hardware will run the assistant slowly. Answers may take minutes; the smallest
model is recommended.
</InfoMessage>}
<Header as='h3'>AI Assistant</Header>
<Group gap='lg'>
<Toggle
label='Enabled'
checked={enabled === true}
disabled={pendingSave || !active_model}
onChange={e => saveSettings({enabled: e.target.checked})}
/>
<Select
label='Active Model'
data={downloadedModels.map(i => ({value: i.name, label: i.name}))}
value={active_model || null}
placeholder={downloadedModels.length ? 'Select a model' : 'Download a model below'}
disabled={pendingSave || !downloadedModels.length}
onChange={value => value && saveSettings({active_model: value})}
/>
<NumberInput
label='Idle unload (minutes)'
min={1}
value={catalog.idle_unload_minutes}
disabled={pendingSave}
onChange={value => value && saveSettings({idle_unload_minutes: value})}
/>
</Group>
{enabled && <Group gap='xs' style={{marginTop: '1em'}}>
<Text>Service: {serviceStatus?.status || 'unknown'}</Text>
<APIButton
size='xs'
color={serviceStatus?.status === 'running' ? undefined : 'violet'}
role={serviceStatus?.status === 'running' ? 'danger' : undefined}
onClick={handleServiceButton}
>
{serviceStatus?.status === 'running' ? 'Stop' : 'Start'}
</APIButton>
</Group>}
<Divider/>
<Header as='h3'>Model Catalog</Header>
<Text size='sm'>
This device has {humanFileSize(total_ram)} of RAM;
the <b>{recommended_tier}</b> tier is recommended.
Models use {humanFileSize(disk_usage)} in <b>ai/models/</b>.
</Text>
<SortableTable
tableHeaders={modelHeaders}
data={models}
rowFunc={(model) => <AiModelRow key={model.name} model={model} onDownload={handleDownload}/>}
rowKey='name'
defaultSortColumn='size'
/>
<InfoMessage storageName='hint_ai_models'>
<p>The AI assistant runs entirely on this WROLPi — no internet is needed after a model
is downloaded. Download a model, select it, then enable the assistant. Monitor
download progress on the <b>Downloads</b> page.</p>
</InfoMessage>
</>;
}
function ChatPlaceholder() {
return <Text>Chat is coming soon. Configure the assistant on the Manage tab.</Text>;
}
export function AiRoute() {
useTitle('AI');
const links = [
{text: 'Chat', to: '/ai', key: 'chat', end: true},
{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>;
}

View file

@ -0,0 +1,112 @@
import React from 'react';
import {fireEvent, screen, waitFor} from '@testing-library/react';
import {render} from '../test-utils';
import {AiModelRow, ManageAi, llmServiceName} from './Ai';
import {Table} from './ui';
jest.mock('../api', () => ({
getAiCatalog: jest.fn(),
postAiSettings: jest.fn().mockResolvedValue({}),
postDownload: jest.fn().mockResolvedValue({}),
}));
jest.mock('../api/controller', () => ({
getServiceStatus: jest.fn().mockResolvedValue({name: 'wrolpi-llm', status: 'stopped'}),
startService: jest.fn().mockResolvedValue({}),
stopService: jest.fn().mockResolvedValue({}),
}));
const {getAiCatalog, postAiSettings, postDownload} = require('../api');
const catalogFixture = (overrides = {}) => ({
models: [
{
name: 'Qwen3-1.7B-Q4_K_M.gguf', tier: 'small', size: 1_200_000_000,
url: 'https://cdn.example.com/ai/Qwen3-1.7B-Q4_K_M.gguf',
description: 'Small model.', downloaded: true, active: true,
},
{
name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf', tier: 'medium', size: 2_600_000_000,
url: 'https://cdn.example.com/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
description: 'Medium model.', downloaded: false, active: false,
},
],
catalog_source: 'bundled',
total_ram: 8 * 1024 ** 3,
recommended_tier: 'medium',
slow_hardware: false,
models_directory: '/media/wrolpi/ai/models',
disk_usage: 1_200_000_000,
enabled: false,
active_model: 'Qwen3-1.7B-Q4_K_M.gguf',
idle_unload_minutes: 15,
context_size: null,
...overrides,
});
describe('llmServiceName', () => {
it('is the compose service in docker, the systemd unit natively', () => {
expect(llmServiceName(true)).toBe('llm');
expect(llmServiceName(false)).toBe('wrolpi-llm');
});
});
describe('AiModelRow', () => {
const renderRow = (model, onDownload = jest.fn()) => render(
<Table><Table.Body><AiModelRow model={model} onDownload={onDownload}/></Table.Body></Table>);
it('shows a downloaded, active model without a download button', () => {
renderRow(catalogFixture().models[0]);
expect(screen.getByText(/Qwen3-1.7B-Q4_K_M.gguf \(active\)/)).toBeInTheDocument();
expect(screen.getByText('Downloaded')).toBeInTheDocument();
expect(screen.queryByText('Download')).not.toBeInTheDocument();
});
it('offers a download for a model that is not downloaded', async () => {
const onDownload = jest.fn();
renderRow(catalogFixture().models[1], onDownload);
fireEvent.click(screen.getByText('Download'));
await waitFor(() => expect(onDownload).toHaveBeenCalled());
expect(onDownload.mock.calls[0][0].name).toBe('Qwen3-4B-Instruct-2507-Q4_K_M.gguf');
});
});
describe('ManageAi', () => {
beforeEach(() => {
jest.clearAllMocks();
getAiCatalog.mockResolvedValue(catalogFixture());
});
it('renders the catalog with the RAM recommendation', async () => {
render(<ManageAi/>);
await waitFor(() => expect(screen.getByText(/tier is recommended/)).toBeInTheDocument());
// The active model appears in both the Select and its catalog row.
expect(screen.getAllByText(/Qwen3-1.7B-Q4_K_M.gguf/).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/of RAM/)).toBeInTheDocument();
});
it('starts a model download with the ai_model downloader', async () => {
render(<ManageAi/>);
await waitFor(() => expect(screen.getByText('Download')).toBeInTheDocument());
fireEvent.click(screen.getByText('Download'));
await waitFor(() => expect(postDownload).toHaveBeenCalled());
expect(postDownload).toHaveBeenCalledWith({
urls: ['https://cdn.example.com/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf'],
downloader: 'ai_model',
destination: 'ai/models',
});
});
it('saves the enabled toggle', async () => {
render(<ManageAi/>);
await waitFor(() => expect(screen.getByLabelText('Enabled')).toBeInTheDocument());
fireEvent.click(screen.getByLabelText('Enabled'));
await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({enabled: true}));
});
it('shows the slow-hardware warning on a Pi 4', async () => {
getAiCatalog.mockResolvedValue(catalogFixture({slow_hardware: true}));
render(<ManageAi/>);
await waitFor(() => expect(screen.getByText(/run the assistant slowly/)).toBeInTheDocument());
});
});

View file

@ -61,6 +61,7 @@ const allLinks = [
{text: 'Playlists', to: '/playlists', key: 'playlists'},
{text: 'Zim', to: '/zim', key: 'zim'},
{text: 'Inventory', to: '/inventory', key: 'inventory'},
{text: 'AI', to: '/ai', key: 'ai'},
// No `end` on these two: a tab stays current for the pages beneath it, and `end` would
// drop the mark the moment either grows a sub-page.
{to: '/more/calculators', text: 'Calculators', key: 'calculators'},

View file

@ -46,6 +46,7 @@ import {
} from './ui';
import {paletteColorNames} from '../themes/mantine';
import {contrastingColor} from './Common';
import {AiModelRow} from './Ai';
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
import {IconMenu2} from '@tabler/icons-react';
@ -725,6 +726,32 @@ export function ThemeSamplePage() {
</Table>
</Section>
<Section label='AI model catalog'>
{/* The real AiModelRow from the AI page's Manage tab, in both of its states. */}
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Model</Table.HeaderCell>
<Table.HeaderCell>Tier</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell/>
</Table.Row>
</Table.Header>
<Table.Body>
<AiModelRow model={{
name: 'Qwen3-1.7B-Q4_K_M.gguf', tier: 'small', size: 1_200_000_000,
description: 'The default assistant for 4GB devices (Pi 4/5).',
downloaded: true, active: true,
}} onDownload={() => {}}/>
<AiModelRow model={{
name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf', tier: 'medium', size: 2_600_000_000,
description: 'The default assistant for 8GB+ devices.',
downloaded: false, active: false,
}} onDownload={() => {}}/>
</Table.Body>
</Table>
</Section>
<Section label='Form controls'>
<Panel>
<div style={{display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 12}}>

View file

@ -194,6 +194,7 @@ export function audioContainerForCodec(codec) {
}
export const Downloaders = {
AiModel: 'ai_model',
Archive: 'archive',
File: 'file',
KiwixCatalog: 'kiwix_catalog',