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