From 09eecbdd9a73384813b32c35047dea6860ca46e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sat, 1 Aug 2026 00:45:03 +0200 Subject: [PATCH] Temporary chats A clock in the top-right starts one. It is never listed in the sidebar and is swept a day after the last thing said in it. A real row rather than something held in the browser, because a reload, a crash or a background tab all look identical from here -- "delete when you navigate away" would lose conversations people meant to keep. The flag rides in the URL (/chat?temporary=1) rather than in JavaScript, so it survives a reload and can be bookmarked, and the composer carries it as a hidden field beside model_id. Keep clears the flag. Without a way out, a conversation that turns out to matter is destroyed a day later with no recourse, and people would find that out exactly once. archived was filtered in three places and temporary mirrors all three, plus Folder.visible_chats. It also skips the unread flag in _persist: there is no sidebar row for the dot to land on, and the toast would name a chat nobody can navigate to. The sweep measures age from the newest message, not from the chat row. 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. It runs at startup beside the existing upload sweep. Deleting a chat cascades its rows but leaves the files on disk; only the orphan sweep unlinks anything, and it looks only at uploads that were never attached. files.remove_files_for_chats() closes that for the new sweep. The same hole in delete_chat is pre-existing and left for its own change, which can now call the same helper. Co-Authored-By: Claude Opus 5 (1M context) --- src/lembas/api/chats.py | 43 +++- src/lembas/api/pages.py | 10 +- src/lembas/db/models/chat.py | 9 +- src/lembas/main.py | 4 + src/lembas/services/chat.py | 49 +++- src/lembas/services/files.py | 20 ++ src/lembas/services/generation.py | 6 +- src/lembas/web/static/css/app.css | 1 + src/lembas/web/templates/chat/_composer.html | 3 + src/lembas/web/templates/chat/index.html | 26 +++ src/lembas/web/templates/partials/icons.html | 4 + tests/test_temporary_chat.py | 222 +++++++++++++++++++ 12 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 tests/test_temporary_chat.py 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 %}
{% if can.get("files.upload") %} diff --git a/src/lembas/web/templates/chat/index.html b/src/lembas/web/templates/chat/index.html index 0b8809c..5e6ccfa 100644 --- a/src/lembas/web/templates/chat/index.html +++ b/src/lembas/web/templates/chat/index.html @@ -25,6 +25,32 @@
+ {# + A link, not a script: the flag lives in the URL, so it survives a + reload and can be bookmarked. On an existing temporary chat the same + corner explains what temporary means and offers the way out -- without + one, a conversation that turns out to matter is destroyed a day later + with no recourse. + #} + {% if chat and chat.temporary %} + + Temporary + + + {% elif not chat and can.get("chat.create") %} + + {{ icon("clock") }} + + {% endif %} + {% if models %} {% if can.get("chat.model_select") or not chat %} {% include "chat/_model_picker.html" %} diff --git a/src/lembas/web/templates/partials/icons.html b/src/lembas/web/templates/partials/icons.html index 30c13bb..a8c15da 100644 --- a/src/lembas/web/templates/partials/icons.html +++ b/src/lembas/web/templates/partials/icons.html @@ -114,6 +114,10 @@ + + + + diff --git a/tests/test_temporary_chat.py b/tests/test_temporary_chat.py new file mode 100644 index 0000000..08270f5 --- /dev/null +++ b/tests/test_temporary_chat.py @@ -0,0 +1,222 @@ +"""Temporary chats: hidden from the sidebar, and swept a day later.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from fastapi.testclient import TestClient +from sqlalchemy import select + +from lembas.db.models import Chat, Connection, Folder, Message, Model +from lembas.services import chat as chat_service +from lembas.services.crypto import encrypt + + +def _connection(db) -> Connection: + connection = Connection( + name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("") + ) + db.add(connection) + db.commit() + db.add(Model(connection_id=connection.id, model_id="test-model")) + db.commit() + return connection + + +def _temporary(db, chat_id: str, *, title: str = "Passing thought") -> Chat: + chat = db.get(Chat, chat_id) + chat.temporary = True + chat.title = title + db.commit() + return chat + + +# --- Hidden ------------------------------------------------------------------ +def test_a_temporary_chat_is_not_in_the_sidebar(client: TestClient, db, registered, make_chat): + _connection(db) + _temporary(db, make_chat()) + assert "Passing thought" not in client.get("/chat").text + + +def test_a_temporary_chat_inside_a_folder_is_not_listed( + client: TestClient, db, registered, make_chat +): + """The folder branch renders through the relationship, which is why this + needs asserting separately from the unfiled list.""" + _connection(db) + client.post("/api/folders", data={"name": "Quests"}) + folder = db.scalar(select(Folder)) + + chat_id = make_chat() + client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id}) + _temporary(db, chat_id) + + page = client.get("/chat").text + assert "Passing thought" not in page + assert "Empty" in page + + +def test_a_temporary_chat_gets_no_unread_dot(client: TestClient, db, registered, make_chat): + """There is no sidebar row for the dot, and the toast would name a chat + nobody can navigate to.""" + _connection(db) + chat = _temporary(db, make_chat()) + chat.unread = True + db.commit() + + response = client.get("/api/chats/unread") + assert chat.id not in response.text + assert "HX-Trigger" not in response.headers + + +def test_user_chats_excludes_temporary_ones(db, registered, make_chat, user_id): + _connection(db) + _temporary(db, make_chat()) + assert chat_service.user_chats(db, user_id) == [] + + +def test_a_finished_temporary_reply_is_not_unread(db, registered, make_chat): + from lembas.services import generation as generation_service + + _connection(db) + chat_id = make_chat() + _temporary(db, chat_id) + reply = Message(chat_id=chat_id, role="assistant", content="", complete=False) + db.add(reply) + db.commit() + + generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id) + generation.content.append("Waybread.") + generation_service._persist(generation, "", 0.0) + + db.expire_all() + assert db.get(Chat, chat_id).unread is False + + +# --- Starting one ------------------------------------------------------------- +def test_the_new_chat_screen_carries_the_flag(client: TestClient, db, registered): + _connection(db) + assert 'name="temporary"' not in client.get("/chat").text + assert 'name="temporary"' in client.get("/chat?temporary=1").text + + +def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered): + _connection(db) + client.post("/api/chats/start", data={"content": "hello", "temporary": "true"}) + assert db.scalar(select(Chat)).temporary is True + + +def test_starting_an_ordinary_chat_does_not(client: TestClient, db, registered): + _connection(db) + client.post("/api/chats/start", data={"content": "hello"}) + assert db.scalar(select(Chat)).temporary is False + + +# --- Keeping one -------------------------------------------------------------- +def test_keeping_a_chat_clears_the_flag(client: TestClient, db, registered, make_chat): + _connection(db) + chat = _temporary(db, make_chat()) + + response = client.post(f"/api/chats/{chat.id}/keep") + assert response.status_code == 204 + assert response.headers["HX-Refresh"] == "true" + + db.refresh(chat) + assert chat.temporary is False + assert "Passing thought" in client.get("/chat").text + + +def test_keeping_someone_elses_chat_is_not_found(client: TestClient, db, registered, make_chat): + from lembas.db.models import User + from lembas.security.passwords import hash_password + + _connection(db) + chat = _temporary(db, make_chat()) + other = User(name="Sam", email="s@shire.test", password_hash=hash_password("x")) + db.add(other) + db.commit() + chat.user_id = other.id + db.commit() + + assert client.post(f"/api/chats/{chat.id}/keep").status_code == 404 + + +# --- The sweep ---------------------------------------------------------------- +def _aged(db, chat_id: str, hours: float) -> None: + """Backdate the chat's newest message, which is what the sweep measures.""" + when = datetime.now(UTC) - timedelta(hours=hours) + message = Message(chat_id=chat_id, role="user", content="hello", created_at=when) + db.add(message) + db.commit() + + +def test_the_sweep_removes_a_stale_temporary_chat(db, registered, make_chat): + _connection(db) + chat_id = make_chat() + _temporary(db, chat_id) + _aged(db, chat_id, 25) + + assert chat_service.sweep_temporary(db) == 1 + assert db.get(Chat, chat_id) is None + + +def test_the_sweep_keeps_one_still_in_use(db, registered, make_chat): + """Age is measured from the newest message. A conversation still going at + hour 23 must not vanish mid-sentence.""" + _connection(db) + chat_id = make_chat() + _temporary(db, chat_id) + _aged(db, chat_id, 40) + _aged(db, chat_id, 0.5) + + assert chat_service.sweep_temporary(db) == 0 + assert db.get(Chat, chat_id) is not None + + +def test_the_sweep_leaves_ordinary_chats_alone(db, registered, make_chat): + _connection(db) + chat_id = make_chat() + _aged(db, chat_id, 500) + + assert chat_service.sweep_temporary(db) == 0 + assert db.get(Chat, chat_id) is not None + + +def test_a_temporary_chat_with_no_messages_ages_from_its_own_row(db, registered, make_chat): + _connection(db) + chat_id = make_chat() + chat = _temporary(db, chat_id) + chat.created_at = datetime.now(UTC) - timedelta(hours=30) + db.commit() + + assert chat_service.sweep_temporary(db) == 1 + + +def test_the_sweep_unlinks_the_files_too(client: TestClient, db, registered, make_chat): + """Deleting a chat cascades the rows but leaves the files on disk. Anything + that deletes chats has to remove them while the rows still say which.""" + from lembas.db.models import Attachment + from lembas.services.files import stored_path + + _connection(db) + chat_id = make_chat() + client.post("/api/files", files={"file": ("notes.txt", b"some words", "text/plain")}) + attachment = db.scalar(select(Attachment)) + attachment.chat_id = chat_id + message = Message(chat_id=chat_id, role="user", content="look") + db.add(message) + db.commit() + attachment.message_id = message.id + db.commit() + + path = stored_path(attachment.stored_name) + assert path is not None and path.exists() + + _temporary(db, chat_id) + db.query(Message).filter(Message.id == message.id).update( + {"created_at": datetime.now(UTC) - timedelta(hours=30)} + ) + db.commit() + + chat_service.sweep_temporary(db) + assert not path.exists()