Two things the running instance needed. **Registration toggle.** Admin -> General, backed by a new settings table group rather than the environment. LEMBAS_ALLOW_SIGNUP now seeds only the initial value: once an administrator saves the setting, the stored value wins. The alternative -- environment always winning -- means a toggle in the UI silently reverts on the next restart, which is worse than not offering one. Closing registration also removes the "Create one" link from the sign-in page, so the link never leads somewhere that refuses. **Password change**, on the user settings page. Changing a password revokes every other session and immediately re-issues a cookie for the current one: if the reason for the change is that somebody else knows the password, leaving their session alive defeats the point, but signing the user out of the tab they are standing in is merely rude. **deploy/ is now host-agnostic.** This repository is public, so the unit and vhost became templates with __PREFIX__ / __SITE_HOST__ / __APP_PORT__ substituted at install time, and every path, hostname and port moved to environment variables. REPO_URL defaults to the checkout's own origin so a fork deploys itself. Machine-specific values belong in private notes, not here -- CLAUDE.md now says so. 83 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.4 KiB
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
. .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.
- No Node, no npm, no build step. Browser libraries are downloaded once by
scripts/fetch_vendor.py, hash-pinned inscripts/vendor.lock.json, and committed underweb/static/vendor/. - Nothing loads from a CDN at runtime. A self-hosted tool must work offline and must not report page views to a third party.
- 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. - No migration tool. SQLite only, schema created at startup by
init_db(), which isCREATE TABLE IF NOT EXISTSand never alters an existing table. See "Changing the schema" below. - Secrets never reach the browser. API keys are Fernet-encrypted at rest and only ever rendered masked.
- Model output is untrusted. Everything from an endpoint goes through
services/markdown.py(markdown-it → nh3) orescape_text(). Never|safeon 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.
Two kinds of settings. lembas.config is deployment configuration read
from the environment at startup. services/settings_store.py is instance
settings an admin edits at runtime, stored in the settings table. Environment
variables seed the latter as an initial value only — once stored, the database
wins, or a toggle in the UI would silently revert on the next restart.
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 a systemd unit template, an nginx vhost template, and
install/update scripts. Both templates are parameterised (__PREFIX__,
__SITE_HOST__, …) and substituted at install time, so nothing host-specific is
committed here. See deploy/README.md.
This repository is public. Keep deployment-specific hostnames, ports and internal infrastructure detail out of it — those belong in whatever private notes describe the 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.