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
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
role="img" aria-label="LLeMbas">
<title>LLeMbas</title>
<defs>
<linearGradient id="f-wafer" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#EACB74"/>
<stop offset="0.5" stop-color="#C9A227"/>
<stop offset="1" stop-color="#916F13"/>
</linearGradient>
<linearGradient id="f-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
<stop offset="0" stop-color="#93A5B6"/>
<stop offset="0.4" stop-color="#F1F6FA"/>
<stop offset="1" stop-color="#B8C7D5"/>
</linearGradient>
<clipPath id="f-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/>
<g transform="translate(32 32) scale(1.16) translate(-32 -32)">
<path d="M20.6 44.6 L15.6 50.1" stroke="#8A9AA8" stroke-width="3.4"
stroke-linecap="round" fill="none"/>
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#f-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.45"
stroke-width="1.8" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB