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