Compare commits

...

15 Commits

Author SHA1 Message Date
oudecheng
f8e1f3c2eb chore: bump 版本至 0.2.0 并新增 CHANGELOG
- Cargo.toml / Cargo.lock: 0.1.2 → 0.2.0
- 新增 docs/CHANGELOG.md,记录 0.2.0 相对 0.1.2 的 47 个 commit 迭代内容,
  按新增功能/性能优化/修复/重构/内部改进分类,涵盖四大方向:
  CapabilityPolicy 统一策略模型、专家/子代理模型独立配置、
  话题级并发隔离、Agent Loop 性能优化
2026-07-31 08:28:36 +08:00
oudecheng
7f05545488 fix: session pool 锁范围收敛与 replace_todos 原子性
- SessionPool.ensure_session_internal 改为 double-checked locking:
  先短暂持锁检查存在性,释放锁后执行耗时的 session 创建(含配置加载、
  agent 工厂构造),再次持锁插入并处理竞态。避免跨 session_factory.create().await
  持有全局锁导致所有 channel 的 session 访问串行化。
- storage::replace_todos 用 transaction() 包裹 DELETE + INSERT,保证原子性:
  中途失败自动回滚,避免 todos 列表丢失且无法恢复。事务内复用同一连接
  查询返回值,消除 drop(conn) 后重新 pool.get() 的冗余。
2026-07-31 08:23:13 +08:00
oudecheng
92db80dc3f perf(agent-loop): 缓存工具定义与轻量 token 估算,DB 写入 offload 至 blocking 池
- 工具定义在 process() 循环外构建一次(快照语义:单次 process 内 Arc<ToolRegistry> 不变)
- 工具 token 估算循环外预算一次,避免每轮 serde_json::to_string 全量序列化
- 新增 estimate_text_tokens_from_chat_messages 直接基于 ChatMessage 累加字段长度,
  消除中间 text_only_messages: Vec<Message> 构造(N 条消息 × 5 字段双克隆 → 0)
- PersistingEmittedMessageHandler 用 spawn_blocking 包装 SQLite 同步写,
  解除 tokio worker 线程阻塞,不改变失败语义(仅记日志不阻断主流程)
2026-07-31 08:22:58 +08:00
oudecheng
649ea2998d feat(web): 集成 ModelSelector 实现主 agent 模型选择 UI
新建 ModelSelector 组件(参考 ExpertSelector 交互模式): 显示当前生效模型,下拉支持 provider/model 选择,草稿暂存+应用提交,重置为默认。

ChatContainer 用共享 flex 容器并排渲染 ExpertSelector 和 ModelSelector,ExpertSelector 简化外层 wrapper(去除冗余 padding/max-width)。

API 层: 新增 sessionSelectedModel 端点常量和 getSelectedModel 函数; types.ts 为 ModelOptionsResponse 添加 current 字段。

按钮语义: 已覆盖时显示 provider/model,未覆盖时显示 '默认 provider/model'。
2026-07-30 23:25:14 +08:00
oudecheng
a825b10c48 feat(model): 主 agent 支持会话级模型覆盖
新增 ModelSelectionStore 存储 session_id -> (provider, model) 映射,职责单一,仅依赖 std,不引入业务模块耦合。

AgentFactory::create 中按链式覆盖应用模型配置: 专家 frontmatter 覆盖 -> 用户手动选择覆盖(最高优先级)。

HTTP API: POST /api/session/select-model 设置/清除用户模型覆盖(校验 provider/model 存在性); GET /api/session/selected-model 读取当前 session 的用户覆盖(与 experts/selected 对称)。

在 build_session_manager 系列函数中创建并注入 ModelSelectionStore,GatewayState 持有 Arc<ModelSelectionStore> 供 HTTP handler 访问。
2026-07-30 23:24:51 +08:00
oudecheng
5ff1e8455c feat(web,docs): 前端支持模型下拉选择并更新 README 专家系统章节
- 前端类型 ExpertItem/SubagentItem 新增 provider/model 字段,新增 ModelOptionsResponse 类型

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

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

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

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

- README 新增 max_nesting_depth 配置项说明,重排后续章节编号
2026-07-30 22:44:28 +08:00
oudecheng
dc9211548a feat(model): 专家和子代理支持独立配置 provider/model
- 新增 ModelResolver 解析器,按 frontmatter 中的 provider/model 名覆盖基础 LLMProviderConfig

- Expert/SubagentDef 数据结构新增 provider/model 字段,frontmatter 解析与渲染支持往返

- AgentFactory 和 DefaultSubAgentRuntime 持有 ModelResolver,在创建 agent 时解析模型覆盖

- HTTP API 新增 /api/model-options 端点,ExpertResponse/Create/Update 和 SubagentUpdateRequest 支持 provider/model

- update_expert/update_subagent 支持 provider/model 字段写回 frontmatter

- 保持架构解耦:ModelResolver 位于 config 底层,不引入新的跨模块依赖
2026-07-30 22:44:10 +08:00
oudecheng
003eab4f21 fix(web): 修复点击设置后白屏(React hooks 顺序违规)
子模态框 ESC 处理的 useEffect 被放在 if(loading)/if(!config) 条件 return 之后,导致首次渲染(loading=true)时不执行该 hook,config 加载后重新渲染才执行 → hooks 数量不一致 → React error #310 白屏崩溃。

修复:将 useEffect 移到所有条件 return 之前,与其他 hooks 放在一起。
2026-07-30 18:56:43 +08:00
oudecheng
951e31aca4 feat(web): 子代理模态框改造并暴露 max_nesting_depth 配置项
对抗审查修复 4 个 P0 问题:
- 子代理 modal handleSave 缺失 allowed_subagents/denied_subagents 字段,导致保存时既有策略被后端 #[serde(default)] 静默清空(数据丢失)
- 子代理 modal 缺失子代理黑白名单 CheckboxList UI
- 子代理 modal 未改造为 ModalHeader/ModalFooter/SectionCard,无 X 关闭按钮与遮罩 onClick
- experts tab 未加载 subagentList,编辑专家时子代理勾选列表显示空

新增改动:
- ui.tsx 新增 ModalHeader/ModalFooter 通用组件
- ConfigPage renderTools 新增最大嵌套深度输入框(后端 TaskConfig.max_nesting_depth 早已存在,默认 2)
- 子代理 modal 子代理选项排除自身避免意外自递归
2026-07-30 17:38:08 +08:00
oudecheng
c27efedb6c feat(capability): 扩展 CapabilityPolicy 支持子代理黑白名单
- domain: CapabilityPolicy 新增 allowed_subagents/denied_subagents 字段及 check_subagent_allowed 方法
- experts: 专家 frontmatter 解析/渲染支持子代理策略字段
- task/runtime: spawn/resume 双路径校验父代理子代理策略;新增 update_subagent 写回 SUBAGENT.md(与 update_expert 对称);新增 SubagentPromptProvider 按专家策略过滤子代理索引
- task/runtime: 子代理自身 capability 作为孙代理的 parent_capability 透传(ToolContext),保持解耦
- traits: ToolContext 新增 parent_capability 字段
- agent_factory: 主 agent 注入 expert_capability 到 ToolContext

安全:策略不通过即拒绝(与 def 不可用即拒绝范式一致),防止 LLM 通过选择被禁子代理绕过限制;max_nesting_depth 兜底防递归不可被 def 覆盖。
2026-07-30 17:37:29 +08:00
oudecheng
9381ed5dd4 feat(web): 专家/子代理 capability 字段改为勾选式编辑
- 专家编辑模态框:4 个 textarea 替换为 CheckboxList,技能/工具按来源分组展示

- 新增子代理编辑模态框:非 builtin 子代理显示编辑按钮,支持描述 + 4 个 capability 勾选字段

- 进入 experts/subagents 标签页时自动拉取技能与工具列表

- allowed_* 为空时传 undefined(后端 None=不限),避免空数组触发白名单空集语义
2026-07-30 16:55:38 +08:00
oudecheng
3ea0c19262 feat(web): 新增工具列表 API 客户端与 CheckboxList 勾选组件
- api/client.ts: 注册 tools 与 subagentsUpdate 端点常量

- api/tools.ts: 新增 listTools() 调用 /api/tools

- api/subagents.ts: 新增 updateSubagent() 调用 PUT /api/subagents/update

- types.ts: 新增 ToolItem / ToolsListResponse 类型

- ui.tsx: 新增 CheckboxList 组件,支持分组展示、legacy 已选值以可移除标签呈现
2026-07-30 16:55:12 +08:00
oudecheng
89c444ad3f feat(gateway): 新增 /api/tools 与 /api/subagents/update 端点
- /api/tools: 只读访问 ToolRegistry,返回 builtin + MCP 工具的 name/description/source

- /api/subagents/update: PUT 接口,调用 SubagentRuntime::update_subagent 写回 SUBAGENT.md frontmatter

- 路由表两处同步注册新端点
2026-07-30 16:54:19 +08:00
oudecheng
12f1094426 feat(capability): 新增 CapabilityPolicy 统一策略模型并集成至专家/子代理/技能
- domain: 定义 CapabilityPolicy(allowed/denied skills/tools)统一策略结构

- experts: 专家支持 capability 字段,新增 update_expert 写回 EXPERT.md

- skills: SkillRuntime 暴露 capability 过滤接口

- tools/task: SubagentDef 增加 capability 字段,SubagentRuntime 新增 update_subagent 写回 SUBAGENT.md,工具集按 capability 过滤

- agent_factory: 按 capability 构建专家/子代理工具集(白名单交集 + 黑名单扣除)
2026-07-30 16:53:30 +08:00
oudecheng
85c021f124 fix(deps): 升级 wechatbot 的 reqwest 至 0.13,消除版本冲突
主 crate 用 reqwest 0.13.2,而 vendored wechatbot 仍用 0.12,
导致 Cargo 同时编译两套 reqwest 及其传递依赖,拖慢编译、增大二进制。

改动:
- vendor/wechatbot/Cargo.toml: reqwest 0.12 -> 0.13,feature rustls-tls -> rustls
  (reqwest 0.13 移除了 rustls-tls feature,改为细粒度的 rustls)
- Cargo.lock: 合并两份 reqwest 为一份 0.13.3,移除 0.12.28 条目

wechatbot 对 reqwest 的使用仅限稳定客户端 API(Client::builder、
get/post/json/body/send、status/text/bytes/headers),0.12 -> 0.13 无破坏性变更,
源码零改动。

验证:
- cargo check 通过
- cargo test --no-run 全部测试二进制编译通过
- cargo test --lib: 494 passed,3 个失败经 stash 对照确认为预存技术债
2026-07-29 17:31:29 +08:00
34 changed files with 3041 additions and 458 deletions

47
Cargo.lock generated
View File

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

View File

@ -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
View File

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

117
docs/CHANGELOG.md Normal file
View 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

View File

@ -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_stringO(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<

View File

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

View File

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

View File

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

View File

@ -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,
// 注入专家 capabilityTaskTool 据此强制校验子代理白/黑名单
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)
}
}
} }

View File

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

View File

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

View 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))
);
}
}

View File

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

View File

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

View File

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

View File

@ -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()),
}) })
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
/** /**

View File

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

View File

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

View File

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

View File

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

View 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>
)
}

View File

@ -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_* 为空时传 undefinedNone=不限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>
) )

View File

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

View File

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