Working chat: auth, connections, streaming, folders

LLeMbas now runs end to end. Register, add an OpenAI-compatible
connection, and hold a real streaming conversation organised into
folders. Verified against the local llama-swap instance.

Streaming is the one genuinely tricky part. Sending a message returns
two HTML fragments -- the user bubble and an empty assistant bubble
carrying an sse-connect -- and that attribute is the ONLY thing that
starts a generation. Rendering an incomplete assistant message as a
streaming shell falls out of the same template, which means loading a
page whose last reply never finished simply picks it up again.

Details worth knowing about, each commented where it matters:

- SSE payloads are split across several data: lines. A raw newline in
  one data: line truncates the event, which shows up the first time a
  model emits a code block.
- Markdown is rendered server-side by the same helper for both the page
  and the final streamed frame, so the two cannot disagree. The fence
  renderer is replaced outright rather than using markdown-it's
  highlight option, which re-wraps output in a second <pre>.
- escape_text is html.escape, not nh3.clean_text: it escapes character
  by character, so escaping stream chunks separately equals escaping
  the whole string.
- The stream opens its own session via session_scope(); it outlives the
  request handler and the dependency-scoped session may be closed.
- Deleting a folder keeps the chats inside it (FK is SET NULL). Losing
  a conversation to a mis-clicked folder delete is unforgivable.
- Login failures use one message for "no such account" and "wrong
  password" so the form cannot enumerate registered addresses.

Also adds deploy/ for the gamebox install at https://chat.lan: system
unit, nginx vhost with buffering off (buffering on turns streaming into
one lump at the end), and install/update scripts following the same
service-user and /srv bind-mount conventions as llama-swap and comfyui.

70 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-21 11:04:13 +02:00
parent 5ef2af6a9f
commit dd9e0e9440
59 changed files with 6273 additions and 12 deletions
+178
View File
@@ -0,0 +1,178 @@
# CLAUDE.md
Working notes for LLeMbas. Read this before changing anything.
## What it is
A self-hosted web UI for OpenAI-compatible LLM endpoints, written in Python and
themed after Middle-earth. Server-rendered FastAPI + Jinja + htmx; SQLite;
no JavaScript build step.
## Commands
```bash
. .venv/bin/activate
pip install -e ".[dev]"
lembas serve # http://127.0.0.1:8080
lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin
pytest # 70 tests, ~2s
ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate all SVG artwork
python scripts/fetch_vendor.py # verify vendored JS against the lockfile
```
## Hard rules
These are the constraints the project is built around. Breaking one is a
redesign, not a tweak.
1. **No Node, no npm, no build step.** Browser libraries are downloaded once by
`scripts/fetch_vendor.py`, hash-pinned in `scripts/vendor.lock.json`, and
committed under `web/static/vendor/`.
2. **Nothing loads from a CDN at runtime.** A self-hosted tool must work
offline and must not report page views to a third party.
3. **No hard-coded colours outside `tokens.css`.** Every colour, space and
radius resolves through a CSS variable. That is what makes a new theme one
new block rather than an audit of every stylesheet.
4. **No migration tool.** SQLite only, schema created at startup by
`init_db()`, which is `CREATE TABLE IF NOT EXISTS` and never alters an
existing table. See "Changing the schema" below.
5. **Secrets never reach the browser.** API keys are Fernet-encrypted at rest
and only ever rendered masked.
6. **Model output is untrusted.** Everything from an endpoint goes through
`services/markdown.py` (markdown-it → nh3) or `escape_text()`. Never
`|safe` on anything that has not.
## The flavour rule
Middle-earth lives in the **artwork, theme names, empty states, loading lines
and error pages**. It does not live in the functional UI.
Chats are called *Chats*, not *Tales*. Folders are *Folders*, not *Chapters*.
Buttons say what they do. Someone who has never read the books must be able to
use this without a glossary. The two themes are named `moria` and `shire`, and
the 404 says "Not all those who wander are lost. This page, however, is." —
that is the right amount.
## Layout
```
src/lembas/
main.py app factory, lifespan, error handlers
config.py pydantic-settings, all LEMBAS_* variables
cli.py typer entry points
api/
deps.py Db / CurrentUser / RequiredUser / AdminUser
auth.py register, login, logout
pages.py full-page routes (chat shell, settings)
chats.py messaging + the SSE stream
folders.py folder CRUD
admin.py connections + models
preferences.py per-user theme
db/
base.py Base, UUID/Timestamp mixins
session.py engine, SQLite pragmas, init_db, session_scope
models/ user, chat, connection, setting
security/ passwords (argon2), sessions
services/
llm/openai_client.py httpx streaming + model discovery
chat.py request building, endpoint resolution, titles
markdown.py markdown-it + pygments + nh3
crypto.py Fernet encrypt/decrypt/mask
sse.py event framing
web/
templating.py render() -- always use this, not TemplateResponse
templates/ Jinja
static/ css, js, vendor, img
assets/ SVG masters (generated)
deploy/ systemd unit, nginx vhost, install/update scripts
```
## Things that will bite you
**`render()`, not `TemplateResponse`.** `web/templating.py:render()` injects
`user`, `theme`, `version` and `allow_signup`. Templates assume they exist. If
you must call `templates.TemplateResponse` directly (the SSE path does, because
there is no `Request`), pass `user` explicitly — `chat/_message.html` renders
both roles and the user branch dereferences it.
**The message template is the state machine.** `chat/_message.html` renders an
incomplete assistant message as a streaming shell carrying `sse-connect`, and a
complete one as finished output. That is the *only* thing that starts a
generation. A consequence worth knowing: loading a page whose last reply is
unfinished restarts it, which is how a dropped connection recovers.
**SSE framing.** `services/sse.py:event()` splits payloads on newlines into
several `data:` lines. A raw newline in a single `data:` line truncates the
event — the failure shows up the first time a model emits a code block.
**Streaming opens its own database session.** `api/chats.py:_generate()` uses
`session_scope()`, not the request's session, because streaming outlives the
request handler.
**Escaping is chunk-safe on purpose.** `escape_text()` is `html.escape`, which
works character by character, so escaping stream chunks separately equals
escaping the whole string. `nh3.clean_text` would also be safe but escapes
spaces and slashes, tripling the size of every streamed token.
**The fence renderer is replaced, not configured.** markdown-it's `highlight`
option re-wraps output in `<pre><code>` unless the string starts with `<pre`,
which would nest a second `<pre>` inside our wrapper. `markdown.py` overrides
`renderer.rules["fence"]` instead. There is a regression test for this.
**SVG `<style>` is document-scoped.** Two text runs in one SVG sharing a class
name means the later rule recolours both. `build_artwork.py` takes class names
as parameters for exactly this reason.
**Gradient ids are document-global.** The `mark()` macro takes a `uid` because
two marks on one page with identical ids make the second silently reuse the
first one's gradients.
**JSON columns need reassignment.** `user.settings_json["theme"] = x` on a
plain dict is not detected. The columns use `MutableDict` (`db/types.py`), but
the safe habit is `obj.field = {**obj.field, "k": v}`.
## Changing the schema
There is no Alembic. `init_db()` creates missing tables and nothing else, so
adding a column to a model does **not** add it to an existing database. For a
live install: `ALTER TABLE` by hand, or delete the database if the data is
disposable.
This is why `Message.parent_id` and `Message.content_parts_json` already exist
though nothing reads them — they are for branching and multimodal turns, and
retrofitting them later would be the painful path.
## Artwork
Do not hand-edit files in `assets/` — they are generated. Change
`scripts/build_artwork.py` and re-run it. It also copies the few files the app
serves into `web/static/img/`.
The leaf geometry is defined once (`LEAF_BLADE`, `LEAF_MIDRIB`, …) and reused by
the icon, favicon, lockup and banner. The 64×64 mark must stay legible at 16px:
the favicon variant drops the score lines, rim and veins because they turn to
mud at that size. The icon sprite is a **template partial**
(`templates/partials/icons.html`), not an asset, because same-document
`<use href="#id">` is universally supported and the cross-document form is not.
The `mark()` macro in `_macros.html` duplicates the mark geometry so it can be
inlined and themed. If the mark changes, update both.
## Deployment
`deploy/` holds the systemd unit and nginx vhost for the gamebox install at
`https://chat.lan`. See `deploy/README.md`. Service user `lembas`, home
`/home/lembas` bind-mounted to `/srv/lembas`, mirroring the llama-swap and
comfyui conventions on that machine.
## Not built yet
Users & groups UI (the tables exist), file upload / vision / PDFs, built-in
tools + admin tool settings, custom tools and MCP, agentic execution (local
subprocess and SSH connection profiles), image generation. Empty packages and
nav entries mark where each one goes.
+153 -1
View File
@@ -1,2 +1,154 @@
# LLeMbas
<p align="center">
<img src="assets/banner.svg" alt="LLeMbas — waybread for the long road of thought" width="100%">
</p>
<p align="center">
<strong>A self-hosted web UI for your language models, written in Python.</strong><br>
Talks to anything that speaks the OpenAI API. Themed after Middle-earth.
</p>
<p align="center">
<img alt="Python 3.11+" src="https://img.shields.io/badge/python-3.11%2B-3E6B7A?style=flat-square">
<img alt="License GPL-3.0" src="https://img.shields.io/badge/license-GPL--3.0-C9A227?style=flat-square">
<img alt="No Node required" src="https://img.shields.io/badge/build%20step-none-6B8E4E?style=flat-square">
</p>
---
*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 `<text>`
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.
+80
View File
@@ -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 <https://chat.lan>, 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.
+57
View File
@@ -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";
}
}
+115
View File
@@ -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/<name>, 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 <<EOF
# LLeMbas service environment. Generated by deploy/install.sh.
# LEMBAS_SECRET_KEY signs sessions and encrypts stored API keys.
# Changing it signs everyone out and makes stored API keys unreadable.
LEMBAS_SECRET_KEY=$KEY
LEMBAS_DATA_DIR=$PREFIX/data
# Loopback only: reachable through the nginx chat.lan vhost, never direct.
LEMBAS_HOST=127.0.0.1
LEMBAS_PORT=8080
LEMBAS_LOG_LEVEL=info
LEMBAS_ALLOW_SIGNUP=true
LEMBAS_DEFAULT_THEME=moria
EOF
sudo chown "$SERVICE_USER:$SERVICE_USER" "$ENV_FILE"
sudo chmod 600 "$ENV_FILE"
echo " generated $ENV_FILE"
else
echo " $ENV_FILE exists, keeping it (and its secret key)"
fi
sudo install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 750 "$PREFIX/data"
echo "== systemd unit =="
sudo install -Dm644 "$HERE/lembas.service" /etc/systemd/system/lembas.service
sudo systemctl daemon-reload
echo "== self-signed cert for chat.lan =="
sudo mkdir -p /etc/nginx/ssl
if [[ ! -f /etc/nginx/ssl/chat.lan.crt ]]; then
sudo openssl req -x509 -newkey rsa:2048 -nodes \
-keyout /etc/nginx/ssl/chat.lan.key -out /etc/nginx/ssl/chat.lan.crt \
-days 3650 -subj "/CN=chat.lan" -addext "subjectAltName=DNS:chat.lan"
sudo chmod 600 /etc/nginx/ssl/chat.lan.key
sudo chmod 644 /etc/nginx/ssl/chat.lan.crt
fi
echo "== nginx vhost =="
sudo install -Dm644 "$HERE/chat.lan.nginx.conf" /etc/nginx/conf.d/chat.lan.conf
sudo nginx -t
sudo systemctl reload nginx
echo "== local name resolution =="
# chat.lan is in Pi-hole, but this box asks the router first and the router's
# dnsmasq is authoritative for .lan without forwarding it on -- same reason
# comfy.lan needs a hosts entry. Harmless if DNS already resolves it.
grep -q 'chat\.lan' /etc/hosts \
|| printf '127.0.0.1\tchat.lan\n::1\t\tchat.lan\n' | sudo tee -a /etc/hosts >/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."
+49
View File
@@ -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
+50
View File
@@ -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
+1
View File
@@ -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",
Regular → Executable
+15 -2
View File
@@ -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__":
+119
View File
@@ -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())
+17
View File
@@ -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"
}
}
+200
View File
@@ -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)
+186
View File
@@ -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
+290
View File
@@ -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"]
+118
View File
@@ -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()
+109
View File
@@ -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),
},
)
+29
View File
@@ -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}
+112
View File
@@ -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()
+14 -8
View File
@@ -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:
+128
View File
@@ -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()
+1 -1
View File
@@ -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()
+225
View File
@@ -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",
]
+245
View File
@@ -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 ""
+134
View File
@@ -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 <pre><code> unless the string already starts with "<pre" --
which would nest a second <pre> 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'<div class="code-block__label">{nh3.clean_text(label)}</div>' if label else ""
)
return (
f'<div class="code-block">{label_html}'
f'<pre class="code-block__pre"><code>{body}</code></pre></div>'
)
@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)
+24
View File
@@ -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"
+113
View File
@@ -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);
}
+444
View File
@@ -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; }
}
+336
View File
@@ -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; }
+191
View File
@@ -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 <html>. `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;
}
}
+264
View File
@@ -0,0 +1,264 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 420"
width="1280" height="420" role="img"
aria-label="LLeMbas - Waybread for the long road of thought">
<title>LLeMbas</title>
<desc>Waybread for the long road of thought. A mallorn leaf and wafer above the mountains at night.</desc>
<defs>
<linearGradient id="b-sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#080B0F"/>
<stop offset="0.62" stop-color="#101822"/>
<stop offset="1" stop-color="#1A2530"/>
</linearGradient>
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
<stop offset="0" stop-color="#C9A227" stop-opacity="0.22"/>
<stop offset="1" stop-color="#C9A227" stop-opacity="0"/>
</radialGradient>
<!-- Cool light sitting just above the ridge line, so the far mountains
separate from the near ones instead of merging into one dark mass. -->
<radialGradient id="b-horizon" cx="0.5" cy="1" r="0.72">
<stop offset="0" stop-color="#4E6C86" stop-opacity="0.30"/>
<stop offset="1" stop-color="#4E6C86" stop-opacity="0"/>
</radialGradient>
<linearGradient id="b-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/>
<stop offset="0.5" stop-color="#C9A227"/>
<stop offset="1" stop-color="#916F13"/>
</linearGradient>
<linearGradient id="b-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/>
<stop offset="0.4" stop-color="#F1F6FA"/>
<stop offset="1" stop-color="#B8C7D5"/>
</linearGradient>
<clipPath id="b-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>
<rect width="1280" height="420" fill="url(#b-sky)"/>
<g fill="#FFFFFF">
<circle cx="579.0" cy="167.9" r="1.80" opacity="0.49"/>
<circle cx="650.0" cy="176.2" r="0.84" opacity="0.52"/>
<circle cx="806.2" cy="237.9" r="0.72" opacity="0.38"/>
<circle cx="116.1" cy="242.9" r="1.50" opacity="0.21"/>
<circle cx="1257.2" cy="289.4" r="1.45" opacity="0.59"/>
<circle cx="201.6" cy="4.5" r="1.29" opacity="0.22"/>
<circle cx="243.5" cy="72.6" r="0.64" opacity="0.49"/>
<circle cx="563.9" cy="252.7" r="1.27" opacity="0.61"/>
<circle cx="639.7" cy="198.7" r="1.19" opacity="0.37"/>
<circle cx="1277.0" cy="298.7" r="1.69" opacity="0.65"/>
<circle cx="403.6" cy="68.9" r="0.98" opacity="0.23"/>
<circle cx="980.8" cy="120.1" r="1.70" opacity="0.44"/>
<circle cx="1226.3" cy="254.2" r="0.60" opacity="0.32"/>
<circle cx="1165.1" cy="141.0" r="1.87" opacity="0.45"/>
<circle cx="93.5" cy="188.8" r="1.61" opacity="0.36"/>
<circle cx="111.5" cy="99.8" r="1.85" opacity="0.69"/>
<circle cx="151.0" cy="73.9" r="0.73" opacity="0.22"/>
<circle cx="1020.2" cy="53.3" r="1.33" opacity="0.48"/>
<circle cx="244.1" cy="219.6" r="0.77" opacity="0.61"/>
<circle cx="149.1" cy="126.2" r="0.88" opacity="0.36"/>
<circle cx="1242.8" cy="241.0" r="1.00" opacity="0.77"/>
<circle cx="269.7" cy="118.3" r="1.71" opacity="0.61"/>
<circle cx="128.4" cy="296.8" r="0.88" opacity="0.35"/>
<circle cx="989.0" cy="98.7" r="0.99" opacity="0.23"/>
<circle cx="115.3" cy="174.8" r="0.92" opacity="0.58"/>
<circle cx="475.8" cy="136.0" r="1.85" opacity="0.50"/>
<circle cx="735.5" cy="260.0" r="0.84" opacity="0.28"/>
<circle cx="1162.8" cy="245.3" r="0.92" opacity="0.31"/>
<circle cx="946.5" cy="282.1" r="0.86" opacity="0.82"/>
<circle cx="1129.2" cy="181.1" r="1.15" opacity="0.25"/>
<circle cx="49.5" cy="288.8" r="0.91" opacity="0.65"/>
<circle cx="328.9" cy="247.1" r="1.38" opacity="0.38"/>
<circle cx="224.6" cy="216.1" r="0.69" opacity="0.33"/>
<circle cx="716.0" cy="255.7" r="1.40" opacity="0.37"/>
<circle cx="1174.2" cy="61.2" r="0.62" opacity="0.36"/>
<circle cx="570.5" cy="18.1" r="0.83" opacity="0.43"/>
<circle cx="732.4" cy="39.5" r="1.07" opacity="0.78"/>
<circle cx="1255.0" cy="197.1" r="1.50" opacity="0.57"/>
<circle cx="179.6" cy="10.5" r="0.62" opacity="0.79"/>
<circle cx="897.2" cy="288.8" r="0.63" opacity="0.61"/>
<circle cx="617.3" cy="219.1" r="1.01" opacity="0.85"/>
<circle cx="96.3" cy="163.8" r="1.56" opacity="0.78"/>
<circle cx="943.5" cy="211.1" r="1.63" opacity="0.79"/>
<circle cx="450.3" cy="205.5" r="1.77" opacity="0.76"/>
<circle cx="534.0" cy="237.2" r="1.72" opacity="0.56"/>
<circle cx="799.9" cy="114.7" r="1.36" opacity="0.59"/>
<circle cx="102.7" cy="191.8" r="1.89" opacity="0.77"/>
<circle cx="932.1" cy="116.5" r="1.56" opacity="0.57"/>
<circle cx="563.9" cy="251.5" r="0.71" opacity="0.68"/>
<circle cx="38.1" cy="180.4" r="1.23" opacity="0.33"/>
<circle cx="893.9" cy="149.2" r="1.40" opacity="0.80"/>
<circle cx="327.5" cy="3.4" r="0.99" opacity="0.63"/>
<circle cx="259.3" cy="50.9" r="1.78" opacity="0.62"/>
<circle cx="565.7" cy="267.5" r="1.03" opacity="0.63"/>
<circle cx="254.1" cy="129.3" r="1.65" opacity="0.79"/>
<circle cx="1126.7" cy="115.3" r="1.36" opacity="0.39"/>
<circle cx="174.3" cy="148.9" r="1.69" opacity="0.75"/>
<circle cx="910.4" cy="285.0" r="0.96" opacity="0.29"/>
<circle cx="576.8" cy="82.5" r="0.88" opacity="0.46"/>
<circle cx="800.9" cy="148.2" r="1.01" opacity="0.74"/>
<circle cx="1257.0" cy="135.7" r="0.70" opacity="0.20"/>
<circle cx="1117.2" cy="12.4" r="1.52" opacity="0.56"/>
<circle cx="395.6" cy="237.5" r="0.62" opacity="0.27"/>
<circle cx="582.2" cy="7.4" r="1.68" opacity="0.34"/>
<circle cx="180.3" cy="14.1" r="1.42" opacity="0.48"/>
<circle cx="806.4" cy="196.5" r="1.65" opacity="0.82"/>
<circle cx="876.2" cy="59.8" r="1.22" opacity="0.30"/>
<circle cx="13.8" cy="141.7" r="1.53" opacity="0.30"/>
<circle cx="348.6" cy="103.7" r="1.51" opacity="0.53"/>
<circle cx="786.5" cy="226.9" r="1.11" opacity="0.71"/>
<circle cx="1160.0" cy="26.2" r="1.81" opacity="0.66"/>
<circle cx="166.3" cy="136.1" r="1.41" opacity="0.79"/>
<circle cx="482.3" cy="170.6" r="1.74" opacity="0.71"/>
<circle cx="1208.7" cy="139.1" r="1.45" opacity="0.32"/>
<circle cx="924.1" cy="245.5" r="1.43" opacity="0.66"/>
<circle cx="273.0" cy="270.0" r="1.87" opacity="0.83"/>
<circle cx="687.3" cy="237.2" r="1.02" opacity="0.79"/>
<circle cx="1095.4" cy="104.6" r="0.71" opacity="0.48"/>
<circle cx="704.4" cy="230.5" r="1.23" opacity="0.20"/>
<circle cx="1035.7" cy="19.2" r="1.64" opacity="0.30"/>
<circle cx="428.8" cy="236.4" r="0.78" opacity="0.28"/>
<circle cx="661.2" cy="217.1" r="1.69" opacity="0.64"/>
<circle cx="1210.6" cy="147.8" r="1.83" opacity="0.24"/>
<circle cx="283.4" cy="158.0" r="0.98" opacity="0.67"/>
<circle cx="817.8" cy="156.8" r="1.70" opacity="0.56"/>
<circle cx="399.0" cy="114.4" r="1.70" opacity="0.78"/>
<circle cx="266.5" cy="255.2" r="1.86" opacity="0.53"/>
<circle cx="733.4" cy="60.3" r="1.30" opacity="0.52"/>
<circle cx="774.7" cy="8.3" r="1.86" opacity="0.53"/>
<circle cx="512.7" cy="240.3" r="1.33" opacity="0.51"/>
<circle cx="884.5" cy="19.8" r="1.30" opacity="0.46"/>
<circle cx="1224.8" cy="277.0" r="0.95" opacity="0.50"/>
<circle cx="162.5" cy="130.1" r="1.66" opacity="0.78"/>
<circle cx="610.0" cy="95.2" r="0.85" opacity="0.59"/>
<circle cx="1184.3" cy="38.8" r="1.61" opacity="0.20"/>
<circle cx="248.5" cy="68.2" r="1.49" opacity="0.40"/>
<circle cx="454.8" cy="185.9" r="0.74" opacity="0.67"/>
<circle cx="157.2" cy="153.1" r="0.93" opacity="0.31"/>
<circle cx="678.9" cy="131.0" r="1.09" opacity="0.46"/>
<circle cx="677.6" cy="47.9" r="0.87" opacity="0.60"/>
<circle cx="817.2" cy="158.9" r="1.71" opacity="0.59"/>
<circle cx="1096.7" cy="69.8" r="1.56" opacity="0.72"/>
<circle cx="1155.4" cy="94.8" r="1.01" opacity="0.80"/>
<circle cx="279.2" cy="299.5" r="1.75" opacity="0.27"/>
<circle cx="306.4" cy="218.0" r="0.94" opacity="0.25"/>
<circle cx="1065.2" cy="126.5" r="1.63" opacity="0.26"/>
<circle cx="515.6" cy="205.6" r="0.62" opacity="0.31"/>
<circle cx="873.5" cy="273.4" r="1.86" opacity="0.26"/>
<circle cx="647.3" cy="227.4" r="1.25" opacity="0.64"/>
<circle cx="241.9" cy="21.2" r="0.74" opacity="0.21"/>
<circle cx="706.1" cy="154.4" r="1.34" opacity="0.28"/>
<circle cx="236.2" cy="61.2" r="1.69" opacity="0.84"/>
<circle cx="1186.4" cy="28.6" r="0.68" opacity="0.82"/>
<circle cx="591.5" cy="229.4" r="1.02" opacity="0.49"/>
<circle cx="659.6" cy="129.0" r="1.38" opacity="0.19"/>
<circle cx="897.3" cy="253.3" r="0.84" opacity="0.48"/>
<circle cx="946.3" cy="121.6" r="0.85" opacity="0.29"/>
<circle cx="656.1" cy="4.6" r="1.76" opacity="0.72"/>
<circle cx="902.0" cy="258.2" r="1.42" opacity="0.45"/>
<circle cx="767.5" cy="151.3" r="1.88" opacity="0.72"/>
<circle cx="330.6" cy="273.4" r="1.57" opacity="0.70"/>
<circle cx="1042.7" cy="121.7" r="1.77" opacity="0.77"/>
<circle cx="889.3" cy="230.2" r="1.59" opacity="0.45"/>
<circle cx="925.0" cy="21.2" r="1.04" opacity="0.49"/>
<circle cx="13.6" cy="106.7" r="1.43" opacity="0.60"/>
<circle cx="297.1" cy="283.4" r="1.47" opacity="0.41"/>
<circle cx="844.5" cy="170.9" r="1.29" opacity="0.44"/>
<circle cx="1279.9" cy="192.7" r="1.51" opacity="0.69"/>
<circle cx="1254.5" cy="6.8" r="1.40" opacity="0.67"/>
<circle cx="328.5" cy="120.5" r="0.67" opacity="0.31"/>
</g>
<rect y="180" width="1280" height="240" fill="url(#b-horizon)"/>
<rect width="1280" height="420" fill="url(#b-glow)"/>
<g transform="translate(120 90) rotate(-18) scale(0.42) translate(-32 -32)" opacity="0.16"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(250 250) rotate(24) scale(0.3) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(1035 95) rotate(12) scale(0.36) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(1160 215) rotate(-32) scale(0.46) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(905 300) rotate(40) scale(0.26) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<g transform="translate(185 300) rotate(-8) scale(0.24) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
<!-- Ridge lines, furthest first. Each is lighter than the one in front of it,
which is what reads as distance. -->
<polygon points="0.0,366.0 77.4,260.4 99.7,287.8 209.3,307.1 222.5,340.5 301.6,290.7 339.9,314.6 467.1,267.1 496.3,282.9 606.7,228.9 632.9,259.8 746.4,307.3 778.6,334.3 861.2,310.5 896.2,334.5 1013.6,227.8 1044.7,263.3 1135.1,235.4 1159.3,271.3 1280.0,304.0 1280.0,366.0 1280,999 0,999" fill="#1C2836"/>
<polygon points="0.0,392.0 76.5,282.7 92.5,305.1 157.2,334.8 195.6,347.7 306.6,319.4 331.0,337.8 404.6,292.3 419.7,305.8 478.9,333.4 502.2,359.5 591.3,344.5 610.7,372.4 673.6,307.7 696.0,329.2 781.8,302.5 807.3,323.8 939.9,310.5 956.3,320.6 1092.6,317.2 1110.4,344.2 1216.2,299.7 1251.5,314.1 1280.0,288.9 1280.0,392.0 1280,999 0,999" fill="#111A25"/>
<polygon points="0.0,416.0 71.3,356.9 100.4,368.9 176.0,352.0 209.4,364.3 309.1,378.7 321.9,389.3 428.2,386.8 461.4,395.6 538.3,388.1 576.7,403.3 707.1,360.5 720.7,370.5 819.7,384.5 856.9,395.1 927.4,350.8 943.4,368.4 1038.7,363.5 1060.3,375.6 1133.4,388.3 1168.4,397.2 1280.0,380.1 1280.0,416.0 1280,999 0,999" fill="#080D13"/>
<rect y="415" width="1280" height="5" fill="#C9A227" opacity="0.55"/>
<!-- Lockup. Colours are fixed rather than themed: the banner carries its own
night sky, so it must not follow the reader's colour scheme. -->
<g transform="translate(304.75 118.00) scale(2.1250)">
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#b-wafer)"/>
<g clip-path="url(#b-clip)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
<path d="M32 6 V58"/>
<path d="M6 32 H58"/>
</g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
<path d="M33.2 6 V58"/>
<path d="M6 33.2 H58"/>
</g>
</g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
<g>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#b-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/>
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round">
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
</g>
</g>
</g>
<g transform="translate(470.08 232.00)">
<style>.base { fill: #EDE6D6; } .accent { fill: #E0B252; }</style>
<path class="accent" data-char="L" d="M4.67 -88.57 14.83 -87.33C15.24 -76.48 15.24 -61.52 15.24 -49.02V-42.98C15.24 -30.21 15.24 -14.83 14.83 -3.84L4.67 -2.61V0.00H65.91L67.56 -26.78H64.95L56.30 -3.43H29.93C29.52 -14.28 29.39 -29.93 29.39 -42.98V-49.02C29.39 -61.52 29.52 -76.48 29.93 -87.33L39.96 -88.57V-91.18H4.67Z"/>
<path class="accent" data-char="L" d="M75.52 -88.57 85.68 -87.33C86.10 -76.48 86.10 -61.52 86.10 -49.02V-42.98C86.10 -30.21 86.10 -14.83 85.68 -3.84L75.52 -2.61V0.00H136.76L138.41 -26.78H135.80L127.15 -3.43H100.79C100.38 -14.28 100.24 -29.93 100.24 -42.98V-49.02C100.24 -61.52 100.38 -76.48 100.79 -87.33L110.81 -88.57V-91.18H75.52Z"/>
<path class="base" data-char="e" d="M175.76 -60.97C182.76 -60.97 187.43 -55.47 187.43 -45.18C187.43 -39.13 185.37 -37.07 179.06 -37.07H161.07C162.03 -54.65 168.76 -60.97 175.76 -60.97ZM175.90 1.79C186.20 1.79 194.85 -2.88 199.79 -13.46L197.87 -14.83C193.75 -9.75 188.39 -6.45 180.98 -6.45C169.44 -6.45 160.93 -16.20 160.93 -32.82V-33.92H198.69C199.24 -35.84 199.52 -37.49 199.52 -40.51C199.52 -54.79 189.63 -64.26 175.62 -64.26C160.38 -64.26 146.93 -51.49 146.93 -30.07C146.93 -9.89 159.70 1.79 175.90 1.79Z"/>
<path class="accent" data-char="M" d="M209.95 0.00H235.63V-2.61L224.64 -3.84V-80.33L253.21 0.00H258.29L286.85 -81.29V-42.98C286.85 -30.21 286.71 -14.69 286.30 -3.84L276.96 -2.61V0.00H311.56V-2.61L301.54 -3.84C301.13 -14.69 300.99 -30.21 300.99 -42.98V-49.02C300.99 -61.52 301.13 -76.48 301.54 -87.33L311.56 -88.57V-91.18H286.30L261.03 -20.32L236.04 -91.18H209.95V-88.57L220.66 -87.19V-3.84L209.95 -2.61Z"/>
<path class="base" data-char="b" d="M320.76 0.00 343.01 1.51V-8.51C347.68 -1.10 353.86 1.79 360.59 1.79C375.28 1.79 386.26 -10.99 386.26 -32.82C386.26 -53.55 375.69 -64.26 361.96 -64.26C353.58 -64.26 347.13 -59.59 343.01 -52.59V-72.50L343.56 -99.96L342.05 -101.34L320.21 -95.57V-93.37L329.69 -91.18V-28.56C329.69 -21.42 329.55 -11.40 329.28 -3.84L320.76 -2.61ZM356.19 -57.26C365.25 -57.26 372.12 -49.84 372.12 -31.99C372.12 -13.73 365.12 -5.36 356.47 -5.36C350.97 -5.36 347.40 -7.00 343.01 -11.67V-49.57C346.72 -54.24 351.11 -57.26 356.19 -57.26Z"/>
<path class="base" data-char="a" d="M442.97 1.51C449.15 1.51 453.00 -1.92 455.06 -6.87L453.41 -8.24C451.21 -5.77 449.98 -4.81 447.92 -4.81C445.58 -4.81 444.21 -6.32 444.21 -10.71V-41.33C444.21 -57.26 437.89 -64.26 423.89 -64.26C410.02 -64.26 400.13 -57.81 398.62 -48.47C399.31 -44.76 401.09 -42.70 404.80 -42.70C408.51 -42.70 411.67 -45.18 412.35 -51.08L413.86 -59.73C415.65 -60.28 417.30 -60.56 419.22 -60.56C427.59 -60.56 430.75 -56.30 430.75 -42.84V-38.04C425.53 -36.80 421.14 -35.56 418.12 -34.47C400.96 -28.97 396.29 -22.24 396.29 -13.73C396.29 -3.71 403.29 1.79 412.49 1.79C419.63 1.79 425.40 -0.82 430.89 -7.96C431.85 -1.92 435.83 1.51 442.97 1.51ZM409.47 -16.89C409.47 -22.93 412.21 -27.87 421.55 -31.99C423.34 -32.82 426.50 -33.92 430.75 -35.29V-10.71C426.22 -7.00 423.20 -6.18 419.08 -6.18C413.59 -6.18 409.47 -9.34 409.47 -16.89Z"/>
<path class="base" data-char="s" d="M481.56 1.79C496.80 1.79 505.18 -5.90 505.18 -17.85C505.18 -28.97 496.94 -33.09 488.84 -36.53L484.99 -38.17C478.26 -41.06 472.50 -44.08 472.50 -50.94C472.50 -56.99 476.89 -61.24 484.58 -61.24C488.01 -61.24 490.76 -60.83 493.23 -59.59L499.13 -43.53H501.47L502.02 -59.46C496.66 -62.61 491.44 -64.26 484.58 -64.26C469.89 -64.26 462.47 -56.02 462.47 -45.31C462.47 -34.47 470.30 -30.21 478.13 -26.91L481.97 -25.27C488.70 -22.38 494.60 -19.50 494.60 -12.50C494.60 -5.90 489.93 -1.10 481.56 -1.10C477.30 -1.10 474.28 -1.65 471.26 -2.75L465.08 -20.46H462.88L462.06 -3.43C468.24 0.00 473.87 1.79 481.56 1.79Z"/>
</g>
<g transform="translate(351.54 300.00)">
<style>.tag { fill: #9AA7B4; }</style>
<path class="tag" data-char="W" d="M7.61 0.23H8.46L19.17 -22.00L21.34 0.23H22.20L33.80 -25.03L36.56 -25.38L36.71 -26.00H29.07L28.95 -25.38L32.56 -25.03L23.56 -5.01L21.58 -25.03L24.87 -25.38L24.99 -26.00H16.45L16.34 -25.38L19.56 -25.03L9.86 -4.97L8.58 -25.03L12.15 -25.38L12.26 -26.00H3.34L3.22 -25.38L5.78 -25.03Z"/>
<path class="tag" data-char="a" d="M37.72 -5.12C37.72 -7.68 38.77 -11.64 40.82 -14.09C41.91 -15.41 43.31 -16.30 44.94 -16.30C46.06 -16.30 46.92 -15.83 47.58 -15.25L45.64 -5.63C43.04 -2.64 41.21 -1.44 39.85 -1.44C38.22 -1.44 37.72 -2.91 37.72 -5.12ZM46.96 0.47C49.28 0.47 50.84 -1.71 52.04 -3.69L51.57 -4.07C50.21 -2.52 48.93 -1.51 48.00 -1.51C47.61 -1.51 47.38 -1.75 47.38 -2.13C47.38 -2.68 47.50 -3.14 47.65 -3.96L50.53 -18.01L50.21 -18.32L48.47 -16.96C47.69 -17.62 46.72 -18.01 45.79 -18.01C41.02 -18.01 35.20 -9.93 35.20 -4.19C35.20 -0.93 36.75 0.47 38.61 0.47C41.02 0.47 43.27 -1.63 45.40 -4.54C44.90 -2.25 44.90 -1.79 44.90 -1.40C44.90 -0.19 45.87 0.47 46.96 0.47Z"/>
<path class="tag" data-char="y" d="M50.87 10.05C51.69 10.05 52.54 9.86 53.47 9.31C56.97 7.30 59.80 3.14 61.78 0.00C63.72 -3.10 65.23 -5.90 66.55 -8.34C67.95 -10.83 68.73 -12.30 69.46 -13.89C69.73 -14.55 70.32 -15.76 70.32 -16.76C70.32 -17.50 70.04 -18.16 69.15 -18.16C68.03 -18.16 67.56 -17.39 66.98 -14.47C65.85 -8.89 64.26 -5.70 61.39 -0.85C61.31 -5.32 60.81 -11.76 60.34 -15.02C60.03 -17.11 59.33 -18.01 57.82 -18.01C56.04 -18.01 54.95 -16.45 53.71 -13.50L54.17 -13.16C55.41 -15.13 56.07 -16.03 56.85 -16.03C57.36 -16.03 57.74 -15.56 57.94 -13.93C58.40 -10.17 59.06 -3.61 59.33 2.17C57.78 4.46 56.07 6.52 53.82 8.11C53.47 8.38 53.05 8.69 52.66 8.89L52.16 8.34C51.34 7.41 50.49 6.91 49.48 6.91C48.62 6.91 47.73 7.37 47.58 8.19C47.93 9.51 49.32 10.05 50.87 10.05Z"/>
<path class="tag" data-char="b" d="M75.40 -4.35C75.40 -6.09 76.02 -8.11 76.18 -8.93L76.80 -11.91C79.44 -14.90 81.34 -16.10 82.73 -16.10C84.36 -16.10 85.02 -14.79 85.02 -12.42C85.02 -9.82 83.94 -5.86 81.88 -3.41C80.79 -2.13 79.47 -1.28 77.84 -1.28C75.94 -1.28 75.40 -2.72 75.40 -4.35ZM76.76 0.47C81.80 0.47 87.55 -7.61 87.55 -13.35C87.55 -16.61 85.84 -18.01 83.94 -18.01C81.57 -18.01 79.20 -15.91 76.99 -12.96L80.21 -28.56L79.82 -28.87L74.08 -27.24L74.00 -26.70L77.30 -26.12L73.61 -8.34C73.30 -6.91 72.92 -5.36 72.92 -4.00C72.92 -1.40 74.04 0.47 76.76 0.47Z"/>
<path class="tag" data-char="r" d="M90.92 0.00 91.23 0.31 93.56 0.00C93.95 -2.64 94.38 -5.20 94.88 -7.76L95.39 -10.28C96.70 -12.81 98.14 -14.94 99.54 -16.10C100.35 -15.25 101.09 -14.82 101.90 -14.82C103.11 -14.82 103.88 -15.64 103.92 -16.69C103.57 -17.73 102.68 -18.01 101.75 -18.01C99.69 -18.01 97.79 -16.10 95.58 -11.84L96.78 -17.70L96.43 -18.01L90.84 -16.38L90.77 -15.83L94.10 -15.29Z"/>
<path class="tag" data-char="e" d="M114.09 -17.23C115.29 -17.23 115.80 -16.22 115.80 -15.06C115.80 -12.46 113.70 -9.74 107.18 -7.80C107.88 -13.19 111.64 -17.23 114.09 -17.23ZM109.94 0.47C112.89 0.47 115.10 -1.40 116.57 -4.00L116.11 -4.31C115.02 -3.07 113.04 -1.47 111.10 -1.47C108.89 -1.47 107.07 -2.95 107.07 -5.98C107.07 -6.33 107.07 -6.67 107.10 -7.02C116.07 -9.55 118.05 -12.42 118.05 -15.06C118.05 -17.04 116.69 -18.01 114.36 -18.01C109.94 -18.01 104.62 -12.19 104.62 -5.32C104.62 -1.55 106.79 0.47 109.94 0.47Z"/>
<path class="tag" data-char="a" d="M122.12 -5.12C122.12 -7.68 123.17 -11.64 125.23 -14.09C126.31 -15.41 127.71 -16.30 129.34 -16.30C130.47 -16.30 131.32 -15.83 131.98 -15.25L130.04 -5.63C127.44 -2.64 125.61 -1.44 124.26 -1.44C122.63 -1.44 122.12 -2.91 122.12 -5.12ZM131.36 0.47C133.69 0.47 135.24 -1.71 136.44 -3.69L135.98 -4.07C134.62 -2.52 133.34 -1.51 132.41 -1.51C132.02 -1.51 131.79 -1.75 131.79 -2.13C131.79 -2.68 131.90 -3.14 132.06 -3.96L134.93 -18.01L134.62 -18.32L132.87 -16.96C132.10 -17.62 131.13 -18.01 130.19 -18.01C125.42 -18.01 119.60 -9.93 119.60 -4.19C119.60 -0.93 121.15 0.47 123.01 0.47C125.42 0.47 127.67 -1.63 129.81 -4.54C129.30 -2.25 129.30 -1.79 129.30 -1.40C129.30 -0.19 130.27 0.47 131.36 0.47Z"/>
<path class="tag" data-char="d" d="M141.41 -5.12C141.41 -7.92 142.69 -12.30 145.02 -14.67C146.03 -15.64 147.27 -16.30 148.63 -16.30C149.75 -16.30 150.61 -15.83 151.27 -15.25L149.29 -5.51C146.69 -2.60 144.90 -1.44 143.54 -1.44C141.91 -1.44 141.41 -2.91 141.41 -5.12ZM150.64 0.47C152.97 0.47 154.53 -1.71 155.73 -3.69L155.26 -4.07C153.90 -2.52 152.62 -1.51 151.69 -1.51C151.30 -1.51 151.07 -1.75 151.07 -2.13C151.07 -2.68 151.19 -3.14 151.34 -3.96L156.43 -28.56L156.08 -28.87L150.37 -27.24L150.26 -26.70L153.59 -26.12L151.77 -17.27C151.07 -17.73 150.26 -18.01 149.48 -18.01C144.71 -18.01 138.89 -9.93 138.89 -4.19C138.89 -0.93 140.44 0.47 142.30 0.47C144.71 0.47 146.96 -1.63 149.05 -4.50L148.90 -3.65C148.63 -2.33 148.59 -1.82 148.59 -1.36C148.59 -0.19 149.56 0.47 150.64 0.47Z"/>
<path class="tag" data-char="f" d="M161.32 10.05C163.22 10.05 164.81 9.08 166.09 7.57C167.95 5.32 169.16 2.02 169.62 -0.97C170.44 -6.17 171.25 -11.41 172.07 -16.61H176.99L177.19 -17.54H172.22C172.30 -17.97 172.34 -18.39 172.41 -18.82C173.35 -24.84 175.29 -27.63 177.30 -28.60L178.70 -27.01C179.48 -26.08 180.02 -25.61 180.84 -25.61C181.42 -25.61 182.04 -25.88 182.19 -26.74C181.96 -28.21 180.06 -29.26 178.12 -29.26C175.67 -29.26 171.33 -27.13 170.01 -18.74C169.97 -18.39 169.89 -18.01 169.85 -17.66L166.24 -17.23V-16.61H169.70C168.88 -11.37 168.07 -6.17 167.25 -0.97C166.90 1.16 166.32 4.42 165.04 6.75C164.54 7.64 163.92 8.42 163.14 8.93L162.56 8.34C161.67 7.45 161.04 6.91 159.88 6.91C159.03 6.91 158.17 7.37 158.02 8.19C158.37 9.51 159.73 10.05 161.32 10.05Z"/>
<path class="tag" data-char="o" d="M182.35 0.47C187.51 0.47 191.27 -5.12 191.27 -11.33C191.27 -15.79 188.64 -18.01 185.45 -18.01C180.33 -18.01 176.57 -12.42 176.57 -6.21C176.57 -1.75 179.17 0.47 182.35 0.47ZM182.35 -0.31C180.10 -0.31 179.09 -2.29 179.09 -5.98C179.09 -12.19 182.08 -17.23 185.45 -17.23C187.70 -17.23 188.75 -15.25 188.75 -11.60C188.75 -5.36 185.73 -0.31 182.35 -0.31Z"/>
<path class="tag" data-char="r" d="M194.77 0.00 195.08 0.31 197.41 0.00C197.79 -2.64 198.22 -5.20 198.73 -7.76L199.23 -10.28C200.55 -12.81 201.99 -14.94 203.38 -16.10C204.20 -15.25 204.93 -14.82 205.75 -14.82C206.95 -14.82 207.73 -15.64 207.77 -16.69C207.42 -17.73 206.53 -18.01 205.59 -18.01C203.54 -18.01 201.64 -16.10 199.42 -11.84L200.63 -17.70L200.28 -18.01L194.69 -16.38L194.61 -15.83L197.95 -15.29Z"/>
<path class="tag" data-char="t" d="M219.18 0.47C221.54 0.47 223.29 -1.71 224.49 -3.69L224.03 -4.07C222.71 -2.52 221.39 -1.51 220.46 -1.51C220.07 -1.51 219.84 -1.75 219.84 -2.13C219.84 -2.68 219.99 -3.14 220.15 -3.96L222.79 -16.61H227.29L227.48 -17.54H222.98L224.22 -23.44H223.52L220.77 -17.70L216.85 -17.23V-16.61H220.42L217.74 -3.73C217.47 -2.37 217.35 -1.82 217.35 -1.36C217.35 -0.19 218.13 0.47 219.18 0.47Z"/>
<path class="tag" data-char="h" d="M228.53 0.31 230.86 0.00C231.24 -2.64 231.63 -5.20 232.18 -7.76L233.22 -12.84C235.82 -14.94 237.73 -15.95 239.16 -15.95C239.94 -15.95 240.56 -15.44 240.56 -14.44C240.56 -13.54 240.17 -11.99 239.90 -10.79L238.23 -3.61C237.92 -2.25 237.92 -1.71 237.92 -1.24C237.92 -0.08 238.93 0.47 239.86 0.47C242.27 0.47 243.90 -1.71 245.10 -3.69L244.63 -4.07C243.27 -2.52 241.88 -1.51 241.14 -1.51C240.79 -1.51 240.44 -1.79 240.44 -2.25C240.44 -2.64 240.56 -3.34 240.75 -4.15L242.50 -11.84C242.77 -13.00 243.04 -14.20 243.04 -15.37C243.04 -17.07 242.19 -18.01 240.67 -18.01C238.46 -18.01 235.75 -16.03 233.42 -13.74L236.52 -28.56L236.21 -28.87L230.39 -27.24L230.31 -26.70L233.50 -26.16C233.22 -24.37 232.87 -22.51 232.49 -20.68L228.22 0.00Z"/>
<path class="tag" data-char="e" d="M256.86 -17.23C258.06 -17.23 258.56 -16.22 258.56 -15.06C258.56 -12.46 256.47 -9.74 249.95 -7.80C250.65 -13.19 254.41 -17.23 256.86 -17.23ZM252.70 0.47C255.65 0.47 257.87 -1.40 259.34 -4.00L258.87 -4.31C257.79 -3.07 255.81 -1.47 253.87 -1.47C251.66 -1.47 249.83 -2.95 249.83 -5.98C249.83 -6.33 249.83 -6.67 249.87 -7.02C258.84 -9.55 260.81 -12.42 260.81 -15.06C260.81 -17.04 259.46 -18.01 257.13 -18.01C252.70 -18.01 247.39 -12.19 247.39 -5.32C247.39 -1.55 249.56 0.47 252.70 0.47Z"/>
<path class="tag" data-char="l" d="M272.84 0.47C275.29 0.47 277.04 -1.71 278.24 -3.69L277.77 -4.07C276.41 -2.52 275.06 -1.51 274.28 -1.51C273.93 -1.51 273.58 -1.79 273.58 -2.25C273.58 -2.64 273.74 -3.34 273.89 -4.15L278.94 -28.56L278.63 -28.87L272.88 -27.24L272.81 -26.70L275.91 -26.16C275.64 -24.37 275.29 -22.51 274.90 -20.68L271.37 -3.61C271.10 -2.25 271.06 -1.71 271.06 -1.24C271.06 -0.08 271.91 0.47 272.84 0.47Z"/>
<path class="tag" data-char="o" d="M286.50 0.47C291.67 0.47 295.43 -5.12 295.43 -11.33C295.43 -15.79 292.79 -18.01 289.61 -18.01C284.49 -18.01 280.72 -12.42 280.72 -6.21C280.72 -1.75 283.32 0.47 286.50 0.47ZM286.50 -0.31C284.25 -0.31 283.24 -2.29 283.24 -5.98C283.24 -12.19 286.23 -17.23 289.61 -17.23C291.86 -17.23 292.91 -15.25 292.91 -11.60C292.91 -5.36 289.88 -0.31 286.50 -0.31Z"/>
<path class="tag" data-char="n" d="M299.23 0.31 301.56 0.00C301.95 -2.64 302.34 -5.20 302.88 -7.76L303.89 -12.84C306.53 -14.94 308.43 -15.95 309.87 -15.95C310.60 -15.95 311.22 -15.44 311.22 -14.44C311.22 -13.54 310.84 -11.99 310.56 -10.79L308.93 -3.61C308.62 -2.25 308.59 -1.71 308.59 -1.24C308.59 -0.08 309.59 0.47 310.53 0.47C312.97 0.47 314.56 -1.71 315.76 -3.69L315.30 -4.07C313.98 -2.52 312.58 -1.51 311.81 -1.51C311.46 -1.51 311.11 -1.79 311.11 -2.25C311.11 -2.64 311.22 -3.34 311.42 -4.15L313.16 -11.84C313.44 -13.00 313.71 -14.20 313.71 -15.37C313.71 -17.07 312.89 -18.01 311.38 -18.01C309.13 -18.01 306.41 -16.03 304.08 -13.70L304.94 -17.70L304.59 -18.01L298.84 -16.38L298.77 -15.83L302.10 -15.29L298.92 0.00Z"/>
<path class="tag" data-char="g" d="M324.19 -5.28C327.91 -5.28 330.28 -8.38 330.94 -11.76C331.21 -13.08 331.64 -14.59 332.02 -15.60C334.08 -15.79 335.52 -16.14 335.52 -17.54C335.52 -17.81 335.40 -18.20 335.24 -18.39C335.01 -18.51 334.62 -18.55 334.24 -18.55C332.76 -18.55 331.48 -17.81 330.82 -13.97V-13.66C330.74 -16.73 328.92 -18.16 326.16 -18.16C322.32 -18.16 319.30 -14.51 319.30 -10.01C319.30 -8.07 320.03 -6.75 321.24 -6.01C319.53 -4.73 318.60 -3.49 318.60 -2.17C318.60 -0.97 319.22 -0.19 320.42 0.19C317.82 1.40 315.61 3.34 315.61 5.98C315.61 8.58 317.78 10.05 321.20 10.05C327.29 10.05 331.36 6.40 331.36 2.60C331.36 0.70 330.16 -0.85 326.55 -1.24L322.90 -1.63C321.16 -1.82 320.46 -2.48 320.46 -3.30C320.46 -3.88 320.69 -4.58 321.70 -5.78C322.44 -5.43 323.25 -5.28 324.19 -5.28ZM324.38 -6.01C322.71 -6.01 321.78 -7.45 321.78 -10.44C321.78 -14.55 323.56 -17.46 325.97 -17.46C327.64 -17.46 328.57 -16.18 328.57 -13.16C328.57 -9.08 326.75 -6.01 324.38 -6.01ZM317.82 5.24C317.82 3.34 319.02 1.75 321.12 0.39C321.31 0.43 321.47 0.43 321.62 0.47L325.50 0.89C328.57 1.24 329.19 2.48 329.19 4.19C329.19 6.60 326.71 8.65 322.63 8.65C319.68 8.65 317.82 7.33 317.82 5.24Z"/>
<path class="tag" data-char="r" d="M344.48 0.00 344.79 0.31 347.12 0.00C347.51 -2.64 347.93 -5.20 348.44 -7.76L348.94 -10.28C350.26 -12.81 351.70 -14.94 353.10 -16.10C353.91 -15.25 354.65 -14.82 355.46 -14.82C356.67 -14.82 357.44 -15.64 357.48 -16.69C357.13 -17.73 356.24 -18.01 355.31 -18.01C353.25 -18.01 351.35 -16.10 349.14 -11.84L350.34 -17.70L349.99 -18.01L344.40 -16.38L344.33 -15.83L347.66 -15.29Z"/>
<path class="tag" data-char="o" d="M364.12 0.47C369.28 0.47 373.04 -5.12 373.04 -11.33C373.04 -15.79 370.40 -18.01 367.22 -18.01C362.10 -18.01 358.33 -12.42 358.33 -6.21C358.33 -1.75 360.93 0.47 364.12 0.47ZM364.12 -0.31C361.87 -0.31 360.86 -2.29 360.86 -5.98C360.86 -12.19 363.84 -17.23 367.22 -17.23C369.47 -17.23 370.52 -15.25 370.52 -11.60C370.52 -5.36 367.49 -0.31 364.12 -0.31Z"/>
<path class="tag" data-char="a" d="M378.01 -5.12C378.01 -7.68 379.06 -11.64 381.11 -14.09C382.20 -15.41 383.60 -16.30 385.23 -16.30C386.35 -16.30 387.21 -15.83 387.87 -15.25L385.93 -5.63C383.33 -2.64 381.50 -1.44 380.14 -1.44C378.51 -1.44 378.01 -2.91 378.01 -5.12ZM387.24 0.47C389.57 0.47 391.13 -1.71 392.33 -3.69L391.86 -4.07C390.50 -2.52 389.22 -1.51 388.29 -1.51C387.90 -1.51 387.67 -1.75 387.67 -2.13C387.67 -2.68 387.79 -3.14 387.94 -3.96L390.81 -18.01L390.50 -18.32L388.76 -16.96C387.98 -17.62 387.01 -18.01 386.08 -18.01C381.31 -18.01 375.49 -9.93 375.49 -4.19C375.49 -0.93 377.04 0.47 378.90 0.47C381.31 0.47 383.56 -1.63 385.69 -4.54C385.19 -2.25 385.19 -1.79 385.19 -1.40C385.19 -0.19 386.16 0.47 387.24 0.47Z"/>
<path class="tag" data-char="d" d="M397.30 -5.12C397.30 -7.92 398.58 -12.30 400.90 -14.67C401.91 -15.64 403.16 -16.30 404.51 -16.30C405.64 -16.30 406.49 -15.83 407.15 -15.25L405.17 -5.51C402.57 -2.60 400.79 -1.44 399.43 -1.44C397.80 -1.44 397.30 -2.91 397.30 -5.12ZM406.53 0.47C408.86 0.47 410.41 -1.71 411.61 -3.69L411.15 -4.07C409.79 -2.52 408.51 -1.51 407.58 -1.51C407.19 -1.51 406.96 -1.75 406.96 -2.13C406.96 -2.68 407.07 -3.14 407.23 -3.96L412.31 -28.56L411.96 -28.87L406.26 -27.24L406.14 -26.70L409.48 -26.12L407.66 -17.27C406.96 -17.73 406.14 -18.01 405.37 -18.01C400.59 -18.01 394.77 -9.93 394.77 -4.19C394.77 -0.93 396.33 0.47 398.19 0.47C400.59 0.47 402.84 -1.63 404.94 -4.50L404.79 -3.65C404.51 -2.33 404.47 -1.82 404.47 -1.36C404.47 -0.19 405.44 0.47 406.53 0.47Z"/>
<path class="tag" data-char="o" d="M427.76 0.47C432.92 0.47 436.68 -5.12 436.68 -11.33C436.68 -15.79 434.04 -18.01 430.86 -18.01C425.74 -18.01 421.98 -12.42 421.98 -6.21C421.98 -1.75 424.58 0.47 427.76 0.47ZM427.76 -0.31C425.51 -0.31 424.50 -2.29 424.50 -5.98C424.50 -12.19 427.49 -17.23 430.86 -17.23C433.11 -17.23 434.16 -15.25 434.16 -11.60C434.16 -5.36 431.13 -0.31 427.76 -0.31Z"/>
<path class="tag" data-char="f" d="M434.20 10.05C436.10 10.05 437.69 9.08 438.97 7.57C440.84 5.32 442.04 2.02 442.50 -0.97C443.32 -6.17 444.13 -11.41 444.95 -16.61H449.88L450.07 -17.54H445.10C445.18 -17.97 445.22 -18.39 445.30 -18.82C446.23 -24.84 448.17 -27.63 450.19 -28.60L451.59 -27.01C452.36 -26.08 452.90 -25.61 453.72 -25.61C454.30 -25.61 454.92 -25.88 455.08 -26.74C454.84 -28.21 452.94 -29.26 451.00 -29.26C448.56 -29.26 444.21 -27.13 442.89 -18.74C442.85 -18.39 442.78 -18.01 442.74 -17.66L439.13 -17.23V-16.61H442.58C441.77 -11.37 440.95 -6.17 440.14 -0.97C439.79 1.16 439.21 4.42 437.93 6.75C437.42 7.64 436.80 8.42 436.02 8.93L435.44 8.34C434.55 7.45 433.93 6.91 432.76 6.91C431.91 6.91 431.06 7.37 430.90 8.19C431.25 9.51 432.61 10.05 434.20 10.05Z"/>
<path class="tag" data-char="t" d="M460.01 0.47C462.37 0.47 464.12 -1.71 465.32 -3.69L464.86 -4.07C463.54 -2.52 462.22 -1.51 461.29 -1.51C460.90 -1.51 460.67 -1.75 460.67 -2.13C460.67 -2.68 460.82 -3.14 460.98 -3.96L463.61 -16.61H468.12L468.31 -17.54H463.81L465.05 -23.44H464.35L461.60 -17.70L457.68 -17.23V-16.61H461.25L458.57 -3.73C458.30 -2.37 458.18 -1.82 458.18 -1.36C458.18 -0.19 458.96 0.47 460.01 0.47Z"/>
<path class="tag" data-char="h" d="M469.36 0.31 471.69 0.00C472.07 -2.64 472.46 -5.20 473.01 -7.76L474.05 -12.84C476.65 -14.94 478.56 -15.95 479.99 -15.95C480.77 -15.95 481.39 -15.44 481.39 -14.44C481.39 -13.54 481.00 -11.99 480.73 -10.79L479.06 -3.61C478.75 -2.25 478.75 -1.71 478.75 -1.24C478.75 -0.08 479.76 0.47 480.69 0.47C483.10 0.47 484.73 -1.71 485.93 -3.69L485.46 -4.07C484.10 -2.52 482.71 -1.51 481.97 -1.51C481.62 -1.51 481.27 -1.79 481.27 -2.25C481.27 -2.64 481.39 -3.34 481.58 -4.15L483.33 -11.84C483.60 -13.00 483.87 -14.20 483.87 -15.37C483.87 -17.07 483.02 -18.01 481.50 -18.01C479.29 -18.01 476.58 -16.03 474.25 -13.74L477.35 -28.56L477.04 -28.87L471.22 -27.24L471.14 -26.70L474.33 -26.16C474.05 -24.37 473.70 -22.51 473.32 -20.68L469.05 0.00Z"/>
<path class="tag" data-char="o" d="M494.16 0.47C499.32 0.47 503.08 -5.12 503.08 -11.33C503.08 -15.79 500.44 -18.01 497.26 -18.01C492.14 -18.01 488.37 -12.42 488.37 -6.21C488.37 -1.75 490.97 0.47 494.16 0.47ZM494.16 -0.31C491.90 -0.31 490.90 -2.29 490.90 -5.98C490.90 -12.19 493.88 -17.23 497.26 -17.23C499.51 -17.23 500.56 -15.25 500.56 -11.60C500.56 -5.36 497.53 -0.31 494.16 -0.31Z"/>
<path class="tag" data-char="u" d="M509.21 0.47C511.46 0.47 514.14 -1.51 516.47 -3.80C516.16 -2.33 516.12 -1.82 516.12 -1.36C516.12 -0.19 516.90 0.47 517.94 0.47C520.31 0.47 522.10 -1.71 523.30 -3.69L522.79 -4.07C521.47 -2.52 520.16 -1.51 519.26 -1.51C518.87 -1.51 518.64 -1.75 518.64 -2.13C518.64 -2.68 518.76 -3.14 518.91 -3.96L521.75 -17.70L521.40 -18.01L519.03 -17.39C518.64 -14.79 518.21 -12.34 517.71 -9.78L516.66 -4.70C514.06 -2.60 512.16 -1.59 510.76 -1.59C509.99 -1.59 509.41 -2.10 509.41 -3.14C509.41 -4.00 509.79 -5.55 510.07 -6.75L512.43 -17.70L512.12 -18.01L506.38 -16.38L506.26 -15.83L509.60 -15.29L507.43 -5.70C507.16 -4.54 506.88 -3.34 506.88 -2.17C506.88 -0.47 507.70 0.47 509.21 0.47Z"/>
<path class="tag" data-char="g" d="M531.68 -5.28C535.41 -5.28 537.77 -8.38 538.43 -11.76C538.70 -13.08 539.13 -14.59 539.52 -15.60C541.58 -15.79 543.01 -16.14 543.01 -17.54C543.01 -17.81 542.90 -18.20 542.74 -18.39C542.51 -18.51 542.12 -18.55 541.73 -18.55C540.26 -18.55 538.98 -17.81 538.32 -13.97V-13.66C538.24 -16.73 536.41 -18.16 533.66 -18.16C529.82 -18.16 526.79 -14.51 526.79 -10.01C526.79 -8.07 527.53 -6.75 528.73 -6.01C527.02 -4.73 526.09 -3.49 526.09 -2.17C526.09 -0.97 526.71 -0.19 527.92 0.19C525.32 1.40 523.10 3.34 523.10 5.98C523.10 8.58 525.28 10.05 528.69 10.05C534.79 10.05 538.86 6.40 538.86 2.60C538.86 0.70 537.66 -0.85 534.05 -1.24L530.40 -1.63C528.65 -1.82 527.96 -2.48 527.96 -3.30C527.96 -3.88 528.19 -4.58 529.20 -5.78C529.93 -5.43 530.75 -5.28 531.68 -5.28ZM531.87 -6.01C530.21 -6.01 529.27 -7.45 529.27 -10.44C529.27 -14.55 531.06 -17.46 533.47 -17.46C535.13 -17.46 536.07 -16.18 536.07 -13.16C536.07 -9.08 534.24 -6.01 531.87 -6.01ZM525.32 5.24C525.32 3.34 526.52 1.75 528.61 0.39C528.81 0.43 528.96 0.43 529.12 0.47L533.00 0.89C536.07 1.24 536.69 2.48 536.69 4.19C536.69 6.60 534.20 8.65 530.13 8.65C527.18 8.65 525.32 7.33 525.32 5.24Z"/>
<path class="tag" data-char="h" d="M543.75 0.31 546.08 0.00C546.47 -2.64 546.85 -5.20 547.40 -7.76L548.44 -12.84C551.04 -14.94 552.95 -15.95 554.38 -15.95C555.16 -15.95 555.78 -15.44 555.78 -14.44C555.78 -13.54 555.39 -11.99 555.12 -10.79L553.45 -3.61C553.14 -2.25 553.14 -1.71 553.14 -1.24C553.14 -0.08 554.15 0.47 555.08 0.47C557.49 0.47 559.12 -1.71 560.32 -3.69L559.85 -4.07C558.50 -2.52 557.10 -1.51 556.36 -1.51C556.01 -1.51 555.66 -1.79 555.66 -2.25C555.66 -2.64 555.78 -3.34 555.97 -4.15L557.72 -11.84C557.99 -13.00 558.26 -14.20 558.26 -15.37C558.26 -17.07 557.41 -18.01 555.90 -18.01C553.68 -18.01 550.97 -16.03 548.64 -13.74L551.74 -28.56L551.43 -28.87L545.61 -27.24L545.53 -26.70L548.72 -26.16C548.44 -24.37 548.10 -22.51 547.71 -20.68L543.44 0.00Z"/>
<path class="tag" data-char="t" d="M565.40 0.47C567.77 0.47 569.52 -1.71 570.72 -3.69L570.25 -4.07C568.93 -2.52 567.61 -1.51 566.68 -1.51C566.30 -1.51 566.06 -1.75 566.06 -2.13C566.06 -2.68 566.22 -3.14 566.37 -3.96L569.01 -16.61H573.51L573.71 -17.54H569.21L570.45 -23.44H569.75L566.99 -17.70L563.07 -17.23V-16.61H566.64L563.97 -3.73C563.70 -2.37 563.58 -1.82 563.58 -1.36C563.58 -0.19 564.36 0.47 565.40 0.47Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<defs>
<linearGradient id="f-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/>
<stop offset="0.5" stop-color="#C9A227"/>
<stop offset="1" stop-color="#916F13"/>
</linearGradient>
<linearGradient id="f-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/>
<stop offset="0.4" stop-color="#F1F6FA"/>
<stop offset="1" stop-color="#B8C7D5"/>
</linearGradient>
<clipPath id="f-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/>
<g transform="translate(32 32) scale(1.16) translate(-32 -32)">
<path d="M20.6 44.6 L15.6 50.1" stroke="#8A9AA8" stroke-width="3.4"
stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#f-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.45"
stroke-width="1.8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+49
View File
@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc>
<defs>
<linearGradient id="m-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/>
<stop offset="0.5" stop-color="#C9A227"/>
<stop offset="1" stop-color="#916F13"/>
</linearGradient>
<linearGradient id="m-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/>
<stop offset="0.4" stop-color="#F1F6FA"/>
<stop offset="1" stop-color="#B8C7D5"/>
</linearGradient>
<clipPath id="m-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#m-wafer)"/>
<g clip-path="url(#m-clip)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
<path d="M32 6 V58"/>
<path d="M6 32 H58"/>
</g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
<path d="M33.2 6 V58"/>
<path d="M6 33.2 H58"/>
</g>
</g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
<g>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#m-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/>
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round">
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+162
View File
@@ -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);
});
})();
File diff suppressed because one or more lines are too long
+290
View File
@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/
(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
htmx.defineExtension('sse', {
/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},
getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},
/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
return
// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})
/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})
var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec, { contextElement: elt })
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()
File diff suppressed because one or more lines are too long
+68
View File
@@ -0,0 +1,68 @@
{#
Shared template macros.
Import at the top of any template that needs them:
{% from "_macros.html" import icon, brand, mark %}
#}
{# An icon from the inlined sprite. `name` omits the "i-" prefix. #}
{% macro icon(name, cls="") -%}
<svg class="icon {{ cls }}" aria-hidden="true"><use href="#i-{{ name }}"/></svg>
{%- endmacro %}
{#
The leaf-and-wafer mark, inlined rather than referenced as <img> so it can
scale with the surrounding font size. Gradient ids are suffixed with `uid`
because ids are document-global: two marks on one page with the same ids
means the second silently reuses the first one's gradients.
#}
{% macro mark(cls="brand-mark", uid="a") -%}
<svg class="{{ cls }}" viewBox="0 0 64 64" role="img" aria-label="LLeMbas">
<defs>
<linearGradient id="mk-{{ uid }}-w" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/>
<stop offset="0.5" stop-color="#C9A227"/>
<stop offset="1" stop-color="#916F13"/>
</linearGradient>
<linearGradient id="mk-{{ uid }}-l" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/>
<stop offset="0.4" stop-color="#F1F6FA"/>
<stop offset="1" stop-color="#B8C7D5"/>
</linearGradient>
<clipPath id="mk-{{ uid }}-c"><rect x="6" y="6" width="52" height="52" rx="13"/></clipPath>
</defs>
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#mk-{{ uid }}-w)"/>
<g clip-path="url(#mk-{{ uid }}-c)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
<path d="M32 6 V58"/><path d="M6 32 H58"/>
</g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
<path d="M33.2 6 V58"/><path d="M6 33.2 H58"/>
</g>
</g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9" fill="none"
stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z"
fill="url(#mk-{{ uid }}-l)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A"
stroke-opacity="0.5" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{%- endmacro %}
{#
The wordmark as live text rather than the outlined SVG: it stays selectable,
searchable and readable to a screen reader, and scales with the user's font
size. The capitals spelling LLM take the gold accent.
#}
{% macro wordmark() -%}
<span class="brand-llm">LL</span>e<span class="brand-llm">M</span>bas
{%- endmacro %}
{% macro brand(href="/", uid="a") -%}
<a class="sidebar__brand" href="{{ href }}">
{{ mark(uid=uid) }}
<span>{{ wordmark() }}</span>
</a>
{%- endmacro %}
@@ -0,0 +1,98 @@
{% from "_macros.html" import icon %}
{#
One connection: an editable form plus its current status.
Swapped in place by the "Test & refresh" button, so this fragment has to be
able to render on its own as well as inside the list.
#}
<section class="card connection" id="connection-{{ connection.id }}">
<form method="post" action="/admin/connections/{{ connection.id }}" class="form-grid">
<div class="connection__head">
<div class="row" style="gap: var(--sp-2); min-width: 0">
<span class="status-dot {{ 'is-ok' if connection.enabled and not connection.last_error
else 'is-bad' if connection.last_error else 'is-off' }}"
aria-hidden="true"></span>
<strong class="truncate">{{ connection.name }}</strong>
{% if model_count is defined %}
<span class="badge">{{ model_count }} model{{ '' if model_count == 1 else 's' }}</span>
{% endif %}
{% if not connection.enabled %}
<span class="badge">disabled</span>
{% endif %}
</div>
<div class="row" style="gap: var(--sp-2)">
<button class="btn btn--sm" type="submit"
hx-post="/admin/connections/{{ connection.id }}/test"
hx-target="#connection-{{ connection.id }}" hx-swap="outerHTML"
formnovalidate>
{{ icon("refresh", "icon--sm") }} Test &amp; refresh
</button>
<button class="btn btn--sm btn--primary" type="submit">Save</button>
</div>
</div>
{% if message %}
<div class="alert alert--{{ message_kind|default('success') }}">
{% if message_kind == "error" %}{{ icon("warning", "alert__icon") }}{% endif %}
<span>{{ message }}</span>
</div>
{% elif connection.last_error %}
<div class="alert alert--error">
{{ icon("warning", "alert__icon") }}
<span>{{ connection.last_error }}</span>
</div>
{% endif %}
<div class="field">
<label class="field__label" for="name-{{ connection.id }}">Name</label>
<input class="input" id="name-{{ connection.id }}" name="name"
value="{{ connection.name }}" required>
</div>
<div class="field">
<label class="field__label" for="url-{{ connection.id }}">Base URL</label>
<input class="input input--mono" id="url-{{ connection.id }}" name="base_url"
value="{{ connection.base_url }}" required>
</div>
<div class="field">
<label class="field__label" for="key-{{ connection.id }}">API key</label>
<input class="input input--mono" id="key-{{ connection.id }}" name="api_key"
type="password" autocomplete="off"
value="{{ unchanged if connection.api_key_encrypted else '' }}"
placeholder="No key set">
<p class="field__hint">
{% if connection.api_key_encrypted %}
Currently <code>{{ masked }}</code>. Leave the dots alone to keep it,
or clear the field to remove the key entirely.
{% else %}
No key is stored for this endpoint.
{% endif %}
</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
{{ 'checked' if connection.enabled }}>
<span>Enabled — its models are offered in chats</span>
</label>
</div>
<div class="connection__footer">
<span class="text-xs faint">
{% if connection.last_checked_at %}
Last checked {{ connection.last_checked_at.strftime("%Y-%m-%d %H:%M") }} UTC
{% else %}
Never checked
{% endif %}
</span>
<button class="btn btn--sm btn--danger" type="submit"
formaction="/admin/connections/{{ connection.id }}/delete" formnovalidate
onclick="return confirm('Delete the connection “{{ connection.name }}”? Existing chats keep their history.')">
{{ icon("trash", "icon--sm") }} Delete
</button>
</div>
</form>
</section>
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, brand %}
{#
Shared chrome for the admin area: its own narrow nav rather than the chat
sidebar, so administration is visibly a different place from chatting.
#}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
<aside class="sidebar">
<div class="sidebar__header">
{{ brand(uid="admin") }}
</div>
<nav class="sidebar__scroll" aria-label="Administration">
<div class="nav-group">
<div class="nav-group__label">Administration</div>
<a class="nav-item {{ 'is-active' if section == 'connections' }}"
href="/admin/connections">
{{ icon("server", "icon--sm") }}
<span class="nav-item__label">Connections</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'models' }}" href="/admin/models">
{{ icon("sliders", "icon--sm") }}
<span class="nav-item__label">Models</span>
</a>
</div>
<div class="nav-group">
<div class="nav-group__label">Not yet built</div>
<span class="nav-item is-disabled">
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">Users &amp; groups</span>
</span>
<span class="nav-item is-disabled">
{{ icon("gear", "icon--sm") }}
<span class="nav-item__label">Tools</span>
</span>
</div>
</nav>
<div class="sidebar__footer">
<a class="nav-item" href="/chat">
{{ icon("chat", "icon--sm") }}
<span class="nav-item__label">Back to chats</span>
</a>
</div>
</aside>
<main class="main">
<header class="topbar">
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
</button>
</header>
<div class="admin-scroll">
<div class="admin-page">
{% block admin_content %}{% endblock %}
</div>
</div>
</main>
</div>
{% endblock %}
@@ -0,0 +1,76 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "connections" %}
{% block title %}Connections - LLeMbas{% endblock %}
{% block heading %}Connections{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Any endpoint that speaks the OpenAI HTTP API: OpenAI itself, or a local
runner such as LM Studio, vLLM, llama.cpp or Ollama. LLeMbas asks each one
for its model list and offers those models in the chat picker.
</p>
{% if message %}
<div class="alert alert--success">{{ message }}</div>
{% endif %}
<section class="card">
<h2 class="card__title">Add a connection</h2>
<form method="post" action="/admin/connections" class="form-grid">
<div class="field">
<label class="field__label" for="new-name">Name</label>
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
</div>
<div class="field">
<label class="field__label" for="new-url">Base URL</label>
<input class="input input--mono" id="new-url" name="base_url" required
placeholder="http://localhost:1234/v1">
<p class="field__hint">
With or without the trailing <code>/v1</code> — both are accepted.
</p>
</div>
<div class="field">
<label class="field__label" for="new-key">API key</label>
<input class="input input--mono" id="new-key" name="api_key" type="password"
autocomplete="off" placeholder="sk-…">
<p class="field__hint">
Encrypted before it is stored, and never sent back to the browser.
Leave empty for endpoints that need no key.
</p>
</div>
<div class="field field--actions">
<button class="btn btn--primary" type="submit">
{{ icon("plus", "icon--sm") }} Add and load models
</button>
</div>
</form>
</section>
<h2 class="admin-section-title">
Configured connections
<span class="badge">{{ connections|length }}</span>
</h2>
{% if not connections %}
<div class="empty" style="padding: var(--sp-10) 0">
{{ icon("server", "empty__mark") }}
<p class="empty__text">
Nothing configured yet. Add a connection above and its models appear here.
</p>
</div>
{% endif %}
<div id="connection-list">
{% for connection in connections %}
{% with masked = masked[connection.id],
model_count = model_counts[connection.id] %}
{% include "admin/_connection_row.html" %}
{% endwith %}
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,51 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "models" %}
{% block title %}Models - LLeMbas{% endblock %}
{% block heading %}Models{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Every model discovered on each connection. Disable the ones you do not want
cluttering the chat picker — nothing is deleted, and re-running
“Test &amp; refresh” will not bring a disabled model back on.
</p>
{% for connection in connections %}
<section class="card">
<h2 class="card__title">
{{ connection.name }}
<span class="badge">{{ connection.models|length }}</span>
{% if not connection.enabled %}<span class="badge">connection disabled</span>{% endif %}
</h2>
{% if not connection.models %}
<p class="muted text-sm">
No models loaded. Run “Test &amp; refresh” on
<a href="/admin/connections">the connections page</a>.
</p>
{% else %}
<ul class="model-list">
{% for model in connection.models %}
<li class="model-list__item">
<code class="model-list__id">{{ model.model_id }}</code>
<form method="post" action="/admin/models/{{ model.id }}/toggle">
<button class="btn btn--sm {{ 'btn--primary' if model.enabled }}" type="submit">
{% if model.enabled %}{{ icon("check", "icon--sm") }} Enabled{% else %}Disabled{% endif %}
</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
</section>
{% else %}
<div class="empty" style="padding: var(--sp-10) 0">
{{ icon("server", "empty__mark") }}
<p class="empty__text">
No connections yet. <a href="/admin/connections">Add one</a> to load models.
</p>
</div>
{% endfor %}
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, mark, wordmark %}
{% block title %}Sign in - LLeMbas{% endblock %}
{% block body %}
<main class="auth">
<div class="auth__card">
<div class="auth__brand">
{{ mark(cls="brand-mark", uid="auth") }}
<h1 class="auth__title">{{ wordmark() }}</h1>
<p class="auth__subtitle">Waybread for the long road of thought.</p>
</div>
{% if error %}
<div class="alert alert--error" role="alert">
{{ icon("warning", "alert__icon") }}
<span>{{ error }}</span>
</div>
{% endif %}
<form method="post" action="/auth/login" class="stack">
<input type="hidden" name="next" value="{{ next|default('/') }}">
<div class="field">
<label class="field__label" for="email">Email</label>
<input class="input" type="email" id="email" name="email" required
autocomplete="username" autofocus value="{{ email|default('') }}">
</div>
<div class="field">
<label class="field__label" for="password">Password</label>
<input class="input" type="password" id="password" name="password" required
autocomplete="current-password">
</div>
<button class="btn btn--primary btn--block" type="submit">Sign in</button>
</form>
{% if allow_signup %}
<p class="auth__footer">
No account yet? <a href="/auth/register">Create one</a>.
</p>
{% endif %}
</div>
</main>
{% endblock %}
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, mark, wordmark %}
{% block title %}{% if first_run %}Set up LLeMbas{% else %}Create an account{% endif %}{% endblock %}
{% block body %}
<main class="auth">
<div class="auth__card">
<div class="auth__brand">
{{ mark(cls="brand-mark", uid="auth") }}
<h1 class="auth__title">{{ wordmark() }}</h1>
{% if first_run %}
<p class="auth__subtitle">
Nobody has claimed this instance yet. The first account becomes its administrator.
</p>
{% else %}
<p class="auth__subtitle">Create your account.</p>
{% endif %}
</div>
{% if error %}
<div class="alert alert--error" role="alert">
{{ icon("warning", "alert__icon") }}
<span>{{ error }}</span>
</div>
{% endif %}
<form method="post" action="/auth/register" class="stack">
<div class="field">
<label class="field__label" for="name">Name</label>
<input class="input" type="text" id="name" name="name" required autofocus
autocomplete="name" value="{{ name|default('') }}">
</div>
<div class="field">
<label class="field__label" for="email">Email</label>
<input class="input" type="email" id="email" name="email" required
autocomplete="username" value="{{ email|default('') }}">
</div>
<div class="field">
<label class="field__label" for="password">Password</label>
<input class="input" type="password" id="password" name="password" required
autocomplete="new-password" minlength="8">
<p class="field__hint">At least 8 characters.</p>
</div>
<button class="btn btn--primary btn--block" type="submit">
{% if first_run %}Create administrator account{% else %}Create account{% endif %}
</button>
</form>
{% if not first_run %}
<p class="auth__footer">
Already have an account? <a href="/auth/login">Sign in</a>.
</p>
{% endif %}
</div>
</main>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en" data-theme="{{ theme }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}LLeMbas{% endblock %}</title>
<meta name="description" content="LLeMbas - a web UI for your language models.">
<meta name="color-scheme" content="dark light">
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
{% block head %}{% endblock %}
{#
Applied before first paint so a reload never flashes the wrong theme. The
server already rendered data-theme from the signed-in user's preference; this
only corrects it for a visitor whose local choice differs (or who is signed
out, where the server has nothing to go on).
#}
<script>
(function () {
try {
var stored = localStorage.getItem("lembas-theme");
if (stored && stored !== document.documentElement.dataset.theme) {
document.documentElement.dataset.theme = stored;
}
} catch (e) { /* private mode: the server-rendered theme stands */ }
})();
</script>
</head>
<body{% block body_attrs %}{% endblock %}>
{% include "partials/icons.html" %}
{% block body %}{% endblock %}
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script>
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
{% block scripts %}{% endblock %}
</body>
</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) %}
<article class="msg msg--{{ message.role }}" id="msg-{{ message.id }}"
{% if streaming %}
hx-ext="sse"
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
sse-close="close"
{% endif %}>
<div class="msg__gutter" aria-hidden="true">
{% if message.role == "assistant" %}
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
{% else %}
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
{% endif %}
</div>
<div class="msg__main">
<header class="msg__meta">
<span class="msg__author">
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
</span>
{% if message.model_id %}
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
{% endif %}
</header>
{% 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. #}
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
sse-swap="token" hx-swap="beforeend"></div>
<div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span>
</div>
{% elif message.error %}
<div class="alert alert--error msg__error" role="alert">
{{ icon("warning", "alert__icon") }}
<div>
<strong>The reply could not be completed.</strong>
<div class="text-sm" style="margin-top: var(--sp-1)">{{ message.error }}</div>
</div>
</div>
{% if message.content %}
<div class="msg__body">{{ body_html|safe }}</div>
{% endif %}
{% elif message.role == "assistant" %}
<div class="msg__body">{{ body_html|safe }}</div>
{% else %}
<div class="msg__body msg__body--plain">{{ message.content }}</div>
{% endif %}
{% if not streaming %}
<footer class="msg__actions">
<button class="btn btn--icon btn--sm" type="button"
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
{{ icon("copy", "icon--sm") }}
</button>
{% if message.role == "assistant" %}
<button class="btn btn--icon btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
aria-label="Regenerate reply">
{{ icon("refresh", "icon--sm") }}
</button>
{% endif %}
</footer>
{# The raw source, so the copy button yields Markdown rather than rendered
text. A hidden div and not a <script>: script content is raw text, so
the escaping Jinja applies would be copied out literally as entities. #}
<div hidden id="msg-body-{{ message.id }}">{{ message.content }}</div>
{% endif %}
</div>
{% if streaming %}
{# Receives the finished bubble and replaces this whole article with it. #}
<div hidden sse-swap="done" hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"></div>
{% endif %}
</article>
@@ -0,0 +1,10 @@
{#
Out-of-band updates sent alongside the finished reply.
A chat is named from its first exchange, which happens on the server while
the reply streams. These two fragments push the new title into the page
without the client having to poll or reload.
#}
<span id="chat-title" hx-swap-oob="true">{{ chat.title }}</span>
<span id="chat-link-label-{{ chat.id }}" hx-swap-oob="true"
class="nav-item__label">{{ chat.title }}</span>
+11
View File
@@ -0,0 +1,11 @@
{#
One exchange: the user's message plus the empty assistant bubble that will
stream into it. Returned by POST /api/chats/{id}/messages and appended to the
thread in a single swap, so the pair always arrives together.
#}
{% with message = user_message %}
{% include "chat/_message.html" %}
{% endwith %}
{% with message = assistant_message %}
{% include "chat/_message.html" %}
{% endwith %}
+120
View File
@@ -0,0 +1,120 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, mark %}
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar"
onclick="document.getElementById('sidebar').toggleAttribute('hidden')">
{{ icon("sidebar") }}
</button>
{% if chat %}
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
{% if models %}
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change from:find select">
<select class="select select--compact" name="model_id" aria-label="Model">
{% for model in models %}
<option value="{{ model.model_id }}" {{ 'selected' if model.model_id == chat.model_id }}>
{{ model.label }}
</option>
{% endfor %}
</select>
</form>
{% endif %}
{% else %}
<h1 class="topbar__title">Chats</h1>
{% endif %}
</header>
{% if not chat %}
{# No chat selected. #}
<div class="empty">
{{ mark(cls="empty__mark", uid="empty") }}
<h2 class="empty__title">The road goes ever on</h2>
<p class="empty__text">
Pick a chat from the side, or start a new one.
</p>
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
{{ icon("plus", "icon--sm") }} New chat
</button>
</div>
{% elif not models %}
{# Nothing to talk to yet. This is the state every fresh install lands in,
so it points straight at the fix rather than just reporting a problem. #}
<div class="empty">
{{ icon("server", "empty__mark") }}
<h2 class="empty__title">No models available</h2>
<p class="empty__text">
{% if user.is_admin %}
Add an OpenAI-compatible connection and LLeMbas will load its models.
{% else %}
No model connections have been set up yet. Ask an administrator.
{% endif %}
</p>
{% if user.is_admin %}
<a class="btn btn--primary" href="/admin/connections">
{{ icon("server", "icon--sm") }} Set up a connection
</a>
{% endif %}
</div>
{% else %}
<div class="thread-scroll" id="thread-scroll">
<div class="thread" id="thread">
{% if not messages %}
<div class="thread__intro">
{{ mark(cls="empty__mark", uid="intro") }}
<h2 class="empty__title">What would you ask?</h2>
<p class="empty__text">Speak, friend, and enter.</p>
</div>
{% endif %}
{% for message in messages %}
{# Markdown was rendered server-side in pages.py, keyed by message
id, so this loop stays a lookup rather than a render. #}
{% with body_html = bodies.get(message.id, "") %}
{% include "chat/_message.html" %}
{% endwith %}
{% endfor %}
</div>
</div>
<div class="composer">
<form class="composer__form"
hx-post="/api/chats/{{ chat.id }}/messages"
hx-target="#thread" hx-swap="beforeend"
hx-on::after-request="if (event.detail.successful) {
this.reset();
const t = this.querySelector('textarea');
window.lembas.autosize(t);
window.lembas.scrollThread(true);
}">
<textarea class="composer__input" name="content" rows="1"
data-autosize data-max-height="320" data-composer-input
placeholder="Send a message…" aria-label="Message"></textarea>
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
{{ icon("send", "icon--sm") }}
</button>
</form>
<p class="composer__hint">
Enter to send, Shift+Enter for a new line.
</p>
</div>
{% endif %}
</main>
</div>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% from "_macros.html" import mark %}
{% block title %}{{ status_code }} - LLeMbas{% endblock %}
{% block body %}
<main class="auth">
<div class="auth__card" style="text-align: center">
{{ mark(cls="empty__mark", uid="err") }}
<h1 class="auth__title" style="margin-top: var(--sp-4)">{{ status_code }}</h1>
<p class="empty__text" style="margin: var(--sp-3) auto var(--sp-5)">{{ flavour }}</p>
{% if detail and detail != flavour %}
<p class="text-sm muted" style="margin-bottom: var(--sp-5)">{{ detail }}</p>
{% endif %}
<a class="btn btn--primary" href="/">Back to your chats</a>
</div>
</main>
{% endblock %}
@@ -0,0 +1,21 @@
{% from "_macros.html" import icon %}
{#
A single chat row. `chat_item` rather than `chat` so this can be included
from a page that already has the open chat bound to `chat`.
#}
<div class="nav-item {% if chat and chat.id == chat_item.id %}is-active{% endif %}"
data-chat-id="{{ chat_item.id }}">
<a class="nav-item__link" href="/chat/{{ chat_item.id }}">
{{ icon("chat", "icon--sm") }}
<span class="nav-item__label" id="chat-link-label-{{ chat_item.id }}">{{ chat_item.title }}</span>
</a>
<span class="nav-item__actions">
<button class="btn btn--icon btn--sm" type="button"
hx-delete="/api/chats/{{ chat_item.id }}"
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
hx-swap="none"
aria-label="Delete chat">
{{ icon("trash", "icon--sm") }}
</button>
</span>
</div>
@@ -0,0 +1,46 @@
{% from "_macros.html" import icon %}
{#
A folder and everything under it. Recursive: a folder includes this same
template for each of its children, so nesting has no fixed depth limit.
Open/closed state is local to the browser (Alpine), not persisted per
request -- collapsing a folder should not cost a round trip.
#}
<div class="folder" x-data="{ open: {{ 'false' if folder.collapsed else 'true' }} }">
<div class="nav-item folder__row">
<button class="folder__toggle" type="button" @click="open = !open"
:aria-expanded="open ? 'true' : 'false'">
<span class="folder__chevron" :class="open && 'is-open'">
{{ icon("chevron-right", "icon--sm") }}
</span>
<span x-show="open">{{ icon("folder-open", "icon--sm") }}</span>
<span x-show="!open" x-cloak>{{ icon("folder", "icon--sm") }}</span>
<span class="nav-item__label">{{ folder.name }}</span>
</button>
<span class="nav-item__actions">
<button class="btn btn--icon btn--sm" type="button"
hx-delete="/api/folders/{{ folder.id }}"
hx-confirm="Delete the folder “{{ folder.name }}”? Chats inside it are kept."
hx-swap="none"
aria-label="Delete folder">
{{ icon("trash", "icon--sm") }}
</button>
</span>
</div>
<div class="folder__contents" x-show="open" x-cloak>
{% for child in folder.children %}
{% with folder = child %}
{% include "partials/_folder.html" %}
{% endwith %}
{% endfor %}
{% for chat_item in folder.chats %}
{% include "partials/_chat_link.html" %}
{% endfor %}
{% if not folder.children and not folder.chats %}
<p class="nav-empty">Empty</p>
{% endif %}
</div>
</div>
@@ -0,0 +1,72 @@
{% from "_macros.html" import icon, brand %}
{#
Sidebar: brand, new chat, the folder tree, then unfiled chats.
Folders render recursively through _folder.html. Chats appear under their
folder, and any chat without one falls to the flat list at the bottom.
#}
<aside class="sidebar" id="sidebar" x-data="{ }">
<div class="sidebar__header">
{{ brand(uid="side") }}
</div>
<div class="sidebar__actions">
<button class="btn btn--primary btn--block" hx-post="/api/chats" hx-swap="none">
{{ icon("plus", "icon--sm") }} New chat
</button>
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
hx-vals='{"name": "New folder"}' aria-label="New folder" title="New folder">
{{ icon("folder") }}
</button>
</div>
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
{% if folders %}
<div class="nav-group">
<div class="nav-group__label">Folders</div>
{% for folder in folders %}
{% include "partials/_folder.html" %}
{% endfor %}
</div>
{% endif %}
<div class="nav-group">
<div class="nav-group__label">Chats</div>
{% if unfiled_chats %}
{% for chat_item in unfiled_chats %}
{% include "partials/_chat_link.html" %}
{% endfor %}
{% else %}
<p class="nav-empty">No chats yet. The road begins here.</p>
{% endif %}
</div>
</nav>
<div class="sidebar__footer">
<div class="row row--between">
<a class="nav-item" href="/settings" style="flex: 1">
{{ icon("user", "icon--sm") }}
<span class="nav-item__label">{{ user.name }}</span>
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
</a>
</div>
<div class="row" style="gap: var(--sp-1); margin-top: var(--sp-1)">
{% if user.is_admin %}
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
{{ icon("shield") }}
</a>
{% endif %}
<button class="btn btn--icon" type="button" data-theme-toggle
aria-label="Switch theme" title="Switch theme">
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
</button>
<span class="topbar__spacer"></span>
<form method="post" action="/auth/logout">
<button class="btn btn--icon" type="submit" aria-label="Sign out" title="Sign out">
{{ icon("logout") }}
</button>
</form>
</div>
</div>
</aside>
+70
View File
@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% from "_macros.html" import icon %}
{% block title %}Your settings - LLeMbas{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
{% endblock %}
{% block body_attrs %} data-authenticated="true"{% endblock %}
{% block body %}
<div class="shell">
{% include "partials/sidebar.html" %}
<main class="main">
<header class="topbar">
<h1 class="topbar__title">Your settings</h1>
</header>
<div class="admin-scroll">
<div class="admin-page">
<section class="card">
<h2 class="card__title">Account</h2>
<div class="field">
<span class="field__label">Name</span>
<p>{{ user.name }}</p>
</div>
<div class="field">
<span class="field__label">Email</span>
<p class="mono text-sm">{{ user.email }}</p>
</div>
<div class="field">
<span class="field__label">Role</span>
<p>
<span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span>
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Appearance</h2>
<div class="row" style="gap: var(--sp-3)">
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
{{ icon("moon", "icon--sm") }} Moria
</button>
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
{{ icon("sun", "icon--sm") }} Shire
</button>
</div>
<p class="field__hint" style="margin-top: var(--sp-3)">
Moria is the dark theme, Shire the light one. Your choice is saved
to this browser and to your account.
</p>
</section>
<section class="card">
<h2 class="card__title">Session</h2>
<form method="post" action="/auth/logout">
<button class="btn btn--danger" type="submit">
{{ icon("logout", "icon--sm") }} Sign out
</button>
</form>
</section>
</div>
</div>
</main>
</div>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
"""Jinja environment and the context every template receives."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi import Request
from fastapi.templating import Jinja2Templates
from lembas import __version__
from lembas.config import settings
from lembas.db.models import User
TEMPLATE_DIR = Path(__file__).parent / "templates"
STATIC_DIR = Path(__file__).parent / "static"
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
templates.env.trim_blocks = True
templates.env.lstrip_blocks = True
def resolve_theme(user: User | None) -> str:
"""Theme to render with on the server.
Only ever a first guess: the inline script in base.html corrects it from
localStorage before first paint. Getting it close server-side is what stops
a signed-in user seeing a flash of the wrong theme on every navigation.
"""
if user is not None:
chosen = (user.settings_json or {}).get("theme")
if chosen in ("moria", "shire"):
return chosen
return settings.default_theme
def render(
request: Request,
template: str,
context: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Render a template with the globals every page expects.
Using this instead of templates.TemplateResponse directly is what
guarantees `user` and `theme` are always defined, so templates never need
to guard against a missing variable.
"""
user = getattr(request.state, "user", None)
payload: dict[str, Any] = {
"request": request,
"user": user,
"theme": resolve_theme(user),
"version": __version__,
"allow_signup": settings.allow_signup,
}
payload.update(context or {})
return templates.TemplateResponse(request, template, payload, **kwargs)
+98
View File
@@ -0,0 +1,98 @@
"""Test fixtures.
Every test runs against a throwaway SQLite file in a tmp_path, never the real
data directory. The environment has to be set before lembas.config is imported,
because Settings is a cached singleton read at import time.
"""
from __future__ import annotations
import os
import tempfile
from collections.abc import Iterator
from pathlib import Path
import pytest
# Must happen before any lembas import.
_TMP = tempfile.mkdtemp(prefix="lembas-tests-")
os.environ.update(
{
"LEMBAS_SECRET_KEY": "test-secret-key-not-for-real-use",
"LEMBAS_DATA_DIR": _TMP,
"LEMBAS_ALLOW_SIGNUP": "true",
"LEMBAS_LOG_LEVEL": "warning",
}
)
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy.orm import Session # noqa: E402
from lembas.config import settings # noqa: E402
from lembas.db.base import Base # noqa: E402
from lembas.db.session import get_engine, get_session_factory, reset_engine # noqa: E402
@pytest.fixture(autouse=True)
def fresh_database(tmp_path: Path) -> Iterator[None]:
"""Point the engine at a per-test database and build the schema.
reset_engine() is essential: the engine is a module-level singleton, so
without it every test after the first would share the first one's file.
"""
settings.data_dir = tmp_path
reset_engine()
settings.ensure_dirs()
import lembas.db.models # noqa: F401 (registers the tables)
Base.metadata.create_all(bind=get_engine())
yield
reset_engine()
@pytest.fixture
def db() -> Iterator[Session]:
session = get_session_factory()()
try:
yield session
finally:
session.close()
@pytest.fixture
def client() -> Iterator[TestClient]:
from lembas.main import app
# raise_server_exceptions=False so error-handler behaviour is exercised
# rather than the exception propagating into the test.
with TestClient(app, raise_server_exceptions=False) as test_client:
yield test_client
@pytest.fixture
def registered(client: TestClient) -> dict[str, str]:
"""Register the first account. It becomes the administrator."""
credentials = {
"name": "Frodo",
"email": "frodo@shire.test",
"password": "speak-friend-and-enter",
}
response = client.post("/auth/register", data=credentials, follow_redirects=False)
assert response.status_code == 303, response.text
return credentials
@pytest.fixture
def user_id(db: Session, registered: dict[str, str]) -> str:
"""The registered user's id.
Chats have a real foreign key to users and SQLite enforces it (the
connect-time PRAGMA in db/session.py turns that on), so tests that build a
Chat directly need a user that actually exists.
"""
from sqlalchemy import select
from lembas.db.models import User
return db.scalar(select(User).where(User.email == registered["email"])).id
+132
View File
@@ -0,0 +1,132 @@
"""Registration, sign-in, and the route guards."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
def test_empty_instance_sends_you_to_registration(client: TestClient):
response = client.get("/auth/login", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/auth/register"
def test_first_account_becomes_admin(client: TestClient, db, registered):
user = db.scalar(select(User).where(User.email == registered["email"]))
assert user.role == ROLE_ADMIN
def test_second_account_is_an_ordinary_user(client: TestClient, db, registered):
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
user = db.scalar(select(User).where(User.email == "sam@shire.test"))
assert user.role == ROLE_USER
def test_registration_signs_you_in(client: TestClient, registered):
assert client.get("/chat").status_code == 200
def test_duplicate_email_is_rejected(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
response = client.post("/auth/register", data=registered, follow_redirects=False)
assert response.status_code == 400
assert "already exists" in response.text
def test_short_password_is_rejected(client: TestClient):
response = client.post(
"/auth/register",
data={"name": "A", "email": "a@b.test", "password": "short"},
follow_redirects=False,
)
assert response.status_code == 400
assert "8 characters" in response.text
def test_sign_in_and_out(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
assert client.get("/chat", follow_redirects=False).status_code == 303
response = client.post(
"/auth/login",
data={"email": registered["email"], "password": registered["password"]},
follow_redirects=False,
)
assert response.status_code == 303
assert client.get("/chat").status_code == 200
def test_wrong_password_does_not_reveal_whether_the_account_exists(
client: TestClient, registered
):
client.post("/auth/logout", follow_redirects=False)
wrong = client.post(
"/auth/login",
data={"email": registered["email"], "password": "wrong-password-here"},
follow_redirects=False,
)
missing = client.post(
"/auth/login",
data={"email": "nobody@nowhere.test", "password": "wrong-password-here"},
follow_redirects=False,
)
assert wrong.status_code == missing.status_code == 401
assert "do not match" in wrong.text
# Identical wording is the whole point: the form must not be usable to
# enumerate which addresses are registered.
assert ("do not match" in missing.text) == ("do not match" in wrong.text)
def test_logout_revokes_the_session_immediately(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
assert client.get("/chat", follow_redirects=False).status_code == 303
def test_signed_out_pages_redirect_to_login(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
for path in ("/", "/chat", "/settings", "/admin/connections"):
assert client.get(path, follow_redirects=False).status_code == 303, path
def test_htmx_requests_get_a_redirect_header_not_a_login_page(
client: TestClient, registered
):
"""An htmx request must never swap a login form into a fragment of the UI."""
client.post("/auth/logout", follow_redirects=False)
response = client.post("/api/chats", headers={"HX-Request": "true"})
assert response.status_code == 204
assert response.headers["HX-Redirect"] == "/auth/login"
def test_admin_area_is_closed_to_ordinary_users(client: TestClient, registered):
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
assert client.get("/admin/connections", follow_redirects=False).status_code == 403
def test_login_next_parameter_cannot_be_used_for_an_open_redirect(
client: TestClient, registered
):
client.post("/auth/logout", follow_redirects=False)
response = client.post(
"/auth/login",
data={
"email": registered["email"],
"password": registered["password"],
"next": "https://evil.example.com/steal",
},
follow_redirects=False,
)
assert response.headers["location"] == "/"
+313
View File
@@ -0,0 +1,313 @@
"""Chat, folders, and the streaming reply path against a mocked endpoint."""
from __future__ import annotations
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from lembas.db.models import Chat, Connection, Folder, Message, Model
from lembas.services import chat as chat_service
from lembas.services.crypto import encrypt
from lembas.services.llm.openai_client import Endpoint, LLMError, delta_text, list_models
from lembas.services.sse import event
# --- SSE framing -------------------------------------------------------------
def test_sse_event_framing():
assert event("token", "hello") == "event: token\ndata: hello\n\n"
def test_sse_splits_newlines_across_data_lines():
"""A payload with a newline must become several data: lines. Sending a raw
newline truncates the event, which is what breaks the first code block a
model emits."""
assert event("token", "a\nb") == "event: token\ndata: a\ndata: b\n\n"
def test_sse_round_trips_through_the_browser_rejoin_rule():
payload = "line one\nline two\n\nline four"
framed = event("token", payload)
body = framed.split("\n", 1)[1]
rejoined = "\n".join(
line.removeprefix("data: ") for line in body.split("\n") if line.startswith("data:")
)
assert rejoined == payload
# --- Delta parsing -----------------------------------------------------------
def test_delta_text_reads_the_normal_shape():
assert delta_text({"choices": [{"delta": {"content": "hi"}}]}) == "hi"
def test_delta_text_handles_typed_content_parts():
chunk = {"choices": [{"delta": {"content": [{"type": "text", "text": "hi"}]}}]}
assert delta_text(chunk) == "hi"
@pytest.mark.parametrize(
"chunk", [{}, {"choices": []}, {"choices": [{}]}, {"choices": [{"delta": {}}]}]
)
def test_delta_text_tolerates_junk(chunk):
"""Providers vary; an unexpected chunk shape must not kill a reply."""
assert delta_text(chunk) == ""
# --- Endpoint URL handling ---------------------------------------------------
@pytest.mark.parametrize(
("base", "expected"),
[
("http://host:1234", "http://host:1234/v1/models"),
("http://host:1234/v1", "http://host:1234/v1/models"),
("http://host:1234/", "http://host:1234/v1/models"),
],
)
def test_base_url_with_or_without_v1(base, expected):
"""Users should not have to guess which form is expected."""
assert Endpoint(base_url=base.rstrip("/"), api_key="", extra_headers={}).url(
"models"
) == expected
def test_no_authorization_header_without_a_key():
"""Local runners often reject an empty bearer token outright."""
assert "Authorization" not in Endpoint("http://h", "", {}).headers()
assert Endpoint("http://h", "k", {}).headers()["Authorization"] == "Bearer k"
# --- Model discovery ---------------------------------------------------------
async def test_list_models_accepts_the_bare_list_shape():
"""The spec says {"data": [...]}, but some servers return a bare list."""
def handler(_request):
return httpx.Response(200, json=[{"id": "a"}, {"id": "b"}])
original = httpx.AsyncClient
class Patched(original):
def __init__(self, **kwargs):
super().__init__(transport=httpx.MockTransport(handler), **kwargs)
httpx.AsyncClient = Patched
try:
assert [m["id"] for m in await list_models(Endpoint("http://h", "", {}))] == ["a", "b"]
finally:
httpx.AsyncClient = original
async def test_list_models_reports_a_rejected_key_readably():
def handler(_request):
return httpx.Response(401, json={"error": {"message": "Incorrect API key."}})
original = httpx.AsyncClient
class Patched(original):
def __init__(self, **kwargs):
super().__init__(transport=httpx.MockTransport(handler), **kwargs)
httpx.AsyncClient = Patched
try:
with pytest.raises(LLMError) as caught:
await list_models(Endpoint("http://h", "bad", {}))
assert "rejected" in caught.value.message
assert "Incorrect API key." in caught.value.message
finally:
httpx.AsyncClient = original
# --- Titles ------------------------------------------------------------------
def test_fallback_title_keeps_a_short_message_intact():
assert chat_service.fallback_title("What is lembas?") == "What is lembas?"
def test_fallback_title_trims_on_a_word_boundary():
title = chat_service.fallback_title("word " * 60)
assert len(title) <= chat_service.MAX_TITLE_LENGTH + 1
assert title.endswith("")
def test_fallback_title_of_nothing():
assert chat_service.fallback_title(" ") == "New chat"
# --- Chats and folders (through the API) -------------------------------------
def _add_connection(db) -> Connection:
# Port 1 refuses connections, which is what the error-path test relies on.
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
return connection
def test_new_chat_redirects_to_its_own_url(client: TestClient, db, registered):
_add_connection(db)
response = client.post("/api/chats", headers={"HX-Request": "true"})
assert response.status_code == 204
assert response.headers["HX-Redirect"].startswith("/chat/")
def test_new_chat_picks_up_the_default_model(client: TestClient, db, registered):
_add_connection(db)
client.post("/api/chats", headers={"HX-Request": "true"})
assert db.scalar(select(Chat)).model_id == "test-model"
def test_posting_a_message_stores_both_turns(client: TestClient, db, registered):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello there"})
assert response.status_code == 200
messages = db.scalars(select(Message).order_by(Message.created_at)).all()
assert [m.role for m in messages] == ["user", "assistant"]
assert messages[0].content == "Hello there"
# The assistant row is created empty and incomplete; that is what carries
# the sse-connect the browser uses to start the stream.
assert messages[1].content == ""
assert messages[1].complete is False
assert "sse-connect" in response.text
def test_empty_message_is_ignored(client: TestClient, db, registered):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
assert client.post(f"/api/chats/{chat_id}/messages", data={"content": " "}).status_code == 204
assert db.scalar(select(Message)) is None
def test_a_chat_belonging_to_someone_else_is_not_found(client: TestClient, db, registered):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
# 404 and not 403: whether that id exists is not this endpoint's to reveal.
assert client.get(f"/chat/{chat_id}").status_code == 404
def test_renaming_a_chat_stops_it_being_auto_titled(client: TestClient, db, registered):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
client.patch(f"/api/chats/{chat_id}", data={"title": "My own title"})
chat = db.get(Chat, chat_id)
db.refresh(chat)
assert chat.title == "My own title"
assert chat.title_generated is True
def test_deleting_a_chat_removes_its_messages(client: TestClient, db, registered):
_add_connection(db)
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
client.delete(f"/api/chats/{chat_id}")
assert db.scalar(select(Chat)) is None
assert db.scalar(select(Message)) is None
def test_deleting_a_folder_keeps_the_chats_inside_it(client: TestClient, db, registered):
"""Losing a conversation to a mis-clicked folder delete is unforgivable."""
_add_connection(db)
client.post("/api/folders", data={"name": "Quests"})
folder = db.scalar(select(Folder))
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id})
client.delete(f"/api/folders/{folder.id}")
chat = db.get(Chat, chat_id)
db.refresh(chat)
assert chat is not None
assert chat.folder_id is None
def test_a_folder_cannot_be_moved_inside_itself(client: TestClient, db, registered):
client.post("/api/folders", data={"name": "Outer"})
folder = db.scalar(select(Folder))
response = client.patch(f"/api/folders/{folder.id}", data={"parent_id": folder.id})
assert response.status_code == 400
# --- Request building --------------------------------------------------------
def test_request_forwards_only_known_sampling_parameters(db, user_id):
"""A stray key in params_json must not become a 400 from the provider that
looks like a LLeMbas bug."""
connection = _add_connection(db)
chat = Chat(
user_id=user_id,
model_id="test-model",
connection_id=connection.id,
params_json={"temperature": 0.4, "nonsense": "drop me"},
)
db.add(chat)
db.commit()
payload = chat_service.build_request(db, chat)
assert payload["temperature"] == 0.4
assert "nonsense" not in payload
def test_history_skips_failed_and_empty_turns(db, user_id):
"""Sending an empty assistant message upsets several providers."""
connection = _add_connection(db)
chat = Chat(user_id=user_id, model_id="test-model", connection_id=connection.id)
db.add(chat)
db.commit()
db.add_all(
[
Message(chat_id=chat.id, role="user", content="one"),
Message(chat_id=chat.id, role="assistant", content="", error="boom"),
Message(chat_id=chat.id, role="user", content="two"),
]
)
db.commit()
contents = [m["content"] for m in chat_service.build_request(db, chat)["messages"]]
assert contents == ["one", "two"]
def test_system_prompt_leads_the_message_list(db, user_id):
connection = _add_connection(db)
chat = Chat(
user_id=user_id,
model_id="test-model",
connection_id=connection.id,
system_prompt="You are terse.",
)
db.add(chat)
db.commit()
messages = chat_service.build_request(db, chat)["messages"]
assert messages[0] == {"role": "system", "content": "You are terse."}
def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
client: TestClient, db, registered
):
"""A failed turn must never be an unexplained blank bubble."""
_add_connection(db) # points at 127.0.0.1:1, which refuses connections
chat_id = client.post("/api/chats").headers["HX-Redirect"].rsplit("/", 1)[1]
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
response = client.get(f"/api/chats/{chat_id}/messages/{message.id}/stream")
assert response.status_code == 200
assert "Could not reach" in response.text
assert "alert--error" in response.text
db.refresh(message)
assert message.complete is True
assert message.error
+67
View File
@@ -0,0 +1,67 @@
"""Secret encryption and password hashing."""
from __future__ import annotations
from lembas.security.passwords import (
hash_password,
validate_password,
verify_password,
)
from lembas.services.crypto import decrypt, encrypt, mask
def test_encrypt_round_trip():
secret = "sk-proj-abcdef1234567890"
assert decrypt(encrypt(secret)) == secret
def test_encrypt_is_not_reversible_by_eye():
secret = "sk-proj-abcdef1234567890"
assert secret not in encrypt(secret)
def test_encrypt_is_randomised():
"""Fernet includes a random IV, so the same input must not repeat."""
assert encrypt("same") != encrypt("same")
def test_empty_secret_stays_empty():
"""Endpoints that need no key store nothing, not an encrypted blank."""
assert encrypt("") == ""
assert decrypt("") == ""
def test_decrypt_fails_soft_on_garbage():
"""An unreadable value means the secret key changed. The admin UI has to
stay usable so the key can simply be re-entered."""
assert decrypt("not-a-valid-token") == ""
def test_mask_keeps_enough_to_identify_but_not_to_use():
masked = mask("sk-proj-abcdef1234567890")
assert masked.startswith("sk-")
assert masked.endswith("7890")
assert "abcdef" not in masked
def test_mask_hides_short_secrets_entirely():
assert set(mask("short")) == {"*"}
def test_password_round_trip():
stored = hash_password("speak-friend")
assert verify_password("speak-friend", stored)
assert not verify_password("Speak-Friend", stored)
def test_password_hash_is_salted():
assert hash_password("same") != hash_password("same")
def test_verify_rejects_a_corrupt_hash_instead_of_raising():
assert not verify_password("anything", "not-a-hash")
def test_short_passwords_are_rejected():
assert validate_password("short") is not None
assert validate_password("long-enough-password") is None
+97
View File
@@ -0,0 +1,97 @@
"""Markdown rendering and sanitisation.
Model output is untrusted input: it routinely contains HTML and a model can be
talked into emitting a script tag. These are the tests that keep that boundary.
"""
from __future__ import annotations
from lembas.services.markdown import escape_text, render_markdown
def test_basic_formatting():
html = render_markdown("Some **bold** and *italic* text.")
assert "<strong>bold</strong>" in html
assert "<em>italic</em>" in html
def test_script_tags_are_stripped():
html = render_markdown("Hello <script>alert('xss')</script> world")
assert "<script" not in html
assert "alert" not in html or "&lt;script" in html
def test_javascript_urls_never_become_links():
"""markdown-it refuses the scheme and leaves the source as literal text, so
the guarantee to assert is that no anchor is produced -- not that the
substring is absent, which it legitimately is not."""
html = render_markdown("[click me](javascript:alert(1))")
assert "<a " not in html
assert 'href="javascript:' not in html
def test_javascript_urls_in_raw_html_anchors_are_stripped():
html = render_markdown('<a href="javascript:alert(1)">click</a>')
assert 'href="javascript:' not in html
def test_event_handlers_are_stripped():
html = render_markdown('<img src="x" onerror="alert(1)">')
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 <pre><code> unless the
# fence rule is replaced outright. This is the regression guard.
assert "<pre><code" not in html
def test_code_block_language_label():
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<script>alert(1)</script>\n```")
assert "<script>" not in html
def test_tables_render():
html = render_markdown("| a | b |\n|---|---|\n| 1 | 2 |")
assert "<table>" in html and "<td>1</td>" in html
def test_bare_urls_are_linkified():
assert "<a href=" in render_markdown("see https://example.com for more")
def test_empty_input():
assert render_markdown("") == ""
def test_escape_text_handles_structural_characters():
assert escape_text("<b>hi</b>") == "&lt;b&gt;hi&lt;/b&gt;"
assert escape_text("a & b") == "a &amp; b"
def test_escape_text_is_chunk_safe():
"""Streaming escapes each token as it arrives, so escaping the pieces must
equal escaping the whole -- otherwise a stream would diverge from the final
rendering."""
whole = "<a>&</a> text"
chunks = ["<a", ">&<", "/a> ", "text"]
assert "".join(escape_text(c) for c in chunks) == escape_text(whole)