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:
parent
7863e497d8
commit
478d24c4dd
@ -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"])
|
||||
|
||||
|
||||
@ -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版本)
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
|
||||
497
modules/shallow_versioning.py
Normal file
497
modules/shallow_versioning.py
Normal 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]
|
||||
@ -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 hash;index 为空时返回空树对象哈希。"""
|
||||
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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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_id(manager 按路径构造)。
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
50
utils/perf_log.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user