agent-Specialization/utils/conversation_manager/list_search_mixin.py
JOJO 8e5d4f05d9 fix(multi-agent): 多智能体模式下侧边栏只加载对应模式的对话记录
后端 get_conversation_list / web_terminal / conversation路由 增加 multi_agent_mode 查询参数:
- multi_agent_mode=1 仅返回 multi_agent_mode 对话
- multi_agent_mode=0 仅返回常规对话
- 不传则不过滤

前端 loadConversationsList / loadWorkspaceConversations 根据 this.multiAgentMode 自动带参
2026-07-12 12:20:18 +08:00

405 lines
16 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
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