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 bdce2764b1
commit 7b67568f2c
30 changed files with 1264 additions and 536 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>
+17 -4
View File
@@ -1,4 +1,5 @@
{% from "_macros.html" import icon, mark %}
{% from "_macros.html" import icon, mark, model_avatar %}
{% set speaking_model = (models_by_id | default({})).get(message.model_id) %}
{#
One message bubble, in either of two states.
@@ -22,7 +23,14 @@
<div class="msg__gutter" aria-hidden="true">
{% if message.role == "assistant" %}
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
{# The model that actually wrote this turn, which is not necessarily the
one the chat is set to now. Falls back to the LLeMbas mark when the
model has been removed or has no image of its own. #}
{% if speaking_model %}
{{ model_avatar(speaking_model, cls="msg__avatar") }}
{% else %}
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
{% endif %}
{% else %}
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
{% endif %}
@@ -31,9 +39,14 @@
<div class="msg__main">
<header class="msg__meta">
<span class="msg__author">
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
{% if message.role == "assistant" %}
{{ speaking_model.label if speaking_model else "LLeMbas" }}
{% else %}
{{ user.name or "You" }}
{% endif %}
</span>
{% if message.model_id %}
{% if message.model_id and (not speaking_model or speaking_model.display_name) %}
{# Only worth showing when it adds something the author line does not. #}
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
{% endif %}
</header>
+58 -126
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% from "_macros.html" import icon, mark %}
{% from "_macros.html" import icon, mark, model_avatar %}
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
{% block title %}{{ chat.title if chat else "New chat" }} - LLeMbas{% endblock %}
{% block head %}
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
@@ -20,84 +20,94 @@
{{ icon("sidebar") }}
</button>
{% if chat %}
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
<h1 class="topbar__title">
<span id="chat-title">{{ chat.title if chat else "New chat" }}</span>
</h1>
{% if models and can.get("chat.model_select") %}
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change from:find select">
<select class="select select--compact" name="model_id" aria-label="Model">
{% if pinned_models %}
<optgroup label="Pinned">
{% for model in pinned_models %}
<option value="{{ model.model_id }}"
{{ 'selected' if model.model_id == chat.model_id }}>{{ model.label }}</option>
{% endfor %}
</optgroup>
<optgroup label="Other models">
<div class="topbar__actions">
{% if models %}
{% if can.get("chat.model_select") or not chat %}
{# Models are listed in the administrator's order. Pinning is a
sidebar shortcut and deliberately does not reorder this. #}
<label class="model-select">
{% if current_model %}{{ model_avatar(current_model, cls="model-select__avatar") }}{% endif %}
<select class="select select--bare" name="model_id" id="model-select"
aria-label="Model"
{% if chat %}hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change"{% else %}onchange="
const u = new URL(window.location);
u.searchParams.set('model', this.value);
window.location = u;
"{% endif %}>
{% for model in models %}
<option value="{{ model.model_id }}"
{{ 'selected' if current_model and model.model_id == current_model.model_id }}>
{{ model.label }}
</option>
{% endfor %}
</select>
</label>
{% elif current_model %}
<span class="badge">{{ current_model.label }}</span>
{% endif %}
{% for model in other_models %}
<option value="{{ model.model_id }}"
{{ 'selected' if model.model_id == chat.model_id }}>{{ model.label }}</option>
{% endfor %}
{% if pinned_models %}</optgroup>{% endif %}
</select>
</form>
{% elif current_model %}
<span class="badge">{{ current_model.label }}</span>
{% endif %}
{% endif %}
<button class="btn btn--icon" type="button" aria-label="Chat settings"
title="Chat settings"
onclick="document.getElementById('chat-settings').toggleAttribute('hidden')">
{{ icon("sliders") }}
</button>
{% else %}
<h1 class="topbar__title">Chats</h1>
{% endif %}
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
<button class="btn btn--icon" type="button" aria-label="Chat settings"
title="Chat settings" data-toggle="#chat-settings">
{{ icon("sliders") }}
</button>
{% endif %}
</div>
</header>
{% if chat and (can.get("chat.system_prompt") or can.get("chat.params")) %}
{# Collapsed by default: these are per-chat overrides, not everyday controls.
Each field saves on change rather than needing a Save button, so there is
no half-applied state to reason about. #}
<section class="chat-settings" id="chat-settings" hidden>
<div class="chat-settings__inner">
{# Collapsed by default: per-chat overrides, not everyday controls. Each
field saves on change, so there is no half-applied state. #}
<section class="panel" id="chat-settings" hidden>
<div class="panel__inner">
{% if current_model and current_model.description %}
<p class="chat-settings__note">{{ current_model.description }}</p>
<p class="panel__note">{{ current_model.description }}</p>
{% endif %}
{% if can.get("chat.system_prompt") %}
<div class="field">
<label class="field__label" for="system-prompt">System prompt</label>
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="3"
placeholder="Instructions that apply to every message in this chat."
placeholder="{{ inherited_prompt or 'Instructions that apply to every message in this chat.' }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change">{{ chat.system_prompt }}</textarea>
<p class="field__hint">
{% if inherited_prompt %}
Leave empty to use the {{ inherited_from }} prompt shown above.
{% else %}
Overrides the model and instance prompts for this chat only.
{% endif %}
</p>
</div>
{% endif %}
{% if can.get("chat.params") %}
<div class="chat-settings__params">
<div class="grid grid--3">
<div class="field">
<label class="field__label" for="temperature">Temperature</label>
<input class="input" id="temperature" name="temperature" type="number"
min="0" max="2" step="0.05" placeholder="default"
value="{{ chat.params_json.get('temperature', '') }}"
value="{{ chat.params_json.get('temperature') if chat.params_json.get('temperature') is not none else '' }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
<div class="field">
<label class="field__label" for="top-p">Top-p</label>
<input class="input" id="top-p" name="top_p" type="number"
min="0" max="1" step="0.05" placeholder="default"
value="{{ chat.params_json.get('top_p', '') }}"
value="{{ chat.params_json.get('top_p') if chat.params_json.get('top_p') is not none else '' }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
<div class="field">
<label class="field__label" for="max-tokens">Max tokens</label>
<input class="input" id="max-tokens" name="max_tokens" type="number"
min="1" step="1" placeholder="default"
value="{{ chat.params_json.get('max_tokens', '') }}"
value="{{ chat.params_json.get('max_tokens') if chat.params_json.get('max_tokens') is not none else '' }}"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change">
</div>
</div>
@@ -110,22 +120,8 @@
</section>
{% endif %}
{% if not chat %}
{# No chat selected. #}
<div class="empty">
{{ mark(cls="empty__mark", uid="empty") }}
<h2 class="empty__title">The road goes ever on</h2>
<p class="empty__text">
Pick a chat from the side, or start a new one.
</p>
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
{{ icon("plus", "icon--sm") }} New chat
</button>
</div>
{% elif not models %}
{# Nothing to talk to yet. This is the state every fresh install lands in,
so it points straight at the fix rather than just reporting a problem. #}
{% if not models %}
{# Every fresh install lands here, so it points at the fix. #}
<div class="empty">
{{ icon("server", "empty__mark") }}
<h2 class="empty__title">No models available</h2>
@@ -164,71 +160,7 @@
</div>
</div>
<div class="composer" {% if can.get("files.upload") %}data-dropzone{% endif %}>
{% if can.get("files.upload") %}
{# Uploads go up as soon as a file is chosen, so the chip (and any
rejection) appears immediately rather than at send time. The chips
carry hidden inputs, which is how the ids reach the message POST. #}
<form id="upload-form" hx-post="/api/files?chat_id={{ chat.id }}"
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__attachments" id="attachments"></div>
<form class="composer__form"
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();
const t = this.querySelector('textarea');
window.lembas.autosize(t);
window.lembas.scrollThread(true);
}">
{# The chips live outside this form, so their hidden inputs are pulled
in explicitly at submit time. #}
<div hx-include="#attachments" hidden></div>
{% if can.get("files.upload") %}
<button class="btn btn--icon composer__attach" 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="Send a message…" aria-label="Message"></textarea>
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
{{ icon("send", "icon--sm") }}
</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>
{% if can.get("files.upload") %}
<div class="dropzone-overlay" aria-hidden="true">
{{ icon("attach", "icon--lg") }}
<span>Drop to attach</span>
</div>
{% endif %}
</div>
{% include "chat/_composer.html" %}
{% endif %}
</main>
</div>