diff --git a/README.md b/README.md index 2daebd0..cb1158a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ runtime. Clone it, `pip install -e .`, run it. put in the prompt - **Folders** — arbitrarily nested, delete a folder without losing the chats inside it +- **Web search** — offered to the model as a tool it calls when a question needs + it. DuckDuckGo out of the box (no account, no key), or point it at your own + SearXNG, or Firecrawl. The sources stay in the transcript +- **Speech in and out** — dictate a message and have replies read aloud, against + any OpenAI-compatible audio endpoint (whisper.cpp, Speaches, Kokoro…). Each + person picks their own voice +- **Installable** — add it to a phone home screen or a desktop launcher and it + runs in its own window - **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp, llama-swap, Ollama or OpenRouter; models are discovered and cached - **Model settings** — searchable, filterable list with a page per model: @@ -64,8 +72,8 @@ runtime. Clone it, `pip install -e .`, run it. **Planned** -Built-in tools with admin settings · custom tools and MCP servers · agentic -execution (local and over SSH) · image generation · OCR for scanned PDFs. +Custom tools and MCP servers · agentic execution (local and over SSH) · image +generation · OCR for scanned PDFs. See [PLAN.md](PLAN.md) for what is built, what is not, and why. @@ -76,7 +84,7 @@ git clone https://git.houmeres.sk/Houmeres/LLeMbas.git cd LLeMbas python -m venv .venv && . .venv/bin/activate -pip install -e ".[dev]" +pip install -e ".[dev,search]" # `search` adds DuckDuckGo; drop it if unwanted cp .env.example .env lembas secret-key # paste the result into LEMBAS_SECRET_KEY @@ -93,6 +101,44 @@ and its models appear in the chat model picker. > access is needed to run. To re-fetch or bump them: > `python scripts/fetch_vendor.py --update`. +### Web search + +**Admin → Web search.** DuckDuckGo needs nothing beyond the `search` extra +above. SearXNG needs its JSON format enabled — add `- json` under +`search.formats` in its `settings.yml`, or every search fails. Firecrawl needs +an API key. + +Search is offered to the model as a *tool*, so it decides when a question needs +looking up. It is only offered to models marked **tools** under +**Admin → Models**: an endpoint without tool support rejects the whole request +rather than ignoring the extra field, so the flag is a real switch and not a +hint. + +### Audio + +**Admin → Audio.** Two endpoints, because they are usually two servers: + +| | Speaks | Example | +|---|---|---| +| Dictation | `POST /v1/audio/transcriptions` | whisper.cpp's `whisper-server`, Speaches, faster-whisper-server | +| Read aloud | `POST /v1/audio/speech` | Kokoro-FastAPI, OpenAI | + +If the speech endpoint also answers `GET /v1/audio/voices` the voice list is +read from it, and each person can pick their own under **Settings → Audio**. +Recorded audio is passed straight through and never written to disk. + +> The microphone needs HTTPS or localhost. Browsers do not grant it over plain +> HTTP, so a LAN install without TLS will not offer dictation. + +### Installing as an app + +Open it in a browser and use *Install* (Chromium) or *Share → Add to Home +Screen* (iOS). This also needs HTTPS or localhost — service workers are +unavailable over plain HTTP, and without one there is nothing to install. + +There is no offline mode beyond a page saying so. Everything is rendered by your +server, so a cached conversation would be a snapshot that silently went stale. + ## Configuration All variables are prefixed `LEMBAS_` and can live in `.env`. See diff --git a/assets/apple-touch-icon-180.png b/assets/apple-touch-icon-180.png new file mode 100644 index 0000000..9a208f2 Binary files /dev/null and b/assets/apple-touch-icon-180.png differ diff --git a/assets/icon-192.png b/assets/icon-192.png new file mode 100644 index 0000000..6c528e8 Binary files /dev/null and b/assets/icon-192.png differ diff --git a/assets/icon-512.png b/assets/icon-512.png new file mode 100644 index 0000000..2a23230 Binary files /dev/null and b/assets/icon-512.png differ diff --git a/assets/icon-maskable-512.png b/assets/icon-maskable-512.png new file mode 100644 index 0000000..c9a1f13 Binary files /dev/null and b/assets/icon-maskable-512.png differ diff --git a/deploy/install.sh b/deploy/install.sh index aac818e..9a095ec 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -76,7 +76,9 @@ if [[ ! -x "$VENV/bin/python" ]]; then sudo -u "$SERVICE_USER" python -m venv "$VENV" fi sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip -sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP" +# With the `search` extra: DuckDuckGo is the default web search provider and is +# meant to work with no setup at all. +sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP[search]" echo "== environment ==" # Generated once and never regenerated: rotating LEMBAS_SECRET_KEY signs every diff --git a/deploy/nginx-vhost.conf b/deploy/nginx-vhost.conf index 4d32155..0308a57 100644 --- a/deploy/nginx-vhost.conf +++ b/deploy/nginx-vhost.conf @@ -58,4 +58,14 @@ server { expires 1h; add_header Cache-Control "public"; } + + # The service worker must never be cached. A stale worker keeps serving a + # stale cache to every tab, and there is no way to tell it to stop. The + # application already sends no-store; this stops the proxy overriding it. + # /manifest.webmanifest needs nothing special and comes through location /. + location = /sw.js { + proxy_pass http://127.0.0.1:__APP_PORT__; + proxy_set_header Host $host; + add_header Cache-Control "no-store"; + } } diff --git a/deploy/update.sh b/deploy/update.sh index 781cb1e..a98da8b 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -37,8 +37,11 @@ else fi # Cheap and idempotent; catches a dependency added since the last deploy. +# The `search` extra is included because DuckDuckGo is the default web search +# provider and is meant to need no setup -- a deployment without it offers a +# provider that fails on every call. echo "== dependencies ==" -sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP" +sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP[search]" echo "== restart ==" sudo systemctl restart lembas diff --git a/pyproject.toml b/pyproject.toml index 0b28960..5533618 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,11 @@ dev = [ "pytest-asyncio>=0.24", "ruff>=0.7", ] +# DuckDuckGo search. Optional because it brings a compiled HTTP client and an +# XML parser with it, and the other two search providers need only httpx, which +# is already a core dependency. Without this the provider is offered in the +# admin UI with an install hint rather than silently missing. +search = ["ddgs>=9.0"] [project.scripts] lembas = "lembas.cli:app" diff --git a/scripts/build_artwork.py b/scripts/build_artwork.py index 547fc3e..c70b912 100755 --- a/scripts/build_artwork.py +++ b/scripts/build_artwork.py @@ -14,8 +14,14 @@ are extracted, as a static drawing -- no font binary is redistributed. This is a design-time tool. The application never imports it, and the generated files are committed. Re-run it only when the artwork itself changes: - pip install fonttools + pip install fonttools cairosvg python scripts/build_artwork.py + +cairosvg is needed only for the PWA icons, which have to be PNG: an installed +web app's icon is drawn by the operating system's launcher, and neither +Android's adaptive-icon masking nor iOS's home screen will take an SVG. The +rasterisation happens here, once, and the PNGs are committed like everything +else -- the running application still has no build step and no rasteriser. """ from __future__ import annotations @@ -39,7 +45,15 @@ STATIC_IMG = ROOT / "src" / "lembas" / "web" / "static" / "img" # assets/ holds the design masters; the application serves its own copies from # static/. These are the few the running app actually needs. -SERVED_BY_APP = ("favicon.svg", "logo-mark.svg", "banner.svg") +SERVED_BY_APP = ( + "favicon.svg", + "logo-mark.svg", + "banner.svg", + "icon-192.png", + "icon-512.png", + "icon-maskable-512.png", + "apple-touch-icon-180.png", +) FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf") FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf") @@ -321,6 +335,66 @@ def build_lockup() -> str: """ +# --- PWA icons --------------------------------------------------------------- +# Same geometry as everything else, rasterised because a launcher icon has to +# be a bitmap. Two shapes are needed, not one: +# +# "any" -- drawn as supplied, so the wafer's own rounded square is the +# silhouette and the corners stay transparent. +# "maskable" -- Android crops it to a circle, squircle or rounded square of +# the launcher's choosing, so the art must be full-bleed and +# the mark must sit inside the central safe zone. An "any" +# icon used as maskable gets its corners sliced off. +# +# The Apple icon is opaque for a different reason: iOS composites a home screen +# icon onto black, so transparency reads as a black tile rather than as the +# wallpaper showing through. +def _framed_mark(prefix: str, *, background: str | None = None, inset: float = 0.0) -> str: + """The mark on a 64x64 canvas, optionally opaque and inset from the edges.""" + size = 64.0 + offset = size * inset + scale = 1.0 - inset * 2 + plate = f' \n' + return f"""{HEADER} viewBox="0 0 64 64" width="64" height="64" + role="img" aria-label="LLeMbas"> +{mark_defs(prefix)} +{plate if background else ""} +{mark_body(prefix)} + + +""" + + +def _rasterise(svg: str, size: int) -> bytes: + try: + import cairosvg + except ImportError: # pragma: no cover - design-time tool + sys.exit("cairosvg is required for the PWA icons: pip install cairosvg") + return cairosvg.svg2png( + bytestring=svg.encode("utf-8"), output_width=size, output_height=size + ) + + +def build_icon_192() -> bytes: + return _rasterise(_framed_mark("i192"), 192) + + +def build_icon_512() -> bytes: + return _rasterise(_framed_mark("i512"), 512) + + +def build_icon_maskable() -> bytes: + # 20% inset leaves the mark inside the central 60%, comfortably within the + # 80% safe circle every launcher mask respects. + return _rasterise(_framed_mark("imask", background=NIGHT_MID, inset=0.20), 512) + + +def build_apple_touch_icon() -> bytes: + # iOS rounds the corners itself, so only a hairline of padding is wanted. + return _rasterise(_framed_mark("iios", background=NIGHT_MID, inset=0.06), 180) + + def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str: """One jagged ridge line spanning the full width.""" rng = random.Random(seed) @@ -458,6 +532,10 @@ BUILDERS = { "wordmark.svg": build_wordmark, "logo-lockup.svg": build_lockup, "banner.svg": build_banner, + "icon-192.png": build_icon_192, + "icon-512.png": build_icon_512, + "icon-maskable-512.png": build_icon_maskable, + "apple-touch-icon-180.png": build_apple_touch_icon, } @@ -472,13 +550,16 @@ def main() -> None: for filename in args.only or BUILDERS: content = BUILDERS[filename]() + # The PNG builders return bytes; everything else returns SVG source. + data = content if isinstance(content, bytes) else content.encode("utf-8") + path = args.out / filename - path.write_text(content, encoding="utf-8") - print(f"wrote {path.relative_to(ROOT)} ({len(content.encode()):,} bytes)") + path.write_bytes(data) + print(f"wrote {path.relative_to(ROOT)} ({len(data):,} bytes)") if filename in SERVED_BY_APP: served = STATIC_IMG / filename - served.write_text(content, encoding="utf-8") + served.write_bytes(data) print(f" -> {served.relative_to(ROOT)}") diff --git a/src/lembas/api/admin.py b/src/lembas/api/admin.py index abbca8a..a786531 100644 --- a/src/lembas/api/admin.py +++ b/src/lembas/api/admin.py @@ -13,7 +13,7 @@ from sqlalchemy.orm import Session as DBSession from lembas.api.deps import AdminUser, Db from lembas.db.models import Connection, Model, User from lembas.services import settings_store -from lembas.services.crypto import decrypt, encrypt, mask +from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt, mask from lembas.services.llm.openai_client import Endpoint, LLMError, list_models from lembas.web.templating import render @@ -21,11 +21,6 @@ log = logging.getLogger(__name__) router = APIRouter(prefix="/admin", tags=["admin"]) -# Sent back in place of a stored key. If a submitted key still equals this, the -# admin did not touch the field and the existing key must be kept -- otherwise -# saving a name change would silently wipe the credential. -UNCHANGED_SENTINEL = "•" * 12 - def _connection(db: DBSession, connection_id: str) -> Connection: connection = db.get(Connection, connection_id) diff --git a/src/lembas/api/admin_audio.py b/src/lembas/api/admin_audio.py new file mode 100644 index 0000000..7123cce --- /dev/null +++ b/src/lembas/api/admin_audio.py @@ -0,0 +1,187 @@ +"""Audio administration: the transcription and speech endpoints.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Form, Request, Response, status +from fastapi.responses import RedirectResponse + +from lembas.api.deps import AdminUser, Db +from lembas.services import audio as audio_service +from lembas.services import settings_store +from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask +from lembas.services.llm.openai_client import LLMError +from lembas.web.templating import render + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/audio", tags=["admin-audio"]) + +# Read out by the speech test. Short, and the one line this project would pick. +TEST_PHRASE = "Speak, friend, and enter." + + +def _page_context(db: Db) -> dict: + config = settings_store.audio(db) + return { + "values": config, + "formats": audio_service.FORMATS, + "masked": { + "stt": mask(decrypt(config.get("stt_api_key_encrypted") or "")), + "tts": mask(decrypt(config.get("tts_api_key_encrypted") or "")), + }, + "unchanged": UNCHANGED_SENTINEL, + } + + +@router.get("") +async def audio_page(request: Request, db: Db, user: AdminUser, saved: bool = False): + from lembas.api.audio import available_voices + + context = _page_context(db) + voices, error = await available_voices(context["values"]) + return render( + request, + "admin/audio.html", + {**context, "voices": voices, "voice_error": error, "saved": saved}, + ) + + +@router.post("") +async def save_audio( + db: Db, + user: AdminUser, + stt_enabled: bool = Form(False), + stt_base_url: str = Form(""), + stt_api_key: str = Form(""), + stt_model: str = Form(""), + stt_language: str = Form(""), + tts_enabled: bool = Form(False), + tts_base_url: str = Form(""), + tts_api_key: str = Form(""), + tts_model: str = Form(""), + tts_voice: str = Form(""), + tts_format: str = Form("mp3"), + tts_speed: float = Form(1.0), + tts_autoplay: bool = Form(False), +) -> Response: + """Save both endpoints. + + Unchecked checkboxes are absent from a form post, which is why every toggle + defaults to False here -- that absence *is* the "off" signal. + """ + current = settings_store.audio(db) + + settings_store.update( + db, + { + "stt_enabled": stt_enabled, + "stt_base_url": stt_base_url.strip().rstrip("/"), + "stt_api_key_encrypted": keep_or_replace( + stt_api_key, current.get("stt_api_key_encrypted") or "" + ), + "stt_model": stt_model.strip() or "whisper-1", + "stt_language": stt_language.strip()[:16], + "tts_enabled": tts_enabled, + "tts_base_url": tts_base_url.strip().rstrip("/"), + "tts_api_key_encrypted": keep_or_replace( + tts_api_key, current.get("tts_api_key_encrypted") or "" + ), + "tts_model": tts_model.strip() or "tts-1", + "tts_voice": tts_voice.strip()[:120], + "tts_format": tts_format if tts_format in audio_service.FORMATS else "mp3", + "tts_speed": min(max(tts_speed, 0.25), 4.0), + "tts_autoplay": tts_autoplay, + }, + key=settings_store.AUDIO, + ) + + # The voice list belongs to whatever URL was configured before; keeping it + # would show the previous server's voices against the new one. + audio_service.forget_voices() + log.info("audio settings saved by %s", user.email) + return RedirectResponse("/admin/audio?saved=1", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/test/{side}") +async def test_audio(request: Request, db: Db, user: AdminUser, side: str): + """Contact one of the two endpoints and report what happened. + + Speech is tested by synthesising a phrase and measuring the bytes back; + transcription by sending a short generated tone, which is *expected* to come + back as no words at all. That still proves what matters -- the URL resolves, + the key is accepted and the response parses. + """ + context = _page_context(db) + config = context["values"] + message, kind = "", "success" + + try: + if side == "tts": + _, stream = await audio_service.speak( + audio_service.endpoint_for(config, "tts"), + TEST_PHRASE, + model=config.get("tts_model") or "tts-1", + voice=config.get("tts_voice") or "", + fmt=config.get("tts_format") or "mp3", + speed=float(config.get("tts_speed") or 1.0), + ) + size = 0 + async for chunk in stream: + size += len(chunk) + message = f"Spoke the test phrase: {size:,} bytes of audio." + elif side == "stt": + text = await audio_service.transcribe( + audio_service.endpoint_for(config, "stt"), + data=_silent_wav(), + filename="test.wav", + content_type="audio/wav", + model=config.get("stt_model") or "whisper-1", + language=config.get("stt_language") or "", + ) + heard = f'Heard "{text}".' if text else "Heard nothing, as expected." + message = f"The endpoint answered. {heard}" + else: + message, kind = "Unknown endpoint.", "error" + except LLMError as exc: + message, kind = exc.message, "error" + + voices, voice_error = [], "" + if side == "tts": + from lembas.api.audio import available_voices + + voices, voice_error = await available_voices(config, refresh=True) + + return render( + request, + "admin/_audio_result.html", + { + "side": side, + "message": message, + "message_kind": kind, + "voices": voices, + "voice_error": voice_error, + "values": config, + }, + ) + + +def _silent_wav(seconds: float = 0.5, rate: int = 16000) -> bytes: + """A valid, silent WAV. + + Generated rather than committed: half a second of silence is fourteen lines + of header arithmetic, and a binary fixture in the repository would be one + more thing nobody can review. + """ + import struct + + frames = int(rate * seconds) + data = b"\x00\x00" * frames + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", 36 + len(data), b"WAVE", + b"fmt ", 16, 1, 1, rate, rate * 2, 2, 16, + b"data", len(data), + ) + return header + data diff --git a/src/lembas/api/admin_search.py b/src/lembas/api/admin_search.py new file mode 100644 index 0000000..251148a --- /dev/null +++ b/src/lembas/api/admin_search.py @@ -0,0 +1,110 @@ +"""Web search administration: which provider, and how to reach it.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Form, Request, Response, status +from fastapi.responses import RedirectResponse + +from lembas.api.deps import AdminUser, Db +from lembas.services import search as search_service +from lembas.services import settings_store +from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, keep_or_replace, mask +from lembas.services.search.base import SearchError +from lembas.web.templating import render + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/search", tags=["admin-search"]) + +SAFESEARCH = ("off", "moderate", "strict") + + +@router.get("") +async def search_page(request: Request, db: Db, user: AdminUser, saved: bool = False): + values = settings_store.search(db) + return render( + request, + "admin/search.html", + { + "values": values, + "providers": search_service.PROVIDERS, + # Keyed by provider so the form can show an install hint against + # the one that needs it, without the template knowing why. + "problems": { + p.key: search_service.availability(p.key) for p in search_service.PROVIDERS + }, + "safesearch_options": SAFESEARCH, + "masked": mask(decrypt(values.get("firecrawl_api_key_encrypted") or "")), + "unchanged": UNCHANGED_SENTINEL, + "saved": saved, + }, + ) + + +@router.post("") +async def save_search( + db: Db, + user: AdminUser, + enabled: bool = Form(False), + provider: str = Form("ddgs"), + max_results: int = Form(5), + region: str = Form("wt-wt"), + safesearch: str = Form("moderate"), + searxng_base_url: str = Form(""), + firecrawl_base_url: str = Form(""), + firecrawl_api_key: str = Form(""), + timeout: float = Form(20.0), +) -> Response: + current = settings_store.search(db) + known = {p.key for p in search_service.PROVIDERS} + + settings_store.update( + db, + { + "enabled": enabled, + "provider": provider if provider in known else "ddgs", + # An upper bound on what any single search may put in the prompt. + # Twenty results is already more than a model reads carefully. + "max_results": min(max(max_results, 1), 20), + "region": region.strip()[:16] or "wt-wt", + "safesearch": safesearch if safesearch in SAFESEARCH else "moderate", + "searxng_base_url": searxng_base_url.strip().rstrip("/"), + "firecrawl_base_url": firecrawl_base_url.strip().rstrip("/") + or "https://api.firecrawl.dev", + "firecrawl_api_key_encrypted": keep_or_replace( + firecrawl_api_key, current.get("firecrawl_api_key_encrypted") or "" + ), + "timeout": min(max(timeout, 5.0), 120.0), + }, + key=settings_store.SEARCH, + ) + log.info("web search %s by %s", "enabled" if enabled else "disabled", user.email) + return RedirectResponse("/admin/search?saved=1", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/test") +async def test_search(request: Request, db: Db, user: AdminUser, query: str = Form("")): + """Run one real search and show what came back. + + Against the stored settings rather than the unsaved form, so what is tested + is what a chat would actually do. + """ + config = settings_store.search(db) + query = query.strip() or "lembas" + + try: + results = await search_service.run(config, query) + message, kind = ( + f"{search_service.provider(config.get('provider')).label} returned " + f"{len(results)} result{'' if len(results) == 1 else 's'}." + ), "success" + except SearchError as exc: + results, message, kind = [], exc.message, "error" + + return render( + request, + "admin/_search_result.html", + {"results": results, "message": message, "message_kind": kind, "query": query}, + ) diff --git a/src/lembas/api/audio.py b/src/lembas/api/audio.py new file mode 100644 index 0000000..c2509b4 --- /dev/null +++ b/src/lembas/api/audio.py @@ -0,0 +1,183 @@ +"""Dictation and read-aloud. + +Both directions go through the server rather than from the browser to the audio +endpoint directly, for the same reason model requests do: the endpoint is often +on a private address the browser cannot reach, and its API key must never leave +this process. +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status +from fastapi.responses import PlainTextResponse, Response, StreamingResponse +from sqlalchemy.orm import Session as DBSession + +from lembas.api.deps import Db, RequiredUser, require_permission +from lembas.db.models import Chat, Message, User +from lembas.services import audio as audio_service +from lembas.services import settings_store +from lembas.services.llm.openai_client import LLMError +from lembas.services.markdown import speakable_text + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/audio", tags=["audio"]) + +# A minute of speech is well under a megabyte in any browser codec; this is a +# ceiling on nonsense, not a budget. Recorded audio is held in memory and never +# written to disk: it is not an attachment, has no owner and nothing would ever +# sweep it up. +MAX_AUDIO_BYTES = 25 * 1024 * 1024 + + +def _user_audio(user: User) -> dict: + return dict((user.settings_json or {}).get("audio") or {}) + + +def resolve_voice(config: dict, user: User) -> str: + """The voice a given user should be read to in. + + Their own choice, then the instance default, then whatever the endpoint + picks. Not validated against the discovered list: a voice can disappear + when a server is reconfigured, and falling back beats failing. + """ + return (_user_audio(user).get("voice") or config.get("tts_voice") or "").strip() + + +def resolve_speed(config: dict, user: User) -> float: + """The playback speed for this user, in the range every endpoint accepts. + + Key presence decides which layer wins, not truthiness: chained `or` would + make a stored speed of 0 fall through to the default instead of being + clamped, which is a different answer for no stated reason. + """ + preferences = _user_audio(user) + if "speed" in preferences: + raw = preferences["speed"] + elif "tts_speed" in config: + raw = config["tts_speed"] + else: + return 1.0 + + try: + chosen = float(raw) + except (TypeError, ValueError): + return 1.0 + # Clamped rather than dropped, unlike the sampling parameters: a speed of 0 + # is not a slower reading, it is silence. + return min(max(chosen, 0.25), 4.0) + + +@router.post( + "/transcribe", dependencies=[Depends(require_permission("audio.transcribe"))] +) +async def transcribe( + db: Db, user: RequiredUser, file: UploadFile = File(...) +) -> Response: + """Turn a recording into text for the composer. + + Returns plain text, not HTML: the caller assigns it to a textarea's value, + where it is never parsed as markup. + """ + config = settings_store.audio(db) + if not config.get("stt_enabled"): + raise HTTPException( + status.HTTP_404_NOT_FOUND, "Dictation is not enabled on this instance." + ) + + data = await file.read(MAX_AUDIO_BYTES + 1) + if len(data) > MAX_AUDIO_BYTES: + raise HTTPException( + status.HTTP_413_CONTENT_TOO_LARGE, "That recording is too long." + ) + if not data: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "The recording was empty.") + + language = (_user_audio(user).get("language") or config.get("stt_language") or "").strip() + + try: + text = await audio_service.transcribe( + audio_service.endpoint_for(config, "stt"), + data=data, + filename=file.filename or "speech.webm", + content_type=file.content_type or "audio/webm", + model=config.get("stt_model") or "whisper-1", + language=language, + ) + except LLMError as exc: + log.info("transcription failed: %s", exc.message) + raise HTTPException(status.HTTP_502_BAD_GATEWAY, exc.message) from exc + + return PlainTextResponse(text) + + +@router.get( + "/speech/{chat_id}/{message_id}", + dependencies=[Depends(require_permission("audio.listen"))], +) +async def speech(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response: + """Read one message aloud.""" + config = settings_store.audio(db) + if not config.get("tts_enabled"): + raise HTTPException( + status.HTTP_404_NOT_FOUND, "Read-aloud is not enabled on this instance." + ) + + message = _owned_message(db, chat_id, message_id, user) + text = speakable_text(message.content) + if not text: + raise HTTPException(status.HTTP_404_NOT_FOUND, "There is nothing to read out.") + + try: + media_type, stream = await audio_service.speak( + audio_service.endpoint_for(config, "tts"), + text, + model=config.get("tts_model") or "tts-1", + voice=resolve_voice(config, user), + fmt=config.get("tts_format") or "mp3", + speed=resolve_speed(config, user), + ) + except LLMError as exc: + log.info("speech failed: %s", exc.message) + raise HTTPException(status.HTTP_502_BAD_GATEWAY, exc.message) from exc + + return StreamingResponse( + stream, + media_type=media_type, + # Not cached: the voice can change under the reader between plays, and + # a message can be regenerated at the same URL. + headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}, + ) + + +async def available_voices(config: dict, *, refresh: bool = False) -> tuple[list[str], str]: + """Discovered voices and, if discovery failed, why. + + Returns rather than raises: a settings page whose voice list could not be + fetched should still render, with the reason next to an empty list. + """ + if not config.get("tts_enabled") or not (config.get("tts_base_url") or "").strip(): + return [], "" + try: + return await audio_service.voices( + audio_service.endpoint_for(config, "tts"), refresh=refresh + ), "" + except LLMError as exc: + return [], exc.message + + +def _owned_message(db: DBSession, chat_id: str, message_id: str, user: User) -> Message: + """The message, if it belongs to a chat this user owns. + + 404 rather than 403 throughout, matching api/chats.py: whether a given id + exists is not information these endpoints hand out. + """ + chat = db.get(Chat, chat_id) + if chat is None or chat.user_id != user.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.") + message = db.get(Message, message_id) + if message is None or message.chat_id != chat.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.") + return message diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index b83c3d4..d3d7dde 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -16,6 +16,7 @@ from lembas.api.deps import Db, RequiredUser, require_permission from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User from lembas.db.session import session_scope from lembas.security import permissions +from lembas.services import audio as audio_service from lembas.services import chat as chat_service from lembas.services import files as files_service from lembas.services import generation as generation_service @@ -183,6 +184,7 @@ async def post_message( "models_by_id": { m.model_id: m for m in chat_service.available_models(db, user) }, + **audio_service.template_flags(db, user), }, ) @@ -217,6 +219,13 @@ async def stream_message( ) +def _tool_activity(events: list[dict], *, live: bool = True) -> str: + """Render the tool block. Whole, never a delta, like every other frame.""" + return templates.get_template("chat/_tool_activity.html").render( + {"tool_events": events, "live": live} + ) + + async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]: """Stream a generation that is running independently of this request. @@ -238,6 +247,8 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]: seen = generation.version if generation.thinking: yield sse.event("reasoning", escape_text(generation.thinking)) + if generation.tool_events: + yield sse.event("tools", _tool_activity(generation.tool_events)) if generation.content: yield sse.event("render", render_markdown(generation.text)) @@ -259,6 +270,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]: yield sse.event("close", "") return + owner = db.get(User, chat.user_id) final_html = templates.get_template("chat/_message.html").render( { "message": message, @@ -267,10 +279,18 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]: # Passed even though an assistant bubble never reads it: the # template shares both roles, and a missing `user` would only # blow up on whichever branch is not being exercised here. - "user": db.get(User, chat.user_id), + "user": owner, "models_by_id": { m.model_id: m for m in chat_service.available_models(db, None) }, + # This frame replaces the whole bubble, so it has to carry the + # speaker button's conditions too -- and the owner's, not the + # follower's: there is no request here to ask who is watching. + **audio_service.template_flags(db, owner), + # The one render that means "this reply just landed", which is + # what read-aloud-automatically keys off. A page load must not + # set it or reopening a chat would start talking. + "just_finished": True, } ) title_html = templates.get_template("chat/_title_oob.html").render({"chat": chat}) @@ -294,6 +314,7 @@ def _thread_context(db: DBSession, chat: Chat, user: User) -> dict: if m.role == ROLE_ASSISTANT and m.content }, "models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)}, + **audio_service.template_flags(db, user), } @@ -565,6 +586,7 @@ async def regenerate( "models_by_id": { m.model_id: m for m in chat_service.available_models(db, user) }, + **audio_service.template_flags(db, user), }, ) diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index f16b976..6de6d42 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -2,20 +2,27 @@ from __future__ import annotations -from fastapi import APIRouter, HTTPException, Request, status -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, HTTPException, Request, Response, status +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from sqlalchemy import select from sqlalchemy.orm import Session as DBSession from lembas.api.deps import Db, RequiredUser from lembas.db.models import Chat, Folder, Message, User from lembas.security import permissions +from lembas.services import audio as audio_service from lembas.services import chat as chat_service +from lembas.services import settings_store from lembas.services.markdown import render_markdown -from lembas.web.templating import render +from lembas.web.templating import STATIC_DIR, render router = APIRouter(tags=["pages"]) +# Matches --bg for each theme in tokens.css. Duplicated here because the +# manifest is JSON read by the operating system before any stylesheet exists; +# there is nowhere for a CSS variable to resolve. +THEME_COLOUR = {"moria": "#101317", "shire": "#F6F1E4"} + def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict: """Model lists and permissions every chat page needs. @@ -35,6 +42,7 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict: # may not be the model the chat is set to now. Keyed by model_id, the # denormalised value stored on each message. "models_by_id": {m.model_id: m for m in models}, + **audio_service.template_flags(db, user), } @@ -74,6 +82,70 @@ async def home(user: RequiredUser): return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER) +# --- Installing as an app ----------------------------------------------------- +# All three routes below are deliberately unauthenticated. A browser fetches a +# manifest and a service worker outside any page's session, and an offline page +# has by definition no server to ask who is looking at it. + + +@router.get("/manifest.webmanifest", include_in_schema=False) +async def manifest(db: Db) -> Response: + """The web app manifest. + + A route rather than a static file because the name is an instance setting, + and an installed app showing "LLeMbas" when the instance is called something + else would be wrong on the one screen that is hardest to correct: the + launcher. + """ + name = settings_store.get(db, "instance_name") or "LLeMbas" + return JSONResponse( + { + "id": "/", + "name": name, + "short_name": name[:12], + "description": "A web UI for your language models.", + "start_url": "/chat", + "scope": "/", + "display": "standalone", + "background_color": THEME_COLOUR["moria"], + "theme_color": THEME_COLOUR["moria"], + "icons": [ + {"src": "/static/img/icon-192.png", "sizes": "192x192", + "type": "image/png", "purpose": "any"}, + {"src": "/static/img/icon-512.png", "sizes": "512x512", + "type": "image/png", "purpose": "any"}, + {"src": "/static/img/icon-maskable-512.png", "sizes": "512x512", + "type": "image/png", "purpose": "maskable"}, + ], + }, + media_type="application/manifest+json", + ) + + +@router.get("/sw.js", include_in_schema=False) +async def service_worker() -> Response: + """The service worker, served from the root. + + A worker may only control pages at or below the path it was served from, so + one delivered by the /static mount would have scope /static/js/ and control + nothing. Serving it here is simpler than the Service-Worker-Allowed header + that would be needed otherwise. + + no-store because a stale worker is a worker that keeps serving a stale + cache: the one file in the application that must never be held onto. + """ + return FileResponse( + STATIC_DIR / "js" / "sw.js", + media_type="text/javascript", + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/offline", include_in_schema=False) +async def offline(request: Request) -> Response: + return render(request, "offline.html", {}) + + @router.get("/chat") async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = ""): """A composer with no chat behind it yet. @@ -177,6 +249,13 @@ async def settings_page( error: str = "", saved: str = "", ): + from lembas.api.audio import available_voices + + context = _chat_context(db, user, None) + # Fetched here rather than by the template so a speech server that is down + # leaves the page renderable, with the reason beside an empty list. + voices, voice_error = await available_voices(context["audio"]) + # error/saved arrive as query parameters because the password form redirects # back here: a POST that re-rendered in place would re-submit on refresh. return render( @@ -186,7 +265,9 @@ async def settings_page( "chat": None, "error": error, "saved": saved, - **_chat_context(db, user, None), + "voices": voices, + "voice_error": voice_error, + **context, **_sidebar_context(db, user), }, ) diff --git a/src/lembas/api/preferences.py b/src/lembas/api/preferences.py index 27c7602..4e68a39 100644 --- a/src/lembas/api/preferences.py +++ b/src/lembas/api/preferences.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import logging from fastapi import APIRouter, Body, Form, Request, status @@ -66,6 +67,40 @@ async def set_default_model( return RedirectResponse("/settings?saved=Default+model+updated.", status_code=303) +@router.post("/audio") +async def set_audio( + db: Db, + user: RequiredUser, + voice: str = Form(""), + speed: str = Form(""), + language: str = Form(""), + autoplay: bool = Form(False), +) -> Response: + """Per-reader audio choices, overriding the instance defaults. + + The voice is deliberately not checked against the discovered list. Voices + come and go when a speech server is reconfigured, and rejecting a saved + preference because a list fetched a moment ago did not mention it would be + a confusing failure with no obvious fix. + """ + chosen: dict[str, object] = {"autoplay": autoplay} + if voice.strip(): + chosen["voice"] = voice.strip()[:120] + if language.strip(): + chosen["language"] = language.strip()[:16] + if speed.strip(): + # An unreadable speed leaves the default in place rather than failing: + # nothing else on the form should be lost to a typo in one field. + with contextlib.suppress(ValueError): + chosen["speed"] = min(max(float(speed), 0.25), 4.0) + + # Whole-dict reassignment: an in-place edit of a JSON column is not + # reliably detected as a change. + user.settings_json = {**(user.settings_json or {}), "audio": chosen} + db.commit() + return RedirectResponse("/settings?saved=Audio+preferences+updated.", status_code=303) + + @router.post("/password") async def change_password( request: Request, diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py index b1a3144..720af82 100644 --- a/src/lembas/db/models/chat.py +++ b/src/lembas/db/models/chat.py @@ -116,6 +116,11 @@ class Message(UUIDPrimaryKey, Timestamps, Base): reasoning_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) model_id: Mapped[str] = mapped_column(String(300), default="") + + # What the model did before answering: one entry per tool call, with its + # arguments and results. Shown in the transcript so the sources behind an + # answer stay visible, and deliberately NOT replayed as context on the next + # turn -- see services/generation.py for why. tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list) usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) diff --git a/src/lembas/main.py b/src/lembas/main.py index c80c5bf..8eb254e 100644 --- a/src/lembas/main.py +++ b/src/lembas/main.py @@ -14,8 +14,11 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from lembas import __version__ from lembas.api import ( admin, + admin_audio, admin_models, + admin_search, admin_users, + audio, auth, chats, files, @@ -92,11 +95,14 @@ def create_app() -> FastAPI: app.include_router(auth.router) app.include_router(preferences.router) app.include_router(chats.router) + app.include_router(audio.router) app.include_router(files.router) app.include_router(folders.router) app.include_router(admin.router) app.include_router(admin_users.router) app.include_router(admin_models.router) + app.include_router(admin_audio.router) + app.include_router(admin_search.router) register_error_handlers(app) return app diff --git a/src/lembas/security/permissions.py b/src/lembas/security/permissions.py index 8ec2544..3a5c745 100644 --- a/src/lembas/security/permissions.py +++ b/src/lembas/security/permissions.py @@ -78,6 +78,28 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = ( True, "Workspace", ), + PermissionDef( + "tools.web_search", + "Search the web", + "Let a model look things up while it answers. Only offered to models " + "marked as supporting tools, and only when web search is configured.", + True, + "Chat", + ), + PermissionDef( + "audio.transcribe", + "Dictate messages", + "Speak a message instead of typing it. Needs a transcription endpoint.", + True, + "Audio", + ), + PermissionDef( + "audio.listen", + "Play replies aloud", + "Have a reply read out. Needs a speech endpoint.", + True, + "Audio", + ), ) PERMISSION_KEYS = tuple(d.key for d in PERMISSION_DEFS) diff --git a/src/lembas/services/audio.py b/src/lembas/services/audio.py new file mode 100644 index 0000000..f35bfe1 --- /dev/null +++ b/src/lembas/services/audio.py @@ -0,0 +1,300 @@ +"""Speech to text and text to speech, against OpenAI-shaped audio endpoints. + +The same reasoning as the chat client: plain httpx rather than an SDK, because +the target is not api.openai.com so much as whisper.cpp's server, Speaches, +faster-whisper-server, Kokoro and anything else exposing ``/v1/audio/*``. They +agree on the request and disagree politely about the response, so this is +tolerant about what comes back. + +Two endpoints, not one. A local install almost always runs transcription and +speech as separate processes -- they are different models on different +schedules -- and forcing them onto one base URL would mean the common case +could not be configured at all. +""" + +from __future__ import annotations + +import logging +import time +from collections.abc import AsyncIterator +from typing import Any + +import httpx + +from lembas.config import settings as env_settings +from lembas.services.crypto import decrypt +from lembas.services.llm.openai_client import ( + Endpoint, + LLMError, + describe_http_error, + wrap_transport_error, +) + +log = logging.getLogger(__name__) + +# api.openai.com has no endpoint that lists voices, so when one is not offered +# these are what a caller can reasonably assume. Anything else -- Kokoro's sixty +# or so -- is discovered. +OPENAI_VOICES = ("alloy", "echo", "fable", "onyx", "nova", "shimmer") + +# Formats every player in a browser can decode. opus is deliberately absent: +# some endpoints emit it in an ogg container that Safari will not play. +FORMATS = ("mp3", "wav", "flac", "aac") + +# Discovery is cached because the voice list is read every time anyone opens +# their settings, and waking a model server to answer that is rude. +_VOICE_TTL = 300.0 +_voice_cache: dict[str, tuple[float, list[str]]] = {} + + +def endpoint_for(config: dict[str, Any], side: str) -> Endpoint: + """Build an Endpoint from the stored audio settings. + + `side` is "stt" or "tts". Endpoint is a frozen snapshot with the key + already decrypted, so nothing downstream has to know the secret was ever + encrypted -- or hold a database session while it streams. + """ + base_url = (config.get(f"{side}_base_url") or "").strip() + if not base_url: + raise LLMError("No audio endpoint has been configured.") + return Endpoint( + base_url=base_url.rstrip("/"), + api_key=decrypt(config.get(f"{side}_api_key_encrypted") or ""), + extra_headers={}, + name=base_url, + ) + + +async def transcribe( + endpoint: Endpoint, + *, + data: bytes, + filename: str, + content_type: str, + model: str = "whisper-1", + language: str = "", +) -> str: + """Turn recorded audio into text. + + `model` is sent even to servers that ignore it: whisper.cpp serves one model + and does not care, while a router in front of several will not dispatch + without it. `language` is omitted when empty, which is what asks the server + to detect it -- sending an empty string instead makes some of them fail. + """ + form: dict[str, Any] = {"model": model, "response_format": "json"} + if language: + form["language"] = language + + try: + async with httpx.AsyncClient(timeout=env_settings.request_timeout) as client: + response = await client.post( + endpoint.url("audio/transcriptions"), + headers=_headers_without_content_type(endpoint), + data=form, + files={"file": (filename, data, content_type or "application/octet-stream")}, + ) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc + except httpx.RequestError as exc: + raise wrap_transport_error(exc, endpoint) from exc + + try: + payload = response.json() + except ValueError: + # response_format=text is what some servers give regardless of the ask. + return response.text.strip() + + if isinstance(payload, dict): + text = payload.get("text") + if isinstance(text, str): + return text.strip() + error = payload.get("error") + if error: + raise LLMError(str(error)) + raise LLMError("The transcription endpoint returned no text.") + + +async def speak( + endpoint: Endpoint, + text: str, + *, + model: str = "tts-1", + voice: str = "", + fmt: str = "mp3", + speed: float = 1.0, +) -> tuple[str, AsyncIterator[bytes]]: + """Synthesise speech, returning its content type and a byte stream. + + Streamed rather than buffered: a long reply is a lot of audio, and playback + can start on the first chunk instead of after the last. + """ + if not text.strip(): + raise LLMError("There is nothing to read out.") + + body: dict[str, Any] = { + "model": model, + "input": text, + "response_format": fmt if fmt in FORMATS else "mp3", + } + if voice: + body["voice"] = voice + if speed and speed != 1.0: + body["speed"] = speed + + client = httpx.AsyncClient(timeout=env_settings.request_timeout) + try: + request = client.build_request( + "POST", endpoint.url("audio/speech"), headers=endpoint.headers(), json=body + ) + response = await client.send(request, stream=True) + if response.status_code >= 400: + # Nothing has been read yet on a streaming response, and the error + # detail is in the body. + await response.aread() + response.raise_for_status() + except httpx.HTTPStatusError as exc: + await client.aclose() + raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc + except httpx.RequestError as exc: + await client.aclose() + raise wrap_transport_error(exc, endpoint) from exc + except Exception: + await client.aclose() + raise + + media_type = response.headers.get("content-type", f"audio/{body['response_format']}") + + async def stream() -> AsyncIterator[bytes]: + # The client is closed here rather than by the caller: it has to outlive + # this function, and a response abandoned without aclose leaks a socket. + try: + async for chunk in response.aiter_bytes(): + yield chunk + finally: + await response.aclose() + await client.aclose() + + return media_type, stream() + + +async def voices(endpoint: Endpoint, *, refresh: bool = False) -> list[str]: + """Voices the speech endpoint offers, newest answer cached briefly. + + Falls back to the OpenAI six on a 404, which is not an error: the official + API simply has no such endpoint, and its voices are a fixed list everyone + already knows. + """ + key = endpoint.base_url + cached = _voice_cache.get(key) + if cached and not refresh and time.monotonic() - cached[0] < _VOICE_TTL: + return cached[1] + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.get( + endpoint.url("audio/voices"), headers=endpoint.headers() + ) + if response.status_code == 404: + found = list(OPENAI_VOICES) + _voice_cache[key] = (time.monotonic(), found) + return found + response.raise_for_status() + payload = response.json() + except httpx.HTTPStatusError as exc: + raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc + except httpx.RequestError as exc: + raise wrap_transport_error(exc, endpoint) from exc + except ValueError as exc: + raise LLMError("The endpoint returned a response that was not JSON.") from exc + + found = _parse_voices(payload) + if not found: + found = list(OPENAI_VOICES) + _voice_cache[key] = (time.monotonic(), found) + return found + + +def _parse_voices(payload: Any) -> list[str]: + """Pull voice names out of whatever shape the server chose. + + Kokoro answers ``{"voices": [{"id": "af_heart", ...}]}``; older builds and + some others answer ``{"voices": ["af_heart", ...]}``; a couple return the + bare list. All three are the same information. + """ + entries = payload + if isinstance(payload, dict): + for field in ("voices", "data"): + if isinstance(payload.get(field), list): + entries = payload[field] + break + if not isinstance(entries, list): + return [] + + names: list[str] = [] + for entry in entries: + if isinstance(entry, str) and entry: + names.append(entry) + elif isinstance(entry, dict): + name = entry.get("id") or entry.get("name") or entry.get("voice") + if isinstance(name, str) and name: + names.append(name) + # Sorted and de-duplicated: sixty voices in the server's arbitrary order is + # not a list anyone can pick from. + return sorted(dict.fromkeys(names)) + + +def _headers_without_content_type(endpoint: Endpoint) -> dict[str, str]: + """Endpoint headers minus Content-Type. + + httpx sets the multipart Content-Type itself, including the boundary. + Leaving the JSON one in place overrides it and the server sees a body it + cannot parse. + """ + return {k: v for k, v in endpoint.headers().items() if k.lower() != "content-type"} + + +def forget_voices() -> None: + """Drop the discovery cache. Used when an administrator changes the URL.""" + _voice_cache.clear() + + +def template_flags(db, user) -> dict[str, Any]: + """What the chat templates need to know about audio. + + Lives here rather than in one page module because a message bubble is + rendered from four places -- the chat page, the two message endpoints, and + the SSE stream, which has no request at all -- and each of them needs the + same three booleans. Getting one of them wrong is how a speaker button ends + up on a page that cannot use it. + """ + from lembas.security import permissions + from lembas.services import settings_store + + config = settings_store.audio(db) + allowed = permissions.resolve(db, user) + listen = bool(config.get("tts_enabled")) and allowed.get("audio.listen", False) + preferences = (user.settings_json or {}).get("audio") or {} if user else {} + + return { + "audio": config, + "user_audio": preferences, + "can_dictate": bool(config.get("stt_enabled")) + and allowed.get("audio.transcribe", False), + "can_listen": listen, + # Only meaningful when can_listen; the template guards on both. + "audio_autoplay": listen + and bool(preferences.get("autoplay", config.get("tts_autoplay"))), + } + + +__all__ = [ + "FORMATS", + "OPENAI_VOICES", + "LLMError", + "endpoint_for", + "forget_voices", + "speak", + "transcribe", + "voices", +] diff --git a/src/lembas/services/crypto.py b/src/lembas/services/crypto.py index 1aa95e6..aef6cd9 100644 --- a/src/lembas/services/crypto.py +++ b/src/lembas/services/crypto.py @@ -19,6 +19,13 @@ from lembas.config import settings log = logging.getLogger(__name__) +# Rendered in a form in place of a stored secret. If a submitted value still +# equals this, the field was never touched and the stored secret must be kept -- +# otherwise saving a name change would silently wipe the credential beside it. +# Lives here rather than in one admin module because every form that edits a +# secret needs the same dance. +UNCHANGED_SENTINEL = "•" * 12 + @lru_cache def _fernet() -> Fernet: @@ -56,3 +63,17 @@ def mask(secret: str) -> str: if len(secret) <= 8: return "*" * len(secret) return f"{secret[:3]}{'*' * 8}{secret[-4:]}" + + +def keep_or_replace(submitted: str, stored_ciphertext: str) -> str: + """Resolve a submitted secret field against what is already stored. + + Three cases, and the middle one is the reason this exists: the sentinel + means "the form rendered a mask and nobody typed over it", which is not the + same as an empty field. An explicitly emptied field does mean "this endpoint + needs no key", so it clears the stored value. + """ + submitted = submitted.strip() + if submitted == UNCHANGED_SENTINEL: + return stored_ciphertext + return encrypt(submitted) diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index b3d2976..f55bda4 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -22,10 +22,18 @@ import time from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta -from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message +from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User from lembas.db.session import session_scope from lembas.services import chat as chat_service -from lembas.services.llm.openai_client import LLMError, delta_reasoning, delta_text, stream_chat +from lembas.services import settings_store +from lembas.services import tools as tools_service +from lembas.services.llm.openai_client import ( + LLMError, + delta_reasoning, + delta_text, + delta_tool_calls, + stream_chat, +) from lembas.services.reasoning import REASONING, ReasoningSplitter log = logging.getLogger(__name__) @@ -52,6 +60,10 @@ class Generation: reasoning: list[str] = field(default_factory=list) reasoning_ms: int = 0 + # One entry per tool call made while producing this reply, in order. Shown + # live as the model works and kept on the message afterwards. + tool_events: list[dict] = field(default_factory=list) + error: str = "" stopped: bool = False done: bool = False @@ -130,7 +142,15 @@ async def shutdown() -> None: async def _run(generation: Generation) -> None: - """Produce one reply, then persist it. Never raises into the task.""" + """Produce one reply, then persist it. Never raises into the task. + + A reply is not necessarily one request. When tools are offered and the + model asks to use one, the loop below runs it, appends the result to the + conversation and asks again -- up to tools_service.MAX_ROUNDS times, after + which the model has to answer with what it has. Text produced before a tool + call is kept, so a model that narrates what it is about to look up does not + lose that when the results come back. + """ splitter = ReasoningSplitter() started = time.monotonic() reasoning_started: float | None = None @@ -151,35 +171,89 @@ async def _run(generation: Generation) -> None: question = _question_from(payload) needs_title = not chat.title_generated - async for chunk in stream_chat(endpoint, payload): - thought = delta_reasoning(chunk) - if thought: - if reasoning_started is None: - reasoning_started = time.monotonic() - generation.reasoning.append(thought) - generation.touch() + # Read while the session is open: everything below outlives it. + offered = tools_service.enabled_tools(db, chat, db.get(User, chat.user_id)) + search_config = settings_store.search(db) if offered else {} - text = delta_text(chunk) - if text: - for kind, piece in splitter.feed(text): - if kind == REASONING: - if reasoning_started is None: - reasoning_started = time.monotonic() - generation.reasoning.append(piece) - else: - if reasoning_started is not None and not generation.reasoning_ms: - generation.reasoning_ms = int( - (time.monotonic() - reasoning_started) * 1000 - ) - generation.content.append(piece) - generation.touch() + if offered: + payload = {**payload, "tools": offered} - if generation.cancel: - generation.stopped = True + for round_number in range(tools_service.MAX_ROUNDS + 1): + accumulator = tools_service.ToolCallAccumulator() + # Text the model produced in *this* round, needed separately from + # generation.content when echoing the assistant turn back. + round_text: list[str] = [] + + async for chunk in stream_chat(endpoint, payload): + thought = delta_reasoning(chunk) + if thought: + if reasoning_started is None: + reasoning_started = time.monotonic() + generation.reasoning.append(thought) + generation.touch() + + if offered: + fragments = delta_tool_calls(chunk) + if fragments: + accumulator.feed(fragments) + + text = delta_text(chunk) + if text: + for kind, piece in splitter.feed(text): + if kind == REASONING: + if reasoning_started is None: + reasoning_started = time.monotonic() + generation.reasoning.append(piece) + else: + if reasoning_started is not None and not generation.reasoning_ms: + generation.reasoning_ms = int( + (time.monotonic() - reasoning_started) * 1000 + ) + generation.content.append(piece) + round_text.append(piece) + generation.touch() + + if generation.cancel: + generation.stopped = True + break + + # Let followers and other tasks run between chunks. + await asyncio.sleep(0) + + calls = accumulator.calls + if generation.stopped or not calls: break - # Let followers and other tasks run between chunks. - await asyncio.sleep(0) + if round_number == tools_service.MAX_ROUNDS: + # Out of rounds with the model still asking for tools. Recorded + # rather than silently dropped: an answer that stops here needs + # to be explicable. + generation.tool_events.append( + { + "name": calls[0]["name"], + "status": "error", + "error": ( + f"Stopped after {tools_service.MAX_ROUNDS} rounds of tool " + f"calls without an answer." + ), + } + ) + generation.touch() + break + + messages = [ + *payload["messages"], + tools_service.assistant_turn(calls, "".join(round_text)), + ] + for call in calls: + outcome = await tools_service.run_tool( + search_config, call["name"], call["arguments"] + ) + generation.tool_events.append(outcome.event) + generation.touch() + messages.append(tools_service.tool_turn(call, outcome.content)) + + payload = {**payload, "messages": messages} for kind, piece in splitter.flush(): (generation.reasoning if kind == REASONING else generation.content).append(piece) @@ -248,6 +322,7 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None: message.content = generation.text message.reasoning = generation.thinking message.reasoning_ms = generation.reasoning_ms + message.tool_calls_json = generation.tool_events message.error = generation.error message.stopped = generation.stopped message.complete = True diff --git a/src/lembas/services/llm/openai_client.py b/src/lembas/services/llm/openai_client.py index 0b72600..85445c7 100644 --- a/src/lembas/services/llm/openai_client.py +++ b/src/lembas/services/llm/openai_client.py @@ -77,9 +77,13 @@ class Endpoint: return headers -def _describe_http_error(exc: httpx.HTTPStatusError) -> str: +def describe_http_error(exc: httpx.HTTPStatusError) -> str: """Turn an upstream error response into something worth reading. + Public because the audio and search clients talk to the same class of + server and want the same translation; LLMError stays the one thing a + caller has to catch. + Providers put the useful part in wildly different places, so try the common shapes before falling back to the raw body. """ @@ -109,7 +113,7 @@ def _describe_http_error(exc: httpx.HTTPStatusError) -> str: return friendly or detail or f"The endpoint returned HTTP {status}." -def _wrap_transport_error(exc: httpx.RequestError, endpoint: Endpoint) -> LLMError: +def wrap_transport_error(exc: httpx.RequestError, endpoint: Endpoint) -> LLMError: if isinstance(exc, httpx.ConnectError): return LLMError( f"Could not reach {endpoint.base_url}. Is the endpoint running and " @@ -131,9 +135,9 @@ async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]: response.raise_for_status() payload = response.json() except httpx.HTTPStatusError as exc: - raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc + raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc except httpx.RequestError as exc: - raise _wrap_transport_error(exc, endpoint) from exc + raise wrap_transport_error(exc, endpoint) from exc except ValueError as exc: raise LLMError("The endpoint returned a response that was not JSON.") from exc @@ -195,9 +199,9 @@ async def stream_chat( continue except httpx.HTTPStatusError as exc: - raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc + raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc except httpx.RequestError as exc: - raise _wrap_transport_error(exc, endpoint) from exc + raise wrap_transport_error(exc, endpoint) from exc async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str: @@ -211,9 +215,9 @@ async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str: response.raise_for_status() data = response.json() except httpx.HTTPStatusError as exc: - raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc + raise LLMError(describe_http_error(exc), status_code=exc.response.status_code) from exc except httpx.RequestError as exc: - raise _wrap_transport_error(exc, endpoint) from exc + raise wrap_transport_error(exc, endpoint) from exc except ValueError as exc: raise LLMError("The endpoint returned a response that was not JSON.") from exc @@ -245,6 +249,40 @@ def delta_reasoning(chunk: dict[str, Any]) -> str: return "" +def delta_tool_calls(chunk: dict[str, Any]) -> list[dict[str, Any]]: + """Pull tool-call fragments out of one streamed chunk. + + Each entry carries an ``index`` and, across chunks, a name that arrives + once and an ``arguments`` string that arrives in pieces. Reassembling them + is lembas.services.tools.ToolCallAccumulator's job; this only extracts. + """ + try: + choices = chunk.get("choices") or [] + if not choices: + return [] + calls = (choices[0].get("delta") or {}).get("tool_calls") + return calls if isinstance(calls, list) else [] + except (AttributeError, TypeError): + return [] + + +def finish_reason(chunk: dict[str, Any]) -> str: + """Why the model stopped, when the chunk says so. + + ``tool_calls`` here is the signal that the reply is not an answer but a + request to run something and come back. Some servers send ``stop`` even + when they emitted tool calls, so the accumulator's contents are the real + authority and this is only a hint. + """ + try: + choices = chunk.get("choices") or [] + if not choices: + return "" + return choices[0].get("finish_reason") or "" + except (AttributeError, TypeError): + return "" + + def delta_text(chunk: dict[str, Any]) -> str: """Pull the text out of one streamed chunk, tolerating provider variation.""" try: diff --git a/src/lembas/services/markdown.py b/src/lembas/services/markdown.py index 9311ba8..2fb8546 100644 --- a/src/lembas/services/markdown.py +++ b/src/lembas/services/markdown.py @@ -13,6 +13,7 @@ from __future__ import annotations import functools import html +import re import nh3 from markdown_it import MarkdownIt @@ -132,3 +133,40 @@ def escape_text(text: str) -> str: slashes, which triples the size of a streamed token for no benefit. """ return html.escape(text, quote=False) + + +# Code blocks are dropped whole rather than read out. A speech model given a +# code fence pronounces every bracket and underscore, which is unlistenable and +# takes longer than the prose it was buried in. +# +# Matched on
 rather than on the .code-block wrapper: the wrapper also
+# contains a label div, so a non-greedy match for its closing tag stops at the
+# label's and leaves the code behind. 
 cannot nest, so this is exact.
+_CODE_BLOCK = re.compile(r"]*>.*?
", re.DOTALL) +_CODE_LABEL = re.compile(r"
.*?
", re.DOTALL) +_TAG = re.compile(r"<[^>]+>") +_WHITESPACE = re.compile(r"[ \t]*\n\s*\n\s*") + +# Speech endpoints reject or truncate very long inputs, and a reply long enough +# to hit this is not one anybody is listening to in full. +MAX_SPEAKABLE = 8000 + + +def speakable_text(text: str) -> str: + """Markdown reduced to something worth reading aloud. + + Goes through the renderer rather than stripping the Markdown source + directly, so tables, lists and links come out as their text instead of as + punctuation, and there is one definition of what a message *says*. + """ + if not text: + return "" + + rendered = _CODE_LABEL.sub(" ", _CODE_BLOCK.sub("\n", render_markdown(text))) + stripped = html.unescape(_TAG.sub(" ", rendered)) + + # Paragraph breaks survive as a single newline: speech models use them as a + # pause, and a wall of one line is read without any. + stripped = _WHITESPACE.sub("\n", stripped) + lines = [" ".join(line.split()) for line in stripped.splitlines()] + return "\n".join(line for line in lines if line)[:MAX_SPEAKABLE] diff --git a/src/lembas/services/search/__init__.py b/src/lembas/services/search/__init__.py new file mode 100644 index 0000000..55eda25 --- /dev/null +++ b/src/lembas/services/search/__init__.py @@ -0,0 +1,108 @@ +"""Web search providers. + +One shape in, one shape out: a query and a limit go in, a list of SearchResult +comes back, and which service answered is a setting rather than a code path any +caller has to know about. + +Everything here returns *untrusted third-party text*. A title or snippet from a +search result is exactly as much attacker-controlled as model output, and gets +the same treatment: escaped on the way into a page, and only http/https URLs +rendered as links. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from lembas.services.search import ddg, firecrawl, searxng +from lembas.services.search.base import SearchError, SearchResult + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Provider: + key: str + label: str + description: str + # Whether an administrator has to configure something before it works. + needs_setup: bool + + +PROVIDERS: tuple[Provider, ...] = ( + Provider( + "ddgs", + "DuckDuckGo", + "No account, no key, no server to run. Rate limited if used heavily.", + False, + ), + Provider( + "searxng", + "SearXNG", + "Your own metasearch instance. Needs its JSON format enabled.", + True, + ), + Provider( + "firecrawl", + "Firecrawl", + "Hosted search API. Needs an account and a key.", + True, + ), +) + +_RUNNERS = {"ddgs": ddg.search, "searxng": searxng.search, "firecrawl": firecrawl.search} + + +def provider(key: str) -> Provider: + return next((p for p in PROVIDERS if p.key == key), PROVIDERS[0]) + + +def availability(key: str) -> str: + """Why a provider cannot be used, or "" when it can. + + Checked before a search is attempted so the admin screen can say what is + wrong while it is being configured, rather than the first chat to try it + being where the problem surfaces. + """ + if key == "ddgs" and not ddg.is_available(): + return ( + "The ddgs package is not installed. Install it with: " + 'pip install "lembas[search]"' + ) + return "" + + +async def run( + config: dict[str, Any], query: str, *, limit: int | None = None +) -> list[SearchResult]: + """Search with whichever provider is configured. + + Raises SearchError with something worth reading; every provider translates + its own failures rather than letting an httpx exception escape. + """ + query = " ".join(query.split())[:400] + if not query: + raise SearchError("There was nothing to search for.") + + key = config.get("provider") or "ddgs" + problem = availability(key) + if problem: + raise SearchError(problem) + + runner = _RUNNERS.get(key) + if runner is None: + raise SearchError(f"Unknown search provider '{key}'.") + + count = limit or int(config.get("max_results") or 5) + # A model that asks for fifty results is asking for a prompt nobody can + # afford; the administrator's number is the ceiling either way. + count = min(max(count, 1), int(config.get("max_results") or 5)) + + results = await runner(config, query, count) + log.info("web search (%s) for %r: %d results", key, query[:60], len(results)) + return results[:count] + + +__all__ = ["PROVIDERS", "Provider", "SearchError", "SearchResult", "availability", "run"] diff --git a/src/lembas/services/search/base.py b/src/lembas/services/search/base.py new file mode 100644 index 0000000..dac8bc5 --- /dev/null +++ b/src/lembas/services/search/base.py @@ -0,0 +1,62 @@ +"""What every search provider produces, and how it fails.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse + +# A snippet is context, not an article. Longer than this and a handful of +# results crowds out the conversation they were meant to inform. +MAX_SNIPPET = 400 + + +class SearchError(Exception): + """A search failure with a message fit to show a user. + + Same contract as LLMError in the chat client: one exception type, always + carrying text that can be put on screen without editing. + """ + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +@dataclass(frozen=True) +class SearchResult: + title: str + url: str + snippet: str + + @property + def host(self) -> str: + try: + return urlparse(self.url).netloc or self.url + except ValueError: + return self.url + + @property + def is_linkable(self) -> bool: + """Whether this result's URL may be rendered as a link. + + Only http and https. A search provider is an untrusted source, and a + javascript: or data: URL arriving in a result and being turned into an + anchor is the obvious way this feature would be abused. + """ + try: + return urlparse(self.url).scheme in ("http", "https") + except ValueError: + return False + + +def clean(title: Any, url: Any, snippet: Any) -> SearchResult | None: + """Normalise one provider's row, or None if there is nothing usable in it.""" + url = str(url or "").strip() + if not url: + return None + return SearchResult( + title=" ".join(str(title or "").split())[:300] or url, + url=url[:2000], + snippet=" ".join(str(snippet or "").split())[:MAX_SNIPPET], + ) diff --git a/src/lembas/services/search/ddg.py b/src/lembas/services/search/ddg.py new file mode 100644 index 0000000..2fbb6b0 --- /dev/null +++ b/src/lembas/services/search/ddg.py @@ -0,0 +1,88 @@ +"""DuckDuckGo, via the ddgs package. + +The default provider because it is the only one that works with no account, no +key and no server to run: enabling web search should not also be a +configuration exercise. + +Optional at install time -- see the `search` extra in pyproject.toml -- so the +import is guarded and its absence is reported as something to install rather +than as a crash. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from lembas.services.search.base import SearchError, SearchResult, clean + +try: # pragma: no cover - exercised by whether the extra is installed + from ddgs import DDGS + + _IMPORT_ERROR = "" +except ImportError as exc: # pragma: no cover + DDGS = None + _IMPORT_ERROR = str(exc) + + +def is_available() -> bool: + return DDGS is not None + + +def _blocking_search(query: str, count: int, region: str, safesearch: str) -> list[dict[str, Any]]: + with DDGS() as client: + return list( + client.text( + query, + region=region or "wt-wt", + safesearch=safesearch or "moderate", + max_results=count, + ) + ) + + +async def search(config: dict[str, Any], query: str, count: int) -> list[SearchResult]: + if not is_available(): + raise SearchError( + 'The ddgs package is not installed. Install it with: pip install "lembas[search]"' + ) + + try: + # ddgs is synchronous. Run it on a thread: blocking the event loop here + # would stall every other chat in the process, including the one that + # asked for the search. + rows = await asyncio.wait_for( + asyncio.to_thread( + _blocking_search, + query, + count, + str(config.get("region") or "wt-wt"), + str(config.get("safesearch") or "moderate"), + ), + timeout=float(config.get("timeout") or 20.0), + ) + except TimeoutError as exc: + raise SearchError("DuckDuckGo did not answer in time.") from exc + except Exception as exc: # noqa: BLE001 - the library raises its own types + # Rate limiting is the common failure and worth naming, because the fix + # is to wait rather than to change anything. + detail = str(exc) + if "ratelimit" in detail.lower() or "202" in detail: + raise SearchError( + "DuckDuckGo is rate limiting this instance. Try again shortly, " + "or configure SearXNG instead." + ) from exc + raise SearchError(f"DuckDuckGo search failed: {detail[:200]}") from exc + + results = [] + for row in rows: + # ddgs renamed its fields across versions; both spellings are read so + # an upgrade does not silently return empty snippets. + result = clean( + row.get("title"), + row.get("href") or row.get("url") or row.get("link"), + row.get("body") or row.get("description") or row.get("snippet"), + ) + if result is not None: + results.append(result) + return results diff --git a/src/lembas/services/search/firecrawl.py b/src/lembas/services/search/firecrawl.py new file mode 100644 index 0000000..f3a8e41 --- /dev/null +++ b/src/lembas/services/search/firecrawl.py @@ -0,0 +1,75 @@ +"""Firecrawl's hosted search API. + +The paid option, and the only one of the three that needs a key. Included +because it answers with cleaned page content rather than a search engine's +snippet, which is materially better material for a model to read. +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from lembas.services.crypto import decrypt +from lembas.services.search.base import SearchError, SearchResult, clean + +DEFAULT_BASE_URL = "https://api.firecrawl.dev" + + +async def search(config: dict[str, Any], query: str, count: int) -> list[SearchResult]: + api_key = decrypt(str(config.get("firecrawl_api_key_encrypted") or "")) + if not api_key: + raise SearchError("No Firecrawl API key has been configured.") + + base_url = str(config.get("firecrawl_base_url") or DEFAULT_BASE_URL).strip().rstrip("/") + + try: + async with httpx.AsyncClient(timeout=float(config.get("timeout") or 20.0)) as client: + response = await client.post( + f"{base_url}/v1/search", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={"query": query, "limit": count}, + ) + except httpx.RequestError as exc: + raise SearchError(f"Could not reach {base_url}: {exc}") from exc + + if response.status_code == 401: + raise SearchError("Firecrawl rejected the API key.") + if response.status_code == 402: + raise SearchError("The Firecrawl account is out of credit.") + + try: + payload = response.json() + except ValueError as exc: + raise SearchError(f"Firecrawl returned HTTP {response.status_code}.") from exc + + if response.status_code >= 400 or ( + isinstance(payload, dict) and payload.get("success") is False + ): + detail = payload.get("error") if isinstance(payload, dict) else "" + raise SearchError(str(detail) or f"Firecrawl returned HTTP {response.status_code}.") + + rows = payload.get("data") if isinstance(payload, dict) else None + # Newer responses nest the list under data.web; older ones put it directly + # in data. Both are read so an API revision does not empty the results. + if isinstance(rows, dict): + rows = rows.get("web") + if not isinstance(rows, list): + raise SearchError("Firecrawl returned a response in an unexpected shape.") + + results = [] + for row in rows[:count]: + if not isinstance(row, dict): + continue + result = clean( + row.get("title"), + row.get("url"), + row.get("description") or row.get("markdown") or row.get("content"), + ) + if result is not None: + results.append(result) + return results diff --git a/src/lembas/services/search/searxng.py b/src/lembas/services/search/searxng.py new file mode 100644 index 0000000..efa2694 --- /dev/null +++ b/src/lembas/services/search/searxng.py @@ -0,0 +1,72 @@ +"""SearXNG, a self-hosted metasearch instance. + +The right answer for anyone already running one: no third party sees the +queries, and it aggregates several engines. It needs one thing switched on +first, which a stock install does not have, so that case is detected and named +rather than reported as "search failed". +""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from lembas.services.search.base import SearchError, SearchResult, clean + +# What a stock settings.yml is missing. Worth quoting exactly: it is the whole +# fix, and hunting for it in the documentation takes longer than reading it. +JSON_DISABLED = ( + "This SearXNG instance will not answer in JSON. Add \"- json\" under " + "search.formats in its settings.yml and restart it." +) + + +async def search(config: dict[str, Any], query: str, count: int) -> list[SearchResult]: + base_url = str(config.get("searxng_base_url") or "").strip().rstrip("/") + if not base_url: + raise SearchError("No SearXNG instance has been configured.") + + params = { + "q": query, + "format": "json", + "categories": "general", + "safesearch": {"off": "0", "moderate": "1", "strict": "2"}.get( + str(config.get("safesearch") or "moderate"), "1" + ), + } + + try: + async with httpx.AsyncClient( + timeout=float(config.get("timeout") or 20.0), follow_redirects=True + ) as client: + response = await client.get(f"{base_url}/search", params=params) + except httpx.RequestError as exc: + raise SearchError(f"Could not reach {base_url}: {exc}") from exc + + # 403 on an otherwise working instance means the JSON format is not in the + # allowed list -- SearXNG refuses the format rather than the request. + if response.status_code == 403: + raise SearchError(JSON_DISABLED) + if response.status_code >= 400: + raise SearchError(f"{base_url} returned HTTP {response.status_code}.") + + try: + payload = response.json() + except ValueError as exc: + # An HTML page where JSON was asked for is the same misconfiguration + # wearing a different status code. + raise SearchError(JSON_DISABLED) from exc + + rows = payload.get("results") if isinstance(payload, dict) else None + if not isinstance(rows, list): + raise SearchError("SearXNG returned a response in an unexpected shape.") + + results = [] + for row in rows[:count]: + if not isinstance(row, dict): + continue + result = clean(row.get("title"), row.get("url"), row.get("content")) + if result is not None: + results.append(result) + return results diff --git a/src/lembas/services/settings_store.py b/src/lembas/services/settings_store.py index 6427991..e292954 100644 --- a/src/lembas/services/settings_store.py +++ b/src/lembas/services/settings_store.py @@ -20,9 +20,11 @@ from lembas.config import settings as env_settings from lembas.db.models import Setting GENERAL = "general" +AUDIO = "audio" +SEARCH = "search" -def _defaults() -> dict[str, Any]: +def _general_defaults() -> dict[str, Any]: return { "allow_signup": env_settings.allow_signup, # When on, new accounts land in the `pending` role and cannot sign in @@ -35,9 +37,64 @@ def _defaults() -> dict[str, Any]: } +def _audio_defaults() -> dict[str, Any]: + """Speech-to-text and text-to-speech endpoints. + + Two separate endpoints rather than one, because they usually are: a local + install runs whisper.cpp for one and Kokoro for the other. Both speak the + OpenAI audio API, so the shape below is the same on each side. + """ + return { + "stt_enabled": False, + "stt_base_url": "", + "stt_api_key_encrypted": "", + "stt_model": "whisper-1", + # Empty means "let the server detect it", which is what whisper does + # best. A forced language is an override, not a default. + "stt_language": "", + "tts_enabled": False, + "tts_base_url": "", + "tts_api_key_encrypted": "", + "tts_model": "tts-1", + "tts_voice": "", + "tts_format": "mp3", + "tts_speed": 1.0, + # The instance-wide starting point for the per-user toggle, not a + # setting that forces anything on anyone. + "tts_autoplay": False, + } + + +def _search_defaults() -> dict[str, Any]: + return { + "enabled": False, + "provider": "ddgs", + "max_results": 5, + "region": "wt-wt", + "safesearch": "moderate", + "searxng_base_url": "", + "firecrawl_base_url": "https://api.firecrawl.dev", + "firecrawl_api_key_encrypted": "", + "timeout": 20.0, + } + + +_DEFAULTS: dict[str, Any] = { + GENERAL: _general_defaults, + AUDIO: _audio_defaults, + SEARCH: _search_defaults, +} + + +def defaults(key: str = GENERAL) -> dict[str, Any]: + """The built-in values for a settings group, with nothing stored applied.""" + factory = _DEFAULTS.get(key) + return factory() if factory else {} + + def get_group(db: DBSession, key: str = GENERAL) -> dict[str, Any]: """Stored settings for a group, with defaults filled in for absent keys.""" - values = _defaults() if key == GENERAL else {} + values = defaults(key) row = db.get(Setting, key) if row is not None and isinstance(row.value, dict): values.update(row.value) @@ -64,3 +121,11 @@ def update(db: DBSession, changes: dict[str, Any], *, key: str = GENERAL) -> dic def signup_allowed(db: DBSession) -> bool: return bool(get(db, "allow_signup")) + + +def audio(db: DBSession) -> dict[str, Any]: + return get_group(db, AUDIO) + + +def search(db: DBSession) -> dict[str, Any]: + return get_group(db, SEARCH) diff --git a/src/lembas/services/tools.py b/src/lembas/services/tools.py new file mode 100644 index 0000000..fddc78a --- /dev/null +++ b/src/lembas/services/tools.py @@ -0,0 +1,261 @@ +"""Tools a model may call while it answers. + +One tool so far -- web search -- but the shape is the point: a registry of +named callables with a JSON schema each, offered to the endpoint and executed +here when it asks. Built-in tools, MCP servers and agentic execution all plug +in at the same place. + +Two things gate whether a tool is offered at all: + +* the administrator has configured and enabled it, and +* the chat's model is marked as supporting tools. + +The second is not optional politeness. Sending a ``tools`` array to an endpoint +that does not implement tool calling fails the entire request, exactly the way +sending image parts to a model without vision does -- and for the same reason, +the capability flag on the model is what decides. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any + +from sqlalchemy.orm import Session as DBSession + +from lembas.db.models import Chat, User +from lembas.services import search as search_service +from lembas.services import settings_store +from lembas.services.search.base import SearchError + +log = logging.getLogger(__name__) + +# How many times a model may call tools before it has to answer with words. +# Not a safety limit so much as a termination one: a small model that has +# decided searching is the answer will otherwise search until the context runs +# out, and each round costs a full request. +MAX_ROUNDS = 3 + +WEB_SEARCH = "web_search" + +WEB_SEARCH_SCHEMA: dict[str, Any] = { + "type": "function", + "function": { + "name": WEB_SEARCH, + "description": ( + "Search the web for current information. Use this when the answer " + "depends on recent events, on facts you are unsure of, or on " + "anything that may have changed since your training data. Returns " + "a numbered list of results with titles, URLs and short extracts." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search terms. Keep them short and specific.", + }, + "max_results": { + "type": "integer", + "description": "How many results to return. Defaults to the site setting.", + }, + }, + "required": ["query"], + }, + }, +} + + +@dataclass +class ToolOutcome: + """What running a tool produced, for the model and for the reader. + + The two are deliberately different. `content` is the flat text the model + reads back; `event` is what the transcript shows, and keeps the results + structured so they can be rendered as links rather than as a wall of URLs. + """ + + content: str + event: dict[str, Any] = field(default_factory=dict) + + +def enabled_tools(db: DBSession, chat: Chat, user: User | None) -> list[dict[str, Any]]: + """The tool schemas to offer for this chat, which is usually none.""" + from lembas.security import permissions + from lembas.services import chat as chat_service + + config = settings_store.search(db) + if not config.get("enabled"): + return [] + if not permissions.has(db, user, "tools.web_search"): + return [] + if not chat_service.model_supports(db, chat, "tools"): + return [] + if search_service.availability(str(config.get("provider") or "ddgs")): + # Configured but unusable -- offering a tool that will fail on every + # call is worse than not offering it. + return [] + return [WEB_SEARCH_SCHEMA] + + +async def run_tool(config: dict[str, Any], name: str, arguments: str) -> ToolOutcome: + """Execute one tool call. + + Never raises. A tool that fails hands the model an explanation and lets it + carry on -- a failed search should produce "I could not look that up" + rather than killing the whole reply. + """ + if name != WEB_SEARCH: + return ToolOutcome( + content=f"There is no tool called {name!r}.", + event={"name": name, "status": "error", "error": "Unknown tool."}, + ) + + try: + parsed = json.loads(arguments) if arguments.strip() else {} + except json.JSONDecodeError: + # Small models emit malformed argument JSON often enough that this is a + # normal path, not an exceptional one. Treat the whole string as the + # query rather than giving up. + parsed = {"query": arguments.strip()} + if not isinstance(parsed, dict): + parsed = {"query": str(parsed)} + + query = str(parsed.get("query") or "").strip() + if not query: + return ToolOutcome( + content="No search query was given.", + event={"name": name, "status": "error", "error": "No query was given."}, + ) + + limit = parsed.get("max_results") + try: + limit = int(limit) if limit is not None else None + except (TypeError, ValueError): + limit = None + + try: + results = await search_service.run(config, query, limit=limit) + except SearchError as exc: + log.info("web search failed for %r: %s", query[:60], exc.message) + return ToolOutcome( + content=f"The search failed: {exc.message}", + event={"name": name, "query": query, "status": "error", "error": exc.message}, + ) + + event = { + "name": name, + "query": query, + "status": "ok", + "results": [ + {"title": r.title, "url": r.url, "snippet": r.snippet, "host": r.host} + for r in results + ], + } + + if not results: + return ToolOutcome(content=f"No results were found for {query!r}.", event=event) + + lines = [f"Search results for {query!r}:"] + for index, result in enumerate(results, start=1): + lines.append(f"\n[{index}] {result.title}\n{result.url}\n{result.snippet}") + return ToolOutcome(content="\n".join(lines), event=event) + + +class ToolCallAccumulator: + """Reassembles tool calls arriving as streamed fragments. + + An endpoint sends ``delta.tool_calls`` as a list of partial objects: the id + and the function name arrive once, and ``arguments`` arrives as a string + split across however many chunks the tokeniser produced. Entries are keyed + by ``index`` because that is the only field guaranteed on every fragment -- + the id is absent from continuations, and matching on name breaks the moment + a model calls the same tool twice in one turn. + """ + + def __init__(self) -> None: + self._calls: dict[int, dict[str, Any]] = {} + + def feed(self, fragments: list[dict[str, Any]]) -> None: + for fragment in fragments: + if not isinstance(fragment, dict): + continue + index = fragment.get("index") + if not isinstance(index, int): + # Some servers omit index entirely when there is only one call. + index = 0 + call = self._calls.setdefault(index, {"id": "", "name": "", "arguments": ""}) + + if fragment.get("id"): + call["id"] = str(fragment["id"]) + function = fragment.get("function") or {} + if isinstance(function, dict): + if function.get("name"): + call["name"] = str(function["name"]) + arguments = function.get("arguments") + if isinstance(arguments, str): + call["arguments"] += arguments + + @property + def calls(self) -> list[dict[str, Any]]: + """Completed calls, in the order the endpoint indexed them.""" + return [ + { + # An id is required when the results are sent back, and not + # every server supplies one. + "id": call["id"] or f"call_{index}", + "name": call["name"], + "arguments": call["arguments"], + } + for index, call in sorted(self._calls.items()) + if call["name"] + ] + + def __bool__(self) -> bool: + return bool(self.calls) + + +def assistant_turn(calls: list[dict[str, Any]], content: str) -> dict[str, Any]: + """The assistant message to send back with the tool results. + + The endpoint needs its own tool_calls echoed before the tool replies, or it + has nothing to match the tool_call_ids against. + """ + return { + "role": "assistant", + "content": content or None, + "tool_calls": [ + { + "id": call["id"], + "type": "function", + "function": {"name": call["name"], "arguments": call["arguments"]}, + } + for call in calls + ], + } + + +def tool_turn(call: dict[str, Any], content: str) -> dict[str, Any]: + return { + "role": "tool", + "tool_call_id": call["id"], + "name": call["name"], + "content": content, + } + + +ToolRunner = Callable[..., Awaitable[ToolOutcome]] + +__all__ = [ + "MAX_ROUNDS", + "WEB_SEARCH", + "ToolCallAccumulator", + "ToolOutcome", + "assistant_turn", + "enabled_tools", + "run_tool", + "tool_turn", +] diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index ce24828..226db54 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -18,6 +18,19 @@ body { height: 100%; } +/* + The `hidden` attribute has to win. + + The browser's own rule is `[hidden] { display: none }`, which any component + rule setting `display` outranks -- `.btn` is `display: inline-flex`, so a + button hidden from JavaScript stayed visible. That is not a styling nit: it + is how the Stop button came to sit permanently beside Send. Anything toggled + with `hidden` anywhere in the application depends on this line. +*/ +[hidden] { + display: none !important; +} + body { margin: 0; font-family: var(--font-body); diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 51519d1..b2ac815 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -197,6 +197,67 @@ 50% { opacity: 1; } } +/* --- Tool activity -------------------------------------------------------- + Deliberately the same object as the reasoning block: both answer "what did + it do before it replied", and giving them two visual languages would suggest + a difference that is not there. */ +.tool-activity-list:empty { display: none; } + +.tool-activity { + margin: 0 0 var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--surface) 70%, transparent); + font-size: var(--text-sm); +} +.tool-activity--error { border-color: var(--danger); } + +.tool-activity__summary { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + cursor: pointer; + color: var(--ink-muted); + list-style: none; + user-select: none; + border-radius: var(--radius); +} +.tool-activity__summary::-webkit-details-marker { display: none; } +.tool-activity__summary:hover { color: var(--ink); background: var(--surface-hover); } +.tool-activity__icon { color: var(--gold); flex: none; } +.tool-activity__label { flex: 1; } +.tool-activity__count { color: var(--ink-faint); } +.tool-activity[open] .reasoning__chevron { transform: rotate(180deg); } + +.tool-activity__body { + padding: 0 var(--sp-3) var(--sp-3); + display: flex; + flex-direction: column; + gap: var(--sp-3); +} +.tool-activity__error { margin: 0; color: var(--ink-muted); } + +.tool-result { + display: flex; + flex-direction: column; + gap: 2px; + padding-left: var(--sp-3); + border-left: 2px solid var(--border-strong); + min-width: 0; +} +.tool-result__title { + color: var(--accent); + font-weight: 500; + overflow-wrap: anywhere; +} +.tool-result__host { color: var(--ink-faint); font-size: var(--text-xs); } +.tool-result__snippet { + margin: 0; + color: var(--ink-muted); + line-height: var(--leading-relaxed); +} + /* --- Stop, notes and editing ---------------------------------------------- */ .msg__waiting { display: flex; @@ -208,15 +269,45 @@ the dots go, but Stop must stay reachable until the stream ends. */ .msg__body--live:not(:empty) + .msg__waiting .dots { display: none; } -.composer__stop { +/* Send and Stop are one button. Which icon shows is decided here rather than + in JavaScript, so the state is visible in the markup and the swap is free. */ +.composer__icon { display: flex; } +[data-composer-action="send"] .composer__icon--stop, +[data-composer-action="stop"] .composer__icon--send { display: none; } + +.composer__btn[data-composer-action="stop"] { background: var(--danger); border-color: var(--danger); color: var(--ink-inverse); } -.composer__stop:hover:not(:disabled) { +.composer__btn[data-composer-action="stop"]:hover:not(:disabled) { background: var(--danger-hover); border-color: var(--danger-hover); } +/* The microphone is the same shape: one button, state in a data attribute. */ +[data-mic-state="idle"] .composer__icon--recording, +[data-mic-state="working"] .composer__icon--recording, +[data-mic-state="recording"] .composer__icon--mic { display: none; } + +.composer__mic[data-mic-state="recording"] { + background: var(--danger); + border-color: var(--danger); + color: var(--ink-inverse); + animation: mic-pulse 1.6s ease-in-out infinite; +} + +@keyframes mic-pulse { + 0%, 100% { box-shadow: 0 0 0 0 var(--accent-soft); } + 50% { box-shadow: 0 0 0 5px transparent; } +} + +/* Play and stop on the read-aloud button, chosen by the class audio.js sets. */ +.speak__icon { display: flex; } +[data-speak] .speak__icon--stop, +[data-speak].is-speaking .speak__icon--play { display: none; } +[data-speak].is-speaking .speak__icon--stop { display: flex; } +[data-speak].is-speaking { color: var(--accent); } + .composer__stop-square { width: 0.7rem; height: 0.7rem; diff --git a/src/lembas/web/static/img/apple-touch-icon-180.png b/src/lembas/web/static/img/apple-touch-icon-180.png new file mode 100644 index 0000000..9a208f2 Binary files /dev/null and b/src/lembas/web/static/img/apple-touch-icon-180.png differ diff --git a/src/lembas/web/static/img/icon-192.png b/src/lembas/web/static/img/icon-192.png new file mode 100644 index 0000000..6c528e8 Binary files /dev/null and b/src/lembas/web/static/img/icon-192.png differ diff --git a/src/lembas/web/static/img/icon-512.png b/src/lembas/web/static/img/icon-512.png new file mode 100644 index 0000000..2a23230 Binary files /dev/null and b/src/lembas/web/static/img/icon-512.png differ diff --git a/src/lembas/web/static/img/icon-maskable-512.png b/src/lembas/web/static/img/icon-maskable-512.png new file mode 100644 index 0000000..c9a1f13 Binary files /dev/null and b/src/lembas/web/static/img/icon-maskable-512.png differ diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index bc7d254..e201e23 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -26,6 +26,16 @@ localStorage.setItem(THEME_KEY, name); } catch (e) { /* private mode */ } + /* Installed, the browser's own chrome is the application's chrome, so it + has to follow the theme too. Read from the stylesheet rather than + repeating the hex here: tokens.css is the one place colours live. */ + var meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + var bg = getComputedStyle(document.documentElement) + .getPropertyValue("--bg").trim(); + if (bg) meta.setAttribute("content", bg); + } + document.querySelectorAll("[data-theme-toggle]").forEach(function (el) { el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)" : "Switch to Moria (dark)"); @@ -190,13 +200,51 @@ }); } + /* --- Installing as an app ---------------------------------------------- + Chromium fires beforeinstallprompt when it decides the app is installable + and lets the page defer the prompt. The event is the only handle on it, so + it is kept; there is no way to ask later whether one is available. + + Nothing appears unless the browser offers it. Firefox and desktop Safari + never fire the event, and there is no useful button to show in their + place -- an "Install" that does nothing is worse than none. */ + var installPrompt = null; + + function revealInstall(show) { + document.querySelectorAll("[data-install-app]").forEach(function (el) { + el.hidden = !show; + }); + } + + function promptInstall() { + if (!installPrompt) return; + installPrompt.prompt(); + installPrompt.userChoice.then(function () { + // A prompt is single-use, accepted or dismissed. + installPrompt = null; + revealInstall(false); + }); + } + + window.addEventListener("beforeinstallprompt", function (event) { + event.preventDefault(); + installPrompt = event; + revealInstall(true); + }); + + window.addEventListener("appinstalled", function () { + installPrompt = null; + revealInstall(false); + }); + window.lembas = { applyTheme: applyTheme, toggleTheme: toggleTheme, copyText: copyText, scrollThread: scrollThread, autosize: autosize, - uploadFiles: uploadFiles + uploadFiles: uploadFiles, + promptInstall: promptInstall }; /* --- Wiring ------------------------------------------------------------ */ diff --git a/src/lembas/web/static/js/audio.js b/src/lembas/web/static/js/audio.js new file mode 100644 index 0000000..036849f --- /dev/null +++ b/src/lembas/web/static/js/audio.js @@ -0,0 +1,209 @@ +/* + Dictation and read-aloud. + + Both halves are progressive: without this file the composer and the message + bubbles still work, they simply have two buttons that do nothing. Neither + feature's markup is rendered at all unless an administrator has configured an + endpoint for it, so that state is rare rather than normal. +*/ +(function () { + "use strict"; + + function notify(message, kind) { + if (window.lembas && window.lembas.notify) { + window.lembas.notify(message, { kind: kind || "info" }); + } + } + + /* --- Dictation --------------------------------------------------------- + MediaRecorder writes whatever container the browser prefers -- webm/opus + almost everywhere, mp4 on Safari. The file is passed upstream with the + type the browser reported rather than being converted here: whisper.cpp + and friends decode through ffmpeg and take all of them, and converting in + the browser would mean shipping an encoder. */ + var recorder = null; + var chunks = []; + var micButton = null; + + function setMicState(button, state) { + if (!button) return; + button.dataset.micState = state; + button.disabled = state === "working"; + button.setAttribute( + "aria-label", + state === "recording" ? "Stop recording" : "Dictate a message" + ); + button.title = button.getAttribute("aria-label"); + } + + function composerInput() { + return document.querySelector("[data-composer-input]"); + } + + function insertTranscript(text) { + var input = composerInput(); + if (!input || !text) return; + // Appended rather than replacing: dictation is usually finishing a thought + // that was already half typed. + var existing = input.value.trim(); + input.value = existing ? existing + " " + text : text; + if (window.lembas && window.lembas.autosize) window.lembas.autosize(input); + input.focus(); + input.selectionStart = input.selectionEnd = input.value.length; + } + + function upload(blob, button) { + var body = new FormData(); + // The extension only has to be something the server can name the part; + // the endpoint sniffs the container itself. + var extension = (blob.type.indexOf("mp4") !== -1) ? "mp4" : "webm"; + body.append("file", blob, "dictation." + extension); + + setMicState(button, "working"); + fetch("/api/audio/transcribe", { + method: "POST", + body: body, + credentials: "same-origin", + }) + .then(function (response) { + if (!response.ok) { + return response.json() + .catch(function () { return {}; }) + .then(function (payload) { + throw new Error(payload.detail || "Transcription failed."); + }); + } + return response.text(); + }) + .then(function (text) { + setMicState(button, "idle"); + if (!text.trim()) { + notify("Nothing was heard in that recording.", "info"); + return; + } + insertTranscript(text.trim()); + }) + .catch(function (error) { + setMicState(button, "idle"); + notify(error.message || "Transcription failed.", "error"); + }); + } + + function startRecording(button) { + /* getUserMedia is undefined on plain http, which a self-hosted install on + a LAN address often is. Saying so beats a button that silently does + nothing -- the fix is not something the page can apply for them. */ + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || + typeof MediaRecorder === "undefined") { + notify( + "The microphone needs HTTPS or localhost. This page is served over " + + "plain HTTP, so the browser will not grant it.", + "error" + ); + return; + } + + navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) { + chunks = []; + recorder = new MediaRecorder(stream); + micButton = button; + + recorder.addEventListener("dataavailable", function (event) { + if (event.data && event.data.size) chunks.push(event.data); + }); + recorder.addEventListener("stop", function () { + // Release the microphone immediately: leaving the track live keeps the + // browser's recording indicator on long after anyone is talking. + stream.getTracks().forEach(function (track) { track.stop(); }); + var blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" }); + recorder = null; + if (blob.size) upload(blob, button); else setMicState(button, "idle"); + }); + + recorder.start(); + setMicState(button, "recording"); + }).catch(function () { + notify("The microphone could not be opened. Permission may be blocked.", "error"); + }); + } + + function stopRecording() { + if (recorder && recorder.state !== "inactive") recorder.stop(); + } + + /* --- Reading a reply aloud --------------------------------------------- + One