feat: complete conversation versioning UX and restore workflow
This commit is contained in:
parent
34a6577f3a
commit
6379f8c729
@ -71,6 +71,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
|
||||
"deep_compress_trigger_tokens": None,
|
||||
"silent_tool_disable": False, # 禁用工具时不向模型插入提示
|
||||
"enhanced_tool_display": True, # 增强工具显示
|
||||
"versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
@ -233,6 +234,8 @@ def sanitize_personalization_payload(
|
||||
elif base.get("default_permission_mode") not in ALLOWED_PERMISSION_MODES:
|
||||
base["default_permission_mode"] = "unrestricted"
|
||||
|
||||
base["versioning_restore_mode"] = "overwrite"
|
||||
|
||||
# 默认模型
|
||||
chosen_model = data.get("default_model", base.get("default_model"))
|
||||
if isinstance(chosen_model, str) and chosen_model in allowed_models:
|
||||
|
||||
746
modules/versioning_manager.py
Normal file
746
modules/versioning_manager.py
Normal file
@ -0,0 +1,746 @@
|
||||
"""Conversation-scoped hidden git versioning for host workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from collections import deque
|
||||
|
||||
|
||||
class VersioningError(RuntimeError):
|
||||
"""Raised when hidden git versioning fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class VersioningPaths:
|
||||
save_root: Path
|
||||
git_dir: Path
|
||||
meta_path: Path
|
||||
inputs_path: Path
|
||||
snapshots_dir: Path
|
||||
|
||||
|
||||
class ConversationVersioningManager:
|
||||
"""Manage hidden git snapshots bound to a conversation."""
|
||||
|
||||
def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str):
|
||||
self.project_path = Path(project_path).expanduser().resolve()
|
||||
self.data_dir = Path(data_dir).expanduser().resolve()
|
||||
self.conversation_id = str(conversation_id or "").strip()
|
||||
if not self.conversation_id:
|
||||
raise VersioningError("缺少 conversation_id")
|
||||
self.paths = VersioningPaths(
|
||||
save_root=(self.data_dir / "save" / self.conversation_id).resolve(),
|
||||
git_dir=(self.data_dir / "save" / self.conversation_id / "git").resolve(),
|
||||
meta_path=(self.data_dir / "save" / self.conversation_id / "meta.json").resolve(),
|
||||
inputs_path=(self.data_dir / "save" / self.conversation_id / "inputs.jsonl").resolve(),
|
||||
snapshots_dir=(self.data_dir / "save" / self.conversation_id / "snapshots").resolve(),
|
||||
)
|
||||
|
||||
# ----------------------------
|
||||
# low-level helpers
|
||||
# ----------------------------
|
||||
def _run_git(
|
||||
self,
|
||||
args: List[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
timeout_seconds: int = 180,
|
||||
) -> Tuple[bool, str, str]:
|
||||
git_bin = shutil.which("git")
|
||||
if not git_bin:
|
||||
raise VersioningError("未检测到 git 可执行文件")
|
||||
cmd = [
|
||||
git_bin,
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
f"--git-dir={self.paths.git_dir}",
|
||||
f"--work-tree={self.project_path}",
|
||||
*args,
|
||||
]
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise VersioningError(f"git 执行超时: {' '.join(args)}") from exc
|
||||
ok = completed.returncode == 0
|
||||
stdout = (completed.stdout or "").strip()
|
||||
stderr = (completed.stderr or "").strip()
|
||||
if check and not ok:
|
||||
raise VersioningError(stderr or stdout or f"git 命令失败: {' '.join(args)}")
|
||||
return ok, stdout, stderr
|
||||
|
||||
def _ensure_exclude_paths(self) -> None:
|
||||
"""Avoid recursive tracking if save root is inside work tree."""
|
||||
try:
|
||||
rel_save = self.paths.save_root.relative_to(self.project_path)
|
||||
except ValueError:
|
||||
return
|
||||
info_exclude = self.paths.git_dir / "info" / "exclude"
|
||||
info_exclude.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = ""
|
||||
if info_exclude.exists():
|
||||
try:
|
||||
existing = info_exclude.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
existing = ""
|
||||
marker = str(rel_save).replace("\\", "/").rstrip("/")
|
||||
if not marker:
|
||||
return
|
||||
line = f"{marker}/"
|
||||
if line not in existing.splitlines():
|
||||
with info_exclude.open("a", encoding="utf-8") as fh:
|
||||
if existing and not existing.endswith("\n"):
|
||||
fh.write("\n")
|
||||
fh.write(line + "\n")
|
||||
|
||||
def _stage_all(self) -> None:
|
||||
self._ensure_exclude_paths()
|
||||
# Stage everything under work tree; exclusions are handled via info/exclude.
|
||||
self._run_git(["add", "-A", "--", "."])
|
||||
|
||||
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 _is_clean(self) -> bool:
|
||||
ok, out, _ = self._run_git(["status", "--porcelain"], check=False)
|
||||
if not ok:
|
||||
return False
|
||||
return not bool(out.strip())
|
||||
|
||||
# ----------------------------
|
||||
# metadata / records
|
||||
# ----------------------------
|
||||
def load_meta(self) -> Dict[str, Any]:
|
||||
if not self.paths.meta_path.exists():
|
||||
return {
|
||||
"enabled": False,
|
||||
"mode": "overwrite",
|
||||
"last_commit": None,
|
||||
"last_input_seq": 0,
|
||||
"updated_at": None,
|
||||
}
|
||||
try:
|
||||
return json.loads(self.paths.meta_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
return {
|
||||
"enabled": False,
|
||||
"mode": "overwrite",
|
||||
"last_commit": None,
|
||||
"last_input_seq": 0,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = dict(meta or {})
|
||||
payload["updated_at"] = datetime.now().isoformat()
|
||||
self.paths.meta_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.paths.meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return payload
|
||||
|
||||
def _read_inputs(self) -> List[Dict[str, Any]]:
|
||||
if not self.paths.inputs_path.exists():
|
||||
return []
|
||||
rows: List[Dict[str, Any]] = []
|
||||
try:
|
||||
for raw in self.paths.inputs_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(row, dict):
|
||||
rows.append(self._normalize_checkpoint_row(row))
|
||||
except OSError:
|
||||
return []
|
||||
return rows
|
||||
|
||||
def _append_input(self, row: Dict[str, Any]) -> None:
|
||||
self.paths.inputs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.paths.inputs_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
|
||||
def _write_inputs(self, rows: List[Dict[str, Any]]) -> None:
|
||||
self.paths.inputs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = "\n".join(json.dumps(row, ensure_ascii=False) for row in (rows or []))
|
||||
if content:
|
||||
content += "\n"
|
||||
self.paths.inputs_path.write_text(content, encoding="utf-8")
|
||||
|
||||
def _decode_git_path(self, raw_path: Any) -> str:
|
||||
text = str(raw_path or "")
|
||||
stripped = text.strip()
|
||||
if len(stripped) >= 2 and stripped[0] == '"' and stripped[-1] == '"':
|
||||
body = stripped[1:-1]
|
||||
try:
|
||||
decoded = bytes(body, "utf-8").decode("unicode_escape")
|
||||
except Exception:
|
||||
decoded = body
|
||||
try:
|
||||
return decoded.encode("latin-1").decode("utf-8")
|
||||
except Exception:
|
||||
return decoded
|
||||
return text
|
||||
|
||||
def _normalize_checkpoint_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = dict(row or {})
|
||||
files = payload.get("files")
|
||||
if isinstance(files, list):
|
||||
normalized_files: List[Dict[str, Any]] = []
|
||||
for file_item in files:
|
||||
if not isinstance(file_item, dict):
|
||||
continue
|
||||
item = dict(file_item)
|
||||
item["path"] = self._decode_git_path(item.get("path"))
|
||||
if item.get("old_path") is not None:
|
||||
item["old_path"] = self._decode_git_path(item.get("old_path"))
|
||||
normalized_files.append(item)
|
||||
payload["files"] = normalized_files
|
||||
return payload
|
||||
|
||||
def _write_snapshot(self, seq: int, payload: Dict[str, Any]) -> str:
|
||||
self.paths.snapshots_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = self.paths.snapshots_dir / f"{int(seq)}.json"
|
||||
target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return str(target.relative_to(self.paths.save_root))
|
||||
|
||||
def get_checkpoint_snapshot(self, seq: int) -> Optional[Dict[str, Any]]:
|
||||
row = self.get_checkpoint(seq)
|
||||
if not row:
|
||||
return None
|
||||
rel = row.get("snapshot_file")
|
||||
if not rel:
|
||||
return None
|
||||
path = (self.paths.save_root / str(rel)).resolve()
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8")) or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ----------------------------
|
||||
# repo lifecycle
|
||||
# ----------------------------
|
||||
def ensure_repo(self) -> Dict[str, Any]:
|
||||
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():
|
||||
self.paths.git_dir.mkdir(parents=True, exist_ok=True)
|
||||
if not (self.paths.git_dir / "HEAD").exists():
|
||||
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)
|
||||
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()
|
||||
return {"ok": True, "head": head}
|
||||
|
||||
def set_enabled(self, enabled: bool, mode: Optional[str] = None) -> Dict[str, Any]:
|
||||
meta = self.load_meta()
|
||||
if enabled:
|
||||
self.ensure_repo()
|
||||
meta["enabled"] = bool(enabled)
|
||||
meta["mode"] = "overwrite"
|
||||
meta["last_commit"] = self._get_head_commit()
|
||||
saved = self.save_meta(meta)
|
||||
return saved
|
||||
|
||||
def ensure_initial_checkpoint(
|
||||
self,
|
||||
*,
|
||||
workspace_path: Optional[str] = None,
|
||||
conversation_snapshot: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Ensure a visible seq=0 checkpoint exists after enabling versioning.
|
||||
"""
|
||||
self.ensure_repo()
|
||||
existing = self.get_checkpoint(0)
|
||||
if existing:
|
||||
return {"created": False, "row": existing, "reason": "already_exists"}
|
||||
|
||||
current_head = self._get_head_commit()
|
||||
if not current_head:
|
||||
raise VersioningError("创建初始版本点失败:未获取到 commit")
|
||||
|
||||
row = {
|
||||
"seq": 0,
|
||||
"message": "版本管理开启(初始状态)",
|
||||
"message_preview": "版本管理开启(初始状态)",
|
||||
"message_index": -1,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"workspace_path": str(workspace_path or ""),
|
||||
"commit": current_head,
|
||||
"parent_commit": None,
|
||||
"insertions": 0,
|
||||
"deletions": 0,
|
||||
"files_changed": 0,
|
||||
"files": [],
|
||||
"changed": False,
|
||||
"run_status": "initial",
|
||||
}
|
||||
if isinstance(conversation_snapshot, dict):
|
||||
row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot)
|
||||
self._append_input(row)
|
||||
meta = self.load_meta()
|
||||
meta["last_commit"] = current_head
|
||||
if int(meta.get("last_input_seq") or 0) < 0:
|
||||
meta["last_input_seq"] = 0
|
||||
self.save_meta(meta)
|
||||
return {"created": True, "row": row, "reason": "created"}
|
||||
|
||||
def ensure_baseline_for_first_input(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Ensure the baseline commit reflects current workspace before first manual input checkpoint.
|
||||
|
||||
This is intentionally hidden from checkpoint list (no seq row created).
|
||||
"""
|
||||
self.ensure_repo()
|
||||
meta = self.load_meta()
|
||||
if self.get_checkpoint(0):
|
||||
return {
|
||||
"created": False,
|
||||
"skipped": True,
|
||||
"reason": "initial_checkpoint_exists",
|
||||
"head": self._get_head_commit(),
|
||||
}
|
||||
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(),
|
||||
}
|
||||
|
||||
previous_head = self._get_head_commit()
|
||||
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
|
||||
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,
|
||||
"has_changes": has_changes,
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# diff / checkpoint
|
||||
# ----------------------------
|
||||
def _parse_numstat(self, text: str) -> Dict[str, Dict[str, int]]:
|
||||
stats: Dict[str, Dict[str, int]] = {}
|
||||
for line in (text or "").splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
ins_raw, del_raw = parts[0], parts[1]
|
||||
path = self._decode_git_path(parts[2])
|
||||
ins = 0 if ins_raw == "-" else int(ins_raw or 0)
|
||||
dele = 0 if del_raw == "-" else int(del_raw or 0)
|
||||
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,
|
||||
)
|
||||
|
||||
numstats = self._parse_numstat(numstat_text)
|
||||
files: List[Dict[str, Any]] = []
|
||||
total_insertions = 0
|
||||
total_deletions = 0
|
||||
for raw in (status_text or "").splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
code = parts[0] if parts else ""
|
||||
path = self._decode_git_path(parts[-1] if len(parts) >= 2 else "")
|
||||
old_path = self._decode_git_path(parts[1]) if code.startswith("R") and len(parts) >= 3 else None
|
||||
file_stat = numstats.get(path, {"insertions": 0, "deletions": 0})
|
||||
ins = int(file_stat.get("insertions") or 0)
|
||||
dele = int(file_stat.get("deletions") or 0)
|
||||
total_insertions += ins
|
||||
total_deletions += dele
|
||||
files.append(
|
||||
{
|
||||
"path": path,
|
||||
"status": code,
|
||||
"old_path": old_path,
|
||||
"insertions": ins,
|
||||
"deletions": dele,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"insertions": total_insertions,
|
||||
"deletions": total_deletions,
|
||||
"files_changed": len(files),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
def create_checkpoint(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
message_index: int,
|
||||
workspace_path: Optional[str] = None,
|
||||
conversation_snapshot: Optional[Dict[str, Any]] = None,
|
||||
run_status: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
self.ensure_repo()
|
||||
previous_head = self._get_head_commit()
|
||||
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(self.load_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()
|
||||
if not current_head:
|
||||
raise VersioningError("创建版本快照失败:未获取到 commit")
|
||||
|
||||
diff_summary = (
|
||||
self._build_diff_summary(previous_head, current_head)
|
||||
if current_head != previous_head
|
||||
else {"insertions": 0, "deletions": 0, "files_changed": 0, "files": []}
|
||||
)
|
||||
|
||||
meta = self.load_meta()
|
||||
seq = int(meta.get("last_input_seq") or 0) + 1
|
||||
row = {
|
||||
"seq": seq,
|
||||
"message": (message or "").strip(),
|
||||
"message_preview": ((message or "").strip().splitlines() or [""])[0][:200],
|
||||
"message_index": int(message_index),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"workspace_path": str(workspace_path or ""),
|
||||
"commit": current_head,
|
||||
"parent_commit": 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),
|
||||
"files": diff_summary.get("files") or [],
|
||||
"changed": bool(current_head != previous_head),
|
||||
}
|
||||
if isinstance(conversation_snapshot, dict):
|
||||
row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot)
|
||||
if run_status:
|
||||
row["run_status"] = str(run_status)
|
||||
self._append_input(row)
|
||||
meta["last_input_seq"] = seq
|
||||
meta["last_commit"] = current_head
|
||||
self.save_meta(meta)
|
||||
return row
|
||||
|
||||
def list_checkpoints(self) -> List[Dict[str, Any]]:
|
||||
rows = self._read_inputs()
|
||||
rows.sort(key=lambda item: int(item.get("seq") or 0), reverse=True)
|
||||
return rows
|
||||
|
||||
def get_checkpoint(self, seq: int) -> Optional[Dict[str, Any]]:
|
||||
for row in self._read_inputs():
|
||||
if int(row.get("seq") or 0) == int(seq):
|
||||
return row
|
||||
return None
|
||||
|
||||
def _collect_file_patch_lines(
|
||||
self,
|
||||
*,
|
||||
parent_commit: Optional[str],
|
||||
commit: str,
|
||||
path: str,
|
||||
old_path: Optional[str] = None,
|
||||
max_lines: int = 600,
|
||||
) -> Dict[str, Any]:
|
||||
target_paths: List[str] = []
|
||||
for candidate in [old_path, path]:
|
||||
value = str(candidate or "").strip()
|
||||
if value and value not in target_paths:
|
||||
target_paths.append(value)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
target_paths=target_paths,
|
||||
max_lines=max_lines,
|
||||
)
|
||||
lines = fallback.get("lines") or []
|
||||
truncated = bool(fallback.get("truncated")) or truncated
|
||||
return {"lines": lines, "truncated": truncated}
|
||||
|
||||
def _collect_file_patch_lines_from_full_diff(
|
||||
self,
|
||||
*,
|
||||
parent_commit: Optional[str],
|
||||
commit: 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,
|
||||
)
|
||||
|
||||
targets = {str(p or "").lstrip("./") for p in target_paths if str(p or "").strip()}
|
||||
chunk_lines: List[str] = []
|
||||
lines: List[Dict[str, Any]] = []
|
||||
truncated = False
|
||||
in_target_section = False
|
||||
for raw in (patch_text or "").splitlines():
|
||||
if raw.startswith("diff --git "):
|
||||
if in_target_section and chunk_lines and len(lines) < max_lines:
|
||||
parsed_chunk = self._extract_contextual_patch_lines("\n".join(chunk_lines), max_lines=max_lines - len(lines))
|
||||
lines.extend(parsed_chunk.get("lines") or [])
|
||||
truncated = truncated or bool(parsed_chunk.get("truncated"))
|
||||
chunk_lines = []
|
||||
in_target_section = False
|
||||
try:
|
||||
parts = raw.split(" ")
|
||||
a_path = self._decode_git_path((parts[2] or "").replace("a/", "", 1)).lstrip("./")
|
||||
b_path = self._decode_git_path((parts[3] or "").replace("b/", "", 1)).lstrip("./")
|
||||
if a_path in targets or b_path in targets:
|
||||
in_target_section = True
|
||||
except Exception:
|
||||
in_target_section = False
|
||||
continue
|
||||
if not in_target_section:
|
||||
continue
|
||||
chunk_lines.append(raw)
|
||||
if len(lines) >= max_lines:
|
||||
truncated = True
|
||||
break
|
||||
if in_target_section and chunk_lines and len(lines) < max_lines and not truncated:
|
||||
parsed_chunk = self._extract_contextual_patch_lines("\n".join(chunk_lines), max_lines=max_lines - len(lines))
|
||||
lines.extend(parsed_chunk.get("lines") or [])
|
||||
truncated = truncated or bool(parsed_chunk.get("truncated"))
|
||||
if len(lines) > max_lines:
|
||||
lines = lines[:max_lines]
|
||||
truncated = True
|
||||
return {"lines": lines, "truncated": truncated}
|
||||
|
||||
def get_checkpoint_detail(self, seq: int, *, include_patch: bool = True) -> Optional[Dict[str, Any]]:
|
||||
row = self.get_checkpoint(seq)
|
||||
if not row:
|
||||
return None
|
||||
payload = dict(row)
|
||||
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")
|
||||
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():
|
||||
patch = self._collect_file_patch_lines(
|
||||
parent_commit=parent_commit,
|
||||
commit=commit,
|
||||
path=str(item.get("path") or ""),
|
||||
old_path=item.get("old_path"),
|
||||
)
|
||||
item["patch_lines"] = patch.get("lines") or []
|
||||
item["patch_truncated"] = bool(patch.get("truncated"))
|
||||
else:
|
||||
item["patch_lines"] = []
|
||||
item["patch_truncated"] = False
|
||||
normalized_files.append(item)
|
||||
payload["files"] = normalized_files
|
||||
return payload
|
||||
|
||||
def get_latest_checkpoint(self) -> Optional[Dict[str, Any]]:
|
||||
rows = self.list_checkpoints()
|
||||
return rows[0] if rows else None
|
||||
|
||||
def prune_checkpoints_after(self, seq: int) -> Dict[str, Any]:
|
||||
target = int(seq)
|
||||
rows = self._read_inputs()
|
||||
if not rows:
|
||||
return {"kept": 0, "removed": 0, "max_seq": 0}
|
||||
|
||||
kept_rows = [row for row in rows if int(row.get("seq") or 0) <= target]
|
||||
removed_rows = [row for row in rows if int(row.get("seq") or 0) > target]
|
||||
|
||||
for row in removed_rows:
|
||||
rel = row.get("snapshot_file")
|
||||
if not rel:
|
||||
continue
|
||||
try:
|
||||
path = (self.paths.save_root / str(rel)).resolve()
|
||||
if path.exists() and path.is_file():
|
||||
path.unlink()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
self._write_inputs(kept_rows)
|
||||
max_seq = max((int(r.get("seq") or 0) for r in kept_rows), default=0)
|
||||
latest_kept = None
|
||||
for r in kept_rows:
|
||||
if int(r.get("seq") or 0) == max_seq:
|
||||
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()
|
||||
self.save_meta(meta)
|
||||
return {
|
||||
"kept": len(kept_rows),
|
||||
"removed": len(removed_rows),
|
||||
"max_seq": max_seq,
|
||||
"last_commit": meta.get("last_commit"),
|
||||
}
|
||||
|
||||
# ----------------------------
|
||||
# restore / status
|
||||
# ----------------------------
|
||||
def restore_to_commit(self, commit: str) -> Dict[str, Any]:
|
||||
if not commit:
|
||||
raise VersioningError("缺少 commit")
|
||||
self.ensure_repo()
|
||||
ok, _, _ = self._run_git(["cat-file", "-e", f"{commit}^{{commit}}"], check=False)
|
||||
if not ok:
|
||||
raise VersioningError("目标 commit 不存在")
|
||||
self._run_git(["reset", "--hard", commit])
|
||||
self._run_git(["clean", "-fd"])
|
||||
meta = self.load_meta()
|
||||
meta["last_restored_commit"] = commit
|
||||
meta["last_commit"] = self._get_head_commit()
|
||||
self.save_meta(meta)
|
||||
return {"success": True, "commit": commit}
|
||||
|
||||
def detect_mismatch(self, latest_commit: Optional[str], expected_workspace_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
expected = str(expected_workspace_path or "").strip()
|
||||
current = str(self.project_path)
|
||||
workspace_matched = True if not expected else (Path(expected).expanduser().resolve() == self.project_path)
|
||||
if not (self.paths.git_dir / "HEAD").exists():
|
||||
return {
|
||||
"has_repo": False,
|
||||
"head": None,
|
||||
"dirty": False,
|
||||
"mismatch": False,
|
||||
"workspace_matched": workspace_matched,
|
||||
}
|
||||
if not workspace_matched:
|
||||
return {
|
||||
"has_repo": True,
|
||||
"head": self._get_head_commit(),
|
||||
"dirty": False,
|
||||
"mismatch": False,
|
||||
"workspace_matched": False,
|
||||
"expected_workspace_path": expected,
|
||||
"current_workspace_path": current,
|
||||
}
|
||||
head = self._get_head_commit()
|
||||
dirty = not self._is_clean()
|
||||
mismatch = bool(dirty or (latest_commit and head and latest_commit != head))
|
||||
return {
|
||||
"has_repo": True,
|
||||
"head": head,
|
||||
"dirty": dirty,
|
||||
"mismatch": mismatch,
|
||||
"workspace_matched": True,
|
||||
"expected_workspace_path": expected or current,
|
||||
"current_workspace_path": current,
|
||||
}
|
||||
def _extract_contextual_patch_lines(self, patch_text: str, max_lines: int = 600) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract +/- lines plus 3 context lines before and after changed regions.
|
||||
"""
|
||||
lines: List[Dict[str, Any]] = []
|
||||
truncated = False
|
||||
pre_context: deque[str] = deque(maxlen=3)
|
||||
post_context_needed = 0
|
||||
|
||||
for raw in (patch_text or "").splitlines():
|
||||
if raw.startswith("diff --git "):
|
||||
pre_context.clear()
|
||||
post_context_needed = 0
|
||||
continue
|
||||
if raw.startswith("index ") or raw.startswith("--- ") or raw.startswith("+++ "):
|
||||
continue
|
||||
if raw.startswith("@@"):
|
||||
pre_context.clear()
|
||||
post_context_needed = 0
|
||||
continue
|
||||
|
||||
if not raw:
|
||||
prefix = " "
|
||||
content = ""
|
||||
else:
|
||||
prefix = raw[0]
|
||||
content = raw[1:] if len(raw) > 1 else ""
|
||||
|
||||
if prefix == " ":
|
||||
if post_context_needed > 0:
|
||||
lines.append({"type": "context", "content": content})
|
||||
post_context_needed -= 1
|
||||
else:
|
||||
pre_context.append(content)
|
||||
elif prefix in {"+", "-"}:
|
||||
if pre_context:
|
||||
for ctx in pre_context:
|
||||
lines.append({"type": "context", "content": ctx})
|
||||
pre_context.clear()
|
||||
lines.append({"type": "add" if prefix == "+" else "remove", "content": content})
|
||||
post_context_needed = 3
|
||||
else:
|
||||
continue
|
||||
|
||||
if len(lines) >= max_lines:
|
||||
truncated = True
|
||||
break
|
||||
|
||||
return {"lines": lines[:max_lines], "truncated": truncated}
|
||||
@ -158,12 +158,25 @@ def detect_malformed_tool_call(text):
|
||||
def process_message_task(terminal: WebTerminal, message: str, images, sender, client_sid, workspace: UserWorkspace, username: str, videos=None):
|
||||
"""在后台处理消息任务"""
|
||||
videos = videos or []
|
||||
auto_user_message_event = bool(getattr(terminal, "_auto_user_message_event", False))
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# 创建可取消的任务
|
||||
task = loop.create_task(handle_task_with_sender(terminal, workspace, message, images, sender, client_sid, username, videos))
|
||||
task = loop.create_task(
|
||||
handle_task_with_sender(
|
||||
terminal,
|
||||
workspace,
|
||||
message,
|
||||
images,
|
||||
sender,
|
||||
client_sid,
|
||||
username,
|
||||
videos,
|
||||
auto_user_message_event=auto_user_message_event,
|
||||
)
|
||||
)
|
||||
|
||||
entry = get_stop_flag(client_sid, username)
|
||||
if not isinstance(entry, dict):
|
||||
|
||||
@ -45,6 +45,7 @@ from modules.upload_security import UploadSecurityError
|
||||
from modules.user_manager import UserWorkspace
|
||||
from modules.usage_tracker import QUOTA_DEFAULTS
|
||||
from modules.sub_agent_manager import TERMINAL_STATUSES
|
||||
from modules.versioning_manager import ConversationVersioningManager, VersioningError
|
||||
from core.web_terminal import WebTerminal
|
||||
from utils.tool_result_formatter import format_tool_result_for_context
|
||||
from utils.conversation_manager import ConversationManager
|
||||
@ -132,6 +133,134 @@ from .chat_flow_tool_loop import execute_tool_calls
|
||||
from .chat_flow_stream_loop import run_streaming_attempts
|
||||
from .deep_compression import run_deep_compression
|
||||
|
||||
|
||||
def _should_skip_versioning_for_message(
|
||||
*,
|
||||
message: str,
|
||||
auto_user_message_event: bool,
|
||||
) -> bool:
|
||||
if auto_user_message_event:
|
||||
return True
|
||||
text = (message or "").strip()
|
||||
if not text:
|
||||
return True
|
||||
if text.startswith("这是一句系统自动发送的user消息"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _prepare_hidden_versioning_baseline_for_first_input(
|
||||
*,
|
||||
web_terminal,
|
||||
workspace,
|
||||
conversation_id: str,
|
||||
message: str,
|
||||
auto_user_message_event: bool,
|
||||
) -> None:
|
||||
"""Ensure first-run baseline is committed (hidden, no checkpoint row)."""
|
||||
try:
|
||||
if not bool(getattr(web_terminal, "username", None) == "host"):
|
||||
return
|
||||
if _should_skip_versioning_for_message(
|
||||
message=message,
|
||||
auto_user_message_event=auto_user_message_event,
|
||||
):
|
||||
return
|
||||
cm = getattr(getattr(web_terminal, "context_manager", None), "conversation_manager", None)
|
||||
if not cm:
|
||||
return
|
||||
conv_data = cm.load_conversation(conversation_id) or {}
|
||||
versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
|
||||
if not bool(versioning_meta.get("enabled", False)):
|
||||
return
|
||||
manager = ConversationVersioningManager(
|
||||
project_path=workspace.project_path,
|
||||
data_dir=workspace.data_dir,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
baseline = manager.ensure_baseline_for_first_input()
|
||||
debug_log(
|
||||
f"[Versioning][Baseline] conv={conversation_id} "
|
||||
f"created={baseline.get('created')} skipped={baseline.get('skipped')} "
|
||||
f"reason={baseline.get('reason')} head={baseline.get('head')}"
|
||||
)
|
||||
except VersioningError as exc:
|
||||
debug_log(f"[Versioning] 创建首轮基线失败: {exc}")
|
||||
except Exception as exc:
|
||||
debug_log(f"[Versioning] 创建首轮基线异常: {exc}")
|
||||
|
||||
|
||||
def _record_hidden_versioning_checkpoint_after_run(
|
||||
*,
|
||||
web_terminal,
|
||||
workspace,
|
||||
conversation_id: str,
|
||||
message: str,
|
||||
message_index: int,
|
||||
auto_user_message_event: bool,
|
||||
run_status: str = "completed",
|
||||
) -> None:
|
||||
"""Host-only hidden snapshot after current manual user input run finished."""
|
||||
try:
|
||||
if not bool(getattr(web_terminal, "username", None) == "host"):
|
||||
return
|
||||
if _should_skip_versioning_for_message(
|
||||
message=message,
|
||||
auto_user_message_event=auto_user_message_event,
|
||||
):
|
||||
return
|
||||
cm = getattr(getattr(web_terminal, "context_manager", None), "conversation_manager", None)
|
||||
if not cm:
|
||||
return
|
||||
conv_data = cm.load_conversation(conversation_id) or {}
|
||||
versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
|
||||
if not bool(versioning_meta.get("enabled", False)):
|
||||
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"),
|
||||
}
|
||||
|
||||
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"),
|
||||
)
|
||||
debug_log(
|
||||
f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} "
|
||||
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",
|
||||
"last_commit": row.get("commit"),
|
||||
"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:
|
||||
debug_log(f"[Versioning] 记录快照异常: {exc}")
|
||||
|
||||
|
||||
async def _dispatch_completion_user_notice(
|
||||
*,
|
||||
web_terminal,
|
||||
@ -198,7 +327,8 @@ async def _dispatch_completion_user_notice(
|
||||
sender=sender,
|
||||
client_sid=client_sid,
|
||||
username=username,
|
||||
videos=[]
|
||||
videos=[],
|
||||
auto_user_message_event=True,
|
||||
))
|
||||
await task_handle
|
||||
except Exception as inner_exc:
|
||||
@ -549,7 +679,17 @@ async def poll_background_command_completion(*, web_terminal, workspace, convers
|
||||
|
||||
debug_log("[BgCommand] 后台轮询结束")
|
||||
|
||||
async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspace, message, images, sender, client_sid, username: str, videos=None):
|
||||
async def handle_task_with_sender(
|
||||
terminal: WebTerminal,
|
||||
workspace: UserWorkspace,
|
||||
message,
|
||||
images,
|
||||
sender,
|
||||
client_sid,
|
||||
username: str,
|
||||
videos=None,
|
||||
auto_user_message_event: bool = False,
|
||||
):
|
||||
"""处理任务并发送消息 - 集成token统计版本"""
|
||||
from .extensions import socketio
|
||||
|
||||
@ -614,6 +754,13 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
user_message_index = len(getattr(web_terminal.context_manager, "conversation_history", []) or []) - 1
|
||||
except Exception:
|
||||
user_message_index = -1
|
||||
_prepare_hidden_versioning_baseline_for_first_input(
|
||||
web_terminal=web_terminal,
|
||||
workspace=workspace,
|
||||
conversation_id=conversation_id,
|
||||
message=message,
|
||||
auto_user_message_event=bool(auto_user_message_event),
|
||||
)
|
||||
|
||||
def finalize_user_work_timer():
|
||||
nonlocal user_work_finalized
|
||||
@ -649,6 +796,23 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
web_terminal.context_manager.auto_save_conversation(force=True)
|
||||
user_work_finalized = True
|
||||
|
||||
versioning_checkpoint_recorded = False
|
||||
|
||||
def finalize_run_versioning_checkpoint(run_status: str = "completed"):
|
||||
nonlocal versioning_checkpoint_recorded
|
||||
if versioning_checkpoint_recorded:
|
||||
return
|
||||
_record_hidden_versioning_checkpoint_after_run(
|
||||
web_terminal=web_terminal,
|
||||
workspace=workspace,
|
||||
conversation_id=conversation_id,
|
||||
message=message,
|
||||
message_index=user_message_index,
|
||||
auto_user_message_event=bool(auto_user_message_event),
|
||||
run_status=run_status,
|
||||
)
|
||||
versioning_checkpoint_recorded = True
|
||||
|
||||
# Skill 提示系统:检测关键词并在用户消息之后插入 system 消息
|
||||
try:
|
||||
personal_config = load_personalization_config(workspace.data_dir)
|
||||
@ -727,6 +891,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
guide_message = (deep_result.get("guide_message") or "").strip()
|
||||
if guide_message:
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("auto_deep_compress_handoff")
|
||||
await handle_task_with_sender(
|
||||
web_terminal,
|
||||
workspace,
|
||||
@ -739,6 +904,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
)
|
||||
return
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("auto_deep_compress_end")
|
||||
return
|
||||
|
||||
# === 移除:不在这里计算输入token,改为在每次API调用前计算 ===
|
||||
@ -772,6 +938,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
'error_type': 'context_overflow'
|
||||
})
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("context_overflow")
|
||||
return
|
||||
usage_percent = (current_tokens / max_context_tokens) * 100
|
||||
warned = web_terminal.context_manager.conversation_metadata.get("context_warning_sent", False)
|
||||
@ -887,6 +1054,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
'quota': quota_info
|
||||
})
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("quota_exceeded")
|
||||
return
|
||||
|
||||
tool_call_limit_label = MAX_TOTAL_TOOL_CALLS if MAX_TOTAL_TOOL_CALLS is not None else "∞"
|
||||
@ -926,6 +1094,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
)
|
||||
if stream_result.get("stopped"):
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("stopped")
|
||||
return
|
||||
|
||||
full_response = stream_result["full_response"]
|
||||
@ -1101,6 +1270,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
last_tool_call_time = tool_loop_result.get("last_tool_call_time", last_tool_call_time)
|
||||
if tool_loop_result.get("stopped"):
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("stopped")
|
||||
return
|
||||
if tool_loop_result.get("approval_rejected"):
|
||||
sender('task_stopped', {
|
||||
@ -1109,12 +1279,14 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
'conversation_id': conversation_id
|
||||
})
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("approval_rejected")
|
||||
return
|
||||
if tool_loop_result.get("deep_compressed"):
|
||||
deep_result = tool_loop_result.get("deep_result") or {}
|
||||
guide_message = (deep_result.get("guide_message") or "").strip()
|
||||
if deep_result.get("success") and guide_message:
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("deep_compress_handoff")
|
||||
await handle_task_with_sender(
|
||||
web_terminal,
|
||||
workspace,
|
||||
@ -1127,6 +1299,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
)
|
||||
return
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("deep_compress_end")
|
||||
return
|
||||
|
||||
# 标记不再是第一次迭代
|
||||
@ -1222,6 +1395,9 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
# 发送完成事件(如果有后台完成任务在运行,前端会保持等待状态)
|
||||
if not has_running_completion_jobs:
|
||||
finalize_user_work_timer()
|
||||
finalize_run_versioning_checkpoint("completed")
|
||||
else:
|
||||
finalize_run_versioning_checkpoint("waiting_background")
|
||||
sender('task_complete', {
|
||||
'total_iterations': total_iterations,
|
||||
'total_tool_calls': total_tool_calls,
|
||||
|
||||
@ -46,6 +46,7 @@ from modules.upload_security import UploadSecurityError
|
||||
from modules.user_manager import UserWorkspace
|
||||
from modules.usage_tracker import QUOTA_DEFAULTS
|
||||
from modules.sub_agent_manager import TERMINAL_STATUSES
|
||||
from modules.versioning_manager import ConversationVersioningManager, VersioningError
|
||||
from core.web_terminal import WebTerminal
|
||||
from utils.tool_result_formatter import format_tool_result_for_context
|
||||
from utils.conversation_manager import ConversationManager
|
||||
@ -147,6 +148,62 @@ def _cancel_running_tasks(username: str, workspace_id: str, timeout_seconds: flo
|
||||
return canceled, False
|
||||
|
||||
|
||||
def _normalize_conv_id(conversation_id: str) -> str:
|
||||
conv = (conversation_id or "").strip()
|
||||
if not conv:
|
||||
return conv
|
||||
return conv if conv.startswith("conv_") else f"conv_{conv}"
|
||||
|
||||
|
||||
def _is_host_mode_request(username: str) -> bool:
|
||||
return bool(session.get("host_mode")) or (username == "host")
|
||||
|
||||
|
||||
def _get_conv_versioning_manager(workspace: UserWorkspace, conversation_id: str) -> ConversationVersioningManager:
|
||||
normalized = _normalize_conv_id(conversation_id)
|
||||
return ConversationVersioningManager(
|
||||
project_path=workspace.project_path,
|
||||
data_dir=workspace.data_dir,
|
||||
conversation_id=normalized,
|
||||
)
|
||||
|
||||
|
||||
def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]:
|
||||
normalized = _normalize_conv_id(conversation_id)
|
||||
data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {}
|
||||
meta = data.get("metadata") or {}
|
||||
versioning = meta.get("versioning") or {}
|
||||
if not isinstance(versioning, dict):
|
||||
versioning = {}
|
||||
enabled = bool(versioning.get("enabled", False))
|
||||
mode = "overwrite"
|
||||
return {"enabled": enabled, "mode": mode, "conversation_id": normalized, "metadata": meta}
|
||||
|
||||
|
||||
def _update_conversation_versioning_meta(
|
||||
terminal: WebTerminal,
|
||||
conversation_id: str,
|
||||
*,
|
||||
enabled: bool,
|
||||
mode: str,
|
||||
last_commit: Optional[str] = None,
|
||||
last_input_seq: Optional[int] = None,
|
||||
) -> bool:
|
||||
normalized = _normalize_conv_id(conversation_id)
|
||||
payload: Dict[str, Any] = {
|
||||
"versioning": {
|
||||
"enabled": bool(enabled),
|
||||
"mode": "overwrite",
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
}
|
||||
if last_commit is not None:
|
||||
payload["versioning"]["last_commit"] = last_commit
|
||||
if last_input_seq is not None:
|
||||
payload["versioning"]["last_input_seq"] = int(last_input_seq)
|
||||
return terminal.context_manager.conversation_manager.update_conversation_metadata(normalized, payload)
|
||||
|
||||
|
||||
# === 背景生成对话标题(从 app_legacy 拆分) ===
|
||||
@conversation_bp.route('/api/conversations', methods=['GET'])
|
||||
@api_login_required
|
||||
@ -299,6 +356,39 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
|
||||
result = terminal.load_conversation(conversation_id)
|
||||
|
||||
if result["success"]:
|
||||
normalized_id = _normalize_conv_id(conversation_id)
|
||||
if _is_host_mode_request(username):
|
||||
try:
|
||||
vm = _get_conv_versioning_manager(workspace, normalized_id)
|
||||
vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||||
latest_checkpoint = vm.get_latest_checkpoint() if vmeta.get("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) if vmeta.get("enabled") else {
|
||||
"has_repo": False,
|
||||
"head": None,
|
||||
"dirty": False,
|
||||
"mismatch": False,
|
||||
"workspace_matched": True,
|
||||
}
|
||||
result["versioning"] = {
|
||||
"enabled": bool(vmeta.get("enabled")),
|
||||
"mode": "overwrite",
|
||||
"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}")
|
||||
result["versioning"] = {
|
||||
"enabled": False,
|
||||
"mode": "overwrite",
|
||||
"mismatch": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
# 广播对话切换事件
|
||||
socketio.emit('conversation_changed', {
|
||||
'conversation_id': conversation_id,
|
||||
@ -449,6 +539,310 @@ def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace:
|
||||
}), 500
|
||||
|
||||
|
||||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
try:
|
||||
normalized_id = _normalize_conv_id(conversation_id)
|
||||
host_mode = _is_host_mode_request(username)
|
||||
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||||
latest = manager.get_latest_checkpoint() if versioning_meta.get("enabled") and host_mode else None
|
||||
mismatch = manager.detect_mismatch(
|
||||
(latest or {}).get("commit"),
|
||||
expected_workspace_path=(latest or {}).get("workspace_path"),
|
||||
) if versioning_meta.get("enabled") and host_mode else {
|
||||
"has_repo": False,
|
||||
"head": None,
|
||||
"dirty": False,
|
||||
"mismatch": False,
|
||||
"workspace_matched": True,
|
||||
}
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"conversation_id": normalized_id,
|
||||
"host_mode": host_mode,
|
||||
"enabled": bool(versioning_meta.get("enabled")) if host_mode else False,
|
||||
"mode": "overwrite",
|
||||
"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)),
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def update_conversation_versioning(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
try:
|
||||
normalized_id = _normalize_conv_id(conversation_id)
|
||||
if not _is_host_mode_request(username):
|
||||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
||||
payload = request.get_json(silent=True) or {}
|
||||
enabled = bool(payload.get("enabled"))
|
||||
mode = "overwrite"
|
||||
|
||||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||||
meta = manager.set_enabled(enabled=enabled, mode=mode)
|
||||
if enabled:
|
||||
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,
|
||||
)
|
||||
init_row = init_result.get("row") or {}
|
||||
debug_log(
|
||||
f"[Versioning][Init] conv={normalized_id} enabled={enabled} "
|
||||
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")
|
||||
ok = _update_conversation_versioning_meta(
|
||||
terminal,
|
||||
normalized_id,
|
||||
enabled=enabled,
|
||||
mode=mode,
|
||||
last_commit=meta.get("last_commit"),
|
||||
last_input_seq=int(meta.get("last_input_seq") or 0),
|
||||
)
|
||||
if not ok:
|
||||
return jsonify({"success": False, "error": "更新对话版本配置失败"}), 404
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"conversation_id": normalized_id,
|
||||
"enabled": bool(meta.get("enabled")),
|
||||
"mode": "overwrite",
|
||||
"last_commit": meta.get("last_commit"),
|
||||
"last_input_seq": int(meta.get("last_input_seq") or 0),
|
||||
}
|
||||
})
|
||||
except VersioningError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning/checkpoints', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
try:
|
||||
normalized_id = _normalize_conv_id(conversation_id)
|
||||
if not _is_host_mode_request(username):
|
||||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
||||
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||||
if not versioning_meta.get("enabled"):
|
||||
return jsonify({"success": True, "data": {"enabled": False, "items": []}})
|
||||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||||
rows = manager.list_checkpoints()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"enabled": True,
|
||||
"mode": "overwrite",
|
||||
"items": rows,
|
||||
}
|
||||
})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning/checkpoints/<int:seq>', methods=['GET'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
try:
|
||||
normalized_id = _normalize_conv_id(conversation_id)
|
||||
if not _is_host_mode_request(username):
|
||||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
||||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||||
row = manager.get_checkpoint_detail(seq, include_patch=True)
|
||||
if not row:
|
||||
return jsonify({"success": False, "error": "未找到对应版本点"}), 404
|
||||
return jsonify({"success": True, "data": row})
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@conversation_bp.route('/api/conversations/<conversation_id>/versioning/restore', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
|
||||
try:
|
||||
normalized_id = _normalize_conv_id(conversation_id)
|
||||
if not _is_host_mode_request(username):
|
||||
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
|
||||
payload = request.get_json(silent=True) or {}
|
||||
seq = int(payload.get("seq") or 0)
|
||||
if seq < 0:
|
||||
return jsonify({"success": False, "error": "缺少有效 seq"}), 400
|
||||
|
||||
vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
|
||||
if not vmeta.get("enabled"):
|
||||
return jsonify({"success": False, "error": "当前对话未开启版本管理"}), 400
|
||||
|
||||
restore_mode = "overwrite"
|
||||
|
||||
manager = _get_conv_versioning_manager(workspace, normalized_id)
|
||||
checkpoint = manager.get_checkpoint(seq)
|
||||
if not checkpoint:
|
||||
return jsonify({"success": False, "error": "未找到对应版本点"}), 404
|
||||
|
||||
debug_log(
|
||||
f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} "
|
||||
f"target_commit={checkpoint.get('commit')} workspace={workspace.project_path}"
|
||||
)
|
||||
manager.restore_to_commit(checkpoint.get("commit"))
|
||||
debug_log(
|
||||
f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} "
|
||||
f"commit={checkpoint.get('commit')}"
|
||||
)
|
||||
|
||||
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)
|
||||
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",
|
||||
last_commit=prune_info.get("last_commit") or checkpoint.get("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",
|
||||
"last_commit": prune_info.get("last_commit") or checkpoint.get("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)}"
|
||||
)
|
||||
|
||||
# 关键修复: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}")
|
||||
|
||||
terminal.load_conversation(target_conversation_id)
|
||||
debug_log(
|
||||
f"[Versioning][Restore] reload done conv={target_conversation_id} "
|
||||
f"messages={len(getattr(terminal.context_manager, 'conversation_history', []) or [])}"
|
||||
)
|
||||
session['run_mode'] = terminal.run_mode
|
||||
session['thinking_mode'] = terminal.thinking_mode
|
||||
|
||||
socketio.emit('conversation_list_update', {
|
||||
'action': 'version_restored',
|
||||
'conversation_id': target_conversation_id
|
||||
}, room=f"user_{username}")
|
||||
socketio.emit('conversation_changed', {
|
||||
'conversation_id': target_conversation_id,
|
||||
'title': (cm.load_conversation(target_conversation_id) or {}).get("title", "版本回溯对话"),
|
||||
}, room=f"user_{username}")
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": {
|
||||
"restore_mode": restore_mode,
|
||||
"conversation_id": target_conversation_id,
|
||||
"restored_seq": int(checkpoint.get("seq") or 0),
|
||||
"restored_commit": checkpoint.get("commit"),
|
||||
"source_conversation_id": normalized_id,
|
||||
}
|
||||
})
|
||||
except VersioningError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
@conversation_bp.route('/api/conversations/<conversation_id>/compress', methods=['POST'])
|
||||
@api_login_required
|
||||
@with_terminal
|
||||
|
||||
@ -316,6 +316,17 @@ class TaskManager:
|
||||
debug_log(f"[Task] 设置上下文回调失败: {exc}")
|
||||
|
||||
# 将 task_id 作为 client_sid,供 stop_flags 检测
|
||||
previous_auto_user_event = None
|
||||
try:
|
||||
previous_auto_user_event = getattr(terminal, "_auto_user_message_event", False)
|
||||
setattr(
|
||||
terminal,
|
||||
"_auto_user_message_event",
|
||||
bool((rec.session_data or {}).get("auto_user_message_event")),
|
||||
)
|
||||
except Exception:
|
||||
previous_auto_user_event = None
|
||||
|
||||
try:
|
||||
run_chat_task_sync(
|
||||
terminal=terminal,
|
||||
@ -329,6 +340,8 @@ class TaskManager:
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
if previous_auto_user_event is not None:
|
||||
setattr(terminal, "_auto_user_message_event", previous_auto_user_event)
|
||||
if terminal and getattr(terminal, "context_manager", None):
|
||||
terminal.context_manager.set_web_terminal_callback(previous_ctx_callback)
|
||||
except Exception as exc:
|
||||
|
||||
@ -280,6 +280,7 @@
|
||||
:permission-menu-open="permissionMenuOpen"
|
||||
:permission-options="permissionModeOptions"
|
||||
:current-context-tokens="currentContextTokens"
|
||||
:versioning-enabled="versioningEnabled"
|
||||
@update:input-message="inputSetMessage"
|
||||
@input-change="handleInputChange"
|
||||
@input-focus="handleInputFocus"
|
||||
@ -308,6 +309,7 @@
|
||||
@open-review="openReviewDialog"
|
||||
@toggle-permission-menu="togglePermissionMenu"
|
||||
@change-permission-mode="changePermissionMode"
|
||||
@open-versioning-dialog="openVersioningDialog"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@ -322,6 +324,7 @@
|
||||
:width="rightWidth"
|
||||
:approvals="pendingToolApprovals"
|
||||
:deciding-approval-ids="decidingApprovalIds"
|
||||
@switch-unrestricted="handleSwitchPermissionToUnrestricted"
|
||||
@approve="approveToolApproval"
|
||||
@reject="rejectToolApproval"
|
||||
@close="rightCollapsed = true"
|
||||
@ -381,6 +384,26 @@
|
||||
</transition>
|
||||
<SubAgentActivityDialog />
|
||||
<BackgroundCommandDialog />
|
||||
<transition name="overlay-fade">
|
||||
<VersioningDialog
|
||||
v-if="versioningDialogOpen"
|
||||
:host-mode="versioningHostMode"
|
||||
:enabled="versioningEnabled"
|
||||
:loading="versioningLoading"
|
||||
:items="versioningCheckpoints"
|
||||
:selected-seq="versioningSelectedSeq"
|
||||
:detail="versioningSelectedDetail"
|
||||
:detail-loading="versioningDetailLoading"
|
||||
:restoring="versioningRestoring"
|
||||
:restore-mode="versioningRestoreMode"
|
||||
@close="versioningDialogOpen = false"
|
||||
@refresh="refreshVersioningDialog"
|
||||
@toggle-enabled="toggleConversationVersioning"
|
||||
@select="selectVersioningCheckpoint"
|
||||
@update:restore-mode="versioningRestoreMode = $event"
|
||||
@confirm="confirmVersioningRestore"
|
||||
/>
|
||||
</transition>
|
||||
<TutorialOverlay v-if="tutorialStore.running" />
|
||||
<NewUserTutorialPrompt
|
||||
:visible="tutorialPromptVisible"
|
||||
@ -639,6 +662,7 @@
|
||||
:width="Math.min(rightWidth || 420, 460)"
|
||||
:approvals="pendingToolApprovals"
|
||||
:deciding-approval-ids="decidingApprovalIds"
|
||||
@switch-unrestricted="handleSwitchPermissionToUnrestricted"
|
||||
@approve="approveToolApproval"
|
||||
@reject="rejectToolApproval"
|
||||
@close="closeMobileOverlay"
|
||||
|
||||
@ -29,6 +29,7 @@ import { toolingMethods } from './app/methods/tooling';
|
||||
import { uiMethods } from './app/methods/ui';
|
||||
import { taskPollingMethods } from './app/methods/taskPolling';
|
||||
import { monitorMethods } from './app/methods/monitor';
|
||||
import { versioningMethods } from './app/methods/versioning';
|
||||
|
||||
// 其他初始化逻辑已迁移到 app/bootstrap.ts
|
||||
|
||||
@ -49,6 +50,7 @@ const appOptions = {
|
||||
...resourceMethods,
|
||||
...toolingMethods,
|
||||
...uiMethods,
|
||||
...versioningMethods,
|
||||
...monitorMethods,
|
||||
...taskPollingMethods,
|
||||
...mapActions(useUiStore, {
|
||||
|
||||
@ -21,6 +21,9 @@ const SubAgentActivityDialog = defineAsyncComponent(
|
||||
const BackgroundCommandDialog = defineAsyncComponent(
|
||||
() => import('../components/overlay/BackgroundCommandDialog.vue')
|
||||
);
|
||||
const VersioningDialog = defineAsyncComponent(
|
||||
() => import('../components/overlay/VersioningDialog.vue')
|
||||
);
|
||||
const TutorialOverlay = defineAsyncComponent(
|
||||
() => import('../components/overlay/TutorialOverlay.vue')
|
||||
);
|
||||
@ -42,6 +45,7 @@ export const appComponents = {
|
||||
ConversationReviewDialog,
|
||||
SubAgentActivityDialog,
|
||||
BackgroundCommandDialog,
|
||||
VersioningDialog,
|
||||
TutorialOverlay,
|
||||
NewUserTutorialPrompt
|
||||
};
|
||||
|
||||
@ -367,6 +367,8 @@ export const conversationMethods = {
|
||||
// 4. 立即加载历史和统计,确保列表切换后界面同步更新
|
||||
await this.fetchAndDisplayHistory();
|
||||
this.fetchPermissionMode();
|
||||
await this.fetchVersioningStatus(conversationId, { silent: true });
|
||||
await this.maybePromptVersioningMismatch(result.versioning);
|
||||
this.fetchPendingToolApprovals();
|
||||
this.fetchConversationTokenStatistics();
|
||||
this.updateCurrentContextTokens();
|
||||
@ -560,6 +562,8 @@ export const conversationMethods = {
|
||||
...this.conversations.filter((conv) => conv && conv.id !== newConversationId)
|
||||
];
|
||||
|
||||
await this.applyPendingVersioningToConversation(newConversationId);
|
||||
|
||||
// 直接加载新对话,确保状态一致
|
||||
// 如果 socket 事件已把 currentConversationId 设置为新ID,则强制加载一次以同步状态
|
||||
await this.loadConversation(newConversationId, { force: true });
|
||||
|
||||
@ -241,6 +241,7 @@ export const messageMethods = {
|
||||
];
|
||||
const pathFragment = this.stripConversationPrefix(targetConversationId);
|
||||
history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`);
|
||||
await this.applyPendingVersioningToConversation(targetConversationId);
|
||||
} catch (error) {
|
||||
this.uiPushToast({
|
||||
title: '发送失败',
|
||||
|
||||
@ -664,6 +664,13 @@ export const uiMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
async handleSwitchPermissionToUnrestricted(approvalId) {
|
||||
await this.changePermissionMode('unrestricted');
|
||||
if (approvalId) {
|
||||
await this.approveToolApproval(approvalId);
|
||||
}
|
||||
},
|
||||
|
||||
async fetchPermissionMode() {
|
||||
try {
|
||||
const response = await fetch('/api/permission-mode');
|
||||
@ -1565,6 +1572,7 @@ export const uiMethods = {
|
||||
const todoPromise = this.fileFetchTodoList();
|
||||
let treePromise: Promise<any> | null = null;
|
||||
const isHostMode = statusData?.container?.mode === 'host';
|
||||
this.versioningHostMode = !!isHostMode;
|
||||
if (isHostMode) {
|
||||
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
|
||||
} else {
|
||||
@ -1624,6 +1632,7 @@ export const uiMethods = {
|
||||
// 获取当前对话的Token统计
|
||||
this.fetchConversationTokenStatistics();
|
||||
this.updateCurrentContextTokens();
|
||||
await this.fetchVersioningStatus(this.currentConversationId, { silent: true });
|
||||
} catch (e) {
|
||||
console.warn('获取当前对话标题失败:', e);
|
||||
this.titleReady = true;
|
||||
|
||||
286
static/src/app/methods/versioning.ts
Normal file
286
static/src/app/methods/versioning.ts
Normal file
@ -0,0 +1,286 @@
|
||||
// @ts-nocheck
|
||||
import { debugLog } from './common';
|
||||
|
||||
export const versioningMethods = {
|
||||
async fetchVersioningStatus(conversationId = null, options = {}) {
|
||||
const targetId = conversationId || this.currentConversationId;
|
||||
const { silent = false } = options as { silent?: boolean };
|
||||
if (!targetId) return null;
|
||||
try {
|
||||
const resp = await fetch(`/api/conversations/${targetId}/versioning`);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data?.success) {
|
||||
if (!silent) {
|
||||
throw new Error(data?.error || '获取版本状态失败');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const payload = data.data || {};
|
||||
this.versioningHostMode = !!payload.host_mode;
|
||||
this.versioningEnabled = !!payload.enabled;
|
||||
this.versioningMode = 'overwrite';
|
||||
this.versioningRestoreMode = 'overwrite';
|
||||
this.versioningMismatch = !!payload.mismatch;
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: error?.message || '获取版本状态失败',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchVersioningCheckpoints(conversationId = null) {
|
||||
const targetId = conversationId || this.currentConversationId;
|
||||
if (!targetId) return [];
|
||||
this.versioningLoading = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/conversations/${targetId}/versioning/checkpoints`);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data?.success) {
|
||||
throw new Error(data?.error || '加载版本点失败');
|
||||
}
|
||||
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
|
||||
this.versioningCheckpoints = items;
|
||||
if (!items.length) {
|
||||
this.versioningSelectedSeq = null;
|
||||
this.versioningSelectedDetail = null;
|
||||
}
|
||||
return items;
|
||||
} catch (error) {
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: error?.message || '加载版本点失败',
|
||||
type: 'error'
|
||||
});
|
||||
return [];
|
||||
} finally {
|
||||
this.versioningLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async openVersioningDialog() {
|
||||
if (!this.currentConversationId) {
|
||||
this.versioningEnabled = !!this.newConversationVersioningEnabled;
|
||||
this.versioningCheckpoints = [];
|
||||
this.versioningSelectedSeq = null;
|
||||
this.versioningSelectedDetail = null;
|
||||
this.versioningDialogOpen = true;
|
||||
return;
|
||||
}
|
||||
await this.fetchVersioningStatus(this.currentConversationId);
|
||||
this.versioningRestoreMode = 'overwrite';
|
||||
this.versioningDialogOpen = true;
|
||||
this.versioningSelectedSeq = null;
|
||||
this.versioningSelectedDetail = null;
|
||||
await this.fetchVersioningCheckpoints(this.currentConversationId);
|
||||
},
|
||||
|
||||
async refreshVersioningDialog() {
|
||||
await this.fetchVersioningStatus(this.currentConversationId);
|
||||
await this.fetchVersioningCheckpoints(this.currentConversationId);
|
||||
},
|
||||
|
||||
async toggleConversationVersioning(enabled: boolean) {
|
||||
if (!this.currentConversationId) {
|
||||
if (!this.versioningHostMode) {
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: '仅宿主机模式支持版本管理',
|
||||
type: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.newConversationVersioningEnabled = !!enabled;
|
||||
this.versioningEnabled = !!enabled;
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理',
|
||||
type: 'success'
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.versioningLoading = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: !!enabled,
|
||||
mode: 'overwrite'
|
||||
})
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data?.success) {
|
||||
throw new Error(data?.error || '切换版本管理失败');
|
||||
}
|
||||
this.versioningEnabled = !!data?.data?.enabled;
|
||||
this.versioningMode = 'overwrite';
|
||||
this.versioningRestoreMode = 'overwrite';
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: this.versioningEnabled ? '已开启' : '已关闭',
|
||||
type: 'success'
|
||||
});
|
||||
if (this.versioningEnabled) {
|
||||
await this.fetchVersioningCheckpoints(this.currentConversationId);
|
||||
} else {
|
||||
this.versioningCheckpoints = [];
|
||||
this.versioningSelectedSeq = null;
|
||||
this.versioningSelectedDetail = null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: error?.message || '切换失败',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
this.versioningLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async selectVersioningCheckpoint(seq: number) {
|
||||
if (!this.currentConversationId || seq === null || seq === undefined) return;
|
||||
this.versioningSelectedSeq = Number(seq);
|
||||
this.versioningDetailLoading = true;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/conversations/${this.currentConversationId}/versioning/checkpoints/${encodeURIComponent(String(seq))}`
|
||||
);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data?.success) {
|
||||
throw new Error(data?.error || '加载详情失败');
|
||||
}
|
||||
this.versioningSelectedDetail = data.data || null;
|
||||
} catch (error) {
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: error?.message || '加载详情失败',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
this.versioningDetailLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async confirmVersioningRestore() {
|
||||
if (
|
||||
!this.currentConversationId ||
|
||||
this.versioningSelectedSeq === null ||
|
||||
this.versioningSelectedSeq === undefined
|
||||
) return;
|
||||
const confirmed = await this.confirmAction({
|
||||
title: '确认回溯',
|
||||
message: `将回溯到输入 #${this.versioningSelectedSeq} 对应的状态,是否继续?`,
|
||||
confirmText: '回溯',
|
||||
cancelText: '取消'
|
||||
});
|
||||
if (!confirmed) return;
|
||||
this.versioningRestoring = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning/restore`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
seq: this.versioningSelectedSeq,
|
||||
mode: 'overwrite'
|
||||
})
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data?.success) {
|
||||
throw new Error(data?.error || '回溯失败');
|
||||
}
|
||||
const targetConversationId = data?.data?.conversation_id || this.currentConversationId;
|
||||
this.versioningDialogOpen = false;
|
||||
await this.loadConversation(targetConversationId, { force: true });
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: '回溯完成',
|
||||
type: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: error?.message || '回溯失败',
|
||||
type: 'error'
|
||||
});
|
||||
} finally {
|
||||
this.versioningRestoring = false;
|
||||
}
|
||||
},
|
||||
|
||||
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: '保持当前'
|
||||
});
|
||||
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.versioningHostMode) return;
|
||||
if (!this.newConversationVersioningEnabled) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/conversations/${targetId}/versioning`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
mode: 'overwrite'
|
||||
})
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data?.success) {
|
||||
throw new Error(data?.error || '新对话开启版本管理失败');
|
||||
}
|
||||
this.newConversationVersioningEnabled = false;
|
||||
this.versioningEnabled = true;
|
||||
} catch (error) {
|
||||
this.uiPushToast({
|
||||
title: '版本管理',
|
||||
message: error?.message || '新对话开启版本管理失败',
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -81,6 +81,19 @@ export function dataState() {
|
||||
modelMenuOpen: false,
|
||||
permissionMenuOpen: false,
|
||||
currentPermissionMode: 'unrestricted',
|
||||
versioningHostMode: false,
|
||||
versioningEnabled: false,
|
||||
newConversationVersioningEnabled: false,
|
||||
versioningMode: 'overwrite',
|
||||
versioningMismatch: false,
|
||||
versioningDialogOpen: false,
|
||||
versioningLoading: false,
|
||||
versioningCheckpoints: [],
|
||||
versioningSelectedSeq: null,
|
||||
versioningSelectedDetail: null,
|
||||
versioningDetailLoading: false,
|
||||
versioningRestoring: false,
|
||||
versioningRestoreMode: 'overwrite',
|
||||
permissionModeOptions: [
|
||||
{
|
||||
value: 'readonly',
|
||||
|
||||
@ -48,8 +48,11 @@ export const watchers = {
|
||||
skipConversationHistoryReload: this.skipConversationHistoryReload
|
||||
});
|
||||
if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) {
|
||||
this.versioningEnabled = !!this.newConversationVersioningEnabled;
|
||||
this.versioningMismatch = false;
|
||||
return;
|
||||
}
|
||||
this.fetchVersioningStatus(newValue, { silent: true });
|
||||
if (this.skipConversationHistoryReload) {
|
||||
this.skipConversationHistoryReload = false;
|
||||
return;
|
||||
|
||||
@ -139,23 +139,33 @@
|
||||
type="button"
|
||||
class="permission-switcher__btn"
|
||||
:disabled="!isConnected || streamingMessage"
|
||||
@click="$emit('toggle-permission-menu')"
|
||||
@click="$emit('open-versioning-dialog')"
|
||||
>
|
||||
<span>权限:{{ currentPermissionLabel }}</span>
|
||||
<span class="permission-switcher__caret" :class="{ open: permissionMenuOpen }">›</span>
|
||||
<span>版本:{{ versioningEnabled ? '开启' : '关闭' }}</span>
|
||||
</button>
|
||||
<div v-if="permissionMenuOpen" class="permission-switcher__menu">
|
||||
<div class="permission-switcher__block">
|
||||
<button
|
||||
v-for="option in permissionOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="permission-switcher__item"
|
||||
:class="{ active: option.value === currentPermissionMode }"
|
||||
@click="$emit('change-permission-mode', option.value)"
|
||||
class="permission-switcher__btn"
|
||||
:disabled="!isConnected || streamingMessage"
|
||||
@click="$emit('toggle-permission-menu')"
|
||||
>
|
||||
<span class="permission-switcher__item-label">{{ option.label }}</span>
|
||||
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
||||
<span>权限:{{ currentPermissionLabel }}</span>
|
||||
<span class="permission-switcher__caret" :class="{ open: permissionMenuOpen }">›</span>
|
||||
</button>
|
||||
<div v-if="permissionMenuOpen" class="permission-switcher__menu">
|
||||
<button
|
||||
v-for="option in permissionOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="permission-switcher__item"
|
||||
:class="{ active: option.value === currentPermissionMode }"
|
||||
@click="$emit('change-permission-mode', option.value)"
|
||||
>
|
||||
<span class="permission-switcher__item-label">{{ option.label }}</span>
|
||||
<span class="permission-switcher__item-desc">{{ option.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="context-usage-switcher" @click.stop>
|
||||
@ -221,7 +231,8 @@ const emit = defineEmits([
|
||||
'remove-video',
|
||||
'open-review',
|
||||
'toggle-permission-menu',
|
||||
'change-permission-mode'
|
||||
'change-permission-mode',
|
||||
'open-versioning-dialog'
|
||||
]);
|
||||
|
||||
const props = defineProps<{
|
||||
@ -269,6 +280,7 @@ const props = defineProps<{
|
||||
permissionMenuOpen: boolean;
|
||||
permissionOptions: Array<{ value: string; label: string; description: string }>;
|
||||
currentContextTokens: number;
|
||||
versioningEnabled?: boolean;
|
||||
}>();
|
||||
|
||||
const inputStore = useInputStore();
|
||||
@ -478,4 +490,5 @@ onMounted(() => {
|
||||
.image-remove-btn-hover:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
253
static/src/components/overlay/VersioningDialog.vue
Normal file
253
static/src/components/overlay/VersioningDialog.vue
Normal file
@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<div class="versioning-dialog" @click.self="$emit('close')">
|
||||
<div class="versioning-card">
|
||||
<header class="versioning-header">
|
||||
<div class="versioning-header-left">
|
||||
<h3>版本管理</h3>
|
||||
<label class="switch-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="enabled"
|
||||
:disabled="!hostMode || loading"
|
||||
@change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span>开启</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="versioning-header-actions">
|
||||
<button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')">
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" class="close-btn" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="versioning-body">
|
||||
<aside class="checkpoint-list">
|
||||
<div
|
||||
v-for="item in orderedItems"
|
||||
:key="item.seq"
|
||||
class="checkpoint-item"
|
||||
:class="{ active: Number(selectedSeq) === Number(item.seq) }"
|
||||
@click="$emit('select', item.seq)"
|
||||
>
|
||||
<div class="row top">
|
||||
<span class="seq">#{{ item.seq }}</span>
|
||||
<span class="time">{{ formatTime(item.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="preview">{{ item.message_preview || '(空消息)' }}</div>
|
||||
<div class="row stats">
|
||||
<span class="plus">+{{ item.insertions || 0 }}</span>
|
||||
<span class="minus">-{{ item.deletions || 0 }}</span>
|
||||
<span class="files">{{ item.files_changed || 0 }} 文件</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!items.length" class="empty">暂无版本点</div>
|
||||
</aside>
|
||||
|
||||
<section class="checkpoint-detail">
|
||||
<div v-if="detailLoading" class="loading">加载详情中...</div>
|
||||
<template v-else-if="detail">
|
||||
<h4>提交详情 #{{ detail.seq }}</h4>
|
||||
<div class="summary">
|
||||
<span class="plus">+{{ detail.insertions || 0 }}</span>
|
||||
<span class="minus">-{{ detail.deletions || 0 }}</span>
|
||||
<span>{{ detail.files_changed || 0 }} 文件</span>
|
||||
</div>
|
||||
<div class="files">
|
||||
<template v-for="(file, idx) in detail.files || []" :key="`${file.status}:${file.path}:${idx}`">
|
||||
<div class="file-row" :class="{ expanded: isFileExpanded(file, idx) }" @click="toggleFileExpand(file, idx)">
|
||||
<span class="status">{{ file.status }}</span>
|
||||
<span class="path">{{ file.path }}</span>
|
||||
<span class="delta">
|
||||
<span class="plus">+{{ file.insertions || 0 }}</span>
|
||||
<span class="minus">-{{ file.deletions || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isFileExpanded(file, idx)" class="tool-result-diff scrollable versioning-diff">
|
||||
<div v-if="!(file.patch_lines || []).length" class="empty">暂无可展示的文本变更行</div>
|
||||
<div
|
||||
v-for="(line, lineIdx) in file.patch_lines || []"
|
||||
:key="`line:${idx}:${lineIdx}`"
|
||||
class="diff-line"
|
||||
:class="line.type === 'add' ? 'diff-add' : (line.type === 'remove' ? 'diff-remove' : 'diff-context')"
|
||||
>
|
||||
{{ line.type === 'add' ? '+ ' : (line.type === 'remove' ? '- ' : ' ') }}{{ line.content }}
|
||||
</div>
|
||||
<div v-if="file.patch_truncated" class="approval-note">内容过长,已截断展示</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty">请选择一个版本点查看详情</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="versioning-footer">
|
||||
<label>回溯模式:覆盖当前对话</label>
|
||||
<button
|
||||
type="button"
|
||||
class="confirm-btn"
|
||||
:disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ restoring ? '回溯中...' : '确认回溯' }}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
hostMode: boolean;
|
||||
enabled: boolean;
|
||||
loading: boolean;
|
||||
items: any[];
|
||||
selectedSeq: number | null;
|
||||
detail: any;
|
||||
detailLoading: boolean;
|
||||
restoring: boolean;
|
||||
restoreMode: string;
|
||||
}>();
|
||||
|
||||
defineEmits([
|
||||
'close',
|
||||
'refresh',
|
||||
'toggle-enabled',
|
||||
'select',
|
||||
'confirm',
|
||||
'update:restore-mode'
|
||||
]);
|
||||
|
||||
const orderedItems = computed(() => {
|
||||
const src = Array.isArray(props.items) ? props.items : [];
|
||||
return [...src].sort((a: any, b: any) => Number(a?.seq || 0) - Number(b?.seq || 0));
|
||||
});
|
||||
|
||||
const expandedFileKey = ref<string | null>(null);
|
||||
const fileKey = (file: any, idx: number) => `${file?.status || ''}:${file?.old_path || ''}:${file?.path || ''}:${idx}`;
|
||||
const toggleFileExpand = (file: any, idx: number) => {
|
||||
const key = fileKey(file, idx);
|
||||
expandedFileKey.value = expandedFileKey.value === key ? null : key;
|
||||
};
|
||||
const isFileExpanded = (file: any, idx: number) => expandedFileKey.value === fileKey(file, idx);
|
||||
|
||||
watch(
|
||||
() => props.detail?.seq,
|
||||
() => {
|
||||
expandedFileKey.value = null;
|
||||
}
|
||||
);
|
||||
|
||||
const formatTime = (value: string) => {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.versioning-dialog { position: fixed; inset: 0; z-index: 1200; background: var(--theme-overlay-scrim); display: flex; align-items: center; justify-content: center; padding: 20px; backdrop-filter: blur(8px); }
|
||||
.versioning-card { width: min(1120px, 96vw); height: 600px; overflow: hidden; border-radius: 14px; background: var(--theme-surface-card); border: 1px solid var(--theme-control-border); box-shadow: var(--theme-shadow-strong); display: flex; flex-direction: column; color: var(--claude-text); }
|
||||
.versioning-header { padding: 14px 18px; border-bottom: 1px solid var(--theme-control-border); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.versioning-header-left { display: inline-flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.versioning-header-actions { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.close-btn { border: 0; background: transparent; font-size: 24px; cursor: pointer; }
|
||||
.switch-line { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
.refresh-btn { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 6px 10px; background: var(--theme-surface-soft); color: var(--claude-text); cursor: pointer; }
|
||||
.versioning-body { display: grid; grid-template-columns: 380px 1fr; min-height: 0; flex: 1; border-bottom: 1px solid var(--theme-control-border); }
|
||||
.checkpoint-list { overflow: auto; border-right: 1px solid var(--theme-control-border); padding: 10px; }
|
||||
.checkpoint-item { border: 1px solid var(--theme-control-border); border-radius: 10px; padding: 10px; cursor: pointer; margin-bottom: 8px; background: var(--theme-surface-soft); }
|
||||
.checkpoint-item.active { border-color: var(--claude-accent); background: var(--theme-tab-active); }
|
||||
.row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.row.top { font-size: 12px; color: var(--claude-text-secondary); }
|
||||
.seq { font-weight: 700; color: var(--claude-text); }
|
||||
.preview { margin-top: 6px; font-size: 13px; color: var(--claude-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.row.stats { margin-top: 6px; font-size: 12px; }
|
||||
.plus { color: #16a34a; }
|
||||
.minus { color: #dc2626; }
|
||||
.files { color: var(--claude-text-secondary); }
|
||||
.checkpoint-detail { overflow: auto; padding: 12px 14px; }
|
||||
.summary { display: flex; gap: 12px; font-size: 13px; margin-bottom: 10px; }
|
||||
.file-row { display: grid; grid-template-columns: 52px 1fr auto; gap: 10px; align-items: center; border-bottom: 1px dashed var(--theme-control-border); padding: 8px 0; font-size: 13px; cursor: pointer; }
|
||||
.file-row.expanded { background: color-mix(in srgb, var(--theme-tab-active) 45%, transparent); }
|
||||
.status { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--claude-text-secondary); }
|
||||
.path { word-break: break-all; color: var(--claude-text); }
|
||||
.delta { display: inline-flex; gap: 8px; }
|
||||
.loading, .empty { color: var(--claude-text-secondary); font-size: 13px; padding: 12px 4px; }
|
||||
.versioning-footer { padding: 12px 18px; display: flex; align-items: center; gap: 10px; justify-content: flex-end; }
|
||||
.versioning-footer select { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 6px 10px; background: var(--theme-surface-soft); color: var(--claude-text); }
|
||||
.confirm-btn { border: 0; background: var(--claude-accent); color: #fff; border-radius: 8px; padding: 8px 14px; cursor: pointer; }
|
||||
.confirm-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* 与文件编辑差异展示保持一致 */
|
||||
.tool-result-diff {
|
||||
margin-top: 12px;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tool-result-diff.scrollable {
|
||||
max-height: calc(8 * 1.5em + 16px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
padding: 2px 4px;
|
||||
margin: 1px 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.diff-remove {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.diff-add {
|
||||
background: rgba(0, 255, 0, 0.1);
|
||||
color: #0a0;
|
||||
}
|
||||
|
||||
.diff-context {
|
||||
background: color-mix(in srgb, var(--theme-surface-soft) 70%, transparent);
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
.approval-note {
|
||||
padding: 6px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--claude-text-secondary);
|
||||
}
|
||||
|
||||
/* 隐藏弹窗内部所有竖向滚动条(保留滚动能力) */
|
||||
.checkpoint-list,
|
||||
.checkpoint-detail,
|
||||
.tool-result-diff.scrollable {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.checkpoint-list::-webkit-scrollbar,
|
||||
.checkpoint-detail::-webkit-scrollbar,
|
||||
.tool-result-diff.scrollable::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
@ -80,6 +80,14 @@
|
||||
</div>
|
||||
|
||||
<div class="approval-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="approval-btn approval-btn--switch"
|
||||
:disabled="isDeciding(item.approval_id)"
|
||||
@click="$emit('switch-unrestricted', item.approval_id)"
|
||||
>
|
||||
切换到无限制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="approval-btn approval-btn--approve"
|
||||
@ -116,6 +124,7 @@ const props = defineProps<{
|
||||
defineEmits<{
|
||||
(event: 'approve', approvalId: string): void;
|
||||
(event: 'reject', approvalId: string): void;
|
||||
(event: 'switch-unrestricted', approvalId: string): void;
|
||||
(event: 'close'): void;
|
||||
}>();
|
||||
|
||||
|
||||
@ -456,6 +456,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="behavior-field">
|
||||
<div class="behavior-field-header">
|
||||
<span class="field-title">版本回溯默认模式</span>
|
||||
<p class="field-desc">
|
||||
版本管理已固定为覆盖当前对话。
|
||||
</p>
|
||||
</div>
|
||||
<div class="run-mode-options">
|
||||
<button type="button" class="run-mode-card active" aria-pressed="true" disabled>
|
||||
<div class="run-mode-card-header">
|
||||
<span class="run-mode-title">覆盖当前</span>
|
||||
</div>
|
||||
<p class="run-mode-desc">直接回溯并覆盖当前对话。</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="personal-actions-row">
|
||||
<div class="personal-form-actions card-aligned">
|
||||
<div class="personal-status-group">
|
||||
|
||||
@ -27,6 +27,7 @@ interface PersonalForm {
|
||||
disabled_tool_categories: string[];
|
||||
default_run_mode: RunMode | null;
|
||||
default_permission_mode: PermissionMode;
|
||||
versioning_restore_mode: 'overwrite';
|
||||
default_model: string | null;
|
||||
image_compression: string;
|
||||
auto_shallow_compress_enabled: boolean;
|
||||
@ -92,6 +93,7 @@ const defaultForm = (): PersonalForm => ({
|
||||
disabled_tool_categories: [],
|
||||
default_run_mode: null,
|
||||
default_permission_mode: 'unrestricted',
|
||||
versioning_restore_mode: 'overwrite',
|
||||
default_model: 'kimi-k2.5',
|
||||
image_compression: 'original',
|
||||
auto_shallow_compress_enabled: false,
|
||||
@ -253,6 +255,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
data.default_permission_mode === 'unrestricted'
|
||||
? data.default_permission_mode
|
||||
: 'unrestricted',
|
||||
versioning_restore_mode: 'overwrite',
|
||||
default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel,
|
||||
image_compression:
|
||||
typeof data.image_compression === 'string' ? data.image_compression : 'original',
|
||||
@ -496,6 +499,14 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
};
|
||||
this.clearFeedback();
|
||||
},
|
||||
setVersioningRestoreMode(_mode: 'overwrite') {
|
||||
const target: 'overwrite' = 'overwrite';
|
||||
this.form = {
|
||||
...this.form,
|
||||
versioning_restore_mode: target
|
||||
};
|
||||
this.clearFeedback();
|
||||
},
|
||||
setDefaultModel(model: string | null) {
|
||||
const modelStore = useModelStore();
|
||||
const allowed = new Set((modelStore.models || []).map((m) => m.key));
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/* 聊天容器整体布局,保证聊天区可见并支持上下滚动 */
|
||||
.chat-container {
|
||||
--chat-surface-color: var(--theme-surface-strong, #ffffff);
|
||||
--composer-reserved-height: calc(75px + var(--app-bottom-inset, 0px));
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -73,7 +74,7 @@
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
padding-top: 30px;
|
||||
padding-bottom: calc(100px + var(--app-bottom-inset, 0px));
|
||||
padding-bottom: calc(20px + var(--app-bottom-inset, 0px));
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
@ -134,6 +135,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
--composer-reserved-height: calc(96px + var(--app-bottom-inset, 0px));
|
||||
}
|
||||
|
||||
.chat-container--mobile {
|
||||
padding-top: 44px;
|
||||
}
|
||||
|
||||
@ -29,6 +29,13 @@
|
||||
left: 20px;
|
||||
bottom: -28px;
|
||||
z-index: 35;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.permission-switcher__block {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.context-usage-switcher {
|
||||
@ -528,6 +535,8 @@
|
||||
|
||||
.composer-container {
|
||||
position: relative;
|
||||
height: var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px)));
|
||||
flex: 0 0 var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px)));
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@ -276,6 +276,11 @@
|
||||
background: var(--claude-success);
|
||||
}
|
||||
|
||||
.approval-btn--switch {
|
||||
color: #fff;
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.approval-btn--reject {
|
||||
color: var(--theme-surface-strong);
|
||||
background: var(--claude-accent-strong);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user