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,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);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user