From 8d665ad6defae6b6049c6cb7021c9d9955362948 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 11 May 2026 17:55:27 +0800 Subject: [PATCH] feat(permission): add auto-approval mode with approval agent, UI flow, and docs sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented a new permission mode between and , including backend policy, runtime behavior, frontend display, and documentation updates. Backend & policy changes: - Added to permission mode validation and persistence paths. - Updated tool permission evaluation: workspace-local direct pass, out-of-workspace requires approval. - Kept readonly-first flow and added auto-approval decision path when permission denial occurs. - Added approval reason/decider support in tool approval manager. - Improved rejection tool-content format to natural language: '工具调用被拒绝\n原因:...' - Fixed permission-mode sync edge cases on conversation create/load and restart-first-switch behavior. Approval agent subsystem: - Added and . - Added lightweight auto-approval config at (name/url/key/model/extra_params + timeouts). - Added optional transcript debug switch in code and transcript output under logs/approval_agent. - Aligned transcript saving toward cumulative messages format and captured reasoning/content/tool_calls/tool sequence. Frontend changes: - Added option to personalization and permission menus. - Reworked approval panel auto-review display into a dedicated block with simplified lines: start, command, final approve/reject + reason. - Added delayed sidebar auto-close (10s) after approval resolved. - Added stricter permission-switch verification and rollback on mismatch/error. Docs updated: - Synced AGENTS.md, README.md, and docs/host_sandbox_and_permission_model.md with new permission mode, approval-agent behavior, config, and current constraints (including docker caveat). --- AGENTS.md | 15 +- README.md | 6 + config/auto_approval.json | 14 + core/main_terminal_parts/context.py | 10 + core/main_terminal_parts/tools_execution.py | 25 +- core/main_terminal_parts/tools_policy.py | 4 +- core/web_terminal.py | 14 +- docs/host_sandbox_and_permission_model.md | 31 +- modules/approval_agent.py | 323 ++++++++++++++++++ modules/auto_approval_service.py | 92 +++++ modules/personalization_manager.py | 2 +- modules/tool_approval_manager.py | 15 +- server/chat.py | 4 +- server/chat_flow_tool_loop.py | 107 ++++-- static/src/App.vue | 4 + static/src/app/methods/taskPolling.ts | 45 ++- static/src/app/methods/ui.ts | 22 +- static/src/app/state.ts | 8 + static/src/components/input/InputComposer.vue | 2 +- .../components/panels/ToolApprovalPanel.vue | 39 ++- .../personalization/PersonalizationDrawer.vue | 3 +- static/src/stores/personalization.ts | 5 +- 22 files changed, 743 insertions(+), 47 deletions(-) create mode 100644 config/auto_approval.json create mode 100644 modules/approval_agent.py create mode 100644 modules/auto_approval_service.py diff --git a/AGENTS.md b/AGENTS.md index ac9b8c5..1173031 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,7 +161,7 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: 为避免误解,当前系统有两套独立但叠加的控制: -1. **权限模式(Permission Mode)**:`readonly` / `approval` / `unrestricted` +1. **权限模式(Permission Mode)**:`readonly` / `approval` / `auto_approval` / `unrestricted` 2. **执行环境(Execution Mode)**:`sandbox` / `direct`(仅宿主机模式可切换) ### 10.1 权限模式 @@ -173,6 +173,10 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: - `run_command` 先走只读沙箱 - 若出现权限拒绝(例如 `Operation not permitted` / `Permission denied`),触发前端审批 - 审批通过后,仅该次命令以可写沙箱重试;工具结果返回“重试后的最终结果” +- `auto_approval` + - `write_file` / `edit_file`:工作区内路径直接执行,工作区外路径进入审批流程 + - `run_command` 先走只读沙箱;触发权限拒绝后由后台审批智能体自动审核 + - 自动审核拒绝时,工具返回“被拒绝+理由”并继续主循环(不强制结束任务) - `unrestricted` - 不做权限模式拦截;是否沙箱由执行环境决定 @@ -195,3 +199,12 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: - 不要在提示词里暴露内部实现细节(例如“命令文本猜测”等) - 文案应面向用户能力边界与操作建议,不描述内部判定算法 + +### 10.5 自动审批智能体配置与调试 + +- 自动审批配置文件:`config/auto_approval.json` + - 建议字段:`name` / `url` / `key` / `model` / `extra_params` + - 额外控制:`timeout_seconds` / `max_rounds` / `max_command_timeout` +- 调试开关(代码变量):`modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT` + - 开启后写入:`logs/approval_agent/` + - 记录以累积 `messages` 为主,便于对齐主/子智能体会话格式 diff --git a/README.md b/README.md index 4168baa..23ffc2f 100644 --- a/README.md +++ b/README.md @@ -136,12 +136,18 @@ HOST_WORKSPACES_FILE=./config/host_workspaces.json - **权限模式(Permission Mode)** - `readonly`:`run_command` 在只读沙箱执行;写入会被系统拒绝 - `approval`:`run_command` 先按只读沙箱执行;若遇权限拒绝,再触发前端审批;审批通过后仅该次命令以可写沙箱重试 + - `auto_approval`:工作区内 `write_file/edit_file` 直通;高风险操作由后台审批智能体自动审核。`run_command` 仍先只读执行,遇权限拒绝后再自动审批 - `unrestricted`:不做权限模式拦截(仍受执行环境约束) - **路径授权(Path Authorization)** - 可配置两类路径:`可读可写` 与 `仅可读` - 语义:`可读集合 = 可读可写 + 仅可读`;`可写集合 = 可读可写` - 默认建议:仅工作区 + 临时目录可写,其余按需白名单 +- **自动审批智能体(Auto Approval Agent)** + - 配置文件:`config/auto_approval.json` + - 核心字段:`name` / `url` / `key` / `model` / `extra_params` + - 调试记录:可在 `modules/approval_agent.py` 打开 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT`,输出到 `logs/approval_agent/` + `HOST_WORKSPACES_FILE` 对应 JSON 示例: ```json diff --git a/config/auto_approval.json b/config/auto_approval.json new file mode 100644 index 0000000..85735cb --- /dev/null +++ b/config/auto_approval.json @@ -0,0 +1,14 @@ +{ + "name": "auto-approval-agent", + "url": "https://api.deepseek.com", + "key": "${API_KEY_DEEPSEEK}", + "model": "deepseek-v4-flash", + "extra_params": { + "thinking": { + "type": "enabled" + } + }, + "timeout_seconds": 60, + "max_rounds": 3, + "max_command_timeout": 60 +} diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index deb61d6..eb8a6aa 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -93,12 +93,14 @@ class MainTerminalContextMixin: "unrestricted": "当前处于无限制模式,所有工具均可直接使用,无需额外批准。", "readonly": "当前处于只读模式,仅能执行不会修改工作区的读取类操作。", "approval": "当前处于批准模式,修改工作区的操作需要用户批准后方可执行。", + "auto_approval": "当前处于自动审核模式,工作区内文件编辑可直接执行,高风险操作由后台审批智能体自动审核。", } _PERMISSION_MODE_LABEL = { "unrestricted": "无限制", "readonly": "只读", "approval": "批准", + "auto_approval": "自动审核", } _EXECUTION_MODE_LABEL = { @@ -135,6 +137,14 @@ class MainTerminalContextMixin: "- 需要用户批准:terminal_input、run_python、write_file、edit_file、save_webpage 及其他会修改工作区的操作\n" "- 被拒绝或超时:本次操作不会执行写入" ) + elif mode == "auto_approval": + detailed_rules = ( + "- write_file / edit_file:若路径在当前工作区内,直接执行;工作区外路径会进入审批流程\n" + "- run_command:先在系统只读沙箱执行;仅当出现权限拒绝时才触发自动审批\n" + "- 自动审批由后台审批智能体执行,默认只判断危险性与越权风险,不判断任务必要性\n" + "- 自动审批拒绝:本次工具调用会返回“拒绝+理由”,主循环继续;人工拒绝可随时接管\n" + "- 可随时人工接管:用户在审批面板点击同意/拒绝/切换无限制后,自动审批会立即停止并以人工决策为准" + ) else: detailed_rules = "" diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 73004f2..f116ba0 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -341,6 +341,29 @@ class MainTerminalToolsExecutionMixin: return {"allowed": True, "mode": mode, "requires_approval": True} return {"allowed": True, "mode": mode} + if mode == "auto_approval": + if tool_name in {"write_file", "edit_file"}: + path = args.get("file_path") or args.get("path") or "" + try: + valid, _, full_path = self.file_manager._validate_path(str(path)) + if valid and full_path is not None: + project_root = Path(self.context_manager.project_path).resolve() + resolved = Path(full_path).resolve() + if resolved == project_root or project_root in resolved.parents: + return {"allowed": True, "mode": mode, "requires_approval": False} + except Exception: + pass + return {"allowed": True, "mode": mode, "requires_approval": True} + if tool_name == "run_command": + return {"allowed": True, "mode": mode, "requires_approval": False} + if tool_name == "terminal_input": + command_text = args.get("command") or args.get("input") + if self._is_readonly_run_command_allowed(command_text): + return {"allowed": True, "mode": mode, "requires_approval": False} + if tool_name in self._APPROVAL_REQUIRED_TOOLS: + return {"allowed": True, "mode": mode, "requires_approval": True} + return {"allowed": True, "mode": mode} + return {"allowed": True, "mode": mode} @staticmethod @@ -1272,7 +1295,7 @@ class MainTerminalToolsExecutionMixin: write_granted_once = bool(arguments.get("_approval_write_granted", False)) sandbox_write_access = not ( permission_mode == "readonly" - or (permission_mode == "approval" and not write_granted_once) + or (permission_mode in {"approval", "auto_approval"} and not write_granted_once) ) run_in_background = bool(arguments.get("run_in_background", False)) timeout_value = arguments.get("timeout") diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index 088b361..6640bfe 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -84,7 +84,7 @@ from config.model_profiles import ( logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True -PERMISSION_MODES = {"readonly", "approval", "unrestricted"} +PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"} class MainTerminalToolsPolicyMixin: def _ensure_runtime_tool_overrides(self) -> Dict[str, bool]: @@ -209,7 +209,7 @@ class MainTerminalToolsPolicyMixin: def set_permission_mode(self, mode: str, *, persist: bool = True, conversation_id: Optional[str] = None) -> str: normalized = str(mode or "").strip().lower() if normalized not in PERMISSION_MODES: - raise ValueError("无效权限模式,仅支持 readonly / approval / unrestricted") + raise ValueError("无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted") self.current_permission_mode = normalized if not persist: return normalized diff --git a/core/web_terminal.py b/core/web_terminal.py index 92f67cc..829ef8f 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -130,7 +130,13 @@ class WebTerminal(MainTerminal): logger.warning("忽略无效默认模型 %s: %s", preferred_model, exc) preferred_mode = prefs.get("default_run_mode") - preferred_permission_mode = prefs.get("default_permission_mode") or "unrestricted" + preferred_permission_mode = None + try: + preferred_permission_mode = self.get_permission_mode() + except Exception: + preferred_permission_mode = None + if not isinstance(preferred_permission_mode, str) or not preferred_permission_mode.strip(): + preferred_permission_mode = prefs.get("default_permission_mode") or "unrestricted" if isinstance(preferred_mode, str) and preferred_mode.lower() in {"fast", "thinking", "deep"}: try: self.set_run_mode(preferred_mode.lower()) @@ -210,6 +216,12 @@ class WebTerminal(MainTerminal): self.thinking_mode = mode self.api_client.thinking_mode = mode self.api_client.start_new_task() + permission_mode = str(meta.get("permission_mode") or "").strip().lower() + if permission_mode: + try: + self.set_permission_mode(permission_mode, persist=False) + except Exception: + pass except Exception: pass # 重置相关状态 diff --git a/docs/host_sandbox_and_permission_model.md b/docs/host_sandbox_and_permission_model.md index a23a5c3..717f6ea 100644 --- a/docs/host_sandbox_and_permission_model.md +++ b/docs/host_sandbox_and_permission_model.md @@ -29,6 +29,7 @@ - `readonly` - `approval` +- `auto_approval` - `unrestricted` 作用:决定工具是否允许调用、是否先只读执行、是否需要用户审批。 @@ -89,7 +90,19 @@ - 工具结果返回“最终执行结果”(即重试后结果),不会把中间权限拒绝作为最终结果返回给模型 - 审批拒绝或超时:返回 rejected,不执行写入重试 -## 5.3 unrestricted +## 5.3 auto_approval + +- `write_file` / `edit_file`: + - 目标路径在当前工作区内:直接执行 + - 目标路径不在当前工作区内:进入审批流程 +- `run_command` 采用“两段式”: + 1) 先在只读沙箱执行 + 2) 若出现权限拒绝,触发自动审批(审批智能体) + 3) 自动审批通过后,仅该次命令可写重试 +- 自动审批拒绝:返回“工具调用被拒绝 + 原因”,主智能体继续下一步(不强制中断整轮任务) +- 用户可随时人工接管审批结果(同意/拒绝/切换无限制) + +## 5.4 unrestricted - 权限模式层不拦截工具 - 是否沙箱执行取决于执行环境: @@ -121,6 +134,7 @@ - 支持权限模式联动(readonly/approval/unrestricted) - 在 host + sandbox 下可走只读沙箱或可写沙箱 - 在 approval 模式可触发“权限拒绝 -> 审批 -> 单次可写重试” +- 在 auto_approval 模式可触发“权限拒绝 -> 自动审批 -> 单次可写重试” ### 7.2 run_python @@ -140,6 +154,19 @@ - `direct`:直接宿主机启动 - Docker 模式维持容器执行 +### 7.5 自动审批(approval agent) + +- 自动审批由独立审批智能体执行(与主智能体分离) +- 配置文件:`config/auto_approval.json` + - `name` / `url` / `key` / `model` / `extra_params` + - `timeout_seconds` / `max_rounds` / `max_command_timeout` +- 审批智能体可调用能力: + - `run_command`(用于只读核查) + - 审批决策工具(approved / rejected + reason) +- 调试开关(代码变量): + - `modules/approval_agent.py` 中 `DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT` + - 开启后记录到 `logs/approval_agent/` + --- ## 8. 配置与运行建议 @@ -167,6 +194,7 @@ 1. `run_command` 若严格收紧读路径,可能导致大量系统命令可用性下降 2. 权限拒绝识别基于错误特征,需持续优化误判/漏判边界 3. 不同 OS 的沙箱能力不完全对齐(Windows 侧为 WSL2 路径) +4. Docker 模式下目前不存在与宿主机同等的 OS 只读沙箱机制;`sandbox_write_access=False` 不会提供同等级只读隔离(需后续补充容器侧只读策略) --- @@ -175,4 +203,3 @@ - 面向用户的提示词仅描述“能力边界”和“可操作建议” - 不暴露内部实现细节(如内部判定逻辑、实现细节名称) - 审批流程文案要强调“单次授权、最小权限、可回退” - diff --git a/modules/approval_agent.py b/modules/approval_agent.py new file mode 100644 index 0000000..00561e2 --- /dev/null +++ b/modules/approval_agent.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import json +import os +import time +import uuid +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +import httpx + + +CONFIG_PATH = Path(__file__).resolve().parents[1] / "config" / "auto_approval.json" +DEFAULT_MAX_ROUNDS = 3 +DEFAULT_TIMEOUT_SECONDS = 60 +DEFAULT_MAX_COMMAND_TIMEOUT = 20 +DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT = True +DEBUG_TRANSCRIPT_DIR = Path(__file__).resolve().parents[1] / "logs" / "approval_agent" + + +def _resolve_env_token(value: str) -> str: + text = str(value or "").strip() + if text.startswith("${") and text.endswith("}"): + key = text[2:-1].strip() + return os.environ.get(key, "") + return text + + +def load_approval_agent_config() -> Dict[str, Any]: + base = { + "name": "auto-approval-agent", + "url": "", + "key": "", + "model": "deepseek-v4-flash", + "extra_params": {}, + "timeout_seconds": DEFAULT_TIMEOUT_SECONDS, + "max_rounds": DEFAULT_MAX_ROUNDS, + "max_command_timeout": DEFAULT_MAX_COMMAND_TIMEOUT, + } + try: + if not CONFIG_PATH.exists(): + return base + raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8")) + if isinstance(raw, dict): + base.update(raw) + base["url"] = _resolve_env_token(base.get("url", "")) + base["key"] = _resolve_env_token(base.get("key", "")) + base["model"] = str(base.get("model") or "deepseek-v4-flash").strip() + base["extra_params"] = base.get("extra_params") if isinstance(base.get("extra_params"), dict) else {} + base["timeout_seconds"] = int(base.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) + base["max_rounds"] = max(1, int(base.get("max_rounds") or DEFAULT_MAX_ROUNDS)) + base["max_command_timeout"] = max(1, int(base.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT)) + return base + except Exception: + return base + + +class ApprovalAgent: + def __init__(self, *, web_terminal: Any): + self.web_terminal = web_terminal + self.cfg = load_approval_agent_config() + + @staticmethod + def _system_prompt() -> str: + return ( + "你是审批智能体,只负责风险与越权判断,不判断任务必要性。" + "审批模式允许正常文件修改,因此“写入/删除”不能一概拒绝,应结合用户输入意图判断。" + "若操作涉及凭据读取(如 .env、密钥文件、token)、越权访问(工作区外敏感目录)、" + "危险破坏(如 rm -rf、权限变更、系统级配置修改、可疑网络外传)," + "且用户输入并未明确要求这些行为,则应拒绝。" + "需要特别注意:提权命令、删除系统文件、批量破坏性 git 操作、访问 SSH/GPG/AWS 凭据目录。" + "你可最多使用少量只读检查(尽量在3轮内完成),然后必须调用 approve_decision 给出 approved/rejected + reason。" + ) + + async def _run_readonly_command(self, command: str) -> Dict[str, Any]: + work_path = Path(getattr(self.web_terminal.context_manager, "project_path", ".")) + timeout = int(self.cfg.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT) + try: + result = await self.web_terminal.terminal_ops.run_command( + command=command, + working_dir=str(work_path), + timeout=timeout, + sandbox_write_access=False, + ) + return result if isinstance(result, dict) else {"success": False, "error": "run_command 返回格式异常"} + except Exception as exc: + return {"success": False, "error": str(exc)} + + async def review( + self, + *, + payload_text: str, + progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None, + cancel_check: Optional[Callable[[], Optional[Dict[str, Any]]]] = None, + ) -> Dict[str, Any]: + debug_transcript: List[Dict[str, Any]] = [] + + def _trace(event: str, payload: Dict[str, Any]) -> None: + if not DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT: + return + debug_transcript.append({ + "ts": int(time.time() * 1000), + "event": event, + "payload": payload, + }) + + def _flush_trace(final_result: Dict[str, Any]) -> None: + if not DEBUG_SAVE_APPROVAL_AGENT_TRANSCRIPT: + return + try: + DEBUG_TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True) + row = { + "created_at": int(time.time() * 1000), + "messages": messages, + } + file = DEBUG_TRANSCRIPT_DIR / f"approval_{int(time.time() * 1000)}.json" + file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8") + except Exception: + pass + + url = str(self.cfg.get("url") or "").strip() + key = str(self.cfg.get("key") or "").strip() + if not url or not key: + out = {"decision": "rejected", "reason": "审批智能体配置缺失", "source": "approval_agent"} + _flush_trace(out) + return out + endpoint = f"{url.rstrip('/')}/chat/completions" + headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"} + max_rounds = int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS) + timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS) + extra_params = dict(self.cfg.get("extra_params") or {}) + tools = [ + { + "type": "function", + "function": { + "name": "run_command", + "description": ( + "在终端中执行只读指令。可用于查看文件内容、搜索代码/文本、检查目录结构、" + "查看 git 状态与差异、确认路径是否存在、收集审批判断所需证据。" + "禁止用于写入、删除、改权限或其他修改性操作。" + ), + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": ( + "要执行的终端命令字符串。应优先使用只读命令,例如 ls、cat、grep、find、" + "rg、git status、git diff、pwd、stat 等。" + ), + } + }, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "approve_decision", + "description": ( + "提交最终审批结果并结束本次审批流程。只能返回 approved 或 rejected," + "并提供简洁、可审计的理由。" + ), + "parameters": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "enum": ["approved", "rejected"], + "description": "审批结论:approved 表示同意执行;rejected 表示拒绝执行。", + }, + "reason": { + "type": "string", + "description": ( + "审批理由。应明确风险点或放行依据,例如是否涉及凭据访问、越权路径、" + "破坏性命令、以及是否与用户输入意图一致。" + ), + }, + }, + "required": ["decision", "reason"], + }, + }, + }, + ] + messages: List[Dict[str, Any]] = [ + {"role": "system", "content": self._system_prompt()}, + {"role": "user", "content": payload_text}, + ] + async with httpx.AsyncClient(timeout=timeout_seconds) as client: + rounds = 0 + while rounds < max_rounds + 1: + rounds += 1 + if cancel_check: + external = cancel_check() + if external: + return external + if progress_cb: + progress_cb({"stage": "model_call", "round": rounds, "message": f"审批轮次 {rounds}"}) + req = { + "model": self.cfg.get("model"), + "messages": messages, + "tools": tools, + "tool_choice": "auto", + "temperature": 0.0, + **extra_params, + } + _trace("request", {"round": rounds, "request": req}) + try: + resp = await client.post(endpoint, headers=headers, json=req) + resp.raise_for_status() + except httpx.HTTPStatusError as exc: + body = "" + try: + body = exc.response.text + except Exception: + body = str(exc) + _trace("http_error", {"round": rounds, "status_code": exc.response.status_code if exc.response else None, "body": body}) + # 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params) + if extra_params: + retry_req = { + "model": self.cfg.get("model"), + "messages": messages, + "tools": tools, + "tool_choice": "auto", + "temperature": 0.0, + } + _trace("retry_request_without_extra_params", {"round": rounds, "request": retry_req}) + retry_resp = await client.post(endpoint, headers=headers, json=retry_req) + try: + retry_resp.raise_for_status() + resp = retry_resp + except httpx.HTTPStatusError: + out = { + "decision": "rejected", + "reason": f"审批智能体请求失败({retry_resp.status_code})", + "source": "approval_agent", + } + _flush_trace(out) + return out + else: + out = { + "decision": "rejected", + "reason": f"审批智能体请求失败({exc.response.status_code if exc.response else 'unknown'})", + "source": "approval_agent", + } + _flush_trace(out) + return out + choice = ((resp.json().get("choices") or [{}])[0] or {}).get("message") or {} + reasoning_content = ( + choice.get("reasoning_content") + or choice.get("reasoning") + or choice.get("thinking") + or "" + ) + _trace( + "response", + { + "round": rounds, + "message": choice, + "reasoning_content": reasoning_content, + }, + ) + tool_calls = choice.get("tool_calls") or [] + content = choice.get("content") + if not tool_calls: + if content or reasoning_content: + messages.append( + { + "role": "assistant", + "content": str(content or ""), + "reasoning_content": str(reasoning_content or ""), + } + ) + if rounds > max_rounds: + messages.append({"role": "user", "content": "请立刻根据当前已知信息决定:同意或拒绝,并给出理由。"}) + continue + out = {"decision": "rejected", "reason": "审批智能体未产出可执行决策", "source": "approval_agent"} + _flush_trace(out) + return out + # 有 tool_calls 时追加一条完整 assistant 消息(与主智能体结构对齐) + assistant_message = { + "role": "assistant", + "content": str(content or ""), + "reasoning_content": str(reasoning_content or ""), + "tool_calls": tool_calls, + } + messages.append(assistant_message) + for call in tool_calls: + fn = (call.get("function") or {}).get("name") + raw_args = (call.get("function") or {}).get("arguments") or "{}" + try: + args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {}) + except Exception: + args = {} + if fn == "approve_decision": + decision = str(args.get("decision") or "").strip().lower() + reason = str(args.get("reason") or "").strip() or "无理由" + if decision in {"approved", "rejected"}: + out = {"decision": decision, "reason": reason, "source": "approval_agent"} + _flush_trace(out) + return out + out = {"decision": "rejected", "reason": "审批智能体返回非法决策", "source": "approval_agent"} + _flush_trace(out) + return out + if fn == "run_command": + cmd = str(args.get("command") or "").strip() + if progress_cb: + progress_cb({"stage": "run_command", "round": rounds, "command": cmd}) + tool_result = await self._run_readonly_command(cmd) if cmd else {"success": False, "error": "command 不能为空"} + _trace("tool_result", {"round": rounds, "tool": "run_command", "command": cmd, "result": tool_result}) + tool_content = json.dumps(tool_result, ensure_ascii=False) + messages.append({ + "role": "tool", + "content": tool_content, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()) + f".{int((time.time()%1)*1000000):06d}", + "message_id": f"msg_{uuid.uuid4().hex}", + "tool_call_id": call.get("id"), + "name": "run_command", + }) + out = {"decision": "rejected", "reason": "审批智能体超出最大轮数", "source": "approval_agent"} + _flush_trace(out) + return out diff --git a/modules/auto_approval_service.py b/modules/auto_approval_service.py new file mode 100644 index 0000000..702acd0 --- /dev/null +++ b/modules/auto_approval_service.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from modules.approval_agent import ApprovalAgent +from server.state import tool_approval_manager + + +def build_auto_approval_payload( + *, + web_terminal: Any, + recent_tool_actions: List[Dict[str, Any]], + function_name: str, + arguments: Dict[str, Any], +) -> str: + history = list(getattr(getattr(web_terminal, "context_manager", None), "conversation_history", []) or []) + user_input = "" + for msg in reversed(history): + if not isinstance(msg, dict) or msg.get("role") != "user": + continue + metadata = msg.get("metadata") or {} + if bool(metadata.get("is_auto_generated")): + continue + content = msg.get("content") + if isinstance(content, str) and content.strip(): + user_input = content.strip() + break + + recent = recent_tool_actions[-3:] if isinstance(recent_tool_actions, list) else [] + lines = [ + "请按照流程审批:", + f"用户输入:{user_input or '(空)'}", + "AI运行的前三步操作:", + ] + if recent: + for idx, row in enumerate(recent, start=1): + lines.append(f"{idx}. {row.get('tool_name')}: {json.dumps(row.get('arguments') or {}, ensure_ascii=False)}") + else: + lines.append("(无)") + lines += [ + "请审核当前操作:", + f"{function_name}: {json.dumps(arguments or {}, ensure_ascii=False)}", + "仅判断危险性和越权风险,不判断任务必要性。", + ] + return "\n".join(lines) + + +async def run_auto_approval( + *, + web_terminal: Any, + username: str, + approval_id: str, + conversation_id: Optional[str], + recent_tool_actions: List[Dict[str, Any]], + function_name: str, + arguments: Dict[str, Any], + sender, +) -> Dict[str, Any]: + def _progress(evt: Dict[str, Any]) -> None: + sender("auto_approval_progress", {"approval_id": approval_id, "progress": evt, "conversation_id": conversation_id}) + + def _manual_takeover() -> Optional[Dict[str, Any]]: + row = tool_approval_manager.get(approval_id) + status = (row or {}).get("status") + if status in {"approved", "rejected"}: + return {"decision": status, "item": row, "source": "manual"} + return None + + _progress({"stage": "start", "message": "自动审批开始"}) + payload = build_auto_approval_payload( + web_terminal=web_terminal, + recent_tool_actions=recent_tool_actions, + function_name=function_name, + arguments=arguments, + ) + agent = ApprovalAgent(web_terminal=web_terminal) + result = await agent.review(payload_text=payload, progress_cb=_progress, cancel_check=_manual_takeover) + if not _manual_takeover(): + decided = tool_approval_manager.decide( + approval_id=approval_id, + username=username, + decision=str(result.get("decision") or "rejected"), + reason=str(result.get("reason") or "").strip(), + decider="approval_agent", + ) + out = {"decision": decided.get("status"), "item": decided, "source": "approval_agent"} + else: + out = _manual_takeover() or {"decision": "rejected", "reason": "人工接管失败"} + _progress({"stage": "done", "message": "自动审批结束", "decision": (out or {}).get("decision")}) + return out + diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index 337bec2..49e6e35 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -16,7 +16,7 @@ from core.tool_config import TOOL_CATEGORIES from config.model_profiles import get_registered_model_keys ALLOWED_RUN_MODES = {"fast", "thinking", "deep"} -ALLOWED_PERMISSION_MODES = {"readonly", "approval", "unrestricted"} +ALLOWED_PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"} ALLOWED_THEMES = {"classic", "light", "dark"} PERSONALIZATION_FILENAME = "personalization.json" diff --git a/modules/tool_approval_manager.py b/modules/tool_approval_manager.py index 6153f4d..69c3e5c 100644 --- a/modules/tool_approval_manager.py +++ b/modules/tool_approval_manager.py @@ -60,7 +60,15 @@ class ToolApprovalManager: rows.sort(key=lambda x: x.get("created_at", 0.0)) return rows - def decide(self, approval_id: str, username: str, decision: str) -> Dict[str, Any]: + def decide( + self, + approval_id: str, + username: str, + decision: str, + *, + reason: Optional[str] = None, + decider: Optional[str] = None, + ) -> Dict[str, Any]: normalized = str(decision or "").strip().lower() if normalized not in {"approved", "rejected"}: raise ValueError("decision 仅支持 approved / rejected") @@ -75,5 +83,8 @@ class ToolApprovalManager: item["status"] = normalized item["decision"] = normalized item["decided_at"] = time.time() + if isinstance(reason, str) and reason.strip(): + item["reason"] = reason.strip() + if isinstance(decider, str) and decider.strip(): + item["decider"] = decider.strip() return dict(item) - diff --git a/server/chat.py b/server/chat.py index 4ee2dcd..ad592b6 100644 --- a/server/chat.py +++ b/server/chat.py @@ -43,7 +43,7 @@ UPLOAD_FOLDER_NAME = "user_upload" chat_bp = Blueprint('chat', __name__) -PERMISSION_MODE_OPTIONS = ["readonly", "approval", "unrestricted"] +PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"] EXECUTION_MODE_OPTIONS = ["sandbox", "direct"] @chat_bp.route('/api/thinking-mode', methods=['POST']) @@ -615,7 +615,7 @@ def update_permission_mode(terminal: WebTerminal, workspace: UserWorkspace, user if target_mode not in PERMISSION_MODE_OPTIONS: return jsonify({ "success": False, - "error": "无效权限模式,仅支持 readonly / approval / unrestricted" + "error": "无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted" }), 400 try: applied_mode = terminal.set_permission_mode(target_mode, persist=True) diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index be86bc9..5681155 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -20,6 +20,7 @@ from utils.tool_result_formatter import ( from utils.context_manager import AUTO_SHALLOW_PLACEHOLDER from config import TOOL_CALL_COOLDOWN from modules.personalization_manager import load_personalization_config, resolve_context_compression_settings +from modules.auto_approval_service import run_auto_approval from .deep_compression import run_deep_compression @@ -177,6 +178,11 @@ def _is_permission_denied_result(result_data: Dict[str, Any]) -> bool: return any(marker in joined for marker in markers) +def _format_rejected_tool_text(reason: str) -> str: + clean_reason = str(reason or "").strip() or "未提供" + return f"工具调用被拒绝\n原因:{clean_reason}" + + async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]: started = time.time() while True: @@ -193,6 +199,7 @@ async def _wait_for_tool_approval(*, approval_id: str, username: str, timeout_se await asyncio.sleep(0.2) + async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, client_sid: str, username: str, iteration: int, conversation_id: Optional[str], last_tool_call_time: float, process_sub_agent_updates, process_background_command_updates, maybe_mark_failure_from_message, mark_force_thinking, get_stop_flag, clear_stop_flag, workspace=None): previous_tool_loop_active = getattr(web_terminal, "_tool_loop_active", False) web_terminal._tool_loop_active = True @@ -205,6 +212,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie allowed_tool_names.add(name) except Exception as exc: debug_log(f"构建工具白名单失败(降级继续): {exc}") + recent_tool_actions = list(getattr(web_terminal, "_recent_tool_actions", []) or []) # 执行每个工具 for tool_call in tool_calls: @@ -409,6 +417,13 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie continue debug_log(f"执行工具: {function_name} (ID: {tool_call_id})") + previous_tool_actions = list(recent_tool_actions) + recent_tool_actions.append({ + "tool_name": function_name, + "arguments": arguments, + }) + recent_tool_actions = recent_tool_actions[-3:] + setattr(web_terminal, "_recent_tool_actions", list(recent_tool_actions)) permission_eval = web_terminal.evaluate_tool_permission(function_name, arguments) if not permission_eval.get("allowed", True): @@ -470,19 +485,38 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie 'message': '等待用户审批', 'conversation_id': conversation_id }) - wait_result = await _wait_for_tool_approval( - approval_id=approval_item.get("approval_id"), - username=username, - ) + wait_result = None + permission_mode = str(permission_eval.get("mode") or "") + if permission_mode == "auto_approval": + approval_id = approval_item.get("approval_id") + wait_result = await run_auto_approval( + web_terminal=web_terminal, + username=username, + approval_id=approval_id, + conversation_id=conversation_id, + recent_tool_actions=previous_tool_actions, + function_name=function_name, + arguments=arguments, + sender=sender, + ) + else: + wait_result = await _wait_for_tool_approval( + approval_id=approval_item.get("approval_id"), + username=username, + ) sender('tool_approval_resolved', { 'approval_id': approval_item.get("approval_id"), 'decision': wait_result.get("decision"), + 'reason': ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason")), 'conversation_id': conversation_id, }) if wait_result.get("decision") != "approved": reject_message = "操作被用户拒绝" if wait_result.get("reason") == "审批超时": reject_message = "审批超时,操作未执行" + reason = ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason") or "").strip() + if reason: + reject_message = f"{reject_message}:{reason}" reject_payload = { "success": False, "status": "rejected", @@ -498,7 +532,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie 'message': reject_message, 'conversation_id': conversation_id }) - reject_content = json.dumps(reject_payload, ensure_ascii=False) + reject_content = _format_rejected_tool_text(reason or reject_message) web_terminal.context_manager.add_conversation( "tool", reject_content, @@ -511,13 +545,15 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie "name": function_name, "content": reject_content }) - web_terminal._tool_loop_active = previous_tool_loop_active - return { - "stopped": False, - "approval_rejected": True, - "approval_message": reject_message, - "last_tool_call_time": last_tool_call_time - } + if permission_mode != "auto_approval": + web_terminal._tool_loop_active = previous_tool_loop_active + return { + "stopped": False, + "approval_rejected": True, + "approval_message": reject_message, + "last_tool_call_time": last_tool_call_time + } + continue # 发送工具开始事件 tool_display_id = f"tool_{iteration}_{function_name}_{time.time()}" @@ -653,7 +689,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie # 批准模式下 run_command 采用“先只读执行,触发权限拒绝后再审批并单次可写重试”。 if ( function_name == "run_command" - and permission_eval.get("mode") == "approval" + and permission_eval.get("mode") in {"approval", "auto_approval"} and not bool(arguments.get("_approval_write_granted", False)) and _is_permission_denied_result(result_data) ): @@ -683,19 +719,38 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie 'message': '检测到写权限受限,等待用户审批', 'conversation_id': conversation_id }) - wait_result = await _wait_for_tool_approval( - approval_id=approval_item.get("approval_id"), - username=username, - ) + wait_result = None + permission_mode = str(permission_eval.get("mode") or "") + if permission_mode == "auto_approval": + approval_id = approval_item.get("approval_id") + wait_result = await run_auto_approval( + web_terminal=web_terminal, + username=username, + approval_id=approval_id, + conversation_id=conversation_id, + recent_tool_actions=previous_tool_actions, + function_name=function_name, + arguments=arguments, + sender=sender, + ) + else: + wait_result = await _wait_for_tool_approval( + approval_id=approval_item.get("approval_id"), + username=username, + ) sender('tool_approval_resolved', { 'approval_id': approval_item.get("approval_id"), 'decision': wait_result.get("decision"), + 'reason': ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason")), 'conversation_id': conversation_id, }) if wait_result.get("decision") != "approved": reject_message = "操作被用户拒绝" if wait_result.get("reason") == "审批超时": reject_message = "审批超时,操作未执行" + reason = ((wait_result.get("item") or {}).get("reason") or wait_result.get("reason") or "").strip() + if reason: + reject_message = f"{reject_message}:{reason}" reject_payload = { "success": False, "status": "rejected", @@ -711,7 +766,7 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie 'message': reject_message, 'conversation_id': conversation_id }) - reject_content = json.dumps(reject_payload, ensure_ascii=False) + reject_content = _format_rejected_tool_text(reason or reject_message) web_terminal.context_manager.add_conversation( "tool", reject_content, @@ -724,13 +779,15 @@ async def execute_tool_calls(*, web_terminal, tool_calls, sender, messages, clie "name": function_name, "content": reject_content }) - web_terminal._tool_loop_active = previous_tool_loop_active - return { - "stopped": False, - "approval_rejected": True, - "approval_message": reject_message, - "last_tool_call_time": last_tool_call_time - } + if permission_mode == "approval": + web_terminal._tool_loop_active = previous_tool_loop_active + return { + "stopped": False, + "approval_rejected": True, + "approval_message": reject_message, + "last_tool_call_time": last_tool_call_time + } + continue retry_arguments = dict(arguments or {}) retry_arguments["_approval_write_granted"] = True diff --git a/static/src/App.vue b/static/src/App.vue index 0dc64de..8406f15 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -347,6 +347,8 @@ :width="rightWidth" :approvals="pendingToolApprovals" :deciding-approval-ids="decidingApprovalIds" + :auto-approval-feed-lines="autoApprovalFeedLines" + :auto-approval-final-message="autoApprovalFinalMessage" @switch-unrestricted="handleSwitchPermissionToUnrestricted" @approve="approveToolApproval" @reject="rejectToolApproval" @@ -720,6 +722,8 @@ :width="Math.min(rightWidth || 420, 460)" :approvals="pendingToolApprovals" :deciding-approval-ids="decidingApprovalIds" + :auto-approval-feed-lines="autoApprovalFeedLines" + :auto-approval-final-message="autoApprovalFinalMessage" @switch-unrestricted="handleSwitchPermissionToUnrestricted" @approve="approveToolApproval" @reject="rejectToolApproval" diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 18400df..521cd0d 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -515,6 +515,9 @@ export const taskPollingMethods = { case 'tool_approval_resolved': this.handleToolApprovalResolved(eventData, eventIdx); break; + case 'auto_approval_progress': + this.handleAutoApprovalProgress(eventData, eventIdx); + break; case 'append_payload': this.handleAppendPayload(eventData, eventIdx); @@ -1171,6 +1174,13 @@ export const taskPollingMethods = { } else { this.pendingToolApprovals.push(approval); } + if (this.approvalAutoCloseTimer) { + clearTimeout(this.approvalAutoCloseTimer); + this.approvalAutoCloseTimer = null; + } + if ((this.autoApprovalFeedLines || []).length === 0) { + this.autoApprovalFinalMessage = ''; + } this.rightCollapsed = false; if (this.rightWidth < this.minPanelWidth) { this.rightWidth = this.minPanelWidth; @@ -1189,10 +1199,39 @@ export const taskPollingMethods = { this.pendingToolApprovals = this.pendingToolApprovals.filter( (item: any) => item && item.approval_id !== approvalId ); - // 电脑端:审批完成后自动折叠面板 - if (!this.pendingToolApprovals.length && !this.isMobileViewport) { - this.rightCollapsed = true; + const decision = String(data?.decision || '').trim().toLowerCase(); + const reason = String(data?.reason || '').trim(); + if (decision === 'approved' || decision === 'rejected') { + const decisionText = decision === 'approved' ? '批准通过' : '拒绝'; + this.autoApprovalFinalMessage = `${decisionText} 原因:${reason || '未提供'}`; } + // 电脑端:审批完成后延迟折叠面板(给用户留出查看结果时间) + if (!this.pendingToolApprovals.length && !this.isMobileViewport) { + if (this.approvalAutoCloseTimer) { + clearTimeout(this.approvalAutoCloseTimer); + } + this.approvalAutoCloseTimer = setTimeout(() => { + this.rightCollapsed = true; + }, 10000); + } + this.$forceUpdate(); + }, + + handleAutoApprovalProgress(data: any) { + const progress = data?.progress; + if (!progress || typeof progress !== 'object') { + return; + } + if (!Array.isArray(this.autoApprovalFeedLines)) { + this.autoApprovalFeedLines = []; + } + if (progress.stage === 'start') { + this.autoApprovalFeedLines = ['自动审批开始']; + this.autoApprovalFinalMessage = ''; + } else if (progress.stage === 'run_command' && progress.command) { + this.autoApprovalFeedLines.push(String(progress.command)); + } + this.autoApprovalFeedLines = this.autoApprovalFeedLines.slice(-20); this.$forceUpdate(); }, diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 640a1e4..9e6d4bf 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -1141,10 +1141,11 @@ export const uiMethods = { const target = String(mode || '') .trim() .toLowerCase(); - if (!target || target === this.currentPermissionMode) { + if (!target) { this.closePermissionMenu(); return; } + const previousMode = this.currentPermissionMode; try { const response = await fetch('/api/permission-mode', { method: 'POST', @@ -1157,7 +1158,23 @@ export const uiMethods = { if (!response.ok || !payload?.success) { throw new Error(payload?.message || payload?.error || '切换权限失败'); } - this.currentPermissionMode = payload.mode || target; + const reportedMode = typeof payload?.mode === 'string' ? payload.mode : ''; + if (!reportedMode) { + throw new Error('切换权限失败:服务端未返回最新权限'); + } + this.currentPermissionMode = reportedMode; + // 二次确认,避免前端状态与后端实际权限漂移 + const verifyResponse = await fetch('/api/permission-mode'); + const verifyPayload = await verifyResponse.json().catch(() => ({})); + if (!verifyResponse.ok || !verifyPayload?.success || typeof verifyPayload?.mode !== 'string') { + throw new Error('切换权限后校验失败,请重试'); + } + this.currentPermissionMode = verifyPayload.mode; + if (verifyPayload.mode !== target) { + throw new Error( + `权限未生效(期望:${this.getPermissionModeLabel(target)},实际:${this.getPermissionModeLabel(verifyPayload.mode)})` + ); + } this.uiPushToast({ title: '权限已切换', message: `当前权限:${this.getPermissionModeLabel(this.currentPermissionMode)}`, @@ -1165,6 +1182,7 @@ export const uiMethods = { duration: 1800 }); } catch (error) { + this.currentPermissionMode = previousMode; const msg = error instanceof Error ? error.message : String(error || '切换权限失败'); this.uiPushToast({ title: '切换权限失败', diff --git a/static/src/app/state.ts b/static/src/app/state.ts index a4c3fd5..6335a64 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -143,6 +143,11 @@ export function dataState() { label: '批准', description: '对工作区文件进行修改的工具需用户批准后才会执行' }, + { + value: 'auto_approval', + label: '自动审核', + description: '工作区内写入直通,高风险操作由后台审核智能体自动审批' + }, { value: 'unrestricted', label: '无限制', @@ -163,6 +168,9 @@ export function dataState() { ], pendingToolApprovals: [], decidingApprovalIds: [], + autoApprovalFeedLines: [], + autoApprovalFinalMessage: '', + approvalAutoCloseTimer: null, imageEntries: [], imageLoading: false, videoEntries: [], diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index afc3595..d0a5601 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -341,7 +341,7 @@ const props = defineProps<{ blockTokenPanel?: boolean; blockCompressConversation?: boolean; blockConversationReview?: boolean; - currentPermissionMode: 'readonly' | 'approval' | 'unrestricted'; + currentPermissionMode: 'readonly' | 'approval' | 'auto_approval' | 'unrestricted'; permissionMenuOpen: boolean; permissionOptions: Array<{ value: string; label: string; description: string }>; executionModeEnabled?: boolean; diff --git a/static/src/components/panels/ToolApprovalPanel.vue b/static/src/components/panels/ToolApprovalPanel.vue index a07e94e..5de0d72 100644 --- a/static/src/components/panels/ToolApprovalPanel.vue +++ b/static/src/components/panels/ToolApprovalPanel.vue @@ -75,7 +75,6 @@
工具:{{ item.tool_name }}
说明:{{ item.preview.summary }}
-
+
+
自动审批记录
+
{{ autoApprovalFeedLines.join('\n') }}
+
{{ autoApprovalFinalMessage }}
+
@@ -118,6 +122,8 @@ const props = defineProps<{ width: number; approvals: Array; decidingApprovalIds?: string[]; + autoApprovalFeedLines?: string[]; + autoApprovalFinalMessage?: string; }>(); const emit = defineEmits<{ @@ -203,3 +209,34 @@ const getEditNewLines = (item: any): string[] => { }; + + diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index 89fa195..7587254 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -1595,7 +1595,7 @@ const personalTabs = computed(() => { const activeTab = ref('preferences'); type RunModeValue = 'fast' | 'thinking' | 'deep' | null; -type PermissionModeValue = 'readonly' | 'approval' | 'unrestricted'; +type PermissionModeValue = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted'; type CompressionField = | 'shallow_compress_trigger_tokens' | 'shallow_compress_keep_recent_tools' @@ -1623,6 +1623,7 @@ const permissionModeOptions: Array<{ }> = [ { id: 'readonly', label: '只读', desc: '仅允许读取/检索类工具,修改操作将被拒绝。' }, { id: 'approval', label: '批准', desc: '对工作区文件进行修改的工具需人工批准后执行。' }, + { id: 'auto_approval', label: '自动审核', desc: '工作区内写入直通;高风险操作由后台审核智能体自动审批。' }, { id: 'unrestricted', label: '无限制', desc: '工具按常规流程直接执行。' } ]; diff --git a/static/src/stores/personalization.ts b/static/src/stores/personalization.ts index 89cfffb..15f8546 100644 --- a/static/src/stores/personalization.ts +++ b/static/src/stores/personalization.ts @@ -3,7 +3,7 @@ import { useModelStore } from './model'; export type BlockDisplayMode = 'traditional' | 'stacked' | 'minimal'; type RunMode = 'fast' | 'thinking' | 'deep'; -type PermissionMode = 'readonly' | 'approval' | 'unrestricted'; +type PermissionMode = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted'; interface PersonalForm { enabled: boolean; @@ -279,6 +279,7 @@ export const usePersonalizationStore = defineStore('personalization', { default_permission_mode: data.default_permission_mode === 'readonly' || data.default_permission_mode === 'approval' || + data.default_permission_mode === 'auto_approval' || data.default_permission_mode === 'unrestricted' ? data.default_permission_mode : 'unrestricted', @@ -540,7 +541,7 @@ export const usePersonalizationStore = defineStore('personalization', { this.clearFeedback(); }, setDefaultPermissionMode(mode: PermissionMode) { - const allowed: PermissionMode[] = ['readonly', 'approval', 'unrestricted']; + const allowed: PermissionMode[] = ['readonly', 'approval', 'auto_approval', 'unrestricted']; const target = allowed.includes(mode) ? mode : 'unrestricted'; this.form = { ...this.form,