Compare commits
15 Commits
4baa8e7a6b
...
f8e1f3c2eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8e1f3c2eb | ||
|
|
7f05545488 | ||
|
|
92db80dc3f | ||
|
|
649ea2998d | ||
|
|
a825b10c48 | ||
|
|
5ff1e8455c | ||
|
|
dc9211548a | ||
|
|
003eab4f21 | ||
|
|
951e31aca4 | ||
|
|
c27efedb6c | ||
|
|
9381ed5dd4 | ||
|
|
3ea0c19262 | ||
|
|
89c444ad3f | ||
|
|
12f1094426 | ||
|
|
85c021f124 |
47
Cargo.lock
generated
47
Cargo.lock
generated
@ -1028,7 +1028,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots 1.0.7",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1636,7 +1635,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@ -1661,7 +1660,7 @@ dependencies = [
|
|||||||
"r2d2",
|
"r2d2",
|
||||||
"r2d2_sqlite",
|
"r2d2_sqlite",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.13.3",
|
"reqwest",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
@ -2026,44 +2025,6 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "reqwest"
|
|
||||||
version = "0.12.28"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"bytes",
|
|
||||||
"futures-core",
|
|
||||||
"http",
|
|
||||||
"http-body",
|
|
||||||
"http-body-util",
|
|
||||||
"hyper",
|
|
||||||
"hyper-rustls",
|
|
||||||
"hyper-util",
|
|
||||||
"js-sys",
|
|
||||||
"log",
|
|
||||||
"percent-encoding",
|
|
||||||
"pin-project-lite",
|
|
||||||
"quinn",
|
|
||||||
"rustls",
|
|
||||||
"rustls-pki-types",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serde_urlencoded",
|
|
||||||
"sync_wrapper",
|
|
||||||
"tokio",
|
|
||||||
"tokio-rustls",
|
|
||||||
"tower",
|
|
||||||
"tower-http",
|
|
||||||
"tower-service",
|
|
||||||
"url",
|
|
||||||
"wasm-bindgen",
|
|
||||||
"wasm-bindgen-futures",
|
|
||||||
"web-sys",
|
|
||||||
"webpki-roots 1.0.7",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
@ -2133,7 +2094,7 @@ dependencies = [
|
|||||||
"pastey",
|
"pastey",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"process-wrap",
|
"process-wrap",
|
||||||
"reqwest 0.13.3",
|
"reqwest",
|
||||||
"rmcp-macros",
|
"rmcp-macros",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@ -3363,7 +3324,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"md-5",
|
"md-5",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"reqwest 0.12.28",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
157
README.md
157
README.md
@ -142,7 +142,8 @@ Agent 执行层 (Agent) ◄─────────────────
|
|||||||
- src/client:WebSocket CLI 客户端实现
|
- src/client:WebSocket 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:某个绝对时间执行一次
|
||||||
- cron:cron 表达式调度
|
- cron:cron 表达式调度
|
||||||
|
|
||||||
### 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 执行能力,但路由
|
|||||||
- /ws:CLI 客户端连接入口
|
- /ws:CLI 客户端连接入口
|
||||||
- /: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
|
||||||
|
|
||||||
|
|||||||
117
docs/CHANGELOG.md
Normal file
117
docs/CHANGELOG.md
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
# 变更日志
|
||||||
|
|
||||||
|
本文件记录 Picobot 各版本的显著变更,遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 风格。
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-07-31
|
||||||
|
|
||||||
|
较 [0.1.2] 的 47 个 commit 迭代,聚焦 **能力策略**、**模型独立配置**、**话题级并发隔离** 与 **Agent Loop 性能优化** 四大方向。
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
|
||||||
|
#### 能力策略(CapabilityPolicy)统一模型
|
||||||
|
- 新增 `CapabilityPolicy` 领域模型,统一管理专家/子代理/技能的 `allowed_skills` / `denied_skills` / `allowed_tools` / `denied_tools` 四类白/黑名单。
|
||||||
|
- 专家和子代理的 Markdown frontmatter 支持上述四类字段,在 `create_agent` 时按策略过滤 `ToolRegistry`(快照语义)。
|
||||||
|
- 子代理支持数据驱动的工具过滤,删除旧版内置 `explore` 白名单。
|
||||||
|
- 新增 HTTP 端点 `GET /api/tools` 与 `PUT /api/subagents/update`,供前端动态加载工具清单与更新子代理配置。
|
||||||
|
- 前端引入通用 `CheckboxList` 勾选组件,将 capability 字段从自由文本 textarea 改为按来源分组的勾选式编辑,避免手输工具名。
|
||||||
|
|
||||||
|
#### 模型独立配置
|
||||||
|
- 专家和子代理的 Markdown frontmatter 新增 `provider` 与 `model` 可选字段,引用 `config.json` 中的 `providers` / `models` 表,可独立指定模型而不再继承主 agent。
|
||||||
|
- 主 agent 支持会话级模型覆盖(`provider` / `model` 在配置层覆盖)。
|
||||||
|
- 前端编辑模态框新增"模型配置"卡片,含 provider/model 下拉选择,选项由 `GET /api/model-options` 提供。
|
||||||
|
- 专家引用不存在的 provider/model 时阻断会话并报错;子代理引用失败时将错误回传给 LLM。
|
||||||
|
|
||||||
|
#### 话题级并发隔离
|
||||||
|
- 实现聊天消息串行锁,确保同一聊天的消息处理串行执行。
|
||||||
|
- 串行锁按 `topic_id` 键化,支持多话题并发执行,互不阻塞。
|
||||||
|
- `topic_id` 全程显式传递(从消息接收到历史加载),修复消息错投与并发阻塞。
|
||||||
|
- 内存历史缓存按 `topic_id` 键化(原按 `chat_id`),修复 DB 压缩时的 TOCTOU 覆盖问题。
|
||||||
|
|
||||||
|
#### 子代理嵌套深度配置
|
||||||
|
- 暴露 `tools.task.max_nesting_depth` 为可配置项(默认 2),前端"Task 子代理"卡片新增输入框。
|
||||||
|
- 保留双重防护:工具调用前校验 + 工具集构造时按深度移除 `task` 工具。
|
||||||
|
|
||||||
|
#### 专家配置热更新
|
||||||
|
- 专家配置保存后即时生效,无需重启网关。
|
||||||
|
|
||||||
|
#### 工具调用序列前向检查
|
||||||
|
- 添加工具调用序列的前向校验,确保 tool 消息紧随 assistant 的 tool_calls 消息,避免 OpenAI/DeepSeek API 400 错误。
|
||||||
|
|
||||||
|
#### 前端测试基础设施
|
||||||
|
- 引入 vitest 测试基础设施,添加 `useChat handleServerMessage` 特征测试。
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
|
||||||
|
#### Agent Loop 性能(P0)
|
||||||
|
- **工具定义缓存**:`get_definitions()` 从每轮迭代调用一次改为 `process()` 循环外构建一次。基于快照语义——单次 `process()` 内 `Arc<ToolRegistry>` 不变,专家切换只影响下一次 `create_agent`。
|
||||||
|
- **轻量 token 估算**:新增 `estimate_text_tokens_from_chat_messages`,直接基于 `ChatMessage` 累加字段长度,消除中间 `text_only_messages: Vec<Message>` 构造(原方案每条消息 × 5 字段双克隆)。
|
||||||
|
- **工具 token 估算循环外预算**:`serde_json::to_string` 全量序列化工具定义从每轮一次降为 `process()` 内一次。
|
||||||
|
- **DB 写 offload**:`PersistingEmittedMessageHandler` 用 `tokio::task::spawn_blocking` 包装 SQLite 同步写,解除 tokio worker 线程阻塞,不改变失败语义(仅记日志不阻断主流程)。
|
||||||
|
|
||||||
|
#### SessionPool 锁范围收敛
|
||||||
|
- `ensure_session_internal` 改为 double-checked locking:先短暂持锁检查存在性,释放锁后执行耗时的 session 创建(含配置加载、agent 工厂构造),再次持锁插入并处理竞态。避免跨 `session_factory.create().await` 持有全局锁导致所有 channel 的 session 访问串行化。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
#### 安全修复
|
||||||
|
- 修复 SSRF 重定向绕过漏洞。
|
||||||
|
- 修复符号链接路径遍历漏洞。
|
||||||
|
- 修复 `TodoItemSummary` 字段缺失。
|
||||||
|
|
||||||
|
#### 并发与稳定性
|
||||||
|
- `replace_todos` 用 `transaction()` 包裹 DELETE + INSERT,保证原子性——中途失败自动回滚,避免 todos 列表丢失。事务内复用同一连接查询返回值,消除 `drop(conn)` 后重新 `pool.get()` 的冗余。
|
||||||
|
- 修复话题隔离下 `topic_id` 全程显式传递,解决消息错投与并发阻塞。
|
||||||
|
- 修复内存历史按 `topic_id` 键化,新增 `replace_topic_history` 修复 DB 压缩覆盖。
|
||||||
|
- 修复 `http.rs` 重启信号吞错(P1)。
|
||||||
|
- 修复 prompt 注入吞错(P0)。
|
||||||
|
|
||||||
|
#### 前端修复
|
||||||
|
- 修复点击设置后白屏:React hooks 顺序违规(`useEffect` 放在条件 return 之后导致 #310 错误)。
|
||||||
|
- 修复 `useEffect` 依赖项缺失(P0)。
|
||||||
|
- 修复子智能体视图间歇性丢消息:同步前置 `subAgentViewRef` / `subAgentStackRef`。
|
||||||
|
- 修复子智能体工具消息去重:改为按 `id + type` 去重,修复工具状态卡在执行中。
|
||||||
|
- 修复高亮消息时退出子智能体视图的问题。
|
||||||
|
- 修复专家发现与前端选择多项问题。
|
||||||
|
- 兼容 `base_url` 末尾斜杠,避免拼接产生双斜杠导致 404。
|
||||||
|
- `TodoWriteTool` merge 模式内存为空时从 DB 回填,防止丢失旧项。
|
||||||
|
|
||||||
|
#### 依赖与构建
|
||||||
|
- 升级 wechatbot 的 `reqwest` 至 0.13,消除版本冲突。
|
||||||
|
- 将 `rmcp` 依赖从 git branch 迁移到 crates.io 版本。
|
||||||
|
- 提交 `Cargo.lock` 以保证二进制构建可复现(binary 项目约定)。
|
||||||
|
|
||||||
|
### 重构
|
||||||
|
|
||||||
|
#### 前端架构
|
||||||
|
- 重写 `useChat` 为组合根,消除上帝 Hook。
|
||||||
|
- 抽取 `useChat` 的领域子 hook 与 `messageMappers`。
|
||||||
|
- 建立前端 API 客户端层,消除硬编码路径与重复 `fetch`。
|
||||||
|
- 拆分 `ConfigPage.tsx` 与 `storage/mod.rs`(P2)。
|
||||||
|
- 清理 P0/P1 技术债(`any` 类型、React key、dead_code、硬编码 URL)。
|
||||||
|
|
||||||
|
#### 后端架构
|
||||||
|
- 重构 `AgentLoop` 中的消息处理逻辑,简化工具结果处理和 LLM 请求构建。
|
||||||
|
- 重构工具提示提供者,整合工具使用说明;删除冗余的 `TodoPromptProvider`。
|
||||||
|
- `get_current` / `save_topic` 直读 DB 加载消息,移除 `SessionManager` 依赖。
|
||||||
|
- 改用 `gray_matter` 解析 Markdown frontmatter,增加相应日志。
|
||||||
|
- 优化工具使用说明,简化记忆检索和写入规则。
|
||||||
|
- 移除未使用的 `cli_sessions` 变量,优化 `ws.rs` 中的代码。
|
||||||
|
|
||||||
|
### 内部改进
|
||||||
|
- 增加整理意识,确保同类工作输出到同一文件夹。
|
||||||
|
- 修复 MCP 日志输出。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.2] - 2026-07-28
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- WebSocket 连接泄漏修复,使用 `CancellationToken` 实现优雅重启。
|
||||||
|
- 交互式 Shell 会话管理。
|
||||||
|
- 数据库连接池优化会话存储,新增错误处理。
|
||||||
|
- 平台特定的进程输入等待检查,增强 Bash 工具的用户授权检测。
|
||||||
|
- 前端静态文件嵌入二进制。
|
||||||
|
- React Web UI 前端界面。
|
||||||
|
|
||||||
|
[0.2.0]: https://github.com/picobot/picobot/compare/v0.1.2...v0.2.0
|
||||||
|
[0.1.2]: https://github.com/picobot/picobot/releases/tag/v0.1.2
|
||||||
@ -155,10 +155,38 @@ fn estimate_tokens_from_serialized_json<T: serde::Serialize>(value: &T) -> usize
|
|||||||
as usize
|
as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 轻量 token 估算:直接从 ChatMessage 累加文本字段长度,
|
||||||
|
/// 避免对完整 Vec<Message> 做 serde_json::to_string(O(N) 序列化)。
|
||||||
|
/// 精度略低于完整 JSON 序列化(忽略 role/tool_call_id 等 overhead),
|
||||||
|
/// 但作为图片预算估算已足够——上下文窗口有 CONTEXT_INPUT_SAFETY_RATIO 兜底。
|
||||||
|
/// 与原 text_only_messages 路径等价覆盖:content + reasoning_content + tool_calls。
|
||||||
|
fn estimate_text_tokens_from_chat_messages(
|
||||||
|
messages: &[ChatMessage],
|
||||||
|
system_prompt: Option<&str>,
|
||||||
|
) -> usize {
|
||||||
|
let mut total_chars: usize = system_prompt.map(|s| s.len()).unwrap_or(0);
|
||||||
|
for m in messages {
|
||||||
|
total_chars += m.content.len();
|
||||||
|
if let Some(rc) = m.reasoning_content.as_ref() {
|
||||||
|
total_chars += rc.len();
|
||||||
|
}
|
||||||
|
if let Some(tcs) = m.tool_calls.as_ref() {
|
||||||
|
for tc in tcs {
|
||||||
|
total_chars += tc.name.len();
|
||||||
|
total_chars += serde_json::to_string(&tc.arguments)
|
||||||
|
.map(|s| s.len())
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
((total_chars.div_ceil(TOKEN_ESTIMATE_CHARS_PER_TOKEN) as f64)
|
||||||
|
* TOKEN_ESTIMATE_SAFETY_MULTIPLIER) as usize
|
||||||
|
}
|
||||||
|
|
||||||
fn image_token_budget_for_request(
|
fn image_token_budget_for_request(
|
||||||
runtime_config: &AgentRuntimeConfig,
|
runtime_config: &AgentRuntimeConfig,
|
||||||
text_only_messages: &[Message],
|
text_tokens: usize,
|
||||||
tools: Option<&Vec<crate::domain::tools::Tool>>,
|
tools_tokens: usize,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
let completion_reserve = runtime_config
|
let completion_reserve = runtime_config
|
||||||
.provider
|
.provider
|
||||||
@ -170,12 +198,7 @@ fn image_token_budget_for_request(
|
|||||||
.saturating_sub(completion_reserve);
|
.saturating_sub(completion_reserve);
|
||||||
let safe_input_window = (input_window as f64 * CONTEXT_INPUT_SAFETY_RATIO) as usize;
|
let safe_input_window = (input_window as f64 * CONTEXT_INPUT_SAFETY_RATIO) as usize;
|
||||||
|
|
||||||
let text_tokens = estimate_tokens_from_serialized_json(&text_only_messages)
|
safe_input_window.saturating_sub(text_tokens + tools_tokens)
|
||||||
+ tools
|
|
||||||
.map(estimate_tokens_from_serialized_json)
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
safe_input_window.saturating_sub(text_tokens)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn count_supported_image_media_refs(messages: &[ChatMessage]) -> usize {
|
fn count_supported_image_media_refs(messages: &[ChatMessage]) -> usize {
|
||||||
@ -700,23 +723,46 @@ impl<H: EmittedMessageHandler> PersistingEmittedMessageHandler<H> {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessageHandler<H> {
|
impl<H: EmittedMessageHandler> EmittedMessageHandler for PersistingEmittedMessageHandler<H> {
|
||||||
async fn handle(&self, message: ChatMessage) {
|
async fn handle(&self, message: ChatMessage) {
|
||||||
if let Err(e) = self.conversation_repository
|
// SQLite 是同步 I/O,直接调用会阻塞 tokio worker 线程。
|
||||||
.append_message_with_topic(&self.session_id, self.topic_id.as_deref(), &message)
|
// 用 spawn_blocking 把写操作挪到 blocking 线程池,不改变语义:
|
||||||
{
|
// 失败仅记日志不阻断主流程(与原 if let Err 一致)。
|
||||||
tracing::error!(error = %e, session_id = %self.session_id,
|
let repo = self.conversation_repository.clone();
|
||||||
"Failed to persist emitted message");
|
let session_id = self.session_id.clone();
|
||||||
}
|
let topic_id = self.topic_id.clone();
|
||||||
|
let msg_for_persist = message.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if let Err(e) = repo.append_message_with_topic(
|
||||||
|
&session_id,
|
||||||
|
topic_id.as_deref(),
|
||||||
|
&msg_for_persist,
|
||||||
|
) {
|
||||||
|
tracing::error!(error = %e, session_id = %session_id,
|
||||||
|
"Failed to persist emitted message");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok(); // JoinError 不影响主流程
|
||||||
self.inner.handle(message).await;
|
self.inner.handle(message).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option<u64>) {
|
async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option<u64>) {
|
||||||
// Persist the ChatMessage first (no duration field, same as before)
|
// Persist the ChatMessage first (no duration field, same as before)
|
||||||
if let Err(e) = self.conversation_repository
|
let repo = self.conversation_repository.clone();
|
||||||
.append_message_with_topic(&self.session_id, self.topic_id.as_deref(), &message)
|
let session_id = self.session_id.clone();
|
||||||
{
|
let topic_id = self.topic_id.clone();
|
||||||
tracing::error!(error = %e, session_id = %self.session_id,
|
let msg_for_persist = message.clone();
|
||||||
"Failed to persist emitted message");
|
tokio::task::spawn_blocking(move || {
|
||||||
}
|
if let Err(e) = repo.append_message_with_topic(
|
||||||
|
&session_id,
|
||||||
|
topic_id.as_deref(),
|
||||||
|
&msg_for_persist,
|
||||||
|
) {
|
||||||
|
tracing::error!(error = %e, session_id = %session_id,
|
||||||
|
"Failed to persist emitted message");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
self.inner.handle_tool_result(message, duration_ms).await;
|
self.inner.handle_tool_result(message, duration_ms).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -920,6 +966,23 @@ impl AgentLoop {
|
|||||||
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
|
let mut loop_detector = LoopDetector::new(LoopDetectorConfig::default());
|
||||||
let mut emitted_messages = Vec::new();
|
let mut emitted_messages = Vec::new();
|
||||||
|
|
||||||
|
// 工具集在 create_agent 时按当前专家 capability 过滤后注入(快照语义)。
|
||||||
|
// 单次 process() 期间专家切换不影响当前 Arc<ToolRegistry> 引用,
|
||||||
|
// 因此循环外构建一次 tool_defs,避免每轮重复读锁 + 全量 clone。
|
||||||
|
// 注意:若未来支持 process() 中途热切换专家(当前不支持),需重新评估。
|
||||||
|
let tool_defs = self.tools.get_definitions();
|
||||||
|
let tools = if tool_defs.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(tool_defs)
|
||||||
|
};
|
||||||
|
// 工具 token 估算在循环外算一次(tool_defs 在 process() 期间不变),
|
||||||
|
// 避免每轮 serde_json::to_string 全量序列化工具定义。
|
||||||
|
let tools_tokens = tools
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| estimate_tokens_from_serialized_json(t))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
for iteration in 0..self.max_iterations {
|
for iteration in 0..self.max_iterations {
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
tracing::debug!(iteration, "Agent iteration started");
|
tracing::debug!(iteration, "Agent iteration started");
|
||||||
@ -934,14 +997,6 @@ impl AgentLoop {
|
|||||||
return Ok(cancel);
|
return Ok(cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build request
|
|
||||||
let tool_defs = self.tools.get_definitions();
|
|
||||||
let tools = if tool_defs.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(tool_defs)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Defense-in-depth: sanitize incomplete tool call sequences
|
// Defense-in-depth: sanitize incomplete tool call sequences
|
||||||
// before EVERY LLM request, not just once at process() entry.
|
// before EVERY LLM request, not just once at process() entry.
|
||||||
// This catches edge cases where compression, persistence races,
|
// This catches edge cases where compression, persistence races,
|
||||||
@ -956,7 +1011,7 @@ impl AgentLoop {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = self.build_llm_request(&messages, system_prompt_context, tools);
|
let request = self.build_llm_request(&messages, system_prompt_context, tools.clone(), tools_tokens);
|
||||||
|
|
||||||
// Set up streaming delta consumer
|
// Set up streaming delta consumer
|
||||||
// Pre-generate the message ID so stream deltas and the final assistant
|
// Pre-generate the message ID so stream deltas and the final assistant
|
||||||
@ -1182,11 +1237,16 @@ impl AgentLoop {
|
|||||||
|
|
||||||
/// 构建 LLM 请求:过滤图片、构建系统提示、转换消息格式。
|
/// 构建 LLM 请求:过滤图片、构建系统提示、转换消息格式。
|
||||||
/// 统一了循环内调用和最终 summary 调用的请求构建逻辑。
|
/// 统一了循环内调用和最终 summary 调用的请求构建逻辑。
|
||||||
|
///
|
||||||
|
/// 优化:token 估算直接基于 ChatMessage 累加字段长度(轻量),
|
||||||
|
/// 不再构造中间 text_only_messages: Vec<Message>(避免 N 条消息 × 5 字段双克隆)。
|
||||||
|
/// 工具 token 估算在循环外预算后传入(tools_tokens)。
|
||||||
fn build_llm_request(
|
fn build_llm_request(
|
||||||
&self,
|
&self,
|
||||||
messages: &[ChatMessage],
|
messages: &[ChatMessage],
|
||||||
system_prompt_context: Option<&SystemPromptContext>,
|
system_prompt_context: Option<&SystemPromptContext>,
|
||||||
tools: Option<Vec<crate::domain::tools::Tool>>,
|
tools: Option<Vec<crate::domain::tools::Tool>>,
|
||||||
|
tools_tokens: usize,
|
||||||
) -> ChatCompletionRequest {
|
) -> ChatCompletionRequest {
|
||||||
let filtered_messages = filter_images_by_age_and_count(
|
let filtered_messages = filter_images_by_age_and_count(
|
||||||
messages,
|
messages,
|
||||||
@ -1201,18 +1261,18 @@ impl AgentLoop {
|
|||||||
.and_then(|provider| provider.build(ctx))
|
.and_then(|provider| provider.build(ctx))
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut text_only_messages: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
|
// 轻量 token 估算:直接从 ChatMessage 累加,无需构造中间 Vec<Message>
|
||||||
if let Some(ref prompt) = system_prompt {
|
let text_tokens = estimate_text_tokens_from_chat_messages(
|
||||||
text_only_messages.push(Message::system(prompt.content.clone()));
|
&filtered_messages,
|
||||||
}
|
system_prompt.as_ref().map(|p| p.content.as_str()),
|
||||||
text_only_messages.extend(filtered_messages.iter().map(chat_message_to_text_only_llm_message));
|
);
|
||||||
|
|
||||||
let image_tokens = image_token_budget_for_request(
|
let image_tokens = image_token_budget_for_request(
|
||||||
&self.runtime_config,
|
&self.runtime_config,
|
||||||
&text_only_messages,
|
text_tokens,
|
||||||
tools.as_ref(),
|
tools_tokens,
|
||||||
);
|
);
|
||||||
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
|
let mut image_budget = ImageInlineBudget::new(image_tokens, image_count);
|
||||||
|
|
||||||
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
|
let mut messages_for_llm: Vec<Message> = Vec::with_capacity(filtered_messages.len() + 2);
|
||||||
if let Some(ref prompt) = system_prompt {
|
if let Some(ref prompt) = system_prompt {
|
||||||
messages_for_llm.push(Message::system(prompt.content.clone()));
|
messages_for_llm.push(Message::system(prompt.content.clone()));
|
||||||
@ -1321,7 +1381,7 @@ impl AgentLoop {
|
|||||||
);
|
);
|
||||||
messages.push(summary_request);
|
messages.push(summary_request);
|
||||||
|
|
||||||
let request = self.build_llm_request(messages, system_prompt_context, None);
|
let request = self.build_llm_request(messages, system_prompt_context, None, 0);
|
||||||
|
|
||||||
// 最终 summary 调用也与取消信号竞速
|
// 最终 summary 调用也与取消信号竞速
|
||||||
let final_result: Result<
|
let final_result: Result<
|
||||||
|
|||||||
@ -1049,6 +1049,80 @@ impl std::fmt::Display for ConfigError {
|
|||||||
|
|
||||||
impl std::error::Error for ConfigError {}
|
impl std::error::Error for ConfigError {}
|
||||||
|
|
||||||
|
/// Provider/Model 解析器:按可选的 provider/model 名覆盖基础 LLMProviderConfig。
|
||||||
|
///
|
||||||
|
/// 用于专家和子代理独立配置模型:frontmatter 中的 `provider`/`model` 字段
|
||||||
|
/// 引用 `config.json` 的 `providers`/`models` 表,由本结构解析覆盖。
|
||||||
|
/// 保留 base 中的 agent 级参数(max_tool_iterations 等)和 image_context 配置。
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ModelResolver {
|
||||||
|
providers: HashMap<String, ProviderConfig>,
|
||||||
|
models: HashMap<String, ModelConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelResolver {
|
||||||
|
pub fn new(providers: HashMap<String, ProviderConfig>, models: HashMap<String, ModelConfig>) -> Self {
|
||||||
|
Self { providers, models }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Config 构造。
|
||||||
|
pub fn from_config(config: &Config) -> Self {
|
||||||
|
Self::new(config.providers.clone(), config.models.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按可选的 provider/model 名解析覆盖 base 中的对应部分。
|
||||||
|
///
|
||||||
|
/// - `provider_name=None` 时保留 base 的 provider 部分
|
||||||
|
/// - `model_name=None` 时保留 base 的 model 部分
|
||||||
|
/// - agent 级参数和 image_context 始终保留 base 的值
|
||||||
|
pub fn resolve(
|
||||||
|
&self,
|
||||||
|
provider_name: Option<&str>,
|
||||||
|
model_name: Option<&str>,
|
||||||
|
base: &LLMProviderConfig,
|
||||||
|
) -> Result<LLMProviderConfig, ConfigError> {
|
||||||
|
let mut result = base.clone();
|
||||||
|
|
||||||
|
if let Some(name) = provider_name.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
let provider = self
|
||||||
|
.providers
|
||||||
|
.get(name)
|
||||||
|
.ok_or(ConfigError::ProviderNotFound(name.to_string()))?;
|
||||||
|
result.provider_type = provider.provider_type.clone();
|
||||||
|
result.name = name.to_string();
|
||||||
|
result.base_url = provider.base_url.clone();
|
||||||
|
result.api_key = provider.api_key.clone();
|
||||||
|
result.extra_headers = provider.extra_headers.clone();
|
||||||
|
result.llm_timeout_secs = provider.llm_timeout_secs;
|
||||||
|
result.memory_maintenance_timeout_secs = provider.memory_maintenance_timeout_secs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = model_name.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
let model = self
|
||||||
|
.models
|
||||||
|
.get(name)
|
||||||
|
.ok_or(ConfigError::ModelNotFound(name.to_string()))?;
|
||||||
|
result.model_id = model.model_id.clone();
|
||||||
|
result.temperature = model.temperature;
|
||||||
|
result.max_tokens = model.max_tokens;
|
||||||
|
result.context_window_tokens = model.context_window_tokens;
|
||||||
|
result.model_extra = model.extra.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出所有可用的 provider 名(供前端下拉框)。
|
||||||
|
pub fn provider_names(&self) -> Vec<String> {
|
||||||
|
self.providers.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出所有可用的 model 名(供前端下拉框)。
|
||||||
|
pub fn model_names(&self) -> Vec<String> {
|
||||||
|
self.models.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_env_file() -> Result<(), Box<dyn std::error::Error>> {
|
fn load_env_file() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let env_path = Path::new(".env");
|
let env_path = Path::new(".env");
|
||||||
if env_path.exists() {
|
if env_path.exists() {
|
||||||
|
|||||||
@ -1,2 +1,82 @@
|
|||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// 角色能力策略:工具、技能、子代理的白/黑名单。全为空表示沿用默认(不过滤)。
|
||||||
|
///
|
||||||
|
/// 生效顺序:先白名单取交集,再黑名单扣除。专家与子代理共用此结构,
|
||||||
|
/// 确保语义一致。MCP 工具注册在 `ToolRegistry` 中(名 `mcp_*`),与内置
|
||||||
|
/// 工具同源,因此 `allowed_tools`/`denied_tools` 覆盖内置 + MCP 工具;
|
||||||
|
/// `allowed_skills`/`denied_skills` 仅覆盖 SKILL.md 技能;
|
||||||
|
/// `allowed_subagents`/`denied_subagents` 覆盖子代理加载(通过 ToolContext
|
||||||
|
/// 传递给 TaskTool,在 spawn/resume 时强制校验)。
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CapabilityPolicy {
|
||||||
|
/// 技能白名单:`None` = 不限;`Some(vec)` = 仅这些 SKILL.md 技能可见。
|
||||||
|
/// `Some(vec![])` 表示全禁(与 `ToolRegistry::only` 的空交集语义对齐)。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub allowed_skills: Option<Vec<String>>,
|
||||||
|
/// 技能黑名单:禁用这些 SKILL.md 技能。空表示不禁。
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub denied_skills: Vec<String>,
|
||||||
|
/// 工具白名单(含 `mcp_*` 工具):`None` = 不限。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub allowed_tools: Option<Vec<String>>,
|
||||||
|
/// 工具黑名单(含 `mcp_*` 工具)。
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub denied_tools: Vec<String>,
|
||||||
|
/// 子代理白名单:`None` = 不限;`Some(vec)` = 仅这些子代理可被加载。
|
||||||
|
/// 通过 ToolContext 传递给 TaskTool,在 spawn/resume 时强制校验。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub allowed_subagents: Option<Vec<String>>,
|
||||||
|
/// 子代理黑名单:禁止加载这些子代理。空表示不禁。
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub denied_subagents: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CapabilityPolicy {
|
||||||
|
/// 所有策略字段均为空 → 沿用主智能体默认配置(不过滤)。
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.allowed_skills.is_none()
|
||||||
|
&& self.denied_skills.is_empty()
|
||||||
|
&& self.allowed_tools.is_none()
|
||||||
|
&& self.denied_tools.is_empty()
|
||||||
|
&& self.allowed_subagents.is_none()
|
||||||
|
&& self.denied_subagents.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否声明了任何技能策略。
|
||||||
|
pub fn has_skill_policy(&self) -> bool {
|
||||||
|
self.allowed_skills.is_some() || !self.denied_skills.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否声明了任何工具策略。
|
||||||
|
pub fn has_tool_policy(&self) -> bool {
|
||||||
|
self.allowed_tools.is_some() || !self.denied_tools.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否声明了任何子代理策略。
|
||||||
|
pub fn has_subagent_policy(&self) -> bool {
|
||||||
|
self.allowed_subagents.is_some() || !self.denied_subagents.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。
|
||||||
|
pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> {
|
||||||
|
if let Some(list) = &self.allowed_subagents {
|
||||||
|
if !list.iter().any(|s| s == name) {
|
||||||
|
return Err(format!(
|
||||||
|
"subagent '{}' is not in the allowed_subagents whitelist",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.denied_subagents.iter().any(|s| s == name) {
|
||||||
|
return Err(format!(
|
||||||
|
"subagent '{}' is in the denied_subagents blacklist",
|
||||||
|
name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use crate::config::ExpertsConfig;
|
use crate::config::ExpertsConfig;
|
||||||
|
use crate::domain::CapabilityPolicy;
|
||||||
use crate::platform::{atomic_rename, home_dir as platform_home_dir};
|
use crate::platform::{atomic_rename, home_dir as platform_home_dir};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@ -22,6 +23,12 @@ pub struct Expert {
|
|||||||
pub body: String,
|
pub body: String,
|
||||||
pub source: ExpertSource,
|
pub source: ExpertSource,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
|
/// 工具与技能加载策略。全为空表示沿用主智能体默认配置(不过滤)。
|
||||||
|
pub capability: CapabilityPolicy,
|
||||||
|
/// 可选的 provider 名(引用 config.json 的 providers 表)。None 时继承主智能体。
|
||||||
|
pub provider: Option<String>,
|
||||||
|
/// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。
|
||||||
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where an expert definition was discovered from.
|
/// Where an expert definition was discovered from.
|
||||||
@ -87,6 +94,15 @@ pub struct ExpertWithStatus {
|
|||||||
pub path: String,
|
pub path: String,
|
||||||
/// Which scopes have this expert disabled. Empty means enabled.
|
/// Which scopes have this expert disabled. Empty means enabled.
|
||||||
pub disabled_in_scopes: Vec<String>,
|
pub disabled_in_scopes: Vec<String>,
|
||||||
|
/// 工具与技能加载策略。
|
||||||
|
#[serde(default)]
|
||||||
|
pub capability: CapabilityPolicy,
|
||||||
|
/// 可选的 provider 名(引用 config.json 的 providers 表)。None 时继承主智能体。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
/// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of an enable/disable operation.
|
/// Result of an enable/disable operation.
|
||||||
@ -324,6 +340,9 @@ impl ExpertRuntime {
|
|||||||
source: expert.source.as_str().to_string(),
|
source: expert.source.as_str().to_string(),
|
||||||
path: expert.path.display().to_string(),
|
path: expert.path.display().to_string(),
|
||||||
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
|
||||||
|
capability: expert.capability.clone(),
|
||||||
|
provider: expert.provider.clone(),
|
||||||
|
model: expert.model.clone(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -345,6 +364,9 @@ impl ExpertRuntime {
|
|||||||
name: &str,
|
name: &str,
|
||||||
description: &str,
|
description: &str,
|
||||||
body: &str,
|
body: &str,
|
||||||
|
capability: &CapabilityPolicy,
|
||||||
|
provider: &Option<String>,
|
||||||
|
model: &Option<String>,
|
||||||
reload: bool,
|
reload: bool,
|
||||||
) -> Result<Expert, String> {
|
) -> Result<Expert, String> {
|
||||||
validate_expert_name(name)?;
|
validate_expert_name(name)?;
|
||||||
@ -357,7 +379,7 @@ impl ExpertRuntime {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
write_expert_file(&path, name, description, body)?;
|
write_expert_file(&path, name, description, body, capability, provider, model)?;
|
||||||
let expert = parse_expert_file(&path, scope.into())?;
|
let expert = parse_expert_file(&path, scope.into())?;
|
||||||
if reload {
|
if reload {
|
||||||
let _ = self.reload()?;
|
let _ = self.reload()?;
|
||||||
@ -371,6 +393,9 @@ impl ExpertRuntime {
|
|||||||
name: &str,
|
name: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
body: Option<&str>,
|
body: Option<&str>,
|
||||||
|
capability: Option<&CapabilityPolicy>,
|
||||||
|
provider: Option<&Option<String>>,
|
||||||
|
model: Option<&Option<String>>,
|
||||||
reload: bool,
|
reload: bool,
|
||||||
) -> Result<Expert, String> {
|
) -> Result<Expert, String> {
|
||||||
validate_expert_name(name)?;
|
validate_expert_name(name)?;
|
||||||
@ -382,8 +407,11 @@ impl ExpertRuntime {
|
|||||||
let existing = parse_expert_file(&path, scope.into())?;
|
let existing = parse_expert_file(&path, scope.into())?;
|
||||||
let next_description = description.unwrap_or(&existing.description);
|
let next_description = description.unwrap_or(&existing.description);
|
||||||
let next_body = body.unwrap_or(&existing.body);
|
let next_body = body.unwrap_or(&existing.body);
|
||||||
|
let next_capability = capability.cloned().unwrap_or(existing.capability);
|
||||||
|
let next_provider = provider.cloned().unwrap_or(existing.provider);
|
||||||
|
let next_model = model.cloned().unwrap_or(existing.model);
|
||||||
|
|
||||||
write_expert_file(&path, name, next_description, next_body)?;
|
write_expert_file(&path, name, next_description, next_body, &next_capability, &next_provider, &next_model)?;
|
||||||
let expert = parse_expert_file(&path, scope.into())?;
|
let expert = parse_expert_file(&path, scope.into())?;
|
||||||
if reload {
|
if reload {
|
||||||
let _ = self.reload()?;
|
let _ = self.reload()?;
|
||||||
@ -720,9 +748,45 @@ struct ExpertFrontmatter {
|
|||||||
description: String,
|
description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
allowed_skills: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
denied_skills: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
allowed_tools: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
denied_tools: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
allowed_subagents: Option<Vec<String>>,
|
||||||
|
#[serde(default)]
|
||||||
|
denied_subagents: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
provider: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_expert_file(name: &str, description: &str, body: &str) -> Result<String, String> {
|
/// 规范化字符串列表:去除空白项与首尾空格。
|
||||||
|
fn normalize_string_list(list: Vec<String>) -> Vec<String> {
|
||||||
|
list.into_iter()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规范化可选字符串列表:`None` 保持 `None`(表示"不限"),`Some` 则清理空项。
|
||||||
|
fn normalize_optional_list(list: Option<Vec<String>>) -> Option<Vec<String>> {
|
||||||
|
list.map(normalize_string_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_expert_file(
|
||||||
|
name: &str,
|
||||||
|
description: &str,
|
||||||
|
body: &str,
|
||||||
|
capability: &CapabilityPolicy,
|
||||||
|
provider: &Option<String>,
|
||||||
|
model: &Option<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
if description.trim().is_empty() {
|
if description.trim().is_empty() {
|
||||||
return Err("description is required and cannot be empty".to_string());
|
return Err("description is required and cannot be empty".to_string());
|
||||||
}
|
}
|
||||||
@ -731,11 +795,35 @@ fn render_expert_file(name: &str, description: &str, body: &str) -> Result<Strin
|
|||||||
struct ExpertFrontmatterOwned {
|
struct ExpertFrontmatterOwned {
|
||||||
name: String,
|
name: String,
|
||||||
description: String,
|
description: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
allowed_skills: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
denied_skills: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
allowed_tools: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
denied_tools: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
allowed_subagents: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
denied_subagents: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
provider: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned {
|
let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
|
allowed_skills: capability.allowed_skills.clone(),
|
||||||
|
denied_skills: capability.denied_skills.clone(),
|
||||||
|
allowed_tools: capability.allowed_tools.clone(),
|
||||||
|
denied_tools: capability.denied_tools.clone(),
|
||||||
|
allowed_subagents: capability.allowed_subagents.clone(),
|
||||||
|
denied_subagents: capability.denied_subagents.clone(),
|
||||||
|
provider: provider.clone(),
|
||||||
|
model: model.clone(),
|
||||||
})
|
})
|
||||||
.map_err(|err| format!("failed to render expert frontmatter: {}", err))?;
|
.map_err(|err| format!("failed to render expert frontmatter: {}", err))?;
|
||||||
|
|
||||||
@ -748,8 +836,16 @@ fn render_expert_file(name: &str, description: &str, body: &str) -> Result<Strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_expert_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> {
|
fn write_expert_file(
|
||||||
let content = render_expert_file(name, description, body)?;
|
path: &Path,
|
||||||
|
name: &str,
|
||||||
|
description: &str,
|
||||||
|
body: &str,
|
||||||
|
capability: &CapabilityPolicy,
|
||||||
|
provider: &Option<String>,
|
||||||
|
model: &Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let content = render_expert_file(name, description, body, capability, provider, model)?;
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
.map_err(|err| format!("failed to create expert directory: {}", err))?;
|
.map_err(|err| format!("failed to create expert directory: {}", err))?;
|
||||||
@ -817,12 +913,33 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String
|
|||||||
|
|
||||||
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
|
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
|
||||||
|
|
||||||
|
let capability = CapabilityPolicy {
|
||||||
|
allowed_skills: normalize_optional_list(frontmatter.allowed_skills),
|
||||||
|
denied_skills: normalize_string_list(frontmatter.denied_skills),
|
||||||
|
allowed_tools: normalize_optional_list(frontmatter.allowed_tools),
|
||||||
|
denied_tools: normalize_string_list(frontmatter.denied_tools),
|
||||||
|
allowed_subagents: normalize_optional_list(frontmatter.allowed_subagents),
|
||||||
|
denied_subagents: normalize_string_list(frontmatter.denied_subagents),
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider = frontmatter
|
||||||
|
.provider
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let model = frontmatter
|
||||||
|
.model
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
Ok(Expert {
|
Ok(Expert {
|
||||||
name,
|
name,
|
||||||
description: description.to_string(),
|
description: description.to_string(),
|
||||||
body: body.trim().to_string(),
|
body: body.trim().to_string(),
|
||||||
source,
|
source,
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
|
capability,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1020,10 +1137,72 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_render_expert_file_requires_description() {
|
fn test_render_expert_file_requires_description() {
|
||||||
let err = render_expert_file("demo", " ", "body").unwrap_err();
|
let err = render_expert_file("demo", " ", "body", &CapabilityPolicy::default(), &None, &None).unwrap_err();
|
||||||
assert!(err.contains("description"));
|
assert!(err.contains("description"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capability_policy_is_empty_helpers() {
|
||||||
|
assert!(CapabilityPolicy::default().is_empty());
|
||||||
|
assert!(!CapabilityPolicy {
|
||||||
|
allowed_skills: Some(vec!["a".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_empty());
|
||||||
|
assert!(CapabilityPolicy {
|
||||||
|
denied_tools: vec![],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.is_empty());
|
||||||
|
assert!(CapabilityPolicy {
|
||||||
|
allowed_tools: Some(vec![]),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.has_tool_policy());
|
||||||
|
assert!(CapabilityPolicy {
|
||||||
|
denied_skills: vec!["x".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.has_skill_policy());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_and_render_capability_roundtrip() {
|
||||||
|
let policy = CapabilityPolicy {
|
||||||
|
allowed_skills: Some(vec!["skill_a".to_string(), "skill_b".to_string()]),
|
||||||
|
denied_skills: vec!["skill_c".to_string()],
|
||||||
|
allowed_tools: Some(vec!["read".to_string(), "mcp_fs_echo".to_string()]),
|
||||||
|
denied_tools: vec!["bash".to_string()],
|
||||||
|
allowed_subagents: None,
|
||||||
|
denied_subagents: vec![],
|
||||||
|
};
|
||||||
|
let rendered = render_expert_file("cap", "desc", "body", &policy, &None, &None).unwrap();
|
||||||
|
// 白名单/黑名单字段都应出现
|
||||||
|
assert!(rendered.contains("allowed_skills:"));
|
||||||
|
assert!(rendered.contains("denied_skills:"));
|
||||||
|
assert!(rendered.contains("allowed_tools:"));
|
||||||
|
assert!(rendered.contains("denied_tools:"));
|
||||||
|
assert!(rendered.contains("skill_a"));
|
||||||
|
assert!(rendered.contains("mcp_fs_echo"));
|
||||||
|
|
||||||
|
// 写入磁盘再读回,策略应一致
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let file_path = temp.path().join("EXPERT.md");
|
||||||
|
write_expert_file(&file_path, "cap", "desc", "body", &policy, &None, &None).unwrap();
|
||||||
|
let expert = parse_expert_file(&file_path, ExpertSource::Project).unwrap();
|
||||||
|
assert_eq!(expert.capability, policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_capability_omits_keys() {
|
||||||
|
// 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容
|
||||||
|
let rendered = render_expert_file("plain", "desc", "body", &CapabilityPolicy::default(), &None, &None).unwrap();
|
||||||
|
assert!(!rendered.contains("allowed_skills"));
|
||||||
|
assert!(!rendered.contains("denied_skills"));
|
||||||
|
assert!(!rendered.contains("allowed_tools"));
|
||||||
|
assert!(!rendered.contains("denied_tools"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_discover_prefers_project_over_user() {
|
fn test_discover_prefers_project_over_user() {
|
||||||
let _lock = acquire_test_lock();
|
let _lock = acquire_test_lock();
|
||||||
@ -1092,6 +1271,9 @@ mod tests {
|
|||||||
"translator",
|
"translator",
|
||||||
"翻译专家",
|
"翻译专家",
|
||||||
"你是一名专业翻译。",
|
"你是一名专业翻译。",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -1104,6 +1286,9 @@ mod tests {
|
|||||||
"translator",
|
"translator",
|
||||||
"dup",
|
"dup",
|
||||||
"body",
|
"body",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert!(dup.is_err());
|
assert!(dup.is_err());
|
||||||
@ -1114,6 +1299,9 @@ mod tests {
|
|||||||
"translator",
|
"translator",
|
||||||
Some("更新翻译专家"),
|
Some("更新翻译专家"),
|
||||||
Some("你是一名中文教师。"),
|
Some("你是一名中文教师。"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -1122,7 +1310,7 @@ mod tests {
|
|||||||
|
|
||||||
// update with None preserves fields
|
// update with None preserves fields
|
||||||
let updated_none = runtime
|
let updated_none = runtime
|
||||||
.update_expert(ExpertScope::Project, "translator", None, None, true)
|
.update_expert(ExpertScope::Project, "translator", None, None, None, None, None, true)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(updated_none.description, "更新翻译专家");
|
assert_eq!(updated_none.description, "更新翻译专家");
|
||||||
assert_eq!(updated_none.body, "你是一名中文教师。");
|
assert_eq!(updated_none.body, "你是一名中文教师。");
|
||||||
@ -1156,6 +1344,9 @@ mod tests {
|
|||||||
"coder",
|
"coder",
|
||||||
"编程专家",
|
"编程专家",
|
||||||
"你是一名编程专家。",
|
"你是一名编程专家。",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -1205,6 +1396,9 @@ mod tests {
|
|||||||
"writer",
|
"writer",
|
||||||
"写作专家",
|
"写作专家",
|
||||||
"你是一名写作专家。",
|
"你是一名写作专家。",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -1245,6 +1439,9 @@ mod tests {
|
|||||||
"reviewer",
|
"reviewer",
|
||||||
"代码审查专家",
|
"代码审查专家",
|
||||||
"你是一名代码审查专家。",
|
"你是一名代码审查专家。",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -1294,6 +1491,9 @@ mod tests {
|
|||||||
"planner",
|
"planner",
|
||||||
"规划专家",
|
"规划专家",
|
||||||
"你是一名规划专家。",
|
"你是一名规划专家。",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -1428,6 +1628,9 @@ mod tests {
|
|||||||
"teacher",
|
"teacher",
|
||||||
"教师专家",
|
"教师专家",
|
||||||
"你是一名中文教师,请用中文回答。",
|
"你是一名中文教师,请用中文回答。",
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -1465,6 +1668,9 @@ mod tests {
|
|||||||
"empty-body",
|
"empty-body",
|
||||||
"无 body 的专家",
|
"无 body 的专家",
|
||||||
"", // body empty
|
"", // body empty
|
||||||
|
&CapabilityPolicy::default(),
|
||||||
|
&None,
|
||||||
|
&None,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider};
|
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider};
|
||||||
use crate::config::LLMProviderConfig;
|
use crate::config::{LLMProviderConfig, ModelResolver};
|
||||||
|
use crate::domain::CapabilityPolicy;
|
||||||
use crate::experts::ExpertPromptProvider;
|
use crate::experts::ExpertPromptProvider;
|
||||||
use crate::experts::ExpertRuntime;
|
use crate::experts::ExpertRuntime;
|
||||||
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
|
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
|
||||||
|
use crate::gateway::model_selection::ModelSelectionStore;
|
||||||
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
|
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
|
||||||
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
use crate::skills::{SkillPromptProvider, SkillRuntime};
|
||||||
use crate::storage::persistent_session_id;
|
use crate::storage::persistent_session_id;
|
||||||
@ -32,9 +34,9 @@ pub(crate) fn build_system_prompt_provider(
|
|||||||
provider_config,
|
provider_config,
|
||||||
prompt_repository,
|
prompt_repository,
|
||||||
)),
|
)),
|
||||||
Box::new(SkillPromptProvider::new(skills)),
|
Box::new(SkillPromptProvider::new(skills, experts.clone())),
|
||||||
Box::new(ExpertPromptProvider::new(experts)),
|
Box::new(ExpertPromptProvider::new(experts.clone())),
|
||||||
Box::new(SubagentPromptProvider::new(subagent_runtime)),
|
Box::new(SubagentPromptProvider::new(subagent_runtime, experts)),
|
||||||
Box::new(ToolPromptProvider::new()),
|
Box::new(ToolPromptProvider::new()),
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
@ -47,6 +49,10 @@ pub(crate) struct AgentFactory {
|
|||||||
subagent_runtime: Arc<SubagentRuntime>,
|
subagent_runtime: Arc<SubagentRuntime>,
|
||||||
reinject_every: usize,
|
reinject_every: usize,
|
||||||
prompt_repository: Arc<dyn PromptInjectionRepository>,
|
prompt_repository: Arc<dyn PromptInjectionRepository>,
|
||||||
|
/// Provider/Model 解析器:按专家 frontmatter 中的 provider/model 字段覆盖基础配置
|
||||||
|
model_resolver: Arc<ModelResolver>,
|
||||||
|
/// per-session 的用户模型选择(最高优先级,覆盖专家配置)
|
||||||
|
model_selections: Arc<ModelSelectionStore>,
|
||||||
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
|
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
|
||||||
instance_id: u64,
|
instance_id: u64,
|
||||||
}
|
}
|
||||||
@ -72,6 +78,8 @@ impl AgentFactory {
|
|||||||
subagent_runtime: Arc<SubagentRuntime>,
|
subagent_runtime: Arc<SubagentRuntime>,
|
||||||
reinject_every: usize,
|
reinject_every: usize,
|
||||||
prompt_repository: Arc<dyn PromptInjectionRepository>,
|
prompt_repository: Arc<dyn PromptInjectionRepository>,
|
||||||
|
model_resolver: Arc<ModelResolver>,
|
||||||
|
model_selections: Arc<ModelSelectionStore>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
// 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例
|
// 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例
|
||||||
let instance_id = Arc::as_ptr(&tools) as u64;
|
let instance_id = Arc::as_ptr(&tools) as u64;
|
||||||
@ -87,6 +95,8 @@ impl AgentFactory {
|
|||||||
subagent_runtime,
|
subagent_runtime,
|
||||||
reinject_every,
|
reinject_every,
|
||||||
prompt_repository,
|
prompt_repository,
|
||||||
|
model_resolver,
|
||||||
|
model_selections,
|
||||||
instance_id,
|
instance_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -94,13 +104,67 @@ impl AgentFactory {
|
|||||||
pub(crate) fn create(&self, request: AgentBuildRequest<'_>) -> Result<AgentLoop, AgentError> {
|
pub(crate) fn create(&self, request: AgentBuildRequest<'_>) -> Result<AgentLoop, AgentError> {
|
||||||
let session_id = persistent_session_id(request.channel_name, request.session_chat_id);
|
let session_id = persistent_session_id(request.channel_name, request.session_chat_id);
|
||||||
|
|
||||||
|
// 读取所选专家(用于工具过滤 + 子代理策略 + 模型覆盖)
|
||||||
|
let expert = self.experts.selected_expert_for(&session_id);
|
||||||
|
let expert_capability = expert.as_ref().map(|e| e.capability.clone());
|
||||||
|
|
||||||
|
// 按专家 frontmatter 中的 provider/model 字段解析覆盖基础 provider_config。
|
||||||
|
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。
|
||||||
|
let expert_provider_config = match &expert {
|
||||||
|
Some(e) if e.provider.is_some() || e.model.is_some() => {
|
||||||
|
let resolved = self.model_resolver.resolve(
|
||||||
|
e.provider.as_deref(),
|
||||||
|
e.model.as_deref(),
|
||||||
|
&request.provider_config,
|
||||||
|
)
|
||||||
|
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||||
|
tracing::info!(
|
||||||
|
instance_id = self.instance_id,
|
||||||
|
session_id = %session_id,
|
||||||
|
expert = %e.name,
|
||||||
|
provider = %resolved.name,
|
||||||
|
model_id = %resolved.model_id,
|
||||||
|
"AgentFactory: applied expert model override"
|
||||||
|
);
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
_ => request.provider_config.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。
|
||||||
|
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。
|
||||||
|
let effective_provider_config =
|
||||||
|
match self.model_selections.get(&session_id) {
|
||||||
|
Some((user_provider, user_model))
|
||||||
|
if user_provider.is_some() || user_model.is_some() =>
|
||||||
|
{
|
||||||
|
let resolved = self
|
||||||
|
.model_resolver
|
||||||
|
.resolve(
|
||||||
|
user_provider.as_deref(),
|
||||||
|
user_model.as_deref(),
|
||||||
|
&expert_provider_config,
|
||||||
|
)
|
||||||
|
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||||
|
tracing::info!(
|
||||||
|
instance_id = self.instance_id,
|
||||||
|
session_id = %session_id,
|
||||||
|
provider = %resolved.name,
|
||||||
|
model_id = %resolved.model_id,
|
||||||
|
"AgentFactory: applied user model override"
|
||||||
|
);
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
_ => expert_provider_config,
|
||||||
|
};
|
||||||
|
|
||||||
// 诊断日志:记录 agent 实际使用的配置和实例 ID
|
// 诊断日志:记录 agent 实际使用的配置和实例 ID
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
instance_id = self.instance_id,
|
instance_id = self.instance_id,
|
||||||
channel = %request.channel_name,
|
channel = %request.channel_name,
|
||||||
session_id = %session_id,
|
session_id = %session_id,
|
||||||
provider = %request.provider_config.name,
|
provider = %effective_provider_config.name,
|
||||||
model_id = %request.provider_config.model_id,
|
model_id = %effective_provider_config.model_id,
|
||||||
tool_count = self.tools.tool_names().len(),
|
tool_count = self.tools.tool_names().len(),
|
||||||
"AgentFactory: creating agent with config"
|
"AgentFactory: creating agent with config"
|
||||||
);
|
);
|
||||||
@ -108,16 +172,35 @@ impl AgentFactory {
|
|||||||
// 创建组合的系统提示词提供者(与命令侧 /save 等共享同一构建逻辑)
|
// 创建组合的系统提示词提供者(与命令侧 /save 等共享同一构建逻辑)
|
||||||
let system_prompt_provider = build_system_prompt_provider(
|
let system_prompt_provider = build_system_prompt_provider(
|
||||||
self.reinject_every,
|
self.reinject_every,
|
||||||
request.provider_config.clone(),
|
effective_provider_config.clone(),
|
||||||
self.prompt_repository.clone(),
|
self.prompt_repository.clone(),
|
||||||
self.skills.clone(),
|
self.skills.clone(),
|
||||||
self.experts.clone(),
|
self.experts.clone(),
|
||||||
self.subagent_runtime.clone(),
|
self.subagent_runtime.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 按所选专家的工具策略过滤工具集(含内置 + MCP 工具)。
|
||||||
|
// 无专家或专家未声明工具策略时,复用共享的 Arc<ToolRegistry>(零拷贝)。
|
||||||
|
let base_tool_count = self.tools.tool_names().len();
|
||||||
|
let tools: Arc<ToolRegistry> = match &expert_capability {
|
||||||
|
Some(cap) if cap.has_tool_policy() => {
|
||||||
|
let filtered = self.build_filtered_registry(cap);
|
||||||
|
let filtered_count = filtered.tool_names().len();
|
||||||
|
tracing::info!(
|
||||||
|
instance_id = self.instance_id,
|
||||||
|
session_id = %session_id,
|
||||||
|
base_tool_count,
|
||||||
|
filtered_tool_count = filtered_count,
|
||||||
|
"AgentFactory: applied expert tool policy"
|
||||||
|
);
|
||||||
|
Arc::new(filtered)
|
||||||
|
}
|
||||||
|
_ => self.tools.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
AgentLoop::with_tools_and_system_prompt_provider(
|
AgentLoop::with_tools_and_system_prompt_provider(
|
||||||
request.provider_config,
|
effective_provider_config,
|
||||||
self.tools.clone(),
|
tools,
|
||||||
system_prompt_provider,
|
system_prompt_provider,
|
||||||
Some(self.skills.clone()),
|
Some(self.skills.clone()),
|
||||||
)
|
)
|
||||||
@ -139,6 +222,8 @@ impl AgentFactory {
|
|||||||
task_id: None,
|
task_id: None,
|
||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
|
// 注入专家 capability,TaskTool 据此强制校验子代理白/黑名单
|
||||||
|
parent_capability: expert_capability.clone(),
|
||||||
});
|
});
|
||||||
// 如果有取消信号接收端,注入 Agent
|
// 如果有取消信号接收端,注入 Agent
|
||||||
if let Some(token) = request.cancel_token {
|
if let Some(token) = request.cancel_token {
|
||||||
@ -147,4 +232,25 @@ impl AgentFactory {
|
|||||||
agent
|
agent
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按专家 CapabilityPolicy 构建过滤后的 ToolRegistry 副本。
|
||||||
|
/// 生效顺序:先白名单取交集,再黑名单扣除(与 subagent filter_tool_registry 语义一致)。
|
||||||
|
/// 底层工具为 Arc<dyn ToolTrait>,克隆廉价。
|
||||||
|
fn build_filtered_registry(&self, policy: &CapabilityPolicy) -> ToolRegistry {
|
||||||
|
// 1. 白名单(取交集);None 表示不限,复制一份以便后续黑名单过滤
|
||||||
|
let after_allow: ToolRegistry = match &policy.allowed_tools {
|
||||||
|
Some(allowed) => {
|
||||||
|
let refs: Vec<&str> = allowed.iter().map(|s| s.as_str()).collect();
|
||||||
|
self.tools.only(&refs)
|
||||||
|
}
|
||||||
|
None => self.tools.without(&[]),
|
||||||
|
};
|
||||||
|
// 2. 黑名单(扣除)
|
||||||
|
if policy.denied_tools.is_empty() {
|
||||||
|
after_allow
|
||||||
|
} else {
|
||||||
|
let refs: Vec<&str> = policy.denied_tools.iter().map(|s| s.as_str()).collect();
|
||||||
|
after_allow.without(&refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use super::GatewayState;
|
use super::GatewayState;
|
||||||
use crate::config::{Config, get_default_config_path};
|
use crate::config::{Config, get_default_config_path};
|
||||||
|
use crate::domain::CapabilityPolicy;
|
||||||
use crate::experts::{Expert, ExpertScope, ExpertWithStatus};
|
use crate::experts::{Expert, ExpertScope, ExpertWithStatus};
|
||||||
use crate::skills::SkillWithStatus;
|
use crate::skills::SkillWithStatus;
|
||||||
use crate::tools::task::runtime::{SubagentScope, SubagentWithStatus};
|
use crate::tools::task::runtime::{SubagentScope, SubagentWithStatus};
|
||||||
@ -248,6 +249,93 @@ pub async fn skills_list(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ToolInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
/// "builtin" 或 "mcp:{server_key}"
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ToolsListResponse {
|
||||||
|
pub total: usize,
|
||||||
|
pub tools: Vec<ToolInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框)。
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ModelOptionsResponse {
|
||||||
|
pub providers: Vec<String>,
|
||||||
|
pub models: Vec<String>,
|
||||||
|
/// 当前默认 agent 的 provider/model 名(来自 config.json agents.default)。
|
||||||
|
/// 前端用于在"继承默认"选项旁标注当前生效的模型。
|
||||||
|
pub current: CurrentModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CurrentModel {
|
||||||
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source.
|
||||||
|
/// 通过 SessionManager::tools() 只读访问 ToolRegistry,不修改状态。
|
||||||
|
pub async fn tools_list(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
) -> Json<ToolsListResponse> {
|
||||||
|
let registry = state.session_manager.tools();
|
||||||
|
let tools: Vec<ToolInfo> = registry
|
||||||
|
.get_definitions()
|
||||||
|
.into_iter()
|
||||||
|
.map(|t| {
|
||||||
|
let source = if t.function.name.starts_with("mcp_") {
|
||||||
|
// mcp_{server}_{tool} → mcp:{server}
|
||||||
|
let parts: Vec<&str> = t.function.name.splitn(3, '_').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
format!("mcp:{}", parts[1])
|
||||||
|
} else {
|
||||||
|
"mcp".to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"builtin".to_string()
|
||||||
|
};
|
||||||
|
ToolInfo {
|
||||||
|
name: t.function.name,
|
||||||
|
description: t.function.description,
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let total = tools.len();
|
||||||
|
Json(ToolsListResponse { total, tools })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。
|
||||||
|
pub async fn model_options(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
) -> Json<ModelOptionsResponse> {
|
||||||
|
let config = state.config.read().await;
|
||||||
|
let resolver = crate::config::ModelResolver::from_config(&config);
|
||||||
|
// 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key)
|
||||||
|
let current = if let Some(agent) = config.agents.get("default") {
|
||||||
|
CurrentModel {
|
||||||
|
provider: agent.provider.clone(),
|
||||||
|
model: agent.model.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CurrentModel {
|
||||||
|
provider: String::new(),
|
||||||
|
model: String::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Json(ModelOptionsResponse {
|
||||||
|
providers: resolver.provider_names(),
|
||||||
|
models: resolver.model_names(),
|
||||||
|
current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/skills/toggle — Enable or disable a specific skill
|
/// POST /api/skills/toggle — Enable or disable a specific skill
|
||||||
pub async fn skills_toggle(
|
pub async fn skills_toggle(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
@ -423,6 +511,77 @@ pub async fn subagents_toggle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SubagentUpdateRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub capability: Option<CapabilityPolicy>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SubagentUpdateResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub subagent: Option<SubagentWithStatus>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/subagents/update — Update subagent capability/description/body (writes back SUBAGENT.md)
|
||||||
|
pub async fn subagents_update(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
Json(req): Json<SubagentUpdateRequest>,
|
||||||
|
) -> Result<Json<SubagentUpdateResponse>, (StatusCode, String)> {
|
||||||
|
let updated = state
|
||||||
|
.subagent_runtime
|
||||||
|
.update_subagent(
|
||||||
|
&req.name,
|
||||||
|
req.description.as_deref(),
|
||||||
|
req.body.as_deref(),
|
||||||
|
req.capability.as_ref(),
|
||||||
|
Some(&req.provider),
|
||||||
|
Some(&req.model),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.map_err(|err| {
|
||||||
|
let status = if err.contains("not found") {
|
||||||
|
StatusCode::NOT_FOUND
|
||||||
|
} else if err.contains("builtin") {
|
||||||
|
StatusCode::BAD_REQUEST
|
||||||
|
} else {
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
};
|
||||||
|
(status, err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 返回更新后的状态(含 disabled_in_scopes)
|
||||||
|
let status = state
|
||||||
|
.subagent_runtime
|
||||||
|
.list_with_status()
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.name == updated.name)
|
||||||
|
.unwrap_or_else(|| SubagentWithStatus {
|
||||||
|
name: updated.name.clone(),
|
||||||
|
description: updated.description.clone(),
|
||||||
|
source: updated.source.as_str().to_string(),
|
||||||
|
disabled_in_scopes: vec![],
|
||||||
|
capability: updated.capability.clone(),
|
||||||
|
provider: updated.provider.clone(),
|
||||||
|
model: updated.model.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(SubagentUpdateResponse {
|
||||||
|
success: true,
|
||||||
|
subagent: Some(status),
|
||||||
|
error: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ===================== Experts =====================
|
// ===================== Experts =====================
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -458,6 +617,12 @@ pub struct ExpertCreateRequest {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub scope: String,
|
pub scope: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub capability: CapabilityPolicy,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -466,6 +631,12 @@ pub struct ExpertUpdateRequest {
|
|||||||
pub scope: String,
|
pub scope: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub body: Option<String>,
|
pub body: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub capability: Option<CapabilityPolicy>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -498,6 +669,23 @@ pub struct ExpertSelectResponse {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /api/session/select-model 请求体
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SelectModelRequest {
|
||||||
|
pub session_id: String,
|
||||||
|
/// None 或空字符串表示清除覆盖(继承默认)
|
||||||
|
pub provider: Option<String>,
|
||||||
|
/// None 或空字符串表示清除覆盖(继承默认)
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SelectModelResponse {
|
||||||
|
pub success: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct ExpertResponse {
|
pub struct ExpertResponse {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@ -505,6 +693,12 @@ pub struct ExpertResponse {
|
|||||||
pub body: String,
|
pub body: String,
|
||||||
pub source: String,
|
pub source: String,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub capability: CapabilityPolicy,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Expert> for ExpertResponse {
|
impl From<Expert> for ExpertResponse {
|
||||||
@ -515,6 +709,9 @@ impl From<Expert> for ExpertResponse {
|
|||||||
body: expert.body,
|
body: expert.body,
|
||||||
source: expert.source.as_str().to_string(),
|
source: expert.source.as_str().to_string(),
|
||||||
path: expert.path.display().to_string(),
|
path: expert.path.display().to_string(),
|
||||||
|
capability: expert.capability,
|
||||||
|
provider: expert.provider,
|
||||||
|
model: expert.model,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -625,7 +822,16 @@ pub async fn experts_create(
|
|||||||
|
|
||||||
let expert = state
|
let expert = state
|
||||||
.experts
|
.experts
|
||||||
.create_expert(scope, &req.name, &req.description, &req.body, true)
|
.create_expert(
|
||||||
|
scope,
|
||||||
|
&req.name,
|
||||||
|
&req.description,
|
||||||
|
&req.body,
|
||||||
|
&req.capability,
|
||||||
|
&req.provider,
|
||||||
|
&req.model,
|
||||||
|
true,
|
||||||
|
)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
let status = if err.contains("already exists") {
|
let status = if err.contains("already exists") {
|
||||||
StatusCode::CONFLICT
|
StatusCode::CONFLICT
|
||||||
@ -653,6 +859,9 @@ pub async fn experts_update(
|
|||||||
&req.name,
|
&req.name,
|
||||||
req.description.as_deref(),
|
req.description.as_deref(),
|
||||||
req.body.as_deref(),
|
req.body.as_deref(),
|
||||||
|
req.capability.as_ref(),
|
||||||
|
Some(&req.provider),
|
||||||
|
Some(&req.model),
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
@ -759,3 +968,81 @@ pub async fn experts_select(
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /api/session/select-model — 设置(或清除)session 的用户模型覆盖
|
||||||
|
pub async fn session_select_model(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
Json(req): Json<SelectModelRequest>,
|
||||||
|
) -> (StatusCode, Json<SelectModelResponse>) {
|
||||||
|
// 规范化:trim 后空字符串视为 None(与 frontmatter 解析逻辑一致)
|
||||||
|
let provider = req
|
||||||
|
.provider
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
let model = req
|
||||||
|
.model
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
// 校验:provider/model 名必须在 config 的 providers/models 表中存在
|
||||||
|
// (与 AgentFactory::create 中的解析失败行为对齐,提前反馈错误)
|
||||||
|
let config = state.config.read().await;
|
||||||
|
if let Some(name) = provider.as_ref() {
|
||||||
|
if !config.providers.contains_key(name) {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(SelectModelResponse {
|
||||||
|
success: false,
|
||||||
|
error: Some(format!("provider '{}' not found in config", name)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(name) = model.as_ref() {
|
||||||
|
if !config.models.contains_key(name) {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(SelectModelResponse {
|
||||||
|
success: false,
|
||||||
|
error: Some(format!("model '{}' not found in config", name)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
state
|
||||||
|
.model_selections
|
||||||
|
.set(&req.session_id, provider, model);
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(SelectModelResponse {
|
||||||
|
success: true,
|
||||||
|
error: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/session/selected-model?session_id=... — 返回该 session 当前的用户模型覆盖
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SessionSelectedModelQuery {
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SessionSelectedModelResponse {
|
||||||
|
/// None 表示未设置用户覆盖(继承默认 / 专家配置)
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn session_selected_model(
|
||||||
|
State(state): State<Arc<GatewayState>>,
|
||||||
|
Query(q): Query<SessionSelectedModelQuery>,
|
||||||
|
) -> Json<SessionSelectedModelResponse> {
|
||||||
|
let (provider, model) = state
|
||||||
|
.model_selections
|
||||||
|
.get(&q.session_id)
|
||||||
|
.unwrap_or((None, None));
|
||||||
|
Json(SessionSelectedModelResponse { provider, model })
|
||||||
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ pub mod http;
|
|||||||
pub mod memory_maintenance;
|
pub mod memory_maintenance;
|
||||||
pub mod memory_maintenance_coordinator;
|
pub mod memory_maintenance_coordinator;
|
||||||
pub mod message_prepare;
|
pub mod message_prepare;
|
||||||
|
pub mod model_selection;
|
||||||
pub mod outbound_dispatcher;
|
pub mod outbound_dispatcher;
|
||||||
pub mod processor;
|
pub mod processor;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
@ -67,6 +68,8 @@ pub struct GatewayState {
|
|||||||
pub skills: Arc<SkillRuntime>,
|
pub skills: Arc<SkillRuntime>,
|
||||||
pub experts: Arc<crate::experts::ExpertRuntime>,
|
pub experts: Arc<crate::experts::ExpertRuntime>,
|
||||||
pub subagent_runtime: Arc<SubagentRuntime>,
|
pub subagent_runtime: Arc<SubagentRuntime>,
|
||||||
|
/// per-session 的用户模型选择(覆盖专家配置)
|
||||||
|
pub model_selections: Arc<model_selection::ModelSelectionStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayState {
|
impl GatewayState {
|
||||||
@ -92,7 +95,7 @@ impl GatewayState {
|
|||||||
mcp_servers: config.mcp_servers.clone(),
|
mcp_servers: config.mcp_servers.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (session_manager, task_repository, mcp_manager, subagent_runtime) = build_session_manager_with_sender(
|
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) = build_session_manager_with_sender(
|
||||||
agent_prompt_reinject_every,
|
agent_prompt_reinject_every,
|
||||||
show_tool_results,
|
show_tool_results,
|
||||||
config.time.timezone.clone(),
|
config.time.timezone.clone(),
|
||||||
@ -108,6 +111,7 @@ impl GatewayState {
|
|||||||
session_ttl_hours,
|
session_ttl_hours,
|
||||||
mcp_config,
|
mcp_config,
|
||||||
Some(bus.clone()),
|
Some(bus.clone()),
|
||||||
|
Arc::new(crate::config::ModelResolver::from_config(&config)),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态)
|
// 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态)
|
||||||
@ -130,6 +134,7 @@ impl GatewayState {
|
|||||||
skills,
|
skills,
|
||||||
experts,
|
experts,
|
||||||
subagent_runtime,
|
subagent_runtime,
|
||||||
|
model_selections,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,8 +246,11 @@ pub async fn run(
|
|||||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||||
.route("/api/skills", routing::get(http::skills_list))
|
.route("/api/skills", routing::get(http::skills_list))
|
||||||
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
||||||
|
.route("/api/tools", routing::get(http::tools_list))
|
||||||
|
.route("/api/model-options", routing::get(http::model_options))
|
||||||
.route("/api/subagents", routing::get(http::subagents_list))
|
.route("/api/subagents", routing::get(http::subagents_list))
|
||||||
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
||||||
|
.route("/api/subagents/update", routing::put(http::subagents_update))
|
||||||
.route("/api/experts", routing::get(http::experts_list))
|
.route("/api/experts", routing::get(http::experts_list))
|
||||||
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
||||||
.route("/api/experts/create", routing::post(http::experts_create))
|
.route("/api/experts/create", routing::post(http::experts_create))
|
||||||
@ -250,6 +258,8 @@ pub async fn run(
|
|||||||
.route("/api/experts/delete", routing::delete(http::experts_delete))
|
.route("/api/experts/delete", routing::delete(http::experts_delete))
|
||||||
.route("/api/experts/selected", routing::get(http::experts_selected))
|
.route("/api/experts/selected", routing::get(http::experts_selected))
|
||||||
.route("/api/experts/select", routing::post(http::experts_select))
|
.route("/api/experts/select", routing::post(http::experts_select))
|
||||||
|
.route("/api/session/select-model", routing::post(http::session_select_model))
|
||||||
|
.route("/api/session/selected-model", routing::get(http::session_selected_model))
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
.fallback(static_handler)
|
.fallback(static_handler)
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
@ -262,8 +272,11 @@ pub async fn run(
|
|||||||
.route("/api/mcp/status", routing::get(http::mcp_status))
|
.route("/api/mcp/status", routing::get(http::mcp_status))
|
||||||
.route("/api/skills", routing::get(http::skills_list))
|
.route("/api/skills", routing::get(http::skills_list))
|
||||||
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
.route("/api/skills/toggle", routing::post(http::skills_toggle))
|
||||||
|
.route("/api/tools", routing::get(http::tools_list))
|
||||||
|
.route("/api/model-options", routing::get(http::model_options))
|
||||||
.route("/api/subagents", routing::get(http::subagents_list))
|
.route("/api/subagents", routing::get(http::subagents_list))
|
||||||
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
.route("/api/subagents/toggle", routing::post(http::subagents_toggle))
|
||||||
|
.route("/api/subagents/update", routing::put(http::subagents_update))
|
||||||
.route("/api/experts", routing::get(http::experts_list))
|
.route("/api/experts", routing::get(http::experts_list))
|
||||||
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
.route("/api/experts/toggle", routing::post(http::experts_toggle))
|
||||||
.route("/api/experts/create", routing::post(http::experts_create))
|
.route("/api/experts/create", routing::post(http::experts_create))
|
||||||
@ -271,6 +284,8 @@ pub async fn run(
|
|||||||
.route("/api/experts/delete", routing::delete(http::experts_delete))
|
.route("/api/experts/delete", routing::delete(http::experts_delete))
|
||||||
.route("/api/experts/selected", routing::get(http::experts_selected))
|
.route("/api/experts/selected", routing::get(http::experts_selected))
|
||||||
.route("/api/experts/select", routing::post(http::experts_select))
|
.route("/api/experts/select", routing::post(http::experts_select))
|
||||||
|
.route("/api/session/select-model", routing::post(http::session_select_model))
|
||||||
|
.route("/api/session/selected-model", routing::get(http::session_selected_model))
|
||||||
.route("/ws", routing::get(ws::ws_handler))
|
.route("/ws", routing::get(ws::ws_handler))
|
||||||
.fallback_service(ServeDir::new(&static_dir))
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(state.clone())
|
.with_state(state.clone())
|
||||||
|
|||||||
84
src/gateway/model_selection.rs
Normal file
84
src/gateway/model_selection.rs
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
/// per-session 的用户模型覆盖选择存储。
|
||||||
|
///
|
||||||
|
/// 与 ExpertRuntime 的 session_experts 平级独立,职责单一:
|
||||||
|
/// 只负责存储 session_id -> (provider, model) 的映射,不依赖任何业务模块。
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ModelSelectionStore {
|
||||||
|
selections: RwLock<HashMap<String, (Option<String>, Option<String>)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelSelectionStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
|
||||||
|
pub fn set(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
provider: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
) {
|
||||||
|
let mut selections = self
|
||||||
|
.selections
|
||||||
|
.write()
|
||||||
|
.expect("model selections rwlock poisoned");
|
||||||
|
if provider.is_none() && model.is_none() {
|
||||||
|
selections.remove(session_id);
|
||||||
|
} else {
|
||||||
|
selections.insert(session_id.to_string(), (provider, model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 session 的用户模型覆盖。
|
||||||
|
pub fn get(&self, session_id: &str) -> Option<(Option<String>, Option<String>)> {
|
||||||
|
self.selections
|
||||||
|
.read()
|
||||||
|
.expect("model selections rwlock poisoned")
|
||||||
|
.get(session_id)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_and_get() {
|
||||||
|
let store = ModelSelectionStore::new();
|
||||||
|
store.set("s1", Some("p1".to_string()), Some("m1".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
store.get("s1"),
|
||||||
|
Some((Some("p1".to_string()), Some("m1".to_string())))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_missing_returns_none() {
|
||||||
|
let store = ModelSelectionStore::new();
|
||||||
|
assert_eq!(store.get("missing"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_none_none_removes_entry() {
|
||||||
|
let store = ModelSelectionStore::new();
|
||||||
|
store.set("s1", Some("p1".to_string()), Some("m1".to_string()));
|
||||||
|
assert!(store.get("s1").is_some());
|
||||||
|
store.set("s1", None, None);
|
||||||
|
assert!(store.get("s1").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_only_provider_keeps_entry() {
|
||||||
|
let store = ModelSelectionStore::new();
|
||||||
|
store.set("s1", Some("p1".to_string()), None);
|
||||||
|
assert_eq!(
|
||||||
|
store.get("s1"),
|
||||||
|
Some((Some("p1".to_string()), None))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,7 +8,8 @@ use tokio::sync::RwLock;
|
|||||||
|
|
||||||
use crate::agent::AgentError;
|
use crate::agent::AgentError;
|
||||||
use crate::bus::MessageBus;
|
use crate::bus::MessageBus;
|
||||||
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, SubagentsConfig, TaskConfig};
|
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig};
|
||||||
|
use crate::gateway::model_selection::ModelSelectionStore;
|
||||||
use crate::gateway::tool_registry_factory::ToolRegistryFactory;
|
use crate::gateway::tool_registry_factory::ToolRegistryFactory;
|
||||||
use crate::mcp::McpInitializer;
|
use crate::mcp::McpInitializer;
|
||||||
use crate::mcp::client::McpClientManager;
|
use crate::mcp::client::McpClientManager;
|
||||||
@ -53,7 +54,8 @@ pub(crate) fn build_session_manager(
|
|||||||
session_ttl_hours: Option<u64>,
|
session_ttl_hours: Option<u64>,
|
||||||
mcp_config: crate::mcp::McpConfig,
|
mcp_config: crate::mcp::McpConfig,
|
||||||
bus: Option<Arc<MessageBus>>,
|
bus: Option<Arc<MessageBus>>,
|
||||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
|
model_resolver: Arc<ModelResolver>,
|
||||||
|
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> {
|
||||||
build_session_manager_with_sender(
|
build_session_manager_with_sender(
|
||||||
agent_prompt_reinject_every,
|
agent_prompt_reinject_every,
|
||||||
show_tool_results,
|
show_tool_results,
|
||||||
@ -70,6 +72,7 @@ pub(crate) fn build_session_manager(
|
|||||||
session_ttl_hours,
|
session_ttl_hours,
|
||||||
mcp_config,
|
mcp_config,
|
||||||
bus,
|
bus,
|
||||||
|
model_resolver,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,7 +93,8 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
session_ttl_hours: Option<u64>,
|
session_ttl_hours: Option<u64>,
|
||||||
mcp_config: crate::mcp::McpConfig,
|
mcp_config: crate::mcp::McpConfig,
|
||||||
bus: Option<Arc<MessageBus>>,
|
bus: Option<Arc<MessageBus>>,
|
||||||
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
|
model_resolver: Arc<ModelResolver>,
|
||||||
|
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> {
|
||||||
let store = Arc::new(
|
let store = Arc::new(
|
||||||
SessionStore::new()
|
SessionStore::new()
|
||||||
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
||||||
@ -191,7 +195,7 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create subagent catalog with discovery, wrap in SubagentRuntime
|
// Create subagent catalog with discovery, wrap in SubagentRuntime
|
||||||
let catalog = Arc::new(SubagentCatalog::discover(&subagents_config));
|
let catalog = SubagentCatalog::discover(&subagents_config);
|
||||||
let subagent_runtime = Arc::new(SubagentRuntime::new(
|
let subagent_runtime = Arc::new(SubagentRuntime::new(
|
||||||
subagents_config.clone(),
|
subagents_config.clone(),
|
||||||
catalog,
|
catalog,
|
||||||
@ -202,7 +206,6 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(),
|
default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(),
|
||||||
default_max_execution_secs: task_config.max_execution_secs,
|
default_max_execution_secs: task_config.max_execution_secs,
|
||||||
ttl_hours: task_config.ttl_hours,
|
ttl_hours: task_config.ttl_hours,
|
||||||
skills_index: skills.system_index_prompt(),
|
|
||||||
max_nesting_depth: task_config.max_nesting_depth,
|
max_nesting_depth: task_config.max_nesting_depth,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -212,9 +215,11 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
conversations.clone(),
|
conversations.clone(),
|
||||||
subagent_tools.clone(),
|
subagent_tools.clone(),
|
||||||
provider_config.clone(),
|
provider_config.clone(),
|
||||||
|
model_resolver.clone(),
|
||||||
subagent_runtime.clone(),
|
subagent_runtime.clone(),
|
||||||
bus.clone(),
|
bus.clone(),
|
||||||
store.clone(),
|
store.clone(),
|
||||||
|
skills.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖)
|
// 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖)
|
||||||
@ -268,6 +273,7 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let prompt_repository: Arc<dyn PromptInjectionRepository> = store.clone();
|
let prompt_repository: Arc<dyn PromptInjectionRepository> = store.clone();
|
||||||
|
let model_selections = Arc::new(ModelSelectionStore::new());
|
||||||
let agent_factory = AgentFactory::new(
|
let agent_factory = AgentFactory::new(
|
||||||
tools.clone(),
|
tools.clone(),
|
||||||
skills.clone(),
|
skills.clone(),
|
||||||
@ -275,6 +281,8 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
subagent_runtime.clone(),
|
subagent_runtime.clone(),
|
||||||
agent_prompt_reinject_every as usize,
|
agent_prompt_reinject_every as usize,
|
||||||
prompt_repository.clone(),
|
prompt_repository.clone(),
|
||||||
|
model_resolver.clone(),
|
||||||
|
model_selections.clone(),
|
||||||
);
|
);
|
||||||
let session_factory = SessionFactory::new(
|
let session_factory = SessionFactory::new(
|
||||||
provider_config.clone(),
|
provider_config.clone(),
|
||||||
@ -311,5 +319,5 @@ pub(crate) fn build_session_manager_with_sender(
|
|||||||
scheduled_tasks,
|
scheduled_tasks,
|
||||||
memory_maintenance,
|
memory_maintenance,
|
||||||
task_repository: task_repository.clone(),
|
task_repository: task_repository.clone(),
|
||||||
}), task_repository, mcp_manager, subagent_runtime))
|
}), task_repository, mcp_manager, subagent_runtime, model_selections))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -258,6 +258,11 @@ impl Session {
|
|||||||
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
|
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
|
||||||
crate::config::ExpertsConfig::default(),
|
crate::config::ExpertsConfig::default(),
|
||||||
));
|
));
|
||||||
|
// Session::new 仅用于测试/简单场景,传入空 ModelResolver(无 provider/model 可解析覆盖)。
|
||||||
|
let model_resolver = Arc::new(crate::config::ModelResolver::new(
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
));
|
||||||
let agent_factory = AgentFactory::new(
|
let agent_factory = AgentFactory::new(
|
||||||
tools,
|
tools,
|
||||||
skills.clone(),
|
skills.clone(),
|
||||||
@ -265,6 +270,8 @@ impl Session {
|
|||||||
subagent_runtime,
|
subagent_runtime,
|
||||||
agent_prompt_reinject_every as usize,
|
agent_prompt_reinject_every as usize,
|
||||||
prompt_repository.clone(),
|
prompt_repository.clone(),
|
||||||
|
model_resolver,
|
||||||
|
Arc::new(super::model_selection::ModelSelectionStore::new()),
|
||||||
);
|
);
|
||||||
Self::with_factories(
|
Self::with_factories(
|
||||||
channel_name,
|
channel_name,
|
||||||
@ -688,6 +695,11 @@ impl SessionManager {
|
|||||||
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
|
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
|
||||||
crate::config::ExpertsConfig::default(),
|
crate::config::ExpertsConfig::default(),
|
||||||
));
|
));
|
||||||
|
// SessionManager::new 用于测试/简单场景,传入空 ModelResolver(无 provider/model 可解析覆盖)。
|
||||||
|
let model_resolver = Arc::new(crate::config::ModelResolver::new(
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
std::collections::HashMap::new(),
|
||||||
|
));
|
||||||
super::runtime::build_session_manager(
|
super::runtime::build_session_manager(
|
||||||
agent_prompt_reinject_every,
|
agent_prompt_reinject_every,
|
||||||
show_tool_results,
|
show_tool_results,
|
||||||
@ -703,8 +715,9 @@ impl SessionManager {
|
|||||||
session_ttl_hours,
|
session_ttl_hours,
|
||||||
mcp_config,
|
mcp_config,
|
||||||
None,
|
None,
|
||||||
|
model_resolver,
|
||||||
)
|
)
|
||||||
.map(|(session_manager, _, _, _)| session_manager)
|
.map(|(session_manager, _, _, _, _)| session_manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tools(&self) -> Arc<ToolRegistry> {
|
pub fn tools(&self) -> Arc<ToolRegistry> {
|
||||||
|
|||||||
@ -54,33 +54,52 @@ impl SessionPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 内部方法:创建 Session(根据 is_scheduler 选择存储位置)
|
/// 内部方法:创建 Session(根据 is_scheduler 选择存储位置)
|
||||||
|
///
|
||||||
|
/// 使用 double-checked locking:先短暂持锁检查存在性,释放锁后执行耗时的
|
||||||
|
/// session 创建(含配置加载、agent 工厂构造),再次持锁插入并处理竞态。
|
||||||
|
/// 避免跨 `session_factory.create().await` 持有全局锁导致所有 channel 的
|
||||||
|
/// session 访问串行化。
|
||||||
async fn ensure_session_internal(&self, channel_name: &str, is_scheduler: bool) -> Result<(), AgentError> {
|
async fn ensure_session_internal(&self, channel_name: &str, is_scheduler: bool) -> Result<(), AgentError> {
|
||||||
let mut inner = self.inner.lock().await;
|
// Fast path: 已存在直接返回(短暂持锁)
|
||||||
|
{
|
||||||
// 选择对应的存储
|
let inner = self.inner.lock().await;
|
||||||
let sessions = if is_scheduler {
|
let sessions = if is_scheduler {
|
||||||
&mut inner.scheduler_sessions
|
&inner.scheduler_sessions
|
||||||
} else {
|
} else {
|
||||||
&mut inner.sessions
|
&inner.sessions
|
||||||
};
|
};
|
||||||
|
if sessions.contains_key(channel_name) {
|
||||||
// 简化:只检查 session 是否存在,不做超时判断
|
return Ok(());
|
||||||
if sessions.contains_key(channel_name) {
|
}
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session 不存在则创建
|
// Slow path: 锁外创建 session(耗时操作:加载配置、构造 agent 工厂)
|
||||||
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
|
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
|
||||||
let session = self
|
let session = self
|
||||||
.session_factory
|
.session_factory
|
||||||
.create(channel_name.to_string(), user_tx)
|
.create(channel_name.to_string(), user_tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// 再次持锁插入,处理竞态(另一个并发任务可能已插入)
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
let sessions = if is_scheduler {
|
||||||
|
&mut inner.scheduler_sessions
|
||||||
|
} else {
|
||||||
|
&mut inner.sessions
|
||||||
|
};
|
||||||
|
if sessions.contains_key(channel_name) {
|
||||||
|
// 竞态:另一任务先插入,丢弃我们创建的 session
|
||||||
|
// (drop session 释放资源,user_tx 也 drop,无泄漏)
|
||||||
|
tracing::debug!(
|
||||||
|
channel = %channel_name,
|
||||||
|
"Session created concurrently by another task, discarding duplicate"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
sessions.insert(channel_name.to_string(), Arc::new(Mutex::new(session)));
|
sessions.insert(channel_name.to_string(), Arc::new(Mutex::new(session)));
|
||||||
inner
|
inner
|
||||||
.session_timestamps
|
.session_timestamps
|
||||||
.insert(channel_name.to_string(), Instant::now());
|
.insert(channel_name.to_string(), Instant::now());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,23 @@ pub struct Skill {
|
|||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 渲染技能索引提示词(`<available_skills>` XML 块)。
|
||||||
|
/// 仅输出技能索引列表;skill_activate / skill_manage 的使用说明已统一收拢到 ToolPromptProvider。
|
||||||
|
fn render_skill_index(skills: &[&Skill]) -> String {
|
||||||
|
let mut prompt = String::from("# 可用技能(Skills)\n\n<available_skills>\n");
|
||||||
|
for skill in skills {
|
||||||
|
let entry = format!(
|
||||||
|
" <skill>\n <name>{}</name>\n <description>{}</description>\n <location>{}</location>\n </skill>\n",
|
||||||
|
platform_xml_escape(&skill.name),
|
||||||
|
platform_xml_escape(&skill.description),
|
||||||
|
platform_xml_escape(&path_to_uri(&skill.path)),
|
||||||
|
);
|
||||||
|
prompt.push_str(&entry);
|
||||||
|
}
|
||||||
|
prompt.push_str("</available_skills>\n");
|
||||||
|
prompt
|
||||||
|
}
|
||||||
|
|
||||||
/// A skill entry with its disabled status across scopes.
|
/// A skill entry with its disabled status across scopes.
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct SkillWithStatus {
|
pub struct SkillWithStatus {
|
||||||
@ -138,6 +155,18 @@ impl SkillRuntime {
|
|||||||
.system_index_prompt()
|
.system_index_prompt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。
|
||||||
|
pub fn system_index_prompt_filtered(
|
||||||
|
&self,
|
||||||
|
allowed: Option<&[String]>,
|
||||||
|
denied: &[String],
|
||||||
|
) -> Option<String> {
|
||||||
|
self.catalog
|
||||||
|
.read()
|
||||||
|
.expect("skills rwlock poisoned")
|
||||||
|
.system_index_prompt_filtered(allowed, denied)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn discovery_event_payload(&self) -> serde_json::Value {
|
pub fn discovery_event_payload(&self) -> serde_json::Value {
|
||||||
self.catalog
|
self.catalog
|
||||||
.read()
|
.read()
|
||||||
@ -465,24 +494,36 @@ impl SkillCatalog {
|
|||||||
if self.skills.is_empty() {
|
if self.skills.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let refs: Vec<&Skill> = self.skills.iter().collect();
|
||||||
|
Some(render_skill_index(&refs))
|
||||||
|
}
|
||||||
|
|
||||||
// 仅输出技能索引列表。
|
/// 按白/黑名单过滤后的技能索引。`allowed` 为 `None` 表示不限(白名单关闭),
|
||||||
// skill_activate / skill_manage 的使用说明已统一收拢到 ToolPromptProvider。
|
/// `Some` 表示仅这些技能可见(空切片 = 全禁)。`denied` 为黑名单。
|
||||||
let mut prompt = String::from("# 可用技能(Skills)\n\n<available_skills>\n");
|
pub fn system_index_prompt_filtered(
|
||||||
|
&self,
|
||||||
|
allowed: Option<&[String]>,
|
||||||
|
denied: &[String],
|
||||||
|
) -> Option<String> {
|
||||||
|
let denied_set: HashSet<&str> = denied.iter().map(|s| s.as_str()).collect();
|
||||||
|
let allowed_set: Option<HashSet<&str>> =
|
||||||
|
allowed.map(|a| a.iter().map(|s| s.as_str()).collect());
|
||||||
|
|
||||||
for skill in &self.skills {
|
let filtered: Vec<&Skill> = self
|
||||||
let entry = format!(
|
.skills
|
||||||
" <skill>\n <name>{}</name>\n <description>{}</description>\n <location>{}</location>\n </skill>\n",
|
.iter()
|
||||||
platform_xml_escape(&skill.name),
|
.filter(|s| !denied_set.contains(s.name.as_str()))
|
||||||
platform_xml_escape(&skill.description),
|
.filter(|s| {
|
||||||
platform_xml_escape(&path_to_uri(&skill.path)),
|
allowed_set
|
||||||
);
|
.as_ref()
|
||||||
prompt.push_str(&entry);
|
.map_or(true, |set| set.contains(s.name.as_str()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
|
Some(render_skill_index(&filtered))
|
||||||
prompt.push_str("</available_skills>\n");
|
|
||||||
|
|
||||||
Some(prompt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn discovery_event_payload(&self) -> serde_json::Value {
|
pub fn discovery_event_payload(&self) -> serde_json::Value {
|
||||||
@ -902,26 +943,45 @@ fn parse_skill_file(path: &Path, source: SkillSource) -> Result<Skill, String> {
|
|||||||
|
|
||||||
// SkillPromptProvider 实现
|
// SkillPromptProvider 实现
|
||||||
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
||||||
|
use crate::experts::ExpertRuntime;
|
||||||
|
|
||||||
/// Skill 提示词提供者
|
/// Skill 提示词提供者
|
||||||
///
|
///
|
||||||
/// 负责提供技能的系统索引提示词(system_index_prompt)。
|
/// 负责提供技能的系统索引提示词(system_index_prompt)。
|
||||||
|
/// 当会话选中了带技能策略的专家时,按专家 `CapabilityPolicy` 过滤技能索引。
|
||||||
pub struct SkillPromptProvider {
|
pub struct SkillPromptProvider {
|
||||||
skills: Arc<SkillRuntime>,
|
skills: Arc<SkillRuntime>,
|
||||||
|
experts: Arc<ExpertRuntime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkillPromptProvider {
|
impl SkillPromptProvider {
|
||||||
/// 创建新的 Skill 提示词提供者
|
/// 创建新的 Skill 提示词提供者
|
||||||
pub fn new(skills: Arc<SkillRuntime>) -> Self {
|
pub fn new(skills: Arc<SkillRuntime>, experts: Arc<ExpertRuntime>) -> Self {
|
||||||
Self { skills }
|
Self { skills, experts }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SystemPromptProvider for SkillPromptProvider {
|
impl SystemPromptProvider for SkillPromptProvider {
|
||||||
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
|
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
|
||||||
// 调用 SkillRuntime 的 system_index_prompt 方法
|
// 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认)
|
||||||
self.skills.system_index_prompt().map(|content| SystemPrompt {
|
let content = match context.session_id.as_deref() {
|
||||||
content,
|
Some(sid) => {
|
||||||
|
let policy = self
|
||||||
|
.experts
|
||||||
|
.selected_expert_for(sid)
|
||||||
|
.map(|e| e.capability);
|
||||||
|
match policy {
|
||||||
|
Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered(
|
||||||
|
p.allowed_skills.as_deref(),
|
||||||
|
&p.denied_skills,
|
||||||
|
),
|
||||||
|
_ => self.skills.system_index_prompt(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => self.skills.system_index_prompt(),
|
||||||
|
};
|
||||||
|
content.map(|c| SystemPrompt {
|
||||||
|
content: c,
|
||||||
context: Some("skill_index".to_string()),
|
context: Some("skill_index".to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1610,18 +1610,22 @@ impl SessionStore {
|
|||||||
scope_key: &str,
|
scope_key: &str,
|
||||||
items: &[TodoRecord],
|
items: &[TodoRecord],
|
||||||
) -> Result<Vec<TodoRecord>, StorageError> {
|
) -> Result<Vec<TodoRecord>, StorageError> {
|
||||||
let conn = self.pool.get()?;
|
let mut conn = self.pool.get()?;
|
||||||
|
// 用 transaction()(非 unchecked_transaction)保证严格事务语义:
|
||||||
|
// 用户数据替换需保证原子性——中途失败必须回滚,避免 DELETE 后 INSERT
|
||||||
|
// 异常导致 todos 列表丢失且无法恢复。
|
||||||
|
let tx = conn.transaction()?;
|
||||||
let now = current_timestamp();
|
let now = current_timestamp();
|
||||||
|
|
||||||
// Delete existing todos for this scope_key
|
// Delete existing todos for this scope_key
|
||||||
conn.execute(
|
tx.execute(
|
||||||
"DELETE FROM todos WHERE scope_key = ?1",
|
"DELETE FROM todos WHERE scope_key = ?1",
|
||||||
params![scope_key],
|
params![scope_key],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Insert new todos
|
// Insert new todos
|
||||||
for item in items {
|
for item in items {
|
||||||
conn.execute(
|
tx.execute(
|
||||||
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id)
|
"INSERT OR REPLACE INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
params![
|
params![
|
||||||
@ -1639,9 +1643,34 @@ impl SessionStore {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
drop(conn);
|
// 事务内复用同一连接查询返回值,避免 drop(conn) 后重新 pool.get()。
|
||||||
|
let mut stmt = tx.prepare(
|
||||||
self.list_todos(scope_key)
|
"SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id
|
||||||
|
FROM todos
|
||||||
|
WHERE scope_key = ?1
|
||||||
|
ORDER BY created_at ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(params![scope_key], |row| {
|
||||||
|
Ok(TodoRecord {
|
||||||
|
id: row.get(0)?,
|
||||||
|
scope_key: row.get(1)?,
|
||||||
|
session_id: row.get(2)?,
|
||||||
|
topic_id: row.get(3)?,
|
||||||
|
content: row.get(4)?,
|
||||||
|
status: row.get(5)?,
|
||||||
|
priority: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
updated_at: row.get(8)?,
|
||||||
|
created_by_message_id: row.get(9)?,
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
result.push(row?);
|
||||||
|
}
|
||||||
|
drop(stmt); // 释放 stmt 借用,才能 commit
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> {
|
pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> {
|
||||||
|
|||||||
@ -101,11 +101,12 @@ mod tests {
|
|||||||
description: "测试".to_string(),
|
description: "测试".to_string(),
|
||||||
prompt_template: "任务: {{description}}\n指令: {{prompt}}".to_string(),
|
prompt_template: "任务: {{description}}\n指令: {{prompt}}".to_string(),
|
||||||
body: None,
|
body: None,
|
||||||
allowed_tools: None,
|
capability: crate::domain::CapabilityPolicy::default(),
|
||||||
denied_tools: None,
|
|
||||||
max_execution_secs: None,
|
max_execution_secs: None,
|
||||||
source: SubagentSource::Builtin,
|
source: SubagentSource::Builtin,
|
||||||
path: None,
|
path: None,
|
||||||
|
provider: None,
|
||||||
|
model: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,8 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::CapabilityPolicy;
|
||||||
|
|
||||||
/// 子代理会话状态
|
/// 子代理会话状态
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@ -60,16 +62,18 @@ pub struct SubagentDef {
|
|||||||
pub prompt_template: String,
|
pub prompt_template: String,
|
||||||
/// 可选的详细指令(body 部分)
|
/// 可选的详细指令(body 部分)
|
||||||
pub body: Option<String>,
|
pub body: Option<String>,
|
||||||
/// 工具白名单(None 表示不过滤,Some 时仅这些工具可用)
|
/// 工具与技能加载策略。全为空表示沿用默认配置(不过滤)。
|
||||||
pub allowed_tools: Option<Vec<String>>,
|
pub capability: CapabilityPolicy,
|
||||||
/// 工具黑名单(None 表示不过滤,Some 时这些工具被禁用;在白名单之后应用)
|
|
||||||
pub denied_tools: Option<Vec<String>>,
|
|
||||||
/// 最大执行时间(秒),None 表示使用默认
|
/// 最大执行时间(秒),None 表示使用默认
|
||||||
pub max_execution_secs: Option<u64>,
|
pub max_execution_secs: Option<u64>,
|
||||||
/// 来源
|
/// 来源
|
||||||
pub source: SubagentSource,
|
pub source: SubagentSource,
|
||||||
/// 文件路径(仅自定义类型)
|
/// 文件路径(仅自定义类型)
|
||||||
pub path: Option<PathBuf>,
|
pub path: Option<PathBuf>,
|
||||||
|
/// 可选的 provider 名(引用 config.json 的 providers 表)。None 时继承主智能体。
|
||||||
|
pub provider: Option<String>,
|
||||||
|
/// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。
|
||||||
|
pub model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubagentDef {
|
impl SubagentDef {
|
||||||
@ -80,11 +84,12 @@ impl SubagentDef {
|
|||||||
description: "通用型子代理 - 处理复杂多步骤任务".to_string(),
|
description: "通用型子代理 - 处理复杂多步骤任务".to_string(),
|
||||||
prompt_template: "你是一个专注的子代理,正在执行一个独立任务。\n\n任务描述: {{description}}\n\n你应该:\n1. 专注于完成任务,不要偏离目标\n2. 使用可用的工具进行必要操作\n3. 完成后给出简洁的总结\n4. 不要尝试创建新的子代理任务\n\n任务追踪:\n你可以使用 `todo_write` 工具追踪子任务进度。规则:同一时间只有一个 in_progress,完成后再标记下一个,3步以上才使用。\n\n注意: 你没有访问主对话历史的权限,这是一个独立的执行上下文。".to_string(),
|
prompt_template: "你是一个专注的子代理,正在执行一个独立任务。\n\n任务描述: {{description}}\n\n你应该:\n1. 专注于完成任务,不要偏离目标\n2. 使用可用的工具进行必要操作\n3. 完成后给出简洁的总结\n4. 不要尝试创建新的子代理任务\n\n任务追踪:\n你可以使用 `todo_write` 工具追踪子任务进度。规则:同一时间只有一个 in_progress,完成后再标记下一个,3步以上才使用。\n\n注意: 你没有访问主对话历史的权限,这是一个独立的执行上下文。".to_string(),
|
||||||
body: None,
|
body: None,
|
||||||
allowed_tools: None,
|
capability: CapabilityPolicy::default(),
|
||||||
denied_tools: None,
|
|
||||||
max_execution_secs: None,
|
max_execution_secs: None,
|
||||||
source: SubagentSource::Builtin,
|
source: SubagentSource::Builtin,
|
||||||
path: None,
|
path: None,
|
||||||
|
provider: None,
|
||||||
|
model: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -187,6 +187,7 @@ mod tests {
|
|||||||
task_id: None,
|
task_id: None,
|
||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
|
parent_capability: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -472,6 +472,7 @@ mod tests {
|
|||||||
task_id: None,
|
task_id: None,
|
||||||
parent_task_id: None,
|
parent_task_id: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
|
parent_capability: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::domain::CapabilityPolicy;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ToolResult {
|
pub struct ToolResult {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
@ -26,6 +28,10 @@ pub struct ToolContext {
|
|||||||
pub parent_task_id: Option<String>,
|
pub parent_task_id: Option<String>,
|
||||||
/// 当前工具调用的 ID(由 agent_loop 在执行前注入,用于精确关联 TaskStarted 事件)
|
/// 当前工具调用的 ID(由 agent_loop 在执行前注入,用于精确关联 TaskStarted 事件)
|
||||||
pub tool_call_id: Option<String>,
|
pub tool_call_id: Option<String>,
|
||||||
|
/// 父智能体(主 agent 所选专家或上级子代理)的 capability 策略快照。
|
||||||
|
/// TaskTool 据此强制校验子代理加载(白/黑名单),与 spawn/resume 安全范式一致。
|
||||||
|
/// 以数据形式传递,避免 task 模块反向依赖 experts 模块。
|
||||||
|
pub parent_capability: Option<CapabilityPolicy>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
4
vendor/wechatbot/Cargo.toml
vendored
4
vendor/wechatbot/Cargo.toml
vendored
@ -53,9 +53,9 @@ version = "0.10"
|
|||||||
version = "0.10"
|
version = "0.10"
|
||||||
|
|
||||||
[dependencies.reqwest]
|
[dependencies.reqwest]
|
||||||
version = "0.12"
|
version = "0.13"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["json", "rustls-tls"]
|
features = ["json", "rustls"]
|
||||||
|
|
||||||
[dependencies.serde]
|
[dependencies.serde]
|
||||||
version = "1"
|
version = "1"
|
||||||
|
|||||||
@ -6,8 +6,11 @@ export const API = {
|
|||||||
mcpStatus: '/api/mcp/status',
|
mcpStatus: '/api/mcp/status',
|
||||||
skills: '/api/skills',
|
skills: '/api/skills',
|
||||||
skillsToggle: '/api/skills/toggle',
|
skillsToggle: '/api/skills/toggle',
|
||||||
|
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',
|
||||||
experts: '/api/experts',
|
experts: '/api/experts',
|
||||||
expertsToggle: '/api/experts/toggle',
|
expertsToggle: '/api/experts/toggle',
|
||||||
expertsCreate: '/api/experts/create',
|
expertsCreate: '/api/experts/create',
|
||||||
@ -15,6 +18,8 @@ export const API = {
|
|||||||
expertsDelete: '/api/experts/delete',
|
expertsDelete: '/api/experts/delete',
|
||||||
expertsSelected: '/api/experts/selected',
|
expertsSelected: '/api/experts/selected',
|
||||||
expertsSelect: '/api/experts/select',
|
expertsSelect: '/api/experts/select',
|
||||||
|
sessionSelectModel: '/api/session/select-model',
|
||||||
|
sessionSelectedModel: '/api/session/selected-model',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { API, apiGetSilent } from './client'
|
import { API, apiGetSilent } from './client'
|
||||||
import type { ExpertListResponse, ExpertItem } 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 }): 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 }): 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' },
|
||||||
@ -51,3 +55,23 @@ export async function selectExpert(sessionId: string, expertName: string | null)
|
|||||||
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }
|
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置(或清除)session 的用户模型覆盖。provider/model 均为空时清除覆盖(继承默认) */
|
||||||
|
export async function selectModel(sessionId: string, provider: string | null, model: string | null): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const resp = await fetch(API.sessionSelectModel, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: sessionId, provider, model }),
|
||||||
|
})
|
||||||
|
const data = await resp.json().catch(() => ({}))
|
||||||
|
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' }
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取 session 当前的用户模型覆盖。provider/model 均为 null 表示未设置(继承默认) */
|
||||||
|
export async function getSelectedModel(sessionId: string): Promise<{ provider: string | null; model: string | null }> {
|
||||||
|
const params = new URLSearchParams({ session_id: sessionId })
|
||||||
|
const resp = await fetch(`${API.sessionSelectedModel}?${params}`)
|
||||||
|
if (!resp.ok) return { provider: null, model: null }
|
||||||
|
return resp.json()
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { API, apiGetSilent } from './client'
|
import { API, apiGetSilent } from './client'
|
||||||
import type { SubagentListResponse } from '../components/Settings/types'
|
import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types'
|
||||||
|
|
||||||
export function listSubagents(): Promise<SubagentListResponse | null> {
|
export function listSubagents(): Promise<SubagentListResponse | null> {
|
||||||
return apiGetSilent<SubagentListResponse>(API.subagents)
|
return apiGetSilent<SubagentListResponse>(API.subagents)
|
||||||
@ -12,3 +12,18 @@ export async function toggleSubagent(name: string, scope: string, enabled: boole
|
|||||||
body: JSON.stringify({ name, scope, enabled }),
|
body: JSON.stringify({ name, scope, enabled }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateSubagent(payload: {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
body?: string
|
||||||
|
capability?: CapabilityPolicy
|
||||||
|
provider?: string
|
||||||
|
model?: string
|
||||||
|
}): Promise<Response> {
|
||||||
|
return fetch(API.subagentsUpdate, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
6
web/src/api/tools.ts
Normal file
6
web/src/api/tools.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { API, apiGetSilent } from './client'
|
||||||
|
import type { ToolsListResponse } from '../components/Settings/types'
|
||||||
|
|
||||||
|
export function listTools(): Promise<ToolsListResponse | null> {
|
||||||
|
return apiGetSilent<ToolsListResponse>(API.tools)
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { MessageList } from './MessageList'
|
import { MessageList } from './MessageList'
|
||||||
import { MessageInput } from './MessageInput'
|
import { MessageInput } from './MessageInput'
|
||||||
import { ExpertSelector } from './ExpertSelector'
|
import { ExpertSelector } from './ExpertSelector'
|
||||||
|
import { ModelSelector } from './ModelSelector'
|
||||||
import type { ChatMessage, Attachment } from '../../types/protocol'
|
import type { ChatMessage, Attachment } from '../../types/protocol'
|
||||||
|
|
||||||
interface ChatContainerProps {
|
interface ChatContainerProps {
|
||||||
@ -47,12 +48,18 @@ export function ChatContainer({
|
|||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
|
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
|
||||||
</div>
|
</div>
|
||||||
<ExpertSelector
|
<div className="flex flex-wrap items-center gap-1 px-4 pt-2 max-w-5xl mx-auto w-full">
|
||||||
sessionId={sessionId ?? null}
|
<ExpertSelector
|
||||||
onManageExperts={onOpenSettings}
|
sessionId={sessionId ?? null}
|
||||||
onSelectionChange={setSelectedExpert}
|
onManageExperts={onOpenSettings}
|
||||||
settingsClosedTick={settingsClosedTick}
|
onSelectionChange={setSelectedExpert}
|
||||||
/>
|
settingsClosedTick={settingsClosedTick}
|
||||||
|
/>
|
||||||
|
<ModelSelector
|
||||||
|
sessionId={sessionId ?? null}
|
||||||
|
settingsClosedTick={settingsClosedTick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MessageInput
|
<MessageInput
|
||||||
onSend={onSendMessage}
|
onSend={onSendMessage}
|
||||||
onStop={onStop}
|
onStop={onStop}
|
||||||
|
|||||||
@ -142,118 +142,116 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
|
|||||||
if (!sessionId) return null
|
if (!sessionId) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative shrink-0 px-4 pt-2 pb-0">
|
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
|
||||||
<div className="max-w-5xl mx-auto flex items-center gap-2">
|
<div className="relative">
|
||||||
<div className="relative">
|
<button
|
||||||
<button
|
onClick={handleToggleOpen}
|
||||||
onClick={handleToggleOpen}
|
disabled={loading}
|
||||||
disabled={loading}
|
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
|
||||||
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
|
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
|
||||||
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
|
>
|
||||||
>
|
{loading ? (
|
||||||
{loading ? (
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
) : (
|
||||||
) : (
|
<UserCheck
|
||||||
<UserCheck
|
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
||||||
className={`h-3.5 w-3.5 ${selectedExpert ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{selectedExpert ? (
|
|
||||||
<span className="flex items-center gap-1 min-w-0">
|
|
||||||
<span className="text-[var(--text-primary)] font-medium truncate max-w-[120px]">
|
|
||||||
{selectedExpert.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--text-muted)] truncate max-w-[180px]">
|
|
||||||
{selectedExpert.description}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--text-muted)]">无专家</span>
|
|
||||||
)}
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
)}
|
||||||
|
{selectedExpert ? (
|
||||||
|
<span className="flex items-center gap-1 min-w-0">
|
||||||
|
<span className="text-[var(--text-primary)] font-medium truncate max-w-[120px]">
|
||||||
|
{selectedExpert.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--text-muted)] truncate max-w-[180px]">
|
||||||
|
{selectedExpert.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[var(--text-muted)]">无专家</span>
|
||||||
|
)}
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
|
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
|
||||||
>
|
>
|
||||||
{listLoading && expertList.length === 0 ? (
|
{listLoading && expertList.length === 0 ? (
|
||||||
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
|
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 加载中...
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 加载中...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* 无专家 option */}
|
{/* 无专家 option */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelect(null)}
|
onClick={() => handleSelect(null)}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--bg-hover)] transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--bg-hover)] transition-colors"
|
||||||
>
|
>
|
||||||
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
|
<UserCheck className="h-4 w-4 text-[var(--text-muted)] shrink-0" />
|
||||||
<span className="text-[var(--text-primary)]">无专家</span>
|
<span className="text-[var(--text-primary)]">无专家</span>
|
||||||
{!selectedExpert && (
|
{!selectedExpert && (
|
||||||
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
|
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{expertList.length > 0 ? (
|
{expertList.length > 0 ? (
|
||||||
<div className="border-t border-[var(--border-color)]">
|
<div className="border-t border-[var(--border-color)]">
|
||||||
{expertList.map(expert => {
|
{expertList.map(expert => {
|
||||||
const isSelected = selectedExpert?.name === expert.name
|
const isSelected = selectedExpert?.name === expert.name
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={expert.name}
|
key={expert.name}
|
||||||
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
|
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
|
||||||
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
|
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
|
||||||
>
|
>
|
||||||
<UserCheck
|
<UserCheck
|
||||||
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
className={`h-4 w-4 shrink-0 mt-0.5 ${isSelected ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start gap-1.5">
|
<div className="flex items-start gap-1.5">
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)] break-all">
|
<span className="text-sm font-medium text-[var(--text-primary)] break-all">
|
||||||
{expert.name}
|
{expert.name}
|
||||||
</span>
|
</span>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0 mt-0.5" />
|
<Check className="h-3.5 w-3.5 text-[var(--accent-cyan)] ml-auto shrink-0 mt-0.5" />
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
|
||||||
{expert.description}
|
|
||||||
</p>
|
|
||||||
{expert.path && (
|
|
||||||
<p
|
|
||||||
className="text-[10px] text-[var(--text-muted)]/60 truncate mt-1 font-mono"
|
|
||||||
title={expert.path}
|
|
||||||
>
|
|
||||||
{expert.path}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<p className="text-xs text-[var(--text-muted)] mt-0.5">
|
||||||
)
|
{expert.description}
|
||||||
})}
|
</p>
|
||||||
</div>
|
{expert.path && (
|
||||||
) : (
|
<p
|
||||||
<div className="border-t border-[var(--border-color)]">
|
className="text-[10px] text-[var(--text-muted)]/60 truncate mt-1 font-mono"
|
||||||
<button
|
title={expert.path}
|
||||||
onClick={handleManage}
|
>
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--accent-cyan)] hover:bg-[var(--bg-hover)] transition-colors"
|
{expert.path}
|
||||||
>
|
</p>
|
||||||
<Settings className="h-4 w-4" />
|
)}
|
||||||
<span>管理专家...</span>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)
|
||||||
)}
|
})}
|
||||||
</>
|
</div>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className="border-t border-[var(--border-color)]">
|
||||||
)}
|
<button
|
||||||
</div>
|
onClick={handleManage}
|
||||||
{error && (
|
className="w-full flex items-center gap-2 px-3 py-2 text-left text-sm text-[var(--text-secondary)] hover:text-[var(--accent-cyan)] hover:bg-[var(--bg-hover)] transition-colors"
|
||||||
<span className="text-xs text-red-400 truncate">{error}</span>
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
<span>管理专家...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-400 truncate">{error}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
248
web/src/components/Chat/ModelSelector.tsx
Normal file
248
web/src/components/Chat/ModelSelector.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react'
|
||||||
|
import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts'
|
||||||
|
import type { ModelOptionsResponse } from '../Settings/types'
|
||||||
|
|
||||||
|
interface ModelSelectorProps {
|
||||||
|
sessionId: string | null
|
||||||
|
/** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */
|
||||||
|
settingsClosedTick?: number
|
||||||
|
/** 选择变化回调(参数为生效的 provider/model,未覆盖时为 current 默认) */
|
||||||
|
onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange }: ModelSelectorProps) {
|
||||||
|
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null)
|
||||||
|
const [userProvider, setUserProvider] = useState<string | null>(null)
|
||||||
|
const [userModel, setUserModel] = useState<string | null>(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交
|
||||||
|
const [draftProvider, setDraftProvider] = useState<string>('')
|
||||||
|
const [draftModel, setDraftModel] = useState<string>('')
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// 刷新当前会话的用户模型覆盖
|
||||||
|
const refreshSelection = useCallback(() => {
|
||||||
|
if (!sessionId) {
|
||||||
|
setUserProvider(null)
|
||||||
|
setUserModel(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
getSelectedModel(sessionId)
|
||||||
|
.then(data => {
|
||||||
|
setUserProvider(data.provider)
|
||||||
|
setUserModel(data.model)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setUserProvider(null)
|
||||||
|
setUserModel(null)
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
// 加载模型选项(全局缓存,仅加载一次)
|
||||||
|
useEffect(() => {
|
||||||
|
if (modelOptions) return
|
||||||
|
listModelOptions().then(data => {
|
||||||
|
if (data) setModelOptions(data)
|
||||||
|
})
|
||||||
|
}, [modelOptions])
|
||||||
|
|
||||||
|
// sessionId 变化时刷新用户选择
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSelection()
|
||||||
|
}, [refreshSelection])
|
||||||
|
|
||||||
|
// 设置弹窗关闭时刷新(处理 config.json 中 provider/model 变更)
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsClosedTick === undefined) return
|
||||||
|
refreshSelection()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settingsClosedTick])
|
||||||
|
|
||||||
|
// 计算生效模型并通知父组件
|
||||||
|
const overridden = userProvider !== null || userModel !== null
|
||||||
|
const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? ''
|
||||||
|
const effectiveModel = userModel ?? modelOptions?.current.model ?? ''
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modelOptions) return
|
||||||
|
onSelectionChange?.({
|
||||||
|
provider: effectiveProvider,
|
||||||
|
model: effectiveModel,
|
||||||
|
overridden,
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [effectiveProvider, effectiveModel, overridden, modelOptions])
|
||||||
|
|
||||||
|
// 点击外部关闭 dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleToggleOpen = () => {
|
||||||
|
const next = !open
|
||||||
|
setOpen(next)
|
||||||
|
if (next) {
|
||||||
|
// 打开时刷新选项与当前选择,同步草稿
|
||||||
|
if (!modelOptions) {
|
||||||
|
listModelOptions().then(data => { if (data) setModelOptions(data) })
|
||||||
|
}
|
||||||
|
refreshSelection()
|
||||||
|
setDraftProvider(userProvider ?? '')
|
||||||
|
setDraftModel(userModel ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApply = async () => {
|
||||||
|
if (!sessionId) return
|
||||||
|
const provider = draftProvider.trim() || null
|
||||||
|
const model = draftModel.trim() || null
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await selectModel(sessionId, provider, model)
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || '切换模型失败')
|
||||||
|
setTimeout(() => setError(null), 3000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUserProvider(provider)
|
||||||
|
setUserModel(model)
|
||||||
|
setOpen(false)
|
||||||
|
} catch {
|
||||||
|
setError('网络错误,切换模型失败')
|
||||||
|
setTimeout(() => setError(null), 3000)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setDraftProvider('')
|
||||||
|
setDraftModel('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) return null
|
||||||
|
|
||||||
|
// 草稿是否与已保存状态不同(用于启用"应用"按钮)
|
||||||
|
const draftChanged =
|
||||||
|
(draftProvider || null) !== (userProvider ?? null) ||
|
||||||
|
(draftModel || null) !== (userModel ?? null)
|
||||||
|
|
||||||
|
const buttonLabel = overridden
|
||||||
|
? `${effectiveProvider}/${effectiveModel}`
|
||||||
|
: `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={handleToggleOpen}
|
||||||
|
disabled={loading}
|
||||||
|
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
|
||||||
|
title={overridden ? `用户覆盖: ${effectiveProvider}/${effectiveModel}` : `继承默认: ${effectiveProvider}/${effectiveModel}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
||||||
|
) : (
|
||||||
|
<Cpu
|
||||||
|
className={`h-3.5 w-3.5 ${overridden ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className={`truncate max-w-[200px] ${overridden ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}>
|
||||||
|
{buttonLabel}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3 w-3 text-[var(--text-muted)] transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-80 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden p-3 space-y-3"
|
||||||
|
>
|
||||||
|
<div className="text-xs text-[var(--text-muted)]">
|
||||||
|
{overridden
|
||||||
|
? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)`
|
||||||
|
: `当前: 继承默认(${modelOptions?.current.provider ?? '-'}/${modelOptions?.current.model ?? '-'})`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-xs font-medium text-[var(--text-secondary)]">
|
||||||
|
Provider
|
||||||
|
<select
|
||||||
|
value={draftProvider}
|
||||||
|
onChange={e => setDraftProvider(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">继承默认{modelOptions ? `(${modelOptions.current.provider})` : ''}</option>
|
||||||
|
{(modelOptions?.providers ?? []).map(p => (
|
||||||
|
<option key={p} value={p}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-xs font-medium text-[var(--text-secondary)]">
|
||||||
|
Model
|
||||||
|
<select
|
||||||
|
value={draftModel}
|
||||||
|
onChange={e => setDraftModel(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">继承默认{modelOptions ? `(${modelOptions.current.model})` : ''}</option>
|
||||||
|
{(modelOptions?.models ?? []).map(m => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-red-400 truncate">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-xs text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
重置为默认
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{draftChanged && (
|
||||||
|
<Check className="h-3 w-3 text-[var(--accent-cyan)] animate-pulse" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={saving || !draftChanged}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/30 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||||
|
应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<span className="text-xs text-red-400 truncate">{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
Settings, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
|
Settings, Save, X, Plus, Trash2, AlertTriangle, Loader2, Wifi,
|
||||||
CheckCircle, RefreshCw, UserCheck, Pencil,
|
CheckCircle, RefreshCw, UserCheck, Pencil, Bot,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ── Extracted modules ─────────────────────────────────
|
// ── Extracted modules ─────────────────────────────────
|
||||||
@ -10,17 +10,21 @@ import type {
|
|||||||
ProviderConfig, ModelConfig, AgentConfig,
|
ProviderConfig, ModelConfig, AgentConfig,
|
||||||
McpServerConfig, McpStatusResponse,
|
McpServerConfig, McpStatusResponse,
|
||||||
SkillListResponse,
|
SkillListResponse,
|
||||||
SubagentListResponse,
|
SubagentListResponse, SubagentItem,
|
||||||
|
ToolsListResponse,
|
||||||
ExpertItem, ExpertListResponse,
|
ExpertItem, ExpertListResponse,
|
||||||
|
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 } from './ui'
|
import { Field, Toggle, TagEditor, SectionCard, SourceEditor, MapEntryHeader, CheckboxList, ModalHeader, ModalFooter } from './ui'
|
||||||
import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config'
|
import { getAppConfig, updateAppConfig, restartGateway, checkHealth } from '../../api/config'
|
||||||
import { listSkills, toggleSkill } from '../../api/skills'
|
import { listSkills, toggleSkill } from '../../api/skills'
|
||||||
import { listSubagents, toggleSubagent } from '../../api/subagents'
|
import { listTools } from '../../api/tools'
|
||||||
import { listExperts, toggleExpert, createExpert, updateExpert, deleteExpert } from '../../api/experts'
|
import { listSubagents, toggleSubagent, updateSubagent } from '../../api/subagents'
|
||||||
|
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'
|
||||||
|
|
||||||
@ -47,10 +51,13 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
|
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
|
||||||
const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
|
const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
|
||||||
const [skillListLoading, setSkillListLoading] = useState(false)
|
const [skillListLoading, setSkillListLoading] = useState(false)
|
||||||
|
const [toolList, setToolList] = useState<ToolsListResponse | null>(null)
|
||||||
|
const [toolListLoading, setToolListLoading] = useState(false)
|
||||||
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null)
|
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null)
|
||||||
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
|
||||||
@ -58,9 +65,31 @@ 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[]
|
||||||
|
deniedSkills: string[]
|
||||||
|
allowedTools: string[]
|
||||||
|
deniedTools: string[]
|
||||||
|
allowedSubagents: string[]
|
||||||
|
deniedSubagents: string[]
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [editingExpertError, setEditingExpertError] = useState('')
|
const [editingExpertError, setEditingExpertError] = useState('')
|
||||||
const [savingExpert, setSavingExpert] = useState(false)
|
const [savingExpert, setSavingExpert] = useState(false)
|
||||||
|
const [editingSubagent, setEditingSubagent] = useState<{
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
allowedSkills: string[]
|
||||||
|
deniedSkills: string[]
|
||||||
|
allowedTools: string[]
|
||||||
|
deniedTools: string[]
|
||||||
|
allowedSubagents: string[]
|
||||||
|
deniedSubagents: string[]
|
||||||
|
} | null>(null)
|
||||||
|
const [editingSubagentError, setEditingSubagentError] = useState('')
|
||||||
|
const [savingSubagent, setSavingSubagent] = useState(false)
|
||||||
|
|
||||||
const fetchMcpStatus = useCallback(async () => {
|
const fetchMcpStatus = useCallback(async () => {
|
||||||
const data = await getMcpStatus()
|
const data = await getMcpStatus()
|
||||||
@ -74,6 +103,13 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
setSkillListLoading(false)
|
setSkillListLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const fetchToolList = useCallback(async () => {
|
||||||
|
setToolListLoading(true)
|
||||||
|
const data = await listTools()
|
||||||
|
if (data) setToolList(data)
|
||||||
|
setToolListLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
|
const toggleSkillCb = useCallback(async (name: string, scope: string, enabled: boolean) => {
|
||||||
return toggleSkill(name, scope, enabled)
|
return toggleSkill(name, scope, enabled)
|
||||||
}, [])
|
}, [])
|
||||||
@ -89,6 +125,10 @@ 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; provider?: string; model?: string }) => {
|
||||||
|
return updateSubagent(payload)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchExpertList = useCallback(async () => {
|
const fetchExpertList = useCallback(async () => {
|
||||||
setExpertListLoading(true)
|
setExpertListLoading(true)
|
||||||
const data = await listExperts()
|
const data = await listExperts()
|
||||||
@ -100,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 }) => {
|
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 }) => {
|
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)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@ -141,6 +181,20 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
if (activeTab === 'subagents') fetchSubagentList()
|
if (activeTab === 'subagents') fetchSubagentList()
|
||||||
}, [activeTab, fetchSubagentList])
|
}, [activeTab, fetchSubagentList])
|
||||||
|
|
||||||
|
// Fetch skills + tools + model options when experts/subagents tab is selected (for capability CheckboxList & provider/model dropdowns)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'experts' || activeTab === 'subagents') {
|
||||||
|
if (!skillList) fetchSkillList()
|
||||||
|
if (!toolList) fetchToolList()
|
||||||
|
if (!modelOptions) listModelOptions().then(data => { if (data) setModelOptions(data) })
|
||||||
|
}
|
||||||
|
}, [activeTab, fetchSkillList, fetchToolList, skillList, toolList, modelOptions])
|
||||||
|
|
||||||
|
// experts tab 编辑专家时也需要子代理勾选列表,按需加载(subagents tab 由下方独立 useEffect 刷新)
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'experts' && !subagentList) fetchSubagentList()
|
||||||
|
}, [activeTab, fetchSubagentList, subagentList])
|
||||||
|
|
||||||
// Fetch expert list when experts tab is selected
|
// Fetch expert list when experts tab is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'experts') fetchExpertList()
|
if (activeTab === 'experts') fetchExpertList()
|
||||||
@ -153,6 +207,23 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
return () => document.removeEventListener('keydown', h)
|
return () => document.removeEventListener('keydown', h)
|
||||||
}, [handleClose])
|
}, [handleClose])
|
||||||
|
|
||||||
|
// 子模态框打开时,ESC 键仅关闭子模态框(阻止冒泡到 ConfigPage 全局 ESC,避免关闭整个配置页)
|
||||||
|
// 必须放在所有条件 return 之前,否则 loading 首次渲染时不执行此 hook,
|
||||||
|
// config 加载后重新渲染才执行 → hooks 数量不一致 → React 崩溃
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingExpert && !editingSubagent) return
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
setEditingExpert(null)
|
||||||
|
setEditingSubagent(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler, true)
|
||||||
|
return () => window.removeEventListener('keydown', handler, true)
|
||||||
|
}, [editingExpert, editingSubagent])
|
||||||
|
|
||||||
const update = useCallback(<K extends keyof AppConfig>(key: K, value: AppConfig[K]) => {
|
const update = useCallback(<K extends keyof AppConfig>(key: K, value: AppConfig[K]) => {
|
||||||
setConfig(prev => prev ? { ...prev, [key]: value } : prev)
|
setConfig(prev => prev ? { ...prev, [key]: value } : prev)
|
||||||
setDirty(true)
|
setDirty(true)
|
||||||
@ -527,6 +598,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]">启用 Task 工具</span><Toggle checked={config.tools.task.enabled} onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })} /></div>
|
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]">启用 Task 工具</span><Toggle checked={config.tools.task.enabled} onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })} /></div>
|
||||||
<Field label="最大执行时间 (秒)"><input type="number" value={config.tools.task.max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value } })} className={inputCls} /></Field>
|
<Field label="最大执行时间 (秒)"><input type="number" value={config.tools.task.max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value } })} className={inputCls} /></Field>
|
||||||
<Field label="TTL (小时)"><input type="number" value={config.tools.task.ttl_hours} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /></Field>
|
<Field label="TTL (小时)"><input type="number" value={config.tools.task.ttl_hours} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /></Field>
|
||||||
|
<Field label="最大嵌套深度" hint="允许的子代理最大嵌套层数。1=仅子代理,2=子代理+孙代理(默认),0=禁止嵌套。深度达上限时移除 task 工具以防无限递归"><input type="number" min={0} value={config.tools.task.max_nesting_depth} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, max_nesting_depth: Math.max(0, +e.target.value || 0) } })} className={inputCls} /></Field>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
<SectionCard title="允许的工具列表">
|
<SectionCard title="允许的工具列表">
|
||||||
<SourceEditor
|
<SourceEditor
|
||||||
@ -568,6 +640,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
{renderDiscoveredSubagents()}
|
{renderDiscoveredSubagents()}
|
||||||
|
{renderSubagentModal()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -636,6 +709,22 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEditSubagent = (subagent: SubagentItem) => {
|
||||||
|
setEditingSubagentError('')
|
||||||
|
setEditingSubagent({
|
||||||
|
name: subagent.name,
|
||||||
|
description: subagent.description,
|
||||||
|
provider: subagent.provider ?? '',
|
||||||
|
model: subagent.model ?? '',
|
||||||
|
allowedSkills: subagent.capability?.allowed_skills ?? [],
|
||||||
|
deniedSkills: subagent.capability?.denied_skills ?? [],
|
||||||
|
allowedTools: subagent.capability?.allowed_tools ?? [],
|
||||||
|
deniedTools: subagent.capability?.denied_tools ?? [],
|
||||||
|
allowedSubagents: subagent.capability?.allowed_subagents ?? [],
|
||||||
|
deniedSubagents: subagent.capability?.denied_subagents ?? [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title="已发现子代理" subtitle="即时生效">
|
<SectionCard title="已发现子代理" subtitle="即时生效">
|
||||||
{subagentListLoading && subagents.length === 0 ? (
|
{subagentListLoading && subagents.length === 0 ? (
|
||||||
@ -648,6 +737,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{subagents.map(subagent => {
|
{subagents.map(subagent => {
|
||||||
const isEnabled = subagent.disabled_in_scopes.length === 0
|
const isEnabled = subagent.disabled_in_scopes.length === 0
|
||||||
|
const isBuiltin = subagent.source === 'builtin'
|
||||||
return (
|
return (
|
||||||
<div key={subagent.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
|
<div key={subagent.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@ -656,9 +746,18 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{subagent.source}</span>
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{subagent.source}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{subagent.description}</p>
|
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{subagent.description}</p>
|
||||||
{renderToolTags('允许', subagent.allowed_tools, 'allow')}
|
{renderToolTags('允许', subagent.capability?.allowed_tools, 'allow')}
|
||||||
{renderToolTags('禁用', subagent.denied_tools, 'deny')}
|
{renderToolTags('禁用', subagent.capability?.denied_tools, 'deny')}
|
||||||
</div>
|
</div>
|
||||||
|
{!isBuiltin && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditSubagent(subagent)}
|
||||||
|
className="p-1 rounded text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Toggle checked={isEnabled} onChange={() => handleToggle(subagent.name, isEnabled)} />
|
<Toggle checked={isEnabled} onChange={() => handleToggle(subagent.name, isEnabled)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -691,7 +790,7 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingExpertError('')
|
setEditingExpertError('')
|
||||||
setEditingExpert({ mode: 'create', scope: 'project', nameField: '', description: '', body: '' })
|
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"
|
||||||
>
|
>
|
||||||
@ -750,6 +849,14 @@ 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 ?? [],
|
||||||
|
deniedSkills: expert.capability?.denied_skills ?? [],
|
||||||
|
allowedTools: expert.capability?.allowed_tools ?? [],
|
||||||
|
deniedTools: expert.capability?.denied_tools ?? [],
|
||||||
|
allowedSubagents: expert.capability?.allowed_subagents ?? [],
|
||||||
|
deniedSubagents: expert.capability?.denied_subagents ?? [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -817,6 +924,14 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 技能/工具勾选选项(专家与子代理编辑模态框共用)
|
||||||
|
const skillOptions = (skillList?.skills ?? []).map(s => ({ key: s.name, label: s.name, description: s.description, group: s.source }))
|
||||||
|
const toolOptions = (toolList?.tools ?? []).map(t => ({ key: t.name, label: t.name, description: t.description, group: t.source }))
|
||||||
|
const subagentOptions = (subagentList?.subagents ?? []).map(s => ({ key: s.name, label: s.name, description: s.description, group: s.source }))
|
||||||
|
const skillEmptyHint = skillListLoading ? '加载中...' : '未发现任何技能,请先在技能页配置来源目录'
|
||||||
|
const toolEmptyHint = toolListLoading ? '加载中...' : '未发现任何工具'
|
||||||
|
const subagentEmptyHint = subagentListLoading ? '加载中...' : '未发现任何子代理'
|
||||||
|
|
||||||
const renderExpertModal = () => {
|
const renderExpertModal = () => {
|
||||||
if (!editingExpert) return null
|
if (!editingExpert) return null
|
||||||
const isEdit = editingExpert.mode === 'edit'
|
const isEdit = editingExpert.mode === 'edit'
|
||||||
@ -827,19 +942,28 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
setSavingExpert(true)
|
setSavingExpert(true)
|
||||||
setEditingExpertError('')
|
setEditingExpertError('')
|
||||||
try {
|
try {
|
||||||
|
// allowed_* 为空时必须传 undefined(后端 None=不限),
|
||||||
|
// 否则空数组会被反序列化为 Some(vec![]) 触发白名单空集语义(全禁)。
|
||||||
|
// denied_* 为 Vec<String>,空数组即"不禁",可直接传。
|
||||||
|
const capability: CapabilityPolicy = {
|
||||||
|
allowed_skills: editingExpert.allowedSkills.length > 0 ? editingExpert.allowedSkills : undefined,
|
||||||
|
denied_skills: editingExpert.deniedSkills,
|
||||||
|
allowed_tools: editingExpert.allowedTools.length > 0 ? editingExpert.allowedTools : undefined,
|
||||||
|
denied_tools: editingExpert.deniedTools,
|
||||||
|
allowed_subagents: editingExpert.allowedSubagents.length > 0 ? editingExpert.allowedSubagents : undefined,
|
||||||
|
denied_subagents: editingExpert.deniedSubagents,
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
name: editingExpert.nameField,
|
||||||
|
description: editingExpert.description,
|
||||||
|
body: editingExpert.body,
|
||||||
|
capability,
|
||||||
|
provider: editingExpert.provider || undefined,
|
||||||
|
model: editingExpert.model || undefined,
|
||||||
|
}
|
||||||
const resp = isEdit
|
const resp = isEdit
|
||||||
? await updateExpertCb({
|
? await updateExpertCb({ ...payload, scope: 'project' })
|
||||||
name: editingExpert.nameField,
|
: await createExpertCb({ ...payload, scope: 'project' })
|
||||||
scope: 'project',
|
|
||||||
description: editingExpert.description,
|
|
||||||
body: editingExpert.body,
|
|
||||||
})
|
|
||||||
: await createExpertCb({
|
|
||||||
name: editingExpert.nameField,
|
|
||||||
description: editingExpert.description,
|
|
||||||
body: editingExpert.body,
|
|
||||||
scope: 'project',
|
|
||||||
})
|
|
||||||
const data = await resp.json().catch(() => ({}))
|
const data = await resp.json().catch(() => ({}))
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
setEditingExpertError(data.error || data.message || '保存失败')
|
setEditingExpertError(data.error || data.message || '保存失败')
|
||||||
@ -858,48 +982,146 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl">
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl" onClick={() => setEditingExpert(null)}>
|
||||||
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl p-6 w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out]">
|
<div
|
||||||
<div className="flex items-center gap-2 mb-4">
|
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
|
||||||
<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />
|
onClick={e => e.stopPropagation()}
|
||||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">
|
>
|
||||||
{isEdit ? '编辑专家' : '添加专家'}
|
<ModalHeader
|
||||||
</h3>
|
icon={<UserCheck className="h-5 w-5 text-[var(--accent-cyan)]" />}
|
||||||
</div>
|
title={isEdit ? '编辑专家' : '添加专家'}
|
||||||
<div className="space-y-3">
|
onClose={() => setEditingExpert(null)}
|
||||||
<Field label="名称" hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入">
|
/>
|
||||||
<input
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
value={editingExpert.nameField}
|
<SectionCard title="基本信息">
|
||||||
onChange={e => setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)}
|
<Field label="名称" hint="创建后不可修改。仅当正文为空时,与描述一起生成兜底提示词;正文非空时不注入">
|
||||||
disabled={isEdit}
|
<input
|
||||||
placeholder="如 translator"
|
value={editingExpert.nameField}
|
||||||
className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')}
|
onChange={e => setEditingExpert(prev => prev ? { ...prev, nameField: e.target.value } : prev)}
|
||||||
autoFocus={!isEdit}
|
disabled={isEdit}
|
||||||
/>
|
placeholder="如 translator"
|
||||||
</Field>
|
className={inputCls + (isEdit ? ' opacity-60 cursor-not-allowed' : '')}
|
||||||
<Field label="描述" hint="必填。仅当正文为空时,与名称一起生成兜底提示词;正文非空时不注入">
|
autoFocus={!isEdit}
|
||||||
<input
|
/>
|
||||||
value={editingExpert.description}
|
</Field>
|
||||||
onChange={e => setEditingExpert(prev => prev ? { ...prev, description: e.target.value } : prev)}
|
<Field label="描述" hint="必填。仅当正文为空时,与名称一起生成兜底提示词;正文非空时不注入">
|
||||||
placeholder="如 翻译专家"
|
<input
|
||||||
className={inputCls}
|
value={editingExpert.description}
|
||||||
/>
|
onChange={e => setEditingExpert(prev => prev ? { ...prev, description: e.target.value } : prev)}
|
||||||
</Field>
|
placeholder="如 翻译专家"
|
||||||
<Field label="专家提示词正文" hint="markdown 格式。非空时仅注入正文(不注入名称和描述);为空时自动用“名称+描述”生成兜底提示词">
|
className={inputCls}
|
||||||
<textarea
|
/>
|
||||||
value={editingExpert.body}
|
</Field>
|
||||||
onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)}
|
<Field label="专家提示词正文" hint="markdown 格式。非空时仅注入正文;为空时自动用“名称+描述”生成兜底提示词">
|
||||||
placeholder="你是一名专业翻译..."
|
<textarea
|
||||||
className={inputCls + ' min-h-[360px] resize-y font-mono text-xs'}
|
value={editingExpert.body}
|
||||||
/>
|
onChange={e => setEditingExpert(prev => prev ? { ...prev, body: e.target.value } : prev)}
|
||||||
</Field>
|
placeholder="你是一名专业翻译..."
|
||||||
|
className={inputCls + ' min-h-[160px] resize-y font-mono text-xs'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</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="白名单取交集,黑名单扣除">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="允许的技能(白名单)" hint="留空表示不限">
|
||||||
|
<CheckboxList
|
||||||
|
options={skillOptions}
|
||||||
|
selected={editingExpert.allowedSkills}
|
||||||
|
onChange={v => setEditingExpert(prev => prev ? { ...prev, allowedSkills: v } : prev)}
|
||||||
|
extraSelected={editingExpert.allowedSkills}
|
||||||
|
emptyHint={skillEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用">
|
||||||
|
<CheckboxList
|
||||||
|
options={skillOptions}
|
||||||
|
selected={editingExpert.deniedSkills}
|
||||||
|
onChange={v => setEditingExpert(prev => prev ? { ...prev, deniedSkills: v } : prev)}
|
||||||
|
extraSelected={editingExpert.deniedSkills}
|
||||||
|
emptyHint={skillEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="工具能力" subtitle="含 mcp_*">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="允许的工具(白名单)" hint="留空表示不限">
|
||||||
|
<CheckboxList
|
||||||
|
options={toolOptions}
|
||||||
|
selected={editingExpert.allowedTools}
|
||||||
|
onChange={v => setEditingExpert(prev => prev ? { ...prev, allowedTools: v } : prev)}
|
||||||
|
extraSelected={editingExpert.allowedTools}
|
||||||
|
emptyHint={toolEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="禁用的工具" hint="在白名单之后应用">
|
||||||
|
<CheckboxList
|
||||||
|
options={toolOptions}
|
||||||
|
selected={editingExpert.deniedTools}
|
||||||
|
onChange={v => setEditingExpert(prev => prev ? { ...prev, deniedTools: v } : prev)}
|
||||||
|
extraSelected={editingExpert.deniedTools}
|
||||||
|
emptyHint={toolEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="子代理能力" subtitle="限制可加载的子代理">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="允许的子代理(白名单)" hint="留空表示不限。仅勾选的子代理可被加载">
|
||||||
|
<CheckboxList
|
||||||
|
options={subagentOptions}
|
||||||
|
selected={editingExpert.allowedSubagents}
|
||||||
|
onChange={v => setEditingExpert(prev => prev ? { ...prev, allowedSubagents: v } : prev)}
|
||||||
|
extraSelected={editingExpert.allowedSubagents}
|
||||||
|
emptyHint={subagentEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="禁用的子代理(黑名单)" hint="在白名单之后应用">
|
||||||
|
<CheckboxList
|
||||||
|
options={subagentOptions}
|
||||||
|
selected={editingExpert.deniedSubagents}
|
||||||
|
onChange={v => setEditingExpert(prev => prev ? { ...prev, deniedSubagents: v } : prev)}
|
||||||
|
extraSelected={editingExpert.deniedSubagents}
|
||||||
|
emptyHint={subagentEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
{editingExpertError && (
|
{editingExpertError && (
|
||||||
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
{editingExpertError}
|
{editingExpertError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-end mt-5">
|
<ModalFooter>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingExpert(null)}
|
onClick={() => setEditingExpert(null)}
|
||||||
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
@ -914,7 +1136,202 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
|||||||
{savingExpert ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
{savingExpert ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
{savingExpert ? '保存中...' : '保存'}
|
{savingExpert ? '保存中...' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSubagentModal = () => {
|
||||||
|
if (!editingSubagent) return null
|
||||||
|
const canSave = editingSubagent.description.trim().length > 0
|
||||||
|
// 排除自身,避免子代理勾选自己造成意外自递归(max_nesting_depth 仍兜底)
|
||||||
|
const subagentOptionsExcludingSelf = subagentOptions.filter(o => o.key !== editingSubagent.name)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!canSave) return
|
||||||
|
setSavingSubagent(true)
|
||||||
|
setEditingSubagentError('')
|
||||||
|
try {
|
||||||
|
// 与专家一致:allowed_* 为空时传 undefined(None=不限),denied_* 空数组即"不禁"
|
||||||
|
// 必须包含全部 6 个字段,否则后端 #[serde(default)] 会让缺失字段变为 None/vec![],
|
||||||
|
// 经 next_capability 完全覆盖原 capability,导致既有策略被清空(数据丢失)
|
||||||
|
const capability: CapabilityPolicy = {
|
||||||
|
allowed_skills: editingSubagent.allowedSkills.length > 0 ? editingSubagent.allowedSkills : undefined,
|
||||||
|
denied_skills: editingSubagent.deniedSkills,
|
||||||
|
allowed_tools: editingSubagent.allowedTools.length > 0 ? editingSubagent.allowedTools : undefined,
|
||||||
|
denied_tools: editingSubagent.deniedTools,
|
||||||
|
allowed_subagents: editingSubagent.allowedSubagents.length > 0 ? editingSubagent.allowedSubagents : undefined,
|
||||||
|
denied_subagents: editingSubagent.deniedSubagents,
|
||||||
|
}
|
||||||
|
const resp = await updateSubagentCb({
|
||||||
|
name: editingSubagent.name,
|
||||||
|
description: editingSubagent.description,
|
||||||
|
capability,
|
||||||
|
provider: editingSubagent.provider || undefined,
|
||||||
|
model: editingSubagent.model || undefined,
|
||||||
|
})
|
||||||
|
const data = await resp.json().catch(() => ({}))
|
||||||
|
if (!resp.ok) {
|
||||||
|
setEditingSubagentError(data.error || data.message || '保存失败')
|
||||||
|
setSavingSubagent(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setToast('子代理已更新')
|
||||||
|
setTimeout(() => setToast(''), 3000)
|
||||||
|
setEditingSubagent(null)
|
||||||
|
fetchSubagentList()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setEditingSubagentError(e instanceof Error ? e.message : '网络错误')
|
||||||
|
} finally {
|
||||||
|
setSavingSubagent(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 backdrop-blur-sm rounded-2xl" onClick={() => setEditingSubagent(null)}>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-xl w-[90%] max-w-3xl mx-4 shadow-2xl animate-[scaleIn_0.2s_ease-out] max-h-[90%] flex flex-col overflow-hidden"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
icon={<Bot className="h-5 w-5 text-[var(--accent-cyan)]" />}
|
||||||
|
title="编辑子代理"
|
||||||
|
onClose={() => setEditingSubagent(null)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
<SectionCard title="基本信息">
|
||||||
|
<Field label="名称">
|
||||||
|
<input
|
||||||
|
value={editingSubagent.name}
|
||||||
|
disabled
|
||||||
|
className={inputCls + ' opacity-60 cursor-not-allowed'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="描述" hint="子代理的简短描述,用于主智能体选择">
|
||||||
|
<input
|
||||||
|
value={editingSubagent.description}
|
||||||
|
onChange={e => setEditingSubagent(prev => prev ? { ...prev, description: e.target.value } : prev)}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</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="白名单取交集,黑名单扣除">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="允许的技能(白名单)" hint="留空表示不限。仅勾选的 SKILL.md 技能可见">
|
||||||
|
<CheckboxList
|
||||||
|
options={skillOptions}
|
||||||
|
selected={editingSubagent.allowedSkills}
|
||||||
|
onChange={v => setEditingSubagent(prev => prev ? { ...prev, allowedSkills: v } : prev)}
|
||||||
|
extraSelected={editingSubagent.allowedSkills}
|
||||||
|
emptyHint={skillEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="禁用的技能(黑名单)" hint="在白名单之后应用">
|
||||||
|
<CheckboxList
|
||||||
|
options={skillOptions}
|
||||||
|
selected={editingSubagent.deniedSkills}
|
||||||
|
onChange={v => setEditingSubagent(prev => prev ? { ...prev, deniedSkills: v } : prev)}
|
||||||
|
extraSelected={editingSubagent.deniedSkills}
|
||||||
|
emptyHint={skillEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="工具能力" subtitle="含 mcp_*">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="允许的工具(白名单)" hint="留空表示不限。覆盖内置 + MCP 工具">
|
||||||
|
<CheckboxList
|
||||||
|
options={toolOptions}
|
||||||
|
selected={editingSubagent.allowedTools}
|
||||||
|
onChange={v => setEditingSubagent(prev => prev ? { ...prev, allowedTools: v } : prev)}
|
||||||
|
extraSelected={editingSubagent.allowedTools}
|
||||||
|
emptyHint={toolEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="禁用的工具" hint="在白名单之后应用">
|
||||||
|
<CheckboxList
|
||||||
|
options={toolOptions}
|
||||||
|
selected={editingSubagent.deniedTools}
|
||||||
|
onChange={v => setEditingSubagent(prev => prev ? { ...prev, deniedTools: v } : prev)}
|
||||||
|
extraSelected={editingSubagent.deniedTools}
|
||||||
|
emptyHint={toolEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
<SectionCard title="子代理能力" subtitle="限制可加载的孙代理">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<Field label="允许的子代理(白名单)" hint="留空表示不限。仅勾选的子代理可被加载为孙代理">
|
||||||
|
<CheckboxList
|
||||||
|
options={subagentOptionsExcludingSelf}
|
||||||
|
selected={editingSubagent.allowedSubagents}
|
||||||
|
onChange={v => setEditingSubagent(prev => prev ? { ...prev, allowedSubagents: v } : prev)}
|
||||||
|
extraSelected={editingSubagent.allowedSubagents}
|
||||||
|
emptyHint={subagentEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="禁用的子代理(黑名单)" hint="在白名单之后应用">
|
||||||
|
<CheckboxList
|
||||||
|
options={subagentOptionsExcludingSelf}
|
||||||
|
selected={editingSubagent.deniedSubagents}
|
||||||
|
onChange={v => setEditingSubagent(prev => prev ? { ...prev, deniedSubagents: v } : prev)}
|
||||||
|
extraSelected={editingSubagent.deniedSubagents}
|
||||||
|
emptyHint={subagentEmptyHint}
|
||||||
|
groupBy={o => o.group ?? '其他'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
{editingSubagentError && (
|
||||||
|
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">
|
||||||
|
{editingSubagentError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ModalFooter>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingSubagent(null)}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave || savingSubagent}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white bg-[var(--accent-cyan)]/20 border border-[var(--accent-cyan)]/30 hover:bg-[var(--accent-cyan)]/30 hover:border-[var(--accent-cyan)]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{savingSubagent ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
{savingSubagent ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
</ModalFooter>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export interface GatewayConfig { host: string; port: number; show_tool_results:
|
|||||||
export interface TimeConfig { timezone: string }
|
export interface TimeConfig { timezone: string }
|
||||||
export interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: SchedulerJobConfig[] }
|
export interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: SchedulerJobConfig[] }
|
||||||
export interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number }
|
export interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number }
|
||||||
export interface TaskConfig { enabled: boolean; max_execution_secs: number; ttl_hours: number; allowed_tools: string[] }
|
export interface TaskConfig { enabled: boolean; max_execution_secs: number; ttl_hours: number; allowed_tools: string[]; max_nesting_depth: number }
|
||||||
export interface ToolsConfig { disabled: string[]; task: TaskConfig }
|
export interface ToolsConfig { disabled: string[]; task: TaskConfig }
|
||||||
export interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number }
|
export interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number }
|
||||||
export interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number }
|
export interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number }
|
||||||
@ -40,13 +40,43 @@ export interface SkillListResponse {
|
|||||||
skills: SkillItem[]
|
skills: SkillItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolItem {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
/** "builtin" 或 "mcp:{server_key}" */
|
||||||
|
source: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolsListResponse {
|
||||||
|
total: number
|
||||||
|
tools: ToolItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框) */
|
||||||
|
export interface ModelOptionsResponse {
|
||||||
|
providers: string[]
|
||||||
|
models: string[]
|
||||||
|
/** 当前默认 agent 的 provider/model 名(前端用于在"继承默认"选项旁标注当前生效的模型) */
|
||||||
|
current: { provider: string, model: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityPolicy {
|
||||||
|
allowed_skills?: string[]
|
||||||
|
denied_skills: string[]
|
||||||
|
allowed_tools?: string[]
|
||||||
|
denied_tools: string[]
|
||||||
|
allowed_subagents?: string[]
|
||||||
|
denied_subagents: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubagentItem {
|
export interface SubagentItem {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
source: string
|
source: string
|
||||||
disabled_in_scopes: string[]
|
disabled_in_scopes: string[]
|
||||||
allowed_tools?: string[]
|
capability?: CapabilityPolicy
|
||||||
denied_tools?: string[]
|
provider?: string
|
||||||
|
model?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubagentListResponse {
|
export interface SubagentListResponse {
|
||||||
@ -63,6 +93,9 @@ export interface ExpertItem {
|
|||||||
path?: string
|
path?: string
|
||||||
body?: string
|
body?: string
|
||||||
disabled_in_scopes: string[]
|
disabled_in_scopes: string[]
|
||||||
|
capability?: CapabilityPolicy
|
||||||
|
provider?: string
|
||||||
|
model?: string
|
||||||
}
|
}
|
||||||
export interface ExpertListResponse {
|
export interface ExpertListResponse {
|
||||||
experts_system_enabled: boolean
|
experts_system_enabled: boolean
|
||||||
|
|||||||
@ -63,6 +63,33 @@ export function SectionCard({ title, subtitle, children }: { title: string; subt
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 模态框标题栏:图标 + 标题 + X 关闭按钮,与项目模态框惯例一致 */
|
||||||
|
export function ModalHeader({ icon, title, onClose }: { icon: ReactNode; title: string; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
|
||||||
|
{icon}
|
||||||
|
<span className="text-base font-semibold text-[var(--text-primary)]">{title}</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto p-2 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
aria-label="关闭"
|
||||||
|
title="关闭 (Esc)"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模态框底部按钮区,与项目模态框惯例一致 */
|
||||||
|
export function ModalFooter({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3 justify-end">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SourceEditor({
|
export function SourceEditor({
|
||||||
sources,
|
sources,
|
||||||
onChange,
|
onChange,
|
||||||
@ -152,6 +179,97 @@ export function SourceEditor({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CheckboxListOption {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
/** 可选分组标识,配合 groupBy 使用 */
|
||||||
|
group?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckboxListProps {
|
||||||
|
options: CheckboxListOption[]
|
||||||
|
selected: string[]
|
||||||
|
onChange: (selected: string[]) => void
|
||||||
|
/** 未在 options 中出现但已选中的值(legacy 数据),以可移除标签形式展示 */
|
||||||
|
extraSelected?: string[]
|
||||||
|
emptyHint?: string
|
||||||
|
/** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx") */
|
||||||
|
groupBy?: (option: CheckboxListOption) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用勾选列表组件:用 Toggle 切换每个预设选项;额外的 legacy 已选值以可移除标签展示。
|
||||||
|
*/
|
||||||
|
export function CheckboxList({ options, selected, onChange, extraSelected = [], emptyHint, groupBy }: CheckboxListProps) {
|
||||||
|
const toggle = (key: string) =>
|
||||||
|
onChange(selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key])
|
||||||
|
|
||||||
|
const selectedSet = new Set(selected)
|
||||||
|
const optionsInList = new Set(options.map(o => o.key))
|
||||||
|
// 仅展示未出现在 options 中的额外已选值
|
||||||
|
const extra = extraSelected.filter(k => !optionsInList.has(k))
|
||||||
|
|
||||||
|
// 分组渲染
|
||||||
|
const renderOptions = (opts: CheckboxListOption[]) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{opts.map(option => (
|
||||||
|
<div key={option.key} className="flex items-center justify-between py-1.5 gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm text-[var(--text-primary)] font-mono">{option.label}</div>
|
||||||
|
{option.description && <div className="text-xs text-[var(--text-muted)] truncate">{option.description}</div>}
|
||||||
|
</div>
|
||||||
|
<Toggle checked={selectedSet.has(option.key)} onChange={() => toggle(option.key)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
let body: ReactNode
|
||||||
|
if (options.length === 0) {
|
||||||
|
body = <p className="text-xs text-[var(--text-muted)]">{emptyHint || '无可用选项'}</p>
|
||||||
|
} else if (groupBy) {
|
||||||
|
const groups = new Map<string, CheckboxListOption[]>()
|
||||||
|
for (const opt of options) {
|
||||||
|
const g = groupBy(opt)
|
||||||
|
const arr = groups.get(g) ?? []
|
||||||
|
arr.push(opt)
|
||||||
|
groups.set(g, arr)
|
||||||
|
}
|
||||||
|
body = (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from(groups.entries()).map(([g, opts]) => (
|
||||||
|
<div key={g}>
|
||||||
|
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">{g}</div>
|
||||||
|
{renderOptions(opts)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
body = renderOptions(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{body}
|
||||||
|
{extra.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">其他已选(不在当前选项中)</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{extra.map(key => (
|
||||||
|
<span key={key} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
|
||||||
|
{key}
|
||||||
|
<button onClick={() => toggle(key)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
|
export function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [val, setVal] = useState(name)
|
const [val, setVal] = useState(name)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user