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),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
{% if not chat and current_model %}
|
||||
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
|
||||
{% endif %}
|
||||
{% if not chat and starting_temporary %}
|
||||
<input type="hidden" name="temporary" value="true">
|
||||
{% endif %}
|
||||
|
||||
<div class="composer__row">
|
||||
{% if can.get("files.upload") %}
|
||||
|
||||
@@ -25,6 +25,32 @@
|
||||
</h1>
|
||||
|
||||
<div class="topbar__actions">
|
||||
{#
|
||||
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 %}
|
||||
<span class="badge badge--warning"
|
||||
title="Not listed in the sidebar, and removed 24 hours after the last message.">
|
||||
Temporary
|
||||
</span>
|
||||
<button class="btn btn--sm" type="button"
|
||||
hx-post="/api/chats/{{ chat.id }}/keep" hx-swap="none"
|
||||
title="Keep this chat and list it in the sidebar">
|
||||
{{ icon("pin", "icon--sm") }} Keep
|
||||
</button>
|
||||
{% elif not chat and can.get("chat.create") %}
|
||||
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
|
||||
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}"
|
||||
aria-label="Temporary chat"
|
||||
title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}">
|
||||
{{ icon("clock") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if models %}
|
||||
{% if can.get("chat.model_select") or not chat %}
|
||||
{% include "chat/_model_picker.html" %}
|
||||
|
||||
@@ -114,6 +114,10 @@
|
||||
<rect x="3.5" y="4.5" width="17" height="4" rx="1.2"/>
|
||||
<path d="M5 8.5v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9M10 12.5h4"/>
|
||||
</symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="8.5"/>
|
||||
<path d="M12 7.2V12l3.2 2"/>
|
||||
</symbol>
|
||||
<symbol id="i-warning" viewBox="0 0 24 24">
|
||||
<path d="M12 4.2 21 19.5H3Z"/>
|
||||
<path d="M12 10v4M12 16.8h.01"/>
|
||||
|
||||
Reference in New Issue
Block a user