a7e59a00f8
The mode select in the topbar posted with hx-post against a route that only answers PATCH, so every change returned 405 and the mode never moved. htmx shows nothing when a request fails, so the control looked like it worked: the select stayed where you put it and the server ignored you. It has never worked. Two more of the same kind. A mode could not be chosen at all until the chat existed, so reaching Plan meant sending something in Manual first and letting the model answer under the wrong rules. And the project directory box was real and submitted, but unlabelled and squeezed to a few characters by the select beside it, so it read as broken -- which is how it was reported. So the kind, the connection, the directory and the mode move out of the strip above the text and into one toolbar row beneath it, where attach and send already are. The directory becomes a button that opens a browser over SFTP, because a path is something you would rather find than spell. `scan_dir` is new beside `list_dir`: a picker has to tell a directory from a file before it can draw the row, and `list_dir` backs a tool whose contract is a list of names and must not change under a model mid-conversation. Browsing is a person clicking, not a model calling, so it does not pass through policy.py -- the same argument the terminal panel rests on. It does mean Manual mode has a second exception now. Also: .chip was two components with one name, and the attachment card won, so the Chat/Agent pills silently wore its padding. --radius-md was used twice and declared nowhere, so both fell back to 0. .btn.is-active has been set by syncToggles since the terminal landed and styled by nothing. Enter-to-send ignored isComposing, so committing an IME candidate sent the message. The terminal had five colours of a sixteen-colour palette, with fallbacks from a palette that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
260 lines
12 KiB
HTML
260 lines
12 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="attach-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="attach-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>
|
|
|
|
{# 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. #}
|
|
<div class="interaction-slot" id="ask-{{ message.id }}"
|
|
sse-swap="ask" 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>
|
|
{# 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. #}
|
|
<span class="msg__status" sse-swap="status" hx-swap="innerHTML"></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.plan_json %}
|
|
{# What the model proposed in Plan mode, with the way to carry it out. #}
|
|
{% include "chat/_plan.html" %}
|
|
{% 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>
|