Fix bulk actions, create chats lazily, rework the UI

Seven reported problems.

**Bulk model actions 404'd.** /admin/models/{model_id} was registered
before /admin/models/bulk, and FastAPI matches in registration order, so
"bulk" was parsed as a model id. Moved above the parameterised route,
with a comment saying why, and a regression test.

**Empty chats piled up.** There is now no endpoint that creates one.
"New chat" is a link to /chat, which renders a composer with no row
behind it, and POST /api/chats/start writes the chat together with its
first message. Opening one and walking away leaves nothing.

**Pinning meant two different things.** The picker is now always in the
administrator's position order; pinned models get shortcuts in the chat
sidebar and nothing else. A picker whose order silently differs from the
admin screen is just confusing.

**Model images were missing in chat.** Assistant bubbles now show the
avatar of the model that actually wrote the turn -- which is not always
the model the chat is set to now -- falling back to the LLeMbas mark.
The picker shows it too.

**No global or per-model system prompt.** Three layers now: instance
(Admin -> General), model (Admin -> Models), chat. Precedence, not
concatenation: most specific wins outright. Stacking them reads well in
a settings screen and badly in practice, because two layers that
disagree give the model contradictory instructions and nobody can tell
which is losing. The chat panel shows the inherited prompt as
placeholder text so "leave empty to inherit" is not a guess.

**Alignment and button sizing.** Added --control-h and friends to
tokens.css; every button, input and select takes its height from them,
so a mixed row is flush by construction rather than by per-instance
nudging. Icon buttons are square at that height. Added .btn-row,
.card__header/.card__footer and .grid so pages stop carrying inline
styles, and moved every admin page onto them.

**Settings needed structure.** The user settings page is now tabbed
(Account / Models / Appearance / Security) using radio inputs and
sibling selectors -- no JavaScript, and the browser keeps the chosen tab
across a re-render.

Caught while checking: the chat.css surgery had deleted the attachment,
chip and dropzone rules. Restored, and there is now a check that every
literal class used in a template has a CSS rule.

197 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 12:59:52 +02:00
parent d90195015c
commit 29db54960e
29 changed files with 1238 additions and 532 deletions
@@ -0,0 +1,84 @@
{% from "_macros.html" import icon %}
{#
The composer, used both inside an existing chat and on /chat where no chat
row exists yet.
The only difference is where it posts. With a chat, the turn is appended to
the thread in place; without one, /api/chats/start creates the chat and
redirects, and the reply streams on arrival because the page renders the
unfinished assistant message with its sse-connect. That is what stops an
opened-and-abandoned chat ever being written to the database.
#}
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
{% if can.get("files.upload") %}
<form id="upload-form" hx-post="/api/files{% if chat %}?chat_id={{ chat.id }}{% endif %}"
hx-target="#attachments" hx-swap="beforeend"
hx-encoding="multipart/form-data" hx-on::after-request="this.reset()">
<input class="visually-hidden" type="file" name="file" id="file-input" multiple
accept="image/*,.pdf,.txt,.md,.csv,.json,.py,.js,.ts,.rs,.go,.sh,.sql,.yaml,.yml,.toml,.log"
onchange="window.lembas.uploadFiles(this.files); this.value = ''">
</form>
{% endif %}
<div class="composer__inner">
<div class="composer__attachments" id="attachments"></div>
<form class="composer__form"
{% if chat %}
hx-post="/api/chats/{{ chat.id }}/messages"
hx-target="#thread" hx-swap="beforeend"
hx-on::after-request="if (event.detail.successful) {
this.reset();
document.getElementById('attachments').replaceChildren();
window.lembas.autosize(this.querySelector('textarea'));
window.lembas.scrollThread(true);
}"
{% else %}
hx-post="/api/chats/start" hx-swap="none"
{% endif %}>
{# The chips live outside this form, so their hidden inputs are pulled in
explicitly at submit time. #}
<div hx-include="#attachments" hidden></div>
{% if not chat and current_model %}
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
{% endif %}
{% if can.get("files.upload") %}
<button class="btn btn--icon composer__btn" type="button"
aria-label="Attach a file" title="Attach a file"
onclick="document.getElementById('file-input').click()">
{{ icon("attach") }}
</button>
{% endif %}
<textarea class="composer__input" name="content" rows="1"
data-autosize data-max-height="320" data-composer-input
placeholder="{% if chat %}Send a message…{% else %}Ask anything…{% endif %}"
aria-label="Message" {{ 'autofocus' if not chat }}></textarea>
<button class="btn btn--primary btn--icon composer__btn" type="submit"
aria-label="Send">
{{ icon("send") }}
</button>
</form>
<p class="composer__hint">
Enter to send, Shift+Enter for a new line.
{% if can.get("files.upload") %}
Drag files in, or paste an image.
{% if current_model and not current_model.capabilities_json.get("vision") %}
<strong>{{ current_model.label }} has no vision</strong>, so images
will not be sent — documents still will.
{% endif %}
{% endif %}
</p>
</div>
{% if can.get("files.upload") %}
<div class="dropzone-overlay" aria-hidden="true">
{{ icon("attach", "icon--lg") }}
<span>Drop to attach</span>
</div>
{% endif %}
</div>