41 Commits

Author SHA1 Message Date
iven
7b0d452845 fix(tool): Windows UNC 路径规范 — PathValidator 路径比较一致性
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- with_workspace() 对 workspace_root 做 canonicalize,确保与
  resolve_and_validate 产出的 canonical 路径格式一致
- 新增 normalize_windows_path() 剥离 \?\ 前缀,解决 Windows 上
  starts_with 比较失败问题
- check_blocked/check_allowed 统一使用规范化路径比较
2026-04-24 17:02:24 +08:00
iven
855c89e8fb fix(tool): 相对路径文件写入失败 — PathValidator 先基于 workspace 解析
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
当 file_write 收到相对路径如 test_tool.txt 时,PathValidator 的
resolve_and_validate 尝试对空父目录 canonicalize 导致失败。

修复:相对路径先基于 workspace_root 解析为绝对路径,再进行安全校验。
2026-04-24 16:02:09 +08:00
iven
3eb098f020 fix(runtime): 工具调用 P1/P2/P3 全面修复
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
P1: 流式模式工具并行执行
- 三阶段执行: Phase 1 中间件预检(serial) → Phase 2 并行+串行分区 → Phase 3 结果排序
- ReadOnly 工具用 JoinSet + Semaphore(3) 并行,Exclusive/Interactive 串行
- 与非流式模式保持一致的执行策略

P2: OpenAI 驱动工具参数解析
- 解析失败不再静默替换为 {},改为返回 _parse_error + _raw_args
- 让 LLM 和工具能感知参数问题并自我修正

P2: ToolOutputGuard 精确匹配
- 从 to_lowercase() 关键词匹配改为 regex 精确匹配实际密钥值
- 检测 sk-xxx(20+), AKIA(16), PEM 私钥, key=value 模式
- 移除 "system:", "you are now" 等过于宽泛的注入检测
- 消除合法内容包含 "password" 等词汇时的误拦

P2: ToolErrorMiddleware per-session 计数
- 从全局 AtomicU32 改为 Mutex<HashMap<session_id, u32>>
- 每个会话独立跟踪连续失败次数,消除跨会话误触发 AbortLoop

P3: Gateway client onTool 回调语义
- 明确 tool_call 的 output 始终为空串 (start 信号)
- 添加注释说明 start/end 语义约定
2026-04-24 12:56:07 +08:00
iven
c12b64150b fix(runtime): 工具调用 P0 修复 — after_tool_call 接入 + stream_errored 工具抢救
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
P0-1: after_tool_call 中间件从未被调用
- 流式模式(run_streaming)和非流式模式(run)均添加 middleware_chain.run_after_tool_call()
- ToolErrorMiddleware 错误计数恢复逻辑现在生效
- ToolOutputGuardMiddleware 敏感信息检测现在生效

P0-2: stream_errored 跳过所有工具执行
- 新增 completed_tool_ids 跟踪哪些工具已收到完整 ToolUseEnd
- 流式错误时区分完整工具和不完整工具
- 完整工具照常执行(产物创建等不受影响)
- 不完整工具发送取消 ToolEnd 事件(前端不再卡"执行中")
- 工具执行后若 stream_errored,break outer 阻止无效 LLM 循环

参考文档:
- docs/references/zclaw-toolcall-issues.md (10项问题分析)
- docs/references/deerflow-toolcall-reference.md (DeerFlow工具调用完整参考)
2026-04-24 12:20:14 +08:00
iven
4c31471cd6 feat(artifact): 产物系统优化 — 共享渲染 + 数据源扩展 + 持久化
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- MarkdownRenderer: 从 StreamingText 提取共享 react-markdown + remark-gfm 组件
- ArtifactPanel: 替换手写 MarkdownPreview 为完整 GFM 渲染,添加文件选择器下拉菜单
- 数据源: file_write/str_replace 双工具 + sendMessage/initStreamListener 双路径
- 持久化: artifactStore 添加 zustand persist + IndexedDB (复用 idb-storage)
2026-04-24 10:59:27 +08:00
iven
b60b96225d docs(wiki): Hermes Phase 1-4 wiki 同步
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- hands-skills: 新增 concurrency() 声明要求不变量
- log: 追加 Hermes Phase 1-4 变更记录
- index: 更新日期
2026-04-24 08:54:48 +08:00
iven
06e93a21af perf(compaction): Hermes Phase 4 — debounce + async cache + iterative summary
Step 4.1: Compaction debounce
- 30s cooldown between consecutive compactions
- Minimum 3 rounds (6 messages) since last compaction before re-triggering
- AtomicU64 lock-free state tracking

Step 4.2: Async compaction with cached fallback
- During cooldown, use cached result from previous compaction
- RwLock<Option<Vec<Message>>> for thread-safe cache access
- Cache updated after each successful compaction

Step 4.3: Iterative summary
- generate_summary/generate_llm_summary accept previous_summary parameter
- LLM prompt includes previous summary for cumulative context preservation
- Rule-based summary carries forward [上轮摘要保留] section
- previous_summary extracted from leading System messages in message history
2026-04-24 08:53:37 +08:00
iven
9060935401 perf(runtime): Hermes Phase 1-3 — prompt caching + parallel tools + smart retry
Phase 1: Anthropic prompt caching
- Add cache_control ephemeral on system prompt blocks
- Track cache_creation/cache_read tokens in CompletionResponse + StreamChunk

Phase 2A: Parallel tool execution
- Add ToolConcurrency enum (ReadOnly/Exclusive/Interactive)
- JoinSet + Semaphore(3) for bounded parallel tool calls
- 7 tools annotated with correct concurrency level
- AtomicU32 for lock-free failure tracking in ToolErrorMiddleware

Phase 2B: Tool output pruning
- prune_tool_outputs() trims old ToolResult > 2000 chars to 500 chars
- Integrated into CompactionMiddleware before token estimation

Phase 3: Error classification + smart retry
- LlmErrorKind + ClassifiedLlmError for structured error mapping
- RetryDriver decorator with jittered exponential backoff
- Kernel wraps all LLM calls with RetryDriver
- CONTEXT_OVERFLOW recovery triggers emergency compaction in loop_runner
2026-04-24 08:39:56 +08:00
iven
6d6673bf5b fix(suggest): 建议默认使用中文,不混入英文词汇
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
规则 7 从"使用与用户相同的语言"改为明确要求中文优先,
英文术语需翻译(如 workflow→工作流)。
示例同步更新为纯中文表达。
2026-04-24 00:01:22 +08:00
iven
15f84bf8c1 fix(suggest): 建议芯片去掉称谓,避免用户发送时角色错位
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
suggestion prompt 新增规则:建议会被用户直接点击发送,
因此不包含"领导/老板/老师"等称谓,改用无主语句式。
同步更新示例和关怀模板中的表达方式。
2026-04-23 23:53:07 +08:00
iven
9a313e3c92 docs(wiki): 回复效率+建议并行化优化 wiki 同步
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- middleware.md: 分波并行执行设计决策 + parallel_safe 标注 + 不变量 + 执行流
- chat.md: suggestion prefetch + 解耦 memory + prompt 重写
- log.md: 追加变更记录
- CLAUDE.md: §13 架构快照 + 最近变更
2026-04-23 23:45:28 +08:00
iven
ee5611a2f8 perf(middleware): before_completion 分波并行执行
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- MiddlewareContext 加 Clone derive, 支持并行克隆上下文
- AgentMiddleware trait 新增 parallel_safe() 默认方法 (false)
- MiddlewareChain::run_before_completion 改为分波执行:
  连续 2+ 个 parallel_safe 中间件用 tokio::spawn 并发执行,
  各自独立修改 system_prompt, 执行完成后合并贡献
- 5 个只修改 system_prompt 的中间件标记 parallel_safe:
  evolution(P78), butler_router(P80), memory(P150),
  title(P180), skill_index(P200)
- 非 parallel_safe 中间件 (compaction, dangling_tool 等) 保持串行

分波效果:
  Wave 1: evolution + butler_router → 并行 (省 ~0.5-1s)
  Wave 2: compaction → 串行 (可能修改 messages)
  Wave 3: memory + title + skill_index → 并行 (省 ~0.5-2s)
  Wave 4+: 工具/安全中间件 → 串行
2026-04-23 23:37:57 +08:00
iven
5cf7adff69 perf(chat): 回复效率 + 建议生成并行化优化
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- identity prompt 缓存: LazyLock<RwLock<HashMap>> 缓存已构建的 identity prompt,
  soul.md 更新时自动失效, 省去每次请求的 mutex + 磁盘 I/O (~0.5-1s)
- pre-conversation hook 并行化: tokio::join! 并行执行 identity build 和
  continuity context 查询, 不再串行等待 (~1-2s)
- suggestion context 预取: 流式回复期间提前启动 fetchSuggestionContext,
  回复结束时 context 已就绪 (~0.5-1s)
- 建议生成与 memory extraction 解耦: generateLLMSuggestions 不再等待
  memory extraction LLM 调用完成, 独立启动 (~3-8s)
- Path B (agent stream) 补全 context: lifecycle:end 路径使用预取 context,
  修复零个性化问题
- 上下文窗口扩展: slice(-6) → slice(-20), 每条截断 200 字符
- suggestion prompt 重写: 1 深入追问 + 1 实用行动 + 1 管家关怀,
  明确角色定位, 禁止空泛建议
2026-04-23 23:13:20 +08:00
iven
10497362bb fix(chat): 澄清问题卡片 UX 优化 — 去悬空引用 + 默认展开
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- 提示词增加 ask_clarification 引用规则,避免 LLM 在文本中生成
  "以下信息"/"比如:"等悬空引用短语
- 新增 stripDanglingClarificationRef 前端安全网,当消息包含
  ask_clarification 工具调用时自动移除末尾悬空引用
- 澄清卡片默认展开,让用户直接看到选项无需额外点击
2026-04-23 19:21:10 +08:00
iven
d7dbdf8600 docs(wiki): 动态建议智能化变更日志
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
2026-04-23 18:01:44 +08:00
iven
8c25b20fe2 feat(suggest): 更新 suggestion prompt 为混合型(2续问+1管家关怀)
- llm-service.ts: HARDCODED_PROMPTS.suggestions.system 改为混合型
  - 2条对话续问 + 1条管家关怀(痛点回访/经验复用/技能推荐)
- streamStore.ts: LLM_PROMPTS_SYSTEM 改为引用 llm-service 导出
  - 单一真相源,OTA 更新时自动生效
2026-04-23 17:58:58 +08:00
iven
87110ffdff feat(suggest): 改造 createCompleteHandler 并行化 + generateLLMSuggestions 增强
- createCompleteHandler: 记忆提取+上下文拉取 Promise.all 并行
- generateLLMSuggestions: 新增 SuggestionContext 参数,构建增强 user message
- llmSuggestViaSaaS: 删除 2s 人为延迟(并行化后不再需要)
- 变量重命名 context→conversationContext 避免与 SuggestionContext 冲突
2026-04-23 17:57:17 +08:00
iven
980a8135fa feat(suggest): 新增 fetchSuggestionContext 聚合函数 + 类型定义
- 4 路并行拉取智能上下文:用户画像、痛点、经验、技能匹配
- 500ms 超时保护 + 静默降级(失败不阻断建议生成)
- Tauri 不可用时直接返回空上下文
2026-04-23 17:54:57 +08:00
iven
e9e7ffd609 feat(intelligence): 新增 experience_find_relevant Tauri 命令 + ExperienceBrief
- 新增 ExperienceBrief 结构(痛点模式+方案摘要+复用次数)
- OnceLock 单例 + init_experience_extractor() 启动初始化
- experience_find_relevant 命令:按 agent_id + query 检索相关经验
- 注册到 invoke_handler + setup 阶段优雅降级初始化
- 新增序列化测试(10 tests PASS)
2026-04-23 17:52:33 +08:00
iven
00ebf18f23 docs(spec): 动态建议智能化设计 — 接通智能层的 Prompt 增强方案
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
发散式探讨确定方案A: 在现有建议生成流程中并行拉取4个智能上下文
(UserProfiler + 痛点 + 经验 + 技能路由),注入增强prompt。
新增1个只读Tauri命令(experience_find_relevant),消除2s人为延迟。
2026-04-23 17:16:25 +08:00
iven
aa84172ca4 refactor(panel): 移除 Agent tab — 跨会话身份由 soul.md 接管
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Agent tab 展示的信息对用户无实际作用,身份记忆已通过
soul.md → pre_conversation_hook 实现跨会话。移除 Agent tab
(简洁+专业模式),清理 ~280 行 dead code。
2026-04-23 14:51:47 +08:00
iven
1c0029001d fix(identity): agent_update 同步写入 soul.md — 跨会话名字记忆
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
config.name 更新后新会话的 system prompt 看不到名字,因为
pre_conversation_hook 只读 soul.md。现在 agent_update 在 name
变更时同步更新 soul.md(含/替换"你的名字是X"),确保下次
会话的 system prompt 包含身份信息。
2026-04-23 14:17:36 +08:00
iven
0bb526509d fix(identity): 名字检测从 memory extraction 解耦 — 502 不再阻断面板刷新
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
agent/user name 检测之前放在 extractFromConversation().then() 回调里,
memory extractor 502 时整个 .then() 跳过,名字永远不会更新。
现在名字检测独立执行,memory extraction 失败不影响面板刷新。
2026-04-23 14:01:05 +08:00
iven
394cb66311 fix(identity): 重构 agent 命名检测正则 — 覆盖"名称改为小芳"等表达
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
detectAgentNameSuggestion 从固定正则改为 trigger+extract 两步法,
10 个 trigger 模式覆盖中文/英文常见命名表达,stopWords 过滤误匹配。
同时修复 streamStore content 类型处理和 RightPanel 重复事件监听。
2026-04-23 13:13:40 +08:00
iven
b56d1a4c34 feat(chat): LLM 动态对话建议 — 替换硬编码关键词匹配
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
AI 回复结束后,将最近对话发给 LLM 生成 3 个上下文相关的后续问题,
替换原有的"继续深入分析"等泛泛默认建议。

变更:
- llm-service.ts: 添加 suggestions 提示模板 + llmSuggest() 辅助函数
- streamStore.ts: SSE 流式请求 via SaaS relay,response.text() 一次性
  读取避免 Tauri WebView2 ReadableStream 兼容问题,失败降级到关键词
- chatStore.ts: suggestionsLoading 状态镜像
- SuggestionChips.tsx: loading 骨架动画
- ChatArea.tsx: 传递 loading prop
2026-04-23 11:41:50 +08:00
iven
3e78dacef3 docs(wiki): 追加身份信号提取与持久化修复日志
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
2026-04-23 10:31:46 +08:00
iven
e64a3ea9a3 fix(identity): Agent详情面板监听Rust身份更新事件刷新名称
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
问题: Rust端post_conversation_hook写回soul.md后emit Tauri事件,
但前端RightPanel未监听该事件,导致面板不刷新。

修复: RightPanel添加zclaw:agent-identity-updated事件监听,
收到后调用updateClone更新AgentConfig.name并刷新clone列表。
2026-04-23 10:28:12 +08:00
iven
08812e541c fix(identity): 接通身份信号提取与持久化 — 对话中起名跨会话记忆
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
根因: 记忆提取管道(COMBINED_EXTRACTION_PROMPT)提取5种画像信号
但无身份信号(agent_name/user_name),不存在从对话到AgentConfig.name
或IdentityFiles的写回路径。

修复内容:
- ProfileSignals 增加 agent_name/user_name 字段
- COMBINED_EXTRACTION_PROMPT 增加身份提取指令
- parse_profile_signals 解析新字段 + 回退推断
- GrowthIntegration 存储身份信号到 VikingStorage
- post_conversation_hook 写回 soul.md + emit Tauri 事件
- streamStore 规则化检测 agent 名字并更新 AgentConfig.name
- cold-start-mapper 新增 detectAgentNameSuggestion

链路: 对话→提取→VikingStorage→hook写回soul.md→事件→前端刷新
2026-04-23 09:20:35 +08:00
iven
17a7a36608 docs(wiki): 追加 agentStore stale client 修复日志
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
2026-04-23 00:07:52 +08:00
iven
5485404c70 docs(wiki): 追加 Agent tab 数据同步修复日志
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
2026-04-22 23:04:31 +08:00
iven
a09a4c0e0a fix(agent-tab): 修复详情页 Agent tab 数据不同步问题
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- 修复 updateClone 中 role→description 字段映射错误:前端发送 role
  但 Tauri agent_update 期望 description,导致角色描述从未保存
- 修复 listClones 中 userName/userRole 数据不可用:agent_list 不
  返回 userProfile,现通过 agent_get + identity_get_file 双通道
  获取用户配置数据和动态学习数据
- 修复 userAddressing 错误使用 agent nickname 作为用户称呼方式
2026-04-22 22:59:26 +08:00
iven
62578d9df4 docs: wiki 知识库编制方法论 — 基于ZCLAW实战提炼的可复用指南
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
6条设计原则 + 5节模块模板 + 集成契约/不变量/症状导航机制
+ 维护工作流 + AI辅助开发特殊考量 + 检查清单

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:49:08 +08:00
iven
9756d9d995 docs: CLAUDE.md+log同步 — wiki重构后§3.3§8.3更新
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 22:03:38 +08:00
iven
7ba7389093 docs(wiki): Phase E+F完成 — index重构+feature-map转索引
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- index.md: 移除架构Q&A(移入模块页)+新增症状导航表 (144→101行)
- feature-map.md: 33链路详细描述→紧凑索引表 (424→60行)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:57:47 +08:00
iven
c10e50d58e docs(wiki): Phase D完成 — 6模块页重构(routing/chat/butler/hands-skills/pipeline/data-model)
- routing.md: 移除Store/lib列表+5节模板 (330→131行)
- chat.md: 添加集成契约+不变量 (180→134行)
- butler.md: 移除重复→引用memory/hands-skills (215→150行)
- hands-skills.md: 5节模板+契约+不变量 (281→170行)
- pipeline.md: 添加契约+重组 (157→154行)
- data-model.md: 添加契约+双库架构图 (181→153行)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:53:17 +08:00
iven
5d88d129d1 docs(wiki): Phase B+C完成 — middleware/saas/security/memory 5节模板重构
- middleware.md: 集成契约+3不变量+执行流 (157→136行)
- saas.md: 移除安全重复→引用security.md+Token Pool算法 (231→173行)
- security.md: 吸收saas认证内容成为安全唯一真相源 (158→199行)
- memory.md: 最大压缩363→147行+Hermes洞察提炼+4不变量

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:42:24 +08:00
iven
36612eac53 docs(wiki): Phase A完成 — hermes归档+known-issues转索引
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:33:35 +08:00
iven
b864973a54 docs(wiki): 归档 log.md 旧条目 — 保留38条,归档22条至 archive/
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:31:16 +08:00
iven
73139da57a docs: wiki重构设计spec+实施计划 — 移至 docs/wiki-restructure/
Some checks failed
CI / E2E Tests (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 21:26:51 +08:00
iven
de7d88afcc docs(spec): wiki重构设计v2 — 修复review问题(执行顺序+内容映射+契约示例)
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:04:54 +08:00
iven
8fd8c02953 docs(spec): wiki 重构设计文档 — 三级结构+集成契约+症状导航
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:00:39 +08:00
84 changed files with 7040 additions and 4114 deletions

197
CLAUDE.md
View File

@@ -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文档同步(代码提交前) #### 步骤 AWiki 同步(最高优先,代码提交前)
检查以下文档是否需要更新,有变更则立即修改: > **为什么 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 存 JWTSaaS 模式用 HttpOnly cookie - 当遇到**认证相关** → 记住 Tauri 模式用 OS keyring 存 JWTSaaS 模式用 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.

View File

@@ -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不要附加其他文本
对话内容: 对话内容:
"#; "#;

View File

@@ -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("小马"));
} }
} }

View File

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

View File

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

View File

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

View File

@@ -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("所有权"));
}
} }

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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 &parallel_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 &parallel_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
} }
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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::*;

View File

@@ -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('>', "&gt;") .replace('>', "&gt;")
} }
/// 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);
}
} }

View File

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

View File

@@ -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(&current_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 {

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 ?? '' };
}

View File

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

View File

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

View File

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

View 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 hook5 分钟缓存 |
| `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 Query5 分钟 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 diffwrite_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` |

View 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` |

View File

@@ -0,0 +1,141 @@
# ZCLAW 工具调用问题分析
> 对比 DeerFlow 工具调用系统,排查 ZCLAW 工具调用问题。
> 分析日期2026-04-24
> 更新日期2026-04-24P0+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` | 前端流式处理 |

View 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?

View File

@@ -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 })` (已有 @connectedTauri 自动注入 `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
View File

@@ -0,0 +1,360 @@
# 项目 Wiki 知识库编制方法论
> 基于 ZCLAW 项目实战经验10 crates + React 前端,~155KB wiki 重构)提炼。
> 适用于任何有 AI 辅助开发参与的中大型项目。
> **一句话总结**Wiki 只记录代码无法告诉你的东西。
---
## 一、设计原则
### 原则 1Wiki 记录"代码不能告诉你的"
| 记录在 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 个页面,就需要去重。
### 原则 5Append-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 行 | 控住 |

View 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?

View 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
```

View 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 schematool_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 | 全页面 CRUDDashboard 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 未注册到中间件链 | ✅ 已注册 @650Hermes 轨迹数据开始流入 |
| 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]]

View 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() @650Hermes 轨迹数据开始流入
- 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 行新增内容

View 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
```

View File

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

View File

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

View File

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

View File

@@ -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]] | AgentSettingskernel_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 → runtimeloop_runner → LLM Driver | | F-10 | 触发 Hand | [[hands-skills]] | LLM tool_callToolRegistry → 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 routesPostgreSQL |
| 流式事件 | 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 |

View File

@@ -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_schemaaction/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` — 系统内部 Handid=`_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** |

View File

@@ -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` — 如有冲突以代码实际为准

View File

@@ -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 schematool_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 | 全页面 CRUDDashboard 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 未注册到中间件链 | ✅ 已注册 @650Hermes 轨迹数据开始流入 |
| 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]]

View File

@@ -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-gfmArtifactPanel 复用
- **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() 返回非 Optionmessaging.rs 调用简化 - Kernel create_middleware_chain() 返回非 Optionmessaging.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 barrel25+ 消费者零改动 - saasStore.ts 缩减为 15行 re-export barrel25+ 消费者零改动
- `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() @650Hermes 轨迹数据开始流入
- 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 行新增内容

View File

@@ -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 数据库,跨库查询需使用正确连接
- 记忆注入在中间件@150AFTER ButlerRouter@80BEFORE 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-IDFembedding 未激活时退化为纯 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

View File

@@ -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` trait4个 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 有测试

View File

@@ -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** — P3Provider Key 禁用后已有任务不自动清理

View File

@@ -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 KeySaaS 维护共享 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] 仅用于本地 KernelSaaS 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) |

View File

@@ -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 KeySaaS 维护共享 Key 池RPM/TPM 轮换 |------|--------|
2. **JWT + Cookie 双通道** — Tauri 用 OS keyring 存 JWT浏览器用 HttpOnly cookie | Token Pool 集中管理 | 桌面端不持有 LLM API KeySaaS 维护共享 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)

View File

@@ -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 不负责 TLSnginx/caddy 提供 HTTPS 终止 |
| Cookie Secure 开发环境 false | 设计意图 | 开发环境 HTTP 无 Secure生产必须 true |
陷阱:
- JWT 签名密钥: `#[cfg(debug_assertions)]` 有 fallbackrelease 模式直接 `bail` 拒绝启动
- TOTP 加密密钥: 生产必须独立设置 `ZCLAW_TOTP_ENCRYPTION_KEY`,不从 JWT 密钥派生
- CORS 白名单: 生产缺失拒绝启动,不允许通配符
- Refresh Token: 单次使用logout 时撤销到 DBrotation 校验已撤销的旧 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 |