29db54960e
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>
176 lines
7.6 KiB
HTML
176 lines
7.6 KiB
HTML
{% 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.
|
|
|
|
An incomplete assistant message renders the streaming shell: it carries the
|
|
sse-connect that opens the reply stream. This is deliberately the ONLY thing
|
|
that starts a generation, which means a page load showing an unfinished reply
|
|
picks it up again -- reloading after a dropped connection retries rather than
|
|
leaving a permanently half-written answer.
|
|
|
|
A complete message renders its finished body: Markdown for the assistant,
|
|
escaped plain text for everyone else.
|
|
#}
|
|
{% set streaming = (message.role == "assistant" and not message.complete) %}
|
|
|
|
<article class="msg msg--{{ message.role }}" id="msg-{{ message.id }}"
|
|
{% if streaming %}
|
|
hx-ext="sse"
|
|
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
|
|
sse-close="close"
|
|
{% endif %}>
|
|
|
|
<div class="msg__gutter" aria-hidden="true">
|
|
{% if message.role == "assistant" %}
|
|
{# 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 %}
|
|
</div>
|
|
|
|
<div class="msg__main">
|
|
<header class="msg__meta">
|
|
<span class="msg__author">
|
|
{% if message.role == "assistant" %}
|
|
{{ speaking_model.label if speaking_model else "LLeMbas" }}
|
|
{% else %}
|
|
{{ user.name or "You" }}
|
|
{% endif %}
|
|
</span>
|
|
{% 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>
|
|
|
|
{% if message.attachments %}
|
|
{# Above the text, matching the order they were added and the order the
|
|
model receives them. #}
|
|
<div class="attachments">
|
|
{% for attachment in message.attachments %}
|
|
{% if attachment.is_image %}
|
|
<a class="attachments__image" href="/api/files/{{ attachment.id }}/content"
|
|
target="_blank" rel="noopener">
|
|
<img src="/api/files/{{ attachment.id }}/content" alt="{{ attachment.filename }}"
|
|
loading="lazy" width="{{ attachment.width }}" height="{{ attachment.height }}">
|
|
</a>
|
|
{% else %}
|
|
<div class="attachments__doc">
|
|
{{ icon("attach", "icon--sm") }}
|
|
<span class="attachments__doc-body">
|
|
<a href="/api/files/{{ attachment.id }}/content">{{ attachment.filename }}</a>
|
|
<span class="chip__meta">
|
|
{{ attachment.human_size }}
|
|
{%- if attachment.pages %} · {{ attachment.pages }} page{{ '' if attachment.pages == 1 else 's' }}{% endif %}
|
|
{%- if attachment.truncated %} · truncated{% endif %}
|
|
{%- if attachment.extracted_text %}
|
|
· <a href="/api/files/{{ attachment.id }}/text" target="_blank"
|
|
rel="noopener">view extracted text</a>
|
|
{%- endif %}
|
|
</span>
|
|
{% if attachment.extraction_error %}
|
|
<span class="chip__warning">{{ attachment.extraction_error }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</div>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if streaming %}
|
|
{# Reasoning arrives before the answer, so this block sits above it. It
|
|
starts open (watching a model think is the point) and the :has() rule
|
|
in chat.css hides the whole thing while it is still empty, so models
|
|
that emit no reasoning never show an empty box. #}
|
|
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}" open>
|
|
<summary class="reasoning__summary">
|
|
{{ icon("sparkle", "icon--sm reasoning__icon") }}
|
|
<span class="reasoning__label">Thinking</span>
|
|
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
|
</summary>
|
|
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
|
|
</details>
|
|
|
|
{# Tokens are appended here as they arrive. The cursor is a CSS
|
|
pseudo-element on the empty parent, so it disappears by itself once
|
|
the first token lands. #}
|
|
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
|
|
sse-swap="token" hx-swap="beforeend"></div>
|
|
<div class="msg__waiting">
|
|
<span class="dots"><i></i><i></i><i></i></span>
|
|
</div>
|
|
{% elif message.reasoning and not message.error %}
|
|
{# Collapsed once finished: the answer is what the reader came for, and
|
|
the thinking is there if they want to audit it. #}
|
|
<details class="reasoning" id="reasoning-{{ message.id }}">
|
|
<summary class="reasoning__summary">
|
|
{{ icon("sparkle", "icon--sm reasoning__icon") }}
|
|
<span class="reasoning__label">
|
|
{% if message.reasoning_ms %}
|
|
Thought for {{ message.reasoning_ms | duration }}
|
|
{% else %}
|
|
Reasoning
|
|
{% endif %}
|
|
</span>
|
|
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
|
</summary>
|
|
<div class="reasoning__body">{{ message.reasoning }}</div>
|
|
</details>
|
|
<div class="msg__body">{{ body_html|safe }}</div>
|
|
|
|
{% elif message.error %}
|
|
<div class="alert alert--error msg__error" role="alert">
|
|
{{ icon("warning", "alert__icon") }}
|
|
<div>
|
|
<strong>The reply could not be completed.</strong>
|
|
<div class="text-sm" style="margin-top: var(--sp-1)">{{ message.error }}</div>
|
|
</div>
|
|
</div>
|
|
{% if message.content %}
|
|
<div class="msg__body">{{ body_html|safe }}</div>
|
|
{% endif %}
|
|
{% elif message.role == "assistant" %}
|
|
<div class="msg__body">{{ body_html|safe }}</div>
|
|
{% elif message.content %}
|
|
<div class="msg__body msg__body--plain">{{ message.content }}</div>
|
|
{% endif %}
|
|
{# An attachment-only turn has no text; rendering the bubble anyway would
|
|
leave an empty box under the file. #}
|
|
|
|
{% if not streaming %}
|
|
<footer class="msg__actions">
|
|
<button class="btn btn--icon btn--sm" type="button"
|
|
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
|
|
{{ icon("copy", "icon--sm") }}
|
|
</button>
|
|
{% if message.role == "assistant" %}
|
|
<button class="btn btn--icon btn--sm" type="button"
|
|
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"
|
|
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
|
|
aria-label="Regenerate reply">
|
|
{{ icon("refresh", "icon--sm") }}
|
|
</button>
|
|
{% endif %}
|
|
</footer>
|
|
{# The raw source, so the copy button yields Markdown rather than rendered
|
|
text. A hidden div and not a <script>: script content is raw text, so
|
|
the escaping Jinja applies would be copied out literally as entities. #}
|
|
<div hidden id="msg-body-{{ message.id }}">{{ message.content }}</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if streaming %}
|
|
{# Receives the finished bubble and replaces this whole article with it. #}
|
|
<div hidden sse-swap="done" hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"></div>
|
|
{% endif %}
|
|
</article>
|