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
+58
View File
@@ -0,0 +1,58 @@
"""Jinja environment and the context every template receives."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from fastapi import Request
from fastapi.templating import Jinja2Templates
from lembas import __version__
from lembas.config import settings
from lembas.db.models import User
TEMPLATE_DIR = Path(__file__).parent / "templates"
STATIC_DIR = Path(__file__).parent / "static"
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
templates.env.trim_blocks = True
templates.env.lstrip_blocks = True
def resolve_theme(user: User | None) -> str:
"""Theme to render with on the server.
Only ever a first guess: the inline script in base.html corrects it from
localStorage before first paint. Getting it close server-side is what stops
a signed-in user seeing a flash of the wrong theme on every navigation.
"""
if user is not None:
chosen = (user.settings_json or {}).get("theme")
if chosen in ("moria", "shire"):
return chosen
return settings.default_theme
def render(
request: Request,
template: str,
context: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Render a template with the globals every page expects.
Using this instead of templates.TemplateResponse directly is what
guarantees `user` and `theme` are always defined, so templates never need
to guard against a missing variable.
"""
user = getattr(request.state, "user", None)
payload: dict[str, Any] = {
"request": request,
"user": user,
"theme": resolve_theme(user),
"version": __version__,
"allow_signup": settings.allow_signup,
}
payload.update(context or {})
return templates.TemplateResponse(request, template, payload, **kwargs)