PWA, one send/stop button, audio in and out, web search as a tool

Four pieces of work.

**Installable.** A manifest carrying the instance name, PWA icons rasterised
from the existing mark at design time, a service worker and a themed offline
page. The worker caches the shell only and bails out on /api/, /auth/, /admin/
and anything accepting text/event-stream -- passing a reply stream through a
worker turns it into one delivery at the end, or nothing. It is served from
GET /sw.js rather than the static mount because a worker's scope is the path it
came from.

**Send and Stop are one button.** They were two, and the hidden one was never
hidden: `.btn` is display: inline-flex, which outranks the browser's own
`[hidden] { display: none }`, so Stop sat permanently beside Send. app.css now
forces the attribute to win -- every control toggled with `hidden` depended on
that -- and the composer renders one button carrying both icons, with ui.js
flipping data-composer-action and the type with it.

**Audio.** Speech to text and text to speech against any OpenAI-shaped
/v1/audio/* endpoint: dictate into the composer, have a reply read out.
Instance settings in Admin, per-reader overrides in Settings, with the voice
list discovered from the server where it offers one. Recorded audio is capped
and never written to disk -- it is not an attachment, it has no owner, and
nothing would ever sweep it.

**Web search, as a tool.** This is the tool loop PLAN.md described as the real
work: one reply is now a bounded sequence of requests rather than one. The model
asks, the tool runs, the result goes back and it is asked again, up to three
rounds. Providers are DuckDuckGo (no setup), SearXNG and Firecrawl.

Two decisions worth stating. Tools are only offered to models flagged `tools`,
because an endpoint without support rejects the whole request rather than
ignoring the array -- the same reason images only reach models flagged
`vision`. And tool results are not replayed as context on the next turn, for the
same reasons reasoning is not: the answer already contains what the model made
of them, and replaying stale results into every later request wastes the window
and reliably sends a small model into a search loop. The sources stay visible in
the transcript instead.

Search results are untrusted third-party text and are treated as such: escaped,
and only http/https URLs rendered as links.

338 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 17:56:50 +02:00
parent ca3e4fd04f
commit 436226370a
61 changed files with 4481 additions and 116 deletions
+49 -3
View File
@@ -48,6 +48,14 @@ runtime. Clone it, `pip install -e .`, run it.
put in the prompt put in the prompt
- **Folders** — arbitrarily nested, delete a folder without losing the chats - **Folders** — arbitrarily nested, delete a folder without losing the chats
inside it 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, - **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp,
llama-swap, Ollama or OpenRouter; models are discovered and cached llama-swap, Ollama or OpenRouter; models are discovered and cached
- **Model settings** — searchable, filterable list with a page per model: - **Model settings** — searchable, filterable list with a page per model:
@@ -64,8 +72,8 @@ runtime. Clone it, `pip install -e .`, run it.
**Planned** **Planned**
Built-in tools with admin settings · custom tools and MCP servers · agentic Custom tools and MCP servers · agentic execution (local and over SSH) · image
execution (local and over SSH) · image generation · OCR for scanned PDFs. generation · OCR for scanned PDFs.
See [PLAN.md](PLAN.md) for what is built, what is not, and why. 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 cd LLeMbas
python -m venv .venv && . .venv/bin/activate 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 cp .env.example .env
lembas secret-key # paste the result into LEMBAS_SECRET_KEY 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: > access is needed to run. To re-fetch or bump them:
> `python scripts/fetch_vendor.py --update`. > `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 ## Configuration
All variables are prefixed `LEMBAS_` and can live in `.env`. See All variables are prefixed `LEMBAS_` and can live in `.env`. See
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+3 -1
View File
@@ -76,7 +76,9 @@ if [[ ! -x "$VENV/bin/python" ]]; then
sudo -u "$SERVICE_USER" python -m venv "$VENV" sudo -u "$SERVICE_USER" python -m venv "$VENV"
fi fi
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip 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 ==" echo "== environment =="
# Generated once and never regenerated: rotating LEMBAS_SECRET_KEY signs every # Generated once and never regenerated: rotating LEMBAS_SECRET_KEY signs every
+10
View File
@@ -58,4 +58,14 @@ server {
expires 1h; expires 1h;
add_header Cache-Control "public"; 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";
}
} }
+4 -1
View File
@@ -37,8 +37,11 @@ else
fi fi
# Cheap and idempotent; catches a dependency added since the last deploy. # 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 ==" 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 ==" echo "== restart =="
sudo systemctl restart lembas sudo systemctl restart lembas
+5
View File
@@ -44,6 +44,11 @@ dev = [
"pytest-asyncio>=0.24", "pytest-asyncio>=0.24",
"ruff>=0.7", "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] [project.scripts]
lembas = "lembas.cli:app" lembas = "lembas.cli:app"
+86 -5
View File
@@ -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 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: files are committed. Re-run it only when the artwork itself changes:
pip install fonttools pip install fonttools cairosvg
python scripts/build_artwork.py 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 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 # assets/ holds the design masters; the application serves its own copies from
# static/. These are the few the running app actually needs. # 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_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.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' <rect width="{size:.0f}" height="{size:.0f}" fill="{background}"/>\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 ""} <g transform="translate({offset:.3f} {offset:.3f}) \
scale({scale:.4f})">
{mark_body(prefix)}
</g>
</svg>
"""
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: def _mountains(width: float, base_y: float, seed: int, height: float, colour: str) -> str:
"""One jagged ridge line spanning the full width.""" """One jagged ridge line spanning the full width."""
rng = random.Random(seed) rng = random.Random(seed)
@@ -458,6 +532,10 @@ BUILDERS = {
"wordmark.svg": build_wordmark, "wordmark.svg": build_wordmark,
"logo-lockup.svg": build_lockup, "logo-lockup.svg": build_lockup,
"banner.svg": build_banner, "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: for filename in args.only or BUILDERS:
content = BUILDERS[filename]() 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 = args.out / filename
path.write_text(content, encoding="utf-8") path.write_bytes(data)
print(f"wrote {path.relative_to(ROOT)} ({len(content.encode()):,} bytes)") print(f"wrote {path.relative_to(ROOT)} ({len(data):,} bytes)")
if filename in SERVED_BY_APP: if filename in SERVED_BY_APP:
served = STATIC_IMG / filename served = STATIC_IMG / filename
served.write_text(content, encoding="utf-8") served.write_bytes(data)
print(f" -> {served.relative_to(ROOT)}") print(f" -> {served.relative_to(ROOT)}")
+1 -6
View File
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db from lembas.api.deps import AdminUser, Db
from lembas.db.models import Connection, Model, User from lembas.db.models import Connection, Model, User
from lembas.services import settings_store 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.services.llm.openai_client import Endpoint, LLMError, list_models
from lembas.web.templating import render from lembas.web.templating import render
@@ -21,11 +21,6 @@ log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"]) 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: def _connection(db: DBSession, connection_id: str) -> Connection:
connection = db.get(Connection, connection_id) connection = db.get(Connection, connection_id)
+187
View File
@@ -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
+110
View File
@@ -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},
)
+183
View File
@@ -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
+23 -1
View File
@@ -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.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
from lembas.db.session import session_scope from lembas.db.session import session_scope
from lembas.security import permissions 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 chat as chat_service
from lembas.services import files as files_service from lembas.services import files as files_service
from lembas.services import generation as generation_service from lembas.services import generation as generation_service
@@ -183,6 +184,7 @@ async def post_message(
"models_by_id": { "models_by_id": {
m.model_id: m for m in chat_service.available_models(db, user) 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]: async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
"""Stream a generation that is running independently of this request. """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 seen = generation.version
if generation.thinking: if generation.thinking:
yield sse.event("reasoning", escape_text(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: if generation.content:
yield sse.event("render", render_markdown(generation.text)) 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", "") yield sse.event("close", "")
return return
owner = db.get(User, chat.user_id)
final_html = templates.get_template("chat/_message.html").render( final_html = templates.get_template("chat/_message.html").render(
{ {
"message": message, "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 # Passed even though an assistant bubble never reads it: the
# template shares both roles, and a missing `user` would only # template shares both roles, and a missing `user` would only
# blow up on whichever branch is not being exercised here. # blow up on whichever branch is not being exercised here.
"user": db.get(User, chat.user_id), "user": owner,
"models_by_id": { "models_by_id": {
m.model_id: m for m in chat_service.available_models(db, None) 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}) 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 if m.role == ROLE_ASSISTANT and m.content
}, },
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)}, "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": { "models_by_id": {
m.model_id: m for m in chat_service.available_models(db, user) m.model_id: m for m in chat_service.available_models(db, user)
}, },
**audio_service.template_flags(db, user),
}, },
) )
+85 -4
View File
@@ -2,20 +2,27 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, status from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session as DBSession from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser from lembas.api.deps import Db, RequiredUser
from lembas.db.models import Chat, Folder, Message, User from lembas.db.models import Chat, Folder, Message, User
from lembas.security import permissions 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 chat as chat_service
from lembas.services import settings_store
from lembas.services.markdown import render_markdown 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"]) 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: def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
"""Model lists and permissions every chat page needs. """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 # may not be the model the chat is set to now. Keyed by model_id, the
# denormalised value stored on each message. # denormalised value stored on each message.
"models_by_id": {m.model_id: m for m in models}, "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) 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") @router.get("/chat")
async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = ""): async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = ""):
"""A composer with no chat behind it yet. """A composer with no chat behind it yet.
@@ -177,6 +249,13 @@ async def settings_page(
error: str = "", error: str = "",
saved: 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 # 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. # back here: a POST that re-rendered in place would re-submit on refresh.
return render( return render(
@@ -186,7 +265,9 @@ async def settings_page(
"chat": None, "chat": None,
"error": error, "error": error,
"saved": saved, "saved": saved,
**_chat_context(db, user, None), "voices": voices,
"voice_error": voice_error,
**context,
**_sidebar_context(db, user), **_sidebar_context(db, user),
}, },
) )
+35
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import logging import logging
from fastapi import APIRouter, Body, Form, Request, status 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) 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") @router.post("/password")
async def change_password( async def change_password(
request: Request, request: Request,
+5
View File
@@ -116,6 +116,11 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
reasoning_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False) reasoning_ms: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
model_id: Mapped[str] = mapped_column(String(300), default="") 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) tool_calls_json: Mapped[list[Any]] = mapped_column(JSONList, default=list)
usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) usage_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
+6
View File
@@ -14,8 +14,11 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from lembas import __version__ from lembas import __version__
from lembas.api import ( from lembas.api import (
admin, admin,
admin_audio,
admin_models, admin_models,
admin_search,
admin_users, admin_users,
audio,
auth, auth,
chats, chats,
files, files,
@@ -92,11 +95,14 @@ def create_app() -> FastAPI:
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(preferences.router) app.include_router(preferences.router)
app.include_router(chats.router) app.include_router(chats.router)
app.include_router(audio.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(folders.router) app.include_router(folders.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(admin_users.router) app.include_router(admin_users.router)
app.include_router(admin_models.router) app.include_router(admin_models.router)
app.include_router(admin_audio.router)
app.include_router(admin_search.router)
register_error_handlers(app) register_error_handlers(app)
return app return app
+22
View File
@@ -78,6 +78,28 @@ PERMISSION_DEFS: tuple[PermissionDef, ...] = (
True, True,
"Workspace", "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) PERMISSION_KEYS = tuple(d.key for d in PERMISSION_DEFS)
+300
View File
@@ -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",
]
+21
View File
@@ -19,6 +19,13 @@ from lembas.config import settings
log = logging.getLogger(__name__) 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 @lru_cache
def _fernet() -> Fernet: def _fernet() -> Fernet:
@@ -56,3 +63,17 @@ def mask(secret: str) -> str:
if len(secret) <= 8: if len(secret) <= 8:
return "*" * len(secret) return "*" * len(secret)
return f"{secret[:3]}{'*' * 8}{secret[-4:]}" 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)
+78 -3
View File
@@ -22,10 +22,18 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta 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.db.session import session_scope
from lembas.services import chat as chat_service 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 from lembas.services.reasoning import REASONING, ReasoningSplitter
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -52,6 +60,10 @@ class Generation:
reasoning: list[str] = field(default_factory=list) reasoning: list[str] = field(default_factory=list)
reasoning_ms: int = 0 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 = "" error: str = ""
stopped: bool = False stopped: bool = False
done: bool = False done: bool = False
@@ -130,7 +142,15 @@ async def shutdown() -> None:
async def _run(generation: Generation) -> 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() splitter = ReasoningSplitter()
started = time.monotonic() started = time.monotonic()
reasoning_started: float | None = None reasoning_started: float | None = None
@@ -151,6 +171,19 @@ async def _run(generation: Generation) -> None:
question = _question_from(payload) question = _question_from(payload)
needs_title = not chat.title_generated needs_title = not chat.title_generated
# 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 {}
if offered:
payload = {**payload, "tools": offered}
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): async for chunk in stream_chat(endpoint, payload):
thought = delta_reasoning(chunk) thought = delta_reasoning(chunk)
if thought: if thought:
@@ -159,6 +192,11 @@ async def _run(generation: Generation) -> None:
generation.reasoning.append(thought) generation.reasoning.append(thought)
generation.touch() generation.touch()
if offered:
fragments = delta_tool_calls(chunk)
if fragments:
accumulator.feed(fragments)
text = delta_text(chunk) text = delta_text(chunk)
if text: if text:
for kind, piece in splitter.feed(text): for kind, piece in splitter.feed(text):
@@ -172,6 +210,7 @@ async def _run(generation: Generation) -> None:
(time.monotonic() - reasoning_started) * 1000 (time.monotonic() - reasoning_started) * 1000
) )
generation.content.append(piece) generation.content.append(piece)
round_text.append(piece)
generation.touch() generation.touch()
if generation.cancel: if generation.cancel:
@@ -181,6 +220,41 @@ async def _run(generation: Generation) -> None:
# Let followers and other tasks run between chunks. # Let followers and other tasks run between chunks.
await asyncio.sleep(0) await asyncio.sleep(0)
calls = accumulator.calls
if generation.stopped or not calls:
break
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(): for kind, piece in splitter.flush():
(generation.reasoning if kind == REASONING else generation.content).append(piece) (generation.reasoning if kind == REASONING else generation.content).append(piece)
generation.touch() generation.touch()
@@ -248,6 +322,7 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
message.content = generation.text message.content = generation.text
message.reasoning = generation.thinking message.reasoning = generation.thinking
message.reasoning_ms = generation.reasoning_ms message.reasoning_ms = generation.reasoning_ms
message.tool_calls_json = generation.tool_events
message.error = generation.error message.error = generation.error
message.stopped = generation.stopped message.stopped = generation.stopped
message.complete = True message.complete = True
+46 -8
View File
@@ -77,9 +77,13 @@ class Endpoint:
return headers 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. """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 Providers put the useful part in wildly different places, so try the common
shapes before falling back to the raw body. 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}." 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): if isinstance(exc, httpx.ConnectError):
return LLMError( return LLMError(
f"Could not reach {endpoint.base_url}. Is the endpoint running and " 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() response.raise_for_status()
payload = response.json() payload = response.json()
except httpx.HTTPStatusError as exc: 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: 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: except ValueError as exc:
raise LLMError("The endpoint returned a response that was not JSON.") from exc raise LLMError("The endpoint returned a response that was not JSON.") from exc
@@ -195,9 +199,9 @@ async def stream_chat(
continue continue
except httpx.HTTPStatusError as exc: 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: 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: 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() response.raise_for_status()
data = response.json() data = response.json()
except httpx.HTTPStatusError as exc: 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: 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: except ValueError as exc:
raise LLMError("The endpoint returned a response that was not JSON.") from 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 "" 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: def delta_text(chunk: dict[str, Any]) -> str:
"""Pull the text out of one streamed chunk, tolerating provider variation.""" """Pull the text out of one streamed chunk, tolerating provider variation."""
try: try:
+38
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import functools import functools
import html import html
import re
import nh3 import nh3
from markdown_it import MarkdownIt 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. slashes, which triples the size of a streamed token for no benefit.
""" """
return html.escape(text, quote=False) 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 <pre> 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. <pre> cannot nest, so this is exact.
_CODE_BLOCK = re.compile(r"<pre\b[^>]*>.*?</pre>", re.DOTALL)
_CODE_LABEL = re.compile(r"<div class=\"code-block__label\">.*?</div>", 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]
+108
View File
@@ -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"]
+62
View File
@@ -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],
)
+88
View File
@@ -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
+75
View File
@@ -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
+72
View File
@@ -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
+67 -2
View File
@@ -20,9 +20,11 @@ from lembas.config import settings as env_settings
from lembas.db.models import Setting from lembas.db.models import Setting
GENERAL = "general" GENERAL = "general"
AUDIO = "audio"
SEARCH = "search"
def _defaults() -> dict[str, Any]: def _general_defaults() -> dict[str, Any]:
return { return {
"allow_signup": env_settings.allow_signup, "allow_signup": env_settings.allow_signup,
# When on, new accounts land in the `pending` role and cannot sign in # 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]: def get_group(db: DBSession, key: str = GENERAL) -> dict[str, Any]:
"""Stored settings for a group, with defaults filled in for absent keys.""" """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) row = db.get(Setting, key)
if row is not None and isinstance(row.value, dict): if row is not None and isinstance(row.value, dict):
values.update(row.value) 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: def signup_allowed(db: DBSession) -> bool:
return bool(get(db, "allow_signup")) 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)
+261
View File
@@ -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",
]
+13
View File
@@ -18,6 +18,19 @@ body {
height: 100%; 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 { body {
margin: 0; margin: 0;
font-family: var(--font-body); font-family: var(--font-body);
+93 -2
View File
@@ -197,6 +197,67 @@
50% { opacity: 1; } 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 ---------------------------------------------- */ /* --- Stop, notes and editing ---------------------------------------------- */
.msg__waiting { .msg__waiting {
display: flex; display: flex;
@@ -208,15 +269,45 @@
the dots go, but Stop must stay reachable until the stream ends. */ the dots go, but Stop must stay reachable until the stream ends. */
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; } .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); background: var(--danger);
border-color: var(--danger); border-color: var(--danger);
color: var(--ink-inverse); color: var(--ink-inverse);
} }
.composer__stop:hover:not(:disabled) { .composer__btn[data-composer-action="stop"]:hover:not(:disabled) {
background: var(--danger-hover); background: var(--danger-hover);
border-color: 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 { .composer__stop-square {
width: 0.7rem; width: 0.7rem;
height: 0.7rem; height: 0.7rem;
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+49 -1
View File
@@ -26,6 +26,16 @@
localStorage.setItem(THEME_KEY, name); localStorage.setItem(THEME_KEY, name);
} catch (e) { /* private mode */ } } 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) { document.querySelectorAll("[data-theme-toggle]").forEach(function (el) {
el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)" el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)"
: "Switch to Moria (dark)"); : "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 = { window.lembas = {
applyTheme: applyTheme, applyTheme: applyTheme,
toggleTheme: toggleTheme, toggleTheme: toggleTheme,
copyText: copyText, copyText: copyText,
scrollThread: scrollThread, scrollThread: scrollThread,
autosize: autosize, autosize: autosize,
uploadFiles: uploadFiles uploadFiles: uploadFiles,
promptInstall: promptInstall
}; };
/* --- Wiring ------------------------------------------------------------ */ /* --- Wiring ------------------------------------------------------------ */
+209
View File
@@ -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 <audio> element for the whole page. Two replies talking over each
other is never what was wanted, and a shared element makes that
impossible rather than merely unlikely. */
var player = null;
var speaking = null;
function audioPlayer() {
if (!player) {
player = new Audio();
player.addEventListener("ended", function () { markSpeaking(null); });
player.addEventListener("error", function () {
if (speaking) notify("That reply could not be read out.", "error");
markSpeaking(null);
});
}
return player;
}
function markSpeaking(button) {
document.querySelectorAll("[data-speak]").forEach(function (el) {
el.classList.toggle("is-speaking", el === button);
});
speaking = button;
}
function speak(button) {
var element = audioPlayer();
if (speaking === button) {
element.pause();
markSpeaking(null);
return;
}
element.pause();
element.src = button.dataset.speak;
markSpeaking(button);
element.play().catch(function () {
/* Autoplay policies reject a play() the reader did not ask for. That is
the browser working as intended, so it is not reported as an error. */
markSpeaking(null);
});
}
/* --- Wiring ------------------------------------------------------------ */
document.addEventListener("click", function (event) {
var mic = event.target.closest("[data-mic]");
if (mic) {
event.preventDefault();
if (mic.dataset.micState === "recording") stopRecording();
else if (mic.dataset.micState === "idle") startRecording(mic);
return;
}
var speaker = event.target.closest("[data-speak]");
if (speaker) {
event.preventDefault();
speak(speaker);
}
});
/* A reply that has just finished streaming carries data-speak-auto, set only
on that one frame. Any swap can bring it in, so this watches them all and
clears the attribute after acting -- a later swap of the same bubble must
not start it again. */
function playArrivals() {
document.querySelectorAll("[data-speak-auto]").forEach(function (button) {
button.removeAttribute("data-speak-auto");
speak(button);
});
}
document.addEventListener("DOMContentLoaded", playArrivals);
if (document.body) {
document.body.addEventListener("htmx:afterSettle", playArrivals);
}
})();
+116
View File
@@ -0,0 +1,116 @@
/*
Service worker.
Served from /sw.js rather than /static/js/sw.js: a worker's scope is the
directory it is served from, so one under /static/ could never control the
pages it is meant to serve. See api/pages.py.
What this is for is installability and an honest offline page -- NOT offline
chat. LLeMbas renders every page on the server, so a cached conversation
would be a snapshot that silently went stale, and a cached one belonging to
whoever was signed in last. The shell is cached; nothing with a user in it is.
The cache is versioned from the query string the registration adds
(/sw.js?v=<app version>), so a release invalidates it with no separate step.
*/
"use strict";
var VERSION = new URL(self.location).searchParams.get("v") || "dev";
var CACHE = "lembas-" + VERSION;
/* The shell: everything needed to draw a page, plus the page shown when there
is no network. Deliberately no HTML but /offline -- see above. */
var SHELL = [
"/offline",
"/static/css/tokens.css",
"/static/css/app.css",
"/static/css/chat.css",
"/static/css/admin.css",
"/static/js/app.js",
"/static/js/ui.js",
"/static/js/audio.js",
"/static/vendor/htmx.min.js",
"/static/vendor/htmx-ext-sse.js",
"/static/vendor/alpine.min.js",
"/static/img/favicon.svg",
"/static/img/logo-mark.svg",
"/static/img/icon-192.png",
"/static/img/icon-512.png",
];
self.addEventListener("install", function (event) {
event.waitUntil(
caches.open(CACHE).then(function (cache) {
// addAll is all-or-nothing: one 404 would leave the worker uninstalled
// and the whole feature silently off, so each entry is added on its own.
return Promise.all(
SHELL.map(function (path) {
return cache.add(new Request(path, { cache: "reload" })).catch(function () {});
})
);
}).then(function () { return self.skipWaiting(); })
);
});
self.addEventListener("activate", function (event) {
event.waitUntil(
caches.keys().then(function (names) {
return Promise.all(
names.map(function (name) {
if (name !== CACHE && name.indexOf("lembas-") === 0) return caches.delete(name);
return null;
})
);
}).then(function () { return self.clients.claim(); })
);
});
/* Paths this worker must never touch. /api/ carries the reply stream, the
unread poll, uploads and attachment downloads; /auth/ and /admin/ carry
credentials and settings. A cached response on any of them is at best stale
and at worst somebody else's. */
function isExcluded(url) {
return url.pathname.indexOf("/api/") === 0 ||
url.pathname.indexOf("/auth/") === 0 ||
url.pathname.indexOf("/admin/") === 0 ||
url.pathname === "/sw.js";
}
self.addEventListener("fetch", function (event) {
var request = event.request;
if (request.method !== "GET") return;
var url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (isExcluded(url)) return;
/* A reply arrives as an endless event stream. Passing one through a worker
is the reliable way to turn a streaming answer into a single delivery at
the end, or into nothing at all -- so it is left entirely alone. */
if ((request.headers.get("accept") || "").indexOf("text/event-stream") !== -1) return;
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(function () {
return caches.match("/offline");
})
);
return;
}
// Static assets: serve from cache, refresh in the background. They are
// versioned by the cache name, so a stale one only lasts until the next
// release.
event.respondWith(
caches.match(request).then(function (hit) {
var live = fetch(request).then(function (response) {
if (response && response.ok) {
var copy = response.clone();
caches.open(CACHE).then(function (cache) { cache.put(request, copy); });
}
return response;
}).catch(function () { return hit; });
return hit || live;
})
);
});
+34 -33
View File
@@ -400,11 +400,17 @@ document.addEventListener("lembas:unread", function (event) {
/* /*
Send becomes Stop while a reply is being written. Send becomes Stop while a reply is being written.
The composer and the streaming bubble are far apart in the document, so the One button in the markup (see chat/_composer.html), retargeted here. The
link between them is made here: whenever the thread changes, look for a composer and the streaming bubble are far apart in the document, so the link
message that is still streaming and point the button at it. A MutationObserver between them is made at runtime: whenever the thread changes, look for a
rather than htmx events, because the bubble is replaced by an SSE swap that message that is still streaming and point the button at it. A
does not always surface as one. MutationObserver rather than htmx events, because the bubble is replaced by
an SSE swap that does not always surface as one.
This used to build a second button and hide it with the `hidden` attribute,
which did nothing at all: `.btn` sets `display: inline-flex`, and that beats
the browser's `[hidden] { display: none }`. app.css now forces the attribute
to win, and there is only one button to get wrong.
*/ */
(function () { (function () {
"use strict"; "use strict";
@@ -418,48 +424,43 @@ document.addEventListener("lembas:unread", function (event) {
} }
function sync() { function sync() {
var form = document.querySelector(".composer__form"); var button = document.querySelector("[data-composer-action]");
if (!form) return; if (!button) return;
var send = form.querySelector('[type="submit"]');
var stop = form.querySelector("[data-composer-stop]");
var active = streamingMessage(); var active = streamingMessage();
if (active) { button.dataset.composerAction = active ? "stop" : "send";
if (send) send.hidden = true; // As a submit button the form sends; as a plain button the click handler
if (!stop) { // below stops. Nothing else distinguishes the two states.
stop = document.createElement("button"); button.type = active ? "button" : "submit";
stop.type = "button"; button.setAttribute("aria-label", active ? "Stop generating" : "Send");
stop.className = "btn btn--icon composer__btn composer__stop"; button.title = active ? "Stop generating" : "";
stop.setAttribute("data-composer-stop", ""); button.disabled = false;
stop.setAttribute("aria-label", "Stop generating"); }
stop.title = "Stop generating";
stop.innerHTML = '<span class="composer__stop-square"></span>'; document.addEventListener("click", function (event) {
stop.addEventListener("click", function () { var button = event.target.closest('[data-composer-action="stop"]');
if (!button) return;
event.preventDefault();
var target = streamingMessage(); var target = streamingMessage();
if (!target) return; if (!target) return;
stop.disabled = true; // Disabled until the next sync, so a second click cannot fire a second
// request at a generation that is already stopping.
button.disabled = true;
fetch( fetch(
"/api/chats/" + target.chatId + "/messages/" + target.messageId + "/stop", "/api/chats/" + target.chatId + "/messages/" + target.messageId + "/stop",
{ method: "POST", credentials: "same-origin" } { method: "POST", credentials: "same-origin" }
).catch(function () { stop.disabled = false; }); ).catch(function () { button.disabled = false; });
}); });
(send ? send.parentNode : form).appendChild(stop);
}
stop.hidden = false;
stop.disabled = false;
} else {
if (send) send.hidden = false;
if (stop) stop.hidden = true;
}
}
function watch() { function watch() {
var thread = document.getElementById("thread"); var thread = document.getElementById("thread");
if (!thread) return; if (thread) {
new MutationObserver(sync).observe(thread, { childList: true, subtree: true }); new MutationObserver(sync).observe(thread, { childList: true, subtree: true });
}
sync(); sync();
} }
document.addEventListener("DOMContentLoaded", watch); document.addEventListener("DOMContentLoaded", watch);
document.body && document.body.addEventListener("htmx:afterSwap", sync); document.body && document.body.addEventListener("htmx:afterSettle", sync);
})(); })();
@@ -0,0 +1,20 @@
{% from "_macros.html" import icon %}
{#
The outcome of one endpoint test, swapped into the card that asked for it.
A tts test also brings back a fresh voice list, because "did it work" and
"what can it say it in" are the same question asked twice otherwise.
#}
<div class="alert alert--{{ 'error' if message_kind == 'error' else 'success' }}"
id="audio-test-{{ side }}">
{{ icon("warning" if message_kind == "error" else "check", "alert__icon") }}
<span>{{ message }}</span>
</div>
{% if side == "tts" %}
<select class="select" id="tts-voice" name="tts_voice" hx-swap-oob="true">
{% with selected = values.tts_voice %}
{% include "partials/_voice_options.html" %}
{% endwith %}
</select>
{% endif %}
@@ -35,6 +35,14 @@
{{ icon("sliders", "icon--sm") }} {{ icon("sliders", "icon--sm") }}
<span class="nav-item__label">Models</span> <span class="nav-item__label">Models</span>
</a> </a>
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
{{ icon("speaker", "icon--sm") }}
<span class="nav-item__label">Audio</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'search' }}" href="/admin/search">
{{ icon("globe", "icon--sm") }}
<span class="nav-item__label">Web search</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users"> <a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users">
{{ icon("user", "icon--sm") }} {{ icon("user", "icon--sm") }}
<span class="nav-item__label">Users</span> <span class="nav-item__label">Users</span>
@@ -0,0 +1,31 @@
{% from "_macros.html" import icon %}
{#
The outcome of a test search.
The results are third-party text and are shown here exactly as a chat would
show them: escaped, and with the URL as text rather than as a link. Nobody
needs to click through from a connectivity test, so this does not offer the
chance.
#}
<div id="search-test">
<div class="alert alert--{{ 'error' if message_kind == 'error' else 'success' }}">
{{ icon("warning" if message_kind == "error" else "check", "alert__icon") }}
<span>{{ message }}</span>
</div>
{% if results %}
<ul class="model-list">
{% for result in results %}
<li class="model-list__item">
<div style="min-width: 0">
<strong>{{ result.title }}</strong>
<div class="text-xs faint mono">{{ result.url }}</div>
{% if result.snippet %}
<div class="text-xs faint">{{ result.snippet }}</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
+184
View File
@@ -0,0 +1,184 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "audio" %}
{% block title %}Audio - LLeMbas{% endblock %}
{% block heading %}Audio{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Two endpoints speaking the OpenAI audio API: one that turns speech into text
so a message can be dictated, one that reads a reply out. They are configured
separately because they usually are separate servers — whisper.cpp and Kokoro,
say, or Speaches for both.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Audio settings saved.</span></div>
{% endif %}
<form method="post" action="/admin/audio">
<section class="card">
<h2 class="card__title">
Dictation
{% if values.stt_enabled %}<span class="badge badge--success">on</span>
{% else %}<span class="badge">off</span>{% endif %}
</h2>
<p class="card__lede">
Adds a microphone to the composer. Recordings are sent to this endpoint
and never written to disk.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="stt_enabled" value="true"
{{ 'checked' if values.stt_enabled }}>
<span>Allow messages to be dictated</span>
</label>
</div>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="stt-base-url">Base URL</label>
<input class="input" id="stt-base-url" name="stt_base_url" type="url"
value="{{ values.stt_base_url }}" placeholder="http://127.0.0.1:8081">
<p class="field__hint">
Where <code>/v1/audio/transcriptions</code> lives — whisper.cpp's
<code>whisper-server</code>, Speaches, or anything else speaking it.
With or without <code>/v1</code>; either is understood.
</p>
</div>
<div class="field">
<label class="field__label" for="stt-api-key">API key</label>
<input class="input" id="stt-api-key" name="stt_api_key" type="password"
value="{{ unchanged if masked.stt else '' }}"
placeholder="{{ masked.stt or 'None needed for a local server' }}"
autocomplete="off">
<p class="field__hint">Encrypted at rest. Clear the field to remove it.</p>
</div>
<div class="field">
<label class="field__label" for="stt-model">Model</label>
<input class="input" id="stt-model" name="stt_model"
value="{{ values.stt_model }}" placeholder="whisper-1">
<p class="field__hint">
Sent even to servers that only host one; a router in front of several
needs it.
</p>
</div>
<div class="field">
<label class="field__label" for="stt-language">Language</label>
<input class="input" id="stt-language" name="stt_language" maxlength="16"
value="{{ values.stt_language }}" placeholder="detect">
<p class="field__hint">
An ISO code such as <code>en</code> or <code>sk</code>. Leave empty to
let the server detect it, which is what whisper does best.
</p>
</div>
</div>
<div class="btn-row">
<button class="btn" type="button" hx-post="/admin/audio/test/stt"
hx-target="#audio-test-stt" hx-swap="outerHTML">
{{ icon("refresh", "icon--sm") }} Test dictation
</button>
</div>
<div id="audio-test-stt"></div>
</section>
<section class="card">
<h2 class="card__title">
Read aloud
{% if values.tts_enabled %}<span class="badge badge--success">on</span>
{% else %}<span class="badge">off</span>{% endif %}
</h2>
<p class="card__lede">
Adds a speaker button to every reply. Each reader can pick their own voice
in their settings; what is chosen here is the default.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="tts_enabled" value="true"
{{ 'checked' if values.tts_enabled }}>
<span>Allow replies to be read out</span>
</label>
</div>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="tts-base-url">Base URL</label>
<input class="input" id="tts-base-url" name="tts_base_url" type="url"
value="{{ values.tts_base_url }}" placeholder="http://127.0.0.1:8880">
<p class="field__hint">
Where <code>/v1/audio/speech</code> lives — Kokoro-FastAPI, OpenAI, or
anything else speaking it.
</p>
</div>
<div class="field">
<label class="field__label" for="tts-api-key">API key</label>
<input class="input" id="tts-api-key" name="tts_api_key" type="password"
value="{{ unchanged if masked.tts else '' }}"
placeholder="{{ masked.tts or 'None needed for a local server' }}"
autocomplete="off">
</div>
<div class="field">
<label class="field__label" for="tts-model">Model</label>
<input class="input" id="tts-model" name="tts_model"
value="{{ values.tts_model }}" placeholder="tts-1">
</div>
<div class="field">
<label class="field__label" for="tts-voice">Default voice</label>
<select class="select" id="tts-voice" name="tts_voice">
{% with selected = values.tts_voice %}
{% include "partials/_voice_options.html" %}
{% endwith %}
</select>
<p class="field__hint">
{% if voice_error %}
Could not read the voice list: {{ voice_error }}
{% else %}
Read from the endpoint. Save and test to refresh it.
{% endif %}
</p>
</div>
<div class="field">
<label class="field__label" for="tts-format">Format</label>
<select class="select" id="tts-format" name="tts_format">
{% for format in formats %}
<option value="{{ format }}" {{ 'selected' if format == values.tts_format }}>
{{ format }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="tts-speed">Speed</label>
<input class="input" id="tts-speed" name="tts_speed" type="number"
min="0.25" max="4" step="0.05" value="{{ values.tts_speed }}">
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="tts_autoplay" value="true"
{{ 'checked' if values.tts_autoplay }}>
<span>Read new replies aloud as they finish, by default</span>
</label>
<p class="field__hint">
Only the starting value for each account — anyone can turn it off in
their own settings, and nobody is made to listen.
</p>
</div>
<div class="btn-row">
<button class="btn" type="button" hx-post="/admin/audio/test/tts"
hx-target="#audio-test-tts" hx-swap="outerHTML">
{{ icon("refresh", "icon--sm") }} Test speech
</button>
</div>
<div id="audio-test-tts"></div>
</section>
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
</form>
{% endblock %}
+150
View File
@@ -0,0 +1,150 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "search" %}
{% block title %}Web search - LLeMbas{% endblock %}
{% block heading %}Web search{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Lets a model look things up while it answers. It is offered as a tool the
model chooses to call, so nothing changes for a question that does not need
it — and it is only offered to models marked as supporting tools, because
sending a tool list to one that does not fails the whole request.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Search settings saved.</span></div>
{% endif %}
<form method="post" action="/admin/search">
<section class="card">
<h2 class="card__title">
Web search
{% if values.enabled %}<span class="badge badge--success">on</span>
{% else %}<span class="badge">off</span>{% endif %}
</h2>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if values.enabled }}>
<span>Offer web search to models that support tools</span>
</label>
<p class="field__hint">
Who may use it is a permission — <code>tools.web_search</code> under
Groups &amp; permissions.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Provider</h2>
<div class="field">
{% for provider in providers %}
<label class="checkbox" style="align-items: flex-start">
<input type="radio" name="provider" value="{{ provider.key }}"
{{ 'checked' if values.provider == provider.key }}>
<span>
<strong>{{ provider.label }}</strong>
{% if not provider.needs_setup %}<span class="badge">no setup</span>{% endif %}
<div class="text-xs faint">{{ provider.description }}</div>
{% if problems[provider.key] %}
<div class="text-xs" style="color: var(--danger)">{{ problems[provider.key] }}</div>
{% endif %}
</span>
</label>
{% endfor %}
</div>
<div class="grid grid--3">
<div class="field">
<label class="field__label" for="max-results">Results per search</label>
<input class="input" id="max-results" name="max_results" type="number"
min="1" max="20" value="{{ values.max_results }}">
<p class="field__hint">A ceiling — a model asking for more gets this.</p>
</div>
<div class="field">
<label class="field__label" for="safesearch">Safe search</label>
<select class="select" id="safesearch" name="safesearch">
{% for option in safesearch_options %}
<option value="{{ option }}" {{ 'selected' if option == values.safesearch }}>
{{ option }}
</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="timeout">Timeout (seconds)</label>
<input class="input" id="timeout" name="timeout" type="number"
min="5" max="120" step="1" value="{{ values.timeout }}">
</div>
</div>
<div class="field">
<label class="field__label" for="region">DuckDuckGo region</label>
<input class="input" id="region" name="region" maxlength="16"
value="{{ values.region }}" placeholder="wt-wt">
<p class="field__hint">
<code>wt-wt</code> is no region at all. <code>uk-en</code>,
<code>de-de</code> and so on bias results to a country.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">SearXNG</h2>
<p class="card__lede">
Only used when SearXNG is the chosen provider. Your own instance, so no
third party sees the queries.
</p>
<div class="field">
<label class="field__label" for="searxng-base-url">Instance URL</label>
<input class="input" id="searxng-base-url" name="searxng_base_url" type="url"
value="{{ values.searxng_base_url }}" placeholder="http://127.0.0.1:8888">
<p class="field__hint">
A stock SearXNG refuses JSON. Add <code>- json</code> under
<code>search.formats</code> in its <code>settings.yml</code> and restart
it, or every search will fail with that message.
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Firecrawl</h2>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="firecrawl-base-url">API URL</label>
<input class="input" id="firecrawl-base-url" name="firecrawl_base_url" type="url"
value="{{ values.firecrawl_base_url }}"
placeholder="https://api.firecrawl.dev">
<p class="field__hint">Change only for a self-hosted Firecrawl.</p>
</div>
<div class="field">
<label class="field__label" for="firecrawl-api-key">API key</label>
<input class="input" id="firecrawl-api-key" name="firecrawl_api_key" type="password"
value="{{ unchanged if masked else '' }}"
placeholder="{{ masked or 'fc-...' }}" autocomplete="off">
<p class="field__hint">Encrypted at rest. Clear the field to remove it.</p>
</div>
</div>
</section>
<div class="btn-row"><button class="btn btn--primary" type="submit">Save settings</button></div>
</form>
<section class="card">
<h2 class="card__title">Try it</h2>
<p class="card__lede">
Runs a real search against the <em>saved</em> settings, which is what a chat
would do. Save first if you have just changed something.
</p>
<div class="row" style="gap: var(--sp-2)">
<input class="input" id="test-query" name="query" placeholder="mallorn tree"
style="flex: 1">
<button class="btn" type="button" hx-post="/admin/search/test"
hx-include="#test-query" hx-target="#search-test" hx-swap="outerHTML">
{{ icon("search", "icon--sm") }} Search
</button>
</div>
<div id="search-test"></div>
</section>
{% endblock %}
+32
View File
@@ -8,6 +8,21 @@
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml"> <link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml">
{#
Installing as an app. The manifest is a route, not a file, because it carries
the instance name; the icons are PNG because a launcher will not take an SVG.
theme-color is rewritten by app.js when the theme changes -- the value here is
only what the browser paints with before the stylesheet has resolved.
#}
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#101317">
<link rel="apple-touch-icon" href="{{ url_for('static', path='img/apple-touch-icon-180.png') }}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="LLeMbas">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}"> <link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}"> <link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
{% block head %}{% endblock %} {% block head %}{% endblock %}
@@ -39,6 +54,23 @@
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script> <script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
<script src="{{ url_for('static', path='js/app.js') }}" defer></script> <script src="{{ url_for('static', path='js/app.js') }}" defer></script>
<script src="{{ url_for('static', path='js/ui.js') }}" defer></script> <script src="{{ url_for('static', path='js/ui.js') }}" defer></script>
<script src="{{ url_for('static', path='js/audio.js') }}" defer></script>
{#
The version in the query string is what versions the worker's cache, so a
release invalidates it without anyone remembering to bump a constant.
serviceWorker is absent over plain http, which is why a LAN install without
TLS silently offers no install prompt -- that is the browser's rule, not ours.
#}
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/sw.js?v={{ version }}").catch(function () {
/* An install failure must never break the page it was loaded from. */
});
});
}
</script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+28 -2
View File
@@ -59,9 +59,35 @@
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}" placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
aria-label="Message" {{ 'autofocus' if not chat }}></textarea> aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
{% if can_dictate %}
{# Recording is started and stopped by the same button; audio.js swaps
data-mic-state and the icon with it. #}
<button class="btn btn--icon composer__btn composer__mic" type="button"
data-mic data-mic-state="idle"
aria-label="Dictate a message" title="Dictate a message">
<span class="composer__icon composer__icon--mic">{{ icon("mic") }}</span>
<span class="composer__icon composer__icon--recording" aria-hidden="true">
{{ icon("stop-circle") }}
</span>
</button>
{% endif %}
{#
One button, two jobs. While a reply is being written it becomes Stop,
because that is where the hand already is and a second button sitting
permanently beside Send is clutter that is wrong most of the time.
ui.js flips data-composer-action, and the type with it: as `submit`
the form's own handler sends, as `button` the click handler stops.
Both icons are rendered here and chosen in CSS, so the swap costs no
layout and cannot flash an empty button.
#}
<button class="btn btn--primary btn--icon composer__btn" type="submit" <button class="btn btn--primary btn--icon composer__btn" type="submit"
aria-label="Send"> data-composer-action="send" aria-label="Send">
{{ icon("send") }} <span class="composer__icon composer__icon--send">{{ icon("send") }}</span>
<span class="composer__icon composer__icon--stop" aria-hidden="true">
<span class="composer__stop-square"></span>
</span>
</button> </button>
</div> </div>
</form> </form>
+42 -5
View File
@@ -101,6 +101,12 @@
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div> <div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
</details> </details>
{# Tool activity as it happens. Empty until the model asks for something,
and the whole block is replaced each time rather than appended to --
a follower attaching late has no earlier fragments to build on. #}
<div class="tool-activity-list" id="tools-{{ message.id }}"
sse-swap="tools" hx-swap="innerHTML"></div>
{# The server re-renders the answer as Markdown a few times a second and {# The server re-renders the answer as Markdown a few times a second and
replaces this whole block, so formatting appears as the model writes replaces this whole block, so formatting appears as the model writes
rather than snapping into place at the end. #} rather than snapping into place at the end. #}
@@ -111,7 +117,11 @@
<div class="msg__waiting"> <div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span> <span class="dots"><i></i><i></i><i></i></span>
</div> </div>
{% elif message.reasoning and not message.error %} {% else %}
{# Finished. Same order as the live view above -- thinking, then what it
looked up, then the answer -- so a reply does not rearrange itself the
moment it stops streaming. #}
{% if message.reasoning and not message.error %}
{# Collapsed once finished: the answer is what the reader came for, and {# Collapsed once finished: the answer is what the reader came for, and
the thinking is there if they want to audit it. #} the thinking is there if they want to audit it. #}
<details class="reasoning" id="reasoning-{{ message.id }}"> <details class="reasoning" id="reasoning-{{ message.id }}">
@@ -128,9 +138,19 @@
</summary> </summary>
<div class="reasoning__body">{{ message.reasoning }}</div> <div class="reasoning__body">{{ message.reasoning }}</div>
</details> </details>
<div class="msg__body">{{ body_html|safe }}</div> {% endif %}
{% elif message.error %} {% if message.tool_calls_json %}
{# Kept with the message rather than discarded with the stream, so the
sources behind an answer are still there tomorrow. #}
<div class="tool-activity-list">
{% with tool_events = message.tool_calls_json, live = false %}
{% include "chat/_tool_activity.html" %}
{% endwith %}
</div>
{% endif %}
{% if message.error %}
<div class="alert alert--error msg__error" role="alert"> <div class="alert alert--error msg__error" role="alert">
{{ icon("warning", "alert__icon") }} {{ icon("warning", "alert__icon") }}
<div> <div>
@@ -138,11 +158,12 @@
<div class="text-sm" style="margin-top: var(--sp-1)">{{ message.error }}</div> <div class="text-sm" style="margin-top: var(--sp-1)">{{ message.error }}</div>
</div> </div>
</div> </div>
{% endif %}
{% if message.role == "assistant" %}
{% if message.content %} {% if message.content %}
<div class="msg__body">{{ body_html|safe }}</div> <div class="msg__body">{{ body_html|safe }}</div>
{% endif %} {% endif %}
{% elif message.role == "assistant" %}
<div class="msg__body">{{ body_html|safe }}</div>
{% if message.stopped %} {% if message.stopped %}
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p> <p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
{% endif %} {% endif %}
@@ -151,6 +172,7 @@
{% endif %} {% endif %}
{# An attachment-only turn has no text; rendering the bubble anyway would {# An attachment-only turn has no text; rendering the bubble anyway would
leave an empty box under the file. #} leave an empty box under the file. #}
{% endif %}
{% if not streaming %} {% if not streaming %}
<footer class="msg__actions"> <footer class="msg__actions">
@@ -173,6 +195,21 @@
aria-label="Regenerate reply"> aria-label="Regenerate reply">
{{ icon("refresh", "icon--sm") }} {{ icon("refresh", "icon--sm") }}
</button> </button>
{% if can_listen | default(false) and message.content and not message.error %}
{# Speech is synthesised on demand rather than stored: the voice can
change under the reader between plays, and a reply can be regenerated
at the same address. #}
{# data-speak-auto is set only on the frame that ends a live stream, never
on a page load: reopening a chat must not start reading its last reply
out loud again. #}
<button class="btn btn--icon btn--sm" type="button"
data-speak="/api/audio/speech/{{ chat.id }}/{{ message.id }}"
{% if audio_autoplay | default(false) and just_finished | default(false) %}data-speak-auto{% endif %}
aria-label="Read this reply aloud">
<span class="speak__icon speak__icon--play">{{ icon("speaker", "icon--sm") }}</span>
<span class="speak__icon speak__icon--stop">{{ icon("stop-circle", "icon--sm") }}</span>
</button>
{% endif %}
{% endif %} {% endif %}
</footer> </footer>
{# The raw source, so the copy button yields Markdown rather than rendered {# The raw source, so the copy button yields Markdown rather than rendered
@@ -0,0 +1,61 @@
{% from "_macros.html" import icon %}
{#
What the model did before answering.
Rendered both live (streamed as a whole block, like the reasoning and the
answer) and from the stored message afterwards, so the sources behind an
answer stay in the transcript rather than vanishing when the stream ends.
EVERYTHING in here comes from a search provider and is untrusted, exactly as
much as model output is. Jinja autoescaping covers the text; the URL is
checked separately, because `is_linkable` is the only thing standing between
a result carrying a javascript: URL and an anchor pointing at it.
#}
{% for event in tool_events %}
<details class="tool-activity {{ 'tool-activity--error' if event.status == 'error' }}">
<summary class="tool-activity__summary">
{{ icon("globe", "icon--sm tool-activity__icon") }}
<span class="tool-activity__label">
{% if event.status == "error" %}
Web search failed
{% elif event.query %}
Searched the web for “{{ event.query }}”
{% else %}
Searched the web
{% endif %}
{% if event.results %}
<span class="tool-activity__count">
· {{ event.results | length }} result{{ '' if event.results | length == 1 else 's' }}
</span>
{% endif %}
</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
<div class="tool-activity__body">
{% if event.error %}
<p class="tool-activity__error">{{ event.error }}</p>
{% elif not event.results %}
<p class="tool-activity__error">Nothing was found.</p>
{% endif %}
{% for result in event.results %}
<div class="tool-result">
{% set scheme = result.url.split(":")[0] | lower %}
{% if scheme in ("http", "https") %}
<a class="tool-result__title" href="{{ result.url }}"
target="_blank" rel="noopener noreferrer nofollow">{{ result.title }}</a>
{% else %}
{# Not a link. A search result is third-party text and its URL is not
trusted to be safe to click. #}
<span class="tool-result__title">{{ result.title }}</span>
{% endif %}
<span class="tool-result__host">{{ result.host }}</span>
{% if result.snippet %}
<p class="tool-result__snippet">{{ result.snippet }}</p>
{% endif %}
</div>
{% endfor %}
</div>
</details>
{% endfor %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% from "_macros.html" import mark %}
{#
Shown by the service worker when a navigation cannot reach the server.
Cached at install time, so it has to stand entirely on its own: no user, no
chats, nothing that was rendered from the database. One of the few places
flavour belongs -- see the flavour rule in CLAUDE.md.
#}
{% block title %}Offline - LLeMbas{% endblock %}
{% block body %}
<main class="auth">
<div class="auth__card" style="text-align: center">
{{ mark(cls="empty__mark", uid="offline") }}
<h1 class="auth__title" style="margin-top: var(--sp-4)">No road from here</h1>
<p class="empty__text" style="margin: var(--sp-3) auto var(--sp-5)">
The Road goes ever on and on — but not without a connection.
</p>
<p class="text-sm muted" style="margin-bottom: var(--sp-5)">
LLeMbas answers from your server, so there is nothing to read until it can
be reached again.
</p>
<button class="btn btn--primary" type="button" onclick="window.location.reload()">
Try again
</button>
</div>
</main>
{% endblock %}
@@ -0,0 +1,25 @@
{#
The contents of a voice <select>.
A fragment rather than markup inside each settings page because the admin
form, a reader's own settings and the endpoint test all need the same list --
which is fetched from the speech endpoint rather than stored, since
reconfiguring the server changes what is on offer.
`instance_voice` is only passed by the user-facing page, where the first
option means "whatever the administrator chose"; on the admin page there is
no such fallback and the empty option means "let the endpoint decide".
#}
{% if instance_voice is defined and instance_voice %}
<option value="">Instance default — {{ instance_voice }}</option>
{% else %}
<option value="">Endpoint default</option>
{% endif %}
{% for voice in voices %}
<option value="{{ voice }}" {{ 'selected' if voice == selected }}>{{ voice }}</option>
{% endfor %}
{% if selected and selected not in voices %}
{# The stored choice is no longer offered -- kept so saving the form does not
silently reset it to the default. #}
<option value="{{ selected }}" selected>{{ selected }} (not currently offered)</option>
{% endif %}
@@ -136,6 +136,27 @@
<circle cx="8" cy="12" r="4"/> <circle cx="8" cy="12" r="4"/>
<path d="M12 12h8M17.5 12v3M20 12v2.5"/> <path d="M12 12h8M17.5 12v3M20 12v2.5"/>
</symbol> </symbol>
<symbol id="i-mic" viewBox="0 0 24 24">
<rect x="9" y="3" width="6" height="10.5" rx="3"/>
<path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21M9 21h6"/>
</symbol>
<symbol id="i-speaker" viewBox="0 0 24 24">
<path d="M11 5 6.5 9H3.5v6h3L11 19Z"/>
<path d="M14.8 9.2a4 4 0 0 1 0 5.6M17.6 6.4a8 8 0 0 1 0 11.2"/>
</symbol>
<symbol id="i-stop-circle" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.5"/>
<rect x="9.2" y="9.2" width="5.6" height="5.6" rx="1"/>
</symbol>
<symbol id="i-globe" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.5"/>
<path d="M3.5 12h17M12 3.5c2.2 2.4 3.3 5.3 3.3 8.5S14.2 18.1 12 20.5c-2.2-2.4-3.3-5.3-3.3-8.5S9.8 5.9 12 3.5Z"/>
</symbol>
<symbol id="i-link" viewBox="0 0 24 24">
<path d="M10.5 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1.5 1.5"/>
<path d="M13.5 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5L12 17"/>
</symbol>
<symbol id="i-leaf" viewBox="0 0 64 64"> <symbol id="i-leaf" viewBox="0 0 64 64">
<path d="M20.5 45.5C13.8 31.7 23.8 20.9 45.5 18.5 49.8 35 39.8 45.8 20.5 45.5Z"/> <path d="M20.5 45.5C13.8 31.7 23.8 20.9 45.5 18.5 49.8 35 39.8 45.8 20.5 45.5Z"/>
<path d="M20.5 45.5C28 38 36 29 45.5 18.5"/> <path d="M20.5 45.5C28 38 36 29 45.5 18.5"/>
+107
View File
@@ -35,6 +35,11 @@
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-appearance"> <input class="visually-hidden" type="radio" name="settings-tab" id="tab-appearance">
<label class="tabs__tab" for="tab-appearance">{{ icon("sun", "icon--sm") }} Appearance</label> <label class="tabs__tab" for="tab-appearance">{{ icon("sun", "icon--sm") }} Appearance</label>
{% if audio.stt_enabled or audio.tts_enabled %}
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-audio">
<label class="tabs__tab" for="tab-audio">{{ icon("speaker", "icon--sm") }} Audio</label>
{% endif %}
<input class="visually-hidden" type="radio" name="settings-tab" id="tab-security"> <input class="visually-hidden" type="radio" name="settings-tab" id="tab-security">
<label class="tabs__tab" for="tab-security">{{ icon("key", "icon--sm") }} Security</label> <label class="tabs__tab" for="tab-security">{{ icon("key", "icon--sm") }} Security</label>
</div> </div>
@@ -166,8 +171,110 @@
</button> </button>
</div> </div>
</div> </div>
<div class="card">
<h2 class="card__title">Install as an app</h2>
<p class="card__lede">
Runs in its own window, without browser chrome. Everything still
comes from your server — there is no offline mode beyond a page
saying so.
</p>
{# Revealed by app.js only when the browser actually offers an
install. Firefox and desktop Safari never do, and a button that
does nothing is worse than no button. #}
<div class="btn-row" data-install-app hidden>
<button class="btn btn--primary" type="button"
onclick="window.lembas.promptInstall()">
{{ icon("plus", "icon--sm") }} Install
</button>
</div>
<p class="field__hint">
Only offered over HTTPS or on localhost, and not at all in some
browsers. On iOS, use Share → Add to Home Screen.
</p>
</div>
</section> </section>
{# --- Audio --- #}
{% if audio.stt_enabled or audio.tts_enabled %}
<section class="tabs__panel" data-tab="tab-audio">
<form method="post" action="/api/preferences/audio">
{% if audio.tts_enabled %}
<div class="card">
<h2 class="card__title">Reading replies aloud</h2>
<p class="card__lede">
Overrides what the administrator chose, for you only.
</p>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="audio-voice">Voice</label>
{# Options are fetched from the speech endpoint rather than
stored, so the list follows the server. #}
<select class="select" id="audio-voice" name="voice">
{% with selected = user_audio.get('voice', ''),
instance_voice = audio.tts_voice %}
{% include "partials/_voice_options.html" %}
{% endwith %}
</select>
<p class="field__hint">
{% if voice_error %}
The voice list could not be read: {{ voice_error }}
{% else %}
{{ voices | length }} available.
{% endif %}
</p>
</div>
<div class="field">
<label class="field__label" for="audio-speed">Speed</label>
<input class="input" id="audio-speed" name="speed" type="number"
min="0.25" max="4" step="0.05"
value="{{ user_audio.get('speed', '') }}"
placeholder="{{ audio.tts_speed }}">
<p class="field__hint">Leave empty to use the instance default.</p>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="autoplay" value="true"
{{ 'checked' if user_audio.get('autoplay', audio.tts_autoplay) }}>
<span>Read each reply aloud as it finishes</span>
</label>
<p class="field__hint">
Only replies that arrive while you are looking at the chat.
</p>
</div>
</div>
{% endif %}
{% if audio.stt_enabled %}
<div class="card">
<h2 class="card__title">Dictation</h2>
<div class="field">
<label class="field__label" for="audio-language">Language</label>
<input class="input" id="audio-language" name="language" maxlength="16"
value="{{ user_audio.get('language', '') }}"
placeholder="{{ audio.stt_language or 'detect' }}">
<p class="field__hint">
An ISO code such as <code>en</code> or <code>sk</code>. Empty
lets the server work it out, which is usually best.
</p>
</div>
<p class="field__hint">
The microphone needs HTTPS or localhost — browsers do not grant
it over plain HTTP.
</p>
</div>
{% endif %}
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save audio preferences</button>
</div>
</form>
</section>
{% endif %}
{# --- Security --- #} {# --- Security --- #}
<section class="tabs__panel" data-tab="tab-security"> <section class="tabs__panel" data-tab="tab-security">
<div class="card"> <div class="card">
+23
View File
@@ -120,6 +120,29 @@ def make_chat(db: Session):
return _create return _create
@pytest.fixture
def mock_http():
"""Answer every outgoing httpx request with a handler of the test's choosing.
The services build their own AsyncClient because each needs its own timeout,
so there is no client to inject; patching the class is what reaches them.
Returns a callable that installs a handler and is undone on teardown.
"""
import httpx
original = httpx.AsyncClient
def install(handler):
class Patched(original):
def __init__(self, **kwargs):
super().__init__(transport=httpx.MockTransport(handler), **kwargs)
httpx.AsyncClient = Patched
yield install
httpx.AsyncClient = original
@pytest.fixture @pytest.fixture
def user_id(db: Session, registered: dict[str, str]) -> str: def user_id(db: Session, registered: dict[str, str]) -> str:
"""The registered user's id. """The registered user's id.
+363
View File
@@ -0,0 +1,363 @@
"""Speech to text and text to speech."""
from __future__ import annotations
import httpx
import pytest
from fastapi.testclient import TestClient
from lembas.services import audio as audio_service
from lembas.services import settings_store
from lembas.services.llm.openai_client import Endpoint, LLMError
from lembas.services.markdown import speakable_text
# --- What gets read out ------------------------------------------------------
def test_speakable_text_drops_code_blocks():
"""A speech model reads every bracket and underscore of a code fence aloud,
which is unlistenable and longer than the prose it was buried in."""
spoken = speakable_text("Try this:\n\n```py\nprint('hello world')\n```\n\nThat is all.")
assert "print" not in spoken
assert "Try this:" in spoken
assert "That is all." in spoken
def test_speakable_text_drops_the_code_block_language_label():
"""The label lives in its own div inside the block, so a naive non-greedy
match for the wrapper's closing tag leaves both it and the code behind."""
assert "python" not in speakable_text("```python\nx = 1\n```")
def test_speakable_text_keeps_inline_code():
assert "os.path" in speakable_text("Use `os.path` for that.")
def test_speakable_text_reduces_links_to_their_words():
spoken = speakable_text("Read [the docs](https://example.com/very/long) first.")
assert "the docs" in spoken
assert "https" not in spoken
def test_speakable_text_unescapes_entities():
"""The renderer escapes & and >; reading "amp semicolon" out is nonsense."""
assert speakable_text("Salt & pepper, 5 > 3") == "Salt & pepper, 5 > 3"
def test_speakable_text_of_nothing():
assert speakable_text("") == ""
assert speakable_text("```\nonly code\n```") == ""
def test_speakable_text_is_capped():
"""Endpoints reject or truncate very long input, and a reply this long is
not one anybody is listening to in full."""
from lembas.services.markdown import MAX_SPEAKABLE
assert len(speakable_text("word " * 10000)) == MAX_SPEAKABLE
# --- Voice discovery ---------------------------------------------------------
@pytest.mark.parametrize(
"payload",
[
{"voices": ["af_heart", "am_adam"]},
{"voices": [{"id": "af_heart"}, {"id": "am_adam"}]},
{"voices": [{"name": "af_heart"}, {"name": "am_adam"}]},
["af_heart", "am_adam"],
],
)
async def test_voices_reads_every_shape_a_server_might_use(mock_http, payload):
"""Kokoro answers with objects, older builds with strings, some with a bare
list. All three are the same information."""
mock_http(lambda _r: httpx.Response(200, json=payload))
audio_service.forget_voices()
found = await audio_service.voices(Endpoint("http://tts", "", {}))
assert found == ["af_heart", "am_adam"]
async def test_voices_falls_back_on_404(mock_http):
"""api.openai.com has no voices endpoint. That is not an error -- its voices
are a fixed list everybody already knows."""
mock_http(lambda _r: httpx.Response(404))
audio_service.forget_voices()
assert await audio_service.voices(Endpoint("http://tts", "", {})) == list(
audio_service.OPENAI_VOICES
)
async def test_voices_are_cached(mock_http):
calls = []
def handler(_request):
calls.append(1)
return httpx.Response(200, json={"voices": ["af_heart"]})
mock_http(handler)
audio_service.forget_voices()
endpoint = Endpoint("http://tts", "", {})
await audio_service.voices(endpoint)
await audio_service.voices(endpoint)
assert len(calls) == 1
await audio_service.voices(endpoint, refresh=True)
assert len(calls) == 2
# --- Transcription -----------------------------------------------------------
async def test_transcribe_reads_the_json_shape(mock_http):
mock_http(lambda _r: httpx.Response(200, json={"text": " speak friend "}))
text = await audio_service.transcribe(
Endpoint("http://stt", "", {}),
data=b"x",
filename="a.wav",
content_type="audio/wav",
)
assert text == "speak friend"
async def test_transcribe_tolerates_a_plain_text_body(mock_http):
"""Some servers answer in text no matter what response_format was asked
for."""
mock_http(lambda _r: httpx.Response(200, text="speak friend"))
text = await audio_service.transcribe(
Endpoint("http://stt", "", {}), data=b"x", filename="a.wav", content_type="audio/wav"
)
assert text == "speak friend"
async def test_transcribe_omits_an_empty_language(mock_http):
"""An empty language must be left out entirely -- sending "" makes some
servers fail rather than detecting it."""
seen = {}
def handler(request):
seen["body"] = request.content
return httpx.Response(200, json={"text": "ok"})
mock_http(handler)
await audio_service.transcribe(
Endpoint("http://stt", "", {}),
data=b"x",
filename="a.wav",
content_type="audio/wav",
language="",
)
assert b'name="language"' not in seen["body"]
async def test_transcribe_reports_a_rejected_key_readably(mock_http):
mock_http(lambda _r: httpx.Response(401, json={"error": {"message": "Bad key."}}))
with pytest.raises(LLMError) as caught:
await audio_service.transcribe(
Endpoint("http://stt", "k", {}),
data=b"x",
filename="a.wav",
content_type="audio/wav",
)
assert "rejected" in caught.value.message
# --- Endpoint construction ---------------------------------------------------
def test_endpoint_for_refuses_an_unconfigured_side():
with pytest.raises(LLMError):
audio_service.endpoint_for({"tts_base_url": ""}, "tts")
def test_endpoint_for_decrypts_the_stored_key():
from lembas.services.crypto import encrypt
endpoint = audio_service.endpoint_for(
{"tts_base_url": "http://tts/", "tts_api_key_encrypted": encrypt("secret")}, "tts"
)
assert endpoint.api_key == "secret"
assert endpoint.base_url == "http://tts"
# --- Settings ----------------------------------------------------------------
def test_audio_settings_round_trip(db):
settings_store.update(
db, {"tts_enabled": True, "tts_voice": "af_heart"}, key=settings_store.AUDIO
)
values = settings_store.audio(db)
assert values["tts_enabled"] is True
assert values["tts_voice"] == "af_heart"
# Untouched keys still come back from the defaults.
assert values["tts_format"] == "mp3"
def test_audio_and_general_settings_do_not_collide(db):
settings_store.update(db, {"instance_name": "Rivendell"})
settings_store.update(db, {"tts_enabled": True}, key=settings_store.AUDIO)
assert settings_store.get(db, "instance_name") == "Rivendell"
assert "instance_name" not in settings_store.audio(db)
def test_a_resubmitted_mask_keeps_the_stored_key(client: TestClient, db, registered):
client.post(
"/admin/audio",
data={"tts_enabled": "true", "tts_base_url": "http://tts", "tts_api_key": "secret"},
follow_redirects=False,
)
stored = settings_store.audio(db)["tts_api_key_encrypted"]
assert stored
# Saving again with the mask in the field must not wipe the credential.
from lembas.services.crypto import UNCHANGED_SENTINEL
client.post(
"/admin/audio",
data={
"tts_enabled": "true",
"tts_base_url": "http://tts",
"tts_api_key": UNCHANGED_SENTINEL,
},
follow_redirects=False,
)
assert settings_store.audio(db)["tts_api_key_encrypted"] == stored
def test_an_emptied_key_field_clears_it(client: TestClient, db, registered):
client.post(
"/admin/audio",
data={"tts_base_url": "http://tts", "tts_api_key": "secret"},
follow_redirects=False,
)
client.post(
"/admin/audio",
data={"tts_base_url": "http://tts", "tts_api_key": ""},
follow_redirects=False,
)
assert settings_store.audio(db)["tts_api_key_encrypted"] == ""
# --- The API -----------------------------------------------------------------
def test_transcribe_is_absent_until_it_is_enabled(client: TestClient, registered):
response = client.post(
"/api/audio/transcribe", files={"file": ("a.wav", b"x", "audio/wav")}
)
assert response.status_code == 404
def test_transcribe_rejects_an_empty_recording(client: TestClient, db, registered):
client.post(
"/admin/audio",
data={"stt_enabled": "true", "stt_base_url": "http://stt"},
follow_redirects=False,
)
response = client.post(
"/api/audio/transcribe", files={"file": ("a.wav", b"", "audio/wav")}
)
assert response.status_code == 400
def test_transcribe_rejects_an_oversized_recording(client: TestClient, db, registered):
from lembas.api.audio import MAX_AUDIO_BYTES
client.post(
"/admin/audio",
data={"stt_enabled": "true", "stt_base_url": "http://stt"},
follow_redirects=False,
)
response = client.post(
"/api/audio/transcribe",
files={"file": ("a.wav", b"x" * (MAX_AUDIO_BYTES + 10), "audio/wav")},
)
# Refused without the body ever reaching the transcription endpoint.
assert response.status_code == 413
def test_speech_404s_on_someone_elses_chat(client: TestClient, db, registered, make_chat):
client.post(
"/admin/audio",
data={"tts_enabled": "true", "tts_base_url": "http://tts"},
follow_redirects=False,
)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-and-more"},
follow_redirects=False,
)
# Now signed in as Sam, asking for Frodo's chat.
chat_id = make_chat(email=registered["email"])
response = client.get(f"/api/audio/speech/{chat_id}/anything")
assert response.status_code == 404
def test_the_audio_tab_appears_only_once_audio_is_configured(
client: TestClient, db, registered
):
assert "tab-audio" not in client.get("/settings").text
client.post(
"/admin/audio",
data={"tts_enabled": "true", "tts_base_url": "http://tts"},
follow_redirects=False,
)
assert "tab-audio" in client.get("/settings").text
def test_saving_audio_preferences(client: TestClient, db, registered):
client.post(
"/api/preferences/audio",
data={"voice": "am_adam", "speed": "1.5", "autoplay": "true"},
follow_redirects=False,
)
from sqlalchemy import select
from lembas.db.models import User
user = db.scalar(select(User).where(User.email == registered["email"]))
db.refresh(user)
assert user.settings_json["audio"] == {
"autoplay": True,
"voice": "am_adam",
"speed": 1.5,
}
def test_an_unreadable_speed_leaves_the_rest_of_the_form_intact(
client: TestClient, db, registered
):
client.post(
"/api/preferences/audio",
data={"voice": "am_adam", "speed": "quickly"},
follow_redirects=False,
)
from sqlalchemy import select
from lembas.db.models import User
user = db.scalar(select(User).where(User.email == registered["email"]))
db.refresh(user)
assert user.settings_json["audio"]["voice"] == "am_adam"
assert "speed" not in user.settings_json["audio"]
# --- Resolving what a given reader hears -------------------------------------
def _user_with(settings):
from lembas.db.models import User
return User(name="x", email="x@x.test", password_hash="", settings_json=settings)
def test_a_readers_voice_beats_the_instance_default():
from lembas.api.audio import resolve_voice
user = _user_with({"audio": {"voice": "am_adam"}})
assert resolve_voice({"tts_voice": "af_heart"}, user) == "am_adam"
def test_the_instance_voice_is_used_when_the_reader_has_no_preference():
from lembas.api.audio import resolve_voice
assert resolve_voice({"tts_voice": "af_heart"}, _user_with({})) == "af_heart"
def test_speed_is_clamped_to_what_every_endpoint_accepts():
from lembas.api.audio import resolve_speed
assert resolve_speed({}, _user_with({"audio": {"speed": 99}})) == 4.0
assert resolve_speed({}, _user_with({"audio": {"speed": 0}})) == 0.25
assert resolve_speed({}, _user_with({"audio": {"speed": "fast"}})) == 1.0
+209
View File
@@ -0,0 +1,209 @@
"""The tool loop: one reply, several requests."""
from __future__ import annotations
from lembas.db.models import ROLE_ASSISTANT, Chat, Connection, Message, Model
from lembas.services import generation as generation_service
from lembas.services import settings_store
from lembas.services import tools as tools_service
from lembas.services.search.base import SearchResult
def _chat_with_tools(db, user_id):
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="m", capabilities_json={"tools": True}))
db.commit()
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
db.add(chat)
db.commit()
db.add(Message(chat_id=chat.id, role="user", content="What is a mallorn?", complete=True))
db.commit()
assistant = Message(chat_id=chat.id, role=ROLE_ASSISTANT, content="", complete=False)
db.add(assistant)
db.commit()
return chat.id, assistant.id
def _tool_call_chunk(name: str, arguments: str) -> dict:
return {
"choices": [
{"delta": {"tool_calls": [{"index": 0, "id": "c1", "function": {
"name": name, "arguments": arguments}}]}}
]
}
def _text_chunk(text: str) -> dict:
return {"choices": [{"delta": {"content": text}}]}
def _stub_stream(rounds, seen_payloads):
"""A stream_chat that returns a different scripted round each time."""
async def stream_chat(_endpoint, payload):
seen_payloads.append(payload)
for chunk in rounds[min(len(seen_payloads) - 1, len(rounds) - 1)]:
yield chunk
return stream_chat
async def test_a_tool_call_produces_a_second_request(db, user_id, monkeypatch):
"""The whole point: one reply, two round trips, with the search result in
the second one's messages."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
async def fake_search(_config, _query, *, limit=None):
return [SearchResult("Mallorn", "https://tolkien.test/mallorn", "A golden tree.")]
monkeypatch.setattr("lembas.services.search.run", fake_search)
payloads = []
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[_tool_call_chunk("web_search", '{"query": "mallorn"}')],
[_text_chunk("A mallorn is a golden tree.")],
],
payloads,
),
)
monkeypatch.setattr(
"lembas.services.chat.generate_title", _never_called_title
)
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
await generation_service._run(generation)
assert len(payloads) == 2, "the model asked for a tool, so it must be asked again"
assert generation.text == "A mallorn is a golden tree."
# The second request carries the assistant's own call back, then the result.
followups = payloads[1]["messages"][-2:]
assert followups[0]["tool_calls"][0]["function"]["name"] == "web_search"
assert followups[1]["role"] == "tool"
assert "https://tolkien.test/mallorn" in followups[1]["content"]
# And the reader gets to see what it looked up.
assert generation.tool_events[0]["query"] == "mallorn"
assert generation.tool_events[0]["results"][0]["url"] == "https://tolkien.test/mallorn"
async def test_the_tools_array_is_absent_without_the_capability(db, user_id, monkeypatch):
chat_id, message_id = _chat_with_tools(db, user_id)
# Search enabled, but the model is not marked as supporting tools.
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
model = db.query(Model).first()
model.capabilities_json = {}
db.commit()
payloads = []
monkeypatch.setattr(
generation_service, "stream_chat", _stub_stream([[_text_chunk("hi")]], payloads)
)
monkeypatch.setattr("lembas.services.chat.generate_title", _never_called_title)
await generation_service._run(
generation_service.Generation(chat_id=chat_id, message_id=message_id)
)
assert "tools" not in payloads[0]
async def test_text_before_a_tool_call_is_kept(db, user_id, monkeypatch):
"""A model that narrates what it is about to look up must not lose that
when the results come back."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
monkeypatch.setattr(
"lembas.services.search.run", _empty_search
)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[
_text_chunk("Let me look that up. "),
_tool_call_chunk("web_search", '{"query": "mallorn"}'),
],
[_text_chunk("Nothing found.")],
],
[],
),
)
monkeypatch.setattr("lembas.services.chat.generate_title", _never_called_title)
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
await generation_service._run(generation)
assert generation.text == "Let me look that up. Nothing found."
async def test_a_model_that_only_ever_calls_tools_is_stopped(db, user_id, monkeypatch):
"""Otherwise a small model that has decided searching is the answer keeps
searching until the context runs out, at a full request each time."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
monkeypatch.setattr("lembas.services.search.run", _empty_search)
payloads = []
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream([[_tool_call_chunk("web_search", '{"query": "x"}')]], payloads),
)
monkeypatch.setattr("lembas.services.chat.generate_title", _never_called_title)
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
await generation_service._run(generation)
assert len(payloads) == tools_service.MAX_ROUNDS + 1
# Recorded rather than silently dropped: an answer that stops here has to
# be explicable.
assert generation.tool_events[-1]["status"] == "error"
async def test_tool_activity_is_stored_with_the_message(db, user_id, monkeypatch):
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat_id, message_id = _chat_with_tools(db, user_id)
async def fake_search(_config, _query, *, limit=None):
return [SearchResult("Mallorn", "https://tolkien.test/m", "A tree.")]
monkeypatch.setattr("lembas.services.search.run", fake_search)
monkeypatch.setattr(
generation_service,
"stream_chat",
_stub_stream(
[
[_tool_call_chunk("web_search", '{"query": "mallorn"}')],
[_text_chunk("Done.")],
],
[],
),
)
monkeypatch.setattr("lembas.services.chat.generate_title", _never_called_title)
await generation_service._run(
generation_service.Generation(chat_id=chat_id, message_id=message_id)
)
stored = db.get(Message, message_id)
db.refresh(stored)
assert stored.tool_calls_json[0]["query"] == "mallorn"
assert stored.complete is True
async def _empty_search(_config, _query, *, limit=None):
return []
async def _never_called_title(*_args, **_kwargs):
"""Auto-titling makes its own request; these tests are about the tool loop."""
return "A title"
+164
View File
@@ -0,0 +1,164 @@
"""Installing as an app, and the composer's single send/stop button."""
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from lembas.services import settings_store
from lembas.web.templating import STATIC_DIR
# --- Manifest ----------------------------------------------------------------
def test_the_manifest_is_readable_when_signed_out(client: TestClient):
"""A browser fetches the manifest outside any page's session."""
response = client.get("/manifest.webmanifest")
assert response.status_code == 200
assert response.headers["content-type"].startswith("application/manifest+json")
def test_the_manifest_carries_the_instance_name(client: TestClient, db, registered):
client.post(
"/admin/general", data={"instance_name": "Rivendell"}, follow_redirects=False
)
assert client.get("/manifest.webmanifest").json()["name"] == "Rivendell"
def test_the_manifest_offers_a_maskable_icon(client: TestClient):
"""Without one, Android crops the corners off the wafer."""
icons = client.get("/manifest.webmanifest").json()["icons"]
assert any(icon["purpose"] == "maskable" for icon in icons)
assert any(icon["sizes"] == "512x512" and icon["purpose"] == "any" for icon in icons)
def test_every_manifest_icon_exists(client: TestClient):
for icon in client.get("/manifest.webmanifest").json()["icons"]:
assert client.get(icon["src"]).status_code == 200, icon["src"]
def test_the_manifest_starts_at_the_chat(client: TestClient):
payload = client.get("/manifest.webmanifest").json()
assert payload["start_url"] == "/chat"
assert payload["scope"] == "/"
assert payload["display"] == "standalone"
# --- Service worker ----------------------------------------------------------
def test_the_worker_is_served_from_the_root(client: TestClient):
"""A worker under /static/js/ would have scope /static/js/ and control
nothing."""
response = client.get("/sw.js")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/javascript")
def test_the_worker_is_never_cached(client: TestClient):
"""A stale worker keeps serving a stale cache."""
assert "no-store" in client.get("/sw.js").headers["cache-control"]
def test_the_worker_leaves_the_api_alone():
"""The reply stream, the unread poll and attachment downloads all live
under /api/. A cached response on any of them is at best stale."""
source = (STATIC_DIR / "js" / "sw.js").read_text()
assert '"/api/"' in source
assert "text/event-stream" in source
def test_every_precached_asset_exists(client: TestClient):
"""addAll is all-or-nothing in most implementations, and a missing entry is
invisible until someone opens the developer tools."""
source = (STATIC_DIR / "js" / "sw.js").read_text()
shell = source.split("var SHELL = [", 1)[1].split("];", 1)[0]
paths = [line.strip().strip('",') for line in shell.splitlines() if '"' in line]
assert paths
for path in paths:
assert client.get(path).status_code == 200, path
def test_the_offline_page_stands_on_its_own(client: TestClient):
"""Cached at install time, so it must render with no user and no chats."""
response = client.get("/offline")
assert response.status_code == 200
assert 'class="sidebar' not in response.text
assert 'id="thread' not in response.text
def test_the_page_links_the_manifest_and_the_apple_icon(client: TestClient, registered):
page = client.get("/chat").text
assert 'rel="manifest"' in page
assert 'rel="apple-touch-icon"' in page
assert 'name="theme-color"' in page
# --- Send and Stop -----------------------------------------------------------
def test_the_hidden_attribute_wins_over_component_styles():
"""`.btn` is display: inline-flex, which beats the browser's own
`[hidden] { display: none }`. Without this rule a button hidden from
JavaScript stays on screen -- which is how Stop came to sit permanently
beside Send."""
css = (STATIC_DIR / "css" / "app.css").read_text()
assert "[hidden]" in css
assert "display: none !important" in css
def test_the_composer_has_exactly_one_send_button(client: TestClient, db, registered):
"""One button that becomes Stop, not two that take turns being hidden."""
_add_a_model(db)
page = client.get("/chat").text
assert page.count("data-composer-action") == 1
def test_the_send_button_carries_both_icons(client: TestClient, db, registered):
"""Rendered together and chosen in CSS, so the swap costs no layout and
cannot flash an empty button."""
_add_a_model(db)
page = client.get("/chat").text
assert "composer__icon--send" in page
assert "composer__icon--stop" in page
def test_the_composer_starts_in_the_send_state(client: TestClient, db, registered):
_add_a_model(db)
assert 'data-composer-action="send"' in client.get("/chat").text
def _add_a_model(db):
from lembas.db.models import Connection, Model
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="m"))
db.commit()
# --- The icons themselves ----------------------------------------------------
def test_the_generated_icons_are_committed():
"""They come from scripts/build_artwork.py and are committed like the SVGs;
the running application has no rasteriser."""
for name in (
"icon-192.png",
"icon-512.png",
"icon-maskable-512.png",
"apple-touch-icon-180.png",
):
path = Path(STATIC_DIR) / "img" / name
assert path.exists(), name
assert path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n", name
def test_the_mic_appears_only_when_dictation_is_configured(
client: TestClient, db, registered
):
_add_a_model(db)
assert "data-mic" not in client.get("/chat").text
settings_store.update(
db,
{"stt_enabled": True, "stt_base_url": "http://stt"},
key=settings_store.AUDIO,
)
assert "data-mic" in client.get("/chat").text
+173
View File
@@ -0,0 +1,173 @@
"""Web search providers and their normalisation."""
from __future__ import annotations
import httpx
import pytest
from fastapi.testclient import TestClient
from lembas.services import search as search_service
from lembas.services import settings_store
from lembas.services.crypto import encrypt
from lembas.services.search import firecrawl, searxng
from lembas.services.search.base import SearchError, SearchResult, clean
# --- What a result is --------------------------------------------------------
def test_only_http_urls_are_linkable():
"""A search provider is an untrusted source. A result carrying a
javascript: URL must never become an anchor pointing at it."""
assert SearchResult("t", "https://example.com", "").is_linkable
assert SearchResult("t", "http://example.com", "").is_linkable
assert not SearchResult("t", "javascript:alert(1)", "").is_linkable
assert not SearchResult("t", "data:text/html,<script>", "").is_linkable
def test_the_host_is_pulled_out_for_display():
assert SearchResult("t", "https://en.wikipedia.org/wiki/X", "").host == "en.wikipedia.org"
def test_clean_drops_a_row_with_no_url():
assert clean("A title", "", "text") is None
def test_clean_falls_back_to_the_url_as_a_title():
assert clean("", "https://example.com", "").title == "https://example.com"
def test_clean_collapses_whitespace_and_truncates():
from lembas.services.search.base import MAX_SNIPPET
result = clean(" a\n b ", "https://x.test", "word " * 500)
assert result.title == "a b"
assert len(result.snippet) <= MAX_SNIPPET
# --- SearXNG -----------------------------------------------------------------
async def test_searxng_normalises_its_results(mock_http):
mock_http(
lambda _r: httpx.Response(
200,
json={
"results": [
{"title": "One", "url": "https://one.test", "content": "first"},
{"title": "Two", "url": "https://two.test", "content": "second"},
]
},
)
)
results = await searxng.search({"searxng_base_url": "http://searx"}, "q", 5)
assert [r.title for r in results] == ["One", "Two"]
assert results[0].snippet == "first"
async def test_searxng_names_the_disabled_json_format(mock_http):
"""A stock instance refuses the JSON format with a 403. Reporting "search
failed" would leave the one-line fix undiscoverable."""
mock_http(lambda _r: httpx.Response(403, text="Forbidden"))
with pytest.raises(SearchError) as caught:
await searxng.search({"searxng_base_url": "http://searx"}, "q", 5)
assert "settings.yml" in caught.value.message
async def test_searxng_treats_an_html_answer_as_the_same_misconfiguration(mock_http):
mock_http(lambda _r: httpx.Response(200, text="<html>results</html>"))
with pytest.raises(SearchError) as caught:
await searxng.search({"searxng_base_url": "http://searx"}, "q", 5)
assert "settings.yml" in caught.value.message
async def test_searxng_needs_a_url():
with pytest.raises(SearchError):
await searxng.search({"searxng_base_url": ""}, "q", 5)
# --- Firecrawl ---------------------------------------------------------------
@pytest.mark.parametrize(
"payload",
[
{"data": [{"title": "One", "url": "https://one.test", "description": "first"}]},
# Newer responses nest the list under data.web.
{"data": {"web": [{"title": "One", "url": "https://one.test", "description": "first"}]}},
],
)
async def test_firecrawl_reads_both_response_shapes(mock_http, payload):
mock_http(lambda _r: httpx.Response(200, json=payload))
results = await firecrawl.search(
{"firecrawl_api_key_encrypted": encrypt("fc-x")}, "q", 5
)
assert [r.url for r in results] == ["https://one.test"]
async def test_firecrawl_reports_a_rejected_key(mock_http):
mock_http(lambda _r: httpx.Response(401, json={"error": "bad key"}))
with pytest.raises(SearchError) as caught:
await firecrawl.search({"firecrawl_api_key_encrypted": encrypt("fc-x")}, "q", 5)
assert "rejected" in caught.value.message
async def test_firecrawl_needs_a_key():
with pytest.raises(SearchError):
await firecrawl.search({"firecrawl_api_key_encrypted": ""}, "q", 5)
# --- Dispatch ----------------------------------------------------------------
async def test_run_refuses_an_empty_query():
with pytest.raises(SearchError):
await search_service.run({"provider": "ddgs"}, " ")
async def test_run_refuses_an_unknown_provider():
with pytest.raises(SearchError):
await search_service.run({"provider": "askjeeves"}, "q")
async def test_the_administrators_limit_is_a_ceiling(mock_http):
"""A model asking for fifty results is asking for a prompt nobody can
afford."""
rows = [{"title": f"r{i}", "url": f"https://x{i}.test", "content": ""} for i in range(50)]
mock_http(lambda _r: httpx.Response(200, json={"results": rows}))
config = {"provider": "searxng", "searxng_base_url": "http://searx", "max_results": 3}
assert len(await search_service.run(config, "q", limit=50)) == 3
# --- Admin -------------------------------------------------------------------
def test_search_settings_round_trip(client: TestClient, db, registered):
client.post(
"/admin/search",
data={
"enabled": "true",
"provider": "searxng",
"max_results": "7",
"region": "uk-en",
"safesearch": "strict",
"searxng_base_url": "http://searx:8888/",
"timeout": "30",
},
follow_redirects=False,
)
values = settings_store.search(db)
assert values["enabled"] is True
assert values["provider"] == "searxng"
assert values["max_results"] == 7
# Trailing slash stripped, so the provider does not build a double slash.
assert values["searxng_base_url"] == "http://searx:8888"
def test_an_unknown_provider_falls_back_to_the_default(client: TestClient, db, registered):
client.post(
"/admin/search", data={"provider": "askjeeves"}, follow_redirects=False
)
assert settings_store.search(db)["provider"] == "ddgs"
def test_results_per_search_is_bounded(client: TestClient, db, registered):
client.post("/admin/search", data={"max_results": "500"}, follow_redirects=False)
assert settings_store.search(db)["max_results"] == 20
def test_the_search_page_renders_every_provider(client: TestClient, registered):
page = client.get("/admin/search").text
for provider in search_service.PROVIDERS:
assert provider.label in page
+227
View File
@@ -0,0 +1,227 @@
"""Tool calling: reassembling calls, deciding what is offered, running it."""
from __future__ import annotations
import json
import pytest
from lembas.db.models import Chat, Connection, Model
from lembas.services import settings_store
from lembas.services import tools as tools_service
from lembas.services.llm.openai_client import delta_tool_calls, finish_reason
from lembas.services.search.base import SearchError, SearchResult
# --- Reading the stream ------------------------------------------------------
def test_delta_tool_calls_reads_the_normal_shape():
chunk = {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "a"}]}}]}
assert delta_tool_calls(chunk) == [{"index": 0, "id": "a"}]
@pytest.mark.parametrize(
"chunk", [{}, {"choices": []}, {"choices": [{}]}, {"choices": [{"delta": {}}]}]
)
def test_delta_tool_calls_tolerates_junk(chunk):
assert delta_tool_calls(chunk) == []
def test_finish_reason_is_read_when_present():
assert finish_reason({"choices": [{"finish_reason": "tool_calls"}]}) == "tool_calls"
assert finish_reason({"choices": [{}]}) == ""
# --- The accumulator ---------------------------------------------------------
def test_arguments_split_across_chunks_are_rejoined():
"""Arguments arrive one token at a time; the id and name arrive once, on
the first fragment only. This is the real shape llama.cpp produces."""
accumulator = tools_service.ToolCallAccumulator()
accumulator.feed(
[{"index": 0, "id": "c1", "function": {"name": "web_search", "arguments": "{"}}]
)
for piece in ['"query"', ":", '"mallorn', ' tree"', "}"]:
accumulator.feed([{"index": 0, "function": {"arguments": piece}}])
assert accumulator.calls == [
{"id": "c1", "name": "web_search", "arguments": '{"query":"mallorn tree"}'}
]
def test_two_calls_are_kept_apart_by_index():
"""Not by name: a model calling the same tool twice in one turn is exactly
the case that breaks if name is the key."""
accumulator = tools_service.ToolCallAccumulator()
accumulator.feed(
[
{"index": 0, "id": "a", "function": {"name": "web_search", "arguments": '{"q":1}'}},
{"index": 1, "id": "b", "function": {"name": "web_search", "arguments": '{"q":2}'}},
]
)
assert [c["id"] for c in accumulator.calls] == ["a", "b"]
assert accumulator.calls[1]["arguments"] == '{"q":2}'
def test_a_missing_index_is_treated_as_the_only_call():
"""Some servers omit index entirely when there is just one call."""
accumulator = tools_service.ToolCallAccumulator()
accumulator.feed([{"function": {"name": "web_search", "arguments": "{}"}}])
assert len(accumulator.calls) == 1
def test_a_call_with_no_name_is_not_a_call():
accumulator = tools_service.ToolCallAccumulator()
accumulator.feed([{"index": 0, "function": {"arguments": "{}"}}])
assert accumulator.calls == []
assert not accumulator
def test_an_id_is_invented_when_the_server_supplies_none():
"""The id is required when the results are sent back, and not every server
provides one."""
accumulator = tools_service.ToolCallAccumulator()
accumulator.feed([{"index": 0, "function": {"name": "web_search", "arguments": "{}"}}])
assert accumulator.calls[0]["id"]
# --- What gets offered -------------------------------------------------------
def _chat_with(db, user_id, *, capabilities):
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
db.add(connection)
db.commit()
db.add(
Model(connection_id=connection.id, model_id="m", capabilities_json=capabilities)
)
db.commit()
chat = Chat(user_id=user_id, model_id="m", connection_id=connection.id)
db.add(chat)
db.commit()
return chat
def _user(db, user_id):
from lembas.db.models import User
return db.get(User, user_id)
def test_nothing_is_offered_when_search_is_off(db, user_id):
chat = _chat_with(db, user_id, capabilities={"tools": True})
assert tools_service.enabled_tools(db, chat, _user(db, user_id)) == []
def test_nothing_is_offered_to_a_model_without_the_tools_capability(db, user_id):
"""Sending a tools array to an endpoint that does not implement tool calling
fails the entire request, exactly as image parts do without vision."""
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat = _chat_with(db, user_id, capabilities={})
assert tools_service.enabled_tools(db, chat, _user(db, user_id)) == []
def test_web_search_is_offered_when_everything_lines_up(db, user_id):
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat = _chat_with(db, user_id, capabilities={"tools": True})
offered = tools_service.enabled_tools(db, chat, _user(db, user_id))
assert len(offered) == 1
assert offered[0]["function"]["name"] == "web_search"
def test_nothing_is_offered_when_the_provider_cannot_run(db, user_id, monkeypatch):
"""Offering a tool that will fail on every call is worse than not offering
it at all."""
monkeypatch.setattr(
"lembas.services.search.availability", lambda _key: "ddgs is not installed."
)
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
chat = _chat_with(db, user_id, capabilities={"tools": True})
assert tools_service.enabled_tools(db, chat, _user(db, user_id)) == []
# --- Running one -------------------------------------------------------------
async def test_running_web_search_formats_results_for_the_model(monkeypatch):
async def fake_run(_config, query, *, limit=None):
return [SearchResult("A title", "https://a.test", "a snippet")]
monkeypatch.setattr("lembas.services.search.run", fake_run)
outcome = await tools_service.run_tool({}, "web_search", '{"query": "mallorn"}')
assert "A title" in outcome.content
assert "https://a.test" in outcome.content
assert outcome.event["status"] == "ok"
assert outcome.event["results"][0]["host"] == "a.test"
async def test_malformed_argument_json_is_treated_as_the_query(monkeypatch):
"""Small models emit broken argument JSON often enough that this is a normal
path, not an exceptional one."""
seen = {}
async def fake_run(_config, query, *, limit=None):
seen["query"] = query
return []
monkeypatch.setattr("lembas.services.search.run", fake_run)
await tools_service.run_tool({}, "web_search", "mallorn tree")
assert seen["query"] == "mallorn tree"
async def test_a_failed_search_hands_the_model_an_explanation(monkeypatch):
"""A failed search should produce "I could not look that up", not kill the
whole reply."""
async def fake_run(_config, _query, *, limit=None):
raise SearchError("DuckDuckGo is rate limiting this instance.")
monkeypatch.setattr("lembas.services.search.run", fake_run)
outcome = await tools_service.run_tool({}, "web_search", '{"query": "x"}')
assert "rate limiting" in outcome.content
assert outcome.event["status"] == "error"
async def test_an_unknown_tool_is_reported_rather_than_raised():
outcome = await tools_service.run_tool({}, "launch_missiles", "{}")
assert "no tool called" in outcome.content
assert outcome.event["status"] == "error"
async def test_a_call_with_no_query_is_reported():
outcome = await tools_service.run_tool({}, "web_search", '{"query": " "}')
assert outcome.event["status"] == "error"
# --- The turns sent back -----------------------------------------------------
def test_the_assistant_turn_echoes_the_calls():
"""The endpoint needs its own tool_calls back before the tool replies, or it
has nothing to match the tool_call_ids against."""
turn = tools_service.assistant_turn(
[{"id": "c1", "name": "web_search", "arguments": '{"query":"x"}'}], "Looking it up."
)
assert turn["role"] == "assistant"
assert turn["content"] == "Looking it up."
assert turn["tool_calls"][0]["id"] == "c1"
assert turn["tool_calls"][0]["function"]["name"] == "web_search"
def test_an_empty_assistant_message_becomes_null():
"""Most endpoints reject an assistant turn whose content is an empty string
alongside tool_calls."""
turn = tools_service.assistant_turn([{"id": "c", "name": "n", "arguments": "{}"}], "")
assert turn["content"] is None
def test_the_tool_turn_carries_the_call_id():
turn = tools_service.tool_turn({"id": "c1", "name": "web_search"}, "results here")
assert turn == {
"role": "tool",
"tool_call_id": "c1",
"name": "web_search",
"content": "results here",
}
def test_the_schema_is_valid_json():
"""It is sent verbatim to the endpoint; a schema that will not serialise
fails every request rather than one."""
json.dumps(tools_service.WEB_SEARCH_SCHEMA)
assert tools_service.WEB_SEARCH_SCHEMA["function"]["parameters"]["required"] == ["query"]