本次变更是对版本控制功能的大范围重构,核心目标:
- 默认使用浅备份(只追踪 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 模式新建对话、编辑文件、回溯、复制对话均验证通过。
312 lines
14 KiB
Python
312 lines
14 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 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"),
|
||
}
|
||
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
|