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