{% 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) %}
{% if message.role == "assistant" %} {{ speaking_model.label if speaking_model else "LLeMbas" }} {% else %} {{ user.name or "You" }} {% endif %} {% 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. #} {{ message.model_id }} {% endif %}
{% if message.attachments %} {# Above the text, matching the order they were added and the order the model receives them. #}
{% for attachment in message.attachments %} {% if attachment.is_image %} {{ attachment.filename }} {% else %}
{{ icon("attach", "icon--sm") }} {{ attachment.filename }} {{ 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 %} · view extracted text {%- endif %} {% if attachment.extraction_error %} {{ attachment.extraction_error }} {% endif %}
{% endif %} {% endfor %}
{% 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. #}
{{ icon("sparkle", "icon--sm reasoning__icon") }} Thinking… {{ icon("chevron-down", "icon--sm reasoning__chevron") }} {# 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. #}
{# 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. #}
{# Where a question from the model, or a command waiting to be allowed, lands. Unlike the blocks above it this frame is sent on every version bump including when it is empty, because the card has to disappear the moment it is answered. The buttons inside are hx-post and they work: the SSE extension processes what it swaps in. #}
{# 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. #}
{# No stop button here: the composer's send button becomes Stop while a reply is being written, which is where the hand already is. #}
{# What the reply is doing when it is not producing tokens. A silent multi-second pause before the first token is what a hang looks like. #}
{# Counts as the reply is written. Everything is an estimate until the usage chunk lands at the very end, and the chips say so. #}
{% 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. #}
{{ icon("sparkle", "icon--sm reasoning__icon") }} {% if message.reasoning_ms %} Thought for {{ message.reasoning_ms | duration }} {% else %} Reasoning {% endif %} {{ icon("chevron-down", "icon--sm reasoning__chevron") }}
{{ message.reasoning }}
{% 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. #}
{% with tool_events = message.tool_calls_json, live = false %} {% include "chat/_tool_activity.html" %} {% endwith %}
{% endif %} {% if message.error %} {% endif %} {% if message.role == "assistant" %} {% if message.content %}
{{ body_html|safe }}
{% endif %} {% if message.stopped %}

{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.

{% endif %} {% elif message.content %}
{{ message.content }}
{% 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. #}
{% with metrics = message.usage_json | metrics %} {% include "chat/_metrics.html" %} {% endwith %}
{% endif %} {% if not streaming %}
{% if message.role == "user" %} {% endif %} {% if message.role == "assistant" %} {% 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. #} {% endif %} {% endif %}
{# The raw source, so the copy button yields Markdown rather than rendered text. A hidden div and not a