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