From aaa3c7d845314f0e790caeebcbae41365d9658d9 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:56:36 -0600 Subject: [PATCH] =?UTF-8?q?ai:=20the=20AI=20nav=20page=20=E2=80=94=20Manag?= =?UTF-8?q?e=20tab=20(model=20catalog,=20settings,=20service)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/src/App.js | 2 + app/src/api.js | 30 ++++ app/src/components/Ai.js | 208 ++++++++++++++++++++++++++ app/src/components/Ai.test.js | 112 ++++++++++++++ app/src/components/Nav.js | 1 + app/src/components/ThemeSamplePage.js | 27 ++++ app/src/components/Vars.js | 1 + 7 files changed, 381 insertions(+) create mode 100644 app/src/components/Ai.js create mode 100644 app/src/components/Ai.test.js diff --git a/app/src/App.js b/app/src/App.js index 1e372a53..a9881881 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -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(}/> }/> }/> + }/> )); export default function App() { diff --git a/app/src/api.js b/app/src/api.js index 2d57e456..1ee9b98b 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -2379,3 +2379,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}); +} diff --git a/app/src/components/Ai.js b/app/src/components/Ai.js new file mode 100644 index 00000000..7760b0e9 --- /dev/null +++ b/app/src/components/Ai.js @@ -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 + + {name}{active ? ' (active)' : ''} + {description && {description}} + + {tier} + {humanFileSize(size)} + + {downloaded + ? + : onDownload(model)}> + Download + } + + ; +} + +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 ; + } + + 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 <> + + + {slow_hardware && + This hardware will run the assistant slowly. Answers may take minutes; the smallest + model is recommended. + } + +
AI Assistant
+ + saveSettings({enabled: e.target.checked})} + /> +