feat(versioning): 支持仅管理对话并兼容 docker

This commit is contained in:
JOJO 2026-05-06 19:18:30 +08:00
parent 987dc822b2
commit d036ef5ccd
8 changed files with 506 additions and 96 deletions

View File

@ -27,6 +27,8 @@ class VersioningPaths:
class ConversationVersioningManager:
"""Manage hidden git snapshots bound to a conversation."""
TRACKING_MODE_WORKSPACE_AND_CONVERSATION = "workspace_and_conversation"
TRACKING_MODE_CONVERSATION_ONLY = "conversation_only"
SYSTEM_AUTO_DIRS = ("compact_result", "skills", "user_upload")
def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str):
@ -43,6 +45,13 @@ class ConversationVersioningManager:
snapshots_dir=(self.data_dir / "save" / self.conversation_id / "snapshots").resolve(),
)
@classmethod
def normalize_tracking_mode(cls, mode: Optional[str]) -> str:
raw = str(mode or "").strip().lower()
if raw == cls.TRACKING_MODE_CONVERSATION_ONLY:
return cls.TRACKING_MODE_CONVERSATION_ONLY
return cls.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
# ----------------------------
# low-level helpers
# ----------------------------
@ -151,16 +160,20 @@ class ConversationVersioningManager:
return {
"enabled": False,
"mode": "overwrite",
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"last_commit": None,
"last_input_seq": 0,
"updated_at": None,
}
try:
return json.loads(self.paths.meta_path.read_text(encoding="utf-8")) or {}
payload = json.loads(self.paths.meta_path.read_text(encoding="utf-8")) or {}
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
return payload
except Exception:
return {
"enabled": False,
"mode": "overwrite",
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"last_commit": None,
"last_input_seq": 0,
"updated_at": None,
@ -168,6 +181,7 @@ class ConversationVersioningManager:
def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]:
payload = dict(meta or {})
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
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")
@ -221,6 +235,7 @@ class ConversationVersioningManager:
def _normalize_checkpoint_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
payload = dict(row or {})
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
files = payload.get("files")
if isinstance(files, list):
normalized_files: List[Dict[str, Any]] = []
@ -276,12 +291,22 @@ class ConversationVersioningManager:
head = self._get_head_commit()
return {"ok": True, "head": head}
def set_enabled(self, enabled: bool, mode: Optional[str] = None) -> Dict[str, Any]:
def set_enabled(
self,
enabled: bool,
mode: Optional[str] = None,
*,
tracking_mode: Optional[str] = None,
) -> Dict[str, Any]:
meta = self.load_meta()
if enabled:
normalized_tracking_mode = self.normalize_tracking_mode(
tracking_mode or meta.get("tracking_mode")
)
if enabled and normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
self.ensure_repo()
meta["enabled"] = bool(enabled)
meta["mode"] = "overwrite"
meta["tracking_mode"] = normalized_tracking_mode
meta["last_commit"] = self._get_head_commit()
saved = self.save_meta(meta)
return saved
@ -291,17 +316,20 @@ class ConversationVersioningManager:
*,
workspace_path: Optional[str] = None,
conversation_snapshot: Optional[Dict[str, Any]] = None,
tracking_mode: Optional[str] = None,
) -> Dict[str, Any]:
"""
Ensure a visible seq=0 checkpoint exists after enabling versioning.
"""
self.ensure_repo()
normalized_tracking_mode = self.normalize_tracking_mode(tracking_mode)
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
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:
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not current_head:
raise VersioningError("创建初始版本点失败:未获取到 commit")
row = {
@ -319,23 +347,37 @@ class ConversationVersioningManager:
"files": [],
"changed": False,
"run_status": "initial",
"tracking_mode": normalized_tracking_mode,
}
if isinstance(conversation_snapshot, dict):
row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot)
self._append_input(row)
meta = self.load_meta()
meta["tracking_mode"] = normalized_tracking_mode
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]:
def ensure_baseline_for_first_input(
self,
*,
tracking_mode: Optional[str] = None,
) -> 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).
"""
normalized_tracking_mode = self.normalize_tracking_mode(tracking_mode)
if normalized_tracking_mode == self.TRACKING_MODE_CONVERSATION_ONLY:
return {
"created": False,
"skipped": True,
"reason": "conversation_only_mode",
"head": self._get_head_commit(),
}
self.ensure_repo()
meta = self.load_meta()
if self.get_checkpoint(0):
@ -439,28 +481,37 @@ class ConversationVersioningManager:
workspace_path: Optional[str] = None,
conversation_snapshot: Optional[Dict[str, Any]] = None,
run_status: Optional[str] = None,
tracking_mode: 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")
meta = self.load_meta()
normalized_tracking_mode = self.normalize_tracking_mode(
tracking_mode or meta.get("tracking_mode")
)
previous_head: Optional[str] = self._get_head_commit()
current_head: Optional[str] = previous_head
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
self.ensure_repo()
previous_head = self._get_head_commit()
self._stage_all()
_, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False)
has_changes = bool((staged_numstat or "").strip())
if has_changes:
next_seq = int(meta.get("last_input_seq") or 0) + 1
title = (message or "").strip().splitlines()[0][:72] if message else ""
commit_msg = f"input#{next_seq} {title}".strip()
self._run_git(["commit", "-m", commit_msg])
current_head = self._get_head_commit()
if not current_head:
raise VersioningError("创建版本快照失败:未获取到 commit")
diff_summary = (
self._build_diff_summary(previous_head, current_head)
if current_head != previous_head
if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
and 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,
@ -475,7 +526,11 @@ class ConversationVersioningManager:
"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),
"changed": bool(
normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
and current_head != previous_head
),
"tracking_mode": normalized_tracking_mode,
}
if isinstance(conversation_snapshot, dict):
row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot)
@ -484,6 +539,7 @@ class ConversationVersioningManager:
self._append_input(row)
meta["last_input_seq"] = seq
meta["last_commit"] = current_head
meta["tracking_mode"] = normalized_tracking_mode
self.save_meta(meta)
return row
@ -686,7 +742,22 @@ class ConversationVersioningManager:
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]:
def detect_mismatch(
self,
latest_commit: Optional[str],
expected_workspace_path: Optional[str] = None,
*,
tracking_mode: Optional[str] = None,
) -> Dict[str, Any]:
normalized_tracking_mode = self.normalize_tracking_mode(tracking_mode)
if normalized_tracking_mode == self.TRACKING_MODE_CONVERSATION_ONLY:
return {
"has_repo": (self.paths.git_dir / "HEAD").exists(),
"head": self._get_head_commit(),
"dirty": False,
"mismatch": False,
"workspace_matched": True,
}
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)

View File

@ -159,8 +159,6 @@ def _prepare_hidden_versioning_baseline_for_first_input(
) -> 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,
@ -173,14 +171,23 @@ def _prepare_hidden_versioning_baseline_for_first_input(
versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
if not bool(versioning_meta.get("enabled", False)):
return
tracking_mode = ConversationVersioningManager.normalize_tracking_mode(
versioning_meta.get("tracking_mode")
)
if (
tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
and not bool(getattr(web_terminal, "username", None) == "host")
):
return
manager = ConversationVersioningManager(
project_path=workspace.project_path,
data_dir=workspace.data_dir,
conversation_id=conversation_id,
)
baseline = manager.ensure_baseline_for_first_input()
baseline = manager.ensure_baseline_for_first_input(tracking_mode=tracking_mode)
debug_log(
f"[Versioning][Baseline] conv={conversation_id} "
f"tracking_mode={tracking_mode} "
f"created={baseline.get('created')} skipped={baseline.get('skipped')} "
f"reason={baseline.get('reason')} head={baseline.get('head')}"
)
@ -200,10 +207,8 @@ def _record_hidden_versioning_checkpoint_after_run(
auto_user_message_event: bool,
run_status: str = "completed",
) -> None:
"""Host-only hidden snapshot after current manual user input run finished."""
"""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,
@ -216,6 +221,14 @@ def _record_hidden_versioning_checkpoint_after_run(
versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
if not bool(versioning_meta.get("enabled", False)):
return
tracking_mode = ConversationVersioningManager.normalize_tracking_mode(
versioning_meta.get("tracking_mode")
)
if (
tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION
and not bool(getattr(web_terminal, "username", None) == "host")
):
return
snapshot_messages = conv_data.get("messages") or []
snapshot_payload = {
"conversation_id": conversation_id,
@ -237,9 +250,11 @@ def _record_hidden_versioning_checkpoint_after_run(
workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload,
run_status=str(run_status or "completed"),
tracking_mode=tracking_mode,
)
debug_log(
f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} "
f"tracking_mode={tracking_mode} "
f"msg_index={message_index} snapshot_messages={len(snapshot_messages)} "
f"commit={row.get('commit')} changed={row.get('changed')} status={row.get('run_status')}"
)
@ -249,6 +264,7 @@ def _record_hidden_versioning_checkpoint_after_run(
"versioning": {
"enabled": True,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"last_commit": row.get("commit"),
"last_input_seq": int(row.get("seq") or 0),
"updated_at": datetime.now().isoformat(),

View File

@ -160,6 +160,17 @@ def _normalize_conv_id(conversation_id: str) -> str:
return conv if conv.startswith("conv_") else f"conv_{conv}"
def _normalize_versioning_tracking_mode(value: Optional[str]) -> str:
return ConversationVersioningManager.normalize_tracking_mode(value)
def _can_use_versioning_scope(username: str, tracking_mode: str) -> bool:
normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode)
return _is_host_mode_request(username) or (
normalized_tracking_mode == ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY
)
def _is_host_mode_request(username: str) -> bool:
return bool(session.get("host_mode")) or (username == "host")
@ -173,6 +184,46 @@ def _get_conv_versioning_manager(workspace: UserWorkspace, conversation_id: str)
)
def _sanitize_scope_component(value: Any, default: str = "default") -> str:
raw = str(value or "").strip()
if not raw:
return default
sanitized = re.sub(r"[^0-9A-Za-z._-]+", "_", raw).strip("._-")
if not sanitized:
sanitized = default
return sanitized[:120]
def _resolve_input_draft_path(workspace: UserWorkspace, username: str) -> Tuple[Path, str]:
base_dir = Path(workspace.data_dir).expanduser().resolve() / "composer_drafts"
if _is_host_mode_request(username):
workspace_id = session.get("host_workspace_id") or session.get("workspace_id") or "default"
scope = f"host_workspace:{workspace_id}"
filename = f"{_sanitize_scope_component(workspace_id)}.json"
return (base_dir / "host" / filename).resolve(), scope
safe_user = _sanitize_scope_component(username or "user", default="user")
scope = f"user:{safe_user}"
# docker 模式下 data_dir 本身已按用户隔离,这里固定单文件即可。
return (base_dir / "docker" / "draft.json").resolve(), scope
def _read_input_draft_payload(path: Path) -> Dict[str, Any]:
if not path.exists():
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8")) or {}
return raw if isinstance(raw, dict) else {}
except Exception:
return {}
def _atomic_write_input_draft(path: Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
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 {}
@ -181,8 +232,15 @@ def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: st
if not isinstance(versioning, dict):
versioning = {}
enabled = bool(versioning.get("enabled", False))
tracking_mode = _normalize_versioning_tracking_mode(versioning.get("tracking_mode"))
mode = "overwrite"
return {"enabled": enabled, "mode": mode, "conversation_id": normalized, "metadata": meta}
return {
"enabled": enabled,
"mode": mode,
"tracking_mode": tracking_mode,
"conversation_id": normalized,
"metadata": meta,
}
def _update_conversation_versioning_meta(
@ -191,14 +249,17 @@ def _update_conversation_versioning_meta(
*,
enabled: bool,
mode: str,
tracking_mode: Optional[str] = None,
last_commit: Optional[str] = None,
last_input_seq: Optional[int] = None,
) -> bool:
normalized = _normalize_conv_id(conversation_id)
normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode)
payload: Dict[str, Any] = {
"versioning": {
"enabled": bool(enabled),
"mode": "overwrite",
"tracking_mode": normalized_tracking_mode,
"updated_at": datetime.now().isoformat(),
}
}
@ -209,6 +270,79 @@ def _update_conversation_versioning_meta(
return terminal.context_manager.conversation_manager.update_conversation_metadata(normalized, payload)
@conversation_bp.route('/api/input-draft', methods=['GET'])
@api_login_required
@with_terminal
def get_input_draft(terminal: WebTerminal, workspace: UserWorkspace, username: str):
del terminal
try:
path, scope = _resolve_input_draft_path(workspace, username)
payload = _read_input_draft_payload(path)
content = payload.get("content")
updated_at = payload.get("updated_at")
return jsonify({
"success": True,
"data": {
"content": content if isinstance(content, str) else "",
"updated_at": str(updated_at or ""),
"scope": scope,
}
})
except Exception as exc:
return jsonify({"success": False, "error": f"读取输入草稿失败: {exc}"}), 500
@conversation_bp.route('/api/input-draft', methods=['POST', 'PUT'])
@api_login_required
@with_terminal
def upsert_input_draft(terminal: WebTerminal, workspace: UserWorkspace, username: str):
del terminal
try:
body = request.get_json(silent=True) or {}
content = body.get("content") if isinstance(body, dict) else ""
if content is None:
content = ""
if not isinstance(content, str):
content = str(content)
if len(content) > 40000:
return jsonify({"success": False, "error": "输入草稿过长(最大 40000 字符)"}), 400
path, scope = _resolve_input_draft_path(workspace, username)
if content == "":
if path.exists():
try:
path.unlink()
except Exception:
pass
return jsonify({
"success": True,
"data": {
"saved": True,
"cleared": True,
"scope": scope,
"updated_at": datetime.now().isoformat(),
}
})
payload = {
"content": content,
"updated_at": datetime.now().isoformat(),
"scope": scope,
}
_atomic_write_input_draft(path, payload)
return jsonify({
"success": True,
"data": {
"saved": True,
"cleared": False,
"scope": scope,
"updated_at": payload["updated_at"],
}
})
except Exception as exc:
return jsonify({"success": False, "error": f"保存输入草稿失败: {exc}"}), 500
# === 背景生成对话标题(从 app_legacy 拆分) ===
@conversation_bp.route('/api/conversations', methods=['GET'])
@api_login_required
@ -365,37 +499,46 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None)
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),
}
try:
vm = _get_conv_versioning_manager(workspace, normalized_id)
vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
tracking_mode = _normalize_versioning_tracking_mode(vmeta.get("tracking_mode"))
enabled = bool(vmeta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode)
latest_checkpoint = vm.get_latest_checkpoint() if enabled else None
latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None
expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None
mismatch = vm.detect_mismatch(
latest_commit,
expected_workspace_path=expected_workspace_path,
tracking_mode=tracking_mode,
) if enabled else {
"has_repo": False,
"head": None,
"dirty": False,
"mismatch": False,
"workspace_matched": True,
}
result["versioning"] = {
"host_mode": _is_host_mode_request(username),
"enabled": enabled,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None,
"latest_commit": latest_commit,
"mismatch": bool(mismatch.get("mismatch")),
"dirty": bool(mismatch.get("dirty")),
"workspace_matched": bool(mismatch.get("workspace_matched", True)),
}
except Exception as exc:
debug_log(f"[Versioning] load status 读取失败: {exc}")
result["versioning"] = {
"host_mode": _is_host_mode_request(username),
"enabled": False,
"mode": "overwrite",
"tracking_mode": ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"mismatch": False,
"error": str(exc),
}
# 广播对话切换事件
socketio.emit('conversation_changed', {
@ -593,12 +736,15 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
normalized_id = _normalize_conv_id(conversation_id)
host_mode = _is_host_mode_request(username)
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
tracking_mode = _normalize_versioning_tracking_mode(versioning_meta.get("tracking_mode"))
enabled = bool(versioning_meta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode)
manager = _get_conv_versioning_manager(workspace, normalized_id)
latest = manager.get_latest_checkpoint() if versioning_meta.get("enabled") and host_mode else None
latest = manager.get_latest_checkpoint() if enabled else None
mismatch = manager.detect_mismatch(
(latest or {}).get("commit"),
expected_workspace_path=(latest or {}).get("workspace_path"),
) if versioning_meta.get("enabled") and host_mode else {
tracking_mode=tracking_mode,
) if enabled else {
"has_repo": False,
"head": None,
"dirty": False,
@ -610,8 +756,11 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
"data": {
"conversation_id": normalized_id,
"host_mode": host_mode,
"enabled": bool(versioning_meta.get("enabled")) if host_mode else False,
"supports_workspace_tracking": host_mode,
"supports_conversation_only": True,
"enabled": enabled,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"latest_seq": int((latest or {}).get("seq") or 0) if latest else None,
"latest_commit": (latest or {}).get("commit"),
"mismatch": bool(mismatch.get("mismatch")),
@ -629,14 +778,19 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
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
host_mode = _is_host_mode_request(username)
current_meta = _get_conversation_versioning_meta(terminal, normalized_id)
payload = request.get_json(silent=True) or {}
enabled = bool(payload.get("enabled"))
mode = "overwrite"
tracking_mode = _normalize_versioning_tracking_mode(
payload.get("tracking_mode") if "tracking_mode" in payload else current_meta.get("tracking_mode")
)
if enabled and tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode:
tracking_mode = ConversationVersioningManager.TRACKING_MODE_CONVERSATION_ONLY
manager = _get_conv_versioning_manager(workspace, normalized_id)
meta = manager.set_enabled(enabled=enabled, mode=mode)
meta = manager.set_enabled(enabled=enabled, mode=mode, tracking_mode=tracking_mode)
if enabled:
conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
snapshot_payload = {
@ -650,10 +804,12 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
init_result = manager.ensure_initial_checkpoint(
workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload,
tracking_mode=tracking_mode,
)
init_row = init_result.get("row") or {}
debug_log(
f"[Versioning][Init] conv={normalized_id} enabled={enabled} "
f"tracking_mode={tracking_mode} "
f"created={init_result.get('created')} reason={init_result.get('reason')} "
f"seq={init_row.get('seq')} commit={init_row.get('commit')}"
)
@ -664,6 +820,7 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
normalized_id,
enabled=enabled,
mode=mode,
tracking_mode=tracking_mode,
last_commit=meta.get("last_commit"),
last_input_seq=int(meta.get("last_input_seq") or 0),
)
@ -675,6 +832,7 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
"conversation_id": normalized_id,
"enabled": bool(meta.get("enabled")),
"mode": "overwrite",
"tracking_mode": tracking_mode,
"last_commit": meta.get("last_commit"),
"last_input_seq": int(meta.get("last_input_seq") or 0),
}
@ -691,11 +849,16 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
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
host_mode = _is_host_mode_request(username)
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id)
tracking_mode = _normalize_versioning_tracking_mode(versioning_meta.get("tracking_mode"))
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode:
return jsonify({"success": False, "error": "当前模式不支持工作区版本点,请切换到宿主机模式"}), 400
if not versioning_meta.get("enabled"):
return jsonify({"success": True, "data": {"enabled": False, "items": []}})
return jsonify({
"success": True,
"data": {"enabled": False, "mode": "overwrite", "tracking_mode": tracking_mode, "items": []}
})
manager = _get_conv_versioning_manager(workspace, normalized_id)
rows = manager.list_checkpoints()
return jsonify({
@ -703,6 +866,7 @@ def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTermi
"data": {
"enabled": True,
"mode": "overwrite",
"tracking_mode": tracking_mode,
"items": rows,
}
})
@ -716,8 +880,11 @@ def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTermi
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
host_mode = _is_host_mode_request(username)
vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
tracking_mode = _normalize_versioning_tracking_mode(vmeta.get("tracking_mode"))
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode:
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:
@ -733,8 +900,7 @@ def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, ter
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
host_mode = _is_host_mode_request(username)
payload = request.get_json(silent=True) or {}
seq = int(payload.get("seq") or 0)
if seq < 0:
@ -750,16 +916,28 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
checkpoint = manager.get_checkpoint(seq)
if not checkpoint:
return jsonify({"success": False, "error": "未找到对应版本点"}), 404
tracking_mode = _normalize_versioning_tracking_mode(
checkpoint.get("tracking_mode") or vmeta.get("tracking_mode")
)
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION and not host_mode:
return jsonify({"success": False, "error": "当前模式不支持工作区回溯,请切换到宿主机模式"}), 400
debug_log(
f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} "
f"tracking_mode={tracking_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')}"
)
if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
manager.restore_to_commit(checkpoint.get("commit"))
debug_log(
f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} "
f"commit={checkpoint.get('commit')}"
)
else:
debug_log(
f"[Versioning][Restore] conversation-only mode, skip workspace restore "
f"conv={normalized_id} seq={seq}"
)
cm = terminal.context_manager.conversation_manager
conv_data = cm.load_conversation(normalized_id) or {}
@ -814,6 +992,9 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
if not ok:
return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500
prune_info = manager.prune_checkpoints_after(seq)
resolved_last_commit = prune_info.get("last_commit") or checkpoint.get("commit")
if not resolved_last_commit:
resolved_last_commit = (manager.load_meta() or {}).get("last_commit")
debug_log(
f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} "
f"kept={prune_info.get('kept')} removed={prune_info.get('removed')} "
@ -825,7 +1006,8 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
normalized_id,
enabled=True,
mode="overwrite",
last_commit=prune_info.get("last_commit") or checkpoint.get("commit"),
tracking_mode=tracking_mode,
last_commit=resolved_last_commit,
last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
)
cm.update_conversation_metadata(
@ -835,7 +1017,8 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
"versioning": {
"enabled": True,
"mode": "overwrite",
"last_commit": prune_info.get("last_commit") or checkpoint.get("commit"),
"tracking_mode": tracking_mode,
"last_commit": resolved_last_commit,
"last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
"updated_at": datetime.now().isoformat(),
},
@ -880,6 +1063,7 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
"conversation_id": target_conversation_id,
"restored_seq": int(checkpoint.get("seq") or 0),
"restored_commit": checkpoint.get("commit"),
"tracking_mode": tracking_mode,
"source_conversation_id": normalized_id,
}
})

View File

@ -253,6 +253,9 @@ async def run_deep_compression(
old_title = conv_data.get("title") or "未命名"
source_versioning = metadata.get("versioning") if isinstance(metadata.get("versioning"), dict) else {}
source_versioning_enabled = bool(source_versioning.get("enabled"))
source_tracking_mode = ConversationVersioningManager.normalize_tracking_mode(
source_versioning.get("tracking_mode")
)
compression_count = int(metadata.get("compression_count", 0) or 0)
previous_records = _normalize_deep_compression_records(metadata)
previous_max_count = max([int(item.get("count") or 0) for item in previous_records], default=0)
@ -349,7 +352,11 @@ async def run_deep_compression(
data_dir=workspace.data_dir,
conversation_id=new_conv_id,
)
vm_meta = vm.set_enabled(enabled=True, mode="overwrite")
vm_meta = vm.set_enabled(
enabled=True,
mode="overwrite",
tracking_mode=source_tracking_mode,
)
new_conv_data = cm.conversation_manager.load_conversation(new_conv_id) or {}
snapshot_payload = {
"conversation_id": new_conv_id,
@ -362,6 +369,7 @@ async def run_deep_compression(
init_result = vm.ensure_initial_checkpoint(
workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload,
tracking_mode=source_tracking_mode,
)
init_row = init_result.get("row") or {}
last_commit = init_row.get("commit") or vm_meta.get("last_commit")
@ -371,6 +379,7 @@ async def run_deep_compression(
"versioning": {
"enabled": True,
"mode": "overwrite",
"tracking_mode": source_tracking_mode,
"updated_at": datetime.now().isoformat(),
"last_commit": last_commit,
"last_input_seq": int(vm_meta.get("last_input_seq") or 0),

View File

@ -402,6 +402,7 @@
v-if="versioningDialogOpen"
:host-mode="versioningHostMode"
:enabled="versioningEnabled"
:tracking-mode="versioningTrackingMode"
:loading="versioningLoading"
:items="versioningCheckpoints"
:selected-seq="versioningSelectedSeq"
@ -415,6 +416,7 @@
@toggle-enabled="toggleConversationVersioning"
@select="selectVersioningCheckpoint"
@update:restore-mode="versioningRestoreMode = $event"
@update:tracking-mode="handleVersioningTrackingModeChange"
@confirm="confirmVersioningRestore"
/>
</transition>

View File

@ -1,6 +1,12 @@
// @ts-nocheck
import { debugLog } from './common';
const normalizeTrackingMode = (value: any): 'workspace_and_conversation' | 'conversation_only' => {
return String(value || '').toLowerCase() === 'conversation_only'
? 'conversation_only'
: 'workspace_and_conversation';
};
export const versioningMethods = {
async fetchVersioningStatus(conversationId = null, options = {}) {
const targetId = conversationId || this.currentConversationId;
@ -18,6 +24,14 @@ export const versioningMethods = {
const payload = data.data || {};
this.versioningHostMode = !!payload.host_mode;
this.versioningEnabled = !!payload.enabled;
const resolvedTrackingMode = normalizeTrackingMode(payload.tracking_mode);
this.versioningTrackingMode = !this.versioningHostMode &&
resolvedTrackingMode !== 'conversation_only'
? 'conversation_only'
: resolvedTrackingMode;
if (!this.versioningHostMode) {
this.newConversationVersioningTrackingMode = 'conversation_only';
}
this.versioningMode = 'overwrite';
this.versioningRestoreMode = 'overwrite';
this.versioningMismatch = !!payload.mismatch;
@ -45,6 +59,9 @@ export const versioningMethods = {
if (!resp.ok || !data?.success) {
throw new Error(data?.error || '加载版本点失败');
}
if (data?.data?.tracking_mode) {
this.versioningTrackingMode = normalizeTrackingMode(data.data.tracking_mode);
}
const items = Array.isArray(data?.data?.items) ? data.data.items : [];
this.versioningCheckpoints = items;
if (!items.length) {
@ -67,6 +84,12 @@ export const versioningMethods = {
async openVersioningDialog() {
if (!this.currentConversationId) {
this.versioningEnabled = !!this.newConversationVersioningEnabled;
this.versioningTrackingMode = normalizeTrackingMode(
this.newConversationVersioningTrackingMode
);
if (!this.versioningHostMode && this.versioningTrackingMode !== 'conversation_only') {
this.versioningTrackingMode = 'conversation_only';
}
this.versioningWorkspaceMatched = true;
this.versioningCheckpoints = [];
this.versioningSelectedSeq = null;
@ -87,18 +110,22 @@ export const versioningMethods = {
await this.fetchVersioningCheckpoints(this.currentConversationId);
},
async handleVersioningTrackingModeChange(mode: string) {
await this.updateVersioningTrackingMode(mode);
},
async toggleConversationVersioning(enabled: boolean) {
if (!this.currentConversationId) {
if (!this.versioningHostMode) {
this.uiPushToast({
title: '版本管理',
message: '仅宿主机模式支持版本管理',
type: 'warning'
});
return;
}
this.newConversationVersioningEnabled = !!enabled;
this.versioningEnabled = !!enabled;
if (enabled) {
if (!this.versioningHostMode) {
this.versioningTrackingMode = 'conversation_only';
}
this.newConversationVersioningTrackingMode = normalizeTrackingMode(
this.versioningTrackingMode
);
}
this.uiPushToast({
title: '版本管理',
message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理',
@ -108,12 +135,18 @@ export const versioningMethods = {
}
this.versioningLoading = true;
try {
let trackingMode = normalizeTrackingMode(this.versioningTrackingMode);
if (!this.versioningHostMode && trackingMode !== 'conversation_only') {
trackingMode = 'conversation_only';
this.versioningTrackingMode = trackingMode;
}
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: !!enabled,
mode: 'overwrite'
mode: 'overwrite',
tracking_mode: trackingMode
})
});
const data = await resp.json().catch(() => ({}));
@ -121,6 +154,9 @@ export const versioningMethods = {
throw new Error(data?.error || '切换版本管理失败');
}
this.versioningEnabled = !!data?.data?.enabled;
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || this.versioningTrackingMode
);
this.versioningMode = 'overwrite';
this.versioningRestoreMode = 'overwrite';
this.uiPushToast({
@ -264,15 +300,20 @@ export const versioningMethods = {
async applyPendingVersioningToConversation(conversationId: string) {
const targetId = conversationId || this.currentConversationId;
if (!targetId) return;
if (!this.versioningHostMode) return;
if (!this.newConversationVersioningEnabled) return;
try {
const trackingMode = normalizeTrackingMode(
!this.versioningHostMode
? 'conversation_only'
: (this.newConversationVersioningTrackingMode || this.versioningTrackingMode)
);
const resp = await fetch(`/api/conversations/${targetId}/versioning`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
mode: 'overwrite'
mode: 'overwrite',
tracking_mode: trackingMode
})
});
const data = await resp.json().catch(() => ({}));
@ -281,6 +322,9 @@ export const versioningMethods = {
}
this.newConversationVersioningEnabled = false;
this.versioningEnabled = true;
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || trackingMode
);
} catch (error) {
this.uiPushToast({
title: '版本管理',
@ -288,5 +332,64 @@ export const versioningMethods = {
type: 'error'
});
}
},
async updateVersioningTrackingMode(mode: string) {
const normalizedMode = normalizeTrackingMode(mode);
if (!this.versioningHostMode && normalizedMode === 'workspace_and_conversation') {
this.uiPushToast({
title: '版本管理',
message: '当前模式仅支持“仅管理对话”',
type: 'warning'
});
this.versioningTrackingMode = 'conversation_only';
this.newConversationVersioningTrackingMode = 'conversation_only';
return false;
}
this.versioningTrackingMode = normalizedMode;
if (!this.currentConversationId) {
this.newConversationVersioningTrackingMode = normalizedMode;
return true;
}
if (!this.versioningEnabled) {
return true;
}
this.versioningLoading = true;
try {
const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: true,
mode: 'overwrite',
tracking_mode: normalizedMode
})
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.success) {
throw new Error(data?.error || '更新版本管理范围失败');
}
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || normalizedMode
);
this.uiPushToast({
title: '版本管理',
message:
this.versioningTrackingMode === 'conversation_only'
? '已切换为仅管理对话'
: '已切换为管理工作区和对话',
type: 'success'
});
return true;
} catch (error) {
this.uiPushToast({
title: '版本管理',
message: error?.message || '更新版本管理范围失败',
type: 'error'
});
return false;
} finally {
this.versioningLoading = false;
}
}
};

View File

@ -98,6 +98,8 @@ export function dataState() {
hostWorkspaceCreateError: '',
versioningEnabled: false,
newConversationVersioningEnabled: false,
versioningTrackingMode: 'workspace_and_conversation',
newConversationVersioningTrackingMode: 'workspace_and_conversation',
versioningMode: 'overwrite',
versioningMismatch: false,
versioningWorkspaceMatched: true,
@ -149,6 +151,10 @@ export function dataState() {
connectionHeartbeatRequestTimeoutMs: 5000,
connectionHeartbeatIntervalMs: 8000,
connectionHeartbeatDisconnectedIntervalMs: 1000,
composerDraftSaveTimer: null,
composerDraftDirty: false,
composerDraftLastSyncedContent: '',
composerDraftFetchSeq: 0,
// 工具控制菜单
icons: ICONS,

View File

@ -8,11 +8,24 @@
<input
type="checkbox"
:checked="enabled"
:disabled="!hostMode || loading"
:disabled="loading"
@change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)"
/>
<span>开启</span>
</label>
<label class="scope-line">
<span>管理范围</span>
<select
:value="trackingMode"
:disabled="!hostMode || loading || restoring"
@change="$emit('update:tracking-mode', ($event.target as HTMLSelectElement).value)"
>
<option value="workspace_and_conversation" :disabled="!hostMode">
管理工作区和对话
</option>
<option value="conversation_only">仅管理对话</option>
</select>
</label>
</div>
<div class="versioning-header-actions">
<button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')">
@ -84,14 +97,14 @@
</div>
<footer class="versioning-footer">
<div v-if="workspaceMatched === false" class="workspace-mismatch-tip">
<div v-if="needWorkspaceMatch && workspaceMatched === false" class="workspace-mismatch-tip">
当前对话所属工作区与当前工作区不一致无法执行回溯
</div>
<label>回溯模式覆盖当前对话</label>
<button
type="button"
class="confirm-btn"
:disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring || workspaceMatched === false"
:disabled="!enabled || selectedSeq === null || selectedSeq === undefined || restoring || (needWorkspaceMatch && workspaceMatched === false)"
@click="$emit('confirm')"
>
{{ restoring ? '回溯中...' : '确认回溯' }}
@ -107,6 +120,7 @@ import { computed, ref, watch } from 'vue';
const props = defineProps<{
hostMode: boolean;
enabled: boolean;
trackingMode: string;
loading: boolean;
items: any[];
selectedSeq: number | null;
@ -121,11 +135,14 @@ defineEmits([
'close',
'refresh',
'toggle-enabled',
'update:tracking-mode',
'select',
'confirm',
'update:restore-mode'
]);
const needWorkspaceMatch = computed(() => String(props.trackingMode || '') !== 'conversation_only');
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));
@ -169,6 +186,8 @@ const formatTime = (value: string) => {
.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; }
.scope-line { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--claude-text-secondary); }
.scope-line select { border: 1px solid var(--theme-control-border); border-radius: 8px; padding: 5px 10px; background: var(--theme-surface-soft); color: var(--claude-text); }
.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; }