{% 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. #}
{# 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