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 0f44e8d24c
58 changed files with 6095 additions and 12 deletions
+97
View File
@@ -0,0 +1,97 @@
"""Markdown rendering and sanitisation.
Model output is untrusted input: it routinely contains HTML and a model can be
talked into emitting a script tag. These are the tests that keep that boundary.
"""
from __future__ import annotations
from lembas.services.markdown import escape_text, render_markdown
def test_basic_formatting():
html = render_markdown("Some **bold** and *italic* text.")
assert "<strong>bold</strong>" in html
assert "<em>italic</em>" in html
def test_script_tags_are_stripped():
html = render_markdown("Hello <script>alert('xss')</script> world")
assert "<script" not in html
assert "alert" not in html or "&lt;script" in html
def test_javascript_urls_never_become_links():
"""markdown-it refuses the scheme and leaves the source as literal text, so
the guarantee to assert is that no anchor is produced -- not that the
substring is absent, which it legitimately is not."""
html = render_markdown("[click me](javascript:alert(1))")
assert "<a " not in html
assert 'href="javascript:' not in html
def test_javascript_urls_in_raw_html_anchors_are_stripped():
html = render_markdown('<a href="javascript:alert(1)">click</a>')
assert 'href="javascript:' not in html
def test_event_handlers_are_stripped():
html = render_markdown('<img src="x" onerror="alert(1)">')
assert "onerror" not in html
def test_external_links_get_protective_rel():
html = render_markdown("[example](https://example.com)")
assert "noopener" in html
assert "noreferrer" in html
def test_code_block_is_highlighted_and_not_double_wrapped():
html = render_markdown("```python\ndef f():\n return 1\n```")
assert 'class="code-block"' in html
assert "pg-k" in html # a Pygments keyword span
# markdown-it wraps highlight output in its own <pre><code> unless the
# fence rule is replaced outright. This is the regression guard.
assert "<pre><code" not in html
def test_code_block_language_label():
assert ">python<" in render_markdown("```python\nx = 1\n```")
def test_unlabelled_code_block_still_renders():
html = render_markdown("```\njust text\n```")
assert 'class="code-block"' in html
assert "just text" in html
def test_code_content_is_escaped():
html = render_markdown("```\n<script>alert(1)</script>\n```")
assert "<script>" not in html
def test_tables_render():
html = render_markdown("| a | b |\n|---|---|\n| 1 | 2 |")
assert "<table>" in html and "<td>1</td>" in html
def test_bare_urls_are_linkified():
assert "<a href=" in render_markdown("see https://example.com for more")
def test_empty_input():
assert render_markdown("") == ""
def test_escape_text_handles_structural_characters():
assert escape_text("<b>hi</b>") == "&lt;b&gt;hi&lt;/b&gt;"
assert escape_text("a & b") == "a &amp; b"
def test_escape_text_is_chunk_safe():
"""Streaming escapes each token as it arrives, so escaping the pieces must
equal escaping the whole -- otherwise a stream would diverge from the final
rendering."""
whole = "<a>&</a> text"
chunks = ["<a", ">&<", "/a> ", "text"]
assert "".join(escape_text(c) for c in chunks) == escape_text(whole)