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
+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)
+186
View File
@@ -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
+290
View File
@@ -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"]
+118
View File
@@ -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()
+109
View File
@@ -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),
},
)
+29
View File
@@ -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}