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