agent-Specialization/utils/conversation_manager/index_mixin.py

313 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
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 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 表示全量重建。
"""
t0 = time.perf_counter()
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))]
if perf_log:
perf_log("_rebuild_index_from_files start", extra={"file_count": len(files), "max_count": 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"),
"multi_agent_mode": bool(metadata.get("multi_agent_mode", False)),
}
elapsed_ms = (time.perf_counter() - t0) * 1000
if perf_log:
perf_log("_rebuild_index_from_files done", elapsed_ms=elapsed_ms, extra={"rebuilt_size": len(rebuilt_index), "max_count": max_count})
if rebuilt_index:
print(f"🔄 已从对话文件重建索引,共 {len(rebuilt_index)} 条记录")
return rebuilt_index
def _index_missing_conversations(self, index: Dict) -> bool:
"""检查索引是否缺失本地对话文件"""
t0 = time.perf_counter()
index_ids = set(index.keys())
files = list(self._iter_conversation_files())
missing = False
for file_path in files:
conv_id = file_path.stem
if conv_id and conv_id not in index_ids:
print(f"🔍 对话 {conv_id} 未出现在索引中,将重建索引。")
missing = True
break
elapsed_ms = (time.perf_counter() - t0) * 1000
if perf_log:
perf_log("_index_missing_conversations", elapsed_ms=elapsed_ms, extra={"file_count": len(files), "index_size": len(index), "missing": missing})
return missing
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 倒序,增量加载批量)。
每次先检查是否有新增对话文件未被纳入索引,避免"创建后列表不显示"的问题。
"""
t0 = time.perf_counter()
needed = max(0, int(offset) + int(limit))
index = self._load_index()
load_ms = (time.perf_counter() - t0) * 1000
if perf_log:
perf_log("_ensure_index_covering load_index", elapsed_ms=load_ms, extra={"index_size": len(index), "needed": needed})
# 主动检查新增文件:索引可能滞后于实际对话文件,导致列表不同步。
t1 = time.perf_counter()
missing = self._index_missing_conversations(index)
missing_ms = (time.perf_counter() - t1) * 1000
if perf_log:
perf_log("_ensure_index_covering check_missing", elapsed_ms=missing_ms, extra={"missing": missing, "index_size": len(index)})
if missing:
t2 = time.perf_counter()
rebuilt = self._rebuild_index_from_files(max_count=max(needed, len(index) + 10))
rebuild_ms = (time.perf_counter() - t2) * 1000
if perf_log:
perf_log("_ensure_index_covering rebuild_missing", elapsed_ms=rebuild_ms, extra={"rebuilt_size": len(rebuilt) if rebuilt else 0})
if rebuilt:
self._save_index(rebuilt)
index = rebuilt
if len(index) >= needed:
total_ms = (time.perf_counter() - t0) * 1000
if perf_log:
perf_log("_ensure_index_covering return", elapsed_ms=total_ms, extra={"index_size": len(index), "needed": needed})
return index
# 第一次尝试:扩展到需要的数量(按更新时间倒序)
t3 = time.perf_counter()
rebuilt = self._rebuild_index_from_files(max_count=needed)
rebuild_ms = (time.perf_counter() - t3) * 1000
if perf_log:
perf_log("_ensure_index_covering rebuild_needed", elapsed_ms=rebuild_ms, extra={"rebuilt_size": len(rebuilt) if rebuilt else 0})
if rebuilt:
self._save_index(rebuilt)
index = rebuilt
# 如果仍不足且存在更多文件可能未被纳入(例如首批限定过小),进行一次全量重建兜底
if len(index) < needed:
t4 = time.perf_counter()
rebuilt_full = self._rebuild_index_from_files(max_count=None)
rebuild_full_ms = (time.perf_counter() - t4) * 1000
if perf_log:
perf_log("_ensure_index_covering rebuild_full", elapsed_ms=rebuild_full_ms, extra={"rebuilt_size": len(rebuilt_full) if rebuilt_full else 0})
if rebuilt_full:
self._save_index(rebuilt_full)
index = rebuilt_full
total_ms = (time.perf_counter() - t0) * 1000
if perf_log:
perf_log("_ensure_index_covering return", elapsed_ms=total_ms, extra={"index_size": len(index), "needed": needed})
return index