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
+200
View File
@@ -0,0 +1,200 @@
"""Administration: OpenAI-compatible connections and their models."""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import AdminUser, Db
from lembas.db.models import Connection, Model
from lembas.services.crypto import decrypt, encrypt, mask
from lembas.services.llm.openai_client import Endpoint, LLMError, list_models
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"])
# Sent back in place of a stored key. If a submitted key still equals this, the
# admin did not touch the field and the existing key must be kept -- otherwise
# saving a name change would silently wipe the credential.
UNCHANGED_SENTINEL = "" * 12
def _connection(db: DBSession, connection_id: str) -> Connection:
connection = db.get(Connection, connection_id)
if connection is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
return connection
def _connections(db: DBSession) -> list[Connection]:
return list(db.scalars(select(Connection).order_by(Connection.position, Connection.name)))
@router.get("")
async def admin_home(user: AdminUser):
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/connections")
async def connections_page(request: Request, db: Db, user: AdminUser, message: str = ""):
connections = _connections(db)
return render(
request,
"admin/connections.html",
{
"connections": connections,
"masked": {c.id: mask(decrypt(c.api_key_encrypted)) for c in connections},
"model_counts": {
c.id: sum(1 for m in c.models if m.enabled) for c in connections
},
"message": message,
"unchanged": UNCHANGED_SENTINEL,
},
)
@router.post("/connections")
async def create_connection(
db: Db,
user: AdminUser,
name: str = Form(...),
base_url: str = Form(...),
api_key: str = Form(""),
) -> Response:
base_url = base_url.strip().rstrip("/")
if not base_url.startswith(("http://", "https://")):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"The base URL must start with http:// or https://",
)
position = db.scalar(select(func.coalesce(func.max(Connection.position), -1))) + 1
connection = Connection(
name=name.strip()[:120] or "Connection",
base_url=base_url,
api_key_encrypted=encrypt(api_key.strip()),
position=position,
)
db.add(connection)
db.commit()
# Discover models immediately: a connection that lists nothing is
# indistinguishable from a broken one, and finding out now is the point.
await _refresh_models(db, connection)
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/connections/{connection_id}")
async def update_connection(
db: Db,
user: AdminUser,
connection_id: str,
name: str = Form(...),
base_url: str = Form(...),
api_key: str = Form(""),
enabled: bool = Form(False),
) -> Response:
connection = _connection(db, connection_id)
connection.name = name.strip()[:120] or connection.name
connection.base_url = base_url.strip().rstrip("/")
connection.enabled = enabled
submitted = api_key.strip()
if submitted and submitted != UNCHANGED_SENTINEL:
connection.api_key_encrypted = encrypt(submitted)
elif not submitted:
# An explicitly emptied field means "this endpoint needs no key".
connection.api_key_encrypted = ""
db.commit()
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/connections/{connection_id}/test")
async def test_connection(
request: Request, db: Db, user: AdminUser, connection_id: str
) -> Response:
"""Contact the endpoint and refresh its model list."""
connection = _connection(db, connection_id)
count, error = await _refresh_models(db, connection)
message = (
f"{connection.name}: {error}"
if error
else f"{connection.name}: found {count} model{'s' if count != 1 else ''}."
)
return render(
request,
"admin/_connection_row.html",
{
"connection": connection,
"masked": mask(decrypt(connection.api_key_encrypted)),
"message": message,
"message_kind": "error" if error else "success",
"unchanged": UNCHANGED_SENTINEL,
},
)
async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, str]:
"""Sync the cached model list. Returns (count, error message)."""
try:
discovered = await list_models(Endpoint.from_connection(connection))
except LLMError as exc:
connection.last_error = exc.message
connection.last_checked_at = datetime.now(UTC)
db.commit()
return 0, exc.message
existing = {model.model_id: model for model in connection.models}
seen: set[str] = set()
for entry in discovered:
model_id = str(entry["id"])[:300]
seen.add(model_id)
if model_id in existing:
continue
db.add(Model(connection_id=connection.id, model_id=model_id))
# Models that vanished upstream are dropped, so the picker never offers
# something the endpoint will reject.
for model_id, model in existing.items():
if model_id not in seen:
db.delete(model)
connection.last_error = ""
connection.last_checked_at = datetime.now(UTC)
db.commit()
log.info("connection %s: %d models", connection.name, len(seen))
return len(seen), ""
@router.post("/connections/{connection_id}/delete")
async def delete_connection(db: Db, user: AdminUser, connection_id: str) -> Response:
connection = _connection(db, connection_id)
db.delete(connection)
db.commit()
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/models")
async def models_page(request: Request, db: Db, user: AdminUser):
connections = _connections(db)
return render(request, "admin/models.html", {"connections": connections})
@router.post("/models/{model_id}/toggle")
async def toggle_model(db: Db, user: AdminUser, model_id: str) -> Response:
model = db.get(Model, model_id)
if model is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
model.enabled = not model.enabled
db.commit()
return RedirectResponse("/admin/models", status_code=status.HTTP_303_SEE_OTHER)