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:
@@ -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)
|
||||
Reference in New Issue
Block a user