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
+191
View File
@@ -0,0 +1,191 @@
/*
Design tokens.
Every colour, space and radius in the application resolves through a variable
declared here. Component CSS must never hard-code a hex value -- that is what
makes adding a theme a matter of writing one new block rather than auditing
every stylesheet.
Themes are selected with data-theme on <html>. `moria` is the default and is
declared on :root so the page is styled even before the theme script runs.
*/
:root {
/* --- Type ------------------------------------------------------------- */
--font-display: "Iowan Old Style", "Palatino Linotype", Palatino, Palladio,
"URW Palladio L", "Book Antiqua", Baskerville, Georgia, serif;
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code",
"Cascadia Code", Menlo, Consolas, monospace;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-md: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--text-3xl: 2.25rem;
--leading-tight: 1.25;
--leading-normal: 1.6;
--leading-relaxed: 1.75;
/* --- Space (4px scale) ------------------------------------------------ */
--sp-1: 0.25rem;
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-4: 1rem;
--sp-5: 1.25rem;
--sp-6: 1.5rem;
--sp-8: 2rem;
--sp-10: 2.5rem;
--sp-12: 3rem;
--sp-16: 4rem;
/* --- Radius & shadow -------------------------------------------------- */
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--radius-xl: 18px;
--radius-full: 999px;
/* --- Layout ----------------------------------------------------------- */
--sidebar-width: 17rem;
--thread-max-width: 48rem;
--header-height: 3.25rem;
--transition-fast: 120ms ease;
--transition: 200ms ease;
}
/*
---------------------------------------------------------------------------
MORIA (default, dark)
Deep stone and lamplight: the halls under the mountain. Surfaces are cool and
near-neutral so the gold and mithril accents carry all the colour.
---------------------------------------------------------------------------
*/
:root,
:root[data-theme="moria"] {
color-scheme: dark;
--bg: #101317;
--bg-sunken: #0B0E11;
--surface: #171B21;
--surface-raised: #1E242B;
--surface-hover: #232A32;
--surface-active: #2A323B;
--border: #2A313A;
--border-strong: #3A434E;
--ink: #E4E8EC;
--ink-muted: #A2ADB8;
--ink-faint: #6E7883;
--ink-inverse: #0B0E11;
/* Mithril: the cool primary, used for focus and interactive accents. */
--accent: #8FB3CC;
--accent-hover: #A9C6DA;
--accent-ink: #0B0E11;
--accent-soft: rgba(143, 179, 204, 0.14);
/* Rune gold: the warm accent. Brand colour, and the assistant's mark. */
--gold: #E0B252;
--gold-hover: #EDC46F;
--gold-soft: rgba(224, 178, 82, 0.13);
/* Ember: destructive actions and errors. */
--danger: #E2795A;
--danger-hover: #EC8E72;
--danger-soft: rgba(226, 121, 90, 0.14);
--success: #7FB77E;
--success-soft: rgba(127, 183, 126, 0.14);
--warning: #DFAE58;
--warning-soft: rgba(223, 174, 88, 0.14);
--bubble-user: #232B34;
--bubble-assistant: transparent;
--code-bg: #0C0F13;
--code-border: #262D36;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
--shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.55);
--scrim: rgba(6, 8, 10, 0.66);
}
/*
---------------------------------------------------------------------------
SHIRE (light)
Parchment, ink and moss: warm, low-contrast, easy to read for a long time.
Backgrounds are deliberately off-white -- pure #FFF next to the gold accent
reads as clinical rather than as paper.
---------------------------------------------------------------------------
*/
:root[data-theme="shire"] {
color-scheme: light;
--bg: #F6F1E4;
--bg-sunken: #EDE6D4;
--surface: #FDFBF5;
--surface-raised: #FFFFFF;
--surface-hover: #F1EADA;
--surface-active: #E7DEC9;
--border: #DED3BB;
--border-strong: #C6B896;
--ink: #2C2419;
--ink-muted: #6A5C48;
--ink-faint: #94856D;
--ink-inverse: #FDFBF5;
/* Hobbit-door blue-green: the cool primary. */
--accent: #3E6B7A;
--accent-hover: #325867;
--accent-ink: #FDFBF5;
--accent-soft: rgba(62, 107, 122, 0.12);
--gold: #A8801A;
--gold-hover: #8E6B12;
--gold-soft: rgba(168, 128, 26, 0.13);
--danger: #A6432B;
--danger-hover: #8C3722;
--danger-soft: rgba(166, 67, 43, 0.11);
--success: #4F7A3F;
--success-soft: rgba(79, 122, 63, 0.12);
--warning: #98701A;
--warning-soft: rgba(152, 112, 26, 0.13);
--bubble-user: #EDE4CF;
--bubble-assistant: transparent;
--code-bg: #F2EBD9;
--code-border: #DED3BB;
--shadow-sm: 0 1px 2px rgba(72, 58, 34, 0.09);
--shadow: 0 4px 14px rgba(72, 58, 34, 0.11);
--shadow-lg: 0 12px 34px rgba(72, 58, 34, 0.16);
--scrim: rgba(44, 36, 25, 0.4);
}
/* Respect a stated preference for reduced motion everywhere, at once. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}