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
+128
View File
@@ -0,0 +1,128 @@
"""Application factory, lifespan and error handling."""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from lembas import __version__
from lembas.api import admin, auth, chats, folders, pages, preferences
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
from lembas.config import settings
from lembas.db.session import init_db
from lembas.web.templating import STATIC_DIR, render
log = logging.getLogger("lembas")
def configure_logging() -> None:
logging.basicConfig(
level=settings.log_level.upper(),
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
configure_logging()
settings.ensure_dirs()
init_db()
if settings.secret_key_is_ephemeral:
log.warning(
"No LEMBAS_SECRET_KEY set, so a temporary one was generated. Every "
"restart will sign all users out and make stored API keys "
"unreadable. Generate a permanent key with:\n"
' python -c "import secrets; print(secrets.token_urlsafe(48))"'
)
log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
log.info("data directory: %s", settings.data_dir.resolve())
yield
log.info("LLeMbas stopped")
def create_app() -> FastAPI:
app = FastAPI(
title="LLeMbas",
version=__version__,
lifespan=lifespan,
# The API is an implementation detail of the UI, not a product surface.
docs_url="/api/docs" if settings.log_level == "debug" else None,
redoc_url=None,
)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(pages.router)
app.include_router(auth.router)
app.include_router(preferences.router)
app.include_router(chats.router)
app.include_router(folders.router)
app.include_router(admin.router)
register_error_handlers(app)
return app
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(RedirectToLogin)
async def _not_signed_in(request: Request, exc: RedirectToLogin) -> Response:
# An htmx request must not swap a login page into a fragment of the
# chat UI, so tell the browser to navigate instead.
if is_htmx(request):
response = Response(status_code=status.HTTP_204_NO_CONTENT)
response.headers["HX-Redirect"] = "/auth/login"
return response
return login_redirect(exc.next_url)
@app.exception_handler(StarletteHTTPException)
async def _http_error(request: Request, exc: StarletteHTTPException) -> Response:
# JSON callers and htmx fragments want the bare status; humans loading a
# page want a themed page they can navigate away from.
wants_page = "text/html" in request.headers.get("accept", "") and not is_htmx(request)
if not wants_page:
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
return render(
request,
"error.html",
{
"status_code": exc.status_code,
"detail": exc.detail,
"flavour": ERROR_FLAVOUR.get(exc.status_code, ERROR_FLAVOUR[500]),
},
status_code=exc.status_code,
)
@app.exception_handler(Exception)
async def _unhandled(request: Request, exc: Exception) -> Response:
log.exception("unhandled error at %s", request.url.path)
if is_htmx(request) or "text/html" not in request.headers.get("accept", ""):
return JSONResponse({"detail": "Internal server error"}, status_code=500)
return render(
request,
"error.html",
{"status_code": 500, "detail": "Something went wrong.",
"flavour": ERROR_FLAVOUR[500]},
status_code=500,
)
# Flavour lives in error pages, empty states and theme names -- never in the
# functional UI. See CLAUDE.md.
ERROR_FLAVOUR = {
403: "Speak, friend, and enter. This door is not yours to open.",
404: "Not all those who wander are lost. This page, however, is.",
500: "The Road goes ever on, but this stretch of it has washed out.",
}
app = create_app()