e185edc9e1
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>
243 lines
11 KiB
HTML
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>
|