# 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 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 @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) -> Dict: """ 获取对话列表 Args: limit: 限制数量 offset: 偏移量 non_empty: 仅返回有内容(total_messages > 0)的对话,分页基于过滤后的结果 Returns: Dict: 包含对话列表和统计信息 """ try: 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 ] 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 ) # 分页 total = max(len(sorted_conversations), total_files) 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") }) return { "conversations": result, "total": total, "limit": limit, "offset": offset, "has_more": offset + limit < total } except Exception as 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