feat: complete conversation versioning UX and restore workflow

This commit is contained in:
JOJO 2026-04-11 16:16:40 +08:00
parent 34a6577f3a
commit 6379f8c729
23 changed files with 2029 additions and 16 deletions

View File

@ -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:

View 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}

View File

@ -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):

View File

@ -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,

View File

@ -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

View File

@ -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:

View File

@ -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"

View File

@ -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, {

View File

@ -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
};

View File

@ -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 });

View File

@ -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: '发送失败',

View File

@ -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;

View 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'
});
}
}
};

View File

@ -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',

View File

@ -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;

View File

@ -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>

View 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>

View File

@ -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;
}>();

View File

@ -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">

View File

@ -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));

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);