diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 0554681..b879a60 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -34,6 +34,7 @@ router = APIRouter(prefix="/api/chats", tags=["chats"]) # Well under nginx's 60s default; see services/sse.py:KEEPALIVE. KEEPALIVE_AFTER = 15.0 + 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 @@ -43,7 +44,14 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat: return chat -def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str = "") -> Chat: +def _new_chat( + db: DBSession, + user: User, + *, + folder_id: str = "", + model_id: str = "", + temporary: bool = False, +) -> Chat: """Create a chat row, resolving which model it should use.""" chosen = None if model_id: @@ -60,6 +68,7 @@ def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str = folder_id=folder_id or None, model_id=chosen[0] if chosen else "", connection_id=chosen[1] if chosen else None, + temporary=temporary, ) db.add(chat) db.commit() @@ -74,6 +83,7 @@ async def start_chat( file_ids: list[str] = Form(default=[]), folder_id: str = Form(""), model_id: str = Form(""), + temporary: bool = Form(False), ) -> Response: """Create a chat from its first message. @@ -86,7 +96,9 @@ async def start_chat( if not content and not file_ids: return Response(status_code=status.HTTP_204_NO_CONTENT) - chat = _new_chat(db, user, folder_id=folder_id, model_id=model_id) + chat = _new_chat( + db, user, folder_id=folder_id, model_id=model_id, temporary=temporary + ) user_message = chat_service.create_message(db, chat, ROLE_USER, content) if file_ids: @@ -106,6 +118,25 @@ async def start_chat( # /start when the first message is actually sent. +@router.post("/{chat_id}/keep") +async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response: + """Stop a temporary chat being temporary. + + A conversation that turns out to matter has to have a way out; without one, + the sweep destroys it a day later with no recourse, and people discover that + exactly once. + """ + chat = _owned_chat(db, chat_id, user.id) + chat.temporary = False + db.commit() + + response = Response(status_code=status.HTTP_204_NO_CONTENT) + # The sidebar has to gain a row and the topbar has to lose a badge; a full + # refresh is one line against a handful of out-of-band fragments. + response.headers["HX-Refresh"] = "true" + return response + + @router.get("/unread") async def unread_poll(db: Db, user: RequiredUser) -> Response: """Dots for the sidebar, and a toast for anything newly arrived. @@ -119,7 +150,13 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response: """ chats = list( db.scalars( - select(Chat).where(Chat.user_id == user.id, Chat.archived.is_(False)) + select(Chat).where( + Chat.user_id == user.id, + Chat.archived.is_(False), + # A temporary chat has no sidebar row, so a dot has nowhere to + # land and the toast would name a chat nobody can navigate to. + Chat.temporary.is_(False), + ) ) ) diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index 79a79d2..961b31b 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -83,6 +83,7 @@ def sidebar_context(db: DBSession, user: User) -> dict: Chat.user_id == user.id, Chat.folder_id.is_(None), Chat.archived.is_(False), + Chat.temporary.is_(False), ) .order_by(Chat.pinned.desc(), Chat.updated_at.desc()) ) @@ -164,11 +165,15 @@ async def offline(request: Request) -> Response: @router.get("/chat") -async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = ""): +async def chat_index( + request: Request, db: Db, user: RequiredUser, model: str = "", temporary: bool = False +): """A composer with no chat behind it yet. `?model=` preselects one, which is how the pinned shortcuts work without - creating a row for a chat that may never be sent. + creating a row for a chat that may never be sent. `?temporary=1` is the + same idea for the temporary flag: it lives in the URL rather than in + JavaScript, so it survives a reload and can be bookmarked. """ context = _chat_context(db, user, None) @@ -195,6 +200,7 @@ async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = "bodies": {}, **context, "current_model": preselected, + "starting_temporary": temporary, **sidebar_context(db, user), }, ) diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py index e4dca77..0eedd64 100644 --- a/src/lembas/db/models/chat.py +++ b/src/lembas/db/models/chat.py @@ -58,7 +58,7 @@ class Folder(UUIDPrimaryKey, Timestamps, Base): Ordered like the unfiled list: pinned first, then most recently touched. """ - kept = [chat for chat in self.chats if not chat.archived] + kept = [chat for chat in self.chats if not chat.archived and not chat.temporary] kept.sort(key=lambda chat: chat.updated_at, reverse=True) kept.sort(key=lambda chat: not chat.pinned) return kept @@ -95,6 +95,13 @@ class Chat(UUIDPrimaryKey, Timestamps, Base): pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + # Never listed in the sidebar, and swept a day after the last thing said in + # it. A real row rather than something held in the browser, so a reload or a + # dropped connection does not lose the conversation -- and `Keep` clears the + # flag, because a temporary chat that turns out to matter must have a way + # out. See services/chat.py:sweep_temporary. + temporary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + # A reply landed while nobody was watching this chat. Cleared when the chat # is next opened. `unread_notified` stops the same arrival being announced # on every poll. diff --git a/src/lembas/main.py b/src/lembas/main.py index c1c6287..dcf0bed 100644 --- a/src/lembas/main.py +++ b/src/lembas/main.py @@ -62,6 +62,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # disk forever. Cheap, and startup is the natural moment for it. try: from lembas.db.session import session_scope + from lembas.services.chat import sweep_temporary from lembas.services.files import sweep_orphans from lembas.services.library.documents import sweep_unfiled @@ -70,6 +71,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: # Documents that predate knowledge bases have nowhere to live until # this runs; see services/library/documents.py. sweep_unfiled(db) + # Temporary chats older than a day. Startup only, like the sweeps + # above it -- see services/chat.py:sweep_temporary. + sweep_temporary(db) except Exception: # noqa: BLE001 - housekeeping must never block startup log.exception("orphaned upload sweep failed") diff --git a/src/lembas/services/chat.py b/src/lembas/services/chat.py index 54c642c..956eded 100644 --- a/src/lembas/services/chat.py +++ b/src/lembas/services/chat.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging +from datetime import UTC, datetime, timedelta from typing import Any -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session as DBSession from lembas.db.models import ( @@ -32,6 +33,9 @@ FORWARDED_PARAMS = frozenset( MAX_TITLE_LENGTH = 60 +# How long a temporary chat survives after the last thing said in it. +TEMPORARY_LIFETIME = timedelta(hours=24) + def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]: """Find the connection and model a chat should use. @@ -387,8 +391,49 @@ def create_message( return message +def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -> int: + """Delete temporary chats nobody has touched for a day. + + Age is measured from the newest message rather than from the chat row's own + timestamps. `created_at` would destroy a conversation still in use at hour + 23, and `updated_at` does not move when a message is inserted -- `onupdate` + fires on an UPDATE of the chat, and adding a message is not one. + + Startup only, like files.sweep_orphans beside it. A server that runs for a + month sweeps once; that is the trade the existing sweep already makes, and a + scheduler is a whole new concern for a single-worker application. + """ + cutoff = datetime.now(UTC) - older_than + newest = ( + select(Message.chat_id, func.max(Message.created_at).label("last")) + .group_by(Message.chat_id) + .subquery() + ) + stale = list( + db.scalars( + select(Chat) + .outerjoin(newest, newest.c.chat_id == Chat.id) + .where( + Chat.temporary.is_(True), + func.coalesce(newest.c.last, Chat.created_at) < cutoff, + ) + ) + ) + if not stale: + return 0 + + files_service.remove_files_for_chats(db, [chat.id for chat in stale]) + for chat in stale: + db.delete(chat) + db.commit() + log.info("swept %d temporary chat(s)", len(stale)) + return len(stale) + + def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]: - query = select(Chat).where(Chat.user_id == user_id, Chat.archived.is_(False)) + query = select(Chat).where( + Chat.user_id == user_id, Chat.archived.is_(False), Chat.temporary.is_(False) + ) if folder_id is not None: query = query.where(Chat.folder_id == folder_id) return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc()))) diff --git a/src/lembas/services/files.py b/src/lembas/services/files.py index 8e2787e..26f500b 100644 --- a/src/lembas/services/files.py +++ b/src/lembas/services/files.py @@ -435,6 +435,26 @@ def claim(db: DBSession, *, ids: list[str], user_id: str, message_id: str) -> li return pending +def remove_files_for_chats(db: DBSession, chat_ids: list[str]) -> int: + """Unlink the files belonging to these chats' attachments. + + Deleting a Chat cascades to its Message and Attachment *rows* but leaves the + files on disk -- only `sweep_orphans` unlinks anything, and it only looks at + uploads that were never attached. Anything that deletes chats has to call + this first, while the rows still say which files to remove. + """ + if not chat_ids: + return 0 + + removed = 0 + for attachment in db.scalars(select(Attachment).where(Attachment.chat_id.in_(chat_ids))): + path = stored_path(attachment.stored_name) + if path is not None and path.exists(): + path.unlink(missing_ok=True) + removed += 1 + return removed + + def sweep_orphans(db: DBSession, older_than: timedelta = ORPHAN_AGE) -> int: """Delete uploads that were never attached to a message. diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index b2913ec..2e4d9dc 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -443,8 +443,10 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None: chat.title_generated = True # Nobody watching when it landed, so it is news. The chat page - # clears this when it is next opened. - if generation.followers == 0: + # clears this when it is next opened. Not for a temporary chat: + # there is no sidebar row for the dot, and the toast would name a + # chat nobody can navigate to. + if generation.followers == 0 and not chat.temporary: chat.unread = True chat.unread_notified = False diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index ca59b6b..ae33297 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -345,6 +345,7 @@ button, input, textarea, select { .badge--leaf { background: var(--leaf-soft); color: var(--leaf); } .badge--success { background: var(--success-soft); color: var(--success); } .badge--danger { background: var(--danger-soft); color: var(--danger); } +.badge--warning { background: var(--warning-soft); color: var(--warning); } /* --- Application shell ----------------------------------------------------- */ .shell { display: flex; height: 100dvh; overflow: hidden; } diff --git a/src/lembas/web/templates/chat/_composer.html b/src/lembas/web/templates/chat/_composer.html index bc67d42..02ae468 100644 --- a/src/lembas/web/templates/chat/_composer.html +++ b/src/lembas/web/templates/chat/_composer.html @@ -50,6 +50,9 @@ {% if not chat and current_model %} {% endif %} + {% if not chat and starting_temporary %} + + {% endif %}