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:
+40
-3
@@ -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),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user