mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
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:
parent
f86058ee8a
commit
0347c2901b
7 changed files with 381 additions and 0 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
208
app/src/components/Ai.js
Normal 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>;
|
||||
}
|
||||
112
app/src/components/Ai.test.js
Normal file
112
app/src/components/Ai.test.js
Normal 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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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'},
|
||||
|
|
|
|||
|
|
@ -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}}>
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ export function audioContainerForCodec(codec) {
|
|||
}
|
||||
|
||||
export const Downloaders = {
|
||||
AiModel: 'ai_model',
|
||||
Archive: 'archive',
|
||||
File: 'file',
|
||||
KiwixCatalog: 'kiwix_catalog',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue