From 6379f8c7296ae49817c0c821bd453e9e8edee6ba Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 11 Apr 2026 16:16:40 +0800 Subject: [PATCH] feat: complete conversation versioning UX and restore workflow --- modules/personalization_manager.py | 3 + modules/versioning_manager.py | 746 ++++++++++++++++++ server/chat_flow.py | 15 +- server/chat_flow_task_main.py | 180 ++++- server/conversation.py | 394 +++++++++ server/tasks.py | 13 + static/src/App.vue | 24 + static/src/app.ts | 2 + static/src/app/components.ts | 4 + static/src/app/methods/conversation.ts | 4 + static/src/app/methods/message.ts | 1 + static/src/app/methods/ui.ts | 9 + static/src/app/methods/versioning.ts | 286 +++++++ static/src/app/state.ts | 13 + static/src/app/watchers.ts | 3 + static/src/components/input/InputComposer.vue | 37 +- .../components/overlay/VersioningDialog.vue | 253 ++++++ .../components/panels/ToolApprovalPanel.vue | 9 + .../personalization/PersonalizationDrawer.vue | 17 + static/src/stores/personalization.ts | 11 + .../styles/components/chat/_chat-area.scss | 7 +- .../styles/components/input/_composer.scss | 9 + .../components/panels/_right-panel.scss | 5 + 23 files changed, 2029 insertions(+), 16 deletions(-) create mode 100644 modules/versioning_manager.py create mode 100644 static/src/app/methods/versioning.ts create mode 100644 static/src/components/overlay/VersioningDialog.vue diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index c70bf4b..1400b5b 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -71,6 +71,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "deep_compress_trigger_tokens": None, "silent_tool_disable": False, # 禁用工具时不向模型插入提示 "enhanced_tool_display": True, # 增强工具显示 + "versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite } __all__ = [ @@ -233,6 +234,8 @@ def sanitize_personalization_payload( elif base.get("default_permission_mode") not in ALLOWED_PERMISSION_MODES: base["default_permission_mode"] = "unrestricted" + base["versioning_restore_mode"] = "overwrite" + # 默认模型 chosen_model = data.get("default_model", base.get("default_model")) if isinstance(chosen_model, str) and chosen_model in allowed_models: diff --git a/modules/versioning_manager.py b/modules/versioning_manager.py new file mode 100644 index 0000000..533ca4e --- /dev/null +++ b/modules/versioning_manager.py @@ -0,0 +1,746 @@ +"""Conversation-scoped hidden git versioning for host workspace.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from collections import deque + + +class VersioningError(RuntimeError): + """Raised when hidden git versioning fails.""" + + +@dataclass +class VersioningPaths: + save_root: Path + git_dir: Path + meta_path: Path + inputs_path: Path + snapshots_dir: Path + + +class ConversationVersioningManager: + """Manage hidden git snapshots bound to a conversation.""" + + def __init__(self, project_path: Path | str, data_dir: Path | str, conversation_id: str): + self.project_path = Path(project_path).expanduser().resolve() + self.data_dir = Path(data_dir).expanduser().resolve() + self.conversation_id = str(conversation_id or "").strip() + if not self.conversation_id: + raise VersioningError("缺少 conversation_id") + self.paths = VersioningPaths( + save_root=(self.data_dir / "save" / self.conversation_id).resolve(), + git_dir=(self.data_dir / "save" / self.conversation_id / "git").resolve(), + meta_path=(self.data_dir / "save" / self.conversation_id / "meta.json").resolve(), + inputs_path=(self.data_dir / "save" / self.conversation_id / "inputs.jsonl").resolve(), + snapshots_dir=(self.data_dir / "save" / self.conversation_id / "snapshots").resolve(), + ) + + # ---------------------------- + # low-level helpers + # ---------------------------- + def _run_git( + self, + args: List[str], + *, + check: bool = True, + timeout_seconds: int = 180, + ) -> Tuple[bool, str, str]: + git_bin = shutil.which("git") + if not git_bin: + raise VersioningError("未检测到 git 可执行文件") + cmd = [ + git_bin, + "-c", + "core.quotepath=false", + f"--git-dir={self.paths.git_dir}", + f"--work-tree={self.project_path}", + *args, + ] + try: + completed = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + raise VersioningError(f"git 执行超时: {' '.join(args)}") from exc + ok = completed.returncode == 0 + stdout = (completed.stdout or "").strip() + stderr = (completed.stderr or "").strip() + if check and not ok: + raise VersioningError(stderr or stdout or f"git 命令失败: {' '.join(args)}") + return ok, stdout, stderr + + def _ensure_exclude_paths(self) -> None: + """Avoid recursive tracking if save root is inside work tree.""" + try: + rel_save = self.paths.save_root.relative_to(self.project_path) + except ValueError: + return + info_exclude = self.paths.git_dir / "info" / "exclude" + info_exclude.parent.mkdir(parents=True, exist_ok=True) + existing = "" + if info_exclude.exists(): + try: + existing = info_exclude.read_text(encoding="utf-8", errors="ignore") + except OSError: + existing = "" + marker = str(rel_save).replace("\\", "/").rstrip("/") + if not marker: + return + line = f"{marker}/" + if line not in existing.splitlines(): + with info_exclude.open("a", encoding="utf-8") as fh: + if existing and not existing.endswith("\n"): + fh.write("\n") + fh.write(line + "\n") + + def _stage_all(self) -> None: + self._ensure_exclude_paths() + # Stage everything under work tree; exclusions are handled via info/exclude. + self._run_git(["add", "-A", "--", "."]) + + def _get_head_commit(self) -> Optional[str]: + ok, out, _ = self._run_git(["rev-parse", "HEAD"], check=False) + return out if ok and out else None + + def _is_clean(self) -> bool: + ok, out, _ = self._run_git(["status", "--porcelain"], check=False) + if not ok: + return False + return not bool(out.strip()) + + # ---------------------------- + # metadata / records + # ---------------------------- + def load_meta(self) -> Dict[str, Any]: + if not self.paths.meta_path.exists(): + return { + "enabled": False, + "mode": "overwrite", + "last_commit": None, + "last_input_seq": 0, + "updated_at": None, + } + try: + return json.loads(self.paths.meta_path.read_text(encoding="utf-8")) or {} + except Exception: + return { + "enabled": False, + "mode": "overwrite", + "last_commit": None, + "last_input_seq": 0, + "updated_at": None, + } + + def save_meta(self, meta: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(meta or {}) + payload["updated_at"] = datetime.now().isoformat() + self.paths.meta_path.parent.mkdir(parents=True, exist_ok=True) + self.paths.meta_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload + + def _read_inputs(self) -> List[Dict[str, Any]]: + if not self.paths.inputs_path.exists(): + return [] + rows: List[Dict[str, Any]] = [] + try: + for raw in self.paths.inputs_path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw.strip() + if not line: + continue + try: + row = json.loads(line) + except Exception: + continue + if isinstance(row, dict): + rows.append(self._normalize_checkpoint_row(row)) + except OSError: + return [] + return rows + + def _append_input(self, row: Dict[str, Any]) -> None: + self.paths.inputs_path.parent.mkdir(parents=True, exist_ok=True) + with self.paths.inputs_path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(row, ensure_ascii=False) + "\n") + + def _write_inputs(self, rows: List[Dict[str, Any]]) -> None: + self.paths.inputs_path.parent.mkdir(parents=True, exist_ok=True) + content = "\n".join(json.dumps(row, ensure_ascii=False) for row in (rows or [])) + if content: + content += "\n" + self.paths.inputs_path.write_text(content, encoding="utf-8") + + def _decode_git_path(self, raw_path: Any) -> str: + text = str(raw_path or "") + stripped = text.strip() + if len(stripped) >= 2 and stripped[0] == '"' and stripped[-1] == '"': + body = stripped[1:-1] + try: + decoded = bytes(body, "utf-8").decode("unicode_escape") + except Exception: + decoded = body + try: + return decoded.encode("latin-1").decode("utf-8") + except Exception: + return decoded + return text + + def _normalize_checkpoint_row(self, row: Dict[str, Any]) -> Dict[str, Any]: + payload = dict(row or {}) + files = payload.get("files") + if isinstance(files, list): + normalized_files: List[Dict[str, Any]] = [] + for file_item in files: + if not isinstance(file_item, dict): + continue + item = dict(file_item) + item["path"] = self._decode_git_path(item.get("path")) + if item.get("old_path") is not None: + item["old_path"] = self._decode_git_path(item.get("old_path")) + normalized_files.append(item) + payload["files"] = normalized_files + return payload + + def _write_snapshot(self, seq: int, payload: Dict[str, Any]) -> str: + self.paths.snapshots_dir.mkdir(parents=True, exist_ok=True) + target = self.paths.snapshots_dir / f"{int(seq)}.json" + target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return str(target.relative_to(self.paths.save_root)) + + def get_checkpoint_snapshot(self, seq: int) -> Optional[Dict[str, Any]]: + row = self.get_checkpoint(seq) + if not row: + return None + rel = row.get("snapshot_file") + if not rel: + return None + path = (self.paths.save_root / str(rel)).resolve() + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) or None + except Exception: + return None + + # ---------------------------- + # repo lifecycle + # ---------------------------- + def ensure_repo(self) -> Dict[str, Any]: + self.paths.save_root.mkdir(parents=True, exist_ok=True) + self.project_path.mkdir(parents=True, exist_ok=True) + if not self.paths.git_dir.exists(): + self.paths.git_dir.mkdir(parents=True, exist_ok=True) + if not (self.paths.git_dir / "HEAD").exists(): + self._run_git(["init"]) + self._run_git(["config", "user.name", "EasyAgent Versioning"], check=False) + self._run_git(["config", "user.email", "easyagent-versioning@local"], check=False) + self._ensure_exclude_paths() + head = self._get_head_commit() + if not head: + self._stage_all() + self._run_git(["commit", "--allow-empty", "-m", "init workspace snapshot"]) + head = self._get_head_commit() + return {"ok": True, "head": head} + + def set_enabled(self, enabled: bool, mode: Optional[str] = None) -> Dict[str, Any]: + meta = self.load_meta() + if enabled: + self.ensure_repo() + meta["enabled"] = bool(enabled) + meta["mode"] = "overwrite" + meta["last_commit"] = self._get_head_commit() + saved = self.save_meta(meta) + return saved + + def ensure_initial_checkpoint( + self, + *, + workspace_path: Optional[str] = None, + conversation_snapshot: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Ensure a visible seq=0 checkpoint exists after enabling versioning. + """ + self.ensure_repo() + existing = self.get_checkpoint(0) + if existing: + return {"created": False, "row": existing, "reason": "already_exists"} + + current_head = self._get_head_commit() + if not current_head: + raise VersioningError("创建初始版本点失败:未获取到 commit") + + row = { + "seq": 0, + "message": "版本管理开启(初始状态)", + "message_preview": "版本管理开启(初始状态)", + "message_index": -1, + "timestamp": datetime.now().isoformat(), + "workspace_path": str(workspace_path or ""), + "commit": current_head, + "parent_commit": None, + "insertions": 0, + "deletions": 0, + "files_changed": 0, + "files": [], + "changed": False, + "run_status": "initial", + } + if isinstance(conversation_snapshot, dict): + row["snapshot_file"] = self._write_snapshot(0, conversation_snapshot) + self._append_input(row) + meta = self.load_meta() + meta["last_commit"] = current_head + if int(meta.get("last_input_seq") or 0) < 0: + meta["last_input_seq"] = 0 + self.save_meta(meta) + return {"created": True, "row": row, "reason": "created"} + + def ensure_baseline_for_first_input(self) -> Dict[str, Any]: + """ + Ensure the baseline commit reflects current workspace before first manual input checkpoint. + + This is intentionally hidden from checkpoint list (no seq row created). + """ + self.ensure_repo() + meta = self.load_meta() + if self.get_checkpoint(0): + return { + "created": False, + "skipped": True, + "reason": "initial_checkpoint_exists", + "head": self._get_head_commit(), + } + if int(meta.get("last_input_seq") or 0) > 0: + return { + "created": False, + "skipped": True, + "reason": "already_has_input_checkpoint", + "head": self._get_head_commit(), + } + + previous_head = self._get_head_commit() + self._stage_all() + _, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False) + has_changes = bool((staged_numstat or "").strip()) + if has_changes: + self._run_git(["commit", "-m", "baseline before input#1"]) + current_head = self._get_head_commit() + meta["last_commit"] = current_head + self.save_meta(meta) + return { + "created": bool(has_changes), + "skipped": False, + "reason": "baseline_committed" if has_changes else "baseline_unchanged", + "head": current_head, + "parent_commit": previous_head, + "has_changes": has_changes, + } + + # ---------------------------- + # diff / checkpoint + # ---------------------------- + def _parse_numstat(self, text: str) -> Dict[str, Dict[str, int]]: + stats: Dict[str, Dict[str, int]] = {} + for line in (text or "").splitlines(): + parts = line.split("\t") + if len(parts) < 3: + continue + ins_raw, del_raw = parts[0], parts[1] + path = self._decode_git_path(parts[2]) + ins = 0 if ins_raw == "-" else int(ins_raw or 0) + dele = 0 if del_raw == "-" else int(del_raw or 0) + stats[path] = {"insertions": ins, "deletions": dele} + return stats + + def _build_diff_summary(self, parent_commit: Optional[str], commit: str) -> Dict[str, Any]: + if parent_commit: + _, numstat_text, _ = self._run_git(["diff", "--numstat", parent_commit, commit], check=False) + _, status_text, _ = self._run_git(["diff", "--name-status", parent_commit, commit], check=False) + else: + _, numstat_text, _ = self._run_git(["show", "--numstat", "--format=", commit], check=False) + _, status_text, _ = self._run_git( + ["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", commit], + check=False, + ) + + numstats = self._parse_numstat(numstat_text) + files: List[Dict[str, Any]] = [] + total_insertions = 0 + total_deletions = 0 + for raw in (status_text or "").splitlines(): + line = raw.strip() + if not line: + continue + parts = line.split("\t") + code = parts[0] if parts else "" + path = self._decode_git_path(parts[-1] if len(parts) >= 2 else "") + old_path = self._decode_git_path(parts[1]) if code.startswith("R") and len(parts) >= 3 else None + file_stat = numstats.get(path, {"insertions": 0, "deletions": 0}) + ins = int(file_stat.get("insertions") or 0) + dele = int(file_stat.get("deletions") or 0) + total_insertions += ins + total_deletions += dele + files.append( + { + "path": path, + "status": code, + "old_path": old_path, + "insertions": ins, + "deletions": dele, + } + ) + return { + "insertions": total_insertions, + "deletions": total_deletions, + "files_changed": len(files), + "files": files, + } + + def create_checkpoint( + self, + *, + message: str, + message_index: int, + workspace_path: Optional[str] = None, + conversation_snapshot: Optional[Dict[str, Any]] = None, + run_status: Optional[str] = None, + ) -> Dict[str, Any]: + self.ensure_repo() + previous_head = self._get_head_commit() + self._stage_all() + _, staged_numstat, _ = self._run_git(["diff", "--cached", "--numstat"], check=False) + has_changes = bool((staged_numstat or "").strip()) + if has_changes: + next_seq = int(self.load_meta().get("last_input_seq") or 0) + 1 + title = (message or "").strip().splitlines()[0][:72] if message else "" + commit_msg = f"input#{next_seq} {title}".strip() + self._run_git(["commit", "-m", commit_msg]) + current_head = self._get_head_commit() + if not current_head: + raise VersioningError("创建版本快照失败:未获取到 commit") + + diff_summary = ( + self._build_diff_summary(previous_head, current_head) + if current_head != previous_head + else {"insertions": 0, "deletions": 0, "files_changed": 0, "files": []} + ) + + meta = self.load_meta() + seq = int(meta.get("last_input_seq") or 0) + 1 + row = { + "seq": seq, + "message": (message or "").strip(), + "message_preview": ((message or "").strip().splitlines() or [""])[0][:200], + "message_index": int(message_index), + "timestamp": datetime.now().isoformat(), + "workspace_path": str(workspace_path or ""), + "commit": current_head, + "parent_commit": previous_head, + "insertions": int(diff_summary.get("insertions") or 0), + "deletions": int(diff_summary.get("deletions") or 0), + "files_changed": int(diff_summary.get("files_changed") or 0), + "files": diff_summary.get("files") or [], + "changed": bool(current_head != previous_head), + } + if isinstance(conversation_snapshot, dict): + row["snapshot_file"] = self._write_snapshot(seq, conversation_snapshot) + if run_status: + row["run_status"] = str(run_status) + self._append_input(row) + meta["last_input_seq"] = seq + meta["last_commit"] = current_head + self.save_meta(meta) + return row + + def list_checkpoints(self) -> List[Dict[str, Any]]: + rows = self._read_inputs() + rows.sort(key=lambda item: int(item.get("seq") or 0), reverse=True) + return rows + + def get_checkpoint(self, seq: int) -> Optional[Dict[str, Any]]: + for row in self._read_inputs(): + if int(row.get("seq") or 0) == int(seq): + return row + return None + + def _collect_file_patch_lines( + self, + *, + parent_commit: Optional[str], + commit: str, + path: str, + old_path: Optional[str] = None, + max_lines: int = 600, + ) -> Dict[str, Any]: + target_paths: List[str] = [] + for candidate in [old_path, path]: + value = str(candidate or "").strip() + if value and value not in target_paths: + target_paths.append(value) + if not target_paths: + return {"lines": [], "truncated": False} + + if parent_commit: + _, patch_text, _ = self._run_git( + ["diff", "--no-color", "--unified=200", parent_commit, commit, "--", *target_paths], + check=False, + ) + else: + _, patch_text, _ = self._run_git( + ["show", "--no-color", "--unified=200", "--format=", commit, "--", *target_paths], + check=False, + ) + parsed = self._extract_contextual_patch_lines(patch_text or "", max_lines=max_lines) + lines = parsed.get("lines") or [] + truncated = bool(parsed.get("truncated")) + # 回退策略:若按路径过滤拿不到行,但该文件确实有增删,尝试从整次 commit patch 中按文件段提取。 + if not lines: + fallback = self._collect_file_patch_lines_from_full_diff( + parent_commit=parent_commit, + commit=commit, + target_paths=target_paths, + max_lines=max_lines, + ) + lines = fallback.get("lines") or [] + truncated = bool(fallback.get("truncated")) or truncated + return {"lines": lines, "truncated": truncated} + + def _collect_file_patch_lines_from_full_diff( + self, + *, + parent_commit: Optional[str], + commit: str, + target_paths: List[str], + max_lines: int = 600, + ) -> Dict[str, Any]: + if parent_commit: + _, patch_text, _ = self._run_git( + ["diff", "--no-color", "--unified=200", parent_commit, commit], + check=False, + ) + else: + _, patch_text, _ = self._run_git( + ["show", "--no-color", "--unified=200", "--format=", commit], + check=False, + ) + + targets = {str(p or "").lstrip("./") for p in target_paths if str(p or "").strip()} + chunk_lines: List[str] = [] + lines: List[Dict[str, Any]] = [] + truncated = False + in_target_section = False + for raw in (patch_text or "").splitlines(): + if raw.startswith("diff --git "): + if in_target_section and chunk_lines and len(lines) < max_lines: + parsed_chunk = self._extract_contextual_patch_lines("\n".join(chunk_lines), max_lines=max_lines - len(lines)) + lines.extend(parsed_chunk.get("lines") or []) + truncated = truncated or bool(parsed_chunk.get("truncated")) + chunk_lines = [] + in_target_section = False + try: + parts = raw.split(" ") + a_path = self._decode_git_path((parts[2] or "").replace("a/", "", 1)).lstrip("./") + b_path = self._decode_git_path((parts[3] or "").replace("b/", "", 1)).lstrip("./") + if a_path in targets or b_path in targets: + in_target_section = True + except Exception: + in_target_section = False + continue + if not in_target_section: + continue + chunk_lines.append(raw) + if len(lines) >= max_lines: + truncated = True + break + if in_target_section and chunk_lines and len(lines) < max_lines and not truncated: + parsed_chunk = self._extract_contextual_patch_lines("\n".join(chunk_lines), max_lines=max_lines - len(lines)) + lines.extend(parsed_chunk.get("lines") or []) + truncated = truncated or bool(parsed_chunk.get("truncated")) + if len(lines) > max_lines: + lines = lines[:max_lines] + truncated = True + return {"lines": lines, "truncated": truncated} + + def get_checkpoint_detail(self, seq: int, *, include_patch: bool = True) -> Optional[Dict[str, Any]]: + row = self.get_checkpoint(seq) + if not row: + return None + payload = dict(row) + files = payload.get("files") + if not include_patch or not isinstance(files, list): + return payload + parent_commit = payload.get("parent_commit") + commit = payload.get("commit") + normalized_files: List[Dict[str, Any]] = [] + for file_item in files: + if not isinstance(file_item, dict): + continue + item = dict(file_item) + if commit and str(item.get("path") or "").strip(): + patch = self._collect_file_patch_lines( + parent_commit=parent_commit, + commit=commit, + path=str(item.get("path") or ""), + old_path=item.get("old_path"), + ) + item["patch_lines"] = patch.get("lines") or [] + item["patch_truncated"] = bool(patch.get("truncated")) + else: + item["patch_lines"] = [] + item["patch_truncated"] = False + normalized_files.append(item) + payload["files"] = normalized_files + return payload + + def get_latest_checkpoint(self) -> Optional[Dict[str, Any]]: + rows = self.list_checkpoints() + return rows[0] if rows else None + + def prune_checkpoints_after(self, seq: int) -> Dict[str, Any]: + target = int(seq) + rows = self._read_inputs() + if not rows: + return {"kept": 0, "removed": 0, "max_seq": 0} + + kept_rows = [row for row in rows if int(row.get("seq") or 0) <= target] + removed_rows = [row for row in rows if int(row.get("seq") or 0) > target] + + for row in removed_rows: + rel = row.get("snapshot_file") + if not rel: + continue + try: + path = (self.paths.save_root / str(rel)).resolve() + if path.exists() and path.is_file(): + path.unlink() + except Exception: + continue + + self._write_inputs(kept_rows) + max_seq = max((int(r.get("seq") or 0) for r in kept_rows), default=0) + latest_kept = None + for r in kept_rows: + if int(r.get("seq") or 0) == max_seq: + latest_kept = r + meta = self.load_meta() + meta["last_input_seq"] = max_seq + meta["last_commit"] = (latest_kept or {}).get("commit") or self._get_head_commit() + self.save_meta(meta) + return { + "kept": len(kept_rows), + "removed": len(removed_rows), + "max_seq": max_seq, + "last_commit": meta.get("last_commit"), + } + + # ---------------------------- + # restore / status + # ---------------------------- + def restore_to_commit(self, commit: str) -> Dict[str, Any]: + if not commit: + raise VersioningError("缺少 commit") + self.ensure_repo() + ok, _, _ = self._run_git(["cat-file", "-e", f"{commit}^{{commit}}"], check=False) + if not ok: + raise VersioningError("目标 commit 不存在") + self._run_git(["reset", "--hard", commit]) + self._run_git(["clean", "-fd"]) + meta = self.load_meta() + meta["last_restored_commit"] = commit + meta["last_commit"] = self._get_head_commit() + self.save_meta(meta) + return {"success": True, "commit": commit} + + def detect_mismatch(self, latest_commit: Optional[str], expected_workspace_path: Optional[str] = None) -> Dict[str, Any]: + expected = str(expected_workspace_path or "").strip() + current = str(self.project_path) + workspace_matched = True if not expected else (Path(expected).expanduser().resolve() == self.project_path) + if not (self.paths.git_dir / "HEAD").exists(): + return { + "has_repo": False, + "head": None, + "dirty": False, + "mismatch": False, + "workspace_matched": workspace_matched, + } + if not workspace_matched: + return { + "has_repo": True, + "head": self._get_head_commit(), + "dirty": False, + "mismatch": False, + "workspace_matched": False, + "expected_workspace_path": expected, + "current_workspace_path": current, + } + head = self._get_head_commit() + dirty = not self._is_clean() + mismatch = bool(dirty or (latest_commit and head and latest_commit != head)) + return { + "has_repo": True, + "head": head, + "dirty": dirty, + "mismatch": mismatch, + "workspace_matched": True, + "expected_workspace_path": expected or current, + "current_workspace_path": current, + } + def _extract_contextual_patch_lines(self, patch_text: str, max_lines: int = 600) -> Dict[str, Any]: + """ + Extract +/- lines plus 3 context lines before and after changed regions. + """ + lines: List[Dict[str, Any]] = [] + truncated = False + pre_context: deque[str] = deque(maxlen=3) + post_context_needed = 0 + + for raw in (patch_text or "").splitlines(): + if raw.startswith("diff --git "): + pre_context.clear() + post_context_needed = 0 + continue + if raw.startswith("index ") or raw.startswith("--- ") or raw.startswith("+++ "): + continue + if raw.startswith("@@"): + pre_context.clear() + post_context_needed = 0 + continue + + if not raw: + prefix = " " + content = "" + else: + prefix = raw[0] + content = raw[1:] if len(raw) > 1 else "" + + if prefix == " ": + if post_context_needed > 0: + lines.append({"type": "context", "content": content}) + post_context_needed -= 1 + else: + pre_context.append(content) + elif prefix in {"+", "-"}: + if pre_context: + for ctx in pre_context: + lines.append({"type": "context", "content": ctx}) + pre_context.clear() + lines.append({"type": "add" if prefix == "+" else "remove", "content": content}) + post_context_needed = 3 + else: + continue + + if len(lines) >= max_lines: + truncated = True + break + + return {"lines": lines[:max_lines], "truncated": truncated} diff --git a/server/chat_flow.py b/server/chat_flow.py index 4f77790..411c619 100644 --- a/server/chat_flow.py +++ b/server/chat_flow.py @@ -158,12 +158,25 @@ def detect_malformed_tool_call(text): def process_message_task(terminal: WebTerminal, message: str, images, sender, client_sid, workspace: UserWorkspace, username: str, videos=None): """在后台处理消息任务""" videos = videos or [] + auto_user_message_event = bool(getattr(terminal, "_auto_user_message_event", False)) try: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) # 创建可取消的任务 - task = loop.create_task(handle_task_with_sender(terminal, workspace, message, images, sender, client_sid, username, videos)) + task = loop.create_task( + handle_task_with_sender( + terminal, + workspace, + message, + images, + sender, + client_sid, + username, + videos, + auto_user_message_event=auto_user_message_event, + ) + ) entry = get_stop_flag(client_sid, username) if not isinstance(entry, dict): diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index 1eea603..0a837a0 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -45,6 +45,7 @@ from modules.upload_security import UploadSecurityError from modules.user_manager import UserWorkspace from modules.usage_tracker import QUOTA_DEFAULTS from modules.sub_agent_manager import TERMINAL_STATUSES +from modules.versioning_manager import ConversationVersioningManager, VersioningError from core.web_terminal import WebTerminal from utils.tool_result_formatter import format_tool_result_for_context from utils.conversation_manager import ConversationManager @@ -132,6 +133,134 @@ from .chat_flow_tool_loop import execute_tool_calls from .chat_flow_stream_loop import run_streaming_attempts from .deep_compression import run_deep_compression + +def _should_skip_versioning_for_message( + *, + message: str, + auto_user_message_event: bool, +) -> bool: + if auto_user_message_event: + return True + text = (message or "").strip() + if not text: + return True + if text.startswith("这是一句系统自动发送的user消息"): + return True + return False + + +def _prepare_hidden_versioning_baseline_for_first_input( + *, + web_terminal, + workspace, + conversation_id: str, + message: str, + auto_user_message_event: bool, +) -> None: + """Ensure first-run baseline is committed (hidden, no checkpoint row).""" + try: + if not bool(getattr(web_terminal, "username", None) == "host"): + return + if _should_skip_versioning_for_message( + message=message, + auto_user_message_event=auto_user_message_event, + ): + return + cm = getattr(getattr(web_terminal, "context_manager", None), "conversation_manager", None) + if not cm: + return + conv_data = cm.load_conversation(conversation_id) or {} + versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {}) + if not bool(versioning_meta.get("enabled", False)): + return + manager = ConversationVersioningManager( + project_path=workspace.project_path, + data_dir=workspace.data_dir, + conversation_id=conversation_id, + ) + baseline = manager.ensure_baseline_for_first_input() + debug_log( + f"[Versioning][Baseline] conv={conversation_id} " + f"created={baseline.get('created')} skipped={baseline.get('skipped')} " + f"reason={baseline.get('reason')} head={baseline.get('head')}" + ) + except VersioningError as exc: + debug_log(f"[Versioning] 创建首轮基线失败: {exc}") + except Exception as exc: + debug_log(f"[Versioning] 创建首轮基线异常: {exc}") + + +def _record_hidden_versioning_checkpoint_after_run( + *, + web_terminal, + workspace, + conversation_id: str, + message: str, + message_index: int, + auto_user_message_event: bool, + run_status: str = "completed", +) -> None: + """Host-only hidden snapshot after current manual user input run finished.""" + try: + if not bool(getattr(web_terminal, "username", None) == "host"): + return + if _should_skip_versioning_for_message( + message=message, + auto_user_message_event=auto_user_message_event, + ): + return + cm = getattr(getattr(web_terminal, "context_manager", None), "conversation_manager", None) + if not cm: + return + conv_data = cm.load_conversation(conversation_id) or {} + versioning_meta = ((conv_data.get("metadata") or {}).get("versioning") or {}) + if not bool(versioning_meta.get("enabled", False)): + return + snapshot_messages = conv_data.get("messages") or [] + snapshot_payload = { + "conversation_id": conversation_id, + "title": conv_data.get("title"), + "metadata": conv_data.get("metadata") or {}, + "messages": snapshot_messages, + "message_index": int(message_index), + "run_status": str(run_status or "completed"), + } + + manager = ConversationVersioningManager( + project_path=workspace.project_path, + data_dir=workspace.data_dir, + conversation_id=conversation_id, + ) + row = manager.create_checkpoint( + message=message, + message_index=message_index, + workspace_path=str(workspace.project_path), + conversation_snapshot=snapshot_payload, + run_status=str(run_status or "completed"), + ) + debug_log( + f"[Versioning][Checkpoint] conv={conversation_id} seq={row.get('seq')} " + f"msg_index={message_index} snapshot_messages={len(snapshot_messages)} " + f"commit={row.get('commit')} changed={row.get('changed')} status={row.get('run_status')}" + ) + cm.update_conversation_metadata( + conversation_id, + { + "versioning": { + "enabled": True, + "mode": "overwrite", + "last_commit": row.get("commit"), + "last_input_seq": int(row.get("seq") or 0), + "updated_at": datetime.now().isoformat(), + } + }, + ) + except VersioningError as exc: + debug_log(f"[Versioning] 记录快照失败: {exc}") + except Exception as exc: + debug_log(f"[Versioning] 记录快照异常: {exc}") + + async def _dispatch_completion_user_notice( *, web_terminal, @@ -198,7 +327,8 @@ async def _dispatch_completion_user_notice( sender=sender, client_sid=client_sid, username=username, - videos=[] + videos=[], + auto_user_message_event=True, )) await task_handle except Exception as inner_exc: @@ -549,7 +679,17 @@ async def poll_background_command_completion(*, web_terminal, workspace, convers debug_log("[BgCommand] 后台轮询结束") -async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspace, message, images, sender, client_sid, username: str, videos=None): +async def handle_task_with_sender( + terminal: WebTerminal, + workspace: UserWorkspace, + message, + images, + sender, + client_sid, + username: str, + videos=None, + auto_user_message_event: bool = False, +): """处理任务并发送消息 - 集成token统计版本""" from .extensions import socketio @@ -614,6 +754,13 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac user_message_index = len(getattr(web_terminal.context_manager, "conversation_history", []) or []) - 1 except Exception: user_message_index = -1 + _prepare_hidden_versioning_baseline_for_first_input( + web_terminal=web_terminal, + workspace=workspace, + conversation_id=conversation_id, + message=message, + auto_user_message_event=bool(auto_user_message_event), + ) def finalize_user_work_timer(): nonlocal user_work_finalized @@ -649,6 +796,23 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac web_terminal.context_manager.auto_save_conversation(force=True) user_work_finalized = True + versioning_checkpoint_recorded = False + + def finalize_run_versioning_checkpoint(run_status: str = "completed"): + nonlocal versioning_checkpoint_recorded + if versioning_checkpoint_recorded: + return + _record_hidden_versioning_checkpoint_after_run( + web_terminal=web_terminal, + workspace=workspace, + conversation_id=conversation_id, + message=message, + message_index=user_message_index, + auto_user_message_event=bool(auto_user_message_event), + run_status=run_status, + ) + versioning_checkpoint_recorded = True + # Skill 提示系统:检测关键词并在用户消息之后插入 system 消息 try: personal_config = load_personalization_config(workspace.data_dir) @@ -727,6 +891,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac guide_message = (deep_result.get("guide_message") or "").strip() if guide_message: finalize_user_work_timer() + finalize_run_versioning_checkpoint("auto_deep_compress_handoff") await handle_task_with_sender( web_terminal, workspace, @@ -739,6 +904,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac ) return finalize_user_work_timer() + finalize_run_versioning_checkpoint("auto_deep_compress_end") return # === 移除:不在这里计算输入token,改为在每次API调用前计算 === @@ -772,6 +938,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac 'error_type': 'context_overflow' }) finalize_user_work_timer() + finalize_run_versioning_checkpoint("context_overflow") return usage_percent = (current_tokens / max_context_tokens) * 100 warned = web_terminal.context_manager.conversation_metadata.get("context_warning_sent", False) @@ -887,6 +1054,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac 'quota': quota_info }) finalize_user_work_timer() + finalize_run_versioning_checkpoint("quota_exceeded") return tool_call_limit_label = MAX_TOTAL_TOOL_CALLS if MAX_TOTAL_TOOL_CALLS is not None else "∞" @@ -926,6 +1094,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac ) if stream_result.get("stopped"): finalize_user_work_timer() + finalize_run_versioning_checkpoint("stopped") return full_response = stream_result["full_response"] @@ -1101,6 +1270,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac last_tool_call_time = tool_loop_result.get("last_tool_call_time", last_tool_call_time) if tool_loop_result.get("stopped"): finalize_user_work_timer() + finalize_run_versioning_checkpoint("stopped") return if tool_loop_result.get("approval_rejected"): sender('task_stopped', { @@ -1109,12 +1279,14 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac 'conversation_id': conversation_id }) finalize_user_work_timer() + finalize_run_versioning_checkpoint("approval_rejected") return if tool_loop_result.get("deep_compressed"): deep_result = tool_loop_result.get("deep_result") or {} guide_message = (deep_result.get("guide_message") or "").strip() if deep_result.get("success") and guide_message: finalize_user_work_timer() + finalize_run_versioning_checkpoint("deep_compress_handoff") await handle_task_with_sender( web_terminal, workspace, @@ -1127,6 +1299,7 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac ) return finalize_user_work_timer() + finalize_run_versioning_checkpoint("deep_compress_end") return # 标记不再是第一次迭代 @@ -1222,6 +1395,9 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac # 发送完成事件(如果有后台完成任务在运行,前端会保持等待状态) if not has_running_completion_jobs: finalize_user_work_timer() + finalize_run_versioning_checkpoint("completed") + else: + finalize_run_versioning_checkpoint("waiting_background") sender('task_complete', { 'total_iterations': total_iterations, 'total_tool_calls': total_tool_calls, diff --git a/server/conversation.py b/server/conversation.py index 8dcd9f5..9ff66ce 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -46,6 +46,7 @@ from modules.upload_security import UploadSecurityError from modules.user_manager import UserWorkspace from modules.usage_tracker import QUOTA_DEFAULTS from modules.sub_agent_manager import TERMINAL_STATUSES +from modules.versioning_manager import ConversationVersioningManager, VersioningError from core.web_terminal import WebTerminal from utils.tool_result_formatter import format_tool_result_for_context from utils.conversation_manager import ConversationManager @@ -147,6 +148,62 @@ def _cancel_running_tasks(username: str, workspace_id: str, timeout_seconds: flo return canceled, False +def _normalize_conv_id(conversation_id: str) -> str: + conv = (conversation_id or "").strip() + if not conv: + return conv + return conv if conv.startswith("conv_") else f"conv_{conv}" + + +def _is_host_mode_request(username: str) -> bool: + return bool(session.get("host_mode")) or (username == "host") + + +def _get_conv_versioning_manager(workspace: UserWorkspace, conversation_id: str) -> ConversationVersioningManager: + normalized = _normalize_conv_id(conversation_id) + return ConversationVersioningManager( + project_path=workspace.project_path, + data_dir=workspace.data_dir, + conversation_id=normalized, + ) + + +def _get_conversation_versioning_meta(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]: + normalized = _normalize_conv_id(conversation_id) + data = terminal.context_manager.conversation_manager.load_conversation(normalized) or {} + meta = data.get("metadata") or {} + versioning = meta.get("versioning") or {} + if not isinstance(versioning, dict): + versioning = {} + enabled = bool(versioning.get("enabled", False)) + mode = "overwrite" + return {"enabled": enabled, "mode": mode, "conversation_id": normalized, "metadata": meta} + + +def _update_conversation_versioning_meta( + terminal: WebTerminal, + conversation_id: str, + *, + enabled: bool, + mode: str, + last_commit: Optional[str] = None, + last_input_seq: Optional[int] = None, +) -> bool: + normalized = _normalize_conv_id(conversation_id) + payload: Dict[str, Any] = { + "versioning": { + "enabled": bool(enabled), + "mode": "overwrite", + "updated_at": datetime.now().isoformat(), + } + } + if last_commit is not None: + payload["versioning"]["last_commit"] = last_commit + if last_input_seq is not None: + payload["versioning"]["last_input_seq"] = int(last_input_seq) + return terminal.context_manager.conversation_manager.update_conversation_metadata(normalized, payload) + + # === 背景生成对话标题(从 app_legacy 拆分) === @conversation_bp.route('/api/conversations', methods=['GET']) @api_login_required @@ -299,6 +356,39 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor result = terminal.load_conversation(conversation_id) if result["success"]: + normalized_id = _normalize_conv_id(conversation_id) + if _is_host_mode_request(username): + try: + vm = _get_conv_versioning_manager(workspace, normalized_id) + vmeta = _get_conversation_versioning_meta(terminal, normalized_id) + latest_checkpoint = vm.get_latest_checkpoint() if vmeta.get("enabled") else None + latest_commit = (latest_checkpoint or {}).get("commit") if latest_checkpoint else None + expected_workspace_path = (latest_checkpoint or {}).get("workspace_path") if latest_checkpoint else None + mismatch = vm.detect_mismatch(latest_commit, expected_workspace_path=expected_workspace_path) if vmeta.get("enabled") else { + "has_repo": False, + "head": None, + "dirty": False, + "mismatch": False, + "workspace_matched": True, + } + result["versioning"] = { + "enabled": bool(vmeta.get("enabled")), + "mode": "overwrite", + "latest_seq": int((latest_checkpoint or {}).get("seq") or 0) if latest_checkpoint else None, + "latest_commit": latest_commit, + "mismatch": bool(mismatch.get("mismatch")), + "dirty": bool(mismatch.get("dirty")), + "workspace_matched": bool(mismatch.get("workspace_matched", True)), + } + except Exception as exc: + debug_log(f"[Versioning] load status 读取失败: {exc}") + result["versioning"] = { + "enabled": False, + "mode": "overwrite", + "mismatch": False, + "error": str(exc), + } + # 广播对话切换事件 socketio.emit('conversation_changed', { 'conversation_id': conversation_id, @@ -449,6 +539,310 @@ def get_conversation_messages(conversation_id, terminal: WebTerminal, workspace: }), 500 +@conversation_bp.route('/api/conversations//versioning', methods=['GET']) +@api_login_required +@with_terminal +def get_conversation_versioning_status(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): + try: + normalized_id = _normalize_conv_id(conversation_id) + host_mode = _is_host_mode_request(username) + versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id) + manager = _get_conv_versioning_manager(workspace, normalized_id) + latest = manager.get_latest_checkpoint() if versioning_meta.get("enabled") and host_mode else None + mismatch = manager.detect_mismatch( + (latest or {}).get("commit"), + expected_workspace_path=(latest or {}).get("workspace_path"), + ) if versioning_meta.get("enabled") and host_mode else { + "has_repo": False, + "head": None, + "dirty": False, + "mismatch": False, + "workspace_matched": True, + } + return jsonify({ + "success": True, + "data": { + "conversation_id": normalized_id, + "host_mode": host_mode, + "enabled": bool(versioning_meta.get("enabled")) if host_mode else False, + "mode": "overwrite", + "latest_seq": int((latest or {}).get("seq") or 0) if latest else None, + "latest_commit": (latest or {}).get("commit"), + "mismatch": bool(mismatch.get("mismatch")), + "dirty": bool(mismatch.get("dirty")), + "workspace_matched": bool(mismatch.get("workspace_matched", True)), + } + }) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@conversation_bp.route('/api/conversations//versioning', methods=['POST']) +@api_login_required +@with_terminal +def update_conversation_versioning(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): + try: + normalized_id = _normalize_conv_id(conversation_id) + if not _is_host_mode_request(username): + return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 + payload = request.get_json(silent=True) or {} + enabled = bool(payload.get("enabled")) + mode = "overwrite" + + manager = _get_conv_versioning_manager(workspace, normalized_id) + meta = manager.set_enabled(enabled=enabled, mode=mode) + if enabled: + conv_data = terminal.context_manager.conversation_manager.load_conversation(normalized_id) or {} + snapshot_payload = { + "conversation_id": normalized_id, + "title": conv_data.get("title"), + "metadata": conv_data.get("metadata") or {}, + "messages": conv_data.get("messages") or [], + "message_index": -1, + "run_status": "initial", + } + init_result = manager.ensure_initial_checkpoint( + workspace_path=str(workspace.project_path), + conversation_snapshot=snapshot_payload, + ) + init_row = init_result.get("row") or {} + debug_log( + f"[Versioning][Init] conv={normalized_id} enabled={enabled} " + f"created={init_result.get('created')} reason={init_result.get('reason')} " + f"seq={init_row.get('seq')} commit={init_row.get('commit')}" + ) + if init_row.get("commit"): + meta["last_commit"] = init_row.get("commit") + ok = _update_conversation_versioning_meta( + terminal, + normalized_id, + enabled=enabled, + mode=mode, + last_commit=meta.get("last_commit"), + last_input_seq=int(meta.get("last_input_seq") or 0), + ) + if not ok: + return jsonify({"success": False, "error": "更新对话版本配置失败"}), 404 + return jsonify({ + "success": True, + "data": { + "conversation_id": normalized_id, + "enabled": bool(meta.get("enabled")), + "mode": "overwrite", + "last_commit": meta.get("last_commit"), + "last_input_seq": int(meta.get("last_input_seq") or 0), + } + }) + except VersioningError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@conversation_bp.route('/api/conversations//versioning/checkpoints', methods=['GET']) +@api_login_required +@with_terminal +def list_conversation_versioning_checkpoints(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): + try: + normalized_id = _normalize_conv_id(conversation_id) + if not _is_host_mode_request(username): + return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 + versioning_meta = _get_conversation_versioning_meta(terminal, normalized_id) + if not versioning_meta.get("enabled"): + return jsonify({"success": True, "data": {"enabled": False, "items": []}}) + manager = _get_conv_versioning_manager(workspace, normalized_id) + rows = manager.list_checkpoints() + return jsonify({ + "success": True, + "data": { + "enabled": True, + "mode": "overwrite", + "items": rows, + } + }) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@conversation_bp.route('/api/conversations//versioning/checkpoints/', methods=['GET']) +@api_login_required +@with_terminal +def get_conversation_versioning_checkpoint_detail(conversation_id, seq: int, terminal: WebTerminal, workspace: UserWorkspace, username: str): + try: + normalized_id = _normalize_conv_id(conversation_id) + if not _is_host_mode_request(username): + return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 + manager = _get_conv_versioning_manager(workspace, normalized_id) + row = manager.get_checkpoint_detail(seq, include_patch=True) + if not row: + return jsonify({"success": False, "error": "未找到对应版本点"}), 404 + return jsonify({"success": True, "data": row}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@conversation_bp.route('/api/conversations//versioning/restore', methods=['POST']) +@api_login_required +@with_terminal +def restore_conversation_versioning_checkpoint(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): + try: + normalized_id = _normalize_conv_id(conversation_id) + if not _is_host_mode_request(username): + return jsonify({"success": False, "error": "版本管理仅支持宿主机模式"}), 400 + payload = request.get_json(silent=True) or {} + seq = int(payload.get("seq") or 0) + if seq < 0: + return jsonify({"success": False, "error": "缺少有效 seq"}), 400 + + vmeta = _get_conversation_versioning_meta(terminal, normalized_id) + if not vmeta.get("enabled"): + return jsonify({"success": False, "error": "当前对话未开启版本管理"}), 400 + + restore_mode = "overwrite" + + manager = _get_conv_versioning_manager(workspace, normalized_id) + checkpoint = manager.get_checkpoint(seq) + if not checkpoint: + return jsonify({"success": False, "error": "未找到对应版本点"}), 404 + + debug_log( + f"[Versioning][Restore] start conv={normalized_id} seq={seq} mode={restore_mode} " + f"target_commit={checkpoint.get('commit')} workspace={workspace.project_path}" + ) + manager.restore_to_commit(checkpoint.get("commit")) + debug_log( + f"[Versioning][Restore] git restored conv={normalized_id} seq={seq} " + f"commit={checkpoint.get('commit')}" + ) + + cm = terminal.context_manager.conversation_manager + conv_data = cm.load_conversation(normalized_id) or {} + conv_meta = conv_data.get("metadata") or {} + + # 优先使用 checkpoint 绑定的快照恢复对话内容,确保与提交时语义一致(发送前状态)。 + snapshot = manager.get_checkpoint_snapshot(seq) + snapshot_messages = (snapshot or {}).get("messages") + if not isinstance(snapshot_messages, list): + snapshot_messages = None + snapshot_meta = (snapshot or {}).get("metadata") + if not isinstance(snapshot_meta, dict): + snapshot_meta = {} + + if snapshot_messages is None: + # 兼容旧数据:若无快照文件,退回到历史索引截断。 + all_messages = conv_data.get("messages") or [] + msg_index = int(checkpoint.get("message_index") or 0) + snapshot_messages = all_messages[: max(0, msg_index + 1)] + debug_log( + f"[Versioning][Restore] fallback truncation conv={normalized_id} seq={seq} " + f"msg_index={msg_index} restored_messages={len(snapshot_messages)}" + ) + else: + debug_log( + f"[Versioning][Restore] snapshot loaded conv={normalized_id} seq={seq} " + f"messages={len(snapshot_messages)} meta_keys={list(snapshot_meta.keys())[:8]}" + ) + + restore_meta = dict(conv_meta or {}) + if snapshot_meta: + restore_meta.update(snapshot_meta) + + restore_project_path = restore_meta.get("project_path") or str(workspace.project_path) + restore_thinking_mode = bool(restore_meta.get("thinking_mode", False)) + restore_run_mode = restore_meta.get("run_mode") or ("thinking" if restore_thinking_mode else "fast") + restore_model_key = restore_meta.get("model_key") + restore_has_images = bool(restore_meta.get("has_images", False)) + restore_has_videos = bool(restore_meta.get("has_videos", False)) + + target_conversation_id = normalized_id + ok = cm.save_conversation( + conversation_id=normalized_id, + messages=snapshot_messages, + project_path=restore_project_path, + thinking_mode=restore_thinking_mode, + run_mode=restore_run_mode, + model_key=restore_model_key, + has_images=restore_has_images, + has_videos=restore_has_videos, + ) + if not ok: + return jsonify({"success": False, "error": "覆盖当前对话失败"}), 500 + prune_info = manager.prune_checkpoints_after(seq) + debug_log( + f"[Versioning][Restore] overwrite pruned conv={normalized_id} seq<={seq} " + f"kept={prune_info.get('kept')} removed={prune_info.get('removed')} " + f"max_seq={prune_info.get('max_seq')}" + ) + # 回填快照中的 metadata(含权限/模型/统计等),再覆盖 versioning 元信息。 + _update_conversation_versioning_meta( + terminal, + normalized_id, + enabled=True, + mode="overwrite", + last_commit=prune_info.get("last_commit") or checkpoint.get("commit"), + last_input_seq=int(prune_info.get("max_seq") or checkpoint.get("seq") or 0), + ) + cm.update_conversation_metadata( + normalized_id, + { + **restore_meta, + "versioning": { + "enabled": True, + "mode": "overwrite", + "last_commit": prune_info.get("last_commit") or checkpoint.get("commit"), + "last_input_seq": int(prune_info.get("max_seq") or checkpoint.get("seq") or 0), + "updated_at": datetime.now().isoformat(), + }, + }, + ) + debug_log( + f"[Versioning][Restore] overwrite saved conv={normalized_id} seq={seq} " + f"messages={len(snapshot_messages)}" + ) + + # 关键修复:overwrite 场景若直接 load 同一对话,ContextManager 会先把内存中的旧历史保存回磁盘, + # 导致刚恢复的对话被覆盖。这里先清空内存历史,避免反向覆写。 + try: + current_loaded_id = getattr(terminal.context_manager, "current_conversation_id", None) + if current_loaded_id == normalized_id: + terminal.context_manager.conversation_history = [] + debug_log(f"[Versioning][Restore] cleared in-memory history before reload conv={normalized_id}") + except Exception as exc: + debug_log(f"[Versioning][Restore] clear in-memory history failed: {exc}") + + terminal.load_conversation(target_conversation_id) + debug_log( + f"[Versioning][Restore] reload done conv={target_conversation_id} " + f"messages={len(getattr(terminal.context_manager, 'conversation_history', []) or [])}" + ) + session['run_mode'] = terminal.run_mode + session['thinking_mode'] = terminal.thinking_mode + + socketio.emit('conversation_list_update', { + 'action': 'version_restored', + 'conversation_id': target_conversation_id + }, room=f"user_{username}") + socketio.emit('conversation_changed', { + 'conversation_id': target_conversation_id, + 'title': (cm.load_conversation(target_conversation_id) or {}).get("title", "版本回溯对话"), + }, room=f"user_{username}") + + return jsonify({ + "success": True, + "data": { + "restore_mode": restore_mode, + "conversation_id": target_conversation_id, + "restored_seq": int(checkpoint.get("seq") or 0), + "restored_commit": checkpoint.get("commit"), + "source_conversation_id": normalized_id, + } + }) + except VersioningError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @conversation_bp.route('/api/conversations//compress', methods=['POST']) @api_login_required @with_terminal diff --git a/server/tasks.py b/server/tasks.py index 6bf7c1f..120e0ca 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -316,6 +316,17 @@ class TaskManager: debug_log(f"[Task] 设置上下文回调失败: {exc}") # 将 task_id 作为 client_sid,供 stop_flags 检测 + previous_auto_user_event = None + try: + previous_auto_user_event = getattr(terminal, "_auto_user_message_event", False) + setattr( + terminal, + "_auto_user_message_event", + bool((rec.session_data or {}).get("auto_user_message_event")), + ) + except Exception: + previous_auto_user_event = None + try: run_chat_task_sync( terminal=terminal, @@ -329,6 +340,8 @@ class TaskManager: ) finally: try: + if previous_auto_user_event is not None: + setattr(terminal, "_auto_user_message_event", previous_auto_user_event) if terminal and getattr(terminal, "context_manager", None): terminal.context_manager.set_web_terminal_callback(previous_ctx_callback) except Exception as exc: diff --git a/static/src/App.vue b/static/src/App.vue index 7c57ac2..7a0163a 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -280,6 +280,7 @@ :permission-menu-open="permissionMenuOpen" :permission-options="permissionModeOptions" :current-context-tokens="currentContextTokens" + :versioning-enabled="versioningEnabled" @update:input-message="inputSetMessage" @input-change="handleInputChange" @input-focus="handleInputFocus" @@ -308,6 +309,7 @@ @open-review="openReviewDialog" @toggle-permission-menu="togglePermissionMenu" @change-permission-mode="changePermissionMode" + @open-versioning-dialog="openVersioningDialog" /> @@ -322,6 +324,7 @@ :width="rightWidth" :approvals="pendingToolApprovals" :deciding-approval-ids="decidingApprovalIds" + @switch-unrestricted="handleSwitchPermissionToUnrestricted" @approve="approveToolApproval" @reject="rejectToolApproval" @close="rightCollapsed = true" @@ -381,6 +384,26 @@ + + + import('../components/overlay/BackgroundCommandDialog.vue') ); +const VersioningDialog = defineAsyncComponent( + () => import('../components/overlay/VersioningDialog.vue') +); const TutorialOverlay = defineAsyncComponent( () => import('../components/overlay/TutorialOverlay.vue') ); @@ -42,6 +45,7 @@ export const appComponents = { ConversationReviewDialog, SubAgentActivityDialog, BackgroundCommandDialog, + VersioningDialog, TutorialOverlay, NewUserTutorialPrompt }; diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index a5da580..f7c7c73 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -367,6 +367,8 @@ export const conversationMethods = { // 4. 立即加载历史和统计,确保列表切换后界面同步更新 await this.fetchAndDisplayHistory(); this.fetchPermissionMode(); + await this.fetchVersioningStatus(conversationId, { silent: true }); + await this.maybePromptVersioningMismatch(result.versioning); this.fetchPendingToolApprovals(); this.fetchConversationTokenStatistics(); this.updateCurrentContextTokens(); @@ -560,6 +562,8 @@ export const conversationMethods = { ...this.conversations.filter((conv) => conv && conv.id !== newConversationId) ]; + await this.applyPendingVersioningToConversation(newConversationId); + // 直接加载新对话,确保状态一致 // 如果 socket 事件已把 currentConversationId 设置为新ID,则强制加载一次以同步状态 await this.loadConversation(newConversationId, { force: true }); diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index 7e0c77b..ba2412f 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -241,6 +241,7 @@ export const messageMethods = { ]; const pathFragment = this.stripConversationPrefix(targetConversationId); history.replaceState({ conversationId: targetConversationId }, '', `/${pathFragment}`); + await this.applyPendingVersioningToConversation(targetConversationId); } catch (error) { this.uiPushToast({ title: '发送失败', diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 70b856a..8ada1dc 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -664,6 +664,13 @@ export const uiMethods = { } }, + async handleSwitchPermissionToUnrestricted(approvalId) { + await this.changePermissionMode('unrestricted'); + if (approvalId) { + await this.approveToolApproval(approvalId); + } + }, + async fetchPermissionMode() { try { const response = await fetch('/api/permission-mode'); @@ -1565,6 +1572,7 @@ export const uiMethods = { const todoPromise = this.fileFetchTodoList(); let treePromise: Promise | null = null; const isHostMode = statusData?.container?.mode === 'host'; + this.versioningHostMode = !!isHostMode; if (isHostMode) { this.fileMarkTreeUnavailable('宿主机模式下文件树不可用'); } else { @@ -1624,6 +1632,7 @@ export const uiMethods = { // 获取当前对话的Token统计 this.fetchConversationTokenStatistics(); this.updateCurrentContextTokens(); + await this.fetchVersioningStatus(this.currentConversationId, { silent: true }); } catch (e) { console.warn('获取当前对话标题失败:', e); this.titleReady = true; diff --git a/static/src/app/methods/versioning.ts b/static/src/app/methods/versioning.ts new file mode 100644 index 0000000..75f941d --- /dev/null +++ b/static/src/app/methods/versioning.ts @@ -0,0 +1,286 @@ +// @ts-nocheck +import { debugLog } from './common'; + +export const versioningMethods = { + async fetchVersioningStatus(conversationId = null, options = {}) { + const targetId = conversationId || this.currentConversationId; + const { silent = false } = options as { silent?: boolean }; + if (!targetId) return null; + try { + const resp = await fetch(`/api/conversations/${targetId}/versioning`); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + if (!silent) { + throw new Error(data?.error || '获取版本状态失败'); + } + return null; + } + const payload = data.data || {}; + this.versioningHostMode = !!payload.host_mode; + this.versioningEnabled = !!payload.enabled; + this.versioningMode = 'overwrite'; + this.versioningRestoreMode = 'overwrite'; + this.versioningMismatch = !!payload.mismatch; + return payload; + } catch (error) { + if (!silent) { + this.uiPushToast({ + title: '版本管理', + message: error?.message || '获取版本状态失败', + type: 'error' + }); + } + return null; + } + }, + + async fetchVersioningCheckpoints(conversationId = null) { + const targetId = conversationId || this.currentConversationId; + if (!targetId) return []; + this.versioningLoading = true; + try { + const resp = await fetch(`/api/conversations/${targetId}/versioning/checkpoints`); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || '加载版本点失败'); + } + const items = Array.isArray(data?.data?.items) ? data.data.items : []; + this.versioningCheckpoints = items; + if (!items.length) { + this.versioningSelectedSeq = null; + this.versioningSelectedDetail = null; + } + return items; + } catch (error) { + this.uiPushToast({ + title: '版本管理', + message: error?.message || '加载版本点失败', + type: 'error' + }); + return []; + } finally { + this.versioningLoading = false; + } + }, + + async openVersioningDialog() { + if (!this.currentConversationId) { + this.versioningEnabled = !!this.newConversationVersioningEnabled; + this.versioningCheckpoints = []; + this.versioningSelectedSeq = null; + this.versioningSelectedDetail = null; + this.versioningDialogOpen = true; + return; + } + await this.fetchVersioningStatus(this.currentConversationId); + this.versioningRestoreMode = 'overwrite'; + this.versioningDialogOpen = true; + this.versioningSelectedSeq = null; + this.versioningSelectedDetail = null; + await this.fetchVersioningCheckpoints(this.currentConversationId); + }, + + async refreshVersioningDialog() { + await this.fetchVersioningStatus(this.currentConversationId); + await this.fetchVersioningCheckpoints(this.currentConversationId); + }, + + async toggleConversationVersioning(enabled: boolean) { + if (!this.currentConversationId) { + if (!this.versioningHostMode) { + this.uiPushToast({ + title: '版本管理', + message: '仅宿主机模式支持版本管理', + type: 'warning' + }); + return; + } + this.newConversationVersioningEnabled = !!enabled; + this.versioningEnabled = !!enabled; + this.uiPushToast({ + title: '版本管理', + message: enabled ? '已为下一次新对话开启版本管理' : '已取消下一次新对话的版本管理', + type: 'success' + }); + return; + } + this.versioningLoading = true; + try { + const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: !!enabled, + mode: 'overwrite' + }) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || '切换版本管理失败'); + } + this.versioningEnabled = !!data?.data?.enabled; + this.versioningMode = 'overwrite'; + this.versioningRestoreMode = 'overwrite'; + this.uiPushToast({ + title: '版本管理', + message: this.versioningEnabled ? '已开启' : '已关闭', + type: 'success' + }); + if (this.versioningEnabled) { + await this.fetchVersioningCheckpoints(this.currentConversationId); + } else { + this.versioningCheckpoints = []; + this.versioningSelectedSeq = null; + this.versioningSelectedDetail = null; + } + } catch (error) { + this.uiPushToast({ + title: '版本管理', + message: error?.message || '切换失败', + type: 'error' + }); + } finally { + this.versioningLoading = false; + } + }, + + async selectVersioningCheckpoint(seq: number) { + if (!this.currentConversationId || seq === null || seq === undefined) return; + this.versioningSelectedSeq = Number(seq); + this.versioningDetailLoading = true; + try { + const resp = await fetch( + `/api/conversations/${this.currentConversationId}/versioning/checkpoints/${encodeURIComponent(String(seq))}` + ); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || '加载详情失败'); + } + this.versioningSelectedDetail = data.data || null; + } catch (error) { + this.uiPushToast({ + title: '版本管理', + message: error?.message || '加载详情失败', + type: 'error' + }); + } finally { + this.versioningDetailLoading = false; + } + }, + + async confirmVersioningRestore() { + if ( + !this.currentConversationId || + this.versioningSelectedSeq === null || + this.versioningSelectedSeq === undefined + ) return; + const confirmed = await this.confirmAction({ + title: '确认回溯', + message: `将回溯到输入 #${this.versioningSelectedSeq} 对应的状态,是否继续?`, + confirmText: '回溯', + cancelText: '取消' + }); + if (!confirmed) return; + this.versioningRestoring = true; + try { + const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning/restore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + seq: this.versioningSelectedSeq, + mode: 'overwrite' + }) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || '回溯失败'); + } + const targetConversationId = data?.data?.conversation_id || this.currentConversationId; + this.versioningDialogOpen = false; + await this.loadConversation(targetConversationId, { force: true }); + this.uiPushToast({ + title: '版本管理', + message: '回溯完成', + type: 'success' + }); + } catch (error) { + this.uiPushToast({ + title: '版本管理', + message: error?.message || '回溯失败', + type: 'error' + }); + } finally { + this.versioningRestoring = false; + } + }, + + async maybePromptVersioningMismatch(versioningPayload: any) { + if (!versioningPayload || !versioningPayload.enabled || !versioningPayload.mismatch) { + return; + } + const latestSeq = versioningPayload.latest_seq; + if (!latestSeq) return; + const confirmed = await this.confirmAction({ + title: '版本状态不一致', + message: '当前工作区与该对话最新版本不一致,是否恢复到最新一次提交?', + confirmText: '恢复', + cancelText: '保持当前' + }); + if (!confirmed) return; + try { + const resp = await fetch(`/api/conversations/${this.currentConversationId}/versioning/restore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + seq: latestSeq, + mode: 'overwrite' + }) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || '恢复失败'); + } + await this.loadConversation(this.currentConversationId, { force: true }); + this.uiPushToast({ + title: '版本管理', + message: '已恢复到最新版本提交', + type: 'success' + }); + } catch (error) { + debugLog('[Versioning] mismatch restore failed', error); + this.uiPushToast({ + title: '版本管理', + message: error?.message || '恢复失败', + type: 'error' + }); + } + }, + async applyPendingVersioningToConversation(conversationId: string) { + const targetId = conversationId || this.currentConversationId; + if (!targetId) return; + if (!this.versioningHostMode) return; + if (!this.newConversationVersioningEnabled) return; + try { + const resp = await fetch(`/api/conversations/${targetId}/versioning`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: true, + mode: 'overwrite' + }) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || '新对话开启版本管理失败'); + } + this.newConversationVersioningEnabled = false; + this.versioningEnabled = true; + } catch (error) { + this.uiPushToast({ + title: '版本管理', + message: error?.message || '新对话开启版本管理失败', + type: 'error' + }); + } + } +}; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 6b9d346..72e3695 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -81,6 +81,19 @@ export function dataState() { modelMenuOpen: false, permissionMenuOpen: false, currentPermissionMode: 'unrestricted', + versioningHostMode: false, + versioningEnabled: false, + newConversationVersioningEnabled: false, + versioningMode: 'overwrite', + versioningMismatch: false, + versioningDialogOpen: false, + versioningLoading: false, + versioningCheckpoints: [], + versioningSelectedSeq: null, + versioningSelectedDetail: null, + versioningDetailLoading: false, + versioningRestoring: false, + versioningRestoreMode: 'overwrite', permissionModeOptions: [ { value: 'readonly', diff --git a/static/src/app/watchers.ts b/static/src/app/watchers.ts index f7f7247..aa6e60b 100644 --- a/static/src/app/watchers.ts +++ b/static/src/app/watchers.ts @@ -48,8 +48,11 @@ export const watchers = { skipConversationHistoryReload: this.skipConversationHistoryReload }); if (!newValue || typeof newValue !== 'string' || newValue.startsWith('temp_')) { + this.versioningEnabled = !!this.newConversationVersioningEnabled; + this.versioningMismatch = false; return; } + this.fetchVersioningStatus(newValue, { silent: true }); if (this.skipConversationHistoryReload) { this.skipConversationHistoryReload = false; return; diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 6f54a7d..555e87b 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -139,23 +139,33 @@ type="button" class="permission-switcher__btn" :disabled="!isConnected || streamingMessage" - @click="$emit('toggle-permission-menu')" + @click="$emit('open-versioning-dialog')" > - 权限:{{ currentPermissionLabel }} - + 版本:{{ versioningEnabled ? '开启' : '关闭' }} -
+
+
+ +
@@ -221,7 +231,8 @@ const emit = defineEmits([ 'remove-video', 'open-review', 'toggle-permission-menu', - 'change-permission-mode' + 'change-permission-mode', + 'open-versioning-dialog' ]); const props = defineProps<{ @@ -269,6 +280,7 @@ const props = defineProps<{ permissionMenuOpen: boolean; permissionOptions: Array<{ value: string; label: string; description: string }>; currentContextTokens: number; + versioningEnabled?: boolean; }>(); const inputStore = useInputStore(); @@ -478,4 +490,5 @@ onMounted(() => { .image-remove-btn-hover:hover { color: #ef4444; } + diff --git a/static/src/components/overlay/VersioningDialog.vue b/static/src/components/overlay/VersioningDialog.vue new file mode 100644 index 0000000..865cfa9 --- /dev/null +++ b/static/src/components/overlay/VersioningDialog.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/static/src/components/panels/ToolApprovalPanel.vue b/static/src/components/panels/ToolApprovalPanel.vue index 2332d22..baf675a 100644 --- a/static/src/components/panels/ToolApprovalPanel.vue +++ b/static/src/components/panels/ToolApprovalPanel.vue @@ -80,6 +80,14 @@
+
+
+
+ 版本回溯默认模式 +

+ 版本管理已固定为覆盖当前对话。 +

+
+
+ +
+
+
diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index a53a51f..b49bdc9 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -27,6 +27,7 @@ interface PersonalForm { disabled_tool_categories: string[]; default_run_mode: RunMode | null; default_permission_mode: PermissionMode; + versioning_restore_mode: 'overwrite'; default_model: string | null; image_compression: string; auto_shallow_compress_enabled: boolean; @@ -92,6 +93,7 @@ const defaultForm = (): PersonalForm => ({ disabled_tool_categories: [], default_run_mode: null, default_permission_mode: 'unrestricted', + versioning_restore_mode: 'overwrite', default_model: 'kimi-k2.5', image_compression: 'original', auto_shallow_compress_enabled: false, @@ -253,6 +255,7 @@ export const usePersonalizationStore = defineStore('personalization', { data.default_permission_mode === 'unrestricted' ? data.default_permission_mode : 'unrestricted', + versioning_restore_mode: 'overwrite', default_model: typeof data.default_model === 'string' ? data.default_model : fallbackModel, image_compression: typeof data.image_compression === 'string' ? data.image_compression : 'original', @@ -496,6 +499,14 @@ export const usePersonalizationStore = defineStore('personalization', { }; this.clearFeedback(); }, + setVersioningRestoreMode(_mode: 'overwrite') { + const target: 'overwrite' = 'overwrite'; + this.form = { + ...this.form, + versioning_restore_mode: target + }; + this.clearFeedback(); + }, setDefaultModel(model: string | null) { const modelStore = useModelStore(); const allowed = new Set((modelStore.models || []).map((m) => m.key)); diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index f0fded5..8a489ec 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -1,6 +1,7 @@ /* 聊天容器整体布局,保证聊天区可见并支持上下滚动 */ .chat-container { --chat-surface-color: var(--theme-surface-strong, #ffffff); + --composer-reserved-height: calc(75px + var(--app-bottom-inset, 0px)); flex: 1; display: flex; flex-direction: column; @@ -73,7 +74,7 @@ overflow-y: auto; padding: 24px; padding-top: 30px; - padding-bottom: calc(100px + var(--app-bottom-inset, 0px)); + padding-bottom: calc(20px + var(--app-bottom-inset, 0px)); min-height: 0; position: relative; } @@ -134,6 +135,10 @@ } @media (max-width: 768px) { + .chat-container { + --composer-reserved-height: calc(96px + var(--app-bottom-inset, 0px)); + } + .chat-container--mobile { padding-top: 44px; } diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index 09efb81..4968632 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -29,6 +29,13 @@ left: 20px; bottom: -28px; z-index: 35; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.permission-switcher__block { + position: relative; } .context-usage-switcher { @@ -528,6 +535,8 @@ .composer-container { position: relative; + height: var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px))); + flex: 0 0 var(--composer-reserved-height, calc(108px + var(--app-bottom-inset, 0px))); transition: transform 0.3s ease; z-index: 2; } diff --git a/static/src/styles/components/panels/_right-panel.scss b/static/src/styles/components/panels/_right-panel.scss index 74d6b77..2958e21 100644 --- a/static/src/styles/components/panels/_right-panel.scss +++ b/static/src/styles/components/panels/_right-panel.scss @@ -276,6 +276,11 @@ background: var(--claude-success); } +.approval-btn--switch { + color: #fff; + background: #dc2626; +} + .approval-btn--reject { color: var(--theme-surface-strong); background: var(--claude-accent-strong);