feat(work-mode): 新增运行模式系统 plan/ask/execute 三档

- 输入栏新增运行模式切换器,仅对话空闲可切换,新对话继承当前选择
- plan 档锁定只读权限与沙箱执行环境(UI 禁用+后端强制),网络权限保持可调
- 计划写入 .astrion/plan/,submit_plan 提交后前端弹窗审批,批准自动切换 execute
- ask 档直接输出讨论并禁用 ask_user;execute 档自行补全细节直接开工
- 个性化新增默认运行模式(默认 plan),主提示词加运行模式让位注解
- 修复后台命令绕过只读权限的漏洞(宿主机按 sandbox_write_access 选只读沙箱计划)
- 计划审批与询问弹窗改用 100dvh 动态视口高度,适配移动端 Safari
This commit is contained in:
JOJO 2026-08-13 12:52:19 +08:00
parent aac374b6f4
commit ec294e2545
43 changed files with 1622 additions and 18 deletions

View File

@ -365,6 +365,30 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
- 开启后写入:`logs/approval_agent/` - 开启后写入:`logs/approval_agent/`
- 记录以累积 `messages` 为主,便于对齐主/子智能体会话格式 - 记录以累积 `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`InputComposerstreaming 时锁定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 ## 11) 多智能体对话类型multi-agent conversation type
> **2026-08 重构**:已废除「多智能体模式」全局状态,多智能体是**对话的不可变属性**`metadata.multi_agent_mode = true`创建时确定、不可变。URL 统一为 `/<conv_id>`(旧 `/multiagent/*` 由前端 bootstrap 重定向到裸路径)。数据目录 `~/.astrion/astrion/host/mutiagents/`(保留原拼写)。完整设计与踩坑记录见项目记忆 `multi_agent_mode_design`,重构方案见 `docs/conversation_type_unification_plan.md`,本节只列 Agent 改代码时必须知道的硬约束。 > **2026-08 重构**:已废除「多智能体模式」全局状态,多智能体是**对话的不可变属性**`metadata.multi_agent_mode = true`创建时确定、不可变。URL 统一为 `/<conv_id>`(旧 `/multiagent/*` 由前端 bootstrap 重定向到裸路径)。数据目录 `~/.astrion/astrion/host/mutiagents/`(保留原拼写)。完整设计与踩坑记录见项目记忆 `multi_agent_mode_design`,重构方案见 `docs/conversation_type_unification_plan.md`,本节只列 Agent 改代码时必须知道的硬约束。

View File

@ -415,6 +415,15 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
normalized = str(mode or "").strip().lower() normalized = str(mode or "").strip().lower()
if normalized not in {"sandbox", "direct"}: if normalized not in {"sandbox", "direct"}:
raise ValueError("无效执行环境,仅支持 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.host_execution_mode = normalized
self._apply_execution_mode_to_runtime() self._apply_execution_mode_to_runtime()
return self.get_execution_mode_state() return self.get_execution_mode_state()

View File

@ -193,6 +193,14 @@ class MessagesMixin:
if execution_mode_message: if execution_mode_message:
messages.append({"role": "system", "content": 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: def _build_recent_conversations_prompt() -> str:
recent_conversations_enabled = ( recent_conversations_enabled = (

View File

@ -110,19 +110,32 @@ class ModeMixin:
"direct": "完全访问权限", "direct": "完全访问权限",
} }
_WORK_MODE_LABEL = {
"plan": "计划",
"ask": "询问",
"execute": "执行",
}
_WORK_MODE_DESC = {
"plan": "当前处于计划模式:只制定计划并与用户讨论,不执行任何修改。",
"ask": "当前处于询问模式:先与用户讨论、确认细节,再开始实施。",
"execute": "当前处于执行模式:自行梳理需求与细节,直接开工完成。",
}
_NETWORK_PERMISSION_LABEL = { _NETWORK_PERMISSION_LABEL = {
"restricted": "受限", "restricted": "受限",
"full": "完全开放", "full": "完全开放",
"none": "完全禁止", "none": "完全禁止",
} }
# 运行期模式(权限/执行环境/网络权限)切换通知的 kind 与 source 映射。 # 运行期模式(权限/执行环境/网络权限/运行模式)切换通知的 kind 与 source 映射。
# source 需与 server/chat_flow_task_support.py 的 _VALID_SOURCES 保持一致。 # 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 = { _RUNTIME_MODE_SOURCE = {
"permission_mode": "permission_change", "permission_mode": "permission_change",
"execution_mode": "execution_change", "execution_mode": "execution_change",
"network_permission": "network_change", "network_permission": "network_change",
"work_mode": "work_mode_change",
} }
# conversation metadata key本对话「智能体已知晓」的运行期模式基线。 # conversation metadata key本对话「智能体已知晓」的运行期模式基线。
# 只在通知真正注入对话后更新;空闲期切换不改它,从而在下一条 user 消息时 # 只在通知真正注入对话后更新;空闲期切换不改它,从而在下一条 user 消息时
@ -176,6 +189,52 @@ class ModeMixin:
detailed_rules=detailed_rules, 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]: def _build_execution_mode_message(self) -> Optional[str]:
"""根据当前执行环境模式构建提示消息。""" """根据当前执行环境模式构建提示消息。"""
# 仅宿主机模式注入Docker 模式不需要该提示。 # 仅宿主机模式注入Docker 模式不需要该提示。
@ -356,6 +415,11 @@ class ModeMixin:
modes["network_permission"] = str(self.get_network_permission() or "") modes["network_permission"] = str(self.get_network_permission() or "")
except Exception: except Exception:
pass pass
try:
if hasattr(self, "get_work_mode"):
modes["work_mode"] = str(self.get_work_mode() or "")
except Exception:
pass
return modes return modes
def get_runtime_mode_baseline(self) -> Dict[str, str]: def get_runtime_mode_baseline(self) -> Dict[str, str]:
@ -415,6 +479,15 @@ class ModeMixin:
return self._build_execution_mode_switch_notice(mode) return self._build_execution_mode_switch_notice(mode)
label = self._EXECUTION_MODE_LABEL.get(mode, mode) label = self._EXECUTION_MODE_LABEL.get(mode, mode)
return f"执行环境被用户修改为 {label}" 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}" return f"运行模式被用户修改为 {mode}"
def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]: def _get_or_init_frozen_mode_prompt(self, key: str, builder) -> Optional[str]:

View File

@ -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"]
}
}
},
] ]

View File

@ -139,6 +139,10 @@ class ToolsDefinitionMainMixin:
} }
} }
}) })
# submit_plan 全模式注入:它是沟通/提交类工具而非危险写操作,
# 过滤会让非 plan 模式的模型不知道该能力存在(如 ask 模式下用户
# 要求「整理好计划提交给我审核」时模型无法响应)。非 plan 调用由
# _handle_submit_plan 运行时兜延返回引导,无需工具层硬限制。
# 附加自定义工具(仅管理员可见) # 附加自定义工具(仅管理员可见)
custom_tools = self._build_custom_tools() custom_tools = self._build_custom_tools()
if custom_tools: if custom_tools:

View File

@ -376,6 +376,12 @@ class MainTerminalToolsExecutionMixin:
if mode == "readonly": if mode == "readonly":
if tool_name == "run_command": if tool_name == "run_command":
return {"allowed": True, "mode": mode} 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: if tool_name not in self._READONLY_ALLOWED_TOOLS:
return { return {
"allowed": False, "allowed": False,
@ -461,6 +467,32 @@ class MainTerminalToolsExecutionMixin:
return {"allowed": True, "mode": mode} 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 @staticmethod
def _skill_meta_key(skill_id: str) -> str: def _skill_meta_key(skill_id: str) -> str:
return f"skill_read::{skill_id}" return f"skill_read::{skill_id}"
@ -1753,6 +1785,7 @@ class MainTerminalToolsExecutionMixin:
conversation_id=getattr(self.context_manager, "current_conversation_id", None), conversation_id=getattr(self.context_manager, "current_conversation_id", None),
wait_seconds=5.0, wait_seconds=5.0,
network_permission=network_permission, network_permission=network_permission,
sandbox_write_access=sandbox_write_access,
) )
else: else:
if timeout_value is None or float(timeout_value) <= 0: if timeout_value is None or float(timeout_value) <= 0:

View File

@ -86,6 +86,10 @@ from config.model_profiles import (
logger = setup_logger(__name__) logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True DISABLE_LENGTH_CHECK = True
PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"} PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"}
# 运行模式(交互方式档):计划 / 询问 / 执行。
# 与权限模式能力档正交plan 档会联动锁死权限为只读。
WORK_MODES = {"plan", "ask", "execute"}
WORK_MODE_DEFAULT = "plan"
class MainTerminalToolsPolicyMixin: class MainTerminalToolsPolicyMixin:
def _ensure_runtime_tool_overrides(self) -> Dict[str, bool]: def _ensure_runtime_tool_overrides(self) -> Dict[str, bool]:
@ -215,6 +219,13 @@ class MainTerminalToolsPolicyMixin:
self.default_permission_mode = "unrestricted" self.default_permission_mode = "unrestricted"
if not getattr(self, "current_permission_mode", None): if not getattr(self, "current_permission_mode", None):
self.current_permission_mode = self.default_permission_mode 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: def get_permission_mode(self) -> str:
mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted") mode = str(getattr(self, "current_permission_mode", "unrestricted") or "unrestricted")
@ -222,10 +233,131 @@ class MainTerminalToolsPolicyMixin:
return "unrestricted" return "unrestricted"
return mode 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: def set_permission_mode(self, mode: str, *, persist: bool = True, conversation_id: Optional[str] = None) -> str:
normalized = str(mode or "").strip().lower() normalized = str(mode or "").strip().lower()
if normalized not in PERMISSION_MODES: if normalized not in PERMISSION_MODES:
raise ValueError("无效权限模式,仅支持 readonly / approval / auto_approval / unrestricted") 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 self.current_permission_mode = normalized
if not persist: if not persist:
return normalized return normalized

View File

@ -171,6 +171,8 @@ class WebTerminal(MainTerminal):
t0 = time.perf_counter() t0 = time.perf_counter()
prefer_defaults = thinking_mode is None and run_mode is None prefer_defaults = thinking_mode is None and run_mode is None
thinking_mode_explicit = thinking_mode is not None thinking_mode_explicit = thinking_mode is not None
# 新对话以 plan 模式创建时记录的「进入前权限模式」(供离开 plan 恢复)
new_conv_pre_plan_permission = None
# 先加载个性化默认配置(用于 start_new_conversation 的 metadata # 先加载个性化默认配置(用于 start_new_conversation 的 metadata
prefs = {} prefs = {}
@ -212,10 +214,37 @@ class WebTerminal(MainTerminal):
self.set_reasoning_effort(preferred_effort) self.set_reasoning_effort(preferred_effort)
except ValueError: except ValueError:
self.set_reasoning_effort(None) 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:
current_work_mode = self.get_work_mode()
except Exception:
pass
if current_work_mode == "plan":
# 不变量plan ⇒ 权限必须只读。记录进入前权限(供离开 plan 恢复)并锁定。
# 注意不能在 plan 状态下调用非只读的 set_permission_modeplan 锁会 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: try:
self.set_permission_mode(preferred_permission_mode, persist=False) self.set_permission_mode(preferred_permission_mode, persist=False)
except Exception: except Exception:
try:
self.set_permission_mode("unrestricted", persist=False) self.set_permission_mode("unrestricted", persist=False)
except Exception:
pass
if isinstance(run_mode, str): if isinstance(run_mode, str):
try: try:
@ -239,6 +268,8 @@ class WebTerminal(MainTerminal):
metadata_overrides_merged = { metadata_overrides_merged = {
"permission_mode": self.get_permission_mode(), "permission_mode": self.get_permission_mode(),
"execution_mode": self.get_execution_mode() if hasattr(self, "get_execution_mode") else "sandbox", "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_permission_mode": None,
"pending_execution_mode": None, "pending_execution_mode": None,
# 推理强度随创建写入一次prefer_defaults 时 self 已应用个性化默认值; # 推理强度随创建写入一次prefer_defaults 时 self 已应用个性化默认值;
@ -407,18 +438,41 @@ class WebTerminal(MainTerminal):
self.set_reasoning_effort(meta.get("reasoning_effort")) self.set_reasoning_effort(meta.get("reasoning_effort"))
except ValueError: except ValueError:
self.set_reasoning_effort(None) 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() permission_mode = str(meta.get("permission_mode") or "").strip().lower()
if permission_mode: if permission_mode:
try: try:
self.set_permission_mode(permission_mode, persist=False) self.set_permission_mode(permission_mode, persist=False)
except Exception: except Exception:
pass 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() execution_mode = str(meta.get("execution_mode") or "").strip().lower()
if execution_mode in {"sandbox", "direct"}: if execution_mode in {"sandbox", "direct"}:
try: try:
self.set_execution_mode(execution_mode) self.set_execution_mode(execution_mode)
except Exception: except Exception:
pass 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() network_permission = str(meta.get("network_permission") or "").strip().lower()
if network_permission in {"restricted", "full", "none"}: if network_permission in {"restricted", "full", "none"}:
try: try:
@ -565,6 +619,7 @@ class WebTerminal(MainTerminal):
"reasoning_effort": getattr(self, "reasoning_effort", None), "reasoning_effort": getattr(self, "reasoning_effort", None),
"model_key": getattr(self, "model_key", None), "model_key": getattr(self, "model_key", None),
"permission_mode": self.get_permission_mode() if hasattr(self, "get_permission_mode") else "unrestricted", "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"}, "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", "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 {}, "pending_runtime_modes": self.get_pending_runtime_modes() if hasattr(self, "get_pending_runtime_modes") else {},

View File

@ -16,6 +16,7 @@ from config import MAX_RUN_COMMAND_CHARS
from modules.host_sandbox_runner import ( from modules.host_sandbox_runner import (
HostSandboxError, HostSandboxError,
build_host_sandbox_plan, build_host_sandbox_plan,
build_host_sandbox_readonly_plan,
host_sandbox_enabled, host_sandbox_enabled,
) )
@ -42,6 +43,7 @@ class BackgroundCommandManager:
conversation_id: Optional[str], conversation_id: Optional[str],
wait_seconds: float = 5.0, wait_seconds: float = 5.0,
network_permission: Optional[str] = None, network_permission: Optional[str] = None,
sandbox_write_access: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""启动后台命令;先等待一小段时间返回已有输出。""" """启动后台命令;先等待一小段时间返回已有输出。"""
if timeout is None or float(timeout) <= 0: if timeout is None or float(timeout) <= 0:
@ -118,6 +120,9 @@ class BackgroundCommandManager:
# 网络权限必须按调用方(对话级 terminal快照传入不能读进程级 # 网络权限必须按调用方(对话级 terminal快照传入不能读进程级
# 环境变量——env 会被其他 terminal 实例覆盖,导致权限不生效或跨工作区串扰。 # 环境变量——env 会被其他 terminal 实例覆盖,导致权限不生效或跨工作区串扰。
"network_permission": network_permission, "network_permission": network_permission,
# 写权限同理:只读模式下后台命令必须使用只读沙箱计划,
# 否则宿主机只读会被后台路径绕过(此前固定用可写计划)。
"sandbox_write_access": bool(sandbox_write_access),
}, },
name=f"bg-run-command-{command_id}", name=f"bg-run-command-{command_id}",
daemon=True, daemon=True,
@ -186,6 +191,7 @@ class BackgroundCommandManager:
python_env: Dict[str, str], python_env: Dict[str, str],
host_execution_mode: str = "sandbox", host_execution_mode: str = "sandbox",
network_permission: Optional[str] = None, network_permission: Optional[str] = None,
sandbox_write_access: bool = True,
) -> None: ) -> None:
start_ts = time.time() start_ts = time.time()
process: Optional[subprocess.Popen] = None process: Optional[subprocess.Popen] = None
@ -241,7 +247,10 @@ class BackgroundCommandManager:
if network_permission if network_permission
else os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted") else os.environ.get("HOST_SANDBOX_NETWORK_PERMISSION", "restricted")
) )
if sandbox_write_access:
plan = build_host_sandbox_plan(command, work_path, env, network_permission=effective_network_permission) 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 cmd_args = plan.command
pass_fds = () pass_fds = ()
seccomp_fd = None seccomp_fd = None

View File

@ -17,6 +17,7 @@ from config.model_profiles import get_default_model_key, get_registered_model_ke
ALLOWED_RUN_MODES = {"fast", "thinking"} ALLOWED_RUN_MODES = {"fast", "thinking"}
ALLOWED_PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"} ALLOWED_PERMISSION_MODES = {"readonly", "approval", "auto_approval", "unrestricted"}
ALLOWED_WORK_MODES = {"plan", "ask", "execute"}
ALLOWED_THEMES = {"classic", "light", "dark"} ALLOWED_THEMES = {"classic", "light", "dark"}
ALLOWED_COMMUNICATION_STYLES = {"default", "human_like", "auto"} ALLOWED_COMMUNICATION_STYLES = {"default", "human_like", "auto"}
ALLOWED_CONVERSATION_CONTINUITY = {"low", "medium", "high"} ALLOWED_CONVERSATION_CONTINUITY = {"low", "medium", "high"}
@ -73,6 +74,7 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"default_run_mode": "thinking", "default_run_mode": "thinking",
"default_reasoning_effort": None, # 默认推理强度None=默认(不传参) "default_reasoning_effort": None, # 默认推理强度None=默认(不传参)
"default_permission_mode": "unrestricted", "default_permission_mode": "unrestricted",
"default_work_mode": "plan", # 默认运行模式plan / ask / execute
"auto_generate_title": True, "auto_generate_title": True,
"recent_conversations_prompt_enabled": False, "recent_conversations_prompt_enabled": False,
"recent_conversations_prompt_limit": RECENT_CONVERSATIONS_PROMPT_LIMIT_DEFAULT, "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: elif base.get("default_permission_mode") not in ALLOWED_PERMISSION_MODES:
base["default_permission_mode"] = "unrestricted" 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" base["versioning_restore_mode"] = "overwrite"
# 默认模型 # 默认模型

View File

@ -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)

View File

@ -32,6 +32,8 @@
## 2. 行为方法:先规划,后执行,再验证 ## 2. 行为方法:先规划,后执行,再验证
> 本节描述的是默认交互节奏基线。若系统提示词中包含「运行模式」(计划/询问/执行)说明,交互节奏以运行模式说明为准(例如执行模式下不要求逐步征求同意)。
1. **理解需求**:复述确认,澄清疑问,识别约束 1. **理解需求**:复述确认,澄清疑问,识别约束
2. **制定计划**:拆分任务,创建待办,预估风险,征求同意 2. **制定计划**:拆分任务,创建待办,预估风险,征求同意
3. **执行操作**:小步快跑,边做边说,保存进度 3. **执行操作**:小步快跑,边做边说,保存进度
@ -155,6 +157,8 @@
**绝对禁止**:擅自行动、猜测需求、越权操作、编造信息、暴露敏感信息 **绝对禁止**:擅自行动、猜测需求、越权操作、编造信息、暴露敏感信息
> 「擅自行动、猜测需求」的判定以当前运行模式为准:执行模式下主动补全用户未提及的细节并直接开工是预期行为,不属于擅自行动。
**谨慎处理**:删除操作二次确认、对外请求确认意图、避免长时间占用资源、写入前检查覆盖 **谨慎处理**:删除操作二次确认、对外请求确认意图、避免长时间占用资源、写入前检查覆盖
**终端禁忌**:禁止交互式程序、禁止命令未完成时继续输入、禁止凭感觉判断状态 **终端禁忌**:禁止交互式程序、禁止命令未完成时继续输入、禁止凭感觉判断状态
@ -263,6 +267,8 @@
5. **正确执行**:主动确认细节,获得明确许可后再开始 5. **正确执行**:主动确认细节,获得明确许可后再开始
6. **沟通原则**:使用用户使用的语言进行思考和交流 6. **沟通原则**:使用用户使用的语言进行思考和交流
> 第 2、5 条为默认基线;运行模式说明(计划/询问/执行)对交互节奏有专门约定时,以运行模式说明为准。
## 8. 个性化配置 ## 8. 个性化配置
当用户的个性化信息与上文冲突时,以用户的个性化信息为准。 当用户的个性化信息与上文冲突时,以用户的个性化信息为准。

View File

@ -32,6 +32,8 @@
## 2. 行为方法:先规划,后执行,再验证 ## 2. 行为方法:先规划,后执行,再验证
> 本节描述的是默认交互节奏基线。若系统提示词中包含「运行模式」(计划/询问/执行)说明,交互节奏以运行模式说明为准(例如执行模式下不要求逐步征求同意)。
1. **理解需求**:复述确认,澄清疑问,识别约束 1. **理解需求**:复述确认,澄清疑问,识别约束
2. **制定计划**:拆分任务,创建待办,预估风险,征求同意 2. **制定计划**:拆分任务,创建待办,预估风险,征求同意
3. **执行操作**:小步快跑,边做边说,保存进度 3. **执行操作**:小步快跑,边做边说,保存进度
@ -172,6 +174,8 @@
**绝对禁止**:擅自行动、猜测需求、越权操作、编造信息、暴露敏感信息 **绝对禁止**:擅自行动、猜测需求、越权操作、编造信息、暴露敏感信息
> 「擅自行动、猜测需求」的判定以当前运行模式为准:执行模式下主动补全用户未提及的细节并直接开工是预期行为,不属于擅自行动。
**谨慎处理**:删除操作二次确认、对外请求确认意图、避免长时间占用资源、写入前检查覆盖 **谨慎处理**:删除操作二次确认、对外请求确认意图、避免长时间占用资源、写入前检查覆盖
**终端禁忌**:禁止交互式程序、禁止命令未完成时继续输入、禁止凭感觉判断状态 **终端禁忌**:禁止交互式程序、禁止命令未完成时继续输入、禁止凭感觉判断状态
@ -280,6 +284,8 @@
5. **正确执行**:主动确认细节,获得明确许可后再开始 5. **正确执行**:主动确认细节,获得明确许可后再开始
6. **沟通原则**:使用用户使用的语言进行思考和交流 6. **沟通原则**:使用用户使用的语言进行思考和交流
> 第 2、5 条为默认基线;运行模式说明(计划/询问/执行)对交互节奏有专门约定时,以运行模式说明为准。
## 8. 个性化配置 ## 8. 个性化配置
当用户的个性化信息与上文冲突时,以用户的个性化信息为准。 当用户的个性化信息与上文冲突时,以用户的个性化信息为准。

8
prompts/work_mode.txt Normal file
View File

@ -0,0 +1,8 @@
## 运行模式:{work_mode_label}{work_mode}
{mode_description}
### 当前规则
{detailed_rules}
本节描述的是当前对话的交互节奏(何时讨论、何时开工、何时提问),优先级高于主提示词中「行为方法」「禁止做的事情」「核心原则」等章节里关于提问与确认的默认描述;运行模式被用户切换时,你会收到新的运行模式说明,永远以最新的一份为准。

View File

@ -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.security import rate_limited, prune_socket_tokens
from server.utils_common import debug_log 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 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.extensions import socketio
from server.monitor import get_cached_monitor_snapshot from server.monitor import get_cached_monitor_snapshot
@ -87,6 +87,49 @@ def answer_user_question(terminal: WebTerminal, workspace: UserWorkspace, userna
"item": item, "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/<approval_id>/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']) @chat_bp.route('/api/tool-approvals/pending', methods=['GET'])
@api_login_required @api_login_required
@with_terminal @with_terminal

View File

@ -5,6 +5,7 @@ import json, time
PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"] PERMISSION_MODE_OPTIONS = ["readonly", "approval", "auto_approval", "unrestricted"]
EXECUTION_MODE_OPTIONS = ["sandbox", "direct"] EXECUTION_MODE_OPTIONS = ["sandbox", "direct"]
NETWORK_PERMISSION_OPTIONS = ["restricted", "full", "none"] NETWORK_PERMISSION_OPTIONS = ["restricted", "full", "none"]
WORK_MODE_OPTIONS = ["plan", "ask", "execute"]
from datetime import datetime from datetime import datetime
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from pathlib import Path 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) ws_terminal.set_execution_mode(mode)
elif kind == "network_permission": elif kind == "network_permission":
ws_terminal.set_network_permission(mode) ws_terminal.set_network_permission(mode)
elif kind == "work_mode":
# 必须走 switch_work_modepersist=False 仅内存态):裸 set 会破坏
# 「plan ⇒ 权限只读」不变量(切 plan 不锁只读 / 离 plan 不恢复权限)。
# persist=False 避免误写工作区 terminal 上可能残留的陈旧对话 metadata。
ws_terminal.switch_work_mode(mode, persist=False)
except Exception: except Exception:
pass pass
@ -391,6 +397,89 @@ def update_network_permission(terminal: WebTerminal, workspace: UserWorkspace, u
"message": "网络权限已更新并立即生效", "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']) @chat_bp.route('/api/path-authorization', methods=['GET'])
@api_login_required @api_login_required
@with_terminal @with_terminal

View File

@ -27,6 +27,7 @@ _VALID_SOURCES = {
"permission_change", "permission_change",
"execution_change", "execution_change",
"network_change", "network_change",
"work_mode_change",
"file_paths", "file_paths",
} }
@ -38,8 +39,8 @@ def _runtime_message_ui_defaults(src: str, *, inline: bool = False) -> Dict[str,
return {"visibility": "hidden", "starts_work": False} return {"visibility": "hidden", "starts_work": False}
if normalized in {"guidance", "notify"}: if normalized in {"guidance", "notify"}:
return {"visibility": "compact", "starts_work": False} 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} return {"visibility": "compact", "starts_work": False}
# 后台完成通知 / 运行期压缩续接:属于当前这轮工作的延续,不开启新的工作头与计时器, # 后台完成通知 / 运行期压缩续接:属于当前这轮工作的延续,不开启新的工作头与计时器,
# 也不暂停上一轮计时器。与 task_main 的 _user_message_ui_defaults 保持一致, # 也不暂停上一轮计时器。与 task_main 的 _user_message_ui_defaults 保持一致,

View File

@ -9,7 +9,7 @@ from typing import Optional, Dict, Any, List
from .utils_common import debug_log 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 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 .monitor import cache_monitor_snapshot
from .security import compact_web_search_result from .security import compact_web_search_result
from .chat_flow_helpers import detect_tool_failure 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 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): async def execute_tool_calls(**kwargs):
"""_execute_tool_calls_impl 的守护包装:保证 _tool_loop_active 在任何退出路径都复位。 """_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, "answer_text": answer_text,
"question_id": qid, "question_id": qid,
}, ensure_ascii=False) }, 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: else:
tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments)) tool_task = asyncio.create_task(web_terminal.handle_tool_call(function_name, arguments))

View File

@ -659,6 +659,17 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
default_permission_mode = (prefs or {}).get("default_permission_mode") default_permission_mode = (prefs or {}).get("default_permission_mode")
if default_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"): if default_permission_mode not in ("readonly", "approval", "auto_approval", "unrestricted"):
default_permission_mode = None 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 当前档位 > 个性化默认值 # 推理强度优先级body 显式值 > terminal 当前档位 > 个性化默认值
if body_effort_provided: if body_effort_provided:
default_effort = body_effort default_effort = body_effort
@ -681,8 +692,10 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
initial_messages=[], initial_messages=[],
model_key=(prefs or {}).get("default_model") or getattr(terminal, "model_key", None), model_key=(prefs or {}).get("default_model") or getattr(terminal, "model_key", None),
metadata_overrides={ 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")(), "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), "multi_agent_mode": bool(multi_agent_mode),
"reasoning_effort": default_effort, "reasoning_effort": default_effort,
}, },

View File

@ -17,6 +17,7 @@ from modules.user_manager import UserManager
from modules.api_user_manager import ApiUserManager from modules.api_user_manager import ApiUserManager
from modules.tool_approval_manager import ToolApprovalManager from modules.tool_approval_manager import ToolApprovalManager
from modules.user_question_manager import UserQuestionManager from modules.user_question_manager import UserQuestionManager
from modules.plan_approval_manager import PlanApprovalManager
# 全局实例 # 全局实例
user_manager = UserManager() user_manager = UserManager()
@ -38,6 +39,7 @@ stop_flags: Dict[str, Dict[str, Any]] = {}
active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling active_polling_tasks: Dict[str, bool] = {} # conversation_id -> is_polling
tool_approval_manager = ToolApprovalManager() tool_approval_manager = ToolApprovalManager()
user_question_manager = UserQuestionManager() user_question_manager = UserQuestionManager()
plan_approval_manager = PlanApprovalManager()
# 监控/限流/用量 # 监控/限流/用量
MONITOR_FILE_TOOLS = {'write_file', 'edit_file'} MONITOR_FILE_TOOLS = {'write_file', 'edit_file'}
@ -107,6 +109,7 @@ __all__ = [
"active_login_nonces", "active_login_nonces",
"tool_approval_manager", "tool_approval_manager",
"user_question_manager", "user_question_manager",
"plan_approval_manager",
"MONITOR_SNAPSHOT_CACHE", "MONITOR_SNAPSHOT_CACHE",
"MONITOR_SNAPSHOT_CACHE_LIMIT", "MONITOR_SNAPSHOT_CACHE_LIMIT",
"PROJECT_STORAGE_CACHE", "PROJECT_STORAGE_CACHE",

View File

@ -307,6 +307,9 @@
:current-conversation-type="currentConversationType" :current-conversation-type="currentConversationType"
:new-conversation-type="newConversationType" :new-conversation-type="newConversationType"
:agent-type-menu-open="agentTypeMenuOpen" :agent-type-menu-open="agentTypeMenuOpen"
:current-work-mode="currentWorkMode"
:work-mode-menu-open="workModeMenuOpen"
:work-mode-options="workModeOptions"
:main-chat-idle="mainChatIdle" :main-chat-idle="mainChatIdle"
:active-sub-agent-count="activeSubAgentCount" :active-sub-agent-count="activeSubAgentCount"
:avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null" :avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null"
@ -352,6 +355,8 @@
@toggle-goal-mode="handleToggleGoalMode" @toggle-goal-mode="handleToggleGoalMode"
@toggle-agent-type-menu="handleToggleAgentTypeMenu" @toggle-agent-type-menu="handleToggleAgentTypeMenu"
@select-new-conversation-type="handleSelectNewConversationType" @select-new-conversation-type="handleSelectNewConversationType"
@toggle-work-mode-menu="toggleWorkModeMenu"
@change-work-mode="changeWorkMode"
@open-goal-dialog="goalDialogOpen = true" @open-goal-dialog="goalDialogOpen = true"
@open-git-changes-panel="openGitChangesPanel" @open-git-changes-panel="openGitChangesPanel"
/> />
@ -529,6 +534,12 @@
@submit="submitUserQuestionAnswers" @submit="submitUserQuestionAnswers"
@dismiss="dismissUserQuestions" @dismiss="dismissUserQuestions"
/> />
<PlanApprovalDialog
:visible="pendingPlanApprovals.length > 0"
:approvals="pendingPlanApprovals"
:submitting-ids="answeringPlanApprovalIds"
@submit="submitPlanApproval"
/>
<transition name="overlay-fade"> <transition name="overlay-fade">
<VersioningDialog <VersioningDialog
v-if="versioningDialogOpen" v-if="versioningDialogOpen"

View File

@ -30,6 +30,9 @@ const VersioningDialog = defineAsyncComponent(
const UserQuestionDialog = defineAsyncComponent( const UserQuestionDialog = defineAsyncComponent(
() => import('../components/overlay/UserQuestionDialog.vue') () => import('../components/overlay/UserQuestionDialog.vue')
); );
const PlanApprovalDialog = defineAsyncComponent(
() => import('../components/overlay/PlanApprovalDialog.vue')
);
const TutorialOverlay = defineAsyncComponent( const TutorialOverlay = defineAsyncComponent(
() => import('../components/overlay/TutorialOverlay.vue') () => import('../components/overlay/TutorialOverlay.vue')
); );
@ -59,6 +62,7 @@ export const appComponents = {
BackgroundCommandDialog, BackgroundCommandDialog,
VersioningDialog, VersioningDialog,
UserQuestionDialog, UserQuestionDialog,
PlanApprovalDialog,
TutorialOverlay, TutorialOverlay,
NewUserTutorialPrompt, NewUserTutorialPrompt,
GoalProgressDialog GoalProgressDialog

View File

@ -244,12 +244,14 @@ export const loadMethods = {
// 用户已点进完成后的“待查看”对话,清除列表里的完成标记。 // 用户已点进完成后的“待查看”对话,清除列表里的完成标记。
this.acknowledgeCompletedWorkspaceTask?.(visibleTask.task_id); this.acknowledgeCompletedWorkspaceTask?.(visibleTask.task_id);
} }
this.fetchWorkMode();
this.fetchPermissionMode(); this.fetchPermissionMode();
this.fetchExecutionMode(); this.fetchExecutionMode();
this.fetchNetworkPermission(); this.fetchNetworkPermission();
await this.fetchVersioningStatus(conversationId, { silent: true }); await this.fetchVersioningStatus(conversationId, { silent: true });
this.fetchPendingToolApprovals(); this.fetchPendingToolApprovals();
this.fetchPendingUserQuestions(); this.fetchPendingUserQuestions();
this.fetchPendingPlanApprovals();
this.refreshProjectGitSummary(); this.refreshProjectGitSummary();
this.fetchConversationTokenStatistics(); this.fetchConversationTokenStatistics();
this.updateCurrentContextTokens(); this.updateCurrentContextTokens();

View File

@ -87,6 +87,7 @@ export const stateMethods = {
this.inputSetQuickMenuOpen(false); this.inputSetQuickMenuOpen(false);
this.modeMenuOpen = false; this.modeMenuOpen = false;
this.permissionMenuOpen = false; this.permissionMenuOpen = false;
this.workModeMenuOpen = false;
this.inputSetLineCount(1); this.inputSetLineCount(1);
this.inputSetMultiline(false); this.inputSetMultiline(false);
this.inputClearMessage(); this.inputClearMessage();

View File

@ -161,6 +161,11 @@ export const resourceMethods = {
if (status && typeof status.permission_mode === 'string') { if (status && typeof status.permission_mode === 'string') {
this.currentPermissionMode = status.permission_mode; this.currentPermissionMode = status.permission_mode;
} }
// 运行模式work_modeterminal 快照权威。对话加载后 load.ts 会再调
// fetchWorkMode 以对话 metadata 为准修正一次,两者一致,不会跳变。
if (status && typeof status.work_mode === 'string') {
this.currentWorkMode = status.work_mode;
}
const pendingModes = status?.pending_runtime_modes || {}; const pendingModes = status?.pending_runtime_modes || {};
if (typeof pendingModes.permission_mode === 'string') { if (typeof pendingModes.permission_mode === 'string') {
this.pendingPermissionMode = pendingModes.permission_mode; this.pendingPermissionMode = pendingModes.permission_mode;

View File

@ -239,6 +239,12 @@ export const lifecycleMethods = {
case 'user_questions_resolved': case 'user_questions_resolved':
this.handleUserQuestionsResolved(eventData, eventIdx); this.handleUserQuestionsResolved(eventData, eventIdx);
break; break;
case 'plan_approval_required':
this.handlePlanApprovalRequired(eventData, eventIdx);
break;
case 'plan_approval_resolved':
this.handlePlanApprovalResolved(eventData, eventIdx);
break;
case 'auto_approval_progress': case 'auto_approval_progress':
this.handleAutoApprovalProgress(eventData, eventIdx); this.handleAutoApprovalProgress(eventData, eventIdx);
break; break;

View File

@ -416,6 +416,43 @@ export const toolMethods = {
} }
this.$forceUpdate(); 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) { notifyUserQuestion(question: any) {
try { try {
if (!this.userQuestionOriginalTitle && typeof document !== 'undefined') { if (!this.userQuestionOriginalTitle && typeof document !== 'undefined') {

View File

@ -76,6 +76,75 @@ export const dialogMethods = {
approveToolApproval(approvalId) { approveToolApproval(approvalId) {
return this.decideToolApproval(approvalId, 'approved'); 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) { rejectToolApproval(approvalId) {
return this.decideToolApproval(approvalId, 'rejected'); return this.decideToolApproval(approvalId, 'rejected');
}, },

View File

@ -12,6 +12,7 @@ import { tutorialMethods } from './tutorial';
import { modeMethods } from './mode'; import { modeMethods } from './mode';
import { modelMethods } from './model'; import { modelMethods } from './model';
import { permissionMethods } from './permission'; import { permissionMethods } from './permission';
import { workModeMethods } from './workMode';
import { menuMethods } from './menu'; import { menuMethods } from './menu';
import { terminalMethods } from './terminal'; import { terminalMethods } from './terminal';
import { reviewMethods } from './review'; import { reviewMethods } from './review';
@ -34,6 +35,7 @@ export const uiMethods = {
...modeMethods, ...modeMethods,
...modelMethods, ...modelMethods,
...permissionMethods, ...permissionMethods,
...workModeMethods,
...menuMethods, ...menuMethods,
...terminalMethods, ...terminalMethods,
...reviewMethods, ...reviewMethods,

View File

@ -36,7 +36,11 @@ export const menuMethods = {
if (!this.isConnected) { if (!this.isConnected) {
return; return;
} }
this.permissionMenuOpen = !this.permissionMenuOpen; const next = !this.permissionMenuOpen;
this.permissionMenuOpen = next;
if (next) {
this.workModeMenuOpen = false;
}
}, },
closePermissionMenu() { closePermissionMenu() {
this.permissionMenuOpen = false; this.permissionMenuOpen = false;
@ -56,7 +60,7 @@ export const menuMethods = {
} }
}, },
handleClickOutsideQuickMenu(event) { handleClickOutsideQuickMenu(event) {
if (!this.quickMenuOpen && !this.permissionMenuOpen && !this.agentTypeMenuOpen) { if (!this.quickMenuOpen && !this.permissionMenuOpen && !this.agentTypeMenuOpen && !this.workModeMenuOpen) {
return; return;
} }
const shell = const shell =
@ -66,6 +70,7 @@ export const menuMethods = {
} }
this.closeQuickMenu(); this.closeQuickMenu();
this.closePermissionMenu(); this.closePermissionMenu();
this.closeWorkModeMenu();
}, },
handleClickOutsideHeaderMenu(event) { handleClickOutsideHeaderMenu(event) {
if (!this.headerMenuOpen) return; if (!this.headerMenuOpen) return;

View File

@ -56,6 +56,7 @@ export const modeMethods = {
this.modeMenuOpen = false; this.modeMenuOpen = false;
this.modelMenuOpen = false; this.modelMenuOpen = false;
this.agentTypeMenuOpen = false; this.agentTypeMenuOpen = false;
this.workModeMenuOpen = false;
}, },
handleToggleGoalMode() { handleToggleGoalMode() {
if (!this.isConnected) { if (!this.isConnected) {
@ -87,6 +88,7 @@ export const modeMethods = {
this.agentTypeMenuOpen = next; this.agentTypeMenuOpen = next;
if (next) { if (next) {
this.permissionMenuOpen = false; this.permissionMenuOpen = false;
this.workModeMenuOpen = false;
this.modelMenuOpen = false; this.modelMenuOpen = false;
this.modeMenuOpen = false; this.modeMenuOpen = false;
this.inputCloseMenus?.(); this.inputCloseMenus?.();

View File

@ -274,6 +274,7 @@ export const socketMethods = {
this.projectPath = statusData.project_path || ''; this.projectPath = statusData.project_path || '';
this.agentVersion = statusData.version || this.agentVersion; this.agentVersion = statusData.version || this.agentVersion;
this.applyStatusSnapshot(statusData); this.applyStatusSnapshot(statusData);
this.fetchWorkMode();
this.fetchPermissionMode(); this.fetchPermissionMode();
this.fetchExecutionMode(); this.fetchExecutionMode();
this.fetchNetworkPermission(); this.fetchNetworkPermission();

View File

@ -0,0 +1,116 @@
// @ts-nocheck
// 运行模式work_modeplan/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<string, string> = { 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;
}
};

View File

@ -198,6 +198,25 @@ export function dataState() {
description: '允许所有出站和入站网络连接' description: '允许所有出站和入站网络连接'
} }
], ],
workModeMenuOpen: false,
currentWorkMode: 'plan',
workModeOptions: [
{
value: 'plan',
label: '计划',
description: '只制定计划,批准后执行'
},
{
value: 'ask',
label: '询问',
description: '先讨论确认,再开工'
},
{
value: 'execute',
label: '执行',
description: '自行补全细节,直接开工'
}
],
pathAuthorizationDialogOpen: false, pathAuthorizationDialogOpen: false,
pathAuthorizationMode: 'writable', pathAuthorizationMode: 'writable',
pathAuthorizationWritableDraft: '', pathAuthorizationWritableDraft: '',
@ -264,6 +283,8 @@ export function dataState() {
pendingToolApprovals: [], pendingToolApprovals: [],
decidingApprovalIds: [], decidingApprovalIds: [],
pendingUserQuestions: [], pendingUserQuestions: [],
pendingPlanApprovals: [],
answeringPlanApprovalIds: [],
userQuestionDialogVisible: false, userQuestionDialogVisible: false,
userQuestionMinimized: false, userQuestionMinimized: false,
userQuestionActiveIndex: 0, userQuestionActiveIndex: 0,

View File

@ -351,6 +351,33 @@
</button> </button>
</div> </div>
</div> </div>
<div class="agent-type-switcher work-mode-switcher" :class="{ 'is-disabled': workModeLocked }">
<button
type="button"
class="agent-type-switcher__btn"
@click.stop="$emit('toggle-work-mode-menu')"
:disabled="!isConnected || workModeLocked"
:aria-expanded="workModeMenuOpen"
aria-haspopup="true"
:title="workModeLocked ? '对话运行中,运行模式只能在空闲时切换' : '运行模式'"
>
<span>{{ workModeLabel }}</span>
<span class="agent-type-switcher__caret" :class="{ open: workModeMenuOpen }" aria-hidden="true"></span>
</button>
<div v-if="workModeMenuOpen && !workModeLocked" class="agent-type-switcher__menu" @click.stop>
<button
v-for="option in workModeOptions"
:key="option.value"
type="button"
class="agent-type-switcher__menu-item"
:class="{ 'is-active': option.value === currentWorkMode }"
@click.stop="$emit('change-work-mode', option.value)"
>
<span class="agent-type-switcher__menu-label">{{ option.label }}</span>
<span class="agent-type-switcher__menu-desc">{{ option.description }}</span>
</button>
</div>
</div>
</div> </div>
<div class="input-actions-right"> <div class="input-actions-right">
<button <button
@ -467,8 +494,26 @@
type="button" type="button"
class="permission-switcher__btn" class="permission-switcher__btn"
:disabled="!isConnected" :disabled="!isConnected"
:title="permissionLockedByPlan ? '计划模式下权限与执行环境已锁定,网络权限仍可调整' : ''"
@click="$emit('toggle-permission-menu')" @click="$emit('toggle-permission-menu')"
> >
<svg
v-if="permissionLockedByPlan"
class="permission-switcher__lock"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>{{ currentPermissionLabel }}</span> <span>{{ currentPermissionLabel }}</span>
<span <span
v-if="executionModeEnabled" v-if="executionModeEnabled"
@ -483,28 +528,41 @@
class="permission-switcher__menu" class="permission-switcher__menu"
:class="{ 'permission-switcher__menu--split': executionModeEnabled }" :class="{ 'permission-switcher__menu--split': executionModeEnabled }"
> >
<div class="permission-switcher__group"> <div
<div class="permission-switcher__group-title">权限</div> class="permission-switcher__group"
:class="{ 'permission-switcher__group--disabled': permissionLockedByPlan }"
>
<div class="permission-switcher__group-title">
权限<span v-if="permissionLockedByPlan" class="permission-switcher__group-lock">计划模式锁定</span>
</div>
<button <button
v-for="option in permissionOptions" v-for="option in permissionOptions"
:key="`permission-${option.value}`" :key="`permission-${option.value}`"
type="button" type="button"
class="permission-switcher__item" class="permission-switcher__item"
:class="{ active: option.value === currentPermissionMode }" :class="{ active: option.value === currentPermissionMode }"
:disabled="permissionLockedByPlan"
@click="$emit('change-permission-mode', option.value)" @click="$emit('change-permission-mode', option.value)"
> >
<span class="permission-switcher__item-label">{{ option.label }}</span> <span class="permission-switcher__item-label">{{ option.label }}</span>
<span class="permission-switcher__item-desc">{{ option.description }}</span> <span class="permission-switcher__item-desc">{{ option.description }}</span>
</button> </button>
</div> </div>
<div v-if="executionModeEnabled" class="permission-switcher__group"> <div
<div class="permission-switcher__group-title">执行环境</div> v-if="executionModeEnabled"
class="permission-switcher__group"
:class="{ 'permission-switcher__group--disabled': permissionLockedByPlan }"
>
<div class="permission-switcher__group-title">
执行环境<span v-if="permissionLockedByPlan" class="permission-switcher__group-lock">计划模式锁定</span>
</div>
<button <button
v-for="option in executionModeOptions" v-for="option in executionModeOptions"
:key="`execution-${option.value}`" :key="`execution-${option.value}`"
type="button" type="button"
class="permission-switcher__item" class="permission-switcher__item"
:class="{ active: option.value === currentExecutionMode }" :class="{ active: option.value === currentExecutionMode }"
:disabled="permissionLockedByPlan"
@click="$emit('change-execution-mode', option.value)" @click="$emit('change-execution-mode', option.value)"
> >
<span <span
@ -639,6 +697,8 @@ const emit = defineEmits([
'open-git-changes-panel', 'open-git-changes-panel',
'toggle-agent-type-menu', 'toggle-agent-type-menu',
'select-new-conversation-type', 'select-new-conversation-type',
'toggle-work-mode-menu',
'change-work-mode',
'stop-all-sub-agents' 'stop-all-sub-agents'
]); ]);
@ -715,6 +775,12 @@ const props = defineProps<{
newConversationType?: 'agent' | 'multi_agent'; newConversationType?: 'agent' | 'multi_agent';
/** 类型选择器菜单是否展开 */ /** 类型选择器菜单是否展开 */
agentTypeMenuOpen?: boolean; agentTypeMenuOpen?: boolean;
/** 当前运行模式plan/ask/execute */
currentWorkMode?: 'plan' | 'ask' | 'execute';
/** 运行模式菜单是否展开 */
workModeMenuOpen?: boolean;
/** 运行模式选项 */
workModeOptions?: Array<{ value: string; label: string; description: string }>;
/** /**
* 主对话是否空闲composerBusy 可能是 true但只是因为后台子智能体在跑 * 主对话是否空闲composerBusy 可能是 true但只是因为后台子智能体在跑
* 多智能体模式下若主对话空闲输入栏应处于正常发送状态不是停止状态 * 多智能体模式下若主对话空闲输入栏应处于正常发送状态不是停止状态
@ -1565,10 +1631,14 @@ const themeSlashMenuItems = computed<SlashMenuItem[]>(() => {
const permissionSlashMenuItems = computed<SlashMenuItem[]>(() => { const permissionSlashMenuItems = computed<SlashMenuItem[]>(() => {
const options = props.permissionOptions || []; const options = props.permissionOptions || [];
const current = String(props.currentPermissionMode || ''); const current = String(props.currentPermissionMode || '');
const lockedByPlan = props.currentWorkMode === 'plan';
return options.map((opt) => ({ return options.map((opt) => ({
id: `perm:${opt.value}`, id: `perm:${opt.value}`,
label: opt.label, label: opt.label,
description: `${opt.value === current ? '当前 · ' : ''}${opt.description || ''}`, description: lockedByPlan
? '计划模式下权限锁定为只读'
: `${opt.value === current ? '当前 · ' : ''}${opt.description || ''}`,
disabled: lockedByPlan,
action: () => emit('change-permission-mode', opt.value) action: () => emit('change-permission-mode', opt.value)
})); }));
}); });
@ -1576,10 +1646,14 @@ const permissionSlashMenuItems = computed<SlashMenuItem[]>(() => {
const executionSlashMenuItems = computed<SlashMenuItem[]>(() => { const executionSlashMenuItems = computed<SlashMenuItem[]>(() => {
const options = props.executionModeOptions || []; const options = props.executionModeOptions || [];
const current = String(props.currentExecutionMode || ''); const current = String(props.currentExecutionMode || '');
const lockedByPlan = props.currentWorkMode === 'plan';
return options.map((opt) => ({ return options.map((opt) => ({
id: `exec:${opt.value}`, id: `exec:${opt.value}`,
label: opt.label, label: opt.label,
description: `${opt.value === current ? '当前 · ' : ''}${opt.description || ''}`, description: lockedByPlan
? '计划模式下执行环境锁定为沙箱'
: `${opt.value === current ? '当前 · ' : ''}${opt.description || ''}`,
disabled: lockedByPlan,
action: () => emit('change-execution-mode', opt.value) action: () => emit('change-execution-mode', opt.value)
})); }));
}); });
@ -2872,6 +2946,20 @@ const agentTypeLabel = computed(() => {
return props.newConversationType === 'multi_agent' ? '多智能体' : '智能体'; return props.newConversationType === 'multi_agent' ? '多智能体' : '智能体';
}); });
/** 运行模式:对话运行中不可切换(后端也会 409 拒绝UI 侧先锁定避免误点) */
const workModeLocked = computed(() => !!props.streamingMessage);
/** 运行模式按钮文案 */
const workModeLabel = computed(() => {
const matched = (props.workModeOptions || []).find(
(item) => item.value === props.currentWorkMode
);
return matched ? matched.label : props.currentWorkMode || '计划';
});
/** 计划模式下权限被锁定为只读UI 禁用 + 后端强制) */
const permissionLockedByPlan = computed(() => props.currentWorkMode === 'plan');
const currentExecutionShortLabel = computed(() => { const currentExecutionShortLabel = computed(() => {
const mode = String(props.currentExecutionMode || ''); const mode = String(props.currentExecutionMode || '');
if (mode === 'direct') return '完全访问'; if (mode === 'direct') return '完全访问';

View File

@ -0,0 +1,310 @@
<template>
<transition name="plan-approval-fade" appear>
<div v-if="visible && current" class="plan-approval-overlay">
<section class="plan-approval-card" role="dialog" aria-modal="true" aria-label="计划待批准">
<header class="plan-approval-windowbar">
<div class="plan-approval-window-title">计划待批准</div>
</header>
<header class="plan-approval-header">
<div class="plan-approval-title-block">
<p v-if="current.summary" class="plan-approval-summary">{{ current.summary }}</p>
<p class="plan-approval-file" :title="current.plan_file">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
<span>{{ current.plan_file }}</span>
<span v-if="current.plan_content_truncated" class="plan-approval-truncated">内容过长已截断预览完整内容见文件</span>
</p>
</div>
</header>
<div class="plan-approval-body">
<MarkdownRenderer :content="current.plan_content || ''" />
</div>
<div class="plan-approval-comment">
<textarea
v-model="comment"
class="plan-approval-textarea"
rows="2"
placeholder="意见(可选):批准时作为补充要求,拒绝时说明需要调整的方向…"
spellcheck="false"
:disabled="submitting"
@keydown.meta.enter.prevent="approve"
@keydown.ctrl.enter.prevent="approve"
></textarea>
</div>
<footer class="plan-approval-footer">
<button
type="button"
class="plan-approval-btn ghost"
:disabled="submitting"
title="拒绝这份计划AI 将根据你的意见修订后重新提交"
@click="reject"
>
拒绝
</button>
<div class="plan-approval-spacer"></div>
<button
type="button"
class="plan-approval-btn primary"
:disabled="submitting"
title="批准计划并切换到执行模式开始实施"
@click="approve"
>
{{ submitting ? '提交中…' : '批准并执行' }}
</button>
</footer>
</section>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import MarkdownRenderer from '../chat/MarkdownRenderer.vue';
const props = defineProps<{
visible: boolean;
approvals: Array<any>;
submittingIds?: string[];
}>();
const emit = defineEmits<{
(event: 'submit', payload: { approval_id: string; approved: boolean; comment: string }): void;
}>();
// plan
// resolved
const current = computed(() => {
const list = Array.isArray(props.approvals) ? props.approvals : [];
return list.length ? list[0] : null;
});
const comment = ref('');
watch(
() => current.value?.approval_id,
() => {
comment.value = '';
}
);
const submitting = computed(() => {
const ids = props.submittingIds || [];
const id = String(current.value?.approval_id || '');
return ids.length > 0 && (!id || ids.includes(id));
});
function approve() {
const id = String(current.value?.approval_id || '').trim();
if (!id || submitting.value) return;
emit('submit', { approval_id: id, approved: true, comment: comment.value.trim() });
}
function reject() {
const id = String(current.value?.approval_id || '').trim();
if (!id || submitting.value) return;
emit('submit', { approval_id: id, approved: false, comment: comment.value.trim() });
}
</script>
<style scoped>
.plan-approval-overlay {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--overlay-scrim);
}
.plan-approval-card {
position: relative;
width: min(860px, calc(100vw - 36px));
max-height: min(680px, calc(100vh - 36px));
max-height: min(680px, calc(100dvh - 36px));
display: flex;
flex-direction: column;
color: var(--text-primary);
background: var(--surface-card);
border: 1px solid var(--border-strong);
border-radius: 16px;
overflow: hidden;
}
.plan-approval-windowbar {
height: 40px;
flex: 0 0 40px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid var(--border-default);
}
.plan-approval-window-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
user-select: none;
}
.plan-approval-header {
padding: 16px 22px 0;
flex-shrink: 0;
}
.plan-approval-title-block {
min-width: 0;
}
.plan-approval-summary {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
}
.plan-approval-file {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.plan-approval-file svg {
flex: 0 0 auto;
}
.plan-approval-truncated {
flex: 0 0 auto;
}
.plan-approval-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 14px 22px 18px;
font-size: 13px;
line-height: 1.65;
}
.plan-approval-comment {
flex-shrink: 0;
padding: 0 22px 12px;
}
.plan-approval-textarea {
width: 100%;
resize: none;
min-height: 56px;
max-height: 120px;
overflow-y: auto;
border: 1px solid var(--border-default);
border-radius: 10px;
padding: 9px 11px;
color: var(--text-primary);
background: var(--surface-soft);
outline: none;
font: inherit;
font-size: 13px;
line-height: 1.5;
box-sizing: border-box;
}
.plan-approval-textarea:focus {
border-color: var(--border-strong);
}
.plan-approval-footer {
height: 56px;
flex: 0 0 56px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 18px;
border-top: 1px solid var(--border-default);
}
.plan-approval-spacer {
flex: 1 1 auto;
}
.plan-approval-btn {
min-width: 72px;
height: 34px;
border-radius: 9px;
padding: 0 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border-strong);
}
.plan-approval-btn.ghost {
background: transparent;
color: var(--text-secondary);
}
.plan-approval-btn.ghost:hover:not(:disabled) {
background: var(--hover-bg);
color: var(--text-primary);
}
.plan-approval-btn.primary {
background: var(--accent);
border-color: var(--accent-strong);
color: var(--on-accent);
}
.plan-approval-btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.plan-approval-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.plan-approval-fade-enter-active,
.plan-approval-fade-leave-active {
transition: opacity 0.16s ease;
}
.plan-approval-fade-enter-active .plan-approval-card,
.plan-approval-fade-leave-active .plan-approval-card {
transition: transform 0.16s ease;
}
.plan-approval-fade-enter-from,
.plan-approval-fade-leave-to {
opacity: 0;
}
.plan-approval-fade-enter-from .plan-approval-card,
.plan-approval-fade-leave-to .plan-approval-card {
transform: translateY(6px) scale(0.99);
}
@media (max-width: 620px) {
.plan-approval-overlay {
padding: 12px;
}
.plan-approval-card {
width: 100%;
max-height: calc(100vh - 24px);
max-height: calc(100dvh - 24px);
}
}
</style>

View File

@ -179,7 +179,9 @@ function dismiss() {
position: relative; position: relative;
width: min(800px, calc(100vw - 36px)); width: min(800px, calc(100vw - 36px));
height: min(520px, calc(100vh - 36px)); height: min(520px, calc(100vh - 36px));
height: min(520px, calc(100dvh - 36px));
max-height: min(520px, calc(100vh - 36px)); max-height: min(520px, calc(100vh - 36px));
max-height: min(520px, calc(100dvh - 36px));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: var(--text-primary); color: var(--text-primary);
@ -481,6 +483,7 @@ function dismiss() {
.user-question-card { .user-question-card {
width: 100%; width: 100%;
max-height: calc(100vh - 24px); max-height: calc(100vh - 24px);
max-height: calc(100dvh - 24px);
} }
} }
</style> </style>

View File

@ -876,6 +876,45 @@
</div> </div>
</div> </div>
</div> </div>
<div class="settings-select-row">
<span class="settings-row-copy"
><span class="settings-row-title">默认运行模式</span
><span class="settings-row-desc"
>新建对话时的默认运行模式计划模式下权限锁定为只读</span
></span
>
<div
class="settings-select-wrap"
:class="{ open: activeDropdown === 'work-mode' }"
@click.stop
>
<button
type="button"
class="settings-select-button"
@click="toggleDropdown('work-mode')"
>
{{ workModeLabel }}
<span class="select-chevron" aria-hidden="true"></span>
</button>
<div
:class="['settings-floating-menu', { dark: activeTheme === 'dark' }]"
:style="activeDropdown ? floatingMenuStyle : undefined"
>
<button
v-for="option in workModeOptions"
:key="option.id"
type="button"
class="settings-menu-option"
:class="{ selected: form.default_work_mode === option.id }"
@click="selectDefaultWorkMode(option.id)"
>
<strong>{{ option.label }}</strong
><span>{{ option.desc }}</span
><svg viewBox="0 0 24 24"><path d="M5 12.5 9.5 17 19 7" /></svg>
</button>
</div>
</div>
</div>
<label class="settings-toggle-row" <label class="settings-toggle-row"
><span class="settings-row-copy" ><span class="settings-row-copy"
><span class="settings-row-title">AGENTS.md 自动注入</span ><span class="settings-row-title">AGENTS.md 自动注入</span
@ -2264,6 +2303,17 @@ const permissionModeOptions: Array<{
{ id: 'unrestricted', label: '无限制', desc: '工具按常规流程直接执行' } { id: 'unrestricted', label: '无限制', desc: '工具按常规流程直接执行' }
]; ];
type WorkModeValue = 'plan' | 'ask' | 'execute';
const workModeOptions: Array<{
id: WorkModeValue;
label: string;
desc: string;
}> = [
{ id: 'plan', label: '计划', desc: '只制定计划,批准后执行' },
{ id: 'ask', label: '询问', desc: '先讨论确认,再开工' },
{ id: 'execute', label: '执行', desc: '自行补全细节,直接开工' }
];
const policyStore = usePolicyStore(); const policyStore = usePolicyStore();
const modelStore = useModelStore(); const modelStore = useModelStore();
@ -2326,6 +2376,13 @@ const permissionModeLabel = computed(() => {
); );
}); });
const workModeLabel = computed(() => {
return (
workModeOptions.find((option) => option.id === form.value.default_work_mode)?.label ||
'未设置'
);
});
const versioningBackupModeLabel = computed(() => { const versioningBackupModeLabel = computed(() => {
if (form.value.versioning_backup_mode === 'full') return '完全备份'; if (form.value.versioning_backup_mode === 'full') return '完全备份';
return '浅备份'; return '浅备份';
@ -2838,6 +2895,11 @@ const selectDefaultPermissionMode = (value: PermissionModeValue) => {
closeDropdown(); closeDropdown();
}; };
const selectDefaultWorkMode = (value: WorkModeValue) => {
personalization.setDefaultWorkMode(value);
closeDropdown();
};
const selectVersioningBackupMode = (value: 'shallow' | 'full') => { const selectVersioningBackupMode = (value: 'shallow' | 'full') => {
personalization.setVersioningBackupMode(value); personalization.setVersioningBackupMode(value);
closeDropdown(); closeDropdown();

View File

@ -6,6 +6,7 @@ export type CompactMessageDisplay = 'full' | 'brief';
type RunMode = 'fast' | 'thinking'; type RunMode = 'fast' | 'thinking';
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
type PermissionMode = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted'; type PermissionMode = 'readonly' | 'approval' | 'auto_approval' | 'unrestricted';
export type WorkMode = 'plan' | 'ask' | 'execute';
type CommunicationStyle = 'default' | 'human_like' | 'auto'; type CommunicationStyle = 'default' | 'human_like' | 'auto';
type ConversationContinuity = 'low' | 'medium' | 'high'; type ConversationContinuity = 'low' | 'medium' | 'high';
type VersioningBackupMode = 'shallow' | 'full'; type VersioningBackupMode = 'shallow' | 'full';
@ -50,6 +51,7 @@ interface PersonalForm {
default_run_mode: RunMode | null; default_run_mode: RunMode | null;
default_reasoning_effort: ReasoningEffort | null; default_reasoning_effort: ReasoningEffort | null;
default_permission_mode: PermissionMode; default_permission_mode: PermissionMode;
default_work_mode: WorkMode;
versioning_enabled_by_default: boolean; versioning_enabled_by_default: boolean;
versioning_backup_mode: VersioningBackupMode; versioning_backup_mode: VersioningBackupMode;
versioning_restore_mode: 'overwrite'; versioning_restore_mode: 'overwrite';
@ -252,6 +254,7 @@ const defaultForm = (): PersonalForm => ({
default_run_mode: null, default_run_mode: null,
default_reasoning_effort: null, default_reasoning_effort: null,
default_permission_mode: 'unrestricted', default_permission_mode: 'unrestricted',
default_work_mode: 'plan',
versioning_enabled_by_default: true, versioning_enabled_by_default: true,
versioning_backup_mode: 'shallow', versioning_backup_mode: 'shallow',
versioning_restore_mode: 'overwrite', versioning_restore_mode: 'overwrite',
@ -496,6 +499,12 @@ export const usePersonalizationStore = defineStore('personalization', {
data.default_permission_mode === 'unrestricted' data.default_permission_mode === 'unrestricted'
? data.default_permission_mode ? data.default_permission_mode
: 'unrestricted', : 'unrestricted',
default_work_mode:
data.default_work_mode === 'plan' ||
data.default_work_mode === 'ask' ||
data.default_work_mode === 'execute'
? data.default_work_mode
: 'plan',
versioning_enabled_by_default: data.versioning_enabled_by_default !== false, versioning_enabled_by_default: data.versioning_enabled_by_default !== false,
versioning_backup_mode: data.versioning_backup_mode === 'full' ? 'full' : 'shallow', versioning_backup_mode: data.versioning_backup_mode === 'full' ? 'full' : 'shallow',
versioning_restore_mode: 'overwrite', versioning_restore_mode: 'overwrite',
@ -910,6 +919,16 @@ export const usePersonalizationStore = defineStore('personalization', {
this.clearFeedback(); this.clearFeedback();
this.scheduleAutoSave(); this.scheduleAutoSave();
}, },
setDefaultWorkMode(mode: WorkMode) {
const allowed: WorkMode[] = ['plan', 'ask', 'execute'];
const target = allowed.includes(mode) ? mode : 'plan';
this.form = {
...this.form,
default_work_mode: target
};
this.clearFeedback();
this.scheduleAutoSave();
},
setVersioningRestoreMode(_mode: 'overwrite') { setVersioningRestoreMode(_mode: 'overwrite') {
const target: 'overwrite' = 'overwrite'; const target: 'overwrite' = 'overwrite';
this.form = { this.form = {

View File

@ -592,6 +592,17 @@ body[data-theme='light'] {
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 600; font-weight: 600;
padding: 2px 4px; padding: 2px 4px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.permission-switcher__group-lock {
font-size: 11px;
font-weight: 500;
color: var(--state-warning);
white-space: nowrap;
} }
.permission-switcher__caret { .permission-switcher__caret {

View File

@ -340,3 +340,32 @@ def _format_active_sub_agents_list(agents: List[Dict[str, Any]]) -> str:
suffix = "" if len(last_output) > 120 else "" suffix = "" if len(last_output) > 120 else ""
lines.append(f" 最近输出:{preview}{suffix}") lines.append(f" 最近输出:{preview}{suffix}")
return "\n".join(lines) return "\n".join(lines)
def _format_submit_plan(result_data: Dict[str, Any]) -> str:
"""submit_plan 工具结果 → 对话上下文摘要(计划批准流的两态)。"""
status = str(result_data.get("status") or "").strip()
plan_file = str(result_data.get("plan_file") or "").strip()
comment = str(result_data.get("comment") or "").strip()
message = str(result_data.get("message") or "").strip()
if status == "approved":
parts = [f"✅ 用户已批准计划({plan_file}),运行模式已切换为「执行」。"]
if comment:
parts.append(f"用户批准意见:{comment}")
parts.append("现在可以开始按计划实施。")
return "\n".join(parts)
if status == "rejected":
parts = [f"❌ 用户拒绝了计划({plan_file}),仍处于计划模式。"]
if comment:
parts.append(f"用户意见:{comment}")
else:
parts.append("用户未填写具体意见。")
parts.append("请修订计划文档后重新提交。")
return "\n".join(parts)
# 校验失败 / 未在计划模式 / 超时等其他状态
if message:
return f"⚠️ submit_plan 未完成({status or 'unknown'}{message}"
return _format_failure("submit_plan", result_data)

View File

@ -43,6 +43,7 @@ from utils.tool_result_formatter.agent_context import (
_format_create_custom_agent, _format_create_custom_agent,
_format_list_agents, _format_list_agents,
_format_list_active_sub_agents, _format_list_active_sub_agents,
_format_submit_plan,
) )
from utils.tool_result_formatter.web_media import ( from utils.tool_result_formatter.web_media import (
_format_extract_webpage, _format_extract_webpage,
@ -143,4 +144,5 @@ TOOL_FORMATTERS = {
"list_agents": _format_list_agents, "list_agents": _format_list_agents,
"list_active_sub_agents": _format_list_active_sub_agents, "list_active_sub_agents": _format_list_active_sub_agents,
"get_sub_agent_status": _format_get_sub_agent_status, "get_sub_agent_status": _format_get_sub_agent_status,
"submit_plan": _format_submit_plan,
} }