本次变更是对版本控制功能的大范围重构,核心目标:
- 默认使用浅备份(只追踪 write_file/edit_file 实际修改的文件),避免大工作区首次初始化耗时十几秒;
- 保留可选的完全备份(git write-tree),不污染 git log;
- 回溯时统一选择范围(仅对话 / 对话+工作区)和模式(覆盖 / 复制);
- 修复复制对话、侧边栏同步、新建对话延迟、diff 统计与渲染等连锁问题。
后端改动:
- modules/shallow_versioning.py(新增)
- 实现 ShallowVersioningManager:track_edit、make_snapshot、rewind、list_snapshots。
- 基于 difflib.SequenceMatcher 计算真实 insertions/deletions,replace 场景正确显示 +N/-M。
- diff 详情使用结构化 add/remove/context 行,不再解析 unified_diff 文本。
- modules/versioning_manager.py
- 完全备份从 git commit 改为 git write-tree,字段 commit/parent_commit 改为 tree_hash/parent_tree_hash。
- 恢复改用 read-tree + checkout-index;create_checkpoint 支持 diff_summary 参数。
- get_checkpoint_detail 保留 API 字段名 last_commit/restored_commit 以兼容前端。
- core/main_terminal_parts/tools_execution.py
- write_file/edit_file 在执行前调用浅备份 track_edit,保留编辑前状态。
- server/chat_flow_task_main.py
- 消息轮次结束时根据 backup_mode 选择完全备份 create_checkpoint 或浅备份 make_snapshot。
- 浅备份模式下同时写入 ConversationVersioningManager 检查点行,让前端能统一展示。
- server/conversation.py
- restore/detail/list API 支持 shallow 模式;list/detail 对浅备份实时重新计算统计。
- 创建对话时读取 personalization 的 versioning_backup_mode,shallow 模式固定 tracking_mode 为 conversation_only。
- core/web_terminal.py
- _ensure_conversation_versioning_enabled 读取默认备份模式并写入 versioning meta。
- modules/file_manager/crud_mixin.py / replace_mixin.py
- write_file / edit_file 返回 original_file / new_file / replacement_details(真实旧行/新行)。
- modules/personalization_manager.py
- 新增 versioning_enabled_by_default、versioning_backup_mode(shallow/full)。
- utils/perf_log.py(新增)
- 创建新对话全链路性能日志,用于定位延迟瓶颈。
- utils/conversation_manager/{index,list_search,crud}_mixin.py
- 加性能日志;index_mixin 主动检查新增对话文件,修复侧边栏不同步。
前端改动:
- static/src/components/overlay/VersioningDialog.vue
- 去掉顶部管理范围下拉;底部新增回溯范围与回溯模式选择。
- diff 详情改为 div.diff-line 网格布局(marker + content),支持横向滚动,状态显示缩写 A/M/D。
- 修复 pre 标签空白黑行、容器被挤窄导致折行等问题。
- static/src/app/methods/versioning.ts
- 移除 mismatch 弹窗逻辑;支持 copy 模式;selectVersioningCheckpoint 增强错误捕获。
- static/src/components/personalization/PersonalizationDrawer.vue
- 「工作区与权限」新增版本控制默认开关与备份方式选择。
- static/src/stores/personalization.ts
- 新增 versioning_enabled_by_default、versioning_backup_mode 字段。
- static/src/components/chat/actions/ToolAction.vue / toolRenderers.ts
- edit_file / write_file 工具展示基于 result.details / result.new_file 的真实替换结果。
- static/src/app/state.ts / watchers.ts / App.vue
- 新增 versioningInitializingBackupToastId,完全备份初始化时显示 toast。
- static/src/app/methods/conversation/action.ts / message/send.ts
- host + 完全备份 + 默认开启时,创建新对话/从 /new 发送首条消息显示「正在初始化备份」toast。
验证:
- /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9 -m unittest test.test_server_refactor_smoke 通过。
- npm run build 通过。
- 宿主机与 docker 模式新建对话、编辑文件、回溯、复制对话均验证通过。
583 lines
25 KiB
Python
583 lines
25 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
|
||
|
||
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 CrudMixin:
|
||
"""ConversationManager crud mixin 能力 mixin。"""
|
||
|
||
def create_conversation(
|
||
self,
|
||
project_path: str,
|
||
thinking_mode: bool = False,
|
||
run_mode: str = "fast",
|
||
initial_messages: List[Dict] = None,
|
||
model_key: Optional[str] = None,
|
||
has_images: bool = False,
|
||
has_videos: bool = False,
|
||
metadata_overrides: Optional[Dict[str, Any]] = None
|
||
) -> str:
|
||
"""
|
||
创建新对话
|
||
|
||
Args:
|
||
project_path: 项目路径
|
||
thinking_mode: 思考模式
|
||
initial_messages: 初始消息列表
|
||
|
||
Returns:
|
||
conversation_id: 对话ID
|
||
"""
|
||
t0 = time.perf_counter()
|
||
if perf_log:
|
||
perf_log("cm.create_conversation enter")
|
||
conversation_id = self._generate_conversation_id()
|
||
messages = initial_messages or []
|
||
|
||
# 创建对话数据
|
||
path_metadata = self._prepare_project_path_metadata(project_path)
|
||
normalized_mode = run_mode if run_mode in {"fast", "thinking", "deep"} else ("thinking" if thinking_mode else "fast")
|
||
metadata = {
|
||
"project_path": path_metadata["project_path"],
|
||
"project_relative_path": path_metadata["project_relative_path"],
|
||
"thinking_mode": thinking_mode,
|
||
"run_mode": normalized_mode,
|
||
"model_key": model_key,
|
||
"permission_mode": "unrestricted",
|
||
"execution_mode": "sandbox",
|
||
"pending_permission_mode": None,
|
||
"pending_execution_mode": None,
|
||
"frozen_permission_prompt": None,
|
||
"frozen_execution_prompt": None,
|
||
"has_images": has_images,
|
||
"has_videos": has_videos,
|
||
# 首次对话尚未生成文件树快照,待首次用户消息时填充
|
||
"project_file_tree": None,
|
||
"project_statistics": None,
|
||
"project_snapshot_at": None,
|
||
"total_messages": len(messages),
|
||
"total_tools": self._count_tools_in_messages(messages),
|
||
"status": "active",
|
||
# 压缩相关字段
|
||
"compression_count": 0,
|
||
"is_long_conversation": False,
|
||
"is_ultra_long_conversation": False,
|
||
"tool_call_count": 0,
|
||
"last_shallow_compress_tool_count": 0,
|
||
"compression_in_progress": False,
|
||
"compression_mode": None,
|
||
"compression_stage": None,
|
||
"compression_job_id": None,
|
||
"compression_error": None,
|
||
"compression_resume_payload": None,
|
||
"deep_compression_records": [],
|
||
"last_deep_compression_record": None,
|
||
"skip_auto_title_generation": False,
|
||
}
|
||
if isinstance(metadata_overrides, dict):
|
||
metadata.update(metadata_overrides)
|
||
|
||
conversation_data = {
|
||
"id": conversation_id,
|
||
"title": self._extract_title_from_messages(messages),
|
||
"created_at": datetime.now().isoformat(),
|
||
"updated_at": datetime.now().isoformat(),
|
||
"messages": messages,
|
||
"todo_list": None,
|
||
"metadata": metadata,
|
||
"token_statistics": self._initialize_token_statistics() # 新增
|
||
}
|
||
|
||
# 保存对话文件
|
||
t1 = time.perf_counter()
|
||
self._save_conversation_file(conversation_id, conversation_data)
|
||
if perf_log:
|
||
perf_log("cm.create_conversation save_file", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": conversation_id})
|
||
|
||
# 更新索引
|
||
self._update_index(conversation_id, conversation_data)
|
||
if perf_log:
|
||
perf_log("cm.create_conversation update_index", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": conversation_id})
|
||
|
||
self.current_conversation_id = conversation_id
|
||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||
if perf_log:
|
||
perf_log("cm.create_conversation done", elapsed_ms=elapsed_ms, extra={"conv_id": conversation_id})
|
||
print(f"📝 创建新对话: {conversation_id} - {conversation_data['title']}")
|
||
|
||
return conversation_id
|
||
|
||
def update_conversation_metadata(self, conversation_id: str, updates: Dict[str, Any]) -> bool:
|
||
"""合并更新对话 metadata。"""
|
||
if not conversation_id or not isinstance(updates, dict):
|
||
return False
|
||
try:
|
||
data = self.load_conversation(conversation_id)
|
||
if not data:
|
||
return False
|
||
metadata = data.get("metadata", {}) or {}
|
||
metadata.update(updates)
|
||
data["metadata"] = metadata
|
||
data["updated_at"] = datetime.now().isoformat()
|
||
self._save_conversation_file(conversation_id, data)
|
||
self._update_index(conversation_id, data)
|
||
return True
|
||
except Exception as exc:
|
||
print(f"⚠️ 更新对话 metadata 失败 {conversation_id}: {exc}")
|
||
return False
|
||
|
||
def update_conversation_title(self, conversation_id: str, title: str) -> bool:
|
||
"""更新对话标题并刷新索引。"""
|
||
if not conversation_id or not title:
|
||
return False
|
||
try:
|
||
data = self.load_conversation(conversation_id)
|
||
if not data:
|
||
return False
|
||
data["title"] = title
|
||
data["updated_at"] = datetime.now().isoformat()
|
||
meta = data.get("metadata", {}) or {}
|
||
meta["title_locked"] = True
|
||
data["metadata"] = meta
|
||
self._save_conversation_file(conversation_id, data)
|
||
self._update_index(conversation_id, data)
|
||
if self.current_conversation_id == conversation_id:
|
||
self.current_conversation_title = title
|
||
return True
|
||
except Exception as exc:
|
||
print(f"⚠️ 更新对话标题失败 {conversation_id}: {exc}")
|
||
return False
|
||
|
||
def _save_conversation_file(self, conversation_id: str, data: Dict):
|
||
"""保存对话文件"""
|
||
file_path = self._get_conversation_file_path(conversation_id)
|
||
try:
|
||
# 确保Token统计数据有效
|
||
data = self._validate_token_statistics(data)
|
||
with self._io_lock:
|
||
self._atomic_write_json(file_path, data)
|
||
except Exception as e:
|
||
print(f"⌘ 保存对话文件失败 {conversation_id}: {e}")
|
||
|
||
def _update_index(self, conversation_id: str, conversation_data: Dict):
|
||
"""更新对话索引"""
|
||
try:
|
||
index = self._load_index()
|
||
|
||
# 创建元数据
|
||
metadata = ConversationMetadata(
|
||
id=conversation_id,
|
||
title=conversation_data["title"],
|
||
created_at=conversation_data["created_at"],
|
||
updated_at=conversation_data["updated_at"],
|
||
project_path=conversation_data["metadata"]["project_path"],
|
||
project_relative_path=conversation_data["metadata"].get("project_relative_path"),
|
||
thinking_mode=conversation_data["metadata"]["thinking_mode"],
|
||
run_mode=conversation_data["metadata"].get("run_mode", "thinking" if conversation_data["metadata"]["thinking_mode"] else "fast"),
|
||
total_messages=conversation_data["metadata"]["total_messages"],
|
||
total_tools=conversation_data["metadata"]["total_tools"],
|
||
status=conversation_data["metadata"].get("status", "active")
|
||
)
|
||
|
||
# 添加到索引
|
||
index[conversation_id] = {
|
||
"title": metadata.title,
|
||
"created_at": metadata.created_at,
|
||
"updated_at": metadata.updated_at,
|
||
"project_path": metadata.project_path,
|
||
"project_relative_path": metadata.project_relative_path,
|
||
"thinking_mode": metadata.thinking_mode,
|
||
"run_mode": metadata.run_mode,
|
||
"model_key": conversation_data["metadata"].get("model_key"),
|
||
"has_images": conversation_data["metadata"].get("has_images", False),
|
||
"has_videos": conversation_data["metadata"].get("has_videos", False),
|
||
"total_messages": metadata.total_messages,
|
||
"total_tools": metadata.total_tools,
|
||
"status": metadata.status
|
||
}
|
||
|
||
self._save_index(index)
|
||
except Exception as e:
|
||
print(f"⌘ 更新对话索引失败: {e}")
|
||
|
||
def save_conversation(
|
||
self,
|
||
conversation_id: str,
|
||
messages: List[Dict],
|
||
project_path: str = None,
|
||
thinking_mode: bool = None,
|
||
run_mode: Optional[str] = None,
|
||
todo_list: Optional[Dict] = None,
|
||
model_key: Optional[str] = None,
|
||
has_images: Optional[bool] = None,
|
||
has_videos: Optional[bool] = None,
|
||
project_file_tree: Optional[str] = None,
|
||
project_statistics: Optional[Dict] = None,
|
||
project_snapshot_at: Optional[str] = None
|
||
) -> bool:
|
||
"""
|
||
保存对话(更新现有对话)
|
||
|
||
Args:
|
||
conversation_id: 对话ID
|
||
messages: 消息列表
|
||
project_path: 项目路径
|
||
thinking_mode: 思考模式
|
||
|
||
Returns:
|
||
bool: 保存是否成功
|
||
"""
|
||
try:
|
||
# 加载现有对话数据
|
||
existing_data = self.load_conversation(conversation_id)
|
||
if not existing_data:
|
||
print(f"⚠️ 对话 {conversation_id} 不存在,无法更新")
|
||
return False
|
||
|
||
# 更新数据
|
||
existing_data["messages"] = messages
|
||
existing_data["updated_at"] = datetime.now().isoformat()
|
||
|
||
# 更新标题(如未锁定自动标题时,仍按首条消息回退)
|
||
title_locked = existing_data.get("metadata", {}).get("title_locked", False)
|
||
if not title_locked:
|
||
new_title = self._extract_title_from_messages(messages)
|
||
if new_title != "新对话":
|
||
existing_data["title"] = new_title
|
||
|
||
# 更新元数据
|
||
if project_path is not None:
|
||
path_metadata = self._prepare_project_path_metadata(project_path)
|
||
existing_data["metadata"]["project_path"] = path_metadata["project_path"]
|
||
existing_data["metadata"]["project_relative_path"] = path_metadata["project_relative_path"]
|
||
else:
|
||
existing_data["metadata"].setdefault("project_relative_path", None)
|
||
if thinking_mode is not None:
|
||
existing_data["metadata"]["thinking_mode"] = thinking_mode
|
||
if run_mode:
|
||
normalized_mode = run_mode if run_mode in {"fast", "thinking", "deep"} else (
|
||
"thinking" if existing_data["metadata"].get("thinking_mode") else "fast"
|
||
)
|
||
existing_data["metadata"]["run_mode"] = normalized_mode
|
||
elif "run_mode" not in existing_data["metadata"]:
|
||
existing_data["metadata"]["run_mode"] = "thinking" if existing_data["metadata"].get("thinking_mode") else "fast"
|
||
# 推断最新使用的模型(优先参数,其次倒序扫描助手消息)
|
||
inferred_model = None
|
||
if model_key is None:
|
||
for msg in reversed(messages):
|
||
if msg.get("role") != "assistant":
|
||
continue
|
||
msg_meta = msg.get("metadata") or {}
|
||
mk = msg_meta.get("model_key")
|
||
if mk:
|
||
inferred_model = mk
|
||
break
|
||
target_model = model_key if model_key is not None else inferred_model
|
||
if target_model is not None:
|
||
existing_data["metadata"]["model_key"] = target_model
|
||
elif "model_key" not in existing_data["metadata"]:
|
||
existing_data["metadata"]["model_key"] = None
|
||
if has_images is not None:
|
||
existing_data["metadata"]["has_images"] = bool(has_images)
|
||
elif "has_images" not in existing_data["metadata"]:
|
||
existing_data["metadata"]["has_images"] = False
|
||
if has_videos is not None:
|
||
existing_data["metadata"]["has_videos"] = bool(has_videos)
|
||
elif "has_videos" not in existing_data["metadata"]:
|
||
existing_data["metadata"]["has_videos"] = False
|
||
# 文件树快照(如果有新值则更新,若已有则保持)
|
||
if project_file_tree is not None:
|
||
existing_data["metadata"]["project_file_tree"] = project_file_tree
|
||
elif "project_file_tree" not in existing_data["metadata"]:
|
||
existing_data["metadata"]["project_file_tree"] = None
|
||
if project_statistics is not None:
|
||
existing_data["metadata"]["project_statistics"] = project_statistics
|
||
elif "project_statistics" not in existing_data["metadata"]:
|
||
existing_data["metadata"]["project_statistics"] = None
|
||
if project_snapshot_at is not None:
|
||
existing_data["metadata"]["project_snapshot_at"] = project_snapshot_at
|
||
elif "project_snapshot_at" not in existing_data["metadata"]:
|
||
existing_data["metadata"]["project_snapshot_at"] = None
|
||
|
||
existing_data["metadata"]["total_messages"] = len(messages)
|
||
existing_data["metadata"]["total_tools"] = self._count_tools_in_messages(messages)
|
||
|
||
# 更新待办列表
|
||
existing_data["todo_list"] = todo_list
|
||
|
||
# 确保Token统计结构存在(向后兼容)
|
||
if "token_statistics" not in existing_data:
|
||
existing_data["token_statistics"] = self._initialize_token_statistics()
|
||
else:
|
||
existing_data["token_statistics"]["updated_at"] = datetime.now().isoformat()
|
||
|
||
# 保存文件
|
||
self._save_conversation_file(conversation_id, existing_data)
|
||
|
||
# 更新索引
|
||
self._update_index(conversation_id, existing_data)
|
||
|
||
return True
|
||
except Exception as e:
|
||
print(f"⌘ 保存对话失败 {conversation_id}: {e}")
|
||
return False
|
||
|
||
def mark_latest_user_work_completed(
|
||
self,
|
||
conversation_id: str,
|
||
finished_at: Optional[str] = None,
|
||
context_manager: Optional[Any] = None,
|
||
) -> bool:
|
||
"""将对话中最后一条 status 为 working 的用户消息的 work_timer 标记为完成。
|
||
|
||
用于任务停止或正常结束后,刷新页面时不再恢复为"工作中"状态。
|
||
|
||
Args:
|
||
conversation_id: 要处理的对话 ID。
|
||
finished_at: 完成时间 ISO 字符串,默认当前时间。
|
||
context_manager: 可选的当前 ContextManager 实例;若提供,会同步更新
|
||
其内存中的 conversation_history,避免后续 auto_save 把已持久化的
|
||
完成状态覆盖回去。
|
||
"""
|
||
if not conversation_id:
|
||
return False
|
||
try:
|
||
data = self.load_conversation(conversation_id)
|
||
if not data:
|
||
return False
|
||
messages = data.get("messages") or []
|
||
completed = False
|
||
now_iso = finished_at or datetime.now().isoformat()
|
||
for msg in reversed(messages):
|
||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||
continue
|
||
metadata = msg.get("metadata") or {}
|
||
timer = metadata.get("work_timer")
|
||
if not isinstance(timer, dict):
|
||
continue
|
||
if timer.get("status") != "working":
|
||
continue
|
||
started_at = timer.get("started_at") or msg.get("timestamp") or now_iso
|
||
try:
|
||
start_dt = datetime.fromisoformat(str(started_at).replace("Z", "+00:00"))
|
||
end_dt = datetime.fromisoformat(now_iso.replace("Z", "+00:00"))
|
||
duration_ms = max(0, int((end_dt - start_dt).total_seconds() * 1000))
|
||
except Exception:
|
||
duration_ms = timer.get("duration_ms", 0) or 0
|
||
timer["status"] = "completed"
|
||
timer["started_at"] = started_at
|
||
timer["finished_at"] = now_iso
|
||
timer["duration_ms"] = duration_ms
|
||
msg["metadata"] = metadata
|
||
completed = True
|
||
break
|
||
if not completed:
|
||
return False
|
||
data["updated_at"] = datetime.now().isoformat()
|
||
self._save_conversation_file(conversation_id, data)
|
||
self._update_index(conversation_id, data)
|
||
|
||
# 同步内存副本(如果调用方持有当前对话的内存引用)
|
||
if (
|
||
context_manager
|
||
and getattr(context_manager, "current_conversation_id", None) == conversation_id
|
||
):
|
||
try:
|
||
self._sync_work_timer_in_memory(context_manager, now_iso)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
except Exception as exc:
|
||
print(f"⌘ 标记用户工作完成失败 {conversation_id}: {exc}")
|
||
return False
|
||
|
||
def _sync_work_timer_in_memory(self, context_manager: Any, finished_at: str) -> bool:
|
||
"""将当前 ContextManager 内存中最后一条 working 的 work_timer 标记为完成。"""
|
||
history = getattr(context_manager, "conversation_history", None) or []
|
||
if not history:
|
||
return False
|
||
|
||
for msg in reversed(history):
|
||
if not isinstance(msg, dict) or msg.get("role") != "user":
|
||
continue
|
||
metadata = msg.get("metadata") or {}
|
||
timer = metadata.get("work_timer")
|
||
if not isinstance(timer, dict):
|
||
continue
|
||
if timer.get("status") != "working":
|
||
continue
|
||
|
||
started_at = timer.get("started_at") or msg.get("timestamp") or finished_at
|
||
try:
|
||
start_dt = datetime.fromisoformat(str(started_at).replace("Z", "+00:00"))
|
||
end_dt = datetime.fromisoformat(finished_at.replace("Z", "+00:00"))
|
||
duration_ms = max(0, int((end_dt - start_dt).total_seconds() * 1000))
|
||
except Exception:
|
||
duration_ms = timer.get("duration_ms", 0) or 0
|
||
|
||
timer.update({
|
||
"status": "completed",
|
||
"started_at": started_at,
|
||
"finished_at": finished_at,
|
||
"duration_ms": duration_ms,
|
||
})
|
||
msg["metadata"] = metadata
|
||
return True
|
||
|
||
return False
|
||
|
||
def update_project_snapshot(
|
||
self,
|
||
conversation_id: str,
|
||
project_file_tree: str,
|
||
project_statistics: Optional[Dict],
|
||
project_snapshot_at: Optional[str] = None
|
||
) -> bool:
|
||
"""
|
||
单独更新对话的项目文件树快照,不修改消息内容。
|
||
便于在首次用户消息时写入固定文件结构。
|
||
"""
|
||
try:
|
||
data = self.load_conversation(conversation_id)
|
||
if not data:
|
||
return False
|
||
meta = data.get("metadata", {}) or {}
|
||
meta["project_file_tree"] = project_file_tree
|
||
meta["project_statistics"] = project_statistics
|
||
meta["project_snapshot_at"] = project_snapshot_at
|
||
data["metadata"] = meta
|
||
self._save_conversation_file(conversation_id, data)
|
||
self._update_index(conversation_id, data)
|
||
return True
|
||
except Exception as exc:
|
||
print(f"⌘ 更新项目快照失败 {conversation_id}: {exc}")
|
||
return False
|
||
|
||
def load_conversation(self, conversation_id: str) -> Optional[Dict]:
|
||
"""
|
||
加载对话数据
|
||
|
||
Args:
|
||
conversation_id: 对话ID
|
||
|
||
Returns:
|
||
Dict: 对话数据,如果不存在返回None
|
||
"""
|
||
try:
|
||
file_path = self._get_conversation_file_path(conversation_id)
|
||
if not file_path.exists():
|
||
return None
|
||
|
||
with open(file_path, 'r', encoding='utf-8') as f:
|
||
content = f.read().strip()
|
||
if not content:
|
||
return None
|
||
|
||
data = json.loads(content)
|
||
|
||
metadata = data.get("metadata", {})
|
||
if "project_relative_path" not in metadata:
|
||
metadata["project_relative_path"] = None
|
||
self._save_conversation_file(conversation_id, data)
|
||
print(f"🔧 为对话 {conversation_id} 添加相对路径字段")
|
||
|
||
# 向后兼容:确保Token统计结构存在
|
||
if "token_statistics" not in data:
|
||
data["token_statistics"] = self._initialize_token_statistics()
|
||
# 自动保存修复后的数据
|
||
self._save_conversation_file(conversation_id, data)
|
||
print(f"🔧 为对话 {conversation_id} 添加Token统计结构")
|
||
else:
|
||
# 验证现有Token统计数据
|
||
data = self._validate_token_statistics(data)
|
||
|
||
if "run_mode" not in metadata:
|
||
metadata["run_mode"] = "thinking" if metadata.get("thinking_mode") else "fast"
|
||
self._save_conversation_file(conversation_id, data)
|
||
print(f"🔧 为对话 {conversation_id} 添加运行模式字段")
|
||
|
||
# 确保项目快照字段存在(向后兼容)
|
||
changed = False
|
||
if "project_file_tree" not in metadata:
|
||
metadata["project_file_tree"] = None
|
||
changed = True
|
||
if "project_statistics" not in metadata:
|
||
metadata["project_statistics"] = None
|
||
changed = True
|
||
if "project_snapshot_at" not in metadata:
|
||
metadata["project_snapshot_at"] = None
|
||
changed = True
|
||
if changed:
|
||
data["metadata"] = metadata
|
||
self._save_conversation_file(conversation_id, data)
|
||
print(f"🔧 为对话 {conversation_id} 补齐项目快照字段")
|
||
|
||
# 兼容历史口径:旧版本 total_tools 可能把 role=tool 也计入,导致翻倍。
|
||
expected_total_tools = self._count_tools_in_messages(data.get("messages") or [])
|
||
if int(metadata.get("total_tools", 0) or 0) != expected_total_tools:
|
||
metadata["total_tools"] = expected_total_tools
|
||
data["metadata"] = metadata
|
||
self._save_conversation_file(conversation_id, data)
|
||
self._update_index(conversation_id, data)
|
||
print(f"🔧 修正对话 {conversation_id} 的 total_tools 统计为 {expected_total_tools}")
|
||
|
||
# 回填缺失的模型字段:从最近的助手消息元数据推断
|
||
if metadata.get("model_key") is None:
|
||
inferred_model = None
|
||
for msg in reversed(data.get("messages") or []):
|
||
if msg.get("role") != "assistant":
|
||
continue
|
||
mk = (msg.get("metadata") or {}).get("model_key")
|
||
if mk:
|
||
inferred_model = mk
|
||
break
|
||
if inferred_model is not None:
|
||
metadata["model_key"] = inferred_model
|
||
self._save_conversation_file(conversation_id, data)
|
||
print(f"🔧 为对话 {conversation_id} 回填模型字段: {inferred_model}")
|
||
|
||
return data
|
||
except (json.JSONDecodeError, Exception) as e:
|
||
print(f"⌘ 加载对话失败 {conversation_id}: {e}")
|
||
return None
|