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: class ConversationVersioningManager:
"""Manage hidden git snapshots bound to a conversation.""" """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") SYSTEM_AUTO_DIRS = ("compact_result", "skills", "user_upload")
def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str): 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(), 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 # low-level helpers
# ---------------------------- # ----------------------------
@ -151,16 +160,20 @@ class ConversationVersioningManager:
return { return {
"enabled": False, "enabled": False,
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"last_commit": None, "last_commit": None,
"last_input_seq": 0, "last_input_seq": 0,
"updated_at": None, "updated_at": None,
} }
try: 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: except Exception:
return { return {
"enabled": False, "enabled": False,
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION,
"last_commit": None, "last_commit": None,
"last_input_seq": 0, "last_input_seq": 0,
"updated_at": None, "updated_at": None,
@ -168,6 +181,7 @@ class ConversationVersioningManager:
def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]: def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]:
payload = dict(meta or {}) payload = dict(meta or {})
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
payload["updated_at"] = datetime.now().isoformat() payload["updated_at"] = datetime.now().isoformat()
self.paths.meta_path.parent.mkdir(parents=True, exist_ok=True) 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") 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]: def _normalize_checkpoint_row(self, row: Dict[str, Any]) -> Dict[str, Any]:
payload = dict(row or {}) payload = dict(row or {})
payload["tracking_mode"] = self.normalize_tracking_mode(payload.get("tracking_mode"))
files = payload.get("files") files = payload.get("files")
if isinstance(files, list): if isinstance(files, list):
normalized_files: List[Dict[str, Any]] = [] normalized_files: List[Dict[str, Any]] = []
@ -276,12 +291,22 @@ class ConversationVersioningManager:
head = self._get_head_commit() head = self._get_head_commit()
return {"ok": True, "head": head} 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() 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() self.ensure_repo()
meta["enabled"] = bool(enabled) meta["enabled"] = bool(enabled)
meta["mode"] = "overwrite" meta["mode"] = "overwrite"
meta["tracking_mode"] = normalized_tracking_mode
meta["last_commit"] = self._get_head_commit() meta["last_commit"] = self._get_head_commit()
saved = self.save_meta(meta) saved = self.save_meta(meta)
return saved return saved
@ -291,17 +316,20 @@ class ConversationVersioningManager:
*, *,
workspace_path: Optional[str] = None, workspace_path: Optional[str] = None,
conversation_snapshot: Optional[Dict[str, Any]] = None, conversation_snapshot: Optional[Dict[str, Any]] = None,
tracking_mode: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Ensure a visible seq=0 checkpoint exists after enabling versioning. 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) existing = self.get_checkpoint(0)
if existing: if existing:
return {"created": False, "row": existing, "reason": "already_exists"} return {"created": False, "row": existing, "reason": "already_exists"}
current_head = self._get_head_commit() 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") raise VersioningError("创建初始版本点失败:未获取到 commit")
row = { row = {
@ -319,23 +347,37 @@ class ConversationVersioningManager:
"files": [], "files": [],
"changed": False, "changed": False,
"run_status": "initial", "run_status": "initial",
"tracking_mode": normalized_tracking_mode,
} }
if isinstance(conversation_snapshot, dict): if isinstance(conversation_snapshot, dict):
row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot) row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot)
self._append_input(row) self._append_input(row)
meta = self.load_meta() meta = self.load_meta()
meta["tracking_mode"] = normalized_tracking_mode
meta["last_commit"] = current_head meta["last_commit"] = current_head
if int(meta.get("last_input_seq") or 0) < 0: if int(meta.get("last_input_seq") or 0) < 0:
meta["last_input_seq"] = 0 meta["last_input_seq"] = 0
self.save_meta(meta) self.save_meta(meta)
return {"created": True, "row": row, "reason": "created"} 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. Ensure the baseline commit reflects current workspace before first manual input checkpoint.
This is intentionally hidden from checkpoint list (no seq row created). 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() self.ensure_repo()
meta = self.load_meta() meta = self.load_meta()
if self.get_checkpoint(0): if self.get_checkpoint(0):
@ -439,28 +481,37 @@ class ConversationVersioningManager:
workspace_path: Optional[str] = None, workspace_path: Optional[str] = None,
conversation_snapshot: Optional[Dict[str, Any]] = None, conversation_snapshot: Optional[Dict[str, Any]] = None,
run_status: Optional[str] = None, run_status: Optional[str] = None,
tracking_mode: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
self.ensure_repo() meta = self.load_meta()
previous_head = self._get_head_commit() normalized_tracking_mode = self.normalize_tracking_mode(
self._stage_all() tracking_mode or meta.get("tracking_mode")
_, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False) )
has_changes = bool((staged_numstat or "").strip()) previous_head: Optional[str] = self._get_head_commit()
if has_changes: current_head: Optional[str] = previous_head
next_seq = int(self.load_meta().get("last_input_seq") or 0) + 1
title = (message or "").strip().splitlines()[0][:72] if message else "" if normalized_tracking_mode == self.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
commit_msg = f"input#{next_seq} {title}".strip() self.ensure_repo()
self._run_git(["commit", "-m", commit_msg]) previous_head = self._get_head_commit()
current_head = self._get_head_commit() self._stage_all()
if not current_head: _, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False)
raise VersioningError("创建版本快照失败:未获取到 commit") 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 = ( diff_summary = (
self._build_diff_summary(previous_head, current_head) 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": []} else {"insertions": 0, "deletions": 0, "files_changed": 0, "files": []}
) )
meta = self.load_meta()
seq = int(meta.get("last_input_seq") or 0) + 1 seq = int(meta.get("last_input_seq") or 0) + 1
row = { row = {
"seq": seq, "seq": seq,
@ -475,7 +526,11 @@ class ConversationVersioningManager:
"deletions": int(diff_summary.get("deletions") or 0), "deletions": int(diff_summary.get("deletions") or 0),
"files_changed": int(diff_summary.get("files_changed") or 0), "files_changed": int(diff_summary.get("files_changed") or 0),
"files": diff_summary.get("files") or [], "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): if isinstance(conversation_snapshot, dict):
row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot) row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot)
@ -484,6 +539,7 @@ class ConversationVersioningManager:
self._append_input(row) self._append_input(row)
meta["last_input_seq"] = seq meta["last_input_seq"] = seq
meta["last_commit"] = current_head meta["last_commit"] = current_head
meta["tracking_mode"] = normalized_tracking_mode
self.save_meta(meta) self.save_meta(meta)
return row return row
@ -686,7 +742,22 @@ class ConversationVersioningManager:
self.save_meta(meta) self.save_meta(meta)
return {"success": True, "commit": commit} 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() expected = str(expected_workspace_path or "").strip()
current = str(self.project_path) current = str(self.project_path)
workspace_matched = True if not expected else (Path(expected).expanduser().resolve() == 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: ) -> None:
"""Ensure first-run baseline is committed (hidden, no checkpoint row).""" """Ensure first-run baseline is committed (hidden, no checkpoint row)."""
try: try:
if not bool(getattr(web_terminal, "username", None) == "host"):
return
if _should_skip_versioning_for_message( if _should_skip_versioning_for_message(
message=message, message=message,
auto_user_message_event=auto_user_message_event, 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 {}) versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
if not bool(versioning_meta.get("enabled", False)): if not bool(versioning_meta.get("enabled", False)):
return 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( manager = ConversationVersioningManager(
project_path=workspace.project_path, project_path=workspace.project_path,
data_dir=workspace.data_dir, data_dir=workspace.data_dir,
conversation_id=conversation_id, conversation_id=conversation_id,
) )
baseline = manager.ensure_baseline_for_first_input() baseline = manager.ensure_baseline_for_first_input(tracking_mode=tracking_mode)
debug_log( debug_log(
f"[Versioning][Baseline] conv={conversation_id} " f"[Versioning][Baseline] conv={conversation_id} "
f"tracking_mode={tracking_mode} "
f"created={baseline.get('created')} skipped={baseline.get('skipped')} " f"created={baseline.get('created')} skipped={baseline.get('skipped')} "
f"reason={baseline.get('reason')} head={baseline.get('head')}" 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, auto_user_message_event: bool,
run_status: str = "completed", run_status: str = "completed",
) -> None: ) -> None:
"""Host-only hidden snapshot after current manual user input run finished.""" """Hidden snapshot after current manual user input run finished."""
try: try:
if not bool(getattr(web_terminal, "username", None) == "host"):
return
if _should_skip_versioning_for_message( if _should_skip_versioning_for_message(
message=message, message=message,
auto_user_message_event=auto_user_message_event, 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 {}) versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {})
if not bool(versioning_meta.get("enabled", False)): if not bool(versioning_meta.get("enabled", False)):
return 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_messages = conv_data.get("messages") or []
snapshot_payload = { snapshot_payload = {
"conversation_id": conversation_id, "conversation_id": conversation_id,
@ -237,9 +250,11 @@ def _record_hidden_versioning_checkpoint_after_run(
workspace_path=str(workspace.project_path), workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload, conversation_snapshot=snapshot_payload,
run_status=str(run_status or "completed"), run_status=str(run_status or "completed"),
tracking_mode=tracking_mode,
) )
debug_log( debug_log(
f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} " 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"msg_index={message_index} snapshot_messages={len(snapshot_messages)} "
f"commit={row.get('commit')} changed={row.get('changed')} status={row.get('run_status')}" 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": { "versioning": {
"enabled": True, "enabled": True,
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": tracking_mode,
"last_commit": row.get("commit"), "last_commit": row.get("commit"),
"last_input_seq": int(row.get("seq") or 0), "last_input_seq": int(row.get("seq") or 0),
"updated_at": datetime.now().isoformat(), "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}" 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: def _is_host_mode_request(username: str) -> bool:
return bool(session.get("host_mode")) or (username == "host") 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]: def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]:
normalized = _normalize_conv_id(conversation_id) normalized = _normalize_conv_id(conversation_id)
data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {} 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): if not isinstance(versioning, dict):
versioning = {} versioning = {}
enabled = bool(versioning.get("enabled", False)) enabled = bool(versioning.get("enabled", False))
tracking_mode = _normalize_versioning_tracking_mode(versioning.get("tracking_mode"))
mode = "overwrite" 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( def _update_conversation_versioning_meta(
@ -191,14 +249,17 @@ def _update_conversation_versioning_meta(
*, *,
enabled: bool, enabled: bool,
mode: str, mode: str,
tracking_mode: Optional[str] = None,
last_commit: Optional[str] = None, last_commit: Optional[str] = None,
last_input_seq: Optional[int] = None, last_input_seq: Optional[int] = None,
) -> bool: ) -> bool:
normalized = _normalize_conv_id(conversation_id) normalized = _normalize_conv_id(conversation_id)
normalized_tracking_mode = _normalize_versioning_tracking_mode(tracking_mode)
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"versioning": { "versioning": {
"enabled": bool(enabled), "enabled": bool(enabled),
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": normalized_tracking_mode,
"updated_at": datetime.now().isoformat(), "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) 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 拆分) === # === 背景生成对话标题(从 app_legacy 拆分) ===
@conversation_bp.route('/api/conversations', methods=['GET']) @conversation_bp.route('/api/conversations', methods=['GET'])
@api_login_required @api_login_required
@ -365,37 +499,46 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
session['thinking_mode'] = terminal.thinking_mode session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None) session['model_key'] = getattr(terminal, "model_key", None)
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if _is_host_mode_request(username): try:
try: vm = _get_conv_versioning_manager(workspace, normalized_id)
vm = _get_conv_versioning_manager(workspace, normalized_id) vmeta = _get_conversation_versioning_meta(terminal, normalized_id)
vmeta = _get_conversation_versioning_meta(terminal, normalized_id) tracking_mode = _normalize_versioning_tracking_mode(vmeta.get("tracking_mode"))
latest_checkpoint = vm.get_latest_checkpoint() if vmeta.get("enabled") else None enabled = bool(vmeta.get("enabled")) and _can_use_versioning_scope(username, tracking_mode)
latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None latest_checkpoint = vm.get_latest_checkpoint() if enabled else None
expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None
mismatch = vm.detect_mismatch(latest_commit, expected_workspace_path=expected_workspace_path) if vmeta.get("enabled") else { expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None
"has_repo": False, mismatch = vm.detect_mismatch(
"head": None, latest_commit,
"dirty": False, expected_workspace_path=expected_workspace_path,
"mismatch": False, tracking_mode=tracking_mode,
"workspace_matched": True, ) if enabled else {
} "has_repo": False,
result["versioning"] = { "head": None,
"enabled": bool(vmeta.get("enabled")), "dirty": False,
"mode": "overwrite", "mismatch": False,
"latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None, "workspace_matched": True,
"latest_commit": latest_commit, }
"mismatch": bool(mismatch.get("mismatch")), result["versioning"] = {
"dirty": bool(mismatch.get("dirty")), "host_mode": _is_host_mode_request(username),
"workspace_matched": bool(mismatch.get("workspace_matched", True)), "enabled": enabled,
} "mode": "overwrite",
except Exception as exc: "tracking_mode": tracking_mode,
debug_log(f"[Versioning] load status 读取失败: {exc}") "latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None,
result["versioning"] = { "latest_commit": latest_commit,
"enabled": False, "mismatch": bool(mismatch.get("mismatch")),
"mode": "overwrite", "dirty": bool(mismatch.get("dirty")),
"mismatch": False, "workspace_matched": bool(mismatch.get("workspace_matched", True)),
"error": str(exc), }
} 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', { 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) normalized_id = _normalize_conv_id(conversation_id)
host_mode = _is_host_mode_request(username) host_mode = _is_host_mode_request(username)
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id) 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) 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( mismatch = manager.detect_mismatch(
(latest or {}).get("commit"), (latest or {}).get("commit"),
expected_workspace_path=(latest or {}).get("workspace_path"), 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, "has_repo": False,
"head": None, "head": None,
"dirty": False, "dirty": False,
@ -610,8 +756,11 @@ def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, w
"data": { "data": {
"conversation_id": normalized_id, "conversation_id": normalized_id,
"host_mode": host_mode, "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", "mode": "overwrite",
"tracking_mode": tracking_mode,
"latest_seq": int((latest or {}).get("seq") or 0) if latest else None, "latest_seq": int((latest or {}).get("seq") or 0) if latest else None,
"latest_commit": (latest or {}).get("commit"), "latest_commit": (latest or {}).get("commit"),
"mismatch": bool(mismatch.get("mismatch")), "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): def update_conversation_versioning(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try: try:
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if not _is_host_mode_request(username): host_mode = _is_host_mode_request(username)
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 current_meta = _get_conversation_versioning_meta(terminal, normalized_id)
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
enabled = bool(payload.get("enabled")) enabled = bool(payload.get("enabled"))
mode = "overwrite" 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) 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: if enabled:
conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {} conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {}
snapshot_payload = { snapshot_payload = {
@ -650,10 +804,12 @@ def update_conversation_versioning(conversation_id, terminal: WebTerminal, works
init_result = manager.ensure_initial_checkpoint( init_result = manager.ensure_initial_checkpoint(
workspace_path=str(workspace.project_path), workspace_path=str(workspace.project_path),
conversation_snapshot=snapshot_payload, conversation_snapshot=snapshot_payload,
tracking_mode=tracking_mode,
) )
init_row = init_result.get("row") or {} init_row = init_result.get("row") or {}
debug_log( debug_log(
f"[Versioning][Init] conv={normalized_id} enabled={enabled} " 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"created={init_result.get('created')} reason={init_result.get('reason')} "
f"seq={init_row.get('seq')} commit={init_row.get('commit')}" 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, normalized_id,
enabled=enabled, enabled=enabled,
mode=mode, mode=mode,
tracking_mode=tracking_mode,
last_commit=meta.get("last_commit"), last_commit=meta.get("last_commit"),
last_input_seq=int(meta.get("last_input_seq") or 0), 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, "conversation_id": normalized_id,
"enabled": bool(meta.get("enabled")), "enabled": bool(meta.get("enabled")),
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": tracking_mode,
"last_commit": meta.get("last_commit"), "last_commit": meta.get("last_commit"),
"last_input_seq": int(meta.get("last_input_seq") or 0), "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): def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try: try:
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if not _is_host_mode_request(username): host_mode = _is_host_mode_request(username)
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id) 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"): 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) manager = _get_conv_versioning_manager(workspace, normalized_id)
rows = manager.list_checkpoints() rows = manager.list_checkpoints()
return jsonify({ return jsonify({
@ -703,6 +866,7 @@ def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTermi
"data": { "data": {
"enabled": True, "enabled": True,
"mode": "overwrite", "mode": "overwrite",
"tracking_mode": tracking_mode,
"items": rows, "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): def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try: try:
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if not _is_host_mode_request(username): host_mode = _is_host_mode_request(username)
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 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) manager = _get_conv_versioning_manager(workspace, normalized_id)
row = manager.get_checkpoint_detail(seq, include_patch=True) row = manager.get_checkpoint_detail(seq, include_patch=True)
if not row: 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): def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str):
try: try:
normalized_id = _normalize_conv_id(conversation_id) normalized_id = _normalize_conv_id(conversation_id)
if not _is_host_mode_request(username): host_mode = _is_host_mode_request(username)
return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
seq = int(payload.get("seq") or 0) seq = int(payload.get("seq") or 0)
if seq < 0: if seq < 0:
@ -750,16 +916,28 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
checkpoint = manager.get_checkpoint(seq) checkpoint = manager.get_checkpoint(seq)
if not checkpoint: if not checkpoint:
return jsonify({"success": False, "error": "未找到对应版本点"}), 404 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( debug_log(
f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} " 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}" f"target_commit={checkpoint.get('commit')} workspace={workspace.project_path}"
) )
manager.restore_to_commit(checkpoint.get("commit")) if tracking_mode == ConversationVersioningManager.TRACKING_MODE_WORKSPACE_AND_CONVERSATION:
debug_log( manager.restore_to_commit(checkpoint.get("commit"))
f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} " debug_log(
f"commit={checkpoint.get('commit')}" 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 cm = terminal.context_manager.conversation_manager
conv_data = cm.load_conversation(normalized_id) or {} conv_data = cm.load_conversation(normalized_id) or {}
@ -814,6 +992,9 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
if not ok: if not ok:
return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500 return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500
prune_info = manager.prune_checkpoints_after(seq) 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( debug_log(
f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} " f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} "
f"kept={prune_info.get('kept')} removed={prune_info.get('removed')} " 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, normalized_id,
enabled=True, enabled=True,
mode="overwrite", 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), last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
) )
cm.update_conversation_metadata( cm.update_conversation_metadata(
@ -835,7 +1017,8 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
"versioning": { "versioning": {
"enabled": True, "enabled": True,
"mode": "overwrite", "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), "last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0),
"updated_at": datetime.now().isoformat(), "updated_at": datetime.now().isoformat(),
}, },
@ -880,6 +1063,7 @@ def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTer
"conversation_id": target_conversation_id, "conversation_id": target_conversation_id,
"restored_seq": int(checkpoint.get("seq") or 0), "restored_seq": int(checkpoint.get("seq") or 0),
"restored_commit": checkpoint.get("commit"), "restored_commit": checkpoint.get("commit"),
"tracking_mode": tracking_mode,
"source_conversation_id": normalized_id, "source_conversation_id": normalized_id,
} }
}) })

View File

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

View File

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

View File

@ -1,6 +1,12 @@
// @ts-nocheck // @ts-nocheck
import { debugLog } from './common'; 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 = { export const versioningMethods = {
async fetchVersioningStatus(conversationId = null, options = {}) { async fetchVersioningStatus(conversationId = null, options = {}) {
const targetId = conversationId || this.currentConversationId; const targetId = conversationId || this.currentConversationId;
@ -18,6 +24,14 @@ export const versioningMethods = {
const payload = data.data || {}; const payload = data.data || {};
this.versioningHostMode = !!payload.host_mode; this.versioningHostMode = !!payload.host_mode;
this.versioningEnabled = !!payload.enabled; 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.versioningMode = 'overwrite';
this.versioningRestoreMode = 'overwrite'; this.versioningRestoreMode = 'overwrite';
this.versioningMismatch = !!payload.mismatch; this.versioningMismatch = !!payload.mismatch;
@ -45,6 +59,9 @@ export const versioningMethods = {
if (!resp.ok || !data?.success) { if (!resp.ok || !data?.success) {
throw new Error(data?.error || '加载版本点失败'); 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 : []; const items = Array.isArray(data?.data?.items) ? data.data.items : [];
this.versioningCheckpoints = items; this.versioningCheckpoints = items;
if (!items.length) { if (!items.length) {
@ -67,6 +84,12 @@ export const versioningMethods = {
async openVersioningDialog() { async openVersioningDialog() {
if (!this.currentConversationId) { if (!this.currentConversationId) {
this.versioningEnabled = !!this.newConversationVersioningEnabled; this.versioningEnabled = !!this.newConversationVersioningEnabled;
this.versioningTrackingMode = normalizeTrackingMode(
this.newConversationVersioningTrackingMode
);
if (!this.versioningHostMode && this.versioningTrackingMode !== 'conversation_only') {
this.versioningTrackingMode = 'conversation_only';
}
this.versioningWorkspaceMatched = true; this.versioningWorkspaceMatched = true;
this.versioningCheckpoints = []; this.versioningCheckpoints = [];
this.versioningSelectedSeq = null; this.versioningSelectedSeq = null;
@ -87,18 +110,22 @@ export const versioningMethods = {
await this.fetchVersioningCheckpoints(this.currentConversationId); await this.fetchVersioningCheckpoints(this.currentConversationId);
}, },
async handleVersioningTrackingModeChange(mode: string) {
await this.updateVersioningTrackingMode(mode);
},
async toggleConversationVersioning(enabled: boolean) { async toggleConversationVersioning(enabled: boolean) {
if (!this.currentConversationId) { if (!this.currentConversationId) {
if (!this.versioningHostMode) {
this.uiPushToast({
title: '版本管理',
message: '仅宿主机模式支持版本管理',
type: 'warning'
});
return;
}
this.newConversationVersioningEnabled = !!enabled; this.newConversationVersioningEnabled = !!enabled;
this.versioningEnabled = !!enabled; this.versioningEnabled = !!enabled;
if (enabled) {
if (!this.versioningHostMode) {
this.versioningTrackingMode = 'conversation_only';
}
this.newConversationVersioningTrackingMode = normalizeTrackingMode(
this.versioningTrackingMode
);
}
this.uiPushToast({ this.uiPushToast({
title: '版本管理', title: '版本管理',
message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理', message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理',
@ -108,12 +135,18 @@ export const versioningMethods = {
} }
this.versioningLoading = true; this.versioningLoading = true;
try { 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`, { const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
enabled: !!enabled, enabled: !!enabled,
mode: 'overwrite' mode: 'overwrite',
tracking_mode: trackingMode
}) })
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
@ -121,6 +154,9 @@ export const versioningMethods = {
throw new Error(data?.error || '切换版本管理失败'); throw new Error(data?.error || '切换版本管理失败');
} }
this.versioningEnabled = !!data?.data?.enabled; this.versioningEnabled = !!data?.data?.enabled;
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || this.versioningTrackingMode
);
this.versioningMode = 'overwrite'; this.versioningMode = 'overwrite';
this.versioningRestoreMode = 'overwrite'; this.versioningRestoreMode = 'overwrite';
this.uiPushToast({ this.uiPushToast({
@ -264,15 +300,20 @@ export const versioningMethods = {
async applyPendingVersioningToConversation(conversationId: string) { async applyPendingVersioningToConversation(conversationId: string) {
const targetId = conversationId || this.currentConversationId; const targetId = conversationId || this.currentConversationId;
if (!targetId) return; if (!targetId) return;
if (!this.versioningHostMode) return;
if (!this.newConversationVersioningEnabled) return; if (!this.newConversationVersioningEnabled) return;
try { try {
const trackingMode = normalizeTrackingMode(
!this.versioningHostMode
? 'conversation_only'
: (this.newConversationVersioningTrackingMode || this.versioningTrackingMode)
);
const resp = await fetch(`/api/conversations/${targetId}/versioning`, { const resp = await fetch(`/api/conversations/${targetId}/versioning`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
enabled: true, enabled: true,
mode: 'overwrite' mode: 'overwrite',
tracking_mode: trackingMode
}) })
}); });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
@ -281,6 +322,9 @@ export const versioningMethods = {
} }
this.newConversationVersioningEnabled = false; this.newConversationVersioningEnabled = false;
this.versioningEnabled = true; this.versioningEnabled = true;
this.versioningTrackingMode = normalizeTrackingMode(
data?.data?.tracking_mode || trackingMode
);
} catch (error) { } catch (error) {
this.uiPushToast({ this.uiPushToast({
title: '版本管理', title: '版本管理',
@ -288,5 +332,64 @@ export const versioningMethods = {
type: 'error' 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: '', hostWorkspaceCreateError: '',
versioningEnabled: false, versioningEnabled: false,
newConversationVersioningEnabled: false, newConversationVersioningEnabled: false,
versioningTrackingMode: 'workspace_and_conversation',
newConversationVersioningTrackingMode: 'workspace_and_conversation',
versioningMode: 'overwrite', versioningMode: 'overwrite',
versioningMismatch: false, versioningMismatch: false,
versioningWorkspaceMatched: true, versioningWorkspaceMatched: true,
@ -149,6 +151,10 @@ export function dataState() {
connectionHeartbeatRequestTimeoutMs: 5000, connectionHeartbeatRequestTimeoutMs: 5000,
connectionHeartbeatIntervalMs: 8000, connectionHeartbeatIntervalMs: 8000,
connectionHeartbeatDisconnectedIntervalMs: 1000, connectionHeartbeatDisconnectedIntervalMs: 1000,
composerDraftSaveTimer: null,
composerDraftDirty: false,
composerDraftLastSyncedContent: '',
composerDraftFetchSeq: 0,
// 工具控制菜单 // 工具控制菜单
icons: ICONS, icons: ICONS,

View File

@ -8,11 +8,24 @@
<input <input
type="checkbox" type="checkbox"
:checked="enabled" :checked="enabled"
:disabled="!hostMode || loading" :disabled="loading"
@change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)" @change="$emit('toggle-enabled', ($event.target as HTMLInputElement).checked)"
/> />
<span>开启</span> <span>开启</span>
</label> </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>
<div class="versioning-header-actions"> <div class="versioning-header-actions">
<button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')"> <button type="button" class="refresh-btn" :disabled="loading" @click="$emit('refresh')">
@ -84,14 +97,14 @@
</div> </div>
<footer class="versioning-footer"> <footer class="versioning-footer">
<div v-if="workspaceMatched === false" class="workspace-mismatch-tip"> <div v-if="needWorkspaceMatch && workspaceMatched === false" class="workspace-mismatch-tip">
当前对话所属工作区与当前工作区不一致无法执行回溯 当前对话所属工作区与当前工作区不一致无法执行回溯
</div> </div>
<label>回溯模式覆盖当前对话</label> <label>回溯模式覆盖当前对话</label>
<button <button
type="button" type="button"
class="confirm-btn" 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')" @click="$emit('confirm')"
> >
{{ restoring ? '回溯中...' : '确认回溯' }} {{ restoring ? '回溯中...' : '确认回溯' }}
@ -107,6 +120,7 @@ import { computed, ref, watch } from 'vue';
const props = defineProps<{ const props = defineProps<{
hostMode: boolean; hostMode: boolean;
enabled: boolean; enabled: boolean;
trackingMode: string;
loading: boolean; loading: boolean;
items: any[]; items: any[];
selectedSeq: number | null; selectedSeq: number | null;
@ -121,11 +135,14 @@ defineEmits([
'close', 'close',
'refresh', 'refresh',
'toggle-enabled', 'toggle-enabled',
'update:tracking-mode',
'select', 'select',
'confirm', 'confirm',
'update:restore-mode' 'update:restore-mode'
]); ]);
const needWorkspaceMatch = computed(() => String(props.trackingMode || '') !== 'conversation_only');
const orderedItems = computed(() => { const orderedItems = computed(() => {
const src = Array.isArray(props.items) ? props.items : []; const src = Array.isArray(props.items) ? props.items : [];
return [...src].sort((a: any, b: any) => Number(a?.seq || 0) - Number(b?.seq || 0)); 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; } .versioning-header-actions { display: inline-flex; align-items: center; gap: 8px; }
.close-btn { border: 0; background: transparent; font-size: 24px; cursor: pointer; } .close-btn { border: 0; background: transparent; font-size: 24px; cursor: pointer; }
.switch-line { display: flex; align-items: center; gap: 8px; font-size: 14px; } .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; } .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); } .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-list { overflow: auto; border-right: 1px solid var(--theme-control-border); padding: 10px; }