Files
LLeMbas/CLAUDE.md
T
Jaroslav Beneš d6c87ac811 Users, groups, permissions, model settings and reasoning display
Four features, plus the schema machinery they needed.

**Schema sync.** The first live instance had data in it, and create_all
only creates missing *tables* -- a new column silently never appeared.
db/migrations.py now diffs the declared models against the database and
ALTER TABLE ... ADD COLUMN for what is missing, deriving a backfill
default from the column type (SQLite refuses a NOT NULL column without
one, and a Python-side `default=dict` cannot be expressed in DDL).
Verified against a copy of the live database: eight changes applied, all
rows preserved, second run a no-op. Renames, drops and retypes are still
manual and say so.

**Permissions.** A flat set of named booleans: an instance baseline
widened by each group the user belongs to. A group grants and never
denies -- with denies, "why can this user not do X" cannot be answered
without simulating every group. Admins bypass entirely, because an admin
can grant it back to themselves in two clicks and pretending otherwise
is theatre. Model *access* is separate: public, or granted to groups.
The picker is not the boundary -- switching a chat to a model you cannot
reach is a 403.

**Model settings.** Ordering, pinned-first, an instance default and a
per-user default, display names, descriptions, capability flags, and
uploaded images. Images are stored and served locally rather than by
URL: a remote URL makes every page render a request to a third party.
Uploads are validated by magic number, not the declared content type,
and stored under a random name. Models with no image get a generated
initial whose hue is derived from the model id, so it is stable.

**Reasoning display.** Streams into its own collapsible block above the
answer, labelled "Thought for 14 seconds", collapsed once finished, and
never replayed as context on the next turn. Two sources: the
reasoning_content delta field, and <think> tags inline in content -- the
latter needs a streaming splitter because the tags arrive split across
chunks. Models emitting no reasoning show nothing, via a :has() rule
rather than JavaScript. Verified against qwen35-9b on llama-swap: 694
reasoning events, 52 answer tokens, cleanly separated.

Two bugs found and fixed while testing:

- A bare `Mapped[list]` relationship is treated by SQLAlchemy as a scalar
  and returns None instead of []. It needs the element type.
- FastAPI substitutes the default for an empty form value, so with
  `x: str | None = Form(None)` a submitted `x=` is indistinguishable from
  an absent field. That silently broke clearing a system prompt or a
  temperature. update_chat now reads the raw form and checks key presence.

143 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:49:32 +02:00

10 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                              # 143 tests, ~5s
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. Additive-only schema changes. SQLite only, no Alembic. init_db() runs db/migrations.py:sync_schema(), which creates missing tables and adds missing columns by diffing the models against the database. Renames, drops and retypes are still manual. 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 + instance settings
    admin_models.py  model ordering, defaults, images, access
    admin_users.py   users, groups, permissions
    preferences.py   per-user theme, default model, password
  db/
    base.py          Base, UUID/Timestamp mixins
    session.py       engine, SQLite pragmas, init_db, session_scope
    migrations.py    additive schema sync (tables + columns)
    models/          user, chat, connection, setting
  security/          passwords (argon2), sessions, permissions
  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
    reasoning.py     splits thinking from the answer
    settings_store.py  runtime instance settings
    uploads.py       validated image storage
    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.

Permissions are a union, and admins bypass them. security/permissions.py resolves a baseline (instance setting) widened by each group. A group grants; it never denies — otherwise "why can this user not do X" needs a simulation of every group to answer. Model access is separate: models_visible_to().

FastAPI cannot tell an empty form field from an absent one. With x: str | None = Form(None), a submitted x= arrives as None, so "clear this field" is indistinguishable from "leave it alone". api/chats.py:update_chat reads await request.form() and checks key presence instead. Anything with a clearable field must do the same.

Mapped[list] without an element type is not a collection. SQLAlchemy treats a bare Mapped[list] as a scalar and hands back None instead of []. Always write Mapped[list[Group]], with a TYPE_CHECKING import if the class lives in another module.

Reasoning arrives two ways. A reasoning_content delta field (llama.cpp, llama-swap, vLLM) or <think> tags inline in content (Ollama and friends). services/reasoning.py handles the second with a streaming splitter, because the tags arrive split across chunks. Reasoning is stored in Message.reasoning and is deliberately not replayed as context on the next turn.

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, but there is db/migrations.py. It compares the declared models against the live database and issues ALTER TABLE ... ADD COLUMN for anything missing, so adding a column to a model is free: restart and it appears, with existing rows backfilled from a type-derived default.

It cannot rename, drop or retype a column, or add a UNIQUE/PRIMARY KEY to an existing table — SQLite mostly cannot do those with ALTER TABLE either. Those need the create-copy-swap dance by hand; record them in MANUAL_STEPS so a failure has somewhere to point.

Because the runner exists, forward-looking columns are cheap now. Message.parent_id and content_parts_json (branching, multimodal) predate it and are still unread.

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

File upload / vision / PDFs, built-in tools + admin tool settings, custom tools and MCP, agentic execution (local subprocess and SSH connection profiles), image generation. Nav entries mark where each one goes.

Model.capabilities_json already carries vision and tools flags that nothing reads yet — they are admin overrides waiting for those features.