From d77eaa9ecd5be12fd706979d48283acfdfc83aba Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:03:11 -0600 Subject: [PATCH 01/26] 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 --- conftest.py | 1 + modules/ai/__init__.py | 1 + modules/ai/api.py | 361 ++++++++++++++++++++++++++++++++++++ modules/ai/conftest.py | 3 + modules/ai/lib.py | 219 ++++++++++++++++++++++ modules/ai/schema.py | 62 +++++++ modules/ai/test/__init__.py | 0 modules/ai/test/test_api.py | 267 ++++++++++++++++++++++++++ modules/ai/test/test_lib.py | 130 +++++++++++++ wrolpi/root_api.py | 2 + 10 files changed, 1046 insertions(+) create mode 100644 modules/ai/__init__.py create mode 100644 modules/ai/api.py create mode 100644 modules/ai/conftest.py create mode 100644 modules/ai/lib.py create mode 100644 modules/ai/schema.py create mode 100644 modules/ai/test/__init__.py create mode 100644 modules/ai/test/test_api.py create mode 100644 modules/ai/test/test_lib.py diff --git a/conftest.py b/conftest.py index 04a24e10..1e08eef2 100644 --- a/conftest.py +++ b/conftest.py @@ -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 diff --git a/modules/ai/__init__.py b/modules/ai/__init__.py new file mode 100644 index 00000000..8f05d9c0 --- /dev/null +++ b/modules/ai/__init__.py @@ -0,0 +1 @@ +from modules.ai.api import ai_bp # noqa diff --git a/modules/ai/api.py b/modules/ai/api.py new file mode 100644 index 00000000..191d5730 --- /dev/null +++ b/modules/ai/api.py @@ -0,0 +1,361 @@ +"""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. +""" +from http import HTTPStatus + +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.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.api_utils import json_response +from wrolpi.collections.lib import search_collections +from wrolpi.common import api_param_limiter, get_media_directory, logger +from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile +from wrolpi.files.lib import search_files +from wrolpi.schema import JSONErrorResponse +from . import lib, schema + +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 + + +def _offset(request: Request) -> int: + try: + return max(int(request.args.get('offset', 0)), 0) + except ValueError: + return 0 + + +@ai_bp.post('/search') +@openapi.definition( + summary='Search all WROLPi content', + description='Search everything in the library at once: videos, archived web pages, ebooks, PDFs, and other' + ' files. Returns lean results with a WROLPi link for each item. Prefer the type-specific search' + ' (videos/archives/docs) when the user asks about one kind of content. When total is large,' + ' narrow the query instead of paging.', + body=schema.AISearchRequest, +) +@openapi.operation('search_all') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@validate(schema.AISearchRequest) +async def search_all(_: Request, body: schema.AISearchRequest): + file_groups, total = search_files( + body.search_str, + ai_limiter(body.limit), + body.offset or 0, + mimetypes=body.mimetypes, + tag_names=body.tag_names, + headline=True, + ) + return json_response(lib.format_file_groups(file_groups, total)) + + +@ai_bp.post('/videos/search') +@openapi.definition( + summary='Search videos', + description='Search downloaded videos (and audio) by title and captions. Returns lean results with a' + ' WROLPi link and a captions_link for each video. When total is large, narrow the query' + ' instead of paging.', + body=schema.AIVideoSearchRequest, +) +@openapi.operation('search_videos') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@validate(schema.AIVideoSearchRequest) +async def search_videos(_: Request, body: schema.AIVideoSearchRequest): + file_groups, total = videos_lib.search_videos( + search_str=body.search_str, + offset=body.offset or 0, + limit=ai_limiter(body.limit), + channel_id=body.channel_id, + tag_names=body.tag_names, + headline=True, + ) + return json_response(lib.format_file_groups(file_groups, total)) + + +@ai_bp.get('/videos/') +@openapi.definition( + summary='Get one video', + description='Get details about one video by its ID from search results: title, channel, description,' + ' duration, link, and a captions_link to read what is said in the video.', +) +@openapi.operation('get_video') +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_video(_: Request, file_group_id: int): + video, _prev, _next = videos_lib.get_video_for_app(file_group_id, skip_viewed=True) + result = lib.format_file_group(video, description_length=lib.DETAIL_DESCRIPTION_LENGTH) + return json_response(result) + + +@ai_bp.get('/videos//captions') +@openapi.definition( + summary='Get video captions', + description='Read the timestamped captions of a video by its ID; use this to learn what is said in a video' + ' without watching it. Long captions are paged: request again with offset=next_offset to' + ' continue reading.', +) +@openapi.operation('get_video_captions') +@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_video_captions(request: Request, file_group_id: int): + video = videos_lib.get_video(file_group_id) + text = lib.format_caption_chunks(video.get_caption_chunks()) + return json_response(lib.paginate_text(text, _offset(request))) + + +@ai_bp.post('/archives/search') +@openapi.definition( + summary='Search archived web pages', + description='Search archived web pages (saved with SingleFile) by title and content. Returns lean results' + ' with a WROLPi link and a text_link to read each page. Filter with domain (e.g. "example.com")' + ' when the user asks about one site.', + body=schema.AIArchiveSearchRequest, +) +@openapi.operation('search_archives') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@validate(schema.AIArchiveSearchRequest) +async def search_archives(_: Request, body: schema.AIArchiveSearchRequest): + file_groups, total = archive_lib.search_archives( + body.search_str, + body.domain, + ai_limiter(body.limit), + body.offset or 0, + None, # order: default (most recently published) + body.tag_names, + headline=True, + ) + return json_response(lib.format_file_groups(file_groups, total)) + + +@ai_bp.get('/archives//text') +@openapi.definition( + summary='Read an archived web page', + description='Read the plain text content of an archived web page by its ID. Long pages are paged: request' + ' again with offset=next_offset to continue reading.', +) +@openapi.operation('get_archive_text') +@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_archive_text(request: Request, file_group_id: int): + archive = archive_lib.get_archive_by_file_group_id(request.ctx.session, file_group_id, skip_viewed=True) + text = lib.read_archive_text(archive) + if text is None: + raise UnknownFile(f'No readable text for archive {file_group_id}') + return json_response(lib.paginate_text(text, _offset(request))) + + +@ai_bp.post('/docs/search') +@openapi.definition( + summary='Search documents and ebooks', + description='Search documents (ebooks, PDFs, comics, office docs) by title, content, author, or subject.' + ' Returns lean results with a WROLPi link for each document. Provide at least one of' + ' search_str, author, subject, or tag_names.', + body=schema.AIDocSearchRequest, +) +@openapi.operation('search_docs') +@openapi.response(HTTPStatus.OK, schema.AISearchResponse) +@validate(schema.AIDocSearchRequest) +async def search_docs(_: Request, body: schema.AIDocSearchRequest): + file_groups, total = _search_docs( + search_str=body.search_str, + author=body.author, + subject=body.subject, + language=body.language, + mimetype=body.mimetype, + limit=ai_limiter(body.limit), + offset=body.offset or 0, + order_by='rank' if body.search_str else 'published_datetime', + tag_names=body.tag_names, + ) + return json_response(lib.format_file_groups(file_groups, total)) + + +@ai_bp.get('/docs/') +@openapi.definition( + summary='Get one document', + description='Get details about one document (ebook, PDF, comic, etc.) by its ID from search results:' + ' title, author, subject, description, and its WROLPi link.', +) +@openapi.operation('get_doc') +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_doc(request: Request, file_group_id: int): + doc = _get_doc(request.ctx.session, file_group_id) + response = _doc_response(doc) + file_group = response['file_group'] + # Merge the Doc's details into the FileGroup dict so the lean formatter can use them. + file_group['doc'] = response['doc'] + result = lib.format_file_group(file_group, description_length=lib.DETAIL_DESCRIPTION_LENGTH) + return json_response(result) + + +@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 Zim encyclopedias (Wikipedia, etc.). Without zim_id this searches only the Zims that' + ' have search-by-default enabled; pass zim_id (from list_zims) to search one specific Zim.' + ' Use the returned link (or zim_id and path) to read a full entry.', + 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//entry') +@openapi.definition( + summary='Read a Zim entry', + description='Read one article from a Zim encyclopedia as plain text. Pass the path from Zim search' + ' results. Long articles are paged: request again with offset=next_offset to continue' + ' reading.', +) +@openapi.operation('get_zim_entry') +@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. Filter with kind: "channel" (video channels), "domain"' + ' (archived websites), or "playlist". Use a channel\'s id with search_videos, or a domain\'s' + ' name with search_archives.', +) +@openapi.operation('list_collections') +async def list_collections(request: Request): + kind = request.args.get('kind') + collections = search_collections(request.ctx.session, kind=kind) + results = [] + for collection in collections: + 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'), + ).items() if v is not None}) + return json_response(dict(results=results, total=len(results))) + + +@ai_bp.get('/inventories') +@openapi.definition( + summary='List inventories', + description='List the inventories (food storage, emergency supplies, etc.). Use the slug with' + ' get_inventory to read the items.', +) +@openapi.operation('list_inventories') +async def list_inventories(_: Request): + inventories = get_inventory_configs().all_inventories() + results = [{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 inventories] + return json_response(dict(results=results, total=len(results))) + + +@ai_bp.get('/inventories/') +@openapi.definition( + summary='Get one inventory', + description='Get one inventory in full (its fields and every item) by its slug from list_inventories.', +) +@openapi.operation('get_inventory') +@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) +async def get_inventory(_: Request, slug: str): + inventory = get_inventory_configs().get_inventory(slug) + if inventory is None: + raise UnknownInventory(f'No inventory: {slug}') + return json_response(dict(inventory=inventory)) + + +@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 search result' + ' links or collection directories). Only text files can be read. Long files are paged:' + ' request again with offset=next_offset to continue reading.', +) +@openapi.operation('read_file') +@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') + + media_directory = get_media_directory().resolve() + path = (media_directory / relative.lstrip('/')).resolve() + if not str(path).startswith(f'{media_directory}/'): + raise InvalidFile('Cannot read outside the media directory') + # The config directory can contain secrets (Wi-Fi credentials, API keys); never serve it. + if str(path).startswith(f'{media_directory / "config"}/'): + raise InvalidFile('Cannot read config files') + 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))) diff --git a/modules/ai/conftest.py b/modules/ai/conftest.py new file mode 100644 index 00000000..25fcf989 --- /dev/null +++ b/modules/ai/conftest.py @@ -0,0 +1,3 @@ +# 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. +from modules.docs.conftest import * # noqa diff --git a/modules/ai/lib.py b/modules/ai/lib.py new file mode 100644 index 00000000..b780b333 --- /dev/null +++ b/modules/ai/lib.py @@ -0,0 +1,219 @@ +"""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 text and len(text) > length: + return text[:length] + '…' + return text + + +def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LENGTH) -> dict: + """Format a FileGroup JSON dict into the lean, flat shape the model consumes. + + Empty fields are omitted so results stay compact on small contexts. + """ + 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), + mimetype=fg.get('mimetype'), + size=fg.get('size'), + published=fg.get('published_datetime'), + author=fg.get('author'), + tags=fg.get('tags') or None, + url=fg.get('url'), + ) + + # 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'] = fg.get('length') + result['captions_link'] = f'/api/ai/videos/{fg["id"]}/captions' if fg.get('id') else None + 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 == 'archive' and fg.get('id'): + result['text_link'] = f'/api/ai/archives/{fg["id"]}/text' + + 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) -> dict: + """Format search results: lean items plus the total so the model narrows instead of paging.""" + return dict(results=[format_file_group(i) for i in file_groups], total=total) + + +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 diff --git a/modules/ai/schema.py b/modules/ai/schema.py new file mode 100644 index 00000000..0a2cc717 --- /dev/null +++ b/modules/ai/schema.py @@ -0,0 +1,62 @@ +import dataclasses +from typing import Optional + + +@dataclasses.dataclass +class AISearchRequest: + search_str: Optional[str] = None + limit: int = 5 + offset: int = 0 + mimetypes: Optional[list] = None + tag_names: Optional[list] = None + + +@dataclasses.dataclass +class AIVideoSearchRequest: + search_str: Optional[str] = None + limit: int = 5 + offset: int = 0 + channel_id: Optional[int] = None + tag_names: Optional[list] = None + + +@dataclasses.dataclass +class AIArchiveSearchRequest: + search_str: Optional[str] = None + limit: int = 5 + offset: int = 0 + domain: Optional[str] = None + tag_names: Optional[list] = None + + +@dataclasses.dataclass +class AIDocSearchRequest: + search_str: Optional[str] = None + author: Optional[str] = None + subject: Optional[str] = None + language: Optional[str] = None + mimetype: Optional[str] = None + limit: int = 5 + offset: int = 0 + tag_names: Optional[list] = None + + +@dataclasses.dataclass +class AIZimSearchRequest: + search_str: Optional[str] = None + zim_id: Optional[int] = None + limit: int = 5 + offset: int = 0 + + +@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 diff --git a/modules/ai/test/__init__.py b/modules/ai/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/ai/test/test_api.py b/modules/ai/test/test_api.py new file mode 100644 index 00000000..ea0928a8 --- /dev/null +++ b/modules/ai/test/test_api.py @@ -0,0 +1,267 @@ +"""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_search_all(async_client, test_session, video_factory, archive_factory): + """Global search returns lean results of every kind with links and a total.""" + video_factory(title='canning tomatoes') + archive_factory(domain='example.com', title='canning peppers', contents='all about canning peppers') + test_session.commit() + + request, response = await async_client.post('/api/ai/search', content=json.dumps(dict(search_str='canning'))) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 2 + kinds = {i['kind'] for i in response.json['results']} + assert kinds == {'video', 'archive'} + for result in response.json['results']: + assert result['link'] + assert result['id'] + + +@pytest.mark.asyncio +async def test_ai_search_all_limit(async_client, test_session, archive_factory): + """The limit is clamped and the total reports all matches so the model narrows, not pages.""" + for i in range(3): + archive_factory(domain='example.com', title=f'gardening {i}', contents='gardening guide') + test_session.commit() + + content = dict(search_str='gardening', limit=1) + request, response = await async_client.post('/api/ai/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert len(response.json['results']) == 1 + assert response.json['total'] == 3 + + # An excessive limit is clamped to the maximum. + content = dict(search_str='gardening', limit=10_000) + request, response = await async_client.post('/api/ai/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +async def test_ai_search_videos(async_client, test_session, video_factory): + """Video search returns lean video results with captions links.""" + video_factory(title='sourdough starter', with_caption_file=True) + video_factory(title='unrelated') + test_session.commit() + + content = dict(search_str='sourdough') + request, response = await async_client.post('/api/ai/videos/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 1 + result = response.json['results'][0] + assert result['kind'] == 'video' + assert result['link'] == f'/videos/{result["id"]}' + assert result['captions_link'] == f'/api/ai/videos/{result["id"]}/captions' + + +@pytest.mark.asyncio +async def test_ai_get_video(async_client, test_session, video_factory): + """A single video can be fetched by its FileGroup ID.""" + video = video_factory(title='wood stove install') + test_session.commit() + file_group_id = video.file_group_id + + request, response = await async_client.get(f'/api/ai/videos/{file_group_id}') + assert response.status_code == HTTPStatus.OK + assert response.json['id'] == file_group_id + assert response.json['kind'] == 'video' + assert response.json['link'] == f'/videos/{file_group_id}' + + # Fetching does not mark the video viewed. + test_session.expire_all() + assert video.file_group.viewed is None + + request, response = await async_client.get('/api/ai/videos/123456') + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_ai_get_video_captions(async_client, test_session, video_factory): + """Captions are returned as timestamped, paged text.""" + video = video_factory(title='captioned', with_caption_file=True) + test_session.commit() + + request, response = await async_client.get(f'/api/ai/videos/{video.file_group_id}/captions') + 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/videos/{video.file_group_id}/captions?offset={offset}') + assert response.status_code == HTTPStatus.OK + assert response.json['content'] == '' + assert response.json['next_offset'] is None + + +@pytest.mark.asyncio +async def test_ai_search_archives(async_client, test_session, archive_factory): + """Archive search supports the domain filter and returns text links.""" + archive_factory(domain='example.com', title='pressure canning', contents='pressure canning guide') + archive_factory(domain='other.org', title='water bath canning', contents='water bath canning guide') + test_session.commit() + + content = dict(search_str='canning', domain='example.com') + request, response = await async_client.post('/api/ai/archives/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 1 + result = response.json['results'][0] + assert result['kind'] == 'archive' + assert result['text_link'] == f'/api/ai/archives/{result["id"]}/text' + + +@pytest.mark.asyncio +async def test_ai_get_archive_text(async_client, test_session, archive_factory): + """Archive text is served from the readability text file and paged.""" + contents = 'word ' * 2_000 # 10,000 chars, more than two pages. + archive = archive_factory(domain='example.com', title='long read', contents=contents) + test_session.commit() + file_group_id = archive.file_group_id + + request, response = await async_client.get(f'/api/ai/archives/{file_group_id}/text') + assert response.status_code == HTTPStatus.OK + assert len(response.json['content']) == lib.PAGE_SIZE + assert response.json['next_offset'] == lib.PAGE_SIZE + assert response.json['total_chars'] == len(contents) + + # The second page continues where the first ended. + request, response = await async_client.get( + f'/api/ai/archives/{file_group_id}/text?offset={response.json["next_offset"]}') + assert response.status_code == HTTPStatus.OK + assert response.json['content'] == contents[lib.PAGE_SIZE:2 * lib.PAGE_SIZE] + + request, response = await async_client.get('/api/ai/archives/123456/text') + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_ai_search_docs(async_client, test_session, test_directory, example_epub, refresh_files): + """Docs can be searched and fetched with links.""" + await refresh_files() + + content = dict(search_str='WROLPi') + request, response = await async_client.post('/api/ai/docs/search', content=json.dumps(content)) + assert response.status_code == HTTPStatus.OK + assert response.json['total'] >= 1 + result = response.json['results'][0] + assert result['kind'] == 'doc' + assert result['link'] == f'/docs/{result["id"]}' + + request, response = await async_client.get(f'/api/ai/docs/{result["id"]}') + assert response.status_code == HTTPStatus.OK + assert response.json['id'] == result['id'] + assert response.json['kind'] == 'doc' + + +@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): + """Collections can be listed and filtered by kind.""" + archive_factory(domain='example.com', title='a page', contents='contents') + 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' + + request, response = await async_client.get('/api/ai/collections?kind=channel') + assert response.status_code == HTTPStatus.OK + assert response.json['total'] == 0 + + +@pytest.mark.asyncio +async def test_ai_inventories(async_client, food_inventory_factory): + """Inventories are listed lean, and fetched in full by slug.""" + 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 + assert response.json['total'] >= 1 + lean = next(i for i in response.json['results'] if i['slug'] == slug) + assert lean['item_count'] == 1 + assert 'items' not in lean + + request, response = await async_client.get(f'/api/ai/inventories/{slug}') + assert response.status_code == HTTPStatus.OK + assert response.json['inventory']['items'][0]['name'] == 'rice' + + request, response = await async_client.get('/api/ai/inventories/no-such-inventory') + assert response.status_code == HTTPStatus.NOT_FOUND + + +@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 diff --git a/modules/ai/test/test_lib.py b/modules/ai/test/test_lib.py new file mode 100644 index 00000000..00826954 --- /dev/null +++ b/modules/ai/test/test_lib.py @@ -0,0 +1,130 @@ +"""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 canning', + ) + result = lib.format_file_group(fg) + assert result['id'] == 42 + assert result['kind'] == 'video' + assert result['link'] == '/videos/42' + assert result['captions_link'] == '/api/ai/videos/42/captions' + assert result['channel'] == 'Homesteading' + assert result['duration'] == 4021 + assert result['headline'] == 'pressure canning' + 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 + + +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 result['text_link'] == '/api/ai/archives/8/text' + assert result['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 = '' \ + '

Hello

World

' + 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_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] diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 01d459c2..736bdfe0 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -13,6 +13,7 @@ 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.archive.api import archive_bp from modules.docs.api import docs_bp from modules.flasher.api import flasher_bp @@ -54,6 +55,7 @@ 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(archive_bp) api_app.blueprint(collection_bp) # Unified collection endpoints api_app.blueprint(config_bp) From b07672b9760480cfdc76b524642e9e2d2d121c8f Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:08:39 -0600 Subject: [PATCH 02/26] =?UTF-8?q?ai:=20system=20endpoints=20=E2=80=94=20ag?= =?UTF-8?q?gregate=20status,=20services,=20logs,=20disks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/ai/api.py | 144 ++++++++++++++++++++++++++++- modules/ai/controller_client.py | 27 ++++++ modules/ai/errors.py | 9 ++ modules/ai/test/test_system_api.py | 130 ++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 modules/ai/controller_client.py create mode 100644 modules/ai/errors.py create mode 100644 modules/ai/test/test_system_api.py diff --git a/modules/ai/api.py b/modules/ai/api.py index 191d5730..957b4ecc 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -16,13 +16,19 @@ 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 from wrolpi.api_utils import json_response from wrolpi.collections.lib import search_collections -from wrolpi.common import api_param_limiter, get_media_directory, logger +from wrolpi.common import api_param_limiter, get_media_directory, logger, wrol_mode_enabled +from wrolpi.downloader import download_manager from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile from wrolpi.files.lib import search_files +from wrolpi.vars import DOCKERIZED +from wrolpi.version import __version__ from wrolpi.schema import JSONErrorResponse from . import lib, schema +from .controller_client import controller_get +from .errors import ControllerUnavailable ai_bp = Blueprint('AI', url_prefix='/api/ai') @@ -328,6 +334,142 @@ async def get_inventory(_: Request, slug: str): return json_response(dict(inventory=inventory)) +# --------------------------------------------------------------------------- +# 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//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.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) + + @ai_bp.get('/files/read') @openapi.definition( summary='Read a text file', diff --git a/modules/ai/controller_client.py b/modules/ai/controller_client.py new file mode 100644 index 00000000..bef142e3 --- /dev/null +++ b/modules/ai/controller_client.py @@ -0,0 +1,27 @@ +"""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, 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() diff --git a/modules/ai/errors.py b/modules/ai/errors.py new file mode 100644 index 00000000..37ee8840 --- /dev/null +++ b/modules/ai/errors.py @@ -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 diff --git a/modules/ai/test/test_system_api.py b/modules/ai/test/test_system_api.py new file mode 100644 index 00000000..79395853 --- /dev/null +++ b/modules/ai/test/test_system_api.py @@ -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 From 85604c80fe49f3fc444e3d11b31e53a896d103ed Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:13:59 -0600 Subject: [PATCH 03/26] =?UTF-8?q?ai:=20help-docs=20search=20=E2=80=94=20He?= =?UTF-8?q?lp=20mode=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker-compose.yml | 2 + modules/ai/api.py | 45 ++++++++- modules/ai/help_docs.py | 152 ++++++++++++++++++++++++++++++ modules/ai/schema.py | 6 ++ modules/ai/test/test_help_docs.py | 114 ++++++++++++++++++++++ 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 modules/ai/help_docs.py create mode 100644 modules/ai/test/test_help_docs.py diff --git a/docker-compose.yml b/docker-compose.yml index 6de09e1c..e719f961 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,6 +49,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: diff --git a/modules/ai/api.py b/modules/ai/api.py index 957b4ecc..b65f6ec3 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -26,7 +26,7 @@ from wrolpi.files.lib import search_files from wrolpi.vars import DOCKERIZED from wrolpi.version import __version__ from wrolpi.schema import JSONErrorResponse -from . import lib, schema +from . import help_docs, lib, schema from .controller_client import controller_get from .errors import ControllerUnavailable @@ -334,6 +334,49 @@ async def get_inventory(_: Request, slug: str): return json_response(dict(inventory=inventory)) +# --------------------------------------------------------------------------- +# 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 with 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/') +@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.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. diff --git a/modules/ai/help_docs.py b/modules/ai/help_docs.py new file mode 100644 index 00000000..b0af1dcd --- /dev/null +++ b/modules/ai/help_docs.py @@ -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( + doc=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 diff --git a/modules/ai/schema.py b/modules/ai/schema.py index 0a2cc717..1c48ef74 100644 --- a/modules/ai/schema.py +++ b/modules/ai/schema.py @@ -49,6 +49,12 @@ class AIZimSearchRequest: offset: int = 0 +@dataclasses.dataclass +class AIHelpSearchRequest: + search_str: Optional[str] = None + limit: int = 5 + + @dataclasses.dataclass class AISearchResponse: results: list = dataclasses.field(default_factory=list) diff --git a/modules/ai/test/test_help_docs.py b/modules/ai/test/test_help_docs.py new file mode 100644 index 00000000..7e495a32 --- /dev/null +++ b/modules/ai/test/test_help_docs.py @@ -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]['doc'] == '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]['doc'] == '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]['doc'] == '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]['doc'] == '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['doc'] == 'system/logs' + + request, response = await async_client.get(f'/api/ai/help/{result["doc"]}') + 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 From c6e0f18d5e0d618fc266f6527c20b5ae7fcc2675 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:21:34 -0600 Subject: [PATCH 04/26] ai: ai.yaml config, model catalog, and the GGUF downloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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 --- main.py | 4 + modules/ai/__init__.py | 1 + modules/ai/api.py | 69 ++++++++++- modules/ai/catalog.py | 108 ++++++++++++++++ modules/ai/config.py | 101 +++++++++++++++ modules/ai/conftest.py | 14 +++ modules/ai/downloader.py | 120 ++++++++++++++++++ modules/ai/schema.py | 8 ++ modules/ai/test/test_config_catalog.py | 163 +++++++++++++++++++++++++ wrolpi/contexts.py | 8 ++ 10 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 modules/ai/catalog.py create mode 100644 modules/ai/config.py create mode 100644 modules/ai/downloader.py create mode 100644 modules/ai/test/test_config_catalog.py diff --git a/main.py b/main.py index 92cd3506..1ce06cd7 100755 --- a/main.py +++ b/main.py @@ -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(): diff --git a/modules/ai/__init__.py b/modules/ai/__init__.py index 8f05d9c0..a34acee8 100644 --- a/modules/ai/__init__.py +++ b/modules/ai/__init__.py @@ -1 +1,2 @@ from modules.ai.api import ai_bp # noqa +from modules.ai.downloader import ModelDownloader # noqa Instantiation registers the downloader. diff --git a/modules/ai/api.py b/modules/ai/api.py index b65f6ec3..4a72ef94 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -21,12 +21,13 @@ from wrolpi.api_utils import json_response from wrolpi.collections.lib import search_collections from wrolpi.common import api_param_limiter, get_media_directory, logger, wrol_mode_enabled from wrolpi.downloader import download_manager -from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile +from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile, ValidationError from wrolpi.files.lib import search_files -from wrolpi.vars import DOCKERIZED +from wrolpi.vars import DOCKERIZED, IS_RPI4 from wrolpi.version import __version__ from wrolpi.schema import JSONErrorResponse -from . import help_docs, lib, schema +from . import catalog, help_docs, lib, schema +from .config import get_ai_config from .controller_client import controller_get from .errors import ControllerUnavailable @@ -513,6 +514,68 @@ async def ai_list_disks(_: Request): return json_response(ret) +# --------------------------------------------------------------------------- +# 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() + downloaded = {i.name for i in models_directory.glob('*.gguf')} if models_directory.is_dir() else set() + 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 downloaded, active=i['name'] == config.active_model) + for i in models] + + 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}') + config.active_model = 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, + )) + + @ai_bp.get('/files/read') @openapi.definition( summary='Read a text file', diff --git a/modules/ai/catalog.py b/modules/ai/catalog.py new file mode 100644 index 00000000..dadf3579 --- /dev/null +++ b/modules/ai/catalog.py @@ -0,0 +1,108 @@ +"""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 + +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 are filled from the published manifest at release time. The +# meta4 sidecar (GPG-verified at download time) is what actually 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='', + size=1_200_000_000, + 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='', + size=2_600_000_000, + 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' + + +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' diff --git a/modules/ai/config.py b/modules/ai/config.py new file mode 100644 index 00000000..29569d36 --- /dev/null +++ b/modules/ai/config.py @@ -0,0 +1,101 @@ +"""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 + + +class AIConfig(ConfigFile): + file_name = 'ai.yaml' + default_config = dict( + version=0, + enabled=False, + active_model='', # a GGUF file name in /ai/models/ + idle_unload_minutes=15, + context_size=None, # None uses the active model's default from the catalog. + max_tool_calls=6, + ) + 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)}) + + +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 diff --git a/modules/ai/conftest.py b/modules/ai/conftest.py index 25fcf989..7fdd759b 100644 --- a/modules/ai/conftest.py +++ b/modules/ai/conftest.py @@ -1,3 +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 diff --git a/modules/ai/downloader.py b/modules/ai/downloader.py new file mode 100644 index 00000000..30d95bec --- /dev/null +++ b/modules/ai/downloader.py @@ -0,0 +1,120 @@ +"""Downloads GGUF model files from the WROLPi CDN into /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() diff --git a/modules/ai/schema.py b/modules/ai/schema.py index 1c48ef74..7d7390db 100644 --- a/modules/ai/schema.py +++ b/modules/ai/schema.py @@ -55,6 +55,14 @@ class AIHelpSearchRequest: 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) diff --git a/modules/ai/test/test_config_catalog.py b/modules/ai/test/test_config_catalog.py new file mode 100644 index 00000000..6fca689a --- /dev/null +++ b/modules/ai/test/test_config_catalog.py @@ -0,0 +1,163 @@ +"""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 + + 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 /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'fake' + + 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''), \ + 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 diff --git a/wrolpi/contexts.py b/wrolpi/contexts.py index d961cdf2..e6c59f69 100644 --- a/wrolpi/contexts.py +++ b/wrolpi/contexts.py @@ -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() @@ -126,6 +127,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 +315,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}') From 41bd8f7c45b9489a25bf51e737d2f314639e8af1 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:27:20 -0600 Subject: [PATCH 05/26] ai: register AIConfig in get_all_configs so it imports on startup Co-Authored-By: Claude Fable 5 --- wrolpi/common.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wrolpi/common.py b/wrolpi/common.py index c9695d15..9f690ba4 100644 --- a/wrolpi/common.py +++ b/wrolpi/common.py @@ -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 From 485a9703ebb0b41eaa4dd94160efe04f5f5a65d8 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:36:00 -0600 Subject: [PATCH 06/26] ai: package llama-server for all three deployments, with idle unload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- controller/defaults.py | 10 ++ controller/lib/docker_services.py | 2 +- controller/test/test_defaults.py | 4 +- .../hooks/normal/9999-wrolpi.hook.chroot | 3 + docker-compose.yml | 17 +++ docker/llm/Dockerfile | 17 +++ docker/llm/entrypoint.sh | 5 + etc/raspberrypios/wrolpi-llm.service | 17 +++ modules/ai/__init__.py | 1 + modules/ai/controller_client.py | 12 +- modules/ai/service.py | 112 ++++++++++++++++++ modules/ai/test/test_service.py | 78 ++++++++++++ pi-gen/stage2/04-wrolpi/03-run-chroot.sh | 3 + scripts/start_llama_server.sh | 54 +++++++++ scripts/upgrade.sh | 4 + wrolpi/contexts.py | 3 + wrolpi/scripts/install_llama_cpp.sh | 65 ++++++++++ wrolpi/scripts/read_config_value.sh | 3 +- 18 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 docker/llm/Dockerfile create mode 100755 docker/llm/entrypoint.sh create mode 100644 etc/raspberrypios/wrolpi-llm.service create mode 100644 modules/ai/service.py create mode 100644 modules/ai/test/test_service.py create mode 100755 scripts/start_llama_server.sh create mode 100755 wrolpi/scripts/install_llama_cpp.sh diff --git a/controller/defaults.py b/controller/defaults.py index 2e469170..ebd32065 100644 --- a/controller/defaults.py +++ b/controller/defaults.py @@ -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", diff --git a/controller/lib/docker_services.py b/controller/lib/docker_services.py index 82459363..e01f76f7 100644 --- a/controller/lib/docker_services.py +++ b/controller/lib/docker_services.py @@ -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"} diff --git a/controller/test/test_defaults.py b/controller/test/test_defaults.py index 960721d3..6c8bbac4 100644 --- a/controller/test/test_defaults.py +++ b/controller/test/test_defaults.py @@ -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.""" diff --git a/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot b/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot index afd3c2e5..84c134f6 100644 --- a/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot +++ b/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index e719f961..aab5101c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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' @@ -147,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: . diff --git a/docker/llm/Dockerfile b/docker/llm/Dockerfile new file mode 100644 index 00000000..24ef1f6e --- /dev/null +++ b/docker/llm/Dockerfile @@ -0,0 +1,17 @@ +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 && 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"] diff --git a/docker/llm/entrypoint.sh b/docker/llm/entrypoint.sh new file mode 100755 index 00000000..2e19fa40 --- /dev/null +++ b/docker/llm/entrypoint.sh @@ -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 diff --git a/etc/raspberrypios/wrolpi-llm.service b/etc/raspberrypios/wrolpi-llm.service new file mode 100644 index 00000000..46208744 --- /dev/null +++ b/etc/raspberrypios/wrolpi-llm.service @@ -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 diff --git a/modules/ai/__init__.py b/modules/ai/__init__.py index a34acee8..0fbb8d43 100644 --- a/modules/ai/__init__.py +++ b/modules/ai/__init__.py @@ -1,2 +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. diff --git a/modules/ai/controller_client.py b/modules/ai/controller_client.py index bef142e3..383451d4 100644 --- a/modules/ai/controller_client.py +++ b/modules/ai/controller_client.py @@ -8,7 +8,7 @@ import os from typing import Tuple from urllib.parse import urlencode -from wrolpi.common import aiohttp_get, logger +from wrolpi.common import aiohttp_get, aiohttp_post, logger from wrolpi.vars import DOCKERIZED logger = logger.getChild(__name__) @@ -25,3 +25,13 @@ async def controller_get(path: str, params: dict = None, timeout: int = 10) -> T 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 diff --git a/modules/ai/service.py b/modules/ai/service.py new file mode 100644 index 00000000..2299ee0d --- /dev/null +++ b/modules/ai/service.py @@ -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) diff --git a/modules/ai/test/test_service.py b/modules/ai/test/test_service.py new file mode 100644 index 00000000..28d74bf5 --- /dev/null +++ b/modules/ai/test/test_service.py @@ -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 diff --git a/pi-gen/stage2/04-wrolpi/03-run-chroot.sh b/pi-gen/stage2/04-wrolpi/03-run-chroot.sh index 9affc7f8..97851f89 100755 --- a/pi-gen/stage2/04-wrolpi/03-run-chroot.sh +++ b/pi-gen/stage2/04-wrolpi/03-run-chroot.sh @@ -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 || diff --git a/scripts/start_llama_server.sh b/scripts/start_llama_server.sh new file mode 100755 index 00000000..b1e786ba --- /dev/null +++ b/scripts/start_llama_server.sh @@ -0,0 +1,54 @@ +#! /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. +exec llama-server \ + --host "${LLAMA_HOST}" \ + --port "${LLAMA_PORT}" \ + --jinja \ + -c "${CONTEXT_SIZE}" \ + -m "${MODEL}" diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 382e4a80..cbfde41e 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -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 diff --git a/wrolpi/contexts.py b/wrolpi/contexts.py index e6c59f69..644bf6a1 100644 --- a/wrolpi/contexts.py +++ b/wrolpi/contexts.py @@ -58,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() diff --git a/wrolpi/scripts/install_llama_cpp.sh b/wrolpi/scripts/install_llama_cpp.sh new file mode 100755 index 00000000..c476e3a1 --- /dev/null +++ b/wrolpi/scripts/install_llama_cpp.sh @@ -0,0 +1,65 @@ +#!/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. +MARKER_DIR=/usr/local/share/wrolpi +MARKER="${MARKER_DIR}/llama_cpp_version" + +install_llama_cpp() { + if [ -f "${MARKER}" ] && [ "$(cat "${MARKER}")" = "${LLAMA_CPP_VERSION}" ] \ + && 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 + + # Static-ish single binary; GGML_NATIVE=OFF so an image built in a chroot/QEMU + # does not bake in the build host's CPU features. + (cd "${build_dir}" && + cmake -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DGGML_NATIVE=OFF \ + -DLLAMA_CURL=OFF \ + -DLLAMA_BUILD_TESTS=OFF \ + -DLLAMA_BUILD_EXAMPLES=OFF \ + -DLLAMA_BUILD_SERVER=ON && + cmake --build build --target llama-server -j"$(nproc)") + install -m 0755 "${build_dir}/build/bin/llama-server" /usr/local/bin/llama-server + rm -rf "${build_dir}" + + mkdir -p "${MARKER_DIR}" + echo "${LLAMA_CPP_VERSION}" > "${MARKER}" + llama-server --version || true + echo "llama.cpp ${LLAMA_CPP_VERSION} installed" +} + +install_llama_cpp diff --git a/wrolpi/scripts/read_config_value.sh b/wrolpi/scripts/read_config_value.sh index c9f55f7f..e57f0637 100755 --- a/wrolpi/scripts/read_config_value.sh +++ b/wrolpi/scripts/read_config_value.sh @@ -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 From f86058ee8a201ec7078ffd63b3ae09a7cd296c73 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:47:50 -0600 Subject: [PATCH 07/26] =?UTF-8?q?ai:=20the=20/api/chat=20agent=20loop=20?= =?UTF-8?q?=E2=80=94=20SSE=20streaming,=20modes,=20tool=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker/web/Caddyfile | 7 + etc/raspberrypios/Caddyfile | 7 + modules/ai/agent.py | 236 +++++++++++++++++++++++++++++++ modules/ai/api.py | 10 ++ modules/ai/chat.py | 200 ++++++++++++++++++++++++++ modules/ai/modes.py | 81 +++++++++++ modules/ai/schema.py | 12 +- modules/ai/test/test_agent.py | 135 ++++++++++++++++++ modules/ai/test/test_chat_api.py | 188 ++++++++++++++++++++++++ wrolpi/root_api.py | 2 + 10 files changed, 872 insertions(+), 6 deletions(-) create mode 100644 modules/ai/agent.py create mode 100644 modules/ai/chat.py create mode 100644 modules/ai/modes.py create mode 100644 modules/ai/test/test_agent.py create mode 100644 modules/ai/test/test_chat_api.py diff --git a/docker/web/Caddyfile b/docker/web/Caddyfile index 1d745032..b775e57a 100644 --- a/docker/web/Caddyfile +++ b/docker/web/Caddyfile @@ -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 diff --git a/etc/raspberrypios/Caddyfile b/etc/raspberrypios/Caddyfile index 5a6f2484..e0a2a2fe 100644 --- a/etc/raspberrypios/Caddyfile +++ b/etc/raspberrypios/Caddyfile @@ -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 diff --git a/modules/ai/agent.py b/modules/ai/agent.py new file mode 100644 index 00000000..bb3f379c --- /dev/null +++ b/modules/ai/agent.py @@ -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 diff --git a/modules/ai/api.py b/modules/ai/api.py index 4a72ef94..7413d65e 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -117,6 +117,7 @@ async def get_video(_: Request, file_group_id: int): ' continue reading.', ) @openapi.operation('get_video_captions') +@openapi.parameter('offset', int, 'query') @openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) async def get_video_captions(request: Request, file_group_id: int): @@ -156,6 +157,7 @@ async def search_archives(_: Request, body: schema.AIArchiveSearchRequest): ' again with offset=next_offset to continue reading.', ) @openapi.operation('get_archive_text') +@openapi.parameter('offset', int, 'query') @openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) async def get_archive_text(request: Request, file_group_id: int): @@ -268,6 +270,8 @@ async def search_zims(request: Request, body: schema.AIZimSearchRequest): ' reading.', ) @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): @@ -289,6 +293,7 @@ async def get_zim_entry(request: Request, zim_id: int): ' name with search_archives.', ) @openapi.operation('list_collections') +@openapi.parameter('kind', str, 'query') async def list_collections(request: Request): kind = request.args.get('kind') collections = search_collections(request.ctx.session, kind=kind) @@ -366,6 +371,7 @@ async def search_help(_: Request, body: schema.AIHelpSearchRequest): ' 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): @@ -466,6 +472,8 @@ async def ai_list_services(_: Request): ' 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: @@ -584,6 +592,8 @@ async def manage_settings(_: Request, body: schema.AIManageSettingsRequest): ' request again with offset=next_offset to continue reading.', ) @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): diff --git a/modules/ai/chat.py b/modules/ai/chat.py new file mode 100644 index 00000000..43166e08 --- /dev/null +++ b/modules/ai/chat.py @@ -0,0 +1,200 @@ +"""POST /api/chat — the agent loop, streamed over SSE. + +Events (each `event: ` 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 .config import get_ai_config +from .modes import 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." + + +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.""" + body = dict(messages=messages, stream=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 + + messages = [dict(role='system', content=mode['system_prompt'])] + messages += [dict(role=m.get('role', 'user'), content=m.get('content', '')) for m in body.messages] + + final_answer = [] + for _ in range(max_tool_calls + 1): + if time.time() > deadline: + await _send_event(response, 'error', dict(message=FALLBACK_MESSAGE)) + return + + service.record_llm_activity() + 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() diff --git a/modules/ai/modes.py b/modules/ai/modes.py new file mode 100644 index 00000000..c4aa05f6 --- /dev/null +++ b/modules/ai/modes.py @@ -0,0 +1,81 @@ +"""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.' + ' Search the help documentation with search_help, read pages with get_help_doc, and answer' + ' 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, then read captions, page text, or entries when the user wants detail.' + ' 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. If nothing matches, say so.' + ), + tools=( + 'search_all', + 'search_videos', + 'get_video', + 'get_video_captions', + 'search_archives', + 'get_archive_text', + 'search_docs', + 'get_doc', + 'list_zims', + 'search_zims', + 'get_zim_entry', + 'list_collections', + 'list_inventories', + 'get_inventory', + 'read_file', + ), + ), + 'system': dict( + system_prompt=( + f'{COMMON_PROMPT}' + ' Your job is to troubleshoot the user\'s WROLPi.' + ' Start every diagnosis with get_system_status; 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', + ), + ), +} + +# 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?'], +} diff --git a/modules/ai/schema.py b/modules/ai/schema.py index 7d7390db..6f6485d1 100644 --- a/modules/ai/schema.py +++ b/modules/ai/schema.py @@ -1,5 +1,5 @@ import dataclasses -from typing import Optional +from typing import List, Optional @dataclasses.dataclass @@ -7,8 +7,8 @@ class AISearchRequest: search_str: Optional[str] = None limit: int = 5 offset: int = 0 - mimetypes: Optional[list] = None - tag_names: Optional[list] = None + mimetypes: Optional[List[str]] = None + tag_names: Optional[List[str]] = None @dataclasses.dataclass @@ -17,7 +17,7 @@ class AIVideoSearchRequest: limit: int = 5 offset: int = 0 channel_id: Optional[int] = None - tag_names: Optional[list] = None + tag_names: Optional[List[str]] = None @dataclasses.dataclass @@ -26,7 +26,7 @@ class AIArchiveSearchRequest: limit: int = 5 offset: int = 0 domain: Optional[str] = None - tag_names: Optional[list] = None + tag_names: Optional[List[str]] = None @dataclasses.dataclass @@ -38,7 +38,7 @@ class AIDocSearchRequest: mimetype: Optional[str] = None limit: int = 5 offset: int = 0 - tag_names: Optional[list] = None + tag_names: Optional[List[str]] = None @dataclasses.dataclass diff --git a/modules/ai/test/test_agent.py b/modules/ai/test/test_agent.py new file mode 100644 index 00000000..f63e1526 --- /dev/null +++ b/modules/ai/test/test_agent.py @@ -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_videos'].parameters_schema() + assert schema['properties']['search_str'] == {'type': 'string'} + assert schema['properties']['tag_names'] == {'type': 'array', 'items': {'type': 'string'}} + schema = tools['get_video_captions'].parameters_schema() + assert schema['required'] == ['file_group_id'] + assert schema['properties']['offset'] == {'type': 'integer'} + + # OpenAI tool format. + openai_tool = tools['search_all'].as_openai_tool() + assert openai_tool['type'] == 'function' + assert openai_tool['function']['name'] == 'search_all' + + +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_videos', + dict(search_str='canning')) + 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_videos', 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, 'get_video_captions', {}) + assert success is False and 'Missing required' in error + + # Wrong type. + success, error = await agent.execute_tool(research_tools, 'get_video_captions', + 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, 'get_video_captions', + 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'], 'get_archive_text', + dict(file_group_id=archive.file_group_id)) + assert success is True + assert len(result) <= agent.TOOL_RESULT_MAX_CHARS + 100 diff --git a/modules/ai/test/test_chat_api.py b/modules/ai/test/test_chat_api.py new file mode 100644 index 00000000..cdb8b13f --- /dev/null +++ b/modules/ai/test/test_chat_api.py @@ -0,0 +1,188 @@ +"""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_videos', arguments='{"search_str": "canning"}')])], + [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) + names = [e for e, _ in events] + assert names == ['tool_call', 'tool_result', 'token', 'token', 'done'] + tool_call = dict(events)['tool_call'] + assert tool_call == dict(tool='search_videos', args=dict(search_str='canning')) + assert dict(events)['tool_result'] == dict(tool='search_videos', success=True) + assert dict(events)['done'] == dict(content='Found one video.') + + # The second llama call saw the system prompt, 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 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 = _parse_sse(response.text) + 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. + tool_messages = [m for m in calls['messages'][-1] if m['role'] == 'tool'] + 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'] diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 736bdfe0..5fd2d82b 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -14,6 +14,7 @@ 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 @@ -56,6 +57,7 @@ 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) From 0347c2901bfbce71fdf9d77860a665cc44d88187 Mon Sep 17 00:00:00 2001 From: Roland Date: Tue, 25 Aug 2026 21:56:36 -0600 Subject: [PATCH 08/26] =?UTF-8?q?ai:=20the=20AI=20nav=20page=20=E2=80=94?= =?UTF-8?q?=20Manage=20tab=20(model=20catalog,=20settings,=20service)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New AI nav item and /ai route with Chat and Manage tabs (Chat is a placeholder until the SSE chat lands). The Manage tab mirrors the Map/Zim download UX: the model catalog with the RAM-based tier recommendation and a Pi 4 performance hint, downloads through the download manager (progress on the Downloads page), active-model select, enable toggle, idle-unload minutes, and llama-server start/stop through the Controller (compose service name in docker, systemd unit natively). AiModelRow is in the theme-sample gallery. Co-Authored-By: Claude Fable 5 --- app/src/App.js | 2 + app/src/api.js | 30 ++++ app/src/components/Ai.js | 208 ++++++++++++++++++++++++++ app/src/components/Ai.test.js | 112 ++++++++++++++ app/src/components/Nav.js | 1 + app/src/components/ThemeSamplePage.js | 27 ++++ app/src/components/Vars.js | 1 + 7 files changed, 381 insertions(+) create mode 100644 app/src/components/Ai.js create mode 100644 app/src/components/Ai.test.js diff --git a/app/src/App.js b/app/src/App.js index 1e372a53..a9881881 100644 --- a/app/src/App.js +++ b/app/src/App.js @@ -31,6 +31,7 @@ import {ZimRoute} from "./components/Zim"; import {PlaylistsRoute} from "./components/Playlists"; import {DocsRoute} from "./components/Docs"; import {FlasherRoute} from "./components/Flasher"; +import {AiRoute} from "./components/Ai"; import ErrorBoundary from "./components/ErrorBoundary"; import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider"; @@ -116,6 +117,7 @@ const router = createBrowserRouter(createRoutesFromElements(}/> }/> }/> + }/> )); export default function App() { diff --git a/app/src/api.js b/app/src/api.js index b06ef557..ddb05a2b 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -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}); +} diff --git a/app/src/components/Ai.js b/app/src/components/Ai.js new file mode 100644 index 00000000..7760b0e9 --- /dev/null +++ b/app/src/components/Ai.js @@ -0,0 +1,208 @@ +import React, {useContext, useEffect, useState} from 'react'; +import {Route, Routes} from 'react-router'; + +import {getAiCatalog, postAiSettings, postDownload} from '../api'; +import {getServiceStatus, startService, stopService} from '../api/controller'; +import {StatusContext} from '../contexts/contexts'; +import {APIButton, humanFileSize, InfoMessage, PageContainer, TabLinks, useTitle, WROLModeMessage} from './Common'; +import {SortableTable} from './SortableTable'; +import {Button, Divider, Group, Header, NumberInput, Select, Table, Text, Toggle, toast} from './ui'; +import {Downloaders} from './Vars'; + +// The Controller's name for llama-server: the compose service in docker, the systemd unit natively. +export const llmServiceName = (dockerized) => dockerized ? 'llm' : 'wrolpi-llm'; + +export function AiModelRow({model, onDownload}) { + const {name, tier, size, description, downloaded, active} = model; + + return + + {name}{active ? ' (active)' : ''} + {description && {description}} + + {tier} + {humanFileSize(size)} + + {downloaded + ? + : onDownload(model)}> + Download + } + + ; +} + +export function ManageAi() { + const [catalog, setCatalog] = useState(null); + const [serviceStatus, setServiceStatus] = useState(null); + const [pendingSave, setPendingSave] = useState(false); + const {status} = useContext(StatusContext); + const serviceName = llmServiceName(status?.dockerized); + + const fetchCatalog = async () => { + try { + const data = await getAiCatalog(); + if (data) { + setCatalog(data); + } + } catch (e) { + console.error(e); + } + }; + + const fetchService = async () => { + try { + setServiceStatus(await getServiceStatus(serviceName)); + } catch (e) { + // The service is unknown until AI is installed; not an error worth toasting. + setServiceStatus(null); + } + }; + + useEffect(() => { + fetchCatalog(); + fetchService(); + const interval = setInterval(() => { + fetchCatalog(); + fetchService(); + }, 30000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serviceName]); + + const handleDownload = async (model) => { + await postDownload({ + urls: [model.url], + downloader: Downloaders.AiModel, + destination: 'ai/models', + }); + toast({ + type: 'success', + title: 'Download started', + description: 'Monitor progress on the Downloads page.', + time: 5000, + }); + }; + + const saveSettings = async (settings) => { + setPendingSave(true); + try { + await postAiSettings(settings); + await fetchCatalog(); + } finally { + setPendingSave(false); + } + }; + + const handleServiceButton = async () => { + if (serviceStatus?.status === 'running') { + await stopService(serviceName); + } else { + await startService(serviceName); + } + await fetchService(); + }; + + if (!catalog) { + return ; + } + + const {models, recommended_tier, total_ram, slow_hardware, disk_usage, enabled, active_model} = catalog; + const downloadedModels = models.filter(i => i.downloaded); + + const modelHeaders = [ + {key: 'name', text: 'Model', sortBy: i => i.name.toLowerCase()}, + {key: 'tier', text: 'Tier', sortBy: i => i.tier}, + {key: 'size', text: 'Size', sortBy: i => i.size || 0}, + {key: 'actions', text: '', sortBy: null}, + ]; + + return <> + + + {slow_hardware && + This hardware will run the assistant slowly. Answers may take minutes; the smallest + model is recommended. + } + +
AI Assistant
+ + saveSettings({enabled: e.target.checked})} + /> +