Files that outlived the chats that held them, and a page that led with its footnotes

The second audit pass. Four things, and the first two were reported.

The Prompts page put a screen of variables and a screen of preview above
the editor, so the tabs began two screens down and switching one had to
drag the whole page to be any use -- and on a short tab it could not drag
far enough, leaving the panel stranded above a screenful of nothing.
Editor first, reference after, bar sticky. Custom themes were three fixed
slots: fifty-seven empty colour boxes on a fresh instance and no way to
make a fourth theme. One block per theme plus a blank one, colours behind
a disclosure. Both measured rather than argued about -- rendered through
TestClient and driven under headless Chromium, where the tab bar moved
385->642px before and does not move now, and the themes page went from
5495px to 2820px.

Asking where generated images go found the other two. Deleting a chat
cascades to the attachment rows and leaves every file on disk; the helper
written for exactly that was called from one place, and it was not the
delete button, a schedule's chat, a helper's chat or deleting an account.
Underneath it, `claim` bound message_id and never chat_id, so anything
picked before a chat existed kept an empty chat_id forever -- which six
readers filter on, so those files were also unnamed in the prompt,
unopenable in the canvas, and invisible to the one caller the cleanup had.

And folders nest now. The route has handled parent_id since folders
existed, with a cycle guard and a depth cap the move path never applied;
the sidebar has always drawn a tree. Nothing could ask for one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 13:20:59 +02:00
parent e970f10cca
commit 59739cc7fd
23 changed files with 780 additions and 103 deletions
+110 -5
View File
@@ -99,18 +99,49 @@ runtime. Clone it, `pip install -e .`, run it.
- **Model settings** — searchable, filterable list with a page per model:
ordering, pinned models, an instance default and a per-user default, custom
names, descriptions and images. Scales to hundreds of models
- **Things that happen because time passed** — say "every Monday at nine" and a
model can set it up itself, against the same recurrence rule the manual form
uses. A run can file a **report** you read later, send you a message, or work
on in a chat of its own. The reply says the timing back in words, which is the
one moment anybody can check that Monday was understood as Monday
- **News that finds you** — a dot in the sidebar, a count in the tab title while
you are looking elsewhere, and **web push** so a schedule firing at seven in
the morning reaches a browser that is shut. Opt-in per device
- **Helpers** — a reply can hand a self-contained piece of work to another model
that runs on its own and reports back, several at once, so research fans out
instead of queueing. A helper cannot ask questions, cannot send helpers of its
own, and on a machine runs only a fixed list of read-only commands
- **Drawing** — point it at a ComfyUI and a model can make images, against
workflow templates and defaults you set: size, steps, sampler, scheduler,
checkpoint. It reviews its own result and can try again
- **Semantic search** — pick an embedding model and library search fuses keyword
and meaning, so *"how do I get paid"* finds a document that says *"invoicing"*.
Choosing none is not a degraded mode: it is byte-for-byte the keyword search
that was always there, with nothing written and no requests made
- **Users, groups & permissions** — per-group grants that union rather than
override, and model access restricted to chosen groups
override, model access restricted to chosen groups, read and write split for
notes, memory and skills, and a screen that answers *"what can this account
actually do?"* by naming where each permission came from
- **Quotas** — monthly tokens, concurrent replies, agent wall clock, images a
day, helpers a reply. Resolved by maximum across a person's groups, with zero
meaning *no limit*
- **Sharing** — hand a document, a note, a skill or a report to a group or a
person, read-only, with a *Shared with me* filter in every listing
- **Make it yours** — name, tagline, logo, favicon and launcher icons; the
Middle-earth wording is editable data; custom **themes** defined as a set of
colours rather than a stylesheet, and global CSS overrides
- **Accounts** — first account becomes the administrator, argon2 password
hashing, revocable server-side sessions, self-service password change,
admin-managed accounts
- **Admin settings** — open or close registration from the UI, stored in the
database and effective immediately
- **Two themes** — *Moria* (dark) and *Shire* (light), switchable per user
- **Admin settings** — registration, upload and extraction limits, prompt
fragments, and an **Updates** page showing what is running, what is available
and what changed between
- **Two themes and your own** — *Moria* (dark), *Shire* (light), and as many
more as you care to define
**Planned**
Image generation · OCR for scanned PDFs · semantic search in the library.
OCR for scanned PDFs · conversation branching · chat export · archived chats.
See [PLAN.md](PLAN.md) for what is built, what is not, and why.
@@ -322,6 +353,80 @@ lembas secret-key # generate a value for LEMBAS_SECRET_KEY
lembas create-admin # create or promote an administrator
```
## Running it somewhere
Three ways, all in this repository.
### Docker
```bash
export LEMBAS_SECRET_KEY="$(lembas secret-key)" # required; there is no default
docker compose up -d
```
One stage, no build step, non-root. The image bakes **no secret key, no data and
no `.git`** — a key inside an image is one every copy shares, and rotating it
makes stored API keys unreadable. Data lives in a named volume on `/data`.
`docker-compose.yml` publishes on `127.0.0.1` and expects a TLS proxy in front:
the service worker and the microphone both require HTTPS or localhost, so plain
http on a LAN address is a constraint rather than a preference. One replica, and
that is deliberate — the generation registry, the terminal sessions and the
schedule ticker are all in-process, so two would mean every schedule firing
twice.
**Updating a container is pulling a new image**, and `/admin/updates` says so
rather than offering a button:
```bash
docker compose pull && docker compose up -d
```
There is deliberately no in-container update helper. The one the other install
paths use restarts a systemd service; the equivalent here would be a process
inside the container reaching the Docker socket to replace the container it is
running in — which is root on the host, granted to anybody who can administer
the web interface. The image is the unit of deployment, and that is the whole
point of it.
### A machine of its own
`deploy/` holds a systemd unit, an nginx vhost, and install/update scripts. Every
template is parameterised and substituted at install time, so nothing
host-specific is committed here. See [deploy/README.md](deploy/README.md).
`deploy/lxc-install.sh` creates an unprivileged Proxmox container and runs that
same installer inside it — a wrapper around what already works rather than a
second install path:
```bash
CTID=140 SITE_HOST=chat.example ./deploy/lxc-install.sh
```
The container gets the **update helper by default**, unlike a bare
`install.sh`. The installer defaults it off because it cannot know what it is
installing onto; a container this script made thirty seconds ago to run one
thing, on a hypervisor you own, is not that host — and an appliance you cannot
update without a shell is one nobody updates. `INSTALL_UPDATE_HELPER=0` opts
out.
### Updating
**Admin → Updates** shows the version running, what is available on the channel
this host follows, and the commits between. `stable` is the newest `vX.Y.Z` tag;
`edge` is the branch tip, which is whatever was pushed most recently.
The button that applies an update is **opt-in**, and that is the design: the
service runs unprivileged and cannot restart itself, so the request is a file
that a systemd `.path` unit picks up and runs as root. It carries no ref and no
channel — pressing it is always "deploy the channel this host was configured
with", never "deploy something else". Install it with
`INSTALL_UPDATE_HELPER=1`; without it the page says so and prints the command to
run by hand.
Release notes come out of the annotated tag itself, so no forge API is involved
anywhere.
## How it fits together
```