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) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 00:45:03 +02:00
parent e185edc9e1
commit 09eecbdd9a
12 changed files with 387 additions and 10 deletions
+40 -3
View File
@@ -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),
)
)
)