diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 2ca7b53..d749574 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -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"]) diff --git a/core/web_terminal.py b/core/web_terminal.py index 65996bd..dc24349 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -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版本) diff --git a/modules/file_manager/crud_mixin.py b/modules/file_manager/crud_mixin.py index 08491ec..36752a5 100644 --- a/modules/file_manager/crud_mixin.py +++ b/modules/file_manager/crud_mixin.py @@ -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)} diff --git a/modules/file_manager/replace_mixin.py b/modules/file_manager/replace_mixin.py index ba5c4bb..f49a053 100644 --- a/modules/file_manager/replace_mixin.py +++ b/modules/file_manager/replace_mixin.py @@ -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 diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 3d336d7..520db11 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -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")) diff --git a/modules/shallow_versioning.py b/modules/shallow_versioning.py new file mode 100644 index 0000000..4dfd9c8 --- /dev/null +++ b/modules/shallow_versioning.py @@ -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] diff --git a/modules/versioning_manager.py b/modules/versioning_manager.py index 03d5f75..ff3b43e 100644 --- a/modules/versioning_manager.py +++ b/modules/versioning_manager.py @@ -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, diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 91f1f31..38d3ac6 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -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: diff --git a/server/conversation.py b/server/conversation.py index 0be40cb..779a456 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -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//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, } }) diff --git a/static/src/App.vue b/static/src/App.vue index fe4fd4d..42ca021 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -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" /> diff --git a/static/src/app/methods/conversation/action.ts b/static/src/app/methods/conversation/action.ts index 98f8c43..80cdbdf 100644 --- a/static/src/app/methods/conversation/action.ts +++ b/static/src/app/methods/conversation/action.ts @@ -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) { diff --git a/static/src/app/methods/conversation/load.ts b/static/src/app/methods/conversation/load.ts index c75c88c..7736aaf 100644 --- a/static/src/app/methods/conversation/load.ts +++ b/static/src/app/methods/conversation/load.ts @@ -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(); diff --git a/static/src/app/methods/message/send.ts b/static/src/app/methods/message/send.ts index 23450dc..e8fdc76 100644 --- a/static/src/app/methods/message/send.ts +++ b/static/src/app/methods/message/send.ts @@ -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; + } } } diff --git a/static/src/app/methods/versioning.ts b/static/src/app/methods/versioning.ts index 6e542cc..e89db97 100644 --- a/static/src/app/methods/versioning.ts +++ b/static/src/app/methods/versioning.ts @@ -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; - } - } }; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 6b6238b..1f93721 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -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', diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts index 6f1999a..4929f6f 100644 --- a/static/src/app/watchers.ts +++ b/static/src/app/watchers.ts @@ -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 }); diff --git a/static/src/components/chat/actions/ToolAction.vue b/static/src/components/chat/actions/ToolAction.vue index c364abc..d5003ea 100644 --- a/static/src/components/chat/actions/ToolAction.vue +++ b/static/src/components/chat/actions/ToolAction.vue @@ -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 = '
'; html += `
路径:${escapeHtml(path)}
`; @@ -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 = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; - html += `
替换组数:${(result.replacement_groups ?? replacements.length) || '无'}
`; + html += `
替换组数:${(result.replacement_groups ?? details.length) || '无'}
`; html += `
找到:${foundCount ?? '无'}处
`; html += `
替换:${replacedCount ?? '无'}处
`; 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 += '
'; - 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 += `
${escapeHtml(lineNoText)}${escapeHtml(marker)}${escapeHtml(text)}
`; }; 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) diff --git a/static/src/components/chat/actions/toolRenderers.ts b/static/src/components/chat/actions/toolRenderers.ts index d20f3e6..a127f6b 100644 --- a/static/src/components/chat/actions/toolRenderers.ts +++ b/static/src/components/chat/actions/toolRenderers.ts @@ -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 = '
'; html += `
路径:${escapeHtml(path)}
`; @@ -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 = '
'; html += `
路径:${escapeHtml(path)}
`; html += `
状态:${status}
`; - html += `
替换组数:${(result.replacement_groups ?? replacements.length) || '无'}
`; + html += `
替换组数:${(result.replacement_groups ?? details.length) || '无'}
`; html += `
找到:${foundCount ?? '无'}处
`; html += `
替换:${replacedCount ?? '无'}处
`; 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 += '
'; - 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 += `
${escapeHtml(lineNoText)}${escapeHtml(marker)}${escapeHtml(text)}
`; }; 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) diff --git a/static/src/components/overlay/VersioningDialog.vue b/static/src/components/overlay/VersioningDialog.vue index 0235015..10af0fc 100644 --- a/static/src/components/overlay/VersioningDialog.vue +++ b/static/src/components/overlay/VersioningDialog.vue @@ -14,43 +14,6 @@ {{ enabled ? '开启' : '关闭' }} -
- 管理范围 -
- -
- -
-
-
+ +
+
+
+ 回溯模式 +
+ + +
+
- 回溯模式:覆盖当前对话