- 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
175 lines
6.5 KiB
Python
175 lines
6.5 KiB
Python
# 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 MetadataMixin:
|
||
"""ConversationManager metadata mixin 能力 mixin。"""
|
||
|
||
def _generate_conversation_id(self) -> str:
|
||
"""生成唯一的对话ID"""
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
# 添加毫秒确保唯一性
|
||
ms = int(time.time() * 1000) % 1000
|
||
return f"conv_{timestamp}_{ms:03d}"
|
||
|
||
def _get_conversation_file_path(self, conversation_id: str) -> Path:
|
||
"""获取对话文件路径"""
|
||
return self.conversations_dir / f"{conversation_id}.json"
|
||
|
||
def _extract_title_from_messages(self, messages: List[Dict]) -> str:
|
||
"""从消息中提取标题"""
|
||
# 找到第一个用户消息作为标题
|
||
for msg in messages:
|
||
if msg.get("role") == "user":
|
||
metadata = msg.get("metadata") or {}
|
||
source = str(metadata.get("source") or metadata.get("message_source") or "").strip().lower()
|
||
if source == "skill" or metadata.get("hidden") is True:
|
||
continue
|
||
content = msg.get("content", "").strip()
|
||
if content:
|
||
# 取前50个字符作为标题
|
||
title = content[:50]
|
||
if len(content) > 50:
|
||
title += "..."
|
||
return title
|
||
return "新对话"
|
||
|
||
def _extract_text_content(self, content: Any) -> str:
|
||
"""从字符串/多模态列表/字典中提取纯文本。"""
|
||
if isinstance(content, str):
|
||
return content
|
||
if isinstance(content, list):
|
||
parts: List[str] = []
|
||
for item in content:
|
||
if isinstance(item, str):
|
||
parts.append(item)
|
||
elif isinstance(item, dict):
|
||
if item.get("type") == "text":
|
||
parts.append(str(item.get("text") or ""))
|
||
elif isinstance(item.get("text"), str):
|
||
parts.append(item.get("text") or "")
|
||
return "".join(parts)
|
||
if isinstance(content, dict):
|
||
return str(content.get("text") or "")
|
||
return ""
|
||
|
||
def _extract_first_user_message(self, messages: List[Dict], max_chars: int = 100) -> str:
|
||
"""提取首条用户消息纯文本,用于最近对话/搜索展示。"""
|
||
for msg in messages or []:
|
||
if msg.get("role") != "user":
|
||
continue
|
||
text = " ".join(self._extract_text_content(msg.get("content")).split())
|
||
if not text:
|
||
continue
|
||
if len(text) > max_chars:
|
||
return text[:max_chars] + "..."
|
||
return text
|
||
return ""
|
||
|
||
def _count_tools_in_messages(self, messages: List[Dict]) -> int:
|
||
"""统计消息中的工具调用数量(仅统计 assistant.tool_calls)。"""
|
||
tool_count = 0
|
||
for msg in messages:
|
||
if msg.get("role") == "assistant" and "tool_calls" in msg:
|
||
tool_calls = msg.get("tool_calls", [])
|
||
tool_count += len(tool_calls) if isinstance(tool_calls, list) else 0
|
||
return tool_count
|
||
|
||
def _prepare_project_path_metadata(self, project_path: Optional[str]) -> Dict[str, Optional[str]]:
|
||
"""
|
||
将项目路径规范化为绝对/相对形式,便于在不同机器间迁移
|
||
"""
|
||
normalized = {
|
||
"project_path": None,
|
||
"project_relative_path": None
|
||
}
|
||
|
||
if not project_path:
|
||
return normalized
|
||
|
||
try:
|
||
absolute_path = Path(project_path).expanduser().resolve()
|
||
normalized["project_path"] = str(absolute_path)
|
||
|
||
try:
|
||
relative_path = absolute_path.relative_to(self.workspace_root)
|
||
normalized["project_relative_path"] = relative_path.as_posix()
|
||
except ValueError:
|
||
normalized["project_relative_path"] = None
|
||
except Exception:
|
||
# 回退为原始字符串,至少不会阻止对话保存
|
||
normalized["project_path"] = str(project_path)
|
||
normalized["project_relative_path"] = None
|
||
|
||
return normalized
|
||
|
||
def _initialize_token_statistics(self) -> Dict:
|
||
"""初始化Token统计结构"""
|
||
now = datetime.now().isoformat()
|
||
return {
|
||
"total_input_tokens": 0,
|
||
"total_output_tokens": 0,
|
||
"total_tokens": 0,
|
||
"current_context_tokens": 0,
|
||
"updated_at": now
|
||
}
|
||
|
||
def _validate_token_statistics(self, data: Dict) -> Dict:
|
||
"""验证并修复Token统计数据"""
|
||
token_stats = data.get("token_statistics", {})
|
||
|
||
# 确保必要字段存在
|
||
defaults = self._initialize_token_statistics()
|
||
for key, default_value in defaults.items():
|
||
if key not in token_stats:
|
||
token_stats[key] = default_value
|
||
|
||
# 确保数值类型正确
|
||
try:
|
||
token_stats["total_input_tokens"] = int(token_stats.get("total_input_tokens", 0))
|
||
token_stats["total_output_tokens"] = int(token_stats.get("total_output_tokens", 0))
|
||
token_stats["total_tokens"] = int(token_stats.get("total_tokens", 0))
|
||
token_stats["current_context_tokens"] = int(token_stats.get("current_context_tokens", 0))
|
||
except (ValueError, TypeError):
|
||
print("⚠️ Token统计数据损坏,重置为0")
|
||
token_stats = defaults
|
||
|
||
data["token_statistics"] = token_stats
|
||
return data
|