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)
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Registration, sign-in and sign-out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import CurrentUser, Db
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
|
||||
from lembas.security.passwords import hash_password, validate_password, verify_password
|
||||
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_session
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _no_users_yet(db: Db) -> bool:
|
||||
return db.scalar(select(func.count()).select_from(User)) == 0
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
max_age=settings.session_ttl,
|
||||
httponly=True,
|
||||
# Lax is what makes this application CSRF-safe without tokens: the
|
||||
# cookie is not sent on cross-site POSTs, and every mutating route here
|
||||
# is a POST. Do not relax to "none".
|
||||
samesite="lax",
|
||||
# Only over HTTPS when the deployment is not plain local http. Marking
|
||||
# it secure on http would silently break sign-in for a LAN install.
|
||||
secure=False,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _safe_next(raw: str | None) -> str:
|
||||
"""Reject open redirects: only same-origin absolute paths are allowed."""
|
||||
if not raw or not raw.startswith("/") or raw.startswith("//"):
|
||||
return "/"
|
||||
return raw
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/"):
|
||||
if user is not None:
|
||||
return RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||
# An empty database means this install has never been set up. Send the
|
||||
# first visitor straight to registration rather than to a login form they
|
||||
# cannot possibly satisfy.
|
||||
if _no_users_yet(db):
|
||||
return RedirectResponse("/auth/register", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return render(request, "auth/login.html", {"next": _safe_next(next)})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
db: Db,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
next: str = Form("/"),
|
||||
):
|
||||
email = email.strip().lower()
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
|
||||
# One message for "no such account" and "wrong password" alike, so the form
|
||||
# cannot be used to discover which addresses are registered.
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
log.info("failed sign-in for %s", email)
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "That email and password do not match.", "email": email,
|
||||
"next": _safe_next(next)},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not user.active:
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "This account has been deactivated. Ask an administrator.",
|
||||
"email": email, "next": _safe_next(next)},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
token = create_session(
|
||||
db,
|
||||
user,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
)
|
||||
response = RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_session_cookie(response, token)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/register")
|
||||
async def register_form(request: Request, db: Db, user: CurrentUser):
|
||||
if user is not None:
|
||||
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
first_run = _no_users_yet(db)
|
||||
if not first_run and not settings.allow_signup:
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "Registration is closed. Ask an administrator for an account."},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
return render(request, "auth/register.html", {"first_run": first_run})
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(
|
||||
request: Request,
|
||||
db: Db,
|
||||
name: str = Form(...),
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
first_run = _no_users_yet(db)
|
||||
if not first_run and not settings.allow_signup:
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "Registration is closed. Ask an administrator for an account."},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
name = name.strip()
|
||||
email = email.strip().lower()
|
||||
|
||||
def fail(message: str) -> Response:
|
||||
return render(
|
||||
request,
|
||||
"auth/register.html",
|
||||
{"error": message, "name": name, "email": email, "first_run": first_run},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not name:
|
||||
return fail("Please enter a name.")
|
||||
if "@" not in email or "." not in email.split("@")[-1]:
|
||||
return fail("Please enter a valid email address.")
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return fail(problem)
|
||||
if db.scalar(select(User).where(User.email == email)) is not None:
|
||||
return fail("An account with that email already exists.")
|
||||
|
||||
# Whoever sets the instance up owns it. Everyone after that is a plain user
|
||||
# until an admin says otherwise.
|
||||
user = User(
|
||||
name=name,
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=ROLE_ADMIN if first_run else ROLE_USER,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
log.info("registered %s as %s", email, user.role)
|
||||
|
||||
token = create_session(
|
||||
db,
|
||||
user,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
)
|
||||
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_session_cookie(response, token)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, db: Db):
|
||||
revoke_session(db, request.cookies.get(COOKIE_NAME))
|
||||
response = RedirectResponse("/auth/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
return response
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Chat creation, messaging and the streaming reply endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import sse
|
||||
from lembas.services.llm.openai_client import LLMError, delta_text, stream_chat
|
||||
from lembas.services.markdown import escape_text, render_markdown
|
||||
from lembas.web.templating import render, templates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/chats", tags=["chats"])
|
||||
|
||||
|
||||
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||
chat = db.get(Chat, chat_id)
|
||||
# 404 rather than 403 for someone else's chat: whether a given id exists is
|
||||
# not information this endpoint should hand out.
|
||||
if chat is None or chat.user_id != user_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
return chat
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) -> Response:
|
||||
chosen = chat_service.default_model(db)
|
||||
chat = Chat(
|
||||
user_id=user.id,
|
||||
folder_id=folder_id or None,
|
||||
model_id=chosen[0] if chosen else "",
|
||||
connection_id=chosen[1] if chosen else None,
|
||||
)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
|
||||
# HX-Redirect rather than a swap: a new chat is a new URL, and the address
|
||||
# bar has to follow so the chat can be reloaded or bookmarked.
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
response.headers["HX-Redirect"] = f"/chat/{chat.id}"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages")
|
||||
async def post_message(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
content: str = Form(...),
|
||||
) -> Response:
|
||||
"""Persist the user's turn and hand back the pair of bubbles.
|
||||
|
||||
The assistant bubble comes back empty, carrying the sse-connect attribute
|
||||
that opens the stream below. Splitting it this way means the POST returns
|
||||
immediately and the slow part is a separate, resumable connection.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
content = content.strip()
|
||||
if not content:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||
assistant_message = chat_service.create_message(
|
||||
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
||||
)
|
||||
|
||||
# `user` is required by the shared message template, which renders both
|
||||
# roles; without it the user bubble's initial blows up.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_turn.html",
|
||||
{
|
||||
"request": request,
|
||||
"user_message": user_message,
|
||||
"assistant_message": assistant_message,
|
||||
"chat": chat,
|
||||
"user": user,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{chat_id}/messages/{message_id}/stream")
|
||||
async def stream_message(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
) -> Response:
|
||||
"""Stream the assistant's reply as server-sent events.
|
||||
|
||||
Emits `token` events carrying escaped text, then a single `done` event
|
||||
carrying the finished bubble rendered from Markdown, then `close`.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||
|
||||
return StreamingResponse(
|
||||
_generate(chat.id, message.id),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
# nginx buffers proxied responses by default, which turns a stream
|
||||
# into one delivery at the end. This is the documented opt-out.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
"""Drive one completion and frame it as SSE.
|
||||
|
||||
Opens its own database session rather than using the request's: streaming
|
||||
outlives the request handler, and the dependency-scoped session may already
|
||||
be closed by the time the first token arrives.
|
||||
"""
|
||||
accumulated: list[str] = []
|
||||
error: str | None = None
|
||||
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, chat_id)
|
||||
message = db.get(Message, message_id)
|
||||
if chat is None or message is None:
|
||||
yield sse.event("close", "")
|
||||
return
|
||||
|
||||
first_user_text = ""
|
||||
try:
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||
payload = chat_service.build_request(db, chat, upto=message)
|
||||
first_user_text = next(
|
||||
(m["content"] for m in reversed(payload["messages"]) if m["role"] == ROLE_USER),
|
||||
"",
|
||||
)
|
||||
|
||||
async for chunk in stream_chat(endpoint, payload):
|
||||
text = delta_text(chunk)
|
||||
if not text:
|
||||
continue
|
||||
accumulated.append(text)
|
||||
yield sse.event("token", escape_text(text))
|
||||
# Hand control back so the event is flushed rather than
|
||||
# batched behind a fast generator.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
except LLMError as exc:
|
||||
error = exc.message
|
||||
log.info("generation failed for chat %s: %s", chat_id, exc.message)
|
||||
except asyncio.CancelledError:
|
||||
# The reader navigated away or closed the tab. Keep whatever was
|
||||
# produced so the partial reply is still there on reload.
|
||||
message.content = "".join(accumulated)
|
||||
message.complete = True
|
||||
db.commit()
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - must not kill the stream silently
|
||||
error = "Something went wrong while generating this reply."
|
||||
log.exception("unexpected generation failure for chat %s: %s", chat_id, exc)
|
||||
|
||||
message.content = "".join(accumulated)
|
||||
message.error = error or ""
|
||||
message.complete = True
|
||||
|
||||
if not chat.title_generated and (accumulated or error):
|
||||
chat.title = (
|
||||
await chat_service.generate_title(
|
||||
endpoint, model_id, first_user_text, message.content
|
||||
)
|
||||
if not error and first_user_text
|
||||
else chat_service.fallback_title(first_user_text)
|
||||
)
|
||||
chat.title_generated = True
|
||||
|
||||
db.commit()
|
||||
|
||||
final_html = templates.get_template("chat/_message.html").render(
|
||||
{
|
||||
"message": message,
|
||||
"body_html": render_markdown(message.content),
|
||||
"chat": chat,
|
||||
# Passed even though an assistant bubble never reads it: the
|
||||
# template shares both roles, and a missing `user` would only
|
||||
# blow up on whichever branch is not being exercised here.
|
||||
"user": db.get(User, chat.user_id),
|
||||
}
|
||||
)
|
||||
title_html = templates.get_template("chat/_title_oob.html").render(
|
||||
{"chat": chat}
|
||||
)
|
||||
|
||||
yield sse.event("done", final_html + title_html)
|
||||
yield sse.event("close", "")
|
||||
|
||||
|
||||
@router.patch("/{chat_id}")
|
||||
async def update_chat(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
title: str | None = Form(None),
|
||||
folder_id: str | None = Form(None),
|
||||
model_id: str | None = Form(None),
|
||||
) -> Response:
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
if title is not None:
|
||||
cleaned = title.strip()[:300]
|
||||
if cleaned:
|
||||
chat.title = cleaned
|
||||
# An explicit rename must not be overwritten by auto-titling later.
|
||||
chat.title_generated = True
|
||||
|
||||
if folder_id is not None:
|
||||
chat.folder_id = folder_id or None
|
||||
|
||||
if model_id is not None and model_id:
|
||||
chat.model_id = model_id
|
||||
match = next(
|
||||
(m for m in chat_service.available_models(db) if m.model_id == model_id), None
|
||||
)
|
||||
chat.connection_id = match.connection_id if match else None
|
||||
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete("/{chat_id}")
|
||||
async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
db.delete(chat)
|
||||
db.commit()
|
||||
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
response.headers["HX-Redirect"] = "/chat"
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{chat_id}/messages/{message_id}/raw")
|
||||
async def raw_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> HTMLResponse:
|
||||
"""The unrendered Markdown of a message, for the copy button."""
|
||||
_owned_chat(db, chat_id, user.id)
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||
return HTMLResponse(escape_text(message.content))
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages/{message_id}/regenerate")
|
||||
async def regenerate(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
) -> Response:
|
||||
"""Discard an assistant reply and produce a fresh one in its place."""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.")
|
||||
|
||||
message.content = ""
|
||||
message.error = ""
|
||||
message.complete = False
|
||||
message.model_id = chat.model_id
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_message.html",
|
||||
{"request": request, "message": message, "chat": chat, "body_html": "", "user": user},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["render", "router"]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Folder management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import Folder
|
||||
|
||||
router = APIRouter(prefix="/api/folders", tags=["folders"])
|
||||
|
||||
MAX_DEPTH = 8
|
||||
|
||||
|
||||
def _owned_folder(db: DBSession, folder_id: str, user_id: str) -> Folder:
|
||||
folder = db.get(Folder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.")
|
||||
return folder
|
||||
|
||||
|
||||
def _depth_of(db: DBSession, folder: Folder | None) -> int:
|
||||
depth = 0
|
||||
seen: set[str] = set()
|
||||
while folder is not None and folder.id not in seen:
|
||||
seen.add(folder.id)
|
||||
depth += 1
|
||||
folder = db.get(Folder, folder.parent_id) if folder.parent_id else None
|
||||
return depth
|
||||
|
||||
|
||||
def _refresh_sidebar() -> Response:
|
||||
"""Tell the browser to reload so the tree re-renders.
|
||||
|
||||
The folder tree is recursive and a change can move any part of it, so
|
||||
re-rendering the whole sidebar server-side is both simpler and less
|
||||
error-prone than trying to patch individual nodes over the wire.
|
||||
"""
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
response.headers["HX-Refresh"] = "true"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_folder(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
name: str = Form("New folder"),
|
||||
parent_id: str = Form(""),
|
||||
) -> Response:
|
||||
parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||
|
||||
# A cap on nesting, so a runaway client cannot build a tree deep enough to
|
||||
# blow the recursion limit in the template.
|
||||
if parent is not None and _depth_of(db, parent) >= MAX_DEPTH:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"Folders cannot be nested more than {MAX_DEPTH} deep.",
|
||||
)
|
||||
|
||||
db.add(
|
||||
Folder(
|
||||
user_id=user.id,
|
||||
name=name.strip()[:200] or "New folder",
|
||||
parent_id=parent.id if parent else None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return _refresh_sidebar()
|
||||
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
async def update_folder(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
folder_id: str,
|
||||
name: str | None = Form(None),
|
||||
parent_id: str | None = Form(None),
|
||||
collapsed: bool | None = Form(None),
|
||||
) -> Response:
|
||||
folder = _owned_folder(db, folder_id, user.id)
|
||||
|
||||
if name is not None and name.strip():
|
||||
folder.name = name.strip()[:200]
|
||||
|
||||
if parent_id is not None:
|
||||
new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||
# Reparenting a folder into its own subtree would detach that subtree
|
||||
# from the root and make it unreachable.
|
||||
cursor = new_parent
|
||||
while cursor is not None:
|
||||
if cursor.id == folder.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"A folder cannot be moved inside itself.",
|
||||
)
|
||||
cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None
|
||||
folder.parent_id = new_parent.id if new_parent else None
|
||||
|
||||
if collapsed is not None:
|
||||
folder.collapsed = collapsed
|
||||
|
||||
db.commit()
|
||||
return _refresh_sidebar()
|
||||
|
||||
|
||||
@router.delete("/{folder_id}")
|
||||
async def delete_folder(db: Db, user: RequiredUser, folder_id: str) -> Response:
|
||||
"""Delete a folder. Child folders go with it; chats do not.
|
||||
|
||||
Chats fall back to the unfiled list (the FK is ON DELETE SET NULL), because
|
||||
losing a conversation to a mis-clicked folder delete is unforgivable.
|
||||
"""
|
||||
folder = _owned_folder(db, folder_id, user.id)
|
||||
db.delete(folder)
|
||||
db.commit()
|
||||
return _refresh_sidebar()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Full-page routes: the chat shell and the user's own settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import Chat, Folder, Message, User
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import render
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
|
||||
|
||||
def _sidebar_context(db: DBSession, user: User) -> dict:
|
||||
"""Folder tree plus the chats that belong to no folder.
|
||||
|
||||
Only root folders are queried; children come through the relationship and
|
||||
render recursively in the template.
|
||||
"""
|
||||
folders = list(
|
||||
db.scalars(
|
||||
select(Folder)
|
||||
.where(Folder.user_id == user.id, Folder.parent_id.is_(None))
|
||||
.order_by(Folder.position, Folder.name)
|
||||
)
|
||||
)
|
||||
unfiled = list(
|
||||
db.scalars(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user.id,
|
||||
Chat.folder_id.is_(None),
|
||||
Chat.archived.is_(False),
|
||||
)
|
||||
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
||||
)
|
||||
)
|
||||
return {"folders": folders, "unfiled_chats": unfiled}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def home(user: RequiredUser):
|
||||
return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/chat")
|
||||
async def chat_index(request: Request, db: Db, user: RequiredUser):
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"chat": None,
|
||||
"messages": [],
|
||||
"models": chat_service.available_models(db),
|
||||
**_sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chat/{chat_id}")
|
||||
async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
|
||||
messages = list(
|
||||
db.scalars(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
)
|
||||
)
|
||||
|
||||
# Markdown is rendered once here rather than in the template so the same
|
||||
# helper produces the page and the streamed final frame -- one code path,
|
||||
# no chance of the two disagreeing.
|
||||
bodies = {
|
||||
message.id: render_markdown(message.content)
|
||||
for message in messages
|
||||
if message.role == "assistant" and message.content
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"chat": chat,
|
||||
"messages": messages,
|
||||
"bodies": bodies,
|
||||
"models": chat_service.available_models(db),
|
||||
**_sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def settings_page(request: Request, db: Db, user: RequiredUser):
|
||||
return render(
|
||||
request,
|
||||
"settings.html",
|
||||
{
|
||||
"chat": None,
|
||||
"models": chat_service.available_models(db),
|
||||
**_sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Per-user preferences set from the browser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
|
||||
router = APIRouter(prefix="/api/preferences", tags=["preferences"])
|
||||
|
||||
THEMES = ("moria", "shire")
|
||||
|
||||
|
||||
@router.post("/theme")
|
||||
async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=True)) -> dict:
|
||||
"""Mirror the browser's theme choice onto the account.
|
||||
|
||||
localStorage is the source of truth for the current tab; this is what makes
|
||||
the choice follow the user to another browser, and what lets the server
|
||||
render the right theme on first paint instead of flashing the default.
|
||||
"""
|
||||
if theme not in THEMES:
|
||||
return {"ok": False, "detail": "Unknown theme."}
|
||||
|
||||
# Replaced rather than mutated in place: SQLAlchemy only reliably detects
|
||||
# a change to a JSON column when the whole value is reassigned.
|
||||
user.settings_json = {**(user.settings_json or {}), "theme": theme}
|
||||
db.commit()
|
||||
return {"ok": True, "theme": theme}
|
||||
Reference in New Issue
Block a user