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.
|
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
|
||||||
KEEPALIVE_AFTER = 15.0
|
KEEPALIVE_AFTER = 15.0
|
||||||
|
|
||||||
|
|
||||||
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||||
chat = db.get(Chat, chat_id)
|
chat = db.get(Chat, chat_id)
|
||||||
# 404 rather than 403 for someone else's chat: whether a given id exists is
|
# 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
|
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."""
|
"""Create a chat row, resolving which model it should use."""
|
||||||
chosen = None
|
chosen = None
|
||||||
if model_id:
|
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,
|
folder_id=folder_id or None,
|
||||||
model_id=chosen[0] if chosen else "",
|
model_id=chosen[0] if chosen else "",
|
||||||
connection_id=chosen[1] if chosen else None,
|
connection_id=chosen[1] if chosen else None,
|
||||||
|
temporary=temporary,
|
||||||
)
|
)
|
||||||
db.add(chat)
|
db.add(chat)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -74,6 +83,7 @@ async def start_chat(
|
|||||||
file_ids: list[str] = Form(default=[]),
|
file_ids: list[str] = Form(default=[]),
|
||||||
folder_id: str = Form(""),
|
folder_id: str = Form(""),
|
||||||
model_id: str = Form(""),
|
model_id: str = Form(""),
|
||||||
|
temporary: bool = Form(False),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Create a chat from its first message.
|
"""Create a chat from its first message.
|
||||||
|
|
||||||
@@ -86,7 +96,9 @@ async def start_chat(
|
|||||||
if not content and not file_ids:
|
if not content and not file_ids:
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
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)
|
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||||
if file_ids:
|
if file_ids:
|
||||||
@@ -106,6 +118,25 @@ async def start_chat(
|
|||||||
# /start when the first message is actually sent.
|
# /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")
|
@router.get("/unread")
|
||||||
async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||||
"""Dots for the sidebar, and a toast for anything newly arrived.
|
"""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(
|
chats = list(
|
||||||
db.scalars(
|
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.user_id == user.id,
|
||||||
Chat.folder_id.is_(None),
|
Chat.folder_id.is_(None),
|
||||||
Chat.archived.is_(False),
|
Chat.archived.is_(False),
|
||||||
|
Chat.temporary.is_(False),
|
||||||
)
|
)
|
||||||
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
||||||
)
|
)
|
||||||
@@ -164,11 +165,15 @@ async def offline(request: Request) -> Response:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/chat")
|
@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.
|
"""A composer with no chat behind it yet.
|
||||||
|
|
||||||
`?model=` preselects one, which is how the pinned shortcuts work without
|
`?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)
|
context = _chat_context(db, user, None)
|
||||||
|
|
||||||
@@ -195,6 +200,7 @@ async def chat_index(request: Request, db: Db, user: RequiredUser, model: str =
|
|||||||
"bodies": {},
|
"bodies": {},
|
||||||
**context,
|
**context,
|
||||||
"current_model": preselected,
|
"current_model": preselected,
|
||||||
|
"starting_temporary": temporary,
|
||||||
**sidebar_context(db, user),
|
**sidebar_context(db, user),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
|
|
||||||
Ordered like the unfiled list: pinned first, then most recently touched.
|
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: chat.updated_at, reverse=True)
|
||||||
kept.sort(key=lambda chat: not chat.pinned)
|
kept.sort(key=lambda chat: not chat.pinned)
|
||||||
return kept
|
return kept
|
||||||
@@ -95,6 +95,13 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
archived: 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
|
# A reply landed while nobody was watching this chat. Cleared when the chat
|
||||||
# is next opened. `unread_notified` stops the same arrival being announced
|
# is next opened. `unread_notified` stops the same arrival being announced
|
||||||
# on every poll.
|
# 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.
|
# disk forever. Cheap, and startup is the natural moment for it.
|
||||||
try:
|
try:
|
||||||
from lembas.db.session import session_scope
|
from lembas.db.session import session_scope
|
||||||
|
from lembas.services.chat import sweep_temporary
|
||||||
from lembas.services.files import sweep_orphans
|
from lembas.services.files import sweep_orphans
|
||||||
from lembas.services.library.documents import sweep_unfiled
|
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
|
# Documents that predate knowledge bases have nowhere to live until
|
||||||
# this runs; see services/library/documents.py.
|
# this runs; see services/library/documents.py.
|
||||||
sweep_unfiled(db)
|
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
|
except Exception: # noqa: BLE001 - housekeeping must never block startup
|
||||||
log.exception("orphaned upload sweep failed")
|
log.exception("orphaned upload sweep failed")
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
from lembas.db.models import (
|
from lembas.db.models import (
|
||||||
@@ -32,6 +33,9 @@ FORWARDED_PARAMS = frozenset(
|
|||||||
|
|
||||||
MAX_TITLE_LENGTH = 60
|
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]:
|
def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]:
|
||||||
"""Find the connection and model a chat should use.
|
"""Find the connection and model a chat should use.
|
||||||
@@ -387,8 +391,49 @@ def create_message(
|
|||||||
return 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]:
|
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:
|
if folder_id is not None:
|
||||||
query = query.where(Chat.folder_id == folder_id)
|
query = query.where(Chat.folder_id == folder_id)
|
||||||
return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc())))
|
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
|
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:
|
def sweep_orphans(db: DBSession, older_than: timedelta = ORPHAN_AGE) -> int:
|
||||||
"""Delete uploads that were never attached to a message.
|
"""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
|
chat.title_generated = True
|
||||||
|
|
||||||
# Nobody watching when it landed, so it is news. The chat page
|
# Nobody watching when it landed, so it is news. The chat page
|
||||||
# clears this when it is next opened.
|
# clears this when it is next opened. Not for a temporary chat:
|
||||||
if generation.followers == 0:
|
# 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 = True
|
||||||
chat.unread_notified = False
|
chat.unread_notified = False
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ button, input, textarea, select {
|
|||||||
.badge--leaf { background: var(--leaf-soft); color: var(--leaf); }
|
.badge--leaf { background: var(--leaf-soft); color: var(--leaf); }
|
||||||
.badge--success { background: var(--success-soft); color: var(--success); }
|
.badge--success { background: var(--success-soft); color: var(--success); }
|
||||||
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.badge--warning { background: var(--warning-soft); color: var(--warning); }
|
||||||
|
|
||||||
/* --- Application shell ----------------------------------------------------- */
|
/* --- Application shell ----------------------------------------------------- */
|
||||||
.shell { display: flex; height: 100dvh; overflow: hidden; }
|
.shell { display: flex; height: 100dvh; overflow: hidden; }
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
{% if not chat and current_model %}
|
{% if not chat and current_model %}
|
||||||
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
|
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if not chat and starting_temporary %}
|
||||||
|
<input type="hidden" name="temporary" value="true">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="composer__row">
|
<div class="composer__row">
|
||||||
{% if can.get("files.upload") %}
|
{% if can.get("files.upload") %}
|
||||||
|
|||||||
@@ -25,6 +25,32 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="topbar__actions">
|
<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 models %}
|
||||||
{% if can.get("chat.model_select") or not chat %}
|
{% if can.get("chat.model_select") or not chat %}
|
||||||
{% include "chat/_model_picker.html" %}
|
{% include "chat/_model_picker.html" %}
|
||||||
|
|||||||
@@ -114,6 +114,10 @@
|
|||||||
<rect x="3.5" y="4.5" width="17" height="4" rx="1.2"/>
|
<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"/>
|
<path d="M5 8.5v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9M10 12.5h4"/>
|
||||||
</symbol>
|
</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">
|
<symbol id="i-warning" viewBox="0 0 24 24">
|
||||||
<path d="M12 4.2 21 19.5H3Z"/>
|
<path d="M12 4.2 21 19.5H3Z"/>
|
||||||
<path d="M12 10v4M12 16.8h.01"/>
|
<path d="M12 10v4M12 16.8h.01"/>
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user