From d036ef5ccd8e273b67ddf9e6d6fac7cc59c238e3 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Wed, 6 May 2026 19:18:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(versioning):=20=E6=94=AF=E6=8C=81=E4=BB=85?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=AF=B9=E8=AF=9D=E5=B9=B6=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=20docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/versioning_manager.py | 117 +++++-- server/chat_flow_task_main.py | 28 +- server/conversation.py | 288 ++++++++++++++---- server/deep_compression.py | 11 +- static/src/App.vue | 2 + static/src/app/methods/versioning.ts | 125 +++++++- static/src/app/state.ts | 6 + .../components/overlay/VersioningDialog.vue | 25 +- 8 files changed, 506 insertions(+), 96 deletions(-) diff --git a/modules/versioning_manager.py b/modules/versioning_manager.py index f316aa2..e2040ab 100644 --- a/modules/versioning_manager.py +++ b/modules/versioning_manager.py @@ -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) diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 6a743ff..e186009 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -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(), diff --git a/server/conversation.py b/server/conversation.py index 9f5b135..35ede1c 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -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, } }) diff --git a/server/deep_compression.py b/server/deep_compression.py index ea2d99e..141517b 100644 --- a/server/deep_compression.py +++ b/server/deep_compression.py @@ -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), diff --git a/static/src/App.vue b/static/src/App.vue index aa8e6ca..8daadf3 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -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" /> diff --git a/static/src/app/methods/versioning.ts b/static/src/app/methods/versioning.ts index 7f86111..6e542cc 100644 --- a/static/src/app/methods/versioning.ts +++ b/static/src/app/methods/versioning.ts @@ -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; + } } }; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index d2e60b2..c0d45cc 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -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, diff --git a/static/src/components/overlay/VersioningDialog.vue b/static/src/components/overlay/VersioningDialog.vue index 099750d..25275b4 100644 --- a/static/src/components/overlay/VersioningDialog.vue +++ b/static/src/components/overlay/VersioningDialog.vue @@ -8,11 +8,24 @@ 开启 +