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
+49
View File
@@ -0,0 +1,49 @@
<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>
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc>
<defs>
<linearGradient id="m-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="m-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="m-clip">
<rect x="6" y="6" width="52" height="52" rx="13"/>
</clipPath>
</defs>
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#m-wafer)"/>
<g clip-path="url(#m-clip)" fill="none" stroke-linecap="round">
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
<path d="M32 6 V58"/>
<path d="M6 32 H58"/>
</g>
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
<path d="M33.2 6 V58"/>
<path d="M6 33.2 H58"/>
</g>
</g>
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
<g>
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
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(#m-leaf)"/>
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
stroke-width="1.5" stroke-linecap="round"/>
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
stroke-width="1" stroke-linecap="round">
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB