agent-Specialization/utils/conversation_manager/index_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

258 lines
11 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 IndexMixin:
"""ConversationManager index mixin 能力 mixin。"""
def _migrate_legacy_conversation_files(self):
"""把 data/conversations/conv_*.json 按 metadata.project_path 移入对应 workspace 子目录。"""
try:
legacy_files = [
p for p in self.conversations_root.glob("conv_*.json")
if p.is_file() and p.parent == self.conversations_root
]
except Exception:
return
moved_count = 0
for file_path in legacy_files:
try:
data = json.loads(file_path.read_text(encoding="utf-8"))
metadata = data.get("metadata", {}) if isinstance(data, dict) else {}
project_path = self._normalize_path(metadata.get("project_path"))
workspace_id = self._resolve_workspace_id(project_path)
if not workspace_id:
continue
target_dir = self._conversation_dir_for_workspace(workspace_id)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / file_path.name
if target.exists():
print(f"⚠️ legacy 对话迁移跳过,目标已存在: {target}")
continue
file_path.replace(target)
moved_count += 1
except Exception as exc:
print(f"⚠️ legacy 对话迁移失败 {file_path.name}: {exc}")
if moved_count:
print(f"🔄 已迁移 {moved_count} 个 legacy 对话到工作区目录")
def _iter_conversation_files(self, sort_by_mtime: bool = True):
"""遍历对话文件(排除索引文件),可按修改时间降序排序。"""
files = []
for p in self.conversations_dir.glob("*.json"):
if p == self.index_file:
continue
stem = p.stem or ""
# 跳过索引备份/损坏文件,避免被误当作对话文件参与重建
if stem == "index" or stem.startswith("index_corrupt_"):
continue
# 仅纳入标准对话文件,避免其他 json 干扰索引
if not stem.startswith("conv_"):
continue
files.append(p)
if sort_by_mtime:
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return files
def _atomic_write_json(self, target_file: Path, payload: Dict[str, Any]):
"""原子写入 JSON使用唯一临时文件避免并发写同名 .tmp 产生竞态。"""
target_file.parent.mkdir(parents=True, exist_ok=True)
temp_path: Optional[Path] = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(target_file.parent),
prefix=f".{target_file.name}.",
suffix=".tmp",
delete=False,
) as fh:
temp_path = Path(fh.name)
json.dump(payload, fh, ensure_ascii=False, indent=2)
fh.flush()
os.fsync(fh.fileno())
os.replace(str(temp_path), str(target_file))
finally:
if temp_path and temp_path.exists():
try:
temp_path.unlink()
except Exception:
pass
def _rebuild_index_from_files(self, max_count: Optional[int] = None) -> Dict:
"""
从现有对话文件重建索引。
Args:
max_count: 限制重建的条目数按文件修改时间倒序None 表示全量重建。
"""
rebuilt_index: Dict[str, Dict] = {}
files = self._iter_conversation_files(sort_by_mtime=True)
if max_count is not None:
files = files[:max(0, int(max_count))]
for file_path in files:
try:
with open(file_path, "r", encoding="utf-8") as f:
raw = f.read().strip()
if not raw:
continue
data = json.loads(raw)
except Exception as exc:
print(f"⚠️ 重建索引时跳过 {file_path.name}: {exc}")
continue
conv_id = data.get("id") or file_path.stem
metadata = data.get("metadata", {}) or {}
rebuilt_index[conv_id] = {
"title": data.get("title") or "未命名对话",
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"project_path": metadata.get("project_path"),
"project_relative_path": metadata.get("project_relative_path"),
"thinking_mode": metadata.get("thinking_mode", False),
"run_mode": metadata.get("run_mode") or ("thinking" if metadata.get("thinking_mode") else "fast"),
"model_key": metadata.get("model_key"),
"has_images": metadata.get("has_images", False),
"has_videos": metadata.get("has_videos", False),
"total_messages": metadata.get("total_messages", 0),
"total_tools": metadata.get("total_tools", 0),
"status": metadata.get("status", "active"),
}
if rebuilt_index:
print(f"🔄 已从对话文件重建索引,共 {len(rebuilt_index)} 条记录")
return rebuilt_index
def _index_missing_conversations(self, index: Dict) -> bool:
"""检查索引是否缺失本地对话文件"""
index_ids = set(index.keys())
for file_path in self._iter_conversation_files():
conv_id = file_path.stem
if conv_id and conv_id not in index_ids:
print(f"🔍 对话 {conv_id} 未出现在索引中,将重建索引。")
return True
return False
def _load_index(self, ensure_integrity: bool = False, max_rebuild: Optional[int] = None) -> Dict:
"""加载对话索引,可选地在缺失时自动重建(可限制重建条数)"""
try:
index: Dict = {}
if self.index_file.exists():
with open(self.index_file, 'r', encoding='utf-8') as f:
content = f.read().strip()
if content:
index = json.loads(content)
if index:
if ensure_integrity and not self._index_verified:
if self._index_missing_conversations(index):
rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt:
self._save_index(rebuilt)
index = rebuilt
self._index_verified = True
return index
# 索引为空但对话文件仍然存在时尝试重建
rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt:
self._save_index(rebuilt)
if ensure_integrity:
self._index_verified = True
return rebuilt
return {}
# 索引缺失但存在对话文件时重建
rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt:
self._save_index(rebuilt)
if ensure_integrity:
self._index_verified = True
return rebuilt
return index
except (json.JSONDecodeError, Exception) as e:
print(f"⚠️ 加载对话索引失败,将尝试重建: {e}")
backup_path = self.index_file.with_name(
f"{self.index_file.stem}_corrupt_{int(time.time() * 1000)}{self.index_file.suffix}"
)
try:
if self.index_file.exists():
self.index_file.replace(backup_path)
print(f"🗄️ 已备份损坏的索引文件到: {backup_path.name}")
except Exception as backup_exc:
print(f"⚠️ 备份损坏索引文件失败: {backup_exc}")
# 索引损坏场景优先全量重建,避免仅重建部分导致“对话丢失”错觉
rebuilt = self._rebuild_index_from_files(max_count=None)
if rebuilt:
self._save_index(rebuilt)
if ensure_integrity:
self._index_verified = True
return rebuilt
return {}
def _save_index(self, index: Dict):
"""保存对话索引"""
try:
with self._io_lock:
self._atomic_write_json(self.index_file, index)
except Exception as e:
print(f"⌘ 保存对话索引失败: {e}")
def _ensure_index_covering(self, limit: int, offset: int) -> Dict:
"""
确保索引涵盖到 offset+limit 条记录,不足时按需扩展重建(仍按 mtime 倒序,增量加载批量)。
"""
needed = max(0, int(offset) + int(limit))
index = self._load_index()
if len(index) >= needed:
return index
# 第一次尝试:扩展到需要的数量(按更新时间倒序)
rebuilt = self._rebuild_index_from_files(max_count=needed)
if rebuilt:
self._save_index(rebuilt)
index = rebuilt
# 如果仍不足且存在更多文件可能未被纳入(例如首批限定过小),进行一次全量重建兜底
if len(index) < needed:
rebuilt_full = self._rebuild_index_from_files(max_count=None)
if rebuilt_full:
self._save_index(rebuilt_full)
index = rebuilt_full
return index