feat(web,docs): 前端支持模型下拉选择并更新 README 专家系统章节

- 前端类型 ExpertItem/SubagentItem 新增 provider/model 字段,新增 ModelOptionsResponse 类型

- API 层 createExpert/updateExpert/updateSubagent payload 支持 provider/model,新增 listModelOptions

- ConfigPage 专家/子代理模态框新增模型配置 SectionCard(Provider/Model 下拉框,留空继承默认)

- 空字符串转 undefined 传后端,避免 P0 反序列化为 Some(vec![]) 导致全禁

- README 新增第 8 节专家系统,更新子代理 SUBAGENT.md 字段说明(含 provider/model 和子代理黑白名单)

- README 新增 max_nesting_depth 配置项说明,重排后续章节编号
This commit is contained in:
oudecheng 2026-07-30 22:44:28 +08:00
parent dc9211548a
commit 5ff1e8455c
6 changed files with 214 additions and 39 deletions

157
README.md
View File

@ -142,7 +142,8 @@ Agent 执行层 (Agent) ◄─────────────────
- src/clientWebSocket CLI 客户端实现 - src/clientWebSocket CLI 客户端实现
- src/command命令系统包括处理器、适配器、上下文和响应处理 - src/command命令系统包括处理器、适配器、上下文和响应处理
- src/config配置解析与默认值定义 - src/config配置解析与默认值定义
- src/domain领域模型包含消息和工具定义 - src/domain领域模型包含消息、工具定义和能力策略CapabilityPolicy
- src/experts专家系统支持基于文件系统的专家发现、加载和运行时管理
- src/gateway网关生命周期、InboundProcessor、OutboundDispatcher、SessionManager以及消息执行、调度任务执行、Prompt 注入、历史压缩和记忆维护编排 - src/gateway网关生命周期、InboundProcessor、OutboundDispatcher、SessionManager以及消息执行、调度任务执行、Prompt 注入、历史压缩和记忆维护编排
- src/providers不同 LLM Provider 的统一抽象,当前支持 openai 和 anthropic - src/providers不同 LLM Provider 的统一抽象,当前支持 openai 和 anthropic
- src/tools内置工具集合与 ToolRegistry - src/tools内置工具集合与 ToolRegistry
@ -502,17 +503,88 @@ tools 配置示例:
注意bash 和 shell 是同一个工具在不同平台上的名称,运行时自动检测。 注意bash 和 shell 是同一个工具在不同平台上的名称,运行时自动检测。
## 8. 子代理系统 ## 8. 专家系统
PicoBot 支持基于文件系统的专家系统,用于给主智能体注入特定角色的行为语气和系统能力配置。专家类似于技能,但作用于主智能体本身,而非作为子任务执行。
### 8.1 专家发现位置
按从低到高优先级合并,后加载来源可覆盖同名专家:
- 用户级:`~/.picobot/experts/*/EXPERT.md`
- 项目级:`.picobot/experts/*/EXPERT.md`
### 8.2 EXPERT.md 格式
```md
---
description: Rust 架构审查专家
name: rust-reviewer # 可选,省略时使用目录名
allowed_skills: [code-review-skill] # 可选,技能白名单(仅这些技能可用)
denied_skills: [shell-exec] # 可选,技能黑名单(这些技能被禁用)
allowed_tools: [read, bash, web_fetch] # 可选,工具白名单(仅这些工具可用)
denied_tools: [task] # 可选,工具黑名单(这些工具被禁用)
allowed_subagents: [general] # 可选,子代理白名单(仅允许创建这些子代理类型)
denied_subagents: [explore] # 可选,子代理黑名单(禁止创建这些子代理类型)
provider: deepseek # 可选,覆盖 provider引用 config.json 的 providers 表)
model: deepseek-chat # 可选,覆盖 model引用 config.json 的 models 表)
---
你是一个专注于 Rust 项目的架构审查专家。
你应该:
1. 重点关注模块解耦性和依赖方向
2. 检查是否存在循环依赖
3. 提出符合 Rust 惯用法的改进建议
4. 关注错误处理和资源管理
```
### 8.3 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `description` | string | 是 | 简短描述,用于专家选择 |
| `name` | string | 否 | 专家名称,默认取目录名 |
| `allowed_skills` | array | 否 | 技能白名单,指定后仅这些技能可用;不指定则不限制 |
| `denied_skills` | array | 否 | 技能黑名单,指定后这些技能被禁用;在白名单之后应用 |
| `allowed_tools` | array | 否 | 工具白名单,指定后仅这些工具可用;不指定则不限制 |
| `denied_tools` | array | 否 | 工具黑名单,指定后这些工具被禁用;在白名单之后应用 |
| `allowed_subagents` | array | 否 | 子代理白名单,指定后仅允许创建这些类型的子代理;不指定则不限制 |
| `denied_subagents` | array | 否 | 子代理黑名单,指定后禁止创建这些类型的子代理;在白名单之后应用 |
| `provider` | string | 否 | 覆盖 provider引用 `config.json``providers` 表中的键名;不指定则继承主智能体 |
| `model` | string | 否 | 覆盖 model引用 `config.json``models` 表中的键名;不指定则继承主智能体 |
> **能力过滤语义**:与子代理一致,`allowed_*``denied_*` 可共存生效顺序为先应用白名单取交集再扣除黑名单。四类能力tools/skills/subagents独立过滤互不影响。全部不指定时主智能体使用完整工具集和技能集。
>
> **模型覆盖语义**`provider``model` 字段独立可选,可只覆盖其一。引用不存在的 provider/model 名会阻止会话并明确反馈。不指定时继承主智能体的 provider/model 配置。
>
> **专家与子代理的区别**:专家配置作用于主智能体(影响 system prompt 和能力策略),子代理配置作用于独立执行上下文(影响子任务的行为和能力)。两者各自独立,子代理不继承专家的能力策略。
### 8.4 专家管理
专家支持通过 Web UI 或 API 进行运行时管理:
- 查看专家列表(含来源、禁用状态)
- 创建/更新/删除专家
- 按作用域(用户级/项目级)禁用/启用专家
- 配置能力策略(工具/技能/子代理黑白名单)和模型覆盖
专家启用/禁用状态写入独立状态文件:
- 用户级状态:`~/.picobot/expert-state.json`
- 项目级状态:`.picobot/expert-state.json`
## 9. 子代理系统
PicoBot 支持通过 `task` 工具创建子代理来处理复杂多步骤任务。子代理在一个独立的执行上下文中运行,拥有独立的会话历史和工具权限。 PicoBot 支持通过 `task` 工具创建子代理来处理复杂多步骤任务。子代理在一个独立的执行上下文中运行,拥有独立的会话历史和工具权限。
### 8.1 内置子代理类型 ### 9.1 内置子代理类型
- **general**通用型子代理适合处理复杂多步骤任务。可以使用读写文件、执行命令、HTTP 请求等完整工具集。 - **general**通用型子代理适合处理复杂多步骤任务。可以使用读写文件、执行命令、HTTP 请求等完整工具集。
> 如需只读探索型子代理,可通过自定义子代理配合 `allowed_tools` 白名单实现(见下文)。 > 如需只读探索型子代理,可通过自定义子代理配合 `allowed_tools` 白名单实现(见下文)。
### 8.2 自定义子代理 ### 9.2 自定义子代理
用户可以在文件系统上定义新的子代理类型,类似于技能的加载方式。子代理定义文件采用 YAML frontmatter + body 的格式。 用户可以在文件系统上定义新的子代理类型,类似于技能的加载方式。子代理定义文件采用 YAML frontmatter + body 的格式。
@ -541,6 +613,12 @@ prompt_template: |
注意: 你是一个只读代理,禁止执行任何修改操作。 注意: 你是一个只读代理,禁止执行任何修改操作。
allowed_tools: [read, bash, web_fetch] # 可选,工具白名单(仅这些工具可用) allowed_tools: [read, bash, web_fetch] # 可选,工具白名单(仅这些工具可用)
denied_tools: [task] # 可选,工具黑名单(这些工具被禁用) denied_tools: [task] # 可选,工具黑名单(这些工具被禁用)
allowed_skills: [code-review-skill] # 可选,技能白名单(仅这些技能可用)
denied_skills: [shell-exec] # 可选,技能黑名单(这些技能被禁用)
allowed_subagents: [general] # 可选,子代理白名单(仅允许创建这些子代理类型)
denied_subagents: [explore] # 可选,子代理黑名单(禁止创建这些子代理类型)
provider: deepseek # 可选,覆盖 provider引用 config.json 的 providers 表)
model: deepseek-chat # 可选,覆盖 model引用 config.json 的 models 表)
max_execution_secs: 600 # 可选,覆盖默认执行时间 max_execution_secs: 600 # 可选,覆盖默认执行时间
--- ---
@ -559,9 +637,17 @@ max_execution_secs: 600 # 可选,覆盖默认执行时间
| `prompt_template` | string | 是 | 提示词模板,支持变量插值 | | `prompt_template` | string | 是 | 提示词模板,支持变量插值 |
| `allowed_tools` | array | 否 | 工具白名单,指定后仅这些工具可用;不指定则不限制 | | `allowed_tools` | array | 否 | 工具白名单,指定后仅这些工具可用;不指定则不限制 |
| `denied_tools` | array | 否 | 工具黑名单,指定后这些工具被禁用;在白名单之后应用 | | `denied_tools` | array | 否 | 工具黑名单,指定后这些工具被禁用;在白名单之后应用 |
| `allowed_skills` | array | 否 | 技能白名单,指定后仅这些技能可用;不指定则不限制 |
| `denied_skills` | array | 否 | 技能黑名单,指定后这些技能被禁用;在白名单之后应用 |
| `allowed_subagents` | array | 否 | 子代理白名单,指定后仅允许创建这些类型的子代理;不指定则不限制 |
| `denied_subagents` | array | 否 | 子代理黑名单,指定后禁止创建这些类型的子代理;在白名单之后应用 |
| `provider` | string | 否 | 覆盖 provider引用 `config.json``providers` 表中的键名;不指定则继承主智能体 |
| `model` | string | 否 | 覆盖 model引用 `config.json``models` 表中的键名;不指定则继承主智能体 |
| `max_execution_secs` | integer | 否 | 最大执行时间(秒) | | `max_execution_secs` | integer | 否 | 最大执行时间(秒) |
> **工具过滤语义**`allowed_tools``denied_tools` 可共存。生效顺序为:先应用白名单(取交集),再扣除黑名单。两者都不指定时,子代理使用完整工具集。当子代理嵌套深度达到 `max_nesting_depth`(默认 2即孙代理始终移除 `task` 工具以防无限嵌套。白名单中未注册的工具名会被静默跳过。 > **能力过滤语义**`allowed_*``denied_*` 可共存。生效顺序为先应用白名单取交集再扣除黑名单。四类能力tools/skills/subagents独立过滤互不影响。全部不指定时子代理使用完整工具集和技能集。当子代理嵌套深度达到 `max_nesting_depth` 时,始终移除 `task` 工具以防无限嵌套。白名单中未注册的工具/技能名会被静默跳过。
>
> **模型覆盖语义**`provider``model` 字段独立可选,可只覆盖其一。引用不存在的 provider/model 名会导致子代理创建失败并反馈给主智能体重试。不指定时继承主智能体的 provider/model 配置。
#### 模板变量 #### 模板变量
@ -586,23 +672,34 @@ max_execution_secs: 600 # 可选,覆盖默认执行时间
如果指定的子代理类型不存在,系统会自动回退到 `general` 类型。 如果指定的子代理类型不存在,系统会自动回退到 `general` 类型。
### 8.3 子代理配置 ### 9.3 子代理配置
```json ```json
{ {
"subagents": { "subagents": {
"enabled": true, "enabled": true,
"sources": ["user", "project"] "sources": ["user", "project"]
},
"tools": {
"task": {
"enabled": true,
"max_nesting_depth": 2,
"max_execution_secs": 3600,
"ttl_hours": 24
}
} }
} }
``` ```
| 字段 | 默认值 | 说明 | | 字段 | 默认值 | 说明 |
|------|--------|------| |------|--------|------|
| `enabled` | `true` | 是否启用自定义子代理发现 | | `subagents.enabled` | `true` | 是否启用自定义子代理发现 |
| `sources` | `["user", "project"]` | 定义来源优先级 | | `subagents.sources` | `["user", "project"]` | 定义来源优先级 |
| `tools.task.max_nesting_depth` | `2` | 子代理最大嵌套深度0=禁止嵌套1=仅子代理2=子代理+孙代理) |
| `tools.task.max_execution_secs` | `3600` | 子代理默认最大执行时间(秒) |
| `tools.task.ttl_hours` | `24` | 子代理任务 TTL小时 |
## 9. 工具机制 ## 10. 工具机制
PicoBot 的 Agent 是围绕工具调用构建的。当前默认注册的工具包括: PicoBot 的 Agent 是围绕工具调用构建的。当前默认注册的工具包括:
@ -632,7 +729,7 @@ PicoBot 的 Agent 是围绕工具调用构建的。当前默认注册的工具
- bash / shell / http_request / web_fetch 让 Agent 具备更强的外部交互能力bash 和 shell 是同一工具在不同平台的名称) - bash / shell / http_request / web_fetch 让 Agent 具备更强的外部交互能力bash 和 shell 是同一工具在不同平台的名称)
- task 允许 Agent 创建独立上下文的子代理来处理复杂多步骤任务支持内置类型general和用户自定义类型 - task 允许 Agent 创建独立上下文的子代理来处理复杂多步骤任务支持内置类型general和用户自定义类型
### 9.1 MCP 工具集成 ### 10.1 MCP 工具集成
PicoBot 支持通过 MCP (Model Context Protocol) 扩展工具能力,可以连接外部 MCP servers 并自动发现其提供的工具。配置格式兼容 Claude Desktop / Cursor。 PicoBot 支持通过 MCP (Model Context Protocol) 扩展工具能力,可以连接外部 MCP servers 并自动发现其提供的工具。配置格式兼容 Claude Desktop / Cursor。
@ -709,18 +806,18 @@ MCP 工具会自动注册到 ToolRegistry命名格式为 `mcp_{server_key}_{t
- 通过 Tool trait 适配器接入,无需修改核心代码 - 通过 Tool trait 适配器接入,无需修改核心代码
- 连接失败不影响 Gateway 运行 - 连接失败不影响 Gateway 运行
## 10. 调度器机制 ## 11. 调度器机制
PicoBot 带有一个基于 SQLite 的调度器,而不是纯内存或 JSON 文件驱动的任务系统。 PicoBot 带有一个基于 SQLite 的调度器,而不是纯内存或 JSON 文件驱动的任务系统。
### 10.1 支持的调度类型 ### 11.1 支持的调度类型
- delay延迟执行一次 - delay延迟执行一次
- interval固定间隔执行 - interval固定间隔执行
- at某个绝对时间执行一次 - at某个绝对时间执行一次
- croncron 表达式调度 - croncron 表达式调度
### 10.2 支持的任务类型 ### 11.2 支持的任务类型
- internal_event内部事件 - internal_event内部事件
- outbound_message直接向目标通道发消息 - outbound_message直接向目标通道发消息
@ -743,7 +840,7 @@ silent_agent_task 和 agent_task 使用同一套 Agent 执行能力,但路由
- 执行失败时会向主 chat 发送一条失败通知,便于用户感知异常 - 执行失败时会向主 chat 发送一条失败通知,便于用户感知异常
- 后台任务的历史、压缩和会话内上下文会留在独立会话中,不污染主会话 - 后台任务的历史、压缩和会话内上下文会留在独立会话中,不污染主会话
### 10.3 运行时管理 ### 11.3 运行时管理
通过 scheduler_manage 可以进行: 通过 scheduler_manage 可以进行:
@ -837,15 +934,15 @@ silent_agent_task 和 agent_task 使用同一套 Agent 执行能力,但路由
- agent_task用户需要直接收到结果例如日报提醒、定时播报、定时外发通知 - agent_task用户需要直接收到结果例如日报提醒、定时播报、定时外发通知
- silent_agent_task任务需要长期积累独立上下文或后台整理材料但不应污染主会话例如周报草稿整理、周期性资料汇总、后台分析任务 - silent_agent_task任务需要长期积累独立上下文或后台整理材料但不应污染主会话例如周报草稿整理、周期性资料汇总、后台分析任务
## 11. 渠道与运行方式 ## 12. 渠道与运行方式
### 11.1 当前支持的通道 ### 12.1 当前支持的通道
- WebSocket CLI 客户端 - WebSocket CLI 客户端
- 飞书通道 - 飞书通道
- 微信通道 - 微信通道
### 11.2 Gateway 接口 ### 12.2 Gateway 接口
网关当前暴露: 网关当前暴露:
@ -853,7 +950,7 @@ silent_agent_task 和 agent_task 使用同一套 Agent 执行能力,但路由
- /wsCLI 客户端连接入口 - /wsCLI 客户端连接入口
- /Web UI 前端(已嵌入二进制,无需外部文件) - /Web UI 前端(已嵌入二进制,无需外部文件)
### 11.3 Web UI ### 12.3 Web UI
PicoBot 内置 Web 前端,在编译时已打包进二进制文件。启动网关后可直接访问: PicoBot 内置 Web 前端,在编译时已打包进二进制文件。启动网关后可直接访问:
@ -866,7 +963,7 @@ http://127.0.0.1:19876/
- 支持 SPA 前端路由 - 支持 SPA 前端路由
- 开发模式下可通过 `STATIC_DIR` 环境变量使用磁盘文件(支持热更新) - 开发模式下可通过 `STATIC_DIR` 环境变量使用磁盘文件(支持热更新)
### 11.4 CLI 使用方式 ### 12.4 CLI 使用方式
程序提供两个主命令: 程序提供两个主命令:
@ -893,7 +990,7 @@ CLI 中已实现的交互命令包括:
- /clear - /clear
- /quit - /quit
## 12. 配置说明 ## 13. 配置说明
配置默认从以下位置加载: 配置默认从以下位置加载:
@ -960,9 +1057,9 @@ CLI 中已实现的交互命令包括:
- tools工具启用/禁用配置(通过 disabled 列表指定禁用的工具) - tools工具启用/禁用配置(通过 disabled 列表指定禁用的工具)
- time.timezone时区默认应使用 IANA 时区名,例如 Asia/Shanghai - time.timezone时区默认应使用 IANA 时区名,例如 Asia/Shanghai
## 13. 快速开始 ## 14. 快速开始
### 13.1 准备配置 ### 14.1 准备配置
1. 复制并修改 config.json或把配置放到 ~/.picobot/config.json 1. 复制并修改 config.json或把配置放到 ~/.picobot/config.json
2. 配置好 Provider 的 base_url、api_key、model_id 2. 配置好 Provider 的 base_url、api_key、model_id
@ -1008,7 +1105,7 @@ CLI 中已实现的交互命令包括:
} }
``` ```
### 13.2 构建与启动 ### 14.2 构建与启动
> **Windows 用户注意:** Windows 默认不包含 `make` 命令。你可以通过以下方式之一来构建: > **Windows 用户注意:** Windows 默认不包含 `make` 命令。你可以通过以下方式之一来构建:
> >
@ -1081,7 +1178,7 @@ $env:STATIC_DIR="static"; cargo run -- gateway
| `make run` | `cargo run --release -- gateway` | `cargo run --release -- gateway` | | `make run` | `cargo run --release -- gateway` | `cargo run --release -- gateway` |
| `make check` | `cd web && npm run build && cargo check && cargo clippy` | `cd web; npm run build; cd ..; cargo check; cargo clippy` | | `make check` | `cd web && npm run build && cargo check && cargo clippy` | `cd web; npm run build; cd ..; cargo check; cargo clippy` |
### 13.3 启动本地 CLI ### 14.3 启动本地 CLI
```bash ```bash
cargo run -- agent cargo run -- agent
@ -1099,13 +1196,13 @@ ws://127.0.0.1:19876/ws
cargo run -- agent --gateway-url ws://127.0.0.1:19876/ws cargo run -- agent --gateway-url ws://127.0.0.1:19876/ws
``` ```
### 13.4 检查服务状态 ### 14.4 检查服务状态
```bash ```bash
curl http://127.0.0.1:19876/health curl http://127.0.0.1:19876/health
``` ```
## 14. 目录结构 ## 15. 目录结构
```text ```text
PicoBot/ PicoBot/
@ -1117,7 +1214,8 @@ PicoBot/
│ ├── client/ # WebSocket CLI 客户端 │ ├── client/ # WebSocket CLI 客户端
│ ├── command/ # 命令系统(处理器、适配器、上下文) │ ├── command/ # 命令系统(处理器、适配器、上下文)
│ ├── config/ # 配置解析 │ ├── config/ # 配置解析
│ ├── domain/ # 领域模型(消息、工具定义) │ ├── domain/ # 领域模型(消息、工具定义、能力策略)
│ ├── experts/ # 专家系统(发现、加载、运行时管理)
│ ├── gateway/ # Gateway、Session 编排、WS/HTTP 控制面、执行服务 │ ├── gateway/ # Gateway、Session 编排、WS/HTTP 控制面、执行服务
│ ├── logging/ # 日志配置 │ ├── logging/ # 日志配置
│ ├── mcp/ # MCP 集成(客户端管理、工具适配器、配置) │ ├── mcp/ # MCP 集成(客户端管理、工具适配器、配置)
@ -1136,7 +1234,7 @@ PicoBot/
└── config.json └── config.json
``` ```
## 15. 测试与维护建议 ## 16. 测试与维护建议
当前 tests 目录中已经包含 Provider 集成测试和工具调用相关测试,但部分测试依赖外部 API Key需要先准备 tests/test.env。 当前 tests 目录中已经包含 Provider 集成测试和工具调用相关测试,但部分测试依赖外部 API Key需要先准备 tests/test.env。
@ -1150,13 +1248,14 @@ PicoBot/
- src/bus/message.rs消息结构变更如 OutboundMessage 新增 session_id - src/bus/message.rs消息结构变更如 OutboundMessage 新增 session_id
- src/command/handlers/:命令处理器实现 - src/command/handlers/:命令处理器实现
## 16. 总结 ## 17. 总结
PicoBot 当前已经具备一个可长期运行 Agent 系统的关键组件: PicoBot 当前已经具备一个可长期运行 Agent 系统的关键组件:
- 有入口Gateway + Channel - 有入口Gateway + Channel
- 有状态SQLite + Session 恢复 - 有状态SQLite + Session 恢复
- 有能力:工具调用 + 技能系统 + MCP 扩展 + 可自定义子代理 - 有能力:工具调用 + 技能系统 + MCP 扩展 + 可自定义子代理
- 有角色:专家系统 + 能力策略(工具/技能/子代理黑白名单)+ 模型独立覆盖
- 有记忆:长期记忆 + 自动维护摘要 - 有记忆:长期记忆 + 自动维护摘要
- 有计划Scheduler + agent_task - 有计划Scheduler + agent_task

View File

@ -7,6 +7,7 @@ export const API = {
skills: '/api/skills', skills: '/api/skills',
skillsToggle: '/api/skills/toggle', skillsToggle: '/api/skills/toggle',
tools: '/api/tools', tools: '/api/tools',
modelOptions: '/api/model-options',
subagents: '/api/subagents', subagents: '/api/subagents',
subagentsToggle: '/api/subagents/toggle', subagentsToggle: '/api/subagents/toggle',
subagentsUpdate: '/api/subagents/update', subagentsUpdate: '/api/subagents/update',

View File

@ -1,10 +1,14 @@
import { API, apiGetSilent } from './client' import { API, apiGetSilent } from './client'
import type { ExpertListResponse, ExpertItem, CapabilityPolicy } from '../components/Settings/types' import type { ExpertListResponse, ExpertItem, CapabilityPolicy, ModelOptionsResponse } from '../components/Settings/types'
export function listExperts(): Promise<ExpertListResponse | null> { export function listExperts(): Promise<ExpertListResponse | null> {
return apiGetSilent<ExpertListResponse>(API.experts) return apiGetSilent<ExpertListResponse>(API.experts)
} }
export function listModelOptions(): Promise<ModelOptionsResponse | null> {
return apiGetSilent<ModelOptionsResponse>(API.modelOptions)
}
export async function toggleExpert(name: string, scope: string, enabled: boolean): Promise<Response> { export async function toggleExpert(name: string, scope: string, enabled: boolean): Promise<Response> {
return fetch(API.expertsToggle, { return fetch(API.expertsToggle, {
method: 'POST', method: 'POST',
@ -13,7 +17,7 @@ export async function toggleExpert(name: string, scope: string, enabled: boolean
}) })
} }
export async function createExpert(payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy }): Promise<Response> { export async function createExpert(payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy; provider?: string; model?: string }): Promise<Response> {
return fetch(API.expertsCreate, { return fetch(API.expertsCreate, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -21,7 +25,7 @@ export async function createExpert(payload: { name: string; description: string;
}) })
} }
export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy }): Promise<Response> { export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy; provider?: string; model?: string }): Promise<Response> {
return fetch(API.expertsUpdate, { return fetch(API.expertsUpdate, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@ -18,6 +18,8 @@ export async function updateSubagent(payload: {
description?: string description?: string
body?: string body?: string
capability?: CapabilityPolicy capability?: CapabilityPolicy
provider?: string
model?: string
}): Promise<Response> { }): Promise<Response> {
return fetch(API.subagentsUpdate, { return fetch(API.subagentsUpdate, {
method: 'PUT', method: 'PUT',

View File

@ -16,6 +16,7 @@ import type {
CapabilityPolicy, CapabilityPolicy,
KnownSource, KnownSource,
SchedulerConfig, ChannelConfig, SchedulerConfig, ChannelConfig,
ModelOptionsResponse,
} from './types' } from './types'
import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants' import { TABS, inputCls, selectCls, TIMEZONE_OPTIONS } from './constants'
import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader, CheckboxList, ModalHeader, ModalFooter } from './ui' import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader, CheckboxList, ModalHeader, ModalFooter } from './ui'
@ -23,7 +24,7 @@ import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../.
import { listSkills, toggleSkill } from '../../api/skills' import { listSkills, toggleSkill } from '../../api/skills'
import { listTools } from '../../api/tools' import { listTools } from '../../api/tools'
import { listSubagents, toggleSubagent, updateSubagent } from '../../api/subagents' import { listSubagents, toggleSubagent, updateSubagent } from '../../api/subagents'
import { listExperts, toggleExpert, createExpert, updateExpert, deleteExpert } from '../../api/experts' import { listExperts, toggleExpert, createExpert, updateExpert, deleteExpert, listModelOptions } from '../../api/experts'
import { getMcpStatus } from '../../api/mcp' import { getMcpStatus } from '../../api/mcp'
export { getSelectedExpert, selectExpert } from '../../api/experts' export { getSelectedExpert, selectExpert } from '../../api/experts'
@ -56,6 +57,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const [subagentListLoading, setSubagentListLoading] = useState(false) const [subagentListLoading, setSubagentListLoading] = useState(false)
const [expertList, setExpertList] = useState<ExpertListResponse | null>(null) const [expertList, setExpertList] = useState<ExpertListResponse | null>(null)
const [expertListLoading, setExpertListLoading] = useState(false) const [expertListLoading, setExpertListLoading] = useState(false)
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null)
const [editingExpert, setEditingExpert] = useState<{ const [editingExpert, setEditingExpert] = useState<{
mode: 'create' | 'edit' mode: 'create' | 'edit'
name?: string name?: string
@ -63,6 +65,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
nameField: string nameField: string
description: string description: string
body: string body: string
provider: string
model: string
allowedSkills: string[] allowedSkills: string[]
deniedSkills: string[] deniedSkills: string[]
allowedTools: string[] allowedTools: string[]
@ -75,6 +79,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
const [editingSubagent, setEditingSubagent] = useState<{ const [editingSubagent, setEditingSubagent] = useState<{
name: string name: string
description: string description: string
provider: string
model: string
allowedSkills: string[] allowedSkills: string[]
deniedSkills: string[] deniedSkills: string[]
allowedTools: string[] allowedTools: string[]
@ -119,7 +125,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
return toggleSubagent(name, scope, enabled) return toggleSubagent(name, scope, enabled)
}, []) }, [])
const updateSubagentCb = useCallback(async (payload: { name: string; description?: string; body?: string; capability?: CapabilityPolicy }) => { const updateSubagentCb = useCallback(async (payload: { name: string; description?: string; body?: string; capability?: CapabilityPolicy; provider?: string; model?: string }) => {
return updateSubagent(payload) return updateSubagent(payload)
}, []) }, [])
@ -134,11 +140,11 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
return toggleExpert(name, scope, enabled) return toggleExpert(name, scope, enabled)
}, []) }, [])
const createExpertCb = useCallback(async (payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy }) => { const createExpertCb = useCallback(async (payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy; provider?: string; model?: string }) => {
return createExpert(payload) return createExpert(payload)
}, []) }, [])
const updateExpertCb = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy }) => { const updateExpertCb = useCallback(async (payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy; provider?: string; model?: string }) => {
return updateExpert(payload) return updateExpert(payload)
}, []) }, [])
@ -175,13 +181,14 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
if (activeTab === 'subagents') fetchSubagentList() if (activeTab === 'subagents') fetchSubagentList()
}, [activeTab, fetchSubagentList]) }, [activeTab, fetchSubagentList])
// Fetch skills + tools when experts/subagents tab is selected (for capability CheckboxList) // Fetch skills + tools + model options when experts/subagents tab is selected (for capability CheckboxList & provider/model dropdowns)
useEffect(() => { useEffect(() => {
if (activeTab === 'experts' || activeTab === 'subagents') { if (activeTab === 'experts' || activeTab === 'subagents') {
if (!skillList) fetchSkillList() if (!skillList) fetchSkillList()
if (!toolList) fetchToolList() if (!toolList) fetchToolList()
if (!modelOptions) listModelOptions().then(data => { if (data) setModelOptions(data) })
} }
}, [activeTab, fetchSkillList, fetchToolList, skillList, toolList]) }, [activeTab, fetchSkillList, fetchToolList, skillList, toolList, modelOptions])
// experts tab 编辑专家时也需要子代理勾选列表按需加载subagents tab 由下方独立 useEffect 刷新) // experts tab 编辑专家时也需要子代理勾选列表按需加载subagents tab 由下方独立 useEffect 刷新)
useEffect(() => { useEffect(() => {
@ -707,6 +714,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
setEditingSubagent({ setEditingSubagent({
name: subagent.name, name: subagent.name,
description: subagent.description, description: subagent.description,
provider: subagent.provider ?? '',
model: subagent.model ?? '',
allowedSkills: subagent.capability?.allowed_skills ?? [], allowedSkills: subagent.capability?.allowed_skills ?? [],
deniedSkills: subagent.capability?.denied_skills ?? [], deniedSkills: subagent.capability?.denied_skills ?? [],
allowedTools: subagent.capability?.allowed_tools ?? [], allowedTools: subagent.capability?.allowed_tools ?? [],
@ -781,7 +790,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
<button <button
onClick={() => { onClick={() => {
setEditingExpertError('') setEditingExpertError('')
setEditingExpert({ mode: 'create', scope: 'project', nameField: '', description: '', body: '', allowedSkills: [], deniedSkills: [], allowedTools: [], deniedTools: [], allowedSubagents: [], deniedSubagents: [] }) setEditingExpert({ mode: 'create', scope: 'project', nameField: '', description: '', body: '', provider: '', model: '', allowedSkills: [], deniedSkills: [], allowedTools: [], deniedTools: [], allowedSubagents: [], deniedSubagents: [] })
}} }}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center" className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-dashed border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:border-[var(--accent-cyan)]/30 transition-colors text-sm w-full justify-center"
> >
@ -840,6 +849,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
nameField: expert.name, nameField: expert.name,
description: expert.description, description: expert.description,
body: expert.body ?? '', body: expert.body ?? '',
provider: expert.provider ?? '',
model: expert.model ?? '',
allowedSkills: expert.capability?.allowed_skills ?? [], allowedSkills: expert.capability?.allowed_skills ?? [],
deniedSkills: expert.capability?.denied_skills ?? [], deniedSkills: expert.capability?.denied_skills ?? [],
allowedTools: expert.capability?.allowed_tools ?? [], allowedTools: expert.capability?.allowed_tools ?? [],
@ -947,6 +958,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
description: editingExpert.description, description: editingExpert.description,
body: editingExpert.body, body: editingExpert.body,
capability, capability,
provider: editingExpert.provider || undefined,
model: editingExpert.model || undefined,
} }
const resp = isEdit const resp = isEdit
? await updateExpertCb({ ...payload, scope: 'project' }) ? await updateExpertCb({ ...payload, scope: 'project' })
@ -1008,6 +1021,28 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
/> />
</Field> </Field>
</SectionCard> </SectionCard>
<SectionCard title="模型配置" subtitle="留空继承默认">
<Field label="Provider" hint="留空继承默认配置">
<select
value={editingExpert.provider}
onChange={e => setEditingExpert(prev => prev ? { ...prev, provider: e.target.value } : prev)}
className={selectCls}
>
<option value=""></option>
{(modelOptions?.providers ?? []).map(p => <option key={p} value={p}>{p}</option>)}
</select>
</Field>
<Field label="Model" hint="留空继承默认配置">
<select
value={editingExpert.model}
onChange={e => setEditingExpert(prev => prev ? { ...prev, model: e.target.value } : prev)}
className={selectCls}
>
<option value=""></option>
{(modelOptions?.models ?? []).map(m => <option key={m} value={m}>{m}</option>)}
</select>
</Field>
</SectionCard>
<SectionCard title="技能能力" subtitle="白名单取交集,黑名单扣除"> <SectionCard title="技能能力" subtitle="白名单取交集,黑名单扣除">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的技能(白名单)" hint="留空表示不限"> <Field label="允许的技能(白名单)" hint="留空表示不限">
@ -1133,6 +1168,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
name: editingSubagent.name, name: editingSubagent.name,
description: editingSubagent.description, description: editingSubagent.description,
capability, capability,
provider: editingSubagent.provider || undefined,
model: editingSubagent.model || undefined,
}) })
const data = await resp.json().catch(() => ({})) const data = await resp.json().catch(() => ({}))
if (!resp.ok) { if (!resp.ok) {
@ -1179,6 +1216,28 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
/> />
</Field> </Field>
</SectionCard> </SectionCard>
<SectionCard title="模型配置" subtitle="留空继承默认">
<Field label="Provider" hint="留空继承默认配置">
<select
value={editingSubagent.provider}
onChange={e => setEditingSubagent(prev => prev ? { ...prev, provider: e.target.value } : prev)}
className={selectCls}
>
<option value=""></option>
{(modelOptions?.providers ?? []).map(p => <option key={p} value={p}>{p}</option>)}
</select>
</Field>
<Field label="Model" hint="留空继承默认配置">
<select
value={editingSubagent.model}
onChange={e => setEditingSubagent(prev => prev ? { ...prev, model: e.target.value } : prev)}
className={selectCls}
>
<option value=""></option>
{(modelOptions?.models ?? []).map(m => <option key={m} value={m}>{m}</option>)}
</select>
</Field>
</SectionCard>
<SectionCard title="技能能力" subtitle="白名单取交集,黑名单扣除"> <SectionCard title="技能能力" subtitle="白名单取交集,黑名单扣除">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="允许的技能(白名单)" hint="留空表示不限。仅勾选的 SKILL.md 技能可见"> <Field label="允许的技能(白名单)" hint="留空表示不限。仅勾选的 SKILL.md 技能可见">

View File

@ -52,6 +52,12 @@ export interface ToolsListResponse {
tools: ToolItem[] tools: ToolItem[]
} }
/** GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框) */
export interface ModelOptionsResponse {
providers: string[]
models: string[]
}
export interface CapabilityPolicy { export interface CapabilityPolicy {
allowed_skills?: string[] allowed_skills?: string[]
denied_skills: string[] denied_skills: string[]
@ -67,6 +73,8 @@ export interface SubagentItem {
source: string source: string
disabled_in_scopes: string[] disabled_in_scopes: string[]
capability?: CapabilityPolicy capability?: CapabilityPolicy
provider?: string
model?: string
} }
export interface SubagentListResponse { export interface SubagentListResponse {
@ -84,6 +92,8 @@ export interface ExpertItem {
body?: string body?: string
disabled_in_scopes: string[] disabled_in_scopes: string[]
capability?: CapabilityPolicy capability?: CapabilityPolicy
provider?: string
model?: string
} }
export interface ExpertListResponse { export interface ExpertListResponse {
experts_system_enabled: boolean experts_system_enabled: boolean