dd9e0e9440
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>
48 lines
1.4 KiB
HTML
48 lines
1.4 KiB
HTML
{% extends "base.html" %}
|
|
{% from "_macros.html" import icon, mark, wordmark %}
|
|
|
|
{% block title %}Sign in - LLeMbas{% endblock %}
|
|
|
|
{% block body %}
|
|
<main class="auth">
|
|
<div class="auth__card">
|
|
<div class="auth__brand">
|
|
{{ mark(cls="brand-mark", uid="auth") }}
|
|
<h1 class="auth__title">{{ wordmark() }}</h1>
|
|
<p class="auth__subtitle">Waybread for the long road of thought.</p>
|
|
</div>
|
|
|
|
{% if error %}
|
|
<div class="alert alert--error" role="alert">
|
|
{{ icon("warning", "alert__icon") }}
|
|
<span>{{ error }}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<form method="post" action="/auth/login" class="stack">
|
|
<input type="hidden" name="next" value="{{ next|default('/') }}">
|
|
|
|
<div class="field">
|
|
<label class="field__label" for="email">Email</label>
|
|
<input class="input" type="email" id="email" name="email" required
|
|
autocomplete="username" autofocus value="{{ email|default('') }}">
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label class="field__label" for="password">Password</label>
|
|
<input class="input" type="password" id="password" name="password" required
|
|
autocomplete="current-password">
|
|
</div>
|
|
|
|
<button class="btn btn--primary btn--block" type="submit">Sign in</button>
|
|
</form>
|
|
|
|
{% if allow_signup %}
|
|
<p class="auth__footer">
|
|
No account yet? <a href="/auth/register">Create one</a>.
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
</main>
|
|
{% endblock %}
|