Files
LLeMbas/CLAUDE.md
T
Jaroslav Beneš dd9e0e9440 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>
2026-07-21 11:04:13 +02:00

7.8 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.

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.