Files
LLeMbas/src/lembas/web/templates/chat/_message.html
T
Jaroslav Beneš 0f44e8d24c Working chat: auth, connections, streaming, folders
LLeMbas now runs end to end. Register, add an OpenAI-compatible
connection, and hold a real streaming conversation organised into
folders. Verified against the local llama-swap instance.

Streaming is the one genuinely tricky part. Sending a message returns
two HTML fragments -- the user bubble and an empty assistant bubble
carrying an sse-connect -- and that attribute is the ONLY thing that
starts a generation. Rendering an incomplete assistant message as a
streaming shell falls out of the same template, which means loading a
page whose last reply never finished simply picks it up again.

Details worth knowing about, each commented where it matters:

- SSE payloads are split across several data: lines. A raw newline in
  one data: line truncates the event, which shows up the first time a
  model emits a code block.
- Markdown is rendered server-side by the same helper for both the page
  and the final streamed frame, so the two cannot disagree. The fence
  renderer is replaced outright rather than using markdown-it's
  highlight option, which re-wraps output in a second <pre>.
- escape_text is html.escape, not nh3.clean_text: it escapes character
  by character, so escaping stream chunks separately equals escaping
  the whole string.
- The stream opens its own session via session_scope(); it outlives the
  request handler and the dependency-scoped session may be closed.
- Deleting a folder keeps the chats inside it (FK is SET NULL). Losing
  a conversation to a mis-clicked folder delete is unforgivable.
- Login failures use one message for "no such account" and "wrong
  password" so the form cannot enumerate registered addresses.

Also adds deploy/ for the gamebox install at https://chat.lan: system
unit, nginx vhost with buffering off (buffering on turns streaming into
one lump at the end), and install/update scripts following the same
service-user and /srv bind-mount conventions as llama-swap and comfyui.

70 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:04:13 +02:00

94 lines
3.7 KiB
HTML

{% from "_macros.html" import icon, mark %}
{#
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" %}
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
{% 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">
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
</span>
{% if message.model_id %}
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
{% endif %}
</header>
{% if streaming %}
{# Tokens are appended here as they arrive. The cursor is a CSS
pseudo-element on the empty parent, so it disappears by itself once
the first token lands. #}
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
sse-swap="token" hx-swap="beforeend"></div>
<div class="msg__waiting">
<span class="dots"><i></i><i></i><i></i></span>
</div>
{% elif 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>
{% if message.content %}
<div class="msg__body">{{ body_html|safe }}</div>
{% endif %}
{% elif message.role == "assistant" %}
<div class="msg__body">{{ body_html|safe }}</div>
{% else %}
<div class="msg__body msg__body--plain">{{ message.content }}</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 == "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>
{% 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>