agent-Specialization/utils/conversation_manager/list_search_mixin.py
JOJO f661dc6456 refactor(server,modules,utils,frontend): split oversized modules into packages and fix regressions
- Split server/chat.py, server/status.py, server/tasks.py into sub-packages
- Split utils/context_manager.py, utils/conversation_manager.py into mixin packages
- Split modules/file_manager.py, persistent_terminal.py, terminal_ops.py, mcp_client_manager.py into packages
- Split core/main_terminal_parts/context.py into base + mixins
- Split static/src/app/methods/{ui,message,upload,taskPolling,conversation} into sub-modules
- Fix flattened method exports, dynamic import paths, missing cross-module imports
- Add missing permission/network/execution mode constants and _PERMISSION_MODE_LABEL
- Update AGENTS.md, CLAUDE.md and project memories to reflect new structure
2026-06-20 15:54:48 +08:00

372 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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