diff --git a/README.md b/README.md index 45d8d3f..b7d08b0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,154 @@ -# LLeMbas +

+ LLeMbas — waybread for the long road of thought +

+

+ A self-hosted web UI for your language models, written in Python.
+ Talks to anything that speaks the OpenAI API. Themed after Middle-earth. +

+ +

+ Python 3.11+ + License GPL-3.0 + No Node required +

+ +--- + +*Lembas* is the Elvish waybread — one bite sustains a traveller for a day's +march. The capitals hide what it runs on: **LLeM**bas. + +## Why this exists + +Most self-hosted LLM front-ends are large JavaScript applications with a Python +API bolted underneath. LLeMbas is the other way round: **server-rendered +Python**, with htmx and a little Alpine for interactivity. There is no +`package.json`, no bundler, no build step, and nothing is fetched from a CDN at +runtime. Clone it, `pip install -e .`, run it. + +## Features + +**Working now** + +- **Chats** — streaming replies, Markdown with server-side syntax highlighting, + copy and regenerate, automatic chat titles +- **Folders** — arbitrarily nested, delete a folder without losing the chats + inside it +- **OpenAI connections** — point at OpenAI, LM Studio, vLLM, llama.cpp, + llama-swap, Ollama or OpenRouter; models are discovered and cached +- **Accounts** — first account becomes the administrator, argon2 password + hashing, revocable server-side sessions +- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user + +**Planned** + +Users & groups with permissions · file upload, vision and PDFs · built-in tools +with admin settings · custom tools and MCP servers · agentic execution (local +and over SSH) · image generation. + +## Quick start + +```bash +git clone https://git.houmeres.sk/Houmeres/LLeMbas.git +cd LLeMbas + +python -m venv .venv && . .venv/bin/activate +pip install -e ".[dev]" + +cp .env.example .env +lembas secret-key # paste the result into LEMBAS_SECRET_KEY + +lembas serve # http://127.0.0.1:8080 +``` + +Open the address and create the first account — it becomes the administrator. +Then go to **Admin → Connections** and add an endpoint. For a local runner that +is usually `http://localhost:1234/v1` with no API key. Press **Test & refresh** +and its models appear in the chat model picker. + +> The vendored browser libraries (htmx, Alpine) are committed, so no network +> access is needed to run. To re-fetch or bump them: +> `python scripts/fetch_vendor.py --update`. + +## Configuration + +All variables are prefixed `LEMBAS_` and can live in `.env`. See +[`.env.example`](.env.example) for the annotated list. + +| Variable | Default | Purpose | +|---|---|---| +| `LEMBAS_SECRET_KEY` | *generated* | Signs sessions and encrypts stored API keys. **Set this.** A generated key changes every restart, signing everyone out and making stored API keys unreadable. | +| `LEMBAS_DATA_DIR` | `./data` | SQLite database and uploads. | +| `LEMBAS_HOST` / `LEMBAS_PORT` | `127.0.0.1` / `8080` | Bind address. | +| `LEMBAS_ALLOW_SIGNUP` | `true` | Let new users register themselves. The first account is always an admin regardless. | +| `LEMBAS_DEFAULT_THEME` | `moria` | `moria` (dark) or `shire` (light). | +| `LEMBAS_SESSION_TTL` | `2592000` | Session lifetime in seconds. | +| `LEMBAS_REQUEST_TIMEOUT` | `300` | Seconds to wait on an upstream model. | + +### Commands + +```bash +lembas serve # run the server +lembas info # where data lives, what is configured +lembas secret-key # generate a value for LEMBAS_SECRET_KEY +lembas create-admin # create or promote an administrator +``` + +## How it fits together + +``` +Browser ──form POST──▶ FastAPI ──▶ SQLite + ▲ │ + │ └──httpx──▶ any OpenAI-compatible endpoint + └──── server-sent events ◀───────────────┘ (streamed reply) +``` + +Sending a message stores the turn and returns two HTML fragments: the user's +bubble and an empty assistant bubble carrying an `sse-connect`. That opens a +server-sent event stream which appends tokens as they arrive, then replaces the +whole bubble with the finished, Markdown-rendered version. Rendering and +highlighting happen in Python, so the streamed and final views cannot disagree. + +``` +src/lembas/ + api/ routes: auth, chats, folders, admin, pages + db/models/ SQLAlchemy schema + security/ password hashing, sessions + services/ llm client, chat orchestration, markdown, crypto, sse + web/ Jinja templates and static assets +assets/ SVG artwork masters +scripts/ artwork generator, vendored-JS fetcher +deploy/ systemd unit and nginx vhost for a real install +``` + +## Development + +```bash +pytest # test suite +ruff check . # lint +python scripts/build_artwork.py # regenerate the SVG artwork +python scripts/fetch_vendor.py # verify vendored JS against the lockfile +``` + +There is no migration tool. The schema is SQLite-only and created at startup, +so changing a column on a live database is a manual job — see `CLAUDE.md`. + +## Artwork + +The logo, favicon and banner are original vector work, generated by +[`scripts/build_artwork.py`](scripts/build_artwork.py) so the mallorn leaf stays +identical across every size it appears at. The wordmark is +[Source Serif 4](https://github.com/adobe-fonts/source-serif) (SIL OFL 1.1) +converted to outlines — a README banner cannot load a webfont, and `` +would render in whatever serif the reader happens to have. + +## Licence + +[GPL-3.0](LICENSE). + +## A note on the theme + +This is an independent hobby project, themed as an affectionate nod to +J.R.R. Tolkien's world. It is **not affiliated with, endorsed by, or connected +to** the Tolkien Estate, Middle-earth Enterprises, or any related rights +holder. All artwork here is original. diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..1a5a080 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,80 @@ +# Deployment + +Installs LLeMbas as a **system** service behind nginx at `https://chat.lan`. + +Written for `gamebox` (Arch), and follows the conventions already used there +for llama-swap and comfyui: + +| | | +|---|---| +| Service user | `lembas` (system account, `nologin`) | +| Home | `/home/lembas`, bind-mounted to `/srv/lembas` | +| Checkout | `/srv/lembas/app` (git clone of the Gitea remote) | +| Virtualenv | `/srv/lembas/venv` | +| Database | `/srv/lembas/data/lembas.db` | +| Environment | `/srv/lembas/lembas.env` (mode 600) | +| Unit | `/etc/systemd/system/lembas.service` | +| Vhost | `/etc/nginx/conf.d/chat.lan.conf`, self-signed cert | +| Listens | `127.0.0.1:8080` — reachable only through nginx | + +The home lives on `/home` rather than `/var/lib` because the root LV on that +box is only 50 GB; `/srv/lembas` is the same bind-mount trick as `/srv/llama` +and `/srv/comfyui`. + +## First install + +```bash +./deploy/install.sh +``` + +Idempotent — safe to re-run. It creates the user and bind mount, clones the +repo, builds the venv, generates `lembas.env` with a fresh +`LEMBAS_SECRET_KEY`, installs the unit and vhost, issues a self-signed cert, +adds a `/etc/hosts` entry, and enables the service. + +Then open , accept the self-signed certificate warning, and +create the first account — it becomes the administrator. + +## Deploying a change + +```bash +git push # from the working copy +./deploy/update.sh +``` + +`update.sh` fetches, hard-resets `/srv/lembas/app` to `origin/main`, reinstalls +dependencies and restarts the service, then prints what changed. The hard reset +is deliberate: nothing is ever edited in place there, so there is no local work +to preserve and no merge conflicts to resolve. + +## Operating it + +```bash +systemctl status lembas +journalctl -u lembas -f +sudo -u lembas /srv/lembas/venv/bin/lembas info # paths and counts +sudo systemctl restart lembas +``` + +Configuration lives in `/srv/lembas/lembas.env`. Edit it and restart. + +## Notes + +**The secret key is generated once.** `install.sh` will not overwrite an +existing `lembas.env`. Rotating `LEMBAS_SECRET_KEY` signs every user out *and* +makes stored upstream API keys unreadable — they would have to be re-entered. + +**nginx buffering is off for a reason.** Replies stream as server-sent events. +With `proxy_buffering on` (the default) nginx holds the entire reply and +delivers it in one lump at the end, which is indistinguishable from streaming +being broken. `proxy_read_timeout` is raised to an hour because a model can +think for minutes before the first token. + +**Name resolution.** `chat.lan` is in Pi-hole, but this box queries the router +first and the router's dnsmasq is authoritative for `.lan` without forwarding +those queries on — the same reason `comfy.lan` needs one. `install.sh` adds a +`/etc/hosts` entry, which is harmless if DNS already answers. + +**Hardening is deliberately moderate.** `ProtectSystem=full`, not `strict`: the +agentic features planned for later need to run commands, and a lockdown that +has to be torn out again is worse than one that was never applied. diff --git a/deploy/chat.lan.nginx.conf b/deploy/chat.lan.nginx.conf new file mode 100644 index 0000000..be8d204 --- /dev/null +++ b/deploy/chat.lan.nginx.conf @@ -0,0 +1,57 @@ +# chat.lan - HTTPS reverse proxy to LLeMbas (127.0.0.1:8080). +# Deployed to /etc/nginx/conf.d/chat.lan.conf. Self-signed cert (chat.lan). +# +# Mirrors the comfy.lan and llama.lan vhosts on this box. + +server { + listen 80; + listen [::]:80; + server_name chat.lan; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name chat.lan; + + ssl_certificate /etc/nginx/ssl/chat.lan.crt; + ssl_certificate_key /etc/nginx/ssl/chat.lan.key; + ssl_protocols TLSv1.2 TLSv1.3; + + # File uploads land here once that feature exists; 0 = no limit. + client_max_body_size 0; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Streamed replies are server-sent events. Every one of these matters: + # with buffering on, nginx holds the whole reply and delivers it in one + # lump at the end, which looks exactly like streaming being broken. + proxy_buffering off; + proxy_request_buffering off; + proxy_cache off; + # SSE is plain HTTP/1.1 chunked, so the connection header must not be + # the websocket upgrade dance -- it must simply stay open. + proxy_set_header Connection ""; + + # A model can think for minutes before the first token. The default + # 60s read timeout would cut long generations off mid-sentence. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # Static assets are immutable per release and never need revalidating. + location /static/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + expires 1h; + add_header Cache-Control "public"; + } +} diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..93a6019 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Install LLeMbas as a system service behind nginx at https://chat.lan. +# +# Follows the conventions already used on this box for llama-swap and comfyui: +# a dedicated service user whose home lives on /home (the root LV is only +# 50 GB) and is bind-mounted to /srv/, a system unit so it survives +# logout, and an nginx vhost with a self-signed cert. +# +# Idempotent: safe to re-run. To deploy new code afterwards use update.sh, +# which is what a `git push` should be followed by. +set -euo pipefail + +REPO_URL="${LEMBAS_REPO_URL:-https://git.houmeres.sk/Houmeres/LLeMbas.git}" +BRANCH="${LEMBAS_BRANCH:-main}" +SERVICE_USER=lembas +HOME_DIR=/home/lembas +PREFIX=/srv/lembas +APP="$PREFIX/app" +VENV="$PREFIX/venv" +ENV_FILE="$PREFIX/lembas.env" +HERE="$(dirname "$(readlink -f "$0")")" + +echo "== service user ==" +# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the +# venv and database sit on the big volume. +if ! getent passwd "$SERVICE_USER" >/dev/null; then + sudo useradd --system --create-home --home-dir "$HOME_DIR" \ + --shell /usr/bin/nologin --comment "LLeMbas" "$SERVICE_USER" +else + echo " user $SERVICE_USER already exists" +fi +sudo chmod 755 "$HOME_DIR" + +echo "== /srv/lembas bind-mount onto /home ==" +sudo mkdir -p "$PREFIX" +grep -q "^$HOME_DIR[[:space:]]" /etc/fstab \ + || echo "$HOME_DIR $PREFIX none bind 0 0" | sudo tee -a /etc/fstab >/dev/null +sudo systemctl daemon-reload +mountpoint -q "$PREFIX" || sudo mount "$PREFIX" + +echo "== checkout ==" +if [[ ! -d "$APP/.git" ]]; then + sudo -u "$SERVICE_USER" git clone --branch "$BRANCH" "$REPO_URL" "$APP" +else + echo " already cloned; use update.sh to pull" +fi + +echo "== virtualenv ==" +if [[ ! -x "$VENV/bin/python" ]]; then + sudo -u "$SERVICE_USER" python -m venv "$VENV" +fi +sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip +sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP" + +echo "== environment ==" +# Generated once and never regenerated: rotating LEMBAS_SECRET_KEY would sign +# every user out and make the stored API keys unreadable. +if [[ ! -f "$ENV_FILE" ]]; then + KEY=$("$VENV/bin/python" -c "import secrets; print(secrets.token_urlsafe(48))") + sudo tee "$ENV_FILE" >/dev/null </dev/null + +echo "== enable service ==" +sudo systemctl enable --now lembas +sleep 2 +sudo systemctl --no-pager --lines=0 status lembas || true + +echo +echo "LLeMbas is up at https://chat.lan (self-signed cert; accept the warning)" +echo "Create the first account -- it becomes the administrator." diff --git a/deploy/lembas.service b/deploy/lembas.service new file mode 100644 index 0000000..a7c3c1b --- /dev/null +++ b/deploy/lembas.service @@ -0,0 +1,49 @@ +# LLeMbas system service. +# +# Deployed to /etc/systemd/system/lembas.service by deploy/install.sh. +# +# A system unit, not a user unit, so it survives logout and comes up at boot +# without anyone signing in -- matching llama-swap and comfyui on this box. +# +# /srv/lembas is a bind mount of /home/lembas: the root LV is only 50 GB and +# the venv plus SQLite database belong on /home, same trick as /srv/llama. + +[Unit] +Description=LLeMbas - web UI for language models +Documentation=https://git.houmeres.sk/Houmeres/LLeMbas +After=network-online.target +Wants=network-online.target +# The unit is useless without the bind mount: the venv and database live there. +RequiresMountsFor=/srv/lembas +# Not a hard dependency. LLeMbas starts fine with the endpoint down and shows a +# readable error in the admin UI, which is better than refusing to boot. +After=llama-swap.service + +[Service] +Type=simple +User=lembas +Group=lembas +WorkingDirectory=/srv/lembas/app +EnvironmentFile=/srv/lembas/lembas.env +ExecStart=/srv/lembas/venv/bin/lembas serve +Restart=on-failure +RestartSec=5 + +# Reachable only through the nginx chat.lan vhost, never directly on the LAN. +# The bind address is set by LEMBAS_HOST in the environment file. + +# --- Hardening ------------------------------------------------------------- +# Modest rather than maximal: the agentic features planned for later will need +# to run commands, so ProtectSystem=strict would only be torn out again. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=full +ProtectKernelTunables=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +# Only /srv/lembas needs to be writable; /home/lembas is the same inode. +ReadWritePaths=/srv/lembas +LimitNOFILE=65535 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100755 index 0000000..db63397 --- /dev/null +++ b/deploy/update.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Pull the latest LLeMbas and restart the service. +# +# This is what to run after pushing: it fetches, hard-resets the deployment +# checkout to the remote branch, reinstalls dependencies if they changed, and +# restarts. Nothing is ever edited in place at /srv/lembas/app, so a hard reset +# is safe and avoids merge conflicts from a dirty deployment tree. +set -euo pipefail + +SERVICE_USER=lembas +PREFIX=/srv/lembas +APP="$PREFIX/app" +VENV="$PREFIX/venv" +BRANCH="${LEMBAS_BRANCH:-main}" + +if [[ ! -d "$APP/.git" ]]; then + echo "No deployment at $APP. Run deploy/install.sh first." >&2 + exit 1 +fi + +before=$(sudo -u "$SERVICE_USER" git -C "$APP" rev-parse HEAD) + +echo "== fetching ==" +sudo -u "$SERVICE_USER" git -C "$APP" fetch --quiet origin "$BRANCH" +sudo -u "$SERVICE_USER" git -C "$APP" reset --hard --quiet "origin/$BRANCH" + +after=$(sudo -u "$SERVICE_USER" git -C "$APP" rev-parse HEAD) + +if [[ "$before" == "$after" ]]; then + echo " already at $(git -C "$APP" rev-parse --short HEAD), nothing to pull" +else + echo " $(echo "$before" | cut -c1-7) -> $(echo "$after" | cut -c1-7)" + sudo -u "$SERVICE_USER" git -C "$APP" --no-pager log --oneline "$before..$after" | sed 's/^/ /' +fi + +# Cheap and idempotent; catches a dependency added since the last deploy. +echo "== dependencies ==" +sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet -e "$APP" + +echo "== restart ==" +sudo systemctl restart lembas +sleep 2 + +if systemctl is-active --quiet lembas; then + echo " lembas is running at https://chat.lan" +else + echo " lembas FAILED to start:" >&2 + sudo journalctl -u lembas -n 30 --no-pager >&2 + exit 1 +fi diff --git a/pyproject.toml b/pyproject.toml index 60a6266..ba6dd0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "cryptography>=43.0", "markdown-it-py>=3.0", "mdit-py-plugins>=0.4", + "linkify-it-py>=2.0", # bare URLs in model output become links "pygments>=2.18", "nh3>=0.2.18", "typer>=0.12", diff --git a/scripts/build_artwork.py b/scripts/build_artwork.py old mode 100644 new mode 100755 index 548c0e8..547fc3e --- a/scripts/build_artwork.py +++ b/scripts/build_artwork.py @@ -35,6 +35,11 @@ except ImportError: # pragma: no cover - design-time tool ROOT = Path(__file__).resolve().parent.parent ASSETS = ROOT / "assets" +STATIC_IMG = ROOT / "src" / "lembas" / "web" / "static" / "img" + +# assets/ holds the design masters; the application serves its own copies from +# static/. These are the few the running app actually needs. +SERVED_BY_APP = ("favicon.svg", "logo-mark.svg", "banner.svg") FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf") FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf") @@ -463,10 +468,18 @@ def main() -> None: args = parser.parse_args() args.out.mkdir(parents=True, exist_ok=True) + STATIC_IMG.mkdir(parents=True, exist_ok=True) + for filename in args.only or BUILDERS: + content = BUILDERS[filename]() path = args.out / filename - path.write_text(BUILDERS[filename](), encoding="utf-8") - print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)") + path.write_text(content, encoding="utf-8") + print(f"wrote {path.relative_to(ROOT)} ({len(content.encode()):,} bytes)") + + if filename in SERVED_BY_APP: + served = STATIC_IMG / filename + served.write_text(content, encoding="utf-8") + print(f" -> {served.relative_to(ROOT)}") if __name__ == "__main__": diff --git a/scripts/fetch_vendor.py b/scripts/fetch_vendor.py new file mode 100755 index 0000000..8b90db6 --- /dev/null +++ b/scripts/fetch_vendor.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Download the pinned browser libraries into the static vendor directory. + +LLeMbas has no Node toolchain and loads nothing from a CDN at runtime -- a +self-hosted tool should keep working without internet access, and should not +report every user's page view to a third party. The three libraries it does use +are fetched once, here, and committed. + +Integrity is enforced with vendor.lock.json. A mismatched hash aborts rather +than overwriting: that is the whole point of pinning. + + python scripts/fetch_vendor.py # fetch and verify against the lock + python scripts/fetch_vendor.py --update # re-pin after a version bump +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +VENDOR_DIR = ROOT / "src" / "lembas" / "web" / "static" / "vendor" +LOCKFILE = Path(__file__).resolve().parent / "vendor.lock.json" + +# Pinned deliberately. Bump the version, run with --update, review the diff. +PACKAGES = { + "htmx.min.js": { + "version": "2.0.10", + "url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js", + "why": "Server-rendered interactivity: every swap in the app.", + }, + "htmx-ext-sse.js": { + "version": "2.2.4", + "url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js", + "why": "Server-sent events, which is how streamed replies reach the page.", + }, + "alpine.min.js": { + "version": "3.15.12", + "url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js", + "why": "Small client-only state: menus, theme toggle, composer autosize.", + }, +} + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def fetch(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "lembas-vendor-fetch"}) + with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 + return response.read() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--update", + action="store_true", + help="rewrite vendor.lock.json with the hashes just downloaded", + ) + args = parser.parse_args() + + lock = json.loads(LOCKFILE.read_text()) if LOCKFILE.exists() else {} + VENDOR_DIR.mkdir(parents=True, exist_ok=True) + + new_lock: dict[str, dict[str, str]] = {} + failed = False + + for filename, spec in PACKAGES.items(): + try: + payload = fetch(spec["url"]) + except (urllib.error.URLError, TimeoutError) as exc: + print(f" FAIL {filename}: {exc}", file=sys.stderr) + failed = True + continue + + digest = sha256(payload) + expected = lock.get(filename, {}).get("sha256") + + if expected and digest != expected and not args.update: + print( + f" FAIL {filename}: hash mismatch\n" + f" expected {expected}\n" + f" received {digest}\n" + f" Refusing to overwrite. If the version was bumped " + f"deliberately, re-run with --update.", + file=sys.stderr, + ) + failed = True + continue + + (VENDOR_DIR / filename).write_bytes(payload) + new_lock[filename] = { + "version": spec["version"], + "url": spec["url"], + "sha256": digest, + } + status = "ok" if expected == digest else ("pinned" if args.update else "new") + print(f" {status:>6} {filename} {len(payload):>8,} bytes v{spec['version']}") + + if failed: + print("\nOne or more downloads failed. Vendored files were not fully written.") + return 1 + + if args.update or not LOCKFILE.exists(): + LOCKFILE.write_text(json.dumps(new_lock, indent=2, sort_keys=True) + "\n") + print(f"\nwrote {LOCKFILE.relative_to(ROOT)}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/vendor.lock.json b/scripts/vendor.lock.json new file mode 100644 index 0000000..aadedf6 --- /dev/null +++ b/scripts/vendor.lock.json @@ -0,0 +1,17 @@ +{ + "alpine.min.js": { + "sha256": "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f", + "url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js", + "version": "3.15.12" + }, + "htmx-ext-sse.js": { + "sha256": "3b5992a541619babefc4c169505af474df5c3039da51e59b96ccf9241ecd61d2", + "url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js", + "version": "2.2.4" + }, + "htmx.min.js": { + "sha256": "71ea67185bfa8c98c39d31717c6fce5d852370fcdfd129db4543774d3145c0de", + "url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js", + "version": "2.0.10" + } +} diff --git a/src/lembas/api/admin.py b/src/lembas/api/admin.py new file mode 100644 index 0000000..214c68a --- /dev/null +++ b/src/lembas/api/admin.py @@ -0,0 +1,200 @@ +"""Administration: OpenAI-compatible connections and their models.""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from fastapi import APIRouter, Form, HTTPException, Request, Response, status +from fastapi.responses import RedirectResponse +from sqlalchemy import func, select +from sqlalchemy.orm import Session as DBSession + +from lembas.api.deps import AdminUser, Db +from lembas.db.models import Connection, Model +from lembas.services.crypto import decrypt, encrypt, mask +from lembas.services.llm.openai_client import Endpoint, LLMError, list_models +from lembas.web.templating import render + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin", tags=["admin"]) + +# Sent back in place of a stored key. If a submitted key still equals this, the +# admin did not touch the field and the existing key must be kept -- otherwise +# saving a name change would silently wipe the credential. +UNCHANGED_SENTINEL = "•" * 12 + + +def _connection(db: DBSession, connection_id: str) -> Connection: + connection = db.get(Connection, connection_id) + if connection is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.") + return connection + + +def _connections(db: DBSession) -> list[Connection]: + return list(db.scalars(select(Connection).order_by(Connection.position, Connection.name))) + + +@router.get("") +async def admin_home(user: AdminUser): + return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/connections") +async def connections_page(request: Request, db: Db, user: AdminUser, message: str = ""): + connections = _connections(db) + return render( + request, + "admin/connections.html", + { + "connections": connections, + "masked": {c.id: mask(decrypt(c.api_key_encrypted)) for c in connections}, + "model_counts": { + c.id: sum(1 for m in c.models if m.enabled) for c in connections + }, + "message": message, + "unchanged": UNCHANGED_SENTINEL, + }, + ) + + +@router.post("/connections") +async def create_connection( + db: Db, + user: AdminUser, + name: str = Form(...), + base_url: str = Form(...), + api_key: str = Form(""), +) -> Response: + base_url = base_url.strip().rstrip("/") + if not base_url.startswith(("http://", "https://")): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "The base URL must start with http:// or https://", + ) + + position = db.scalar(select(func.coalesce(func.max(Connection.position), -1))) + 1 + connection = Connection( + name=name.strip()[:120] or "Connection", + base_url=base_url, + api_key_encrypted=encrypt(api_key.strip()), + position=position, + ) + db.add(connection) + db.commit() + + # Discover models immediately: a connection that lists nothing is + # indistinguishable from a broken one, and finding out now is the point. + await _refresh_models(db, connection) + return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/connections/{connection_id}") +async def update_connection( + db: Db, + user: AdminUser, + connection_id: str, + name: str = Form(...), + base_url: str = Form(...), + api_key: str = Form(""), + enabled: bool = Form(False), +) -> Response: + connection = _connection(db, connection_id) + connection.name = name.strip()[:120] or connection.name + connection.base_url = base_url.strip().rstrip("/") + connection.enabled = enabled + + submitted = api_key.strip() + if submitted and submitted != UNCHANGED_SENTINEL: + connection.api_key_encrypted = encrypt(submitted) + elif not submitted: + # An explicitly emptied field means "this endpoint needs no key". + connection.api_key_encrypted = "" + + db.commit() + return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/connections/{connection_id}/test") +async def test_connection( + request: Request, db: Db, user: AdminUser, connection_id: str +) -> Response: + """Contact the endpoint and refresh its model list.""" + connection = _connection(db, connection_id) + count, error = await _refresh_models(db, connection) + + message = ( + f"{connection.name}: {error}" + if error + else f"{connection.name}: found {count} model{'s' if count != 1 else ''}." + ) + return render( + request, + "admin/_connection_row.html", + { + "connection": connection, + "masked": mask(decrypt(connection.api_key_encrypted)), + "message": message, + "message_kind": "error" if error else "success", + "unchanged": UNCHANGED_SENTINEL, + }, + ) + + +async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, str]: + """Sync the cached model list. Returns (count, error message).""" + try: + discovered = await list_models(Endpoint.from_connection(connection)) + except LLMError as exc: + connection.last_error = exc.message + connection.last_checked_at = datetime.now(UTC) + db.commit() + return 0, exc.message + + existing = {model.model_id: model for model in connection.models} + seen: set[str] = set() + + for entry in discovered: + model_id = str(entry["id"])[:300] + seen.add(model_id) + if model_id in existing: + continue + db.add(Model(connection_id=connection.id, model_id=model_id)) + + # Models that vanished upstream are dropped, so the picker never offers + # something the endpoint will reject. + for model_id, model in existing.items(): + if model_id not in seen: + db.delete(model) + + connection.last_error = "" + connection.last_checked_at = datetime.now(UTC) + db.commit() + log.info("connection %s: %d models", connection.name, len(seen)) + return len(seen), "" + + +@router.post("/connections/{connection_id}/delete") +async def delete_connection(db: Db, user: AdminUser, connection_id: str) -> Response: + connection = _connection(db, connection_id) + db.delete(connection) + db.commit() + return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/models") +async def models_page(request: Request, db: Db, user: AdminUser): + connections = _connections(db) + return render(request, "admin/models.html", {"connections": connections}) + + +@router.post("/models/{model_id}/toggle") +async def toggle_model(db: Db, user: AdminUser, model_id: str) -> Response: + model = db.get(Model, model_id) + if model is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.") + model.enabled = not model.enabled + db.commit() + return RedirectResponse("/admin/models", status_code=status.HTTP_303_SEE_OTHER) diff --git a/src/lembas/api/auth.py b/src/lembas/api/auth.py new file mode 100644 index 0000000..65d3677 --- /dev/null +++ b/src/lembas/api/auth.py @@ -0,0 +1,186 @@ +"""Registration, sign-in and sign-out.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Form, Request, Response, status +from fastapi.responses import RedirectResponse +from sqlalchemy import func, select + +from lembas.api.deps import CurrentUser, Db +from lembas.config import settings +from lembas.db.models import ROLE_ADMIN, ROLE_USER, User +from lembas.security.passwords import hash_password, validate_password, verify_password +from lembas.security.sessions import COOKIE_NAME, create_session, revoke_session +from lembas.web.templating import render + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _no_users_yet(db: Db) -> bool: + return db.scalar(select(func.count()).select_from(User)) == 0 + + +def _set_session_cookie(response: Response, token: str) -> None: + response.set_cookie( + COOKIE_NAME, + token, + max_age=settings.session_ttl, + httponly=True, + # Lax is what makes this application CSRF-safe without tokens: the + # cookie is not sent on cross-site POSTs, and every mutating route here + # is a POST. Do not relax to "none". + samesite="lax", + # Only over HTTPS when the deployment is not plain local http. Marking + # it secure on http would silently break sign-in for a LAN install. + secure=False, + path="/", + ) + + +def _safe_next(raw: str | None) -> str: + """Reject open redirects: only same-origin absolute paths are allowed.""" + if not raw or not raw.startswith("/") or raw.startswith("//"): + return "/" + return raw + + +@router.get("/login") +async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/"): + if user is not None: + return RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER) + # An empty database means this install has never been set up. Send the + # first visitor straight to registration rather than to a login form they + # cannot possibly satisfy. + if _no_users_yet(db): + return RedirectResponse("/auth/register", status_code=status.HTTP_303_SEE_OTHER) + return render(request, "auth/login.html", {"next": _safe_next(next)}) + + +@router.post("/login") +async def login( + request: Request, + db: Db, + email: str = Form(...), + password: str = Form(...), + next: str = Form("/"), +): + email = email.strip().lower() + user = db.scalar(select(User).where(User.email == email)) + + # One message for "no such account" and "wrong password" alike, so the form + # cannot be used to discover which addresses are registered. + if user is None or not verify_password(password, user.password_hash): + log.info("failed sign-in for %s", email) + return render( + request, + "auth/login.html", + {"error": "That email and password do not match.", "email": email, + "next": _safe_next(next)}, + status_code=status.HTTP_401_UNAUTHORIZED, + ) + + if not user.active: + return render( + request, + "auth/login.html", + {"error": "This account has been deactivated. Ask an administrator.", + "email": email, "next": _safe_next(next)}, + status_code=status.HTTP_403_FORBIDDEN, + ) + + token = create_session( + db, + user, + user_agent=request.headers.get("user-agent", ""), + ip_address=request.client.host if request.client else "", + ) + response = RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER) + _set_session_cookie(response, token) + return response + + +@router.get("/register") +async def register_form(request: Request, db: Db, user: CurrentUser): + if user is not None: + return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + first_run = _no_users_yet(db) + if not first_run and not settings.allow_signup: + return render( + request, + "auth/login.html", + {"error": "Registration is closed. Ask an administrator for an account."}, + status_code=status.HTTP_403_FORBIDDEN, + ) + return render(request, "auth/register.html", {"first_run": first_run}) + + +@router.post("/register") +async def register( + request: Request, + db: Db, + name: str = Form(...), + email: str = Form(...), + password: str = Form(...), +): + first_run = _no_users_yet(db) + if not first_run and not settings.allow_signup: + return render( + request, + "auth/login.html", + {"error": "Registration is closed. Ask an administrator for an account."}, + status_code=status.HTTP_403_FORBIDDEN, + ) + + name = name.strip() + email = email.strip().lower() + + def fail(message: str) -> Response: + return render( + request, + "auth/register.html", + {"error": message, "name": name, "email": email, "first_run": first_run}, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + if not name: + return fail("Please enter a name.") + if "@" not in email or "." not in email.split("@")[-1]: + return fail("Please enter a valid email address.") + if (problem := validate_password(password)) is not None: + return fail(problem) + if db.scalar(select(User).where(User.email == email)) is not None: + return fail("An account with that email already exists.") + + # Whoever sets the instance up owns it. Everyone after that is a plain user + # until an admin says otherwise. + user = User( + name=name, + email=email, + password_hash=hash_password(password), + role=ROLE_ADMIN if first_run else ROLE_USER, + ) + db.add(user) + db.commit() + log.info("registered %s as %s", email, user.role) + + token = create_session( + db, + user, + user_agent=request.headers.get("user-agent", ""), + ip_address=request.client.host if request.client else "", + ) + response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) + _set_session_cookie(response, token) + return response + + +@router.post("/logout") +async def logout(request: Request, db: Db): + revoke_session(db, request.cookies.get(COOKIE_NAME)) + response = RedirectResponse("/auth/login", status_code=status.HTTP_303_SEE_OTHER) + response.delete_cookie(COOKIE_NAME, path="/") + return response diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py new file mode 100644 index 0000000..1bdaf43 --- /dev/null +++ b/src/lembas/api/chats.py @@ -0,0 +1,290 @@ +"""Chat creation, messaging and the streaming reply endpoint.""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import AsyncIterator + +from fastapi import APIRouter, Form, HTTPException, Request, status +from fastapi.responses import HTMLResponse, Response, StreamingResponse +from sqlalchemy.orm import Session as DBSession + +from lembas.api.deps import Db, RequiredUser +from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User +from lembas.db.session import session_scope +from lembas.services import chat as chat_service +from lembas.services import sse +from lembas.services.llm.openai_client import LLMError, delta_text, stream_chat +from lembas.services.markdown import escape_text, render_markdown +from lembas.web.templating import render, templates + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/chats", tags=["chats"]) + + +def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat: + chat = db.get(Chat, chat_id) + # 404 rather than 403 for someone else's chat: whether a given id exists is + # not information this endpoint should hand out. + if chat is None or chat.user_id != user_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.") + return chat + + +@router.post("") +async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) -> Response: + chosen = chat_service.default_model(db) + chat = Chat( + user_id=user.id, + folder_id=folder_id or None, + model_id=chosen[0] if chosen else "", + connection_id=chosen[1] if chosen else None, + ) + db.add(chat) + db.commit() + + # HX-Redirect rather than a swap: a new chat is a new URL, and the address + # bar has to follow so the chat can be reloaded or bookmarked. + response = Response(status_code=status.HTTP_204_NO_CONTENT) + response.headers["HX-Redirect"] = f"/chat/{chat.id}" + return response + + +@router.post("/{chat_id}/messages") +async def post_message( + request: Request, + db: Db, + user: RequiredUser, + chat_id: str, + content: str = Form(...), +) -> Response: + """Persist the user's turn and hand back the pair of bubbles. + + The assistant bubble comes back empty, carrying the sse-connect attribute + that opens the stream below. Splitting it this way means the POST returns + immediately and the slow part is a separate, resumable connection. + """ + chat = _owned_chat(db, chat_id, user.id) + + content = content.strip() + if not content: + return Response(status_code=status.HTTP_204_NO_CONTENT) + + user_message = chat_service.create_message(db, chat, ROLE_USER, content) + assistant_message = chat_service.create_message( + db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id + ) + + # `user` is required by the shared message template, which renders both + # roles; without it the user bubble's initial blows up. + return templates.TemplateResponse( + request, + "chat/_turn.html", + { + "request": request, + "user_message": user_message, + "assistant_message": assistant_message, + "chat": chat, + "user": user, + }, + ) + + +@router.get("/{chat_id}/messages/{message_id}/stream") +async def stream_message( + db: Db, + user: RequiredUser, + chat_id: str, + message_id: str, +) -> Response: + """Stream the assistant's reply as server-sent events. + + Emits `token` events carrying escaped text, then a single `done` event + carrying the finished bubble rendered from Markdown, then `close`. + """ + chat = _owned_chat(db, chat_id, user.id) + 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 StreamingResponse( + _generate(chat.id, message.id), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + # nginx buffers proxied responses by default, which turns a stream + # into one delivery at the end. This is the documented opt-out. + "X-Accel-Buffering": "no", + }, + ) + + +async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]: + """Drive one completion and frame it as SSE. + + Opens its own database session rather than using the request's: streaming + outlives the request handler, and the dependency-scoped session may already + be closed by the time the first token arrives. + """ + accumulated: list[str] = [] + error: str | None = None + + with session_scope() as db: + chat = db.get(Chat, chat_id) + message = db.get(Message, message_id) + if chat is None or message is None: + yield sse.event("close", "") + return + + first_user_text = "" + try: + endpoint, model_id = chat_service.resolve_endpoint(db, chat) + payload = chat_service.build_request(db, chat, upto=message) + first_user_text = next( + (m["content"] for m in reversed(payload["messages"]) if m["role"] == ROLE_USER), + "", + ) + + async for chunk in stream_chat(endpoint, payload): + text = delta_text(chunk) + if not text: + continue + accumulated.append(text) + yield sse.event("token", escape_text(text)) + # Hand control back so the event is flushed rather than + # batched behind a fast generator. + await asyncio.sleep(0) + + except LLMError as exc: + error = exc.message + log.info("generation failed for chat %s: %s", chat_id, exc.message) + except asyncio.CancelledError: + # The reader navigated away or closed the tab. Keep whatever was + # produced so the partial reply is still there on reload. + message.content = "".join(accumulated) + message.complete = True + db.commit() + raise + except Exception as exc: # noqa: BLE001 - must not kill the stream silently + error = "Something went wrong while generating this reply." + log.exception("unexpected generation failure for chat %s: %s", chat_id, exc) + + message.content = "".join(accumulated) + message.error = error or "" + message.complete = True + + if not chat.title_generated and (accumulated or error): + chat.title = ( + await chat_service.generate_title( + endpoint, model_id, first_user_text, message.content + ) + if not error and first_user_text + else chat_service.fallback_title(first_user_text) + ) + chat.title_generated = True + + db.commit() + + final_html = templates.get_template("chat/_message.html").render( + { + "message": message, + "body_html": render_markdown(message.content), + "chat": chat, + # Passed even though an assistant bubble never reads it: the + # template shares both roles, and a missing `user` would only + # blow up on whichever branch is not being exercised here. + "user": db.get(User, chat.user_id), + } + ) + title_html = templates.get_template("chat/_title_oob.html").render( + {"chat": chat} + ) + + yield sse.event("done", final_html + title_html) + yield sse.event("close", "") + + +@router.patch("/{chat_id}") +async def update_chat( + db: Db, + user: RequiredUser, + chat_id: str, + title: str | None = Form(None), + folder_id: str | None = Form(None), + model_id: str | None = Form(None), +) -> Response: + chat = _owned_chat(db, chat_id, user.id) + + if title is not None: + cleaned = title.strip()[:300] + if cleaned: + chat.title = cleaned + # An explicit rename must not be overwritten by auto-titling later. + chat.title_generated = True + + if folder_id is not None: + chat.folder_id = folder_id or None + + if model_id is not None and model_id: + chat.model_id = model_id + match = next( + (m for m in chat_service.available_models(db) if m.model_id == model_id), None + ) + chat.connection_id = match.connection_id if match else None + + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.delete("/{chat_id}") +async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response: + chat = _owned_chat(db, chat_id, user.id) + db.delete(chat) + db.commit() + + response = Response(status_code=status.HTTP_204_NO_CONTENT) + response.headers["HX-Redirect"] = "/chat" + return response + + +@router.get("/{chat_id}/messages/{message_id}/raw") +async def raw_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> HTMLResponse: + """The unrendered Markdown of a message, for the copy button.""" + _owned_chat(db, chat_id, user.id) + 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 HTMLResponse(escape_text(message.content)) + + +@router.post("/{chat_id}/messages/{message_id}/regenerate") +async def regenerate( + request: Request, + db: Db, + user: RequiredUser, + chat_id: str, + message_id: str, +) -> Response: + """Discard an assistant reply and produce a fresh one in its place.""" + chat = _owned_chat(db, chat_id, user.id) + message = db.get(Message, message_id) + if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.") + + message.content = "" + message.error = "" + message.complete = False + message.model_id = chat.model_id + db.commit() + + return templates.TemplateResponse( + request, + "chat/_message.html", + {"request": request, "message": message, "chat": chat, "body_html": "", "user": user}, + ) + + +__all__ = ["render", "router"] diff --git a/src/lembas/api/folders.py b/src/lembas/api/folders.py new file mode 100644 index 0000000..606da24 --- /dev/null +++ b/src/lembas/api/folders.py @@ -0,0 +1,118 @@ +"""Folder management.""" + +from __future__ import annotations + +from fastapi import APIRouter, Form, HTTPException, Response, status +from sqlalchemy.orm import Session as DBSession + +from lembas.api.deps import Db, RequiredUser +from lembas.db.models import Folder + +router = APIRouter(prefix="/api/folders", tags=["folders"]) + +MAX_DEPTH = 8 + + +def _owned_folder(db: DBSession, folder_id: str, user_id: str) -> Folder: + folder = db.get(Folder, folder_id) + if folder is None or folder.user_id != user_id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.") + return folder + + +def _depth_of(db: DBSession, folder: Folder | None) -> int: + depth = 0 + seen: set[str] = set() + while folder is not None and folder.id not in seen: + seen.add(folder.id) + depth += 1 + folder = db.get(Folder, folder.parent_id) if folder.parent_id else None + return depth + + +def _refresh_sidebar() -> Response: + """Tell the browser to reload so the tree re-renders. + + The folder tree is recursive and a change can move any part of it, so + re-rendering the whole sidebar server-side is both simpler and less + error-prone than trying to patch individual nodes over the wire. + """ + response = Response(status_code=status.HTTP_204_NO_CONTENT) + response.headers["HX-Refresh"] = "true" + return response + + +@router.post("") +async def create_folder( + db: Db, + user: RequiredUser, + name: str = Form("New folder"), + parent_id: str = Form(""), +) -> Response: + parent = _owned_folder(db, parent_id, user.id) if parent_id else None + + # A cap on nesting, so a runaway client cannot build a tree deep enough to + # blow the recursion limit in the template. + if parent is not None and _depth_of(db, parent) >= MAX_DEPTH: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"Folders cannot be nested more than {MAX_DEPTH} deep.", + ) + + db.add( + Folder( + user_id=user.id, + name=name.strip()[:200] or "New folder", + parent_id=parent.id if parent else None, + ) + ) + db.commit() + return _refresh_sidebar() + + +@router.patch("/{folder_id}") +async def update_folder( + db: Db, + user: RequiredUser, + folder_id: str, + name: str | None = Form(None), + parent_id: str | None = Form(None), + collapsed: bool | None = Form(None), +) -> Response: + folder = _owned_folder(db, folder_id, user.id) + + if name is not None and name.strip(): + folder.name = name.strip()[:200] + + if parent_id is not None: + new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None + # Reparenting a folder into its own subtree would detach that subtree + # from the root and make it unreachable. + cursor = new_parent + while cursor is not None: + if cursor.id == folder.id: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "A folder cannot be moved inside itself.", + ) + cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None + folder.parent_id = new_parent.id if new_parent else None + + if collapsed is not None: + folder.collapsed = collapsed + + db.commit() + return _refresh_sidebar() + + +@router.delete("/{folder_id}") +async def delete_folder(db: Db, user: RequiredUser, folder_id: str) -> Response: + """Delete a folder. Child folders go with it; chats do not. + + Chats fall back to the unfiled list (the FK is ON DELETE SET NULL), because + losing a conversation to a mis-clicked folder delete is unforgivable. + """ + folder = _owned_folder(db, folder_id, user.id) + db.delete(folder) + db.commit() + return _refresh_sidebar() diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py new file mode 100644 index 0000000..e9f9675 --- /dev/null +++ b/src/lembas/api/pages.py @@ -0,0 +1,109 @@ +"""Full-page routes: the chat shell and the user's own settings.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request, status +from fastapi.responses import RedirectResponse +from sqlalchemy import select +from sqlalchemy.orm import Session as DBSession + +from lembas.api.deps import Db, RequiredUser +from lembas.db.models import Chat, Folder, Message, User +from lembas.services import chat as chat_service +from lembas.services.markdown import render_markdown +from lembas.web.templating import render + +router = APIRouter(tags=["pages"]) + + +def _sidebar_context(db: DBSession, user: User) -> dict: + """Folder tree plus the chats that belong to no folder. + + Only root folders are queried; children come through the relationship and + render recursively in the template. + """ + folders = list( + db.scalars( + select(Folder) + .where(Folder.user_id == user.id, Folder.parent_id.is_(None)) + .order_by(Folder.position, Folder.name) + ) + ) + unfiled = list( + db.scalars( + select(Chat) + .where( + Chat.user_id == user.id, + Chat.folder_id.is_(None), + Chat.archived.is_(False), + ) + .order_by(Chat.pinned.desc(), Chat.updated_at.desc()) + ) + ) + return {"folders": folders, "unfiled_chats": unfiled} + + +@router.get("/") +async def home(user: RequiredUser): + return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/chat") +async def chat_index(request: Request, db: Db, user: RequiredUser): + return render( + request, + "chat/index.html", + { + "chat": None, + "messages": [], + "models": chat_service.available_models(db), + **_sidebar_context(db, user), + }, + ) + + +@router.get("/chat/{chat_id}") +async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str): + 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.") + + messages = list( + db.scalars( + select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at) + ) + ) + + # Markdown is rendered once here rather than in the template so the same + # helper produces the page and the streamed final frame -- one code path, + # no chance of the two disagreeing. + bodies = { + message.id: render_markdown(message.content) + for message in messages + if message.role == "assistant" and message.content + } + + return render( + request, + "chat/index.html", + { + "chat": chat, + "messages": messages, + "bodies": bodies, + "models": chat_service.available_models(db), + **_sidebar_context(db, user), + }, + ) + + +@router.get("/settings") +async def settings_page(request: Request, db: Db, user: RequiredUser): + return render( + request, + "settings.html", + { + "chat": None, + "models": chat_service.available_models(db), + **_sidebar_context(db, user), + }, + ) diff --git a/src/lembas/api/preferences.py b/src/lembas/api/preferences.py new file mode 100644 index 0000000..8f08639 --- /dev/null +++ b/src/lembas/api/preferences.py @@ -0,0 +1,29 @@ +"""Per-user preferences set from the browser.""" + +from __future__ import annotations + +from fastapi import APIRouter, Body + +from lembas.api.deps import Db, RequiredUser + +router = APIRouter(prefix="/api/preferences", tags=["preferences"]) + +THEMES = ("moria", "shire") + + +@router.post("/theme") +async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=True)) -> dict: + """Mirror the browser's theme choice onto the account. + + localStorage is the source of truth for the current tab; this is what makes + the choice follow the user to another browser, and what lets the server + render the right theme on first paint instead of flashing the default. + """ + if theme not in THEMES: + return {"ok": False, "detail": "Unknown theme."} + + # Replaced rather than mutated in place: SQLAlchemy only reliably detects + # a change to a JSON column when the whole value is reassigned. + user.settings_json = {**(user.settings_json or {}), "theme": theme} + db.commit() + return {"ok": True, "theme": theme} diff --git a/src/lembas/cli.py b/src/lembas/cli.py new file mode 100644 index 0000000..22c3df1 --- /dev/null +++ b/src/lembas/cli.py @@ -0,0 +1,112 @@ +"""Command line entry points.""" + +from __future__ import annotations + +import secrets as secrets_module + +import typer +import uvicorn +from sqlalchemy import func, select + +from lembas import __version__ +from lembas.config import settings + +app = typer.Typer( + help="LLeMbas - a Middle-earth themed web UI for your language models.", + no_args_is_help=True, + add_completion=False, +) + + +@app.command() +def serve( + host: str = typer.Option(None, help="Bind address. Defaults to LEMBAS_HOST."), + port: int = typer.Option(None, help="Port. Defaults to LEMBAS_PORT."), + reload: bool = typer.Option(None, "--reload/--no-reload", help="Autoreload on change."), +) -> None: + """Run the web server.""" + uvicorn.run( + "lembas.main:app", + host=host or settings.host, + port=port or settings.port, + reload=settings.reload if reload is None else reload, + log_level=settings.log_level, + # Access logs duplicate what the application already logs and drown out + # anything useful during development. + access_log=settings.log_level == "debug", + ) + + +@app.command("create-admin") +def create_admin( + email: str = typer.Option(..., prompt=True), + name: str = typer.Option(..., prompt=True), + password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True), +) -> None: + """Create an administrator, or promote an existing account to one. + + The web sign-up already makes the first account an admin. This is the way + back in when that account is lost, or when scripting a deployment. + """ + from lembas.db.models import ROLE_ADMIN, User + from lembas.db.session import init_db, session_scope + from lembas.security.passwords import hash_password, validate_password + + if (problem := validate_password(password)) is not None: + typer.secho(problem, fg=typer.colors.RED) + raise typer.Exit(1) + + init_db() + with session_scope() as db: + existing = db.scalar(select(User).where(User.email == email.strip().lower())) + if existing is not None: + existing.role = ROLE_ADMIN + existing.password_hash = hash_password(password) + existing.active = True + typer.secho(f"Promoted {existing.email} to administrator.", fg=typer.colors.GREEN) + return + + db.add( + User( + email=email.strip().lower(), + name=name.strip(), + password_hash=hash_password(password), + role=ROLE_ADMIN, + ) + ) + typer.secho(f"Created administrator {email}.", fg=typer.colors.GREEN) + + +@app.command("secret-key") +def secret_key() -> None: + """Print a fresh value for LEMBAS_SECRET_KEY.""" + typer.echo(secrets_module.token_urlsafe(48)) + + +@app.command() +def info() -> None: + """Show where this instance keeps its data and what is configured.""" + from lembas.db.models import Chat, Connection, User + from lembas.db.session import init_db, session_scope + + init_db() + typer.echo(f"LLeMbas {__version__}") + typer.echo(f" data directory : {settings.data_dir.resolve()}") + typer.echo(f" database : {settings.db_path.resolve()}") + typer.echo(f" bind : {settings.host}:{settings.port}") + typer.echo(f" default theme : {settings.default_theme}") + typer.echo(f" signup open : {settings.allow_signup}") + if settings.secret_key_is_ephemeral: + typer.secho( + " secret key : GENERATED (set LEMBAS_SECRET_KEY for a real install)", + fg=typer.colors.YELLOW, + ) + + with session_scope() as db: + for label, model in (("users", User), ("connections", Connection), ("chats", Chat)): + count = db.scalar(select(func.count()).select_from(model)) + typer.echo(f" {label:<15}: {count}") + + +if __name__ == "__main__": + app() diff --git a/src/lembas/config.py b/src/lembas/config.py index 5517cc4..59e7a66 100644 --- a/src/lembas/config.py +++ b/src/lembas/config.py @@ -7,7 +7,7 @@ from functools import lru_cache from pathlib import Path from typing import Literal -from pydantic import Field, field_validator +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -22,6 +22,10 @@ class Settings(BaseSettings): ) secret_key: str = Field(default="") + # Set when no LEMBAS_SECRET_KEY was supplied and one had to be invented. + # main.py warns about it at startup; see the validator below. + secret_key_is_ephemeral: bool = Field(default=False, exclude=True) + data_dir: Path = Path("./data") host: str = "127.0.0.1" @@ -34,13 +38,15 @@ class Settings(BaseSettings): session_ttl: int = 60 * 60 * 24 * 30 request_timeout: float = 300.0 - @field_validator("secret_key") - @classmethod - def _generate_secret_if_absent(cls, v: str) -> str: - # A generated key lets `lembas serve` work out of the box, but it changes - # on every restart: sessions drop and stored API keys become unreadable. - # main.py warns loudly about this. Never rely on it in production. - return v or secrets.token_urlsafe(48) + @model_validator(mode="after") + def _generate_secret_if_absent(self) -> Settings: + # A generated key lets `lembas serve` work with no configuration at all, + # but it changes on every restart: sessions drop and stored API keys + # become unreadable. Flagged so startup can warn. Never use in anger. + if not self.secret_key: + self.secret_key = secrets.token_urlsafe(48) + self.secret_key_is_ephemeral = True + return self @property def db_path(self) -> Path: diff --git a/src/lembas/main.py b/src/lembas/main.py new file mode 100644 index 0000000..7d17d0e --- /dev/null +++ b/src/lembas/main.py @@ -0,0 +1,128 @@ +"""Application factory, lifespan and error handling.""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse, Response +from fastapi.staticfiles import StaticFiles +from starlette.exceptions import HTTPException as StarletteHTTPException + +from lembas import __version__ +from lembas.api import admin, auth, chats, folders, pages, preferences +from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect +from lembas.config import settings +from lembas.db.session import init_db +from lembas.web.templating import STATIC_DIR, render + +log = logging.getLogger("lembas") + + +def configure_logging() -> None: + logging.basicConfig( + level=settings.log_level.upper(), + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + configure_logging() + settings.ensure_dirs() + init_db() + + if settings.secret_key_is_ephemeral: + log.warning( + "No LEMBAS_SECRET_KEY set, so a temporary one was generated. Every " + "restart will sign all users out and make stored API keys " + "unreadable. Generate a permanent key with:\n" + ' python -c "import secrets; print(secrets.token_urlsafe(48))"' + ) + + log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port) + log.info("data directory: %s", settings.data_dir.resolve()) + yield + log.info("LLeMbas stopped") + + +def create_app() -> FastAPI: + app = FastAPI( + title="LLeMbas", + version=__version__, + lifespan=lifespan, + # The API is an implementation detail of the UI, not a product surface. + docs_url="/api/docs" if settings.log_level == "debug" else None, + redoc_url=None, + ) + + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + app.include_router(pages.router) + app.include_router(auth.router) + app.include_router(preferences.router) + app.include_router(chats.router) + app.include_router(folders.router) + app.include_router(admin.router) + + register_error_handlers(app) + return app + + +def register_error_handlers(app: FastAPI) -> None: + @app.exception_handler(RedirectToLogin) + async def _not_signed_in(request: Request, exc: RedirectToLogin) -> Response: + # An htmx request must not swap a login page into a fragment of the + # chat UI, so tell the browser to navigate instead. + if is_htmx(request): + response = Response(status_code=status.HTTP_204_NO_CONTENT) + response.headers["HX-Redirect"] = "/auth/login" + return response + return login_redirect(exc.next_url) + + @app.exception_handler(StarletteHTTPException) + async def _http_error(request: Request, exc: StarletteHTTPException) -> Response: + # JSON callers and htmx fragments want the bare status; humans loading a + # page want a themed page they can navigate away from. + wants_page = "text/html" in request.headers.get("accept", "") and not is_htmx(request) + if not wants_page: + return JSONResponse({"detail": exc.detail}, status_code=exc.status_code) + + return render( + request, + "error.html", + { + "status_code": exc.status_code, + "detail": exc.detail, + "flavour": ERROR_FLAVOUR.get(exc.status_code, ERROR_FLAVOUR[500]), + }, + status_code=exc.status_code, + ) + + @app.exception_handler(Exception) + async def _unhandled(request: Request, exc: Exception) -> Response: + log.exception("unhandled error at %s", request.url.path) + if is_htmx(request) or "text/html" not in request.headers.get("accept", ""): + return JSONResponse({"detail": "Internal server error"}, status_code=500) + return render( + request, + "error.html", + {"status_code": 500, "detail": "Something went wrong.", + "flavour": ERROR_FLAVOUR[500]}, + status_code=500, + ) + + +# Flavour lives in error pages, empty states and theme names -- never in the +# functional UI. See CLAUDE.md. +ERROR_FLAVOUR = { + 403: "Speak, friend, and enter. This door is not yours to open.", + 404: "Not all those who wander are lost. This page, however, is.", + 500: "The Road goes ever on, but this stretch of it has washed out.", +} + + +app = create_app() diff --git a/src/lembas/security/passwords.py b/src/lembas/security/passwords.py index 6e8b651..1220e67 100644 --- a/src/lembas/security/passwords.py +++ b/src/lembas/security/passwords.py @@ -8,7 +8,7 @@ defaults tighten in a future release. from __future__ import annotations from argon2 import PasswordHasher -from argon2.exceptions import InvalidHashError, VerifyMismatchError, VerificationError +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError _hasher = PasswordHasher() diff --git a/src/lembas/services/chat.py b/src/lembas/services/chat.py new file mode 100644 index 0000000..5e084e2 --- /dev/null +++ b/src/lembas/services/chat.py @@ -0,0 +1,225 @@ +"""Chat orchestration: building requests, streaming replies, naming chats.""" + +from __future__ import annotations + +import logging +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session as DBSession + +from lembas.db.models import ( + ROLE_ASSISTANT, + ROLE_SYSTEM, + ROLE_USER, + Chat, + Connection, + Message, + Model, +) +from lembas.services.llm.openai_client import Endpoint, LLMError, complete + +log = logging.getLogger(__name__) + +# Sampling keys forwarded upstream. Anything else a user puts in params_json is +# ignored rather than passed through, so a typo cannot produce a 400 from the +# provider that looks like a LLeMbas bug. +FORWARDED_PARAMS = frozenset( + {"temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty", + "seed", "stop"} +) + +MAX_TITLE_LENGTH = 60 + + +def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]: + """Find the connection and model a chat should use. + + Chats store the model id as text rather than a foreign key so history + survives an admin deleting a connection, which means the mapping back to a + live connection has to be resolved at send time and can legitimately fail. + """ + if not chat.model_id: + raise LLMError("This chat has no model selected.") + + connection: Connection | None = None + if chat.connection_id: + connection = db.get(Connection, chat.connection_id) + + if connection is None or not connection.enabled: + # The original connection is gone or disabled. Any enabled connection + # still offering this model id will do. + model = db.scalar( + select(Model) + .join(Connection) + .where( + Model.model_id == chat.model_id, + Model.enabled.is_(True), + Connection.enabled.is_(True), + ) + .order_by(Connection.position) + ) + if model is None: + raise LLMError( + f"No enabled connection currently offers the model " + f"'{chat.model_id}'. Pick another model for this chat." + ) + connection = model.connection + chat.connection_id = connection.id + db.commit() + + return Endpoint.from_connection(connection), chat.model_id + + +def build_messages(db: DBSession, chat: Chat, *, upto: Message | None = None) -> list[dict]: + """Assemble the message list to send upstream. + + `upto` excludes the placeholder assistant row being generated into, and + everything after it. + """ + payload: list[dict[str, Any]] = [] + if chat.system_prompt.strip(): + payload.append({"role": ROLE_SYSTEM, "content": chat.system_prompt.strip()}) + + history = db.scalars( + select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at) + ).all() + + for message in history: + if upto is not None and message.id == upto.id: + break + # Skip turns that failed or produced nothing: sending an empty + # assistant message upsets several providers. + if message.error or not message.content.strip(): + continue + payload.append({"role": message.role, "content": message.content}) + + return payload + + +def build_request(db: DBSession, chat: Chat, *, upto: Message | None = None) -> dict[str, Any]: + params = { + key: value + for key, value in (chat.params_json or {}).items() + if key in FORWARDED_PARAMS and value not in (None, "") + } + return { + "model": chat.model_id, + "messages": build_messages(db, chat, upto=upto), + **params, + } + + +def default_model(db: DBSession) -> tuple[str, str] | None: + """First enabled model on the first enabled connection, or None.""" + model = db.scalar( + select(Model) + .join(Connection) + .where(Model.enabled.is_(True), Connection.enabled.is_(True)) + .order_by(Connection.position, Model.model_id) + ) + if model is None: + return None + return model.model_id, model.connection_id + + +def available_models(db: DBSession) -> list[Model]: + return list( + db.scalars( + select(Model) + .join(Connection) + .where(Model.enabled.is_(True), Connection.enabled.is_(True)) + .order_by(Connection.position, Model.model_id) + ) + ) + + +def fallback_title(text: str) -> str: + """Derive a chat title from the opening message, without calling a model.""" + cleaned = " ".join(text.split()) + if not cleaned: + return "New chat" + if len(cleaned) <= MAX_TITLE_LENGTH: + return cleaned + # Prefer a word boundary, but only if it does not cut the title in half. + clipped = cleaned[:MAX_TITLE_LENGTH] + space = clipped.rfind(" ") + if space > MAX_TITLE_LENGTH * 0.6: + clipped = clipped[:space] + return clipped.rstrip(" ,.;:-") + "…" + + +async def generate_title(endpoint: Endpoint, model_id: str, question: str, answer: str) -> str: + """Ask the model for a short chat title. + + Best-effort by design: any failure falls back to trimming the first + message. Naming a chat is never worth surfacing an error for. + """ + prompt = ( + "Summarise this exchange as a title of at most six words. " + "Reply with the title alone: no quotes, no punctuation at the end, " + "no preamble.\n\n" + f"User: {question[:500]}\n\nAssistant: {answer[:500]}" + ) + try: + raw = await complete( + endpoint, + { + "model": model_id, + "messages": [{"role": ROLE_USER, "content": prompt}], + "max_tokens": 24, + "temperature": 0.2, + }, + ) + except LLMError as exc: + log.debug("auto-title failed, using fallback: %s", exc) + return fallback_title(question) + + title = " ".join(raw.split()).strip().strip('"“”\'') + # Small models sometimes ignore the instruction and answer the question + # instead; an over-long reply is a better signal of that than anything else. + if not title or len(title) > MAX_TITLE_LENGTH * 1.5: + return fallback_title(question) + return title[:MAX_TITLE_LENGTH] + + +def create_message( + db: DBSession, + chat: Chat, + role: str, + content: str = "", + *, + complete_: bool = True, + model_id: str = "", +) -> Message: + message = Message( + chat_id=chat.id, + role=role, + content=content, + complete=complete_, + model_id=model_id, + ) + db.add(message) + db.commit() + return message + + +def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]: + query = select(Chat).where(Chat.user_id == user_id, Chat.archived.is_(False)) + if folder_id is not None: + query = query.where(Chat.folder_id == folder_id) + return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc()))) + + +__all__ = [ + "ROLE_ASSISTANT", + "ROLE_USER", + "available_models", + "build_request", + "create_message", + "default_model", + "fallback_title", + "generate_title", + "resolve_endpoint", + "user_chats", +] diff --git a/src/lembas/services/llm/openai_client.py b/src/lembas/services/llm/openai_client.py new file mode 100644 index 0000000..864c9c8 --- /dev/null +++ b/src/lembas/services/llm/openai_client.py @@ -0,0 +1,245 @@ +"""Client for OpenAI-compatible chat endpoints. + +Deliberately plain httpx rather than the official SDK. The target is not just +api.openai.com but LM Studio, vLLM, llama.cpp, Ollama's compatibility layer, +OpenRouter and anything else exposing /v1 -- and they differ in small ways. A +thin client passes request parameters through untouched and is tolerant about +what comes back, which is exactly what talking to all of them requires. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any + +import httpx + +from lembas.config import settings +from lembas.db.models import Connection +from lembas.services.crypto import decrypt + +log = logging.getLogger(__name__) + + +class LLMError(Exception): + """An upstream failure with a message fit to show a user. + + Every failure path in this module raises this rather than letting an httpx + or JSON exception escape, so callers have exactly one thing to catch and + the chat UI always has something intelligible to display. + """ + + def __init__(self, message: str, *, status_code: int | None = None) -> None: + super().__init__(message) + self.message = message + self.status_code = status_code + + +@dataclass(frozen=True) +class Endpoint: + """Everything needed to call a connection, with the key already decrypted. + + A frozen snapshot rather than the ORM object because streaming outlives the + request that started it, and a detached SQLAlchemy instance is a trap. + """ + + base_url: str + api_key: str + extra_headers: dict[str, str] + name: str = "" + + @classmethod + def from_connection(cls, connection: Connection) -> Endpoint: + return cls( + base_url=connection.base_url.rstrip("/"), + api_key=decrypt(connection.api_key_encrypted), + extra_headers=dict(connection.extra_headers_json or {}), + name=connection.name, + ) + + def url(self, path: str) -> str: + # Accept both "http://host:1234" and "http://host:1234/v1" so users do + # not have to guess which form this expects. + base = self.base_url + if not base.endswith("/v1") and "/v1/" not in base: + base = f"{base}/v1" + return f"{base}/{path.lstrip('/')}" + + def headers(self) -> dict[str, str]: + headers = {"Content-Type": "application/json", **self.extra_headers} + # Local endpoints frequently need no key at all; sending an empty + # bearer token makes some of them reject the request outright. + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + return headers + + +def _describe_http_error(exc: httpx.HTTPStatusError) -> str: + """Turn an upstream error response into something worth reading. + + Providers put the useful part in wildly different places, so try the common + shapes before falling back to the raw body. + """ + status = exc.response.status_code + detail = "" + try: + payload = exc.response.json() + if isinstance(payload, dict): + error = payload.get("error") + if isinstance(error, dict): + detail = error.get("message", "") + elif isinstance(error, str): + detail = error + detail = detail or payload.get("message", "") or payload.get("detail", "") + except (ValueError, json.JSONDecodeError): + detail = exc.response.text[:300] + + friendly = { + 401: "The API key was rejected.", + 403: "The API key is not permitted to use this model.", + 404: "The endpoint or model was not found.", + 429: "Rate limited by the provider.", + }.get(status) + + if friendly and detail: + return f"{friendly} {detail}" + return friendly or detail or f"The endpoint returned HTTP {status}." + + +def _wrap_transport_error(exc: httpx.RequestError, endpoint: Endpoint) -> LLMError: + if isinstance(exc, httpx.ConnectError): + return LLMError( + f"Could not reach {endpoint.base_url}. Is the endpoint running and " + f"the URL correct?" + ) + if isinstance(exc, httpx.TimeoutException): + return LLMError( + f"{endpoint.base_url} did not respond within " + f"{settings.request_timeout:.0f}s." + ) + return LLMError(f"Could not reach {endpoint.base_url}: {exc}") + + +async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]: + """Fetch the models a connection advertises via GET /v1/models.""" + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(endpoint.url("models"), headers=endpoint.headers()) + 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 + + # The spec says {"data": [...]}, but some servers return a bare list. + entries = payload.get("data", payload) if isinstance(payload, dict) else payload + if not isinstance(entries, list): + raise LLMError("The endpoint's model list was not in the expected format.") + + models = [] + for entry in entries: + if isinstance(entry, dict) and entry.get("id"): + models.append(entry) + elif isinstance(entry, str): + models.append({"id": entry}) + return models + + +async def stream_chat( + endpoint: Endpoint, + payload: dict[str, Any], +) -> AsyncIterator[dict[str, Any]]: + """Stream a chat completion, yielding each parsed SSE data object. + + Yields the raw upstream chunks; interpreting them is the caller's job. The + terminating "[DONE]" sentinel is consumed here and not yielded. + """ + body = {**payload, "stream": True} + + try: + async with ( + httpx.AsyncClient(timeout=settings.request_timeout) as client, + client.stream( + "POST", + endpoint.url("chat/completions"), + headers=endpoint.headers(), + json=body, + ) as response, + ): + if response.status_code >= 400: + # The body has not been read yet on a streaming response, and + # the error detail is in it. + await response.aread() + response.raise_for_status() + + async for line in response.aiter_lines(): + line = line.strip() + if not line or line.startswith(":"): + continue # keep-alive comment + if not line.startswith("data:"): + continue + data = line[5:].strip() + if data == "[DONE]": + return + try: + yield json.loads(data) + except json.JSONDecodeError: + # A malformed frame is not worth killing a reply over. + log.warning("skipping unparseable SSE frame: %.120s", data) + continue + + 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 + + +async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str: + """Non-streaming completion. Used for short internal calls like auto-titling.""" + body = {**payload, "stream": False} + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + endpoint.url("chat/completions"), headers=endpoint.headers(), json=body + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPStatusError as exc: + raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc + 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 + + try: + return data["choices"][0]["message"]["content"] or "" + except (KeyError, IndexError, TypeError) as exc: + raise LLMError("The endpoint returned no completion.") from exc + + +def delta_text(chunk: dict[str, Any]) -> str: + """Pull the text out of one streamed chunk, tolerating provider variation.""" + try: + choices = chunk.get("choices") or [] + if not choices: + return "" + delta = choices[0].get("delta") or {} + content = delta.get("content") + if isinstance(content, str): + return content + # Some providers send content as a list of typed parts even in deltas. + if isinstance(content, list): + return "".join( + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ) + return "" + except (AttributeError, TypeError): + return "" diff --git a/src/lembas/services/markdown.py b/src/lembas/services/markdown.py new file mode 100644 index 0000000..9311ba8 --- /dev/null +++ b/src/lembas/services/markdown.py @@ -0,0 +1,134 @@ +"""Render assistant messages from Markdown to sanitised HTML. + +Rendering happens on the server, in Python, so there is no JavaScript Markdown +library to vendor and the streamed and final views cannot disagree about how +something should look. + +The output is sanitised with nh3 (Rust ammonia). Model output is untrusted +input: it routinely contains HTML, and a model can be talked into emitting a +script tag, so this is a real boundary and not a formality. +""" + +from __future__ import annotations + +import functools +import html + +import nh3 +from markdown_it import MarkdownIt +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import get_lexer_by_name, guess_lexer +from pygments.util import ClassNotFound + +# Class-based highlighting; the colours come from theme tokens in chat.css, so +# code blocks follow the active theme instead of carrying their own palette. +_FORMATTER = HtmlFormatter(nowrap=True, classprefix="pg-") + +ALLOWED_TAGS = { + "p", "br", "hr", "div", "span", + "strong", "em", "del", "sub", "sup", "mark", + "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "li", + "blockquote", "pre", "code", + "table", "thead", "tbody", "tr", "th", "td", + "a", "img", +} + +ALLOWED_ATTRIBUTES = { + # "rel" is intentionally absent: nh3 rejects it here when link_rel is set, + # because link_rel below is what writes it. + "a": {"href", "title", "target"}, + "img": {"src", "alt", "title"}, + "code": {"class"}, + "pre": {"class"}, + "span": {"class"}, + "div": {"class"}, + "td": {"align"}, + "th": {"align"}, +} + +# javascript: and data: URLs are the obvious injection route through a link. +ALLOWED_URL_SCHEMES = {"http", "https", "mailto"} + + +def _render_fence(tokens, idx, _options, _env) -> str: + """Render a fenced code block. + + This replaces the renderer's `fence` rule outright rather than using + markdown-it's `highlight` option, because that option re-wraps whatever it + is given in
 unless the string already starts with " inside the wrapper this returns.
+    """
+    token = tokens[idx]
+    code = token.content
+    language = (token.info or "").strip().split()[0] if token.info else ""
+
+    lexer = None
+    if language:
+        try:
+            lexer = get_lexer_by_name(language, stripall=False)
+        except (ClassNotFound, ValueError):
+            lexer = None
+    elif code.strip():
+        # Guessing is only worth it for a decent sample; on two lines of text
+        # Pygments guesses confidently and wrongly.
+        try:
+            lexer = guess_lexer(code) if len(code) > 80 else None
+        except (ClassNotFound, ValueError):
+            lexer = None
+
+    if lexer is None:
+        body = nh3.clean_text(code)
+        label = language
+    else:
+        body = highlight(code, lexer, _FORMATTER)
+        label = language or (lexer.aliases[0] if lexer.aliases else "")
+
+    label_html = (
+        f'
{nh3.clean_text(label)}
' if label else "" + ) + return ( + f'
{label_html}' + f'
{body}
' + ) + + +@functools.lru_cache(maxsize=1) +def _parser() -> MarkdownIt: + md = MarkdownIt("commonmark", {"linkify": True, "typographer": False}) + md.enable(["table", "strikethrough", "linkify"]) + md.renderer.rules["fence"] = _render_fence + return md + + +def render_markdown(text: str) -> str: + """Markdown to safe HTML, ready to drop into a message bubble.""" + if not text: + return "" + + html = _parser().render(text) + return nh3.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + url_schemes=ALLOWED_URL_SCHEMES, + # Anything opened from a model's output is untrusted; noopener stops it + # reaching back through window.opener. + link_rel="nofollow noopener noreferrer", + ) + + +def escape_text(text: str) -> str: + """Escape a plain-text run for insertion as HTML element content. + + Used for user messages and for partial assistant text mid-stream, where the + content is not yet complete enough to parse as Markdown. + + html.escape rather than nh3.clean_text: escaping the three structural + characters is all that is needed for a text node, and it escapes character + by character, so escaping a stream chunk-by-chunk gives the same result as + escaping the whole string at once. nh3.clean_text also escapes spaces and + slashes, which triples the size of a streamed token for no benefit. + """ + return html.escape(text, quote=False) diff --git a/src/lembas/services/sse.py b/src/lembas/services/sse.py new file mode 100644 index 0000000..4524559 --- /dev/null +++ b/src/lembas/services/sse.py @@ -0,0 +1,24 @@ +"""Server-sent event framing. + +Small, but worth isolating: getting the wire format subtly wrong is the usual +cause of a stream that "works" until a model emits a newline. +""" + +from __future__ import annotations + +# Every 15s of silence, so proxies that kill idle connections (nginx defaults +# to 60s) do not drop a stream while a model is still thinking. +KEEPALIVE = ": keepalive\n\n" + + +def event(name: str, data: str) -> str: + """Frame one SSE event. + + A payload containing newlines must be split across several `data:` lines; + the browser rejoins them with "\\n". Sending a raw newline inside a single + data line silently truncates the event, which is exactly what happens the + first time a model emits a code block. + """ + lines = data.split("\n") + body = "".join(f"data: {line}\n" for line in lines) + return f"event: {name}\n{body}\n" diff --git a/src/lembas/web/static/css/admin.css b/src/lembas/web/static/css/admin.css new file mode 100644 index 0000000..f5547b3 --- /dev/null +++ b/src/lembas/web/static/css/admin.css @@ -0,0 +1,113 @@ +/* Administration screens. */ + +.admin-scroll { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +.admin-page { + max-width: 46rem; + margin: 0 auto; + padding: var(--sp-6) var(--sp-5) var(--sp-12); +} + +.admin-lede { + color: var(--ink-muted); + font-size: var(--text-sm); + line-height: var(--leading-relaxed); + margin-bottom: var(--sp-6); + max-width: 42rem; +} + +.admin-section-title { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--text-lg); + margin: var(--sp-8) 0 var(--sp-4); +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--sp-5); + margin-bottom: var(--sp-4); +} +.card__title { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--text-md); + margin-bottom: var(--sp-4); +} + +.form-grid { display: block; } +.form-grid .field:last-of-type { margin-bottom: 0; } +.field--actions { margin-top: var(--sp-5); margin-bottom: 0; } + +.connection__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-bottom: var(--sp-4); + flex-wrap: wrap; +} +.connection__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + margin-top: var(--sp-5); + padding-top: var(--sp-4); + border-top: 1px solid var(--border); + flex-wrap: wrap; +} + +/* Reachable status at a glance: green working, red errored, grey disabled. */ +.status-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: var(--radius-full); + flex: none; + background: var(--ink-faint); +} +.status-dot.is-ok { background: var(--success); } +.status-dot.is-bad { background: var(--danger); } +.status-dot.is-off { background: var(--ink-faint); } + +.model-list { list-style: none; margin: 0; padding: 0; } +.model-list__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-2) 0; + border-bottom: 1px solid var(--border); +} +.model-list__item:last-child { border-bottom: 0; } +.model-list__id { + font-family: var(--font-mono); + font-size: var(--text-sm); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nav-item.is-disabled { + opacity: 0.45; + cursor: default; +} +.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); } + +.field__hint code { + font-family: var(--font-mono); + font-size: 0.92em; + padding: 0.05em 0.3em; + border-radius: var(--radius-sm); + background: var(--code-bg); + border: 1px solid var(--code-border); +} diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css new file mode 100644 index 0000000..510bd5a --- /dev/null +++ b/src/lembas/web/static/css/app.css @@ -0,0 +1,444 @@ +/* + Application styles. + + Rules here resolve colour, spacing and radius through the variables in + tokens.css and never hard-code a value. Layout is flexbox and grid only -- + no framework, no preprocessor, no build step. +*/ + +/* --- Reset ---------------------------------------------------------------- */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + font-family: var(--font-body); + font-size: var(--text-base); + line-height: var(--leading-normal); + color: var(--ink); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4, h5, h6 { + margin: 0; + font-family: var(--font-display); + font-weight: 600; + line-height: var(--leading-tight); + letter-spacing: 0.01em; +} + +p { margin: 0 0 var(--sp-4); } +p:last-child { margin-bottom: 0; } + +a { + color: var(--accent); + text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); + text-underline-offset: 2px; +} +a:hover { color: var(--accent-hover); } + +button, input, textarea, select { + font: inherit; + color: inherit; +} + +/* A single, consistent focus ring. Never remove it without a replacement. */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} +:focus:not(:focus-visible) { outline: none; } + +.visually-hidden { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +/* --- Icons ---------------------------------------------------------------- */ +.icon { + width: 1.25em; + height: 1.25em; + flex: none; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} +.icon--sm { width: 1em; height: 1em; } +.icon--lg { width: 1.5em; height: 1.5em; } +/* The leaf is a filled silhouette, not a stroked pictogram. */ +.icon--leaf { fill: currentColor; stroke: none; } + +/* --- Buttons -------------------------------------------------------------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: 0.5rem 0.9rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-raised); + color: var(--ink); + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), + color var(--transition-fast); + white-space: nowrap; +} +.btn:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--border-strong); +} +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn--primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); +} +.btn--primary:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn--danger { color: var(--danger); border-color: var(--border); } +.btn--danger:hover:not(:disabled) { + background: var(--danger-soft); + border-color: var(--danger); +} + +.btn--ghost { background: transparent; border-color: transparent; } +.btn--ghost:hover:not(:disabled) { background: var(--surface-hover); border-color: transparent; } + +.btn--icon { + padding: 0.4rem; + border-radius: var(--radius); + background: transparent; + border-color: transparent; + color: var(--ink-muted); +} +.btn--icon:hover:not(:disabled) { background: var(--surface-hover); color: var(--ink); } + +.btn--block { width: 100%; } +.btn--sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); } + +/* --- Forms ---------------------------------------------------------------- */ +.field { margin-bottom: var(--sp-4); } +.field__label { + display: block; + margin-bottom: var(--sp-2); + font-size: var(--text-sm); + font-weight: 500; + color: var(--ink-muted); +} +.field__hint { + margin-top: var(--sp-2); + font-size: var(--text-xs); + color: var(--ink-faint); + line-height: var(--leading-normal); +} + +.input, +.textarea, +.select { + width: 100%; + padding: 0.55rem 0.7rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-sunken); + color: var(--ink); + font-size: var(--text-base); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} +.input:focus, +.textarea:focus, +.select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.input::placeholder, .textarea::placeholder { color: var(--ink-faint); } +.textarea { resize: vertical; min-height: 5rem; line-height: var(--leading-normal); } +.input--mono { font-family: var(--font-mono); font-size: var(--text-sm); } + +.checkbox { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--text-sm); + cursor: pointer; +} +.checkbox input { accent-color: var(--accent); width: 1rem; height: 1rem; } + +/* --- Alerts --------------------------------------------------------------- */ +.alert { + display: flex; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--border); + border-left-width: 3px; + border-radius: var(--radius); + font-size: var(--text-sm); + margin-bottom: var(--sp-4); + background: var(--surface); +} +.alert--error { border-left-color: var(--danger); background: var(--danger-soft); color: var(--ink); } +.alert--success { border-left-color: var(--success); background: var(--success-soft); } +.alert--warning { border-left-color: var(--warning); background: var(--warning-soft); } +.alert__icon { color: var(--danger); flex: none; margin-top: 0.15rem; } + +/* --- Badges --------------------------------------------------------------- */ +.badge { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: 0.1rem 0.45rem; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: 500; + background: var(--surface-active); + color: var(--ink-muted); +} +.badge--gold { background: var(--gold-soft); color: var(--gold); } +.badge--success { background: var(--success-soft); color: var(--success); } +.badge--danger { background: var(--danger-soft); color: var(--danger); } + +/* --- Application shell ---------------------------------------------------- */ +.shell { + display: flex; + height: 100dvh; + overflow: hidden; +} + +.sidebar { + width: var(--sidebar-width); + flex: none; + display: flex; + flex-direction: column; + background: var(--bg-sunken); + border-right: 1px solid var(--border); + transition: margin-left var(--transition); +} +.sidebar[hidden] { display: none; } + +.sidebar__header { + display: flex; + align-items: center; + gap: var(--sp-2); + height: var(--header-height); + padding: 0 var(--sp-3); + flex: none; +} +.sidebar__brand { + display: flex; + align-items: center; + gap: var(--sp-2); + color: var(--ink); + text-decoration: none; + font-family: var(--font-display); + font-size: var(--text-lg); + font-weight: 600; + letter-spacing: 0.01em; + min-width: 0; +} +.sidebar__brand:hover { color: var(--ink); } +.sidebar__brand .brand-mark { width: 1.6rem; height: 1.6rem; flex: none; } +.sidebar__brand .brand-llm { color: var(--gold); } + +.sidebar__actions { padding: 0 var(--sp-3) var(--sp-3); display: flex; gap: var(--sp-2); } + +.sidebar__scroll { + flex: 1; + overflow-y: auto; + padding: 0 var(--sp-2) var(--sp-3); + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +.sidebar__footer { + flex: none; + border-top: 1px solid var(--border); + padding: var(--sp-2); +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg); +} + +.topbar { + display: flex; + align-items: center; + gap: var(--sp-3); + height: var(--header-height); + flex: none; + padding: 0 var(--sp-4); + border-bottom: 1px solid var(--border); + background: var(--bg); +} +.topbar__title { + font-family: var(--font-display); + font-size: var(--text-md); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; +} +.topbar__spacer { flex: 1; } + +/* --- Sidebar navigation --------------------------------------------------- */ +.nav-group { margin-bottom: var(--sp-4); } +.nav-group__label { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-2) var(--sp-2) var(--sp-1); + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--ink-faint); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 0.4rem var(--sp-2); + border-radius: var(--radius); + color: var(--ink-muted); + text-decoration: none; + font-size: var(--text-sm); + cursor: pointer; + position: relative; +} +.nav-item:hover { background: var(--surface-hover); color: var(--ink); } +.nav-item.is-active { background: var(--surface-active); color: var(--ink); font-weight: 500; } +.nav-item__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} +/* Row actions stay hidden until the row is hovered or focused within, so the + list reads calmly, but they remain keyboard reachable. */ +.nav-item__actions { + display: flex; + gap: 0.1rem; + opacity: 0; + transition: opacity var(--transition-fast); +} +.nav-item:hover .nav-item__actions, +.nav-item:focus-within .nav-item__actions { opacity: 1; } + +.nav-empty { + padding: var(--sp-3) var(--sp-2); + font-size: var(--text-xs); + color: var(--ink-faint); + font-style: italic; +} + +/* --- Auth screens --------------------------------------------------------- */ +.auth { + min-height: 100dvh; + display: grid; + place-items: center; + padding: var(--sp-6); + background: + radial-gradient(ellipse 60% 50% at 50% 0%, var(--gold-soft), transparent 70%), + var(--bg); +} +.auth__card { + width: 100%; + max-width: 25rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: var(--sp-8); + box-shadow: var(--shadow-lg); +} +.auth__brand { display: grid; place-items: center; gap: var(--sp-3); margin-bottom: var(--sp-6); } +.auth__brand .brand-mark { width: 3.5rem; height: 3.5rem; } +.auth__title { + font-size: var(--text-2xl); + font-family: var(--font-display); + text-align: center; +} +.auth__subtitle { + text-align: center; + color: var(--ink-muted); + font-size: var(--text-sm); + margin-top: var(--sp-2); +} +.auth__footer { + margin-top: var(--sp-5); + padding-top: var(--sp-4); + border-top: 1px solid var(--border); + text-align: center; + font-size: var(--text-sm); + color: var(--ink-muted); +} + +/* --- Empty states --------------------------------------------------------- */ +.empty { + flex: 1; + display: grid; + place-content: center; + justify-items: center; + gap: var(--sp-3); + padding: var(--sp-10); + text-align: center; +} +.empty__mark { width: 4.5rem; height: 4.5rem; opacity: 0.85; } +.empty__title { font-size: var(--text-xl); font-family: var(--font-display); } +.empty__text { + color: var(--ink-muted); + font-size: var(--text-sm); + max-width: 32rem; + font-style: italic; +} + +/* --- Utilities ------------------------------------------------------------ */ +.stack > * + * { margin-top: var(--sp-4); } +.row { display: flex; align-items: center; gap: var(--sp-3); } +.row--between { justify-content: space-between; } +.muted { color: var(--ink-muted); } +.faint { color: var(--ink-faint); } +.text-sm { font-size: var(--text-sm); } +.text-xs { font-size: var(--text-xs); } +.mono { font-family: var(--font-mono); } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* --- Small screens -------------------------------------------------------- */ +@media (max-width: 48rem) { + .sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 40; + box-shadow: var(--shadow-lg); + } + .sidebar[data-collapsed="true"] { display: none; } +} diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css new file mode 100644 index 0000000..c941a63 --- /dev/null +++ b/src/lembas/web/static/css/chat.css @@ -0,0 +1,336 @@ +/* + Chat thread, composer, message bodies and code blocks. + + Loaded only on chat pages. Like app.css, every value resolves through + tokens.css. +*/ + +/* --- Thread --------------------------------------------------------------- */ +.thread-scroll { + flex: 1; + overflow-y: auto; + scroll-behavior: smooth; + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +.thread { + max-width: var(--thread-max-width); + margin: 0 auto; + padding: var(--sp-6) var(--sp-5) var(--sp-8); + display: flex; + flex-direction: column; + gap: var(--sp-6); +} + +.thread__intro { + display: grid; + place-items: center; + gap: var(--sp-3); + text-align: center; + padding: var(--sp-12) 0 var(--sp-6); +} + +/* --- Messages ------------------------------------------------------------- */ +.msg { + display: grid; + grid-template-columns: 2rem 1fr; + gap: var(--sp-3); + align-items: start; +} + +.msg__gutter { + display: grid; + place-items: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius-full); + overflow: hidden; +} +.msg__mark { width: 2rem; height: 2rem; } +.msg__initial { + width: 2rem; height: 2rem; + display: grid; + place-items: center; + border-radius: var(--radius-full); + background: var(--surface-active); + color: var(--ink-muted); + font-size: var(--text-sm); + font-weight: 600; +} + +.msg__main { min-width: 0; } + +.msg__meta { + display: flex; + align-items: baseline; + gap: var(--sp-2); + margin-bottom: var(--sp-1); +} +.msg__author { + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: 600; +} +.msg__model { + font-size: var(--text-xs); + color: var(--ink-faint); + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 16rem; +} + +.msg__body { + line-height: var(--leading-relaxed); + overflow-wrap: break-word; +} +/* User turns and mid-stream assistant text are plain text, so newlines and + runs of spaces have to survive. */ +.msg__body--plain, +.msg__body--streaming { white-space: pre-wrap; } + +.msg--user .msg__body--plain { + background: var(--bubble-user); + padding: var(--sp-3) var(--sp-4); + border-radius: var(--radius-lg); + display: inline-block; + max-width: 100%; +} + +.msg__error { margin: var(--sp-2) 0; align-items: flex-start; } + +/* --- Streaming indicator -------------------------------------------------- */ +/* Shown until the first token arrives, then hidden by the sibling selector + below -- no JavaScript involved in either direction. */ +.msg__waiting { padding: var(--sp-2) 0; } +.msg__body--streaming:not(:empty) + .msg__waiting { display: none; } + +.dots { display: inline-flex; gap: 0.25rem; align-items: center; } +.dots i { + width: 0.4rem; + height: 0.4rem; + border-radius: var(--radius-full); + background: var(--ink-faint); + animation: dot-pulse 1.3s ease-in-out infinite; +} +.dots i:nth-child(2) { animation-delay: 0.18s; } +.dots i:nth-child(3) { animation-delay: 0.36s; } + +@keyframes dot-pulse { + 0%, 60%, 100% { opacity: 0.28; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-2px); } +} + +/* A caret trailing the text while it streams. */ +.msg__body--streaming::after { + content: ""; + display: inline-block; + width: 0.45rem; + height: 1.05em; + margin-left: 1px; + vertical-align: text-bottom; + background: var(--gold); + opacity: 0.75; + animation: caret 1.05s steps(1) infinite; +} +@keyframes caret { 0%, 49% { opacity: 0.75; } 50%, 100% { opacity: 0; } } + +/* --- Message actions ------------------------------------------------------ */ +.msg__actions { + display: flex; + gap: var(--sp-1); + margin-top: var(--sp-2); + opacity: 0; + transition: opacity var(--transition-fast); +} +.msg:hover .msg__actions, +.msg:focus-within .msg__actions { opacity: 1; } +.msg__actions .is-copied { color: var(--success); } + +/* --- Rendered Markdown ---------------------------------------------------- */ +.msg__body > :first-child { margin-top: 0; } +.msg__body > :last-child { margin-bottom: 0; } + +.msg__body h1, .msg__body h2, .msg__body h3, +.msg__body h4, .msg__body h5, .msg__body h6 { + margin: var(--sp-5) 0 var(--sp-2); +} +.msg__body h1 { font-size: var(--text-xl); } +.msg__body h2 { font-size: var(--text-lg); } +.msg__body h3 { font-size: var(--text-md); } + +.msg__body ul, .msg__body ol { margin: 0 0 var(--sp-4); padding-left: var(--sp-6); } +.msg__body li { margin-bottom: var(--sp-1); } + +.msg__body blockquote { + margin: 0 0 var(--sp-4); + padding: var(--sp-1) var(--sp-4); + border-left: 3px solid var(--border-strong); + color: var(--ink-muted); + font-style: italic; +} + +.msg__body hr { border: 0; border-top: 1px solid var(--border); margin: var(--sp-5) 0; } + +.msg__body :not(pre) > code { + font-family: var(--font-mono); + font-size: 0.875em; + padding: 0.13em 0.36em; + border-radius: var(--radius-sm); + background: var(--code-bg); + border: 1px solid var(--code-border); +} + +.msg__body table { + width: 100%; + border-collapse: collapse; + margin: 0 0 var(--sp-4); + font-size: var(--text-sm); + display: block; + overflow-x: auto; +} +.msg__body th, .msg__body td { + border: 1px solid var(--border); + padding: var(--sp-2) var(--sp-3); + text-align: left; +} +.msg__body th { background: var(--surface); font-weight: 600; } + +.msg__body img { max-width: 100%; height: auto; border-radius: var(--radius); } + +/* --- Code blocks ---------------------------------------------------------- */ +.code-block { + margin: 0 0 var(--sp-4); + border: 1px solid var(--code-border); + border-radius: var(--radius); + background: var(--code-bg); + overflow: hidden; +} +.code-block__label { + padding: var(--sp-1) var(--sp-3); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--ink-faint); + border-bottom: 1px solid var(--code-border); + background: color-mix(in srgb, var(--code-bg) 60%, var(--surface)); +} +.code-block__pre { + margin: 0; + padding: var(--sp-3) var(--sp-4); + overflow-x: auto; + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: 1.55; +} +.code-block__pre code { font-family: inherit; background: none; border: 0; padding: 0; } + +/* + Pygments token colours, mapped onto theme tokens rather than a fixed scheme, + so code follows the active theme. classprefix "pg-" is set in markdown.py. +*/ +.pg-c, .pg-c1, .pg-cm, .pg-cs, .pg-cp { color: var(--ink-faint); font-style: italic; } +.pg-k, .pg-kn, .pg-kd, .pg-kc, .pg-kr, .pg-kt { color: var(--gold); } +.pg-s, .pg-s1, .pg-s2, .pg-sb, .pg-sd, .pg-se, .pg-sh, .pg-si, .pg-sx { color: var(--success); } +.pg-m, .pg-mi, .pg-mf, .pg-mh, .pg-mo { color: var(--danger); } +.pg-nf, .pg-nd { color: var(--accent); } +.pg-nc, .pg-nn { color: var(--accent-hover); font-weight: 600; } +.pg-nb, .pg-bp { color: var(--accent); } +.pg-nv, .pg-vi, .pg-vg, .pg-vc { color: var(--ink); } +.pg-o, .pg-ow, .pg-p { color: var(--ink-muted); } +.pg-err { color: var(--danger); } +.pg-gd { color: var(--danger); } +.pg-gi { color: var(--success); } + +/* --- Composer ------------------------------------------------------------- */ +.composer { + flex: none; + padding: var(--sp-3) var(--sp-5) var(--sp-4); + border-top: 1px solid var(--border); + background: var(--bg); +} +.composer__form { + max-width: var(--thread-max-width); + margin: 0 auto; + display: flex; + gap: var(--sp-2); + align-items: flex-end; + padding: var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + background: var(--surface); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} +.composer__form:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.composer__input { + flex: 1; + border: 0; + background: none; + resize: none; + padding: var(--sp-2); + font-size: var(--text-base); + line-height: var(--leading-normal); + max-height: 20rem; +} +.composer__input:focus { outline: none; } +.composer__send { border-radius: var(--radius-full); padding: 0.55rem 0.7rem; } +.composer__hint { + max-width: var(--thread-max-width); + margin: var(--sp-2) auto 0; + font-size: var(--text-xs); + color: var(--ink-faint); + text-align: center; +} + +.select--compact { + width: auto; + max-width: 16rem; + padding: 0.3rem 0.5rem; + font-size: var(--text-sm); +} + +/* --- Folders -------------------------------------------------------------- */ +.folder__row { padding-right: var(--sp-1); } +.folder__toggle { + display: flex; + align-items: center; + gap: var(--sp-2); + flex: 1; + min-width: 0; + background: none; + border: 0; + padding: 0; + color: inherit; + font: inherit; + cursor: pointer; + text-align: left; +} +.folder__chevron { + display: inline-flex; + transition: transform var(--transition-fast); +} +.folder__chevron.is-open { transform: rotate(90deg); } +.folder__contents { padding-left: var(--sp-4); } + +.nav-item__link { + display: flex; + align-items: center; + gap: var(--sp-2); + flex: 1; + min-width: 0; + color: inherit; + text-decoration: none; +} + +/* --- Theme toggle --------------------------------------------------------- */ +/* Only the icon for the theme you would switch TO is shown. */ +:root[data-theme="moria"] .theme-icon--dark { display: none; } +:root[data-theme="shire"] .theme-icon--light { display: none; } + +/* Alpine sets x-cloak until it has initialised; without this, collapsed + folders flash open on every page load. */ +[x-cloak] { display: none !important; } diff --git a/src/lembas/web/static/css/tokens.css b/src/lembas/web/static/css/tokens.css new file mode 100644 index 0000000..4d94dc0 --- /dev/null +++ b/src/lembas/web/static/css/tokens.css @@ -0,0 +1,191 @@ +/* + Design tokens. + + Every colour, space and radius in the application resolves through a variable + declared here. Component CSS must never hard-code a hex value -- that is what + makes adding a theme a matter of writing one new block rather than auditing + every stylesheet. + + Themes are selected with data-theme on . `moria` is the default and is + declared on :root so the page is styled even before the theme script runs. +*/ + +:root { + /* --- Type ------------------------------------------------------------- */ + --font-display: "Iowan Old Style", "Palatino Linotype", Palatino, Palladio, + "URW Palladio L", "Book Antiqua", Baskerville, Georgia, serif; + --font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", + "Cascadia Code", Menlo, Consolas, monospace; + + --text-xs: 0.75rem; + --text-sm: 0.8125rem; + --text-base: 0.9375rem; + --text-md: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.375rem; + --text-2xl: 1.75rem; + --text-3xl: 2.25rem; + + --leading-tight: 1.25; + --leading-normal: 1.6; + --leading-relaxed: 1.75; + + /* --- Space (4px scale) ------------------------------------------------ */ + --sp-1: 0.25rem; + --sp-2: 0.5rem; + --sp-3: 0.75rem; + --sp-4: 1rem; + --sp-5: 1.25rem; + --sp-6: 1.5rem; + --sp-8: 2rem; + --sp-10: 2.5rem; + --sp-12: 3rem; + --sp-16: 4rem; + + /* --- Radius & shadow -------------------------------------------------- */ + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 12px; + --radius-xl: 18px; + --radius-full: 999px; + + /* --- Layout ----------------------------------------------------------- */ + --sidebar-width: 17rem; + --thread-max-width: 48rem; + --header-height: 3.25rem; + + --transition-fast: 120ms ease; + --transition: 200ms ease; +} + +/* + --------------------------------------------------------------------------- + MORIA (default, dark) + + Deep stone and lamplight: the halls under the mountain. Surfaces are cool and + near-neutral so the gold and mithril accents carry all the colour. + --------------------------------------------------------------------------- +*/ +:root, +:root[data-theme="moria"] { + color-scheme: dark; + + --bg: #101317; + --bg-sunken: #0B0E11; + --surface: #171B21; + --surface-raised: #1E242B; + --surface-hover: #232A32; + --surface-active: #2A323B; + + --border: #2A313A; + --border-strong: #3A434E; + + --ink: #E4E8EC; + --ink-muted: #A2ADB8; + --ink-faint: #6E7883; + --ink-inverse: #0B0E11; + + /* Mithril: the cool primary, used for focus and interactive accents. */ + --accent: #8FB3CC; + --accent-hover: #A9C6DA; + --accent-ink: #0B0E11; + --accent-soft: rgba(143, 179, 204, 0.14); + + /* Rune gold: the warm accent. Brand colour, and the assistant's mark. */ + --gold: #E0B252; + --gold-hover: #EDC46F; + --gold-soft: rgba(224, 178, 82, 0.13); + + /* Ember: destructive actions and errors. */ + --danger: #E2795A; + --danger-hover: #EC8E72; + --danger-soft: rgba(226, 121, 90, 0.14); + + --success: #7FB77E; + --success-soft: rgba(127, 183, 126, 0.14); + --warning: #DFAE58; + --warning-soft: rgba(223, 174, 88, 0.14); + + --bubble-user: #232B34; + --bubble-assistant: transparent; + --code-bg: #0C0F13; + --code-border: #262D36; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow: 0 4px 14px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.55); + + --scrim: rgba(6, 8, 10, 0.66); +} + +/* + --------------------------------------------------------------------------- + SHIRE (light) + + Parchment, ink and moss: warm, low-contrast, easy to read for a long time. + Backgrounds are deliberately off-white -- pure #FFF next to the gold accent + reads as clinical rather than as paper. + --------------------------------------------------------------------------- +*/ +:root[data-theme="shire"] { + color-scheme: light; + + --bg: #F6F1E4; + --bg-sunken: #EDE6D4; + --surface: #FDFBF5; + --surface-raised: #FFFFFF; + --surface-hover: #F1EADA; + --surface-active: #E7DEC9; + + --border: #DED3BB; + --border-strong: #C6B896; + + --ink: #2C2419; + --ink-muted: #6A5C48; + --ink-faint: #94856D; + --ink-inverse: #FDFBF5; + + /* Hobbit-door blue-green: the cool primary. */ + --accent: #3E6B7A; + --accent-hover: #325867; + --accent-ink: #FDFBF5; + --accent-soft: rgba(62, 107, 122, 0.12); + + --gold: #A8801A; + --gold-hover: #8E6B12; + --gold-soft: rgba(168, 128, 26, 0.13); + + --danger: #A6432B; + --danger-hover: #8C3722; + --danger-soft: rgba(166, 67, 43, 0.11); + + --success: #4F7A3F; + --success-soft: rgba(79, 122, 63, 0.12); + --warning: #98701A; + --warning-soft: rgba(152, 112, 26, 0.13); + + --bubble-user: #EDE4CF; + --bubble-assistant: transparent; + --code-bg: #F2EBD9; + --code-border: #DED3BB; + + --shadow-sm: 0 1px 2px rgba(72, 58, 34, 0.09); + --shadow: 0 4px 14px rgba(72, 58, 34, 0.11); + --shadow-lg: 0 12px 34px rgba(72, 58, 34, 0.16); + + --scrim: rgba(44, 36, 25, 0.4); +} + +/* Respect a stated preference for reduced motion everywhere, at once. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/lembas/web/static/img/banner.svg b/src/lembas/web/static/img/banner.svg new file mode 100644 index 0000000..a0080b1 --- /dev/null +++ b/src/lembas/web/static/img/banner.svg @@ -0,0 +1,264 @@ + + LLeMbas + Waybread for the long road of thought. A mallorn leaf and wafer above the mountains at night. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lembas/web/static/img/favicon.svg b/src/lembas/web/static/img/favicon.svg new file mode 100644 index 0000000..6b4c4aa --- /dev/null +++ b/src/lembas/web/static/img/favicon.svg @@ -0,0 +1,27 @@ + + LLeMbas + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lembas/web/static/img/logo-mark.svg b/src/lembas/web/static/img/logo-mark.svg new file mode 100644 index 0000000..787cb74 --- /dev/null +++ b/src/lembas/web/static/img/logo-mark.svg @@ -0,0 +1,49 @@ + + LLeMbas + A silver mallorn leaf laid across a scored golden lembas wafer. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js new file mode 100644 index 0000000..81ed765 --- /dev/null +++ b/src/lembas/web/static/js/app.js @@ -0,0 +1,162 @@ +/* + Client-side behaviour. + + Everything here is progressive: the application is server-rendered and works + without this file, apart from the streaming reply, which is htmx's SSE + extension rather than anything hand-written below. +*/ +(function () { + "use strict"; + + var THEME_KEY = "lembas-theme"; + var THEMES = ["moria", "shire"]; + + /* --- Theme ------------------------------------------------------------- + Stored locally so the choice applies instantly and survives being signed + out, and mirrored to the server so it follows the user to another device. + The server call is best-effort: a failure must not undo the local switch. */ + function currentTheme() { + return document.documentElement.dataset.theme || THEMES[0]; + } + + function applyTheme(name) { + if (THEMES.indexOf(name) === -1) return; + document.documentElement.dataset.theme = name; + try { + localStorage.setItem(THEME_KEY, name); + } catch (e) { /* private mode */ } + + document.querySelectorAll("[data-theme-toggle]").forEach(function (el) { + el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)" + : "Switch to Moria (dark)"); + }); + + if (document.body.dataset.authenticated === "true") { + fetch("/api/preferences/theme", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ theme: name }) + }).catch(function () { /* preference is already applied locally */ }); + } + } + + function toggleTheme() { + applyTheme(currentTheme() === "moria" ? "shire" : "moria"); + } + + /* --- Textarea autosize ------------------------------------------------- + Grows the composer with its content up to a cap, after which it scrolls. */ + function autosize(el) { + if (!el) return; + var max = parseInt(el.dataset.maxHeight || "320", 10); + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, max) + "px"; + el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden"; + } + + /* --- Copy -------------------------------------------------------------- + Falls back to a hidden textarea because navigator.clipboard is unavailable + on pages served over plain http, which self-hosted installs often are. */ + function copyText(text, trigger) { + function done() { + if (!trigger) return; + var original = trigger.getAttribute("aria-label"); + trigger.classList.add("is-copied"); + trigger.setAttribute("aria-label", "Copied"); + setTimeout(function () { + trigger.classList.remove("is-copied"); + if (original) trigger.setAttribute("aria-label", original); + }, 1400); + } + + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(done).catch(function () {}); + return; + } + var scratch = document.createElement("textarea"); + scratch.value = text; + scratch.setAttribute("readonly", ""); + scratch.style.position = "fixed"; + scratch.style.opacity = "0"; + document.body.appendChild(scratch); + scratch.select(); + try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ } + document.body.removeChild(scratch); + } + + /* --- Thread scrolling -------------------------------------------------- + Only auto-scrolls when the reader is already near the bottom, so scrolling + up to re-read something is not yanked away by an incoming token. */ + var STICK_THRESHOLD = 120; + + function isNearBottom(el) { + return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD; + } + + function scrollThread(force) { + var thread = document.getElementById("thread-scroll"); + if (!thread) return; + if (force || isNearBottom(thread)) { + thread.scrollTop = thread.scrollHeight; + } + } + + window.lembas = { + applyTheme: applyTheme, + toggleTheme: toggleTheme, + copyText: copyText, + scrollThread: scrollThread, + autosize: autosize + }; + + /* --- Wiring ------------------------------------------------------------ */ + document.addEventListener("click", function (event) { + var toggle = event.target.closest("[data-theme-toggle]"); + if (toggle) { + event.preventDefault(); + toggleTheme(); + return; + } + + var copy = event.target.closest("[data-copy]"); + if (copy) { + event.preventDefault(); + var source = document.getElementById(copy.dataset.copy); + if (source) copyText(source.textContent.trim(), copy); + } + }); + + document.addEventListener("input", function (event) { + if (event.target.matches("[data-autosize]")) autosize(event.target); + }); + + /* Enter sends, Shift+Enter inserts a newline -- the convention every chat + application uses. Left alone on touch devices, where there is no easy + Shift and Enter should mean "new line". */ + document.addEventListener("keydown", function (event) { + if (event.key !== "Enter" || event.shiftKey) return; + var composer = event.target.closest("[data-composer-input]"); + if (!composer) return; + if (window.matchMedia("(pointer: coarse)").matches) return; + event.preventDefault(); + var form = composer.closest("form"); + if (form && composer.value.trim()) form.requestSubmit(); + }); + + document.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll("[data-autosize]").forEach(autosize); + scrollThread(true); + applyTheme(currentTheme()); + }); + + /* After any htmx swap: re-measure the composer and follow new content. */ + document.body.addEventListener("htmx:afterSwap", function () { + document.querySelectorAll("[data-autosize]").forEach(autosize); + scrollThread(false); + }); + + /* Tokens arriving over SSE are appended outside the normal swap cycle. */ + document.body.addEventListener("htmx:sseMessage", function () { + scrollThread(false); + }); +})(); diff --git a/src/lembas/web/static/vendor/alpine.min.js b/src/lembas/web/static/vendor/alpine.min.js new file mode 100644 index 0000000..ab371ef --- /dev/null +++ b/src/lembas/web/static/vendor/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message} + +${r?'Expression: "'+r+`" + +`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + +{% include "partials/icons.html" %} + +{% block body %}{% endblock %} + + + + + +{% block scripts %}{% endblock %} + + diff --git a/src/lembas/web/templates/chat/_message.html b/src/lembas/web/templates/chat/_message.html new file mode 100644 index 0000000..51e5fd8 --- /dev/null +++ b/src/lembas/web/templates/chat/_message.html @@ -0,0 +1,93 @@ +{% from "_macros.html" import icon, mark %} +{# + One message bubble, in either of two states. + + An incomplete assistant message renders the streaming shell: it carries the + sse-connect that opens the reply stream. This is deliberately the ONLY thing + that starts a generation, which means a page load showing an unfinished reply + picks it up again -- reloading after a dropped connection retries rather than + leaving a permanently half-written answer. + + A complete message renders its finished body: Markdown for the assistant, + escaped plain text for everyone else. +#} +{% set streaming = (message.role == "assistant" and not message.complete) %} + +
+ + + +
+
+ + {{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }} + + {% if message.model_id %} + {{ message.model_id }} + {% endif %} +
+ + {% if streaming %} + {# Tokens are appended here as they arrive. The cursor is a CSS + pseudo-element on the empty parent, so it disappears by itself once + the first token lands. #} +
+
+ +
+ {% elif message.error %} + + {% if message.content %} +
{{ body_html|safe }}
+ {% endif %} + {% elif message.role == "assistant" %} +
{{ body_html|safe }}
+ {% else %} +
{{ message.content }}
+ {% endif %} + + {% if not streaming %} +
+ + {% if message.role == "assistant" %} + + {% endif %} +
+ {# The raw source, so the copy button yields Markdown rather than rendered + text. A hidden div and not a world") + assert "click') + assert 'href="javascript:' not in html + + +def test_event_handlers_are_stripped(): + html = render_markdown('') + assert "onerror" not in html + + +def test_external_links_get_protective_rel(): + html = render_markdown("[example](https://example.com)") + assert "noopener" in html + assert "noreferrer" in html + + +def test_code_block_is_highlighted_and_not_double_wrapped(): + html = render_markdown("```python\ndef f():\n return 1\n```") + assert 'class="code-block"' in html + assert "pg-k" in html # a Pygments keyword span + # markdown-it wraps highlight output in its own
 unless the
+    # fence rule is replaced outright. This is the regression guard.
+    assert "
python<" in render_markdown("```python\nx = 1\n```")
+
+
+def test_unlabelled_code_block_still_renders():
+    html = render_markdown("```\njust text\n```")
+    assert 'class="code-block"' in html
+    assert "just text" in html
+
+
+def test_code_content_is_escaped():
+    html = render_markdown("```\n\n```")
+    assert "