Compare commits

...
Sign in to create a new pull request.

26 commits

Author SHA1 Message Date
lrnselfreliance
4b3c6510b4
ai: list custom GGUF files in the Manage tab (#720)
* ai: list custom GGUF files in the Manage tab

A GGUF copied into ai/models/ by hand could be activated by editing
ai.yaml, but never appeared in the Manage tab because the catalog
endpoint only returned CDN/bundled models. The Active Model dropdown
builds from downloaded models, so a custom file was unselectable.

The catalog endpoint now merges any on-disk *.gguf that is not in the
catalog as a `custom` tier model with its real size and no URL. It is
discovered on every request, so a file appears the next time the page
loads; the file refresh is not involved and does not index models.

Catalog models are never duplicated and in-progress .tmp downloads are
ignored. Custom rows read "On disk" and the hint explains the copy-in
path, the tool-calling requirement, and the context_size fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ai: only list custom GGUFs llama-server can load; reset context on custom select

Review found two gaps in the custom-model listing.

Any *.gguf basename was listed and accepted, but start_llama_server.sh
reads active_model back out of ai.yaml through read_config_value.sh.
PyYAML dumps non-ASCII as escapes the reader prints literally, the
reader strips " #..." before unquoting, and the start script's
traversal guard rejects any ".." run. Those names got a 200 from the
API and then "Model file does not exist" at startup. A shared
is_loadable_model_name check now filters the catalog loop and rejects
the name in manage_settings, so the UI never shows an activation that
cannot start. Directories named *.gguf are skipped too.

Selecting a custom model after a catalog model kept the previous
model's explicit context_size (e.g. 16384 from the 4B) because the
adopt branch was skipped when there was no catalog default. The
default is now always written on model selection, so a custom model
resets to null and the conservative default applies. The hint text
says so instead of claiming the default is used unconditionally.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
168f194512
ai: page list_collections
A library with a few hundred channels returned 137k characters from one
list_collections call, far past any tool-result budget.  The endpoint
now takes limit/offset (clamped like the other AI listings) and a
search_str name filter, and reports total and next_offset; the MCP tool
passes them through and tells the caller how to continue.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
1b172ddda5
ai: wrolpi_mcp exposes only the compact tool set
The per-kind MCP wrappers (search/search_videos/search_archives/
search_docs, get_video/get_archive/get_doc, get_video_captions/
get_video_comments/get_archive_text) duplicated search_files, get_file,
and read_content and cost an external client ten redundant definitions
on every request.  They are gone.  search_default_zims folds into
search_zim (zim_id optional), get_inventory_items into get_inventory
(slug optional), and read_file is exposed for the first time.  16 tools,
matching what the built-in assistant sees plus the read-only listings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
e4d996013a
ai: research mode runs on the compact tool catalog
Research mode switches to nine kind-generic tools: search_files,
get_file, read_content, search_zims, get_zim_entry, get_inventory,
list_files, read_file, search_places.  Tool definitions drop from 20
tools / 8,947 chars (~2,240 tokens) to 9 / 3,881 (~970) on every
request, a sixth of the 8k context the small tier runs with instead of
a quarter.  The remaining descriptions are trimmed toward 300 chars.

The replaced per-kind endpoints are removed (four searches, three
getters, three readers, search_suggestions, the slug inventory route);
their tests move onto the generic routes.  The research prompt, its
worked example, and the empty-search hint no longer tell the model to
list collections first: channels resolve by name from the library map
that is already in the prompt.  list_collections, list_tags, and
get_map_overview stay as endpoints for the MCP wrappers but leave the
chat allowlist; the MCP search_suggestions tool goes since search_files
returns the same matches inline.

scripts/ai_eval.py expects search_files and treats "what channels do I
have" as answerable from context.  On the 1.7B model against the dev
library both catalogs score 14/18, but the compact one answers with one
tool call where the old one needed up to five, no longer invents the
example's channel, and keeps kinds straight when asked for videos.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
85ce1d0172
ai: kind-generic search_files / get_file / read_content
Small models carry every tool definition on every request, and the
per-kind trios (four searches, three getters, three readers) cost a
quarter of an 8k context while giving the model near-duplicate choices.
Three generic endpoints replace them:

- POST files/search: one search over videos, archives, and docs with an
  optional kind; channel (name or id), domain, author, subject each
  imply their kind and are ignored for other kinds.  When the term also
  names channels/domains/authors, they are returned as `matches` so the
  model can narrow without a second tool.  Channels resolve by name, so
  the library map already in the prompt is enough: no list call first.
- GET files/{id}: detail by id whatever the kind; videos now report
  has_captions/has_comments and the full description, archives keep
  their snapshot history.
- GET files/{id}/content?part=text|comments: captions, page text, or
  comments, paged; a wrong part for the kind says what the file is.

Inventories take an optional slug on GET /inventories (one tool lists
and fetches).  Listings are slimmed: no derivable captions/text links,
no size/mimetype, duration as H:MM:SS, published as a date.  The
library map gains the tag names.

wrolpi_mcp keeps every existing tool name; the wrappers now call the
generic routes, and search_files/get_file/read_content are exposed too.
The old per-kind endpoints remain until the modes switch over.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
c5ca3dae78
ai: tags, downloads, map, and search-preview tools
Five new read-only /api/ai endpoints, each with a matching wrolpi_mcp
proxy tool: list_tags (tags with what they are applied to, plus recent
names), search_suggestions (matching channels/domains/authors/subjects
and file/Zim result estimates), list_downloads (queue summary and lean
recurring/one-time entries with relative destinations; status filter;
errors keep their tail, which holds the exception message),
get_map_overview (map files, search-index state, subscriptions, pins
with map links), and search_places (place search with map links and
optional proximity ranking).

Research mode allowlists the library-facing four.  Nothing here can
start, stop, or retry a download.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
6cbc48c78e
ai: list_files tool — browse the media directory tree
New read-only GET /api/ai/files/list lists one directory of the media
directory (directories first, then files with size and guessed
mimetype), paged at 100 entries.  Every entry carries its relative
path for list_files or read_file.  Same guards as read_file via a
shared resolver: no escaping the media directory, and the config
directory is refused and hidden from the root listing.

The agent loop picks the operation up from the spec; library mode
allowlists it.  wrolpi_mcp gains the matching list_files proxy tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
6b6f057c86
ai: system mode never answers state from memory
The re-run showed "Is everything running?" parroting the example's
all-clear without checking.  The system prompt now states that state is
never known from memory or examples — every health answer starts with a
fresh get_system_status — and the example's answer cites its own
example data (service names, version) so a verbatim parrot is
self-evidently wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
6e8e11b2b9
ai: no invented failures in the system-mode example
The eval's two remaining System misses were the model investigating a
fictional failed wrolpi-kiwix — planted by the few-shot example itself.
The example now shows a healthy status and an all-clear answer; it
still demonstrates status-first without seeding a fake diagnosis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
40b63b6a28
ai: eval findings — truthful help example, links in list answers
The first scored eval run (39/45) caught two example defects: a user
asking about the help example's own topic got the example's invented
UI detail parroted back without a search, so the example now carries
true content from the real certificates doc on a topic users rarely
lead with; and list-style answers dropped WROLPi links because the
research example only showed links in prose, so its answer now models
a numbered list with a link per item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
ced78e181f
ai: ground answers in a live library map, budget the context
Response-quality work, all aimed at giving the model enough context to
answer accurately:

- Every chat's system prompt now carries a live library map (today's
  date, counts, and the exact names of channels, domains, zims, and
  inventories, capped and cached 60s).  Exact spellings are in context
  before the first token, and the list_collections round-trip — minutes
  on slow hardware — is usually unnecessary.
- The request is budgeted against the model's real context: oldest
  turns are trimmed first, the current question is never dropped, and
  an over-budget turn gets a clear "start a new conversation" error
  instead of a silently truncated prompt.  cache_prompt reuses the KV
  cache for the stable prefix, which is most of the latency on a Pi.
- context_size plumbing fixed: selecting a model adopts its catalog
  default (the 4B was running at 8k instead of 16k), and llama-server
  runs --parallel 1 so the single user gets the whole context.
- temperature is configurable in ai.yaml (default 0.3 — small models
  call tools far more reliably near 0.2-0.3 than at server defaults).
- One worked tool-use example per mode is prepended to every
  conversation; small models imitate far better than they obey.
- scripts/ai_eval.py scores the assistant against a live WROLPi on
  mechanical checks (finished, called the right tool first, included a
  WROLPi link, no external URLs) so prompt changes stop being validated
  by anecdote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
82e11cbaba
ai: keep slow-CPU chat streams alive
On the FX-8320 the first token took over a minute, and Sanic's default
60-second RESPONSE_TIMEOUT killed the stream before anything was sent.
Raise the timeout above the chat loop's own 600s hard ceiling, and emit
a status event before every model call so the stream is never silent —
for proxies and for the user staring at a slow machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
642cfa29ee
ai: build every x86 CPU variant of llama.cpp with runtime dispatch
The portable (GGML_NATIVE=OFF) x86-64 build still assumes AVX2, which
SIGILLs on older CPUs — found deploying to an AMD FX-8320.  x86_64 now
builds all CPU-variant backends with runtime dispatch (GGML_BACKEND_DL
+ GGML_CPU_ALL_VARIANTS), the same scheme as llama.cpp's official
release binaries; arm64 keeps the baseline build, which runs on both
Pi 4 and Pi 5.  The binary and its dlopen'd backends install together
in /opt/llama-cpp (the loader scans the executable's directory) with
$ORIGIN rpath, symlinked into /usr/local/bin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
6ca2fc954c
ai: ground the librarian — collection links, retry hints, no source URLs
Live testing surfaced three ways small models went wrong summarizing a
library: they gave up after one empty search (misspelled channel names
found nothing), so zero-result searches now carry a retry hint and the
Research prompt requires list_collections before channel/site questions
and search_videos(channel_id) before claiming a channel is summarized;
they presented the familiar YouTube source URL instead of the WROLPi
link, so listings omit the source URL (archive detail keeps it); and
they invented links for channels, so channel collections now carry
their real /videos/channel page link.  Search descriptions state that
search_str is optional for browsing the newest items.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
9cb1414d7a
ai: live-chat fixes from first real-model run
Three findings from running Qwen3-1.7B against the docker stack:
the llm image lost libgomp when the build tools were removed
(llama-server needs the OpenMP runtime); the Help prompt let the
small model answer from memory instead of the docs, so it now
requires search_help before any answer; and help search results
called the slug "doc" while get_help_doc's argument is "slug", so
the model invented slugs — the names now match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
bf52b24b98
ai: publish tooling + real model hashes in the bundled catalog
publish_ai_models.py produces the CDN artifacts for every AI_MODELS
entry — GPG-signed .meta4 sidecars (reusing the map trust chain) and
the signed ai/models.json manifest — and uploads them with s3cmd.
The bundled catalog now carries the real sha256/size of the two Qwen
GGUFs, which match Hugging Face's LFS records for the Apache 2.0
unsloth quants, and whose signed meta4s verify against the committed
public key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
ebbea75e26
ai: wrolpi_mcp becomes a thin proxy over /api/ai
Every MCP tool is now one /api/ai call with minimal text rendering;
the formatting, link building, and truncation logic it used to carry
untested lives (tested) in modules/ai.  Tool names are unchanged for
external clients; a source-scan test guards the names and that the
proxy only reaches /api/ai.  New lean blueprint endpoints back the two
tools that had no equivalent: GET archives/{id} (detail + snapshot
history) and GET videos/{id}/comments (paged author lines).  Paged
content is read up to the old 50k-char bound.  get_inventory_items now
takes the inventory slug (inventories are config-only; the old
numeric-id endpoint no longer exists).  This also brings wrolpi_mcp
into version control (venv/ is already gitignored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
4a37d95fab
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>
2026-09-24 14:09:54 -06:00
Roland
0347c2901b
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>
2026-09-24 14:09:54 -06:00
Roland
f86058ee8a
ai: the /api/chat agent loop — SSE streaming, modes, tool execution
Tool definitions are generated in-process from the /api/ai blueprint's
own OpenAPI operations (operationId = tool name, description = tool
description, dataclass bodies + declared parameters = one flat JSON
schema) — the spec IS the catalog, nothing to drift.  Manage endpoints
never become tools.  A mode is a server-enforced system prompt + tool
allowlist (Help / Research / System per ai.md); the executor can only
reach allowlisted /api/ai endpoints, path parameters are encoded and
traversal-rejected so a crafted argument cannot escape an endpoint.

POST /api/chat streams SSE (status/token/tool_call/tool_result/done/
error), starts llama-server on demand, validates tool args server-side
with instructive errors the model can retry on, caps tool results with
a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a
hard timeout.  Caddy passes the stream unbuffered (flush_interval -1)
in both deployments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
485a9703eb
ai: package llama-server for all three deployments, with idle unload
install_llama_cpp.sh builds llama-server from the SHA-pinned v0.3.0
source tarball (single source of truth: on-device upgrade, Pi image,
Live ISO, and the new docker/llm container all call it; version-marker
makes re-runs no-ops).  start_llama_server.sh refuses to start unless
ai.yaml enables AI and the active model file exists — which also covers
unmounted media.  wrolpi-llm.service is installed but deliberately
never enabled at boot: the Controller starts it on demand (registered
in managed_services; the llm compose service follows the samba
pattern), and the API's perpetual idle-unload worker asks the
Controller to stop it after the configured idle minutes.  llama-server
is localhost-only natively, internal-network-only in docker, and never
proxied by Caddy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
41bd8f7c45
ai: register AIConfig in get_all_configs so it imports on startup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
c6e0f18d5e
ai: ai.yaml config, model catalog, and the GGUF downloader
ai.yaml is the source of truth (opt-in: enabled=False by default) with
active model, idle-unload minutes, context override, and tool-call
bound.  The model catalog follows the map pattern: a bundled constant
keeps the Manage tab working offline, the CDN manifest (ai/models.json
+ detached GPG signature) wins when reachable.  GGUF downloads clone
the map-extract flow — the per-file meta4 is GPG-verified once and the
same bytes handed to aria2c, so the verified hash is the enforced one;
unverified downloads are refused.  Models land in <media>/ai/models/
(gated by the existing media_mounted download rules).  Manage endpoints
(catalog with RAM-tier recommendation, settings) live under
/api/ai/manage/ which the future agent loop excludes from its tool
catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:53 -06:00
Roland
85604c80fe
ai: help-docs search — Help mode tools
Search the wrolpi-help markdown in-process: files are parsed on demand
and cached by mtime; title matches outrank headings outrank body.  No
database table — the corpus is dozens of small files.  Pages are read
as paged markdown.  The docker api container gets a read-only bind
mount of the help docs at the native /opt/wrolpi-help location; a
missing help directory degrades to empty results with a message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:53 -06:00
Roland
b07672b976
ai: system endpoints — aggregate status, services, logs, disks
Proxy the Controller under /api/ai so the model sees one API: an
aggregate /status to start every diagnosis, lean service list, recent
service logs (lines clamped), and disks + SMART.  One controller_client
helper resolves the Controller base URL (docker service name vs
localhost).  Every proxy degrades to an `errors` entry when the
Controller is unreachable — never a 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:53 -06:00
Roland
d77eaa9ecd
ai: add the /api/ai blueprint, the read-only LLM tool catalog
Lean, flat, LLM-shaped read-only endpoints over the library: global,
video, archive, and doc search; paged captions, archive text, and zim
entries; collections, inventories, and a media-directory text reader.
Links are server-computed; big content is paged, never truncated.
This ports the formatting layer from wrolpi_mcp (untouched for now)
into tested code.  First phase of ai.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:52 -06:00
67 changed files with 6364 additions and 10 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});
}

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

@ -0,0 +1,216 @@
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 {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';
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'>
{tier === 'custom' ? 'On disk' : '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>
<p>You can also copy your own GGUF file into <b>ai/models/</b>; it will appear here as a
<b>custom</b> model the next time this page loads. Use a plain ASCII file name without
<b>#</b>, quotes, or <b>..</b>. The model must support tool calling (Qwen3, Llama 3.x,
and Mistral instruct models do). Selecting a custom model resets the context size to the
default; set <b>context_size</b> in <b>ai.yaml</b> to override it.</p>
</InfoMessage>
</>;
}
export function AiRoute() {
useTitle('AI');
const links = [
{text: 'Chat', to: '/ai', key: 'chat', end: true},
{text: 'Manage', to: '/ai/manage', key: 'manage'},
];
// 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,130 @@
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('lists a custom GGUF from ai/models in the Active Model dropdown', async () => {
const custom = {
name: 'My-Custom-Model-Q8_0.gguf', tier: 'custom', size: 3_000_000_000, url: null,
description: 'Custom model found in ai/models/. Not from the WROLPi catalog.',
downloaded: true, active: false,
};
const fixture = catalogFixture();
getAiCatalog.mockResolvedValue({...fixture, models: [...fixture.models, custom]});
render(<ManageAi/>);
await waitFor(() => expect(screen.getByText('On disk')).toBeInTheDocument());
expect(screen.getByRole('cell', {name: 'custom'})).toBeInTheDocument();
fireEvent.click(screen.getByRole('textbox', {name: 'Active Model'}));
const option = await screen.findByRole('option', {name: 'My-Custom-Model-Q8_0.gguf'});
fireEvent.click(option);
await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({active_model: 'My-Custom-Model-Q8_0.gguf'}));
});
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

@ -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_files', args: {search_str: 'canning', kind: 'video'}});
onEvent('tool_result', {tool: 'search_files', 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 files/)).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

@ -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,8 @@ import {
} from './ui';
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';
@ -725,6 +727,51 @@ 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='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_files', pending: false, success: true}],
}}/>
<ChatMessage message={{
role: 'assistant', content: '',
activity: [{tool: 'search_files', pending: true}], streaming: true,
}}/>
<ChatMessage message={{
role: 'assistant', content: '',
activity: [{tool: 'read_content', 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

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

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

View file

@ -1,3 +1,4 @@
from modules.ai.conftest import * # noqa
from modules.archive.conftest import * # noqa
from modules.inventory.conftest import * # noqa
from modules.videos.conftest import * # noqa

View file

@ -78,6 +78,16 @@ DEFAULT_CONFIG = {
"use_https": True,
"description": "Kiwix/Zim server",
},
{
"name": "wrolpi-llm",
"systemd_name": "wrolpi-llm",
"port": 11435,
"viewable": False,
"use_https": False,
"description": "AI assistant (llama-server)",
# On-demand: started by the Controller when AI is enabled, stopped by the API when idle.
"kind": "persistent",
},
# System services
{
"name": "caddy",

View file

@ -27,7 +27,7 @@ except ImportError:
CONTAINER_PREFIX = os.environ.get("COMPOSE_PROJECT_NAME", "wrolpi")
# Containers that should not have a "View" button (non-HTTP services)
NON_VIEWABLE_CONTAINERS = {"redis", "memcached", "samba"}
NON_VIEWABLE_CONTAINERS = {"redis", "memcached", "samba", "llm"}
# Containers that should use HTTPS
HTTPS_CONTAINERS = {"web"}

View file

@ -57,7 +57,7 @@ class TestManagedServicesConfig:
def test_managed_services_count(self):
"""Should have expected number of managed services."""
services = DEFAULT_CONFIG["managed_services"]
assert len(services) == 11
assert len(services) == 12
@pytest.mark.parametrize("name,expected", [
("wrolpi-api",
@ -68,6 +68,8 @@ class TestManagedServicesConfig:
("wrolpi-app-dev", {"port": 3000, "viewable": False, "show_only_when_running": True}),
("caddy", {"port": 443}),
("wrolpi-upgrade", {"port": None, "viewable": False, "show_only_when_running": True}),
("wrolpi-llm",
{"systemd_name": "wrolpi-llm", "port": 11435, "viewable": False, "kind": "persistent"}),
])
def test_service_config(self, name, expected):
"""Should have service configured with expected values."""

View file

@ -143,6 +143,9 @@ cp /opt/wrolpi/wrolpi/blobs/* /opt/wrolpi-blobs/
# systemd.
/opt/wrolpi/wrolpi/scripts/install_map_tools.sh
# Bake in llama.cpp (local AI) so a fresh install does not compile it on first upgrade.
/opt/wrolpi/wrolpi/scripts/install_llama_cpp.sh
# Install WROLPi Help.
/opt/wrolpi/scripts/install_help_service.sh

View file

@ -42,6 +42,8 @@ services:
# Sanic 25.x wants 'spawn' but our code uses 'fork' for multiprocessing.
# This tells Sanic to accept the existing start method.
- SANIC_START_METHOD_SET=1
# llama-server runs in the llm container (native installs use 127.0.0.1).
- LLAMA_URL=http://llm:11435
volumes:
- './main.py:/opt/wrolpi/main.py'
- './wrolpi:/opt/wrolpi/wrolpi'
@ -49,6 +51,8 @@ services:
- './scripts:/opt/wrolpi/scripts'
- './test:/opt/wrolpi/test'
- './test:/media/wrolpi'
# Help markdown for the AI help search (native installs read /opt/wrolpi-help directly).
- './docker/help/wrolpi-help/docs:/opt/wrolpi-help/docs:ro'
- './alembic:/opt/wrolpi/alembic'
- './alembic.ini:/opt/wrolpi/alembic.ini'
ports:
@ -145,6 +149,21 @@ services:
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:80' ]
interval: 1m
llm: # llama-server (local AI) - started/stopped by the controller on demand; the API stops it when idle.
build:
context: .
dockerfile: docker/llm/Dockerfile
restart: "no"
depends_on:
controller:
condition: service_healthy
volumes:
- ${MEDIA_DIRECTORY:-./test}:/media/wrolpi
- './docker/llm/entrypoint.sh:/entrypoint.sh'
healthcheck:
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:11435/health' ]
interval: 1m
samba: # Samba file sharing - started/stopped by the controller based on configured shares.
build:
context: .

19
docker/llm/Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM debian:13
ENV DOCKER=true
RUN apt-get update && apt-get install -y curl ca-certificates
# Build llama-server from the pinned release (single source of truth with native installs).
COPY wrolpi/scripts/install_llama_cpp.sh /tmp/install_llama_cpp.sh
RUN /tmp/install_llama_cpp.sh && rm /tmp/install_llama_cpp.sh \
&& apt-get remove -y build-essential cmake && apt-get autoremove -y \
# llama-server needs the OpenMP runtime, which the build-tool cleanup just removed.
&& apt-get install -y libgomp1 && apt-get clean
# The same startup scripts the native install uses.
COPY scripts/start_llama_server.sh /opt/wrolpi/scripts/start_llama_server.sh
COPY wrolpi/scripts/read_config_value.sh /opt/wrolpi/wrolpi/scripts/read_config_value.sh
COPY docker/llm/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

5
docker/llm/entrypoint.sh Executable file
View file

@ -0,0 +1,5 @@
#! /usr/bin/env bash
# Bind all interfaces so the api container can reach llama-server on the internal docker network.
# The container publishes no host ports and is not proxied by Caddy.
export LLAMA_HOST=0.0.0.0
exec /opt/wrolpi/scripts/start_llama_server.sh

View file

@ -51,6 +51,13 @@
}
}
# AI chat streams Server-Sent Events; never buffer them.
handle /api/chat* {
reverse_proxy api:8081 {
flush_interval -1
}
}
# Main API.
handle /api/* {
reverse_proxy api:8081

View file

@ -51,6 +51,13 @@
}
}
# AI chat streams Server-Sent Events; never buffer them.
handle /api/chat* {
reverse_proxy 0.0.0.0:8081 {
flush_interval -1
}
}
# Main API.
handle /api/* {
reverse_proxy 0.0.0.0:8081

View file

@ -0,0 +1,17 @@
[Unit]
Description=WROLPi AI (llama-server)
PartOf=wrolpi.target
[Service]
ExecStart=/opt/wrolpi/scripts/start_llama_server.sh
Group=wrolpi
User=wrolpi
WorkingDirectory=/opt/wrolpi/
Restart=on-failure
# The lowest priority of any WROLPi service; inference must never starve the API.
Nice=15
# On-demand service: deliberately NOT enabled at boot and NOT wanted by wrolpi.target.
# The Controller starts it when the user enables AI; the API stops it when idle.
[Install]
WantedBy=default.target

View file

@ -289,6 +289,10 @@ async def start_single_tasks(app: Sanic):
from wrolpi.downloader import get_download_cache_config
get_download_cache_config().import_config()
logger.debug('download cache config imported')
with log_and_suppress(Exception, message='Failed to import ai config'):
from modules.ai.config import get_ai_config
get_ai_config().import_config()
logger.debug('ai config imported')
logger.debug('start_single_tasks waiting for db...')
async with flags.db_up.wait_for():

3
modules/ai/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from modules.ai.api import ai_bp # noqa
from modules.ai.downloader import ModelDownloader # noqa Instantiation registers the downloader.
from modules.ai import service # noqa Import registers the idle-unload perpetual worker.

236
modules/ai/agent.py Normal file
View file

@ -0,0 +1,236 @@
"""Tool definitions and tool execution for the agent loop.
The /api/ai blueprint IS the tool catalog: tool definitions are generated from the blueprint's
own OpenAPI operations (in-process — the spec endpoint is not proxied by Caddy), so there is no
separate registry to drift. operationIds are the tool names; @openapi.description strings are
the tool descriptions; @validate dataclasses and @openapi.parameter declarations become the
JSON-schema parameters."""
import dataclasses
import json
from typing import Dict, List, Optional, Tuple
from urllib.parse import quote, urlencode
from sanic_ext.extensions.openapi.builders import OperationStore, SpecificationBuilder
from sanic_ext.utils.route import get_all_routes
from wrolpi.api_utils import api_app
from wrolpi.common import aiohttp_get, aiohttp_post, logger
from wrolpi.vars import API_PORT
from .lib import PAGE_SIZE
logger = logger.getChild(__name__)
AI_PATH_PREFIX = '/api/ai/'
# Manage endpoints are for the Manage tab, never the model.
EXCLUDED_PREFIX = '/api/ai/manage'
# Tool results are capped so one result cannot blow a small context; content endpoints page.
TOOL_RESULT_MAX_CHARS = PAGE_SIZE + 1_000
_TYPE_MAP = {'integer': int, 'number': float, 'boolean': bool, 'string': str}
@dataclasses.dataclass
class ToolDefinition:
name: str
description: str
method: str # 'get' or 'post'
path: str # e.g. '/api/ai/videos/{file_group_id}/captions'
path_params: Dict[str, dict] # name -> JSON schema
query_params: Dict[str, dict]
body_params: Dict[str, dict]
def parameters_schema(self) -> dict:
"""One flat JSON-schema object; the executor routes each arg to its place."""
properties = {**self.body_params, **self.query_params, **self.path_params}
required = sorted(self.path_params)
schema = dict(type='object', properties=properties)
if required:
schema['required'] = required
return schema
def as_openai_tool(self) -> dict:
return dict(type='function', function=dict(
name=self.name,
description=self.description,
parameters=self.parameters_schema(),
))
def _build_ai_spec() -> dict:
"""Populate and serialize the OpenAPI spec for the AI blueprint.
Replicates sanic-ext's build_spec startup hook (sanic_ext/extensions/openapi/blueprint.py) for
just our routes, so tool definitions work identically in production and under pytest."""
spec = SpecificationBuilder()
store = OperationStore()
for uri, route_name, route_parameters, method_handlers, host in get_all_routes(api_app, '/docs'):
if not uri.startswith(AI_PATH_PREFIX):
continue
for method, handler in method_handlers:
if method in ('OPTIONS', 'HEAD', 'TRACE'):
continue
if handler not in store and (func := getattr(handler, '__func__', None)) and func in store:
handler = func
operation = store[handler]
if operation._exclude:
continue
operation._default['operationId'] = f'{method.lower()}~{route_name}'
for parameter in route_parameters:
if not any(p.fields['name'] == parameter.name for p in operation.parameters):
operation.parameter(parameter.name, parameter.cast, 'path')
operation._app = api_app
spec.operation(uri, method, operation)
return spec.build(api_app).serialize()
def _resolve_ref(spec: dict, schema: Optional[dict]) -> dict:
if not schema:
return {}
if ref := schema.get('$ref'):
name = ref.rsplit('/', 1)[-1]
return (spec.get('components', {}).get('schemas', {}) or {}).get(name, {})
return schema
def _clean_property(schema: dict) -> dict:
"""A lean JSON-schema property for the model: type (+items), nothing else."""
schema = schema or {}
cleaned = {}
if type_ := schema.get('type'):
cleaned['type'] = type_
if items := schema.get('items'):
cleaned['items'] = _clean_property(items)
return cleaned or {'type': 'string'}
_TOOL_DEFINITIONS: Optional[Dict[str, ToolDefinition]] = None
def get_ai_tool_definitions(refresh: bool = False) -> Dict[str, ToolDefinition]:
"""All tools the agent loop can ever execute, keyed by name. Built lazily, cached.
This map is also the hard allowlist: the executor can only reach /api/ai endpoints that
appear here, and manage endpoints never do."""
global _TOOL_DEFINITIONS
if _TOOL_DEFINITIONS is not None and not refresh:
return _TOOL_DEFINITIONS
spec = _build_ai_spec()
tools = {}
for path, operations in (spec.get('paths') or {}).items():
if not path.startswith(AI_PATH_PREFIX) or path.startswith(EXCLUDED_PREFIX):
continue
for method, operation in operations.items():
if method not in ('get', 'post'):
continue
name = operation.get('operationId')
description = operation.get('description') or operation.get('summary') or ''
if not name or '~' in name:
# Every tool endpoint must declare a deliberate @openapi.operation name.
logger.warning(f'AI endpoint without an operation name is not a tool: {method} {path}')
continue
path_params, query_params = {}, {}
for parameter in operation.get('parameters') or []:
cleaned = _clean_property(parameter.get('schema'))
if parameter.get('in') == 'path':
path_params[parameter['name']] = cleaned
elif parameter.get('in') == 'query':
query_params[parameter['name']] = cleaned
body_params = {}
content = (operation.get('requestBody') or {}).get('content') or {}
# sanic-ext serializes dataclass bodies under '*/*'.
body_schema = (content.get('application/json') or content.get('*/*') or {}).get('schema')
body_schema = _resolve_ref(spec, body_schema)
for prop_name, prop_schema in (body_schema.get('properties') or {}).items():
body_params[prop_name] = _clean_property(_resolve_ref(spec, prop_schema))
tools[name] = ToolDefinition(
name=name, description=description, method=method, path=path,
path_params=path_params, query_params=query_params, body_params=body_params,
)
_TOOL_DEFINITIONS = tools
return tools
def get_mode_tools(mode_tools: tuple) -> List[dict]:
"""OpenAI-format tool definitions for one mode's allowlist."""
definitions = get_ai_tool_definitions()
return [definitions[name].as_openai_tool() for name in mode_tools if name in definitions]
async def call_ai_endpoint(method: str, path: str, query: dict, body: Optional[dict]) -> Tuple[int, dict]:
"""Execute one blueprint endpoint over localhost. Patched in tests."""
url = f'http://127.0.0.1:{API_PORT}{path}'
if query:
url = f'{url}?{urlencode(query)}'
if method == 'post':
async with aiohttp_post(url, json_=body or {}, timeout=60) as response:
return response.status, await response.json()
async with aiohttp_get(url, timeout=60) as response:
return response.status, await response.json()
def _validate_args(tool: ToolDefinition, args: dict) -> Optional[str]:
"""Return a crisp, instructive error string for the model, or None when the args are usable."""
known = {**tool.path_params, **tool.query_params, **tool.body_params}
if unknown := sorted(set(args) - set(known)):
return f'Unknown arguments {unknown}. {tool.name} accepts: {sorted(known)}.'
if missing := sorted(set(tool.path_params) - set(args)):
return f'Missing required arguments {missing} for {tool.name}.'
for name, value in args.items():
expected = _TYPE_MAP.get(known[name].get('type'))
if expected in (int, float) and value is not None and not isinstance(value, (int, float)) \
and not (isinstance(value, str) and value.lstrip('-').isdigit()):
return f'Argument {name} must be a {known[name]["type"]}, got {value!r}.'
# Path parameters land in the request URL; refuse anything that could escape the
# endpoint's own path (the allowlist must hold even against a crafted value).
if name in tool.path_params and isinstance(value, str):
segments = value.split('/')
if value.startswith('/') or '' in segments or '.' in segments or '..' in segments:
return f'Argument {name} must not contain path traversal.'
return None
async def execute_tool(mode_tools: tuple, name: str, args: dict) -> Tuple[bool, str]:
"""Validate and execute one tool call. Returns (success, result text for the model).
Every failure is a plain-language error string — small models retry well on crisp errors."""
definitions = get_ai_tool_definitions()
if name not in definitions or name not in mode_tools:
available = sorted(set(mode_tools) & set(definitions))
return False, f'Unknown tool {name!r}. Available tools: {available}.'
tool = definitions[name]
args = args or {}
if error := _validate_args(tool, args):
return False, error
path = tool.path
for param in tool.path_params:
# Encode everything but '/' (help-doc slugs contain it); traversal was rejected above.
path = path.replace(f'{{{param}}}', quote(str(args[param]), safe='/'))
# The substituted path must still be inside the endpoint the tool declares.
static_prefix = tool.path.split('{', 1)[0]
if not path.startswith(static_prefix) or '..' in path.split('/'):
return False, f'Arguments produced an invalid path for {tool.name}.'
query = {k: v for k, v in args.items() if k in tool.query_params and v is not None}
body = {k: v for k, v in args.items() if k in tool.body_params} if tool.method == 'post' else None
try:
status, response = await call_ai_endpoint(tool.method, path, query, body)
except Exception as e:
logger.error(f'Tool {name} failed', exc_info=e)
return False, f'Tool {name} failed to execute; try different arguments or another tool.'
text = json.dumps(response, default=str)
if len(text) > TOOL_RESULT_MAX_CHARS:
text = text[:TOOL_RESULT_MAX_CHARS] + '… (result truncated; use limit/offset to get less at a time)'
if status >= 400:
message = response.get('message') or response.get('error') or f'HTTP {status}'
return False, f'{tool.name} returned an error: {message}'
return True, text

911
modules/ai/api.py Normal file
View file

@ -0,0 +1,911 @@
"""The /api/ai blueprint — read-only, LLM-shaped endpoints.
This blueprint IS the AI tool catalog: the agent loop generates its tool definitions from these
endpoints' OpenAPI operations, and the external MCP server proxies them. Every endpoint is
read-only; descriptions are written for the model, not for humans.
"""
import asyncio
import mimetypes
import pathlib
from http import HTTPStatus
from typing import Optional
from sanic import Blueprint, Request
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from modules.archive import lib as archive_lib
from modules.archive.lib import search_domains_by_name
from modules.docs.lib import search_authors_by_name, search_subjects_by_name
from modules.map import lib as map_lib
from modules.map import search as map_search
from modules.map.pins import get_map_pins_config
from modules.videos.channel.lib import search_channels_by_name
from modules.videos.models import Video
from modules.docs.lib import _doc_response, _get_doc, _search_docs
from modules.inventory.common import get_inventory_configs
from modules.inventory.errors import UnknownInventory
from modules.videos.video import lib as videos_lib
from modules.zim import lib as zim_lib
from wrolpi import flags, tags
from wrolpi.api_utils import json_response
from wrolpi.collections.lib import search_collections
from wrolpi.common import api_param_limiter, get_media_directory, get_relative_to_media_directory, logger, \
wrol_mode_enabled
from wrolpi.downloader import download_manager
from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile, ValidationError
from wrolpi.files.lib import search_files, HIDDEN_DIRECTORIES, HIDDEN_FILES
from wrolpi.files.models import FileGroup
from wrolpi.vars import DOCKERIZED, IS_RPI4
from wrolpi.version import __version__
from wrolpi.schema import JSONErrorResponse
from . import catalog, help_docs, lib, schema
from .config import get_ai_config
from .controller_client import controller_get
from .errors import ControllerUnavailable
ai_bp = Blueprint('AI', url_prefix='/api/ai')
logger = logger.getChild(__name__)
ai_limiter = api_param_limiter(25, default=5)
# Refuse to serve files this large through the text reader; real content is paged anyway.
MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024
# Directory listings are paged by entry count so one big channel can't fill the model's context.
LIST_PAGE_SIZE = 100
def _offset(request: Request) -> int:
try:
return max(int(request.args.get('offset', 0)), 0)
except ValueError:
return 0
@ai_bp.get('/zims')
@openapi.definition(
summary='List Zim encyclopedias',
description='List the Zim encyclopedias in the library (Wikipedia, Wiktionary, etc.). Returns the zim_id'
' needed to search a specific Zim or read one of its entries.',
)
@openapi.operation('list_zims')
async def list_zims(request: Request):
zims = zim_lib.get_zims(request.ctx.session)
results = []
for zim in zims:
metadata = lib.zim_metadata_dict(zim.zim_metadata)
results.append({k: v for k, v in dict(
id=zim.id,
title=metadata.get('title'),
creator=metadata.get('creator'),
description=metadata.get('description'),
size=zim.file_group.size if zim.file_group else None,
auto_search=zim.auto_search,
).items() if v is not None})
return json_response(dict(results=results, total=len(results)))
@ai_bp.post('/zims/search')
@openapi.definition(
summary='Search Zim encyclopedias',
description='Search the Zim encyclopedias (Wikipedia, etc.) enabled for search; pass zim_id to search one.'
' Read a result in full with get_zim_entry (zim_id and path).',
body=schema.AIZimSearchRequest,
)
@openapi.operation('search_zims')
@openapi.response(HTTPStatus.OK, schema.AISearchResponse)
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@validate(schema.AIZimSearchRequest)
async def search_zims(request: Request, body: schema.AIZimSearchRequest):
if not body.search_str:
raise SearchEmpty()
limit = ai_limiter(body.limit)
offset = body.offset or 0
if body.zim_id:
result = zim_lib.headline_zim(request.ctx.session, body.search_str, body.zim_id, offset=offset, limit=limit)
for entry in result.get('search') or []:
entry['zim_id'] = body.zim_id
zim_results = [result]
else:
zim_results = zim_lib.search_all_zims(request.ctx.session, body.search_str, offset=offset, limit=limit)
return json_response(lib.format_zim_search(zim_results))
@ai_bp.get('/zims/<zim_id:int>/entry')
@openapi.definition(
summary='Read a Zim entry',
description='Read one Zim article as plain text by zim_id and the path from search_zims. Paged: request'
' again with offset=next_offset.',
)
@openapi.operation('get_zim_entry')
@openapi.parameter('path', str, 'query')
@openapi.parameter('offset', int, 'query')
@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse)
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_zim_entry(request: Request, zim_id: int):
path = request.args.get('path')
if not path:
raise InvalidFile('path query parameter is required')
entry = zim_lib.get_entry(path, zim_id)
html = bytes(entry.get_item().content).decode('UTF-8', errors='replace')
result = lib.paginate_text(lib.html_to_text(html), _offset(request))
result['link'] = lib.zim_entry_link(zim_id, path)
return json_response(result)
@ai_bp.get('/collections')
@openapi.definition(
summary='List collections',
description='List collections in the library, paged. Filter with kind: "channel" (video channels),'
' "domain" (archived websites), or "playlist"; search_str matches names. Use a channel\'s'
' name or id, or a domain\'s name, as the channel or domain filter of search_files.',
)
@openapi.operation('list_collections')
@openapi.parameter('kind', str, 'query')
@openapi.parameter('search_str', str, 'query')
@openapi.parameter('limit', int, 'query')
@openapi.parameter('offset', int, 'query')
async def list_collections(request: Request):
kind = request.args.get('kind')
search_str = request.args.get('search_str') or None
try:
limit = ai_limiter(int(request.args.get('limit', 0)) or None)
except ValueError:
raise ValidationError('limit must be an integer')
offset = _offset(request)
collections = search_collections(request.ctx.session, kind=kind, search_str=search_str)
results = []
for collection in collections[offset:offset + limit]:
# Channels have a browsable page; a real link stops the model inventing one.
link = f"/videos/channel/{collection['id']}/video" if collection.get('kind') == 'channel' else None
results.append({k: v for k, v in dict(
id=collection.get('id'),
name=collection.get('name'),
kind=collection.get('kind'),
directory=collection.get('directory'),
link=link,
).items() if v is not None})
next_offset = offset + limit if offset + limit < len(collections) else None
return json_response(dict(results=results, total=len(collections), next_offset=next_offset))
def _lean_inventories() -> list:
return [{k: v for k, v in dict(
slug=i.get('slug'),
name=i.get('name'),
type=i.get('type'),
item_count=len(i.get('items') or []),
).items() if v is not None} for i in get_inventory_configs().all_inventories()]
def _inventory_by_slug(slug: str) -> dict:
inventory = get_inventory_configs().get_inventory(slug)
if inventory is None:
raise UnknownInventory(f'No inventory: {slug}')
return inventory
@ai_bp.get('/inventories')
@openapi.definition(
summary='Get inventories',
description='Without slug: list the inventories (food storage, emergency supplies, etc.) with item counts.'
' With slug: that inventory in full, every item and field.',
)
@openapi.operation('get_inventory')
@openapi.parameter('slug', str, 'query')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_inventory(request: Request):
if slug := request.args.get('slug'):
return json_response(dict(inventory=_inventory_by_slug(slug)))
results = _lean_inventories()
return json_response(dict(results=results, total=len(results)))
# ---------------------------------------------------------------------------
# Help endpoints (Help mode). Help markdown is searched in-process; see help_docs.py.
# ---------------------------------------------------------------------------
@ai_bp.post('/help/search')
@openapi.definition(
summary='Search the WROLPi help documentation',
description='Search the WROLPi help documentation for how to use and repair WROLPi. Returns matching'
' help pages with a snippet; read a full page by passing its slug to get_help_doc. Links are pages on the'
' WROLPi help site.',
body=schema.AIHelpSearchRequest,
)
@openapi.operation('search_help')
@openapi.response(HTTPStatus.OK, schema.AISearchResponse)
@validate(schema.AIHelpSearchRequest)
async def search_help(_: Request, body: schema.AIHelpSearchRequest):
if not body.search_str:
raise SearchEmpty()
if not help_docs.get_help_docs_directory():
return json_response(dict(results=[], total=0, message='Help documentation is not installed.'))
results, total = help_docs.search_help(body.search_str, ai_limiter(body.limit))
return json_response(dict(results=results, total=total))
@ai_bp.get('/help/<slug:path>')
@openapi.definition(
summary='Read a help page',
description='Read one WROLPi help page as markdown by its doc slug from search_help results. Long'
' pages are paged: request again with offset=next_offset to continue reading.',
)
@openapi.operation('get_help_doc')
@openapi.parameter('offset', int, 'query')
@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse)
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_help_doc(request: Request, slug: str):
doc = help_docs.get_help_doc(slug)
if not doc:
raise UnknownFile(f'No such help doc: {slug}')
result = lib.paginate_text(doc.body, _offset(request))
result['title'] = doc.title
result['link'] = help_docs.help_doc_link(doc.slug)
return json_response(result)
# ---------------------------------------------------------------------------
# System endpoints (System mode). The Controller is proxied so the model sees one API; every
# proxy degrades gracefully — a Controller outage becomes an entry in `errors`, never a 500.
# ---------------------------------------------------------------------------
# The lean subset of the Controller's cached status that is useful for diagnosis; the full blob
# (processes, iostat, disk bandwidth) is too big for small model contexts.
SYSTEM_STATUS_KEYS = ('cpu', 'memory', 'load', 'drives', 'network', 'uptime', 'hotspot', 'throttle', 'is_rpi')
log_lines_limiter = api_param_limiter(1_000, default=100)
@ai_bp.get('/status')
@openapi.definition(
summary='Get WROLPi system status',
description='Get an aggregate of the whole system in one call: WROLPi version, mode flags, download'
' summary, CPU/memory/load/disk usage, and the state of every WROLPi service. Start'
' troubleshooting here. Any part that could not be gathered is listed in errors.',
)
@openapi.operation('get_system_status')
async def get_system_status(_: Request):
errors = []
ret = dict(
version=__version__.strip(),
dockerized=DOCKERIZED,
wrol_mode=wrol_mode_enabled(),
flags=flags.get_flags(),
errors=errors,
)
try:
ret['downloads'] = download_manager.get_summary()
except Exception as e:
logger.debug('AI status could not get download summary', exc_info=e)
errors.append('Could not get download summary')
try:
status, stats = await controller_get('/api/stats')
if status == HTTPStatus.OK:
ret['system'] = {k: v for k, v in stats.items() if k in SYSTEM_STATUS_KEYS}
else:
errors.append(f'Controller stats returned HTTP {status}')
except Exception as e:
logger.debug('AI status could not reach Controller stats', exc_info=e)
errors.append('Could not reach the Controller for system stats')
try:
ret['services'] = await _lean_services()
except Exception as e:
logger.debug('AI status could not get services', exc_info=e)
errors.append('Could not get service statuses')
return json_response(ret)
async def _lean_services() -> list:
status, services = await controller_get('/api/services')
if status != HTTPStatus.OK or not isinstance(services, list):
raise ControllerUnavailable(f'Controller services returned HTTP {status}')
return [{k: v for k, v in dict(
name=i.get('name'),
status=i.get('status'),
enabled=i.get('enabled'),
port=i.get('port'),
description=i.get('description') or None,
).items() if v is not None} for i in services]
@ai_bp.get('/services')
@openapi.definition(
summary='List WROLPi services',
description='List every WROLPi service with its state (running, stopped, failed) and port. Use the'
' name with get_service_logs to read a failing service\'s logs.',
)
@openapi.operation('list_services')
@openapi.response(HTTPStatus.BAD_GATEWAY, JSONErrorResponse)
async def ai_list_services(_: Request):
services = await _lean_services()
return json_response(dict(results=services, total=len(services)))
@ai_bp.get('/services/<name:str>/logs')
@openapi.definition(
summary='Read service logs',
description='Read the most recent log lines of one WROLPi service by its name from list_services.'
' Pass lines to read more history (at most 1000).',
)
@openapi.operation('get_service_logs')
@openapi.parameter('lines', int, 'query')
@openapi.parameter('since', str, 'query')
@openapi.response(HTTPStatus.BAD_GATEWAY, JSONErrorResponse)
async def ai_get_service_logs(request: Request, name: str):
try:
lines = log_lines_limiter(int(request.args.get('lines', 100)))
except ValueError:
lines = 100
status, body = await controller_get(f'/api/services/{name}/logs',
params=dict(lines=max(lines, 1), since=request.args.get('since')))
if status != HTTPStatus.OK:
raise ControllerUnavailable(f'Controller logs returned HTTP {status}: {body.get("detail")}')
return json_response(dict(service=body.get('service', name), lines=body.get('lines'), logs=body.get('logs', '')))
@ai_bp.get('/disks')
@openapi.definition(
summary='Get disks and drive health',
description='List the disks and partitions (size, filesystem, where mounted) and SMART drive-health'
' status. Use this to diagnose full or failing drives. Parts that could not be gathered'
' are listed in errors.',
)
@openapi.operation('list_disks')
async def ai_list_disks(_: Request):
errors = []
ret = dict(errors=errors)
try:
status, disks = await controller_get('/api/disks')
if status == HTTPStatus.OK:
ret['disks'] = disks
else:
errors.append(f'Controller disks returned HTTP {status}: {disks.get("detail")}')
except Exception as e:
logger.debug('AI disks could not reach Controller', exc_info=e)
errors.append('Could not reach the Controller for disks')
try:
status, smart = await controller_get('/api/disks/smart')
if status == HTTPStatus.OK:
ret['smart'] = smart
else:
errors.append(f'Controller SMART returned HTTP {status}: {smart.get("detail")}')
except Exception as e:
logger.debug('AI disks could not reach Controller SMART', exc_info=e)
errors.append('Could not reach the Controller for SMART status')
return json_response(ret)
# Errors on downloads can be long tracebacks; the model only needs the gist.
DOWNLOAD_ERROR_LENGTH = 300
MAP_LINK_ZOOM = 12
def _map_link(lat, lon) -> str:
return f'/map?lat={lat}&lon={lon}&z={MAP_LINK_ZOOM}'
@ai_bp.get('/tags')
@openapi.definition(
summary='List tags',
description='List every tag in the library with how many files, Zim entries, channels, and domains carry'
' it, plus the most recently used tag names. Use a tag name in the tag_names filter of the'
' search tools. Users tag things they care about, so tags are a good map of their interests.',
)
@openapi.operation('list_tags')
async def list_tags(_: Request):
results = [dict(
name=i['name'],
file_groups=i['file_group_count'],
zim_entries=i['zim_entry_count'],
channels=i['channel_count'],
domains=i['domain_count'],
) for i in tags.get_tags()]
return json_response(dict(results=results, recent=tags.get_recent_tags(), total=len(results)))
def _format_download(download: dict) -> dict:
error = download.get('error')
if error and len(error) > DOWNLOAD_ERROR_LENGTH:
# Errors are usually tracebacks; the exception message at the end is the useful part.
error = '…' + error[-DOWNLOAD_ERROR_LENGTH:]
destination = download.get('destination')
return {k: v for k, v in dict(
id=download.get('id'),
url=download.get('url'),
status=download.get('status'),
downloader=download.get('downloader'),
sub_downloader=download.get('sub_downloader'),
frequency=download.get('frequency'),
destination=str(get_relative_to_media_directory(destination)) if destination else None,
collection_id=download.get('collection_id'),
tag_names=download.get('tag_names') or None,
next_download=download.get('next_download'),
last_successful_download=download.get('last_successful_download'),
error=error,
).items() if v is not None}
@ai_bp.get('/downloads')
@openapi.definition(
summary='List downloads',
description='Read the download queue: a summary (pending count, whether downloading is disabled or'
' stopped, daily limit), the recurring downloads (channels, feeds; frequency is in seconds),'
' and the newest one-time downloads. Filter with status: new, pending, failed, deferred, or'
' complete. Use this when the user asks what is downloading, why a download failed, or what'
' is scheduled. You cannot start, stop, or retry downloads.',
)
@openapi.operation('list_downloads')
@openapi.parameter('status', str, 'query')
@openapi.parameter('limit', int, 'query')
async def list_downloads(request: Request):
status = request.args.get('status')
try:
limit = ai_limiter(int(request.args.get('limit', 0)) or None)
except ValueError:
raise ValidationError('limit must be an integer')
summary = download_manager.get_summary()
data = download_manager.get_fe_downloads()
def select(downloads: list) -> list:
if status:
downloads = [i for i in downloads if i.get('status') == status]
return [_format_download(i) for i in downloads[:limit]]
return json_response(dict(
summary=summary,
recurring=select(data['recurring_downloads']),
once=select(data['once_downloads']),
pending_once=data.get('pending_once_downloads', 0),
))
@ai_bp.get('/map')
@openapi.definition(
summary='Get map overview',
description='What maps this WROLPi has: the downloaded map files (regions) and whether each has a place'
' search index, the map regions subscribed for updates, and the user\'s saved pins with a'
' link to each. Use search_places to look up a town or landmark.',
)
@openapi.operation('get_map_overview')
async def get_map_overview(request: Request):
files = [dict(name=i['name'], path=i['path'], size=i['size'], has_search_index=i['has_search_index'])
for i in map_lib.get_pmtiles_files()]
pins = [dict(id=i.get('id'), label=i.get('label'), lat=i.get('lat'), lon=i.get('lon'),
link=_map_link(i.get('lat'), i.get('lon')))
for i in get_map_pins_config().pins]
return json_response(dict(
files=files,
subscriptions=map_lib.get_map_subscriptions(request.ctx.session),
pins=pins,
search_indexes=map_search.get_search_status(),
))
@ai_bp.get('/map/search')
@openapi.definition(
summary='Search places on the map',
description='Find towns, cities, and landmarks by name in the downloaded maps. Each result has coordinates'
' and a map link. Pass lat and lon to rank nearest first.',
)
@openapi.operation('search_places')
@openapi.parameter('q', str, 'query')
@openapi.parameter('limit', int, 'query')
@openapi.parameter('offset', int, 'query')
@openapi.parameter('lat', float, 'query')
@openapi.parameter('lon', float, 'query')
async def search_places(request: Request):
q = (request.args.get('q') or '').strip()
if not q:
raise ValidationError('q query parameter is required')
try:
limit = ai_limiter(int(request.args.get('limit', 0)) or None)
lat = float(request.args.get('lat')) if request.args.get('lat') is not None else None
lon = float(request.args.get('lon')) if request.args.get('lon') is not None else None
except ValueError:
raise ValidationError('limit, lat, and lon must be numbers')
data = await asyncio.to_thread(map_search.search_places, q, limit=limit, offset=_offset(request),
lat=lat, lon=lon)
results = [{k: v for k, v in dict(
name=i.get('name'),
kind=i.get('kind_detail') or i.get('kind'),
region=i.get('region'),
population=i.get('population'),
lat=i.get('lat'),
lon=i.get('lon'),
link=_map_link(i.get('lat'), i.get('lon')),
).items() if v is not None} for i in data.get('results') or []]
return json_response(dict(results=results, total=data.get('total', len(results))))
# ---------------------------------------------------------------------------
# Consolidated, kind-generic endpoints. Small models carry every tool definition on every request,
# so one search / one detail / one reader replaces the per-kind trio.
# ---------------------------------------------------------------------------
FILE_KINDS = ('video', 'archive', 'doc')
# Name matches offered alongside search results so the model can narrow without another tool.
MATCHES_PER_GROUP = 3
def _archive_detail(archive) -> dict:
result = lib.format_file_group(archive.file_group.__json__(),
description_length=lib.DETAIL_DESCRIPTION_LENGTH, include_url=True, detail=True)
history = []
for snapshot in archive.history[:10]:
fg = snapshot.file_group.__json__()
history.append({k: v for k, v in dict(
id=fg.get('id'),
title=fg.get('title') or fg.get('name'),
published=lib.format_date(fg.get('published_datetime')),
link=lib.wrolpi_link(fg),
).items() if v is not None})
if history:
result['history'] = history
return result
def _video_detail(video: Video) -> dict:
fg = video.__json__()
try:
fg['video']['description'] = video.get_description()
except Exception as e:
logger.debug(f'Could not read description of {video}', exc_info=e)
result = lib.format_file_group(fg, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
result['has_captions'] = bool(video.caption_paths)
if video.have_comments:
result['has_comments'] = True
return result
def _doc_detail(session, file_group_id: int) -> dict:
response = _doc_response(_get_doc(session, file_group_id))
file_group = response['file_group']
file_group['doc'] = response['doc']
return lib.format_file_group(file_group, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
async def _resolve_channel_id(session, channel: str) -> Optional[int]:
"""A channel given as a numeric id or a (partial) name; None when nothing matches."""
channel = (channel or '').strip()
if not channel:
return None
if channel.isdigit():
return int(channel)
matches = await search_channels_by_name(session, channel, limit=1, order_by_video_count=True)
return matches[0].id if matches else None
@ai_bp.post('/files/search')
@openapi.definition(
summary='Search the library',
description='Search videos, archived web pages, and documents by title and content; omit search_str to'
' browse the newest. kind: video, archive, or doc. channel (name or id) is for videos, domain for'
' archives, author/subject for docs. Each result has an id for get_file/read_content and a link.'
' matches names channels/domains/authors that fit the term: narrow with one when total is large.',
body=schema.AIFileSearchRequest,
)
@openapi.operation('search_files')
@openapi.response(HTTPStatus.OK, schema.AISearchResponse)
@validate(schema.AIFileSearchRequest)
async def search_files_endpoint(request: Request, body: schema.AIFileSearchRequest):
session = request.ctx.session
kind = (body.kind or '').strip().lower() or None
if kind and kind not in FILE_KINDS:
raise ValidationError(f'kind must be one of {", ".join(FILE_KINDS)}')
# A kind-specific filter implies the kind.
if not kind:
if body.channel:
kind = 'video'
elif body.domain:
kind = 'archive'
elif body.author or body.subject:
kind = 'doc'
limit = ai_limiter(body.limit)
offset = body.offset or 0
searched = bool(body.search_str)
narrowed = False
if kind == 'video':
channel_id = None
if body.channel:
channel_id = await _resolve_channel_id(session, body.channel)
if channel_id is None:
result = lib.format_file_groups([], 0, searched=True)
result['hint'] = (f'No channel matches "{body.channel}". Use the exact channel name from the'
' library list, or search without channel.')
return json_response(result)
narrowed = True
file_groups, total = videos_lib.search_videos(
search_str=body.search_str, offset=offset, limit=limit, channel_id=channel_id,
tag_names=body.tag_names, headline=True)
elif kind == 'archive':
narrowed = bool(body.domain)
file_groups, total = archive_lib.search_archives(
body.search_str, body.domain, limit, offset, None, body.tag_names, headline=True)
elif kind == 'doc':
narrowed = bool(body.author or body.subject)
file_groups, total = _search_docs(
search_str=body.search_str, author=body.author, subject=body.subject, limit=limit, offset=offset,
order_by='rank' if body.search_str else 'published_datetime', tag_names=body.tag_names)
else:
file_groups, total = search_files(body.search_str, limit, offset, tag_names=body.tag_names, headline=True)
result = lib.format_file_groups(file_groups, total, searched=searched)
if searched and not narrowed:
matches = dict()
channels = await search_channels_by_name(session, body.search_str, limit=MATCHES_PER_GROUP,
order_by_video_count=True)
if channels and kind in (None, 'video'):
matches['channels'] = [dict(id=i.id, name=i.name) for i in channels]
domains = await search_domains_by_name(session, body.search_str, limit=MATCHES_PER_GROUP)
if domains and kind in (None, 'archive'):
matches['domains'] = [dict(name=i['domain']) for i in domains]
if kind in (None, 'doc'):
authors = await search_authors_by_name(session, body.search_str, limit=MATCHES_PER_GROUP)
subjects = await search_subjects_by_name(session, body.search_str, limit=MATCHES_PER_GROUP)
if authors:
matches['authors'] = [dict(name=i['name']) for i in authors]
if subjects:
matches['subjects'] = [dict(name=i['name']) for i in subjects]
if matches:
result['matches'] = matches
return json_response(result)
@ai_bp.get('/files/<file_group_id:int>')
@openapi.definition(
summary='Get one file',
description='Details of one item by its id, any kind: title, link, date, channel/author, tags, size, full'
' description; videos say whether captions/comments exist; archives include earlier snapshots.',
)
@openapi.operation('get_file')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_file(request: Request, file_group_id: int):
session = request.ctx.session
file_group = FileGroup.find_by_id(session, file_group_id)
if file_group.model == 'video':
return json_response(_video_detail(Video.find_by_file_group_id(session, file_group_id)))
if file_group.model == 'archive':
archive = archive_lib.get_archive_by_file_group_id(session, file_group_id, skip_viewed=True)
return json_response(_archive_detail(archive))
if file_group.model == 'doc':
return json_response(_doc_detail(session, file_group_id))
return json_response(lib.format_file_group(file_group.__json__(),
description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True))
CONTENT_PARTS = ('text', 'comments')
@ai_bp.get('/files/<file_group_id:int>/content')
@openapi.definition(
summary='Read a file\'s content',
description='Read an item by its id: part=text is a video\'s captions or an archived page\'s text;'
' part=comments is a video\'s comments. Paged: request again with offset=next_offset.',
)
@openapi.operation('read_content')
@openapi.parameter('part', str, 'query')
@openapi.parameter('offset', int, 'query')
@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse)
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def read_content(request: Request, file_group_id: int):
part = (request.args.get('part') or 'text').strip().lower()
if part not in CONTENT_PARTS:
raise ValidationError(f'part must be one of {", ".join(CONTENT_PARTS)}')
session = request.ctx.session
file_group = FileGroup.find_by_id(session, file_group_id)
kind = file_group.model or 'file'
text = None
if kind == 'video':
video = Video.find_by_file_group_id(session, file_group_id)
if part == 'text':
text = lib.format_caption_chunks(video.get_caption_chunks())
else:
lines = []
for comment in video.get_comments() or []:
author = comment.get('author') or 'unknown'
if body := (comment.get('text') or '').strip():
lines.append(f'{author}: {body}')
text = '\n'.join(lines)
elif kind == 'archive' and part == 'text':
archive = archive_lib.get_archive_by_file_group_id(session, file_group_id, skip_viewed=True)
text = lib.read_archive_text(archive)
if text is None:
raise UnknownFile(f'This is a {kind}; it has no {part} to read')
return json_response(lib.paginate_text(text, _offset(request)))
# ---------------------------------------------------------------------------
# Manage endpoints — for the AI page's Manage tab, NOT part of the model's tool catalog.
# The agent loop excludes everything under /api/ai/manage/ when building tool definitions.
# ---------------------------------------------------------------------------
@ai_bp.get('/manage/catalog')
@openapi.description('The AI model catalog, download/active state, and the recommended tier for this'
' device. For the Manage tab, not the model.')
async def manage_catalog(_: Request):
models, source = await catalog.get_models_catalog()
models_directory = catalog.get_models_directory()
on_disk = {i.name: i for i in models_directory.glob('*.gguf')} if models_directory.is_dir() else dict()
disk_usage = sum(i.stat().st_size for i in models_directory.glob('*')) if models_directory.is_dir() else 0
config = get_ai_config()
models = [dict(i, downloaded=i['name'] in on_disk, active=i['name'] == config.active_model)
for i in models]
# Any GGUF the user copied into ai/models themselves is a "custom" model. It is discovered
# here on every request (no file refresh) so it appears in the Active Model dropdown.
catalog_names = {i['name'] for i in models}
for name, path in sorted(on_disk.items()):
# Skip names start_llama_server.sh could not load (see is_loadable_model_name).
if name not in catalog_names and path.is_file() and catalog.is_loadable_model_name(name):
models.append(dict(
name=name,
tier='custom',
url=None,
size=path.stat().st_size,
description='Custom model found in ai/models/. Not from the WROLPi catalog.',
downloaded=True,
active=name == config.active_model,
))
total_ram = catalog.get_total_ram_bytes()
return json_response(dict(
models=models,
catalog_source=source,
total_ram=total_ram,
recommended_tier=catalog.recommend_tier(total_ram),
slow_hardware=IS_RPI4,
models_directory=str(models_directory),
disk_usage=disk_usage,
enabled=config.enabled,
active_model=config.active_model,
idle_unload_minutes=config.idle_unload_minutes,
context_size=config.context_size,
))
@ai_bp.post('/manage/settings')
@openapi.definition(
description='Update the AI settings (ai.yaml). For the Manage tab, not the model.',
body=schema.AIManageSettingsRequest,
)
@validate(schema.AIManageSettingsRequest)
async def manage_settings(_: Request, body: schema.AIManageSettingsRequest):
config = get_ai_config()
if body.active_model is not None:
if body.active_model and not (catalog.get_models_directory() / body.active_model).is_file():
raise ValidationError(f'Model is not downloaded: {body.active_model}')
if body.active_model and not catalog.is_loadable_model_name(body.active_model):
raise ValidationError(f'Model file name cannot be loaded by llama-server; rename it to plain ASCII'
f' without #, quotes, or "..": {body.active_model}')
config.active_model = body.active_model
# Each model has its own context default (the 4B runs 16k, the small tier 8k); selecting
# a model adopts it unless this request also sets context_size explicitly. A custom model
# has no catalog default, so the previous model's explicit value is cleared rather than
# carried over.
if body.context_size is None:
config.context_size = catalog.get_model_default_context(body.active_model)
if body.enabled is not None:
config.enabled = body.enabled
if body.idle_unload_minutes is not None:
if body.idle_unload_minutes < 1:
raise ValidationError('idle_unload_minutes must be at least 1')
config.idle_unload_minutes = body.idle_unload_minutes
if body.context_size is not None:
config.context_size = body.context_size
return json_response(dict(
enabled=config.enabled,
active_model=config.active_model,
idle_unload_minutes=config.idle_unload_minutes,
context_size=config.context_size,
))
def _resolve_media_path(relative: str | None, verb: str) -> pathlib.Path:
"""Resolve a relative path inside the media directory, refusing escapes and the config directory."""
media_directory = get_media_directory().resolve()
path = (media_directory / (relative or '').strip('/')).resolve()
if path != media_directory and not str(path).startswith(f'{media_directory}/'):
raise InvalidFile(f'Cannot {verb} outside the media directory')
# The config directory can contain secrets (Wi-Fi credentials, API keys); never serve it.
config_directory = media_directory / 'config'
if path == config_directory or str(path).startswith(f'{config_directory}/'):
raise InvalidFile(f'Cannot {verb} config files')
return path
@ai_bp.get('/files/list')
@openapi.definition(
summary='List a directory',
description='List one directory of the media directory by relative path (omit for the top level):'
' directories first, then files, each with a path for list_files or read_file. Paged by offset.'
' For "what is in this folder"; use search_files for content by topic.',
)
@openapi.operation('list_files')
@openapi.parameter('path', str, 'query')
@openapi.parameter('offset', int, 'query')
@openapi.response(HTTPStatus.OK, schema.AIListFilesResponse)
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def list_files(request: Request):
relative = request.args.get('path') or ''
media_directory = get_media_directory().resolve()
directory = _resolve_media_path(relative, 'list')
if not directory.is_dir():
raise UnknownFile(f'No such directory: {relative}')
def relative_path(path: pathlib.Path) -> str:
rel = str(path.relative_to(media_directory))
return f'{rel}/' if path.is_dir() else rel
directories, files = [], []
for child in directory.iterdir():
if child.is_dir():
# The config directory is never served (see _resolve_media_path), so don't advertise it.
if child.name not in HIDDEN_DIRECTORIES and child != media_directory / 'config':
directories.append(child)
elif child.name not in HIDDEN_FILES:
files.append(child)
directories.sort(key=lambda i: i.name.lower())
files.sort(key=lambda i: i.name.lower())
entries = [dict(name=i.name, path=relative_path(i)) for i in directories]
for file in files:
try:
size = file.stat().st_size
except OSError:
size = None
entries.append(dict(name=file.name, path=relative_path(file), size=size,
mimetype=mimetypes.guess_type(file.name)[0]))
offset = _offset(request)
page = entries[offset:offset + LIST_PAGE_SIZE]
next_offset = offset + LIST_PAGE_SIZE if offset + LIST_PAGE_SIZE < len(entries) else None
return json_response(dict(
path=relative_path(directory) if directory != media_directory else '',
directories=[i for i in page if 'size' not in i],
files=[i for i in page if 'size' in i],
total=len(entries),
next_offset=next_offset,
))
@ai_bp.get('/files/read')
@openapi.definition(
summary='Read a text file',
description='Read a plain-text file from the media directory by its relative path (from list_files).'
' Text files only. Paged: request again with offset=next_offset.',
)
@openapi.operation('read_file')
@openapi.parameter('path', str, 'query')
@openapi.parameter('offset', int, 'query')
@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse)
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def read_file(request: Request):
relative = request.args.get('path')
if not relative:
raise InvalidFile('path query parameter is required')
path = _resolve_media_path(relative, 'read')
if not path.is_file():
raise UnknownFile(f'No such file: {relative}')
if path.stat().st_size > MAX_TEXT_FILE_SIZE:
raise InvalidFile('File is too large to read')
data = path.read_bytes()
if b'\x00' in data[:8192]:
raise InvalidFile('Not a text file')
return json_response(lib.paginate_text(data.decode('UTF-8', errors='replace'), _offset(request)))

145
modules/ai/catalog.py Normal file
View file

@ -0,0 +1,145 @@
"""The AI model catalog.
The bundled AI_MODELS constant is the offline fallback (like the map module's MAP_REGIONS);
the CDN manifest at ai/models.json is fetched at browse/download time and GPG-verified with the
committed WROLPi public key. The release process refreshes AI_MODELS from the published manifest.
"""
import json
import tempfile
from pathlib import Path
from typing import Optional
import psutil
from wrolpi.common import aiohttp_get, get_media_directory, logger, verify_gpg_signature
logger = logger.getChild(__name__)
CDN = 'https://wrolpi.nyc3.cdn.digitaloceanspaces.com'
MODELS_MANIFEST_URL = f'{CDN}/ai/models.json'
# Sizes are bytes; sha256 values match the published manifest (wrolpi/scripts/publish_ai_models.py
# refreshes both). The meta4 sidecar (GPG-verified at download time) is what enforces the hash.
AI_MODELS = [
dict(
name='Qwen3-1.7B-Q4_K_M.gguf',
tier='small',
url=f'{CDN}/ai/Qwen3-1.7B-Q4_K_M.gguf',
sha256='b139949c5bd74937ad8ed8c8cf3d9ffb1e99c866c823204dc42c0d91fa181897',
size=1_107_409_472,
min_ram_gb=4,
default_context=8_192,
license='Apache 2.0',
description='The default assistant for 4GB devices (Pi 4/5). Best tool-calling per byte.',
),
dict(
name='Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
tier='medium',
url=f'{CDN}/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
sha256='3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597',
size=2_497_281_120,
min_ram_gb=8,
default_context=16_384,
license='Apache 2.0',
description='The default assistant for 8GB+ devices. A standout small tool-caller.',
),
]
def get_models_directory() -> Path:
"""Where GGUF model files are stored. Always inside the media directory."""
return get_media_directory() / 'ai/models'
# Printable ASCII only, minus the characters the shell config reader cannot round-trip.
# start_llama_server.sh reads active_model back out of ai.yaml with read_config_value.sh: PyYAML
# escapes non-ASCII (\xE9), the reader strips " #..." before unquoting, and quotes/backslashes
# survive as literal characters. The start script's traversal guard also rejects any ".." run.
_UNLOADABLE_CHARS = set('#"\'\\/')
def is_loadable_model_name(name: str) -> bool:
"""True when llama-server can be started with this file name from ai/models.
Catalog names always pass; this exists for GGUFs the user copies in by hand."""
if not name or not name.endswith('.gguf') or name != name.strip():
return False
if '..' in name or any(c in _UNLOADABLE_CHARS for c in name):
return False
return all(0x20 <= ord(c) < 0x7F for c in name)
async def fetch_models_manifest(url: str = None) -> dict:
"""Fetch the models manifest from the CDN and verify its GPG signature (map-manifest pattern)."""
url = url or MODELS_MANIFEST_URL
sig_url = f'{url}.sig'
async with aiohttp_get(url, timeout=30) as response:
if response.status != 200:
raise RuntimeError(f'Failed to fetch models manifest: HTTP {response.status}')
manifest_bytes = await response.content.read()
async with aiohttp_get(sig_url, timeout=30) as response:
if response.status != 200:
raise RuntimeError(f'Failed to fetch models manifest signature: HTTP {response.status}')
signature_bytes = await response.content.read()
with tempfile.TemporaryDirectory() as tmpdir:
manifest_path = Path(tmpdir) / 'models.json'
sig_path = Path(tmpdir) / 'models.json.sig'
manifest_path.write_bytes(manifest_bytes)
sig_path.write_bytes(signature_bytes)
if not await verify_gpg_signature(manifest_path, sig_path):
raise RuntimeError('Models manifest GPG signature verification failed')
logger.info('Models manifest GPG signature verified successfully')
return json.loads(manifest_bytes)
async def get_models_catalog() -> (list, str):
"""The list of downloadable models, and where it came from ('cdn' or 'bundled').
The CDN manifest wins when reachable; the bundled constant keeps the Manage tab working
offline."""
try:
manifest = await fetch_models_manifest()
models = manifest.get('models') or []
if models:
return models, 'cdn'
except Exception as e:
logger.debug(f'Could not fetch models manifest, using bundled catalog: {e}')
return AI_MODELS, 'bundled'
def get_total_ram_bytes() -> int:
return psutil.virtual_memory().total
def recommend_tier(total_ram_bytes: int = None) -> str:
"""Recommend a model tier for this device's RAM. Other tiers stay selectable with a warning."""
total_ram_bytes = total_ram_bytes if total_ram_bytes is not None else get_total_ram_bytes()
gb = total_ram_bytes / 1024 ** 3
if gb < 6:
return 'small'
if gb < 24:
return 'medium'
return 'large'
def get_model_default_context(name: str) -> Optional[int]:
"""The bundled catalog's default context size for a model, if known."""
for model in AI_MODELS:
if model['name'] == name:
return model.get('default_context')
return None
def get_effective_context_size() -> int:
"""The context llama-server runs with: the explicit ai.yaml value, else the active model's
catalog default, else the conservative small-tier default. start_llama_server.sh reads the
explicit value; manage_settings writes the model default there on model selection so the
script and this function agree."""
from modules.ai.config import get_ai_config
config = get_ai_config()
return config.context_size or get_model_default_context(config.active_model) or 8_192

256
modules/ai/chat.py Normal file
View file

@ -0,0 +1,256 @@
"""POST /api/chat — the agent loop, streamed over SSE.
Events (each `event: <name>` with a JSON `data:` line):
status {message} e.g. "Loading the model…"
token {content} one streamed answer fragment
tool_call {tool, args} the model is using a tool
tool_result {tool, success} the tool finished
done {content} the complete final answer
error {message}
The loop: build the mode's system prompt and tool definitions -> stream from llama-server ->
execute tool calls against /api/ai only (the mode allowlist is enforced server-side) -> repeat,
bounded by ai.yaml's max_tool_calls -> stream the final answer.
A streaming response outlives the request middleware, so nothing here may use
request.ctx.session; tools execute through the blueprint, which manages its own sessions."""
import dataclasses
import json
import time
from http import HTTPStatus
from typing import AsyncGenerator, List, Optional
from sanic import Blueprint, Request
from sanic_ext import validate
from sanic_ext.extensions.openapi import openapi
from wrolpi.common import aiohttp_post, logger
from wrolpi.errors import APIError, ValidationError
from . import agent, service
from .catalog import get_effective_context_size
from .config import get_ai_config
from .context import get_library_context
from .modes import MODE_EXAMPLES, MODE_SUGGESTIONS, MODES
chat_bp = Blueprint('AIChat', url_prefix='/api/chat')
logger = logger.getChild(__name__)
# The whole conversation turn (all tool calls and answers) must finish within this.
CHAT_TIMEOUT_SECONDS = 600
FALLBACK_MESSAGE = "I couldn't complete that request. Try rephrasing, or a more specific question."
# Tokens kept free for the model's reply when budgeting the prompt.
REPLY_RESERVE_TOKENS = 768
CONTEXT_FULL_MESSAGE = ('This conversation no longer fits the model\'s context.'
' Start a new conversation and ask a more specific question.')
def estimate_tokens(messages: List[dict], tools: List[dict]) -> int:
"""A deliberate overestimate (~4 chars/token) of what one completion request costs."""
return (len(json.dumps(messages)) + len(json.dumps(tools, default=str))) // 4
def trim_history(history: List[dict], fixed: List[dict], tools: List[dict], budget: int) -> List[dict]:
"""Drop the oldest conversation turns until fixed + history fits the budget.
`fixed` (system prompt, library map, few-shot example, and the current user message) is never
trimmed; old turns are the least valuable tokens in the request."""
history = list(history)
while history and estimate_tokens(fixed + history, tools) > budget:
history.pop(0)
return history
class AIDisabled(APIError):
code = 'AI_DISABLED'
summary = 'AI is not enabled. Enable it and select a model on the AI page.'
status_code = HTTPStatus.CONFLICT
@dataclasses.dataclass
class ChatRequest:
mode: str = ''
messages: List[dict] = dataclasses.field(default_factory=list)
model: Optional[str] = None # reserved; llama-server runs the single active model
@chat_bp.get('/modes')
@openapi.description('The available chat modes with their suggestion chips.')
async def get_modes(_: Request):
from wrolpi.api_utils import json_response
modes = [dict(name=name, suggestions=MODE_SUGGESTIONS.get(name, [])) for name in MODES]
return json_response(dict(modes=modes))
async def llama_chat_stream(messages: List[dict], tools: List[dict]) -> AsyncGenerator[dict, None]:
"""Stream one /v1/chat/completions call from llama-server.
Yields {'type': 'token', 'content': str} for answer fragments, then exactly one
{'type': 'stop', 'tool_calls': [...]} where tool_calls is empty for a final answer.
Patched in tests."""
config = get_ai_config()
body = dict(
messages=messages,
stream=True,
# Low temperature: tool calls from small models are far more reliable near 0.2-0.3.
temperature=config.temperature,
# Reuse the KV cache for the stable prefix (system prompt + example); on slow CPUs
# re-processing that prefix is most of the latency.
cache_prompt=True,
)
if tools:
body['tools'] = tools
tool_calls = {} # index -> {id, name, arguments}
async with aiohttp_post(f'{service.LLAMA_URL}/v1/chat/completions', json_=body,
timeout=CHAT_TIMEOUT_SECONDS) as response:
if response.status != 200:
raise RuntimeError(f'llama-server returned HTTP {response.status}')
async for raw_line in response.content:
line = raw_line.decode('UTF-8', errors='replace').strip()
if not line.startswith('data:'):
continue
data = line[5:].strip()
if data == '[DONE]':
break
try:
chunk = json.loads(data)
except ValueError:
continue
choice = (chunk.get('choices') or [{}])[0]
delta = choice.get('delta') or {}
if content := delta.get('content'):
yield dict(type='token', content=content)
for tool_call in delta.get('tool_calls') or []:
index = tool_call.get('index', 0)
entry = tool_calls.setdefault(index, dict(id=None, name='', arguments=''))
if tool_call.get('id'):
entry['id'] = tool_call['id']
function = tool_call.get('function') or {}
if function.get('name'):
entry['name'] = function['name']
if function.get('arguments'):
entry['arguments'] += function['arguments']
yield dict(type='stop', tool_calls=[tool_calls[i] for i in sorted(tool_calls)])
async def _send_event(response, event: str, data: dict):
await response.send(f'event: {event}\ndata: {json.dumps(data, default=str)}\n\n')
@chat_bp.post('/')
@openapi.definition(
summary='Chat with the local AI assistant (SSE stream)',
body=ChatRequest,
)
@validate(ChatRequest)
async def post_chat(request: Request, body: ChatRequest):
if body.mode not in MODES:
raise ValidationError(f'Unknown mode: {body.mode!r}. Modes: {sorted(MODES)}')
if not body.messages:
raise ValidationError('messages must not be empty')
config = get_ai_config()
if not config.enabled or not config.active_model:
raise AIDisabled()
mode = MODES[body.mode]
tools = agent.get_mode_tools(mode['tools'])
max_tool_calls = config.max_tool_calls
deadline = time.time() + CHAT_TIMEOUT_SECONDS
response = await request.respond(content_type='text/event-stream', headers={
'Cache-Control': 'no-cache',
# Ask proxies not to buffer the stream.
'X-Accel-Buffering': 'no',
})
try:
if not await service.llama_healthy():
await _send_event(response, 'status', dict(message='Loading the model…'))
if not await service.ensure_llama_running():
await _send_event(response, 'error', dict(message='The AI service could not be started.'))
return
# System prompt + the live library map, so exact names, counts, and today's date are in
# context before the first token; then the mode's worked example, then the conversation.
system_content = mode['system_prompt']
if library_context := get_library_context():
system_content += f'\n\n{library_context}'
fixed = [dict(role='system', content=system_content)]
fixed += MODE_EXAMPLES.get(body.mode, [])
history = [dict(role=m.get('role', 'user'), content=m.get('content', '')) for m in body.messages]
# The current user message is never trimmed away.
current, history = history[-1], history[:-1]
fixed.append(current)
budget = get_effective_context_size() - REPLY_RESERVE_TOKENS
history = trim_history(history, fixed, tools, budget)
if estimate_tokens(fixed + history, tools) > budget:
await _send_event(response, 'error', dict(message=CONTEXT_FULL_MESSAGE))
return
messages = fixed[:-1] + history + [current]
final_answer = []
for _ in range(max_tool_calls + 1):
if time.time() > deadline:
await _send_event(response, 'error', dict(message=FALLBACK_MESSAGE))
return
# Tool results grow the request; refuse to overflow the model's context mid-loop.
if estimate_tokens(messages, tools) > budget:
await _send_event(response, 'error', dict(message=CONTEXT_FULL_MESSAGE))
return
service.record_llm_activity()
# Always send something before the model call: on slow CPUs the first token can be
# minutes away, and a silent stream looks dead to the UI and to proxies.
await _send_event(response, 'status', dict(message='Thinking…'))
tool_calls = []
final_answer = []
async for event in llama_chat_stream(messages, tools):
if event['type'] == 'token':
final_answer.append(event['content'])
await _send_event(response, 'token', dict(content=event['content']))
elif event['type'] == 'stop':
tool_calls = event['tool_calls']
if not tool_calls:
# A final answer.
content = ''.join(final_answer).strip()
await _send_event(response, 'done', dict(content=content or FALLBACK_MESSAGE))
return
# Execute the tool calls, then loop for the model's next step.
messages.append(dict(role='assistant', content=''.join(final_answer) or None, tool_calls=[
dict(id=i['id'] or f'call_{n}', type='function',
function=dict(name=i['name'], arguments=i['arguments']))
for n, i in enumerate(tool_calls)
]))
for n, tool_call in enumerate(tool_calls):
name = tool_call['name']
try:
args = json.loads(tool_call['arguments']) if tool_call['arguments'] else {}
except ValueError:
args = None
await _send_event(response, 'tool_call', dict(tool=name, args=args))
if args is None:
success, result = False, 'The tool arguments were not valid JSON. Try again.'
else:
success, result = await agent.execute_tool(mode['tools'], name, args)
await _send_event(response, 'tool_result', dict(tool=name, success=success))
messages.append(dict(role='tool', tool_call_id=tool_call['id'] or f'call_{n}',
content=result))
# Tool-call budget exhausted without a final answer.
await _send_event(response, 'error', dict(message=FALLBACK_MESSAGE))
except Exception as e:
logger.error('Chat failed', exc_info=e)
try:
await _send_event(response, 'error', dict(message='The AI failed to answer. Check the logs.'))
except Exception:
pass
finally:
await response.eof()

112
modules/ai/config.py Normal file
View file

@ -0,0 +1,112 @@
"""The ai.yaml config — the source of truth for the AI assistant's state.
AI is opt-in: nothing is downloaded and llama-server does not run until the user enables it on
the Manage tab. YAML-only (no database)."""
import contextlib
from dataclasses import dataclass
from typing import Optional
from wrolpi.common import ConfigFile
@dataclass
class AIConfigValidator:
version: int = None
enabled: bool = None
active_model: str = None
idle_unload_minutes: int = None
context_size: Optional[int] = None
max_tool_calls: int = None
temperature: float = None
class AIConfig(ConfigFile):
file_name = 'ai.yaml'
default_config = dict(
version=0,
enabled=False,
active_model='', # a GGUF file name in <media>/ai/models/
idle_unload_minutes=15,
context_size=None, # None uses the active model's default from the catalog.
max_tool_calls=6,
temperature=0.3, # Low: tool-calling small models are far more reliable near 0.2-0.3.
)
validator = AIConfigValidator
def import_config(self, file=None, send_events=False):
super().import_config(file, send_events)
# YAML-only config (no database); a successful read is a successful import.
self.successful_import = True
@property
def enabled(self) -> bool:
return self._config['enabled']
@enabled.setter
def enabled(self, value: bool):
self.update({'enabled': bool(value)})
@property
def active_model(self) -> str:
return self._config['active_model']
@active_model.setter
def active_model(self, value: str):
self.update({'active_model': value or ''})
@property
def idle_unload_minutes(self) -> int:
return self._config['idle_unload_minutes']
@idle_unload_minutes.setter
def idle_unload_minutes(self, value: int):
self.update({'idle_unload_minutes': int(value)})
@property
def context_size(self) -> Optional[int]:
return self._config['context_size']
@context_size.setter
def context_size(self, value: Optional[int]):
self.update({'context_size': int(value) if value else None})
@property
def max_tool_calls(self) -> int:
return self._config['max_tool_calls']
@max_tool_calls.setter
def max_tool_calls(self, value: int):
self.update({'max_tool_calls': int(value)})
@property
def temperature(self) -> float:
# .get(): configs imported before this field existed lack the key.
return self._config.get('temperature', 0.3)
@temperature.setter
def temperature(self, value: float):
self.update({'temperature': float(value)})
AI_CONFIG: AIConfig = AIConfig()
# Test override (see get_ai_config).
TEST_AI_CONFIG: Optional[AIConfig] = None
def get_ai_config() -> AIConfig:
global TEST_AI_CONFIG
if isinstance(TEST_AI_CONFIG, ConfigFile):
return TEST_AI_CONFIG
return AI_CONFIG
@contextlib.contextmanager
def ai_config_context():
"""Used to create a test config."""
global TEST_AI_CONFIG
TEST_AI_CONFIG = AIConfig()
try:
yield TEST_AI_CONFIG
finally:
TEST_AI_CONFIG = None

17
modules/ai/conftest.py Normal file
View file

@ -0,0 +1,17 @@
# AI module test fixtures. The docs conftest is not star-imported by the root conftest, but the
# AI endpoints cover docs; import its fixtures so AI tests can use doc_factory.
import pytest
from modules.ai.config import ai_config_context
from modules.docs.conftest import * # noqa
from wrolpi.api_utils import api_app
@pytest.fixture
def test_ai_config(test_directory, async_client):
"""An isolated ai.yaml config for one test."""
(test_directory / 'config').mkdir(exist_ok=True)
config_path = test_directory / 'config/ai.yaml'
with ai_config_context() as config:
config.initialize(api_app.shared_ctx.ai_config)
yield config_path

91
modules/ai/context.py Normal file
View file

@ -0,0 +1,91 @@
"""The library map: a compact, live overview of this WROLPi injected into every chat.
Small models answer from what is in context. Without this block the model must spend a tool
round-trip (minutes on slow hardware) just to learn what channels exist — and it still misread
spellings. With it, exact names, counts, and today's date are in front of the model before the
first token. Kept to a few hundred tokens: names are capped and the block is cached briefly."""
import time
from typing import List, Optional
from sqlalchemy import func
from wrolpi.common import logger
from wrolpi.dates import now
from wrolpi.db import get_db_session
logger = logger.getChild(__name__)
# At most this many names per category; a huge library lists the rest as a count.
MAX_NAMES = 30
CACHE_SECONDS = 60
_cache = dict(text=None, expires=0.0)
def _capped_names(names: List[str], label: str) -> str:
names = [i for i in names if i]
if not names:
return f'{label}: none'
listed = ', '.join(sorted(names)[:MAX_NAMES])
extra = f' (and {len(names) - MAX_NAMES} more)' if len(names) > MAX_NAMES else ''
return f'{label} ({len(names)}): {listed}{extra}'
def build_library_context() -> str:
"""Build the library overview. Every query here must stay cheap (counts and name lists)."""
from modules.archive.models import Archive
from modules.docs.models import Doc
from modules.videos.models import Video
from modules.zim.models import Zim
from wrolpi.collections.models import Collection
lines = [f'Today is {now().strftime("%A, %B %d, %Y")}.', "The user's library contains:"]
with get_db_session() as session:
counts = {model.__name__: session.query(func.count(model.id)).scalar() or 0
for model in (Video, Archive, Doc)}
lines.append(f'- {counts["Video"]} videos, {counts["Archive"]} archived web pages,'
f' {counts["Doc"]} documents/ebooks.')
collections = session.query(Collection.kind, Collection.name).all()
channels = [name for kind, name in collections if kind == 'channel']
domains = [name for kind, name in collections if kind == 'domain']
lines.append(f'- {_capped_names(channels, "Video channels")}')
lines.append(f'- {_capped_names(domains, "Archived websites")}')
zims = session.query(Zim).all()
zim_titles = []
for zim in zims:
try:
zim_titles.append(zim.zim_metadata.title)
except Exception:
zim_titles.append(zim.path.name if zim.path else None)
lines.append(f'- {_capped_names(zim_titles, "Zim encyclopedias")}')
try:
from wrolpi import tags
lines.append(f'- {_capped_names([i["name"] for i in tags.get_tags()], "Tags")}')
except Exception as e:
logger.debug('Library context could not list tags', exc_info=e)
try:
from modules.inventory.common import get_inventory_configs
inventories = [i.get('name') for i in get_inventory_configs().all_inventories()]
lines.append(f'- {_capped_names(inventories, "Inventories")}')
except Exception as e:
logger.debug('Library context could not list inventories', exc_info=e)
lines.append('Use the exact names above; user spellings may differ.')
return '\n'.join(lines)
def get_library_context() -> Optional[str]:
"""The cached library overview; None when it cannot be built (chat proceeds without it)."""
if _cache['text'] is not None and time.time() < _cache['expires']:
return _cache['text']
try:
text = build_library_context()
except Exception as e:
logger.warning('Could not build the library context', exc_info=e)
return _cache['text']
_cache.update(text=text, expires=time.time() + CACHE_SECONDS)
return text

View file

@ -0,0 +1,37 @@
"""One helper for API -> Controller requests.
Existing call sites disagree on the base URL (wrolpi/common.py uses http://127.0.0.1, the zim
module uses the docker service name); this module resolves it once: the docker service name when
DOCKERIZED, else localhost, overridable with the CONTROLLER_URL environment variable.
"""
import os
from typing import Tuple
from urllib.parse import urlencode
from wrolpi.common import aiohttp_get, aiohttp_post, logger
from wrolpi.vars import DOCKERIZED
logger = logger.getChild(__name__)
CONTROLLER_URL = os.environ.get('CONTROLLER_URL') or ('http://controller' if DOCKERIZED else 'http://127.0.0.1')
async def controller_get(path: str, params: dict = None, timeout: int = 10) -> Tuple[int, dict]:
"""GET a Controller endpoint, returning (status, JSON body). Raises on connection errors."""
url = f'{CONTROLLER_URL}{path}'
if params:
params = {k: v for k, v in params.items() if v is not None}
if params:
url = f'{url}?{urlencode(params)}'
async with aiohttp_get(url, timeout=timeout) as response:
return response.status, await response.json()
async def controller_post(path: str, json_: dict = None, timeout: int = 30) -> Tuple[int, dict]:
"""POST a Controller endpoint, returning (status, JSON body). Raises on connection errors."""
async with aiohttp_post(f'{CONTROLLER_URL}{path}', json_=json_ or {}, timeout=timeout) as response:
try:
body = await response.json()
except Exception:
body = {}
return response.status, body

120
modules/ai/downloader.py Normal file
View file

@ -0,0 +1,120 @@
"""Downloads GGUF model files from the WROLPi CDN into <media>/ai/models/.
Follows the map-extract pattern: the per-file .meta4 sidecar is fetched once and its hash
signature GPG-verified, then the same bytes are handed to aria2c so the hash that was verified
is the hash that is enforced. Downloads are resumable and respect the media_mounted rules via
the download manager."""
import pathlib
from dataclasses import dataclass
from typing import Optional
from sqlalchemy.orm import Session
from modules.ai.catalog import get_models_directory
from modules.map.downloader import verify_meta4_signature
from wrolpi.common import logger
from wrolpi.downloader import Download, DownloadContext, Downloader, DownloadResult
from wrolpi.events import Events
__all__ = ['ModelDownloader', 'PreparedModel', 'ExecutedModel', 'ai_model_downloader']
logger = logger.getChild(__name__)
@dataclass
class PreparedModel:
"""Plan produced by ModelDownloader.prepare_download."""
name: str
models_directory: pathlib.Path
output_path: pathlib.Path
tmp_path: pathlib.Path
already_done: bool = False
error: Optional[str] = None
@dataclass
class ExecutedModel:
"""Output of ModelDownloader.execute_download."""
name: str
output_path: pathlib.Path
skipped: bool = False
error: Optional[str] = None
class ModelDownloader(Downloader):
"""Downloads one GGUF model file via aria2c with GPG-verified hash checking."""
name = 'ai_model'
listable = False
def prepare_download(self, session: Session, download: Download) -> PreparedModel:
name = download.url.rstrip('/').rsplit('/', 1)[-1]
models_directory = get_models_directory()
output_path = models_directory / name
tmp_path = models_directory / f'{name}.tmp'
if not name.endswith('.gguf'):
return PreparedModel(name=name, models_directory=models_directory, output_path=output_path,
tmp_path=tmp_path, error=f'Not a GGUF model URL: {download.url}')
prepared = PreparedModel(name=name, models_directory=models_directory, output_path=output_path,
tmp_path=tmp_path)
if output_path.is_file():
logger.info(f'{name} already exists, skipping download')
prepared.already_done = True
return prepared
models_directory.mkdir(parents=True, exist_ok=True)
# Clean up any leftover temp file; aria2c resumes its own .aria2 checkpoints.
tmp_path.unlink(missing_ok=True)
return prepared
async def execute_download(self, prepared: PreparedModel, ctx: DownloadContext,
download: Download = None) -> ExecutedModel:
if prepared.error:
return ExecutedModel(name=prepared.name, output_path=prepared.output_path, error=prepared.error)
if prepared.already_done:
return ExecutedModel(name=prepared.name, output_path=prepared.output_path, skipped=True)
url = download.url
# Fetch the meta4 once, GPG-verify the hash signature before downloading.
meta4_contents = await self.get_meta4_contents(url)
if not meta4_contents:
return ExecutedModel(name=prepared.name, output_path=prepared.output_path,
error=f'No meta4 available for {prepared.name}; refusing an unverified download')
if not await verify_meta4_signature(meta4_contents):
return ExecutedModel(name=prepared.name, output_path=prepared.output_path,
error=f'meta4 hash signature verification failed for {prepared.name}')
logger.info(f'meta4 signature verified for {prepared.name}')
downloaded_path = None
try:
downloaded_path = await self.download_file(
download, url, prepared.models_directory,
check_for_meta4=False, concurrent=1, meta4_xml=meta4_contents,
ctx=ctx,
)
downloaded_path.rename(prepared.tmp_path)
except Exception as e:
logger.error(f'Model download failed for {prepared.name}: {e}')
prepared.tmp_path.unlink(missing_ok=True)
if downloaded_path:
downloaded_path.unlink(missing_ok=True)
return ExecutedModel(name=prepared.name, output_path=prepared.output_path,
error=f'Download failed: {str(e)[:500]}')
# Replace atomically.
prepared.tmp_path.rename(prepared.output_path)
return ExecutedModel(name=prepared.name, output_path=prepared.output_path)
def finalize_download(self, session: Session, download: Download, executed: ExecutedModel) -> DownloadResult:
if executed.error:
return DownloadResult(success=False, error=executed.error)
if not executed.skipped:
Events.send_user_notify(f'AI model downloaded: {executed.name}', url='/ai/manage')
return DownloadResult(success=True, location='/ai/manage')
# Instantiating registers with the download manager.
ai_model_downloader = ModelDownloader()

9
modules/ai/errors.py Normal file
View file

@ -0,0 +1,9 @@
from http import HTTPStatus
from wrolpi.errors import APIError
class ControllerUnavailable(APIError):
code = 'CONTROLLER_UNAVAILABLE'
summary = 'The Controller did not answer, or answered with an error.'
status_code = HTTPStatus.BAD_GATEWAY

152
modules/ai/help_docs.py Normal file
View file

@ -0,0 +1,152 @@
"""In-memory search over the WROLPi help markdown.
The help docs are a small corpus (dozens of markdown files) served by mkdocs from the wrolpi-help
repository. They live outside the media directory and get no FileGroups, so they are searched
in-process: files are parsed on demand and cached by mtime. No database, no index to build.
"""
import dataclasses
import os
import pathlib
import re
from typing import Dict, List, Optional
from wrolpi.common import logger
from wrolpi.vars import PROJECT_DIR
logger = logger.getChild(__name__)
# Native installs clone wrolpi-help to /opt/wrolpi-help; development uses the git submodule.
# The docker api container gets a read-only bind mount at the native location.
DEFAULT_HELP_DIRS = (
pathlib.Path('/opt/wrolpi-help/docs'),
PROJECT_DIR / 'docker/help/wrolpi-help/docs',
)
SNIPPET_LENGTH = 200
def get_help_docs_directory() -> Optional[pathlib.Path]:
"""The directory containing the help markdown, or None when help is not installed."""
if directory := os.environ.get('HELP_DOCS_DIR'):
directory = pathlib.Path(directory)
return directory if directory.is_dir() else None
for directory in DEFAULT_HELP_DIRS:
if directory.is_dir():
return directory
return None
@dataclasses.dataclass
class HelpDoc:
slug: str # relative path without .md, e.g. "system/logs"
title: str
headings: List[str]
body: str
mtime: float
# slug -> HelpDoc, invalidated per-file by mtime.
_CACHE: Dict[str, HelpDoc] = {}
def _parse_doc(slug: str, path: pathlib.Path) -> HelpDoc:
body = path.read_text(errors='replace')
title = None
headings = []
for line in body.splitlines():
if match := re.match(r'^(#{1,6})\s+(.+)', line):
heading = match.group(2).strip()
headings.append(heading)
if title is None and match.group(1) == '#':
title = heading
return HelpDoc(slug=slug, title=title or slug, headings=headings, body=body, mtime=path.stat().st_mtime)
def load_help_docs() -> List[HelpDoc]:
"""Load every help markdown file, reusing cached parses of unchanged files."""
directory = get_help_docs_directory()
if not directory or not directory.is_dir():
return []
docs = []
seen = set()
for path in sorted(directory.rglob('*.md')):
slug = str(path.relative_to(directory)).removesuffix('.md')
seen.add(slug)
try:
cached = _CACHE.get(slug)
if not cached or cached.mtime != path.stat().st_mtime:
_CACHE[slug] = _parse_doc(slug, path)
docs.append(_CACHE[slug])
except OSError as e:
logger.warning(f'Could not read help doc {path}', exc_info=e)
# Drop deleted files from the cache.
for slug in set(_CACHE) - seen:
del _CACHE[slug]
return docs
def help_doc_link(slug: str) -> str:
"""The mkdocs URL path of a doc; the help server (port 8086) serves index.md at /."""
if slug == 'index':
return '/'
slug = slug.removesuffix('/index')
return f'/{slug}/'
def _tokenize(text: str) -> List[str]:
return [i for i in re.split(r'\W+', text.lower()) if len(i) >= 2]
def _snippet(body: str, tokens: List[str]) -> Optional[str]:
"""A little context around the first token match in the body."""
lower = body.lower()
position = min((p for token in tokens if (p := lower.find(token)) >= 0), default=-1)
if position < 0:
return None
start = max(position - SNIPPET_LENGTH // 4, 0)
snippet = body[start:start + SNIPPET_LENGTH]
snippet = re.sub(r'\s+', ' ', snippet).strip()
return f'…{snippet}…'
def search_help(search_str: str, limit: int = 5) -> (List[dict], int):
"""Score every help doc against the query; title matches beat heading matches beat body matches."""
tokens = _tokenize(search_str or '')
if not tokens:
return []
scored = []
for doc in load_help_docs():
title = doc.title.lower()
headings = ' '.join(doc.headings).lower()
body = doc.body.lower()
score = 0
for token in tokens:
score += 10 * title.count(token)
score += 4 * headings.count(token)
score += body.count(token)
if score:
scored.append((score, doc))
scored.sort(key=lambda i: (-i[0], i[1].slug))
results = []
for score, doc in scored[:limit]:
result = dict(
slug=doc.slug,
title=doc.title,
link=help_doc_link(doc.slug),
snippet=_snippet(doc.body, tokens),
)
results.append({k: v for k, v in result.items() if v is not None})
return results, len(scored)
def get_help_doc(slug: str) -> Optional[HelpDoc]:
"""Fetch one help doc by its slug. Returns None when it (or the help directory) is missing."""
if '..' in slug.split('/'):
return None
for doc in load_help_docs():
if doc.slug == slug:
return doc
return None

250
modules/ai/lib.py Normal file
View file

@ -0,0 +1,250 @@
"""Formatting helpers for the /api/ai blueprint — the LLM tool catalog.
Results are lean, flat JSON with server-computed links so the model never constructs URLs.
Links are relative to the WROLPi host (e.g. /videos/123); the caller (frontend, MCP client)
prefixes its own base URL.
"""
import pathlib
from typing import Optional
from bs4 import BeautifulSoup
from wrolpi.common import get_relative_to_media_directory, logger
logger = logger.getChild(__name__)
# Big content (captions, archive text, zim entries, help docs) is paged in chunks of this many
# characters, never silently truncated. Sized for small (8k-token) model contexts.
PAGE_SIZE = 4_000
# Description length in a detail response; descriptions in search listings are shorter.
DETAIL_DESCRIPTION_LENGTH = 1_000
LISTING_DESCRIPTION_LENGTH = 300
def _relative_path(path) -> Optional[str]:
"""Return the media-relative string of a path from a FileGroup JSON dict, if possible."""
if not path:
return None
try:
return str(get_relative_to_media_directory(path))
except Exception:
return str(path)
def file_group_kind(fg: dict) -> str:
"""Categorize a FileGroup JSON dict for the model: video/archive/doc/image/file."""
mimetype = fg.get('mimetype') or ''
if fg.get('model') == 'video' or isinstance(fg.get('video'), dict):
return 'video'
if fg.get('model') == 'archive' or isinstance(fg.get('archive'), dict):
return 'archive'
if fg.get('model') == 'doc':
return 'doc'
if mimetype.startswith('image/'):
return 'image'
return 'file'
def wrolpi_link(fg: dict) -> Optional[str]:
"""Build the relative WROLPi UI link for a FileGroup JSON dict.
Videos/docs/archives deep-link by FileGroup ID; epubs open in the reader; everything else is
served directly from /media/ or /download/.
"""
fg_id = fg.get('id')
kind = file_group_kind(fg)
if kind == 'video' and fg_id:
return f'/videos/{fg_id}'
if kind == 'doc' and fg_id:
return f'/docs/{fg_id}'
if kind == 'archive' and fg_id:
return f'/archives/{fg_id}'
mimetype = fg.get('mimetype') or ''
primary_path = _relative_path(fg.get('primary_path'))
if not primary_path:
return None
if mimetype.startswith('application/epub'):
return f'/epub/epub.html?url=/download/{primary_path}'
if mimetype.startswith('application/x-mobipocket-ebook'):
return f'/download/{primary_path}'
# Caddy serves any media file at /media/{path}.
return f'/media/{primary_path}'
def _truncate(text: Optional[str], length: int) -> Optional[str]:
if not text:
return None
return text if len(text) <= length else text[:length] + '…'
def format_duration(seconds) -> Optional[str]:
"""Seconds as H:MM:SS (or M:SS), which a model reads more reliably than raw seconds."""
try:
seconds = int(seconds)
except (TypeError, ValueError):
return None
if seconds < 0:
return None
hours, rest = divmod(seconds, 3600)
minutes, secs = divmod(rest, 60)
return f'{hours}:{minutes:02d}:{secs:02d}' if hours else f'{minutes}:{secs:02d}'
def format_date(value) -> Optional[str]:
"""Only the date of a datetime (or ISO string); the time of day is noise for the model."""
if not value:
return None
if hasattr(value, 'date'):
return value.date().isoformat()
return str(value)[:10]
def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LENGTH,
include_url: bool = False, detail: bool = False) -> dict:
"""Format a FileGroup JSON dict into the lean, flat shape the model consumes.
Listings (detail=False) carry only what the model needs to choose an item: id, kind, title,
link, date, channel/author, duration, tags, and the search headline. Details (detail=True)
add size, mimetype, and the longer description. Everything the model can derive from the id
(captions/text endpoints) is left out. Empty fields are omitted so results stay compact on
small contexts. The source URL is omitted unless requested (archive detail): given both,
small models present the familiar external URL instead of the WROLPi link.
"""
kind = file_group_kind(fg)
result = dict(
id=fg.get('id'),
kind=kind,
title=fg.get('title') or fg.get('name'),
link=wrolpi_link(fg),
published=format_date(fg.get('published_datetime')),
author=fg.get('author'),
tags=fg.get('tags') or None,
url=fg.get('url') if include_url else None,
)
if detail:
result['mimetype'] = fg.get('mimetype')
result['size'] = fg.get('size')
# FTS headline (content match context), if the search requested headlines.
headline = fg.get('d_headline') or fg.get('b_headline') or fg.get('c_headline')
if headline:
result['headline'] = headline
video = fg.get('video')
if kind == 'video':
result['duration'] = format_duration(fg.get('length'))
if isinstance(video, dict):
channel = video.get('channel')
if isinstance(channel, dict):
result['channel'] = channel.get('name') or channel.get('id')
elif video.get('channel_id'):
result['channel_id'] = video['channel_id']
result['description'] = _truncate(video.get('description'), description_length)
if kind == 'doc':
doc = fg.get('doc') or {}
result['subject'] = doc.get('subject')
result['language'] = doc.get('language')
result['pages'] = doc.get('page_count')
result['description'] = _truncate(doc.get('description'), description_length)
return {k: v for k, v in result.items() if v is not None}
def format_file_groups(file_groups: list, total: int, searched: bool = False) -> dict:
"""Format search results: lean items plus the total so the model narrows instead of paging."""
result = dict(results=[format_file_group(i) for i in file_groups], total=total)
if searched and total == 0:
# Small models give up on one empty search; steer the retry.
result['hint'] = ('No matches. Names may be spelled differently: try fewer or different'
' words, use the exact channel/website names from the library list, or omit'
' search_str to browse the newest items.')
return result
def paginate_text(text: Optional[str], offset: int = 0) -> dict:
"""Page big content: ~PAGE_SIZE chars plus next_offset/total_chars, never silent truncation."""
text = text or ''
offset = max(int(offset or 0), 0)
end = offset + PAGE_SIZE
next_offset = end if end < len(text) else None
return dict(content=text[offset:end], next_offset=next_offset, total_chars=len(text))
def html_to_text(html: str) -> str:
"""Strip HTML down to clean plain text for the model. Pagination handles size."""
soup = BeautifulSoup(html, 'html.parser')
for tag in soup(['script', 'style', 'nav', 'footer', 'header']):
tag.decompose()
text = soup.get_text(separator='\n', strip=True)
# Collapse multiple blank lines.
lines = []
prev_blank = False
for line in text.splitlines():
stripped = line.strip()
if not stripped:
if not prev_blank:
lines.append('')
prev_blank = True
else:
lines.append(stripped)
prev_blank = False
return '\n'.join(lines)
def format_caption_chunks(chunks: Optional[list]) -> str:
"""Render caption chunks ({start_seconds, text}) as timestamped lines."""
if not chunks:
return ''
lines = []
for chunk in chunks:
minutes, seconds = divmod(int(chunk.get('start_seconds') or 0), 60)
hours, minutes = divmod(minutes, 60)
lines.append(f'[{hours:02d}:{minutes:02d}:{seconds:02d}] {chunk.get("text") or ""}')
return '\n'.join(lines)
def zim_entry_link(zim_id: int, path: str) -> str:
return f'/api/zim/{zim_id}/entry/{path}'
def zim_metadata_dict(metadata) -> dict:
"""Zim lib functions return a ZimMetadata object; normalize it to a dict."""
if metadata is None:
return {}
return metadata if isinstance(metadata, dict) else metadata.__json__()
def format_zim_search(zim_results: list) -> dict:
"""Flatten per-Zim search results into lean entries with links."""
results = []
total = 0
for zim_result in zim_results:
metadata = zim_metadata_dict(zim_result.get('metadata'))
zim_title = metadata.get('title')
total += zim_result.get('estimate') or 0
for entry in zim_result.get('search') or []:
zim_id = entry.get('zim_id')
path = entry.get('path')
result = dict(
zim_id=zim_id,
zim=zim_title,
path=path,
title=entry.get('title'),
headline=entry.get('headline'),
link=zim_entry_link(zim_id, path) if zim_id and path else None,
)
results.append({k: v for k, v in result.items() if v is not None})
return dict(results=results, total=total)
def read_archive_text(archive) -> Optional[str]:
"""Return the readable plain text of an Archive: readability text, else stripped singlefile."""
if (path := archive.readability_txt_path) and pathlib.Path(path).is_file():
return pathlib.Path(path).read_text(errors='replace')
if (path := archive.singlefile_path) and pathlib.Path(path).is_file():
return html_to_text(pathlib.Path(path).read_text(errors='replace'))
return None

144
modules/ai/modes.py Normal file
View file

@ -0,0 +1,144 @@
"""Chat modes: a mode is a system prompt plus a tool allowlist, enforced server-side in the agent
loop — the model cannot opt out. Tool names are the /api/ai blueprint's operationIds."""
COMMON_PROMPT = (
'You are the WROLPi assistant, running locally on the user\'s own offline WROLPi server.'
' WROLPi is an off-grid digital library: videos, archived web pages, ebooks, Zim encyclopedias,'
' maps, and inventories. You have no internet access.'
' Answer briefly and plainly. Use the provided tools to find real information before answering;'
' never invent library content or system state.'
)
MODES = {
'help': dict(
system_prompt=(
f'{COMMON_PROMPT}'
' Your job is to teach the user how to use WROLPi.'
' You do NOT know how WROLPi works from memory: before answering ANY question, you MUST'
' first call search_help, then read the matching page with get_help_doc, and answer only'
' from what the docs actually say. Always include the link of every help page you used.'
' If the docs do not cover the question, say so.'
),
tools=(
'search_help',
'get_help_doc',
'get_system_status',
),
),
'research': dict(
system_prompt=(
f'{COMMON_PROMPT}'
' Your job is to be the librarian: find and summarize content from the user\'s library.'
' Search first with search_files, then read captions, page text, or entries with read_content'
' when the user wants detail. The library list above has the exact channel, website, and'
' Zim names (spellings often differ): pass a channel or domain name to search_files directly.'
' To summarize a channel or browse everything of one kind, call search_files WITHOUT search_str'
' and with the channel, domain, or kind; that lists the newest items.'
' When a search finds nothing, do not give up: retry with fewer or different words, or drop the'
' filters. When it offers matches, narrow with one of them.'
' ALWAYS include the WROLPi link for every item you mention, exactly as the tools return it.'
' Never give external URLs. When a search returns a large total, narrow the query instead'
' of paging.'
),
tools=(
'search_files',
'get_file',
'read_content',
'search_zims',
'get_zim_entry',
'get_inventory',
'list_files',
'read_file',
'search_places',
),
),
'system': dict(
system_prompt=(
f'{COMMON_PROMPT}'
' Your job is to troubleshoot the user\'s WROLPi.'
' You never know the current state of this system from memory or from example'
' conversations: EVERY answer about services, disks, or health starts with a fresh'
' get_system_status call. Read a failing service\'s logs with get_service_logs;'
' check drives with list_disks.'
' You CANNOT execute commands or change anything — you can only read.'
' Only suggest commands that appear in the help documentation: find them with search_help,'
' quote them exactly, and include the help page link.'
),
tools=(
'get_system_status',
'list_services',
'get_service_logs',
'list_disks',
'search_help',
'get_help_doc',
),
),
}
def _example(*messages) -> list:
"""A compact worked exchange (user -> tool call -> tool result -> answer) prepended to every
conversation. Small models imitate far better than they obey; one example per mode is what
stops them answering from memory or quitting after the first tool call."""
return list(messages)
def _tool_call(call_id: str, name: str, arguments: str) -> dict:
return dict(role='assistant', content=None, tool_calls=[
dict(id=call_id, type='function', function=dict(name=name, arguments=arguments))])
def _tool_result(call_id: str, content: str) -> dict:
return dict(role='tool', tool_call_id=call_id, content=content)
MODE_EXAMPLES = {
# The example's content must be TRUE (from the real help docs): when a user asks about the
# example's topic, a small model may answer straight from it without searching.
'help': _example(
dict(role='user', content='Why does my browser warn about the certificate?'),
_tool_call('ex_help_1', 'search_help', '{"search_str": "certificate"}'),
_tool_result('ex_help_1',
'{"results": [{"slug": "system/certificates", "title": "HTTPS Certificates",'
' "link": "/system/certificates/",'
' "snippet": "…WROLPi generates its own Root CA certificate…"}], "total": 1}'),
_tool_call('ex_help_2', 'get_help_doc', '{"slug": "system/certificates"}'),
_tool_result('ex_help_2',
'{"content": "# HTTPS Certificates\\nWROLPi is offline, so it cannot use a public'
' certificate authority. It generates its own Root CA certificate which you install'
' once on each device that connects to it.", "next_offset": null, "total_chars": 205}'),
dict(role='assistant', content='WROLPi is offline, so it cannot use a public certificate'
' authority; it signs its own certificate with a Root CA that you'
' install once on each device. The HTTPS Certificates help page'
' explains how: /system/certificates/'),
),
'research': _example(
dict(role='user', content='Summarize the bakehouse channel'),
_tool_call('ex_res_1', 'search_files', '{"channel": "Bakehouse"}'),
_tool_result('ex_res_1',
'{"results": [{"id": 12, "kind": "video", "title": "Sourdough Basics",'
' "link": "/videos/12", "channel": "Bakehouse", "duration": "15:00"}], "total": 1}'),
dict(role='assistant', content='The Bakehouse channel has one video:\\n'
'1. **Sourdough Basics** (/videos/12) — a 15 minute introduction'
' to sourdough baking.'),
),
# No invented failures in the example: the model carries them into real diagnoses (it kept
# investigating a fictional failed service the first version of this example described).
'system': _example(
dict(role='user', content='Is everything running?'),
_tool_call('ex_sys_1', 'get_system_status', '{}'),
_tool_result('ex_sys_1',
'{"version": "0.28", "wrol_mode": false, "services": [{"name": "wrolpi-api",'
' "status": "running"}, {"name": "wrolpi-app", "status": "running"}], "errors": []}'),
dict(role='assistant', content='The status shows both services (wrolpi-api, wrolpi-app)'
' running on version 0.28, with no errors reported.'),
),
}
# Suggestion chips shown by the Chat tab; small models do much better when the opening message
# matches what the mode's prompt was tuned for.
MODE_SUGGESTIONS = {
'help': ['How do I download videos?', 'How do I add my own files?', 'What are Tags?'],
'research': ['Find videos about canning', 'What do I have about gardening?', 'Summarize my newest archives'],
'system': ['Why is my drive full?', 'Is everything running?', 'Why do downloads fail?'],
}

59
modules/ai/schema.py Normal file
View file

@ -0,0 +1,59 @@
import dataclasses
from typing import List, Optional
@dataclasses.dataclass
class AIZimSearchRequest:
search_str: Optional[str] = None
zim_id: Optional[int] = None
limit: int = 5
offset: int = 0
@dataclasses.dataclass
class AIHelpSearchRequest:
search_str: Optional[str] = None
limit: int = 5
@dataclasses.dataclass
class AIManageSettingsRequest:
enabled: Optional[bool] = None
active_model: Optional[str] = None
idle_unload_minutes: Optional[int] = None
context_size: Optional[int] = None
@dataclasses.dataclass
class AISearchResponse:
results: list = dataclasses.field(default_factory=list)
total: int = 0
@dataclasses.dataclass
class AIPagedTextResponse:
content: str = ''
next_offset: Optional[int] = None
total_chars: int = 0
@dataclasses.dataclass
class AIListFilesResponse:
path: str = ''
directories: list = dataclasses.field(default_factory=list)
files: list = dataclasses.field(default_factory=list)
total: int = 0
next_offset: Optional[int] = None
@dataclasses.dataclass
class AIFileSearchRequest:
search_str: Optional[str] = None
kind: Optional[str] = None
channel: Optional[str] = None
domain: Optional[str] = None
author: Optional[str] = None
subject: Optional[str] = None
tag_names: Optional[List[str]] = None
limit: int = 5
offset: int = 0

112
modules/ai/service.py Normal file
View file

@ -0,0 +1,112 @@
"""llama-server lifecycle: on-demand start and idle unload.
The API owns the policy (this module); the Controller executes the mechanism through its
existing service start/stop endpoints — it runs as root natively and manages the `llm`
container in docker, so no sudoers changes are needed.
Idle unload is mandatory on small devices: a swapped-to-death Pi is worse than a slow first
token. The chat handler records activity; the perpetual worker stops llama-server after the
configured idle minutes."""
import asyncio
import os
import time
from modules.ai.config import get_ai_config
from modules.ai.controller_client import controller_post
from wrolpi.api_utils import api_app, perpetual_signal
from wrolpi.common import aiohttp_get, logger
from wrolpi.vars import DOCKERIZED
logger = logger.getChild(__name__)
LLAMA_URL = os.environ.get('LLAMA_URL', 'http://127.0.0.1:11435')
# The Controller's name for the service: the systemd unit natively, the compose service in docker.
LLM_SERVICE_NAME = 'llm' if DOCKERIZED else 'wrolpi-llm'
# How long to wait for llama-server to answer /health after a cold start (model load included).
STARTUP_TIMEOUT_SECONDS = 90
def record_llm_activity():
"""Note that the LLM was just used; postpones the idle unload."""
try:
api_app.shared_ctx.ai_llm_last_request.value = time.time()
except AttributeError:
# Shared contexts are absent in some unit tests.
pass
def get_last_llm_activity() -> float:
try:
return api_app.shared_ctx.ai_llm_last_request.value
except AttributeError:
return 0.0
async def llama_healthy() -> bool:
try:
async with aiohttp_get(f'{LLAMA_URL}/health', timeout=5) as response:
return response.status == 200
except Exception:
return False
async def start_llm_service() -> bool:
status, body = await controller_post(f'/api/services/{LLM_SERVICE_NAME}/start')
if status != 200:
logger.error(f'Controller failed to start {LLM_SERVICE_NAME}: HTTP {status} {body}')
return False
return True
async def stop_llm_service() -> bool:
status, body = await controller_post(f'/api/services/{LLM_SERVICE_NAME}/stop')
if status != 200:
logger.error(f'Controller failed to stop {LLM_SERVICE_NAME}: HTTP {status} {body}')
return False
return True
async def ensure_llama_running(timeout: int = STARTUP_TIMEOUT_SECONDS) -> bool:
"""Start llama-server on demand and wait for it to answer /health.
Returns True when llama-server is up. Cold-start latency (service start + model load) is
expected and acceptable."""
if await llama_healthy():
record_llm_activity()
return True
logger.info(f'Asking the Controller to start {LLM_SERVICE_NAME}')
if not await start_llm_service():
return False
started = time.time()
while time.time() - started < timeout:
if await llama_healthy():
record_llm_activity()
return True
await asyncio.sleep(1)
logger.error(f'llama-server did not become healthy within {timeout}s')
return False
@perpetual_signal(sleep=60)
async def perpetual_llm_idle_unload():
"""Stop llama-server after the configured idle minutes. Only acts after it was used."""
try:
last_activity = get_last_llm_activity()
if not last_activity:
return
config = get_ai_config()
idle_seconds = config.idle_unload_minutes * 60
if time.time() - last_activity < idle_seconds:
return
if not await llama_healthy():
# Already stopped (or crashed); nothing to unload.
api_app.shared_ctx.ai_llm_last_request.value = 0.0
return
logger.info(f'Stopping {LLM_SERVICE_NAME} after {config.idle_unload_minutes} idle minutes')
if await stop_llm_service():
api_app.shared_ctx.ai_llm_last_request.value = 0.0
except Exception as e:
logger.error('LLM idle unload worker failed', exc_info=e)

View file

View file

@ -0,0 +1,135 @@
"""Tests for tool-definition generation and tool execution."""
import json
from unittest import mock
from urllib.parse import urlencode
import pytest
from modules.ai import agent
from modules.ai.modes import MODES
@pytest.fixture
async def tool_definitions(async_client):
# Any request finalizes the router, which the spec builder requires.
await async_client.get('/api/ai/zims')
return agent.get_ai_tool_definitions(refresh=True)
@pytest.mark.asyncio
async def test_tool_definitions_from_spec(tool_definitions):
"""Tool definitions are generated from the blueprint's own OpenAPI operations."""
tools = tool_definitions
# Every tool any mode allowlists must exist — this guards allowlists against drift.
for mode_name, mode in MODES.items():
for tool_name in mode['tools']:
assert tool_name in tools, f'Mode {mode_name} allowlists missing tool {tool_name}'
# Every tool has a model-facing description.
for name, tool in tools.items():
assert tool.description, f'Tool {name} has no description'
# Manage endpoints are never tools.
assert not any(t.path.startswith('/api/ai/manage') for t in tools.values())
assert 'manage_catalog' not in tools and 'manage_settings' not in tools
# The chat endpoint itself is not a tool.
assert not any(t.path.startswith('/api/chat') for t in tools.values())
# Body, query, and path parameters all appear in the flat schema.
schema = tools['search_files'].parameters_schema()
assert schema['properties']['search_str'] == {'type': 'string'}
assert schema['properties']['tag_names'] == {'type': 'array', 'items': {'type': 'string'}}
schema = tools['read_content'].parameters_schema()
assert schema['required'] == ['file_group_id']
assert schema['properties']['offset'] == {'type': 'integer'}
# OpenAI tool format.
openai_tool = tools['search_files'].as_openai_tool()
assert openai_tool['type'] == 'function'
assert openai_tool['function']['name'] == 'search_files'
def _endpoint_shim(async_client):
async def shim(method, path, query, body):
url = f'{path}?{urlencode(query)}' if query else path
if method == 'post':
request, response = await async_client.post(url, content=json.dumps(body or {}))
else:
request, response = await async_client.get(url)
return response.status_code, response.json
return mock.patch('modules.ai.agent.call_ai_endpoint', side_effect=shim)
@pytest.mark.asyncio
async def test_execute_tool(async_client, tool_definitions, video_factory, test_session):
"""Tools execute against the blueprint; every failure is an instructive error string."""
video_factory(title='pressure canning')
test_session.commit()
research_tools = MODES['research']['tools']
with _endpoint_shim(async_client):
# A real search through the blueprint.
success, result = await agent.execute_tool(research_tools, 'search_files',
dict(search_str='canning', kind='video'))
assert success is True
result = json.loads(result)
assert result['total'] == 1
# Unknown tool.
success, error = await agent.execute_tool(research_tools, 'delete_everything', {})
assert success is False and 'Unknown tool' in error
# Mode allowlist: research mode cannot read service logs even though the tool exists.
success, error = await agent.execute_tool(research_tools, 'get_service_logs',
dict(name='wrolpi-api'))
assert success is False and 'Unknown tool' in error
# Unknown argument names the accepted ones.
success, error = await agent.execute_tool(research_tools, 'search_files', dict(query='x'))
assert success is False and 'accepts' in error and 'search_str' in error
# Missing required path parameter.
success, error = await agent.execute_tool(research_tools, 'read_content', {})
assert success is False and 'Missing required' in error
# Wrong type.
success, error = await agent.execute_tool(research_tools, 'read_content',
dict(file_group_id='banana'))
assert success is False and 'must be a integer' in error
# An endpoint error comes back as a message, not an exception.
success, error = await agent.execute_tool(research_tools, 'read_content',
dict(file_group_id=123456))
assert success is False and 'error' in error.lower()
@pytest.mark.asyncio
async def test_execute_tool_path_traversal(async_client, tool_definitions):
"""A crafted path parameter cannot escape the tool's endpoint (allowlist bypass)."""
help_tools = MODES['help']['tools']
with _endpoint_shim(async_client):
for slug in ('../../manage/catalog', '../manage/settings', '/etc/passwd',
'a/../../files/refresh', 'a//b', 'a/./b'):
success, error = await agent.execute_tool(help_tools, 'get_help_doc', dict(slug=slug))
assert success is False, f'traversal not refused: {slug}'
assert 'traversal' in error or 'invalid path' in error
# Legitimate nested slugs keep working (404 from the endpoint is fine, no traversal error).
success, error = await agent.execute_tool(help_tools, 'get_help_doc', dict(slug='system/logs'))
assert 'traversal' not in error and 'invalid path' not in (error or '')
@pytest.mark.asyncio
async def test_execute_tool_result_truncated(async_client, tool_definitions, archive_factory, test_session):
"""A huge tool result is capped with a paging hint."""
archive_factory(domain='example.com', title='big page', contents='word ' * 5_000)
test_session.commit()
from modules.archive.models import Archive
archive = test_session.query(Archive).one()
with _endpoint_shim(async_client):
success, result = await agent.execute_tool(MODES['research']['tools'], 'read_content',
dict(file_group_id=archive.file_group_id))
assert success is True
assert len(result) <= agent.TOOL_RESULT_MAX_CHARS + 100

485
modules/ai/test/test_api.py Normal file
View file

@ -0,0 +1,485 @@
"""Tests for the /api/ai blueprint — the LLM tool catalog."""
import json
from http import HTTPStatus
import pytest
from modules.ai import lib
@pytest.mark.asyncio
async def test_ai_zims(async_client, test_session, test_zim):
"""Zims can be listed, searched, and their entries read as paged text."""
request, response = await async_client.get('/api/ai/zims')
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 1
zim_id = response.json['results'][0]['id']
# Search one specific Zim.
content = dict(search_str='item', zim_id=zim_id)
request, response = await async_client.post('/api/ai/zims/search', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
assert response.json['total'] >= 1
result = response.json['results'][0]
assert result['zim_id'] == zim_id
assert result['link'] == f'/api/zim/{zim_id}/entry/{result["path"]}'
# An empty search is a clear error for the model.
request, response = await async_client.post('/api/ai/zims/search', content=json.dumps(dict()))
assert response.status_code == HTTPStatus.BAD_REQUEST
# Read the entry the search found.
request, response = await async_client.get(f'/api/ai/zims/{zim_id}/entry?path={result["path"]}')
assert response.status_code == HTTPStatus.OK
assert response.json['content']
assert response.json['link'] == f'/api/zim/{zim_id}/entry/{result["path"]}'
# The path is required.
request, response = await async_client.get(f'/api/ai/zims/{zim_id}/entry')
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_ai_list_collections(async_client, test_session, archive_factory, channel_factory):
"""Collections are listed paged, filtered by kind, and searchable by name."""
archive_factory(domain='example.com', title='a page', contents='contents')
for name in ('Alpha Cooking', 'Beta Cooking', 'Gamma Woodwork'):
channel_factory(name=name)
test_session.commit()
request, response = await async_client.get('/api/ai/collections?kind=domain')
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 1
assert response.json['results'][0]['name'] == 'example.com'
assert response.json['results'][0]['kind'] == 'domain'
assert response.json['next_offset'] is None
# Paged, with the full total so the model knows how many there are.
request, response = await async_client.get('/api/ai/collections?kind=channel&limit=2')
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 3 and len(response.json['results']) == 2
assert response.json['next_offset'] == 2
request, response = await async_client.get('/api/ai/collections?kind=channel&limit=2&offset=2')
assert [i['name'] for i in response.json['results']] == ['Gamma Woodwork']
assert response.json['next_offset'] is None
# Name search.
request, response = await async_client.get('/api/ai/collections?search_str=cooking')
assert response.status_code == HTTPStatus.OK
assert sorted(i['name'] for i in response.json['results']) == ['Alpha Cooking', 'Beta Cooking']
@pytest.mark.asyncio
async def test_ai_read_file(async_client, test_directory):
"""Text files in the media directory can be read; everything else is refused."""
(test_directory / 'notes').mkdir()
(test_directory / 'notes/todo.txt').write_text('buy more rice\n' * 400) # > one page
request, response = await async_client.get('/api/ai/files/read?path=notes/todo.txt')
assert response.status_code == HTTPStatus.OK
assert response.json['content'].startswith('buy more rice')
assert response.json['next_offset'] == lib.PAGE_SIZE
# Missing path parameter.
request, response = await async_client.get('/api/ai/files/read')
assert response.status_code == HTTPStatus.BAD_REQUEST
# Missing file.
request, response = await async_client.get('/api/ai/files/read?path=notes/nope.txt')
assert response.status_code == HTTPStatus.NOT_FOUND
# Binary files are refused.
(test_directory / 'notes/blob.bin').write_bytes(b'\x00\x01\x02')
request, response = await async_client.get('/api/ai/files/read?path=notes/blob.bin')
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_ai_read_file_refuses_escapes(async_client, test_directory):
"""Reads outside the media directory, and of config files, are refused."""
# Path traversal.
request, response = await async_client.get('/api/ai/files/read?path=../secrets.txt')
assert response.status_code == HTTPStatus.BAD_REQUEST
# The config directory can contain secrets.
(test_directory / 'config').mkdir(exist_ok=True)
(test_directory / 'config/wrolpi.yaml').write_text('secret: hunter2')
request, response = await async_client.get('/api/ai/files/read?path=config/wrolpi.yaml')
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_ai_list_files(async_client, test_directory):
"""Directories in the media directory can be listed: directories first, then files, both sorted."""
(test_directory / 'videos/channel').mkdir(parents=True)
(test_directory / 'videos/channel/b.mp4').write_bytes(b'x' * 10)
(test_directory / 'videos/channel/a.mp4').write_bytes(b'x' * 20)
(test_directory / 'videos/channel/a.en.vtt').write_text('WEBVTT')
(test_directory / 'videos/channel/.DS_Store').write_bytes(b'\x00') # macOS junk is hidden
(test_directory / 'videos/other').mkdir()
(test_directory / 'notes.txt').write_text('hi')
(test_directory / 'lost+found').mkdir() # hidden
(test_directory / 'config').mkdir(exist_ok=True) # hidden: may hold secrets
# The root is listed when no path is given.
request, response = await async_client.get('/api/ai/files/list')
assert response.status_code == HTTPStatus.OK
assert response.json['path'] == ''
assert [i['path'] for i in response.json['directories']] == ['videos/']
assert [i['path'] for i in response.json['files']] == ['notes.txt']
assert response.json['total'] == 2
assert response.json['next_offset'] is None
request, response = await async_client.get('/api/ai/files/list?path=videos/channel')
assert response.status_code == HTTPStatus.OK
assert response.json['path'] == 'videos/channel/'
assert response.json['directories'] == []
assert [(i['path'], i['size']) for i in response.json['files']] == [
('videos/channel/a.en.vtt', 6),
('videos/channel/a.mp4', 20),
('videos/channel/b.mp4', 10),
]
assert response.json['files'][1]['mimetype'] == 'video/mp4'
# Trailing slashes and leading slashes are tolerated.
request, response = await async_client.get('/api/ai/files/list?path=/videos/')
assert response.status_code == HTTPStatus.OK
assert [i['path'] for i in response.json['directories']] == ['videos/channel/', 'videos/other/']
assert response.json['files'] == []
# Missing directory, and a file rather than a directory.
request, response = await async_client.get('/api/ai/files/list?path=nope')
assert response.status_code == HTTPStatus.NOT_FOUND
request, response = await async_client.get('/api/ai/files/list?path=notes.txt')
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_ai_list_files_paged(async_client, test_directory):
"""Big directories are paged so a single listing cannot blow the model's context."""
from modules.ai.api import LIST_PAGE_SIZE
(test_directory / 'big').mkdir()
for i in range(LIST_PAGE_SIZE + 5):
(test_directory / f'big/{i:04d}.txt').write_text('x')
request, response = await async_client.get('/api/ai/files/list?path=big')
assert response.status_code == HTTPStatus.OK
assert len(response.json['files']) == LIST_PAGE_SIZE
assert response.json['total'] == LIST_PAGE_SIZE + 5
assert response.json['next_offset'] == LIST_PAGE_SIZE
request, response = await async_client.get(f'/api/ai/files/list?path=big&offset={LIST_PAGE_SIZE}')
assert response.status_code == HTTPStatus.OK
assert [i['name'] for i in response.json['files']] == [f'{i:04d}.txt' for i in range(LIST_PAGE_SIZE, LIST_PAGE_SIZE + 5)]
assert response.json['next_offset'] is None
@pytest.mark.asyncio
async def test_ai_list_files_refuses_escapes(async_client, test_directory):
"""Listings outside the media directory, and inside the config directory, are refused."""
request, response = await async_client.get('/api/ai/files/list?path=../')
assert response.status_code == HTTPStatus.BAD_REQUEST
(test_directory / 'config').mkdir(exist_ok=True)
request, response = await async_client.get('/api/ai/files/list?path=config')
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_ai_list_tags(async_client, test_session, tag_factory, video_factory):
"""Tags are listed with what they are applied to, plus the recently used names."""
one = await tag_factory('one')
await tag_factory('two')
video = video_factory(title='tagged video')
video.file_group.add_tag(test_session, one.id)
test_session.commit()
request, response = await async_client.get('/api/ai/tags')
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 2
assert [i['name'] for i in response.json['results']] == ['one', 'two']
assert response.json['results'][0]['file_groups'] == 1
assert response.json['results'][1]['file_groups'] == 0
assert response.json['recent'] == ['one']
@pytest.mark.asyncio
async def test_ai_list_downloads(async_client, test_session, test_download_manager, test_downloader,
test_directory):
"""The download queue is summarized with lean, relative-path entries; status can filter."""
test_download_manager.create_download(test_session, 'https://example.com/once', test_downloader.name,
destination=test_directory / 'archive/example.com')
test_download_manager.recurring_download(test_session, 'https://example.com/feed', 86400,
test_downloader.name)
test_session.commit()
request, response = await async_client.get('/api/ai/downloads')
assert response.status_code == HTTPStatus.OK
assert response.json['summary']['recurring'] == 1
assert 'disabled' in response.json['summary']
once, = response.json['once']
assert once['url'] == 'https://example.com/once'
assert once['destination'] == 'archive/example.com'
assert once['status'] == 'new'
assert once['downloader'] == test_downloader.name
recurring, = response.json['recurring']
assert recurring['url'] == 'https://example.com/feed'
assert recurring['frequency'] == 86400
# Filter by status.
request, response = await async_client.get('/api/ai/downloads?status=complete')
assert response.status_code == HTTPStatus.OK
assert response.json['once'] == [] and response.json['recurring'] == []
@pytest.mark.asyncio
async def test_ai_map(async_client, test_session, test_directory, make_files_structure):
"""The map overview reports map files, subscriptions, pins, and search indexes; places can be searched."""
from modules.map.pins import get_map_pins_config
from modules.map.test.test_search import _create_test_search_db
make_files_structure(['map/oregon.pmtiles'])
_create_test_search_db(test_directory / 'map/oregon.search.db', [
('Portland', 'city', 45.5, -122.6, 6, 'places', 'city', 650000, 'Oregon'),
('Port Orford', 'town', 42.7, -124.5, 9, 'places', 'town', 1100, 'Oregon'),
])
get_map_pins_config().add_pin(45.1, -122.2, 'Home')
request, response = await async_client.get('/api/ai/map')
assert response.status_code == HTTPStatus.OK
files, = response.json['files']
assert files['name'] == 'oregon.pmtiles' and files['has_search_index'] is True
assert response.json['subscriptions'] == []
pin, = response.json['pins']
assert pin['label'] == 'Home' and pin['link'] == '/map?lat=45.1&lon=-122.2&z=12'
request, response = await async_client.get('/api/ai/map/search?q=port')
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 2
first = response.json['results'][0]
assert first['name'] == 'Portland'
assert first['region'] == 'Oregon'
assert first['link'] == '/map?lat=45.5&lon=-122.6&z=12'
# Proximity ranking within an importance tier: equal min_zoom, nearest first.
_create_test_search_db(test_directory / 'map/coast.search.db', [
('Port A', 'town', 42.0, -124.0, 9, 'places', 'town', 100, 'Oregon'),
('Port B', 'town', 44.0, -124.0, 9, 'places', 'town', 100, 'Oregon'),
])
request, response = await async_client.get('/api/ai/map/search?q=port%20b&lat=44.0&lon=-124.0')
assert response.status_code == HTTPStatus.OK
assert response.json['results'][0]['name'] == 'Port B'
request, response = await async_client.get('/api/ai/map/search?q=port&lat=42.0&lon=-124.0&limit=2')
assert response.status_code == HTTPStatus.OK
assert len(response.json['results']) == 2 and response.json['total'] == 4
# q is required.
request, response = await async_client.get('/api/ai/map/search')
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_ai_format_download_error_tail():
"""Download errors are tracebacks; the model gets the end (the exception message), not the start."""
from modules.ai.api import _format_download, DOWNLOAD_ERROR_LENGTH
error = 'Traceback (most recent call last):\n' + (' File "x.py"\n' * 100) + 'ValueError: the real reason'
formatted = _format_download(dict(id=1, url='https://example.com', status='failed', error=error))
assert formatted['error'].endswith('ValueError: the real reason')
assert len(formatted['error']) == DOWNLOAD_ERROR_LENGTH + 1
assert 'error' not in _format_download(dict(id=2, url='https://example.com', status='new', error=None))
# ---------------------------------------------------------------------------
# Consolidated, kind-generic endpoints for small models.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ai_search_files_kinds(async_client, test_session, video_factory, archive_factory, channel_factory):
"""One search covers every kind; kind narrows it; channel accepts a name or an id."""
channel = channel_factory(name='Wild Cooking')
video_factory(channel_id=channel.id, title='cooking rice')
video_factory(title='cooking beans video')
archive_factory(domain='cooking.example.com', title='cooking beans', contents='beans')
test_session.commit()
async def search(**body):
request, response = await async_client.post('/api/ai/files/search', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK, response.json
return response.json
data = await search(search_str='cooking')
assert data['total'] == 3
assert {i['kind'] for i in data['results']} == {'video', 'archive'}
# The matching channel and domain are offered so the model can narrow without another tool.
assert [i['name'] for i in data['matches']['channels']] == ['Wild Cooking']
assert data['matches']['channels'][0]['id'] == channel.id
assert [i['name'] for i in data['matches']['domains']] == ['cooking.example.com']
data = await search(search_str='cooking', kind='video')
assert data['total'] == 2 and all(i['kind'] == 'video' for i in data['results'])
data = await search(search_str='cooking', kind='archive')
assert data['total'] == 1 and data['results'][0]['kind'] == 'archive'
# channel by exact name, partial name, and numeric id; channel implies kind=video.
for channel_ref in ('Wild Cooking', 'wild', str(channel.id)):
data = await search(search_str='cooking', channel=channel_ref)
assert data['total'] == 1, channel_ref
assert data['results'][0]['title'] == 'cooking rice'
assert 'matches' not in data # already narrowed to one channel
# domain implies kind=archive.
data = await search(domain='cooking.example.com')
assert data['total'] == 1 and data['results'][0]['kind'] == 'archive'
# A filter that does not apply to the kind is ignored, not fatal.
data = await search(search_str='cooking', kind='archive', channel='Wild Cooking')
assert data['total'] == 1 and data['results'][0]['kind'] == 'archive'
# An unknown channel name gives no results and a hint, not an error.
data = await search(search_str='cooking', channel='No Such Channel')
assert data['total'] == 0 and data['results'] == []
assert 'channel' in data['hint'].lower()
# Listings are slim: ids, titles, links, kind; no links the model can derive.
result = data = (await search(search_str='cooking', kind='video'))['results'][0]
assert {'id', 'kind', 'title', 'link'} <= set(result)
assert 'captions_link' not in result and 'size' not in result and 'mimetype' not in result
# No filters at all browses the newest items.
request, response = await async_client.post('/api/ai/files/search', content=json.dumps({}))
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 3
# The limit is clamped and total reports every match so the model narrows instead of paging.
data = await search(search_str='cooking', limit=1)
assert len(data['results']) == 1 and data['total'] == 3
data = await search(search_str='cooking', limit=10_000)
assert len(data['results']) == 3
# An unknown kind is refused.
request, response = await async_client.post('/api/ai/files/search', content=json.dumps(dict(kind='song')))
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_ai_search_files_docs(async_client, test_session, test_directory, example_epub, refresh_files):
"""kind=doc searches documents; author/subject imply kind=doc."""
await refresh_files()
request, response = await async_client.post('/api/ai/files/search', content=json.dumps(dict(search_str='WROLPi', kind='doc')))
assert response.status_code == HTTPStatus.OK
assert response.json['total'] >= 1
doc = response.json['results'][0]
assert doc['kind'] == 'doc' and doc['link'] == f'/docs/{doc["id"]}'
# get_file returns the doc detail through the generic route.
request, response = await async_client.get(f'/api/ai/files/{doc["id"]}')
assert response.status_code == HTTPStatus.OK
assert response.json['kind'] == 'doc' and response.json['id'] == doc['id']
@pytest.mark.asyncio
async def test_ai_get_file(async_client, test_session, video_factory, archive_factory, make_files_structure, tag_factory):
"""get_file dispatches on the file's model and carries more than the listing row."""
tag = await tag_factory('food')
video = video_factory(title='wood stove install', with_caption_file=True)
video.file_group.add_tag(test_session, tag.id)
archive_factory(domain='example.com', url='https://example.com/a', title='first', contents='one')
archive = archive_factory(domain='example.com', url='https://example.com/a', title='second', contents='two')
test_session.commit()
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}')
assert response.status_code == HTTPStatus.OK
assert response.json['kind'] == 'video'
assert response.json['link'] == f'/videos/{video.file_group_id}'
assert response.json['has_captions'] is True
assert response.json['tags'] == ['food']
assert response.json['mimetype'].startswith('video/')
# Fetching does not mark the video viewed.
test_session.expire_all()
assert video.file_group.viewed is None
request, response = await async_client.get(f'/api/ai/files/{archive.file_group_id}')
assert response.status_code == HTTPStatus.OK
assert response.json['kind'] == 'archive'
assert response.json['url'] == 'https://example.com/a'
assert [i['title'] for i in response.json['history']] == ['first']
# A plain file (no model) still resolves with a media link.
from wrolpi.files.lib import upsert_file
path, = make_files_structure({'photos/a.png': 'not really a png'})
fg = await upsert_file(path)
request, response = await async_client.get(f'/api/ai/files/{fg.id}')
assert response.status_code == HTTPStatus.OK
assert response.json['link'] == '/media/photos/a.png'
request, response = await async_client.get('/api/ai/files/123456')
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_ai_read_content(async_client, test_session, video_factory, archive_factory):
"""read_content serves captions/comments for videos and text for archives, paged."""
info_json = dict(duration=5, comments=[dict(author='alice', text='great video')])
video = video_factory(title='captioned', with_caption_file=True, with_info_json=info_json)
contents = 'word ' * 2_000
archive = archive_factory(domain='example.com', title='long read', contents=contents)
test_session.commit()
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content')
assert response.status_code == HTTPStatus.OK
assert response.json['content'].startswith('[00:00:0')
assert response.json['total_chars'] > 0
# An offset beyond the end returns an empty page, never an error.
offset = response.json['total_chars'] + 100
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content?offset={offset}')
assert response.status_code == HTTPStatus.OK
assert response.json['content'] == '' and response.json['next_offset'] is None
# A video without comments is an empty page, not an error.
quiet = video_factory(title='quiet')
test_session.commit()
request, response = await async_client.get(f'/api/ai/files/{quiet.file_group_id}/content?part=comments')
assert response.status_code == HTTPStatus.OK
assert response.json['content'] == ''
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content?part=comments')
assert response.status_code == HTTPStatus.OK
assert 'alice: great video' in response.json['content']
request, response = await async_client.get(f'/api/ai/files/{archive.file_group_id}/content')
assert response.status_code == HTTPStatus.OK
assert len(response.json['content']) == lib.PAGE_SIZE
assert response.json['next_offset'] == lib.PAGE_SIZE
request, response = await async_client.get(
f'/api/ai/files/{archive.file_group_id}/content?offset={response.json["next_offset"]}')
assert response.json['content'] == contents[lib.PAGE_SIZE:2 * lib.PAGE_SIZE]
# An archive has no comments; the error says what the file is.
request, response = await async_client.get(f'/api/ai/files/{archive.file_group_id}/content?part=comments')
assert response.status_code == HTTPStatus.NOT_FOUND
assert 'archive' in response.json['error'].lower()
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content?part=bogus')
assert response.status_code == HTTPStatus.BAD_REQUEST
request, response = await async_client.get('/api/ai/files/123456/content')
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_ai_get_inventory_optional_slug(async_client, food_inventory_factory):
"""Without a slug the inventories are listed lean; with one, the inventory is returned in full."""
slug = food_inventory_factory(items=[dict(name='rice', count=2)])
request, response = await async_client.get('/api/ai/inventories')
assert response.status_code == HTTPStatus.OK
lean = next(i for i in response.json['results'] if i['slug'] == slug)
assert lean['item_count'] == 1 and 'items' not in lean
request, response = await async_client.get(f'/api/ai/inventories?slug={slug}')
assert response.status_code == HTTPStatus.OK
assert response.json['inventory']['items'][0]['name'] == 'rice'
request, response = await async_client.get('/api/ai/inventories?slug=no-such-inventory')
assert response.status_code == HTTPStatus.NOT_FOUND

View file

@ -0,0 +1,250 @@
"""End-to-end tests for /api/chat: SSE framing, the agent loop, and its guardrails.
llama-server is scripted (llama_chat_stream is patched); tool calls execute against the real
/api/ai blueprint through the test client."""
import json
from http import HTTPStatus
from unittest import mock
from urllib.parse import urlencode
import pytest
from modules.ai.config import get_ai_config
@pytest.fixture
async def ai_enabled(async_client, test_ai_config, test_directory):
"""AI enabled with a (fake) downloaded model, llama-server 'healthy'."""
models_dir = test_directory / 'ai/models'
models_dir.mkdir(parents=True)
(models_dir / 'test.gguf').write_bytes(b'GGUF')
config = get_ai_config()
config.active_model = 'test.gguf'
config.enabled = True
with mock.patch('modules.ai.service.llama_healthy', return_value=True), \
mock.patch('modules.ai.chat.service.ensure_llama_running', return_value=True):
yield config
def _llama_script(*turns):
"""Patch llama_chat_stream with scripted turns.
Each turn is a list of events ({'type': 'token'|'stop', ...}); turn N answers call N."""
calls = dict(count=0, messages=[])
def factory(messages, tools):
calls['messages'].append(messages)
turn = turns[min(calls['count'], len(turns) - 1)]
calls['count'] += 1
async def generator():
for event in turn:
yield event
return generator()
return mock.patch('modules.ai.chat.llama_chat_stream', side_effect=factory), calls
def _endpoint_shim(async_client):
async def shim(method, path, query, body):
url = f'{path}?{urlencode(query)}' if query else path
if method == 'post':
request, response = await async_client.post(url, content=json.dumps(body or {}))
else:
request, response = await async_client.get(url)
return response.status_code, response.json
return mock.patch('modules.ai.agent.call_ai_endpoint', side_effect=shim)
def _parse_sse(text):
"""[(event, data_dict), ...] from an SSE body."""
events = []
for block in text.split('\n\n'):
event = data = None
for line in block.splitlines():
if line.startswith('event:'):
event = line[6:].strip()
elif line.startswith('data:'):
data = json.loads(line[5:].strip())
if event:
events.append((event, data))
return events
@pytest.mark.asyncio
async def test_chat_tool_call_then_answer(async_client, ai_enabled, video_factory, test_session):
"""The loop executes a tool call against the blueprint, then streams the final answer."""
video_factory(title='pressure canning')
test_session.commit()
patch, calls = _llama_script(
[dict(type='stop', tool_calls=[
dict(id='call_1', name='search_files', arguments='{"search_str": "canning", "kind": "video"}')])],
[dict(type='token', content='Found '), dict(type='token', content='one video.'),
dict(type='stop', tool_calls=[])],
)
body = dict(mode='research', messages=[dict(role='user', content='Find videos about canning')])
with patch, _endpoint_shim(async_client):
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK
events = _parse_sse(response.text)
# A status event precedes every model call so slow CPUs never leave the stream silent.
names = [e for e, _ in events if e != 'status']
assert names == ['tool_call', 'tool_result', 'token', 'token', 'done']
tool_call = dict(events)['tool_call']
assert tool_call == dict(tool='search_files', args=dict(search_str='canning', kind='video'))
assert dict(events)['tool_result'] == dict(tool='search_files', success=True)
assert dict(events)['done'] == dict(content='Found one video.')
# The second llama call saw the system prompt (with the live library map), the mode's
# worked example, the user message, and the tool result.
second_call_messages = calls['messages'][1]
assert second_call_messages[0]['role'] == 'system'
assert 'librarian' in second_call_messages[0]['content']
assert 'Today is' in second_call_messages[0]['content']
assert "library contains" in second_call_messages[0]['content']
example = [m for m in second_call_messages if m.get('content') and 'Bakehouse' in str(m.get('content'))]
assert example, 'the research few-shot example is in context'
assert second_call_messages[-1]['role'] == 'tool'
assert 'pressure canning' in second_call_messages[-1]['content']
@pytest.mark.asyncio
async def test_chat_plain_answer(async_client, ai_enabled):
"""A toolless answer streams tokens then done."""
patch, _ = _llama_script(
[dict(type='token', content='Hello!'), dict(type='stop', tool_calls=[])],
)
body = dict(mode='help', messages=[dict(role='user', content='hi')])
with patch:
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
events = [e for e in _parse_sse(response.text) if e[0] != 'status']
assert events == [('token', dict(content='Hello!')), ('done', dict(content='Hello!'))]
@pytest.mark.asyncio
async def test_chat_tool_loop_bounded(async_client, ai_enabled):
"""A model that never stops calling tools hits max_tool_calls and gets the fallback."""
get_ai_config().max_tool_calls = 2
patch, calls = _llama_script(
[dict(type='stop', tool_calls=[dict(id='x', name='search_help', arguments='{"search_str": "a"}')])],
)
body = dict(mode='help', messages=[dict(role='user', content='loop forever')])
with patch, _endpoint_shim(async_client):
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
events = _parse_sse(response.text)
assert events[-1][0] == 'error'
assert calls['count'] == 3 # initial + max_tool_calls
@pytest.mark.asyncio
async def test_chat_invalid_tool_args(async_client, ai_enabled):
"""Bad tool JSON and disallowed tools come back to the model as errors, not crashes."""
patch, calls = _llama_script(
[dict(type='stop', tool_calls=[dict(id='x', name='search_help', arguments='{not json')])],
[dict(type='stop', tool_calls=[dict(id='y', name='list_disks', arguments='{}')])],
[dict(type='token', content='Sorry.'), dict(type='stop', tool_calls=[])],
)
body = dict(mode='help', messages=[dict(role='user', content='hi')])
with patch, _endpoint_shim(async_client):
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
events = _parse_sse(response.text)
results = [d for e, d in events if e == 'tool_result']
assert results[0]['success'] is False # invalid JSON arguments
assert results[1]['success'] is False # list_disks is not a help-mode tool
assert events[-1] == ('done', dict(content='Sorry.'))
# The model saw instructive errors (skip the few-shot example's tool messages).
tool_messages = [m for m in calls['messages'][-1]
if m['role'] == 'tool' and not str(m.get('tool_call_id', '')).startswith('ex_')]
assert 'not valid JSON' in tool_messages[0]['content']
assert 'Unknown tool' in tool_messages[1]['content']
@pytest.mark.asyncio
async def test_chat_validation(async_client, ai_enabled):
"""Bad requests are refused before the stream starts."""
request, response = await async_client.post(
'/api/chat/', content=json.dumps(dict(mode='nope', messages=[dict(role='user', content='x')])))
assert response.status_code == HTTPStatus.BAD_REQUEST
request, response = await async_client.post(
'/api/chat/', content=json.dumps(dict(mode='help', messages=[])))
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_chat_disabled(async_client, test_ai_config):
"""Chat refuses when AI is disabled or no model is selected."""
body = dict(mode='help', messages=[dict(role='user', content='x')])
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
assert response.status_code == HTTPStatus.CONFLICT
assert response.json['code'] == 'AI_DISABLED'
@pytest.mark.asyncio
async def test_chat_modes_endpoint(async_client):
"""The Chat tab can list modes and their suggestion chips."""
request, response = await async_client.get('/api/chat/modes')
assert response.status_code == HTTPStatus.OK
modes = {m['name']: m for m in response.json['modes']}
assert set(modes) == {'help', 'research', 'system'}
assert modes['research']['suggestions']
def test_trim_history():
"""Old turns are dropped first; fixed messages and the current turn are never trimmed."""
from modules.ai import chat
fixed = [dict(role='system', content='s' * 400), dict(role='user', content='current')]
history = [dict(role='user', content=f'old {i} ' + 'x' * 400) for i in range(10)]
# A huge budget keeps everything.
assert chat.trim_history(history, fixed, [], budget=10_000) == history
# A small budget drops the OLDEST turns.
trimmed = chat.trim_history(history, fixed, [], budget=400)
assert len(trimmed) < len(history)
assert trimmed == history[len(history) - len(trimmed):]
# A budget the fixed messages alone exceed empties the history without crashing.
assert chat.trim_history(history, fixed, [], budget=1) == []
@pytest.mark.asyncio
async def test_chat_context_full(async_client, ai_enabled):
"""A conversation that cannot fit the model context gets a clear error, not a bad answer."""
from modules.ai import chat
get_ai_config().context_size = 1 # far below the reply reserve
patch, calls = _llama_script([dict(type='token', content='hi'), dict(type='stop', tool_calls=[])])
body = dict(mode='help', messages=[dict(role='user', content='hello')])
with patch:
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
events = _parse_sse(response.text)
assert events[-1][0] == 'error'
assert 'no longer fits' in events[-1][1]['message']
assert calls['count'] == 0 # the model was never called
@pytest.mark.asyncio
async def test_chat_history_trimmed(async_client, ai_enabled):
"""Old turns are dropped to fit the context; the current question always survives."""
get_ai_config().context_size = 4_096
patch, calls = _llama_script([dict(type='token', content='ok'), dict(type='stop', tool_calls=[])])
old_turns = []
for i in range(6):
old_turns += [dict(role='user', content=f'old question {i} ' + 'pad ' * 500),
dict(role='assistant', content=f'old answer {i} ' + 'pad ' * 500)]
body = dict(mode='help', messages=old_turns + [dict(role='user', content='the current question')])
with patch:
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
assert _parse_sse(response.text)[-1][0] == 'done'
sent = calls['messages'][0]
contents = [str(m.get('content')) for m in sent]
assert any('the current question' in c for c in contents)
assert not any('old question 0' in c for c in contents) # oldest turn was trimmed

View file

@ -0,0 +1,287 @@
"""Tests for the ai.yaml config, the model catalog, and the GGUF downloader."""
import json
from http import HTTPStatus
from unittest import mock
import pytest
import yaml
from modules.ai import catalog
from modules.ai.config import get_ai_config
from modules.ai.downloader import ai_model_downloader
from wrolpi.downloader import Download
def test_ai_config_defaults_and_update(test_ai_config):
"""The config has safe defaults (AI off) and round-trips values."""
config = get_ai_config()
assert config.enabled is False
assert config.active_model == ''
assert config.idle_unload_minutes == 15
assert config.context_size is None
assert config.max_tool_calls == 6
assert config.temperature == 0.3
config.enabled = True
config.active_model = 'Qwen3-1.7B-Q4_K_M.gguf'
config.context_size = 8_192
assert get_ai_config().enabled is True
assert get_ai_config().active_model == 'Qwen3-1.7B-Q4_K_M.gguf'
assert get_ai_config().context_size == 8_192
@pytest.mark.asyncio
async def test_ai_config_import(test_ai_config):
"""A hand-edited ai.yaml is the source of truth on import."""
test_ai_config.write_text(yaml.dump(dict(
version=1, enabled=True, active_model='custom.gguf', idle_unload_minutes=5,
context_size=None, max_tool_calls=4,
)))
config = get_ai_config()
config.import_config(test_ai_config)
assert config.enabled is True
assert config.active_model == 'custom.gguf'
assert config.idle_unload_minutes == 5
assert config.max_tool_calls == 4
assert config.successful_import is True
@pytest.mark.asyncio
async def test_models_catalog_fallback(async_client):
"""The bundled catalog is served when the CDN is unreachable."""
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
models, source = await catalog.get_models_catalog()
assert source == 'bundled'
assert models == catalog.AI_MODELS
manifest = dict(version=1, models=[dict(name='new.gguf', tier='small')])
with mock.patch('modules.ai.catalog.fetch_models_manifest', return_value=manifest):
models, source = await catalog.get_models_catalog()
assert source == 'cdn'
assert models[0]['name'] == 'new.gguf'
def test_recommend_tier():
gb = 1024 ** 3
assert catalog.recommend_tier(4 * gb) == 'small'
assert catalog.recommend_tier(8 * gb) == 'medium'
assert catalog.recommend_tier(32 * gb) == 'large'
@pytest.mark.asyncio
async def test_model_downloader(test_session, test_directory, test_download_manager):
"""The model downloader verifies the meta4 signature, stages, and lands in <media>/ai/models."""
url = 'https://example.com/ai/Test-Model-Q4_K_M.gguf'
download = Download(url=url, downloader='ai_model')
prepared = ai_model_downloader.prepare_download(test_session, download)
assert prepared.error is None
assert prepared.output_path == test_directory / 'ai/models/Test-Model-Q4_K_M.gguf'
assert prepared.models_directory.is_dir()
meta4 = b'<metalink>fake</metalink>'
async def fake_download_file(download_, url_, directory, **kwargs):
assert kwargs['meta4_xml'] == meta4
path = directory / 'Test-Model-Q4_K_M.gguf'
path.write_bytes(b'GGUF fake weights')
return path
with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=meta4), \
mock.patch('modules.ai.downloader.verify_meta4_signature', return_value=True), \
mock.patch.object(ai_model_downloader, 'download_file', side_effect=fake_download_file):
executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download)
assert executed.error is None
assert prepared.output_path.is_file()
result = ai_model_downloader.finalize_download(test_session, download, executed)
assert result.success is True
# A second download of the same model is skipped.
prepared = ai_model_downloader.prepare_download(test_session, download)
assert prepared.already_done is True
executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download)
assert executed.skipped is True
@pytest.mark.asyncio
async def test_model_downloader_refuses_unverified(test_session, test_directory, test_download_manager):
"""No meta4, or a bad signature, refuses the download."""
url = 'https://example.com/ai/Test-Model.gguf'
download = Download(url=url, downloader='ai_model')
prepared = ai_model_downloader.prepare_download(test_session, download)
with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=None):
executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download)
assert 'refusing an unverified download' in executed.error
with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=b'<m/>'), \
mock.patch('modules.ai.downloader.verify_meta4_signature', return_value=False):
executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download)
assert 'signature verification failed' in executed.error
assert not prepared.output_path.exists()
# Not a GGUF URL is refused in prepare.
bad = ai_model_downloader.prepare_download(test_session, Download(url='https://example.com/x.zip'))
assert 'Not a GGUF model URL' in bad.error
@pytest.mark.asyncio
async def test_ai_manage_endpoints(async_client, test_directory, test_ai_config):
"""The Manage tab's catalog and settings endpoints."""
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
request, response = await async_client.get('/api/ai/manage/catalog')
assert response.status_code == HTTPStatus.OK
assert response.json['catalog_source'] == 'bundled'
assert response.json['enabled'] is False
assert response.json['recommended_tier'] in ('small', 'medium', 'large')
assert all(i['downloaded'] is False for i in response.json['models'])
# Selecting a model that is not downloaded is refused.
content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf')
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.status_code == HTTPStatus.BAD_REQUEST
# Once the file exists it can be selected, and shows downloaded/active in the catalog.
models_dir = test_directory / 'ai/models'
models_dir.mkdir(parents=True)
(models_dir / 'Qwen3-1.7B-Q4_K_M.gguf').write_bytes(b'GGUF')
content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf', enabled=True, idle_unload_minutes=5)
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
assert response.json['active_model'] == 'Qwen3-1.7B-Q4_K_M.gguf'
assert response.json['enabled'] is True
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
request, response = await async_client.get('/api/ai/manage/catalog')
model = next(i for i in response.json['models'] if i['name'] == 'Qwen3-1.7B-Q4_K_M.gguf')
assert model['downloaded'] is True and model['active'] is True
assert response.json['disk_usage'] > 0
# Bad idle minutes.
request, response = await async_client.post('/api/ai/manage/settings',
content=json.dumps(dict(idle_unload_minutes=0)))
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_effective_context_size(test_ai_config):
"""Explicit ai.yaml value wins; else the active model's catalog default; else the small default."""
config = get_ai_config()
assert catalog.get_effective_context_size() == 8_192 # no model, no override
config.active_model = 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf'
assert catalog.get_effective_context_size() == 16_384 # the 4B's catalog default
config.context_size = 4_096
assert catalog.get_effective_context_size() == 4_096 # explicit override wins
@pytest.mark.asyncio
async def test_manage_settings_adopts_model_context(async_client, test_directory, test_ai_config):
"""Selecting a model writes its catalog context default unless the request sets one."""
models_dir = test_directory / 'ai/models'
models_dir.mkdir(parents=True)
(models_dir / 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf').write_bytes(b'GGUF')
content = dict(active_model='Qwen3-4B-Instruct-2507-Q4_K_M.gguf')
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
assert response.json['context_size'] == 16_384
# An explicit context_size in the same request is respected.
content = dict(active_model='Qwen3-4B-Instruct-2507-Q4_K_M.gguf', context_size=4_096)
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.json['context_size'] == 4_096
@pytest.mark.asyncio
async def test_manage_catalog_lists_custom_models(async_client, test_directory, test_ai_config):
"""A GGUF the user copied into ai/models (not from the catalog) is listed as a custom model so it
can be selected in the Manage tab. It is discovered on every request; no file refresh needed."""
models_dir = test_directory / 'ai/models'
models_dir.mkdir(parents=True)
(models_dir / 'My-Custom-Model-Q8_0.gguf').write_bytes(b'GGUF' * 10)
(models_dir / 'Qwen3-1.7B-Q4_K_M.gguf').write_bytes(b'GGUF')
(models_dir / 'partial.gguf.tmp').write_bytes(b'x') # an in-progress download is not a model
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
request, response = await async_client.get('/api/ai/manage/catalog')
assert response.status_code == HTTPStatus.OK
names = [i['name'] for i in response.json['models']]
assert names.count('Qwen3-1.7B-Q4_K_M.gguf') == 1, 'catalog model must not be duplicated'
assert 'partial.gguf.tmp' not in names
custom = next(i for i in response.json['models'] if i['name'] == 'My-Custom-Model-Q8_0.gguf')
assert custom['tier'] == 'custom'
assert custom['downloaded'] is True
assert custom['active'] is False
assert custom['size'] == 40
assert custom['url'] is None
# Selecting a catalog model first stores its context default in ai.yaml ...
content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf')
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
assert response.json['context_size'] == 8_192
# ... and switching to the custom model clears it, since a custom model has no catalog
# default; it must not silently keep the previous model's value.
content = dict(active_model='My-Custom-Model-Q8_0.gguf')
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
assert response.json['active_model'] == 'My-Custom-Model-Q8_0.gguf'
assert response.json['context_size'] is None
assert catalog.get_effective_context_size() == 8_192 # the conservative default
# An explicit context_size in the same request still wins for a custom model.
content = dict(active_model='My-Custom-Model-Q8_0.gguf', context_size=4_096)
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.json['context_size'] == 4_096
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
request, response = await async_client.get('/api/ai/manage/catalog')
custom = next(i for i in response.json['models'] if i['name'] == 'My-Custom-Model-Q8_0.gguf')
assert custom['active'] is True
@pytest.mark.parametrize('name,loadable', [
('My-Custom-Model-Q8_0.gguf', True),
('model (v2) [q4].gguf', True), # shell-safe once quoted by PyYAML
('café.gguf', False), # PyYAML dumps \xE9; read_config_value.sh prints the escape literally
('模型-Q4.gguf', False),
('model #1.gguf', False), # the reader strips " #..." before unquoting
('foo..bar.gguf', False), # start_llama_server.sh's traversal guard rejects any ".."
('it\'s.gguf', False),
('say "hi".gguf', False),
('back\\slash.gguf', False),
(' padded.gguf', False),
('model.bin', False),
])
def test_is_loadable_model_name(name, loadable):
"""Only names start_llama_server.sh can read back out of ai.yaml are loadable."""
assert catalog.is_loadable_model_name(name) is loadable
@pytest.mark.asyncio
async def test_manage_catalog_skips_unloadable_custom_models(async_client, test_directory, test_ai_config):
"""A copied-in GGUF whose name cannot round-trip through ai.yaml and the shell reader is neither
listed nor accepted, so the UI never reports an activation that llama-server would refuse."""
models_dir = test_directory / 'ai/models'
models_dir.mkdir(parents=True)
(models_dir / 'café.gguf').write_bytes(b'GGUF')
(models_dir / 'model #1.gguf').write_bytes(b'GGUF')
(models_dir / 'foo..bar.gguf').write_bytes(b'GGUF')
(models_dir / 'Fine-Model.gguf').write_bytes(b'GGUF')
(models_dir / 'a-directory.gguf').mkdir()
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
request, response = await async_client.get('/api/ai/manage/catalog')
names = [i['name'] for i in response.json['models']]
assert 'Fine-Model.gguf' in names
for bad in ('café.gguf', 'model #1.gguf', 'foo..bar.gguf', 'a-directory.gguf'):
assert bad not in names
for bad in ('café.gguf', 'model #1.gguf', 'foo..bar.gguf'):
request, response = await async_client.post('/api/ai/manage/settings',
content=json.dumps(dict(active_model=bad)))
assert response.status_code == HTTPStatus.BAD_REQUEST, bad
assert 'cannot be loaded' in response.json['error']

View file

@ -0,0 +1,62 @@
"""Tests for the library map injected into every chat."""
import time
from unittest import mock
import pytest
from modules.ai import context
@pytest.mark.asyncio
async def test_build_library_context(async_client, test_session, video_factory, archive_factory,
test_zim, food_inventory_factory, tag_factory):
"""The map carries today's date, counts, and exact names."""
from wrolpi.collections.models import Collection
test_session.add(Collection(name='Talking Sasquach', kind='channel'))
await tag_factory('Food Preservation')
video_factory(title='a video')
archive_factory(domain='example.com', title='a page', contents='contents')
food_inventory_factory(name='Pantry')
test_session.commit()
text = context.build_library_context()
assert 'Today is' in text
assert '1 videos, 1 archived web pages' in text
assert 'Talking Sasquach' in text
assert 'example.com' in text
assert 'WROLPi example ZIM' in text
assert 'Pantry' in text
assert 'Tags (1): Food Preservation' in text
assert 'user spellings may differ' in text
@pytest.mark.asyncio
async def test_library_context_caps_names(async_client, test_session, archive_factory):
"""A huge library lists at most MAX_NAMES names per category, plus a count of the rest."""
for i in range(context.MAX_NAMES + 5):
archive_factory(domain=f'site{i:02}.com', title=f'page {i}', contents='x')
test_session.commit()
text = context.build_library_context()
assert f'Archived websites ({context.MAX_NAMES + 5}):' in text
assert 'and 5 more' in text
listed = [i for i in range(context.MAX_NAMES + 5) if f'site{i:02}.com' in text]
assert len(listed) == context.MAX_NAMES
@pytest.mark.asyncio
async def test_library_context_cache_and_degradation(async_client, test_session):
"""The map is cached briefly; a build failure serves the stale copy rather than crashing chat."""
context._cache.update(text=None, expires=0.0)
first = context.get_library_context()
assert first and 'Today is' in first
with mock.patch('modules.ai.context.build_library_context', side_effect=RuntimeError('db down')) as build:
# Cached: the builder is not called.
assert context.get_library_context() == first
build.assert_not_called()
# Expired + failing builder: the stale copy is served.
context._cache['expires'] = time.time() - 1
assert context.get_library_context() == first
build.assert_called_once()

View file

@ -0,0 +1,114 @@
"""Tests for the help-docs search (Help mode). Uses a fixture corpus, never the real submodule."""
import json
from http import HTTPStatus
import pytest
from modules.ai import help_docs, lib
@pytest.fixture
def help_corpus(test_directory, monkeypatch):
"""A small help-docs corpus in a temporary directory."""
docs = test_directory / 'help-docs'
(docs / 'system').mkdir(parents=True)
(docs / 'modules').mkdir()
(docs / 'index.md').write_text('# Home\n\nWelcome to WROLPi help.\n')
(docs / 'system/logs.md').write_text(
'# Logs\n\n## Reading logs\n\nUse journalctl to read service logs on a WROLPi.\n')
(docs / 'modules/videos.md').write_text(
'# Videos\n\n## Downloading\n\nVideos are downloaded with yt-dlp into channels.\n'
+ ('More text about videos. ' * 400)) # > one page
monkeypatch.setenv('HELP_DOCS_DIR', str(docs))
help_docs._CACHE.clear()
try:
yield docs
finally:
help_docs._CACHE.clear()
def test_search_help_scoring(help_corpus):
"""Title matches outrank body matches; snippets show context."""
results, total = help_docs.search_help('logs')
assert total == 1
assert results[0]['slug'] == 'system/logs'
assert results[0]['title'] == 'Logs'
assert results[0]['link'] == '/system/logs/'
assert 'journalctl' in results[0]['snippet'] or 'Logs' in results[0]['snippet']
# 'videos' matches the videos doc far better than any other.
results, _ = help_docs.search_help('downloading videos')
assert results[0]['slug'] == 'modules/videos'
# No matches.
results, total = help_docs.search_help('xyzzy')
assert results == [] and total == 0
def test_help_doc_cache_invalidation(help_corpus):
"""Edited files are re-parsed; deleted files leave the corpus."""
results, _ = help_docs.search_help('welcome')
assert results[0]['slug'] == 'index'
assert results[0]['link'] == '/'
(help_corpus / 'index.md').write_text('# Home\n\nGreetings, traveler.\n')
import os
os.utime(help_corpus / 'index.md', (0, 0)) # force a distinct mtime
results, total = help_docs.search_help('welcome')
assert total == 0
results, _ = help_docs.search_help('greetings')
assert results[0]['slug'] == 'index'
(help_corpus / 'system/logs.md').unlink()
_, total = help_docs.search_help('logs')
assert total == 0
def test_get_help_doc_refuses_traversal(help_corpus):
assert help_docs.get_help_doc('../secrets') is None
assert help_docs.get_help_doc('system/logs') is not None
@pytest.mark.asyncio
async def test_ai_help_endpoints(async_client, help_corpus):
"""Help can be searched and pages read as paged markdown."""
content = dict(search_str='logs')
request, response = await async_client.post('/api/ai/help/search', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 1
result = response.json['results'][0]
assert result['slug'] == 'system/logs'
request, response = await async_client.get(f'/api/ai/help/{result["slug"]}')
assert response.status_code == HTTPStatus.OK
assert response.json['title'] == 'Logs'
assert 'journalctl' in response.json['content']
# Long pages are paged.
request, response = await async_client.get('/api/ai/help/modules/videos')
assert response.status_code == HTTPStatus.OK
assert response.json['next_offset'] == lib.PAGE_SIZE
# Unknown page.
request, response = await async_client.get('/api/ai/help/no/such/page')
assert response.status_code == HTTPStatus.NOT_FOUND
# Empty search is a clear error.
request, response = await async_client.post('/api/ai/help/search', content=json.dumps(dict()))
assert response.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_ai_help_not_installed(async_client, monkeypatch, test_directory):
"""A missing help directory returns empty results with a message, never a 500."""
monkeypatch.setenv('HELP_DOCS_DIR', str(test_directory / 'nope'))
help_docs._CACHE.clear()
content = dict(search_str='logs')
request, response = await async_client.post('/api/ai/help/search', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
assert response.json['results'] == []
assert 'not installed' in response.json['message']
request, response = await async_client.get('/api/ai/help/system/logs')
assert response.status_code == HTTPStatus.NOT_FOUND

149
modules/ai/test/test_lib.py Normal file
View file

@ -0,0 +1,149 @@
"""Tests for the /api/ai formatting helpers."""
from modules.ai import lib
def test_wrolpi_link_video():
fg = dict(id=123, model='video', video=dict(), mimetype='video/mp4')
assert lib.wrolpi_link(fg) == '/videos/123'
def test_wrolpi_link_doc():
fg = dict(id=5, model='doc', mimetype='application/pdf')
assert lib.wrolpi_link(fg) == '/docs/5'
def test_wrolpi_link_archive():
fg = dict(id=7, model='archive', mimetype='text/html')
assert lib.wrolpi_link(fg) == '/archives/7'
def test_wrolpi_link_epub(test_directory):
fg = dict(id=9, mimetype='application/epub+zip', primary_path=test_directory / 'books/foo.epub')
assert lib.wrolpi_link(fg) == '/epub/epub.html?url=/download/books/foo.epub'
def test_wrolpi_link_mobi(test_directory):
fg = dict(id=9, mimetype='application/x-mobipocket-ebook', primary_path=test_directory / 'books/foo.mobi')
assert lib.wrolpi_link(fg) == '/download/books/foo.mobi'
def test_wrolpi_link_media_fallback(test_directory):
fg = dict(id=2, mimetype='image/png', primary_path=test_directory / 'photos/a.png')
assert lib.wrolpi_link(fg) == '/media/photos/a.png'
def test_wrolpi_link_no_path():
assert lib.wrolpi_link(dict(mimetype='image/png')) is None
def test_format_file_group_video():
fg = dict(
id=42,
model='video',
title='Canning Basics',
mimetype='video/mp4',
size=1000,
length=4021,
published_datetime='2024-01-02',
tags=['food'],
video=dict(channel=dict(id=1, name='Homesteading'), description='x' * 500),
d_headline='pressure <b>canning</b>',
)
result = lib.format_file_group(fg)
assert result['id'] == 42
assert result['kind'] == 'video'
assert result['link'] == '/videos/42'
assert result['channel'] == 'Homesteading'
assert result['duration'] == '1:07:01'
assert result['published'] == '2024-01-02'
assert result['headline'] == 'pressure <b>canning</b>'
assert result['tags'] == ['food']
# Listing descriptions are truncated.
assert len(result['description']) == lib.LISTING_DESCRIPTION_LENGTH + 1
# Empty fields are omitted.
assert 'author' not in result and 'url' not in result
# Listings leave out what the model can derive or does not need to choose an item.
assert 'captions_link' not in result and 'size' not in result and 'mimetype' not in result
# Details add them back.
detail = lib.format_file_group(fg, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
assert detail['size'] == 1000 and detail['mimetype'] == 'video/mp4'
assert len(detail['description']) == 500
def test_format_file_group_archive():
fg = dict(id=8, model='archive', title='Some Page', mimetype='text/html', url='https://example.com/a')
result = lib.format_file_group(fg)
assert result['kind'] == 'archive'
assert result['link'] == '/archives/8'
assert 'text_link' not in result
# Listings omit the source URL so small models present the WROLPi link instead.
assert 'url' not in result
assert lib.format_file_group(fg, include_url=True)['url'] == 'https://example.com/a'
def test_format_file_groups_total():
result = lib.format_file_groups([dict(id=1, model='archive')], 55)
assert result['total'] == 55
assert len(result['results']) == 1
def test_paginate_text():
text = 'a' * (lib.PAGE_SIZE + 100)
page = lib.paginate_text(text, 0)
assert len(page['content']) == lib.PAGE_SIZE
assert page['next_offset'] == lib.PAGE_SIZE
assert page['total_chars'] == lib.PAGE_SIZE + 100
page = lib.paginate_text(text, page['next_offset'])
assert len(page['content']) == 100
assert page['next_offset'] is None
# Exact boundary has no next page.
page = lib.paginate_text('a' * lib.PAGE_SIZE, 0)
assert page['next_offset'] is None
# Empty text.
page = lib.paginate_text(None, 0)
assert page == dict(content='', next_offset=None, total_chars=0)
def test_html_to_text():
html = '<html><head><style>.x{}</style><script>evil()</script></head>' \
'<body><nav>menu</nav><p>Hello</p><p></p><p>World</p></body></html>'
text = lib.html_to_text(html)
assert 'Hello' in text and 'World' in text
assert 'evil' not in text and 'menu' not in text and '.x{}' not in text
def test_format_duration_and_date():
assert lib.format_duration(59) == '0:59'
assert lib.format_duration(692) == '11:32'
assert lib.format_duration(4021) == '1:07:01'
assert lib.format_duration(None) is None and lib.format_duration('x') is None
import datetime
assert lib.format_date(datetime.datetime(2024, 1, 2, 13, 4, 5)) == '2024-01-02'
assert lib.format_date('2024-01-02T13:04:05+00:00') == '2024-01-02'
assert lib.format_date(None) is None
def test_format_caption_chunks():
chunks = [dict(start_seconds=0, text='hello'), dict(start_seconds=3671, text='goodbye')]
text = lib.format_caption_chunks(chunks)
assert text == '[00:00:00] hello\n[01:01:11] goodbye'
assert lib.format_caption_chunks(None) == ''
def test_format_zim_search():
zim_results = [dict(
metadata=dict(title='Test Zim'),
estimate=2,
search=[
dict(zim_id=1, path='one', title='One', headline='the first', rank=1),
dict(zim_id=1, path='two', title='Two', headline=None, rank=1),
],
)]
result = lib.format_zim_search(zim_results)
assert result['total'] == 2
assert result['results'][0]['link'] == '/api/zim/1/entry/one'
assert result['results'][0]['zim'] == 'Test Zim'
assert 'headline' not in result['results'][1]

View file

@ -0,0 +1,39 @@
"""Guards for the wrolpi_mcp thin proxy.
The MCP server's venv (with the `mcp` package) is separate from the API's, so these are source
scans: the tool set is deliberate (no per-kind duplicates of the generic tools), and the proxy
must only reach the tested /api/ai blueprint (plus the whole-API statistics read)."""
import re
from wrolpi.vars import PROJECT_DIR
MCP_SERVER = PROJECT_DIR / 'wrolpi_mcp/server.py'
# The MCP mirrors the compact catalog the built-in assistant uses, plus the read-only listings
# (collections, zims, tags, downloads, map, statistics, status) that only external clients need.
EXPECTED_TOOLS = {
'search_files', 'get_file', 'read_content',
'search_zim', 'get_zim_entry', 'list_zim_files',
'list_collections', 'list_tags', 'list_files', 'read_file',
'list_downloads', 'get_map_overview', 'search_places',
'get_statistics', 'get_inventory', 'get_status',
}
def test_mcp_tool_set():
text = MCP_SERVER.read_text()
tools = set(re.findall(r'@mcp\.tool[^\n]*\)\nasync def (\w+)\(', text))
assert tools == EXPECTED_TOOLS
def test_mcp_is_a_thin_proxy():
"""The MCP calls only /api/ai (and the raw statistics read); the formatting layer is gone."""
text = MCP_SERVER.read_text()
endpoints = set(re.findall(r'["\'](/api/[a-z_/{}]+)', text))
allowed_non_ai = {'/api/statistics'}
offenders = {e for e in endpoints if not e.startswith('/api/ai') and e not in allowed_non_ai}
assert not offenders, f'MCP tools must proxy /api/ai, found: {sorted(offenders)}'
# The formatting/truncation layer moved into modules/ai; it must not come back here.
for name in ('_wrolpi_link', '_fmt_file_group', 'html_to_text', 'BeautifulSoup'):
assert name not in text, f'{name} belongs in modules/ai, not the MCP proxy'

View file

@ -0,0 +1,78 @@
"""Tests for the llama-server lifecycle: on-demand start and idle unload."""
import time
from unittest import mock
import pytest
from modules.ai import service
@pytest.mark.asyncio
async def test_ensure_llama_running_already_healthy(async_client):
"""A healthy llama-server is used as-is and activity is recorded."""
with mock.patch('modules.ai.service.llama_healthy', return_value=True), \
mock.patch('modules.ai.service.start_llm_service') as start:
assert await service.ensure_llama_running() is True
start.assert_not_called()
assert service.get_last_llm_activity() > 0
@pytest.mark.asyncio
async def test_ensure_llama_running_cold_start(async_client):
"""An unhealthy llama-server is started via the Controller and polled until healthy."""
health = iter([False, False, True])
async def fake_healthy():
return next(health)
with mock.patch('modules.ai.service.llama_healthy', side_effect=fake_healthy), \
mock.patch('modules.ai.service.start_llm_service', return_value=True) as start, \
mock.patch('modules.ai.service.asyncio.sleep'):
assert await service.ensure_llama_running(timeout=10) is True
start.assert_called_once()
@pytest.mark.asyncio
async def test_ensure_llama_running_start_fails(async_client):
"""A Controller start failure is reported as False, not an exception."""
with mock.patch('modules.ai.service.llama_healthy', return_value=False), \
mock.patch('modules.ai.service.start_llm_service', return_value=False):
assert await service.ensure_llama_running(timeout=1) is False
@pytest.mark.asyncio
async def test_idle_unload(async_client, test_ai_config):
"""llama-server is stopped only after the configured idle minutes."""
from wrolpi.api_utils import api_app
from modules.ai.config import get_ai_config
get_ai_config().idle_unload_minutes = 10
# Never used: nothing happens.
api_app.shared_ctx.ai_llm_last_request.value = 0.0
with mock.patch('modules.ai.service.stop_llm_service') as stop:
await service.perpetual_llm_idle_unload()
stop.assert_not_called()
# Recently used: not stopped.
api_app.shared_ctx.ai_llm_last_request.value = time.time()
with mock.patch('modules.ai.service.stop_llm_service') as stop, \
mock.patch('modules.ai.service.llama_healthy', return_value=True):
await service.perpetual_llm_idle_unload()
stop.assert_not_called()
# Idle past the threshold: stopped, and the activity marker resets.
api_app.shared_ctx.ai_llm_last_request.value = time.time() - 11 * 60
with mock.patch('modules.ai.service.stop_llm_service', return_value=True) as stop, \
mock.patch('modules.ai.service.llama_healthy', return_value=True):
await service.perpetual_llm_idle_unload()
stop.assert_called_once()
assert api_app.shared_ctx.ai_llm_last_request.value == 0.0
# Idle but already stopped: the marker resets without a stop call.
api_app.shared_ctx.ai_llm_last_request.value = time.time() - 11 * 60
with mock.patch('modules.ai.service.stop_llm_service') as stop, \
mock.patch('modules.ai.service.llama_healthy', return_value=False):
await service.perpetual_llm_idle_unload()
stop.assert_not_called()
assert api_app.shared_ctx.ai_llm_last_request.value == 0.0

View file

@ -0,0 +1,130 @@
"""Tests for the /api/ai system endpoints (System mode)."""
from http import HTTPStatus
from unittest import mock
import pytest
CONTROLLER_STATS = dict(
cpu=dict(percent=12.5),
memory=dict(total=8_000_000_000, used=2_000_000_000),
load=dict(minute_1=0.5),
drives=[dict(mount='/media/wrolpi', percent=41)],
# Keys the lean status must NOT forward (too big for small contexts).
processes=[dict(pid=1, name='huge')],
iostat=dict(huge=True),
)
CONTROLLER_SERVICES = [
dict(name='wrolpi-api', status='running', enabled=True, port=8081, description='API', viewable=True),
dict(name='wrolpi-kiwix', status='failed', enabled=True, port=9085, description='', view_path='/'),
]
def _mock_controller(responses: dict):
"""Patch controller_get to answer from {path: (status, body)}."""
async def fake_controller_get(path, params=None, timeout=10):
if path not in responses:
raise ConnectionError(f'no controller response for {path}')
return responses[path]
return mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get)
@pytest.mark.asyncio
async def test_ai_system_status(async_client, test_session):
"""The aggregate status contains API state, lean Controller stats, and services."""
responses = {
'/api/stats': (200, CONTROLLER_STATS),
'/api/services': (200, CONTROLLER_SERVICES),
}
with _mock_controller(responses):
request, response = await async_client.get('/api/ai/status')
assert response.status_code == HTTPStatus.OK
assert response.json['version']
assert response.json['wrol_mode'] is False
assert isinstance(response.json['flags'], dict)
# Only the lean subset of Controller stats is forwarded.
assert response.json['system']['cpu'] == dict(percent=12.5)
assert 'processes' not in response.json['system']
assert 'iostat' not in response.json['system']
# Services are lean: no view_path/viewable noise, empty description omitted.
services = {i['name']: i for i in response.json['services']}
assert services['wrolpi-kiwix']['status'] == 'failed'
assert 'viewable' not in services['wrolpi-api']
assert 'description' not in services['wrolpi-kiwix']
assert response.json['errors'] == []
@pytest.mark.asyncio
async def test_ai_system_status_controller_down(async_client, test_session):
"""A Controller outage degrades to errors entries, never a 500."""
with _mock_controller({}):
request, response = await async_client.get('/api/ai/status')
assert response.status_code == HTTPStatus.OK
assert response.json['version']
assert any('system stats' in i for i in response.json['errors'])
assert any('service statuses' in i for i in response.json['errors'])
assert 'system' not in response.json
assert 'services' not in response.json
@pytest.mark.asyncio
async def test_ai_list_services(async_client, test_session):
"""Services are listed lean; a Controller failure is a clear 502."""
with _mock_controller({'/api/services': (200, CONTROLLER_SERVICES)}):
request, response = await async_client.get('/api/ai/services')
assert response.status_code == HTTPStatus.OK
assert response.json['total'] == 2
assert response.json['results'][0]['name'] == 'wrolpi-api'
with _mock_controller({'/api/services': (500, dict(error='boom'))}):
request, response = await async_client.get('/api/ai/services')
assert response.status_code == HTTPStatus.BAD_GATEWAY
@pytest.mark.asyncio
async def test_ai_get_service_logs(async_client, test_session):
"""Logs are proxied with the lines param clamped."""
captured = {}
async def fake_controller_get(path, params=None, timeout=10):
captured['path'] = path
captured['params'] = params
return 200, dict(service='wrolpi-kiwix', lines=params['lines'], logs='line1\nline2')
with mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get):
request, response = await async_client.get('/api/ai/services/wrolpi-kiwix/logs?lines=999999')
assert response.status_code == HTTPStatus.OK
assert captured['path'] == '/api/services/wrolpi-kiwix/logs'
assert captured['params']['lines'] == 1_000 # clamped
assert response.json['logs'] == 'line1\nline2'
# Unknown service: the Controller's error becomes a 502 with its detail.
with _mock_controller({'/api/services/nope/logs': (404, dict(detail='Service not found'))}):
request, response = await async_client.get('/api/ai/services/nope/logs')
assert response.status_code == HTTPStatus.BAD_GATEWAY
@pytest.mark.asyncio
async def test_ai_list_disks(async_client, test_session):
"""Disks and SMART are aggregated; partial failures land in errors."""
disks = [dict(name='sda1', path='/dev/sda1', size='500G', fstype='ext4', mountpoint='/media/wrolpi')]
responses = {
'/api/disks': (200, disks),
'/api/disks/smart': (200, dict(available=True, drives=[dict(name='sda', assessment='PASS')])),
}
with _mock_controller(responses):
request, response = await async_client.get('/api/ai/disks')
assert response.status_code == HTTPStatus.OK
assert response.json['disks'] == disks
assert response.json['smart']['available'] is True
assert response.json['errors'] == []
# SMART unavailable (e.g. docker) is an error entry, not a failure.
with _mock_controller({'/api/disks': (200, disks)}):
request, response = await async_client.get('/api/ai/disks')
assert response.status_code == HTTPStatus.OK
assert response.json['disks'] == disks
assert 'smart' not in response.json
assert len(response.json['errors']) == 1

View file

@ -45,6 +45,9 @@ git config --global --add safe.directory /opt/wrolpi
# CPU-heavy step on weak hardware.
/opt/wrolpi/wrolpi/scripts/install_map_tools.sh
# Bake in llama.cpp (local AI) so a fresh Pi does not compile it on first upgrade.
/opt/wrolpi/wrolpi/scripts/install_llama_cpp.sh
# Install Python requirements. Try multiple times because pypi may stop responding.
python3 -m venv /opt/wrolpi/venv
/opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt ||

116
scripts/ai_eval.py Executable file
View file

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Evaluate the AI assistant against a live WROLPi.
Prompt and mode changes have so far been validated by anecdote; this scores every answer on
mechanical properties that do not need a judge:
- the stream finished with `done` (no error, no timeout)
- at least one tool was called, and the FIRST tool was one the question expects
- the answer contains a relative WROLPi link when one is expected
- the answer contains no external URLs (the modes forbid them)
Run it before and after any prompt/tool change:
python3 scripts/ai_eval.py --base https://localhost:8443 [--mode research] [--timeout 600]
Exit code 1 when any question errors out; the score table is the real output. Requires only
`requests` (in WROLPi's requirements). The suite reads the library map's world: questions are
library-agnostic (browse/status/help) so they work on any WROLPi with some content."""
import argparse
import json
import re
import sys
import time
import requests
requests.packages.urllib3.disable_warnings() # noqa Self-signed certs.
# (mode, question, expected first tools (any of; None = answerable from context, no tool expected),
# expect a WROLPi link in the answer)
QUESTIONS = [
('help', 'How do I download videos?', {'search_help'}, True),
('help', 'What are Tags?', {'search_help'}, True),
('help', 'How do I add my own files to WROLPi?', {'search_help'}, True),
('research', 'What are my three newest archives?', {'search_files'}, True),
# The library map in the prompt already names the channels; no tool call is needed.
('research', 'What video channels do I have?', None, False),
('research', 'Find videos about cooking', {'search_files'}, False),
('research', 'Summarize my newest videos', {'search_files'}, True),
('system', 'Is everything running?', {'get_system_status', 'list_services'}, False),
('system', 'How full are my drives?', {'get_system_status', 'list_disks'}, False),
('system', 'Why would downloads fail?', {'get_system_status', 'search_help'}, False),
]
EXTERNAL_URL = re.compile(r'https?://(?!localhost|127\.0\.0\.1)')
RELATIVE_LINK = re.compile(r'(^|[\s(\[])/(videos|archives|docs|epub|media|download|system|modules)[/\w]')
def run_question(base: str, mode: str, question: str, timeout: int) -> dict:
started = time.time()
tools, answer, error = [], None, None
try:
response = requests.post(f'{base}/api/chat/', verify=False, stream=True, timeout=timeout,
json=dict(mode=mode, messages=[dict(role='user', content=question)]))
if response.status_code != 200:
return dict(error=f'HTTP {response.status_code}', tools=tools, elapsed=time.time() - started)
event = None
for raw_line in response.iter_lines(decode_unicode=True):
if raw_line.startswith('event:'):
event = raw_line[6:].strip()
elif raw_line.startswith('data:'):
data = json.loads(raw_line[5:].strip())
if event == 'tool_call':
tools.append(data.get('tool'))
elif event == 'done':
answer = data.get('content')
elif event == 'error':
error = data.get('message')
except Exception as e:
error = str(e)[:120]
return dict(answer=answer, error=error, tools=tools, elapsed=time.time() - started)
def score(result: dict, expected_tools: set, expect_link: bool) -> dict:
answer = result.get('answer') or ''
checks = {
'done': result.get('error') is None and bool(answer),
'no_external_urls': not EXTERNAL_URL.search(answer),
}
if expected_tools is not None:
checks['used_tool'] = bool(result['tools'])
checks['right_tool'] = bool(result['tools']) and result['tools'][0] in expected_tools
if expect_link:
checks['has_link'] = bool(RELATIVE_LINK.search(answer))
return checks
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base', default='https://localhost:8443')
parser.add_argument('--mode', help='Only run one mode')
parser.add_argument('--timeout', type=int, default=600)
args = parser.parse_args()
questions = [q for q in QUESTIONS if not args.mode or q[0] == args.mode]
passed = total = errors = 0
for mode, question, expected_tools, expect_link in questions:
result = run_question(args.base, mode, question, args.timeout)
checks = score(result, expected_tools, expect_link)
passed += sum(checks.values())
total += len(checks)
if result.get('error'):
errors += 1
flags = ' '.join(f'{name}={"Y" if ok else "N"}' for name, ok in checks.items())
print(f'[{mode:8}] {result["elapsed"]:6.1f}s {flags} {question}')
if not all(checks.values()):
print(f' tools={result["tools"]}')
detail = result.get('error') or (result.get('answer') or '')[:200]
print(f' {detail}')
print(f'\nScore: {passed}/{total} checks passed across {len(questions)} questions; {errors} hard errors.')
return 1 if errors else 0
if __name__ == '__main__':
sys.exit(main())

View file

@ -197,14 +197,16 @@ CATALOG = [
dict(name='ai/collections', method='GET', path='/api/ai/collections'),
dict(name='ai/inventories', method='GET', path='/api/ai/inventories'),
dict(name='ai/zims', method='GET', path='/api/ai/zims'),
dict(name='ai/search', method='POST', path='/api/ai/search', body={'search_str': '{q}', 'limit': 5}),
dict(name='ai/videos/search', method='POST', path='/api/ai/videos/search', body={'search_str': '{q}', 'limit': 5}),
dict(name='ai/archives/search', method='POST', path='/api/ai/archives/search',
body={'search_str': '{q}', 'limit': 5}),
dict(name='ai/docs/search', method='POST', path='/api/ai/docs/search', body={'search_str': '{q}', 'limit': 5}),
dict(name='ai/files/search', method='POST', path='/api/ai/files/search', body={'search_str': '{q}', 'limit': 5}),
dict(name='ai/files/search video', method='POST', path='/api/ai/files/search',
body={'search_str': '{q}', 'kind': 'video', 'limit': 5}),
dict(name='ai/files/search archive', method='POST', path='/api/ai/files/search',
body={'search_str': '{q}', 'kind': 'archive', 'limit': 5}),
dict(name='ai/files/search doc', method='POST', path='/api/ai/files/search',
body={'search_str': '{q}', 'kind': 'doc', 'limit': 5}),
dict(name='ai/zims/search', method='POST', path='/api/ai/zims/search', body={'search_str': '{q}', 'limit': 5}),
dict(name='ai/videos/<id>', method='GET', path='/api/ai/videos/{video_id}', needs='video_id'),
dict(name='ai/archives/<id>', method='GET', path='/api/ai/archives/{archive_id}', needs='archive_id'),
dict(name='ai/files/<video id>', method='GET', path='/api/ai/files/{video_id}', needs='video_id'),
dict(name='ai/files/<archive id>', method='GET', path='/api/ai/files/{archive_id}', needs='archive_id'),
dict(name='chat/modes', method='GET', path='/api/chat/modes'),
]

56
scripts/start_llama_server.sh Executable file
View file

@ -0,0 +1,56 @@
#! /usr/bin/env bash
# Start llama-server (WROLPi's local AI inference runtime) with the model selected in ai.yaml.
#
# AI is opt-in: this refuses to start unless ai.yaml enables it and the active model file exists
# in the media directory (which also covers the unmounted-media case). Started/stopped on demand
# by the Controller; the API's idle-unload worker stops it after inactivity.
set -u
MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}"
PROJECT_DIR="${PROJECT_DIR:-/opt/wrolpi}"
AI_CONFIG="${MEDIA_DIRECTORY}/config/ai.yaml"
# Docker overrides the host so the api container can reach this container.
LLAMA_HOST="${LLAMA_HOST:-127.0.0.1}"
LLAMA_PORT="${LLAMA_PORT:-11435}"
read_ai_config() {
MEDIA_DIRECTORY="${MEDIA_DIRECTORY}" WROLPI_CONFIG_FILE="${AI_CONFIG}" \
"${PROJECT_DIR}/wrolpi/scripts/read_config_value.sh" "$@"
}
[ ! -d "${MEDIA_DIRECTORY}" ] && echo "Cannot start llama-server without media directory mounted" && exit 1
[ ! -f "${AI_CONFIG}" ] && echo "Cannot start llama-server without ${AI_CONFIG}" && exit 1
ENABLED="$(read_ai_config enabled false)"
if [ "${ENABLED}" != "true" ] && [ "${ENABLED}" != "True" ]; then
echo "AI is not enabled in ${AI_CONFIG}; refusing to start"
exit 1
fi
ACTIVE_MODEL="$(read_ai_config active_model)"
[ -z "${ACTIVE_MODEL}" ] && echo "No active_model in ${AI_CONFIG}; refusing to start" && exit 1
# Defense in depth: the model must be a bare file name inside the models directory.
case "${ACTIVE_MODEL}" in
*/*|*..*)
echo "Ignoring unsafe active_model '${ACTIVE_MODEL}'" >&2
exit 1
;;
esac
MODEL="${MEDIA_DIRECTORY}/ai/models/${ACTIVE_MODEL}"
[ ! -f "${MODEL}" ] && echo "Model file does not exist: ${MODEL}" && exit 1
CONTEXT_SIZE="$(read_ai_config context_size 8192)"
case "${CONTEXT_SIZE}" in
''|*[!0-9]*) CONTEXT_SIZE=8192 ;;
esac
# --jinja enables tool-call chat templates; mmap is llama.cpp's default so cold RAM cost is low.
# Localhost only on native installs -- never exposed through Caddy.
# --parallel 1: WROLPi is single-user; one slot gets the whole context.
exec llama-server \
--host "${LLAMA_HOST}" \
--port "${LLAMA_PORT}" \
--jinja \
--parallel 1 \
-c "${CONTEXT_SIZE}" \
-m "${MODEL}"

View file

@ -258,6 +258,10 @@ rm -f /home/wrolpi/.pgpass
# Migrate from old Leaflet/renderd/Apache2 map stack to MapLibre GL JS + PMTiles.
/opt/wrolpi/scripts/install_protomaps.sh || echo "Protomaps migration failed."
# Install llama.cpp for the local AI assistant (version-guarded no-op once installed;
# the first build takes several minutes on a Raspberry Pi).
/opt/wrolpi/wrolpi/scripts/install_llama_cpp.sh || echo "llama.cpp install failed, continuing..."
# Download map fonts blob if missing.
MAP_FONTS_BLOB=/opt/wrolpi-blobs/map-fonts.tar.gz
if [[ ! -f ${MAP_FONTS_BLOB} || ! -s ${MAP_FONTS_BLOB} ]]; then

View file

@ -1173,6 +1173,10 @@ def get_all_configs() -> Dict[str, ConfigFile]:
if playlists_config := get_playlists_config():
all_configs[playlists_config.file_name] = playlists_config
from modules.ai.config import get_ai_config
if ai_config := get_ai_config():
all_configs[ai_config.file_name] = ai_config
return all_configs

View file

@ -35,6 +35,7 @@ def attach_shared_contexts(app: Sanic):
app.shared_ctx.download_cache_config = manager.dict()
app.shared_ctx.map_pins_config = manager.dict()
app.shared_ctx.flasher_config = manager.dict()
app.shared_ctx.ai_config = manager.dict()
# Shared dicts.
app.shared_ctx.uploaded_files = manager.dict()
app.shared_ctx.status = manager.dict()
@ -57,6 +58,9 @@ def attach_shared_contexts(app: Sanic):
app.shared_ctx.download_manager_disabled.set()
app.shared_ctx.download_manager_stopped.set()
# Monotonic-ish timestamp of the last chat/LLM request; the AI idle-unload worker reads it.
app.shared_ctx.ai_llm_last_request = multiprocessing.Value(ctypes.c_double, 0.0)
app.shared_ctx.single_tasks_started = multiprocessing.Event()
app.shared_ctx.flags_initialized = multiprocessing.Event()
@ -126,6 +130,7 @@ def reset_shared_contexts(app: Sanic):
app.shared_ctx.domains_config.clear()
app.shared_ctx.archive_downloader_config.clear()
app.shared_ctx.download_cache_config.clear()
app.shared_ctx.ai_config.clear()
# Shared dicts.
app.shared_ctx.uploaded_files.clear()
app.shared_ctx.status.clear()
@ -313,3 +318,9 @@ def initialize_configs_contexts(app: Sanic):
FLASHER_CONFIG.initialize(app.shared_ctx.flasher_config)
except Exception as e:
logger.error(f'Failed to initialize in-memory flasher config: {e}')
try:
from modules.ai.config import AI_CONFIG
AI_CONFIG.initialize(app.shared_ctx.ai_config)
except Exception as e:
logger.error(f'Failed to initialize in-memory ai config: {e}')

View file

@ -13,6 +13,8 @@ from sanic_ext.extensions.openapi import openapi
from vininfo import Vin
from vininfo.details._base import VinDetails
from modules.ai.api import ai_bp
from modules.ai.chat import chat_bp
from modules.archive.api import archive_bp
from modules.docs.api import docs_bp
from modules.flasher.api import flasher_bp
@ -49,11 +51,16 @@ api_app.config.FALLBACK_ERROR_FORMAT = 'json'
# Caddy is the single proxy in front of the API; trust its X-Forwarded-For so request.remote_addr
# is the browser's address (used by the speed test).
api_app.config.PROXIES_COUNT = 1
# The AI chat streams for as long as CHAT_TIMEOUT_SECONDS (its own hard ceiling); Sanic's 60s
# default killed the stream before a slow CPU produced its first token.
api_app.config.RESPONSE_TIMEOUT = 660
api_bp = Blueprint('RootAPI', url_prefix='/api')
# Blueprints order here defines what order they are displayed in OpenAPI Docs.
api_app.blueprint(api_bp)
api_app.blueprint(ai_bp)
api_app.blueprint(chat_bp)
api_app.blueprint(archive_bp)
api_app.blueprint(collection_bp) # Unified collection endpoints
api_app.blueprint(config_bp)

View file

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Install llama.cpp's llama-server, WROLPi's local AI inference runtime.
#
# This is the single source of truth, called by every place that provisions a
# WROLPi so a version bump only has to be made here:
# - scripts/upgrade.sh (on-device upgrade)
# - pi-gen/stage2/04-wrolpi/03-run-chroot.sh (Raspberry Pi image build)
# - debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot (Debian Live ISO build)
# - docker/llm/Dockerfile (Docker llm container)
#
# Built from the SHA-pinned source tarball (llama.cpp ships no generic Linux
# binaries for ARM). The build takes several minutes on a Raspberry Pi; the
# version marker makes re-runs no-ops until the pinned version changes.
# Run as root: installs into /usr/local/bin and apt-installs build dependencies.
set -e
LLAMA_CPP_VERSION="v0.3.0"
# SHA256 of the source tarball -- arch-independent, one value covers every build.
LLAMA_CPP_SHA256="d94c02d86db22d68692f6bb5b3854763d5091e52142868dc7251995517c666d1"
# llama-server's version output tracks build numbers, not release tags, so the
# installed release is recorded in a marker file. The +dl suffix marks the
# multi-variant CPU build; bumping it forces a rebuild of older installs.
MARKER_DIR=/usr/local/share/wrolpi
MARKER="${MARKER_DIR}/llama_cpp_version"
MARKER_VALUE="${LLAMA_CPP_VERSION}+dl1"
# llama-server and its dlopen'd CPU-variant backends live together here; the
# backend loader scans the real executable's directory.
INSTALL_DIR=/opt/llama-cpp
install_llama_cpp() {
if [ -f "${MARKER}" ] && [ "$(cat "${MARKER}")" = "${MARKER_VALUE}" ] \
&& command -v llama-server &>/dev/null; then
echo "llama.cpp ${LLAMA_CPP_VERSION} already installed"
return 0
fi
echo "Building llama.cpp ${LLAMA_CPP_VERSION}..."
apt-get install -y build-essential cmake
local build_dir
build_dir=$(mktemp -d)
curl -fsSL "https://github.com/ggml-org/llama.cpp/archive/refs/tags/${LLAMA_CPP_VERSION}.tar.gz" \
-o /tmp/llama_cpp.tar.gz
echo "${LLAMA_CPP_SHA256} /tmp/llama_cpp.tar.gz" | sha256sum -c -
tar -xz -C "${build_dir}" --strip-components=1 -f /tmp/llama_cpp.tar.gz
rm -f /tmp/llama_cpp.tar.gz
# GGML_NATIVE=OFF: an image built in a chroot/QEMU (or a docker image moved
# between machines) must not bake in the build host's CPU features. On
# x86_64 the baseline still assumes AVX2, which SIGILLs on older CPUs
# (AMD FX, early Atom) -- so build every CPU variant with runtime dispatch,
# exactly like llama.cpp's official release binaries.
local variant_flags=""
if [ "$(uname -m)" = "x86_64" ]; then
variant_flags="-DGGML_BACKEND_DL=ON -DGGML_CPU_ALL_VARIANTS=ON"
fi
(cd "${build_dir}" &&
cmake -B build \
-DCMAKE_BUILD_TYPE=Release \
-DGGML_NATIVE=OFF \
-DCMAKE_BUILD_RPATH_USE_ORIGIN=ON \
${variant_flags} \
-DLLAMA_CURL=OFF \
-DLLAMA_BUILD_TESTS=OFF \
-DLLAMA_BUILD_EXAMPLES=OFF \
-DLLAMA_BUILD_SERVER=ON &&
cmake --build build -j"$(nproc)")
# Install the binary and every built library (CPU-variant backends included)
# together; the backend loader scans the executable's real directory.
rm -rf "${INSTALL_DIR}"
mkdir -p "${INSTALL_DIR}"
cp "${build_dir}"/build/bin/llama-server "${INSTALL_DIR}/"
cp "${build_dir}"/build/bin/*.so "${INSTALL_DIR}/" 2>/dev/null || true
find "${build_dir}/build" -name 'lib*.so*' -exec cp {} "${INSTALL_DIR}/" \; 2>/dev/null || true
chmod 0755 "${INSTALL_DIR}"/llama-server
ln -sf "${INSTALL_DIR}/llama-server" /usr/local/bin/llama-server
rm -rf "${build_dir}"
mkdir -p "${MARKER_DIR}"
echo "${MARKER_VALUE}" > "${MARKER}"
LD_LIBRARY_PATH="${INSTALL_DIR}" llama-server --version || true
echo "llama.cpp ${LLAMA_CPP_VERSION} installed"
}
install_llama_cpp

View file

@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Publish the AI model catalog to the WROLPi CDN.
For every model in modules/ai/catalog.py AI_MODELS this expects the GGUF file in MODELS_DIR,
then produces and uploads (the same trust chain the maps use):
- <model>.gguf.meta4 Metalink4 with size, sha-256, and a GPG signature of the hash
(aria2c enforces the hash; WROLPi GPG-verifies it before download)
- ai/models.json the manifest, with the real sha256/size values
- ai/models.json.sig detached GPG signature of the manifest
Run on a machine with the roland@learningselfreliance.com secret key and an s3cmd configured
for the wrolpi Spaces bucket:
python3 wrolpi/scripts/publish_ai_models.py <models_dir> [--upload]
Afterwards, copy the printed sha256/size values into AI_MODELS (the bundled offline catalog).
"""
import argparse
import json
import pathlib
import subprocess
import sys
PROJECT_DIR = pathlib.Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_DIR))
from modules.ai.catalog import AI_MODELS, MODELS_MANIFEST_URL # noqa: E402
from wrolpi.scripts.build_map_regions import generate_meta4, sha256_file, sign_hash # noqa: E402
BUCKET = 's3://wrolpi/ai'
GPG_UID = 'roland@learningselfreliance.com'
def sign_file(path: pathlib.Path) -> pathlib.Path:
sig_path = path.with_suffix(path.suffix + '.sig')
sig_path.unlink(missing_ok=True)
subprocess.run(['gpg', '--batch', '-u', GPG_UID, '--detach-sign', '--armor',
'-o', str(sig_path), str(path)], check=True)
return sig_path
def upload(path: pathlib.Path):
subprocess.run(['s3cmd', 'put', '--acl-public', str(path), f'{BUCKET}/{path.name}'], check=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('models_dir', type=pathlib.Path)
parser.add_argument('--upload', action='store_true', help='Upload to the CDN with s3cmd')
args = parser.parse_args()
manifest_models = []
artifacts = []
for model in AI_MODELS:
path = args.models_dir / model['name']
if not path.is_file():
print(f'MISSING: {path} — download it first', file=sys.stderr)
return 1
size = path.stat().st_size
print(f'Hashing {model["name"]} ({size:,} bytes)...')
sha256 = sha256_file(str(path))
signature = sign_hash(sha256)
meta4_path = pathlib.Path(f'{path}.meta4')
meta4_path.write_text(generate_meta4(model['name'], model['url'], sha256, size, signature))
print(f' sha256 {sha256}')
manifest_models.append(dict(model, sha256=sha256, size=size,
meta4_url=f'{model["url"]}.meta4'))
artifacts += [path, meta4_path]
manifest_path = args.models_dir / 'models.json'
manifest_path.write_text(json.dumps(dict(version=1, models=manifest_models), indent=2) + '\n')
sig_path = sign_file(manifest_path)
artifacts += [manifest_path, sig_path]
print(f'Manifest written: {manifest_path} (URL: {MODELS_MANIFEST_URL})')
if args.upload:
for path in artifacts:
print(f'Uploading {path.name}...')
upload(path)
print('Upload complete.')
else:
print('\nDry run (no --upload). Artifacts ready:')
for path in artifacts:
print(f' {path}')
print('\nCopy these real values into modules/ai/catalog.py AI_MODELS:')
for model in manifest_models:
print(f' {model["name"]}: sha256={model["sha256"]} size={model["size"]}')
return 0
if __name__ == '__main__':
sys.exit(main())

View file

@ -20,7 +20,8 @@ KEY="${1:?Usage: read_config_value.sh KEY [DEFAULT]}"
DEFAULT="${2:-}"
MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}"
CONFIG="${MEDIA_DIRECTORY}/config/wrolpi.yaml"
# Another top-level config file (e.g. ai.yaml) may be selected with WROLPI_CONFIG_FILE.
CONFIG="${WROLPI_CONFIG_FILE:-${MEDIA_DIRECTORY}/config/wrolpi.yaml}"
value=""
if [ -f "${CONFIG}" ]; then

1
wrolpi_mcp/__init__.py Normal file
View file

@ -0,0 +1 @@
"""WROLPi MCP Server - Exposes WROLPi content to LLMs via the Model Context Protocol."""

13
wrolpi_mcp/__main__.py Normal file
View file

@ -0,0 +1,13 @@
"""Entry point: python -m wrolpi_mcp, or python wrolpi_mcp/__main__.py"""
import sys
from pathlib import Path
# Ensure the project root is on sys.path so wrolpi_mcp is importable
# regardless of the working directory or how this script is invoked.
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
from wrolpi_mcp.server import mcp
mcp.run(transport="stdio")

28
wrolpi_mcp/client.py Normal file
View file

@ -0,0 +1,28 @@
"""HTTP client for the WROLPi API."""
import httpx
from wrolpi_mcp.config import API_BASE_URL, VERIFY_TLS
async def api_get(path: str, params: dict | None = None) -> dict:
"""GET request to the WROLPi API. Returns parsed JSON."""
async with httpx.AsyncClient(base_url=API_BASE_URL, verify=VERIFY_TLS, timeout=30.0) as client:
resp = await client.get(path, params=params)
resp.raise_for_status()
return resp.json()
async def api_post(path: str, json: dict | None = None) -> dict:
"""POST request to the WROLPi API. Returns parsed JSON."""
async with httpx.AsyncClient(base_url=API_BASE_URL, verify=VERIFY_TLS, timeout=30.0) as client:
resp = await client.post(path, json=json or {})
resp.raise_for_status()
return resp.json()
async def api_get_text(path: str) -> str:
"""GET request that returns raw text/HTML."""
async with httpx.AsyncClient(base_url=API_BASE_URL, verify=VERIFY_TLS, timeout=30.0) as client:
resp = await client.get(path)
resp.raise_for_status()
return resp.text

11
wrolpi_mcp/config.py Normal file
View file

@ -0,0 +1,11 @@
"""Configuration for the WROLPi MCP server."""
import os
# WROLPi API base URL. Override with WROLPI_API_URL environment variable.
API_BASE_URL = os.environ.get("WROLPI_API_URL", "https://localhost:8443")
# Whether to verify TLS certificates (WROLPi uses self-signed certs locally).
VERIFY_TLS = os.environ.get("WROLPI_VERIFY_TLS", "false").lower() in ("true", "1", "yes")
# Default search result limit.
DEFAULT_LIMIT = 10

View file

@ -0,0 +1,2 @@
mcp[cli]>=1.26.0
httpx>=0.27.0

552
wrolpi_mcp/server.py Normal file
View file

@ -0,0 +1,552 @@
"""WROLPi MCP Server — exposes WROLPi content to LLMs via the Model Context Protocol.
A thin proxy: every tool is one call to the WROLPi API's /api/ai blueprint, which owns (and
tests) the lean result shapes, links, and paging. External clients (Claude etc.) and WROLPi's
local assistant share the same compact, kind-generic tools."""
import json
import logging
import sys
import httpx
from wrolpi_mcp.client import api_get, api_post
from wrolpi_mcp.config import API_BASE_URL, DEFAULT_LIMIT
from mcp.server.fastmcp import FastMCP
# All logging must go to stderr in stdio transport mode.
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
logger = logging.getLogger("wrolpi_mcp")
mcp = FastMCP(
"WROLPi",
instructions=(
"WROLPi is an offline digital library containing videos, archived web pages, "
"ebooks, Zim encyclopedias (Wikipedia, etc.), maps, and documents. "
"Use search_files to find content of any kind, get_file for details, and read_content "
"for captions, page text, or comments. "
"IMPORTANT: When presenting results to the user, ALWAYS include the 'WROLPi Link' "
"for every item. Never use the 'Source URL' — only use the local WROLPi Link. "
"When calling tools, use the top-level ID (FileGroup ID)."
),
)
# ---------------------------------------------------------------------------
# Rendering helpers. /api/ai returns lean flat JSON with relative links; these render it as
# text and prefix the WROLPi base URL.
# ---------------------------------------------------------------------------
# Big content (captions, page text, zim entries) is read page by page up to this many chars.
MAX_CONTENT_CHARS = 50_000
_FIELD_LABELS = (
('mimetype', 'Type'),
('size', 'Size'),
('duration', 'Duration'),
('published', 'Published'),
('channel', 'Channel'),
('has_captions', 'Has captions'),
('has_comments', 'Has comments'),
('author', 'Author'),
('subject', 'Subject'),
('language', 'Language'),
('pages', 'Pages'),
('url', 'Source URL'),
('headline', 'Headline'),
('description', 'Description'),
)
_KIND_HINTS = {
'video': 'use this for get_file, read_content (captions/comments)',
'archive': 'use this for get_file, read_content (page text)',
'doc': 'use this for get_file',
}
def _link(relative: str | None) -> str | None:
return f'{API_BASE_URL.rstrip("/")}{relative}' if relative else None
def _render_result(item: dict) -> str:
"""One lean /api/ai result as concise text. The link goes first so the LLM can't drop it."""
parts = []
title = item.get('title')
link = _link(item.get('link'))
if title and link:
parts.append(f'Title: {title} — LINK: {link}')
elif title:
parts.append(f'Title: {title}')
elif link:
parts.append(f'LINK: {link}')
if item.get('id'):
hint = _KIND_HINTS.get(item.get('kind'))
parts.append(f'ID: {item["id"]}' + (f' ({hint})' if hint else ''))
for key, label in _FIELD_LABELS:
if value := item.get(key):
parts.append(f'{label}: {value}')
if tags := item.get('tags'):
parts.append(f'Tags: {", ".join(map(str, tags))}')
if history := item.get('history'):
parts.append(f'Earlier snapshots ({len(history)}):')
for snapshot in history:
parts.append(f' - {snapshot.get("title", "Untitled")} (ID: {snapshot.get("id")}, {snapshot.get("published", "?")})')
return '\n'.join(parts)
def _render_results(data: dict) -> str:
results = data.get('results') or []
if not results:
return 'No results found.'
sections = [f'--- Result {i} ---\n{_render_result(item)}' for i, item in enumerate(results, 1)]
text = '\n\n'.join(sections)
if (total := data.get('total')) is not None:
text += f'\n\nTotal matching: {total}'
if matches := data.get('matches'):
text += '\n\nNames matching the term (use to narrow the search):'
for channel in matches.get('channels') or []:
text += f"\n channel: {channel.get('name')} (ID: {channel.get('id')})"
for group in ('domains', 'authors', 'subjects'):
for item in matches.get(group) or []:
text += f"\n {group[:-1]}: {item.get('name')}"
return text
async def _read_paged(path: str, params: dict | None = None, max_chars: int = MAX_CONTENT_CHARS) -> str:
"""Read a paged /api/ai text endpoint until it ends or max_chars is reached."""
params = dict(params or {})
chunks = []
read = 0
offset = 0
while True:
data = await api_get(path, params={**params, 'offset': offset})
chunks.append(data.get('content') or '')
read += len(chunks[-1])
offset = data.get('next_offset')
if offset is None or read >= max_chars:
break
text = ''.join(chunks)
if offset is not None:
text += '\n\n[Truncated]'
return text
def _render_zim_entries(data: dict) -> str:
results = data.get('results') or []
if not results:
return 'No results found.'
sections = []
for i, entry in enumerate(results, 1):
parts = [f'--- Result {i} ---']
title = entry.get('title')
link = _link(entry.get('link'))
if title and link:
parts.append(f'Title: {title} — LINK: {link}')
elif title:
parts.append(f'Title: {title}')
if entry.get('zim'):
parts.append(f'Zim: {entry["zim"]}')
if entry.get('zim_id') is not None:
parts.append(f'Zim ID: {entry["zim_id"]} | Path: {entry.get("path", "")}')
if entry.get('headline'):
parts.append(f'Headline: {entry["headline"]}')
sections.append('\n'.join(parts))
text = '\n\n'.join(sections)
if (total := data.get('total')) is not None:
text += f'\n\nTotal matching: {total}'
return text
@mcp.tool(annotations={"readOnlyHint": True})
async def search_zim(
query: str,
zim_id: int | None = None,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> str:
"""Search the Zim encyclopedias (Wikipedia, Wiktionary, etc.).
Without zim_id this searches the Zims that have 'search by default' enabled; pass a zim_id
(from list_zim_files) to search one specific Zim. Read a result with get_zim_entry.
Args:
query: Text to search for.
zim_id: ID of one Zim file to search; omit to search the default Zims.
limit: Maximum results (per Zim when searching several).
offset: Pagination offset.
"""
body = {"search_str": query, "limit": limit, "offset": offset}
if zim_id is not None:
body["zim_id"] = zim_id
try:
data = await api_post("/api/ai/zims/search", json=body)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"Zim ID {zim_id} not found. Use list_zim_files to find valid Zim IDs."
raise
return _render_zim_entries(data)
@mcp.tool(annotations={"readOnlyHint": True})
async def get_zim_entry(zim_id: int, entry_path: str) -> str:
"""Read a specific article/entry from a Zim file (e.g. a Wikipedia article).
Use search_zim first to find entry paths.
Args:
zim_id: ID of the Zim file.
entry_path: Path to the entry within the Zim file (from search results).
"""
try:
first = await api_get(f"/api/ai/zims/{zim_id}/entry", params={"path": entry_path})
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"Zim ID {zim_id} not found. Use list_zim_files to find valid Zim IDs."
raise
text = first.get("content") or ""
if first.get("next_offset") is not None:
text += await _read_paged(f"/api/ai/zims/{zim_id}/entry",
params={"path": entry_path, "offset": first["next_offset"]},
max_chars=MAX_CONTENT_CHARS - len(text))
link = _link(first.get("link"))
return f"LINK: {link}\n\n{text}"
# ---------------------------------------------------------------------------
# Library tools (the same compact set the built-in assistant uses)
# ---------------------------------------------------------------------------
@mcp.tool(annotations={"readOnlyHint": True})
async def search_files(
query: str | None = None,
kind: str | None = None,
channel: str | None = None,
domain: str | None = None,
author: str | None = None,
subject: str | None = None,
tag_names: list[str] | None = None,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> str:
"""Search the whole library (videos, archived web pages, documents/ebooks) in one call.
Args:
query: Text to search for in titles and content. Omit to browse the newest items.
kind: Narrow to "video", "archive", or "doc".
channel: A video channel name (or id); implies kind=video.
domain: An archived site name, e.g. "example.com"; implies kind=archive.
author: Document author (partial match); implies kind=doc.
subject: Document subject (partial match); implies kind=doc.
tag_names: Only items with these tags.
limit: Maximum results.
offset: Pagination offset.
"""
body = {k: v for k, v in dict(search_str=query, kind=kind, channel=channel, domain=domain, author=author,
subject=subject, tag_names=tag_names, limit=limit, offset=offset).items()
if v not in (None, [], "")}
return _render_results(await api_post("/api/ai/files/search", json=body))
@mcp.tool(annotations={"readOnlyHint": True})
async def get_file(file_group_id: int) -> str:
"""Get details about one item of any kind (video, archived page, document) by its ID from search results."""
return _render_result(await api_get(f"/api/ai/files/{file_group_id}"))
@mcp.tool(annotations={"readOnlyHint": True})
async def read_content(file_group_id: int, part: str = "text") -> str:
"""Read what an item says: a video's captions or an archived page's text (part="text"), or a video's
comments (part="comments").
Args:
file_group_id: The ID from search results.
part: "text" (default) or "comments".
"""
try:
text = await _read_paged(f"/api/ai/files/{file_group_id}/content", params={"part": part})
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"No {part} available for item {file_group_id}. Try get_file for its details."
raise
return text or f"No {part} available for item {file_group_id}."
# ---------------------------------------------------------------------------
# Browsing / listing tools
# ---------------------------------------------------------------------------
@mcp.tool(annotations={"readOnlyHint": True})
async def list_collections(
kind: str | None = None,
query: str | None = None,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> str:
"""List collections (video channels, archived domains, playlists) in the library, paged.
Args:
kind: Filter by collection kind: "channel", "domain", "playlist", or None for all.
query: Match collection names containing this text.
limit: Maximum results.
offset: Pagination offset (from a previous listing's "next offset").
"""
params = {"limit": limit, "offset": offset}
if kind:
params["kind"] = kind
if query:
params["search_str"] = query
data = await api_get("/api/ai/collections", params=params)
collections = data.get("results") or []
if not collections:
return "No collections found."
lines = []
for collection in collections:
parts = [f"ID: {collection.get('id')} | {collection.get('name', 'Unnamed')} ({collection.get('kind', '?')})"]
if collection.get("directory"):
parts.append(f" Directory: {collection['directory']}")
lines.append("\n".join(parts))
lines.append(f"\nTotal: {data.get('total', len(collections))}")
if (next_offset := data.get("next_offset")) is not None:
lines.append(f"More available: call list_collections again with offset={next_offset}")
return "\n\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True})
async def list_files(path: str = "", offset: int = 0) -> str:
"""List the directories and files inside one directory of the WROLPi media directory.
Use this to explore how the library is organized on disk (e.g. "videos/", "archive/", a channel's
directory). Directories are listed first, then files. Every entry's relative path can be passed
back to list_files to descend. Large directories are paged; call again with the returned offset.
Args:
path: Directory relative to the media directory (e.g. "videos/SomeChannel"). Empty for the top level.
offset: Number of entries to skip (from a previous listing's "next offset").
"""
try:
data = await api_get("/api/ai/files/list", params={"path": path, "offset": offset})
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"No such directory: {path or '/'}"
raise
lines = [f"Directory: {data.get('path') or '/'}"]
for directory in data.get("directories") or []:
lines.append(f" [dir] {directory['path']}")
for file in data.get("files") or []:
size = file.get("size")
size_text = f"{size / (1024 * 1024):,.1f} MB" if size and size >= 1024 * 1024 else f"{size or 0:,} B"
mimetype = file.get("mimetype") or "unknown"
lines.append(f" [file] {file['path']} ({mimetype}, {size_text})")
if not (data.get("directories") or data.get("files")):
lines.append(" (empty)")
lines.append(f"\nTotal entries: {data.get('total', 0)}")
if (next_offset := data.get("next_offset")) is not None:
lines.append(f"More entries available: call list_files again with offset={next_offset}")
return "\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True})
async def read_file(path: str) -> str:
"""Read a plain-text file from the media directory by its relative path (from list_files).
Args:
path: File path relative to the media directory, e.g. "notes/todo.txt". Text files only.
"""
try:
text = await _read_paged("/api/ai/files/read", params={"path": path})
except httpx.HTTPStatusError as e:
if e.response.status_code in (400, 404):
detail = ""
try:
detail = e.response.json().get("error") or ""
except Exception:
pass
return f"Could not read {path}: {detail or 'not a readable text file'}"
raise
return text or f"{path} is empty."
@mcp.tool(annotations={"readOnlyHint": True})
async def list_zim_files() -> str:
"""List all available Zim encyclopedias (Wikipedia, Wiktionary, etc.).
Returns Zim IDs needed for search_zim and get_zim_entry.
"""
data = await api_get("/api/ai/zims")
zims = data.get("results") or []
if not zims:
return "No Zim files found."
lines = []
for zim in zims:
size_mb = (zim.get("size") or 0) / (1024 * 1024)
parts = [
f"ID: {zim.get('id')} | {zim.get('title', 'Unknown')}",
f" Creator: {zim.get('creator', 'Unknown')}",
f" Description: {zim.get('description', 'N/A')}",
f" Size: {size_mb:,.0f} MB",
]
lines.append("\n".join(parts))
return "\n\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True})
async def list_tags() -> str:
"""List every tag in the library with how many files, Zim entries, channels, and domains carry it.
Tag names can be passed as tag_names to the search tools. Users tag what they care about, so this
is a good map of their interests. Also returns the most recently used tag names.
"""
data = await api_get("/api/ai/tags")
results = data.get("results") or []
if not results:
return "No tags found."
lines = []
for tag in results:
counts = ", ".join(f"{tag.get(k, 0)} {k.replace('_', ' ')}" for k in
("file_groups", "zim_entries", "channels", "domains") if tag.get(k))
lines.append(f" {tag['name']}" + (f" ({counts})" if counts else " (unused)"))
text = f"Tags ({data.get('total', len(results))}):\n" + "\n".join(lines)
if recent := data.get("recent"):
text += f"\n\nRecently used: {', '.join(recent)}"
return text
@mcp.tool(annotations={"readOnlyHint": True})
async def list_downloads(status: str | None = None, limit: int = DEFAULT_LIMIT) -> str:
"""Read the WROLPi download queue: summary, recurring downloads (channels, feeds), and one-time downloads.
Use this when the user asks what is downloading, what failed and why, or what is scheduled.
Downloads cannot be started, stopped, or retried from here.
Args:
status: Only show downloads with this status: new, pending, failed, deferred, or complete.
limit: Maximum recurring and one-time downloads to show (each).
"""
params = {"limit": limit}
if status:
params["status"] = status
data = await api_get("/api/ai/downloads", params=params)
summary = data.get("summary") or {}
lines = ["Summary: " + ", ".join(f"{k}={v}" for k, v in summary.items())]
def render(download: dict) -> str:
parts = [f" [{download.get('status')}] {download.get('url')} (ID: {download.get('id')},"
f" {download.get('downloader')})"]
if download.get("frequency"):
parts.append(f" every {download['frequency']}s, next: {download.get('next_download') or 'unscheduled'}")
if download.get("destination"):
parts.append(f" destination: {download['destination']}")
if download.get("tag_names"):
parts.append(f" tags: {', '.join(download['tag_names'])}")
if download.get("error"):
parts.append(f" error: {download['error']}")
return "\n".join(parts)
recurring = data.get("recurring") or []
once = data.get("once") or []
lines.append(f"\nRecurring downloads ({len(recurring)} shown):")
lines.extend(render(i) for i in recurring) if recurring else lines.append(" none")
lines.append(f"\nOne-time downloads ({len(once)} shown, {data.get('pending_once', 0)} pending):")
lines.extend(render(i) for i in once) if once else lines.append(" none")
return "\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True})
async def get_map_overview() -> str:
"""Describe the maps on this WROLPi: downloaded map regions, whether each has a place-search index,
subscribed regions, and the user's saved pins (with links)."""
data = await api_get("/api/ai/map")
lines = ["Map files:"]
files = data.get("files") or []
for file in files:
size_gb = (file.get("size") or 0) / (1024 ** 3)
index = "searchable" if file.get("has_search_index") else "no search index"
lines.append(f" {file['name']} ({size_gb:,.2f} GB, {index})")
if not files:
lines.append(" none")
subscriptions = data.get("subscriptions") or []
names = [i.get("name") or i.get("region") if isinstance(i, dict) else str(i) for i in subscriptions]
lines.append(f"\nSubscribed regions: {', '.join(names) if names else 'none'}")
lines.append("\nPins:")
pins = data.get("pins") or []
for pin in pins:
lines.append(f" {pin.get('label') or 'Unlabeled'} ({pin.get('lat')}, {pin.get('lon')})"
f" LINK: {_link(pin.get('link'))}")
if not pins:
lines.append(" none")
return "\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True})
async def search_places(
query: str,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
lat: float | None = None,
lon: float | None = None,
) -> str:
"""Find towns, cities, and landmarks by name in the downloaded maps. Each result links to the WROLPi map.
Args:
query: Place name (prefix match), e.g. "Portland".
limit: Maximum results.
offset: Pagination offset.
lat: Latitude to rank nearby places first.
lon: Longitude to rank nearby places first.
"""
params = {"q": query, "limit": limit, "offset": offset}
if lat is not None and lon is not None:
params.update(lat=lat, lon=lon)
data = await api_get("/api/ai/map/search", params=params)
results = data.get("results") or []
if not results:
return "No places found. The maps may have no search index (see get_map_overview)."
lines = []
for i, place in enumerate(results, 1):
detail = ", ".join(str(place[k]) for k in ("kind", "region") if place.get(k))
if place.get("population"):
detail += f", pop. {place['population']:,}"
lines.append(f"{i}. {place['name']} ({detail}) at {place.get('lat')}, {place.get('lon')}"
f" LINK: {_link(place.get('link'))}")
lines.append(f"\nTotal matching: {data.get('total', len(results))}")
return "\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True})
async def get_statistics() -> str:
"""Get an overview of what content is stored in the WROLPi library.
Returns counts and sizes for videos, archives, ebooks, Zim files, and other content.
"""
data = await api_get("/api/statistics")
return json.dumps(data, indent=2, default=str)
@mcp.tool(annotations={"readOnlyHint": True})
async def get_inventory(inventory_slug: str | None = None) -> str:
"""List the inventories (emergency supplies, food storage, etc.), or read one in full.
Args:
inventory_slug: The slug of one inventory to read with every item; omit to list them all.
"""
if inventory_slug:
data = await api_get("/api/ai/inventories", params={"slug": inventory_slug})
return json.dumps(data, indent=2, default=str)
data = await api_get("/api/ai/inventories")
inventories = data.get("results") or []
if not inventories:
return "No inventories found."
lines = []
for inventory in inventories:
parts = [f"Slug: {inventory.get('slug')} | {inventory.get('name', 'Unnamed')}"]
if inventory.get("item_count") is not None:
parts.append(f" Items: {inventory['item_count']}")
lines.append("\n".join(parts))
return "\n\n".join(lines)
@mcp.tool(annotations={"readOnlyHint": True})
async def get_status() -> str:
"""Get WROLPi system status (version, services, CPU, disks, flags)."""
data = await api_get("/api/ai/status")
return json.dumps(data, indent=2, default=str)