Seven things. **Reasoning starts closed.** The answer is what the reader is waiting for; the thinking is one click away. **Image borders.** .attachments__image was a block-level <a>, so its border stretched the full column around a narrow picture. inline-block, and the frame is the picture. Same fix for the composer thumbnail. **Markdown now renders during the stream.** The generator re-renders the answer so far and sends it as a `render` event at most every 100ms, swapped with innerHTML, instead of appending escaped tokens and formatting everything at the end. Re-rendering whole rather than appending is the point: a list or a code fence is only correct once its context exists, and partial syntax resolves itself as more arrives. Measured against a live model: 29 render events, formatting visible from the first content token. **Stop button.** A stop request goes into an in-process set the generator checks between chunks; whatever arrived is kept, because a half-written answer the reader chose to cut short is still worth having. Measured: stream ended 0.2s after the request, 1155 characters preserved, message marked stopped rather than errored. Navigating away does the same thing via CancelledError. **Rewind and edit.** Edit one of your own turns and everything after it is deleted, then the conversation runs on from there. Deliberately not branching: that needs a UI for choosing between versions, and "go back and try again from here" is what was asked for. The form states how many messages will be discarded before you confirm. **Custom model picker.** A <select> renders only text in an <option>, so it can never show an avatar. Built from buttons and a hidden input, with descriptions, capability tags, a filter box past eight models, and arrow-key navigation written out by hand since there is no native widget doing it. **Notification system.** lembas.notify/confirm/prompt in ui.js, built on <dialog> so focus trapping, Escape and page inertness come from the browser. htmx:confirm is intercepted, so every existing hx-confirm gets the themed dialog with no change at the call site; the browser's grey confirm() is gone from every template. 230 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 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 # 230 tests, ~9s
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 values outside
tokens.css. Every colour, space, radius and control height resolves through a CSS variable.--control-his why buttons, inputs and selects line up: they all take their height from it, so a mixed row is flush by construction rather than by nudging. - Additive-only schema changes. SQLite only, no Alembic.
init_db()runsdb/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. - 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 + instance settings
admin_models.py model ordering, defaults, images, access
admin_users.py users, groups, permissions
files.py upload, serve, remove attachments
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
files.py attachment validation, images, PDF/text extraction
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.
Attachments are typed by their bytes, not their name. services/files.py
sniffs magic numbers; a .png full of text is stored as text. Images are
downscaled and re-encoded (a phone photo is megabytes of base64), PDFs have
their text extracted once at upload — re-extracting per request would let a
reply change because a parser was upgraded.
Images only go to models marked vision. Sending content parts to an
endpoint without multimodal support is not graceful degradation; most reject
the whole request. build_request() checks the capability and falls back to a
plain string. A plain text turn must stay a plain string for the same reason.
Attachments are served, never linked. Images reach the model as base64 data
URIs: a local endpoint has no route back to LLeMbas and a hosted one has no
credentials. Non-images are served Content-Disposition: attachment with
nosniff, so an uploaded .html cannot execute in this origin.
Uploads are unbound until the message is sent. Attachment.message_id is
null in the composer; files.claim() binds them, and only unclaimed rows owned
by that user, so a forged id cannot pull in someone else's file. Abandoned ones
are swept at startup.
Markdown renders progressively, server-side. The stream sends render
events carrying the whole answer re-rendered from Markdown, at most every
RENDER_INTERVAL, swapped with innerHTML. Appending raw tokens instead would
mean formatting only appearing at the end -- a list or code fence is only
correct once its context exists, so partial output must be re-rendered whole
rather than appended to.
Stopping a stream is an in-process set. api/chats.py:_CANCELLED holds
message ids the reader asked to stop; the generator checks it between chunks.
Correct for the single-worker deployment this ships with; multiple workers
would need it in the database or a broker.
Editing rewinds, it does not branch. POST .../messages/{id}/edit rewrites
a user turn and deletes everything after it. Branching would need a UI for
choosing between versions; "go back and try again from here" is what was asked
for and what other clients do. Message.parent_id still exists unused.
Dialogs and toasts are ours, not the browser's. static/js/ui.js provides
lembas.notify/confirm/prompt, and intercepts htmx's htmx:confirm so every
existing hx-confirm gets the themed dialog with no change at the call site.
Plain forms opt in with data-confirm, lone submit buttons with
data-confirm-button. Never add a window.confirm back.
The model picker is hand-built. A <select> renders only text in an
<option> -- no avatar, no description, no badges. chat/_model_picker.html
plus the picker block in ui.js; the value lives in a hidden input so it still
behaves as a form field.
Chats are created lazily. There is no endpoint that makes an empty chat.
"New chat" is a link to /chat, which renders a composer with no row behind
it; POST /api/chats/start writes the chat together with its first message.
That is why an opened-and-abandoned chat never appears in the sidebar. Tests
that just need a chat use the make_chat fixture rather than the HTTP flow.
Admin lists are list-plus-detail, never a form per row. /admin/models
renders compact rows with search, filter tabs and pagination; the full form
lives at /admin/models/{id}/edit. A connection can advertise a hundred models,
and a page that renders a form for each is unusable. Any future admin list
(tools, agents) should follow the same shape.
Route order matters for static path segments. FastAPI matches in
registration order, so /admin/models/bulk must be registered before
/admin/models/{model_id} or "bulk" is parsed as a model id and 404s. This has
already been a bug once.
Pinning is not ordering. The model picker is always in the administrator's
position order. Pinned models get shortcuts in the chat sidebar and nothing
else -- a picker whose order differs from the admin screen is just confusing.
System prompts are precedence, not concatenation. chat > model > instance,
most specific wins outright (services/chat.py:effective_system_prompt).
Stacking them reads well in a settings screen and badly in practice: two layers
that disagree give the model contradictory instructions and nobody can tell
which is losing.
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
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 carries a tools flag nothing reads yet. No OCR:
a scanned PDF is stored with an explanatory extraction_error rather than
silently contributing nothing.