Compare commits
49 Commits
b2758d34e9
...
chore/sqlx
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc7a1d5400 | ||
|
|
d9b0b4f4f7 | ||
|
|
edd6dd5fc8 | ||
|
|
4329bae1ea | ||
|
|
924ad5a6ec | ||
|
|
e94235c4f9 | ||
|
|
72b3206a6b | ||
|
|
0fd78ac321 | ||
|
|
ab4d06c4d6 | ||
|
|
1595290db2 | ||
|
|
2c0602e0e6 | ||
|
|
f358f14f12 | ||
|
|
7cdcfaddb0 | ||
|
|
3c6581f915 | ||
|
|
cb727fdcc7 | ||
|
|
a9ea9d8691 | ||
|
|
f97e6fdbb6 | ||
|
|
7d03e6a90c | ||
|
|
415abf9e66 | ||
|
|
8d218e9ab9 | ||
|
|
e2d44ecf52 | ||
|
|
8ec6ca5990 | ||
|
|
7e8eb64c4a | ||
|
|
e88c51fd85 | ||
|
|
e10549a1b9 | ||
|
|
f3fb5340b5 | ||
|
|
35a11504d7 | ||
|
|
450569dc88 | ||
|
|
3a24455401 | ||
|
|
4e4eefdde1 | ||
|
|
0522f2bf95 | ||
|
|
04f70c797d | ||
|
|
a685e97b17 | ||
|
|
2037809196 | ||
|
|
eaa99a20db | ||
|
|
a38e91935f | ||
|
|
5687dc20e0 | ||
|
|
21c3222ad5 | ||
|
|
5381e316f0 | ||
|
|
96294d5b87 | ||
|
|
e3b6003be2 | ||
|
|
f9f5472d99 | ||
|
|
cb9e48f11d | ||
|
|
14fa7e150a | ||
|
|
f9290ea683 | ||
|
|
0754ea19c2 | ||
|
|
2cae822775 | ||
|
|
93df380ca8 | ||
|
|
90340725a4 |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
||||
|
||||
- name: Rust Clippy
|
||||
working-directory: .
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
run: cargo clippy --workspace --exclude zclaw-saas -- -D warnings
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: desktop
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: .
|
||||
run: cargo test --workspace
|
||||
run: cargo test --workspace --exclude zclaw-saas
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: desktop
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
|
||||
- name: Rust release build
|
||||
working-directory: .
|
||||
run: cargo build --release --workspace
|
||||
run: cargo build --release --workspace --exclude zclaw-saas
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: desktop
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Run Rust tests
|
||||
working-directory: .
|
||||
run: cargo test --workspace
|
||||
run: cargo test --workspace --exclude zclaw-saas
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: desktop
|
||||
|
||||
31
CLAUDE.md
31
CLAUDE.md
@@ -227,21 +227,22 @@ Client → 负责网络通信和协议转换
|
||||
|
||||
## 6. 自主能力系统 (Hands)
|
||||
|
||||
ZCLAW 提供 11 个自主能力包(9 启用 + 2 禁用):
|
||||
ZCLAW 提供 12 个自主能力包(7 已注册 + 3 开发中 + 2 禁用):
|
||||
|
||||
| Hand | 功能 | 状态 |
|
||||
|------|------|------|
|
||||
| Browser | 浏览器自动化 | ✅ 可用 |
|
||||
| Collector | 数据收集聚合 | ✅ 可用 |
|
||||
| Researcher | 深度研究 | ✅ 可用 |
|
||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
||||
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
||||
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
||||
| Speech | 语音合成 | ✅ 可用(Browser TTS 前端集成完成) |
|
||||
| Quiz | 测验生成 | ✅ 可用 |
|
||||
| _reminder | 系统内部提醒 | ✅ 可用(kernel 编程注册,无 HAND.toml) |
|
||||
| Whiteboard | 白板演示 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||
| Slideshow | 幻灯片生成 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||
| Speech | 语音合成 | 🚧 开发中(HAND.toml 未合并到主分支) |
|
||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
|
||||
**触发 Hand 时:**
|
||||
1. 检查依赖是否满足
|
||||
@@ -541,10 +542,10 @@ refactor(store): 统一 Store 数据获取方式
|
||||
| Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 |
|
||||
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
|
||||
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
|
||||
| 记忆管道 (Memory) | ✅ 稳定 | 04-02 闭环修复: 对话→提取→FTS5+TF-IDF→检索→注入 |
|
||||
| 记忆管道 (Memory) | ✅ 稳定 | 04-17 E2E 验证: 存储+FTS5+TF-IDF+注入闭环,去重+跨会话注入已修复 |
|
||||
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
|
||||
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
|
||||
| Hands 系统 | ✅ 稳定 | 9 启用 (Browser/Collector/Researcher/Twitter/Whiteboard/Slideshow/Speech/Quiz/Clip) |
|
||||
| Hands 系统 | ✅ 稳定 | 7 注册 (6 HAND.toml + _reminder),Whiteboard/Slideshow/Speech 开发中 |
|
||||
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
|
||||
| 中间件链 | ✅ 稳定 | 14 层 (ButlerRouter@80, DataMasking@90, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) |
|
||||
|
||||
@@ -555,17 +556,17 @@ refactor(store): 统一 Store 数据获取方式
|
||||
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。详见 [ARCHITECTURE_BRIEF.md](docs/ARCHITECTURE_BRIEF.md)
|
||||
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
|
||||
- **SaaS 认证**: JWT→OS keyring 存储 + HttpOnly cookie + Token池 RPM/TPM 限流轮换 + SaaS unreachable 自动降级
|
||||
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示
|
||||
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示(E2E 04-17 验证通过,去重+跨会话注入已修复)
|
||||
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
|
||||
|
||||
### 最近变更
|
||||
|
||||
1. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
||||
2. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
||||
2. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
||||
3. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
||||
3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite
|
||||
4. [04-06] 4 个发布前 bug 修复 (身份覆盖/模型配置/agent同步/自动身份)
|
||||
1. [04-17] 全系统 E2E 测试 129 链路: 82 PASS / 20 PARTIAL / 1 FAIL / 26 SKIP,有效通过率 79.1%。7 项 Bug 修复 (Dashboard 404/记忆去重/记忆注入/invoice_id/Prompt版本/agent隔离/行业字段)
|
||||
2. [04-16] 3 项 P0 修复 + 5 项 E2E Bug 修复 + Agent 面板刷新 + TRUTH.md 数字校准
|
||||
3. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
||||
4. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
||||
5. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
||||
6. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
||||
|
||||
<!-- ARCH-SNAPSHOT-END -->
|
||||
|
||||
|
||||
320
Cargo.lock
generated
320
Cargo.lock
generated
@@ -61,19 +61,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -179,7 +166,7 @@ version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
@@ -235,7 +222,7 @@ version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -253,7 +240,7 @@ dependencies = [
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix 1.1.4",
|
||||
]
|
||||
@@ -1606,9 +1593,9 @@ dependencies = [
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serde_yaml_bw",
|
||||
"sha2",
|
||||
"sqlx 0.7.4",
|
||||
"sqlx",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-mcp",
|
||||
@@ -1974,12 +1961,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -1997,7 +1978,7 @@ version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
@@ -2699,10 +2680,6 @@ name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
@@ -2726,15 +2703,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.10.0"
|
||||
@@ -2773,9 +2741,6 @@ name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -3555,9 +3520,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.27.0"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@@ -4358,12 +4323,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pathdiff"
|
||||
version = "0.2.3"
|
||||
@@ -4426,7 +4385,7 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b"
|
||||
dependencies = [
|
||||
"sqlx 0.8.6",
|
||||
"sqlx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5492,6 +5451,7 @@ version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5912,19 +5872,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"itoa 1.0.18",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml_bw"
|
||||
version = "2.5.3"
|
||||
@@ -6187,78 +6134,17 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlformat"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"unicode_categories",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa"
|
||||
dependencies = [
|
||||
"sqlx-core 0.7.4",
|
||||
"sqlx-macros 0.7.4",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres 0.7.4",
|
||||
"sqlx-sqlite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
|
||||
dependencies = [
|
||||
"sqlx-core 0.8.6",
|
||||
"sqlx-macros 0.8.6",
|
||||
"sqlx-postgres 0.8.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"atoi",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
"event-listener 2.5.3",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-intrusive",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hashlink 0.8.4",
|
||||
"hex",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"paste",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlformat",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6269,16 +6155,17 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-intrusive",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hashbrown 0.15.5",
|
||||
"hashlink 0.10.0",
|
||||
"hashlink",
|
||||
"indexmap 2.13.0",
|
||||
"log",
|
||||
"memchr",
|
||||
@@ -6289,23 +6176,12 @@ dependencies = [
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sqlx-core 0.7.4",
|
||||
"sqlx-macros-core 0.7.4",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.8.6"
|
||||
@@ -6314,37 +6190,11 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"sqlx-core 0.8.6",
|
||||
"sqlx-macros-core 0.8.6",
|
||||
"sqlx-core",
|
||||
"sqlx-macros-core",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8"
|
||||
dependencies = [
|
||||
"dotenvy",
|
||||
"either",
|
||||
"heck 0.4.1",
|
||||
"hex",
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx-core 0.7.4",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres 0.7.4",
|
||||
"sqlx-sqlite",
|
||||
"syn 1.0.109",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.8.6"
|
||||
@@ -6361,20 +6211,23 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx-core 0.8.6",
|
||||
"sqlx-postgres 0.8.6",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
"sqlx-sqlite",
|
||||
"syn 2.0.117",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.7.4"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418"
|
||||
checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.21.7",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
@@ -6403,48 +6256,9 @@ dependencies = [
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core 0.7.4",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 1.0.69",
|
||||
"tracing",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.21.7",
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"home",
|
||||
"itoa 1.0.18",
|
||||
"log",
|
||||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core 0.7.4",
|
||||
"stringprep",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"whoami",
|
||||
]
|
||||
@@ -6459,6 +6273,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
@@ -6479,7 +6294,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core 0.8.6",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
@@ -6488,9 +6303,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.7.4"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa"
|
||||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
@@ -6504,10 +6319,11 @@ dependencies = [
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"sqlx-core 0.7.4",
|
||||
"serde_urlencoded",
|
||||
"sqlx-core",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7824,12 +7640,6 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unicode_categories"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
@@ -7840,12 +7650,6 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml-norway"
|
||||
version = "0.2.15"
|
||||
@@ -7858,6 +7662,35 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -7895,6 +7728,12 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
@@ -9573,7 +9412,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener 5.4.1",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
@@ -9630,7 +9469,7 @@ dependencies = [
|
||||
"libsqlite3-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx 0.7.4",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
@@ -9696,7 +9535,7 @@ dependencies = [
|
||||
"libsqlite3-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx 0.7.4",
|
||||
"sqlx",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -9723,7 +9562,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zclaw-hands",
|
||||
"zclaw-kernel",
|
||||
"zclaw-runtime",
|
||||
"zclaw-skills",
|
||||
"zclaw-types",
|
||||
@@ -9809,7 +9647,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
"sqlx 0.7.4",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -9840,6 +9678,8 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ureq",
|
||||
"url",
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
|
||||
@@ -57,12 +57,15 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||
|
||||
# Database
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
|
||||
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
|
||||
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
|
||||
|
||||
# HTTP client (for LLM drivers)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||
|
||||
# Synchronous HTTP (for WASM host functions in blocking threads)
|
||||
ureq = { version = "3", features = ["rustls"] }
|
||||
|
||||
# URL parsing
|
||||
url = "2"
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ function Sidebar({
|
||||
const isActive =
|
||||
item.path === '/'
|
||||
? activePath === '/'
|
||||
: activePath.startsWith(item.path)
|
||||
: activePath === item.path || activePath.startsWith(item.path + '/')
|
||||
|
||||
const btn = (
|
||||
<button
|
||||
|
||||
@@ -3,5 +3,5 @@ import type { DashboardStats } from '@/types'
|
||||
|
||||
export const statsService = {
|
||||
dashboard: (signal?: AbortSignal) =>
|
||||
request.get<DashboardStats>('/stats/dashboard', withSignal({}, signal)).then((r) => r.data),
|
||||
request.get<DashboardStats>('/admin/dashboard', withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
305
crates/zclaw-growth/src/evolution_engine.rs
Normal file
305
crates/zclaw-growth/src/evolution_engine.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
//! 进化引擎中枢
|
||||
//! 协调 L1/L2/L3 三层进化的触发和执行
|
||||
//! L1 (记忆进化) 在 GrowthIntegration 中处理
|
||||
//! L2 (技能进化) 通过 PatternAggregator + SkillGenerator + QualityGate 协调
|
||||
//! L3 (工作流进化) 通过 WorkflowComposer 协调
|
||||
//! 反馈闭环通过 FeedbackCollector 管理
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::experience_store::ExperienceStore;
|
||||
use crate::feedback_collector::{
|
||||
FeedbackCollector, FeedbackEntry, TrustUpdate,
|
||||
};
|
||||
use crate::pattern_aggregator::{AggregatedPattern, PatternAggregator};
|
||||
use crate::quality_gate::{QualityGate, QualityReport};
|
||||
use crate::skill_generator::{SkillCandidate, SkillGenerator};
|
||||
use crate::workflow_composer::{ToolChainPattern, WorkflowComposer};
|
||||
use crate::VikingAdapter;
|
||||
use zclaw_types::Result;
|
||||
|
||||
/// 进化引擎配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvolutionConfig {
|
||||
/// 经验复用次数达到此阈值触发 L2
|
||||
pub min_reuse_for_skill: u32,
|
||||
/// 置信度阈值
|
||||
pub quality_confidence_threshold: f32,
|
||||
/// 是否启用进化引擎
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for EvolutionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_reuse_for_skill: 3,
|
||||
quality_confidence_threshold: 0.7,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 进化引擎中枢
|
||||
pub struct EvolutionEngine {
|
||||
viking: Arc<VikingAdapter>,
|
||||
feedback: Arc<tokio::sync::Mutex<FeedbackCollector>>,
|
||||
config: EvolutionConfig,
|
||||
}
|
||||
|
||||
impl EvolutionEngine {
|
||||
pub fn new(viking: Arc<VikingAdapter>) -> Self {
|
||||
Self {
|
||||
viking: viking.clone(),
|
||||
feedback: Arc::new(tokio::sync::Mutex::new(
|
||||
FeedbackCollector::with_viking(viking),
|
||||
)),
|
||||
config: EvolutionConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
/// Backward-compatible constructor
|
||||
/// 从 ExperienceStore 中提取共享的 VikingAdapter 实例
|
||||
pub fn from_experience_store(experience_store: Arc<ExperienceStore>) -> Self {
|
||||
let viking = experience_store.viking().clone();
|
||||
Self {
|
||||
viking: viking.clone(),
|
||||
feedback: Arc::new(tokio::sync::Mutex::new(
|
||||
FeedbackCollector::with_viking(viking),
|
||||
)),
|
||||
config: EvolutionConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
pub fn with_config(mut self, config: EvolutionConfig) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.config.enabled = enabled;
|
||||
}
|
||||
|
||||
/// L2 检查:是否有可进化的模式
|
||||
pub async fn check_evolvable_patterns(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
) -> Result<Vec<AggregatedPattern>> {
|
||||
if !self.config.enabled {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let store = ExperienceStore::new(self.viking.clone());
|
||||
let aggregator = PatternAggregator::new(store);
|
||||
aggregator
|
||||
.find_evolvable_patterns(agent_id, self.config.min_reuse_for_skill)
|
||||
.await
|
||||
}
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
/// L2 执行:为给定模式构建技能生成 prompt
|
||||
/// 返回 (prompt_string, pattern) 供上层通过 LLM 调用后 parse
|
||||
pub fn build_skill_prompt(&self, pattern: &AggregatedPattern) -> String {
|
||||
SkillGenerator::build_prompt(pattern)
|
||||
}
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
/// L2 执行:解析 LLM 返回的技能 JSON 并进行质量门控
|
||||
pub fn validate_skill_candidate(
|
||||
&self,
|
||||
json_str: &str,
|
||||
pattern: &AggregatedPattern,
|
||||
existing_triggers: Vec<String>,
|
||||
) -> Result<(SkillCandidate, QualityReport)> {
|
||||
let candidate = SkillGenerator::parse_response(json_str, pattern)?;
|
||||
let gate = QualityGate::new(self.config.quality_confidence_threshold, existing_triggers);
|
||||
let report = gate.validate_skill(&candidate);
|
||||
Ok((candidate, report))
|
||||
}
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
/// 获取当前配置
|
||||
pub fn config(&self) -> &EvolutionConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// L3: 工作流进化
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
/// L3: 从轨迹数据中提取重复的工具链模式
|
||||
pub fn analyze_trajectory_patterns(
|
||||
&self,
|
||||
trajectories: &[(String, Vec<String>)], // (session_id, tools_used)
|
||||
) -> Vec<(ToolChainPattern, Vec<String>)> {
|
||||
if !self.config.enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
WorkflowComposer::extract_patterns(trajectories)
|
||||
}
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
/// L3: 为给定工具链模式构建工作流生成 prompt
|
||||
pub fn build_workflow_prompt(
|
||||
&self,
|
||||
pattern: &ToolChainPattern,
|
||||
frequency: usize,
|
||||
industry: Option<&str>,
|
||||
) -> String {
|
||||
WorkflowComposer::build_prompt(pattern, frequency, industry)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 反馈闭环
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// 提交反馈并获取信任度更新,自动持久化
|
||||
pub async fn submit_feedback(&self, entry: FeedbackEntry) -> TrustUpdate {
|
||||
let mut feedback = self.feedback.lock().await;
|
||||
let update = feedback.submit_feedback(entry);
|
||||
// 非阻塞持久化:失败仅打日志,不影响返回值
|
||||
if let Err(e) = feedback.save().await {
|
||||
tracing::warn!("[EvolutionEngine] Failed to persist trust records: {}", e);
|
||||
}
|
||||
update
|
||||
}
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
/// 获取需要优化的进化产物
|
||||
pub async fn get_artifacts_needing_optimization(&self) -> Vec<String> {
|
||||
self.feedback
|
||||
.lock()
|
||||
.await
|
||||
.get_artifacts_needing_optimization()
|
||||
.iter()
|
||||
.map(|r| r.artifact_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
/// 获取建议归档的进化产物
|
||||
pub async fn get_artifacts_to_archive(&self) -> Vec<String> {
|
||||
self.feedback
|
||||
.lock()
|
||||
.await
|
||||
.get_artifacts_to_archive()
|
||||
.iter()
|
||||
.map(|r| r.artifact_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
|
||||
/// 获取推荐产物
|
||||
pub async fn get_recommended_artifacts(&self) -> Vec<String> {
|
||||
self.feedback
|
||||
.lock()
|
||||
.await
|
||||
.get_recommended_artifacts()
|
||||
.iter()
|
||||
.map(|r| r.artifact_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 启动时加载已持久化的信任度记录
|
||||
pub async fn load_feedback(&self) -> Result<usize> {
|
||||
self.feedback
|
||||
.lock()
|
||||
.await
|
||||
.load()
|
||||
.await
|
||||
.map_err(|e| zclaw_types::ZclawError::Internal(e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::experience_store::Experience;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_disabled_returns_empty() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let mut engine = EvolutionEngine::new(viking);
|
||||
engine.set_enabled(false);
|
||||
|
||||
let patterns = engine.check_evolvable_patterns("agent-1").await.unwrap();
|
||||
assert!(patterns.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_evolvable_patterns() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let engine = EvolutionEngine::new(viking);
|
||||
|
||||
let patterns = engine.check_evolvable_patterns("unknown-agent").await.unwrap();
|
||||
assert!(patterns.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_finds_evolvable_pattern() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let store_inner = ExperienceStore::new(viking.clone());
|
||||
|
||||
let mut exp = Experience::new(
|
||||
"agent-1",
|
||||
"report generation",
|
||||
"researcher",
|
||||
vec!["query db".into(), "format".into()],
|
||||
"success",
|
||||
);
|
||||
exp.reuse_count = 5;
|
||||
store_inner.store_experience(&exp).await.unwrap();
|
||||
|
||||
let engine = EvolutionEngine::new(viking);
|
||||
|
||||
let patterns = engine.check_evolvable_patterns("agent-1").await.unwrap();
|
||||
assert_eq!(patterns.len(), 1);
|
||||
assert_eq!(patterns[0].pain_pattern, "report generation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_skill_prompt() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let engine = EvolutionEngine::new(viking);
|
||||
|
||||
let exp = Experience::new(
|
||||
"a", "report", "researcher", vec!["step1".into()], "ok",
|
||||
);
|
||||
let pattern = AggregatedPattern {
|
||||
pain_pattern: "report".to_string(),
|
||||
experiences: vec![exp],
|
||||
common_steps: vec!["step1".into()],
|
||||
total_reuse: 5,
|
||||
tools_used: vec!["researcher".into()],
|
||||
industry_context: None,
|
||||
};
|
||||
let prompt = engine.build_skill_prompt(&pattern);
|
||||
assert!(prompt.contains("report"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_skill_candidate() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let engine = EvolutionEngine::new(viking);
|
||||
|
||||
let exp = Experience::new(
|
||||
"a", "report", "researcher", vec!["step1".into()], "ok",
|
||||
);
|
||||
let pattern = AggregatedPattern {
|
||||
pain_pattern: "report".to_string(),
|
||||
experiences: vec![exp],
|
||||
common_steps: vec!["step1".into()],
|
||||
total_reuse: 5,
|
||||
tools_used: vec!["researcher".into()],
|
||||
industry_context: None,
|
||||
};
|
||||
|
||||
let json = r##"{"name":"报表技能","description":"生成报表","triggers":["报表","日报"],"tools":["researcher"],"body_markdown":"# 报表\n步骤","confidence":0.9}"##;
|
||||
let (candidate, report) = engine
|
||||
.validate_skill_candidate(json, &pattern, vec!["搜索".to_string()])
|
||||
.unwrap();
|
||||
assert_eq!(candidate.name, "报表技能");
|
||||
assert!(report.passed);
|
||||
}
|
||||
}
|
||||
119
crates/zclaw-growth/src/experience_extractor.rs
Normal file
119
crates/zclaw-growth/src/experience_extractor.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
//! 结构化经验提取器
|
||||
//! 从对话中提取 ExperienceCandidate(pain_pattern → solution_steps → outcome)
|
||||
//! 持久化到 ExperienceStore
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::experience_store::ExperienceStore;
|
||||
use crate::types::{CombinedExtraction, Outcome};
|
||||
|
||||
/// 结构化经验提取器
|
||||
/// LLM 调用已由上层 MemoryExtractor 完成,这里只做解析和持久化
|
||||
pub struct ExperienceExtractor {
|
||||
store: Option<Arc<ExperienceStore>>,
|
||||
}
|
||||
|
||||
impl ExperienceExtractor {
|
||||
pub fn new() -> Self {
|
||||
Self { store: None }
|
||||
}
|
||||
|
||||
pub fn with_store(mut self, store: Arc<ExperienceStore>) -> Self {
|
||||
self.store = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// 从 CombinedExtraction 中提取经验并持久化
|
||||
/// LLM 调用已由上层完成,这里只做解析和存储
|
||||
pub async fn persist_experiences(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
extraction: &CombinedExtraction,
|
||||
) -> zclaw_types::Result<usize> {
|
||||
let store = match &self.store {
|
||||
Some(s) => s,
|
||||
None => return Ok(0),
|
||||
};
|
||||
|
||||
let mut count = 0;
|
||||
for candidate in &extraction.experiences {
|
||||
if candidate.confidence < 0.6 {
|
||||
continue;
|
||||
}
|
||||
let outcome_str = match candidate.outcome {
|
||||
Outcome::Success => "success",
|
||||
Outcome::Partial => "partial",
|
||||
Outcome::Failed => "failed",
|
||||
};
|
||||
let mut exp = crate::experience_store::Experience::new(
|
||||
agent_id,
|
||||
&candidate.pain_pattern,
|
||||
&candidate.context,
|
||||
candidate.solution_steps.clone(),
|
||||
outcome_str,
|
||||
);
|
||||
// 填充 tool_used:取 tools_used 中的第一个作为主要工具
|
||||
exp.tool_used = candidate.tools_used.first().cloned();
|
||||
exp.industry_context = candidate.industry_context.clone();
|
||||
store.store_experience(&exp).await?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExperienceExtractor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{ExperienceCandidate, Outcome};
|
||||
|
||||
#[test]
|
||||
fn test_extractor_new_without_store() {
|
||||
let ext = ExperienceExtractor::new();
|
||||
assert!(ext.store.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_persist_no_store_returns_zero() {
|
||||
let ext = ExperienceExtractor::new();
|
||||
let extraction = CombinedExtraction::default();
|
||||
let count = ext.persist_experiences("agent1", &extraction).await.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_persist_filters_low_confidence() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let store = Arc::new(ExperienceStore::new(viking));
|
||||
let ext = ExperienceExtractor::new().with_store(store);
|
||||
|
||||
let mut extraction = CombinedExtraction::default();
|
||||
extraction.experiences.push(ExperienceCandidate {
|
||||
pain_pattern: "low confidence task".to_string(),
|
||||
context: "should be filtered".to_string(),
|
||||
solution_steps: vec!["step1".to_string()],
|
||||
outcome: Outcome::Success,
|
||||
confidence: 0.3, // 低于 0.6 阈值
|
||||
tools_used: vec![],
|
||||
industry_context: None,
|
||||
});
|
||||
extraction.experiences.push(ExperienceCandidate {
|
||||
pain_pattern: "high confidence task".to_string(),
|
||||
context: "should be stored".to_string(),
|
||||
solution_steps: vec!["step1".to_string(), "step2".to_string()],
|
||||
outcome: Outcome::Success,
|
||||
confidence: 0.9,
|
||||
tools_used: vec!["researcher".to_string()],
|
||||
industry_context: Some("healthcare".to_string()),
|
||||
});
|
||||
|
||||
let count = ext.persist_experiences("agent-1", &extraction).await.unwrap();
|
||||
assert_eq!(count, 1); // 只有 1 个通过置信度过滤
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,9 @@ pub struct Experience {
|
||||
/// Which trigger signal produced this experience.
|
||||
#[serde(default)]
|
||||
pub source_trigger: Option<String>,
|
||||
/// Primary tool/skill used to resolve this pain point.
|
||||
#[serde(default)]
|
||||
pub tool_used: Option<String>,
|
||||
}
|
||||
|
||||
impl Experience {
|
||||
@@ -72,6 +75,7 @@ impl Experience {
|
||||
updated_at: now,
|
||||
industry_context: None,
|
||||
source_trigger: None,
|
||||
tool_used: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +113,11 @@ impl ExperienceStore {
|
||||
Self { viking }
|
||||
}
|
||||
|
||||
/// Get a reference to the underlying VikingAdapter.
|
||||
pub fn viking(&self) -> &Arc<VikingAdapter> {
|
||||
&self.viking
|
||||
}
|
||||
|
||||
/// Store (or overwrite) an experience. The URI is derived from
|
||||
/// `agent_id + pain_pattern`, ensuring one experience per pattern.
|
||||
pub async fn store_experience(&self, exp: &Experience) -> zclaw_types::Result<()> {
|
||||
@@ -119,6 +128,9 @@ impl ExperienceStore {
|
||||
if let Some(ref industry) = exp.industry_context {
|
||||
keywords.push(industry.clone());
|
||||
}
|
||||
if let Some(ref tool) = exp.tool_used {
|
||||
keywords.push(tool.clone());
|
||||
}
|
||||
|
||||
let entry = MemoryEntry {
|
||||
uri,
|
||||
|
||||
@@ -19,6 +19,34 @@ pub trait LlmDriverForExtraction: Send + Sync {
|
||||
messages: &[Message],
|
||||
extraction_type: MemoryType,
|
||||
) -> Result<Vec<ExtractedMemory>>;
|
||||
|
||||
/// 单次 LLM 调用提取全部类型(记忆 + 经验 + 画像信号)
|
||||
/// 默认实现:退化到 3 次独立调用(experiences 和 profile_signals 为空)
|
||||
async fn extract_combined_all(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
) -> Result<crate::types::CombinedExtraction> {
|
||||
let mut combined = crate::types::CombinedExtraction::default();
|
||||
for mt in [MemoryType::Preference, MemoryType::Knowledge, MemoryType::Experience] {
|
||||
if let Ok(mems) = self.extract_memories(messages, mt).await {
|
||||
combined.memories.extend(mems);
|
||||
}
|
||||
}
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
/// 使用自定义 prompt 进行单次 LLM 调用,返回原始文本响应
|
||||
/// 用于统一提取场景,默认返回不支持错误
|
||||
async fn extract_with_prompt(
|
||||
&self,
|
||||
_messages: &[Message],
|
||||
_system_prompt: &str,
|
||||
_user_prompt: &str,
|
||||
) -> Result<String> {
|
||||
Err(zclaw_types::ZclawError::Internal(
|
||||
"extract_with_prompt not implemented".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory Extractor - extracts memories from conversations
|
||||
@@ -85,13 +113,10 @@ impl MemoryExtractor {
|
||||
session_id: SessionId,
|
||||
) -> Result<Vec<ExtractedMemory>> {
|
||||
// Check if LLM driver is available
|
||||
let _llm_driver = match &self.llm_driver {
|
||||
Some(driver) => driver,
|
||||
None => {
|
||||
tracing::debug!("[MemoryExtractor] No LLM driver configured, skipping extraction");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
if self.llm_driver.is_none() {
|
||||
tracing::debug!("[MemoryExtractor] No LLM driver configured, skipping extraction");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -227,6 +252,299 @@ impl MemoryExtractor {
|
||||
tracing::info!("[MemoryExtractor] Stored {} memories to OpenViking", stored);
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
/// 统一提取:单次 LLM 调用同时产出 memories + experiences + profile_signals
|
||||
///
|
||||
/// 优先使用 `extract_with_prompt()` 进行单次调用;若 driver 不支持则
|
||||
/// 退化为 `extract()` + 从记忆推断经验/画像。
|
||||
pub async fn extract_combined(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
session_id: SessionId,
|
||||
) -> Result<crate::types::CombinedExtraction> {
|
||||
let llm_driver = match &self.llm_driver {
|
||||
Some(driver) => driver,
|
||||
None => {
|
||||
tracing::debug!(
|
||||
"[MemoryExtractor] No LLM driver configured, skipping combined extraction"
|
||||
);
|
||||
return Ok(crate::types::CombinedExtraction::default());
|
||||
}
|
||||
};
|
||||
|
||||
// 尝试单次 LLM 调用路径
|
||||
let system_prompt = "You are a memory extraction assistant. Analyze conversations and extract \
|
||||
structured memories, experiences, and profile signals in valid JSON format. \
|
||||
Always respond with valid JSON only, no additional text or markdown formatting.";
|
||||
let user_prompt = format!(
|
||||
"{}{}",
|
||||
crate::extractor::prompts::COMBINED_EXTRACTION_PROMPT,
|
||||
format_conversation_text(messages)
|
||||
);
|
||||
|
||||
match llm_driver
|
||||
.extract_with_prompt(messages, system_prompt, &user_prompt)
|
||||
.await
|
||||
{
|
||||
Ok(raw_text) if !raw_text.trim().is_empty() => {
|
||||
match parse_combined_response(&raw_text, session_id.clone()) {
|
||||
Ok(combined) => {
|
||||
tracing::info!(
|
||||
"[MemoryExtractor] Combined extraction: {} memories, {} experiences, {} profile signals",
|
||||
combined.memories.len(),
|
||||
combined.experiences.len(),
|
||||
combined.profile_signals.signal_count(),
|
||||
);
|
||||
return Ok(combined);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[MemoryExtractor] Combined response parse failed, falling back: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::debug!("[MemoryExtractor] extract_with_prompt returned empty, falling back");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"[MemoryExtractor] extract_with_prompt not supported ({}), falling back",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 退化路径:使用已有的 extract() 然后推断 experiences 和 profile_signals
|
||||
let memories = self.extract(messages, session_id).await?;
|
||||
let experiences = infer_experiences_from_memories(&memories);
|
||||
let profile_signals = infer_profile_signals_from_memories(&memories);
|
||||
|
||||
Ok(crate::types::CombinedExtraction {
|
||||
memories,
|
||||
experiences,
|
||||
profile_signals,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化对话消息为文本
|
||||
fn format_conversation_text(messages: &[Message]) -> String {
|
||||
messages
|
||||
.iter()
|
||||
.filter_map(|msg| match msg {
|
||||
Message::User { content } => Some(format!("[User]: {}", content)),
|
||||
Message::Assistant { content, .. } => Some(format!("[Assistant]: {}", content)),
|
||||
Message::System { content } => Some(format!("[System]: {}", content)),
|
||||
Message::ToolUse { .. } | Message::ToolResult { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
}
|
||||
|
||||
/// 从 LLM 原始响应解析 CombinedExtraction
|
||||
pub fn parse_combined_response(
|
||||
raw: &str,
|
||||
session_id: SessionId,
|
||||
) -> Result<crate::types::CombinedExtraction> {
|
||||
use crate::types::CombinedExtraction;
|
||||
|
||||
let json_str = crate::json_utils::extract_json_block(raw);
|
||||
let parsed: serde_json::Value = serde_json::from_str(json_str).map_err(|e| {
|
||||
zclaw_types::ZclawError::Internal(format!("Failed to parse combined JSON: {}", e))
|
||||
})?;
|
||||
|
||||
// 解析 memories
|
||||
let memories = parsed
|
||||
.get("memories")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| parse_memory_item(item, &session_id))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// 解析 experiences
|
||||
let experiences = parsed
|
||||
.get("experiences")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(parse_experience_item)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// 解析 profile_signals
|
||||
let profile_signals = parse_profile_signals(&parsed);
|
||||
|
||||
Ok(CombinedExtraction {
|
||||
memories,
|
||||
experiences,
|
||||
profile_signals,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析单个 memory 项
|
||||
fn parse_memory_item(
|
||||
value: &serde_json::Value,
|
||||
session_id: &SessionId,
|
||||
) -> Option<ExtractedMemory> {
|
||||
let content = value.get("content")?.as_str()?.to_string();
|
||||
let category = value
|
||||
.get("category")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let memory_type_str = value
|
||||
.get("memory_type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("knowledge");
|
||||
let memory_type = crate::types::MemoryType::parse(memory_type_str);
|
||||
let confidence = value
|
||||
.get("confidence")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.7) as f32;
|
||||
let keywords = crate::json_utils::extract_string_array(value, "keywords");
|
||||
|
||||
Some(
|
||||
ExtractedMemory::new(memory_type, category, content, session_id.clone())
|
||||
.with_confidence(confidence)
|
||||
.with_keywords(keywords),
|
||||
)
|
||||
}
|
||||
|
||||
/// 解析单个 experience 项
|
||||
fn parse_experience_item(value: &serde_json::Value) -> Option<crate::types::ExperienceCandidate> {
|
||||
use crate::types::Outcome;
|
||||
|
||||
let pain_pattern = value.get("pain_pattern")?.as_str()?.to_string();
|
||||
let context = value
|
||||
.get("context")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let solution_steps = crate::json_utils::extract_string_array(value, "solution_steps");
|
||||
let outcome_str = value
|
||||
.get("outcome")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("partial");
|
||||
let outcome = match outcome_str {
|
||||
"success" => Outcome::Success,
|
||||
"failed" => Outcome::Failed,
|
||||
_ => Outcome::Partial,
|
||||
};
|
||||
let confidence = value
|
||||
.get("confidence")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.6) as f32;
|
||||
let tools_used = crate::json_utils::extract_string_array(value, "tools_used");
|
||||
let industry_context = value
|
||||
.get("industry_context")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
Some(crate::types::ExperienceCandidate {
|
||||
pain_pattern,
|
||||
context,
|
||||
solution_steps,
|
||||
outcome,
|
||||
confidence,
|
||||
tools_used,
|
||||
industry_context,
|
||||
})
|
||||
}
|
||||
|
||||
/// 解析 profile_signals
|
||||
fn parse_profile_signals(obj: &serde_json::Value) -> crate::types::ProfileSignals {
|
||||
let signals = obj.get("profile_signals");
|
||||
crate::types::ProfileSignals {
|
||||
industry: signals
|
||||
.and_then(|s| s.get("industry"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
recent_topic: signals
|
||||
.and_then(|s| s.get("recent_topic"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
pain_point: signals
|
||||
.and_then(|s| s.get("pain_point"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
preferred_tool: signals
|
||||
.and_then(|s| s.get("preferred_tool"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
communication_style: signals
|
||||
.and_then(|s| s.get("communication_style"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从已有记忆推断结构化经验(退化路径)
|
||||
fn infer_experiences_from_memories(
|
||||
memories: &[ExtractedMemory],
|
||||
) -> Vec<crate::types::ExperienceCandidate> {
|
||||
memories
|
||||
.iter()
|
||||
.filter(|m| m.memory_type == crate::types::MemoryType::Experience)
|
||||
.filter_map(|m| {
|
||||
// 经验类记忆 → ExperienceCandidate
|
||||
let content = &m.content;
|
||||
if content.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
Some(crate::types::ExperienceCandidate {
|
||||
pain_pattern: m.category.clone(),
|
||||
context: content.clone(),
|
||||
solution_steps: Vec::new(),
|
||||
outcome: crate::types::Outcome::Partial,
|
||||
confidence: m.confidence * 0.7, // 降低推断置信度
|
||||
tools_used: m.keywords.clone(),
|
||||
industry_context: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 从已有记忆推断画像信号(退化路径)
|
||||
fn infer_profile_signals_from_memories(
|
||||
memories: &[ExtractedMemory],
|
||||
) -> crate::types::ProfileSignals {
|
||||
use crate::types::ProfileSignals;
|
||||
|
||||
let mut signals = ProfileSignals::default();
|
||||
for m in memories {
|
||||
match m.memory_type {
|
||||
crate::types::MemoryType::Preference => {
|
||||
if m.category.contains("style") || m.category.contains("风格") {
|
||||
if signals.communication_style.is_none() {
|
||||
signals.communication_style = Some(m.content.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::types::MemoryType::Knowledge => {
|
||||
if signals.recent_topic.is_none() && !m.keywords.is_empty() {
|
||||
signals.recent_topic = Some(m.keywords.first().cloned().unwrap_or_default());
|
||||
}
|
||||
}
|
||||
crate::types::MemoryType::Experience => {
|
||||
for kw in &m.keywords {
|
||||
if signals.preferred_tool.is_none()
|
||||
&& m.content.contains(kw.as_str())
|
||||
{
|
||||
signals.preferred_tool = Some(kw.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
signals
|
||||
}
|
||||
|
||||
/// Default extraction prompts for LLM
|
||||
@@ -243,6 +561,55 @@ pub mod prompts {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一提取 prompt — 单次 LLM 调用同时提取记忆、结构化经验、画像信号
|
||||
pub const COMBINED_EXTRACTION_PROMPT: &str = r#"
|
||||
分析以下对话,一次性提取三类信息。严格按 JSON 格式返回。
|
||||
|
||||
## 输出格式
|
||||
|
||||
```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_type": "preference|knowledge|experience",
|
||||
"category": "分类标签",
|
||||
"content": "记忆内容",
|
||||
"confidence": 0.0-1.0,
|
||||
"keywords": ["关键词"]
|
||||
}
|
||||
],
|
||||
"experiences": [
|
||||
{
|
||||
"pain_pattern": "痛点模式简述",
|
||||
"context": "问题发生的上下文",
|
||||
"solution_steps": ["步骤1", "步骤2"],
|
||||
"outcome": "success|partial|failed",
|
||||
"confidence": 0.0-1.0,
|
||||
"tools_used": ["使用的工具/技能"],
|
||||
"industry_context": "行业标识(可选)"
|
||||
}
|
||||
],
|
||||
"profile_signals": {
|
||||
"industry": "用户所在行业(可选)",
|
||||
"recent_topic": "最近讨论的主要话题(可选)",
|
||||
"pain_point": "用户当前痛点(可选)",
|
||||
"preferred_tool": "用户偏好的工具/技能(可选)",
|
||||
"communication_style": "沟通风格: concise|detailed|formal|casual(可选)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 提取规则
|
||||
|
||||
1. **memories**: 提取用户偏好(沟通风格/格式/语言)、知识(事实/领域知识/经验教训)、使用经验(技能/工具使用模式和结果)
|
||||
2. **experiences**: 仅提取明确的"问题→解决"模式,要求有清晰的痛点和步骤,confidence >= 0.6
|
||||
3. **profile_signals**: 从对话中推断用户画像信息,只在有明确信号时填写,留空则不填
|
||||
4. 每个字段都要有实际内容,不确定的宁可省略
|
||||
5. 只返回 JSON,不要附加其他文本
|
||||
|
||||
对话内容:
|
||||
"#;
|
||||
|
||||
const PREFERENCE_EXTRACTION_PROMPT: &str = r#"
|
||||
分析以下对话,提取用户的偏好设置。关注:
|
||||
- 沟通风格偏好(简洁/详细、正式/随意)
|
||||
@@ -362,11 +729,103 @@ mod tests {
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_combined_all_default_impl() {
|
||||
let driver = MockLlmDriver;
|
||||
let messages = vec![Message::user("Hello")];
|
||||
let result = driver.extract_combined_all(&messages).await.unwrap();
|
||||
assert_eq!(result.memories.len(), 3); // 3 types
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompts_available() {
|
||||
assert!(!prompts::get_extraction_prompt(MemoryType::Preference).is_empty());
|
||||
assert!(!prompts::get_extraction_prompt(MemoryType::Knowledge).is_empty());
|
||||
assert!(!prompts::get_extraction_prompt(MemoryType::Experience).is_empty());
|
||||
assert!(!prompts::get_extraction_prompt(MemoryType::Session).is_empty());
|
||||
assert!(!prompts::COMBINED_EXTRACTION_PROMPT.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_combined_response_full() {
|
||||
let raw = r#"```json
|
||||
{
|
||||
"memories": [
|
||||
{
|
||||
"memory_type": "preference",
|
||||
"category": "communication-style",
|
||||
"content": "用户偏好简洁回复",
|
||||
"confidence": 0.9,
|
||||
"keywords": ["简洁", "风格"]
|
||||
},
|
||||
{
|
||||
"memory_type": "knowledge",
|
||||
"category": "user-facts",
|
||||
"content": "用户是医院行政人员",
|
||||
"confidence": 0.85,
|
||||
"keywords": ["医院", "行政"]
|
||||
}
|
||||
],
|
||||
"experiences": [
|
||||
{
|
||||
"pain_pattern": "报表生成耗时",
|
||||
"context": "月度报表需要手动汇总多个Excel",
|
||||
"solution_steps": ["使用researcher工具自动抓取", "格式化输出为Excel"],
|
||||
"outcome": "success",
|
||||
"confidence": 0.85,
|
||||
"tools_used": ["researcher"],
|
||||
"industry_context": "healthcare"
|
||||
}
|
||||
],
|
||||
"profile_signals": {
|
||||
"industry": "healthcare",
|
||||
"recent_topic": "报表自动化",
|
||||
"pain_point": "手动汇总Excel太慢",
|
||||
"preferred_tool": "researcher",
|
||||
"communication_style": "concise"
|
||||
}
|
||||
}
|
||||
```"#;
|
||||
|
||||
let result = super::parse_combined_response(raw, SessionId::new()).unwrap();
|
||||
assert_eq!(result.memories.len(), 2);
|
||||
assert_eq!(result.experiences.len(), 1);
|
||||
assert_eq!(result.experiences[0].pain_pattern, "报表生成耗时");
|
||||
assert_eq!(result.experiences[0].outcome, crate::types::Outcome::Success);
|
||||
assert_eq!(result.profile_signals.industry.as_deref(), Some("healthcare"));
|
||||
assert_eq!(result.profile_signals.pain_point.as_deref(), Some("手动汇总Excel太慢"));
|
||||
assert!(result.profile_signals.has_any_signal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_combined_response_minimal() {
|
||||
let raw = r#"{"memories": [], "experiences": [], "profile_signals": {}}"#;
|
||||
let result = super::parse_combined_response(raw, SessionId::new()).unwrap();
|
||||
assert!(result.memories.is_empty());
|
||||
assert!(result.experiences.is_empty());
|
||||
assert!(!result.profile_signals.has_any_signal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_combined_response_invalid() {
|
||||
let raw = "not json at all";
|
||||
let result = super::parse_combined_response(raw, SessionId::new());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_extract_combined_fallback() {
|
||||
// MockLlmDriver doesn't implement extract_with_prompt, so it falls back
|
||||
let driver = Arc::new(MockLlmDriver);
|
||||
let extractor = MemoryExtractor::new(driver);
|
||||
let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
|
||||
|
||||
let result = extractor
|
||||
.extract_combined(&messages, SessionId::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Fallback: extract() produces 3 memories, infer produces experiences from them
|
||||
assert!(!result.memories.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
448
crates/zclaw-growth/src/feedback_collector.rs
Normal file
448
crates/zclaw-growth/src/feedback_collector.rs
Normal file
@@ -0,0 +1,448 @@
|
||||
//! 反馈信号收集与信任度管理(Phase 5 反馈闭环)
|
||||
//! 收集用户对进化产物(技能/Pipeline)的显式/隐式反馈
|
||||
//! 管理信任度衰减和优化循环
|
||||
//! 信任度记录通过 VikingAdapter 持久化
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::types::MemoryType;
|
||||
use crate::viking_adapter::VikingAdapter;
|
||||
|
||||
/// 反馈信号类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum FeedbackSignal {
|
||||
/// 用户直接表达的意见
|
||||
Explicit,
|
||||
/// 从使用行为推断
|
||||
ImplicitUsage,
|
||||
/// 使用频率
|
||||
UsageCount,
|
||||
/// 任务完成率
|
||||
CompletionRate,
|
||||
}
|
||||
|
||||
/// 情感倾向
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Sentiment {
|
||||
Positive,
|
||||
Negative,
|
||||
Neutral,
|
||||
}
|
||||
|
||||
/// 进化产物类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EvolutionArtifact {
|
||||
Skill,
|
||||
Pipeline,
|
||||
}
|
||||
|
||||
/// 单条反馈记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FeedbackEntry {
|
||||
pub artifact_id: String,
|
||||
pub artifact_type: EvolutionArtifact,
|
||||
pub signal: FeedbackSignal,
|
||||
pub sentiment: Sentiment,
|
||||
pub details: Option<String>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 信任度记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrustRecord {
|
||||
pub artifact_id: String,
|
||||
pub artifact_type: EvolutionArtifact,
|
||||
pub trust_score: f32,
|
||||
pub total_feedback: u32,
|
||||
pub positive_count: u32,
|
||||
pub negative_count: u32,
|
||||
pub last_updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 反馈收集器
|
||||
/// 管理反馈记录和信任度评分
|
||||
/// 通过 VikingAdapter 持久化信任度记录(可选)
|
||||
pub struct FeedbackCollector {
|
||||
trust_records: HashMap<String, TrustRecord>,
|
||||
viking: Option<Arc<VikingAdapter>>,
|
||||
/// 是否已从持久化存储加载信任度记录
|
||||
loaded: bool,
|
||||
}
|
||||
|
||||
impl FeedbackCollector {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
trust_records: HashMap::new(),
|
||||
viking: None,
|
||||
loaded: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带 VikingAdapter 的 FeedbackCollector
|
||||
pub fn with_viking(viking: Arc<VikingAdapter>) -> Self {
|
||||
Self {
|
||||
trust_records: HashMap::new(),
|
||||
viking: Some(viking),
|
||||
loaded: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 VikingAdapter 加载已持久化的信任度记录
|
||||
pub async fn load(&mut self) -> Result<usize, String> {
|
||||
let viking = match &self.viking {
|
||||
Some(v) => v,
|
||||
None => return Ok(0),
|
||||
};
|
||||
|
||||
// MemoryEntry::new("feedback", Session, artifact_id) 生成
|
||||
// URI: agent://feedback/sessions/{artifact_id}
|
||||
let entries = viking
|
||||
.find_by_prefix("agent://feedback/sessions/")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load trust records: {}", e))?;
|
||||
|
||||
let mut count = 0;
|
||||
for entry in entries {
|
||||
match serde_json::from_str::<TrustRecord>(&entry.content) {
|
||||
Ok(record) => {
|
||||
// 只合并不覆盖:保留内存中的较新记录
|
||||
self.trust_records
|
||||
.entry(record.artifact_id.clone())
|
||||
.or_insert(record);
|
||||
count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[FeedbackCollector] Failed to deserialize trust record at {}: {}",
|
||||
entry.uri,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"[FeedbackCollector] Loaded {} trust records from storage",
|
||||
count
|
||||
);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 将信任度记录持久化到 VikingAdapter
|
||||
/// 首次调用时自动从存储加载已有记录,避免覆盖
|
||||
pub async fn save(&mut self) -> Result<usize, String> {
|
||||
// 首次保存前自动加载已有记录,防止丢失历史数据
|
||||
if !self.loaded {
|
||||
match self.load().await {
|
||||
Ok(_) => {
|
||||
self.loaded = true;
|
||||
}
|
||||
Err(e) => {
|
||||
// 加载失败时保留 loaded=false,下次 save 会重试
|
||||
tracing::warn!(
|
||||
"[FeedbackCollector] Auto-load before save failed, will retry next save: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let viking = match &self.viking {
|
||||
Some(v) => v,
|
||||
None => return Ok(0),
|
||||
};
|
||||
|
||||
let mut saved = 0;
|
||||
for record in self.trust_records.values() {
|
||||
let content = match serde_json::to_string(record) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[FeedbackCollector] Failed to serialize trust record {}: {}",
|
||||
record.artifact_id,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let entry = crate::types::MemoryEntry::new(
|
||||
"feedback",
|
||||
MemoryType::Session,
|
||||
&record.artifact_id,
|
||||
content,
|
||||
)
|
||||
.with_importance((record.trust_score * 10.0) as u8);
|
||||
|
||||
match viking.store(&entry).await {
|
||||
Ok(_) => saved += 1,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[FeedbackCollector] Failed to save trust record {}: {}",
|
||||
record.artifact_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"[FeedbackCollector] Saved {} trust records to storage",
|
||||
saved
|
||||
);
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// 提交一条反馈
|
||||
pub fn submit_feedback(&mut self, entry: FeedbackEntry) -> TrustUpdate {
|
||||
let record = self
|
||||
.trust_records
|
||||
.entry(entry.artifact_id.clone())
|
||||
.or_insert_with(|| TrustRecord {
|
||||
artifact_id: entry.artifact_id.clone(),
|
||||
artifact_type: entry.artifact_type.clone(),
|
||||
trust_score: 0.5,
|
||||
total_feedback: 0,
|
||||
positive_count: 0,
|
||||
negative_count: 0,
|
||||
last_updated: Utc::now(),
|
||||
});
|
||||
|
||||
// 更新计数
|
||||
record.total_feedback += 1;
|
||||
match entry.sentiment {
|
||||
Sentiment::Positive => record.positive_count += 1,
|
||||
Sentiment::Negative => record.negative_count += 1,
|
||||
Sentiment::Neutral => {}
|
||||
}
|
||||
|
||||
// 重新计算信任度
|
||||
let old_score = record.trust_score;
|
||||
record.trust_score = Self::calculate_trust_internal(
|
||||
record.positive_count,
|
||||
record.negative_count,
|
||||
record.total_feedback,
|
||||
record.last_updated,
|
||||
);
|
||||
record.last_updated = Utc::now();
|
||||
|
||||
let new_score = record.trust_score;
|
||||
let total = record.total_feedback;
|
||||
let action = Self::recommend_action_internal(new_score, total);
|
||||
|
||||
TrustUpdate {
|
||||
artifact_id: entry.artifact_id.clone(),
|
||||
old_score,
|
||||
new_score,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取信任度记录
|
||||
pub fn get_trust(&self, artifact_id: &str) -> Option<&TrustRecord> {
|
||||
self.trust_records.get(artifact_id)
|
||||
}
|
||||
|
||||
/// 获取所有需要优化的产物(信任度 < 0.4)
|
||||
pub fn get_artifacts_needing_optimization(&self) -> Vec<&TrustRecord> {
|
||||
self.trust_records
|
||||
.values()
|
||||
.filter(|r| r.trust_score < 0.4 && r.total_feedback >= 2)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取所有应该归档的产物(信任度 < 0.2 且反馈 >= 5)
|
||||
pub fn get_artifacts_to_archive(&self) -> Vec<&TrustRecord> {
|
||||
self.trust_records
|
||||
.values()
|
||||
.filter(|r| r.trust_score < 0.2 && r.total_feedback >= 5)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 获取所有高信任产物(信任度 >= 0.8)
|
||||
pub fn get_recommended_artifacts(&self) -> Vec<&TrustRecord> {
|
||||
self.trust_records
|
||||
.values()
|
||||
.filter(|r| r.trust_score >= 0.8)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn calculate_trust_internal(
|
||||
positive: u32,
|
||||
negative: u32,
|
||||
total: u32,
|
||||
last_updated: DateTime<Utc>,
|
||||
) -> f32 {
|
||||
if total == 0 {
|
||||
return 0.5;
|
||||
}
|
||||
let positive_ratio = positive as f32 / total as f32;
|
||||
let negative_penalty = negative as f32 * 0.1;
|
||||
let days_since = (Utc::now() - last_updated).num_days().max(0) as f32;
|
||||
let time_decay = 1.0 - (days_since * 0.005).min(0.5);
|
||||
(positive_ratio * time_decay - negative_penalty).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn recommend_action_internal(trust_score: f32, total_feedback: u32) -> RecommendedAction {
|
||||
if trust_score >= 0.8 {
|
||||
RecommendedAction::Promote
|
||||
} else if trust_score < 0.2 && total_feedback >= 5 {
|
||||
RecommendedAction::Archive
|
||||
} else if trust_score < 0.4 && total_feedback >= 2 {
|
||||
RecommendedAction::Optimize
|
||||
} else {
|
||||
RecommendedAction::Monitor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FeedbackCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 信任度更新结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrustUpdate {
|
||||
pub artifact_id: String,
|
||||
pub old_score: f32,
|
||||
pub new_score: f32,
|
||||
pub action: RecommendedAction,
|
||||
}
|
||||
|
||||
/// 建议动作
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RecommendedAction {
|
||||
/// 继续观察
|
||||
Monitor,
|
||||
/// 需要优化
|
||||
Optimize,
|
||||
/// 建议归档(降级为记忆)
|
||||
Archive,
|
||||
/// 建议提升为推荐技能
|
||||
Promote,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_feedback(artifact_id: &str, sentiment: Sentiment) -> FeedbackEntry {
|
||||
FeedbackEntry {
|
||||
artifact_id: artifact_id.to_string(),
|
||||
artifact_type: EvolutionArtifact::Skill,
|
||||
signal: FeedbackSignal::Explicit,
|
||||
sentiment,
|
||||
details: None,
|
||||
timestamp: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_initial_trust() {
|
||||
let collector = FeedbackCollector::new();
|
||||
assert!(collector.get_trust("skill-1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_positive_feedback_increases_trust() {
|
||||
let mut collector = FeedbackCollector::new();
|
||||
collector.submit_feedback(make_feedback("skill-1", Sentiment::Positive));
|
||||
let record = collector.get_trust("skill-1").unwrap();
|
||||
assert!(record.trust_score > 0.5);
|
||||
assert_eq!(record.positive_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_negative_feedback_decreases_trust() {
|
||||
let mut collector = FeedbackCollector::new();
|
||||
collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
|
||||
let record = collector.get_trust("skill-1").unwrap();
|
||||
assert!(record.trust_score < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_feedback() {
|
||||
let mut collector = FeedbackCollector::new();
|
||||
collector.submit_feedback(make_feedback("skill-1", Sentiment::Positive));
|
||||
collector.submit_feedback(make_feedback("skill-1", Sentiment::Positive));
|
||||
collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
|
||||
let record = collector.get_trust("skill-1").unwrap();
|
||||
assert_eq!(record.total_feedback, 3);
|
||||
assert!(record.trust_score > 0.3); // 2/3 positive
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recommend_optimize() {
|
||||
let mut collector = FeedbackCollector::new();
|
||||
collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
|
||||
let update = collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
|
||||
assert_eq!(update.action, RecommendedAction::Optimize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_optimization_filter() {
|
||||
let mut collector = FeedbackCollector::new();
|
||||
collector.submit_feedback(make_feedback("bad-skill", Sentiment::Negative));
|
||||
collector.submit_feedback(make_feedback("bad-skill", Sentiment::Negative));
|
||||
collector.submit_feedback(make_feedback("good-skill", Sentiment::Positive));
|
||||
|
||||
let needs = collector.get_artifacts_needing_optimization();
|
||||
assert_eq!(needs.len(), 1);
|
||||
assert_eq!(needs[0].artifact_id, "bad-skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_promote_recommendation() {
|
||||
let mut collector = FeedbackCollector::new();
|
||||
for _ in 0..5 {
|
||||
collector.submit_feedback(make_feedback("great-skill", Sentiment::Positive));
|
||||
}
|
||||
let recommended = collector.get_recommended_artifacts();
|
||||
assert_eq!(recommended.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_and_load_roundtrip() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
|
||||
// 写入阶段
|
||||
let mut collector = FeedbackCollector::with_viking(viking.clone());
|
||||
collector.submit_feedback(make_feedback("skill-a", Sentiment::Positive));
|
||||
collector.submit_feedback(make_feedback("skill-a", Sentiment::Positive));
|
||||
collector.submit_feedback(make_feedback("skill-b", Sentiment::Negative));
|
||||
|
||||
let saved = collector.save().await.unwrap();
|
||||
assert_eq!(saved, 2); // 2 个 artifact
|
||||
|
||||
// 读取阶段:新 collector 从存储加载
|
||||
let mut collector2 = FeedbackCollector::with_viking(viking);
|
||||
let loaded = collector2.load().await.unwrap();
|
||||
assert_eq!(loaded, 2);
|
||||
|
||||
let record_a = collector2.get_trust("skill-a").unwrap();
|
||||
assert_eq!(record_a.positive_count, 2);
|
||||
assert_eq!(record_a.total_feedback, 2);
|
||||
|
||||
let record_b = collector2.get_trust("skill-b").unwrap();
|
||||
assert_eq!(record_b.negative_count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_without_viking_returns_zero() {
|
||||
let mut collector = FeedbackCollector::new();
|
||||
let loaded = collector.load().await.unwrap();
|
||||
assert_eq!(loaded, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_save_without_viking_returns_zero() {
|
||||
let mut collector = FeedbackCollector::new();
|
||||
let saved = collector.save().await.unwrap();
|
||||
assert_eq!(saved, 0);
|
||||
}
|
||||
}
|
||||
148
crates/zclaw-growth/src/json_utils.rs
Normal file
148
crates/zclaw-growth/src/json_utils.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
//! 共享 JSON 工具函数
|
||||
//! 从 LLM 返回的文本中提取 JSON 块
|
||||
|
||||
/// 从 LLM 返回文本中提取 JSON 块
|
||||
/// 支持三种格式:```json...``` 围栏、```...``` 围栏、裸 {...}
|
||||
/// 使用括号平衡算法找到第一个完整 JSON 块,避免误匹配
|
||||
pub fn extract_json_block(text: &str) -> &str {
|
||||
// 尝试匹配 ```json ... ```
|
||||
if let Some(start) = text.find("```json") {
|
||||
let json_start = start + 7;
|
||||
if let Some(end) = text[json_start..].find("```") {
|
||||
return text[json_start..json_start + end].trim();
|
||||
}
|
||||
}
|
||||
// 尝试匹配 ``` ... ```
|
||||
if let Some(start) = text.find("```") {
|
||||
let json_start = start + 3;
|
||||
if let Some(end) = text[json_start..].find("```") {
|
||||
return text[json_start..json_start + end].trim();
|
||||
}
|
||||
}
|
||||
// 用括号平衡算法找第一个完整 {...} 块
|
||||
if let Some(slice) = find_balanced_json(text) {
|
||||
return slice;
|
||||
}
|
||||
text.trim()
|
||||
}
|
||||
|
||||
/// 使用括号平衡计数找到第一个完整的 {...} JSON 块
|
||||
/// 正确处理字符串字面量中的花括号
|
||||
fn find_balanced_json(text: &str) -> Option<&str> {
|
||||
let start = text.find('{')?;
|
||||
let mut depth = 0i32;
|
||||
let mut in_string = false;
|
||||
let mut escape_next = false;
|
||||
|
||||
for (i, c) in text[start..].char_indices() {
|
||||
if escape_next {
|
||||
escape_next = false;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'\\' if in_string => escape_next = true,
|
||||
'"' => in_string = !in_string,
|
||||
'{' if !in_string => {
|
||||
depth += 1;
|
||||
}
|
||||
'}' if !in_string => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(&text[start..=start + i]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 serde_json::Value 中提取字符串数组
|
||||
/// 用于解析 LLM 返回 JSON 中的 triggers/tools 等字段
|
||||
pub fn extract_string_array(raw: &serde_json::Value, key: &str) -> Vec<String> {
|
||||
raw.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_json_block_with_markdown() {
|
||||
let text = "Here is the result:\n```json\n{\"key\": \"value\"}\n```\nDone.";
|
||||
assert_eq!(extract_json_block(text), "{\"key\": \"value\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_block_bare() {
|
||||
let text = "{\"key\": \"value\"}";
|
||||
assert_eq!(extract_json_block(text), "{\"key\": \"value\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_block_plain_fences() {
|
||||
let text = "Result:\n```\n{\"a\": 1}\n```";
|
||||
assert_eq!(extract_json_block(text), "{\"a\": 1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_block_nested_braces() {
|
||||
let text = r#"{"outer": {"inner": "val"}}"#;
|
||||
assert_eq!(extract_json_block(text), r#"{"outer": {"inner": "val"}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_block_no_json() {
|
||||
let text = "no json here";
|
||||
assert_eq!(extract_json_block(text), "no json here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_balanced_json_skips_outer_text() {
|
||||
// 第一个 { 到最后一个 } 会包含多余文本,但平衡算法只取第一个完整块
|
||||
let text = "prefix {\"a\": 1} suffix {\"b\": 2}";
|
||||
assert_eq!(extract_json_block(text), "{\"a\": 1}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_balanced_json_handles_braces_in_strings() {
|
||||
let text = r#"{"body": "function() { return x; }", "name": "test"}"#;
|
||||
assert_eq!(
|
||||
extract_json_block(text),
|
||||
r#"{"body": "function() { return x; }", "name": "test"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_balanced_json_handles_escaped_quotes() {
|
||||
let text = r#"{"msg": "He said \"hello {world}\""}"#;
|
||||
assert_eq!(
|
||||
extract_json_block(text),
|
||||
r#"{"msg": "He said \"hello {world}\""}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_string_array() {
|
||||
let raw: serde_json::Value = serde_json::from_str(
|
||||
r#"{"triggers": ["报表", "日报"], "name": "test"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let arr = extract_string_array(&raw, "triggers");
|
||||
assert_eq!(arr, vec!["报表", "日报"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_string_array_missing_key() {
|
||||
let raw: serde_json::Value = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
|
||||
let arr = extract_string_array(&raw, "triggers");
|
||||
assert!(arr.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,13 @@
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The growth system consists of four main components:
|
||||
//! The growth system consists of several subsystems:
|
||||
//!
|
||||
//! ## Memory Pipeline (L0-L2)
|
||||
//!
|
||||
//! 1. **MemoryExtractor** (`extractor`) - Analyzes conversations and extracts
|
||||
//! preferences, knowledge, and experience using LLM.
|
||||
//! preferences, knowledge, and experience using LLM. Supports combined extraction
|
||||
//! (single LLM call for memories + experiences + profile signals).
|
||||
//!
|
||||
//! 2. **MemoryRetriever** (`retriever`) - Performs semantic search over
|
||||
//! stored memories to find contextually relevant information.
|
||||
@@ -19,6 +22,28 @@
|
||||
//! 4. **GrowthTracker** (`tracker`) - Tracks growth metrics and evolution
|
||||
//! over time.
|
||||
//!
|
||||
//! ## Evolution Engine (L1-L3)
|
||||
//!
|
||||
//! 5. **ExperienceStore** (`experience_store`) - FTS5-backed structured experience storage.
|
||||
//!
|
||||
//! 6. **PatternAggregator** (`pattern_aggregator`) - Collects high-frequency patterns for L2.
|
||||
//!
|
||||
//! 7. **SkillGenerator** (`skill_generator`) - LLM-driven SKILL.md content generation.
|
||||
//!
|
||||
//! 8. **QualityGate** (`quality_gate`) - Validates candidate skills (confidence, conflicts).
|
||||
//!
|
||||
//! 9. **EvolutionEngine** (`evolution_engine`) - Orchestrates L1/L2/L3 evolution phases.
|
||||
//!
|
||||
//! 10. **WorkflowComposer** (`workflow_composer`) - Extracts tool chain patterns for Pipeline YAML.
|
||||
//!
|
||||
//! 11. **FeedbackCollector** (`feedback_collector`) - Trust score management with decay.
|
||||
//!
|
||||
//! ## Support Modules
|
||||
//!
|
||||
//! 12. **VikingAdapter** (`viking_adapter`) - Storage abstraction (in-memory + SQLite backends).
|
||||
//! 13. **Summarizer** (`summarizer`) - L0/L1 summary generation.
|
||||
//! 14. **JsonUtils** (`json_utils`) - Shared JSON parsing utilities.
|
||||
//!
|
||||
//! # Storage
|
||||
//!
|
||||
//! All memories are stored in OpenViking with a URI structure:
|
||||
@@ -65,6 +90,15 @@ pub mod storage;
|
||||
pub mod retrieval;
|
||||
pub mod summarizer;
|
||||
pub mod experience_store;
|
||||
pub mod json_utils;
|
||||
pub mod experience_extractor;
|
||||
pub mod profile_updater;
|
||||
pub mod pattern_aggregator;
|
||||
pub mod skill_generator;
|
||||
pub mod quality_gate;
|
||||
pub mod evolution_engine;
|
||||
pub mod workflow_composer;
|
||||
pub mod feedback_collector;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use types::{
|
||||
@@ -78,6 +112,14 @@ pub use types::{
|
||||
RetrievalResult,
|
||||
UriBuilder,
|
||||
effective_importance,
|
||||
ArtifactType,
|
||||
CombinedExtraction,
|
||||
EvolutionEvent,
|
||||
EvolutionEventType,
|
||||
EvolutionStatus,
|
||||
ExperienceCandidate,
|
||||
Outcome,
|
||||
ProfileSignals,
|
||||
};
|
||||
|
||||
pub use extractor::{LlmDriverForExtraction, MemoryExtractor};
|
||||
@@ -89,6 +131,18 @@ pub use storage::SqliteStorage;
|
||||
pub use experience_store::{Experience, ExperienceStore};
|
||||
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
|
||||
pub use summarizer::SummaryLlmDriver;
|
||||
pub use experience_extractor::ExperienceExtractor;
|
||||
pub use json_utils::{extract_json_block, extract_string_array};
|
||||
pub use profile_updater::{ProfileFieldUpdate, ProfileUpdateKind, UserProfileUpdater};
|
||||
pub use pattern_aggregator::{AggregatedPattern, PatternAggregator};
|
||||
pub use skill_generator::{SkillCandidate, SkillGenerator};
|
||||
pub use quality_gate::{QualityGate, QualityReport};
|
||||
pub use evolution_engine::{EvolutionConfig, EvolutionEngine};
|
||||
pub use workflow_composer::{PipelineCandidate, ToolChainPattern, WorkflowComposer};
|
||||
pub use feedback_collector::{
|
||||
EvolutionArtifact, FeedbackCollector, FeedbackEntry, FeedbackSignal,
|
||||
RecommendedAction, Sentiment, TrustRecord, TrustUpdate,
|
||||
};
|
||||
|
||||
/// Growth system configuration
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
245
crates/zclaw-growth/src/pattern_aggregator.rs
Normal file
245
crates/zclaw-growth/src/pattern_aggregator.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
//! 经验模式聚合器
|
||||
//! 收集同一 pain_pattern 下的所有 Experience,找出共同步骤
|
||||
//! 用于 L2 技能进化触发判断
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::experience_store::{Experience, ExperienceStore};
|
||||
use zclaw_types::Result;
|
||||
|
||||
/// 聚合后的经验模式
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AggregatedPattern {
|
||||
pub pain_pattern: String,
|
||||
pub experiences: Vec<Experience>,
|
||||
pub common_steps: Vec<String>,
|
||||
pub total_reuse: u32,
|
||||
pub tools_used: Vec<String>,
|
||||
pub industry_context: Option<String>,
|
||||
}
|
||||
|
||||
/// 经验模式聚合器
|
||||
/// 从 ExperienceStore 中收集高频复用的模式,作为 L2 技能生成的输入
|
||||
pub struct PatternAggregator {
|
||||
store: ExperienceStore,
|
||||
}
|
||||
|
||||
impl PatternAggregator {
|
||||
pub fn new(store: ExperienceStore) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// 查找可固化的模式:reuse_count >= threshold 的经验
|
||||
pub async fn find_evolvable_patterns(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
min_reuse: u32,
|
||||
) -> Result<Vec<AggregatedPattern>> {
|
||||
let all = self.store.find_by_agent(agent_id).await?;
|
||||
let mut grouped: HashMap<String, Vec<Experience>> = HashMap::new();
|
||||
|
||||
for exp in all {
|
||||
if exp.reuse_count >= min_reuse {
|
||||
grouped
|
||||
.entry(exp.pain_pattern.clone())
|
||||
.or_default()
|
||||
.push(exp);
|
||||
}
|
||||
}
|
||||
|
||||
let mut patterns = Vec::new();
|
||||
for (pattern, experiences) in grouped {
|
||||
let total_reuse: u32 = experiences.iter().map(|e| e.reuse_count).sum();
|
||||
let common_steps = Self::find_common_steps(&experiences);
|
||||
|
||||
// 从 tool_used 字段提取工具名
|
||||
let tools: Vec<String> = experiences
|
||||
.iter()
|
||||
.filter_map(|e| e.tool_used.clone())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let industry = experiences
|
||||
.iter()
|
||||
.filter_map(|e| e.industry_context.clone())
|
||||
.next();
|
||||
|
||||
patterns.push(AggregatedPattern {
|
||||
pain_pattern: pattern,
|
||||
experiences,
|
||||
common_steps,
|
||||
total_reuse,
|
||||
tools_used: tools,
|
||||
industry_context: industry,
|
||||
});
|
||||
}
|
||||
|
||||
// 按 reuse 排序
|
||||
patterns.sort_by(|a, b| b.total_reuse.cmp(&a.total_reuse));
|
||||
Ok(patterns)
|
||||
}
|
||||
|
||||
/// 找出多条经验中共同的解决步骤
|
||||
fn find_common_steps(experiences: &[Experience]) -> Vec<String> {
|
||||
if experiences.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if experiences.len() == 1 {
|
||||
return experiences[0].solution_steps.clone();
|
||||
}
|
||||
|
||||
// 取所有经验的交集步骤
|
||||
let mut step_counts: HashMap<String, u32> = HashMap::new();
|
||||
for exp in experiences {
|
||||
for step in &exp.solution_steps {
|
||||
*step_counts.entry(step.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let threshold = experiences.len() as f32 * 0.5; // 出现在 50%+ 的经验中
|
||||
let mut common: Vec<_> = step_counts
|
||||
.into_iter()
|
||||
.filter(|(_, count)| (*count as f32) >= threshold)
|
||||
.map(|(step, _)| step)
|
||||
.collect();
|
||||
common.dedup();
|
||||
common
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn test_find_common_steps_empty() {
|
||||
let steps = PatternAggregator::find_common_steps(&[]);
|
||||
assert!(steps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_common_steps_single() {
|
||||
let exp = Experience::new(
|
||||
"a",
|
||||
"packaging",
|
||||
"ctx",
|
||||
vec!["step1".into(), "step2".into()],
|
||||
"ok",
|
||||
);
|
||||
let steps = PatternAggregator::find_common_steps(&[exp]);
|
||||
assert_eq!(steps.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_common_steps_multiple() {
|
||||
let exp1 = Experience::new(
|
||||
"a",
|
||||
"packaging",
|
||||
"ctx",
|
||||
vec!["step1".into(), "step2".into(), "step3".into()],
|
||||
"ok",
|
||||
);
|
||||
let exp2 = Experience::new(
|
||||
"a",
|
||||
"packaging",
|
||||
"ctx",
|
||||
vec!["step1".into(), "step2".into(), "step4".into()],
|
||||
"ok",
|
||||
);
|
||||
// step1 and step2 appear in both (100% >= 50%)
|
||||
let steps = PatternAggregator::find_common_steps(&[exp1, exp2]);
|
||||
assert!(steps.contains(&"step1".to_string()));
|
||||
assert!(steps.contains(&"step2".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_evolvable_patterns_filters_low_reuse() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let store = ExperienceStore::new(viking);
|
||||
|
||||
// 经验 1: reuse_count = 0 (低于阈值)
|
||||
let mut exp_low = Experience::new(
|
||||
"agent-1",
|
||||
"low reuse task",
|
||||
"ctx",
|
||||
vec!["step".into()],
|
||||
"ok",
|
||||
);
|
||||
exp_low.reuse_count = 0;
|
||||
store.store_experience(&exp_low).await.unwrap();
|
||||
|
||||
// 经验 2: reuse_count = 5 (高于阈值)
|
||||
let mut exp_high = Experience::new(
|
||||
"agent-1",
|
||||
"high reuse task",
|
||||
"ctx",
|
||||
vec!["step1".into()],
|
||||
"ok",
|
||||
);
|
||||
exp_high.reuse_count = 5;
|
||||
store.store_experience(&exp_high).await.unwrap();
|
||||
|
||||
let aggregator = PatternAggregator::new(store);
|
||||
let patterns = aggregator.find_evolvable_patterns("agent-1", 3).await.unwrap();
|
||||
|
||||
assert_eq!(patterns.len(), 1);
|
||||
assert_eq!(patterns[0].pain_pattern, "high reuse task");
|
||||
assert_eq!(patterns[0].total_reuse, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_evolvable_patterns_groups_by_pain() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let store = ExperienceStore::new(viking);
|
||||
|
||||
let mut exp1 = Experience::new(
|
||||
"agent-1",
|
||||
"report generation",
|
||||
"ctx1",
|
||||
vec!["query db".into(), "format".into()],
|
||||
"ok",
|
||||
);
|
||||
exp1.reuse_count = 3;
|
||||
store.store_experience(&exp1).await.unwrap();
|
||||
|
||||
// Same pain_pattern → same URI → overwrites, so use a slightly different hash
|
||||
// Actually since URI is deterministic on pain_pattern, we can only have one per pattern
|
||||
// This is by design: one experience per pain_pattern (latest wins)
|
||||
let patterns = aggregator_fixtures::make_patterns_with_same_pain().await;
|
||||
assert_eq!(patterns.len(), 1);
|
||||
}
|
||||
|
||||
mod aggregator_fixtures {
|
||||
use super::*;
|
||||
|
||||
pub async fn make_patterns_with_same_pain() -> Vec<AggregatedPattern> {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let store = ExperienceStore::new(viking);
|
||||
|
||||
let mut exp = Experience::new(
|
||||
"agent-1",
|
||||
"report generation",
|
||||
"ctx1",
|
||||
vec!["query db".into(), "format".into()],
|
||||
"ok",
|
||||
);
|
||||
exp.reuse_count = 3;
|
||||
store.store_experience(&exp).await.unwrap();
|
||||
|
||||
let aggregator = PatternAggregator::new(store);
|
||||
aggregator.find_evolvable_patterns("agent-1", 2).await.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_evolvable_patterns_empty() {
|
||||
let viking = Arc::new(crate::VikingAdapter::in_memory());
|
||||
let store = ExperienceStore::new(viking);
|
||||
let aggregator = PatternAggregator::new(store);
|
||||
let patterns = aggregator.find_evolvable_patterns("unknown-agent", 3).await.unwrap();
|
||||
assert!(patterns.is_empty());
|
||||
}
|
||||
}
|
||||
157
crates/zclaw-growth/src/profile_updater.rs
Normal file
157
crates/zclaw-growth/src/profile_updater.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! 用户画像增量更新器
|
||||
//! 从 CombinedExtraction 的 profile_signals 提取需要更新的字段
|
||||
//! 不额外调用 LLM,纯规则驱动
|
||||
|
||||
use crate::types::CombinedExtraction;
|
||||
|
||||
/// 更新类型:字段覆盖 vs 数组追加
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ProfileUpdateKind {
|
||||
/// 直接覆盖字段值(industry, communication_style)
|
||||
SetField,
|
||||
/// 追加到 JSON 数组字段(recent_topic, pain_point, preferred_tool)
|
||||
AppendArray,
|
||||
}
|
||||
|
||||
/// 待更新的画像字段
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProfileFieldUpdate {
|
||||
pub field: String,
|
||||
pub value: String,
|
||||
pub kind: ProfileUpdateKind,
|
||||
}
|
||||
|
||||
/// 用户画像更新器
|
||||
/// 从 CombinedExtraction 的 profile_signals 中提取需更新的字段列表
|
||||
/// 调用方(zclaw-runtime)负责实际写入 UserProfileStore
|
||||
pub struct UserProfileUpdater;
|
||||
|
||||
impl UserProfileUpdater {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 从提取结果中收集需要更新的画像字段
|
||||
/// 返回 (field, value, kind) 列表,由调用方根据 kind 选择写入方式
|
||||
pub fn collect_updates(
|
||||
&self,
|
||||
extraction: &CombinedExtraction,
|
||||
) -> Vec<ProfileFieldUpdate> {
|
||||
let signals = &extraction.profile_signals;
|
||||
let mut updates = Vec::new();
|
||||
|
||||
if let Some(ref industry) = signals.industry {
|
||||
updates.push(ProfileFieldUpdate {
|
||||
field: "industry".to_string(),
|
||||
value: industry.clone(),
|
||||
kind: ProfileUpdateKind::SetField,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref style) = signals.communication_style {
|
||||
updates.push(ProfileFieldUpdate {
|
||||
field: "communication_style".to_string(),
|
||||
value: style.clone(),
|
||||
kind: ProfileUpdateKind::SetField,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref topic) = signals.recent_topic {
|
||||
updates.push(ProfileFieldUpdate {
|
||||
field: "recent_topic".to_string(),
|
||||
value: topic.clone(),
|
||||
kind: ProfileUpdateKind::AppendArray,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref pain) = signals.pain_point {
|
||||
updates.push(ProfileFieldUpdate {
|
||||
field: "pain_point".to_string(),
|
||||
value: pain.clone(),
|
||||
kind: ProfileUpdateKind::AppendArray,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref tool) = signals.preferred_tool {
|
||||
updates.push(ProfileFieldUpdate {
|
||||
field: "preferred_tool".to_string(),
|
||||
value: tool.clone(),
|
||||
kind: ProfileUpdateKind::AppendArray,
|
||||
});
|
||||
}
|
||||
|
||||
updates
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UserProfileUpdater {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_collect_updates_industry() {
|
||||
let mut extraction = CombinedExtraction::default();
|
||||
extraction.profile_signals.industry = Some("healthcare".to_string());
|
||||
|
||||
let updater = UserProfileUpdater::new();
|
||||
let updates = updater.collect_updates(&extraction);
|
||||
|
||||
assert_eq!(updates.len(), 1);
|
||||
assert_eq!(updates[0].field, "industry");
|
||||
assert_eq!(updates[0].value, "healthcare");
|
||||
assert_eq!(updates[0].kind, ProfileUpdateKind::SetField);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_updates_no_signals() {
|
||||
let extraction = CombinedExtraction::default();
|
||||
let updater = UserProfileUpdater::new();
|
||||
let updates = updater.collect_updates(&extraction);
|
||||
assert!(updates.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_updates_multiple_signals() {
|
||||
let mut extraction = CombinedExtraction::default();
|
||||
extraction.profile_signals.industry = Some("ecommerce".to_string());
|
||||
extraction.profile_signals.communication_style = Some("concise".to_string());
|
||||
|
||||
let updater = UserProfileUpdater::new();
|
||||
let updates = updater.collect_updates(&extraction);
|
||||
|
||||
assert_eq!(updates.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_updates_all_five_dimensions() {
|
||||
let mut extraction = CombinedExtraction::default();
|
||||
extraction.profile_signals.industry = Some("healthcare".to_string());
|
||||
extraction.profile_signals.communication_style = Some("concise".to_string());
|
||||
extraction.profile_signals.recent_topic = Some("报表自动化".to_string());
|
||||
extraction.profile_signals.pain_point = Some("手动汇总太慢".to_string());
|
||||
extraction.profile_signals.preferred_tool = Some("researcher".to_string());
|
||||
|
||||
let updater = UserProfileUpdater::new();
|
||||
let updates = updater.collect_updates(&extraction);
|
||||
|
||||
assert_eq!(updates.len(), 5);
|
||||
let set_fields: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| u.kind == ProfileUpdateKind::SetField)
|
||||
.map(|u| u.field.as_str())
|
||||
.collect();
|
||||
let append_fields: Vec<_> = updates
|
||||
.iter()
|
||||
.filter(|u| u.kind == ProfileUpdateKind::AppendArray)
|
||||
.map(|u| u.field.as_str())
|
||||
.collect();
|
||||
assert_eq!(set_fields, vec!["industry", "communication_style"]);
|
||||
assert_eq!(append_fields, vec!["recent_topic", "pain_point", "preferred_tool"]);
|
||||
}
|
||||
}
|
||||
160
crates/zclaw-growth/src/quality_gate.rs
Normal file
160
crates/zclaw-growth/src/quality_gate.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! 质量门控
|
||||
//! 验证生成的技能/工作流是否满足质量标准
|
||||
//! 包括:置信度阈值、触发词冲突检查、格式校验
|
||||
|
||||
use crate::skill_generator::SkillCandidate;
|
||||
|
||||
/// 质量验证报告
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QualityReport {
|
||||
pub passed: bool,
|
||||
pub issues: Vec<String>,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// 质量门控验证器
|
||||
pub struct QualityGate {
|
||||
min_confidence: f32,
|
||||
existing_triggers: Vec<String>,
|
||||
}
|
||||
|
||||
impl QualityGate {
|
||||
pub fn new(min_confidence: f32, existing_triggers: Vec<String>) -> Self {
|
||||
Self {
|
||||
min_confidence,
|
||||
existing_triggers,
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证技能候选项
|
||||
pub fn validate_skill(&self, candidate: &SkillCandidate) -> QualityReport {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// 1. 置信度检查
|
||||
if candidate.confidence < self.min_confidence {
|
||||
issues.push(format!(
|
||||
"置信度 {:.2} 低于阈值 {:.2}",
|
||||
candidate.confidence, self.min_confidence
|
||||
));
|
||||
}
|
||||
|
||||
// 2. 名称非空
|
||||
if candidate.name.trim().is_empty() {
|
||||
issues.push("技能名称不能为空".to_string());
|
||||
}
|
||||
|
||||
// 3. 至少一个触发词
|
||||
if candidate.triggers.is_empty() {
|
||||
issues.push("至少需要一个触发词".to_string());
|
||||
}
|
||||
|
||||
// 4. 触发词不与现有技能冲突
|
||||
let conflicts: Vec<_> = candidate
|
||||
.triggers
|
||||
.iter()
|
||||
.filter(|t| self.existing_triggers.iter().any(|et| et == *t))
|
||||
.collect();
|
||||
if !conflicts.is_empty() {
|
||||
issues.push(format!("触发词冲突: {:?}", conflicts));
|
||||
}
|
||||
|
||||
// 5. SKILL.md 正文非空
|
||||
if candidate.body_markdown.trim().is_empty() {
|
||||
issues.push("技能正文不能为空".to_string());
|
||||
}
|
||||
|
||||
QualityReport {
|
||||
passed: issues.is_empty(),
|
||||
issues,
|
||||
confidence: candidate.confidence,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_valid_candidate() -> SkillCandidate {
|
||||
SkillCandidate {
|
||||
name: "每日报表".to_string(),
|
||||
description: "生成每日报表".to_string(),
|
||||
triggers: vec!["报表".to_string(), "日报".to_string()],
|
||||
tools: vec!["researcher".to_string()],
|
||||
body_markdown: "# 每日报表\n步骤1\n步骤2".to_string(),
|
||||
source_pattern: "报表生成".to_string(),
|
||||
confidence: 0.85,
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_valid_skill() {
|
||||
let gate = QualityGate::new(0.7, vec!["搜索".to_string()]);
|
||||
let candidate = make_valid_candidate();
|
||||
let report = gate.validate_skill(&candidate);
|
||||
assert!(report.passed);
|
||||
assert!(report.issues.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_low_confidence() {
|
||||
let gate = QualityGate::new(0.7, vec![]);
|
||||
let mut candidate = make_valid_candidate();
|
||||
candidate.confidence = 0.5;
|
||||
let report = gate.validate_skill(&candidate);
|
||||
assert!(!report.passed);
|
||||
assert!(report.issues.iter().any(|i| i.contains("置信度")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_empty_name() {
|
||||
let gate = QualityGate::new(0.5, vec![]);
|
||||
let mut candidate = make_valid_candidate();
|
||||
candidate.name = "".to_string();
|
||||
let report = gate.validate_skill(&candidate);
|
||||
assert!(!report.passed);
|
||||
assert!(report.issues.iter().any(|i| i.contains("名称")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_empty_triggers() {
|
||||
let gate = QualityGate::new(0.5, vec![]);
|
||||
let mut candidate = make_valid_candidate();
|
||||
candidate.triggers = vec![];
|
||||
let report = gate.validate_skill(&candidate);
|
||||
assert!(!report.passed);
|
||||
assert!(report.issues.iter().any(|i| i.contains("触发词")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_trigger_conflict() {
|
||||
let gate = QualityGate::new(0.5, vec!["报表".to_string()]);
|
||||
let candidate = make_valid_candidate();
|
||||
let report = gate.validate_skill(&candidate);
|
||||
assert!(!report.passed);
|
||||
assert!(report.issues.iter().any(|i| i.contains("冲突")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_empty_body() {
|
||||
let gate = QualityGate::new(0.5, vec![]);
|
||||
let mut candidate = make_valid_candidate();
|
||||
candidate.body_markdown = "".to_string();
|
||||
let report = gate.validate_skill(&candidate);
|
||||
assert!(!report.passed);
|
||||
assert!(report.issues.iter().any(|i| i.contains("正文")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_multiple_issues() {
|
||||
let gate = QualityGate::new(0.9, vec![]);
|
||||
let mut candidate = make_valid_candidate();
|
||||
candidate.confidence = 0.3;
|
||||
candidate.triggers = vec![];
|
||||
candidate.body_markdown = "".to_string();
|
||||
let report = gate.validate_skill(&candidate);
|
||||
assert!(!report.passed);
|
||||
assert!(report.issues.len() >= 3);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ struct CacheEntry {
|
||||
}
|
||||
|
||||
/// Cache key for efficient lookups (reserved for future cache optimization)
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: post-release cache optimization lookups
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
struct CacheKey {
|
||||
agent_id: String,
|
||||
|
||||
164
crates/zclaw-growth/src/skill_generator.rs
Normal file
164
crates/zclaw-growth/src/skill_generator.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
//! 技能生成器
|
||||
//! 将聚合的经验模式通过 LLM 转化为 SKILL.md 内容
|
||||
//! 提供 prompt 构建和 JSON 结果解析
|
||||
|
||||
use crate::pattern_aggregator::AggregatedPattern;
|
||||
use zclaw_types::Result;
|
||||
|
||||
/// 技能候选项
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SkillCandidate {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub triggers: Vec<String>,
|
||||
pub tools: Vec<String>,
|
||||
pub body_markdown: String,
|
||||
pub source_pattern: String,
|
||||
pub confidence: f32,
|
||||
/// 技能版本号,用于后续迭代追踪
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
/// LLM 驱动的技能生成 prompt
|
||||
const SKILL_GENERATION_PROMPT: &str = r#"
|
||||
你是一个技能设计专家。根据以下用户反复出现的问题和解决步骤,生成一个可复用的技能定义。
|
||||
|
||||
问题模式:{pain_pattern}
|
||||
解决步骤:{steps}
|
||||
使用的工具:{tools}
|
||||
行业背景:{industry}
|
||||
|
||||
请生成以下 JSON:
|
||||
```json
|
||||
{
|
||||
"name": "技能名称(简短中文)",
|
||||
"description": "技能描述(一段话)",
|
||||
"triggers": ["触发词1", "触发词2", "触发词3"],
|
||||
"tools": ["tool1", "tool2"],
|
||||
"body_markdown": "技能的 Markdown 正文,包含步骤说明",
|
||||
"confidence": 0.85
|
||||
}
|
||||
```
|
||||
"#;
|
||||
|
||||
/// 技能生成器
|
||||
/// 负责 prompt 构建和 LLM 返回的 JSON 解析
|
||||
pub struct SkillGenerator;
|
||||
|
||||
impl SkillGenerator {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 从聚合模式构建 LLM prompt
|
||||
pub fn build_prompt(pattern: &AggregatedPattern) -> String {
|
||||
SKILL_GENERATION_PROMPT
|
||||
.replace("{pain_pattern}", &pattern.pain_pattern)
|
||||
.replace("{steps}", &pattern.common_steps.join(" → "))
|
||||
.replace("{tools}", &pattern.tools_used.join(", "))
|
||||
.replace("{industry}", pattern.industry_context.as_deref().unwrap_or("通用"))
|
||||
}
|
||||
|
||||
/// 解析 LLM 返回的 JSON 为 SkillCandidate
|
||||
pub fn parse_response(json_str: &str, pattern: &AggregatedPattern) -> Result<SkillCandidate> {
|
||||
let json_str = crate::json_utils::extract_json_block(json_str);
|
||||
|
||||
let raw: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
|
||||
zclaw_types::ZclawError::ConfigError(format!("Invalid skill JSON: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(SkillCandidate {
|
||||
name: raw["name"]
|
||||
.as_str()
|
||||
.unwrap_or("未命名技能")
|
||||
.to_string(),
|
||||
description: raw["description"].as_str().unwrap_or("").to_string(),
|
||||
triggers: crate::json_utils::extract_string_array(&raw, "triggers"),
|
||||
tools: crate::json_utils::extract_string_array(&raw, "tools"),
|
||||
body_markdown: raw["body_markdown"].as_str().unwrap_or("").to_string(),
|
||||
source_pattern: pattern.pain_pattern.clone(),
|
||||
confidence: raw["confidence"].as_f64().unwrap_or(0.5) as f32,
|
||||
version: raw["version"].as_u64().unwrap_or(1) as u32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SkillGenerator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::experience_store::Experience;
|
||||
|
||||
fn make_pattern() -> AggregatedPattern {
|
||||
let exp = Experience::new(
|
||||
"agent-1",
|
||||
"报表生成",
|
||||
"researcher",
|
||||
vec!["查询数据库".into(), "格式化输出".into()],
|
||||
"success",
|
||||
);
|
||||
AggregatedPattern {
|
||||
pain_pattern: "报表生成".to_string(),
|
||||
experiences: vec![exp],
|
||||
common_steps: vec!["查询数据库".into(), "格式化输出".into()],
|
||||
total_reuse: 5,
|
||||
tools_used: vec!["researcher".into()],
|
||||
industry_context: Some("healthcare".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_prompt() {
|
||||
let pattern = make_pattern();
|
||||
let prompt = SkillGenerator::build_prompt(&pattern);
|
||||
assert!(prompt.contains("报表生成"));
|
||||
assert!(prompt.contains("查询数据库"));
|
||||
assert!(prompt.contains("researcher"));
|
||||
assert!(prompt.contains("healthcare"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_valid_json() {
|
||||
let pattern = make_pattern();
|
||||
let json = r##"{"name":"每日报表","description":"生成每日报表","triggers":["报表","日报"],"tools":["researcher"],"body_markdown":"# 每日报表\n步骤1","confidence":0.9}"##;
|
||||
let candidate = SkillGenerator::parse_response(json, &pattern).unwrap();
|
||||
assert_eq!(candidate.name, "每日报表");
|
||||
assert_eq!(candidate.triggers.len(), 2);
|
||||
assert_eq!(candidate.confidence, 0.9);
|
||||
assert_eq!(candidate.source_pattern, "报表生成");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_json_block() {
|
||||
let pattern = make_pattern();
|
||||
let text = r#"```json
|
||||
{"name":"技能A","description":"desc","triggers":["a"],"tools":[],"body_markdown":"body","confidence":0.8}
|
||||
```"#;
|
||||
let candidate = SkillGenerator::parse_response(text, &pattern).unwrap();
|
||||
assert_eq!(candidate.name, "技能A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_invalid_json() {
|
||||
let pattern = make_pattern();
|
||||
let result = SkillGenerator::parse_response("not json at all", &pattern);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_json_block_with_markdown() {
|
||||
let text = "Here is the result:\n```json\n{\"key\": \"value\"}\n```\nDone.";
|
||||
assert_eq!(crate::json_utils::extract_json_block(text), "{\"key\": \"value\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_json_block_bare() {
|
||||
let text = "{\"key\": \"value\"}";
|
||||
assert_eq!(crate::json_utils::extract_json_block(text), "{\"key\": \"value\"}");
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ pub struct SqliteStorage {
|
||||
/// Semantic scorer for similarity computation
|
||||
scorer: Arc<RwLock<SemanticScorer>>,
|
||||
/// Database path (for reference)
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: db path for diagnostics and reconnect
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -162,22 +162,44 @@ impl SqliteStorage {
|
||||
.map_err(|e| ZclawError::StorageError(format!("Failed to create importance index: {}", e)))?;
|
||||
|
||||
// Migration: add overview column (L1 summary)
|
||||
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN overview TEXT")
|
||||
// SQLite ALTER TABLE ADD COLUMN fails with "duplicate column name" if already applied
|
||||
if let Err(e) = sqlx::query("ALTER TABLE memories ADD COLUMN overview TEXT")
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
let msg = e.to_string();
|
||||
if !msg.contains("duplicate column name") {
|
||||
tracing::warn!("[Growth] Migration overview failed: {}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: add abstract_summary column (L0 keywords)
|
||||
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN abstract_summary TEXT")
|
||||
if let Err(e) = sqlx::query("ALTER TABLE memories ADD COLUMN abstract_summary TEXT")
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
let msg = e.to_string();
|
||||
if !msg.contains("duplicate column name") {
|
||||
tracing::warn!("[Growth] Migration abstract_summary failed: {}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
// P2-24: Migration — content fingerprint for deduplication
|
||||
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN content_hash TEXT")
|
||||
if let Err(e) = sqlx::query("ALTER TABLE memories ADD COLUMN content_hash TEXT")
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
let _ = sqlx::query("CREATE INDEX IF NOT EXISTS idx_content_hash ON memories(content_hash)")
|
||||
.await
|
||||
{
|
||||
let msg = e.to_string();
|
||||
if !msg.contains("duplicate column name") {
|
||||
tracing::warn!("[Growth] Migration content_hash failed: {}", msg);
|
||||
}
|
||||
}
|
||||
if let Err(e) = sqlx::query("CREATE INDEX IF NOT EXISTS idx_content_hash ON memories(content_hash)")
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[Growth] Migration idx_content_hash failed: {}", e);
|
||||
}
|
||||
|
||||
// Backfill content_hash for existing entries that have NULL content_hash
|
||||
{
|
||||
@@ -196,11 +218,14 @@ impl SqliteStorage {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
normalized.hash(&mut hasher);
|
||||
let hash = format!("{:016x}", hasher.finish());
|
||||
let _ = sqlx::query("UPDATE memories SET content_hash = ? WHERE uri = ?")
|
||||
if let Err(e) = sqlx::query("UPDATE memories SET content_hash = ? WHERE uri = ?")
|
||||
.bind(&hash)
|
||||
.bind(uri)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[sqlite] content_hash update failed for {}: {}", uri, e);
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"[SqliteStorage] Backfilled content_hash for {} existing entries",
|
||||
@@ -234,9 +259,12 @@ impl SqliteStorage {
|
||||
if needs_rebuild {
|
||||
tracing::info!("[SqliteStorage] Rebuilding FTS5 table: unicode61 → trigram for CJK support");
|
||||
// Drop old FTS5 table
|
||||
let _ = sqlx::query("DROP TABLE IF EXISTS memories_fts")
|
||||
if let Err(e) = sqlx::query("DROP TABLE IF EXISTS memories_fts")
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[sqlite] FTS5 table drop failed during rebuild: {}", e);
|
||||
}
|
||||
// Recreate with trigram tokenizer
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -401,14 +429,17 @@ impl SqliteStorage {
|
||||
.await;
|
||||
|
||||
// Also clean up FTS entries for archived memories
|
||||
let _ = sqlx::query(
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM memories_fts
|
||||
WHERE uri NOT IN (SELECT uri FROM memories)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::warn!("[sqlite] FTS cleanup after archive failed: {}", e);
|
||||
}
|
||||
|
||||
let archived = archive_result
|
||||
.map(|r| r.rows_affected())
|
||||
@@ -461,13 +492,58 @@ impl SqliteStorage {
|
||||
});
|
||||
|
||||
if has_cjk {
|
||||
// For CJK, use the full query as a quoted phrase for substring matching
|
||||
// trigram will match any 3-char subsequence
|
||||
if lower.len() >= 3 {
|
||||
format!("\"{}\"", lower)
|
||||
} else {
|
||||
String::new()
|
||||
// For CJK queries, extract tokens: CJK character sequences and ASCII words.
|
||||
// Join with OR for broad matching (not exact phrase, which would miss scattered terms).
|
||||
let mut tokens: Vec<String> = Vec::new();
|
||||
let mut cjk_buf = String::new();
|
||||
let mut ascii_buf = String::new();
|
||||
|
||||
for ch in lower.chars() {
|
||||
let is_cjk = matches!(ch, '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}');
|
||||
if is_cjk {
|
||||
if !ascii_buf.is_empty() {
|
||||
if ascii_buf.len() >= 2 {
|
||||
tokens.push(format!("\"{}\"", ascii_buf));
|
||||
}
|
||||
ascii_buf.clear();
|
||||
}
|
||||
cjk_buf.push(ch);
|
||||
} else if ch.is_alphanumeric() {
|
||||
if !cjk_buf.is_empty() {
|
||||
// Flush CJK buffer — each CJK character is a potential token
|
||||
// (trigram indexes 3-char sequences, so single CJK chars won't
|
||||
// match alone, but 2+ char sequences will)
|
||||
if cjk_buf.len() >= 2 {
|
||||
tokens.push(format!("\"{}\"", cjk_buf));
|
||||
}
|
||||
cjk_buf.clear();
|
||||
}
|
||||
ascii_buf.push(ch);
|
||||
} else {
|
||||
// Separator — flush both buffers
|
||||
if cjk_buf.len() >= 2 {
|
||||
tokens.push(format!("\"{}\"", cjk_buf));
|
||||
}
|
||||
cjk_buf.clear();
|
||||
if ascii_buf.len() >= 2 {
|
||||
tokens.push(format!("\"{}\"", ascii_buf));
|
||||
}
|
||||
ascii_buf.clear();
|
||||
}
|
||||
}
|
||||
// Flush remaining
|
||||
if cjk_buf.len() >= 2 {
|
||||
tokens.push(format!("\"{}\"", cjk_buf));
|
||||
}
|
||||
if ascii_buf.len() >= 2 {
|
||||
tokens.push(format!("\"{}\"", ascii_buf));
|
||||
}
|
||||
|
||||
if tokens.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
tokens.join(" OR ")
|
||||
} else {
|
||||
// For non-CJK, split into terms and join with OR
|
||||
let terms: Vec<String> = lower
|
||||
|
||||
@@ -66,21 +66,30 @@ impl GrowthTracker {
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
|
||||
// Store learning event
|
||||
self.viking
|
||||
.store_metadata(
|
||||
&format!("agent://{}/events/{}", agent_id, session_id),
|
||||
&event,
|
||||
)
|
||||
.await?;
|
||||
// Store learning event as MemoryEntry so get_timeline can find it via find_by_prefix
|
||||
let event_uri = format!("agent://{}/events/{}", agent_id, session_id);
|
||||
let content = serde_json::to_string(&event)?;
|
||||
let entry = crate::types::MemoryEntry {
|
||||
uri: event_uri,
|
||||
memory_type: MemoryType::Session,
|
||||
content,
|
||||
keywords: vec![agent_id.to_string(), session_id.to_string()],
|
||||
importance: 5,
|
||||
access_count: 0,
|
||||
created_at: event.timestamp,
|
||||
last_accessed: event.timestamp,
|
||||
overview: None,
|
||||
abstract_summary: None,
|
||||
};
|
||||
self.viking.store(&entry).await?;
|
||||
|
||||
// Update last learning time
|
||||
// Update last learning time via metadata
|
||||
self.viking
|
||||
.store_metadata(
|
||||
&format!("agent://{}", agent_id),
|
||||
&AgentMetadata {
|
||||
last_learning_time: Some(Utc::now()),
|
||||
total_learning_events: None, // Will be computed
|
||||
total_learning_events: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -394,6 +394,103 @@ pub struct DecayResult {
|
||||
pub archived: u64,
|
||||
}
|
||||
|
||||
// === Evolution Engine Types ===
|
||||
|
||||
/// 经验提取结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExperienceCandidate {
|
||||
pub pain_pattern: String,
|
||||
pub context: String,
|
||||
pub solution_steps: Vec<String>,
|
||||
pub outcome: Outcome,
|
||||
pub confidence: f32,
|
||||
pub tools_used: Vec<String>,
|
||||
pub industry_context: Option<String>,
|
||||
}
|
||||
|
||||
/// 结果状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Outcome {
|
||||
Success,
|
||||
Partial,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// 合并提取结果(单次 LLM 调用的全部输出)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CombinedExtraction {
|
||||
pub memories: Vec<ExtractedMemory>,
|
||||
pub experiences: Vec<ExperienceCandidate>,
|
||||
pub profile_signals: ProfileSignals,
|
||||
}
|
||||
|
||||
/// 画像更新信号(从提取结果中推断,不额外调用 LLM)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProfileSignals {
|
||||
pub industry: Option<String>,
|
||||
pub recent_topic: Option<String>,
|
||||
pub pain_point: Option<String>,
|
||||
pub preferred_tool: Option<String>,
|
||||
pub communication_style: Option<String>,
|
||||
}
|
||||
|
||||
impl ProfileSignals {
|
||||
/// 是否包含至少一个有效信号
|
||||
pub fn has_any_signal(&self) -> bool {
|
||||
self.industry.is_some()
|
||||
|| self.recent_topic.is_some()
|
||||
|| self.pain_point.is_some()
|
||||
|| self.preferred_tool.is_some()
|
||||
|| self.communication_style.is_some()
|
||||
}
|
||||
|
||||
/// 有效信号数量
|
||||
pub fn signal_count(&self) -> usize {
|
||||
let mut count = 0;
|
||||
if self.industry.is_some() { count += 1; }
|
||||
if self.recent_topic.is_some() { count += 1; }
|
||||
if self.pain_point.is_some() { count += 1; }
|
||||
if self.preferred_tool.is_some() { count += 1; }
|
||||
if self.communication_style.is_some() { count += 1; }
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
/// 进化事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EvolutionEvent {
|
||||
pub id: String,
|
||||
pub event_type: EvolutionEventType,
|
||||
pub artifact_type: ArtifactType,
|
||||
pub artifact_id: String,
|
||||
pub status: EvolutionStatus,
|
||||
pub confidence: f32,
|
||||
pub user_feedback: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EvolutionEventType {
|
||||
SkillGenerated,
|
||||
SkillOptimized,
|
||||
WorkflowGenerated,
|
||||
WorkflowOptimized,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ArtifactType {
|
||||
Skill,
|
||||
Pipeline,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum EvolutionStatus {
|
||||
Pending,
|
||||
Confirmed,
|
||||
Rejected,
|
||||
Optimized,
|
||||
}
|
||||
|
||||
/// Compute effective importance with time decay.
|
||||
///
|
||||
/// Uses exponential decay: each 30-day period of non-access reduces
|
||||
@@ -524,4 +621,61 @@ mod tests {
|
||||
assert!(!result.is_empty());
|
||||
assert_eq!(result.total_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_experience_candidate_roundtrip() {
|
||||
let candidate = ExperienceCandidate {
|
||||
pain_pattern: "报表生成".to_string(),
|
||||
context: "月度销售报表".to_string(),
|
||||
solution_steps: vec!["查询数据库".to_string(), "格式化输出".to_string()],
|
||||
outcome: Outcome::Success,
|
||||
confidence: 0.85,
|
||||
tools_used: vec!["researcher".to_string()],
|
||||
industry_context: Some("healthcare".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&candidate).unwrap();
|
||||
let decoded: ExperienceCandidate = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.pain_pattern, "报表生成");
|
||||
assert_eq!(decoded.outcome, Outcome::Success);
|
||||
assert_eq!(decoded.solution_steps.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evolution_event_roundtrip() {
|
||||
let event = EvolutionEvent {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
event_type: EvolutionEventType::SkillGenerated,
|
||||
artifact_type: ArtifactType::Skill,
|
||||
artifact_id: "daily-report".to_string(),
|
||||
status: EvolutionStatus::Pending,
|
||||
confidence: 0.8,
|
||||
user_feedback: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let decoded: EvolutionEvent = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.event_type, EvolutionEventType::SkillGenerated);
|
||||
assert_eq!(decoded.status, EvolutionStatus::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_combined_extraction_default() {
|
||||
let combined = CombinedExtraction::default();
|
||||
assert!(combined.memories.is_empty());
|
||||
assert!(combined.experiences.is_empty());
|
||||
assert!(combined.profile_signals.industry.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_signals() {
|
||||
let signals = ProfileSignals {
|
||||
industry: Some("healthcare".to_string()),
|
||||
recent_topic: Some("报表".to_string()),
|
||||
pain_point: None,
|
||||
preferred_tool: Some("researcher".to_string()),
|
||||
communication_style: Some("concise".to_string()),
|
||||
};
|
||||
assert_eq!(signals.industry.as_deref(), Some("healthcare"));
|
||||
assert!(signals.pain_point.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
180
crates/zclaw-growth/src/workflow_composer.rs
Normal file
180
crates/zclaw-growth/src/workflow_composer.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
//! 工作流组装器(L3 工作流进化)
|
||||
//! 从轨迹数据中分析重复的工具链模式,自动组装 Pipeline YAML
|
||||
//! 触发条件:CompressedTrajectory 中出现 2 次以上相同工具链序列
|
||||
|
||||
use zclaw_types::Result;
|
||||
|
||||
/// Pipeline 候选项
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PipelineCandidate {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub triggers: Vec<String>,
|
||||
pub yaml_content: String,
|
||||
pub source_sessions: Vec<String>,
|
||||
pub confidence: f32,
|
||||
}
|
||||
|
||||
/// 工具链模式(用于聚类分析)
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
pub struct ToolChainPattern {
|
||||
pub steps: Vec<String>,
|
||||
}
|
||||
|
||||
/// 工作流组装 prompt
|
||||
const WORKFLOW_GENERATION_PROMPT: &str = r#"
|
||||
你是一个工作流设计专家。根据以下用户反复执行的工具链序列,设计一个可复用的 Pipeline 工作流。
|
||||
|
||||
工具链序列:{tool_chain}
|
||||
执行频率:{frequency} 次
|
||||
行业背景:{industry}
|
||||
|
||||
请生成以下 JSON:
|
||||
```json
|
||||
{
|
||||
"name": "工作流名称(简短中文)",
|
||||
"description": "工作流描述",
|
||||
"triggers": ["触发词1", "触发词2"],
|
||||
"yaml_content": "Pipeline YAML 内容",
|
||||
"confidence": 0.8
|
||||
}
|
||||
```
|
||||
"#;
|
||||
|
||||
/// 工作流组装器
|
||||
/// 分析压缩轨迹中的工具链模式,通过 LLM 生成 Pipeline YAML
|
||||
pub struct WorkflowComposer;
|
||||
|
||||
impl WorkflowComposer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 从压缩轨迹的工具链中提取模式
|
||||
/// 简单的精确匹配聚类:相同工具链序列视为同一模式
|
||||
pub fn extract_patterns(
|
||||
trajectories: &[(String, Vec<String>)], // (session_id, tools_used)
|
||||
) -> Vec<(ToolChainPattern, Vec<String>)> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut groups: HashMap<ToolChainPattern, Vec<String>> = HashMap::new();
|
||||
for (session_id, tools) in trajectories {
|
||||
if tools.len() < 2 {
|
||||
continue; // 单步操作不构成工作流
|
||||
}
|
||||
let pattern = ToolChainPattern {
|
||||
steps: tools.clone(),
|
||||
};
|
||||
groups.entry(pattern).or_default().push(session_id.clone());
|
||||
}
|
||||
|
||||
// 过滤出现 2 次以上的模式
|
||||
groups
|
||||
.into_iter()
|
||||
.filter(|(_, sessions)| sessions.len() >= 2)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 构建 LLM prompt
|
||||
pub fn build_prompt(
|
||||
pattern: &ToolChainPattern,
|
||||
frequency: usize,
|
||||
industry: Option<&str>,
|
||||
) -> String {
|
||||
WORKFLOW_GENERATION_PROMPT
|
||||
.replace("{tool_chain}", &pattern.steps.join(" → "))
|
||||
.replace("{frequency}", &frequency.to_string())
|
||||
.replace("{industry}", industry.unwrap_or("通用"))
|
||||
}
|
||||
|
||||
/// 解析 LLM 返回的 JSON 为 PipelineCandidate
|
||||
pub fn parse_response(
|
||||
json_str: &str,
|
||||
_pattern: &ToolChainPattern,
|
||||
source_sessions: Vec<String>,
|
||||
) -> Result<PipelineCandidate> {
|
||||
let json_str = crate::json_utils::extract_json_block(json_str);
|
||||
let raw: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
|
||||
zclaw_types::ZclawError::ConfigError(format!("Invalid pipeline JSON: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(PipelineCandidate {
|
||||
name: raw["name"].as_str().unwrap_or("未命名工作流").to_string(),
|
||||
description: raw["description"].as_str().unwrap_or("").to_string(),
|
||||
triggers: crate::json_utils::extract_string_array(&raw, "triggers"),
|
||||
yaml_content: raw["yaml_content"].as_str().unwrap_or("").to_string(),
|
||||
source_sessions,
|
||||
confidence: raw["confidence"].as_f64().unwrap_or(0.5) as f32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WorkflowComposer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_patterns_filters_single_step() {
|
||||
let trajectories = vec![
|
||||
("s1".to_string(), vec!["researcher".to_string()]),
|
||||
];
|
||||
let patterns = WorkflowComposer::extract_patterns(&trajectories);
|
||||
assert!(patterns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_patterns_groups_identical_chains() {
|
||||
let trajectories = vec![
|
||||
("s1".to_string(), vec!["researcher".into(), "collector".into()]),
|
||||
("s2".to_string(), vec!["researcher".into(), "collector".into()]),
|
||||
("s3".to_string(), vec!["browser".into()]), // 单步,过滤
|
||||
];
|
||||
let patterns = WorkflowComposer::extract_patterns(&trajectories);
|
||||
assert_eq!(patterns.len(), 1);
|
||||
assert_eq!(patterns[0].1.len(), 2); // 2 sessions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_patterns_requires_min_2() {
|
||||
let trajectories = vec![
|
||||
("s1".to_string(), vec!["a".into(), "b".into()]),
|
||||
];
|
||||
let patterns = WorkflowComposer::extract_patterns(&trajectories);
|
||||
assert!(patterns.is_empty()); // 只出现 1 次
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_prompt() {
|
||||
let pattern = ToolChainPattern {
|
||||
steps: vec!["researcher".into(), "collector".into(), "summarize".into()],
|
||||
};
|
||||
let prompt = WorkflowComposer::build_prompt(&pattern, 3, Some("healthcare"));
|
||||
assert!(prompt.contains("researcher"));
|
||||
assert!(prompt.contains("3"));
|
||||
assert!(prompt.contains("healthcare"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response() {
|
||||
let pattern = ToolChainPattern {
|
||||
steps: vec!["researcher".into()],
|
||||
};
|
||||
let json = r##"{"name":"每日简报","description":"搜索+汇总","triggers":["简报","日报"],"yaml_content":"steps: []","confidence":0.85}"##;
|
||||
let candidate = WorkflowComposer::parse_response(
|
||||
json,
|
||||
&pattern,
|
||||
vec!["s1".into(), "s2".into()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(candidate.name, "每日简报");
|
||||
assert_eq!(candidate.triggers.len(), 2);
|
||||
assert_eq!(candidate.source_sessions.len(), 2);
|
||||
assert!((candidate.confidence - 0.85).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
@@ -459,7 +459,7 @@ impl ClipHand {
|
||||
let args = vec![
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", temp_file.to_str().unwrap(),
|
||||
"-i", temp_file.to_str().ok_or_else(|| zclaw_types::ZclawError::HandError("Temp file path is not valid UTF-8".to_string()))?,
|
||||
"-c", "copy",
|
||||
&config.output_path,
|
||||
];
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Educational Hands - Teaching and presentation capabilities
|
||||
//!
|
||||
//! This module provides hands for interactive classroom experiences:
|
||||
//! - Whiteboard: Drawing and annotation
|
||||
//! - Slideshow: Presentation control
|
||||
//! - Speech: Text-to-speech synthesis
|
||||
//! This module provides hands for interactive experiences:
|
||||
//! - Quiz: Assessment and evaluation
|
||||
//! - Browser: Web automation
|
||||
//! - Researcher: Deep research and analysis
|
||||
@@ -11,9 +8,6 @@
|
||||
//! - Clip: Video processing
|
||||
//! - Twitter: Social media automation
|
||||
|
||||
mod whiteboard;
|
||||
mod slideshow;
|
||||
mod speech;
|
||||
pub mod quiz;
|
||||
mod browser;
|
||||
mod researcher;
|
||||
@@ -22,9 +16,6 @@ mod clip;
|
||||
mod twitter;
|
||||
pub mod reminder;
|
||||
|
||||
pub use whiteboard::*;
|
||||
pub use slideshow::*;
|
||||
pub use speech::*;
|
||||
pub use quiz::*;
|
||||
pub use browser::*;
|
||||
pub use researcher::*;
|
||||
|
||||
@@ -1,797 +0,0 @@
|
||||
//! Slideshow Hand - Presentation control capabilities
|
||||
//!
|
||||
//! Provides slideshow control for teaching:
|
||||
//! - next_slide/prev_slide: Navigation
|
||||
//! - goto_slide: Jump to specific slide
|
||||
//! - spotlight: Highlight elements
|
||||
//! - laser: Show laser pointer
|
||||
//! - highlight: Highlight areas
|
||||
//! - play_animation: Trigger animations
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// Slideshow action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum SlideshowAction {
|
||||
/// Go to next slide
|
||||
NextSlide,
|
||||
/// Go to previous slide
|
||||
PrevSlide,
|
||||
/// Go to specific slide
|
||||
GotoSlide {
|
||||
slide_number: usize,
|
||||
},
|
||||
/// Spotlight/highlight an element
|
||||
Spotlight {
|
||||
element_id: String,
|
||||
#[serde(default = "default_spotlight_duration")]
|
||||
duration_ms: u64,
|
||||
},
|
||||
/// Show laser pointer at position
|
||||
Laser {
|
||||
x: f64,
|
||||
y: f64,
|
||||
#[serde(default = "default_laser_duration")]
|
||||
duration_ms: u64,
|
||||
},
|
||||
/// Highlight a rectangular area
|
||||
Highlight {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
#[serde(default = "default_highlight_duration")]
|
||||
duration_ms: u64,
|
||||
},
|
||||
/// Play animation
|
||||
PlayAnimation {
|
||||
animation_id: String,
|
||||
},
|
||||
/// Pause auto-play
|
||||
Pause,
|
||||
/// Resume auto-play
|
||||
Resume,
|
||||
/// Start auto-play
|
||||
AutoPlay {
|
||||
#[serde(default = "default_interval")]
|
||||
interval_ms: u64,
|
||||
},
|
||||
/// Stop auto-play
|
||||
StopAutoPlay,
|
||||
/// Get current state
|
||||
GetState,
|
||||
/// Set slide content (for dynamic slides)
|
||||
SetContent {
|
||||
slide_number: usize,
|
||||
content: SlideContent,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_spotlight_duration() -> u64 { 2000 }
|
||||
fn default_laser_duration() -> u64 { 3000 }
|
||||
fn default_highlight_duration() -> u64 { 2000 }
|
||||
fn default_interval() -> u64 { 5000 }
|
||||
|
||||
/// Slide content structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlideContent {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub subtitle: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Vec<ContentBlock>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub background: Option<String>,
|
||||
}
|
||||
|
||||
/// Presentation/slideshow rendering content block. Domain-specific for slide content.
|
||||
/// Distinct from zclaw_types::ContentBlock (LLM messages) and zclaw_protocols::ContentBlock (MCP).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
Text { text: String, style: Option<TextStyle> },
|
||||
Image { url: String, alt: Option<String> },
|
||||
List { items: Vec<String>, ordered: bool },
|
||||
Code { code: String, language: Option<String> },
|
||||
Math { latex: String },
|
||||
Table { headers: Vec<String>, rows: Vec<Vec<String>> },
|
||||
Chart { chart_type: String, data: serde_json::Value },
|
||||
}
|
||||
|
||||
/// Text style options
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TextStyle {
|
||||
#[serde(default)]
|
||||
pub bold: bool,
|
||||
#[serde(default)]
|
||||
pub italic: bool,
|
||||
#[serde(default)]
|
||||
pub size: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// Slideshow state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlideshowState {
|
||||
pub current_slide: usize,
|
||||
pub total_slides: usize,
|
||||
pub is_playing: bool,
|
||||
pub auto_play_interval_ms: u64,
|
||||
pub slides: Vec<SlideContent>,
|
||||
}
|
||||
|
||||
impl Default for SlideshowState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_slide: 0,
|
||||
total_slides: 0,
|
||||
is_playing: false,
|
||||
auto_play_interval_ms: 5000,
|
||||
slides: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slideshow Hand implementation
|
||||
pub struct SlideshowHand {
|
||||
config: HandConfig,
|
||||
state: Arc<RwLock<SlideshowState>>,
|
||||
}
|
||||
|
||||
impl SlideshowHand {
|
||||
/// Create a new slideshow hand
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "slideshow".to_string(),
|
||||
name: "幻灯片".to_string(),
|
||||
description: "控制演示文稿的播放、导航和标注".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"slide_number": { "type": "integer" },
|
||||
"element_id": { "type": "string" },
|
||||
}
|
||||
})),
|
||||
tags: vec!["presentation".to_string(), "education".to_string()],
|
||||
enabled: true,
|
||||
max_concurrent: 0,
|
||||
timeout_secs: 0,
|
||||
},
|
||||
state: Arc::new(RwLock::new(SlideshowState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with slides (async version)
|
||||
pub async fn with_slides_async(slides: Vec<SlideContent>) -> Self {
|
||||
let hand = Self::new();
|
||||
let mut state = hand.state.write().await;
|
||||
state.total_slides = slides.len();
|
||||
state.slides = slides;
|
||||
drop(state);
|
||||
hand
|
||||
}
|
||||
|
||||
/// Execute a slideshow action
|
||||
pub async fn execute_action(&self, action: SlideshowAction) -> Result<HandResult> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match action {
|
||||
SlideshowAction::NextSlide => {
|
||||
if state.current_slide < state.total_slides.saturating_sub(1) {
|
||||
state.current_slide += 1;
|
||||
}
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "next",
|
||||
"current_slide": state.current_slide,
|
||||
"total_slides": state.total_slides,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::PrevSlide => {
|
||||
if state.current_slide > 0 {
|
||||
state.current_slide -= 1;
|
||||
}
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "prev",
|
||||
"current_slide": state.current_slide,
|
||||
"total_slides": state.total_slides,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::GotoSlide { slide_number } => {
|
||||
if slide_number < state.total_slides {
|
||||
state.current_slide = slide_number;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "goto",
|
||||
"current_slide": state.current_slide,
|
||||
"slide_content": state.slides.get(slide_number),
|
||||
})))
|
||||
} else {
|
||||
Ok(HandResult::error(format!("Slide {} out of range", slide_number)))
|
||||
}
|
||||
}
|
||||
SlideshowAction::Spotlight { element_id, duration_ms } => {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "spotlight",
|
||||
"element_id": element_id,
|
||||
"duration_ms": duration_ms,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::Laser { x, y, duration_ms } => {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "laser",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"duration_ms": duration_ms,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::Highlight { x, y, width, height, color, duration_ms } => {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "highlight",
|
||||
"x": x, "y": y,
|
||||
"width": width, "height": height,
|
||||
"color": color.unwrap_or_else(|| "#ffcc00".to_string()),
|
||||
"duration_ms": duration_ms,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::PlayAnimation { animation_id } => {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "animation",
|
||||
"animation_id": animation_id,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::Pause => {
|
||||
state.is_playing = false;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "paused",
|
||||
})))
|
||||
}
|
||||
SlideshowAction::Resume => {
|
||||
state.is_playing = true;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "resumed",
|
||||
})))
|
||||
}
|
||||
SlideshowAction::AutoPlay { interval_ms } => {
|
||||
state.is_playing = true;
|
||||
state.auto_play_interval_ms = interval_ms;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "autoplay",
|
||||
"interval_ms": interval_ms,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::StopAutoPlay => {
|
||||
state.is_playing = false;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "stopped",
|
||||
})))
|
||||
}
|
||||
SlideshowAction::GetState => {
|
||||
Ok(HandResult::success(serde_json::to_value(&*state).unwrap_or(Value::Null)))
|
||||
}
|
||||
SlideshowAction::SetContent { slide_number, content } => {
|
||||
if slide_number < state.slides.len() {
|
||||
state.slides[slide_number] = content.clone();
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "content_set",
|
||||
"slide_number": slide_number,
|
||||
})))
|
||||
} else if slide_number == state.slides.len() {
|
||||
state.slides.push(content);
|
||||
state.total_slides = state.slides.len();
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "slide_added",
|
||||
"slide_number": slide_number,
|
||||
})))
|
||||
} else {
|
||||
Ok(HandResult::error(format!("Invalid slide number: {}", slide_number)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub async fn get_state(&self) -> SlideshowState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Add a slide
|
||||
pub async fn add_slide(&self, content: SlideContent) {
|
||||
let mut state = self.state.write().await;
|
||||
state.slides.push(content);
|
||||
state.total_slides = state.slides.len();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SlideshowHand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for SlideshowHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
let action: SlideshowAction = match serde_json::from_value(input) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return Ok(HandResult::error(format!("Invalid slideshow action: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
self.execute_action(action).await
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// === Config & Defaults ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_slideshow_creation() {
|
||||
let hand = SlideshowHand::new();
|
||||
assert_eq!(hand.config().id, "slideshow");
|
||||
assert_eq!(hand.config().name, "幻灯片");
|
||||
assert!(!hand.config().needs_approval);
|
||||
assert!(hand.config().enabled);
|
||||
assert!(hand.config().tags.contains(&"presentation".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_impl() {
|
||||
let hand = SlideshowHand::default();
|
||||
assert_eq!(hand.config().id, "slideshow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_approval() {
|
||||
let hand = SlideshowHand::new();
|
||||
assert!(!hand.needs_approval());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status() {
|
||||
let hand = SlideshowHand::new();
|
||||
assert_eq!(hand.status(), HandStatus::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_state() {
|
||||
let state = SlideshowState::default();
|
||||
assert_eq!(state.current_slide, 0);
|
||||
assert_eq!(state.total_slides, 0);
|
||||
assert!(!state.is_playing);
|
||||
assert_eq!(state.auto_play_interval_ms, 5000);
|
||||
assert!(state.slides.is_empty());
|
||||
}
|
||||
|
||||
// === Navigation ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_navigation() {
|
||||
let hand = SlideshowHand::with_slides_async(vec![
|
||||
SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
SlideContent { title: "Slide 3".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
]).await;
|
||||
|
||||
// Next
|
||||
hand.execute_action(SlideshowAction::NextSlide).await.unwrap();
|
||||
assert_eq!(hand.get_state().await.current_slide, 1);
|
||||
|
||||
// Goto
|
||||
hand.execute_action(SlideshowAction::GotoSlide { slide_number: 2 }).await.unwrap();
|
||||
assert_eq!(hand.get_state().await.current_slide, 2);
|
||||
|
||||
// Prev
|
||||
hand.execute_action(SlideshowAction::PrevSlide).await.unwrap();
|
||||
assert_eq!(hand.get_state().await.current_slide, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_next_slide_at_end() {
|
||||
let hand = SlideshowHand::with_slides_async(vec![
|
||||
SlideContent { title: "Only Slide".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
]).await;
|
||||
|
||||
// At slide 0, should not advance past last slide
|
||||
hand.execute_action(SlideshowAction::NextSlide).await.unwrap();
|
||||
assert_eq!(hand.get_state().await.current_slide, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prev_slide_at_beginning() {
|
||||
let hand = SlideshowHand::with_slides_async(vec![
|
||||
SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
]).await;
|
||||
|
||||
// At slide 0, should not go below 0
|
||||
hand.execute_action(SlideshowAction::PrevSlide).await.unwrap();
|
||||
assert_eq!(hand.get_state().await.current_slide, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_goto_slide_out_of_range() {
|
||||
let hand = SlideshowHand::with_slides_async(vec![
|
||||
SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
]).await;
|
||||
|
||||
let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 5 }).await.unwrap();
|
||||
assert!(!result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_goto_slide_returns_content() {
|
||||
let hand = SlideshowHand::with_slides_async(vec![
|
||||
SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
SlideContent { title: "Second".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
]).await;
|
||||
|
||||
let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 1 }).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output["slide_content"]["title"], "Second");
|
||||
}
|
||||
|
||||
// === Spotlight & Laser & Highlight ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_spotlight() {
|
||||
let hand = SlideshowHand::new();
|
||||
let action = SlideshowAction::Spotlight {
|
||||
element_id: "title".to_string(),
|
||||
duration_ms: 2000,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output["element_id"], "title");
|
||||
assert_eq!(result.output["duration_ms"], 2000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_spotlight_default_duration() {
|
||||
let hand = SlideshowHand::new();
|
||||
let action = SlideshowAction::Spotlight {
|
||||
element_id: "elem".to_string(),
|
||||
duration_ms: default_spotlight_duration(),
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert_eq!(result.output["duration_ms"], 2000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_laser() {
|
||||
let hand = SlideshowHand::new();
|
||||
let action = SlideshowAction::Laser {
|
||||
x: 100.0,
|
||||
y: 200.0,
|
||||
duration_ms: 3000,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output["x"], 100.0);
|
||||
assert_eq!(result.output["y"], 200.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_highlight_default_color() {
|
||||
let hand = SlideshowHand::new();
|
||||
let action = SlideshowAction::Highlight {
|
||||
x: 10.0, y: 20.0, width: 100.0, height: 50.0,
|
||||
color: None, duration_ms: 2000,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output["color"], "#ffcc00");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_highlight_custom_color() {
|
||||
let hand = SlideshowHand::new();
|
||||
let action = SlideshowAction::Highlight {
|
||||
x: 0.0, y: 0.0, width: 50.0, height: 50.0,
|
||||
color: Some("#ff0000".to_string()), duration_ms: 1000,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert_eq!(result.output["color"], "#ff0000");
|
||||
}
|
||||
|
||||
// === AutoPlay / Pause / Resume ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_autoplay_pause_resume() {
|
||||
let hand = SlideshowHand::new();
|
||||
|
||||
// AutoPlay
|
||||
let result = hand.execute_action(SlideshowAction::AutoPlay { interval_ms: 3000 }).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(hand.get_state().await.is_playing);
|
||||
assert_eq!(hand.get_state().await.auto_play_interval_ms, 3000);
|
||||
|
||||
// Pause
|
||||
hand.execute_action(SlideshowAction::Pause).await.unwrap();
|
||||
assert!(!hand.get_state().await.is_playing);
|
||||
|
||||
// Resume
|
||||
hand.execute_action(SlideshowAction::Resume).await.unwrap();
|
||||
assert!(hand.get_state().await.is_playing);
|
||||
|
||||
// Stop
|
||||
hand.execute_action(SlideshowAction::StopAutoPlay).await.unwrap();
|
||||
assert!(!hand.get_state().await.is_playing);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_autoplay_default_interval() {
|
||||
let hand = SlideshowHand::new();
|
||||
hand.execute_action(SlideshowAction::AutoPlay { interval_ms: default_interval() }).await.unwrap();
|
||||
assert_eq!(hand.get_state().await.auto_play_interval_ms, 5000);
|
||||
}
|
||||
|
||||
// === PlayAnimation ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_play_animation() {
|
||||
let hand = SlideshowHand::new();
|
||||
let result = hand.execute_action(SlideshowAction::PlayAnimation {
|
||||
animation_id: "fade_in".to_string(),
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output["animation_id"], "fade_in");
|
||||
}
|
||||
|
||||
// === GetState ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_state() {
|
||||
let hand = SlideshowHand::with_slides_async(vec![
|
||||
SlideContent { title: "A".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
]).await;
|
||||
|
||||
let result = hand.execute_action(SlideshowAction::GetState).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output["total_slides"], 1);
|
||||
assert_eq!(result.output["current_slide"], 0);
|
||||
}
|
||||
|
||||
// === SetContent ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_content() {
|
||||
let hand = SlideshowHand::new();
|
||||
|
||||
let content = SlideContent {
|
||||
title: "Test Slide".to_string(),
|
||||
subtitle: Some("Subtitle".to_string()),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
style: None,
|
||||
}],
|
||||
notes: Some("Speaker notes".to_string()),
|
||||
background: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(SlideshowAction::SetContent {
|
||||
slide_number: 0,
|
||||
content,
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.total_slides, 1);
|
||||
assert_eq!(hand.get_state().await.slides[0].title, "Test Slide");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_content_append() {
|
||||
let hand = SlideshowHand::with_slides_async(vec![
|
||||
SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
]).await;
|
||||
|
||||
let content = SlideContent {
|
||||
title: "Appended".to_string(), subtitle: None, content: vec![], notes: None, background: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(SlideshowAction::SetContent {
|
||||
slide_number: 1,
|
||||
content,
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output["status"], "slide_added");
|
||||
assert_eq!(hand.get_state().await.total_slides, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_content_invalid_index() {
|
||||
let hand = SlideshowHand::new();
|
||||
|
||||
let content = SlideContent {
|
||||
title: "Gap".to_string(), subtitle: None, content: vec![], notes: None, background: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(SlideshowAction::SetContent {
|
||||
slide_number: 5,
|
||||
content,
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(!result.success);
|
||||
}
|
||||
|
||||
// === Action Deserialization ===
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_next_slide() {
|
||||
let action: SlideshowAction = serde_json::from_value(json!({"action": "next_slide"})).unwrap();
|
||||
assert!(matches!(action, SlideshowAction::NextSlide));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_goto_slide() {
|
||||
let action: SlideshowAction = serde_json::from_value(json!({"action": "goto_slide", "slide_number": 3})).unwrap();
|
||||
match action {
|
||||
SlideshowAction::GotoSlide { slide_number } => assert_eq!(slide_number, 3),
|
||||
_ => panic!("Expected GotoSlide"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_laser() {
|
||||
let action: SlideshowAction = serde_json::from_value(json!({
|
||||
"action": "laser", "x": 50.0, "y": 75.0
|
||||
})).unwrap();
|
||||
match action {
|
||||
SlideshowAction::Laser { x, y, .. } => {
|
||||
assert_eq!(x, 50.0);
|
||||
assert_eq!(y, 75.0);
|
||||
}
|
||||
_ => panic!("Expected Laser"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_autoplay() {
|
||||
let action: SlideshowAction = serde_json::from_value(json!({"action": "auto_play"})).unwrap();
|
||||
match action {
|
||||
SlideshowAction::AutoPlay { interval_ms } => assert_eq!(interval_ms, 5000),
|
||||
_ => panic!("Expected AutoPlay"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_invalid_action() {
|
||||
let result = serde_json::from_value::<SlideshowAction>(json!({"action": "nonexistent"}));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// === ContentBlock Deserialization ===
|
||||
|
||||
#[test]
|
||||
fn test_content_block_text() {
|
||||
let block: ContentBlock = serde_json::from_value(json!({
|
||||
"type": "text", "text": "Hello"
|
||||
})).unwrap();
|
||||
match block {
|
||||
ContentBlock::Text { text, style } => {
|
||||
assert_eq!(text, "Hello");
|
||||
assert!(style.is_none());
|
||||
}
|
||||
_ => panic!("Expected Text"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_block_list() {
|
||||
let block: ContentBlock = serde_json::from_value(json!({
|
||||
"type": "list", "items": ["A", "B"], "ordered": true
|
||||
})).unwrap();
|
||||
match block {
|
||||
ContentBlock::List { items, ordered } => {
|
||||
assert_eq!(items, vec!["A", "B"]);
|
||||
assert!(ordered);
|
||||
}
|
||||
_ => panic!("Expected List"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_block_code() {
|
||||
let block: ContentBlock = serde_json::from_value(json!({
|
||||
"type": "code", "code": "fn main() {}", "language": "rust"
|
||||
})).unwrap();
|
||||
match block {
|
||||
ContentBlock::Code { code, language } => {
|
||||
assert_eq!(code, "fn main() {}");
|
||||
assert_eq!(language, Some("rust".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Code"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_block_table() {
|
||||
let block: ContentBlock = serde_json::from_value(json!({
|
||||
"type": "table",
|
||||
"headers": ["Name", "Age"],
|
||||
"rows": [["Alice", "30"]]
|
||||
})).unwrap();
|
||||
match block {
|
||||
ContentBlock::Table { headers, rows } => {
|
||||
assert_eq!(headers, vec!["Name", "Age"]);
|
||||
assert_eq!(rows, vec![vec!["Alice", "30"]]);
|
||||
}
|
||||
_ => panic!("Expected Table"),
|
||||
}
|
||||
}
|
||||
|
||||
// === Hand trait via execute ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hand_execute_dispatch() {
|
||||
let hand = SlideshowHand::with_slides_async(vec![
|
||||
SlideContent { title: "S1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
SlideContent { title: "S2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
]).await;
|
||||
|
||||
let ctx = HandContext::default();
|
||||
let result = hand.execute(&ctx, json!({"action": "next_slide"})).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output["current_slide"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hand_execute_invalid_action() {
|
||||
let hand = SlideshowHand::new();
|
||||
let ctx = HandContext::default();
|
||||
let result = hand.execute(&ctx, json!({"action": "invalid"})).await.unwrap();
|
||||
assert!(!result.success);
|
||||
}
|
||||
|
||||
// === add_slide helper ===
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_slide() {
|
||||
let hand = SlideshowHand::new();
|
||||
hand.add_slide(SlideContent {
|
||||
title: "Dynamic".to_string(), subtitle: None, content: vec![], notes: None, background: None,
|
||||
}).await;
|
||||
hand.add_slide(SlideContent {
|
||||
title: "Dynamic 2".to_string(), subtitle: None, content: vec![], notes: None, background: None,
|
||||
}).await;
|
||||
|
||||
let state = hand.get_state().await;
|
||||
assert_eq!(state.total_slides, 2);
|
||||
assert_eq!(state.slides.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
//! Speech Hand - Text-to-Speech synthesis capabilities
|
||||
//!
|
||||
//! Provides speech synthesis for teaching:
|
||||
//! - speak: Convert text to speech
|
||||
//! - speak_ssml: Advanced speech with SSML markup
|
||||
//! - pause/resume/stop: Playback control
|
||||
//! - list_voices: Get available voices
|
||||
//! - set_voice: Configure voice settings
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// TTS Provider types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TtsProvider {
|
||||
#[default]
|
||||
Browser,
|
||||
Azure,
|
||||
OpenAI,
|
||||
ElevenLabs,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// Speech action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum SpeechAction {
|
||||
/// Speak text
|
||||
Speak {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
voice: Option<String>,
|
||||
#[serde(default = "default_rate")]
|
||||
rate: f32,
|
||||
#[serde(default = "default_pitch")]
|
||||
pitch: f32,
|
||||
#[serde(default = "default_volume")]
|
||||
volume: f32,
|
||||
#[serde(default)]
|
||||
language: Option<String>,
|
||||
},
|
||||
/// Speak with SSML markup
|
||||
SpeakSsml {
|
||||
ssml: String,
|
||||
#[serde(default)]
|
||||
voice: Option<String>,
|
||||
},
|
||||
/// Pause playback
|
||||
Pause,
|
||||
/// Resume playback
|
||||
Resume,
|
||||
/// Stop playback
|
||||
Stop,
|
||||
/// List available voices
|
||||
ListVoices {
|
||||
#[serde(default)]
|
||||
language: Option<String>,
|
||||
},
|
||||
/// Set default voice
|
||||
SetVoice {
|
||||
voice: String,
|
||||
#[serde(default)]
|
||||
language: Option<String>,
|
||||
},
|
||||
/// Set provider
|
||||
SetProvider {
|
||||
provider: TtsProvider,
|
||||
#[serde(default)]
|
||||
api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
region: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_rate() -> f32 { 1.0 }
|
||||
fn default_pitch() -> f32 { 1.0 }
|
||||
fn default_volume() -> f32 { 1.0 }
|
||||
|
||||
/// Voice information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VoiceInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub language: String,
|
||||
pub gender: String,
|
||||
#[serde(default)]
|
||||
pub preview_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Playback state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub enum PlaybackState {
|
||||
#[default]
|
||||
Idle,
|
||||
Playing,
|
||||
Paused,
|
||||
}
|
||||
|
||||
/// Speech configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpeechConfig {
|
||||
pub provider: TtsProvider,
|
||||
pub default_voice: Option<String>,
|
||||
pub default_language: String,
|
||||
pub default_rate: f32,
|
||||
pub default_pitch: f32,
|
||||
pub default_volume: f32,
|
||||
}
|
||||
|
||||
impl Default for SpeechConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: TtsProvider::Browser,
|
||||
default_voice: None,
|
||||
default_language: "zh-CN".to_string(),
|
||||
default_rate: 1.0,
|
||||
default_pitch: 1.0,
|
||||
default_volume: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Speech state
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SpeechState {
|
||||
pub config: SpeechConfig,
|
||||
pub playback: PlaybackState,
|
||||
pub current_text: Option<String>,
|
||||
pub position_ms: u64,
|
||||
pub available_voices: Vec<VoiceInfo>,
|
||||
}
|
||||
|
||||
/// Speech Hand implementation
|
||||
pub struct SpeechHand {
|
||||
config: HandConfig,
|
||||
state: Arc<RwLock<SpeechState>>,
|
||||
}
|
||||
|
||||
impl SpeechHand {
|
||||
/// Create a new speech hand
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "speech".to_string(),
|
||||
name: "语音合成".to_string(),
|
||||
description: "文本转语音合成输出".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"voice": { "type": "string" },
|
||||
"rate": { "type": "number" },
|
||||
}
|
||||
})),
|
||||
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string(), "demo".to_string()],
|
||||
enabled: true,
|
||||
max_concurrent: 0,
|
||||
timeout_secs: 0,
|
||||
},
|
||||
state: Arc::new(RwLock::new(SpeechState {
|
||||
config: SpeechConfig::default(),
|
||||
playback: PlaybackState::Idle,
|
||||
available_voices: Self::get_default_voices(),
|
||||
..Default::default()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom provider
|
||||
pub fn with_provider(provider: TtsProvider) -> Self {
|
||||
let hand = Self::new();
|
||||
let mut state = hand.state.blocking_write();
|
||||
state.config.provider = provider;
|
||||
drop(state);
|
||||
hand
|
||||
}
|
||||
|
||||
/// Get default voices
|
||||
fn get_default_voices() -> Vec<VoiceInfo> {
|
||||
vec![
|
||||
VoiceInfo {
|
||||
id: "zh-CN-XiaoxiaoNeural".to_string(),
|
||||
name: "Xiaoxiao".to_string(),
|
||||
language: "zh-CN".to_string(),
|
||||
gender: "female".to_string(),
|
||||
preview_url: None,
|
||||
},
|
||||
VoiceInfo {
|
||||
id: "zh-CN-YunxiNeural".to_string(),
|
||||
name: "Yunxi".to_string(),
|
||||
language: "zh-CN".to_string(),
|
||||
gender: "male".to_string(),
|
||||
preview_url: None,
|
||||
},
|
||||
VoiceInfo {
|
||||
id: "en-US-JennyNeural".to_string(),
|
||||
name: "Jenny".to_string(),
|
||||
language: "en-US".to_string(),
|
||||
gender: "female".to_string(),
|
||||
preview_url: None,
|
||||
},
|
||||
VoiceInfo {
|
||||
id: "en-US-GuyNeural".to_string(),
|
||||
name: "Guy".to_string(),
|
||||
language: "en-US".to_string(),
|
||||
gender: "male".to_string(),
|
||||
preview_url: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Execute a speech action
|
||||
pub async fn execute_action(&self, action: SpeechAction) -> Result<HandResult> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match action {
|
||||
SpeechAction::Speak { text, voice, rate, pitch, volume, language } => {
|
||||
let voice_id = voice.or(state.config.default_voice.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let lang = language.unwrap_or_else(|| state.config.default_language.clone());
|
||||
let actual_rate = if rate == 1.0 { state.config.default_rate } else { rate };
|
||||
let actual_pitch = if pitch == 1.0 { state.config.default_pitch } else { pitch };
|
||||
let actual_volume = if volume == 1.0 { state.config.default_volume } else { volume };
|
||||
|
||||
state.playback = PlaybackState::Playing;
|
||||
state.current_text = Some(text.clone());
|
||||
|
||||
// Determine TTS method based on provider:
|
||||
// - Browser: frontend uses Web Speech API (zero deps, works offline)
|
||||
// - OpenAI: frontend calls speech_tts command (high-quality, needs API key)
|
||||
// - Others: future support
|
||||
let tts_method = match state.config.provider {
|
||||
TtsProvider::Browser => "browser",
|
||||
TtsProvider::OpenAI => "openai_api",
|
||||
TtsProvider::Azure => "azure_api",
|
||||
TtsProvider::ElevenLabs => "elevenlabs_api",
|
||||
TtsProvider::Local => "local_engine",
|
||||
};
|
||||
|
||||
let estimated_duration_ms = (text.chars().count() as f64 / 5.0 * 1000.0) as u64;
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "speaking",
|
||||
"tts_method": tts_method,
|
||||
"text": text,
|
||||
"voice": voice_id,
|
||||
"language": lang,
|
||||
"rate": actual_rate,
|
||||
"pitch": actual_pitch,
|
||||
"volume": actual_volume,
|
||||
"provider": format!("{:?}", state.config.provider).to_lowercase(),
|
||||
"duration_ms": estimated_duration_ms,
|
||||
"instruction": "Frontend should play this via TTS engine"
|
||||
})))
|
||||
}
|
||||
SpeechAction::SpeakSsml { ssml, voice } => {
|
||||
let voice_id = voice.or(state.config.default_voice.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
state.playback = PlaybackState::Playing;
|
||||
state.current_text = Some(ssml.clone());
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "speaking_ssml",
|
||||
"ssml": ssml,
|
||||
"voice": voice_id,
|
||||
"provider": state.config.provider,
|
||||
})))
|
||||
}
|
||||
SpeechAction::Pause => {
|
||||
state.playback = PlaybackState::Paused;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "paused",
|
||||
"position_ms": state.position_ms,
|
||||
})))
|
||||
}
|
||||
SpeechAction::Resume => {
|
||||
state.playback = PlaybackState::Playing;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "resumed",
|
||||
"position_ms": state.position_ms,
|
||||
})))
|
||||
}
|
||||
SpeechAction::Stop => {
|
||||
state.playback = PlaybackState::Idle;
|
||||
state.current_text = None;
|
||||
state.position_ms = 0;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "stopped",
|
||||
})))
|
||||
}
|
||||
SpeechAction::ListVoices { language } => {
|
||||
let voices: Vec<_> = state.available_voices.iter()
|
||||
.filter(|v| {
|
||||
language.as_ref()
|
||||
.map(|l| v.language.starts_with(l))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"voices": voices,
|
||||
"count": voices.len(),
|
||||
})))
|
||||
}
|
||||
SpeechAction::SetVoice { voice, language } => {
|
||||
state.config.default_voice = Some(voice.clone());
|
||||
if let Some(lang) = language {
|
||||
state.config.default_language = lang;
|
||||
}
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "voice_set",
|
||||
"voice": voice,
|
||||
"language": state.config.default_language,
|
||||
})))
|
||||
}
|
||||
SpeechAction::SetProvider { provider, api_key, region: _ } => {
|
||||
state.config.provider = provider.clone();
|
||||
// In real implementation, would configure provider
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "provider_set",
|
||||
"provider": provider,
|
||||
"configured": api_key.is_some(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub async fn get_state(&self) -> SpeechState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SpeechHand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for SpeechHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
let action: SpeechAction = match serde_json::from_value(input) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return Ok(HandResult::error(format!("Invalid speech action: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
self.execute_action(action).await
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_speech_creation() {
|
||||
let hand = SpeechHand::new();
|
||||
assert_eq!(hand.config().id, "speech");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_speak() {
|
||||
let hand = SpeechHand::new();
|
||||
let action = SpeechAction::Speak {
|
||||
text: "Hello, world!".to_string(),
|
||||
voice: None,
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
volume: 1.0,
|
||||
language: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pause_resume() {
|
||||
let hand = SpeechHand::new();
|
||||
|
||||
// Speak first
|
||||
hand.execute_action(SpeechAction::Speak {
|
||||
text: "Test".to_string(),
|
||||
voice: None, rate: 1.0, pitch: 1.0, volume: 1.0, language: None,
|
||||
}).await.unwrap();
|
||||
|
||||
// Pause
|
||||
let result = hand.execute_action(SpeechAction::Pause).await.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
// Resume
|
||||
let result = hand.execute_action(SpeechAction::Resume).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_voices() {
|
||||
let hand = SpeechHand::new();
|
||||
let action = SpeechAction::ListVoices { language: Some("zh".to_string()) };
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_voice() {
|
||||
let hand = SpeechHand::new();
|
||||
let action = SpeechAction::SetVoice {
|
||||
voice: "zh-CN-XiaoxiaoNeural".to_string(),
|
||||
language: Some("zh-CN".to_string()),
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let state = hand.get_state().await;
|
||||
assert_eq!(state.config.default_voice, Some("zh-CN-XiaoxiaoNeural".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
//! Whiteboard Hand - Drawing and annotation capabilities
|
||||
//!
|
||||
//! Provides whiteboard drawing actions for teaching:
|
||||
//! - draw_text: Draw text on the whiteboard
|
||||
//! - draw_shape: Draw shapes (rectangle, circle, arrow, etc.)
|
||||
//! - draw_line: Draw lines and curves
|
||||
//! - draw_chart: Draw charts (bar, line, pie)
|
||||
//! - draw_latex: Render LaTeX formulas
|
||||
//! - draw_table: Draw data tables
|
||||
//! - clear: Clear the whiteboard
|
||||
//! - export: Export as image
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// Whiteboard action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum WhiteboardAction {
|
||||
/// Draw text
|
||||
DrawText {
|
||||
x: f64,
|
||||
y: f64,
|
||||
text: String,
|
||||
#[serde(default = "default_font_size")]
|
||||
font_size: u32,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
#[serde(default)]
|
||||
font_family: Option<String>,
|
||||
},
|
||||
/// Draw a shape
|
||||
DrawShape {
|
||||
shape: ShapeType,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
fill: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke: Option<String>,
|
||||
#[serde(default = "default_stroke_width")]
|
||||
stroke_width: u32,
|
||||
},
|
||||
/// Draw a line
|
||||
DrawLine {
|
||||
points: Vec<Point>,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
#[serde(default = "default_stroke_width")]
|
||||
stroke_width: u32,
|
||||
},
|
||||
/// Draw a chart
|
||||
DrawChart {
|
||||
chart_type: ChartType,
|
||||
data: ChartData,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
},
|
||||
/// Draw LaTeX formula
|
||||
DrawLatex {
|
||||
latex: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
#[serde(default = "default_font_size")]
|
||||
font_size: u32,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
},
|
||||
/// Draw a table
|
||||
DrawTable {
|
||||
headers: Vec<String>,
|
||||
rows: Vec<Vec<String>>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
#[serde(default)]
|
||||
column_widths: Option<Vec<f64>>,
|
||||
},
|
||||
/// Erase area
|
||||
Erase {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
},
|
||||
/// Clear whiteboard
|
||||
Clear,
|
||||
/// Undo last action
|
||||
Undo,
|
||||
/// Redo last undone action
|
||||
Redo,
|
||||
/// Export as image
|
||||
Export {
|
||||
#[serde(default = "default_export_format")]
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_font_size() -> u32 { 16 }
|
||||
fn default_stroke_width() -> u32 { 2 }
|
||||
fn default_export_format() -> String { "png".to_string() }
|
||||
|
||||
/// Shape types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShapeType {
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Circle,
|
||||
Ellipse,
|
||||
Triangle,
|
||||
Arrow,
|
||||
Star,
|
||||
Checkmark,
|
||||
Cross,
|
||||
}
|
||||
|
||||
/// Point for line drawing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// Chart types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChartType {
|
||||
Bar,
|
||||
Line,
|
||||
Pie,
|
||||
Scatter,
|
||||
Area,
|
||||
Radar,
|
||||
}
|
||||
|
||||
/// Chart data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChartData {
|
||||
pub labels: Vec<String>,
|
||||
pub datasets: Vec<Dataset>,
|
||||
}
|
||||
|
||||
/// Dataset for charts
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Dataset {
|
||||
pub label: String,
|
||||
pub values: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// Whiteboard state (for undo/redo)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WhiteboardState {
|
||||
pub actions: Vec<WhiteboardAction>,
|
||||
pub undone: Vec<WhiteboardAction>,
|
||||
pub canvas_width: f64,
|
||||
pub canvas_height: f64,
|
||||
}
|
||||
|
||||
/// Whiteboard Hand implementation
|
||||
pub struct WhiteboardHand {
|
||||
config: HandConfig,
|
||||
state: std::sync::Arc<tokio::sync::RwLock<WhiteboardState>>,
|
||||
}
|
||||
|
||||
impl WhiteboardHand {
|
||||
/// Create a new whiteboard hand
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "whiteboard".to_string(),
|
||||
name: "白板".to_string(),
|
||||
description: "在虚拟白板上绘制和标注".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"text": { "type": "string" },
|
||||
}
|
||||
})),
|
||||
tags: vec!["presentation".to_string(), "education".to_string()],
|
||||
enabled: true,
|
||||
max_concurrent: 0,
|
||||
timeout_secs: 0,
|
||||
},
|
||||
state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState {
|
||||
canvas_width: 1920.0,
|
||||
canvas_height: 1080.0,
|
||||
..Default::default()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom canvas size
|
||||
pub fn with_size(width: f64, height: f64) -> Self {
|
||||
let hand = Self::new();
|
||||
let mut state = hand.state.blocking_write();
|
||||
state.canvas_width = width;
|
||||
state.canvas_height = height;
|
||||
drop(state);
|
||||
hand
|
||||
}
|
||||
|
||||
/// Execute a whiteboard action
|
||||
pub async fn execute_action(&self, action: WhiteboardAction) -> Result<HandResult> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match &action {
|
||||
WhiteboardAction::Clear => {
|
||||
state.actions.clear();
|
||||
state.undone.clear();
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "cleared",
|
||||
"action_count": 0
|
||||
})));
|
||||
}
|
||||
WhiteboardAction::Undo => {
|
||||
if let Some(last) = state.actions.pop() {
|
||||
state.undone.push(last);
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "undone",
|
||||
"remaining_actions": state.actions.len()
|
||||
})));
|
||||
}
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "no_action_to_undo"
|
||||
})));
|
||||
}
|
||||
WhiteboardAction::Redo => {
|
||||
if let Some(redone) = state.undone.pop() {
|
||||
state.actions.push(redone);
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "redone",
|
||||
"total_actions": state.actions.len()
|
||||
})));
|
||||
}
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "no_action_to_redo"
|
||||
})));
|
||||
}
|
||||
WhiteboardAction::Export { format } => {
|
||||
// In real implementation, would render to image
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "exported",
|
||||
"format": format,
|
||||
"data_url": format!("data:image/{};base64,<rendered_data>", format)
|
||||
})));
|
||||
}
|
||||
_ => {
|
||||
// Regular drawing action
|
||||
state.actions.push(action.clone());
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "drawn",
|
||||
"action": action,
|
||||
"total_actions": state.actions.len()
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub async fn get_state(&self) -> WhiteboardState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get all actions
|
||||
pub async fn get_actions(&self) -> Vec<WhiteboardAction> {
|
||||
self.state.read().await.actions.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WhiteboardHand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for WhiteboardHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
// Parse action from input
|
||||
let action: WhiteboardAction = match serde_json::from_value(input.clone()) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return Ok(HandResult::error(format!("Invalid whiteboard action: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
self.execute_action(action).await
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
// Check if there are any actions
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whiteboard_creation() {
|
||||
let hand = WhiteboardHand::new();
|
||||
assert_eq!(hand.config().id, "whiteboard");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_draw_text() {
|
||||
let hand = WhiteboardHand::new();
|
||||
let action = WhiteboardAction::DrawText {
|
||||
x: 100.0,
|
||||
y: 100.0,
|
||||
text: "Hello World".to_string(),
|
||||
font_size: 24,
|
||||
color: Some("#333333".to_string()),
|
||||
font_family: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let state = hand.get_state().await;
|
||||
assert_eq!(state.actions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_draw_shape() {
|
||||
let hand = WhiteboardHand::new();
|
||||
let action = WhiteboardAction::DrawShape {
|
||||
shape: ShapeType::Rectangle,
|
||||
x: 50.0,
|
||||
y: 50.0,
|
||||
width: 200.0,
|
||||
height: 100.0,
|
||||
fill: Some("#4CAF50".to_string()),
|
||||
stroke: None,
|
||||
stroke_width: 2,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_undo_redo() {
|
||||
let hand = WhiteboardHand::new();
|
||||
|
||||
// Draw something
|
||||
hand.execute_action(WhiteboardAction::DrawText {
|
||||
x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
|
||||
}).await.unwrap();
|
||||
|
||||
// Undo
|
||||
let result = hand.execute_action(WhiteboardAction::Undo).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.actions.len(), 0);
|
||||
|
||||
// Redo
|
||||
let result = hand.execute_action(WhiteboardAction::Redo).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.actions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear() {
|
||||
let hand = WhiteboardHand::new();
|
||||
|
||||
// Draw something
|
||||
hand.execute_action(WhiteboardAction::DrawText {
|
||||
x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
|
||||
}).await.unwrap();
|
||||
|
||||
// Clear
|
||||
let result = hand.execute_action(WhiteboardAction::Clear).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.actions.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chart() {
|
||||
let hand = WhiteboardHand::new();
|
||||
let action = WhiteboardAction::DrawChart {
|
||||
chart_type: ChartType::Bar,
|
||||
data: ChartData {
|
||||
labels: vec!["A".to_string(), "B".to_string(), "C".to_string()],
|
||||
datasets: vec![Dataset {
|
||||
label: "Values".to_string(),
|
||||
values: vec![10.0, 20.0, 15.0],
|
||||
color: Some("#2196F3".to_string()),
|
||||
}],
|
||||
},
|
||||
x: 100.0,
|
||||
y: 100.0,
|
||||
width: 400.0,
|
||||
height: 300.0,
|
||||
title: Some("Test Chart".to_string()),
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,6 @@ description = "ZCLAW kernel - central coordinator for all subsystems"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable multi-agent orchestration (Director, A2A protocol)
|
||||
multi-agent = ["zclaw-protocols/a2a"]
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Default for ApiProtocol {
|
||||
///
|
||||
/// This is the single source of truth for LLM configuration.
|
||||
/// Model ID is passed directly to the API without any transformation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct LlmConfig {
|
||||
/// API base URL (e.g., "https://api.openai.com/v1")
|
||||
pub base_url: String,
|
||||
@@ -61,6 +61,20 @@ pub struct LlmConfig {
|
||||
pub context_window: u32,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LlmConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("LlmConfig")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("api_key", &"***REDACTED***")
|
||||
.field("model", &self.model)
|
||||
.field("api_protocol", &self.api_protocol)
|
||||
.field("max_tokens", &self.max_tokens)
|
||||
.field("temperature", &self.temperature)
|
||||
.field("context_window", &self.context_window)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LlmConfig {
|
||||
/// Create a new LLM config
|
||||
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, model: impl Into<String>) -> Self {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{RwLock, Mutex, mpsc};
|
||||
use tokio::sync::{RwLock, Mutex, mpsc, oneshot};
|
||||
use zclaw_types::{AgentId, Result, ZclawError};
|
||||
use zclaw_protocols::{A2aEnvelope, A2aMessageType, A2aRecipient, A2aRouter, A2aAgentProfile, A2aCapability};
|
||||
use zclaw_runtime::{LlmDriver, CompletionRequest};
|
||||
@@ -199,9 +199,9 @@ pub struct Director {
|
||||
director_id: AgentId,
|
||||
/// Optional LLM driver for intelligent scheduling
|
||||
llm_driver: Option<Arc<dyn LlmDriver>>,
|
||||
/// Inbox for receiving responses (stores pending request IDs and their response channels)
|
||||
pending_requests: Arc<Mutex<std::collections::HashMap<String, mpsc::Sender<A2aEnvelope>>>>,
|
||||
/// Receiver for incoming messages
|
||||
/// Pending request response channels (request_id → oneshot sender)
|
||||
pending_requests: Arc<Mutex<std::collections::HashMap<String, oneshot::Sender<A2aEnvelope>>>>,
|
||||
/// Receiver for incoming messages (consumed by inbox reader task)
|
||||
inbox: Arc<Mutex<Option<mpsc::Receiver<A2aEnvelope>>>>,
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ impl Director {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.expect("system clock is valid")
|
||||
.as_nanos();
|
||||
let idx = (now as usize) % agents.len();
|
||||
Some(agents[idx].clone())
|
||||
@@ -481,13 +481,16 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
||||
}
|
||||
|
||||
/// Send message to selected agent and wait for response
|
||||
///
|
||||
/// Uses oneshot channels to avoid deadlock: each call creates its own
|
||||
/// response channel, and a shared inbox reader dispatches responses.
|
||||
pub async fn send_to_agent(
|
||||
&self,
|
||||
agent: &DirectorAgent,
|
||||
message: String,
|
||||
) -> Result<String> {
|
||||
// Create a response channel for this request
|
||||
let (_response_tx, mut _response_rx) = mpsc::channel::<A2aEnvelope>(1);
|
||||
// Create a oneshot channel for this specific request's response
|
||||
let (response_tx, response_rx) = oneshot::channel::<A2aEnvelope>();
|
||||
|
||||
let envelope = A2aEnvelope::new(
|
||||
self.director_id.clone(),
|
||||
@@ -500,50 +503,32 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
||||
}),
|
||||
);
|
||||
|
||||
// Store the request ID with its response channel
|
||||
// Store the oneshot sender so the inbox reader can dispatch to it
|
||||
let request_id = envelope.id.clone();
|
||||
{
|
||||
let mut pending = self.pending_requests.lock().await;
|
||||
pending.insert(request_id.clone(), _response_tx);
|
||||
pending.insert(request_id.clone(), response_tx);
|
||||
}
|
||||
|
||||
// Send the request
|
||||
self.router.route(envelope).await?;
|
||||
|
||||
// Wait for response with timeout
|
||||
// Ensure the inbox reader is running
|
||||
self.ensure_inbox_reader().await;
|
||||
|
||||
// Wait for response on our dedicated oneshot channel with timeout
|
||||
let timeout_duration = std::time::Duration::from_secs(self.config.response_timeout);
|
||||
let request_id_clone = request_id.clone();
|
||||
|
||||
let response = tokio::time::timeout(timeout_duration, async {
|
||||
// Poll the inbox for responses
|
||||
let mut inbox_guard = self.inbox.lock().await;
|
||||
if let Some(ref mut rx) = *inbox_guard {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
// Check if this is a response to our request
|
||||
if msg.message_type == A2aMessageType::Response {
|
||||
if let Some(ref reply_to) = msg.reply_to {
|
||||
if reply_to == &request_id_clone {
|
||||
// Found our response
|
||||
return Some(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not our response, continue waiting
|
||||
// (In a real implementation, we'd re-queue non-matching messages)
|
||||
}
|
||||
}
|
||||
None
|
||||
}).await;
|
||||
let response = tokio::time::timeout(timeout_duration, response_rx).await;
|
||||
|
||||
// Clean up pending request
|
||||
// Clean up pending request (sender already consumed on success)
|
||||
{
|
||||
let mut pending = self.pending_requests.lock().await;
|
||||
pending.remove(&request_id);
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(Some(envelope)) => {
|
||||
// Extract response text from payload
|
||||
Ok(Ok(envelope)) => {
|
||||
let response_text = envelope.payload
|
||||
.get("response")
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
@@ -551,7 +536,7 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
||||
.to_string();
|
||||
Ok(response_text)
|
||||
}
|
||||
Ok(None) => {
|
||||
Ok(Err(_)) => {
|
||||
Err(ZclawError::Timeout("No response received".into()))
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -563,6 +548,47 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the inbox reader task is running.
|
||||
/// The inbox reader continuously reads from the shared inbox channel
|
||||
/// and dispatches each response to the correct oneshot sender.
|
||||
async fn ensure_inbox_reader(&self) {
|
||||
// Quick check: if inbox has already been taken, reader is running
|
||||
{
|
||||
let inbox = self.inbox.lock().await;
|
||||
if inbox.is_none() {
|
||||
return; // Reader already spawned and consumed the receiver
|
||||
}
|
||||
}
|
||||
|
||||
// Take the receiver out (only once)
|
||||
let rx = {
|
||||
let mut inbox = self.inbox.lock().await;
|
||||
inbox.take()
|
||||
};
|
||||
|
||||
if let Some(mut rx) = rx {
|
||||
let pending = self.pending_requests.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
// Find and dispatch to the correct oneshot sender
|
||||
if msg.message_type == A2aMessageType::Response {
|
||||
if let Some(ref reply_to) = msg.reply_to {
|
||||
let reply_to_clone = reply_to.clone();
|
||||
let mut pending_guard = pending.lock().await;
|
||||
if let Some(sender) = pending_guard.remove(reply_to) {
|
||||
// Send the response; if receiver already dropped, request was cancelled
|
||||
if sender.send(msg).is_err() {
|
||||
tracing::debug!("[Director] Response dropped: receiver cancelled for reply_to={}", reply_to_clone);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-response messages are dropped (notifications, etc.)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Broadcast message to all agents
|
||||
pub async fn broadcast(&self, message: String) -> Result<()> {
|
||||
let envelope = A2aEnvelope::new(
|
||||
@@ -616,7 +642,9 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
|
||||
}
|
||||
|
||||
if let Some(ref user_input) = input {
|
||||
context.push_str(&format!("User: {}\n\n", user_input));
|
||||
context.push_str("<user_input>\n");
|
||||
context.push_str(&format!("{}\n", user_input));
|
||||
context.push_str("</user_input>\n\n");
|
||||
}
|
||||
|
||||
// Add recent history
|
||||
@@ -882,7 +910,9 @@ impl Director {
|
||||
let prompt = format!(
|
||||
r#"你是 ZCLAW 管家。请将以下用户需求拆解为 1-5 个具体子任务。
|
||||
|
||||
用户需求:{}
|
||||
<user_request>
|
||||
{}
|
||||
</user_request>
|
||||
|
||||
请按 JSON 数组格式输出,每个元素包含:
|
||||
- description: 子任务描述(中文)
|
||||
|
||||
@@ -17,8 +17,9 @@ impl EventBus {
|
||||
|
||||
/// Publish an event
|
||||
pub fn publish(&self, event: Event) {
|
||||
// Ignore send errors (no subscribers)
|
||||
let _ = self.sender.send(event);
|
||||
if let Err(e) = self.sender.send(event) {
|
||||
tracing::debug!("Event dropped (no subscribers or channel full): {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to events
|
||||
|
||||
@@ -14,7 +14,7 @@ use zclaw_types::Result;
|
||||
/// HTML exporter
|
||||
pub struct HtmlExporter {
|
||||
/// Template name (reserved for future template support)
|
||||
#[allow(dead_code)] // TODO: Implement template-based HTML export
|
||||
#[allow(dead_code)] // @reserved: post-release template-based HTML export
|
||||
template: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -490,7 +490,7 @@ impl PptxExporter {
|
||||
paths.sort();
|
||||
|
||||
for path in paths {
|
||||
let content = files.get(path).unwrap();
|
||||
let content = files.get(path).expect("path comes from files.keys(), must exist");
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ fn clean_fallback_response(text: &str) -> String {
|
||||
fn current_timestamp_millis() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.expect("system clock is valid")
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
|
||||
@@ -557,7 +557,7 @@ Use Chinese if the topic is in Chinese. Include metaphors that relate to everyda
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: instance-method convenience wrapper for static helper
|
||||
fn extract_text_from_response(&self, response: &CompletionResponse) -> String {
|
||||
Self::extract_text_from_response_static(response)
|
||||
}
|
||||
@@ -882,7 +882,7 @@ fn current_timestamp() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.expect("system clock is valid")
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
//! A2A (Agent-to-Agent) messaging
|
||||
//!
|
||||
//! All items in this module are gated by the `multi-agent` feature flag.
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use zclaw_types::{AgentId, Capability, Event, Result};
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use zclaw_protocols::{A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient};
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use super::Kernel;
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
impl Kernel {
|
||||
// ============================================================
|
||||
// A2A (Agent-to-Agent) Messaging
|
||||
|
||||
@@ -106,13 +106,11 @@ impl SkillExecutor for KernelSkillExecutor {
|
||||
|
||||
/// Inbox wrapper for A2A message receivers that supports re-queuing
|
||||
/// non-matching messages instead of dropping them.
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub(crate) struct AgentInbox {
|
||||
pub(crate) rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>,
|
||||
pub(crate) pending: std::collections::VecDeque<zclaw_protocols::A2aEnvelope>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
impl AgentInbox {
|
||||
pub(crate) fn new(rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>) -> Self {
|
||||
Self { rx, pending: std::collections::VecDeque::new() }
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use tokio::sync::Mutex;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use super::adapters::AgentInbox;
|
||||
|
||||
use super::Kernel;
|
||||
@@ -23,7 +20,6 @@ impl Kernel {
|
||||
self.memory.save_agent(&config).await?;
|
||||
|
||||
// Register with A2A router for multi-agent messaging (before config is moved)
|
||||
#[cfg(feature = "multi-agent")]
|
||||
{
|
||||
let profile = Self::agent_config_to_a2a_profile(&config);
|
||||
let rx = self.a2a_router.register_agent(profile).await;
|
||||
@@ -52,7 +48,6 @@ impl Kernel {
|
||||
self.memory.delete_agent(id).await?;
|
||||
|
||||
// Unregister from A2A router
|
||||
#[cfg(feature = "multi-agent")]
|
||||
{
|
||||
self.a2a_router.unregister_agent(id).await;
|
||||
self.a2a_inboxes.remove(id);
|
||||
|
||||
@@ -85,14 +85,14 @@ impl Kernel {
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
};
|
||||
let _ = memory.save_hand_run(&run).await.map_err(|e| {
|
||||
tracing::warn!("[Approval] Failed to save hand run: {}", e);
|
||||
});
|
||||
if let Err(e) = memory.save_hand_run(&run).await {
|
||||
tracing::error!("[Approval] Failed to save hand run: {}", e);
|
||||
}
|
||||
run.status = HandRunStatus::Running;
|
||||
run.started_at = Some(chrono::Utc::now().to_rfc3339());
|
||||
let _ = memory.update_hand_run(&run).await.map_err(|e| {
|
||||
tracing::warn!("[Approval] Failed to update hand run (running): {}", e);
|
||||
});
|
||||
if let Err(e) = memory.update_hand_run(&run).await {
|
||||
tracing::error!("[Approval] Failed to update hand run (running): {}", e);
|
||||
}
|
||||
|
||||
// Register cancellation flag
|
||||
let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
@@ -121,9 +121,9 @@ impl Kernel {
|
||||
}
|
||||
run.duration_ms = Some(duration.as_millis() as u64);
|
||||
run.completed_at = Some(completed_at);
|
||||
let _ = memory.update_hand_run(&run).await.map_err(|e| {
|
||||
tracing::warn!("[Approval] Failed to update hand run (completed): {}", e);
|
||||
});
|
||||
if let Err(e) = memory.update_hand_run(&run).await {
|
||||
tracing::error!("[Approval] Failed to update hand run (completed): {}", e);
|
||||
}
|
||||
|
||||
// Update approval status based on execution result
|
||||
let mut approvals = approvals.lock().await;
|
||||
|
||||
@@ -83,10 +83,8 @@ impl Kernel {
|
||||
loop_runner = loop_runner.with_path_validator(path_validator);
|
||||
}
|
||||
|
||||
// Inject middleware chain if available
|
||||
if let Some(chain) = self.create_middleware_chain() {
|
||||
loop_runner = loop_runner.with_middleware_chain(chain);
|
||||
}
|
||||
// Inject middleware chain
|
||||
loop_runner = loop_runner.with_middleware_chain(self.create_middleware_chain());
|
||||
|
||||
// Apply chat mode configuration (thinking/reasoning/plan mode)
|
||||
if let Some(ref mode) = chat_mode {
|
||||
@@ -198,10 +196,8 @@ impl Kernel {
|
||||
loop_runner = loop_runner.with_path_validator(path_validator);
|
||||
}
|
||||
|
||||
// Inject middleware chain if available
|
||||
if let Some(chain) = self.create_middleware_chain() {
|
||||
loop_runner = loop_runner.with_middleware_chain(chain);
|
||||
}
|
||||
// Inject middleware chain
|
||||
loop_runner = loop_runner.with_middleware_chain(self.create_middleware_chain());
|
||||
|
||||
// Apply chat mode configuration (thinking/reasoning/plan mode from frontend)
|
||||
if let Some(ref mode) = chat_mode {
|
||||
|
||||
@@ -8,16 +8,13 @@ mod hands;
|
||||
mod triggers;
|
||||
mod approvals;
|
||||
mod orchestration;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
mod a2a;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use zclaw_types::{Event, Result, AgentState};
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use zclaw_types::AgentId;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use zclaw_protocols::A2aRouter;
|
||||
|
||||
use crate::registry::AgentRegistry;
|
||||
@@ -27,7 +24,7 @@ use crate::config::KernelConfig;
|
||||
use zclaw_memory::MemoryStore;
|
||||
use zclaw_runtime::{LlmDriver, ToolRegistry, tool::SkillExecutor};
|
||||
use zclaw_skills::SkillRegistry;
|
||||
use zclaw_hands::{HandRegistry, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, ReminderHand, quiz::LlmQuizGenerator}};
|
||||
use zclaw_hands::{HandRegistry, hands::{BrowserHand, QuizHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, ReminderHand, quiz::LlmQuizGenerator}};
|
||||
|
||||
pub use adapters::KernelSkillExecutor;
|
||||
pub use messaging::ChatModeConfig;
|
||||
@@ -56,11 +53,9 @@ pub struct Kernel {
|
||||
mcp_adapters: Arc<std::sync::RwLock<Vec<zclaw_protocols::McpToolAdapter>>>,
|
||||
/// Dynamic industry keyword configs — shared with Tauri frontend, loaded from SaaS
|
||||
industry_keywords: Arc<tokio::sync::RwLock<Vec<zclaw_runtime::IndustryKeywordConfig>>>,
|
||||
/// A2A router for inter-agent messaging (gated by multi-agent feature)
|
||||
#[cfg(feature = "multi-agent")]
|
||||
/// A2A router for inter-agent messaging
|
||||
a2a_router: Arc<A2aRouter>,
|
||||
/// Per-agent A2A inbox receivers (supports re-queuing non-matching messages)
|
||||
#[cfg(feature = "multi-agent")]
|
||||
a2a_inboxes: Arc<dashmap::DashMap<AgentId, Arc<Mutex<adapters::AgentInbox>>>>,
|
||||
}
|
||||
|
||||
@@ -93,10 +88,7 @@ impl Kernel {
|
||||
let quiz_model = config.model().to_string();
|
||||
let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model));
|
||||
hands.register(Arc::new(BrowserHand::new())).await;
|
||||
hands.register(Arc::new(SlideshowHand::new())).await;
|
||||
hands.register(Arc::new(SpeechHand::new())).await;
|
||||
hands.register(Arc::new(QuizHand::with_generator(quiz_generator))).await;
|
||||
hands.register(Arc::new(WhiteboardHand::new())).await;
|
||||
hands.register(Arc::new(ResearcherHand::new())).await;
|
||||
hands.register(Arc::new(CollectorHand::new())).await;
|
||||
hands.register(Arc::new(ClipHand::new())).await;
|
||||
@@ -138,7 +130,6 @@ impl Kernel {
|
||||
}
|
||||
|
||||
// Initialize A2A router for multi-agent support
|
||||
#[cfg(feature = "multi-agent")]
|
||||
let a2a_router = {
|
||||
let kernel_agent_id = AgentId::new();
|
||||
Arc::new(A2aRouter::new(kernel_agent_id))
|
||||
@@ -162,9 +153,7 @@ impl Kernel {
|
||||
extraction_driver: None,
|
||||
mcp_adapters: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||
industry_keywords: Arc::new(tokio::sync::RwLock::new(Vec::new())),
|
||||
#[cfg(feature = "multi-agent")]
|
||||
a2a_router,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
a2a_inboxes: Arc::new(dashmap::DashMap::new()),
|
||||
})
|
||||
}
|
||||
@@ -204,7 +193,7 @@ impl Kernel {
|
||||
/// When middleware is configured, cross-cutting concerns (compaction, loop guard,
|
||||
/// token calibration, etc.) are delegated to the chain. When no middleware is
|
||||
/// registered, the legacy inline path in `AgentLoop` is used instead.
|
||||
pub(crate) fn create_middleware_chain(&self) -> Option<zclaw_runtime::middleware::MiddlewareChain> {
|
||||
pub(crate) fn create_middleware_chain(&self) -> zclaw_runtime::middleware::MiddlewareChain {
|
||||
let mut chain = zclaw_runtime::middleware::MiddlewareChain::new();
|
||||
|
||||
// Butler router — semantic skill routing context injection
|
||||
@@ -250,6 +239,9 @@ impl Kernel {
|
||||
}
|
||||
|
||||
// Data masking middleware — mask sensitive entities before any other processing
|
||||
// NOTE: Registration order does NOT determine execution order.
|
||||
// The chain sorts by priority() ascending before execution.
|
||||
// Execution order: Evolution(78) → ButlerRouter(80) → DataMasking(90) → ...
|
||||
{
|
||||
use std::sync::Arc;
|
||||
let masker = Arc::new(zclaw_runtime::middleware::data_masking::DataMasker::new());
|
||||
@@ -263,6 +255,13 @@ impl Kernel {
|
||||
growth = growth.with_llm_driver(driver.clone());
|
||||
}
|
||||
|
||||
// Evolution middleware — pushes evolution candidate skills into system prompt
|
||||
// priority=78, executed first by chain (before ButlerRouter@80)
|
||||
let evolution_mw = std::sync::Arc::new(
|
||||
zclaw_runtime::middleware::evolution::EvolutionMiddleware::new()
|
||||
);
|
||||
chain.register(evolution_mw.clone());
|
||||
|
||||
// Compaction middleware — only register when threshold > 0
|
||||
let threshold = self.config.compaction_threshold();
|
||||
if threshold > 0 {
|
||||
@@ -280,10 +279,11 @@ impl Kernel {
|
||||
chain.register(Arc::new(mw));
|
||||
}
|
||||
|
||||
// Memory middleware — auto-extract memories after conversations
|
||||
// Memory middleware — auto-extract memories + check evolution after conversations
|
||||
{
|
||||
use std::sync::Arc;
|
||||
let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth);
|
||||
let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth)
|
||||
.with_evolution(evolution_mw);
|
||||
chain.register(Arc::new(mw));
|
||||
}
|
||||
|
||||
@@ -362,13 +362,11 @@ impl Kernel {
|
||||
chain.register(Arc::new(mw));
|
||||
}
|
||||
|
||||
// Only return Some if we actually registered middleware
|
||||
if chain.is_empty() {
|
||||
None
|
||||
} else {
|
||||
// Always return the chain (empty chain is a no-op)
|
||||
if !chain.is_empty() {
|
||||
tracing::info!("[Kernel] Middleware chain created with {} middlewares", chain.len());
|
||||
Some(chain)
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// Subscribe to events
|
||||
|
||||
@@ -10,7 +10,6 @@ pub mod trigger_manager;
|
||||
pub mod config;
|
||||
pub mod scheduler;
|
||||
pub mod skill_router;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub mod director;
|
||||
pub mod generation;
|
||||
pub mod export;
|
||||
@@ -21,13 +20,11 @@ pub use capabilities::*;
|
||||
pub use events::*;
|
||||
pub use config::*;
|
||||
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub use director::{
|
||||
Director, DirectorConfig, DirectorBuilder, DirectorAgent,
|
||||
ConversationState, ScheduleStrategy,
|
||||
// Note: AgentRole is intentionally NOT re-exported here — use generation::AgentRole instead
|
||||
};
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub use zclaw_protocols::{
|
||||
A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient,
|
||||
A2aReceiver,
|
||||
|
||||
@@ -25,7 +25,6 @@ reqwest = { workspace = true }
|
||||
# Internal crates
|
||||
zclaw-types = { workspace = true }
|
||||
zclaw-runtime = { workspace = true }
|
||||
zclaw-kernel = { workspace = true }
|
||||
zclaw-skills = { workspace = true }
|
||||
zclaw-hands = { workspace = true }
|
||||
|
||||
|
||||
@@ -589,7 +589,7 @@ impl StageEngine {
|
||||
}
|
||||
|
||||
/// Clone with drivers (reserved for future use)
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: post-release stage cloning with drivers
|
||||
fn clone_with_drivers(&self) -> Self {
|
||||
Self {
|
||||
llm_driver: self.llm_driver.clone(),
|
||||
|
||||
@@ -40,6 +40,15 @@ pub enum ExecuteError {
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Maximum completed/failed/cancelled runs to keep in memory
|
||||
const MAX_COMPLETED_RUNS: usize = 100;
|
||||
|
||||
/// Maximum allowed delay in milliseconds (60 seconds)
|
||||
const MAX_DELAY_MS: u64 = 60_000;
|
||||
|
||||
/// Default per-step timeout (5 minutes)
|
||||
const DEFAULT_STEP_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Pipeline executor
|
||||
pub struct PipelineExecutor {
|
||||
/// Action registry
|
||||
@@ -107,35 +116,50 @@ impl PipelineExecutor {
|
||||
// Create execution context
|
||||
let mut context = ExecutionContext::new(inputs);
|
||||
|
||||
// Determine per-step timeout from pipeline spec (0 means use default)
|
||||
let step_timeout = if pipeline.spec.timeout_secs > 0 {
|
||||
pipeline.spec.timeout_secs
|
||||
} else {
|
||||
DEFAULT_STEP_TIMEOUT_SECS
|
||||
};
|
||||
|
||||
// Execute steps
|
||||
let result = self.execute_steps(pipeline, &mut context, &run_id).await;
|
||||
let result = self.execute_steps(pipeline, &mut context, &run_id, step_timeout).await;
|
||||
|
||||
// Update run state
|
||||
let mut runs = self.runs.write().await;
|
||||
if let Some(run) = runs.get_mut(&run_id) {
|
||||
match result {
|
||||
Ok(outputs) => {
|
||||
run.status = RunStatus::Completed;
|
||||
run.outputs = Some(serde_json::to_value(&outputs).unwrap_or(Value::Null));
|
||||
}
|
||||
Err(e) => {
|
||||
run.status = RunStatus::Failed;
|
||||
run.error = Some(e.to_string());
|
||||
let return_value = {
|
||||
let mut runs = self.runs.write().await;
|
||||
if let Some(run) = runs.get_mut(&run_id) {
|
||||
match result {
|
||||
Ok(outputs) => {
|
||||
run.status = RunStatus::Completed;
|
||||
run.outputs = Some(serde_json::to_value(&outputs).unwrap_or(Value::Null));
|
||||
}
|
||||
Err(e) => {
|
||||
run.status = RunStatus::Failed;
|
||||
run.error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
run.ended_at = Some(Utc::now());
|
||||
Ok(run.clone())
|
||||
} else {
|
||||
Err(ExecuteError::Action("执行后未找到运行记录".to_string()))
|
||||
}
|
||||
run.ended_at = Some(Utc::now());
|
||||
return Ok(run.clone());
|
||||
}
|
||||
};
|
||||
|
||||
Err(ExecuteError::Action("执行后未找到运行记录".to_string()))
|
||||
// Auto-cleanup old completed runs (after releasing the write lock)
|
||||
self.cleanup().await;
|
||||
|
||||
return_value
|
||||
}
|
||||
|
||||
/// Execute pipeline steps
|
||||
/// Execute pipeline steps with per-step timeout
|
||||
async fn execute_steps(
|
||||
&self,
|
||||
pipeline: &Pipeline,
|
||||
context: &mut ExecutionContext,
|
||||
run_id: &str,
|
||||
step_timeout_secs: u64,
|
||||
) -> Result<HashMap<String, Value>, ExecuteError> {
|
||||
let total_steps = pipeline.spec.steps.len();
|
||||
|
||||
@@ -161,8 +185,15 @@ impl PipelineExecutor {
|
||||
|
||||
tracing::info!("Executing step {} ({}/{})", step.id, idx + 1, total_steps);
|
||||
|
||||
// Execute action
|
||||
let result = self.execute_action(&step.action, context).await?;
|
||||
// Execute action with per-step timeout
|
||||
let timeout_duration = std::time::Duration::from_secs(step_timeout_secs);
|
||||
let result = tokio::time::timeout(
|
||||
timeout_duration,
|
||||
self.execute_action(&step.action, context),
|
||||
).await.map_err(|_| {
|
||||
tracing::error!("Step {} timed out after {}s", step.id, step_timeout_secs);
|
||||
ExecuteError::Timeout
|
||||
})??;
|
||||
|
||||
// Store result
|
||||
context.set_output(&step.id, result.clone());
|
||||
@@ -336,7 +367,16 @@ impl PipelineExecutor {
|
||||
}
|
||||
|
||||
Action::Delay { ms } => {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(*ms)).await;
|
||||
let capped_ms = if *ms > MAX_DELAY_MS {
|
||||
tracing::warn!(
|
||||
"Delay ms {} exceeds max {}, capping to {}",
|
||||
ms, MAX_DELAY_MS, MAX_DELAY_MS
|
||||
);
|
||||
MAX_DELAY_MS
|
||||
} else {
|
||||
*ms
|
||||
};
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(capped_ms)).await;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -508,6 +548,33 @@ impl PipelineExecutor {
|
||||
pub async fn list_runs(&self) -> Vec<PipelineRun> {
|
||||
self.runs.read().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Clean up old completed/failed/cancelled runs to prevent memory leaks.
|
||||
/// Keeps at most MAX_COMPLETED_RUNS finished runs, evicting the oldest first.
|
||||
pub async fn cleanup(&self) {
|
||||
let mut runs = self.runs.write().await;
|
||||
|
||||
// Collect IDs of finished runs (completed, failed, cancelled)
|
||||
let mut finished: Vec<(String, chrono::DateTime<Utc>)> = runs
|
||||
.iter()
|
||||
.filter(|(_, r)| matches!(r.status, RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled))
|
||||
.map(|(id, r)| (id.clone(), r.ended_at.unwrap_or(r.started_at)))
|
||||
.collect();
|
||||
|
||||
let to_remove = finished.len().saturating_sub(MAX_COMPLETED_RUNS);
|
||||
if to_remove > 0 {
|
||||
// Sort by end time ascending (oldest first)
|
||||
finished.sort_by_key(|(_, t)| *t);
|
||||
for (id, _) in finished.into_iter().take(to_remove) {
|
||||
runs.remove(&id);
|
||||
// Also clean up cancellation flag
|
||||
drop(runs);
|
||||
self.cancellations.write().await.remove(&id);
|
||||
runs = self.runs.write().await;
|
||||
}
|
||||
tracing::debug!("Cleaned up {} old pipeline runs", to_remove);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -48,7 +48,7 @@ impl ExecutionContext {
|
||||
steps_output: HashMap::new(),
|
||||
variables: HashMap::new(),
|
||||
loop_context: None,
|
||||
expr_regex: Regex::new(r"\$\{([^}]+)\}").unwrap(),
|
||||
expr_regex: Regex::new(r"\$\{([^}]+)\}").expect("static regex is valid"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ impl ExecutionContext {
|
||||
steps_output,
|
||||
variables,
|
||||
loop_context: None,
|
||||
expr_regex: Regex::new(r"\$\{([^}]+)\}").unwrap(),
|
||||
expr_regex: Regex::new(r"\$\{([^}]+)\}").expect("static regex is valid"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
//! ZCLAW Protocols
|
||||
//!
|
||||
//! Protocol support for MCP (Model Context Protocol) and A2A (Agent-to-Agent).
|
||||
//!
|
||||
//! A2A is gated behind the `a2a` feature flag (reserved for future multi-agent scenarios).
|
||||
//! MCP is always available as a framework for tool integration.
|
||||
|
||||
mod mcp;
|
||||
mod mcp_types;
|
||||
mod mcp_tool_adapter;
|
||||
mod mcp_transport;
|
||||
#[cfg(feature = "a2a")]
|
||||
mod a2a;
|
||||
|
||||
pub use mcp::*;
|
||||
pub use mcp_types::*;
|
||||
pub use mcp_tool_adapter::*;
|
||||
pub use mcp_transport::*;
|
||||
#[cfg(feature = "a2a")]
|
||||
pub use a2a::*;
|
||||
|
||||
@@ -130,7 +130,7 @@ impl McpToolAdapter {
|
||||
|
||||
match result.len() {
|
||||
0 => Ok(Value::Null),
|
||||
1 => Ok(result.into_iter().next().unwrap()),
|
||||
1 => Ok(result.into_iter().next().unwrap_or(Value::Null)),
|
||||
_ => Ok(Value::Array(result)),
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ impl McpServiceManager {
|
||||
let adapters = McpToolAdapter::from_server(name.clone(), client.clone()).await?;
|
||||
self.clients.insert(name.clone(), client);
|
||||
self.adapters.insert(name.clone(), adapters);
|
||||
Ok(self.adapters.get(&name).unwrap().iter().collect())
|
||||
Ok(self.adapters.get(&name).map(|v| v.iter().collect()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Get all registered tool adapters from all services
|
||||
|
||||
@@ -84,12 +84,20 @@ impl McpServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Combined transport handles (stdin + stdout) behind a single Mutex.
|
||||
/// This ensures write-then-read is atomic, preventing concurrent requests
|
||||
/// from receiving each other's responses.
|
||||
struct TransportHandles {
|
||||
stdin: BufWriter<ChildStdin>,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
}
|
||||
|
||||
/// MCP Transport using stdio
|
||||
pub struct McpTransport {
|
||||
config: McpServerConfig,
|
||||
child: Arc<Mutex<Option<Child>>>,
|
||||
stdin: Arc<Mutex<Option<BufWriter<ChildStdin>>>>,
|
||||
stdout: Arc<Mutex<Option<BufReader<ChildStdout>>>>,
|
||||
/// Single Mutex protecting both stdin and stdout for atomic write-then-read
|
||||
handles: Arc<Mutex<Option<TransportHandles>>>,
|
||||
capabilities: Arc<Mutex<Option<ServerCapabilities>>>,
|
||||
}
|
||||
|
||||
@@ -99,8 +107,7 @@ impl McpTransport {
|
||||
Self {
|
||||
config,
|
||||
child: Arc::new(Mutex::new(None)),
|
||||
stdin: Arc::new(Mutex::new(None)),
|
||||
stdout: Arc::new(Mutex::new(None)),
|
||||
handles: Arc::new(Mutex::new(None)),
|
||||
capabilities: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
@@ -162,9 +169,11 @@ impl McpTransport {
|
||||
});
|
||||
}
|
||||
|
||||
// Store handles in separate mutexes
|
||||
*self.stdin.lock().await = Some(BufWriter::new(stdin));
|
||||
*self.stdout.lock().await = Some(BufReader::new(stdout));
|
||||
// Store handles in single mutex for atomic write-then-read
|
||||
*self.handles.lock().await = Some(TransportHandles {
|
||||
stdin: BufWriter::new(stdin),
|
||||
stdout: BufReader::new(stdout),
|
||||
});
|
||||
*child_guard = Some(child);
|
||||
|
||||
Ok(())
|
||||
@@ -201,21 +210,21 @@ impl McpTransport {
|
||||
let line = serde_json::to_string(notification)
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to serialize notification: {}", e)))?;
|
||||
|
||||
let mut stdin_guard = self.stdin.lock().await;
|
||||
let stdin = stdin_guard.as_mut()
|
||||
let mut handles_guard = self.handles.lock().await;
|
||||
let handles = handles_guard.as_mut()
|
||||
.ok_or_else(|| ZclawError::McpError("Transport not started".to_string()))?;
|
||||
|
||||
stdin.write_all(line.as_bytes())
|
||||
handles.stdin.write_all(line.as_bytes())
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to write notification: {}", e)))?;
|
||||
stdin.write_all(b"\n")
|
||||
handles.stdin.write_all(b"\n")
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to write newline: {}", e)))?;
|
||||
stdin.flush()
|
||||
handles.stdin.flush()
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to flush notification: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send JSON-RPC request
|
||||
/// Send JSON-RPC request (atomic write-then-read under single lock)
|
||||
async fn send_request<T: DeserializeOwned>(
|
||||
&self,
|
||||
method: &str,
|
||||
@@ -234,28 +243,23 @@ impl McpTransport {
|
||||
let line = serde_json::to_string(&request)
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to serialize request: {}", e)))?;
|
||||
|
||||
// Write to stdin
|
||||
{
|
||||
let mut stdin_guard = self.stdin.lock().await;
|
||||
let stdin = stdin_guard.as_mut()
|
||||
.ok_or_else(|| ZclawError::McpError("Transport not started".to_string()))?;
|
||||
|
||||
stdin.write_all(line.as_bytes())
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to write request: {}", e)))?;
|
||||
stdin.write_all(b"\n")
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to write newline: {}", e)))?;
|
||||
stdin.flush()
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to flush request: {}", e)))?;
|
||||
}
|
||||
|
||||
// Read from stdout
|
||||
// Atomic write-then-read under single lock
|
||||
let response_line = {
|
||||
let mut stdout_guard = self.stdout.lock().await;
|
||||
let stdout = stdout_guard.as_mut()
|
||||
let mut handles_guard = self.handles.lock().await;
|
||||
let handles = handles_guard.as_mut()
|
||||
.ok_or_else(|| ZclawError::McpError("Transport not started".to_string()))?;
|
||||
|
||||
// Write to stdin
|
||||
handles.stdin.write_all(line.as_bytes())
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to write request: {}", e)))?;
|
||||
handles.stdin.write_all(b"\n")
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to write newline: {}", e)))?;
|
||||
handles.stdin.flush()
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to flush request: {}", e)))?;
|
||||
|
||||
// Read from stdout (still holding the lock — no interleaving possible)
|
||||
let mut response_line = String::new();
|
||||
stdout.read_line(&mut response_line)
|
||||
handles.stdout.read_line(&mut response_line)
|
||||
.map_err(|e| ZclawError::McpError(format!("Failed to read response: {}", e)))?;
|
||||
response_line
|
||||
};
|
||||
@@ -429,7 +433,7 @@ impl Drop for McpTransport {
|
||||
let _ = child.wait();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[McpTransport] Failed to kill child process: {}", e);
|
||||
tracing::warn!("[McpTransport] Failed to kill child process (potential zombie): {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,7 +616,7 @@ struct GeminiResponseContent {
|
||||
#[serde(default)]
|
||||
parts: Vec<GeminiResponsePart>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: deserialized from Gemini API, not accessed in code
|
||||
role: Option<String>,
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@ struct GeminiUsageMetadata {
|
||||
#[serde(default)]
|
||||
candidates_token_count: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: deserialized from Gemini API, not accessed in code
|
||||
total_token_count: Option<u32>,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
use zclaw_growth::{
|
||||
GrowthTracker, InjectionFormat, LlmDriverForExtraction,
|
||||
MemoryExtractor, MemoryRetriever, PromptInjector, RetrievalResult,
|
||||
VikingAdapter,
|
||||
AggregatedPattern, CombinedExtraction, EvolutionConfig, EvolutionEngine,
|
||||
ExperienceExtractor, ExperienceStore, GrowthTracker, InjectionFormat,
|
||||
LlmDriverForExtraction, MemoryExtractor, MemoryRetriever, PromptInjector,
|
||||
RetrievalResult, UserProfileUpdater, VikingAdapter,
|
||||
};
|
||||
use zclaw_memory::{ExtractedFactBatch, Fact, FactCategory};
|
||||
use zclaw_memory::{ExtractedFactBatch, Fact, FactCategory, UserProfileStore};
|
||||
use zclaw_types::{AgentId, Message, Result, SessionId};
|
||||
|
||||
/// Growth system integration for AgentLoop
|
||||
@@ -32,6 +33,14 @@ pub struct GrowthIntegration {
|
||||
injector: PromptInjector,
|
||||
/// Growth tracker for tracking growth metrics
|
||||
tracker: GrowthTracker,
|
||||
/// Experience extractor for structured experience persistence
|
||||
experience_extractor: ExperienceExtractor,
|
||||
/// Profile updater for incremental user profile updates
|
||||
profile_updater: UserProfileUpdater,
|
||||
/// User profile store (optional, for profile updates)
|
||||
profile_store: Option<Arc<UserProfileStore>>,
|
||||
/// Evolution engine for L2 skill generation (optional)
|
||||
evolution_engine: Option<EvolutionEngine>,
|
||||
/// Configuration
|
||||
config: GrowthConfigInner,
|
||||
}
|
||||
@@ -69,13 +78,19 @@ impl GrowthIntegration {
|
||||
|
||||
let retriever = MemoryRetriever::new(viking.clone());
|
||||
let injector = PromptInjector::new();
|
||||
let tracker = GrowthTracker::new(viking);
|
||||
let tracker = GrowthTracker::new(viking.clone());
|
||||
let evolution_engine = Some(EvolutionEngine::new(viking.clone()));
|
||||
|
||||
Self {
|
||||
retriever,
|
||||
extractor,
|
||||
injector,
|
||||
tracker,
|
||||
experience_extractor: ExperienceExtractor::new()
|
||||
.with_store(Arc::new(ExperienceStore::new(viking))),
|
||||
profile_updater: UserProfileUpdater::new(),
|
||||
profile_store: None,
|
||||
evolution_engine,
|
||||
config: GrowthConfigInner::default(),
|
||||
}
|
||||
}
|
||||
@@ -102,11 +117,73 @@ impl GrowthIntegration {
|
||||
self.config.enabled
|
||||
}
|
||||
|
||||
/// 启动时初始化:从持久化存储恢复进化引擎的信任度记录
|
||||
///
|
||||
/// **注意**:FeedbackCollector 内部已实现 lazy-load(首次 save() 时自动加载),
|
||||
/// 所以此方法为可选优化 — 提前加载可避免首次反馈提交时的延迟。
|
||||
pub async fn initialize(&self) -> Result<()> {
|
||||
if let Some(ref engine) = self.evolution_engine {
|
||||
match engine.load_feedback().await {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!(
|
||||
"[GrowthIntegration] Loaded {} trust records from storage",
|
||||
count
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"[GrowthIntegration] Failed to load trust records: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable or disable auto extraction
|
||||
pub fn set_auto_extract(&mut self, auto_extract: bool) {
|
||||
self.config.auto_extract = auto_extract;
|
||||
}
|
||||
|
||||
/// Set the user profile store for incremental profile updates
|
||||
pub fn with_profile_store(mut self, store: Arc<UserProfileStore>) -> Self {
|
||||
self.profile_store = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the evolution engine configuration
|
||||
pub fn with_evolution_config(self, config: EvolutionConfig) -> Self {
|
||||
let engine = self.evolution_engine.unwrap_or_else(|| {
|
||||
EvolutionEngine::new(Arc::new(VikingAdapter::in_memory()))
|
||||
});
|
||||
Self {
|
||||
evolution_engine: Some(engine.with_config(config)),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable or disable the evolution engine
|
||||
pub fn set_evolution_enabled(&mut self, enabled: bool) {
|
||||
if let Some(ref mut engine) = self.evolution_engine {
|
||||
engine.set_enabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// L2 检查:是否有可进化的模式
|
||||
/// 在 extract_combined 之后调用,返回可固化的经验模式列表
|
||||
pub async fn check_evolution(
|
||||
&self,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<Vec<AggregatedPattern>> {
|
||||
match &self.evolution_engine {
|
||||
Some(engine) => engine.check_evolvable_patterns(&agent_id.to_string()).await,
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhance system prompt with retrieved memories
|
||||
///
|
||||
/// This method:
|
||||
@@ -213,8 +290,8 @@ impl GrowthIntegration {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Combined extraction: single LLM call that produces both stored memories
|
||||
/// and structured facts, avoiding double extraction overhead.
|
||||
/// Combined extraction: single LLM call that produces stored memories,
|
||||
/// structured experiences, and profile signals — all in one pass.
|
||||
///
|
||||
/// Returns `(memory_count, Option<ExtractedFactBatch>)` on success.
|
||||
pub async fn extract_combined(
|
||||
@@ -227,25 +304,28 @@ impl GrowthIntegration {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Single LLM extraction call
|
||||
let extracted = self
|
||||
// 单次 LLM 提取:memories + experiences + profile_signals
|
||||
let combined = self
|
||||
.extractor
|
||||
.extract(messages, session_id.clone())
|
||||
.extract_combined(messages, session_id.clone())
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("[GrowthIntegration] Combined extraction failed: {}", e);
|
||||
Vec::new()
|
||||
CombinedExtraction::default()
|
||||
});
|
||||
|
||||
if extracted.is_empty() {
|
||||
if combined.memories.is_empty()
|
||||
&& combined.experiences.is_empty()
|
||||
&& !combined.profile_signals.has_any_signal()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mem_count = extracted.len();
|
||||
let mem_count = combined.memories.len();
|
||||
|
||||
// Store raw memories
|
||||
self.extractor
|
||||
.store_memories(&agent_id.to_string(), &extracted)
|
||||
.store_memories(&agent_id.to_string(), &combined.memories)
|
||||
.await?;
|
||||
|
||||
// Track learning event
|
||||
@@ -253,8 +333,71 @@ impl GrowthIntegration {
|
||||
.record_learning(agent_id, &session_id.to_string(), mem_count)
|
||||
.await?;
|
||||
|
||||
// Convert same extracted memories to structured facts (no extra LLM call)
|
||||
let facts: Vec<Fact> = extracted
|
||||
// Persist structured experiences (L1 enhancement)
|
||||
if let Ok(exp_count) = self
|
||||
.experience_extractor
|
||||
.persist_experiences(&agent_id.to_string(), &combined)
|
||||
.await
|
||||
{
|
||||
if exp_count > 0 {
|
||||
tracing::debug!(
|
||||
"[GrowthIntegration] Persisted {} structured experiences",
|
||||
exp_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user profile from extraction signals (L1 enhancement)
|
||||
if let Some(profile_store) = &self.profile_store {
|
||||
let updates = self.profile_updater.collect_updates(&combined);
|
||||
let user_id = agent_id.to_string();
|
||||
for update in updates {
|
||||
let result = match update.kind {
|
||||
zclaw_growth::ProfileUpdateKind::SetField => {
|
||||
profile_store
|
||||
.update_field(&user_id, &update.field, &update.value)
|
||||
.await
|
||||
}
|
||||
zclaw_growth::ProfileUpdateKind::AppendArray => {
|
||||
match update.field.as_str() {
|
||||
"recent_topic" => {
|
||||
profile_store
|
||||
.add_recent_topic(&user_id, &update.value, 10)
|
||||
.await
|
||||
}
|
||||
"pain_point" => {
|
||||
profile_store
|
||||
.add_pain_point(&user_id, &update.value, 10)
|
||||
.await
|
||||
}
|
||||
"preferred_tool" => {
|
||||
profile_store
|
||||
.add_preferred_tool(&user_id, &update.value, 10)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"[GrowthIntegration] Unknown array field: {}",
|
||||
update.field
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
tracing::warn!(
|
||||
"[GrowthIntegration] Profile update failed for {}: {}",
|
||||
update.field,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert extracted memories to structured facts
|
||||
let facts: Vec<Fact> = combined
|
||||
.memories
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let category = match m.memory_type {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Agent loop implementation
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use futures::StreamExt;
|
||||
use tokio::sync::mpsc;
|
||||
use zclaw_types::{AgentId, SessionId, Message, Result};
|
||||
@@ -10,7 +9,6 @@ use crate::driver::{LlmDriver, CompletionRequest, ContentBlock};
|
||||
use crate::stream::StreamChunk;
|
||||
use crate::tool::{ToolRegistry, ToolContext, SkillExecutor};
|
||||
use crate::tool::builtin::PathValidator;
|
||||
use crate::loop_guard::{LoopGuard, LoopGuardResult};
|
||||
use crate::growth::GrowthIntegration;
|
||||
use crate::compaction::{self, CompactionConfig};
|
||||
use crate::middleware::{self, MiddlewareChain};
|
||||
@@ -23,7 +21,6 @@ pub struct AgentLoop {
|
||||
driver: Arc<dyn LlmDriver>,
|
||||
tools: ToolRegistry,
|
||||
memory: Arc<MemoryStore>,
|
||||
loop_guard: Mutex<LoopGuard>,
|
||||
model: String,
|
||||
system_prompt: Option<String>,
|
||||
/// Custom agent personality for prompt assembly
|
||||
@@ -38,10 +35,9 @@ pub struct AgentLoop {
|
||||
compaction_threshold: usize,
|
||||
/// Compaction behavior configuration
|
||||
compaction_config: CompactionConfig,
|
||||
/// Optional middleware chain — when `Some`, cross-cutting logic is
|
||||
/// delegated to the chain instead of the inline code below.
|
||||
/// When `None`, the legacy inline path is used (100% backward compatible).
|
||||
middleware_chain: Option<MiddlewareChain>,
|
||||
/// Middleware chain — cross-cutting concerns are delegated to the chain.
|
||||
/// An empty chain (Default) is a no-op: all `run_*` methods return Continue/Allow.
|
||||
middleware_chain: MiddlewareChain,
|
||||
/// Chat mode: extended thinking enabled
|
||||
thinking_enabled: bool,
|
||||
/// Chat mode: reasoning effort level
|
||||
@@ -62,7 +58,6 @@ impl AgentLoop {
|
||||
driver,
|
||||
tools,
|
||||
memory,
|
||||
loop_guard: Mutex::new(LoopGuard::default()),
|
||||
model: String::new(), // Must be set via with_model()
|
||||
system_prompt: None,
|
||||
soul: None,
|
||||
@@ -73,7 +68,7 @@ impl AgentLoop {
|
||||
growth: None,
|
||||
compaction_threshold: 0,
|
||||
compaction_config: CompactionConfig::default(),
|
||||
middleware_chain: None,
|
||||
middleware_chain: MiddlewareChain::default(),
|
||||
thinking_enabled: false,
|
||||
reasoning_effort: None,
|
||||
plan_mode: false,
|
||||
@@ -167,11 +162,10 @@ impl AgentLoop {
|
||||
self
|
||||
}
|
||||
|
||||
/// Inject a middleware chain. When set, cross-cutting concerns (compaction,
|
||||
/// loop guard, token calibration, etc.) are delegated to the chain instead
|
||||
/// of the inline logic.
|
||||
/// Inject a middleware chain. Cross-cutting concerns (compaction,
|
||||
/// loop guard, token calibration, etc.) are delegated to the chain.
|
||||
pub fn with_middleware_chain(mut self, chain: MiddlewareChain) -> Self {
|
||||
self.middleware_chain = Some(chain);
|
||||
self.middleware_chain = chain;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -227,49 +221,19 @@ impl AgentLoop {
|
||||
// Get all messages for context
|
||||
let mut messages = self.memory.get_messages(&session_id).await?;
|
||||
|
||||
let use_middleware = self.middleware_chain.is_some();
|
||||
|
||||
// Apply compaction — skip inline path when middleware chain handles it
|
||||
if !use_middleware && self.compaction_threshold > 0 {
|
||||
let needs_async =
|
||||
self.compaction_config.use_llm || self.compaction_config.memory_flush_enabled;
|
||||
if needs_async {
|
||||
let outcome = compaction::maybe_compact_with_config(
|
||||
messages,
|
||||
self.compaction_threshold,
|
||||
&self.compaction_config,
|
||||
&self.agent_id,
|
||||
&session_id,
|
||||
Some(&self.driver),
|
||||
self.growth.as_ref(),
|
||||
)
|
||||
.await;
|
||||
messages = outcome.messages;
|
||||
} else {
|
||||
messages = compaction::maybe_compact(messages, self.compaction_threshold);
|
||||
}
|
||||
}
|
||||
|
||||
// Enhance system prompt — skip when middleware chain handles it
|
||||
let mut enhanced_prompt = if use_middleware {
|
||||
let prompt_ctx = PromptContext {
|
||||
base_prompt: self.system_prompt.clone(),
|
||||
soul: self.soul.clone(),
|
||||
thinking_enabled: self.thinking_enabled,
|
||||
plan_mode: self.plan_mode,
|
||||
tool_definitions: self.tools.definitions(),
|
||||
agent_name: None,
|
||||
};
|
||||
PromptBuilder::new().build(&prompt_ctx)
|
||||
} else if let Some(ref growth) = self.growth {
|
||||
let base = self.system_prompt.as_deref().unwrap_or("");
|
||||
growth.enhance_prompt(&self.agent_id, base, &input).await?
|
||||
} else {
|
||||
self.system_prompt.clone().unwrap_or_default()
|
||||
// Enhance system prompt via PromptBuilder (middleware may further modify)
|
||||
let prompt_ctx = PromptContext {
|
||||
base_prompt: self.system_prompt.clone(),
|
||||
soul: self.soul.clone(),
|
||||
thinking_enabled: self.thinking_enabled,
|
||||
plan_mode: self.plan_mode,
|
||||
tool_definitions: self.tools.definitions(),
|
||||
agent_name: None,
|
||||
};
|
||||
let mut enhanced_prompt = PromptBuilder::new().build(&prompt_ctx);
|
||||
|
||||
// Run middleware before_completion hooks (compaction, memory inject, etc.)
|
||||
if let Some(ref chain) = self.middleware_chain {
|
||||
{
|
||||
let mut mw_ctx = middleware::MiddlewareContext {
|
||||
agent_id: self.agent_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
@@ -280,7 +244,7 @@ impl AgentLoop {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
};
|
||||
match chain.run_before_completion(&mut mw_ctx).await? {
|
||||
match self.middleware_chain.run_before_completion(&mut mw_ctx).await? {
|
||||
middleware::MiddlewareDecision::Continue => {
|
||||
messages = mw_ctx.messages;
|
||||
enhanced_prompt = mw_ctx.system_prompt;
|
||||
@@ -400,7 +364,6 @@ impl AgentLoop {
|
||||
|
||||
// Create tool context and execute all tools
|
||||
let tool_context = self.create_tool_context(session_id.clone());
|
||||
let mut circuit_breaker_triggered = false;
|
||||
let mut abort_result: Option<AgentLoopResult> = None;
|
||||
let mut clarification_result: Option<AgentLoopResult> = None;
|
||||
for (id, name, input) in tool_calls {
|
||||
@@ -408,8 +371,8 @@ impl AgentLoop {
|
||||
if abort_result.is_some() {
|
||||
break;
|
||||
}
|
||||
// Check tool call safety — via middleware chain or inline loop guard
|
||||
if let Some(ref chain) = self.middleware_chain {
|
||||
// Check tool call safety — via middleware chain
|
||||
{
|
||||
let mw_ctx_ref = middleware::MiddlewareContext {
|
||||
agent_id: self.agent_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
@@ -420,7 +383,7 @@ impl AgentLoop {
|
||||
input_tokens: total_input_tokens,
|
||||
output_tokens: total_output_tokens,
|
||||
};
|
||||
match chain.run_before_tool_call(&mw_ctx_ref, &name, &input).await? {
|
||||
match self.middleware_chain.run_before_tool_call(&mw_ctx_ref, &name, &input).await? {
|
||||
middleware::ToolCallDecision::Allow => {}
|
||||
middleware::ToolCallDecision::Block(msg) => {
|
||||
tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg);
|
||||
@@ -456,26 +419,6 @@ impl AgentLoop {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy inline path
|
||||
let guard_result = self.loop_guard.lock().unwrap_or_else(|e| e.into_inner()).check(&name, &input);
|
||||
match guard_result {
|
||||
LoopGuardResult::CircuitBreaker => {
|
||||
tracing::warn!("[AgentLoop] Circuit breaker triggered by tool '{}'", name);
|
||||
circuit_breaker_triggered = true;
|
||||
break;
|
||||
}
|
||||
LoopGuardResult::Blocked => {
|
||||
tracing::warn!("[AgentLoop] Tool '{}' blocked by loop guard", name);
|
||||
let error_output = serde_json::json!({ "error": "工具调用被循环防护拦截" });
|
||||
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true));
|
||||
continue;
|
||||
}
|
||||
LoopGuardResult::Warn => {
|
||||
tracing::warn!("[AgentLoop] Tool '{}' triggered loop guard warning", name);
|
||||
}
|
||||
LoopGuardResult::Allowed => {}
|
||||
}
|
||||
}
|
||||
|
||||
let tool_result = match tokio::time::timeout(
|
||||
@@ -537,21 +480,10 @@ impl AgentLoop {
|
||||
break result;
|
||||
}
|
||||
|
||||
// If circuit breaker was triggered, terminate immediately
|
||||
if circuit_breaker_triggered {
|
||||
let msg = "检测到工具调用循环,已自动终止";
|
||||
self.memory.append_message(&session_id, &Message::assistant(msg)).await?;
|
||||
break AgentLoopResult {
|
||||
response: msg.to_string(),
|
||||
input_tokens: total_input_tokens,
|
||||
output_tokens: total_output_tokens,
|
||||
iterations,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Post-completion processing — middleware chain or inline growth
|
||||
if let Some(ref chain) = self.middleware_chain {
|
||||
// Post-completion processing — middleware chain
|
||||
{
|
||||
let mw_ctx = middleware::MiddlewareContext {
|
||||
agent_id: self.agent_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
@@ -562,16 +494,9 @@ impl AgentLoop {
|
||||
input_tokens: total_input_tokens,
|
||||
output_tokens: total_output_tokens,
|
||||
};
|
||||
if let Err(e) = chain.run_after_completion(&mw_ctx).await {
|
||||
if let Err(e) = self.middleware_chain.run_after_completion(&mw_ctx).await {
|
||||
tracing::warn!("[AgentLoop] Middleware after_completion failed: {}", e);
|
||||
}
|
||||
} else if let Some(ref growth) = self.growth {
|
||||
// Legacy inline path
|
||||
if let Ok(all_messages) = self.memory.get_messages(&session_id).await {
|
||||
if let Err(e) = growth.process_conversation(&self.agent_id, &all_messages, session_id.clone()).await {
|
||||
tracing::warn!("[AgentLoop] Growth processing failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
@@ -593,49 +518,19 @@ impl AgentLoop {
|
||||
// Get all messages for context
|
||||
let mut messages = self.memory.get_messages(&session_id).await?;
|
||||
|
||||
let use_middleware = self.middleware_chain.is_some();
|
||||
|
||||
// Apply compaction — skip inline path when middleware chain handles it
|
||||
if !use_middleware && self.compaction_threshold > 0 {
|
||||
let needs_async =
|
||||
self.compaction_config.use_llm || self.compaction_config.memory_flush_enabled;
|
||||
if needs_async {
|
||||
let outcome = compaction::maybe_compact_with_config(
|
||||
messages,
|
||||
self.compaction_threshold,
|
||||
&self.compaction_config,
|
||||
&self.agent_id,
|
||||
&session_id,
|
||||
Some(&self.driver),
|
||||
self.growth.as_ref(),
|
||||
)
|
||||
.await;
|
||||
messages = outcome.messages;
|
||||
} else {
|
||||
messages = compaction::maybe_compact(messages, self.compaction_threshold);
|
||||
}
|
||||
}
|
||||
|
||||
// Enhance system prompt — skip when middleware chain handles it
|
||||
let mut enhanced_prompt = if use_middleware {
|
||||
let prompt_ctx = PromptContext {
|
||||
base_prompt: self.system_prompt.clone(),
|
||||
soul: self.soul.clone(),
|
||||
thinking_enabled: self.thinking_enabled,
|
||||
plan_mode: self.plan_mode,
|
||||
tool_definitions: self.tools.definitions(),
|
||||
agent_name: None,
|
||||
};
|
||||
PromptBuilder::new().build(&prompt_ctx)
|
||||
} else if let Some(ref growth) = self.growth {
|
||||
let base = self.system_prompt.as_deref().unwrap_or("");
|
||||
growth.enhance_prompt(&self.agent_id, base, &input).await?
|
||||
} else {
|
||||
self.system_prompt.clone().unwrap_or_default()
|
||||
// Enhance system prompt via PromptBuilder (middleware may further modify)
|
||||
let prompt_ctx = PromptContext {
|
||||
base_prompt: self.system_prompt.clone(),
|
||||
soul: self.soul.clone(),
|
||||
thinking_enabled: self.thinking_enabled,
|
||||
plan_mode: self.plan_mode,
|
||||
tool_definitions: self.tools.definitions(),
|
||||
agent_name: None,
|
||||
};
|
||||
let mut enhanced_prompt = PromptBuilder::new().build(&prompt_ctx);
|
||||
|
||||
// Run middleware before_completion hooks (compaction, memory inject, etc.)
|
||||
if let Some(ref chain) = self.middleware_chain {
|
||||
{
|
||||
let mut mw_ctx = middleware::MiddlewareContext {
|
||||
agent_id: self.agent_id.clone(),
|
||||
session_id: session_id.clone(),
|
||||
@@ -646,18 +541,20 @@ impl AgentLoop {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
};
|
||||
match chain.run_before_completion(&mut mw_ctx).await? {
|
||||
match self.middleware_chain.run_before_completion(&mut mw_ctx).await? {
|
||||
middleware::MiddlewareDecision::Continue => {
|
||||
messages = mw_ctx.messages;
|
||||
enhanced_prompt = mw_ctx.system_prompt;
|
||||
}
|
||||
middleware::MiddlewareDecision::Stop(reason) => {
|
||||
let _ = tx.send(LoopEvent::Complete(AgentLoopResult {
|
||||
if let Err(e) = tx.send(LoopEvent::Complete(AgentLoopResult {
|
||||
response: reason,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: 1,
|
||||
})).await;
|
||||
})).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Complete event: {}", e);
|
||||
}
|
||||
return Ok(rx);
|
||||
}
|
||||
}
|
||||
@@ -668,7 +565,6 @@ impl AgentLoop {
|
||||
let memory = self.memory.clone();
|
||||
let driver = self.driver.clone();
|
||||
let tools = self.tools.clone();
|
||||
let loop_guard_clone = self.loop_guard.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let middleware_chain = self.middleware_chain.clone();
|
||||
let skill_executor = self.skill_executor.clone();
|
||||
let path_validator = self.path_validator.clone();
|
||||
@@ -682,7 +578,6 @@ impl AgentLoop {
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut messages = messages;
|
||||
let loop_guard_clone = Mutex::new(loop_guard_clone);
|
||||
let max_iterations = 10;
|
||||
let mut iteration = 0;
|
||||
let mut total_input_tokens = 0u32;
|
||||
@@ -691,15 +586,19 @@ impl AgentLoop {
|
||||
'outer: loop {
|
||||
iteration += 1;
|
||||
if iteration > max_iterations {
|
||||
let _ = tx.send(LoopEvent::Error("达到最大迭代次数".to_string())).await;
|
||||
if let Err(e) = tx.send(LoopEvent::Error("达到最大迭代次数".to_string())).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Notify iteration start
|
||||
let _ = tx.send(LoopEvent::IterationStart {
|
||||
if let Err(e) = tx.send(LoopEvent::IterationStart {
|
||||
iteration,
|
||||
max_iterations,
|
||||
}).await;
|
||||
}).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send IterationStart event: {}", e);
|
||||
}
|
||||
|
||||
// Build completion request
|
||||
let request = CompletionRequest {
|
||||
@@ -742,13 +641,17 @@ impl AgentLoop {
|
||||
text_delta_count += 1;
|
||||
tracing::debug!("[AgentLoop] TextDelta #{}: {} chars", text_delta_count, delta.len());
|
||||
iteration_text.push_str(delta);
|
||||
let _ = tx.send(LoopEvent::Delta(delta.clone())).await;
|
||||
if let Err(e) = tx.send(LoopEvent::Delta(delta.clone())).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Delta event: {}", e);
|
||||
}
|
||||
}
|
||||
StreamChunk::ThinkingDelta { delta } => {
|
||||
thinking_delta_count += 1;
|
||||
tracing::debug!("[AgentLoop] ThinkingDelta #{}: {} chars", thinking_delta_count, delta.len());
|
||||
reasoning_text.push_str(delta);
|
||||
let _ = tx.send(LoopEvent::ThinkingDelta(delta.clone())).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ThinkingDelta(delta.clone())).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ThinkingDelta event: {}", e);
|
||||
}
|
||||
}
|
||||
StreamChunk::ToolUseStart { id, name } => {
|
||||
tracing::debug!("[AgentLoop] ToolUseStart: id={}, name={}", id, name);
|
||||
@@ -770,7 +673,9 @@ impl AgentLoop {
|
||||
// Update with final parsed input and emit ToolStart event
|
||||
if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) {
|
||||
tool.2 = input.clone();
|
||||
let _ = tx.send(LoopEvent::ToolStart { name: tool.1.clone(), input: input.clone() }).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ToolStart { name: tool.1.clone(), input: input.clone() }).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ToolStart event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
StreamChunk::Complete { input_tokens: it, output_tokens: ot, .. } => {
|
||||
@@ -787,20 +692,26 @@ impl AgentLoop {
|
||||
}
|
||||
StreamChunk::Error { message } => {
|
||||
tracing::error!("[AgentLoop] Stream error: {}", message);
|
||||
let _ = tx.send(LoopEvent::Error(message.clone())).await;
|
||||
if let Err(e) = tx.send(LoopEvent::Error(message.clone())).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
|
||||
}
|
||||
stream_errored = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
tracing::error!("[AgentLoop] Chunk error: {}", e);
|
||||
let _ = tx.send(LoopEvent::Error(format!("LLM 响应错误: {}", e.to_string()))).await;
|
||||
if let Err(e) = tx.send(LoopEvent::Error(format!("LLM 响应错误: {}", e.to_string()))).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
|
||||
}
|
||||
stream_errored = true;
|
||||
}
|
||||
Ok(None) => break, // Stream ended normally
|
||||
Err(_) => {
|
||||
tracing::error!("[AgentLoop] Stream chunk timeout ({}s)", chunk_timeout.as_secs());
|
||||
let _ = tx.send(LoopEvent::Error("LLM 响应超时,请重试".to_string())).await;
|
||||
if let Err(e) = tx.send(LoopEvent::Error("LLM 响应超时,请重试".to_string())).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
|
||||
}
|
||||
stream_errored = true;
|
||||
}
|
||||
}
|
||||
@@ -820,7 +731,9 @@ impl AgentLoop {
|
||||
if iteration_text.is_empty() && !reasoning_text.is_empty() {
|
||||
tracing::info!("[AgentLoop] Model generated {} chars of reasoning but no text — using reasoning as response",
|
||||
reasoning_text.len());
|
||||
let _ = tx.send(LoopEvent::Delta(reasoning_text.clone())).await;
|
||||
if let Err(e) = tx.send(LoopEvent::Delta(reasoning_text.clone())).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Delta event: {}", e);
|
||||
}
|
||||
iteration_text = reasoning_text.clone();
|
||||
} else if iteration_text.is_empty() {
|
||||
tracing::warn!("[AgentLoop] No text content after {} chunks (thinking_delta={})",
|
||||
@@ -838,15 +751,17 @@ impl AgentLoop {
|
||||
tracing::warn!("[AgentLoop] Failed to save final assistant message: {}", e);
|
||||
}
|
||||
|
||||
let _ = tx.send(LoopEvent::Complete(AgentLoopResult {
|
||||
if let Err(e) = tx.send(LoopEvent::Complete(AgentLoopResult {
|
||||
response: iteration_text.clone(),
|
||||
input_tokens: total_input_tokens,
|
||||
output_tokens: total_output_tokens,
|
||||
iterations: iteration,
|
||||
})).await;
|
||||
})).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Complete event: {}", e);
|
||||
}
|
||||
|
||||
// Post-completion: middleware after_completion (memory extraction, etc.)
|
||||
if let Some(ref chain) = middleware_chain {
|
||||
{
|
||||
let mw_ctx = middleware::MiddlewareContext {
|
||||
agent_id: agent_id.clone(),
|
||||
session_id: session_id_clone.clone(),
|
||||
@@ -857,7 +772,7 @@ impl AgentLoop {
|
||||
input_tokens: total_input_tokens,
|
||||
output_tokens: total_output_tokens,
|
||||
};
|
||||
if let Err(e) = chain.run_after_completion(&mw_ctx).await {
|
||||
if let Err(e) = middleware_chain.run_after_completion(&mw_ctx).await {
|
||||
tracing::warn!("[AgentLoop] Streaming middleware after_completion failed: {}", e);
|
||||
}
|
||||
}
|
||||
@@ -889,8 +804,8 @@ impl AgentLoop {
|
||||
for (id, name, input) in pending_tool_calls {
|
||||
tracing::debug!("[AgentLoop] Executing tool: name={}, input={:?}", name, input);
|
||||
|
||||
// Check tool call safety — via middleware chain or inline loop guard
|
||||
if let Some(ref chain) = middleware_chain {
|
||||
// Check tool call safety — via middleware chain
|
||||
{
|
||||
let mw_ctx = middleware::MiddlewareContext {
|
||||
agent_id: agent_id.clone(),
|
||||
session_id: session_id_clone.clone(),
|
||||
@@ -901,18 +816,22 @@ impl AgentLoop {
|
||||
input_tokens: total_input_tokens,
|
||||
output_tokens: total_output_tokens,
|
||||
};
|
||||
match chain.run_before_tool_call(&mw_ctx, &name, &input).await {
|
||||
match middleware_chain.run_before_tool_call(&mw_ctx, &name, &input).await {
|
||||
Ok(middleware::ToolCallDecision::Allow) => {}
|
||||
Ok(middleware::ToolCallDecision::Block(msg)) => {
|
||||
tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg);
|
||||
let error_output = serde_json::json!({ "error": msg });
|
||||
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
|
||||
}
|
||||
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true));
|
||||
continue;
|
||||
}
|
||||
Ok(middleware::ToolCallDecision::AbortLoop(reason)) => {
|
||||
tracing::warn!("[AgentLoop] Loop aborted by middleware: {}", reason);
|
||||
let _ = tx.send(LoopEvent::Error(reason)).await;
|
||||
if let Err(e) = tx.send(LoopEvent::Error(reason)).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
Ok(middleware::ToolCallDecision::ReplaceInput(new_input)) => {
|
||||
@@ -936,18 +855,24 @@ impl AgentLoop {
|
||||
let (result, is_error) = if let Some(tool) = tools.get(&name) {
|
||||
match tool.execute(new_input, &tool_context).await {
|
||||
Ok(output) => {
|
||||
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: output.clone() }).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: output.clone() }).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
|
||||
}
|
||||
(output, false)
|
||||
}
|
||||
Err(e) => {
|
||||
let error_output = serde_json::json!({ "error": e.to_string() });
|
||||
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
|
||||
}
|
||||
(error_output, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error_output = serde_json::json!({ "error": format!("Unknown tool: {}", name) });
|
||||
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
|
||||
}
|
||||
(error_output, true)
|
||||
};
|
||||
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), result, is_error));
|
||||
@@ -956,31 +881,13 @@ impl AgentLoop {
|
||||
Err(e) => {
|
||||
tracing::error!("[AgentLoop] Middleware error for tool '{}': {}", name, e);
|
||||
let error_output = serde_json::json!({ "error": e.to_string() });
|
||||
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
|
||||
}
|
||||
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy inline loop guard path
|
||||
let guard_result = loop_guard_clone.lock().unwrap_or_else(|e| e.into_inner()).check(&name, &input);
|
||||
match guard_result {
|
||||
LoopGuardResult::CircuitBreaker => {
|
||||
let _ = tx.send(LoopEvent::Error("检测到工具调用循环,已自动终止".to_string())).await;
|
||||
break 'outer;
|
||||
}
|
||||
LoopGuardResult::Blocked => {
|
||||
tracing::warn!("[AgentLoop] Tool '{}' blocked by loop guard", name);
|
||||
let error_output = serde_json::json!({ "error": "工具调用被循环防护拦截" });
|
||||
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await;
|
||||
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true));
|
||||
continue;
|
||||
}
|
||||
LoopGuardResult::Warn => {
|
||||
tracing::warn!("[AgentLoop] Tool '{}' triggered loop guard warning", name);
|
||||
}
|
||||
LoopGuardResult::Allowed => {}
|
||||
}
|
||||
}
|
||||
// Use pre-resolved path_validator (already has default fallback from create_tool_context logic)
|
||||
let pv = path_validator.clone().unwrap_or_else(|| {
|
||||
@@ -1005,20 +912,26 @@ impl AgentLoop {
|
||||
match tool.execute(input.clone(), &tool_context).await {
|
||||
Ok(output) => {
|
||||
tracing::debug!("[AgentLoop] Tool '{}' executed successfully: {:?}", name, output);
|
||||
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: output.clone() }).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: output.clone() }).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
|
||||
}
|
||||
(output, false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[AgentLoop] Tool '{}' execution failed: {}", name, e);
|
||||
let error_output = serde_json::json!({ "error": e.to_string() });
|
||||
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
|
||||
}
|
||||
(error_output, true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::error!("[AgentLoop] Tool '{}' not found in registry", name);
|
||||
let error_output = serde_json::json!({ "error": format!("Unknown tool: {}", name) });
|
||||
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await;
|
||||
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
|
||||
}
|
||||
(error_output, true)
|
||||
};
|
||||
|
||||
@@ -1038,13 +951,17 @@ impl AgentLoop {
|
||||
is_error,
|
||||
));
|
||||
// Send the question as final delta so the user sees it
|
||||
let _ = tx.send(LoopEvent::Delta(question.clone())).await;
|
||||
let _ = tx.send(LoopEvent::Complete(AgentLoopResult {
|
||||
if let Err(e) = tx.send(LoopEvent::Delta(question.clone())).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Delta event: {}", e);
|
||||
}
|
||||
if let Err(e) = tx.send(LoopEvent::Complete(AgentLoopResult {
|
||||
response: question.clone(),
|
||||
input_tokens: total_input_tokens,
|
||||
output_tokens: total_output_tokens,
|
||||
iterations: iteration,
|
||||
})).await;
|
||||
})).await {
|
||||
tracing::warn!("[AgentLoop] Failed to send Complete event: {}", e);
|
||||
}
|
||||
if let Err(e) = memory.append_message(&session_id_clone, &Message::assistant(&question)).await {
|
||||
tracing::warn!("[AgentLoop] Failed to save clarification message: {}", e);
|
||||
}
|
||||
|
||||
@@ -279,3 +279,4 @@ pub mod token_calibration;
|
||||
pub mod tool_error;
|
||||
pub mod tool_output_guard;
|
||||
pub mod trajectory_recorder;
|
||||
pub mod evolution;
|
||||
|
||||
@@ -20,19 +20,19 @@ use super::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static RE_COMPANY: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)").unwrap()
|
||||
Regex::new(r"[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)").expect("static regex is valid")
|
||||
});
|
||||
static RE_MONEY: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元").unwrap()
|
||||
Regex::new(r"[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元").expect("static regex is valid")
|
||||
});
|
||||
static RE_PHONE: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"1[3-9]\d-?\d{4}-?\d{4}").unwrap()
|
||||
Regex::new(r"1[3-9]\d-?\d{4}-?\d{4}").expect("static regex is valid")
|
||||
});
|
||||
static RE_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap()
|
||||
Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").expect("static regex is valid")
|
||||
});
|
||||
static RE_ID_CARD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"\b\d{17}[\dXx]\b").unwrap()
|
||||
Regex::new(r"\b\d{17}[\dXx]\b").expect("static regex is valid")
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
165
crates/zclaw-runtime/src/middleware/evolution.rs
Normal file
165
crates/zclaw-runtime/src/middleware/evolution.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! 进化引擎中间件
|
||||
//! 在管家对话中检测并呈现"技能进化确认"提示
|
||||
//! 优先级 78(在 ButlerRouter@80 之前运行)
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use crate::middleware::{
|
||||
AgentMiddleware, MiddlewareContext, MiddlewareDecision,
|
||||
};
|
||||
use zclaw_types::Result;
|
||||
|
||||
/// 待确认的进化事件
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingEvolution {
|
||||
pub pattern_name: String,
|
||||
pub trigger_suggestion: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// 进化引擎中间件
|
||||
/// 检查是否有待确认的进化事件,注入确认提示到 system prompt
|
||||
pub struct EvolutionMiddleware {
|
||||
pending: Arc<RwLock<Vec<PendingEvolution>>>,
|
||||
}
|
||||
|
||||
impl EvolutionMiddleware {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加一个待确认的进化事件
|
||||
pub async fn add_pending(&self, evolution: PendingEvolution) {
|
||||
self.pending.write().await.push(evolution);
|
||||
}
|
||||
|
||||
/// 获取并清除所有待确认事件
|
||||
pub async fn drain_pending(&self) -> Vec<PendingEvolution> {
|
||||
let mut pending = self.pending.write().await;
|
||||
std::mem::take(&mut *pending)
|
||||
}
|
||||
|
||||
/// 当前待确认事件数量
|
||||
pub async fn pending_count(&self) -> usize {
|
||||
self.pending.read().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EvolutionMiddleware {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentMiddleware for EvolutionMiddleware {
|
||||
fn name(&self) -> &str {
|
||||
"evolution"
|
||||
}
|
||||
|
||||
fn priority(&self) -> i32 {
|
||||
78 // 在 ButlerRouter(80) 之前
|
||||
}
|
||||
|
||||
async fn before_completion(
|
||||
&self,
|
||||
ctx: &mut MiddlewareContext,
|
||||
) -> Result<MiddlewareDecision> {
|
||||
// 先用 read lock 快速判空,避免每次对话都获取写锁
|
||||
if self.pending.read().await.is_empty() {
|
||||
return Ok(MiddlewareDecision::Continue);
|
||||
}
|
||||
|
||||
// 只移除第一个事件,保留后续事件留待下次注入
|
||||
let to_inject = {
|
||||
let mut pending = self.pending.write().await;
|
||||
if pending.is_empty() {
|
||||
return Ok(MiddlewareDecision::Continue);
|
||||
}
|
||||
pending.remove(0)
|
||||
};
|
||||
|
||||
let injection = format!(
|
||||
"\n\n<evolution-suggestion>\n\
|
||||
我注意到你经常做「{pattern}」相关的事情。\n\
|
||||
我可以帮你整理成一个技能,以后直接说「{trigger}」就能用了。\n\
|
||||
技能描述:{desc}\n\
|
||||
如果你同意,请回复 '确认保存技能'。如果你想调整,可以告诉我怎么改。\n\
|
||||
</evolution-suggestion>",
|
||||
pattern = to_inject.pattern_name,
|
||||
trigger = to_inject.trigger_suggestion,
|
||||
desc = to_inject.description,
|
||||
);
|
||||
ctx.system_prompt.push_str(&injection);
|
||||
|
||||
tracing::info!(
|
||||
"[EvolutionMiddleware] Injected evolution suggestion for: {}",
|
||||
to_inject.pattern_name
|
||||
);
|
||||
|
||||
Ok(MiddlewareDecision::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_no_pending_continues() {
|
||||
let mw = EvolutionMiddleware::new();
|
||||
assert_eq!(mw.pending_count().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_and_drain() {
|
||||
let mw = EvolutionMiddleware::new();
|
||||
mw.add_pending(PendingEvolution {
|
||||
pattern_name: "报表生成".to_string(),
|
||||
trigger_suggestion: "生成报表".to_string(),
|
||||
description: "自动生成每日报表".to_string(),
|
||||
})
|
||||
.await;
|
||||
assert_eq!(mw.pending_count().await, 1);
|
||||
|
||||
let drained = mw.drain_pending().await;
|
||||
assert_eq!(drained.len(), 1);
|
||||
assert_eq!(drained[0].pattern_name, "报表生成");
|
||||
assert_eq!(mw.pending_count().await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_name_and_priority() {
|
||||
let mw = EvolutionMiddleware::new();
|
||||
assert_eq!(mw.name(), "evolution");
|
||||
assert_eq!(mw.priority(), 78);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_only_first_event_injected() {
|
||||
let mw = EvolutionMiddleware::new();
|
||||
mw.add_pending(PendingEvolution {
|
||||
pattern_name: "事件A".to_string(),
|
||||
trigger_suggestion: "触发A".to_string(),
|
||||
description: "描述A".to_string(),
|
||||
})
|
||||
.await;
|
||||
mw.add_pending(PendingEvolution {
|
||||
pattern_name: "事件B".to_string(),
|
||||
trigger_suggestion: "触发B".to_string(),
|
||||
description: "描述B".to_string(),
|
||||
})
|
||||
.await;
|
||||
|
||||
// 模拟注入:用 read 判空 + write 取第一个
|
||||
let first = {
|
||||
let mut pending = mw.pending.write().await;
|
||||
pending.remove(0)
|
||||
};
|
||||
assert_eq!(first.pattern_name, "事件A");
|
||||
assert_eq!(mw.pending_count().await, 1); // 事件B 仍保留
|
||||
}
|
||||
}
|
||||
@@ -11,14 +11,17 @@ use async_trait::async_trait;
|
||||
use zclaw_types::Result;
|
||||
use crate::growth::GrowthIntegration;
|
||||
use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
||||
use crate::middleware::evolution::EvolutionMiddleware;
|
||||
|
||||
/// Middleware that handles memory retrieval (pre-completion) and extraction (post-completion).
|
||||
///
|
||||
/// Wraps `GrowthIntegration` and delegates:
|
||||
/// - `before_completion` → `enhance_prompt()` for memory injection
|
||||
/// - `after_completion` → `process_conversation()` for memory extraction
|
||||
/// - `after_completion` → `extract_combined()` for memory extraction + evolution check
|
||||
pub struct MemoryMiddleware {
|
||||
growth: GrowthIntegration,
|
||||
/// Shared EvolutionMiddleware for pushing evolution suggestions
|
||||
evolution_mw: Option<std::sync::Arc<EvolutionMiddleware>>,
|
||||
/// Minimum seconds between extractions for the same agent (debounce).
|
||||
debounce_secs: u64,
|
||||
/// Timestamp of last extraction per agent (for debouncing).
|
||||
@@ -29,11 +32,18 @@ impl MemoryMiddleware {
|
||||
pub fn new(growth: GrowthIntegration) -> Self {
|
||||
Self {
|
||||
growth,
|
||||
evolution_mw: None,
|
||||
debounce_secs: 30,
|
||||
last_extraction: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a shared EvolutionMiddleware for pushing evolution suggestions.
|
||||
pub fn with_evolution(mut self, mw: std::sync::Arc<EvolutionMiddleware>) -> Self {
|
||||
self.evolution_mw = Some(mw);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the debounce interval in seconds.
|
||||
pub fn with_debounce_secs(mut self, secs: u64) -> Self {
|
||||
self.debounce_secs = secs;
|
||||
@@ -52,6 +62,49 @@ impl MemoryMiddleware {
|
||||
map.insert(agent_id.to_string(), now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Check for evolvable patterns and push suggestions to EvolutionMiddleware.
|
||||
async fn check_and_push_evolution(&self, agent_id: &zclaw_types::AgentId) {
|
||||
let evolution_mw = match &self.evolution_mw {
|
||||
Some(mw) => mw,
|
||||
None => return,
|
||||
};
|
||||
|
||||
match self.growth.check_evolution(agent_id).await {
|
||||
Ok(patterns) if !patterns.is_empty() => {
|
||||
for pattern in &patterns {
|
||||
let trigger = pattern
|
||||
.common_steps
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| pattern.pain_pattern.clone());
|
||||
evolution_mw.add_pending(
|
||||
crate::middleware::evolution::PendingEvolution {
|
||||
pattern_name: pattern.pain_pattern.clone(),
|
||||
trigger_suggestion: trigger,
|
||||
description: format!(
|
||||
"基于 {} 次重复经验,自动固化技能",
|
||||
pattern.total_reuse
|
||||
),
|
||||
},
|
||||
).await;
|
||||
}
|
||||
tracing::info!(
|
||||
"[MemoryMiddleware] Pushed {} evolution candidates for agent {}",
|
||||
patterns.len(),
|
||||
agent_id
|
||||
);
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::debug!("[MemoryMiddleware] No evolvable patterns found");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
"[MemoryMiddleware] Evolution check failed (non-fatal): {}", e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -65,11 +118,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
ctx.user_input.chars().take(50).collect::<String>()
|
||||
);
|
||||
|
||||
// Retrieve relevant memories and inject into system prompt.
|
||||
// The SqliteStorage retriever now uses FTS5-only matching — if FTS5 finds
|
||||
// no relevant results, no memories are returned (no scope-based fallback).
|
||||
// This prevents irrelevant high-importance memories from leaking into
|
||||
// unrelated conversations.
|
||||
let base = &ctx.system_prompt;
|
||||
match self.growth.enhance_prompt(&ctx.agent_id, base, &ctx.user_input).await {
|
||||
Ok(enhanced) => {
|
||||
@@ -88,7 +136,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
Ok(MiddlewareDecision::Continue)
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-fatal: retrieval failure should not block the conversation
|
||||
tracing::warn!(
|
||||
"[MemoryMiddleware] Memory retrieval failed (non-fatal): {}",
|
||||
e
|
||||
@@ -99,7 +146,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
}
|
||||
|
||||
async fn after_completion(&self, ctx: &MiddlewareContext) -> Result<()> {
|
||||
// Debounce: skip extraction if called too recently for this agent
|
||||
let agent_key = ctx.agent_id.to_string();
|
||||
if !self.should_extract(&agent_key) {
|
||||
tracing::debug!(
|
||||
@@ -113,8 +159,6 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Combined extraction: single LLM call produces both memories and structured facts.
|
||||
// Avoids double LLM extraction ( process_conversation + extract_structured_facts).
|
||||
match self.growth.extract_combined(
|
||||
&ctx.agent_id,
|
||||
&ctx.messages,
|
||||
@@ -127,12 +171,14 @@ impl AgentMiddleware for MemoryMiddleware {
|
||||
facts.len(),
|
||||
agent_key
|
||||
);
|
||||
|
||||
// Check for evolvable patterns after successful extraction
|
||||
self.check_and_push_evolution(&ctx.agent_id).await;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!("[MemoryMiddleware] No memories or facts extracted");
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-fatal: extraction failure should not affect the response
|
||||
tracing::warn!("[MemoryMiddleware] Combined extraction failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Three-layer fallback strategy:
|
||||
//! 1. Regex pattern matching (covers ~80% of common expressions)
|
||||
//! 2. LLM-assisted parsing (for ambiguous/complex expressions) — TODO: wire when Haiku driver available
|
||||
//! 2. LLM-assisted parsing (for ambiguous/complex expressions) — FUTURE: post-release LLM-assisted natural language parsing
|
||||
//! 3. Interactive clarification (return `Unclear`)
|
||||
//!
|
||||
//! Lives in `zclaw-runtime` because it's a pure text→cron utility with no kernel dependency.
|
||||
@@ -69,7 +69,7 @@ const PERIOD: &str = "(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|
|
||||
static RE_TIME_STRIP: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"^(?:凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?\d{1,2}[点时::]\d{0,2}分?"
|
||||
).unwrap()
|
||||
).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_every_day
|
||||
@@ -77,13 +77,13 @@ static RE_EVERY_DAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:每天|每日)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
static RE_EVERY_DAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?:每天|每日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
||||
).unwrap()
|
||||
).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_every_week
|
||||
@@ -91,7 +91,7 @@ static RE_EVERY_WEEK: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:每周|每个?星期|每个?礼拜)(一|二|三|四|五|六|日|天|周一|周二|周三|周四|周五|周六|周日|周天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|星期天|礼拜一|礼拜二|礼拜三|礼拜四|礼拜五|礼拜六|礼拜日|礼拜天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_workday
|
||||
@@ -99,18 +99,18 @@ static RE_WORKDAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:工作日|每个?工作日|工作日(?:的)?){}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
static RE_WORKDAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?:工作日|每个?工作日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
|
||||
).unwrap()
|
||||
).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_interval
|
||||
static RE_INTERVAL: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").unwrap()
|
||||
Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_monthly
|
||||
@@ -118,7 +118,7 @@ static RE_MONTHLY: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?:每月|每个月)(?:的)?(\d{{1,2}})[号日](?:的)?{}(\d{{1,2}})?[点时::]?(\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// try_one_shot
|
||||
@@ -126,7 +126,7 @@ static RE_ONE_SHOT: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(明天|后天|大后天)(?:的)?{}(\d{{1,2}})[点时::](\d{{1,2}})?",
|
||||
PERIOD
|
||||
)).unwrap()
|
||||
)).expect("static regex pattern is valid")
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -112,12 +112,14 @@ impl Tool for TaskTool {
|
||||
let task_id = sub_agent_id.to_string();
|
||||
|
||||
if let Some(ref tx) = context.event_sender {
|
||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
||||
if tx.send(LoopEvent::SubtaskStatus {
|
||||
task_id: task_id.clone(),
|
||||
description: description.to_string(),
|
||||
status: "started".to_string(),
|
||||
detail: None,
|
||||
}).await;
|
||||
}).await.is_err() {
|
||||
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||
}
|
||||
}
|
||||
|
||||
// Create a fresh session for the sub-agent
|
||||
@@ -161,12 +163,14 @@ impl Tool for TaskTool {
|
||||
|
||||
// Emit subtask_running event
|
||||
if let Some(ref tx) = context.event_sender {
|
||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
||||
if tx.send(LoopEvent::SubtaskStatus {
|
||||
task_id: task_id.clone(),
|
||||
description: description.to_string(),
|
||||
status: "running".to_string(),
|
||||
detail: Some("子Agent正在执行中...".to_string()),
|
||||
}).await;
|
||||
}).await.is_err() {
|
||||
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the sub-agent loop (non-streaming — collect full result)
|
||||
@@ -179,7 +183,7 @@ impl Tool for TaskTool {
|
||||
|
||||
// Emit subtask_completed event
|
||||
if let Some(ref tx) = context.event_sender {
|
||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
||||
if tx.send(LoopEvent::SubtaskStatus {
|
||||
task_id: task_id.clone(),
|
||||
description: description.to_string(),
|
||||
status: "completed".to_string(),
|
||||
@@ -187,7 +191,9 @@ impl Tool for TaskTool {
|
||||
"完成 ({}次迭代, {}输入token)",
|
||||
loop_result.iterations, loop_result.input_tokens
|
||||
)),
|
||||
}).await;
|
||||
}).await.is_err() {
|
||||
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
@@ -204,12 +210,14 @@ impl Tool for TaskTool {
|
||||
|
||||
// Emit subtask_failed event
|
||||
if let Some(ref tx) = context.event_sender {
|
||||
let _ = tx.send(LoopEvent::SubtaskStatus {
|
||||
if tx.send(LoopEvent::SubtaskStatus {
|
||||
task_id: task_id.clone(),
|
||||
description: description.to_string(),
|
||||
status: "failed".to_string(),
|
||||
detail: Some(e.to_string()),
|
||||
}).await;
|
||||
}).await.is_err() {
|
||||
tracing::debug!("[TaskTool] Subtask status dropped: parent loop ended");
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Add missing indexes for performance-critical queries
|
||||
-- 2026-04-18 Release readiness audit
|
||||
|
||||
-- Rate limit events cleanup (DELETE WHERE created_at < ...)
|
||||
CREATE INDEX IF NOT EXISTS idx_rle_created_at ON rate_limit_events(created_at);
|
||||
|
||||
-- Billing subscriptions plan lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_sub_plan ON billing_subscriptions(plan_id);
|
||||
|
||||
-- Knowledge items created_by lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_ki_created_by ON knowledge_items(created_by);
|
||||
@@ -16,8 +16,13 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
.route("/api/v1/tokens", post(handlers::create_token))
|
||||
.route("/api/v1/tokens/:id", delete(handlers::revoke_token))
|
||||
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
||||
.route("/api/v1/admin/dashboard", get(handlers::dashboard_stats))
|
||||
.route("/api/v1/devices", get(handlers::list_devices))
|
||||
.route("/api/v1/devices/register", post(handlers::register_device))
|
||||
.route("/api/v1/devices/heartbeat", post(handlers::device_heartbeat))
|
||||
}
|
||||
|
||||
/// Admin-only 路由 (需 admin_guard_middleware 保护)
|
||||
pub fn admin_routes() -> axum::Router<crate::state::AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/admin/dashboard", get(handlers::dashboard_stats))
|
||||
}
|
||||
|
||||
@@ -565,7 +565,7 @@ async fn store_refresh_token(
|
||||
|
||||
/// 清理过期和已使用的 refresh tokens
|
||||
/// 注意: 现已迁移到 Worker/Scheduler 定期执行,此函数保留作为备用
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: backup for Worker/Scheduler cleanup; kept as fallback
|
||||
async fn cleanup_expired_refresh_tokens(db: &sqlx::PgPool) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now();
|
||||
// 删除过期超过 30 天的已使用 token (减少 DB 膨胀)
|
||||
|
||||
@@ -203,6 +203,27 @@ pub async fn auth_middleware(
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin 路由守卫中间件: 确保 AuthContext 具有 admin/super_admin 角色
|
||||
/// 必须在 auth_middleware 之后使用(依赖 Extension<AuthContext>)
|
||||
pub async fn admin_guard_middleware(
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
use crate::auth::handlers::check_permission;
|
||||
|
||||
let ctx = req.extensions().get::<AuthContext>().cloned();
|
||||
match ctx {
|
||||
Some(ctx) => {
|
||||
if let Err(e) = check_permission(&ctx, "account:admin") {
|
||||
e.into_response()
|
||||
} else {
|
||||
next.run(req).await
|
||||
}
|
||||
}
|
||||
None => SaasError::Unauthorized.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由 (无需认证的端点)
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
use axum::routing::post;
|
||||
|
||||
@@ -587,7 +587,7 @@ pub async fn get_invoice_pdf(
|
||||
})?;
|
||||
|
||||
// 返回 PDF 响应
|
||||
Ok(axum::response::Response::builder()
|
||||
axum::response::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", "application/pdf")
|
||||
.header(
|
||||
@@ -595,5 +595,8 @@ pub async fn get_invoice_pdf(
|
||||
format!("attachment; filename=\"invoice-{}.pdf\"", invoice.id),
|
||||
)
|
||||
.body(axum::body::Body::from(bytes))
|
||||
.unwrap())
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to build PDF response: {}", e);
|
||||
SaasError::Internal("PDF 响应构建失败".into())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -396,6 +396,23 @@ impl SaaSConfig {
|
||||
config.database.url = db_url;
|
||||
}
|
||||
|
||||
// Config validation
|
||||
if config.auth.jwt_expiration_hours < 1 {
|
||||
anyhow::bail!(
|
||||
"auth.jwt_expiration_hours must be >= 1, got {}",
|
||||
config.auth.jwt_expiration_hours
|
||||
);
|
||||
}
|
||||
if config.database.max_connections == 0 {
|
||||
anyhow::bail!("database.max_connections must be > 0");
|
||||
}
|
||||
if config.database.min_connections > config.database.max_connections {
|
||||
anyhow::bail!(
|
||||
"database.min_connections ({}) must be <= max_connections ({})",
|
||||
config.database.min_connections, config.database.max_connections
|
||||
);
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
|
||||
@@ -804,7 +804,7 @@ async fn handle_document_upload(
|
||||
|
||||
// 创建知识条目
|
||||
let item_req = CreateItemRequest {
|
||||
category_id: "uploaded".to_string(), // TODO: 从上传参数获取
|
||||
category_id: "uploaded".to_string(), // FUTURE: post-release category_id from upload params
|
||||
title: doc.title.clone(),
|
||||
content,
|
||||
keywords: None,
|
||||
|
||||
@@ -309,10 +309,34 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
.unwrap_or(false);
|
||||
if config.server.cors_origins.is_empty() {
|
||||
if is_dev {
|
||||
// Dev mode: use explicit localhost origins (Any + credentials violates CORS spec)
|
||||
let dev_origins: Vec<HeaderValue> = [
|
||||
"http://localhost:1420",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:1420",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080",
|
||||
"tauri://localhost",
|
||||
"https://tauri.localhost",
|
||||
].iter()
|
||||
.filter_map(|o| o.parse::<HeaderValue>().ok())
|
||||
.collect();
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
.allow_origin(dev_origins)
|
||||
.allow_methods([
|
||||
axum::http::Method::GET,
|
||||
axum::http::Method::POST,
|
||||
axum::http::Method::PUT,
|
||||
axum::http::Method::PATCH,
|
||||
axum::http::Method::DELETE,
|
||||
axum::http::Method::OPTIONS,
|
||||
])
|
||||
.allow_headers([
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::header::COOKIE,
|
||||
])
|
||||
.allow_credentials(true)
|
||||
} else {
|
||||
tracing::error!("生产环境必须配置 server.cors_origins,不能使用 allow_origin(Any)");
|
||||
@@ -352,6 +376,10 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
|
||||
let protected_routes = zclaw_saas::auth::protected_routes()
|
||||
.merge(zclaw_saas::account::routes())
|
||||
.merge(
|
||||
zclaw_saas::account::admin_routes()
|
||||
.layer(middleware::from_fn(zclaw_saas::auth::admin_guard_middleware))
|
||||
)
|
||||
.merge(zclaw_saas::model_config::routes())
|
||||
// relay::routes() 不在此合并 — SSE 端点需要更长超时,在最终 Router 单独合并
|
||||
.merge(zclaw_saas::migration::routes())
|
||||
@@ -361,7 +389,10 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
.merge(zclaw_saas::scheduled_task::routes())
|
||||
.merge(zclaw_saas::telemetry::routes())
|
||||
.merge(zclaw_saas::billing::routes())
|
||||
.merge(zclaw_saas::billing::admin_routes())
|
||||
.merge(
|
||||
zclaw_saas::billing::admin_routes()
|
||||
.layer(middleware::from_fn(zclaw_saas::auth::admin_guard_middleware))
|
||||
)
|
||||
.merge(zclaw_saas::knowledge::routes())
|
||||
.merge(zclaw_saas::industry::routes())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::types::*;
|
||||
|
||||
/// 数据库行结构
|
||||
#[derive(Debug, FromRow)]
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: FromRow deserialization struct; fields accessed via destructuring
|
||||
struct ScheduledTaskRow {
|
||||
id: String,
|
||||
account_id: String,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
//! 清理过期 Rate Limit 条目 Worker
|
||||
//!
|
||||
//! rate_limit_events 表中的持久化条目会无限增长。
|
||||
//! 此 Worker 定期删除超过 1 小时的旧条目,防止数据库膨胀。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::PgPool;
|
||||
@@ -21,10 +24,31 @@ impl Worker for CleanupRateLimitWorker {
|
||||
"cleanup_rate_limit"
|
||||
}
|
||||
|
||||
async fn perform(&self, _db: &PgPool, _args: Self::Args) -> SaasResult<()> {
|
||||
// Rate limit entries are in-memory (DashMap), not in DB
|
||||
// This worker is a placeholder for when rate limits are persisted
|
||||
// Currently the cleanup happens in main.rs background task
|
||||
async fn perform(&self, db: &PgPool, args: Self::Args) -> SaasResult<()> {
|
||||
let retention_secs = args.window_secs.max(3600); // 至少保留 1 小时
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM rate_limit_events WHERE created_at < NOW() - ($1 || ' seconds')::interval"
|
||||
)
|
||||
.bind(retention_secs.to_string())
|
||||
.execute(db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(r) => {
|
||||
let deleted = r.rows_affected();
|
||||
if deleted > 0 {
|
||||
tracing::info!(
|
||||
"[cleanup_rate_limit] Deleted {} expired rate limit events (retention: {}s)",
|
||||
deleted, retention_secs
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("[cleanup_rate_limit] Failed to clean up rate limit events: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ description = "ZCLAW skill system"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
wasm = ["wasmtime", "wasmtime-wasi/p1"]
|
||||
wasm = ["wasmtime", "wasmtime-wasi/p1", "ureq", "url"]
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
@@ -27,3 +27,5 @@ shlex = { workspace = true }
|
||||
# Optional WASM runtime (enable with --features wasm)
|
||||
wasmtime = { workspace = true, optional = true }
|
||||
wasmtime-wasi = { workspace = true, optional = true }
|
||||
ureq = { workspace = true, optional = true }
|
||||
url = { workspace = true, optional = true }
|
||||
|
||||
@@ -83,7 +83,7 @@ impl DefaultExecutor {
|
||||
}
|
||||
|
||||
/// Execute a single node (used by pipeline orchestration action driver)
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: post-release pipeline orchestration action driver
|
||||
async fn execute_node(
|
||||
&self,
|
||||
node: &super::SkillNode,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::io::Read as IoRead;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{debug, warn};
|
||||
use wasmtime::*;
|
||||
@@ -23,6 +24,9 @@ use crate::{Skill, SkillContext, SkillManifest, SkillResult};
|
||||
/// Maximum WASM binary size (10 MB).
|
||||
const MAX_WASM_SIZE: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// Maximum HTTP response body size for host function (1 MB).
|
||||
const MAX_HTTP_RESPONSE_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Fuel per second of CPU time (heuristic: ~10M instructions/sec).
|
||||
const FUEL_PER_SEC: u64 = 10_000_000;
|
||||
|
||||
@@ -230,49 +234,178 @@ fn create_engine_config() -> Config {
|
||||
}
|
||||
|
||||
/// Add ZCLAW host functions to the wasmtime linker.
|
||||
fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, _network_allowed: bool) -> Result<()> {
|
||||
fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, network_allowed: bool) -> Result<()> {
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"zclaw_log",
|
||||
|_caller: Caller<'_, WasiP1Ctx>, _ptr: u32, _len: u32| {
|
||||
debug!("[WasmSkill] guest called zclaw_log");
|
||||
|mut caller: Caller<'_, WasiP1Ctx>, ptr: u32, len: u32| {
|
||||
let msg = read_guest_string(&mut caller, ptr, len);
|
||||
debug!("[WasmSkill] guest log: {}", msg);
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_log: {}", e))
|
||||
})?;
|
||||
|
||||
// zclaw_http_fetch(url_ptr, url_len, out_ptr, out_cap) -> bytes_written (-1 = error)
|
||||
// Performs a synchronous GET request. Result is written to guest memory as JSON string.
|
||||
let net = network_allowed;
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"zclaw_http_fetch",
|
||||
|_caller: Caller<'_, WasiP1Ctx>,
|
||||
_url_ptr: u32,
|
||||
_url_len: u32,
|
||||
_out_ptr: u32,
|
||||
_out_cap: u32|
|
||||
-> i32 {
|
||||
warn!("[WasmSkill] guest called zclaw_http_fetch — denied");
|
||||
-1
|
||||
move |mut caller: Caller<'_, WasiP1Ctx>,
|
||||
url_ptr: u32,
|
||||
url_len: u32,
|
||||
out_ptr: u32,
|
||||
out_cap: u32|
|
||||
-> i32 {
|
||||
if !net {
|
||||
warn!("[WasmSkill] guest called zclaw_http_fetch — denied (network not allowed)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
let url = read_guest_string(&mut caller, url_ptr, url_len);
|
||||
if url.is_empty() {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Security: validate URL scheme to prevent SSRF.
|
||||
// Only http:// and https:// are allowed.
|
||||
let parsed = match url::Url::parse(&url) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
warn!("[WasmSkill] http_fetch denied — invalid URL: {}", url);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
let scheme = parsed.scheme();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
warn!("[WasmSkill] http_fetch denied — unsupported scheme: {}", scheme);
|
||||
return -1;
|
||||
}
|
||||
// Block private/loopback hosts to prevent SSRF
|
||||
if let Some(host) = parsed.host_str() {
|
||||
let lower = host.to_lowercase();
|
||||
if lower == "localhost"
|
||||
|| lower.starts_with("127.")
|
||||
|| lower.starts_with("10.")
|
||||
|| lower.starts_with("192.168.")
|
||||
|| lower.starts_with("169.254.")
|
||||
|| lower.starts_with("0.")
|
||||
|| lower.ends_with(".internal")
|
||||
|| lower.ends_with(".local")
|
||||
{
|
||||
warn!("[WasmSkill] http_fetch denied — private/loopback host: {}", host);
|
||||
return -1;
|
||||
}
|
||||
// Also block 172.16.0.0/12 range
|
||||
if lower.starts_with("172.") {
|
||||
if let Ok(second) = lower.split('.').nth(1).unwrap_or("0").parse::<u8>() {
|
||||
if (16..=31).contains(&second) {
|
||||
warn!("[WasmSkill] http_fetch denied — private host (172.16-31.x.x): {}", host);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("[WasmSkill] guest http_fetch: {}", url);
|
||||
|
||||
// Synchronous HTTP GET (we're already on a blocking thread)
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(std::time::Duration::from_secs(10)))
|
||||
.build()
|
||||
.new_agent();
|
||||
let response = agent.get(&url).call();
|
||||
|
||||
match response {
|
||||
Ok(mut resp) => {
|
||||
// Enforce response size limit before reading body
|
||||
let content_length = resp.header("content-length")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<usize>().ok());
|
||||
if let Some(len) = content_length {
|
||||
if len > MAX_HTTP_RESPONSE_SIZE {
|
||||
warn!("[WasmSkill] http_fetch denied — response too large: {} bytes (max {})", len, MAX_HTTP_RESPONSE_SIZE);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
let mut body = String::new();
|
||||
match resp.body_mut().read_to_string(&mut body) {
|
||||
Ok(_) => {
|
||||
if body.len() > MAX_HTTP_RESPONSE_SIZE {
|
||||
warn!("[WasmSkill] http_fetch — response exceeded limit after read, truncating");
|
||||
body.truncate(MAX_HTTP_RESPONSE_SIZE);
|
||||
}
|
||||
write_guest_bytes(&mut caller, out_ptr, out_cap, body.as_bytes())
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[WasmSkill] http_fetch body read error: {}", e);
|
||||
-1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[WasmSkill] http_fetch error for {}: {}", url, e);
|
||||
-1
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_http_fetch: {}", e))
|
||||
})?;
|
||||
|
||||
// zclaw_file_read(path_ptr, path_len, out_ptr, out_cap) -> bytes_written (-1 = error)
|
||||
// Reads a file from the preopened /workspace directory. Paths must be relative.
|
||||
linker
|
||||
.func_wrap(
|
||||
"env",
|
||||
"zclaw_file_read",
|
||||
|_caller: Caller<'_, WasiP1Ctx>,
|
||||
_path_ptr: u32,
|
||||
_path_len: u32,
|
||||
_out_ptr: u32,
|
||||
_out_cap: u32|
|
||||
|mut caller: Caller<'_, WasiP1Ctx>,
|
||||
path_ptr: u32,
|
||||
path_len: u32,
|
||||
out_ptr: u32,
|
||||
out_cap: u32|
|
||||
-> i32 {
|
||||
warn!("[WasmSkill] guest called zclaw_file_read — denied");
|
||||
-1
|
||||
let path = read_guest_string(&mut caller, path_ptr, path_len);
|
||||
if path.is_empty() {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Security: validate path stays within /workspace sandbox.
|
||||
// Reject absolute paths, and filter any path component that
|
||||
// is ".." (e.g. "foo/../../etc/passwd").
|
||||
let joined = std::path::Path::new("/workspace").join(&path);
|
||||
let mut safe = true;
|
||||
for comp in joined.components() {
|
||||
match comp {
|
||||
std::path::Component::ParentDir => {
|
||||
safe = false;
|
||||
break;
|
||||
}
|
||||
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
|
||||
safe = false;
|
||||
break;
|
||||
}
|
||||
_ => {} // Normal, CurDir — ok
|
||||
}
|
||||
}
|
||||
if !safe {
|
||||
warn!("[WasmSkill] guest file_read denied — path escapes sandbox: {}", path);
|
||||
return -1;
|
||||
}
|
||||
|
||||
match std::fs::read(&joined) {
|
||||
Ok(data) => write_guest_bytes(&mut caller, out_ptr, out_cap, &data),
|
||||
Err(e) => {
|
||||
debug!("[WasmSkill] file_read error for {}: {}", path, e);
|
||||
-1
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
@@ -282,6 +415,38 @@ fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, _network_allowed: bool) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a string from WASM guest memory.
|
||||
fn read_guest_string(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, len: u32) -> String {
|
||||
let mem = match caller.get_export("memory") {
|
||||
Some(Extern::Memory(m)) => m,
|
||||
_ => return String::new(),
|
||||
};
|
||||
let offset = ptr as usize;
|
||||
let length = len as usize;
|
||||
let data = mem.data(&caller);
|
||||
if offset + length > data.len() {
|
||||
return String::new();
|
||||
}
|
||||
String::from_utf8_lossy(&data[offset..offset + length]).into_owned()
|
||||
}
|
||||
|
||||
/// Write bytes to WASM guest memory. Returns the number of bytes written, or -1 on overflow.
|
||||
fn write_guest_bytes(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, cap: u32, data: &[u8]) -> i32 {
|
||||
let mem = match caller.get_export("memory") {
|
||||
Some(Extern::Memory(m)) => m,
|
||||
_ => return -1,
|
||||
};
|
||||
let offset = ptr as usize;
|
||||
let capacity = cap as usize;
|
||||
let write_len = data.len().min(capacity);
|
||||
if offset + write_len > mem.data_size(&caller) {
|
||||
return -1;
|
||||
}
|
||||
// Safety: we've bounds-checked the write region.
|
||||
mem.data_mut(&mut *caller)[offset..offset + write_len].copy_from_slice(&data[..write_len]);
|
||||
write_len as i32
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,9 +1,95 @@
|
||||
//! Error types for ZCLAW
|
||||
//!
|
||||
//! Provides structured error classification via [`ErrorKind`] and machine-readable
|
||||
//! error codes alongside human-readable messages. The enum variants are preserved
|
||||
//! for backward compatibility — all existing construction sites continue to work.
|
||||
|
||||
use thiserror::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// ZCLAW unified error type
|
||||
#[derive(Debug, Error)]
|
||||
// === Error Kind (structured classification) ===
|
||||
|
||||
/// Machine-readable error category for structured error reporting.
|
||||
///
|
||||
/// Each variant maps to a stable error code prefix (e.g., `E404x` for `NotFound`).
|
||||
/// Frontend code should match on `ErrorKind` rather than string patterns.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorKind {
|
||||
NotFound,
|
||||
Permission,
|
||||
Auth,
|
||||
Llm,
|
||||
Tool,
|
||||
Storage,
|
||||
Config,
|
||||
Http,
|
||||
Timeout,
|
||||
Validation,
|
||||
LoopDetected,
|
||||
RateLimit,
|
||||
Mcp,
|
||||
Security,
|
||||
Hand,
|
||||
Export,
|
||||
Internal,
|
||||
}
|
||||
|
||||
// === Error Codes ===
|
||||
|
||||
/// Stable error codes for machine-readable error matching.
|
||||
///
|
||||
/// Format: `E{HTTP_STATUS_MIRROR}{SEQUENCE}`.
|
||||
/// Frontend should use these codes instead of regex-matching error strings.
|
||||
pub mod error_codes {
|
||||
// Not Found (4040-4049)
|
||||
pub const NOT_FOUND: &str = "E4040";
|
||||
// Permission (4030-4039)
|
||||
pub const PERMISSION_DENIED: &str = "E4030";
|
||||
// Auth (4010-4019)
|
||||
pub const AUTH_FAILED: &str = "E4010";
|
||||
// LLM (5000-5009)
|
||||
pub const LLM_ERROR: &str = "E5001";
|
||||
pub const LLM_TIMEOUT: &str = "E5002";
|
||||
pub const LLM_RATE_LIMITED: &str = "E5003";
|
||||
// Tool (5010-5019)
|
||||
pub const TOOL_ERROR: &str = "E5010";
|
||||
pub const TOOL_NOT_FOUND: &str = "E5011";
|
||||
pub const TOOL_TIMEOUT: &str = "E5012";
|
||||
// Storage (5020-5029)
|
||||
pub const STORAGE_ERROR: &str = "E5020";
|
||||
pub const STORAGE_CORRUPTION: &str = "E5021";
|
||||
// Config (5030-5039)
|
||||
pub const CONFIG_ERROR: &str = "E5030";
|
||||
// HTTP (5040-5049)
|
||||
pub const HTTP_ERROR: &str = "E5040";
|
||||
// Timeout (5050-5059)
|
||||
pub const TIMEOUT: &str = "E5050";
|
||||
// Validation (4000-4009)
|
||||
pub const VALIDATION_ERROR: &str = "E4000";
|
||||
// Loop (5060-5069)
|
||||
pub const LOOP_DETECTED: &str = "E5060";
|
||||
// Rate Limit (4290-4299)
|
||||
pub const RATE_LIMITED: &str = "E4290";
|
||||
// MCP (5070-5079)
|
||||
pub const MCP_ERROR: &str = "E5070";
|
||||
// Security (5080-5089)
|
||||
pub const SECURITY_ERROR: &str = "E5080";
|
||||
// Hand (5090-5099)
|
||||
pub const HAND_ERROR: &str = "E5090";
|
||||
// Export (5100-5109)
|
||||
pub const EXPORT_ERROR: &str = "E5100";
|
||||
// Internal (5110-5119)
|
||||
pub const INTERNAL: &str = "E5110";
|
||||
}
|
||||
|
||||
// === ZclawError ===
|
||||
|
||||
/// ZCLAW unified error type.
|
||||
///
|
||||
/// All variants are preserved for backward compatibility.
|
||||
/// Use `.kind()` and `.code()` for structured classification.
|
||||
/// Implements [`Serialize`] for JSON transport to frontend.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ZclawError {
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
@@ -60,6 +146,80 @@ pub enum ZclawError {
|
||||
HandError(String),
|
||||
}
|
||||
|
||||
impl ZclawError {
|
||||
/// Returns the structured error category.
|
||||
pub fn kind(&self) -> ErrorKind {
|
||||
match self {
|
||||
Self::NotFound(_) => ErrorKind::NotFound,
|
||||
Self::PermissionDenied(_) => ErrorKind::Permission,
|
||||
Self::LlmError(_) => ErrorKind::Llm,
|
||||
Self::ToolError(_) => ErrorKind::Tool,
|
||||
Self::StorageError(_) => ErrorKind::Storage,
|
||||
Self::ConfigError(_) => ErrorKind::Config,
|
||||
Self::SerializationError(_) => ErrorKind::Internal,
|
||||
Self::IoError(_) => ErrorKind::Internal,
|
||||
Self::HttpError(_) => ErrorKind::Http,
|
||||
Self::Timeout(_) => ErrorKind::Timeout,
|
||||
Self::InvalidInput(_) => ErrorKind::Validation,
|
||||
Self::LoopDetected(_) => ErrorKind::LoopDetected,
|
||||
Self::RateLimited(_) => ErrorKind::RateLimit,
|
||||
Self::Internal(_) => ErrorKind::Internal,
|
||||
Self::ExportError(_) => ErrorKind::Export,
|
||||
Self::McpError(_) => ErrorKind::Mcp,
|
||||
Self::SecurityError(_) => ErrorKind::Security,
|
||||
Self::HandError(_) => ErrorKind::Hand,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the stable error code (e.g., `"E4040"` for `NotFound`).
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NotFound(_) => error_codes::NOT_FOUND,
|
||||
Self::PermissionDenied(_) => error_codes::PERMISSION_DENIED,
|
||||
Self::LlmError(_) => error_codes::LLM_ERROR,
|
||||
Self::ToolError(_) => error_codes::TOOL_ERROR,
|
||||
Self::StorageError(_) => error_codes::STORAGE_ERROR,
|
||||
Self::ConfigError(_) => error_codes::CONFIG_ERROR,
|
||||
Self::SerializationError(_) => error_codes::INTERNAL,
|
||||
Self::IoError(_) => error_codes::INTERNAL,
|
||||
Self::HttpError(_) => error_codes::HTTP_ERROR,
|
||||
Self::Timeout(_) => error_codes::TIMEOUT,
|
||||
Self::InvalidInput(_) => error_codes::VALIDATION_ERROR,
|
||||
Self::LoopDetected(_) => error_codes::LOOP_DETECTED,
|
||||
Self::RateLimited(_) => error_codes::RATE_LIMITED,
|
||||
Self::Internal(_) => error_codes::INTERNAL,
|
||||
Self::ExportError(_) => error_codes::EXPORT_ERROR,
|
||||
Self::McpError(_) => error_codes::MCP_ERROR,
|
||||
Self::SecurityError(_) => error_codes::SECURITY_ERROR,
|
||||
Self::HandError(_) => error_codes::HAND_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured JSON representation for frontend consumption.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ErrorDetail {
|
||||
pub kind: ErrorKind,
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl From<&ZclawError> for ErrorDetail {
|
||||
fn from(err: &ZclawError) -> Self {
|
||||
Self {
|
||||
kind: err.kind(),
|
||||
code: err.code(),
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ZclawError {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
ErrorDetail::from(self).serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result type alias for ZCLAW operations
|
||||
pub type Result<T> = std::result::Result<T, ZclawError>;
|
||||
|
||||
@@ -177,4 +337,63 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), ZclawError::NotFound(_)));
|
||||
}
|
||||
|
||||
// === New structured error tests ===
|
||||
|
||||
#[test]
|
||||
fn test_error_kind_mapping() {
|
||||
assert_eq!(ZclawError::NotFound("x".into()).kind(), ErrorKind::NotFound);
|
||||
assert_eq!(ZclawError::PermissionDenied("x".into()).kind(), ErrorKind::Permission);
|
||||
assert_eq!(ZclawError::LlmError("x".into()).kind(), ErrorKind::Llm);
|
||||
assert_eq!(ZclawError::ToolError("x".into()).kind(), ErrorKind::Tool);
|
||||
assert_eq!(ZclawError::StorageError("x".into()).kind(), ErrorKind::Storage);
|
||||
assert_eq!(ZclawError::InvalidInput("x".into()).kind(), ErrorKind::Validation);
|
||||
assert_eq!(ZclawError::Timeout("x".into()).kind(), ErrorKind::Timeout);
|
||||
assert_eq!(ZclawError::SecurityError("x".into()).kind(), ErrorKind::Security);
|
||||
assert_eq!(ZclawError::HandError("x".into()).kind(), ErrorKind::Hand);
|
||||
assert_eq!(ZclawError::McpError("x".into()).kind(), ErrorKind::Mcp);
|
||||
assert_eq!(ZclawError::Internal("x".into()).kind(), ErrorKind::Internal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_code_stability() {
|
||||
assert_eq!(ZclawError::NotFound("x".into()).code(), "E4040");
|
||||
assert_eq!(ZclawError::PermissionDenied("x".into()).code(), "E4030");
|
||||
assert_eq!(ZclawError::LlmError("x".into()).code(), "E5001");
|
||||
assert_eq!(ZclawError::ToolError("x".into()).code(), "E5010");
|
||||
assert_eq!(ZclawError::StorageError("x".into()).code(), "E5020");
|
||||
assert_eq!(ZclawError::InvalidInput("x".into()).code(), "E4000");
|
||||
assert_eq!(ZclawError::Timeout("x".into()).code(), "E5050");
|
||||
assert_eq!(ZclawError::SecurityError("x".into()).code(), "E5080");
|
||||
assert_eq!(ZclawError::HandError("x".into()).code(), "E5090");
|
||||
assert_eq!(ZclawError::McpError("x".into()).code(), "E5070");
|
||||
assert_eq!(ZclawError::Internal("x".into()).code(), "E5110");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_serialize_json() {
|
||||
let err = ZclawError::NotFound("agent-123".to_string());
|
||||
let json = serde_json::to_value(&err).unwrap();
|
||||
assert_eq!(json["kind"], "not_found");
|
||||
assert_eq!(json["code"], "E4040");
|
||||
assert_eq!(json["message"], "Not found: agent-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_detail_from() {
|
||||
let err = ZclawError::LlmError("timeout".to_string());
|
||||
let detail = ErrorDetail::from(&err);
|
||||
assert_eq!(detail.kind, ErrorKind::Llm);
|
||||
assert_eq!(detail.code, "E5001");
|
||||
assert_eq!(detail.message, "LLM error: timeout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_kind_serde_roundtrip() {
|
||||
let kind = ErrorKind::Storage;
|
||||
let json = serde_json::to_string(&kind).unwrap();
|
||||
assert_eq!(json, "\"storage\"");
|
||||
let back: ErrorKind = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, kind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +116,6 @@ impl Message {
|
||||
|
||||
/// Canonical LLM message content block. Used for agent conversation messages.
|
||||
/// See also: zclaw_runtime::driver::ContentBlock (LLM driver response subset),
|
||||
/// zclaw_hands::slideshow::ContentBlock (presentation rendering),
|
||||
/// zclaw_protocols::mcp_types::ContentBlock (MCP protocol wire format).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
|
||||
@@ -16,9 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[features]
|
||||
default = ["multi-agent"]
|
||||
# Multi-agent orchestration (A2A protocol, Director, agent delegation)
|
||||
multi-agent = ["zclaw-kernel/multi-agent"]
|
||||
default = []
|
||||
dev-server = ["dep:axum", "dep:tower-http"]
|
||||
|
||||
[dependencies]
|
||||
@@ -49,7 +47,7 @@ async-trait = { workspace = true }
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = "0.9"
|
||||
serde_yaml = { package = "serde_yaml_bw", version = "2" }
|
||||
toml = "0.8"
|
||||
|
||||
# HTTP client
|
||||
|
||||
@@ -168,7 +168,7 @@ impl SessionManager {
|
||||
|
||||
/// Get the number of active sessions
|
||||
/// Reserved for future status dashboard
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: post-release status dashboard session count
|
||||
pub async fn session_count(&self) -> usize {
|
||||
let sessions = self.sessions.read().await;
|
||||
sessions.len()
|
||||
|
||||
@@ -207,7 +207,7 @@ pub async fn classroom_generate(
|
||||
metadata: zclaw_kernel::generation::ClassroomMetadata {
|
||||
generated_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.expect("system clock is valid")
|
||||
.as_millis() as i64,
|
||||
source_document: kernel_request.document.map(|_| "user_document".to_string()),
|
||||
model: None,
|
||||
|
||||
@@ -55,7 +55,9 @@ pub async fn init_persistence(
|
||||
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||
|
||||
let db_path = app_dir.join("classroom").join("classrooms.db");
|
||||
std::fs::create_dir_all(db_path.parent().unwrap())
|
||||
let db_dir = db_path.parent()
|
||||
.ok_or_else(|| "Invalid classroom database path: no parent directory".to_string())?;
|
||||
std::fs::create_dir_all(db_dir)
|
||||
.map_err(|e| format!("Failed to create classroom dir: {}", e))?;
|
||||
|
||||
let persistence: ClassroomPersistence = ClassroomPersistence::open(db_path).await?;
|
||||
|
||||
@@ -101,7 +101,7 @@ impl ClassroomPersistence {
|
||||
}
|
||||
|
||||
/// Delete a classroom and its chat history.
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: post-release classroom deletion Tauri command
|
||||
pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> {
|
||||
let mut conn = self.conn.lock().await;
|
||||
sqlx::query("DELETE FROM classrooms WHERE id = ?")
|
||||
|
||||
@@ -48,10 +48,10 @@ async fn run_server(state: DevServerState) {
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin([
|
||||
"http://localhost:1420".parse().unwrap(),
|
||||
"http://127.0.0.1:1420".parse().unwrap(),
|
||||
"http://localhost:5173".parse().unwrap(),
|
||||
"http://127.0.0.1:5173".parse().unwrap(),
|
||||
"http://localhost:1420".parse().expect("hardcoded localhost URL is valid"),
|
||||
"http://127.0.0.1:1420".parse().expect("hardcoded localhost URL is valid"),
|
||||
"http://localhost:5173".parse().expect("hardcoded localhost URL is valid"),
|
||||
"http://127.0.0.1:5173".parse().expect("hardcoded localhost URL is valid"),
|
||||
])
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]),
|
||||
|
||||
@@ -99,7 +99,7 @@ pub struct CompactionCheck {
|
||||
|
||||
/// Configuration for LLM-based summary generation
|
||||
/// NOTE: Reserved for future LLM compaction Tauri command
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: post-release LLM compaction Tauri command
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LlmSummaryConfig {
|
||||
pub provider: String,
|
||||
|
||||
@@ -225,6 +225,69 @@ impl LlmDriverForExtraction for TauriExtractionDriver {
|
||||
|
||||
Ok(memories)
|
||||
}
|
||||
|
||||
async fn extract_with_prompt(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
system_prompt: &str,
|
||||
user_prompt: &str,
|
||||
) -> Result<String> {
|
||||
if messages.len() < 2 {
|
||||
return Err(zclaw_types::ZclawError::InvalidInput(
|
||||
"Too few messages for combined extraction".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
"[TauriExtractionDriver] Combined extraction from {} messages",
|
||||
messages.len()
|
||||
);
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: self.model.clone(),
|
||||
system: Some(system_prompt.to_string()),
|
||||
messages: vec![Message::user(user_prompt.to_string())],
|
||||
tools: Vec::new(),
|
||||
max_tokens: Some(3000),
|
||||
temperature: Some(0.3),
|
||||
stop: Vec::new(),
|
||||
stream: false,
|
||||
thinking_enabled: false,
|
||||
reasoning_effort: None,
|
||||
plan_mode: false,
|
||||
};
|
||||
|
||||
let response = self.driver.complete(request).await.map_err(|e| {
|
||||
tracing::error!(
|
||||
"[TauriExtractionDriver] Combined extraction LLM call failed: {}",
|
||||
e
|
||||
);
|
||||
e
|
||||
})?;
|
||||
|
||||
let response_text: String = response
|
||||
.content
|
||||
.into_iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::Text { text } => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
if response_text.is_empty() {
|
||||
return Err(zclaw_types::ZclawError::LlmError(
|
||||
"Empty response from LLM for combined extraction".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[TauriExtractionDriver] Combined extraction response: {} chars",
|
||||
response_text.len()
|
||||
);
|
||||
|
||||
Ok(response_text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global extraction driver instance (legacy path, kept for compatibility).
|
||||
@@ -250,7 +313,7 @@ pub fn configure_extraction_driver(driver: Arc<dyn LlmDriver>, model: String) {
|
||||
}
|
||||
|
||||
/// Check if the extraction driver is available (legacy OnceCell path).
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: diagnostic check for legacy OnceCell extraction driver
|
||||
pub fn is_extraction_driver_configured() -> bool {
|
||||
EXTRACTION_DRIVER.get().is_some()
|
||||
}
|
||||
@@ -258,7 +321,7 @@ pub fn is_extraction_driver_configured() -> bool {
|
||||
/// Get the global extraction driver (legacy OnceCell path).
|
||||
///
|
||||
/// Prefer accessing via `kernel.extraction_driver()` when the Kernel is available.
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: legacy accessor, prefer kernel.extraction_driver()
|
||||
pub fn get_extraction_driver() -> Option<Arc<TauriExtractionDriver>> {
|
||||
EXTRACTION_DRIVER.get().cloned()
|
||||
}
|
||||
@@ -272,22 +335,6 @@ mod tests {
|
||||
assert!(!is_extraction_driver_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_response() {
|
||||
// We cannot create a real LlmDriver easily in tests, so we test the
|
||||
// parsing logic via a minimal helper.
|
||||
struct DummyDriver;
|
||||
impl TauriExtractionDriver {
|
||||
fn parse_response_test(
|
||||
&self,
|
||||
response_text: &str,
|
||||
extraction_type: MemoryType,
|
||||
) -> Vec<ExtractedMemory> {
|
||||
self.parse_response(response_text, extraction_type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_valid_json_response() {
|
||||
let response = r#"```json
|
||||
|
||||
@@ -383,7 +383,10 @@ async fn execute_tick(
|
||||
|
||||
// Send alerts via broadcast channel (internal)
|
||||
for alert in &filtered_alerts {
|
||||
let _ = alert_sender.send(alert.clone());
|
||||
if alert_sender.send(alert.clone()).is_err() {
|
||||
tracing::debug!("[heartbeat] No alert receivers, alert dropped");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit alerts to frontend via Tauri event (real-time toast)
|
||||
|
||||
@@ -324,7 +324,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence helpers (stub — TODO: integrate with VikingStorage)
|
||||
// Persistence helpers (stub — FUTURE: post-release personality persistence via VikingStorage)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use std::sync::OnceLock;
|
||||
@@ -339,12 +339,12 @@ fn personality_store() -> &'static Mutex<std::collections::HashMap<String, Perso
|
||||
/// Load personality config for a given agent.
|
||||
/// Returns default config if none is stored.
|
||||
pub fn load_personality_config(agent_id: &str) -> PersonalityConfig {
|
||||
let store = personality_store().lock().unwrap();
|
||||
let store = personality_store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
store.get(agent_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Save personality config for a given agent.
|
||||
pub fn save_personality_config(agent_id: &str, config: &PersonalityConfig) {
|
||||
let mut store = personality_store().lock().unwrap();
|
||||
let mut store = personality_store().lock().unwrap_or_else(|e| e.into_inner());
|
||||
store.insert(agent_id.to_string(), config.clone());
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::sync::Arc;
|
||||
use chrono::Utc;
|
||||
use tracing::{debug, warn};
|
||||
use zclaw_memory::fact::Fact;
|
||||
#[cfg(test)]
|
||||
use zclaw_memory::fact::FactCategory;
|
||||
use zclaw_memory::user_profile_store::{
|
||||
CommStyle, Level, UserProfile, UserProfileStore,
|
||||
};
|
||||
@@ -86,7 +88,7 @@ fn classify_fact_content(fact: &Fact) -> Option<ProfileFieldUpdate> {
|
||||
return Some(ProfileFieldUpdate::PreferredTool("collector".into()));
|
||||
}
|
||||
if content.contains("幻灯") || content.contains("演示") || content.contains("ppt") {
|
||||
return Some(ProfileFieldUpdate::PreferredTool("slideshow".into()));
|
||||
return Some(ProfileFieldUpdate::RecentTopic("演示文稿".into()));
|
||||
}
|
||||
|
||||
// Default: treat as a recent topic
|
||||
|
||||
@@ -206,7 +206,7 @@ pub async fn post_conversation_hook(
|
||||
///
|
||||
/// NOTE: Memory injection is now handled by MemoryMiddleware in the Kernel
|
||||
/// middleware chain. This function is kept as a utility for ad-hoc queries.
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: ad-hoc memory context queries outside middleware chain
|
||||
async fn build_memory_context(
|
||||
agent_id: &str,
|
||||
user_message: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! A2A (Agent-to-Agent) commands — gated behind `multi-agent` feature
|
||||
//! A2A (Agent-to-Agent) commands
|
||||
|
||||
use serde_json;
|
||||
use tauri::State;
|
||||
@@ -7,10 +7,9 @@ use zclaw_types::AgentId;
|
||||
use super::KernelState;
|
||||
|
||||
// ============================================================
|
||||
// A2A (Agent-to-Agent) Commands — gated behind multi-agent feature
|
||||
// A2A (Agent-to-Agent) Commands
|
||||
// ============================================================
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
/// Send a direct A2A message from one agent to another
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
@@ -44,7 +43,6 @@ pub async fn agent_a2a_send(
|
||||
}
|
||||
|
||||
/// Broadcast a message from one agent to all other agents
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn agent_a2a_broadcast(
|
||||
@@ -66,7 +64,6 @@ pub async fn agent_a2a_broadcast(
|
||||
}
|
||||
|
||||
/// Discover agents with a specific capability
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn agent_a2a_discover(
|
||||
@@ -88,7 +85,6 @@ pub async fn agent_a2a_discover(
|
||||
}
|
||||
|
||||
/// Delegate a task to another agent and wait for response
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn agent_a2a_delegate_task(
|
||||
@@ -116,11 +112,10 @@ pub async fn agent_a2a_delegate_task(
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Butler Delegation Command — multi-agent feature
|
||||
// Butler Delegation Command
|
||||
// ============================================================
|
||||
|
||||
/// Butler delegates a user request to expert agents via the Director.
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// @reserved: butler multi-agent delegation
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
|
||||
@@ -291,15 +291,19 @@ pub async fn agent_chat_stream(
|
||||
);
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||
let _ = tx.send(zclaw_runtime::LoopEvent::Delta(confirm_msg)).await;
|
||||
let _ = tx.send(zclaw_runtime::LoopEvent::Complete(
|
||||
if tx.send(zclaw_runtime::LoopEvent::Delta(confirm_msg)).await.is_err() {
|
||||
tracing::warn!("[agent_chat_stream] Failed to send confirm msg to new channel");
|
||||
}
|
||||
if tx.send(zclaw_runtime::LoopEvent::Complete(
|
||||
zclaw_runtime::AgentLoopResult {
|
||||
response: String::new(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: 1,
|
||||
}
|
||||
)).await;
|
||||
)).await.is_err() {
|
||||
tracing::warn!("[agent_chat_stream] Failed to send complete to new channel");
|
||||
}
|
||||
drop(tx);
|
||||
(rx, None)
|
||||
} else {
|
||||
@@ -400,10 +404,12 @@ pub async fn agent_chat_stream(
|
||||
// Check cancellation flag before each recv
|
||||
if cancel_clone.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
tracing::info!("[agent_chat_stream] Stream cancelled for session: {}", session_id);
|
||||
let _ = app.emit("stream:chunk", serde_json::json!({
|
||||
if let Err(e) = app.emit("stream:chunk", serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"event": StreamChatEvent::Error { message: "已取消".to_string() }
|
||||
}));
|
||||
})) {
|
||||
tracing::debug!("[agent_chat_stream] Failed to emit cancel event: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -491,12 +497,14 @@ pub async fn agent_chat_stream(
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!("[agent_chat_stream] Stream idle timeout for session: {}", session_id);
|
||||
let _ = app.emit("stream:chunk", serde_json::json!({
|
||||
if let Err(e) = app.emit("stream:chunk", serde_json::json!({
|
||||
"sessionId": session_id,
|
||||
"event": StreamChatEvent::Error {
|
||||
message: "流式响应超时,请重试".to_string()
|
||||
}
|
||||
}));
|
||||
})) {
|
||||
tracing::debug!("[agent_chat_stream] Failed to emit timeout event: {}", e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ impl Default for McpManagerState {
|
||||
|
||||
impl McpManagerState {
|
||||
/// Create with a pre-allocated kernel_adapters Arc for sharing with Kernel.
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // @reserved: alternate constructor for shared MCP adapter injection
|
||||
pub fn with_shared_adapters(kernel_adapters: Arc<std::sync::RwLock<Vec<McpToolAdapter>>>) -> Self {
|
||||
Self {
|
||||
manager: Arc::new(Mutex::new(McpServiceManager::new())),
|
||||
|
||||
@@ -19,7 +19,6 @@ pub mod skill;
|
||||
pub mod trigger;
|
||||
pub mod workspace;
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
pub mod a2a;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -204,6 +204,7 @@ impl From<zclaw_skills::orchestration::OrchestrationResult> for OrchestrationRes
|
||||
|
||||
/// @reserved — no frontend UI yet
|
||||
/// Execute a skill orchestration
|
||||
/// @reserved — orchestration engine internal, no direct frontend caller
|
||||
///
|
||||
/// Either auto-composes a graph from skill_ids, or uses a pre-defined graph.
|
||||
/// Executes with true parallel execution within each dependency level.
|
||||
|
||||
@@ -255,16 +255,11 @@ pub fn run() {
|
||||
kernel_commands::scheduled_task::scheduled_task_create,
|
||||
kernel_commands::scheduled_task::scheduled_task_list,
|
||||
|
||||
// A2A commands gated behind multi-agent feature
|
||||
#[cfg(feature = "multi-agent")]
|
||||
// A2A commands
|
||||
kernel_commands::a2a::agent_a2a_send,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
kernel_commands::a2a::agent_a2a_broadcast,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
kernel_commands::a2a::agent_a2a_discover,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
kernel_commands::a2a::agent_a2a_delegate_task,
|
||||
#[cfg(feature = "multi-agent")]
|
||||
kernel_commands::a2a::butler_delegate_task,
|
||||
|
||||
// Pipeline commands (DSL-based workflows)
|
||||
|
||||
@@ -489,7 +489,7 @@ pub async fn viking_tree(path: String, depth: Option<usize>) -> Result<serde_jso
|
||||
current = current
|
||||
.get_mut(*part)
|
||||
.and_then(|v| v.as_object_mut())
|
||||
.unwrap();
|
||||
.ok_or_else(|| format!("Invalid URI tree structure at segment: {}", part))?;
|
||||
}
|
||||
|
||||
if let Some(last) = parts.last() {
|
||||
|
||||
@@ -862,7 +862,7 @@ export function AuditLogsPanel() {
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
|
||||
<AlertCircle className="w-8 h-8 mb-2" />
|
||||
<p>No audit logs found</p>
|
||||
<p>暂无审计日志</p>
|
||||
{(searchTerm || Object.keys(filter).length > 0) && (
|
||||
<button
|
||||
onClick={handleResetFilters}
|
||||
|
||||
@@ -683,14 +683,14 @@ function MessageBubble({ message, onRetry }: { message: Message; setInput?: (tex
|
||||
// Thinking indicator
|
||||
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
|
||||
<LoadingDots />
|
||||
<span className="text-sm">Thinking...</span>
|
||||
<span className="text-sm">思考中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
|
||||
{/* Optimistic sending indicator */}
|
||||
{isUser && message.optimistic && (
|
||||
<span className="text-xs text-blue-200 dark:text-blue-300 mb-1 block animate-pulse">
|
||||
Sending...
|
||||
发送中...
|
||||
</span>
|
||||
)}
|
||||
{/* Reasoning block for thinking content (DeerFlow-inspired) */}
|
||||
|
||||
@@ -543,7 +543,7 @@ export function CreateTriggerModal({ isOpen, onClose, onSuccess }: CreateTrigger
|
||||
{submitStatus === 'success' && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg text-green-700 dark:text-green-400">
|
||||
<CheckCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">Trigger created successfully!</span>
|
||||
<span className="text-sm">触发器创建成功!</span>
|
||||
</div>
|
||||
)}
|
||||
{submitStatus === 'error' && (
|
||||
|
||||
@@ -57,21 +57,21 @@ const RISK_CONFIG: Record<
|
||||
{ label: string; color: string; bgColor: string; borderColor: string; icon: typeof AlertTriangle }
|
||||
> = {
|
||||
low: {
|
||||
label: 'Low Risk',
|
||||
label: '低风险',
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-100 dark:bg-green-900/30',
|
||||
borderColor: 'border-green-300 dark:border-green-700',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium Risk',
|
||||
label: '中风险',
|
||||
color: 'text-yellow-600 dark:text-yellow-400',
|
||||
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||
borderColor: 'border-yellow-300 dark:border-yellow-700',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
high: {
|
||||
label: 'High Risk',
|
||||
label: '高风险',
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-100 dark:bg-red-900/30',
|
||||
borderColor: 'border-red-300 dark:border-red-700',
|
||||
@@ -135,32 +135,32 @@ function calculateRiskLevel(handId: HandId, params: Record<string, unknown>): Ri
|
||||
function getExpectedImpact(handId: HandId, params: Record<string, unknown>): string {
|
||||
switch (handId) {
|
||||
case 'browser':
|
||||
return `Will perform browser automation on ${params.url || 'specified URL'}`;
|
||||
return `将在 ${params.url || '指定网址'} 执行浏览器自动化`;
|
||||
case 'twitter':
|
||||
if (params.action === 'post') {
|
||||
return 'Will post content to Twitter/X publicly';
|
||||
return '将公开发布内容到 Twitter/X';
|
||||
}
|
||||
if (params.action === 'engage') {
|
||||
return 'Will like/reply to tweets';
|
||||
return '将点赞/回复推文';
|
||||
}
|
||||
return 'Will perform Twitter/X operations';
|
||||
return '将执行 Twitter/X 操作';
|
||||
case 'collector':
|
||||
return `Will collect data from ${params.targetUrl || 'specified source'}`;
|
||||
return `将从 ${params.targetUrl || '指定来源'} 收集数据`;
|
||||
case 'lead':
|
||||
return `Will search for leads from ${params.source || 'specified source'}`;
|
||||
return `将从 ${params.source || '指定来源'} 搜索线索`;
|
||||
case 'clip':
|
||||
return `Will process video: ${params.inputPath || 'specified input'}`;
|
||||
return `将处理视频: ${params.inputPath || '指定输入'}`;
|
||||
case 'predictor':
|
||||
return `Will run prediction on ${params.dataSource || 'specified data'}`;
|
||||
return `将对 ${params.dataSource || '指定数据'} 运行预测`;
|
||||
case 'researcher':
|
||||
return `Will conduct research on: ${params.topic || 'specified topic'}`;
|
||||
return `将研究: ${params.topic || '指定主题'}`;
|
||||
default:
|
||||
return 'Will execute Hand operation';
|
||||
return '将执行 Hand 操作';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeRemaining(seconds: number): string {
|
||||
if (seconds <= 0) return 'Expired';
|
||||
if (seconds <= 0) return '已过期';
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
@@ -218,7 +218,7 @@ function TimeoutProgress({ timeRemaining, totalSeconds }: { timeRemaining: numbe
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Time Remaining
|
||||
剩余时间
|
||||
</span>
|
||||
<span
|
||||
className={`font-medium ${isUrgent ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-gray-300'}`}
|
||||
@@ -241,7 +241,7 @@ function TimeoutProgress({ timeRemaining, totalSeconds }: { timeRemaining: numbe
|
||||
function ParamsDisplay({ params }: { params: Record<string, unknown> }) {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">No parameters provided</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">暂无参数</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ export function HandApprovalModal({
|
||||
runId: handRun.runId,
|
||||
handId,
|
||||
handName: handDef?.name || handId,
|
||||
description: handDef?.description || 'Hand execution request',
|
||||
description: handDef?.description || 'Hand 执行请求',
|
||||
params,
|
||||
riskLevel: calculateRiskLevel(handId, params),
|
||||
expectedImpact: getExpectedImpact(handId, params),
|
||||
@@ -329,7 +329,7 @@ export function HandApprovalModal({
|
||||
await onApprove(approvalData.runId);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to approve');
|
||||
setError(err instanceof Error ? err.message : '批准失败');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -344,7 +344,7 @@ export function HandApprovalModal({
|
||||
}
|
||||
|
||||
if (!rejectReason.trim()) {
|
||||
setError('Please provide a reason for rejection');
|
||||
setError('请提供拒绝原因');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ export function HandApprovalModal({
|
||||
await onReject(approvalData.runId, rejectReason.trim());
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to reject');
|
||||
setError(err instanceof Error ? err.message : '拒绝失败');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -387,10 +387,10 @@ export function HandApprovalModal({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Hand Approval Request
|
||||
Hand 审批请求
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Review and approve Hand execution
|
||||
审核并批准 Hand 执行
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,7 +408,7 @@ export function HandApprovalModal({
|
||||
{isExpired && (
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-900 rounded-lg text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm">This approval request has expired</span>
|
||||
<span className="text-sm">此审批请求已过期</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -439,7 +439,7 @@ export function HandApprovalModal({
|
||||
{/* Parameters */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Execution Parameters
|
||||
执行参数
|
||||
</label>
|
||||
<ParamsDisplay params={approvalData.params} />
|
||||
</div>
|
||||
@@ -449,7 +449,7 @@ export function HandApprovalModal({
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
Expected Impact
|
||||
预期影响
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
|
||||
{approvalData.expectedImpact}
|
||||
@@ -459,9 +459,9 @@ export function HandApprovalModal({
|
||||
|
||||
{/* Request Info */}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<p>Run ID: {approvalData.runId}</p>
|
||||
<p>运行 ID: {approvalData.runId}</p>
|
||||
<p>
|
||||
Requested: {new Date(approvalData.requestedAt).toLocaleString()}
|
||||
请求时间: {new Date(approvalData.requestedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -469,12 +469,12 @@ export function HandApprovalModal({
|
||||
{showRejectInput && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Rejection Reason <span className="text-red-500">*</span>
|
||||
拒绝原因 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Please provide a reason for rejecting this request..."
|
||||
placeholder="请提供拒绝此请求的原因..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
rows={3}
|
||||
autoFocus
|
||||
@@ -502,7 +502,7 @@ export function HandApprovalModal({
|
||||
disabled={isProcessing}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -513,12 +513,12 @@ export function HandApprovalModal({
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Rejecting...
|
||||
拒绝中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Confirm Rejection
|
||||
确认拒绝
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -531,7 +531,7 @@ export function HandApprovalModal({
|
||||
disabled={isProcessing}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Close
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -540,7 +540,7 @@ export function HandApprovalModal({
|
||||
className="px-4 py-2 text-sm border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Reject
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -551,12 +551,12 @@ export function HandApprovalModal({
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Approving...
|
||||
批准中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Approve
|
||||
批准
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -368,7 +368,7 @@ function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInput
|
||||
</div>
|
||||
|
||||
{items.length === 0 && !newItem && (
|
||||
<p className="text-xs text-gray-400 text-center">No items added yet</p>
|
||||
<p className="text-xs text-gray-400 text-center">暂无条目</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -428,10 +428,10 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
||||
onChange={(e) => setFilters((prev) => ({ ...prev, timeRange: e.target.value as SearchFilters['timeRange'] }))}
|
||||
className="text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
>
|
||||
<option value="all">All time</option>
|
||||
<option value="today">Today</option>
|
||||
<option value="week">This week</option>
|
||||
<option value="month">This month</option>
|
||||
<option value="all">全部时间</option>
|
||||
<option value="today">今天</option>
|
||||
<option value="week">本周</option>
|
||||
<option value="month">本月</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -442,7 +442,7 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
|
||||
{/* Search history */}
|
||||
{!query && searchHistory.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mb-1">Recent searches:</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mb-1">最近搜索:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{searchHistory.slice(0, 5).map((item, index) => (
|
||||
<button
|
||||
|
||||
163
desktop/src/components/Settings/FeatureGates.tsx
Normal file
163
desktop/src/components/Settings/FeatureGates.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Feature Gates — 实验性功能开关
|
||||
*
|
||||
* 控制前端实验性功能的可见性。当前为前端运行时开关,
|
||||
* 持久化到 localStorage。未来可对接 Kernel config。
|
||||
*
|
||||
* 可用开关:
|
||||
* - multiAgent: 多 Agent 协作模式 (Director + A2A)
|
||||
* - wasmSkills: WASM 技能沙箱执行
|
||||
* - verboseToolOutput: 显示完整工具输出 (调试用)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '../../lib/logger';
|
||||
|
||||
const log = createLogger('FeatureGates');
|
||||
|
||||
// === Feature Flag Definitions ===
|
||||
|
||||
interface FeatureDef {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
defaultEnabled: boolean;
|
||||
/** Whether this feature has Rust backend support compiled in */
|
||||
backendNote?: string;
|
||||
}
|
||||
|
||||
const FEATURES: FeatureDef[] = [
|
||||
{
|
||||
key: 'multiAgent',
|
||||
label: '多 Agent 协作',
|
||||
description: '启用 Director 编排 + A2A 协议,支持多 Agent 协作完成复杂任务',
|
||||
defaultEnabled: false,
|
||||
backendNote: '需要编译时启用 multi-agent feature',
|
||||
},
|
||||
{
|
||||
key: 'wasmSkills',
|
||||
label: 'WASM 技能沙箱',
|
||||
description: '启用 WebAssembly 技能在沙箱中执行,支持用户自定义技能',
|
||||
defaultEnabled: false,
|
||||
backendNote: '需要编译时启用 wasm feature',
|
||||
},
|
||||
{
|
||||
key: 'verboseToolOutput',
|
||||
label: '详细工具输出',
|
||||
description: '在聊天中显示完整的工具调用结果(默认已截断)',
|
||||
defaultEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
const STORAGE_PREFIX = 'zclaw-feature-';
|
||||
|
||||
function loadFeatureState(key: string, defaultEnabled: boolean): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(`${STORAGE_PREFIX}${key}`);
|
||||
if (stored !== null) return stored === 'true';
|
||||
} catch { /* ignore */ }
|
||||
return defaultEnabled;
|
||||
}
|
||||
|
||||
function saveFeatureState(key: string, enabled: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${key}`, String(enabled));
|
||||
} catch (e) {
|
||||
log.warn('Failed to persist feature flag', { key, error: e });
|
||||
}
|
||||
}
|
||||
|
||||
// === Public API ===
|
||||
|
||||
/** Check if a feature gate is enabled (can be called outside React) */
|
||||
export function isFeatureEnabled(key: string): boolean {
|
||||
const def = FEATURES.find(f => f.key === key);
|
||||
return loadFeatureState(key, def?.defaultEnabled ?? false);
|
||||
}
|
||||
|
||||
// === Component ===
|
||||
|
||||
export function FeatureGates() {
|
||||
const [states, setStates] = useState<Record<string, boolean>>(() => {
|
||||
const initial: Record<string, boolean> = {};
|
||||
for (const f of FEATURES) {
|
||||
initial[f.key] = loadFeatureState(f.key, f.defaultEnabled);
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
|
||||
const toggle = useCallback((key: string) => {
|
||||
setStates(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
saveFeatureState(key, next[key]);
|
||||
log.info(`Feature gate '${key}' set to ${next[key]}`);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900">实验性功能</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
这些功能处于早期开发阶段,可能不稳定。启用后请反馈使用体验。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Warning banner */}
|
||||
<div className="mb-6 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<span className="text-amber-600 text-lg">⚠️</span>
|
||||
<div className="text-sm text-amber-800">
|
||||
<p className="font-medium">实验性功能警告</p>
|
||||
<p className="mt-1">启用实验性功能可能影响系统稳定性。建议在非生产环境中测试。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature toggles */}
|
||||
<div className="space-y-4">
|
||||
{FEATURES.map(feature => (
|
||||
<div
|
||||
key={feature.key}
|
||||
className="flex items-start justify-between gap-4 rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-900">{feature.label}</h3>
|
||||
{states[feature.key] && (
|
||||
<span className="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||
已启用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{feature.description}</p>
|
||||
{feature.backendNote && (
|
||||
<p className="mt-1 text-xs text-gray-400">{feature.backendNote}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(feature.key)}
|
||||
className={`
|
||||
relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
${states[feature.key] ? 'bg-blue-600' : 'bg-gray-200'}
|
||||
`}
|
||||
role="switch"
|
||||
aria-checked={states[feature.key]}
|
||||
aria-label={`切换 ${feature.label}`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0
|
||||
transition duration-200 ease-in-out
|
||||
${states[feature.key] ? 'translate-x-5' : 'translate-x-0'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user