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
+134
View File
@@ -0,0 +1,134 @@
"""Render assistant messages from Markdown to sanitised HTML.
Rendering happens on the server, in Python, so there is no JavaScript Markdown
library to vendor and the streamed and final views cannot disagree about how
something should look.
The output is sanitised with nh3 (Rust ammonia). Model output is untrusted
input: it routinely contains HTML, and a model can be talked into emitting a
script tag, so this is a real boundary and not a formality.
"""
from __future__ import annotations
import functools
import html
import nh3
from markdown_it import MarkdownIt
from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.util import ClassNotFound
# Class-based highlighting; the colours come from theme tokens in chat.css, so
# code blocks follow the active theme instead of carrying their own palette.
_FORMATTER = HtmlFormatter(nowrap=True, classprefix="pg-")
ALLOWED_TAGS = {
"p", "br", "hr", "div", "span",
"strong", "em", "del", "sub", "sup", "mark",
"h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li",
"blockquote", "pre", "code",
"table", "thead", "tbody", "tr", "th", "td",
"a", "img",
}
ALLOWED_ATTRIBUTES = {
# "rel" is intentionally absent: nh3 rejects it here when link_rel is set,
# because link_rel below is what writes it.
"a": {"href", "title", "target"},
"img": {"src", "alt", "title"},
"code": {"class"},
"pre": {"class"},
"span": {"class"},
"div": {"class"},
"td": {"align"},
"th": {"align"},
}
# javascript: and data: URLs are the obvious injection route through a link.
ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
def _render_fence(tokens, idx, _options, _env) -> str:
"""Render a fenced code block.
This replaces the renderer's `fence` rule outright rather than using
markdown-it's `highlight` option, because that option re-wraps whatever it
is given in <pre><code> unless the string already starts with "<pre" --
which would nest a second <pre> inside the wrapper this returns.
"""
token = tokens[idx]
code = token.content
language = (token.info or "").strip().split()[0] if token.info else ""
lexer = None
if language:
try:
lexer = get_lexer_by_name(language, stripall=False)
except (ClassNotFound, ValueError):
lexer = None
elif code.strip():
# Guessing is only worth it for a decent sample; on two lines of text
# Pygments guesses confidently and wrongly.
try:
lexer = guess_lexer(code) if len(code) > 80 else None
except (ClassNotFound, ValueError):
lexer = None
if lexer is None:
body = nh3.clean_text(code)
label = language
else:
body = highlight(code, lexer, _FORMATTER)
label = language or (lexer.aliases[0] if lexer.aliases else "")
label_html = (
f'<div class="code-block__label">{nh3.clean_text(label)}</div>' if label else ""
)
return (
f'<div class="code-block">{label_html}'
f'<pre class="code-block__pre"><code>{body}</code></pre></div>'
)
@functools.lru_cache(maxsize=1)
def _parser() -> MarkdownIt:
md = MarkdownIt("commonmark", {"linkify": True, "typographer": False})
md.enable(["table", "strikethrough", "linkify"])
md.renderer.rules["fence"] = _render_fence
return md
def render_markdown(text: str) -> str:
"""Markdown to safe HTML, ready to drop into a message bubble."""
if not text:
return ""
html = _parser().render(text)
return nh3.clean(
html,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
url_schemes=ALLOWED_URL_SCHEMES,
# Anything opened from a model's output is untrusted; noopener stops it
# reaching back through window.opener.
link_rel="nofollow noopener noreferrer",
)
def escape_text(text: str) -> str:
"""Escape a plain-text run for insertion as HTML element content.
Used for user messages and for partial assistant text mid-stream, where the
content is not yet complete enough to parse as Markdown.
html.escape rather than nh3.clean_text: escaping the three structural
characters is all that is needed for a text node, and it escapes character
by character, so escaping a stream chunk-by-chunk gives the same result as
escaping the whole string at once. nh3.clean_text also escapes spaces and
slashes, which triples the size of a streamed token for no benefit.
"""
return html.escape(text, quote=False)