Files
LLeMbas/src/lembas/web/templates/chat/_message.html
T
Jaroslav Beneš e185edc9e1 Show what a reply cost, live and afterwards
Tokens, how full the context is, and tokens per second -- as chips under
each assistant bubble, updating while the reply streams and still there when
it finishes.

The numbers come from one Metrics object built either from the generation
still being written or from the row it left behind. That is the point rather
than tidiness: the finished bubble is re-rendered from the database the
instant the stream ends, so two code paths would make the figures visibly
jump at exactly the moment someone is watching them. Here the only thing
that changes is that an estimate may become exact.

Message.usage_json has existed and been dead since the schema was written.
It is the store.

Two counts that look like one. prompt and completion are summed across tool
rounds -- what the reply cost. context_tokens is overwritten each round with
that round's prompt plus completion -- what the window actually holds. A
three-round reply pays for its prompt three times and only ever occupies the
window once, so a single number would be wrong for one of the two questions.

Generation gains started_at as a field rather than a local in _run, because
_follow is a different function that sees only the Generation and otherwise
has nothing to compute a live speed against. It also carries a prompt
estimate taken before the first chunk, since real usage arrives in one chunk
at the very end and a percentage that appears only after the reply is
useless.

Everything is marked with a tilde when the endpoint reported nothing, and
the percentage is simply absent when no context length is set: unknown has
to stay tellable from small, and a percentage of an unknown total is a
made-up number in a place people trust numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 00:40:44 +02:00

243 lines
11 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.
Closed by default -- the answer is what the reader is waiting for, and
the thinking is one click away. 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 }}">
<summary class="reasoning__summary">
{{ icon("sparkle", "icon--sm reasoning__icon") }}
<span class="reasoning__label">Thinking…</span>
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
</summary>
{# innerHTML, not beforeend: the frame carries the whole block of
thinking each time, exactly as `render` and `tools` do. Appending it
repeated everything already shown, so the panel grew quadratically. #}
<div class="reasoning__body" sse-swap="reasoning" hx-swap="innerHTML"></div>
</details>
{# Tool activity as it happens. Empty until the model asks for something,
and the whole block is replaced each time rather than appended to --
a follower attaching late has no earlier fragments to build on. #}
<div class="tool-activity-list" id="tools-{{ message.id }}"
sse-swap="tools" hx-swap="innerHTML"></div>
{# The server re-renders the answer as Markdown a few times a second and
replaces this whole block, so formatting appears as the model writes
rather than snapping into place at the end. #}
<div class="msg__body msg__body--live" id="stream-{{ message.id }}"
sse-swap="render" hx-swap="innerHTML"></div>
{# No stop button here: the composer's send button becomes Stop while a
reply is being written, which is where the hand already is. #}
<div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span>
</div>
{# Counts as the reply is written. Everything is an estimate until the
usage chunk lands at the very end, and the chips say so. #}
<div class="msg__metrics" id="metrics-{{ message.id }}"
sse-swap="metrics" hx-swap="innerHTML"></div>
{% else %}
{# Finished. Same order as the live view above -- thinking, then what it
looked up, then the answer -- so a reply does not rearrange itself the
moment it stops streaming. #}
{% if 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>
{% endif %}
{% if message.tool_calls_json %}
{# Kept with the message rather than discarded with the stream, so the
sources behind an answer are still there tomorrow. #}
<div class="tool-activity-list">
{% with tool_events = message.tool_calls_json, live = false %}
{% include "chat/_tool_activity.html" %}
{% endwith %}
</div>
{% endif %}
{% if 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>
{% endif %}
{% if message.role == "assistant" %}
{% if message.content %}
<div class="msg__body">{{ body_html|safe }}</div>
{% endif %}
{% if message.stopped %}
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
{% endif %}
{% 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. #}
{% endif %}
{% if not streaming and message.role == "assistant" and message.usage_json %}
{# Above the buttons, not among them: the actions row is things you press. #}
<div class="msg__metrics" id="metrics-{{ message.id }}">
{% with metrics = message.usage_json | metrics %}
{% include "chat/_metrics.html" %}
{% endwith %}
</div>
{% endif %}
{% 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 == "user" %}
<button class="btn btn--icon btn--sm" type="button"
hx-get="/api/chats/{{ chat.id }}/messages/{{ message.id }}/edit"
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
aria-label="Edit and retry from here">
{{ icon("pencil", "icon--sm") }}
</button>
{% endif %}
{% 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>
{% if can_listen | default(false) and message.content and not message.error %}
{# Speech is synthesised on demand rather than stored: the voice can
change under the reader between plays, and a reply can be regenerated
at the same address. #}
{# data-speak-auto is set only on the frame that ends a live stream, never
on a page load: reopening a chat must not start reading its last reply
out loud again. #}
<button class="btn btn--icon btn--sm" type="button"
data-speak="/api/audio/speech/{{ chat.id }}/{{ message.id }}"
{% if audio_autoplay | default(false) and just_finished | default(false) %}data-speak-auto{% endif %}
aria-label="Read this reply aloud">
<span class="speak__icon speak__icon--play">{{ icon("speaker", "icon--sm") }}</span>
<span class="speak__icon speak__icon--stop">{{ icon("stop-circle", "icon--sm") }}</span>
</button>
{% endif %}
{% 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>