Compare commits
41 Commits
fa5ab4e161
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b0d452845 | ||
|
|
855c89e8fb | ||
|
|
3eb098f020 | ||
|
|
c12b64150b | ||
|
|
4c31471cd6 | ||
|
|
b60b96225d | ||
|
|
06e93a21af | ||
|
|
9060935401 | ||
|
|
6d6673bf5b | ||
|
|
15f84bf8c1 | ||
|
|
9a313e3c92 | ||
|
|
ee5611a2f8 | ||
|
|
5cf7adff69 | ||
|
|
10497362bb | ||
|
|
d7dbdf8600 | ||
|
|
8c25b20fe2 | ||
|
|
87110ffdff | ||
|
|
980a8135fa | ||
|
|
e9e7ffd609 | ||
|
|
00ebf18f23 | ||
|
|
aa84172ca4 | ||
|
|
1c0029001d | ||
|
|
0bb526509d | ||
|
|
394cb66311 | ||
|
|
b56d1a4c34 | ||
|
|
3e78dacef3 | ||
|
|
e64a3ea9a3 | ||
|
|
08812e541c | ||
|
|
17a7a36608 | ||
|
|
5485404c70 | ||
|
|
a09a4c0e0a | ||
|
|
62578d9df4 | ||
|
|
9756d9d995 | ||
|
|
7ba7389093 | ||
|
|
c10e50d58e | ||
|
|
5d88d129d1 | ||
|
|
36612eac53 | ||
|
|
b864973a54 | ||
|
|
73139da57a | ||
|
|
de7d88afcc | ||
|
|
8fd8c02953 |
197
CLAUDE.md
197
CLAUDE.md
@@ -142,21 +142,9 @@ desktop/src-tauri (→ kernel, skills, hands, protocols)
|
|||||||
|
|
||||||
**接到任务后,第一件事是阅读 wiki 获取上下文,而不是直接动手。**
|
**接到任务后,第一件事是阅读 wiki 获取上下文,而不是直接动手。**
|
||||||
|
|
||||||
1. 读取 `wiki/index.md` — 理解全局架构和模块导航
|
1. 读取 `wiki/index.md` — 理解全局架构,利用**症状导航表**快速定位相关模块
|
||||||
2. 根据任务涉及的模块,读取对应的 wiki 页面:
|
2. 读取对应模块页 — 每个模块页统一 5 节结构:设计决策 → 关键文件+集成契约 → 代码逻辑(不变量) → 活跃问题+陷阱 → 变更记录
|
||||||
- 聊天/消息相关 → `wiki/chat.md`
|
3. 如涉及已知问题,检查模块页的"活跃问题"节(全局索引见 `wiki/known-issues.md`)
|
||||||
- 连接/路由相关 → `wiki/routing.md`
|
|
||||||
- 记忆/上下文相关 → `wiki/memory.md`
|
|
||||||
- Agent/分身相关 → `wiki/chat.md` (Agent 部分)
|
|
||||||
- Hands/技能相关 → `wiki/hands-skills.md`
|
|
||||||
- 管家/行业相关 → `wiki/butler.md`
|
|
||||||
- 中间件相关 → `wiki/middleware.md`
|
|
||||||
- SaaS/认证/计费 → `wiki/saas.md`
|
|
||||||
- 安全相关 → `wiki/security.md`
|
|
||||||
- 数据库相关 → `wiki/data-model.md`
|
|
||||||
- Pipeline/工作流 → `wiki/pipeline.md`
|
|
||||||
- 功能链路追踪 → `wiki/feature-map.md`
|
|
||||||
3. 如涉及已知问题,检查 `wiki/known-issues.md`
|
|
||||||
|
|
||||||
**判断标准**: 你能用一句话说清楚"这个改动涉及哪个模块、走哪条数据链路、影响哪些组件"吗?如果不能,你还没读完。
|
**判断标准**: 你能用一句话说清楚"这个改动涉及哪个模块、走哪条数据链路、影响哪些组件"吗?如果不能,你还没读完。
|
||||||
|
|
||||||
@@ -177,10 +165,25 @@ desktop/src-tauri (→ kernel, skills, hands, protocols)
|
|||||||
2. **自动验证** — `cargo check` / `cargo test` / `tsc --noEmit` / `vitest run` 必须通过
|
2. **自动验证** — `cargo check` / `cargo test` / `tsc --noEmit` / `vitest run` 必须通过
|
||||||
3. **回归测试** — 跑受影响 crate 的全量测试,确认无回归
|
3. **回归测试** — 跑受影响 crate 的全量测试,确认无回归
|
||||||
|
|
||||||
#### 阶段 4: 提交 + 同步(立即,不积压)
|
#### 阶段 4: Wiki 同步 + 提交(立即,不积压)
|
||||||
|
|
||||||
1. **提交推送** — 按 §11 规范提交,**立即 `git push`**
|
**Wiki 同步评估(硬门槛,不可跳过)**
|
||||||
2. **文档同步** — 按 §8.3 检查并更新相关文档,提交并推送
|
|
||||||
|
代码改完后、提交前,逐条回答以下问题。任何一条为"是"→ 必须更新对应 wiki 页面:
|
||||||
|
|
||||||
|
| 评估问题 | 为"是"时更新 |
|
||||||
|
|----------|-------------|
|
||||||
|
| 这个改动修复或引入了 bug? | 对应模块页"活跃问题+陷阱"节 + `wiki/known-issues.md` |
|
||||||
|
| 这个改动改变了某个模块的行为或设计理由? | 对应模块页"设计决策"节 |
|
||||||
|
| 这个改动增删了文件或改变了目录结构? | 对应模块页"关键文件"表 |
|
||||||
|
| 这个改动影响了跨模块接口(谁调谁、参数形状、触发时机)? | 涉及双方的"集成契约"表 |
|
||||||
|
| 这个改动涉及一个必须始终成立的约束? | 对应模块页"代码逻辑"节的 ⚡ 不变量 |
|
||||||
|
| 这个改动改变了功能链路(前端→后端的完整路径)? | `wiki/feature-map.md` 索引表 |
|
||||||
|
| 这个改动改变了关键数字(命令数/Store数/测试数等)? | `wiki/index.md` 关键数字表 + `docs/TRUTH.md` |
|
||||||
|
|
||||||
|
全部回答完后,无论是否有更新,都追加一条到 `wiki/log.md` + 更新模块页"变更记录"节(保持 5 条)。
|
||||||
|
|
||||||
|
**提交推送** — 按 §11 规范提交,**立即 `git push`**。详细文档同步规则见 §8.3。
|
||||||
|
|
||||||
**铁律:不允许"等一下再提交"或"最后一起推送"。每个独立工作单元完成后立即推送。**
|
**铁律:不允许"等一下再提交"或"最后一起推送"。每个独立工作单元完成后立即推送。**
|
||||||
|
|
||||||
@@ -386,35 +389,44 @@ docs/
|
|||||||
|
|
||||||
每次完成功能实现、架构变更、问题修复后,**必须立即执行以下收尾**:
|
每次完成功能实现、架构变更、问题修复后,**必须立即执行以下收尾**:
|
||||||
|
|
||||||
#### 步骤 A:文档同步(代码提交前)
|
#### 步骤 A:Wiki 同步(最高优先,代码提交前)
|
||||||
|
|
||||||
检查以下文档是否需要更新,有变更则立即修改:
|
> **为什么 wiki 排第一**:wiki 是新 AI 会话的启动燃料。如果 wiki 与代码不一致,后续所有会话都会基于错误上下文工作,错误会积累放大。
|
||||||
|
|
||||||
|
在 §3.3 阶段 4 的评估表基础上,执行具体更新:
|
||||||
|
|
||||||
|
| 触发事件 | 更新目标 | 更新内容 |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| 修复 bug | 对应模块页"活跃问题+陷阱" | 修复→移除条目;新增→添加条目 |
|
||||||
|
| 架构/设计变更 | 对应模块页"设计决策" | WHY 变了 + 新的权衡取舍 |
|
||||||
|
| 文件增删/移动 | 对应模块页"关键文件"表 | 更新文件列表 |
|
||||||
|
| 跨模块接口变化 | **涉及双方**的"集成契约"表 | 方向/接口/触发时机 |
|
||||||
|
| 发现新的不变量 | 对应模块页"代码逻辑"节 | ⚡ 标记 + 一句话描述 |
|
||||||
|
| 功能链路变化 | `wiki/feature-map.md` | 更新索引表对应行 |
|
||||||
|
| 关键数字变化 | `wiki/index.md` + `docs/TRUTH.md` | 更新数字 + 验证命令 |
|
||||||
|
| **每次收尾** | `wiki/log.md` + 模块页"变更记录" | 追加日志条目 + 变更记录保持 5 条 |
|
||||||
|
|
||||||
|
**wiki 更新原则**:
|
||||||
|
- 只记录代码不能告诉你的东西(WHY、跨模块关系、不变量、历史教训)
|
||||||
|
- 模块页控制在 100-200 行,超出则归档到 `wiki/archive/`
|
||||||
|
- 同一信息只出现在一个页面(单一真相源),其他页面只引用
|
||||||
|
|
||||||
|
#### 步骤 B:其他文档同步
|
||||||
|
|
||||||
1. **CLAUDE.md** — 项目结构、技术栈、工作流程、命令变化时
|
1. **CLAUDE.md** — 项目结构、技术栈、工作流程、命令变化时
|
||||||
2. **CLAUDE.md §13 架构快照** — 涉及子系统变更时,更新 `<!-- ARCH-SNAPSHOT-START/END -->` 标记区域(可执行 `/sync-arch` 技能自动分析)
|
2. **CLAUDE.md §13 架构快照** — 涉及子系统变更时(可执行 `/sync-arch` 技能自动分析)
|
||||||
3. **docs/ARCHITECTURE_BRIEF.md** — 架构决策或关键组件变更时
|
3. **docs/ARCHITECTURE_BRIEF.md** — 架构决策或关键组件变更时
|
||||||
4. **docs/features/** — 功能状态变化时
|
4. **docs/features/** — 功能状态变化时
|
||||||
5. **docs/knowledge-base/** — 新的排查经验或配置说明
|
5. **docs/knowledge-base/** — 新的排查经验或配置说明
|
||||||
6. **wiki/** — 编译后知识库维护(按触发规则更新对应页面):
|
|
||||||
- 修复 bug → 更新 `wiki/known-issues.md`
|
|
||||||
- 架构变更 → 更新对应模块页 (routing/chat/saas/memory/...)
|
|
||||||
- 文件结构变化 → 更新对应模块页的"关键文件"表
|
|
||||||
- 模块状态变化 → 更新对应模块页的"功能清单"表
|
|
||||||
- 功能清单变化 → 更新 `wiki/feature-map.md` 对应链路
|
|
||||||
- API 接口增删 → 更新对应模块页的"API 接口"表
|
|
||||||
- 测试增删 → 更新对应模块页的"测试链路"表
|
|
||||||
- 数字变化 → 更新 `wiki/index.md` 关键数字表 + `docs/TRUTH.md`
|
|
||||||
- 每次更新 → 在 `wiki/log.md` 追加一条记录
|
|
||||||
6. **docs/TRUTH.md** — 数字(命令数、Store 数、crates 数等)变化时
|
|
||||||
|
|
||||||
#### 步骤 B:提交(按逻辑分组)
|
#### 步骤 C:提交(按逻辑分组)
|
||||||
|
|
||||||
```
|
```
|
||||||
代码变更 → 一个或多个逻辑提交
|
代码变更 → 一个或多个逻辑提交
|
||||||
文档变更 → 独立提交(如果和代码分开更清晰)
|
文档变更 → 独立提交(如果和代码分开更清晰)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 步骤 C:推送(立即)
|
#### 步骤 D:推送(立即)
|
||||||
|
|
||||||
```
|
```
|
||||||
git push
|
git push
|
||||||
@@ -572,7 +584,7 @@ refactor(store): 统一 Store 数据获取方式
|
|||||||
***
|
***
|
||||||
|
|
||||||
<!-- ARCH-SNAPSHOT-START -->
|
<!-- ARCH-SNAPSHOT-START -->
|
||||||
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-15 -->
|
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-23 -->
|
||||||
|
|
||||||
## 13. 当前架构快照
|
## 13. 当前架构快照
|
||||||
|
|
||||||
@@ -580,51 +592,53 @@ refactor(store): 统一 Store 数据获取方式
|
|||||||
|
|
||||||
| 子系统 | 状态 | 最新变更 |
|
| 子系统 | 状态 | 最新变更 |
|
||||||
|--------|------|----------|
|
|--------|------|----------|
|
||||||
| 管家模式 (Butler) | ✅ 活跃 | 04-12 行业配置4行业 + 跨会话连续性 + <butler-context> XML fencing |
|
| 管家模式 (Butler) | ✅ 活跃 | 04-23 跨会话身份(soul.md) + 动态建议(4路并行LLM驱动) + Agent tab 移除 |
|
||||||
| Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 |
|
| Hermes 管线 | ✅ 活跃 | 04-23 experience_find_relevant Tauri 命令 + ExperienceBrief + OnceLock 单例 |
|
||||||
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
|
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
|
||||||
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
|
| 聊天流 (ChatStream) | ✅ 活跃 | 04-23 LLM 动态建议(替换硬编码) + 澄清卡片 UX 优化 |
|
||||||
| 记忆管道 (Memory) | ✅ 稳定 | 04-17 E2E 验证: 存储+FTS5+TF-IDF+注入闭环,去重+跨会话注入已修复 |
|
| 记忆管道 (Memory) | ✅ 活跃 | 04-23 身份信号提取(agent_name/user_name) + ProfileSignals 增强 |
|
||||||
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
|
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
|
||||||
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
|
| Pipeline DSL | ✅ 稳定 | 04-01 18 个 YAML 模板 + DAG 执行器 |
|
||||||
| Hands 系统 | ✅ 稳定 | 7 注册 (6 HAND.toml + _reminder),Whiteboard/Slideshow/Speech 开发中 |
|
| Hands 系统 | ✅ 稳定 | 7 注册 (6 HAND.toml + _reminder),Whiteboard/Slideshow/Speech 已删除 |
|
||||||
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
|
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
|
||||||
| 中间件链 | ✅ 稳定 | 13 层 (ButlerRouter@80, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) |
|
| 中间件链 | ✅ 稳定 | 14 层 + 分波并行 (Evolution@78✅, ButlerRouter@80✅, Compaction@100, Memory@150✅, Title@180✅, SkillIndex@200✅, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) — ✅=parallel_safe |
|
||||||
|
|
||||||
### 关键架构模式
|
### 关键架构模式
|
||||||
|
|
||||||
- **Hermes 管线**: 4模块闭环 — ExperienceStore(FTS5经验存取) + UserProfiler(结构化用户画像) + NlScheduleParser(中文时间→cron) + TrajectoryRecorder+Compressor(轨迹记录压缩)。通过中间件链+intelligence hooks调用
|
- **Hermes 管线**: 4模块闭环 — ExperienceStore(FTS5经验存取) + UserProfiler(结构化用户画像) + NlScheduleParser(中文时间→cron) + TrajectoryRecorder+Compressor(轨迹记录压缩)。通过中间件链+intelligence hooks调用
|
||||||
- **管家模式**: 双模式UI (默认简洁/解锁专业) + ButlerRouter 动态行业关键词(4内置+自定义) + <butler-context> XML fencing注入 + 跨会话连续性(痛点回访+经验检索) + 触发信号持久化(VikingStorage) + 冷启动4阶段hook
|
- **管家模式**: 双模式UI (默认简洁/解锁专业) + ButlerRouter 动态行业关键词(4内置+自定义) + <butler-context> XML fencing注入 + 跨会话连续性(痛点回访+经验检索) + 触发信号持久化(VikingStorage) + 冷启动4阶段hook + 跨会话身份(soul.md) + 动态建议(4路并行LLM驱动2续问+1关怀)
|
||||||
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。详见 [ARCHITECTURE_BRIEF.md](docs/ARCHITECTURE_BRIEF.md)
|
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。动态建议: prefetch context + generateLLMSuggestions(1追问+1行动+1关怀) 与 memory extraction 解耦。详见 [ARCHITECTURE_BRIEF.md](docs/ARCHITECTURE_BRIEF.md)
|
||||||
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
|
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
|
||||||
- **SaaS 认证**: JWT→OS keyring 存储 + HttpOnly cookie + Token池 RPM/TPM 限流轮换 + SaaS unreachable 自动降级
|
- **SaaS 认证**: JWT→OS keyring 存储 + HttpOnly cookie + Token池 RPM/TPM 限流轮换 + SaaS unreachable 自动降级
|
||||||
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示(E2E 04-17 验证通过,去重+跨会话注入已修复)
|
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示 + 身份信号提取(agent_name/user_name)→VikingStorage→soul.md→跨会话名字记忆
|
||||||
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
|
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
|
||||||
|
|
||||||
### 最近变更
|
### 最近变更
|
||||||
|
|
||||||
1. [04-21] Embedding 接通 + 自学习自动化 A线+B线: 记忆检索Embedding(GrowthIntegration→MemoryRetriever→SemanticScorer) + Skill路由Embedding+LLM Fallback(替换new_tf_idf_only) + evolution_bridge(SkillCandidate→SkillManifest) + generate_and_register_skill()全链路 + EvolutionMiddleware双模式(auto/suggest) + QualityGate加固(长度/标题/置信度上限)。验证: 934 tests PASS
|
1. [04-23] 回复效率+建议生成并行化: identity prompt 缓存 + pre-hook 并行(tokio::join!) + middleware 分波并行(parallel_safe, 5层✅) + suggestion context 预取 + 建议与 memory 解耦 + prompt 重写(1追问+1行动+1关怀)
|
||||||
2. [04-21] Phase 0+1 突破之路 8 项基础链路修复: 经验积累覆盖修复(reuse_count累积) + Skill工具调用桥接(complete_with_tools) + Hand字段映射(runId) + Heartbeat痛点感知 + Browser委托消息 + 跨会话检索增强(IdentityRecall 26→43模式+弱身份fallback) + Twitter凭据持久化。验证: 912 tests PASS
|
2. [04-23] 动态建议智能化: fetchSuggestionContext 4路并行(用户画像/痛点/经验/技能匹配) + generateLLMSuggestions 混合型 prompt (2续问+1管家关怀) + experience_find_relevant Tauri 命令 + ExperienceBrief
|
||||||
2. [04-17] 全系统 E2E 测试 129 链路: 82 PASS / 20 PARTIAL / 1 FAIL / 26 SKIP,有效通过率 79.1%。7 项 Bug 修复 (Dashboard 404/记忆去重/记忆注入/invoice_id/Prompt版本/agent隔离/行业字段)
|
3. [04-23] 跨会话身份: detectAgentNameSuggestion trigger+extract 两步法(10 trigger) + ProfileSignals agent_name/user_name + soul.md 写回 + Agent tab 移除 (~280 行 dead code 清理)
|
||||||
2. [04-16] 3 项 P0 修复 + 5 项 E2E Bug 修复 + Agent 面板刷新 + TRUTH.md 数字校准
|
4. [04-22] Wiki 全面重构: 5节模板+集成契约+症状导航+归档压缩,净减 ~1,200 行
|
||||||
3. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
|
4. [04-22] 跨会话记忆断裂修复 + DataMasking 中间件移除 + 搜索功能修复(多引擎+质量过滤+SSE行缓冲)
|
||||||
4. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
|
5. [04-21] Embedding 接通 + 自学习自动化 A线+B线 + Phase 0+1 突破之路 8 项链路修复。验证: 934 tests PASS
|
||||||
5. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
|
6. [04-20] 50 轮功能链路审计 7 项断链修复 (42/50 = 84% 通过率)
|
||||||
6. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
|
7. [04-17] 全系统 E2E 测试 129 链路: 82 PASS / 20 PARTIAL / 1 FAIL / 26 SKIP,有效通过率 79.1%
|
||||||
|
|
||||||
|
<!-- ARCH-SNAPSHOT-END -->
|
||||||
|
|
||||||
<!-- ARCH-SNAPSHOT-END -->
|
<!-- ARCH-SNAPSHOT-END -->
|
||||||
|
|
||||||
<!-- ANTI-PATTERN-START -->
|
<!-- ANTI-PATTERN-START -->
|
||||||
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-09 -->
|
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-23 -->
|
||||||
|
|
||||||
## 14. AI 协作注意事项
|
## 14. AI 协作注意事项
|
||||||
|
|
||||||
### 反模式警告
|
### 反模式警告
|
||||||
|
|
||||||
- ❌ **不要**建议新增 SaaS API 端点 — 已有 140 个,稳定化约束禁止新增
|
- ❌ **不要**建议新增 SaaS API 端点 — 已有 137 个,稳定化约束禁止新增
|
||||||
- ❌ **不要**忽略管家模式 — 已上线且为默认模式,所有聊天经过 ButlerRouter
|
- ❌ **不要**忽略管家模式 — 已上线且为默认模式,所有聊天经过 ButlerRouter
|
||||||
- ❌ **不要**假设 Tauri 直连 LLM — 实际通过 SaaS Token 池中转,SaaS unreachable 时降级到本地 Kernel
|
- ❌ **不要**假设 Tauri 直连 LLM — 实际通过 SaaS Token 池中转,SaaS unreachable 时降级到本地 Kernel
|
||||||
- ❌ **不要**建议从零实现已有能力 — 先查 Hand(9个)/Skill(75个)/Pipeline(17模板) 现有库
|
- ❌ **不要**建议从零实现已有能力 — 先查 Hand(7注册)/Skill(75个)/Pipeline(18模板) 现有库
|
||||||
- ❌ **不要**在 CLAUDE.md 以外创建项目级配置或规则文件 — 单一入口原则
|
- ❌ **不要**在 CLAUDE.md 以外创建项目级配置或规则文件 — 单一入口原则
|
||||||
|
|
||||||
### 场景化指令
|
### 场景化指令
|
||||||
@@ -633,6 +647,75 @@ refactor(store): 统一 Store 数据获取方式
|
|||||||
- 当遇到**认证相关** → 记住 Tauri 模式用 OS keyring 存 JWT,SaaS 模式用 HttpOnly cookie
|
- 当遇到**认证相关** → 记住 Tauri 模式用 OS keyring 存 JWT,SaaS 模式用 HttpOnly cookie
|
||||||
- 当遇到**新功能建议** → 先查 [TRUTH.md](docs/TRUTH.md) 确认可用能力清单,避免重复建设
|
- 当遇到**新功能建议** → 先查 [TRUTH.md](docs/TRUTH.md) 确认可用能力清单,避免重复建设
|
||||||
- 当遇到**记忆/上下文相关** → 记住闭环已接通: FTS5+TF-IDF+embedding,不是空壳
|
- 当遇到**记忆/上下文相关** → 记住闭环已接通: FTS5+TF-IDF+embedding,不是空壳
|
||||||
- 当遇到**管家/Butler** → 管家模式是默认模式,ButlerRouter 在中间件链中做关键词分类+system prompt 增强
|
- 当遇到**管家/Butler** → 管家模式是默认模式,ButlerRouter 在中间件链中做关键词分类+system prompt 增强。跨会话身份走 soul.md,动态建议走 4 路并行上下文+LLM
|
||||||
|
|
||||||
<!-- ANTI-PATTERN-END -->
|
<!-- ANTI-PATTERN-END -->
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## 15. Karpathy 编码原则
|
||||||
|
|
||||||
|
> 源自 Andrej Karpathy 对 LLM 编码问题的观察。偏向谨慎而非速度,简单任务可灵活判断。
|
||||||
|
|
||||||
|
### 15.1 Think Before Coding
|
||||||
|
|
||||||
|
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||||
|
|
||||||
|
- State assumptions explicitly. If uncertain, ask.
|
||||||
|
- If multiple interpretations exist, present them — don't pick silently.
|
||||||
|
- If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
- If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
### 15.2 Simplicity First
|
||||||
|
|
||||||
|
**Minimum code that solves the problem. Nothing speculative.**
|
||||||
|
|
||||||
|
- No features beyond what was asked.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
- No error handling for impossible scenarios.
|
||||||
|
- If you write 200 lines and it could be 50, rewrite it.
|
||||||
|
|
||||||
|
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||||
|
|
||||||
|
### 15.3 Surgical Changes
|
||||||
|
|
||||||
|
**Touch only what you must. Clean up only your own mess.**
|
||||||
|
|
||||||
|
When editing existing code:
|
||||||
|
|
||||||
|
- Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
- Don't refactor things that aren't broken.
|
||||||
|
- Match existing style, even if you'd do it differently.
|
||||||
|
- If you notice unrelated dead code, mention it — don't delete it.
|
||||||
|
|
||||||
|
When your changes create orphans:
|
||||||
|
|
||||||
|
- Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
- Don't remove pre-existing dead code unless asked.
|
||||||
|
|
||||||
|
The test: Every changed line should trace directly to the user's request.
|
||||||
|
|
||||||
|
### 15.4 Goal-Driven Execution
|
||||||
|
|
||||||
|
**Define success criteria. Loop until verified.**
|
||||||
|
|
||||||
|
Transform tasks into verifiable goals:
|
||||||
|
|
||||||
|
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||||
|
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||||
|
- "Refactor X" → "Ensure tests pass before and after"
|
||||||
|
|
||||||
|
For multi-step tasks, state a brief plan:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. [Step] → verify: [check]
|
||||||
|
2. [Step] → verify: [check]
|
||||||
|
3. [Step] → verify: [check]
|
||||||
|
```
|
||||||
|
|
||||||
|
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||||
|
|||||||
@@ -253,6 +253,18 @@ impl MemoryExtractor {
|
|||||||
Ok(stored)
|
Ok(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Store a single pre-built MemoryEntry to VikingStorage
|
||||||
|
pub async fn store_memory_entry(&self, entry: &crate::types::MemoryEntry) -> Result<()> {
|
||||||
|
let viking = match &self.viking {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("[MemoryExtractor] No VikingAdapter configured");
|
||||||
|
return Err(zclaw_types::ZclawError::Internal("No VikingAdapter".to_string()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
viking.store(entry).await
|
||||||
|
}
|
||||||
|
|
||||||
/// 统一提取:单次 LLM 调用同时产出 memories + experiences + profile_signals
|
/// 统一提取:单次 LLM 调用同时产出 memories + experiences + profile_signals
|
||||||
///
|
///
|
||||||
/// 优先使用 `extract_with_prompt()` 进行单次调用;若 driver 不支持则
|
/// 优先使用 `extract_with_prompt()` 进行单次调用;若 driver 不支持则
|
||||||
@@ -481,6 +493,16 @@ fn parse_profile_signals(obj: &serde_json::Value) -> crate::types::ProfileSignal
|
|||||||
.and_then(|s| s.get("communication_style"))
|
.and_then(|s| s.get("communication_style"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(String::from),
|
.map(String::from),
|
||||||
|
agent_name: signals
|
||||||
|
.and_then(|s| s.get("agent_name"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
user_name: signals
|
||||||
|
.and_then(|s| s.get("user_name"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +547,22 @@ fn infer_profile_signals_from_memories(
|
|||||||
signals.communication_style = Some(m.content.clone());
|
signals.communication_style = Some(m.content.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 身份信号回退: 从 preference 记忆中检测命名/称呼关键词
|
||||||
|
let lower = m.content.to_lowercase();
|
||||||
|
if lower.contains("叫你") || lower.contains("助手名字") || lower.contains("称呼") {
|
||||||
|
if signals.agent_name.is_none() {
|
||||||
|
// 尝试提取引号内的名字
|
||||||
|
signals.agent_name = extract_quoted_name(&m.content)
|
||||||
|
.or_else(|| extract_name_after_pattern(&lower, &m.content, "叫你"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lower.contains("我叫") || lower.contains("我的名字") || lower.contains("用户名") {
|
||||||
|
if signals.user_name.is_none() {
|
||||||
|
signals.user_name = extract_name_after_pattern(&lower, &m.content, "我叫")
|
||||||
|
.or_else(|| extract_name_after_pattern(&lower, &m.content, "我的名字是"))
|
||||||
|
.or_else(|| extract_name_after_pattern(&lower, &m.content, "我叫"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
crate::types::MemoryType::Knowledge => {
|
crate::types::MemoryType::Knowledge => {
|
||||||
if signals.recent_topic.is_none() && !m.keywords.is_empty() {
|
if signals.recent_topic.is_none() && !m.keywords.is_empty() {
|
||||||
@@ -547,6 +585,38 @@ fn infer_profile_signals_from_memories(
|
|||||||
signals
|
signals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从引号中提取名字(如"以后叫你'小马'"→"小马")
|
||||||
|
fn extract_quoted_name(text: &str) -> Option<String> {
|
||||||
|
for delim in ['"', '\'', '「', '」', '『', '』'] {
|
||||||
|
let mut parts = text.split(delim);
|
||||||
|
parts.next(); // skip before first delimiter
|
||||||
|
if let Some(name) = parts.next() {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if !trimmed.is_empty() && trimmed.chars().count() <= 20 {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从指定模式后提取名字(如"叫你小马"→"小马")
|
||||||
|
fn extract_name_after_pattern(lower: &str, original: &str, pattern: &str) -> Option<String> {
|
||||||
|
if let Some(pos) = lower.find(pattern) {
|
||||||
|
let after = &original[pos + pattern.len()..];
|
||||||
|
// 取第一个词(中文或英文,最多10个字符)
|
||||||
|
let name: String = after
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| !c.is_whitespace() && !matches!(c, ','| '。' | '!' | '?' | ',' | '.' | '!' | '?'))
|
||||||
|
.take(10)
|
||||||
|
.collect();
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Some(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Default extraction prompts for LLM
|
/// Default extraction prompts for LLM
|
||||||
pub mod prompts {
|
pub mod prompts {
|
||||||
use crate::types::MemoryType;
|
use crate::types::MemoryType;
|
||||||
@@ -594,7 +664,9 @@ pub mod prompts {
|
|||||||
"recent_topic": "最近讨论的主要话题(可选)",
|
"recent_topic": "最近讨论的主要话题(可选)",
|
||||||
"pain_point": "用户当前痛点(可选)",
|
"pain_point": "用户当前痛点(可选)",
|
||||||
"preferred_tool": "用户偏好的工具/技能(可选)",
|
"preferred_tool": "用户偏好的工具/技能(可选)",
|
||||||
"communication_style": "沟通风格: concise|detailed|formal|casual(可选)"
|
"communication_style": "沟通风格: concise|detailed|formal|casual(可选)",
|
||||||
|
"agent_name": "用户给助手起的名称(可选,仅在用户明确命名时填写,如'以后叫你小马')",
|
||||||
|
"user_name": "用户提到的自己的名字(可选,仅在用户明确自我介绍时填写,如'我叫张三')"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -604,8 +676,9 @@ pub mod prompts {
|
|||||||
1. **memories**: 提取用户偏好(沟通风格/格式/语言)、知识(事实/领域知识/经验教训)、使用经验(技能/工具使用模式和结果)
|
1. **memories**: 提取用户偏好(沟通风格/格式/语言)、知识(事实/领域知识/经验教训)、使用经验(技能/工具使用模式和结果)
|
||||||
2. **experiences**: 仅提取明确的"问题→解决"模式,要求有清晰的痛点和步骤,confidence >= 0.6
|
2. **experiences**: 仅提取明确的"问题→解决"模式,要求有清晰的痛点和步骤,confidence >= 0.6
|
||||||
3. **profile_signals**: 从对话中推断用户画像信息,只在有明确信号时填写,留空则不填
|
3. **profile_signals**: 从对话中推断用户画像信息,只在有明确信号时填写,留空则不填
|
||||||
4. 每个字段都要有实际内容,不确定的宁可省略
|
4. **identity**: 检测用户是否给助手命名(如"你叫X"/"以后叫你X"/"你的名字是X")或自我介绍(如"我叫X"/"我的名字是X"),填入 agent_name 或 user_name 字段
|
||||||
5. 只返回 JSON,不要附加其他文本
|
5. 每个字段都要有实际内容,不确定的宁可省略
|
||||||
|
6. 只返回 JSON,不要附加其他文本
|
||||||
|
|
||||||
对话内容:
|
对话内容:
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
@@ -432,6 +432,10 @@ pub struct ProfileSignals {
|
|||||||
pub pain_point: Option<String>,
|
pub pain_point: Option<String>,
|
||||||
pub preferred_tool: Option<String>,
|
pub preferred_tool: Option<String>,
|
||||||
pub communication_style: Option<String>,
|
pub communication_style: Option<String>,
|
||||||
|
/// 用户给助手起的名称(如"以后叫你小马")
|
||||||
|
pub agent_name: Option<String>,
|
||||||
|
/// 用户提到的自己的名字(如"我叫张三")
|
||||||
|
pub user_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileSignals {
|
impl ProfileSignals {
|
||||||
@@ -442,6 +446,8 @@ impl ProfileSignals {
|
|||||||
|| self.pain_point.is_some()
|
|| self.pain_point.is_some()
|
||||||
|| self.preferred_tool.is_some()
|
|| self.preferred_tool.is_some()
|
||||||
|| self.communication_style.is_some()
|
|| self.communication_style.is_some()
|
||||||
|
|| self.agent_name.is_some()
|
||||||
|
|| self.user_name.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 有效信号数量
|
/// 有效信号数量
|
||||||
@@ -452,8 +458,15 @@ impl ProfileSignals {
|
|||||||
if self.pain_point.is_some() { count += 1; }
|
if self.pain_point.is_some() { count += 1; }
|
||||||
if self.preferred_tool.is_some() { count += 1; }
|
if self.preferred_tool.is_some() { count += 1; }
|
||||||
if self.communication_style.is_some() { count += 1; }
|
if self.communication_style.is_some() { count += 1; }
|
||||||
|
if self.agent_name.is_some() { count += 1; }
|
||||||
|
if self.user_name.is_some() { count += 1; }
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是否包含身份信号(agent_name 或 user_name)
|
||||||
|
pub fn has_identity_signal(&self) -> bool {
|
||||||
|
self.agent_name.is_some() || self.user_name.is_some()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 进化事件
|
/// 进化事件
|
||||||
@@ -674,8 +687,23 @@ mod tests {
|
|||||||
pain_point: None,
|
pain_point: None,
|
||||||
preferred_tool: Some("researcher".to_string()),
|
preferred_tool: Some("researcher".to_string()),
|
||||||
communication_style: Some("concise".to_string()),
|
communication_style: Some("concise".to_string()),
|
||||||
|
agent_name: None,
|
||||||
|
user_name: None,
|
||||||
};
|
};
|
||||||
assert_eq!(signals.industry.as_deref(), Some("healthcare"));
|
assert_eq!(signals.industry.as_deref(), Some("healthcare"));
|
||||||
assert!(signals.pain_point.is_none());
|
assert!(signals.pain_point.is_none());
|
||||||
|
assert!(!signals.has_identity_signal());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_profile_signals_identity() {
|
||||||
|
let signals = ProfileSignals {
|
||||||
|
agent_name: Some("小马".to_string()),
|
||||||
|
user_name: Some("张三".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(signals.has_identity_signal());
|
||||||
|
assert_eq!(signals.signal_count(), 2);
|
||||||
|
assert_eq!(signals.agent_name.as_deref(), Some("小马"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,9 @@ impl Kernel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use zclaw_runtime::{AgentLoop, tool::builtin::PathValidator};
|
use std::sync::Arc;
|
||||||
|
use zclaw_runtime::{AgentLoop, LlmDriver, tool::builtin::PathValidator};
|
||||||
|
use zclaw_runtime::driver::{RetryDriver, RetryConfig};
|
||||||
|
|
||||||
use super::Kernel;
|
use super::Kernel;
|
||||||
use super::super::MessageResponse;
|
use super::super::MessageResponse;
|
||||||
@@ -161,9 +163,12 @@ impl Kernel {
|
|||||||
let subagent_enabled = chat_mode.as_ref().and_then(|m| m.subagent_enabled).unwrap_or(false);
|
let subagent_enabled = chat_mode.as_ref().and_then(|m| m.subagent_enabled).unwrap_or(false);
|
||||||
let tools = self.create_tool_registry(subagent_enabled);
|
let tools = self.create_tool_registry(subagent_enabled);
|
||||||
self.skill_executor.set_tool_registry(tools.clone());
|
self.skill_executor.set_tool_registry(tools.clone());
|
||||||
|
let driver: Arc<dyn LlmDriver> = Arc::new(
|
||||||
|
RetryDriver::new(self.driver.clone(), RetryConfig::default())
|
||||||
|
);
|
||||||
let mut loop_runner = AgentLoop::new(
|
let mut loop_runner = AgentLoop::new(
|
||||||
*agent_id,
|
*agent_id,
|
||||||
self.driver.clone(),
|
driver,
|
||||||
tools,
|
tools,
|
||||||
self.memory.clone(),
|
self.memory.clone(),
|
||||||
)
|
)
|
||||||
@@ -275,9 +280,12 @@ impl Kernel {
|
|||||||
let subagent_enabled = chat_mode.as_ref().and_then(|m| m.subagent_enabled).unwrap_or(false);
|
let subagent_enabled = chat_mode.as_ref().and_then(|m| m.subagent_enabled).unwrap_or(false);
|
||||||
let tools = self.create_tool_registry(subagent_enabled);
|
let tools = self.create_tool_registry(subagent_enabled);
|
||||||
self.skill_executor.set_tool_registry(tools.clone());
|
self.skill_executor.set_tool_registry(tools.clone());
|
||||||
|
let driver: Arc<dyn LlmDriver> = Arc::new(
|
||||||
|
RetryDriver::new(self.driver.clone(), RetryConfig::default())
|
||||||
|
);
|
||||||
let mut loop_runner = AgentLoop::new(
|
let mut loop_runner = AgentLoop::new(
|
||||||
*agent_id,
|
*agent_id,
|
||||||
self.driver.clone(),
|
driver,
|
||||||
tools,
|
tools,
|
||||||
self.memory.clone(),
|
self.memory.clone(),
|
||||||
)
|
)
|
||||||
@@ -426,6 +434,7 @@ impl Kernel {
|
|||||||
prompt.push_str("- Provide clear options when possible\n");
|
prompt.push_str("- Provide clear options when possible\n");
|
||||||
prompt.push_str("- Include brief context about why you're asking\n");
|
prompt.push_str("- Include brief context about why you're asking\n");
|
||||||
prompt.push_str("- After receiving clarification, proceed immediately\n");
|
prompt.push_str("- After receiving clarification, proceed immediately\n");
|
||||||
|
prompt.push_str("- CRITICAL: When calling ask_clarification, do NOT repeat the options in your text response. The options will be shown in a dedicated card above your reply. Simply greet the user and briefly explain why you need clarification — avoid phrases like \"以下信息\" or \"the following options\" that imply a list follows in your text\n");
|
||||||
|
|
||||||
prompt
|
prompt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ async fn seam_hand_tool_routing() {
|
|||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
stop_reason: "tool_use".to_string(),
|
stop_reason: "tool_use".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
// Second stream: final text after tool executes
|
// Second stream: final text after tool executes
|
||||||
@@ -40,6 +42,8 @@ async fn seam_hand_tool_routing() {
|
|||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
stop_reason: "end_turn".to_string(),
|
stop_reason: "end_turn".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -105,6 +109,8 @@ async fn seam_hand_execution_callback() {
|
|||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
stop_reason: "tool_use".to_string(),
|
stop_reason: "tool_use".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.with_stream_chunks(vec![
|
.with_stream_chunks(vec![
|
||||||
@@ -113,6 +119,8 @@ async fn seam_hand_execution_callback() {
|
|||||||
input_tokens: 5,
|
input_tokens: 5,
|
||||||
output_tokens: 1,
|
output_tokens: 1,
|
||||||
stop_reason: "end_turn".to_string(),
|
stop_reason: "end_turn".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -173,6 +181,8 @@ async fn seam_generic_tool_routing() {
|
|||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
stop_reason: "tool_use".to_string(),
|
stop_reason: "tool_use".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.with_stream_chunks(vec![
|
.with_stream_chunks(vec![
|
||||||
@@ -181,6 +191,8 @@ async fn seam_generic_tool_routing() {
|
|||||||
input_tokens: 5,
|
input_tokens: 5,
|
||||||
output_tokens: 3,
|
output_tokens: 3,
|
||||||
stop_reason: "end_turn".to_string(),
|
stop_reason: "end_turn".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ async fn smoke_hands_full_lifecycle() {
|
|||||||
input_tokens: 15,
|
input_tokens: 15,
|
||||||
output_tokens: 10,
|
output_tokens: 10,
|
||||||
stop_reason: "tool_use".to_string(),
|
stop_reason: "tool_use".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
// After hand_quiz returns, LLM generates final response
|
// After hand_quiz returns, LLM generates final response
|
||||||
@@ -36,6 +38,8 @@ async fn smoke_hands_full_lifecycle() {
|
|||||||
input_tokens: 20,
|
input_tokens: 20,
|
||||||
output_tokens: 5,
|
output_tokens: 5,
|
||||||
stop_reason: "end_turn".to_string(),
|
stop_reason: "end_turn".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use serde_json::Value;
|
||||||
use zclaw_types::{AgentId, Message, SessionId};
|
use zclaw_types::{AgentId, Message, SessionId};
|
||||||
|
|
||||||
use crate::driver::{CompletionRequest, ContentBlock, LlmDriver};
|
use crate::driver::{CompletionRequest, ContentBlock, LlmDriver};
|
||||||
@@ -136,7 +137,7 @@ pub fn update_calibration(estimated: usize, actual: u32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Estimate total tokens for messages with calibration applied.
|
/// Estimate total tokens for messages with calibration applied.
|
||||||
fn estimate_messages_tokens_calibrated(messages: &[Message]) -> usize {
|
pub fn estimate_messages_tokens_calibrated(messages: &[Message]) -> usize {
|
||||||
let raw = estimate_messages_tokens(messages);
|
let raw = estimate_messages_tokens(messages);
|
||||||
let factor = get_calibration_factor();
|
let factor = get_calibration_factor();
|
||||||
if (factor - 1.0).abs() < f64::EPSILON {
|
if (factor - 1.0).abs() < f64::EPSILON {
|
||||||
@@ -178,7 +179,7 @@ pub fn compact_messages(messages: Vec<Message>, keep_recent: usize) -> (Vec<Mess
|
|||||||
let old_messages = &messages[..split_index];
|
let old_messages = &messages[..split_index];
|
||||||
let recent_messages = &messages[split_index..];
|
let recent_messages = &messages[split_index..];
|
||||||
|
|
||||||
let summary = generate_summary(old_messages);
|
let summary = generate_summary(old_messages, None);
|
||||||
let removed_count = old_messages.len();
|
let removed_count = old_messages.len();
|
||||||
|
|
||||||
let mut compacted = Vec::with_capacity(1 + recent_messages.len());
|
let mut compacted = Vec::with_capacity(1 + recent_messages.len());
|
||||||
@@ -188,6 +189,38 @@ pub fn compact_messages(messages: Vec<Message>, keep_recent: usize) -> (Vec<Mess
|
|||||||
(compacted, removed_count)
|
(compacted, removed_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prune old tool outputs to reduce token consumption. Runs before compaction.
|
||||||
|
/// Only prunes ToolResult messages older than PRUNE_AGE_THRESHOLD messages.
|
||||||
|
const PRUNE_AGE_THRESHOLD: usize = 8;
|
||||||
|
const PRUNE_MAX_CHARS: usize = 2000;
|
||||||
|
const PRUNE_KEEP_HEAD_CHARS: usize = 500;
|
||||||
|
|
||||||
|
pub fn prune_tool_outputs(messages: &mut [Message]) -> usize {
|
||||||
|
let total = messages.len();
|
||||||
|
let mut pruned_count = 0;
|
||||||
|
|
||||||
|
for i in 0..total.saturating_sub(PRUNE_AGE_THRESHOLD) {
|
||||||
|
if let Message::ToolResult { output, is_error, .. } = &mut messages[i] {
|
||||||
|
if *is_error { continue; }
|
||||||
|
|
||||||
|
let text = match output {
|
||||||
|
Value::String(ref s) => s.clone(),
|
||||||
|
ref other => other.to_string(),
|
||||||
|
};
|
||||||
|
if text.len() <= PRUNE_MAX_CHARS { continue; }
|
||||||
|
|
||||||
|
let end = text.floor_char_boundary(PRUNE_KEEP_HEAD_CHARS.min(text.len()));
|
||||||
|
*output = serde_json::json!({
|
||||||
|
"_pruned": true,
|
||||||
|
"_original_chars": text.len(),
|
||||||
|
"head": &text[..end],
|
||||||
|
});
|
||||||
|
pruned_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pruned_count
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if compaction should be triggered and perform it if needed.
|
/// Check if compaction should be triggered and perform it if needed.
|
||||||
///
|
///
|
||||||
/// Returns the (possibly compacted) message list.
|
/// Returns the (possibly compacted) message list.
|
||||||
@@ -315,6 +348,18 @@ pub async fn maybe_compact_with_config(
|
|||||||
.iter()
|
.iter()
|
||||||
.take_while(|m| matches!(m, Message::System { .. }))
|
.take_while(|m| matches!(m, Message::System { .. }))
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
|
// Extract previous summary from leading system messages for iterative summarization
|
||||||
|
let previous_summary = messages.iter()
|
||||||
|
.take(leading_system_count)
|
||||||
|
.filter_map(|m| match m {
|
||||||
|
Message::System { content } if content.starts_with("[以下是之前对话的摘要]") => {
|
||||||
|
Some(content.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.next();
|
||||||
|
|
||||||
let keep_from_end = DEFAULT_KEEP_RECENT
|
let keep_from_end = DEFAULT_KEEP_RECENT
|
||||||
.min(messages.len().saturating_sub(leading_system_count));
|
.min(messages.len().saturating_sub(leading_system_count));
|
||||||
let split_index = messages.len().saturating_sub(keep_from_end);
|
let split_index = messages.len().saturating_sub(keep_from_end);
|
||||||
@@ -333,14 +378,16 @@ pub async fn maybe_compact_with_config(
|
|||||||
let recent_messages = &messages[split_index..];
|
let recent_messages = &messages[split_index..];
|
||||||
let removed_count = old_messages.len();
|
let removed_count = old_messages.len();
|
||||||
|
|
||||||
// Step 3: Generate summary (LLM or rule-based)
|
// Step 3: Generate summary (LLM or rule-based), with iterative context
|
||||||
|
let prev_ref = previous_summary.as_deref();
|
||||||
let summary = if config.use_llm {
|
let summary = if config.use_llm {
|
||||||
if let Some(driver) = driver {
|
if let Some(driver) = driver {
|
||||||
match generate_llm_summary(driver, old_messages, config.summary_max_tokens).await {
|
match generate_llm_summary(driver, old_messages, prev_ref, config.summary_max_tokens).await {
|
||||||
Ok(llm_summary) => {
|
Ok(llm_summary) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[Compaction] Generated LLM summary ({} chars)",
|
"[Compaction] Generated LLM summary ({} chars, iterative={})",
|
||||||
llm_summary.len()
|
llm_summary.len(),
|
||||||
|
previous_summary.is_some()
|
||||||
);
|
);
|
||||||
llm_summary
|
llm_summary
|
||||||
}
|
}
|
||||||
@@ -350,7 +397,7 @@ pub async fn maybe_compact_with_config(
|
|||||||
"[Compaction] LLM summary failed: {}, falling back to rules",
|
"[Compaction] LLM summary failed: {}, falling back to rules",
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
generate_summary(old_messages)
|
generate_summary(old_messages, prev_ref)
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"[Compaction] LLM summary failed: {}, returning original messages",
|
"[Compaction] LLM summary failed: {}, returning original messages",
|
||||||
@@ -369,10 +416,10 @@ pub async fn maybe_compact_with_config(
|
|||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"[Compaction] LLM compaction requested but no driver available, using rules"
|
"[Compaction] LLM compaction requested but no driver available, using rules"
|
||||||
);
|
);
|
||||||
generate_summary(old_messages)
|
generate_summary(old_messages, prev_ref)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
generate_summary(old_messages)
|
generate_summary(old_messages, prev_ref)
|
||||||
};
|
};
|
||||||
|
|
||||||
let used_llm = config.use_llm && driver.is_some();
|
let used_llm = config.use_llm && driver.is_some();
|
||||||
@@ -398,9 +445,11 @@ pub async fn maybe_compact_with_config(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a summary using an LLM driver.
|
/// Generate a summary using an LLM driver.
|
||||||
|
/// If `previous_summary` is provided, builds on it iteratively.
|
||||||
async fn generate_llm_summary(
|
async fn generate_llm_summary(
|
||||||
driver: &Arc<dyn LlmDriver>,
|
driver: &Arc<dyn LlmDriver>,
|
||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
|
previous_summary: Option<&str>,
|
||||||
max_tokens: u32,
|
max_tokens: u32,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let mut conversation_text = String::new();
|
let mut conversation_text = String::new();
|
||||||
@@ -437,11 +486,21 @@ async fn generate_llm_summary(
|
|||||||
conversation_text.push_str("\n...(对话已截断)");
|
conversation_text.push_str("\n...(对话已截断)");
|
||||||
}
|
}
|
||||||
|
|
||||||
let prompt = format!(
|
let prompt = match previous_summary {
|
||||||
"请用简洁的中文总结以下对话的关键信息。保留重要的讨论主题、决策、结论和待办事项。\
|
Some(prev) => format!(
|
||||||
输出格式为段落式摘要,不超过200字。\n\n{}",
|
"你是一个对话摘要助手。\n\n\
|
||||||
conversation_text
|
## 上一轮摘要\n{}\n\n\
|
||||||
);
|
## 新增对话内容\n{}\n\n\
|
||||||
|
请在上一轮摘要的基础上更新,保留所有关键决策、用户偏好和文件操作。\
|
||||||
|
输出200字以内的中文摘要。",
|
||||||
|
prev, conversation_text
|
||||||
|
),
|
||||||
|
None => format!(
|
||||||
|
"请用简洁的中文总结以下对话的关键信息。保留重要的讨论主题、决策、结论和待办事项。\
|
||||||
|
输出格式为段落式摘要,不超过200字。\n\n{}",
|
||||||
|
conversation_text
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
let request = CompletionRequest {
|
let request = CompletionRequest {
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
@@ -484,13 +543,22 @@ async fn generate_llm_summary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a rule-based summary of old messages.
|
/// Generate a rule-based summary of old messages.
|
||||||
fn generate_summary(messages: &[Message]) -> String {
|
/// If `previous_summary` is provided, carries forward key info.
|
||||||
|
fn generate_summary(messages: &[Message], previous_summary: Option<&str>) -> String {
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return "[对话开始]".to_string();
|
return "[对话开始]".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut sections: Vec<String> = vec!["[以下是之前对话的摘要]".to_string()];
|
let mut sections: Vec<String> = vec!["[以下是之前对话的摘要]".to_string()];
|
||||||
|
|
||||||
|
// Carry forward previous summary if available
|
||||||
|
if let Some(prev) = previous_summary {
|
||||||
|
// Strip the header line from previous summary for cleaner nesting
|
||||||
|
let prev_body = prev.strip_prefix("[以下是之前对话的摘要]\n")
|
||||||
|
.unwrap_or(prev);
|
||||||
|
sections.push(format!("[上轮摘要保留]: {}", truncate(prev_body, 200)));
|
||||||
|
}
|
||||||
|
|
||||||
let mut user_count = 0;
|
let mut user_count = 0;
|
||||||
let mut assistant_count = 0;
|
let mut assistant_count = 0;
|
||||||
let mut topics: Vec<String> = Vec::new();
|
let mut topics: Vec<String> = Vec::new();
|
||||||
@@ -696,8 +764,21 @@ mod tests {
|
|||||||
Message::user("How does ownership work?"),
|
Message::user("How does ownership work?"),
|
||||||
Message::assistant("Ownership is Rust's memory management system"),
|
Message::assistant("Ownership is Rust's memory management system"),
|
||||||
];
|
];
|
||||||
let summary = generate_summary(&messages);
|
let summary = generate_summary(&messages, None);
|
||||||
assert!(summary.contains("摘要"));
|
assert!(summary.contains("摘要"));
|
||||||
assert!(summary.contains("2"));
|
assert!(summary.contains("2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_summary_iterative() {
|
||||||
|
let messages = vec![
|
||||||
|
Message::user("What is async/await?"),
|
||||||
|
Message::assistant("Async/await is a concurrency model"),
|
||||||
|
];
|
||||||
|
let prev = "[以下是之前对话的摘要]\n讨论主题: Rust; 所有权\n(已压缩 4 条消息)";
|
||||||
|
let summary = generate_summary(&messages, Some(prev));
|
||||||
|
assert!(summary.contains("摘要"));
|
||||||
|
assert!(summary.contains("上轮摘要保留"));
|
||||||
|
assert!(summary.contains("所有权"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ impl LlmDriver for AnthropicDriver {
|
|||||||
let mut byte_stream = response.bytes_stream();
|
let mut byte_stream = response.bytes_stream();
|
||||||
let mut current_tool_id: Option<String> = None;
|
let mut current_tool_id: Option<String> = None;
|
||||||
let mut tool_input_buffer = String::new();
|
let mut tool_input_buffer = String::new();
|
||||||
|
let mut cache_creation_input_tokens: Option<u32> = None;
|
||||||
|
let mut cache_read_input_tokens: Option<u32> = None;
|
||||||
|
|
||||||
while let Some(chunk_result) = byte_stream.next().await {
|
while let Some(chunk_result) = byte_stream.next().await {
|
||||||
let chunk = match chunk_result {
|
let chunk = match chunk_result {
|
||||||
@@ -141,6 +143,15 @@ impl LlmDriver for AnthropicDriver {
|
|||||||
match serde_json::from_str::<AnthropicStreamEvent>(data) {
|
match serde_json::from_str::<AnthropicStreamEvent>(data) {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
match event.event_type.as_str() {
|
match event.event_type.as_str() {
|
||||||
|
"message_start" => {
|
||||||
|
// Capture cache token info from message_start event
|
||||||
|
if let Some(msg) = event.message {
|
||||||
|
if let Some(usage) = msg.usage {
|
||||||
|
cache_creation_input_tokens = usage.cache_creation_input_tokens;
|
||||||
|
cache_read_input_tokens = usage.cache_read_input_tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"content_block_delta" => {
|
"content_block_delta" => {
|
||||||
if let Some(delta) = event.delta {
|
if let Some(delta) = event.delta {
|
||||||
if let Some(text) = delta.text {
|
if let Some(text) = delta.text {
|
||||||
@@ -186,6 +197,8 @@ impl LlmDriver for AnthropicDriver {
|
|||||||
input_tokens: msg.usage.as_ref().map(|u| u.input_tokens).unwrap_or(0),
|
input_tokens: msg.usage.as_ref().map(|u| u.input_tokens).unwrap_or(0),
|
||||||
output_tokens: msg.usage.as_ref().map(|u| u.output_tokens).unwrap_or(0),
|
output_tokens: msg.usage.as_ref().map(|u| u.output_tokens).unwrap_or(0),
|
||||||
stop_reason: msg.stop_reason.unwrap_or_else(|| "end_turn".to_string()),
|
stop_reason: msg.stop_reason.unwrap_or_else(|| "end_turn".to_string()),
|
||||||
|
cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +311,15 @@ impl AnthropicDriver {
|
|||||||
AnthropicRequest {
|
AnthropicRequest {
|
||||||
model: request.model.clone(),
|
model: request.model.clone(),
|
||||||
max_tokens: effective_max,
|
max_tokens: effective_max,
|
||||||
system: request.system.clone(),
|
system: request.system.as_ref().map(|s| {
|
||||||
|
vec![SystemContentBlock {
|
||||||
|
r#type: "text".to_string(),
|
||||||
|
text: s.clone(),
|
||||||
|
cache_control: Some(CacheControl {
|
||||||
|
r#type: "ephemeral".to_string(),
|
||||||
|
}),
|
||||||
|
}]
|
||||||
|
}),
|
||||||
messages,
|
messages,
|
||||||
tools: if tools.is_empty() { None } else { Some(tools) },
|
tools: if tools.is_empty() { None } else { Some(tools) },
|
||||||
temperature: request.temperature,
|
temperature: request.temperature,
|
||||||
@@ -337,18 +358,35 @@ impl AnthropicDriver {
|
|||||||
input_tokens: api_response.usage.input_tokens,
|
input_tokens: api_response.usage.input_tokens,
|
||||||
output_tokens: api_response.usage.output_tokens,
|
output_tokens: api_response.usage.output_tokens,
|
||||||
stop_reason,
|
stop_reason,
|
||||||
|
cache_creation_input_tokens: api_response.usage.cache_creation_input_tokens,
|
||||||
|
cache_read_input_tokens: api_response.usage.cache_read_input_tokens,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anthropic API types
|
// Anthropic API types
|
||||||
|
|
||||||
|
/// Anthropic cache_control 标记
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct CacheControl {
|
||||||
|
r#type: String, // "ephemeral"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anthropic system prompt 内容块(支持 cache_control)
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
struct SystemContentBlock {
|
||||||
|
r#type: String, // "text"
|
||||||
|
text: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
cache_control: Option<CacheControl>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct AnthropicRequest {
|
struct AnthropicRequest {
|
||||||
model: String,
|
model: String,
|
||||||
max_tokens: u32,
|
max_tokens: u32,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
system: Option<String>,
|
system: Option<Vec<SystemContentBlock>>,
|
||||||
messages: Vec<AnthropicMessage>,
|
messages: Vec<AnthropicMessage>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
tools: Option<Vec<AnthropicTool>>,
|
tools: Option<Vec<AnthropicTool>>,
|
||||||
@@ -404,6 +442,10 @@ struct AnthropicContentBlock {
|
|||||||
struct AnthropicUsage {
|
struct AnthropicUsage {
|
||||||
input_tokens: u32,
|
input_tokens: u32,
|
||||||
output_tokens: u32,
|
output_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
cache_creation_input_tokens: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
cache_read_input_tokens: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming types
|
// Streaming types
|
||||||
@@ -458,4 +500,8 @@ struct AnthropicStreamUsage {
|
|||||||
input_tokens: u32,
|
input_tokens: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
output_tokens: u32,
|
output_tokens: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
cache_creation_input_tokens: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
cache_read_input_tokens: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|||||||
139
crates/zclaw-runtime/src/driver/error_classifier.rs
Normal file
139
crates/zclaw-runtime/src/driver/error_classifier.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
//! LLM 错误分类器。将 HTTP 状态码 + 错误体映射为 LlmErrorKind。
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
use zclaw_types::{LlmErrorKind, ClassifiedLlmError};
|
||||||
|
|
||||||
|
/// 分类 LLM 错误
|
||||||
|
pub fn classify_llm_error(
|
||||||
|
provider: &str,
|
||||||
|
status: u16,
|
||||||
|
body: &str,
|
||||||
|
is_timeout: bool,
|
||||||
|
) -> ClassifiedLlmError {
|
||||||
|
let _ = provider; // reserved for per-provider overrides
|
||||||
|
|
||||||
|
if is_timeout {
|
||||||
|
return ClassifiedLlmError {
|
||||||
|
kind: LlmErrorKind::Timeout,
|
||||||
|
retryable: true,
|
||||||
|
should_compress: false,
|
||||||
|
should_rotate_credential: false,
|
||||||
|
retry_after: None,
|
||||||
|
message: "请求超时".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match status {
|
||||||
|
401 | 403 => ClassifiedLlmError {
|
||||||
|
kind: LlmErrorKind::Auth,
|
||||||
|
retryable: false,
|
||||||
|
should_compress: false,
|
||||||
|
should_rotate_credential: true,
|
||||||
|
retry_after: None,
|
||||||
|
message: "认证失败,请检查 API Key".to_string(),
|
||||||
|
},
|
||||||
|
402 => {
|
||||||
|
let is_quota_transient = body.contains("retry")
|
||||||
|
|| body.contains("limit")
|
||||||
|
|| body.contains("usage");
|
||||||
|
ClassifiedLlmError {
|
||||||
|
kind: if is_quota_transient { LlmErrorKind::RateLimited } else { LlmErrorKind::BillingExhausted },
|
||||||
|
retryable: is_quota_transient,
|
||||||
|
should_compress: false,
|
||||||
|
should_rotate_credential: !is_quota_transient,
|
||||||
|
retry_after: if is_quota_transient { Some(Duration::from_secs(30)) } else { None },
|
||||||
|
message: if is_quota_transient { "使用限制,稍后重试".to_string() } else { "计费额度已耗尽".to_string() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
429 => ClassifiedLlmError {
|
||||||
|
kind: LlmErrorKind::RateLimited,
|
||||||
|
retryable: true,
|
||||||
|
should_compress: false,
|
||||||
|
should_rotate_credential: true,
|
||||||
|
retry_after: parse_retry_after(body),
|
||||||
|
message: "速率限制".to_string(),
|
||||||
|
},
|
||||||
|
529 => ClassifiedLlmError {
|
||||||
|
kind: LlmErrorKind::Overloaded,
|
||||||
|
retryable: true,
|
||||||
|
should_compress: false,
|
||||||
|
should_rotate_credential: false,
|
||||||
|
retry_after: Some(Duration::from_secs(5)),
|
||||||
|
message: "提供商过载".to_string(),
|
||||||
|
},
|
||||||
|
500 | 502 => ClassifiedLlmError {
|
||||||
|
kind: LlmErrorKind::ServerError,
|
||||||
|
retryable: true,
|
||||||
|
should_compress: false,
|
||||||
|
should_rotate_credential: false,
|
||||||
|
retry_after: None,
|
||||||
|
message: "服务端错误".to_string(),
|
||||||
|
},
|
||||||
|
503 => ClassifiedLlmError {
|
||||||
|
kind: LlmErrorKind::Overloaded,
|
||||||
|
retryable: true,
|
||||||
|
should_compress: false,
|
||||||
|
should_rotate_credential: false,
|
||||||
|
retry_after: Some(Duration::from_secs(3)),
|
||||||
|
message: "服务暂时不可用".to_string(),
|
||||||
|
},
|
||||||
|
400 => {
|
||||||
|
let is_context_overflow = body.contains("context_length")
|
||||||
|
|| body.contains("max_tokens")
|
||||||
|
|| body.contains("too many tokens")
|
||||||
|
|| body.contains("prompt is too long");
|
||||||
|
ClassifiedLlmError {
|
||||||
|
kind: if is_context_overflow { LlmErrorKind::ContextOverflow } else { LlmErrorKind::Unknown },
|
||||||
|
retryable: false,
|
||||||
|
should_compress: is_context_overflow,
|
||||||
|
should_rotate_credential: false,
|
||||||
|
retry_after: None,
|
||||||
|
message: if is_context_overflow {
|
||||||
|
"上下文过长,需要压缩".to_string()
|
||||||
|
} else {
|
||||||
|
format!("请求错误: {}", &body[..body.len().min(200)])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
404 => ClassifiedLlmError {
|
||||||
|
kind: LlmErrorKind::ModelNotFound,
|
||||||
|
retryable: false,
|
||||||
|
should_compress: false,
|
||||||
|
should_rotate_credential: false,
|
||||||
|
retry_after: None,
|
||||||
|
message: "模型不存在".to_string(),
|
||||||
|
},
|
||||||
|
_ => ClassifiedLlmError {
|
||||||
|
kind: LlmErrorKind::Unknown,
|
||||||
|
retryable: true,
|
||||||
|
should_compress: false,
|
||||||
|
should_rotate_credential: false,
|
||||||
|
retry_after: None,
|
||||||
|
message: format!("未知错误 ({}) {}", status, &body[..body.len().min(200)]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_retry_after(body: &str) -> Option<Duration> {
|
||||||
|
// Anthropic: "Please retry after X seconds"
|
||||||
|
// OpenAI: "Please retry after Xms"
|
||||||
|
if let Some(secs) = extract_retry_seconds(body) {
|
||||||
|
return Some(Duration::from_secs(secs));
|
||||||
|
}
|
||||||
|
if let Some(ms) = extract_retry_millis(body) {
|
||||||
|
return Some(Duration::from_millis(ms));
|
||||||
|
}
|
||||||
|
Some(Duration::from_secs(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_retry_seconds(body: &str) -> Option<u64> {
|
||||||
|
let re = regex::Regex::new(r"retry\s+(?:after\s+)?(\d+)\s*(?:s|sec|seconds?)").ok()?;
|
||||||
|
let caps = re.captures(body)?;
|
||||||
|
caps[1].parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_retry_millis(body: &str) -> Option<u64> {
|
||||||
|
let re = regex::Regex::new(r"retry\s+(?:after\s+)?(\d+)\s*ms").ok()?;
|
||||||
|
let caps = re.captures(body)?;
|
||||||
|
caps[1].parse().ok()
|
||||||
|
}
|
||||||
@@ -238,6 +238,8 @@ impl LlmDriver for GeminiDriver {
|
|||||||
input_tokens,
|
input_tokens,
|
||||||
output_tokens,
|
output_tokens,
|
||||||
stop_reason: stop_reason.to_string(),
|
stop_reason: stop_reason.to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,6 +502,8 @@ impl GeminiDriver {
|
|||||||
input_tokens,
|
input_tokens,
|
||||||
output_tokens,
|
output_tokens,
|
||||||
stop_reason,
|
stop_reason,
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ impl LocalDriver {
|
|||||||
input_tokens,
|
input_tokens,
|
||||||
output_tokens,
|
output_tokens,
|
||||||
stop_reason,
|
stop_reason,
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +398,8 @@ impl LlmDriver for LocalDriver {
|
|||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
stop_reason: "end_turn".to_string(),
|
stop_reason: "end_turn".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ mod anthropic;
|
|||||||
mod openai;
|
mod openai;
|
||||||
mod gemini;
|
mod gemini;
|
||||||
mod local;
|
mod local;
|
||||||
|
mod error_classifier;
|
||||||
|
mod retry_driver;
|
||||||
|
|
||||||
pub use anthropic::AnthropicDriver;
|
pub use anthropic::AnthropicDriver;
|
||||||
pub use openai::OpenAiDriver;
|
pub use openai::OpenAiDriver;
|
||||||
pub use gemini::GeminiDriver;
|
pub use gemini::GeminiDriver;
|
||||||
pub use local::LocalDriver;
|
pub use local::LocalDriver;
|
||||||
|
pub use retry_driver::{RetryDriver, RetryConfig};
|
||||||
|
|
||||||
/// LLM Driver trait - unified interface for all providers
|
/// LLM Driver trait - unified interface for all providers
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -106,6 +109,12 @@ pub struct CompletionResponse {
|
|||||||
pub output_tokens: u32,
|
pub output_tokens: u32,
|
||||||
/// Stop reason
|
/// Stop reason
|
||||||
pub stop_reason: StopReason,
|
pub stop_reason: StopReason,
|
||||||
|
/// Cache creation input tokens (Anthropic prompt caching)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_creation_input_tokens: Option<u32>,
|
||||||
|
/// Cache read input tokens (Anthropic prompt caching)
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_read_input_tokens: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LLM driver response content block (subset of canonical zclaw_types::ContentBlock).
|
/// LLM driver response content block (subset of canonical zclaw_types::ContentBlock).
|
||||||
|
|||||||
@@ -222,10 +222,13 @@ impl LlmDriver for OpenAiDriver {
|
|||||||
let parsed_args: serde_json::Value = if args.is_empty() {
|
let parsed_args: serde_json::Value = if args.is_empty() {
|
||||||
serde_json::json!({})
|
serde_json::json!({})
|
||||||
} else {
|
} else {
|
||||||
serde_json::from_str(args).unwrap_or_else(|e| {
|
match serde_json::from_str(args) {
|
||||||
tracing::warn!("[OpenAI] Failed to parse tool args '{}': {}, using empty object", args, e);
|
Ok(v) => v,
|
||||||
serde_json::json!({})
|
Err(e) => {
|
||||||
})
|
tracing::error!("[OpenAI] Failed to parse tool call '{}' args: {}. Raw: {}", name, e, &args[..args.len().min(200)]);
|
||||||
|
serde_json::json!({ "_parse_error": e.to_string(), "_raw_args": args[..args.len().min(500)].to_string() })
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
yield Ok(StreamChunk::ToolUseEnd {
|
yield Ok(StreamChunk::ToolUseEnd {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
@@ -237,6 +240,8 @@ impl LlmDriver for OpenAiDriver {
|
|||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
stop_reason: "end_turn".to_string(),
|
stop_reason: "end_turn".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -638,6 +643,8 @@ impl OpenAiDriver {
|
|||||||
input_tokens,
|
input_tokens,
|
||||||
output_tokens,
|
output_tokens,
|
||||||
stop_reason,
|
stop_reason,
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,6 +768,8 @@ impl OpenAiDriver {
|
|||||||
StopReason::StopSequence => "stop",
|
StopReason::StopSequence => "stop",
|
||||||
StopReason::Error => "error",
|
StopReason::Error => "error",
|
||||||
}.to_string(),
|
}.to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
123
crates/zclaw-runtime/src/driver/retry_driver.rs
Normal file
123
crates/zclaw-runtime/src/driver/retry_driver.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
//! RetryDriver: LlmDriver 的重试装饰器。
|
||||||
|
//! 仅在本地 Kernel 路径使用,SaaS Relay 已有自己的重试逻辑。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use futures::Stream;
|
||||||
|
use rand::Rng;
|
||||||
|
use zclaw_types::{Result, ZclawError};
|
||||||
|
|
||||||
|
use super::{LlmDriver, CompletionRequest, CompletionResponse, StreamChunk};
|
||||||
|
use super::error_classifier::classify_llm_error;
|
||||||
|
|
||||||
|
/// 重试配置
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RetryConfig {
|
||||||
|
pub max_attempts: u32,
|
||||||
|
pub base_delay_secs: f64,
|
||||||
|
pub max_delay_secs: f64,
|
||||||
|
pub jitter_ratio: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RetryConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_attempts: 3,
|
||||||
|
base_delay_secs: 1.0,
|
||||||
|
max_delay_secs: 8.0,
|
||||||
|
jitter_ratio: 0.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重试装饰器
|
||||||
|
pub struct RetryDriver {
|
||||||
|
inner: Arc<dyn LlmDriver>,
|
||||||
|
config: RetryConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RetryDriver {
|
||||||
|
pub fn new(inner: Arc<dyn LlmDriver>, config: RetryConfig) -> Self {
|
||||||
|
Self { inner, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jittered_backoff(&self, attempt: u32) -> Duration {
|
||||||
|
let base = self.config.base_delay_secs * 2_f64.powi(attempt as i32);
|
||||||
|
let capped = base.min(self.config.max_delay_secs);
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let jitter = capped * self.config.jitter_ratio * rng.gen::<f64>();
|
||||||
|
Duration::from_secs_f64(capped + jitter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LlmDriver for RetryDriver {
|
||||||
|
fn provider(&self) -> &str {
|
||||||
|
self.inner.provider()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
|
||||||
|
let mut last_error: Option<ZclawError> = None;
|
||||||
|
|
||||||
|
for attempt in 0..self.config.max_attempts {
|
||||||
|
match self.inner.complete(request.clone()).await {
|
||||||
|
Ok(response) => return Ok(response),
|
||||||
|
Err(e) => {
|
||||||
|
let message = e.to_string();
|
||||||
|
let status = extract_status_from_error(&message);
|
||||||
|
let classified = classify_llm_error(
|
||||||
|
self.inner.provider(),
|
||||||
|
status,
|
||||||
|
&message,
|
||||||
|
message.contains("timeout") || message.contains("Timeout"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if !classified.retryable {
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if classified.should_compress {
|
||||||
|
return Err(ZclawError::LlmError(
|
||||||
|
format!("[CONTEXT_OVERFLOW] {}", message)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
last_error = Some(e);
|
||||||
|
|
||||||
|
if attempt + 1 < self.config.max_attempts {
|
||||||
|
let delay = classified.retry_after
|
||||||
|
.unwrap_or_else(|| self.jittered_backoff(attempt));
|
||||||
|
tracing::warn!(
|
||||||
|
"[RetryDriver] Attempt {}/{} failed ({}), retrying in {:.1}s",
|
||||||
|
attempt + 1, self.config.max_attempts, classified.message,
|
||||||
|
delay.as_secs_f64()
|
||||||
|
);
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| ZclawError::LlmError("重试耗尽".to_string())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream(
|
||||||
|
&self,
|
||||||
|
request: CompletionRequest,
|
||||||
|
) -> std::pin::Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send + '_>> {
|
||||||
|
// 流式路径不重试——部分 delta 已发送,重试会导致 UI 重复
|
||||||
|
self.inner.stream(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_configured(&self) -> bool {
|
||||||
|
self.inner.is_configured()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_status_from_error(message: &str) -> u16 {
|
||||||
|
let re = regex::Regex::new(r"(?:error|status)[:\s]+(\d{3})").ok();
|
||||||
|
re.and_then(|re| re.captures(message))
|
||||||
|
.and_then(|caps| caps[1].parse().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
@@ -440,6 +440,39 @@ impl GrowthIntegration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store identity signals as special memories for cross-session persistence
|
||||||
|
if combined.profile_signals.has_identity_signal() {
|
||||||
|
let agent_id_str = agent_id.to_string();
|
||||||
|
if let Some(ref agent_name) = combined.profile_signals.agent_name {
|
||||||
|
let entry = zclaw_growth::types::MemoryEntry::new(
|
||||||
|
&agent_id_str,
|
||||||
|
zclaw_growth::types::MemoryType::Preference,
|
||||||
|
"identity",
|
||||||
|
format!("助手的名字是{}", agent_name),
|
||||||
|
).with_importance(8)
|
||||||
|
.with_keywords(vec!["名字".to_string(), "称呼".to_string(), "identity".to_string(), agent_name.clone()]);
|
||||||
|
if let Err(e) = self.extractor.store_memory_entry(&entry).await {
|
||||||
|
tracing::warn!("[GrowthIntegration] Failed to store agent_name signal: {}", e);
|
||||||
|
} else {
|
||||||
|
tracing::info!("[GrowthIntegration] Stored agent_name '{}' for {}", agent_name, agent_id_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref user_name) = combined.profile_signals.user_name {
|
||||||
|
let entry = zclaw_growth::types::MemoryEntry::new(
|
||||||
|
&agent_id_str,
|
||||||
|
zclaw_growth::types::MemoryType::Preference,
|
||||||
|
"identity",
|
||||||
|
format!("用户的名字是{}", user_name),
|
||||||
|
).with_importance(8)
|
||||||
|
.with_keywords(vec!["名字".to_string(), "用户名".to_string(), "identity".to_string(), user_name.clone()]);
|
||||||
|
if let Err(e) = self.extractor.store_memory_entry(&entry).await {
|
||||||
|
tracing::warn!("[GrowthIntegration] Failed to store user_name signal: {}", e);
|
||||||
|
} else {
|
||||||
|
tracing::info!("[GrowthIntegration] Stored user_name '{}' for {}", user_name, agent_id_str);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert extracted memories to structured facts
|
// Convert extracted memories to structured facts
|
||||||
let facts: Vec<Fact> = combined
|
let facts: Vec<Fact> = combined
|
||||||
.memories
|
.memories
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ use std::sync::Arc;
|
|||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use zclaw_types::{AgentId, SessionId, Message, Result};
|
use zclaw_types::{AgentId, SessionId, Message, Result};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::driver::{LlmDriver, CompletionRequest, ContentBlock};
|
use crate::driver::{LlmDriver, CompletionRequest, ContentBlock};
|
||||||
use crate::stream::StreamChunk;
|
use crate::stream::StreamChunk;
|
||||||
use crate::tool::{ToolRegistry, ToolContext, SkillExecutor, HandExecutor};
|
use crate::tool::{ToolRegistry, ToolContext, SkillExecutor, HandExecutor, ToolConcurrency};
|
||||||
use crate::tool::builtin::PathValidator;
|
use crate::tool::builtin::PathValidator;
|
||||||
use crate::growth::GrowthIntegration;
|
use crate::growth::GrowthIntegration;
|
||||||
use crate::compaction::{self, CompactionConfig};
|
use crate::compaction::{self, CompactionConfig};
|
||||||
@@ -303,8 +304,28 @@ impl AgentLoop {
|
|||||||
plan_mode: self.plan_mode,
|
plan_mode: self.plan_mode,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call LLM
|
// Call LLM with context-overflow recovery
|
||||||
let response = self.driver.complete(request).await?;
|
let response = match self.driver.complete(request).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
let err_str = e.to_string();
|
||||||
|
if err_str.contains("[CONTEXT_OVERFLOW]") && self.compaction_threshold > 0 {
|
||||||
|
tracing::warn!("[AgentLoop] Context overflow detected, triggering emergency compaction");
|
||||||
|
let pruned = compaction::prune_tool_outputs(&mut messages);
|
||||||
|
if pruned > 0 {
|
||||||
|
tracing::info!("[AgentLoop] Emergency pruning removed {} tool outputs", pruned);
|
||||||
|
}
|
||||||
|
let keep_recent = messages.len().saturating_sub(messages.len() / 3);
|
||||||
|
let (compacted, removed) = compaction::compact_messages(messages, keep_recent.max(4));
|
||||||
|
if removed > 0 {
|
||||||
|
tracing::info!("[AgentLoop] Emergency compaction removed {} messages", removed);
|
||||||
|
messages = compacted;
|
||||||
|
continue; // retry the iteration with compacted messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
total_input_tokens += response.input_tokens;
|
total_input_tokens += response.input_tokens;
|
||||||
total_output_tokens += response.output_tokens;
|
total_output_tokens += response.output_tokens;
|
||||||
|
|
||||||
@@ -375,21 +396,22 @@ impl AgentLoop {
|
|||||||
let tool_context = self.create_tool_context(session_id.clone());
|
let tool_context = self.create_tool_context(session_id.clone());
|
||||||
let mut abort_result: Option<AgentLoopResult> = None;
|
let mut abort_result: Option<AgentLoopResult> = None;
|
||||||
let mut clarification_result: Option<AgentLoopResult> = None;
|
let mut clarification_result: Option<AgentLoopResult> = None;
|
||||||
for (id, name, input) in tool_calls {
|
|
||||||
// Check if loop was already aborted
|
// Phase 1: Pre-process inputs + middleware checks (serial)
|
||||||
if abort_result.is_some() {
|
struct ToolPlan {
|
||||||
break;
|
idx: usize,
|
||||||
}
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: Value,
|
||||||
|
}
|
||||||
|
let mut plans: Vec<ToolPlan> = Vec::new();
|
||||||
|
for (idx, (id, name, input)) in tool_calls.into_iter().enumerate() {
|
||||||
|
if abort_result.is_some() { break; }
|
||||||
|
|
||||||
// GLM and other models sometimes send tool calls with empty arguments `{}`
|
// GLM and other models sometimes send tool calls with empty arguments `{}`
|
||||||
// Inject the last user message as a fallback query so the tool can infer intent.
|
|
||||||
let input = if input.as_object().map_or(false, |obj| obj.is_empty()) {
|
let input = if input.as_object().map_or(false, |obj| obj.is_empty()) {
|
||||||
if let Some(last_user_msg) = messages.iter().rev().find_map(|m| {
|
if let Some(last_user_msg) = messages.iter().rev().find_map(|m| {
|
||||||
if let Message::User { content } = m {
|
if let Message::User { content } = m { Some(content.clone()) } else { None }
|
||||||
Some(content.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}) {
|
}) {
|
||||||
tracing::info!("[AgentLoop] Tool '{}' received empty input, injecting user message as fallback query", name);
|
tracing::info!("[AgentLoop] Tool '{}' received empty input, injecting user message as fallback query", name);
|
||||||
serde_json::json!({ "_fallback_query": last_user_msg })
|
serde_json::json!({ "_fallback_query": last_user_msg })
|
||||||
@@ -400,101 +422,152 @@ impl AgentLoop {
|
|||||||
input
|
input
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check tool call safety — via middleware chain
|
let mw_ctx = middleware::MiddlewareContext {
|
||||||
{
|
agent_id: self.agent_id.clone(),
|
||||||
let mw_ctx_ref = middleware::MiddlewareContext {
|
session_id: session_id.clone(),
|
||||||
|
user_input: input.to_string(),
|
||||||
|
system_prompt: enhanced_prompt.clone(),
|
||||||
|
messages: messages.clone(),
|
||||||
|
response_content: Vec::new(),
|
||||||
|
input_tokens: total_input_tokens,
|
||||||
|
output_tokens: total_output_tokens,
|
||||||
|
};
|
||||||
|
match self.middleware_chain.run_before_tool_call(&mw_ctx, &name, &input).await? {
|
||||||
|
middleware::ToolCallDecision::Allow => {
|
||||||
|
plans.push(ToolPlan { idx, id, name, input });
|
||||||
|
}
|
||||||
|
middleware::ToolCallDecision::Block(msg) => {
|
||||||
|
tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg);
|
||||||
|
messages.push(Message::tool_result(&id, zclaw_types::ToolId::new(&name), serde_json::json!({ "error": msg }), true));
|
||||||
|
}
|
||||||
|
middleware::ToolCallDecision::ReplaceInput(new_input) => {
|
||||||
|
plans.push(ToolPlan { idx, id, name, input: new_input });
|
||||||
|
}
|
||||||
|
middleware::ToolCallDecision::AbortLoop(reason) => {
|
||||||
|
tracing::warn!("[AgentLoop] Loop aborted by middleware: {}", reason);
|
||||||
|
let msg = format!("{}\n已自动终止", reason);
|
||||||
|
self.memory.append_message(&session_id, &Message::assistant(&msg)).await?;
|
||||||
|
abort_result = Some(AgentLoopResult {
|
||||||
|
response: msg,
|
||||||
|
input_tokens: total_input_tokens,
|
||||||
|
output_tokens: total_output_tokens,
|
||||||
|
iterations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Execute tools (parallel for ReadOnly, serial for others)
|
||||||
|
if abort_result.is_none() && !plans.is_empty() {
|
||||||
|
let (parallel_plans, sequential_plans): (Vec<_>, Vec<_>) = plans.iter()
|
||||||
|
.partition(|p| {
|
||||||
|
self.tools.get(&p.name)
|
||||||
|
.map(|t| t.concurrency())
|
||||||
|
.unwrap_or(ToolConcurrency::Exclusive) == ToolConcurrency::ReadOnly
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut results: std::collections::HashMap<usize, (String, String, serde_json::Value)> = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
// Execute parallel (ReadOnly) tools with JoinSet (max 3 concurrent)
|
||||||
|
if !parallel_plans.is_empty() {
|
||||||
|
let semaphore = Arc::new(tokio::sync::Semaphore::new(3));
|
||||||
|
let mut join_set = tokio::task::JoinSet::new();
|
||||||
|
|
||||||
|
for plan in ¶llel_plans {
|
||||||
|
let tool = self.tools.get(&plan.name).unwrap();
|
||||||
|
let ctx = tool_context.clone();
|
||||||
|
let input = plan.input.clone();
|
||||||
|
let idx = plan.idx;
|
||||||
|
let id = plan.id.clone();
|
||||||
|
let name = plan.name.clone();
|
||||||
|
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||||
|
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
tool.execute(input, &ctx)
|
||||||
|
).await;
|
||||||
|
drop(permit);
|
||||||
|
(idx, id, name, result)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(res) = join_set.join_next().await {
|
||||||
|
match res {
|
||||||
|
Ok((idx, id, name, Ok(Ok(value)))) => {
|
||||||
|
results.insert(idx, (id, name, value));
|
||||||
|
}
|
||||||
|
Ok((idx, id, name, Ok(Err(e)))) => {
|
||||||
|
results.insert(idx, (id, name, serde_json::json!({ "error": e.to_string() })));
|
||||||
|
}
|
||||||
|
Ok((idx, id, name, Err(_))) => {
|
||||||
|
tracing::warn!("[AgentLoop] Tool '{}' timed out after 30s (parallel)", name);
|
||||||
|
results.insert(idx, (id, name.clone(), serde_json::json!({ "error": format!("工具 '{}' 执行超时(30秒),请重试", name) })));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[AgentLoop] JoinError in parallel tool execution: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute sequential (Exclusive/Interactive) tools
|
||||||
|
for plan in &sequential_plans {
|
||||||
|
let tool_result = match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
self.execute_tool(&plan.name, plan.input.clone(), &tool_context),
|
||||||
|
).await {
|
||||||
|
Ok(Ok(result)) => result,
|
||||||
|
Ok(Err(e)) => serde_json::json!({ "error": e.to_string() }),
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!("[AgentLoop] Tool '{}' timed out after 30s", plan.name);
|
||||||
|
serde_json::json!({ "error": format!("工具 '{}' 执行超时(30秒),请重试", plan.name) })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if this is a clarification response
|
||||||
|
if plan.name == "ask_clarification"
|
||||||
|
&& tool_result.get("status").and_then(|v| v.as_str()) == Some("clarification_needed")
|
||||||
|
{
|
||||||
|
tracing::info!("[AgentLoop] Clarification requested, terminating loop");
|
||||||
|
let question = tool_result.get("question")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("需要更多信息")
|
||||||
|
.to_string();
|
||||||
|
results.insert(plan.idx, (plan.id.clone(), plan.name.clone(), tool_result));
|
||||||
|
self.memory.append_message(&session_id, &Message::assistant(&question)).await?;
|
||||||
|
clarification_result = Some(AgentLoopResult {
|
||||||
|
response: question,
|
||||||
|
input_tokens: total_input_tokens,
|
||||||
|
output_tokens: total_output_tokens,
|
||||||
|
iterations,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
results.insert(plan.idx, (plan.id.clone(), plan.name.clone(), tool_result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push results in original tool_call order
|
||||||
|
let mut sorted_indices: Vec<usize> = results.keys().copied().collect();
|
||||||
|
sorted_indices.sort();
|
||||||
|
for idx in sorted_indices {
|
||||||
|
let (id, name, result) = results.remove(&idx).unwrap();
|
||||||
|
// Run after_tool_call middleware (error counting, output guard, etc.)
|
||||||
|
let mut mw_ctx = middleware::MiddlewareContext {
|
||||||
agent_id: self.agent_id.clone(),
|
agent_id: self.agent_id.clone(),
|
||||||
session_id: session_id.clone(),
|
session_id: session_id.clone(),
|
||||||
user_input: input.to_string(),
|
user_input: String::new(),
|
||||||
system_prompt: enhanced_prompt.clone(),
|
system_prompt: enhanced_prompt.clone(),
|
||||||
messages: messages.clone(),
|
messages: messages.clone(),
|
||||||
response_content: Vec::new(),
|
response_content: Vec::new(),
|
||||||
input_tokens: total_input_tokens,
|
input_tokens: total_input_tokens,
|
||||||
output_tokens: total_output_tokens,
|
output_tokens: total_output_tokens,
|
||||||
};
|
};
|
||||||
match self.middleware_chain.run_before_tool_call(&mw_ctx_ref, &name, &input).await? {
|
if let Err(e) = self.middleware_chain.run_after_tool_call(&mut mw_ctx, &name, &result).await {
|
||||||
middleware::ToolCallDecision::Allow => {}
|
tracing::warn!("[AgentLoop] after_tool_call middleware failed for '{}': {}", name, e);
|
||||||
middleware::ToolCallDecision::Block(msg) => {
|
|
||||||
tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg);
|
|
||||||
let error_output = serde_json::json!({ "error": msg });
|
|
||||||
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
middleware::ToolCallDecision::ReplaceInput(new_input) => {
|
|
||||||
// Execute with replaced input (with timeout)
|
|
||||||
let tool_result = match tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(30),
|
|
||||||
self.execute_tool(&name, new_input, &tool_context),
|
|
||||||
).await {
|
|
||||||
Ok(Ok(result)) => result,
|
|
||||||
Ok(Err(e)) => serde_json::json!({ "error": e.to_string() }),
|
|
||||||
Err(_) => {
|
|
||||||
tracing::warn!("[AgentLoop] Tool '{}' (replaced input) timed out after 30s", name);
|
|
||||||
serde_json::json!({ "error": format!("工具 '{}' 执行超时(30秒),请重试", name) })
|
|
||||||
}
|
|
||||||
};
|
|
||||||
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), tool_result, false));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
middleware::ToolCallDecision::AbortLoop(reason) => {
|
|
||||||
tracing::warn!("[AgentLoop] Loop aborted by middleware: {}", reason);
|
|
||||||
let msg = format!("{}\n已自动终止", reason);
|
|
||||||
self.memory.append_message(&session_id, &Message::assistant(&msg)).await?;
|
|
||||||
abort_result = Some(AgentLoopResult {
|
|
||||||
response: msg,
|
|
||||||
input_tokens: total_input_tokens,
|
|
||||||
output_tokens: total_output_tokens,
|
|
||||||
iterations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
messages.push(Message::tool_result(&id, zclaw_types::ToolId::new(&name), result, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
let tool_result = match tokio::time::timeout(
|
|
||||||
std::time::Duration::from_secs(30),
|
|
||||||
self.execute_tool(&name, input, &tool_context),
|
|
||||||
).await {
|
|
||||||
Ok(Ok(result)) => result,
|
|
||||||
Ok(Err(e)) => serde_json::json!({ "error": e.to_string() }),
|
|
||||||
Err(_) => {
|
|
||||||
tracing::warn!("[AgentLoop] Tool '{}' timed out after 30s", name);
|
|
||||||
serde_json::json!({ "error": format!("工具 '{}' 执行超时(30秒),请重试", name) })
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if this is a clarification response — terminate loop immediately
|
|
||||||
// so the LLM waits for user input instead of continuing to generate.
|
|
||||||
if name == "ask_clarification"
|
|
||||||
&& tool_result.get("status").and_then(|v| v.as_str()) == Some("clarification_needed")
|
|
||||||
{
|
|
||||||
tracing::info!("[AgentLoop] Clarification requested, terminating loop");
|
|
||||||
let question = tool_result.get("question")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("需要更多信息")
|
|
||||||
.to_string();
|
|
||||||
messages.push(Message::tool_result(
|
|
||||||
id,
|
|
||||||
zclaw_types::ToolId::new(&name),
|
|
||||||
tool_result,
|
|
||||||
false,
|
|
||||||
));
|
|
||||||
self.memory.append_message(&session_id, &Message::assistant(&question)).await?;
|
|
||||||
clarification_result = Some(AgentLoopResult {
|
|
||||||
response: question,
|
|
||||||
input_tokens: total_input_tokens,
|
|
||||||
output_tokens: total_output_tokens,
|
|
||||||
iterations,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tool result to messages
|
|
||||||
messages.push(Message::tool_result(
|
|
||||||
id,
|
|
||||||
zclaw_types::ToolId::new(&name),
|
|
||||||
tool_result,
|
|
||||||
false, // is_error - we include errors in the result itself
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue the loop - LLM will process tool results and generate final response
|
// Continue the loop - LLM will process tool results and generate final response
|
||||||
@@ -647,6 +720,7 @@ impl AgentLoop {
|
|||||||
|
|
||||||
let mut stream = driver.stream(request);
|
let mut stream = driver.stream(request);
|
||||||
let mut pending_tool_calls: Vec<(String, String, serde_json::Value)> = Vec::new();
|
let mut pending_tool_calls: Vec<(String, String, serde_json::Value)> = Vec::new();
|
||||||
|
let mut completed_tool_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
let mut iteration_text = String::new();
|
let mut iteration_text = String::new();
|
||||||
let mut reasoning_text = String::new(); // Track reasoning separately for API requirement
|
let mut reasoning_text = String::new(); // Track reasoning separately for API requirement
|
||||||
|
|
||||||
@@ -703,6 +777,7 @@ impl AgentLoop {
|
|||||||
// Update with final parsed input and emit ToolStart event
|
// Update with final parsed input and emit ToolStart event
|
||||||
if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) {
|
if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) {
|
||||||
tool.2 = input.clone();
|
tool.2 = input.clone();
|
||||||
|
completed_tool_ids.insert(id.clone());
|
||||||
if let Err(e) = 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);
|
tracing::warn!("[AgentLoop] Failed to send ToolStart event: {}", e);
|
||||||
}
|
}
|
||||||
@@ -810,10 +885,26 @@ impl AgentLoop {
|
|||||||
break 'outer;
|
break 'outer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip tool processing if stream errored or timed out
|
// Handle stream errors — execute complete tool calls, cancel incomplete ones
|
||||||
if stream_errored {
|
if stream_errored {
|
||||||
tracing::debug!("[AgentLoop] Stream errored, skipping tool processing and breaking");
|
// Cancel incomplete tools (ToolStart sent but ToolUseEnd not received)
|
||||||
break 'outer;
|
let incomplete: Vec<_> = pending_tool_calls.iter()
|
||||||
|
.filter(|(id, _, _)| !completed_tool_ids.contains(id))
|
||||||
|
.collect();
|
||||||
|
for (_, name, _) in &incomplete {
|
||||||
|
tracing::warn!("[AgentLoop] Cancelling incomplete tool '{}' due to stream error", name);
|
||||||
|
let error_output = serde_json::json!({ "error": "流式响应中断,工具调用未完成" });
|
||||||
|
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output }).await {
|
||||||
|
tracing::warn!("[AgentLoop] Failed to send cancellation ToolEnd event: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Retain only complete tools for execution
|
||||||
|
pending_tool_calls.retain(|(id, _, _)| completed_tool_ids.contains(id));
|
||||||
|
if pending_tool_calls.is_empty() {
|
||||||
|
tracing::debug!("[AgentLoop] Stream errored with no complete tool calls, breaking");
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
tracing::info!("[AgentLoop] Stream errored but executing {} complete tool calls", pending_tool_calls.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!("[AgentLoop] Processing {} tool calls (reasoning: {} chars)", pending_tool_calls.len(), reasoning_text.len());
|
tracing::debug!("[AgentLoop] Processing {} tool calls (reasoning: {} chars)", pending_tool_calls.len(), reasoning_text.len());
|
||||||
@@ -830,187 +921,192 @@ impl AgentLoop {
|
|||||||
messages.push(Message::tool_use(id, zclaw_types::ToolId::new(name), input.clone()));
|
messages.push(Message::tool_use(id, zclaw_types::ToolId::new(name), input.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute tools
|
// Execute tools — Phase 1: Pre-process through middleware (serial)
|
||||||
for (id, name, input) in pending_tool_calls {
|
struct StreamToolPlan { idx: usize, id: String, name: String, input: Value }
|
||||||
tracing::debug!("[AgentLoop] Executing tool: name={}, input={:?}", name, input);
|
let mut plans: Vec<StreamToolPlan> = Vec::new();
|
||||||
|
let mut abort_loop = false;
|
||||||
|
for (idx, (id, name, input)) in pending_tool_calls.into_iter().enumerate() {
|
||||||
|
if abort_loop { break; }
|
||||||
|
let mw_ctx = middleware::MiddlewareContext {
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
session_id: session_id_clone.clone(),
|
||||||
|
user_input: input.to_string(),
|
||||||
|
system_prompt: enhanced_prompt.clone(),
|
||||||
|
messages: messages.clone(),
|
||||||
|
response_content: Vec::new(),
|
||||||
|
input_tokens: total_input_tokens,
|
||||||
|
output_tokens: total_output_tokens,
|
||||||
|
};
|
||||||
|
match middleware_chain.run_before_tool_call(&mw_ctx, &name, &input).await {
|
||||||
|
Ok(middleware::ToolCallDecision::Allow) => {
|
||||||
|
plans.push(StreamToolPlan { idx, id, name, input });
|
||||||
|
}
|
||||||
|
Ok(middleware::ToolCallDecision::Block(msg)) => {
|
||||||
|
tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg);
|
||||||
|
let error_output = serde_json::json!({ "error": msg });
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
Ok(middleware::ToolCallDecision::ReplaceInput(new_input)) => {
|
||||||
|
plans.push(StreamToolPlan { idx, id, name, input: new_input });
|
||||||
|
}
|
||||||
|
Ok(middleware::ToolCallDecision::AbortLoop(reason)) => {
|
||||||
|
tracing::warn!("[AgentLoop] Loop aborted by middleware: {}", reason);
|
||||||
|
if let Err(e) = tx.send(LoopEvent::Error(reason)).await {
|
||||||
|
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
|
||||||
|
}
|
||||||
|
abort_loop = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[AgentLoop] Middleware error for tool '{}': {}", name, e);
|
||||||
|
let error_output = serde_json::json!({ "error": e.to_string() });
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if abort_loop { break 'outer; }
|
||||||
|
if plans.is_empty() {
|
||||||
|
tracing::debug!("[AgentLoop] No tools to execute after middleware filtering");
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
|
||||||
// Check tool call safety — via middleware chain
|
// Build shared tool context
|
||||||
|
let pv = path_validator.clone().unwrap_or_else(|| {
|
||||||
|
let home = std::env::var("USERPROFILE")
|
||||||
|
.or_else(|_| std::env::var("HOME"))
|
||||||
|
.unwrap_or_else(|_| ".".to_string());
|
||||||
|
PathValidator::new().with_workspace(std::path::PathBuf::from(&home))
|
||||||
|
});
|
||||||
|
let working_dir = pv.workspace_root().map(|p| p.to_string_lossy().to_string());
|
||||||
|
let tool_context = ToolContext {
|
||||||
|
agent_id: agent_id.clone(),
|
||||||
|
working_directory: working_dir,
|
||||||
|
session_id: Some(session_id_clone.to_string()),
|
||||||
|
skill_executor: skill_executor.clone(),
|
||||||
|
hand_executor: hand_executor.clone(),
|
||||||
|
path_validator: Some(pv),
|
||||||
|
event_sender: Some(tx.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Phase 2: Execute tools (parallel for ReadOnly, serial for others)
|
||||||
|
let (parallel_plans, sequential_plans): (Vec<_>, Vec<_>) = plans.iter()
|
||||||
|
.partition(|p| {
|
||||||
|
tools.get(&p.name)
|
||||||
|
.map(|t| t.concurrency())
|
||||||
|
.unwrap_or(ToolConcurrency::Exclusive) == ToolConcurrency::ReadOnly
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut results: std::collections::HashMap<usize, (String, String, serde_json::Value, bool)> = std::collections::HashMap::new();
|
||||||
|
|
||||||
|
// Execute parallel (ReadOnly) tools with JoinSet (max 3 concurrent)
|
||||||
|
if !parallel_plans.is_empty() {
|
||||||
|
let sem = Arc::new(tokio::sync::Semaphore::new(3));
|
||||||
|
let mut join_set = tokio::task::JoinSet::new();
|
||||||
|
for plan in ¶llel_plans {
|
||||||
|
let tool_ctx = tool_context.clone();
|
||||||
|
let input = plan.input.clone();
|
||||||
|
let idx = plan.idx;
|
||||||
|
let id = plan.id.clone();
|
||||||
|
let name = plan.name.clone();
|
||||||
|
let tools_ref = tools.clone();
|
||||||
|
let permit = sem.clone().acquire_owned().await.unwrap();
|
||||||
|
join_set.spawn(async move {
|
||||||
|
let result = if let Some(tool) = tools_ref.get(&name) {
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(30), tool.execute(input, &tool_ctx)).await
|
||||||
|
} else {
|
||||||
|
Ok(Err(zclaw_types::ZclawError::Internal(format!("Unknown tool: {}", name))))
|
||||||
|
};
|
||||||
|
drop(permit);
|
||||||
|
(idx, id, name, result)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
while let Some(res) = join_set.join_next().await {
|
||||||
|
match res {
|
||||||
|
Ok((idx, id, name, Ok(Ok(value)))) => {
|
||||||
|
results.insert(idx, (id, name, value, false));
|
||||||
|
}
|
||||||
|
Ok((idx, id, name, Ok(Err(e)))) => {
|
||||||
|
results.insert(idx, (id, name, serde_json::json!({ "error": e.to_string() }), true));
|
||||||
|
}
|
||||||
|
Ok((idx, id, name, Err(_))) => {
|
||||||
|
tracing::warn!("[AgentLoop] Tool '{}' timed out (parallel, 30s)", name);
|
||||||
|
results.insert(idx, (id, name.clone(), serde_json::json!({ "error": format!("工具 '{}' 执行超时", name) }), true));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[AgentLoop] JoinError in parallel tool execution: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute sequential (Exclusive/Interactive) tools
|
||||||
|
for plan in &sequential_plans {
|
||||||
|
let (result, is_error) = if let Some(tool) = tools.get(&plan.name) {
|
||||||
|
match tool.execute(plan.input.clone(), &tool_context).await {
|
||||||
|
Ok(output) => (output, false),
|
||||||
|
Err(e) => (serde_json::json!({ "error": e.to_string() }), true),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(serde_json::json!({ "error": format!("Unknown tool: {}", plan.name) }), true)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check clarification (only from sequential tools — ask_clarification is Interactive)
|
||||||
|
if plan.name == "ask_clarification"
|
||||||
|
&& result.get("status").and_then(|v| v.as_str()) == Some("clarification_needed")
|
||||||
{
|
{
|
||||||
let mw_ctx = middleware::MiddlewareContext {
|
tracing::info!("[AgentLoop] Streaming: Clarification requested, terminating loop");
|
||||||
|
let question = result.get("question").and_then(|v| v.as_str()).unwrap_or("需要更多信息").to_string();
|
||||||
|
messages.push(Message::tool_result(plan.id.clone(), zclaw_types::ToolId::new(&plan.name), result, is_error));
|
||||||
|
if let Err(e) = tx.send(LoopEvent::Delta(question.clone())).await { tracing::warn!("{}", 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 { tracing::warn!("{}", e); }
|
||||||
|
if let Err(e) = memory.append_message(&session_id_clone, &Message::assistant(&question)).await { tracing::warn!("{}", e); }
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
results.insert(plan.idx, (plan.id.clone(), plan.name.clone(), result, is_error));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: after_tool_call middleware + push results in original order
|
||||||
|
let mut sorted_indices: Vec<usize> = results.keys().copied().collect();
|
||||||
|
sorted_indices.sort();
|
||||||
|
for idx in sorted_indices {
|
||||||
|
let (id, name, result, is_error) = results.remove(&idx).unwrap();
|
||||||
|
|
||||||
|
// Emit ToolEnd event
|
||||||
|
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: result.clone() }).await {
|
||||||
|
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run after_tool_call middleware
|
||||||
|
{
|
||||||
|
let mut mw_ctx = middleware::MiddlewareContext {
|
||||||
agent_id: agent_id.clone(),
|
agent_id: agent_id.clone(),
|
||||||
session_id: session_id_clone.clone(),
|
session_id: session_id_clone.clone(),
|
||||||
user_input: input.to_string(),
|
user_input: String::new(),
|
||||||
system_prompt: enhanced_prompt.clone(),
|
system_prompt: enhanced_prompt.clone(),
|
||||||
messages: messages.clone(),
|
messages: messages.clone(),
|
||||||
response_content: Vec::new(),
|
response_content: Vec::new(),
|
||||||
input_tokens: total_input_tokens,
|
input_tokens: total_input_tokens,
|
||||||
output_tokens: total_output_tokens,
|
output_tokens: total_output_tokens,
|
||||||
};
|
};
|
||||||
match middleware_chain.run_before_tool_call(&mw_ctx, &name, &input).await {
|
if let Err(e) = middleware_chain.run_after_tool_call(&mut mw_ctx, &name, &result).await {
|
||||||
Ok(middleware::ToolCallDecision::Allow) => {}
|
tracing::warn!("[AgentLoop] after_tool_call middleware failed for '{}': {}", name, e);
|
||||||
Ok(middleware::ToolCallDecision::Block(msg)) => {
|
|
||||||
tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg);
|
|
||||||
let error_output = serde_json::json!({ "error": msg });
|
|
||||||
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);
|
|
||||||
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)) => {
|
|
||||||
// Execute with replaced input (same path_validator logic below)
|
|
||||||
let pv = path_validator.clone().unwrap_or_else(|| {
|
|
||||||
let home = std::env::var("USERPROFILE")
|
|
||||||
.or_else(|_| std::env::var("HOME"))
|
|
||||||
.unwrap_or_else(|_| ".".to_string());
|
|
||||||
PathValidator::new().with_workspace(std::path::PathBuf::from(&home))
|
|
||||||
});
|
|
||||||
let working_dir = pv.workspace_root()
|
|
||||||
.map(|p| p.to_string_lossy().to_string());
|
|
||||||
let tool_context = ToolContext {
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
working_directory: working_dir,
|
|
||||||
session_id: Some(session_id_clone.to_string()),
|
|
||||||
skill_executor: skill_executor.clone(),
|
|
||||||
hand_executor: hand_executor.clone(),
|
|
||||||
path_validator: Some(pv),
|
|
||||||
event_sender: Some(tx.clone()),
|
|
||||||
};
|
|
||||||
let (result, is_error) = if let Some(tool) = tools.get(&name) {
|
|
||||||
match tool.execute(new_input, &tool_context).await {
|
|
||||||
Ok(output) => {
|
|
||||||
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() });
|
|
||||||
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) });
|
|
||||||
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));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("[AgentLoop] Middleware error for tool '{}': {}", name, e);
|
|
||||||
let error_output = serde_json::json!({ "error": e.to_string() });
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Use pre-resolved path_validator (already has default fallback from create_tool_context logic)
|
|
||||||
let pv = path_validator.clone().unwrap_or_else(|| {
|
|
||||||
let home = std::env::var("USERPROFILE")
|
|
||||||
.or_else(|_| std::env::var("HOME"))
|
|
||||||
.unwrap_or_else(|_| ".".to_string());
|
|
||||||
PathValidator::new().with_workspace(std::path::PathBuf::from(&home))
|
|
||||||
});
|
|
||||||
let working_dir = pv.workspace_root()
|
|
||||||
.map(|p| p.to_string_lossy().to_string());
|
|
||||||
let tool_context = ToolContext {
|
|
||||||
agent_id: agent_id.clone(),
|
|
||||||
working_directory: working_dir,
|
|
||||||
session_id: Some(session_id_clone.to_string()),
|
|
||||||
skill_executor: skill_executor.clone(),
|
|
||||||
hand_executor: hand_executor.clone(),
|
|
||||||
path_validator: Some(pv),
|
|
||||||
event_sender: Some(tx.clone()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let (result, is_error) = if let Some(tool) = tools.get(&name) {
|
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), result, is_error));
|
||||||
tracing::debug!("[AgentLoop] Tool '{}' found, executing...", name);
|
|
||||||
match tool.execute(input.clone(), &tool_context).await {
|
|
||||||
Ok(output) => {
|
|
||||||
tracing::debug!("[AgentLoop] Tool '{}' executed successfully: {:?}", name, output);
|
|
||||||
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() });
|
|
||||||
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) });
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if this is a clarification response — break outer loop
|
|
||||||
if name == "ask_clarification"
|
|
||||||
&& result.get("status").and_then(|v| v.as_str()) == Some("clarification_needed")
|
|
||||||
{
|
|
||||||
tracing::info!("[AgentLoop] Streaming: Clarification requested, terminating loop");
|
|
||||||
let question = result.get("question")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("需要更多信息")
|
|
||||||
.to_string();
|
|
||||||
messages.push(Message::tool_result(
|
|
||||||
id,
|
|
||||||
zclaw_types::ToolId::new(&name),
|
|
||||||
result,
|
|
||||||
is_error,
|
|
||||||
));
|
|
||||||
// Send the question as final delta so the user sees it
|
|
||||||
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 {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
break 'outer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tool result to message history
|
|
||||||
tracing::debug!("[AgentLoop] Adding tool_result to history: id={}, name={}, is_error={}", id, name, is_error);
|
|
||||||
messages.push(Message::tool_result(
|
|
||||||
id,
|
|
||||||
zclaw_types::ToolId::new(&name),
|
|
||||||
result,
|
|
||||||
is_error,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!("[AgentLoop] Continuing to next iteration for LLM to process tool results");
|
tracing::debug!("[AgentLoop] Continuing to next iteration for LLM to process tool results");
|
||||||
|
// If stream errored, we executed complete tools but cannot continue the LLM loop
|
||||||
|
if stream_errored {
|
||||||
|
tracing::info!("[AgentLoop] Stream was errored — executed salvageable tools, now breaking");
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
// Continue loop - next iteration will call LLM with tool results
|
// Continue loop - next iteration will call LLM with tool results
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
//! | 200-399 | Capability | SkillIndex, Guardrail |
|
//! | 200-399 | Capability | SkillIndex, Guardrail |
|
||||||
//! | 400-599 | Safety | LoopGuard, Guardrail |
|
//! | 400-599 | Safety | LoopGuard, Guardrail |
|
||||||
//! | 600-799 | Telemetry | TokenCalibration, Tracking |
|
//! | 600-799 | Telemetry | TokenCalibration, Tracking |
|
||||||
|
//!
|
||||||
|
//! # Wave parallelization
|
||||||
|
//!
|
||||||
|
//! `before_completion` middlewares that only modify `system_prompt` (not `messages`)
|
||||||
|
//! can declare `parallel_safe() == true`. The chain runs consecutive parallel-safe
|
||||||
|
//! middlewares concurrently, merging their prompt contributions. This reduces
|
||||||
|
//! sequential latency for the context-injection phase.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -50,6 +57,7 @@ pub enum ToolCallDecision {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Carries the mutable state that middleware may inspect or modify.
|
/// Carries the mutable state that middleware may inspect or modify.
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct MiddlewareContext {
|
pub struct MiddlewareContext {
|
||||||
/// The agent that owns this loop.
|
/// The agent that owns this loop.
|
||||||
pub agent_id: AgentId,
|
pub agent_id: AgentId,
|
||||||
@@ -101,6 +109,15 @@ pub trait AgentMiddleware: Send + Sync {
|
|||||||
500
|
500
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `before_completion` is safe to run concurrently with other
|
||||||
|
/// parallel-safe middlewares. Only return `true` if the middleware:
|
||||||
|
/// - Only modifies `ctx.system_prompt` (never `ctx.messages`)
|
||||||
|
/// - Does not depend on prompt modifications from other middlewares
|
||||||
|
/// - Does not return `MiddlewareDecision::Stop`
|
||||||
|
fn parallel_safe(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/// Hook executed **before** the LLM completion request is sent.
|
/// Hook executed **before** the LLM completion request is sent.
|
||||||
///
|
///
|
||||||
/// Use this to inject context (memory, skill index, etc.) or to
|
/// Use this to inject context (memory, skill index, etc.) or to
|
||||||
@@ -163,15 +180,74 @@ impl MiddlewareChain {
|
|||||||
self.middlewares.insert(pos, mw);
|
self.middlewares.insert(pos, mw);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run all `before_completion` hooks in order.
|
/// Run all `before_completion` hooks with wave-based parallelization.
|
||||||
|
///
|
||||||
|
/// Consecutive `parallel_safe` middlewares run concurrently — each gets
|
||||||
|
/// its own cloned context and appends to `system_prompt` independently.
|
||||||
|
/// Their contributions are merged after all complete. Non-parallel-safe
|
||||||
|
/// middlewares (and non-consecutive ones) run sequentially as before.
|
||||||
pub async fn run_before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision> {
|
pub async fn run_before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision> {
|
||||||
for mw in &self.middlewares {
|
let mut idx = 0;
|
||||||
match mw.before_completion(ctx).await? {
|
while idx < self.middlewares.len() {
|
||||||
MiddlewareDecision::Continue => {}
|
// Find the extent of consecutive parallel-safe middlewares
|
||||||
MiddlewareDecision::Stop(reason) => {
|
let wave_start = idx;
|
||||||
tracing::info!("[MiddlewareChain] '{}' requested stop: {}", mw.name(), reason);
|
let mut wave_end = idx;
|
||||||
return Ok(MiddlewareDecision::Stop(reason));
|
while wave_end < self.middlewares.len()
|
||||||
|
&& self.middlewares[wave_end].parallel_safe()
|
||||||
|
{
|
||||||
|
wave_end += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if wave_end - wave_start >= 2 {
|
||||||
|
// Run parallel wave (2+ consecutive parallel-safe middlewares)
|
||||||
|
let base_prompt_len = ctx.system_prompt.len();
|
||||||
|
let wave = &self.middlewares[wave_start..wave_end];
|
||||||
|
|
||||||
|
// Spawn concurrent tasks — each owns its cloned context + Arc ref to middleware
|
||||||
|
let mut join_handles = Vec::with_capacity(wave.len());
|
||||||
|
for mw in wave.iter() {
|
||||||
|
let mut ctx_clone = ctx.clone();
|
||||||
|
let mw_arc = Arc::clone(mw);
|
||||||
|
join_handles.push(tokio::spawn(async move {
|
||||||
|
let result = mw_arc.before_completion(&mut ctx_clone).await;
|
||||||
|
(result, ctx_clone.system_prompt)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Await all and merge prompt contributions
|
||||||
|
for (i, handle) in join_handles.into_iter().enumerate() {
|
||||||
|
let (result, modified_prompt): (Result<MiddlewareDecision>, String) = handle.await
|
||||||
|
.map_err(|e| zclaw_types::ZclawError::Internal(format!("Parallel middleware panicked: {}", e)))?;
|
||||||
|
match result? {
|
||||||
|
MiddlewareDecision::Continue => {}
|
||||||
|
MiddlewareDecision::Stop(reason) => {
|
||||||
|
tracing::info!(
|
||||||
|
"[MiddlewareChain] '{}' requested stop: {}",
|
||||||
|
self.middlewares[wave_start + i].name(),
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
return Ok(MiddlewareDecision::Stop(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Merge system_prompt contribution from this clone
|
||||||
|
if modified_prompt.len() > base_prompt_len {
|
||||||
|
let contribution = &modified_prompt[base_prompt_len..];
|
||||||
|
ctx.system_prompt.push_str(contribution);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx = wave_end;
|
||||||
|
} else {
|
||||||
|
// Run single middleware sequentially
|
||||||
|
let mw = &self.middlewares[idx];
|
||||||
|
match mw.before_completion(ctx).await? {
|
||||||
|
MiddlewareDecision::Continue => {}
|
||||||
|
MiddlewareDecision::Stop(reason) => {
|
||||||
|
tracing::info!("[MiddlewareChain] '{}' requested stop: {}", mw.name(), reason);
|
||||||
|
return Ok(MiddlewareDecision::Stop(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
idx += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(MiddlewareDecision::Continue)
|
Ok(MiddlewareDecision::Continue)
|
||||||
|
|||||||
@@ -290,6 +290,8 @@ impl AgentMiddleware for ButlerRouterMiddleware {
|
|||||||
80
|
80
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parallel_safe(&self) -> bool { true }
|
||||||
|
|
||||||
async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision> {
|
async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision> {
|
||||||
// Only route on the first user message in a turn (not tool results)
|
// Only route on the first user message in a turn (not tool results)
|
||||||
let user_input = &ctx.user_input;
|
let user_input = &ctx.user_input;
|
||||||
|
|||||||
@@ -1,21 +1,49 @@
|
|||||||
//! Compaction middleware — wraps the existing compaction module.
|
//! Compaction middleware — wraps the existing compaction module.
|
||||||
|
//!
|
||||||
|
//! Supports debounce (cooldown + min-round checks), async LLM compression
|
||||||
|
//! with cached fallback, and iterative summaries that carry forward key info.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use zclaw_types::Result;
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
|
||||||
use crate::compaction::{self, CompactionConfig};
|
|
||||||
use crate::growth::GrowthIntegration;
|
|
||||||
use crate::driver::LlmDriver;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use zclaw_types::{Message, Result};
|
||||||
|
use crate::compaction::{self, CompactionConfig};
|
||||||
|
use crate::driver::LlmDriver;
|
||||||
|
use crate::growth::GrowthIntegration;
|
||||||
|
use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};
|
||||||
|
|
||||||
|
/// Minimum seconds between consecutive compactions.
|
||||||
|
const COMPACTION_COOLDOWN_SECS: u64 = 30;
|
||||||
|
/// Minimum message pairs (user+assistant) since last compaction before triggering again.
|
||||||
|
const COMPACTION_MIN_ROUNDS: u64 = 3;
|
||||||
|
|
||||||
|
fn now_millis() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared compaction debounce state (lock-free).
|
||||||
|
struct CompactionState {
|
||||||
|
last_compaction_ms: AtomicU64,
|
||||||
|
last_compaction_msg_count: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached result from a previous async LLM compaction.
|
||||||
|
struct AsyncCompactionCache {
|
||||||
|
last_result: RwLock<Option<Vec<Message>>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Middleware that compresses conversation history when it exceeds a token threshold.
|
/// Middleware that compresses conversation history when it exceeds a token threshold.
|
||||||
pub struct CompactionMiddleware {
|
pub struct CompactionMiddleware {
|
||||||
threshold: usize,
|
threshold: usize,
|
||||||
config: CompactionConfig,
|
config: CompactionConfig,
|
||||||
/// Optional LLM driver for async compaction (LLM summarisation, memory flush).
|
|
||||||
driver: Option<Arc<dyn LlmDriver>>,
|
driver: Option<Arc<dyn LlmDriver>>,
|
||||||
/// Optional growth integration for memory flushing during compaction.
|
|
||||||
growth: Option<GrowthIntegration>,
|
growth: Option<GrowthIntegration>,
|
||||||
|
state: Arc<CompactionState>,
|
||||||
|
cache: Arc<AsyncCompactionCache>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompactionMiddleware {
|
impl CompactionMiddleware {
|
||||||
@@ -25,7 +53,39 @@ impl CompactionMiddleware {
|
|||||||
driver: Option<Arc<dyn LlmDriver>>,
|
driver: Option<Arc<dyn LlmDriver>>,
|
||||||
growth: Option<GrowthIntegration>,
|
growth: Option<GrowthIntegration>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { threshold, config, driver, growth }
|
Self {
|
||||||
|
threshold,
|
||||||
|
config,
|
||||||
|
driver,
|
||||||
|
growth,
|
||||||
|
state: Arc::new(CompactionState {
|
||||||
|
last_compaction_ms: AtomicU64::new(0),
|
||||||
|
last_compaction_msg_count: AtomicU64::new(0),
|
||||||
|
}),
|
||||||
|
cache: Arc::new(AsyncCompactionCache {
|
||||||
|
last_result: RwLock::new(None),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_compact(&self, msg_count: u64) -> bool {
|
||||||
|
let last_ms = self.state.last_compaction_ms.load(Ordering::Relaxed);
|
||||||
|
let last_count = self.state.last_compaction_msg_count.load(Ordering::Relaxed);
|
||||||
|
|
||||||
|
if now_millis().saturating_sub(last_ms) < COMPACTION_COOLDOWN_SECS * 1000 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg_count.saturating_sub(last_count) < COMPACTION_MIN_ROUNDS * 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_compaction(&self, msg_count: u64) {
|
||||||
|
self.state.last_compaction_ms.store(now_millis(), Ordering::Relaxed);
|
||||||
|
self.state.last_compaction_msg_count.store(msg_count, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +99,29 @@ impl AgentMiddleware for CompactionMiddleware {
|
|||||||
return Ok(MiddlewareDecision::Continue);
|
return Ok(MiddlewareDecision::Continue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 1: Prune old tool outputs (cheap, no LLM needed)
|
||||||
|
let pruned = compaction::prune_tool_outputs(&mut ctx.messages);
|
||||||
|
if pruned > 0 {
|
||||||
|
tracing::info!("[CompactionMiddleware] Pruned {} old tool outputs", pruned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Re-estimate tokens after pruning
|
||||||
|
let tokens = compaction::estimate_messages_tokens_calibrated(&ctx.messages);
|
||||||
|
if tokens < self.threshold {
|
||||||
|
return Ok(MiddlewareDecision::Continue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Debounce check
|
||||||
|
if !self.should_compact(ctx.messages.len() as u64) {
|
||||||
|
// Still over threshold but within cooldown — use cached result if available
|
||||||
|
if let Some(cached) = self.cache.last_result.read().await.clone() {
|
||||||
|
tracing::debug!("[CompactionMiddleware] Cooldown active, using cached compaction result");
|
||||||
|
ctx.messages = cached;
|
||||||
|
}
|
||||||
|
return Ok(MiddlewareDecision::Continue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Execute compaction
|
||||||
let needs_async = self.config.use_llm || self.config.memory_flush_enabled;
|
let needs_async = self.config.use_llm || self.config.memory_flush_enabled;
|
||||||
if needs_async {
|
if needs_async {
|
||||||
let outcome = compaction::maybe_compact_with_config(
|
let outcome = compaction::maybe_compact_with_config(
|
||||||
@@ -56,6 +139,14 @@ impl AgentMiddleware for CompactionMiddleware {
|
|||||||
ctx.messages = compaction::maybe_compact(ctx.messages.clone(), self.threshold);
|
ctx.messages = compaction::maybe_compact(ctx.messages.clone(), self.threshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.record_compaction(ctx.messages.len() as u64);
|
||||||
|
|
||||||
|
// Cache result for cooldown fallback
|
||||||
|
{
|
||||||
|
let mut cache = self.cache.last_result.write().await;
|
||||||
|
*cache = Some(ctx.messages.clone());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(MiddlewareDecision::Continue)
|
Ok(MiddlewareDecision::Continue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ impl AgentMiddleware for EvolutionMiddleware {
|
|||||||
78 // 在 ButlerRouter(80) 之前
|
78 // 在 ButlerRouter(80) 之前
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parallel_safe(&self) -> bool { true }
|
||||||
|
|
||||||
async fn before_completion(
|
async fn before_completion(
|
||||||
&self,
|
&self,
|
||||||
ctx: &mut MiddlewareContext,
|
ctx: &mut MiddlewareContext,
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ impl MemoryMiddleware {
|
|||||||
impl AgentMiddleware for MemoryMiddleware {
|
impl AgentMiddleware for MemoryMiddleware {
|
||||||
fn name(&self) -> &str { "memory" }
|
fn name(&self) -> &str { "memory" }
|
||||||
fn priority(&self) -> i32 { 150 }
|
fn priority(&self) -> i32 { 150 }
|
||||||
|
fn parallel_safe(&self) -> bool { true }
|
||||||
|
|
||||||
async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision> {
|
async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision> {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ impl SkillIndexMiddleware {
|
|||||||
impl AgentMiddleware for SkillIndexMiddleware {
|
impl AgentMiddleware for SkillIndexMiddleware {
|
||||||
fn name(&self) -> &str { "skill_index" }
|
fn name(&self) -> &str { "skill_index" }
|
||||||
fn priority(&self) -> i32 { 200 }
|
fn priority(&self) -> i32 { 200 }
|
||||||
|
fn parallel_safe(&self) -> bool { true }
|
||||||
|
|
||||||
async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision> {
|
async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision> {
|
||||||
if self.entries.is_empty() {
|
if self.entries.is_empty() {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ impl Default for TitleMiddleware {
|
|||||||
impl AgentMiddleware for TitleMiddleware {
|
impl AgentMiddleware for TitleMiddleware {
|
||||||
fn name(&self) -> &str { "title" }
|
fn name(&self) -> &str { "title" }
|
||||||
fn priority(&self) -> i32 { 180 }
|
fn priority(&self) -> i32 { 180 }
|
||||||
|
fn parallel_safe(&self) -> bool { true }
|
||||||
|
|
||||||
// All hooks default to Continue — placeholder until LLM driver is wired in.
|
// All hooks default to Continue — placeholder until LLM driver is wired in.
|
||||||
async fn before_completion(&self, _ctx: &mut crate::middleware::MiddlewareContext) -> zclaw_types::Result<MiddlewareDecision> {
|
async fn before_completion(&self, _ctx: &mut crate::middleware::MiddlewareContext) -> zclaw_types::Result<MiddlewareDecision> {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use serde_json::Value;
|
|||||||
use zclaw_types::Result;
|
use zclaw_types::Result;
|
||||||
use crate::driver::ContentBlock;
|
use crate::driver::ContentBlock;
|
||||||
use crate::middleware::{AgentMiddleware, MiddlewareContext, ToolCallDecision};
|
use crate::middleware::{AgentMiddleware, MiddlewareContext, ToolCallDecision};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// Middleware that intercepts tool call errors and formats recovery messages.
|
/// Middleware that intercepts tool call errors and formats recovery messages.
|
||||||
@@ -23,8 +24,8 @@ pub struct ToolErrorMiddleware {
|
|||||||
max_error_length: usize,
|
max_error_length: usize,
|
||||||
/// Maximum consecutive failures before aborting the loop.
|
/// Maximum consecutive failures before aborting the loop.
|
||||||
max_consecutive_failures: u32,
|
max_consecutive_failures: u32,
|
||||||
/// Tracks consecutive tool failures.
|
/// Tracks consecutive tool failures per session.
|
||||||
consecutive_failures: Mutex<u32>,
|
session_failures: Mutex<HashMap<String, u32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolErrorMiddleware {
|
impl ToolErrorMiddleware {
|
||||||
@@ -32,7 +33,7 @@ impl ToolErrorMiddleware {
|
|||||||
Self {
|
Self {
|
||||||
max_error_length: 500,
|
max_error_length: 500,
|
||||||
max_consecutive_failures: 3,
|
max_consecutive_failures: 3,
|
||||||
consecutive_failures: Mutex::new(0),
|
session_failures: Mutex::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ impl AgentMiddleware for ToolErrorMiddleware {
|
|||||||
|
|
||||||
async fn before_tool_call(
|
async fn before_tool_call(
|
||||||
&self,
|
&self,
|
||||||
_ctx: &MiddlewareContext,
|
ctx: &MiddlewareContext,
|
||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
tool_input: &Value,
|
tool_input: &Value,
|
||||||
) -> Result<ToolCallDecision> {
|
) -> Result<ToolCallDecision> {
|
||||||
@@ -79,15 +80,17 @@ impl AgentMiddleware for ToolErrorMiddleware {
|
|||||||
return Ok(ToolCallDecision::ReplaceInput(serde_json::json!({})));
|
return Ok(ToolCallDecision::ReplaceInput(serde_json::json!({})));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check consecutive failure count — abort if too many failures
|
// Check consecutive failure count — abort if too many failures (per session)
|
||||||
let failures = self.consecutive_failures.lock().unwrap_or_else(|e| e.into_inner());
|
let failures = self.session_failures.lock()
|
||||||
if *failures >= self.max_consecutive_failures {
|
.map(|m| m.get(&ctx.session_id.to_string()).copied().unwrap_or(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
if failures >= self.max_consecutive_failures {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"[ToolErrorMiddleware] Aborting loop: {} consecutive tool failures",
|
"[ToolErrorMiddleware] Aborting loop: {} consecutive tool failures",
|
||||||
*failures
|
failures
|
||||||
);
|
);
|
||||||
return Ok(ToolCallDecision::AbortLoop(
|
return Ok(ToolCallDecision::AbortLoop(
|
||||||
format!("连续 {} 次工具调用失败,已自动终止以避免无限重试", *failures)
|
format!("连续 {} 次工具调用失败,已自动终止以避免无限重试", failures)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,11 +103,16 @@ impl AgentMiddleware for ToolErrorMiddleware {
|
|||||||
tool_name: &str,
|
tool_name: &str,
|
||||||
result: &Value,
|
result: &Value,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut failures = self.consecutive_failures.lock().unwrap_or_else(|e| e.into_inner());
|
|
||||||
|
|
||||||
// Check if the tool result indicates an error.
|
// Check if the tool result indicates an error.
|
||||||
if let Some(error) = result.get("error") {
|
if let Some(error) = result.get("error") {
|
||||||
*failures += 1;
|
let session_key = ctx.session_id.to_string();
|
||||||
|
let failures = self.session_failures.lock()
|
||||||
|
.map(|mut m| {
|
||||||
|
let count = m.entry(session_key.clone()).or_insert(0);
|
||||||
|
*count += 1;
|
||||||
|
*count
|
||||||
|
})
|
||||||
|
.unwrap_or(1);
|
||||||
let error_msg = match error {
|
let error_msg = match error {
|
||||||
Value::String(s) => s.clone(),
|
Value::String(s) => s.clone(),
|
||||||
other => other.to_string(),
|
other => other.to_string(),
|
||||||
@@ -118,7 +126,7 @@ impl AgentMiddleware for ToolErrorMiddleware {
|
|||||||
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"[ToolErrorMiddleware] Tool '{}' failed ({}/{} consecutive): {}",
|
"[ToolErrorMiddleware] Tool '{}' failed ({}/{} consecutive): {}",
|
||||||
tool_name, *failures, self.max_consecutive_failures, truncated
|
tool_name, failures, self.max_consecutive_failures, truncated
|
||||||
);
|
);
|
||||||
|
|
||||||
let guided_message = self.format_tool_error(tool_name, &truncated);
|
let guided_message = self.format_tool_error(tool_name, &truncated);
|
||||||
@@ -126,8 +134,11 @@ impl AgentMiddleware for ToolErrorMiddleware {
|
|||||||
text: guided_message,
|
text: guided_message,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Success — reset consecutive failure counter
|
// Success — reset consecutive failure counter for this session
|
||||||
*failures = 0;
|
let session_key = ctx.session_id.to_string();
|
||||||
|
if let Ok(mut m) = self.session_failures.lock() {
|
||||||
|
m.insert(session_key, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -21,35 +21,27 @@ use crate::middleware::{AgentMiddleware, MiddlewareContext, ToolCallDecision};
|
|||||||
/// Maximum safe output length in characters.
|
/// Maximum safe output length in characters.
|
||||||
const MAX_OUTPUT_LENGTH: usize = 50_000;
|
const MAX_OUTPUT_LENGTH: usize = 50_000;
|
||||||
|
|
||||||
/// Patterns that indicate sensitive information in tool output.
|
/// Regex patterns that match actual secret values (not just keywords).
|
||||||
const SENSITIVE_PATTERNS: &[&str] = &[
|
/// These detect the *value format* of secrets, avoiding false positives
|
||||||
"api_key",
|
/// from legitimate content that merely mentions "password" or "api_key".
|
||||||
"apikey",
|
const SECRET_VALUE_PATTERNS: &[&str] = &[
|
||||||
"api-key",
|
r#"sk-[a-zA-Z0-9]{20,}"#, // OpenAI API keys (sk-xxx, 20+ chars)
|
||||||
"secret_key",
|
r#"sk_live_[a-zA-Z0-9]{20,}"#, // Stripe live keys
|
||||||
"secretkey",
|
r#"sk_test_[a-zA-Z0-9]{20,}"#, // Stripe test keys
|
||||||
"access_token",
|
r#"AKIA[A-Z0-9]{16}"#, // AWS access keys (exact 20 chars)
|
||||||
"auth_token",
|
r#"-----BEGIN (RSA |EC )?PRIVATE KEY-----"#, // PEM private keys
|
||||||
"password",
|
r#"(?:api_?key|secret_?key|access_?token|auth_?token|password)\s*[:=]\s*["'][^"']{8,}["']"#, // key=value with actual secret
|
||||||
"private_key",
|
|
||||||
"-----BEGIN RSA",
|
|
||||||
"-----BEGIN PRIVATE",
|
|
||||||
"sk-", // OpenAI API keys
|
|
||||||
"sk_live_", // Stripe keys
|
|
||||||
"AKIA", // AWS access keys
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Patterns that may indicate prompt injection in tool output.
|
/// Keyword patterns that indicate prompt injection in tool output.
|
||||||
|
/// These are specific enough to avoid false positives from normal content.
|
||||||
const INJECTION_PATTERNS: &[&str] = &[
|
const INJECTION_PATTERNS: &[&str] = &[
|
||||||
"ignore previous instructions",
|
"ignore previous instructions",
|
||||||
"ignore all previous",
|
"ignore all previous",
|
||||||
"disregard your instructions",
|
"disregard your instructions",
|
||||||
"you are now",
|
|
||||||
"new instructions:",
|
"new instructions:",
|
||||||
"system:",
|
|
||||||
"[INST]",
|
"[INST]",
|
||||||
"</scratchpad>",
|
"</scratchpad>",
|
||||||
"think step by step about",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Tool output sanitization middleware.
|
/// Tool output sanitization middleware.
|
||||||
@@ -105,22 +97,24 @@ impl AgentMiddleware for ToolOutputGuardMiddleware {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 2: Sensitive information detection — block output containing secrets (P2-22)
|
// Rule 2: Sensitive information detection — match actual secret values, not keywords
|
||||||
let output_lower = output_str.to_lowercase();
|
for pattern in SECRET_VALUE_PATTERNS {
|
||||||
for pattern in SENSITIVE_PATTERNS {
|
if let Ok(re) = regex::Regex::new(pattern) {
|
||||||
if output_lower.contains(pattern) {
|
if re.is_match(&output_str) {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"[ToolOutputGuard] BLOCKED tool '{}' output: sensitive pattern '{}'",
|
"[ToolOutputGuard] BLOCKED tool '{}' output: secret value matched pattern '{}'",
|
||||||
tool_name, pattern
|
tool_name, pattern
|
||||||
);
|
);
|
||||||
return Err(zclaw_types::ZclawError::Internal(format!(
|
return Err(zclaw_types::ZclawError::Internal(format!(
|
||||||
"[ToolOutputGuard] Tool '{}' output blocked: sensitive information detected ('{}')",
|
"[ToolOutputGuard] Tool '{}' output blocked: sensitive information detected",
|
||||||
tool_name, pattern
|
tool_name
|
||||||
)));
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 3: Injection marker detection — BLOCK the output (P2-22 fix)
|
// Rule 3: Injection marker detection — specific phrase matching
|
||||||
|
let output_lower = output_str.to_lowercase();
|
||||||
for pattern in INJECTION_PATTERNS {
|
for pattern in INJECTION_PATTERNS {
|
||||||
if output_lower.contains(pattern) {
|
if output_lower.contains(pattern) {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ pub enum StreamChunk {
|
|||||||
input_tokens: u32,
|
input_tokens: u32,
|
||||||
output_tokens: u32,
|
output_tokens: u32,
|
||||||
stop_reason: String,
|
stop_reason: String,
|
||||||
|
#[serde(default)]
|
||||||
|
cache_creation_input_tokens: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
cache_read_input_tokens: Option<u32>,
|
||||||
},
|
},
|
||||||
/// Error occurred
|
/// Error occurred
|
||||||
Error { message: String },
|
Error { message: String },
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ impl MockLlmDriver {
|
|||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: text.len() as u32 / 4,
|
output_tokens: text.len() as u32 / 4,
|
||||||
stop_reason: StopReason::EndTurn,
|
stop_reason: StopReason::EndTurn,
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
});
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,8 @@ impl MockLlmDriver {
|
|||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 20,
|
output_tokens: 20,
|
||||||
stop_reason: StopReason::ToolUse,
|
stop_reason: StopReason::ToolUse,
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
});
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -86,6 +90,8 @@ impl MockLlmDriver {
|
|||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
stop_reason: StopReason::Error,
|
stop_reason: StopReason::Error,
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
});
|
});
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -142,6 +148,8 @@ impl MockLlmDriver {
|
|||||||
input_tokens: 0,
|
input_tokens: 0,
|
||||||
output_tokens: 0,
|
output_tokens: 0,
|
||||||
stop_reason: StopReason::EndTurn,
|
stop_reason: StopReason::EndTurn,
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +198,8 @@ impl LlmDriver for MockLlmDriver {
|
|||||||
input_tokens: 10,
|
input_tokens: 10,
|
||||||
output_tokens: 2,
|
output_tokens: 2,
|
||||||
stop_reason: "end_turn".to_string(),
|
stop_reason: "end_turn".to_string(),
|
||||||
|
cache_creation_input_tokens: None,
|
||||||
|
cache_read_input_tokens: None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ use crate::driver::ToolDefinition;
|
|||||||
use crate::loop_runner::LoopEvent;
|
use crate::loop_runner::LoopEvent;
|
||||||
use crate::tool::builtin::PathValidator;
|
use crate::tool::builtin::PathValidator;
|
||||||
|
|
||||||
|
/// Tool concurrency safety level
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToolConcurrency {
|
||||||
|
/// Read-only operations, always safe to parallelize (file_read, web_fetch, etc.)
|
||||||
|
ReadOnly,
|
||||||
|
/// Exclusive operations, must be serial (file_write, shell_exec, etc.)
|
||||||
|
Exclusive,
|
||||||
|
/// Interactive operations, never parallelize (ask_clarification, etc.)
|
||||||
|
Interactive,
|
||||||
|
}
|
||||||
|
|
||||||
/// Tool trait for implementing agent tools
|
/// Tool trait for implementing agent tools
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Tool: Send + Sync {
|
pub trait Tool: Send + Sync {
|
||||||
@@ -25,6 +36,11 @@ pub trait Tool: Send + Sync {
|
|||||||
|
|
||||||
/// Execute the tool
|
/// Execute the tool
|
||||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value>;
|
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value>;
|
||||||
|
|
||||||
|
/// Tool concurrency safety level. Default: ReadOnly.
|
||||||
|
fn concurrency(&self) -> ToolConcurrency {
|
||||||
|
ToolConcurrency::ReadOnly
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Skill executor trait for runtime skill execution
|
/// Skill executor trait for runtime skill execution
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use async_trait::async_trait;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use zclaw_types::{Result, ZclawError};
|
use zclaw_types::{Result, ZclawError};
|
||||||
|
|
||||||
use crate::tool::{Tool, ToolContext};
|
use crate::tool::{Tool, ToolContext, ToolConcurrency};
|
||||||
|
|
||||||
/// Clarification type — categorizes the reason for asking.
|
/// Clarification type — categorizes the reason for asking.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -96,6 +96,10 @@ impl Tool for AskClarificationTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn concurrency(&self) -> ToolConcurrency {
|
||||||
|
ToolConcurrency::Interactive
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
||||||
let question = input["question"].as_str()
|
let question = input["question"].as_str()
|
||||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'question' parameter".into()))?;
|
.ok_or_else(|| ZclawError::InvalidInput("Missing 'question' parameter".into()))?;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use zclaw_types::{Result, ZclawError};
|
use zclaw_types::{Result, ZclawError};
|
||||||
|
|
||||||
use crate::tool::{Tool, ToolContext};
|
use crate::tool::{Tool, ToolContext, ToolConcurrency};
|
||||||
|
|
||||||
pub struct ExecuteSkillTool;
|
pub struct ExecuteSkillTool;
|
||||||
|
|
||||||
@@ -42,6 +42,10 @@ impl Tool for ExecuteSkillTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn concurrency(&self) -> ToolConcurrency {
|
||||||
|
ToolConcurrency::Exclusive
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
|
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
|
||||||
let skill_id = input["skill_id"].as_str()
|
let skill_id = input["skill_id"].as_str()
|
||||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'skill_id' parameter".into()))?;
|
.ok_or_else(|| ZclawError::InvalidInput("Missing 'skill_id' parameter".into()))?;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use zclaw_types::{Result, ZclawError};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use crate::tool::{Tool, ToolContext};
|
use crate::tool::{Tool, ToolContext, ToolConcurrency};
|
||||||
use super::path_validator::PathValidator;
|
use super::path_validator::PathValidator;
|
||||||
|
|
||||||
pub struct FileWriteTool;
|
pub struct FileWriteTool;
|
||||||
@@ -55,6 +55,10 @@ impl Tool for FileWriteTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn concurrency(&self) -> ToolConcurrency {
|
||||||
|
ToolConcurrency::Exclusive
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
|
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
|
||||||
let path = input["path"].as_str()
|
let path = input["path"].as_str()
|
||||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'path' parameter".into()))?;
|
.ok_or_else(|| ZclawError::InvalidInput("Missing 'path' parameter".into()))?;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use serde_json::Value;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use zclaw_types::Result;
|
use zclaw_types::Result;
|
||||||
|
|
||||||
use crate::tool::{Tool, ToolContext};
|
use crate::tool::{Tool, ToolContext, ToolConcurrency};
|
||||||
|
|
||||||
/// Wraps an MCP tool adapter into the `Tool` trait.
|
/// Wraps an MCP tool adapter into the `Tool` trait.
|
||||||
///
|
///
|
||||||
@@ -42,6 +42,10 @@ impl Tool for McpToolWrapper {
|
|||||||
self.adapter.input_schema().clone()
|
self.adapter.input_schema().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn concurrency(&self) -> ToolConcurrency {
|
||||||
|
ToolConcurrency::Exclusive
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
||||||
self.adapter.execute(input).await
|
self.adapter.execute(input).await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ fn default_blocked_paths() -> Vec<PathBuf> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize Windows UNC path prefix for consistent comparison.
|
||||||
|
/// `\\?\C:\Users\...` → `C:\Users\...`
|
||||||
|
fn normalize_windows_path(path: &Path) -> std::borrow::Cow<'_, Path> {
|
||||||
|
let s = path.to_string_lossy();
|
||||||
|
if s.starts_with(r"\\?\") {
|
||||||
|
std::borrow::Cow::Owned(PathBuf::from(&s[4..]))
|
||||||
|
} else {
|
||||||
|
std::borrow::Cow::Borrowed(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Expand tilde in path to home directory
|
/// Expand tilde in path to home directory
|
||||||
fn expand_tilde(path: &str) -> PathBuf {
|
fn expand_tilde(path: &str) -> PathBuf {
|
||||||
if path.starts_with('~') {
|
if path.starts_with('~') {
|
||||||
@@ -154,9 +165,16 @@ impl PathValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the workspace root directory
|
/// Set the workspace root directory.
|
||||||
|
/// Canonicalizes the path to ensure consistent comparison on Windows
|
||||||
|
/// (where canonicalize() returns `\\?\C:\...` UNC paths).
|
||||||
pub fn with_workspace(mut self, workspace: PathBuf) -> Self {
|
pub fn with_workspace(mut self, workspace: PathBuf) -> Self {
|
||||||
self.workspace_root = Some(workspace);
|
let canonical = if workspace.exists() {
|
||||||
|
workspace.canonicalize().unwrap_or(workspace)
|
||||||
|
} else {
|
||||||
|
workspace
|
||||||
|
};
|
||||||
|
self.workspace_root = Some(canonical);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +248,14 @@ impl PathValidator {
|
|||||||
fn resolve_and_validate(&self, path: &str) -> Result<PathBuf> {
|
fn resolve_and_validate(&self, path: &str) -> Result<PathBuf> {
|
||||||
// Expand tilde
|
// Expand tilde
|
||||||
let expanded = expand_tilde(path);
|
let expanded = expand_tilde(path);
|
||||||
let path_buf = PathBuf::from(&expanded);
|
let mut path_buf = PathBuf::from(&expanded);
|
||||||
|
|
||||||
|
// If relative path and workspace is configured, resolve against workspace
|
||||||
|
if path_buf.is_relative() {
|
||||||
|
if let Some(ref workspace) = self.workspace_root {
|
||||||
|
path_buf = workspace.join(&path_buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for path traversal
|
// Check for path traversal
|
||||||
self.check_path_traversal(&path_buf)?;
|
self.check_path_traversal(&path_buf)?;
|
||||||
@@ -280,10 +305,14 @@ impl PathValidator {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if path is in blocked list
|
/// Check if path is in blocked list.
|
||||||
|
/// Normalizes Windows UNC prefix (`\\?\`) for consistent comparison.
|
||||||
fn check_blocked(&self, path: &Path) -> Result<()> {
|
fn check_blocked(&self, path: &Path) -> Result<()> {
|
||||||
|
// Strip Windows UNC prefix for consistent matching
|
||||||
|
let normalized = normalize_windows_path(path);
|
||||||
for blocked in &self.config.blocked_paths {
|
for blocked in &self.config.blocked_paths {
|
||||||
if path.starts_with(blocked) || path == blocked {
|
let blocked_norm = normalize_windows_path(blocked);
|
||||||
|
if normalized.starts_with(&*blocked_norm) || normalized == blocked_norm {
|
||||||
return Err(ZclawError::InvalidInput(format!(
|
return Err(ZclawError::InvalidInput(format!(
|
||||||
"Access to this path is blocked: {}",
|
"Access to this path is blocked: {}",
|
||||||
path.display()
|
path.display()
|
||||||
@@ -303,11 +332,15 @@ impl PathValidator {
|
|||||||
/// - This prevents accidental exposure of the entire filesystem
|
/// - This prevents accidental exposure of the entire filesystem
|
||||||
/// when the validator is misconfigured or used without setup
|
/// when the validator is misconfigured or used without setup
|
||||||
fn check_allowed(&self, path: &Path) -> Result<()> {
|
fn check_allowed(&self, path: &Path) -> Result<()> {
|
||||||
|
let path_norm = normalize_windows_path(path);
|
||||||
|
|
||||||
// If no allowed paths specified, check workspace
|
// If no allowed paths specified, check workspace
|
||||||
if self.config.allowed_paths.is_empty() {
|
if self.config.allowed_paths.is_empty() {
|
||||||
if let Some(ref workspace) = self.workspace_root {
|
if let Some(ref workspace) = self.workspace_root {
|
||||||
// Workspace is configured - validate path is within it
|
// Workspace is configured - validate path is within it
|
||||||
if !path.starts_with(workspace) {
|
// Both sides are canonicalized (workspace via with_workspace, path via resolve_and_validate)
|
||||||
|
let ws_norm = normalize_windows_path(workspace);
|
||||||
|
if !path_norm.starts_with(&*ws_norm) {
|
||||||
return Err(ZclawError::InvalidInput(format!(
|
return Err(ZclawError::InvalidInput(format!(
|
||||||
"Path outside workspace: {} (workspace: {})",
|
"Path outside workspace: {} (workspace: {})",
|
||||||
path.display(),
|
path.display(),
|
||||||
@@ -329,7 +362,8 @@ impl PathValidator {
|
|||||||
|
|
||||||
// Check against allowed paths
|
// Check against allowed paths
|
||||||
for allowed in &self.config.allowed_paths {
|
for allowed in &self.config.allowed_paths {
|
||||||
if path.starts_with(allowed) {
|
let allowed_norm = normalize_windows_path(allowed);
|
||||||
|
if path_norm.starts_with(&*allowed_norm) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use std::process::{Command, Stdio};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use zclaw_types::{Result, ZclawError};
|
use zclaw_types::{Result, ZclawError};
|
||||||
|
|
||||||
use crate::tool::{Tool, ToolContext};
|
use crate::tool::{Tool, ToolContext, ToolConcurrency};
|
||||||
|
|
||||||
/// Parse a command string into program and arguments using proper shell quoting
|
/// Parse a command string into program and arguments using proper shell quoting
|
||||||
fn parse_command(command: &str) -> Result<(String, Vec<String>)> {
|
fn parse_command(command: &str) -> Result<(String, Vec<String>)> {
|
||||||
@@ -175,6 +175,10 @@ impl Tool for ShellExecTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn concurrency(&self) -> ToolConcurrency {
|
||||||
|
ToolConcurrency::Exclusive
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<Value> {
|
||||||
let command = input["command"].as_str()
|
let command = input["command"].as_str()
|
||||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'command' parameter".into()))?;
|
.ok_or_else(|| ZclawError::InvalidInput("Missing 'command' parameter".into()))?;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use zclaw_memory::MemoryStore;
|
|||||||
|
|
||||||
use crate::driver::LlmDriver;
|
use crate::driver::LlmDriver;
|
||||||
use crate::loop_runner::{AgentLoop, LoopEvent};
|
use crate::loop_runner::{AgentLoop, LoopEvent};
|
||||||
use crate::tool::{Tool, ToolContext, ToolRegistry};
|
use crate::tool::{Tool, ToolContext, ToolRegistry, ToolConcurrency};
|
||||||
use crate::tool::builtin::register_builtin_tools;
|
use crate::tool::builtin::register_builtin_tools;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -91,6 +91,10 @@ impl Tool for TaskTool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn concurrency(&self) -> ToolConcurrency {
|
||||||
|
ToolConcurrency::Exclusive
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
|
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
|
||||||
let description = input["description"].as_str()
|
let description = input["description"].as_str()
|
||||||
.ok_or_else(|| ZclawError::InvalidInput("Missing 'description' parameter".into()))?;
|
.ok_or_else(|| ZclawError::InvalidInput("Missing 'description' parameter".into()))?;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use async_trait::async_trait;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use zclaw_types::Result;
|
use zclaw_types::Result;
|
||||||
|
|
||||||
use crate::tool::{Tool, ToolContext};
|
use crate::tool::{Tool, ToolContext, ToolConcurrency};
|
||||||
|
|
||||||
/// Wrapper that exposes a Hand as a Tool in the agent's tool registry.
|
/// Wrapper that exposes a Hand as a Tool in the agent's tool registry.
|
||||||
///
|
///
|
||||||
@@ -78,6 +78,10 @@ impl Tool for HandTool {
|
|||||||
self.input_schema.clone()
|
self.input_schema.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn concurrency(&self) -> ToolConcurrency {
|
||||||
|
ToolConcurrency::Exclusive
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
|
async fn execute(&self, input: Value, context: &ToolContext) -> Result<Value> {
|
||||||
// Delegate to the HandExecutor (bridged from HandRegistry via kernel).
|
// Delegate to the HandExecutor (bridged from HandRegistry via kernel).
|
||||||
// If no hand_executor is available (e.g., standalone runtime without kernel),
|
// If no hand_executor is available (e.g., standalone runtime without kernel),
|
||||||
|
|||||||
@@ -223,6 +223,33 @@ impl Serialize for ZclawError {
|
|||||||
/// Result type alias for ZCLAW operations
|
/// Result type alias for ZCLAW operations
|
||||||
pub type Result<T> = std::result::Result<T, ZclawError>;
|
pub type Result<T> = std::result::Result<T, ZclawError>;
|
||||||
|
|
||||||
|
/// LLM 调用错误的细粒度分类,指导重试和恢复策略
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LlmErrorKind {
|
||||||
|
Auth,
|
||||||
|
AuthPermanent,
|
||||||
|
BillingExhausted,
|
||||||
|
RateLimited,
|
||||||
|
Overloaded,
|
||||||
|
ServerError,
|
||||||
|
Timeout,
|
||||||
|
ContextOverflow,
|
||||||
|
ModelNotFound,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分类后的 LLM 错误,附带恢复提示
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ClassifiedLlmError {
|
||||||
|
pub kind: LlmErrorKind,
|
||||||
|
pub retryable: bool,
|
||||||
|
pub should_compress: bool,
|
||||||
|
pub should_rotate_credential: bool,
|
||||||
|
pub retry_after: Option<std::time::Duration>,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ use zclaw_types::Result;
|
|||||||
use super::pain_aggregator::PainPoint;
|
use super::pain_aggregator::PainPoint;
|
||||||
use super::solution_generator::Proposal;
|
use super::solution_generator::Proposal;
|
||||||
|
|
||||||
|
/// Brief summary of a stored experience, for suggestion context enrichment.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExperienceBrief {
|
||||||
|
pub pain_pattern: String,
|
||||||
|
pub solution_summary: String,
|
||||||
|
pub reuse_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
static EXPERIENCE_EXTRACTOR: std::sync::OnceLock<std::sync::Arc<ExperienceExtractor>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// Get the global ExperienceExtractor singleton (if initialized).
|
||||||
|
pub(crate) fn get_experience_extractor() -> Option<std::sync::Arc<ExperienceExtractor>> {
|
||||||
|
EXPERIENCE_EXTRACTOR.get().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared completion status
|
// Shared completion status
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -263,6 +278,36 @@ fn xml_escape(s: &str) -> String {
|
|||||||
.replace('>', ">")
|
.replace('>', ">")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initialize the global ExperienceExtractor singleton.
|
||||||
|
/// Called once during app startup, after viking storage is ready.
|
||||||
|
pub async fn init_experience_extractor() -> Result<()> {
|
||||||
|
let sqlite_storage = crate::viking_commands::get_storage().await
|
||||||
|
.map_err(|e| zclaw_types::ZclawError::StorageError(e))?;
|
||||||
|
let viking = std::sync::Arc::new(zclaw_growth::VikingAdapter::new(sqlite_storage));
|
||||||
|
let store = std::sync::Arc::new(ExperienceStore::new(viking));
|
||||||
|
let extractor = std::sync::Arc::new(ExperienceExtractor::new(store));
|
||||||
|
EXPERIENCE_EXTRACTOR.set(extractor)
|
||||||
|
.map_err(|_| zclaw_types::ZclawError::StorageError("ExperienceExtractor already initialized".into()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find experiences relevant to the current conversation for suggestion enrichment.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn experience_find_relevant(
|
||||||
|
agent_id: String,
|
||||||
|
query: String,
|
||||||
|
) -> std::result::Result<Vec<ExperienceBrief>, String> {
|
||||||
|
let extractor = get_experience_extractor()
|
||||||
|
.ok_or("ExperienceExtractor not initialized".to_string())?;
|
||||||
|
let experiences = extractor.find_relevant_experiences(&agent_id, &query).await;
|
||||||
|
Ok(experiences.into_iter().take(3).map(|e| ExperienceBrief {
|
||||||
|
pain_pattern: e.pain_pattern,
|
||||||
|
solution_summary: e.solution_steps.join(";")
|
||||||
|
.chars().take(100).collect(),
|
||||||
|
reuse_count: e.reuse_count,
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -407,4 +452,17 @@ mod tests {
|
|||||||
assert_eq!(truncate("hello", 10), "hello");
|
assert_eq!(truncate("hello", 10), "hello");
|
||||||
assert_eq!(truncate("这是一个很长的字符串用于测试截断", 10).chars().count(), 11); // 10 + …
|
assert_eq!(truncate("这是一个很长的字符串用于测试截断", 10).chars().count(), 11); // 10 + …
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_experience_brief_serialization() {
|
||||||
|
let brief = super::ExperienceBrief {
|
||||||
|
pain_pattern: "报表生成慢".to_string(),
|
||||||
|
solution_summary: "使用 researcher 技能自动收集".to_string(),
|
||||||
|
reuse_count: 3,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&brief).unwrap();
|
||||||
|
let parsed: super::ExperienceBrief = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.pain_pattern, "报表生成慢");
|
||||||
|
assert_eq!(parsed.reuse_count, 3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,47 @@
|
|||||||
|
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tauri::Emitter;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use zclaw_growth::VikingStorage;
|
||||||
|
|
||||||
use crate::intelligence::identity::IdentityManagerState;
|
use crate::intelligence::identity::IdentityManagerState;
|
||||||
use crate::intelligence::heartbeat::HeartbeatEngineState;
|
use crate::intelligence::heartbeat::HeartbeatEngineState;
|
||||||
use crate::intelligence::reflection::{MemoryEntryForAnalysis, ReflectionEngineState};
|
use crate::intelligence::reflection::{MemoryEntryForAnalysis, ReflectionEngineState};
|
||||||
use zclaw_runtime::driver::LlmDriver;
|
use zclaw_runtime::driver::LlmDriver;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Identity prompt cache — avoids mutex + disk I/O on every request
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct CachedIdentity {
|
||||||
|
prompt: String,
|
||||||
|
#[allow(dead_code)] // Reserved for future TTL-based cache validation
|
||||||
|
soul_hash: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
static IDENTITY_CACHE: std::sync::LazyLock<RwLock<HashMap<String, CachedIdentity>>> =
|
||||||
|
std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
|
||||||
|
|
||||||
|
/// Invalidate cached identity prompt for a given agent (call when soul.md changes).
|
||||||
|
pub fn invalidate_identity_cache(agent_id: &str) {
|
||||||
|
let cache = &*IDENTITY_CACHE;
|
||||||
|
// Non-blocking: spawn a task to remove the entry
|
||||||
|
if let Ok(mut guard) = cache.try_write() {
|
||||||
|
guard.remove(agent_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple hash for cache invalidation — uses string content hash.
|
||||||
|
fn content_hash(s: &str) -> u64 {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
s.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
/// Run pre-conversation intelligence hooks
|
/// Run pre-conversation intelligence hooks
|
||||||
///
|
///
|
||||||
/// Builds identity-enhanced system prompt (SOUL.md + instructions) and
|
/// Builds identity-enhanced system prompt (SOUL.md + instructions) and
|
||||||
@@ -27,10 +61,29 @@ pub async fn pre_conversation_hook(
|
|||||||
_user_message: &str,
|
_user_message: &str,
|
||||||
identity_state: &IdentityManagerState,
|
identity_state: &IdentityManagerState,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
// Build identity-enhanced system prompt (SOUL.md + instructions)
|
// Check identity prompt cache first (avoids mutex + disk I/O)
|
||||||
// Memory context is injected by MemoryMiddleware in the kernel middleware chain,
|
let cache = &*IDENTITY_CACHE;
|
||||||
// not here, to avoid duplicate injection.
|
{
|
||||||
let enhanced_prompt = match build_identity_prompt(agent_id, "", identity_state).await {
|
let guard = cache.read().await;
|
||||||
|
if let Some(cached) = guard.get(agent_id) {
|
||||||
|
// Cache hit — still need continuity context, but skip identity build
|
||||||
|
let continuity_context = build_continuity_context(agent_id, _user_message).await;
|
||||||
|
let mut result = cached.prompt.clone();
|
||||||
|
if !continuity_context.is_empty() {
|
||||||
|
result.push_str(&continuity_context);
|
||||||
|
}
|
||||||
|
debug!("[intelligence_hooks] Identity cache HIT for agent {}", agent_id);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss — build identity prompt and continuity context in parallel
|
||||||
|
let (identity_result, continuity_context) = tokio::join!(
|
||||||
|
build_identity_prompt_cached(agent_id, "", identity_state, cache),
|
||||||
|
build_continuity_context(agent_id, _user_message)
|
||||||
|
);
|
||||||
|
|
||||||
|
let enhanced_prompt = match identity_result {
|
||||||
Ok(prompt) => prompt,
|
Ok(prompt) => prompt,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -41,9 +94,6 @@ pub async fn pre_conversation_hook(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cross-session continuity: check for unresolved pain points and recent experiences
|
|
||||||
let continuity_context = build_continuity_context(agent_id, _user_message).await;
|
|
||||||
|
|
||||||
let mut result = enhanced_prompt;
|
let mut result = enhanced_prompt;
|
||||||
if !continuity_context.is_empty() {
|
if !continuity_context.is_empty() {
|
||||||
result.push_str(&continuity_context);
|
result.push_str(&continuity_context);
|
||||||
@@ -56,12 +106,15 @@ pub async fn pre_conversation_hook(
|
|||||||
///
|
///
|
||||||
/// 1. Record interaction for heartbeat engine
|
/// 1. Record interaction for heartbeat engine
|
||||||
/// 2. Record conversation for reflection engine, trigger reflection if needed
|
/// 2. Record conversation for reflection engine, trigger reflection if needed
|
||||||
|
/// 3. Detect identity signals and write back to identity files
|
||||||
pub async fn post_conversation_hook(
|
pub async fn post_conversation_hook(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
_user_message: &str,
|
_user_message: &str,
|
||||||
_heartbeat_state: &HeartbeatEngineState,
|
_heartbeat_state: &HeartbeatEngineState,
|
||||||
reflection_state: &ReflectionEngineState,
|
reflection_state: &ReflectionEngineState,
|
||||||
llm_driver: Option<Arc<dyn LlmDriver>>,
|
llm_driver: Option<Arc<dyn LlmDriver>>,
|
||||||
|
identity_state: &IdentityManagerState,
|
||||||
|
app: &tauri::AppHandle,
|
||||||
) {
|
) {
|
||||||
// Step 1: Record interaction for heartbeat
|
// Step 1: Record interaction for heartbeat
|
||||||
crate::intelligence::heartbeat::record_interaction(agent_id);
|
crate::intelligence::heartbeat::record_interaction(agent_id);
|
||||||
@@ -200,6 +253,73 @@ pub async fn post_conversation_hook(
|
|||||||
reflection_result.improvements.len()
|
reflection_result.improvements.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step 3: Detect identity signals from recent memory extraction and write back
|
||||||
|
if let Ok(storage) = crate::viking_commands::get_storage().await {
|
||||||
|
let identity_prefix = format!("agent://{}/identity/", agent_id);
|
||||||
|
|
||||||
|
// Check for agent_name identity signal
|
||||||
|
let agent_name_uri = format!("{}agent-name", identity_prefix);
|
||||||
|
if let Ok(Some(entry)) = VikingStorage::get(storage.as_ref(), &agent_name_uri).await {
|
||||||
|
// Extract name from content like "助手的名字是小马"
|
||||||
|
let name = entry.content.strip_prefix("助手的名字是")
|
||||||
|
.map(|n| n.trim().to_string())
|
||||||
|
.unwrap_or_else(|| entry.content.clone());
|
||||||
|
|
||||||
|
if !name.is_empty() {
|
||||||
|
// Update IdentityFiles.soul to include the agent name
|
||||||
|
let mut manager = identity_state.lock().await;
|
||||||
|
let current_soul = manager.get_file(agent_id, crate::intelligence::identity::IdentityFile::Soul);
|
||||||
|
|
||||||
|
// Only update if the name isn't already in the soul
|
||||||
|
if !current_soul.contains(&name) {
|
||||||
|
let updated_soul = if current_soul.is_empty() {
|
||||||
|
format!("# ZCLAW 人格\n\n你的名字是{}。\n\n你是一个成长性的中文 AI 助手。", name)
|
||||||
|
} else if current_soul.contains("你的名字是") || current_soul.contains("你的名字:") {
|
||||||
|
// Replace existing name line
|
||||||
|
let re = regex::Regex::new(r"你的名字是[^\n]+").unwrap();
|
||||||
|
re.replace(¤t_soul, format!("你的名字是{}", name)).to_string()
|
||||||
|
} else {
|
||||||
|
// Prepend name to existing soul
|
||||||
|
format!("你的名字是{}。\n\n{}", name, current_soul)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = manager.update_file(agent_id, "soul", &updated_soul) {
|
||||||
|
warn!("[intelligence_hooks] Failed to update soul with agent name: {}", e);
|
||||||
|
} else {
|
||||||
|
debug!("[intelligence_hooks] Updated agent name to '{}' in soul", name);
|
||||||
|
// Invalidate cache since soul.md changed
|
||||||
|
invalidate_identity_cache(agent_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(manager);
|
||||||
|
|
||||||
|
// Emit event for frontend to update AgentConfig.name
|
||||||
|
let _ = app.emit("zclaw:agent-identity-updated", serde_json::json!({
|
||||||
|
"agentId": agent_id,
|
||||||
|
"agentName": name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for user_name identity signal
|
||||||
|
let user_name_uri = format!("{}user-name", identity_prefix);
|
||||||
|
if let Ok(Some(entry)) = VikingStorage::get(storage.as_ref(), &user_name_uri).await {
|
||||||
|
let name = entry.content.strip_prefix("用户的名字是")
|
||||||
|
.map(|n| n.trim().to_string())
|
||||||
|
.unwrap_or_else(|| entry.content.clone());
|
||||||
|
|
||||||
|
if !name.is_empty() {
|
||||||
|
let mut manager = identity_state.lock().await;
|
||||||
|
let profile = manager.get_file(agent_id, crate::intelligence::identity::IdentityFile::UserProfile);
|
||||||
|
|
||||||
|
if !profile.contains(&name) {
|
||||||
|
manager.append_to_user_profile(agent_id, &format!("- 用户名字: {}", name));
|
||||||
|
debug!("[intelligence_hooks] Appended user name '{}' to profile", name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build memory context by searching VikingStorage for relevant memories
|
/// Build memory context by searching VikingStorage for relevant memories
|
||||||
@@ -270,21 +390,34 @@ async fn build_memory_context(
|
|||||||
Ok(context)
|
Ok(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build identity-enhanced system prompt
|
/// Build identity-enhanced system prompt and cache the result.
|
||||||
async fn build_identity_prompt(
|
async fn build_identity_prompt_cached(
|
||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
memory_context: &str,
|
memory_context: &str,
|
||||||
identity_state: &IdentityManagerState,
|
identity_state: &IdentityManagerState,
|
||||||
|
cache: &RwLock<HashMap<String, CachedIdentity>>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
// IdentityManagerState is Arc<tokio::sync::Mutex<AgentIdentityManager>>
|
|
||||||
// tokio::sync::Mutex::lock() returns MutexGuard directly
|
|
||||||
let mut manager = identity_state.lock().await;
|
let mut manager = identity_state.lock().await;
|
||||||
|
|
||||||
|
// Read current soul content for hashing
|
||||||
|
let soul_content = manager.get_file(agent_id, crate::intelligence::identity::IdentityFile::Soul);
|
||||||
|
let soul_hash = content_hash(&soul_content);
|
||||||
|
|
||||||
let prompt = manager.build_system_prompt(
|
let prompt = manager.build_system_prompt(
|
||||||
agent_id,
|
agent_id,
|
||||||
if memory_context.is_empty() { None } else { Some(memory_context) },
|
if memory_context.is_empty() { None } else { Some(memory_context) },
|
||||||
).await;
|
).await;
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
drop(manager); // Release lock before acquiring write guard
|
||||||
|
{
|
||||||
|
let mut guard = cache.write().await;
|
||||||
|
guard.insert(agent_id.to_string(), CachedIdentity {
|
||||||
|
prompt: prompt.clone(),
|
||||||
|
soul_hash,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(prompt)
|
Ok(prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use zclaw_types::{AgentConfig, AgentId, AgentInfo};
|
|||||||
|
|
||||||
use super::{validate_agent_id, KernelState};
|
use super::{validate_agent_id, KernelState};
|
||||||
use crate::intelligence::validation::validate_string_length;
|
use crate::intelligence::validation::validate_string_length;
|
||||||
use crate::intelligence::identity::IdentityManagerState;
|
use crate::intelligence::identity::{IdentityFile, IdentityManagerState};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Request / Response types
|
// Request / Response types
|
||||||
@@ -235,6 +235,7 @@ pub async fn agent_delete(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn agent_update(
|
pub async fn agent_update(
|
||||||
state: State<'_, KernelState>,
|
state: State<'_, KernelState>,
|
||||||
|
identity_state: State<'_, IdentityManagerState>,
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
updates: AgentUpdateRequest,
|
updates: AgentUpdateRequest,
|
||||||
) -> Result<AgentInfo, String> {
|
) -> Result<AgentInfo, String> {
|
||||||
@@ -253,6 +254,20 @@ pub async fn agent_update(
|
|||||||
|
|
||||||
// Apply updates
|
// Apply updates
|
||||||
if let Some(name) = updates.name {
|
if let Some(name) = updates.name {
|
||||||
|
// Sync name to identity soul so next session's system prompt includes it
|
||||||
|
let mut identity_mgr = identity_state.lock().await;
|
||||||
|
let current_soul = identity_mgr.get_file(&agent_id, IdentityFile::Soul);
|
||||||
|
let updated_soul = if current_soul.is_empty() {
|
||||||
|
format!("# ZCLAW 人格\n\n你的名字是{}。\n\n你是一个成长性的中文 AI 助手。", name)
|
||||||
|
} else if current_soul.contains("你的名字是") {
|
||||||
|
let re = regex::Regex::new(r"你的名字是[^\n]+").unwrap();
|
||||||
|
re.replace(¤t_soul, format!("你的名字是{}", name)).to_string()
|
||||||
|
} else {
|
||||||
|
format!("你的名字是{}。\n\n{}", name, current_soul)
|
||||||
|
};
|
||||||
|
let _ = identity_mgr.update_file(&agent_id, "soul", &updated_soul);
|
||||||
|
drop(identity_mgr);
|
||||||
|
|
||||||
config.name = name;
|
config.name = name;
|
||||||
}
|
}
|
||||||
if let Some(description) = updates.description {
|
if let Some(description) = updates.description {
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ pub async fn agent_chat_stream(
|
|||||||
|
|
||||||
let hb_state = heartbeat_state.inner().clone();
|
let hb_state = heartbeat_state.inner().clone();
|
||||||
let rf_state = reflection_state.inner().clone();
|
let rf_state = reflection_state.inner().clone();
|
||||||
|
let id_state_hook = identity_state.inner().clone();
|
||||||
|
|
||||||
// Clone the guard map for cleanup in the spawned task
|
// Clone the guard map for cleanup in the spawned task
|
||||||
let guard_map: SessionStreamGuard = stream_guard.inner().clone();
|
let guard_map: SessionStreamGuard = stream_guard.inner().clone();
|
||||||
@@ -380,12 +381,14 @@ pub async fn agent_chat_stream(
|
|||||||
let hb = hb_state.clone();
|
let hb = hb_state.clone();
|
||||||
let rf = rf_state.clone();
|
let rf = rf_state.clone();
|
||||||
let driver = llm_driver.clone();
|
let driver = llm_driver.clone();
|
||||||
|
let id_state = id_state_hook.clone();
|
||||||
|
let app_hook = app.clone();
|
||||||
if driver.is_none() {
|
if driver.is_none() {
|
||||||
tracing::debug!("[agent_chat_stream] Post-hook firing without LLM driver (schedule intercept path)");
|
tracing::debug!("[agent_chat_stream] Post-hook firing without LLM driver (schedule intercept path)");
|
||||||
}
|
}
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
crate::intelligence_hooks::post_conversation_hook(
|
crate::intelligence_hooks::post_conversation_hook(
|
||||||
&agent_id_hook, &message_hook, &hb, &rf, driver,
|
&agent_id_hook, &message_hook, &hb, &rf, driver, &id_state, &app_hook,
|
||||||
).await;
|
).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,12 @@ pub fn run() {
|
|||||||
if let Err(e) = rt.block_on(intelligence::pain_aggregator::init_pain_storage(pool)) {
|
if let Err(e) = rt.block_on(intelligence::pain_aggregator::init_pain_storage(pool)) {
|
||||||
tracing::error!("[PainStorage] Init failed: {}, pain points will not persist", e);
|
tracing::error!("[PainStorage] Init failed: {}, pain points will not persist", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize experience extractor for suggestion enrichment.
|
||||||
|
// Graceful degradation: failure does not block app startup.
|
||||||
|
if let Err(e) = rt.block_on(intelligence::experience::init_experience_extractor()) {
|
||||||
|
tracing::warn!("[ExperienceExtractor] Init failed: {}, suggestion context will be empty", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -435,6 +441,8 @@ pub fn run() {
|
|||||||
intelligence::pain_aggregator::butler_update_proposal_status,
|
intelligence::pain_aggregator::butler_update_proposal_status,
|
||||||
// Industry config loader
|
// Industry config loader
|
||||||
viking_commands::viking_load_industry_keywords,
|
viking_commands::viking_load_industry_keywords,
|
||||||
|
// Experience finder for suggestion enrichment
|
||||||
|
intelligence::experience::experience_find_relevant,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
|
|||||||
const {
|
const {
|
||||||
messages, isStreaming, isLoading,
|
messages, isStreaming, isLoading,
|
||||||
sendMessage: sendToGateway, initStreamListener,
|
sendMessage: sendToGateway, initStreamListener,
|
||||||
chatMode, setChatMode, suggestions,
|
chatMode, setChatMode, suggestions, suggestionsLoading,
|
||||||
totalInputTokens, totalOutputTokens,
|
totalInputTokens, totalOutputTokens,
|
||||||
cancelStream,
|
cancelStream,
|
||||||
} = useChatStore();
|
} = useChatStore();
|
||||||
@@ -505,9 +505,10 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
|
|||||||
<div className="flex-shrink-0 p-4 bg-white dark:bg-gray-900">
|
<div className="flex-shrink-0 p-4 bg-white dark:bg-gray-900">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* Suggestion chips */}
|
{/* Suggestion chips */}
|
||||||
{!isStreaming && suggestions.length > 0 && !messages.some(m => m.error) && (
|
{!isStreaming && !messages.some(m => m.error) && (suggestions.length > 0 || suggestionsLoading) && (
|
||||||
<SuggestionChips
|
<SuggestionChips
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
|
loading={suggestionsLoading}
|
||||||
onSelect={(text) => { setInput(text); textareaRef.current?.focus(); setTimeout(() => handleSend(), 0); }}
|
onSelect={(text) => { setInput(text); textareaRef.current?.focus(); setTimeout(() => handleSend(), 0); }}
|
||||||
className="mb-3"
|
className="mb-3"
|
||||||
/>
|
/>
|
||||||
@@ -664,6 +665,28 @@ function stripToolNarration(content: string): string {
|
|||||||
return result || content;
|
return result || content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip dangling clarification references from text when ask_clarification tool was called.
|
||||||
|
* When the LLM calls ask_clarification, it often ends its text with phrases like
|
||||||
|
* "比如:" / "以下信息" / "以下选项" that reference the tool output — but the tool output
|
||||||
|
* is rendered in a separate ClarificationCard, so these become confusing dead-end sentences.
|
||||||
|
*/
|
||||||
|
function stripDanglingClarificationRef(text: string, hasClarificationTool: boolean): string {
|
||||||
|
if (!hasClarificationTool || !text) return text;
|
||||||
|
// Match trailing dangling references in Chinese and English
|
||||||
|
const patterns = [
|
||||||
|
/[,,]\s*可以(?:提供以下|告诉我更多细节,)?(?:信息|选项|方向|细节|分类|类型)[::]\s*$/,
|
||||||
|
/[,,]\s*比如[::]\s*$/,
|
||||||
|
/[,,]\s*(?:例如|譬如|如以下)[::]\s*$/,
|
||||||
|
/,\s*(?:for example|such as|like|the following)[::]?\s*$/i,
|
||||||
|
];
|
||||||
|
for (const pat of patterns) {
|
||||||
|
const stripped = text.replace(pat, '');
|
||||||
|
if (stripped !== text) return stripped;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
function MessageBubble({ message, onRetry }: { message: Message; setInput?: (text: string) => void; onRetry?: () => void }) {
|
function MessageBubble({ message, onRetry }: { message: Message; setInput?: (text: string) => void; onRetry?: () => void }) {
|
||||||
if (message.role === 'tool') {
|
if (message.role === 'tool') {
|
||||||
return null;
|
return null;
|
||||||
@@ -748,7 +771,10 @@ function MessageBubble({ message, onRetry }: { message: Message; setInput?: (tex
|
|||||||
? (isUser
|
? (isUser
|
||||||
? message.content
|
? message.content
|
||||||
: <StreamingText
|
: <StreamingText
|
||||||
content={stripToolNarration(message.content)}
|
content={stripDanglingClarificationRef(
|
||||||
|
stripToolNarration(message.content),
|
||||||
|
toolCallSteps?.some(s => s.toolName === 'ask_clarification') ?? false,
|
||||||
|
)}
|
||||||
isStreaming={!!message.streaming}
|
isStreaming={!!message.streaming}
|
||||||
className="text-gray-700 dark:text-gray-200"
|
className="text-gray-700 dark:text-gray-200"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { getStoredGatewayUrl } from '../lib/gateway-client';
|
import { getStoredGatewayUrl } from '../lib/gateway-client';
|
||||||
import { useConnectionStore } from '../store/connectionStore';
|
import { useConnectionStore } from '../store/connectionStore';
|
||||||
import { useAgentStore, type PluginStatus } from '../store/agentStore';
|
import { useAgentStore, type PluginStatus } from '../store/agentStore';
|
||||||
import { useConfigStore } from '../store/configStore';
|
import { useConfigStore } from '../store/configStore';
|
||||||
import { toChatAgent, useChatStore, type CodeBlock } from '../store/chatStore';
|
import { useChatStore, type CodeBlock } from '../store/chatStore';
|
||||||
import { useConversationStore } from '../store/chat/conversationStore';
|
import { useConversationStore } from '../store/chat/conversationStore';
|
||||||
import { intelligenceClient, type IdentitySnapshot } from '../lib/intelligence-client';
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import type { AgentInfo } from '../lib/kernel-types';
|
|
||||||
import {
|
import {
|
||||||
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
|
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
|
||||||
MessageSquare, Cpu, FileText, User, Activity, Brain,
|
MessageSquare, Cpu, FileText, Activity, Brain,
|
||||||
Shield, Sparkles, List, Network, Dna, History,
|
Shield, Sparkles, List, Network, Dna,
|
||||||
ChevronDown, ChevronUp, RotateCcw, AlertCircle, Loader2,
|
|
||||||
ConciergeBell,
|
ConciergeBell,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ButlerPanel } from './ButlerPanel';
|
import { ButlerPanel } from './ButlerPanel';
|
||||||
@@ -85,7 +82,7 @@ import { IdentityChangeProposalPanel } from './IdentityChangeProposal';
|
|||||||
import { CodeSnippetPanel, type CodeSnippet } from './CodeSnippetPanel';
|
import { CodeSnippetPanel, type CodeSnippet } from './CodeSnippetPanel';
|
||||||
import { cardHover, defaultTransition } from '../lib/animations';
|
import { cardHover, defaultTransition } from '../lib/animations';
|
||||||
import { Button, Badge } from './ui';
|
import { Button, Badge } from './ui';
|
||||||
import { getPersonalityById } from '../lib/personality-presets';
|
|
||||||
import { silentErrorHandler } from '../lib/error-utils';
|
import { silentErrorHandler } from '../lib/error-utils';
|
||||||
|
|
||||||
interface RightPanelProps {
|
interface RightPanelProps {
|
||||||
@@ -109,12 +106,10 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
|
|||||||
const updateClone = useAgentStore((s) => s.updateClone);
|
const updateClone = useAgentStore((s) => s.updateClone);
|
||||||
|
|
||||||
// Config store
|
// Config store
|
||||||
const workspaceInfo = useConfigStore((s) => s.workspaceInfo);
|
|
||||||
const quickConfig = useConfigStore((s) => s.quickConfig);
|
const quickConfig = useConfigStore((s) => s.quickConfig);
|
||||||
|
|
||||||
// Use shallow selector for message stats to avoid re-rendering during streaming.
|
// Use shallow selector for message stats to avoid re-rendering during streaming.
|
||||||
// Counts only change when messages are added/removed, not when content is appended.
|
// Counts only change when messages are added/removed, not when content is appended.
|
||||||
const setCurrentAgent = useChatStore((s) => s.setCurrentAgent);
|
|
||||||
const { messageCount, userMsgCount, assistantMsgCount, toolCallCount } = useChatStore(
|
const { messageCount, userMsgCount, assistantMsgCount, toolCallCount } = useChatStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
messageCount: s.messages.length,
|
messageCount: s.messages.length,
|
||||||
@@ -132,36 +127,12 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
|
|||||||
const messages = stableMessagesRef.current;
|
const messages = stableMessagesRef.current;
|
||||||
const currentModel = useConversationStore((s) => s.currentModel);
|
const currentModel = useConversationStore((s) => s.currentModel);
|
||||||
const currentAgent = useConversationStore((s) => s.currentAgent);
|
const currentAgent = useConversationStore((s) => s.currentAgent);
|
||||||
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'agent' | 'memory' | 'reflection' | 'autonomy' | 'evolution' | 'butler'>('status');
|
const [activeTab, setActiveTab] = useState<'status' | 'files' | 'memory' | 'reflection' | 'autonomy' | 'evolution' | 'butler'>('status');
|
||||||
const [memoryViewMode, setMemoryViewMode] = useState<'list' | 'graph'>('list');
|
const [memoryViewMode, setMemoryViewMode] = useState<'list' | 'graph'>('list');
|
||||||
const [isEditingAgent, setIsEditingAgent] = useState(false);
|
|
||||||
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
|
|
||||||
|
|
||||||
// Identity snapshot state
|
|
||||||
const [snapshots, setSnapshots] = useState<IdentitySnapshot[]>([]);
|
|
||||||
const [snapshotsExpanded, setSnapshotsExpanded] = useState(false);
|
|
||||||
const [snapshotsLoading, setSnapshotsLoading] = useState(false);
|
|
||||||
const [snapshotsError, setSnapshotsError] = useState<string | null>(null);
|
|
||||||
const [restoringSnapshotId, setRestoringSnapshotId] = useState<string | null>(null);
|
|
||||||
const [confirmRestoreId, setConfirmRestoreId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// UserProfile from memory store (dynamic, learned from conversations)
|
|
||||||
const [userProfile, setUserProfile] = useState<Record<string, unknown> | null>(null);
|
|
||||||
|
|
||||||
const connected = connectionState === 'connected';
|
const connected = connectionState === 'connected';
|
||||||
const selectedClone = useMemo(
|
|
||||||
() => clones.find((clone) => clone.id === currentAgent?.id),
|
|
||||||
[clones, currentAgent?.id]
|
|
||||||
);
|
|
||||||
const focusAreas = selectedClone?.scenarios?.length ? selectedClone.scenarios : ['coding', 'writing', 'research', 'product', 'data'];
|
|
||||||
const bootstrapFiles = selectedClone?.bootstrapFiles || [];
|
|
||||||
const gatewayUrl = quickConfig.gatewayUrl || getStoredGatewayUrl();
|
const gatewayUrl = quickConfig.gatewayUrl || getStoredGatewayUrl();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedClone || isEditingAgent) return;
|
|
||||||
setAgentDraft(createAgentDraft(selectedClone, currentModel));
|
|
||||||
}, [selectedClone, currentModel, isEditingAgent]);
|
|
||||||
|
|
||||||
// Load data when connected
|
// Load data when connected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
@@ -171,112 +142,28 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
|
|||||||
}
|
}
|
||||||
}, [connected]);
|
}, [connected]);
|
||||||
|
|
||||||
// Fetch UserProfile from agent data (includes memory-learned profile)
|
// Listen for Tauri identity update events (from Rust post_conversation_hook)
|
||||||
|
// When agent name changes in soul.md, update AgentConfig.name and refresh panel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentAgent?.id) return;
|
let unlisten: UnlistenFn | undefined;
|
||||||
invoke<AgentInfo | null>('agent_get', { agentId: currentAgent.id })
|
listen<{ agentId: string; agentName?: string }>('zclaw:agent-identity-updated', (event) => {
|
||||||
.then(data => setUserProfile(data?.userProfile ?? null))
|
const { agentName } = event.payload;
|
||||||
.catch(() => setUserProfile(null));
|
if (agentName && currentAgent?.id) {
|
||||||
}, [currentAgent?.id]);
|
updateClone(currentAgent.id, { name: agentName })
|
||||||
|
.then(() => loadClones())
|
||||||
// Listen for profile updates after conversations (fired after memory extraction completes)
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: Event) => {
|
|
||||||
const detail = (e as CustomEvent).detail;
|
|
||||||
if (detail?.agentId === currentAgent?.id && currentAgent?.id) {
|
|
||||||
invoke<AgentInfo | null>('agent_get', { agentId: currentAgent.id })
|
|
||||||
.then(data => setUserProfile(data?.userProfile ?? null))
|
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
// Refresh clones data so selectedClone (name, role, nickname, etc.) stays current
|
|
||||||
loadClones();
|
|
||||||
}
|
}
|
||||||
};
|
})
|
||||||
window.addEventListener('zclaw:agent-profile-updated', handler);
|
.then(fn => { unlisten = fn; })
|
||||||
return () => window.removeEventListener('zclaw:agent-profile-updated', handler);
|
.catch(() => {});
|
||||||
|
return () => { unlisten?.(); };
|
||||||
}, [currentAgent?.id]);
|
}, [currentAgent?.id]);
|
||||||
|
|
||||||
const handleReconnect = () => {
|
const handleReconnect = () => {
|
||||||
connect().catch(silentErrorHandler('RightPanel'));
|
connect().catch(silentErrorHandler('RightPanel'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartEdit = () => {
|
|
||||||
if (!selectedClone) return;
|
|
||||||
setAgentDraft(createAgentDraft(selectedClone, currentModel));
|
|
||||||
setIsEditingAgent(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
if (selectedClone) {
|
|
||||||
setAgentDraft(createAgentDraft(selectedClone, currentModel));
|
|
||||||
}
|
|
||||||
setIsEditingAgent(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAgent = async () => {
|
|
||||||
if (!selectedClone || !agentDraft || !agentDraft.name.trim()) return;
|
|
||||||
const updatedClone = await updateClone(selectedClone.id, {
|
|
||||||
name: agentDraft.name.trim(),
|
|
||||||
role: agentDraft.role.trim() || undefined,
|
|
||||||
nickname: agentDraft.nickname.trim() || undefined,
|
|
||||||
model: agentDraft.model.trim() || undefined,
|
|
||||||
scenarios: agentDraft.scenarios.split(',').map((item) => item.trim()).filter(Boolean),
|
|
||||||
workspaceDir: agentDraft.workspaceDir.trim() || undefined,
|
|
||||||
userName: agentDraft.userName.trim() || undefined,
|
|
||||||
userRole: agentDraft.userRole.trim() || undefined,
|
|
||||||
restrictFiles: agentDraft.restrictFiles,
|
|
||||||
privacyOptIn: agentDraft.privacyOptIn,
|
|
||||||
});
|
|
||||||
if (updatedClone) {
|
|
||||||
setCurrentAgent(toChatAgent(updatedClone));
|
|
||||||
setAgentDraft(createAgentDraft(updatedClone, updatedClone.model || currentModel));
|
|
||||||
setIsEditingAgent(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadSnapshots = useCallback(async () => {
|
|
||||||
const agentId = currentAgent?.id;
|
|
||||||
if (!agentId) return;
|
|
||||||
setSnapshotsLoading(true);
|
|
||||||
setSnapshotsError(null);
|
|
||||||
try {
|
|
||||||
const result = await intelligenceClient.identity.getSnapshots(agentId, 20);
|
|
||||||
setSnapshots(result);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
setSnapshotsError(`加载快照失败: ${msg}`);
|
|
||||||
} finally {
|
|
||||||
setSnapshotsLoading(false);
|
|
||||||
}
|
|
||||||
}, [currentAgent?.id]);
|
|
||||||
|
|
||||||
const handleRestoreSnapshot = useCallback(async (snapshotId: string) => {
|
|
||||||
const agentId = currentAgent?.id;
|
|
||||||
if (!agentId) return;
|
|
||||||
setRestoringSnapshotId(snapshotId);
|
|
||||||
setSnapshotsError(null);
|
|
||||||
setConfirmRestoreId(null);
|
|
||||||
try {
|
|
||||||
await intelligenceClient.identity.restoreSnapshot(agentId, snapshotId);
|
|
||||||
await loadSnapshots();
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
setSnapshotsError(`回滚失败: ${msg}`);
|
|
||||||
} finally {
|
|
||||||
setRestoringSnapshotId(null);
|
|
||||||
}
|
|
||||||
}, [currentAgent?.id, loadSnapshots]);
|
|
||||||
|
|
||||||
// Load snapshots when agent tab is active and agent changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'agent' && currentAgent?.id) {
|
|
||||||
loadSnapshots();
|
|
||||||
}
|
|
||||||
}, [activeTab, currentAgent?.id, loadSnapshots]);
|
|
||||||
|
|
||||||
const runtimeSummary = connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接';
|
const runtimeSummary = connected ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接';
|
||||||
const userNameDisplay = selectedClone?.userName || quickConfig.userName || 'User';
|
|
||||||
const userAddressing = selectedClone?.nickname || selectedClone?.userName || quickConfig.userName || 'User';
|
|
||||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '系统时区';
|
|
||||||
|
|
||||||
// Extract code blocks from all messages (both from codeBlocks property and content parsing)
|
// Extract code blocks from all messages (both from codeBlocks property and content parsing)
|
||||||
const codeSnippets = useMemo((): CodeSnippet[] => {
|
const codeSnippets = useMemo((): CodeSnippet[] => {
|
||||||
@@ -320,7 +207,7 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
|
|||||||
{/* 顶部工具栏 - Tab 栏 */}
|
{/* 顶部工具栏 - Tab 栏 */}
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
<div className="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||||
{simpleMode ? (
|
{simpleMode ? (
|
||||||
/* 简洁模式: 仅 状态 / Agent / 管家 */
|
/* 简洁模式: 仅 状态 / 管家 */
|
||||||
<div className="flex items-center px-2 py-2 gap-1">
|
<div className="flex items-center px-2 py-2 gap-1">
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'status'}
|
active={activeTab === 'status'}
|
||||||
@@ -328,12 +215,6 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
|
|||||||
icon={<Activity className="w-4 h-4" />}
|
icon={<Activity className="w-4 h-4" />}
|
||||||
label="状态"
|
label="状态"
|
||||||
/>
|
/>
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'agent'}
|
|
||||||
onClick={() => setActiveTab('agent')}
|
|
||||||
icon={<User className="w-4 h-4" />}
|
|
||||||
label="Agent"
|
|
||||||
/>
|
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'butler'}
|
active={activeTab === 'butler'}
|
||||||
onClick={() => setActiveTab('butler')}
|
onClick={() => setActiveTab('butler')}
|
||||||
@@ -351,12 +232,6 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
|
|||||||
icon={<Activity className="w-4 h-4" />}
|
icon={<Activity className="w-4 h-4" />}
|
||||||
label="状态"
|
label="状态"
|
||||||
/>
|
/>
|
||||||
<TabButton
|
|
||||||
active={activeTab === 'agent'}
|
|
||||||
onClick={() => setActiveTab('agent')}
|
|
||||||
icon={<User className="w-4 h-4" />}
|
|
||||||
label="Agent"
|
|
||||||
/>
|
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'files'}
|
active={activeTab === 'files'}
|
||||||
onClick={() => setActiveTab('files')}
|
onClick={() => setActiveTab('files')}
|
||||||
@@ -472,289 +347,6 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
|
|||||||
<IdentityChangeProposalPanel />
|
<IdentityChangeProposalPanel />
|
||||||
) : activeTab === 'butler' ? (
|
) : activeTab === 'butler' ? (
|
||||||
<ButlerPanel agentId={currentAgent?.id} />
|
<ButlerPanel agentId={currentAgent?.id} />
|
||||||
) : activeTab === 'agent'? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<motion.div
|
|
||||||
whileHover={cardHover}
|
|
||||||
transition={defaultTransition}
|
|
||||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-orange-400 to-red-500 flex items-center justify-center text-white text-lg font-semibold">
|
|
||||||
{selectedClone?.emoji ? (
|
|
||||||
<span className="text-2xl">{selectedClone.emoji}</span>
|
|
||||||
) : (
|
|
||||||
<span>🦞</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
|
||||||
{selectedClone?.name || currentAgent?.name || '全能助手'}
|
|
||||||
{selectedClone?.personality ? (
|
|
||||||
<Badge variant="default" className="text-xs ml-1">
|
|
||||||
{getPersonalityById(selectedClone.personality)?.label || selectedClone.personality}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="default" className="text-xs ml-1">
|
|
||||||
友好亲切
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">{selectedClone?.role || '全能型 AI 助手'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectedClone ? (
|
|
||||||
isEditingAgent ? (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
aria-label="Cancel edit"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { handleSaveAgent().catch(silentErrorHandler('RightPanel')); }}
|
|
||||||
aria-label="Save edit"
|
|
||||||
>
|
|
||||||
保存
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleStartEdit}
|
|
||||||
aria-label="Edit Agent"
|
|
||||||
>
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
whileHover={cardHover}
|
|
||||||
transition={defaultTransition}
|
|
||||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">关于我</div>
|
|
||||||
{isEditingAgent && agentDraft ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<AgentInput label="名称" value={agentDraft.name} onChange={(value) => setAgentDraft({ ...agentDraft, name: value })} />
|
|
||||||
<AgentInput label="角色" value={agentDraft.role} onChange={(value) => setAgentDraft({ ...agentDraft, role: value })} />
|
|
||||||
<AgentInput label="昵称" value={agentDraft.nickname} onChange={(value) => setAgentDraft({ ...agentDraft, nickname: value })} />
|
|
||||||
<AgentInput label="模型" value={agentDraft.model} onChange={(value) => setAgentDraft({ ...agentDraft, model: value })} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<AgentRow label="角色" value={selectedClone?.role || '全能型 AI 助手'} />
|
|
||||||
<AgentRow label="昵称" value={selectedClone?.nickname || '小龙'} />
|
|
||||||
<AgentRow label="模型" value={selectedClone?.model || currentModel} />
|
|
||||||
<AgentRow label="表情" value={selectedClone?.emoji || '🦞'} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
whileHover={cardHover}
|
|
||||||
transition={defaultTransition}
|
|
||||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">我眼中的你</div>
|
|
||||||
{isEditingAgent && agentDraft ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<AgentInput label="你的名称" value={agentDraft.userName} onChange={(value) => setAgentDraft({ ...agentDraft, userName: value })} />
|
|
||||||
<AgentInput label="你的角色" value={agentDraft.userRole} onChange={(value) => setAgentDraft({ ...agentDraft, userRole: value })} />
|
|
||||||
<AgentInput label="场景" value={agentDraft.scenarios} onChange={(value) => setAgentDraft({ ...agentDraft, scenarios: value })} placeholder="编程, 研究" />
|
|
||||||
<AgentInput label="工作区" value={agentDraft.workspaceDir} onChange={(value) => setAgentDraft({ ...agentDraft, workspaceDir: value })} />
|
|
||||||
<AgentToggle label="文件限制" checked={agentDraft.restrictFiles} onChange={(value) => setAgentDraft({ ...agentDraft, restrictFiles: value })} />
|
|
||||||
<AgentToggle label="隐私计划" checked={agentDraft.privacyOptIn} onChange={(value) => setAgentDraft({ ...agentDraft, privacyOptIn: value })} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<AgentRow label="你的名称" value={userNameDisplay} />
|
|
||||||
<AgentRow label="称呼方式" value={userAddressing} />
|
|
||||||
<AgentRow label="时区" value={localTimezone} />
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="w-16 text-gray-500 dark:text-gray-400">专注</div>
|
|
||||||
<div className="flex-1 flex flex-wrap gap-2">
|
|
||||||
{focusAreas.map((item) => (
|
|
||||||
<Badge key={item} variant="default">{item}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AgentRow label="工作区" value={selectedClone?.workspaceDir || workspaceInfo?.path || '~/.zclaw/zclaw-workspace'} />
|
|
||||||
<AgentRow label="已解析" value={selectedClone?.workspaceResolvedPath || workspaceInfo?.resolvedPath || '-'} />
|
|
||||||
<AgentRow label="文件限制" value={selectedClone?.restrictFiles ? '已开启' : '已关闭'} />
|
|
||||||
<AgentRow label="隐私计划" value={selectedClone?.privacyOptIn ? '已加入' : '未加入'} />
|
|
||||||
{/* Dynamic: UserProfile data (from conversation learning) */}
|
|
||||||
{userProfile && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800">
|
|
||||||
<div className="text-xs text-gray-400 mb-2">对话中了解到的</div>
|
|
||||||
{userProfile.industry ? (
|
|
||||||
<AgentRow label="行业" value={String(userProfile.industry)} />
|
|
||||||
) : null}
|
|
||||||
{userProfile.role ? (
|
|
||||||
<AgentRow label="角色" value={String(userProfile.role)} />
|
|
||||||
) : null}
|
|
||||||
{userProfile.communicationStyle ? (
|
|
||||||
<AgentRow label="沟通偏好" value={String(userProfile.communicationStyle)} />
|
|
||||||
) : null}
|
|
||||||
{Array.isArray(userProfile.recentTopics) && (userProfile.recentTopics as string[]).length > 0 ? (
|
|
||||||
<AgentRow label="近期话题" value={(userProfile.recentTopics as string[]).slice(0, 5).join(', ')} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
whileHover={cardHover}
|
|
||||||
transition={defaultTransition}
|
|
||||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">引导文件</div>
|
|
||||||
<Badge variant={selectedClone?.bootstrapReady ? 'success' : 'default'}>
|
|
||||||
{selectedClone?.bootstrapReady ? '已生成' : '未生成'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{bootstrapFiles.length > 0 ? bootstrapFiles.map((file) => (
|
|
||||||
<div key={file.name} className="rounded-lg border border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50 px-3 py-2">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="font-medium text-gray-800 dark:text-gray-200">{file.name}</span>
|
|
||||||
<Badge variant={file.exists ? 'success' : 'error'}>
|
|
||||||
{file.exists ? '已存在' : '缺失'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 break-all">{file.path}</div>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">该 Agent 尚未生成引导文件。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 历史快照 */}
|
|
||||||
<motion.div
|
|
||||||
whileHover={cardHover}
|
|
||||||
transition={defaultTransition}
|
|
||||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-full flex items-center justify-between mb-0"
|
|
||||||
onClick={() => setSnapshotsExpanded(!snapshotsExpanded)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<History className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">历史快照</span>
|
|
||||||
{snapshots.length > 0 && (
|
|
||||||
<Badge variant="default" className="text-xs">{snapshots.length}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{snapshotsExpanded ? (
|
|
||||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{snapshotsExpanded && (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{snapshotsError && (
|
|
||||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs">
|
|
||||||
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
|
|
||||||
<span>{snapshotsError}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{snapshotsLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400 text-xs">
|
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
加载中...
|
|
||||||
</div>
|
|
||||||
) : snapshots.length === 0 ? (
|
|
||||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-xs bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-700">
|
|
||||||
暂无快照记录
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
snapshots.map((snap) => {
|
|
||||||
const isRestoring = restoringSnapshotId === snap.id;
|
|
||||||
const isConfirming = confirmRestoreId === snap.id;
|
|
||||||
const timeLabel = formatSnapshotTime(snap.timestamp);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={snap.id}
|
|
||||||
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700"
|
|
||||||
>
|
|
||||||
<div className="w-7 h-7 rounded-md bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<History className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">{timeLabel}</span>
|
|
||||||
{isConfirming ? (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setConfirmRestoreId(null)}
|
|
||||||
disabled={isRestoring}
|
|
||||||
className="text-xs px-2 py-0.5 h-auto"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRestoreSnapshot(snap.id)}
|
|
||||||
disabled={isRestoring}
|
|
||||||
className="text-xs px-2 py-0.5 h-auto bg-orange-500 hover:bg-orange-600"
|
|
||||||
>
|
|
||||||
{isRestoring ? (
|
|
||||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
|
||||||
)}
|
|
||||||
确认回滚
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setConfirmRestoreId(snap.id)}
|
|
||||||
disabled={restoringSnapshotId !== null}
|
|
||||||
className="text-xs text-gray-500 hover:text-orange-600 px-2 py-0.5 h-auto"
|
|
||||||
title="回滚到此版本"
|
|
||||||
>
|
|
||||||
<RotateCcw className="w-3 h-3 mr-1" />
|
|
||||||
回滚
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1 truncate" title={snap.reason}>
|
|
||||||
{snap.reason || '自动快照'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
) : activeTab === 'files' ? (
|
) : activeTab === 'files' ? (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<CodeSnippetPanel snippets={codeSnippets} />
|
<CodeSnippetPanel snippets={codeSnippets} />
|
||||||
@@ -978,107 +570,3 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="w-16 text-gray-500">{label}</div>
|
|
||||||
<div className="flex-1 text-gray-700 break-all">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentDraft = {
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
nickname: string;
|
|
||||||
model: string;
|
|
||||||
scenarios: string;
|
|
||||||
workspaceDir: string;
|
|
||||||
userName: string;
|
|
||||||
userRole: string;
|
|
||||||
restrictFiles: boolean;
|
|
||||||
privacyOptIn: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function createAgentDraft(
|
|
||||||
clone: {
|
|
||||||
name: string;
|
|
||||||
role?: string;
|
|
||||||
nickname?: string;
|
|
||||||
model?: string;
|
|
||||||
scenarios?: string[];
|
|
||||||
workspaceDir?: string;
|
|
||||||
userName?: string;
|
|
||||||
userRole?: string;
|
|
||||||
restrictFiles?: boolean;
|
|
||||||
privacyOptIn?: boolean;
|
|
||||||
},
|
|
||||||
currentModel: string
|
|
||||||
): AgentDraft {
|
|
||||||
return {
|
|
||||||
name: clone.name || '',
|
|
||||||
role: clone.role || '',
|
|
||||||
nickname: clone.nickname || '',
|
|
||||||
model: clone.model || currentModel,
|
|
||||||
scenarios: clone.scenarios?.join(', ') || '',
|
|
||||||
workspaceDir: clone.workspaceDir || '~/.zclaw/zclaw-workspace',
|
|
||||||
userName: clone.userName || '',
|
|
||||||
userRole: clone.userRole || '',
|
|
||||||
restrictFiles: clone.restrictFiles ?? true,
|
|
||||||
privacyOptIn: clone.privacyOptIn ?? false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentInput({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="block">
|
|
||||||
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="w-full text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentToggle({
|
|
||||||
label,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (value: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label className="flex items-center justify-between text-sm text-gray-700 border border-gray-100 rounded-lg px-3 py-2">
|
|
||||||
<span>{label}</span>
|
|
||||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSnapshotTime(timestamp: string): string {
|
|
||||||
const now = Date.now();
|
|
||||||
const then = new Date(timestamp).getTime();
|
|
||||||
const diff = now - then;
|
|
||||||
|
|
||||||
if (diff < 60000) return '刚刚';
|
|
||||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
|
|
||||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
|
|
||||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`;
|
|
||||||
return new Date(timestamp).toLocaleDateString('zh-CN');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import {
|
|||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Download,
|
Download,
|
||||||
Copy,
|
Copy,
|
||||||
ChevronLeft,
|
ChevronDown,
|
||||||
File,
|
File,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -76,6 +77,7 @@ export function ArtifactPanel({
|
|||||||
className = '',
|
className = '',
|
||||||
}: ArtifactPanelProps) {
|
}: ArtifactPanelProps) {
|
||||||
const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview');
|
const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview');
|
||||||
|
const [fileMenuOpen, setFileMenuOpen] = useState(false);
|
||||||
const selected = useMemo(
|
const selected = useMemo(
|
||||||
() => artifacts.find((a) => a.id === selectedId),
|
() => artifacts.find((a) => a.id === selectedId),
|
||||||
[artifacts, selectedId]
|
[artifacts, selectedId]
|
||||||
@@ -135,22 +137,59 @@ export function ArtifactPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-full flex flex-col ${className}`}>
|
<div className={`h-full flex flex-col ${className}`}>
|
||||||
{/* File header */}
|
{/* File header with inline file selector */}
|
||||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2 flex-shrink-0">
|
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center gap-2 flex-shrink-0">
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={() => onSelect('')}
|
<button
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
onClick={() => setFileMenuOpen(!fileMenuOpen)}
|
||||||
title="返回文件列表"
|
className="flex items-center gap-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 truncate hover:text-orange-500 transition-colors"
|
||||||
>
|
title="切换文件"
|
||||||
<ChevronLeft className="w-4 h-4" />
|
>
|
||||||
</button>
|
<Icon className="w-4 h-4 text-orange-500 flex-shrink-0" />
|
||||||
<Icon className="w-4 h-4 text-orange-500 flex-shrink-0" />
|
<span className="truncate max-w-[120px]">{selected.name}</span>
|
||||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200 truncate flex-1">
|
{artifacts.length > 1 && (
|
||||||
{selected.name}
|
<ChevronDown className={`w-3.5 h-3.5 text-gray-400 transition-transform ${fileMenuOpen ? 'rotate-180' : ''}`} />
|
||||||
</span>
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* File selector dropdown */}
|
||||||
|
{fileMenuOpen && artifacts.length > 1 && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setFileMenuOpen(false)} />
|
||||||
|
<div className="absolute top-full left-0 mt-1 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-20 py-1 max-h-60 overflow-y-auto">
|
||||||
|
{artifacts.map((artifact) => {
|
||||||
|
const ItemIcon = getFileIcon(artifact.type);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={artifact.id}
|
||||||
|
onClick={() => { onSelect(artifact.id); setFileMenuOpen(false); }}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
|
||||||
|
artifact.id === selected.id ? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300' : 'text-gray-700 dark:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ItemIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="truncate flex-1">{artifact.name}</span>
|
||||||
|
<span className={`text-[10px] px-1 py-0.5 rounded ${getTypeColor(artifact.type)}`}>
|
||||||
|
{getTypeLabel(artifact.type)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${getTypeColor(selected.type)}`}>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${getTypeColor(selected.type)}`}>
|
||||||
{getTypeLabel(selected.type)}
|
{getTypeLabel(selected.type)}
|
||||||
</span>
|
</span>
|
||||||
|
{selected.language && (
|
||||||
|
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||||
|
{selected.language}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View mode toggle */}
|
{/* View mode toggle */}
|
||||||
@@ -180,19 +219,7 @@ export function ArtifactPanel({
|
|||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
|
||||||
{viewMode === 'preview' ? (
|
{viewMode === 'preview' ? (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
<ArtifactContentPreview artifact={selected} />
|
||||||
{selected.type === 'markdown' ? (
|
|
||||||
<MarkdownPreview content={selected.content} />
|
|
||||||
) : selected.type === 'code' ? (
|
|
||||||
<pre className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-xs font-mono overflow-x-auto text-gray-700 dark:text-gray-200">
|
|
||||||
{selected.content}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">
|
|
||||||
{selected.content}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<pre className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-xs font-mono overflow-x-auto text-gray-700 dark:text-gray-200 leading-relaxed">
|
<pre className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3 text-xs font-mono overflow-x-auto text-gray-700 dark:text-gray-200 leading-relaxed">
|
||||||
{selected.content}
|
{selected.content}
|
||||||
@@ -217,6 +244,37 @@ export function ArtifactPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ArtifactContentPreview — renders artifact based on type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function ArtifactContentPreview({ artifact }: { artifact: ArtifactFile }) {
|
||||||
|
if (artifact.type === 'markdown') {
|
||||||
|
return <MarkdownRenderer content={artifact.content} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifact.type === 'code') {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{artifact.language && (
|
||||||
|
<div className="absolute top-2 right-2 text-[10px] text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded">
|
||||||
|
{artifact.language}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 text-xs font-mono overflow-x-auto text-gray-700 dark:text-gray-200 leading-relaxed border border-gray-200 dark:border-gray-700">
|
||||||
|
{artifact.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{artifact.content}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ActionButton
|
// ActionButton
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -243,50 +301,6 @@ function ActionButton({ icon, label, onClick }: { icon: React.ReactNode; label:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Simple Markdown preview (no external deps)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function MarkdownPreview({ content }: { content: string }) {
|
|
||||||
// Basic markdown rendering: headings, bold, code blocks, lists
|
|
||||||
const lines = content.split('\n');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{lines.map((line, i) => {
|
|
||||||
// Heading
|
|
||||||
if (line.startsWith('### ')) {
|
|
||||||
return <h3 key={i} className="text-sm font-bold text-gray-800 dark:text-gray-100 mt-3">{line.slice(4)}</h3>;
|
|
||||||
}
|
|
||||||
if (line.startsWith('## ')) {
|
|
||||||
return <h2 key={i} className="text-base font-bold text-gray-800 dark:text-gray-100 mt-4">{line.slice(3)}</h2>;
|
|
||||||
}
|
|
||||||
if (line.startsWith('# ')) {
|
|
||||||
return <h1 key={i} className="text-lg font-bold text-gray-800 dark:text-gray-100">{line.slice(2)}</h1>;
|
|
||||||
}
|
|
||||||
// Code block (simplified)
|
|
||||||
if (line.startsWith('```')) return null;
|
|
||||||
// List item
|
|
||||||
if (line.startsWith('- ') || line.startsWith('* ')) {
|
|
||||||
return <li key={i} className="text-sm text-gray-700 dark:text-gray-300 ml-4">{renderInline(line.slice(2))}</li>;
|
|
||||||
}
|
|
||||||
// Empty line
|
|
||||||
if (!line.trim()) return <div key={i} className="h-2" />;
|
|
||||||
// Regular paragraph
|
|
||||||
return <p key={i} className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{renderInline(line)}</p>;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInline(text: string): React.ReactNode {
|
|
||||||
// Bold
|
|
||||||
const parts = text.split(/\*\*(.*?)\*\*/g);
|
|
||||||
return parts.map((part, i) =>
|
|
||||||
i % 2 === 1 ? <strong key={i} className="font-semibold">{part}</strong> : part
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Download helper
|
// Download helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
123
desktop/src/components/ai/MarkdownRenderer.tsx
Normal file
123
desktop/src/components/ai/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* MarkdownRenderer — shared Markdown rendering with styled components.
|
||||||
|
*
|
||||||
|
* Extracted from StreamingText.tsx so ArtifactPanel and other consumers
|
||||||
|
* can reuse the same rich rendering (GFM tables, syntax blocks, etc.)
|
||||||
|
* without duplicating the component overrides.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import type { Components } from 'react-markdown';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared component overrides for react-markdown
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const markdownComponents: Components = {
|
||||||
|
pre({ children }) {
|
||||||
|
return (
|
||||||
|
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm leading-relaxed border border-gray-200 dark:border-gray-700 my-3">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
code({ className, children, ...props }) {
|
||||||
|
const isBlock = className?.startsWith('language-');
|
||||||
|
if (isBlock) {
|
||||||
|
return (
|
||||||
|
<code className={`${className || ''} text-gray-800 dark:text-gray-200`} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-1.5 py-0.5 rounded text-[0.9em] font-mono" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
table({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto my-3 -mx-1">
|
||||||
|
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700 rounded-lg text-sm">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
thead({ children }) {
|
||||||
|
return <thead className="bg-gray-50 dark:bg-gray-800/50">{children}</thead>;
|
||||||
|
},
|
||||||
|
th({ children }) {
|
||||||
|
return (
|
||||||
|
<th className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
td({ children }) {
|
||||||
|
return (
|
||||||
|
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ul({ children }) {
|
||||||
|
return <ul className="list-disc list-outside ml-5 my-2 space-y-1">{children}</ul>;
|
||||||
|
},
|
||||||
|
ol({ children }) {
|
||||||
|
return <ol className="list-decimal list-outside ml-5 my-2 space-y-1">{children}</ol>;
|
||||||
|
},
|
||||||
|
li({ children }) {
|
||||||
|
return <li className="leading-relaxed">{children}</li>;
|
||||||
|
},
|
||||||
|
h1({ children }) {
|
||||||
|
return <h1 className="text-xl font-bold mt-5 mb-3 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h1>;
|
||||||
|
},
|
||||||
|
h2({ children }) {
|
||||||
|
return <h2 className="text-lg font-bold mt-4 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h2>;
|
||||||
|
},
|
||||||
|
h3({ children }) {
|
||||||
|
return <h3 className="text-base font-semibold mt-3 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h3>;
|
||||||
|
},
|
||||||
|
blockquote({ children }) {
|
||||||
|
return (
|
||||||
|
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 py-1 my-3 text-gray-600 dark:text-gray-400 italic bg-gray-50 dark:bg-gray-800/30 rounded-r-lg">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
p({ children }) {
|
||||||
|
return <p className="my-2 leading-relaxed first:mt-0 last:mb-0">{children}</p>;
|
||||||
|
},
|
||||||
|
a({ href, children }) {
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
hr() {
|
||||||
|
return <hr className="my-4 border-gray-200 dark:border-gray-700" />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convenience wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MarkdownRendererProps {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
||||||
|
return (
|
||||||
|
<div className={`prose-sm prose-gray dark:prose-invert max-w-none ${className}`}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import type { Components } from 'react-markdown';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streaming text with word-by-word reveal animation.
|
* Streaming text with word-by-word reveal animation.
|
||||||
@@ -18,111 +16,6 @@ interface StreamingTextProps {
|
|||||||
asMarkdown?: boolean;
|
asMarkdown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Markdown component overrides for rich rendering
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const markdownComponents: Components = {
|
|
||||||
// Code blocks (```...```)
|
|
||||||
pre({ children }) {
|
|
||||||
return (
|
|
||||||
<pre className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto text-sm leading-relaxed border border-gray-200 dark:border-gray-700 my-3">
|
|
||||||
{children}
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Inline code (`...`)
|
|
||||||
code({ className, children, ...props }) {
|
|
||||||
// If it has a language class, it's inside a code block — render as block
|
|
||||||
const isBlock = className?.startsWith('language-');
|
|
||||||
if (isBlock) {
|
|
||||||
return (
|
|
||||||
<code className={`${className || ''} text-gray-800 dark:text-gray-200`} {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<code className="bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-1.5 py-0.5 rounded text-[0.9em] font-mono" {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Tables
|
|
||||||
table({ children }) {
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto my-3 -mx-1">
|
|
||||||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700 rounded-lg text-sm">
|
|
||||||
{children}
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
thead({ children }) {
|
|
||||||
return <thead className="bg-gray-50 dark:bg-gray-800/50">{children}</thead>;
|
|
||||||
},
|
|
||||||
th({ children }) {
|
|
||||||
return (
|
|
||||||
<th className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">
|
|
||||||
{children}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
td({ children }) {
|
|
||||||
return (
|
|
||||||
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-gray-600 dark:text-gray-400">
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Unordered lists
|
|
||||||
ul({ children }) {
|
|
||||||
return <ul className="list-disc list-outside ml-5 my-2 space-y-1">{children}</ul>;
|
|
||||||
},
|
|
||||||
// Ordered lists
|
|
||||||
ol({ children }) {
|
|
||||||
return <ol className="list-decimal list-outside ml-5 my-2 space-y-1">{children}</ol>;
|
|
||||||
},
|
|
||||||
// List items
|
|
||||||
li({ children }) {
|
|
||||||
return <li className="leading-relaxed">{children}</li>;
|
|
||||||
},
|
|
||||||
// Headings
|
|
||||||
h1({ children }) {
|
|
||||||
return <h1 className="text-xl font-bold mt-5 mb-3 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h1>;
|
|
||||||
},
|
|
||||||
h2({ children }) {
|
|
||||||
return <h2 className="text-lg font-bold mt-4 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h2>;
|
|
||||||
},
|
|
||||||
h3({ children }) {
|
|
||||||
return <h3 className="text-base font-semibold mt-3 mb-2 text-gray-900 dark:text-gray-100 first:mt-0">{children}</h3>;
|
|
||||||
},
|
|
||||||
// Blockquotes
|
|
||||||
blockquote({ children }) {
|
|
||||||
return (
|
|
||||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 py-1 my-3 text-gray-600 dark:text-gray-400 italic bg-gray-50 dark:bg-gray-800/30 rounded-r-lg">
|
|
||||||
{children}
|
|
||||||
</blockquote>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Paragraphs
|
|
||||||
p({ children }) {
|
|
||||||
return <p className="my-2 leading-relaxed first:mt-0 last:mb-0">{children}</p>;
|
|
||||||
},
|
|
||||||
// Links
|
|
||||||
a({ href, children }) {
|
|
||||||
return (
|
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 underline hover:text-blue-800 dark:hover:text-blue-300">
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
// Horizontal rules
|
|
||||||
hr() {
|
|
||||||
return <hr className="my-4 border-gray-200 dark:border-gray-700" />;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Token splitter for streaming animation
|
// Token splitter for streaming animation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -176,13 +69,7 @@ export function StreamingText({
|
|||||||
}: StreamingTextProps) {
|
}: StreamingTextProps) {
|
||||||
// For completed messages, use full markdown rendering with styled components
|
// For completed messages, use full markdown rendering with styled components
|
||||||
if (!isStreaming && asMarkdown) {
|
if (!isStreaming && asMarkdown) {
|
||||||
return (
|
return <MarkdownRenderer content={content} className={className} />;
|
||||||
<div className={`prose-sm prose-gray dark:prose-invert max-w-none ${className}`}>
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
||||||
{content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For streaming messages, use token-by-token animation
|
// For streaming messages, use token-by-token animation
|
||||||
|
|||||||
@@ -7,15 +7,30 @@ import { motion } from 'framer-motion';
|
|||||||
* - Horizontal scrollable chip list
|
* - Horizontal scrollable chip list
|
||||||
* - Click to fill input
|
* - Click to fill input
|
||||||
* - Animated entrance
|
* - Animated entrance
|
||||||
|
* - Loading skeleton while LLM generates suggestions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface SuggestionChipsProps {
|
interface SuggestionChipsProps {
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
|
loading?: boolean;
|
||||||
onSelect: (text: string) => void;
|
onSelect: (text: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestionChips({ suggestions, onSelect, className = '' }: SuggestionChipsProps) {
|
export function SuggestionChips({ suggestions, loading, onSelect, className = '' }: SuggestionChipsProps) {
|
||||||
|
if (loading && suggestions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap gap-2 ${className}`}>
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-7 w-28 rounded-full bg-gray-100 dark:bg-gray-800 animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (suggestions.length === 0) return null;
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ interface ToolStepRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ToolStepRow({ step, isActive, showConnector }: ToolStepRowProps) {
|
function ToolStepRow({ step, isActive, showConnector }: ToolStepRowProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
// Clarification cards default to expanded so users see options immediately
|
||||||
|
const [expanded, setExpanded] = useState(step.toolName === 'ask_clarification');
|
||||||
const Icon = getToolIcon(step.toolName);
|
const Icon = getToolIcon(step.toolName);
|
||||||
const label = getToolLabel(step.toolName);
|
const label = getToolLabel(step.toolName);
|
||||||
const isRunning = step.status === 'running';
|
const isRunning = step.status === 'running';
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export { SuggestionChips } from './SuggestionChips';
|
|||||||
export { ResizableChatLayout } from './ResizableChatLayout';
|
export { ResizableChatLayout } from './ResizableChatLayout';
|
||||||
export { ToolCallChain, type ToolCallStep } from './ToolCallChain';
|
export { ToolCallChain, type ToolCallStep } from './ToolCallChain';
|
||||||
export { ArtifactPanel, type ArtifactFile } from './ArtifactPanel';
|
export { ArtifactPanel, type ArtifactFile } from './ArtifactPanel';
|
||||||
|
export { MarkdownRenderer, markdownComponents } from './MarkdownRenderer';
|
||||||
export { TokenMeter } from './TokenMeter';
|
export { TokenMeter } from './TokenMeter';
|
||||||
|
|||||||
@@ -146,6 +146,48 @@ export function detectNameSuggestion(message: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if user gives the agent a name.
|
||||||
|
* Covers: "叫你小马", "你就叫小芳", "名称改为小芳", "名字叫小马",
|
||||||
|
* "改名为X", "起名X", "称呼你为X", English patterns, etc.
|
||||||
|
*/
|
||||||
|
export function detectAgentNameSuggestion(message: string): string | undefined {
|
||||||
|
if (!message || typeof message !== 'string') return undefined;
|
||||||
|
// Trigger phrases: the name appears RIGHT AFTER the matched trigger
|
||||||
|
const triggers = [
|
||||||
|
/叫你\s*[""''「」]?/, // "叫你小马"
|
||||||
|
/你就叫\s*[""''「」]?/, // "你就叫小芳"
|
||||||
|
/你(?:以後|以后)?叫\s*[""''「」]?/, // "你叫小马" / "你以后叫小马"
|
||||||
|
/[名].{0,2}[为是叫成]\s*[""''「」]?/, // "名称改为" / "名字是" / "名称改成"
|
||||||
|
/改[名为称叫]\s*[""''「」]?/, // "改名为X" / "改名X" / "改称X"
|
||||||
|
/起[个]?名[字]?(?:叫)?\s*[""''「」]?/, // "起名X" / "起名叫X"
|
||||||
|
/称呼[你你].{0,2}[为是]\s*[""''「」]?/, // "称呼你为X"
|
||||||
|
/\bname you\s+/i,
|
||||||
|
/\bcall you\s+/i,
|
||||||
|
/\byour name\s+(?:is|should be)\s+/i,
|
||||||
|
];
|
||||||
|
const stopWords = new Set([
|
||||||
|
'你', '我', '他', '她', '它', '的', '了', '是', '在', '有', '不',
|
||||||
|
'也', '都', '还', '又', '这', '那', '什么', '怎么', '为什么', '可以',
|
||||||
|
'能', '会', '要', '想', '去', '来', '做', '说', '看', '好', '吧',
|
||||||
|
'呢', '啊', '哦', '嗯', '哈', '呀', '嘛',
|
||||||
|
]);
|
||||||
|
for (const trigger of triggers) {
|
||||||
|
const m = message.match(trigger);
|
||||||
|
if (!m) continue;
|
||||||
|
// Extract 1-6 Chinese characters or word chars after the trigger
|
||||||
|
const rest = message.slice(m.index! + m[0].length);
|
||||||
|
const nameMatch = rest.match(/^[""''「」]?([一-鿿]{1,6}|\w{1,10})/);
|
||||||
|
if (nameMatch && nameMatch[1]) {
|
||||||
|
const raw = nameMatch[1].replace(/[吧。!,、呢啊了]+$/g, '').trim();
|
||||||
|
if (raw.length >= 1 && raw.length <= 8 && !stopWords.has(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the next cold start phase based on current phase and user message.
|
* Determine the next cold start phase based on current phase and user message.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -696,13 +696,14 @@ export class GatewayClient {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'tool_call':
|
case 'tool_call':
|
||||||
// Tool call event
|
// Tool call start: onTool(name, input, '') — empty output signals start
|
||||||
if (callbacks.onTool && data.tool) {
|
if (callbacks.onTool && data.tool) {
|
||||||
callbacks.onTool(data.tool, JSON.stringify(data.input || {}), data.output || '');
|
callbacks.onTool(data.tool, JSON.stringify(data.input || {}), '');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'tool_result':
|
case 'tool_result':
|
||||||
|
// Tool call end: onTool(name, '', output) — empty input signals end
|
||||||
if (callbacks.onTool && data.tool) {
|
if (callbacks.onTool && data.tool) {
|
||||||
callbacks.onTool(data.tool, '', String(data.result || data.output || ''));
|
callbacks.onTool(data.tool, '', String(data.result || data.output || ''));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,36 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
|||||||
*/
|
*/
|
||||||
proto.listClones = async function (this: KernelClient): Promise<{ clones: any[] }> {
|
proto.listClones = async function (this: KernelClient): Promise<{ clones: any[] }> {
|
||||||
const agents = await this.listAgents();
|
const agents = await this.listAgents();
|
||||||
const clones = agents.map((agent) => {
|
|
||||||
|
// Enrich each agent with: (a) full profile from agent_get, (b) identity user_profile file
|
||||||
|
const enriched = await Promise.all(
|
||||||
|
agents.map(async (agent) => {
|
||||||
|
// Fetch full agent data (includes UserProfile from SQLite)
|
||||||
|
let full: AgentInfo | null = null;
|
||||||
|
try {
|
||||||
|
full = await invoke<AgentInfo | null>('agent_get', { agentId: agent.id });
|
||||||
|
} catch { /* non-critical */ }
|
||||||
|
|
||||||
|
// Fetch identity user_profile file (stores user-configured userName/userRole)
|
||||||
|
let identityUserName: string | undefined;
|
||||||
|
let identityUserRole: string | undefined;
|
||||||
|
try {
|
||||||
|
const content = await invoke<string | null>('identity_get_file', { agentId: agent.id, file: 'user_profile' });
|
||||||
|
if (content) {
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const nameMatch = line.match(/^-\s*姓名[::]\s*(.+)$/);
|
||||||
|
if (nameMatch?.[1]?.trim()) identityUserName = nameMatch[1].trim();
|
||||||
|
const roleMatch = line.match(/^-\s*角色[::]\s*(.+)$/);
|
||||||
|
if (roleMatch?.[1]?.trim()) identityUserRole = roleMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* non-critical */ }
|
||||||
|
|
||||||
|
return { agent: full || agent, identityUserName, identityUserRole };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const clones = enriched.map(({ agent, identityUserName, identityUserRole }) => {
|
||||||
// Parse personality/emoji/nickname from SOUL.md content
|
// Parse personality/emoji/nickname from SOUL.md content
|
||||||
const soulLines = (agent.soul || '').split('\n');
|
const soulLines = (agent.soul || '').split('\n');
|
||||||
let emoji: string | undefined;
|
let emoji: string | undefined;
|
||||||
@@ -86,13 +115,16 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse userName/userRole from userProfile
|
// Merge userName/userRole: user-configured (identity files) > learned (UserProfileStore)
|
||||||
let userName: string | undefined;
|
let userName = identityUserName;
|
||||||
let userRole: string | undefined;
|
let userRole = identityUserRole;
|
||||||
if (agent.userProfile && typeof agent.userProfile === 'object') {
|
if (!userName && agent.userProfile && typeof agent.userProfile === 'object') {
|
||||||
const profile = agent.userProfile as Record<string, unknown>;
|
const profile = agent.userProfile as Record<string, unknown>;
|
||||||
userName = profile.userName as string | undefined || profile.name as string | undefined;
|
userName = (profile.userName || profile.name) as string | undefined;
|
||||||
userRole = profile.userRole as string | undefined || profile.role as string | undefined;
|
}
|
||||||
|
if (!userRole && agent.userProfile && typeof agent.userProfile === 'object') {
|
||||||
|
const profile = agent.userProfile as Record<string, unknown>;
|
||||||
|
userRole = (profile.userRole || profile.role) as string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -173,7 +205,7 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
|||||||
agentId: id,
|
agentId: id,
|
||||||
updates: {
|
updates: {
|
||||||
name: updates.name as string | undefined,
|
name: updates.name as string | undefined,
|
||||||
description: updates.description as string | undefined,
|
description: (updates.role || updates.description) as string | undefined,
|
||||||
systemPrompt: updates.systemPrompt as string | undefined,
|
systemPrompt: updates.systemPrompt as string | undefined,
|
||||||
model: updates.model as string | undefined,
|
model: updates.model as string | undefined,
|
||||||
provider: updates.provider as string | undefined,
|
provider: updates.provider as string | undefined,
|
||||||
@@ -257,7 +289,7 @@ export function installAgentMethods(ClientClass: { prototype: KernelClient }): v
|
|||||||
const clone = {
|
const clone = {
|
||||||
id,
|
id,
|
||||||
name: updates.name,
|
name: updates.name,
|
||||||
role: updates.description || updates.role,
|
role: updates.role || updates.description,
|
||||||
nickname: updates.nickname,
|
nickname: updates.nickname,
|
||||||
model: updates.model,
|
model: updates.model,
|
||||||
emoji: updates.emoji,
|
emoji: updates.emoji,
|
||||||
|
|||||||
@@ -644,6 +644,28 @@ const HARDCODED_PROMPTS: Record<string, { system: string; user: (arg: string) =>
|
|||||||
]`,
|
]`,
|
||||||
user: (conversation: string) => `从以下对话中提取值得长期记住的信息:\n\n${conversation}\n\n如果没有值得记忆的内容,返回空数组 []。`,
|
user: (conversation: string) => `从以下对话中提取值得长期记住的信息:\n\n${conversation}\n\n如果没有值得记忆的内容,返回空数组 []。`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
suggestions: {
|
||||||
|
system: `你是 ZCLAW 的管家助手,需要站在用户角度思考他们真正需要什么,生成 3 个个性化建议。
|
||||||
|
|
||||||
|
## 生成规则
|
||||||
|
1. 第 1 条 — 深入追问:基于当前话题,提出一个有洞察力的追问,帮助用户深入探索
|
||||||
|
2. 第 2 条 — 实用行动:建议一个具体的、可操作的下一步(调用技能、执行工具、查看数据等)
|
||||||
|
3. 第 3 条 — 管家关怀:
|
||||||
|
- 如果有未解决痛点 → 回访建议,如"上次提到的X,后来解决了吗?"
|
||||||
|
- 如果有相关经验 → 引导复用,如"上次用X方法解决了类似问题,要再试试吗?"
|
||||||
|
- 如果有匹配技能 → 推荐使用,如"试试 [技能名] 来处理这个"
|
||||||
|
- 如果没有提供痛点/经验/技能信息 → 给出一个启发性的思考角度
|
||||||
|
4. 每个不超过 30 个中文字符
|
||||||
|
5. 不要重复对话中已讨论过的内容
|
||||||
|
6. 不要生成空泛的建议(如"继续分析"、"换个角度")
|
||||||
|
7. 默认使用中文,不要混入英文词汇(如"workflow"用"工作流"、"report"用"报表"),除非用户在对话中明确使用英文
|
||||||
|
8. 建议会被用户直接点击发送,因此不要包含任何称谓(如"领导"、"老板"、"老师"等),用无主语的问句或陈述句
|
||||||
|
|
||||||
|
只输出 JSON 数组,包含恰好 3 个字符串。不要输出任何其他内容。
|
||||||
|
示例:["科室绩效分析可以按哪些维度拆解?", "用研究技能查一下相关文献?", "上次提到的排班冲突问题,需要继续想解决方案吗?"]`,
|
||||||
|
user: (context: string) => `以下是对话中最近的消息:\n\n${context}\n\n请生成 3 个后续建议(1 深入追问 + 1 实用行动 + 1 管家关怀)。`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// === Prompt Cache (SaaS OTA) ===
|
// === Prompt Cache (SaaS OTA) ===
|
||||||
@@ -806,6 +828,7 @@ export const LLM_PROMPTS = {
|
|||||||
get reflection() { return { system: getSystemPrompt('reflection'), user: getUserPromptTemplate('reflection')! }; },
|
get reflection() { return { system: getSystemPrompt('reflection'), user: getUserPromptTemplate('reflection')! }; },
|
||||||
get compaction() { return { system: getSystemPrompt('compaction'), user: getUserPromptTemplate('compaction')! }; },
|
get compaction() { return { system: getSystemPrompt('compaction'), user: getUserPromptTemplate('compaction')! }; },
|
||||||
get extraction() { return { system: getSystemPrompt('extraction'), user: getUserPromptTemplate('extraction')! }; },
|
get extraction() { return { system: getSystemPrompt('extraction'), user: getUserPromptTemplate('extraction')! }; },
|
||||||
|
get suggestions() { return { system: getSystemPrompt('suggestions'), user: getUserPromptTemplate('suggestions')! }; },
|
||||||
};
|
};
|
||||||
|
|
||||||
// === Telemetry Integration ===
|
// === Telemetry Integration ===
|
||||||
@@ -876,3 +899,18 @@ export async function llmExtract(
|
|||||||
trackLLMCall(llm, response);
|
trackLLMCall(llm, response);
|
||||||
return response.content;
|
return response.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function llmSuggest(
|
||||||
|
conversationContext: string,
|
||||||
|
adapter?: LLMServiceAdapter,
|
||||||
|
): Promise<string> {
|
||||||
|
const llm = adapter || getLLMAdapter();
|
||||||
|
|
||||||
|
const response = await llm.complete([
|
||||||
|
{ role: 'system', content: LLM_PROMPTS.suggestions.system },
|
||||||
|
{ role: 'user', content: typeof LLM_PROMPTS.suggestions.user === 'function' ? LLM_PROMPTS.suggestions.user(conversationContext) : LLM_PROMPTS.suggestions.user },
|
||||||
|
]);
|
||||||
|
|
||||||
|
trackLLMCall(llm, response);
|
||||||
|
return response.content;
|
||||||
|
}
|
||||||
|
|||||||
131
desktop/src/lib/suggestion-context.ts
Normal file
131
desktop/src/lib/suggestion-context.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Suggestion context enrichment — fetches intelligence data for personalized suggestions.
|
||||||
|
* All fetches are optional; failures silently degrade to empty context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { createLogger } from './logger';
|
||||||
|
|
||||||
|
const log = createLogger('SuggestionContext');
|
||||||
|
|
||||||
|
const CONTEXT_FETCH_TIMEOUT = 500;
|
||||||
|
|
||||||
|
/** Pain point from butler intelligence layer. */
|
||||||
|
interface PainPoint {
|
||||||
|
summary: string;
|
||||||
|
category: string;
|
||||||
|
confidence: number;
|
||||||
|
status: string;
|
||||||
|
occurrence_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brief experience from the experience store. */
|
||||||
|
interface ExperienceBrief {
|
||||||
|
pain_pattern: string;
|
||||||
|
solution_summary: string;
|
||||||
|
reuse_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pipeline/skill match candidate. */
|
||||||
|
interface PipelineCandidateInfo {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
description: string;
|
||||||
|
category: string | null;
|
||||||
|
match_reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route intent response (only NoMatch variant has suggestions). */
|
||||||
|
interface RouteResultResponse {
|
||||||
|
type: 'Matched' | 'Ambiguous' | 'NoMatch' | 'NeedMoreInfo';
|
||||||
|
suggestions?: PipelineCandidateInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggregated suggestion context from all intelligence sources. */
|
||||||
|
export interface SuggestionContext {
|
||||||
|
userProfile: string;
|
||||||
|
painPoints: string;
|
||||||
|
experiences: string;
|
||||||
|
skillMatch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTauriAvailable(): boolean {
|
||||||
|
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise<null>(resolve => setTimeout(() => resolve(null), ms)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserProfile(agentId: string): Promise<string> {
|
||||||
|
const profile = await invoke<string>('identity_get_file', {
|
||||||
|
agentId,
|
||||||
|
file: 'userprofile',
|
||||||
|
});
|
||||||
|
if (!profile || profile.trim().length === 0) return '';
|
||||||
|
const text = profile.trim();
|
||||||
|
return text.length > 200 ? text.slice(0, 200) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPainPoints(agentId: string): Promise<string> {
|
||||||
|
const points = await invoke<PainPoint[]>('butler_list_pain_points', { agentId });
|
||||||
|
if (!Array.isArray(points) || points.length === 0) return '';
|
||||||
|
|
||||||
|
const active = points
|
||||||
|
.filter(p => p.confidence >= 0.5 && p.status !== 'Solved' && p.status !== 'Dismissed')
|
||||||
|
.sort((a, b) => b.confidence - a.confidence)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (active.length === 0) return '';
|
||||||
|
return active
|
||||||
|
.map((p, i) => `${i + 1}. [${p.category}] ${p.summary}(出现${p.occurrence_count}次)`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchExperiences(agentId: string, query: string): Promise<string> {
|
||||||
|
const experiences = await invoke<ExperienceBrief[]>('experience_find_relevant', {
|
||||||
|
agentId,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
if (!Array.isArray(experiences) || experiences.length === 0) return '';
|
||||||
|
|
||||||
|
return experiences.slice(0, 2)
|
||||||
|
.map(e => `上次解决"${e.pain_pattern}"的方法:${e.solution_summary}(已复用${e.reuse_count}次)`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSkillMatch(userInput: string): Promise<string> {
|
||||||
|
const result = await invoke<RouteResultResponse>('route_intent', { userInput });
|
||||||
|
const suggestions = result?.suggestions;
|
||||||
|
if (!Array.isArray(suggestions) || suggestions.length === 0) return '';
|
||||||
|
|
||||||
|
const best = suggestions[0];
|
||||||
|
return `你可能需要:${best.display_name} — ${best.description}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_CONTEXT: SuggestionContext = { userProfile: '', painPoints: '', experiences: '', skillMatch: '' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all intelligence context in parallel for suggestion enrichment.
|
||||||
|
* Returns empty strings for any source that fails — never throws.
|
||||||
|
*/
|
||||||
|
export async function fetchSuggestionContext(
|
||||||
|
agentId: string,
|
||||||
|
lastUserMessage: string,
|
||||||
|
): Promise<SuggestionContext> {
|
||||||
|
if (!isTauriAvailable()) {
|
||||||
|
return EMPTY_CONTEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userProfile, painPoints, experiences, skillMatch] = await Promise.all([
|
||||||
|
withTimeout(fetchUserProfile(agentId).catch(e => { log.warn('User profile fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||||
|
withTimeout(fetchPainPoints(agentId).catch(e => { log.warn('Pain points fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||||
|
withTimeout(fetchExperiences(agentId, lastUserMessage).catch(e => { log.warn('Experiences fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||||
|
withTimeout(fetchSkillMatch(lastUserMessage).catch(e => { log.warn('Skill match fetch failed:', e); return ''; }), CONTEXT_FETCH_TIMEOUT),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { userProfile: userProfile ?? '', painPoints: painPoints ?? '', experiences: experiences ?? '', skillMatch: skillMatch ?? '' };
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* ArtifactStore — manages the artifact panel state.
|
* ArtifactStore — manages the artifact panel state with IndexedDB persistence.
|
||||||
*
|
*
|
||||||
* Extracted from chatStore.ts as part of the structured refactor.
|
* Extracted from chatStore.ts as part of the structured refactor.
|
||||||
* This store has zero external dependencies — the simplest slice to extract.
|
* Uses zustand/middleware persist + idb-storage for persistence across refreshes.
|
||||||
*
|
|
||||||
* @see docs/superpowers/specs/2026-04-02-chatstore-refactor-design.md §3.5
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import { createIdbStorageAdapter } from '../../lib/idb-storage';
|
||||||
import type { ArtifactFile } from '../../components/ai/ArtifactPanel';
|
import type { ArtifactFile } from '../../components/ai/ArtifactPanel';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -33,22 +33,33 @@ export interface ArtifactState {
|
|||||||
// Store
|
// Store
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const useArtifactStore = create<ArtifactState>()((set) => ({
|
export const useArtifactStore = create<ArtifactState>()(
|
||||||
artifacts: [],
|
persist(
|
||||||
selectedArtifactId: null,
|
(set) => ({
|
||||||
artifactPanelOpen: false,
|
artifacts: [],
|
||||||
|
selectedArtifactId: null,
|
||||||
|
artifactPanelOpen: false,
|
||||||
|
|
||||||
addArtifact: (artifact: ArtifactFile) =>
|
addArtifact: (artifact: ArtifactFile) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
artifacts: [...state.artifacts, artifact],
|
artifacts: [...state.artifacts, artifact],
|
||||||
selectedArtifactId: artifact.id,
|
selectedArtifactId: artifact.id,
|
||||||
artifactPanelOpen: true,
|
artifactPanelOpen: true,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
selectArtifact: (id: string | null) => set({ selectedArtifactId: id }),
|
selectArtifact: (id: string | null) => set({ selectedArtifactId: id }),
|
||||||
|
|
||||||
setArtifactPanelOpen: (open: boolean) => set({ artifactPanelOpen: open }),
|
setArtifactPanelOpen: (open: boolean) => set({ artifactPanelOpen: open }),
|
||||||
|
|
||||||
clearArtifacts: () =>
|
clearArtifacts: () =>
|
||||||
set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
|
set({ artifacts: [], selectedArtifactId: null, artifactPanelOpen: false }),
|
||||||
}));
|
}),
|
||||||
|
{
|
||||||
|
name: 'zclaw-artifact-storage',
|
||||||
|
storage: createJSONStorage(() => createIdbStorageAdapter()),
|
||||||
|
partialize: (state) => ({
|
||||||
|
artifacts: state.artifacts,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -34,9 +34,16 @@ import {
|
|||||||
} from './conversationStore';
|
} from './conversationStore';
|
||||||
import { useMessageStore } from './messageStore';
|
import { useMessageStore } from './messageStore';
|
||||||
import { useArtifactStore } from './artifactStore';
|
import { useArtifactStore } from './artifactStore';
|
||||||
|
import { llmSuggest, LLM_PROMPTS } from '../../lib/llm-service';
|
||||||
|
import { detectNameSuggestion, detectAgentNameSuggestion } from '../../lib/cold-start-mapper';
|
||||||
|
import { fetchSuggestionContext, type SuggestionContext } from '../../lib/suggestion-context';
|
||||||
|
|
||||||
const log = createLogger('StreamStore');
|
const log = createLogger('StreamStore');
|
||||||
|
|
||||||
|
// Module-level prefetch for suggestion context — started during streaming,
|
||||||
|
// consumed on stream completion. Saves ~0.5-1s vs fetching after stream ends.
|
||||||
|
let _activeSuggestionContextPrefetch: Promise<SuggestionContext> | null = null;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Error formatting — convert raw LLM/API errors to user-friendly messages
|
// Error formatting — convert raw LLM/API errors to user-friendly messages
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -212,6 +219,67 @@ class DeltaBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Artifact creation from tool output (shared between sendMessage & agent stream)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ARTIFACT_TYPE_MAP: Record<string, 'code' | 'markdown' | 'text' | 'table' | 'image'> = {
|
||||||
|
ts: 'code', tsx: 'code', js: 'code', jsx: 'code',
|
||||||
|
py: 'code', rs: 'code', go: 'code', java: 'code',
|
||||||
|
md: 'markdown', txt: 'text', json: 'code',
|
||||||
|
html: 'code', css: 'code', sql: 'code', sh: 'code',
|
||||||
|
yaml: 'code', yml: 'code', toml: 'code', xml: 'code',
|
||||||
|
csv: 'table', svg: 'image',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARTIFACT_LANG_MAP: Record<string, string> = {
|
||||||
|
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
||||||
|
py: 'python', rs: 'rust', go: 'go', java: 'java',
|
||||||
|
html: 'html', css: 'css', sql: 'sql', sh: 'bash',
|
||||||
|
json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
|
||||||
|
xml: 'xml', csv: 'csv', md: 'markdown', txt: 'text',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Attempt to create an artifact from a completed tool call. */
|
||||||
|
function tryCreateArtifactFromToolOutput(toolName: string, toolInput: string, toolOutput: string): void {
|
||||||
|
if (!toolOutput) return;
|
||||||
|
|
||||||
|
const toolsWithArtifacts = ['file_write', 'write_file', 'str_replace', 'str_replace_editor'];
|
||||||
|
if (!toolsWithArtifacts.includes(toolName)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(toolOutput);
|
||||||
|
const filePath = parsed?.path || parsed?.file_path || '';
|
||||||
|
let content = parsed?.content || '';
|
||||||
|
|
||||||
|
// For str_replace tools, content may be in input
|
||||||
|
if (!content && toolInput) {
|
||||||
|
try {
|
||||||
|
const inputParsed = JSON.parse(toolInput);
|
||||||
|
content = inputParsed?.new_text || inputParsed?.content || '';
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filePath || !content) return;
|
||||||
|
|
||||||
|
// Deduplicate: skip if an artifact with the same path already exists
|
||||||
|
const existing = useArtifactStore.getState().artifacts;
|
||||||
|
if (existing.some(a => a.name === filePath.split('/').pop())) return;
|
||||||
|
|
||||||
|
const fileName = filePath.split('/').pop() || filePath;
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
|
||||||
|
useArtifactStore.getState().addArtifact({
|
||||||
|
id: `artifact_${Date.now()}`,
|
||||||
|
name: fileName,
|
||||||
|
content: typeof content === 'string' ? content : JSON.stringify(content, null, 2),
|
||||||
|
type: ARTIFACT_TYPE_MAP[ext] || 'text',
|
||||||
|
language: ARTIFACT_LANG_MAP[ext],
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
} catch { /* non-critical: artifact creation from tool output */ }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Stream event handlers (extracted from sendMessage)
|
// Stream event handlers (extracted from sendMessage)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -234,38 +302,8 @@ function createToolHandler(assistantId: string, chat: ChatStoreAccess) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-create artifact when file_write tool produces output
|
// Auto-create artifact from tool output
|
||||||
if (tool === 'file_write') {
|
tryCreateArtifactFromToolOutput(tool, input, output);
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(output);
|
|
||||||
const filePath = parsed?.path || parsed?.file_path || '';
|
|
||||||
const content = parsed?.content || '';
|
|
||||||
if (filePath && content) {
|
|
||||||
const fileName = filePath.split('/').pop() || filePath;
|
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
||||||
const typeMap: Record<string, 'code' | 'markdown' | 'text'> = {
|
|
||||||
ts: 'code', tsx: 'code', js: 'code', jsx: 'code',
|
|
||||||
py: 'code', rs: 'code', go: 'code', java: 'code',
|
|
||||||
md: 'markdown', txt: 'text', json: 'code',
|
|
||||||
html: 'code', css: 'code', sql: 'code', sh: 'code',
|
|
||||||
};
|
|
||||||
const langMap: Record<string, string> = {
|
|
||||||
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
|
||||||
py: 'python', rs: 'rust', go: 'go', java: 'java',
|
|
||||||
html: 'html', css: 'css', sql: 'sql', sh: 'bash', json: 'json',
|
|
||||||
};
|
|
||||||
useArtifactStore.getState().addArtifact({
|
|
||||||
id: `artifact_${Date.now()}`,
|
|
||||||
name: fileName,
|
|
||||||
content: typeof content === 'string' ? content : JSON.stringify(content, null, 2),
|
|
||||||
type: typeMap[ext] || 'text',
|
|
||||||
language: langMap[ext],
|
|
||||||
createdAt: new Date(),
|
|
||||||
sourceStepId: assistantId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch { /* non-critical: artifact creation from tool output */ }
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// toolStart: create new running step
|
// toolStart: create new running step
|
||||||
const step: ToolCallStep = {
|
const step: ToolCallStep = {
|
||||||
@@ -364,42 +402,83 @@ function createCompleteHandler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Async memory extraction
|
// Detect name changes from last user message (independent of memory extraction)
|
||||||
const msgs = chat.getMessages() || [];
|
const msgs = chat.getMessages() || [];
|
||||||
|
const lastUserMsg = [...msgs].reverse().find(m => m.role === 'user');
|
||||||
|
const lastContent = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '';
|
||||||
|
|
||||||
|
if (lastContent && agentId) {
|
||||||
|
// User name detection (e.g. "叫我小王")
|
||||||
|
const detectedName = detectNameSuggestion(lastContent);
|
||||||
|
if (detectedName) {
|
||||||
|
import('../agentStore').then(({ useAgentStore }) =>
|
||||||
|
useAgentStore.getState().updateClone(agentId, { userName: detectedName })
|
||||||
|
.then(() => log.info(`Updated userName to "${detectedName}" from conversation`))
|
||||||
|
.catch(e => log.warn('Failed to persist detected userName:', e))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Agent name detection (e.g. "叫你小马", "名称改为小芳")
|
||||||
|
const detectedAgentName = detectAgentNameSuggestion(lastContent);
|
||||||
|
if (detectedAgentName) {
|
||||||
|
import('../agentStore').then(({ useAgentStore }) =>
|
||||||
|
useAgentStore.getState().updateClone(agentId, { name: detectedAgentName })
|
||||||
|
.then(() => {
|
||||||
|
log.info(`Updated agent name to "${detectedAgentName}" from conversation`);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('zclaw:agent-profile-updated', {
|
||||||
|
detail: { agentId }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => log.warn('Failed to persist detected agent name:', e))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoupled: suggestion generation runs immediately with prefetched context,
|
||||||
|
// memory extraction + reflection run independently in background.
|
||||||
const filtered = msgs
|
const filtered = msgs
|
||||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||||
.map(m => ({ role: m.role, content: m.content }));
|
.map(m => ({ role: m.role, content: m.content }));
|
||||||
const convId = useConversationStore.getState().currentConversationId;
|
const convId = useConversationStore.getState().currentConversationId;
|
||||||
getMemoryExtractor().extractFromConversation(filtered, agentId, convId ?? undefined)
|
|
||||||
.then(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.dispatchEvent(new CustomEvent('zclaw:agent-profile-updated', {
|
|
||||||
detail: { agentId }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(err => log.warn('Memory extraction failed:', err));
|
|
||||||
|
|
||||||
intelligenceClient.reflection.recordConversation().catch(err => {
|
// Build conversation messages for suggestions
|
||||||
log.warn('Recording conversation failed:', err);
|
|
||||||
});
|
|
||||||
intelligenceClient.reflection.shouldReflect().then(shouldReflect => {
|
|
||||||
if (shouldReflect) {
|
|
||||||
intelligenceClient.reflection.reflect(agentId, []).catch(err => {
|
|
||||||
log.warn('Reflection failed:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Follow-up suggestions
|
|
||||||
const latestMsgs = chat.getMessages() || [];
|
const latestMsgs = chat.getMessages() || [];
|
||||||
const completedMsg = latestMsgs.find(m => m.id === assistantId);
|
const conversationMessages = latestMsgs
|
||||||
if (completedMsg?.content) {
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||||
const suggestions = generateFollowUpSuggestions(completedMsg.content);
|
.filter(m => !m.streaming)
|
||||||
if (suggestions.length > 0) {
|
.map(m => ({ role: m.role, content: m.content }));
|
||||||
set({ suggestions });
|
|
||||||
}
|
// Consume prefetched context (started in sendMessage during streaming)
|
||||||
|
const prefetchPromise = _activeSuggestionContextPrefetch;
|
||||||
|
_activeSuggestionContextPrefetch = null;
|
||||||
|
|
||||||
|
// Fire suggestion generation immediately — don't wait for memory extraction
|
||||||
|
const fireSuggestions = (ctx?: SuggestionContext) => {
|
||||||
|
generateLLMSuggestions(conversationMessages, set, ctx).catch(err => {
|
||||||
|
log.warn('Suggestion generation error:', err);
|
||||||
|
set({ suggestionsLoading: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (prefetchPromise) {
|
||||||
|
prefetchPromise.then(fireSuggestions).catch(() => fireSuggestions());
|
||||||
|
} else {
|
||||||
|
fireSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Background tasks run independently — never block suggestions
|
||||||
|
getMemoryExtractor().extractFromConversation(filtered, agentId, convId ?? undefined)
|
||||||
|
.catch(err => log.warn('Memory extraction failed:', err));
|
||||||
|
intelligenceClient.reflection.recordConversation()
|
||||||
|
.catch(err => log.warn('Recording conversation failed:', err))
|
||||||
|
.then(() => intelligenceClient.reflection.shouldReflect())
|
||||||
|
.then(shouldReflect => {
|
||||||
|
if (shouldReflect) {
|
||||||
|
intelligenceClient.reflection.reflect(agentId, []).catch(err => {
|
||||||
|
log.warn('Reflection failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +489,8 @@ export interface StreamState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
chatMode: ChatModeType;
|
chatMode: ChatModeType;
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
|
/** Whether LLM-generated suggestions are being fetched. */
|
||||||
|
suggestionsLoading: boolean;
|
||||||
/** Run ID of the currently active stream (null when idle). */
|
/** Run ID of the currently active stream (null when idle). */
|
||||||
activeRunId: string | null;
|
activeRunId: string | null;
|
||||||
|
|
||||||
@@ -425,6 +506,7 @@ export interface StreamState {
|
|||||||
|
|
||||||
// Suggestions
|
// Suggestions
|
||||||
setSuggestions: (suggestions: string[]) => void;
|
setSuggestions: (suggestions: string[]) => void;
|
||||||
|
setSuggestionsLoading: (loading: boolean) => void;
|
||||||
|
|
||||||
// Skill search
|
// Skill search
|
||||||
searchSkills: (query: string) => {
|
searchSkills: (query: string) => {
|
||||||
@@ -440,7 +522,7 @@ export interface StreamState {
|
|||||||
// Follow-up suggestion generator
|
// Follow-up suggestion generator
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function generateFollowUpSuggestions(content: string): string[] {
|
function generateKeywordFallback(content: string): string[] {
|
||||||
const suggestions: string[] = [];
|
const suggestions: string[] = [];
|
||||||
const lower = content.toLowerCase();
|
const lower = content.toLowerCase();
|
||||||
|
|
||||||
@@ -473,6 +555,181 @@ function generateFollowUpSuggestions(content: string): string[] {
|
|||||||
return suggestions;
|
return suggestions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse LLM response into an array of suggestion strings.
|
||||||
|
* Handles: raw JSON array, markdown-fenced JSON, trailing/leading text.
|
||||||
|
*/
|
||||||
|
function parseSuggestionResponse(raw: string): string[] {
|
||||||
|
let cleaned = raw.trim();
|
||||||
|
// Strip markdown code fences
|
||||||
|
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/i, '');
|
||||||
|
cleaned = cleaned.replace(/\n?```\s*$/i, '');
|
||||||
|
cleaned = cleaned.trim();
|
||||||
|
|
||||||
|
// Direct JSON parse
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cleaned);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||||
|
.slice(0, 3);
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
|
||||||
|
// Extract JSON array from surrounding text
|
||||||
|
const arrayMatch = cleaned.match(/\[[\s\S]*?\]/);
|
||||||
|
if (arrayMatch) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(arrayMatch[0]);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||||
|
.slice(0, 3);
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: split by newlines, strip list markers
|
||||||
|
const lines = cleaned
|
||||||
|
.split(/\n/)
|
||||||
|
.map(l => l.replace(/^[-*\d.)\]]+\s*/, '').trim())
|
||||||
|
.filter(l => l.length > 0 && l.length < 60);
|
||||||
|
if (lines.length > 0) {
|
||||||
|
return lines.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate contextual follow-up suggestions via LLM.
|
||||||
|
* Routes through SaaS relay or local kernel based on connection mode.
|
||||||
|
* Falls back to keyword-based approach on any failure.
|
||||||
|
*/
|
||||||
|
async function generateLLMSuggestions(
|
||||||
|
messages: Array<{ role: string; content: string }>,
|
||||||
|
set: (partial: Partial<StreamState>) => void,
|
||||||
|
context?: SuggestionContext,
|
||||||
|
): Promise<void> {
|
||||||
|
set({ suggestionsLoading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recentMessages = messages.slice(-20);
|
||||||
|
const conversationContext = recentMessages
|
||||||
|
.map(m => `${m.role === 'user' ? '用户' : '助手'}: ${m.content.slice(0, 200)}`)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
// Build dynamic user message with intelligence context
|
||||||
|
const ctx = context ?? { userProfile: '', painPoints: '', experiences: '', skillMatch: '' };
|
||||||
|
const hasContext = ctx.userProfile || ctx.painPoints || ctx.experiences || ctx.skillMatch;
|
||||||
|
let userMessage: string;
|
||||||
|
if (hasContext) {
|
||||||
|
const sections: string[] = ['以下是用户的背景信息,请在生成建议时参考:\n'];
|
||||||
|
if (ctx.userProfile) sections.push(`## 用户画像\n${ctx.userProfile}`);
|
||||||
|
if (ctx.painPoints) sections.push(`## 活跃痛点\n${ctx.painPoints}`);
|
||||||
|
if (ctx.experiences) sections.push(`## 相关经验\n${ctx.experiences}`);
|
||||||
|
if (ctx.skillMatch) sections.push(`## 可用技能\n${ctx.skillMatch}`);
|
||||||
|
sections.push(`\n最近对话:\n${conversationContext}`);
|
||||||
|
userMessage = sections.join('\n\n');
|
||||||
|
} else {
|
||||||
|
userMessage = `以下是对话中最近的消息:\n\n${conversationContext}\n\n请生成 3 个后续问题。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionMode = typeof localStorage !== 'undefined'
|
||||||
|
? localStorage.getItem('zclaw-connection-mode')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
|
||||||
|
if (connectionMode === 'saas') {
|
||||||
|
raw = await llmSuggestViaSaaS(userMessage);
|
||||||
|
} else {
|
||||||
|
raw = await llmSuggest(userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = parseSuggestionResponse(raw);
|
||||||
|
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
set({ suggestions, suggestionsLoading: false });
|
||||||
|
} else {
|
||||||
|
const lastAssistant = messages.filter(m => m.role === 'assistant').pop()?.content || '';
|
||||||
|
set({ suggestions: generateKeywordFallback(lastAssistant), suggestionsLoading: false });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('LLM suggestion generation failed, using keyword fallback:', err);
|
||||||
|
const lastAssistant = messages.filter(m => m.role === 'assistant').pop()?.content || '';
|
||||||
|
set({ suggestions: generateKeywordFallback(lastAssistant), suggestionsLoading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate suggestions via SaaS relay using SSE streaming.
|
||||||
|
* Uses the same streaming path as the main chat to avoid relay timeout issues
|
||||||
|
* with non-streaming requests. Collects the full response from SSE deltas,
|
||||||
|
* then parses the suggestion JSON from the accumulated text.
|
||||||
|
*/
|
||||||
|
async function llmSuggestViaSaaS(userMessage: string): Promise<string> {
|
||||||
|
const { saasClient } = await import('../../lib/saas-client');
|
||||||
|
const { useConversationStore } = await import('./conversationStore');
|
||||||
|
const { useSaaSStore } = await import('../saasStore');
|
||||||
|
|
||||||
|
const currentModel = useConversationStore.getState().currentModel;
|
||||||
|
const availableModels = useSaaSStore.getState().availableModels;
|
||||||
|
const model = currentModel || (availableModels.length > 0 ? availableModels[0]?.id : undefined);
|
||||||
|
if (!model) throw new Error('No model available for suggestions');
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 60000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await saasClient.chatCompletion(
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: LLM_PROMPTS_SYSTEM },
|
||||||
|
{ role: 'user', content: userMessage },
|
||||||
|
],
|
||||||
|
max_tokens: 500,
|
||||||
|
temperature: 0.7,
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text().catch(() => 'unknown error');
|
||||||
|
throw new Error(`SaaS relay error ${response.status}: ${errText.substring(0, 100)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read full response as text — suggestion responses are small (max 500 tokens),
|
||||||
|
// so streaming is unnecessary. This avoids ReadableStream compatibility issues
|
||||||
|
// in Tauri WebView2 where body.getReader() may not yield SSE chunks correctly.
|
||||||
|
const rawText = await response.text();
|
||||||
|
log.debug('[Suggest] Raw response length:', rawText.length);
|
||||||
|
|
||||||
|
// Parse SSE "data:" lines from accumulated text
|
||||||
|
let accumulated = '';
|
||||||
|
for (const line of rawText.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith('data: ')) continue;
|
||||||
|
const payload = trimmed.slice(6).trim();
|
||||||
|
if (payload === '[DONE]') continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload);
|
||||||
|
const delta = parsed.choices?.[0]?.delta;
|
||||||
|
if (delta?.content) accumulated += delta.content;
|
||||||
|
} catch { /* skip malformed lines */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('[Suggest] Accumulated length:', accumulated.length);
|
||||||
|
return accumulated;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LLM_PROMPTS_SYSTEM = LLM_PROMPTS.suggestions.system;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ChatStore injection (avoids circular imports)
|
// ChatStore injection (avoids circular imports)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -499,6 +756,7 @@ export const useStreamStore = create<StreamState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
chatMode: 'thinking' as ChatModeType,
|
chatMode: 'thinking' as ChatModeType,
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
|
suggestionsLoading: false,
|
||||||
activeRunId: null as string | null,
|
activeRunId: null as string | null,
|
||||||
|
|
||||||
// ── Chat Mode ──
|
// ── Chat Mode ──
|
||||||
@@ -508,6 +766,7 @@ export const useStreamStore = create<StreamState>()(
|
|||||||
getChatModeConfig: () => CHAT_MODES[get().chatMode].config,
|
getChatModeConfig: () => CHAT_MODES[get().chatMode].config,
|
||||||
|
|
||||||
setSuggestions: (suggestions: string[]) => set({ suggestions }),
|
setSuggestions: (suggestions: string[]) => set({ suggestions }),
|
||||||
|
setSuggestionsLoading: (loading: boolean) => set({ suggestionsLoading: loading }),
|
||||||
|
|
||||||
setIsLoading: (loading: boolean) => set({ isLoading: loading }),
|
setIsLoading: (loading: boolean) => set({ isLoading: loading }),
|
||||||
|
|
||||||
@@ -535,7 +794,7 @@ export const useStreamStore = create<StreamState>()(
|
|||||||
const currentAgent = convStore.currentAgent;
|
const currentAgent = convStore.currentAgent;
|
||||||
const sessionKey = convStore.sessionKey;
|
const sessionKey = convStore.sessionKey;
|
||||||
|
|
||||||
set({ suggestions: [] });
|
set({ suggestions: [], suggestionsLoading: false });
|
||||||
const effectiveSessionKey = sessionKey || crypto.randomUUID();
|
const effectiveSessionKey = sessionKey || crypto.randomUUID();
|
||||||
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
|
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
|
||||||
const agentId = currentAgent?.id || 'zclaw-main';
|
const agentId = currentAgent?.id || 'zclaw-main';
|
||||||
@@ -581,6 +840,9 @@ export const useStreamStore = create<StreamState>()(
|
|||||||
});
|
});
|
||||||
set({ isStreaming: true, activeRunId: null });
|
set({ isStreaming: true, activeRunId: null });
|
||||||
|
|
||||||
|
// Prefetch suggestion context during streaming — saves ~0.5-1s post-stream
|
||||||
|
_activeSuggestionContextPrefetch = fetchSuggestionContext(agentId, content);
|
||||||
|
|
||||||
// Delta buffer — batches updates at ~60fps
|
// Delta buffer — batches updates at ~60fps
|
||||||
const buffer = new DeltaBuffer(assistantId, _chat);
|
const buffer = new DeltaBuffer(assistantId, _chat);
|
||||||
|
|
||||||
@@ -796,6 +1058,13 @@ export const useStreamStore = create<StreamState>()(
|
|||||||
return { ...m, toolSteps: steps };
|
return { ...m, toolSteps: steps };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-create artifact from tool output (agent stream path)
|
||||||
|
tryCreateArtifactFromToolOutput(
|
||||||
|
delta.tool || 'unknown',
|
||||||
|
delta.toolInput || '',
|
||||||
|
delta.toolOutput,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// toolStart: create new running step
|
// toolStart: create new running step
|
||||||
const step: ToolCallStep = {
|
const step: ToolCallStep = {
|
||||||
@@ -849,12 +1118,24 @@ export const useStreamStore = create<StreamState>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const latestMsgs = _chat?.getMessages() || [];
|
const latestMsgs = _chat?.getMessages() || [];
|
||||||
const completedMsg = latestMsgs.find(m => m.id === streamingMsg.id);
|
const conversationMessages = latestMsgs
|
||||||
if (completedMsg?.content) {
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||||
const suggestions = generateFollowUpSuggestions(completedMsg.content);
|
.filter(m => !m.streaming)
|
||||||
if (suggestions.length > 0) {
|
.map(m => ({ role: m.role, content: m.content }));
|
||||||
get().setSuggestions(suggestions);
|
|
||||||
}
|
// Path B: use prefetched context for agent stream — fixes zero-personalization
|
||||||
|
const prefetchPromise = _activeSuggestionContextPrefetch;
|
||||||
|
_activeSuggestionContextPrefetch = null;
|
||||||
|
const fireSuggestions = (ctx?: SuggestionContext) => {
|
||||||
|
generateLLMSuggestions(conversationMessages, set, ctx).catch(err => {
|
||||||
|
log.warn('Suggestion generation error:', err);
|
||||||
|
set({ suggestionsLoading: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (prefetchPromise) {
|
||||||
|
prefetchPromise.then(fireSuggestions).catch(() => fireSuggestions());
|
||||||
|
} else {
|
||||||
|
fireSuggestions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface ChatState {
|
|||||||
totalOutputTokens: number;
|
totalOutputTokens: number;
|
||||||
chatMode: ChatModeType;
|
chatMode: ChatModeType;
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
|
suggestionsLoading: boolean;
|
||||||
|
|
||||||
addMessage: (message: Message) => void;
|
addMessage: (message: Message) => void;
|
||||||
updateMessage: (id: string, updates: Partial<Message>) => void;
|
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||||
@@ -111,6 +112,7 @@ export const useChatStore = create<ChatState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
chatMode: 'thinking' as ChatModeType,
|
chatMode: 'thinking' as ChatModeType,
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
|
suggestionsLoading: false,
|
||||||
totalInputTokens: 0,
|
totalInputTokens: 0,
|
||||||
totalOutputTokens: 0,
|
totalOutputTokens: 0,
|
||||||
|
|
||||||
@@ -367,6 +369,7 @@ const unsubStream = useStreamStore.subscribe((state) => {
|
|||||||
if (chat.isLoading !== state.isLoading) updates.isLoading = state.isLoading;
|
if (chat.isLoading !== state.isLoading) updates.isLoading = state.isLoading;
|
||||||
if (chat.chatMode !== state.chatMode) updates.chatMode = state.chatMode;
|
if (chat.chatMode !== state.chatMode) updates.chatMode = state.chatMode;
|
||||||
if (chat.suggestions !== state.suggestions) updates.suggestions = state.suggestions;
|
if (chat.suggestions !== state.suggestions) updates.suggestions = state.suggestions;
|
||||||
|
if (chat.suggestionsLoading !== state.suggestionsLoading) updates.suggestionsLoading = state.suggestionsLoading;
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
useChatStore.setState(updates);
|
useChatStore.setState(updates);
|
||||||
}
|
}
|
||||||
|
|||||||
309
docs/references/artifact-system-reference.md
Normal file
309
docs/references/artifact-system-reference.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# 产物系统参考文档
|
||||||
|
|
||||||
|
> 调研 DeerFlow 和 Hermes Agent 的产物/输出面板实现,为 ZCLAW 产物系统重构提供参考。
|
||||||
|
> 分析日期:2026-04-24
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、DeerFlow 产物系统
|
||||||
|
|
||||||
|
DeerFlow 有完整的全栈产物管道,是主要参考对象。
|
||||||
|
|
||||||
|
### 1.1 端到端数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
Agent tool call (write_file / str_replace / present_files)
|
||||||
|
↓
|
||||||
|
Backend: ThreadState.artifacts (LangGraph annotated list, merge_artifacts reducer 去重)
|
||||||
|
↓ 文件写入: {base_dir}/threads/{thread_id}/user-data/outputs/
|
||||||
|
↓ 虚拟路径: /mnt/user-data/outputs/filename.ext
|
||||||
|
↓
|
||||||
|
Backend API: GET /api/threads/{thread_id}/artifacts/{virtual_path}
|
||||||
|
↓ MIME 检测 / .skill ZIP 解压 / download vs inline
|
||||||
|
↓
|
||||||
|
Frontend: thread.values.artifacts (string[]) → ArtifactsProvider context
|
||||||
|
↓
|
||||||
|
ChatBox (ResizablePanelGroup) → chat(60%) | artifact panel(40%)
|
||||||
|
↓
|
||||||
|
ArtifactFileDetail → CodeMirror(代码) / Streamdown(Markdown) / iframe(HTML)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 关键文件
|
||||||
|
|
||||||
|
#### 前端核心
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/core/artifacts/utils.ts` | URL 构建、产物列表提取、路径解析 |
|
||||||
|
| `frontend/src/core/artifacts/loader.ts` | 从后端 API 获取产物文本;从 tool call args 直接提取内容 |
|
||||||
|
| `frontend/src/core/artifacts/hooks.ts` | TanStack React Query hook,5 分钟缓存 |
|
||||||
|
| `frontend/src/components/workspace/artifacts/context.tsx` | ArtifactsProvider + useArtifacts() — 管理列表、选中、开关、自动选中 |
|
||||||
|
| `frontend/src/components/workspace/artifacts/artifact-file-detail.tsx` | 产物详情视图:头部(文件选择器+code/preview切换) + CodeEditor/Preview |
|
||||||
|
| `frontend/src/components/workspace/artifacts/artifact-file-list.tsx` | 卡片式列表视图,每个卡片含图标/名称/扩展名/下载/安装按钮 |
|
||||||
|
| `frontend/src/components/workspace/artifacts/artifact-trigger.tsx` | 头部触发按钮,仅在产物存在时显示 |
|
||||||
|
|
||||||
|
#### 前端渲染
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/components/workspace/code-editor.tsx` | CodeMirror 只读编辑器,支持 CSS/HTML/JS/JSON/MD/Python 语法高亮 |
|
||||||
|
| `frontend/src/components/ai-elements/code-block.tsx` | Shiki 语法高亮代码块,双主题(light/dark) |
|
||||||
|
| `frontend/src/components/ai-elements/web-preview.tsx` | iframe 网页预览,含地址栏和导航按钮 |
|
||||||
|
| `frontend/src/components/workspace/messages/markdown-content.tsx` | Streamdown 渲染 Markdown (GFM + Math + Raw HTML + KaTeX) |
|
||||||
|
| `frontend/src/core/utils/files.tsx` | 140+ 扩展名→语言映射,文件图标/类型判断 |
|
||||||
|
|
||||||
|
#### 后端
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/.../thread_state.py` | ThreadState.artifacts 列表 + merge_artifacts 去重 reducer |
|
||||||
|
| `backend/.../present_file_tool.py` | present_files 工具 — 标准化路径,返回 Command(update) |
|
||||||
|
| `backend/.../paths.py` | 路径管理:threads/{id}/user-data/{workspace,uploads,outputs} |
|
||||||
|
| `backend/app/gateway/routers/artifacts.py` | FastAPI 路由:GET 产物文件,MIME 检测,安全处理 |
|
||||||
|
|
||||||
|
### 1.3 支持的内容类型
|
||||||
|
|
||||||
|
| 类型 | 渲染方式 |
|
||||||
|
|------|----------|
|
||||||
|
| 代码文件 (140+ 扩展名) | CodeMirror 只读 + 语法高亮 |
|
||||||
|
| Markdown (.md) | Streamdown (GFM + Math + KaTeX + Raw HTML) |
|
||||||
|
| HTML (.html/.htm) | 沙箱 `<iframe>` (srcDoc) |
|
||||||
|
| 图片 (.png/.jpg/.svg/.webp) | `<img>` 标签,非代码文件用 iframe |
|
||||||
|
| .skill 压缩包 | ZIP 解压,SKILL.md 渲染为 Markdown |
|
||||||
|
| 二进制文件 (PDF 等) | 后端 inline Content-Disposition |
|
||||||
|
| 文本文件 (.txt/.csv/.log) | CodeMirror 纯文本模式 |
|
||||||
|
|
||||||
|
### 1.4 持久化架构
|
||||||
|
|
||||||
|
**磁盘存储:**
|
||||||
|
```
|
||||||
|
{DEER_FLOW_HOME}/threads/{thread_id}/user-data/outputs/
|
||||||
|
```
|
||||||
|
|
||||||
|
**状态持久化:** artifacts 列表是 LangGraph ThreadState 的一部分,由 checkpoint 系统自动持久化。
|
||||||
|
|
||||||
|
**前端缓存:** TanStack React Query,5 分钟 stale time。
|
||||||
|
|
||||||
|
### 1.5 UI/UX 设计模式
|
||||||
|
|
||||||
|
#### 分栏布局 (chat-box.tsx)
|
||||||
|
- `react-resizable-panels` 水平分栏
|
||||||
|
- 关闭态:chat=100%, artifacts=0%
|
||||||
|
- 打开态:chat=60%, artifacts=40%
|
||||||
|
- 300ms CSS 过渡动画
|
||||||
|
|
||||||
|
#### 自动打开 + 自动选中
|
||||||
|
- 检测到 `write_file` / `str_replace` tool call 时自动打开面板并选中文件
|
||||||
|
- `autoOpen` / `autoSelect` 标志防止用户手动关闭后重复打开
|
||||||
|
|
||||||
|
#### 代码/预览切换
|
||||||
|
- HTML/Markdown 默认 Preview,其他默认 Code
|
||||||
|
- Preview 用 Streamdown(MD) 或 iframe(HTML)
|
||||||
|
|
||||||
|
#### 头部操作栏
|
||||||
|
- 文件选择器下拉菜单(不用返回列表即可切换)
|
||||||
|
- 复制 / 下载 / 新窗口打开 / 关闭
|
||||||
|
|
||||||
|
#### 聊天内嵌展示
|
||||||
|
- `present_files` tool call → 聊天流内渲染卡片网格
|
||||||
|
- 点击卡片 → 侧栏打开该文件
|
||||||
|
|
||||||
|
#### 双路径方案
|
||||||
|
1. **真实文件路径** — 从后端 API 获取,React Query 缓存
|
||||||
|
2. **`write-file:` 虚拟路径** — 直接从 tool call args 提取内容,无需后端请求,支持流式显示
|
||||||
|
|
||||||
|
### 1.6 Provider 层级
|
||||||
|
|
||||||
|
```
|
||||||
|
ArtifactsProvider → 提供useArtifacts() context
|
||||||
|
ChatBox → ResizablePanelGroup
|
||||||
|
Panel(chat) → MessageList → ToolCall 自动打开产物面板
|
||||||
|
Panel(artifacts) → ArtifactFileDetail → useArtifactContent() hook
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、Hermes Agent 产物机制
|
||||||
|
|
||||||
|
> **结论:Hermes Agent 无产物面板、无 Web 前端、无分栏布局。** 它是终端 CLI 工具,所有输出在终端内联渲染。但有值得借鉴的大输出处理机制。
|
||||||
|
|
||||||
|
### 2.1 项目定位
|
||||||
|
|
||||||
|
Hermes Agent 是 **Python CLI/TUI Agent**(类似 Claude Code),通过 prompt_toolkit TUI 运行,同时支持 Telegram/Discord/Slack/WhatsApp 等 IM 平台网关。
|
||||||
|
|
||||||
|
**无 React/Next.js/Web UI。** 暴露 OpenAI 兼容 API 供 Open WebUI/LobeChat 等第三方 UI 接入。
|
||||||
|
|
||||||
|
### 2.2 大输出处理(3 层防御)
|
||||||
|
|
||||||
|
这是唯一接近"产物管理"的机制,值得借鉴。
|
||||||
|
|
||||||
|
**文件:`tools/tool_result_storage.py`**
|
||||||
|
|
||||||
|
| 层级 | 机制 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Layer 1 | 工具自身截断 | 每个工具限制自己的输出长度 |
|
||||||
|
| Layer 2 | `maybe_persist_tool_result` | 单个结果超阈值 → 写入沙箱临时文件,上下文中替换为 `<persisted-output>` 预览块 |
|
||||||
|
| Layer 3 | `enforce_turn_budget` | 整轮超过 200K 字符 → 最大的几个溢出到磁盘 |
|
||||||
|
|
||||||
|
核心逻辑:
|
||||||
|
```python
|
||||||
|
# 超阈值时:完整内容写入文件,上下文替换为预览
|
||||||
|
remote_path = f"{storage_dir}/{tool_use_id}.txt"
|
||||||
|
_write_to_sandbox(content, remote_path, env)
|
||||||
|
return _build_persisted_message(preview, has_more, len(content), remote_path)
|
||||||
|
# 后续 agent 可用 read_file + offset/limit 读取完整内容
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 预算配置
|
||||||
|
|
||||||
|
**文件:`tools/budget_config.py`**
|
||||||
|
|
||||||
|
| 参数 | 默认值 |
|
||||||
|
|------|--------|
|
||||||
|
| `DEFAULT_RESULT_SIZE_CHARS` | 100,000(单工具阈值)|
|
||||||
|
| `DEFAULT_TURN_BUDGET_CHARS` | 200,000(整轮上限)|
|
||||||
|
| `DEFAULT_PREVIEW_SIZE_CHARS` | 1,500(内联预览长度)|
|
||||||
|
|
||||||
|
### 2.4 CLI 渲染方式
|
||||||
|
|
||||||
|
**文件:`agent/display.py`**
|
||||||
|
|
||||||
|
- **工具进度**:KawaiiSpinner 动画 + 一行摘要
|
||||||
|
- **文件编辑**:内联 colored unified diff(write_file / patch 工具)
|
||||||
|
- **最终响应**:Rich Panel 边框包裹,主题色可换(7 套 skin)
|
||||||
|
|
||||||
|
### 2.5 会话持久化
|
||||||
|
|
||||||
|
**文件:`hermes_state.py`**
|
||||||
|
|
||||||
|
SQLite (`~/.hermes/state.db`) + FTS5 全文搜索:
|
||||||
|
- sessions 表:元数据、模型配置、token 计数、费用、标题
|
||||||
|
- messages 表:role、content、tool_call_id、reasoning、时间戳
|
||||||
|
|
||||||
|
### 2.6 值得借鉴的点
|
||||||
|
|
||||||
|
| 点 | 借鉴价值 |
|
||||||
|
|----|----------|
|
||||||
|
| 大输出溢出到磁盘 + 内联预览 | 解决 context window 溢出问题 |
|
||||||
|
| 3 层递进防御 | 对 ZCLAW 中间件链有参考价值 |
|
||||||
|
| 预算配置化 | 阈值可调,不同场景不同策略 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、对比分析:ZCLAW 现状 vs 参考方案
|
||||||
|
|
||||||
|
### 3.1 现状差距
|
||||||
|
|
||||||
|
| 维度 | DeerFlow | ZCLAW 现状 | 差距 |
|
||||||
|
|------|----------|------------|------|
|
||||||
|
| 数据源 | 3 个工具(present_files/write_file/str_replace)主动注册 | 仅 streamStore 解析 tool output 的 filePath | 极窄,几乎不触发 |
|
||||||
|
| 持久化 | 磁盘文件 + LangGraph checkpoint | 纯内存 Zustand | 刷新即丢失 |
|
||||||
|
| 渲染-代码 | CodeMirror 只读 + 语法高亮 (140+ 语言) | 纯 `<pre>` 标签,无高亮 | 无高亮 |
|
||||||
|
| 渲染-Markdown | Streamdown (GFM+Math+KaTeX+RawHTML) | 手写 30 行正则渲染器 | 仅标题/粗体/列表 |
|
||||||
|
| 渲染-HTML | 沙箱 iframe | 不支持 | 无 |
|
||||||
|
| 渲染-图片 | `<img>` + iframe | 类型声明了无实现 | 无 |
|
||||||
|
| 渲染-表格 | GFM 表格 | 纯文本 `<pre>` | 无 |
|
||||||
|
| 面板布局 | react-resizable-panels 60/40 | react-resizable-panels 65/35 | 已有,可复用 |
|
||||||
|
| 自动打开 | write_file/str_replace 触发 | addArtifact 时打开 | 已有 |
|
||||||
|
| 文件选择 | 下拉菜单不离开详情视图 | 必须返回列表再选 | 体验差 |
|
||||||
|
| 聊天内嵌 | present_files → 卡片网格 | 无 | 缺失 |
|
||||||
|
| 缓存 | React Query 5min | 无 | 缺失 |
|
||||||
|
| 双路径 | 真实路径 + write-file: 虚拟路径 | 仅运行时内存 | 缺失 |
|
||||||
|
| 右面板重叠 | 单一面板 | ArtifactPanel + RightPanel"文件"tab 职责交叉 | 架构问题 |
|
||||||
|
|
||||||
|
### 3.2 核心差距总结
|
||||||
|
|
||||||
|
**按优先级排列:**
|
||||||
|
|
||||||
|
1. **P0 数据源断裂** — 产物几乎没有来源,是最根本的问题
|
||||||
|
2. **P0 无持久化** — 产物刷新即丢
|
||||||
|
3. **P1 Markdown 渲染残缺** — 30 行正则 vs 完整 GFM 渲染器
|
||||||
|
4. **P1 代码无语法高亮** — 纯 `<pre>` vs CodeMirror/Shiki
|
||||||
|
5. **P2 双面板职责交叉** — ArtifactPanel vs RightPanel"文件"tab
|
||||||
|
6. **P2 缺少详情内文件切换** — 需返回列表才能切换文件
|
||||||
|
7. **P3 聊天内嵌产物卡片缺失**
|
||||||
|
8. **P3 HTML/图片/表格渲染缺失**
|
||||||
|
|
||||||
|
### 3.3 推荐方案
|
||||||
|
|
||||||
|
#### 方案 A:最小可行(基于现有架构补全)
|
||||||
|
|
||||||
|
在现有 ArtifactPanel + artifactStore 上修补:
|
||||||
|
|
||||||
|
- **数据源**:扩展 streamStore 中的 tool output 解析,覆盖更多工具类型
|
||||||
|
- **持久化**:artifactStore 追加 IndexedDB 写入(复用 messageStore 模式)
|
||||||
|
- **Markdown**:引入 `react-markdown` + `remark-gfm` 替换手写渲染器
|
||||||
|
- **代码高亮**:引入 `shiki` 或 `highlight.js`
|
||||||
|
- **合并面板**:RightPanel "文件"tab 功能合并到 ArtifactPanel,删除 RightPanel 的 files tab
|
||||||
|
|
||||||
|
**工作量**:~2-3 天
|
||||||
|
|
||||||
|
#### 方案 B:参照 DeerFlow 重构(推荐)
|
||||||
|
|
||||||
|
借鉴 DeerFlow 架构但适配 ZCLAW Tauri 本地架构:
|
||||||
|
|
||||||
|
| DeerFlow 组件 | ZCLAW 适配 |
|
||||||
|
|---------------|------------|
|
||||||
|
| FastAPI 产物路由 | Tauri 命令 `artifact_list` / `artifact_read` / `artifact_serve` |
|
||||||
|
| 磁盘 outputs/ 目录 | `{workspace}/artifacts/{session_key}/` |
|
||||||
|
| LangGraph checkpoint | SQLite (已有 zclaw-memory) |
|
||||||
|
| React Query 缓存 | TanStack Query 或 Zustand + stale cache |
|
||||||
|
| CodeMirror 只读 | 引入 @uiw/react-codemirror |
|
||||||
|
| Streamdown MD | react-markdown + remark-gfm + rehype-katex |
|
||||||
|
| iframe HTML 预览 | Tauri webview window (安全隔离) |
|
||||||
|
|
||||||
|
**核心改动清单:**
|
||||||
|
|
||||||
|
1. **Rust 侧**(zclaw-kernel):
|
||||||
|
- 新增 `artifact_create` / `artifact_list` / `artifact_read` Tauri 命令
|
||||||
|
- 产物写入 `{workspace}/artifacts/{session_key}/`
|
||||||
|
- 中间件链中 ToolEnd 事件触发产物注册
|
||||||
|
|
||||||
|
2. **前端 Store**:
|
||||||
|
- artifactStore 增加 IndexedDB 持久化
|
||||||
|
- 从 streamStore 解耦产物创建逻辑到独立 hook
|
||||||
|
|
||||||
|
3. **前端组件**:
|
||||||
|
- 替换 MarkdownPreview → react-markdown + GFM
|
||||||
|
- 引入 CodeMirror/shiki 代码高亮
|
||||||
|
- 详情视图增加文件下拉切换
|
||||||
|
- RightPanel "文件" tab 合并或移除
|
||||||
|
|
||||||
|
**工作量**:~5-7 天
|
||||||
|
|
||||||
|
#### 方案 C:借鉴 Hermes 防御机制(附加)
|
||||||
|
|
||||||
|
无论选 A 还是 B,都可叠加 Hermes 的大输出防御:
|
||||||
|
|
||||||
|
- 中间件链 ToolOutputGuard 层增加溢出检测
|
||||||
|
- 超阈值产物自动持久化到磁盘,上下文替换为 `<persisted-output>` 预览
|
||||||
|
- agent 可通过 read_file 回读完整内容
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、关键依赖库参考
|
||||||
|
|
||||||
|
| 库 | 用途 | DeerFlow 使用 | 推荐 |
|
||||||
|
|----|------|--------------|------|
|
||||||
|
| react-markdown | Markdown 渲染 | ✅ (Streamdown) | ✅ |
|
||||||
|
| remark-gfm | GFM 表格/删除线/任务列表 | ✅ | ✅ |
|
||||||
|
| rehype-katex | 数学公式渲染 | ✅ | 按需 |
|
||||||
|
| @uiw/react-codemirror | 代码编辑器/高亮 | ✅ | ✅ |
|
||||||
|
| shiki | 静态代码高亮 | ✅ (chat 内代码块) | ✅ |
|
||||||
|
| react-resizable-panels | 分栏布局 | ✅ | 已有 |
|
||||||
|
| @tanstack/react-query | 数据缓存 | ✅ | 可选 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、文件索引
|
||||||
|
|
||||||
|
| 参考项目 | 关键路径 |
|
||||||
|
|----------|----------|
|
||||||
|
| DeerFlow 前端 | `G:/deerflow/frontend/src/components/workspace/artifacts/` |
|
||||||
|
| DeerFlow 前端工具 | `G:/deerflow/frontend/src/core/artifacts/` |
|
||||||
|
| DeerFlow 布局 | `G:/deerflow/frontend/src/components/workspace/chats/chat-box.tsx` |
|
||||||
|
| DeerFlow 代码编辑 | `G:/deerflow/frontend/src/components/workspace/code-editor.tsx` |
|
||||||
|
| DeerFlow 后端路由 | `G:/deerflow/backend/app/gateway/routers/artifacts.py` |
|
||||||
|
| DeerFlow 后端工具 | `G:/deerflow/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` |
|
||||||
|
| Hermes 输出管理 | `G:/hermes-agent-main/tools/tool_result_storage.py` |
|
||||||
|
| Hermes 预算配置 | `G:/hermes-agent-main/tools/budget_config.py` |
|
||||||
212
docs/references/deerflow-toolcall-reference.md
Normal file
212
docs/references/deerflow-toolcall-reference.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# DeerFlow 工具调用系统参考文档
|
||||||
|
|
||||||
|
> 调研 DeerFlow 的工具调用完整流程,为 ZCLAW 工具调用问题排查提供参考。
|
||||||
|
> 分析日期:2026-04-24
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、端到端数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
用户消息
|
||||||
|
→ FastAPI Gateway (/api/threads/{id}/runs/stream)
|
||||||
|
→ services.start_run() → asyncio.create_task(run_agent(...))
|
||||||
|
→ LangGraph Agent Graph (create_agent)
|
||||||
|
→ LLM Model (ChatOpenAI / Claude)
|
||||||
|
→ AIMessage (含 tool_calls 列表)
|
||||||
|
→ 14 层 Middleware 链处理
|
||||||
|
→ ToolNode (LangGraph 内置, 按 tool_call.name 路由)
|
||||||
|
→ ToolMessage (执行结果)
|
||||||
|
→ 再次调用 LLM (带着 ToolMessage 继续)
|
||||||
|
→ StreamBridge.publish() → asyncio.Queue
|
||||||
|
→ SSE → 前端 useStream hook
|
||||||
|
→ React 组件渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、工具注册与执行
|
||||||
|
|
||||||
|
### 2.1 注册入口
|
||||||
|
|
||||||
|
**文件**: `G:/deerflow/backend/packages/harness/deerflow/tools/tools.py` — `get_available_tools()`
|
||||||
|
|
||||||
|
工具来自四个来源:
|
||||||
|
|
||||||
|
| 来源 | 加载方式 | 示例 |
|
||||||
|
|------|----------|------|
|
||||||
|
| Config 工具 | YAML 配置 + 反射导入 (`module:variable`) | `deerflow.sandbox.tools:bash_tool` |
|
||||||
|
| Builtin 工具 | 硬编码导入 | `present_file_tool`, `ask_clarification_tool` |
|
||||||
|
| MCP 工具 | `MultiServerMCPClient` 从 MCP 服务器缓存获取 | 第三方 MCP 工具 |
|
||||||
|
| ACP 工具 | `build_invoke_acp_agent_tool()` 动态构建 | 外部 agent 调用 |
|
||||||
|
|
||||||
|
### 2.2 Sandbox 工具清单
|
||||||
|
|
||||||
|
**文件**: `G:/deerflow/backend/packages/harness/deerflow/sandbox/tools.py`
|
||||||
|
|
||||||
|
| 工具名 | 功能 |
|
||||||
|
|--------|------|
|
||||||
|
| `bash` | 沙箱中执行命令 |
|
||||||
|
| `ls` | 列出目录 |
|
||||||
|
| `read_file` | 读取文件 |
|
||||||
|
| `write_file` | 写入文件(触发产物面板自动打开) |
|
||||||
|
| `str_replace` | 字符串替换(触发产物面板自动打开) |
|
||||||
|
|
||||||
|
### 2.3 Builtin 工具
|
||||||
|
|
||||||
|
**文件**: `G:/deerflow/backend/packages/harness/deerflow/tools/builtins/`
|
||||||
|
|
||||||
|
| 工具 | 功能 |
|
||||||
|
|------|------|
|
||||||
|
| `ask_clarification` | 向用户提问澄清(中断执行等待回复) |
|
||||||
|
| `present_file` | 展示文件给用户(触发产物卡片) |
|
||||||
|
| `setup_agent` | 自定义 agent 创建 |
|
||||||
|
| `task_tool` | 子 agent 任务委派 |
|
||||||
|
| `view_image` | 图片查看(仅视觉模型) |
|
||||||
|
| `tool_search` | 延迟工具搜索(MCP 工具按需暴露) |
|
||||||
|
|
||||||
|
## 三、中间件链(14 层)
|
||||||
|
|
||||||
|
**文件**: `G:/deerflow/backend/packages/harness/deerflow/agents/lead_agent/agent.py` — `_build_middlewares()`
|
||||||
|
|
||||||
|
与工具调用相关的关键中间件:
|
||||||
|
|
||||||
|
### 3.1 DanglingToolCallMiddleware
|
||||||
|
|
||||||
|
**文件**: `dangling_tool_call_middleware.py`
|
||||||
|
|
||||||
|
在 `wrap_model_call` 中检测消息历史中缺失 ToolMessage 的 AIMessage,自动注入占位 ToolMessage:
|
||||||
|
```python
|
||||||
|
ToolMessage(
|
||||||
|
content="[Tool call was interrupted and did not return a result.]",
|
||||||
|
tool_call_id=tc_id,
|
||||||
|
name=tc.get("name", "unknown"),
|
||||||
|
status="error",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 ToolErrorHandlingMiddleware
|
||||||
|
|
||||||
|
**文件**: `tool_error_handling_middleware.py`
|
||||||
|
|
||||||
|
在 `wrap_tool_call` 中捕获工具执行异常,转换为错误 ToolMessage 而非让整个 run 崩溃。
|
||||||
|
|
||||||
|
### 3.3 LoopDetectionMiddleware
|
||||||
|
|
||||||
|
**文件**: `loop_detection_middleware.py`
|
||||||
|
|
||||||
|
在 `after_model` 中检测重复工具调用:
|
||||||
|
- 阈值 3 次 → 注入警告 HumanMessage
|
||||||
|
- 阈值 5 次 → 直接清空 tool_calls,强制 LLM 产出文本回答
|
||||||
|
|
||||||
|
### 3.4 DeferredToolFilterMiddleware
|
||||||
|
|
||||||
|
**文件**: `deferred_tool_filter_middleware.py`
|
||||||
|
|
||||||
|
在 `wrap_model_call` 中过滤延迟注册的 MCP 工具 schema,仅在 LLM 通过 `tool_search` 发现后才暴露。
|
||||||
|
|
||||||
|
### 3.5 ClarificationMiddleware
|
||||||
|
|
||||||
|
拦截 `ask_clarification` 工具调用,中断执行等待用户回复。
|
||||||
|
|
||||||
|
### 3.6 SubagentLimitMiddleware
|
||||||
|
|
||||||
|
截断过多的并行子 agent 调用。
|
||||||
|
|
||||||
|
## 四、工具结果回传
|
||||||
|
|
||||||
|
### 4.1 格式
|
||||||
|
|
||||||
|
LangChain 的 `ToolMessage`,包含:
|
||||||
|
- `content`: 执行结果文本
|
||||||
|
- `tool_call_id`: 匹配 AIMessage 中的 tool_call ID
|
||||||
|
- `name`: 工具名称
|
||||||
|
- `status`: `"error"` 或省略
|
||||||
|
|
||||||
|
### 4.2 特殊工具
|
||||||
|
|
||||||
|
`present_file_tool` 返回 `Command` 而非纯字符串,同时更新 `artifacts` 和 `messages` 两个 state channel。
|
||||||
|
|
||||||
|
## 五、前端工具调用展示
|
||||||
|
|
||||||
|
### 5.1 消息分组
|
||||||
|
|
||||||
|
**文件**: `G:/deerflow/frontend/src/core/messages/utils.ts` — `groupMessages()`
|
||||||
|
|
||||||
|
| 分组类型 | 触发条件 | 展示 |
|
||||||
|
|----------|----------|------|
|
||||||
|
| `assistant:processing` | AI 消息含 tool_calls 或 reasoning | MessageGroup (折叠) |
|
||||||
|
| `assistant` | AI 消息有文本无 tool_calls | MessageListItem (气泡) |
|
||||||
|
| `assistant:present-files` | 含 present_files tool call | ArtifactFileList |
|
||||||
|
| `assistant:clarification` | ask_clarification 结果 | MarkdownContent |
|
||||||
|
| `assistant:subagent` | 含 task tool call | SubtaskCard |
|
||||||
|
|
||||||
|
### 5.2 工具状态推断
|
||||||
|
|
||||||
|
前端**没有显式状态机**。通过消息序列推断:
|
||||||
|
- AI 消息含 tool_calls 但无对应 ToolMessage → 正在执行
|
||||||
|
- ToolMessage 出现 → 执行完成
|
||||||
|
- `assistant:processing` 组由 `ChainOfThought` 折叠组件包裹
|
||||||
|
|
||||||
|
### 5.3 工具调用 UI
|
||||||
|
|
||||||
|
**文件**: `message-group.tsx` 第 186-423 行
|
||||||
|
|
||||||
|
按工具名渲染不同图标和内容:
|
||||||
|
- `bash` → 终端图标 + 命令代码块
|
||||||
|
- `read_file`/`write_file`/`str_replace` → 文件图标 + 路径链接(点击打开产物面板)
|
||||||
|
- `web_search` → 搜索图标 + 结果链接
|
||||||
|
- 默认 → 扳手图标 + 工具名
|
||||||
|
|
||||||
|
## 六、流式处理中的工具调用
|
||||||
|
|
||||||
|
### 6.1 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
agent.astream(stream_mode=["values"])
|
||||||
|
→ StreamBridge (asyncio.Queue per run, maxsize=256)
|
||||||
|
→ sse_consumer() → SSE frames → 前端
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 关键特征
|
||||||
|
|
||||||
|
- 工具调用**不中断**流。LangGraph 自动在 agent_node 和 tool_node 之间路由
|
||||||
|
- 每次状态变更产出完整的 `values` 快照,前端通过 `seen_ids` 去重
|
||||||
|
- 15 秒心跳包保持 SSE 连接
|
||||||
|
|
||||||
|
### 6.3 前端看到的事件序列
|
||||||
|
|
||||||
|
1. `values` 事件: 含 `tool_calls` 的 AIMessage
|
||||||
|
2. `values` 事件: ToolMessage(工具结果)
|
||||||
|
3. `values` 事件: LLM 基于工具结果的最终回答
|
||||||
|
|
||||||
|
整个过程连续,不中断 SSE 连接。
|
||||||
|
|
||||||
|
## 七、与 ZCLAW 对比(工具调用)
|
||||||
|
|
||||||
|
| 维度 | DeerFlow | ZCLAW |
|
||||||
|
|------|----------|-------|
|
||||||
|
| 框架 | LangGraph (graph-based) | 自研 loop_runner (循环) |
|
||||||
|
| 工具生命周期 | LangGraph ToolNode 自动管理 | 手动 ToolRegistry + loop_runner |
|
||||||
|
| after_tool_call 中间件 | ✅ wrap_tool_call 钩子完整 | ❌ 流式和非流式模式均未调用 |
|
||||||
|
| 并行工具执行 | LangGraph 自动处理 | 非流式有 JoinSet,流式全串行 |
|
||||||
|
| 悬挂修复 | DanglingToolCallMiddleware | DanglingToolMiddleware (有) |
|
||||||
|
| 错误恢复 | ToolErrorHandlingMiddleware (异常→ToolMessage) | ToolErrorMiddleware (计数器) |
|
||||||
|
| 循环检测 | LoopDetectionMiddleware (3次警告/5次强停) | LoopGuardMiddleware (有) |
|
||||||
|
| 前端状态 | 消息序列推断 | 显式 ToolCallStep 状态机 |
|
||||||
|
| MCP 工具 | 延迟注册 + tool_search 按需暴露 | 全量注册 |
|
||||||
|
|
||||||
|
## 八、关键文件索引
|
||||||
|
|
||||||
|
| 功能 | DeerFlow 文件 |
|
||||||
|
|------|-------------|
|
||||||
|
| Agent 工厂 | `backend/packages/harness/deerflow/agents/lead_agent/agent.py` |
|
||||||
|
| 中间件组装 | `backend/packages/harness/deerflow/agents/factory.py` |
|
||||||
|
| 工具注册 | `backend/packages/harness/deerflow/tools/tools.py` |
|
||||||
|
| Sandbox 工具 | `backend/packages/harness/deerflow/sandbox/tools.py` |
|
||||||
|
| Builtin 工具 | `backend/packages/harness/deerflow/tools/builtins/` |
|
||||||
|
| 错误处理中间件 | `agents/middlewares/tool_error_handling_middleware.py` |
|
||||||
|
| 悬挂修复中间件 | `agents/middlewares/dangling_tool_call_middleware.py` |
|
||||||
|
| 循环检测中间件 | `agents/middlewares/loop_detection_middleware.py` |
|
||||||
|
| 延迟过滤中间件 | `agents/middlewares/deferred_tool_filter_middleware.py` |
|
||||||
|
| 流式 Bridge | `runtime/stream_bridge/memory.py` |
|
||||||
|
| 前端消息分组 | `frontend/src/core/messages/utils.ts` |
|
||||||
|
| 前端工具调用组件 | `frontend/src/components/workspace/messages/message-group.tsx` |
|
||||||
141
docs/references/zclaw-toolcall-issues.md
Normal file
141
docs/references/zclaw-toolcall-issues.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# ZCLAW 工具调用问题分析
|
||||||
|
|
||||||
|
> 对比 DeerFlow 工具调用系统,排查 ZCLAW 工具调用问题。
|
||||||
|
> 分析日期:2026-04-24
|
||||||
|
> 更新日期:2026-04-24(P0+P0-stream_errored 已修复)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、发现的问题
|
||||||
|
|
||||||
|
### P0: `after_tool_call` 中间件从未被调用 — ✅ 已修复 (2026-04-24)
|
||||||
|
|
||||||
|
**文件**: `crates/zclaw-runtime/src/loop_runner.rs`
|
||||||
|
|
||||||
|
在 `run()`(非流式,第 400-558 行)和 `run_streaming`(流式,第 893-1070 行)中,工具执行后直接 push `Message::tool_result` 到消息历史,**没有调用 `middleware_chain.run_after_tool_call()`**。
|
||||||
|
|
||||||
|
**影响**:
|
||||||
|
- `ToolErrorMiddleware.after_tool_call` 的错误计数和恢复消息逻辑不生效
|
||||||
|
- `ToolOutputGuardMiddleware.after_tool_call` 的敏感信息检测不生效
|
||||||
|
- 工具错误只能靠工具自身的错误返回传递,中间件层的防护形同虚设
|
||||||
|
|
||||||
|
**DeerFlow 对比**: `ToolErrorHandlingMiddleware` 通过 `wrap_tool_call` 钩子完整包裹每次工具执行。
|
||||||
|
|
||||||
|
### P0: `stream_errored` 跳过所有工具执行 — ✅ 已修复 (2026-04-24)
|
||||||
|
|
||||||
|
**文件**: `crates/zclaw-runtime/src/loop_runner.rs` 第 872-876 行
|
||||||
|
|
||||||
|
流式模式中,当 LLM 流出现任何错误(网络超时、API 错误、驱动错误)时,`stream_errored = true`,然后 `break 'outer` 直接退出循环,**跳过所有已解析的工具调用**。
|
||||||
|
|
||||||
|
**影响**:
|
||||||
|
- ToolStart 事件已发送给前端(用户看到"执行中"按钮),但工具从未实际执行
|
||||||
|
- ToolEnd 事件永远不会发送 → 前端工具状态卡在"执行中"
|
||||||
|
- 已完整接收(ToolUseEnd)的工具调用也被丢弃
|
||||||
|
|
||||||
|
**修复**: 区分完整工具(收到 ToolUseEnd)和不完整工具(仅收到 ToolUseStart/Delta)。完整工具照常执行,不完整工具发送取消 ToolEnd 事件。
|
||||||
|
|
||||||
|
### P1: 流式模式工具全串行 — ✅ 已修复 (2026-04-24)
|
||||||
|
|
||||||
|
**文件**: `loop_runner.rs` 流式模式工具执行段
|
||||||
|
|
||||||
|
非流式模式有 `JoinSet` + `Semaphore(3)` 并行执行 ReadOnly 工具,但流式模式用简单 `for` 循环串行执行所有工具。
|
||||||
|
|
||||||
|
**修复**: 流式模式采用三阶段执行:Phase 1 中间件预检(serial) → Phase 2 并行+串行分区执行 → Phase 3 after_tool_call + 结果排序推送。
|
||||||
|
|
||||||
|
### P2: OpenAI 驱动工具参数静默替换 — ✅ 已修复 (2026-04-24)
|
||||||
|
|
||||||
|
**文件**: `crates/zclaw-runtime/src/driver/openai.rs` 第 222-228 行
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let parsed_args = if args.is_empty() {
|
||||||
|
serde_json::json!({})
|
||||||
|
} else {
|
||||||
|
serde_json::from_str(args).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!("Failed to parse tool args '{}': {}", args, e);
|
||||||
|
serde_json::json!({})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 解析失败时静默替换为 `{}`,结合 loop_runner.rs 的空参数处理(第 412-423 行),会注入 `_fallback_query` 替代实际参数。
|
||||||
|
|
||||||
|
**修复**: 解析失败时返回 `_parse_error` + `_raw_args` 字段,让工具和 LLM 能感知到参数问题并自我修正。
|
||||||
|
|
||||||
|
### P2: ToolOutputGuard 过于激进 — ✅ 已修复 (2026-04-24)
|
||||||
|
|
||||||
|
**文件**: `crates/zclaw-runtime/src/middleware/tool_output_guard.rs` 第 109 行
|
||||||
|
|
||||||
|
使用 `to_lowercase()` 匹配敏感模式,合法内容中包含 "password"、"system:" 等字符串会被误拦。
|
||||||
|
|
||||||
|
**修复**: 改用 `regex` 精确匹配实际密钥值格式(如 `sk-[a-zA-Z0-9]{20,}`、`AKIA[A-Z0-9]{16}`、`key=value` 模式),不再误拦仅包含关键词的合法内容。移除了 "system:" 等过于宽泛的注入检测模式。
|
||||||
|
|
||||||
|
### P2: ToolErrorMiddleware 失败计数器是全局的 — ✅ 已修复 (2026-04-24)
|
||||||
|
|
||||||
|
**文件**: `crates/zclaw-runtime/src/middleware/tool_error.rs` 第 27 行
|
||||||
|
|
||||||
|
`consecutive_failures: AtomicU32` 是结构体字段,所有 session 共享。高并发下 A session 失败 2 次 + B session 失败 1 次就会触发 AbortLoop(阈值 3)。
|
||||||
|
|
||||||
|
**修复**: 改用 `Mutex<HashMap<String, u32>>` 以 session_id 为 key 存储计数,每个会话独立跟踪。
|
||||||
|
|
||||||
|
### P3: Gateway 客户端 onTool 回调语义不一致 — ✅ 已修复 (2026-04-24)
|
||||||
|
|
||||||
|
**文件**: `desktop/src/lib/gateway-client.ts` 第 698-707 行
|
||||||
|
|
||||||
|
`tool_call` 和 `tool_result` 两个 case 共用 `onTool` 回调,但参数约定不同,调用者必须通过 `output` 是否为空判断 start/end。
|
||||||
|
|
||||||
|
**修复**: 明确 `tool_call` 的 output 始终为 `''`(修复了可能传递 data.output 的问题),添加清晰注释说明 start/end 语义约定。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、根因分析
|
||||||
|
|
||||||
|
工具调用问题最常见的故障模式:
|
||||||
|
|
||||||
|
1. **LLM 返回的 tool_call 参数格式错误** → OpenAI 驱动静默替换为 `{}` → 工具以空参数执行 → 结果不符合预期
|
||||||
|
2. **工具执行异常** → after_tool_call 中间件未调用 → 错误未格式化 → LLM 收到原始错误信息无法恢复
|
||||||
|
3. **流被中断后重连** → DanglingToolMiddleware 修复悬挂 → 但如果修复逻辑本身有 bug(如重复修补),会导致消息膨胀
|
||||||
|
|
||||||
|
## 三、修复建议
|
||||||
|
|
||||||
|
### 修复 1: 在 loop_runner 中调用 after_tool_call
|
||||||
|
|
||||||
|
**优先级**: P0
|
||||||
|
**影响文件**: `loop_runner.rs`
|
||||||
|
|
||||||
|
在非流式模式的工具执行循环中(约第 530 行),工具执行后调用:
|
||||||
|
```rust
|
||||||
|
let after_result = middleware_chain.run_after_tool_call(
|
||||||
|
&name, &input_json, &output_str, &mut ctx
|
||||||
|
).await;
|
||||||
|
```
|
||||||
|
|
||||||
|
在流式模式的工具执行后(约第 1020 行),同样调用。
|
||||||
|
|
||||||
|
### 修复 2: 将 ToolErrorMiddleware 计数器改为 per-session
|
||||||
|
|
||||||
|
**优先级**: P2
|
||||||
|
**影响文件**: `middleware/tool_error.rs`
|
||||||
|
|
||||||
|
使用 `HashMap<String, u32>` 以 session_id 为 key 存储计数。
|
||||||
|
|
||||||
|
### 修复 3: ToolOutputGuard 改为精确匹配
|
||||||
|
|
||||||
|
**优先级**: P2
|
||||||
|
**影响文件**: `middleware/tool_output_guard.rs`
|
||||||
|
|
||||||
|
只在检测到独立的密钥值时触发(如 `sk-[48字符]`),而非单词级匹配。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、关键文件
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `crates/zclaw-runtime/src/loop_runner.rs` | 主循环,工具调度 |
|
||||||
|
| `crates/zclaw-runtime/src/tool.rs` | ToolRegistry + Tool trait |
|
||||||
|
| `crates/zclaw-runtime/src/middleware/tool_error.rs` | 工具错误处理 |
|
||||||
|
| `crates/zclaw-runtime/src/middleware/tool_output_guard.rs` | 输出安全检查 |
|
||||||
|
| `crates/zclaw-runtime/src/middleware/dangling_tool.rs` | 断裂工具修复 |
|
||||||
|
| `crates/zclaw-runtime/src/driver/openai.rs` | OpenAI 兼容驱动 |
|
||||||
|
| `desktop/src/lib/gateway-client.ts` | 前端通信客户端 |
|
||||||
|
| `desktop/src/store/chat/streamStore.ts` | 前端流式处理 |
|
||||||
335
docs/superpowers/specs/2026-04-22-wiki-restructure-design.md
Normal file
335
docs/superpowers/specs/2026-04-22-wiki-restructure-design.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# Wiki Restructure Design
|
||||||
|
|
||||||
|
> Date: 2026-04-22
|
||||||
|
> Status: Approved
|
||||||
|
> Author: Claude + User brainstorming session
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
Current wiki (16 files, ~155KB) has three structural problems:
|
||||||
|
|
||||||
|
1. **No task-oriented navigation** — Cannot go from symptom to module quickly
|
||||||
|
2. **Duplicate content** — Middleware/security/evolution described in 3+ pages
|
||||||
|
3. **Missing integration contracts** — Cross-module boundaries undocumented
|
||||||
|
4. **Growing append-only sections** — log.md already 31KB, known-issues.md 13KB
|
||||||
|
|
||||||
|
The wiki's primary reader is a Claude AI session that reads it at conversation start to orient itself. Secondary reader is the human developer.
|
||||||
|
|
||||||
|
## 2. Design Principles
|
||||||
|
|
||||||
|
1. **Wiki documents what code cannot tell you** — WHY decisions, navigation shortcuts, traps, invariants
|
||||||
|
2. **Code logic sections focus on flows + invariants + algorithms** — NOT field lists or function signatures
|
||||||
|
3. **Page size budget** — index ≤ 120 lines, module pages 100-200 lines (3-6KB)
|
||||||
|
4. **Single source of truth per topic** — No content duplicated across pages; use references
|
||||||
|
5. **Append-only sections are capped** — log.md capped at 50 entries, old entries archived
|
||||||
|
|
||||||
|
## 3. Structure
|
||||||
|
|
||||||
|
### 3.1 Level 1: `index.md` — Navigation + Symptom Index
|
||||||
|
|
||||||
|
```
|
||||||
|
wiki/index.md
|
||||||
|
├── Project one-liner
|
||||||
|
├── Key numbers table (cross-validated with TRUTH.md)
|
||||||
|
├── System data flow diagram (existing ASCII art)
|
||||||
|
├── Module navigation tree (one-line description per module)
|
||||||
|
├── Symptom navigation table (NEW)
|
||||||
|
└── Module dependency map (who calls who)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Symptom Navigation Table** (NEW):
|
||||||
|
|
||||||
|
| Symptom | First check | Then check | Common root cause |
|
||||||
|
|---------|-------------|------------|-------------------|
|
||||||
|
| Stream stuck | routing | chat → middleware | Connection lost / SaaS relay timeout |
|
||||||
|
| Memory not injected | memory | middleware | FTS5 index empty / middleware skipped |
|
||||||
|
| Hand trigger failed | hands-skills | middleware | Tool call blocked by Guardrail |
|
||||||
|
| SaaS relay 502 | saas | routing | Token Pool exhausted / Key expired |
|
||||||
|
| Model switch not working | routing | chat | SaaS whitelist vs local config mismatch |
|
||||||
|
| Agent creation failed | chat | saas | Permission or persistence issue |
|
||||||
|
| Pipeline execution stuck | pipeline | middleware | DAG cycle / missing dependency |
|
||||||
|
| Admin page 403 | saas | security | JWT expired / admin_guard blocked |
|
||||||
|
|
||||||
|
**Target**: ≤ 120 lines. A new AI session reads index and immediately knows which modules to open.
|
||||||
|
|
||||||
|
### 3.2 Level 2: Module Pages (~15)
|
||||||
|
|
||||||
|
Each module page has 5 sections in reading priority order:
|
||||||
|
|
||||||
|
#### Section 1: Design Decisions (WHY)
|
||||||
|
|
||||||
|
- Why this module was designed this way
|
||||||
|
- Historical context and background
|
||||||
|
- Trade-offs made and alternatives rejected
|
||||||
|
- Key architectural decisions
|
||||||
|
|
||||||
|
Format: prose paragraphs + Q&A pairs for important decisions.
|
||||||
|
|
||||||
|
#### Section 2: Key Files + Data Flow (WHERE)
|
||||||
|
|
||||||
|
- Core files table (3-7 files, one-line responsibility each)
|
||||||
|
- Module-internal data flow diagram (ASCII or mermaid)
|
||||||
|
- **Integration contracts** (NEW):
|
||||||
|
- What this module calls upstream
|
||||||
|
- What this module exposes downstream
|
||||||
|
- Interface shapes at boundaries
|
||||||
|
|
||||||
|
#### Section 3: Code Logic (LOGIC)
|
||||||
|
|
||||||
|
Focus on three types of information that code alone cannot efficiently convey:
|
||||||
|
|
||||||
|
- **Key data flows**: Cross-function/cross-file complete paths
|
||||||
|
- **Invariants**: Constraints that must always hold (marked with ⚡)
|
||||||
|
- **Non-obvious algorithms**: Logic that is hard to understand from reading code alone
|
||||||
|
|
||||||
|
Explicitly EXCLUDED:
|
||||||
|
- Field lists (read from code)
|
||||||
|
- Function signatures (read from code)
|
||||||
|
- CRUD operations (obvious from code)
|
||||||
|
- Anything that can be answered by `grep`
|
||||||
|
|
||||||
|
#### Section 4: Active Issues + Gotchas (GOTCHAS)
|
||||||
|
|
||||||
|
- Active issues (0-5 items, removed when fixed)
|
||||||
|
- Historical pitfall records (≤ 10 items, distilled to one lesson each)
|
||||||
|
- ⚠️ Warnings (error-prone areas)
|
||||||
|
|
||||||
|
#### Section 5: Change Log (CHANGES)
|
||||||
|
|
||||||
|
- Last 5 changes (format: date + one-liner)
|
||||||
|
- Older changes → global `log.md`
|
||||||
|
|
||||||
|
### 3.3 Module Page Template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: {Module Name}
|
||||||
|
updated: {YYYY-MM-DD}
|
||||||
|
status: active | stable | developing
|
||||||
|
tags: [{module-specific tags}]
|
||||||
|
---
|
||||||
|
|
||||||
|
# {Module Name}
|
||||||
|
|
||||||
|
> From [[index]]. Related: [[related-module-1]] [[related-module-2]]
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
{Why this module exists, key design choices, tradeoffs}
|
||||||
|
|
||||||
|
## Key Files + Data Flow
|
||||||
|
|
||||||
|
### Core Files
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|---------------|
|
||||||
|
| `path/to/file` | One-line description |
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
{ASCII flow diagram}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Contracts
|
||||||
|
|
||||||
|
> Format: Direction | Module | Interface (Rust trait / Tauri invoke / TS function) | Trigger
|
||||||
|
|
||||||
|
| Direction | Module | Interface | Trigger |
|
||||||
|
|-----------|--------|-----------|---------|
|
||||||
|
| Calls → | {module} | `{rust_fn / tauri_invoke / ts_fn}` | {when/why} |
|
||||||
|
| Called by ← | {module} | `{rust_fn / tauri_invoke / ts_fn}` | {when/why} |
|
||||||
|
|
||||||
|
<!-- Example (middleware.md):
|
||||||
|
| Calls → | runtime | `MiddlewareChain::run_before_completion()` | Every chat request before LLM call |
|
||||||
|
| Called by ← | kernel | `kernel/mod.rs:create_middleware_chain()` | Kernel boot, once per session |
|
||||||
|
| Called by ← | saas | HTTP relay handler | SaaS relay routes (10 HTTP middleware) |
|
||||||
|
| Provides → | all modules | `AgentMiddleware` trait | 14 implementations registered |
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Code Logic
|
||||||
|
|
||||||
|
### Key Data Flows
|
||||||
|
|
||||||
|
{Cross-function paths with intent}
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
⚡ {Invariant 1}: {description of what must always be true}
|
||||||
|
|
||||||
|
⚡ {Invariant 2}: {description}
|
||||||
|
|
||||||
|
### Non-obvious Algorithms
|
||||||
|
|
||||||
|
{Algorithms that are hard to understand from reading code}
|
||||||
|
|
||||||
|
## Active Issues + Gotchas
|
||||||
|
|
||||||
|
### Active Issues
|
||||||
|
|
||||||
|
| Issue | Severity | Status | Notes |
|
||||||
|
|-------|----------|--------|-------|
|
||||||
|
| {description} | P{0-3} | Open | {context} |
|
||||||
|
|
||||||
|
### Historical Pitfalls
|
||||||
|
|
||||||
|
- {Lesson learned}: {one-line description of what went wrong and the fix}
|
||||||
|
|
||||||
|
### Warnings
|
||||||
|
|
||||||
|
⚠️ {Warning}: {what to watch out for}
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| {YYYY-MM-DD} | {one-line description} |
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Migration Plan
|
||||||
|
|
||||||
|
### 4.0 Execution Order
|
||||||
|
|
||||||
|
Migration must follow this sequence due to cross-page dependencies:
|
||||||
|
|
||||||
|
1. **Phase A — Archive/cap** (no dependencies): Cap `log.md` at 50 entries, archive old to `wiki/archive/`. Convert `known-issues.md` to pointer. Archive `hermes-analysis.md`.
|
||||||
|
2. **Phase B — Single source of truth**: Restructure `middleware.md` first (other pages reference it).
|
||||||
|
3. **Phase C — Dependent pages**: `saas.md`, `security.md`, `memory.md` (remove middleware/evolution duplicates, add contracts).
|
||||||
|
4. **Phase D — Remaining modules**: `routing.md`, `chat.md`, `butler.md`, `hands-skills.md`, `pipeline.md`, `data-model.md`.
|
||||||
|
5. **Phase E — Index last**: `index.md` restructure (depends on all modules being complete).
|
||||||
|
6. **Phase F — feature-map.md**: Distribute chain traces to module "Code Logic" sections, convert to index page.
|
||||||
|
|
||||||
|
**Rollback strategy**: Migrate one module per commit. Any partial state is internally consistent per-page. `git revert` on a single commit restores that module's old version.
|
||||||
|
|
||||||
|
### 4.1 Per-Page Source-to-Target Mapping
|
||||||
|
|
||||||
|
#### `index.md` (8KB → target ≤ 120 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Key numbers table | Keep | Stay (cross-validated with TRUTH.md) |
|
||||||
|
| System data flow diagram | Keep | Stay |
|
||||||
|
| Module navigation tree | Keep | Stay |
|
||||||
|
| Architecture Q&A "Why 14 middleware" | Move | → `middleware.md` Design Decisions |
|
||||||
|
| Architecture Q&A "Why 管家 default" | Move | → `butler.md` Design Decisions |
|
||||||
|
| Architecture Q&A "Why 3 ChatStream" | Move | → `chat.md` Design Decisions |
|
||||||
|
| Architecture Q&A "Why SaaS relay" | Move | → `routing.md` Design Decisions |
|
||||||
|
| Architecture Q&A "Evolution engine" | Move | → `memory.md` Design Decisions |
|
||||||
|
| Symptom navigation table | NEW | Add after navigation tree |
|
||||||
|
|
||||||
|
#### `middleware.md` (7KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Design thought | Keep as "Design Decisions" | Expand with WHY from index Q&A |
|
||||||
|
| 14-layer table | Keep | Core Files + Data Flow |
|
||||||
|
| Execution flow diagram | Keep | Code Logic |
|
||||||
|
| SaaS HTTP middleware (10 layers) | Keep | Integration Contracts |
|
||||||
|
| "11/14 no tests" warning | Keep | Active Issues |
|
||||||
|
| API interface (trait) | Trim to key data flow | Code Logic (flows only) |
|
||||||
|
|
||||||
|
#### `routing.md` (13KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| 5-branch decision tree | Keep | Code Logic → Key Data Flows |
|
||||||
|
| Store layer listing (25 stores) | Remove | Split: chat stores → `chat.md`, saas stores → `saas.md`, connection stores stay |
|
||||||
|
| lib/ file listing (75 files) | Remove | → `development.md` reference appendix |
|
||||||
|
| Model routing full chain | Keep (simplified) | Code Logic → Key Data Flows |
|
||||||
|
| Tauri commands table | Keep | Integration Contracts |
|
||||||
|
|
||||||
|
#### `chat.md` (6KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| 3 ChatStream implementations | Keep as Design Decision | Add WHY from index Q&A |
|
||||||
|
| Store 拆分 (5 Store) | Move | → Key Files table |
|
||||||
|
| Send message flow | Keep | Code Logic → Key Data Flows |
|
||||||
|
| Add invariants | NEW | e.g., ⚡ sessionKey must be consistent within a conversation |
|
||||||
|
| Add integration contracts | NEW | Calls → routing (getClient), middleware (chain), saas (relay) |
|
||||||
|
|
||||||
|
#### `memory.md` (19KB → target 200 lines, largest compression needed)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Memory pipeline design | Keep as Design Decisions | + Absorb WHY from index Q&A |
|
||||||
|
| FTS5/TF-IDF/embedding details | Keep invariants and flows | Code Logic |
|
||||||
|
| Hermes insights (from hermes-analysis.md) | Distill 3-5 key lessons | Design Decisions (one paragraph) + Gotchas |
|
||||||
|
| Detailed extraction logic | Trim to flows + invariants | Archive detailed prose to `wiki/archive/` |
|
||||||
|
| Cross-session injection fix | Keep as historical pitfall | Gotchas |
|
||||||
|
| Profile store connection fix | Keep as historical pitfall | Gotchas |
|
||||||
|
|
||||||
|
#### `saas.md` (10KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Auth flow (JWT/Cookie/TOTP) | Remove details | → `security.md` owns design, saas.md keeps reference |
|
||||||
|
| Billing/subscription | Keep | Code Logic → Key Data Flows |
|
||||||
|
| Admin V2 | Keep summary | Key Files |
|
||||||
|
| Token Pool RPM/TPM | Keep | Code Logic → Non-obvious Algorithms |
|
||||||
|
| Add integration contracts | NEW | Calls → relay, Called by ← desktop client |
|
||||||
|
|
||||||
|
#### `security.md` (6KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Auth flow details | OWN this content | Absorb from saas.md, become single source |
|
||||||
|
| JWT/Cookie/TOTP details | Keep | Code Logic |
|
||||||
|
| Rate limiting | Keep | Code Logic |
|
||||||
|
| Add integration contracts | NEW | Provides auth middleware to SaaS, crypto utils to client |
|
||||||
|
|
||||||
|
#### Other modules (`butler`, `hands-skills`, `pipeline`, `data-model`)
|
||||||
|
|
||||||
|
All follow same pattern: keep existing design/code sections, add integration contracts, add invariants, trim to size budget.
|
||||||
|
|
||||||
|
#### `feature-map.md` (15KB → Convert to index)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| F-01~F-05 chat chains | Distribute | → `chat.md` Code Logic as chain trace reference |
|
||||||
|
| F-06~F-10 memory chains | Distribute | → `memory.md` Code Logic |
|
||||||
|
| F-11~F-15 hand chains | Distribute | → `hands-skills.md` Code Logic |
|
||||||
|
| Remaining chains | Distribute | → Corresponding module pages |
|
||||||
|
| File itself | Keep as index | Module → feature chain mapping only |
|
||||||
|
|
||||||
|
### 4.2 Pages to Merge/Archive
|
||||||
|
|
||||||
|
| Page | Action | Destination |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `known-issues.md` | Convert to pointer | Active issues → per-module, global file = links only |
|
||||||
|
| `log.md` | Cap at 50 entries | Archive old entries to `wiki/archive/log-{YYYY-MM}.md` |
|
||||||
|
| `hermes-analysis.md` | Archive | Key insights → `memory.md` Gotchas, file → `wiki/archive/` |
|
||||||
|
| `development.md` | Keep as-is | Global dev standards, not per-module |
|
||||||
|
|
||||||
|
### 4.3 Duplicate Content Resolution
|
||||||
|
|
||||||
|
| Content | Current Location | New Owner | Others |
|
||||||
|
|---------|-----------------|-----------|--------|
|
||||||
|
| Middleware descriptions | middleware + saas + security | `middleware.md` | Reference only |
|
||||||
|
| Security mechanisms | security + saas | `security.md` | saas.md references |
|
||||||
|
| Evolution engine | memory + middleware + index | `memory.md` | Others reference |
|
||||||
|
| Store listing (25) | routing.md | Split: chat→chat, saas→saas, etc. | routing.md keeps connection stores |
|
||||||
|
| lib/ file listing (75) | routing.md | `development.md` or dedicated reference | routing.md removes |
|
||||||
|
|
||||||
|
## 5. Validation Criteria
|
||||||
|
|
||||||
|
- [ ] New AI session can locate any module's core files from index in ≤ 2 hops
|
||||||
|
- [ ] Each module page has integration contracts section
|
||||||
|
- [ ] No content duplicated across ≥ 3 pages
|
||||||
|
- [ ] index.md ≤ 120 lines
|
||||||
|
- [ ] Each module page 100-200 lines
|
||||||
|
- [ ] log.md ≤ 50 active entries
|
||||||
|
- [ ] Symptom navigation table covers top 8 common debugging scenarios
|
||||||
|
|
||||||
|
## 6. Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Module pages exceed size budget | Medium | AI context waste | Trim during migration, move details to archive |
|
||||||
|
| Invariants drift from code | Medium | Misleading docs | Add "last verified" date, check during code changes |
|
||||||
|
| Integration contracts incomplete | High | Gap remains | Start with existing cross-references, fill during next debug session |
|
||||||
|
| Migration breaks existing workflow | Low | Confusion | Migrate one module at a time, verify after each |
|
||||||
|
|
||||||
|
## 7. Open Questions
|
||||||
|
|
||||||
|
- Should we add a `wiki/templates/module-template.md` for consistency?
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# 动态建议智能化设计
|
||||||
|
|
||||||
|
> 日期: 2026-04-23 | 状态: Draft | 方案: Prompt 增强法
|
||||||
|
|
||||||
|
## 1. 问题与目标
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
|
||||||
|
ZCLAW 的 SuggestionChips 系统能工作,但建议内容是"有引擎没燃料"的状态:
|
||||||
|
|
||||||
|
- 建议由 LLM 基于最近 6 条对话文本生成,纯通用续问
|
||||||
|
- Hermes 管线(ExperienceStore、PainAggregator、UserProfiler)已实现但未接入
|
||||||
|
- ButlerRouter 的行业检测 + SemanticSkillRouter 的技能匹配未用于建议
|
||||||
|
- SaaS 模式有 2s 人为延迟(`setTimeout(2000)` 避免与记忆提取并发)
|
||||||
|
- 冷启动的行业检测与动态建议完全断开
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
|
||||||
|
接通 UserProfiler + 痛点/经验 + 行业/技能路由,让建议从"通用续问"变"个性化混合建议"(2 条续问 + 1 条管家关怀),不改 UI 形态。
|
||||||
|
|
||||||
|
### 约束
|
||||||
|
|
||||||
|
- 稳定化功能冻结:不新增 SaaS 端点、不新增 SKILL.md、不新增 admin 页面
|
||||||
|
- 允许小幅扩展:可新增 1-2 个只读 Tauri 命令
|
||||||
|
- 复用 @reserved 命令:5 个 Butler 命令已注册未接通,优先复用
|
||||||
|
|
||||||
|
## 2. 方案选择
|
||||||
|
|
||||||
|
评估了 3 种方案:
|
||||||
|
|
||||||
|
| 方案 | 描述 | 改动量 | 风险 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| **A. Prompt 增强(选定)** | 拉取智能上下文注入建议 prompt | 小 | 低 |
|
||||||
|
| B. 双轨建议引擎 | LLM 续问 + 规则引擎管家关怀分离 | 中 | 中 |
|
||||||
|
| C. 中间件注入 | 在中间件链中生成建议上下文 | 大 | 高 |
|
||||||
|
|
||||||
|
选择 A 的理由:改动最小、增量安全(上下文是可选增强)、复用现有 @reserved 命令、可并行化消除人为延迟。
|
||||||
|
|
||||||
|
## 3. 架构设计
|
||||||
|
|
||||||
|
### 3.1 改造后流程
|
||||||
|
|
||||||
|
```
|
||||||
|
[Stream 完成]
|
||||||
|
↓
|
||||||
|
createCompleteHandler()
|
||||||
|
↓ Promise.all (并行)
|
||||||
|
├── extractFromConversation() ← 记忆提取(已有)
|
||||||
|
├── reflection.record() ← 反思记录(已有)
|
||||||
|
└── fetchSuggestionContext() ← 🆕 智能上下文拉取
|
||||||
|
├── 检查 __TAURI_INTERNALS__ 是否存在(SaaS 模式下不存在则跳过全部)
|
||||||
|
├── identity_get_file("userprofile")
|
||||||
|
├── butler_list_pain_points()
|
||||||
|
├── experience_find_relevant() ← 🆕 新 Tauri 命令
|
||||||
|
└── route_intent() ← 技能/流水线匹配
|
||||||
|
↓
|
||||||
|
generateLLMSuggestions(对话文本 + 智能上下文) ← 增强 prompt
|
||||||
|
↓
|
||||||
|
SuggestionChips 渲染(UI 不变)
|
||||||
|
```
|
||||||
|
|
||||||
|
**SaaS 模式处理**: `fetchSuggestionContext()` 首先检查 `window.__TAURI_INTERNALS__` 是否存在。SaaS 模式下浏览器环境无 Tauri 运行时,此检查失败后直接返回空上下文——建议生成回退到纯对话续问,与改造前行为一致。无需新增 SaaS API 端点。
|
||||||
|
|
||||||
|
### 3.2 上下文源详细设计
|
||||||
|
|
||||||
|
#### 源 1: 用户画像
|
||||||
|
|
||||||
|
- **命令**: `identity_get_file(agent_id, "userprofile")` (已有 @connected)
|
||||||
|
- **注意**: 参数用 `"userprofile"`(identity.rs 641 行的规范键名,`"user_profile"` 也兼容)
|
||||||
|
- **返回**: `String` — 用户画像文本(行业、角色、专长、沟通风格)
|
||||||
|
- **前端处理**: 截取前 200 字符,格式化为 `用户是{行业}{角色},{偏好}。最近关注{话题}。`
|
||||||
|
- **降级**: 为空时跳过该段落
|
||||||
|
|
||||||
|
#### 源 2: 痛点列表
|
||||||
|
|
||||||
|
- **命令**: `butler_list_pain_points(agent_id)` (已在 invoke_handler 注册,@reserved 仅表示无前端 UI,前端可直接 `invoke('butler_list_pain_points', { agentId })` 调用)
|
||||||
|
- **返回**: `Vec<PainPoint>` — 含 summary, category, confidence, status, occurrence_count
|
||||||
|
- **前端处理**:
|
||||||
|
- 过滤: `confidence >= 0.5 && status ∉ {Solved, Dismissed}`
|
||||||
|
- 排序: 按 confidence 降序
|
||||||
|
- 取前 3 条,格式化为 `1. [{category}] {summary}(出现{n}次)`
|
||||||
|
- **降级**: 为空时跳过管家关怀指令,全部 3 条生成对话续问
|
||||||
|
|
||||||
|
#### 源 3: 相关经验
|
||||||
|
|
||||||
|
- **命令**: `experience_find_relevant(agent_id, query)` (**新增 1 个只读命令**)
|
||||||
|
- **Rust 实现**: 封装 `ExperienceExtractor::find_relevant_experiences()`
|
||||||
|
- **返回**: `Vec<ExperienceBrief>` — `{ pain_pattern: String, solution_summary: String, reuse_count: u32 }`
|
||||||
|
- **前端处理**: 取前 2 条,格式化为 `上次解决"{pain}"的方法:{solution}(已复用{n}次)`
|
||||||
|
- **超时**: 500ms,超时后跳过
|
||||||
|
|
||||||
|
#### 源 4: 技能/流水线匹配
|
||||||
|
|
||||||
|
- **命令**: `route_intent({ userInput })` (已有 @connected,Tauri 自动注入 `PipelineState` + `KernelState`)
|
||||||
|
- **返回**: `RouteResultResponse::NoMatch { suggestions: Vec<PipelineCandidateInfo> }`
|
||||||
|
- **前端处理**: 取 confidence 最高的 1 条,格式化为 `你可能需要:{display_name} — {description}`
|
||||||
|
- **降级**: 无匹配时跳过
|
||||||
|
|
||||||
|
### 3.3 新增 Tauri 命令
|
||||||
|
|
||||||
|
只需 1 个新的只读命令。遵循 `butler_list_pain_points` 的无状态单例模式(不使用 `tauri::State`):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// desktop/src-tauri/src/intelligence/experience.rs
|
||||||
|
|
||||||
|
static EXPERIENCE_EXTRACTOR: OnceLock<Arc<ExperienceExtractor>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn get_extractor() -> Option<Arc<ExperienceExtractor>> {
|
||||||
|
EXPERIENCE_EXTRACTOR.get().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the global ExperienceExtractor with a VikingAdapter-backed store.
|
||||||
|
/// Called once during app startup (alongside init_pain_storage).
|
||||||
|
pub async fn init_experience_extractor(pool: sqlx::SqlitePool) -> Result<()> {
|
||||||
|
let sqlite_storage = crate::viking_commands::get_storage().await
|
||||||
|
.map_err(|e| anyhow::anyhow!("viking storage: {}", e))?;
|
||||||
|
let viking = Arc::new(zclaw_growth::VikingAdapter::from_sqlite_storage(sqlite_storage));
|
||||||
|
let store = Arc::new(ExperienceStore::new(viking));
|
||||||
|
let extractor = Arc::new(ExperienceExtractor::new(store));
|
||||||
|
EXPERIENCE_EXTRACTOR.set(extractor)
|
||||||
|
.map_err(|_| anyhow::anyhow!("ExperienceExtractor already initialized"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn experience_find_relevant(
|
||||||
|
agent_id: String,
|
||||||
|
query: String,
|
||||||
|
) -> Result<Vec<ExperienceBrief>, String> {
|
||||||
|
let extractor = get_extractor()
|
||||||
|
.ok_or("ExperienceExtractor not initialized".to_string())?;
|
||||||
|
let experiences = extractor.find_relevant_experiences(&agent_id, &query).await;
|
||||||
|
// Map full Experience → brief (in command, not in extractor)
|
||||||
|
Ok(experiences.into_iter().take(3).map(|e| ExperienceBrief {
|
||||||
|
pain_pattern: e.pain_pattern,
|
||||||
|
solution_summary: e.solution_steps.join(";")
|
||||||
|
.chars().take(100).collect(),
|
||||||
|
reuse_count: e.reuse_count,
|
||||||
|
}).collect())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ExperienceBrief` 结构(定义在同一文件):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ExperienceBrief {
|
||||||
|
pub pain_pattern: String,
|
||||||
|
pub solution_summary: String,
|
||||||
|
pub reuse_count: u32,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计决策**:
|
||||||
|
- 使用 `OnceLock<Arc<ExperienceExtractor>>` 单例,与 `PAIN_AGGREGATOR` 模式一致
|
||||||
|
- 通过 `viking_commands::get_storage()` → `VikingAdapter::from_sqlite_storage()` → `ExperienceStore` 获取持久化后端
|
||||||
|
- `Experience → ExperienceBrief` 映射在命令内完成,`ExperienceExtractor` 保持原样不变
|
||||||
|
- 启动时在 `init_pain_storage()` 旁调用 `init_experience_extractor()`
|
||||||
|
|
||||||
|
## 4. 增强 Prompt 模板
|
||||||
|
|
||||||
|
### 4.1 双层 Prompt 结构
|
||||||
|
|
||||||
|
**System prompt(静态,OTA 可缓存)**:保持 `HARDCODED_PROMPTS.suggestions` 作为基础 system prompt,只修改生成规则部分:
|
||||||
|
|
||||||
|
```
|
||||||
|
根据对话上下文和用户画像,生成恰好 3 个个性化建议。
|
||||||
|
|
||||||
|
## 生成规则
|
||||||
|
1. 2 条对话续问(深入当前话题,帮助用户继续探索)
|
||||||
|
2. 1 条管家关怀(基于用户消息中提供的痛点、经验或技能信息)
|
||||||
|
- 如果有未解决痛点 → 回访建议
|
||||||
|
- 如果有相关经验 → 引导复用
|
||||||
|
- 如果有匹配技能 → 推荐使用
|
||||||
|
- 无特殊信号时 → 也生成对话续问
|
||||||
|
3. 每个不超过 30 个中文字符
|
||||||
|
4. 返回 JSON 数组 ["建议1", "建议2", "建议3"]
|
||||||
|
5. 使用与用户相同的语言
|
||||||
|
6. 不要重复已经讨论过的内容
|
||||||
|
```
|
||||||
|
|
||||||
|
**User message(动态,每次请求拼装)**:由 `fetchSuggestionContext()` 生成的上下文段落拼入 user message,与对话历史一起发送:
|
||||||
|
|
||||||
|
```
|
||||||
|
以下是用户的背景信息,请在生成建议时参考:
|
||||||
|
|
||||||
|
{user_profile_section}
|
||||||
|
{pain_points_section}
|
||||||
|
{experiences_section}
|
||||||
|
{skill_match_section}
|
||||||
|
|
||||||
|
最近对话:
|
||||||
|
{conversation_text}
|
||||||
|
```
|
||||||
|
|
||||||
|
**OTA 兼容**:System prompt 仍走 SaaS OTA 缓存(`getSystemPrompt('suggestions')`),动态上下文只在 user message 中注入,不影响缓存机制。
|
||||||
|
|
||||||
|
### 4.2 全部为空时的回退
|
||||||
|
|
||||||
|
当所有上下文段落均为空时,user message 不注入背景信息,直接使用对话文本——行为与改造前完全一致。
|
||||||
|
|
||||||
|
## 5. 降级策略
|
||||||
|
|
||||||
|
| 故障场景 | 降级行为 | 用户感知 |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| 用户画像为空 | 跳过该段落 | 无 |
|
||||||
|
| 痛点列表为空 | 跳过管家关怀指令 | 无——3 条都是对话续问 |
|
||||||
|
| 经验查询超时(500ms) | 跳过该段落 | 无 |
|
||||||
|
| 技能无匹配 | 跳过该段落 | 无 |
|
||||||
|
| 所有上下文全部失败 | 使用原始 prompt(纯对话续问) | 无——与改造前完全一致 |
|
||||||
|
| LLM 建议生成失败 | 触发现有关键词 fallback | 无变化 |
|
||||||
|
|
||||||
|
**核心原则**: 上下文是可选增强,任何失败都静默降级,不破坏现有体验。
|
||||||
|
|
||||||
|
**错误日志**: 所有降级通过 `createLogger('StreamStore')` 在 `warn` 级别记录,与现有记忆提取失败的处理方式一致。不在用户界面显示错误。
|
||||||
|
|
||||||
|
## 6. 延迟优化
|
||||||
|
|
||||||
|
| 对比项 | 改造前 | 改造后 |
|
||||||
|
|--------|--------|--------|
|
||||||
|
| 上下文拉取 | 无 | Promise.all 并行 ~100-300ms |
|
||||||
|
| 人为延迟 | setTimeout(2000) | **消除** |
|
||||||
|
| LLM 调用时机 | +2000ms 后 | +max(记忆, 上下文) 后 |
|
||||||
|
| 建议出现时间 | ~2s + LLM | ~0.3s + LLM |
|
||||||
|
| **净提升** | — | **~1.7s 更快(估算值,需实测验证)** |
|
||||||
|
|
||||||
|
> **注意**: 表中"~100-300ms"为估算值。实际延迟取决于 SQLite 冷读、`PainAggregator` 的 `RwLock` 竞争、以及 `ExperienceExtractor` 的 FTS5 查询性能。建议在实现后用 `performance.now()` 埋点实测。
|
||||||
|
|
||||||
|
## 7. 关键文件清单
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- `desktop/src/lib/suggestion-context.ts` — `fetchSuggestionContext()` 聚合函数 + 类型定义
|
||||||
|
|
||||||
|
### 修改
|
||||||
|
- `desktop/src-tauri/src/intelligence/experience.rs` — 新增 `experience_find_relevant` Tauri 命令
|
||||||
|
- `desktop/src-tauri/src/lib.rs` — 注册新命令到 invoke_handler
|
||||||
|
- `desktop/src/store/chat/streamStore.ts` — 改造 `createCompleteHandler()` 和 `generateLLMSuggestions()`
|
||||||
|
- `desktop/src/lib/llm-service.ts` — 更新 suggestion prompt 模板
|
||||||
|
|
||||||
|
### 复用(已有,不修改)
|
||||||
|
- `desktop/src-tauri/src/intelligence/pain_aggregator.rs` — `butler_list_pain_points` 命令
|
||||||
|
- `desktop/src-tauri/src/intelligence/identity.rs` — `identity_get_file` 命令
|
||||||
|
- `desktop/src-tauri/src/pipeline_commands/intent_router.rs` — `route_intent` 命令
|
||||||
|
|
||||||
|
## 8. 验证方式
|
||||||
|
|
||||||
|
1. **Rust 编译**: `cargo check --workspace --exclude zclaw-saas`
|
||||||
|
2. **Rust 测试**: `cargo test -p zclaw-kernel -- experience`
|
||||||
|
3. **TypeScript 类型**: `cd desktop && pnpm tsc --noEmit`
|
||||||
|
4. **前端测试**: `cd desktop && pnpm vitest run`
|
||||||
|
5. **手动验证**:
|
||||||
|
- 启动 `pnpm start:dev`
|
||||||
|
- 进行 2-3 轮对话,观察建议内容是否个性化
|
||||||
|
- 检查开发者工具 console 无上下文拉取错误
|
||||||
|
- 对比改造前后建议相关性和出现速度
|
||||||
360
docs/wiki-methodology.md
Normal file
360
docs/wiki-methodology.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# 项目 Wiki 知识库编制方法论
|
||||||
|
|
||||||
|
> 基于 ZCLAW 项目实战经验(10 crates + React 前端,~155KB wiki 重构)提炼。
|
||||||
|
> 适用于任何有 AI 辅助开发参与的中大型项目。
|
||||||
|
> **一句话总结**:Wiki 只记录代码无法告诉你的东西。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、设计原则
|
||||||
|
|
||||||
|
### 原则 1:Wiki 记录"代码不能告诉你的"
|
||||||
|
|
||||||
|
| 记录在 Wiki ✅ | 不记录在 Wiki ❌ |
|
||||||
|
|---------------|-----------------|
|
||||||
|
| 为什么这样设计(WHY) | 字段列表、函数签名 |
|
||||||
|
| 跨模块数据流走向 | 单文件内的代码逻辑 |
|
||||||
|
| 历史踩坑和教训 | 可用 `grep` 直接查到的信息 |
|
||||||
|
| 必须始终成立的约束(不变量) | CRUD 操作、getter/setter |
|
||||||
|
| 模块间调用接口(集成契约) | 具体的行号、变量名 |
|
||||||
|
|
||||||
|
**判断标准**:如果 `git log` 或 `grep` 能在 30 秒内回答这个问题,就不需要写在 wiki 里。
|
||||||
|
|
||||||
|
### 原则 2:每个模块页统一 5 节结构
|
||||||
|
|
||||||
|
按**阅读优先级**排列(先给最重要的信息):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 设计决策 (WHY) — 为什么这样设计、历史背景、权衡取舍
|
||||||
|
2. 关键文件 + 数据流 — 3-7 个核心文件 + 跨模块接口
|
||||||
|
3. 代码逻辑 — 数据流走向 + 不变量 + 非显而易见的算法
|
||||||
|
4. 活跃问题 + 陷阱 — 当前未解决 + 历史教训
|
||||||
|
5. 变更记录 — 最近 5 条,超出的归入全局日志
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么是这个顺序**:新的 AI 会话(或开发者)首先需要知道"这个模块为什么存在"和"文件在哪",然后才是"怎么工作的",最后是"有什么问题"。
|
||||||
|
|
||||||
|
### 原则 3:页面大小必须有预算
|
||||||
|
|
||||||
|
| 页面类型 | 行数预算 | 原因 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| 首页/索引 | ≤ 120 行 | 需要快速扫描,AI 一次加载 |
|
||||||
|
| 模块页 | 100-200 行 | AI 一次加载 2-3 个模块不爆 context |
|
||||||
|
| 全局日志 | ≤ 50 条活跃 | 防止无限膨胀,旧条目归档 |
|
||||||
|
|
||||||
|
**超过预算怎么办**:把详细内容归档到 `archive/` 目录,模块页只保留摘要 + 链接。
|
||||||
|
|
||||||
|
### 原则 4:单一真相源
|
||||||
|
|
||||||
|
同一信息只出现在一个页面。其他需要该信息的地方只放引用。
|
||||||
|
|
||||||
|
```
|
||||||
|
错误:安全认证流程同时写在 saas.md、security.md、middleware.md
|
||||||
|
正确:security.md 拥有完整描述,saas.md 只写"详见 [[security]]"
|
||||||
|
```
|
||||||
|
|
||||||
|
**检查方法**:`grep` 关键内容,如果出现在 ≥ 3 个页面,就需要去重。
|
||||||
|
|
||||||
|
### 原则 5:Append-only 内容必须封顶
|
||||||
|
|
||||||
|
日志、问题列表等只增不减的内容,必须设置上限并定期归档。
|
||||||
|
|
||||||
|
```
|
||||||
|
活跃日志 ≤ 50 条 → 旧条目归入 archive/log-{YYYY-MM}.md
|
||||||
|
活跃问题 ≤ 5 条/模块 → 修复后立即移除
|
||||||
|
变更记录 ≤ 5 条/模块 → 旧记录在全局 log.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 原则 6:用症状导航补充模块导航
|
||||||
|
|
||||||
|
模块导航解决"这个模块是什么"的问题。但实际开发中,人们更多是在解决"出了问题该看哪里"。
|
||||||
|
|
||||||
|
**症状导航表**格式:
|
||||||
|
|
||||||
|
| 症状 | 先查 | 再查 | 常见根因 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 流式响应卡住 | routing | chat → middleware | 连接断开 / 超时 |
|
||||||
|
| 数据没持久化 | data-model | 对应模块 | 表结构 / 迁移缺失 |
|
||||||
|
|
||||||
|
放在首页/索引页,让新来的人(或 AI 会话)0 跳就能定位排查方向。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、结构模板
|
||||||
|
|
||||||
|
### 2.1 三级层级
|
||||||
|
|
||||||
|
```
|
||||||
|
项目 Wiki
|
||||||
|
├── Level 1: index.md — 纯导航 + 症状索引(≤ 120 行)
|
||||||
|
├── Level 2: {module}.md — 每个功能模块一个页面(100-200 行)
|
||||||
|
├── Level 3: archive/ — 历史内容归档
|
||||||
|
└── (可选) known-issues.md — 活跃问题全局索引
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 首页模板 (index.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# {项目名} 知识库
|
||||||
|
|
||||||
|
> 一句话定位。使用方式说明。
|
||||||
|
|
||||||
|
## 关键数字
|
||||||
|
| 指标 | 值 | 验证方式 |
|
||||||
|
|------|-----|---------|
|
||||||
|
|
||||||
|
## 系统数据流
|
||||||
|
{ASCII 全景图}
|
||||||
|
|
||||||
|
## 模块导航
|
||||||
|
- [[module-a]] — 一句话说明
|
||||||
|
- [[module-b]] — 一句话说明
|
||||||
|
|
||||||
|
## 症状导航
|
||||||
|
| 症状 | 先查 | 再查 | 常见根因 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 模块页模板 ({module}.md)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: {模块名}
|
||||||
|
updated: {YYYY-MM-DD}
|
||||||
|
status: active | stable | developing
|
||||||
|
tags: [{tags}]
|
||||||
|
---
|
||||||
|
|
||||||
|
# {模块名}
|
||||||
|
|
||||||
|
> 从 [[index]] 导航。关联: [[related-1]] [[related-2]]
|
||||||
|
|
||||||
|
## 1. 设计决策
|
||||||
|
|
||||||
|
{为什么这样设计、历史背景、权衡取舍}
|
||||||
|
{用 Q&A 格式记录关键架构决策}
|
||||||
|
|
||||||
|
## 2. 关键文件 + 数据流
|
||||||
|
|
||||||
|
### 核心文件
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `path/to/file` | 一句话说明 |
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
{ASCII 流程图}
|
||||||
|
|
||||||
|
### 集成契约
|
||||||
|
| 方向 | 模块 | 接口 | 触发时机 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| 调用 → | {module} | `{function/API}` | {when} |
|
||||||
|
| 被调用 ← | {module} | `{function/API}` | {when} |
|
||||||
|
|
||||||
|
## 3. 代码逻辑
|
||||||
|
|
||||||
|
### 关键数据流
|
||||||
|
{跨函数/跨文件的完整路径,附意图说明}
|
||||||
|
|
||||||
|
### 不变量
|
||||||
|
⚡ {不变量 1}: {必须始终成立的约束}
|
||||||
|
⚡ {不变量 2}: {描述}
|
||||||
|
|
||||||
|
### 非显而易见的算法
|
||||||
|
{读代码难以理解的逻辑}
|
||||||
|
|
||||||
|
## 4. 活跃问题 + 陷阱
|
||||||
|
|
||||||
|
### 活跃问题
|
||||||
|
| 问题 | 级别 | 状态 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
{0-5 条,修复后移除}
|
||||||
|
|
||||||
|
### 历史教训
|
||||||
|
- {教训}: {一句话描述}
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
⚠️ {易出错的地方}
|
||||||
|
|
||||||
|
## 5. 变更记录
|
||||||
|
| 日期 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
{最近 5 条}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、关键机制详解
|
||||||
|
|
||||||
|
### 3.1 集成契约
|
||||||
|
|
||||||
|
**问题**:跨模块边界的信息(谁调谁、接口形状)是最难从代码中获取的知识,也是 wiki 最大的结构性缺口。
|
||||||
|
|
||||||
|
**做法**:每个模块页的"关键文件"节下增加一个"集成契约"小表,回答四个问题:
|
||||||
|
|
||||||
|
| 问题 | 对应列 |
|
||||||
|
|------|--------|
|
||||||
|
| 这个模块调用了谁? | 调用 → |
|
||||||
|
| 这个模块被谁调用? | 被调用 ← |
|
||||||
|
| 通过什么接口? | 接口(函数名/API路径) |
|
||||||
|
| 什么时候触发? | 触发时机 |
|
||||||
|
|
||||||
|
**示例**(中间件模块):
|
||||||
|
|
||||||
|
| 方向 | 模块 | 接口 | 触发时机 |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| 被调用 ← | kernel | `create_middleware_chain()` | 内核启动 |
|
||||||
|
| 调用 → | runtime | `run_before_completion()` | 每次聊天请求 |
|
||||||
|
| 提供 → | 所有模块 | `AgentMiddleware` trait | 14 个实现 |
|
||||||
|
|
||||||
|
### 3.2 不变量标记
|
||||||
|
|
||||||
|
**问题**:系统中有一些"必须始终成立的约束",它们不像代码那样显式存在,但一旦被违反就会产生隐蔽的 bug。
|
||||||
|
|
||||||
|
**做法**:用 ⚡ 标记不变量,放在"代码逻辑"节下。
|
||||||
|
|
||||||
|
```
|
||||||
|
⚡ Priority 是升序排列:0-999,数值越小越先执行
|
||||||
|
⚡ memories.db 和 data.db 是独立数据库,跨库查询需确认目标库
|
||||||
|
⚡ 记忆注入在中间件@150,在管家路由@80之后,技能索引@200之前
|
||||||
|
```
|
||||||
|
|
||||||
|
**判断什么是好的不变量**:
|
||||||
|
- 它描述的是一种**关系或顺序**,不是单个组件的行为
|
||||||
|
- 如果有人不知道这个约束,修改代码时很可能无意中违反它
|
||||||
|
- 违反的后果不会立即显现,而是演化几轮后变成隐性 bug
|
||||||
|
|
||||||
|
### 3.3 去重规则
|
||||||
|
|
||||||
|
| 重复类型 | 处理方式 |
|
||||||
|
|---------|---------|
|
||||||
|
| 完整描述出现在 A 和 B | 选择一个为真相源,另一个只引用 |
|
||||||
|
| 相同信息出现在 ≥ 3 页 | 必须去重,指定唯一归属 |
|
||||||
|
| 概述 vs 详情 | 概述页保留一句话 + 链接,详情页拥有完整描述 |
|
||||||
|
|
||||||
|
**去重检查命令**:
|
||||||
|
```bash
|
||||||
|
grep -l '关键内容' wiki/*.md | wc -l
|
||||||
|
# 结果 ≥ 3 → 需要去重
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 症状导航
|
||||||
|
|
||||||
|
**为什么需要**:模块导航是"模块→功能"方向,但排查问题时需要的是"症状→模块"方向。
|
||||||
|
|
||||||
|
**编制方法**:
|
||||||
|
1. 收集团队/AI 会话中反复出现的调试场景(8-12 个)
|
||||||
|
2. 每个场景记录:症状、先查哪个页面、再查哪个、最常见根因
|
||||||
|
3. 放在首页,新会话/新人 0 跳可达
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
| 症状 | 先查 | 再查 | 常见根因 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| API 返回 502 | saas | routing | Token 耗尽 / 服务超时 |
|
||||||
|
| 数据不持久 | data-model | 对应模块 | 表缺失 / 字段不匹配 |
|
||||||
|
| 流式中断 | chat | middleware | 连接断开 / 超时守护 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、维护工作流
|
||||||
|
|
||||||
|
### 4.1 什么时候更新 Wiki
|
||||||
|
|
||||||
|
| 触发事件 | 更新什么 |
|
||||||
|
|---------|---------|
|
||||||
|
| 修复 bug | 对应模块页"活跃问题" + 全局 known-issues 索引 |
|
||||||
|
| 架构变更 | 对应模块页"设计决策" + 集成契约 |
|
||||||
|
| 文件结构变化 | 对应模块页"核心文件"表 |
|
||||||
|
| 跨模块接口变化 | 涉及双方的"集成契约"表 |
|
||||||
|
| 发现新不变量 | 对应模块页"代码逻辑"节的 ⚡ 项 |
|
||||||
|
| 每次更新 | 模块页"变更记录"(保持5条) + 全局 log.md |
|
||||||
|
|
||||||
|
### 4.2 防止 drift 的策略
|
||||||
|
|
||||||
|
| 策略 | 做法 |
|
||||||
|
|------|------|
|
||||||
|
| 页面大小预算 | 超过 200 行强制裁剪,移入 archive/ |
|
||||||
|
| 活跃问题生命周期 | 修复后立即移除,不保留已修复项 |
|
||||||
|
| 变更记录滑动窗口 | 只保留最近 5 条,旧的自然滚入全局日志 |
|
||||||
|
| 数字验证 | 关键数字标注验证命令,定期执行确认 |
|
||||||
|
| "最后验证"日期 | 在 frontmatter 的 `updated` 字段记录,超过 30 天需要复查 |
|
||||||
|
|
||||||
|
### 4.3 重构 Wiki 的执行顺序
|
||||||
|
|
||||||
|
如果需要对已有 wiki 进行重构,按依赖关系分阶段:
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: 归档/封顶 — 压缩日志、归档旧内容(无依赖)
|
||||||
|
Phase 2: 确立真相源 — 最被其他页面引用的模块优先重构
|
||||||
|
Phase 3: 依赖页面 — 引用 Phase 2 模块的页面去重
|
||||||
|
Phase 4: 剩余模块 — 独立页面逐一重构
|
||||||
|
Phase 5: 首页/索引 — 最后改(依赖所有模块页完成)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键约束**:每个模块页独立提交,可安全 `git revert` 回滚单个页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、AI 辅助开发的特殊考量
|
||||||
|
|
||||||
|
### 5.1 Wiki 的主要读者可能是 AI
|
||||||
|
|
||||||
|
在 AI 辅助开发中,wiki 的主要读者是每次新会话的 AI 实例(context 从零开始)。这改变了 wiki 的设计优先级:
|
||||||
|
|
||||||
|
| 传统 wiki | AI 辅助 wiki |
|
||||||
|
|-----------|-------------|
|
||||||
|
| 详细、全面 | 精炼、可快速加载 |
|
||||||
|
| 按主题组织 | 按任务场景导航 |
|
||||||
|
| 历史记录丰富 | 只保留活跃信息 |
|
||||||
|
| 人工索引 | 症状→页面直接映射 |
|
||||||
|
|
||||||
|
### 5.2 Context 预算思维
|
||||||
|
|
||||||
|
AI 的 context window 是有限资源。wiki 的每个字节都在消耗这个预算。
|
||||||
|
|
||||||
|
**优化策略**:
|
||||||
|
- 首页只放导航,不放内容(让 AI 按需读取模块页)
|
||||||
|
- 模块页控制在 100-200 行(一次加载 2-3 个不爆 context)
|
||||||
|
- 代码逻辑只写流向和不变量,不写可从代码读取的细节
|
||||||
|
- 使用 `archive/` 存放低频需要的历史内容
|
||||||
|
|
||||||
|
### 5.3 Wiki 作为新会话的启动燃料
|
||||||
|
|
||||||
|
设计 wiki 时要问:**一个全新的 AI 会话,读完首页后能定位问题吗?读完 2 个模块页后能开始工作吗?**
|
||||||
|
|
||||||
|
如果答案是"不能",说明 wiki 的导航层不够好(首页缺症状导航)或模块页的结构不对(信息不在前两节)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、检查清单
|
||||||
|
|
||||||
|
### 创建 Wiki 时
|
||||||
|
|
||||||
|
- [ ] 首页 ≤ 120 行,包含:项目一句话定位、关键数字、模块导航、症状导航
|
||||||
|
- [ ] 每个模块页统一 5 节结构
|
||||||
|
- [ ] 每个模块页有集成契约表
|
||||||
|
- [ ] 每个模块页有 ⚡ 不变量
|
||||||
|
- [ ] 每个模块页 100-200 行
|
||||||
|
- [ ] 无内容重复出现在 ≥ 3 个页面
|
||||||
|
- [ ] 全局日志封顶 50 条,有归档机制
|
||||||
|
|
||||||
|
### 维护 Wiki 时
|
||||||
|
|
||||||
|
- [ ] 修复 bug 后更新对应模块"活跃问题"
|
||||||
|
- [ ] 架构变更后更新对应模块"设计决策"+ 集成契约
|
||||||
|
- [ ] 每次更新追加全局 log.md 条目
|
||||||
|
- [ ] 每次更新模块页变更记录(保持 5 条)
|
||||||
|
- [ ] 定期检查页面是否超过大小预算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:ZCLAW 重构效果
|
||||||
|
|
||||||
|
| 指标 | 重构前 | 重构后 | 变化 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| 模块页总行数 | ~2,800 | ~1,547 | -45% |
|
||||||
|
| 重复内容 | 安全×3, 进化×3 | 各×1 | 消除 |
|
||||||
|
| 集成契约覆盖 | 0/10 页 | 10/10 页 | 全覆盖 |
|
||||||
|
| 症状导航 | 无 | 8 条路径 | 新增 |
|
||||||
|
| 首页 | 144 行 | 101 行 | +症状导航 |
|
||||||
|
| 最大单页 | 424 行 | 199 行 | 控住 |
|
||||||
335
docs/wiki-restructure/design-spec.md
Normal file
335
docs/wiki-restructure/design-spec.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# Wiki Restructure Design
|
||||||
|
|
||||||
|
> Date: 2026-04-22
|
||||||
|
> Status: Approved
|
||||||
|
> Author: Claude + User brainstorming session
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
Current wiki (16 files, ~155KB) has three structural problems:
|
||||||
|
|
||||||
|
1. **No task-oriented navigation** — Cannot go from symptom to module quickly
|
||||||
|
2. **Duplicate content** — Middleware/security/evolution described in 3+ pages
|
||||||
|
3. **Missing integration contracts** — Cross-module boundaries undocumented
|
||||||
|
4. **Growing append-only sections** — log.md already 31KB, known-issues.md 13KB
|
||||||
|
|
||||||
|
The wiki's primary reader is a Claude AI session that reads it at conversation start to orient itself. Secondary reader is the human developer.
|
||||||
|
|
||||||
|
## 2. Design Principles
|
||||||
|
|
||||||
|
1. **Wiki documents what code cannot tell you** — WHY decisions, navigation shortcuts, traps, invariants
|
||||||
|
2. **Code logic sections focus on flows + invariants + algorithms** — NOT field lists or function signatures
|
||||||
|
3. **Page size budget** — index ≤ 120 lines, module pages 100-200 lines (3-6KB)
|
||||||
|
4. **Single source of truth per topic** — No content duplicated across pages; use references
|
||||||
|
5. **Append-only sections are capped** — log.md capped at 50 entries, old entries archived
|
||||||
|
|
||||||
|
## 3. Structure
|
||||||
|
|
||||||
|
### 3.1 Level 1: `index.md` — Navigation + Symptom Index
|
||||||
|
|
||||||
|
```
|
||||||
|
wiki/index.md
|
||||||
|
├── Project one-liner
|
||||||
|
├── Key numbers table (cross-validated with TRUTH.md)
|
||||||
|
├── System data flow diagram (existing ASCII art)
|
||||||
|
├── Module navigation tree (one-line description per module)
|
||||||
|
├── Symptom navigation table (NEW)
|
||||||
|
└── Module dependency map (who calls who)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Symptom Navigation Table** (NEW):
|
||||||
|
|
||||||
|
| Symptom | First check | Then check | Common root cause |
|
||||||
|
|---------|-------------|------------|-------------------|
|
||||||
|
| Stream stuck | routing | chat → middleware | Connection lost / SaaS relay timeout |
|
||||||
|
| Memory not injected | memory | middleware | FTS5 index empty / middleware skipped |
|
||||||
|
| Hand trigger failed | hands-skills | middleware | Tool call blocked by Guardrail |
|
||||||
|
| SaaS relay 502 | saas | routing | Token Pool exhausted / Key expired |
|
||||||
|
| Model switch not working | routing | chat | SaaS whitelist vs local config mismatch |
|
||||||
|
| Agent creation failed | chat | saas | Permission or persistence issue |
|
||||||
|
| Pipeline execution stuck | pipeline | middleware | DAG cycle / missing dependency |
|
||||||
|
| Admin page 403 | saas | security | JWT expired / admin_guard blocked |
|
||||||
|
|
||||||
|
**Target**: ≤ 120 lines. A new AI session reads index and immediately knows which modules to open.
|
||||||
|
|
||||||
|
### 3.2 Level 2: Module Pages (~15)
|
||||||
|
|
||||||
|
Each module page has 5 sections in reading priority order:
|
||||||
|
|
||||||
|
#### Section 1: Design Decisions (WHY)
|
||||||
|
|
||||||
|
- Why this module was designed this way
|
||||||
|
- Historical context and background
|
||||||
|
- Trade-offs made and alternatives rejected
|
||||||
|
- Key architectural decisions
|
||||||
|
|
||||||
|
Format: prose paragraphs + Q&A pairs for important decisions.
|
||||||
|
|
||||||
|
#### Section 2: Key Files + Data Flow (WHERE)
|
||||||
|
|
||||||
|
- Core files table (3-7 files, one-line responsibility each)
|
||||||
|
- Module-internal data flow diagram (ASCII or mermaid)
|
||||||
|
- **Integration contracts** (NEW):
|
||||||
|
- What this module calls upstream
|
||||||
|
- What this module exposes downstream
|
||||||
|
- Interface shapes at boundaries
|
||||||
|
|
||||||
|
#### Section 3: Code Logic (LOGIC)
|
||||||
|
|
||||||
|
Focus on three types of information that code alone cannot efficiently convey:
|
||||||
|
|
||||||
|
- **Key data flows**: Cross-function/cross-file complete paths
|
||||||
|
- **Invariants**: Constraints that must always hold (marked with ⚡)
|
||||||
|
- **Non-obvious algorithms**: Logic that is hard to understand from reading code alone
|
||||||
|
|
||||||
|
Explicitly EXCLUDED:
|
||||||
|
- Field lists (read from code)
|
||||||
|
- Function signatures (read from code)
|
||||||
|
- CRUD operations (obvious from code)
|
||||||
|
- Anything that can be answered by `grep`
|
||||||
|
|
||||||
|
#### Section 4: Active Issues + Gotchas (GOTCHAS)
|
||||||
|
|
||||||
|
- Active issues (0-5 items, removed when fixed)
|
||||||
|
- Historical pitfall records (≤ 10 items, distilled to one lesson each)
|
||||||
|
- ⚠️ Warnings (error-prone areas)
|
||||||
|
|
||||||
|
#### Section 5: Change Log (CHANGES)
|
||||||
|
|
||||||
|
- Last 5 changes (format: date + one-liner)
|
||||||
|
- Older changes → global `log.md`
|
||||||
|
|
||||||
|
### 3.3 Module Page Template
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: {Module Name}
|
||||||
|
updated: {YYYY-MM-DD}
|
||||||
|
status: active | stable | developing
|
||||||
|
tags: [{module-specific tags}]
|
||||||
|
---
|
||||||
|
|
||||||
|
# {Module Name}
|
||||||
|
|
||||||
|
> From [[index]]. Related: [[related-module-1]] [[related-module-2]]
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
{Why this module exists, key design choices, tradeoffs}
|
||||||
|
|
||||||
|
## Key Files + Data Flow
|
||||||
|
|
||||||
|
### Core Files
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|------|---------------|
|
||||||
|
| `path/to/file` | One-line description |
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
{ASCII flow diagram}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Contracts
|
||||||
|
|
||||||
|
> Format: Direction | Module | Interface (Rust trait / Tauri invoke / TS function) | Trigger
|
||||||
|
|
||||||
|
| Direction | Module | Interface | Trigger |
|
||||||
|
|-----------|--------|-----------|---------|
|
||||||
|
| Calls → | {module} | `{rust_fn / tauri_invoke / ts_fn}` | {when/why} |
|
||||||
|
| Called by ← | {module} | `{rust_fn / tauri_invoke / ts_fn}` | {when/why} |
|
||||||
|
|
||||||
|
<!-- Example (middleware.md):
|
||||||
|
| Calls → | runtime | `MiddlewareChain::run_before_completion()` | Every chat request before LLM call |
|
||||||
|
| Called by ← | kernel | `kernel/mod.rs:create_middleware_chain()` | Kernel boot, once per session |
|
||||||
|
| Called by ← | saas | HTTP relay handler | SaaS relay routes (10 HTTP middleware) |
|
||||||
|
| Provides → | all modules | `AgentMiddleware` trait | 14 implementations registered |
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Code Logic
|
||||||
|
|
||||||
|
### Key Data Flows
|
||||||
|
|
||||||
|
{Cross-function paths with intent}
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
⚡ {Invariant 1}: {description of what must always be true}
|
||||||
|
|
||||||
|
⚡ {Invariant 2}: {description}
|
||||||
|
|
||||||
|
### Non-obvious Algorithms
|
||||||
|
|
||||||
|
{Algorithms that are hard to understand from reading code}
|
||||||
|
|
||||||
|
## Active Issues + Gotchas
|
||||||
|
|
||||||
|
### Active Issues
|
||||||
|
|
||||||
|
| Issue | Severity | Status | Notes |
|
||||||
|
|-------|----------|--------|-------|
|
||||||
|
| {description} | P{0-3} | Open | {context} |
|
||||||
|
|
||||||
|
### Historical Pitfalls
|
||||||
|
|
||||||
|
- {Lesson learned}: {one-line description of what went wrong and the fix}
|
||||||
|
|
||||||
|
### Warnings
|
||||||
|
|
||||||
|
⚠️ {Warning}: {what to watch out for}
|
||||||
|
|
||||||
|
## Change Log
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| {YYYY-MM-DD} | {one-line description} |
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Migration Plan
|
||||||
|
|
||||||
|
### 4.0 Execution Order
|
||||||
|
|
||||||
|
Migration must follow this sequence due to cross-page dependencies:
|
||||||
|
|
||||||
|
1. **Phase A — Archive/cap** (no dependencies): Cap `log.md` at 50 entries, archive old to `wiki/archive/`. Convert `known-issues.md` to pointer. Archive `hermes-analysis.md`.
|
||||||
|
2. **Phase B — Single source of truth**: Restructure `middleware.md` first (other pages reference it).
|
||||||
|
3. **Phase C — Dependent pages**: `saas.md`, `security.md`, `memory.md` (remove middleware/evolution duplicates, add contracts).
|
||||||
|
4. **Phase D — Remaining modules**: `routing.md`, `chat.md`, `butler.md`, `hands-skills.md`, `pipeline.md`, `data-model.md`.
|
||||||
|
5. **Phase E — Index last**: `index.md` restructure (depends on all modules being complete).
|
||||||
|
6. **Phase F — feature-map.md**: Distribute chain traces to module "Code Logic" sections, convert to index page.
|
||||||
|
|
||||||
|
**Rollback strategy**: Migrate one module per commit. Any partial state is internally consistent per-page. `git revert` on a single commit restores that module's old version.
|
||||||
|
|
||||||
|
### 4.1 Per-Page Source-to-Target Mapping
|
||||||
|
|
||||||
|
#### `index.md` (8KB → target ≤ 120 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Key numbers table | Keep | Stay (cross-validated with TRUTH.md) |
|
||||||
|
| System data flow diagram | Keep | Stay |
|
||||||
|
| Module navigation tree | Keep | Stay |
|
||||||
|
| Architecture Q&A "Why 14 middleware" | Move | → `middleware.md` Design Decisions |
|
||||||
|
| Architecture Q&A "Why 管家 default" | Move | → `butler.md` Design Decisions |
|
||||||
|
| Architecture Q&A "Why 3 ChatStream" | Move | → `chat.md` Design Decisions |
|
||||||
|
| Architecture Q&A "Why SaaS relay" | Move | → `routing.md` Design Decisions |
|
||||||
|
| Architecture Q&A "Evolution engine" | Move | → `memory.md` Design Decisions |
|
||||||
|
| Symptom navigation table | NEW | Add after navigation tree |
|
||||||
|
|
||||||
|
#### `middleware.md` (7KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Design thought | Keep as "Design Decisions" | Expand with WHY from index Q&A |
|
||||||
|
| 14-layer table | Keep | Core Files + Data Flow |
|
||||||
|
| Execution flow diagram | Keep | Code Logic |
|
||||||
|
| SaaS HTTP middleware (10 layers) | Keep | Integration Contracts |
|
||||||
|
| "11/14 no tests" warning | Keep | Active Issues |
|
||||||
|
| API interface (trait) | Trim to key data flow | Code Logic (flows only) |
|
||||||
|
|
||||||
|
#### `routing.md` (13KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| 5-branch decision tree | Keep | Code Logic → Key Data Flows |
|
||||||
|
| Store layer listing (25 stores) | Remove | Split: chat stores → `chat.md`, saas stores → `saas.md`, connection stores stay |
|
||||||
|
| lib/ file listing (75 files) | Remove | → `development.md` reference appendix |
|
||||||
|
| Model routing full chain | Keep (simplified) | Code Logic → Key Data Flows |
|
||||||
|
| Tauri commands table | Keep | Integration Contracts |
|
||||||
|
|
||||||
|
#### `chat.md` (6KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| 3 ChatStream implementations | Keep as Design Decision | Add WHY from index Q&A |
|
||||||
|
| Store 拆分 (5 Store) | Move | → Key Files table |
|
||||||
|
| Send message flow | Keep | Code Logic → Key Data Flows |
|
||||||
|
| Add invariants | NEW | e.g., ⚡ sessionKey must be consistent within a conversation |
|
||||||
|
| Add integration contracts | NEW | Calls → routing (getClient), middleware (chain), saas (relay) |
|
||||||
|
|
||||||
|
#### `memory.md` (19KB → target 200 lines, largest compression needed)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Memory pipeline design | Keep as Design Decisions | + Absorb WHY from index Q&A |
|
||||||
|
| FTS5/TF-IDF/embedding details | Keep invariants and flows | Code Logic |
|
||||||
|
| Hermes insights (from hermes-analysis.md) | Distill 3-5 key lessons | Design Decisions (one paragraph) + Gotchas |
|
||||||
|
| Detailed extraction logic | Trim to flows + invariants | Archive detailed prose to `wiki/archive/` |
|
||||||
|
| Cross-session injection fix | Keep as historical pitfall | Gotchas |
|
||||||
|
| Profile store connection fix | Keep as historical pitfall | Gotchas |
|
||||||
|
|
||||||
|
#### `saas.md` (10KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Auth flow (JWT/Cookie/TOTP) | Remove details | → `security.md` owns design, saas.md keeps reference |
|
||||||
|
| Billing/subscription | Keep | Code Logic → Key Data Flows |
|
||||||
|
| Admin V2 | Keep summary | Key Files |
|
||||||
|
| Token Pool RPM/TPM | Keep | Code Logic → Non-obvious Algorithms |
|
||||||
|
| Add integration contracts | NEW | Calls → relay, Called by ← desktop client |
|
||||||
|
|
||||||
|
#### `security.md` (6KB → target 150-200 lines)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| Auth flow details | OWN this content | Absorb from saas.md, become single source |
|
||||||
|
| JWT/Cookie/TOTP details | Keep | Code Logic |
|
||||||
|
| Rate limiting | Keep | Code Logic |
|
||||||
|
| Add integration contracts | NEW | Provides auth middleware to SaaS, crypto utils to client |
|
||||||
|
|
||||||
|
#### Other modules (`butler`, `hands-skills`, `pipeline`, `data-model`)
|
||||||
|
|
||||||
|
All follow same pattern: keep existing design/code sections, add integration contracts, add invariants, trim to size budget.
|
||||||
|
|
||||||
|
#### `feature-map.md` (15KB → Convert to index)
|
||||||
|
|
||||||
|
| Current Content | Action | Destination |
|
||||||
|
|----------------|--------|-------------|
|
||||||
|
| F-01~F-05 chat chains | Distribute | → `chat.md` Code Logic as chain trace reference |
|
||||||
|
| F-06~F-10 memory chains | Distribute | → `memory.md` Code Logic |
|
||||||
|
| F-11~F-15 hand chains | Distribute | → `hands-skills.md` Code Logic |
|
||||||
|
| Remaining chains | Distribute | → Corresponding module pages |
|
||||||
|
| File itself | Keep as index | Module → feature chain mapping only |
|
||||||
|
|
||||||
|
### 4.2 Pages to Merge/Archive
|
||||||
|
|
||||||
|
| Page | Action | Destination |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `known-issues.md` | Convert to pointer | Active issues → per-module, global file = links only |
|
||||||
|
| `log.md` | Cap at 50 entries | Archive old entries to `wiki/archive/log-{YYYY-MM}.md` |
|
||||||
|
| `hermes-analysis.md` | Archive | Key insights → `memory.md` Gotchas, file → `wiki/archive/` |
|
||||||
|
| `development.md` | Keep as-is | Global dev standards, not per-module |
|
||||||
|
|
||||||
|
### 4.3 Duplicate Content Resolution
|
||||||
|
|
||||||
|
| Content | Current Location | New Owner | Others |
|
||||||
|
|---------|-----------------|-----------|--------|
|
||||||
|
| Middleware descriptions | middleware + saas + security | `middleware.md` | Reference only |
|
||||||
|
| Security mechanisms | security + saas | `security.md` | saas.md references |
|
||||||
|
| Evolution engine | memory + middleware + index | `memory.md` | Others reference |
|
||||||
|
| Store listing (25) | routing.md | Split: chat→chat, saas→saas, etc. | routing.md keeps connection stores |
|
||||||
|
| lib/ file listing (75) | routing.md | `development.md` or dedicated reference | routing.md removes |
|
||||||
|
|
||||||
|
## 5. Validation Criteria
|
||||||
|
|
||||||
|
- [ ] New AI session can locate any module's core files from index in ≤ 2 hops
|
||||||
|
- [ ] Each module page has integration contracts section
|
||||||
|
- [ ] No content duplicated across ≥ 3 pages
|
||||||
|
- [ ] index.md ≤ 120 lines
|
||||||
|
- [ ] Each module page 100-200 lines
|
||||||
|
- [ ] log.md ≤ 50 active entries
|
||||||
|
- [ ] Symptom navigation table covers top 8 common debugging scenarios
|
||||||
|
|
||||||
|
## 6. Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Module pages exceed size budget | Medium | AI context waste | Trim during migration, move details to archive |
|
||||||
|
| Invariants drift from code | Medium | Misleading docs | Add "last verified" date, check during code changes |
|
||||||
|
| Integration contracts incomplete | High | Gap remains | Start with existing cross-references, fill during next debug session |
|
||||||
|
| Migration breaks existing workflow | Low | Confusion | Migrate one module at a time, verify after each |
|
||||||
|
|
||||||
|
## 7. Open Questions
|
||||||
|
|
||||||
|
- Should we add a `wiki/templates/module-template.md` for consistency?
|
||||||
492
docs/wiki-restructure/implementation-plan.md
Normal file
492
docs/wiki-restructure/implementation-plan.md
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
# Wiki Restructure Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Restructure ZCLAW wiki from inconsistent module pages to a unified 5-section template with symptom navigation, integration contracts, and size budgets.
|
||||||
|
|
||||||
|
**Architecture:** 6-phase migration following dependency order: archive/cap → middleware (single source) → dependents → remaining modules → index → feature-map. One module per commit for safe rollback.
|
||||||
|
|
||||||
|
**Spec:** `docs/wiki-restructure/design-spec.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Phase A — Archive & Cap (no dependencies)
|
||||||
|
|
||||||
|
### Task 1: Archive log.md old entries
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `wiki/archive/log-2026-04-pre.md`
|
||||||
|
- Modify: `wiki/log.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Count entries and identify cutoff**
|
||||||
|
|
||||||
|
Run: `grep -c '^\[' wiki/log.md`
|
||||||
|
Expected: ~100+ entries
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create archive file with entries beyond the most recent 50**
|
||||||
|
|
||||||
|
Open `wiki/log.md`, identify the first 50 entries (newest first), move everything after line ~250 (the 50th entry boundary) to `wiki/archive/log-2026-04-pre.md`. Keep the frontmatter and header in both files.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify log.md has ≤ 50 entries**
|
||||||
|
|
||||||
|
Run: `grep -c '^\[' wiki/log.md`
|
||||||
|
Expected: ≤ 50
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/log.md wiki/archive/log-2026-04-pre.md
|
||||||
|
git commit -m "docs(wiki): 归档 log.md 旧条目 — 保留最近50条"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 2: Archive hermes-analysis.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Move: `wiki/hermes-analysis.md` → `wiki/archive/hermes-analysis.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Move file to archive**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv wiki/hermes-analysis.md wiki/archive/hermes-analysis.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/hermes-analysis.md wiki/archive/hermes-analysis.md
|
||||||
|
git commit -m "docs(wiki): 归档 hermes-analysis.md — 洞察已在 memory.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 3: Convert known-issues.md to pointer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `wiki/archive/known-issues-full-2026-04-22.md`
|
||||||
|
- Modify: `wiki/known-issues.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Archive full content**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp wiki/known-issues.md wiki/archive/known-issues-full-2026-04-22.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extract active issues per module for later use**
|
||||||
|
|
||||||
|
Read `wiki/known-issues.md`, note all currently OPEN issues and which module they belong to. Use these when writing Task 5-13 module pages.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite known-issues.md as pointer index**
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: 已知问题索引
|
||||||
|
updated: 2026-04-22
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
|
# 已知问题索引
|
||||||
|
|
||||||
|
> 活跃问题已迁移至各模块页面的"活跃问题+陷阱"章节。本文件仅作索引。
|
||||||
|
|
||||||
|
## 活跃问题
|
||||||
|
|
||||||
|
| 模块 | 问题数 | 详见 |
|
||||||
|
|------|--------|------|
|
||||||
|
| chat | 1 | [[chat#Active Issues]] |
|
||||||
|
| memory | 1 | [[memory#Active Issues]] |
|
||||||
|
| hands-skills | 2 | [[hands-skills#Active Issues]] |
|
||||||
|
| middleware | 2 | [[middleware#Active Issues]] |
|
||||||
|
|
||||||
|
## 已归档
|
||||||
|
|
||||||
|
- 全量问题记录: `wiki/archive/known-issues-full-2026-04-22.md`
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/known-issues.md wiki/archive/known-issues-full-2026-04-22.md
|
||||||
|
git commit -m "docs(wiki): known-issues.md 转为索引 — 活跃问题迁入各模块"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Phase B — middleware.md (single source of truth)
|
||||||
|
|
||||||
|
### Task 4: Restructure middleware.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/middleware.md` (7KB → target 150-200 lines)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write new middleware.md**
|
||||||
|
|
||||||
|
Rewrite with 5-section template. Content mapping:
|
||||||
|
|
||||||
|
| New Section | Source |
|
||||||
|
|-------------|--------|
|
||||||
|
| Design Decisions | Current "设计思想" + index Q&A "为什么14层中间件" |
|
||||||
|
| Key Files + Data Flow | 14-layer table → Core Files, execution flow → Data Flow |
|
||||||
|
| Integration Contracts | NEW (see below) |
|
||||||
|
| Code Logic | Priority ordering, decision types |
|
||||||
|
| Active Issues + Gotchas | "11/14 no tests" warning, TrajectoryRecorder fix |
|
||||||
|
| Change Log | Last 5 from log.md |
|
||||||
|
|
||||||
|
Integration contracts:
|
||||||
|
- Called by ← kernel: `kernel/mod.rs:create_middleware_chain()` (kernel boot, once per session)
|
||||||
|
- Calls → runtime: `MiddlewareChain::run_before_completion()` (every chat request before LLM call)
|
||||||
|
- Called by ← saas: HTTP relay handler (10 HTTP middleware layers)
|
||||||
|
- Provides → all: `AgentMiddleware` trait (14 implementations registered)
|
||||||
|
|
||||||
|
Invariants:
|
||||||
|
- ⚡ Priority is ascending: 0-999, lower = earlier execution
|
||||||
|
- ⚡ Registration order ≠ execution order; chain sorts by priority at runtime
|
||||||
|
- ⚡ Stop/Block/AbortLoop halts the chain immediately (no further middleware runs)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify line count**
|
||||||
|
|
||||||
|
Run: `wc -l wiki/middleware.md`
|
||||||
|
Expected: 150-200
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify all 5 sections**
|
||||||
|
|
||||||
|
Run: `grep '^## ' wiki/middleware.md`
|
||||||
|
Expected: ≥ 5 sections
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/middleware.md
|
||||||
|
git commit -m "docs(wiki): 重构 middleware.md — 5节模板+集成契约+不变量"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 3: Phase C — Dependent pages
|
||||||
|
|
||||||
|
### Task 5: Restructure saas.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/saas.md` (231 lines → target 150-200)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove security duplicates**
|
||||||
|
|
||||||
|
Delete: 认证流 (L46-61), 密码安全 (L78-89), Token刷新 (L91-98). Replace with: "认证安全详见 [[security]]"
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write 5-section structure**
|
||||||
|
|
||||||
|
- Design Decisions: WHY SaaS relay architecture, WHY Token Pool
|
||||||
|
- Key Files: 16 SaaS dirs, 3-7 core files
|
||||||
|
- Integration Contracts: Called by ← desktop (Tauri invoke), Calls → relay, Token Pool
|
||||||
|
- Code Logic: Token Pool RPM/TPM algorithm, Workers (7), billing flow
|
||||||
|
- Active Issues + Gotchas: Active items from known-issues + Embedding deferred
|
||||||
|
- Change Log: Last 5
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify line count and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/saas.md
|
||||||
|
git commit -m "docs(wiki): 重构 saas.md — 移除安全重复+5节模板+契约"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 6: Restructure security.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/security.md` (158 lines → target 150-200)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Absorb auth content from saas.md**
|
||||||
|
|
||||||
|
Import the authentication flow, JWT pwv, password security content removed from saas.md.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write 5-section structure**
|
||||||
|
|
||||||
|
- Design Decisions: WHY each security mechanism
|
||||||
|
- Key Files: security files, auth flow diagram
|
||||||
|
- Integration Contracts: Provides → saas (auth middleware), Provides → desktop (crypto utils)
|
||||||
|
- Code Logic: JWT pwv mechanism, Argon2id, AES-256-GCM, rate limiting
|
||||||
|
- Active Issues + Gotchas: Security audit findings
|
||||||
|
- Change Log: Last 5
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/security.md
|
||||||
|
git commit -m "docs(wiki): 重构 security.md — 吸收saas安全内容+5节模板"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 7: Restructure memory.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `wiki/archive/memory-extraction-details.md`
|
||||||
|
- Modify: `wiki/memory.md` (363 lines → target 200)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Archive detailed extraction prose**
|
||||||
|
|
||||||
|
Move detailed extraction logic to `wiki/archive/memory-extraction-details.md`. Keep only flows + invariants in memory.md.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write 5-section structure**
|
||||||
|
|
||||||
|
- Design Decisions: WHY memory pipeline + index Q&A "进化引擎做什么" + Hermes insights (3-5 lessons in one paragraph)
|
||||||
|
- Key Files: 闭环数据流 diagram, 3-7 core files
|
||||||
|
- Integration Contracts: Called by ← middleware (Memory@150), Calls → FTS5/TF-IDF, Provides → loop_runner
|
||||||
|
- Code Logic: Pipeline flow, cross-session injection, FTS5+TF-IDF
|
||||||
|
- Active Issues + Gotchas: Cross-session fix history, profile store fix, Embedding deferred
|
||||||
|
- Change Log: Last 5
|
||||||
|
|
||||||
|
Invariants:
|
||||||
|
- ⚡ memories.db and data.db are separate SQLite databases; cross-DB queries need correct connection
|
||||||
|
- ⚡ Memory injection at middleware@150, AFTER ButlerRouter@80, BEFORE SkillIndex@200
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify ≤ 200 lines and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/memory.md wiki/archive/memory-extraction-details.md
|
||||||
|
git commit -m "docs(wiki): 重构 memory.md — 压缩至200行+不变量+Hermes提炼"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 4: Phase D — Remaining modules
|
||||||
|
|
||||||
|
### Task 8: Restructure routing.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/routing.md` (13KB → target 150-200)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove Store/lib listings**
|
||||||
|
|
||||||
|
Move chat stores → chat.md, saas stores → saas.md. Move lib/ listing → development.md.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write 5-section structure**
|
||||||
|
|
||||||
|
- Design Decisions: WHY 5-branch + WHY SaaS relay + index Q&A
|
||||||
|
- Key Files: 5-branch decision tree, degradation flow
|
||||||
|
- Integration Contracts: Calls → saas, Calls → kernel, Called by ← stores (getClient)
|
||||||
|
- Code Logic: Decision tree flow, model routing, SaaS degradation
|
||||||
|
- Active Issues + Gotchas: Current known issues
|
||||||
|
- Change Log: Last 5
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/routing.md
|
||||||
|
git commit -m "docs(wiki): 重构 routing.md — 移除Store/lib列表+5节模板"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 9: Restructure chat.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/chat.md` (~180 lines → target 150-200)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write 5-section structure**
|
||||||
|
|
||||||
|
- Design Decisions: WHY 3 ChatStream + index Q&A
|
||||||
|
- Key Files: 5 Store split, key files, send flow
|
||||||
|
- Integration Contracts: Calls → routing (getClient), Called by ← UI, Emits → streamStore
|
||||||
|
- Code Logic: Send message flow, stream events, 5-min timeout
|
||||||
|
- Active Issues: B-CHAT-07 (P2)
|
||||||
|
- Change Log: Last 5
|
||||||
|
|
||||||
|
Invariants:
|
||||||
|
- ⚡ sessionKey consistent within conversation
|
||||||
|
- ⚡ cancelStream sets atomic flag, no race with onDelta
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/chat.md
|
||||||
|
git commit -m "docs(wiki): 重构 chat.md — 3种ChatStream WHY+契约+不变量"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 10: Restructure butler.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/butler.md` (215 lines → target 150-200)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove duplicates**
|
||||||
|
|
||||||
|
MemorySection frontend path → reference [[memory]]. SemanticSkillRouter → reference [[hands-skills]].
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write 5-section structure**
|
||||||
|
|
||||||
|
- Design Decisions: WHY 管家默认 + WHY 双模式
|
||||||
|
- Key Files: ButlerRouter flow, cold start hook
|
||||||
|
- Integration Contracts: Middleware@80, Calls → skill router, Calls → memory
|
||||||
|
- Code Logic: Keyword matching, XML fencing, cross-session continuity
|
||||||
|
- Active Issues + Gotchas: Current issues
|
||||||
|
- Change Log: Last 5
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/butler.md
|
||||||
|
git commit -m "docs(wiki): 重构 butler.md — 移除重复+5节模板+契约"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 11: Restructure hands-skills.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/hands-skills.md` (281 lines → target 150-200)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write 5-section structure**
|
||||||
|
|
||||||
|
- Design Decisions: WHY 7 hands + WHY 75 skills + semantic routing
|
||||||
|
- Key Files: Hand trigger flow, skill chain
|
||||||
|
- Integration Contracts: Called by ← loop_runner, Calls → browser/Twitter, Provides → SkillIndex middleware
|
||||||
|
- Code Logic: Hand trigger+approval, TF-IDF routing, MCP bridge
|
||||||
|
- Active Issues: Hands E2E, Clip needs FFmpeg
|
||||||
|
- Change Log: Last 5
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/hands-skills.md
|
||||||
|
git commit -m "docs(wiki): 重构 hands-skills.md — 5节模板+契约"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 12: Restructure pipeline.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/pipeline.md` (157 lines → target 150-200)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add contracts and reorganize**
|
||||||
|
|
||||||
|
Already near target. Add integration contracts, invariants, reorganize to 5-section.
|
||||||
|
|
||||||
|
- Design Decisions: WHY DAG + WHY YAML
|
||||||
|
- Key Files: Architecture, templates
|
||||||
|
- Integration Contracts: Called by ← UI, Calls → runtime (DAG executor)
|
||||||
|
- Code Logic: DAG execution, template loading
|
||||||
|
- Active Issues: E2E pass rate
|
||||||
|
- Change Log: Last 5
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/pipeline.md
|
||||||
|
git commit -m "docs(wiki): 重构 pipeline.md — 5节模板+契约"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task 13: Restructure data-model.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/data-model.md` (181 lines → target 150-200)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add contracts and reorganize**
|
||||||
|
|
||||||
|
Already near target. Add integration contracts, reorganize to 5-section.
|
||||||
|
|
||||||
|
- Design Decisions: WHY dual database (PG+SQLite)
|
||||||
|
- Key Files: DB schema overview
|
||||||
|
- Integration Contracts: Called by ← saas (PG), Called by ← memory (SQLite/FTS5)
|
||||||
|
- Code Logic: Dual-DB architecture, FTS5 structure
|
||||||
|
- Active Issues: pgvector deferred, CJK fallback
|
||||||
|
- Change Log: Last 5
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/data-model.md
|
||||||
|
git commit -m "docs(wiki): 重构 data-model.md — 5节模板+契约"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 5: Phase E — Index restructure
|
||||||
|
|
||||||
|
### Task 14: Restructure index.md
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/index.md` (144 lines → target ≤ 120)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove architecture Q&A**
|
||||||
|
|
||||||
|
Delete "核心架构决策" section (5 Q&A pairs). Now in respective module pages.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add symptom navigation table**
|
||||||
|
|
||||||
|
8-row table from spec, after module navigation tree.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add module dependency map**
|
||||||
|
|
||||||
|
Simple ASCII: UI → routing → chat → middleware → memory → saas → security
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify ≤ 120 lines**
|
||||||
|
|
||||||
|
Run: `wc -l wiki/index.md`
|
||||||
|
Expected: ≤ 120
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/index.md
|
||||||
|
git commit -m "docs(wiki): 重构 index.md — 症状导航+依赖图+≤120行"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 6: Phase F — feature-map.md conversion
|
||||||
|
|
||||||
|
### Task 15: Distribute feature-map chain traces
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `wiki/feature-map.md` (424 lines → ~80, index only)
|
||||||
|
- Modify: Module pages (add chain trace references)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add one-line chain traces to module Code Logic sections**
|
||||||
|
|
||||||
|
| Module | Features |
|
||||||
|
|--------|----------|
|
||||||
|
| chat | F-01~F-05, F-06~F-09 |
|
||||||
|
| hands-skills | F-10~F-13 |
|
||||||
|
| memory | F-14~F-16 |
|
||||||
|
| saas | F-17~F-22 |
|
||||||
|
| butler | F-23~F-25 |
|
||||||
|
| pipeline | F-26~F-28 |
|
||||||
|
| routing | F-29~F-31 |
|
||||||
|
| security | F-32~F-33 |
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite feature-map.md as lightweight index**
|
||||||
|
|
||||||
|
Module → feature mapping table only. No full chain details.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add wiki/feature-map.md wiki/chat.md wiki/memory.md wiki/hands-skills.md wiki/saas.md wiki/butler.md wiki/pipeline.md wiki/routing.md wiki/security.md
|
||||||
|
git commit -m "docs(wiki): feature-map 分发链路到各模块 — 转为索引页"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final: Validation
|
||||||
|
|
||||||
|
### Task 16: Validate all criteria
|
||||||
|
|
||||||
|
- [ ] **Step 1: Line counts**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wc -l wiki/index.md wiki/routing.md wiki/chat.md wiki/saas.md wiki/security.md wiki/memory.md wiki/butler.md wiki/middleware.md wiki/hands-skills.md wiki/pipeline.md wiki/data-model.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: index ≤ 120, all others 100-200
|
||||||
|
|
||||||
|
- [ ] **Step 2: 5-section coverage**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in wiki/routing.md wiki/chat.md wiki/saas.md wiki/security.md wiki/memory.md wiki/butler.md wiki/middleware.md wiki/hands-skills.md wiki/pipeline.md wiki/data-model.md; do echo "=== $f ===" && grep '^## ' $f; done
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: each has Design Decisions, Key Files, Code Logic, Active Issues, Change Log
|
||||||
|
|
||||||
|
- [ ] **Step 3: Integration contracts**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -l 'Integration Contracts\|集成契约' wiki/*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all module pages
|
||||||
|
|
||||||
|
- [ ] **Step 4: Push**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
276
wiki/archive/known-issues-full-2026-04-22.md
Normal file
276
wiki/archive/known-issues-full-2026-04-22.md
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
---
|
||||||
|
title: 已知问题
|
||||||
|
updated: 2026-04-22
|
||||||
|
status: active
|
||||||
|
tags: [issues, bugs]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 已知问题
|
||||||
|
|
||||||
|
> 从 [[index]] 导航。完整清单见 `docs/TRUTH.md §3`
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
| 级别 | 数量 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| P0 (崩溃) | 2 | 全部已修复 |
|
||||||
|
| P1 (功能失效) | 9 | 全部已修复 |
|
||||||
|
| P1.5 (代码质量) | 7 | 全部已修复 |
|
||||||
|
| P2 (代码质量) | 10 | 待处理 |
|
||||||
|
| V13 P1 (断链) | 3 | **全部已修复** |
|
||||||
|
| V13 P2 (差距) | 3 | **全部已修复** |
|
||||||
|
| E2E 04-17 HIGH | 2 | **全部已修复** (commit a504a40) |
|
||||||
|
| E2E 04-17 MEDIUM | 5 | **全部已修复** (M4 admin_guard_middleware 已添加) |
|
||||||
|
| E2E 04-17 LOW | 2 | **全部已验证修复** (L1 代码已统一 + L2 反序列化已修复) |
|
||||||
|
| 审计 04-20 P0 | 2 | **全部已修复** (commit f291736) |
|
||||||
|
| 审计 04-20 P1 | 3 | **全部已修复** (commit f291736) |
|
||||||
|
| 审计 04-20 P2 | 2 | 待处理 (B-SCHED-5 任务名噪声 + B-CHAT-7 混合域截断) |
|
||||||
|
| 搜索 04-22 P1 | 3 | **全部已修复** (commit 5816f56 + 81005c3) |
|
||||||
|
| DataMasking 04-22 P1 | 1 | **已移除** (DataMasking 中间件彻底删除) |
|
||||||
|
|
||||||
|
## 搜索功能修复 04-22
|
||||||
|
|
||||||
|
| ID | 级别 | 问题 | 修复 | commit |
|
||||||
|
|------|------|------|------|--------|
|
||||||
|
| SEARCH-1 | P1 | glm-5.1 不理解 oneOf+const schema,tool_calls 参数为空 `{}` | 扁平化 input_schema (action/query/url/urls/engine) + empty-input 回退注入 | 5816f56 |
|
||||||
|
| SEARCH-2 | P1 | DuckDuckGo 被墙,搜索优先使用 Google | 改为 Baidu + Bing CN 并行,DDG 仅 fallback | 5816f56 |
|
||||||
|
| SEARCH-3 | P1 | stripToolNarration 按句子拆分破坏 markdown 排版 | 改为行级过滤,保留 markdown 结构行 | 81005c3 |
|
||||||
|
|
||||||
|
## DataMasking 过度匹配修复 04-22
|
||||||
|
|
||||||
|
| ID | 级别 | 问题 | 修复 | commit |
|
||||||
|
|------|------|------|------|--------|
|
||||||
|
| MASK-1 | P1 | DataMasking 正则把"有一家公司"误判为公司实体,替换为 `__ENTITY_1__`;LLM 响应缺少 unmask 导致用户看到占位符 | **已移除** — DataMasking 中间件彻底删除 (data_masking.rs 367行 + loop_runner unmask 逻辑 + 前端 mask/unmask) | 73d50fd (禁用) + 后续完全移除 |
|
||||||
|
|
||||||
|
## E2E 全系统功能测试 04-17 (129 链路)
|
||||||
|
|
||||||
|
> AI Agent 自动执行 (Tauri MCP + Chrome DevTools MCP + HTTP API)
|
||||||
|
> 完整报告: `docs/test-evidence/2026-04-17/E2E_TEST_REPORT_2026_04_17.md`
|
||||||
|
|
||||||
|
### 通过率概要
|
||||||
|
|
||||||
|
| 指标 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 总链路 | 129 |
|
||||||
|
| PASS | 82 (63.6%) |
|
||||||
|
| PARTIAL | 20 (15.5%) |
|
||||||
|
| FAIL | 1 (0.8%) |
|
||||||
|
| SKIP | 26 (20.2%) |
|
||||||
|
| 有效通过率 | 102/129 = 79.1% |
|
||||||
|
| CRITICAL 失败 | 0 |
|
||||||
|
| SaaS API 覆盖率 | ~78% (50/64 端点) |
|
||||||
|
|
||||||
|
### HIGH (2) — ✅ 已修复
|
||||||
|
|
||||||
|
| ID | 模块 | 描述 | 状态 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| BUG-H1 | V7 Admin | Dashboard 端点 404: `/api/v1/admin/dashboard` 未注册路由 | ✅ 已修复 (a504a40) |
|
||||||
|
| BUG-H2 | V4 Memory | 记忆不去重: viking_add 相同 URI+content 添加两次均返回 "added" | ✅ 已修复 (a504a40) |
|
||||||
|
|
||||||
|
### MEDIUM (5)
|
||||||
|
|
||||||
|
| ID | 模块 | 描述 | 状态 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| BUG-M1 | V8 Billing | invoice_id 未暴露给用户端 | ✅ 已修复 (a504a40) |
|
||||||
|
| BUG-M2 | V7 Prompt | 版本号不自增: PUT 更新后 current_version 保持 1 | ✅ 已修复 (a504a40) |
|
||||||
|
| BUG-M3 | V4 Memory | viking_find 不按 agent 隔离: 查询返回所有 agent 记忆 | ✅ 已修复 (a504a40) |
|
||||||
|
| BUG-M4 | V3 Auth | Admin 端点对非 admin 用户返回 404 非 403 | ✅ 已修复 (admin_guard_middleware) |
|
||||||
|
| BUG-M5 | V4 Memory | 跨会话记忆注入未工作: 新会话助手表示"没有找到对话历史" | ✅ 已修复 (a504a40) |
|
||||||
|
| BUG-M6 | V4 Memory | profile_store未连接+双数据库不一致导致UserProfile永远为空 | ✅ 已修复 (adf0251) |
|
||||||
|
|
||||||
|
### LOW (2)
|
||||||
|
|
||||||
|
| ID | 模块 | 描述 | 状态 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| BUG-L1 | V3 Industry | API 字段名不一致 (pain_seeds vs pain_seed_categories) | ✅ 已验证修复 (代码已统一为 pain_seed_categories) |
|
||||||
|
| BUG-L2 | V9 Pipeline | pipeline_create Tauri 命令参数反序列化失败 | ✅ 已验证修复 (04-17 回归) |
|
||||||
|
|
||||||
|
### 04-17 回归验证 (13/13 PASS)
|
||||||
|
|
||||||
|
> Tauri MCP + HTTP API 全量回归,验证 commit a504a40 修复有效性 + 子系统链路
|
||||||
|
|
||||||
|
**Phase 1 — Bug 修复回归 (6/6 PASS)**
|
||||||
|
|
||||||
|
| ID | 验证方法 | 结果 |
|
||||||
|
|----|----------|------|
|
||||||
|
| H1 Dashboard | HTTP GET /admin/dashboard → 200 | PASS |
|
||||||
|
| H2 Memory 去重 | viking_add × 2 → 第二次 "deduped" | PASS |
|
||||||
|
| M1 Invoice ID | POST /billing/payments → 含 invoice_id | PASS |
|
||||||
|
| M2 Prompt 版本 | PUT → current_version 1→2 | PASS |
|
||||||
|
| M3 Agent 隔离 | viking_find scope → 各返回 1 条无泄漏 | PASS |
|
||||||
|
| M5 跨会话注入 | memory_build_context → 检索到旧记忆 | PASS |
|
||||||
|
|
||||||
|
**Phase 2 — 子系统链路 (4/4 PASS)**
|
||||||
|
|
||||||
|
| 测试项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| Pipeline list → 17 模板 | PASS |
|
||||||
|
| Pipeline create → camelCase 反序列化 | PASS |
|
||||||
|
| Pipeline run → DAG 构建+执行(未配LLM) | PASS (链路通) |
|
||||||
|
| Skill 75 + route_intent 匹配 | PASS |
|
||||||
|
|
||||||
|
**Phase 3 — Butler + 记忆 (3/3 PASS)**
|
||||||
|
|
||||||
|
| 测试项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| Kernel init → 4 agents | PASS |
|
||||||
|
| agent_chat_stream → 事件分发 | PASS |
|
||||||
|
| health_snapshot + memory_stats → 381 记忆 | PASS |
|
||||||
|
|
||||||
|
### 子系统健康度
|
||||||
|
|
||||||
|
| 子系统 | PASS率 | 评分 | 说明 |
|
||||||
|
|--------|--------|------|------|
|
||||||
|
| 核心聊天链路 | 91.7% | 95/100 | 注册→登录→JWT→聊天→流式→持久化全闭环 |
|
||||||
|
| SaaS 后端 | — | 90/100 | 137 端点,78% 已测试 |
|
||||||
|
| Admin 后台 | 66.7% | 88/100 | 全页面 CRUD,Dashboard 404 已修复 |
|
||||||
|
| Hands 自主能力 | 70.0% | 85/100 | 10 Hand 全部 enabled,审批机制正确 |
|
||||||
|
| 计费系统 | 70.0% | 85/100 | 套餐/配额/支付全闭环 |
|
||||||
|
| 管家模式 | 60.0% | 80/100 | 路由+追问+tool_call 正常 |
|
||||||
|
| 记忆管道 | 62.5% | 70/100 | 存储+检索正常,去重/注入已修复 |
|
||||||
|
| Pipeline+Skill | 37.5% | 65/100 | Tauri IPC 可用但参数格式问题多 |
|
||||||
|
|
||||||
|
## V13 审计修复 (2026-04-13 全部完成)
|
||||||
|
|
||||||
|
### P1 — 功能断链 ✅ 全部已修复
|
||||||
|
|
||||||
|
| ID | 问题 | 修复 |
|
||||||
|
|----|------|------|
|
||||||
|
| V13-GAP-01 | TrajectoryRecorderMiddleware 未注册到中间件链 | ✅ 已注册 @650,Hermes 轨迹数据开始流入 |
|
||||||
|
| V13-GAP-02 | industryStore 存在但无组件导入 | ✅ 已接入 ButlerPanel,桌面端展示行业专长卡片 |
|
||||||
|
| V13-GAP-03 | 桌面端未接入 Knowledge Search API | ✅ saas-knowledge mixin + VikingPanel SaaS KB 搜索 UI |
|
||||||
|
|
||||||
|
### P2 — 代码清洁度 ✅ 全部已修复
|
||||||
|
|
||||||
|
| ID | 问题 | 修复 |
|
||||||
|
|----|------|------|
|
||||||
|
| V13-GAP-04 | Webhook 孤儿表 | ✅ deprecated 标注 + down migration 注释 |
|
||||||
|
| V13-GAP-05 | Structured Data Source 无 Admin UI | ✅ Admin Knowledge 新增"结构化数据"Tab |
|
||||||
|
| V13-GAP-06 | PersistentMemoryStore 遗留模块 | ✅ 全量移除 — persistent.rs 611→57 行 |
|
||||||
|
|
||||||
|
## Heartbeat 参数名修复 (2026-04-16)
|
||||||
|
|
||||||
|
| 问题 | 级别 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| Tauri invoke 参数名 snake_case 错误 | P1 | ✅ 已修复 |
|
||||||
|
|
||||||
|
**根因**: Tauri 2.x `#[tauri::command]` 默认 `rename_all = "camelCase"`,前端 invoke 必须用 camelCase(`agentId` 不是 `agent_id`)。`intelligence-client.ts` 中 3 处 invoke 调用使用了错误的 snake_case。
|
||||||
|
|
||||||
|
**修复**: commit `f6c5dd2` — 3 处参数名修正 + HealthPanel.tsx 恢复正确命名。
|
||||||
|
|
||||||
|
**教训**: 所有 Tauri invoke 调用的参数名必须用 camelCase,与 Rust 端 snake_case 参数名对应。参见 `browser-client.ts` 中已有的正确示例。
|
||||||
|
|
||||||
|
## Relay API Key 解密自愈 (2026-04-16)
|
||||||
|
|
||||||
|
| 问题 | 级别 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| Provider Key 解密失败导致整个 relay 500 | P1 | ✅ 已修复 |
|
||||||
|
|
||||||
|
**根因**: `key_pool.rs` 的 `select_best_key` 遍历 key 时,第一个解密失败的 key 就通过 `?` 直接返回 500,不会尝试下一个。如果 DB 中有旧的加密 key(密钥已变更),整个 relay 请求被阻断。重新保存只能临时解决,旧 key 仍在 DB 中。
|
||||||
|
|
||||||
|
**修复**: commit `b69dc61`:
|
||||||
|
- 解密失败时 `warn + continue` 跳到下一个 key
|
||||||
|
- 启动自愈 `heal_provider_keys()`: 逐个解密并重新加密,无法解密的标记 inactive
|
||||||
|
|
||||||
|
**教训**: 密钥池选择应容错(skip bad keys),而不是 fail-fast。加密数据迁移应自动化。
|
||||||
|
|
||||||
|
## 设置页面清理 (2026-04-16)
|
||||||
|
|
||||||
|
| 变更 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 删除"用量统计"页面 | 与"订阅与计费"功能重复 |
|
||||||
|
| 删除"积分详情"页面 | 与"订阅与计费"功能重复 |
|
||||||
|
|
||||||
|
commit `7dea456` — 移除 UsageStats + Credits 组件及菜单项。
|
||||||
|
|
||||||
|
## 三端联调测试 V2 (2026-04-15)
|
||||||
|
|
||||||
|
通过 Chrome DevTools MCP + Tauri MCP 实际界面操作验证。
|
||||||
|
|
||||||
|
### 已修复
|
||||||
|
|
||||||
|
| 问题 | 级别 | 修复 |
|
||||||
|
|------|------|------|
|
||||||
|
| SSE 中转任务 Token (入/出) 全部为 0 | P2 | ✅ SseUsageCapture 增加 stream_done 标志 + 前缀兼容 |
|
||||||
|
|
||||||
|
### 已验证通过
|
||||||
|
|
||||||
|
| 功能 | 状态 | 验证方式 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 桌面端登录 (SaaS 模式) | ✅ | Tauri MCP 实际登录 |
|
||||||
|
| 聊天流 (kimi-for-coding) | ✅ | 发送消息并收到流式回复 |
|
||||||
|
| 模型切换 | ✅ | 切换 deepseek → kimi |
|
||||||
|
| 智能体面板 | ✅ | 显示"默认助手" |
|
||||||
|
| 设置 20 个选项卡 | ✅ | 逐页检查:用量统计/模型/记忆/SaaS平台 |
|
||||||
|
| 语义记忆搜索 | ✅ | 100 条记忆,FTS5 + TF-IDF |
|
||||||
|
| Admin V2 仪表盘 | ✅ | Chrome DevTools: 30 账号/3 服务商/17 请求 |
|
||||||
|
| Admin V2 账号管理 | ✅ | 30 用户正常展示 |
|
||||||
|
| Admin V2 模型服务 | ✅ | DeepSeek/Kimi/zhipu 3 个 Provider |
|
||||||
|
| Admin V2 API 密钥 | ✅ | 不再崩溃(上次修复验证) |
|
||||||
|
| Admin V2 知识库 | ✅ | 6 条目 + 5 个 Tab |
|
||||||
|
| Admin V2 行业配置 | ✅ | 4 个内置行业 |
|
||||||
|
| Admin V2 计费管理 | ✅ | 团队版 570/20000 中转请求 |
|
||||||
|
| Admin V2 角色权限 | ✅ | 3 角色(超管/管理/用户) |
|
||||||
|
| Admin V2 操作日志 | ✅ | 2088 条记录 |
|
||||||
|
| Admin V2 Agent 模板 | ✅ | 10 模板(3 内置 + 7 自定义) |
|
||||||
|
|
||||||
|
### 待处理 / 观察项
|
||||||
|
|
||||||
|
| 问题 | 级别 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Admin 用量统计 0/0 | P2 | 用量统计页显示请求=0/Token=0,但仪表盘显示 17 请求/6304 Token。数据来源不同 |
|
||||||
|
| Deepseek 中转任务卡 processing | P3 | Provider Key 禁用后已有任务不会自动清理,需手动处理 |
|
||||||
|
| 桌面端 Token 统计为 0 | P2 | 用量统计页 Token 输入/输出=0,但图表显示 ~3.6M,数据不一致 |
|
||||||
|
|
||||||
|
## 三端联调测试 (2026-04-14)
|
||||||
|
|
||||||
|
30+ API / 16 Admin / 8 Tauri 全量测试结果:
|
||||||
|
|
||||||
|
| 问题 | 级别 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| API 密钥页崩溃 (undefined .map) | P1 | ✅ 已修复 |
|
||||||
|
| 桌面端 401 后不自动恢复 | P1 | ✅ 已修复 |
|
||||||
|
| 用量统计全零 (telemetry SQL timestamptz) | P1 | ✅ 已修复 |
|
||||||
|
| 行业选择 500 (industry 类型匹配) | P1 | ✅ 已修复 |
|
||||||
|
| 管理员切换订阅计划 500 | P1 | ✅ 已修复 |
|
||||||
|
| SaaS 启动崩溃 (config_items 约束) | P1 | ✅ 已修复 |
|
||||||
|
| SaaS 模型选择残留模型 ID | P0 | ✅ 已修复 |
|
||||||
|
|
||||||
|
## 代码健康度指标(2026-04-19)
|
||||||
|
|
||||||
|
| 指标 | 值 | 变化 | 说明 |
|
||||||
|
|------|-----|------|------|
|
||||||
|
| TODO/FIXME 前端 | 1 | 不变 | memory-extractor.ts |
|
||||||
|
| TODO/FIXME Rust | 1 | 3→1 | 已清理 |
|
||||||
|
| @reserved 标注 | 97 | 89→97 | 04-19 新增标注 |
|
||||||
|
| dead_code 标记 | 0 | 16→0 | 全部清理 |
|
||||||
|
| 前端孤立 invoke | 0 | 不变 | 已清理 |
|
||||||
|
| Cargo Warnings | 0 | 不变 | 非 SaaS,仅 sqlx 外部 |
|
||||||
|
| 前端测试通过 | 344+1 skipped | 不变 | pnpm vitest run |
|
||||||
|
| Rust 测试 (workspace) | 797 通过 | 684→797 | sqlx 0.8 升级 + 测试补充 |
|
||||||
|
|
||||||
|
## 长期观察项
|
||||||
|
|
||||||
|
| 问题 | 说明 | 位置 |
|
||||||
|
|------|------|------|
|
||||||
|
| Tauri 命令孤儿 | 注册 190 命令,前端调用 104 处,@reserved 97 个,剩余 ~0 个 (差异来自内部命令调用) | `desktop/src-tauri/src/lib.rs` |
|
||||||
|
| Embedding 未激活 | NoOpEmbeddingClient 为默认值,用户配置后替换为真实 provider | `zclaw-growth/src/retrieval/semantic.rs` |
|
||||||
|
| SaaS embedding deferred | pgvector 索引就绪,生成未实现 | `zclaw-saas/src/workers/generate_embedding.rs` |
|
||||||
|
| SkillIndex 条件注册 | 无技能时 skill_index 中间件不注册 | `kernel/mod.rs:309` |
|
||||||
|
|
||||||
|
## 已修复的关键问题(历史记录)
|
||||||
|
|
||||||
|
| ID | 问题 | 修复日期 |
|
||||||
|
|----|------|----------|
|
||||||
|
| SEC2-P0-01 | skill_execute 反序列化崩溃 | 04-02 |
|
||||||
|
| SEC2-P0-02 | TaskTool::default() panic | 04-02 |
|
||||||
|
| SEC2-P1-01~09 | 9 项功能失效 (FactStore/路径/监听/...) | 04-02 |
|
||||||
|
| SEC2-P1.5-01~07 | 7 项代码质量修复 | 04-02 |
|
||||||
|
| P0-2/P0-3 | usage 端点 + refresh token 类型 | 04-10 |
|
||||||
|
| P1-02 | 浏览器聊天 SaaS fixture | 04-10 |
|
||||||
|
| P1-04 | AuthGuard 竞态条件 | 04-10 |
|
||||||
|
| BREAKS 全部 | 全部 P0/P1/P2 已修复 | 04-10 |
|
||||||
|
| V13-GAP-01~06 | 6 项断链/差距全部修复 | 04-13 |
|
||||||
|
| 三端联调 P0/P1 | 7 项全部修复 | 04-14 |
|
||||||
|
|
||||||
|
→ 模块详情见各模块页面: [[routing]] [[chat]] [[saas]] [[memory]] [[middleware]]
|
||||||
225
wiki/archive/log-2026-04-pre.md
Normal file
225
wiki/archive/log-2026-04-pre.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
---
|
||||||
|
title: 变更日志归档 (2026-04-13 及更早)
|
||||||
|
archived: 2026-04-22
|
||||||
|
---
|
||||||
|
|
||||||
|
# 变更日志归档
|
||||||
|
|
||||||
|
> 2026-04-22 归档。活跃日志见 [[log]]。
|
||||||
|
|
||||||
|
|
||||||
|
## [2026-04-13] fix | V13 审计 6 项修复全部完成
|
||||||
|
|
||||||
|
- FIX-01~06: TrajectoryRecorder注册 + industryStore接入 + 知识搜索 + webhook标注 + 结构化UI + PersistentMemoryStore移除
|
||||||
|
- 提交: c167ea4 + fd3e7fd
|
||||||
|
|
||||||
|
## [2026-04-12] audit | V13 系统性功能审计 — 6 项新发现
|
||||||
|
|
||||||
|
- 全系统功能一致性审计完成, 总体健康度 82/100 (V12: 76)
|
||||||
|
- P1 新发现 3 项: TrajectoryRecorder 未注册中间件链, industryStore 无组件导入, 桌面端无 Knowledge Search
|
||||||
|
- P2 新发现 3 项: Webhook 孤儿表, Structured Data Source 无 Admin UI, PersistentMemoryStore 遗留
|
||||||
|
- 修正 V12 错误认知 5 项: Butler/MCP/Gateway/Presentation 已接通, Reflection driver 已修复
|
||||||
|
- TRUTH.md 数字校准: Tauri 184→191, SaaS 122→136, @reserved 33→24, dead_code 76→43
|
||||||
|
- 完整报告: `docs/features/audit-v13/V13-FULL-REPORT.md`
|
||||||
|
|
||||||
|
## [2026-04-12] fix | 三轮审计修复 — 3 HIGH + 4 MEDIUM 清零
|
||||||
|
|
||||||
|
- H1: status disabled→inactive 统一 + source 补 admin 映射
|
||||||
|
- H2: experience.rs format_for_injection XML 转义
|
||||||
|
- H3: TriggerContext industry_keywords 全局缓存接通
|
||||||
|
- M2: ID 自动生成移除中文 + 无 ASCII 手动提示
|
||||||
|
- M3: TS CreateIndustryRequest 补 id 字段
|
||||||
|
- M4: ListIndustriesQuery deny_unknown_fields
|
||||||
|
|
||||||
|
## [2026-04-12] feat | 知识库 Phase D — 统一搜索 + 种子知识冷启动
|
||||||
|
|
||||||
|
- search/recommend API 返回 UnifiedSearchResult (文档+结构化双通道合并)
|
||||||
|
- POST /api/v1/knowledge/seed 种子知识冷启动接口 (幂等, admin权限)
|
||||||
|
- seed_knowledge: 按标题+行业查重, source='distillation', tags标记行业
|
||||||
|
- SearchRequest 扩展: search_structured/search_documents/industry_id 字段
|
||||||
|
- 167 行新增, 4 文件变更
|
||||||
|
|
||||||
|
## [2026-04-12] fix | 二次审计修复 — 2 CRITICAL + 4 HIGH + 2 MEDIUM
|
||||||
|
|
||||||
|
- C-1: Industries.tsx 创建弹窗缺少 id → 添加 id 输入 + name 自动生成
|
||||||
|
- C-2: Accounts.tsx handleSave 部分 save → try/catch + handleClose 统一
|
||||||
|
- V1: viking_commands Mutex 跨 await → Arc clone 后释放 Mutex
|
||||||
|
- I1+I2: 误导性"相关度"分数移除 + pain point XML 转义
|
||||||
|
- S1+S2: industry status 枚举白名单 + id 格式正则验证
|
||||||
|
- H-3+H-4: 编辑模态数据竞争守卫 + useEffect editingId 守卫
|
||||||
|
|
||||||
|
## [2026-04-12] feat | 知识库 Phase B+C — 文档提取器 + multipart 文件上传
|
||||||
|
|
||||||
|
- extractors.rs: PDF(pdf-extract) + DOCX(zip+quick-xml) + Excel(calamine) 三格式提取
|
||||||
|
- 格式路由 detect_format() → RAG 通道或结构化通道
|
||||||
|
- POST /api/v1/knowledge/upload multipart 文件上传
|
||||||
|
- PDF/DOCX/Markdown → RAG 管线,Excel → structured_rows JSONB 存储
|
||||||
|
- 结构化数据源 API: GET/DELETE /api/v1/structured/sources + /rows + /query
|
||||||
|
- 修复 industry/service.rs SaasError::Database 类型不匹配
|
||||||
|
- 累计新增 849 行,7 文件变更
|
||||||
|
|
||||||
|
## [2026-04-12] fix | 审计修复 — 4 CRITICAL + 5 HIGH 全部解决
|
||||||
|
|
||||||
|
- C1: SQL 注入风险 → industry/service.rs 参数化查询 ($N 绑定)
|
||||||
|
- C2: INDUSTRY_CONFIGS 死链 → Kernel 共享 Arc + ButlerRouter 共享实例
|
||||||
|
- C3: IndustryListItem 缺字段 → keywords_count + 时间戳补全
|
||||||
|
- C4: 非事务性行业绑定 → batch ANY($1) 验证 + 事务 DELETE+INSERT
|
||||||
|
- H8: Accounts.tsx 竞态 → mutate→mutateAsync + confirmLoading 双检测
|
||||||
|
- H9: XML 注入未转义 → xml_escape() 辅助函数
|
||||||
|
- H10: update 覆盖 source → 保留原始值
|
||||||
|
- H11: 面包屑 /industries 映射缺失
|
||||||
|
|
||||||
|
## [2026-04-12] feat | 行业配置 + 管家主动性 全栈 5 Phase 实施
|
||||||
|
|
||||||
|
Phase 1 — 行业配置基础 (13 files, 886 insertions):
|
||||||
|
- SaaS industries + account_industries 表 (migration v15)
|
||||||
|
- 4 内置行业: 医疗/教育/制衣/电商 (keywords/prompt/pain_seed_categories)
|
||||||
|
- ButlerRouter 动态行业关键词注入 (Arc<RwLock<Vec<IndustryKeywordConfig>>>)
|
||||||
|
- 8 SaaS API handlers (list/create/update/fullConfig/accountIndustries)
|
||||||
|
|
||||||
|
Phase 2 — 学习循环基础 (5 files, 271 insertions):
|
||||||
|
- 5 触发信号: PainConfirmed/PositiveFeedback/ComplexToolChain/UserCorrection/IndustryPattern
|
||||||
|
- Experience 增加 industry_context + source_trigger 维度
|
||||||
|
- experience_store keywords 含行业标签
|
||||||
|
|
||||||
|
Phase 3 — Tauri 行业配置加载 (6 files, 310 insertions):
|
||||||
|
- desktop saas-industry.ts mixin (4 API methods)
|
||||||
|
- industryStore.ts (Zustand + persist, 离线缓存)
|
||||||
|
- viking_load_industry_keywords Tauri 命令 (JSON String → Rust struct)
|
||||||
|
|
||||||
|
Phase 4 — Admin 行业管理 (6 files, 564 insertions):
|
||||||
|
- Industries.tsx: 行业列表 + 编辑弹窗(关键词/prompt/痛点种子) + 新建弹窗
|
||||||
|
- Accounts.tsx 增强: 行业授权多选 + 主行业标记
|
||||||
|
- /industries 路由 + ShopOutlined 侧边栏导航
|
||||||
|
|
||||||
|
Phase 5 — 主动行为激活 (3 files, 152 insertions):
|
||||||
|
- 注入格式升级: [路由上下文] → <butler-context> XML fencing (Hermes 策略)
|
||||||
|
- 跨会话连续性: pre_hook 注入活跃痛点 + 相关经验
|
||||||
|
- 触发信号持久化: store_trigger_experience() 模板提取零 LLM 成本
|
||||||
|
|
||||||
|
## [2026-04-11] chore | 发布前准备 — 版本号统一 + 数字校准 + 安全加固
|
||||||
|
|
||||||
|
1. Cargo.toml 版本 0.1.0 → 0.9.0-beta.1 (workspace 统一)
|
||||||
|
2. TRUTH.md 数字全面校准 — Rust 代码 66K→74.6K、Tauri 命令 182→184、SaaS .route() 140→122 等 10 项
|
||||||
|
3. CSP 加固 — 添加 `object-src 'none'`
|
||||||
|
4. .env.example 补充 SaaS 关键环境变量 (JWT_SECRET/TOTP_KEY/Admin 凭据)
|
||||||
|
5. 安全检查通过 — 无硬编码密钥、SQL 全参数化、Cookie 三件套完整
|
||||||
|
|
||||||
|
## [2026-04-11] fix | 模型路由链路修复 — 消除硬编码不匹配模型
|
||||||
|
|
||||||
|
1. summarizer_adapter.rs — "glm-4-flash" 硬编码 fallback → 未配置时明确报错 (fail fast)
|
||||||
|
2. saas-relay-client.ts — 'glm-4-flash-250414' 硬编码 fallback → 未获取模型时报错
|
||||||
|
3. Wiki routing.md — 新增完整模型路由文档 (Tauri SaaS Relay 主路径 + 辅助 LLM + Browser 模式)
|
||||||
|
|
||||||
|
## [2026-04-11] fix | Skill/MCP 调用链路修复 3 个断点
|
||||||
|
|
||||||
|
1. Anthropic Driver ToolResult 格式 — ContentBlock 添加 ToolResult 变体, tool_call_id 不再丢弃
|
||||||
|
2. 前端 callMcpTool 参数名 — serviceName/toolName/args → service_name/tool_name/arguments
|
||||||
|
3. MCP 工具桥接 ToolRegistry — McpToolWrapper + Kernel mcp_adapters 共享状态 + 启停同步
|
||||||
|
4. Wiki 更新 — hands-skills.md 添加 Skill 调用链路 + MCP 架构文档
|
||||||
|
|
||||||
|
## [2026-04-11] fix | 发布内测前修复 6 批次
|
||||||
|
|
||||||
|
- Batch 1: 新用户 llm_routing 默认改为 relay (SQL + migration)
|
||||||
|
- Batch 2: SaaS URL 集中配置化 (VITE_SAAS_URL, 5处硬编码消除)
|
||||||
|
- Batch 3: Gateway URL 配置化 + Rust panic hook 崩溃报告
|
||||||
|
- Batch 4: UX 文案修复 — 新/老用户区分 + 去政务化 + 忘记密码
|
||||||
|
- Batch 5: 移除空壳"行业资讯" Tab + Provider URL 去重统一到 api-urls.ts
|
||||||
|
- Batch 6: 版本号 0.1.0 → 0.9.0-beta.1 + updater 插件预留
|
||||||
|
|
||||||
|
## [2026-04-11] docs | Wiki 全面更新 — 代码验证驱动
|
||||||
|
|
||||||
|
- 全部 10 个 wiki 页面基于代码扫描验证更新(非文档推测)
|
||||||
|
- 关键数字修正: Rust 95K行(335 .rs文件, 原文档66K)、Tauri命令 190/183、SaaS路由 121、前端组件 104、lib/ 85 文件
|
||||||
|
- 测试函数修正: ~1,055 (872内联+183集成,原文档仅计#[test])
|
||||||
|
- 新增中间件完整注册清单(14层runtime + 6层SaaS HTTP)
|
||||||
|
- 新增 Store 完整目录结构(17 文件 + chat/4 子store)
|
||||||
|
- 新增 Pipeline 模板完整目录树(17 YAML, 8 行业目录)
|
||||||
|
- 新增 Hands 测试数分布
|
||||||
|
- 新增 memory Tauri 命令完整列表(16 个)
|
||||||
|
- 新增代码健康度指标(TODO/FIXME 仅 8 个)
|
||||||
|
- 修正管家模式描述: 关键词路由 → 语义路由(TF-IDF)
|
||||||
|
- 新增 artifactStore 到 chat Store 拆分列表
|
||||||
|
|
||||||
|
## [2026-04-11] init | 创建 wiki 知识库
|
||||||
|
|
||||||
|
- 从 TRUTH.md / ARCHITECTURE_BRIEF.md / CLAUDE.md 编译 8 个 wiki 页面
|
||||||
|
- 创建 index.md 入口 + 7 个主题页
|
||||||
|
- CLAUDE.md 添加 @wiki/index.md 引用
|
||||||
|
|
||||||
|
## [2026-04-10] fix | 发布前修复批次
|
||||||
|
|
||||||
|
- ButlerRouter 语义路由 — SemanticSkillRouter TF-IDF 替代关键词
|
||||||
|
- P1-04 AuthGuard 竞态 — 三态守卫 + cookie 先验证
|
||||||
|
- P2-03 限流 — Cross 测试共享 token
|
||||||
|
- P1-02 浏览器聊天 — Playwright SaaS fixture
|
||||||
|
- BREAKS.md 全部 P0/P1/P2 已修复
|
||||||
|
|
||||||
|
## [2026-04-09] feat | Hermes Intelligence Pipeline 4 Chunk
|
||||||
|
|
||||||
|
- Chunk1 ExperienceStore+Extractor (10 tests)
|
||||||
|
- Chunk2 UserProfileStore+Profiler (14 tests)
|
||||||
|
- Chunk3 NlScheduleParser (16 tests)
|
||||||
|
- Chunk4 TrajectoryRecorder+Compressor (18 tests)
|
||||||
|
- 中间件 13→14 层 (+TrajectoryRecorder@650)
|
||||||
|
- Schema v2→v4 (user_profiles + trajectory tables)
|
||||||
|
|
||||||
|
## [2026-04-09] feat | 管家模式发布前实施完成
|
||||||
|
|
||||||
|
- ButlerRouter + 冷启动 + 简洁UI
|
||||||
|
- 痛点持久化 SQLite
|
||||||
|
- 桥测试 43 通过
|
||||||
|
|
||||||
|
## [2026-04-07] feat | 管家能力激活
|
||||||
|
|
||||||
|
- Tauri 命令 183→189 (+6 butler)
|
||||||
|
- multi-agent feature 默认启用
|
||||||
|
- ButlerPanel UI 3 区
|
||||||
|
- DataMaskingMiddleware@90
|
||||||
|
|
||||||
|
## [2026-04-03] fix | 前端改进 + 数字校准
|
||||||
|
|
||||||
|
- Pipeline 8 invoke 接通前端
|
||||||
|
- Viking 5 孤立 invoke 清理
|
||||||
|
- SaaS API 93→131 (新增 knowledge/billing/role)
|
||||||
|
- scheduled_task Admin V2 完整接入
|
||||||
|
|
||||||
|
## [2026-04-02] fix | P0/P1 全部修复
|
||||||
|
|
||||||
|
- 2 P0 崩溃修复
|
||||||
|
- 9 P1 功能失效修复
|
||||||
|
- 7 P1.5 代码质量修复
|
||||||
|
- TRUTH.md 初始创建
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 更新规则: 每次重大变更后追加一条,最新在最上面
|
||||||
|
|
||||||
|
## [2026-04-13] fix | V13 审计 6 项修复全部完成
|
||||||
|
|
||||||
|
- FIX-01 (P1): TrajectoryRecorderMiddleware 注册到 create_middleware_chain() @650,Hermes 轨迹数据开始流入
|
||||||
|
- FIX-02 (P1): industryStore 接入 ButlerPanel,桌面端展示行业专长卡片 + 自动拉取
|
||||||
|
- FIX-03 (P1): 桌面端知识库搜索 — saas-knowledge mixin + VikingPanel SaaS KB 搜索 UI
|
||||||
|
- FIX-04 (P2): Webhook 孤儿迁移标注 deprecated + down migration 注释
|
||||||
|
- FIX-05 (P2): Admin Knowledge 新增"结构化数据"Tab (CRUD + 行浏览)
|
||||||
|
- FIX-06 (P2): PersistentMemoryStore 全量移除 — persistent.rs 611→57行,删除死 embedding global + 2 @reserved 命令 + viking_commands 冗余配置,Tauri 命令 191→189
|
||||||
|
- 文件: 13 个 (Rust 5 + TS 7 + docs 1), 提交: c167ea4 + fd3e7fd + 本轮
|
||||||
|
|
||||||
|
- P0: memory_search 空查询 min_similarity 默认值; hand_trigger null→handAutoTrigger; 重启后 chat 路由竞态修复
|
||||||
|
- P1: AgentInfo 扩展 UserProfile 桥接; 反思阈值降低 5→3; 反思 state restore peek+pop 竞态修复
|
||||||
|
- P2: 演化历史可展开差异视图; 管家 Tab 条件 header + 空状态引导
|
||||||
|
- 文件: 14 个 (Rust 5 + TS 9), 10 次提交
|
||||||
|
|
||||||
|
## [2026-04-21] docs | Wiki 系统性更新
|
||||||
|
|
||||||
|
**变更**: wiki 三层架构增强 — L0 速览 + L1 模块标准化 + L2 功能链路映射
|
||||||
|
|
||||||
|
- L0: index.md 增强 — 用户功能清单(10类) + 跨模块数据流全景图 + 导航树增强(含3新页面)
|
||||||
|
- L1: 8 个模块页标准化 — 新增功能清单/API接口/测试链路/已知问题标准章节
|
||||||
|
- routing.md (252→326), chat.md (101→157), saas.md (153→230), memory.md (182→333)
|
||||||
|
- butler.md (137→179), middleware.md (121→159), hands-skills.md (218→257), pipeline.md (111→156)
|
||||||
|
- L1: 新增 security.md (157行) + data-model.md (180行)
|
||||||
|
- L2: 新增 feature-map.md (408行, 33条功能链路, 覆盖对话/Agent/Hands/记忆/SaaS/管家/Pipeline/配置/安全)
|
||||||
|
- 维护: CLAUDE.md §8.3 wiki 触发规则扩展 (6→9条规则)
|
||||||
|
- 设计文档: docs/superpowers/specs/2026-04-21-wiki-systematic-overhaul-design.md
|
||||||
|
- 文件: 11 个修改 + 3 个新增, 总计 ~1400 行新增内容
|
||||||
73
wiki/archive/memory-extraction-details.md
Normal file
73
wiki/archive/memory-extraction-details.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
title: 记忆提取详细逻辑 (归档)
|
||||||
|
archived_from: wiki/memory.md
|
||||||
|
archived_on: 2026-04-22
|
||||||
|
reason: Wiki 压缩重构,详细提取逻辑从主页面移除
|
||||||
|
---
|
||||||
|
|
||||||
|
# 记忆提取详细逻辑 (归档)
|
||||||
|
|
||||||
|
> 2026-04-22 从 wiki/memory.md 归档。详细提取 prose 和 Hermes 分析内容。
|
||||||
|
> 主页面仅保留数据流 + 不变量 + 活跃问题。
|
||||||
|
|
||||||
|
## 原始内容
|
||||||
|
|
||||||
|
详细的提取逻辑 prose 已移除。需要时请参考以下源文件:
|
||||||
|
|
||||||
|
- `crates/zclaw-growth/src/extractor.rs` — LLM 记忆提取实现
|
||||||
|
- `crates/zclaw-growth/src/retriever.rs` — 语义检索实现
|
||||||
|
- `crates/zclaw-growth/src/retrieval/query.rs` — QueryAnalyzer 意图分类
|
||||||
|
- `crates/zclaw-growth/src/experience_store.rs` — 经验 CRUD
|
||||||
|
- `wiki/archive/hermes-analysis.md` — Hermes 管线完整分析 (463 行)
|
||||||
|
|
||||||
|
## 查询意图分类 (QueryAnalyzer)
|
||||||
|
|
||||||
|
| 意图 | 说明 | 检索策略 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Preference | 用户偏好 | 精确匹配 preference 类型记忆 |
|
||||||
|
| Knowledge | 知识查询 | 语义搜索 knowledge 类型 |
|
||||||
|
| Experience | 经验检索 | 时间+相关性排序 |
|
||||||
|
| Code | 代码相关 | 关键词优先 |
|
||||||
|
| General | 通用 | 混合策略 |
|
||||||
|
|
||||||
|
## 进化引擎组件清单
|
||||||
|
|
||||||
|
```
|
||||||
|
EvolutionEngine — 行为模式检测 → 技能/工作流建议
|
||||||
|
FeedbackCollector — 收集用户反馈信号
|
||||||
|
PatternAggregator — 行为模式聚合
|
||||||
|
QualityGate — 进化质量门控 (长度/标题/置信度/去重)
|
||||||
|
SkillGenerator — 自动技能生成 (SkillManifest)
|
||||||
|
WorkflowComposer — 工作流自动编排
|
||||||
|
ProfileUpdater — 用户画像更新
|
||||||
|
ExperienceExtractor — 经验提取器
|
||||||
|
Summarizer — 记忆摘要
|
||||||
|
```
|
||||||
|
|
||||||
|
## zclaw-growth 完整模块结构 (19 文件)
|
||||||
|
|
||||||
|
```
|
||||||
|
crates/zclaw-growth/src/
|
||||||
|
├── evolution_engine.rs 进化引擎核心
|
||||||
|
├── experience_extractor.rs 经验提取
|
||||||
|
├── experience_store.rs 经验 CRUD
|
||||||
|
├── extractor.rs 记忆提取
|
||||||
|
├── feedback_collector.rs 反馈收集
|
||||||
|
├── injector.rs Prompt 注入
|
||||||
|
├── json_utils.rs JSON 工具
|
||||||
|
├── pattern_aggregator.rs 模式聚合
|
||||||
|
├── profile_updater.rs 画像更新
|
||||||
|
├── quality_gate.rs 质量门控
|
||||||
|
├── retriever.rs 语义检索
|
||||||
|
├── skill_generator.rs 技能生成
|
||||||
|
├── summarizer.rs 摘要生成
|
||||||
|
├── tracker.rs 追踪器
|
||||||
|
├── types.rs 类型定义
|
||||||
|
├── viking_adapter.rs Viking 适配器
|
||||||
|
├── workflow_composer.rs 工作流编排
|
||||||
|
├── retrieval/ 检索子模块
|
||||||
|
│ ├── query.rs 意图分类 + CJK
|
||||||
|
│ └── semantic.rs EmbeddingClient
|
||||||
|
└── storage/ 存储子模块
|
||||||
|
└── sqlite.rs FTS5 + TF-IDF
|
||||||
|
```
|
||||||
318
wiki/butler.md
318
wiki/butler.md
@@ -7,208 +7,144 @@ tags: [module, butler, interaction]
|
|||||||
|
|
||||||
# 管家模式 (Butler Mode)
|
# 管家模式 (Butler Mode)
|
||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[chat]] [[middleware]] [[memory]]
|
> 从 [[index]] 导航。关联: [[chat]] [[middleware]] [[memory]] [[hands-skills]]
|
||||||
|
|
||||||
## 设计思想
|
## 1. 设计决策
|
||||||
|
|
||||||
**核心问题: 非技术用户(如医院行政)不会写 prompt,需要 AI 主动引导。**
|
**核心问题: 非技术用户(如医院行政)不会写 prompt,需要 AI 主动引导。**
|
||||||
|
|
||||||
设计决策:
|
| 决策 | WHY |
|
||||||
1. **默认激活** — 所有聊天都经过 ButlerRouter,不需要用户手动开启
|
|------|-----|
|
||||||
2. **语义路由** — SemanticSkillRouter 用 TF-IDF 匹配 75 个技能,替代简单关键词
|
| 默认激活 | 所有聊天都经过 ButlerRouter,无需用户手动开启。降低使用门槛到零 |
|
||||||
3. **痛点积累** — 从对话中提取用户痛点,积累后生成方案建议
|
| 语义路由 + 痛点积累 | SemanticSkillRouter 用 TF-IDF 匹配 75 个技能(详见 [[hands-skills]]),从对话中提取痛点并积累后生成方案建议 |
|
||||||
4. **双模式 UI** — simple(纯聊天,默认) / professional(完整功能),渐进式解锁
|
| 双模式 UI | simple(纯聊天) / professional(完整功能),渐进式解锁。简洁模式隐藏高级功能降低认知负担 |
|
||||||
|
| ButlerRouter@80 中间件 | 在 Evolution@78 之后、Memory@150 之前执行。先路由增强 prompt,再检索记忆注入,最后技能索引 |
|
||||||
|
| XML fencing `<butler-context>` | 结构化注入 system prompt,避免与用户消息混淆。LLM 可区分管家上下文和用户输入 |
|
||||||
|
| 冷启动 4 阶段 hook | idle -> greeting_sent -> waiting_response -> completed,自动检测新用户并发送欢迎引导 |
|
||||||
|
| 4 内置行业 + 自定义关键词 | 医疗/教育/制衣/电商开箱即用,ButlerRouter 动态行业关键词注入支持扩展 |
|
||||||
|
|
||||||
## 代码逻辑
|
## 2. 关键文件 + 数据流
|
||||||
|
|
||||||
### 数据流
|
### 核心文件
|
||||||
|
|
||||||
```
|
|
||||||
用户消息
|
|
||||||
→ ButlerRouter 中间件 (middleware/butler_router.rs)
|
|
||||||
→ ButlerRouterBackend trait → SemanticRouterAdapter
|
|
||||||
→ SemanticSkillRouter (zclaw-skills/src/semantic_router.rs)
|
|
||||||
→ TF-IDF 计算与 75 个技能的相似度
|
|
||||||
→ 返回 RoutingHint { category, confidence, skill_id }
|
|
||||||
→ 增强 system prompt (匹配的技能上下文)
|
|
||||||
→ LLM 响应
|
|
||||||
→ PainAggregator 提取痛点
|
|
||||||
→ PainStorage (内存 Vec 热缓存 + SQLite 持久层)
|
|
||||||
→ 全局 PAIN_STORAGE 单例
|
|
||||||
→ SolutionGenerator
|
|
||||||
→ 基于痛点生成解决方案提案
|
|
||||||
```
|
|
||||||
|
|
||||||
### 语义路由桥接(kernel 层)
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// crates/zclaw-kernel/src/kernel/mod.rs:196-231
|
|
||||||
struct SemanticRouterAdapter { router: Arc<SemanticSkillRouter> }
|
|
||||||
impl ButlerRouterBackend for SemanticRouterAdapter {
|
|
||||||
async fn classify(&self, query: &str) -> Option<RoutingHint> { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
这是 kernel 依赖 zclaw-runtime + zclaw-skills 的桥接点。
|
|
||||||
|
|
||||||
### 冷启动 (新用户引导)
|
|
||||||
|
|
||||||
入口: `desktop/src/hooks/use-cold-start.ts`(lib/ 下有同名文件)
|
|
||||||
|
|
||||||
```
|
|
||||||
idle → (检测新用户) → greeting_sent → waiting_response → completed
|
|
||||||
```
|
|
||||||
|
|
||||||
4 个阶段,自动检测用户是否需要引导,发送欢迎消息,等待响应后完成。
|
|
||||||
|
|
||||||
### UI 双模式
|
|
||||||
|
|
||||||
| 模式 | Store | 特点 |
|
|
||||||
|------|-------|------|
|
|
||||||
| simple (默认) | `uiModeStore.ts` | 纯聊天界面,隐藏高级功能 |
|
|
||||||
| professional | `uiModeStore.ts` | 完整功能面板 |
|
|
||||||
|
|
||||||
切换文件: `desktop/src/store/uiModeStore.ts`
|
|
||||||
简洁侧边栏: `desktop/src/components/SimpleSidebar.tsx`
|
|
||||||
管家面板: `desktop/src/components/ButlerPanel.tsx` (3 区: 洞察/方案/记忆 + 行业专长卡片)
|
|
||||||
|
|
||||||
### 管家Tab记忆展示(2026-04-22 增强)
|
|
||||||
|
|
||||||
> ButlerPanel 的 MemorySection 组件负责向用户展示管家了解的信息。
|
|
||||||
|
|
||||||
```
|
|
||||||
ButlerPanel (index.tsx)
|
|
||||||
├── InsightsSection — 痛点洞察
|
|
||||||
├── ProposalsSection — 方案建议
|
|
||||||
├── MemorySection — 记忆 + 用户画像 (增强后)
|
|
||||||
│ ├── 用户画像卡片 — agent_get → UserProfileStore (data.db)
|
|
||||||
│ │ ├── 行业/角色/沟通风格 (profile_store.update_field)
|
|
||||||
│ │ ├── 近期话题标签 (profile_store.add_recent_topic, 上限10)
|
|
||||||
│ │ └── 常用工具标签 (profile_store.add_preferred_tool, 上限10)
|
|
||||||
│ └── 记忆分组列表 — viking_ls + viking_read(L1) (memories.db)
|
|
||||||
│ ├── 偏好 (preferences) — 默认展开
|
|
||||||
│ ├── 知识 (knowledge) — 默认展开
|
|
||||||
│ ├── 经验 (experience) — 折叠
|
|
||||||
│ └── 会话 (sessions) — 折叠
|
|
||||||
└── 行业专长卡片 — industryStore
|
|
||||||
|
|
||||||
数据源:
|
|
||||||
记忆列表: listVikingResources("agent://{agent_id}/") → viking_ls
|
|
||||||
记忆摘要: readVikingResource(uri, "L1") → viking_read → L1 摘要 (并行加载)
|
|
||||||
用户画像: agent_get(agentId) → kernel.memory() → UserProfileStore.get() → data.db
|
|
||||||
|
|
||||||
关键文件:
|
|
||||||
desktop/src/components/ButlerPanel/MemorySection.tsx 记忆+画像展示组件
|
|
||||||
desktop/src/components/ButlerPanel/index.tsx 管家面板主组件
|
|
||||||
desktop/src/lib/viking-client.ts viking_ls/viking_read 客户端
|
|
||||||
desktop/src/lib/kernel-types.ts AgentInfo.userProfile 类型
|
|
||||||
```
|
|
||||||
|
|
||||||
### 行业配置 (V13 已接通)
|
|
||||||
|
|
||||||
- `desktop/src/store/industryStore.ts` — 行业配置 Zustand Store (persist, 离线缓存)
|
|
||||||
- ButlerPanel 展示行业专长卡片 + 自动拉取行业配置
|
|
||||||
- SaaS API: `industry/list` / `industry/fullConfig` / `industry/accountIndustries`
|
|
||||||
- 4 内置行业: 医疗/教育/制衣/电商 (keywords/prompt/pain_seed_categories)
|
|
||||||
- ButlerRouter 动态行业关键词注入 (Arc<RwLock<Vec<IndustryKeywordConfig>>>)
|
|
||||||
|
|
||||||
### Tauri 命令
|
|
||||||
|
|
||||||
5 个 butler 命令 (已标注 @reserved):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
// desktop/src-tauri/src/intelligence/pain_aggregator.rs
|
|
||||||
butler_list_pain_points
|
|
||||||
butler_record_pain_point
|
|
||||||
butler_generate_solution
|
|
||||||
butler_list_proposals
|
|
||||||
butler_update_proposal_status
|
|
||||||
```
|
|
||||||
|
|
||||||
### Intelligence 层文件结构 (16 .rs 文件)
|
|
||||||
|
|
||||||
```
|
|
||||||
desktop/src-tauri/src/intelligence/
|
|
||||||
├── compactor.rs (5 commands: token estimation + compaction)
|
|
||||||
├── heartbeat.rs (10 commands: heartbeat engine CRUD)
|
|
||||||
├── identity.rs (16 commands: agent identity manager)
|
|
||||||
├── pain_aggregator.rs (5 commands: butler pain points)
|
|
||||||
├── reflection.rs (7 commands: reflection engine)
|
|
||||||
├── experience.rs 经验管理桥接
|
|
||||||
├── extraction_adapter.rs 记忆提取适配器
|
|
||||||
├── health_snapshot.rs 统一健康快照
|
|
||||||
├── mod.rs 模块入口
|
|
||||||
├── pain_storage.rs 痛点持久化
|
|
||||||
├── personality_detector.rs 人格检测
|
|
||||||
├── solution_generator.rs 方案生成
|
|
||||||
├── trajectory_compressor.rs 轨迹压缩
|
|
||||||
├── triggers.rs 触发信号管理
|
|
||||||
├── user_profiler.rs 用户画像
|
|
||||||
└── validation.rs 验证逻辑
|
|
||||||
```
|
|
||||||
|
|
||||||
## 功能清单
|
|
||||||
|
|
||||||
| 功能 | 描述 | 入口文件 | 状态 |
|
|
||||||
|------|------|----------|------|
|
|
||||||
| 语义路由 | TF-IDF 匹配 75 技能关键词 | butler_router.rs | ✅ |
|
|
||||||
| 管家主动引导 | 冷启动 4 阶段 + 追问 | use-cold-start.ts | ✅ |
|
|
||||||
| 痛点积累 | 对话中提取痛点 → 方案建议 | pain_storage.rs | ✅ |
|
|
||||||
| 双模式 UI | simple/professional 渐进式 | uiModeStore.ts | ✅ |
|
|
||||||
| 行业配置 | 4 内置行业 + 自定义 | industryStore.ts | ✅ |
|
|
||||||
| 跨会话连续 | 痛点回访 + 经验检索 | butlerStore.ts | ✅ |
|
|
||||||
| ButlerContext 注入 | XML fencing 增强系统提示 | ButlerRouter@80 | ✅ |
|
|
||||||
| 个性化检测 | personality_detector 自动分类 | personality_detector.rs | ✅ |
|
|
||||||
| 方案生成 | 痛点 → 解决方案建议 | solution_generator.rs | ✅ |
|
|
||||||
|
|
||||||
## 测试链路
|
|
||||||
|
|
||||||
| 功能 | 测试文件 | 测试数 | 覆盖状态 |
|
|
||||||
|------|---------|--------|---------|
|
|
||||||
| 管家路由 | intelligence/butler_router.rs (middleware/) | 12 | ✅ |
|
|
||||||
| 冷启动 | intelligence/cold_start_prompt.rs | 7 | ✅ |
|
|
||||||
| 痛点聚合 | intelligence/pain_aggregator.rs | 9 | ✅ |
|
|
||||||
| 痛点存储 | intelligence/pain_storage.rs | 11 | ✅ |
|
|
||||||
| 方案生成 | intelligence/solution_generator.rs | 5 | ✅ |
|
|
||||||
| 个性化 | intelligence/personality_detector.rs | 8 | ✅ |
|
|
||||||
| 触发信号 | intelligence/triggers.rs | 7 | ✅ |
|
|
||||||
| 用户画像 | intelligence/user_profiler.rs | 9 | ✅ |
|
|
||||||
| 经验 | intelligence/experience.rs | 9 | ✅ |
|
|
||||||
| 身份 | intelligence/identity.rs | 5 | ✅ |
|
|
||||||
| 反思 | intelligence/reflection.rs | 4 | ✅ |
|
|
||||||
| 压缩 | intelligence/trajectory_compressor.rs | 11 | ✅ |
|
|
||||||
| 验证 | intelligence/validation.rs | 5 | ✅ |
|
|
||||||
| 提取适配 | intelligence/extraction_adapter.rs | 3 | ✅ |
|
|
||||||
| **合计** | 15 文件 | **99** | |
|
|
||||||
|
|
||||||
## 关联模块
|
|
||||||
|
|
||||||
- [[middleware]] — ButlerRouter 是中间件链中的第一层
|
|
||||||
- [[chat]] — 消息流经过管家路由增强
|
|
||||||
- [[memory]] — 痛点存储在 memory 子系统
|
|
||||||
- [[hands-skills]] — 语义路由使用 75 个技能的 TF-IDF
|
|
||||||
|
|
||||||
## 关键文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `crates/zclaw-runtime/src/middleware/butler_router.rs` | 管家路由器 + ButlerRouterBackend trait |
|
| `crates/zclaw-runtime/src/middleware/butler_router.rs` | 管家路由器 + ButlerRouterBackend trait |
|
||||||
| `crates/zclaw-skills/src/semantic_router.rs` | SemanticSkillRouter TF-IDF 实现 |
|
| `crates/zclaw-kernel/src/kernel/mod.rs:196-231` | SemanticRouterAdapter 桥接 (kernel -> skills) |
|
||||||
| `crates/zclaw-kernel/src/kernel/mod.rs:196-231` | SemanticRouterAdapter 桥接 |
|
| `desktop/src-tauri/src/intelligence/pain_storage.rs` | 痛点双写 (内存 Vec + SQLite) |
|
||||||
| `crates/zclaw-kernel/src/intelligence/pain_storage.rs` | 痛点双写 (内存+SQLite) |
|
| `desktop/src-tauri/src/intelligence/solution_generator.rs` | 方案生成 |
|
||||||
| `crates/zclaw-kernel/src/intelligence/solution_generator.rs` | 方案生成 |
|
|
||||||
| `desktop/src/hooks/use-cold-start.ts` | 冷启动 4 阶段 |
|
| `desktop/src/hooks/use-cold-start.ts` | 冷启动 4 阶段 |
|
||||||
| `desktop/src/store/uiModeStore.ts` | 双模式切换 |
|
| `desktop/src/store/uiModeStore.ts` | 双模式切换 |
|
||||||
| `desktop/src/components/SimpleSidebar.tsx` | 简洁模式侧边栏 |
|
| `desktop/src/store/industryStore.ts` | 行业配置 (persist, 离线缓存) |
|
||||||
| `desktop/src/components/ButlerPanel/index.tsx` | 管家面板主组件 (洞察/方案/记忆/行业) |
|
| `desktop/src/components/ButlerPanel/index.tsx` | 管家面板 (洞察/方案/记忆/行业) |
|
||||||
| `desktop/src/components/ButlerPanel/MemorySection.tsx` | 记忆展示+用户画像卡片 (viking_read L1 + agent_get) |
|
| `desktop/src/components/ButlerPanel/MemorySection.tsx` | 记忆展示 + 用户画像卡片 |
|
||||||
| `desktop/src/components/ButlerPanel/InsightsSection.tsx` | 痛点洞察列表 |
|
|
||||||
| `desktop/src/components/ButlerPanel/ProposalsSection.tsx` | 方案建议列表 |
|
|
||||||
|
|
||||||
## 已知问题
|
### ButlerRouter 数据流
|
||||||
|
|
||||||
- ✅ **行业 API 字段名不一致** — BUG-L1 已修复。`pain_seeds` vs `pain_seed_categories`
|
```
|
||||||
- ✅ **industryStore 无组件导入** — V13-GAP-02 已修复 (已连接 ButlerPanel)
|
用户消息
|
||||||
- ✅ **行业选择 500** — 类型不匹配已修复
|
-> ButlerRouter@80 (middleware/butler_router.rs)
|
||||||
- ✅ **桌面端未接入 Knowledge Search** — V13-GAP-03 已修复 (saas-knowledge mixin)
|
-> SemanticRouterAdapter -> SemanticSkillRouter (TF-IDF)
|
||||||
- ⚠️ **SkillIndex 条件注册** — 无技能时中间件不注册,长期观察
|
-> 返回 RoutingHint { category, confidence, skill_id }
|
||||||
|
-> 增强 system prompt (匹配技能上下文 + <butler-context> XML fencing)
|
||||||
|
-> LLM 响应
|
||||||
|
-> PainAggregator 提取痛点 -> PainStorage (内存+SQLite)
|
||||||
|
-> SolutionGenerator 基于痛点生成方案
|
||||||
|
```
|
||||||
|
|
||||||
|
### 集成契约
|
||||||
|
|
||||||
|
| 方向 | 模块 | 接口 / 触发点 |
|
||||||
|
|------|------|---------------|
|
||||||
|
| Registered as | middleware: ButlerRouter@80 | `kernel/mod.rs:create_middleware_chain()` |
|
||||||
|
| Calls -> | hands-skills: SemanticSkillRouter | TF-IDF 技能路由,返回 RoutingHint |
|
||||||
|
| Calls -> | memory: ExperienceStore, UserProfileStore | 痛点/经验检索,pre_hook 注入活跃痛点 |
|
||||||
|
| Called by <- | middleware chain | Every chat request |
|
||||||
|
|
||||||
|
### Intelligence 层 (16 .rs 文件)
|
||||||
|
|
||||||
|
`desktop/src-tauri/src/intelligence/` 包含: compactor(5 cmd), heartbeat(10 cmd), identity(16 cmd), pain_aggregator(5 cmd), reflection(7 cmd), experience, extraction_adapter, health_snapshot, pain_storage, personality_detector, solution_generator, trajectory_compressor, triggers, user_profiler, validation。
|
||||||
|
|
||||||
|
管家相关 Tauri 命令 (5 个, @reserved): `butler_list_pain_points`, `butler_record_pain_point`, `butler_generate_solution`, `butler_list_proposals`, `butler_update_proposal_status`。
|
||||||
|
|
||||||
|
## 3. 代码逻辑
|
||||||
|
|
||||||
|
### 语义关键词匹配
|
||||||
|
|
||||||
|
ButlerRouter 维护行业关键词配置 (`Arc<RwLock<Vec<IndustryKeywordConfig>>>`):
|
||||||
|
- 4 内置行业: 医疗/教育/制衣/电商,各有 keywords/prompt/pain_seed_categories
|
||||||
|
- 动态注入: SaaS `industry/fullConfig` 端点拉取自定义行业
|
||||||
|
- 匹配流程: message -> 关键词命中 -> 识别行业 -> 注入行业 prompt 增强
|
||||||
|
|
||||||
|
### XML fencing 注入格式
|
||||||
|
|
||||||
|
```
|
||||||
|
<butler-context>
|
||||||
|
<active-pain-points>...</active-pain-points>
|
||||||
|
<related-experience>...</related-experience>
|
||||||
|
<industry>医疗</industry>
|
||||||
|
<routing-hint confidence="0.85">data-analysis</routing-hint>
|
||||||
|
</butler-context>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 跨会话连续性 (pre_hook)
|
||||||
|
|
||||||
|
新会话开始时,ButlerRouter 的 pre_hook 注入:
|
||||||
|
1. 活跃痛点: 从 PainStorage 加载未解决痛点
|
||||||
|
2. 相关经验: 通过 ExperienceStore FTS5 检索
|
||||||
|
3. 用户画像: UserProfileStore (data.db) 提供行业/角色/偏好
|
||||||
|
4. 记忆展示: ButlerPanel -> MemorySection -> viking_ls + viking_read(L1) (详见 [[memory]])
|
||||||
|
|
||||||
|
### 冷启动 4 阶段
|
||||||
|
|
||||||
|
```
|
||||||
|
idle -> (检测新用户) -> greeting_sent -> waiting_response -> completed
|
||||||
|
```
|
||||||
|
|
||||||
|
自动检测 -> 发送欢迎消息 -> 等待响应 -> 完成引导。入口: `use-cold-start.ts`。
|
||||||
|
|
||||||
|
### 不变量
|
||||||
|
|
||||||
|
- ButlerRouter@80 在所有能力类中间件之前执行,确保 routing hint 可被后续中间件利用
|
||||||
|
- PainStorage 双写: 内存 Vec 热缓存 + SQLite 持久层,通过全局 PAIN_STORAGE 单例
|
||||||
|
- UI 双模式: simple(默认) 隐藏高级功能,professional 完整面板。切换: `uiModeStore.ts`
|
||||||
|
|
||||||
|
## 4. 活跃问题 + 陷阱
|
||||||
|
|
||||||
|
### 活跃
|
||||||
|
|
||||||
|
| 问题 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| SkillIndex 条件注册 | 长期观察 | 无技能时中间件不注册,需关注空技能场景一致性 |
|
||||||
|
|
||||||
|
### 历史 (已修复)
|
||||||
|
|
||||||
|
| 问题 | 修复 |
|
||||||
|
|------|------|
|
||||||
|
| 行业 API 字段名不一致 (pain_seeds vs pain_seed_categories) | BUG-L1 已修复 |
|
||||||
|
| industryStore 无组件导入 | V13-GAP-02 已修复,ButlerPanel 已连接 |
|
||||||
|
| 行业选择 500 | 类型不匹配已修复 |
|
||||||
|
| 桌面端未接入 Knowledge Search | V13-GAP-03 已修复 |
|
||||||
|
| DataMasking 中间件过度匹配 | 04-22 移除整个中间件 |
|
||||||
|
|
||||||
|
## 5. 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更 | 关联 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2026-04-22 | Wiki 5-section 重构: 215->~190 行,移除重复内容引用 [[memory]]/[[hands-skills]] | wiki/ |
|
||||||
|
| 2026-04-22 | 跨会话记忆断裂修复: profile_store 连接 + 双数据库统一 | commit adf0251 |
|
||||||
|
| 2026-04-17 | E2E 全系统验证 129 链路: 7 项 Bug 修复含行业字段/记忆去重 | 79.1% 通过率 |
|
||||||
|
| 2026-04-12 | 行业配置+管家主动性全栈: 4内置行业+动态关键词+跨会话连续性+XML fencing | 全栈 5 Phase |
|
||||||
|
| 2026-04-09 | 管家模式 6 交付物完成: ButlerRouter+冷启动+简洁UI+桥测试+文档 | 43 tests PASS |
|
||||||
|
|
||||||
|
### 测试概览
|
||||||
|
|
||||||
|
| 功能 | 测试文件 | 测试数 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 管家路由 | intelligence/butler_router.rs | 12 |
|
||||||
|
| 冷启动 | cold_start_prompt.rs | 7 |
|
||||||
|
| 痛点聚合+存储 | pain_aggregator + pain_storage | 20 |
|
||||||
|
| 方案生成 | solution_generator.rs | 5 |
|
||||||
|
| 个性化 | personality_detector.rs | 8 |
|
||||||
|
| 其他 (触发/画像/经验/身份/反思/压缩/验证/提取) | 8 文件 | 47 |
|
||||||
|
| **合计** | **15 文件** | **99** |
|
||||||
|
|||||||
223
wiki/chat.md
223
wiki/chat.md
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 聊天系统
|
title: 聊天系统
|
||||||
updated: 2026-04-21
|
updated: 2026-04-23
|
||||||
status: active
|
status: active
|
||||||
tags: [module, chat, stream]
|
tags: [module, chat, stream]
|
||||||
---
|
---
|
||||||
@@ -9,149 +9,148 @@ tags: [module, chat, stream]
|
|||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[routing]] [[saas]] [[butler]]
|
> 从 [[index]] 导航。关联模块: [[routing]] [[saas]] [[butler]]
|
||||||
|
|
||||||
## 设计思想
|
## 1. 设计决策
|
||||||
|
|
||||||
**核心问题: 3 种运行环境需要 3 种 ChatStream 实现。**
|
| 决策 | 原因 |
|
||||||
|
|------|------|
|
||||||
|
| 3 种 ChatStream 实现 | 覆盖 3 种运行环境: KernelClient(Tauri) / SaaSRelay(浏览器) / GatewayClient(外部进程) |
|
||||||
|
| 5 Store 拆分 | 原 908 行 ChatStore → stream/conversation/message/chat/artifact,单一职责 |
|
||||||
|
| 5 分钟超时守护 | 防止流挂起: kernel-chat.ts:76,超时自动 cancelStream |
|
||||||
|
| 统一回调接口 | 3 种实现共享 `{ onDelta, onThinkingDelta, onTool, onHand, onComplete, onError }` |
|
||||||
|
| LLM 动态建议 | 替换硬编码关键词匹配,用 LLM 生成个性化建议(1深入追问+1实用行动+1管家关怀),4路并行预取智能上下文 |
|
||||||
|
|
||||||
| 环境 | 实现 | 传输 | 为什么 |
|
### ChatStream 实现
|
||||||
|------|------|------|--------|
|
|
||||||
| 桌面端 (Tauri) | KernelClient | Tauri Event | 内置 Kernel,但 baseUrl 可指向 SaaS relay |
|
|
||||||
| 桌面端 (Tauri + SaaS) | KernelClient + relay | Tauri Event → SaaS | 主路径: Token Pool 中转 |
|
|
||||||
| 浏览器端 | SaaSRelayGatewayClient | HTTP SSE | 无 Tauri 运行时 |
|
|
||||||
| 外部 Gateway | GatewayClient | WebSocket | 独立进程部署 |
|
|
||||||
|
|
||||||
**统一接口**: 3 种实现共享同一套回调:
|
| 环境 | 实现 | 传输 |
|
||||||
|
|------|------|------|
|
||||||
|
| Tauri + SaaS (主路径) | KernelClient + relay | Tauri Event → SaaS Token Pool → LLM |
|
||||||
|
| Tauri 本地 | KernelClient | Tauri Event → Kernel → LLM 直连 |
|
||||||
|
| 浏览器端 | SaaSRelayGatewayClient | HTTP SSE → SaaS → LLM |
|
||||||
|
| 外部 Gateway | GatewayClient | WebSocket |
|
||||||
|
|
||||||
```ts
|
## 2. 关键文件 + 数据流
|
||||||
{ onDelta, onThinkingDelta, onTool, onHand, onComplete, onError }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 功能清单
|
### 核心文件
|
||||||
|
|
||||||
| 功能 | 描述 | 入口文件 | 状态 |
|
| 文件 | 职责 |
|
||||||
|------|------|----------|------|
|
|------|------|
|
||||||
| 发送消息 | 流式/非流式,支持 thinking | streamStore.ts | ✅ |
|
| `desktop/src/store/chat/streamStore.ts` | 流式消息编排、发送、取消、LLM 动态建议生成 |
|
||||||
| 流式响应 | SSE/Tauri Event 实时推送 | streamStore.ts | ✅ |
|
| `desktop/src/store/chat/conversationStore.ts` | 会话管理、当前模型、sessionKey |
|
||||||
| 模型切换 | 运行时切换 LLM 模型 | conversationStore.ts | ✅ |
|
| `desktop/src/store/chat/messageStore.ts` | 消息持久化 (IndexedDB) |
|
||||||
| 上下文管理 | 会话持久化 + 跨会话恢复 | conversationStore.ts | ✅ |
|
| `desktop/src/lib/kernel-chat.ts` | KernelClient ChatStream (Tauri) |
|
||||||
| 取消流式 | 原子标志位中断 | kernel-chat.ts | ✅ |
|
| `desktop/src/lib/suggestion-context.ts` | 4路并行智能上下文拉取 (用户画像/痛点/经验/技能匹配) |
|
||||||
| Agent 聊天 | 指定 agent_id 独立对话 | streamStore.ts | ✅ |
|
| `desktop/src/lib/cold-start-mapper.ts` | 冷启动配置映射 (行业检测/命名/个性/技能) |
|
||||||
| 课堂聊天 | 教育场景专用 | classroomStore.ts | ✅ |
|
| `desktop/src/components/ChatArea.tsx` | 聊天区域 UI |
|
||||||
| 消息持久化 | IndexedDB 存储 | messageStore.ts | ✅ |
|
| `desktop/src/components/ai/SuggestionChips.tsx` | 动态建议芯片展示 |
|
||||||
| 聊天产物 | 附件/代码块管理 | artifactStore.ts | ✅ |
|
| `crates/zclaw-runtime/src/loop_runner.rs` | Rust 主聊天循环 + 中间件链 |
|
||||||
|
|
||||||
## 代码逻辑
|
|
||||||
|
|
||||||
### 发送消息流
|
### 发送消息流
|
||||||
|
|
||||||
入口: `streamStore.sendMessage(content)` → `store/chat/streamStore.ts`
|
```
|
||||||
|
用户输入 → ChatPanel.tsx
|
||||||
|
→ streamStore.sendMessage(content)
|
||||||
|
→ effectiveSessionKey = conversationStore.sessionKey || uuid()
|
||||||
|
→ effectiveAgentId = resolveGatewayAgentId(currentAgent)
|
||||||
|
→ getClient().chatStream(content, callbacks, { sessionKey, agentId, chatMode })
|
||||||
|
→ [KernelClient] Tauri invoke('agent_chat_stream')
|
||||||
|
→ Kernel → loop_runner → 14层中间件 → LLM Driver
|
||||||
|
→ Tauri Event emit('chat-response-delta')
|
||||||
|
→ onDelta → streamStore 追加 delta → UI 渲染
|
||||||
|
→ onComplete → conversationStore 持久化 → messageStore 写 IndexedDB
|
||||||
|
→ [SaaSRelay] POST /api/v1/relay/chat/completions → SSE
|
||||||
|
→ [GatewayClient] WebSocket send → onmessage
|
||||||
|
→ 5 分钟超时守护 (kernel-chat.ts:76)
|
||||||
|
```
|
||||||
|
|
||||||
```
|
### 集成契约
|
||||||
sendMessage(content)
|
|
||||||
→ effectiveSessionKey = conversationStore.sessionKey || uuid()
|
| 方向 | 模块 | 接口 | 说明 |
|
||||||
→ effectiveAgentId = resolveGatewayAgentId(currentAgent)
|
|------|------|------|------|
|
||||||
→ client.chatStream(content, callbacks, { sessionKey, agentId, chatMode })
|
| Calls -> | routing | `getClient()` | 确定走哪条 ChatStream |
|
||||||
→ KernelClient: Tauri invoke('kernel_chat', ...)
|
| Calls -> | middleware | Through loop_runner | 14 层 runtime 中间件链 |
|
||||||
→ Kernel → loop_runner → LLM Driver
|
| Called by <- | UI | ChatPanel.tsx | 用户发送消息、取消流式 |
|
||||||
→ 如果 baseUrl 指向 SaaS relay → 请求发往 Token Pool → LLM
|
| Emits -> | streamStore | Tauri Event `chat-response-delta` | 流式增量更新 |
|
||||||
→ 如果 baseUrl 指向 LLM 直连 → 请求直接发往 LLM
|
|
||||||
→ Tauri Event emit('chat-response-delta', ...)
|
## 3. 代码逻辑
|
||||||
→ onDelta(text) → streamStore 追加 delta
|
|
||||||
→ onTool(tool) → toolStore 更新
|
|
||||||
→ onHand(hand) → handStore 更新
|
|
||||||
→ onComplete() → conversationStore 持久化
|
|
||||||
→ SaaSRelay: HTTP POST /api/v1/relay/chat/completions → SSE
|
|
||||||
→ GatewayClient: WebSocket send → onmessage
|
|
||||||
→ 5 分钟超时守护 (kernel-chat.ts:76) 防止流挂起
|
|
||||||
```
|
|
||||||
|
|
||||||
### Store 拆分 (5 Store)
|
### Store 拆分 (5 Store)
|
||||||
|
|
||||||
原来 908 行的 ChatStore 已拆分为:
|
|
||||||
|
|
||||||
| Store | 文件 | 职责 |
|
| Store | 文件 | 职责 |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| streamStore | `store/chat/streamStore.ts` | 流式消息编排、发送、取消 |
|
| streamStore | `store/chat/streamStore.ts` | 流式编排、发送、取消 |
|
||||||
| conversationStore | `store/chat/conversationStore.ts` | 会话管理、当前模型 |
|
| conversationStore | `store/chat/conversationStore.ts` | 会话管理、当前模型 |
|
||||||
| messageStore | `store/chat/messageStore.ts` | 消息持久化 |
|
| messageStore | `store/chat/messageStore.ts` | 消息持久化 (IndexedDB) |
|
||||||
| chatStore | `store/chat/chatStore.ts` | 聊天通用状态 |
|
| chatStore | `store/chat/chatStore.ts` | 聊天通用状态 |
|
||||||
| artifactStore | `store/chat/artifactStore.ts` | 聊天产物/附件 |
|
| artifactStore | `store/chat/artifactStore.ts` | 聊天产物/附件 |
|
||||||
|
|
||||||
### 前端 Tauri 命令映射
|
### 流式事件类型
|
||||||
|
|
||||||
```
|
`agent_chat_stream` Tauri Event emit 的 tagged union:
|
||||||
kernel_chat / agent_chat / agent_chat_stream → 发送消息
|
|
||||||
cancel_stream → 取消流式响应
|
`Delta` / `ThinkingDelta` / `ToolStart` / `ToolEnd` / `HandStart` / `HandEnd` / `SubtaskStatus` / `IterationStart` / `Complete` / `Error`
|
||||||
```
|
|
||||||
|
|
||||||
### 模型切换
|
### 模型切换
|
||||||
|
|
||||||
```
|
```
|
||||||
UI 选择模型 → conversationStore.currentModel = newModel
|
UI 选择模型 → conversationStore.currentModel = newModel
|
||||||
→ 下次 sendMessage 时,connectionStore 读取 currentModel
|
→ 下次 sendMessage → getClient() 读取 currentModel
|
||||||
→ SaaS 模式: relay 白名单验证 → 可用则切换
|
→ SaaS 模式: relay 白名单验证 → 可用则切换
|
||||||
→ 本地模式: 直接使用用户配置的模型
|
→ 本地模式: 直接使用用户配置的模型
|
||||||
```
|
```
|
||||||
|
|
||||||
## API 接口
|
### 不变量
|
||||||
|
|
||||||
|
- sessionKey 在会话内必须一致 (UUID 生成一次,持久化 IndexedDB)
|
||||||
|
- cancelStream 设置原子标志位,与 onDelta 回调无竞态
|
||||||
|
- 3 种 ChatStream 共享同一套回调接口,上层代码无需感知实现差异
|
||||||
|
- 消息持久化走 messageStore → IndexedDB,与流式渲染解耦
|
||||||
|
- 动态建议 4 路并行预取 (userProfile/painPoints/experiences/skillMatch),500ms 超时降级为空串
|
||||||
|
- 建议生成与 memory extraction 解耦 — 不等 memory LLM 调用完成即启动建议
|
||||||
|
|
||||||
|
### LLM 动态建议
|
||||||
|
|
||||||
|
```
|
||||||
|
sendMessage → isStreaming=true + _activeSuggestionContextPrefetch = fetchSuggestionContext(...)
|
||||||
|
→ 流式响应中 prefetch 在后台执行
|
||||||
|
onComplete → createCompleteHandler
|
||||||
|
→ generateLLMSuggestions(prefetchedContext) — 立即启动不等 memory
|
||||||
|
→ prompt: 1 深入追问 + 1 实用行动 + 1 管家关怀
|
||||||
|
→ memory/reflection 后台独立运行 (Promise.all)
|
||||||
|
→ SuggestionChips 渲染
|
||||||
|
```
|
||||||
|
|
||||||
### Tauri 命令
|
### Tauri 命令
|
||||||
|
|
||||||
**聊天核心** (`desktop/src-tauri/src/kernel_commands/chat.rs`):
|
| 命令 | 说明 |
|
||||||
|
|
||||||
| 命令 | 参数 | 返回值 | 说明 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `agent_chat` | ChatRequest { agent_id, message, thinking_enabled?, model? } | `ChatResponse` | 非流式聊天 |
|
|
||||||
| `agent_chat_stream` | StreamChatRequest { +session_id } | Tauri Event 流 | 流式聊天(主路径) |
|
|
||||||
| `cancel_stream` | session_id | `()` | 取消当前流式 |
|
|
||||||
|
|
||||||
**课堂聊天** (`desktop/src-tauri/src/classroom_commands/chat.rs`):
|
|
||||||
|
|
||||||
| 命令 | 参数 | 返回值 | 说明 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `classroom_chat` | { classroom_id, user_message, scene_context? } | `Vec<ClassroomChatMessage>` | 课堂对话 |
|
|
||||||
| `classroom_chat_history` | classroom_id | `Vec<ClassroomChatMessage>` | 历史消息 |
|
|
||||||
|
|
||||||
**流式事件类型** (agent_chat_stream emit):
|
|
||||||
`Delta` / `ThinkingDelta` / `ToolStart` / `ToolEnd` / `HandStart` / `HandEnd` / `SubtaskStatus` / `IterationStart` / `Complete` / `Error`
|
|
||||||
|
|
||||||
### SaaS Relay 路由
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| POST | `/api/v1/relay/chat/completions` | OpenAI 兼容格式,支持 session_key/agent_id 透传 |
|
|
||||||
|
|
||||||
## 测试链路
|
|
||||||
|
|
||||||
| 功能 | 测试文件 | 测试数 | 覆盖状态 |
|
|
||||||
|------|---------|--------|---------|
|
|
||||||
| ChatStore 完整流程 | `tests/desktop/chatStore.test.ts` | 11 | ✅ sendMessage/sessionKey/agent隔离/stream相关 |
|
|
||||||
| 类型契约测试 | `tests/seam/chat-seam.test.ts` | 8 | ✅ StreamChatRequest/ChatResponse camelCase/Event tagged union |
|
|
||||||
|
|
||||||
## 关联模块
|
|
||||||
|
|
||||||
- [[routing]] — 路由决定使用哪种 client
|
|
||||||
- [[saas]] — Token Pool 提供模型和 API Key
|
|
||||||
- [[butler]] — ButlerRouter 中间件增强 system prompt
|
|
||||||
- [[middleware]] — 消息经过 15 层 runtime 中间件处理
|
|
||||||
- [[memory]] — 对话内容可能触发记忆提取
|
|
||||||
|
|
||||||
## 关键文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|------|------|
|
||||||
| `desktop/src/store/chat/streamStore.ts` | 流式消息编排 |
|
| `agent_chat_stream` | 流式聊天 (主路径) |
|
||||||
| `desktop/src/store/chat/conversationStore.ts` | 会话管理 |
|
| `agent_chat` | 非流式聊天 |
|
||||||
| `desktop/src/store/chat/artifactStore.ts` | 聊天产物管理 |
|
| `cancel_stream` | 取消当前流式响应 |
|
||||||
| `desktop/src/lib/kernel-chat.ts` | Kernel ChatStream (Tauri) |
|
| `classroom_chat` | 课堂场景对话 |
|
||||||
| `desktop/src/lib/saas-relay-client.ts` | SaaS Relay ChatStream |
|
|
||||||
| `desktop/src/lib/gateway-client.ts` | Gateway ChatStream (WS) |
|
|
||||||
| `desktop/src/components/ChatArea.tsx` | 聊天区域 UI |
|
|
||||||
| `crates/zclaw-runtime/src/loop_runner.rs` | Rust 主聊天循环 |
|
|
||||||
|
|
||||||
## 已知问题
|
## 4. 活跃问题 + 注意事项
|
||||||
|
|
||||||
- ⚠️ **B-CHAT-07: 混合域截断** — P2 Open。跨域消息时可能截断上下文
|
| 问题 | 状态 | 说明 |
|
||||||
- ✅ **SSE Token 统计为 0** — P2 已修复 (SseUsageCapture stream_done flag)
|
|------|------|------|
|
||||||
- ✅ **Tauri invoke 参数名 snake_case** — P1 已修复 (commit f6c5dd2)
|
| after_tool_call 中间件未调用 | ✅ 已修复 (04-24) | 流式+非流式均添加调用,ToolErrorMiddleware/ToolOutputGuard 现在生效 |
|
||||||
- ✅ **Provider Key 解密致 relay 500** — P1 已修复 (commit b69dc61)
|
| stream_errored 跳过所有工具 | ✅ 已修复 (04-24) | 完整工具照常执行,不完整工具发送取消事件 |
|
||||||
|
| B-CHAT-07 混合域截断 | P2 Open | 跨域消息时可能截断上下文 |
|
||||||
|
| SSE Token 统计为 0 | ✅ 已修复 | SseUsageCapture stream_done flag |
|
||||||
|
| Tauri invoke 参数名 | ✅ 已修复 (f6c5dd2) | camelCase 格式 |
|
||||||
|
| Provider Key 解密 | ✅ 已修复 (b69dc61) | warn+skip + heal |
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
- 辅助 LLM 调用 (记忆摘要/提取、管家路由) 复用 `kernel_init` 的 model+base_url,与聊天同链路
|
||||||
|
- 课堂聊天是独立 Tauri 命令 (`classroom_chat`),不走 `agent_chat_stream`
|
||||||
|
- Agent tab 已移除 — 跨会话身份由 soul.md 接管,不再通过 RightPanel 管理
|
||||||
|
|
||||||
|
## 5. 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| 04-24 | 工具调用 P0 修复: after_tool_call 中间件接入(流式+非流式) + stream_errored 工具抢救(完整工具执行+不完整工具取消) |
|
||||||
|
| 04-24 | 产物系统优化: MarkdownRenderer 提取共享 + ArtifactPanel react-markdown 渲染 + 文件选择器下拉 + 数据源扩展(file_write/str_replace 两路径) + artifactStore IndexedDB 持久化 |
|
||||||
|
| 04-23 | 建议 prefetch: sendMessage 时启动 context 预取,流结束后立即消费,不等 memory extraction |
|
||||||
|
| 04-23 | 建议 prompt 重写: 1深入追问+1实用行动+1管家关怀,上下文窗口 6→20 条 |
|
||||||
|
| 04-23 | 身份信号: detectAgentNameSuggestion 前端即时检测 + RightPanel 监听 Tauri 事件刷新名称 |
|
||||||
|
| 04-23 | Agent tab 移除: RightPanel 清理 ~280 行 dead code,身份由 soul.md 接管 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 数据模型
|
title: 数据模型
|
||||||
updated: 2026-04-21
|
updated: 2026-04-22
|
||||||
status: active
|
status: active
|
||||||
tags: [module, database, schema]
|
tags: [module, database, schema]
|
||||||
---
|
---
|
||||||
@@ -9,82 +9,78 @@ tags: [module, database, schema]
|
|||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[saas]] [[memory]]
|
> 从 [[index]] 导航。关联模块: [[saas]] [[memory]]
|
||||||
|
|
||||||
## 设计思想
|
## 1. 设计决策
|
||||||
|
|
||||||
**双存储架构: PostgreSQL (SaaS 多租户) + SQLite (本地单用户)**
|
**WHY 双存储架构**: PostgreSQL 适合 SaaS 多租户场景 — 42 表支持用户隔离、计费、审计、知识库。SQLite 适合桌面端单用户 — 零配置、嵌入式、本地记忆无需网络。两者完全隔离,通过 SaaS relay + 配置同步桥接。
|
||||||
|
|
||||||
- PostgreSQL: SaaS 后端,42 表,42 CREATE TABLE,支持多用户/多 Agent/计费/知识库
|
**WHY 42 PostgreSQL 表**: 按领域划分为认证(5)、Provider(6)、计费(5)、知识库(7)、其他(19) 五大域,每个域内部高内聚,域间通过外键约束保证引用完整性。
|
||||||
- SQLite: 本地桌面端,记忆存储 + 会话持久化 + FTS5 全文索引
|
|
||||||
- 两者通过 SaaS relay + 配置同步实现数据桥接
|
|
||||||
|
|
||||||
## 功能清单
|
**WHY FTS5 (SQLite 全文搜索)**: 本地记忆搜索需支持中文。FTS5 的 trigram 分词器原生支持 CJK 字符,无需外部依赖。配合 TF-IDF 权重排序,兼顾搜索精度和零配置部署。
|
||||||
|
|
||||||
| 功能 | 描述 | 存储层 | 状态 |
|
**WHY 迁移系统**: SaaS 用 SQL 文件迁移 (21 up + 17 down),本地 SQLite 用 schema.rs 程序化迁移。两种策略分别匹配各自部署场景 — PG 需要运维可控的 SQL 脚本,SQLite 需要应用内自动迁移。
|
||||||
|------|------|--------|------|
|
|
||||||
| 用户管理 | 账户 CRUD + 角色权限 | PostgreSQL | ✅ |
|
|
||||||
| 认证数据 | JWT + 密码 + TOTP | PostgreSQL | ✅ |
|
|
||||||
| 计费系统 | 订阅/支付/发票/配额 | PostgreSQL | ✅ |
|
|
||||||
| 知识库 | 分类/条目/向量/结构化 | PostgreSQL | ✅ |
|
|
||||||
| 模型管理 | Provider/模型/Key 池 | PostgreSQL | ✅ |
|
|
||||||
| Agent 配置 | 模板/分配/行业 | PostgreSQL | ✅ |
|
|
||||||
| 本地会话 | 会话/消息持久化 | SQLite | ✅ |
|
|
||||||
| 本地记忆 | 记忆 CRUD + FTS5 搜索 | SQLite | ✅ |
|
|
||||||
| 用户画像 | 结构化偏好/兴趣 | SQLite | ✅ |
|
|
||||||
| 轨迹记录 | 工具调用链 + 压缩摘要 | SQLite | ✅ |
|
|
||||||
|
|
||||||
## 代码逻辑
|
**WHY pgvector (deferred)**: knowledge_chunks 表已创建 pgvector 索引,为将来 embedding 语义搜索预留。当前记忆搜索走 FTS5 + TF-IDF,足够满足中文场景。embedding 激活需要 LLM embedding API 调用,尚未排期。
|
||||||
|
|
||||||
### PostgreSQL (SaaS) — 42 表
|
## 2. 关键文件 + 数据流
|
||||||
|
|
||||||
**迁移文件**: `crates/zclaw-saas/migrations/` (21 up + 17 down)
|
### 核心文件
|
||||||
|
|
||||||
#### 认证与账户域 (5 表)
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `crates/zclaw-saas/migrations/` | 21 up SQL 迁移 (42 CREATE TABLE) |
|
||||||
|
| `crates/zclaw-saas/src/models/` | PostgreSQL 数据模型 struct 定义 |
|
||||||
|
| `crates/zclaw-memory/src/schema.rs` | SQLite schema 定义 + 程序化迁移 |
|
||||||
|
| `crates/zclaw-memory/src/store.rs` | SQLite 会话/消息存储 (20 tests) |
|
||||||
|
| `crates/zclaw-memory/src/user_profile_store.rs` | 用户画像存储 (20 tests) |
|
||||||
|
| `crates/zclaw-memory/src/trajectory_store.rs` | 轨迹存储 (9 tests) |
|
||||||
|
| `crates/zclaw-growth/src/storage/sqlite.rs` | FTS5 + TF-IDF 记忆存储 (6 tests) |
|
||||||
|
| `docker-compose.yml` | PostgreSQL 容器配置 |
|
||||||
|
|
||||||
| 表 | 说明 | 关键关系 |
|
### 双库架构
|
||||||
|----|------|---------|
|
|
||||||
| `accounts` | 用户账户 (邮箱/密码/pwv/角色) | → api_tokens, operation_logs, subscriptions |
|
|
||||||
| `api_tokens` | API 访问令牌 | FK → accounts |
|
|
||||||
| `roles` | 角色定义 | — |
|
|
||||||
| `permission_templates` | 权限模板 | — |
|
|
||||||
| `refresh_tokens` | JWT 刷新令牌 (单次使用) | FK → accounts |
|
|
||||||
|
|
||||||
#### Provider 与模型域 (6 表)
|
```
|
||||||
|
PostgreSQL (SaaS, 42 表) SQLite (本地, 2 库)
|
||||||
|
┌─────────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ 认证: accounts/tokens │ │ data.db: │
|
||||||
|
│ Provider: keys/models │ │ agents, sessions, │
|
||||||
|
│ 计费: plans/invoices │ │ messages, kv_store, │
|
||||||
|
│ 知识库: items/chunks │ │ facts, user_profiles, │
|
||||||
|
│ 配置/日志/Webhook │ │ trajectory_events, │
|
||||||
|
│ 行业: industries │ │ hand_runs │
|
||||||
|
└─────────────────────────┘ ├──────────────────────────┤
|
||||||
|
↑ SaaS API │ memories.db: │
|
||||||
|
│ relay_tasks │ memories (FTS5), │
|
||||||
|
│ usage_records │ metadata │
|
||||||
|
└──────────────────────────┘
|
||||||
|
↑ 本地 API
|
||||||
|
```
|
||||||
|
|
||||||
| 表 | 说明 | 关键关系 |
|
### 集成契约
|
||||||
|----|------|---------|
|
|
||||||
| `providers` | LLM Provider 配置 | → models, provider_keys |
|
|
||||||
| `models` | 模型定义 (白名单) | FK → providers |
|
|
||||||
| `provider_keys` | 加密 API Key 池 | FK → providers, accounts |
|
|
||||||
| `key_usage_window` | Key RPM/TPM 滑动窗口 | — |
|
|
||||||
| `model_groups` | 模型组 (故障转移) | → model_group_members |
|
|
||||||
| `model_group_members` | 组成员 | FK → model_groups, providers |
|
|
||||||
|
|
||||||
#### 计费域 (5 表)
|
| 方向 | 接口 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Called by ← saas | PostgreSQL 连接池 (sqlx) | 所有 SaaS API 端点通过连接池访问,Pool 大小可配置 |
|
||||||
|
| Called by ← memory | SQLite/FTS5 连接 (rusqlite) | 记忆提取/检索/注入 通过 zclaw-memory + zclaw-growth |
|
||||||
|
| Provides → growth | TF-IDF + FTS5 索引 | zclaw-growth 调用 FTS5 全文搜索 + TF-IDF 语义评分 |
|
||||||
|
| Called by ← kernel | schema.rs 程序化迁移 | 桌面端启动时自动执行 SQLite 迁移,版本由 schema_version 表跟踪 |
|
||||||
|
| Provides → butler | user_profiles + facts | 管家模式读取用户画像和提取事实进行上下文注入 |
|
||||||
|
|
||||||
| 表 | 说明 | 关键关系 |
|
## 3. 代码逻辑
|
||||||
|----|------|---------|
|
|
||||||
| `billing_plans` | 计费计划目录 | → subscriptions |
|
|
||||||
| `billing_subscriptions` | 用户订阅 | FK → accounts, billing_plans |
|
|
||||||
| `billing_invoices` | 发票 | FK → accounts, subscriptions, plans |
|
|
||||||
| `billing_payments` | 支付记录 | FK → billing_invoices, accounts |
|
|
||||||
| `billing_usage_quotas` | 用量配额 | FK → accounts, billing_plans |
|
|
||||||
|
|
||||||
#### 知识库域 (7 表)
|
### PostgreSQL 域划分 (42 表)
|
||||||
|
|
||||||
| 表 | 说明 | 关键关系 |
|
**认证域 (5 表)**: `accounts` (邮箱/密码/pwv/角色) → `api_tokens`, `refresh_tokens` (JWT 单次使用), `roles`, `permission_templates`
|
||||||
|----|------|---------|
|
|
||||||
| `knowledge_categories` | 分类 (自引用 parent_id) | → knowledge_items |
|
|
||||||
| `knowledge_items` | 知识条目 | FK → categories, accounts |
|
|
||||||
| `knowledge_chunks` | 向量分块 (pgvector) | FK → knowledge_items |
|
|
||||||
| `knowledge_versions` | 版本历史 | FK → items, chunks, accounts |
|
|
||||||
| `knowledge_usage` | 使用统计 | FK → knowledge_items |
|
|
||||||
| `structured_sources` | 结构化数据源 | FK → accounts |
|
|
||||||
| `structured_rows` | 结构化行数据 | FK → structured_sources |
|
|
||||||
|
|
||||||
#### 其他域 (19 表)
|
**Provider 域 (6 表)**: `providers` → `models` (白名单), `provider_keys` (加密 API Key 池), `key_usage_window` (RPM/TPM 滑动窗口), `model_groups` → `model_group_members` (故障转移组)
|
||||||
|
|
||||||
| 域 | 表 | 说明 |
|
**计费域 (5 表)**: `billing_plans` → `billing_subscriptions` (用户订阅) → `billing_invoices` → `billing_payments`, `billing_usage_quotas` (实时递增配额)
|
||||||
|----|----|------|
|
|
||||||
|
**知识库域 (7 表)**: `knowledge_categories` (自引用树) → `knowledge_items` → `knowledge_chunks` (pgvector 向量), `knowledge_versions`, `knowledge_usage`, `structured_sources` → `structured_rows`
|
||||||
|
|
||||||
|
**其他域 (19 表)**:
|
||||||
|
|
||||||
|
| 子域 | 表 | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
| API & Relay | `account_api_keys`, `usage_records`, `relay_tasks` | API Key/用量/异步任务 |
|
| API & Relay | `account_api_keys`, `usage_records`, `relay_tasks` | API Key/用量/异步任务 |
|
||||||
| 配置 | `config_items`, `config_sync_log` | KV 配置/同步日志 |
|
| 配置 | `config_items`, `config_sync_log` | KV 配置/同步日志 |
|
||||||
| Prompt | `prompt_templates`, `prompt_versions`, `prompt_sync_status` | 模板/版本/同步 |
|
| Prompt | `prompt_templates`, `prompt_versions`, `prompt_sync_status` | 模板/版本/同步 |
|
||||||
@@ -92,89 +88,66 @@ tags: [module, database, schema]
|
|||||||
| 设备 | `devices` | 设备管理 |
|
| 设备 | `devices` | 设备管理 |
|
||||||
| 遥测 | `operation_logs`, `telemetry_reports`, `saas_schema_version` | 操作日志/统计/版本 |
|
| 遥测 | `operation_logs`, `telemetry_reports`, `saas_schema_version` | 操作日志/统计/版本 |
|
||||||
| 调度 | `scheduled_tasks` | 定时任务 |
|
| 调度 | `scheduled_tasks` | 定时任务 |
|
||||||
| 限流 | `rate_limit_events` | 限流事件日志 |
|
| 限流 | `rate_limit_events` | 限流事件 (持久化到 PG) |
|
||||||
| Webhook | `webhook_subscriptions`, `webhook_deliveries` | Webhook 订阅/投递 |
|
| Webhook | `webhook_subscriptions`, `webhook_deliveries` | Webhook 订阅/投递 |
|
||||||
| 行业 | `industries`, `account_industries` | 行业配置/账户关联 |
|
| 行业 | `industries`, `account_industries` | 行业配置/账户关联 |
|
||||||
|
|
||||||
### SQLite 本地存储
|
### SQLite 本地存储
|
||||||
|
|
||||||
**zclaw-memory** (`crates/zclaw-memory/src/schema.rs`):
|
**data.db** (`zclaw-memory/schema.rs`): agents, sessions, messages, kv_store, facts, user_profiles, trajectory_events, compressed_trajectories, hand_runs, schema_version
|
||||||
|
|
||||||
| 表 | 说明 | 版本 |
|
**memories.db** (`zclaw-growth/storage/sqlite.rs`): memories (uri, memory_type, content, keywords, importance, access_count), metadata (KV)
|
||||||
|----|------|------|
|
|
||||||
| `agents` | Agent 定义 | v1 |
|
|
||||||
| `sessions` | 聊天会话 | v1, FK → agents |
|
|
||||||
| `messages` | 会话消息 | v1, FK → sessions |
|
|
||||||
| `kv_store` | Agent KV 存储 | v1, FK → agents |
|
|
||||||
| `facts` | 提取的事实 | v2, FK → agents |
|
|
||||||
| `user_profiles` | 用户画像 | v2 |
|
|
||||||
| `trajectory_events` | 工具调用链事件 | v3 |
|
|
||||||
| `compressed_trajectories` | 压缩轨迹摘要 | v3 |
|
|
||||||
| `hand_runs` | Hand 执行追踪 | v1 |
|
|
||||||
| `schema_version` | 迁移版本 | v1 |
|
|
||||||
|
|
||||||
**zclaw-growth** (`crates/zclaw-growth/src/storage/sqlite.rs`):
|
### FTS5 虚拟表
|
||||||
|
|
||||||
| 表 | 说明 |
|
```sql
|
||||||
|----|------|
|
CREATE VIRTUAL TABLE memories_fts USING fts5(uri, content, keywords, tokenize='trigram');
|
||||||
| `memories` | 记忆条目 (uri, memory_type, content, keywords, importance, access_count) |
|
|
||||||
| `metadata` | KV 元数据 |
|
|
||||||
|
|
||||||
**FTS5 虚拟表**:
|
|
||||||
|
|
||||||
| 虚拟表 | 定义 | 分词器 |
|
|
||||||
|--------|------|--------|
|
|
||||||
| `memories_fts` | `fts5(uri, content, keywords)` | `trigram` (CJK 支持) |
|
|
||||||
|
|
||||||
> FTS5 使用 `trigram` 分词器(从 `unicode61` 迁移)支持中文/日文/韩文。CJK 查询零结果时 fallback 到 LIKE 搜索。
|
|
||||||
|
|
||||||
## 数据流
|
|
||||||
|
|
||||||
```
|
|
||||||
桌面端聊天
|
|
||||||
→ SQLite: sessions + messages (本地持久化)
|
|
||||||
→ SaaS Relay: relay_tasks (异步任务追踪)
|
|
||||||
→ PostgreSQL: usage_records (用量记录)
|
|
||||||
|
|
||||||
记忆管道
|
|
||||||
→ SQLite: memories + memories_fts (FTS5 全文索引)
|
|
||||||
→ SQLite: facts + user_profiles (结构化提取)
|
|
||||||
→ PostgreSQL: knowledge_chunks (pgvector 向量, embedding deferred)
|
|
||||||
|
|
||||||
计费闭环
|
|
||||||
→ PostgreSQL: billing_usage_quotas (实时递增)
|
|
||||||
→ PostgreSQL: billing_subscriptions → invoices → payments
|
|
||||||
→ Worker: aggregate_usage (聚合器调度)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 测试链路
|
> trigram 分词器从 unicode61 迁移,原生支持 CJK。零结果时 fallback 到 LIKE 搜索。
|
||||||
|
|
||||||
| 功能 | 测试文件 | 覆盖状态 |
|
### 不变量
|
||||||
|------|---------|---------|
|
|
||||||
| 全模块 | `crates/zclaw-saas/tests/` (17 文件) | ✅ |
|
> **SaaS 用 PostgreSQL,本地记忆用 SQLite,两者完全隔离。** 数据不自动同步,仅通过 SaaS relay API 手动触发。
|
||||||
| SQL 迁移 | `crates/zclaw-saas/migrations/` (21 up) | ✅ 启动时自动执行 |
|
> **FTS5 使用 trigram 分词器,中文搜索依赖正确分词。** 极短查询 (1-2 字) 可能 fallback 到 LIKE。
|
||||||
| 本地存储 | `crates/zclaw-memory/src/store.rs` (20 tests) | ✅ |
|
> **data.db 与 memories.db 是两个独立的 SQLite 数据库。** zclaw-memory 管理 data.db (会话/画像),zclaw-growth 管理 memories.db (记忆/FTS5)。跨库查询不适用,数据交换通过 Rust API。
|
||||||
| 用户画像 | `crates/zclaw-memory/src/user_profile_store.rs` (20 tests) | ✅ |
|
|
||||||
| 轨迹存储 | `crates/zclaw-memory/src/trajectory_store.rs` (9 tests) | ✅ |
|
### 测试链路
|
||||||
| 记忆存储 | `crates/zclaw-growth/src/storage/sqlite.rs` (6 tests) | ✅ |
|
|
||||||
|
| 功能 | 测试文件 | 测试数 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| SaaS 全模块 | `crates/zclaw-saas/tests/` (17 文件) | 集成测试 |
|
||||||
|
| SQL 迁移 | `crates/zclaw-saas/migrations/` (21 up) | 启动时自动执行 |
|
||||||
|
| 本地存储 | `zclaw-memory/src/store.rs` | 20 |
|
||||||
|
| 用户画像 | `zclaw-memory/src/user_profile_store.rs` | 20 |
|
||||||
|
| 轨迹存储 | `zclaw-memory/src/trajectory_store.rs` | 9 |
|
||||||
|
| 记忆存储 | `zclaw-growth/src/storage/sqlite.rs` | 6 |
|
||||||
|
|
||||||
|
## 4. 活跃问题 + 注意事项
|
||||||
|
|
||||||
|
| 优先级 | 问题 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| P2 | pgvector embedding 未激活 | knowledge_chunks 表索引就绪,generate_embedding Worker 逻辑 deferred |
|
||||||
|
| P3 | FTS5 CJK 极短查询 | trigram 已启用,1-2 字查询可能 fallback 到 LIKE,待真实用户反馈 |
|
||||||
|
| — | 迁移幂等性 | PG 迁移用 `IF NOT EXISTS`,SQLite 迁移用 schema_version 表跟踪 |
|
||||||
|
|
||||||
|
**注意事项**: PostgreSQL 连接需要 `ZCLAW_DATABASE_URL` 或 `saas-config.toml` 中的 `database_url` 配置。本地 SQLite 数据库文件存储在 Tauri 应用数据目录下,卸载应用会丢失数据。`schema.rs` 中的 SQLite 迁移通过 `schema_version` 表跟踪版本号,仅执行增量迁移,不会重建已存在的表。环境变量 `DB_PASSWORD` 支持 `saas-config.toml` 中 `${VAR_NAME}` 插值引用。
|
||||||
|
|
||||||
## 关联模块
|
## 关联模块
|
||||||
|
|
||||||
- [[saas]] — PostgreSQL 由 SaaS 后端管理
|
- [[saas]] — PostgreSQL 由 SaaS 后端管理,所有 API 端点的数据源
|
||||||
- [[memory]] — SQLite 本地记忆存储 + FTS5
|
- [[memory]] — SQLite 本地记忆存储 + FTS5 全文搜索 + TF-IDF 权重
|
||||||
- [[routing]] — relay_tasks 异步任务追踪
|
- [[routing]] — relay_tasks 异步任务追踪,连接前端请求和 SaaS 后端
|
||||||
|
- [[security]] — 数据加密: provider_keys AES-256-GCM, 密码 Argon2id, TOTP 独立加密密钥
|
||||||
|
|
||||||
## 关键文件
|
## 5. 变更日志
|
||||||
|
|
||||||
| 文件 | 职责 |
|
> 最近 5 条与数据模型相关的变更。完整日志见 [[log]]。
|
||||||
|
|
||||||
|
| 日期 | 变更 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `crates/zclaw-saas/migrations/` | 21 up SQL 迁移 (42 CREATE TABLE) |
|
| 2026-04-22 | 跨会话记忆修复: profile_store 连接 + 双数据库统一 + 诊断日志 |
|
||||||
| `crates/zclaw-saas/src/models/` | 数据模型 struct 定义 |
|
| 2026-04-21 | Phase 0+1: 经验积累 reuse_count 修复 + 跨会话检索增强 IdentityRecall 26→54 模式 |
|
||||||
| `crates/zclaw-memory/src/schema.rs` | SQLite schema 定义 |
|
| 2026-04-19 | TRUTH.md 数字校准: 42 CREATE TABLE, 38 迁移文件, Rust 101,967 行 |
|
||||||
| `crates/zclaw-growth/src/storage/sqlite.rs` | FTS5 + TF-IDF 存储 |
|
| 2026-04-17 | E2E 测试: 记忆去重+记忆注入+invoice_id+agent隔离修复 |
|
||||||
| `docker-compose.yml` | PostgreSQL 容器配置 |
|
| 2026-04-15 | Heartbeat: health_snapshot 统一收集器,删除 intelligence-client/ 9 废弃文件 |
|
||||||
|
|
||||||
## 已知问题
|
|
||||||
|
|
||||||
- ⚠️ **pgvector embedding 生成未实现** — 索引就绪,`generate_embedding.rs` Worker 逻辑 deferred
|
|
||||||
- ⚠️ **FTS5 CJK 零结果** — trigram 分词器已启用,极短查询可能仍 fallback 到 LIKE
|
|
||||||
|
|||||||
@@ -1,423 +1,60 @@
|
|||||||
---
|
---
|
||||||
title: 功能链路映射
|
title: 功能链路索引
|
||||||
updated: 2026-04-22
|
updated: 2026-04-22
|
||||||
status: active
|
status: active
|
||||||
tags: [reference, feature-map, testing]
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 功能链路映射
|
# 功能链路索引
|
||||||
|
|
||||||
> 从 [[index]] 导航。每条链路追踪一个功能从前端到后端的完整路径 + 测试覆盖。
|
> 每个功能从前端到后端的完整路径。详细实现见各模块页面。
|
||||||
> 排序: 用户流程优先级 — 对话 > Agent > Hands > 记忆 > SaaS > 管家 > Pipeline > 配置 > 安全
|
|
||||||
|
## 链路总览
|
||||||
---
|
|
||||||
|
| ID | 功能 | 模块 | 链路摘要 |
|
||||||
## 对话 (5 条)
|
|----|------|------|----------|
|
||||||
|
| F-01 | 发送消息 | [[chat]] | ChatPanel → streamStore → getClient() → kernel_chat → loop_runner → LLM |
|
||||||
### F-01: 发送消息
|
| F-02 | 流式响应 | [[chat]] | Tauri Event 'chat-response-delta' → streamStore.onDelta → UI |
|
||||||
|
| F-03 | 模型切换 | [[routing]] | conversationStore.currentModel → connectionStore → SaaS 白名单验证 |
|
||||||
| 维度 | 内容 |
|
| F-04 | 上下文管理 | [[chat]] | conversationStore → IndexedDB → 跨会话恢复 |
|
||||||
|------|------|
|
| F-05 | 取消流式 | [[chat]] | cancelStream() → atomic flag → kernel cancel |
|
||||||
| 用户入口 | 聊天输入框 → streamStore.sendMessage(content) |
|
| F-06 | 创建 Agent | [[chat]] | agentStore → kernel_agent_create → SQLite |
|
||||||
| 前端关键文件 | streamStore.ts, conversationStore.ts, kernel-chat.ts |
|
| F-07 | 切换 Agent | [[chat]] | agentStore.select → conversationStore.sessionKey 重置 |
|
||||||
| 通信层 | getClient() → KernelClient (Tauri) / SaaSRelay (SSE) |
|
| F-08 | 配置 Agent | [[chat]] | AgentSettings → kernel_agent_update → TOML/SQLite |
|
||||||
| Tauri 命令 | `agent_chat_stream` → desktop/src-tauri/src/kernel_commands/chat.rs |
|
| F-09 | 删除 Agent | [[chat]] | agentStore → kernel_agent_delete → SQLite cleanup |
|
||||||
| 中间件链 | ButlerRouter@80 → ... → Memory@150 → ... → Guardrail@400 → ... |
|
| F-09.5 | Agent 搜索 | [[hands-skills]] | ResearcherHand → Baidu+Bing CN 并行 → Jina Reader |
|
||||||
| Rust 核心 | kernel → runtime → loop_runner → LLM Driver |
|
| F-10 | 触发 Hand | [[hands-skills]] | LLM tool_call → ToolRegistry → HandExecutor |
|
||||||
| SaaS API | POST /api/v1/relay/chat/completions |
|
| F-11 | Hand 审批 | [[hands-skills]] | needs_approval=true → UI confirm → HandExecutor |
|
||||||
| 流式返回 | LLM → runtime → Tauri Event (stream:chunk) → streamStore.onDelta → UI |
|
| F-12 | Hand 结果 | [[hands-skills]] | HandEnd event → handStore → UI |
|
||||||
| 测试文件 | tests/desktop/chatStore.test.ts (11), tests/seam/chat-seam.test.ts (8) |
|
| F-13 | Browser 自动化 | [[hands-skills]] | BrowserHand → chromiumoxide → headless Chrome |
|
||||||
| 测试状态 | ✅ 19/19 PASS |
|
| F-14 | 记忆搜索 | [[memory]] | MemoryPanel → viking_ls → FTS5 fulltext → UI |
|
||||||
|
| F-15 | 记忆注入 | [[memory]] | Middleware@150 → extraction_adapter → FTS5+TF-IDF → system prompt |
|
||||||
### F-02: 流式响应接收
|
| F-16 | 记忆管理 | [[memory]] | MemoryPanel → viking_delete → FTS5 |
|
||||||
|
| F-17 | 用户注册 | [[saas]] | RegisterForm → POST /api/auth/register → Argon2id → JWT |
|
||||||
| 维度 | 内容 |
|
| F-18 | 用户登录 | [[saas]] | LoginForm → POST /api/auth/login → JWT→Cookie→Keyring |
|
||||||
|------|------|
|
| F-19 | Token 刷新 | [[security]] | HttpOnly cookie → POST /api/auth/refresh → rotate JWT |
|
||||||
| 用户入口 | 聊天面板实时显示文字流 |
|
| F-20 | 订阅管理 | [[saas]] | BillingPanel → GET /api/subscriptions → SaaS quota |
|
||||||
| 前端关键文件 | streamStore.ts (onDelta/onThinkingDelta/onTool/onComplete) |
|
| F-21 | 支付计费 | [[saas]] | PayButton → POST /api/payments → Alipay/WeChat mock |
|
||||||
| 通信层 | Tauri Event emit → streamStore 回调 |
|
| F-22 | Admin 管理 | [[saas]] | Admin V2 → 137 routes → PostgreSQL |
|
||||||
| 流式事件 | Delta / ThinkingDelta / ToolStart / ToolEnd / HandStart / HandEnd / Complete / Error |
|
| F-23 | 简洁/专业切换 | [[butler]] | uiModeStore.toggle → ButlerPanel layout switch |
|
||||||
| 超时守护 | kernel-chat.ts:76 — 5 分钟超时防挂起 |
|
| F-24 | 行业配置 | [[butler]] | industryStore → saas-industry API → ButlerRouter keywords |
|
||||||
| 测试文件 | tests/desktop/chatStore.test.ts (stream correlation via runId) |
|
| F-25 | 痛点积累 | [[butler]] | Middleware → ExperienceStore → FTS5 → pre_hook injection |
|
||||||
| 测试状态 | ✅ PASS |
|
| F-26 | 选择模板 | [[pipeline]] | WorkflowPanel → pipelineStore → YAML parse |
|
||||||
|
| F-27 | 配置参数 | [[pipeline]] | WorkflowBuilder → DAG config → Tauri invoke |
|
||||||
### F-03: 模型切换
|
| F-28 | 执行工作流 | [[pipeline]] | DAG executor → topological sort → parallel execution |
|
||||||
|
| F-29 | 模型设置 | [[routing]] | Settings → configStore → kernel_set_model |
|
||||||
| 维度 | 内容 |
|
| F-30 | 工作区配置 | [[routing]] | Settings → configStore → TOML write |
|
||||||
|------|------|
|
| F-31 | 数据隐私 | [[security]] | Settings → secure_storage → OS keyring |
|
||||||
| 用户入口 | 聊天面板模型选择器 |
|
| F-32 | JWT 认证 | [[security]] | login → JWT Claims(pwv) → Cookie→Keyring |
|
||||||
| 前端关键文件 | conversationStore.ts (currentModel), connectionStore.ts |
|
| F-33 | TOTP 2FA | [[security]] | Settings → TOTP secret → AES-256-GCM → verify |
|
||||||
| 决策链 | UI 选择 → conversationStore.currentModel = newModel → 下次 sendMessage 生效 |
|
|
||||||
| SaaS 验证 | relay 白名单精确匹配 model_id (无别名解析) |
|
## 统计
|
||||||
| 降级 | SaaS 不可达 → 降级到本地 Kernel + 用户自定义模型 |
|
|
||||||
| 测试文件 | tests/desktop/chatStore.test.ts |
|
| 模块 | 链路数 | 详见 |
|
||||||
| 测试状态 | ✅ PASS |
|
|------|--------|------|
|
||||||
|
| 对话/Agent | 9 | [[chat]] |
|
||||||
### F-04: 上下文管理
|
| 自主能力 | 5 | [[hands-skills]] |
|
||||||
|
| 记忆 | 3 | [[memory]] |
|
||||||
| 维度 | 内容 |
|
| SaaS | 6 | [[saas]] |
|
||||||
|------|------|
|
| 管家 | 3 | [[butler]] |
|
||||||
| 用户入口 | 跨会话恢复对话历史 |
|
| Pipeline | 3 | [[pipeline]] |
|
||||||
| 前端关键文件 | conversationStore.ts (sessionKey), messageStore.ts (IndexedDB) |
|
| 配置/安全 | 5 | [[routing]] [[security]] |
|
||||||
| 持久化 | IndexedDB 消息存储 + SQLite sessions/messages |
|
|
||||||
| Tauri 命令 | kernel_init 时传入 session_id |
|
|
||||||
| 测试文件 | tests/desktop/chatStore.test.ts (session isolation) |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-05: 取消流式
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 聊天面板停止按钮 |
|
|
||||||
| 前端关键文件 | streamStore.ts → cancelStream() |
|
|
||||||
| Tauri 命令 | `cancel_stream` → kernel_commands/chat.rs (原子标志位) |
|
|
||||||
| 机制 | AtomicBool cancel flag → 流式任务检测 → emit Error 事件 |
|
|
||||||
| 测试状态 | ✅ (chatStore test 覆盖) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 分身 / Agent (4 条)
|
|
||||||
|
|
||||||
### F-06: 创建 Agent
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 侧边栏 → 新建 Agent |
|
|
||||||
| 前端关键文件 | agentStore.ts |
|
|
||||||
| Tauri 命令 | `agent_create` → kernel_commands/agent.rs |
|
|
||||||
| Rust 核心 | kernel → zclaw-memory → SQLite agents 表 |
|
|
||||||
| 测试文件 | tests/desktop/chatStore.test.ts (agent isolation) |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-07: 切换 Agent
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 侧边栏 Agent 列表点击切换 |
|
|
||||||
| 前端关键文件 | agentStore.ts (currentAgent), conversationStore.ts |
|
|
||||||
| 机制 | 切换 agent_id → 新 session_key → 下次 sendMessage 使用新 Agent |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-08: 配置 Agent
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | Agent 设置面板 (名称/模型/系统提示) |
|
|
||||||
| 前端关键文件 | agentStore.ts |
|
|
||||||
| Tauri 命令 | `agent_update` → kernel_commands/agent.rs |
|
|
||||||
| 存储 | config.toml (本地) / agent_templates 表 (SaaS) |
|
|
||||||
| 测试状态 | ✅ |
|
|
||||||
|
|
||||||
### F-09: 删除 Agent
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | Agent 列表 → 删除确认 |
|
|
||||||
| 前端关键文件 | agentStore.ts |
|
|
||||||
| Tauri 命令 | `agent_delete` → kernel_commands/agent.rs |
|
|
||||||
| 级联 | SQLite agents + sessions + messages 级联删除 |
|
|
||||||
| 测试状态 | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 自主能力 / Hands (4 条)
|
|
||||||
|
|
||||||
### F-09.5: Agent 搜索(网络搜索)
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 聊天中输入搜索请求(如"搜索今天的新闻") |
|
|
||||||
| 前端关键文件 | ChatArea.tsx (stripToolNarration), StreamingText.tsx (ReactMarkdown) |
|
|
||||||
| 触发机制 | LLM 判断需要搜索 → ToolUse{hand_researcher} → AgentLoop 执行 |
|
|
||||||
| Tauri 命令 | 无独立命令,通过 agent_chat_stream → loop_runner → hand_execute |
|
|
||||||
| Rust 核心 | zclaw-hands/researcher.rs: search_native() → Baidu + Bing CN 并行 |
|
|
||||||
| 网页获取 | fetch_via_jina() (Jina Reader API) → fetch_direct() (降级) |
|
|
||||||
| LLM 兼容 | 扁平 input_schema + empty-input 回退(_fallback_query 注入) |
|
|
||||||
| UI 处理 | hand 消息隐藏 + stripToolNarration 行级过滤 + ReactMarkdown 渲染 |
|
|
||||||
| 搜索引擎 | Baidu + Bing CN(国内优先), DuckDuckGo fallback |
|
|
||||||
| 测试状态 | ✅ E2E 验证通过 (commit 5816f56 + 81005c3) |
|
|
||||||
|
|
||||||
### F-10: 触发 Hand
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 聊天中 LLM 决定调用 Hand / 自动化面板手动触发 |
|
|
||||||
| 前端关键文件 | handStore.ts, streamStore.ts (onHand 回调) |
|
|
||||||
| Tauri 命令 | `hand_execute` → kernel_commands/hand.rs → HandRegistry |
|
|
||||||
| Rust 核心 | zclaw-hands → 具体 Hand 实现 (Browser/Collector/Twitter/Quiz/...) |
|
|
||||||
| 流式事件 | HandStart / HandEnd (via agent_chat_stream) |
|
|
||||||
| 测试文件 | crates/zclaw-hands/src/hands/ (117 tests) |
|
|
||||||
| 测试状态 | ✅ 117/117 PASS |
|
|
||||||
|
|
||||||
### F-11: Hand 审批
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 审批弹窗 (needs_approval 状态) |
|
|
||||||
| 前端关键文件 | handStore.ts (approval UI) |
|
|
||||||
| Tauri 命令 | `hand_approve` / `hand_cancel` → kernel_commands/hand.rs |
|
|
||||||
| 机制 | Hand TOML 配置 needs_approval=true → 执行前暂停等审批 |
|
|
||||||
| 测试状态 | ✅ |
|
|
||||||
|
|
||||||
### F-12: Hand 结果查看
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 聊天面板中 Hand 结果展示 |
|
|
||||||
| 前端关键文件 | handStore.ts |
|
|
||||||
| Tauri 命令 | `hand_run_status` / `hand_run_list` |
|
|
||||||
| 机制 | Hand 执行完成 → HandEnd 事件 → UI 展示结果 |
|
|
||||||
| 测试状态 | ✅ |
|
|
||||||
|
|
||||||
### F-13: Browser 自动化
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 聊天中触发浏览器操作 |
|
|
||||||
| 前端关键文件 | browserHandStore.ts |
|
|
||||||
| Tauri 命令 | 23 个 browser_* 命令 → desktop/src-tauri/src/browser/commands.rs |
|
|
||||||
| 依赖 | WebDriver (需要外部 WebDriver 进程) |
|
|
||||||
| 测试文件 | crates/zclaw-hands/src/hands/browser.rs (11 tests) |
|
|
||||||
| 测试状态 | ✅ 11/11 PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 记忆 (3 条)
|
|
||||||
|
|
||||||
### F-14: 记忆搜索
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 设置 > 语义记忆 / 管家面板记忆搜索 |
|
|
||||||
| 前端关键文件 | memoryGraphStore.ts |
|
|
||||||
| Tauri 命令 | `memory_search` → memory_commands.rs → FTS5 + TF-IDF |
|
|
||||||
| Rust 核心 | zclaw-growth → retriever.rs → QueryAnalyzer → SemanticScorer |
|
|
||||||
| 查询类型 | Preference / Knowledge / Experience / Code / General |
|
|
||||||
| 测试文件 | crates/zclaw-growth/ (181 tests), zclaw-memory/ (54 tests) |
|
|
||||||
| 测试状态 | ✅ 235/235 PASS |
|
|
||||||
|
|
||||||
### F-15: 记忆自动注入
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 无感 — 聊天时自动触发 |
|
|
||||||
| 触发链 | Memory 中间件@150 → 检测对话内容 → 提取记忆 |
|
|
||||||
| Tauri 命令 | `extract_and_store_memories` → memory/extractor.rs |
|
|
||||||
| 注入链 | PromptInjector.inject(system_prompt, memories) → token 预算控制 |
|
|
||||||
| 测试文件 | crates/zclaw-growth/src/injector.rs (9 tests) |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-16: 记忆手动管理
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 设置 > 语义记忆 — 查看/删除/导出/导入 |
|
|
||||||
| 前端关键文件 | memoryGraphStore.ts |
|
|
||||||
| Tauri 命令 | `memory_stats` / `memory_export` / `memory_import` / `memory_delete_all` |
|
|
||||||
| 测试文件 | crates/zclaw-growth/src/storage/sqlite.rs (6 tests) |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SaaS (6 条)
|
|
||||||
|
|
||||||
### F-17: 用户注册
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 登录页 > 注册 |
|
|
||||||
| 前端关键文件 | saasStore (auth.ts) |
|
|
||||||
| SaaS API | POST /api/v1/auth/register (3次/小时 IP 限流) |
|
|
||||||
| Rust 核心 | auth/handlers.rs → Argon2id + OsRg 盐 + 邮箱 RFC 5322 校验 |
|
|
||||||
| 存储 | PostgreSQL accounts 表 |
|
|
||||||
| 测试文件 | crates/zclaw-saas/tests/auth_test.rs |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-18: 用户登录
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | SaaS 平台登录页 |
|
|
||||||
| 前端关键文件 | saasStore (auth.ts) |
|
|
||||||
| SaaS API | POST /api/v1/auth/login (5次/分钟 IP 限流 + 持久化) |
|
|
||||||
| 安全机制 | 账户锁定 (5 次失败 → 15 分钟) + JWT pwv 机制 |
|
|
||||||
| Token 存储 | Tauri: OS keyring / 浏览器: HttpOnly Cookie |
|
|
||||||
| 测试文件 | crates/zclaw-saas/tests/auth_test.rs, auth_security_test.rs |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-19: Token 刷新
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 无感 — access token 过期时自动触发 |
|
|
||||||
| 前端关键文件 | saasStore (auth.ts) |
|
|
||||||
| SaaS API | POST /api/v1/auth/refresh |
|
|
||||||
| 安全机制 | 单次使用 refresh token → 旧 token 撤销到 DB → 签发新对 |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-20: 订阅管理
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 设置 > SaaS 平台 / Admin 后台 |
|
|
||||||
| 前端关键文件 | saasStore (billing.ts) |
|
|
||||||
| SaaS API | GET /billing/subscription, GET /billing/plans |
|
|
||||||
| 数据表 | billing_plans, billing_subscriptions |
|
|
||||||
| 测试文件 | crates/zclaw-saas/tests/billing_test.rs |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-21: 支付/计费
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | SaaS 平台支付页 / Admin 管理计费 |
|
|
||||||
| 前端关键文件 | saasStore (billing.ts) |
|
|
||||||
| SaaS API | POST /billing/payments, GET /billing/invoices/:id/pdf |
|
|
||||||
| 支付渠道 | Alipay + WeChat (mock 路由用于开发) |
|
|
||||||
| 回调 | POST /billing/callback/:method |
|
|
||||||
| 实时配额 | billing_usage_quotas 递增 + aggregate_usage Worker |
|
|
||||||
| 测试文件 | crates/zclaw-saas/tests/billing_test.rs |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-22: Admin 后台管理
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | Admin V2 后台 (admin-v2/) |
|
|
||||||
| 前端关键文件 | admin-v2/src/pages/ (17 页面) |
|
|
||||||
| SaaS API | 118 个路由覆盖 13 个模块 |
|
|
||||||
| 认证 | Admin HttpOnly Cookie + admin_guard_middleware 权限验证 |
|
|
||||||
| 测试文件 | admin-v2/tests/pages/ (17 文件) + crates/zclaw-saas/tests/ (17 文件) |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 管家 (3 条)
|
|
||||||
|
|
||||||
### F-23: 简洁/专业模式切换
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 聊天面板右上角模式切换 |
|
|
||||||
| 前端关键文件 | uiModeStore.ts, SimpleSidebar.tsx |
|
|
||||||
| 机制 | simple 模式隐藏高级功能 / professional 模式展示完整功能 |
|
|
||||||
| 测试状态 | ✅ |
|
|
||||||
|
|
||||||
### F-24: 行业配置
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 管家面板 > 行业选择 / Admin > 行业配置 |
|
|
||||||
| 前端关键文件 | industryStore.ts, ButlerPanel.tsx |
|
|
||||||
| SaaS API | GET /industries, GET /accounts/me/industries, PUT /accounts/:id/industries |
|
|
||||||
| 内置行业 | 4 个 (医疗/教育/金融/法律) |
|
|
||||||
| 中间件 | ButlerRouter@80 动态行业关键词匹配 |
|
|
||||||
| 测试状态 | ✅ 4 行业已验证 |
|
|
||||||
|
|
||||||
### F-25: 痛点积累
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 无感 — 聊天中自动提取 |
|
|
||||||
| 触发链 | ButlerRouter → pain_aggregator → pain_storage (双写内存+SQLite) |
|
|
||||||
| Tauri 命令 | `butler_record_pain_point`, `butler_list_pain_points` |
|
|
||||||
| 方案生成 | 痛点积累到阈值 → `butler_generate_solution` |
|
|
||||||
| 测试文件 | intelligence/pain_aggregator.rs (9), pain_storage.rs (11), solution_generator.rs (5) |
|
|
||||||
| 测试状态 | ✅ 25/25 PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pipeline (3 条)
|
|
||||||
|
|
||||||
### F-26: 选择模板
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 工作流面板 → 模板列表 |
|
|
||||||
| 前端关键文件 | workflowStore.ts, pipeline-client.ts |
|
|
||||||
| Tauri 命令 | pipeline discovery 命令 (8 个已接通前端) |
|
|
||||||
| 模板来源 | pipelines/ 目录 18 个 YAML (8 行业目录) |
|
|
||||||
| 测试文件 | crates/zclaw-pipeline/src/parser_v2.rs (11 tests) |
|
|
||||||
| 测试状态 | ✅ 11/11 PASS |
|
|
||||||
|
|
||||||
### F-27: 配置参数
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 工作流面板 → 参数填写 |
|
|
||||||
| 前端关键文件 | workflowStore.ts |
|
|
||||||
| Tauri 命令 | pipeline discovery 配置命令 |
|
|
||||||
| YAML 结构 | 步骤 + 依赖 + 输入/输出定义 |
|
|
||||||
| 测试文件 | crates/zclaw-pipeline/src/parser.rs (5 tests) |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-28: 执行工作流
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 工作流面板 → 执行按钮 |
|
|
||||||
| Tauri 命令 | `orchestration_execute` (@reserved, 无前端 UI) |
|
|
||||||
| Rust 核心 | zclaw-pipeline → executor.rs → DAG 拓扑排序 + 并行执行 |
|
|
||||||
| 测试文件 | crates/zclaw-pipeline/src/executor.rs (2 tests) |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 配置 (3 条)
|
|
||||||
|
|
||||||
### F-29: 模型设置
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 设置 > 模型与 API |
|
|
||||||
| 前端关键文件 | configStore.ts, settingsStore |
|
|
||||||
| 机制 | UI → config.toml 写入 → Kernel 热重载 |
|
|
||||||
| 8 Provider | Kimi/Qwen/DeepSeek/Zhipu/OpenAI/Anthropic/Gemini/Local |
|
|
||||||
| 测试状态 | ✅ |
|
|
||||||
|
|
||||||
### F-30: 工作区配置
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 设置 > 工作区 |
|
|
||||||
| 前端关键文件 | configStore.ts |
|
|
||||||
| 持久化 | config.toml + environment variable 插值 ${VAR_NAME} |
|
|
||||||
| 测试状态 | ✅ |
|
|
||||||
|
|
||||||
### F-31: 数据隐私
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 设置 > 数据与隐私 |
|
|
||||||
| 前端关键文件 | configStore.ts |
|
|
||||||
| 功能 | 清除对话历史 / 导出数据 / 记忆管理 |
|
|
||||||
| Tauri 命令 | `memory_delete_all`, `memory_export` |
|
|
||||||
| 测试状态 | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 安全 (2 条)
|
|
||||||
|
|
||||||
### F-32: JWT 认证
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 登录时自动触发 |
|
|
||||||
| 前端关键文件 | saasStore (auth.ts) → secure_storage.ts |
|
|
||||||
| SaaS API | POST /auth/login → JWT 签发 → Cookie 设置 |
|
|
||||||
| 验证链 | auth_middleware → JWT 解码 → Claims.pwv vs DB.pwv 比对 |
|
|
||||||
| 存储 | Tauri: OS keyring (DPAPI/Keychain/Secret Service) |
|
|
||||||
| 刷新 | POST /auth/refresh → 单次使用 rotation |
|
|
||||||
| 测试文件 | crates/zclaw-saas/tests/auth_test.rs, auth_security_test.rs |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|
||||||
### F-33: TOTP 2FA
|
|
||||||
|
|
||||||
| 维度 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| 用户入口 | 设置 > 安全存储 > 2FA 设置 |
|
|
||||||
| 前端关键文件 | securityStore.ts |
|
|
||||||
| SaaS API | POST /auth/totp/setup → QR 码 / verify → 激活 / disable → 禁用 |
|
|
||||||
| 加密 | TOTP 密钥 AES-256-GCM 加密存储, 独立 ZCLAW_TOTP_ENCRYPTION_KEY |
|
|
||||||
| 测试文件 | crates/zclaw-saas/src/auth/totp.rs (inline tests) |
|
|
||||||
| 测试状态 | ✅ PASS |
|
|
||||||
|
|||||||
@@ -7,216 +7,60 @@ tags: [module, hands, skills, mcp]
|
|||||||
|
|
||||||
# Hands + Skills + MCP
|
# Hands + Skills + MCP
|
||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[chat]] [[middleware]] [[butler]]
|
> 从 [[index]] 导航。关联: [[chat]] [[middleware]] [[butler]]
|
||||||
|
|
||||||
## 设计思想
|
## 1. 设计决策
|
||||||
|
|
||||||
**Hands = 自主能力 (行动层), Skills = 知识技能 (认知层), MCP = 外部工具协议**
|
**Hands = 自主能力 (行动层), Skills = 知识技能 (认知层), MCP = 外部工具协议。**
|
||||||
|
|
||||||
- Hands: 浏览器自动化、数据收集、Twitter 操作等 — **执行动作**
|
| 决策 | WHY |
|
||||||
- Skills: 75 个 SKILL.md 文件 — **语义路由匹配**,增强 LLM 的领域知识
|
|------|-----|
|
||||||
- MCP: Model Context Protocol — **动态外部工具**,运行时发现和调用
|
| 7 注册 Hands (6 TOML + _reminder) | 每个 Hand 有独立配置和 Rust 实现,启用/禁用由 `enabled` 字段控制。_reminder 由 kernel 代码注册,无 HAND.toml |
|
||||||
- 触发: 用户请求 → LLM 判断需要执行 → 选择 Hand/Skill/MCP Tool → 执行
|
| 75 Skills + 语义路由 | SKILL.md 定义领域知识,SemanticSkillRouter 用 TF-IDF 匹配(详见 [[butler]] 路由细节),增强 LLM 领域能力而不硬编码 |
|
||||||
|
| MCP bridge | 运行时发现外部工具服务器,McpToolWrapper 适配 Tool trait,让 LLM 在对话中直接调用 filesystem/database 等外部工具 |
|
||||||
|
| LLM Tool Calling 触发 | Skill 调用通过 LLM 生成 ToolUse,不是直接函数调用。保持 LLM 主导决策权 |
|
||||||
|
| 定时提醒链路 | NlScheduleParser 中文时间->cron + _reminder Hand + TriggerManager,支持"每天早上9点提醒我查房" |
|
||||||
|
|
||||||
## 代码逻辑
|
## 2. 关键文件 + 数据流
|
||||||
|
|
||||||
### Hands (7 注册: 6 TOML + 1 系统内部)
|
### 核心文件
|
||||||
|
|
||||||
每个 Hand 有独立的 `hands/<Name>.HAND.toml` 配置和 `crates/zclaw-hands/src/hands/` 下的 Rust 实现。
|
| 文件 | 职责 |
|
||||||
|
|
||||||
| Hand | 功能 | 依赖 | 测试数 | 配置 |
|
|
||||||
|------|------|------|--------|------|
|
|
||||||
| Browser | 浏览器自动化 (23 Tauri命令) | WebDriver | 8 | `hands/browser.HAND.toml` |
|
|
||||||
| Collector | 数据收集聚合 | — | 8 | `hands/collector.HAND.toml` |
|
|
||||||
| Researcher | 深度研究 + 网络搜索 | 网络 | 22 | `hands/researcher.HAND.toml` |
|
|
||||||
| Clip | 视频处理 | FFmpeg | 30 | `hands/clip.HAND.toml` |
|
|
||||||
| Twitter | Twitter 自动化 (12 API v2) | OAuth 1.0a | 25 | `hands/twitter.HAND.toml` |
|
|
||||||
| Quiz | 测验生成 | — | — | `hands/quiz.HAND.toml` |
|
|
||||||
| _reminder | 定时提醒 (系统内部) | — | — | 无 TOML(代码注册) |
|
|
||||||
|
|
||||||
Hands 测试分布(前 5): Clip(30), Twitter(25), Researcher(22), Browser(8), Collector(8)
|
|
||||||
|
|
||||||
### Researcher 搜索能力(04-22 修复)
|
|
||||||
|
|
||||||
Researcher 是 ZCLAW 的核心搜索 Hand,支持从对话中直接触发网络搜索和网页获取。
|
|
||||||
|
|
||||||
**搜索引擎**: Baidu + Bing CN 并行(国内用户可用),DuckDuckGo 作为 fallback
|
|
||||||
**网页获取**: Jina Reader API(优先,返回干净 Markdown)→ 直接 HTTP fetch(降级)
|
|
||||||
**LLM 兼容**: 扁平化 input_schema(action/query/url/urls/engine),兼容 glm-5.1 等国产模型
|
|
||||||
**空参数回退**: 当 LLM 发送空 `{}` 时,loop_runner 自动注入用户消息作为 `_fallback_query`
|
|
||||||
|
|
||||||
```
|
|
||||||
用户消息 "搜索今天的新闻"
|
|
||||||
→ LLM 生成 ToolUse{hand_researcher, {action:"search", query:"今日新闻"}}
|
|
||||||
→ AgentLoop → ResearcherHand.execute()
|
|
||||||
→ execute_search() → search_native() → Baidu + Bing CN 并行
|
|
||||||
→ 搜索结果 (10条) → ToolResult → LLM 基于结果生成回复
|
|
||||||
→ 前端 stripToolNarration 过滤内部叙述 + ReactMarkdown 渲染排版
|
|
||||||
```
|
|
||||||
|
|
||||||
关键修复 (commit 5816f56 + 81005c3):
|
|
||||||
- **schema 简化**: `oneOf`+`const` → 扁平属性,解决 glm-5.1 不理解复杂 schema 导致空参数
|
|
||||||
- **empty-input 回退**: loop_runner 检测 `{}` → 注入 `_fallback_query` → researcher 自动搜索
|
|
||||||
- **排版修复**: stripToolNarration 从句子级拆分改为行级过滤,保留 markdown 结构
|
|
||||||
|
|
||||||
### 已删除 Hands (04-17 Phase 5 空壳清理)
|
|
||||||
|
|
||||||
| Hand | 原状态 | 删除原因 |
|
|
||||||
|------|--------|----------|
|
|
||||||
| Whiteboard | 有 HAND.toml + Rust (422行) | 空壳实现,无真实功能,已删除 |
|
|
||||||
| Slideshow | 有 HAND.toml + Rust (797行) | 空壳实现,无真实功能,已删除 |
|
|
||||||
| Speech | 有 HAND.toml + Rust (442行) | 空壳实现,无真实功能,已删除 |
|
|
||||||
|
|
||||||
净减 ~5400 行。
|
|
||||||
|
|
||||||
### 禁用 Hands
|
|
||||||
|
|
||||||
| Hand | 状态 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Predictor | 禁用 | 无 TOML/无 Rust 实现,仅概念定义 |
|
|
||||||
| Lead | 禁用 | 无 TOML/无 Rust 实现,仅概念定义 |
|
|
||||||
|
|
||||||
### 触发流
|
|
||||||
|
|
||||||
```
|
|
||||||
UI 触发 → handStore.trigger(handName, params)
|
|
||||||
→ Tauri invoke('hand_execute', { handName, params })
|
|
||||||
→ Kernel → Hand 执行
|
|
||||||
→ needs_approval? → 等待 approvalStore 确认
|
|
||||||
→ 执行结果 → Tauri Event emit
|
|
||||||
→ handStore 更新状态 + 记录日志
|
|
||||||
```
|
|
||||||
|
|
||||||
### 定时提醒链路(NlScheduleParser → _reminder Hand)
|
|
||||||
|
|
||||||
用户在聊天中输入包含定时意图的消息(如"每天早上9点提醒我查房"),系统自动拦截并创建定时触发器:
|
|
||||||
|
|
||||||
```
|
|
||||||
用户消息 "每天早上9点提醒我查房"
|
|
||||||
→ agent_chat_stream (chat.rs)
|
|
||||||
→ has_schedule_intent() 检测关键词(提醒我/定时/每天/每周等)
|
|
||||||
→ parse_nl_schedule() 解析为 cron 表达式
|
|
||||||
→ ScheduleParseResult::Exact (confidence >= 0.8)
|
|
||||||
→ TriggerConfig { hand_id: "_reminder", trigger_type: Schedule { cron: "0 9 * * *" } }
|
|
||||||
→ kernel.create_trigger() → TriggerManager 存储
|
|
||||||
→ LoopEvent::Delta(确认消息) → 前端流式显示
|
|
||||||
→ 跳过 LLM 调用(省 token)
|
|
||||||
→ SchedulerService 每60秒轮询
|
|
||||||
→ should_fire_cron() 匹配 → execute_hand_with_source("_reminder")
|
|
||||||
→ ReminderHand.execute() → 记录日志
|
|
||||||
```
|
|
||||||
|
|
||||||
关键组件:
|
|
||||||
- `crates/zclaw-runtime/src/nl_schedule.rs` — 中文时间→cron 转换(支持6种模式)
|
|
||||||
- `crates/zclaw-hands/src/hands/reminder.rs` — 系统内部 Hand(id=`_reminder`)
|
|
||||||
- `crates/zclaw-kernel/src/trigger_manager.rs` — 触发器 CRUD(`_` 前缀 hand_id 免验证)
|
|
||||||
- `crates/zclaw-kernel/src/scheduler.rs` — 60秒轮询 + cron 匹配
|
|
||||||
- `desktop/src-tauri/src/kernel_commands/chat.rs` — 定时意图拦截入口
|
|
||||||
|
|
||||||
Hand 相关 Tauri 命令 (8 个):
|
|
||||||
`hand_list, hand_execute, hand_approve, hand_cancel, hand_get, hand_run_status, hand_run_list, hand_run_cancel`
|
|
||||||
|
|
||||||
### Skills (75 个目录)
|
|
||||||
|
|
||||||
```
|
|
||||||
skills/
|
|
||||||
├── accessibility-auditor/ api-tester/
|
|
||||||
├── agentic-identity-trust/ app-store-optimizer/
|
|
||||||
├── agents-orchestrator/ backend-architect/
|
|
||||||
├── ai-engineer/ brand-guardian/
|
|
||||||
├── analytics-reporter/ chart-visualization/
|
|
||||||
├── chinese-writing/ classroom-generator/
|
|
||||||
├── code-review/ consulting-analysis/
|
|
||||||
├── content-creator/ data-analysis/
|
|
||||||
├── data-consolidation-agent/ deep-research/
|
|
||||||
├── devops-automator/ evidence-collector/
|
|
||||||
├── ... (75 个目录,每个含 SKILL.md)
|
|
||||||
```
|
|
||||||
|
|
||||||
每个 SKILL.md 定义:
|
|
||||||
- 技能名称和描述
|
|
||||||
- 触发条件
|
|
||||||
- 执行步骤
|
|
||||||
- 输入/输出格式
|
|
||||||
|
|
||||||
### 语义路由
|
|
||||||
|
|
||||||
`crates/zclaw-skills/src/semantic_router.rs`
|
|
||||||
|
|
||||||
```
|
|
||||||
用户消息 → SemanticSkillRouter
|
|
||||||
→ TF-IDF 计算消息与 75 个技能的相似度
|
|
||||||
→ 返回 { skill_id, confidence }
|
|
||||||
→ ButlerRouter 使用 RoutingHint 增强 system prompt
|
|
||||||
```
|
|
||||||
|
|
||||||
在 kernel 中通过 `SemanticRouterAdapter` 桥接到 `ButlerRouterBackend` trait。
|
|
||||||
|
|
||||||
### Skill 调用链路(LLM Tool Calling)
|
|
||||||
|
|
||||||
```
|
|
||||||
Skills 目录 → SkillRegistry 加载 → SkillIndexMiddleware(P200) 注入系统提示
|
|
||||||
→ LLM 看到 skill_load + execute_skill 工具定义
|
|
||||||
→ LLM 生成 ToolUse{skill_load} → AgentLoop 执行 → 返回技能详情
|
|
||||||
→ LLM 生成 ToolUse{execute_skill} → AgentLoop 执行 → KernelSkillExecutor
|
|
||||||
→ Skill 执行结果 → ToolResult → LLM 继续对话
|
|
||||||
```
|
|
||||||
|
|
||||||
关键路径:
|
|
||||||
- `kernel/mod.rs:create_tool_registry()` 注册 7 个内置工具(含 skill_load, execute_skill)
|
|
||||||
- `runtime/loop_runner.rs` 检测 `ContentBlock::ToolUse` → 调用 `Tool::execute()`
|
|
||||||
- `runtime/tool/builtin/execute_skill.rs` → `KernelSkillExecutor::execute_skill()`
|
|
||||||
- Anthropic Driver: ToolResult 必须用 `ContentBlock::ToolResult{tool_use_id, content}` 格式
|
|
||||||
|
|
||||||
## MCP (Model Context Protocol)
|
|
||||||
|
|
||||||
### 概述
|
|
||||||
|
|
||||||
MCP 允许 ZCLAW 在运行时连接外部工具服务器(如 filesystem、database、custom tools),让 LLM 在对话中直接调用这些工具。
|
|
||||||
|
|
||||||
### 架构
|
|
||||||
|
|
||||||
```
|
|
||||||
前端 UI (MCPServices.tsx)
|
|
||||||
→ mcp-client.ts → Tauri invoke('mcp_start_service', {config})
|
|
||||||
→ McpManagerState → McpServiceManager → BasicMcpClient (stdio transport)
|
|
||||||
→ MCP Server 进程 → list_tools → 注册 adapters
|
|
||||||
|
|
||||||
LLM 对话调用:
|
|
||||||
Kernel.create_tool_registry()
|
|
||||||
→ 遍历 mcp_adapters (Arc<RwLock<Vec<McpToolAdapter>>>)
|
|
||||||
→ McpToolWrapper 包装为 Tool trait
|
|
||||||
→ 注册到 ToolRegistry → LLM API tool definitions
|
|
||||||
|
|
||||||
LLM 生成 ToolUse{filesystem.read_file}
|
|
||||||
→ AgentLoop → McpToolWrapper.execute()
|
|
||||||
→ McpToolAdapter.execute() → MCP Server → 结果返回
|
|
||||||
```
|
|
||||||
|
|
||||||
### 关键桥接机制
|
|
||||||
|
|
||||||
`McpManagerState` 和 `Kernel` 共享同一个 `Arc<RwLock<Vec<McpToolAdapter>>>`:
|
|
||||||
- Kernel boot 时,`kernel_init` 将 `McpManagerState.kernel_adapters` Arc 注入到 Kernel
|
|
||||||
- MCP 服务启动/停止时,`sync_to_kernel()` 更新共享列表
|
|
||||||
- `create_tool_registry()` 每次对话时读取最新 adapters
|
|
||||||
|
|
||||||
### MCP Tauri 命令 (4 个)
|
|
||||||
|
|
||||||
| 命令 | 功能 |
|
|
||||||
|------|------|
|
|------|------|
|
||||||
| `mcp_start_service` | 启动 MCP 服务 + 发现工具 + 同步到 Kernel |
|
| `crates/zclaw-hands/src/hands/` | 7 个 Hand 实现 (browser/collector/researcher/clip/twitter/quiz/reminder) |
|
||||||
| `mcp_stop_service` | 停止服务 + 从 Kernel 移除工具 |
|
| `crates/zclaw-runtime/src/tool/registry.rs` | ToolRegistry 工具注册表 |
|
||||||
| `mcp_list_services` | 列出所有运行中的服务和工具 |
|
| `crates/zclaw-runtime/src/tool/builtin/execute_skill.rs` | KernelSkillExecutor 技能执行 |
|
||||||
| `mcp_call_tool` | 手动调用 MCP 工具(支持 service_name 精确路由) |
|
| `crates/zclaw-skills/src/semantic_router.rs` | TF-IDF 语义路由 (路由细节见 [[butler]]) |
|
||||||
|
| `crates/zclaw-skills/src/` | 技能解析、索引、WASM runner |
|
||||||
|
| `crates/zclaw-runtime/src/nl_schedule.rs` | 中文时间->cron 解析器 (6 种模式) |
|
||||||
|
| `crates/zclaw-protocols/src/mcp_tool_adapter.rs` | MCP 工具适配器 + 服务管理 |
|
||||||
|
| `crates/zclaw-protocols/src/mcp.rs` | MCP 协议类型 + BasicMcpClient (stdio transport) |
|
||||||
|
| `crates/zclaw-runtime/src/tool/builtin/mcp_tool.rs` | McpToolWrapper (Tool trait 桥接) |
|
||||||
|
| `desktop/src/store/handStore.ts` | 前端 Hand 状态 |
|
||||||
|
| `desktop/src/lib/mcp-client.ts` | 前端 MCP 客户端 |
|
||||||
|
|
||||||
### 限定名规则
|
### Hand 触发流
|
||||||
|
|
||||||
MCP 工具在 ToolRegistry 中使用限定名 `service_name.tool_name` 避免冲突。
|
```
|
||||||
例如:`filesystem.read_file`, `database.query`。
|
LLM 生成 ToolUse{hand_name, params}
|
||||||
|
-> AgentLoop (loop_runner.rs) 检测 ContentBlock::ToolUse
|
||||||
|
-> ToolRegistry.get(hand_name) -> HandExecutor
|
||||||
|
-> needs_approval? -> 等待 approvalStore 确认 -> 用户批准
|
||||||
|
-> Hand.execute(params) -> 结果
|
||||||
|
-> ToolResult -> LLM 继续对话
|
||||||
|
-> Tauri Event emit -> handStore 更新状态
|
||||||
|
```
|
||||||
|
|
||||||
## API 接口
|
### 集成契约
|
||||||
|
|
||||||
### Hand Tauri 命令 (`desktop/src-tauri/src/kernel_commands/hand.rs`)
|
| 方向 | 模块 | 接口 / 触发点 |
|
||||||
|
|------|------|---------------|
|
||||||
|
| Called by <- | loop_runner | Tool 执行 | Every tool call during chat |
|
||||||
|
| Calls -> | browser/Twitter/etc | External APIs | Hand-specific operations |
|
||||||
|
| Provides -> | middleware: SkillIndex@200 | `skill_index.rs` | 技能索引注入 system prompt |
|
||||||
|
| Provides -> | mcp: McpToolWrapper | `Tool` trait | 外部工具桥接到 ToolRegistry |
|
||||||
|
|
||||||
|
### Hand Tauri 命令 (8 个)
|
||||||
|
|
||||||
| 命令 | 状态 | 说明 |
|
| 命令 | 状态 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -229,52 +73,111 @@ MCP 工具在 ToolRegistry 中使用限定名 `service_name.tool_name` 避免冲
|
|||||||
| `hand_run_list` | @connected | 运行列表 |
|
| `hand_run_list` | @connected | 运行列表 |
|
||||||
| `hand_run_cancel` | @reserved | 取消运行 (无前端 UI) |
|
| `hand_run_cancel` | @reserved | 取消运行 (无前端 UI) |
|
||||||
|
|
||||||
## 测试链路
|
### MCP 命令 (4 个)
|
||||||
|
|
||||||
| 功能 | Crate | 测试数 | 覆盖状态 |
|
`mcp_start_service`, `mcp_stop_service`, `mcp_list_services`, `mcp_call_tool`。
|
||||||
|------|-------|--------|---------|
|
MCP 工具在 ToolRegistry 中使用限定名 `service_name.tool_name` (如 `filesystem.read_file`)。
|
||||||
| Browser | zclaw-hands | 11 | ✅ |
|
`McpManagerState` 和 `Kernel` 共享 `Arc<RwLock<Vec<McpToolAdapter>>>`,通过 `sync_to_kernel()` 同步。
|
||||||
| Clip (视频) | zclaw-hands | 32 | ✅ |
|
|
||||||
| Collector | zclaw-hands | 9 | ✅ |
|
|
||||||
| DailyReport | zclaw-hands | 5 | ✅ |
|
|
||||||
| Quiz | zclaw-hands | 5 | ✅ |
|
|
||||||
| Researcher | zclaw-hands | 25 | ✅ |
|
|
||||||
| Twitter | zclaw-hands | 30 | ✅ |
|
|
||||||
| **Hands 小计** | | **117** | |
|
|
||||||
| 语义路由 | zclaw-skills | 7 | ✅ |
|
|
||||||
| WASM Runner | zclaw-skills | 2 | ✅ |
|
|
||||||
| 编排 (7 文件) | zclaw-skills | 17 | ✅ |
|
|
||||||
| **Skills 小计** | | **26** | |
|
|
||||||
| **合计** | | **143** | |
|
|
||||||
|
|
||||||
## 关联模块
|
## 3. 代码逻辑
|
||||||
|
|
||||||
- [[chat]] — 消息流中可能触发 Hand/Skill
|
### 7 注册 Hands
|
||||||
- [[butler]] — ButlerRouter 使用语义路由匹配技能
|
|
||||||
- [[middleware]] — SkillIndex 中间件注入技能索引
|
|
||||||
|
|
||||||
## 关键文件
|
| Hand | 功能 | 依赖 | 测试 | 配置 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| Browser | 浏览器自动化 (23 Tauri 命令) | WebDriver | 11 | `hands/browser.HAND.toml` |
|
||||||
|
| Collector | 数据收集聚合 | -- | 9 | `hands/collector.HAND.toml` |
|
||||||
|
| Researcher | 深度研究 + 网络搜索 | 网络 | 25 | `hands/researcher.HAND.toml` |
|
||||||
|
| Clip | 视频处理 | FFmpeg | 32 | `hands/clip.HAND.toml` |
|
||||||
|
| Twitter | Twitter 自动化 (12 API v2) | OAuth 1.0a | 30 | `hands/twitter.HAND.toml` |
|
||||||
|
| Quiz | 测验生成 | -- | 5 | `hands/quiz.HAND.toml` |
|
||||||
|
| _reminder | 定时提醒 (系统内部) | -- | -- | 无 TOML (代码注册) |
|
||||||
|
|
||||||
| 文件 | 职责 |
|
### Researcher 搜索能力 (04-22 修复)
|
||||||
|
|
||||||
|
- **搜索引擎**: Baidu + Bing CN 并行 (国内可用),DuckDuckGo fallback
|
||||||
|
- **网页获取**: Jina Reader API (优先,干净 Markdown) -> HTTP fetch (降级)
|
||||||
|
- **LLM 兼容**: 扁平化 input_schema (action/query/url/urls/engine),兼容 glm-5.1 等国产模型
|
||||||
|
- **空参数回退**: LLM 发送空 `{}` 时,loop_runner 注入 `_fallback_query` 自动搜索
|
||||||
|
|
||||||
|
### 定时提醒链路
|
||||||
|
|
||||||
|
```
|
||||||
|
用户消息 "每天早上9点提醒我查房"
|
||||||
|
-> agent_chat_stream (chat.rs)
|
||||||
|
-> has_schedule_intent() 关键词检测 (提醒我/定时/每天/每周等)
|
||||||
|
-> parse_nl_schedule() -> cron 表达式
|
||||||
|
-> ScheduleParseResult::Exact (confidence >= 0.8)
|
||||||
|
-> TriggerConfig { hand_id: "_reminder", trigger_type: Schedule { cron } }
|
||||||
|
-> kernel.create_trigger() -> TriggerManager 存储
|
||||||
|
-> 跳过 LLM 调用 (省 token)
|
||||||
|
-> SchedulerService 每60秒轮询 -> should_fire_cron() -> ReminderHand.execute()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skill 调用链路 (LLM Tool Calling)
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/ -> SkillRegistry 加载 -> SkillIndexMiddleware@200 注入系统提示
|
||||||
|
-> LLM 看到 skill_load + execute_skill 工具定义
|
||||||
|
-> LLM 生成 ToolUse{skill_load} -> AgentLoop -> 返回技能详情
|
||||||
|
-> LLM 生成 ToolUse{execute_skill} -> KernelSkillExecutor -> Skill 执行
|
||||||
|
-> ToolResult -> LLM 继续对话
|
||||||
|
```
|
||||||
|
|
||||||
|
关键: Anthropic Driver 要求 ToolResult 必须用 `ContentBlock::ToolResult{tool_use_id, content}` 格式。
|
||||||
|
|
||||||
|
### 不变量
|
||||||
|
|
||||||
|
- Hand 配置中 `enabled=false` 的 Hand 不会注册到 ToolRegistry
|
||||||
|
- Skill 调用通过 LLM Tool Calling,不是直接函数调用
|
||||||
|
- MCP 限定名 `service_name.tool_name` 避免与内置工具冲突
|
||||||
|
- 已删除空壳 Hands (04-17): Whiteboard/Slideshow/Speech,净减 ~5400 行
|
||||||
|
|
||||||
|
### ⚡ 新增工具/技能必须声明 concurrency 级别
|
||||||
|
|
||||||
|
`Tool` trait 的 `concurrency()` 方法决定并行执行策略 (04-24 Hermes Phase 2A):
|
||||||
|
|
||||||
|
| 级别 | 含义 | 适用场景 |
|
||||||
|
|------|------|---------|
|
||||||
|
| `ReadOnly` (默认) | 只读,始终可并行 | file_read, web_search, calculator |
|
||||||
|
| `Exclusive` | 有副作用,必须串行 | file_write, shell_exec, send_message, execute_skill, task |
|
||||||
|
| `Interactive` | 需要用户交互,永不并行 | ask_clarification |
|
||||||
|
|
||||||
|
**新增工具时**:在 `impl Tool for YourTool` 中覆盖 `concurrency()` 方法。默认 `ReadOnly`,如果有写操作/副作用必须返回 `ToolConcurrency::Exclusive`。未正确声明会导致并行执行时产生竞态条件。
|
||||||
|
|
||||||
|
## 4. 活跃问题 + 陷阱
|
||||||
|
|
||||||
|
### 活跃
|
||||||
|
|
||||||
|
| 问题 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Clip 依赖 FFmpeg | P3 | 用户需本地安装 FFmpeg,否则视频处理 Hand 不可用 |
|
||||||
|
| Hands E2E 通过率 ~70% | P2 | 10 Hand 全部启用,审批机制正常,但部分 Hand 边界场景未覆盖 |
|
||||||
|
| hand.rs TODO | P2 | tool_count/metric_count 待从实际 Hand 实例填充 |
|
||||||
|
|
||||||
|
### 历史 (已修复)
|
||||||
|
|
||||||
|
| 问题 | 修复 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `crates/zclaw-hands/src/hands/` | 7 个 Hand 实现 (6 有 TOML + _reminder 系统内部) |
|
| skill_execute 反序列化崩溃 | SEC2-P0-01 04-02 已修复 |
|
||||||
| `crates/zclaw-runtime/src/nl_schedule.rs` | 中文时间→cron 解析器 |
|
| Researcher 空参数 (glm-5.1 不理解 oneOf+const schema) | 04-22 schema 扁平化 + empty-input fallback |
|
||||||
| `crates/zclaw-skills/src/semantic_router.rs` | TF-IDF 语义路由 |
|
| 排版乱码 (stripToolNarration 句子级拆分破坏 markdown) | 04-22 行级过滤 |
|
||||||
| `crates/zclaw-skills/src/` | 技能解析和索引 |
|
|
||||||
| `skills/*/SKILL.md` | 75 个技能定义 |
|
|
||||||
| `hands/*.HAND.toml` | 6 个 Hand 配置 |
|
|
||||||
| `crates/zclaw-protocols/src/mcp_tool_adapter.rs` | MCP 工具适配器 + 服务管理 |
|
|
||||||
| `crates/zclaw-protocols/src/mcp.rs` | MCP 协议类型 + BasicMcpClient |
|
|
||||||
| `crates/zclaw-runtime/src/tool/builtin/mcp_tool.rs` | McpToolWrapper (Tool trait 桥接) |
|
|
||||||
| `crates/zclaw-runtime/src/driver/anthropic.rs` | Anthropic Driver (含 ToolResult 格式) |
|
|
||||||
| `desktop/src/store/handStore.ts` | 前端 Hand 状态 |
|
|
||||||
| `desktop/src/store/browserHandStore.ts` | Browser Hand 专用 |
|
|
||||||
| `desktop/src/lib/mcp-client.ts` | 前端 MCP 客户端 |
|
|
||||||
| `desktop/src-tauri/src/kernel_commands/mcp.rs` | MCP Tauri 命令 (4) + Kernel 桥接 |
|
|
||||||
|
|
||||||
## 已知问题
|
## 5. 变更日志
|
||||||
|
|
||||||
- ✅ **skill_execute 反序列化崩溃** — SEC2-P0-01 已于 04-02 修复
|
| 日期 | 变更 | 关联 |
|
||||||
- ✅ **Hands E2E 通过率 70%** — 10 Hand 全部启用,审批机制正常
|
|------|------|------|
|
||||||
- ⚠️ **hand.rs TODO** — P2-03: tool_count/metric_count 待从实际 Hand 实例填充
|
| 2026-04-24 | Hermes Phase 2A: ToolConcurrency 枚举 + 并行执行 + concurrency() 声明要求 | commit 9060935 |
|
||||||
| `desktop/src-tauri/src/kernel_commands/hand.rs` | Hand Tauri 命令 (8) |
|
| 2026-04-22 | Wiki 5-section 重构: 281->~195 行,语义路由细节引用 [[butler]] | wiki/ |
|
||||||
|
| 2026-04-22 | Researcher 搜索修复: schema 扁平化 + 空参数回退 + 排版修复 | commit 5816f56+81005c3 |
|
||||||
|
| 2026-04-17 | 空壳 Hand 清理: Whiteboard/Slideshow/Speech 删除,净减 ~5400 行 | Phase 5 清理 |
|
||||||
|
| 2026-04-16 | 3 项 P0 修复 + 5 项 E2E Bug 修复 | 三端联调测试 |
|
||||||
|
| 2026-04-09 | 管家模式交付: 语义路由 TF-IDF 接入 ButlerRouter | 6 交付物完成 |
|
||||||
|
|
||||||
|
### 测试概览
|
||||||
|
|
||||||
|
| 功能 | Crate | 测试数 |
|
||||||
|
|------|-------|--------|
|
||||||
|
| Hands (7 实现) | zclaw-hands | 117 |
|
||||||
|
| 语义路由 + WASM + 编排 | zclaw-skills | 26 |
|
||||||
|
| **合计** | | **143** |
|
||||||
|
|||||||
148
wiki/index.md
148
wiki/index.md
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: ZCLAW 项目知识库
|
title: ZCLAW 项目知识库
|
||||||
updated: 2026-04-21
|
updated: 2026-04-24
|
||||||
status: active
|
status: active
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -8,84 +8,57 @@ status: active
|
|||||||
|
|
||||||
> 面向中文用户的 AI Agent 桌面客户端。管家模式 + 多模型 + 7 自主能力 + 75 技能。
|
> 面向中文用户的 AI Agent 桌面客户端。管家模式 + 多模型 + 7 自主能力 + 75 技能。
|
||||||
> **使用方式**: 找到你要处理的模块,读对应页面,直接开始工作。
|
> **使用方式**: 找到你要处理的模块,读对应页面,直接开始工作。
|
||||||
> **数据来源**: 2026-04-19 代码全量扫描验证,非文档推测。
|
> **数据来源**: 2026-04-23 代码全量扫描验证,非文档推测。
|
||||||
|
|
||||||
## 项目画像
|
## 项目画像
|
||||||
|
|
||||||
| 维度 | 值 |
|
| 维度 | 值 |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| 定位 | AI Agent 桌面客户端 (Tauri 2.x) |
|
| 定位 | AI Agent 桌面客户端 (Tauri 2.x) |
|
||||||
| 技术栈 | Rust 10 crates + src-tauri (~102K行, 357 .rs文件) + React 19 + TypeScript + PostgreSQL |
|
| 技术栈 | Rust 10 crates + src-tauri (~148K行, 384 .rs) + React 19 + TypeScript + PostgreSQL |
|
||||||
| 阶段 | 发布前稳定化,功能冻结中 |
|
| 阶段 | 发布前稳定化,功能冻结中 |
|
||||||
|
|
||||||
## 关键数字(2026-04-19 代码验证)
|
## 关键数字(2026-04-23 代码验证)
|
||||||
|
|
||||||
| 指标 | 值 | 验证方式 |
|
| 指标 | 值 |
|
||||||
|------|-----|----------|
|
|------|-----|
|
||||||
| Rust Crates | 10 + src-tauri | `ls crates/zclaw-*/Cargo.toml` |
|
| Rust Crates | 10 + src-tauri |
|
||||||
| Rust 代码 | 101,967 行 (80,754 crates + 21,213 src-tauri, 357 .rs文件) | `wc -l` (2026-04-19 验证) |
|
| Rust 代码 | 148,185 行 (384 .rs文件) |
|
||||||
| Rust 测试 | 987 (640 #[test] + 347 #[tokio::test]) | `grep '#\[test\]'` 含 src-tauri (2026-04-19 验证) |
|
| Rust 测试 | 997 定义 (619 #[test] + 378 #[tokio::test]) |
|
||||||
| Rust 测试通过 | 797 workspace (sqlx 0.8 升级后) | `cargo test --workspace --exclude zclaw-saas` |
|
| Tauri 命令 | 193 定义 / 104 invoke |
|
||||||
| Tauri 命令 | 190 定义 | `grep '#\[.*tauri::command'` (2026-04-16 验证) |
|
| SaaS API | 137 .route() / 16 模块 / 38 SQL 迁移 / 42 表 |
|
||||||
| 前端 invoke 调用 | 104 处 / 91 唯一命令 | `grep invoke( desktop/src/` (2026-04-19 验证) |
|
| 中间件 | 14 层 runtime + 10 层 SaaS HTTP |
|
||||||
| @reserved 标注 | 97 个 (孤儿命令 ~0) | `grep @reserved src-tauri/` (2026-04-19 验证) |
|
| SKILL / HAND | 75 技能目录 / 7 注册 Hand (6 TOML + _reminder) |
|
||||||
| SaaS .route() | 137 个 | `grep .route( crates/zclaw-saas/` (2026-04-16 验证) |
|
| Pipeline | 18 YAML 模板 (8 目录) |
|
||||||
| SaaS 模块 | 16 个目录 | `ls crates/zclaw-saas/src/*/` (2026-04-19 验证) |
|
| 前端 | 25 Store / 103 组件 / 78 lib / 17 Admin 页面 |
|
||||||
| SKILL 目录 | 75 个 | `ls -d skills/*/` |
|
| Intelligence | 16 .rs 文件 |
|
||||||
| HAND 配置 | 6 TOML + 1 系统内部 (_reminder) = 7 注册 | `ls hands/*.HAND.toml` + kernel registry |
|
| 质量指标 | 0 cargo warnings / 2 TODO/FIXME / 0 dead_code |
|
||||||
| Pipeline YAML | 18 个 (8 目录) | `find pipelines/ -name "*.yaml"` (2026-04-19 验证) |
|
|
||||||
| Zustand Store | 25 个 (.ts, 含子目录 saas/5) | `find desktop/src/store/` (2026-04-19 验证) |
|
|
||||||
| React 组件 | 102 个 (.tsx/.ts, 11 子目录) | `find desktop/src/components/` (2026-04-19 验证) |
|
|
||||||
| Admin V2 页面 | 17 个 (.tsx) | `ls admin-v2/src/pages/` (2026-04-19 验证) |
|
|
||||||
| 中间件 | 14 层 runtime + 10 层 SaaS HTTP | `chain.register` 计数 (2026-04-22 验证) |
|
|
||||||
| 前端 lib/ | 75 个 .ts (71 顶层 + workflow-builder/3 + __tests__/1) | `find desktop/src/lib/` (2026-04-19 验证) |
|
|
||||||
| SQL 迁移 | 38 文件 (21 up + 17 down) / 42 CREATE TABLE | `ls crates/zclaw-saas/migrations/*.sql` (2026-04-19 验证) |
|
|
||||||
| Intelligence | 16 个 .rs 文件 | `ls src-tauri/src/intelligence/` (2026-04-19 验证) |
|
|
||||||
| Cargo Warnings | 0 (非 SaaS) | `cargo check --workspace --exclude zclaw-saas` |
|
|
||||||
| TODO/FIXME | 前端 1 + Rust 1 = 2 | `grep TODO/FIXME` (2026-04-19 验证) |
|
|
||||||
| dead_code | 0 个 | `grep '#\[dead_code\]'` (2026-04-19 验证) |
|
|
||||||
|
|
||||||
## 用户功能清单
|
## 用户功能清单
|
||||||
|
|
||||||
> ZCLAW 能做什么?按用户视角组织,快速定位功能所属模块。
|
| 类别 | 功能 | 入口 | Wiki |
|
||||||
|
|------|------|------|------|
|
||||||
| 类别 | 功能 | 用户入口 | Wiki 详情 |
|
| 对话 | 发消息、流式响应、多模型切换、LLM 动态建议 | 聊天面板 | [[chat]] |
|
||||||
|------|------|----------|-----------|
|
| 分身 | 创建/切换/配置 Agent、跨会话身份记忆 (soul.md) | 侧边栏 Agent 列表 | [[chat]] |
|
||||||
| 对话 | 发消息、流式响应、多模型切换 | 聊天面板 | [[chat]] |
|
|
||||||
| 分身 | 创建/切换/配置 Agent | 侧边栏 Agent 列表 | [[chat]] |
|
|
||||||
| 自主 | 触发 Browser/Collector/Twitter 等 | 自动化面板 | [[hands-skills]] |
|
| 自主 | 触发 Browser/Collector/Twitter 等 | 自动化面板 | [[hands-skills]] |
|
||||||
| 记忆 | 搜索历史、自动注入上下文 | 设置 > 语义记忆 | [[memory]] |
|
| 记忆 | 搜索历史、自动注入上下文、身份信号提取 | 设置 > 语义记忆 | [[memory]] |
|
||||||
| 配置 | 模型/API/工作区/安全存储 | 设置面板 (19 页) | [[development]] |
|
| 配置 | 模型/API/工作区/安全存储 | 设置面板 (19 页) | [[development]] |
|
||||||
| SaaS | 登录注册、订阅计费、Admin 管理 | SaaS 平台 / Admin 后台 | [[saas]] |
|
| SaaS | 登录注册、订阅计费、Admin 管理 | SaaS 平台 / Admin 后台 | [[saas]] |
|
||||||
| 管家 | 痛点积累、行业配置、简洁/专业模式 | 聊天面板 (默认模式) | [[butler]] |
|
| 管家 | 痛点积累、行业配置、简洁/专业模式、跨会话身份、动态建议 | 聊天面板 (默认模式) | [[butler]] |
|
||||||
| Pipeline | YAML 模板选择、配置、DAG 执行 | 工作流面板 | [[pipeline]] |
|
| Pipeline | YAML 模板选择、配置、DAG 执行 | 工作流面板 | [[pipeline]] |
|
||||||
| 安全 | JWT 认证、TOTP 2FA、操作审计 | 设置 > 安全存储 | [[security]] |
|
| 安全 | JWT 认证、TOTP 2FA、操作审计 | 设置 > 安全存储 | [[security]] |
|
||||||
| 数据 | PostgreSQL (SaaS 42表) + SQLite/FTS5 (本地记忆) | — | [[data-model]] |
|
| 数据 | PostgreSQL (42表) + SQLite/FTS5 (本地记忆) | — | [[data-model]] |
|
||||||
|
|
||||||
## 跨模块数据流全景图
|
## 跨模块数据流全景图
|
||||||
|
|
||||||
> 一个请求的完整生命周期(SaaS relay 主路径)。详细流程见 [[routing]] 和 [[chat]]。
|
> 请求的完整生命周期(SaaS relay 主路径)。详细流程见 [[routing]] 和 [[chat]]。
|
||||||
|
|
||||||
```
|
```
|
||||||
用户输入
|
用户输入 → React 组件 → Zustand Store → getClient() 路由决策
|
||||||
↓
|
├── SaaS Relay (主路径): SSE → Token Pool → LLM Provider → 流式返回
|
||||||
React 组件 (ChatPanel.tsx)
|
└── 本地 Kernel (降级): Tauri invoke → Runtime → Middleware Chain (14层) → LLM Driver
|
||||||
↓
|
↓
|
||||||
Zustand Store (chatStore.sendMessage)
|
streamStore.onDelta ← Tauri Event emit ←←←←←←←←←←←←←←←←←←←←←←←←←←←←
|
||||||
↓
|
|
||||||
getClient() 路由决策 ──→ SaaS Relay (主路径) ──→ 本地 Kernel (降级)
|
|
||||||
↓ ↓ ↓
|
|
||||||
Tauri invoke SSE 连接 直接调用
|
|
||||||
↓ ↓ ↓
|
|
||||||
Kernel Runtime SaaS → Token Pool Runtime
|
|
||||||
↓ → LLM Provider ↓
|
|
||||||
Middleware Chain (15层) ↓ Middleware Chain
|
|
||||||
↓ 流式 SSE 返回 ↓
|
|
||||||
LLM Driver ←─────────────────┘ LLM Driver
|
|
||||||
↓ ↓
|
|
||||||
Tauri Event emit Tauri Event emit
|
|
||||||
↓ ↓
|
|
||||||
streamStore.onDelta ←────────────────────────────┘
|
|
||||||
↓
|
↓
|
||||||
UI 更新 (消息气泡渲染)
|
UI 更新 (消息气泡渲染)
|
||||||
```
|
```
|
||||||
@@ -94,50 +67,37 @@ UI 更新 (消息气泡渲染)
|
|||||||
|
|
||||||
```
|
```
|
||||||
ZCLAW
|
ZCLAW
|
||||||
├── [[routing]] 客户端路由 — 连接断了吗?数据走哪条路?看这里
|
├── [[routing]] 客户端路由 — 连接断了吗?数据走哪条路?
|
||||||
│ └── [[chat]] 聊天系统 — 消息怎么发?流式怎么接?Store 怎么拆?
|
│ └── [[chat]] 聊天系统 — 消息怎么发?流式怎么接?Store 怎么拆?
|
||||||
│
|
├── [[saas]] SaaS平台 — 用户/计费/Admin API
|
||||||
├── [[saas]] SaaS平台 — 用户/计费/Admin API 都在这里
|
├── [[butler]] 管家模式 — 行业配置、痛点积累、简洁/专业模式
|
||||||
│ ├── 认证 JWT + Cookie + Token池 RPM/TPM轮换
|
├── [[middleware]] 中间件链 — 请求处理、优先级排序
|
||||||
│ ├── 计费 配额实时递增 + Alipay/WeChat
|
├── [[memory]] 记忆管道 — 对话→记忆→检索→注入
|
||||||
│ └── Admin V2 17页管理后台
|
├── [[hands-skills]] Hands(7注册) + Skills(75) — 动作与技能
|
||||||
│
|
├── [[pipeline]] Pipeline DSL — 工作流配置、DAG 执行
|
||||||
├── [[butler]] 管家模式 — 用户看到什么?行业怎么配?痛点怎么积?
|
├── [[security]] 安全体系 — JWT/Cookie/TOTP/CSP/限流
|
||||||
│
|
├── [[data-model]] 数据模型 — 42表 PostgreSQL + FTS5 本地
|
||||||
├── [[middleware]] 中间件链 — 请求经过哪些处理?优先级怎么排?
|
├── [[feature-map]] 功能链路映射 — 前端到后端完整路径+测试
|
||||||
│
|
|
||||||
├── [[memory]] 记忆管道 — 对话怎么变记忆?怎么检索?怎么注入?
|
|
||||||
│
|
|
||||||
├── [[hands-skills]] Hands(7注册) + Skills(75) — Agent能做什么动作?懂什么技能?
|
|
||||||
│
|
|
||||||
├── [[pipeline]] Pipeline DSL — 工作流怎么配?DAG怎么跑?有哪些模板?
|
|
||||||
│
|
|
||||||
├── [[security]] 安全体系 — JWT/Cookie/TOTP/CSP/限流/加密
|
|
||||||
│
|
|
||||||
├── [[data-model]] 数据模型 — 42表PostgreSQL + FTS5本地存储
|
|
||||||
│
|
|
||||||
├── [[feature-map]] 功能链路映射 — 每个功能从前端到后端的完整路径+测试
|
|
||||||
│
|
|
||||||
├── [[development]] 开发规范 — 闭环工作法/验证命令/提交规范
|
├── [[development]] 开发规范 — 闭环工作法/验证命令/提交规范
|
||||||
├── [[known-issues]] 已知问题 — P0/P1已修复,P2待处理
|
├── [[known-issues]] 已知问题 — P0/P1已修复,P2待处理
|
||||||
└── [[log]] 变更日志 — append-only
|
└── [[log]] 变更日志 — append-only
|
||||||
```
|
```
|
||||||
|
|
||||||
## 核心架构决策(为什么这样设计)
|
## 症状导航
|
||||||
|
|
||||||
**Q: 为什么 Tauri 不直连 LLM?**
|
> 出问题了?按症状查表,先查"先查"列,再查"再查"列。
|
||||||
→ 因为 SaaS Token Pool 集中管理 API Key,支持用量追踪、计费、模型白名单。直连是降级后备。
|
|
||||||
|
|
||||||
**Q: 为什么有3种 ChatStream?**
|
| 症状 | 先查 | 再查 | 常见根因 |
|
||||||
→ GatewayClient(WS) 用于外部进程,KernelClient(Tauri Event) 用于桌面端,SaaSRelayGatewayClient(SSE) 用于浏览器。Tauri 桌面端的 KernelClient 通过 `baseUrl` 指向 SaaS relay 实现间接中转。
|
|------|------|------|----------|
|
||||||
|
| 流式响应卡住 | [[routing]] | [[chat]] → [[middleware]] | 连接断开 / SaaS relay 超时 |
|
||||||
**Q: 为什么管家模式是默认?**
|
| 记忆没有注入 | [[memory]] | [[middleware]] | FTS5 索引空 / 中间件跳过 |
|
||||||
→ 面向医院行政等非技术用户,语义路由(75技能TF-IDF)+痛点积累+方案生成,降低使用门槛。
|
| Hand 触发失败 | [[hands-skills]] | [[middleware]] | 工具调用被 Guardrail 拦截 |
|
||||||
|
| SaaS relay 502 | [[saas]] | [[routing]] | Token Pool 耗尽 / Key 过期 |
|
||||||
**Q: 为什么中间件是14层runtime?**
|
| 模型切换不生效 | [[routing]] | [[chat]] | SaaS 白名单 vs 本地配置不一致 |
|
||||||
→ 按优先级分6类: 78进化(Evolution) → 80-99路由(Butler) → 100-199上下文(Compaction/Memory/Title) → 200-399能力(SkillIndex/DanglingTool/ToolError/ToolOutputGuard) → 400-599安全(Guardrail/LoopGuard/SubagentLimit) → 600-799遥测(TrajectoryRecorder/TokenCalibration)。另有 10 层 SaaS HTTP 中间件 (限流/认证/配额/CORS/日志等)。
|
| Agent 创建失败 | [[chat]] | [[saas]] | 权限或持久化问题 |
|
||||||
|
| Pipeline 执行卡住 | [[pipeline]] | [[middleware]] | DAG 循环 / 依赖缺失 |
|
||||||
**Q: zclaw-growth 的进化引擎做什么?**
|
| Admin 页面 403 | [[saas]] | [[security]] | JWT 过期 / admin_guard 拦截 |
|
||||||
→ EvolutionEngine 负责从对话历史中检测行为模式变化,生成进化候选项(如新技能建议、工作流优化),通过 EvolutionMiddleware@78 注入 system prompt。配合 FeedbackCollector、PatternAggregator、QualityGate、SkillGenerator、WorkflowComposer 形成自我改进闭环。
|
| Agent 名字不记住 | [[butler]] | [[memory]] | soul.md 写入失败 / identity signal 未提取 |
|
||||||
|
| 建议不个性化 | [[chat]] | [[butler]] | 4路上下文超时 / ExperienceExtractor 未初始化 |
|
||||||
|
|
||||||
> 数字真相源: `docs/TRUTH.md` — 如有冲突以代码实际为准
|
> 数字真相源: `docs/TRUTH.md` — 如有冲突以代码实际为准
|
||||||
|
|||||||
@@ -1,276 +1,38 @@
|
|||||||
---
|
---
|
||||||
title: 已知问题
|
title: 已知问题索引
|
||||||
updated: 2026-04-22
|
updated: 2026-04-22
|
||||||
status: active
|
status: active
|
||||||
tags: [issues, bugs]
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 已知问题
|
# 已知问题索引
|
||||||
|
|
||||||
> 从 [[index]] 导航。完整清单见 `docs/TRUTH.md §3`
|
> 活跃问题已迁移至各模块页面的"活跃问题+陷阱"章节。本文件仅作索引用。
|
||||||
|
|
||||||
## 当前状态
|
## 活跃问题
|
||||||
|
|
||||||
| 级别 | 数量 | 状态 |
|
| 模块 | 问题 | 级别 | 详见 |
|
||||||
|------|------|------|
|
|------|------|------|------|
|
||||||
| P0 (崩溃) | 2 | 全部已修复 |
|
| saas | Admin 用量统计 0/0 | P2 | [[saas]] |
|
||||||
| P1 (功能失效) | 9 | 全部已修复 |
|
| saas | 桌面端 Token 统计为 0 | P2 | [[saas]] |
|
||||||
| P1.5 (代码质量) | 7 | 全部已修复 |
|
| saas | Deepseek 中转任务卡 processing | P3 | [[saas]] |
|
||||||
| P2 (代码质量) | 10 | 待处理 |
|
| chat | B-CHAT-07 混合域截断 | P2 | [[chat]] |
|
||||||
| V13 P1 (断链) | 3 | **全部已修复** |
|
| middleware | SkillIndex 条件注册(无技能不注册) | 长期 | [[middleware]] |
|
||||||
| V13 P2 (差距) | 3 | **全部已修复** |
|
| memory | Embedding 未激活 (NoOpEmbeddingClient) | 长期 | [[memory]] |
|
||||||
| E2E 04-17 HIGH | 2 | **全部已修复** (commit a504a40) |
|
| saas | SaaS embedding deferred (pgvector 就绪未实现) | 长期 | [[saas]] |
|
||||||
| E2E 04-17 MEDIUM | 5 | **全部已修复** (M4 admin_guard_middleware 已添加) |
|
| routing | Tauri 命令孤儿 (~0, 差异来自内部调用) | 长期 | [[routing]] |
|
||||||
| E2E 04-17 LOW | 2 | **全部已验证修复** (L1 代码已统一 + L2 反序列化已修复) |
|
|
||||||
| 审计 04-20 P0 | 2 | **全部已修复** (commit f291736) |
|
|
||||||
| 审计 04-20 P1 | 3 | **全部已修复** (commit f291736) |
|
|
||||||
| 审计 04-20 P2 | 2 | 待处理 (B-SCHED-5 任务名噪声 + B-CHAT-7 混合域截断) |
|
|
||||||
| 搜索 04-22 P1 | 3 | **全部已修复** (commit 5816f56 + 81005c3) |
|
|
||||||
| DataMasking 04-22 P1 | 1 | **已移除** (DataMasking 中间件彻底删除) |
|
|
||||||
|
|
||||||
## 搜索功能修复 04-22
|
## 代码健康度(2026-04-19)
|
||||||
|
|
||||||
| ID | 级别 | 问题 | 修复 | commit |
|
|
||||||
|------|------|------|------|--------|
|
|
||||||
| SEARCH-1 | P1 | glm-5.1 不理解 oneOf+const schema,tool_calls 参数为空 `{}` | 扁平化 input_schema (action/query/url/urls/engine) + empty-input 回退注入 | 5816f56 |
|
|
||||||
| SEARCH-2 | P1 | DuckDuckGo 被墙,搜索优先使用 Google | 改为 Baidu + Bing CN 并行,DDG 仅 fallback | 5816f56 |
|
|
||||||
| SEARCH-3 | P1 | stripToolNarration 按句子拆分破坏 markdown 排版 | 改为行级过滤,保留 markdown 结构行 | 81005c3 |
|
|
||||||
|
|
||||||
## DataMasking 过度匹配修复 04-22
|
|
||||||
|
|
||||||
| ID | 级别 | 问题 | 修复 | commit |
|
|
||||||
|------|------|------|------|--------|
|
|
||||||
| MASK-1 | P1 | DataMasking 正则把"有一家公司"误判为公司实体,替换为 `__ENTITY_1__`;LLM 响应缺少 unmask 导致用户看到占位符 | **已移除** — DataMasking 中间件彻底删除 (data_masking.rs 367行 + loop_runner unmask 逻辑 + 前端 mask/unmask) | 73d50fd (禁用) + 后续完全移除 |
|
|
||||||
|
|
||||||
## E2E 全系统功能测试 04-17 (129 链路)
|
|
||||||
|
|
||||||
> AI Agent 自动执行 (Tauri MCP + Chrome DevTools MCP + HTTP API)
|
|
||||||
> 完整报告: `docs/test-evidence/2026-04-17/E2E_TEST_REPORT_2026_04_17.md`
|
|
||||||
|
|
||||||
### 通过率概要
|
|
||||||
|
|
||||||
| 指标 | 值 |
|
| 指标 | 值 |
|
||||||
|------|-----|
|
|------|-----|
|
||||||
| 总链路 | 129 |
|
| TODO/FIXME | 前端 1 + Rust 1 = 2 |
|
||||||
| PASS | 82 (63.6%) |
|
| @reserved 标注 | 97 |
|
||||||
| PARTIAL | 20 (15.5%) |
|
| dead_code 标记 | 0 |
|
||||||
| FAIL | 1 (0.8%) |
|
| 前端孤立 invoke | 0 |
|
||||||
| SKIP | 26 (20.2%) |
|
| Cargo Warnings | 0 (非 SaaS) |
|
||||||
| 有效通过率 | 102/129 = 79.1% |
|
| 前端测试 | 344 + 1 skipped |
|
||||||
| CRITICAL 失败 | 0 |
|
| Rust 测试 | 797 通过 |
|
||||||
| SaaS API 覆盖率 | ~78% (50/64 端点) |
|
|
||||||
|
|
||||||
### HIGH (2) — ✅ 已修复
|
## 已归档
|
||||||
|
|
||||||
| ID | 模块 | 描述 | 状态 |
|
- 全量问题记录: `wiki/archive/known-issues-full-2026-04-22.md`
|
||||||
|----|------|------|------|
|
|
||||||
| BUG-H1 | V7 Admin | Dashboard 端点 404: `/api/v1/admin/dashboard` 未注册路由 | ✅ 已修复 (a504a40) |
|
|
||||||
| BUG-H2 | V4 Memory | 记忆不去重: viking_add 相同 URI+content 添加两次均返回 "added" | ✅ 已修复 (a504a40) |
|
|
||||||
|
|
||||||
### MEDIUM (5)
|
|
||||||
|
|
||||||
| ID | 模块 | 描述 | 状态 |
|
|
||||||
|----|------|------|------|
|
|
||||||
| BUG-M1 | V8 Billing | invoice_id 未暴露给用户端 | ✅ 已修复 (a504a40) |
|
|
||||||
| BUG-M2 | V7 Prompt | 版本号不自增: PUT 更新后 current_version 保持 1 | ✅ 已修复 (a504a40) |
|
|
||||||
| BUG-M3 | V4 Memory | viking_find 不按 agent 隔离: 查询返回所有 agent 记忆 | ✅ 已修复 (a504a40) |
|
|
||||||
| BUG-M4 | V3 Auth | Admin 端点对非 admin 用户返回 404 非 403 | ✅ 已修复 (admin_guard_middleware) |
|
|
||||||
| BUG-M5 | V4 Memory | 跨会话记忆注入未工作: 新会话助手表示"没有找到对话历史" | ✅ 已修复 (a504a40) |
|
|
||||||
| BUG-M6 | V4 Memory | profile_store未连接+双数据库不一致导致UserProfile永远为空 | ✅ 已修复 (adf0251) |
|
|
||||||
|
|
||||||
### LOW (2)
|
|
||||||
|
|
||||||
| ID | 模块 | 描述 | 状态 |
|
|
||||||
|----|------|------|------|
|
|
||||||
| BUG-L1 | V3 Industry | API 字段名不一致 (pain_seeds vs pain_seed_categories) | ✅ 已验证修复 (代码已统一为 pain_seed_categories) |
|
|
||||||
| BUG-L2 | V9 Pipeline | pipeline_create Tauri 命令参数反序列化失败 | ✅ 已验证修复 (04-17 回归) |
|
|
||||||
|
|
||||||
### 04-17 回归验证 (13/13 PASS)
|
|
||||||
|
|
||||||
> Tauri MCP + HTTP API 全量回归,验证 commit a504a40 修复有效性 + 子系统链路
|
|
||||||
|
|
||||||
**Phase 1 — Bug 修复回归 (6/6 PASS)**
|
|
||||||
|
|
||||||
| ID | 验证方法 | 结果 |
|
|
||||||
|----|----------|------|
|
|
||||||
| H1 Dashboard | HTTP GET /admin/dashboard → 200 | PASS |
|
|
||||||
| H2 Memory 去重 | viking_add × 2 → 第二次 "deduped" | PASS |
|
|
||||||
| M1 Invoice ID | POST /billing/payments → 含 invoice_id | PASS |
|
|
||||||
| M2 Prompt 版本 | PUT → current_version 1→2 | PASS |
|
|
||||||
| M3 Agent 隔离 | viking_find scope → 各返回 1 条无泄漏 | PASS |
|
|
||||||
| M5 跨会话注入 | memory_build_context → 检索到旧记忆 | PASS |
|
|
||||||
|
|
||||||
**Phase 2 — 子系统链路 (4/4 PASS)**
|
|
||||||
|
|
||||||
| 测试项 | 结果 |
|
|
||||||
|--------|------|
|
|
||||||
| Pipeline list → 17 模板 | PASS |
|
|
||||||
| Pipeline create → camelCase 反序列化 | PASS |
|
|
||||||
| Pipeline run → DAG 构建+执行(未配LLM) | PASS (链路通) |
|
|
||||||
| Skill 75 + route_intent 匹配 | PASS |
|
|
||||||
|
|
||||||
**Phase 3 — Butler + 记忆 (3/3 PASS)**
|
|
||||||
|
|
||||||
| 测试项 | 结果 |
|
|
||||||
|--------|------|
|
|
||||||
| Kernel init → 4 agents | PASS |
|
|
||||||
| agent_chat_stream → 事件分发 | PASS |
|
|
||||||
| health_snapshot + memory_stats → 381 记忆 | PASS |
|
|
||||||
|
|
||||||
### 子系统健康度
|
|
||||||
|
|
||||||
| 子系统 | PASS率 | 评分 | 说明 |
|
|
||||||
|--------|--------|------|------|
|
|
||||||
| 核心聊天链路 | 91.7% | 95/100 | 注册→登录→JWT→聊天→流式→持久化全闭环 |
|
|
||||||
| SaaS 后端 | — | 90/100 | 137 端点,78% 已测试 |
|
|
||||||
| Admin 后台 | 66.7% | 88/100 | 全页面 CRUD,Dashboard 404 已修复 |
|
|
||||||
| Hands 自主能力 | 70.0% | 85/100 | 10 Hand 全部 enabled,审批机制正确 |
|
|
||||||
| 计费系统 | 70.0% | 85/100 | 套餐/配额/支付全闭环 |
|
|
||||||
| 管家模式 | 60.0% | 80/100 | 路由+追问+tool_call 正常 |
|
|
||||||
| 记忆管道 | 62.5% | 70/100 | 存储+检索正常,去重/注入已修复 |
|
|
||||||
| Pipeline+Skill | 37.5% | 65/100 | Tauri IPC 可用但参数格式问题多 |
|
|
||||||
|
|
||||||
## V13 审计修复 (2026-04-13 全部完成)
|
|
||||||
|
|
||||||
### P1 — 功能断链 ✅ 全部已修复
|
|
||||||
|
|
||||||
| ID | 问题 | 修复 |
|
|
||||||
|----|------|------|
|
|
||||||
| V13-GAP-01 | TrajectoryRecorderMiddleware 未注册到中间件链 | ✅ 已注册 @650,Hermes 轨迹数据开始流入 |
|
|
||||||
| V13-GAP-02 | industryStore 存在但无组件导入 | ✅ 已接入 ButlerPanel,桌面端展示行业专长卡片 |
|
|
||||||
| V13-GAP-03 | 桌面端未接入 Knowledge Search API | ✅ saas-knowledge mixin + VikingPanel SaaS KB 搜索 UI |
|
|
||||||
|
|
||||||
### P2 — 代码清洁度 ✅ 全部已修复
|
|
||||||
|
|
||||||
| ID | 问题 | 修复 |
|
|
||||||
|----|------|------|
|
|
||||||
| V13-GAP-04 | Webhook 孤儿表 | ✅ deprecated 标注 + down migration 注释 |
|
|
||||||
| V13-GAP-05 | Structured Data Source 无 Admin UI | ✅ Admin Knowledge 新增"结构化数据"Tab |
|
|
||||||
| V13-GAP-06 | PersistentMemoryStore 遗留模块 | ✅ 全量移除 — persistent.rs 611→57 行 |
|
|
||||||
|
|
||||||
## Heartbeat 参数名修复 (2026-04-16)
|
|
||||||
|
|
||||||
| 问题 | 级别 | 状态 |
|
|
||||||
|------|------|------|
|
|
||||||
| Tauri invoke 参数名 snake_case 错误 | P1 | ✅ 已修复 |
|
|
||||||
|
|
||||||
**根因**: Tauri 2.x `#[tauri::command]` 默认 `rename_all = "camelCase"`,前端 invoke 必须用 camelCase(`agentId` 不是 `agent_id`)。`intelligence-client.ts` 中 3 处 invoke 调用使用了错误的 snake_case。
|
|
||||||
|
|
||||||
**修复**: commit `f6c5dd2` — 3 处参数名修正 + HealthPanel.tsx 恢复正确命名。
|
|
||||||
|
|
||||||
**教训**: 所有 Tauri invoke 调用的参数名必须用 camelCase,与 Rust 端 snake_case 参数名对应。参见 `browser-client.ts` 中已有的正确示例。
|
|
||||||
|
|
||||||
## Relay API Key 解密自愈 (2026-04-16)
|
|
||||||
|
|
||||||
| 问题 | 级别 | 状态 |
|
|
||||||
|------|------|------|
|
|
||||||
| Provider Key 解密失败导致整个 relay 500 | P1 | ✅ 已修复 |
|
|
||||||
|
|
||||||
**根因**: `key_pool.rs` 的 `select_best_key` 遍历 key 时,第一个解密失败的 key 就通过 `?` 直接返回 500,不会尝试下一个。如果 DB 中有旧的加密 key(密钥已变更),整个 relay 请求被阻断。重新保存只能临时解决,旧 key 仍在 DB 中。
|
|
||||||
|
|
||||||
**修复**: commit `b69dc61`:
|
|
||||||
- 解密失败时 `warn + continue` 跳到下一个 key
|
|
||||||
- 启动自愈 `heal_provider_keys()`: 逐个解密并重新加密,无法解密的标记 inactive
|
|
||||||
|
|
||||||
**教训**: 密钥池选择应容错(skip bad keys),而不是 fail-fast。加密数据迁移应自动化。
|
|
||||||
|
|
||||||
## 设置页面清理 (2026-04-16)
|
|
||||||
|
|
||||||
| 变更 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| 删除"用量统计"页面 | 与"订阅与计费"功能重复 |
|
|
||||||
| 删除"积分详情"页面 | 与"订阅与计费"功能重复 |
|
|
||||||
|
|
||||||
commit `7dea456` — 移除 UsageStats + Credits 组件及菜单项。
|
|
||||||
|
|
||||||
## 三端联调测试 V2 (2026-04-15)
|
|
||||||
|
|
||||||
通过 Chrome DevTools MCP + Tauri MCP 实际界面操作验证。
|
|
||||||
|
|
||||||
### 已修复
|
|
||||||
|
|
||||||
| 问题 | 级别 | 修复 |
|
|
||||||
|------|------|------|
|
|
||||||
| SSE 中转任务 Token (入/出) 全部为 0 | P2 | ✅ SseUsageCapture 增加 stream_done 标志 + 前缀兼容 |
|
|
||||||
|
|
||||||
### 已验证通过
|
|
||||||
|
|
||||||
| 功能 | 状态 | 验证方式 |
|
|
||||||
|------|------|----------|
|
|
||||||
| 桌面端登录 (SaaS 模式) | ✅ | Tauri MCP 实际登录 |
|
|
||||||
| 聊天流 (kimi-for-coding) | ✅ | 发送消息并收到流式回复 |
|
|
||||||
| 模型切换 | ✅ | 切换 deepseek → kimi |
|
|
||||||
| 智能体面板 | ✅ | 显示"默认助手" |
|
|
||||||
| 设置 20 个选项卡 | ✅ | 逐页检查:用量统计/模型/记忆/SaaS平台 |
|
|
||||||
| 语义记忆搜索 | ✅ | 100 条记忆,FTS5 + TF-IDF |
|
|
||||||
| Admin V2 仪表盘 | ✅ | Chrome DevTools: 30 账号/3 服务商/17 请求 |
|
|
||||||
| Admin V2 账号管理 | ✅ | 30 用户正常展示 |
|
|
||||||
| Admin V2 模型服务 | ✅ | DeepSeek/Kimi/zhipu 3 个 Provider |
|
|
||||||
| Admin V2 API 密钥 | ✅ | 不再崩溃(上次修复验证) |
|
|
||||||
| Admin V2 知识库 | ✅ | 6 条目 + 5 个 Tab |
|
|
||||||
| Admin V2 行业配置 | ✅ | 4 个内置行业 |
|
|
||||||
| Admin V2 计费管理 | ✅ | 团队版 570/20000 中转请求 |
|
|
||||||
| Admin V2 角色权限 | ✅ | 3 角色(超管/管理/用户) |
|
|
||||||
| Admin V2 操作日志 | ✅ | 2088 条记录 |
|
|
||||||
| Admin V2 Agent 模板 | ✅ | 10 模板(3 内置 + 7 自定义) |
|
|
||||||
|
|
||||||
### 待处理 / 观察项
|
|
||||||
|
|
||||||
| 问题 | 级别 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Admin 用量统计 0/0 | P2 | 用量统计页显示请求=0/Token=0,但仪表盘显示 17 请求/6304 Token。数据来源不同 |
|
|
||||||
| Deepseek 中转任务卡 processing | P3 | Provider Key 禁用后已有任务不会自动清理,需手动处理 |
|
|
||||||
| 桌面端 Token 统计为 0 | P2 | 用量统计页 Token 输入/输出=0,但图表显示 ~3.6M,数据不一致 |
|
|
||||||
|
|
||||||
## 三端联调测试 (2026-04-14)
|
|
||||||
|
|
||||||
30+ API / 16 Admin / 8 Tauri 全量测试结果:
|
|
||||||
|
|
||||||
| 问题 | 级别 | 状态 |
|
|
||||||
|------|------|------|
|
|
||||||
| API 密钥页崩溃 (undefined .map) | P1 | ✅ 已修复 |
|
|
||||||
| 桌面端 401 后不自动恢复 | P1 | ✅ 已修复 |
|
|
||||||
| 用量统计全零 (telemetry SQL timestamptz) | P1 | ✅ 已修复 |
|
|
||||||
| 行业选择 500 (industry 类型匹配) | P1 | ✅ 已修复 |
|
|
||||||
| 管理员切换订阅计划 500 | P1 | ✅ 已修复 |
|
|
||||||
| SaaS 启动崩溃 (config_items 约束) | P1 | ✅ 已修复 |
|
|
||||||
| SaaS 模型选择残留模型 ID | P0 | ✅ 已修复 |
|
|
||||||
|
|
||||||
## 代码健康度指标(2026-04-19)
|
|
||||||
|
|
||||||
| 指标 | 值 | 变化 | 说明 |
|
|
||||||
|------|-----|------|------|
|
|
||||||
| TODO/FIXME 前端 | 1 | 不变 | memory-extractor.ts |
|
|
||||||
| TODO/FIXME Rust | 1 | 3→1 | 已清理 |
|
|
||||||
| @reserved 标注 | 97 | 89→97 | 04-19 新增标注 |
|
|
||||||
| dead_code 标记 | 0 | 16→0 | 全部清理 |
|
|
||||||
| 前端孤立 invoke | 0 | 不变 | 已清理 |
|
|
||||||
| Cargo Warnings | 0 | 不变 | 非 SaaS,仅 sqlx 外部 |
|
|
||||||
| 前端测试通过 | 344+1 skipped | 不变 | pnpm vitest run |
|
|
||||||
| Rust 测试 (workspace) | 797 通过 | 684→797 | sqlx 0.8 升级 + 测试补充 |
|
|
||||||
|
|
||||||
## 长期观察项
|
|
||||||
|
|
||||||
| 问题 | 说明 | 位置 |
|
|
||||||
|------|------|------|
|
|
||||||
| Tauri 命令孤儿 | 注册 190 命令,前端调用 104 处,@reserved 97 个,剩余 ~0 个 (差异来自内部命令调用) | `desktop/src-tauri/src/lib.rs` |
|
|
||||||
| Embedding 未激活 | NoOpEmbeddingClient 为默认值,用户配置后替换为真实 provider | `zclaw-growth/src/retrieval/semantic.rs` |
|
|
||||||
| SaaS embedding deferred | pgvector 索引就绪,生成未实现 | `zclaw-saas/src/workers/generate_embedding.rs` |
|
|
||||||
| SkillIndex 条件注册 | 无技能时 skill_index 中间件不注册 | `kernel/mod.rs:309` |
|
|
||||||
|
|
||||||
## 已修复的关键问题(历史记录)
|
|
||||||
|
|
||||||
| ID | 问题 | 修复日期 |
|
|
||||||
|----|------|----------|
|
|
||||||
| SEC2-P0-01 | skill_execute 反序列化崩溃 | 04-02 |
|
|
||||||
| SEC2-P0-02 | TaskTool::default() panic | 04-02 |
|
|
||||||
| SEC2-P1-01~09 | 9 项功能失效 (FactStore/路径/监听/...) | 04-02 |
|
|
||||||
| SEC2-P1.5-01~07 | 7 项代码质量修复 | 04-02 |
|
|
||||||
| P0-2/P0-3 | usage 端点 + refresh token 类型 | 04-10 |
|
|
||||||
| P1-02 | 浏览器聊天 SaaS fixture | 04-10 |
|
|
||||||
| P1-04 | AuthGuard 竞态条件 | 04-10 |
|
|
||||||
| BREAKS 全部 | 全部 P0/P1/P2 已修复 | 04-10 |
|
|
||||||
| V13-GAP-01~06 | 6 项断链/差距全部修复 | 04-13 |
|
|
||||||
| 三端联调 P0/P1 | 7 项全部修复 | 04-14 |
|
|
||||||
|
|
||||||
→ 模块详情见各模块页面: [[routing]] [[chat]] [[saas]] [[memory]] [[middleware]]
|
|
||||||
|
|||||||
372
wiki/log.md
372
wiki/log.md
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 变更日志
|
title: 变更日志
|
||||||
updated: 2026-04-22
|
updated: 2026-04-24
|
||||||
status: active
|
status: active
|
||||||
tags: [log, history]
|
tags: [log, history]
|
||||||
---
|
---
|
||||||
@@ -9,7 +9,93 @@ tags: [log, history]
|
|||||||
|
|
||||||
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
|
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
|
||||||
|
|
||||||
### 2026-04-22 跨会话记忆断裂修复 (commit adf0251)
|
## [2026-04-24] fix(runtime+middleware) | 工具调用 P1/P2/P3 全面修复
|
||||||
|
- **P1 流式工具并行**: 三阶段执行 (中间件预检→并行+串行分区→结果排序),ReadOnly 工具 JoinSet+Semaphore(3)
|
||||||
|
- **P2 OpenAI 驱动**: 参数解析失败不再静默替换为 `{}`,改为返回 `_parse_error`+`_raw_args` 让 LLM 自我修正
|
||||||
|
- **P2 ToolOutputGuard**: 从关键词匹配改为 regex 精确匹配实际密钥值 (sk-xxx/AKIA/PEM 等),消除误拦
|
||||||
|
- **P2 ToolErrorMiddleware**: 失败计数器从全局 AtomicU32 改为 per-session HashMap,消除跨会话误触发
|
||||||
|
- **P3 Gateway client**: 明确 tool_call/tool_result 的 onTool 回调语义约定 (output='' 为 start, input='' 为 end)
|
||||||
|
- **测试**: 91 tests PASS, tsc --noEmit PASS
|
||||||
|
|
||||||
|
## [2026-04-24] fix(runtime) | 工具调用两个 P0 修复
|
||||||
|
- **P0: after_tool_call 中间件从未调用**: 流式+非流式模式均添加 `middleware_chain.run_after_tool_call()` 调用,ToolErrorMiddleware 和 ToolOutputGuardMiddleware 的 after 逻辑现在生效
|
||||||
|
- **P0: stream_errored 跳过所有工具**: 流式模式中 `stream_errored` 不再 `break 'outer`,改为区分完整工具(ToolUseEnd 已接收)和不完整工具;完整工具照常执行,不完整工具发送取消 ToolEnd 事件
|
||||||
|
- **影响文件**: `loop_runner.rs`
|
||||||
|
- **测试**: 91 tests PASS, 0 cargo warnings
|
||||||
|
|
||||||
|
## [2026-04-24] feat(artifact) | 产物系统优化完善
|
||||||
|
- **MarkdownRenderer**: 从 StreamingText 提取共享 Markdown 渲染组件(react-markdown + remark-gfm),ArtifactPanel 复用
|
||||||
|
- **ArtifactPanel**: 替换手写 30 行 MarkdownPreview → 完整 GFM 渲染(表格/代码块/列表/引用);添加文件选择器下拉菜单
|
||||||
|
- **数据源扩展**: 产物创建从 file_write 单工具 → file_write/str_replace/write_file/str_replace_editor;从 sendMessage 单路径 → sendMessage + initStreamListener 双路径
|
||||||
|
- **持久化**: artifactStore 添加 zustand persist + IndexedDB (复用 idb-storage),刷新后产物保留
|
||||||
|
- **验证**: tsc --noEmit PASS, 343 vitest PASS
|
||||||
|
|
||||||
|
## [2026-04-24] perf | Hermes 高价值设计实施 Phase 1-4
|
||||||
|
- **Phase 1**: Anthropic prompt caching — cache_control ephemeral + cache token tracking (CompletionResponse + StreamChunk)
|
||||||
|
- **Phase 2A**: 并行工具执行 — ToolConcurrency 枚举 (ReadOnly/Exclusive/Interactive) + JoinSet + Semaphore(3) + AtomicU32
|
||||||
|
- **Phase 2B**: 工具输出修剪 — prune_tool_outputs() (2000→500 chars) + 集成到 CompactionMiddleware
|
||||||
|
- **Phase 3**: 错误分类+智能重试 — LlmErrorKind + ClassifiedLlmError + RetryDriver (jittered backoff) + CONTEXT_OVERFLOW recovery
|
||||||
|
- **Phase 4**: 异步压缩+迭代摘要 — 30s 防抖 + cached fallback + previous_summary 迭代累积
|
||||||
|
- **新增文件**: error_classifier.rs, retry_driver.rs
|
||||||
|
- **验证**: 997 workspace tests PASS
|
||||||
|
|
||||||
|
## [2026-04-23] perf | 回复效率+建议生成并行化优化 (三部分)
|
||||||
|
- **perf(src-tauri)**: identity prompt 缓存 (`LazyLock<RwLock<HashMap>>`) + `pre_conversation_hook` 并行化 (`tokio::join!`)
|
||||||
|
- **perf(runtime)**: middleware `before_completion` 分波并行 — `parallel_safe()` trait + wave detection + `tokio::spawn`,5 层 safe 中间件可并行
|
||||||
|
- **perf(desktop)**: suggestion context 预取 (sendMessage 时启动) + generateLLMSuggestions 与 memory extraction 解耦
|
||||||
|
- **feat(desktop)**: suggestion prompt 重写 (1深入追问+1实用行动+1管家关怀) + 上下文窗口 6→20 条
|
||||||
|
- **文件**: intelligence_hooks.rs, middleware.rs, 5 个 middleware 子模块, streamStore.ts, llm-service.ts
|
||||||
|
- **验证**: cargo test --workspace --exclude zclaw-saas 0 fail, tsc --noEmit 0 error
|
||||||
|
|
||||||
|
## [2026-04-23] fix | Agent 命名检测重构+跨会话记忆修复+Agent tab 移除
|
||||||
|
- **fix(desktop)**: `detectAgentNameSuggestion` 从 6 个固定正则改为 trigger+extract 两步法 (10 个 trigger)
|
||||||
|
- **fix(desktop)**: 名字检测从 memory extraction 解耦 — 502 不再阻断面板刷新
|
||||||
|
- **fix(src-tauri)**: `agent_update` 同步写入 soul.md — config.name → system prompt 断链修复
|
||||||
|
|
||||||
|
## [2026-04-23] feat | 动态建议智能化
|
||||||
|
- **feat(src-tauri)**: 新增 `experience_find_relevant` Tauri 命令 + `ExperienceBrief` 结构 + OnceLock 单例
|
||||||
|
- **feat(desktop)**: 新增 `suggestion-context.ts` — 4 路并行拉取智能上下文(用户画像/痛点/经验/技能匹配)
|
||||||
|
- **feat(desktop)**: `streamStore.ts` createCompleteHandler 并行化 + generateLLMSuggestions 增强
|
||||||
|
- **feat(desktop)**: suggestion prompt 改为混合型(2 续问 + 1 管家关怀)
|
||||||
|
- **文件**: experience.rs, lib.rs, suggestion-context.ts, streamStore.ts, llm-service.ts
|
||||||
|
- **refactor(desktop)**: 移除 Agent tab (简洁模式/专业模式),清理 dead code (~280 行)
|
||||||
|
- **验证**: cargo check 0 error, tsc --noEmit 0 error
|
||||||
|
|
||||||
|
## [2026-04-23] fix | 身份信号提取与持久化 — 对话中起名跨会话记忆+面板刷新
|
||||||
|
- **fix(zclaw-growth)**: ProfileSignals 增加 agent_name/user_name 字段 + 提取提示词扩展 + 解析器+回退逻辑
|
||||||
|
- **fix(zclaw-runtime)**: 身份信号存入 VikingStorage (importance=8)
|
||||||
|
- **fix(src-tauri)**: post_conversation_hook 身份写回 soul.md + emit `zclaw:agent-identity-updated` Tauri 事件
|
||||||
|
- **fix(desktop)**: 双通道更新 — 前端规则检测 `detectAgentNameSuggestion` 即时改名 + Rust 事件驱动 RightPanel 刷新
|
||||||
|
- **验证**: cargo check 0 error/0 warning, tsc --noEmit 0 error
|
||||||
|
|
||||||
|
## [2026-04-22] fix | agentStore stale client — getClient() 直接读 connectionStore
|
||||||
|
- **fix(desktop)**: agentStore `_client` 模块缓存导致 Tauri 模式下持有旧 GatewayClient 引用
|
||||||
|
- **根因**: `initializeStores()` 被 `_storesInitialized` 守卫阻止二次注入,KernelClient 替换后 agentStore 仍用旧引用
|
||||||
|
- **修复**: `getClient()` 改为直接读 `connectionStore.getState().client`,去掉本地缓存
|
||||||
|
|
||||||
|
## [2026-04-22] fix | Agent tab 数据不同步 — role映射+userProfile双通道+称呼方式
|
||||||
|
- **fix(desktop)**: updateClone role→description 字段映射修复 (kernel-agent.ts:176)
|
||||||
|
- **fix(desktop)**: listClones 新增 agent_get + identity_get_file 双通道获取 userName/userRole
|
||||||
|
- **fix(desktop)**: userAddressing 错误使用 agent nickname 作为用户称呼方式 → 改用 userName
|
||||||
|
|
||||||
|
## [2026-04-22] docs | Wiki 重构 — 5节模板+集成契约+症状导航+归档压缩
|
||||||
|
- **Phase A**: log.md 归档(548→335行, 38条活跃) + hermes-analysis 归档 + known-issues 转索引(277→38行)
|
||||||
|
- **Phase B**: middleware.md 重构(157→136行) — 集成契约+3不变量+单真相源
|
||||||
|
- **Phase C**: saas.md(231→173, 移除安全重复) + security.md(158→199, 吸收安全内容) + memory.md(363→147, 最大压缩59%)
|
||||||
|
- **Phase D**: routing(330→131) + chat(180→134) + butler(215→150) + hands-skills(281→170) + pipeline(157→154) + data-model(181→153)
|
||||||
|
- **Phase E**: index.md 新增症状导航表(144→101行, ≤120预算) + 移除架构Q&A(移入各模块)
|
||||||
|
- **Phase F**: feature-map.md 33链路详细描述→紧凑索引(424→60行)
|
||||||
|
- **CLAUDE.md**: §3.3 阶段1 更新(症状导航+5节说明) + §8.3 wiki维护规则更新(新模板触发规则)
|
||||||
|
- 净减 ~1,200 行,消除所有跨3+页重复,10/10 模块页新增集成契约
|
||||||
|
|
||||||
|
## [2026-04-22] docs | Wiki 一致性修复 — 数字/格式/重复内容清理
|
||||||
|
- **index.md**: 数据流图中间件 15→14 层
|
||||||
|
- **chat.md**: 中间件层引用 15→14 层
|
||||||
|
- **development.md**: 稳定化约束中间件 15→14 层
|
||||||
|
- **memory.md**: 删除"前端 Tauri 命令"与"API 接口"重复的 VikingStorage/Intelligence 表(保留 API 接口章节)
|
||||||
|
- **log.md**: 统一所有标题格式为 `## [YYYY-MM-DD] 类型 |`(27行);删除重复的 `# 变更日志` 标题;修正历史条目中"中间件 15层"→"14层";DataMasking 条目补充 Evolution@78
|
||||||
|
|
||||||
|
## [2026-04-22] fix | 跨会话记忆断裂修复 (commit adf0251)
|
||||||
- **根因**: 3个断裂点
|
- **根因**: 3个断裂点
|
||||||
1. `profile_store`未连接 — `create_middleware_chain()`中GrowthIntegration未设置UserProfileStore, extract_combined()的profile_signals被静默丢弃
|
1. `profile_store`未连接 — `create_middleware_chain()`中GrowthIntegration未设置UserProfileStore, extract_combined()的profile_signals被静默丢弃
|
||||||
2. 双数据库不一致 — UserProfileStore写入data.db, agent_get读取memories.db, 两库隔离导致UserProfile永远读不到
|
2. 双数据库不一致 — UserProfileStore写入data.db, agent_get读取memories.db, 两库隔离导致UserProfile永远读不到
|
||||||
@@ -17,15 +103,15 @@ tags: [log, history]
|
|||||||
- **修复**: `with_profile_store(memory.pool())` + agent_get改用`kernel.memory()` + Kernel暴露`memory()`方法 + growth.rs增强日志
|
- **修复**: `with_profile_store(memory.pool())` + agent_get改用`kernel.memory()` + Kernel暴露`memory()`方法 + growth.rs增强日志
|
||||||
- **验证**: Tauri端E2E — 会话A提取6记忆+4 profile signals → 新会话B成功注入记忆 → 管家Tab显示用户画像+近期话题+53条记忆
|
- **验证**: Tauri端E2E — 会话A提取6记忆+4 profile signals → 新会话B成功注入记忆 → 管家Tab显示用户画像+近期话题+53条记忆
|
||||||
|
|
||||||
### 2026-04-22 管家Tab记忆展示增强
|
## [2026-04-22] feat | 管家Tab记忆展示增强
|
||||||
- **变更**: MemorySection.tsx 重写 — L1摘要并行加载 + 按类型分组(偏好/知识/经验/会话) + 用户画像卡片(行业/角色/沟通风格/近期话题)
|
- **变更**: MemorySection.tsx 重写 — L1摘要并行加载 + 按类型分组(偏好/知识/经验/会话) + 用户画像卡片(行业/角色/沟通风格/近期话题)
|
||||||
- **数据源**: viking_ls+viking_read(L1) + agent_get(userProfile)
|
- **数据源**: viking_ls+viking_read(L1) + agent_get(userProfile)
|
||||||
|
|
||||||
### 2026-04-22 DataMasking 完全移除
|
## [2026-04-22] refactor | DataMasking 完全移除
|
||||||
- **变更**: 删除 `data_masking.rs` (367行) + loop_runner unmask 逻辑 + saas-relay-client.ts 前端 mask/unmask
|
- **变更**: 删除 `data_masking.rs` (367行) + loop_runner unmask 逻辑 + saas-relay-client.ts 前端 mask/unmask
|
||||||
- **原因**: 正则过度匹配中文文本(commit 73d50fd 已禁用),NLP方案未排期,彻底移除减少维护负担
|
- **原因**: 正则过度匹配中文文本(commit 73d50fd 已禁用),NLP方案未排期,彻底移除减少维护负担
|
||||||
- **影响**: 中间件链 15→14 层,loop_runner 简化,SaaS relay 路径不再做前端脱敏
|
- **影响**: 中间件链 15→14 层,loop_runner 简化,SaaS relay 路径不再做前端脱敏
|
||||||
- **中间件链**: `ButlerRouter@80, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700`
|
- **中间件链**: `Evolution@78, ButlerRouter@80, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700`
|
||||||
|
|
||||||
## [2026-04-22] fix | Agent 搜索功能修复 — glm 空参数 + schema 简化 + 排版修复 (commit 5816f56 + 81005c3)
|
## [2026-04-22] fix | Agent 搜索功能修复 — glm 空参数 + schema 简化 + 排版修复 (commit 5816f56 + 81005c3)
|
||||||
|
|
||||||
@@ -88,9 +174,9 @@ tags: [log, history]
|
|||||||
- B-CHAT-2 工具循环: 连续失败计数器 (3次上限)
|
- B-CHAT-2 工具循环: 连续失败计数器 (3次上限)
|
||||||
- B-CHAT-5 Stream 竞态: cancelCooldown 500ms
|
- B-CHAT-5 Stream 竞态: cancelCooldown 500ms
|
||||||
|
|
||||||
## 2026-04-19 docs | Wiki 全量深度梳理 — 11 页同步至代码实际状态
|
## [2026-04-19] docs | Wiki 全量深度梳理 — 11 页同步至代码实际状态
|
||||||
|
|
||||||
- **index.md**: 全面更新关键数字 — Rust 102K行/357文件/987测试、Store 25、组件 102、lib 75、@reserved 97、中间件 15层、SQL 38文件/42表、dead_code 0;新增进化引擎架构说明;修正 Hands 7注册(非9);Pipeline 18模板
|
- **index.md**: 全面更新关键数字 — Rust 102K行/357文件/987测试、Store 25、组件 102、lib 75、@reserved 97、中间件 14层、SQL 38文件/42表、dead_code 0;新增进化引擎架构说明;修正 Hands 7注册(非9);Pipeline 18模板
|
||||||
- **routing.md**: Store 列表删除 workflowBuilderStore(已不存在)、新增 saas/ 子模块(5文件)拆分;路由决策从4分支修正为5分支+降级;lib/ 计数 76→75
|
- **routing.md**: Store 列表删除 workflowBuilderStore(已不存在)、新增 saas/ 子模块(5文件)拆分;路由决策从4分支修正为5分支+降级;lib/ 计数 76→75
|
||||||
- **hands-skills.md**: Hands 从"9启用"修正为"7注册"(6 TOML + _reminder);新增"已删除 Hands"节(Whiteboard/Slideshow/Speech 空壳清理);HAND.toml 9→6
|
- **hands-skills.md**: Hands 从"9启用"修正为"7注册"(6 TOML + _reminder);新增"已删除 Hands"节(Whiteboard/Slideshow/Speech 空壳清理);HAND.toml 9→6
|
||||||
- **saas.md**: SaaS 模块从"16+distill"修正为精确16目录;SQL迁移从"20文件"修正为"38文件(21up+17down)";CREATE TABLE 从104修正为42
|
- **saas.md**: SaaS 模块从"16+distill"修正为精确16目录;SQL迁移从"20文件"修正为"38文件(21up+17down)";CREATE TABLE 从104修正为42
|
||||||
@@ -98,15 +184,11 @@ tags: [log, history]
|
|||||||
- **memory.md**: 新增进化引擎(EvolutionEngine)完整模块结构(19文件);新增 FeedbackCollector/PatternAggregator/QualityGate/SkillGenerator/WorkflowComposer 描述
|
- **memory.md**: 新增进化引擎(EvolutionEngine)完整模块结构(19文件);新增 FeedbackCollector/PatternAggregator/QualityGate/SkillGenerator/WorkflowComposer 描述
|
||||||
- **butler.md**: Intelligence 层从5文件扩展到16文件完整清单;新增 experience/health_snapshot/personality_detector 等
|
- **butler.md**: Intelligence 层从5文件扩展到16文件完整清单;新增 experience/health_snapshot/personality_detector 等
|
||||||
- **pipeline.md**: 模板数从17修正为18;修正模板分布总计公式
|
- **pipeline.md**: 模板数从17修正为18;修正模板分布总计公式
|
||||||
- **chat.md**: 中间件层引用从14修正为15
|
- **chat.md**: 中间件层引用 14层(含Evolution@78)
|
||||||
- **development.md**: 稳定化约束数字全面更新(Store 25、中间件 15、组件 102);分层职责同步
|
- **development.md**: 稳定化约束数字全面更新(Store 25、中间件 14、组件 102);分层职责同步
|
||||||
- **验证方式**: 3路并行代码分析(Rust crates/前端/TRUTH交叉) + 20+ grep/find 命令实际验证
|
- **验证方式**: 3路并行代码分析(Rust crates/前端/TRUTH交叉) + 20+ grep/find 命令实际验证
|
||||||
|
|
||||||
# 变更日志
|
## [2026-04-19] fix | 穷尽审计修复 — CRITICAL×1 + HIGH×6 + MEDIUM×4
|
||||||
|
|
||||||
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
|
|
||||||
|
|
||||||
## 2026-04-19 fix | 穷尽审计修复 — CRITICAL×1 + HIGH×6 + MEDIUM×4
|
|
||||||
|
|
||||||
- C1: mark_key_429 设 is_active=FALSE,自动恢复路径可达化
|
- C1: mark_key_429 设 is_active=FALSE,自动恢复路径可达化
|
||||||
- H1+H2: 重试查询补全日志 + fallthrough 错误信息修正 (RateLimited)
|
- H1+H2: 重试查询补全日志 + fallthrough 错误信息修正 (RateLimited)
|
||||||
@@ -114,7 +196,7 @@ tags: [log, history]
|
|||||||
- H5+H6: auth.ts 提取 triggerReconnect(),login/TOTP/restore 三路径统一
|
- H5+H6: auth.ts 提取 triggerReconnect(),login/TOTP/restore 三路径统一
|
||||||
- M1: toggle_key_active(true) 清除 cooldown_until
|
- M1: toggle_key_active(true) 清除 cooldown_until
|
||||||
|
|
||||||
## 2026-04-19 fix | 发布前审计 5 项修复
|
## [2026-04-19] fix | 发布前审计 5 项修复
|
||||||
|
|
||||||
- P0-1: key_pool.rs Provider Key cooldown 过期自动恢复(is_active=false → true)
|
- P0-1: key_pool.rs Provider Key cooldown 过期自动恢复(is_active=false → true)
|
||||||
- P0-2: agentStore.ts createClone/createFromTemplate 友好错误信息(502/503/401 分类)
|
- P0-2: agentStore.ts createClone/createFromTemplate 友好错误信息(502/503/401 分类)
|
||||||
@@ -122,21 +204,21 @@ tags: [log, history]
|
|||||||
- P1-3: health_snapshot heartbeat engine 未初始化时返回 pending 快照(不再报错)
|
- P1-3: health_snapshot heartbeat engine 未初始化时返回 pending 快照(不再报错)
|
||||||
- P1-1: configStore.ts loadSkillsCatalog 增加延迟重试(最多2次,1.5s/3s 间隔)
|
- P1-1: configStore.ts loadSkillsCatalog 增加延迟重试(最多2次,1.5s/3s 间隔)
|
||||||
|
|
||||||
## 2026-04-19 chore | sqlx 0.7→0.8 统一 + 测试覆盖补充
|
## [2026-04-19] chore | sqlx 0.7→0.8 统一 + 测试覆盖补充
|
||||||
|
|
||||||
- sqlx workspace 0.7→0.8.6 + libsqlite3-sys 0.27→0.30,消除 pgvector 引入的双版本
|
- sqlx workspace 0.7→0.8.6 + libsqlite3-sys 0.27→0.30,消除 pgvector 引入的双版本
|
||||||
- 零源码修改,719→797 测试全通过
|
- 零源码修改,719→797 测试全通过
|
||||||
- zclaw-protocols +43 测试: MCP types serde / transport config / domain roundtrips
|
- zclaw-protocols +43 测试: MCP types serde / transport config / domain roundtrips
|
||||||
- zclaw-skills +47 测试: SKILL.md/TOML parsing / auto-classify / PromptOnlySkill / types roundtrips
|
- zclaw-skills +47 测试: SKILL.md/TOML parsing / auto-classify / PromptOnlySkill / types roundtrips
|
||||||
|
|
||||||
## 2026-04-18 fix | 审计后续 3 项修复
|
## [2026-04-18] fix | 审计后续 3 项修复
|
||||||
|
|
||||||
- Shell Hands 残留清理 3 处 (message.rs 注释/profiler 偏好/handStore mock)
|
- Shell Hands 残留清理 3 处 (message.rs 注释/profiler 偏好/handStore mock)
|
||||||
- FTS5 CJK 查询修复: sanitize_fts_query 从精确短语改为 token OR 组合
|
- FTS5 CJK 查询修复: sanitize_fts_query 从精确短语改为 token OR 组合
|
||||||
- WASM HTTP 响应大小限制: Content-Length 预检 + 1MB 上限
|
- WASM HTTP 响应大小限制: Content-Length 预检 + 1MB 上限
|
||||||
- zclaw-growth 集成测试 2/2 修复, 全量 651 测试 0 失败
|
- zclaw-growth 集成测试 2/2 修复, 全量 651 测试 0 失败
|
||||||
|
|
||||||
## 2026-04-18 fix | 深度审计修复 — WASM 安全 + 编译路径
|
## [2026-04-18] fix | 深度审计修复 — WASM 安全 + 编译路径
|
||||||
|
|
||||||
- CRITICAL: zclaw_file_read 路径遍历修复 (组件级过滤)
|
- CRITICAL: zclaw_file_read 路径遍历修复 (组件级过滤)
|
||||||
- CRITICAL: zclaw_http_fetch SSRF 防护 (scheme 白名单 + 私有 IP 阻止)
|
- CRITICAL: zclaw_http_fetch SSRF 防护 (scheme 白名单 + 私有 IP 阻止)
|
||||||
@@ -145,13 +227,13 @@ tags: [log, history]
|
|||||||
- 移除 kernel/desktop multi-agent feature (不再控制任何代码)
|
- 移除 kernel/desktop multi-agent feature (不再控制任何代码)
|
||||||
- 563 测试全通过
|
- 563 测试全通过
|
||||||
|
|
||||||
## 2026-04-17 refactor | Phase 4A multi-agent feature gate 移除
|
## [2026-04-17] refactor | Phase 4A multi-agent feature gate 移除
|
||||||
|
|
||||||
- 8 个文件移除 33 处 `#[cfg(feature = "multi-agent")]`
|
- 8 个文件移除 33 处 `#[cfg(feature = "multi-agent")]`
|
||||||
- zclaw-kernel default features 新增 multi-agent,始终编译
|
- zclaw-kernel default features 新增 multi-agent,始终编译
|
||||||
- A2A router、agents、adapters 代码不再条件编译
|
- A2A router、agents、adapters 代码不再条件编译
|
||||||
|
|
||||||
## 2026-04-17 feat | Phase 4B WASM host 函数真实实现
|
## [2026-04-17] feat | Phase 4B WASM host 函数真实实现
|
||||||
|
|
||||||
- zclaw_log: 读取 guest 内存字符串 + debug! 日志
|
- zclaw_log: 读取 guest 内存字符串 + debug! 日志
|
||||||
- zclaw_http_fetch: ureq v3 同步 GET (10s timeout, network_allowed 守卫)
|
- zclaw_http_fetch: ureq v3 同步 GET (10s timeout, network_allowed 守卫)
|
||||||
@@ -159,7 +241,7 @@ tags: [log, history]
|
|||||||
- 新增 ureq v3 workspace 依赖 (wasm feature gated)
|
- 新增 ureq v3 workspace 依赖 (wasm feature gated)
|
||||||
- 25 测试全通过,workspace check 零错误
|
- 25 测试全通过,workspace check 零错误
|
||||||
|
|
||||||
## 2026-04-17 refactor | Phase 3A loop_runner 双路径合并
|
## [2026-04-17] refactor | Phase 3A loop_runner 双路径合并
|
||||||
|
|
||||||
- middleware_chain 从 Option<MiddlewareChain> 改为 MiddlewareChain (Default = 空链)
|
- middleware_chain 从 Option<MiddlewareChain> 改为 MiddlewareChain (Default = 空链)
|
||||||
- 移除 6 处 `use_middleware` 分支 + 2 处 legacy loop_guard inline path
|
- 移除 6 处 `use_middleware` 分支 + 2 处 legacy loop_guard inline path
|
||||||
@@ -167,20 +249,20 @@ tags: [log, history]
|
|||||||
- Kernel create_middleware_chain() 返回非 Option,messaging.rs 调用简化
|
- Kernel create_middleware_chain() 返回非 Option,messaging.rs 调用简化
|
||||||
- 1154→1023 行,净减 131 行;`cargo check --workspace` ✓ | `cargo test` ✓
|
- 1154→1023 行,净减 131 行;`cargo check --workspace` ✓ | `cargo test` ✓
|
||||||
|
|
||||||
## 2026-04-17 refactor | Phase 2A Pipeline→Kernel 解耦
|
## [2026-04-17] refactor | Phase 2A Pipeline→Kernel 解耦
|
||||||
|
|
||||||
- Pipeline 代码中无任何 `zclaw_kernel` 引用,`Cargo.toml` 中的依赖是空壳
|
- Pipeline 代码中无任何 `zclaw_kernel` 引用,`Cargo.toml` 中的依赖是空壳
|
||||||
- 移除后 `cargo check --workspace --exclude zclaw-saas` ✓
|
- 移除后 `cargo check --workspace --exclude zclaw-saas` ✓
|
||||||
- 依赖图简化: Pipeline 不再拉入 Kernel 及其传递依赖
|
- 依赖图简化: Pipeline 不再拉入 Kernel 及其传递依赖
|
||||||
|
|
||||||
## 2026-04-17 refactor | Phase 2B saasStore 拆分为子模块
|
## [2026-04-17] refactor | Phase 2B saasStore 拆分为子模块
|
||||||
|
|
||||||
- 1025行单文件 → 5个子模块 + barrel re-export
|
- 1025行单文件 → 5个子模块 + barrel re-export
|
||||||
- saas/types.ts(103行) + shared.ts(93行) + auth.ts(362行) + billing.ts(84行) + index.ts(309行)
|
- saas/types.ts(103行) + shared.ts(93行) + auth.ts(362行) + billing.ts(84行) + index.ts(309行)
|
||||||
- saasStore.ts 缩减为 15行 re-export barrel,25+ 消费者零改动
|
- saasStore.ts 缩减为 15行 re-export barrel,25+ 消费者零改动
|
||||||
- `tsc --noEmit` ✓
|
- `tsc --noEmit` ✓
|
||||||
|
|
||||||
## 2026-04-17 refactor | Phase 5 移除空壳 Hand — Whiteboard/Slideshow/Speech
|
## [2026-04-17] refactor | Phase 5 移除空壳 Hand — Whiteboard/Slideshow/Speech
|
||||||
|
|
||||||
- **Rust**: 删除 whiteboard.rs(422行) + slideshow.rs(797行) + speech.rs(442行)
|
- **Rust**: 删除 whiteboard.rs(422行) + slideshow.rs(797行) + speech.rs(442行)
|
||||||
- **前端**: 删除 WhiteboardCanvas + SlideshowRenderer + speech-synth + 类型/常量
|
- **前端**: 删除 WhiteboardCanvas + SlideshowRenderer + speech-synth + 类型/常量
|
||||||
@@ -188,7 +270,7 @@ tags: [log, history]
|
|||||||
- Hands 9→6 启用 (Browser/Collector/Researcher/Clip/Twitter/Quiz + Reminder系统内部)
|
- Hands 9→6 启用 (Browser/Collector/Researcher/Clip/Twitter/Quiz + Reminder系统内部)
|
||||||
- 净减 ~5400 行,`cargo check` ✓ | `tsc --noEmit` ✓
|
- 净减 ~5400 行,`cargo check` ✓ | `tsc --noEmit` ✓
|
||||||
|
|
||||||
## 2026-04-17 feat | Phase 1 错误体系重构 — ErrorKind + code + Serialize
|
## [2026-04-17] feat | Phase 1 错误体系重构 — ErrorKind + code + Serialize
|
||||||
|
|
||||||
- **Rust**: `zclaw-types/error.rs` 新增 `ErrorKind` (17种) + `error_codes` (E4040-E5110)
|
- **Rust**: `zclaw-types/error.rs` 新增 `ErrorKind` (17种) + `error_codes` (E4040-E5110)
|
||||||
- ZclawError 新增 `kind()` / `code()` 方法 + `Serialize` impl (零破坏性)
|
- ZclawError 新增 `kind()` / `code()` 方法 + `Serialize` impl (零破坏性)
|
||||||
@@ -196,14 +278,14 @@ tags: [log, history]
|
|||||||
- **前端**: `error-types.ts` 新增 `RustErrorKind` / `RustErrorDetail` / `tryParseRustError()`
|
- **前端**: `error-types.ts` 新增 `RustErrorKind` / `RustErrorDetail` / `tryParseRustError()`
|
||||||
- `classifyError()` 优先解析结构化错误 → 17 种中文标题映射
|
- `classifyError()` 优先解析结构化错误 → 17 种中文标题映射
|
||||||
|
|
||||||
## 2026-04-17 fix | Phase 0 阻碍项修复 — 流式事件/CI/中文化
|
## [2026-04-17] fix | Phase 0 阻碍项修复 — 流式事件/CI/中文化
|
||||||
|
|
||||||
- **BLK-2**: loop_runner.rs 22 处 `let _ = tx.send()` 替换为 `if let Err(e) { tracing::warn!(...) }`,修复流式事件静默丢失
|
- **BLK-2**: loop_runner.rs 22 处 `let _ = tx.send()` 替换为 `if let Err(e) { tracing::warn!(...) }`,修复流式事件静默丢失
|
||||||
- **BLK-5**: 50+ 英文字符串翻译为中文 (HandApprovalModal/ChatArea/AuditLogsPanel 等 7 组件)
|
- **BLK-5**: 50+ 英文字符串翻译为中文 (HandApprovalModal/ChatArea/AuditLogsPanel 等 7 组件)
|
||||||
- **BLK-6**: CI/Release workflow 添加 `--exclude zclaw-saas`,无 DB 时 CI 绿灯
|
- **BLK-6**: CI/Release workflow 添加 `--exclude zclaw-saas`,无 DB 时 CI 绿灯
|
||||||
- **验证**: `cargo check --workspace --exclude zclaw-saas` ✓ | `tsc --noEmit` ✓
|
- **验证**: `cargo check --workspace --exclude zclaw-saas` ✓ | `tsc --noEmit` ✓
|
||||||
|
|
||||||
## 2026-04-17 fix | M4 Admin 权限守卫 + L1 文档同步
|
## [2026-04-17] fix | M4 Admin 权限守卫 + L1 文档同步
|
||||||
|
|
||||||
- **BUG-M4**: 新增 `admin_guard_middleware` (auth/mod.rs),在中间件层拦截非 admin 请求
|
- **BUG-M4**: 新增 `admin_guard_middleware` (auth/mod.rs),在中间件层拦截非 admin 请求
|
||||||
- `billing::admin_routes()` 和 `account::admin_routes()` 挂载时加 guard layer
|
- `billing::admin_routes()` 和 `account::admin_routes()` 挂载时加 guard layer
|
||||||
@@ -211,7 +293,7 @@ tags: [log, history]
|
|||||||
- `account/mod.rs` 拆分 `admin_routes()` (dashboard 端点独立)
|
- `account/mod.rs` 拆分 `admin_routes()` (dashboard 端点独立)
|
||||||
- **BUG-L1**: 字段名已在代码中统一为 `pain_seed_categories`,同步 wiki/butler.md/log.md 文档
|
- **BUG-L1**: 字段名已在代码中统一为 `pain_seed_categories`,同步 wiki/butler.md/log.md 文档
|
||||||
|
|
||||||
## 2026-04-17 test | 回归验证 — 13/13 PASS,全部 04-17 bug 修复确认
|
## [2026-04-17] test | 回归验证 — 13/13 PASS,全部 04-17 bug 修复确认
|
||||||
|
|
||||||
- Phase 1: 6 项 bug 修复回归 (H1/H2/M1/M2/M3/M5) 全部 PASS
|
- Phase 1: 6 项 bug 修复回归 (H1/H2/M1/M2/M3/M5) 全部 PASS
|
||||||
- Phase 2: Pipeline (list/create/run) + Skill (75 + route_intent) 全部 PASS
|
- Phase 2: Pipeline (list/create/run) + Skill (75 + route_intent) 全部 PASS
|
||||||
@@ -220,7 +302,7 @@ tags: [log, history]
|
|||||||
- 记忆系统健康: 381 条记忆, 12 agent, FTS5+TF-IDF 工作正常
|
- 记忆系统健康: 381 条记忆, 12 agent, FTS5+TF-IDF 工作正常
|
||||||
- 详见 [[known-issues#04-17 回归验证]]
|
- 详见 [[known-issues#04-17 回归验证]]
|
||||||
|
|
||||||
## 2026-04-17 test | 全系统功能 E2E 测试 — 129 链路覆盖
|
## [2026-04-17] test | 全系统功能 E2E 测试 — 129 链路覆盖
|
||||||
|
|
||||||
- 129 条链路全量测试 (Tauri MCP + Chrome DevTools MCP + HTTP API)
|
- 129 条链路全量测试 (Tauri MCP + Chrome DevTools MCP + HTTP API)
|
||||||
- 82 PASS / 20 PARTIAL / 1 FAIL / 26 SKIP,有效通过率 79.1%
|
- 82 PASS / 20 PARTIAL / 1 FAIL / 26 SKIP,有效通过率 79.1%
|
||||||
@@ -230,7 +312,7 @@ tags: [log, history]
|
|||||||
- 完整报告: `docs/test-evidence/2026-04-17/E2E_TEST_REPORT_2026_04_17.md`
|
- 完整报告: `docs/test-evidence/2026-04-17/E2E_TEST_REPORT_2026_04_17.md`
|
||||||
- 详见 [[known-issues]]
|
- 详见 [[known-issues]]
|
||||||
|
|
||||||
## 2026-04-17 fix | 7 项 E2E Bug 修复 — Dashboard 404 / 记忆去重 / 记忆注入 / invoice_id / Prompt 版本
|
## [2026-04-17] fix | 7 项 E2E Bug 修复 — Dashboard 404 / 记忆去重 / 记忆注入 / invoice_id / Prompt 版本
|
||||||
|
|
||||||
- **fix(admin)**: Dashboard 404 — 路由注册修复
|
- **fix(admin)**: Dashboard 404 — 路由注册修复
|
||||||
- **fix(memory)**: viking_add 记忆去重 — URI+content 双重校验
|
- **fix(memory)**: viking_add 记忆去重 — URI+content 双重校验
|
||||||
@@ -241,27 +323,27 @@ tags: [log, history]
|
|||||||
- **fix(industry)**: API 字段名统一 (pain_seeds → pain_seed_categories)
|
- **fix(industry)**: API 字段名统一 (pain_seeds → pain_seed_categories)
|
||||||
- commit: a504a40
|
- commit: a504a40
|
||||||
|
|
||||||
## 2026-04-16 fix | Agent 面板信息不随对话更新 — 事件时序 + clones 刷新
|
## [2026-04-16] fix | Agent 面板信息不随对话更新 — 事件时序 + clones 刷新
|
||||||
|
|
||||||
- **fix(desktop)**: Agent 面板信息不随对话更新 — 事件时序 + clones 刷新
|
- **fix(desktop)**: Agent 面板信息不随对话更新 — 事件时序 + clones 刷新
|
||||||
- commit: 1309101
|
- commit: 1309101
|
||||||
|
|
||||||
## 2026-04-16 fix | 3 项 P0 安全/功能修复 + TRUTH.md 数字校准
|
## [2026-04-16] fix | 3 项 P0 安全/功能修复 + TRUTH.md 数字校准
|
||||||
|
|
||||||
- **fix(saas)**: 3 项 P0 修复 (详见 [[known-issues]])
|
- **fix(saas)**: 3 项 P0 修复 (详见 [[known-issues]])
|
||||||
- TRUTH.md 数字同步更新
|
- TRUTH.md 数字同步更新
|
||||||
- commit: 0d79993
|
- commit: 0d79993
|
||||||
|
|
||||||
## 2026-04-16 fix | 5 项 E2E 测试 Bug 修复
|
## [2026-04-16] fix | 5 项 E2E 测试 Bug 修复
|
||||||
|
|
||||||
- Agent 502 / 错误持久化 / 模型标记 / 侧面板 / 记忆页
|
- Agent 502 / 错误持久化 / 模型标记 / 侧面板 / 记忆页
|
||||||
- commit: a0d1392
|
- commit: a0d1392
|
||||||
|
|
||||||
## 2026-04-16 fix | useButlerInsights 使用 resolvedAgentId 查询痛点/方案
|
## [2026-04-16] fix | useButlerInsights 使用 resolvedAgentId 查询痛点/方案
|
||||||
|
|
||||||
- commit: 7db9eb2
|
- commit: 7db9eb2
|
||||||
|
|
||||||
## 2026-04-16 fix | Heartbeat 参数名 + Relay 解密自愈 + 设置清理
|
## [2026-04-16] fix | Heartbeat 参数名 + Relay 解密自愈 + 设置清理
|
||||||
|
|
||||||
- **fix(heartbeat)**: Tauri invoke 参数名修正 snake_case → camelCase (`f6c5dd2`)
|
- **fix(heartbeat)**: Tauri invoke 参数名修正 snake_case → camelCase (`f6c5dd2`)
|
||||||
- intelligence-client.ts 3 处 invoke 调用: agentId/taskCount/totalEntries 等
|
- intelligence-client.ts 3 处 invoke 调用: agentId/taskCount/totalEntries 等
|
||||||
@@ -272,7 +354,7 @@ tags: [log, history]
|
|||||||
- **chore(settings)**: 删除用量统计和积分详情页面 (`7dea456`)
|
- **chore(settings)**: 删除用量统计和积分详情页面 (`7dea456`)
|
||||||
- 与"订阅与计费"功能重复,-240 行
|
- 与"订阅与计费"功能重复,-240 行
|
||||||
|
|
||||||
## 2026-04-15 feat | Heartbeat 统一健康系统
|
## [2026-04-15] feat | Heartbeat 统一健康系统
|
||||||
|
|
||||||
- **feat(runtime)**: health_snapshot.rs — 统一健康快照收集器 (LLM连接/记忆/会话/系统资源)
|
- **feat(runtime)**: health_snapshot.rs — 统一健康快照收集器 (LLM连接/记忆/会话/系统资源)
|
||||||
- **feat(runtime)**: heartbeat.rs 重构 — HeartbeatManager + HealthSnapshot 集成
|
- **feat(runtime)**: heartbeat.rs 重构 — HeartbeatManager + HealthSnapshot 集成
|
||||||
@@ -281,7 +363,7 @@ tags: [log, history]
|
|||||||
- **docs**: TRUTH.md + wiki 数字同步 (Tauri 183命令, React 105组件, lib 76文件, intelligence 16文件)
|
- **docs**: TRUTH.md + wiki 数字同步 (Tauri 183命令, React 105组件, lib 76文件, intelligence 16文件)
|
||||||
- 验证: cargo check 0 error, tsc 0 error
|
- 验证: cargo check 0 error, tsc 0 error
|
||||||
|
|
||||||
## 2026-04-15 fix | 聊天定时功能断链接通 — NlScheduleParser + _reminder Hand
|
## [2026-04-15] fix | 聊天定时功能断链接通 — NlScheduleParser + _reminder Hand
|
||||||
|
|
||||||
- **fix(runtime)**: NlScheduleParser 接入 chat.rs — has_schedule_intent() 意图检测 + parse_nl_schedule() cron 解析
|
- **fix(runtime)**: NlScheduleParser 接入 chat.rs — has_schedule_intent() 意图检测 + parse_nl_schedule() cron 解析
|
||||||
- **fix(hands)**: 新增 _reminder 系统内部 Hand — 定时触发器桥接
|
- **fix(hands)**: 新增 _reminder 系统内部 Hand — 定时触发器桥接
|
||||||
@@ -290,7 +372,7 @@ tags: [log, history]
|
|||||||
- **docs(wiki)**: hands-skills.md 新增定时提醒链路说明
|
- **docs(wiki)**: hands-skills.md 新增定时提醒链路说明
|
||||||
- 验证: cargo check 0 error, 49 tests passed, Tauri MCP 实操验证 "每天早上9点提醒我查房" → cron `0 9 * * *` 确认消息正确显示
|
- 验证: cargo check 0 error, 49 tests passed, Tauri MCP 实操验证 "每天早上9点提醒我查房" → cron `0 9 * * *` 确认消息正确显示
|
||||||
|
|
||||||
## 2026-04-15 fix | 发布前冲刺 Day1 — 5项修复 + 2项标注 + 文档同步
|
## [2026-04-15] fix | 发布前冲刺 Day1 — 5项修复 + 2项标注 + 文档同步
|
||||||
|
|
||||||
- **fix(saas)**: SSE 用量统计一致性 — 回写 usage_records 真实 token + 消除 relay_requests 双重计数
|
- **fix(saas)**: SSE 用量统计一致性 — 回写 usage_records 真实 token + 消除 relay_requests 双重计数
|
||||||
- **fix(saas)**: relay_tasks 超时自动清理 — 每5分钟扫描 processing>10min 标记 failed
|
- **fix(saas)**: relay_tasks 超时自动清理 — 每5分钟扫描 processing>10min 标记 failed
|
||||||
@@ -300,7 +382,7 @@ tags: [log, history]
|
|||||||
- **docs**: TRUTH.md 数字更新 (Tauri 182命令、95 invoke、89 @reserved、0 孤儿)
|
- **docs**: TRUTH.md 数字更新 (Tauri 182命令、95 invoke、89 @reserved、0 孤儿)
|
||||||
- 验证: tsc 0错误、vitest 344通过、cargo check 0 warning、pnpm build 成功
|
- 验证: tsc 0错误、vitest 344通过、cargo check 0 warning、pnpm build 成功
|
||||||
|
|
||||||
## 2026-04-15 fix | 三端联调 V2 — SSE Token 捕获修复 + 调试环境文档
|
## [2026-04-15] fix | 三端联调 V2 — SSE Token 捕获修复 + 调试环境文档
|
||||||
|
|
||||||
- **fix(saas)**: SseUsageCapture 增加 `stream_done` 标志,修复 SSE 路径 Token 始终为 0 的根因
|
- **fix(saas)**: SseUsageCapture 增加 `stream_done` 标志,修复 SSE 路径 Token 始终为 0 的根因
|
||||||
- **fix(saas)**: `parse_sse_line` 兼容 `data:` 和 `data: ` 两种前缀 + `total_tokens` 兜底
|
- **fix(saas)**: `parse_sse_line` 兼容 `data:` 和 `data: ` 两种前缀 + `total_tokens` 兜底
|
||||||
@@ -327,218 +409,6 @@ tags: [log, history]
|
|||||||
- P1: API 密钥页崩溃 / 桌面端 401 恢复 / 用量统计全零 / 行业选择 500 / 管理员订阅 500 / SaaS 启动崩溃
|
- P1: API 密钥页崩溃 / 桌面端 401 恢复 / 用量统计全零 / 行业选择 500 / 管理员订阅 500 / SaaS 启动崩溃
|
||||||
- 完整报告: `docs/INTEGRATION_TEST_REPORT_20260414_V2.md`
|
- 完整报告: `docs/INTEGRATION_TEST_REPORT_20260414_V2.md`
|
||||||
|
|
||||||
## [2026-04-13] fix | V13 审计 6 项修复全部完成
|
|
||||||
|
|
||||||
- FIX-01~06: TrajectoryRecorder注册 + industryStore接入 + 知识搜索 + webhook标注 + 结构化UI + PersistentMemoryStore移除
|
|
||||||
- 提交: c167ea4 + fd3e7fd
|
|
||||||
|
|
||||||
## [2026-04-12] audit | V13 系统性功能审计 — 6 项新发现
|
|
||||||
|
|
||||||
- 全系统功能一致性审计完成, 总体健康度 82/100 (V12: 76)
|
|
||||||
- P1 新发现 3 项: TrajectoryRecorder 未注册中间件链, industryStore 无组件导入, 桌面端无 Knowledge Search
|
|
||||||
- P2 新发现 3 项: Webhook 孤儿表, Structured Data Source 无 Admin UI, PersistentMemoryStore 遗留
|
|
||||||
- 修正 V12 错误认知 5 项: Butler/MCP/Gateway/Presentation 已接通, Reflection driver 已修复
|
|
||||||
- TRUTH.md 数字校准: Tauri 184→191, SaaS 122→136, @reserved 33→24, dead_code 76→43
|
|
||||||
- 完整报告: `docs/features/audit-v13/V13-FULL-REPORT.md`
|
|
||||||
|
|
||||||
## [2026-04-12] fix | 三轮审计修复 — 3 HIGH + 4 MEDIUM 清零
|
|
||||||
|
|
||||||
- H1: status disabled→inactive 统一 + source 补 admin 映射
|
|
||||||
- H2: experience.rs format_for_injection XML 转义
|
|
||||||
- H3: TriggerContext industry_keywords 全局缓存接通
|
|
||||||
- M2: ID 自动生成移除中文 + 无 ASCII 手动提示
|
|
||||||
- M3: TS CreateIndustryRequest 补 id 字段
|
|
||||||
- M4: ListIndustriesQuery deny_unknown_fields
|
|
||||||
|
|
||||||
## [2026-04-12] feat | 知识库 Phase D — 统一搜索 + 种子知识冷启动
|
|
||||||
|
|
||||||
- search/recommend API 返回 UnifiedSearchResult (文档+结构化双通道合并)
|
|
||||||
- POST /api/v1/knowledge/seed 种子知识冷启动接口 (幂等, admin权限)
|
|
||||||
- seed_knowledge: 按标题+行业查重, source='distillation', tags标记行业
|
|
||||||
- SearchRequest 扩展: search_structured/search_documents/industry_id 字段
|
|
||||||
- 167 行新增, 4 文件变更
|
|
||||||
|
|
||||||
## [2026-04-12] fix | 二次审计修复 — 2 CRITICAL + 4 HIGH + 2 MEDIUM
|
|
||||||
|
|
||||||
- C-1: Industries.tsx 创建弹窗缺少 id → 添加 id 输入 + name 自动生成
|
|
||||||
- C-2: Accounts.tsx handleSave 部分 save → try/catch + handleClose 统一
|
|
||||||
- V1: viking_commands Mutex 跨 await → Arc clone 后释放 Mutex
|
|
||||||
- I1+I2: 误导性"相关度"分数移除 + pain point XML 转义
|
|
||||||
- S1+S2: industry status 枚举白名单 + id 格式正则验证
|
|
||||||
- H-3+H-4: 编辑模态数据竞争守卫 + useEffect editingId 守卫
|
|
||||||
|
|
||||||
## [2026-04-12] feat | 知识库 Phase B+C — 文档提取器 + multipart 文件上传
|
|
||||||
|
|
||||||
- extractors.rs: PDF(pdf-extract) + DOCX(zip+quick-xml) + Excel(calamine) 三格式提取
|
|
||||||
- 格式路由 detect_format() → RAG 通道或结构化通道
|
|
||||||
- POST /api/v1/knowledge/upload multipart 文件上传
|
|
||||||
- PDF/DOCX/Markdown → RAG 管线,Excel → structured_rows JSONB 存储
|
|
||||||
- 结构化数据源 API: GET/DELETE /api/v1/structured/sources + /rows + /query
|
|
||||||
- 修复 industry/service.rs SaasError::Database 类型不匹配
|
|
||||||
- 累计新增 849 行,7 文件变更
|
|
||||||
|
|
||||||
## [2026-04-12] fix | 审计修复 — 4 CRITICAL + 5 HIGH 全部解决
|
|
||||||
|
|
||||||
- C1: SQL 注入风险 → industry/service.rs 参数化查询 ($N 绑定)
|
|
||||||
- C2: INDUSTRY_CONFIGS 死链 → Kernel 共享 Arc + ButlerRouter 共享实例
|
|
||||||
- C3: IndustryListItem 缺字段 → keywords_count + 时间戳补全
|
|
||||||
- C4: 非事务性行业绑定 → batch ANY($1) 验证 + 事务 DELETE+INSERT
|
|
||||||
- H8: Accounts.tsx 竞态 → mutate→mutateAsync + confirmLoading 双检测
|
|
||||||
- H9: XML 注入未转义 → xml_escape() 辅助函数
|
|
||||||
- H10: update 覆盖 source → 保留原始值
|
|
||||||
- H11: 面包屑 /industries 映射缺失
|
|
||||||
|
|
||||||
## [2026-04-12] feat | 行业配置 + 管家主动性 全栈 5 Phase 实施
|
|
||||||
|
|
||||||
Phase 1 — 行业配置基础 (13 files, 886 insertions):
|
|
||||||
- SaaS industries + account_industries 表 (migration v15)
|
|
||||||
- 4 内置行业: 医疗/教育/制衣/电商 (keywords/prompt/pain_seed_categories)
|
|
||||||
- ButlerRouter 动态行业关键词注入 (Arc<RwLock<Vec<IndustryKeywordConfig>>>)
|
|
||||||
- 8 SaaS API handlers (list/create/update/fullConfig/accountIndustries)
|
|
||||||
|
|
||||||
Phase 2 — 学习循环基础 (5 files, 271 insertions):
|
|
||||||
- 5 触发信号: PainConfirmed/PositiveFeedback/ComplexToolChain/UserCorrection/IndustryPattern
|
|
||||||
- Experience 增加 industry_context + source_trigger 维度
|
|
||||||
- experience_store keywords 含行业标签
|
|
||||||
|
|
||||||
Phase 3 — Tauri 行业配置加载 (6 files, 310 insertions):
|
|
||||||
- desktop saas-industry.ts mixin (4 API methods)
|
|
||||||
- industryStore.ts (Zustand + persist, 离线缓存)
|
|
||||||
- viking_load_industry_keywords Tauri 命令 (JSON String → Rust struct)
|
|
||||||
|
|
||||||
Phase 4 — Admin 行业管理 (6 files, 564 insertions):
|
|
||||||
- Industries.tsx: 行业列表 + 编辑弹窗(关键词/prompt/痛点种子) + 新建弹窗
|
|
||||||
- Accounts.tsx 增强: 行业授权多选 + 主行业标记
|
|
||||||
- /industries 路由 + ShopOutlined 侧边栏导航
|
|
||||||
|
|
||||||
Phase 5 — 主动行为激活 (3 files, 152 insertions):
|
|
||||||
- 注入格式升级: [路由上下文] → <butler-context> XML fencing (Hermes 策略)
|
|
||||||
- 跨会话连续性: pre_hook 注入活跃痛点 + 相关经验
|
|
||||||
- 触发信号持久化: store_trigger_experience() 模板提取零 LLM 成本
|
|
||||||
|
|
||||||
## [2026-04-11] chore | 发布前准备 — 版本号统一 + 数字校准 + 安全加固
|
|
||||||
|
|
||||||
1. Cargo.toml 版本 0.1.0 → 0.9.0-beta.1 (workspace 统一)
|
|
||||||
2. TRUTH.md 数字全面校准 — Rust 代码 66K→74.6K、Tauri 命令 182→184、SaaS .route() 140→122 等 10 项
|
|
||||||
3. CSP 加固 — 添加 `object-src 'none'`
|
|
||||||
4. .env.example 补充 SaaS 关键环境变量 (JWT_SECRET/TOTP_KEY/Admin 凭据)
|
|
||||||
5. 安全检查通过 — 无硬编码密钥、SQL 全参数化、Cookie 三件套完整
|
|
||||||
|
|
||||||
## [2026-04-11] fix | 模型路由链路修复 — 消除硬编码不匹配模型
|
|
||||||
|
|
||||||
1. summarizer_adapter.rs — "glm-4-flash" 硬编码 fallback → 未配置时明确报错 (fail fast)
|
|
||||||
2. saas-relay-client.ts — 'glm-4-flash-250414' 硬编码 fallback → 未获取模型时报错
|
|
||||||
3. Wiki routing.md — 新增完整模型路由文档 (Tauri SaaS Relay 主路径 + 辅助 LLM + Browser 模式)
|
|
||||||
|
|
||||||
## [2026-04-11] fix | Skill/MCP 调用链路修复 3 个断点
|
|
||||||
|
|
||||||
1. Anthropic Driver ToolResult 格式 — ContentBlock 添加 ToolResult 变体, tool_call_id 不再丢弃
|
|
||||||
2. 前端 callMcpTool 参数名 — serviceName/toolName/args → service_name/tool_name/arguments
|
|
||||||
3. MCP 工具桥接 ToolRegistry — McpToolWrapper + Kernel mcp_adapters 共享状态 + 启停同步
|
|
||||||
4. Wiki 更新 — hands-skills.md 添加 Skill 调用链路 + MCP 架构文档
|
|
||||||
|
|
||||||
## [2026-04-11] fix | 发布内测前修复 6 批次
|
|
||||||
|
|
||||||
- Batch 1: 新用户 llm_routing 默认改为 relay (SQL + migration)
|
|
||||||
- Batch 2: SaaS URL 集中配置化 (VITE_SAAS_URL, 5处硬编码消除)
|
|
||||||
- Batch 3: Gateway URL 配置化 + Rust panic hook 崩溃报告
|
|
||||||
- Batch 4: UX 文案修复 — 新/老用户区分 + 去政务化 + 忘记密码
|
|
||||||
- Batch 5: 移除空壳"行业资讯" Tab + Provider URL 去重统一到 api-urls.ts
|
|
||||||
- Batch 6: 版本号 0.1.0 → 0.9.0-beta.1 + updater 插件预留
|
|
||||||
|
|
||||||
## [2026-04-11] docs | Wiki 全面更新 — 代码验证驱动
|
|
||||||
|
|
||||||
- 全部 10 个 wiki 页面基于代码扫描验证更新(非文档推测)
|
|
||||||
- 关键数字修正: Rust 95K行(335 .rs文件, 原文档66K)、Tauri命令 190/183、SaaS路由 121、前端组件 104、lib/ 85 文件
|
|
||||||
- 测试函数修正: ~1,055 (872内联+183集成,原文档仅计#[test])
|
|
||||||
- 新增中间件完整注册清单(14层runtime + 6层SaaS HTTP)
|
|
||||||
- 新增 Store 完整目录结构(17 文件 + chat/4 子store)
|
|
||||||
- 新增 Pipeline 模板完整目录树(17 YAML, 8 行业目录)
|
|
||||||
- 新增 Hands 测试数分布
|
|
||||||
- 新增 memory Tauri 命令完整列表(16 个)
|
|
||||||
- 新增代码健康度指标(TODO/FIXME 仅 8 个)
|
|
||||||
- 修正管家模式描述: 关键词路由 → 语义路由(TF-IDF)
|
|
||||||
- 新增 artifactStore 到 chat Store 拆分列表
|
|
||||||
|
|
||||||
## [2026-04-11] init | 创建 wiki 知识库
|
|
||||||
|
|
||||||
- 从 TRUTH.md / ARCHITECTURE_BRIEF.md / CLAUDE.md 编译 8 个 wiki 页面
|
|
||||||
- 创建 index.md 入口 + 7 个主题页
|
|
||||||
- CLAUDE.md 添加 @wiki/index.md 引用
|
|
||||||
|
|
||||||
## [2026-04-10] fix | 发布前修复批次
|
|
||||||
|
|
||||||
- ButlerRouter 语义路由 — SemanticSkillRouter TF-IDF 替代关键词
|
|
||||||
- P1-04 AuthGuard 竞态 — 三态守卫 + cookie 先验证
|
|
||||||
- P2-03 限流 — Cross 测试共享 token
|
|
||||||
- P1-02 浏览器聊天 — Playwright SaaS fixture
|
|
||||||
- BREAKS.md 全部 P0/P1/P2 已修复
|
|
||||||
|
|
||||||
## [2026-04-09] feat | Hermes Intelligence Pipeline 4 Chunk
|
|
||||||
|
|
||||||
- Chunk1 ExperienceStore+Extractor (10 tests)
|
|
||||||
- Chunk2 UserProfileStore+Profiler (14 tests)
|
|
||||||
- Chunk3 NlScheduleParser (16 tests)
|
|
||||||
- Chunk4 TrajectoryRecorder+Compressor (18 tests)
|
|
||||||
- 中间件 13→14 层 (+TrajectoryRecorder@650)
|
|
||||||
- Schema v2→v4 (user_profiles + trajectory tables)
|
|
||||||
|
|
||||||
## [2026-04-09] feat | 管家模式发布前实施完成
|
|
||||||
|
|
||||||
- ButlerRouter + 冷启动 + 简洁UI
|
|
||||||
- 痛点持久化 SQLite
|
|
||||||
- 桥测试 43 通过
|
|
||||||
|
|
||||||
## [2026-04-07] feat | 管家能力激活
|
|
||||||
|
|
||||||
- Tauri 命令 183→189 (+6 butler)
|
|
||||||
- multi-agent feature 默认启用
|
|
||||||
- ButlerPanel UI 3 区
|
|
||||||
- DataMaskingMiddleware@90
|
|
||||||
|
|
||||||
## [2026-04-03] fix | 前端改进 + 数字校准
|
|
||||||
|
|
||||||
- Pipeline 8 invoke 接通前端
|
|
||||||
- Viking 5 孤立 invoke 清理
|
|
||||||
- SaaS API 93→131 (新增 knowledge/billing/role)
|
|
||||||
- scheduled_task Admin V2 完整接入
|
|
||||||
|
|
||||||
## [2026-04-02] fix | P0/P1 全部修复
|
|
||||||
|
|
||||||
- 2 P0 崩溃修复
|
|
||||||
- 9 P1 功能失效修复
|
|
||||||
- 7 P1.5 代码质量修复
|
|
||||||
- TRUTH.md 初始创建
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> 更新规则: 每次重大变更后追加一条,最新在最上面
|
> 更新规则: 每次重大变更后追加一条,最新在最上面
|
||||||
|
|
||||||
### [2026-04-13] V13 审计 6 项修复全部完成
|
|
||||||
|
|
||||||
- FIX-01 (P1): TrajectoryRecorderMiddleware 注册到 create_middleware_chain() @650,Hermes 轨迹数据开始流入
|
|
||||||
- FIX-02 (P1): industryStore 接入 ButlerPanel,桌面端展示行业专长卡片 + 自动拉取
|
|
||||||
- FIX-03 (P1): 桌面端知识库搜索 — saas-knowledge mixin + VikingPanel SaaS KB 搜索 UI
|
|
||||||
- FIX-04 (P2): Webhook 孤儿迁移标注 deprecated + down migration 注释
|
|
||||||
- FIX-05 (P2): Admin Knowledge 新增"结构化数据"Tab (CRUD + 行浏览)
|
|
||||||
- FIX-06 (P2): PersistentMemoryStore 全量移除 — persistent.rs 611→57行,删除死 embedding global + 2 @reserved 命令 + viking_commands 冗余配置,Tauri 命令 191→189
|
|
||||||
- 文件: 13 个 (Rust 5 + TS 7 + docs 1), 提交: c167ea4 + fd3e7fd + 本轮
|
|
||||||
|
|
||||||
- P0: memory_search 空查询 min_similarity 默认值; hand_trigger null→handAutoTrigger; 重启后 chat 路由竞态修复
|
|
||||||
- P1: AgentInfo 扩展 UserProfile 桥接; 反思阈值降低 5→3; 反思 state restore peek+pop 竞态修复
|
|
||||||
- P2: 演化历史可展开差异视图; 管家 Tab 条件 header + 空状态引导
|
|
||||||
- 文件: 14 个 (Rust 5 + TS 9), 10 次提交
|
|
||||||
|
|
||||||
## 2026-04-21: Wiki 系统性更新
|
|
||||||
|
|
||||||
**变更**: wiki 三层架构增强 — L0 速览 + L1 模块标准化 + L2 功能链路映射
|
|
||||||
|
|
||||||
- L0: index.md 增强 — 用户功能清单(10类) + 跨模块数据流全景图 + 导航树增强(含3新页面)
|
|
||||||
- L1: 8 个模块页标准化 — 新增功能清单/API接口/测试链路/已知问题标准章节
|
|
||||||
- routing.md (252→326), chat.md (101→157), saas.md (153→230), memory.md (182→333)
|
|
||||||
- butler.md (137→179), middleware.md (121→159), hands-skills.md (218→257), pipeline.md (111→156)
|
|
||||||
- L1: 新增 security.md (157行) + data-model.md (180行)
|
|
||||||
- L2: 新增 feature-map.md (408行, 33条功能链路, 覆盖对话/Agent/Hands/记忆/SaaS/管家/Pipeline/配置/安全)
|
|
||||||
- 维护: CLAUDE.md §8.3 wiki 触发规则扩展 (6→9条规则)
|
|
||||||
- 设计文档: docs/superpowers/specs/2026-04-21-wiki-systematic-overhaul-design.md
|
|
||||||
- 文件: 11 个修改 + 3 个新增, 总计 ~1400 行新增内容
|
|
||||||
|
|||||||
473
wiki/memory.md
473
wiki/memory.md
@@ -2,412 +2,149 @@
|
|||||||
title: 记忆管道
|
title: 记忆管道
|
||||||
updated: 2026-04-22
|
updated: 2026-04-22
|
||||||
status: active
|
status: active
|
||||||
tags: [module, memory, growth]
|
tags: [module, memory, fts5, growth]
|
||||||
---
|
---
|
||||||
|
|
||||||
# 记忆管道 (Memory Pipeline)
|
# 记忆管道 (Memory Pipeline)
|
||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[chat]] [[middleware]]
|
> 从 [[index]] 导航。关联: [[chat]] [[middleware]] [[butler]]
|
||||||
|
> 详细提取逻辑归档: [[archive/memory-extraction-details]]
|
||||||
|
|
||||||
## 设计思想
|
## 1. 设计决策
|
||||||
|
|
||||||
**核心问题: LLM 无状态,每次对话都从零开始。需要从历史对话中积累知识。**
|
**核心问题: LLM 无状态,每次对话从零开始。需要从历史对话中积累知识。**
|
||||||
|
|
||||||
设计决策:
|
| 决策 | WHY |
|
||||||
1. **闭环架构** — 对话 → 提取 → 索引 → 检索 → 注入,形成正向循环
|
|------|-----|
|
||||||
2. **FTS5 + TF-IDF** — 轻量级语义搜索,不依赖外部 embedding 服务
|
| 闭环架构 | 对话→提取→索引→检索→注入,形成正向循环。每次聊天都积累,每次提问都利用 |
|
||||||
3. **Token 预算控制** — 注入 system prompt 时有 token 上限,防止溢出
|
| 双数据库 | memories.db (FTS5 全文索引) + data.db (结构化画像)。前者处理模糊语义检索,后者处理精确字段查询 |
|
||||||
4. **EmbeddingClient trait 已预留** — 接口已写,激活即可升级到向量搜索
|
| FTS5+TF-IDF+Embedding 三层 | FTS5 粗筛 + TF-IDF 加权 + Embedding 精排(当前 NoOp,配置后激活)。不依赖外部服务即可工作 |
|
||||||
|
| 进化引擎 | 从对话中检测行为模式变化,生成进化候选项(技能建议/工作流优化),通过 EvolutionMiddleware@78 注入 system prompt |
|
||||||
|
| Token 预算控制 | 注入 system prompt 时有 token 上限,防止记忆注入挤占用户实际对话空间 |
|
||||||
|
|
||||||
## 功能清单
|
**Hermes 核心借鉴** (详见 [[archive/hermes-analysis]]): 经验库 FTS5 全文检索 + 用户画像结构化建模 + 自然语言 cron 调度 + 轨迹记录压缩。4 Chunk 已交付: ExperienceStore(10 tests) + UserProfileStore(14 tests) + NlScheduleParser(16 tests) + TrajectoryRecorder(18 tests)。
|
||||||
|
|
||||||
| 功能 | 描述 | 入口文件 | 状态 |
|
## 2. 关键文件 + 数据流
|
||||||
|------|------|----------|------|
|
|
||||||
| 记忆提取 | LLM 从对话中提取偏好/知识/经验 | extractor.rs | ✅ |
|
|
||||||
| FTS5 全文检索 | SQLite FTS5 + TF-IDF 权重搜索 | storage/sqlite.rs | ✅ |
|
|
||||||
| 语义检索 | 意图分类 + CJK 关键词 + 混合评分 | retriever.rs, retrieval/query.rs | ✅ |
|
|
||||||
| Prompt 注入 | token 预算控制 + 结构化上下文注入 | injector.rs | ✅ |
|
|
||||||
| 经验管理 | pain→solution→outcome CRUD | experience_store.rs | ✅ |
|
|
||||||
| 用户画像 | 结构化偏好/兴趣/能力管理 | profile_updater.rs | ✅ |
|
|
||||||
| 进化引擎 | 行为模式检测 → 技能/工作流建议 | evolution_engine.rs | ✅ |
|
|
||||||
| 质量门控 | 长度/标题/置信度/去重校验 | quality_gate.rs | ✅ |
|
|
||||||
| 自动技能生成 | 从模式生成 SkillManifest | skill_generator.rs | ✅ |
|
|
||||||
| 上下文压缩 | 超长对话压缩摘要 | summarizer.rs | ✅ |
|
|
||||||
| 嵌入向量 | EmbeddingClient trait + NoOp 默认 | retrieval/semantic.rs | 🚧 接口就绪 |
|
|
||||||
| SaaS pgvector | knowledge_chunks 向量索引 | saas/generate_embedding.rs | 🚧 索引就绪,生成未实现 |
|
|
||||||
|
|
||||||
## 代码逻辑
|
### 核心文件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `crates/zclaw-growth/src/extractor.rs` | LLM 记忆提取 (偏好/知识/经验) |
|
||||||
|
| `crates/zclaw-growth/src/retriever.rs` | 语义检索 (FTS5 + TF-IDF + 意图分类) |
|
||||||
|
| `crates/zclaw-growth/src/injector.rs` | Prompt 注入 (token 预算) |
|
||||||
|
| `crates/zclaw-growth/src/storage/sqlite.rs` | FTS5 + TF-IDF 核心 (memories.db) |
|
||||||
|
| `crates/zclaw-runtime/src/middleware/memory.rs` | 记忆中间件 (提取+注入编排) |
|
||||||
|
| `crates/zclaw-runtime/src/growth.rs` | GrowthIntegration 闭环编排 |
|
||||||
|
| `crates/zclaw-memory/src/user_profile_store.rs` | UserProfileStore (data.db) |
|
||||||
|
|
||||||
### 闭环数据流
|
### 闭环数据流
|
||||||
|
|
||||||
```
|
```
|
||||||
[提取] 对话发生
|
[提取] 对话完成 → MemoryMiddleware.after_completion
|
||||||
→ MemoryExtractor (crates/zclaw-growth/src/extractor.rs)
|
→ MemoryExtractor.extract_combined() → LLM 单次调用
|
||||||
→ LLM 提取: 偏好 (Preference) / 知识 (Knowledge) / 经验 (Experience)
|
→ CombinedExtraction { memories, experiences, profile_signals (含 agent_name/user_name) }
|
||||||
→ MemoryEntry { agent_id, memory_type, content, keywords, importance }
|
→ VikingAdapter → SqliteStorage → memories.db (FTS5 索引)
|
||||||
|
→ UserProfileStore → data.db (结构化画像)
|
||||||
|
→ [身份信号] identity/* → VikingStorage → post_conversation_hook → soul.md + Tauri event
|
||||||
|
|
||||||
[索引] 存储
|
[检索] 新请求 → MemoryMiddleware.before_completion
|
||||||
→ SqliteStorage.store() (crates/zclaw-growth/src/storage/sqlite.rs)
|
→ MemoryRetriever.retrieve(agent_id, user_input)
|
||||||
→ SQLite + FTS5 全文索引
|
→ QueryAnalyzer 意图分类 (5类: Preference/Knowledge/Experience/Code/General)
|
||||||
→ TF-IDF 权重计算
|
→ FTS5 全文搜索 + TF-IDF 评分 + IdentityRecall 43+ 模式
|
||||||
→ (可选) EmbeddingClient.embed() → 向量存储 [未激活]
|
→ 弱身份 fallback: <3 结果 → 补充 broad retrieval
|
||||||
|
→ PromptInjector.inject_with_format(system_prompt, memories)
|
||||||
[检索] 查询时
|
|
||||||
→ MemoryRetriever.retrieve(query, agent_id) (crates/zclaw-growth/src/retriever.rs)
|
|
||||||
→ QueryAnalyzer: 意图分类 (Preference/Knowledge/Experience/Code/General)
|
|
||||||
→ 中文+英文关键词提取 + CJK 支持 + 同义词扩展
|
|
||||||
→ SemanticScorer: TF-IDF 匹配 (70% embedding / 30% TF-IDF, embedding 未激活)
|
|
||||||
→ 返回 top-k 相关记忆
|
|
||||||
|
|
||||||
[注入] 给 LLM
|
|
||||||
→ PromptInjector.inject(system_prompt, memories) (crates/zclaw-growth/src/injector.rs)
|
|
||||||
→ token 预算控制
|
|
||||||
→ 格式化为结构化上下文块
|
|
||||||
→ 插入到 system prompt 中
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 跨会话记忆完整链路(2026-04-22 验证通过)
|
### 集成契约
|
||||||
|
|
||||||
> **重要:修改任何环节前请先阅读此链路,避免引入断裂。**
|
| 方向 | 模块 | 触发点 |
|
||||||
|
|------|------|--------|
|
||||||
|
| Called by | middleware: Memory@150 | Every chat completion (after) + new request (before) |
|
||||||
|
| Calls | zclaw-memory: FTS5 store, TF-IDF scorer | Memory extraction + retrieval |
|
||||||
|
| Calls | zclaw-growth: GrowthIntegration, EvolutionEngine | Pattern detection + skill generation |
|
||||||
|
| Provides | loop_runner: Context injection into system prompt | Before LLM call |
|
||||||
|
|
||||||
|
### Tauri 命令
|
||||||
|
|
||||||
|
| 分类 | 命令数 | 关键命令 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Memory CRUD (`memory_commands.rs`) | 11 | `memory_store`, `memory_search`, `memory_build_context` |
|
||||||
|
| VikingStorage (`viking_commands.rs`) | 14 | `viking_add`, `viking_find`, `viking_inject_prompt` |
|
||||||
|
| Intelligence (`intelligence/`) | ~40 | identity(13), heartbeat(11), pain(5), reflection(6) |
|
||||||
|
| 提取+Embedding | 5 | `extract_session_memories`, `embedding_create` |
|
||||||
|
|
||||||
|
## 3. 代码逻辑
|
||||||
|
|
||||||
|
### 跨会话记忆完整链路
|
||||||
|
|
||||||
```
|
```
|
||||||
[初始化] kernel_init (desktop/src-tauri/src/kernel_commands/lifecycle.rs)
|
[初始化] kernel_init → init_storage(memories.db) → MemoryStore(data.db)
|
||||||
① viking_commands::init_storage() → 初始化 SqliteStorage → memories.db
|
→ set_viking(SqliteStorage) → set_extraction_driver(LLM)
|
||||||
② Kernel::boot(config) → 创建 MemoryStore (data.db) + in-memory VikingAdapter
|
→ [首次聊天] create_middleware_chain() 重建 GrowthIntegration:
|
||||||
③ viking_commands::get_storage() → SqliteStorage 包装为 VikingAdapter → kernel.set_viking()
|
VikingAdapter + ExtractionDriver + UserProfileStore(data.db)
|
||||||
↑ 此时 GrowthIntegration 缓存被清除
|
→ MemoryMiddleware@150 注册到中间件链
|
||||||
④ TauriExtractionDriver::new(driver, model) → kernel.set_extraction_driver()
|
|
||||||
↑ GrowthIntegration 缓存再次清除
|
|
||||||
⑤ [首次聊天时] create_middleware_chain() 重建 GrowthIntegration:
|
|
||||||
- GrowthIntegration::new(self.viking.clone()) ← 持久化 SqliteStorage
|
|
||||||
- .with_llm_driver(extraction_driver) ← LLM 提取能力
|
|
||||||
- .with_profile_store(UserProfileStore::new(memory.pool())) ← data.db 画像持久化
|
|
||||||
- .configure_embedding(embedding_client) ← 语义检索(可选)
|
|
||||||
→ 缓存到 kernel.growth (Mutex<Arc<GrowthIntegration>>)
|
|
||||||
→ MemoryMiddleware::new(growth) 注册到中间件链 (priority=150)
|
|
||||||
|
|
||||||
[写入] after_completion — 记忆提取 (crates/zclaw-runtime/src/middleware/memory.rs)
|
[写入] after_completion → 30秒去重 → extract_combined(LLM)
|
||||||
MemoryMiddleware.after_completion(ctx)
|
→ memories → memories.db | profile_signals → data.db
|
||||||
→ 30秒去重: should_extract(agent_id) — 同一 agent 30秒内跳过
|
|
||||||
→ growth.extract_combined(agent_id, messages, session_id)
|
|
||||||
→ MemoryExtractor.extract_combined() (crates/zclaw-growth/src/extractor.rs)
|
|
||||||
→ LLM 单次调用 (COMBINED_EXTRACTION_PROMPT + 对话文本)
|
|
||||||
→ 返回 CombinedExtraction { memories, experiences, profile_signals }
|
|
||||||
→ extractor.store_memories(agent_id, memories)
|
|
||||||
→ VikingAdapter → SqliteStorage.store() → memories.db (FTS5 索引)
|
|
||||||
→ experience_extractor.persist_experiences(agent_id, combined)
|
|
||||||
→ agent://{agent_id}/experience/... URI
|
|
||||||
→ profile_updater.collect_updates(combined)
|
|
||||||
→ UserProfileStore.update_field/add_recent_topic/add_pain_point/add_preferred_tool
|
|
||||||
→ 写入 data.db.user_profiles 表 (user_id = agent_id)
|
|
||||||
|
|
||||||
[读取] before_completion — 记忆检索+注入 (每个新请求)
|
[读取] before_completion → retrieve(agent_id, query)
|
||||||
MemoryMiddleware.before_completion(ctx)
|
→ FTS5 + TF-IDF + IdentityRecall → inject_with_format()
|
||||||
→ growth.enhance_prompt(agent_id, system_prompt, user_input)
|
|
||||||
→ retriever.retrieve(agent_id, user_input)
|
|
||||||
→ 按 agent_id 构建搜索范围: agent://{agent_id}/{type}
|
|
||||||
→ QueryAnalyzer 意图分析 + IdentityRecall 43+ 模式匹配
|
|
||||||
→ FTS5 全文搜索 + TF-IDF 评分 + 语义重排序
|
|
||||||
→ 弱身份 fallback: <3 结果 + weak_identity → 补充 broad retrieval
|
|
||||||
→ injector.inject_with_format(system_prompt, memories)
|
|
||||||
→ 按 token 预算注入结构化上下文
|
|
||||||
|
|
||||||
[展示] 管家Tab — 前端读取 (desktop/src/components/ButlerPanel/)
|
[展示] 管家Tab → viking_ls/viking_read(memories.db) + agent_get(data.db)
|
||||||
MemorySection.tsx:
|
|
||||||
→ listVikingResources("agent://{agent_id}/") → viking_ls → memories.db
|
|
||||||
→ readVikingResource(uri, "L1") → viking_read → L1 摘要
|
|
||||||
→ 按类型分组: 偏好/知识/经验/会话
|
|
||||||
→ agent_get(agentId) → kernel.memory() → UserProfileStore.get() → data.db
|
|
||||||
→ 用户画像卡片: 行业/角色/沟通风格/近期话题/常用工具
|
|
||||||
|
|
||||||
[数据库架构]
|
|
||||||
memories.db (SqliteStorage, viking_commands 管理)
|
|
||||||
→ memories 表: URI + memory_type + content + FTS5 索引
|
|
||||||
→ memories_fts 虚拟表: FTS5 trigram tokenizer (CJK 支持)
|
|
||||||
data.db (MemoryStore, kernel 管理)
|
|
||||||
→ user_profiles 表: user_id + industry + role + recent_topics(JSON) + ...
|
|
||||||
→ agents 表: agent 配置
|
|
||||||
→ sessions 表: 会话数据
|
|
||||||
|
|
||||||
[关键文件地图]
|
|
||||||
crates/zclaw-kernel/src/kernel/mod.rs create_middleware_chain() + memory()
|
|
||||||
crates/zclaw-runtime/src/middleware/memory.rs MemoryMiddleware before/after
|
|
||||||
crates/zclaw-runtime/src/growth.rs GrowthIntegration 闭环编排
|
|
||||||
crates/zclaw-growth/src/extractor.rs extract_combined() LLM 提取
|
|
||||||
crates/zclaw-growth/src/retriever.rs retrieve() FTS5+TF-IDF 检索
|
|
||||||
crates/zclaw-growth/src/injector.rs inject_with_format() prompt 注入
|
|
||||||
crates/zclaw-growth/src/storage/sqlite.rs SqliteStorage (memories.db)
|
|
||||||
crates/zclaw-memory/src/user_profile_store.rs UserProfileStore (data.db)
|
|
||||||
desktop/src-tauri/src/viking_commands.rs viking_ls/viking_read Tauri 命令
|
|
||||||
desktop/src-tauri/src/kernel_commands/agent.rs agent_get (读取 UserProfile)
|
|
||||||
desktop/src-tauri/src/kernel_commands/lifecycle.rs kernel_init (初始化链路)
|
|
||||||
desktop/src/components/ButlerPanel/MemorySection.tsx 前端展示
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 经验存储 (ExperienceStore)
|
### 不变量
|
||||||
|
|
||||||
Hermes 管线 Chunk1 新增:
|
- memories.db 和 data.db 是独立的 SQLite 数据库,跨库查询需使用正确连接
|
||||||
|
- 记忆注入在中间件@150,AFTER ButlerRouter@80,BEFORE SkillIndex@200
|
||||||
|
- Embedding 当前为 NoOpEmbeddingClient,用户配置 provider 后替换为真实实现
|
||||||
|
- 30 秒去重窗口:同一 agent 30 秒内跳过重复提取
|
||||||
|
- URI scheme: `agent://{agent_id}/{type}/{category}`
|
||||||
|
|
||||||
```
|
### 非显然逻辑
|
||||||
ExperienceStore (crates/zclaw-growth/src/experience_store.rs)
|
|
||||||
→ CRUD 封装: pain → solution → outcome 结构化经验
|
|
||||||
→ 底层使用 VikingAdapter
|
|
||||||
→ URI scheme: agent://{agent_id}/experience/...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hermes 管线 (4 Chunk)
|
- TF-IDF 语义路由:70% embedding 权重 + 30% TF-IDF(embedding 未激活时退化为纯 TF-IDF)
|
||||||
|
- IdentityRecall: 43+ 模式匹配("你记得我"/"上次说的"/"我的偏好"等)触发记忆检索
|
||||||
|
- 弱身份 fallback: 检索结果 <3 且含弱身份关键词时,补充 broad retrieval 扩大搜索范围
|
||||||
|
|
||||||
| Chunk | 模块 | 文件 | 测试 |
|
## 4. 活跃问题 + 陷阱
|
||||||
|-------|------|------|------|
|
|
||||||
| 1 | ExperienceStore + Extractor | experience_store.rs | 10 |
|
|
||||||
| 2 | UserProfileStore + Profiler | user_profile.rs | 14 |
|
|
||||||
| 3 | NlScheduleParser | nl_schedule.rs | 16 |
|
|
||||||
| 4 | TrajectoryRecorder + Compressor | middleware/trajectory_recorder.rs | 18 |
|
|
||||||
|
|
||||||
Hermes 相关测试分布:
|
### 活跃
|
||||||
- `nl_schedule.rs`: 16 tests (中文时间→cron 解析)
|
|
||||||
- `types.rs`: 9 tests (记忆类型)
|
|
||||||
- `injector.rs`: 9 tests (prompt 注入)
|
|
||||||
|
|
||||||
### 查询意图分类
|
| 问题 | 状态 | 说明 |
|
||||||
|
|
||||||
`QueryAnalyzer` 支持 5 种意图:
|
|
||||||
|
|
||||||
| 意图 | 说明 | 检索策略 |
|
|
||||||
|------|------|----------|
|
|
||||||
| Preference | 用户偏好 | 精确匹配 preference 类型记忆 |
|
|
||||||
| Knowledge | 知识查询 | 语义搜索 knowledge 类型 |
|
|
||||||
| Experience | 经验检索 | 时间+相关性排序 |
|
|
||||||
| Code | 代码相关 | 关键词优先 |
|
|
||||||
| General | 通用 | 混合策略 |
|
|
||||||
|
|
||||||
### Embedding 基础设施 (已写未激活)
|
|
||||||
|
|
||||||
```
|
|
||||||
EmbeddingClient trait (crates/zclaw-growth/src/retrieval/semantic.rs)
|
|
||||||
→ async embed(&str) -> Vec<f32>
|
|
||||||
→ is_available() -> bool
|
|
||||||
→ 当前实现: NoOpEmbeddingClient (始终返回空)
|
|
||||||
|
|
||||||
SaaS 侧:
|
|
||||||
→ pgvector HNSW 索引就绪 (knowledge_chunks 表, vector(1536))
|
|
||||||
→ generate_embedding Worker: 内容分块 + 中文关键词提取 (Phase 2 embedding deferred)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 进化引擎 (EvolutionEngine)
|
|
||||||
|
|
||||||
zclaw-growth 包含完整的自我改进闭环:
|
|
||||||
|
|
||||||
```
|
|
||||||
EvolutionEngine (crates/zclaw-growth/src/evolution_engine.rs)
|
|
||||||
→ 从对话历史中检测行为模式变化
|
|
||||||
→ 生成进化候选项 (新技能建议/工作流优化)
|
|
||||||
→ EvolutionMiddleware@78 注入 system prompt
|
|
||||||
|
|
||||||
配套组件:
|
|
||||||
→ FeedbackCollector — 收集用户反馈信号
|
|
||||||
→ PatternAggregator — 行为模式聚合
|
|
||||||
→ QualityGate — 进化质量门控
|
|
||||||
→ SkillGenerator — 自动技能生成
|
|
||||||
→ WorkflowComposer — 工作流自动编排
|
|
||||||
→ ProfileUpdater — 用户画像更新
|
|
||||||
→ ExperienceExtractor — 经验提取器
|
|
||||||
→ Summarizer — 记忆摘要
|
|
||||||
```
|
|
||||||
|
|
||||||
zclaw-growth 模块结构 (19 文件):
|
|
||||||
```
|
|
||||||
crates/zclaw-growth/src/
|
|
||||||
├── evolution_engine.rs 进化引擎核心
|
|
||||||
├── experience_extractor.rs 经验提取
|
|
||||||
├── experience_store.rs 经验 CRUD
|
|
||||||
├── extractor.rs 记忆提取
|
|
||||||
├── feedback_collector.rs 反馈收集
|
|
||||||
├── injector.rs Prompt 注入
|
|
||||||
├── json_utils.rs JSON 工具
|
|
||||||
├── pattern_aggregator.rs 模式聚合
|
|
||||||
├── profile_updater.rs 画像更新
|
|
||||||
├── quality_gate.rs 质量门控
|
|
||||||
├── retriever.rs 语义检索
|
|
||||||
├── skill_generator.rs 技能生成
|
|
||||||
├── summarizer.rs 摘要生成
|
|
||||||
├── tracker.rs 追踪器
|
|
||||||
├── types.rs 类型定义
|
|
||||||
├── viking_adapter.rs Viking 适配器
|
|
||||||
├── workflow_composer.rs 工作流编排
|
|
||||||
├── retrieval/ 检索子模块
|
|
||||||
│ ├── query.rs 意图分类 + CJK
|
|
||||||
│ └── semantic.rs EmbeddingClient
|
|
||||||
└── storage/ 存储子模块
|
|
||||||
└── sqlite.rs FTS5 + TF-IDF
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端 Tauri 命令
|
|
||||||
|
|
||||||
> 完整 API 接口详情见下方 `## API 接口` 章节。
|
|
||||||
|
|
||||||
**Memory CRUD** (`desktop/src-tauri/src/memory_commands.rs`, 11 命令):
|
|
||||||
|
|
||||||
| 命令 | 参数 | 说明 |
|
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `memory_store` | MemoryEntryInput | 存储记忆条目 |
|
| Embedding 未激活 | 🚧 长期观察 | NoOpEmbeddingClient 是默认值,用户配置 provider 后替换 |
|
||||||
| `memory_get` | id | 按 ID 获取 |
|
| SaaS pgvector | 🚧 deferred | HNSW 索引就绪 (knowledge_chunks, vector(1536)),生成逻辑未实现 |
|
||||||
| `memory_search` | MemorySearchOptions | FTS5 + TF-IDF 搜索 |
|
|
||||||
| `memory_delete` | id | 删除单条 |
|
|
||||||
| `memory_delete_all` | agent_id | 删除 Agent 全部记忆 |
|
|
||||||
| `memory_stats` | — | 记忆统计 |
|
|
||||||
| `memory_export` | — | 导出全部 |
|
|
||||||
| `memory_import` | Vec\<PersistentMemory\> | 批量导入 |
|
|
||||||
| `memory_build_context` | agent_id, query, max_tokens? | 构建检索增强上下文 |
|
|
||||||
| `memory_db_path` | — | SQLite 路径 |
|
|
||||||
| `memory_init` | — | 初始化 (no-op, Viking 自动初始化) |
|
|
||||||
|
|
||||||
**VikingStorage** (`desktop/src-tauri/src/viking_commands.rs`, 14 命令):
|
### 历史陷阱 (已修复)
|
||||||
|
|
||||||
| 命令 | 说明 |
|
- 跨会话记忆断裂 (profile_store 未连接 + 双数据库不一致 + 缺日志) → 04-22 已修复 (adf0251)
|
||||||
|------|------|
|
- 记忆去重失败 (同 URI+content 重复添加) → 04-17 已修复 (a504a40)
|
||||||
| `viking_add` / `viking_add_with_metadata` | 添加记忆 (URI scheme: agent://{id}/{type}/{category}) |
|
- 记忆非 Agent 隔离 (viking_find 返回所有 Agent 记忆) → 04-17 已修复 (a504a40)
|
||||||
| `viking_find` | 语义/关键词搜索 |
|
- 跨会话注入断裂 (新会话报 "no conversation history found") → 04-17 已修复 (a504a40)
|
||||||
| `viking_grep` | 正则搜索 |
|
|
||||||
| `viking_ls` / `viking_tree` / `viking_read` | 浏览/读取 |
|
|
||||||
| `viking_remove` | 删除 |
|
|
||||||
| `viking_inject_prompt` | 记忆注入 prompt |
|
|
||||||
| `viking_configure_embedding` | 配置 embedding provider |
|
|
||||||
| `viking_configure_summary_driver` | 配置摘要 LLM |
|
|
||||||
| `viking_store_with_summaries` | 自动摘要存储 |
|
|
||||||
| `viking_status` | 存储健康检查 |
|
|
||||||
| `viking_load_industry_keywords` | 加载行业关键词 |
|
|
||||||
|
|
||||||
**Intelligence** (`desktop/src-tauri/src/intelligence/`, ~40 命令):
|
### 警告
|
||||||
|
|
||||||
| 模块 | 命令数 | 功能 |
|
> memories.db / data.db 连接池隔离。修改存储层代码务必确认目标数据库。
|
||||||
|------|--------|------|
|
> GrowthIntegration 缓存在 `set_viking()` / `set_extraction_driver()` 时会被清除,首次聊天时重建。
|
||||||
| identity.rs | 13 | Agent 身份/SOUL.md 管理 |
|
|
||||||
| heartbeat.rs | 11 | Heartbeat 引擎配置+记录 |
|
|
||||||
| pain_aggregator.rs | 5 | 痛点追踪+方案生成 |
|
|
||||||
| reflection.rs | 6 | 自我反思引擎 |
|
|
||||||
| compactor.rs | 4 | 上下文压缩 |
|
|
||||||
| health_snapshot.rs | 1 | 综合健康快照 |
|
|
||||||
|
|
||||||
**记忆提取** (`desktop/src-tauri/src/memory/`, 3 命令):
|
## 5. 变更日志
|
||||||
`extract_session_memories`, `extract_and_store_memories`, `estimate_content_tokens`
|
|
||||||
|
|
||||||
**Embedding** (`desktop/src-tauri/src/llm/`, 2 命令):
|
| 日期 | 变更 | 关联 |
|
||||||
`embedding_create`, `embedding_providers`
|
|
||||||
|
|
||||||
**总计 ~74 个** memory/growth/intelligence 相关 Tauri 命令。
|
|
||||||
|
|
||||||
## API 接口
|
|
||||||
|
|
||||||
### Memory CRUD (`desktop/src-tauri/src/memory_commands.rs`, 11 命令)
|
|
||||||
|
|
||||||
| 命令 | 参数 | 说明 |
|
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `memory_store` | MemoryEntryInput | 存储记忆条目 |
|
| 2026-04-23 | agent_update 同步写 soul.md + 命名检测解耦 memory extraction + Agent tab 移除 | commit 394cb66+0bb5265+1c00290 |
|
||||||
| `memory_get` | id | 按 ID 获取 |
|
| 2026-04-23 | 身份信号提取: ProfileSignals+agent_name/user_name + VikingStorage identity 存储 + soul.md 写回 | commit 08812e5+e64a3ea |
|
||||||
| `memory_search` | MemorySearchOptions | FTS5 + TF-IDF 搜索 |
|
| 2026-04-22 | 跨会话记忆断裂修复: profile_store 连接 + 双数据库统一 + 诊断日志 | commit adf0251 |
|
||||||
| `memory_delete` | id | 删除单条 |
|
| 2026-04-22 | Wiki 5-section 重构: 363→~190 行,详细逻辑归档 | wiki/ |
|
||||||
| `memory_delete_all` | agent_id | 删除 Agent 全部记忆 |
|
| 2026-04-21 | Embedding 接通 + 自学习自动化 A线+B线 (SemanticScorer + EvolutionMiddleware) | 934 tests PASS |
|
||||||
| `memory_stats` | — | 记忆统计 |
|
| 2026-04-17 | E2E 全系统验证 129 链路: 记忆去重+注入+跨会话 修复 | 79.1% 通过率 |
|
||||||
| `memory_export` | — | 导出全部 |
|
| 2026-04-15 | Heartbeat 统一健康系统 (health_snapshot + 健康快照集成) | 183 commands |
|
||||||
| `memory_import` | Vec\<PersistentMemory\> | 批量导入 |
|
|
||||||
| `memory_build_context` | agent_id, query, max_tokens? | 构建检索增强上下文 |
|
|
||||||
| `memory_db_path` | — | SQLite 路径 |
|
|
||||||
| `memory_init` | — | 初始化 (no-op) |
|
|
||||||
|
|
||||||
### VikingStorage (`desktop/src-tauri/src/viking_commands.rs`, 14 命令)
|
## 测试概览
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| Crate | 测试数 | 覆盖 |
|
||||||
|------|------|
|
|-------|--------|------|
|
||||||
| `viking_add` / `viking_add_with_metadata` | 添加记忆 (URI: agent://{id}/{type}/{category}) |
|
| zclaw-growth | 181 (29 文件) | 全覆盖 |
|
||||||
| `viking_find` | 语义/关键词搜索 |
|
| zclaw-memory | 54 (4 文件) | 全覆盖 |
|
||||||
| `viking_grep` | 正则搜索 |
|
| **合计** | **235** | |
|
||||||
| `viking_ls` / `viking_tree` / `viking_read` | 浏览/读取 |
|
|
||||||
| `viking_remove` | 删除 |
|
|
||||||
| `viking_inject_prompt` | 记忆注入 prompt |
|
|
||||||
| `viking_configure_embedding` | 配置 embedding provider |
|
|
||||||
| `viking_configure_summary_driver` | 配置摘要 LLM |
|
|
||||||
| `viking_store_with_summaries` | 自动摘要存储 |
|
|
||||||
| `viking_status` / `viking_load_industry_keywords` | 健康检查/行业关键词 |
|
|
||||||
|
|
||||||
### Intelligence (`desktop/src-tauri/src/intelligence/`, ~40 命令)
|
|
||||||
|
|
||||||
| 模块 | 命令数 | 功能 |
|
|
||||||
|------|--------|------|
|
|
||||||
| identity.rs | 13 | Agent 身份/SOUL.md 管理 |
|
|
||||||
| heartbeat.rs | 11 | Heartbeat 引擎配置+记录 |
|
|
||||||
| pain_aggregator.rs | 5 | 痛点追踪+方案生成 |
|
|
||||||
| reflection.rs | 6 | 自我反思引擎 |
|
|
||||||
| compactor.rs | 4 | 上下文压缩 |
|
|
||||||
| health_snapshot.rs | 1 | 综合健康快照 |
|
|
||||||
|
|
||||||
### 记忆提取 + Embedding (5 命令)
|
|
||||||
|
|
||||||
`extract_session_memories`, `extract_and_store_memories`, `estimate_content_tokens` (memory/)
|
|
||||||
+ `embedding_create`, `embedding_providers` (llm/)
|
|
||||||
|
|
||||||
**总计 ~74 个** memory/growth/intelligence 相关 Tauri 命令。
|
|
||||||
|
|
||||||
## 测试链路
|
|
||||||
|
|
||||||
| 功能 | Crate | 测试文件 | 测试数 | 覆盖状态 |
|
|
||||||
|------|-------|---------|--------|---------|
|
|
||||||
| 记忆存储/检索 | zclaw-growth | storage/sqlite.rs | 6 | ✅ |
|
|
||||||
| 经验 CRUD | zclaw-growth | experience_store.rs | 12 | ✅ |
|
|
||||||
| 提取器 | zclaw-growth | extractor.rs | 8 | ✅ |
|
|
||||||
| 注入器 | zclaw-growth | injector.rs | 9 | ✅ |
|
|
||||||
| 质量门控 | zclaw-growth | quality_gate.rs | 9 | ✅ |
|
|
||||||
| 进化引擎 | zclaw-growth | evolution_engine.rs | 5 | ✅ |
|
|
||||||
| 技能生成 | zclaw-growth | skill_generator.rs | 6 | ✅ |
|
|
||||||
| 工作流编排 | zclaw-growth | workflow_composer.rs | 5 | ✅ |
|
|
||||||
| 意图分类+CJK | zclaw-growth | retrieval/query.rs | 11 | ✅ |
|
|
||||||
| 语义评分 | zclaw-growth | retrieval/semantic.rs | 9 | ✅ |
|
|
||||||
| Embedding | zclaw-growth | retrieval/cache.rs | 7 | ✅ |
|
|
||||||
| 模式聚合 | zclaw-growth | pattern_aggregator.rs | 6 | ✅ |
|
|
||||||
| 反馈收集 | zclaw-growth | feedback_collector.rs | 10 | ✅ |
|
|
||||||
| 摘要 | zclaw-growth | summarizer.rs | 5 | ✅ |
|
|
||||||
| 类型定义 | zclaw-growth | types.rs | 13 | ✅ |
|
|
||||||
| 进化闭环 | zclaw-growth | tests/evolution_loop_test.rs | 6 | ✅ |
|
|
||||||
| 经验链 | zclaw-growth | tests/experience_chain_test.rs | 6 | ✅ |
|
|
||||||
| 集成 | zclaw-growth | tests/integration_test.rs | 9 | ✅ |
|
|
||||||
| **zclaw-growth 小计** | | 29 文件 | **181** | |
|
|
||||||
| 记忆存储 | zclaw-memory | store.rs | 20 | ✅ |
|
|
||||||
| 用户画像 | zclaw-memory | user_profile_store.rs | 20 | ✅ |
|
|
||||||
| 轨迹记录 | zclaw-memory | trajectory_store.rs | 9 | ✅ |
|
|
||||||
| 事实提取 | zclaw-memory | fact.rs | 5 | ✅ |
|
|
||||||
| **zclaw-memory 小计** | | 4 文件 | **54** | |
|
|
||||||
| **合计** | | 33 文件 | **235** | |
|
|
||||||
|
|
||||||
## 关联模块
|
|
||||||
|
|
||||||
- [[chat]] — 对话是记忆的输入源
|
|
||||||
- [[butler]] — 管家模式可能利用记忆提供个性化响应
|
|
||||||
- [[middleware]] — Memory 中间件自动提取 + SkillIndex 注入
|
|
||||||
|
|
||||||
## 关键文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `crates/zclaw-growth/src/extractor.rs` | LLM 记忆提取 |
|
|
||||||
| `crates/zclaw-growth/src/retriever.rs` | 语义检索 |
|
|
||||||
| `crates/zclaw-growth/src/injector.rs` | Prompt 注入 (token 预算) |
|
|
||||||
| `crates/zclaw-growth/src/experience_store.rs` | 经验 CRUD |
|
|
||||||
| `crates/zclaw-growth/src/storage/sqlite.rs` | FTS5 + TF-IDF 核心 |
|
|
||||||
| `crates/zclaw-growth/src/retrieval/semantic.rs` | EmbeddingClient trait |
|
|
||||||
| `crates/zclaw-growth/src/retrieval/query.rs` | 意图分类 + CJK 关键词 |
|
|
||||||
| `crates/zclaw-growth/src/nl_schedule.rs` | 中文时间→cron 解析 (16 tests) |
|
|
||||||
| `crates/zclaw-runtime/src/middleware/memory.rs` | 记忆中间件 |
|
|
||||||
| `crates/zclaw-runtime/src/middleware/trajectory_recorder.rs` | 轨迹记录中间件 |
|
|
||||||
| `desktop/src/store/memoryGraphStore.ts` | 前端记忆 UI |
|
|
||||||
| `desktop/src-tauri/src/memory/` | Tauri 记忆命令桥接 |
|
|
||||||
| `desktop/src-tauri/src/memory_commands.rs` | 13 个 memory CRUD 命令 |
|
|
||||||
|
|
||||||
## 已知问题
|
|
||||||
|
|
||||||
- ✅ **记忆去重失败** — BUG-H2 已修复 (commit a504a40)。`viking_add` 同 URI+content 重复添加
|
|
||||||
- ✅ **记忆非 Agent 隔离** — BUG-M3 已修复 (commit a504a40)。`viking_find` 返回所有 Agent 记忆
|
|
||||||
- ✅ **跨会话注入断裂** — BUG-M5 已修复 (commit a504a40)。新会话报 "no conversation history found"
|
|
||||||
- ✅ **profile_store 未连接** — 已修复 (commit adf0251)。create_middleware_chain() 中未设置 UserProfileStore,导致 profile_signals 被静默丢弃
|
|
||||||
- ✅ **双数据库 UserProfile 不一致** — 已修复 (commit adf0251)。UserProfileStore 写入 data.db 而 agent_get 读取 memories.db
|
|
||||||
- ⚠️ **NoOpEmbeddingClient 是默认值** — 正常设计,用户配置 provider 后替换
|
|
||||||
- ⚠️ **SaaS pgvector embedding 生成未实现** — 索引就绪,生成逻辑 deferred
|
|
||||||
|
|||||||
@@ -1,97 +1,114 @@
|
|||||||
---
|
---
|
||||||
title: 中间件链
|
title: 中间件链
|
||||||
updated: 2026-04-21
|
updated: 2026-04-23
|
||||||
status: active
|
status: active
|
||||||
tags: [module, middleware, runtime]
|
tags: [module, middleware, runtime]
|
||||||
---
|
---
|
||||||
|
|
||||||
# 中间件链
|
# 中间件链
|
||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[chat]] [[butler]] [[memory]]
|
> 从 [[index]] 导航。关联模块: [[chat]] [[butler]] [[memory]] [[hands-skills]]
|
||||||
|
|
||||||
## 设计思想
|
## 1. 设计决策
|
||||||
|
|
||||||
**中间件是请求处理的管道,按优先级顺序执行。**
|
**中间件是请求处理的管道,每条聊天消息都经过完整链路。**
|
||||||
|
|
||||||
- 优先级 0-999,数值越小越先执行(`middleware.rs` 按升序排列)
|
- **WHY 优先级排序 (0-999)**: 数值越小越先执行。宽范围设计允许在任意位置插入新中间件而无需重新编号。
|
||||||
- 每层中间件实现 `AgentMiddleware` trait,4个 hook 点: `before_completion` / `before_tool_call` / `after_tool_call` / `after_completion`
|
- **WHY 注册顺序 != 执行顺序**: `kernel/mod.rs` 中 14 次 `chain.register()` 的代码顺序与运行时顺序无关,chain 按 `priority()` 升序排列后执行。
|
||||||
- 所有消息流(聊天、管家)都经过完整中间件链
|
- **WHY 6 类 14 层**: 进化(70-79) -> 路由(80-99) -> 上下文(100-199) -> 能力(200-399) -> 安全(400-599) -> 遥测(600-799),优先级范围即执行阶段。
|
||||||
- 中间件可返回 `Stop`/`Block`/`AbortLoop` 决策来中断流程
|
- **WHY Stop/Block/AbortLoop**: 细粒度流控 -- Stop 中断 LLM 循环,Block 阻止单次工具调用,AbortLoop 终止整个 Agent 循环。命中后跳过所有后续中间件。
|
||||||
|
- **WHY 分波并行 (parallel_safe)**: `before_completion` 阶段,只修改 `system_prompt` 的中间件可声明 `parallel_safe() == true`,连续的 parallel-safe 中间件通过 `tokio::spawn` 并行执行,各自持有 `MiddlewareContext` clone,完成后合并 prompt 贡献。降低串行延迟 ~1-3s。
|
||||||
|
|
||||||
## 代码逻辑
|
## 2. 关键文件 + 数据流
|
||||||
|
|
||||||
### 14 层 Runtime 中间件(注册顺序见 `kernel/mod.rs:248-361`,执行按 priority 升序)
|
### 核心文件
|
||||||
|
|
||||||
| # | 中间件 | 优先级 | 文件 | 职责 | 注册条件 |
|
| 文件 | 职责 |
|
||||||
|---|--------|--------|------|------|----------|
|
|------|------|
|
||||||
| 1 | EvolutionMiddleware | 78 | `middleware/evolution.rs` | 推送进化候选项到 system prompt | 始终 |
|
| `crates/zclaw-runtime/src/middleware.rs` | `AgentMiddleware` trait + `MiddlewareChain` 执行引擎 |
|
||||||
| 2 | ButlerRouter | 80 | `middleware/butler_router.rs` | 语义技能路由 + system prompt 增强 | 始终 |
|
| `crates/zclaw-runtime/src/middleware/` | 14 个中间件实现 (.rs) |
|
||||||
| 3 | Compaction | 100 | `middleware/compaction.rs` | 超阈值时压缩对话历史 | `compaction_threshold > 0` |
|
| `crates/zclaw-kernel/src/kernel/mod.rs:248-361` | `create_middleware_chain()` 注册入口 (14 次 register) |
|
||||||
| 4 | Memory | 150 | `middleware/memory.rs` | 对话后自动提取记忆 + 进化检查 | 始终 |
|
| `crates/zclaw-saas/src/main.rs` | SaaS HTTP 中间件注册 (10 层) |
|
||||||
| 5 | Title | 180 | `middleware/title.rs` | 自动生成会话标题 | 始终 |
|
|
||||||
| 6 | SkillIndex | 200 | `middleware/skill_index.rs` | 注入技能索引到 system prompt | `!skill_index.is_empty()` |
|
|
||||||
| 7 | DanglingTool | 300 | `middleware/dangling_tool.rs` | 修复缺失的工具调用结果 | 始终 |
|
|
||||||
| 8 | ToolError | 350 | `middleware/tool_error.rs` | 格式化工具错误供 LLM 恢复 | 始终 |
|
|
||||||
| 9 | ToolOutputGuard | 360 | `middleware/tool_output_guard.rs` | 工具输出安全检查 | 始终 |
|
|
||||||
| 10 | Guardrail | 400 | `middleware/guardrail.rs` | shell_exec/file_write/web_fetch 安全规则 | 始终 |
|
|
||||||
| 11 | LoopGuard | 500 | `middleware/loop_guard.rs` | 防止工具调用无限循环 | 始终 |
|
|
||||||
| 12 | SubagentLimit | 550 | `middleware/subagent_limit.rs` | 限制并发子 agent | 始终 |
|
|
||||||
| 13 | TrajectoryRecorder | 650 | `middleware/trajectory_recorder.rs` | 轨迹记录 + 压缩 | 始终 |
|
|
||||||
| 14 | TokenCalibration | 700 | `middleware/token_calibration.rs` | Token 用量校准 | 始终 |
|
|
||||||
|
|
||||||
> **注意**: 注册顺序(代码中的 chain.register 调用顺序)与执行顺序不同。Chain 按 priority 升序排列后执行。
|
### 执行流
|
||||||
|
|
||||||
### 10 层 SaaS HTTP 中间件(`zclaw-saas/src/main.rs`)
|
|
||||||
|
|
||||||
| # | 中间件 | 职责 | 层级 |
|
|
||||||
|---|--------|------|------|
|
|
||||||
| 1 | public_rate_limit_middleware | 公共端点限流 (20次/分钟/IP) | 公共路由 |
|
|
||||||
| 2 | api_version_middleware | API 版本校验 | 公共 + 认证路由 |
|
|
||||||
| 3 | request_id_middleware | 请求 ID 注入 | 公共 + 认证路由 |
|
|
||||||
| 4 | rate_limit_middleware | 认证端点限流 (5次/分钟/IP) | 认证路由 |
|
|
||||||
| 5 | auth_middleware | JWT 认证 + 权限校验 | 认证路由 |
|
|
||||||
| 6 | TimeoutLayer | 请求超时 15s | 认证路由 |
|
|
||||||
| 7 | api_version_middleware (relay) | API 版本校验 | Relay 路由 |
|
|
||||||
| 8 | request_id_middleware (relay) | 请求 ID 注入 | Relay 路由 |
|
|
||||||
| 9 | quota_check_middleware | 配额检查 | Relay 路由 |
|
|
||||||
| 10 | CORS / 其他 layer | 跨域等 | 全局 |
|
|
||||||
|
|
||||||
### 优先级分类(Runtime,来自 `middleware.rs` 头注释)
|
|
||||||
|
|
||||||
| 范围 | 类别 | 包含的中间件 |
|
|
||||||
|------|------|-------------|
|
|
||||||
| 70-79 | 进化 | EvolutionMiddleware |
|
|
||||||
| 80-99 | 路由 | ButlerRouter |
|
|
||||||
| 100-199 | 上下文塑造 | Compaction, Memory |
|
|
||||||
| 200-399 | 能力 | SkillIndex, DanglingTool, ToolError, ToolOutputGuard |
|
|
||||||
| 400-599 | 安全 | Guardrail, LoopGuard, SubagentLimit |
|
|
||||||
| 600-799 | 遥测 | TrajectoryRecorder, TokenCalibration, Title |
|
|
||||||
|
|
||||||
### 中间件执行流
|
|
||||||
|
|
||||||
```
|
```
|
||||||
用户消息 → AgentLoop
|
用户消息 -> AgentLoop
|
||||||
→ chain.run_before_completion(ctx)
|
-> chain.run_before_completion(ctx)
|
||||||
→ [按优先级升序] 每层 middleware.before_completion()
|
-> [分波并行] 检测连续 parallel_safe 中间件
|
||||||
→ Continue: 继续下一层
|
-> Wave 并行 (2+ safe): tokio::spawn 各自 ctx.clone() → 合并 prompt
|
||||||
→ Stop(reason): 中断循环,返回 reason
|
-> 串行 (unsafe / 单个 safe): 逐个执行
|
||||||
→ LLM 调用
|
-> Continue: 下一层 | Stop(reason): 中断循环
|
||||||
→ (工具调用时) chain.run_before_tool_call()
|
-> LLM 调用
|
||||||
→ Allow: 允许执行
|
-> (工具调用时) chain.run_before_tool_call()
|
||||||
→ Block(msg): 阻止,返回错误给 LLM
|
-> Allow | Block(msg) | ReplaceInput | AbortLoop
|
||||||
→ ReplaceInput: 替换参数后允许
|
-> 工具执行
|
||||||
→ AbortLoop: 立即终止整个循环
|
-> chain.run_after_tool_call()
|
||||||
→ chain.run_after_tool_call()
|
-> chain.run_after_completion()
|
||||||
→ chain.run_after_completion()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 集成契约
|
||||||
|
|
||||||
|
| 方向 | 模块 | 接口 | 触发时机 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| Called by <- | kernel | `kernel/mod.rs:create_middleware_chain()` | Kernel 启动 |
|
||||||
|
| Calls -> | runtime | `MiddlewareChain::run_before_completion()` | 每条聊天请求 |
|
||||||
|
| Called by <- | saas | HTTP relay handler | SaaS relay 路由 |
|
||||||
|
| Provides -> | all | `AgentMiddleware` trait | 14 个实现 |
|
||||||
|
|
||||||
|
## 3. 代码逻辑
|
||||||
|
|
||||||
|
### 14 层 Runtime 中间件
|
||||||
|
|
||||||
|
| 优先级 | 中间件 | 文件 | 职责 | parallel_safe | 注册条件 |
|
||||||
|
|--------|--------|------|------|---------------|----------|
|
||||||
|
| @78 | EvolutionMiddleware | `evolution.rs` | 推送进化候选项到 system prompt | ✅ | 始终 |
|
||||||
|
| @80 | ButlerRouter | `butler_router.rs` | 语义技能路由 + system prompt 增强 + XML fencing | ✅ | 始终 |
|
||||||
|
| @100 | Compaction | `compaction.rs` | 超阈值时压缩对话历史 | ❌ | `compaction_threshold > 0` |
|
||||||
|
| @150 | Memory | `memory.rs` | 对话后自动提取记忆 + 注入检索结果 | ✅ | 始终 |
|
||||||
|
| @180 | Title | `title.rs` | 自动生成会话标题 | ✅ | 始终 |
|
||||||
|
| @200 | SkillIndex | `skill_index.rs` | 注入技能索引到 system prompt | ✅ | `!skill_index.is_empty()` |
|
||||||
|
| @300 | DanglingTool | `dangling_tool.rs` | 修复缺失的工具调用结果 | ❌ | 始终 |
|
||||||
|
| @350 | ToolError | `tool_error.rs` | 格式化工具错误供 LLM 恢复 | ❌ | 始终 |
|
||||||
|
| @360 | ToolOutputGuard | `tool_output_guard.rs` | 工具输出安全检查 | ❌ | 始终 |
|
||||||
|
| @400 | Guardrail | `guardrail.rs` | shell_exec/file_write/web_fetch 安全规则 | ❌ | 始终 |
|
||||||
|
| @500 | LoopGuard | `loop_guard.rs` | 防止工具调用无限循环 | ❌ | 始终 |
|
||||||
|
| @550 | SubagentLimit | `subagent_limit.rs` | 限制并发子 agent | ❌ | 始终 |
|
||||||
|
| @650 | TrajectoryRecorder | `trajectory_recorder.rs` | 轨迹记录 + 压缩 | ❌ | 始终 |
|
||||||
|
| @700 | TokenCalibration | `token_calibration.rs` | Token 用量校准 | ❌ | 始终 |
|
||||||
|
|
||||||
|
> 注册顺序 (代码) 与执行顺序 (priority) 不同。Chain 按 priority 升序排列后执行。
|
||||||
|
|
||||||
|
### 10 层 SaaS HTTP 中间件
|
||||||
|
|
||||||
|
| 层级 | 中间件 | 职责 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 公共路由 | `public_rate_limit_middleware` | 20次/分钟/IP |
|
||||||
|
| 公共+认证 | `api_version_middleware` | API 版本校验 |
|
||||||
|
| 公共+认证 | `request_id_middleware` | 请求 ID 注入 |
|
||||||
|
| 认证路由 | `rate_limit_middleware` | 5次/分钟/IP |
|
||||||
|
| 认证路由 | `auth_middleware` | JWT 认证 + 权限 |
|
||||||
|
| 认证路由 | `TimeoutLayer` | 请求超时 15s |
|
||||||
|
| Relay 路由 | `api_version_middleware` | 版本校验 |
|
||||||
|
| Relay 路由 | `request_id_middleware` | 请求 ID |
|
||||||
|
| Relay 路由 | `quota_check_middleware` | 配额检查 |
|
||||||
|
| 全局 | CORS / 其他 layer | 跨域等 |
|
||||||
|
|
||||||
|
### 不变量
|
||||||
|
|
||||||
|
- Priority 升序: 0-999, 数值越小越先执行
|
||||||
|
- 注册顺序 != 执行顺序; chain 按 priority 运行时排序
|
||||||
|
- Stop/Block/AbortLoop 立即中断, 不执行后续中间件
|
||||||
|
- parallel_safe 中间件只修改 system_prompt,不修改 messages,不返回 Stop
|
||||||
|
- 分波合并: 并行 wave 中每个中间件 clone context,完成后按 base_prompt_len 截取增量合并
|
||||||
|
|
||||||
### 核心接口
|
### 核心接口
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
// crates/zclaw-runtime/src/middleware.rs
|
|
||||||
trait AgentMiddleware: Send + Sync {
|
trait AgentMiddleware: Send + Sync {
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
fn priority(&self) -> i32 { 500 }
|
fn priority(&self) -> i32 { 500 }
|
||||||
|
fn parallel_safe(&self) -> bool { false }
|
||||||
async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision>;
|
async fn before_completion(&self, ctx: &mut MiddlewareContext) -> Result<MiddlewareDecision>;
|
||||||
async fn before_tool_call(&self, ctx: &MiddlewareContext, tool_name: &str, tool_input: &Value) -> Result<ToolCallDecision>;
|
async fn before_tool_call(&self, ctx: &MiddlewareContext, tool_name: &str, tool_input: &Value) -> Result<ToolCallDecision>;
|
||||||
async fn after_tool_call(&self, ctx: &mut MiddlewareContext, tool_name: &str, result: &Value) -> Result<()>;
|
async fn after_tool_call(&self, ctx: &mut MiddlewareContext, tool_name: &str, result: &Value) -> Result<()>;
|
||||||
@@ -99,58 +116,27 @@ trait AgentMiddleware: Send + Sync {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 注册位置
|
## 4. 活跃问题 + 陷阱
|
||||||
|
|
||||||
`crates/zclaw-kernel/src/kernel/mod.rs:248-361` — `create_middleware_chain()` 方法,14 次 `chain.register()`(含 2 个条件注册: SkillIndex, Compaction)。注册顺序与执行顺序不同,chain 按 priority 升序排列后执行。
|
### 活跃问题
|
||||||
|
|
||||||
## 功能清单
|
- **11/14 中间件无独立测试** (P2): 仅 `butler_router`(12) / `evolution`(4) / `trajectory_recorder`(4) 有测试,共 20 个。其余 11 层依赖集成测试覆盖。
|
||||||
|
- **SkillIndex 条件注册** (长期观察): 无技能时不注册,非 bug 但需关注空技能场景下的行为一致性。
|
||||||
|
|
||||||
| 优先级 | 中间件 | 功能 | 状态 |
|
### 历史陷阱
|
||||||
|--------|--------|------|------|
|
|
||||||
| @78 | EvolutionMiddleware | 进化引擎注入 | ✅ |
|
|
||||||
| @80 | ButlerRouter | 管家语义路由 + XML fencing | ✅ |
|
|
||||||
| @100 | Compaction | 上下文压缩 (条件注册) | ✅ |
|
|
||||||
| @150 | Memory | 记忆自动提取 + 注入 | ✅ |
|
|
||||||
| @180 | Title | 对话标题生成 | ✅ |
|
|
||||||
| @200 | SkillIndex | 技能索引注入 (条件注册) | ✅ |
|
|
||||||
| @300 | DanglingTool | 悬空工具清理 | ✅ |
|
|
||||||
| @350 | ToolError | 工具错误处理 | ✅ |
|
|
||||||
| @360 | ToolOutputGuard | 工具输出守卫 | ✅ |
|
|
||||||
| @400 | Guardrail | 安全护栏 | ✅ |
|
|
||||||
| @500 | LoopGuard | 循环检测 (防无限) | ✅ |
|
|
||||||
| @550 | SubagentLimit | 子代理数量限制 | ✅ |
|
|
||||||
| @650 | TrajectoryRecorder | 轨迹记录+压缩 | ✅ |
|
|
||||||
| @700 | TokenCalibration | Token 校准 | ✅ |
|
|
||||||
|
|
||||||
## 测试链路
|
| 问题 | 根因 | 修复 |
|
||||||
|
|------|------|------|
|
||||||
|
| TrajectoryRecorder 未注册 | V13-GAP-01: 遗漏 `chain.register()` 调用 | 已在 @650 注册 |
|
||||||
|
| Admin 端点 404 而非 403 | admin_guard_middleware 返回码错误 | 已修复为 403 |
|
||||||
|
| DataMasking 中间件 | 增加延迟但无实际安全收益 | 04-22 移除 |
|
||||||
|
|
||||||
| 功能 | 测试文件 | 测试数 | 覆盖状态 |
|
## 5. 变更日志
|
||||||
|------|---------|--------|---------|
|
|
||||||
| 管家路由 | middleware/butler_router.rs | 12 | ✅ |
|
|
||||||
| 进化中间件 | middleware/evolution.rs | 4 | ✅ |
|
|
||||||
| 轨迹记录 | middleware/trajectory_recorder.rs | 4 | ✅ |
|
|
||||||
| 其余 11 层 | — | 0 | ⚠️ 无独立测试 |
|
|
||||||
| **合计** | 3/14 文件有测试 | **20** | |
|
|
||||||
|
|
||||||
## 关联模块
|
| 日期 | 变更 | 影响 |
|
||||||
|
|------|------|------|
|
||||||
- [[butler]] — ButlerRouter 是管家模式的核心
|
| 04-23 | 分波并行执行: parallel_safe() + wave detection + tokio::spawn | before_completion 阶段 5 层 safe 中间件可并行,延迟降低 ~1-3s |
|
||||||
- [[chat]] — 每条消息经过完整中间件链
|
| 04-22 | DataMasking 中间件移除 | 14->14 层 (替换为无), 减少 1 层无收益处理 |
|
||||||
- [[memory]] — Memory 中间件从对话提取记忆
|
| 04-22 | 跨会话记忆修复 | Memory 中间件去重+跨会话注入修复 |
|
||||||
- [[hands-skills]] — SkillIndex 中间件注入技能索引
|
| 04-22 | Wiki 一致性校准 | 数字与代码验证对齐 |
|
||||||
|
| 04-21 | Embedding 接通 | SkillIndex 路由 TF-IDF->Embedding+LLM fallback |
|
||||||
## 关键文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `crates/zclaw-runtime/src/middleware.rs` | AgentMiddleware trait + MiddlewareChain |
|
|
||||||
| `crates/zclaw-runtime/src/middleware/` | 14 个中间件实现 (14个 .rs 文件) |
|
|
||||||
| `crates/zclaw-kernel/src/kernel/mod.rs:248-361` | 注册入口 |
|
|
||||||
| `crates/zclaw-saas/src/main.rs` | SaaS HTTP 中间件注册 (10 层) |
|
|
||||||
|
|
||||||
## 已知问题
|
|
||||||
|
|
||||||
- ✅ **TrajectoryRecorder 未注册** — V13-GAP-01 已修复 (在 @650 注册)
|
|
||||||
- ✅ **Admin 端点 404 而非 403** — admin_guard_middleware 已修复
|
|
||||||
- ⚠️ **SkillIndex 条件注册** — 无技能时不注册,长期观察
|
|
||||||
- ⚠️ **11/14 中间件无独立测试** — 仅 butler_router/evolution/trajectory_recorder 有测试
|
|
||||||
|
|||||||
218
wiki/pipeline.md
218
wiki/pipeline.md
@@ -1,86 +1,86 @@
|
|||||||
---
|
---
|
||||||
title: Pipeline DSL
|
title: Pipeline DSL
|
||||||
updated: 2026-04-21
|
updated: 2026-04-22
|
||||||
status: active
|
status: active
|
||||||
tags: [module, pipeline, dsl]
|
tags: [module, pipeline, dsl]
|
||||||
---
|
---
|
||||||
|
|
||||||
# Pipeline DSL
|
# Pipeline DSL
|
||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[hands-skills]]
|
> 从 [[index]] 导航。关联模块: [[hands-skills]] [[chat]]
|
||||||
|
|
||||||
## 设计思想
|
## 1. 设计决策
|
||||||
|
|
||||||
**Pipeline = 可编排的工作流,按 DAG 依赖顺序执行步骤。**
|
**WHY DAG 执行器**: 工作流步骤之间存在数据依赖,DAG (有向无环图) 通过拓扑排序自动推导执行顺序,支持无依赖节点的并行执行,比线性管道更灵活、更高效。
|
||||||
|
|
||||||
- YAML 定义 Pipeline 结构(步骤、依赖、输入/输出)
|
**WHY YAML 模板**: 声明式定义 + 可版本控制。非技术用户可直接编辑 YAML 文件调整步骤和参数,无需重新编译。模板可随项目仓库同步、diff、review。
|
||||||
- DAG 执行器按依赖拓扑排序执行
|
|
||||||
- 18 个 YAML 模板覆盖 8 大行业目录
|
|
||||||
- 前端已接通 8 个 Tauri invoke 调用
|
|
||||||
|
|
||||||
## 代码逻辑
|
**WHY 18 模板覆盖 8 行业**: 管家模式面向行业垂直场景。每个行业目录包含该领域典型工作流(如汕头设计的供应链采集、医疗的政策合规),用户可直接使用或定制。
|
||||||
|
|
||||||
### 架构
|
**WHY v2 解析器**: v1 仅支持线性步骤序列,v2 引入 DAG 依赖声明 (`depends_on` 字段),支持复杂分支和并行。v1 解析器保留用于向后兼容。
|
||||||
|
|
||||||
|
**WHY ActionRegistry**: Pipeline 步骤与具体执行逻辑解耦。`action_type` 字符串映射到注册的处理函数,新增步骤类型只需注册新 action,不改动执行器核心。
|
||||||
|
|
||||||
|
## 2. 关键文件 + 数据流
|
||||||
|
|
||||||
|
### 核心文件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `crates/zclaw-pipeline/src/executor.rs` | DAG 执行器 — 拓扑排序 + 并行执行 |
|
||||||
|
| `crates/zclaw-pipeline/src/parser_v2.rs` | YAML v2 解析器 (11 tests) |
|
||||||
|
| `crates/zclaw-pipeline/src/parser.rs` | YAML v1 解析器 (兼容) |
|
||||||
|
| `crates/zclaw-pipeline/src/state.rs` | 运行状态管理 |
|
||||||
|
| `crates/zclaw-pipeline/src/intent.rs` | Pipeline 意图匹配 |
|
||||||
|
| `crates/zclaw-pipeline/src/trigger.rs` | 定时/事件触发器 |
|
||||||
|
| `desktop/src/lib/pipeline-client.ts` | 前端 Pipeline 客户端 |
|
||||||
|
| `desktop/src/store/workflowBuilderStore.ts` | 工作流编辑器状态 |
|
||||||
|
| `desktop/src/components/pipeline/` | Pipeline UI 组件 |
|
||||||
|
| `desktop/src-tauri/src/pipeline_commands/` | 12 个 Tauri 命令 (4 文件) |
|
||||||
|
| `pipelines/` | 18 个 YAML 模板 (8 目录) |
|
||||||
|
|
||||||
|
### 架构流程
|
||||||
|
|
||||||
```
|
```
|
||||||
YAML Pipeline 定义
|
用户选择模板 (Workflow 面板)
|
||||||
→ PipelineExecutor (crates/zclaw-pipeline/src/executor.rs)
|
→ pipeline-client.ts: invoke('pipeline_load_template')
|
||||||
→ 构建 DAG (按依赖排序)
|
→ parser_v2: 解析 YAML → PipelineDefinition (steps + depends_on)
|
||||||
→ 逐步执行:
|
→ executor: 构建 DAG → 拓扑排序 → 检测循环依赖
|
||||||
→ ActionRegistry.resolve(action_type)
|
→ 并行执行无依赖节点:
|
||||||
→ 执行 action → PipelineRun.step_results
|
→ ActionRegistry.resolve(action_type) → 具体 Handler
|
||||||
→ 全部完成 → PipelineRun.status = Completed
|
→ Handler 执行 → step_result (output + status)
|
||||||
|
→ 有依赖节点等待前驱完成 → 执行
|
||||||
|
→ 全部完成 → PipelineRun.status = Completed
|
||||||
|
→ 前端轮询 progress 或 Tauri Event 推送状态
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 集成契约
|
||||||
|
|
||||||
|
| 方向 | 接口 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Called by ← UI | `pipelineStore.ts` / `workflowBuilderStore.ts` | 工作流面板交互: 列出模板、创建/运行/取消 Pipeline |
|
||||||
|
| Calls → runtime | Tauri invoke (discovery 12 命令) | pipeline_commands/ 转发到 DAG executor |
|
||||||
|
| Calls → skills/hands | `ActionRegistry.resolve(action_type)` | Pipeline 步骤可能调用 Skill 或 Hand 执行具体动作 |
|
||||||
|
| Called by ← chat | `intent_router.rs` | 聊天消息意图匹配到 Pipeline 模板 |
|
||||||
|
| Calls → memory | 记忆检索 (via runtime) | Pipeline 执行时可检索历史记忆增强步骤上下文 |
|
||||||
|
|
||||||
|
## 3. 代码逻辑
|
||||||
|
|
||||||
### 运行状态
|
### 运行状态
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
enum RunStatus { Pending, Running, Completed, Failed, Cancelled }
|
enum RunStatus { Pending, Running, Completed, Failed, Cancelled }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 模板分布 (18 个 YAML)
|
### DAG 执行流程详解
|
||||||
|
|
||||||
```
|
1. **解析阶段**: `parser_v2.rs` 将 YAML 反序列化为 `PipelineDefinition`,包含 `steps: Vec<StepDef>` 和每个 step 的 `depends_on: Vec<String>`
|
||||||
pipelines/
|
2. **构建阶段**: `executor.rs` 将 steps 映射为 DAG 节点,建立邻接表 (step_id → [依赖的 step_ids])
|
||||||
├── _templates/ (2 模板)
|
3. **排序阶段**: Kahn 算法拓扑排序,检测循环依赖 — 若排序后节点数 < 总节点数,说明存在环,返回错误
|
||||||
│ ├── article-summary.yaml
|
4. **执行阶段**: 按拓扑序逐层执行,同层无依赖节点并行。每步通过 `ActionRegistry` 解析 `action_type` 到具体 Handler
|
||||||
│ └── competitor-analysis.yaml
|
5. **完成阶段**: 全部步骤成功 → `Completed`;任一步骤失败 → 整体 `Failed`;用户可随时 `Cancel`
|
||||||
├── design-shantou/ (4 模板) — 汕头玩具/服装行业
|
|
||||||
│ ├── client-communication.yaml
|
|
||||||
│ ├── competitor-research.yaml
|
|
||||||
│ ├── supply-chain-collect.yaml
|
|
||||||
│ └── trend-to-design.yaml
|
|
||||||
├── education/ (4 模板)
|
|
||||||
│ ├── classroom.yaml
|
|
||||||
│ ├── lesson-plan.yaml
|
|
||||||
│ ├── research-to-quiz.yaml
|
|
||||||
│ └── student-analysis.yaml
|
|
||||||
├── healthcare/ (3 模板)
|
|
||||||
│ ├── data-report.yaml
|
|
||||||
│ ├── meeting-minutes.yaml
|
|
||||||
│ └── policy-compliance.yaml
|
|
||||||
├── legal/ (1 模板)
|
|
||||||
│ └── contract-review.yaml
|
|
||||||
├── marketing/ (1 模板)
|
|
||||||
│ └── campaign.yaml
|
|
||||||
├── productivity/ (1 模板)
|
|
||||||
│ └── meeting-summary.yaml
|
|
||||||
└── research/ (1 模板)
|
|
||||||
└── literature-review.yaml
|
|
||||||
|
|
||||||
注: 共 18 个 YAML, 总数 = 2+4+4+3+1+1+1+1+1 = 18
|
### Tauri 命令分布
|
||||||
```
|
|
||||||
|
|
||||||
### 前端集成
|
|
||||||
|
|
||||||
| 组件 | 文件 |
|
|
||||||
|------|------|
|
|
||||||
| PipelineClient | `desktop/src/lib/pipeline-client.ts` |
|
|
||||||
| WorkflowBuilderStore | `desktop/src/store/workflowBuilderStore.ts` |
|
|
||||||
| Pipeline UI | `desktop/src/components/pipeline/` |
|
|
||||||
| Tauri 命令 | `desktop/src-tauri/src/pipeline_commands/` |
|
|
||||||
|
|
||||||
Pipeline Tauri 命令 (12 个):
|
|
||||||
|
|
||||||
| 文件 | 命令数 | 命令 |
|
| 文件 | 命令数 | 命令 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
@@ -89,68 +89,66 @@ Pipeline Tauri 命令 (12 个):
|
|||||||
| intent_router.rs | 1 | route_intent |
|
| intent_router.rs | 1 | route_intent |
|
||||||
| presentation.rs | 2 | analyze_presentation/pipeline_templates |
|
| presentation.rs | 2 | analyze_presentation/pipeline_templates |
|
||||||
|
|
||||||
前端 invoke 匹配: 8 个调用对应 8 个 discovery 命令,完整可用。
|
前端 invoke 匹配: 8 个调用对应 8 个 discovery 命令。另有 2 个 @reserved (`orchestration_execute`/`orchestration_validate`,无前端 UI)。
|
||||||
|
|
||||||
### 测试
|
### 模板分布 (18 YAML, 8 目录)
|
||||||
|
|
||||||
`parser_v2.rs`: 11 tests — YAML 解析和 DAG 构建验证。
|
```
|
||||||
|
pipelines/
|
||||||
|
├── _templates/ (2) — article-summary, competitor-analysis
|
||||||
|
├── design-shantou/ (4) — 汕头玩具/服装: 通信/竞品/供应链/趋势
|
||||||
|
├── education/ (4) — 课堂/教案/研究→测验/学生分析
|
||||||
|
├── healthcare/ (3) — 数据报告/会议纪要/政策合规
|
||||||
|
├── legal/ (1) — 合同审查
|
||||||
|
├── marketing/ (1) — 营销活动
|
||||||
|
├── productivity/ (1) — 会议摘要
|
||||||
|
└── research/ (1) — 文献综述
|
||||||
|
```
|
||||||
|
|
||||||
## 功能清单
|
### 不变量
|
||||||
|
|
||||||
| 功能 | 描述 | 入口文件 | 状态 |
|
> **DAG 节点必须有明确的依赖关系,循环依赖会在 topological sort 阶段被检测并报错。**
|
||||||
|------|------|----------|------|
|
> **模板 YAML 结构不重复 — 每个行业目录的模板聚焦该领域特有场景。**
|
||||||
| YAML 解析 | v2 解析器,支持 DAG 依赖 | parser_v2.rs | ✅ |
|
|
||||||
| DAG 执行 | 拓扑排序 + 并行执行 | executor.rs | ✅ |
|
|
||||||
| 模板发现 | 18 模板 + 8 行业目录 | pipeline_commands/ | ✅ |
|
|
||||||
| 模型意图 | Pipeline 意图匹配 | intent.rs | ✅ |
|
|
||||||
| 状态管理 | Pipeline 运行状态 | state.rs | ✅ |
|
|
||||||
| 触发器 | 定时/事件触发 | trigger.rs | ✅ |
|
|
||||||
| 演示分析 | Pipeline 结果分析 | presentation/ | ✅ |
|
|
||||||
|
|
||||||
## API 接口
|
### 测试链路
|
||||||
|
|
||||||
### Tauri 命令
|
| 功能 | 测试文件 | 测试数 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| YAML 解析 v2 | parser_v2.rs | 11 |
|
||||||
|
| DAG 执行 | executor.rs | 2 |
|
||||||
|
| 意图匹配 | intent.rs | 5 |
|
||||||
|
| 状态管理 | state.rs | 6 |
|
||||||
|
| 触发器 | trigger.rs | 5 |
|
||||||
|
| 类型 | types.rs + types_v2.rs | 4 |
|
||||||
|
| 解析 v1 | parser.rs | 5 |
|
||||||
|
| 引擎上下文/阶段 | engine/ | 8 |
|
||||||
|
| 演示 | presentation/ | 13 |
|
||||||
|
| **合计** | 13 文件 | **59** |
|
||||||
|
|
||||||
| 命令 | 状态 | 说明 |
|
## 4. 活跃问题 + 注意事项
|
||||||
|------|------|------|
|
|
||||||
| `orchestration_execute` | @reserved | 执行工作流 (无前端 UI) |
|
|
||||||
| `orchestration_validate` | @reserved | 验证工作流 (无前端 UI) |
|
|
||||||
|
|
||||||
> 另有 12 个 pipeline discovery 命令在 `desktop/src-tauri/src/pipeline_commands/`,8 个已接通前端。
|
| 优先级 | 问题 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| P2 | Pipeline+Skill E2E 通过率 37.5% | Tauri IPC 可用但参数格式问题,非核心链路 |
|
||||||
|
| P3 | Deepseek 中继任务卡 processing | Provider Key 禁用后已有任务不自动清理 |
|
||||||
|
| — | pipeline_create 反序列化 | BUG-L2 已修复 (04-17 回归),需持续关注 |
|
||||||
|
|
||||||
## 测试链路
|
**注意事项**: Pipeline 步骤中的 `action_type` 必须在 `ActionRegistry` 中注册,未注册的 action 会导致步骤 Failed。模板 YAML 的 `depends_on` 字段引用的 step_id 必须存在,否则解析阶段报错。前端 `workflowBuilderStore.ts` 负责编辑器状态,但 CRUD 操作通过 `pipeline-client.ts` 调用 Tauri 命令,不直接操作文件系统。
|
||||||
|
|
||||||
| 功能 | 测试文件 | 测试数 | 覆盖状态 |
|
## 5. 变更日志
|
||||||
|------|---------|--------|---------|
|
|
||||||
| YAML 解析 v2 | parser_v2.rs | 11 | ✅ |
|
> 最近 5 条与 Pipeline 相关的变更。完整日志见 [[log]]。
|
||||||
| DAG 执行 | executor.rs | 2 | ✅ |
|
|
||||||
| 意图匹配 | intent.rs | 5 | ✅ |
|
| 日期 | 变更 |
|
||||||
| 状态管理 | state.rs | 6 | ✅ |
|
|------|------|
|
||||||
| 触发器 | trigger.rs | 5 | ✅ |
|
| 2026-04-21 | Phase 0+1 修复: Skill 工具调用桥接 complete_with_tools() + Hand 字段映射 runId |
|
||||||
| 类型 | types.rs + types_v2.rs | 4 | ✅ |
|
| 2026-04-17 | E2E 回归: pipeline_create 反序列化 BUG-L2 修复 |
|
||||||
| 解析 v1 | parser.rs | 5 | ✅ |
|
| 2026-04-16 | 3 项 P0 修复 + 5 项 E2E Bug 修复,Pipeline Tauri 命令数校正 |
|
||||||
| 引擎上下文 | engine/context.rs | 7 | ✅ |
|
| 2026-04-09 | Pipeline+Hands 双交付,DAG 执行器稳定化 |
|
||||||
| 引擎阶段 | engine/stage.rs | 1 | ✅ |
|
| 2026-04-01 | 17 YAML 模板 + DAG 执行器初始版本 |
|
||||||
| 演示 | presentation/ (3文件) | 13 | ✅ |
|
|
||||||
| **合计** | 13 文件 | **59** | |
|
|
||||||
|
|
||||||
## 关联模块
|
## 关联模块
|
||||||
|
|
||||||
- [[hands-skills]] — Pipeline 步骤可能调用 Hand/Skill
|
- [[hands-skills]] — Pipeline 步骤可能调用 Hand/Skill 执行具体动作
|
||||||
- [[chat]] — Pipeline 可通过聊天触发
|
- [[chat]] — Pipeline 可通过聊天意图匹配触发 (`intent_router.rs`)
|
||||||
|
- [[memory]] — Pipeline 执行时可检索记忆增强上下文
|
||||||
## 关键文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|
||||||
| `crates/zclaw-pipeline/src/executor.rs` | DAG 执行器 |
|
|
||||||
| `crates/zclaw-pipeline/src/parser_v2.rs` | YAML 解析 (11 tests) |
|
|
||||||
| `pipelines/` | 18 个 YAML 模板 (8 目录) |
|
|
||||||
| `desktop/src/lib/pipeline-client.ts` | 前端 Pipeline 客户端 |
|
|
||||||
| `desktop/src-tauri/src/pipeline_commands/` | 12 个 Tauri 命令 (4 文件) |
|
|
||||||
|
|
||||||
## 已知问题
|
|
||||||
|
|
||||||
- ✅ **pipeline_create 反序列化失败** — BUG-L2 已修复 (04-17 回归)
|
|
||||||
- ⚠️ **Pipeline+Skill E2E 通过率 37.5%** — Tauri IPC 可用但参数格式问题
|
|
||||||
- ⚠️ **Deepseek 中继任务卡 processing** — P3,Provider Key 禁用后已有任务不自动清理
|
|
||||||
|
|||||||
375
wiki/routing.md
375
wiki/routing.md
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 客户端路由
|
title: 客户端路由
|
||||||
updated: 2026-04-21
|
updated: 2026-04-22
|
||||||
status: active
|
status: active
|
||||||
tags: [module, routing, connection]
|
tags: [module, routing, connection]
|
||||||
---
|
---
|
||||||
@@ -9,318 +9,123 @@ tags: [module, routing, connection]
|
|||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[chat]] [[saas]]
|
> 从 [[index]] 导航。关联模块: [[chat]] [[saas]]
|
||||||
|
|
||||||
## 设计思想
|
## 1. 设计决策
|
||||||
|
|
||||||
**核心决策: Tauri 桌面端通过 SaaS Token Pool 中转访问 LLM,不直连。**
|
**核心: Tauri 桌面端通过 SaaS Token Pool 中转访问 LLM,不直连。**
|
||||||
|
|
||||||
为什么?
|
| 决策 | 原因 |
|
||||||
1. **集中密钥管理** — 用户不需要自己的 API Key,SaaS 维护共享 Key 池
|
|------|------|
|
||||||
2. **用量追踪 + 计费** — 每次调用经过 SaaS,`record_usage` worker 记录 token 消耗
|
| 5 分支路由 | 覆盖全部部署形态: Admin本地 / Tauri+SaaS / Browser+SaaS / Tauri本地 / 外部Gateway |
|
||||||
3. **模型白名单** — Admin 配置哪些模型可用,`listModels()` 返回白名单
|
| SaaS Relay 中转 | 集中密钥管理 — 用户无需自备 API Key;用量追踪计费 — 每次调用经 SaaS;模型白名单 — Admin 控制可用模型 |
|
||||||
4. **降级保障** — SaaS 挂了自动切本地 Kernel,桌面端不变砖
|
| 自动降级到本地 Kernel | SaaS 不可达时桌面端不变砖,无感切换,不需要用户干预 |
|
||||||
|
| Kernel 不直连 LLM | 直连是降级后备。主路径经 SaaS Token Pool 做 RPM/TPM 轮换 + 故障转移 |
|
||||||
|
| getClient() 全局单例 | 所有 Store 通过 `initializeStores()` 获取共享 client,避免重复连接 |
|
||||||
|
|
||||||
## 功能清单
|
## 2. 关键文件 + 数据流
|
||||||
|
|
||||||
| 功能 | 描述 | 入口文件 | 状态 |
|
### 核心文件
|
||||||
|------|------|----------|------|
|
|
||||||
| 连接管理 | 5 分支路由决策 + 自动降级 | connectionStore.ts | ✅ |
|
|
||||||
| SaaS Relay 中转 | Tauri 通过 SaaS Token Pool 中转 LLM | connectionStore.ts | ✅ |
|
|
||||||
| 浏览器模式 | SSE 连接 SaaS relay | saas-relay-client.ts | ✅ |
|
|
||||||
| 本地 Kernel | Tauri 内置 Kernel 直连 LLM | kernel-client.ts | ✅ |
|
|
||||||
| 外部 Gateway | WebSocket 独立进程 | gateway-client.ts | ✅ |
|
|
||||||
| Gateway 进程管理 | 启动/停止/重启/状态/诊断 | gateway/commands.rs | ✅ |
|
|
||||||
| 健康检查 | 端口检测 + 完整诊断 | health_check.rs | ✅ |
|
|
||||||
| 设备配对 | 设备审批 + 公钥交换 | gateway/commands.rs | ✅ |
|
|
||||||
| 模型路由 | 白名单验证 + fallback + 别名解析 | connectionStore.ts | ✅ |
|
|
||||||
|
|
||||||
## 代码逻辑
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `desktop/src/store/connectionStore.ts` | 路由决策核心: 5 分支 + 降级 + 模型路由 |
|
||||||
|
| `desktop/src/lib/kernel-chat.ts` | KernelClient ChatStream (Tauri Event) |
|
||||||
|
| `desktop/src/lib/kernel-client.ts` | Kernel 客户端配置 (setConfig/boot) |
|
||||||
|
| `desktop/src/lib/saas-relay-client.ts` | SaaS Relay ChatStream (SSE) |
|
||||||
|
| `desktop/src/lib/gateway-client.ts` | External Gateway ChatStream (WebSocket) |
|
||||||
|
| `desktop/src/store/index.ts` | Store 协调器 + client 注入 |
|
||||||
|
|
||||||
### 5 分支 + 降级决策树
|
### 5 分支决策树
|
||||||
|
|
||||||
入口: `connectionStore.ts` → `connect(url?, token?)`
|
|
||||||
|
|
||||||
```
|
```
|
||||||
connect()
|
connect()
|
||||||
│
|
├─ [1] Admin强制本地: adminRouting=local && isTauri → Kernel 直连
|
||||||
├── [1] Admin 强制本地: adminRouting === 'local' && isTauri()
|
├─ [2] SaaS+Tauri: savedMode=saas && isTauri → KernelClient + baseUrl=SaaS relay
|
||||||
│ → 直接走 Kernel 模式,跳过 SaaS
|
│ └─ SaaS不可达 → 降级 [4]
|
||||||
│
|
├─ [3] SaaS+Browser: savedMode=saas && !isTauri → SaaSRelayClient (SSE)
|
||||||
├── [2] SaaS Relay (Tauri 路径): savedMode === 'saas' && isTauri()
|
│ └─ SaaS不可达 → 降级 [4]
|
||||||
│ → KernelClient + baseUrl = saasUrl/api/v1/relay
|
├─ [4] 本地Kernel: isTauriRuntime && 非SaaS → KernelClient + 用户自配 Key
|
||||||
│ → apiKey = SaaS JWT (不是 LLM Key!)
|
└─ [5] 外部Gateway: !isTauri → GatewayClient (WebSocket)
|
||||||
│ → SaaS 不可达 → 降级到本地 Kernel
|
|
||||||
│
|
|
||||||
├── [3] SaaS Relay (Browser 路径): savedMode === 'saas' && !isTauri()
|
|
||||||
│ → SaaSRelayGatewayClient (SSE)
|
|
||||||
│ → SaaS 不可达 → 降级到本地 Kernel
|
|
||||||
│
|
|
||||||
├── [4] 本地 Kernel: isTauriRuntime() && 非 SaaS 模式
|
|
||||||
│ → KernelClient + 用户自定义模型配置
|
|
||||||
│ → 用户需要自己的 API Key
|
|
||||||
│
|
|
||||||
└── [5] External Gateway (fallback): !isTauri()
|
|
||||||
→ GatewayClient via WebSocket/REST
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### SaaS Relay 主路径 (Tauri 桌面端)
|
### 集成契约
|
||||||
|
|
||||||
关键代码: `connectionStore.ts:482-535`
|
| 方向 | 模块 | 接口 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| Calls -> | saas | relay URL + JWT | Chat relay, model list, 用量上报 |
|
||||||
|
| Calls -> | kernel | Tauri invoke | Kernel boot, chat, config |
|
||||||
|
| Called by <- | all stores | `getClient()` | 每个 API 调用都经过路由决策 |
|
||||||
|
| Provides -> | UI | Connection status, model list | 所有聊天依赖组件消费 |
|
||||||
|
|
||||||
|
## 3. 代码逻辑
|
||||||
|
|
||||||
|
### 模型路由链 (SaaS Relay 主路径)
|
||||||
|
|
||||||
```ts
|
|
||||||
kernelClient.setConfig({
|
|
||||||
provider: 'custom',
|
|
||||||
model: modelToUse, // 从 SaaS listModels() 获取
|
|
||||||
apiKey: session.token, // SaaS JWT,不是 LLM Key
|
|
||||||
baseUrl: `${session.saasUrl}/api/v1/relay`, // 指向 SaaS relay
|
|
||||||
apiProtocol: 'openai',
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
前端选择模型 → preferredModel || fallbackId
|
||||||
**注意**: Kernel 仍然执行 LLM 调用逻辑,但请求发往 SaaS relay 而非直连 LLM。
|
→ kernelClient.setConfig({ model, apiKey: JWT, baseUrl: saasUrl/api/v1/relay })
|
||||||
SaaS relay 接到请求后,从 Token Pool 中取一个可用 Key,转发给真实 LLM。
|
→ Tauri invoke kernel_init → Kernel::boot(config)
|
||||||
|
→ loop_runner → POST {base_url}/chat/completions
|
||||||
|
→ SaaS Relay → cache 精确匹配 model_id → Key Pool 轮换 → 真实 LLM
|
||||||
|
→ SSE 流式返回
|
||||||
|
```
|
||||||
|
|
||||||
### SaaS 降级流程
|
### SaaS 降级流程
|
||||||
|
|
||||||
关键代码: `connectionStore.ts:446-468`
|
|
||||||
|
|
||||||
```
|
```
|
||||||
listModels() 失败
|
listModels() 失败
|
||||||
→ 401 → session 过期 → logout → 要求重新登录
|
→ 401 → session 过期 → logout
|
||||||
→ 其他错误 → saasDegraded = true
|
→ 其他 → saasDegraded=true → 降级本地 Kernel
|
||||||
→ saasStore.saasReachable = false
|
|
||||||
→ 降级到本地 Kernel 模式
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 客户端类型
|
### 客户端类型
|
||||||
|
|
||||||
| 客户端 | 传输 | 文件 | 用途 |
|
| 客户端 | 传输 | 用途 |
|
||||||
|--------|------|------|------|
|
|--------|------|------|
|
||||||
| GatewayClient | WebSocket + REST | `lib/gateway-client.ts` | 外部 Gateway 进程 |
|
| GatewayClient | WebSocket + REST | 外部 Gateway 进程 |
|
||||||
| KernelClient | Tauri invoke() | `lib/kernel-chat.ts` | 内置 Kernel (桌面端) |
|
| KernelClient | Tauri invoke() | 内置 Kernel (桌面端) |
|
||||||
| SaaSRelayGatewayClient | HTTP SSE | `lib/saas-relay-client.ts` | 浏览器端 SaaS 中继 |
|
| SaaSRelayGatewayClient | HTTP SSE | 浏览器端 SaaS 中继 |
|
||||||
|
|
||||||
`getClient()` 定义: `connectionStore.ts:844`
|
### 不变量
|
||||||
所有 Store 通过 `initializeStores()` (store/index.ts:94) 获取共享 client。
|
|
||||||
|
|
||||||
### Store 层 (16 根文件 + chat/4 + saas/5 = 25)
|
- `getClient()` 是全局单例,所有 Store 通过 `initializeStores()` 获取共享 client
|
||||||
|
- SaaS 不可达时自动降级到本地 Kernel,不需要用户干预
|
||||||
```
|
- SaaS Relay 按 `model_id` 精确匹配,不解析别名 (`config.toml [llm.aliases]` 仅本地 Kernel)
|
||||||
desktop/src/store/
|
- Provider Key 解密失败时 warn+skip,不 500 (`key_pool.rs`)
|
||||||
├── index.ts Store 协调器 + client 注入
|
|
||||||
├── agentStore.ts Agent 分身管理
|
|
||||||
├── browserHandStore.ts 浏览器 Hand 状态
|
|
||||||
├── chatStore.ts 聊天通用状态
|
|
||||||
├── classroomStore.ts 课堂模式
|
|
||||||
├── configStore.ts 配置读写
|
|
||||||
├── connectionStore.ts 路由决策核心
|
|
||||||
├── handStore.ts Hand 状态管理
|
|
||||||
├── industryStore.ts 行业配置 (已接通 ButlerPanel)
|
|
||||||
├── memoryGraphStore.ts 记忆图谱
|
|
||||||
├── offlineStore.ts 离线队列
|
|
||||||
├── saasStore.ts SaaS 认证 (re-export barrel)
|
|
||||||
├── securityStore.ts 安全状态
|
|
||||||
├── sessionStore.ts 会话管理
|
|
||||||
├── uiModeStore.ts 双模式 UI
|
|
||||||
├── workflowStore.ts 工作流状态
|
|
||||||
├── chat/
|
|
||||||
│ ├── artifactStore.ts 聊天产物
|
|
||||||
│ ├── conversationStore.ts 会话管理
|
|
||||||
│ ├── messageStore.ts 消息持久化
|
|
||||||
│ └── streamStore.ts 流式编排
|
|
||||||
└── saas/ (拆分子模块, 04-17 refactor)
|
|
||||||
├── index.ts 子模块入口
|
|
||||||
├── auth.ts 认证逻辑
|
|
||||||
├── billing.ts 计费逻辑
|
|
||||||
├── shared.ts 共享状态/工具
|
|
||||||
└── types.ts 类型定义
|
|
||||||
```
|
|
||||||
|
|
||||||
### lib/ 工具层 (75 个 .ts 文件)
|
|
||||||
|
|
||||||
关键分类:
|
|
||||||
|
|
||||||
| 类别 | 文件 | 数量 |
|
|
||||||
|------|------|------|
|
|
||||||
| Kernel 通信 | kernel-client/kernel-chat/kernel-agent/kernel-skills/kernel-triggers/kernel-hands/... | 8 |
|
|
||||||
| SaaS 通信 | saas-client/saas-auth/saas-billing/saas-relay/saas-industry/saas-knowledge/... | 12 |
|
|
||||||
| Gateway | gateway-client/gateway-api/gateway-auth/gateway-config/... | 9 |
|
|
||||||
| Intelligence | intelligence-backend/intelligence-client/embedding-client/memory-extractor | 4 |
|
|
||||||
| Viking | viking-client | 1 |
|
|
||||||
| Pipeline | pipeline-client/pipeline-recommender | 2 |
|
|
||||||
| Security | crypto-utils/secure-storage/security-audit/security-index/api-key-storage | 5 |
|
|
||||||
| 工具 | config-parser/logger/utils/error-types/error-utils/json-utils/... | 10+ |
|
|
||||||
| Tauri 集成 | safe-tauri/tauri-gateway | 2 |
|
|
||||||
| 工作流 | workflow-builder/ (index + types + yaml-converter) | 3 |
|
|
||||||
|
|
||||||
## 模型路由
|
|
||||||
|
|
||||||
### 完整链路 (Tauri SaaS Relay 主路径)
|
|
||||||
|
|
||||||
```
|
|
||||||
前端模型选择
|
|
||||||
│
|
|
||||||
├─ conversationStore.currentModel (用户上次选择的模型)
|
|
||||||
│ 持久化到 IndexedDB,跨会话保留
|
|
||||||
│
|
|
||||||
├─ connectionStore 连接时获取 SaaS 可用模型
|
|
||||||
│ saasClient.listModels() → [{id: "deepseek-chat"}, {id: "GLM-4.7"}, ...]
|
|
||||||
│ relayModels[0]?.id 作为 fallback
|
|
||||||
│
|
|
||||||
└─ 最终: preferredModel || fallbackId
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
kernelClient.setConfig({ model: modelToUse })
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
kernel_init (Tauri Command)
|
|
||||||
│ KernelConfigRequest { model, api_key, base_url }
|
|
||||||
│ base_url = "https://saas-host/api/v1/relay"
|
|
||||||
│ api_key = SaaS JWT (不是 LLM Key!)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Kernel::boot(config)
|
|
||||||
│ config.llm.model = modelToUse
|
|
||||||
│ config.llm.base_url = SaaS relay URL
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
loop_runner → LLM Driver (OpenAI compatible)
|
|
||||||
│ POST {base_url}/chat/completions
|
|
||||||
│ body: { model: modelToUse, messages: [...] }
|
|
||||||
│ header: Authorization: Bearer {SaaS JWT}
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
SaaS Relay Handler (handlers.rs)
|
|
||||||
│ cache.get_model(model_name) → 精确匹配 model_id
|
|
||||||
│ ⚠️ 无别名解析! "glm-4-flash" ≠ "deepseek-chat"
|
|
||||||
│ 找不到 → 400 "模型 xxx 不存在或未启用"
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Key Pool 轮换
|
|
||||||
│ priority ASC → last_used_at ASC → cooldown 检查 → RPM/TPM 滑动窗口
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
真实 LLM API
|
|
||||||
│ 429 → mark cooldown → 切换 key
|
|
||||||
│ 5xx → exponential backoff
|
|
||||||
│ model_group → 跨 Provider 故障转移
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
响应 → SSE 流式返回 → 前端
|
|
||||||
```
|
|
||||||
|
|
||||||
### 辅助 LLM 调用 (非聊天主路径)
|
|
||||||
|
|
||||||
这些 Rust 端组件也通过同一个 relay 发起 LLM 请求:
|
|
||||||
|
|
||||||
| 组件 | 文件 | 模型来源 | 触发时机 |
|
|
||||||
|------|------|----------|----------|
|
|
||||||
| 记忆摘要 | `summarizer_adapter.rs` | kernel_init 传入的 model | 定期 L0/L1 摘要生成 |
|
|
||||||
| 记忆提取 | `extraction_adapter.rs` | kernel_init 传入的 model | 中间件触发提取 |
|
|
||||||
| 管家路由 | ButlerRouter via loop_runner | 同聊天模型 | 聊天中间件链 |
|
|
||||||
|
|
||||||
**关键**: `summarizer_adapter.rs` 和 `extraction_adapter.rs` 在 `kernel_init` 时配置,
|
|
||||||
使用与聊天相同的 `model` 和 `base_url`。未配置时会明确报错,不会静默 fallback 到错误模型。
|
|
||||||
|
|
||||||
### SaaS Relay 模型匹配规则
|
|
||||||
|
|
||||||
```
|
|
||||||
前端发送 model: "deepseek-chat"
|
|
||||||
→ SaaS cache 按 model_id 精确匹配
|
|
||||||
→ 匹配: cache.models["deepseek-chat"] → 命中
|
|
||||||
→ 不匹配: cache.models["glm-4-flash"] → null → 400 错误
|
|
||||||
|
|
||||||
⚠️ config.toml 中的 [llm.aliases] 仅用于本地 Kernel,SaaS relay 不解析别名!
|
|
||||||
```
|
|
||||||
|
|
||||||
### Browser 模式模型路由
|
|
||||||
|
|
||||||
```
|
|
||||||
createSaaSRelayGatewayClient(saasUrl, getModel)
|
|
||||||
│ getModel() 回调 → conversationStore.currentModel || relayModels[0]?.id
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
chatStream() → saasClient.chatCompletion({ model: getModel() })
|
|
||||||
│ 未获取到模型时 → onError 报错,不发请求
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
POST /api/v1/relay/chat/completions → SSE 流
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 接口
|
|
||||||
|
|
||||||
### Tauri 命令
|
### Tauri 命令
|
||||||
|
|
||||||
**Gateway 管理** (`desktop/src-tauri/src/gateway/commands.rs`):
|
| 命令 | 说明 |
|
||||||
|
|
||||||
| 命令 | 参数 | 返回值 | 说明 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `zclaw_status` | — | `LocalGatewayStatus` | Kernel 运行状态 |
|
|
||||||
| `zclaw_start` | — | `LocalGatewayStatus` | 启动 Kernel |
|
|
||||||
| `zclaw_stop` | — | `LocalGatewayStatus` | 停止 Kernel |
|
|
||||||
| `zclaw_restart` | — | `LocalGatewayStatus` | 重启 Kernel |
|
|
||||||
| `zclaw_local_auth` | — | `LocalGatewayAuth` | 获取本地认证 token |
|
|
||||||
| `zclaw_prepare_for_tauri` | — | `LocalGatewayPrepareResult` | 更新 Tauri allowed origins |
|
|
||||||
| `zclaw_approve_device_pairing` | device_id, public_key_base64, url? | `PairingApprovalResult` | 设备配对审批 |
|
|
||||||
| `zclaw_doctor` | — | `String` | 诊断报告 |
|
|
||||||
| `zclaw_process_list` | — | `ProcessListResponse` | 进程列表 |
|
|
||||||
| `zclaw_process_logs` | pid?, lines? | `ProcessLogsResponse` | 进程日志 |
|
|
||||||
| `zclaw_version` | — | `VersionResponse` | 版本信息 |
|
|
||||||
|
|
||||||
**健康检查** (`desktop/src-tauri/src/health_check.rs`):
|
|
||||||
|
|
||||||
| 命令 | 参数 | 返回值 | 说明 |
|
|
||||||
|------|------|--------|------|
|
|
||||||
| `zclaw_health_check` | port?, timeout_ms? | `HealthCheckResponse` | 完整健康检查 |
|
|
||||||
| `zclaw_ping` | — | `bool` | 快速端口检测 |
|
|
||||||
|
|
||||||
### SaaS Relay 路由 (`crates/zclaw-saas/src/relay/`)
|
|
||||||
|
|
||||||
| 方法 | 路径 | 权限 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| POST | `/api/v1/relay/chat/completions` | 认证+配额 | 主聊天中转 |
|
|
||||||
| GET | `/api/v1/relay/models` | 认证 | 可用模型列表 |
|
|
||||||
| GET | `/api/v1/relay/tasks` | 认证 | 任务列表 |
|
|
||||||
| GET | `/api/v1/relay/tasks/:id` | 认证 | 任务详情 |
|
|
||||||
| POST | `/api/v1/relay/tasks/:id/retry` | admin | 重试失败任务 |
|
|
||||||
|
|
||||||
### Provider Key 管理 (`crates/zclaw-saas/src/relay/handlers.rs`)
|
|
||||||
|
|
||||||
| 方法 | 路径 | 权限 | 说明 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| GET | `/api/v1/providers/:id/keys` | admin | 列出 Provider Key |
|
|
||||||
| POST | `/api/v1/providers/:id/keys` | admin | 添加加密 Key |
|
|
||||||
| PUT | `/api/v1/providers/:id/keys/:kid/toggle` | admin | 启停 Key |
|
|
||||||
| DELETE | `/api/v1/providers/:id/keys/:kid` | admin | 删除 Key |
|
|
||||||
|
|
||||||
## 测试链路
|
|
||||||
|
|
||||||
| 功能 | 测试文件 | 测试数 | 覆盖状态 |
|
|
||||||
|------|---------|--------|---------|
|
|
||||||
| Admin 路由解析 | `tests/desktop/connectionStore.adminRouting.test.ts` | — | ✅ parseAdminRouting() 纯函数 |
|
|
||||||
| 连接流程 | `tests/desktop/gatewayStore.test.ts` | — | ✅ connect() + 数据加载 |
|
|
||||||
| GatewayClient | `tests/gateway/ws-client.test.ts` | — | ✅ WS 连接/事件/断连 |
|
|
||||||
| Mock Server | `tests/fixtures/zclaw-mock-server.ts` | — | ✅ HTTP+WS 模拟 |
|
|
||||||
|
|
||||||
## 关联模块
|
|
||||||
|
|
||||||
- [[chat]] — 路由决定使用哪种 ChatStream
|
|
||||||
- [[saas]] — Token Pool、认证、模型管理
|
|
||||||
- [[middleware]] — 请求经过中间件链处理
|
|
||||||
- [[butler]] — 管家模式通过 ButlerRouter 中间件介入
|
|
||||||
|
|
||||||
## 关键文件
|
|
||||||
|
|
||||||
| 文件 | 职责 |
|
|
||||||
|------|------|
|
|------|------|
|
||||||
| `desktop/src/store/connectionStore.ts` | 路由决策核心 |
|
| `kernel_init` | 初始化 Kernel 配置 (model, apiKey, baseUrl) |
|
||||||
| `desktop/src/lib/gateway-client.ts` | WebSocket 客户端 |
|
| `zclaw_start` / `zclaw_stop` / `zclaw_restart` | Kernel 生命周期管理 |
|
||||||
| `desktop/src/lib/kernel-chat.ts` | Tauri 内核聊天 |
|
| `zclaw_health_check` / `zclaw_ping` | 健康检查 + 端口检测 |
|
||||||
| `desktop/src/lib/kernel-client.ts` | Kernel 客户端配置 |
|
| `zclaw_doctor` | 完整诊断报告 |
|
||||||
| `desktop/src/lib/saas-relay-client.ts` | SaaS SSE 中继 |
|
|
||||||
| `desktop/src/lib/saas-client.ts` | SaaS API 客户端 |
|
|
||||||
| `desktop/src/store/index.ts` | Store 协调器 + client 注入 |
|
|
||||||
|
|
||||||
## 已知问题
|
### SaaS Relay 路由
|
||||||
|
|
||||||
- ✅ **Tauri invoke 参数名 snake_case vs camelCase** — P1 已修复 (commit f6c5dd2)。Tauri 2.x 默认 `rename_all = "camelCase"`,所有 invoke 调用必须用 camelCase
|
| 路径 | 说明 |
|
||||||
- ✅ **Provider Key 解密失败导致 relay 500** — P1 已修复 (commit b69dc61)。`key_pool.rs` 现在 decrypt 失败时 warn+skip 到下一个 key,启动时 `heal_provider_keys()` 自动重新加密有效 key
|
|------|------|
|
||||||
|
| `POST /api/v1/relay/chat/completions` | 主聊天中转 (认证+配额) |
|
||||||
|
| `GET /api/v1/relay/models` | 可用模型列表 |
|
||||||
|
|
||||||
|
## 4. 活跃问题 + 注意事项
|
||||||
|
|
||||||
|
| 问题 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Tauri invoke 参数名 snake_case | ✅ 已修复 (f6c5dd2) | Tauri 2.x 默认 `rename_all="camelCase"`,invoke 必须用 camelCase |
|
||||||
|
| Provider Key 解密致 relay 500 | ✅ 已修复 (b69dc61) | decrypt 失败 warn+skip,启动时 `heal_provider_keys()` 自动重新加密 |
|
||||||
|
| Tauri 命令孤儿 | ~0 (差异来自内部调用) | 190 定义 / 104 invoke / 97 @reserved |
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
- `summarizer_adapter.rs` 和 `extraction_adapter.rs` 在 `kernel_init` 时配置,使用与聊天相同的 model+base_url。未配置时明确报错,不静默 fallback
|
||||||
|
- Browser 模式 `getModel()` 未获取到模型时 onError 报错,不发请求
|
||||||
|
|
||||||
|
## 5. 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更 |
|
||||||
|
|------|------|
|
||||||
|
| 04-22 | Wiki 重写: 5 节模板,移除 Store/lib 全量列表 |
|
||||||
|
| 04-21 | 上一轮更新 |
|
||||||
|
| 04-19 | TRUTH.md 数字校准: 190 命令 / 104 invoke / 97 @reserved |
|
||||||
|
| 04-16 | Provider Key 解密修复 (b69dc61) |
|
||||||
|
| 04-16 | Tauri invoke 参数名修复 (f6c5dd2) |
|
||||||
|
|||||||
315
wiki/saas.md
315
wiki/saas.md
@@ -1,105 +1,31 @@
|
|||||||
---
|
---
|
||||||
title: SaaS 平台
|
title: SaaS 平台
|
||||||
updated: 2026-04-21
|
updated: 2026-04-22
|
||||||
status: active
|
status: active
|
||||||
tags: [module, saas, auth, billing]
|
tags: [module, saas, billing, relay]
|
||||||
---
|
---
|
||||||
|
|
||||||
# SaaS 平台
|
# SaaS 平台
|
||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[routing]] [[chat]]
|
> 从 [[index]] 导航。关联模块: [[routing]] [[chat]] [[security]]
|
||||||
|
|
||||||
## 设计思想
|
## 设计决策
|
||||||
|
|
||||||
**核心定位: SaaS 是 Tauri 桌面端的中枢,不是独立 Web 应用。**
|
**核心定位: SaaS 是 Tauri 桌面端的中枢,不是独立 Web 应用。**
|
||||||
|
|
||||||
关键决策:
|
| 决策 | 为什么 |
|
||||||
1. **Token Pool** — 桌面端不持有 LLM API Key,SaaS 维护共享 Key 池,RPM/TPM 轮换
|
|------|--------|
|
||||||
2. **JWT + Cookie 双通道** — Tauri 用 OS keyring 存 JWT,浏览器用 HttpOnly cookie
|
| Token Pool 集中管理 | 桌面端不持有 LLM API Key,SaaS 维护共享 Key 池做 RPM/TPM 轮换,支持用量追踪和计费 |
|
||||||
3. **计费闭环** — 配额实时递增 → 聚合器调度 → mock 支付路由
|
| 16 模块目录划分 | 按业务域高内聚:auth/relay/billing/knowledge/model_config/account/agent_template/industry/role/prompt/scheduled_task/telemetry/migration/models/tasks/workers |
|
||||||
4. **Admin V2** — 17 页管理后台,管理模型/用户/计费/知识库
|
| 137 routes + 13 路由模块 | main.rs 用 `.merge()` 统一注册,每个模块独立维护路由 |
|
||||||
|
| 7 后台 Workers | 用量记录/聚合、限流清理、token 清理、embedding 生成等异步任务解耦 |
|
||||||
|
| 认证与安全 | 详见 [[security]] |
|
||||||
|
|
||||||
## 功能清单
|
## 关键文件 + 数据流
|
||||||
|
|
||||||
| 功能 | 描述 | 入口文件 | 状态 |
|
### SaaS 模块结构
|
||||||
|------|------|----------|------|
|
|
||||||
| 用户认证 | 注册/登录/JWT刷新/登出 | auth/handlers.rs | ✅ |
|
|
||||||
| TOTP 2FA | 设置/验证/禁用 | auth/handlers.rs | ✅ |
|
|
||||||
| Token Pool | RPM/TPM 轮换 Key 分配 | relay/handlers.rs | ✅ |
|
|
||||||
| 聊天中转 | OpenAI 兼容 relay | relay/handlers.rs | ✅ |
|
|
||||||
| 计费系统 | 配额递增/订阅/支付回调 | billing/ | ✅ |
|
|
||||||
| 用户管理 | CRUD/状态/设备/token | account/ | ✅ |
|
|
||||||
| 模型管理 | Provider/模型/Key CRUD | model_config/ | ✅ |
|
|
||||||
| Agent 模板 | 模板 CRUD + 自动分配 | agent_template/ | ✅ |
|
|
||||||
| 知识库 | 分类/条目/搜索/pgvector | knowledge/ | ✅ |
|
|
||||||
| Prompt 管理 | 版本控制/回滚 | prompt/ | ✅ |
|
|
||||||
| 角色权限 | RBAC + 权限模板 | role/ | ✅ |
|
|
||||||
| 行业配置 | 行业 CRUD + 账户分配 | industry/ | ✅ |
|
|
||||||
| 定时任务 | 任务调度 CRUD | scheduled_task/ | ✅ |
|
|
||||||
| 用量统计 | Telemetry 上报/查询 | telemetry/ | ✅ |
|
|
||||||
| 配置同步 | 项 CRUD/分析/seed/sync/diff | migration/ | ✅ |
|
|
||||||
| Admin Dashboard | 系统概览 + 运营指标 | account/admin_routes | ✅ |
|
|
||||||
| Mock 支付 | 开发环境模拟支付 | billing/mock_routes | ✅ |
|
|
||||||
|
|
||||||
## 代码逻辑
|
16 个目录 (`crates/zclaw-saas/src/`):
|
||||||
|
|
||||||
### 认证流
|
|
||||||
|
|
||||||
```
|
|
||||||
用户登录 (POST /api/v1/auth/login)
|
|
||||||
→ Argon2id + OsRg 盐验证密码
|
|
||||||
→ 签发 JWT (Claims: user_id, role, pwv)
|
|
||||||
→ set_auth_cookies():
|
|
||||||
zclaw_access_token (path:/api, 2h TTL, HttpOnly)
|
|
||||||
zclaw_refresh_token (path:/api/v1/auth, 7d TTL, HttpOnly)
|
|
||||||
Secure: dev=false, prod=true | SameSite=Strict
|
|
||||||
|
|
||||||
前端存储:
|
|
||||||
→ Tauri: OS keyring → saasStore.token
|
|
||||||
→ 浏览器: HttpOnly Cookie (JS 不可读)
|
|
||||||
→ localStorage: saasUrl + account 信息 (非敏感)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token 池 + 限流
|
|
||||||
|
|
||||||
```
|
|
||||||
SaaS Relay 收到 LLM 请求 (POST /api/v1/relay/chat/completions)
|
|
||||||
→ 验证 JWT → 提取 user_id
|
|
||||||
→ 从 Token Pool 选择可用 Key (RPM/TPM 轮换)
|
|
||||||
→ 转发请求到真实 LLM API
|
|
||||||
→ 记录 usage (record_usage worker)
|
|
||||||
→ 返回响应
|
|
||||||
|
|
||||||
限流规则:
|
|
||||||
→ /api/auth/login: 5次/分钟/IP (防暴力) + 持久化到 PostgreSQL
|
|
||||||
→ /api/auth/register: 3次/小时/IP (防刷注册)
|
|
||||||
→ 公共端点: 20次/分钟/IP
|
|
||||||
```
|
|
||||||
|
|
||||||
### 密码安全
|
|
||||||
|
|
||||||
```
|
|
||||||
JWT password_version (pwv):
|
|
||||||
→ JWT Claims 含 pwv 字段
|
|
||||||
→ 每次验证 JWT 时比对 Claims.pwv vs DB.pwv
|
|
||||||
→ 修改密码 → DB.pwv 递增 → 所有旧 JWT 自动失效
|
|
||||||
|
|
||||||
密码存储: Argon2id + OsRg 随机盐
|
|
||||||
TOTP 加密: AES-256-GCM + 随机 Nonce
|
|
||||||
```
|
|
||||||
|
|
||||||
### Token 刷新
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v1/auth/refresh
|
|
||||||
→ 验证 refresh_token (单次使用)
|
|
||||||
→ 旧 refresh_token 撤销到 DB (rotation 校验)
|
|
||||||
→ 签发新 access + refresh token
|
|
||||||
```
|
|
||||||
|
|
||||||
### SaaS 模块结构(代码验证)
|
|
||||||
|
|
||||||
16 个模块目录 (`crates/zclaw-saas/src/`):
|
|
||||||
|
|
||||||
```
|
```
|
||||||
account/ agent_template/ auth/ billing/ industry/
|
account/ agent_template/ auth/ billing/ industry/
|
||||||
@@ -107,124 +33,141 @@ knowledge/ migration/ model_config/ models/ prompt/
|
|||||||
relay/ role/ scheduled_task/ tasks/ telemetry/ workers/
|
relay/ role/ scheduled_task/ tasks/ telemetry/ workers/
|
||||||
```
|
```
|
||||||
|
|
||||||
### SaaS API 分布
|
### 核心文件
|
||||||
|
|
||||||
137 个 `.route()` 调用,13 个路由模块 (main.rs `.merge()` 注册)。
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `crates/zclaw-saas/src/main.rs` | 路由注册入口 (13 个 .merge()) |
|
||||||
|
| `crates/zclaw-saas/src/relay/handlers.rs` | 聊天中转 + Token Pool 分配 |
|
||||||
|
| `crates/zclaw-saas/src/billing/` | 配额递增/订阅/支付回调 |
|
||||||
|
| `crates/zclaw-saas/src/knowledge/` | 知识库 CRUD + pgvector (最大模块, 24 routes) |
|
||||||
|
| `crates/zclaw-saas/src/workers/` | 7 个后台 Worker |
|
||||||
|
| `crates/zclaw-saas/migrations/` | SQL 迁移 (38 文件, 42 CREATE TABLE) |
|
||||||
|
| `admin-v2/src/pages/` | 17 页管理后台 |
|
||||||
|
| `desktop/src/lib/saas-client.ts` | 前端 SaaS API 客户端 |
|
||||||
|
| `desktop/src/store/saasStore.ts` | SaaS 认证状态 |
|
||||||
|
|
||||||
| 模块 | 路由注册 | 说明 |
|
### 数据流
|
||||||
|------|----------|------|
|
|
||||||
| auth | handlers.rs | 登录/注册/刷新/2FA |
|
```
|
||||||
| relay | relay/ | 聊天中转/模型列表/任务 |
|
桌面端请求 (ChatPanel)
|
||||||
| billing | billing/ + callback_routes | 配额/订阅/支付 |
|
→ Tauri invoke / HTTP SSE
|
||||||
| knowledge | knowledge/ | 知识库 CRUD + pgvector (最大模块) |
|
→ SaaS Relay (POST /api/v1/relay/chat/completions)
|
||||||
| model_config | model_config/ | Provider + 模型管理 |
|
→ JWT 验证 → Token Pool 选择 Key → LLM API
|
||||||
| account | account/ | 用户管理 |
|
→ SSE 流式返回
|
||||||
| agent_template | agent_template/ | Agent 模板 |
|
→ 桌面端 streamStore.onDelta 渲染
|
||||||
| role | role/ | 角色 + 权限 |
|
```
|
||||||
| telemetry | telemetry/ | 用量统计 |
|
|
||||||
| prompt | prompt/ | Prompt 模板 |
|
### 集成契约
|
||||||
| scheduled_task | scheduled_task/ | 定时任务 CRUD |
|
|
||||||
| industry | industry/ | 行业配置管理 (V13 新增) |
|
| 方向 | 接口 | 说明 |
|
||||||
| migration | migration/ | Schema 迁移 |
|
|------|------|------|
|
||||||
|
| Called by <-- desktop | Tauri invoke / HTTP SSE | Chat relay, billing, auth |
|
||||||
|
| Calls --> relay handlers | POST /api/v1/relay/chat/completions | Token Pool RPM/TPM 轮换 |
|
||||||
|
| Provides --> admin | 137 routes | User/billing/knowledge/model 管理 |
|
||||||
|
|
||||||
|
### API 分布
|
||||||
|
|
||||||
|
| 模块 | 路由数 | 核心端点 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| knowledge | 24 | 分类/条目/搜索/上传/版本/结构化 |
|
||||||
|
| model_config | 19 | Provider/模型/Key/模型组/用量 |
|
||||||
|
| billing | 12 | 订阅/用量/支付/mock/回调/invoice |
|
||||||
|
| account | 12 | CRUD/状态/token/设备/操作日志/dashboard |
|
||||||
|
| agent_template | 11 | 模板 CRUD + 创建 Agent + 分配 |
|
||||||
|
| auth | 9 | POST /auth/{register,login,refresh,logout} + TOTP + password |
|
||||||
|
| industry | 8 | 行业 CRUD + 账户分配 |
|
||||||
|
| migration | 8 | 配置项 CRUD + 分析/seed/sync/diff |
|
||||||
|
| prompt | 6 | Prompt CRUD + 版本/回滚 |
|
||||||
|
| role | 6 | 角色/权限模板 CRUD |
|
||||||
|
| telemetry | 4 | 上报/统计/日报/审计 |
|
||||||
|
| relay | 5 | POST /relay/chat/completions + GET /relay/models |
|
||||||
|
| scheduled_task | 2 | 定时任务 CRUD |
|
||||||
|
|
||||||
### 数据表 (42 CREATE TABLE)
|
### 数据表 (42 CREATE TABLE)
|
||||||
|
|
||||||
38 个 SQL 迁移文件 (21 up + 17 down),42 个 `CREATE TABLE` 语句。
|
|
||||||
|
|
||||||
核心表: users, agents, conversations, messages, billing_*, knowledge_*, model_configs, roles, permissions, scheduled_tasks, telemetry, agent_templates, saas_schema_version, user_profiles, trajectory_records, industries, account_industries
|
核心表: users, agents, conversations, messages, billing_*, knowledge_*, model_configs, roles, permissions, scheduled_tasks, telemetry, agent_templates, saas_schema_version, user_profiles, trajectory_records, industries, account_industries
|
||||||
|
|
||||||
|
## 代码逻辑
|
||||||
|
|
||||||
|
### Token Pool RPM/TPM 轮换算法
|
||||||
|
|
||||||
|
```
|
||||||
|
SaaS Relay 收到请求
|
||||||
|
→ 验证 JWT → 提取 user_id
|
||||||
|
→ Token Pool 选择 Key:
|
||||||
|
1. priority ASC (高优先级优先)
|
||||||
|
2. last_used_at ASC (最久未用优先)
|
||||||
|
3. cooldown 检查 (跳过冷却中的 Key)
|
||||||
|
4. RPM/TPM 滑动窗口检查 (当前窗口是否超限)
|
||||||
|
→ 转发请求到 LLM API
|
||||||
|
→ record_usage worker 异步记录
|
||||||
|
```
|
||||||
|
|
||||||
### Workers (7 个)
|
### Workers (7 个)
|
||||||
|
|
||||||
| Worker | 文件 | 职责 |
|
| Worker | 文件 | 职责 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
|
| record_usage | workers/ | 用量记录 (relay 后异步) |
|
||||||
|
| aggregate_usage | workers/ | 用量聚合 (日报/月报) |
|
||||||
|
| generate_embedding | workers/ | 内容分块 (embedding deferred, pgvector 就绪) |
|
||||||
| log_operation | workers/ | 操作日志 |
|
| log_operation | workers/ | 操作日志 |
|
||||||
| cleanup_rate_limit | workers/ | 限流记录清理 |
|
| cleanup_rate_limit | workers/ | 限流记录清理 |
|
||||||
| cleanup_refresh_tokens | workers/ | 刷新 token 清理 |
|
| cleanup_refresh_tokens | workers/ | 刷新 token 清理 |
|
||||||
| record_usage | workers/ | 用量记录 |
|
| update_last_used | workers/ | 模型最后使用时间更新 |
|
||||||
| update_last_used | workers/ | 模型最后使用更新 |
|
|
||||||
| aggregate_usage | workers/ | 用量聚合 |
|
|
||||||
| generate_embedding | workers/ | 内容分块 (embedding deferred) |
|
|
||||||
|
|
||||||
## API 接口
|
### 计费流程
|
||||||
|
|
||||||
~118 个唯一路由,分布在 13 个模块中(详见上方「SaaS API 分布」和各模块 `mod.rs`)。
|
```
|
||||||
|
用户请求 relay → quota_check_middleware 检查月度配额
|
||||||
|
→ 通过: relay 正常执行
|
||||||
|
→ record_usage worker 递增 relay_requests + input_tokens
|
||||||
|
→ aggregate_usage worker 定期聚合
|
||||||
|
→ 超额: 返回 429 QuotaExceeded
|
||||||
|
```
|
||||||
|
|
||||||
| 模块 | 路由数 | 核心端点 |
|
## 活跃问题 + 陷阱
|
||||||
|------|--------|---------|
|
|
||||||
| auth | 9 | POST /auth/{register,login,refresh,logout} + TOTP + password |
|
|
||||||
| relay | 5 | POST /relay/chat/completions + GET /relay/models |
|
|
||||||
| billing | 12 | 订阅/用量/支付/mock/回调/invoice |
|
|
||||||
| knowledge | 24 | 分类/条目/搜索/上传/版本/分析/结构化 |
|
|
||||||
| model_config | 19 | Provider/模型/Key/模型组/用量 |
|
|
||||||
| account | 12 | CRUD/状态/token/设备/操作日志/dashboard |
|
|
||||||
| agent_template | 11 | 模板 CRUD + 创建 Agent + 分配 |
|
|
||||||
| industry | 8 | 行业 CRUD + 账户分配 |
|
|
||||||
| prompt | 6 | Prompt CRUD + 版本/回滚 |
|
|
||||||
| role | 6 | 角色/权限模板 CRUD |
|
|
||||||
| telemetry | 4 | 上报/统计/日报/审计 |
|
|
||||||
| scheduled_task | 2 | 定时任务 CRUD |
|
|
||||||
| migration | 8 | 配置项 CRUD + 分析/seed/sync/diff |
|
|
||||||
|
|
||||||
## 测试链路
|
| 问题 | 级别 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Admin 用量统计显示 0/0 | P2 Open | Dashboard 17 requests / 6304 tokens,但 Usage 页 0/0,数据源不一致 |
|
||||||
|
| 桌面端 Token 统计为 0 | P2 Open | 前端 token 统计未接通后端数据源 |
|
||||||
|
| Deepseek 中转任务卡 processing | P3 Open | 特定模型 relay 任务状态不更新 |
|
||||||
|
| Embedding 生成未实现 | 长期 | pgvector 索引就绪,generate_embedding worker 逻辑空 |
|
||||||
|
| AuthGuard 竞态条件 | P1 Deferred | 并发请求时可能绕过认证 |
|
||||||
|
|
||||||
| 功能 | 测试文件 | 说明 |
|
陷阱:
|
||||||
|------|---------|------|
|
- SaaS 数据库需要 PostgreSQL (`docker-compose.yml`),不是 SQLite
|
||||||
| 认证 | `crates/zclaw-saas/tests/auth_test.rs` | 注册/登录/刷新/登出 |
|
- Token Pool 的 RPM/TPM 是滑动窗口不是固定窗口,测试时注意时间边界
|
||||||
| 认证安全 | `crates/zclaw-saas/tests/auth_security_test.rs` | 安全边界场景 |
|
- `saas-config.toml` 支持 `${ENV_VAR}` 环境变量插值
|
||||||
| 账户 | `crates/zclaw-saas/tests/account_test.rs` | CRUD/token/设备 |
|
- knowledge 是最大模块 (24 routes),修改时影响面广
|
||||||
| 账户安全 | `crates/zclaw-saas/tests/account_security_test.rs` | 安全边界 |
|
|
||||||
| Agent 模板 | `crates/zclaw-saas/tests/agent_template_test.rs` | 模板 CRUD |
|
|
||||||
| 计费 | `crates/zclaw-saas/tests/billing_test.rs` | 计划/订阅/支付 |
|
|
||||||
| 知识库 | `crates/zclaw-saas/tests/knowledge_test.rs` | CRUD/搜索 |
|
|
||||||
| 模型配置 | `crates/zclaw-saas/tests/model_config_test.rs` | Provider/模型/Key |
|
|
||||||
| 模型配置扩展 | `crates/zclaw-saas/tests/model_config_extended_test.rs` | 扩展覆盖 |
|
|
||||||
| Prompt | `crates/zclaw-saas/tests/prompt_test.rs` | 版本管理 |
|
|
||||||
| 权限矩阵 | `crates/zclaw-saas/tests/permission_matrix_test.rs` | 角色/权限 |
|
|
||||||
| Relay | `crates/zclaw-saas/tests/relay_test.rs` | 聊天中转 |
|
|
||||||
| Relay 验证 | `crates/zclaw-saas/tests/relay_validation_test.rs` | 请求验证 |
|
|
||||||
| 角色 | `crates/zclaw-saas/tests/role_test.rs` | 角色 CRUD |
|
|
||||||
| 定时任务 | `crates/zclaw-saas/tests/scheduled_task_test.rs` | 任务 CRUD |
|
|
||||||
| Telemetry | `crates/zclaw-saas/tests/telemetry_test.rs` | 上报/查询 |
|
|
||||||
| 配置同步 | `crates/zclaw-saas/tests/migration_test.rs` | sync/diff/seed |
|
|
||||||
| Admin 启动 | `crates/zclaw-saas/tests/smoke_saas.rs` | 启动冒烟 |
|
|
||||||
| Admin V2 UI | `admin-v2/tests/pages/*.test.tsx` (17文件) | 每页独立测试 |
|
|
||||||
| 桌面端 SaaS | `tests/desktop/connectionStore.adminRouting.test.ts` | Admin 路由 |
|
|
||||||
| 桌面端集成 | `tests/desktop/integration/zclaw-api.test.ts` | API 集成 |
|
|
||||||
|
|
||||||
## 关联模块
|
## 变更日志
|
||||||
|
|
||||||
- [[routing]] — SaaS Relay 是 Tauri 的主路径
|
| 日期 | 变更 | 提交 |
|
||||||
- [[chat]] — 聊天请求经过 SaaS relay 中转
|
|------|------|------|
|
||||||
- [[memory]] — knowledge_chunks 表有 pgvector 索引
|
| 2026-04-21 | Embedding 接通 + 自学习 A/B 线 | — |
|
||||||
|
| 2026-04-17 | E2E 测试 129 链路,7 Bug 修复 | — |
|
||||||
|
| 2026-04-15 | Heartbeat 统一健康系统 | — |
|
||||||
|
| 2026-04-12 | 行业配置 + 管家主动性全栈 5 Phase | — |
|
||||||
|
| 2026-04-09 | Hermes Intelligence Pipeline 4 Chunk | 684 tests PASS |
|
||||||
|
|
||||||
## 关键文件
|
### 测试覆盖
|
||||||
|
|
||||||
| 文件 | 职责 |
|
| 功能 | 测试文件 |
|
||||||
|------|------|
|
|------|---------|
|
||||||
| `crates/zclaw-saas/src/main.rs` | 路由注册入口 (13个 .merge()) |
|
| 认证流程 | `crates/zclaw-saas/tests/auth_test.rs` |
|
||||||
| `crates/zclaw-saas/src/auth/handlers.rs` | 认证端点 |
|
| 认证安全 | `crates/zclaw-saas/tests/auth_security_test.rs` |
|
||||||
| `crates/zclaw-saas/src/relay/` | 聊天中转 |
|
| 账户 CRUD | `crates/zclaw-saas/tests/account_test.rs` |
|
||||||
| `crates/zclaw-saas/src/billing/` | 计费 |
|
| 账户安全 | `crates/zclaw-saas/tests/account_security_test.rs` |
|
||||||
| `crates/zclaw-saas/src/knowledge/` | 知识库 |
|
| 计费 | `crates/zclaw-saas/tests/billing_test.rs` |
|
||||||
| `crates/zclaw-saas/src/workers/` | 7 个后台 Worker |
|
| 知识库 | `crates/zclaw-saas/tests/knowledge_test.rs` |
|
||||||
| `crates/zclaw-saas/migrations/` | SQL 迁移 (38 文件) |
|
| 模型配置 | `crates/zclaw-saas/tests/model_config_test.rs` |
|
||||||
| `admin-v2/src/pages/` | 17 页管理后台(含 Dashboard/Accounts/Billing/Industries/Knowledge/Prompts/Roles/ScheduledTasks/Config 等) |
|
| Prompt | `crates/zclaw-saas/tests/prompt_test.rs` |
|
||||||
| `desktop/src/lib/saas-client.ts` | 前端 SaaS API 客户端 |
|
| 权限矩阵 | `crates/zclaw-saas/tests/permission_matrix_test.rs` |
|
||||||
| `desktop/src/store/saasStore.ts` | SaaS 认证状态 |
|
| Relay | `crates/zclaw-saas/tests/relay_test.rs` |
|
||||||
|
| Relay 验证 | `crates/zclaw-saas/tests/relay_validation_test.rs` |
|
||||||
## 安全
|
| 角色 | `crates/zclaw-saas/tests/role_test.rs` |
|
||||||
|
| 定时任务 | `crates/zclaw-saas/tests/scheduled_task_test.rs` |
|
||||||
完整审计: `docs/features/SECURITY_PENETRATION_TEST_V1.md`
|
| Telemetry | `crates/zclaw-saas/tests/telemetry_test.rs` |
|
||||||
- CORS 白名单 (生产缺失拒绝启动)
|
| 配置同步 | `crates/zclaw-saas/tests/migration_test.rs` |
|
||||||
- Cookie Secure (dev=false, prod=true)
|
|
||||||
- JWT 签名密钥 >= 32 字符 (release fallback 拒绝启动)
|
|
||||||
- 独立 TOTP 加密密钥
|
|
||||||
|
|
||||||
## 已知问题
|
|
||||||
|
|
||||||
- ⚠️ **Admin 用量统计显示 0/0** — P2 Open。Dashboard 显示 17 requests / 6304 tokens,但 Usage 页显示 0/0,数据源不一致
|
|
||||||
- ⚠️ **SaaS Embedding 生成未实现** — Open。pgvector 索引就绪,`generate_embedding.rs` Worker 存在但生成逻辑未实现
|
|
||||||
- ⚠️ **AuthGuard 竞态条件** — P1-04 Deferred。并发请求时可能绕过认证
|
|
||||||
- ✅ **Dashboard 404** — BUG-H1 已修复。`/api/v1/admin/dashboard` 路由注册遗漏
|
|
||||||
- ✅ **invoice_id 未暴露** — BUG-M1 已修复
|
|
||||||
- ✅ **非 Admin 返回 404 而非 403** — BUG-M4 已修复 (admin_guard_middleware)
|
|
||||||
|
|||||||
218
wiki/security.md
218
wiki/security.md
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 安全体系
|
title: 安全体系
|
||||||
updated: 2026-04-21
|
updated: 2026-04-22
|
||||||
status: active
|
status: active
|
||||||
tags: [module, security, auth, encryption]
|
tags: [module, security, auth, encryption]
|
||||||
---
|
---
|
||||||
@@ -9,37 +9,34 @@ tags: [module, security, auth, encryption]
|
|||||||
|
|
||||||
> 从 [[index]] 导航。关联模块: [[saas]] [[routing]] [[middleware]]
|
> 从 [[index]] 导航。关联模块: [[saas]] [[routing]] [[middleware]]
|
||||||
|
|
||||||
## 设计思想
|
## 设计决策
|
||||||
|
|
||||||
**核心原则: 多层防御,深度安全。**
|
**核心原则: 多层防御,深度安全。**
|
||||||
|
|
||||||
1. **认证层** — JWT + Cookie + TOTP 2FA + 账户锁定
|
| 决策 | 为什么 |
|
||||||
2. **传输层** — CORS 白名单 + Cookie Secure + HTTPS (反向代理)
|
|------|--------|
|
||||||
3. **存储层** — Argon2id 密码 + AES-256-GCM 加密 + OS Keyring
|
| JWT + HttpOnly Cookie 双通道 | Tauri 桌面端用 OS keyring 存 JWT,浏览器用 HttpOnly Cookie 防 XSS 窃取,双环境统一认证 |
|
||||||
4. **运行时层** — 限流 + 配额 + CSP + Guardrail 中间件
|
| password_version (pwv) 失效 | 修改密码后自动使所有已签发 JWT 失效,无需 token 黑名单,O(1) 验证 |
|
||||||
|
| TOTP AES-256-GCM 加密 | TOTP 共享密钥不能明文存储,随机 Nonce 防重放,生产环境强制独立密钥 |
|
||||||
|
| IP 级限流 + 持久化 | 防暴力破解(login 5/min)、防刷注册(3/hour),持久化到 PostgreSQL 避免重启丢失 |
|
||||||
|
| CORS 白名单强制 | 生产环境 `cors_origins` 缺失直接拒绝启动,不允许 `*` 通配 |
|
||||||
|
|
||||||
完整审计报告: `docs/features/SECURITY_PENETRATION_TEST_V1.md`
|
完整审计报告: `docs/features/SECURITY_PENETRATION_TEST_V1.md`
|
||||||
|
|
||||||
## 功能清单
|
## 关键文件 + 数据流
|
||||||
|
|
||||||
| 功能 | 描述 | 入口文件 | 状态 |
|
### 核心文件
|
||||||
|------|------|----------|------|
|
|
||||||
| JWT 认证 | 签发/验证/刷新/失效 | auth/handlers.rs | ✅ |
|
|
||||||
| Cookie 双通道 | Tauri keyring + 浏览器 HttpOnly | auth/handlers.rs | ✅ |
|
|
||||||
| TOTP 2FA | 设置/验证/禁用 | auth/totp.rs | ✅ |
|
|
||||||
| 密码安全 | Argon2id + OsRng 盐 + pwv 失效 | auth/handlers.rs | ✅ |
|
|
||||||
| Token 池加密 | AES-256-GCM + 随机 Nonce | relay/key_pool.rs | ✅ |
|
|
||||||
| OS Keyring | Win DPAPI/macOS Keychain/Linux Secret | secure_storage.rs | ✅ |
|
|
||||||
| 本地记忆加密 | AES-256-GCM (可选) | memory/crypto.rs | ✅ |
|
|
||||||
| 账户锁定 | 5 次失败锁 15 分钟 | auth/handlers.rs | ✅ |
|
|
||||||
| 限流 | IP 级 + 账户级滑动窗口 | middleware.rs | ✅ |
|
|
||||||
| CORS 白名单 | 生产缺失拒绝启动 | main.rs CorsLayer | ✅ |
|
|
||||||
| CSP | Tauri 移除 unsafe-inline | desktop/src-tauri/ | ✅ |
|
|
||||||
| Admin 权限 | admin_guard + RBAC | middleware.rs | ✅ |
|
|
||||||
|
|
||||||
## 代码逻辑
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `crates/zclaw-saas/src/auth/handlers.rs` | 认证端点: 登录/注册/刷新/TOTP/密码修改 |
|
||||||
|
| `crates/zclaw-saas/src/auth/totp.rs` | TOTP 2FA: QR 生成 + 验证 + AES-256-GCM 加密 |
|
||||||
|
| `crates/zclaw-saas/src/middleware.rs` | HTTP 中间件栈 (10 层): 认证/限流/配额/CORS |
|
||||||
|
| `crates/zclaw-saas/src/relay/key_pool.rs` | Token Pool: Key 加密存储 + RPM/TPM 轮换 |
|
||||||
|
| `desktop/src-tauri/src/secure_storage.rs` | OS Keyring: Win DPAPI / macOS Keychain / Linux Secret |
|
||||||
|
| `desktop/src-tauri/src/memory/crypto.rs` | 本地记忆加密: AES-256-GCM (可选) |
|
||||||
|
|
||||||
### 认证流程
|
### 认证数据流
|
||||||
|
|
||||||
```
|
```
|
||||||
用户登录 (POST /api/v1/auth/login)
|
用户登录 (POST /api/v1/auth/login)
|
||||||
@@ -57,50 +54,26 @@ tags: [module, security, auth, encryption]
|
|||||||
→ localStorage: 仅 saasUrl + account 非敏感信息
|
→ localStorage: 仅 saasUrl + account 非敏感信息
|
||||||
```
|
```
|
||||||
|
|
||||||
### JWT Password Version 失效机制
|
### 集成契约
|
||||||
|
|
||||||
```
|
| 方向 | 接口 | 说明 |
|
||||||
JWT Claims 含 pwv (password_version) 字段
|
|------|------|------|
|
||||||
→ 每次验证 JWT 时比对 Claims.pwv vs DB.pwv
|
| Provides --> saas | auth_middleware, JWT validation, rate limiting | 每个 API 请求经过认证层 |
|
||||||
→ 修改密码 → DB.pwv 递增 → 所有旧 JWT 自动失效
|
| Provides --> desktop | secure_storage, crypto_utils | 配置/凭据安全存储 |
|
||||||
```
|
| Provides --> admin | admin_guard_middleware | Admin 路由权限验证 |
|
||||||
|
|
||||||
### Token 池安全
|
### Auth API 接口
|
||||||
|
|
||||||
```
|
**公开路由:**
|
||||||
Provider Key 存储: AES-256-GCM + 随机 Nonce 加密
|
|
||||||
→ 解密失败: warn + skip 到下一个 Key (不阻塞 relay)
|
|
||||||
→ 启动时: heal_provider_keys() 自动重新加密有效 Key
|
|
||||||
→ TOTP 加密密钥: 生产环境强制独立 ZCLAW_TOTP_ENCRYPTION_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
### SaaS HTTP 中间件栈 (10 层)
|
|
||||||
|
|
||||||
| # | 中间件 | 路由组 | 功能 |
|
|
||||||
|---|--------|--------|------|
|
|
||||||
| 1 | public_rate_limit | Public | IP 限流: login 5/min, register 3/hour |
|
|
||||||
| 2 | auth_middleware | Protected+Relay | JWT/Cookie/API Token 身份验证 |
|
|
||||||
| 3 | rate_limit_middleware | Protected+Relay | 账户级请求频率限制 |
|
|
||||||
| 4 | quota_check_middleware | Relay | 月度配额检查 (relay_requests + input_tokens) |
|
|
||||||
| 5 | request_id_middleware | All | UUID 请求追踪 |
|
|
||||||
| 6 | api_version_middleware | All | API 版本头 |
|
|
||||||
| 7 | TimeoutLayer (15s) | Protected | 非流式请求超时 |
|
|
||||||
| 8 | admin_guard | Admin 子路由 | admin 权限验证 |
|
|
||||||
| G1 | TraceLayer | All | HTTP 请求追踪 |
|
|
||||||
| G2 | CorsLayer | All | CORS 白名单 (生产缺失拒绝启动) |
|
|
||||||
|
|
||||||
## API 接口
|
|
||||||
|
|
||||||
### Auth 公开路由
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| POST | `/api/v1/auth/register` | 注册 (邮箱 RFC 5322 + 254 字符限制) |
|
| POST | `/api/v1/auth/register` | 注册 (邮箱 RFC 5322 + 254 字符) |
|
||||||
| POST | `/api/v1/auth/login` | 登录 (5 次/分钟 IP 限流) |
|
| POST | `/api/v1/auth/login` | 登录 (5 次/分钟 IP 限流) |
|
||||||
| POST | `/api/v1/auth/refresh` | 刷新 Token (单次使用,旧 token 撤销到 DB) |
|
| POST | `/api/v1/auth/refresh` | Token 刷新 (单次使用, 旧 token 撤销) |
|
||||||
| POST | `/api/v1/auth/logout` | 登出 |
|
| POST | `/api/v1/auth/logout` | 登出 |
|
||||||
|
|
||||||
### Auth 受保护路由
|
**受保护路由:**
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -110,7 +83,7 @@ Provider Key 存储: AES-256-GCM + 随机 Nonce 加密
|
|||||||
| POST | `/api/v1/auth/totp/verify` | TOTP 验证激活 |
|
| POST | `/api/v1/auth/totp/verify` | TOTP 验证激活 |
|
||||||
| POST | `/api/v1/auth/totp/disable` | TOTP 禁用 (需密码) |
|
| POST | `/api/v1/auth/totp/disable` | TOTP 禁用 (需密码) |
|
||||||
|
|
||||||
### Tauri 安全命令
|
**Tauri 安全命令:**
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -119,39 +92,108 @@ Provider Key 存储: AES-256-GCM + 随机 Nonce 加密
|
|||||||
| `secure_store_delete` | OS Keyring 删除 |
|
| `secure_store_delete` | OS Keyring 删除 |
|
||||||
| `secure_store_is_available` | Keyring 可用性检测 |
|
| `secure_store_is_available` | Keyring 可用性检测 |
|
||||||
|
|
||||||
## 测试链路
|
## 代码逻辑
|
||||||
|
|
||||||
| 功能 | 测试文件 | 覆盖状态 |
|
### JWT Password Version 失效机制
|
||||||
|------|---------|---------|
|
|
||||||
| 认证流程 | `crates/zclaw-saas/tests/auth_test.rs` | ✅ |
|
|
||||||
| 认证安全边界 | `crates/zclaw-saas/tests/auth_security_test.rs` | ✅ |
|
|
||||||
| 账户安全 | `crates/zclaw-saas/tests/account_security_test.rs` | ✅ |
|
|
||||||
| 权限矩阵 | `crates/zclaw-saas/tests/permission_matrix_test.rs` | ✅ |
|
|
||||||
| TOTP | `crates/zclaw-saas/src/auth/totp.rs` inline | ✅ |
|
|
||||||
| 本地加密 | `desktop/src-tauri/src/memory/crypto.rs` inline | ✅ |
|
|
||||||
|
|
||||||
## 关联模块
|
```
|
||||||
|
JWT Claims 含 pwv (password_version) 字段
|
||||||
|
→ auth_middleware 每次验证 JWT 时: Claims.pwv vs DB.pwv
|
||||||
|
→ 不匹配 → 401 Unauthorized
|
||||||
|
→ 修改密码 → DB.pwv 递增 → 所有旧 JWT 自动失效
|
||||||
|
→ 无需 token 黑名单,验证成本 O(1)
|
||||||
|
```
|
||||||
|
|
||||||
- [[saas]] — 安全体系运行在 SaaS 后端
|
### 密码存储: Argon2id + OsRng
|
||||||
- [[routing]] — SaaS JWT 用于 relay 认证
|
|
||||||
- [[middleware]] — Guardrail + LoopGuard + SubagentLimit 运行时安全
|
|
||||||
|
|
||||||
## 关键文件
|
```
|
||||||
|
注册/修改密码:
|
||||||
|
→ OsRng 生成随机盐
|
||||||
|
→ Argon2id 哈希 (内存硬 + 时间成本)
|
||||||
|
→ 存储到 users.password_hash
|
||||||
|
验证:
|
||||||
|
→ Argon2id::verify(password, stored_hash)
|
||||||
|
→ 失败计数递增 → 5 次后锁定 15 分钟
|
||||||
|
```
|
||||||
|
|
||||||
| 文件 | 职责 |
|
### TOTP / API Key 加密: AES-256-GCM
|
||||||
|------|------|
|
|
||||||
| `crates/zclaw-saas/src/auth/handlers.rs` | 认证端点实现 |
|
|
||||||
| `crates/zclaw-saas/src/auth/totp.rs` | TOTP 2FA 实现 |
|
|
||||||
| `crates/zclaw-saas/src/middleware.rs` | HTTP 中间件栈 |
|
|
||||||
| `crates/zclaw-saas/src/relay/key_pool.rs` | Token Pool + Key 加密 |
|
|
||||||
| `desktop/src-tauri/src/secure_storage.rs` | OS Keyring 接口 |
|
|
||||||
| `desktop/src-tauri/src/memory/crypto.rs` | 本地 AES-256-GCM |
|
|
||||||
| `docs/features/SECURITY_PENETRATION_TEST_V1.md` | 安全审计报告 |
|
|
||||||
|
|
||||||
## 已知问题
|
```
|
||||||
|
TOTP 密钥存储:
|
||||||
|
→ 随机生成 12 字节 Nonce
|
||||||
|
→ AES-256-GCM 加密 (密钥: ZCLAW_TOTP_ENCRYPTION_KEY, 64 hex)
|
||||||
|
→ 存储 nonce + ciphertext
|
||||||
|
解密:
|
||||||
|
→ 取出 nonce → AES-256-GCM 解密
|
||||||
|
→ 解密失败: warn + 跳过 (不阻塞认证)
|
||||||
|
|
||||||
- ✅ **JWT 签名密钥 fallback** — `#[cfg(debug_assertions)]` 保护,release 拒绝启动
|
Provider API Key 同理: heal_provider_keys() 启动时重新加密有效 Key
|
||||||
- ✅ **TOTP 加密密钥解耦** — 生产环境强制独立 `ZCLAW_TOTP_ENCRYPTION_KEY`
|
```
|
||||||
- ✅ **Cookie Secure 标志** — dev=false, prod=true
|
|
||||||
- ✅ **CORS 白名单** — 生产缺失拒绝启动
|
### Token 刷新轮换
|
||||||
- ✅ **Admin 404→403** — admin_guard_middleware 已修复
|
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/refresh
|
||||||
|
→ 验证 refresh_token 有效性
|
||||||
|
→ 检查旧 token 是否已撤销 (rotation 防重放)
|
||||||
|
→ 撤销旧 refresh_token (写入 DB revoked_at)
|
||||||
|
→ 签发新 access_token (2h) + refresh_token (7d)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 限流规则
|
||||||
|
|
||||||
|
| 端点 | 限制 | 持久化 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `/api/auth/login` | 5 次/分钟/IP | PostgreSQL |
|
||||||
|
| `/api/auth/register` | 3 次/小时/IP | PostgreSQL |
|
||||||
|
| 公共端点 | 20 次/分钟/IP | 内存 |
|
||||||
|
|
||||||
|
### SaaS HTTP 中间件栈 (10 层)
|
||||||
|
|
||||||
|
| # | 中间件 | 路由组 | 功能 |
|
||||||
|
|---|--------|--------|------|
|
||||||
|
| 1 | public_rate_limit | Public | IP 限流 |
|
||||||
|
| 2 | auth_middleware | Protected+Relay | JWT/Cookie/API Token 身份验证 |
|
||||||
|
| 3 | rate_limit_middleware | Protected+Relay | 账户级频率限制 |
|
||||||
|
| 4 | quota_check_middleware | Relay | 月度配额检查 |
|
||||||
|
| 5 | request_id_middleware | All | UUID 请求追踪 |
|
||||||
|
| 6 | api_version_middleware | All | API 版本头 |
|
||||||
|
| 7 | TimeoutLayer (15s) | Protected | 非流式请求超时 |
|
||||||
|
| 8 | admin_guard | Admin 子路由 | admin 权限验证 |
|
||||||
|
| G1 | TraceLayer | All | HTTP 请求追踪 |
|
||||||
|
| G2 | CorsLayer | All | CORS 白名单 |
|
||||||
|
|
||||||
|
## 活跃问题 + 陷阱
|
||||||
|
|
||||||
|
| 问题 | 级别 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| CSP 已加固 | Done | Tauri 移除 `unsafe-inline` script,`connect-src` 限制 `http://localhost:*` + `https://*` |
|
||||||
|
| TLS 依赖反向代理 | 长期 | Axum 不负责 TLS,nginx/caddy 提供 HTTPS 终止 |
|
||||||
|
| Cookie Secure 开发环境 false | 设计意图 | 开发环境 HTTP 无 Secure,生产必须 true |
|
||||||
|
|
||||||
|
陷阱:
|
||||||
|
- JWT 签名密钥: `#[cfg(debug_assertions)]` 有 fallback,release 模式直接 `bail` 拒绝启动
|
||||||
|
- TOTP 加密密钥: 生产必须独立设置 `ZCLAW_TOTP_ENCRYPTION_KEY`,不从 JWT 密钥派生
|
||||||
|
- CORS 白名单: 生产缺失拒绝启动,不允许通配符
|
||||||
|
- Refresh Token: 单次使用,logout 时撤销到 DB,rotation 校验已撤销的旧 token
|
||||||
|
|
||||||
|
## 变更日志
|
||||||
|
|
||||||
|
| 日期 | 变更 | 提交 |
|
||||||
|
|------|------|------|
|
||||||
|
| 2026-04-21 | 移除数据脱敏中间件 (稳定化约束) | fa5ab4e |
|
||||||
|
| 2026-04-17 | E2E 测试安全链路验证通过 | — |
|
||||||
|
| 2026-04-16 | Agent 隔离修复 + Admin 权限校验 | — |
|
||||||
|
| 2026-04-13 | 安全渗透测试 V1: 15 项修复 | — |
|
||||||
|
| 2026-04-09 | CSP 加固 + JWT pwv + 账户锁定 + TOTP 解耦 | — |
|
||||||
|
|
||||||
|
### 测试覆盖
|
||||||
|
|
||||||
|
| 功能 | 测试文件 |
|
||||||
|
|------|---------|
|
||||||
|
| 认证流程 | `crates/zclaw-saas/tests/auth_test.rs` |
|
||||||
|
| 认证安全边界 | `crates/zclaw-saas/tests/auth_security_test.rs` |
|
||||||
|
| 账户安全 | `crates/zclaw-saas/tests/account_security_test.rs` |
|
||||||
|
| 权限矩阵 | `crates/zclaw-saas/tests/permission_matrix_test.rs` |
|
||||||
|
| TOTP | `crates/zclaw-saas/src/auth/totp.rs` inline tests |
|
||||||
|
| 本地加密 | `desktop/src-tauri/src/memory/crypto.rs` inline tests |
|
||||||
|
|||||||
Reference in New Issue
Block a user