"""会话存储:创建时即占位,用于校验 ID 由后端签发;此处为协议和进程内存实现,MongoDB 实现见 conversation_mongo""" from datetime import UTC, datetime, timedelta from typing import Protocol from uuid import uuid4 from app.config import settings from app.schemas.conversation import ConversationSummary, Turn TITLE_MAX_LENGTH = 30 def make_title(prompt: str) -> str: """会话标题取首轮用户输入,合并空白后截断""" title = " ".join(prompt.split()) if len(title) <= TITLE_MAX_LENGTH: return title return title[:TITLE_MAX_LENGTH] + "…" class ConversationStore(Protocol): """内存和 MongoDB 实现同一组方法,业务代码不需要关心具体存储""" async def create(self, user_id: int, screen_id: str | None = None) -> str: ... async def exists(self, conversation_id: str, user_id: int) -> bool: """会话已签发、未过期且属于该用户;不属于时与不存在同样返回 False""" ... async def load_turns( self, conversation_id: str, limit: int | None ) -> list[Turn]: ... async def save_turn(self, turn: Turn) -> None: ... async def list_by_user( self, user_id: int, limit: int, offset: int, screen_id: str | None = None ) -> list[ConversationSummary]: """该用户已产生对话的会话,按最近更新时间倒序;没有对话的空会话不返回;给了 screen_id 只返回该大屏的""" ... async def count_by_user(self, user_id: int, screen_id: str | None = None) -> int: """与 list_by_user 同一口径的总数""" ... async def rename(self, conversation_id: str, user_id: int, title: str) -> bool: """会话不存在或不属于该用户返回 False""" ... async def delete(self, conversation_id: str, user_id: int) -> bool: """连同对话轮次一起删除;会话不存在或不属于该用户返回 False""" ... def _now() -> datetime: return datetime.now(UTC) class InMemoryConversationStore: def __init__(self, empty_ttl: int | None = None): ttl = empty_ttl if empty_ttl is not None else settings.conversation_empty_ttl self._empty_ttl = timedelta(seconds=ttl) # 会话 ID -> 创建时间;轮次按 thread_id 去重并保持首次写入顺序 self._created: dict[str, datetime] = {} self._owner: dict[str, int] = {} self._screen: dict[str, str | None] = {} self._titles: dict[str, str] = {} self._turns: dict[str, dict[str, Turn]] = {} def _purge_expired(self) -> None: # 只清理没有任何轮次且超过保留时长的空会话,有对话的会话不受影响 deadline = _now() - self._empty_ttl for conversation_id in [ key for key, created in self._created.items() if created < deadline and not self._turns.get(key) ]: self._forget(conversation_id) def _forget(self, conversation_id: str) -> None: for table in (self._created, self._owner, self._screen, self._titles, self._turns): table.pop(conversation_id, None) async def create(self, user_id: int, screen_id: str | None = None) -> str: self._purge_expired() conversation_id = str(uuid4()) self._created[conversation_id] = _now() self._owner[conversation_id] = user_id self._screen[conversation_id] = screen_id return conversation_id async def exists(self, conversation_id: str, user_id: int) -> bool: self._purge_expired() return ( conversation_id in self._created and self._owner.get(conversation_id) == user_id ) async def load_turns(self, conversation_id: str, limit: int | None) -> list[Turn]: turns = list(self._turns.get(conversation_id, {}).values()) turns.sort(key=lambda turn: turn.created_at) if limit is None: return turns # 只取最近 limit 轮,limit 为 0 时不带历史 return turns[-limit:] if limit else [] async def save_turn(self, turn: Turn) -> None: # 同一任务再次写入(澄清恢复后)保留首次创建时间,只更新内容 existing = self._turns.setdefault(turn.conversation_id, {}).get(turn.thread_id) if existing is not None: turn = turn.model_copy(update={"created_at": existing.created_at}) self._turns[turn.conversation_id][turn.thread_id] = turn def _summaries( self, user_id: int, screen_id: str | None ) -> list[ConversationSummary]: summaries = [] for conversation_id, owner in self._owner.items(): turns = sorted( self._turns.get(conversation_id, {}).values(), key=lambda turn: turn.created_at, ) if owner != user_id or not turns: continue if screen_id is not None and self._screen.get(conversation_id) != screen_id: continue summaries.append( ConversationSummary( conversation_id=conversation_id, title=self._titles.get(conversation_id) or make_title(turns[0].prompt), screen_id=self._screen.get(conversation_id), updated_at=turns[-1].created_at, ) ) summaries.sort( key=lambda item: (item.updated_at, item.conversation_id), reverse=True ) return summaries async def list_by_user( self, user_id: int, limit: int, offset: int, screen_id: str | None = None ) -> list[ConversationSummary]: return self._summaries(user_id, screen_id)[offset : offset + limit] async def count_by_user(self, user_id: int, screen_id: str | None = None) -> int: return len(self._summaries(user_id, screen_id)) async def rename(self, conversation_id: str, user_id: int, title: str) -> bool: if not await self.exists(conversation_id, user_id): return False self._titles[conversation_id] = title return True async def delete(self, conversation_id: str, user_id: int) -> bool: if not await self.exists(conversation_id, user_id): return False self._forget(conversation_id) return True