# utils/conversation_manager.py - 对话持久化管理器(集成Token统计) import json import os import time import tempfile import threading from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Any from dataclasses import dataclass import time try: from config import DATA_DIR, HOST_WORKSPACES_FILE except ImportError: import sys from pathlib import Path project_root = Path(__file__).resolve().parents[1] if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) from config import DATA_DIR, HOST_WORKSPACES_FILE try: from utils.perf_log import perf_log except Exception: perf_log = None @dataclass class ConversationMetadata: """对话元数据""" id: str title: str created_at: str updated_at: str project_path: Optional[str] project_relative_path: Optional[str] thinking_mode: bool total_messages: int total_tools: int run_mode: str = "fast" model_key: Optional[str] = None has_images: bool = False has_videos: bool = False status: str = "active" # active, archived, error class ListSearchMixin: """ConversationManager list search mixin 能力 mixin。""" def get_conversation_list(self, limit: int = 50, offset: int = 0, non_empty: bool = False, multi_agent_mode: Optional[bool] = None) -> Dict: """ 获取对话列表 Args: limit: 限制数量 offset: 偏移量 non_empty: 仅返回有内容(total_messages > 0)的对话,分页基于过滤后的结果 multi_agent_mode: 若为 True 仅返回多智能体模式对话;若为 False 仅返回常规对话;None 不过滤 Returns: Dict: 包含对话列表和统计信息 """ t0 = time.perf_counter() if perf_log: perf_log("get_conversation_list enter", extra={"limit": limit, "offset": offset, "non_empty": non_empty, "multi_agent_mode": multi_agent_mode}) try: def _filter_by_multi_agent(items): """按 multi_agent_mode 元数据过滤。""" if multi_agent_mode is None: return items result = [] for conv_id, meta in items: conv_ma = bool(meta.get("multi_agent_mode", False)) if conv_ma == multi_agent_mode: result.append((conv_id, meta)) return result if non_empty: # 过滤模式:全量加载后剔除空对话,再在过滤结果上分页, # 保证 total / has_more 与"有内容对话"的真实数量一致。 index = self._ensure_index_covering(limit=10000, offset=0) sorted_conversations = sorted( index.items(), key=lambda x: x[1].get("updated_at") or "", reverse=True ) sorted_conversations = [ item for item in sorted_conversations if (item[1].get("total_messages", 0) or 0) > 0 ] if multi_agent_mode is not None: sorted_conversations = _filter_by_multi_agent(sorted_conversations) total = len(sorted_conversations) conversations = sorted_conversations[offset:offset + limit] else: # 总对话数按文件数统计,防止初始索引截断导致"没有更多"按钮消失 total_files = len(self._iter_conversation_files(sort_by_mtime=False)) index = self._ensure_index_covering(limit=limit, offset=offset) # 按更新时间倒序排列(处理 None 值) sorted_conversations = sorted( index.items(), key=lambda x: x[1].get("updated_at") or "", reverse=True ) if multi_agent_mode is not None: sorted_conversations = _filter_by_multi_agent(sorted_conversations) # 分页 total = max(len(sorted_conversations), total_files if multi_agent_mode is None else len(sorted_conversations)) conversations = sorted_conversations[offset:offset+limit] # 格式化结果 result = [] for conv_id, metadata in conversations: result.append({ "id": conv_id, "title": metadata.get("title", "未命名对话"), "created_at": metadata.get("created_at"), "updated_at": metadata.get("updated_at"), "project_path": metadata.get("project_path"), "project_relative_path": metadata.get("project_relative_path"), "thinking_mode": metadata.get("thinking_mode", False), "total_messages": metadata.get("total_messages", 0), "total_tools": metadata.get("total_tools", 0), "status": metadata.get("status", "active"), "multi_agent_mode": bool(metadata.get("multi_agent_mode", False)) }) elapsed_ms = (time.perf_counter() - t0) * 1000 if perf_log: perf_log("get_conversation_list done", elapsed_ms=elapsed_ms, extra={"result_count": len(result), "total": total}) return { "conversations": result, "total": total, "limit": limit, "offset": offset, "has_more": offset + limit < total } except Exception as e: elapsed_ms = (time.perf_counter() - t0) * 1000 if perf_log: perf_log("get_conversation_list error", elapsed_ms=elapsed_ms, extra={"error": str(e)}) print(f"⌘ 获取对话列表失败: {e}") return { "conversations": [], "total": 0, "limit": limit, "offset": offset, "has_more": False } def get_recent_conversation_summaries( self, limit: int = 10, *, exclude_conversation_id: Optional[str] = None, first_message_max_chars: int = 100, ) -> List[Dict]: """返回当前工作区最近对话摘要:标题 + 首条用户消息。""" try: target_limit = max(1, int(limit)) listing = self.get_conversation_list(limit=10000, offset=0) summaries: List[Dict] = [] for item in listing.get("conversations") or []: conv_id = item.get("id") if not conv_id or conv_id == exclude_conversation_id: continue data = self.load_conversation(conv_id) or {} first_user_message = self._extract_first_user_message( data.get("messages") or [], max_chars=max(1, int(first_message_max_chars or 100)), ) if not first_user_message: continue summaries.append({ "id": conv_id, "title": item.get("title") or data.get("title") or "未命名对话", "created_at": item.get("created_at") or data.get("created_at"), "updated_at": item.get("updated_at") or data.get("updated_at"), "total_messages": item.get("total_messages") or (data.get("metadata") or {}).get("total_messages", 0), "total_tools": item.get("total_tools") or (data.get("metadata") or {}).get("total_tools", 0), "first_user_message": first_user_message, }) if len(summaries) >= target_limit: break return summaries except Exception as e: print(f"⌘ 获取最近对话摘要失败: {e}") return [] def search_conversation_summaries( self, query: str = "", *, keywords: Optional[List[str]] = None, start_date: Optional[str] = None, end_date: Optional[str] = None, limit: int = 10, first_message_max_chars: int = 100, exclude_conversation_id: Optional[str] = None, ) -> List[Dict]: """在当前工作区内搜索标题 + 首条用户消息,可按创建日期过滤。""" try: raw_keywords = keywords if isinstance(keywords, list) else [] normalized_keywords = [str(item).strip().lower() for item in raw_keywords if str(item or "").strip()] fallback_query = (query or "").strip().lower() if fallback_query: normalized_keywords.append(fallback_query) normalized_keywords = normalized_keywords[:3] start_key = (start_date or "").strip() end_key = (end_date or "").strip() listing = self.get_conversation_list(limit=10000, offset=0) results: List[Dict] = [] for item in listing.get("conversations") or []: conv_id = item.get("id") if not conv_id or conv_id == exclude_conversation_id: continue created_at = str(item.get("created_at") or "") date_key = created_at[:10] if start_key and date_key and date_key < start_key: continue if end_key and date_key and date_key > end_key: continue data = self.load_conversation(conv_id) or {} first_message = self._extract_first_user_message( data.get("messages") or [], max_chars=max(1, int(first_message_max_chars or 100)), ) # 空对话(没有任何用户文本)不应出现在搜索/列表结果中, # 否则无关键词 list 最近对话时会出现大量“新对话”。 if not first_message: continue title = item.get("title") or data.get("title") or "未命名对话" haystack = f"{title} {first_message}".lower() if normalized_keywords and not any(keyword in haystack for keyword in normalized_keywords): continue results.append({ "id": conv_id, "title": title, "first_user_message": first_message, "created_at": item.get("created_at") or data.get("created_at"), "updated_at": item.get("updated_at") or data.get("updated_at"), "total_messages": item.get("total_messages") or (data.get("metadata") or {}).get("total_messages", 0), "total_tools": item.get("total_tools") or (data.get("metadata") or {}).get("total_tools", 0), }) if len(results) >= max(1, int(limit or 10)): break return results except Exception as e: print(f"⌘ 搜索对话摘要失败: {e}") return [] def delete_conversation(self, conversation_id: str) -> bool: """ 删除对话 Args: conversation_id: 对话ID Returns: bool: 删除是否成功 """ try: # 删除对话文件 file_path = self._get_conversation_file_path(conversation_id) if file_path.exists(): file_path.unlink() # 从索引中删除 index = self._load_index() if conversation_id in index: del index[conversation_id] self._save_index(index) # 如果删除的是当前对话,清除当前对话ID if self.current_conversation_id == conversation_id: self.current_conversation_id = None print(f"🗑️ 已删除对话: {conversation_id}") return True except Exception as e: print(f"⌘ 删除对话失败 {conversation_id}: {e}") return False def archive_conversation(self, conversation_id: str) -> bool: """ 归档对话(标记为已归档,不删除) Args: conversation_id: 对话ID Returns: bool: 归档是否成功 """ try: # 更新对话状态 conversation_data = self.load_conversation(conversation_id) if not conversation_data: return False conversation_data["metadata"]["status"] = "archived" conversation_data["updated_at"] = datetime.now().isoformat() # 保存更新 self._save_conversation_file(conversation_id, conversation_data) self._update_index(conversation_id, conversation_data) print(f"📦 已归档对话: {conversation_id}") return True except Exception as e: print(f"⌘ 归档对话失败 {conversation_id}: {e}") return False def search_conversations(self, query: str, limit: int = 20) -> List[Dict]: """ 搜索对话 Args: query: 搜索关键词 limit: 限制数量 Returns: List[Dict]: 匹配的对话列表 """ try: index = self._load_index() results = [] query_lower = query.lower() for conv_id, metadata in index.items(): # 搜索标题 title = metadata.get("title", "").lower() if query_lower in title: score = 100 # 标题匹配权重最高 results.append((score, { "id": conv_id, "title": metadata.get("title"), "created_at": metadata.get("created_at"), "updated_at": metadata.get("updated_at"), "project_path": metadata.get("project_path"), "match_type": "title" })) continue # 搜索项目路径 project_path = metadata.get("project_path", "").lower() if query_lower in project_path: results.append((50, { "id": conv_id, "title": metadata.get("title"), "created_at": metadata.get("created_at"), "updated_at": metadata.get("updated_at"), "project_path": metadata.get("project_path"), "match_type": "project_path" })) # 按分数排序 results.sort(key=lambda x: x[0], reverse=True) # 返回前N个结果 return [result[1] for result in results[:limit]] except Exception as e: print(f"⌘ 搜索对话失败: {e}") return [] def cleanup_old_conversations(self, days: int = 30) -> int: """ 清理旧对话(可选功能) Args: days: 保留天数 Returns: int: 清理的对话数量 """ try: from datetime import datetime, timedelta cutoff_date = datetime.now() - timedelta(days=days) cutoff_iso = cutoff_date.isoformat() index = self._load_index() to_delete = [] for conv_id, metadata in index.items(): updated_at = metadata.get("updated_at", "") if updated_at < cutoff_iso and metadata.get("status") != "archived": to_delete.append(conv_id) deleted_count = 0 for conv_id in to_delete: if self.delete_conversation(conv_id): deleted_count += 1 if deleted_count > 0: print(f"🧹 清理了 {deleted_count} 个旧对话") return deleted_count except Exception as e: print(f"⌘ 清理旧对话失败: {e}") return 0