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>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
{% 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>
|
||||
Reference in New Issue
Block a user