Files
LLeMbas/CLAUDE.md
T
Jaroslav Beneš ba2fb1e13d Add registration toggle and password change; genericise deploy
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>
2026-07-21 11:14:33 +02:00

8.4 KiB
Raw Blame History

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.

  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.

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.