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>
|
||||
@@ -0,0 +1,10 @@
|
||||
{#
|
||||
Out-of-band updates sent alongside the finished reply.
|
||||
|
||||
A chat is named from its first exchange, which happens on the server while
|
||||
the reply streams. These two fragments push the new title into the page
|
||||
without the client having to poll or reload.
|
||||
#}
|
||||
<span id="chat-title" hx-swap-oob="true">{{ chat.title }}</span>
|
||||
<span id="chat-link-label-{{ chat.id }}" hx-swap-oob="true"
|
||||
class="nav-item__label">{{ chat.title }}</span>
|
||||
@@ -0,0 +1,11 @@
|
||||
{#
|
||||
One exchange: the user's message plus the empty assistant bubble that will
|
||||
stream into it. Returned by POST /api/chats/{id}/messages and appended to the
|
||||
thread in a single swap, so the pair always arrives together.
|
||||
#}
|
||||
{% with message = user_message %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% with message = assistant_message %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, mark %}
|
||||
|
||||
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="shell">
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar"
|
||||
onclick="document.getElementById('sidebar').toggleAttribute('hidden')">
|
||||
{{ icon("sidebar") }}
|
||||
</button>
|
||||
|
||||
{% if chat %}
|
||||
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
|
||||
|
||||
{% if models %}
|
||||
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change from:find select">
|
||||
<select class="select select--compact" name="model_id" aria-label="Model">
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}" {{ 'selected' if model.model_id == chat.model_id }}>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h1 class="topbar__title">Chats</h1>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if not chat %}
|
||||
{# No chat selected. #}
|
||||
<div class="empty">
|
||||
{{ mark(cls="empty__mark", uid="empty") }}
|
||||
<h2 class="empty__title">The road goes ever on</h2>
|
||||
<p class="empty__text">
|
||||
Pick a chat from the side, or start a new one.
|
||||
</p>
|
||||
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
|
||||
{{ icon("plus", "icon--sm") }} New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% elif not models %}
|
||||
{# Nothing to talk to yet. This is the state every fresh install lands in,
|
||||
so it points straight at the fix rather than just reporting a problem. #}
|
||||
<div class="empty">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<h2 class="empty__title">No models available</h2>
|
||||
<p class="empty__text">
|
||||
{% if user.is_admin %}
|
||||
Add an OpenAI-compatible connection and LLeMbas will load its models.
|
||||
{% else %}
|
||||
No model connections have been set up yet. Ask an administrator.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if user.is_admin %}
|
||||
<a class="btn btn--primary" href="/admin/connections">
|
||||
{{ icon("server", "icon--sm") }} Set up a connection
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="thread-scroll" id="thread-scroll">
|
||||
<div class="thread" id="thread">
|
||||
{% if not messages %}
|
||||
<div class="thread__intro">
|
||||
{{ mark(cls="empty__mark", uid="intro") }}
|
||||
<h2 class="empty__title">What would you ask?</h2>
|
||||
<p class="empty__text">Speak, friend, and enter.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for message in messages %}
|
||||
{# Markdown was rendered server-side in pages.py, keyed by message
|
||||
id, so this loop stays a lookup rather than a render. #}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<form class="composer__form"
|
||||
hx-post="/api/chats/{{ chat.id }}/messages"
|
||||
hx-target="#thread" hx-swap="beforeend"
|
||||
hx-on::after-request="if (event.detail.successful) {
|
||||
this.reset();
|
||||
const t = this.querySelector('textarea');
|
||||
window.lembas.autosize(t);
|
||||
window.lembas.scrollThread(true);
|
||||
}">
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="Send a message…" aria-label="Message"></textarea>
|
||||
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
|
||||
{{ icon("send", "icon--sm") }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="composer__hint">
|
||||
Enter to send, Shift+Enter for a new line.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user