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