diff --git a/AGENTS.md b/AGENTS.md index cd0e57bb..630ac960 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -365,6 +365,30 @@ AI 执行以下流程时,每一步都要向用户说明在做什么: - 开启后写入:`logs/approval_agent/` - 记录以累积 `messages` 为主,便于对齐主/子智能体会话格式 +## 10.6) 运行模式(work_mode)系统(2026-08-13 新增) + +运行模式(**work_mode**:`plan` / `ask` / `execute`)控制**与用户的交互节奏**,与权限模式(限制模型能力)正交。命名注意:`run_mode` 已被 fast/thinking 思考模式占用,严禁混用。 + +### 三档语义 + +- **plan(计划)**:只制定计划并讨论;权限**锁定为只读**且执行环境**锁定为沙箱**(UI 禁用 + 后端 `set_permission_mode`/`set_execution_mode` 双重强制——只读在宿主机依赖 OS 沙箱硬限制,direct 下无沙箱形同虚设);唯一写例外是 `.astrion/plan/*.md` 计划文档(权限层判定,提示词要求用 write_file/edit_file 直写,不走沙箱);计划完成后调 `submit_plan` 工具提请批准,**批准后自动切换到 execute**(恢复进入 plan 前的权限与执行环境,无记录时权限回落个性化 `default_permission_mode`、执行环境保持沙箱安全默认) +- **ask(询问)**:先讨论后开工;**禁用 ask_user 工具**(开放式讨论必须直接写在回复里);与 execute 无执行层差异,仅提示词不同 +- **execute(执行)**:自行梳理计划、脑补细节直接开工;仅硬阻塞才提问 + +### 实现要点(改代码必须知道) + +- **后端核心**:`core/main_terminal_parts/tools_policy.py`(WORK_MODES、`get/set/switch_work_mode`、plan 锁);`switch_work_mode` 处理 plan⇄只读+沙箱联动,`pre_plan_permission_mode`/`pre_plan_execution_mode` 存对话 metadata;执行环境 plan 锁在 `core/main_terminal.py::set_execution_mode`(只拦 direct) +- **创建对话的 work_mode 继承**:三条创建路径(prefer_defaults / 显式模式 / safe_navigation)一律沿用 terminal 当前值,**不用个性化默认值覆盖**——/new 页面切换器显示什么新对话就是什么;个性化 `default_work_mode` 仅在 terminal 首次构造时生效。plan 锁存在于 `set_permission_mode`,任何创建路径不得在其之前调用非只读 set(曾因此 500) +- **提示词**:`prompts/work_mode.txt` 模板 + `mode.py::_build_work_mode_rules`(三档规则唯一来源,冻结注入与切换通知共用);冻结注入在 `messages.py`(执行环境之后);切换走现有 drift 机制(`_RUNTIME_MODE_KINDS` 第四种),通知携带完整新规则文本 +- **API**:`GET/POST /api/work-mode`(`server/chat/permission.py`);**仅空闲可切换,运行中 409**(无 pending 队列);`plan-approvals` pending/answer 端点在 `server/chat/approval.py` +- **submit_plan 链路**:工具全模式注入(沟通类工具不过滤,非 plan 调用由 handler 运行时兜延返回引导);`PlanApprovalManager`(`modules/plan_approval_manager.py`)+ `chat_flow_tool_loop.py::_handle_submit_plan` 阻塞等待 → 前端 `PlanApprovalDialog.vue` 弹窗 → 批准则工具循环内切 execute 并静默更新 baseline(避免误发 drift 通知) +- **个性化**:`default_work_mode`(默认 plan),后端 `personalization_manager.py` 白名单 + 前端 `stores/personalization.ts` 三处(接口/默认值/sanitize) +- **前端**:输入栏 `work-mode-switcher`(InputComposer,streaming 时锁定);plan 下权限菜单内「权限」「执行环境」两组选项禁用+锁定标记(**网络权限组保持可调**,不整体锁死菜单),slash 菜单同步禁用;`get_status` 带 `work_mode` 供多标签页同步 + +### 10.7) 后台命令只读沙箱修复(2026-08-13) + +后台 `run_command`(`run_in_background=true`)此前在**所有环境**绕过只读权限(`background_command_manager` 固定用可写沙箱计划)。已修复:`create_background_command` 透传 `sandbox_write_access`,宿主机路径按它选只读/可写计划。docker/web 模式的只读仍仅靠提示词约束(无真沙箱),维持现状。 + ## 11) 多智能体对话类型(multi-agent conversation type) > **2026-08 重构**:已废除「多智能体模式」全局状态,多智能体是**对话的不可变属性**(`metadata.multi_agent_mode = true`,创建时确定、不可变)。URL 统一为 `/`(旧 `/multiagent/*` 由前端 bootstrap 重定向到裸路径)。数据目录 `~/.astrion/astrion/host/mutiagents/`(保留原拼写)。完整设计与踩坑记录见项目记忆 `multi_agent_mode_design`,重构方案见 `docs/conversation_type_unification_plan.md`,本节只列 Agent 改代码时必须知道的硬约束。 diff --git a/core/main_terminal.py b/core/main_terminal.py index a81a951a..cd8222b1 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -415,6 +415,15 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi normalized = str(mode or "").strip().lower() if normalized not in {"sandbox", "direct"}: raise ValueError("无效执行环境,仅支持 sandbox / direct") + # 计划模式下执行环境锁死为沙箱:只读权限依赖 OS 沙箱硬限制, + # direct 下无沙箱,只读形同虚设。解除锁定的唯一路径是 + # switch_work_mode 先切离 plan 再恢复执行环境。 + if normalized == "direct" and getattr(self, "get_work_mode", None): + try: + if self.get_work_mode() == "plan": + raise ValueError("计划模式下执行环境锁定为沙箱,请先切换运行模式") + except AttributeError: + pass self.host_execution_mode = normalized self._apply_execution_mode_to_runtime() return self.get_execution_mode_state() diff --git a/core/main_terminal_parts/context/messages.py b/core/main_terminal_parts/context/messages.py index 0dc662e8..9c34f3e6 100644 --- a/core/main_terminal_parts/context/messages.py +++ b/core/main_terminal_parts/context/messages.py @@ -193,6 +193,14 @@ class MessagesMixin: if execution_mode_message: messages.append({"role": "system", "content": execution_mode_message}) + # 运行模式(计划/询问/执行) + work_mode_message = self._get_or_init_frozen_mode_prompt( + "frozen_work_mode_prompt", + self._build_work_mode_message, + ) + if work_mode_message: + messages.append({"role": "system", "content": work_mode_message}) + # 最近对话 def _build_recent_conversations_prompt() -> str: recent_conversations_enabled = ( diff --git a/core/main_terminal_parts/context/mode.py b/core/main_terminal_parts/context/mode.py index 7791c1d9..7ab8e8d6 100644 --- a/core/main_terminal_parts/context/mode.py +++ b/core/main_terminal_parts/context/mode.py @@ -110,19 +110,32 @@ class ModeMixin: "direct": "完全访问权限", } + _WORK_MODE_LABEL = { + "plan": "计划", + "ask": "询问", + "execute": "执行", + } + + _WORK_MODE_DESC = { + "plan": "当前处于计划模式:只制定计划并与用户讨论,不执行任何修改。", + "ask": "当前处于询问模式:先与用户讨论、确认细节,再开始实施。", + "execute": "当前处于执行模式:自行梳理需求与细节,直接开工完成。", + } + _NETWORK_PERMISSION_LABEL = { "restricted": "受限", "full": "完全开放", "none": "完全禁止", } - # 运行期模式(权限/执行环境/网络权限)切换通知的 kind 与 source 映射。 + # 运行期模式(权限/执行环境/网络权限/运行模式)切换通知的 kind 与 source 映射。 # source 需与 server/chat_flow_task_support.py 的 _VALID_SOURCES 保持一致。 - _RUNTIME_MODE_KINDS = ("permission_mode", "execution_mode", "network_permission") + _RUNTIME_MODE_KINDS = ("permission_mode", "execution_mode", "network_permission", "work_mode") _RUNTIME_MODE_SOURCE = { "permission_mode": "permission_change", "execution_mode": "execution_change", "network_permission": "network_change", + "work_mode": "work_mode_change", } # conversation metadata key:本对话「智能体已知晓」的运行期模式基线。 # 只在通知真正注入对话后更新;空闲期切换不改它,从而在下一条 user 消息时 @@ -176,6 +189,52 @@ class ModeMixin: detailed_rules=detailed_rules, ) + def _build_work_mode_rules(self, mode: str) -> str: + """三档运行模式的行为规则(系统提示词与切换通知共用,保证两处一致)。""" + if mode == "plan": + return ( + "- 本模式下你只制定计划、与用户讨论方案,**禁止执行任何修改**:不要调用写工具,也不要运行任何会修改文件或系统状态的命令\n" + "- 权限模式已被锁定为只读、执行环境已被锁定为沙箱,修改型操作会被系统直接拒绝,不要尝试绕过\n" + "- 唯一例外:可以使用 write_file / edit_file 在工作区 `.astrion/plan/` 目录下创建和修改计划文档(.md),该目录之外的写入依然被拒绝\n" + "- 工作方式:充分调研(读代码、搜索、与用户讨论)→ 把计划写入 `.astrion/plan/` 下的计划文档(包含:目标理解、方案与取舍、分步实施步骤、涉及的关键文件、验证方式)→ 调用 submit_plan 提请用户批准\n" + "- 计划保持精炼可读,突出关键决策点;过长的计划会降低用户批准意愿\n" + "- 你的一轮输出只应以两种方式结束:向用户提问/讨论,或调用 submit_plan 提交计划。不要不了了之\n" + "- 用户批准后系统会自动切换到执行模式;用户拒绝时会附带意见,你应据此修订计划文档后重新提交" + ) + if mode == "ask": + return ( + "- 先讨论,后开工:收到任务后先快速了解必要背景,然后把你的理解、拟定的方案、需要用户拍板的问题**直接写在回复里**与用户讨论\n" + "- **禁止使用 ask_user 工具**:本模式的讨论是开放式的多轮交流,直接在回复正文中输出讨论内容即可\n" + "- 与用户确认清楚关键细节之前,不要开始正式实施(不要改文件、不要跑修改型命令)\n" + "- 用户确认后(包括用户直接说「开始」「动手」「go」等),视为细节已拍板,此后自主完成剩余工作,不再逐项征求同意" + ) + if mode == "execute": + return ( + "- 直接开工:用户的要求即最终需求,自行梳理、制定计划并立即着手实施,不要停下来向用户复述确认\n" + "- 自行补全用户未说明的细节:按最合理的方式决策并继续推进,不要就设计取舍、命名、路径等细节向用户提问\n" + "- 仅当出现缺少你无法自行获取的信息(如密钥、账号)或任何方案都有重大风险的硬阻塞时,才允许向用户提问\n" + "- 完成后自行验证(构建/测试/检查),再在最终回复中汇报结果,并列出你做出的关键假设以便用户纠正" + ) + return "" + + def _build_work_mode_message(self) -> Optional[str]: + """根据当前运行模式构建运行模式说明消息(从模板读取并替换占位符)。""" + template = self.load_prompt("work_mode") + if not template: + return None + mode = "plan" + try: + if hasattr(self, "get_work_mode"): + mode = self.get_work_mode() + except Exception: + pass + return template.format( + work_mode=mode, + work_mode_label=self._WORK_MODE_LABEL.get(mode, mode), + mode_description=self._WORK_MODE_DESC.get(mode, ""), + detailed_rules=self._build_work_mode_rules(mode), + ) + def _build_execution_mode_message(self) -> Optional[str]: """根据当前执行环境模式构建提示消息。""" # 仅宿主机模式注入;Docker 模式不需要该提示。 @@ -356,6 +415,11 @@ class ModeMixin: modes["network_permission"] = str(self.get_network_permission() or "") except Exception: pass + try: + if hasattr(self, "get_work_mode"): + modes["work_mode"] = str(self.get_work_mode() or "") + except Exception: + pass return modes def get_runtime_mode_baseline(self) -> Dict[str, str]: @@ -415,6 +479,15 @@ class ModeMixin: return self._build_execution_mode_switch_notice(mode) label = self._EXECUTION_MODE_LABEL.get(mode, mode) return f"执行环境被用户修改为 {label}" + if kind == "work_mode": + # 运行模式的行为规则差异大,切换通知必须携带完整新模式规则 + # (冻结的系统提示词仍是旧模式,以此为准覆盖)。 + label = self._WORK_MODE_LABEL.get(mode, mode) + rules = self._build_work_mode_rules(mode) + header = f"运行模式被用户修改为 {label}" + if rules: + return f"{header}。与之前系统提示词中的运行模式说明不一致时,以本次为准。\n\n### 当前规则\n{rules}" + return header return f"运行模式被用户修改为 {mode}" def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]: diff --git a/core/main_terminal_parts/tools_definition/core_tools.py b/core/main_terminal_parts/tools_definition/core_tools.py index 92715e5d..9b141101 100644 --- a/core/main_terminal_parts/tools_definition/core_tools.py +++ b/core/main_terminal_parts/tools_definition/core_tools.py @@ -204,4 +204,26 @@ class ToolsDefinitionCoreToolsMixin: } }, + { + "type": "function", + "function": { + "name": "submit_plan", + "description": "【仅计划模式可用】计划已完成并写入计划文档后,调用本工具提请用户批准。前端会弹出批准窗口展示计划文档内容,用户可以批准(附或不附意见)或拒绝(附意见)。批准后系统会自动切换到执行模式,你应随即按计划开始实施;被拒绝时根据用户意见修订计划文档后重新调用本工具提交。不要在计划未定稿、或仍有待澄清的问题时调用;也不要用本工具询问开放式问题——讨论请直接在回复正文中进行。调用本工具时不要同时调用其他工具。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "plan_file": { + "type": "string", + "description": "计划文档路径,必须位于工作区 .astrion/plan/ 目录下(.md 文件)。" + }, + "summary": { + "type": "string", + "description": "一句话概述这份计划要解决什么,展示在批准窗口标题区。" + } + }), + "required": ["plan_file"] + } + } + }, + ] diff --git a/core/main_terminal_parts/tools_definition/main.py b/core/main_terminal_parts/tools_definition/main.py index a5b46392..ddc851af 100644 --- a/core/main_terminal_parts/tools_definition/main.py +++ b/core/main_terminal_parts/tools_definition/main.py @@ -139,6 +139,10 @@ class ToolsDefinitionMainMixin: } } }) + # submit_plan 全模式注入:它是沟通/提交类工具而非危险写操作, + # 过滤会让非 plan 模式的模型不知道该能力存在(如 ask 模式下用户 + # 要求「整理好计划提交给我审核」时模型无法响应)。非 plan 调用由 + # _handle_submit_plan 运行时兜延返回引导,无需工具层硬限制。 # 附加自定义工具(仅管理员可见) custom_tools = self._build_custom_tools() if custom_tools: diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 8f5dfe43..0505ee59 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -376,6 +376,12 @@ class MainTerminalToolsExecutionMixin: if mode == "readonly": if tool_name == "run_command": return {"allowed": True, "mode": mode} + # 计划模式例外:计划文档(工作区 .astrion/plan/ 下的 .md)是唯一可写对象 + if tool_name in {"write_file", "edit_file"} and self._is_plan_workspace_file(args): + return {"allowed": True, "mode": mode} + # 计划模式下允许提交计划求批准 + if tool_name == "submit_plan" and self._is_plan_work_mode(): + return {"allowed": True, "mode": mode} if tool_name not in self._READONLY_ALLOWED_TOOLS: return { "allowed": False, @@ -461,6 +467,32 @@ class MainTerminalToolsExecutionMixin: return {"allowed": True, "mode": mode} + def _is_plan_work_mode(self) -> bool: + try: + return hasattr(self, "get_work_mode") and self.get_work_mode() == "plan" + except Exception: + return False + + def _is_plan_workspace_file(self, args: Dict[str, Any]) -> bool: + """计划模式写例外:仅当目标路径是工作区 .astrion/plan/ 下的 .md 文件。""" + try: + if not self._is_plan_work_mode(): + return False + path = (args or {}).get("file_path") or (args or {}).get("path") or "" + if not path: + return False + valid, _, full_path = self.file_manager._validate_path(str(path)) + if not valid or full_path is None: + return False + project_root = Path(self.context_manager.project_path).resolve() + plan_dir = (project_root / ".astrion" / "plan").resolve() + resolved = Path(full_path).resolve() + if resolved != plan_dir and plan_dir not in resolved.parents: + return False + return resolved.suffix.lower() == ".md" + except Exception: + return False + @staticmethod def _skill_meta_key(skill_id: str) -> str: return f"skill_read::{skill_id}" @@ -1753,6 +1785,7 @@ class MainTerminalToolsExecutionMixin: conversation_id=getattr(self.context_manager, "current_conversation_id", None), wait_seconds=5.0, network_permission=network_permission, + sandbox_write_access=sandbox_write_access, ) else: if timeout_value is None or float(timeout_value) <= 0: diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index e37a415b..da16510c 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -86,6 +86,10 @@ from config.model_profiles import ( logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"} +# 运行模式(交互方式档):计划 / 询问 / 执行。 +# 与权限模式(能力档)正交;plan 档会联动锁死权限为只读。 +WORK_MODES = {"plan", "ask", "execute"} +WORK_MODE_DEFAULT = "plan" class MainTerminalToolsPolicyMixin: def _ensure_runtime_tool_overrides(self) -> Dict[str, bool]: @@ -215,6 +219,13 @@ class MainTerminalToolsPolicyMixin: self.default_permission_mode = "unrestricted" if not getattr(self, "current_permission_mode", None): self.current_permission_mode = self.default_permission_mode + work_mode = effective_config.get("default_work_mode") + if isinstance(work_mode, str) and work_mode.strip().lower() in WORK_MODES: + self.default_work_mode = work_mode.strip().lower() + else: + self.default_work_mode = WORK_MODE_DEFAULT + if not getattr(self, "current_work_mode", None): + self.current_work_mode = self.default_work_mode def get_permission_mode(self) -> str: mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted") @@ -222,10 +233,131 @@ class MainTerminalToolsPolicyMixin: return "unrestricted" return mode + def get_work_mode(self) -> str: + mode = str(getattr(self, "current_work_mode", None) or WORK_MODE_DEFAULT).strip().lower() + if mode not in WORK_MODES: + return WORK_MODE_DEFAULT + return mode + + def set_work_mode(self, mode: str, *, persist: bool = True, conversation_id: Optional[str] = None) -> str: + normalized = str(mode or "").strip().lower() + if normalized not in WORK_MODES: + raise ValueError("无效运行模式,仅支持 plan / ask / execute") + self.current_work_mode = normalized + if not persist: + return normalized + + conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None) + if conv_id and getattr(self, "context_manager", None): + try: + self.context_manager._get_conversation_manager_for_id(conv_id).update_conversation_metadata(conv_id, + {"work_mode": normalized}, + ) + if self.context_manager.current_conversation_id == conv_id: + self.context_manager.conversation_metadata["work_mode"] = normalized + except Exception: + pass + return normalized + + def switch_work_mode(self, mode: str, *, persist: bool = True, conversation_id: Optional[str] = None) -> Dict[str, Any]: + """切换运行模式(plan/ask/execute),并处理 plan ⇄ 只读权限的联动。 + + - 进入 plan:先把当前权限模式存为 pre_plan_permission_mode,再强制只读; + - 离开 plan:恢复 pre_plan_permission_mode(无记录则不动当前权限); + - ask / execute 之间切换不动权限。 + 返回 {"mode": ..., "permission_mode": ...} 供调用方做通知与状态广播。 + """ + normalized = str(mode or "").strip().lower() + if normalized not in WORK_MODES: + raise ValueError("无效运行模式,仅支持 plan / ask / execute") + previous = self.get_work_mode() + conv_id = conversation_id or getattr(getattr(self, "context_manager", None), "current_conversation_id", None) + + entering_plan = normalized == "plan" and previous != "plan" + leaving_plan = previous == "plan" and normalized != "plan" + + if entering_plan: + current_permission = self.get_permission_mode() + if current_permission != "readonly": + # 记录进入前的权限模式,供离开 plan 时恢复(Claude Code prePlanMode 式) + if persist: + try: + self._persist_runtime_mode_metadata({"pre_plan_permission_mode": current_permission}) + except Exception: + pass + self.set_permission_mode("readonly", persist=persist, conversation_id=conv_id) + # 执行环境联动:direct ⇒ sandbox。只读权限在宿主机依赖 OS 沙箱硬限制, + # direct(完全访问)下无沙箱,只读形同虚设,必须一并锁回沙箱。 + try: + if hasattr(self, "get_execution_mode") and self.get_execution_mode() == "direct": + if persist: + try: + self._persist_runtime_mode_metadata({"pre_plan_execution_mode": "direct"}) + except Exception: + pass + if hasattr(self, "set_execution_mode"): + self.set_execution_mode("sandbox") + if persist and hasattr(self, "_persist_runtime_mode_metadata"): + try: + self._persist_runtime_mode_metadata({"execution_mode": "sandbox"}) + except Exception: + pass + except Exception: + pass + # 先落新模式,再恢复权限:set_permission_mode 的 plan 锁以新模式为准 + self.set_work_mode(normalized, persist=persist, conversation_id=conv_id) + if leaving_plan: + restore = None + try: + meta = getattr(getattr(self, "context_manager", None), "conversation_metadata", None) or {} + restore = str(meta.get("pre_plan_permission_mode") or "").strip().lower() or None + except Exception: + restore = None + if restore not in PERMISSION_MODES: + restore = None + if not restore: + # 无记录(如新对话直接以 plan 创建):回落到个性化默认权限模式 + try: + prefs = load_personalization_config(getattr(self, "data_dir", None)) + candidate = str(prefs.get("default_permission_mode") or "").strip().lower() + if candidate in PERMISSION_MODES: + restore = candidate + except Exception: + restore = None + if restore and restore != "readonly": + self.set_permission_mode(restore, persist=persist, conversation_id=conv_id) + if persist: + try: + self._persist_runtime_mode_metadata({"pre_plan_permission_mode": None}) + except Exception: + pass + # 恢复执行环境(仅当有明确进入前记录;无记录保持 sandbox,安全默认) + try: + meta = getattr(getattr(self, "context_manager", None), "conversation_metadata", None) or {} + pre_exec = str(meta.get("pre_plan_execution_mode") or "").strip().lower() + if pre_exec == "direct" and hasattr(self, "set_execution_mode"): + self.set_execution_mode("direct") + if persist and hasattr(self, "_persist_runtime_mode_metadata"): + try: + self._persist_runtime_mode_metadata({"execution_mode": "direct", "pre_plan_execution_mode": None}) + except Exception: + pass + except Exception: + pass + return {"mode": normalized, "previous_mode": previous, "permission_mode": self.get_permission_mode()} + 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 / auto_approval / unrestricted") + # 计划模式下权限锁死为只读(后端强制):仅允许保持/切到 readonly。 + # 解除锁定的唯一路径是 switch_work_mode 先切离 plan 再恢复权限。 + if normalized != "readonly" and getattr(self, "get_work_mode", None): + try: + if self.get_work_mode() == "plan": + raise ValueError("计划模式下权限模式锁定为只读,请先切换运行模式") + except AttributeError: + pass self.current_permission_mode = normalized if not persist: return normalized diff --git a/core/web_terminal.py b/core/web_terminal.py index becacecf..b4d1fc91 100644 --- a/core/web_terminal.py +++ b/core/web_terminal.py @@ -171,6 +171,8 @@ class WebTerminal(MainTerminal): t0 = time.perf_counter() prefer_defaults = thinking_mode is None and run_mode is None thinking_mode_explicit = thinking_mode is not None + # 新对话以 plan 模式创建时记录的「进入前权限模式」(供离开 plan 恢复) + new_conv_pre_plan_permission = None # 先加载个性化默认配置(用于 start_new_conversation 的 metadata) prefs = {} @@ -212,10 +214,37 @@ class WebTerminal(MainTerminal): self.set_reasoning_effort(preferred_effort) except ValueError: self.set_reasoning_effort(None) + # 运行模式(work_mode):沿用 terminal 当前值,不用个性化默认值覆盖—— + # /new 页面的运行模式切换已同步到 terminal(_sync_workspace_terminal_mode), + # 切换器上显示什么,新对话冻结进 metadata 的就是什么,第一条消息发送时 + # 按当时实际模式生成冻结提示词(与执行环境/网络权限的继承方式一致)。 + # 个性化 default_work_mode 仅在 terminal 首次构造时生效(tools_policy 加载)。 + current_work_mode = "plan" try: - self.set_permission_mode(preferred_permission_mode, persist=False) + current_work_mode = self.get_work_mode() except Exception: - self.set_permission_mode("unrestricted", persist=False) + pass + if current_work_mode == "plan": + # 不变量:plan ⇒ 权限必须只读。记录进入前权限(供离开 plan 恢复)并锁定。 + # 注意不能在 plan 状态下调用非只读的 set_permission_mode(plan 锁会 raise)。 + try: + pre_plan_perm = self.get_permission_mode() + except Exception: + pre_plan_perm = "unrestricted" + if pre_plan_perm != "readonly": + new_conv_pre_plan_permission = pre_plan_perm + try: + self.set_permission_mode("readonly", persist=False) + except Exception: + pass + else: + try: + self.set_permission_mode(preferred_permission_mode, persist=False) + except Exception: + try: + self.set_permission_mode("unrestricted", persist=False) + except Exception: + pass if isinstance(run_mode, str): try: @@ -239,6 +268,8 @@ class WebTerminal(MainTerminal): metadata_overrides_merged = { "permission_mode": self.get_permission_mode(), "execution_mode": self.get_execution_mode() if hasattr(self, "get_execution_mode") else "sandbox", + "work_mode": self.get_work_mode() if hasattr(self, "get_work_mode") else "plan", + "pre_plan_permission_mode": new_conv_pre_plan_permission, "pending_permission_mode": None, "pending_execution_mode": None, # 推理强度随创建写入一次:prefer_defaults 时 self 已应用个性化默认值; @@ -407,18 +438,41 @@ class WebTerminal(MainTerminal): self.set_reasoning_effort(meta.get("reasoning_effort")) except ValueError: self.set_reasoning_effort(None) + # 运行模式必须先于权限恢复:set_permission_mode 带 plan 锁 + # (plan 下禁止非只读),若 permission 先恢复,上一对话残留的 + # work_mode=plan 会让本对话的非只读权限恢复被误拦截。 + work_mode = str(meta.get("work_mode") or "").strip().lower() + if work_mode in {"plan", "ask", "execute"}: + try: + self.set_work_mode(work_mode, persist=False) + except Exception: + pass 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 + # 自愈:plan 模式但权限不是只读的异常数据,强制回只读 + if self.get_work_mode() == "plan" and self.get_permission_mode() != "readonly": + try: + self.set_permission_mode("readonly", persist=False) + except Exception: + pass execution_mode = str(meta.get("execution_mode") or "").strip().lower() if execution_mode in {"sandbox", "direct"}: try: self.set_execution_mode(execution_mode) except Exception: pass + # 自愈:plan 模式但执行环境是 direct 的异常数据,强制回沙箱 + # (只读权限依赖沙箱硬限制,direct 下只读无牙齿) + if self.get_work_mode() == "plan" and hasattr(self, "get_execution_mode"): + try: + if self.get_execution_mode() != "sandbox": + self.set_execution_mode("sandbox") + except Exception: + pass network_permission = str(meta.get("network_permission") or "").strip().lower() if network_permission in {"restricted", "full", "none"}: try: @@ -565,6 +619,7 @@ class WebTerminal(MainTerminal): "reasoning_effort": getattr(self, "reasoning_effort", None), "model_key": getattr(self, "model_key", None), "permission_mode": self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted", + "work_mode": self.get_work_mode() if hasattr(self, "get_work_mode") else "plan", "execution_mode": self.get_execution_mode_state() if hasattr(self, "get_execution_mode_state") else {"mode": "sandbox"}, "network_permission": self.get_network_permission() if hasattr(self, "get_network_permission") else "restricted", "pending_runtime_modes": self.get_pending_runtime_modes() if hasattr(self, "get_pending_runtime_modes") else {}, diff --git a/modules/background_command_manager.py b/modules/background_command_manager.py index 193ffe6b..e7f476c1 100644 --- a/modules/background_command_manager.py +++ b/modules/background_command_manager.py @@ -16,6 +16,7 @@ from config import MAX_RUN_COMMAND_CHARS from modules.host_sandbox_runner import ( HostSandboxError, build_host_sandbox_plan, + build_host_sandbox_readonly_plan, host_sandbox_enabled, ) @@ -42,6 +43,7 @@ class BackgroundCommandManager: conversation_id: Optional[str], wait_seconds: float = 5.0, network_permission: Optional[str] = None, + sandbox_write_access: bool = True, ) -> Dict[str, Any]: """启动后台命令;先等待一小段时间返回已有输出。""" if timeout is None or float(timeout) <= 0: @@ -118,6 +120,9 @@ class BackgroundCommandManager: # 网络权限必须按调用方(对话级 terminal)快照传入,不能读进程级 # 环境变量——env 会被其他 terminal 实例覆盖,导致权限不生效或跨工作区串扰。 "network_permission": network_permission, + # 写权限同理:只读模式下后台命令必须使用只读沙箱计划, + # 否则宿主机只读会被后台路径绕过(此前固定用可写计划)。 + "sandbox_write_access": bool(sandbox_write_access), }, name=f"bg-run-command-{command_id}", daemon=True, @@ -186,6 +191,7 @@ class BackgroundCommandManager: python_env: Dict[str, str], host_execution_mode: str = "sandbox", network_permission: Optional[str] = None, + sandbox_write_access: bool = True, ) -> None: start_ts = time.time() process: Optional[subprocess.Popen] = None @@ -241,7 +247,10 @@ class BackgroundCommandManager: if network_permission else os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted") ) - plan = build_host_sandbox_plan(command, work_path, env, network_permission=effective_network_permission) + if sandbox_write_access: + plan = build_host_sandbox_plan(command, work_path, env, network_permission=effective_network_permission) + else: + plan = build_host_sandbox_readonly_plan(command, work_path, env, network_permission=effective_network_permission) cmd_args = plan.command pass_fds = () seccomp_fd = None diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index c7278875..2ad0b001 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -17,6 +17,7 @@ from config.model_profiles import get_default_model_key, get_registered_model_ke ALLOWED_RUN_MODES = {"fast", "thinking"} ALLOWED_PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"} +ALLOWED_WORK_MODES = {"plan", "ask", "execute"} ALLOWED_THEMES = {"classic", "light", "dark"} ALLOWED_COMMUNICATION_STYLES = {"default", "human_like", "auto"} ALLOWED_CONVERSATION_CONTINUITY = {"low", "medium", "high"} @@ -73,6 +74,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = { "default_run_mode": "thinking", "default_reasoning_effort": None, # 默认推理强度:None=默认(不传参) "default_permission_mode": "unrestricted", + "default_work_mode": "plan", # 默认运行模式:plan / ask / execute "auto_generate_title": True, "recent_conversations_prompt_enabled": False, "recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, @@ -336,6 +338,12 @@ def sanitize_personalization_payload( elif base.get("default_permission_mode") not in ALLOWED_PERMISSION_MODES: base["default_permission_mode"] = "unrestricted" + work_mode = data.get("default_work_mode", base.get("default_work_mode")) + if isinstance(work_mode, str) and work_mode.strip().lower() in ALLOWED_WORK_MODES: + base["default_work_mode"] = work_mode.strip().lower() + elif base.get("default_work_mode") not in ALLOWED_WORK_MODES: + base["default_work_mode"] = "plan" + base["versioning_restore_mode"] = "overwrite" # 默认模型 diff --git a/modules/plan_approval_manager.py b/modules/plan_approval_manager.py new file mode 100644 index 00000000..1a7cd76c --- /dev/null +++ b/modules/plan_approval_manager.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import threading +import time +import uuid +from typing import Any, Dict, List, Optional + +# 计划文档内容送进弹窗/记录的最大字符数(超出截断,完整内容始终在计划文件里) +PLAN_CONTENT_MAX_CHARS = 20000 + + +class PlanApprovalManager: + """In-memory manager for plan-mode plan approval requests (submit_plan 工具). + + 与 UserQuestionManager 同构:模型在计划模式下调用 submit_plan 后阻塞等待, + 前端弹窗展示计划文档内容,用户批准(可附意见)或拒绝(附意见)。 + """ + + def __init__(self): + self._items: Dict[str, Dict[str, Any]] = {} + self._lock = threading.Lock() + + def create_request( + self, + *, + username: str, + conversation_id: Optional[str], + task_id: Optional[str], + tool_call_id: Optional[str], + plan_file: str, + plan_content: str, + summary: Optional[str] = None, + ) -> Dict[str, Any]: + approval_id = f"plan_approval_{uuid.uuid4().hex}" + content = str(plan_content or "") + truncated = False + if len(content) > PLAN_CONTENT_MAX_CHARS: + content = content[:PLAN_CONTENT_MAX_CHARS] + truncated = True + item = { + "approval_id": approval_id, + "username": username, + "conversation_id": conversation_id, + "task_id": task_id, + "tool_call_id": tool_call_id, + "plan_file": str(plan_file or "").strip(), + "plan_content": content, + "plan_content_truncated": truncated, + "summary": str(summary or "").strip()[:300], + "status": "pending", + "created_at": time.time(), + "resolved_at": None, + "comment": "", + } + with self._lock: + self._items[approval_id] = item + return dict(item) + + def get(self, approval_id: str) -> Optional[Dict[str, Any]]: + with self._lock: + item = self._items.get(approval_id) + return dict(item) if item else None + + def list_pending(self, username: str, conversation_id: Optional[str] = None) -> List[Dict[str, Any]]: + with self._lock: + rows = [] + for item in self._items.values(): + if item.get("username") != username: + continue + if item.get("status") != "pending": + continue + if conversation_id and item.get("conversation_id") != conversation_id: + continue + rows.append(dict(item)) + rows.sort(key=lambda x: x.get("created_at", 0.0)) + return rows + + def answer( + self, + *, + approval_id: str, + username: str, + approved: bool, + comment: Optional[str] = None, + ) -> Dict[str, Any]: + clean_comment = str(comment or "").strip() + with self._lock: + item = self._items.get(approval_id) + if not item: + raise KeyError("计划批准请求不存在") + if item.get("username") != username: + raise PermissionError("无权限处理该计划批准请求") + if item.get("status") != "pending": + return dict(item) + item["status"] = "approved" if approved else "rejected" + item["resolved_at"] = time.time() + item["comment"] = clean_comment[:2000] + return dict(item) diff --git a/prompts/main_system.txt b/prompts/main_system.txt index 669df141..994d3518 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -32,6 +32,8 @@ ## 2. 行为方法:先规划,后执行,再验证 +> 本节描述的是默认交互节奏基线。若系统提示词中包含「运行模式」(计划/询问/执行)说明,交互节奏以运行模式说明为准(例如执行模式下不要求逐步征求同意)。 + 1. **理解需求**:复述确认,澄清疑问,识别约束 2. **制定计划**:拆分任务,创建待办,预估风险,征求同意 3. **执行操作**:小步快跑,边做边说,保存进度 @@ -155,6 +157,8 @@ **绝对禁止**:擅自行动、猜测需求、越权操作、编造信息、暴露敏感信息 +> 「擅自行动、猜测需求」的判定以当前运行模式为准:执行模式下主动补全用户未提及的细节并直接开工是预期行为,不属于擅自行动。 + **谨慎处理**:删除操作二次确认、对外请求确认意图、避免长时间占用资源、写入前检查覆盖 **终端禁忌**:禁止交互式程序、禁止命令未完成时继续输入、禁止凭感觉判断状态 @@ -263,6 +267,8 @@ 5. **正确执行**:主动确认细节,获得明确许可后再开始 6. **沟通原则**:使用用户使用的语言进行思考和交流 +> 第 2、5 条为默认基线;运行模式说明(计划/询问/执行)对交互节奏有专门约定时,以运行模式说明为准。 + ## 8. 个性化配置 当用户的个性化信息与上文冲突时,以用户的个性化信息为准。 diff --git a/prompts/main_system_vl.txt b/prompts/main_system_vl.txt index 7271842a..794b74e5 100644 --- a/prompts/main_system_vl.txt +++ b/prompts/main_system_vl.txt @@ -32,6 +32,8 @@ ## 2. 行为方法:先规划,后执行,再验证 +> 本节描述的是默认交互节奏基线。若系统提示词中包含「运行模式」(计划/询问/执行)说明,交互节奏以运行模式说明为准(例如执行模式下不要求逐步征求同意)。 + 1. **理解需求**:复述确认,澄清疑问,识别约束 2. **制定计划**:拆分任务,创建待办,预估风险,征求同意 3. **执行操作**:小步快跑,边做边说,保存进度 @@ -172,6 +174,8 @@ **绝对禁止**:擅自行动、猜测需求、越权操作、编造信息、暴露敏感信息 +> 「擅自行动、猜测需求」的判定以当前运行模式为准:执行模式下主动补全用户未提及的细节并直接开工是预期行为,不属于擅自行动。 + **谨慎处理**:删除操作二次确认、对外请求确认意图、避免长时间占用资源、写入前检查覆盖 **终端禁忌**:禁止交互式程序、禁止命令未完成时继续输入、禁止凭感觉判断状态 @@ -280,6 +284,8 @@ 5. **正确执行**:主动确认细节,获得明确许可后再开始 6. **沟通原则**:使用用户使用的语言进行思考和交流 +> 第 2、5 条为默认基线;运行模式说明(计划/询问/执行)对交互节奏有专门约定时,以运行模式说明为准。 + ## 8. 个性化配置 当用户的个性化信息与上文冲突时,以用户的个性化信息为准。 diff --git a/prompts/work_mode.txt b/prompts/work_mode.txt new file mode 100644 index 00000000..cbcc5f77 --- /dev/null +++ b/prompts/work_mode.txt @@ -0,0 +1,8 @@ +## 运行模式:{work_mode_label}({work_mode}) + +{mode_description} + +### 当前规则 +{detailed_rules} + +本节描述的是当前对话的交互节奏(何时讨论、何时开工、何时提问),优先级高于主提示词中「行为方法」「禁止做的事情」「核心原则」等章节里关于提问与确认的默认描述;运行模式被用户切换时,你会收到新的运行模式说明,永远以最新的一份为准。 diff --git a/server/chat/approval.py b/server/chat/approval.py index df7e3ed2..447cc4ac 100644 --- a/server/chat/approval.py +++ b/server/chat/approval.py @@ -38,7 +38,7 @@ from server.context import with_terminal, get_gui_manager, get_upload_guard, bui from server.security import rate_limited, prune_socket_tokens from server.utils_common import debug_log from server.state import PROJECT_MAX_STORAGE_MB, pending_socket_tokens, SOCKET_TOKEN_TTL_SECONDS -from server.state import tool_approval_manager, user_question_manager +from server.state import tool_approval_manager, user_question_manager, plan_approval_manager from server.extensions import socketio from server.monitor import get_cached_monitor_snapshot @@ -87,6 +87,49 @@ def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, userna "item": item, }) +@chat_bp.route('/api/plan-approvals/pending', methods=['GET']) +@api_login_required +@with_terminal +def list_pending_plan_approvals(terminal: WebTerminal, workspace: UserWorkspace, username: str): + """获取当前用户待批准的计划列表(work_mode=plan 的 submit_plan 工具)。""" + requested_conv_id = (request.args.get("conversation_id") or "").strip() or None + if requested_conv_id is None: + requested_conv_id = getattr(terminal.context_manager, "current_conversation_id", None) + items = plan_approval_manager.list_pending(username=username, conversation_id=requested_conv_id) + return jsonify({ + "success": True, + "items": items, + "conversation_id": requested_conv_id, + }) + +@chat_bp.route('/api/plan-approvals//answer', methods=['POST']) +@api_login_required +@with_terminal +@rate_limited("plan_approval_answer", 120, 60, scope="user") +def answer_plan_approval(terminal: WebTerminal, workspace: UserWorkspace, username: str, approval_id: str): + """提交计划批准/拒绝决策。approved=true 时工具循环侧会自动切换到 execute 模式。""" + data = request.get_json() or {} + try: + item = plan_approval_manager.answer( + approval_id=approval_id, + username=username, + approved=bool(data.get("approved")), + comment=data.get("comment"), + ) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except KeyError: + return jsonify({"success": False, "error": "计划批准请求不存在"}), 404 + except PermissionError as exc: + return jsonify({"success": False, "error": str(exc)}), 403 + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + return jsonify({ + "success": True, + "item": item, + }) + @chat_bp.route('/api/tool-approvals/pending', methods=['GET']) @api_login_required @with_terminal diff --git a/server/chat/permission.py b/server/chat/permission.py index 26dbd98b..9b85a3d2 100644 --- a/server/chat/permission.py +++ b/server/chat/permission.py @@ -5,6 +5,7 @@ import json, time PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"] EXECUTION_MODE_OPTIONS = ["sandbox", "direct"] NETWORK_PERMISSION_OPTIONS = ["restricted", "full", "none"] +WORK_MODE_OPTIONS = ["plan", "ask", "execute"] from datetime import datetime from typing import Dict, Any, Optional from pathlib import Path @@ -79,6 +80,11 @@ def _sync_workspace_terminal_mode(username: str, workspace, kind: str, mode: str ws_terminal.set_execution_mode(mode) elif kind == "network_permission": ws_terminal.set_network_permission(mode) + elif kind == "work_mode": + # 必须走 switch_work_mode(persist=False 仅内存态):裸 set 会破坏 + # 「plan ⇒ 权限只读」不变量(切 plan 不锁只读 / 离 plan 不恢复权限)。 + # persist=False 避免误写工作区 terminal 上可能残留的陈旧对话 metadata。 + ws_terminal.switch_work_mode(mode, persist=False) except Exception: pass @@ -391,6 +397,89 @@ def update_network_permission(terminal: WebTerminal, workspace: UserWorkspace, u "message": "网络权限已更新并立即生效", }) +@chat_bp.route('/api/work-mode', methods=['GET']) +@api_login_required +@with_terminal +def get_work_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str): + """获取当前运行模式(plan/ask/execute)。""" + current_conversation_id = getattr(terminal.context_manager, "current_conversation_id", None) + return jsonify({ + "success": True, + "mode": terminal.get_work_mode() if hasattr(terminal, "get_work_mode") else "plan", + # plan 档下权限被联动锁定为只读、执行环境被联动锁定为沙箱,前端需要同步显示 + "permission_mode": terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else None, + "execution_mode": terminal.get_execution_mode() if hasattr(terminal, "get_execution_mode") else None, + "options": WORK_MODE_OPTIONS, + "conversation_id": current_conversation_id, + }) + +@chat_bp.route('/api/work-mode', methods=['POST']) +@api_login_required +@with_terminal +@rate_limited("work_mode_switch", 30, 60, scope="user") +def update_work_mode(terminal: WebTerminal, workspace: UserWorkspace, username: str): + """切换运行模式。与权限/执行环境不同:仅允许对话空闲时切换,运行中直接拒绝 + (运行模式决定交互节奏,运行中切换会让正在执行的任务行为自相矛盾)。 + """ + data = request.get_json() or {} + target_mode = str(data.get("mode") or "").strip().lower() + if target_mode not in WORK_MODE_OPTIONS: + return jsonify({ + "success": False, + "error": "无效运行模式,仅支持 plan / ask / execute" + }), 400 + + # 运行中拒绝切换(无 pending 队列——运行模式不存在「工具结果后插入」的路径)。 + # 运行模式是对话级状态,只检测本对话是否有运行中任务;其他对话运行不影响。 + try: + from server.tasks import task_manager + current_conv = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None) + if current_conv: + conv_running = [ + r for r in task_manager.list_tasks(username) + if r.status in {"pending", "running", "cancel_requested"} and r.conversation_id == current_conv + ] + if conv_running: + return jsonify({ + "success": False, + "error": "对话运行中,运行模式只能在空闲时切换", + "message": "对话运行中,运行模式只能在空闲时切换", + }), 409 + except Exception: + pass + + previous_permission = terminal.get_permission_mode() if hasattr(terminal, "get_permission_mode") else None + try: + result = terminal.switch_work_mode(target_mode) + except Exception as exc: + return jsonify({ + "success": False, + "error": str(exc), + "message": "切换运行模式失败" + }), 500 + + # plan 联动可能改了权限模式:同步工作区级 terminal 的两个模式 + try: + _sync_workspace_terminal_mode(username, workspace, "work_mode", result.get("mode") or target_mode) + new_permission = result.get("permission_mode") + if new_permission and new_permission != previous_permission: + _sync_workspace_terminal_mode(username, workspace, "permission_mode", new_permission) + except Exception: + pass + + status = terminal.get_status() + socketio.emit('status_update', status, room=f"user_{username}") + return jsonify({ + "success": True, + "mode": result.get("mode") or target_mode, + "permission_mode": result.get("permission_mode"), + # plan 联动可能切了执行环境(direct⇒sandbox),一并返回供前端同步 + "execution_mode": terminal.get_execution_mode() if hasattr(terminal, "get_execution_mode") else None, + "options": WORK_MODE_OPTIONS, + "conversation_id": getattr(terminal.context_manager, "current_conversation_id", None), + "message": "运行模式已更新并立即生效", + }) + @chat_bp.route('/api/path-authorization', methods=['GET']) @api_login_required @with_terminal diff --git a/server/chat_flow_task_support.py b/server/chat_flow_task_support.py index 8e5d04bd..52df85d0 100644 --- a/server/chat_flow_task_support.py +++ b/server/chat_flow_task_support.py @@ -27,6 +27,7 @@ _VALID_SOURCES = { "permission_change", "execution_change", "network_change", + "work_mode_change", "file_paths", } @@ -38,8 +39,8 @@ def _runtime_message_ui_defaults(src: str, *, inline: bool = False) -> Dict[str, return {"visibility": "hidden", "starts_work": False} if normalized in {"guidance", "notify"}: return {"visibility": "compact", "starts_work": False} - # 运行期模式(权限/执行环境/网络权限)切换通知:仅通知,不开启新一轮工作。 - if normalized in {"permission_change", "execution_change", "network_change"}: + # 运行期模式(权限/执行环境/网络权限/运行模式)切换通知:仅通知,不开启新一轮工作。 + if normalized in {"permission_change", "execution_change", "network_change", "work_mode_change"}: return {"visibility": "compact", "starts_work": False} # 后台完成通知 / 运行期压缩续接:属于当前这轮工作的延续,不开启新的工作头与计时器, # 也不暂停上一轮计时器。与 task_main 的 _user_message_ui_defaults 保持一致, diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index 336bbb23..c91a86c9 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -9,7 +9,7 @@ from typing import Optional, Dict, Any, List from .utils_common import debug_log from .state import MONITOR_FILE_TOOLS, MONITOR_MEMORY_TOOLS, MONITOR_SNAPSHOT_CHAR_LIMIT, MONITOR_MEMORY_ENTRY_LIMIT -from .state import tool_approval_manager, user_question_manager +from .state import tool_approval_manager, user_question_manager, plan_approval_manager from .monitor import cache_monitor_snapshot from .security import compact_web_search_result from .chat_flow_helpers import detect_tool_failure @@ -261,6 +261,151 @@ async def _wait_for_user_questions(*, question_ids: List[str], username: str, ti return answered +async def _wait_for_plan_approval(*, approval_id: str, username: str, timeout_seconds: float = 3600.0) -> Dict[str, Any]: + """阻塞等待计划批准结果(轮询管理器,与 _wait_for_user_questions 同构)。""" + started = time.time() + while True: + row = plan_approval_manager.get(approval_id) + if not row: + return {"status": "missing"} + if row.get("username") != username: + return {"status": "forbidden"} + if row.get("status") in {"approved", "rejected"}: + return row + if (time.time() - started) >= timeout_seconds: + return {"status": "timeout"} + await asyncio.sleep(0.3) + + +async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender, username: str, conversation_id: Optional[str], tool_call_id: Optional[str]) -> str: + """submit_plan 工具的计划批准流:弹窗展示计划文档 → 用户批准/拒绝 → + 批准则自动切换运行模式到 execute(联动恢复权限)并更新运行期模式基线。""" + args = arguments or {} + + def _payload(**kwargs) -> str: + return json.dumps(kwargs, ensure_ascii=False) + + # 1. 仅计划模式可调用(工具列表已按模式过滤,这里是兜底) + try: + current_work_mode = web_terminal.get_work_mode() if hasattr(web_terminal, "get_work_mode") else None + except Exception: + current_work_mode = None + if current_work_mode != "plan": + return _payload(success=False, status="not_in_plan_mode", + message="当前不在计划模式,submit_plan 不可用。若需要用户确认方案,请直接在回复中输出讨论内容。") + + # 2. 校验计划文件路径并读取内容 + plan_file = str(args.get("plan_file") or "").strip() + summary = str(args.get("summary") or "").strip() + if not plan_file: + return _payload(success=False, status="invalid_plan_file", + message="缺少 plan_file 参数。请先把计划写入 .astrion/plan/ 下的 .md 文件再提交。") + try: + valid, err, full_path = web_terminal.file_manager._validate_path(plan_file) + except Exception as exc: + valid, err, full_path = False, str(exc), None + if not valid or not full_path: + return _payload(success=False, status="invalid_plan_file", + message=f"计划文件路径无效:{err or plan_file}") + try: + project_root = Path(web_terminal.context_manager.project_path).resolve() + plan_dir = (project_root / ".astrion" / "plan").resolve() + resolved = Path(full_path).resolve() + if resolved != plan_dir and plan_dir not in resolved.parents: + return _payload(success=False, status="invalid_plan_file", + message="计划文件必须位于工作区 .astrion/plan/ 目录下。") + if resolved.suffix.lower() != ".md": + return _payload(success=False, status="invalid_plan_file", + message="计划文件必须是 .md 文档。") + if not resolved.is_file(): + return _payload(success=False, status="plan_file_missing", + message=f"计划文件不存在:{plan_file}。请先写入计划文档再提交。") + plan_content = resolved.read_text(encoding="utf-8", errors="replace") + except Exception as exc: + return _payload(success=False, status="plan_file_error", + message=f"读取计划文件失败:{exc}") + if not plan_content.strip(): + return _payload(success=False, status="plan_file_empty", + message="计划文档还是空的,请先写入计划内容再提交。") + + # 3. 创建批准请求并通知前端弹窗 + approval = plan_approval_manager.create_request( + username=username, + conversation_id=conversation_id, + task_id=getattr(web_terminal, "task_id", None), + tool_call_id=tool_call_id, + plan_file=plan_file, + plan_content=plan_content, + summary=summary, + ) + sender('update_action', { + 'preparing_id': tool_call_id, + 'status': 'awaiting_plan_approval', + 'result': { + "success": False, + "status": "awaiting_plan_approval", + "approval_id": approval.get("approval_id"), + "message": "等待用户批准计划" + }, + 'message': '等待用户批准计划', + 'conversation_id': conversation_id + }) + sender('plan_approval_required', { + 'approval': approval, + 'conversation_id': conversation_id, + }) + + # 4. 阻塞等待用户决定 + resolved = await _wait_for_plan_approval(approval_id=str(approval.get("approval_id") or ""), username=username) + status = str(resolved.get("status") or "") + comment = str(resolved.get("comment") or "").strip() + sender('plan_approval_resolved', { + 'approval_id': approval.get("approval_id"), + 'decision': status, + 'comment': comment, + 'conversation_id': conversation_id, + }) + + # 5. 批准 → 切换到执行模式(恢复权限),并同步运行期模式基线避免重复通知 + if status == "approved": + switch_note = "" + try: + result = web_terminal.switch_work_mode("execute") + try: + web_terminal.update_runtime_mode_baseline({ + "work_mode": "execute", + "permission_mode": result.get("permission_mode") or "", + }) + except Exception: + pass + try: + from .extensions import socketio + socketio.emit('status_update', web_terminal.get_status(), room=f"user_{username}") + except Exception: + pass + switch_note = "系统已自动切换到执行模式,权限已解除只读锁定。" + except Exception as exc: + switch_note = f"(切换到执行模式失败:{exc},可请用户手动切换运行模式)" + message = f"用户已批准你的计划。{switch_note}请立即按照计划开始实施。" + if comment: + message += f"\n用户批准时附带的意见:{comment}" + return _payload(success=True, status="approved", message=message, + comment=comment, plan_file=plan_file) + + if status == "rejected": + message = "用户拒绝了这份计划,你仍处于计划模式。请根据用户的意见修订计划文档后重新调用 submit_plan 提交。" + if comment: + message += f"\n用户的意见:{comment}" + else: + message += "\n用户没有填写具体意见,可在回复中询问用户需要调整的方向。" + return _payload(success=False, status="rejected", message=message, + comment=comment, plan_file=plan_file) + + return _payload(success=False, status=status or "unknown", + message="计划批准未得到用户决定(可能已超时或请求丢失),可重新调用 submit_plan 提交。", + plan_file=plan_file) + + async def execute_tool_calls(**kwargs): """_execute_tool_calls_impl 的守护包装:保证 _tool_loop_active 在任何退出路径都复位。 @@ -768,6 +913,15 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages "answer_text": answer_text, "question_id": qid, }, ensure_ascii=False) + elif function_name == "submit_plan": + tool_result = await _handle_submit_plan( + web_terminal=web_terminal, + arguments=arguments, + sender=sender, + username=username, + conversation_id=conversation_id, + tool_call_id=str(tool_call_id) if tool_call_id else None, + ) else: tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments)) diff --git a/server/conversation.py b/server/conversation.py index c08de4f5..332370f8 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -659,6 +659,17 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam default_permission_mode = (prefs or {}).get("default_permission_mode") if default_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): default_permission_mode = None + # 运行模式(work_mode):沿用 terminal 当前值(与正常创建路径一致); + # plan 档不变量 ⇒ 权限必须只读,同时记录进入前权限供离开 plan 恢复 + safe_work_mode = getattr(terminal, "get_work_mode", lambda: "plan")() + if safe_work_mode not in ("plan", "ask", "execute"): + safe_work_mode = "plan" + safe_permission_mode = default_permission_mode or getattr(terminal, "get_permission_mode", lambda: "unrestricted")() + safe_pre_plan_permission = None + if safe_work_mode == "plan": + if safe_permission_mode != "readonly": + safe_pre_plan_permission = safe_permission_mode + safe_permission_mode = "readonly" # 推理强度优先级:body 显式值 > terminal 当前档位 > 个性化默认值 if body_effort_provided: default_effort = body_effort @@ -681,8 +692,10 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam initial_messages=[], model_key=(prefs or {}).get("default_model") or getattr(terminal, "model_key", None), metadata_overrides={ - "permission_mode": default_permission_mode or getattr(terminal, "get_permission_mode", lambda: "unrestricted")(), + "permission_mode": safe_permission_mode, "execution_mode": getattr(terminal, "get_execution_mode", lambda: "sandbox")(), + "work_mode": safe_work_mode, + "pre_plan_permission_mode": safe_pre_plan_permission, "multi_agent_mode": bool(multi_agent_mode), "reasoning_effort": default_effort, }, diff --git a/server/state.py b/server/state.py index 956624ef..535ebd4a 100644 --- a/server/state.py +++ b/server/state.py @@ -17,6 +17,7 @@ from modules.user_manager import UserManager from modules.api_user_manager import ApiUserManager from modules.tool_approval_manager import ToolApprovalManager from modules.user_question_manager import UserQuestionManager +from modules.plan_approval_manager import PlanApprovalManager # 全局实例 user_manager = UserManager() @@ -38,6 +39,7 @@ stop_flags: Dict[str, Dict[str, Any]] = {} active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling tool_approval_manager = ToolApprovalManager() user_question_manager = UserQuestionManager() +plan_approval_manager = PlanApprovalManager() # 监控/限流/用量 MONITOR_FILE_TOOLS = {'write_file', 'edit_file'} @@ -107,6 +109,7 @@ __all__ = [ "active_login_nonces", "tool_approval_manager", "user_question_manager", + "plan_approval_manager", "MONITOR_SNAPSHOT_CACHE", "MONITOR_SNAPSHOT_CACHE_LIMIT", "PROJECT_STORAGE_CACHE", diff --git a/static/src/App.vue b/static/src/App.vue index 29c6726d..403f4821 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -307,6 +307,9 @@ :current-conversation-type="currentConversationType" :new-conversation-type="newConversationType" :agent-type-menu-open="agentTypeMenuOpen" + :current-work-mode="currentWorkMode" + :work-mode-menu-open="workModeMenuOpen" + :work-mode-options="workModeOptions" :main-chat-idle="mainChatIdle" :active-sub-agent-count="activeSubAgentCount" :avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null" @@ -352,6 +355,8 @@ @toggle-goal-mode="handleToggleGoalMode" @toggle-agent-type-menu="handleToggleAgentTypeMenu" @select-new-conversation-type="handleSelectNewConversationType" + @toggle-work-mode-menu="toggleWorkModeMenu" + @change-work-mode="changeWorkMode" @open-goal-dialog="goalDialogOpen = true" @open-git-changes-panel="openGitChangesPanel" /> @@ -529,6 +534,12 @@ @submit="submitUserQuestionAnswers" @dismiss="dismissUserQuestions" /> + import('../components/overlay/UserQuestionDialog.vue') ); +const PlanApprovalDialog = defineAsyncComponent( + () => import('../components/overlay/PlanApprovalDialog.vue') +); const TutorialOverlay = defineAsyncComponent( () => import('../components/overlay/TutorialOverlay.vue') ); @@ -59,6 +62,7 @@ export const appComponents = { BackgroundCommandDialog, VersioningDialog, UserQuestionDialog, + PlanApprovalDialog, TutorialOverlay, NewUserTutorialPrompt, GoalProgressDialog diff --git a/static/src/app/methods/conversation/load.ts b/static/src/app/methods/conversation/load.ts index 2f11894c..9005de17 100644 --- a/static/src/app/methods/conversation/load.ts +++ b/static/src/app/methods/conversation/load.ts @@ -244,12 +244,14 @@ export const loadMethods = { // 用户已点进完成后的“待查看”对话,清除列表里的完成标记。 this.acknowledgeCompletedWorkspaceTask?.(visibleTask.task_id); } + this.fetchWorkMode(); this.fetchPermissionMode(); this.fetchExecutionMode(); this.fetchNetworkPermission(); await this.fetchVersioningStatus(conversationId, { silent: true }); this.fetchPendingToolApprovals(); this.fetchPendingUserQuestions(); + this.fetchPendingPlanApprovals(); this.refreshProjectGitSummary(); this.fetchConversationTokenStatistics(); this.updateCurrentContextTokens(); diff --git a/static/src/app/methods/conversation/state.ts b/static/src/app/methods/conversation/state.ts index 98d81740..346ecd8b 100644 --- a/static/src/app/methods/conversation/state.ts +++ b/static/src/app/methods/conversation/state.ts @@ -87,6 +87,7 @@ export const stateMethods = { this.inputSetQuickMenuOpen(false); this.modeMenuOpen = false; this.permissionMenuOpen = false; + this.workModeMenuOpen = false; this.inputSetLineCount(1); this.inputSetMultiline(false); this.inputClearMessage(); diff --git a/static/src/app/methods/resources.ts b/static/src/app/methods/resources.ts index 754424e9..48b51594 100644 --- a/static/src/app/methods/resources.ts +++ b/static/src/app/methods/resources.ts @@ -161,6 +161,11 @@ export const resourceMethods = { if (status && typeof status.permission_mode === 'string') { this.currentPermissionMode = status.permission_mode; } + // 运行模式(work_mode):terminal 快照权威。对话加载后 load.ts 会再调 + // fetchWorkMode 以对话 metadata 为准修正一次,两者一致,不会跳变。 + if (status && typeof status.work_mode === 'string') { + this.currentWorkMode = status.work_mode; + } const pendingModes = status?.pending_runtime_modes || {}; if (typeof pendingModes.permission_mode === 'string') { this.pendingPermissionMode = pendingModes.permission_mode; diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts index 9855672e..961acf81 100644 --- a/static/src/app/methods/taskPolling/lifecycle.ts +++ b/static/src/app/methods/taskPolling/lifecycle.ts @@ -239,6 +239,12 @@ export const lifecycleMethods = { case 'user_questions_resolved': this.handleUserQuestionsResolved(eventData, eventIdx); break; + case 'plan_approval_required': + this.handlePlanApprovalRequired(eventData, eventIdx); + break; + case 'plan_approval_resolved': + this.handlePlanApprovalResolved(eventData, eventIdx); + break; case 'auto_approval_progress': this.handleAutoApprovalProgress(eventData, eventIdx); break; diff --git a/static/src/app/methods/taskPolling/tool.ts b/static/src/app/methods/taskPolling/tool.ts index 7739dc65..02f7f27a 100644 --- a/static/src/app/methods/taskPolling/tool.ts +++ b/static/src/app/methods/taskPolling/tool.ts @@ -416,6 +416,43 @@ export const toolMethods = { } this.$forceUpdate(); }, + handlePlanApprovalRequired(data: any) { + const approval = data?.approval; + if (!approval || !approval.approval_id) { + return; + } + if (!Array.isArray(this.pendingPlanApprovals)) { + this.pendingPlanApprovals = []; + } + const idx = this.pendingPlanApprovals.findIndex( + (item: any) => item && item.approval_id === approval.approval_id + ); + if (idx >= 0) { + this.pendingPlanApprovals.splice(idx, 1, approval); + } else { + this.pendingPlanApprovals.push(approval); + } + this.pendingPlanApprovals.sort( + (a: any, b: any) => Number(a?.created_at || 0) - Number(b?.created_at || 0) + ); + this.$forceUpdate(); + }, + handlePlanApprovalResolved(data: any) { + const id = String(data?.approval_id || '').trim(); + if (!id || !Array.isArray(this.pendingPlanApprovals)) { + return; + } + this.pendingPlanApprovals = this.pendingPlanApprovals.filter( + (item: any) => item && String(item.approval_id || '') !== id + ); + // 批准后后端已切换运行模式/恢复权限与执行环境,刷新显示(多标签页同步场景) + if (String(data?.decision || '') === 'approved') { + this.fetchWorkMode(); + this.fetchPermissionMode(); + this.fetchExecutionMode(); + } + this.$forceUpdate(); + }, notifyUserQuestion(question: any) { try { if (!this.userQuestionOriginalTitle && typeof document !== 'undefined') { diff --git a/static/src/app/methods/ui/dialog.ts b/static/src/app/methods/ui/dialog.ts index e55ae04b..fbc960ff 100644 --- a/static/src/app/methods/ui/dialog.ts +++ b/static/src/app/methods/ui/dialog.ts @@ -76,6 +76,75 @@ export const dialogMethods = { approveToolApproval(approvalId) { return this.decideToolApproval(approvalId, 'approved'); }, + async fetchPendingPlanApprovals() { + if (!this.currentConversationId) { + this.pendingPlanApprovals = []; + return; + } + try { + const response = await fetch( + `/api/plan-approvals/pending?conversation_id=${encodeURIComponent(this.currentConversationId)}` + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + return; + } + this.pendingPlanApprovals = Array.isArray(payload.items) ? payload.items : []; + } catch (_error) { + // ignore + } + }, + async submitPlanApproval(payload) { + const approvalId = String(payload?.approval_id || '').trim(); + if (!approvalId) { + return; + } + this.answeringPlanApprovalIds = [approvalId]; + try { + const response = await fetch( + `/api/plan-approvals/${encodeURIComponent(approvalId)}/answer`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + approved: payload?.approved === true, + comment: String(payload?.comment || '').trim() + }) + } + ); + const result = await response.json().catch(() => ({})); + if (!response.ok || !result?.success) { + throw new Error(result?.message || result?.error || '提交计划决策失败'); + } + this.pendingPlanApprovals = (this.pendingPlanApprovals || []).filter( + (item) => item && String(item.approval_id || '') !== approvalId + ); + // 批准后后端已自动切换到 execute 并恢复权限与执行环境:立即刷新模式显示 + if (payload?.approved === true) { + this.fetchWorkMode(); + this.fetchPermissionMode(); + this.fetchExecutionMode(); + this.uiPushToast({ + title: '计划已批准', + message: '已切换到执行模式,开始实施', + type: 'success', + duration: 2200 + }); + } else { + this.uiPushToast({ + title: '已拒绝计划', + message: 'AI 将根据你的意见修订后重新提交', + type: 'info', + duration: 2200 + }); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error || '提交计划决策失败'); + this.uiPushToast({ title: '提交计划决策失败', message: msg, type: 'error' }); + } finally { + this.answeringPlanApprovalIds = []; + } + }, rejectToolApproval(approvalId) { return this.decideToolApproval(approvalId, 'rejected'); }, diff --git a/static/src/app/methods/ui/index.ts b/static/src/app/methods/ui/index.ts index d466356b..46006bd3 100644 --- a/static/src/app/methods/ui/index.ts +++ b/static/src/app/methods/ui/index.ts @@ -12,6 +12,7 @@ import { tutorialMethods } from './tutorial'; import { modeMethods } from './mode'; import { modelMethods } from './model'; import { permissionMethods } from './permission'; +import { workModeMethods } from './workMode'; import { menuMethods } from './menu'; import { terminalMethods } from './terminal'; import { reviewMethods } from './review'; @@ -34,6 +35,7 @@ export const uiMethods = { ...modeMethods, ...modelMethods, ...permissionMethods, + ...workModeMethods, ...menuMethods, ...terminalMethods, ...reviewMethods, diff --git a/static/src/app/methods/ui/menu.ts b/static/src/app/methods/ui/menu.ts index 1f5502b6..42034fca 100644 --- a/static/src/app/methods/ui/menu.ts +++ b/static/src/app/methods/ui/menu.ts @@ -36,7 +36,11 @@ export const menuMethods = { if (!this.isConnected) { return; } - this.permissionMenuOpen = !this.permissionMenuOpen; + const next = !this.permissionMenuOpen; + this.permissionMenuOpen = next; + if (next) { + this.workModeMenuOpen = false; + } }, closePermissionMenu() { this.permissionMenuOpen = false; @@ -56,7 +60,7 @@ export const menuMethods = { } }, handleClickOutsideQuickMenu(event) { - if (!this.quickMenuOpen && !this.permissionMenuOpen && !this.agentTypeMenuOpen) { + if (!this.quickMenuOpen && !this.permissionMenuOpen && !this.agentTypeMenuOpen && !this.workModeMenuOpen) { return; } const shell = @@ -66,6 +70,7 @@ export const menuMethods = { } this.closeQuickMenu(); this.closePermissionMenu(); + this.closeWorkModeMenu(); }, handleClickOutsideHeaderMenu(event) { if (!this.headerMenuOpen) return; diff --git a/static/src/app/methods/ui/mode.ts b/static/src/app/methods/ui/mode.ts index 88ac25a3..36ab198f 100644 --- a/static/src/app/methods/ui/mode.ts +++ b/static/src/app/methods/ui/mode.ts @@ -56,6 +56,7 @@ export const modeMethods = { this.modeMenuOpen = false; this.modelMenuOpen = false; this.agentTypeMenuOpen = false; + this.workModeMenuOpen = false; }, handleToggleGoalMode() { if (!this.isConnected) { @@ -87,6 +88,7 @@ export const modeMethods = { this.agentTypeMenuOpen = next; if (next) { this.permissionMenuOpen = false; + this.workModeMenuOpen = false; this.modelMenuOpen = false; this.modeMenuOpen = false; this.inputCloseMenus?.(); diff --git a/static/src/app/methods/ui/socket.ts b/static/src/app/methods/ui/socket.ts index d5bdeb83..7412b6db 100644 --- a/static/src/app/methods/ui/socket.ts +++ b/static/src/app/methods/ui/socket.ts @@ -274,6 +274,7 @@ export const socketMethods = { this.projectPath = statusData.project_path || ''; this.agentVersion = statusData.version || this.agentVersion; this.applyStatusSnapshot(statusData); + this.fetchWorkMode(); this.fetchPermissionMode(); this.fetchExecutionMode(); this.fetchNetworkPermission(); diff --git a/static/src/app/methods/ui/workMode.ts b/static/src/app/methods/ui/workMode.ts new file mode 100644 index 00000000..9a2a6154 --- /dev/null +++ b/static/src/app/methods/ui/workMode.ts @@ -0,0 +1,116 @@ +// @ts-nocheck +// 运行模式(work_mode:plan/ask/execute)前端方法。 +// 与权限/执行环境/网络权限三件套同构:对话级 POST(携带 conversation_id)、 +// 空闲才可切换(运行中后端返回 409)、切换结果由后端注入系统通知。 +// 联动规则:plan 档锁定权限为只读(UI 禁用 + 后端强制); +// submit_plan 批准后后端自动切到 execute,前端经 plan_approval_resolved 刷新。 + +export const workModeMethods = { + getWorkModeLabel(mode) { + const options = Array.isArray(this.workModeOptions) ? this.workModeOptions : []; + const hit = options.find((item) => item.value === mode); + return hit ? hit.label : mode || '未知'; + }, + async fetchWorkMode() { + try { + const query = this.currentConversationId + ? `?conversation_id=${encodeURIComponent(this.currentConversationId)}` + : ''; + const response = await fetch(`/api/work-mode${query}`); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload?.success) { + return; + } + if (typeof payload.mode === 'string') { + this.currentWorkMode = payload.mode; + } + // plan 档下权限被锁定为只读、执行环境被锁定为沙箱,同步刷新显示 + if (typeof payload.permission_mode === 'string' && payload.permission_mode) { + this.currentPermissionMode = payload.permission_mode; + } + if (typeof payload.execution_mode === 'string' && payload.execution_mode) { + this.currentExecutionMode = payload.execution_mode; + } + } catch (_error) { + // ignore + } + }, + async changeWorkMode(mode) { + const target = String(mode || '') + .trim() + .toLowerCase(); + if (!target) { + this.closeWorkModeMenu(); + return; + } + try { + const response = await fetch('/api/work-mode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + mode: target, + // 对话级隔离:与权限模式一致,携带当前对话 ID; + // /new 页面无对话时回退到工作区级 terminal(新对话创建时继承)。 + ...(this.currentConversationId ? { conversation_id: this.currentConversationId } : {}) + }) + }); + const payload = await response.json().catch(() => ({})); + if (response.status === 409) { + throw new Error(payload?.message || '对话运行中,请等待当前任务结束后再切换运行模式'); + } + if (!response.ok || !payload?.success) { + throw new Error(payload?.message || payload?.error || '切换运行模式失败'); + } + if (typeof payload?.mode === 'string') { + this.currentWorkMode = payload.mode; + } + // 联动:plan 锁只读+沙箱 / 离开 plan 恢复,后端返回最新权限与执行环境 + if (typeof payload?.permission_mode === 'string' && payload.permission_mode) { + this.currentPermissionMode = payload.permission_mode; + } else { + this.fetchPermissionMode(); + } + if (typeof payload?.execution_mode === 'string' && payload.execution_mode) { + this.currentExecutionMode = payload.execution_mode; + } else { + this.fetchExecutionMode(); + } + const labelMap: Record = { plan: '计划', ask: '询问', execute: '执行' }; + this.uiPushToast({ + title: '运行模式已更新', + message: payload?.message || `已切换为 ${labelMap[this.currentWorkMode] || this.currentWorkMode}`, + type: 'info', + duration: 1800 + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error || '切换运行模式失败'); + this.uiPushToast({ + title: '切换运行模式失败', + message: msg, + type: 'error' + }); + } finally { + this.closeWorkModeMenu(); + } + }, + toggleWorkModeMenu() { + if (!this.isConnected || this.streamingMessage) { + return; + } + const next = !this.workModeMenuOpen; + this.workModeMenuOpen = next; + if (next) { + // 与其他弹出菜单互斥(仿 handleToggleAgentTypeMenu) + this.permissionMenuOpen = false; + this.modelMenuOpen = false; + this.modeMenuOpen = false; + this.agentTypeMenuOpen = false; + this.inputCloseMenus?.(); + } + }, + closeWorkModeMenu() { + this.workModeMenuOpen = false; + } +}; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index a9975ded..4c297fbd 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -198,6 +198,25 @@ export function dataState() { description: '允许所有出站和入站网络连接' } ], + workModeMenuOpen: false, + currentWorkMode: 'plan', + workModeOptions: [ + { + value: 'plan', + label: '计划', + description: '只制定计划,批准后执行' + }, + { + value: 'ask', + label: '询问', + description: '先讨论确认,再开工' + }, + { + value: 'execute', + label: '执行', + description: '自行补全细节,直接开工' + } + ], pathAuthorizationDialogOpen: false, pathAuthorizationMode: 'writable', pathAuthorizationWritableDraft: '', @@ -264,6 +283,8 @@ export function dataState() { pendingToolApprovals: [], decidingApprovalIds: [], pendingUserQuestions: [], + pendingPlanApprovals: [], + answeringPlanApprovalIds: [], userQuestionDialogVisible: false, userQuestionMinimized: false, userQuestionActiveIndex: 0, diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 98b544bf..4c8fdf36 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -351,6 +351,33 @@ +
+ +
+ +
+
-
-
执行环境
+
+
+ 执行环境计划模式锁定 +
+
+ 默认运行模式新建对话时的默认运行模式;计划模式下权限锁定为只读 +
+ +
+ +
+
+