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>
This commit is contained in:
Jaroslav Beneš
2026-07-21 11:49:32 +02:00
parent ba2fb1e13d
commit d6c87ac811
37 changed files with 2887 additions and 152 deletions
+53 -18
View File
@@ -19,7 +19,7 @@ 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
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
@@ -38,9 +38,10 @@ redesign, not a tweak.
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.
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
@@ -71,18 +72,24 @@ src/lembas/
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
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
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
@@ -138,20 +145,46 @@ 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. `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.
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.
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.
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
@@ -182,7 +215,9 @@ 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.
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.