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:
Jaroslav Beneš
2026-07-21 11:04:13 +02:00
parent 5ef2af6a9f
commit dd9e0e9440
59 changed files with 6273 additions and 12 deletions
+162
View File
@@ -0,0 +1,162 @@
/*
Client-side behaviour.
Everything here is progressive: the application is server-rendered and works
without this file, apart from the streaming reply, which is htmx's SSE
extension rather than anything hand-written below.
*/
(function () {
"use strict";
var THEME_KEY = "lembas-theme";
var THEMES = ["moria", "shire"];
/* --- Theme -------------------------------------------------------------
Stored locally so the choice applies instantly and survives being signed
out, and mirrored to the server so it follows the user to another device.
The server call is best-effort: a failure must not undo the local switch. */
function currentTheme() {
return document.documentElement.dataset.theme || THEMES[0];
}
function applyTheme(name) {
if (THEMES.indexOf(name) === -1) return;
document.documentElement.dataset.theme = name;
try {
localStorage.setItem(THEME_KEY, name);
} catch (e) { /* private mode */ }
document.querySelectorAll("[data-theme-toggle]").forEach(function (el) {
el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)"
: "Switch to Moria (dark)");
});
if (document.body.dataset.authenticated === "true") {
fetch("/api/preferences/theme", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme: name })
}).catch(function () { /* preference is already applied locally */ });
}
}
function toggleTheme() {
applyTheme(currentTheme() === "moria" ? "shire" : "moria");
}
/* --- Textarea autosize -------------------------------------------------
Grows the composer with its content up to a cap, after which it scrolls. */
function autosize(el) {
if (!el) return;
var max = parseInt(el.dataset.maxHeight || "320", 10);
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, max) + "px";
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}
/* --- Copy --------------------------------------------------------------
Falls back to a hidden textarea because navigator.clipboard is unavailable
on pages served over plain http, which self-hosted installs often are. */
function copyText(text, trigger) {
function done() {
if (!trigger) return;
var original = trigger.getAttribute("aria-label");
trigger.classList.add("is-copied");
trigger.setAttribute("aria-label", "Copied");
setTimeout(function () {
trigger.classList.remove("is-copied");
if (original) trigger.setAttribute("aria-label", original);
}, 1400);
}
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(function () {});
return;
}
var scratch = document.createElement("textarea");
scratch.value = text;
scratch.setAttribute("readonly", "");
scratch.style.position = "fixed";
scratch.style.opacity = "0";
document.body.appendChild(scratch);
scratch.select();
try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ }
document.body.removeChild(scratch);
}
/* --- Thread scrolling --------------------------------------------------
Only auto-scrolls when the reader is already near the bottom, so scrolling
up to re-read something is not yanked away by an incoming token. */
var STICK_THRESHOLD = 120;
function isNearBottom(el) {
return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
}
function scrollThread(force) {
var thread = document.getElementById("thread-scroll");
if (!thread) return;
if (force || isNearBottom(thread)) {
thread.scrollTop = thread.scrollHeight;
}
}
window.lembas = {
applyTheme: applyTheme,
toggleTheme: toggleTheme,
copyText: copyText,
scrollThread: scrollThread,
autosize: autosize
};
/* --- Wiring ------------------------------------------------------------ */
document.addEventListener("click", function (event) {
var toggle = event.target.closest("[data-theme-toggle]");
if (toggle) {
event.preventDefault();
toggleTheme();
return;
}
var copy = event.target.closest("[data-copy]");
if (copy) {
event.preventDefault();
var source = document.getElementById(copy.dataset.copy);
if (source) copyText(source.textContent.trim(), copy);
}
});
document.addEventListener("input", function (event) {
if (event.target.matches("[data-autosize]")) autosize(event.target);
});
/* Enter sends, Shift+Enter inserts a newline -- the convention every chat
application uses. Left alone on touch devices, where there is no easy
Shift and Enter should mean "new line". */
document.addEventListener("keydown", function (event) {
if (event.key !== "Enter" || event.shiftKey) return;
var composer = event.target.closest("[data-composer-input]");
if (!composer) return;
if (window.matchMedia("(pointer: coarse)").matches) return;
event.preventDefault();
var form = composer.closest("form");
if (form && composer.value.trim()) form.requestSubmit();
});
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("[data-autosize]").forEach(autosize);
scrollThread(true);
applyTheme(currentTheme());
});
/* After any htmx swap: re-measure the composer and follow new content. */
document.body.addEventListener("htmx:afterSwap", function () {
document.querySelectorAll("[data-autosize]").forEach(autosize);
scrollThread(false);
});
/* Tokens arriving over SSE are appended outside the normal swap cycle. */
document.body.addEventListener("htmx:sseMessage", function () {
scrollThread(false);
});
})();