""" Conversation Manager - 类似ChatGPT的多会话管理 支持用户维度、会话列表、历史消息 """ import asyncio import json import os from dataclasses import dataclass, field, asdict from datetime import datetime from pathlib import Path from typing import Any, Optional, List from enum import Enum from gfa_core.project_paths import MEMORY_ROOT class ConversationStatus(str, Enum): """会话状态 - 使用str继承以便JSON序列化""" ACTIVE = "active" ARCHIVED = "archived" DELETED = "deleted" @dataclass class Conversation: """会话(类似ChatGPT的一个聊天窗口)""" conversation_id: str user_id: str title: str status: str = "active" # 使用str而不是Enum # 关联信息 gene_family: Optional[str] = None species: Optional[str] = None job_id: Optional[str] = None input_dir: Optional[str] = None output_dir: Optional[str] = None working_dir: Optional[str] = None # 消息历史 messages: List[dict] = field(default_factory=list) # 元数据 created_at: str = field(default_factory=lambda: datetime.now().isoformat()) updated_at: str = field(default_factory=lambda: datetime.now().isoformat()) message_count: int = 0 # 设置 model: str = "glm-5" system_prompt: Optional[str] = None @dataclass class ConversationSummary: """会话摘要(用于列表展示)""" conversation_id: str title: str last_message: str updated_at: str message_count: int gene_family: Optional[str] = None job_id: Optional[str] = None input_dir: Optional[str] = None output_dir: Optional[str] = None working_dir: Optional[str] = None class ConversationManager: """ 会话管理器 功能: 1. 创建/删除/归档会话 2. 按用户列出所有会话 3. 获取会话消息历史 4. 自动更新会话标题 """ def __init__(self, storage_dir: Path = None): if storage_dir is None: storage_dir = Path(os.environ.get("BBAV5_CONVERSATION_DIR") or MEMORY_ROOT / "conversations") self.storage_dir = Path(storage_dir) self.storage_dir.mkdir(parents=True, exist_ok=True) # 用户索引:user_id -> list of conversation_ids self._user_index_file = self.storage_dir / "user_index.json" self._user_index: dict = {} self._load_user_index() self._lock = asyncio.Lock() print(f"[ConversationManager] Storage: {self.storage_dir}") def _load_user_index(self): """加载用户索引""" if self._user_index_file.exists(): try: with open(self._user_index_file, 'r', encoding='utf-8') as f: self._user_index = json.load(f) except Exception as e: print(f"[ConversationManager] Failed to load index: {e}") self._user_index = {} async def _save_user_index(self): """保存用户索引""" async with self._lock: with open(self._user_index_file, 'w', encoding='utf-8') as f: json.dump(self._user_index, f, ensure_ascii=False, indent=2) def _get_conv_file(self, conversation_id: str) -> Path: """获取会话文件路径。 Existing conversation ids contain their creation date, e.g. conv_20260524_172646_default. Resolve that month first so old chats remain readable after the calendar month changes. """ match = None try: import re match = re.match(r"^conv_(\d{6})\d{2}_", str(conversation_id or "")) except Exception: match = None candidate_months = [] if match: candidate_months.append(match.group(1)) candidate_months.append(datetime.now().strftime("%Y%m")) seen = set() for date_prefix in candidate_months: if date_prefix in seen: continue seen.add(date_prefix) dir_path = self.storage_dir / date_prefix path = dir_path / f"{conversation_id}.json" if path.exists(): return path dir_path = self.storage_dir / (candidate_months[0] if candidate_months else datetime.now().strftime("%Y%m")) dir_path.mkdir(exist_ok=True) return dir_path / f"{conversation_id}.json" async def create_conversation( self, user_id: str, title: str = None, gene_family: str = None, species: str = None, system_prompt: str = None, job_id: str = None, input_dir: str = None, output_dir: str = None, working_dir: str = None, ) -> Conversation: """创建新会话""" conversation_id = f"conv_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{user_id[:8]}" if title is None: title = f"新会话 {datetime.now().strftime('%m-%d %H:%M')}" conversation = Conversation( conversation_id=conversation_id, user_id=user_id, title=title, gene_family=gene_family, species=species, system_prompt=system_prompt, job_id=job_id, input_dir=input_dir, output_dir=output_dir, working_dir=working_dir, ) # 保存会话 await self._save_conversation(conversation) # 更新用户索引 if user_id not in self._user_index: self._user_index[user_id] = [] self._user_index[user_id].insert(0, conversation_id) await self._save_user_index() print(f"[ConversationManager] Created: {conversation_id} for user {user_id}") return conversation async def _save_conversation(self, conversation: Conversation): """保存会话到文件""" file_path = self._get_conv_file(conversation.conversation_id) async with self._lock: with open(file_path, 'w', encoding='utf-8') as f: json.dump(asdict(conversation), f, ensure_ascii=False, indent=2) async def get_conversation(self, conversation_id: str) -> Optional[Conversation]: """获取会话详情""" file_path = self._get_conv_file(conversation_id) if not file_path.exists(): return None try: async with self._lock: with open(file_path, 'r', encoding='utf-8') as f: data = json.load(f) return Conversation(**data) except Exception as e: print(f"[ConversationManager] Failed to load conversation: {e}") return None async def list_user_conversations( self, user_id: str, status: str = "active", limit: int = 20, offset: int = 0, ) -> List[ConversationSummary]: """ 列出用户的所有会话(类似ChatGPT左侧边栏) Returns: List[ConversationSummary]: 会话摘要列表,按更新时间倒序 """ conversation_ids = self._user_index.get(user_id, []) summaries = [] for conv_id in conversation_ids: conv = await self.get_conversation(conv_id) if conv and conv.status == status: # 获取最后一条消息 last_msg = "" if conv.messages: last_msg_content = conv.messages[-1].get('content', '') last_msg = last_msg_content[:50] + "..." if len(last_msg_content) > 50 else last_msg_content summaries.append(ConversationSummary( conversation_id=conv.conversation_id, title=conv.title, last_message=last_msg, updated_at=conv.updated_at, message_count=conv.message_count, gene_family=conv.gene_family, job_id=conv.job_id, input_dir=conv.input_dir, output_dir=conv.output_dir, working_dir=conv.working_dir, )) # 按更新时间倒序 summaries.sort(key=lambda x: x.updated_at, reverse=True) return summaries[offset:offset + limit] async def add_message( self, conversation_id: str, role: str, content: str, metadata: dict = None, ) -> bool: """添加消息到会话""" conv = await self.get_conversation(conversation_id) if not conv: return False message = { "role": role, "content": content, "timestamp": datetime.now().isoformat(), "metadata": metadata or {}, } conv.messages.append(message) conv.message_count += 1 conv.updated_at = datetime.now().isoformat() # 自动更新标题(第一条用户消息) if conv.message_count == 1 and role == "user": conv.title = content[:30] + "..." if len(content) > 30 else content await self._save_conversation(conv) return True await self._save_conversation(conv) return True async def get_messages( self, conversation_id: str, limit: int = 100, offset: int = 0, ) -> List[dict]: """获取会话消息历史""" conv = await self.get_conversation(conversation_id) if not conv: return [] return conv.messages[offset:offset + limit] async def update_title(self, conversation_id: str, title: str) -> bool: """更新会话标题""" conv = await self.get_conversation(conversation_id) if not conv: return False conv.title = title await self._save_conversation(conv) return True async def delete_conversation(self, conversation_id: str) -> bool: """删除会话""" conv = await self.get_conversation(conversation_id) if not conv: return False conv.status = "deleted" await self._save_conversation(conv) # 从用户索引中移除 if conv.user_id in self._user_index: if conversation_id in self._user_index[conv.user_id]: self._user_index[conv.user_id].remove(conversation_id) await self._save_user_index() return True async def archive_conversation(self, conversation_id: str) -> bool: """归档会话""" conv = await self.get_conversation(conversation_id) if not conv: return False conv.status = "archived" await self._save_conversation(conv) return True async def link_to_job(self, conversation_id: str, job_id: str) -> bool: """关联会话到分析任务""" conv = await self.get_conversation(conversation_id) if not conv: return False conv.job_id = job_id await self._save_conversation(conv) return True