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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user