feat(versioning): 重构版本控制为对话级浅备份 + 可选完全备份

本次变更是对版本控制功能的大范围重构,核心目标:
- 默认使用浅备份(只追踪 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 模式新建对话、编辑文件、回溯、复制对话均验证通过。
This commit is contained in:
JOJO 2026-07-07 12:07:44 +08:00
parent 7863e497d8
commit 478d24c4dd
25 changed files with 1795 additions and 649 deletions

View File

@ -6,6 +6,8 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
from modules.shallow_versioning import ShallowVersioningManager
try:
from config import (
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
@ -636,6 +638,36 @@ class MainTerminalToolsExecutionMixin:
sorted(visited),
)
def _track_shallow_versioning(self, file_path: Any) -> None:
"""Track a file edit for shallow versioning (conversation-scoped undo)."""
conversation_id = getattr(self.context_manager, "current_conversation_id", None)
from utils.perf_log import perf_log
perf_log("_track_shallow_versioning called", extra={
"conversation_id": conversation_id,
"project_path": str(self.project_path),
"data_dir": str(self.data_dir),
"file_path": str(file_path),
})
if not conversation_id or not self.project_path or not self.data_dir:
perf_log("_track_shallow_versioning skipped", extra={"reason": "missing_required"})
return
try:
manager = ShallowVersioningManager(
project_path=self.project_path,
data_dir=self.data_dir,
conversation_id=conversation_id,
)
manager.track_edit(file_path, conversation_id)
perf_log("_track_shallow_versioning done", extra={
"conversation_id": conversation_id,
"file_path": str(file_path),
"tracked_files_count": len(getattr(manager, "_tracked_files", set())),
})
except Exception as exc:
perf_log("_track_shallow_versioning error", extra={"error": str(exc)})
# Shallow versioning is best-effort; never block the tool result.
pass
def _target_file_exists(self, path: Any) -> bool:
try:
valid, _, full_path = self.file_manager._validate_path(str(path or ""))
@ -1247,6 +1279,8 @@ class MainTerminalToolsExecutionMixin:
if not path:
result = {"success": False, "error": "缺少必要参数: file_path"}
else:
# 在写入前先备份当前内容(浅备份)。
self._track_shallow_versioning(path)
mode = "a" if append_flag else "w"
result = self.file_manager.write_file(path, content, mode=mode)
if isinstance(result, dict) and result.get("success"):
@ -1265,7 +1299,11 @@ class MainTerminalToolsExecutionMixin:
elif not isinstance(replacements, list) or not replacements:
result = {"success": False, "error": "缺少必要参数: replacements必须是非空数组"}
else:
# 在替换前先备份当前内容(浅备份)。
self._track_shallow_versioning(path)
result = self.file_manager.replace_many_in_file(path, replacements)
if isinstance(result, dict) and result.get("success"):
self._mark_file_as_read_visited(result.get("path") or path)
elif tool_name == "create_folder":
result = self.file_manager.create_folder(arguments["path"])

View File

@ -1,11 +1,15 @@
# core/web_terminal.py - Web终端集成对话持久化
import json
import time
from datetime import datetime
from typing import Dict, List, Optional, Callable, TYPE_CHECKING
import os
from core.main_terminal import MainTerminal
from utils.logger import setup_logger
from modules.personalization_manager import load_personalization_config
from modules.versioning_manager import ConversationVersioningManager, VersioningError
from utils.perf_log import perf_log, PerfTimer
try:
from config import MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE
except ImportError:
@ -112,6 +116,8 @@ class WebTerminal(MainTerminal):
Returns:
Dict: 包含新对话信息
"""
perf_log("create_new_conversation enter")
t0 = time.perf_counter()
prefer_defaults = thinking_mode is None and run_mode is None
thinking_mode_explicit = thinking_mode is not None
@ -204,24 +210,95 @@ class WebTerminal(MainTerminal):
except Exception as exc:
logger.warning("保存新对话默认模型失败: %s", exc)
perf_log("create_new_conversation before default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": conversation_id})
# 根据个性化设置默认开启版本控制
try:
default_versioning_enabled = bool((prefs or {}).get("versioning_enabled_by_default", True))
if default_versioning_enabled:
self._ensure_conversation_versioning_enabled(conversation_id)
except Exception as exc:
logger.warning("新对话应用默认版本控制失败: %s", exc)
perf_log("create_new_conversation after default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": conversation_id})
# 重置相关状态
if self.thinking_mode:
self.api_client.start_new_task()
self.current_session_id += 1
perf_log("create_new_conversation done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": conversation_id})
return {
"success": True,
"conversation_id": conversation_id,
"message": f"已创建新对话: {conversation_id}"
}
except Exception as e:
perf_log("create_new_conversation error", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"error": str(e)})
return {
"success": False,
"error": str(e),
"message": f"创建新对话失败: {e}"
}
def _ensure_conversation_versioning_enabled(self, conversation_id: str) -> None:
"""为指定对话启用版本控制(初始快照)。"""
normalized_id = conversation_id if conversation_id.startswith("conv_") else f"conv_{conversation_id}"
t0 = time.perf_counter()
perf_log("_ensure_conversation_versioning_enabled enter", extra={"conv_id": normalized_id})
is_host = bool(getattr(self, "_is_host_mode", lambda: False)())
from modules.personalization_manager import load_personalization_config
personal_config = load_personalization_config(self.data_dir)
backup_mode = str(personal_config.get("versioning_backup_mode") or "shallow").strip().lower()
backup_mode = "full" if backup_mode == "full" else "shallow"
# 浅备份模式下不启用完整 workspace git 备份,只保留对话记录回溯
if backup_mode == "shallow":
tracking_mode = ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY
elif is_host:
tracking_mode = ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
else:
tracking_mode = ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY
manager = ConversationVersioningManager(
project_path=self.project_path,
data_dir=self.data_dir,
conversation_id=normalized_id,
)
perf_log("_ensure_conversation_versioning_enabled manager created", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": normalized_id})
meta = manager.set_enabled(enabled=True, mode="overwrite", tracking_mode=tracking_mode)
perf_log("_ensure_conversation_versioning_enabled set_enabled done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": normalized_id})
conv_data = self.context_manager.conversation_manager.load_conversation(normalized_id) or {}
snapshot_payload = {
"conversation_id": normalized_id,
"title": conv_data.get("title"),
"metadata": conv_data.get("metadata") or {},
"messages": conv_data.get("messages") or [],
"message_index": -1,
"run_status": "initial",
}
init_result = manager.ensure_initial_checkpoint(
workspace_path=str(self.project_path),
conversation_snapshot=snapshot_payload,
tracking_mode=tracking_mode,
)
perf_log("_ensure_conversation_versioning_enabled initial checkpoint done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": normalized_id})
init_row = init_result.get("row") or {}
if init_row.get("tree_hash"):
meta["last_tree_hash"] = init_row.get("tree_hash")
self.context_manager.conversation_manager.update_conversation_metadata(
normalized_id,
{
"versioning": {
"enabled": True,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"backup_mode": backup_mode,
"last_commit": meta.get("last_tree_hash"),
"last_input_seq": int(meta.get("last_input_seq") or 0),
"updated_at": datetime.now().isoformat(),
}
},
)
perf_log("_ensure_conversation_versioning_enabled done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": normalized_id})
def load_conversation(self, conversation_id: str) -> Dict:
"""
加载指定对话Web版本

View File

@ -293,6 +293,14 @@ class CrudMixin:
"project_size_bytes": current_size,
"attempt_size_bytes": len(content)
}
# 记录写入前的原始内容(用于版本控制和前端 diff 显示)
original_file: Optional[str] = None
try:
if full_path.exists():
original_file = full_path.read_text(encoding='utf-8')
except Exception:
original_file = None
if self._use_container():
result = self._container_call("write_file", {
"path": relative_path,
@ -302,6 +310,7 @@ class CrudMixin:
if result.get("success"):
action = "覆盖" if mode == "w" else "追加"
print(f"{OUTPUT_FORMATS['file']} {action}文件: {relative_path}")
result["original_file"] = original_file
return result
# 创建父目录
@ -313,11 +322,19 @@ class CrudMixin:
action = "覆盖" if mode == "w" else "追加"
print(f"{OUTPUT_FORMATS['file']} {action}文件: {relative_path}")
# 读取写入后的实际内容(追加模式需要从文件读取)
try:
new_file = full_path.read_text(encoding='utf-8')
except Exception:
new_file = content
return {
"success": True,
"path": relative_path,
"size": len(content),
"mode": mode
"mode": mode,
"original_file": original_file,
"new_file": new_file,
}
except Exception as e:
return {"success": False, "error": str(e)}

View File

@ -255,6 +255,12 @@ class ReplaceMixin:
"replacements": replacement_count,
"matched_lines": matched_lines,
"contexts": contexts,
"old_string": old_text,
"new_string": new_text,
"old_lines": old_text.splitlines(),
"new_lines": new_text.splitlines(),
"old_line_count": len(old_text.splitlines()),
"new_line_count": len(new_text.splitlines()),
})
details.append(detail)
total_found_matches += found_count

View File

@ -97,6 +97,8 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"show_status_avatar": True, # 是否显示助手状态形象
"stacked_hide_borders": False, # 堆叠块隐藏边线
"show_git_status_bar": True, # 是否显示输入栏上方 Git 状态栏
"versioning_enabled_by_default": True, # 新对话是否默认开启版本控制
"versioning_backup_mode": "shallow", # 文件备份方式shallow-浅备份只备份AI编辑的文件/ full-完全备份(整个工作区)
"versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关
"allow_root_file_creation": False, # 允许在根目录创建文件开关
@ -446,6 +448,19 @@ def sanitize_personalization_payload(
else:
base["show_git_status_bar"] = bool(base.get("show_git_status_bar", True))
# 新对话默认开启版本控制
if "versioning_enabled_by_default" in data:
base["versioning_enabled_by_default"] = bool(data.get("versioning_enabled_by_default"))
else:
base["versioning_enabled_by_default"] = bool(base.get("versioning_enabled_by_default", True))
# 文件备份方式
if "versioning_backup_mode" in data:
mode = str(data.get("versioning_backup_mode") or "shallow").strip().lower()
base["versioning_backup_mode"] = "full" if mode == "full" else "shallow"
else:
base["versioning_backup_mode"] = "shallow" if base.get("versioning_backup_mode") not in ("shallow", "full") else base["versioning_backup_mode"]
# 使用自定义称呼
if "use_custom_names" in data:
base["use_custom_names"] = bool(data.get("use_custom_names"))

View File

@ -0,0 +1,497 @@
"""Conversation-scoped shallow file versioning for workspace edits.
Mirrors Claude Code's file-history approach:
- Only files touched by write_file / edit_file are tracked.
- Before each edit the current content is backed up.
- A snapshot is taken per user message, referencing the latest backup version
of every tracked file at that point in time.
- Rewind restores the tracked files to the state recorded for a target message.
"""
from __future__ import annotations
import hashlib
import json
import shutil
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
class ShallowVersioningError(RuntimeError):
"""Raised when shallow versioning fails."""
@dataclass
class FileBackup:
backup_file_name: Optional[str] # null means the file did not exist at that version
version: int
backup_time: str
@dataclass
class ShallowSnapshot:
message_id: str
tracked_file_backups: Dict[str, FileBackup]
timestamp: str
class ShallowVersioningManager:
"""Lightweight per-conversation backup for files edited by AI tools."""
MAX_SNAPSHOTS = 100
def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str):
self.project_path = Path(project_path).expanduser().resolve()
self.conversation_id = str(conversation_id or "").strip()
if not self.conversation_id:
raise ShallowVersioningError("缺少 conversation_id")
self.save_root = (Path(data_dir).expanduser().resolve() / "save" / self.conversation_id).resolve()
self.backup_dir = self.save_root / "shallow_backups"
self.state_file = self.backup_dir / "state.jsonl"
self._tracked_files: Set[str] = set()
self._snapshots: List[ShallowSnapshot] = []
self._snapshot_sequence = 0
self._load_state()
# ----------------------------
# persistence
# ----------------------------
def _load_state(self) -> None:
if not self.state_file.exists():
return
try:
for raw in self.state_file.read_text(encoding="utf-8", errors="ignore").splitlines():
line = raw.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
if not isinstance(row, dict):
continue
snapshot = self._deserialize_snapshot(row)
if snapshot:
self._snapshots.append(snapshot)
self._snapshot_sequence += 1
for path in snapshot.tracked_file_backups:
self._tracked_files.add(path)
except OSError:
pass
def _persist_snapshot(self, snapshot: ShallowSnapshot) -> None:
self.backup_dir.mkdir(parents=True, exist_ok=True)
with self.state_file.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(self._serialize_snapshot(snapshot), ensure_ascii=False) + "\n")
def _serialize_snapshot(self, snapshot: ShallowSnapshot) -> Dict[str, Any]:
return {
"message_id": snapshot.message_id,
"timestamp": snapshot.timestamp,
"tracked_file_backups": {
path: {
"backup_file_name": backup.backup_file_name,
"version": backup.version,
"backup_time": backup.backup_time,
}
for path, backup in snapshot.tracked_file_backups.items()
},
}
def _deserialize_snapshot(self, row: Dict[str, Any]) -> Optional[ShallowSnapshot]:
if not isinstance(row.get("message_id"), str):
return None
backups: Dict[str, FileBackup] = {}
for path, backup_raw in (row.get("tracked_file_backups") or {}).items():
if not isinstance(backup_raw, dict):
continue
backups[str(path)] = FileBackup(
backup_file_name=backup_raw.get("backup_file_name"),
version=int(backup_raw.get("version") or 1),
backup_time=str(backup_raw.get("backup_time") or datetime.now().isoformat()),
)
return ShallowSnapshot(
message_id=row["message_id"],
tracked_file_backups=backups,
timestamp=str(row.get("timestamp") or datetime.now().isoformat()),
)
# ----------------------------
# path helpers
# ----------------------------
def _normalize_path(self, file_path: Path | str) -> str:
path = Path(file_path).expanduser()
if not path.is_absolute():
path = self.project_path / path
try:
rel = path.resolve().relative_to(self.project_path)
return str(rel).replace("\\", "/")
except ValueError:
return str(path).replace("\\", "/")
def _resolve_file_path(self, tracking_path: str) -> Path:
if Path(tracking_path).is_absolute():
return Path(tracking_path).expanduser().resolve()
return (self.project_path / tracking_path).resolve()
def _backup_file_name(self, tracking_path: str, version: int) -> str:
file_name_hash = hashlib.sha256(tracking_path.encode("utf-8")).hexdigest()[:16]
return f"{file_name_hash}@v{version}"
def _backup_path(self, backup_file_name: str) -> Path:
return (self.backup_dir / backup_file_name).resolve()
# ----------------------------
# backup operations
# ----------------------------
def _create_backup(self, file_path: Path, version: int) -> FileBackup:
backup_file_name = self._backup_file_name(str(file_path), version)
backup_path = self._backup_path(backup_file_name)
backup_path.parent.mkdir(parents=True, exist_ok=True)
if not file_path.exists():
return FileBackup(backup_file_name=None, version=version, backup_time=datetime.now().isoformat())
shutil.copy2(file_path, backup_path)
return FileBackup(
backup_file_name=backup_file_name,
version=version,
backup_time=datetime.now().isoformat(),
)
def _get_latest_backup_version(self, tracking_path: str) -> Optional[FileBackup]:
latest: Optional[FileBackup] = None
for snapshot in self._snapshots:
backup = snapshot.tracked_file_backups.get(tracking_path)
if backup and (latest is None or backup.version > latest.version):
latest = backup
return latest
def _file_changed_since_backup(self, file_path: Path, backup: FileBackup) -> bool:
if backup.backup_file_name is None:
return file_path.exists()
backup_path = self._backup_path(backup.backup_file_name)
if not file_path.exists():
return True
if not backup_path.exists():
return True
try:
if file_path.stat().st_size != backup_path.stat().st_size:
return True
current = file_path.read_text(encoding="utf-8", errors="ignore")
saved = backup_path.read_text(encoding="utf-8", errors="ignore")
return current != saved
except Exception:
return True
# ----------------------------
# public API
# ----------------------------
def track_edit(self, file_path: Path | str, message_id: str) -> None:
"""Track a file before it is edited; backup its current content if not already tracked."""
tracking_path = self._normalize_path(file_path)
from utils.perf_log import perf_log
perf_log("[ShallowVersioning] track_edit called", extra={
"tracking_path": tracking_path,
"message_id": message_id,
"already_tracked": tracking_path in self._tracked_files,
"snapshots_count": len(self._snapshots),
})
if tracking_path in self._tracked_files:
return
resolved = self._resolve_file_path(tracking_path)
backup = self._create_backup(resolved, 1)
self._tracked_files.add(tracking_path)
# Add to the most recent snapshot if one exists, otherwise create a pre-edit snapshot.
now = datetime.now().isoformat()
if self._snapshots:
latest = self._snapshots[-1]
latest.tracked_file_backups[tracking_path] = backup
self._persist_snapshot(latest)
else:
snapshot = ShallowSnapshot(
message_id=message_id,
tracked_file_backups={tracking_path: backup},
timestamp=now,
)
self._snapshots.append(snapshot)
self._snapshot_sequence += 1
self._persist_snapshot(snapshot)
def make_snapshot(self, message_id: str) -> Dict[str, Any]:
"""Create a snapshot for the given message, backing up changed tracked files."""
from utils.perf_log import perf_log
perf_log("[ShallowVersioning] make_snapshot called", extra={
"message_id": message_id,
"tracked_files_count": len(self._tracked_files),
"snapshots_count": len(self._snapshots),
})
tracked_file_backups: Dict[str, FileBackup] = {}
now = datetime.now().isoformat()
for tracking_path in self._tracked_files:
resolved = self._resolve_file_path(tracking_path)
latest_backup = self._get_latest_backup_version(tracking_path)
next_version = (latest_backup.version + 1) if latest_backup else 1
if latest_backup and not self._file_changed_since_backup(resolved, latest_backup):
tracked_file_backups[tracking_path] = latest_backup
continue
tracked_file_backups[tracking_path] = self._create_backup(resolved, next_version)
# Merge in backups from the latest snapshot for any files that may have been added
# by track_edit during the async window.
if self._snapshots:
for tracking_path, backup in self._snapshots[-1].tracked_file_backups.items():
if tracking_path not in tracked_file_backups:
tracked_file_backups[tracking_path] = backup
snapshot = ShallowSnapshot(
message_id=message_id,
tracked_file_backups=tracked_file_backups,
timestamp=now,
)
self._snapshots.append(snapshot)
self._snapshot_sequence += 1
self._persist_snapshot(snapshot)
# Evict old snapshots while keeping the most recent MAX_SNAPSHOTS.
if len(self._snapshots) > self.MAX_SNAPSHOTS:
self._snapshots = self._snapshots[-self.MAX_SNAPSHOTS :]
perf_log("[ShallowVersioning] make_snapshot done", extra={
"message_id": message_id,
"tracked_files_count": len(tracked_file_backups),
"backups": {k: v.backup_file_name for k, v in tracked_file_backups.items()},
})
return {
"message_id": message_id,
"tracked_files_count": len(tracked_file_backups),
"timestamp": now,
}
def can_restore(self, message_id: str) -> bool:
return any(snapshot.message_id == message_id for snapshot in self._snapshots)
def rewind(self, message_id: str) -> Dict[str, Any]:
"""Restore tracked files to the state recorded for the target message."""
target_snapshot: Optional[ShallowSnapshot] = None
for snapshot in reversed(self._snapshots):
if snapshot.message_id == message_id:
target_snapshot = snapshot
break
if not target_snapshot:
raise ShallowVersioningError(f"未找到消息 {message_id} 对应的快照")
files_changed: List[str] = []
for tracking_path in self._tracked_files:
target_backup = target_snapshot.tracked_file_backups.get(tracking_path)
if not target_backup:
# File was not tracked at the target snapshot; leave it unchanged.
continue
resolved = self._resolve_file_path(tracking_path)
if target_backup.backup_file_name is None:
if resolved.exists():
resolved.unlink()
files_changed.append(tracking_path)
continue
backup_path = self._backup_path(target_backup.backup_file_name)
if not backup_path.exists():
continue
if self._file_changed_since_backup(resolved, target_backup):
resolved.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(backup_path, resolved)
files_changed.append(tracking_path)
return {
"success": True,
"message_id": message_id,
"files_changed": files_changed,
}
def _read_backup_lines(self, backup: Optional[FileBackup]) -> List[str]:
"""Read lines from a backup entry; empty list for missing/deleted files."""
if not backup or backup.backup_file_name is None:
return []
backup_path = self._backup_path(backup.backup_file_name)
if not backup_path.exists():
return []
try:
return backup_path.read_text(encoding="utf-8", errors="ignore").splitlines()
except Exception:
return []
def _count_line_changes(self, previous_lines: List[str], target_lines: List[str]) -> tuple[int, int]:
"""Count real insertions/deletions between two line lists using SequenceMatcher."""
if previous_lines == target_lines:
return 0, 0
try:
import difflib
sm = difflib.SequenceMatcher(None, previous_lines, target_lines)
except Exception:
# Fallback to naive delta if difflib fails.
if len(previous_lines) > len(target_lines):
return 0, len(previous_lines) - len(target_lines)
return len(target_lines) - len(previous_lines), 0
insertions = 0
deletions = 0
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "insert":
insertions += j2 - j1
elif tag == "delete":
deletions += i2 - i1
elif tag == "replace":
insertions += j2 - j1
deletions += i2 - i1
return insertions, deletions
def _find_snapshot_pair(self, message_id: str):
"""Return (target_snapshot, previous_snapshot) for the given message_id."""
target_snapshot: Optional[ShallowSnapshot] = None
target_index = -1
for idx, snapshot in enumerate(self._snapshots):
if snapshot.message_id == message_id:
target_snapshot = snapshot
target_index = idx
if not target_snapshot or target_index < 0:
return None, None
previous_snapshot = self._snapshots[target_index - 1] if target_index > 0 else None
return target_snapshot, previous_snapshot
def get_diff_stats(self, message_id: str) -> Optional[Dict[str, Any]]:
"""Compute insertions/deletions/files_changed between the target snapshot and the previous snapshot."""
target_snapshot, previous_snapshot = self._find_snapshot_pair(message_id)
if not target_snapshot:
return None
files_changed: List[str] = []
insertions = 0
deletions = 0
for tracking_path in self._tracked_files:
target_backup = target_snapshot.tracked_file_backups.get(tracking_path)
if not target_backup:
continue
previous_backup = previous_snapshot.tracked_file_backups.get(tracking_path) if previous_snapshot else None
previous_lines = self._read_backup_lines(previous_backup)
target_lines = self._read_backup_lines(target_backup)
if previous_lines == target_lines:
continue
files_changed.append(tracking_path)
ins, dels = self._count_line_changes(previous_lines, target_lines)
insertions += ins
deletions += dels
return {
"filesChanged": files_changed,
"insertions": insertions,
"deletions": deletions,
}
def has_any_changes(self, message_id: str) -> bool:
stats = self.get_diff_stats(message_id)
if not stats:
return False
return bool(stats.get("filesChanged"))
def get_file_diff_stats(self, message_id: str) -> List[Dict[str, Any]]:
"""Return per-file insertions/deletions/status for a target snapshot vs previous snapshot."""
target_snapshot, previous_snapshot = self._find_snapshot_pair(message_id)
if not target_snapshot:
return []
result: List[Dict[str, Any]] = []
for tracking_path in self._tracked_files:
target_backup = target_snapshot.tracked_file_backups.get(tracking_path)
if not target_backup:
continue
previous_backup = previous_snapshot.tracked_file_backups.get(tracking_path) if previous_snapshot else None
previous_lines = self._read_backup_lines(previous_backup)
target_lines = self._read_backup_lines(target_backup)
if previous_lines == target_lines:
continue
insertions, deletions = self._count_line_changes(previous_lines, target_lines)
resolved = self._resolve_file_path(tracking_path)
status = "deleted" if target_backup.backup_file_name is None else ("added" if previous_backup is None or previous_backup.backup_file_name is None else "modified")
result.append({
"path": tracking_path,
"status": status,
"insertions": insertions,
"deletions": deletions,
})
return result
def get_file_patch_lines(
self,
tracking_path: str,
message_id: str,
max_lines: int = 600,
) -> Dict[str, Any]:
"""Return contextual patch lines between target snapshot and previous snapshot."""
target_snapshot, previous_snapshot = self._find_snapshot_pair(message_id)
if not target_snapshot:
return {"lines": [], "truncated": False}
target_backup = target_snapshot.tracked_file_backups.get(tracking_path)
if not target_backup:
return {"lines": [], "truncated": False}
previous_backup = previous_snapshot.tracked_file_backups.get(tracking_path) if previous_snapshot else None
previous_lines = self._read_backup_lines(previous_backup)
target_lines = self._read_backup_lines(target_backup)
if previous_lines == target_lines:
return {"lines": [], "truncated": False}
try:
import difflib
sm = difflib.SequenceMatcher(None, previous_lines, target_lines)
except Exception:
return {"lines": [], "truncated": False}
lines: List[Dict[str, Any]] = []
truncated = False
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
continue
if tag == "replace" or tag == "delete":
for line in previous_lines[i1:i2]:
if len(lines) >= max_lines:
truncated = True
break
lines.append({"type": "remove", "content": line})
if tag == "replace" or tag == "insert":
for line in target_lines[j1:j2]:
if len(lines) >= max_lines:
truncated = True
break
lines.append({"type": "add", "content": line})
if truncated:
break
return {"lines": lines, "truncated": truncated}
def get_snapshot_by_seq(self, seq: int) -> Optional[ShallowSnapshot]:
"""Return the user-message snapshot by 1-based index (skips the initial track_edit snapshot)."""
user_snapshots = [s for s in self._snapshots if s.message_id != self.conversation_id]
idx = int(seq) - 1
if idx < 0 or idx >= len(user_snapshots):
return None
return user_snapshots[idx]
def list_snapshots(self) -> List[Dict[str, Any]]:
return [self._serialize_snapshot(s) for s in self._snapshots]

View File

@ -5,12 +5,15 @@ from __future__ import annotations
import json
import shutil
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from collections import deque
from utils.perf_log import perf_log
class VersioningError(RuntimeError):
"""Raised when hidden git versioning fails."""
@ -30,6 +33,9 @@ class ConversationVersioningManager:
TRACKING_MODE_WORKSPACE_AND_CONVERSATION = "workspace_and_conversation"
TRACKING_MODE_CONVERSATION_ONLY = "conversation_only"
SYSTEM_AUTO_DIRS = (".agents/compact_result", ".agents/skills", ".agents/user_upload")
# 空 Git tree 对象的固定哈希,用于 tree-to-tree diff 的基准点
EMPTY_TREE_HASH = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str):
self.project_path = Path(project_path).expanduser().resolve()
@ -137,14 +143,23 @@ class ConversationVersioningManager:
self._run_git(["rm", "-r", "--cached", "--ignore-unmatch", "--", clean], check=False)
def _stage_all(self) -> None:
t0 = time.perf_counter()
self._ensure_exclude_paths()
self._drop_ignored_from_index()
perf_log("versioning _stage_all before add", elapsed_ms=(time.perf_counter() - t0) * 1000)
# Stage everything under work tree; exclusions are handled via info/exclude.
t1 = time.perf_counter()
self._run_git(["add", "-A", "--", "."])
perf_log("versioning _stage_all add", elapsed_ms=(time.perf_counter() - t1) * 1000)
perf_log("versioning _stage_all done", elapsed_ms=(time.perf_counter() - t0) * 1000)
def _get_head_commit(self) -> Optional[str]:
ok, out, _ = self._run_git(["rev-parse", "HEAD"], check=False)
return out if ok and out else None
def _get_head_tree(self) -> Optional[str]:
"""获取当前 index 对应的 tree hashindex 为空时返回空树对象哈希。"""
ok, out, _ = self._run_git(["write-tree"], check=False)
if ok and out:
return out
# 空 tree 的 hash 是固定的 Git 常量
return "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
def _is_clean(self) -> bool:
ok, out, _ = self._run_git(["status", "--porcelain"], check=False)
@ -161,7 +176,7 @@ class ConversationVersioningManager:
"enabled": False,
"mode": "overwrite",
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"last_commit": None,
"last_tree_hash": None,
"last_input_seq": 0,
"updated_at": None,
}
@ -174,7 +189,7 @@ class ConversationVersioningManager:
"enabled": False,
"mode": "overwrite",
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"last_commit": None,
"last_tree_hash": None,
"last_input_seq": 0,
"updated_at": None,
}
@ -275,6 +290,7 @@ class ConversationVersioningManager:
# repo lifecycle
# ----------------------------
def ensure_repo(self) -> Dict[str, Any]:
t0 = time.perf_counter()
self.paths.save_root.mkdir(parents=True, exist_ok=True)
self.project_path.mkdir(parents=True, exist_ok=True)
if not self.paths.git_dir.exists():
@ -283,12 +299,15 @@ class ConversationVersioningManager:
self._run_git(["init"])
self._run_git(["config", "user.name", "EasyAgent Versioning"], check=False)
self._run_git(["config", "user.email", "easyagent-versioning@local"], check=False)
perf_log("versioning ensure_repo init", elapsed_ms=(time.perf_counter() - t0) * 1000)
t1 = time.perf_counter()
self._ensure_exclude_paths()
head = self._get_head_commit()
if not head:
self._stage_all()
self._run_git(["commit", "--allow-empty", "-m", "init workspace snapshot"])
head = self._get_head_commit()
perf_log("versioning ensure_repo exclude", elapsed_ms=(time.perf_counter() - t1) * 1000)
# 使用 write-tree 获取当前 index 的树对象哈希,不创建 commit不污染 git 历史。
t2 = time.perf_counter()
head = self._get_head_tree()
perf_log("versioning ensure_repo write_tree", elapsed_ms=(time.perf_counter() - t2) * 1000)
perf_log("versioning ensure_repo done", elapsed_ms=(time.perf_counter() - t0) * 1000)
return {"ok": True, "head": head}
def set_enabled(
@ -307,7 +326,7 @@ class ConversationVersioningManager:
meta["enabled"] = bool(enabled)
meta["mode"] = "overwrite"
meta["tracking_mode"] = normalized_tracking_mode
meta["last_commit"] = self._get_head_commit()
meta["last_tree_hash"] = self._get_head_tree()
saved = self.save_meta(meta)
return saved
@ -328,7 +347,7 @@ class ConversationVersioningManager:
if existing:
return {"created": False, "row": existing, "reason": "already_exists"}
current_head = self._get_head_commit()
current_head = self._get_head_tree()
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not current_head:
raise VersioningError("创建初始版本点失败:未获取到 commit")
@ -339,8 +358,8 @@ class ConversationVersioningManager:
"message_index": -1,
"timestamp": datetime.now().isoformat(),
"workspace_path": str(workspace_path or ""),
"commit": current_head,
"parent_commit": None,
"tree_hash": current_head,
"parent_tree_hash": None,
"insertions": 0,
"deletions": 0,
"files_changed": 0,
@ -354,7 +373,7 @@ class ConversationVersioningManager:
self._append_input(row)
meta = self.load_meta()
meta["tracking_mode"] = normalized_tracking_mode
meta["last_commit"] = current_head
meta["last_tree_hash"] = current_head
if int(meta.get("last_input_seq") or 0) < 0:
meta["last_input_seq"] = 0
self.save_meta(meta)
@ -376,7 +395,7 @@ class ConversationVersioningManager:
"created": False,
"skipped": True,
"reason": "conversation_only_mode",
"head": self._get_head_commit(),
"head": self._get_head_tree(),
}
self.ensure_repo()
meta = self.load_meta()
@ -385,31 +404,37 @@ class ConversationVersioningManager:
"created": False,
"skipped": True,
"reason": "initial_checkpoint_exists",
"head": self._get_head_commit(),
"head": self._get_head_tree(),
}
if int(meta.get("last_input_seq") or 0) > 0:
return {
"created": False,
"skipped": True,
"reason": "already_has_input_checkpoint",
"head": self._get_head_commit(),
"head": self._get_head_tree(),
}
previous_head = self._get_head_commit()
previous_head = self._get_head_tree()
t_stage = time.perf_counter()
self._stage_all()
_, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False)
has_changes = bool((staged_numstat or "").strip())
if has_changes:
self._run_git(["commit", "-m", "baseline before input#1"])
current_head = self._get_head_commit()
meta["last_commit"] = current_head
perf_log("versioning baseline stage_all", elapsed_ms=(time.perf_counter() - t_stage) * 1000)
t_diff = time.perf_counter()
current_head = self._get_head_tree()
perf_log("versioning baseline write_tree", elapsed_ms=(time.perf_counter() - t_diff) * 1000)
# 通过 tree-to-tree diff 判断是否有变更
_, numstat_text, _ = self._run_git(
["diff", "--numstat", previous_head or self.EMPTY_TREE_HASH, current_head],
check=False,
)
has_changes = bool((numstat_text or "").strip())
meta["last_tree_hash"] = current_head
self.save_meta(meta)
return {
"created": bool(has_changes),
"skipped": False,
"reason": "baseline_committed" if has_changes else "baseline_unchanged",
"head": current_head,
"parent_commit": previous_head,
"parent_tree_hash": previous_head,
"has_changes": has_changes,
}
@ -429,16 +454,10 @@ class ConversationVersioningManager:
stats[path] = {"insertions": ins, "deletions": dele}
return stats
def _build_diff_summary(self, parent_commit: Optional[str], commit: str) -> Dict[str, Any]:
if parent_commit:
_, numstat_text, _ = self._run_git(["diff", "--numstat", parent_commit, commit], check=False)
_, status_text, _ = self._run_git(["diff", "--name-status", parent_commit, commit], check=False)
else:
_, numstat_text, _ = self._run_git(["show", "--numstat", "--format=", commit], check=False)
_, status_text, _ = self._run_git(
["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", commit],
check=False,
)
def _build_diff_summary(self, parent_tree_hash: Optional[str], tree_hash: str) -> Dict[str, Any]:
base = parent_tree_hash or self.EMPTY_TREE_HASH
_, numstat_text, _ = self._run_git(["diff", "--numstat", base, tree_hash], check=False)
_, status_text, _ = self._run_git(["diff", "--name-status", base, tree_hash], check=False)
numstats = self._parse_numstat(numstat_text)
files: List[Dict[str, Any]] = []
@ -482,35 +501,38 @@ class ConversationVersioningManager:
conversation_snapshot: Optional[Dict[str, Any]] = None,
run_status: Optional[str] = None,
tracking_mode: Optional[str] = None,
diff_summary: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
meta = self.load_meta()
normalized_tracking_mode = self.normalize_tracking_mode(
tracking_mode or meta.get("tracking_mode")
)
previous_head: Optional[str] = self._get_head_commit()
previous_head: Optional[str] = self._get_head_tree()
current_head: Optional[str] = previous_head
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
self.ensure_repo()
previous_head = self._get_head_commit()
previous_head = self._get_head_tree()
self._stage_all()
_, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False)
has_changes = bool((staged_numstat or "").strip())
if has_changes:
next_seq = int(meta.get("last_input_seq") or 0) + 1
title = (message or "").strip().splitlines()[0][:72] if message else ""
commit_msg = f"input#{next_seq} {title}".strip()
self._run_git(["commit", "-m", commit_msg])
current_head = self._get_head_commit()
current_head = self._get_head_tree()
if not current_head:
raise VersioningError("创建版本快照失败:未获取到 commit")
raise VersioningError("创建版本快照失败:未获取到 tree hash")
# 通过 tree-to-tree diff 判断是否有变更
_, numstat_text, _ = self._run_git(
["diff", "--numstat", previous_head or self.EMPTY_TREE_HASH, current_head],
check=False,
)
has_changes = bool((numstat_text or "").strip())
diff_summary = (
self._build_diff_summary(previous_head, current_head)
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
if diff_summary is not None:
diff_summary = dict(diff_summary)
elif (
normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
and current_head != previous_head
else {"insertions": 0, "deletions": 0, "files_changed": 0, "files": []}
)
):
diff_summary = self._build_diff_summary(previous_head, current_head)
else:
diff_summary = {"insertions": 0, "deletions": 0, "files_changed": 0, "files": []}
seq = int(meta.get("last_input_seq") or 0) + 1
row = {
@ -520,8 +542,8 @@ class ConversationVersioningManager:
"message_index": int(message_index),
"timestamp": datetime.now().isoformat(),
"workspace_path": str(workspace_path or ""),
"commit": current_head,
"parent_commit": previous_head,
"tree_hash": current_head,
"parent_tree_hash": previous_head,
"insertions": int(diff_summary.get("insertions") or 0),
"deletions": int(diff_summary.get("deletions") or 0),
"files_changed": int(diff_summary.get("files_changed") or 0),
@ -534,11 +556,14 @@ class ConversationVersioningManager:
}
if isinstance(conversation_snapshot, dict):
row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot)
shallow_message_id = conversation_snapshot.get("shallow_message_id")
if shallow_message_id:
row["shallow_message_id"] = str(shallow_message_id)
if run_status:
row["run_status"] = str(run_status)
self._append_input(row)
meta["last_input_seq"] = seq
meta["last_commit"] = current_head
meta["last_tree_hash"] = current_head
meta["tracking_mode"] = normalized_tracking_mode
self.save_meta(meta)
return row
@ -557,8 +582,8 @@ class ConversationVersioningManager:
def _collect_file_patch_lines(
self,
*,
parent_commit: Optional[str],
commit: str,
parent_tree_hash: Optional[str],
tree_hash: str,
path: str,
old_path: Optional[str] = None,
max_lines: int = 600,
@ -571,24 +596,19 @@ class ConversationVersioningManager:
if not target_paths:
return {"lines": [], "truncated": False}
if parent_commit:
_, patch_text, _ = self._run_git(
["diff", "--no-color", "--unified=200", parent_commit, commit, "--", *target_paths],
check=False,
)
else:
_, patch_text, _ = self._run_git(
["show", "--no-color", "--unified=200", "--format=", commit, "--", *target_paths],
check=False,
)
base = parent_tree_hash or self.EMPTY_TREE_HASH
_, patch_text, _ = self._run_git(
["diff", "--no-color", "--unified=200", base, tree_hash, "--", *target_paths],
check=False,
)
parsed = self._extract_contextual_patch_lines(patch_text or "", max_lines=max_lines)
lines = parsed.get("lines") or []
truncated = bool(parsed.get("truncated"))
# 回退策略:若按路径过滤拿不到行,但该文件确实有增删,尝试从整次 commit patch 中按文件段提取。
if not lines:
fallback = self._collect_file_patch_lines_from_full_diff(
parent_commit=parent_commit,
commit=commit,
parent_tree_hash=parent_tree_hash,
tree_hash=tree_hash,
target_paths=target_paths,
max_lines=max_lines,
)
@ -599,21 +619,16 @@ class ConversationVersioningManager:
def _collect_file_patch_lines_from_full_diff(
self,
*,
parent_commit: Optional[str],
commit: str,
parent_tree_hash: Optional[str],
tree_hash: str,
target_paths: List[str],
max_lines: int = 600,
) -> Dict[str, Any]:
if parent_commit:
_, patch_text, _ = self._run_git(
["diff", "--no-color", "--unified=200", parent_commit, commit],
check=False,
)
else:
_, patch_text, _ = self._run_git(
["show", "--no-color", "--unified=200", "--format=", commit],
check=False,
)
base = parent_tree_hash or self.EMPTY_TREE_HASH
_, patch_text, _ = self._run_git(
["diff", "--no-color", "--unified=200", base, tree_hash],
check=False,
)
targets = {str(p or "").lstrip("./") for p in target_paths if str(p or "").strip()}
chunk_lines: List[str] = []
@ -660,17 +675,17 @@ class ConversationVersioningManager:
files = payload.get("files")
if not include_patch or not isinstance(files, list):
return payload
parent_commit = payload.get("parent_commit")
commit = payload.get("commit")
parent_tree_hash = payload.get("parent_tree_hash")
tree_hash = payload.get("tree_hash")
normalized_files: List[Dict[str, Any]] = []
for file_item in files:
if not isinstance(file_item, dict):
continue
item = dict(file_item)
if commit and str(item.get("path") or "").strip():
if tree_hash and str(item.get("path") or "").strip():
patch = self._collect_file_patch_lines(
parent_commit=parent_commit,
commit=commit,
parent_tree_hash=parent_tree_hash,
tree_hash=tree_hash,
path=str(item.get("path") or ""),
old_path=item.get("old_path"),
)
@ -715,36 +730,38 @@ class ConversationVersioningManager:
latest_kept = r
meta = self.load_meta()
meta["last_input_seq"] = max_seq
meta["last_commit"] = (latest_kept or {}).get("commit") or self._get_head_commit()
meta["last_tree_hash"] = (latest_kept or {}).get("tree_hash") or self._get_head_tree()
self.save_meta(meta)
return {
"kept": len(kept_rows),
"removed": len(removed_rows),
"max_seq": max_seq,
"last_commit": meta.get("last_commit"),
"last_tree_hash": meta.get("last_tree_hash"),
}
# ----------------------------
# restore / status
# ----------------------------
def restore_to_commit(self, commit: str) -> Dict[str, Any]:
if not commit:
raise VersioningError("缺少 commit")
def restore_to_tree(self, tree_hash: str) -> Dict[str, Any]:
if not tree_hash:
raise VersioningError("缺少 tree hash")
self.ensure_repo()
ok, _, _ = self._run_git(["cat-file", "-e", f"{commit}^{{commit}}"], check=False)
ok, _, _ = self._run_git(["cat-file", "-e", tree_hash], check=False)
if not ok:
raise VersioningError("目标 commit 不存在")
self._run_git(["reset", "--hard", commit])
raise VersioningError("目标 tree 不存在")
# 使用 read-tree + checkout-index 恢复到指定 tree不依赖 commit 历史
self._run_git(["read-tree", tree_hash])
self._run_git(["checkout-index", "-a", "-f"])
self._run_git(["clean", "-fd"])
meta = self.load_meta()
meta["last_restored_commit"] = commit
meta["last_commit"] = self._get_head_commit()
meta["last_restored_tree_hash"] = tree_hash
meta["last_tree_hash"] = tree_hash
self.save_meta(meta)
return {"success": True, "commit": commit}
return {"success": True, "tree_hash": tree_hash}
def detect_mismatch(
self,
latest_commit: Optional[str],
latest_tree_hash: Optional[str],
expected_workspace_path: Optional[str] = None,
*,
tracking_mode: Optional[str] = None,
@ -753,7 +770,7 @@ class ConversationVersioningManager:
if normalized_tracking_mode == self.TRACKING_MODE_CONVERSATION_ONLY:
return {
"has_repo": (self.paths.git_dir / "HEAD").exists(),
"head": self._get_head_commit(),
"head": self._get_head_tree(),
"dirty": False,
"mismatch": False,
"workspace_matched": True,
@ -772,16 +789,16 @@ class ConversationVersioningManager:
if not workspace_matched:
return {
"has_repo": True,
"head": self._get_head_commit(),
"head": self._get_head_tree(),
"dirty": False,
"mismatch": False,
"workspace_matched": False,
"expected_workspace_path": expected,
"current_workspace_path": current,
}
head = self._get_head_commit()
head = self._get_head_tree()
dirty = not self._is_clean()
mismatch = bool(dirty or (latest_commit and head and latest_commit != head))
mismatch = bool(dirty or (latest_tree_hash and head and latest_tree_hash != head))
return {
"has_repo": True,
"head": head,

View File

@ -46,6 +46,7 @@ from modules.user_manager import UserWorkspace
from modules.usage_tracker import QUOTA_DEFAULTS
from modules.sub_agent import TERMINAL_STATUSES
from modules.versioning_manager import ConversationVersioningManager, VersioningError
from modules.shallow_versioning import ShallowVersioningManager
from core.web_terminal import WebTerminal
from utils.tool_result_formatter import format_tool_result_for_context
from utils.conversation_manager import ConversationManager
@ -242,26 +243,29 @@ def _prepare_hidden_versioning_baseline_for_first_input(
versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
if not bool(versioning_meta.get("enabled", False)):
return
tracking_mode = ConversationVersioningManager.normalize_tracking_mode(
versioning_meta.get("tracking_mode")
)
if (
tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
and not bool(getattr(web_terminal, "username", None) == "host")
):
return
manager = ConversationVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=conversation_id,
)
baseline = manager.ensure_baseline_for_first_input(tracking_mode=tracking_mode)
debug_log(
f"[Versioning][Baseline] conv={conversation_id} "
f"tracking_mode={tracking_mode} "
f"created={baseline.get('created')} skipped={baseline.get('skipped')} "
f"reason={baseline.get('reason')} head={baseline.get('head')}"
)
backup_mode = str(versioning_meta.get("backup_mode") or "shallow").strip().lower()
if backup_mode == "full":
tracking_mode = ConversationVersioningManager.normalize_tracking_mode(
versioning_meta.get("tracking_mode")
)
if (
tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
and not bool(getattr(web_terminal, "username", None) == "host")
):
return
manager = ConversationVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=conversation_id,
)
baseline = manager.ensure_baseline_for_first_input(tracking_mode=tracking_mode)
debug_log(
f"[Versioning][Baseline] conv={conversation_id} "
f"tracking_mode={tracking_mode} "
f"created={baseline.get('created')} skipped={baseline.get('skipped')} "
f"reason={baseline.get('reason')} head={baseline.get('head')}"
)
# shallow 模式下不需要完整 workspace baseline由工具调用前的 track_edit 处理
except VersioningError as exc:
debug_log(f"[Versioning] 创建首轮基线失败: {exc}")
except Exception as exc:
@ -301,47 +305,128 @@ def _record_hidden_versioning_checkpoint_after_run(
):
return
snapshot_messages = conv_data.get("messages") or []
snapshot_payload = {
"conversation_id": conversation_id,
"title": conv_data.get("title"),
"metadata": conv_data.get("metadata") or {},
"messages": snapshot_messages,
"message_index": int(message_index),
"run_status": str(run_status or "completed"),
}
# 找到当前用户消息的消息ID用于浅备份快照
message_id = None
try:
target_msg = snapshot_messages[int(message_index)] if 0 <= int(message_index) < len(snapshot_messages) else None
if target_msg and target_msg.get("role") == "user":
message_id = target_msg.get("message_id") or target_msg.get("id")
except Exception:
message_id = None
manager = ConversationVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=conversation_id,
)
row = manager.create_checkpoint(
message=message,
message_index=message_index,
workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload,
run_status=str(run_status or "completed"),
tracking_mode=tracking_mode,
)
debug_log(
f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} "
f"tracking_mode={tracking_mode} "
f"msg_index={message_index} snapshot_messages={len(snapshot_messages)} "
f"commit={row.get('commit')} changed={row.get('changed')} status={row.get('run_status')}"
)
cm.update_conversation_metadata(
conversation_id,
{
"versioning": {
"enabled": True,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"last_commit": row.get("commit"),
"last_input_seq": int(row.get("seq") or 0),
"updated_at": datetime.now().isoformat(),
}
},
)
backup_mode = str(versioning_meta.get("backup_mode") or "shallow").strip().lower()
if backup_mode == "full":
snapshot_payload = {
"conversation_id": conversation_id,
"title": conv_data.get("title"),
"metadata": conv_data.get("metadata") or {},
"messages": snapshot_messages,
"message_index": int(message_index),
"run_status": str(run_status or "completed"),
}
manager = ConversationVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=conversation_id,
)
row = manager.create_checkpoint(
message=message,
message_index=message_index,
workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload,
run_status=str(run_status or "completed"),
tracking_mode=tracking_mode,
)
debug_log(
f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} "
f"tracking_mode={tracking_mode} "
f"msg_index={message_index} snapshot_messages={len(snapshot_messages)} "
f"tree_hash={row.get('tree_hash')} changed={row.get('changed')} status={row.get('run_status')}"
)
cm.update_conversation_metadata(
conversation_id,
{
"versioning": {
"enabled": True,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"backup_mode": "full",
"last_commit": row.get("tree_hash"),
"last_input_seq": int(row.get("seq") or 0),
"updated_at": datetime.now().isoformat(),
}
},
)
else:
# 浅备份:每个用户消息结束时对 tracked files 做快照
shallow_files: List[Dict[str, Any]] = []
shallow_insertions = 0
shallow_deletions = 0
if message_id:
shallow_manager = ShallowVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=conversation_id,
)
snapshot = shallow_manager.make_snapshot(str(message_id))
shallow_files = shallow_manager.get_file_diff_stats(str(message_id))
stats = shallow_manager.get_diff_stats(str(message_id)) or {}
shallow_insertions = int(stats.get("insertions") or 0)
shallow_deletions = int(stats.get("deletions") or 0)
debug_log(
f"[Versioning][ShallowSnapshot] conv={conversation_id} "
f"msg_id={message_id} tracked_files={snapshot.get('tracked_files_count')}"
)
# 同时写入一个轻量 ConversationVersioningManager 检查点行,
# 让前端版本管理弹窗能统一列出回溯点。
manager = ConversationVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=conversation_id,
)
snapshot_payload = {
"conversation_id": conversation_id,
"title": conv_data.get("title"),
"metadata": conv_data.get("metadata") or {},
"messages": snapshot_messages,
"message_index": int(message_index),
"run_status": str(run_status or "completed"),
"shallow_message_id": str(message_id) if message_id else None,
}
row = manager.create_checkpoint(
message=message,
message_index=int(message_index),
workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload,
run_status=str(run_status or "completed"),
tracking_mode=tracking_mode,
diff_summary={
"insertions": shallow_insertions,
"deletions": shallow_deletions,
"files_changed": len(shallow_files),
"files": shallow_files,
},
)
debug_log(
f"[Versioning][ShallowCheckpoint] conv={conversation_id} seq={row.get('seq')} "
f"msg_index={message_index} files={len(shallow_files)} "
f"insertions={shallow_insertions} deletions={shallow_deletions}"
)
cm.update_conversation_metadata(
conversation_id,
{
"versioning": {
"enabled": True,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"backup_mode": "shallow",
"last_commit": row.get("tree_hash"),
"last_input_seq": int(row.get("seq") or 0),
"updated_at": datetime.now().isoformat(),
}
},
)
except VersioningError as exc:
debug_log(f"[Versioning] 记录快照失败: {exc}")
except Exception as exc:

View File

@ -4,7 +4,7 @@ PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
import asyncio, json, time, re, os
import asyncio, json, time, re, os, shutil
from datetime import datetime, timedelta
from pathlib import Path
from collections import defaultdict, Counter, deque
@ -48,6 +48,8 @@ from modules.user_manager import UserWorkspace
from modules.usage_tracker import QUOTA_DEFAULTS
from modules.sub_agent import TERMINAL_STATUSES
from modules.versioning_manager import ConversationVersioningManager, VersioningError
from modules.shallow_versioning import ShallowVersioningManager
from utils.perf_log import perf_log, PerfTimer
from core.web_terminal import WebTerminal
from utils.tool_result_formatter import format_tool_result_for_context
from utils.conversation_manager import ConversationManager
@ -287,6 +289,59 @@ def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: st
}
def _ensure_conversation_versioning_enabled(
terminal: WebTerminal,
workspace: UserWorkspace,
conversation_id: str,
tracking_mode: Optional[str] = None,
) -> None:
"""为指定对话启用版本控制并创建初始 checkpoint用于默认开启场景"""
normalized_id = _normalize_conv_id(conversation_id)
host_mode = _is_host_mode_request(get_current_username())
try:
prefs = load_personalization_config(workspace.data_dir)
except Exception:
prefs = {}
backup_mode = str(prefs.get("versioning_backup_mode") or "shallow").strip().lower()
backup_mode = "full" if backup_mode == "full" else "shallow"
if backup_mode == "shallow":
default_mode = ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY
elif host_mode:
default_mode = ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
else:
default_mode = ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY
manager = _get_conv_versioning_manager(workspace, normalized_id)
normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode or default_mode)
meta = manager.set_enabled(enabled=True, mode="overwrite", tracking_mode=normalized_tracking_mode)
conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
snapshot_payload = {
"conversation_id": normalized_id,
"title": conv_data.get("title"),
"metadata": conv_data.get("metadata") or {},
"messages": conv_data.get("messages") or [],
"message_index": -1,
"run_status": "initial",
}
init_result = manager.ensure_initial_checkpoint(
workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload,
tracking_mode=normalized_tracking_mode,
)
init_row = init_result.get("row") or {}
if init_row.get("tree_hash"):
meta["last_tree_hash"] = init_row.get("tree_hash")
_update_conversation_versioning_meta(
terminal,
normalized_id,
enabled=True,
mode="overwrite",
tracking_mode=normalized_tracking_mode,
backup_mode=backup_mode,
last_commit=meta.get("last_commit"),
last_input_seq=int(meta.get("last_input_seq") or 0),
)
def _update_conversation_versioning_meta(
terminal: WebTerminal,
conversation_id: str,
@ -294,6 +349,7 @@ def _update_conversation_versioning_meta(
enabled: bool,
mode: str,
tracking_mode: Optional[str] = None,
backup_mode: Optional[str] = None,
last_commit: Optional[str] = None,
last_input_seq: Optional[int] = None,
) -> bool:
@ -307,6 +363,8 @@ def _update_conversation_versioning_meta(
"updated_at": datetime.now().isoformat(),
}
}
if backup_mode is not None:
payload["versioning"]["backup_mode"] = "full" if backup_mode == "full" else "shallow"
if last_commit is not None:
payload["versioning"]["last_commit"] = last_commit
if last_input_seq is not None:
@ -454,6 +512,8 @@ def get_conversations(terminal: WebTerminal, workspace: UserWorkspace, username:
@with_terminal
def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, username: str):
"""创建新对话"""
perf_log("create_conversation route enter", extra={"username": username})
t0 = time.perf_counter()
try:
data = request.get_json() or {}
# 前端现在期望"新建对话"回到用户配置的默认模型/模式,
@ -512,6 +572,15 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
if result["success"]:
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
perf_log("create_conversation before default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")})
# 根据个性化设置,为新对话默认开启版本控制(安全导航路径也需要)。
try:
prefs = load_personalization_config(workspace.data_dir)
if bool(prefs.get("versioning_enabled_by_default", True)):
_ensure_conversation_versioning_enabled(terminal, workspace, result["conversation_id"])
except Exception as exc:
debug_log(f"[Versioning] create_conversation apply default failed: {exc}")
perf_log("create_conversation after default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")})
# 广播对话列表更新事件
socketio.emit('conversation_list_update', {
'action': 'created',
@ -525,8 +594,10 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
'title': "新对话"
}, room=f"user_{username}")
perf_log("create_conversation route done", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")})
return jsonify(result), 201
else:
perf_log("create_conversation route failed", elapsed_ms=(time.perf_counter() - t0) * 1000)
return jsonify(result), 500
except Exception as e:
@ -602,19 +673,7 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
tracking_mode = _normalize_versioning_tracking_mode(vmeta.get("tracking_mode"))
enabled = bool(vmeta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode)
latest_checkpoint = vm.get_latest_checkpoint() if enabled else None
latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None
expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None
mismatch = vm.detect_mismatch(
latest_commit,
expected_workspace_path=expected_workspace_path,
tracking_mode=tracking_mode,
) if enabled else {
"has_repo": False,
"head": None,
"dirty": False,
"mismatch": False,
"workspace_matched": True,
}
latest_commit = (latest_checkpoint or {}).get("tree_hash") if latest_checkpoint else None
result["versioning"] = {
"host_mode": _is_host_mode_request(username),
"enabled": enabled,
@ -622,9 +681,6 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
"tracking_mode": tracking_mode,
"latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None,
"latest_commit": latest_commit,
"mismatch": bool(mismatch.get("mismatch")),
"dirty": bool(mismatch.get("dirty")),
"workspace_matched": bool(mismatch.get("workspace_matched", True)),
}
except Exception as exc:
debug_log(f"[Versioning] load status 读取失败: {exc}")
@ -633,7 +689,6 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
"enabled": False,
"mode": "overwrite",
"tracking_mode": ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"mismatch": False,
"error": str(exc),
}
@ -838,17 +893,6 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
enabled = bool(versioning_meta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode)
manager = _get_conv_versioning_manager(workspace, normalized_id)
latest = manager.get_latest_checkpoint() if enabled else None
mismatch = manager.detect_mismatch(
(latest or {}).get("commit"),
expected_workspace_path=(latest or {}).get("workspace_path"),
tracking_mode=tracking_mode,
) if enabled else {
"has_repo": False,
"head": None,
"dirty": False,
"mismatch": False,
"workspace_matched": True,
}
return jsonify({
"success": True,
"data": {
@ -860,10 +904,7 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
"mode": "overwrite",
"tracking_mode": tracking_mode,
"latest_seq": int((latest or {}).get("seq") or 0) if latest else None,
"latest_commit": (latest or {}).get("commit"),
"mismatch": bool(mismatch.get("mismatch")),
"dirty": bool(mismatch.get("dirty")),
"workspace_matched": bool(mismatch.get("workspace_matched", True)),
"latest_commit": (latest or {}).get("tree_hash"),
}
})
except Exception as exc:
@ -911,8 +952,8 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
f"created={init_result.get('created')} reason={init_result.get('reason')} "
f"seq={init_row.get('seq')} commit={init_row.get('commit')}"
)
if init_row.get("commit"):
meta["last_commit"] = init_row.get("commit")
if init_row.get("tree_hash"):
meta["last_tree_hash"] = init_row.get("tree_hash")
ok = _update_conversation_versioning_meta(
terminal,
normalized_id,
@ -959,6 +1000,36 @@ def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTermi
})
manager = _get_conv_versioning_manager(workspace, normalized_id)
rows = manager.list_checkpoints()
backup_mode = str(versioning_meta.get("backup_mode") or "shallow").strip().lower()
if backup_mode == "shallow":
shallow_manager = ShallowVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=normalized_id,
)
normalized_rows: List[Dict[str, Any]] = []
for row in rows:
row = dict(row)
shallow_message_id = row.get("shallow_message_id")
if not shallow_message_id:
snapshot_file = row.get("snapshot_file")
if snapshot_file:
try:
snapshot_path = (Path(workspace.data_dir) / "save" / normalized_id / str(snapshot_file)).resolve()
if snapshot_path.exists():
snapshot_data = json.loads(snapshot_path.read_text(encoding="utf-8", errors="ignore"))
shallow_message_id = snapshot_data.get("shallow_message_id")
except Exception:
pass
if shallow_message_id:
stats = shallow_manager.get_diff_stats(str(shallow_message_id)) or {}
files = shallow_manager.get_file_diff_stats(str(shallow_message_id)) or []
row["insertions"] = int(stats.get("insertions") or 0)
row["deletions"] = int(stats.get("deletions") or 0)
row["files_changed"] = len(files)
row["files"] = files
normalized_rows.append(row)
rows = normalized_rows
return jsonify({
"success": True,
"data": {
@ -987,11 +1058,210 @@ def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, ter
row = manager.get_checkpoint_detail(seq, include_patch=True)
if not row:
return jsonify({"success": False, "error": "未找到对应版本点"}), 404
backup_mode = str(vmeta.get("backup_mode") or "shallow").strip().lower()
debug_log(f"[Versioning][Detail] conv={normalized_id} seq={seq} backup_mode={backup_mode} row_shallow_msg_id={row.get('shallow_message_id')} files_count={len(row.get('files') or [])}")
if backup_mode == "shallow":
shallow_message_id = row.get("shallow_message_id")
# 兼容旧检查点shallow_message_id 之前只写在 snapshot_file 里
if not shallow_message_id:
snapshot_file = row.get("snapshot_file")
if snapshot_file:
try:
snapshot_path = (Path(workspace.data_dir) / "save" / normalized_id / str(snapshot_file)).resolve()
if snapshot_path.exists():
snapshot_data = json.loads(snapshot_path.read_text(encoding="utf-8", errors="ignore"))
shallow_message_id = snapshot_data.get("shallow_message_id")
except Exception:
pass
files = row.get("files") or []
debug_log(f"[Versioning][Detail] resolved shallow_message_id={shallow_message_id} files={files}")
if shallow_message_id and isinstance(files, list):
shallow_manager = ShallowVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=normalized_id,
)
stats = shallow_manager.get_diff_stats(str(shallow_message_id)) or {}
normalized_files: List[Dict[str, Any]] = []
for file_item in files:
if not isinstance(file_item, dict):
continue
item = dict(file_item)
path = str(item.get("path") or "")
if path:
patch = shallow_manager.get_file_patch_lines(path, str(shallow_message_id))
debug_log(f"[Versioning][Detail] path={path} patch={patch}")
item["patch_lines"] = patch.get("lines") or []
item["patch_truncated"] = bool(patch.get("truncated"))
normalized_files.append(item)
row["files"] = normalized_files
row["insertions"] = int(stats.get("insertions") or 0)
row["deletions"] = int(stats.get("deletions") or 0)
row["files_changed"] = len(normalized_files)
debug_log(f"[Versioning][Detail] response files={row.get('files')}")
return jsonify({"success": True, "data": row})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
def _copy_versioning_records(source_conversation_id: str, target_conversation_id: str, data_dir: Path) -> bool:
"""把源对话的版本控制记录checkpoints / snapshots / git / meta复制到目标对话。"""
source_dir = (Path(data_dir) / "save" / source_conversation_id).resolve()
target_dir = (Path(data_dir) / "save" / target_conversation_id).resolve()
if not source_dir.exists():
return False
try:
if target_dir.exists():
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
# 复制后保持目标 meta 与源一致,无需修改 conversation_idmanager 按路径构造)。
return True
except Exception as exc:
debug_log(f"[Versioning] copy records failed {source_conversation_id} -> {target_conversation_id}: {exc}")
return False
def _restore_checkpoint_to_conversation(
terminal: WebTerminal,
workspace: UserWorkspace,
source_conversation_id: str,
target_conversation_id: str,
seq: int,
tracking_mode: str,
host_mode: bool,
backup_mode: Optional[str] = None,
) -> Dict[str, Any]:
"""把源对话的指定 checkpoint 恢复到目标对话,覆盖目标对话内容。"""
source_manager = _get_conv_versioning_manager(workspace, source_conversation_id)
target_manager = _get_conv_versioning_manager(workspace, target_conversation_id)
checkpoint = source_manager.get_checkpoint(seq)
if not checkpoint:
raise VersioningError("未找到对应版本点")
normalized_backup_mode = "full" if backup_mode == "full" else "shallow"
if normalized_backup_mode == "shallow":
# 浅备份模式:根据检查点行中的 shallow_message_id 恢复被跟踪文件
shallow_message_id = checkpoint.get("shallow_message_id")
if shallow_message_id:
shallow_manager = ShallowVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=source_conversation_id,
)
rewind_result = shallow_manager.rewind(str(shallow_message_id))
debug_log(
f"[Versioning][Restore] shallow rewind conv={target_conversation_id} seq={seq} "
f"msg_id={shallow_message_id} files_changed={len(rewind_result.get('files_changed') or [])}"
)
else:
debug_log(
f"[Versioning][Restore] shallow checkpoint missing message_id "
f"conv={target_conversation_id} seq={seq}"
)
elif tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
if not host_mode:
raise VersioningError("当前模式不支持工作区回溯,请切换到宿主机模式")
target_manager.restore_to_tree(checkpoint.get("tree_hash"))
debug_log(
f"[Versioning][Restore] git restored conv={target_conversation_id} seq={seq} "
f"commit={checkpoint.get('commit')}"
)
else:
debug_log(
f"[Versioning][Restore] conversation-only mode, skip workspace restore "
f"conv={target_conversation_id} seq={seq}"
)
cm = terminal.context_manager.conversation_manager
conv_data = cm.load_conversation(source_conversation_id) or {}
conv_meta = conv_data.get("metadata") or {}
snapshot = source_manager.get_checkpoint_snapshot(seq)
snapshot_messages = (snapshot or {}).get("messages")
if not isinstance(snapshot_messages, list):
snapshot_messages = None
snapshot_meta = (snapshot or {}).get("metadata")
if not isinstance(snapshot_meta, dict):
snapshot_meta = {}
if snapshot_messages is None:
all_messages = conv_data.get("messages") or []
msg_index = int(checkpoint.get("message_index") or 0)
snapshot_messages = all_messages[: max(0, msg_index + 1)]
debug_log(
f"[Versioning][Restore] fallback truncation conv={target_conversation_id} seq={seq} "
f"msg_index={msg_index} restored_messages={len(snapshot_messages)}"
)
else:
debug_log(
f"[Versioning][Restore] snapshot loaded conv={target_conversation_id} seq={seq} "
f"messages={len(snapshot_messages)} meta_keys={list(snapshot_meta.keys())[:8]}"
)
restore_meta = dict(conv_meta or {})
if snapshot_meta:
restore_meta.update(snapshot_meta)
restore_project_path = restore_meta.get("project_path") or str(workspace.project_path)
restore_thinking_mode = bool(restore_meta.get("thinking_mode", False))
restore_run_mode = restore_meta.get("run_mode") or ("thinking" if restore_thinking_mode else "fast")
restore_model_key = restore_meta.get("model_key")
restore_has_images = bool(restore_meta.get("has_images", False))
restore_has_videos = bool(restore_meta.get("has_videos", False))
ok = cm.save_conversation(
conversation_id=target_conversation_id,
messages=snapshot_messages,
project_path=restore_project_path,
thinking_mode=restore_thinking_mode,
run_mode=restore_run_mode,
model_key=restore_model_key,
has_images=restore_has_images,
has_videos=restore_has_videos,
)
if not ok:
raise VersioningError("保存恢复后的对话失败")
prune_info = target_manager.prune_checkpoints_after(seq)
resolved_last_commit = prune_info.get("last_tree_hash") or checkpoint.get("tree_hash")
if not resolved_last_commit:
resolved_last_commit = (target_manager.load_meta() or {}).get("last_tree_hash")
_update_conversation_versioning_meta(
terminal,
target_conversation_id,
enabled=True,
mode="overwrite",
tracking_mode=tracking_mode,
last_commit=resolved_last_commit,
last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
)
cm.update_conversation_metadata(
target_conversation_id,
{
**restore_meta,
"versioning": {
"enabled": True,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"last_commit": resolved_last_commit,
"last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
"updated_at": datetime.now().isoformat(),
},
},
)
debug_log(
f"[Versioning][Restore] saved conv={target_conversation_id} seq={seq} "
f"messages={len(snapshot_messages)}"
)
return {
"restored_seq": int(checkpoint.get("seq") or 0),
"restored_commit": checkpoint.get("tree_hash"),
"tracking_mode": tracking_mode,
}
@conversation_bp.route('/api/conversations/<conversation_id>/versioning/restore', methods=['POST'])
@api_login_required
@with_terminal
@ -1008,134 +1278,57 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
if not vmeta.get("enabled"):
return jsonify({"success": False, "error": "当前对话未开启版本管理"}), 400
restore_mode = "overwrite"
restore_mode = str(payload.get("mode") or "overwrite").lower()
if restore_mode not in {"overwrite", "copy"}:
restore_mode = "overwrite"
requested_tracking_mode = _normalize_versioning_tracking_mode(payload.get("tracking_mode"))
manager = _get_conv_versioning_manager(workspace, normalized_id)
checkpoint = manager.get_checkpoint(seq)
if not checkpoint:
return jsonify({"success": False, "error": "未找到对应版本点"}), 404
# 默认使用 checkpoint 创建时的 tracking_mode若前端显式指定则优先采用。
tracking_mode = _normalize_versioning_tracking_mode(
checkpoint.get("tracking_mode") or vmeta.get("tracking_mode")
requested_tracking_mode or checkpoint.get("tracking_mode") or vmeta.get("tracking_mode")
)
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode:
return jsonify({"success": False, "error": "当前模式不支持工作区回溯,请切换到宿主机模式"}), 400
cm = terminal.context_manager.conversation_manager
target_conversation_id = normalized_id
if restore_mode == "copy":
duplicate_result = terminal.context_manager.duplicate_conversation(normalized_id)
if not duplicate_result.get("success"):
return jsonify({"success": False, "error": duplicate_result.get("error", "复制对话失败")}), 500
target_conversation_id = _normalize_conv_id(duplicate_result["duplicate_conversation_id"])
copied = _copy_versioning_records(normalized_id, target_conversation_id, workspace.data_dir)
if not copied:
# 复制记录失败时继续执行,只是版本控制记录无法继承;后续仍可恢复对话内容。
debug_log(f"[Versioning][Restore] copy records skipped for {target_conversation_id}")
debug_log(
f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} "
f"tracking_mode={tracking_mode} "
f"tracking_mode={tracking_mode} target={target_conversation_id} "
f"target_commit={checkpoint.get('commit')} workspace={workspace.project_path}"
)
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
manager.restore_to_commit(checkpoint.get("commit"))
debug_log(
f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} "
f"commit={checkpoint.get('commit')}"
)
else:
debug_log(
f"[Versioning][Restore] conversation-only mode, skip workspace restore "
f"conv={normalized_id} seq={seq}"
)
cm = terminal.context_manager.conversation_manager
conv_data = cm.load_conversation(normalized_id) or {}
conv_meta = conv_data.get("metadata") or {}
# 优先使用 checkpoint 绑定的快照恢复对话内容,确保与提交时语义一致(发送前状态)。
snapshot = manager.get_checkpoint_snapshot(seq)
snapshot_messages = (snapshot or {}).get("messages")
if not isinstance(snapshot_messages, list):
snapshot_messages = None
snapshot_meta = (snapshot or {}).get("metadata")
if not isinstance(snapshot_meta, dict):
snapshot_meta = {}
if snapshot_messages is None:
# 兼容旧数据:若无快照文件,退回到历史索引截断。
all_messages = conv_data.get("messages") or []
msg_index = int(checkpoint.get("message_index") or 0)
snapshot_messages = all_messages[: max(0, msg_index + 1)]
debug_log(
f"[Versioning][Restore] fallback truncation conv={normalized_id} seq={seq} "
f"msg_index={msg_index} restored_messages={len(snapshot_messages)}"
)
else:
debug_log(
f"[Versioning][Restore] snapshot loaded conv={normalized_id} seq={seq} "
f"messages={len(snapshot_messages)} meta_keys={list(snapshot_meta.keys())[:8]}"
)
restore_meta = dict(conv_meta or {})
if snapshot_meta:
restore_meta.update(snapshot_meta)
restore_project_path = restore_meta.get("project_path") or str(workspace.project_path)
restore_thinking_mode = bool(restore_meta.get("thinking_mode", False))
restore_run_mode = restore_meta.get("run_mode") or ("thinking" if restore_thinking_mode else "fast")
restore_model_key = restore_meta.get("model_key")
restore_has_images = bool(restore_meta.get("has_images", False))
restore_has_videos = bool(restore_meta.get("has_videos", False))
target_conversation_id = normalized_id
ok = cm.save_conversation(
conversation_id=normalized_id,
messages=snapshot_messages,
project_path=restore_project_path,
thinking_mode=restore_thinking_mode,
run_mode=restore_run_mode,
model_key=restore_model_key,
has_images=restore_has_images,
has_videos=restore_has_videos,
)
if not ok:
return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500
prune_info = manager.prune_checkpoints_after(seq)
resolved_last_commit = prune_info.get("last_commit") or checkpoint.get("commit")
if not resolved_last_commit:
resolved_last_commit = (manager.load_meta() or {}).get("last_commit")
debug_log(
f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} "
f"kept={prune_info.get('kept')} removed={prune_info.get('removed')} "
f"max_seq={prune_info.get('max_seq')}"
)
# 回填快照中的 metadata含权限/模型/统计等),再覆盖 versioning 元信息。
_update_conversation_versioning_meta(
terminal,
normalized_id,
enabled=True,
mode="overwrite",
tracking_mode=tracking_mode,
last_commit=resolved_last_commit,
last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
)
cm.update_conversation_metadata(
normalized_id,
{
**restore_meta,
"versioning": {
"enabled": True,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"last_commit": resolved_last_commit,
"last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
"updated_at": datetime.now().isoformat(),
},
},
)
debug_log(
f"[Versioning][Restore] overwrite saved conv={normalized_id} seq={seq} "
f"messages={len(snapshot_messages)}"
backup_mode = str(vmeta.get("backup_mode") or "shallow").strip().lower()
restore_info = _restore_checkpoint_to_conversation(
terminal, workspace, normalized_id, target_conversation_id, seq, tracking_mode, host_mode, backup_mode
)
# 关键修复overwrite 场景若直接 load 同一对话ContextManager 会先把内存中的旧历史保存回磁盘,
# 导致刚恢复的对话被覆盖。这里先清空内存历史,避免反向覆写。
try:
current_loaded_id = getattr(terminal.context_manager, "current_conversation_id", None)
if current_loaded_id == normalized_id:
terminal.context_manager.conversation_history = []
debug_log(f"[Versioning][Restore] cleared in-memory history before reload conv={normalized_id}")
except Exception as exc:
debug_log(f"[Versioning][Restore] clear in-memory history failed: {exc}")
# overwrite 场景需要清空内存历史避免反向覆写copy 场景目标是新对话,无需处理。
if restore_mode == "overwrite":
try:
current_loaded_id = getattr(terminal.context_manager, "current_conversation_id", None)
if current_loaded_id == normalized_id:
terminal.context_manager.conversation_history = []
debug_log(f"[Versioning][Restore] cleared in-memory history before reload conv={normalized_id}")
except Exception as exc:
debug_log(f"[Versioning][Restore] clear in-memory history failed: {exc}")
terminal.load_conversation(target_conversation_id)
debug_log(
@ -1159,9 +1352,9 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
"data": {
"restore_mode": restore_mode,
"conversation_id": target_conversation_id,
"restored_seq": int(checkpoint.get("seq") or 0),
"restored_commit": checkpoint.get("commit"),
"tracking_mode": tracking_mode,
"restored_seq": restore_info["restored_seq"],
"restored_commit": restore_info["restored_commit"],
"tracking_mode": restore_info["tracking_mode"],
"source_conversation_id": normalized_id,
}
})

View File

@ -514,14 +514,13 @@
:detail-loading="versioningDetailLoading"
:restoring="versioningRestoring"
:restore-mode="versioningRestoreMode"
:workspace-matched="versioningWorkspaceMatched"
:icon-style="iconStyle"
@close="versioningDialogOpen = false"
@refresh="refreshVersioningDialog"
@toggle-enabled="toggleConversationVersioning"
@select="selectVersioningCheckpoint"
@update:restore-mode="versioningRestoreMode = $event"
@update:tracking-mode="handleVersioningTrackingModeChange"
@update:tracking-mode="versioningTrackingMode = $event"
@confirm="confirmVersioningRestore"
/>
</transition>

View File

@ -104,7 +104,24 @@ export const actionMethods = {
console.error('[创建新对话] 停止本地轮询失败:', error);
}
let backupToastId = null;
try {
const personalizationStore = usePersonalizationStore();
const shouldShowBackupToast =
this.versioningHostMode &&
personalizationStore.form.versioning_enabled_by_default &&
personalizationStore.form.versioning_backup_mode === 'full';
if (shouldShowBackupToast) {
backupToastId = this.uiPushToast({
title: '正在初始化备份',
message: '正在创建完整工作区快照,请稍候…',
type: 'info',
duration: null,
closable: false
});
this.versioningInitializingBackupToastId = backupToastId;
}
const response = await fetch('/api/conversations', {
method: 'POST',
headers: {
@ -151,8 +168,6 @@ export const actionMethods = {
this.conversationListAnimationMode = 'idle';
}, 700);
await this.applyPendingVersioningToConversation(newConversationId);
// 直接加载新对话,确保状态一致
// 如果 socket 事件已把 currentConversationId 设置为新ID则强制加载一次以同步状态
await this.loadConversation(newConversationId, { force: true });
@ -188,6 +203,11 @@ export const actionMethods = {
message: error.message || String(error),
type: 'error'
});
} finally {
if (backupToastId) {
this.uiDismissToast(backupToastId);
this.versioningInitializingBackupToastId = null;
}
}
},
async deleteConversation(conversationId) {

View File

@ -18,10 +18,12 @@ export const loadMethods = {
const data = await response.json();
if (data.success) {
if (refreshToken !== this.conversationListRefreshToken) {
if (refreshToken < this.conversationListRefreshToken) {
debugLog('忽略已过期的对话列表响应', {
requestSeq,
responseOffset: queryOffset
responseOffset: queryOffset,
refreshToken,
currentRefreshToken: this.conversationListRefreshToken
});
return;
}
@ -218,7 +220,6 @@ export const loadMethods = {
this.fetchExecutionMode();
this.fetchNetworkPermission();
await this.fetchVersioningStatus(conversationId, { silent: true });
await this.maybePromptVersioningMismatch(result.versioning);
this.fetchPendingToolApprovals();
this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens();

View File

@ -2,6 +2,7 @@
import { debugLog, goalModeDebugLog } from '../common';
import { useTaskStore } from '../../../stores/task';
import { useModelStore } from '../../../stores/model';
import { usePersonalizationStore } from '../../../stores/personalization';
import {
extractSkillRefsFromMessage,
SKILL_MARKDOWN_LINK_RE,
@ -189,8 +190,25 @@ export const sendMethods = {
}
let targetConversationId = this.currentConversationId;
let backupToastId = null;
if (!targetConversationId) {
try {
const personalizationStore = usePersonalizationStore();
const shouldShowBackupToast =
this.versioningHostMode &&
personalizationStore.form.versioning_enabled_by_default &&
personalizationStore.form.versioning_backup_mode === 'full';
if (shouldShowBackupToast) {
backupToastId = this.uiPushToast({
title: '正在初始化备份',
message: '正在创建完整工作区快照,请稍候…',
type: 'info',
duration: null,
closable: false
});
this.versioningInitializingBackupToastId = backupToastId;
}
const createResp = await fetch('/api/conversations', {
method: 'POST',
headers: {
@ -221,7 +239,6 @@ export const sendMethods = {
];
const pathFragment = this.stripConversationPrefix(targetConversationId);
history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`);
await this.applyPendingVersioningToConversation(targetConversationId);
} catch (error) {
this.uiPushToast({
title: '发送失败',
@ -229,6 +246,11 @@ export const sendMethods = {
type: 'error'
});
return false;
} finally {
if (backupToastId) {
this.uiDismissToast(backupToastId);
this.versioningInitializingBackupToastId = null;
}
}
}

View File

@ -24,18 +24,8 @@ export const versioningMethods = {
const payload = data.data || {};
this.versioningHostMode = !!payload.host_mode;
this.versioningEnabled = !!payload.enabled;
const resolvedTrackingMode = normalizeTrackingMode(payload.tracking_mode);
this.versioningTrackingMode = !this.versioningHostMode &&
resolvedTrackingMode !== 'conversation_only'
? 'conversation_only'
: resolvedTrackingMode;
if (!this.versioningHostMode) {
this.newConversationVersioningTrackingMode = 'conversation_only';
}
this.versioningMode = 'overwrite';
this.versioningRestoreMode = 'overwrite';
this.versioningMismatch = !!payload.mismatch;
this.versioningWorkspaceMatched = payload.workspace_matched !== false;
return payload;
} catch (error) {
if (!silent) {
@ -59,9 +49,6 @@ export const versioningMethods = {
if (!resp.ok || !data?.success) {
throw new Error(data?.error || '加载版本点失败');
}
if (data?.data?.tracking_mode) {
this.versioningTrackingMode = normalizeTrackingMode(data.data.tracking_mode);
}
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
this.versioningCheckpoints = items;
if (!items.length) {
@ -82,26 +69,17 @@ export const versioningMethods = {
},
async openVersioningDialog() {
if (!this.currentConversationId) {
this.versioningEnabled = !!this.newConversationVersioningEnabled;
this.versioningTrackingMode = normalizeTrackingMode(
this.newConversationVersioningTrackingMode
);
if (!this.versioningHostMode && this.versioningTrackingMode !== 'conversation_only') {
this.versioningTrackingMode = 'conversation_only';
}
this.versioningWorkspaceMatched = true;
this.versioningCheckpoints = [];
this.versioningSelectedSeq = null;
this.versioningSelectedDetail = null;
this.versioningDialogOpen = true;
return;
}
await this.fetchVersioningStatus(this.currentConversationId);
this.versioningRestoreMode = 'overwrite';
this.versioningTrackingMode = 'conversation_only';
this.versioningDialogOpen = true;
this.versioningSelectedSeq = null;
this.versioningSelectedDetail = null;
this.versioningCheckpoints = [];
if (!this.currentConversationId) {
this.versioningEnabled = false;
return;
}
await this.fetchVersioningStatus(this.currentConversationId);
await this.fetchVersioningCheckpoints(this.currentConversationId);
},
@ -110,22 +88,9 @@ export const versioningMethods = {
await this.fetchVersioningCheckpoints(this.currentConversationId);
},
async handleVersioningTrackingModeChange(mode: string) {
await this.updateVersioningTrackingMode(mode);
},
async toggleConversationVersioning(enabled: boolean) {
if (!this.currentConversationId) {
this.newConversationVersioningEnabled = !!enabled;
this.versioningEnabled = !!enabled;
if (enabled) {
if (!this.versioningHostMode) {
this.versioningTrackingMode = 'conversation_only';
}
this.newConversationVersioningTrackingMode = normalizeTrackingMode(
this.versioningTrackingMode
);
}
this.uiPushToast({
title: '版本管理',
message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理',
@ -135,18 +100,12 @@ export const versioningMethods = {
}
this.versioningLoading = true;
try {
let trackingMode = normalizeTrackingMode(this.versioningTrackingMode);
if (!this.versioningHostMode && trackingMode !== 'conversation_only') {
trackingMode = 'conversation_only';
this.versioningTrackingMode = trackingMode;
}
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: !!enabled,
mode: 'overwrite',
tracking_mode: trackingMode
mode: 'overwrite'
})
});
const data = await resp.json().catch(() => ({}));
@ -154,9 +113,6 @@ export const versioningMethods = {
throw new Error(data?.error || '切换版本管理失败');
}
this.versioningEnabled = !!data?.data?.enabled;
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || this.versioningTrackingMode
);
this.versioningMode = 'overwrite';
this.versioningRestoreMode = 'overwrite';
this.uiPushToast({
@ -183,19 +139,38 @@ export const versioningMethods = {
},
async selectVersioningCheckpoint(seq: number) {
if (!this.currentConversationId || seq === null || seq === undefined) return;
this.versioningSelectedSeq = Number(seq);
if (!this.currentConversationId || seq === null || seq === undefined || Number.isNaN(Number(seq))) return;
const targetSeq = Number(seq);
this.versioningSelectedSeq = targetSeq;
this.versioningDetailLoading = true;
try {
const resp = await fetch(
`/api/conversations/${this.currentConversationId}/versioning/checkpoints/${encodeURIComponent(String(seq))}`
`/api/conversations/${this.currentConversationId}/versioning/checkpoints/${encodeURIComponent(String(targetSeq))}`
);
const data = await resp.json().catch(() => ({}));
const text = await resp.text();
let data: any = {};
try {
data = text ? JSON.parse(text) : {};
} catch (parseErr: any) {
// eslint-disable-next-line no-console
console.error('[VersioningDetail] parse failed:', parseErr?.message, 'status=', resp.status, 'text=', text);
throw new Error(`详情响应解析失败: ${parseErr?.message || '未知错误'}`);
}
// eslint-disable-next-line no-console
console.log('[VersioningDetail] raw response:', data);
if (!resp.ok || !data?.success) {
throw new Error(data?.error || '加载详情失败');
}
this.versioningSelectedDetail = data.data || null;
} catch (error) {
// eslint-disable-next-line no-console
console.log('[VersioningDetail] detail set:', this.versioningSelectedDetail);
// eslint-disable-next-line no-console
console.log('[VersioningDetail] files:', this.versioningSelectedDetail?.files);
// eslint-disable-next-line no-console
console.log('[VersioningDetail] first file patch_lines:', this.versioningSelectedDetail?.files?.[0]?.patch_lines);
} catch (error: any) {
// eslint-disable-next-line no-console
console.error('[VersioningDetail] error:', error);
this.uiPushToast({
title: '版本管理',
message: error?.message || '加载详情失败',
@ -212,13 +187,19 @@ export const versioningMethods = {
this.versioningSelectedSeq === null ||
this.versioningSelectedSeq === undefined
) return;
const restoreMode = this.versioningRestoreMode || 'overwrite';
const trackingMode = normalizeTrackingMode(this.versioningTrackingMode);
const scopeLabel = trackingMode === 'conversation_only' ? '仅回溯对话' : '回溯对话和工作区';
const modeLabel = restoreMode === 'copy' ? '复制对话' : '覆盖当前对话';
const confirmed = await this.confirmAction({
title: '确认回溯',
message: `将回溯到输入 #${this.versioningSelectedSeq} 对应的状态,是否继续?`,
message: `${scopeLabel}到输入 #${this.versioningSelectedSeq} 对应的状态,${modeLabel}是否继续?`,
confirmText: '回溯',
cancelText: '取消'
});
if (!confirmed) return;
this.versioningRestoring = true;
try {
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning/restore`, {
@ -226,7 +207,8 @@ export const versioningMethods = {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
seq: this.versioningSelectedSeq,
mode: 'overwrite'
mode: restoreMode,
tracking_mode: trackingMode
})
});
const data = await resp.json().catch(() => ({}));
@ -236,9 +218,19 @@ export const versioningMethods = {
const targetConversationId = data?.data?.conversation_id || this.currentConversationId;
this.versioningDialogOpen = false;
await this.loadConversation(targetConversationId, { force: true });
// 立即刷新侧边栏对话列表,避免依赖 socket 事件延迟或丢失
this.conversationsOffset = 0;
await this.loadConversationsList();
// copy 模式下给侧边栏一个即时占位,随后列表刷新会补齐真实数据
if (restoreMode === 'copy' && !this.conversations.some((c) => c && c.id === targetConversationId)) {
this.conversations = [
{ id: targetConversationId, title: '版本回溯对话', updated_at: new Date().toISOString(), total_messages: 0, total_tools: 0 },
...this.conversations.filter((c) => c && c.id !== targetConversationId)
];
}
this.uiPushToast({
title: '版本管理',
message: '回溯完成',
message: restoreMode === 'copy' ? '已复制并回溯到新对话' : '回溯完成',
type: 'success'
});
} catch (error) {
@ -252,144 +244,4 @@ export const versioningMethods = {
}
},
async maybePromptVersioningMismatch(versioningPayload: any) {
if (!versioningPayload || !versioningPayload.enabled || !versioningPayload.mismatch) {
return;
}
const latestSeq = versioningPayload.latest_seq;
if (!latestSeq) return;
const confirmed = await this.confirmAction({
title: '版本状态不一致',
message: '当前工作区与该对话最新版本不一致,是否恢复到最新一次提交?',
confirmText: '恢复',
cancelText: '保持当前',
warningText: '警告:恢复操作会导致当前工作区内容被重置为当前对话的最新一次提交备份。',
closeOnBackdrop: false,
confirmVariant: 'danger',
confirmOnLeft: true
});
if (!confirmed) return;
try {
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
seq: latestSeq,
mode: 'overwrite'
})
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.error || '恢复失败');
}
await this.loadConversation(this.currentConversationId, { force: true });
this.uiPushToast({
title: '版本管理',
message: '已恢复到最新版本提交',
type: 'success'
});
} catch (error) {
debugLog('[Versioning] mismatch restore failed', error);
this.uiPushToast({
title: '版本管理',
message: error?.message || '恢复失败',
type: 'error'
});
}
},
async applyPendingVersioningToConversation(conversationId: string) {
const targetId = conversationId || this.currentConversationId;
if (!targetId) return;
if (!this.newConversationVersioningEnabled) return;
try {
const trackingMode = normalizeTrackingMode(
!this.versioningHostMode
? 'conversation_only'
: (this.newConversationVersioningTrackingMode || this.versioningTrackingMode)
);
const resp = await fetch(`/api/conversations/${targetId}/versioning`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
mode: 'overwrite',
tracking_mode: trackingMode
})
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.error || '新对话开启版本管理失败');
}
this.newConversationVersioningEnabled = false;
this.versioningEnabled = true;
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || trackingMode
);
} catch (error) {
this.uiPushToast({
title: '版本管理',
message: error?.message || '新对话开启版本管理失败',
type: 'error'
});
}
},
async updateVersioningTrackingMode(mode: string) {
const normalizedMode = normalizeTrackingMode(mode);
if (!this.versioningHostMode && normalizedMode === 'workspace_and_conversation') {
this.uiPushToast({
title: '版本管理',
message: '当前模式仅支持“仅管理对话”',
type: 'warning'
});
this.versioningTrackingMode = 'conversation_only';
this.newConversationVersioningTrackingMode = 'conversation_only';
return false;
}
this.versioningTrackingMode = normalizedMode;
if (!this.currentConversationId) {
this.newConversationVersioningTrackingMode = normalizedMode;
return true;
}
if (!this.versioningEnabled) {
return true;
}
this.versioningLoading = true;
try {
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
mode: 'overwrite',
tracking_mode: normalizedMode
})
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.error || '更新版本管理范围失败');
}
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || normalizedMode
);
this.uiPushToast({
title: '版本管理',
message:
this.versioningTrackingMode === 'conversation_only'
? '已切换为仅管理对话'
: '已切换为管理工作区和对话',
type: 'success'
});
return true;
} catch (error) {
this.uiPushToast({
title: '版本管理',
message: error?.message || '更新版本管理范围失败',
type: 'error'
});
return false;
} finally {
this.versioningLoading = false;
}
}
};

View File

@ -164,12 +164,8 @@ export function dataState() {
hostWorkspaceManageDialogOpen: false,
hostWorkspaceManageSubmitting: false,
versioningEnabled: false,
newConversationVersioningEnabled: false,
versioningTrackingMode: 'workspace_and_conversation',
newConversationVersioningTrackingMode: 'workspace_and_conversation',
versioningTrackingMode: 'conversation_only',
versioningMode: 'overwrite',
versioningMismatch: false,
versioningWorkspaceMatched: true,
versioningDialogOpen: false,
versioningLoading: false,
versioningCheckpoints: [],
@ -178,6 +174,7 @@ export function dataState() {
versioningDetailLoading: false,
versioningRestoring: false,
versioningRestoreMode: 'overwrite',
versioningInitializingBackupToastId: null,
permissionModeOptions: [
{
value: 'readonly',

View File

@ -92,10 +92,8 @@ export const watchers = {
);
}
if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) {
this.versioningEnabled = !!this.newConversationVersioningEnabled;
this.versioningTrackingMode =
this.newConversationVersioningTrackingMode || 'workspace_and_conversation';
this.versioningMismatch = false;
this.versioningEnabled = false;
this.versioningTrackingMode = 'conversation_only';
return;
}
this.fetchVersioningStatus(newValue, { silent: true });

View File

@ -308,7 +308,6 @@ function renderCreateFile(result: any, args: any): string {
function renderWriteFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已写入');
const content = args.content || '';
const appendFlag = typeof args.append === 'boolean' ? args.append : null;
const modeRaw = String(result.mode || '').toLowerCase();
const writeMode =
@ -317,6 +316,10 @@ function renderWriteFile(result: any, args: any): string {
: appendFlag === false || modeRaw === 'w'
? '覆盖'
: '无';
const content =
appendFlag === true || modeRaw === 'a'
? args.content || ''
: (result.new_file ?? args.content ?? '');
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
@ -351,12 +354,14 @@ function renderEditFile(result: any, args: any): string {
: null;
const replacedCount = typeof result.replacements === 'number' ? result.replacements : null;
const replacements = Array.isArray(args.replacements) ? args.replacements : [];
const details = Array.isArray(result.details)
? result.details.filter((d: any) => d && d.status === 'success')
: [];
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>替换组数:</strong>${(result.replacement_groups ?? replacements.length) || '无'}</div>`;
html += `<div><strong>替换组数:</strong>${(result.replacement_groups ?? details.length) || '无'}</div>`;
html += `<div><strong>找到:</strong>${foundCount ?? '无'}处</div>`;
html += `<div><strong>替换:</strong>${replacedCount ?? '无'}处</div>`;
if (!result.success && result.error) {
@ -368,18 +373,16 @@ function renderEditFile(result: any, args: any): string {
return html;
}
if (replacements.length > 0) {
if (details.length > 0) {
html += '<div class="tool-result-diff scrollable">';
const details = Array.isArray(result.details) ? result.details : [];
const renderDiffLine = (lineNo: number | null, marker: string, text: string, className = '') => {
const lineNoText = typeof lineNo === 'number' && Number.isFinite(lineNo) ? String(lineNo) : '';
html += `<div class="diff-line${className ? ` ${className}` : ''}"><span class="diff-line-number">${escapeHtml(lineNoText)}</span><span class="diff-marker">${escapeHtml(marker)}</span><span class="diff-content">${escapeHtml(text)}</span></div>`;
};
const hunks: any[] = [];
replacements.forEach((rep: any, idx: number) => {
const oldText = rep.old_string || '';
const newText = rep.new_string || '';
const detail = details[idx] || {};
details.forEach((detail: any) => {
const oldText = detail.old_string || '';
const newText = detail.new_string || '';
const contexts = Array.isArray(detail.contexts) ? detail.contexts : [];
const buildPairRows = (context?: any) => {
const rows: any[] = [];
@ -421,7 +424,6 @@ function renderEditFile(result: any, args: any): string {
? rawAfterLines.slice(0, -1)
: rawAfterLines;
// /after context
const contextOldStartLine = Number(context?.old_start_line ?? detail.matched_lines?.[0]);
const contextOldEndLine = Number(context?.old_end_line ?? contextOldStartLine);
const oldLineCount = Number.isFinite(contextOldStartLine) && Number.isFinite(contextOldEndLine)

View File

@ -250,7 +250,6 @@ function renderCreateFile(result: any, args: any): string {
function renderWriteFile(result: any, args: any): string {
const path = args.file_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 已写入');
const content = args.content || '';
const appendFlag = typeof args.append === 'boolean' ? args.append : null;
const modeRaw = String(result.mode || '').toLowerCase();
const writeMode =
@ -259,6 +258,10 @@ function renderWriteFile(result: any, args: any): string {
: appendFlag === false || modeRaw === 'w'
? '覆盖'
: '无';
const content =
appendFlag === true || modeRaw === 'a'
? args.content || ''
: (result.new_file ?? args.content ?? '');
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
@ -293,12 +296,14 @@ function renderEditFile(result: any, args: any): string {
: null;
const replacedCount = typeof result.replacements === 'number' ? result.replacements : null;
const replacements = Array.isArray(args.replacements) ? args.replacements : [];
const details = Array.isArray(result.details)
? result.details.filter((d: any) => d && d.status === 'success')
: [];
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
html += `<div><strong>状态:</strong>${status}</div>`;
html += `<div><strong>替换组数:</strong>${(result.replacement_groups ?? replacements.length) || '无'}</div>`;
html += `<div><strong>替换组数:</strong>${(result.replacement_groups ?? details.length) || '无'}</div>`;
html += `<div><strong>找到:</strong>${foundCount ?? '无'}处</div>`;
html += `<div><strong>替换:</strong>${replacedCount ?? '无'}处</div>`;
if (!result.success && result.error) {
@ -310,18 +315,16 @@ function renderEditFile(result: any, args: any): string {
return html;
}
if (replacements.length > 0) {
if (details.length > 0) {
html += '<div class="tool-result-diff scrollable">';
const details = Array.isArray(result.details) ? result.details : [];
const renderDiffLine = (lineNo: number | null, marker: string, text: string, className = '') => {
const lineNoText = typeof lineNo === 'number' && Number.isFinite(lineNo) ? String(lineNo) : '';
html += `<div class="diff-line${className ? ` ${className}` : ''}"><span class="diff-line-number">${escapeHtml(lineNoText)}</span><span class="diff-marker">${escapeHtml(marker)}</span><span class="diff-content">${escapeHtml(text)}</span></div>`;
};
const hunks: any[] = [];
replacements.forEach((rep: any, idx: number) => {
const oldText = rep.old_string || '';
const newText = rep.new_string || '';
const detail = details[idx] || {};
details.forEach((detail: any) => {
const oldText = detail.old_string || '';
const newText = detail.new_string || '';
const contexts = Array.isArray(detail.contexts) ? detail.contexts : [];
const oldLines = oldText ? oldText.split('\n') : [];
const newLines = newText ? newText.split('\n') : [];
@ -363,7 +366,6 @@ function renderEditFile(result: any, args: any): string {
? rawAfterLines.slice(0, -1)
: rawAfterLines;
// 编辑后新增/删除行数不同会导致后续行号偏移after context 需按新文件位置重新编号
const contextOldStartLine = Number(context?.old_start_line ?? detail.matched_lines?.[0]);
const contextOldEndLine = Number(context?.old_end_line ?? contextOldStartLine);
const oldLineCount = Number.isFinite(contextOldStartLine) && Number.isFinite(contextOldEndLine)

View File

@ -14,43 +14,6 @@
<span class="switch"></span>
<span class="switch-text">{{ enabled ? '开启' : '关闭' }}</span>
</label>
<div class="scope-line">
<span class="scope-label">管理范围</span>
<div
class="scope-select"
:class="{ open: scopeMenuOpen, disabled: !hostMode || loading || restoring }"
@click.stop
>
<button
type="button"
class="scope-select-button"
:disabled="!hostMode || loading || restoring"
@click="toggleScopeMenu"
>
<span class="scope-select-value">{{ trackingModeLabel }}</span>
<span class="scope-chevron" aria-hidden="true"></span>
</button>
<div v-if="scopeMenuOpen" class="scope-menu">
<button
v-for="opt in scopeOptions"
:key="opt.value"
type="button"
class="scope-menu-option"
:class="{ selected: trackingMode === opt.value, disabled: opt.disabled }"
:disabled="opt.disabled"
@click="selectScope(opt.value)"
>
<span class="scope-option-label">{{ opt.label }}</span>
<span
v-if="trackingMode === opt.value"
class="icon icon-sm scope-option-check"
:style="iconStyle('check')"
aria-hidden="true"
></span>
</button>
</div>
</div>
</div>
</div>
<div class="versioning-header-actions">
<button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')">
@ -99,7 +62,7 @@
<div class="file-list">
<template v-for="(file, idx) in detail.files || []" :key="`${file.status}:${file.path}:${idx}`">
<div class="file-row" :class="{ expanded: isFileExpanded(file, idx) }" @click="toggleFileExpand(file, idx)">
<span class="status">{{ file.status }}</span>
<span class="status">{{ statusLabel(file.status) }}</span>
<span class="path">{{ file.path }}</span>
<span class="delta">
<span class="plus">+{{ file.insertions || 0 }}</span>
@ -108,14 +71,17 @@
</div>
<div v-if="isFileExpanded(file, idx)" class="tool-result-diff scroll-area versioning-diff">
<div v-if="!(file.patch_lines || []).length" class="empty">暂无可展示的文本变更行</div>
<div
v-for="(line, lineIdx) in file.patch_lines || []"
:key="`line:${idx}:${lineIdx}`"
class="diff-line"
:class="line.type === 'add' ? 'diff-add' : (line.type === 'remove' ? 'diff-remove' : 'diff-context')"
>
{{ line.type === 'add' ? '+ ' : (line.type === 'remove' ? '- ' : ' ') }}{{ line.content }}
</div>
<template v-for="(line, lineIdx) in file.patch_lines || []" :key="`line:${idx}:${lineIdx}`">
<div
v-for="(subLine, subIdx) in splitDiffContent(line.content)"
:key="`line:${idx}:${lineIdx}:${subIdx}`"
class="diff-line"
:class="line.type === 'add' ? 'diff-add' : (line.type === 'remove' ? 'diff-remove' : 'diff-context')"
>
<span class="diff-marker">{{ line.type === 'add' ? '+' : (line.type === 'remove' ? '-' : ' ') }}</span>
<span class="diff-content">{{ subLine }}</span>
</div>
</template>
<div v-if="file.patch_truncated" class="approval-note">内容过长已截断展示</div>
</div>
</template>
@ -126,14 +92,58 @@
</div>
<footer class="versioning-footer">
<div v-if="needWorkspaceMatch && workspaceMatched === false" class="workspace-mismatch-tip">
当前对话所属工作区与当前工作区不一致无法执行回溯
<div class="restore-options">
<div class="restore-option-group">
<span class="restore-option-label">回溯范围</span>
<div class="restore-option-buttons">
<button
type="button"
class="restore-option-btn"
:class="{ active: trackingMode === 'conversation_only' }"
:disabled="restoring"
@click="$emit('update:tracking-mode', 'conversation_only')"
>
仅回溯对话
</button>
<button
type="button"
class="restore-option-btn"
:class="{ active: trackingMode === 'workspace_and_conversation', disabled: !hostMode }"
:disabled="restoring || !hostMode"
@click="$emit('update:tracking-mode', 'workspace_and_conversation')"
>
回溯对话和工作区
</button>
</div>
</div>
<div class="restore-option-group">
<span class="restore-option-label">回溯模式</span>
<div class="restore-option-buttons">
<button
type="button"
class="restore-option-btn"
:class="{ active: restoreMode === 'overwrite' }"
:disabled="restoring"
@click="$emit('update:restore-mode', 'overwrite')"
>
覆盖当前对话
</button>
<button
type="button"
class="restore-option-btn"
:class="{ active: restoreMode === 'copy' }"
:disabled="restoring"
@click="$emit('update:restore-mode', 'copy')"
>
复制对话
</button>
</div>
</div>
</div>
<span class="restore-mode-label">回溯模式覆盖当前对话</span>
<button
type="button"
class="confirm-btn"
:disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring || (needWorkspaceMatch && workspaceMatched === false)"
:disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring"
@click="$emit('confirm')"
>
{{ restoring ? '回溯中...' : '确认回溯' }}
@ -149,7 +159,6 @@ import { computed, ref, watch } from 'vue';
const props = defineProps<{
hostMode: boolean;
enabled: boolean;
trackingMode: string;
loading: boolean;
items: any[];
selectedSeq: number | null;
@ -157,7 +166,7 @@ const props = defineProps<{
detailLoading: boolean;
restoring: boolean;
restoreMode: string;
workspaceMatched?: boolean;
trackingMode: string;
iconStyle?: (key: string) => Record<string, string>;
}>();
@ -165,40 +174,14 @@ const emit = defineEmits([
'close',
'refresh',
'toggle-enabled',
'update:tracking-mode',
'select',
'confirm',
'update:restore-mode'
'update:restore-mode',
'update:tracking-mode'
]);
const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
const needWorkspaceMatch = computed(() => String(props.trackingMode || '') !== 'conversation_only');
const scopeOptions = computed(() => [
{ value: 'workspace_and_conversation', label: '管理工作区和对话', disabled: !props.hostMode },
{ value: 'conversation_only', label: '仅管理对话', disabled: false }
]);
const trackingModeLabel = computed(() => {
const found = scopeOptions.value.find((o) => o.value === props.trackingMode);
return found ? found.label : '仅管理对话';
});
const scopeMenuOpen = ref(false);
const toggleScopeMenu = () => {
if (!props.hostMode || props.loading || props.restoring) return;
scopeMenuOpen.value = !scopeMenuOpen.value;
};
const closeScopeMenu = () => {
scopeMenuOpen.value = false;
};
const selectScope = (value: string) => {
scopeMenuOpen.value = false;
if (value === props.trackingMode) return;
emit('update:tracking-mode', value);
};
const orderedItems = computed(() => {
const src = Array.isArray(props.items) ? props.items : [];
return [...src].sort((a: any, b: any) => Number(a?.seq || 0) - Number(b?.seq || 0));
@ -232,6 +215,21 @@ const formatTime = (value: string) => {
return value;
}
};
const statusLabel = (status: string): string => {
const map: Record<string, string> = {
added: 'A',
modified: 'M',
deleted: 'D',
renamed: 'R'
};
return map[String(status || '').toLowerCase()] || String(status || '').toUpperCase().slice(0, 1);
};
const splitDiffContent = (content: any): string[] => {
if (content === null || content === undefined) return [''];
return String(content).split('\n');
};
</script>
<style scoped>
@ -523,7 +521,7 @@ const formatTime = (value: string) => {
/* ===== 主体:左右分栏靠竖线 ===== */
.versioning-body {
display: grid;
grid-template-columns: 360px 1fr;
grid-template-columns: 360px minmax(0, 1fr);
min-height: 0;
flex: 1 1 auto;
}
@ -635,6 +633,8 @@ const formatTime = (value: string) => {
.checkpoint-detail {
padding: 14px 16px;
min-width: 0;
overflow-x: hidden;
}
.checkpoint-detail h4 {
@ -653,6 +653,8 @@ const formatTime = (value: string) => {
.file-list {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
}
/* 文件行:固定高度,靠虚线分隔 */
@ -703,25 +705,74 @@ const formatTime = (value: string) => {
/* ===== 底部:固定高度,靠分隔线 ===== */
.versioning-footer {
flex: 0 0 auto;
height: 60px;
padding: 0 18px;
min-height: 60px;
padding: 12px 18px;
border-top: 1px solid var(--claude-border);
display: flex;
align-items: center;
gap: 12px;
gap: 16px;
justify-content: flex-end;
}
.restore-mode-label {
font-size: 13px;
color: var(--claude-text-secondary);
.restore-options {
display: flex;
align-items: center;
gap: 20px;
margin-right: auto;
}
.workspace-mismatch-tip {
margin-right: auto;
color: var(--claude-warning);
.restore-option-group {
display: flex;
align-items: center;
gap: 10px;
}
.restore-option-label {
font-size: 13px;
font-weight: 600;
color: var(--claude-text-secondary);
white-space: nowrap;
}
.restore-option-buttons {
display: flex;
align-items: center;
gap: 6px;
}
.restore-option-btn {
height: 32px;
padding: 0 12px;
border: 1px solid var(--claude-border);
border-radius: 9px;
background: transparent;
color: var(--claude-text);
font-size: 13px;
cursor: pointer;
white-space: nowrap;
transition:
background 140ms ease,
border-color 140ms ease,
color 140ms ease;
}
.restore-option-btn:hover:not(:disabled) {
background: var(--theme-tab-active);
}
.restore-option-btn.active {
border-color: var(--claude-accent);
background: var(--claude-accent);
color: var(--on-accent);
}
.restore-option-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.restore-option-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.confirm-btn {
@ -752,6 +803,9 @@ const formatTime = (value: string) => {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.tool-result-diff.scroll-area {
@ -760,11 +814,28 @@ const formatTime = (value: string) => {
border-radius: 8px;
padding: 8px;
background: var(--theme-surface-soft);
overflow-x: auto;
}
.diff-line {
display: grid;
grid-template-columns: 1.5ch minmax(0, 1fr);
column-gap: 6px;
padding: 2px 4px;
margin: 0;
align-items: start;
white-space: pre;
min-width: 0;
}
.diff-marker {
color: var(--claude-text-secondary);
text-align: center;
user-select: none;
}
.diff-content {
min-width: 0;
white-space: pre-wrap;
word-wrap: break-word;
}

View File

@ -949,6 +949,81 @@
></path></svg></span
></label>
<div class="settings-section-divider">
<span class="settings-section-divider__label">版本控制</span>
</div>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">新对话默认开启版本控制</span
><span class="settings-row-desc"
>创建新对话时自动启用可在输入栏下方手动关闭</span
></span
><input
type="checkbox"
:checked="form.versioning_enabled_by_default"
@change="
personalization.updateField({
key: 'versioning_enabled_by_default',
value: $event.target.checked
})
" /><span class="fancy-check" aria-hidden="true"
><svg viewBox="0 0 64 64">
<path
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
pathLength="575.0541381835938"
class="fancy-path"
></path></svg></span
></label>
<div class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">文件备份方式</span
><span class="settings-row-desc"
>浅备份只记录 AI 编辑的文件完全备份会保存整个工作区快照</span
></span
>
<div
class="settings-select-wrap"
:class="{ open: activeDropdown === 'versioning-backup-mode' }"
@click.stop
>
<button
type="button"
class="settings-select-button"
@click="toggleDropdown('versioning-backup-mode')"
>
{{ versioningBackupModeLabel }}
<span class="select-chevron" aria-hidden="true"></span>
</button>
<div
:class="['settings-floating-menu', { dark: activeTheme === 'dark' }]"
:style="activeDropdown ? floatingMenuStyle : undefined"
>
<button
type="button"
class="settings-menu-option"
:class="{ selected: form.versioning_backup_mode === 'shallow' }"
@click="selectVersioningBackupMode('shallow')"
>
<strong>浅备份</strong
><span>速度快只回溯被编辑过的文件</span
><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
<button
type="button"
class="settings-menu-option"
:class="{ selected: form.versioning_backup_mode === 'full' }"
@click="selectVersioningBackupMode('full')"
>
<strong>完全备份</strong
><span>完整工作区快照首次创建可能较慢</span
><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
</div>
</div>
</div>
<div class="settings-section-divider">
<span class="settings-section-divider__label">目标模式</span>
</div>
@ -2047,6 +2122,11 @@ const permissionModeLabel = computed(() => {
);
});
const versioningBackupModeLabel = computed(() => {
if (form.value.versioning_backup_mode === 'full') return '完全备份';
return '浅备份';
});
const imageCompressionLabel = computed(() => {
return (
imageCompressionOptions.find((option) => option.id === form.value.image_compression)?.label ||
@ -2463,6 +2543,11 @@ const selectDefaultPermissionMode = (value: PermissionModeValue) => {
closeDropdown();
};
const selectVersioningBackupMode = (value: 'shallow' | 'full') => {
personalization.setVersioningBackupMode(value);
closeDropdown();
};
const selectCommunicationStyle = (value: 'default' | 'human_like' | 'auto') => {
setCommunicationStyle(value);
closeDropdown();

View File

@ -8,6 +8,7 @@ type RunMode = 'fast' | 'thinking' | 'deep';
type PermissionMode = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
type CommunicationStyle = 'default' | 'human_like' | 'auto';
type ConversationContinuity = 'low' | 'medium' | 'high';
type VersioningBackupMode = 'shallow' | 'full';
interface PersonalForm {
enabled: boolean;
@ -41,6 +42,8 @@ interface PersonalForm {
disabled_tool_categories: string[];
default_run_mode: RunMode | null;
default_permission_mode: PermissionMode;
versioning_enabled_by_default: boolean;
versioning_backup_mode: VersioningBackupMode;
versioning_restore_mode: 'overwrite';
default_model: string | null;
image_compression: string;
@ -159,6 +162,8 @@ const defaultForm = (): PersonalForm => ({
disabled_tool_categories: [],
default_run_mode: null,
default_permission_mode: 'unrestricted',
versioning_enabled_by_default: true,
versioning_backup_mode: 'shallow',
versioning_restore_mode: 'overwrite',
default_model: null,
image_compression: 'original',
@ -368,6 +373,9 @@ export const usePersonalizationStore = defineStore('personalization', {
data.default_permission_mode === 'unrestricted'
? data.default_permission_mode
: 'unrestricted',
versioning_enabled_by_default: data.versioning_enabled_by_default !== false,
versioning_backup_mode:
data.versioning_backup_mode === 'full' ? 'full' : 'shallow',
versioning_restore_mode: 'overwrite',
default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel,
image_compression:
@ -754,6 +762,15 @@ export const usePersonalizationStore = defineStore('personalization', {
this.clearFeedback();
this.scheduleAutoSave();
},
setVersioningBackupMode(mode: VersioningBackupMode) {
const target: VersioningBackupMode = mode === 'full' ? 'full' : 'shallow';
this.form = {
...this.form,
versioning_backup_mode: target
};
this.clearFeedback();
this.scheduleAutoSave();
},
setDefaultModel(model: string | null) {
const modelStore = useModelStore();
const allowed = new Set((modelStore.models || []).map((m) => m.key));

View File

@ -19,6 +19,11 @@ except ImportError:
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:
"""对话元数据"""
@ -63,6 +68,9 @@ class CrudMixin:
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 []
@ -121,14 +129,22 @@ class CrudMixin:
}
# 保存对话文件
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:

View File

@ -19,6 +19,11 @@ except ImportError:
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:
"""对话元数据"""
@ -123,10 +128,13 @@ class IndexMixin:
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:
@ -156,19 +164,29 @@ class IndexMixin:
"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())
for file_path in self._iter_conversation_files():
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} 未出现在索引中,将重建索引。")
return True
return False
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:
"""加载对话索引,可选地在缺失时自动重建(可限制重建条数)"""
@ -235,23 +253,59 @@ class IndexMixin:
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

View File

@ -9,6 +9,7 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
import time
try:
from config import DATA_DIR, HOST_WORKSPACES_FILE
except ImportError:
@ -19,6 +20,11 @@ except ImportError:
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:
"""对话元数据"""
@ -53,6 +59,9 @@ class ListSearchMixin:
Returns:
Dict: 包含对话列表和统计信息
"""
t0 = time.perf_counter()
if perf_log:
perf_log("get_conversation_list enter", extra={"limit": limit, "offset": offset, "non_empty": non_empty})
try:
if non_empty:
# 过滤模式:全量加载后剔除空对话,再在过滤结果上分页,
@ -102,6 +111,9 @@ class ListSearchMixin:
"status": metadata.get("status", "active")
})
elapsed_ms = (time.perf_counter() - t0) * 1000
if perf_log:
perf_log("get_conversation_list done", elapsed_ms=elapsed_ms, extra={"result_count": len(result), "total": total})
return {
"conversations": result,
"total": total,
@ -110,6 +122,9 @@ class ListSearchMixin:
"has_more": offset + limit < total
}
except Exception as e:
elapsed_ms = (time.perf_counter() - t0) * 1000
if perf_log:
perf_log("get_conversation_list error", elapsed_ms=elapsed_ms, extra={"error": str(e)})
print(f"⌘ 获取对话列表失败: {e}")
return {
"conversations": [],

50
utils/perf_log.py Normal file
View File

@ -0,0 +1,50 @@
"""Simple performance log helper for debugging latency issues."""
from __future__ import annotations
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
PERF_LOG_PATH = Path("/Users/jojo/Desktop/agents/正在修复中/agents/logs/perf_debug.log")
def perf_log(label: str, elapsed_ms: Optional[float] = None, extra: Optional[Dict[str, Any]] = None) -> None:
"""Append a timestamped performance entry to the perf log file."""
try:
PERF_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
now = datetime.now().isoformat()
parts = [now, label]
if elapsed_ms is not None:
parts.append(f"elapsed_ms={elapsed_ms:.2f}")
if extra:
parts.append(str(extra))
line = " | ".join(parts) + "\n"
with open(PERF_LOG_PATH, "a", encoding="utf-8") as fh:
fh.write(line)
except Exception:
pass
class PerfTimer:
"""Context manager / helper to measure and log elapsed time."""
def __init__(self, label: str, extra: Optional[Dict[str, Any]] = None):
self.label = label
self.extra = extra or {}
self.start: Optional[float] = None
def __enter__(self) -> "PerfTimer":
self.start = time.perf_counter()
perf_log(f"START {self.label}", extra=self.extra)
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
elapsed = (time.perf_counter() - self.start) * 1000 if self.start else 0.0
perf_log(f"END {self.label}", elapsed_ms=elapsed, extra=self.extra)
def mark(self, sub_label: str, extra: Optional[Dict[str, Any]] = None) -> None:
elapsed = (time.perf_counter() - self.start) * 1000 if self.start else 0.0
merged = {**self.extra, **(extra or {})}
perf_log(f"MARK {self.label}::{sub_label}", elapsed_ms=elapsed, extra=merged)