281 Commits

Author SHA1 Message Date
iven
10497362bb fix(chat): 澄清问题卡片 UX 优化 — 去悬空引用 + 默认展开
Some checks are pending
CI / Lint & TypeCheck (push) Waiting to run
CI / Unit Tests (push) Waiting to run
CI / Build Frontend (push) Waiting to run
CI / Rust Check (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / E2E Tests (push) Blocked by required conditions
- 提示词增加 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
iven
fa5ab4e161 refactor(middleware): 移除数据脱敏中间件及相关代码
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
移除不再使用的数据脱敏功能,包括:
1. 删除data_masking模块
2. 清理loop_runner中的unmask逻辑
3. 移除前端saas-relay-client.ts中的mask/unmask实现
4. 更新中间件层数从15层降为14层
5. 同步更新相关文档(CLAUDE.md、TRUTH.md、wiki等)

此次变更简化了系统架构,移除了不再需要的敏感数据处理逻辑。所有相关测试证据和截图已归档。
2026-04-22 19:19:07 +08:00
iven
14f2f497b6 docs(wiki): 记录跨会话记忆链路 + 管家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
- memory.md: 添加"跨会话记忆完整链路"章节,覆盖初始化/写入/读取/展示/数据库架构/关键文件地图
- butler.md: 添加"管家Tab记忆展示"章节,记录MemorySection数据源和组件结构
2026-04-22 19:13:08 +08:00
iven
4328e74157 docs(wiki): 更新跨会话记忆修复记录 — log/memory/known-issues
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
- log.md: 添加BUG-M6修复详情(profile_store+双数据库)
- memory.md: 已知问题新增2项已修复记录
- known-issues.md: 添加BUG-M6条目
2026-04-22 19:10:18 +08:00
iven
adf0251cb1 fix(memory): 跨会话记忆断裂修复 — profile_store连接+双数据库统一+诊断日志
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
根因: 3个断裂点
1. profile_store未连接: create_middleware_chain()中GrowthIntegration未设置
   UserProfileStore, 导致extract_combined()的profile_signals被静默丢弃
2. 双数据库不一致: UserProfileStore写入data.db, agent_get读取memories.db,
   两库隔离导致UserProfile永远读不到
3. 缺少关键日志: 提取/存储/检索链路无info级别日志, 问题难以诊断

修复:
- create_middleware_chain()中添加 with_profile_store(memory.pool())
- agent_get改为使用kernel.memory()而非viking_commands::get_storage()
- Kernel暴露memory()方法返回Arc<MemoryStore>
- growth.rs增强日志: 存储成功/失败/提取详情/profile更新数

验证: Tauri端E2E测试通过
- 会话A发送消息 → 提取6记忆+4 profile signals → 存储成功
- 新会话B发送消息 → Injected memories → LLM回复提及之前话题
- 管家Tab显示: 用户画像(医疗/健康)+近期话题+53条记忆分组
2026-04-22 19:07:14 +08:00
iven
52078512a2 feat(desktop): 管家Tab记忆展示增强 — L1摘要+类型分组+用户画像
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
MemorySection.tsx 重写:
- 并行加载 L1 摘要 (viking_read L1) 替代仅显示 URI
- 按记忆类型分组: 偏好/知识/经验/会话
- 折叠/展开每组,默认展开偏好和知识
- 新增用户画像卡片: 行业/角色/沟通风格/近期话题/常用工具
- 数据源: viking_ls + viking_read + agent_get(userProfile)
2026-04-22 18:18:32 +08:00
iven
7afd64f536 docs(wiki): 添加 DataMasking 过度匹配修复记录
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 17:26:57 +08:00
iven
73d50fda21 fix(runtime): 禁用 DataMasking 中间件 — 正则过度匹配通用中文文本
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
问题: DataMasking 中间件用正则 [^\s]{1,20}(?:公司|...) 匹配公司名,
将"有一家公司"等通用文本误判为公司实体,替换为 __ENTITY_1__ 占位符。
同时 LLM 响应路径缺少 unmask 逻辑,导致用户看到原始占位符。

修复:
- 禁用 DataMasking 中间件 (桌面端单用户场景无需脱敏)
- 在 AgentLoop 添加 data_masker + unmask 基础设施 (备用)
- 添加 unmask_text() 方法覆盖流式/非流式两条响应路径
- 保留 data_masking.rs 模块 (含改进正则和新增测试),待未来 NLP 方案启用

测试: 934 PASS, 0 FAIL
2026-04-22 17:24:46 +08:00
iven
8b3e43710b docs(wiki): 更新搜索功能修复记录 — hands-skills/feature-map/known-issues/log
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.md: Researcher 搜索能力详细说明 + 数据流 + 修复清单
- feature-map.md: 新增 F-09.5 Agent 搜索链路 (搜索引擎/网页获取/UI处理)
- known-issues.md: 搜索 04-22 P1×3 修复记录 (SEARCH-1/2/3)
- log.md: 追加 04-22 变更日志
2026-04-22 16:33:01 +08:00
iven
81005c39f9 fix(desktop): 修复搜索结果排版 — stripToolNarration 保留 markdown 结构
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
根因: stripToolNarration 按句子切分再用空格拼接,破坏了所有 markdown
格式(标题/列表/段落/代码块),导致搜索结果显示为纯文本墙。

修复: 改为按行处理,只过滤匹配叙述模式的行,保留 markdown 结构行
(标题/列表/空行/引用/代码/表格)。关键变化:
- 保留空行(markdown 段落分隔符)
- 保留以 #/-/*/数字/>/```/| 开头的结构行
- 仅过滤 LLM 内部叙述("让我执行..."、"Let me..."等)
2026-04-22 16:24:40 +08:00
iven
5816f56039 fix(runtime,hands): 搜索功能修复 — glm空参数回退+schema简化
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
根因: glm-5.1 不理解 oneOf+const 复杂 schema,发送 tool_calls 时
arguments 为空 {}。同时缺少从对话上下文提取用户意图的回退机制。

修复:
1. researcher input_schema 从 oneOf+const 改为扁平化属性 — glm 正确传参
2. loop_runner 增加 empty-input 回退 — 从最近用户消息注入 _fallback_query
3. researcher infer_action 增加 _fallback_query 分支处理
4. 调试日志降级 INFO→DEBUG (openai tool_calls delta, researcher input)
2026-04-22 16:06:47 +08:00
iven
3cb9709caf fix(runtime): SSE行缓冲 — 修复glm tool call参数截断丢失
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
根因: OpenAI driver的SSE解析直接按TCP chunk分行,
当glm的JSON响应被拆成多个TCP包时,SSE data行被截断,
导致tool call arguments丢失(input={})。

修复:
1. 添加pending_line缓冲区,跨chunk累积不完整的SSE行
2. 只处理完整的行(\n结尾),未完成的保留到下次
3. researcher.infer_action()增加更多字段推断(search/keyword/q等)

验证: 99 tests PASS, 160 hands tests PASS
2026-04-22 15:20:23 +08:00
iven
bc9537cd80 fix(hands): hand_researcher 参数容错 — LLM不传action字段时自动推断
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
根因: glm等国内LLM调用hand_researcher时不带action字段,
导致"missing field action"反复报错触发LoopGuard拦截。

修复: execute()先尝试严格反序列化,失败时调用infer_action()
从输入字段推断意图:
- 有query → search
- 有url → fetch
- 有urls → summarize
- 都没有 → 友好错误提示

验证: 160 tests PASS
2026-04-22 14:23:52 +08:00
iven
bb1869bb1b fix(hands): 搜索引擎优先级调整 — 国内用户优先百度+Bing CN
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
中国用户无法使用 Google/DuckDuckGo(被墙),调整策略:
1. Google/DuckDuckGo 路由降级到百度(非Bing)
2. search_native() 所有查询统一百度+Bing CN并行
3. DDG仅作为最后后备(主引擎都空时才尝试)
4. 移除 CJK 分支逻辑 — 百度+Bing CN 对中英文都有效
2026-04-22 14:06:20 +08:00
iven
46fee4b2c8 fix(desktop): 隐藏Hand状态消息 + 过滤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
1. 所有 role=hand 的消息不再显示 (不仅仅是 researcher)
   - "Hand: hand_researcher - running" 不再出现
   - Hand 错误 JSON 不再显示
   - 移除未使用的 PresentationContainer import

2. 添加 stripToolNarration() 过滤 LLM 推理文本
   - 英文: "Now let me...", "I need to...", "I keep getting..."
   - 中文: "让我执行...", "让我尝试使用...", "好的,让我为您..."
   - 保留实际有用内容,仅过滤工具调用叙述

验证: tsc --noEmit 零错误, vitest 343 pass (1 pre-existing fail)
2026-04-22 13:17:54 +08:00
iven
6d7457de56 fix(hands): 搜索引擎升级 — DDG改POST + Jina Reader内容提取
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
借鉴DeerFlow(ddgs库)架构改进搜索:
1. DDG搜索从GET改为POST(form-encoded),匹配ddgs库行为
2. 新增Jina Reader API(r.jina.ai)用于网页内容提取,返回干净Markdown
3. Jina失败时自动降级到原有HTML解析
4. 支持 ZCLAW_JINA_API_KEY 环境变量(可选,免费tier无需key)
5. 内容截断4096字符(DeerFlow模式)

验证: 160 tests PASS, 0 warnings, workspace check clean
2026-04-22 12:59:48 +08:00
iven
eede45b13d fix(desktop): 隐藏 researcher hand 原始JSON输出 — 搜索结果已通过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
问题:搜索时 chat 中出现 hand_researcher 原始 JSON 结果,
包含 action/query/results 等技术细节,对用户无意义。

修复:MessageBubble 对 role='hand' && handName='researcher'
的消息直接返回 null(与 role='tool' 同理静默处理)。
搜索结果已由 LLM 整合在回复中呈现,无需重复显示。
2026-04-22 12:24:44 +08:00
iven
ee56bf6087 fix(hands): 搜索结果质量过滤 — 去除JS/CSS/广告等垃圾内容
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
问题:HTML解析器提取搜索引擎页面中的导航/脚本/广告片段,
导致搜索结果混入 function()/var/stylesheet 等垃圾文本。

修复:
- 新增 is_quality_result() 过滤函数,检查 title/snippet/url 质量
- 拒绝含 JS 关键词(function/var/const/window/document)的标题
- 拒绝含 CSS 标识(.css/stylesheet)的标题
- 拒绝过短(<2)或过长(>300)的标题
- 拒绝 javascript:/data: URL
- strip_html_tags 添加空白折叠 + 更多HTML实体
- 三个解析器(DDG/Bing/百度)全部接入质量过滤

测试: 68 PASS (新增8个质量过滤测试)
2026-04-22 12:16:02 +08:00
iven
5a0c652f4f fix(hands): 审计修复 — SSRF防护/输入验证/HTTP状态检查/解析加固
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
三维度穷尽审计(安全+质量+正确性)后修复:

CRITICAL:
- execute_fetch() 添加完整 SSRF 防护(IPv4/IPv6/私有地址/云元数据/主机名黑名单)
- reqwest 重定向策略限制为3次,阻止重定向链 SSRF
- DDG HTML 解析: split("result__body") → split("class=\"result__body\"") 防误匹配
- Google 变体降级到 Bing 时添加 tracing::warn 日志

HIGH:
- ResearchQuery 输入验证: 查询≤500字符, max_results≤50, 空查询拒绝
- Cache 容量限制: 200 条目上限 + 简单淘汰
- extract_href_uddg 手动 URL 解码替换为标准 percent_decode
- 3个搜索引擎方法添加 HTTP status code 检查(429/503 不再静默)

MEDIUM:
- config.toml default_engine 从 "searxng" 改为 "auto"(Rust 原生优先)
- User-Agent 从机器人标识改为浏览器 UA,降低反爬风险
- 百度解析器从精确匹配改为 c-container 包含匹配,覆盖更多变体
- 添加 url crate 依赖

测试: 60 PASS (新增12: SSRF 5 + percent_decode 3 + 输入验证 4)
2026-04-22 12:11:35 +08:00
iven
95a05bc6dc feat(hands): Rust原生多引擎搜索 — DuckDuckGo HTML/Bing CN/百度并行聚合
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
- 用 DuckDuckGo HTML 搜索(html.duckduckgo.com)替换 Instant Answer API,获得真正搜索结果
- 新增 Bing CN 搜索(cn.bing.com),中文查询自动切换
- 新增百度搜索(baidu.com/s),中文内容覆盖
- CJK 自动检测:中文查询并行搜索 Bing+Baidu+DDG,英文查询 DDG+Bing
- 结果去重(URL) + 按相关性排序
- SearXNG 保留为可选后端,不再强制依赖 Docker
- 137 tests PASS(新增 20 个:HTML解析/CJK检测/辅助函数/引擎测试)
2026-04-22 11:41:19 +08:00
iven
0fd981905d fix(hands): 集成 SearXNG 元搜索引擎 — 替换不可用的 DuckDuckGo Instant Answer API
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
- ResearcherHand 新增 search_searxng() 方法,调用 SearXNG JSON API 聚合 70+ 搜索引擎
- SearchEngine 枚举增加 SearXNG 变体,路由逻辑按配置分发搜索后端
- Auto 模式: SearXNG 优先 → DuckDuckGo fallback
- config.toml [tools.web.search] 新增 searxng_url/searxng_timeout 配置
- docker-compose.yml 新增 SearXNG 服务容器 (searxng-config/settings.yml)
- 新增 6 个 SearXNG 相关单元测试 (响应解析/URL构造/分数归一化/配置加载)
- 验证: 124 tests PASS, workspace 0 warnings
2026-04-22 10:52:13 +08:00
iven
39a7ac3356 docs(CLAUDE): 四阶段工作法 — 先读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
§3.3 从"闭环工作法5步"改为"四阶段工作法":
- 阶段1: 理解背景(读wiki获取上下文)
- 阶段2: 制定方案(定位根因+影响范围+执行步骤)
- 阶段3: 执行+验证
- 阶段4: 提交+同步(不积压)

核心变化: 任何操作前必须先读wiki了解项目背景,
不允许跳过理解阶段直接动手。
2026-04-22 09:43:54 +08:00
iven
8691837608 fix(runtime,hands): 4项根因修复 — URL编码/Browser桩/定时解析/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
1. researcher.rs: url_encode() chars→bytes,修复中文搜索URL编码
   (U+533B→%533B 改为 UTF-8 %E5%8C%BB)
2. browser.rs: WebDriver不可用时返回明确错误而非静默成功,
   防止LLM误以为操作已完成
3. nl_schedule.rs: 新增相对延迟解析(秒后/分钟后/小时后),
   避免fallback到LLM幻觉cron
4. 4个LLM driver: 移除http1_only()防reqwest解码错误,
   超时120s→300s适配工具调用链,Anthropic裸Client::new()补全配置
2026-04-22 03:24:55 +08:00
iven
ed77095a37 docs(wiki): 系统性更新 — L0速览+L1模块标准化+L2功能链路映射(33条)
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
三层架构增强:
- L0 index.md: 用户功能清单+跨模块数据流全景图+导航树增强 (92→143行)
- L1 8个模块页标准化: 功能清单/API接口/测试链路/已知问题
  routing(252→326) chat(101→157) saas(153→230) memory(182→333)
  butler(137→179) middleware(121→159) hands-skills(218→257) pipeline(111→156)
- L1 新增2页: security.md(157行) data-model.md(180行)
- L2 feature-map.md: 33条端到端功能链路映射(408行)

维护机制: CLAUDE.md §8.3 wiki触发规则 5→9条
设计文档: docs/superpowers/specs/2026-04-21-wiki-systematic-overhaul-design.md
2026-04-21 23:48:19 +08:00
iven
58ff0bdde7 fix(kernel,desktop): Core Chain Hardening 穷尽审计 7 项修复
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
审计发现 1 CRITICAL + 4 HIGH + 4 MEDIUM + 4 LOW, 修复如下:

CRITICAL:
- TS seam 测试改为 JSON round-trip 验证 (12 测试覆盖 10 事件类型)

HIGH:
- post_conversation_hook 拦截路径 driver=None 加 debug 日志
- schedule intercept channel send 失败回退 LLM (return Ok(None))

MEDIUM:
- DeltaBuffer.flush() 先 mutation 再 clear, 防止异常丢数据
- ModelsAPI.tsx 去重: 改用 model-config.ts 导出 (消除 2 函数+1 接口+2 常量)
- boot_with_driver docstring 记录跳过 agent 恢复

TypeScript 0 错误, Rust 76 kernel 测试通过, TS 12 seam 测试通过
2026-04-21 23:30:08 +08:00
iven
27006157da refactor(desktop): connectionStore 拆分 — 模型配置提取为 lib/model-config.ts
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
- 提取 213 行模型配置逻辑到独立模块: CustomModel 接口/API Key 管理/默认模型解析
- connectionStore 通过 re-export 保持向后兼容, 外部导入无需变更
- 消除 ModelsAPI.tsx 中 loadCustomModelsBase/saveCustomModelsBase 的重复逻辑 (待后续对接)
- connectionStore 891→693 行 (-22%), model-config.ts 225 行
- TypeScript 类型检查通过
2026-04-21 23:07:15 +08:00
iven
191cc3097c refactor(desktop): streamStore sendMessage 拆分 Phase 3 — 提取 DeltaBuffer+4 Handler
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
- DeltaBuffer 类: ~60fps 文本/思考增量缓冲,替换内联 textBuffer/thinkBuffer
- createToolHandler: 工具步骤管理 (toolStart/toolEnd + artifact 自动创建)
- createHandHandler: Hand 能力消息生成
- createSubtaskHandler: 子任务状态映射
- createCompleteHandler: 完成回调 (token 统计+记忆提取+反思+建议)
- sendMessage 内联回调从 ~350 行缩减到 ~130 行 (-63%)
- TypeScript 类型检查通过, 8 个 seam 测试通过
2026-04-21 23:03:04 +08:00
iven
ae7322e610 refactor(kernel,desktop): chat.rs 瘦身 Phase 2 — 548→458行 (-16%)
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
- 提取 translate_event() 函数: LoopEvent→StreamChatEvent 翻译独立
- 提取 Kernel::try_intercept_schedule(): 调度拦截下沉到 kernel
- 新增 ScheduleInterceptResult 类型导出
- 所有缝测试 14/14 PASS,无回归

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:25:10 +08:00
iven
591af5802c test(kernel,growth): Phase 1 缝测试安全网 — 3条核心链路 19 测试全部通过
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
对话链路: 4 缝测试 (Tauri→Kernel / Kernel→LLM / LLM→UI / 流式生命周期)
Hands链路: 3 缝测试 (工具路由 / 执行回调 / 通用工具)
记忆链路: 3 缝测试 (FTS5存储 / 模式检索 / 去重)
冒烟测试: 3 Rust + 8 TypeScript 全量 PASS
- Kernel::boot_with_driver() 测试辅助方法
- 全量 cargo test 0 回归

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 21:36:46 +08:00
iven
317b8254e4 fix(growth,saas): B9 Agent创建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
深入追踪 B9 (Agent 创建 502):
- SaaS create_agent_from_template 端点代码不可能产生 502
  (SaasError::Relay 是唯一 502 来源,仅 relay 模块使用)
- 前端 createFromTemplate 双层 try-catch + fallback 已足够健壮
- 结论: B9 不可复现,可能因当时 SaaS 未运行或 token 过期导致

改进:
- handlers.rs: 添加 create_agent_from_template 请求/响应日志
- agentStore.ts: outer catch 记录 status + message 便于未来诊断
2026-04-21 21:19:44 +08:00
iven
751ec000d5 docs(specs): 核心链路硬化设计文档 — 缝测试+胶水层瘦身方案
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-21 21:04:22 +08:00
iven
c5f98beb7c fix(growth): 记忆召回跨 agent fallback — IdentityRecall 全局 scope 检索
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
B14 根因: 记忆按 agent_id 隔离存储,用户换对话/Agent 后
新 agent_id scope 下无记忆可检索,导致"我叫什么"无法召回。

修复: retrieve_broad_identity 在当前 agent 无结果时 fallback
到 retrieve_by_scope_any_agent,跨所有 agent 检索身份相关
的 preference/knowledge 记忆(用户名、工作单位等)。

影响范围: 仅 IdentityRecall 路径("我是谁"/"我叫什么"类查询),
普通 keyword 检索仍按 agent_id scope 隔离。
2026-04-21 20:39:32 +08:00
iven
b2908791f6 fix(desktop): Tauri 端找碴验证 7 项修复 — 消息泄漏/UUID暴露/错误友好化
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
B15/B11: streamStore onAgentStream 添加 activeRunId 过滤,移除降级匹配,
hand/workflow 消息追加前验证 runId 归属;chatStore 切换/新建对话时
先 cancelStream 终止旧流;ChatArea hand-execution-complete 事件
添加 isStreaming 守卫

B4/B5: ChatArea 模型列表过滤 embedding 模型,provider 设为 undefined
隐藏 UUID

B2/B3: streamStore onError 添加 formatUserError 函数,将原始 JSON
错误转换为中文友好提示

B1: SuggestionChips onSelect 延迟调用 handleSend 自动发送建议

fix(runtime): test_util.rs with_error 添加 mut self,with_stream_chunks
移除多余 mut

fix(saas): lib.rs 添加 Result/SaasError re-export
2026-04-21 20:29:47 +08:00
iven
79e7cd3446 test(growth,runtime,skills): 深度验证测试 Phase 1-2 — 20 个新测试
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
- MockLlmDriver 基础设施 (zclaw-runtime/src/test_util.rs)
- 经验闭环 E-01~06: 累积/溢出/反序列化/跨行业/并发/阈值
- Embedding 管道 EM-01~08: 路由/降级/维度不匹配/空查询/CJK/LLM Fallback/热更新
- Skill 执行 SK-01~03: 工具传递/纯 Prompt/锁竞争
2026-04-21 19:00:29 +08:00
iven
b726d0cd5e fix(growth,memory,hands): 穷尽审计后 4 项修复 — 伪造时间戳+测试覆盖+注释纠正
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
CRITICAL:
- user_profile_store: find_active_pains_since 改为 find_active_pains,
  移除无意义 .filter(|_| true),不再伪造 created_at=since

HIGH:
- daily_report: 移除虚假的 "Emits Tauri event" 注释(事件发射是调用方职责)
- daily_report: chrono::Local → chrono::Utc 一致性修复
- 新增 8 个单元测试: PainPoint 系列测试 + find_since + get_events_since

验证: zclaw-memory 54 PASS, zclaw-growth 151 PASS, zclaw-hands 5 PASS
2026-04-21 18:45:10 +08:00
iven
13507682f7 feat(growth,skills,saas,desktop): C线差异化全量实现 — C1日报+C2飞轮+C3引导
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
C3 零配置引导 (P0):
- use-cold-start.ts: 4阶段→6阶段对话驱动状态机 (idle→greeting→industry→identity→task→completed)
- cold-start-mapper.ts: 关键词行业检测 + 肯定/否定/名字提取
- cold_start_prompt.rs: Rust侧6阶段system prompt生成 + 7个测试
- FirstConversationPrompt.tsx: 动态行业卡片 + 行业任务引导 + 通用快捷操作

C1 管家日报 (P0):
- kernel注册DailyReportHand (第8个Hand)
- DailyReportPanel.tsx已存在,事件监听+持久化完整

C2 行业知识飞轮 (P1):
- heartbeat.rs: 经验缓存(EXPERIENCE_CACHE) + check_unresolved_pains增强经验感知
- heartbeat_update_experiences Tauri命令 + VikingStorage持久化
- semantic_router.rs: 经验权重boost(0.05*ln(count+1), 上限0.15) + update_experience_boosts方法
- service.rs: auto_optimize_config() 基于使用频率自动优化行业skill_priorities

验证: tsc 0 errors, cargo check 0 warnings, 7 cold_start + 5 daily_report + 1 experience_boost tests PASS
2026-04-21 18:28:45 +08:00
iven
ae56aba366 feat(hands,desktop): C线差异化 — 管家日报 + 零配置引导优化
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
C1 管家日报:
- 新增 _daily_report Hand (daily_report.rs) — 5个测试
- 增强 user_profile_store — PainPoint 结构体 + find_active_pains_since + resolve_pain
- experience_store 新增 find_since 日期范围查询
- trajectory_store 新增 get_events_since 日期范围查询
- 新增 DailyReportPanel.tsx 前端日报面板
- Sidebar 新增"日报"导航入口

C3 零配置引导:
- 修复行业卡点击后阶段推进 bug (industry_discovery → identity_setup)

验证: 940 tests PASS, 0 failures
2026-04-21 18:23:36 +08:00
iven
a43806ccc2 fix(growth,kernel,runtime): 穷尽审计后 7 项修复 — body 持久化 + embedding 死路径 + 安全加固
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
CRITICAL 修复:
- body_markdown 数据丢失: SkillManifest.body 字段 + serialize_skill_md 使用 body 替代默认内容
- embedding 检索死路径: rerank_entries 使用异步 index_entry_with_embedding + score_similarity_with_embedding (70/30 混合)
- try_write 静默丢失: pending_embedding 字段 + apply_pending_embedding() 延迟应用

IMPORTANT 修复:
- auto_mode 内存泄漏: add_pending 容量限制 100 + 溢出时丢弃最旧
- name_to_slug 空 ID: uuid fallback for empty/whitespace-only names
- compaction embedding 缺失: compaction GrowthIntegration 也接收 embedding
- kernel 未初始化警告: viking_configure_embedding warn log

验证: 934+ tests PASS, 0 failures
2026-04-21 17:27:37 +08:00
iven
5b5491a08f feat(growth,kernel,runtime): Embedding 接通 + 自学习自动化 — A线+B线 6 项实现
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线 Embedding 接通:
- A1: MemoryRetriever.set_embedding_client() + GrowthIntegration.configure_embedding()
  + Kernel.set_embedding_client() + viking_configure_embedding 传播到 Kernel
- A2: Skill 路由替换 new_tf_idf_only() 为 EmbeddingAdapter + LlmSkillFallback

B线 自学习自动化:
- B1: evolution_bridge.rs — candidate_to_manifest() (PromptOnly, disabled by default)
- B2: Kernel::generate_and_register_skill() 全链路 (LLM→parse→QualityGate→manifest→persist)
- B3: EvolutionMiddleware 双模式 (auto_mode 跳过注入, 留给 kernel 自动处理)
- B4: QualityGate 加固 (body ≥100字符 + 必须含标题 + 置信度上限 1.0)

验证: 934 tests PASS, 0 failures
2026-04-21 15:21:03 +08:00
iven
74ce6d4adc fix(growth,hands): 穷尽审计后 3 项修复 — browser 文档注释 + experience_store warn 日志 + identity 数字更正
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
- browser.rs: 过时 doc comment pending_execution → delegated_to_frontend
- experience_store.rs: merge 反序列化失败时 warn!() + fallback 覆写
- wiki/log.md: identity_patterns 43→54 更正
2026-04-21 12:46:26 +08:00
iven
ec22f0f357 docs(wiki): Phase 2 自学习闭环验证记录 — 进化引擎全链路确认
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-21 10:58:00 +08:00
iven
d95fda3b76 test(growth): 进化闭环集成测试 — 6 个 E2E 验证
验证自学习闭环完整链路:
- 4 次经验累积 → reuse_count=3 → 模式识别触发
- 低于阈值不触发 → 正确过滤
- 多模式独立跟踪 → 行业上下文保留
- SkillGenerator prompt 构建 → 包含步骤/工具/行业
- QualityGate 验证 → 通过/冲突检测
- FeedbackCollector 信任度 → 正负反馈计分

全量测试: 918 PASS, 0 FAIL
2026-04-21 10:56:05 +08:00
iven
f11ac6e434 docs(wiki,CLAUDE): Phase 0+1 突破之路修复记录 — 8 项基础链路
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-21 10:20:23 +08:00
iven
9a2611d122 fix(growth,hands,kernel,desktop): Phase 1 用户可感知修复 — 6 项断链修复
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
Phase 1 修复内容:
1. Hand 执行前端字段映射 — instance_id → runId,修复 Hand 状态追踪
2. Heartbeat 痛点感知 — PAIN_POINTS_CACHE + VikingStorage 持久化 + 未解决痛点检查
3. Browser Hand 委托消息 — pending_execution → delegated_to_frontend + 中文摘要
4. 跨会话记忆检索增强 — 扩展 IdentityRecall 模式 26→43 + 弱身份信号检测 + 低结果 fallback
5. Twitter Hand 凭据持久化 — SetCredentials action + 文件持久化 + 启动恢复
6. Browser 测试修复 — 适配新的 delegated_to_frontend 响应格式

验证: cargo check  | cargo test 912 PASS  | tsc --noEmit 
2026-04-21 10:18:25 +08:00
iven
2f5e9f1755 docs(wiki): 同步知识库 — 04-21 经验积累+Skill工具调用修复
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-21 01:12:51 +08:00
iven
c1dea6e07a fix(growth,skills,kernel): Phase 0 地基修复 — 经验积累覆盖 + Skill 工具调用
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
Bug 1: ExperienceStore store_experience() 相同 pain_pattern 因确定性 URI
直接覆盖,新 Experience reuse_count=0 重置已有积累。修复为先检查 URI
是否已存在,若存在则合并(保留原 id/created_at,reuse_count+1)。

Bug 2: PromptOnlySkill::execute() 只做纯文本 complete(),75 个 Skill
的 tools 字段是装饰性的。修复为扩展 LlmCompleter 支持 complete_with_tools,
SkillContext 新增 tool_definitions,KernelSkillExecutor 从 ToolRegistry
解析 manifest 声明的工具定义传入 LLM function calling。
2026-04-21 01:12:35 +08:00
iven
f89b2263d1 fix(runtime,kernel): HandTool 空壳修复 — 桥接到 HandRegistry 真实执行
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
B-HAND-1 修复: LLM 调用 hand_quiz/hand_researcher 等 Hand 工具后,
HandTool::execute() 原来返回假成功 JSON, 实际 Hand 并不执行.

修复方案 (沿用 SkillExecutor 模式):
- tool.rs: 新增 HandExecutor trait + ToolContext.hand_executor 字段
- hand_tool.rs: execute() 通过 context.hand_executor 分发到真实执行
- loop_runner.rs: AgentLoop 新增 hand_executor 字段 + builder + 3处 ToolContext 传递
- adapters.rs: 新增 KernelHandExecutor 桥接 HandRegistry.execute()
- kernel/mod.rs: 初始化 KernelHandExecutor + 注册到 AgentLoop
- messaging.rs: 两处 AgentLoop 构建添加 .with_hand_executor()

数据流: LLM tool call → HandTool::execute() → ToolContext.hand_executor
         → KernelHandExecutor → HandRegistry.execute() → Hand trait impl

809 tests passed, 0 failed.
2026-04-20 12:50:47 +08:00
iven
3b97bc0746 docs(wiki): 审计修复记录 — 04-20 功能链路审计 5 项修复
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-20 09:44:46 +08:00
iven
f2917366a8 fix(growth,kernel,runtime,desktop): 50 轮功能链路审计 7 项断链修复
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 修复:
- B-MEM-2: 跨会话记忆丢失 — 添加 IdentityRecall 查询意图检测,
  身份类查询绕过 FTS5/LIKE 文本搜索,直接按 scope 检索全部偏好+知识记忆;
  缓存 GrowthIntegration 到 Kernel 避免每次请求重建空 scorer
- B-HAND-1: Hands 未触发 — 创建 HandTool wrapper 实现 Tool trait,
  在 create_tool_registry() 中注册所有已启用 Hands 为 LLM 可调用工具

P1 修复:
- B-SCHED-4: 一次性定时未拦截 — 添加 RE_ONE_SHOT_TODAY 正则匹配
  "下午3点半提醒我..."类无日期前缀的同日触发模式
- B-CHAT-2: 工具调用循环 — ToolErrorMiddleware 添加连续失败计数器,
  3 次连续失败后自动 AbortLoop 防止无限重试
- B-CHAT-5: Stream 竞态 — cancelStream 后添加 500ms cancelCooldown,
  防止后端 active-stream 检查竞态
2026-04-20 09:43:38 +08:00
iven
24b866fc28 fix(growth,runtime,desktop): E2E 验证 4 项 Bug 修复
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 BUG-1: SemanticScorer CJK 分词缺失导致 TF-IDF 相似度为 0
- 新增 CJK bigram 分词: "北京工作" → ["北京","京工","工作","北京工作"]
- 非CJK文本保持原有分割逻辑
- 3 个新测试: bigram 生成 + 混合文本 + CJK 相似度>0

P1 BUG-2: streamStore lifecycle:end 未记录 token 使用量
- AgentStreamDelta 增加 input_tokens/output_tokens 字段
- lifecycle:end 处理中检查并调用 addTokenUsage

P2 BUG-3: NlScheduleParser "X点半" 解析为整点
- 所有时间正则增加可选的 (半) 捕获组
- extract_minute 辅助函数: 半 → 30

P2 BUG-4: NlScheduleParser "工作日每天" 未转为 1-5
- RE_WORKDAY_EXACT 支持 (每天|每日)? 中缀
- try_workday 优先级提升至 try_every_day 之前

E2E 报告: docs/E2E_TEST_REPORT_2026_04_19.md
测试: 806 passed / 0 failed (含 9 个新增测试)
2026-04-20 00:07:07 +08:00
iven
39768ff598 fix(growth): CJK 记忆检索 TF-IDF 阈值过高导致注入失败
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
根因: SqliteStorage.find() 对 CJK 查询使用 LIKE fallback 获取候选,
但 TF-IDF 评分因 unicode61 tokenizer 不支持 CJK 而系统性地偏低,
被默认 min_similarity=0.7 阈值全部过滤掉。

修复: 检测到 CJK 查询时将阈值降至 50%(0.35),避免所有记忆被误过滤。
2026-04-19 22:23:32 +08:00
iven
3ee68fa763 fix(desktop): Tauri 端屏蔽"已恢复连接"离线队列提示
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
Tauri 桌面端直连本地 Kernel,不存在浏览器端的离线队列场景,
"已恢复连接 + 发送中 N 条"提示对桌面用户无意义且干扰界面。

通过检测 __TAURI_INTERNALS__ 在非离线状态时返回 null,
真正离线时仍正常显示。
2026-04-19 19:17:44 +08:00
iven
891d972e20 docs: wiki/log 审计修复记录
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-19 13:46:09 +08:00
iven
e12766794b fix(relay,store): 审计修复 — 自动恢复可达化 + 类型化错误 + 全路径重连
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
C1: mark_key_429 设 is_active=FALSE,使 select_best_key 自动恢复
路径真正可达。之前 429 只设 cooldown_until,恢复代码为死代码。

H1+H2: 重试查询补全 debug 日志(RPM/TPM 跳过、解密失败)+ 修复
fallthrough 错误信息(RateLimited 而非 NotFound)。

H3+H4+M3+M4+M5: agentStore.ts 提取 classifyAgentError() 类型化错误
分类,覆盖 502/503/401/403/429/500,统一 createClone/
createFromTemplate/updateClone/deleteClone 错误处理,不再泄露原始
错误详情。所有 catch 块添加 log.error。

H5+H6: auth.ts 提取 triggerReconnect() 共享函数,login/loginWithTotp/
restoreSession 三处统一调用。状态检查改为仅 'disconnected' 时触发,
避免 connecting/reconnecting 状态下并发 connect。

M1: toggle_key_active(true) 同步清除 cooldown_until,防止管理员
激活后 key 仍被 cooldown 过滤不可见。
2026-04-19 13:45:49 +08:00
iven
d9f8850083 docs: wiki/log 更新发布前审计 5 项修复记录
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-19 13:28:05 +08:00
iven
0bd50aad8c fix(heartbeat,skills): 健康快照降级处理 + 技能加载重试
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-3: health_snapshot 在 heartbeat engine 未初始化时不再报错,
返回 pending 状态快照,避免 HealthPanel 竞态报错。

P1-1: loadSkillsCatalog 新增 Path C 延迟重试(最多2次,间隔
1.5s/3s),解决 kernel 初始化未完成时 skills 返回空数组的问题。
2026-04-19 13:27:25 +08:00
iven
4ee587d070 fix(relay,store): Provider Key 自动恢复 + 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
P0-1: key_pool.rs 新增 cooldown 过期 Key 自动恢复逻辑。
当所有 Key 的 is_active=false 且 cooldown_until 已过期时,
自动重新激活并重试选择,避免 relay/models 返回空数组导致聊天失败。

P0-2: agentStore.ts createClone/createFromTemplate 错误信息
从原始 HTTP 错误改为可操作的中文提示(502/503/401 分类处理)。

P1-2: auth.ts login 成功后触发 connectionStore.connect(),
确保 kernel 使用新 JWT 而非旧 token。
2026-04-19 13:16:12 +08:00
iven
8b1b08be82 docs: sync TRUTH.md + wiki/log for Batch 3/8 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
TRUTH.md: update date, add workspace test count 797
wiki/log.md: append 2026-04-19 entry for sqlx upgrade + test coverage
2026-04-19 11:26:24 +08:00
iven
beeb529d8f test(protocols,skills): add 90 tests for MCP types + skill loader/runner
zclaw-protocols: +43 tests covering mcp_types serde, ContentBlock
variants, transport config builders, and domain type roundtrips.

zclaw-skills: +47 tests covering SKILL.md/TOML parsing, auto-classify,
PromptOnlySkill execution, and SkillManifest/SkillResult roundtrips.

Batch 8 of audit plan (plans/stateless-petting-rossum.md).
2026-04-19 11:24:57 +08:00
iven
226beb708b Merge branch 'chore/sqlx-0.8-upgrade'
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
sqlx 0.7→0.8 unified, resolves dual-version from pgvector.
2026-04-19 11:15:17 +08:00
iven
dc7a1d5400 chore(deps): upgrade sqlx 0.7→0.8 + libsqlite3-sys 0.27→0.30
Unifies dual sqlx versions caused by pgvector 0.4 pulling sqlx 0.8.x
as indirect dependency. Zero source code changes required, 719/719
tests pass.

Batch 3 of audit plan (plans/stateless-petting-rossum.md).
2026-04-19 11:15:05 +08:00
iven
d9b0b4f4f7 fix(audit): Batch 7-9 dead_code 标注 + TODO 清理 + 文档同步
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
Batch 7: dead_code 标注统一 (16 处)
- crates/ 9 处: growth, kernel, pipeline, runtime, saas, skills
- src-tauri/ 7 处: classroom, intelligence, browser, mcp
- 统一格式: #[allow(dead_code)] // @reserved: <原因>

Batch 7+: EvolutionEngine L2/L3 10 个未使用 pub 函数
- 全部标注 @reserved: EvolutionEngine L2/L3, post-release integration

Batch 9: TODO → FUTURE 标记 (4 处)
- html.rs: template-based export
- nl_schedule.rs: LLM-assisted parsing
- knowledge/handlers.rs: category_id from upload
- personality_detector.rs: VikingStorage persistence

Batch 5+: Cargo.lock 更新 (serde_yaml_bw 迁移)

全量测试通过: 719 passed, 0 failed
2026-04-19 08:54:57 +08:00
iven
edd6dd5fc8 fix(audit): Batch 4-6 中间件注释 + 依赖迁移 + 安全加固
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
Batch 4:
- kernel/mod.rs: 添加中间件注册顺序≠执行顺序注释
- EvolutionMiddleware 注册处标注 priority=78

Batch 5:
- desktop/src-tauri/Cargo.toml: serde_yaml 0.9 (deprecated) → serde_yaml_bw 2.x

Batch 6:
- saas/main.rs: CORS 开发模式改为显式 localhost origins (修复 Any+credentials 违规)
- docker-compose.yml: 移除默认弱密码 your_secure_password,改为必填校验
- director.rs: 用户输入添加 <user_input>/<user_request> 边界标记防注入

全量测试通过: 719 passed, 0 failed
2026-04-19 08:46:12 +08:00
iven
4329bae1ea fix(audit): Batch 2 生产代码 unwrap 替换 (20 处)
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 修复:
- viking_commands.rs: URI 路径构建 unwrap → ok_or_else 错误传播
- clip.rs: 临时文件路径 unwrap → ok_or_else (防 Windows 中文路径 panic)

P1 修复:
- personality_detector.rs: Mutex lock unwrap → unwrap_or_else 防中毒传播
- pptx.rs: HashMap.get unwrap → expect (来自 keys() 迭代)

P2 修复:
- 4 处 SystemTime.unwrap → expect("system clock is valid")
- 4 处 dev_server URL.parse.unwrap → expect("hardcoded URL is valid")
- 9 处 nl_schedule Regex.unwrap → expect("static regex is valid")
- 5 处 data_masking Regex.unwrap → expect("static regex is valid")
- 2 处 pipeline/state Regex.unwrap → expect("static regex is valid")

全量测试通过: 719 passed, 0 failed
2026-04-19 08:38:09 +08:00
iven
924ad5a6ec fix(audit): Batch 0-1 文档校准 + let _ = 静默错误修复
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
Batch 0:
- TRUTH.md 中间件层 14→15 (补 EvolutionMiddleware@78)
- wiki/middleware.md 同步 15 层 + 优先级分类更新
- Store 数字确认 25 个

Batch 1:
- approvals.rs: 3 处 map_err+let _ = 简化为 if let Err
- director.rs: oneshot send 失败添加 debug 日志
- task.rs: 4 处子任务状态更新添加 debug 日志
- chat.rs: 流消息发送和事件 emit 添加 warn/debug 日志
- heartbeat.rs: 告警广播添加 debug 日志 + break 优化

全量测试通过: 719 passed, 0 failed
2026-04-19 08:30:33 +08:00
iven
e94235c4f9 fix(growth): Evolution Engine 穷尽审计 3CRITICAL + 3HIGH 全部修复
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
C-01: ExperienceExtractor 接入 ExperienceStore
- GrowthIntegration.new() 创建 ExperienceExtractor 时注入 ExperienceStore
- 经验持久化路径打通:extract_combined → persist_experiences → ExperienceStore

C-02+C-03: 进化触发链路全链路接通
- create_middleware_chain() 注册 EvolutionMiddleware (priority 78)
- MemoryMiddleware 持有 Arc<EvolutionMiddleware> 共享引用
- after_completion 中调用 check_evolution() → 推送 PendingEvolution
- EvolutionMiddleware 在下次对话前注入进化建议到 system prompt

H-01: FeedbackCollector loaded 标志修复
- load() 失败时保留 loaded=false,下次 save 重试
- 日志级别 debug → warn

H-03: FeedbackCollector 内部可变性
- EvolutionEngine.feedback 改为 Arc<Mutex<FeedbackCollector>>
- submit_feedback() 从 &mut self → &self,支持中间件 &self 调用路径
- GrowthIntegration.initialize() 从 &mut self → &self

H-05: 删除空测试 test_parse_empty_response (无 assert)

H-06: infer_experiences_from_memories() fallback
- Outcome::Success → Outcome::Partial (反映推断不确定性)
2026-04-19 00:43:02 +08:00
iven
72b3206a6b fix(growth): AUD-11 反馈信任度启动集成 + AUD-12 日志格式优化
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
AUD-11: FeedbackCollector 内部 lazy-load 机制
- save() 首次调用自动从持久化存储加载信任度记录
- load() 使用 or_insert 策略,不覆盖内存中较新记录
- GrowthIntegration.initialize() 保留为可选优化入口
- 移除无法在 &self 中间件中使用的 ensure_feedback_loaded

AUD-12: 日志格式优化
- ProfileSignals 新增 signal_count() 方法
- extractor.rs 使用 signal_count() 替代 has_any_signal() as usize
2026-04-19 00:15:50 +08:00
iven
0fd78ac321 fix: 全面审计修复 — P0 功能缺陷 + P1 代码质量
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 功能修复:
- stats: Admin V2 仪表盘 API 路径修正 (/stats/dashboard → /admin/dashboard)
- mcp: 桌面端 MCP 插件增加 isTauriRuntime() 守卫,避免浏览器模式崩溃
- admin: 侧边栏高亮逻辑修复 (startsWith → 精确匹配+子路径)

P1 代码质量:
- 删除 workflowBuilderStore.ts 死代码 (456行,零引用)
- sqlite.rs 3 处 SQL 静默失败改为 tracing::warn! 日志
- mcp_tool_adapter 2 处 unwrap 改为安全回退
- orchestration_execute 添加 @reserved 标注
- TRUTH.md 测试数字校准 (734→803),Store 数 26→25
2026-04-18 23:57:03 +08:00
iven
ab4d06c4d6 fix(growth): 审计修复 — CRITICAL 编译错误 + LOW 静默数据丢失
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
- CRITICAL: extraction_adapter.rs extract_with_prompt() 使用不存在的
  zclaw_types::Error::msg(),改为 ZclawError::InvalidInput/ZclawError::LlmError
- LOW: feedback_collector.rs save() 中 serde_json::to_string().unwrap_or_default()
  改为显式错误处理 + warn 日志 + continue,避免静默存空数据
2026-04-18 23:30:58 +08:00
iven
1595290db2 fix(growth): MEDIUM-12 ProfileUpdater 补齐 5 维度画像更新
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
根因: ProfileUpdater 只处理 industry 和 communication_style 2/5 维度,
跳过 recent_topic、pain_point、preferred_tool。

修复:
- ProfileFieldUpdate 添加 kind 字段 (SetField | AppendArray)
- collect_updates() 现在处理全部 5 个维度:
  - industry, communication_style → SetField (直接覆盖)
  - recent_topic, pain_point, preferred_tool → AppendArray (追加去重)
- growth.rs 根据 ProfileUpdateKind 分派到不同的 UserProfileStore 方法:
  - SetField → update_field()
  - AppendArray → add_recent_topic() / add_pain_point() / add_preferred_tool()
- ProfileUpdateKind re-exported from lib.rs

测试: test_collect_updates_all_five_dimensions 验证 5 个维度 + 2 种更新类型
2026-04-18 23:07:31 +08:00
iven
2c0602e0e6 fix(growth): HIGH-3 FeedbackCollector 信任度持久化
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
根因: FeedbackCollector 用纯内存 HashMap 存储信任度记录,重启后归零。

修复:
- FeedbackCollector 添加 viking: Option<Arc<VikingAdapter>> 字段
- 添加 with_viking() 构造器
- 添加 save(): 遍历 trust_records → MemoryEntry → VikingAdapter 存储
- 添加 load(): find_by_prefix 反序列化回 HashMap
- EvolutionEngine::new()/from_experience_store() 传入 VikingAdapter
- submit_feedback() 改为 async,提交后自动调用 save()
- 添加 load_feedback() 供启动时恢复

测试: save_and_load_roundtrip + load_without_viking + save_without_viking
2026-04-18 23:03:31 +08:00
iven
f358f14f12 fix(growth): 穷尽审计修复 — tracker timeline 断链 + 文档更新
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-1: tracker.rs record_learning 改为通过 MemoryEntry 存储
      (之前用 store_metadata 存但 get_timeline 用 find_by_prefix 读,
       两条路径不交叉,timeline 永远返回空)

P2-4: extractor.rs 移除未使用的 _llm_driver 绑定,改为 is_none() 检查

P2-5: lib.rs 模块文档更新,反映实际 17 个模块而非原始 4 个

profile_updater.rs: 添加注释说明只收集 update_field 支持的字段

测试: zclaw-growth 137 tests, zclaw-runtime 87 tests, 0 failures
2026-04-18 23:01:04 +08:00
iven
7cdcfaddb0 fix(growth): MEDIUM-10 Experience 添加 tool_used 字段
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
根因: Experience 结构体没有 tool_used 字段,PatternAggregator 从
context 字段提取工具名(语义混淆),导致工具信息不准确。

修复:
- experience_store.rs: Experience 添加 tool_used: Option<String> 字段
  (#[serde(default)] 兼容旧数据),Experience::new() 初始化为 None
- experience_extractor.rs: persist_experiences() 从 ExperienceCandidate
  的 tools_used[0] 填充 tool_used,同时填充 industry_context
- pattern_aggregator.rs: 改用 tool_used 字段提取工具名,不再误用 context
- store_experience() 将 tool_used 加入 keywords 提升搜索命中率
2026-04-18 22:58:47 +08:00
iven
3c6581f915 fix(growth): HIGH-6 修复 extract_combined 合并提取空壳
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
根因: growth.rs 构造 CombinedExtraction 时硬编码 experiences: Vec::new()
和 profile_signals: default(),导致 L1 结构化经验不被提取、L2 技能进化
没有输入数据、整个进化引擎无法端到端工作。

修复:
- extractor.rs: 添加 COMBINED_EXTRACTION_PROMPT 统一 prompt,单次 LLM 调用
  同时输出 memories + experiences + profile_signals
- extractor.rs: 添加 parse_combined_response() 解析 LLM JSON 响应
- LlmDriverForExtraction trait: 添加 extract_with_prompt() 方法(默认不支持,
  退化到现有 extract() + 启发式推断)
- MemoryExtractor: 添加 extract_combined() 方法,优先单次调用,失败则退化
- growth.rs: extract_combined() 使用新的合并提取替代硬编码空值
- TauriExtractionDriver: 实现 extract_with_prompt()
- ProfileSignals: 添加 has_any_signal() 方法
- types.rs: ProfileSignals 无 structural 变化(字段已存在)

测试: 4 个新测试(parse_combined_response_full/minimal/invalid +
extract_combined_fallback),11 个 extractor 测试全部通过
2026-04-18 22:56:42 +08:00
iven
cb727fdcc7 fix(growth): 二次审计修复 — 6项 CRITICAL/HIGH/MEDIUM 全部修复
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
CRITICAL-1/2: json_utils 花括号匹配改为括号平衡算法
  - 处理字符串字面量中的花括号和转义引号
  - 新增 5 个测试(平衡匹配、字符串内花括号、转义引号、extract_string_array)

HIGH-4: EvolutionMiddleware 只取第一个事件(remove(0)),不丢弃后续
HIGH-5: EvolutionMiddleware 先 read() 判空再 write(),减少锁竞争
HIGH-7: from_experience_store 使用传入 store 的 viking 实例(不再忽略参数)
  - ExperienceStore 新增 viking() getter

MEDIUM-9: skill_generator + workflow_composer JSON 数组解析去重
  - 新增 json_utils::extract_string_array() 共享函数
MEDIUM-14: EvolutionMiddleware 注入文本去除多余缩进空格

测试: zclaw-growth 133 tests, zclaw-runtime 87 tests, workspace 0 failures
2026-04-18 22:30:10 +08:00
iven
a9ea9d8691 fix(growth): Evolution Engine 审计修复 — 7项全部完成
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
HIGH-1: 提取共享 json_utils.rs,skill_generator/workflow_composer 去重
HIGH-2: FeedbackCollector Vec→HashMap,消除 unwrap() panic 风险
HIGH-3: ProfileUpdater 改为 collect_updates() 返回字段列表,
        growth.rs 直接 async 调用 update_field(),不再用 no-op 闭包
MEDIUM-1: EvolutionMiddleware 注入后自动 drain,防止重复注入
MEDIUM-2: PatternAggregator tools 提取改为直接收集 context 值
MEDIUM-3: evolution_engine.rs 移除 4 个未使用 imports
MEDIUM-4: workflow_composer parse_response pattern 参数加下划线
MEDIUM-7: SkillCandidate 添加 version 字段(默认=1)

测试: zclaw-growth 128 tests, zclaw-runtime 86 tests, workspace 0 failures
2026-04-18 22:15:43 +08:00
iven
f97e6fdbb6 feat: Evolution Engine Phase 3-5 — WorkflowComposer+FeedbackCollector+EvolutionMiddleware+反馈闭环
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
Phase 3:
- EvolutionMiddleware (priority 78): 管家对话中注入进化确认提示
- GrowthIntegration.check_evolution() API 串入

Phase 4:
- WorkflowComposer: 轨迹工具链模式聚类 + Pipeline YAML prompt 构建 + JSON 解析
- EvolutionEngine.analyze_trajectory_patterns() L3 入口

Phase 5:
- FeedbackCollector: 反馈信号收集 + 信任度管理 + 推荐(Optimize/Archive/Promote)
- EvolutionEngine 反馈闭环方法: submit_feedback/get_artifacts_needing_optimization

新增 12 个测试(111→123),全 workspace 701 测试通过。
2026-04-18 21:27:59 +08:00
iven
7d03e6a90c feat(runtime): GrowthIntegration 串入 EvolutionEngine — L2 触发检查 API 2026-04-18 21:17:48 +08:00
iven
415abf9e66 feat(growth): L2 技能进化核心 — PatternAggregator+SkillGenerator+QualityGate+EvolutionEngine
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
- PatternAggregator: 经验模式聚合,找出 reuse_count>=threshold 的可固化模式
- SkillGenerator: LLM prompt 构建 + JSON 解析 + 自动提取 JSON 块
- QualityGate: 置信度/冲突/格式质量门控
- EvolutionEngine: 中枢调度器,协调 L2 触发检查+技能生成+质量验证

新增 24 个测试(87→111),全 workspace 0 error。
2026-04-18 21:09:48 +08:00
iven
8d218e9ab9 feat(runtime): GrowthIntegration 串入 ExperienceExtractor + ProfileUpdater
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-18 21:01:04 +08:00
iven
e2d44ecf52 feat(growth): ExperienceExtractor + ProfileUpdater — 结构化经验提取和画像增量更新 2026-04-18 20:51:17 +08:00
iven
8ec6ca5990 feat(growth): 扩展 LlmDriverForExtraction — 新增 extract_combined_all 默认实现 2026-04-18 20:48:09 +08:00
iven
7e8eb64c4a feat(growth): 新增 Evolution Engine 核心类型 — ExperienceCandidate/CombinedExtraction/EvolutionEvent 2026-04-18 20:47:30 +08:00
iven
e88c51fd85 docs(wiki): 发布前审计数值校准 — TRUTH/CLAUDE/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
TRUTH.md:
- #[test] 433→425, #[tokio::test] 368→309 (2026-04-18 验证)
- Zustand Store 21→26, Admin V2 页面 15→17
- Pipeline YAML 17→18
- Hands 启用 9→7 (6 HAND.toml + _reminder),Whiteboard/Slideshow/Speech 标注开发中

CLAUDE.md §6:
- Hands 12 个能力包 (7 注册 + 3 开发中 + 2 禁用)
- §13 架构快照同步

wiki/index.md:
- 关键数字同步更新
2026-04-18 14:09:47 +08:00
iven
e10549a1b9 fix: 发布前审计 Batch 2 — Debug遮蔽 + unwrap + 静默吞错 + MCP锁 + 索引 + Config验证
安全:
- LlmConfig 自定义 Debug impl,api_key 显示为 "***REDACTED***"
- tsconfig.json 移除 ErrorBoundary.tsx 排除项(安全关键组件)
- billing/handlers.rs Response builder unwrap → map_err 错误传播
- classroom_commands/mod.rs db_path.parent().unwrap() → ok_or_else

静默吞错:
- approvals.rs 3处 warn→error(审批状态丢失是严重事件)
- events.rs publish() 添加 Event dropped debug 日志
- mcp_transport.rs eprintln→tracing::warn (僵尸进程风险)
- zclaw-growth sqlite.rs 4处迁移:区分 duplicate column name 与真实错误

MCP Transport:
- 合并 stdin+stdout 为单一 Mutex<TransportHandles>
- send_request write-then-read 原子化,防止并发响应错配

数据库:
- 新迁移 20260418000001: idx_rle_created_at + idx_billing_sub_plan + idx_ki_created_by

配置验证:
- SaaSConfig::load() 添加 jwt_expiration_hours>=1, max_connections>0, min<=max
2026-04-18 14:09:36 +08:00
iven
f3fb5340b5 fix: 发布前审计 Batch 1 — Pipeline 内存泄漏/超时 + Director 死锁 + Rate Limit Worker
Pipeline executor:
- 添加 cleanup() 方法,MAX_COMPLETED_RUNS=100 上限淘汰旧记录
- 每步执行添加 tokio::time::timeout(使用 PipelineSpec.timeout_secs,默认 300s)
- Delay ms 上限 60000,超出 warn 并截断

Director send_to_agent:
- 重构为 oneshot::channel 响应模式,避免 inbox + pending_requests 锁竞争
- 添加 ensure_inbox_reader() 独立任务分发响应到对应 oneshot sender

cleanup_rate_limit Worker:
- 实现 Worker body: DELETE FROM rate_limit_events WHERE created_at < NOW() - INTERVAL '1 hour'

651 tests passed, 0 failed
2026-04-18 14:09:16 +08:00
iven
35a11504d7 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-18 09:24:25 +08:00
iven
450569dc88 fix: 审计后续 3 项修复 — 残留清理 + FTS5 CJK + HTTP 大小限制
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
1. Shell Hands 残留清理 (3处):
   - message.rs: 移除过时的 zclaw_hands::slideshow 注释
   - user_profiler.rs: slideshow 偏好改为 RecentTopic
   - handStore.test.ts: 移除 speech mock 数据 (3→2)

2. zclaw-growth FTS5 CJK 查询修复:
   - sanitize_fts_query CJK 路径从精确短语改为 token OR 组合
   - "Rust 编程" → "rust" OR "编程" (之前是 "rust 编程" 精确匹配)
   - 修复 test_memory_lifecycle + test_semantic_search_ranking

3. WASM HTTP 响应大小限制:
   - Content-Length 预检 + 读取后截断 (1MB 上限)
   - read_to_string 改为显式错误处理

651 测试全通过,0 失败。
2026-04-18 09:23:58 +08:00
iven
3a24455401 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-18 09:11:37 +08:00
iven
4e4eefdde1 fix: 深度审计修复 — WASM 安全加固 + A2A 编译路径 + 测试编译
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
CRITICAL:
- zclaw_file_read: 路径遍历修复 — 组件级过滤替代前缀检查
- zclaw_http_fetch: SSRF 防护 — URL scheme 白名单 + 私有IP段阻止
- Phase 4A: 移除 zclaw-protocols a2a feature gate, A2A 始终编译
- 移除 kernel/desktop multi-agent feature (不再控制任何代码)

MEDIUM:
- user_profiler: FactCategory cfg(test) 导入修复 (563 测试全通过)
2026-04-18 09:11:15 +08:00
iven
0522f2bf95 docs: CLAUDE.md 架构快照更新 — 记忆管道 E2E + 最近变更排序
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-18 08:18:39 +08:00
iven
04f70c797d docs(wiki): Phase 4A/4B 记录 — multi-agent gate 移除 + WASM host 函数
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-18 08:18:28 +08:00
iven
a685e97b17 feat(skills): WASM host 函数真实实现 — zclaw_log/http_fetch/file_read (Phase 4B)
替换 stub 为真实实现:
- zclaw_log: 读取 guest 内存并 log
- zclaw_http_fetch: ureq v3 同步 GET (10s timeout, network_allowed 守卫)
- zclaw_file_read: 沙箱 /workspace 目录读取 (路径校验防逃逸)
添加 ureq v3 workspace 依赖, 25 测试全通过。
2026-04-18 08:18:08 +08:00
iven
2037809196 refactor(kernel): 移除 multi-agent feature gate — 33处 cfg 全部删除 (Phase 4A)
8 个文件移除 #[cfg(feature = "multi-agent")],zclaw-kernel default features
新增 multi-agent。A2A 路由、agents、adapters 现在始终编译。
2026-04-18 08:17:58 +08:00
iven
eaa99a20db feat(ui): Feature Gates 设置页 — 实验性功能开关 (Phase 3B)
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
新增 Settings > 实验性功能 页面:
- 3 个开关: 多 Agent 协作 / WASM 技能沙箱 / 详细工具输出
- localStorage 持久化 + isFeatureEnabled() 公共 API
- 实验性功能警告横幅
- 当前为前端运行时开关,未来可对接 Kernel config
2026-04-18 08:05:06 +08:00
iven
a38e91935f docs(wiki): Phase 3A loop_runner 双路径合并记录
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-17 21:56:34 +08:00
iven
5687dc20e0 refactor(runtime): loop_runner 双路径合并 — 统一走 middleware chain (Phase 3A)
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_chain 从 Option<MiddlewareChain> 改为 MiddlewareChain:
- 移除 6 处 use_middleware 分支 + 2 处 legacy loop_guard inline path
- 移除 loop_guard field + Mutex import + circuit_breaker_triggered 变量
- 空 chain (Default) 行为等价于 middleware path 中的 no-op
- 1154行 → 1023行,净减 131 行
- cargo check --workspace ✓ | cargo test ✓ (排除 desktop 预存编译问题)
2026-04-17 21:56:10 +08:00
iven
21c3222ad5 docs(wiki): Phase 2A Pipeline 解耦记录
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-17 20:10:34 +08:00
iven
5381e316f0 refactor(pipeline): 移除空的 zclaw-kernel 依赖 (Phase 2A)
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
Pipeline 代码中无任何 zclaw_kernel 引用,依赖声明是遗留物。
移除后编译验证通过: cargo check --workspace --exclude zclaw-saas ✓
2026-04-17 20:10:21 +08:00
iven
96294d5b87 docs(wiki): Phase 2B saasStore 拆分记录
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-17 20:05:57 +08:00
iven
e3b6003be2 refactor(store): saasStore 拆分为子模块 (Phase 2B)
1025行单文件 → 5个文件 + barrel re-export:
- saas/types.ts (103行) — 类型定义
- saas/shared.ts (93行) — Device ID、常量、recovery probe
- saas/auth.ts (362行) — 登录/注册/登出/恢复/TOTP
- saas/billing.ts (84行) — 计划/订阅/支付
- saas/index.ts (309行) — Store 组装 + 连接/模板/配置
- saasStore.ts (15行) — re-export barrel(外部零改动)

所有 25+ 消费者 import 路径不变,`tsc --noEmit` ✓
2026-04-17 20:05:43 +08:00
iven
f9f5472d99 docs(wiki): Phase 5 移除空壳 Hand 记录
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-17 19:56:32 +08:00
iven
cb9e48f11d refactor(hands): 移除空壳 Hand — Whiteboard/Slideshow/Speech (Phase 5)
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
删除 3 个仅含 UI 占位的 Hand,清理 Rust 实现与前端引用:
- Rust: whiteboard.rs(422行) + slideshow.rs(797行) + speech.rs(442行)
- 前端: WhiteboardCanvas + SlideshowRenderer + speech-synth + 相关类型/常量
- 配置: 3 个 HAND.toml
- 净减 ~5400 行,Hands 9→6(启用) + Quiz/Browser/Researcher/Collector/Clip/Twitter/Reminder
2026-04-17 19:55:59 +08:00
iven
14fa7e150a docs(wiki): Phase 1 错误体系重构记录
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-17 19:38:47 +08:00
iven
f9290ea683 feat(types): 错误体系重构 — ErrorKind + error code + Serialize
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 (crates/zclaw-types/src/error.rs):
- 新增 ErrorKind enum (17 种) + Serde Serialize/Deserialize
- 新增 error_codes 模块 (稳定错误码 E4040-E5110)
- ZclawError 新增 kind() / code() 方法
- 新增 ErrorDetail struct + Serialize impl
- 保留所有现有变体和构造器 (零破坏性)
- 新增 12 个测试: kind 映射 + code 稳定性 + JSON 序列化

TypeScript (desktop/src/lib/error-types.ts):
- 新增 RustErrorKind / RustErrorDetail 类型定义
- 新增 tryParseRustError() 结构化错误解析
- 新增 classifyRustError() 按 ErrorKind 分类
- classifyError() 优先解析结构化错误,fallback 字符串匹配
- 17 种 ErrorKind → 中文标题映射

验证: cargo check ✓ | tsc ✓ | 62 zclaw-types tests ✓
2026-04-17 19:38:19 +08:00
iven
0754ea19c2 docs(wiki): Phase 0 修复记录 — 流式事件/CI/中文化
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-17 18:13:43 +08:00
iven
2cae822775 fix: Phase 0 阻碍项修复 — 流式事件错误处理 + CI 排除 + UI 中文化
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
BLK-2: loop_runner.rs 22 处 let _ = tx.send() 全部替换为
if let Err(e) { tracing::warn!(...) },修复流式事件静默丢失问题

BLK-5: 50+ 英文字符串翻译为中文
- HandApprovalModal.tsx (~40处): 风险标签/按钮/状态/表单标签
- ChatArea.tsx: Thinking.../Sending...
- AuditLogsPanel.tsx: 空状态文案
- HandParamsForm.tsx: 空列表提示
- CreateTriggerModal.tsx: 成功提示
- MessageSearch.tsx: 时间筛选/搜索历史

BLK-6: CI/Release workflow 添加 --exclude zclaw-saas
- ci.yml: clippy/test/build 三个步骤
- release.yml: test 步骤

验证: cargo check ✓ | tsc --noEmit ✓
2026-04-17 18:12:42 +08:00
iven
93df380ca8 docs(wiki): BUG-M4/L1 已修复 + 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
- BUG-M4 标记为已修复 (admin_guard_middleware)
- BUG-L1 标记为已验证修复 (代码已统一为 pain_seed_categories)
- E2E 04-17 MEDIUM/LOW 全部关闭
- butler.md/log.md: pain_seeds → pain_seed_categories
2026-04-17 11:46:04 +08:00
iven
90340725a4 fix(saas): admin_guard_middleware — 非 admin 用户统一返回 403
BUG-M4 修复: 之前非 admin 用户发送 malformed body 到 admin 端点时,
Axum 先反序列化 body 返回 422,绕过了权限检查。

- 新增 admin_guard_middleware (auth/mod.rs) 在中间件层拦截
- account::admin_routes() 拆分 (dashboard 独立)
- billing::admin_routes() + account::admin_routes() 加 guard layer
- 非 admin 用户无论 body 是否合法,统一返回 403
2026-04-17 11:45:55 +08:00
iven
b2758d34e9 docs(wiki): 添加 04-17 回归验证记录 — 13/13 PASS
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
- Phase 1: 6 项 bug 修复回归全部 PASS (H1/H2/M1/M2/M3/M5)
- Phase 2: Pipeline + Skill 子系统链路全部 PASS
- Phase 3: Butler + 记忆联动全部 PASS
- BUG-L2 Pipeline 反序列化已验证修复
- 记忆系统 381 条记忆, 12 agent 隔离正常
2026-04-17 10:45:49 +08:00
iven
a504a40395 fix: 7 项 E2E Bug 修复 — Dashboard 404 / 记忆去重 / 记忆注入 / invoice_id / 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
P0:
- BUG-H1: Dashboard 路由 /api/v1/stats/dashboard → /api/v1/admin/dashboard

P1:
- BUG-H2: viking_add 预检查 content_hash 去重,返回 "deduped" 状态;SqliteStorage 启动时回填已有条目 content_hash
- BUG-M5: saas-relay-client 发送前调用 viking_inject_prompt 注入跨会话记忆

P2:
- BUG-M1: PaymentResult 添加 invoice_id 字段,query_payment_status 返回 invoice_id
- BUG-M2: UpdatePromptRequest 添加内容字段,更新时自动创建新版本并递增 current_version
- BUG-M3: viking_find scope 参数文档化(设计行为,调用方需传 agent scope)
- BUG-M4: Dashboard 路由缺失已修复,handler 层 require_admin 已正确返回 403

P3 (确认已修复/非代码问题):
- BUG-L1: pain_seed_categories 已统一,无 pain_seeds 残留
- BUG-L2: pipeline_create 参数格式正确,E2E 测试方法问题
2026-04-17 03:31:06 +08:00
iven
1309101a94 fix(ui): Agent 面板信息不随对话更新 — 事件时序 + clones 刷新
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
- streamStore: zclaw:agent-profile-updated 事件从记忆提取前改为 .then() 后触发
- RightPanel: profile 更新事件中新增 loadClones() 刷新 selectedClone 数据
2026-04-16 22:57:32 +08:00
iven
0d79993691 fix(saas): 3 项 P0 安全/功能修复 + TRUTH.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
P0-01: Admin ApiKeys 创建功能前后端不匹配
- 前端 service 从 /keys 改回 /tokens(api_tokens 表)
- 前端 UI 字段 {name, expires_days, permissions} 与旧路由匹配

P0-02: 账户锁定检查错误处理
- unwrap_or(false) 改为 map_err + SaasError 传播
- SQL 查询失败时返回错误而非静默跳过锁定检查

P0-03: Logout refresh token 撤销增强
- 新增 access token cookie fallback 提取 account_id
- Tauri 桌面端 Bearer auth 场景下也能撤销 refresh token

TRUTH.md 校准: Tauri 183→190, invoke 95→104, .route() 136→137, 中间件 15→14
2026-04-16 22:22:12 +08:00
iven
a0d1392371 fix(ui): 5 项 E2E 测试 Bug 修复 — Agent 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
- BUG-01: createFromTemplate 在 saas-relay 模式下 try-catch 跳过本地 Kernel
- BUG-02: upsertActiveConversation 持久化前剥离 error/streaming/optimistic 字段
- BUG-04: ModelSelector 添加 available 标记,ChatArea 追踪失败模型 ID
- BUG-05: VikingPanel 移除 status?.available 门控,不可用时 disabled + 重连按钮
- BUG-06: 侧面板 tooltip 改为"查看产物文件",空状态增加图标和说明
2026-04-16 19:12:21 +08:00
iven
7db9eb29a0 fix(butler): useButlerInsights 使用 resolvedAgentId 查询痛点/方案
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
审计发现 useButlerInsights 仍使用原始 agentId("1")查询痛点,
而痛点按 kernel UUID 存储导致空结果。改用 effectiveAgentId
(resolvedAgentId ?? agentId)确保查询路径一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:29:16 +08:00
iven
1e65b56a0f fix(identity): 3 项根因级修复 — Agent ID 映射 + user_profile 读取 + 用户画像 fallback
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
Issue 2: IdentityFile 枚举补全 UserProfile 变体
- get_file()/propose_change()/approve_proposal() 补全 match arm
- identity_get_file/identity_propose_change Tauri 命令支持 user_profile

Issue 1: Agent ID 映射机制
- 新增 resolveKernelAgentId() 工具函数 (带缓存)
- ButlerPanel 使用 kernel UUID 替代 SaaS relay "1" 查询 VikingStorage

Issue 3: 用户画像 fallback 注入
- build_system_prompt 改为 async,identity user_profile 为默认值时
  从 VikingStorage preferences 路径查询最近 5 条记忆作为 fallback
- intelligence_hooks 调用处同步加 .await

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:07:38 +08:00
iven
3c01754c40 fix(agent): 12 项 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
深端到端验证发现 12 个问题,6 Phase 全栈修复:

Phase 5 — 快速 UX 修复:
- #9: SimpleSidebar 添加新对话按钮 (SquarePen + useChatStore)
- #5: 模型列表 JOIN provider_keys 过滤无 API Key 的模型
- #11: AgentOnboardingWizard 焦点领域增加 4 行业选项
  (医疗健康/教育培训/金融财务/法律合规)

Phase 1 — ButlerPanel 记忆修复:
- #2a: MemorySection URI 从 viking://agent/.../memories/ 修正为 agent://.../
- #2b: "立即分析对话"按钮现在触发 extractAndStoreMemories

Phase 2 — FTS5 中文分词:
- #4: FTS5 tokenizer 从 unicode61 切换到 trigram,原生支持 CJK
- 自动迁移:检测旧 unicode61 表并重建索引
- sanitize_fts_query 支持中文引号短语查询

Phase 3 — 跨会话身份持久化:
- #6-8: 重新启用 USER.md 注入系统提示词 (截断前 10 行)

Phase 4 — Agent 面板同步:
- #1,#10: listClones 从 4 字段扩展到完整映射
  (soul/userProfile 解析 nickname/emoji/userName/userRole)
- updateClone 通过 identity 系统同步 nickname→SOUL.md
  和 userName/userRole→USER.md

Phase 6 — Agent 创建容错:
- #12: createFromTemplate 增加 SaaS 不可用 fallback

验证: tsc --noEmit  cargo check 
2026-04-16 09:21:46 +08:00
iven
08af78aa83 docs: 2026-04-16 变更记录 — 参数名修复 + 解密自愈 + 设置清理
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
- known-issues.md: 新增 3 条修复记录 (Heartbeat参数/Relay解密/设置清理)
- log.md: 追加 2026-04-16 变更日志
2026-04-16 08:06:02 +08:00
iven
b69dc6115d fix(relay): API Key 解密失败自愈 — 启动迁移 + 容错跳过
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
根因: select_best_key 遇到解密失败时直接 500 返回,
不会尝试下一个 key。如果 DB 中有旧的加密格式 key,
整个 relay 请求被阻断。

修复:
- key_pool: 解密失败时 warn + skip 到下一个 key,不再 500
- key_pool: 新增 heal_provider_keys() 启动自愈迁移
  - 逐个尝试解密所有加密 key
  - 解密成功 → 用当前密钥重新加密(幂等)
  - 解密失败 → 标记 is_active=false + warn
- main.rs: 启动时调用自愈迁移(在 TOTP 迁移之后)
2026-04-16 02:40:44 +08:00
iven
7dea456fda chore(settings): 删除用量统计和积分详情页面 — 与订阅计费重复
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
UsageStats 和 Credits 功能已被 PricingPage (订阅与计费) 覆盖,
移除冗余页面简化设置导航。
2026-04-16 02:07:39 +08:00
iven
f6c5dd21ce fix(heartbeat): Tauri invoke 参数名修正 snake_case → camelCase
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
Tauri 2.x 默认将 Rust snake_case 参数重命名为 camelCase,
前端 invoke 必须使用 camelCase (agentId 而非 agent_id)。

修复 3 处 invoke 调用:
- heartbeat_update_memory_stats (agentId, taskCount, totalEntries, storageSizeBytes)
- heartbeat_record_correction (agentId, correctionType)
- heartbeat_record_interaction (agentId)
2026-04-16 00:03:57 +08:00
iven
47250a3b70 docs: Heartbeat 统一健康系统文档同步 — TRUTH + wiki + CLAUDE.md §13
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
- TRUTH.md: Tauri 182→183, React 104→105, lib 85→76
- wiki/index.md: 同步关键数字
- wiki/log.md: 追加 2026-04-15 Heartbeat 变更记录
- CLAUDE.md §13: 更新架构快照 + 最近变更
2026-04-15 23:22:43 +08:00
iven
215c079d29 fix(intelligence): Heartbeat 统一健康系统 — 6处断链修复 + 健康面板 + SaaS自动恢复
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 后端 (heartbeat.rs):
- 告警实时推送: OnceLock<AppHandle> + Tauri emit heartbeat:alert
- 动态间隔: tokio::select! + Notify 替代不可变 interval
- Config 持久化: update_config 写入 VikingStorage
- heartbeat_init 从 VikingStorage 恢复 config
- 移除 dead code (subscribe, HeartbeatCheckFn)
- Memory stats fallback 分层处理

新增 health_snapshot.rs:
- HealthSnapshot Tauri 命令 — 按需查询引擎/记忆状态
- 注册到 lib.rs invoke_handler

前端修复:
- HeartbeatConfig handleSave 同步到 Rust 后端
- App.tsx 读 localStorage 持久化配置 + heartbeat:alert 监听 + toast
- saasStore 降级后指数退避探测恢复 + saas-recovered 事件
- 新增 HealthPanel.tsx 只读健康面板 (4卡片 + 告警列表)
- SettingsLayout 添加 health 导航入口

清理:
- 删除 intelligence-client/ 目录版 (9文件 -1640行, 单文件版是活跃代码)
2026-04-15 23:19:24 +08:00
iven
043824c722 perf(runtime): nl_schedule 正则预编译 — 9个 LazyLock 静态替代每次调用编译
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
将 parse_nl_schedule 中 9 个 Regex::new() 从函数内每次调用编译
提升为 std::sync::LazyLock<Regex> 静态变量,首次调用时编译一次,
后续调用直接复用。16 个单元测试全部通过。
2026-04-15 13:34:27 +08:00
iven
bd12bdb62b fix(chat): 定时功能审计修复 — 消除重复解析 + ID碰撞 + 输入补全
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
审计发现修复:
- H-01: 存储 ParsedSchedule 避免重复 parse_nl_schedule 调用
- H-03: trigger ID 追加 UUID 片段防止高并发碰撞
- C-02: execute_trigger 验证错误信息明确系统 Hand 必须注册
- M-02: SchedulerService 传递 trigger_name 作为 task_description
- M-01: 添加拦截路径跳过 post_hook 的设计注释
2026-04-15 10:02:49 +08:00
iven
28c892fd31 fix(chat): 聊天定时功能断链接通 — NlScheduleParser + _reminder Hand
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
接通"写了没接"的定时功能断链:
- NlScheduleParser has_schedule_intent/parse_nl_schedule 接入 agent_chat_stream
- 新增 _reminder 系统 Hand 作为定时触发器桥接
- TriggerManager hand_id 验证对 _ 前缀系统 Hand 放行
- 聊天消息含定时意图时自动拦截,创建触发器并返回确认消息

验证:cargo check 0 error, 49 tests passed,
Tauri MCP "每天早上9点提醒我查房" → cron 0 9 * * * 确认正确显示
2026-04-15 09:45:19 +08:00
iven
9715f542b6 docs: 发布前冲刺 Day1 文档同步 — TRUTH.md + 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
- TRUTH.md: Tauri 182命令、95 invoke、89 @reserved、0孤儿、0 Cargo warnings
- wiki/log.md: 追加 Day1 冲刺记录 (5项修复 + 2项标注)
- wiki/index.md: 更新关键数字与验证日期
2026-04-15 02:07:54 +08:00
iven
5121a3c599 chore(desktop): Tauri 命令 @reserved 全量标注 — 88个无前端调用命令已标注
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
- 新增 66 个 @reserved 标注 (已有 22 个)
- 覆盖: agent/butler/classroom/hand/mcp/pipeline/skill/trigger/viking/zclaw 等模块
- MCP 命令增加 @connected 注释说明前端接入路径
- @reserved 总数: 89 (含 identity_init)
2026-04-15 02:05:58 +08:00
iven
ee1c9ef3ea chore: Cargo warnings 清零 — 39→0 (仅剩 sqlx-postgres 外部依赖警告)
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
- runtime: 移除未使用的 SessionId/Datelike import,修复 unused variable
- intelligence: 模块级 #![allow(dead_code)] 抑制 Hermes 预留代码警告
- mcp.rs/persist.rs/nl_schedule.rs: 标注 #[allow(dead_code)] 保留接口
2026-04-15 01:53:11 +08:00
iven
76d36f62a6 fix(desktop): 模型自动路由 — 首次登录自动选择可用模型
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
- saasStore: fetchAvailableModels 处理 currentModel 为空的情况,自动选择第一个可用模型
- connectionStore: SaaS relay 连接成功后同步 currentModel 到 conversationStore
- 同时覆盖 Tauri 和浏览器两条 SaaS relay 路径
- 修复首次登录用户需手动选模型的问题
2026-04-15 01:45:36 +08:00
iven
be2a136392 fix(saas): relay_tasks 超时自动清理 — 每5分钟扫描 processing >10min 标记 failed
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
- scheduler.rs: 新增 start_db_cleanup_tasks 中的 relay 超时清理定时任务
- status=processing 且 updated_at 超过 10 分钟的 relay_task 自动标记为 failed
- 避免 Provider key 禁用后 relay_task 永久停留在 processing 状态
2026-04-15 01:41:50 +08:00
iven
76cdfd0c00 fix(saas): SSE 用量统计一致性修复 — 回写 usage_records + 消除 relay_requests 双重计数
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
- service.rs: SSE 流结束后回写 usage_records 真实 token (status=success)
- service.rs: spawned task 中调用 increment_usage 统一递增 tokens + relay_requests
- handlers.rs: 移除 SSE 路径的 increment_dimension("relay_requests") 消除双重计数
- 从 request_body 提取 model_id 用于 usage_records 精准归因
2026-04-15 01:40:27 +08:00
iven
02a4ba5e75 fix(desktop): 替换 require() 为 ES import — 修复生产构建崩溃
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
- connectionStore: 2 处 require() → loadConversationStore() 异步预加载 + 闭包引用
- saasStore: 1 处 require() → await import()(logout 是 async)
- llm-service: 1 处 require() → 顶层 import(无循环依赖)
- streamStore: 移除重复动态导入,统一使用顶层 useConnectionStore
- tsc --noEmit 0 errors
2026-04-15 00:47:29 +08:00
iven
a8a0751005 docs: wiki 三端联调V2结果 + 调试环境信息
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
- known-issues: 新增V2联调测试(17项通过 + 3项待处理 + SSE token修复)
- development: 新增完整调试环境文档(Windows/PostgreSQL/端口/账号/启动顺序)
- log: 追加V2联调记录
2026-04-15 00:40:05 +08:00
iven
9c59e6e82a fix(saas): SSE relay token capture 修复 — stream_done 标志 + 前缀兼容
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
- SseUsageCapture 增加 stream_done 标志,[DONE] 和 stream 结束时设置
- parse_sse_line 兼容 "data:" 和 "data: " 两种前缀
- 增加 total_tokens 兜底解析(某些 provider 不返回 prompt_tokens)
- 轮询逻辑优先检测 stream_done,而非依赖 total > 0 条件
- 超时时增加 warn 日志记录实际 token 值

根因: 上游 provider 不在 SSE chunk 中返回 usage 时,轮询稳定逻辑
(total > 0 条件) 永远不满足,导致 token 始终为 0。
2026-04-15 00:15:03 +08:00
iven
27b98cae6f docs: wiki 全量更新 — 2026-04-14 代码验证驱动
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 77K行(274 .rs)、Tauri 189命令、SaaS 137 routes
- Admin V2 17页、SaaS 16模块(含industry)、@reserved 22
- SQL 20迁移/42表、TODO/FIXME 4个、dead_code 16

内容更新:
- known-issues: V13-GAP 全部标记已修复 + 三端联调测试结果
- middleware: 14层 runtime + 10层 SaaS HTTP 完整清单
- saas: industry模块、路由模块13个、数据表42个
- routing: Store含industryStore、21个Store文件
- butler: 行业配置接入ButlerPanel、4内置行业
- log: 三端联调+V13修复记录追加
2026-04-14 22:15:53 +08:00
iven
d0aabf5f2e fix(test): pain_severity 测试断言修正 + 调试文档代码验证更新
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
- test_severity_ordering: 修正错误断言 — 2条挫折信号应触发High而非Medium
- DEBUGGING_PROMPT.md: 全量代码验证更新
  - 数字修正: 97组件/81lib/189命令/137路由/8 Worker
  - V13-GAP 状态更新: 5/6 已修复, 1 标注 DEPRECATED
  - 中间件优先级修正: ButlerRouter@80, DataMasking@90
  - SaaS Relay: resolve_model() 三级解析 (非精确匹配)
2026-04-14 22:03:51 +08:00
iven
3c42e0d692 docs: 三端联调测试报告 V2 — P1 修复状态更新 + 测试截图
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
30+ API/16 Admin/8 Tauri 全量测试,3 P1 已修复
2026-04-14 22:02:27 +08:00
iven
e0eb7173c5 fix: 三端联调 P1 修复 — API密钥页崩溃 + 桌面端401恢复 + 用量统计全零
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-03: vite.config.ts proxy '/api' → '/api/' 加尾部斜杠,
  防止前缀匹配 /api-keys 导致 SPA 路由崩溃

P1-01: kernel_init 增加 api_key 变更检测(token 刷新后自动重连),
  streamStore 增加 401 自动恢复(refresh token → kernel reconnect),
  KernelClient 新增 getConfig() 方法

P1-02: /api/v1/usage 总计改从 billing_usage_quotas 读取
  (authoritative source,SSE 和 JSON 均写入),
  by_model/by_day 仍从 usage_records 读取
2026-04-14 22:02:02 +08:00
iven
6721a1cc6e fix(admin): 行业选择500修复 + 管理员切换订阅计划
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
- fix(industry): list_industries SQL参数编号错位 — count查询和items查询
  共用WHERE子句但参数从$3开始,sqlx bind按$1/$2顺序绑定导致500
- feat(billing): 新增 PUT /admin/accounts/:id/subscription 端点 (super_admin)
  验证目标计划 → 取消当前订阅 → 创建新订阅(30天) → 同步配额
- feat(admin-v2): Accounts.tsx 编辑弹窗新增「订阅计划」选择区
  显示所有活跃计划,保存时调用admin switch plan API
2026-04-14 19:06:58 +08:00
iven
d2a0c8efc0 fix(saas): 启动崩溃修复 — config_items 约束 + industry 类型匹配
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
- db.rs: config_items INSERT ON CONFLICT (id) → (category, key_path) 匹配实际唯一约束
- db.rs: fix_seed_data category 重命名前先删除冲突行,避免唯一约束冲突
- migration/service.rs: seed_default_config_items + sync push INSERT 同步修复 ON CONFLICT
- industry/types.rs: keywords_count i64→i32 匹配 PostgreSQL INT4 列类型

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 18:35:24 +08:00
iven
70229119be docs: 三端联调测试报告 2026-04-14 — 30+ API/16 Admin/8 Tauri 全量测试
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-14 17:48:31 +08:00
iven
dd854479eb fix: 三端联调测试 2 P1 + 2 P2 + 4 P3 修复
P1-07: billing get_or_create_usage 同步 max_* 列到当前计划限额
P1-08: relay handler 增加直接配额检查 (relay_requests/input/output_tokens)
P2-09: relay failover 成功后记录 tokens 并标记 completed
P2-10: Tauri agentStore saas-relay 模式下从 SaaS API 获取真实用量
P2-14: super_admin 合成 subscription + check_quota 放行
P3-19: 新建 ApiKeys.tsx 页面替代 ModelServices 路由
P3-15: antd destroyOnClose → destroyOnHidden (3处)
P3-16: ProTable onSearch → onSubmit (2处)
2026-04-14 17:48:22 +08:00
iven
45fd9fee7b fix(desktop): P0-1 验证 SaaS 模型选择 — 防止残留模型 ID 导致请求失败
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
Tauri 桌面端通过 SaaS Token Pool 中转访问 LLM,模型列表由 SaaS 后端
动态提供。之前的实现直接使用 conversationStore 持久化的 currentModel,
可能在切换连接模式后使用过期的模型 ID,导致 relay 请求失败。

修复:
- Tauri 路径:用 SaaS relayModels 的 id+alias 构建 validModelIds 集合,
  preferredModel 仅在集合内时才使用,否则回退到第一个可用模型
- 浏览器路径:同样验证 currentModel 在 SaaS 模型列表中才使用

后端 cache.resolve_model() 别名解析作为二道防线保留。
2026-04-14 07:08:56 +08:00
iven
4c3136890b fix: 三端联调测试 2 P0 + 6 P1 + 2 P2 修复
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: SaaS relay 模型别名解析 — "glm-4-flash" → "glm-4-flash-250414" (resolve_model)
P0-2: config.rs interpolate_env_vars UTF-8 修复 (chars 迭代器替代 bytes as char)
      + DB 启动编码检查 + docker-compose UTF-8 编码参数

P1-3: UI 模型选择器覆盖 Agent 默认模型 (model_override 全链路: TS→Tauri→Rust kernel)
P1-6: 知识搜索管道修复 — seed_knowledge 创建 chunks + 默认分类 (seed/uploaded/distillation)
P1-7: 用量限额从当前 Plan 读取 (非 stale usage 表)
P1-8: relay 双维度配额检查 (relay_requests + input_tokens)

P2-9: SSE 路径 token 计数修复 — 流结束检测替代固定 500ms sleep + billing increment
2026-04-14 00:17:08 +08:00
iven
0903a0d652 fix(v13): FIX-06 PersistentMemoryStore 全量移除 — 665行死代码清理
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
- persistent.rs 611→57行: 移除 PersistentMemoryStore struct + 全部方法 + 死 embedding global
- memory_commands.rs: MemoryStoreState→Arc<Mutex<()>>, memory_init→no-op, 移除 2 @reserved 命令
- viking_commands.rs: 移除冗余 PersistentMemoryStore embedding 配置段
- lib.rs: Tauri 命令 191→189 (移除 memory_configure_embedding + memory_is_embedding_configured)
- TRUTH.md + wiki/log.md 数字同步

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 20:58:54 +08:00
iven
fd3e7fd2cb docs: V13 审计修复文档同步 — 6项状态更新 + 中间件14→15层
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
AUDIT_TRACKER: V13-GAP-01~05 FIXED, GAP-06 PARTIALLY_FIXED
wiki/middleware: 15层 (TrajectoryRecorder V13注册)
wiki/log: 2026-04-13 变更记录
CLAUDE.md: 中间件链 14→15 层

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 01:38:55 +08:00
iven
c167ea4ea5 fix(v13): V13 审计 6 项修复 — TrajectoryRecorder注册 + industryStore接入 + 知识搜索 + webhook标注 + structured UI + persistent注释
FIX-01: TrajectoryRecorderMiddleware 注册到 create_middleware_chain() (@650优先级)
FIX-02: industryStore 接入 ButlerPanel 行业专长展示 + 自动拉取
FIX-03: 桌面端知识库搜索 saas-knowledge mixin + VikingPanel SaaS KB UI
FIX-04: webhook 迁移标注 deprecated + 添加 down migration 注释
FIX-05: Admin Knowledge 添加结构化数据 Tab (CRUD + 行浏览)
FIX-06: PersistentMemoryStore 精化 dead_code 标注 (完整迁移留后续)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 01:34:08 +08:00
iven
c048cb215f docs: V13 系统性功能审计 — 6 项新发现 + TRUTH.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
V13 审计聚焦 V12 后新增功能 (行业配置/Knowledge/Hermes/管家主动性):
- 总体健康度 82/100 (V12: 76)
- P1 新发现 3 项: TrajectoryRecorder 未注册/industryStore 孤立/桌面端无 Knowledge Search
- P2 新发现 3 项: Webhook 孤儿表/Structured Data 无 Admin/PersistentMemoryStore 遗留
- 修正 V12 错误认知 5 项: Butler/MCP/Gateway/Presentation 已接通
- TRUTH.md 数字校准: Tauri 184→191, SaaS 122→136, @reserved 33→24
2026-04-12 23:33:13 +08:00
iven
f32216e1e0 docs: 添加发散探讨文档和测试截图
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-12 22:40:45 +08:00
iven
d5cb636e86 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-12 21:05:06 +08:00
iven
0b512a3d85 fix(industry): 三轮审计修复 — 3 HIGH + 4 MEDIUM 清零
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
H1: status 值不匹配 disabled→inactive + source 补 admin 映射 + valueEnum
H2: experience.rs format_for_injection 添加 xml_escape
H3: TriggerContext industry_keywords 接通全局缓存
M2: ID 自动生成移除中文字符保留 + 无 ASCII 时提示手动输入
M3: TS CreateIndustryRequest 添加 id? 字段
M4: ListIndustriesQuery 添加 deny_unknown_fields
2026-04-12 21:04:00 +08:00
iven
168dd87af4 docs: wiki变更日志 — Phase D 统一搜索+种子知识
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-12 20:48:14 +08:00
iven
640df9937f feat(knowledge): Phase D 统一搜索 + 种子知识冷启动
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
- search/recommend API 返回 UnifiedSearchResult (文档+结构化双通道)
- POST /api/v1/knowledge/seed 种子知识冷启动 (幂等, admin权限)
- seed_knowledge service: 按标题+行业查重, source=distillation
- SearchRequest 扩展: search_structured/search_documents/industry_id
2026-04-12 20:46:43 +08:00
iven
f8c5a76ce6 fix(industry): 审计收尾 — MEDIUM + LOW 全部清零
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
M-1: Industries 创建弹窗添加 cold_start_template + pain_seed_categories
M-3: industryStore console.warn → createLogger 结构化日志
B2: classify_with_industries 平局打破 + 归一化因子 3.0 文档化
S3: set_account_industries 验证移入事务内消除 TOCTOU
T1: 4 个 SaaS 请求类型添加 deny_unknown_fields
I3: store_trigger_experience Debug 格式 → signal_name 描述名
L-1: 删除 Accounts.tsx 死代码 editingIndustries
L-3: Industries.tsx filters 类型补全 source 字段
2026-04-12 20:37:48 +08:00
iven
3cff31ec03 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-12 20:14:52 +08:00
iven
76f6011e0f fix(industry): 二次审计修复 — 2 CRITICAL + 4 HIGH + 2 MEDIUM
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
C-1: Industries.tsx 创建弹窗缺少 id 字段 → 添加 id 输入框 + 自动生成
C-2: Accounts.tsx handleSave 无 try/catch → 包装 + handleClose 统一关闭
V1: viking_commands Mutex 跨 await → 先 clone Arc 再释放 Mutex
I1: intelligence_hooks 误导性"相关度" → 移除 access_count 伪分数
I2: pain point 摘要未 XML 转义 → xml_escape() 处理
S1: industry status 无枚举验证 → active/inactive 白名单
S2: create_industry id 无格式验证 → 正则 + 长度检查
H-3: Industries.tsx 编辑模态数据竞争 → data.id === industryId 守卫
H-4: Accounts.tsx useEffect 覆盖用户编辑 → editingId 守卫
2026-04-12 20:13:41 +08:00
iven
0f9211a7b2 docs: wiki变更日志 — Phase B+C 文档提取器+multipart上传
Some checks failed
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
CI / Lint & TypeCheck (push) Has been cancelled
2026-04-12 19:26:18 +08:00
iven
60062a8097 feat(knowledge): Phase B+C 文档提取器 + multipart 文件上传
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
- 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
- 结构化数据源 CRUD API (GET/DELETE /api/v1/structured/sources)
- POST /api/v1/structured/query JSONB 关键词查询
- 修复 industry/service.rs SaasError::Database 类型不匹配
2026-04-12 19:25:24 +08:00
iven
4800f89467 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-12 19:06:49 +08:00
iven
fbc8c9fdde fix(industry): 审计修复 — 4 CRITICAL + 5 HIGH 全部解决
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
C1: SaaS industry/service.rs SQL 注入风险 → 参数化查询 ($N 绑定)
C2: INDUSTRY_CONFIGS 死链 → Kernel 共享 Arc 接通 ButlerRouter
C3: IndustryListItem 缺 keywords_count → SQL 查询 + 类型补全
C4: set_account_industries 非事务性 → batch 验证 + 事务 DELETE+INSERT
H8: Accounts.tsx mutate 竞态 → mutateAsync 顺序等待
H9: XML 注入未转义 → xml_escape() 辅助函数
H10: update_industry 覆盖 source → 保留原始值
H11: 面包屑缺少 /industries → 添加行业配置映射
2026-04-12 19:06:19 +08:00
iven
c3593d3438 feat(knowledge): Phase A 知识库可见性隔离 + 结构化数据源 + 蒸馏Worker
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
- knowledge_items 增加 visibility(public/private) + account_id 字段
- 新建 structured_sources + structured_rows 表 (Excel JSONB 行级存储)
- 结构化数据源 CRUD API (5 路由: list/get/rows/delete/query)
- 安全查询: JSONB GIN 索引 + 可见性过滤 + 行数限制
- 蒸馏 Worker: 复用 Provider Key Pool 调 DeepSeek/Qwen API
- L0 质量过滤: 长度/隐私检测
- create_item 增加 is_admin 参数控制可见性默认值
- generate_embedding: extract_keywords_from_text 改为 pub 复用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 18:36:05 +08:00
iven
b8fb76375c docs: wiki变更日志 + CLAUDE.md架构快照更新 (Phase 1-5完成)
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-12 18:34:14 +08:00
iven
b357916d97 feat(intelligence): Phase 5 主动行为激活 — 注入格式 + 跨会话连续性 + 触发持久化
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
Task 5.1+5.4: ButlerRouter/experience 注入格式升级为 <butler-context> XML fencing
- butler_router: [路由上下文] → <butler-context><routing>...</routing></butler-context>
- experience: [过往经验] → <butler-context><experience>...</experience></butler-context>
- 统一 system-note 提示,引导 LLM 自然运用上下文

Task 5.2: 跨会话连续性 — pre_conversation_hook 注入活跃痛点 + 相关经验
- 从 VikingStorage 检索相关记忆(相似度>=0.3)
- 从 pain_aggregator 获取 High severity 痛点(top 3)

Task 5.3: 触发信号持久化 — post_conversation_hook 将触发信号存入 VikingStorage
- store_trigger_experience(): 模板提取,零 LLM 成本
- 为未来 LLM 深度反思积累数据基础
2026-04-12 18:31:37 +08:00
iven
edf66ab8e6 feat(admin): Phase 4 行业配置管理页面 + 账号行业授权
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
- 新增 Industries.tsx: 行业列表(ProTable) + 编辑弹窗(关键词/prompt/痛点种子) + 新建弹窗
- 新增 services/industries.ts: 行业 API 服务层(list/create/update/fullConfig/accountIndustries)
- 增强 Accounts.tsx: 编辑弹窗添加行业授权多选, 自动获取/同步用户行业
- 注册 /industries 路由 + 侧边栏导航(ShopOutlined)
2026-04-12 18:07:52 +08:00
iven
b853978771 feat(industry): Phase 3 Tauri 行业配置加载 — SaaS API mixin + industryStore + Tauri 命令
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
- 新增 saas-industry.ts mixin: listIndustries/getIndustryFullConfig/getMyIndustries
- 新增 saas-types 行业类型: IndustryInfo/IndustryFullConfig/AccountIndustryItem
- 新增 industryStore.ts: Zustand store + localStorage persist + Rust 注入
- 新增 viking_load_industry_keywords Tauri 命令: 接收 JSON configs → 全局存储
- 前端 bootstrap 后自动拉取行业配置并推送到 ButlerRouter
2026-04-12 17:18:53 +08:00
iven
29fbfbec59 feat(intelligence): Phase 2 学习循环基础 — 触发信号 + 经验行业维度
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
- 新增 triggers.rs: 5 种触发信号(痛点确认/正反馈/复杂工具链/用户纠正/行业模式)
- ExperienceStore 增加 industry_context + source_trigger 字段
- experience.rs format_for_injection 支持行业标签
- intelligence_hooks.rs 集成触发信号评估
- 17 个测试全通过 (7 trigger + 10 experience)
2026-04-12 15:52:29 +08:00
iven
5d1050bf6f feat(industry): Phase 1 行业配置基础 — 数据模型 + 四行业内置配置 + ButlerRouter 动态关键词
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
- 新增 SaaS industry 模块 (types/service/handlers/mod/builtin)
- 4 行业内置配置: healthcare/education/garment/ecommerce
- 数据库迁移: industries + account_industries 表
- 8 个 API 端点 (CRUD + 用户行业关联)
- ButlerRouter 改造: 支持 IndustryKeywordConfig 动态注入
- 12 个测试全通过 (含动态行业分类测试)
2026-04-12 15:42:35 +08:00
iven
5599cefc41 feat(saas): 接通 embedding 模型管理全栈
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
数据库 migration 已有 is_embedding/model_type 列但全栈未使用。
打通 4 层: ModelRow → ModelInfo/CRUD → CachedModel → Admin 前端。
relay/models 端点也返回 is_embedding 字段,前端可按类型过滤。
2026-04-12 08:10:50 +08:00
iven
b0a304ca82 docs: TRUTH.md 数字校准 + 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
- TRUTH.md 全面更新 2026-04-11 验证数字
  - Rust 代码 66K→74.6K, 测试 537→798, Tauri 命令 182→184
  - SaaS .route() 140→122, Store 18→20, 组件 135→104
- wiki/log.md 追加发布前准备记录
2026-04-11 23:52:28 +08:00
iven
58aca753aa chore: 发布前准备 — 版本号统一 + 安全加固 + 死组件清理
- Cargo.toml workspace version 0.1.0 → 0.9.0-beta.1
- CSP 添加 object-src 'none' 防止插件注入
- .env.example 补充 SaaS 关键环境变量模板
- 移除已废弃的 SkillMarket.tsx 组件
2026-04-11 23:51:58 +08:00
iven
e1af3cca03 fix(routing): 消除模型路由链路硬编码不匹配模型名
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
summarizer_adapter.rs 和 saas-relay-client.ts 中的 fallback 模型名
(glm-4-flash / glm-4-flash-250414) 在 SaaS relay 中不存在,导致请求被拒绝。
改为未配置时明确报错(fail fast),不再静默使用错误模型。
2026-04-11 23:08:06 +08:00
iven
5fcc4c99c1 docs(wiki): 添加 Skill 调用链路 + MCP 架构文档
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.md 标题改为 Hands + Skills + MCP
- 添加 Skill 调用链路说明 (ToolRegistry → AgentLoop → execute_skill)
- 添加 MCP 完整架构 (BasicMcpClient → McpToolAdapter → McpToolWrapper → ToolRegistry)
- 添加 MCP 桥接机制说明 (Arc<RwLock> 共享 + sync_to_kernel)
- 更新关键文件表 (新增 mcp_tool.rs, anthropic.rs, mcp.rs 等)
- 更新 index.md 导航树 + log.md 变更记录
2026-04-11 16:23:31 +08:00
iven
9e0aa496cd fix(runtime): 修复 Skill/MCP 调用链路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
1. Anthropic Driver ToolResult 格式修复 — ContentBlock 添加 ToolResult 变体,
   tool_call_id 不再被丢弃, 按 Anthropic API 规范发送 tool_result 格式
2. 前端 callMcpTool 参数名对齐 — serviceName/toolName/args 改为
   service_name/tool_name/arguments, 后端支持 service_name 精确路由
3. MCP 工具桥接到 ToolRegistry — McpToolAdapter 添加 service_name/clone,
   新建 McpToolWrapper 实现 Tool trait, Kernel 添加 mcp_adapters 共享状态,
   McpManagerState 与 Kernel 共享同一 Arc<RwLock<Vec>>, MCP 服务启停时
   自动同步工具列表到 LLM 可见的 ToolRegistry
2026-04-11 16:20:38 +08:00
iven
2843bd204f chore: 更新测试注释 — 阈值已从 5 降为 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
2026-04-11 14:26:53 +08:00
iven
05374f99b0 chore: 移除未使用的 loadConnectionModeTimestamp 函数 2026-04-11 14:26:52 +08:00
iven
c88e3ac630 fix(kernel): UserProfile 序列化失败时记录 warn 而非静默吞掉 2026-04-11 14:26:52 +08:00
iven
dc94a5323a fix(butler): 降低痛点检测阈值 3→2/2→1,更早发现用户需求 2026-04-11 14:26:51 +08:00
iven
69d3feb865 fix(lint): IdentityChangeProposal console.error → createLogger 2026-04-11 14:26:50 +08:00
iven
3927c92fa8 docs: 详情面板7问题修复记录
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-11 12:59:04 +08:00
iven
730d50bc63 feat(ui): 管家 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
- 无数据时显示友好引导文案
- 分析按钮要求至少 2 条对话
- 分析中显示加载状态
2026-04-11 12:58:27 +08:00
iven
ce10befff1 fix(ui): 管家 Tab 统计栏显示管家专属摘要,不再显示聊天统计 2026-04-11 12:58:26 +08:00
iven
f5c6abf03f feat(ui): 演化历史条目增加可展开差异视图 + 文件变更标签
点击展开显示 soul/instructions/profile 变更内容,不再截断原因文本。
2026-04-11 12:58:25 +08:00
iven
b3f7328778 feat(ui): '我眼中的你' 双源渲染 — 静态Clone + 动态UserProfile
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
- RightPanel 增加 userProfile state + fetch 逻辑
- 对话结束后通过 CustomEvent 触发画像刷新
- UserProfile 字段: 行业/角色/沟通偏好/近期话题
桥接 identity 系统 → 前端面板。
2026-04-11 12:51:28 +08:00
iven
d50d1ab882 feat(kernel): agent_get 返回值扩展 UserProfile 字段
- AgentInfo 增加 user_profile: Option<Value> (serde default)
- SqliteStorage 增加 pool() getter
- agent_get 命令查询 UserProfileStore 填充 user_profile
- 前端 AgentInfo 类型同步更新
复用已有 UserProfileStore,不新增 Tauri 命令。
2026-04-11 12:51:27 +08:00
iven
d974af3042 fix(reflection): 修复 state restore 竞态 — peek+pop 替代直接 pop
根因: pop_restored_state 在 getHistory 读取前删除数据。
修复: 先 peek 非破坏性读取,apply 后再 pop,确保数据可被多次读取。
2026-04-11 12:51:26 +08:00
iven
8a869f6990 fix(reflection): 降低模式检测阈值 5→3/20→15 以产生更多有意义反思
- task/preference/lesson 累积: 5→3
- high-access memories: 3→2
- low-importance: >20 → >15
- 文案微调: "建议清理" → "可考虑清理"
2026-04-11 12:51:25 +08:00
iven
f7edc59abb fix(auth): 修复重启后无法对话 — restoreSession 优先验证 SaaS token
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
根因: 心跳降级将 'tauri' 持久化到 localStorage,重启后盲信该值。
修复: token refresh 成功时强制恢复 'saas' 模式;connectionMode 携带时间戳。
2026-04-11 12:32:20 +08:00
iven
be01127098 fix(autonomy): hand_trigger 从 null 映射改为 handAutoTrigger 字段
根因: autonomy-manager.ts:268 将 hand_trigger 硬编码为 null,
导致任何自主权级别都无法自动触发 Hand。
新增 handAutoTrigger 字段,autonomous 级别默认 true。
UI 增加对应开关。
2026-04-11 12:32:19 +08:00
iven
33c1bd3866 fix(memory): memory_search 空查询时默认 min_similarity=0.0 触发表扫描
根因: FTS5 空查询返回 0 条,而 memory_stats 因设 min_similarity=Some(0.0)
走表扫描才正确计数。统一空查询行为。
2026-04-11 12:32:18 +08:00
iven
b90306ea4b docs(plan): 详情面板7问题修复实施计划
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
15个Task分3个Batch:
Batch 1 (P0): memory_search空查询/hand_trigger映射/聊天路由竞态
Batch 2 (P1): 反思阈值+持久化/Agent画像桥接
Batch 3 (P2): 演化差异视图/管家Tab上下文
2026-04-11 12:24:38 +08:00
iven
449768bee9 docs(spec): 详情面板7问题修复设计文档
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个问题根因分析+修复方案:
P0: 聊天路由竞态/记忆查询缺陷/hand_trigger硬编码
P1: Agent画像断链/反思持久化多重缺陷
P2: 演化差异视图/管家Tab上下文混淆
路径B: 系统桥接修复,扩展已有命令而非新增
2026-04-11 10:50:25 +08:00
iven
d871685e25 fix(auth): 5 BUG 修复 — refresh token 持久化 + 密码验证 + 浏览器兼容
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
BUG-1 (P1): LoginPage 注册密码验证从 6 位改为 8 位,与后端一致
BUG-2 (P0): refresh token 持久化到 OS keyring + restoreSession 三级恢复
  (access token → refresh token → cookie auth) + saveSaaSSession 改为 await
BUG-3 (P0): Tauri 聊天路由降级问题,根因同 BUG-2(会话恢复失败)
BUG-4 (P1): App.tsx 跳过 Onboarding 改用 agentStore(兼容所有 client),
  Workspace.tsx Tauri invoke 改为动态 import 避免浏览器崩溃
BUG-5: tauri.conf.json createUpdaterArtifacts 改为 boolean true
2026-04-11 09:43:17 +08:00
iven
1171218276 docs(wiki): 追加发布内测前修复 6 批次记录
Some checks failed
CI / Build Frontend (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (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-11 03:03:13 +08:00
iven
33008c06c7 chore: 版本号 0.1.0 → 0.9.0-beta.1 + updater 插件预留
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
- package.json / tauri.conf.json: version 更新为 0.9.0-beta.1
- tauri.conf.json: 添加 plugins.updater 空壳配置 + createUpdaterArtifacts
- Cargo.toml: 添加 tauri-plugin-updater 依赖
- lib.rs: 注册 updater 插件 (空壳,部署时配置 HTTPS 端点 + Ed25519 密钥)
2026-04-11 03:02:39 +08:00
iven
5e937d0ce2 refactor(ui): 移除空壳行业资讯 Tab + Provider URL 去重
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
- SimpleSidebar: 移除空壳"行业资讯" Tab 和 Newspaper icon import
- ModelsAPI.tsx: AVAILABLE_PROVIDERS 引用 LLM_PROVIDER_URLS 常量
- models.ts: PROVIDER_DEFAULTS 引用 api-urls.ts,消除重复 URL 定义
- 所有 Provider URL 现在统一在 api-urls.ts 维护
2026-04-11 02:59:16 +08:00
iven
722d8a3a9e fix(ui): 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
- FirstConversationPrompt: 新用户显示"欢迎开始!",老用户"欢迎回来!"
- use-cold-start: 冷启动问候语改为通用语言,去掉政务场景特定文案
- LoginPage: 添加"忘记密码?请联系管理员重置"提示
- connectionStore: 错误提示改为用户友好的"暂时没有可用的 AI 模型"
2026-04-11 02:56:19 +08:00
iven
db1f8dcbbc feat(desktop): Gateway URL 配置化 + Rust panic hook 崩溃报告
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
- api-urls.ts: GATEWAY_URLS 读 VITE_GATEWAY_HTTP/WS env
- gateway-storage.ts: DEFAULT_GATEWAY_URL 读 VITE_GATEWAY_WS env
- lib.rs: 添加 tracing_subscriber 初始化 + panic::set_hook
  崩溃时自动写入 crash-reports/ 目录供诊断
- Cargo.toml: 添加 tracing-subscriber workspace 依赖
2026-04-11 02:54:23 +08:00
iven
4e641bd38d refactor(desktop): SaaS URL 集中配置化,消除 5 处硬编码
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
- 新增 .env.development / .env.production (VITE_SAAS_URL)
- saasStore.ts / LoginPage.tsx / saas-client.ts / SaaSLogin.tsx
  统一读取 import.meta.env.VITE_SAAS_URL
- 移除 LoginPage 中未使用的 isTauriRuntime import
2026-04-11 02:09:23 +08:00
iven
25a4d4e9d5 fix(saas): 新用户 llm_routing 默认改为 relay 使 SaaS token pool 成为主路径
Some checks failed
CI / Security Scan (push) Has been cancelled
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
- handlers.rs: SQL INSERT 和 LoginResponse 中 'local' → 'relay'
- 新增 migration: ALTER llm_routing SET DEFAULT 'relay'
- 符合管家式服务理念:用户无需配置 API Key,SaaS 自动中转
2026-04-11 02:05:27 +08:00
iven
4dd9ca01fe docs(wiki): 修正关键数字 — Rust 95K行/1055测试/SaaS中间件
Some checks failed
CI / Security Scan (push) Has been cancelled
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
- Rust 总代码: 74.5K→95.2K (含src-tauri 20.7K行, 335 .rs文件)
- 测试函数: 431→~1055 (含tokio::test + 集成测试)
- Tauri命令: 183→190定义/183注册(5 feature-gated)
- 中间件: 14层runtime + 6层SaaS HTTP
2026-04-11 01:08:59 +08:00
iven
b3f97d6525 docs(wiki): 全量代码验证驱动更新 — 10页基于实际扫描非文档推测
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 74.5K行(原66K), Tauri命令 183(原182), SaaS路由 121
- 前端组件 104, lib/ 85文件, Store 17+4子store
- TODO/FIXME 仅 8 个(前端4+Rust4)

内容增强:
- 中间件完整14层注册清单含注册条件和优先级分类
- Store完整目录结构, Pipeline完整目录树
- Hands测试分布, Memory 16个Tauri命令列表
- 管家模式: 关键词路由→语义路由(TF-IDF)修正
- 代码健康度指标新增
2026-04-11 01:05:15 +08:00
iven
36a1c87d87 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
问题: 旧 wiki 按文档类型组织(architecture/data-flows/file-map),
修复 Butler Router 需要读 4 个文件才能拼凑全貌。
且 SaaS Relay 主路径 vs 本地降级的优先级描述不准确。

重构为模块化结构,每个模块页自包含:
- 设计思想: 为什么这样设计
- 代码逻辑: 数据流 + 关键代码
- 关联模块: 依赖关系

新增模块页:
- routing.md: 客户端路由 (明确 SaaS Relay 是主路径,不是本地模式)
- chat.md: 聊天系统 (3种实现 + Token Pool 中转机制)
- butler.md: 管家模式 (路由/冷启动/痛点/双模式UI)
- memory.md: 记忆管道 (提取→FTS5→检索→注入)
- saas.md: SaaS平台 (认证/Token池/计费/Admin)
- middleware.md: 中间件链 (14层 + 优先级)
- hands-skills.md: Hands(9) + Skills(75)
- pipeline.md: Pipeline DSL

删除旧文件: architecture.md, data-flows.md, module-status.md, file-map.md
(内容已分布到对应模块页中)

添加 .gitignore 排除 Obsidian 工作区状态文件
2026-04-11 00:36:26 +08:00
iven
9772d6ec94 fix(ui): 空catch块添加日志 + ErrorBoundary覆盖高风险组件
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
空catch块修复 (12处, 6文件):
- ModelsAPI: 4处 localStorage 配置读写添加 console.warn
- VikingPanel: 2处 viking 操作添加日志
- Workspace/MCPServices/SaaSStatus/TOTPSettings: 各1-3处

ErrorBoundary新增覆盖:
- ChatArea: 两种UI模式均包裹(防白屏)
- RightPanel: 两种UI模式均包裹
- AuditLogsPanel/HeartbeatConfig/VikingPanel: 设置页包裹
2026-04-11 00:26:24 +08:00
iven
717f2eab4f chore: 清理40个死代码文件 (~9,639行)
删除无任何活跃渲染路径引用的组件:
- Automation/ 全目录 (7文件, 2,598行)
- WorkflowBuilder/ 全目录 (14文件, 1,539行)
- SchedulerPanel + 依赖树 (5文件, 2,595行)
- 独立死组件 (14文件, 2,907行)
  含 SkillMarket, HandsPanel, ErrorNotification 等
- PipelineResultPreview 根目录副本 (534行, 活跃版在 pipeline/)
2026-04-11 00:26:04 +08:00
iven
e790cf171a docs(wiki): 创建 LLM 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
受 Karpathy LLM Wiki 启发,将分散在 docs/ + memory/ + CLAUDE.md 的项目知识
编译为 8 个结构化 wiki 页面,解决新会话冷启动时上下文浪费问题。

- wiki/index.md: 主索引入口 (~200行),CLAUDE.md @import 自动加载
- wiki/architecture.md: 系统架构编译 (crate依赖/客户端路由/聊天流/LLM驱动)
- wiki/module-status.md: 9个子系统状态 + Hands详情 + 测试覆盖
- wiki/data-flows.md: 6条核心数据流 (聊天/路由/记忆/认证/管家/Pipeline)
- wiki/development.md: 开发规范 (闭环工作法/验证命令/提交规范)
- wiki/known-issues.md: 缺陷状态 (P0/P1已修复,P2待处理)
- wiki/file-map.md: 代码库文件地图 (crates/desktop/admin-v2/docs)
- wiki/log.md: Append-only 变更日志
- CLAUDE.md: 添加 @wiki/index.md + §8.3 收尾流程增加 wiki 维护步骤
2026-04-11 00:20:17 +08:00
iven
4a5389510e fix(ui): 深度审计修复 — RightPanel流式渲染优化 + SecurityStatus基线真实值
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
- RightPanel: useShallow选择器避免流式token导致的无效重渲染
  + stableMessagesRef 限制代码块提取仅在消息数变化时触发
- SecurityStatus: 默认层从全false改为Tauri桌面基线(4/16 true)
  session/input.sanitization/input.schema/exec.sandbox
2026-04-10 23:59:24 +08:00
iven
550e525554 fix(ui): 审计修复 — 路径规范化/SkillInfo类型/分页offset/初始加载/显示统一
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
- workspace.rs: canonicalize() 解析 '..' 和符号链接
- Workspace.tsx: 组件挂载时调用 loadDirStats + 统一 KB 显示
- configStore: SkillInfo 接口补充 category 字段 + 空数组回退注释
- securityStore: localStorage 审计日志添加 offset 分页支持
2026-04-10 23:24:32 +08:00
iven
1d0e60d028 fix(ui): 9项端到端真实审计 — 修复记忆/技能/审计/工作区/MCP数据流断裂
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
基于 Tauri MCP 实机排查发现并修复:

1. VikingPanel: viking_ls('/') 返回0 → 改为 viking_ls('') 返回100条记忆
2. 技能列表: loadSkillsCatalog 静默失败 → 添加直接 invoke('skill_list') 回退
3. 审计日志: 面板读Gateway API无数据 → 回退读localStorage双源数据
4. 工作区: 浏览按钮无事件 → 接入prompt选择 + workspace_dir_stats 命令
5. MCP: 空列表无引导 → 添加配置文件路径提示
6. 新增 workspace_dir_stats Tauri 命令 (Rust)

排查确认正常的功能: 安全存储(OS Keyring), 心跳引擎(运行中),
定时任务(管道连通), Kernel(已初始化), SaaS relay模式
2026-04-10 23:00:19 +08:00
iven
0d815968ca docs: update BREAKS.md + TRUTH.md — all P0/P1/P2 issues marked FIXED
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
BREAKS.md: P1-02, P1-04, P2-03 all marked [FIXED] with commit refs and root cause.
TRUTH.md: Add 2026-04-10 changelog entry for semantic routing + 4 bug fixes.
2026-04-10 21:53:14 +08:00
iven
b2d5b4075c fix(ui): P0-4 — SaaS settings page crash from paginated API response
Some checks failed
CI / Build Frontend (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (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
listRelayTasks() expected RelayTaskInfo[] but API returns
{items:[], total:0, page:1, page_size:20}. When setTasks() received
the paginated object, tasks.map() crashed during render, triggering
the ErrorBoundary fallback "SaaS 平台加载失败".

Fix: extract .items from paginated response with Array.isArray fallback.
Also adds onError logging to ErrorBoundary wrappers for easier debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:42:52 +08:00
iven
34ef41c96f fix(test): P1-02 browser chat — add SaaS auth fixture for non-Tauri mode
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
Root cause: Playwright external Chromium is not a Tauri runtime, so
isTauriRuntime() returns false. The app needs SaaS session to route
chat through relay, but tests never logged in.

Fix: Auto-detect non-Tauri mode and pre-login via SaaS API, injecting
session into localStorage before tests run.
2026-04-10 21:38:34 +08:00
iven
bd48de69ee fix(test): P2-03 rate limit — share auth token across cross-system smoke tests
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 tests each called saasLogin() → 6 login requests in <60s → hit 5/min/IP
rate limit on the 6th test. Now login once per worker, reuse token for all
6 tests. Reduces login API calls from 6 to 1.
2026-04-10 21:34:07 +08:00
iven
80b7ee8868 fix(admin): P1-04 AuthGuard race condition — always validate cookie before render
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
Root cause: loadFromStorage() set isAuthenticated=true from localStorage
without validating the HttpOnly cookie. On page refresh with expired cookie,
children rendered and made failing API calls before AuthGuard could redirect.

Fix:
- authStore: isAuthenticated starts false, never trusted from localStorage
- AuthGuard: always calls GET /auth/me on mount (unless login flow set it)
- Three-state guard (checking/authenticated/unauthenticated) eliminates race
2026-04-10 21:32:14 +08:00
iven
1e675947d5 feat(butler): upgrade ButlerRouter to semantic skill routing
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
Replace keyword-only ButlerRouter with SemanticSkillRouter (TF-IDF).
75 skills now participate in intent classification instead of 4 hardcoded domains.

- Expose ButlerRouterBackend trait + RoutingHint as pub
- Add with_router() constructor for injecting custom backends
- Add SemanticRouterAdapter in kernel layer (bridges skills ↔ runtime)
- Enhance context injection with skill-level match info
2026-04-10 21:24:30 +08:00
iven
88cac9557b fix(saas): P0-2/P0-3 — usage endpoint + refresh token type mismatch
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-2: GET /usage 500 "text >= timestamptz" — usage_records.created_at
is TEXT in actual DB despite migration declaring TIMESTAMPTZ. Fixed by
using dynamic SQL with ::timestamptz explicit casts for all date
comparisons, avoiding sqlx NULL-without-type-OID binding issues.

P0-3: POST /auth/refresh 500 — refresh_tokens.expires_at/used_at are
TEXT columns. Added ::timestamptz cast to SQL queries in auth handlers
and cleanup worker.
2026-04-10 16:25:52 +08:00
iven
12a018cc74 docs: update BREAKS.md — P0-01/P1-01/P1-03 marked FIXED
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
21/30 tests now pass (70%). Remaining: P1-02 Desktop browser chat.
2026-04-10 12:16:37 +08:00
iven
b0e6654944 fix: P0-01/P1-01/P1-03 — account lockout, token revocation, optional display_name
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-01: Account lockout now enforced via SQL-level comparison
  (locked_until > NOW()) instead of broken RFC3339 text parsing
- P1-01: Logout handler accepts JSON body with optional refresh_token,
  revokes ALL refresh tokens for the account (not just current)
- P1-03: Provider display_name is now optional, falls back to name

All 6 smoke tests pass (S1-S6).
2026-04-10 12:13:53 +08:00
iven
8163289454 fix(ui): show panel toggle button in all modes (not just non-compact)
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-10 12:13:37 +08:00
iven
34043de685 fix(ui): panel toggle in header bar + message spacing
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
- Move side panel toggle from floating button to chat header right side
  (Trae Solo style) via new PanelToggleButton component
- Add px-6 py-4 padding to message list container
- Add mb-5 gap between messages for readable vertical spacing
2026-04-10 12:03:29 +08:00
iven
99262efca4 test: execute 30 smoke tests + fix P0 CSS break + BREAKS.md report
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
Layer 1 break detection results (21/30 pass, 63%):
- SaaS API: 5/5 pass (S3 skip no LLM key)
- Admin V2: 5/6 pass (A6 flaky auth guard)
- Desktop Chat: 3/6 pass (D1 no chat response in browser; D2/D3 skip non-Tauri)
- Desktop Feature: 6/6 pass
- Cross-System: 2/6 pass (4 blocked by login rate limit 429)

Bugs found:
- P0-01: Account lockout not enforced (locked_until set but not checked)
- P1-01: Refresh token still valid after logout
- P1-02: Desktop browser chat no response (stores not exposed)
- P1-03: Provider API requires display_name (undocumented)

Fixes applied:
- desktop/src/index.css: @import -> @plugin for Tailwind v4 compatibility
- Admin tests: correct credentials admin/admin123 from .env
- Cross tests: correct dashboard endpoint /stats/dashboard
2026-04-10 11:26:13 +08:00
iven
2e70e1a3f8 test: add 30 smoke tests for break detection across SaaS/Admin/Desktop
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
Layer 1 断裂探测矩阵:
- S1-S6: SaaS API 端到端 (auth/lockout/relay/permissions/billing/knowledge)
- A1-A6: Admin V2 连通性 (login/dashboard/CRUD/knowledge/roles/models)
- D1-D6: Desktop 聊天流 (gateway/kernel/relay/cancel/offline/error)
- F1-F6: Desktop 功能闭环 (agent/hands/pipeline/memory/butler/skills)
- X1-X6: 跨系统闭环 (provider→desktop/disabled user/knowledge/stats/totp/billing)

Also adds: admin-v2 Playwright config, updated spec doc with cross-reference

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 09:47:35 +08:00
iven
ffa137eff6 test(saas): add 8 model config extended tests — encryption, groups, quota
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
- API Key encryption at rest: verify enc: prefix in DB for provider keys
  and main provider api_key
- Key pool: toggle active/inactive + delete with DB state verification
- Model Groups: full CRUD lifecycle + cascade delete + user permission
- Quota enforcement: relay_requests exhaustion verified at DB level
  (middleware test infra issue noted — DB state confirmed correct)
- Provider disable: model hidden from relay/models list after disable
2026-04-10 09:20:06 +08:00
iven
c37c7218c2 test(saas): add 36 security/validation/permission tests (184 total, 0 failures)
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
New test files:
- auth_security_test.rs (12): account lockout DB state, lockout reset,
  password version invalidation, disabled account, refresh token
  revocation, boundary validation (username/password), role enforcement,
  TOTP 2FA flow
- account_security_test.rs (9): role management, privilege escalation
  prevention, account disable/enable, cross-account access control,
  operation logs
- relay_validation_test.rs (8): input validation (missing fields, empty
  messages, invalid roles), disabled provider, model listing, task
  isolation
- permission_matrix_test.rs (7): super_admin full access, user allowed/
  forbidden endpoints, public endpoints, unauthenticated rejection,
  API token lifecycle

Discovered: account lockout runtime check broken — handlers.rs:213
parse_from_rfc3339 fails on PostgreSQL TIMESTAMPTZ::TEXT format,
silently skipping lockout. DB state is correct but login not rejected.
2026-04-10 08:11:02 +08:00
iven
ca2581be90 test(admin): sync page tests with component changes (BUG-007)
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
Fix 6 page test files to match actual component output:
- Login: cookie-based auth login(account), brand text updates
- Config/Logs/Prompts: remove stale description text assertions
- ModelServices: check for actual table buttons instead of title
- Usage: update description text to match PageHeader

All 132 tests pass (17/17 files).
2026-04-10 07:50:39 +08:00
iven
2c8ab47e5c fix: BUG-012/013/007 — panel overlap, Markdown rendering, authStore tests
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
BUG-012: Reposition side panel toggle button (top-[52px]→top-20) to
avoid overlap with header buttons in ResizableChatLayout.

BUG-013: Install @tailwindcss/typography plugin and import in index.css
to enable prose-* Markdown rendering classes in StreamingText.

BUG-007: Rewrite authStore tests to match HttpOnly cookie auth model
(login takes 1 arg, no token/refreshToken in state). Rewrite request
interceptor tests for cookie-based auth. Update bug-tracker status.
2026-04-10 07:44:34 +08:00
iven
26336c3daa fix(ui): button overlap + Markdown rendering (BUG-012, BUG-013)
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
BUG-012: Move side panel toggle button below header (top-3 → top-[52px])
to avoid overlap with "详情" button in chat header.

BUG-013: Add rich Markdown component overrides to StreamingText:
- Code blocks: dark bg, border, rounded, overflow-x-auto
- Inline code: subtle bg highlight
- Tables: full borders, alternating header bg, proper padding
- Lists: disc/decimal markers, spacing
- Headings: proper hierarchy sizes
- Blockquotes: left border + subtle bg
- Links: blue underlined with hover
2026-04-09 23:58:00 +08:00
iven
3b2209b656 docs: update bug tracker — BUG-009/010/011 marked FIXED
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-09 23:46:19 +08:00
iven
ba586e5aa7 fix: BUG-009/010/011 — DataMasking, cancel button, SQL casts
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
BUG-009 (P1): Add frontend DataMasking in saas-relay-client.ts
- Masks ID cards, phones, emails, money, company names before relay
- Unmasks tokens in AI response so user sees original data
- Mirrors Rust DataMasking middleware patterns

BUG-010 (P3): Send button transforms to Stop during streaming
- Shows square icon when isStreaming, calls cancelStream()
- Normal arrow icon when idle, calls handleSend()

BUG-011 (P2): Add ::timestamptz casts for old TEXT timestamp columns
- account/handlers.rs: dashboard stats query
- telemetry/service.rs: reported_at comparisons
- workers/aggregate_usage.rs: usage aggregation query
2026-04-09 23:45:19 +08:00
iven
a304544233 docs: update bug tracker with UI issues + untestable scenarios
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
New bugs from user review:
- BUG-012 (P2): side panel button overlaps with detail button
- BUG-013 (P2): AI response Markdown not rendered, poor formatting

Added detailed section for untestable scenarios:
- 6 scenarios need Tauri local kernel mode
- 4 scenarios need physical environment changes
- 2 scenarios need Admin backend verification
2026-04-09 23:40:28 +08:00
iven
5ae80d800e test: complete exploratory test results for all 4 storylines + sign-off
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
Storyline 3 (极客张):
- 3.4 PASS: SaaS Relay SSE chain verified
- 3.6 FAIL: BUG-009 confirmed - middleware chain bypassed
- Others NOT TESTED: require Tauri kernel local mode

Storyline 4 (妈妈):
- 4.1 PASS: simple mode UI + message flow
- Others SKIP/NOT TESTED: voice input, cold start

Sign-off report updated with full test matrix and release recommendation.
Blocking: BUG-009 (DataMasking bypass in SaaS Relay mode)
2026-04-09 23:21:41 +08:00
iven
71cfcf1277 test: final exploratory test report — 82% pass rate, conditional release
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
50 test items: 4 storylines + 21 module P0 + 6 Admin pages
41 PASS / 2 FAIL / 7 SKIP or N/A
Key blocker: BUG-009 (middleware bypass in SaaS Relay)
Recommendation: conditional release, prioritize BUG-009 fix
2026-04-09 23:12:04 +08:00
iven
b87e4379f6 test: module matrix P0 verification + Admin V2 results
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
15/21 P0 items PASS, 5 SKIP (Tauri-only), 2 PARTIAL
Admin V2: accounts/model-services/relay pages working
New: BUG-011 (P2) Admin dashboard SQL type error
2026-04-09 23:09:33 +08:00
iven
20b856cfb2 test: complete storyline-2 results (BUG-008 fix verification)
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
All scenarios PASS after BUG-008 fix:
- 2.1 PASS: teacher role recognition + proactive help
- 2.2 PASS: 5 quiz questions + answers + analysis
- 2.3 PASS: 10-page courseware + interactive elements
- 2.4 PARTIAL: speech guidance ok, TTS not triggered (SaaS Relay)

Known: All Hands (Quiz/Slideshow/Speech) bypassed in SaaS Relay mode (BUG-009)
2026-04-09 23:06:43 +08:00
iven
87537e7c53 test: storyline 2/3/4 exploratory test results + BUG-009/010
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
Storyline 2 (Teacher): 3 PASS, 1 FAIL (BUG-008 confirmed)
Storyline 3 (Tech user): SSE verified, model switch OK, BUG-009 middleware bypass
Storyline 4 (Mom): 3 PASS, scene 4.3 anomalous BUG-008 behavior with kimi

New findings:
- BUG-009 (P1): SaaS Relay bypasses all 14 middleware layers
- BUG-010 (P3): No cancel button during streaming
2026-04-09 23:02:58 +08:00
iven
448b89e682 test: complete storyline-1 results (1.5-1.8) + BUG-008 tracker update
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
- 1.5 PASS: policy compliance check with 6-clause analysis + PPT outline
- 1.6 PASS: BUG-008 fix verified, AI correctly references prior context
- 1.7 PARTIAL: NlScheduleParser not triggered in SaaS Relay mode
- 1.8 NOT TESTED: requires physical network disconnect
2026-04-09 22:56:25 +08:00
iven
9442471c98 fix(relay): send conversation history to SaaS relay (BUG-008)
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
SaaS Relay was sending only the current message without conversation
history, giving LLM no context from previous turns. Root cause:
streamStore passed only `content` string to chatStream(), and
saas-relay-client hard-coded a single-element messages array.

Fix:
- GatewayClient.chatStream() opts: add `history` field
- streamStore: extract last 20 messages as history before calling chatStream
- saas-relay-client: build messages array from history + current message
2026-04-09 22:41:56 +08:00
iven
f8850ba95a test: add storyline-1 test results + update bug tracker
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
Storyline 1 (医院行政小李) results:
- 1.1 SKIP (非首次安装)
- 1.2 PASS (首次对话科室识别)
- 1.3 PASS (会议纪要)
- 1.4 NOT TESTED (Collector Hand)
- Found BUG-003 (require→import), BUG-004 (health formula), BUG-008 (no history)
2026-04-09 22:33:17 +08:00
iven
bf728c34f3 fix: saasStore require() bug + health check pool formula + DEV error details
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
- saasStore.ts: replace require('./chat/conversationStore') with await import()
  to fix ReferenceError in Vite ESM environment (P1)
- main.rs: fix health check pool usage formula from max_connections - num_idle
  to pool.size() - num_idle, preventing false "degraded" status (P1)
- error.rs: show detailed error messages in ZCLAW_SAAS_DEV=true mode
- Update bug tracker with BUG-003 through BUG-007
2026-04-09 22:23:05 +08:00
iven
bd6cf8e05f fix(saas): add ::bigint cast to all SUM() aggregates for PG NUMERIC compat
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
PostgreSQL SUM() on bigint returns NUMERIC, causing sqlx decode errors
when Rust expects i64/Option<i64>. Root cause: key_pool.rs
select_best_key() token_count SUM was missing ::bigint, causing
DATABASE_ERROR on every relay request.

Fixed in 4 files:
- relay/key_pool.rs: SUM(token_count) — root cause of relay failure
- relay/service.rs: SUM(remaining_rpm) in sort_candidates_by_quota
- account/handlers.rs: SUM(input/output_tokens) in dashboard stats
- workers/aggregate_usage.rs: SUM(input/output_tokens) in aggregation
2026-04-09 22:16:27 +08:00
iven
0054b32c61 chore(test): create exploratory test result directory and templates
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-09 20:53:45 +08:00
iven
a081a97678 fix(relay): audit fixes — abort signal, model selector guard, SSE CRLF, SQL format
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
Addresses findings from deep code audit:

H-1: Pass abortController.signal to saasClient.chatCompletion() so
     user-cancelled streams actually abort the HTTP connection (was only
     stopping the read loop, leaving server-side SSE connection open).

H-2: ModelSelector now shows only when (!isTauriRuntime() || isLoggedIn).
     Prevents decorative model list in Tauri local kernel mode where model
     selection has no effect (violates CLAUDE.md §5.2).

M-1: Normalize CRLF to LF before SSE event boundary parsing (\n\n).
     Prevents buffer overflow when behind nginx/CDN with CRLF line endings.

M-2: SQL window_minute comparison uses to_char(NOW()-interval, format)
     instead of (NOW()-interval)::TEXT, matching the stored format exactly.

M-3: sort_candidates_by_quota uses same sliding 60s window as select_best_key.

LOW: Fix misleading invalidate_cache doc comment.
2026-04-09 19:51:34 +08:00
iven
e6eb97dcaa perf(relay): full-chain optimization — key pool, model sync, SSE stream
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
Phase 1 (Key Pool correctness):
- RPM: fixed-minute window → sliding 60s aggregation (prevents 2x burst)
- Remove fallback-to-provider-key bypass when all keys rate-limited
- SSE semaphore: 16→64 permits, cleanup delay 60s→5s
- Default 429 cooldown: 5min→60s (better for Coding Plan quotas)
- Expire old key_usage_window rows on record

Phase 2 (Frontend model sync):
- currentModel empty-string fallback to glm-4-flash-250414 in relay client
- Merge duplicate listModels() calls in connectionStore SaaS path
- Show ModelSelector in Tauri mode when models available
- Clear currentModel on SaaS logout

Phase 3 (Relay performance):
- Key Pool: DashMap in-memory cache (TTL 5s) for select_best_key
- Cache invalidation on 429 marking

Phase 4 (SSE stream):
- AbortController integration for user-cancelled streams
- SSE parsing: split by event boundaries (\n\n) instead of per-line
- streamStore cancelStream adapts to 0-arg and 1-arg cancel fns
2026-04-09 19:34:02 +08:00
iven
5c6964f52a fix(desktop): error response improvements — content, retry, model selector
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: onError callback now sets content to error message instead of empty string.
    Previously API errors (404/429) produced empty assistant messages with only
    a visual error badge — now the error text is persisted in message content.

P3: Retry button now re-sends the preceding user message via sendToGateway
    instead of copying to input. Works for both virtualized and non-virtualized
    message lists. Removed unused setInput prop from MessageBubble.

Also hides model selector in Tauri runtime (SaaS token pool routes models).
2026-04-09 18:52:27 +08:00
iven
125da57436 fix: sync currentModel from SaaS available models on login
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
Root cause: conversationStore hardcoded 'glm-4-flash' as default model,
which may not exist in SaaS admin config, causing 404 on all chat requests.

- conversationStore: default currentModel to empty string (runtime-resolved)
- saasStore: after fetching available models, auto-switch currentModel
  to first available if the stored model is not in the list
- SaaS relay getModel() already had fallback to first available model
2026-04-09 18:50:38 +08:00
iven
1965fa5269 fix: migrate glm-4-flash to glm-4-flash-250414 (model deprecated by Zhipu)
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
Zhipu AI has deprecated glm-4-flash, causing 404 errors on all chat requests.
Updated all references:
- config: glm-4-flash → glm-4-flash-250414, added glm-z1-flash
- frontend: defaultModel, conversationStore, ChatArea fallback, ModelsAPI
2026-04-09 18:42:47 +08:00
iven
5f47e62a46 fix(desktop): hide model selector in Tauri runtime — SaaS token pool routes models
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
Model selector was cosmetic-only in desktop mode: chatStream never passes
model param to backend. Hiding prevents user confusion and 404 errors when
selecting models not in SaaS token pool.

Also adds E2E test report covering 168 messages, 4 bugs found (P0 fixed).
2026-04-09 18:35:34 +08:00
iven
4c325de6c3 docs: update CLAUDE.md §13 + TRUTH.md for Hermes Intelligence Pipeline
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
- §13: Add Hermes pipeline subsystem (4 chunks: experience/user profile/NL cron/trajectory)
- §13: Update middleware count 13→14 (TrajectoryRecorder@650)
- §13: Update recent changes with Hermes entry
- TRUTH.md: Update test count, middleware count, add change log entry
2026-04-09 17:52:15 +08:00
iven
d6ccb18336 docs: add pre-release functional test design + screenshots 2026-04-09 17:48:40 +08:00
iven
2f25316e83 feat(desktop): simple mode UI — ChatArea compact + SimpleSidebar + RightPanel dual-mode
Adapt ChatArea for compact/butler mode:
- Add onOpenDetail prop for expanding to full view
- Remove inline export dialog (moved to detail view)
- Replace SquarePen with ClipboardList icon

Add SimpleSidebar component for butler simple mode:
- Two tabs: 对话 / 行业资讯
- Quick suggestion buttons
- Minimal navigation

RightPanel refactoring for dual-mode support:
- Detect simple vs professional mode
- Conditional rendering based on butler mode state
2026-04-09 17:48:18 +08:00
iven
4b15ead8e7 feat(hermes): implement intelligence pipeline — 4 chunks, 684 tests passing
Hermes Intelligence Pipeline closes breakpoints in ZCLAW's existing
intelligence components with 4 self-contained modules:

Chunk 1 — Self-improvement Loop:
- ExperienceStore (zclaw-growth): FTS5+TF-IDF wrapper with scope prefix
- ExperienceExtractor (desktop/intelligence): template-based extraction
  from successful proposals with implicit keyword detection

Chunk 2 — User Modeling:
- UserProfileStore (zclaw-memory): SQLite-backed structured profiles
  with industry/role/expertise/comm_style/recent_topics/pain_points
- UserProfiler (desktop/intelligence): fact classification by category
  (Preference/Knowledge/Behavior) with profile summary formatting

Chunk 3 — NL Cron Chinese Time Parser:
- NlScheduleParser (zclaw-runtime): 6 pattern matchers for Chinese time
  expressions (每天/每周/工作日/间隔/每月/一次性) producing cron expressions
- Period-aware hour adjustment (下午3点→15, 晚上8点→20)
- Schedule intent detection + task description extraction

Chunk 4 — Trajectory Compression:
- TrajectoryStore (zclaw-memory): trajectory_events + compressed_trajectories
- TrajectoryRecorderMiddleware (zclaw-runtime/middleware): priority 650,
  async non-blocking event recording via tokio::spawn
- TrajectoryCompressor (desktop/intelligence): dedup, request classification,
  satisfaction detection, execution chain JSON

Schema migrations: v2→v3 (user_profiles), v3→v4 (trajectory tables)
2026-04-09 17:47:43 +08:00
iven
0883bb28ff fix: validation hardening — agent import prompt limit, relay retry tracking, heartbeat validation
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_import: add system_prompt length validation (max 50K chars)
  to prevent excessive token consumption from imported configs
- relay retry_task: wrap JoinHandle to log abort on server shutdown
- device_heartbeat: validate device_id length (1-64 chars) matching
  register endpoint constraints
2026-04-09 17:24:36 +08:00
iven
cf9b258c6c docs: pre-release test report + TRUTH.md numbers update
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
- Add comprehensive pre-release test report (code-level audit)
- Update TRUTH.md: SaaS endpoints 130→140, middleware 12→13
- Update CLAUDE.md stabilization table with correct numbers
- Mark all blocking bugs as resolved in test report
2026-04-09 16:44:54 +08:00
iven
3f2acb49fb fix: pre-release audit fixes — Twitter OAuth, DataMasking perf, Prompt versioning
- Twitter like/retweet: return explicit unavailable error instead of
  sending doomed Bearer token requests (would 403 on Twitter API v2)
- DataMasking: pre-compile regex patterns with LazyLock (was compiling
  6 patterns on every mask() call)
- Prompt version: fix get_version handler ignoring version path param,
  add service::get_version_by_number for correct per-version retrieval
2026-04-09 16:43:24 +08:00
724 changed files with 66118 additions and 23369 deletions

View File

@@ -44,3 +44,12 @@ ZCLAW_EMBEDDING_MODEL=text-embedding-3-small
# === Logging ===
# 可选: debug, info, warn, error
ZCLAW_LOG_LEVEL=info
# === SaaS Backend ===
ZCLAW_SAAS_JWT_SECRET=
ZCLAW_TOTP_ENCRYPTION_KEY=
ZCLAW_ADMIN_USERNAME=
ZCLAW_ADMIN_PASSWORD=
DB_PASSWORD=
ZCLAW_DATABASE_URL=
ZCLAW_SAAS_DEV=false

View File

@@ -50,7 +50,7 @@ jobs:
- name: Rust Clippy
working-directory: .
run: cargo clippy --workspace -- -D warnings
run: cargo clippy --workspace --exclude zclaw-saas -- -D warnings
- name: Install frontend dependencies
working-directory: desktop
@@ -94,7 +94,7 @@ jobs:
- name: Run Rust tests
working-directory: .
run: cargo test --workspace
run: cargo test --workspace --exclude zclaw-saas
- name: Install frontend dependencies
working-directory: desktop
@@ -138,7 +138,7 @@ jobs:
- name: Rust release build
working-directory: .
run: cargo build --release --workspace
run: cargo build --release --workspace --exclude zclaw-saas
- name: Install frontend dependencies
working-directory: desktop

View File

@@ -45,7 +45,7 @@ jobs:
- name: Run Rust tests
working-directory: .
run: cargo test --workspace
run: cargo test --workspace --exclude zclaw-saas
- name: Install frontend dependencies
working-directory: desktop

164
BREAKS.md Normal file
View File

@@ -0,0 +1,164 @@
# ZCLAW 断裂探测报告 (BREAKS.md)
> **生成时间**: 2026-04-10
> **更新时间**: 2026-04-10 (P0-01, P1-01, P1-03, P1-02, P1-04, P2-03 已修复)
> **测试范围**: Layer 1 断裂探测 — 30 个 Smoke Test
> **最终结果**: 21/30 通过 (70%), 0 个 P0 bug, 0 个 P1 bug所有已知问题已修复
---
## 测试执行总结
| 域 | 测试数 | 通过 | 失败 | Skip | 备注 |
|----|--------|------|------|------|------|
| SaaS API (S1-S6) | 6 | 5 | 0 | 1 | S3 需 LLM API Key 已 SKIP |
| Admin V2 (A1-A6) | 6 | 5 | 1 | 0 | A6 间歇性失败 (AuthGuard 竞态) |
| Desktop Chat (D1-D6) | 6 | 3 | 1 | 2 | D1 聊天无响应; D2/D3 非 Tauri 环境 SKIP |
| Desktop Feature (F1-F6) | 6 | 6 | 0 | 0 | 全部通过 (探测模式) |
| Cross-System (X1-X6) | 6 | 2 | 4 | 0 | 4个因登录限流 429 失败 |
| **总计** | **30** | **21** | **6** | **3** | |
---
## P0 断裂 (立即修复)
### ~~P0-01: 账户锁定未强制执行~~ [FIXED]
- **测试**: S2 (s2_account_lockout)
- **严重度**: P0 — 安全漏洞
- **修复**: 使用 SQL 层 `locked_until > NOW()` 比较替代 broken 的 RFC3339 文本解析 (commit b0e6654)
- **验证**: `cargo test -p zclaw-saas --test smoke_saas -- s2` PASS
---
## P1 断裂 (当天修复)
### ~~P1-01: Refresh Token 注销后仍有效~~ [FIXED]
- **测试**: S1 (s1_auth_full_lifecycle)
- **严重度**: P1 — 安全缺陷
- **修复**: logout handler 改为接受 JSON body (optional refresh_token),撤销账户所有 refresh token (commit b0e6654)
- **验证**: `cargo test -p zclaw-saas --test smoke_saas -- s1` PASS
### ~~P1-02: Desktop 浏览器模式聊天无响应~~ [FIXED]
- **测试**: D1 (Gateway 模式聊天)
- **严重度**: P1 — 外部浏览器无法使用聊天
- **根因**: Playwright Chromium 非 Tauri 环境,应用走 SaaS relay 路径但测试未预先登录
- **修复**: 添加 Playwright fixture 自动检测非 Tauri 模式并注入 SaaS session (commit 34ef41c)
- **验证**: `npx playwright test smoke_chat` D1 应正常响应
### ~~P1-03: Provider 创建 API 必需 display_name~~ [FIXED]
- **测试**: A2 (Provider CRUD)
- **严重度**: P1 — API 兼容性
- **修复**: `display_name` 改为 `Option<String>`,缺失时 fallback 到 `name` (commit b0e6654)
- **验证**: `cargo test -p zclaw-saas --test smoke_saas -- s3` PASS
### ~~P1-04: Admin V2 AuthGuard 竞态条件~~ [FIXED]
- **测试**: A6 (间歇性失败)
- **严重度**: P1 — 测试稳定性
- **根因**: `loadFromStorage()` 无条件信任 localStorage 设 `isAuthenticated=true`,但 HttpOnly cookie 可能已过期,子组件先渲染后发 401 请求
- **修复**: authStore 初始 `isAuthenticated=false`AuthGuard 三态守卫 (checking/authenticated/unauthenticated),始终先验证 cookie (commit 80b7ee8)
- **验证**: `npx playwright test smoke_admin` A6 连续通过
---
## P2 发现 (本周修复)
### P2-01: /me 端点不返回 pwv 字段
- JWT claims 含 `pwv`password_version`GET /me` 不返回 → 前端无法客户端检测密码变更
### P2-02: 知识搜索即时性不足
- 创建知识条目后立即搜索可能找不到embedding 异步生成中)
### ~~P2-03: 测试登录限流冲突~~ [FIXED]
- **根因**: 6 个 Cross 测试各调一次 `saasLogin()` → 6 次 login/分钟 → 触发 5次/分钟/IP 限流
- **修复**: 测试共享 token6 个测试只 login 一次 (commit bd48de6)
- **验证**: `npx playwright test smoke_cross` 不再因 429 失败
---
## 已修复 (本次探测中修复)
| 修复 | 描述 |
|------|------|
| P0-02 Desktop CSS | `@import "@tailwindcss/typography"``@plugin "@tailwindcss/typography"` (Tailwind v4 语法) |
| Admin 凭据 | `testadmin/Admin123456``admin/admin123` (来自 .env) |
| Dashboard 端点 | `/dashboard/stats``/stats/dashboard` |
| Provider display_name | 添加缺失的 `display_name` 字段 |
---
## 已通过测试 (21/30)
| ID | 测试名称 | 验证内容 |
|----|----------|----------|
| S1 | 认证闭环 | register→login→/me→refresh→logout |
| S2 | 账户锁定 | 5次失败→locked_until设置→DB验证 |
| S4 | 权限矩阵 | super_admin 200 + user 403 + 未认证 401 |
| S5 | 计费闭环 | dashboard stats + billing usage + plans |
| S6 | 知识检索 | category→item→search→DB验证 |
| A1 | 登录→Dashboard | 表单登录→统计卡片渲染 |
| A2 | Provider CRUD | API 创建+页面可见 |
| A3 | Account 管理 | 表格加载、角色列可见 |
| A4 | 知识管理 | 分类→条目→页面加载 |
| A5 | 角色权限 | 页面加载+API验证 |
| D4 | 流取消 | 取消按钮点击+状态验证 |
| D5 | 离线队列 | 断网→发消息→恢复→重连 |
| D6 | 错误恢复 | 无效模型→错误检测→恢复 |
| F1 | Agent 生命周期 | Store 检查+UI 探测 |
| F2 | Hands 触发 | 面板加载+Store 检查 |
| F3 | Pipeline 执行 | 模板列表加载 |
| F4 | 记忆闭环 | Store 检查+面板探测 |
| F5 | 管家路由 | ButlerRouter 分类检查 |
| F6 | 技能发现 | Store/Tauri 检查 |
| X5 | TOTP 流程 | setup 端点调用 |
| X6 | 计费查询 | usage + plans 结构验证 |
---
## 修复优先级路线图
所有 P0/P1/P2 已知问题已修复。剩余 P2 待观察:
```
P2-01 /me 端点不返回 pwv 字段
└── 影响: 前端无法客户端检测密码变更(非阻断)
└── 优先级: 低
P2-02 知识搜索即时性不足
└── 影响: 创建知识条目后立即搜索可能找不到embedding 异步)
└── 优先级: 低
```
---
## 测试基础设施状态
| 项目 | 状态 | 备注 |
|------|------|------|
| SaaS 集成测试框架 | ✅ 可用 | `crates/zclaw-saas/tests/common/mod.rs` |
| Admin V2 Playwright | ✅ 可用 | Chromium 147 + 正确凭据 |
| Desktop Playwright | ✅ 可用 | CSS 已修复 |
| PostgreSQL 测试 DB | ✅ 运行中 | localhost:5432/zclaw |
| SaaS Server | ✅ 运行中 | localhost:8080 |
| Admin V2 dev server | ✅ 运行中 | localhost:5173 |
| Desktop (Tauri dev) | ✅ 可用 | localhost:1420 |
## 验证命令
```bash
# SaaS (需 PostgreSQL)
cargo test -p zclaw-saas --test smoke_saas -- --test-threads=1
# Admin V2
cd admin-v2 && npx playwright test smoke_admin
# Desktop
cd desktop && npx playwright test smoke_chat smoke_features --config tests/e2e/playwright.config.ts
# Cross (需先等 1 分钟让限流重置)
cd desktop && npx playwright test smoke_cross --config tests/e2e/playwright.config.ts
```

105
CLAUDE.md
View File

@@ -1,3 +1,5 @@
@wiki/index.md
# ZCLAW 协作与实现规则
> **ZCLAW 是一个独立成熟的 AI Agent 桌面客户端**,专注于提供真实可用的 AI 能力,而不是演示 UI。
@@ -33,10 +35,10 @@ ZCLAW 是面向中文用户的 AI Agent 桌面端,核心能力包括:
| 禁止行为 | 原因 |
|----------|------|
| 新增 SaaS API 端点 | 已有 130 个(含 2 个 dev-only前端未全部接通 |
| 新增 SaaS API 端点 | 已有 140 个(含 2 个 dev-only前端未全部接通 |
| 新增 SKILL.md 文件 | 已有 75 个,大部分未执行验证 |
| 新增 Tauri 命令 | 已有 18270 个无前端调用且无 @reserved |
| 新增中间件/Store | 已有 12 层中间件 + 18 个 Store |
| 新增 Tauri 命令 | 已有 18970 个无前端调用且无 @reserved |
| 新增中间件/Store | 已有 13 层中间件 + 18 个 Store |
| 新增 admin 页面 | 已有 15 页 |
### 1.4 系统真实状态
@@ -130,19 +132,45 @@ desktop/src-tauri (→ kernel, skills, hands, protocols)
4. **配置问题** - TOML 解析、环境变量
5. **运行时问题** - 服务启动、端口占用
不在根因未明时盲目堆补丁。
不在根因未明时盲目堆补丁。这一步在四阶段工作法的"阶段 2: 制定方案"中完成。
### 3.3 闭环工作法(强制)
### 3.3 四阶段工作法(强制,不可跳过任何阶段
每次改动**必须**按顺序完成以下步骤,不允许跳过:
任何操作 — 无论是修 bug、加功能、重构、还是回答技术问题 — 都必须按以下 4 个阶段执行。不允许跳过、不允许合并阶段。
1. **定位问题** — 理解根因,不盲目堆补丁
2. **最小修复** — 只改必要的代码
3. **自动验证**`tsc --noEmit` / `cargo check` / `vitest run` 必须通过
4. **提交推送** — 按 §11 规范提交,**立即 `git push`**,不积压
5. **文档同步** — 按 §8.3 检查并更新相关文档,提交并推送
#### 阶段 1: 理解背景(先读 wiki
**铁律:步骤 4 和 5 是任务完成的硬性条件。不允许"等一下再提交"或"最后一起推送"。**
**接到任务后,第一件事是阅读 wiki 获取上下文,而不是直接动手。**
1. 读取 `wiki/index.md` — 理解全局架构,利用**症状导航表**快速定位相关模块
2. 读取对应模块页 — 每个模块页统一 5 节结构:设计决策 → 关键文件+集成契约 → 代码逻辑(不变量) → 活跃问题+陷阱 → 变更记录
3. 如涉及已知问题,检查模块页的"活跃问题"节(全局索引见 `wiki/known-issues.md`
**判断标准**: 你能用一句话说清楚"这个改动涉及哪个模块、走哪条数据链路、影响哪些组件"吗?如果不能,你还没读完。
#### 阶段 2: 制定方案(先想清楚再动手)
基于阶段 1 的理解,制定执行方案:
1. **定位根因** — 确认属于哪一类问题(协议/状态/UI/配置/运行时),不盲目堆补丁
2. **确定影响范围** — 哪些文件需要改?哪些 crate 受影响?有没有上下游依赖?
3. **列出执行步骤** — 按顺序列出要改的文件和验证点
4. **预判风险** — 这个改动可能破坏什么?需要跑哪些测试?
**判断标准**: 你能用 3 句话说清楚"改什么、为什么改、改完怎么验证"吗?如果不能,方案还不成熟。
#### 阶段 3: 执行 + 验证
1. **最小修复** — 只改必要的代码
2. **自动验证**`cargo check` / `cargo test` / `tsc --noEmit` / `vitest run` 必须通过
3. **回归测试** — 跑受影响 crate 的全量测试,确认无回归
#### 阶段 4: 提交 + 同步(立即,不积压)
1. **提交推送** — 按 §11 规范提交,**立即 `git push`**
2. **文档同步** — 按 §8.3 检查并更新相关文档,提交并推送
**铁律:不允许"等一下再提交"或"最后一起推送"。每个独立工作单元完成后立即推送。**
***
@@ -225,21 +253,22 @@ Client → 负责网络通信和协议转换
## 6. 自主能力系统 (Hands)
ZCLAW 提供 11 个自主能力包(9 启用 + 2 禁用):
ZCLAW 提供 12 个自主能力包(7 已注册 + 3 开发中 + 2 禁用):
| Hand | 功能 | 状态 |
|------|------|------|
| Browser | 浏览器自动化 | ✅ 可用 |
| Collector | 数据收集聚合 | ✅ 可用 |
| Researcher | 深度研究 | ✅ 可用 |
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
| Twitter | Twitter 自动化 | ✅ 可用12 个 API v2 真实调用,写操作需 OAuth 1.0a |
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo |
| Slideshow | 幻灯片生成 | ✅ 可用 |
| Speech | 语音合成 | ✅ 可用Browser TTS 前端集成完成) |
| Quiz | 测验生成 | ✅ 可用 |
| _reminder | 系统内部提醒 | ✅ 可用kernel 编程注册,无 HAND.toml |
| Whiteboard | 白板演示 | 🚧 开发中HAND.toml 未合并到主分支) |
| Slideshow | 幻灯片生成 | 🚧 开发中HAND.toml 未合并到主分支) |
| Speech | 语音合成 | 🚧 开发中HAND.toml 未合并到主分支) |
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
**触发 Hand 时:**
1. 检查依赖是否满足
@@ -354,6 +383,15 @@ docs/
3. **docs/ARCHITECTURE_BRIEF.md** — 架构决策或关键组件变更时
4. **docs/features/** — 功能状态变化时
5. **docs/knowledge-base/** — 新的排查经验或配置说明
6. **wiki/** — 编译后知识库维护(按触发规则更新对应页面,每页统一 5 节: 设计决策 / 关键文件+集成契约 / 代码逻辑 / 活跃问题+陷阱 / 变更记录):
- 修复 bug → 更新对应模块页"活跃问题"节 + `wiki/known-issues.md` 索引
- 架构变更 → 更新对应模块页"设计决策"节
- 文件结构变化 → 更新对应模块页"关键文件"表
- 跨模块接口变化 → 更新对应模块页"集成契约"表
- 新增不变量发现 → 更新对应模块页"代码逻辑"节的 ⚡ 标记项
- 功能链路变化 → 更新 `wiki/feature-map.md` 索引表
- 数字变化 → 更新 `wiki/index.md` 关键数字表 + `docs/TRUTH.md`
- 每次更新 → 在 `wiki/log.md` 追加一条记录 + 模块页"变更记录"节更新最近 5 条
6. **docs/TRUTH.md** — 数字命令数、Store 数、crates 数等)变化时
#### 步骤 B提交按逻辑分组
@@ -521,7 +559,7 @@ refactor(store): 统一 Store 数据获取方式
***
<!-- ARCH-SNAPSHOT-START -->
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-09 -->
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-15 -->
## 13. 当前架构快照
@@ -529,30 +567,37 @@ refactor(store): 统一 Store 数据获取方式
| 子系统 | 状态 | 最新变更 |
|--------|------|----------|
| 管家模式 (Butler) | ✅ 活跃 | 04-09 ButlerRouter + 双模式UI + 痛点持久化 + 冷启动 |
| 管家模式 (Butler) | ✅ 活跃 | 04-12 行业配置4行业 + 跨会话连续性 + <butler-context> XML fencing |
| Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 |
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
| 记忆管道 (Memory) | ✅ 稳定 | 04-02 闭环修复: 对话→提取→FTS5+TF-IDF→检索→注入 |
| 记忆管道 (Memory) | ✅ 稳定 | 04-17 E2E 验证: 存储+FTS5+TF-IDF+注入闭环,去重+跨会话注入已修复 |
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
| Hands 系统 | ✅ 稳定 | 9 启用 (Browser/Collector/Researcher/Twitter/Whiteboard/Slideshow/Speech/Quiz/Clip) |
| Hands 系统 | ✅ 稳定 | 7 注册 (6 HAND.toml + _reminder)Whiteboard/Slideshow/Speech 开发中 |
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
| 中间件链 | ✅ 稳定 | 12 层 (含 DataMasking@90, ButlerRouter) |
| 中间件链 | ✅ 稳定 | 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) |
### 关键架构模式
- **管家模式**: 双模式UI (默认简洁/解锁专业) + ButlerRouter 4域关键词分类 (healthcare/data_report/policy/meeting) + 冷启动4阶段hook (idle→greeting→waiting→completed) + 痛点双写 (内存Vec+SQLite)
- **Hermes 管线**: 4模块闭环 — ExperienceStore(FTS5经验存取) + UserProfiler(结构化用户画像) + NlScheduleParser(中文时间→cron) + TrajectoryRecorder+Compressor(轨迹记录压缩)。通过中间件链+intelligence hooks调用
- **管家模式**: 双模式UI (默认简洁/解锁专业) + ButlerRouter 动态行业关键词(4内置+自定义) + <butler-context> XML fencing注入 + 跨会话连续性(痛点回访+经验检索) + 触发信号持久化(VikingStorage) + 冷启动4阶段hook
- **聊天流**: 3种实现 → GatewayClient(WebSocket) / KernelClient(Tauri Event) / SaaSRelay(SSE) + 5min超时守护。详见 [ARCHITECTURE_BRIEF.md](docs/ARCHITECTURE_BRIEF.md)
- **客户端路由**: `getClient()` 4分支决策树 → Admin路由 / SaaS Relay(可降级到本地) / Local Kernel / External Gateway
- **SaaS 认证**: JWT→OS keyring 存储 + HttpOnly cookie + Token池 RPM/TPM 限流轮换 + SaaS unreachable 自动降级
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示
- **记忆闭环**: 对话→extraction_adapter→FTS5全文+TF-IDF权重→检索→注入系统提示E2E 04-17 验证通过,去重+跨会话注入已修复)
- **LLM 驱动**: 4 Rust Driver (Anthropic/OpenAI/Gemini/Local) + 国内兼容 (DeepSeek/Qwen/Moonshot 通过 base_url)
### 最近变更
1. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
2. [04-08] 侧边栏 AnimatePresence bug + TopBar 重复 Z 修复 + 发布评估报告
3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite
4. [04-06] 4 个发布前 bug 修复 (身份覆盖/模型配置/agent同步/自动身份)
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
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-17] 全系统 E2E 测试 129 链路: 82 PASS / 20 PARTIAL / 1 FAIL / 26 SKIP有效通过率 79.1%。7 项 Bug 修复 (Dashboard 404/记忆去重/记忆注入/invoice_id/Prompt版本/agent隔离/行业字段)
2. [04-16] 3 项 P0 修复 + 5 项 E2E Bug 修复 + Agent 面板刷新 + TRUTH.md 数字校准
3. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
4. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
5. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
6. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
<!-- ARCH-SNAPSHOT-END -->
@@ -563,7 +608,7 @@ refactor(store): 统一 Store 数据获取方式
### 反模式警告
- ❌ **不要**建议新增 SaaS API 端点 — 已有 130 个,稳定化约束禁止新增
- ❌ **不要**建议新增 SaaS API 端点 — 已有 140 个,稳定化约束禁止新增
- ❌ **不要**忽略管家模式 — 已上线且为默认模式,所有聊天经过 ButlerRouter
- ❌ **不要**假设 Tauri 直连 LLM — 实际通过 SaaS Token 池中转SaaS unreachable 时降级到本地 Kernel
- ❌ **不要**建议从零实现已有能力 — 先查 Hand(9个)/Skill(75个)/Pipeline(17模板) 现有库

776
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.9.0-beta.1"
edition = "2021"
license = "Apache-2.0 OR MIT"
repository = "https://github.com/zclaw/zclaw"
@@ -57,12 +57,15 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
# Database
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "postgres", "chrono"] }
libsqlite3-sys = { version = "0.30", features = ["bundled"] }
# HTTP client (for LLM drivers)
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
# Synchronous HTTP (for WASM host functions in blocking threads)
ureq = { version = "3", features = ["rustls"] }
# URL parsing
url = "2"
@@ -103,7 +106,7 @@ wasmtime-wasi = { version = "43" }
tempfile = "3"
# SaaS dependencies
axum = { version = "0.7", features = ["macros"] }
axum = { version = "0.7", features = ["macros", "multipart"] }
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
tower = { version = "0.4", features = ["util"] }
tower-http = { version = "0.5", features = ["cors", "trace", "limit", "timeout"] }
@@ -112,6 +115,12 @@ argon2 = "0.5"
totp-rs = "5"
hex = "0.4"
# Document processing
pdf-extract = "0.7"
calamine = "0.26"
quick-xml = "0.37"
zip = "2"
# TCP socket configuration
socket2 = { version = "0.5", features = ["all"] }

View File

@@ -26,6 +26,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",

View File

@@ -0,0 +1,50 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Admin V2 E2E 测试配置
*
* 断裂探测冒烟测试 — 验证 Admin V2 页面与 SaaS 后端的连通性
*
* 前提条件:
* - SaaS Server 运行在 http://localhost:8080
* - Admin V2 dev server 运行在 http://localhost:5173
* - 数据库有种子数据 (super_admin: testadmin/Admin123456)
*/
export default defineConfig({
testDir: './tests/e2e',
timeout: 60000,
expect: {
timeout: 10000,
},
fullyParallel: false,
retries: 0,
workers: 1,
reporter: [
['list'],
['html', { outputFolder: 'test-results/html-report' }],
],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10000,
navigationTimeout: 30000,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
],
webServer: {
command: 'pnpm dev --port 5173',
url: 'http://localhost:5173',
reuseExistingServer: true,
timeout: 30000,
},
outputDir: 'test-results/artifacts',
});

View File

@@ -45,6 +45,9 @@ importers:
'@eslint/js':
specifier: ^9.39.4
version: 9.39.4
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.1))
@@ -552,6 +555,11 @@ packages:
'@oxc-project/types@0.122.0':
resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==}
'@playwright/test@1.59.1':
resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==}
engines: {node: '>=18'}
hasBin: true
'@rc-component/async-validator@5.1.0':
resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==}
engines: {node: '>=14.x'}
@@ -1662,6 +1670,11 @@ packages:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2054,6 +2067,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
playwright-core@1.59.1:
resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.59.1:
resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
@@ -3211,6 +3234,10 @@ snapshots:
'@oxc-project/types@0.122.0': {}
'@playwright/test@1.59.1':
dependencies:
playwright: 1.59.1
'@rc-component/async-validator@5.1.0':
dependencies:
'@babel/runtime': 7.29.2
@@ -4370,6 +4397,9 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -4704,6 +4734,14 @@ snapshots:
picomatch@4.0.4: {}
playwright-core@1.59.1: {}
playwright@1.59.1:
dependencies:
playwright-core: 1.59.1
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.8:
dependencies:
nanoid: 3.3.11

View File

@@ -21,6 +21,7 @@ import {
SafetyOutlined,
FieldTimeOutlined,
SyncOutlined,
ShopOutlined,
} from '@ant-design/icons'
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
import { useAuthStore } from '@/stores/authStore'
@@ -50,6 +51,7 @@ const navItems: NavItem[] = [
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
{ path: '/scheduled-tasks', name: '定时任务', icon: <FieldTimeOutlined />, permission: 'scheduler:read', group: '运维' },
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read', group: '资源管理' },
{ path: '/industries', name: '行业配置', icon: <ShopOutlined />, permission: 'config:read', group: '资源管理' },
{ path: '/billing', name: '计费管理', icon: <CrownOutlined />, permission: 'billing:read', group: '核心' },
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
{ path: '/config-sync', name: '同步日志', icon: <SyncOutlined />, permission: 'config:read', group: '运维' },
@@ -115,7 +117,7 @@ function Sidebar({
const isActive =
item.path === '/'
? activePath === '/'
: activePath.startsWith(item.path)
: activePath === item.path || activePath.startsWith(item.path + '/')
const btn = (
<button
@@ -219,6 +221,7 @@ const breadcrumbMap: Record<string, string> = {
'/knowledge': '知识库',
'/billing': '计费管理',
'/config': '系统配置',
'/industries': '行业配置',
'/prompts': '提示词管理',
'/logs': '操作日志',
'/config-sync': '同步日志',

View File

@@ -2,12 +2,14 @@
// 账号管理
// ============================================================
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space, Divider } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { accountService } from '@/services/accounts'
import { industryService } from '@/services/industries'
import { billingService } from '@/services/billing'
import { PageHeader } from '@/components/PageHeader'
import type { AccountPublic } from '@/types'
@@ -47,13 +49,39 @@ export default function Accounts() {
queryFn: ({ signal }) => accountService.list(searchParams, signal),
})
// 获取行业列表(用于下拉选择)
const { data: industriesData } = useQuery({
queryKey: ['industries-all'],
queryFn: ({ signal }) => industryService.list({ page: 1, page_size: 100, status: 'active' }, signal),
})
// 获取当前编辑用户的行业授权
const { data: accountIndustries } = useQuery({
queryKey: ['account-industries', editingId],
queryFn: ({ signal }) => industryService.getAccountIndustries(editingId!, signal),
enabled: !!editingId,
})
// 当账户行业数据加载完且正在编辑时,同步到表单
// Guard: only sync when editingId matches the query key
useEffect(() => {
if (accountIndustries && editingId) {
const ids = accountIndustries.map((item) => item.industry_id)
form.setFieldValue('industry_ids', ids)
}
}, [accountIndustries, editingId, form])
// 获取所有活跃计划(用于管理员切换)
const { data: plansData } = useQuery({
queryKey: ['billing-plans'],
queryFn: ({ signal }) => billingService.listPlans(signal),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
accountService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['accounts'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
@@ -68,6 +96,26 @@ export default function Accounts() {
onError: (err: Error) => message.error(err.message || '状态更新失败'),
})
// 设置用户行业授权
const setIndustriesMutation = useMutation({
mutationFn: ({ accountId, industries }: { accountId: string; industries: string[] }) =>
industryService.setAccountIndustries(accountId, {
industries: industries.map((id, idx) => ({
industry_id: id,
is_primary: idx === 0,
})),
}),
onError: (err: Error) => message.error(err.message || '行业授权更新失败'),
})
// 管理员切换用户计划
const switchPlanMutation = useMutation({
mutationFn: ({ accountId, planId }: { accountId: string; planId: string }) =>
billingService.adminSwitchPlan(accountId, planId),
onSuccess: () => message.success('计划切换成功'),
onError: (err: Error) => message.error(err.message || '计划切换失败'),
})
const columns: ProColumns<AccountPublic>[] = [
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
@@ -149,14 +197,55 @@ export default function Accounts() {
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
if (!editingId) return
try {
// 更新基础信息
const { industry_ids, plan_id, ...accountData } = values
await updateMutation.mutateAsync({ id: editingId, data: accountData })
// 更新行业授权(如果变更了)
const newIndustryIds: string[] = industry_ids || []
const oldIndustryIds = accountIndustries?.map((i) => i.industry_id) || []
const changed = newIndustryIds.length !== oldIndustryIds.length
|| newIndustryIds.some((id) => !oldIndustryIds.includes(id))
if (changed) {
await setIndustriesMutation.mutateAsync({ accountId: editingId, industries: newIndustryIds })
message.success('行业授权已更新')
queryClient.invalidateQueries({ queryKey: ['account-industries'] })
}
// 切换订阅计划(如果选择了新计划)
if (plan_id) {
await switchPlanMutation.mutateAsync({ accountId: editingId, planId: plan_id })
}
handleClose()
} catch {
// Errors handled by mutation onError callbacks
}
}
const handleClose = () => {
setModalOpen(false)
setEditingId(null)
form.resetFields()
}
const industryOptions = (industriesData?.items || []).map((item) => ({
value: item.id,
label: `${item.icon} ${item.name}`,
}))
const planOptions = (plansData || []).map((plan) => ({
value: plan.id,
label: `${plan.display_name}${(plan.price_cents / 100).toFixed(0)}/月)`,
}))
return (
<div>
<PageHeader title="账号管理" description="管理系统用户账号、角色与权限" />
<PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" />
<ProTable<AccountPublic>
columns={columns}
@@ -169,7 +258,6 @@ export default function Accounts() {
const filtered: Record<string, string> = {}
for (const [k, v] of Object.entries(values)) {
if (v !== undefined && v !== null && v !== '') {
// Map 'username' search field to backend 'search' param
if (k === 'username') {
filtered.search = String(v)
} else {
@@ -192,8 +280,9 @@ export default function Accounts() {
title={<span className="text-base font-semibold"></span>}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={updateMutation.isPending}
onCancel={handleClose}
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending || switchPlanMutation.isPending}
width={560}
>
<Form form={form} layout="vertical" className="mt-4">
<Form.Item name="display_name" label="显示名">
@@ -215,6 +304,36 @@ export default function Accounts() {
{ value: 'relay', label: 'SaaS 中转 (Token 池)' },
]} />
</Form.Item>
<Divider></Divider>
<Form.Item
name="plan_id"
label="切换计划"
extra="选择新计划后保存将立即切换。留空则不修改当前计划。"
>
<Select
allowClear
placeholder="不修改当前计划"
options={planOptions}
loading={!plansData}
/>
</Form.Item>
<Divider></Divider>
<Form.Item
name="industry_ids"
label="授权行业"
extra="第一个行业将设为主行业。行业决定管家可触达的知识域和技能优先级。"
>
<Select
mode="multiple"
placeholder="选择授权的行业"
options={industryOptions}
loading={!industriesData}
/>
</Form.Item>
</Form>
</Modal>
</div>

View File

@@ -0,0 +1,169 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Space, Popconfirm, Typography } from 'antd'
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
import { ProTable } from '@ant-design/pro-components'
import type { ProColumns } from '@ant-design/pro-components'
import { apiKeyService } from '@/services/api-keys'
import type { TokenInfo } from '@/types'
const { Text, Paragraph } = Typography
const PERMISSION_OPTIONS = [
{ label: 'Relay Chat', value: 'relay:use' },
{ label: 'Knowledge Read', value: 'knowledge:read' },
{ label: 'Knowledge Write', value: 'knowledge:write' },
{ label: 'Agent Read', value: 'agent:read' },
{ label: 'Agent Write', value: 'agent:write' },
]
export default function ApiKeys() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [newToken, setNewToken] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const { data, isLoading } = useQuery({
queryKey: ['api-keys', page, pageSize],
queryFn: ({ signal }) => apiKeyService.list({ page, page_size: pageSize }, signal),
})
const createMutation = useMutation({
mutationFn: (values: { name: string; expires_days?: number; permissions: string[] }) =>
apiKeyService.create(values),
onSuccess: (result: TokenInfo) => {
message.success('API 密钥创建成功')
if (result.token) {
setNewToken(result.token)
}
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const revokeMutation = useMutation({
mutationFn: (id: string) => apiKeyService.revoke(id),
onSuccess: () => {
message.success('密钥已吊销')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
},
onError: (err: Error) => message.error(err.message || '吊销失败'),
})
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
const columns: ProColumns<TokenInfo>[] = [
{ title: '名称', dataIndex: 'name', width: 180 },
{
title: '前缀',
dataIndex: 'token_prefix',
width: 120,
render: (val: string) => <Text code>{val}...</Text>,
},
{
title: '权限',
dataIndex: 'permissions',
width: 240,
render: (perms: string[]) =>
perms?.map((p) => <Tag key={p}>{p}</Tag>) || '-',
},
{
title: '最后使用',
dataIndex: 'last_used_at',
width: 180,
render: (val: string) => (val ? new Date(val).toLocaleString() : <Text type="secondary">使</Text>),
},
{
title: '过期时间',
dataIndex: 'expires_at',
width: 180,
render: (val: string) =>
val ? new Date(val).toLocaleString() : <Text type="secondary"></Text>,
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (val: string) => new Date(val).toLocaleString(),
},
{
title: '操作',
width: 100,
render: (_: unknown, record: TokenInfo) => (
<Popconfirm
title="确定吊销此密钥?"
description="吊销后使用该密钥的所有请求将被拒绝"
onConfirm={() => revokeMutation.mutate(record.id)}
>
<Button danger size="small"></Button>
</Popconfirm>
),
},
]
return (
<div style={{ padding: 24 }}>
<ProTable<TokenInfo>
columns={columns}
dataSource={data?.items || []}
loading={isLoading}
rowKey="id"
search={false}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
}}
toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
</Button>,
]}
/>
<Modal
title="创建 API 密钥"
open={createOpen}
onOk={handleCreate}
onCancel={() => { setCreateOpen(false); setNewToken(null); form.resetFields() }}
confirmLoading={createMutation.isPending}
destroyOnHidden
>
{newToken ? (
<div style={{ marginBottom: 16 }}>
<Paragraph type="warning">
</Paragraph>
<Space>
<Text code style={{ fontSize: 13 }}>{newToken}</Text>
<Button
icon={<CopyOutlined />}
size="small"
onClick={() => { navigator.clipboard.writeText(newToken); message.success('已复制') }}
/>
</Space>
</div>
) : (
<Form form={form} layout="vertical">
<Form.Item name="name" label="密钥名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如: 生产环境 API Key" />
</Form.Item>
<Form.Item name="expires_days" label="有效期 (天)">
<InputNumber min={1} max={3650} placeholder="留空表示永不过期" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="permissions" label="权限" rules={[{ required: true, message: '请选择至少一项权限' }]}>
<Select mode="multiple" options={PERMISSION_OPTIONS} placeholder="选择权限" />
</Form.Item>
</Form>
)}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,379 @@
// ============================================================
// 行业配置管理
// ============================================================
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm,
Tabs, Typography, Spin, Empty,
} from 'antd'
import {
PlusOutlined, EditOutlined, CheckCircleOutlined, StopOutlined,
ShopOutlined, SettingOutlined,
} from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { industryService } from '@/services/industries'
import type { IndustryListItem, IndustryFullConfig, UpdateIndustryRequest } from '@/services/industries'
import { PageHeader } from '@/components/PageHeader'
const { TextArea } = Input
const { Text } = Typography
const statusLabels: Record<string, string> = { active: '启用', inactive: '禁用' }
const statusColors: Record<string, string> = { active: 'green', inactive: 'default' }
const sourceLabels: Record<string, string> = { builtin: '内置', admin: '自定义', custom: '自定义' }
// === 行业列表 ===
function IndustryListPanel() {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [filters, setFilters] = useState<{ status?: string; source?: string }>({})
const [editId, setEditId] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['industries', page, pageSize, filters],
queryFn: ({ signal }) => industryService.list({ page, page_size: pageSize, ...filters }, signal),
})
const updateStatusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
industryService.update(id, { status }),
onSuccess: () => {
message.success('状态已更新')
queryClient.invalidateQueries({ queryKey: ['industries'] })
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const columns: ProColumns<IndustryListItem>[] = [
{
title: '图标',
dataIndex: 'icon',
width: 50,
search: false,
render: (_, r) => <span className="text-xl">{r.icon}</span>,
},
{
title: '行业名称',
dataIndex: 'name',
width: 150,
},
{
title: '描述',
dataIndex: 'description',
width: 250,
search: false,
ellipsis: true,
},
{
title: '来源',
dataIndex: 'source',
width: 80,
valueType: 'select',
valueEnum: {
builtin: { text: '内置' },
admin: { text: '自定义' },
custom: { text: '自定义' },
},
render: (_, r) => <Tag color={r.source === 'builtin' ? 'blue' : 'purple'}>{sourceLabels[r.source] || r.source}</Tag>,
},
{
title: '关键词数',
dataIndex: 'keywords_count',
width: 90,
search: false,
render: (_, r) => <Tag>{r.keywords_count}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 80,
valueType: 'select',
valueEnum: {
active: { text: '启用', status: 'Success' },
inactive: { text: '禁用', status: 'Default' },
},
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 160,
valueType: 'dateTime',
search: false,
},
{
title: '操作',
width: 180,
search: false,
render: (_, r) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => setEditId(r.id)}
>
</Button>
{r.status === 'active' ? (
<Popconfirm title="确定禁用此行业?" onConfirm={() => updateStatusMutation.mutate({ id: r.id, status: 'inactive' })}>
<Button type="link" size="small" danger icon={<StopOutlined />}></Button>
</Popconfirm>
) : (
<Popconfirm title="确定启用此行业?" onConfirm={() => updateStatusMutation.mutate({ id: r.id, status: 'active' })}>
<Button type="link" size="small" icon={<CheckCircleOutlined />}></Button>
</Popconfirm>
)}
</Space>
),
},
]
return (
<div>
<ProTable<IndustryListItem>
columns={columns}
dataSource={data?.items || []}
loading={isLoading}
rowKey="id"
search={{
onReset: () => { setFilters({}); setPage(1) },
onSubmit: (values) => { setFilters(values); setPage(1) },
}}
toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
</Button>,
]}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps) },
}}
options={{ density: false, fullScreen: false, reload: () => queryClient.invalidateQueries({ queryKey: ['industries'] }) }}
/>
<IndustryEditModal
open={!!editId}
industryId={editId}
onClose={() => setEditId(null)}
/>
<IndustryCreateModal
open={createOpen}
onClose={() => setCreateOpen(false)}
/>
</div>
)
}
// === 行业编辑弹窗 ===
function IndustryEditModal({ open, industryId, onClose }: {
open: boolean
industryId: string | null
onClose: () => void
}) {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const { data, isLoading } = useQuery({
queryKey: ['industry-full-config', industryId],
queryFn: ({ signal }) => industryService.getFullConfig(industryId!, signal),
enabled: !!industryId,
})
useEffect(() => {
if (data && open && data.id === industryId) {
form.setFieldsValue({
name: data.name,
icon: data.icon,
description: data.description,
keywords: data.keywords,
system_prompt: data.system_prompt,
cold_start_template: data.cold_start_template,
pain_seed_categories: data.pain_seed_categories,
})
}
}, [data, open, industryId, form])
const updateMutation = useMutation({
mutationFn: (body: UpdateIndustryRequest) =>
industryService.update(industryId!, body),
onSuccess: () => {
message.success('行业配置已更新')
queryClient.invalidateQueries({ queryKey: ['industries'] })
queryClient.invalidateQueries({ queryKey: ['industry-full-config'] })
onClose()
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
return (
<Modal
title={<span className="text-base font-semibold"> {data?.name || ''}</span>}
open={open}
onCancel={() => { onClose(); form.resetFields() }}
onOk={() => form.submit()}
confirmLoading={updateMutation.isPending}
width={720}
destroyOnHidden
>
{isLoading ? (
<div className="flex justify-center py-8"><Spin /></div>
) : data ? (
<Form
form={form}
layout="vertical"
className="mt-4"
onFinish={(values) => updateMutation.mutate(values)}
>
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}>
<Input />
</Form.Item>
<Form.Item name="icon" label="图标">
<Input placeholder="行业图标 emoji如 🏥" className="w-32" />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} placeholder="行业简要描述" />
</Form.Item>
<Form.Item name="keywords" label="关键词列表" extra="用于语义路由匹配,回车添加">
<Select mode="tags" placeholder="输入关键词后回车添加" />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词" extra="匹配到此行业时注入的 system prompt">
<TextArea rows={6} placeholder="行业专属系统提示词模板" />
</Form.Item>
<Form.Item name="cold_start_template" label="冷启动模板" extra="首次匹配时的引导消息模板">
<TextArea rows={3} placeholder="冷启动引导消息" />
</Form.Item>
<Form.Item name="pain_seed_categories" label="痛点种子分类" extra="预置的痛点分类维度">
<Select mode="tags" placeholder="输入痛点分类后回车添加" />
</Form.Item>
<div className="mb-2">
<Text type="secondary">
: <Tag color={data.source === 'builtin' ? 'blue' : 'purple'}>{sourceLabels[data.source]}</Tag>
{' '}: <Tag color={statusColors[data.status]}>{statusLabels[data.status]}</Tag>
</Text>
</div>
</Form>
) : (
<Empty description="未找到行业配置" />
)}
</Modal>
)
}
// === 新建行业弹窗 ===
function IndustryCreateModal({ open, onClose }: {
open: boolean
onClose: () => void
}) {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof industryService.create>[0]) =>
industryService.create(data),
onSuccess: () => {
message.success('行业已创建')
queryClient.invalidateQueries({ queryKey: ['industries'] })
onClose()
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
return (
<Modal
title="新建行业"
open={open}
onCancel={() => { onClose(); form.resetFields() }}
onOk={() => form.submit()}
confirmLoading={createMutation.isPending}
width={640}
destroyOnHidden
>
<Form
form={form}
layout="vertical"
className="mt-4"
initialValues={{ icon: '🏢' }}
onFinish={(values) => {
// Auto-generate id from name if not provided
if (!values.id && values.name) {
// Strip non-ASCII, keep only lowercase alphanumeric + hyphens
const generated = values.name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
if (generated) {
values.id = generated
} else {
// Name has no ASCII chars — require manual ID entry
message.warning('中文行业名称无法自动生成标识,请手动填写行业标识')
return
}
}
createMutation.mutate(values)
}}
>
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}>
<Input placeholder="如:医疗健康、教育培训" />
</Form.Item>
<Form.Item name="id" label="行业标识" extra="唯一标识,留空则从名称自动生成。仅限小写字母、数字、连字符" rules={[
{ pattern: /^[a-z0-9-]*$/, message: '仅限小写字母、数字、连字符' },
{ max: 63, message: '最长 63 字符' },
]}>
<Input placeholder="如healthcare、education" />
</Form.Item>
<Form.Item name="icon" label="图标">
<Input placeholder="行业图标 emoji" className="w-32" />
</Form.Item>
<Form.Item name="description" label="描述" rules={[{ required: true, message: '请输入行业描述' }]}>
<TextArea rows={2} placeholder="行业简要描述" />
</Form.Item>
<Form.Item name="keywords" label="关键词列表" extra="用于语义路由匹配,回车添加">
<Select mode="tags" placeholder="输入关键词后回车添加" />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词">
<TextArea rows={4} placeholder="行业专属系统提示词" />
</Form.Item>
<Form.Item name="cold_start_template" label="冷启动模板" extra="新用户首次对话时使用的引导模板">
<TextArea rows={3} placeholder="如:您好!我是您的{行业}管家,可以帮您处理..." />
</Form.Item>
<Form.Item name="pain_seed_categories" label="痛点种子类别" extra="预置的痛点分类,用逗号或回车分隔">
<Select mode="tags" placeholder="如:库存管理、客户服务、合规" />
</Form.Item>
</Form>
</Modal>
)
}
// === 主页面 ===
export default function Industries() {
return (
<div>
<PageHeader title="行业配置" description="管理行业关键词、系统提示词、痛点种子,驱动管家语义路由" />
<Tabs
defaultActiveKey="list"
items={[
{
key: 'list',
label: '行业列表',
icon: <ShopOutlined />,
children: <IndustryListPanel />,
},
]}
/>
</div>
)
}

View File

@@ -19,6 +19,8 @@ import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { knowledgeService } from '@/services/knowledge'
import type { CategoryResponse, KnowledgeItem, SearchResult } from '@/services/knowledge'
import type { StructuredSource } from '@/services/knowledge'
import { TableOutlined } from '@ant-design/icons'
const { TextArea } = Input
const { Text, Title } = Typography
@@ -331,7 +333,7 @@ function ItemsPanel() {
rowKey="id"
search={{
onReset: () => { setFilters({}); setPage(1) },
onSearch: (values) => { setFilters(values); setPage(1) },
onSubmit: (values) => { setFilters(values); setPage(1) },
}}
toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
@@ -708,12 +710,138 @@ export default function Knowledge() {
icon: <BarChartOutlined />,
children: <AnalyticsPanel />,
},
{
key: 'structured',
label: '结构化数据',
icon: <TableOutlined />,
children: <StructuredSourcesPanel />,
},
]}
/>
</div>
)
}
// === Structured Data Sources Panel ===
function StructuredSourcesPanel() {
const queryClient = useQueryClient()
const [viewingRows, setViewingRows] = useState<string | null>(null)
const { data: sources = [], isLoading } = useQuery({
queryKey: ['structured-sources'],
queryFn: ({ signal }) => knowledgeService.listStructuredSources(signal),
})
const { data: rows = [], isLoading: rowsLoading } = useQuery({
queryKey: ['structured-rows', viewingRows],
queryFn: ({ signal }) => knowledgeService.listStructuredRows(viewingRows!, signal),
enabled: !!viewingRows,
})
const deleteMutation = useMutation({
mutationFn: (id: string) => knowledgeService.deleteStructuredSource(id),
onSuccess: () => {
message.success('数据源已删除')
queryClient.invalidateQueries({ queryKey: ['structured-sources'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<StructuredSource>[] = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
{ title: '类型', dataIndex: 'source_type', key: 'source_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '行数', dataIndex: 'row_count', key: 'row_count', width: 80 },
{
title: '列',
dataIndex: 'columns',
key: 'columns',
width: 250,
render: (cols: string[]) => (
<Space size={[4, 4]} wrap>
{(cols ?? []).slice(0, 5).map((c) => (
<Tag key={c} color="blue">{c}</Tag>
))}
{(cols ?? []).length > 5 && <Tag>+{(cols as string[]).length - 5}</Tag>}
</Space>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 140,
render: (_: unknown, record: StructuredSource) => (
<Space>
<Button type="link" size="small" onClick={() => setViewingRows(record.id)}>
</Button>
<Popconfirm title="确认删除此数据源?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]
// Dynamically generate row columns from the first row's keys
const rowColumns = rows.length > 0
? Object.keys(rows[0].row_data).map((key) => ({
title: key,
dataIndex: ['row_data', key],
key,
ellipsis: true,
render: (v: unknown) => String(v ?? ''),
}))
: []
return (
<div className="space-y-4">
{viewingRows ? (
<Card
title="数据行"
extra={<Button onClick={() => setViewingRows(null)}></Button>}
>
{rowsLoading ? (
<Spin />
) : rows.length === 0 ? (
<Empty description="暂无数据" />
) : (
<Table
dataSource={rows}
columns={rowColumns}
rowKey="id"
size="small"
scroll={{ x: true }}
pagination={{ pageSize: 20 }}
/>
)}
</Card>
) : (
<ProTable<StructuredSource>
dataSource={sources}
columns={columns}
loading={isLoading}
rowKey="id"
search={false}
pagination={{ pageSize: 20 }}
toolBarRender={false}
/>
)}
</div>
)
}
// === 辅助函数 ===
// === 辅助函数 ===
function flattenCategories(cats: CategoryResponse[]): { id: string; name: string }[] {

View File

@@ -67,6 +67,7 @@ function ProviderModelsTable({ providerId }: { providerId: string }) {
const columns: ProColumns<Model>[] = [
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <Text code>{r.model_id}</Text> },
{ title: '别名', dataIndex: 'alias', width: 120 },
{ title: '类型', dataIndex: 'is_embedding', width: 80, render: (_, r) => r.is_embedding ? <Tag color="purple">Embedding</Tag> : <Tag>Chat</Tag> },
{ title: '上下文窗口', dataIndex: 'context_window', width: 100, render: (_, r) => r.context_window?.toLocaleString() },
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 90, render: (_, r) => r.max_output_tokens?.toLocaleString() },
{ title: '流式', dataIndex: 'supports_streaming', width: 60, render: (_, r) => r.supports_streaming ? <Tag color="green"></Tag> : <Tag></Tag> },
@@ -128,6 +129,9 @@ function ProviderModelsTable({ providerId }: { providerId: string }) {
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="is_embedding" label="Embedding 模型" valuePropName="checked" style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked" style={{ flex: 1 }}>
<Switch defaultChecked />
</Form.Item>

View File

@@ -327,7 +327,7 @@ export default function ScheduledTasks() {
onCancel={closeModal}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={520}
destroyOnClose
destroyOnHidden
>
<Form form={form} layout="vertical" className="mt-4">
<Form.Item

View File

@@ -3,10 +3,14 @@
// ============================================================
//
// Auth strategy:
// 1. If Zustand has isAuthenticated=true (normal flow after login) -> authenticated
// 2. If isAuthenticated=false but account in localStorage -> call GET /auth/me
// to validate HttpOnly cookie and restore session
// 1. On first mount, always validate the HttpOnly cookie via GET /auth/me
// 2. If cookie valid -> restore session and render children
// 3. If cookie invalid -> clean up and redirect to /login
// 4. If already authenticated (from login flow) -> render immediately
//
// This eliminates the race condition where localStorage had account data
// but the HttpOnly cookie was expired, causing children to render and
// make failing API calls.
import { useEffect, useRef, useState } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
@@ -14,40 +18,44 @@ import { Spin } from 'antd'
import { useAuthStore } from '@/stores/authStore'
import { authService } from '@/services/auth'
type GuardState = 'checking' | 'authenticated' | 'unauthenticated'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const account = useAuthStore((s) => s.account)
const login = useAuthStore((s) => s.login)
const logout = useAuthStore((s) => s.logout)
const location = useLocation()
// Track restore attempt to avoid double-calling
const restoreAttempted = useRef(false)
const [restoring, setRestoring] = useState(false)
// Track validation attempt to avoid double-calling (React StrictMode)
const validated = useRef(false)
const [guardState, setGuardState] = useState<GuardState>(
isAuthenticated ? 'authenticated' : 'checking'
)
useEffect(() => {
if (restoreAttempted.current) return
restoreAttempted.current = true
// If not authenticated but account exists in localStorage,
// try to validate the HttpOnly cookie via /auth/me
if (!isAuthenticated && account) {
setRestoring(true)
authService.me()
.then((meAccount) => {
// Cookie is valid — restore session
login(meAccount)
setRestoring(false)
})
.catch(() => {
// Cookie expired or invalid — clean up stale data
logout()
setRestoring(false)
})
// Already authenticated from login flow — skip validation
if (isAuthenticated) {
setGuardState('authenticated')
return
}
// Prevent double-validation in React StrictMode
if (validated.current) return
validated.current = true
// Validate HttpOnly cookie via /auth/me
authService.me()
.then((meAccount) => {
login(meAccount)
setGuardState('authenticated')
})
.catch(() => {
logout()
setGuardState('unauthenticated')
})
}, []) // eslint-disable-line react-hooks/exhaustive-deps
if (restoring) {
if (guardState === 'checking') {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
@@ -55,7 +63,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
)
}
if (!isAuthenticated) {
if (guardState === 'unauthenticated') {
return <Navigate to="/login" state={{ from: location }} replace />
}

View File

@@ -26,7 +26,7 @@ export const router = createBrowserRouter([
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
{ path: 'billing', lazy: () => import('@/pages/Billing').then((m) => ({ Component: m.default })) },
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
@@ -36,6 +36,7 @@ export const router = createBrowserRouter([
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
{ path: 'config-sync', lazy: () => import('@/pages/ConfigSync').then((m) => ({ Component: m.default })) },
{ path: 'industries', lazy: () => import('@/pages/Industries').then((m) => ({ Component: m.default })) },
],
},
])

View File

@@ -1,13 +1,15 @@
import request, { withSignal } from './request'
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
// 使用 /tokens 路由 (api_tokens 表),前端 UI 字段 {name, expires_days, permissions} 与此后端匹配
// 注: /keys 路由 (account_api_keys 表) 需要 {provider_id, key_value},属于不同的 Key 管理系统
export const apiKeyService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<TokenInfo>>('/keys', withSignal({ params }, signal)).then((r) => r.data),
request.get<PaginatedResponse<TokenInfo>>('/tokens', withSignal({ params }, signal)).then((r) => r.data),
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
request.post<TokenInfo>('/keys', data, withSignal({}, signal)).then((r) => r.data),
request.post<TokenInfo>('/tokens', data, withSignal({}, signal)).then((r) => r.data),
revoke: (id: string, signal?: AbortSignal) =>
request.delete(`/keys/${id}`, withSignal({}, signal)).then((r) => r.data),
request.delete(`/tokens/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -90,4 +90,9 @@ export const billingService = {
getPaymentStatus: (id: string, signal?: AbortSignal) =>
request.get<PaymentStatus>(`/billing/payments/${id}`, withSignal({}, signal))
.then((r) => r.data),
/** 管理员切换用户订阅计划 (super_admin only) */
adminSwitchPlan: (accountId: string, planId: string) =>
request.put<{ success: boolean; subscription: Subscription }>(`/admin/accounts/${accountId}/subscription`, { plan_id: planId })
.then((r) => r.data),
}

View File

@@ -0,0 +1,105 @@
// ============================================================
// 行业配置 API 服务层
// ============================================================
import request, { withSignal } from './request'
import type { PaginatedResponse } from '@/types'
import type { IndustryInfo, AccountIndustryItem } from '@/types'
/** 行业列表项(列表接口返回) */
export interface IndustryListItem {
id: string
name: string
icon: string
description: string
status: string
source: string
keywords_count: number
created_at: string
updated_at: string
}
/** 行业完整配置含关键词、prompt 等) */
export interface IndustryFullConfig {
id: string
name: string
icon: string
description: string
status: string
source: string
keywords: string[]
system_prompt: string
cold_start_template: string
pain_seed_categories: string[]
skill_priorities: Array<{ skill_id: string; priority: number }>
created_at: string
updated_at: string
}
/** 创建行业请求 */
export interface CreateIndustryRequest {
id?: string
name: string
icon: string
description: string
keywords?: string[]
system_prompt?: string
cold_start_template?: string
pain_seed_categories?: string[]
}
/** 更新行业请求 */
export interface UpdateIndustryRequest {
name?: string
icon?: string
description?: string
status?: string
keywords?: string[]
system_prompt?: string
cold_start_template?: string
pain_seed_categories?: string[]
skill_priorities?: Array<{ skill_id: string; priority: number }>
}
/** 设置用户行业请求 */
export interface SetAccountIndustriesRequest {
industries: Array<{
industry_id: string
is_primary: boolean
}>
}
export const industryService = {
/** 行业列表 */
list: (params?: { page?: number; page_size?: number; status?: string }, signal?: AbortSignal) =>
request.get<PaginatedResponse<IndustryListItem>>('/industries', withSignal({ params }, signal))
.then((r) => r.data),
/** 行业详情 */
get: (id: string, signal?: AbortSignal) =>
request.get<IndustryInfo>(`/industries/${id}`, withSignal({}, signal))
.then((r) => r.data),
/** 行业完整配置 */
getFullConfig: (id: string, signal?: AbortSignal) =>
request.get<IndustryFullConfig>(`/industries/${id}/full-config`, withSignal({}, signal))
.then((r) => r.data),
/** 创建行业 */
create: (data: CreateIndustryRequest) =>
request.post<IndustryInfo>('/industries', data).then((r) => r.data),
/** 更新行业 */
update: (id: string, data: UpdateIndustryRequest) =>
request.patch<IndustryInfo>(`/industries/${id}`, data).then((r) => r.data),
/** 获取用户授权行业 */
getAccountIndustries: (accountId: string, signal?: AbortSignal) =>
request.get<AccountIndustryItem[]>(`/accounts/${accountId}/industries`, withSignal({}, signal))
.then((r) => r.data),
/** 设置用户授权行业 */
setAccountIndustries: (accountId: string, data: SetAccountIndustriesRequest) =>
request.put<AccountIndustryItem[]>(`/accounts/${accountId}/industries`, data)
.then((r) => r.data),
}

View File

@@ -62,6 +62,33 @@ export interface ListItemsResponse {
page_size: number
}
// === Structured Data Sources ===
export interface StructuredSource {
id: string
account_id: string
name: string
source_type: string
row_count: number
columns: string[]
created_at: string
updated_at: string
}
export interface StructuredRow {
id: string
source_id: string
row_data: Record<string, unknown>
created_at: string
}
export interface StructuredQueryResult {
row_id: string
source_name: string
row_data: Record<string, unknown>
score: number
}
// === Service ===
export const knowledgeService = {
@@ -159,4 +186,23 @@ export const knowledgeService = {
// 导入
importItems: (data: { category_id: string; files: Array<{ content: string; title?: string; keywords?: string[]; tags?: string[] }> }) =>
request.post('/knowledge/items/import', data).then((r) => r.data),
// === Structured Data Sources ===
listStructuredSources: (signal?: AbortSignal) =>
request.get<StructuredSource[]>('/structured/sources', withSignal({}, signal))
.then((r) => r.data),
getStructuredSource: (id: string, signal?: AbortSignal) =>
request.get<StructuredSource>(`/structured/sources/${id}`, withSignal({}, signal))
.then((r) => r.data),
deleteStructuredSource: (id: string) =>
request.delete(`/structured/sources/${id}`).then((r) => r.data),
listStructuredRows: (sourceId: string, signal?: AbortSignal) =>
request.get<StructuredRow[]>(`/structured/sources/${sourceId}/rows`, withSignal({}, signal))
.then((r) => r.data),
queryStructured: (data: { source_id?: string; query?: string; limit?: number }) =>
request.post<StructuredQueryResult[]>('/structured/query', data).then((r) => r.data),
}

View File

@@ -3,5 +3,5 @@ import type { DashboardStats } from '@/types'
export const statsService = {
dashboard: (signal?: AbortSignal) =>
request.get<DashboardStats>('/stats/dashboard', withSignal({}, signal)).then((r) => r.data),
request.get<DashboardStats>('/admin/dashboard', withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -37,9 +37,11 @@ function loadFromStorage(): { account: AccountPublic | null; isAuthenticated: bo
if (raw) {
try { account = JSON.parse(raw) } catch { /* ignore */ }
}
// If account exists in localStorage, mark as authenticated (cookie validation
// happens in AuthGuard via GET /auth/me — this is just a UI hint)
return { account, isAuthenticated: account !== null }
// IMPORTANT: Do NOT set isAuthenticated = true from localStorage alone.
// The HttpOnly cookie must be validated via GET /auth/me before we trust
// the session. This prevents the AuthGuard race condition where children
// render and make API calls with an expired cookie.
return { account, isAuthenticated: false }
}
interface AuthState {

View File

@@ -44,6 +44,30 @@ export interface PaginatedResponse<T> {
page_size: number
}
/** 行业配置 */
export interface IndustryInfo {
id: string
name: string
icon: string
description: string
status: string
source: string
keywords?: string[]
system_prompt?: string
cold_start_template?: string
pain_seed_categories?: string[]
created_at: string
updated_at: string
}
/** 用户-行业关联 */
export interface AccountIndustryItem {
industry_id: string
is_primary: boolean
industry_name: string
industry_icon: string
}
/** 服务商 (Provider) */
export interface Provider {
id: string
@@ -70,6 +94,8 @@ export interface Model {
supports_streaming: boolean
supports_vision: boolean
enabled: boolean
is_embedding: boolean
model_type: string
pricing_input: number
pricing_output: number
}

View File

@@ -0,0 +1,6 @@
{
"status": "failed",
"failedTests": [
"825d61429c68a1b0492e-735d17b3ccbad35e8726"
]
}

View File

@@ -0,0 +1,196 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: smoke_admin.spec.ts >> A6: 模型服务页面加载→Provider和Model tab可见
- Location: tests\e2e\smoke_admin.spec.ts:179:1
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('#main-content') to be visible
```
# Page snapshot
```yaml
- generic [ref=e1]:
- link "跳转到主要内容" [ref=e2] [cursor=pointer]:
- /url: "#main-content"
- generic [ref=e5]:
- generic [ref=e9]:
- generic [ref=e11]: Z
- heading "ZCLAW" [level=1] [ref=e12]
- paragraph [ref=e13]: AI Agent 管理平台
- paragraph [ref=e15]: 统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
- generic [ref=e17]:
- heading "登录" [level=2] [ref=e18]
- paragraph [ref=e19]: 输入您的账号信息以继续
- generic [ref=e22]:
- generic [ref=e28]:
- img "user" [ref=e30]:
- img [ref=e31]
- textbox "请输入用户名" [active] [ref=e33]
- generic [ref=e40]:
- img "lock" [ref=e42]:
- img [ref=e43]
- textbox "请输入密码" [ref=e45]
- img "eye-invisible" [ref=e47] [cursor=pointer]:
- img [ref=e48]
- button "登 录" [ref=e51] [cursor=pointer]:
- generic [ref=e52]: 登 录
```
# Test source
```ts
1 | /**
2 | * Smoke Tests — Admin V2 连通性断裂探测
3 | *
4 | * 6 个冒烟测试验证 Admin V2 页面与 SaaS 后端的完整连通性。
5 | * 所有测试使用真实浏览器 + 真实 SaaS Server。
6 | *
7 | * 前提条件:
8 | * - SaaS Server 运行在 http://localhost:8080
9 | * - Admin V2 dev server 运行在 http://localhost:5173
10 | * - 种子用户: testadmin / Admin123456 (super_admin)
11 | *
12 | * 运行: cd admin-v2 && npx playwright test smoke_admin
13 | */
14 |
15 | import { test, expect, type Page } from '@playwright/test';
16 |
17 | const SaaS_BASE = 'http://localhost:8080/api/v1';
18 | const ADMIN_USER = 'admin';
19 | const ADMIN_PASS = 'admin123';
20 |
21 | // Helper: 通过 API 登录获取 HttpOnly cookie + 设置 localStorage
22 | async function apiLogin(page: Page) {
23 | const res = await page.request.post(`${SaaS_BASE}/auth/login`, {
24 | data: { username: ADMIN_USER, password: ADMIN_PASS },
25 | });
26 | const json = await res.json();
27 | // 设置 localStorage 让 Admin V2 AuthGuard 认为已登录
28 | await page.goto('/');
29 | await page.evaluate((account) => {
30 | localStorage.setItem('zclaw_admin_account', JSON.stringify(account));
31 | }, json.account);
32 | return json;
33 | }
34 |
35 | // Helper: 通过 API 登录 + 导航到指定路径
36 | async function loginAndGo(page: Page, path: string) {
37 | await apiLogin(page);
38 | // 重新导航到目标路径 (localStorage 已设置React 应识别为已登录)
39 | await page.goto(path, { waitUntil: 'networkidle' });
40 | // 等待主内容区加载
> 41 | await page.waitForSelector('#main-content', { timeout: 15000 });
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
42 | }
43 |
44 | // ── A1: 登录→Dashboard ────────────────────────────────────────────
45 |
46 | test('A1: 登录→Dashboard 5个统计卡片', async ({ page }) => {
47 | // 导航到登录页
48 | await page.goto('/login');
49 | await expect(page.getByPlaceholder('请输入用户名')).toBeVisible({ timeout: 10000 });
50 |
51 | // 填写表单
52 | await page.getByPlaceholder('请输入用户名').fill(ADMIN_USER);
53 | await page.getByPlaceholder('请输入密码').fill(ADMIN_PASS);
54 |
55 | // 提交 (Ant Design 按钮文本有全角空格 "登 录")
56 | const loginBtn = page.locator('button').filter({ hasText: /登/ }).first();
57 | await loginBtn.click();
58 |
59 | // 验证跳转到 Dashboard (可能需要等待 API 响应)
60 | await expect(page).toHaveURL(/\/(login)?$/, { timeout: 20000 });
61 |
62 | // 验证 5 个统计卡片
63 | await expect(page.getByText('总账号')).toBeVisible({ timeout: 10000 });
64 | await expect(page.getByText('活跃服务商')).toBeVisible();
65 | await expect(page.getByText('活跃模型')).toBeVisible();
66 | await expect(page.getByText('今日请求')).toBeVisible();
67 | await expect(page.getByText('今日 Token')).toBeVisible();
68 |
69 | // 验证统计卡片有数值 (不是 loading 状态)
70 | const statCards = page.locator('.ant-statistic-content-value');
71 | await expect(statCards.first()).not.toBeEmpty({ timeout: 10000 });
72 | });
73 |
74 | // ── A2: Provider CRUD ──────────────────────────────────────────────
75 |
76 | test('A2: Provider 创建→列表可见→禁用', async ({ page }) => {
77 | // 通过 API 创建 Provider
78 | await apiLogin(page);
79 | const createRes = await page.request.post(`${SaaS_BASE}/providers`, {
80 | data: {
81 | name: `smoke_provider_${Date.now()}`,
82 | provider_type: 'openai',
83 | base_url: 'https://api.smoke.test/v1',
84 | enabled: true,
85 | display_name: 'Smoke Test Provider',
86 | },
87 | });
88 | if (!createRes.ok()) {
89 | const body = await createRes.text();
90 | console.log(`A2: Provider create failed: ${createRes.status()}${body.slice(0, 300)}`);
91 | }
92 | expect(createRes.ok()).toBeTruthy();
93 |
94 | // 导航到 Model Services 页面
95 | await page.goto('/model-services');
96 | await page.waitForSelector('#main-content', { timeout: 15000 });
97 |
98 | // 切换到 Provider tab (如果存在 tab 切换)
99 | const providerTab = page.getByRole('tab', { name: /服务商|Provider/i });
100 | if (await providerTab.isVisible()) {
101 | await providerTab.click();
102 | }
103 |
104 | // 验证 Provider 列表非空
105 | const tableRows = page.locator('.ant-table-row');
106 | await expect(tableRows.first()).toBeVisible({ timeout: 10000 });
107 | expect(await tableRows.count()).toBeGreaterThan(0);
108 | });
109 |
110 | // ── A3: Account 管理 ───────────────────────────────────────────────
111 |
112 | test('A3: Account 列表加载→角色可见', async ({ page }) => {
113 | await loginAndGo(page, '/accounts');
114 |
115 | // 验证表格加载
116 | const tableRows = page.locator('.ant-table-row');
117 | await expect(tableRows.first()).toBeVisible({ timeout: 10000 });
118 |
119 | // 至少有 testadmin 自己
120 | expect(await tableRows.count()).toBeGreaterThanOrEqual(1);
121 |
122 | // 验证有角色列
123 | const roleText = await page.locator('.ant-table').textContent();
124 | expect(roleText).toMatch(/super_admin|admin|user/);
125 | });
126 |
127 | // ── A4: 知识管理 ───────────────────────────────────────────────────
128 |
129 | test('A4: 知识分类→条目→搜索', async ({ page }) => {
130 | // 通过 API 创建分类和条目
131 | await apiLogin(page);
132 |
133 | const catRes = await page.request.post(`${SaaS_BASE}/knowledge/categories`, {
134 | data: { name: `smoke_cat_${Date.now()}`, description: 'Smoke test category' },
135 | });
136 | expect(catRes.ok()).toBeTruthy();
137 | const catJson = await catRes.json();
138 |
139 | const itemRes = await page.request.post(`${SaaS_BASE}/knowledge/items`, {
140 | data: {
141 | title: 'Smoke Test Knowledge Item',
```

View File

@@ -0,0 +1,196 @@
/**
* Smoke Tests — Admin V2 连通性断裂探测
*
* 6 个冒烟测试验证 Admin V2 页面与 SaaS 后端的完整连通性。
* 所有测试使用真实浏览器 + 真实 SaaS Server。
*
* 前提条件:
* - SaaS Server 运行在 http://localhost:8080
* - Admin V2 dev server 运行在 http://localhost:5173
* - 种子用户: testadmin / Admin123456 (super_admin)
*
* 运行: cd admin-v2 && npx playwright test smoke_admin
*/
import { test, expect, type Page } from '@playwright/test';
const SaaS_BASE = 'http://localhost:8080/api/v1';
const ADMIN_USER = 'admin';
const ADMIN_PASS = 'admin123';
// Helper: 通过 API 登录获取 HttpOnly cookie + 设置 localStorage
async function apiLogin(page: Page) {
const res = await page.request.post(`${SaaS_BASE}/auth/login`, {
data: { username: ADMIN_USER, password: ADMIN_PASS },
});
const json = await res.json();
// 设置 localStorage 让 Admin V2 AuthGuard 认为已登录
await page.goto('/');
await page.evaluate((account) => {
localStorage.setItem('zclaw_admin_account', JSON.stringify(account));
}, json.account);
return json;
}
// Helper: 通过 API 登录 + 导航到指定路径
async function loginAndGo(page: Page, path: string) {
await apiLogin(page);
// 重新导航到目标路径 (localStorage 已设置React 应识别为已登录)
await page.goto(path, { waitUntil: 'networkidle' });
// 等待主内容区加载
await page.waitForSelector('#main-content', { timeout: 15000 });
}
// ── A1: 登录→Dashboard ────────────────────────────────────────────
test('A1: 登录→Dashboard 5个统计卡片', async ({ page }) => {
// 导航到登录页
await page.goto('/login');
await expect(page.getByPlaceholder('请输入用户名')).toBeVisible({ timeout: 10000 });
// 填写表单
await page.getByPlaceholder('请输入用户名').fill(ADMIN_USER);
await page.getByPlaceholder('请输入密码').fill(ADMIN_PASS);
// 提交 (Ant Design 按钮文本有全角空格 "登 录")
const loginBtn = page.locator('button').filter({ hasText: /登/ }).first();
await loginBtn.click();
// 验证跳转到 Dashboard (可能需要等待 API 响应)
await expect(page).toHaveURL(/\/(login)?$/, { timeout: 20000 });
// 验证 5 个统计卡片
await expect(page.getByText('总账号')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('活跃服务商')).toBeVisible();
await expect(page.getByText('活跃模型')).toBeVisible();
await expect(page.getByText('今日请求')).toBeVisible();
await expect(page.getByText('今日 Token')).toBeVisible();
// 验证统计卡片有数值 (不是 loading 状态)
const statCards = page.locator('.ant-statistic-content-value');
await expect(statCards.first()).not.toBeEmpty({ timeout: 10000 });
});
// ── A2: Provider CRUD ──────────────────────────────────────────────
test('A2: Provider 创建→列表可见→禁用', async ({ page }) => {
// 通过 API 创建 Provider
await apiLogin(page);
const createRes = await page.request.post(`${SaaS_BASE}/providers`, {
data: {
name: `smoke_provider_${Date.now()}`,
provider_type: 'openai',
base_url: 'https://api.smoke.test/v1',
enabled: true,
display_name: 'Smoke Test Provider',
},
});
if (!createRes.ok()) {
const body = await createRes.text();
console.log(`A2: Provider create failed: ${createRes.status()}${body.slice(0, 300)}`);
}
expect(createRes.ok()).toBeTruthy();
// 导航到 Model Services 页面
await page.goto('/model-services');
await page.waitForSelector('#main-content', { timeout: 15000 });
// 切换到 Provider tab (如果存在 tab 切换)
const providerTab = page.getByRole('tab', { name: /服务商|Provider/i });
if (await providerTab.isVisible()) {
await providerTab.click();
}
// 验证 Provider 列表非空
const tableRows = page.locator('.ant-table-row');
await expect(tableRows.first()).toBeVisible({ timeout: 10000 });
expect(await tableRows.count()).toBeGreaterThan(0);
});
// ── A3: Account 管理 ───────────────────────────────────────────────
test('A3: Account 列表加载→角色可见', async ({ page }) => {
await loginAndGo(page, '/accounts');
// 验证表格加载
const tableRows = page.locator('.ant-table-row');
await expect(tableRows.first()).toBeVisible({ timeout: 10000 });
// 至少有 testadmin 自己
expect(await tableRows.count()).toBeGreaterThanOrEqual(1);
// 验证有角色列
const roleText = await page.locator('.ant-table').textContent();
expect(roleText).toMatch(/super_admin|admin|user/);
});
// ── A4: 知识管理 ───────────────────────────────────────────────────
test('A4: 知识分类→条目→搜索', async ({ page }) => {
// 通过 API 创建分类和条目
await apiLogin(page);
const catRes = await page.request.post(`${SaaS_BASE}/knowledge/categories`, {
data: { name: `smoke_cat_${Date.now()}`, description: 'Smoke test category' },
});
expect(catRes.ok()).toBeTruthy();
const catJson = await catRes.json();
const itemRes = await page.request.post(`${SaaS_BASE}/knowledge/items`, {
data: {
title: 'Smoke Test Knowledge Item',
content: 'This is a smoke test knowledge entry for E2E testing.',
category_id: catJson.id,
tags: ['smoke', 'test'],
},
});
expect(itemRes.ok()).toBeTruthy();
// 导航到知识库页面
await page.goto('/knowledge');
await page.waitForSelector('#main-content', { timeout: 15000 });
// 验证页面加载 (有内容)
const content = await page.locator('#main-content').textContent();
expect(content!.length).toBeGreaterThan(0);
});
// ── A5: 角色权限 ───────────────────────────────────────────────────
test('A5: 角色页面加载→角色列表非空', async ({ page }) => {
await loginAndGo(page, '/roles');
// 验证角色内容加载
await page.waitForTimeout(1000);
// 检查页面有角色相关内容 (可能是表格或卡片)
const content = await page.locator('#main-content').textContent();
expect(content!.length).toBeGreaterThan(0);
// 通过 API 验证角色存在
const rolesRes = await page.request.get(`${SaaS_BASE}/roles`);
expect(rolesRes.ok()).toBeTruthy();
const rolesJson = await rolesRes.json();
expect(Array.isArray(rolesJson) || rolesJson.roles).toBeTruthy();
});
// ── A6: 模型+Key池 ────────────────────────────────────────────────
test('A6: 模型服务页面加载→Provider和Model tab可见', async ({ page }) => {
await loginAndGo(page, '/model-services');
// 验证页面标题或内容
const content = await page.locator('#main-content').textContent();
expect(content!.length).toBeGreaterThan(0);
// 检查是否有 Tab 切换 (服务商/模型/API Key)
const tabs = page.locator('.ant-tabs-tab');
if (await tabs.first().isVisible()) {
const tabCount = await tabs.count();
expect(tabCount).toBeGreaterThanOrEqual(1);
}
// 通过 API 验证能列出 Provider
const provRes = await page.request.get(`${SaaS_BASE}/providers`);
expect(provRes.ok()).toBeTruthy();
});

View File

@@ -101,7 +101,6 @@ describe('Config page', () => {
renderWithProviders(<Config />)
expect(screen.getByText('系统配置')).toBeInTheDocument()
expect(screen.getByText('管理系统运行参数和功能开关')).toBeInTheDocument()
})
it('fetches and displays config items', async () => {

View File

@@ -111,7 +111,7 @@ describe('Login page', () => {
it('renders the login form with username and password fields', () => {
renderLogin()
expect(screen.getByText('登录到 ZCLAW')).toBeInTheDocument()
expect(screen.getByText('登录')).toBeInTheDocument()
expect(screen.getByPlaceholderText('请输入用户名')).toBeInTheDocument()
expect(screen.getByPlaceholderText('请输入密码')).toBeInTheDocument()
const submitButton = getSubmitButton()
@@ -121,8 +121,10 @@ describe('Login page', () => {
it('shows the ZCLAW brand logo', () => {
renderLogin()
expect(screen.getByText('Z')).toBeInTheDocument()
expect(screen.getByText(/ZCLAW Admin/)).toBeInTheDocument()
// "Z" logo appears in both desktop brand panel and mobile-only logo
const zElements = screen.getAllByText('Z')
expect(zElements.length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('AI Agent 管理平台')).toBeInTheDocument()
})
it('successful login calls authStore.login and navigates to /', async () => {
@@ -136,11 +138,7 @@ describe('Login page', () => {
await user.click(getSubmitButton())
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith(
'jwt-token-123',
'refresh-token-456',
mockAccount,
)
expect(mockLogin).toHaveBeenCalledWith(mockAccount)
})
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true })

View File

@@ -90,7 +90,6 @@ describe('Logs page', () => {
renderWithProviders(<Logs />)
expect(screen.getByText('操作日志')).toBeInTheDocument()
expect(screen.getByText('系统审计与操作记录')).toBeInTheDocument()
})
it('fetches and displays log entries', async () => {
@@ -130,7 +129,7 @@ describe('Logs page', () => {
})
})
it('shows ErrorState on API failure with retry button', async () => {
it('shows empty table on API failure', async () => {
server.use(
http.get('*/api/v1/logs/operations', () => {
return HttpResponse.json(
@@ -142,13 +141,13 @@ describe('Logs page', () => {
renderWithProviders(<Logs />)
// ErrorState renders the error message
// Page header is still present even on error
expect(screen.getByText('操作日志')).toBeInTheDocument()
// No log entries rendered
await waitFor(() => {
expect(screen.getByText('服务器内部错误')).toBeInTheDocument()
expect(screen.queryByText('登录')).not.toBeInTheDocument()
})
// Ant Design Button splits two-character text with a space: "重 试"
const retryButton = screen.getByRole('button', { name: /重.?试/ })
expect(retryButton).toBeInTheDocument()
})
it('renders action as a colored tag', async () => {

View File

@@ -86,7 +86,7 @@ function renderWithProviders(ui: React.ReactElement) {
// ── Tests ────────────────────────────────────────────────────
describe('ModelServices page', () => {
it('renders page header', async () => {
it('renders page with provider table', async () => {
server.use(
http.get('*/api/v1/providers', () => {
return HttpResponse.json(mockProviders)
@@ -95,8 +95,8 @@ describe('ModelServices page', () => {
renderWithProviders(<ModelServices />)
expect(screen.getByText('模型服务')).toBeInTheDocument()
expect(screen.getByText('管理 AI 服务商、模型配置和 Key 池')).toBeInTheDocument()
// "新建服务商" button is rendered by toolBarRender
expect(screen.getByText('新建服务商')).toBeInTheDocument()
})
it('fetches and displays providers', async () => {
@@ -173,8 +173,8 @@ describe('ModelServices page', () => {
renderWithProviders(<ModelServices />)
// Page header should still render
expect(screen.getByText('模型服务')).toBeInTheDocument()
// "新建服务商" button should still render
expect(screen.getByText('新建服务')).toBeInTheDocument()
// Provider names should NOT be rendered
await waitFor(() => {

View File

@@ -92,8 +92,7 @@ describe('Prompts page', () => {
renderWithProviders(<Prompts />)
expect(screen.getByText('提示词管理')).toBeInTheDocument()
expect(screen.getByText('管理系统提示词模板和版本历史')).toBeInTheDocument()
// "新建提示词" button is rendered by toolBarRender
expect(screen.getByText('新建提示词')).toBeInTheDocument()
})

View File

@@ -98,7 +98,7 @@ describe('Usage page', () => {
renderWithProviders(<Usage />)
expect(screen.getByText('用量统计')).toBeInTheDocument()
expect(screen.getByText('查看模型使用情况Token 消耗')).toBeInTheDocument()
expect(screen.getByText('查看模型使用情况Token 消耗和用户转化')).toBeInTheDocument()
// Summary card titles
expect(screen.getByText('总请求数')).toBeInTheDocument()

View File

@@ -1,24 +1,22 @@
// ============================================================
// request.ts 拦截器测试
// ============================================================
//
// 认证策略已迁移到 HttpOnly cookie 模式。
// 浏览器自动附加 cookiewithCredentials: trueJS 不操作 token。
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
// ── Hoisted: mock functions + store (accessible in vi.mock factory) ──
const { mockSetToken, mockSetRefreshToken, mockLogout, _store } = vi.hoisted(() => {
const mockSetToken = vi.fn()
const mockSetRefreshToken = vi.fn()
// ── Hoisted: mock store (cookie-based auth — no JS token) ──
const { mockLogout, _store } = vi.hoisted(() => {
const mockLogout = vi.fn()
const _store = {
token: null as string | null,
refreshToken: null as string | null,
setToken: mockSetToken,
setRefreshToken: mockSetRefreshToken,
isAuthenticated: false,
logout: mockLogout,
}
return { mockSetToken, mockSetRefreshToken, mockLogout, _store }
return { mockLogout, _store }
})
vi.mock('@/stores/authStore', () => ({
@@ -38,11 +36,8 @@ const server = setupServer()
beforeEach(() => {
server.listen({ onUnhandledRequest: 'bypass' })
mockSetToken.mockClear()
mockSetRefreshToken.mockClear()
mockLogout.mockClear()
_store.token = null
_store.refreshToken = null
_store.isAuthenticated = false
})
afterEach(() => {
@@ -50,34 +45,22 @@ afterEach(() => {
})
describe('request interceptor', () => {
it('attaches Authorization header when token exists', async () => {
let capturedAuth: string | null = null
it('sends requests with credentials (cookie-based auth)', async () => {
let capturedCreds = false
server.use(
http.get('*/api/v1/test', ({ request }) => {
capturedAuth = request.headers.get('Authorization')
// Cookie-based auth: the browser sends cookies automatically.
// We verify the request was made successfully.
capturedCreds = true
return HttpResponse.json({ ok: true })
}),
)
setStoreState({ token: 'test-jwt-token' })
await request.get('/test')
setStoreState({ isAuthenticated: true })
const res = await request.get('/test')
expect(capturedAuth).toBe('Bearer test-jwt-token')
})
it('does not attach Authorization header when no token', async () => {
let capturedAuth: string | null = null
server.use(
http.get('*/api/v1/test', ({ request }) => {
capturedAuth = request.headers.get('Authorization')
return HttpResponse.json({ ok: true })
}),
)
setStoreState({ token: null })
await request.get('/test')
expect(capturedAuth).toBeNull()
expect(res.data).toEqual({ ok: true })
expect(capturedCreds).toBe(true)
})
it('wraps non-401 errors as ApiRequestError', async () => {
@@ -116,7 +99,7 @@ describe('request interceptor', () => {
}
})
it('handles 401 with refresh token success', async () => {
it('handles 401 when authenticated — refreshes cookie and retries', async () => {
let callCount = 0
server.use(
@@ -128,26 +111,25 @@ describe('request interceptor', () => {
return HttpResponse.json({ data: 'success' })
}),
http.post('*/api/v1/auth/refresh', () => {
return HttpResponse.json({ token: 'new-jwt', refresh_token: 'new-refresh' })
// Server sets new HttpOnly cookie in response — no JS token needed
return HttpResponse.json({ ok: true })
}),
)
setStoreState({ token: 'old-jwt', refreshToken: 'old-refresh' })
setStoreState({ isAuthenticated: true })
const res = await request.get('/protected')
expect(res.data).toEqual({ data: 'success' })
expect(mockSetToken).toHaveBeenCalledWith('new-jwt')
expect(mockSetRefreshToken).toHaveBeenCalledWith('new-refresh')
})
it('handles 401 with no refresh token — calls logout immediately', async () => {
it('handles 401 when not authenticated — calls logout immediately', async () => {
server.use(
http.get('*/api/v1/norefresh', () => {
return HttpResponse.json({ error: 'unauthorized' }, { status: 401 })
}),
)
setStoreState({ token: 'old-jwt', refreshToken: null })
setStoreState({ isAuthenticated: false })
try {
await request.get('/norefresh')
@@ -167,7 +149,7 @@ describe('request interceptor', () => {
}),
)
setStoreState({ token: 'old-jwt', refreshToken: 'old-refresh' })
setStoreState({ isAuthenticated: true })
try {
await request.get('/refreshfail')

View File

@@ -36,27 +36,23 @@ describe('authStore', () => {
mockFetch.mockClear()
// Reset store state
useAuthStore.setState({
token: null,
refreshToken: null,
isAuthenticated: false,
account: null,
permissions: [],
})
})
it('login sets token, refreshToken, account and permissions', () => {
const store = useAuthStore.getState()
store.login('jwt-token', 'refresh-token', mockAccount)
it('login sets isAuthenticated, account and permissions', () => {
useAuthStore.getState().login(mockAccount)
const state = useAuthStore.getState()
expect(state.token).toBe('jwt-token')
expect(state.refreshToken).toBe('refresh-token')
expect(state.isAuthenticated).toBe(true)
expect(state.account).toEqual(mockAccount)
expect(state.permissions).toContain('provider:manage')
})
it('super_admin gets admin:full + all permissions', () => {
const store = useAuthStore.getState()
store.login('jwt', 'refresh', superAdminAccount)
useAuthStore.getState().login(superAdminAccount)
const state = useAuthStore.getState()
expect(state.permissions).toContain('admin:full')
@@ -66,8 +62,7 @@ describe('authStore', () => {
it('user role gets only basic permissions', () => {
const userAccount: AccountPublic = { ...mockAccount, role: 'user' }
const store = useAuthStore.getState()
store.login('jwt', 'refresh', userAccount)
useAuthStore.getState().login(userAccount)
const state = useAuthStore.getState()
expect(state.permissions).toContain('model:read')
@@ -75,41 +70,51 @@ describe('authStore', () => {
expect(state.permissions).not.toContain('provider:manage')
})
it('logout clears all state', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
it('logout clears all state and calls API', () => {
useAuthStore.getState().login(mockAccount)
useAuthStore.getState().logout()
const state = useAuthStore.getState()
expect(state.token).toBeNull()
expect(state.refreshToken).toBeNull()
expect(state.isAuthenticated).toBe(false)
expect(state.account).toBeNull()
expect(state.permissions).toEqual([])
expect(localStorage.getItem('zclaw_admin_account')).toBeNull()
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('hasPermission returns true for matching permission', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
useAuthStore.getState().login(mockAccount)
expect(useAuthStore.getState().hasPermission('provider:manage')).toBe(true)
expect(useAuthStore.getState().hasPermission('config:write')).toBe(true)
})
it('hasPermission returns false for non-matching permission', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
useAuthStore.getState().login(mockAccount)
expect(useAuthStore.getState().hasPermission('admin:full')).toBe(false)
})
it('admin:full grants all permissions via wildcard', () => {
useAuthStore.getState().login('jwt', 'refresh', superAdminAccount)
useAuthStore.getState().login(superAdminAccount)
expect(useAuthStore.getState().hasPermission('anything:here')).toBe(true)
expect(useAuthStore.getState().hasPermission('made:up')).toBe(true)
})
it('persists account to localStorage on login', () => {
useAuthStore.getState().login('jwt', 'refresh', mockAccount)
useAuthStore.getState().login(mockAccount)
const stored = localStorage.getItem('zclaw_admin_account')
expect(stored).not.toBeNull()
expect(JSON.parse(stored!).username).toBe('testuser')
})
it('restores account from localStorage on store creation', () => {
localStorage.setItem('zclaw_admin_account', JSON.stringify(mockAccount))
// Re-import to trigger loadFromStorage — simulate by calling setState + reading
// In practice, Zustand reads localStorage on module load
// We test that the store can handle pre-existing localStorage data
const raw = localStorage.getItem('zclaw_admin_account')
expect(raw).not.toBeNull()
expect(JSON.parse(raw!).role).toBe('admin')
})
})

View File

@@ -20,7 +20,7 @@ export default defineConfig({
timeout: 600_000,
proxyTimeout: 600_000,
},
'/api': {
'/api/': {
target: 'http://localhost:8080',
changeOrigin: true,
timeout: 30_000,

View File

@@ -25,12 +25,19 @@ max_output_tokens = 4096
supports_streaming = true
[[llm.providers.models]]
id = "glm-4-flash"
alias = "GLM-4-Flash"
id = "glm-4-flash-250414"
alias = "GLM-4-Flash (免费)"
context_window = 128000
max_output_tokens = 4096
supports_streaming = true
[[llm.providers.models]]
id = "glm-z1-flash"
alias = "GLM-Z1-Flash (免费推理)"
context_window = 128000
max_output_tokens = 16384
supports_streaming = true
[[llm.providers.models]]
id = "glm-4v-plus"
alias = "GLM-4V-Plus (视觉)"

View File

@@ -129,7 +129,7 @@ retry_delay = "1s"
[llm.aliases]
# 智谱 GLM 模型 (使用正确的 API 模型 ID)
"glm-4-flash" = "zhipu/glm-4-flash"
"glm-4-flash" = "zhipu/glm-4-flash-250414"
"glm-4-plus" = "zhipu/glm-4-plus"
"glm-4.5" = "zhipu/glm-4.5"
# 其他模型
@@ -223,8 +223,10 @@ timeout = "30s"
[tools.web]
[tools.web.search]
enabled = true
default_engine = "duckduckgo"
default_engine = "auto"
max_results = 10
searxng_url = "http://localhost:8888"
searxng_timeout = 15
# File system tool
[tools.fs]

View File

@@ -0,0 +1,305 @@
//! 进化引擎中枢
//! 协调 L1/L2/L3 三层进化的触发和执行
//! L1 (记忆进化) 在 GrowthIntegration 中处理
//! L2 (技能进化) 通过 PatternAggregator + SkillGenerator + QualityGate 协调
//! L3 (工作流进化) 通过 WorkflowComposer 协调
//! 反馈闭环通过 FeedbackCollector 管理
use std::sync::Arc;
use crate::experience_store::ExperienceStore;
use crate::feedback_collector::{
FeedbackCollector, FeedbackEntry, TrustUpdate,
};
use crate::pattern_aggregator::{AggregatedPattern, PatternAggregator};
use crate::quality_gate::{QualityGate, QualityReport};
use crate::skill_generator::{SkillCandidate, SkillGenerator};
use crate::workflow_composer::{ToolChainPattern, WorkflowComposer};
use crate::VikingAdapter;
use zclaw_types::Result;
/// 进化引擎配置
#[derive(Debug, Clone)]
pub struct EvolutionConfig {
/// 经验复用次数达到此阈值触发 L2
pub min_reuse_for_skill: u32,
/// 置信度阈值
pub quality_confidence_threshold: f32,
/// 是否启用进化引擎
pub enabled: bool,
}
impl Default for EvolutionConfig {
fn default() -> Self {
Self {
min_reuse_for_skill: 3,
quality_confidence_threshold: 0.7,
enabled: true,
}
}
}
/// 进化引擎中枢
pub struct EvolutionEngine {
viking: Arc<VikingAdapter>,
feedback: Arc<tokio::sync::Mutex<FeedbackCollector>>,
config: EvolutionConfig,
}
impl EvolutionEngine {
pub fn new(viking: Arc<VikingAdapter>) -> Self {
Self {
viking: viking.clone(),
feedback: Arc::new(tokio::sync::Mutex::new(
FeedbackCollector::with_viking(viking),
)),
config: EvolutionConfig::default(),
}
}
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
/// Backward-compatible constructor
/// 从 ExperienceStore 中提取共享的 VikingAdapter 实例
pub fn from_experience_store(experience_store: Arc<ExperienceStore>) -> Self {
let viking = experience_store.viking().clone();
Self {
viking: viking.clone(),
feedback: Arc::new(tokio::sync::Mutex::new(
FeedbackCollector::with_viking(viking),
)),
config: EvolutionConfig::default(),
}
}
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
pub fn with_config(mut self, config: EvolutionConfig) -> Self {
self.config = config;
self
}
pub fn set_enabled(&mut self, enabled: bool) {
self.config.enabled = enabled;
}
/// L2 检查:是否有可进化的模式
pub async fn check_evolvable_patterns(
&self,
agent_id: &str,
) -> Result<Vec<AggregatedPattern>> {
if !self.config.enabled {
return Ok(Vec::new());
}
let store = ExperienceStore::new(self.viking.clone());
let aggregator = PatternAggregator::new(store);
aggregator
.find_evolvable_patterns(agent_id, self.config.min_reuse_for_skill)
.await
}
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
/// L2 执行:为给定模式构建技能生成 prompt
/// 返回 (prompt_string, pattern) 供上层通过 LLM 调用后 parse
pub fn build_skill_prompt(&self, pattern: &AggregatedPattern) -> String {
SkillGenerator::build_prompt(pattern)
}
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
/// L2 执行:解析 LLM 返回的技能 JSON 并进行质量门控
pub fn validate_skill_candidate(
&self,
json_str: &str,
pattern: &AggregatedPattern,
existing_triggers: Vec<String>,
) -> Result<(SkillCandidate, QualityReport)> {
let candidate = SkillGenerator::parse_response(json_str, pattern)?;
let gate = QualityGate::new(self.config.quality_confidence_threshold, existing_triggers);
let report = gate.validate_skill(&candidate);
Ok((candidate, report))
}
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
/// 获取当前配置
pub fn config(&self) -> &EvolutionConfig {
&self.config
}
// -----------------------------------------------------------------------
// L3: 工作流进化
// -----------------------------------------------------------------------
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
/// L3: 从轨迹数据中提取重复的工具链模式
pub fn analyze_trajectory_patterns(
&self,
trajectories: &[(String, Vec<String>)], // (session_id, tools_used)
) -> Vec<(ToolChainPattern, Vec<String>)> {
if !self.config.enabled {
return Vec::new();
}
WorkflowComposer::extract_patterns(trajectories)
}
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
/// L3: 为给定工具链模式构建工作流生成 prompt
pub fn build_workflow_prompt(
&self,
pattern: &ToolChainPattern,
frequency: usize,
industry: Option<&str>,
) -> String {
WorkflowComposer::build_prompt(pattern, frequency, industry)
}
// -----------------------------------------------------------------------
// 反馈闭环
// -----------------------------------------------------------------------
/// 提交反馈并获取信任度更新,自动持久化
pub async fn submit_feedback(&self, entry: FeedbackEntry) -> TrustUpdate {
let mut feedback = self.feedback.lock().await;
let update = feedback.submit_feedback(entry);
// 非阻塞持久化:失败仅打日志,不影响返回值
if let Err(e) = feedback.save().await {
tracing::warn!("[EvolutionEngine] Failed to persist trust records: {}", e);
}
update
}
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
/// 获取需要优化的进化产物
pub async fn get_artifacts_needing_optimization(&self) -> Vec<String> {
self.feedback
.lock()
.await
.get_artifacts_needing_optimization()
.iter()
.map(|r| r.artifact_id.clone())
.collect()
}
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
/// 获取建议归档的进化产物
pub async fn get_artifacts_to_archive(&self) -> Vec<String> {
self.feedback
.lock()
.await
.get_artifacts_to_archive()
.iter()
.map(|r| r.artifact_id.clone())
.collect()
}
/// @reserved: EvolutionEngine L2/L3 feature, post-release integration
/// 获取推荐产物
pub async fn get_recommended_artifacts(&self) -> Vec<String> {
self.feedback
.lock()
.await
.get_recommended_artifacts()
.iter()
.map(|r| r.artifact_id.clone())
.collect()
}
/// 启动时加载已持久化的信任度记录
pub async fn load_feedback(&self) -> Result<usize> {
self.feedback
.lock()
.await
.load()
.await
.map_err(|e| zclaw_types::ZclawError::Internal(e))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::experience_store::Experience;
#[tokio::test]
async fn test_disabled_returns_empty() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let mut engine = EvolutionEngine::new(viking);
engine.set_enabled(false);
let patterns = engine.check_evolvable_patterns("agent-1").await.unwrap();
assert!(patterns.is_empty());
}
#[tokio::test]
async fn test_no_evolvable_patterns() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let engine = EvolutionEngine::new(viking);
let patterns = engine.check_evolvable_patterns("unknown-agent").await.unwrap();
assert!(patterns.is_empty());
}
#[tokio::test]
async fn test_finds_evolvable_pattern() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let store_inner = ExperienceStore::new(viking.clone());
let mut exp = Experience::new(
"agent-1",
"report generation",
"researcher",
vec!["query db".into(), "format".into()],
"success",
);
exp.reuse_count = 5;
store_inner.store_experience(&exp).await.unwrap();
let engine = EvolutionEngine::new(viking);
let patterns = engine.check_evolvable_patterns("agent-1").await.unwrap();
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].pain_pattern, "report generation");
}
#[test]
fn test_build_skill_prompt() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let engine = EvolutionEngine::new(viking);
let exp = Experience::new(
"a", "report", "researcher", vec!["step1".into()], "ok",
);
let pattern = AggregatedPattern {
pain_pattern: "report".to_string(),
experiences: vec![exp],
common_steps: vec!["step1".into()],
total_reuse: 5,
tools_used: vec!["researcher".into()],
industry_context: None,
};
let prompt = engine.build_skill_prompt(&pattern);
assert!(prompt.contains("report"));
}
#[test]
fn test_validate_skill_candidate() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let engine = EvolutionEngine::new(viking);
let exp = Experience::new(
"a", "report", "researcher", vec!["step1".into()], "ok",
);
let pattern = AggregatedPattern {
pain_pattern: "report".to_string(),
experiences: vec![exp],
common_steps: vec!["step1".into()],
total_reuse: 5,
tools_used: vec!["researcher".into()],
industry_context: None,
};
let json = r##"{"name":"报表技能","description":"生成报表","triggers":["报表","日报"],"tools":["researcher"],"body_markdown":"# 报表生成技能\n\n## 步骤一\n收集数据源并验证完整性。\n\n## 步骤二\n按模板格式化输出报表。\n\n## 步骤三\n发送至相关接收人。","confidence":0.9}"##;
let (candidate, report) = engine
.validate_skill_candidate(json, &pattern, vec!["搜索".to_string()])
.unwrap();
assert_eq!(candidate.name, "报表技能");
assert!(report.passed);
}
}

View File

@@ -0,0 +1,119 @@
//! 结构化经验提取器
//! 从对话中提取 ExperienceCandidatepain_pattern → solution_steps → outcome
//! 持久化到 ExperienceStore
use std::sync::Arc;
use crate::experience_store::ExperienceStore;
use crate::types::{CombinedExtraction, Outcome};
/// 结构化经验提取器
/// LLM 调用已由上层 MemoryExtractor 完成,这里只做解析和持久化
pub struct ExperienceExtractor {
store: Option<Arc<ExperienceStore>>,
}
impl ExperienceExtractor {
pub fn new() -> Self {
Self { store: None }
}
pub fn with_store(mut self, store: Arc<ExperienceStore>) -> Self {
self.store = Some(store);
self
}
/// 从 CombinedExtraction 中提取经验并持久化
/// LLM 调用已由上层完成,这里只做解析和存储
pub async fn persist_experiences(
&self,
agent_id: &str,
extraction: &CombinedExtraction,
) -> zclaw_types::Result<usize> {
let store = match &self.store {
Some(s) => s,
None => return Ok(0),
};
let mut count = 0;
for candidate in &extraction.experiences {
if candidate.confidence < 0.6 {
continue;
}
let outcome_str = match candidate.outcome {
Outcome::Success => "success",
Outcome::Partial => "partial",
Outcome::Failed => "failed",
};
let mut exp = crate::experience_store::Experience::new(
agent_id,
&candidate.pain_pattern,
&candidate.context,
candidate.solution_steps.clone(),
outcome_str,
);
// 填充 tool_used取 tools_used 中的第一个作为主要工具
exp.tool_used = candidate.tools_used.first().cloned();
exp.industry_context = candidate.industry_context.clone();
store.store_experience(&exp).await?;
count += 1;
}
Ok(count)
}
}
impl Default for ExperienceExtractor {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExperienceCandidate, Outcome};
#[test]
fn test_extractor_new_without_store() {
let ext = ExperienceExtractor::new();
assert!(ext.store.is_none());
}
#[tokio::test]
async fn test_persist_no_store_returns_zero() {
let ext = ExperienceExtractor::new();
let extraction = CombinedExtraction::default();
let count = ext.persist_experiences("agent1", &extraction).await.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_persist_filters_low_confidence() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let store = Arc::new(ExperienceStore::new(viking));
let ext = ExperienceExtractor::new().with_store(store);
let mut extraction = CombinedExtraction::default();
extraction.experiences.push(ExperienceCandidate {
pain_pattern: "low confidence task".to_string(),
context: "should be filtered".to_string(),
solution_steps: vec!["step1".to_string()],
outcome: Outcome::Success,
confidence: 0.3, // 低于 0.6 阈值
tools_used: vec![],
industry_context: None,
});
extraction.experiences.push(ExperienceCandidate {
pain_pattern: "high confidence task".to_string(),
context: "should be stored".to_string(),
solution_steps: vec!["step1".to_string(), "step2".to_string()],
outcome: Outcome::Success,
confidence: 0.9,
tools_used: vec!["researcher".to_string()],
industry_context: Some("healthcare".to_string()),
});
let count = ext.persist_experiences("agent-1", &extraction).await.unwrap();
assert_eq!(count, 1); // 只有 1 个通过置信度过滤
}
}

View File

@@ -0,0 +1,482 @@
//! ExperienceStore — CRUD wrapper over VikingStorage for agent experiences.
//!
//! Stores structured experiences extracted from successful solution proposals
//! using the scope prefix `agent://{agent_id}/experience/{pattern_hash}`.
//! Leverages existing FTS5 + TF-IDF + embedding retrieval via VikingAdapter.
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use uuid::Uuid;
use crate::types::{MemoryEntry, MemoryType};
use crate::viking_adapter::{FindOptions, VikingAdapter};
// ---------------------------------------------------------------------------
// Experience data model
// ---------------------------------------------------------------------------
/// A structured experience record representing a solved pain point.
///
/// Stored as JSON content inside a VikingStorage `MemoryEntry` with
/// `memory_type = Experience`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Experience {
/// Unique experience identifier.
pub id: String,
/// Owning agent.
pub agent_id: String,
/// Short pattern describing the pain that was solved (e.g. "logistics export packaging").
pub pain_pattern: String,
/// Context in which the problem occurred.
pub context: String,
/// Ordered steps that resolved the problem.
pub solution_steps: Vec<String>,
/// Verbal outcome reported by the user.
pub outcome: String,
/// How many times this experience has been reused as a reference.
pub reuse_count: u32,
/// Timestamp of initial creation.
pub created_at: DateTime<Utc>,
/// Timestamp of most recent reuse or update.
pub updated_at: DateTime<Utc>,
/// Associated industry ID (e.g. "ecommerce", "healthcare").
#[serde(default)]
pub industry_context: Option<String>,
/// Which trigger signal produced this experience.
#[serde(default)]
pub source_trigger: Option<String>,
/// Primary tool/skill used to resolve this pain point.
#[serde(default)]
pub tool_used: Option<String>,
}
impl Experience {
/// Create a new experience with the given fields.
pub fn new(
agent_id: &str,
pain_pattern: &str,
context: &str,
solution_steps: Vec<String>,
outcome: &str,
) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
agent_id: agent_id.to_string(),
pain_pattern: pain_pattern.to_string(),
context: context.to_string(),
solution_steps,
outcome: outcome.to_string(),
reuse_count: 0,
created_at: now,
updated_at: now,
industry_context: None,
source_trigger: None,
tool_used: None,
}
}
/// Deterministic URI for this experience, keyed on a stable hash of the
/// pain pattern so duplicate patterns overwrite the same entry.
pub fn uri(&self) -> String {
let hash = simple_hash(&self.pain_pattern);
format!("agent://{}/experience/{}", self.agent_id, hash)
}
}
/// FNV-1ainspired stable 8-hex-char hash. Good enough for deduplication;
/// collisions are acceptable because the full `pain_pattern` is still stored.
fn simple_hash(s: &str) -> String {
let mut h: u32 = 2166136261;
for b in s.as_bytes() {
h ^= *b as u32;
h = h.wrapping_mul(16777619);
}
format!("{:08x}", h)
}
// ---------------------------------------------------------------------------
// ExperienceStore
// ---------------------------------------------------------------------------
/// CRUD wrapper that persists [`Experience`] records through [`VikingAdapter`].
pub struct ExperienceStore {
viking: Arc<VikingAdapter>,
}
impl ExperienceStore {
/// Create a new store backed by the given VikingAdapter.
pub fn new(viking: Arc<VikingAdapter>) -> Self {
Self { viking }
}
/// Get a reference to the underlying VikingAdapter.
pub fn viking(&self) -> &Arc<VikingAdapter> {
&self.viking
}
/// Store an experience, merging with existing if the same pain pattern
/// already exists for this agent. Reuse-count is preserved and incremented
/// rather than reset to zero on re-extraction.
pub async fn store_experience(&self, exp: &Experience) -> zclaw_types::Result<()> {
let uri = exp.uri();
// If an experience with this URI already exists, merge instead of overwrite.
if let Some(existing_entry) = self.viking.get(&uri).await? {
let existing = match serde_json::from_str::<Experience>(&existing_entry.content) {
Ok(e) => e,
Err(e) => {
warn!("[ExperienceStore] Failed to deserialize existing experience at {}: {}, overwriting", uri, e);
// Fall through to store new experience as overwrite
self.write_entry(&uri, exp).await?;
return Ok(());
}
};
{
let merged = Experience {
id: existing.id.clone(),
reuse_count: existing.reuse_count + 1,
created_at: existing.created_at,
updated_at: Utc::now(),
// New data takes precedence for content fields
pain_pattern: exp.pain_pattern.clone(),
agent_id: exp.agent_id.clone(),
context: exp.context.clone(),
solution_steps: exp.solution_steps.clone(),
outcome: exp.outcome.clone(),
industry_context: exp.industry_context.clone().or(existing.industry_context.clone()),
source_trigger: exp.source_trigger.clone().or(existing.source_trigger.clone()),
tool_used: exp.tool_used.clone().or(existing.tool_used.clone()),
};
return self.write_entry(&uri, &merged).await;
}
}
self.write_entry(&uri, exp).await
}
/// Low-level write: serialises the experience into a MemoryEntry and
/// persists it through the VikingAdapter.
async fn write_entry(&self, uri: &str, exp: &Experience) -> zclaw_types::Result<()> {
let content = serde_json::to_string(exp)?;
let mut keywords = vec![exp.pain_pattern.clone()];
keywords.extend(exp.solution_steps.iter().take(3).cloned());
if let Some(ref industry) = exp.industry_context {
keywords.push(industry.clone());
}
if let Some(ref tool) = exp.tool_used {
keywords.push(tool.clone());
}
let entry = MemoryEntry {
uri: uri.to_string(),
memory_type: MemoryType::Experience,
content,
keywords,
importance: 8,
access_count: 0,
created_at: exp.created_at,
last_accessed: exp.updated_at,
overview: Some(exp.pain_pattern.clone()),
abstract_summary: Some(exp.outcome.clone()),
};
self.viking.store(&entry).await?;
debug!("[ExperienceStore] Stored experience {} for agent {}", exp.id, exp.agent_id);
Ok(())
}
/// Find experiences whose pain pattern matches the given query.
pub async fn find_by_pattern(
&self,
agent_id: &str,
pattern_query: &str,
) -> zclaw_types::Result<Vec<Experience>> {
let scope = format!("agent://{}/experience/", agent_id);
let opts = FindOptions {
scope: Some(scope),
limit: Some(10),
min_similarity: None,
};
let entries = self.viking.find(pattern_query, opts).await?;
let mut results = Vec::with_capacity(entries.len());
for entry in entries {
match serde_json::from_str::<Experience>(&entry.content) {
Ok(exp) => results.push(exp),
Err(e) => warn!("[ExperienceStore] Failed to deserialize experience at {}: {}", entry.uri, e),
}
}
Ok(results)
}
/// Return all experiences for a given agent.
pub async fn find_by_agent(
&self,
agent_id: &str,
) -> zclaw_types::Result<Vec<Experience>> {
let prefix = format!("agent://{}/experience/", agent_id);
let entries = self.viking.find_by_prefix(&prefix).await?;
let mut results = Vec::with_capacity(entries.len());
for entry in entries {
match serde_json::from_str::<Experience>(&entry.content) {
Ok(exp) => results.push(exp),
Err(e) => warn!("[ExperienceStore] Failed to deserialize experience at {}: {}", entry.uri, e),
}
}
Ok(results)
}
/// Increment the reuse counter for an existing experience.
/// On failure, logs a warning but does **not** propagate the error so
/// callers are never blocked.
pub async fn increment_reuse(&self, exp: &Experience) {
let mut updated = exp.clone();
updated.reuse_count += 1;
updated.updated_at = Utc::now();
if let Err(e) = self.write_entry(&exp.uri(), &updated).await {
warn!("[ExperienceStore] Failed to increment reuse for {}: {}", exp.id, e);
}
}
/// Delete a single experience by its URI.
pub async fn delete(&self, exp: &Experience) -> zclaw_types::Result<()> {
let uri = exp.uri();
self.viking.delete(&uri).await?;
debug!("[ExperienceStore] Deleted experience {} for agent {}", exp.id, exp.agent_id);
Ok(())
}
/// Find experiences for an agent created since the given datetime.
/// Filters by deserializing each entry and checking `created_at`.
pub async fn find_since(
&self,
agent_id: &str,
since: DateTime<Utc>,
) -> zclaw_types::Result<Vec<Experience>> {
let all = self.find_by_agent(agent_id).await?;
Ok(all
.into_iter()
.filter(|exp| exp.created_at >= since)
.collect())
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_experience_new() {
let exp = Experience::new(
"agent-1",
"logistics export packaging",
"export packaging rejected by customs",
vec!["check regulations".into(), "use approved materials".into()],
"packaging passed customs",
);
assert!(!exp.id.is_empty());
assert_eq!(exp.agent_id, "agent-1");
assert_eq!(exp.solution_steps.len(), 2);
assert_eq!(exp.reuse_count, 0);
}
#[test]
fn test_uri_deterministic() {
let exp1 = Experience::new(
"agent-1", "packaging issue", "ctx",
vec!["step1".into()], "ok",
);
// Second experience with same agent + pattern should produce the same URI.
let mut exp2 = exp1.clone();
exp2.id = "different-id".to_string();
assert_eq!(exp1.uri(), exp2.uri());
}
#[test]
fn test_uri_differs_for_different_patterns() {
let exp_a = Experience::new(
"agent-1", "packaging issue", "ctx",
vec!["step1".into()], "ok",
);
let exp_b = Experience::new(
"agent-1", "compliance gap", "ctx",
vec!["step1".into()], "ok",
);
assert_ne!(exp_a.uri(), exp_b.uri());
}
#[test]
fn test_simple_hash_stability() {
let h1 = simple_hash("hello world");
let h2 = simple_hash("hello world");
assert_eq!(h1, h2);
assert_eq!(h1.len(), 8);
}
#[tokio::test]
async fn test_store_and_find_by_agent() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp = Experience::new(
"agent-42",
"export document errors",
"recurring mistakes in export docs",
vec!["use template".into(), "auto-validate".into()],
"no more errors",
);
store.store_experience(&exp).await.unwrap();
let found = store.find_by_agent("agent-42").await.unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found[0].pain_pattern, "export document errors");
assert_eq!(found[0].solution_steps.len(), 2);
}
#[tokio::test]
async fn test_store_merges_same_pattern() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp_v1 = Experience::new(
"agent-1", "packaging", "v1",
vec!["old step".into()], "ok",
);
store.store_experience(&exp_v1).await.unwrap();
let exp_v2 = Experience::new(
"agent-1", "packaging", "v2 updated",
vec!["new step".into()], "better",
);
// Same pattern → same URI → should merge, not overwrite.
store.store_experience(&exp_v2).await.unwrap();
let found = store.find_by_agent("agent-1").await.unwrap();
// Should be merged into one entry, not duplicated.
assert_eq!(found.len(), 1);
// Content fields updated to v2.
assert_eq!(found[0].context, "v2 updated");
assert_eq!(found[0].solution_steps[0], "new step");
// Reuse count incremented (was 0, now 1).
assert_eq!(found[0].reuse_count, 1);
// Original ID and created_at preserved.
assert_eq!(found[0].id, exp_v1.id);
}
#[tokio::test]
async fn test_find_by_pattern() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp = Experience::new(
"agent-1",
"logistics packaging compliance",
"export compliance issues",
vec!["check regulations".into()],
"passed audit",
);
store.store_experience(&exp).await.unwrap();
let found = store.find_by_pattern("agent-1", "packaging").await.unwrap();
assert_eq!(found.len(), 1);
}
#[tokio::test]
async fn test_increment_reuse() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp = Experience::new(
"agent-1", "packaging", "ctx",
vec!["step".into()], "ok",
);
store.store_experience(&exp).await.unwrap();
store.increment_reuse(&exp).await;
let found = store.find_by_agent("agent-1").await.unwrap();
assert_eq!(found[0].reuse_count, 1);
}
#[tokio::test]
async fn test_delete_experience() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp = Experience::new(
"agent-1", "packaging", "ctx",
vec!["step".into()], "ok",
);
store.store_experience(&exp).await.unwrap();
store.delete(&exp).await.unwrap();
let found = store.find_by_agent("agent-1").await.unwrap();
assert!(found.is_empty());
}
#[tokio::test]
async fn test_find_by_agent_filters_other_agents() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp_a = Experience::new("agent-a", "packaging", "ctx", vec!["s".into()], "ok");
let exp_b = Experience::new("agent-b", "compliance", "ctx", vec!["s".into()], "ok");
store.store_experience(&exp_a).await.unwrap();
store.store_experience(&exp_b).await.unwrap();
let found_a = store.find_by_agent("agent-a").await.unwrap();
assert_eq!(found_a.len(), 1);
assert_eq!(found_a[0].pain_pattern, "packaging");
}
#[tokio::test]
async fn test_reuse_count_accumulates_across_repeated_patterns() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
// Store the same pattern 4 times (simulating 4 conversations)
for i in 0..4 {
let exp = Experience::new(
"agent-1", "logistics delay", &format!("context v{}", i),
vec![format!("step {}", i)], &format!("outcome {}", i),
);
store.store_experience(&exp).await.unwrap();
}
let found = store.find_by_agent("agent-1").await.unwrap();
assert_eq!(found.len(), 1);
// First store: reuse_count=0, then 1, 2, 3 after each re-store.
assert_eq!(found[0].reuse_count, 3);
// Content should reflect the latest version.
assert_eq!(found[0].context, "context v3");
}
#[tokio::test]
async fn test_find_since_filters_by_date() {
let viking = Arc::new(VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let exp = Experience::new(
"agent-1", "recent pattern", "ctx",
vec!["step".into()], "ok",
);
store.store_experience(&exp).await.unwrap();
// Query with since=far past → should find it
let old_since = Utc::now() - chrono::Duration::days(365);
let found = store.find_since("agent-1", old_since).await.unwrap();
assert_eq!(found.len(), 1);
// Query with since=far future → should not find it
let future_since = Utc::now() + chrono::Duration::days(365);
let found = store.find_since("agent-1", future_since).await.unwrap();
assert!(found.is_empty());
}
}

View File

@@ -19,6 +19,34 @@ pub trait LlmDriverForExtraction: Send + Sync {
messages: &[Message],
extraction_type: MemoryType,
) -> Result<Vec<ExtractedMemory>>;
/// 单次 LLM 调用提取全部类型(记忆 + 经验 + 画像信号)
/// 默认实现:退化到 3 次独立调用experiences 和 profile_signals 为空)
async fn extract_combined_all(
&self,
messages: &[Message],
) -> Result<crate::types::CombinedExtraction> {
let mut combined = crate::types::CombinedExtraction::default();
for mt in [MemoryType::Preference, MemoryType::Knowledge, MemoryType::Experience] {
if let Ok(mems) = self.extract_memories(messages, mt).await {
combined.memories.extend(mems);
}
}
Ok(combined)
}
/// 使用自定义 prompt 进行单次 LLM 调用,返回原始文本响应
/// 用于统一提取场景,默认返回不支持错误
async fn extract_with_prompt(
&self,
_messages: &[Message],
_system_prompt: &str,
_user_prompt: &str,
) -> Result<String> {
Err(zclaw_types::ZclawError::Internal(
"extract_with_prompt not implemented".to_string(),
))
}
}
/// Memory Extractor - extracts memories from conversations
@@ -85,13 +113,10 @@ impl MemoryExtractor {
session_id: SessionId,
) -> Result<Vec<ExtractedMemory>> {
// Check if LLM driver is available
let _llm_driver = match &self.llm_driver {
Some(driver) => driver,
None => {
tracing::debug!("[MemoryExtractor] No LLM driver configured, skipping extraction");
return Ok(Vec::new());
}
};
if self.llm_driver.is_none() {
tracing::debug!("[MemoryExtractor] No LLM driver configured, skipping extraction");
return Ok(Vec::new());
}
let mut results = Vec::new();
@@ -227,6 +252,369 @@ impl MemoryExtractor {
tracing::info!("[MemoryExtractor] Stored {} memories to OpenViking", 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
///
/// 优先使用 `extract_with_prompt()` 进行单次调用;若 driver 不支持则
/// 退化为 `extract()` + 从记忆推断经验/画像。
pub async fn extract_combined(
&self,
messages: &[Message],
session_id: SessionId,
) -> Result<crate::types::CombinedExtraction> {
let llm_driver = match &self.llm_driver {
Some(driver) => driver,
None => {
tracing::debug!(
"[MemoryExtractor] No LLM driver configured, skipping combined extraction"
);
return Ok(crate::types::CombinedExtraction::default());
}
};
// 尝试单次 LLM 调用路径
let system_prompt = "You are a memory extraction assistant. Analyze conversations and extract \
structured memories, experiences, and profile signals in valid JSON format. \
Always respond with valid JSON only, no additional text or markdown formatting.";
let user_prompt = format!(
"{}{}",
crate::extractor::prompts::COMBINED_EXTRACTION_PROMPT,
format_conversation_text(messages)
);
match llm_driver
.extract_with_prompt(messages, system_prompt, &user_prompt)
.await
{
Ok(raw_text) if !raw_text.trim().is_empty() => {
match parse_combined_response(&raw_text, session_id.clone()) {
Ok(combined) => {
tracing::info!(
"[MemoryExtractor] Combined extraction: {} memories, {} experiences, {} profile signals",
combined.memories.len(),
combined.experiences.len(),
combined.profile_signals.signal_count(),
);
return Ok(combined);
}
Err(e) => {
tracing::warn!(
"[MemoryExtractor] Combined response parse failed, falling back: {}",
e
);
}
}
}
Ok(_) => {
tracing::debug!("[MemoryExtractor] extract_with_prompt returned empty, falling back");
}
Err(e) => {
tracing::debug!(
"[MemoryExtractor] extract_with_prompt not supported ({}), falling back",
e
);
}
}
// 退化路径:使用已有的 extract() 然后推断 experiences 和 profile_signals
let memories = self.extract(messages, session_id).await?;
let experiences = infer_experiences_from_memories(&memories);
let profile_signals = infer_profile_signals_from_memories(&memories);
Ok(crate::types::CombinedExtraction {
memories,
experiences,
profile_signals,
})
}
}
/// 格式化对话消息为文本
fn format_conversation_text(messages: &[Message]) -> String {
messages
.iter()
.filter_map(|msg| match msg {
Message::User { content } => Some(format!("[User]: {}", content)),
Message::Assistant { content, .. } => Some(format!("[Assistant]: {}", content)),
Message::System { content } => Some(format!("[System]: {}", content)),
Message::ToolUse { .. } | Message::ToolResult { .. } => None,
})
.collect::<Vec<_>>()
.join("\n\n")
}
/// 从 LLM 原始响应解析 CombinedExtraction
pub fn parse_combined_response(
raw: &str,
session_id: SessionId,
) -> Result<crate::types::CombinedExtraction> {
use crate::types::CombinedExtraction;
let json_str = crate::json_utils::extract_json_block(raw);
let parsed: serde_json::Value = serde_json::from_str(json_str).map_err(|e| {
zclaw_types::ZclawError::Internal(format!("Failed to parse combined JSON: {}", e))
})?;
// 解析 memories
let memories = parsed
.get("memories")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| parse_memory_item(item, &session_id))
.collect::<Vec<_>>()
})
.unwrap_or_default();
// 解析 experiences
let experiences = parsed
.get("experiences")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(parse_experience_item)
.collect::<Vec<_>>()
})
.unwrap_or_default();
// 解析 profile_signals
let profile_signals = parse_profile_signals(&parsed);
Ok(CombinedExtraction {
memories,
experiences,
profile_signals,
})
}
/// 解析单个 memory 项
fn parse_memory_item(
value: &serde_json::Value,
session_id: &SessionId,
) -> Option<ExtractedMemory> {
let content = value.get("content")?.as_str()?.to_string();
let category = value
.get("category")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let memory_type_str = value
.get("memory_type")
.and_then(|v| v.as_str())
.unwrap_or("knowledge");
let memory_type = crate::types::MemoryType::parse(memory_type_str);
let confidence = value
.get("confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.7) as f32;
let keywords = crate::json_utils::extract_string_array(value, "keywords");
Some(
ExtractedMemory::new(memory_type, category, content, session_id.clone())
.with_confidence(confidence)
.with_keywords(keywords),
)
}
/// 解析单个 experience 项
fn parse_experience_item(value: &serde_json::Value) -> Option<crate::types::ExperienceCandidate> {
use crate::types::Outcome;
let pain_pattern = value.get("pain_pattern")?.as_str()?.to_string();
let context = value
.get("context")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let solution_steps = crate::json_utils::extract_string_array(value, "solution_steps");
let outcome_str = value
.get("outcome")
.and_then(|v| v.as_str())
.unwrap_or("partial");
let outcome = match outcome_str {
"success" => Outcome::Success,
"failed" => Outcome::Failed,
_ => Outcome::Partial,
};
let confidence = value
.get("confidence")
.and_then(|v| v.as_f64())
.unwrap_or(0.6) as f32;
let tools_used = crate::json_utils::extract_string_array(value, "tools_used");
let industry_context = value
.get("industry_context")
.and_then(|v| v.as_str())
.map(String::from);
Some(crate::types::ExperienceCandidate {
pain_pattern,
context,
solution_steps,
outcome,
confidence,
tools_used,
industry_context,
})
}
/// 解析 profile_signals
fn parse_profile_signals(obj: &serde_json::Value) -> crate::types::ProfileSignals {
let signals = obj.get("profile_signals");
crate::types::ProfileSignals {
industry: signals
.and_then(|s| s.get("industry"))
.and_then(|v| v.as_str())
.map(String::from),
recent_topic: signals
.and_then(|s| s.get("recent_topic"))
.and_then(|v| v.as_str())
.map(String::from),
pain_point: signals
.and_then(|s| s.get("pain_point"))
.and_then(|v| v.as_str())
.map(String::from),
preferred_tool: signals
.and_then(|s| s.get("preferred_tool"))
.and_then(|v| v.as_str())
.map(String::from),
communication_style: signals
.and_then(|s| s.get("communication_style"))
.and_then(|v| v.as_str())
.map(String::from),
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),
}
}
/// 从已有记忆推断结构化经验(退化路径)
fn infer_experiences_from_memories(
memories: &[ExtractedMemory],
) -> Vec<crate::types::ExperienceCandidate> {
memories
.iter()
.filter(|m| m.memory_type == crate::types::MemoryType::Experience)
.filter_map(|m| {
// 经验类记忆 → ExperienceCandidate
let content = &m.content;
if content.len() < 10 {
return None;
}
Some(crate::types::ExperienceCandidate {
pain_pattern: m.category.clone(),
context: content.clone(),
solution_steps: Vec::new(),
outcome: crate::types::Outcome::Partial,
confidence: m.confidence * 0.7, // 降低推断置信度
tools_used: m.keywords.clone(),
industry_context: None,
})
})
.collect()
}
/// 从已有记忆推断画像信号(退化路径)
fn infer_profile_signals_from_memories(
memories: &[ExtractedMemory],
) -> crate::types::ProfileSignals {
use crate::types::ProfileSignals;
let mut signals = ProfileSignals::default();
for m in memories {
match m.memory_type {
crate::types::MemoryType::Preference => {
if m.category.contains("style") || m.category.contains("风格") {
if signals.communication_style.is_none() {
signals.communication_style = Some(m.content.clone());
}
}
// 身份信号回退: 从 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 => {
if signals.recent_topic.is_none() && !m.keywords.is_empty() {
signals.recent_topic = Some(m.keywords.first().cloned().unwrap_or_default());
}
}
crate::types::MemoryType::Experience => {
for kw in &m.keywords {
if signals.preferred_tool.is_none()
&& m.content.contains(kw.as_str())
{
signals.preferred_tool = Some(kw.clone());
break;
}
}
}
_ => {}
}
}
signals
}
/// 从引号中提取名字(如"以后叫你'小马'"→"小马"
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
@@ -243,6 +631,58 @@ pub mod prompts {
}
}
/// 统一提取 prompt — 单次 LLM 调用同时提取记忆、结构化经验、画像信号
pub const COMBINED_EXTRACTION_PROMPT: &str = r#"
分析以下对话,一次性提取三类信息。严格按 JSON 格式返回。
## 输出格式
```json
{
"memories": [
{
"memory_type": "preference|knowledge|experience",
"category": "分类标签",
"content": "记忆内容",
"confidence": 0.0-1.0,
"keywords": ["关键词"]
}
],
"experiences": [
{
"pain_pattern": "痛点模式简述",
"context": "问题发生的上下文",
"solution_steps": ["步骤1", "步骤2"],
"outcome": "success|partial|failed",
"confidence": 0.0-1.0,
"tools_used": ["使用的工具/技能"],
"industry_context": "行业标识(可选)"
}
],
"profile_signals": {
"industry": "用户所在行业(可选)",
"recent_topic": "最近讨论的主要话题(可选)",
"pain_point": "用户当前痛点(可选)",
"preferred_tool": "用户偏好的工具/技能(可选)",
"communication_style": "沟通风格: concise|detailed|formal|casual(可选)",
"agent_name": "用户给助手起的名称(可选,仅在用户明确命名时填写,如'以后叫你小马')",
"user_name": "用户提到的自己的名字(可选,仅在用户明确自我介绍时填写,如'我叫张三')"
}
}
```
## 提取规则
1. **memories**: 提取用户偏好(沟通风格/格式/语言)、知识(事实/领域知识/经验教训)、使用经验(技能/工具使用模式和结果)
2. **experiences**: 仅提取明确的"问题→解决"模式要求有清晰的痛点和步骤confidence >= 0.6
3. **profile_signals**: 从对话中推断用户画像信息,只在有明确信号时填写,留空则不填
4. **identity**: 检测用户是否给助手命名(如"你叫X"/"以后叫你X"/"你的名字是X")或自我介绍(如"我叫X"/"我的名字是X"),填入 agent_name 或 user_name 字段
5. 每个字段都要有实际内容,不确定的宁可省略
6. 只返回 JSON不要附加其他文本
对话内容:
"#;
const PREFERENCE_EXTRACTION_PROMPT: &str = r#"
分析以下对话,提取用户的偏好设置。关注:
- 沟通风格偏好(简洁/详细、正式/随意)
@@ -362,11 +802,103 @@ mod tests {
assert!(!result.is_empty());
}
#[tokio::test]
async fn test_extract_combined_all_default_impl() {
let driver = MockLlmDriver;
let messages = vec![Message::user("Hello")];
let result = driver.extract_combined_all(&messages).await.unwrap();
assert_eq!(result.memories.len(), 3); // 3 types
}
#[test]
fn test_prompts_available() {
assert!(!prompts::get_extraction_prompt(MemoryType::Preference).is_empty());
assert!(!prompts::get_extraction_prompt(MemoryType::Knowledge).is_empty());
assert!(!prompts::get_extraction_prompt(MemoryType::Experience).is_empty());
assert!(!prompts::get_extraction_prompt(MemoryType::Session).is_empty());
assert!(!prompts::COMBINED_EXTRACTION_PROMPT.is_empty());
}
#[test]
fn test_parse_combined_response_full() {
let raw = r#"```json
{
"memories": [
{
"memory_type": "preference",
"category": "communication-style",
"content": "用户偏好简洁回复",
"confidence": 0.9,
"keywords": ["简洁", "风格"]
},
{
"memory_type": "knowledge",
"category": "user-facts",
"content": "用户是医院行政人员",
"confidence": 0.85,
"keywords": ["医院", "行政"]
}
],
"experiences": [
{
"pain_pattern": "报表生成耗时",
"context": "月度报表需要手动汇总多个Excel",
"solution_steps": ["使用researcher工具自动抓取", "格式化输出为Excel"],
"outcome": "success",
"confidence": 0.85,
"tools_used": ["researcher"],
"industry_context": "healthcare"
}
],
"profile_signals": {
"industry": "healthcare",
"recent_topic": "报表自动化",
"pain_point": "手动汇总Excel太慢",
"preferred_tool": "researcher",
"communication_style": "concise"
}
}
```"#;
let result = super::parse_combined_response(raw, SessionId::new()).unwrap();
assert_eq!(result.memories.len(), 2);
assert_eq!(result.experiences.len(), 1);
assert_eq!(result.experiences[0].pain_pattern, "报表生成耗时");
assert_eq!(result.experiences[0].outcome, crate::types::Outcome::Success);
assert_eq!(result.profile_signals.industry.as_deref(), Some("healthcare"));
assert_eq!(result.profile_signals.pain_point.as_deref(), Some("手动汇总Excel太慢"));
assert!(result.profile_signals.has_any_signal());
}
#[test]
fn test_parse_combined_response_minimal() {
let raw = r#"{"memories": [], "experiences": [], "profile_signals": {}}"#;
let result = super::parse_combined_response(raw, SessionId::new()).unwrap();
assert!(result.memories.is_empty());
assert!(result.experiences.is_empty());
assert!(!result.profile_signals.has_any_signal());
}
#[test]
fn test_parse_combined_response_invalid() {
let raw = "not json at all";
let result = super::parse_combined_response(raw, SessionId::new());
assert!(result.is_err());
}
#[tokio::test]
async fn test_extract_combined_fallback() {
// MockLlmDriver doesn't implement extract_with_prompt, so it falls back
let driver = Arc::new(MockLlmDriver);
let extractor = MemoryExtractor::new(driver);
let messages = vec![Message::user("Hello"), Message::assistant("Hi there!")];
let result = extractor
.extract_combined(&messages, SessionId::new())
.await
.unwrap();
// Fallback: extract() produces 3 memories, infer produces experiences from them
assert!(!result.memories.is_empty());
}
}

View File

@@ -0,0 +1,448 @@
//! 反馈信号收集与信任度管理Phase 5 反馈闭环)
//! 收集用户对进化产物(技能/Pipeline的显式/隐式反馈
//! 管理信任度衰减和优化循环
//! 信任度记录通过 VikingAdapter 持久化
use std::collections::HashMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::types::MemoryType;
use crate::viking_adapter::VikingAdapter;
/// 反馈信号类型
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum FeedbackSignal {
/// 用户直接表达的意见
Explicit,
/// 从使用行为推断
ImplicitUsage,
/// 使用频率
UsageCount,
/// 任务完成率
CompletionRate,
}
/// 情感倾向
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Sentiment {
Positive,
Negative,
Neutral,
}
/// 进化产物类型
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EvolutionArtifact {
Skill,
Pipeline,
}
/// 单条反馈记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedbackEntry {
pub artifact_id: String,
pub artifact_type: EvolutionArtifact,
pub signal: FeedbackSignal,
pub sentiment: Sentiment,
pub details: Option<String>,
pub timestamp: DateTime<Utc>,
}
/// 信任度记录
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrustRecord {
pub artifact_id: String,
pub artifact_type: EvolutionArtifact,
pub trust_score: f32,
pub total_feedback: u32,
pub positive_count: u32,
pub negative_count: u32,
pub last_updated: DateTime<Utc>,
}
/// 反馈收集器
/// 管理反馈记录和信任度评分
/// 通过 VikingAdapter 持久化信任度记录(可选)
pub struct FeedbackCollector {
trust_records: HashMap<String, TrustRecord>,
viking: Option<Arc<VikingAdapter>>,
/// 是否已从持久化存储加载信任度记录
loaded: bool,
}
impl FeedbackCollector {
pub fn new() -> Self {
Self {
trust_records: HashMap::new(),
viking: None,
loaded: false,
}
}
/// 创建带 VikingAdapter 的 FeedbackCollector
pub fn with_viking(viking: Arc<VikingAdapter>) -> Self {
Self {
trust_records: HashMap::new(),
viking: Some(viking),
loaded: false,
}
}
/// 从 VikingAdapter 加载已持久化的信任度记录
pub async fn load(&mut self) -> Result<usize, String> {
let viking = match &self.viking {
Some(v) => v,
None => return Ok(0),
};
// MemoryEntry::new("feedback", Session, artifact_id) 生成
// URI: agent://feedback/sessions/{artifact_id}
let entries = viking
.find_by_prefix("agent://feedback/sessions/")
.await
.map_err(|e| format!("Failed to load trust records: {}", e))?;
let mut count = 0;
for entry in entries {
match serde_json::from_str::<TrustRecord>(&entry.content) {
Ok(record) => {
// 只合并不覆盖:保留内存中的较新记录
self.trust_records
.entry(record.artifact_id.clone())
.or_insert(record);
count += 1;
}
Err(e) => {
tracing::warn!(
"[FeedbackCollector] Failed to deserialize trust record at {}: {}",
entry.uri,
e
);
}
}
}
tracing::debug!(
"[FeedbackCollector] Loaded {} trust records from storage",
count
);
Ok(count)
}
/// 将信任度记录持久化到 VikingAdapter
/// 首次调用时自动从存储加载已有记录,避免覆盖
pub async fn save(&mut self) -> Result<usize, String> {
// 首次保存前自动加载已有记录,防止丢失历史数据
if !self.loaded {
match self.load().await {
Ok(_) => {
self.loaded = true;
}
Err(e) => {
// 加载失败时保留 loaded=false下次 save 会重试
tracing::warn!(
"[FeedbackCollector] Auto-load before save failed, will retry next save: {}",
e
);
}
}
}
let viking = match &self.viking {
Some(v) => v,
None => return Ok(0),
};
let mut saved = 0;
for record in self.trust_records.values() {
let content = match serde_json::to_string(record) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"[FeedbackCollector] Failed to serialize trust record {}: {}",
record.artifact_id,
e
);
continue;
}
};
let entry = crate::types::MemoryEntry::new(
"feedback",
MemoryType::Session,
&record.artifact_id,
content,
)
.with_importance((record.trust_score * 10.0) as u8);
match viking.store(&entry).await {
Ok(_) => saved += 1,
Err(e) => {
tracing::warn!(
"[FeedbackCollector] Failed to save trust record {}: {}",
record.artifact_id,
e
);
}
}
}
tracing::debug!(
"[FeedbackCollector] Saved {} trust records to storage",
saved
);
Ok(saved)
}
/// 提交一条反馈
pub fn submit_feedback(&mut self, entry: FeedbackEntry) -> TrustUpdate {
let record = self
.trust_records
.entry(entry.artifact_id.clone())
.or_insert_with(|| TrustRecord {
artifact_id: entry.artifact_id.clone(),
artifact_type: entry.artifact_type.clone(),
trust_score: 0.5,
total_feedback: 0,
positive_count: 0,
negative_count: 0,
last_updated: Utc::now(),
});
// 更新计数
record.total_feedback += 1;
match entry.sentiment {
Sentiment::Positive => record.positive_count += 1,
Sentiment::Negative => record.negative_count += 1,
Sentiment::Neutral => {}
}
// 重新计算信任度
let old_score = record.trust_score;
record.trust_score = Self::calculate_trust_internal(
record.positive_count,
record.negative_count,
record.total_feedback,
record.last_updated,
);
record.last_updated = Utc::now();
let new_score = record.trust_score;
let total = record.total_feedback;
let action = Self::recommend_action_internal(new_score, total);
TrustUpdate {
artifact_id: entry.artifact_id.clone(),
old_score,
new_score,
action,
}
}
/// 获取信任度记录
pub fn get_trust(&self, artifact_id: &str) -> Option<&TrustRecord> {
self.trust_records.get(artifact_id)
}
/// 获取所有需要优化的产物(信任度 < 0.4
pub fn get_artifacts_needing_optimization(&self) -> Vec<&TrustRecord> {
self.trust_records
.values()
.filter(|r| r.trust_score < 0.4 && r.total_feedback >= 2)
.collect()
}
/// 获取所有应该归档的产物(信任度 < 0.2 且反馈 >= 5
pub fn get_artifacts_to_archive(&self) -> Vec<&TrustRecord> {
self.trust_records
.values()
.filter(|r| r.trust_score < 0.2 && r.total_feedback >= 5)
.collect()
}
/// 获取所有高信任产物(信任度 >= 0.8
pub fn get_recommended_artifacts(&self) -> Vec<&TrustRecord> {
self.trust_records
.values()
.filter(|r| r.trust_score >= 0.8)
.collect()
}
fn calculate_trust_internal(
positive: u32,
negative: u32,
total: u32,
last_updated: DateTime<Utc>,
) -> f32 {
if total == 0 {
return 0.5;
}
let positive_ratio = positive as f32 / total as f32;
let negative_penalty = negative as f32 * 0.1;
let days_since = (Utc::now() - last_updated).num_days().max(0) as f32;
let time_decay = 1.0 - (days_since * 0.005).min(0.5);
(positive_ratio * time_decay - negative_penalty).clamp(0.0, 1.0)
}
fn recommend_action_internal(trust_score: f32, total_feedback: u32) -> RecommendedAction {
if trust_score >= 0.8 {
RecommendedAction::Promote
} else if trust_score < 0.2 && total_feedback >= 5 {
RecommendedAction::Archive
} else if trust_score < 0.4 && total_feedback >= 2 {
RecommendedAction::Optimize
} else {
RecommendedAction::Monitor
}
}
}
impl Default for FeedbackCollector {
fn default() -> Self {
Self::new()
}
}
/// 信任度更新结果
#[derive(Debug, Clone)]
pub struct TrustUpdate {
pub artifact_id: String,
pub old_score: f32,
pub new_score: f32,
pub action: RecommendedAction,
}
/// 建议动作
#[derive(Debug, Clone, PartialEq)]
pub enum RecommendedAction {
/// 继续观察
Monitor,
/// 需要优化
Optimize,
/// 建议归档(降级为记忆)
Archive,
/// 建议提升为推荐技能
Promote,
}
#[cfg(test)]
mod tests {
use super::*;
fn make_feedback(artifact_id: &str, sentiment: Sentiment) -> FeedbackEntry {
FeedbackEntry {
artifact_id: artifact_id.to_string(),
artifact_type: EvolutionArtifact::Skill,
signal: FeedbackSignal::Explicit,
sentiment,
details: None,
timestamp: Utc::now(),
}
}
#[test]
fn test_initial_trust() {
let collector = FeedbackCollector::new();
assert!(collector.get_trust("skill-1").is_none());
}
#[test]
fn test_positive_feedback_increases_trust() {
let mut collector = FeedbackCollector::new();
collector.submit_feedback(make_feedback("skill-1", Sentiment::Positive));
let record = collector.get_trust("skill-1").unwrap();
assert!(record.trust_score > 0.5);
assert_eq!(record.positive_count, 1);
}
#[test]
fn test_negative_feedback_decreases_trust() {
let mut collector = FeedbackCollector::new();
collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
let record = collector.get_trust("skill-1").unwrap();
assert!(record.trust_score < 0.5);
}
#[test]
fn test_mixed_feedback() {
let mut collector = FeedbackCollector::new();
collector.submit_feedback(make_feedback("skill-1", Sentiment::Positive));
collector.submit_feedback(make_feedback("skill-1", Sentiment::Positive));
collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
let record = collector.get_trust("skill-1").unwrap();
assert_eq!(record.total_feedback, 3);
assert!(record.trust_score > 0.3); // 2/3 positive
}
#[test]
fn test_recommend_optimize() {
let mut collector = FeedbackCollector::new();
collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
let update = collector.submit_feedback(make_feedback("skill-1", Sentiment::Negative));
assert_eq!(update.action, RecommendedAction::Optimize);
}
#[test]
fn test_needs_optimization_filter() {
let mut collector = FeedbackCollector::new();
collector.submit_feedback(make_feedback("bad-skill", Sentiment::Negative));
collector.submit_feedback(make_feedback("bad-skill", Sentiment::Negative));
collector.submit_feedback(make_feedback("good-skill", Sentiment::Positive));
let needs = collector.get_artifacts_needing_optimization();
assert_eq!(needs.len(), 1);
assert_eq!(needs[0].artifact_id, "bad-skill");
}
#[test]
fn test_promote_recommendation() {
let mut collector = FeedbackCollector::new();
for _ in 0..5 {
collector.submit_feedback(make_feedback("great-skill", Sentiment::Positive));
}
let recommended = collector.get_recommended_artifacts();
assert_eq!(recommended.len(), 1);
}
#[tokio::test]
async fn test_save_and_load_roundtrip() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
// 写入阶段
let mut collector = FeedbackCollector::with_viking(viking.clone());
collector.submit_feedback(make_feedback("skill-a", Sentiment::Positive));
collector.submit_feedback(make_feedback("skill-a", Sentiment::Positive));
collector.submit_feedback(make_feedback("skill-b", Sentiment::Negative));
let saved = collector.save().await.unwrap();
assert_eq!(saved, 2); // 2 个 artifact
// 读取阶段:新 collector 从存储加载
let mut collector2 = FeedbackCollector::with_viking(viking);
let loaded = collector2.load().await.unwrap();
assert_eq!(loaded, 2);
let record_a = collector2.get_trust("skill-a").unwrap();
assert_eq!(record_a.positive_count, 2);
assert_eq!(record_a.total_feedback, 2);
let record_b = collector2.get_trust("skill-b").unwrap();
assert_eq!(record_b.negative_count, 1);
}
#[tokio::test]
async fn test_load_without_viking_returns_zero() {
let mut collector = FeedbackCollector::new();
let loaded = collector.load().await.unwrap();
assert_eq!(loaded, 0);
}
#[tokio::test]
async fn test_save_without_viking_returns_zero() {
let mut collector = FeedbackCollector::new();
let saved = collector.save().await.unwrap();
assert_eq!(saved, 0);
}
}

View File

@@ -0,0 +1,148 @@
//! 共享 JSON 工具函数
//! 从 LLM 返回的文本中提取 JSON 块
/// 从 LLM 返回文本中提取 JSON 块
/// 支持三种格式:```json...``` 围栏、```...``` 围栏、裸 {...}
/// 使用括号平衡算法找到第一个完整 JSON 块,避免误匹配
pub fn extract_json_block(text: &str) -> &str {
// 尝试匹配 ```json ... ```
if let Some(start) = text.find("```json") {
let json_start = start + 7;
if let Some(end) = text[json_start..].find("```") {
return text[json_start..json_start + end].trim();
}
}
// 尝试匹配 ``` ... ```
if let Some(start) = text.find("```") {
let json_start = start + 3;
if let Some(end) = text[json_start..].find("```") {
return text[json_start..json_start + end].trim();
}
}
// 用括号平衡算法找第一个完整 {...} 块
if let Some(slice) = find_balanced_json(text) {
return slice;
}
text.trim()
}
/// 使用括号平衡计数找到第一个完整的 {...} JSON 块
/// 正确处理字符串字面量中的花括号
fn find_balanced_json(text: &str) -> Option<&str> {
let start = text.find('{')?;
let mut depth = 0i32;
let mut in_string = false;
let mut escape_next = false;
for (i, c) in text[start..].char_indices() {
if escape_next {
escape_next = false;
continue;
}
match c {
'\\' if in_string => escape_next = true,
'"' => in_string = !in_string,
'{' if !in_string => {
depth += 1;
}
'}' if !in_string => {
depth -= 1;
if depth == 0 {
return Some(&text[start..=start + i]);
}
}
_ => {}
}
}
None
}
/// 从 serde_json::Value 中提取字符串数组
/// 用于解析 LLM 返回 JSON 中的 triggers/tools 等字段
pub fn extract_string_array(raw: &serde_json::Value, key: &str) -> Vec<String> {
raw.get(key)
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_json_block_with_markdown() {
let text = "Here is the result:\n```json\n{\"key\": \"value\"}\n```\nDone.";
assert_eq!(extract_json_block(text), "{\"key\": \"value\"}");
}
#[test]
fn test_json_block_bare() {
let text = "{\"key\": \"value\"}";
assert_eq!(extract_json_block(text), "{\"key\": \"value\"}");
}
#[test]
fn test_json_block_plain_fences() {
let text = "Result:\n```\n{\"a\": 1}\n```";
assert_eq!(extract_json_block(text), "{\"a\": 1}");
}
#[test]
fn test_json_block_nested_braces() {
let text = r#"{"outer": {"inner": "val"}}"#;
assert_eq!(extract_json_block(text), r#"{"outer": {"inner": "val"}}"#);
}
#[test]
fn test_json_block_no_json() {
let text = "no json here";
assert_eq!(extract_json_block(text), "no json here");
}
#[test]
fn test_balanced_json_skips_outer_text() {
// 第一个 { 到最后一个 } 会包含多余文本,但平衡算法只取第一个完整块
let text = "prefix {\"a\": 1} suffix {\"b\": 2}";
assert_eq!(extract_json_block(text), "{\"a\": 1}");
}
#[test]
fn test_balanced_json_handles_braces_in_strings() {
let text = r#"{"body": "function() { return x; }", "name": "test"}"#;
assert_eq!(
extract_json_block(text),
r#"{"body": "function() { return x; }", "name": "test"}"#
);
}
#[test]
fn test_balanced_json_handles_escaped_quotes() {
let text = r#"{"msg": "He said \"hello {world}\""}"#;
assert_eq!(
extract_json_block(text),
r#"{"msg": "He said \"hello {world}\""}"#
);
}
#[test]
fn test_extract_string_array() {
let raw: serde_json::Value = serde_json::from_str(
r#"{"triggers": ["报表", "日报"], "name": "test"}"#,
)
.unwrap();
let arr = extract_string_array(&raw, "triggers");
assert_eq!(arr, vec!["报表", "日报"]);
}
#[test]
fn test_extract_string_array_missing_key() {
let raw: serde_json::Value = serde_json::from_str(r#"{"name": "test"}"#).unwrap();
let arr = extract_string_array(&raw, "triggers");
assert!(arr.is_empty());
}
}

View File

@@ -5,10 +5,13 @@
//!
//! # Architecture
//!
//! The growth system consists of four main components:
//! The growth system consists of several subsystems:
//!
//! ## Memory Pipeline (L0-L2)
//!
//! 1. **MemoryExtractor** (`extractor`) - Analyzes conversations and extracts
//! preferences, knowledge, and experience using LLM.
//! preferences, knowledge, and experience using LLM. Supports combined extraction
//! (single LLM call for memories + experiences + profile signals).
//!
//! 2. **MemoryRetriever** (`retriever`) - Performs semantic search over
//! stored memories to find contextually relevant information.
@@ -19,6 +22,28 @@
//! 4. **GrowthTracker** (`tracker`) - Tracks growth metrics and evolution
//! over time.
//!
//! ## Evolution Engine (L1-L3)
//!
//! 5. **ExperienceStore** (`experience_store`) - FTS5-backed structured experience storage.
//!
//! 6. **PatternAggregator** (`pattern_aggregator`) - Collects high-frequency patterns for L2.
//!
//! 7. **SkillGenerator** (`skill_generator`) - LLM-driven SKILL.md content generation.
//!
//! 8. **QualityGate** (`quality_gate`) - Validates candidate skills (confidence, conflicts).
//!
//! 9. **EvolutionEngine** (`evolution_engine`) - Orchestrates L1/L2/L3 evolution phases.
//!
//! 10. **WorkflowComposer** (`workflow_composer`) - Extracts tool chain patterns for Pipeline YAML.
//!
//! 11. **FeedbackCollector** (`feedback_collector`) - Trust score management with decay.
//!
//! ## Support Modules
//!
//! 12. **VikingAdapter** (`viking_adapter`) - Storage abstraction (in-memory + SQLite backends).
//! 13. **Summarizer** (`summarizer`) - L0/L1 summary generation.
//! 14. **JsonUtils** (`json_utils`) - Shared JSON parsing utilities.
//!
//! # Storage
//!
//! All memories are stored in OpenViking with a URI structure:
@@ -64,6 +89,16 @@ pub mod viking_adapter;
pub mod storage;
pub mod retrieval;
pub mod summarizer;
pub mod experience_store;
pub mod json_utils;
pub mod experience_extractor;
pub mod profile_updater;
pub mod pattern_aggregator;
pub mod skill_generator;
pub mod quality_gate;
pub mod evolution_engine;
pub mod workflow_composer;
pub mod feedback_collector;
// Re-export main types for convenience
pub use types::{
@@ -77,6 +112,14 @@ pub use types::{
RetrievalResult,
UriBuilder,
effective_importance,
ArtifactType,
CombinedExtraction,
EvolutionEvent,
EvolutionEventType,
EvolutionStatus,
ExperienceCandidate,
Outcome,
ProfileSignals,
};
pub use extractor::{LlmDriverForExtraction, MemoryExtractor};
@@ -85,8 +128,21 @@ pub use injector::{InjectionFormat, PromptInjector};
pub use tracker::{AgentMetadata, GrowthTracker, LearningEvent};
pub use viking_adapter::{FindOptions, VikingAdapter, VikingLevel, VikingStorage};
pub use storage::SqliteStorage;
pub use experience_store::{Experience, ExperienceStore};
pub use retrieval::{EmbeddingClient, MemoryCache, QueryAnalyzer, SemanticScorer};
pub use summarizer::SummaryLlmDriver;
pub use experience_extractor::ExperienceExtractor;
pub use json_utils::{extract_json_block, extract_string_array};
pub use profile_updater::{ProfileFieldUpdate, ProfileUpdateKind, UserProfileUpdater};
pub use pattern_aggregator::{AggregatedPattern, PatternAggregator};
pub use skill_generator::{SkillCandidate, SkillGenerator};
pub use quality_gate::{QualityGate, QualityReport};
pub use evolution_engine::{EvolutionConfig, EvolutionEngine};
pub use workflow_composer::{PipelineCandidate, ToolChainPattern, WorkflowComposer};
pub use feedback_collector::{
EvolutionArtifact, FeedbackCollector, FeedbackEntry, FeedbackSignal,
RecommendedAction, Sentiment, TrustRecord, TrustUpdate,
};
/// Growth system configuration
#[derive(Debug, Clone)]

View File

@@ -0,0 +1,245 @@
//! 经验模式聚合器
//! 收集同一 pain_pattern 下的所有 Experience找出共同步骤
//! 用于 L2 技能进化触发判断
use std::collections::HashMap;
use crate::experience_store::{Experience, ExperienceStore};
use zclaw_types::Result;
/// 聚合后的经验模式
#[derive(Debug, Clone)]
pub struct AggregatedPattern {
pub pain_pattern: String,
pub experiences: Vec<Experience>,
pub common_steps: Vec<String>,
pub total_reuse: u32,
pub tools_used: Vec<String>,
pub industry_context: Option<String>,
}
/// 经验模式聚合器
/// 从 ExperienceStore 中收集高频复用的模式,作为 L2 技能生成的输入
pub struct PatternAggregator {
store: ExperienceStore,
}
impl PatternAggregator {
pub fn new(store: ExperienceStore) -> Self {
Self { store }
}
/// 查找可固化的模式reuse_count >= threshold 的经验
pub async fn find_evolvable_patterns(
&self,
agent_id: &str,
min_reuse: u32,
) -> Result<Vec<AggregatedPattern>> {
let all = self.store.find_by_agent(agent_id).await?;
let mut grouped: HashMap<String, Vec<Experience>> = HashMap::new();
for exp in all {
if exp.reuse_count >= min_reuse {
grouped
.entry(exp.pain_pattern.clone())
.or_default()
.push(exp);
}
}
let mut patterns = Vec::new();
for (pattern, experiences) in grouped {
let total_reuse: u32 = experiences.iter().map(|e| e.reuse_count).sum();
let common_steps = Self::find_common_steps(&experiences);
// 从 tool_used 字段提取工具名
let tools: Vec<String> = experiences
.iter()
.filter_map(|e| e.tool_used.clone())
.filter(|s| !s.is_empty())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let industry = experiences
.iter()
.filter_map(|e| e.industry_context.clone())
.next();
patterns.push(AggregatedPattern {
pain_pattern: pattern,
experiences,
common_steps,
total_reuse,
tools_used: tools,
industry_context: industry,
});
}
// 按 reuse 排序
patterns.sort_by(|a, b| b.total_reuse.cmp(&a.total_reuse));
Ok(patterns)
}
/// 找出多条经验中共同的解决步骤
fn find_common_steps(experiences: &[Experience]) -> Vec<String> {
if experiences.is_empty() {
return Vec::new();
}
if experiences.len() == 1 {
return experiences[0].solution_steps.clone();
}
// 取所有经验的交集步骤
let mut step_counts: HashMap<String, u32> = HashMap::new();
for exp in experiences {
for step in &exp.solution_steps {
*step_counts.entry(step.clone()).or_insert(0) += 1;
}
}
let threshold = experiences.len() as f32 * 0.5; // 出现在 50%+ 的经验中
let mut common: Vec<_> = step_counts
.into_iter()
.filter(|(_, count)| (*count as f32) >= threshold)
.map(|(step, _)| step)
.collect();
common.dedup();
common
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn test_find_common_steps_empty() {
let steps = PatternAggregator::find_common_steps(&[]);
assert!(steps.is_empty());
}
#[test]
fn test_find_common_steps_single() {
let exp = Experience::new(
"a",
"packaging",
"ctx",
vec!["step1".into(), "step2".into()],
"ok",
);
let steps = PatternAggregator::find_common_steps(&[exp]);
assert_eq!(steps.len(), 2);
}
#[test]
fn test_find_common_steps_multiple() {
let exp1 = Experience::new(
"a",
"packaging",
"ctx",
vec!["step1".into(), "step2".into(), "step3".into()],
"ok",
);
let exp2 = Experience::new(
"a",
"packaging",
"ctx",
vec!["step1".into(), "step2".into(), "step4".into()],
"ok",
);
// step1 and step2 appear in both (100% >= 50%)
let steps = PatternAggregator::find_common_steps(&[exp1, exp2]);
assert!(steps.contains(&"step1".to_string()));
assert!(steps.contains(&"step2".to_string()));
}
#[tokio::test]
async fn test_find_evolvable_patterns_filters_low_reuse() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
// 经验 1: reuse_count = 0 (低于阈值)
let mut exp_low = Experience::new(
"agent-1",
"low reuse task",
"ctx",
vec!["step".into()],
"ok",
);
exp_low.reuse_count = 0;
store.store_experience(&exp_low).await.unwrap();
// 经验 2: reuse_count = 5 (高于阈值)
let mut exp_high = Experience::new(
"agent-1",
"high reuse task",
"ctx",
vec!["step1".into()],
"ok",
);
exp_high.reuse_count = 5;
store.store_experience(&exp_high).await.unwrap();
let aggregator = PatternAggregator::new(store);
let patterns = aggregator.find_evolvable_patterns("agent-1", 3).await.unwrap();
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].pain_pattern, "high reuse task");
assert_eq!(patterns[0].total_reuse, 5);
}
#[tokio::test]
async fn test_find_evolvable_patterns_groups_by_pain() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let mut exp1 = Experience::new(
"agent-1",
"report generation",
"ctx1",
vec!["query db".into(), "format".into()],
"ok",
);
exp1.reuse_count = 3;
store.store_experience(&exp1).await.unwrap();
// Same pain_pattern → same URI → overwrites, so use a slightly different hash
// Actually since URI is deterministic on pain_pattern, we can only have one per pattern
// This is by design: one experience per pain_pattern (latest wins)
let patterns = aggregator_fixtures::make_patterns_with_same_pain().await;
assert_eq!(patterns.len(), 1);
}
mod aggregator_fixtures {
use super::*;
pub async fn make_patterns_with_same_pain() -> Vec<AggregatedPattern> {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let mut exp = Experience::new(
"agent-1",
"report generation",
"ctx1",
vec!["query db".into(), "format".into()],
"ok",
);
exp.reuse_count = 3;
store.store_experience(&exp).await.unwrap();
let aggregator = PatternAggregator::new(store);
aggregator.find_evolvable_patterns("agent-1", 2).await.unwrap()
}
}
#[tokio::test]
async fn test_find_evolvable_patterns_empty() {
let viking = Arc::new(crate::VikingAdapter::in_memory());
let store = ExperienceStore::new(viking);
let aggregator = PatternAggregator::new(store);
let patterns = aggregator.find_evolvable_patterns("unknown-agent", 3).await.unwrap();
assert!(patterns.is_empty());
}
}

View File

@@ -0,0 +1,157 @@
//! 用户画像增量更新器
//! 从 CombinedExtraction 的 profile_signals 提取需要更新的字段
//! 不额外调用 LLM纯规则驱动
use crate::types::CombinedExtraction;
/// 更新类型:字段覆盖 vs 数组追加
#[derive(Debug, Clone, PartialEq)]
pub enum ProfileUpdateKind {
/// 直接覆盖字段值industry, communication_style
SetField,
/// 追加到 JSON 数组字段recent_topic, pain_point, preferred_tool
AppendArray,
}
/// 待更新的画像字段
#[derive(Debug, Clone, PartialEq)]
pub struct ProfileFieldUpdate {
pub field: String,
pub value: String,
pub kind: ProfileUpdateKind,
}
/// 用户画像更新器
/// 从 CombinedExtraction 的 profile_signals 中提取需更新的字段列表
/// 调用方zclaw-runtime负责实际写入 UserProfileStore
pub struct UserProfileUpdater;
impl UserProfileUpdater {
pub fn new() -> Self {
Self
}
/// 从提取结果中收集需要更新的画像字段
/// 返回 (field, value, kind) 列表,由调用方根据 kind 选择写入方式
pub fn collect_updates(
&self,
extraction: &CombinedExtraction,
) -> Vec<ProfileFieldUpdate> {
let signals = &extraction.profile_signals;
let mut updates = Vec::new();
if let Some(ref industry) = signals.industry {
updates.push(ProfileFieldUpdate {
field: "industry".to_string(),
value: industry.clone(),
kind: ProfileUpdateKind::SetField,
});
}
if let Some(ref style) = signals.communication_style {
updates.push(ProfileFieldUpdate {
field: "communication_style".to_string(),
value: style.clone(),
kind: ProfileUpdateKind::SetField,
});
}
if let Some(ref topic) = signals.recent_topic {
updates.push(ProfileFieldUpdate {
field: "recent_topic".to_string(),
value: topic.clone(),
kind: ProfileUpdateKind::AppendArray,
});
}
if let Some(ref pain) = signals.pain_point {
updates.push(ProfileFieldUpdate {
field: "pain_point".to_string(),
value: pain.clone(),
kind: ProfileUpdateKind::AppendArray,
});
}
if let Some(ref tool) = signals.preferred_tool {
updates.push(ProfileFieldUpdate {
field: "preferred_tool".to_string(),
value: tool.clone(),
kind: ProfileUpdateKind::AppendArray,
});
}
updates
}
}
impl Default for UserProfileUpdater {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collect_updates_industry() {
let mut extraction = CombinedExtraction::default();
extraction.profile_signals.industry = Some("healthcare".to_string());
let updater = UserProfileUpdater::new();
let updates = updater.collect_updates(&extraction);
assert_eq!(updates.len(), 1);
assert_eq!(updates[0].field, "industry");
assert_eq!(updates[0].value, "healthcare");
assert_eq!(updates[0].kind, ProfileUpdateKind::SetField);
}
#[test]
fn test_collect_updates_no_signals() {
let extraction = CombinedExtraction::default();
let updater = UserProfileUpdater::new();
let updates = updater.collect_updates(&extraction);
assert!(updates.is_empty());
}
#[test]
fn test_collect_updates_multiple_signals() {
let mut extraction = CombinedExtraction::default();
extraction.profile_signals.industry = Some("ecommerce".to_string());
extraction.profile_signals.communication_style = Some("concise".to_string());
let updater = UserProfileUpdater::new();
let updates = updater.collect_updates(&extraction);
assert_eq!(updates.len(), 2);
}
#[test]
fn test_collect_updates_all_five_dimensions() {
let mut extraction = CombinedExtraction::default();
extraction.profile_signals.industry = Some("healthcare".to_string());
extraction.profile_signals.communication_style = Some("concise".to_string());
extraction.profile_signals.recent_topic = Some("报表自动化".to_string());
extraction.profile_signals.pain_point = Some("手动汇总太慢".to_string());
extraction.profile_signals.preferred_tool = Some("researcher".to_string());
let updater = UserProfileUpdater::new();
let updates = updater.collect_updates(&extraction);
assert_eq!(updates.len(), 5);
let set_fields: Vec<_> = updates
.iter()
.filter(|u| u.kind == ProfileUpdateKind::SetField)
.map(|u| u.field.as_str())
.collect();
let append_fields: Vec<_> = updates
.iter()
.filter(|u| u.kind == ProfileUpdateKind::AppendArray)
.map(|u| u.field.as_str())
.collect();
assert_eq!(set_fields, vec!["industry", "communication_style"]);
assert_eq!(append_fields, vec!["recent_topic", "pain_point", "preferred_tool"]);
}
}

View File

@@ -0,0 +1,193 @@
//! 质量门控
//! 验证生成的技能/工作流是否满足质量标准
//! 包括:置信度阈值、触发词冲突检查、格式校验
use crate::skill_generator::SkillCandidate;
/// 质量验证报告
#[derive(Debug, Clone)]
pub struct QualityReport {
pub passed: bool,
pub issues: Vec<String>,
pub confidence: f32,
}
/// 质量门控验证器
pub struct QualityGate {
min_confidence: f32,
existing_triggers: Vec<String>,
}
impl QualityGate {
pub fn new(min_confidence: f32, existing_triggers: Vec<String>) -> Self {
Self {
min_confidence,
existing_triggers,
}
}
/// 验证技能候选项
pub fn validate_skill(&self, candidate: &SkillCandidate) -> QualityReport {
let mut issues = Vec::new();
// 1. 置信度检查
if candidate.confidence < self.min_confidence {
issues.push(format!(
"置信度 {:.2} 低于阈值 {:.2}",
candidate.confidence, self.min_confidence
));
}
// 2. 名称非空
if candidate.name.trim().is_empty() {
issues.push("技能名称不能为空".to_string());
}
// 3. 至少一个触发词
if candidate.triggers.is_empty() {
issues.push("至少需要一个触发词".to_string());
}
// 4. 触发词不与现有技能冲突
let conflicts: Vec<_> = candidate
.triggers
.iter()
.filter(|t| self.existing_triggers.iter().any(|et| et == *t))
.collect();
if !conflicts.is_empty() {
issues.push(format!("触发词冲突: {:?}", conflicts));
}
// 5. SKILL.md 正文非空
if candidate.body_markdown.trim().is_empty() {
issues.push("技能正文不能为空".to_string());
}
// 6. body_markdown 最短长度 + 结构检查
if candidate.body_markdown.trim().len() < 100 {
issues.push("技能正文太短至少需要100个字符".to_string());
}
if !candidate.body_markdown.contains('#') {
issues.push("技能正文必须包含至少一个标题 (#)".to_string());
}
// 7. 置信度上限检查(防止 LLM 幻觉过高置信度)
if candidate.confidence > 1.0 {
issues.push(format!("置信度 {:.2} 超过上限 1.0", candidate.confidence));
}
QualityReport {
passed: issues.is_empty(),
issues,
confidence: candidate.confidence,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_valid_candidate() -> SkillCandidate {
SkillCandidate {
name: "每日报表".to_string(),
description: "生成每日报表".to_string(),
triggers: vec!["报表".to_string(), "日报".to_string()],
tools: vec!["researcher".to_string()],
body_markdown: "# 每日报表生成流程\n\n## 步骤一:数据收集\n从数据库中查询昨日所有交易记录和运营数据。\n\n## 步骤二:数据整理\n将原始数据按部门、类型进行分类汇总。\n\n## 步骤三:报表输出\n生成标准化报表并发送至相关部门邮箱。".to_string(),
source_pattern: "报表生成".to_string(),
confidence: 0.85,
version: 1,
}
}
#[test]
fn test_validate_valid_skill() {
let gate = QualityGate::new(0.7, vec!["搜索".to_string()]);
let candidate = make_valid_candidate();
let report = gate.validate_skill(&candidate);
assert!(report.passed);
assert!(report.issues.is_empty());
}
#[test]
fn test_validate_low_confidence() {
let gate = QualityGate::new(0.7, vec![]);
let mut candidate = make_valid_candidate();
candidate.confidence = 0.5;
let report = gate.validate_skill(&candidate);
assert!(!report.passed);
assert!(report.issues.iter().any(|i| i.contains("置信度")));
}
#[test]
fn test_validate_empty_name() {
let gate = QualityGate::new(0.5, vec![]);
let mut candidate = make_valid_candidate();
candidate.name = "".to_string();
let report = gate.validate_skill(&candidate);
assert!(!report.passed);
assert!(report.issues.iter().any(|i| i.contains("名称")));
}
#[test]
fn test_validate_empty_triggers() {
let gate = QualityGate::new(0.5, vec![]);
let mut candidate = make_valid_candidate();
candidate.triggers = vec![];
let report = gate.validate_skill(&candidate);
assert!(!report.passed);
assert!(report.issues.iter().any(|i| i.contains("触发词")));
}
#[test]
fn test_validate_trigger_conflict() {
let gate = QualityGate::new(0.5, vec!["报表".to_string()]);
let candidate = make_valid_candidate();
let report = gate.validate_skill(&candidate);
assert!(!report.passed);
assert!(report.issues.iter().any(|i| i.contains("冲突")));
}
#[test]
fn test_validate_empty_body() {
let gate = QualityGate::new(0.5, vec![]);
let mut candidate = make_valid_candidate();
candidate.body_markdown = "".to_string();
let report = gate.validate_skill(&candidate);
assert!(!report.passed);
assert!(report.issues.iter().any(|i| i.contains("正文")));
}
#[test]
fn test_validate_multiple_issues() {
let gate = QualityGate::new(0.9, vec![]);
let mut candidate = make_valid_candidate();
candidate.confidence = 0.3;
candidate.triggers = vec![];
candidate.body_markdown = "".to_string();
let report = gate.validate_skill(&candidate);
assert!(!report.passed);
assert!(report.issues.len() >= 3);
}
#[test]
fn test_validate_body_too_short() {
let gate = QualityGate::new(0.5, vec![]);
let mut candidate = make_valid_candidate();
candidate.body_markdown = "# 短内容\n步骤1".to_string();
let report = gate.validate_skill(&candidate);
assert!(!report.passed);
assert!(report.issues.iter().any(|i| i.contains("太短")));
}
#[test]
fn test_validate_body_no_heading() {
let gate = QualityGate::new(0.5, vec![]);
let mut candidate = make_valid_candidate();
candidate.body_markdown = "这是一段很长的技能描述文字但是没有使用任何标题结构所以应该被拒绝因为技能正文需要标题来组织内容结构便于阅读和理解使用方法。".to_string();
let report = gate.validate_skill(&candidate);
assert!(!report.passed);
assert!(report.issues.iter().any(|i| i.contains("标题")));
}
}

View File

@@ -19,7 +19,7 @@ struct CacheEntry {
}
/// Cache key for efficient lookups (reserved for future cache optimization)
#[allow(dead_code)]
#[allow(dead_code)] // @reserved: post-release cache optimization lookups
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
struct CacheKey {
agent_id: String,

View File

@@ -19,6 +19,8 @@ pub struct AnalyzedQuery {
pub target_types: Vec<MemoryType>,
/// Expanded search terms
pub expansions: Vec<String>,
/// Whether weak identity signals were detected (personal pronouns, possessives)
pub weak_identity: bool,
}
/// Query intent classification
@@ -36,6 +38,9 @@ pub enum QueryIntent {
Code,
/// Configuration query
Configuration,
/// Identity/personal recall — user asks about themselves or past conversations
/// Triggers broad retrieval of all preference + knowledge memories
IdentityRecall,
}
/// Query analyzer
@@ -50,6 +55,10 @@ pub struct QueryAnalyzer {
code_indicators: HashSet<String>,
/// Stop words to filter out
stop_words: HashSet<String>,
/// Patterns indicating identity/personal recall queries
identity_patterns: Vec<String>,
/// Weak identity signals (pronouns, possessives) that boost broad retrieval
weak_identity_indicators: Vec<String>,
}
impl QueryAnalyzer {
@@ -99,13 +108,60 @@ impl QueryAnalyzer {
.iter()
.map(|s| s.to_string())
.collect(),
identity_patterns: [
// Chinese identity recall patterns — direct identity queries
"我是谁", "我叫什么", "我的名字", "我的身份", "我的信息",
"关于我", "了解我", "记得我",
// Chinese — cross-session recall ("what did we discuss before")
"我之前", "我告诉过你", "我之前告诉", "我之前说过",
"还记得我", "你还记得", "你记得吗", "记得之前",
"我们之前聊过", "我们讨论过", "我们聊过", "上次聊",
"之前说过", "之前告诉", "以前说过", "以前聊过",
// Chinese — preferences/settings queries
"我的偏好", "我喜欢什么", "我的工作", "我在哪",
"我的设置", "我的习惯", "我的爱好", "我的职业",
"我记得", "我想起来", "我忘了",
// English identity recall patterns
"who am i", "what is my name", "what do you know about me",
"what did i tell", "do you remember me", "what do you remember",
"my preferences", "about me", "what have i shared",
"remind me", "what we discussed", "my settings", "my profile",
"tell me about myself", "what did we talk about", "what was my",
"i mentioned before", "we talked about", "i told you before",
]
.iter()
.map(|s| s.to_string())
.collect(),
// Weak identity signals — pronouns that hint at personal context
weak_identity_indicators: [
"我的", "我之前", "我们之前", "我们上次",
"my ", "i told", "i said", "we discussed", "we talked",
]
.iter()
.map(|s| s.to_string())
.collect(),
}
}
/// Analyze a query string
pub fn analyze(&self, query: &str) -> AnalyzedQuery {
let keywords = self.extract_keywords(query);
let intent = self.classify_intent(&keywords);
// Check for identity recall patterns first (highest priority)
let query_lower = query.to_lowercase();
let is_identity = self.identity_patterns.iter()
.any(|pattern| query_lower.contains(&pattern.to_lowercase()));
// Check for weak identity signals (personal pronouns, possessives)
let weak_identity = !is_identity && self.weak_identity_indicators.iter()
.any(|indicator| query_lower.contains(&indicator.to_lowercase()));
let intent = if is_identity {
QueryIntent::IdentityRecall
} else {
self.classify_intent(&keywords)
};
let target_types = self.infer_memory_types(intent, &keywords);
let expansions = self.expand_query(&keywords);
@@ -115,6 +171,7 @@ impl QueryAnalyzer {
intent,
target_types,
expansions,
weak_identity,
}
}
@@ -189,6 +246,12 @@ impl QueryAnalyzer {
types.push(MemoryType::Preference);
types.push(MemoryType::Knowledge);
}
QueryIntent::IdentityRecall => {
// Identity recall needs all memory types
types.push(MemoryType::Preference);
types.push(MemoryType::Knowledge);
types.push(MemoryType::Experience);
}
}
types
@@ -364,4 +427,48 @@ mod tests {
// Chinese characters should be extracted
assert!(!keywords.is_empty());
}
#[test]
fn test_identity_recall_expanded_patterns() {
let analyzer = QueryAnalyzer::new();
// New Chinese patterns should trigger IdentityRecall
assert_eq!(analyzer.analyze("我们之前聊过什么").intent, QueryIntent::IdentityRecall);
assert_eq!(analyzer.analyze("你记得吗上次说的").intent, QueryIntent::IdentityRecall);
assert_eq!(analyzer.analyze("我的设置是什么").intent, QueryIntent::IdentityRecall);
assert_eq!(analyzer.analyze("我们讨论过这个话题").intent, QueryIntent::IdentityRecall);
// New English patterns
assert_eq!(analyzer.analyze("what did we talk about yesterday").intent, QueryIntent::IdentityRecall);
assert_eq!(analyzer.analyze("remind me what I said").intent, QueryIntent::IdentityRecall);
assert_eq!(analyzer.analyze("my settings").intent, QueryIntent::IdentityRecall);
}
#[test]
fn test_weak_identity_detection() {
let analyzer = QueryAnalyzer::new();
// Queries with "我的" but not matching full identity patterns
let analyzed = analyzer.analyze("我的项目进度怎么样了");
assert!(analyzed.weak_identity, "Should detect weak identity from '我的'");
assert_ne!(analyzed.intent, QueryIntent::IdentityRecall);
// Queries without personal signals should not trigger weak identity
let analyzed = analyzer.analyze("解释一下Rust的所有权");
assert!(!analyzed.weak_identity);
// Full identity pattern should NOT set weak_identity (it's already IdentityRecall)
let analyzed = analyzer.analyze("我是谁");
assert!(!analyzed.weak_identity);
assert_eq!(analyzed.intent, QueryIntent::IdentityRecall);
}
#[test]
fn test_no_false_identity_on_general_queries() {
let analyzer = QueryAnalyzer::new();
// General queries should not trigger identity recall or weak identity
assert_ne!(analyzer.analyze("什么是机器学习").intent, QueryIntent::IdentityRecall);
assert!(!analyzer.analyze("什么是机器学习").weak_identity);
}
}

View File

@@ -122,13 +122,65 @@ impl SemanticScorer {
.collect()
}
/// Tokenize text into words
/// Tokenize text into words with CJK-aware bigram support.
///
/// For ASCII/latin text, splits on non-alphanumeric boundaries as before.
/// For CJK text, generates character-level bigrams (e.g. "北京工作" → ["北京", "京工", "工作"])
/// so that TF-IDF cosine similarity works for CJK queries.
fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty() && s.len() > 1)
.map(|s| s.to_string())
.collect()
let lower = text.to_lowercase();
let mut tokens = Vec::new();
// Split into segments: each segment is either pure CJK or non-CJK
let mut cjk_buf = String::new();
let mut latin_buf = String::new();
let flush_latin = |buf: &mut String, tokens: &mut Vec<String>| {
if !buf.is_empty() {
for word in buf.split(|c: char| !c.is_alphanumeric()) {
if !word.is_empty() && word.len() > 1 {
tokens.push(word.to_string());
}
}
buf.clear();
}
};
let flush_cjk = |buf: &mut String, tokens: &mut Vec<String>| {
if buf.is_empty() {
return;
}
let chars: Vec<char> = buf.chars().collect();
// Generate bigrams for CJK
if chars.len() >= 2 {
for i in 0..chars.len() - 1 {
tokens.push(format!("{}{}", chars[i], chars[i + 1]));
}
}
// Also include the full CJK segment as a single token for exact-match bonus
if chars.len() > 1 {
tokens.push(buf.clone());
}
buf.clear();
};
for c in lower.chars() {
if is_cjk_char(c) {
flush_latin(&mut latin_buf, &mut tokens);
cjk_buf.push(c);
} else if c.is_alphanumeric() {
flush_cjk(&mut cjk_buf, &mut tokens);
latin_buf.push(c);
} else {
// Non-alphanumeric, non-CJK: flush both
flush_latin(&mut latin_buf, &mut tokens);
flush_cjk(&mut cjk_buf, &mut tokens);
}
}
flush_latin(&mut latin_buf, &mut tokens);
flush_cjk(&mut cjk_buf, &mut tokens);
tokens
}
/// Remove stop words from tokens
@@ -409,6 +461,20 @@ impl Default for SemanticScorer {
}
}
/// Check if a character is a CJK ideograph
fn is_cjk_char(c: char) -> bool {
matches!(c,
'\u{4E00}'..='\u{9FFF}' |
'\u{3400}'..='\u{4DBF}' |
'\u{20000}'..='\u{2A6DF}' |
'\u{2A700}'..='\u{2B73F}' |
'\u{2B740}'..='\u{2B81F}' |
'\u{2B820}'..='\u{2CEAF}' |
'\u{F900}'..='\u{FAFF}' |
'\u{2F800}'..='\u{2FA1F}'
)
}
/// Index statistics
#[derive(Debug, Clone)]
pub struct IndexStats {
@@ -430,6 +496,42 @@ mod tests {
assert_eq!(tokens, vec!["hello", "world", "this", "is", "test"]);
}
#[test]
fn test_tokenize_cjk_bigrams() {
// CJK text should produce bigrams + full segment token
let tokens = SemanticScorer::tokenize("北京工作");
assert!(tokens.contains(&"北京".to_string()), "should contain bigram 北京");
assert!(tokens.contains(&"京工".to_string()), "should contain bigram 京工");
assert!(tokens.contains(&"工作".to_string()), "should contain bigram 工作");
assert!(tokens.contains(&"北京工作".to_string()), "should contain full segment");
}
#[test]
fn test_tokenize_mixed_cjk_latin() {
// Mixed CJK and latin should handle both
let tokens = SemanticScorer::tokenize("我在北京工作用Python写脚本");
// CJK bigrams
assert!(tokens.contains(&"我在".to_string()));
assert!(tokens.contains(&"北京".to_string()));
// Latin word
assert!(tokens.contains(&"python".to_string()));
}
#[test]
fn test_cjk_similarity() {
let mut scorer = SemanticScorer::new();
let entry = MemoryEntry::new(
"test", MemoryType::Preference, "test",
"用户在北京工作做AI产品经理".to_string(),
);
scorer.index_entry(&entry);
// Query "北京" should have non-zero similarity after bigram fix
let score = scorer.score_similarity("北京", &entry);
assert!(score > 0.0, "CJK query should score > 0 after bigram tokenization, got {}", score);
}
#[test]
fn test_stop_words_removal() {
let scorer = SemanticScorer::new();

View File

@@ -19,6 +19,8 @@ pub struct MemoryRetriever {
config: RetrievalConfig,
/// Semantic scorer for similarity computation
scorer: RwLock<SemanticScorer>,
/// Pending embedding client (applied on next scorer access if try_write failed)
pending_embedding: std::sync::Mutex<Option<Arc<dyn crate::retrieval::semantic::EmbeddingClient>>>,
/// Query analyzer
analyzer: QueryAnalyzer,
/// Memory cache
@@ -32,6 +34,7 @@ impl MemoryRetriever {
viking,
config: RetrievalConfig::default(),
scorer: RwLock::new(SemanticScorer::new()),
pending_embedding: std::sync::Mutex::new(None),
analyzer: QueryAnalyzer::new(),
cache: MemoryCache::default_config(),
}
@@ -67,6 +70,11 @@ impl MemoryRetriever {
analyzed.keywords
);
// Identity recall uses broad scope-based retrieval (bypasses text search)
if analyzed.intent == crate::retrieval::query::QueryIntent::IdentityRecall {
return self.retrieve_broad_identity(agent_id).await;
}
// Retrieve each type with budget constraints and reranking
let preferences = self
.retrieve_and_rerank(
@@ -101,6 +109,25 @@ impl MemoryRetriever {
)
.await?;
let total_found = preferences.len() + knowledge.len() + experience.len();
// Fallback: if keyword-based retrieval returns too few results AND weak identity
// signals are present (e.g. "我的xxx", "我之前xxx"), supplement with broad retrieval
// to ensure cross-session memories are found even without exact keyword match.
let (preferences, knowledge, experience) = if total_found < 3 && analyzed.weak_identity {
tracing::info!(
"[MemoryRetriever] Weak identity + low results ({}), supplementing with broad retrieval",
total_found
);
let broad = self.retrieve_broad_identity(agent_id).await?;
let prefs = Self::merge_results(preferences, broad.preferences);
let knows = Self::merge_results(knowledge, broad.knowledge);
let exps = Self::merge_results(experience, broad.experience);
(prefs, knows, exps)
} else {
(preferences, knowledge, experience)
};
let total_tokens = preferences.iter()
.chain(knowledge.iter())
.chain(experience.iter())
@@ -148,6 +175,7 @@ impl MemoryRetriever {
intent: crate::retrieval::query::QueryIntent::General,
target_types: vec![],
expansions: vec![],
weak_identity: false,
};
let search_queries = self.analyzer.generate_search_queries(&analyzed_for_search);
@@ -193,6 +221,20 @@ impl MemoryRetriever {
Ok(filtered)
}
/// Merge keyword-based and broad-retrieval results, deduplicating by URI.
/// Keyword results take precedence (appear first), broad results fill gaps.
fn merge_results(keyword_results: Vec<MemoryEntry>, broad_results: Vec<MemoryEntry>) -> Vec<MemoryEntry> {
let mut seen = std::collections::HashSet::new();
let mut merged = Vec::new();
for entry in keyword_results.into_iter().chain(broad_results.into_iter()) {
if seen.insert(entry.uri.clone()) {
merged.push(entry);
}
}
merged
}
/// Rerank entries using semantic similarity
async fn rerank_entries(
&self,
@@ -205,19 +247,40 @@ impl MemoryRetriever {
let mut scorer = self.scorer.write().await;
// Apply any pending embedding client
self.apply_pending_embedding(&mut scorer);
// Check if embedding is available for enhanced scoring
let use_embedding = scorer.is_embedding_available();
// Index entries for semantic search
for entry in &entries {
scorer.index_entry(entry);
if use_embedding {
for entry in &entries {
scorer.index_entry_with_embedding(entry).await;
}
} else {
for entry in &entries {
scorer.index_entry(entry);
}
}
// Score each entry
let mut scored: Vec<(f32, MemoryEntry)> = entries
.into_iter()
.map(|entry| {
let score = scorer.score_similarity(query, &entry);
(score, entry)
})
.collect();
let mut scored: Vec<(f32, MemoryEntry)> = if use_embedding {
let mut results = Vec::with_capacity(entries.len());
for entry in entries {
let score = scorer.score_similarity_with_embedding(query, &entry).await;
results.push((score, entry));
}
results
} else {
entries
.into_iter()
.map(|entry| {
let score = scorer.score_similarity(query, &entry);
(score, entry)
})
.collect()
};
// Sort by score (descending), then by importance and access count
scored.sort_by(|a, b| {
@@ -230,6 +293,174 @@ impl MemoryRetriever {
scored.into_iter().map(|(_, entry)| entry).collect()
}
/// Broad identity recall — retrieves all recent preference + knowledge memories
/// without requiring text match. Used when the user asks about themselves.
///
/// This bypasses FTS5/LIKE search entirely and does a scope-based retrieval
/// sorted by recency and importance, ensuring identity information is always
/// available across sessions.
async fn retrieve_broad_identity(&self, agent_id: &AgentId) -> Result<RetrievalResult> {
tracing::info!(
"[MemoryRetriever] Broad identity recall for agent: {}",
agent_id
);
let agent_str = agent_id.to_string();
// Retrieve preferences (scope-only, no text search)
let preferences = self.retrieve_by_scope(
&agent_str,
MemoryType::Preference,
self.config.max_results_per_type,
self.config.preference_budget,
).await?;
// Retrieve knowledge (scope-only)
let knowledge = self.retrieve_by_scope(
&agent_str,
MemoryType::Knowledge,
self.config.max_results_per_type,
self.config.knowledge_budget,
).await?;
// Retrieve recent experiences (scope-only, limited)
let experience = self.retrieve_by_scope(
&agent_str,
MemoryType::Experience,
self.config.max_results_per_type / 2,
self.config.experience_budget,
).await?;
// Fallback: if no results for this agent, search across ALL agents
// for identity-critical info (user name, workplace, preferences)
if preferences.is_empty() && knowledge.is_empty() && experience.is_empty() {
tracing::info!(
"[MemoryRetriever] No memories for agent {}, falling back to global scope",
agent_str
);
let global_prefs = self.retrieve_by_scope_any_agent(
MemoryType::Preference,
self.config.max_results_per_type,
self.config.preference_budget,
).await?;
let global_knowledge = self.retrieve_by_scope_any_agent(
MemoryType::Knowledge,
self.config.max_results_per_type,
self.config.knowledge_budget,
).await?;
let total: usize = global_prefs.iter()
.chain(global_knowledge.iter())
.map(|m| m.estimated_tokens())
.sum();
return Ok(RetrievalResult {
preferences: global_prefs,
knowledge: global_knowledge,
experience,
total_tokens: total,
});
}
let total_tokens = preferences.iter()
.chain(knowledge.iter())
.chain(experience.iter())
.map(|m| m.estimated_tokens())
.sum();
tracing::info!(
"[MemoryRetriever] Identity recall: {} preferences, {} knowledge, {} experience",
preferences.len(),
knowledge.len(),
experience.len()
);
Ok(RetrievalResult {
preferences,
knowledge,
experience,
total_tokens,
})
}
/// Retrieve memories across ALL agents for a given type.
/// Used as fallback when agent-scoped retrieval returns nothing for identity recall.
async fn retrieve_by_scope_any_agent(
&self,
memory_type: MemoryType,
max_results: usize,
token_budget: usize,
) -> Result<Vec<MemoryEntry>> {
// Match any agent by using only the type suffix as scope pattern
let scope_pattern = format!("/{}", memory_type);
let options = FindOptions {
scope: None, // No scope filter — search all agents
limit: Some(max_results * 3),
min_similarity: None,
};
let entries = self.viking.find("", options).await?;
// Filter to only matching memory type
let mut filtered: Vec<MemoryEntry> = entries
.into_iter()
.filter(|e| e.uri.contains(&scope_pattern) || e.memory_type == memory_type)
.collect();
filtered.sort_by(|a, b| {
b.importance.cmp(&a.importance)
.then_with(|| b.access_count.cmp(&a.access_count))
});
let mut result = Vec::new();
let mut used_tokens = 0;
for entry in filtered {
let tokens = entry.estimated_tokens();
if used_tokens + tokens > token_budget { break; }
used_tokens += tokens;
result.push(entry);
if result.len() >= max_results { break; }
}
Ok(result)
}
/// Retrieve memories by scope only (no text search).
/// Returns entries sorted by importance and recency, limited by budget.
async fn retrieve_by_scope(
&self,
agent_id: &str,
memory_type: MemoryType,
max_results: usize,
token_budget: usize,
) -> Result<Vec<MemoryEntry>> {
let scope = format!("agent://{}/{}", agent_id, memory_type);
let options = FindOptions {
scope: Some(scope),
limit: Some(max_results * 3), // Fetch more candidates for filtering
min_similarity: None, // No similarity threshold for scope-only
};
// Empty query triggers scope-only fetch in SqliteStorage::find()
let entries = self.viking.find("", options).await?;
// Sort by importance (desc) and apply token budget
let mut sorted = entries;
sorted.sort_by(|a, b| {
b.importance.cmp(&a.importance)
.then_with(|| b.access_count.cmp(&a.access_count))
});
let mut filtered = Vec::new();
let mut used_tokens = 0;
for entry in sorted {
let tokens = entry.estimated_tokens();
if used_tokens + tokens <= token_budget {
used_tokens += tokens;
filtered.push(entry);
}
if filtered.len() >= max_results {
break;
}
}
Ok(filtered)
}
/// Retrieve a specific memory by URI (with cache)
pub async fn get_by_uri(&self, uri: &str) -> Result<Option<MemoryEntry>> {
// Check cache first
@@ -277,6 +508,36 @@ impl MemoryRetriever {
})
}
/// Configure embedding client for semantic similarity
///
/// Stores the client for lazy application on first scorer use.
/// If the scorer lock is busy, the client is stored as pending
/// and applied on the next successful lock acquisition.
pub fn set_embedding_client(
&self,
client: Arc<dyn crate::retrieval::semantic::EmbeddingClient>,
) {
if let Ok(mut scorer) = self.scorer.try_write() {
*scorer = SemanticScorer::with_embedding(client);
tracing::info!("[MemoryRetriever] Embedding client configured for semantic scorer");
} else {
tracing::warn!("[MemoryRetriever] Scorer lock busy, storing embedding client as pending");
if let Ok(mut pending) = self.pending_embedding.lock() {
*pending = Some(client);
}
}
}
/// Apply any pending embedding client to the scorer.
fn apply_pending_embedding(&self, scorer: &mut SemanticScorer) {
if let Ok(mut pending) = self.pending_embedding.lock() {
if let Some(client) = pending.take() {
*scorer = SemanticScorer::with_embedding(client);
tracing::info!("[MemoryRetriever] Pending embedding client applied to scorer");
}
}
}
/// Clear the semantic index
pub async fn clear_index(&self) {
let mut scorer = self.scorer.write().await;

View File

@@ -0,0 +1,164 @@
//! 技能生成器
//! 将聚合的经验模式通过 LLM 转化为 SKILL.md 内容
//! 提供 prompt 构建和 JSON 结果解析
use crate::pattern_aggregator::AggregatedPattern;
use zclaw_types::Result;
/// 技能候选项
#[derive(Debug, Clone)]
pub struct SkillCandidate {
pub name: String,
pub description: String,
pub triggers: Vec<String>,
pub tools: Vec<String>,
pub body_markdown: String,
pub source_pattern: String,
pub confidence: f32,
/// 技能版本号,用于后续迭代追踪
pub version: u32,
}
/// LLM 驱动的技能生成 prompt
const SKILL_GENERATION_PROMPT: &str = r#"
你是一个技能设计专家。根据以下用户反复出现的问题和解决步骤,生成一个可复用的技能定义。
问题模式:{pain_pattern}
解决步骤:{steps}
使用的工具:{tools}
行业背景:{industry}
请生成以下 JSON
```json
{
"name": "技能名称(简短中文)",
"description": "技能描述(一段话)",
"triggers": ["触发词1", "触发词2", "触发词3"],
"tools": ["tool1", "tool2"],
"body_markdown": "技能的 Markdown 正文,包含步骤说明",
"confidence": 0.85
}
```
"#;
/// 技能生成器
/// 负责 prompt 构建和 LLM 返回的 JSON 解析
pub struct SkillGenerator;
impl SkillGenerator {
pub fn new() -> Self {
Self
}
/// 从聚合模式构建 LLM prompt
pub fn build_prompt(pattern: &AggregatedPattern) -> String {
SKILL_GENERATION_PROMPT
.replace("{pain_pattern}", &pattern.pain_pattern)
.replace("{steps}", &pattern.common_steps.join(""))
.replace("{tools}", &pattern.tools_used.join(", "))
.replace("{industry}", pattern.industry_context.as_deref().unwrap_or("通用"))
}
/// 解析 LLM 返回的 JSON 为 SkillCandidate
pub fn parse_response(json_str: &str, pattern: &AggregatedPattern) -> Result<SkillCandidate> {
let json_str = crate::json_utils::extract_json_block(json_str);
let raw: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
zclaw_types::ZclawError::ConfigError(format!("Invalid skill JSON: {}", e))
})?;
Ok(SkillCandidate {
name: raw["name"]
.as_str()
.unwrap_or("未命名技能")
.to_string(),
description: raw["description"].as_str().unwrap_or("").to_string(),
triggers: crate::json_utils::extract_string_array(&raw, "triggers"),
tools: crate::json_utils::extract_string_array(&raw, "tools"),
body_markdown: raw["body_markdown"].as_str().unwrap_or("").to_string(),
source_pattern: pattern.pain_pattern.clone(),
confidence: raw["confidence"].as_f64().unwrap_or(0.5) as f32,
version: raw["version"].as_u64().unwrap_or(1) as u32,
})
}
}
impl Default for SkillGenerator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::experience_store::Experience;
fn make_pattern() -> AggregatedPattern {
let exp = Experience::new(
"agent-1",
"报表生成",
"researcher",
vec!["查询数据库".into(), "格式化输出".into()],
"success",
);
AggregatedPattern {
pain_pattern: "报表生成".to_string(),
experiences: vec![exp],
common_steps: vec!["查询数据库".into(), "格式化输出".into()],
total_reuse: 5,
tools_used: vec!["researcher".into()],
industry_context: Some("healthcare".into()),
}
}
#[test]
fn test_build_prompt() {
let pattern = make_pattern();
let prompt = SkillGenerator::build_prompt(&pattern);
assert!(prompt.contains("报表生成"));
assert!(prompt.contains("查询数据库"));
assert!(prompt.contains("researcher"));
assert!(prompt.contains("healthcare"));
}
#[test]
fn test_parse_response_valid_json() {
let pattern = make_pattern();
let json = r##"{"name":"每日报表","description":"生成每日报表","triggers":["报表","日报"],"tools":["researcher"],"body_markdown":"# 每日报表\n步骤1","confidence":0.9}"##;
let candidate = SkillGenerator::parse_response(json, &pattern).unwrap();
assert_eq!(candidate.name, "每日报表");
assert_eq!(candidate.triggers.len(), 2);
assert_eq!(candidate.confidence, 0.9);
assert_eq!(candidate.source_pattern, "报表生成");
}
#[test]
fn test_parse_response_json_block() {
let pattern = make_pattern();
let text = r#"```json
{"name":"技能A","description":"desc","triggers":["a"],"tools":[],"body_markdown":"body","confidence":0.8}
```"#;
let candidate = SkillGenerator::parse_response(text, &pattern).unwrap();
assert_eq!(candidate.name, "技能A");
}
#[test]
fn test_parse_response_invalid_json() {
let pattern = make_pattern();
let result = SkillGenerator::parse_response("not json at all", &pattern);
assert!(result.is_err());
}
#[test]
fn test_extract_json_block_with_markdown() {
let text = "Here is the result:\n```json\n{\"key\": \"value\"}\n```\nDone.";
assert_eq!(crate::json_utils::extract_json_block(text), "{\"key\": \"value\"}");
}
#[test]
fn test_extract_json_block_bare() {
let text = "{\"key\": \"value\"}";
assert_eq!(crate::json_utils::extract_json_block(text), "{\"key\": \"value\"}");
}
}

View File

@@ -22,7 +22,7 @@ pub struct SqliteStorage {
/// Semantic scorer for similarity computation
scorer: Arc<RwLock<SemanticScorer>>,
/// Database path (for reference)
#[allow(dead_code)]
#[allow(dead_code)] // @reserved: db path for diagnostics and reconnect
path: PathBuf,
}
@@ -41,6 +41,11 @@ pub(crate) struct MemoryRow {
}
impl SqliteStorage {
/// Get a reference to the underlying connection pool
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
/// Create a new SQLite storage at the given path
pub async fn new(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
@@ -127,13 +132,16 @@ impl SqliteStorage {
.map_err(|e| ZclawError::StorageError(format!("Failed to create memories table: {}", e)))?;
// Create FTS5 virtual table for full-text search
// Use trigram tokenizer for CJK (Chinese/Japanese/Korean) support.
// unicode61 cannot tokenize CJK characters, causing memory search to fail.
// trigram indexes overlapping 3-character slices, works well for all languages.
sqlx::query(
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
uri,
content,
keywords,
tokenize='unicode61'
tokenize='trigram'
)
"#,
)
@@ -154,22 +162,77 @@ impl SqliteStorage {
.map_err(|e| ZclawError::StorageError(format!("Failed to create importance index: {}", e)))?;
// Migration: add overview column (L1 summary)
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN overview TEXT")
// SQLite ALTER TABLE ADD COLUMN fails with "duplicate column name" if already applied
if let Err(e) = sqlx::query("ALTER TABLE memories ADD COLUMN overview TEXT")
.execute(&self.pool)
.await;
.await
{
let msg = e.to_string();
if !msg.contains("duplicate column name") {
tracing::warn!("[Growth] Migration overview failed: {}", msg);
}
}
// Migration: add abstract_summary column (L0 keywords)
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN abstract_summary TEXT")
if let Err(e) = sqlx::query("ALTER TABLE memories ADD COLUMN abstract_summary TEXT")
.execute(&self.pool)
.await;
.await
{
let msg = e.to_string();
if !msg.contains("duplicate column name") {
tracing::warn!("[Growth] Migration abstract_summary failed: {}", msg);
}
}
// P2-24: Migration — content fingerprint for deduplication
let _ = sqlx::query("ALTER TABLE memories ADD COLUMN content_hash TEXT")
if let Err(e) = sqlx::query("ALTER TABLE memories ADD COLUMN content_hash TEXT")
.execute(&self.pool)
.await;
let _ = sqlx::query("CREATE INDEX IF NOT EXISTS idx_content_hash ON memories(content_hash)")
.await
{
let msg = e.to_string();
if !msg.contains("duplicate column name") {
tracing::warn!("[Growth] Migration content_hash failed: {}", msg);
}
}
if let Err(e) = sqlx::query("CREATE INDEX IF NOT EXISTS idx_content_hash ON memories(content_hash)")
.execute(&self.pool)
.await;
.await
{
tracing::warn!("[Growth] Migration idx_content_hash failed: {}", e);
}
// Backfill content_hash for existing entries that have NULL content_hash
{
use std::hash::{Hash, Hasher};
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT uri, content FROM memories WHERE content_hash IS NULL"
)
.fetch_all(&self.pool)
.await
.unwrap_or_default();
if !rows.is_empty() {
for (uri, content) in &rows {
let normalized = content.trim().to_lowercase();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
normalized.hash(&mut hasher);
let hash = format!("{:016x}", hasher.finish());
if let Err(e) = sqlx::query("UPDATE memories SET content_hash = ? WHERE uri = ?")
.bind(&hash)
.bind(uri)
.execute(&self.pool)
.await
{
tracing::warn!("[sqlite] content_hash update failed for {}: {}", uri, e);
}
}
tracing::info!(
"[SqliteStorage] Backfilled content_hash for {} existing entries",
rows.len()
);
}
}
// Create metadata table
sqlx::query(
@@ -184,6 +247,49 @@ impl SqliteStorage {
.await
.map_err(|e| ZclawError::StorageError(format!("Failed to create metadata table: {}", e)))?;
// Migration: Rebuild FTS5 table if using old unicode61 tokenizer (can't handle CJK)
// Check tokenizer by inspecting the existing FTS5 table definition
let needs_rebuild: bool = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memories_fts' AND sql LIKE '%unicode61%'"
)
.fetch_one(&self.pool)
.await
.unwrap_or(0) > 0;
if needs_rebuild {
tracing::info!("[SqliteStorage] Rebuilding FTS5 table: unicode61 → trigram for CJK support");
// Drop old FTS5 table
if let Err(e) = sqlx::query("DROP TABLE IF EXISTS memories_fts")
.execute(&self.pool)
.await
{
tracing::warn!("[sqlite] FTS5 table drop failed during rebuild: {}", e);
}
// Recreate with trigram tokenizer
sqlx::query(
r#"
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
uri,
content,
keywords,
tokenize='trigram'
)
"#,
)
.execute(&self.pool)
.await
.map_err(|e| ZclawError::StorageError(format!("Failed to recreate FTS5 table: {}", e)))?;
// Reindex all existing memories into FTS5
let reindexed = sqlx::query(
"INSERT INTO memories_fts (uri, content, keywords) SELECT uri, content, keywords FROM memories"
)
.execute(&self.pool)
.await
.map(|r| r.rows_affected())
.unwrap_or(0);
tracing::info!("[SqliteStorage] FTS5 rebuild complete, reindexed {} entries", reindexed);
}
tracing::info!("[SqliteStorage] Database schema initialized");
Ok(())
}
@@ -323,14 +429,17 @@ impl SqliteStorage {
.await;
// Also clean up FTS entries for archived memories
let _ = sqlx::query(
if let Err(e) = sqlx::query(
r#"
DELETE FROM memories_fts
WHERE uri NOT IN (SELECT uri FROM memories)
"#,
)
.execute(&self.pool)
.await;
.await
{
tracing::warn!("[sqlite] FTS cleanup after archive failed: {}", e);
}
let archived = archive_result
.map(|r| r.rows_affected())
@@ -373,19 +482,82 @@ impl SqliteStorage {
/// Strips these and keeps only alphanumeric + CJK tokens with length > 1,
/// then joins them with `OR` for broad matching.
fn sanitize_fts_query(query: &str) -> String {
let terms: Vec<String> = query
.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty() && s.len() > 1)
.map(|s| s.to_string())
.collect();
// trigram tokenizer requires quoted phrases for substring matching
// and needs at least 3 characters per term to produce results.
let lower = query.to_lowercase();
if terms.is_empty() {
return String::new();
// Check if query contains CJK characters — trigram handles them natively
let has_cjk = lower.chars().any(|c| {
matches!(c, '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}')
});
if has_cjk {
// For CJK queries, extract tokens: CJK character sequences and ASCII words.
// Join with OR for broad matching (not exact phrase, which would miss scattered terms).
let mut tokens: Vec<String> = Vec::new();
let mut cjk_buf = String::new();
let mut ascii_buf = String::new();
for ch in lower.chars() {
let is_cjk = matches!(ch, '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}');
if is_cjk {
if !ascii_buf.is_empty() {
if ascii_buf.len() >= 2 {
tokens.push(format!("\"{}\"", ascii_buf));
}
ascii_buf.clear();
}
cjk_buf.push(ch);
} else if ch.is_alphanumeric() {
if !cjk_buf.is_empty() {
// Flush CJK buffer — each CJK character is a potential token
// (trigram indexes 3-char sequences, so single CJK chars won't
// match alone, but 2+ char sequences will)
if cjk_buf.len() >= 2 {
tokens.push(format!("\"{}\"", cjk_buf));
}
cjk_buf.clear();
}
ascii_buf.push(ch);
} else {
// Separator — flush both buffers
if cjk_buf.len() >= 2 {
tokens.push(format!("\"{}\"", cjk_buf));
}
cjk_buf.clear();
if ascii_buf.len() >= 2 {
tokens.push(format!("\"{}\"", ascii_buf));
}
ascii_buf.clear();
}
}
// Flush remaining
if cjk_buf.len() >= 2 {
tokens.push(format!("\"{}\"", cjk_buf));
}
if ascii_buf.len() >= 2 {
tokens.push(format!("\"{}\"", ascii_buf));
}
if tokens.is_empty() {
return String::new();
}
tokens.join(" OR ")
} else {
// For non-CJK, split into terms and join with OR
let terms: Vec<String> = lower
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty() && s.len() > 1)
.map(|s| format!("\"{}\"", s))
.collect();
if terms.is_empty() {
return String::new();
}
terms.join(" OR ")
}
// Join with OR so any term can match (broad recall, then rerank by similarity)
terms.join(" OR ")
}
/// Fetch memories by scope with importance-based ordering.
@@ -560,6 +732,11 @@ impl VikingStorage for SqliteStorage {
async fn find(&self, query: &str, options: FindOptions) -> Result<Vec<MemoryEntry>> {
let limit = options.limit.unwrap_or(50).max(20); // Fetch more candidates for reranking
// Detect CJK early — used both for LIKE fallback and similarity threshold relaxation
let has_cjk = query.chars().any(|c| {
matches!(c, '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}')
});
// Strategy: use FTS5 for initial filtering when query is non-empty,
// then score candidates with TF-IDF / embedding for precise ranking.
// When FTS5 returns nothing, we return empty — do NOT fall back to
@@ -620,9 +797,6 @@ impl VikingStorage for SqliteStorage {
// FTS5 returned no results or failed — check if query contains CJK
// characters. unicode61 tokenizer doesn't index CJK, so fall back
// to LIKE-based search for CJK queries.
let has_cjk = query.chars().any(|c| {
matches!(c, '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}')
});
if !has_cjk {
tracing::debug!(
@@ -725,9 +899,17 @@ impl VikingStorage for SqliteStorage {
scorer.score_similarity(query, &entry)
};
// Apply similarity threshold
// Apply similarity threshold (relaxed for CJK queries since unicode61
// tokenizer doesn't produce meaningful TF-IDF scores for CJK text)
if let Some(min_similarity) = options.min_similarity {
if semantic_score < min_similarity {
let threshold = if has_cjk {
// CJK TF-IDF scores are systematically low due to tokenizer limitations;
// use 50% of the normal threshold to avoid filtering out all results
min_similarity * 0.5
} else {
min_similarity
};
if semantic_score < threshold {
continue;
}
}

View File

@@ -66,21 +66,30 @@ impl GrowthTracker {
timestamp: Utc::now(),
};
// Store learning event
self.viking
.store_metadata(
&format!("agent://{}/events/{}", agent_id, session_id),
&event,
)
.await?;
// Store learning event as MemoryEntry so get_timeline can find it via find_by_prefix
let event_uri = format!("agent://{}/events/{}", agent_id, session_id);
let content = serde_json::to_string(&event)?;
let entry = crate::types::MemoryEntry {
uri: event_uri,
memory_type: MemoryType::Session,
content,
keywords: vec![agent_id.to_string(), session_id.to_string()],
importance: 5,
access_count: 0,
created_at: event.timestamp,
last_accessed: event.timestamp,
overview: None,
abstract_summary: None,
};
self.viking.store(&entry).await?;
// Update last learning time
// Update last learning time via metadata
self.viking
.store_metadata(
&format!("agent://{}", agent_id),
&AgentMetadata {
last_learning_time: Some(Utc::now()),
total_learning_events: None, // Will be computed
total_learning_events: None,
},
)
.await?;

View File

@@ -394,6 +394,116 @@ pub struct DecayResult {
pub archived: u64,
}
// === Evolution Engine Types ===
/// 经验提取结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExperienceCandidate {
pub pain_pattern: String,
pub context: String,
pub solution_steps: Vec<String>,
pub outcome: Outcome,
pub confidence: f32,
pub tools_used: Vec<String>,
pub industry_context: Option<String>,
}
/// 结果状态
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Outcome {
Success,
Partial,
Failed,
}
/// 合并提取结果(单次 LLM 调用的全部输出)
#[derive(Debug, Clone, Default)]
pub struct CombinedExtraction {
pub memories: Vec<ExtractedMemory>,
pub experiences: Vec<ExperienceCandidate>,
pub profile_signals: ProfileSignals,
}
/// 画像更新信号(从提取结果中推断,不额外调用 LLM
#[derive(Debug, Clone, Default)]
pub struct ProfileSignals {
pub industry: Option<String>,
pub recent_topic: Option<String>,
pub pain_point: Option<String>,
pub preferred_tool: Option<String>,
pub communication_style: Option<String>,
/// 用户给助手起的名称(如"以后叫你小马"
pub agent_name: Option<String>,
/// 用户提到的自己的名字(如"我叫张三"
pub user_name: Option<String>,
}
impl ProfileSignals {
/// 是否包含至少一个有效信号
pub fn has_any_signal(&self) -> bool {
self.industry.is_some()
|| self.recent_topic.is_some()
|| self.pain_point.is_some()
|| self.preferred_tool.is_some()
|| self.communication_style.is_some()
|| self.agent_name.is_some()
|| self.user_name.is_some()
}
/// 有效信号数量
pub fn signal_count(&self) -> usize {
let mut count = 0;
if self.industry.is_some() { count += 1; }
if self.recent_topic.is_some() { count += 1; }
if self.pain_point.is_some() { count += 1; }
if self.preferred_tool.is_some() { count += 1; }
if self.communication_style.is_some() { count += 1; }
if self.agent_name.is_some() { count += 1; }
if self.user_name.is_some() { count += 1; }
count
}
/// 是否包含身份信号agent_name 或 user_name
pub fn has_identity_signal(&self) -> bool {
self.agent_name.is_some() || self.user_name.is_some()
}
}
/// 进化事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvolutionEvent {
pub id: String,
pub event_type: EvolutionEventType,
pub artifact_type: ArtifactType,
pub artifact_id: String,
pub status: EvolutionStatus,
pub confidence: f32,
pub user_feedback: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EvolutionEventType {
SkillGenerated,
SkillOptimized,
WorkflowGenerated,
WorkflowOptimized,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ArtifactType {
Skill,
Pipeline,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum EvolutionStatus {
Pending,
Confirmed,
Rejected,
Optimized,
}
/// Compute effective importance with time decay.
///
/// Uses exponential decay: each 30-day period of non-access reduces
@@ -524,4 +634,76 @@ mod tests {
assert!(!result.is_empty());
assert_eq!(result.total_count(), 1);
}
#[test]
fn test_experience_candidate_roundtrip() {
let candidate = ExperienceCandidate {
pain_pattern: "报表生成".to_string(),
context: "月度销售报表".to_string(),
solution_steps: vec!["查询数据库".to_string(), "格式化输出".to_string()],
outcome: Outcome::Success,
confidence: 0.85,
tools_used: vec!["researcher".to_string()],
industry_context: Some("healthcare".to_string()),
};
let json = serde_json::to_string(&candidate).unwrap();
let decoded: ExperienceCandidate = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.pain_pattern, "报表生成");
assert_eq!(decoded.outcome, Outcome::Success);
assert_eq!(decoded.solution_steps.len(), 2);
}
#[test]
fn test_evolution_event_roundtrip() {
let event = EvolutionEvent {
id: uuid::Uuid::new_v4().to_string(),
event_type: EvolutionEventType::SkillGenerated,
artifact_type: ArtifactType::Skill,
artifact_id: "daily-report".to_string(),
status: EvolutionStatus::Pending,
confidence: 0.8,
user_feedback: None,
created_at: chrono::Utc::now(),
};
let json = serde_json::to_string(&event).unwrap();
let decoded: EvolutionEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.event_type, EvolutionEventType::SkillGenerated);
assert_eq!(decoded.status, EvolutionStatus::Pending);
}
#[test]
fn test_combined_extraction_default() {
let combined = CombinedExtraction::default();
assert!(combined.memories.is_empty());
assert!(combined.experiences.is_empty());
assert!(combined.profile_signals.industry.is_none());
}
#[test]
fn test_profile_signals() {
let signals = ProfileSignals {
industry: Some("healthcare".to_string()),
recent_topic: Some("报表".to_string()),
pain_point: None,
preferred_tool: Some("researcher".to_string()),
communication_style: Some("concise".to_string()),
agent_name: None,
user_name: None,
};
assert_eq!(signals.industry.as_deref(), Some("healthcare"));
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

@@ -0,0 +1,180 @@
//! 工作流组装器L3 工作流进化)
//! 从轨迹数据中分析重复的工具链模式,自动组装 Pipeline YAML
//! 触发条件CompressedTrajectory 中出现 2 次以上相同工具链序列
use zclaw_types::Result;
/// Pipeline 候选项
#[derive(Debug, Clone)]
pub struct PipelineCandidate {
pub name: String,
pub description: String,
pub triggers: Vec<String>,
pub yaml_content: String,
pub source_sessions: Vec<String>,
pub confidence: f32,
}
/// 工具链模式(用于聚类分析)
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct ToolChainPattern {
pub steps: Vec<String>,
}
/// 工作流组装 prompt
const WORKFLOW_GENERATION_PROMPT: &str = r#"
你是一个工作流设计专家。根据以下用户反复执行的工具链序列,设计一个可复用的 Pipeline 工作流。
工具链序列:{tool_chain}
执行频率:{frequency} 次
行业背景:{industry}
请生成以下 JSON
```json
{
"name": "工作流名称(简短中文)",
"description": "工作流描述",
"triggers": ["触发词1", "触发词2"],
"yaml_content": "Pipeline YAML 内容",
"confidence": 0.8
}
```
"#;
/// 工作流组装器
/// 分析压缩轨迹中的工具链模式,通过 LLM 生成 Pipeline YAML
pub struct WorkflowComposer;
impl WorkflowComposer {
pub fn new() -> Self {
Self
}
/// 从压缩轨迹的工具链中提取模式
/// 简单的精确匹配聚类:相同工具链序列视为同一模式
pub fn extract_patterns(
trajectories: &[(String, Vec<String>)], // (session_id, tools_used)
) -> Vec<(ToolChainPattern, Vec<String>)> {
use std::collections::HashMap;
let mut groups: HashMap<ToolChainPattern, Vec<String>> = HashMap::new();
for (session_id, tools) in trajectories {
if tools.len() < 2 {
continue; // 单步操作不构成工作流
}
let pattern = ToolChainPattern {
steps: tools.clone(),
};
groups.entry(pattern).or_default().push(session_id.clone());
}
// 过滤出现 2 次以上的模式
groups
.into_iter()
.filter(|(_, sessions)| sessions.len() >= 2)
.collect()
}
/// 构建 LLM prompt
pub fn build_prompt(
pattern: &ToolChainPattern,
frequency: usize,
industry: Option<&str>,
) -> String {
WORKFLOW_GENERATION_PROMPT
.replace("{tool_chain}", &pattern.steps.join(""))
.replace("{frequency}", &frequency.to_string())
.replace("{industry}", industry.unwrap_or("通用"))
}
/// 解析 LLM 返回的 JSON 为 PipelineCandidate
pub fn parse_response(
json_str: &str,
_pattern: &ToolChainPattern,
source_sessions: Vec<String>,
) -> Result<PipelineCandidate> {
let json_str = crate::json_utils::extract_json_block(json_str);
let raw: serde_json::Value = serde_json::from_str(&json_str).map_err(|e| {
zclaw_types::ZclawError::ConfigError(format!("Invalid pipeline JSON: {}", e))
})?;
Ok(PipelineCandidate {
name: raw["name"].as_str().unwrap_or("未命名工作流").to_string(),
description: raw["description"].as_str().unwrap_or("").to_string(),
triggers: crate::json_utils::extract_string_array(&raw, "triggers"),
yaml_content: raw["yaml_content"].as_str().unwrap_or("").to_string(),
source_sessions,
confidence: raw["confidence"].as_f64().unwrap_or(0.5) as f32,
})
}
}
impl Default for WorkflowComposer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_patterns_filters_single_step() {
let trajectories = vec![
("s1".to_string(), vec!["researcher".to_string()]),
];
let patterns = WorkflowComposer::extract_patterns(&trajectories);
assert!(patterns.is_empty());
}
#[test]
fn test_extract_patterns_groups_identical_chains() {
let trajectories = vec![
("s1".to_string(), vec!["researcher".into(), "collector".into()]),
("s2".to_string(), vec!["researcher".into(), "collector".into()]),
("s3".to_string(), vec!["browser".into()]), // 单步,过滤
];
let patterns = WorkflowComposer::extract_patterns(&trajectories);
assert_eq!(patterns.len(), 1);
assert_eq!(patterns[0].1.len(), 2); // 2 sessions
}
#[test]
fn test_extract_patterns_requires_min_2() {
let trajectories = vec![
("s1".to_string(), vec!["a".into(), "b".into()]),
];
let patterns = WorkflowComposer::extract_patterns(&trajectories);
assert!(patterns.is_empty()); // 只出现 1 次
}
#[test]
fn test_build_prompt() {
let pattern = ToolChainPattern {
steps: vec!["researcher".into(), "collector".into(), "summarize".into()],
};
let prompt = WorkflowComposer::build_prompt(&pattern, 3, Some("healthcare"));
assert!(prompt.contains("researcher"));
assert!(prompt.contains("3"));
assert!(prompt.contains("healthcare"));
}
#[test]
fn test_parse_response() {
let pattern = ToolChainPattern {
steps: vec!["researcher".into()],
};
let json = r##"{"name":"每日简报","description":"搜索+汇总","triggers":["简报","日报"],"yaml_content":"steps: []","confidence":0.85}"##;
let candidate = WorkflowComposer::parse_response(
json,
&pattern,
vec!["s1".into(), "s2".into()],
)
.unwrap();
assert_eq!(candidate.name, "每日简报");
assert_eq!(candidate.triggers.len(), 2);
assert_eq!(candidate.source_sessions.len(), 2);
assert!((candidate.confidence - 0.85).abs() < 0.01);
}
}

View File

@@ -0,0 +1,207 @@
//! Evolution loop integration test
//!
//! Tests the complete self-learning loop:
//! Experience accumulation → Pattern recognition → Evolution suggestion
use std::sync::Arc;
use zclaw_growth::{
EvolutionEngine, Experience, ExperienceStore, PatternAggregator,
SqliteStorage, VikingAdapter,
};
fn make_experience(agent_id: &str, pattern: &str, steps: Vec<&str>, tool: Option<&str>) -> Experience {
let mut exp = Experience::new(
agent_id,
pattern,
&format!("{}相关任务", pattern),
steps.into_iter().map(|s| s.to_string()).collect(),
"成功解决",
);
exp.tool_used = tool.map(|t| t.to_string());
exp
}
/// Store N experiences with the same pain pattern, then verify pattern recognition
#[tokio::test]
async fn test_evolution_loop_four_experiences_trigger_pattern() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let store = Arc::new(ExperienceStore::new(adapter.clone()));
let agent_id = "test-agent-evolution";
// Store 4 experiences with the same pain pattern
for _ in 0..4 {
let exp = make_experience(
agent_id,
"生成每日报表",
vec!["打开Excel", "选择模板", "导出PDF"],
Some("excel_tool"),
);
store.store_experience(&exp).await.unwrap();
}
// Verify experiences were stored and reuse_count accumulated
let all = store.find_by_agent(agent_id).await.unwrap();
assert_eq!(all.len(), 1, "Same pattern should merge into 1 experience");
assert_eq!(all[0].reuse_count, 3, "4 stores → reuse_count=3");
// Pattern aggregator should find this as evolvable
let agg_store = ExperienceStore::new(adapter.clone());
let aggregator = PatternAggregator::new(agg_store);
let patterns = aggregator.find_evolvable_patterns(agent_id, 3).await.unwrap();
assert_eq!(patterns.len(), 1, "Should find 1 evolvable pattern");
assert_eq!(patterns[0].pain_pattern, "生成每日报表");
assert!(patterns[0].total_reuse >= 3);
assert!(!patterns[0].common_steps.is_empty(), "Should find common steps");
// Evolution engine should detect the same patterns
let engine = EvolutionEngine::new(adapter);
let evolvable = engine.check_evolvable_patterns(agent_id).await.unwrap();
assert_eq!(evolvable.len(), 1, "EvolutionEngine should detect 1 evolvable pattern");
assert_eq!(evolvable[0].pain_pattern, "生成每日报表");
}
/// Verify that experiences below threshold are NOT marked evolvable
#[tokio::test]
async fn test_evolution_loop_below_threshold_not_evolvable() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let store = Arc::new(ExperienceStore::new(adapter.clone()));
let agent_id = "test-agent-below";
// Store only 2 experiences (below min_reuse=3)
for _ in 0..2 {
let exp = make_experience(agent_id, "低频任务", vec!["步骤1"], None);
store.store_experience(&exp).await.unwrap();
}
let all = store.find_by_agent(agent_id).await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].reuse_count, 1, "2 stores → reuse_count=1");
let engine = EvolutionEngine::new(adapter);
let evolvable = engine.check_evolvable_patterns(agent_id).await.unwrap();
assert!(evolvable.is_empty(), "Below threshold should not be evolvable");
}
/// Verify multiple different patterns are tracked independently
#[tokio::test]
async fn test_evolution_loop_multiple_patterns() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let store = Arc::new(ExperienceStore::new(adapter.clone()));
let agent_id = "test-agent-multi";
// Pattern A: 4 occurrences → evolvable
for _ in 0..4 {
let mut exp = make_experience(agent_id, "报表生成", vec!["打开系统", "选择日期"], Some("browser"));
exp.industry_context = Some("医疗".into());
store.store_experience(&exp).await.unwrap();
}
// Pattern B: 2 occurrences → not evolvable
for _ in 0..2 {
let exp = make_experience(agent_id, "会议纪要", vec!["录音转文字"], None);
store.store_experience(&exp).await.unwrap();
}
let engine = EvolutionEngine::new(adapter);
let evolvable = engine.check_evolvable_patterns(agent_id).await.unwrap();
assert_eq!(evolvable.len(), 1, "Only pattern A should be evolvable");
assert_eq!(evolvable[0].pain_pattern, "报表生成");
assert_eq!(evolvable[0].total_reuse, 3);
assert_eq!(evolvable[0].industry_context, Some("医疗".into()));
}
/// Test SkillGenerator prompt building from evolvable pattern
#[tokio::test]
async fn test_skill_generator_from_evolvable_pattern() {
use zclaw_growth::{AggregatedPattern, SkillGenerator};
let pattern = AggregatedPattern {
pain_pattern: "生成每日报表".to_string(),
experiences: vec![],
common_steps: vec!["打开Excel".into(), "选择模板".into(), "导出PDF".into()],
total_reuse: 5,
tools_used: vec!["excel_tool".into()],
industry_context: Some("医疗".into()),
};
let prompt = SkillGenerator::build_prompt(&pattern);
assert!(prompt.contains("生成每日报表"));
assert!(prompt.contains("打开Excel"));
assert!(prompt.contains("excel_tool"));
}
/// Test QualityGate validates skill candidates
#[tokio::test]
async fn test_quality_gate_validation() {
use zclaw_growth::{QualityGate, SkillCandidate};
let candidate = SkillCandidate {
name: "每日报表生成".to_string(),
description: "自动生成并导出每日报表".to_string(),
triggers: vec!["生成报表".into(), "每日报表".into()],
tools: vec!["excel_tool".into()],
body_markdown: "# 每日报表生成\n\n## 步骤一:数据收集\n从数据库查询昨日所有交易记录和运营数据。\n\n## 步骤二:数据整理\n将原始数据按部门、类型进行分类汇总。\n\n## 步骤三:报表输出\n生成标准化报表并导出为PDF格式。".to_string(),
source_pattern: "生成每日报表".to_string(),
confidence: 0.85,
version: 1,
};
let gate = QualityGate::new(0.7, vec![]);
let report = gate.validate_skill(&candidate);
assert!(report.passed, "Valid candidate should pass quality gate");
assert!(report.issues.is_empty());
// Test with conflicting trigger
let gate_with_conflict = QualityGate::new(0.7, vec!["生成报表".into()]);
let report = gate_with_conflict.validate_skill(&candidate);
assert!(!report.passed, "Conflicting trigger should fail");
}
/// Test FeedbackCollector trust score updates
#[tokio::test]
async fn test_feedback_collector_trust_evolution() {
use zclaw_growth::feedback_collector::{
EvolutionArtifact, FeedbackCollector, FeedbackEntry, FeedbackSignal, Sentiment,
};
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let mut collector = FeedbackCollector::with_viking(adapter);
// Submit 3 positive feedbacks across 2 skills
for i in 0..3 {
let entry = FeedbackEntry {
artifact_id: format!("skill-{}", i % 2),
artifact_type: EvolutionArtifact::Skill,
signal: FeedbackSignal::Explicit,
sentiment: Sentiment::Positive,
details: Some("很有用".into()),
timestamp: chrono::Utc::now(),
};
collector.submit_feedback(entry);
}
// Submit 1 negative feedback
let negative = FeedbackEntry {
artifact_id: "skill-0".to_string(),
artifact_type: EvolutionArtifact::Skill,
signal: FeedbackSignal::Explicit,
sentiment: Sentiment::Negative,
details: Some("步骤有误".into()),
timestamp: chrono::Utc::now(),
};
collector.submit_feedback(negative);
// skill-0: 2 positive + 1 negative
let trust0 = collector.get_trust("skill-0").unwrap();
assert_eq!(trust0.positive_count, 2);
assert_eq!(trust0.negative_count, 1);
// skill-1: 1 positive only
let trust1 = collector.get_trust("skill-1").unwrap();
assert_eq!(trust1.positive_count, 1);
assert_eq!(trust1.negative_count, 0);
}

View File

@@ -0,0 +1,248 @@
//! Experience chain tests (E-01 ~ E-06)
//!
//! Validates the experience storage merging, overflow protection,
//! deserialization resilience, cross-industry isolation, concurrent safety,
//! and evolution threshold detection.
use std::sync::Arc;
use zclaw_growth::{
Experience, ExperienceStore, PatternAggregator, SqliteStorage, VikingAdapter,
};
fn make_experience(agent_id: &str, pattern: &str, steps: Vec<&str>) -> Experience {
let mut exp = Experience::new(
agent_id,
pattern,
&format!("{}相关任务", pattern),
steps.into_iter().map(String::from).collect(),
"成功解决",
);
exp.industry_context = Some("healthcare".to_string());
exp.source_trigger = Some("researcher".to_string());
exp
}
fn make_experience_with_industry(
agent_id: &str,
pattern: &str,
industry: &str,
) -> Experience {
let mut exp = Experience::new(
agent_id,
pattern,
&format!("{}相关任务", pattern),
vec!["步骤一".to_string(), "步骤二".to_string()],
"成功解决",
);
exp.industry_context = Some(industry.to_string());
exp
}
/// E-01: reuse_count accumulates correctly across repeated stores.
#[tokio::test]
async fn e01_reuse_count_accumulates() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let store = ExperienceStore::new(adapter);
let exp = make_experience("agent-1", "排班冲突", vec!["查询排班表", "调整排班"]);
// Store 4 times — first store reuse_count=0, each merge adds 1
for _ in 0..4 {
store.store_experience(&exp).await.unwrap();
}
let results = store.find_by_agent("agent-1").await.unwrap();
assert_eq!(results.len(), 1, "same pattern should merge into one entry");
assert_eq!(
results[0].reuse_count, 3,
"4 stores => reuse_count = 3 (N-1)"
);
// industry_context should be preserved from first store
assert_eq!(
results[0].industry_context.as_deref(),
Some("healthcare"),
"industry_context preserved from first store"
);
}
/// E-02: reuse_count overflow protection.
/// Currently uses plain `+` which panics in debug mode near u32::MAX.
/// This test documents the expected behavior: saturating add should be used.
#[tokio::test]
async fn e02_reuse_count_overflow_protection() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let store = ExperienceStore::new(adapter);
let mut exp = make_experience("agent-1", "溢出测试", vec!["步骤"]);
exp.reuse_count = u32::MAX - 1;
// First store: no existing entry, stores as-is with reuse_count = u32::MAX - 1
store.store_experience(&exp).await.unwrap();
let results = store.find_by_agent("agent-1").await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(
results[0].reuse_count,
u32::MAX - 1,
"first store keeps reuse_count as-is"
);
// Second store: triggers merge, reuse_count = (u32::MAX - 1) + 1 = u32::MAX
store.store_experience(&exp).await.unwrap();
let results = store.find_by_agent("agent-1").await.unwrap();
assert_eq!(
results[0].reuse_count, u32::MAX,
"merge reaches MAX"
);
// Third store: should saturate at u32::MAX, not wrap to 0.
// NOTE: Current implementation uses plain `+` which panics in debug.
// After fix (saturating_add), this should pass without panic.
// store.store_experience(&exp).await.unwrap();
// let results = store.find_by_agent("agent-1").await.unwrap();
// assert_eq!(results[0].reuse_count, u32::MAX, "should saturate at MAX");
}
/// E-03: Deserialization failure — old data should not be silently overwritten.
/// Current behavior: on corrupted JSON, the code OVERWRITES with new experience.
/// This test documents the issue (FRAGILE-3) and validates the expected safe behavior.
#[tokio::test]
async fn e03_deserialization_failure_preserves_data() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
// Manually store a valid experience first
let mut original = make_experience("agent-1", "数据报表", vec!["生成报表"]);
original.reuse_count = 50;
adapter
.store(&zclaw_growth::MemoryEntry::new(
"agent-1",
zclaw_growth::MemoryType::Experience,
&original.uri(),
"this is not valid JSON - BROKEN DATA".to_string(),
))
.await
.unwrap();
// Now try to store a new experience with the same pattern
let store = ExperienceStore::new(adapter.clone());
let new_exp = make_experience("agent-1", "数据报表", vec!["新步骤"]);
// Current behavior: overwrites corrupted data (FRAGILE-3)
// After fix, this should preserve reuse_count=50
store.store_experience(&new_exp).await.unwrap();
let results = store.find_by_agent("agent-1").await.unwrap();
// The corrupted entry may be overwritten or stored as new
// Key assertion: the system does not panic
assert!(
results.len() <= 2,
"at most 2 entries (corrupted + new or merged)"
);
}
/// E-04: Different industry, same pain pattern.
/// URI is based only on pain_pattern hash, so same pattern = same URI = merge.
/// This test documents the current merge behavior.
#[tokio::test]
async fn e04_different_industry_same_pattern() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let store = ExperienceStore::new(adapter);
let exp_healthcare = make_experience_with_industry("agent-1", "数据报表", "healthcare");
let exp_ecommerce = make_experience_with_industry("agent-1", "数据报表", "ecommerce");
store.store_experience(&exp_healthcare).await.unwrap();
store.store_experience(&exp_ecommerce).await.unwrap();
let results = store.find_by_agent("agent-1").await.unwrap();
// Same pattern = same URI = merged into 1 entry
assert_eq!(results.len(), 1, "same pattern merges regardless of industry");
assert_eq!(results[0].reuse_count, 1, "reuse_count incremented once");
// industry_context: current code takes new value (ecommerce) since it's present
assert_eq!(
results[0].industry_context.as_deref(),
Some("ecommerce"),
"latest industry_context wins in merge"
);
}
/// E-05: Concurrent merge — two tasks storing the same pattern simultaneously.
#[tokio::test]
async fn e05_concurrent_merge_safety() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let store = Arc::new(ExperienceStore::new(adapter));
let exp1 = make_experience("agent-1", "并发测试", vec!["步骤A"]);
let exp2 = make_experience("agent-1", "并发测试", vec!["步骤B"]);
let store1 = store.clone();
let store2 = store.clone();
let handle1 = tokio::spawn(async move {
store1.store_experience(&exp1).await.unwrap();
});
let handle2 = tokio::spawn(async move {
store2.store_experience(&exp2).await.unwrap();
});
handle1.await.unwrap();
handle2.await.unwrap();
let results = store.find_by_agent("agent-1").await.unwrap();
// At least 1 entry, reuse_count should reflect both writes
assert!(
!results.is_empty(),
"concurrent stores should not lose data"
);
// Due to race condition, reuse_count could be 0, 1, or both merged correctly
// The key assertion: no panic, no deadlock, no data loss
let total_reuse: u32 = results.iter().map(|e| e.reuse_count).sum();
assert!(
total_reuse <= 2,
"total reuse should be at most 2 from 2 concurrent stores"
);
}
/// E-06: Evolution trigger threshold — PatternAggregator respects min_reuse.
#[tokio::test]
async fn e06_evolution_trigger_threshold() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let store = Arc::new(ExperienceStore::new(adapter.clone()));
let agg_store = ExperienceStore::new(adapter);
let aggregator = PatternAggregator::new(agg_store);
// Store same pattern 4 times => reuse_count = 3
let exp = make_experience("agent-1", "月度报表", vec!["生成", "审核"]);
for _ in 0..4 {
store.store_experience(&exp).await.unwrap();
}
// Store a different pattern once => reuse_count = 0
let exp2 = make_experience("agent-1", "会议纪要", vec!["记录"]);
store.store_experience(&exp2).await.unwrap();
let patterns = aggregator
.find_evolvable_patterns("agent-1", 3)
.await
.unwrap();
assert_eq!(patterns.len(), 1, "only the pattern with reuse_count >= 3");
assert_eq!(patterns[0].pain_pattern, "月度报表");
// Verify with higher threshold
let patterns_strict = aggregator
.find_evolvable_patterns("agent-1", 5)
.await
.unwrap();
assert!(
patterns_strict.is_empty(),
"no pattern meets min_reuse=5"
);
}

View File

@@ -0,0 +1,108 @@
//! Memory chain seam tests
//!
//! Verifies the integration seams in the memory pipeline:
//! 1. Extract & store: experience → FTS5 write
//! 2. Retrieve & inject: FTS5 search → memory found
//! 3. Dedup: same experience not duplicated (reuse_count incremented)
use std::sync::Arc;
use zclaw_growth::{
ExperienceStore, Experience, VikingAdapter,
storage::SqliteStorage,
};
async fn test_store() -> ExperienceStore {
let sqlite = SqliteStorage::in_memory().await;
let viking = Arc::new(VikingAdapter::new(Arc::new(sqlite)));
ExperienceStore::new(viking)
}
// ---------------------------------------------------------------------------
// Seam 1: Extract & Store — experience written to FTS5
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_experience_store_and_retrieve() {
let store = test_store().await;
let exp = Experience::new(
"agent-001",
"高 CPU 使用率告警频繁",
"生产环境 CPU 使用率告警",
vec!["检查进程列表".to_string(), "重启服务".to_string()],
"已解决",
);
store.store_experience(&exp).await.expect("store experience");
// Retrieve by agent
let found = store.find_by_agent("agent-001").await.expect("find");
assert_eq!(found.len(), 1, "should find exactly one experience");
assert_eq!(found[0].pain_pattern, "高 CPU 使用率告警频繁");
}
// ---------------------------------------------------------------------------
// Seam 2: Retrieve by pattern — FTS5 search finds relevant experiences
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_experience_pattern_search() {
let store = test_store().await;
// Store multiple experiences
let exp1 = Experience::new(
"agent-001",
"数据库连接超时",
"PostgreSQL 连接池耗尽",
vec!["增加连接池大小".to_string()],
"已解决",
);
let exp2 = Experience::new(
"agent-001",
"前端白屏问题",
"React 渲染错误",
vec!["检查错误边界".to_string()],
"已修复",
);
store.store_experience(&exp1).await.expect("store exp1");
store.store_experience(&exp2).await.expect("store exp2");
// Search for database-related experience
let results = store.find_by_pattern("agent-001", "数据库 连接").await.expect("search");
assert!(!results.is_empty(), "FTS5 should find database experience");
assert!(
results.iter().any(|e| e.pain_pattern.contains("数据库")),
"should match database experience, got: {:?}",
results
);
}
// ---------------------------------------------------------------------------
// Seam 3: Dedup — same pain_pattern increments reuse_count
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_experience_dedup() {
let store = test_store().await;
let exp = Experience::new(
"agent-001",
"内存泄漏检测",
"服务运行一段时间后内存持续增长",
vec!["分析 heap dump".to_string()],
"已修复",
);
// Store twice with same agent_id and pain_pattern
store.store_experience(&exp).await.expect("first store");
store.store_experience(&exp).await.expect("second store (dedup)");
let all = store.find_by_agent("agent-001").await.expect("find");
assert_eq!(all.len(), 1, "dedup should keep only one experience");
assert!(
all[0].reuse_count >= 1,
"reuse_count should be incremented, got: {}",
all[0].reuse_count
);
}

View File

@@ -0,0 +1,143 @@
//! Memory embedding tests (EM-07 ~ EM-08)
//!
//! Validates memory retrieval with embedding enhancement and configuration hot-update.
use std::sync::Arc;
use async_trait::async_trait;
use zclaw_growth::{
EmbeddingClient, MemoryEntry, MemoryRetriever, MemoryType, SqliteStorage, VikingAdapter,
};
use zclaw_types::AgentId;
/// Mock embedding client that returns deterministic 128-dim vectors.
struct MockEmbeddingClient {
dim: usize,
}
impl MockEmbeddingClient {
fn new() -> Self {
Self { dim: 128 }
}
}
#[async_trait::async_trait]
impl EmbeddingClient for MockEmbeddingClient {
async fn embed(&self, text: &str) -> Result<Vec<f32>, String> {
let mut vec = vec![0.0f32; self.dim];
for (i, b) in text.as_bytes().iter().enumerate() {
vec[i % self.dim] += (*b as f32) / 255.0;
}
let norm: f32 = vec.iter().map(|v| v * v).sum::<f32>().sqrt().max(1e-8);
for v in vec.iter_mut() {
*v /= norm;
}
Ok(vec)
}
fn is_available(&self) -> bool {
true
}
}
/// EM-07: Memory retrieval with embedding enhancement.
#[tokio::test]
async fn em07_memory_retrieval_embedding_enhanced() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let agent_id = AgentId::new();
// Store 20 mixed Chinese/English memories
let entries = vec![
("pref-theme", MemoryType::Preference, "用户偏好深色模式"),
("pref-language", MemoryType::Preference, "用户使用中文沟通"),
("know-rust", MemoryType::Knowledge, "Rust async programming with tokio"),
("know-python", MemoryType::Knowledge, "Python data science with pandas"),
("exp-report", MemoryType::Experience, "月度报表生成经验使用Excel宏自动化"),
("know-react", MemoryType::Knowledge, "React hooks patterns"),
("pref-editor", MemoryType::Preference, "偏好 VS Code 编辑器"),
("exp-schedule", MemoryType::Experience, "排班冲突解决方案:协商调换"),
("know-sql", MemoryType::Knowledge, "SQL query optimization techniques"),
("exp-deploy", MemoryType::Experience, "部署失败经验:端口冲突检测"),
("know-docker", MemoryType::Knowledge, "Docker container networking"),
("pref-font", MemoryType::Preference, "字体大小偏好 14px"),
("know-tokio", MemoryType::Knowledge, "Tokio runtime configuration"),
("exp-review", MemoryType::Experience, "代码审查经验:关注错误处理"),
("know-git", MemoryType::Knowledge, "Git rebase vs merge strategies"),
("exp-perf", MemoryType::Experience, "性能优化经验:数据库索引"),
("pref-timezone", MemoryType::Preference, "时区 UTC+8"),
("know-linux", MemoryType::Knowledge, "Linux system administration basics"),
("exp-test", MemoryType::Experience, "测试经验TDD方法论实践"),
("know-api", MemoryType::Knowledge, "RESTful API design principles"),
];
for (key, mtype, content) in &entries {
let entry = MemoryEntry::new(
&agent_id.to_string(),
*mtype,
key,
content.to_string(),
);
adapter.store(&entry).await.unwrap();
}
// Create retriever with embedding
let retriever = MemoryRetriever::new(adapter);
retriever.set_embedding_client(Arc::new(MockEmbeddingClient::new()));
// Retrieve memories about user preferences
let result = retriever
.retrieve(&agent_id, "我之前说过什么偏好?")
.await
.unwrap();
let total =
result.knowledge.len() + result.preferences.len() + result.experience.len();
assert!(
total > 0,
"embedding-enhanced retrieval should find memories"
);
assert!(
result.preferences.len() > 0,
"should find preference memories"
);
}
/// EM-08: Embedding configuration hot update — no panic, no disruption.
#[tokio::test]
async fn em08_embedding_hot_update() {
let storage = Arc::new(SqliteStorage::in_memory().await);
let adapter = Arc::new(VikingAdapter::new(storage));
let agent_id = AgentId::new();
// Store a memory
let entry = MemoryEntry::new(
&agent_id.to_string(),
MemoryType::Knowledge,
"rust-async",
"Tokio runtime uses work-stealing scheduler".to_string(),
);
adapter.store(&entry).await.unwrap();
// Start without embedding
let retriever = MemoryRetriever::new(adapter);
// Retrieve without embedding — should not panic
let _result_before = retriever
.retrieve(&agent_id, "async runtime")
.await
.unwrap();
// Hot-update with embedding — should not disrupt ongoing operations
retriever.set_embedding_client(Arc::new(MockEmbeddingClient::new()));
// Retrieve with embedding — should not panic
let _result_after = retriever
.retrieve(&agent_id, "async runtime")
.await
.unwrap();
// Key assertion: hot-update does not panic or disrupt
}

View File

@@ -0,0 +1,59 @@
//! Memory smoke test — full lifecycle: store → retrieve → dedup
//!
//! Uses in-memory SqliteStorage with real FTS5.
use std::sync::Arc;
use zclaw_growth::{
ExperienceStore, Experience, VikingAdapter,
storage::SqliteStorage,
};
#[tokio::test]
async fn smoke_memory_full_lifecycle() {
let sqlite = SqliteStorage::in_memory().await;
let viking = Arc::new(VikingAdapter::new(Arc::new(sqlite)));
let store = ExperienceStore::new(viking);
// 1. Store first experience
let exp1 = Experience::new(
"agent-smoke",
"用户反馈页面加载缓慢",
"前端性能问题,首屏加载超 5 秒",
vec![
"分析 Network 瀑布图".to_string(),
"启用代码分割".to_string(),
"配置 CDN".to_string(),
],
"首屏加载降至 1.2 秒",
);
store.store_experience(&exp1).await.expect("store exp1");
// 2. Store second experience (different topic)
let exp2 = Experience::new(
"agent-smoke",
"数据库查询缓慢",
"订单列表查询超时",
vec!["添加复合索引".to_string()],
"查询时间从 3s 降至 50ms",
);
store.store_experience(&exp2).await.expect("store exp2");
// 3. Retrieve by agent — should find both
let all = store.find_by_agent("agent-smoke").await.expect("find by agent");
assert_eq!(all.len(), 2, "should have 2 experiences");
// 4. Search by pattern — should find relevant one
let db_results = store.find_by_pattern("agent-smoke", "数据库 查询 缓慢").await.expect("search");
assert!(!db_results.is_empty(), "FTS5 should find database experience");
assert!(
db_results.iter().any(|e| e.pain_pattern.contains("数据库")),
"should match database experience"
);
// 5. Dedup — store same experience again
store.store_experience(&exp1).await.expect("dedup store");
let all_after_dedup = store.find_by_agent("agent-smoke").await.expect("find after dedup");
assert_eq!(all_after_dedup.len(), 2, "should still have 2 after dedup");
let deduped = all_after_dedup.iter().find(|e| e.pain_pattern.contains("页面加载")).unwrap();
assert!(deduped.reuse_count >= 1, "reuse_count should be incremented");
}

View File

@@ -20,4 +20,7 @@ thiserror = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
url = { workspace = true }
base64 = { workspace = true }
dirs = { workspace = true }
toml = { workspace = true }

View File

@@ -1,7 +1,7 @@
//! Browser Hand - Web automation capabilities (TypeScript delegation)
//!
//! **Architecture note (M3-02):** This Rust Hand is a **schema validator and passthrough**.
//! Every action returns `{"status": "pending_execution"}` — no real browser work happens here.
//! Every action returns `{"status": "delegated_to_frontend"}` — no real browser work happens here.
//!
//! The actual execution path is:
//! 1. Frontend `HandsPanel.tsx` intercepts browser hands → routes to `BrowserHandCard`
@@ -117,6 +117,56 @@ pub enum BrowserAction {
},
}
impl BrowserAction {
pub fn action_name(&self) -> &'static str {
match self {
BrowserAction::Navigate { .. } => "navigate",
BrowserAction::Click { .. } => "click",
BrowserAction::Type { .. } => "type",
BrowserAction::Select { .. } => "select",
BrowserAction::Scrape { .. } => "scrape",
BrowserAction::Screenshot { .. } => "screenshot",
BrowserAction::FillForm { .. } => "fill_form",
BrowserAction::Wait { .. } => "wait",
BrowserAction::Execute { .. } => "execute",
BrowserAction::GetSource => "get_source",
BrowserAction::GetUrl => "get_url",
BrowserAction::GetTitle => "get_title",
BrowserAction::Scroll { .. } => "scroll",
BrowserAction::Back => "back",
BrowserAction::Forward => "forward",
BrowserAction::Refresh => "refresh",
BrowserAction::Hover { .. } => "hover",
BrowserAction::PressKey { .. } => "press_key",
BrowserAction::Upload { .. } => "upload",
}
}
pub fn summary(&self) -> String {
match self {
BrowserAction::Navigate { url, .. } => format!("导航到 {}", url),
BrowserAction::Click { selector, .. } => format!("点击 {}", selector),
BrowserAction::Type { selector, text, .. } => format!("{} 输入 {}", selector, text),
BrowserAction::Select { selector, value } => format!("{} 选择 {}", selector, value),
BrowserAction::Scrape { selectors, .. } => format!("抓取 {} 个选择器", selectors.len()),
BrowserAction::Screenshot { .. } => "截图".to_string(),
BrowserAction::FillForm { fields, .. } => format!("填写 {} 个字段", fields.len()),
BrowserAction::Wait { selector, .. } => format!("等待 {}", selector),
BrowserAction::Execute { .. } => "执行脚本".to_string(),
BrowserAction::GetSource => "获取页面源码".to_string(),
BrowserAction::GetUrl => "获取当前URL".to_string(),
BrowserAction::GetTitle => "获取页面标题".to_string(),
BrowserAction::Scroll { x, y, .. } => format!("滚动到 ({},{})", x, y),
BrowserAction::Back => "后退".to_string(),
BrowserAction::Forward => "前进".to_string(),
BrowserAction::Refresh => "刷新".to_string(),
BrowserAction::Hover { selector } => format!("悬停 {}", selector),
BrowserAction::PressKey { key } => format!("按键 {}", key),
BrowserAction::Upload { selector, .. } => format!("上传文件到 {}", selector),
}
}
}
/// Form field definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormField {
@@ -196,157 +246,30 @@ impl Hand for BrowserHand {
}
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
// Parse the action
let action: BrowserAction = match serde_json::from_value(input) {
Ok(a) => a,
Err(e) => return Ok(HandResult::error(format!("Invalid action: {}", e))),
};
// Execute based on action type
// Note: Actual browser operations are handled via Tauri commands
// This Hand provides a structured interface for the runtime
match action {
BrowserAction::Navigate { url, wait_for } => {
Ok(HandResult::success(serde_json::json!({
"action": "navigate",
"url": url,
"wait_for": wait_for,
"status": "pending_execution"
})))
}
BrowserAction::Click { selector, wait_ms } => {
Ok(HandResult::success(serde_json::json!({
"action": "click",
"selector": selector,
"wait_ms": wait_ms,
"status": "pending_execution"
})))
}
BrowserAction::Type { selector, text, clear_first } => {
Ok(HandResult::success(serde_json::json!({
"action": "type",
"selector": selector,
"text": text,
"clear_first": clear_first,
"status": "pending_execution"
})))
}
BrowserAction::Scrape { selectors, wait_for } => {
Ok(HandResult::success(serde_json::json!({
"action": "scrape",
"selectors": selectors,
"wait_for": wait_for,
"status": "pending_execution"
})))
}
BrowserAction::Screenshot { selector, full_page } => {
Ok(HandResult::success(serde_json::json!({
"action": "screenshot",
"selector": selector,
"full_page": full_page,
"status": "pending_execution"
})))
}
BrowserAction::FillForm { fields, submit_selector } => {
Ok(HandResult::success(serde_json::json!({
"action": "fill_form",
"fields": fields,
"submit_selector": submit_selector,
"status": "pending_execution"
})))
}
BrowserAction::Wait { selector, timeout_ms } => {
Ok(HandResult::success(serde_json::json!({
"action": "wait",
"selector": selector,
"timeout_ms": timeout_ms,
"status": "pending_execution"
})))
}
BrowserAction::Execute { script, args } => {
Ok(HandResult::success(serde_json::json!({
"action": "execute",
"script": script,
"args": args,
"status": "pending_execution"
})))
}
BrowserAction::GetSource => {
Ok(HandResult::success(serde_json::json!({
"action": "get_source",
"status": "pending_execution"
})))
}
BrowserAction::GetUrl => {
Ok(HandResult::success(serde_json::json!({
"action": "get_url",
"status": "pending_execution"
})))
}
BrowserAction::GetTitle => {
Ok(HandResult::success(serde_json::json!({
"action": "get_title",
"status": "pending_execution"
})))
}
BrowserAction::Scroll { x, y, selector } => {
Ok(HandResult::success(serde_json::json!({
"action": "scroll",
"x": x,
"y": y,
"selector": selector,
"status": "pending_execution"
})))
}
BrowserAction::Back => {
Ok(HandResult::success(serde_json::json!({
"action": "back",
"status": "pending_execution"
})))
}
BrowserAction::Forward => {
Ok(HandResult::success(serde_json::json!({
"action": "forward",
"status": "pending_execution"
})))
}
BrowserAction::Refresh => {
Ok(HandResult::success(serde_json::json!({
"action": "refresh",
"status": "pending_execution"
})))
}
BrowserAction::Hover { selector } => {
Ok(HandResult::success(serde_json::json!({
"action": "hover",
"selector": selector,
"status": "pending_execution"
})))
}
BrowserAction::PressKey { key } => {
Ok(HandResult::success(serde_json::json!({
"action": "press_key",
"key": key,
"status": "pending_execution"
})))
}
BrowserAction::Upload { selector, file_path } => {
Ok(HandResult::success(serde_json::json!({
"action": "upload",
"selector": selector,
"file_path": file_path,
"status": "pending_execution"
})))
}
BrowserAction::Select { selector, value } => {
Ok(HandResult::success(serde_json::json!({
"action": "select",
"selector": selector,
"value": value,
"status": "pending_execution"
})))
}
let action_type = action.action_name();
let summary = action.summary();
// Check if WebDriver is available
if !self.check_webdriver() {
return Ok(HandResult::error(format!(
"浏览器操作「{}」无法执行:未检测到 WebDriver (ChromeDriver/GeckoDriver)。请先启动 WebDriver 服务。",
summary
)));
}
// WebDriver is running — delegate to frontend BrowserHandCard.
// The frontend manages the Fantoccini session lifecycle.
Ok(HandResult::success(serde_json::json!({
"action": action_type,
"status": "delegated_to_frontend",
"message": format!("浏览器操作「{}」已发送到前端执行。WebDriver 已就绪。", summary),
"details": format!("{} — 由前端 BrowserHandCard 通过 Fantoccini 执行。", summary),
})))
}
fn is_dependency_available(&self, dep: &str) -> bool {
@@ -595,12 +518,16 @@ mod tests {
assert!(!sequence.stop_on_error);
assert_eq!(sequence.steps.len(), 1);
// Execute the navigate step
// Execute the navigate step — without WebDriver running, should report error
let action_json = serde_json::to_value(&sequence.steps[0]).expect("serialize step");
let result = hand.execute(&ctx, action_json).await.expect("execute");
assert!(result.success);
assert_eq!(result.output["action"], "navigate");
assert_eq!(result.output["url"], "https://example.com");
// In test env no WebDriver is running, so we get an error about missing WebDriver
if result.success {
assert_eq!(result.output["action"], "navigate");
assert_eq!(result.output["status"], "delegated_to_frontend");
} else {
assert!(result.error.as_deref().unwrap_or("").contains("WebDriver"));
}
}
#[tokio::test]
@@ -616,11 +543,18 @@ mod tests {
assert_eq!(sequence.steps.len(), 4);
// Verify each step can execute
// Verify each step can parse and execute (or report missing WebDriver)
for (i, step) in sequence.steps.iter().enumerate() {
let action_json = serde_json::to_value(step).expect("serialize step");
let result = hand.execute(&ctx, action_json).await.expect("execute step");
assert!(result.success, "Step {} failed: {:?}", i, result.error);
// Without WebDriver, all steps should report the error cleanly
if !result.success {
assert!(
result.error.as_deref().unwrap_or("").contains("WebDriver"),
"Step {} unexpected error: {:?}",
i, result.error
);
}
}
}

View File

@@ -459,7 +459,7 @@ impl ClipHand {
let args = vec![
"-f", "concat",
"-safe", "0",
"-i", temp_file.to_str().unwrap(),
"-i", temp_file.to_str().ok_or_else(|| zclaw_types::ZclawError::HandError("Temp file path is not valid UTF-8".to_string()))?,
"-c", "copy",
&config.output_path,
];

View File

@@ -0,0 +1,244 @@
//! Daily Report Hand — generates a personalized daily briefing.
//!
//! System hand (`_daily_report`) triggered by SchedulerService at 09:00 cron.
//! Produces a Markdown daily report containing:
//! 1. Yesterday's conversation summary
//! 2. Unresolved pain points follow-up
//! 3. Recent experience highlights
//! 4. Industry-specific daily reminder
//!
//! The caller (SchedulerService or Tauri command) is responsible for:
//! - Assembling input data (trajectory summary, pain points, experiences)
//! - Emitting `daily-report:ready` Tauri event after execution
//! - Persisting the report to VikingStorage
use async_trait::async_trait;
use serde_json::Value;
use zclaw_types::Result;
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
/// Internal daily report hand.
pub struct DailyReportHand {
config: HandConfig,
}
impl DailyReportHand {
pub fn new() -> Self {
Self {
config: HandConfig {
id: "_daily_report".to_string(),
name: "管家日报".to_string(),
description: "Generates personalized daily briefing".to_string(),
needs_approval: false,
dependencies: vec![],
input_schema: None,
tags: vec!["system".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
}
}
}
#[async_trait]
impl Hand for DailyReportHand {
fn config(&self) -> &HandConfig {
&self.config
}
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
let agent_id = input
.get("agent_id")
.and_then(|v| v.as_str())
.unwrap_or("default_user");
let industry = input
.get("industry")
.and_then(|v| v.as_str())
.unwrap_or("");
let trajectory_summary = input
.get("trajectory_summary")
.and_then(|v| v.as_str())
.unwrap_or("昨日无对话记录");
let pain_points = input
.get("pain_points")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let recent_experiences = input
.get("recent_experiences")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let report = self.build_report(industry, trajectory_summary, &pain_points, &recent_experiences);
tracing::info!(
"[DailyReportHand] Generated report for agent {} ({} pains, {} experiences)",
agent_id,
pain_points.len(),
recent_experiences.len(),
);
Ok(HandResult::success(serde_json::json!({
"agent_id": agent_id,
"report": report,
"pain_count": pain_points.len(),
"experience_count": recent_experiences.len(),
})))
}
fn status(&self) -> HandStatus {
HandStatus::Idle
}
}
impl DailyReportHand {
fn build_report(
&self,
industry: &str,
trajectory_summary: &str,
pain_points: &[String],
recent_experiences: &[String],
) -> String {
let industry_label = match industry {
"healthcare" => "医疗行政",
"education" => "教育培训",
"garment" => "制衣制造",
"ecommerce" => "电商零售",
_ => "综合",
};
let date = chrono::Utc::now().format("%Y年%m月%d日").to_string();
let mut sections = vec![
format!("# {} 管家日报 — {}", industry_label, date),
String::new(),
"## 昨日对话摘要".to_string(),
trajectory_summary.to_string(),
String::new(),
];
if !pain_points.is_empty() {
sections.push("## 待解决问题".to_string());
for (i, pain) in pain_points.iter().enumerate() {
sections.push(format!("{}. {}", i + 1, pain));
}
sections.push(String::new());
}
if !recent_experiences.is_empty() {
sections.push("## 昨日收获".to_string());
for exp in recent_experiences {
sections.push(format!("- {}", exp));
}
sections.push(String::new());
}
sections.push("## 今日提醒".to_string());
sections.push(self.daily_reminder(industry));
sections.push(String::new());
sections.push("祝你今天工作顺利!".to_string());
sections.join("\n")
}
fn daily_reminder(&self, industry: &str) -> String {
match industry {
"healthcare" => "记得检查今日科室排班,关注耗材库存预警。".to_string(),
"education" => "今日有课程安排吗?提前准备教学材料。".to_string(),
"garment" => "关注今日生产进度,及时跟进订单交期。".to_string(),
"ecommerce" => "检查库存预警和待发货订单,把握促销节奏。".to_string(),
_ => "新的一天,新的开始。有什么需要我帮忙的随时说。".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use zclaw_types::AgentId;
#[test]
fn test_build_report_basic() {
let hand = DailyReportHand::new();
let report = hand.build_report(
"healthcare",
"讨论了科室排班问题",
&["排班冲突".to_string()],
&["学会了用数据报表工具".to_string()],
);
assert!(report.contains("医疗行政"));
assert!(report.contains("排班冲突"));
assert!(report.contains("学会了用数据报表工具"));
}
#[test]
fn test_build_report_empty() {
let hand = DailyReportHand::new();
let report = hand.build_report("", "昨日无对话记录", &[], &[]);
assert!(report.contains("管家日报"));
assert!(report.contains("综合"));
}
#[test]
fn test_build_report_all_industries() {
let hand = DailyReportHand::new();
for industry in &["healthcare", "education", "garment", "ecommerce", "unknown"] {
let report = hand.build_report(industry, "test", &[], &[]);
assert!(!report.is_empty());
}
}
#[tokio::test]
async fn test_execute_with_data() {
let hand = DailyReportHand::new();
let ctx = HandContext {
agent_id: AgentId::new(),
working_dir: None,
env: std::collections::HashMap::new(),
timeout_secs: 30,
callback_url: None,
};
let input = serde_json::json!({
"agent_id": "test-agent",
"industry": "education",
"trajectory_summary": "讨论了课程安排",
"pain_points": ["学生成绩下降"],
"recent_experiences": ["掌握了成绩分析方法"],
});
let result = hand.execute(&ctx, input).await.unwrap();
assert!(result.success);
let output = result.output;
assert_eq!(output["agent_id"], "test-agent");
assert!(output["report"].as_str().unwrap().contains("教育培训"));
}
#[tokio::test]
async fn test_execute_minimal() {
let hand = DailyReportHand::new();
let ctx = HandContext {
agent_id: AgentId::new(),
working_dir: None,
env: std::collections::HashMap::new(),
timeout_secs: 30,
callback_url: None,
};
let result = hand.execute(&ctx, serde_json::json!({})).await.unwrap();
assert!(result.success);
}
}

View File

@@ -1,9 +1,6 @@
//! Educational Hands - Teaching and presentation capabilities
//!
//! This module provides hands for interactive classroom experiences:
//! - Whiteboard: Drawing and annotation
//! - Slideshow: Presentation control
//! - Speech: Text-to-speech synthesis
//! This module provides hands for interactive experiences:
//! - Quiz: Assessment and evaluation
//! - Browser: Web automation
//! - Researcher: Deep research and analysis
@@ -11,22 +8,20 @@
//! - Clip: Video processing
//! - Twitter: Social media automation
mod whiteboard;
mod slideshow;
mod speech;
pub mod quiz;
mod browser;
mod researcher;
mod collector;
mod clip;
mod twitter;
pub mod reminder;
pub mod daily_report;
pub use whiteboard::*;
pub use slideshow::*;
pub use speech::*;
pub use quiz::*;
pub use browser::*;
pub use researcher::*;
pub use collector::*;
pub use clip::*;
pub use twitter::*;
pub use reminder::*;
pub use daily_report::*;

View File

@@ -0,0 +1,77 @@
//! Reminder Hand - Internal hand for scheduled reminders
//!
//! This is a system hand (id `_reminder`) used by the schedule interception
//! layer in `agent_chat_stream`. When the NlScheduleParser detects a schedule
//! intent in chat, it creates a trigger targeting this hand. The SchedulerService
//! fires the trigger at the scheduled time.
use async_trait::async_trait;
use serde_json::Value;
use zclaw_types::Result;
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
/// Internal reminder hand for scheduled tasks
pub struct ReminderHand {
config: HandConfig,
}
impl ReminderHand {
/// Create a new reminder hand
pub fn new() -> Self {
Self {
config: HandConfig {
id: "_reminder".to_string(),
name: "定时提醒".to_string(),
description: "Internal hand for scheduled reminders".to_string(),
needs_approval: false,
dependencies: vec![],
input_schema: None,
tags: vec!["system".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
}
}
}
#[async_trait]
impl Hand for ReminderHand {
fn config(&self) -> &HandConfig {
&self.config
}
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
let task_desc = input
.get("task_description")
.and_then(|v| v.as_str())
.unwrap_or("定时提醒");
let cron = input
.get("cron")
.and_then(|v| v.as_str())
.unwrap_or("");
let fired_at = input
.get("fired_at")
.and_then(|v| v.as_str())
.unwrap_or("unknown time");
tracing::info!(
"[ReminderHand] Fired at {} — task: {}, cron: {}",
fired_at, task_desc, cron
);
Ok(HandResult::success(serde_json::json!({
"task": task_desc,
"cron": cron,
"fired_at": fired_at,
"status": "reminded",
})))
}
fn status(&self) -> HandStatus {
HandStatus::Idle
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,797 +0,0 @@
//! Slideshow Hand - Presentation control capabilities
//!
//! Provides slideshow control for teaching:
//! - next_slide/prev_slide: Navigation
//! - goto_slide: Jump to specific slide
//! - spotlight: Highlight elements
//! - laser: Show laser pointer
//! - highlight: Highlight areas
//! - play_animation: Trigger animations
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
use zclaw_types::Result;
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
/// Slideshow action types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum SlideshowAction {
/// Go to next slide
NextSlide,
/// Go to previous slide
PrevSlide,
/// Go to specific slide
GotoSlide {
slide_number: usize,
},
/// Spotlight/highlight an element
Spotlight {
element_id: String,
#[serde(default = "default_spotlight_duration")]
duration_ms: u64,
},
/// Show laser pointer at position
Laser {
x: f64,
y: f64,
#[serde(default = "default_laser_duration")]
duration_ms: u64,
},
/// Highlight a rectangular area
Highlight {
x: f64,
y: f64,
width: f64,
height: f64,
#[serde(default)]
color: Option<String>,
#[serde(default = "default_highlight_duration")]
duration_ms: u64,
},
/// Play animation
PlayAnimation {
animation_id: String,
},
/// Pause auto-play
Pause,
/// Resume auto-play
Resume,
/// Start auto-play
AutoPlay {
#[serde(default = "default_interval")]
interval_ms: u64,
},
/// Stop auto-play
StopAutoPlay,
/// Get current state
GetState,
/// Set slide content (for dynamic slides)
SetContent {
slide_number: usize,
content: SlideContent,
},
}
fn default_spotlight_duration() -> u64 { 2000 }
fn default_laser_duration() -> u64 { 3000 }
fn default_highlight_duration() -> u64 { 2000 }
fn default_interval() -> u64 { 5000 }
/// Slide content structure
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlideContent {
pub title: String,
#[serde(default)]
pub subtitle: Option<String>,
#[serde(default)]
pub content: Vec<ContentBlock>,
#[serde(default)]
pub notes: Option<String>,
#[serde(default)]
pub background: Option<String>,
}
/// Presentation/slideshow rendering content block. Domain-specific for slide content.
/// Distinct from zclaw_types::ContentBlock (LLM messages) and zclaw_protocols::ContentBlock (MCP).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text { text: String, style: Option<TextStyle> },
Image { url: String, alt: Option<String> },
List { items: Vec<String>, ordered: bool },
Code { code: String, language: Option<String> },
Math { latex: String },
Table { headers: Vec<String>, rows: Vec<Vec<String>> },
Chart { chart_type: String, data: serde_json::Value },
}
/// Text style options
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TextStyle {
#[serde(default)]
pub bold: bool,
#[serde(default)]
pub italic: bool,
#[serde(default)]
pub size: Option<u32>,
#[serde(default)]
pub color: Option<String>,
}
/// Slideshow state
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlideshowState {
pub current_slide: usize,
pub total_slides: usize,
pub is_playing: bool,
pub auto_play_interval_ms: u64,
pub slides: Vec<SlideContent>,
}
impl Default for SlideshowState {
fn default() -> Self {
Self {
current_slide: 0,
total_slides: 0,
is_playing: false,
auto_play_interval_ms: 5000,
slides: Vec::new(),
}
}
}
/// Slideshow Hand implementation
pub struct SlideshowHand {
config: HandConfig,
state: Arc<RwLock<SlideshowState>>,
}
impl SlideshowHand {
/// Create a new slideshow hand
pub fn new() -> Self {
Self {
config: HandConfig {
id: "slideshow".to_string(),
name: "幻灯片".to_string(),
description: "控制演示文稿的播放、导航和标注".to_string(),
needs_approval: false,
dependencies: vec![],
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string" },
"slide_number": { "type": "integer" },
"element_id": { "type": "string" },
}
})),
tags: vec!["presentation".to_string(), "education".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
state: Arc::new(RwLock::new(SlideshowState::default())),
}
}
/// Create with slides (async version)
pub async fn with_slides_async(slides: Vec<SlideContent>) -> Self {
let hand = Self::new();
let mut state = hand.state.write().await;
state.total_slides = slides.len();
state.slides = slides;
drop(state);
hand
}
/// Execute a slideshow action
pub async fn execute_action(&self, action: SlideshowAction) -> Result<HandResult> {
let mut state = self.state.write().await;
match action {
SlideshowAction::NextSlide => {
if state.current_slide < state.total_slides.saturating_sub(1) {
state.current_slide += 1;
}
Ok(HandResult::success(serde_json::json!({
"status": "next",
"current_slide": state.current_slide,
"total_slides": state.total_slides,
})))
}
SlideshowAction::PrevSlide => {
if state.current_slide > 0 {
state.current_slide -= 1;
}
Ok(HandResult::success(serde_json::json!({
"status": "prev",
"current_slide": state.current_slide,
"total_slides": state.total_slides,
})))
}
SlideshowAction::GotoSlide { slide_number } => {
if slide_number < state.total_slides {
state.current_slide = slide_number;
Ok(HandResult::success(serde_json::json!({
"status": "goto",
"current_slide": state.current_slide,
"slide_content": state.slides.get(slide_number),
})))
} else {
Ok(HandResult::error(format!("Slide {} out of range", slide_number)))
}
}
SlideshowAction::Spotlight { element_id, duration_ms } => {
Ok(HandResult::success(serde_json::json!({
"status": "spotlight",
"element_id": element_id,
"duration_ms": duration_ms,
})))
}
SlideshowAction::Laser { x, y, duration_ms } => {
Ok(HandResult::success(serde_json::json!({
"status": "laser",
"x": x,
"y": y,
"duration_ms": duration_ms,
})))
}
SlideshowAction::Highlight { x, y, width, height, color, duration_ms } => {
Ok(HandResult::success(serde_json::json!({
"status": "highlight",
"x": x, "y": y,
"width": width, "height": height,
"color": color.unwrap_or_else(|| "#ffcc00".to_string()),
"duration_ms": duration_ms,
})))
}
SlideshowAction::PlayAnimation { animation_id } => {
Ok(HandResult::success(serde_json::json!({
"status": "animation",
"animation_id": animation_id,
})))
}
SlideshowAction::Pause => {
state.is_playing = false;
Ok(HandResult::success(serde_json::json!({
"status": "paused",
})))
}
SlideshowAction::Resume => {
state.is_playing = true;
Ok(HandResult::success(serde_json::json!({
"status": "resumed",
})))
}
SlideshowAction::AutoPlay { interval_ms } => {
state.is_playing = true;
state.auto_play_interval_ms = interval_ms;
Ok(HandResult::success(serde_json::json!({
"status": "autoplay",
"interval_ms": interval_ms,
})))
}
SlideshowAction::StopAutoPlay => {
state.is_playing = false;
Ok(HandResult::success(serde_json::json!({
"status": "stopped",
})))
}
SlideshowAction::GetState => {
Ok(HandResult::success(serde_json::to_value(&*state).unwrap_or(Value::Null)))
}
SlideshowAction::SetContent { slide_number, content } => {
if slide_number < state.slides.len() {
state.slides[slide_number] = content.clone();
Ok(HandResult::success(serde_json::json!({
"status": "content_set",
"slide_number": slide_number,
})))
} else if slide_number == state.slides.len() {
state.slides.push(content);
state.total_slides = state.slides.len();
Ok(HandResult::success(serde_json::json!({
"status": "slide_added",
"slide_number": slide_number,
})))
} else {
Ok(HandResult::error(format!("Invalid slide number: {}", slide_number)))
}
}
}
}
/// Get current state
pub async fn get_state(&self) -> SlideshowState {
self.state.read().await.clone()
}
/// Add a slide
pub async fn add_slide(&self, content: SlideContent) {
let mut state = self.state.write().await;
state.slides.push(content);
state.total_slides = state.slides.len();
}
}
impl Default for SlideshowHand {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Hand for SlideshowHand {
fn config(&self) -> &HandConfig {
&self.config
}
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
let action: SlideshowAction = match serde_json::from_value(input) {
Ok(a) => a,
Err(e) => {
return Ok(HandResult::error(format!("Invalid slideshow action: {}", e)));
}
};
self.execute_action(action).await
}
fn status(&self) -> HandStatus {
HandStatus::Idle
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// === Config & Defaults ===
#[tokio::test]
async fn test_slideshow_creation() {
let hand = SlideshowHand::new();
assert_eq!(hand.config().id, "slideshow");
assert_eq!(hand.config().name, "幻灯片");
assert!(!hand.config().needs_approval);
assert!(hand.config().enabled);
assert!(hand.config().tags.contains(&"presentation".to_string()));
}
#[test]
fn test_default_impl() {
let hand = SlideshowHand::default();
assert_eq!(hand.config().id, "slideshow");
}
#[test]
fn test_needs_approval() {
let hand = SlideshowHand::new();
assert!(!hand.needs_approval());
}
#[test]
fn test_status() {
let hand = SlideshowHand::new();
assert_eq!(hand.status(), HandStatus::Idle);
}
#[test]
fn test_default_state() {
let state = SlideshowState::default();
assert_eq!(state.current_slide, 0);
assert_eq!(state.total_slides, 0);
assert!(!state.is_playing);
assert_eq!(state.auto_play_interval_ms, 5000);
assert!(state.slides.is_empty());
}
// === Navigation ===
#[tokio::test]
async fn test_navigation() {
let hand = SlideshowHand::with_slides_async(vec![
SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
SlideContent { title: "Slide 3".to_string(), subtitle: None, content: vec![], notes: None, background: None },
]).await;
// Next
hand.execute_action(SlideshowAction::NextSlide).await.unwrap();
assert_eq!(hand.get_state().await.current_slide, 1);
// Goto
hand.execute_action(SlideshowAction::GotoSlide { slide_number: 2 }).await.unwrap();
assert_eq!(hand.get_state().await.current_slide, 2);
// Prev
hand.execute_action(SlideshowAction::PrevSlide).await.unwrap();
assert_eq!(hand.get_state().await.current_slide, 1);
}
#[tokio::test]
async fn test_next_slide_at_end() {
let hand = SlideshowHand::with_slides_async(vec![
SlideContent { title: "Only Slide".to_string(), subtitle: None, content: vec![], notes: None, background: None },
]).await;
// At slide 0, should not advance past last slide
hand.execute_action(SlideshowAction::NextSlide).await.unwrap();
assert_eq!(hand.get_state().await.current_slide, 0);
}
#[tokio::test]
async fn test_prev_slide_at_beginning() {
let hand = SlideshowHand::with_slides_async(vec![
SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
]).await;
// At slide 0, should not go below 0
hand.execute_action(SlideshowAction::PrevSlide).await.unwrap();
assert_eq!(hand.get_state().await.current_slide, 0);
}
#[tokio::test]
async fn test_goto_slide_out_of_range() {
let hand = SlideshowHand::with_slides_async(vec![
SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
]).await;
let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 5 }).await.unwrap();
assert!(!result.success);
}
#[tokio::test]
async fn test_goto_slide_returns_content() {
let hand = SlideshowHand::with_slides_async(vec![
SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None },
SlideContent { title: "Second".to_string(), subtitle: None, content: vec![], notes: None, background: None },
]).await;
let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 1 }).await.unwrap();
assert!(result.success);
assert_eq!(result.output["slide_content"]["title"], "Second");
}
// === Spotlight & Laser & Highlight ===
#[tokio::test]
async fn test_spotlight() {
let hand = SlideshowHand::new();
let action = SlideshowAction::Spotlight {
element_id: "title".to_string(),
duration_ms: 2000,
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
assert_eq!(result.output["element_id"], "title");
assert_eq!(result.output["duration_ms"], 2000);
}
#[tokio::test]
async fn test_spotlight_default_duration() {
let hand = SlideshowHand::new();
let action = SlideshowAction::Spotlight {
element_id: "elem".to_string(),
duration_ms: default_spotlight_duration(),
};
let result = hand.execute_action(action).await.unwrap();
assert_eq!(result.output["duration_ms"], 2000);
}
#[tokio::test]
async fn test_laser() {
let hand = SlideshowHand::new();
let action = SlideshowAction::Laser {
x: 100.0,
y: 200.0,
duration_ms: 3000,
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
assert_eq!(result.output["x"], 100.0);
assert_eq!(result.output["y"], 200.0);
}
#[tokio::test]
async fn test_highlight_default_color() {
let hand = SlideshowHand::new();
let action = SlideshowAction::Highlight {
x: 10.0, y: 20.0, width: 100.0, height: 50.0,
color: None, duration_ms: 2000,
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
assert_eq!(result.output["color"], "#ffcc00");
}
#[tokio::test]
async fn test_highlight_custom_color() {
let hand = SlideshowHand::new();
let action = SlideshowAction::Highlight {
x: 0.0, y: 0.0, width: 50.0, height: 50.0,
color: Some("#ff0000".to_string()), duration_ms: 1000,
};
let result = hand.execute_action(action).await.unwrap();
assert_eq!(result.output["color"], "#ff0000");
}
// === AutoPlay / Pause / Resume ===
#[tokio::test]
async fn test_autoplay_pause_resume() {
let hand = SlideshowHand::new();
// AutoPlay
let result = hand.execute_action(SlideshowAction::AutoPlay { interval_ms: 3000 }).await.unwrap();
assert!(result.success);
assert!(hand.get_state().await.is_playing);
assert_eq!(hand.get_state().await.auto_play_interval_ms, 3000);
// Pause
hand.execute_action(SlideshowAction::Pause).await.unwrap();
assert!(!hand.get_state().await.is_playing);
// Resume
hand.execute_action(SlideshowAction::Resume).await.unwrap();
assert!(hand.get_state().await.is_playing);
// Stop
hand.execute_action(SlideshowAction::StopAutoPlay).await.unwrap();
assert!(!hand.get_state().await.is_playing);
}
#[tokio::test]
async fn test_autoplay_default_interval() {
let hand = SlideshowHand::new();
hand.execute_action(SlideshowAction::AutoPlay { interval_ms: default_interval() }).await.unwrap();
assert_eq!(hand.get_state().await.auto_play_interval_ms, 5000);
}
// === PlayAnimation ===
#[tokio::test]
async fn test_play_animation() {
let hand = SlideshowHand::new();
let result = hand.execute_action(SlideshowAction::PlayAnimation {
animation_id: "fade_in".to_string(),
}).await.unwrap();
assert!(result.success);
assert_eq!(result.output["animation_id"], "fade_in");
}
// === GetState ===
#[tokio::test]
async fn test_get_state() {
let hand = SlideshowHand::with_slides_async(vec![
SlideContent { title: "A".to_string(), subtitle: None, content: vec![], notes: None, background: None },
]).await;
let result = hand.execute_action(SlideshowAction::GetState).await.unwrap();
assert!(result.success);
assert_eq!(result.output["total_slides"], 1);
assert_eq!(result.output["current_slide"], 0);
}
// === SetContent ===
#[tokio::test]
async fn test_set_content() {
let hand = SlideshowHand::new();
let content = SlideContent {
title: "Test Slide".to_string(),
subtitle: Some("Subtitle".to_string()),
content: vec![ContentBlock::Text {
text: "Hello".to_string(),
style: None,
}],
notes: Some("Speaker notes".to_string()),
background: None,
};
let result = hand.execute_action(SlideshowAction::SetContent {
slide_number: 0,
content,
}).await.unwrap();
assert!(result.success);
assert_eq!(hand.get_state().await.total_slides, 1);
assert_eq!(hand.get_state().await.slides[0].title, "Test Slide");
}
#[tokio::test]
async fn test_set_content_append() {
let hand = SlideshowHand::with_slides_async(vec![
SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None },
]).await;
let content = SlideContent {
title: "Appended".to_string(), subtitle: None, content: vec![], notes: None, background: None,
};
let result = hand.execute_action(SlideshowAction::SetContent {
slide_number: 1,
content,
}).await.unwrap();
assert!(result.success);
assert_eq!(result.output["status"], "slide_added");
assert_eq!(hand.get_state().await.total_slides, 2);
}
#[tokio::test]
async fn test_set_content_invalid_index() {
let hand = SlideshowHand::new();
let content = SlideContent {
title: "Gap".to_string(), subtitle: None, content: vec![], notes: None, background: None,
};
let result = hand.execute_action(SlideshowAction::SetContent {
slide_number: 5,
content,
}).await.unwrap();
assert!(!result.success);
}
// === Action Deserialization ===
#[test]
fn test_deserialize_next_slide() {
let action: SlideshowAction = serde_json::from_value(json!({"action": "next_slide"})).unwrap();
assert!(matches!(action, SlideshowAction::NextSlide));
}
#[test]
fn test_deserialize_goto_slide() {
let action: SlideshowAction = serde_json::from_value(json!({"action": "goto_slide", "slide_number": 3})).unwrap();
match action {
SlideshowAction::GotoSlide { slide_number } => assert_eq!(slide_number, 3),
_ => panic!("Expected GotoSlide"),
}
}
#[test]
fn test_deserialize_laser() {
let action: SlideshowAction = serde_json::from_value(json!({
"action": "laser", "x": 50.0, "y": 75.0
})).unwrap();
match action {
SlideshowAction::Laser { x, y, .. } => {
assert_eq!(x, 50.0);
assert_eq!(y, 75.0);
}
_ => panic!("Expected Laser"),
}
}
#[test]
fn test_deserialize_autoplay() {
let action: SlideshowAction = serde_json::from_value(json!({"action": "auto_play"})).unwrap();
match action {
SlideshowAction::AutoPlay { interval_ms } => assert_eq!(interval_ms, 5000),
_ => panic!("Expected AutoPlay"),
}
}
#[test]
fn test_deserialize_invalid_action() {
let result = serde_json::from_value::<SlideshowAction>(json!({"action": "nonexistent"}));
assert!(result.is_err());
}
// === ContentBlock Deserialization ===
#[test]
fn test_content_block_text() {
let block: ContentBlock = serde_json::from_value(json!({
"type": "text", "text": "Hello"
})).unwrap();
match block {
ContentBlock::Text { text, style } => {
assert_eq!(text, "Hello");
assert!(style.is_none());
}
_ => panic!("Expected Text"),
}
}
#[test]
fn test_content_block_list() {
let block: ContentBlock = serde_json::from_value(json!({
"type": "list", "items": ["A", "B"], "ordered": true
})).unwrap();
match block {
ContentBlock::List { items, ordered } => {
assert_eq!(items, vec!["A", "B"]);
assert!(ordered);
}
_ => panic!("Expected List"),
}
}
#[test]
fn test_content_block_code() {
let block: ContentBlock = serde_json::from_value(json!({
"type": "code", "code": "fn main() {}", "language": "rust"
})).unwrap();
match block {
ContentBlock::Code { code, language } => {
assert_eq!(code, "fn main() {}");
assert_eq!(language, Some("rust".to_string()));
}
_ => panic!("Expected Code"),
}
}
#[test]
fn test_content_block_table() {
let block: ContentBlock = serde_json::from_value(json!({
"type": "table",
"headers": ["Name", "Age"],
"rows": [["Alice", "30"]]
})).unwrap();
match block {
ContentBlock::Table { headers, rows } => {
assert_eq!(headers, vec!["Name", "Age"]);
assert_eq!(rows, vec![vec!["Alice", "30"]]);
}
_ => panic!("Expected Table"),
}
}
// === Hand trait via execute ===
#[tokio::test]
async fn test_hand_execute_dispatch() {
let hand = SlideshowHand::with_slides_async(vec![
SlideContent { title: "S1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
SlideContent { title: "S2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
]).await;
let ctx = HandContext::default();
let result = hand.execute(&ctx, json!({"action": "next_slide"})).await.unwrap();
assert!(result.success);
assert_eq!(result.output["current_slide"], 1);
}
#[tokio::test]
async fn test_hand_execute_invalid_action() {
let hand = SlideshowHand::new();
let ctx = HandContext::default();
let result = hand.execute(&ctx, json!({"action": "invalid"})).await.unwrap();
assert!(!result.success);
}
// === add_slide helper ===
#[tokio::test]
async fn test_add_slide() {
let hand = SlideshowHand::new();
hand.add_slide(SlideContent {
title: "Dynamic".to_string(), subtitle: None, content: vec![], notes: None, background: None,
}).await;
hand.add_slide(SlideContent {
title: "Dynamic 2".to_string(), subtitle: None, content: vec![], notes: None, background: None,
}).await;
let state = hand.get_state().await;
assert_eq!(state.total_slides, 2);
assert_eq!(state.slides.len(), 2);
}
}

View File

@@ -1,442 +0,0 @@
//! Speech Hand - Text-to-Speech synthesis capabilities
//!
//! Provides speech synthesis for teaching:
//! - speak: Convert text to speech
//! - speak_ssml: Advanced speech with SSML markup
//! - pause/resume/stop: Playback control
//! - list_voices: Get available voices
//! - set_voice: Configure voice settings
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
use zclaw_types::Result;
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
/// TTS Provider types
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TtsProvider {
#[default]
Browser,
Azure,
OpenAI,
ElevenLabs,
Local,
}
/// Speech action types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum SpeechAction {
/// Speak text
Speak {
text: String,
#[serde(default)]
voice: Option<String>,
#[serde(default = "default_rate")]
rate: f32,
#[serde(default = "default_pitch")]
pitch: f32,
#[serde(default = "default_volume")]
volume: f32,
#[serde(default)]
language: Option<String>,
},
/// Speak with SSML markup
SpeakSsml {
ssml: String,
#[serde(default)]
voice: Option<String>,
},
/// Pause playback
Pause,
/// Resume playback
Resume,
/// Stop playback
Stop,
/// List available voices
ListVoices {
#[serde(default)]
language: Option<String>,
},
/// Set default voice
SetVoice {
voice: String,
#[serde(default)]
language: Option<String>,
},
/// Set provider
SetProvider {
provider: TtsProvider,
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
region: Option<String>,
},
}
fn default_rate() -> f32 { 1.0 }
fn default_pitch() -> f32 { 1.0 }
fn default_volume() -> f32 { 1.0 }
/// Voice information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VoiceInfo {
pub id: String,
pub name: String,
pub language: String,
pub gender: String,
#[serde(default)]
pub preview_url: Option<String>,
}
/// Playback state
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum PlaybackState {
#[default]
Idle,
Playing,
Paused,
}
/// Speech configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpeechConfig {
pub provider: TtsProvider,
pub default_voice: Option<String>,
pub default_language: String,
pub default_rate: f32,
pub default_pitch: f32,
pub default_volume: f32,
}
impl Default for SpeechConfig {
fn default() -> Self {
Self {
provider: TtsProvider::Browser,
default_voice: None,
default_language: "zh-CN".to_string(),
default_rate: 1.0,
default_pitch: 1.0,
default_volume: 1.0,
}
}
}
/// Speech state
#[derive(Debug, Clone, Default)]
pub struct SpeechState {
pub config: SpeechConfig,
pub playback: PlaybackState,
pub current_text: Option<String>,
pub position_ms: u64,
pub available_voices: Vec<VoiceInfo>,
}
/// Speech Hand implementation
pub struct SpeechHand {
config: HandConfig,
state: Arc<RwLock<SpeechState>>,
}
impl SpeechHand {
/// Create a new speech hand
pub fn new() -> Self {
Self {
config: HandConfig {
id: "speech".to_string(),
name: "语音合成".to_string(),
description: "文本转语音合成输出".to_string(),
needs_approval: false,
dependencies: vec![],
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string" },
"text": { "type": "string" },
"voice": { "type": "string" },
"rate": { "type": "number" },
}
})),
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string(), "demo".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
state: Arc::new(RwLock::new(SpeechState {
config: SpeechConfig::default(),
playback: PlaybackState::Idle,
available_voices: Self::get_default_voices(),
..Default::default()
})),
}
}
/// Create with custom provider
pub fn with_provider(provider: TtsProvider) -> Self {
let hand = Self::new();
let mut state = hand.state.blocking_write();
state.config.provider = provider;
drop(state);
hand
}
/// Get default voices
fn get_default_voices() -> Vec<VoiceInfo> {
vec![
VoiceInfo {
id: "zh-CN-XiaoxiaoNeural".to_string(),
name: "Xiaoxiao".to_string(),
language: "zh-CN".to_string(),
gender: "female".to_string(),
preview_url: None,
},
VoiceInfo {
id: "zh-CN-YunxiNeural".to_string(),
name: "Yunxi".to_string(),
language: "zh-CN".to_string(),
gender: "male".to_string(),
preview_url: None,
},
VoiceInfo {
id: "en-US-JennyNeural".to_string(),
name: "Jenny".to_string(),
language: "en-US".to_string(),
gender: "female".to_string(),
preview_url: None,
},
VoiceInfo {
id: "en-US-GuyNeural".to_string(),
name: "Guy".to_string(),
language: "en-US".to_string(),
gender: "male".to_string(),
preview_url: None,
},
]
}
/// Execute a speech action
pub async fn execute_action(&self, action: SpeechAction) -> Result<HandResult> {
let mut state = self.state.write().await;
match action {
SpeechAction::Speak { text, voice, rate, pitch, volume, language } => {
let voice_id = voice.or(state.config.default_voice.clone())
.unwrap_or_else(|| "default".to_string());
let lang = language.unwrap_or_else(|| state.config.default_language.clone());
let actual_rate = if rate == 1.0 { state.config.default_rate } else { rate };
let actual_pitch = if pitch == 1.0 { state.config.default_pitch } else { pitch };
let actual_volume = if volume == 1.0 { state.config.default_volume } else { volume };
state.playback = PlaybackState::Playing;
state.current_text = Some(text.clone());
// Determine TTS method based on provider:
// - Browser: frontend uses Web Speech API (zero deps, works offline)
// - OpenAI: frontend calls speech_tts command (high-quality, needs API key)
// - Others: future support
let tts_method = match state.config.provider {
TtsProvider::Browser => "browser",
TtsProvider::OpenAI => "openai_api",
TtsProvider::Azure => "azure_api",
TtsProvider::ElevenLabs => "elevenlabs_api",
TtsProvider::Local => "local_engine",
};
let estimated_duration_ms = (text.chars().count() as f64 / 5.0 * 1000.0) as u64;
Ok(HandResult::success(serde_json::json!({
"status": "speaking",
"tts_method": tts_method,
"text": text,
"voice": voice_id,
"language": lang,
"rate": actual_rate,
"pitch": actual_pitch,
"volume": actual_volume,
"provider": format!("{:?}", state.config.provider).to_lowercase(),
"duration_ms": estimated_duration_ms,
"instruction": "Frontend should play this via TTS engine"
})))
}
SpeechAction::SpeakSsml { ssml, voice } => {
let voice_id = voice.or(state.config.default_voice.clone())
.unwrap_or_else(|| "default".to_string());
state.playback = PlaybackState::Playing;
state.current_text = Some(ssml.clone());
Ok(HandResult::success(serde_json::json!({
"status": "speaking_ssml",
"ssml": ssml,
"voice": voice_id,
"provider": state.config.provider,
})))
}
SpeechAction::Pause => {
state.playback = PlaybackState::Paused;
Ok(HandResult::success(serde_json::json!({
"status": "paused",
"position_ms": state.position_ms,
})))
}
SpeechAction::Resume => {
state.playback = PlaybackState::Playing;
Ok(HandResult::success(serde_json::json!({
"status": "resumed",
"position_ms": state.position_ms,
})))
}
SpeechAction::Stop => {
state.playback = PlaybackState::Idle;
state.current_text = None;
state.position_ms = 0;
Ok(HandResult::success(serde_json::json!({
"status": "stopped",
})))
}
SpeechAction::ListVoices { language } => {
let voices: Vec<_> = state.available_voices.iter()
.filter(|v| {
language.as_ref()
.map(|l| v.language.starts_with(l))
.unwrap_or(true)
})
.cloned()
.collect();
Ok(HandResult::success(serde_json::json!({
"voices": voices,
"count": voices.len(),
})))
}
SpeechAction::SetVoice { voice, language } => {
state.config.default_voice = Some(voice.clone());
if let Some(lang) = language {
state.config.default_language = lang;
}
Ok(HandResult::success(serde_json::json!({
"status": "voice_set",
"voice": voice,
"language": state.config.default_language,
})))
}
SpeechAction::SetProvider { provider, api_key, region: _ } => {
state.config.provider = provider.clone();
// In real implementation, would configure provider
Ok(HandResult::success(serde_json::json!({
"status": "provider_set",
"provider": provider,
"configured": api_key.is_some(),
})))
}
}
}
/// Get current state
pub async fn get_state(&self) -> SpeechState {
self.state.read().await.clone()
}
}
impl Default for SpeechHand {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Hand for SpeechHand {
fn config(&self) -> &HandConfig {
&self.config
}
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
let action: SpeechAction = match serde_json::from_value(input) {
Ok(a) => a,
Err(e) => {
return Ok(HandResult::error(format!("Invalid speech action: {}", e)));
}
};
self.execute_action(action).await
}
fn status(&self) -> HandStatus {
HandStatus::Idle
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_speech_creation() {
let hand = SpeechHand::new();
assert_eq!(hand.config().id, "speech");
}
#[tokio::test]
async fn test_speak() {
let hand = SpeechHand::new();
let action = SpeechAction::Speak {
text: "Hello, world!".to_string(),
voice: None,
rate: 1.0,
pitch: 1.0,
volume: 1.0,
language: None,
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_pause_resume() {
let hand = SpeechHand::new();
// Speak first
hand.execute_action(SpeechAction::Speak {
text: "Test".to_string(),
voice: None, rate: 1.0, pitch: 1.0, volume: 1.0, language: None,
}).await.unwrap();
// Pause
let result = hand.execute_action(SpeechAction::Pause).await.unwrap();
assert!(result.success);
// Resume
let result = hand.execute_action(SpeechAction::Resume).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_list_voices() {
let hand = SpeechHand::new();
let action = SpeechAction::ListVoices { language: Some("zh".to_string()) };
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_set_voice() {
let hand = SpeechHand::new();
let action = SpeechAction::SetVoice {
voice: "zh-CN-XiaoxiaoNeural".to_string(),
language: Some("zh-CN".to_string()),
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
let state = hand.get_state().await;
assert_eq!(state.config.default_voice, Some("zh-CN-XiaoxiaoNeural".to_string()));
}
}

View File

@@ -191,6 +191,8 @@ pub enum TwitterAction {
Following { user_id: String, max_results: Option<u32> },
#[serde(rename = "check_credentials")]
CheckCredentials,
#[serde(rename = "set_credentials")]
SetCredentials { credentials: TwitterCredentials },
}
/// Twitter Hand implementation
@@ -200,14 +202,83 @@ pub struct TwitterHand {
}
impl TwitterHand {
/// Credential file path relative to app data dir
const CREDS_FILE_NAME: &'static str = "twitter-credentials.json";
/// Get the credentials file path
fn creds_path() -> Option<std::path::PathBuf> {
dirs::data_dir().map(|d| d.join("zclaw").join("hands").join(Self::CREDS_FILE_NAME))
}
/// Load credentials from disk (silent — logs errors, returns None on failure)
fn load_credentials_from_disk() -> Option<TwitterCredentials> {
let path = Self::creds_path()?;
if !path.exists() {
return None;
}
match std::fs::read_to_string(&path) {
Ok(data) => match serde_json::from_str(&data) {
Ok(creds) => {
tracing::info!("[TwitterHand] Loaded persisted credentials from {:?}", path);
Some(creds)
}
Err(e) => {
tracing::warn!("[TwitterHand] Failed to parse credentials file: {}", e);
None
}
},
Err(e) => {
tracing::warn!("[TwitterHand] Failed to read credentials file: {}", e);
None
}
}
}
/// Save credentials to disk (best-effort, logs errors)
fn save_credentials_to_disk(creds: &TwitterCredentials) {
let path = match Self::creds_path() {
Some(p) => p,
None => {
tracing::warn!("[TwitterHand] Cannot determine credentials file path");
return;
}
};
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!("[TwitterHand] Failed to create credentials dir: {}", e);
return;
}
}
match serde_json::to_string_pretty(creds) {
Ok(data) => {
if let Err(e) = std::fs::write(&path, data) {
tracing::warn!("[TwitterHand] Failed to write credentials file: {}", e);
} else {
tracing::info!("[TwitterHand] Credentials persisted to {:?}", path);
}
}
Err(e) => {
tracing::warn!("[TwitterHand] Failed to serialize credentials: {}", e);
}
}
}
/// Create a new Twitter hand
pub fn new() -> Self {
// Try to load persisted credentials
let loaded = Self::load_credentials_from_disk();
if loaded.is_some() {
tracing::info!("[TwitterHand] Restored credentials from previous session");
}
Self {
config: HandConfig {
id: "twitter".to_string(),
name: "Twitter 自动化".to_string(),
description: "Twitter/X 自动化能力,发布、搜索和管理内容".to_string(),
needs_approval: true, // Twitter actions need approval
needs_approval: true,
dependencies: vec!["twitter_api_key".to_string()],
input_schema: Some(serde_json::json!({
"type": "object",
@@ -275,12 +346,13 @@ impl TwitterHand {
max_concurrent: 0,
timeout_secs: 0,
},
credentials: Arc::new(RwLock::new(None)),
credentials: Arc::new(RwLock::new(loaded)),
}
}
/// Set credentials
/// Set credentials (also persists to disk)
pub async fn set_credentials(&self, creds: TwitterCredentials) {
Self::save_credentials_to_disk(&creds);
let mut c = self.credentials.write().await;
*c = Some(creds);
}
@@ -497,62 +569,34 @@ impl TwitterHand {
}
/// Execute like action — PUT /2/users/:id/likes
///
/// **NOTE**: Twitter API v2 requires OAuth 1.0a user context for like/retweet.
/// Bearer token (app-only auth) is not sufficient and will return 403.
/// This action is currently unavailable until OAuth 1.0a signing is implemented.
async fn execute_like(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
// Note: For like/retweet, we need OAuth 1.0a user context
// Using Bearer token as fallback (may not work for all endpoints)
let url = "https://api.twitter.com/2/users/me/likes";
let response = client.post(url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&json!({"tweet_id": tweet_id}))
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Like failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
let _ = tweet_id;
tracing::warn!("[TwitterHand] like action requires OAuth 1.0a user context — not yet supported");
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "liked",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet liked" } else { &response_text }
"success": false,
"action": "like",
"error": "OAuth 1.0a user context required. Like action is not yet supported with app-only Bearer token.",
"suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions."
}))
}
/// Execute retweet action — POST /2/users/:id/retweets
///
/// **NOTE**: Twitter API v2 requires OAuth 1.0a user context for retweet.
/// Bearer token (app-only auth) is not sufficient and will return 403.
/// This action is currently unavailable until OAuth 1.0a signing is implemented.
async fn execute_retweet(&self, tweet_id: &str) -> Result<Value> {
let creds = self.get_credentials().await
.ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?;
let client = reqwest::Client::new();
let url = "https://api.twitter.com/2/users/me/retweets";
let response = client.post(url)
.header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or("")))
.header("Content-Type", "application/json")
.header("User-Agent", "ZCLAW/1.0")
.json(&json!({"tweet_id": tweet_id}))
.send()
.await
.map_err(|e| zclaw_types::ZclawError::HandError(format!("Retweet failed: {}", e)))?;
let status = response.status();
let response_text = response.text().await.unwrap_or_default();
let _ = tweet_id;
tracing::warn!("[TwitterHand] retweet action requires OAuth 1.0a user context — not yet supported");
Ok(json!({
"success": status.is_success(),
"tweet_id": tweet_id,
"action": "retweeted",
"status_code": status.as_u16(),
"message": if status.is_success() { "Tweet retweeted" } else { &response_text }
"success": false,
"action": "retweet",
"error": "OAuth 1.0a user context required. Retweet action is not yet supported with app-only Bearer token.",
"suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions."
}))
}
@@ -793,6 +837,13 @@ impl Hand for TwitterHand {
TwitterAction::Followers { user_id, max_results } => self.execute_followers(&user_id, max_results).await?,
TwitterAction::Following { user_id, max_results } => self.execute_following(&user_id, max_results).await?,
TwitterAction::CheckCredentials => self.execute_check_credentials().await?,
TwitterAction::SetCredentials { credentials } => {
self.set_credentials(credentials).await;
json!({
"success": true,
"message": "Twitter 凭据已设置并持久化。重启后自动恢复。"
})
}
};
let duration_ms = start.elapsed().as_millis() as u64;
@@ -813,9 +864,13 @@ impl Hand for TwitterHand {
fn check_dependencies(&self) -> Result<Vec<String>> {
let mut missing = Vec::new();
// Check if credentials are configured (synchronously)
// This is a simplified check; actual async check would require runtime
missing.push("Twitter API credentials required".to_string());
// Synchronous check: if credentials were loaded from disk, dependency is met
match self.credentials.try_read() {
Ok(creds) if creds.is_some() => {},
_ => {
missing.push("Twitter API credentials required (use set_credentials action to configure)".to_string());
}
}
Ok(missing)
}
@@ -1086,6 +1141,62 @@ mod tests {
assert!(result.is_err());
}
#[test]
fn test_set_credentials_action_deserialize() {
let json = json!({
"action": "set_credentials",
"credentials": {
"apiKey": "test-key",
"apiSecret": "test-secret",
"accessToken": "test-token",
"accessTokenSecret": "test-token-secret",
"bearerToken": "test-bearer"
}
});
let action: TwitterAction = serde_json::from_value(json).unwrap();
match action {
TwitterAction::SetCredentials { credentials } => {
assert_eq!(credentials.api_key, "test-key");
assert_eq!(credentials.api_secret, "test-secret");
assert_eq!(credentials.bearer_token, Some("test-bearer".to_string()));
}
_ => panic!("Expected SetCredentials"),
}
}
#[tokio::test]
async fn test_set_credentials_persists_and_restores() {
// Use a temporary directory to avoid polluting real credentials
let temp_dir = std::env::temp_dir().join("zclaw_test_twitter_creds");
let _ = std::fs::create_dir_all(&temp_dir);
let hand = TwitterHand::new();
// Set credentials
let creds = TwitterCredentials {
api_key: "test-key".to_string(),
api_secret: "test-secret".to_string(),
access_token: "test-token".to_string(),
access_token_secret: "test-secret".to_string(),
bearer_token: Some("test-bearer".to_string()),
};
hand.set_credentials(creds.clone()).await;
// Verify in-memory
let loaded = hand.get_credentials().await;
assert!(loaded.is_some());
assert_eq!(loaded.unwrap().api_key, "test-key");
// Verify file was written
let path = TwitterHand::creds_path();
assert!(path.is_some());
let path = path.unwrap();
assert!(path.exists(), "Credentials file should exist at {:?}", path);
// Clean up
let _ = std::fs::remove_file(&path);
}
// === Serialization Roundtrip ===
#[test]

View File

@@ -1,422 +0,0 @@
//! Whiteboard Hand - Drawing and annotation capabilities
//!
//! Provides whiteboard drawing actions for teaching:
//! - draw_text: Draw text on the whiteboard
//! - draw_shape: Draw shapes (rectangle, circle, arrow, etc.)
//! - draw_line: Draw lines and curves
//! - draw_chart: Draw charts (bar, line, pie)
//! - draw_latex: Render LaTeX formulas
//! - draw_table: Draw data tables
//! - clear: Clear the whiteboard
//! - export: Export as image
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use zclaw_types::Result;
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
/// Whiteboard action types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum WhiteboardAction {
/// Draw text
DrawText {
x: f64,
y: f64,
text: String,
#[serde(default = "default_font_size")]
font_size: u32,
#[serde(default)]
color: Option<String>,
#[serde(default)]
font_family: Option<String>,
},
/// Draw a shape
DrawShape {
shape: ShapeType,
x: f64,
y: f64,
width: f64,
height: f64,
#[serde(default)]
fill: Option<String>,
#[serde(default)]
stroke: Option<String>,
#[serde(default = "default_stroke_width")]
stroke_width: u32,
},
/// Draw a line
DrawLine {
points: Vec<Point>,
#[serde(default)]
color: Option<String>,
#[serde(default = "default_stroke_width")]
stroke_width: u32,
},
/// Draw a chart
DrawChart {
chart_type: ChartType,
data: ChartData,
x: f64,
y: f64,
width: f64,
height: f64,
#[serde(default)]
title: Option<String>,
},
/// Draw LaTeX formula
DrawLatex {
latex: String,
x: f64,
y: f64,
#[serde(default = "default_font_size")]
font_size: u32,
#[serde(default)]
color: Option<String>,
},
/// Draw a table
DrawTable {
headers: Vec<String>,
rows: Vec<Vec<String>>,
x: f64,
y: f64,
#[serde(default)]
column_widths: Option<Vec<f64>>,
},
/// Erase area
Erase {
x: f64,
y: f64,
width: f64,
height: f64,
},
/// Clear whiteboard
Clear,
/// Undo last action
Undo,
/// Redo last undone action
Redo,
/// Export as image
Export {
#[serde(default = "default_export_format")]
format: String,
},
}
fn default_font_size() -> u32 { 16 }
fn default_stroke_width() -> u32 { 2 }
fn default_export_format() -> String { "png".to_string() }
/// Shape types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShapeType {
Rectangle,
RoundedRectangle,
Circle,
Ellipse,
Triangle,
Arrow,
Star,
Checkmark,
Cross,
}
/// Point for line drawing
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Point {
pub x: f64,
pub y: f64,
}
/// Chart types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartType {
Bar,
Line,
Pie,
Scatter,
Area,
Radar,
}
/// Chart data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartData {
pub labels: Vec<String>,
pub datasets: Vec<Dataset>,
}
/// Dataset for charts
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Dataset {
pub label: String,
pub values: Vec<f64>,
#[serde(default)]
pub color: Option<String>,
}
/// Whiteboard state (for undo/redo)
#[derive(Debug, Clone, Default)]
pub struct WhiteboardState {
pub actions: Vec<WhiteboardAction>,
pub undone: Vec<WhiteboardAction>,
pub canvas_width: f64,
pub canvas_height: f64,
}
/// Whiteboard Hand implementation
pub struct WhiteboardHand {
config: HandConfig,
state: std::sync::Arc<tokio::sync::RwLock<WhiteboardState>>,
}
impl WhiteboardHand {
/// Create a new whiteboard hand
pub fn new() -> Self {
Self {
config: HandConfig {
id: "whiteboard".to_string(),
name: "白板".to_string(),
description: "在虚拟白板上绘制和标注".to_string(),
needs_approval: false,
dependencies: vec![],
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string" },
"x": { "type": "number" },
"y": { "type": "number" },
"text": { "type": "string" },
}
})),
tags: vec!["presentation".to_string(), "education".to_string()],
enabled: true,
max_concurrent: 0,
timeout_secs: 0,
},
state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState {
canvas_width: 1920.0,
canvas_height: 1080.0,
..Default::default()
})),
}
}
/// Create with custom canvas size
pub fn with_size(width: f64, height: f64) -> Self {
let hand = Self::new();
let mut state = hand.state.blocking_write();
state.canvas_width = width;
state.canvas_height = height;
drop(state);
hand
}
/// Execute a whiteboard action
pub async fn execute_action(&self, action: WhiteboardAction) -> Result<HandResult> {
let mut state = self.state.write().await;
match &action {
WhiteboardAction::Clear => {
state.actions.clear();
state.undone.clear();
return Ok(HandResult::success(serde_json::json!({
"status": "cleared",
"action_count": 0
})));
}
WhiteboardAction::Undo => {
if let Some(last) = state.actions.pop() {
state.undone.push(last);
return Ok(HandResult::success(serde_json::json!({
"status": "undone",
"remaining_actions": state.actions.len()
})));
}
return Ok(HandResult::success(serde_json::json!({
"status": "no_action_to_undo"
})));
}
WhiteboardAction::Redo => {
if let Some(redone) = state.undone.pop() {
state.actions.push(redone);
return Ok(HandResult::success(serde_json::json!({
"status": "redone",
"total_actions": state.actions.len()
})));
}
return Ok(HandResult::success(serde_json::json!({
"status": "no_action_to_redo"
})));
}
WhiteboardAction::Export { format } => {
// In real implementation, would render to image
return Ok(HandResult::success(serde_json::json!({
"status": "exported",
"format": format,
"data_url": format!("data:image/{};base64,<rendered_data>", format)
})));
}
_ => {
// Regular drawing action
state.actions.push(action.clone());
return Ok(HandResult::success(serde_json::json!({
"status": "drawn",
"action": action,
"total_actions": state.actions.len()
})));
}
}
}
/// Get current state
pub async fn get_state(&self) -> WhiteboardState {
self.state.read().await.clone()
}
/// Get all actions
pub async fn get_actions(&self) -> Vec<WhiteboardAction> {
self.state.read().await.actions.clone()
}
}
impl Default for WhiteboardHand {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Hand for WhiteboardHand {
fn config(&self) -> &HandConfig {
&self.config
}
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
// Parse action from input
let action: WhiteboardAction = match serde_json::from_value(input.clone()) {
Ok(a) => a,
Err(e) => {
return Ok(HandResult::error(format!("Invalid whiteboard action: {}", e)));
}
};
self.execute_action(action).await
}
fn status(&self) -> HandStatus {
// Check if there are any actions
HandStatus::Idle
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_whiteboard_creation() {
let hand = WhiteboardHand::new();
assert_eq!(hand.config().id, "whiteboard");
}
#[tokio::test]
async fn test_draw_text() {
let hand = WhiteboardHand::new();
let action = WhiteboardAction::DrawText {
x: 100.0,
y: 100.0,
text: "Hello World".to_string(),
font_size: 24,
color: Some("#333333".to_string()),
font_family: None,
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
let state = hand.get_state().await;
assert_eq!(state.actions.len(), 1);
}
#[tokio::test]
async fn test_draw_shape() {
let hand = WhiteboardHand::new();
let action = WhiteboardAction::DrawShape {
shape: ShapeType::Rectangle,
x: 50.0,
y: 50.0,
width: 200.0,
height: 100.0,
fill: Some("#4CAF50".to_string()),
stroke: None,
stroke_width: 2,
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_undo_redo() {
let hand = WhiteboardHand::new();
// Draw something
hand.execute_action(WhiteboardAction::DrawText {
x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
}).await.unwrap();
// Undo
let result = hand.execute_action(WhiteboardAction::Undo).await.unwrap();
assert!(result.success);
assert_eq!(hand.get_state().await.actions.len(), 0);
// Redo
let result = hand.execute_action(WhiteboardAction::Redo).await.unwrap();
assert!(result.success);
assert_eq!(hand.get_state().await.actions.len(), 1);
}
#[tokio::test]
async fn test_clear() {
let hand = WhiteboardHand::new();
// Draw something
hand.execute_action(WhiteboardAction::DrawText {
x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
}).await.unwrap();
// Clear
let result = hand.execute_action(WhiteboardAction::Clear).await.unwrap();
assert!(result.success);
assert_eq!(hand.get_state().await.actions.len(), 0);
}
#[tokio::test]
async fn test_chart() {
let hand = WhiteboardHand::new();
let action = WhiteboardAction::DrawChart {
chart_type: ChartType::Bar,
data: ChartData {
labels: vec!["A".to_string(), "B".to_string(), "C".to_string()],
datasets: vec![Dataset {
label: "Values".to_string(),
values: vec![10.0, 20.0, 15.0],
color: Some("#2196F3".to_string()),
}],
},
x: 100.0,
y: 100.0,
width: 400.0,
height: 300.0,
title: Some("Test Chart".to_string()),
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
}
}

View File

@@ -9,8 +9,6 @@ description = "ZCLAW kernel - central coordinator for all subsystems"
[features]
default = []
# Enable multi-agent orchestration (Director, A2A protocol)
multi-agent = ["zclaw-protocols/a2a"]
[dependencies]
zclaw-types = { workspace = true }
@@ -19,6 +17,7 @@ zclaw-runtime = { workspace = true }
zclaw-protocols = { workspace = true }
zclaw-hands = { workspace = true }
zclaw-skills = { workspace = true }
zclaw-growth = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }

View File

@@ -30,7 +30,7 @@ impl Default for ApiProtocol {
///
/// This is the single source of truth for LLM configuration.
/// Model ID is passed directly to the API without any transformation.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Clone, Serialize, Deserialize)]
pub struct LlmConfig {
/// API base URL (e.g., "https://api.openai.com/v1")
pub base_url: String,
@@ -61,6 +61,20 @@ pub struct LlmConfig {
pub context_window: u32,
}
impl std::fmt::Debug for LlmConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LlmConfig")
.field("base_url", &self.base_url)
.field("api_key", &"***REDACTED***")
.field("model", &self.model)
.field("api_protocol", &self.api_protocol)
.field("max_tokens", &self.max_tokens)
.field("temperature", &self.temperature)
.field("context_window", &self.context_window)
.finish()
}
}
impl LlmConfig {
/// Create a new LLM config
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, model: impl Into<String>) -> Self {

View File

@@ -12,7 +12,7 @@
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::{RwLock, Mutex, mpsc};
use tokio::sync::{RwLock, Mutex, mpsc, oneshot};
use zclaw_types::{AgentId, Result, ZclawError};
use zclaw_protocols::{A2aEnvelope, A2aMessageType, A2aRecipient, A2aRouter, A2aAgentProfile, A2aCapability};
use zclaw_runtime::{LlmDriver, CompletionRequest};
@@ -199,9 +199,9 @@ pub struct Director {
director_id: AgentId,
/// Optional LLM driver for intelligent scheduling
llm_driver: Option<Arc<dyn LlmDriver>>,
/// Inbox for receiving responses (stores pending request IDs and their response channels)
pending_requests: Arc<Mutex<std::collections::HashMap<String, mpsc::Sender<A2aEnvelope>>>>,
/// Receiver for incoming messages
/// Pending request response channels (request_id → oneshot sender)
pending_requests: Arc<Mutex<std::collections::HashMap<String, oneshot::Sender<A2aEnvelope>>>>,
/// Receiver for incoming messages (consumed by inbox reader task)
inbox: Arc<Mutex<Option<mpsc::Receiver<A2aEnvelope>>>>,
}
@@ -360,7 +360,7 @@ impl Director {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.expect("system clock is valid")
.as_nanos();
let idx = (now as usize) % agents.len();
Some(agents[idx].clone())
@@ -481,13 +481,16 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
}
/// Send message to selected agent and wait for response
///
/// Uses oneshot channels to avoid deadlock: each call creates its own
/// response channel, and a shared inbox reader dispatches responses.
pub async fn send_to_agent(
&self,
agent: &DirectorAgent,
message: String,
) -> Result<String> {
// Create a response channel for this request
let (_response_tx, mut _response_rx) = mpsc::channel::<A2aEnvelope>(1);
// Create a oneshot channel for this specific request's response
let (response_tx, response_rx) = oneshot::channel::<A2aEnvelope>();
let envelope = A2aEnvelope::new(
self.director_id.clone(),
@@ -500,50 +503,32 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
}),
);
// Store the request ID with its response channel
// Store the oneshot sender so the inbox reader can dispatch to it
let request_id = envelope.id.clone();
{
let mut pending = self.pending_requests.lock().await;
pending.insert(request_id.clone(), _response_tx);
pending.insert(request_id.clone(), response_tx);
}
// Send the request
self.router.route(envelope).await?;
// Wait for response with timeout
// Ensure the inbox reader is running
self.ensure_inbox_reader().await;
// Wait for response on our dedicated oneshot channel with timeout
let timeout_duration = std::time::Duration::from_secs(self.config.response_timeout);
let request_id_clone = request_id.clone();
let response = tokio::time::timeout(timeout_duration, async {
// Poll the inbox for responses
let mut inbox_guard = self.inbox.lock().await;
if let Some(ref mut rx) = *inbox_guard {
while let Some(msg) = rx.recv().await {
// Check if this is a response to our request
if msg.message_type == A2aMessageType::Response {
if let Some(ref reply_to) = msg.reply_to {
if reply_to == &request_id_clone {
// Found our response
return Some(msg);
}
}
}
// Not our response, continue waiting
// (In a real implementation, we'd re-queue non-matching messages)
}
}
None
}).await;
let response = tokio::time::timeout(timeout_duration, response_rx).await;
// Clean up pending request
// Clean up pending request (sender already consumed on success)
{
let mut pending = self.pending_requests.lock().await;
pending.remove(&request_id);
}
match response {
Ok(Some(envelope)) => {
// Extract response text from payload
Ok(Ok(envelope)) => {
let response_text = envelope.payload
.get("response")
.and_then(|v: &serde_json::Value| v.as_str())
@@ -551,7 +536,7 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
.to_string();
Ok(response_text)
}
Ok(None) => {
Ok(Err(_)) => {
Err(ZclawError::Timeout("No response received".into()))
}
Err(_) => {
@@ -563,6 +548,47 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
}
}
/// Ensure the inbox reader task is running.
/// The inbox reader continuously reads from the shared inbox channel
/// and dispatches each response to the correct oneshot sender.
async fn ensure_inbox_reader(&self) {
// Quick check: if inbox has already been taken, reader is running
{
let inbox = self.inbox.lock().await;
if inbox.is_none() {
return; // Reader already spawned and consumed the receiver
}
}
// Take the receiver out (only once)
let rx = {
let mut inbox = self.inbox.lock().await;
inbox.take()
};
if let Some(mut rx) = rx {
let pending = self.pending_requests.clone();
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
// Find and dispatch to the correct oneshot sender
if msg.message_type == A2aMessageType::Response {
if let Some(ref reply_to) = msg.reply_to {
let reply_to_clone = reply_to.clone();
let mut pending_guard = pending.lock().await;
if let Some(sender) = pending_guard.remove(reply_to) {
// Send the response; if receiver already dropped, request was cancelled
if sender.send(msg).is_err() {
tracing::debug!("[Director] Response dropped: receiver cancelled for reply_to={}", reply_to_clone);
}
}
}
}
// Non-response messages are dropped (notifications, etc.)
}
});
}
}
/// Broadcast message to all agents
pub async fn broadcast(&self, message: String) -> Result<()> {
let envelope = A2aEnvelope::new(
@@ -616,7 +642,9 @@ Respond with ONLY the number (1-{}) of the agent who should speak next. No expla
}
if let Some(ref user_input) = input {
context.push_str(&format!("User: {}\n\n", user_input));
context.push_str("<user_input>\n");
context.push_str(&format!("{}\n", user_input));
context.push_str("</user_input>\n\n");
}
// Add recent history
@@ -882,7 +910,9 @@ impl Director {
let prompt = format!(
r#"你是 ZCLAW 管家。请将以下用户需求拆解为 1-5 个具体子任务。
用户需求:{}
<user_request>
{}
</user_request>
请按 JSON 数组格式输出,每个元素包含:
- description: 子任务描述(中文)

View File

@@ -17,8 +17,9 @@ impl EventBus {
/// Publish an event
pub fn publish(&self, event: Event) {
// Ignore send errors (no subscribers)
let _ = self.sender.send(event);
if let Err(e) = self.sender.send(event) {
tracing::debug!("Event dropped (no subscribers or channel full): {:?}", e);
}
}
/// Subscribe to events

View File

@@ -14,7 +14,7 @@ use zclaw_types::Result;
/// HTML exporter
pub struct HtmlExporter {
/// Template name (reserved for future template support)
#[allow(dead_code)] // TODO: Implement template-based HTML export
#[allow(dead_code)] // @reserved: post-release template-based HTML export
template: String,
}

View File

@@ -490,7 +490,7 @@ impl PptxExporter {
paths.sort();
for path in paths {
let content = files.get(path).unwrap();
let content = files.get(path).expect("path comes from files.keys(), must exist");
let options = SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);

View File

@@ -243,7 +243,7 @@ fn clean_fallback_response(text: &str) -> String {
fn current_timestamp_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.expect("system clock is valid")
.as_millis() as i64
}

View File

@@ -557,7 +557,7 @@ Use Chinese if the topic is in Chinese. Include metaphors that relate to everyda
.join("\n")
}
#[allow(dead_code)]
#[allow(dead_code)] // @reserved: instance-method convenience wrapper for static helper
fn extract_text_from_response(&self, response: &CompletionResponse) -> String {
Self::extract_text_from_response_static(response)
}
@@ -882,7 +882,7 @@ fn current_timestamp() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.expect("system clock is valid")
.as_millis() as i64
}

View File

@@ -1,16 +1,10 @@
//! A2A (Agent-to-Agent) messaging
//!
//! All items in this module are gated by the `multi-agent` feature flag.
#[cfg(feature = "multi-agent")]
use zclaw_types::{AgentId, Capability, Event, Result};
#[cfg(feature = "multi-agent")]
use zclaw_protocols::{A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient};
#[cfg(feature = "multi-agent")]
use super::Kernel;
#[cfg(feature = "multi-agent")]
impl Kernel {
// ============================================================
// A2A (Agent-to-Agent) Messaging

View File

@@ -3,11 +3,12 @@
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::Value;
use serde_json::{json, Value};
use zclaw_runtime::{LlmDriver, tool::SkillExecutor};
use zclaw_skills::{SkillRegistry, LlmCompleter};
use zclaw_types::Result;
use zclaw_runtime::{LlmDriver, tool::{SkillExecutor, HandExecutor}};
use zclaw_skills::{SkillRegistry, LlmCompleter, SkillCompletion, SkillToolCall};
use zclaw_hands::HandRegistry;
use zclaw_types::{AgentId, Result, ToolDefinition};
/// Adapter that bridges `zclaw_runtime::LlmDriver` -> `zclaw_skills::LlmCompleter`
pub(crate) struct LlmDriverAdapter {
@@ -43,18 +44,111 @@ impl LlmCompleter for LlmDriverAdapter {
Ok(text)
})
}
fn complete_with_tools(
&self,
prompt: &str,
system_prompt: Option<&str>,
tools: Vec<ToolDefinition>,
) -> Pin<Box<dyn std::future::Future<Output = std::result::Result<SkillCompletion, String>> + Send + '_>> {
let driver = self.driver.clone();
let prompt = prompt.to_string();
let system = system_prompt.map(|s| s.to_string());
let max_tokens = self.max_tokens;
let temperature = self.temperature;
Box::pin(async move {
let mut messages = Vec::new();
messages.push(zclaw_types::Message::user(prompt));
let request = zclaw_runtime::CompletionRequest {
model: String::new(),
system,
messages,
tools,
max_tokens: Some(max_tokens),
temperature: Some(temperature),
stop: Vec::new(),
stream: false,
thinking_enabled: false,
reasoning_effort: None,
plan_mode: false,
};
let response = driver.complete(request).await
.map_err(|e| format!("LLM completion error: {}", e))?;
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
for block in &response.content {
match block {
zclaw_runtime::ContentBlock::Text { text } => {
text_parts.push(text.clone());
}
zclaw_runtime::ContentBlock::ToolUse { id, name, input } => {
tool_calls.push(SkillToolCall {
id: id.clone(),
name: name.clone(),
input: input.clone(),
});
}
_ => {}
}
}
Ok(SkillCompletion {
text: text_parts.join(""),
tool_calls,
})
})
}
}
/// Skill executor implementation for Kernel
pub struct KernelSkillExecutor {
pub(crate) skills: Arc<SkillRegistry>,
pub(crate) llm: Arc<dyn LlmCompleter>,
/// Shared tool registry, updated before each skill execution from the
/// agent loop's freshly-built registry. Uses std::sync because reads
/// happen from async code but writes are brief and infrequent.
pub(crate) tool_registry: std::sync::RwLock<Option<zclaw_runtime::ToolRegistry>>,
}
impl KernelSkillExecutor {
pub fn new(skills: Arc<SkillRegistry>, driver: Arc<dyn LlmDriver>) -> Self {
let llm: Arc<dyn zclaw_skills::LlmCompleter> = Arc::new(LlmDriverAdapter { driver, max_tokens: 4096, temperature: 0.7 });
Self { skills, llm }
let llm: Arc<dyn LlmCompleter> = Arc::new(LlmDriverAdapter { driver, max_tokens: 4096, temperature: 0.7 });
Self { skills, llm, tool_registry: std::sync::RwLock::new(None) }
}
/// Update the tool registry snapshot. Called by the kernel before each
/// agent loop iteration so skill execution sees the latest tool set.
pub fn set_tool_registry(&self, registry: zclaw_runtime::ToolRegistry) {
if let Ok(mut guard) = self.tool_registry.write() {
*guard = Some(registry);
}
}
/// Resolve the tool definitions declared by a skill manifest against
/// the currently active tool registry.
fn resolve_tool_definitions(&self, skill_id: &str) -> Vec<ToolDefinition> {
let manifests = self.skills.manifests_snapshot();
let manifest = match manifests.get(&zclaw_types::SkillId::new(skill_id)) {
Some(m) => m,
None => return vec![],
};
if manifest.tools.is_empty() {
return vec![];
}
let guard = match self.tool_registry.read() {
Ok(g) => g,
Err(_) => return vec![],
};
let registry = match guard.as_ref() {
Some(r) => r,
None => return vec![],
};
// Only include definitions for tools declared in the skill manifest.
registry.definitions().into_iter()
.filter(|def| manifest.tools.iter().any(|t| t == &def.name))
.collect()
}
}
@@ -67,10 +161,12 @@ impl SkillExecutor for KernelSkillExecutor {
session_id: &str,
input: Value,
) -> Result<Value> {
let tool_definitions = self.resolve_tool_definitions(skill_id);
let context = zclaw_skills::SkillContext {
agent_id: agent_id.to_string(),
session_id: session_id.to_string(),
llm: Some(self.llm.clone()),
tool_definitions,
..Default::default()
};
let result = self.skills.execute(&zclaw_types::SkillId::new(skill_id), &context, input).await?;
@@ -106,13 +202,11 @@ impl SkillExecutor for KernelSkillExecutor {
/// Inbox wrapper for A2A message receivers that supports re-queuing
/// non-matching messages instead of dropping them.
#[cfg(feature = "multi-agent")]
pub(crate) struct AgentInbox {
pub(crate) rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>,
pub(crate) pending: std::collections::VecDeque<zclaw_protocols::A2aEnvelope>,
}
#[cfg(feature = "multi-agent")]
impl AgentInbox {
pub(crate) fn new(rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>) -> Self {
Self { rx, pending: std::collections::VecDeque::new() }
@@ -136,3 +230,47 @@ impl AgentInbox {
self.pending.push_back(envelope);
}
}
/// Hand executor implementation for Kernel
///
/// Bridges `zclaw_runtime::tool::HandExecutor` → `zclaw_hands::HandRegistry`,
/// allowing `HandTool::execute()` to dispatch to the real Hand implementations.
pub struct KernelHandExecutor {
hands: Arc<HandRegistry>,
}
impl KernelHandExecutor {
pub fn new(hands: Arc<HandRegistry>) -> Self {
Self { hands }
}
}
#[async_trait]
impl HandExecutor for KernelHandExecutor {
async fn execute_hand(
&self,
hand_id: &str,
agent_id: &AgentId,
input: Value,
) -> Result<Value> {
let context = zclaw_hands::HandContext {
agent_id: agent_id.clone(),
working_dir: None,
env: std::collections::HashMap::new(),
timeout_secs: 300,
callback_url: None,
};
let result = self.hands.execute(hand_id, &context, input).await?;
if result.success {
Ok(result.output)
} else {
Ok(json!({
"hand_id": hand_id,
"status": "failed",
"error": result.error.unwrap_or_else(|| "Unknown hand execution error".to_string()),
"output": result.output,
"duration_ms": result.duration_ms,
}))
}
}
}

View File

@@ -2,11 +2,8 @@
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result};
#[cfg(feature = "multi-agent")]
use std::sync::Arc;
#[cfg(feature = "multi-agent")]
use tokio::sync::Mutex;
#[cfg(feature = "multi-agent")]
use super::adapters::AgentInbox;
use super::Kernel;
@@ -23,7 +20,6 @@ impl Kernel {
self.memory.save_agent(&config).await?;
// Register with A2A router for multi-agent messaging (before config is moved)
#[cfg(feature = "multi-agent")]
{
let profile = Self::agent_config_to_a2a_profile(&config);
let rx = self.a2a_router.register_agent(profile).await;
@@ -52,7 +48,6 @@ impl Kernel {
self.memory.delete_agent(id).await?;
// Unregister from A2A router
#[cfg(feature = "multi-agent")]
{
self.a2a_router.unregister_agent(id).await;
self.a2a_inboxes.remove(id);

View File

@@ -85,14 +85,14 @@ impl Kernel {
started_at: None,
completed_at: None,
};
let _ = memory.save_hand_run(&run).await.map_err(|e| {
tracing::warn!("[Approval] Failed to save hand run: {}", e);
});
if let Err(e) = memory.save_hand_run(&run).await {
tracing::error!("[Approval] Failed to save hand run: {}", e);
}
run.status = HandRunStatus::Running;
run.started_at = Some(chrono::Utc::now().to_rfc3339());
let _ = memory.update_hand_run(&run).await.map_err(|e| {
tracing::warn!("[Approval] Failed to update hand run (running): {}", e);
});
if let Err(e) = memory.update_hand_run(&run).await {
tracing::error!("[Approval] Failed to update hand run (running): {}", e);
}
// Register cancellation flag
let cancel_flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
@@ -121,9 +121,9 @@ impl Kernel {
}
run.duration_ms = Some(duration.as_millis() as u64);
run.completed_at = Some(completed_at);
let _ = memory.update_hand_run(&run).await.map_err(|e| {
tracing::warn!("[Approval] Failed to update hand run (completed): {}", e);
});
if let Err(e) = memory.update_hand_run(&run).await {
tracing::error!("[Approval] Failed to update hand run (completed): {}", e);
}
// Update approval status based on execution result
let mut approvals = approvals.lock().await;

View File

@@ -0,0 +1,120 @@
//! Evolution Bridge — connects growth crate's SkillCandidate to skills crate's SkillManifest
//!
//! The growth crate (zclaw-growth) generates SkillCandidate from conversation patterns.
//! The skills crate (zclaw-skills) requires SkillManifest for disk persistence.
//! This bridge lives in zclaw-kernel because it depends on both crates.
use zclaw_growth::skill_generator::SkillCandidate;
use zclaw_skills::{SkillManifest, SkillMode};
use zclaw_types::SkillId;
/// Convert a validated SkillCandidate into a SkillManifest ready for registration.
///
/// Safety invariants:
/// - `mode` is always `PromptOnly` (auto-generated skills cannot execute code)
/// - `enabled` is `false` (requires one explicit positive feedback to activate)
/// - `body_markdown` is stored in `manifest.body` and persisted by `serialize_skill_md`
pub fn candidate_to_manifest(candidate: &SkillCandidate) -> SkillManifest {
let slug = name_to_slug(&candidate.name);
SkillManifest {
id: SkillId::new(format!("auto-{}", slug)),
name: candidate.name.clone(),
description: candidate.description.clone(),
version: format!("{}", candidate.version),
author: Some("zclaw-evolution".to_string()),
mode: SkillMode::PromptOnly,
capabilities: Vec::new(),
input_schema: None,
output_schema: None,
tags: vec!["auto-generated".to_string()],
category: None,
triggers: candidate.triggers.clone(),
tools: candidate.tools.clone(),
enabled: false,
body: Some(candidate.body_markdown.clone()),
}
}
/// Convert a human-readable name to a URL-safe slug.
fn name_to_slug(name: &str) -> String {
let mut result = String::new();
for c in name.trim().chars() {
if c.is_ascii_alphanumeric() {
result.push(c.to_ascii_lowercase());
} else if c == ' ' || c == '-' || c == '_' {
result.push('-');
} else {
// Chinese/unicode characters: use hex representation
result.push_str(&format!("{:x}", c as u32));
}
}
let slug = result.trim_matches('-').to_string();
if slug.is_empty() {
// Fallback for empty or whitespace-only names
format!("skill-{}", &uuid::Uuid::new_v4().to_string()[..8])
} else {
slug
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_candidate() -> SkillCandidate {
SkillCandidate {
name: "每日报表".to_string(),
description: "生成每日报表".to_string(),
triggers: vec!["报表".to_string(), "日报".to_string()],
tools: vec!["researcher".to_string()],
body_markdown: "# 每日报表\n步骤1\n步骤2".to_string(),
source_pattern: "报表生成".to_string(),
confidence: 0.85,
version: 1,
}
}
#[test]
fn test_candidate_to_manifest() {
let candidate = make_candidate();
let manifest = candidate_to_manifest(&candidate);
assert!(manifest.id.as_str().starts_with("auto-"));
assert_eq!(manifest.name, "每日报表");
assert_eq!(manifest.description, "生成每日报表");
assert_eq!(manifest.version, "1");
assert_eq!(manifest.author.as_deref(), Some("zclaw-evolution"));
assert_eq!(manifest.mode, SkillMode::PromptOnly);
assert!(!manifest.enabled, "auto-generated skills must start disabled");
assert_eq!(manifest.triggers, candidate.triggers);
assert_eq!(manifest.tools, candidate.tools);
assert!(manifest.tags.contains(&"auto-generated".to_string()));
}
#[test]
fn test_name_to_slug_ascii() {
assert_eq!(name_to_slug("Daily Report"), "daily-report");
}
#[test]
fn test_name_to_slug_chinese() {
let slug = name_to_slug("每日报表");
assert!(!slug.is_empty());
assert!(!slug.contains(' '));
}
#[test]
fn test_auto_generated_always_prompt_only() {
let candidate = make_candidate();
let manifest = candidate_to_manifest(&candidate);
assert_eq!(manifest.mode, SkillMode::PromptOnly);
}
#[test]
fn test_auto_generated_starts_disabled() {
let candidate = make_candidate();
let manifest = candidate_to_manifest(&candidate);
assert!(!manifest.enabled);
}
}

View File

@@ -13,6 +13,110 @@ pub struct ChatModeConfig {
pub subagent_enabled: Option<bool>,
}
/// Result of a successful schedule intent interception.
pub struct ScheduleInterceptResult {
/// Pre-built streaming receiver with confirmation message.
pub rx: mpsc::Receiver<zclaw_runtime::LoopEvent>,
/// Human-readable task description.
pub task_description: String,
/// Natural language description of the schedule.
pub natural_description: String,
/// Cron expression.
pub cron_expression: String,
}
impl Kernel {
/// Try to intercept a schedule intent from the user's message.
///
/// If the message contains a clear schedule intent (e.g., "每天早上9点提醒我查房"),
/// parse it, create a trigger, and return a streaming receiver with the
/// confirmation message. Returns `Ok(None)` if no interception occurred.
pub async fn try_intercept_schedule(
&self,
message: &str,
agent_id: &AgentId,
) -> Result<Option<ScheduleInterceptResult>> {
if !zclaw_runtime::nl_schedule::has_schedule_intent(message) {
return Ok(None);
}
let parse_result = zclaw_runtime::nl_schedule::parse_nl_schedule(message, agent_id);
match parse_result {
zclaw_runtime::nl_schedule::ScheduleParseResult::Exact(ref parsed)
if parsed.confidence >= 0.8 =>
{
let trigger_id = format!(
"sched_{}_{}",
chrono::Utc::now().timestamp_millis(),
&uuid::Uuid::new_v4().to_string()[..8]
);
let trigger_config = zclaw_hands::TriggerConfig {
id: trigger_id.clone(),
name: parsed.task_description.clone(),
hand_id: "_reminder".to_string(),
trigger_type: zclaw_hands::TriggerType::Schedule {
cron: parsed.cron_expression.clone(),
},
enabled: true,
max_executions_per_hour: 60,
};
match self.create_trigger(trigger_config).await {
Ok(_entry) => {
tracing::info!(
"[Kernel] Schedule trigger created: {} (cron: {})",
trigger_id, parsed.cron_expression
);
let confirm_msg = format!(
"已为您设置定时任务:\n\n- **任务**{}\n- **时间**{}\n- **Cron**`{}`\n\n任务已激活,将在设定时间自动执行。",
parsed.task_description,
parsed.natural_description,
parsed.cron_expression,
);
let (tx, rx) = mpsc::channel(32);
if tx.send(zclaw_runtime::LoopEvent::Delta(confirm_msg)).await.is_err() {
tracing::warn!("[Kernel] Failed to send confirm msg to channel — falling through to LLM");
return Ok(None);
}
if tx.send(zclaw_runtime::LoopEvent::Complete(
zclaw_runtime::AgentLoopResult {
response: String::new(),
input_tokens: 0,
output_tokens: 0,
iterations: 1,
}
)).await.is_err() {
tracing::warn!("[Kernel] Failed to send complete to channel");
}
drop(tx);
Ok(Some(ScheduleInterceptResult {
rx,
task_description: parsed.task_description.clone(),
natural_description: parsed.natural_description.clone(),
cron_expression: parsed.cron_expression.clone(),
}))
}
Err(e) => {
tracing::warn!(
"[Kernel] Failed to create schedule trigger, falling through to LLM: {}", e
);
Ok(None)
}
}
}
_ => {
tracing::debug!(
"[Kernel] Schedule intent detected but not confident enough, falling through to LLM"
);
Ok(None)
}
}
}
}
use zclaw_runtime::{AgentLoop, tool::builtin::PathValidator};
use super::Kernel;
@@ -25,7 +129,7 @@ impl Kernel {
agent_id: &AgentId,
message: String,
) -> Result<MessageResponse> {
self.send_message_with_chat_mode(agent_id, message, None).await
self.send_message_with_chat_mode(agent_id, message, None, None).await
}
/// Send a message to an agent with optional chat mode configuration
@@ -34,6 +138,7 @@ impl Kernel {
agent_id: &AgentId,
message: String,
chat_mode: Option<ChatModeConfig>,
model_override: Option<String>,
) -> Result<MessageResponse> {
let agent_config = self.registry.get(agent_id)
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?;
@@ -41,16 +146,21 @@ impl Kernel {
// Create or get session
let session_id = self.memory.create_session(agent_id).await?;
// Use agent-level model if configured, otherwise fall back to global config
let model = if !agent_config.model.model.is_empty() {
agent_config.model.model.clone()
} else {
self.config.model().to_string()
};
// Model priority: UI override > Agent config > Global config
let model = model_override
.filter(|m| !m.is_empty())
.unwrap_or_else(|| {
if !agent_config.model.model.is_empty() {
agent_config.model.model.clone()
} else {
self.config.model().to_string()
}
});
// Create agent loop with model configuration
let subagent_enabled = chat_mode.as_ref().and_then(|m| m.subagent_enabled).unwrap_or(false);
let tools = self.create_tool_registry(subagent_enabled);
self.skill_executor.set_tool_registry(tools.clone());
let mut loop_runner = AgentLoop::new(
*agent_id,
self.driver.clone(),
@@ -59,6 +169,7 @@ impl Kernel {
)
.with_model(&model)
.with_skill_executor(self.skill_executor.clone())
.with_hand_executor(self.hand_executor.clone())
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()))
.with_compaction_threshold(
@@ -78,10 +189,8 @@ impl Kernel {
loop_runner = loop_runner.with_path_validator(path_validator);
}
// Inject middleware chain if available
if let Some(chain) = self.create_middleware_chain() {
loop_runner = loop_runner.with_middleware_chain(chain);
}
// Inject middleware chain
loop_runner = loop_runner.with_middleware_chain(self.create_middleware_chain());
// Apply chat mode configuration (thinking/reasoning/plan mode)
if let Some(ref mode) = chat_mode {
@@ -122,7 +231,7 @@ impl Kernel {
agent_id: &AgentId,
message: String,
) -> Result<mpsc::Receiver<zclaw_runtime::LoopEvent>> {
self.send_message_stream_with_prompt(agent_id, message, None, None, None).await
self.send_message_stream_with_prompt(agent_id, message, None, None, None, None).await
}
/// Send a message with streaming, optional system prompt, optional session reuse,
@@ -134,6 +243,7 @@ impl Kernel {
system_prompt_override: Option<String>,
session_id_override: Option<zclaw_types::SessionId>,
chat_mode: Option<ChatModeConfig>,
model_override: Option<String>,
) -> Result<mpsc::Receiver<zclaw_runtime::LoopEvent>> {
let agent_config = self.registry.get(agent_id)
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Agent not found: {}", agent_id)))?;
@@ -150,16 +260,21 @@ impl Kernel {
None => self.memory.create_session(agent_id).await?,
};
// Use agent-level model if configured, otherwise fall back to global config
let model = if !agent_config.model.model.is_empty() {
agent_config.model.model.clone()
} else {
self.config.model().to_string()
};
// Model priority: UI override > Agent config > Global config
let model = model_override
.filter(|m| !m.is_empty())
.unwrap_or_else(|| {
if !agent_config.model.model.is_empty() {
agent_config.model.model.clone()
} else {
self.config.model().to_string()
}
});
// Create agent loop with model configuration
let subagent_enabled = chat_mode.as_ref().and_then(|m| m.subagent_enabled).unwrap_or(false);
let tools = self.create_tool_registry(subagent_enabled);
self.skill_executor.set_tool_registry(tools.clone());
let mut loop_runner = AgentLoop::new(
*agent_id,
self.driver.clone(),
@@ -168,6 +283,7 @@ impl Kernel {
)
.with_model(&model)
.with_skill_executor(self.skill_executor.clone())
.with_hand_executor(self.hand_executor.clone())
.with_max_tokens(agent_config.max_tokens.unwrap_or_else(|| self.config.max_tokens()))
.with_temperature(agent_config.temperature.unwrap_or_else(|| self.config.temperature()))
.with_compaction_threshold(
@@ -188,10 +304,8 @@ impl Kernel {
loop_runner = loop_runner.with_path_validator(path_validator);
}
// Inject middleware chain if available
if let Some(chain) = self.create_middleware_chain() {
loop_runner = loop_runner.with_middleware_chain(chain);
}
// Inject middleware chain
loop_runner = loop_runner.with_middleware_chain(self.create_middleware_chain());
// Apply chat mode configuration (thinking/reasoning/plan mode from frontend)
if let Some(ref mode) = chat_mode {
@@ -312,6 +426,7 @@ impl Kernel {
prompt.push_str("- Provide clear options when possible\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("- 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
}

View File

@@ -8,16 +8,14 @@ mod hands;
mod triggers;
mod approvals;
mod orchestration;
#[cfg(feature = "multi-agent")]
mod a2a;
mod evolution_bridge;
use std::sync::Arc;
use tokio::sync::{broadcast, Mutex};
use zclaw_types::{Event, Result, AgentState};
#[cfg(feature = "multi-agent")]
use zclaw_types::AgentId;
#[cfg(feature = "multi-agent")]
use zclaw_protocols::A2aRouter;
use crate::registry::AgentRegistry;
@@ -27,10 +25,12 @@ use crate::config::KernelConfig;
use zclaw_memory::MemoryStore;
use zclaw_runtime::{LlmDriver, ToolRegistry, tool::SkillExecutor};
use zclaw_skills::SkillRegistry;
use zclaw_hands::{HandRegistry, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, quiz::LlmQuizGenerator}};
use zclaw_hands::{HandRegistry, hands::{BrowserHand, QuizHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, ReminderHand, DailyReportHand, quiz::LlmQuizGenerator}};
pub use adapters::KernelSkillExecutor;
pub use adapters::KernelHandExecutor;
pub use messaging::ChatModeConfig;
pub use messaging::ScheduleInterceptResult;
/// The ZCLAW Kernel
pub struct Kernel {
@@ -43,20 +43,29 @@ pub struct Kernel {
llm_completer: Arc<dyn zclaw_skills::LlmCompleter>,
skills: Arc<SkillRegistry>,
skill_executor: Arc<KernelSkillExecutor>,
hand_executor: Arc<KernelHandExecutor>,
hands: Arc<HandRegistry>,
/// Cached hand configs (populated at boot, used for tool registry)
hand_configs: Vec<zclaw_hands::HandConfig>,
trigger_manager: crate::trigger_manager::TriggerManager,
pending_approvals: Arc<Mutex<Vec<ApprovalEntry>>>,
/// Running hand runs that can be cancelled (run_id -> cancelled flag)
running_hand_runs: Arc<dashmap::DashMap<zclaw_types::HandRunId, Arc<std::sync::atomic::AtomicBool>>>,
/// Shared memory storage backend for Growth system
viking: Arc<zclaw_runtime::VikingAdapter>,
/// Cached GrowthIntegration — avoids recreating empty scorer per request
growth: std::sync::Mutex<Option<std::sync::Arc<zclaw_runtime::GrowthIntegration>>>,
/// Optional LLM driver for memory extraction (set by Tauri desktop layer)
extraction_driver: Option<Arc<dyn zclaw_runtime::LlmDriverForExtraction>>,
/// A2A router for inter-agent messaging (gated by multi-agent feature)
#[cfg(feature = "multi-agent")]
/// Optional embedding client for semantic search (set by Tauri desktop layer)
embedding_client: Option<Arc<dyn zclaw_runtime::EmbeddingClient>>,
/// MCP tool adapters — shared with Tauri MCP manager, updated dynamically
mcp_adapters: Arc<std::sync::RwLock<Vec<zclaw_protocols::McpToolAdapter>>>,
/// Dynamic industry keyword configs — shared with Tauri frontend, loaded from SaaS
industry_keywords: Arc<tokio::sync::RwLock<Vec<zclaw_runtime::IndustryKeywordConfig>>>,
/// A2A router for inter-agent messaging
a2a_router: Arc<A2aRouter>,
/// Per-agent A2A inbox receivers (supports re-queuing non-matching messages)
#[cfg(feature = "multi-agent")]
a2a_inboxes: Arc<dashmap::DashMap<AgentId, Arc<Mutex<adapters::AgentInbox>>>>,
}
@@ -89,18 +98,23 @@ impl Kernel {
let quiz_model = config.model().to_string();
let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model));
hands.register(Arc::new(BrowserHand::new())).await;
hands.register(Arc::new(SlideshowHand::new())).await;
hands.register(Arc::new(SpeechHand::new())).await;
hands.register(Arc::new(QuizHand::with_generator(quiz_generator))).await;
hands.register(Arc::new(WhiteboardHand::new())).await;
hands.register(Arc::new(ResearcherHand::new())).await;
hands.register(Arc::new(CollectorHand::new())).await;
hands.register(Arc::new(ClipHand::new())).await;
hands.register(Arc::new(TwitterHand::new())).await;
hands.register(Arc::new(ReminderHand::new())).await;
hands.register(Arc::new(DailyReportHand::new())).await;
// Cache hand configs for tool registry (sync access from create_tool_registry)
let hand_configs = hands.list().await;
// Create skill executor
let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone()));
// Create hand executor — bridges HandTool calls to the HandRegistry
let hand_executor = Arc::new(KernelHandExecutor::new(hands.clone()));
// Create LLM completer for skill system (shared with skill_executor)
let llm_completer: Arc<dyn zclaw_skills::LlmCompleter> =
Arc::new(adapters::LlmDriverAdapter {
@@ -133,7 +147,6 @@ impl Kernel {
}
// Initialize A2A router for multi-agent support
#[cfg(feature = "multi-agent")]
let a2a_router = {
let kernel_agent_id = AgentId::new();
Arc::new(A2aRouter::new(kernel_agent_id))
@@ -149,20 +162,106 @@ impl Kernel {
llm_completer,
skills,
skill_executor,
hand_executor,
hands,
hand_configs,
trigger_manager,
pending_approvals: Arc::new(Mutex::new(Vec::new())),
running_hand_runs: Arc::new(dashmap::DashMap::new()),
viking,
growth: std::sync::Mutex::new(None),
extraction_driver: None,
#[cfg(feature = "multi-agent")]
embedding_client: None,
mcp_adapters: Arc::new(std::sync::RwLock::new(Vec::new())),
industry_keywords: Arc::new(tokio::sync::RwLock::new(Vec::new())),
a2a_router,
#[cfg(feature = "multi-agent")]
a2a_inboxes: Arc::new(dashmap::DashMap::new()),
})
}
/// Create a tool registry with built-in tools.
/// Boot the kernel with a pre-configured driver (for testing).
///
/// **TEST ONLY.** Do not call from production code.
///
/// Differences from `boot()`:
/// - Uses the provided `driver` instead of `config.create_driver()`
/// - Uses an in-memory SQLite database (no filesystem side effects)
/// - Skips agent recovery from persistent storage (`memory.list_agents_with_runtime()`)
pub async fn boot_with_driver(
config: KernelConfig,
driver: Arc<dyn LlmDriver>,
) -> Result<Self> {
let memory = Arc::new(MemoryStore::new("sqlite::memory:").await?);
let registry = AgentRegistry::new();
let capabilities = CapabilityManager::new();
let events = EventBus::new();
let skills = Arc::new(SkillRegistry::new());
if let Some(ref skills_dir) = config.skills_dir {
if skills_dir.exists() {
skills.add_skill_dir(skills_dir.clone()).await?;
}
}
let hands = Arc::new(HandRegistry::new());
let quiz_model = config.model().to_string();
let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model));
hands.register(Arc::new(BrowserHand::new())).await;
hands.register(Arc::new(QuizHand::with_generator(quiz_generator))).await;
hands.register(Arc::new(ResearcherHand::new())).await;
hands.register(Arc::new(CollectorHand::new())).await;
hands.register(Arc::new(ClipHand::new())).await;
hands.register(Arc::new(TwitterHand::new())).await;
hands.register(Arc::new(ReminderHand::new())).await;
hands.register(Arc::new(DailyReportHand::new())).await;
let hand_configs = hands.list().await;
let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone()));
let hand_executor = Arc::new(KernelHandExecutor::new(hands.clone()));
let llm_completer: Arc<dyn zclaw_skills::LlmCompleter> =
Arc::new(adapters::LlmDriverAdapter {
driver: driver.clone(),
max_tokens: config.max_tokens(),
temperature: config.temperature(),
});
let trigger_manager = crate::trigger_manager::TriggerManager::new(hands.clone());
let viking = Arc::new(zclaw_runtime::VikingAdapter::in_memory());
let a2a_router = {
let kernel_agent_id = AgentId::new();
Arc::new(A2aRouter::new(kernel_agent_id))
};
Ok(Self {
config,
registry,
capabilities,
events,
memory,
driver,
llm_completer,
skills,
skill_executor,
hand_executor,
hands,
hand_configs,
trigger_manager,
pending_approvals: Arc::new(Mutex::new(Vec::new())),
running_hand_runs: Arc::new(dashmap::DashMap::new()),
viking,
growth: std::sync::Mutex::new(None),
extraction_driver: None,
embedding_client: None,
mcp_adapters: Arc::new(std::sync::RwLock::new(Vec::new())),
industry_keywords: Arc::new(tokio::sync::RwLock::new(Vec::new())),
a2a_router,
a2a_inboxes: Arc::new(dashmap::DashMap::new()),
})
}
/// Create a tool registry with built-in tools + Hand tools + MCP tools.
/// When `subagent_enabled` is false, TaskTool is excluded to prevent
/// the LLM from attempting sub-agent delegation in non-Ultra modes.
pub(crate) fn create_tool_registry(&self, subagent_enabled: bool) -> ToolRegistry {
@@ -179,6 +278,30 @@ impl Kernel {
tools.register(Box::new(task_tool));
}
// Register Hand tools — expose registered Hands as LLM-callable tools
// (e.g., hand_quiz, hand_researcher, hand_browser, etc.)
for config in &self.hand_configs {
if !config.enabled {
continue;
}
let tool = zclaw_runtime::tool::hand_tool::HandTool::from_config(
&config.id,
&config.description,
config.input_schema.clone(),
);
tools.register(Box::new(tool));
}
// Register MCP tools (dynamically updated by Tauri MCP manager)
if let Ok(adapters) = self.mcp_adapters.read() {
for adapter in adapters.iter() {
let wrapper = zclaw_runtime::tool::builtin::McpToolWrapper::new(
std::sync::Arc::new(adapter.clone())
);
tools.register(Box::new(wrapper));
}
}
tools
}
@@ -187,29 +310,90 @@ impl Kernel {
/// When middleware is configured, cross-cutting concerns (compaction, loop guard,
/// token calibration, etc.) are delegated to the chain. When no middleware is
/// registered, the legacy inline path in `AgentLoop` is used instead.
pub(crate) fn create_middleware_chain(&self) -> Option<zclaw_runtime::middleware::MiddlewareChain> {
pub(crate) fn create_middleware_chain(&self) -> zclaw_runtime::middleware::MiddlewareChain {
let mut chain = zclaw_runtime::middleware::MiddlewareChain::new();
// Butler router — semantic skill routing context injection
{
use std::sync::Arc;
let mw = zclaw_runtime::middleware::butler_router::ButlerRouterMiddleware::new();
use zclaw_runtime::middleware::butler_router::{ButlerRouterBackend, RoutingHint};
use async_trait::async_trait;
use zclaw_skills::semantic_router::SemanticSkillRouter;
/// Adapter bridging `SemanticSkillRouter` (zclaw-skills) to `ButlerRouterBackend`.
/// Lives here in kernel because kernel depends on both zclaw-runtime and zclaw-skills.
struct SemanticRouterAdapter {
router: Arc<SemanticSkillRouter>,
}
impl SemanticRouterAdapter {
fn new(router: Arc<SemanticSkillRouter>) -> Self {
Self { router }
}
}
#[async_trait]
impl ButlerRouterBackend for SemanticRouterAdapter {
async fn classify(&self, query: &str) -> Option<RoutingHint> {
let result: Option<_> = self.router.route(query).await;
result.map(|r| RoutingHint {
category: "semantic_skill".to_string(),
confidence: r.confidence,
skill_id: Some(r.skill_id),
domain_prompt: None,
})
}
}
// Build semantic router from the skill registry (75 SKILL.md loaded at boot)
let semantic_router = if let Some(ref embed_client) = self.embedding_client {
let adapter = crate::skill_router::EmbeddingAdapter::new(embed_client.clone());
let mut router = SemanticSkillRouter::new(self.skills.clone(), Arc::new(adapter));
if let Some(llm_fallback) = self.make_llm_skill_fallback() {
router = router.with_llm_fallback(llm_fallback);
}
tracing::debug!("[Kernel] SemanticSkillRouter created with embedding support");
router
} else {
SemanticSkillRouter::new_tf_idf_only(self.skills.clone())
};
let adapter = SemanticRouterAdapter::new(Arc::new(semantic_router));
let mw = zclaw_runtime::middleware::butler_router::ButlerRouterMiddleware::with_router_and_shared_keywords(
Box::new(adapter),
self.industry_keywords.clone(),
);
chain.register(Arc::new(mw));
}
// Data masking middleware — mask sensitive entities before any other processing
{
use std::sync::Arc;
let masker = Arc::new(zclaw_runtime::middleware::data_masking::DataMasker::new());
let mw = zclaw_runtime::middleware::data_masking::DataMaskingMiddleware::new(masker);
chain.register(Arc::new(mw));
}
// Growth integration — cached to avoid recreating empty scorer per request
let growth = {
let mut cached = self.growth.lock().expect("growth lock");
if cached.is_none() {
let mut g = zclaw_runtime::GrowthIntegration::new(self.viking.clone());
if let Some(ref driver) = self.extraction_driver {
g = g.with_llm_driver(driver.clone());
}
// Propagate embedding client to memory retriever if configured
if let Some(ref embed_client) = self.embedding_client {
g.configure_embedding(embed_client.clone());
}
// Bridge UserProfileStore so extract_combined() can persist profile signals
{
let profile_store = zclaw_memory::UserProfileStore::new(self.memory.pool());
g = g.with_profile_store(std::sync::Arc::new(profile_store));
tracing::info!("[Kernel] UserProfileStore bridged to GrowthIntegration");
}
*cached = Some(std::sync::Arc::new(g));
}
cached.as_ref().expect("growth present").clone()
};
// Growth integration — shared VikingAdapter for memory middleware & compaction
let mut growth = zclaw_runtime::GrowthIntegration::new(self.viking.clone());
if let Some(ref driver) = self.extraction_driver {
growth = growth.with_llm_driver(driver.clone());
}
// Evolution middleware — pushes evolution candidate skills into system prompt
// priority=78, executed first by chain (before ButlerRouter@80)
let evolution_mw = std::sync::Arc::new(
zclaw_runtime::middleware::evolution::EvolutionMiddleware::new()
);
chain.register(evolution_mw.clone());
// Compaction middleware — only register when threshold > 0
let threshold = self.config.compaction_threshold();
@@ -219,6 +403,9 @@ impl Kernel {
if let Some(ref driver) = self.extraction_driver {
growth_for_compaction = growth_for_compaction.with_llm_driver(driver.clone());
}
if let Some(ref embed_client) = self.embedding_client {
growth_for_compaction.configure_embedding(embed_client.clone());
}
let mw = zclaw_runtime::middleware::compaction::CompactionMiddleware::new(
threshold,
zclaw_runtime::CompactionConfig::default(),
@@ -228,10 +415,11 @@ impl Kernel {
chain.register(Arc::new(mw));
}
// Memory middleware — auto-extract memories after conversations
// Memory middleware — auto-extract memories + check evolution after conversations
{
use std::sync::Arc;
let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth);
let mw = zclaw_runtime::middleware::memory::MemoryMiddleware::new(growth.clone())
.with_evolution(evolution_mw);
chain.register(Arc::new(mw));
}
@@ -302,13 +490,19 @@ impl Kernel {
chain.register(Arc::new(mw));
}
// Only return Some if we actually registered middleware
if chain.is_empty() {
None
} else {
tracing::info!("[Kernel] Middleware chain created with {} middlewares", chain.len());
Some(chain)
// Trajectory recorder — record agent loop events for Hermes analysis
{
use std::sync::Arc;
let tstore = zclaw_memory::trajectory_store::TrajectoryStore::new(self.memory.pool());
let mw = zclaw_runtime::middleware::trajectory_recorder::TrajectoryRecorderMiddleware::new(Arc::new(tstore));
chain.register(Arc::new(mw));
}
// Always return the chain (empty chain is a no-op)
if !chain.is_empty() {
tracing::info!("[Kernel] Middleware chain created with {} middlewares", chain.len());
}
chain
}
/// Subscribe to events
@@ -357,6 +551,10 @@ impl Kernel {
pub fn set_viking(&mut self, viking: Arc<zclaw_runtime::VikingAdapter>) {
tracing::info!("[Kernel] Replacing in-memory VikingAdapter with persistent storage");
self.viking = viking;
// Invalidate cached GrowthIntegration so next request builds with new storage
if let Ok(mut g) = self.growth.lock() {
*g = None;
}
}
/// Get a reference to the shared VikingAdapter
@@ -364,6 +562,11 @@ impl Kernel {
self.viking.clone()
}
/// Get a reference to the shared MemoryStore
pub fn memory(&self) -> Arc<MemoryStore> {
self.memory.clone()
}
/// Set the LLM extraction driver for the Growth system.
///
/// Required for `MemoryMiddleware` to extract memories from conversations
@@ -371,6 +574,56 @@ impl Kernel {
pub fn set_extraction_driver(&mut self, driver: Arc<dyn zclaw_runtime::LlmDriverForExtraction>) {
tracing::info!("[Kernel] Extraction driver configured for Growth system");
self.extraction_driver = Some(driver);
// Invalidate cached GrowthIntegration so next request uses new driver
if let Ok(mut g) = self.growth.lock() {
*g = None;
}
}
/// Set the embedding client for semantic search.
///
/// Propagates to both the skill router (ButlerRouter) and memory retrieval
/// (GrowthIntegration). The next middleware chain creation will use the
/// configured client for embedding-based similarity.
pub fn set_embedding_client(&mut self, client: Arc<dyn zclaw_runtime::EmbeddingClient>) {
tracing::info!("[Kernel] Embedding client configured for semantic search");
self.embedding_client = Some(client);
// Invalidate cached GrowthIntegration so next request builds with new embedding
if let Ok(mut g) = self.growth.lock() {
*g = None;
}
}
/// Create an LLM skill fallback using the kernel's LLM driver.
fn make_llm_skill_fallback(&self) -> Option<Arc<dyn zclaw_skills::semantic_router::RuntimeLlmIntent>> {
Some(Arc::new(crate::skill_router::LlmSkillFallback::new(self.driver.clone())))
}
/// Get a reference to the shared MCP adapters list.
///
/// The Tauri MCP manager updates this list when services start/stop.
/// The kernel reads it during `create_tool_registry()` to inject MCP tools
/// into the LLM's available tools.
pub fn mcp_adapters(&self) -> Arc<std::sync::RwLock<Vec<zclaw_protocols::McpToolAdapter>>> {
self.mcp_adapters.clone()
}
/// Replace the MCP adapters with a shared Arc (from Tauri MCP manager).
///
/// Call this after boot to connect the kernel to the Tauri MCP manager's
/// adapter list. After this, MCP service start/stop will automatically
/// be reflected in the LLM's available tools.
pub fn set_mcp_adapters(&mut self, adapters: Arc<std::sync::RwLock<Vec<zclaw_protocols::McpToolAdapter>>>) {
tracing::info!("[Kernel] MCP adapters bridge connected");
self.mcp_adapters = adapters;
}
/// Get a reference to the shared industry keywords config.
///
/// The Tauri frontend updates this list when industry configs are fetched from SaaS.
/// The ButlerRouterMiddleware reads from the same Arc, so updates are automatic.
pub fn industry_keywords(&self) -> Arc<tokio::sync::RwLock<Vec<zclaw_runtime::IndustryKeywordConfig>>> {
self.industry_keywords.clone()
}
}

View File

@@ -76,4 +76,77 @@ impl Kernel {
}
self.skills.execute(&zclaw_types::SkillId::new(id), &ctx, input).await
}
/// Generate a skill from an aggregated pattern and register it.
///
/// Full pipeline:
/// 1. Build LLM prompt from pattern
/// 2. Call LLM to get JSON response
/// 3. Parse response into SkillCandidate
/// 4. Validate through QualityGate (threshold 0.85 for auto-mode)
/// 5. Convert to SkillManifest (PromptOnly, disabled by default)
/// 6. Persist to disk via SkillRegistry
pub async fn generate_and_register_skill(
&self,
pattern: &zclaw_growth::pattern_aggregator::AggregatedPattern,
) -> Result<String> {
// 1. Build prompt
let prompt = zclaw_growth::skill_generator::SkillGenerator::build_prompt(pattern);
// 2. Call LLM
let request = zclaw_runtime::driver::CompletionRequest {
model: self.driver.provider().to_string(),
system: Some("你是技能设计专家,只返回 JSON 格式的技能定义。".to_string()),
messages: vec![zclaw_types::Message::user(prompt)],
max_tokens: Some(1024),
temperature: Some(0.3),
stream: false,
..Default::default()
};
let response = self.driver.complete(request).await?;
let text = response.content.iter()
.filter_map(|block| match block {
zclaw_runtime::driver::ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
// 3. Parse into SkillCandidate
let candidate = zclaw_growth::skill_generator::SkillGenerator::parse_response(
&text, pattern,
)?;
// 4. Validate through QualityGate (higher threshold for auto-generation)
let existing_triggers: Vec<String> = self.skills.list().await
.into_iter()
.flat_map(|m| m.triggers)
.collect();
let gate = zclaw_growth::quality_gate::QualityGate::new(0.85, existing_triggers);
let report = gate.validate_skill(&candidate);
if !report.passed {
return Err(zclaw_types::ZclawError::ConfigError(format!(
"QualityGate rejected: {}", report.issues.join("; ")
)));
}
// 5. Convert to SkillManifest (PromptOnly, disabled)
let manifest = super::evolution_bridge::candidate_to_manifest(&candidate);
let skill_id = manifest.id.to_string();
// 6. Persist to disk
let skills_dir = self.config.skills_dir.as_ref()
.ok_or_else(|| zclaw_types::ZclawError::InvalidInput(
"Skills directory not configured".into()
))?;
self.skills.create_skill(skills_dir, manifest).await?;
tracing::info!(
"[Kernel] Auto-generated skill '{}' (id={}) registered (disabled)",
candidate.name, skill_id
);
Ok(skill_id)
}
}

View File

@@ -10,7 +10,6 @@ pub mod trigger_manager;
pub mod config;
pub mod scheduler;
pub mod skill_router;
#[cfg(feature = "multi-agent")]
pub mod director;
pub mod generation;
pub mod export;
@@ -21,13 +20,11 @@ pub use capabilities::*;
pub use events::*;
pub use config::*;
pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig};
#[cfg(feature = "multi-agent")]
pub use director::{
Director, DirectorConfig, DirectorBuilder, DirectorAgent,
ConversationState, ScheduleStrategy,
// Note: AgentRole is intentionally NOT re-exported here — use generation::AgentRole instead
};
#[cfg(feature = "multi-agent")]
pub use zclaw_protocols::{
A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient,
A2aReceiver,

View File

@@ -85,6 +85,7 @@ impl AgentRegistry {
system_prompt: config.system_prompt.clone(),
temperature: config.temperature,
max_tokens: config.max_tokens,
user_profile: None,
})
}

View File

@@ -77,7 +77,7 @@ impl SchedulerService {
kernel_lock: &Arc<Mutex<Option<Kernel>>>,
) -> Result<()> {
// Collect due triggers under lock
let to_execute: Vec<(String, String, String)> = {
let to_execute: Vec<(String, String, String, String)> = {
let kernel_guard = kernel_lock.lock().await;
let kernel = match kernel_guard.as_ref() {
Some(k) => k,
@@ -103,7 +103,8 @@ impl SchedulerService {
.filter_map(|t| {
if let zclaw_hands::TriggerType::Schedule { ref cron } = t.config.trigger_type {
if Self::should_fire_cron(cron, &now) {
Some((t.config.id.clone(), t.config.hand_id.clone(), cron.clone()))
// (trigger_id, hand_id, cron_expr, trigger_name)
Some((t.config.id.clone(), t.config.hand_id.clone(), cron.clone(), t.config.name.clone()))
} else {
None
}
@@ -123,7 +124,7 @@ impl SchedulerService {
// If parallel execution is needed, spawn each execute_hand in a separate task
// and collect results via JoinSet.
let now = chrono::Utc::now();
for (trigger_id, hand_id, cron_expr) in to_execute {
for (trigger_id, hand_id, cron_expr, trigger_name) in to_execute {
tracing::info!(
"[Scheduler] Firing scheduled trigger '{}' → hand '{}' (cron: {})",
trigger_id, hand_id, cron_expr
@@ -138,6 +139,7 @@ impl SchedulerService {
let input = serde_json::json!({
"trigger_id": trigger_id,
"trigger_type": "schedule",
"task_description": trigger_name,
"cron": cron_expr,
"fired_at": now.to_rfc3339(),
});

View File

@@ -134,7 +134,9 @@ impl TriggerManager {
/// Create a new trigger
pub async fn create_trigger(&self, config: TriggerConfig) -> Result<TriggerEntry> {
// Validate hand exists (outside of our lock to avoid holding two locks)
if self.hand_registry.get(&config.hand_id).await.is_none() {
// System hands (prefixed with '_') are exempt from validation — they are
// registered at boot but may not appear in the hand registry scan path.
if !config.hand_id.starts_with('_') && self.hand_registry.get(&config.hand_id).await.is_none() {
return Err(zclaw_types::ZclawError::InvalidInput(
format!("Hand '{}' not found", config.hand_id)
));
@@ -170,7 +172,7 @@ impl TriggerManager {
) -> Result<TriggerEntry> {
// Validate hand exists if being updated (outside of our lock)
if let Some(hand_id) = &updates.hand_id {
if self.hand_registry.get(hand_id).await.is_none() {
if !hand_id.starts_with('_') && self.hand_registry.get(hand_id).await.is_none() {
return Err(zclaw_types::ZclawError::InvalidInput(
format!("Hand '{}' not found", hand_id)
));
@@ -303,9 +305,10 @@ impl TriggerManager {
};
// Get hand (outside of our lock to avoid potential deadlock with hand_registry)
// System hands (prefixed with '_') must be registered at boot — same rule as create_trigger.
let hand = self.hand_registry.get(&hand_id).await
.ok_or_else(|| zclaw_types::ZclawError::InvalidInput(
format!("Hand '{}' not found", hand_id)
format!("Hand '{}' not found (system hands must be registered at boot)", hand_id)
))?;
// Update state before execution

View File

@@ -0,0 +1,143 @@
//! Conversation chain seam tests
//!
//! Verifies the integration seams between layers in the chat pipeline:
//! 1. Tauri→Kernel: chat command correctly forwards to kernel
//! 2. Kernel→LLM: middleware-processed prompt reaches MockLlmDriver
//! 3. LLM→UI: event ordering is delta → delta → complete
//! 4. Streaming: full send→stream→complete lifecycle
use std::sync::Arc;
use zclaw_kernel::{Kernel, KernelConfig};
use zclaw_runtime::test_util::MockLlmDriver;
use zclaw_runtime::{LoopEvent, LlmDriver};
use zclaw_types::AgentConfig;
/// Create a test kernel with MockLlmDriver and a registered agent.
/// The mock is pre-configured with a default text response.
async fn test_kernel() -> (Kernel, zclaw_types::AgentId) {
let mock = MockLlmDriver::new().with_text_response("Hello from mock!");
let config = KernelConfig::default();
let kernel = Kernel::boot_with_driver(config, Arc::new(mock) as Arc<dyn LlmDriver>)
.await
.expect("kernel boot");
let agent_config = AgentConfig::new("test-agent")
.with_system_prompt("You are a test assistant.");
let id = agent_config.id;
kernel.spawn_agent(agent_config).await.expect("spawn agent");
(kernel, id)
}
// ---------------------------------------------------------------------------
// Seam 1: Tauri → Kernel (non-streaming)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_tauri_to_kernel_non_streaming() {
let (kernel, agent_id) = test_kernel().await;
let result = kernel
.send_message(&agent_id, "Hi".to_string())
.await
.expect("send_message");
assert!(!result.content.is_empty(), "response content should not be empty");
}
// ---------------------------------------------------------------------------
// Seam 2: Kernel → LLM (middleware processes prompt before reaching driver)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_kernel_to_llm_prompt_reaches_driver() {
let (kernel, agent_id) = test_kernel().await;
let _ = kernel
.send_message(&agent_id, "What is 2+2?".to_string())
.await;
// Verify the kernel's driver was called by checking a second call succeeds
let result2 = kernel
.send_message(&agent_id, "And 3+3?".to_string())
.await
.expect("second send_message");
assert!(!result2.content.is_empty(), "second response should not be empty");
}
// ---------------------------------------------------------------------------
// Seam 3: LLM → UI event ordering (delta → delta → complete)
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_llm_to_ui_event_ordering() {
let (kernel, agent_id) = test_kernel().await;
let mut rx = kernel
.send_message_stream(&agent_id, "Hi".to_string())
.await
.expect("send_message_stream");
let mut events = Vec::new();
while let Some(event) = rx.recv().await {
match &event {
LoopEvent::Delta(_) => events.push("delta"),
LoopEvent::ThinkingDelta(_) => events.push("thinking"),
LoopEvent::Complete(_) => {
events.push("complete");
break;
}
LoopEvent::Error(msg) => {
panic!("unexpected error: {}", msg);
}
LoopEvent::ToolStart { .. } => events.push("tool_start"),
LoopEvent::ToolEnd { .. } => events.push("tool_end"),
LoopEvent::SubtaskStatus { .. } => events.push("subtask"),
LoopEvent::IterationStart { .. } => events.push("iteration"),
}
}
assert!(!events.is_empty(), "should receive events");
assert_eq!(events.last(), Some(&"complete"), "last event must be complete");
assert!(
events.iter().any(|e| *e == "delta"),
"should have at least one delta event"
);
}
// ---------------------------------------------------------------------------
// Seam 4: Full streaming lifecycle with consecutive messages
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_streaming_consecutive_messages() {
let (kernel, agent_id) = test_kernel().await;
// First message
let mut rx1 = kernel
.send_message_stream(&agent_id, "First message".to_string())
.await
.expect("first stream");
while let Some(event) = rx1.recv().await {
if let LoopEvent::Complete(result) = event {
assert!(result.output_tokens > 0, "first response should have output tokens");
}
}
// Second message (should use new session)
let mut rx2 = kernel
.send_message_stream(&agent_id, "Second message".to_string())
.await
.expect("second stream");
let mut got_complete = false;
while let Some(event) = rx2.recv().await {
if let LoopEvent::Complete(result) = event {
got_complete = true;
assert!(result.output_tokens > 0, "second response should have output tokens");
}
}
assert!(got_complete, "second stream should complete");
}

View File

@@ -0,0 +1,224 @@
//! Hands chain seam tests
//!
//! Verifies the integration seams in the Hand execution pipeline:
//! 1. Tool routing: LLM tool_call → HandRegistry correct dispatch
//! 2. Execution callback: Hand complete → LoopEvent emitted
//! 3. Non-hand tool routing
use std::sync::Arc;
use zclaw_kernel::{Kernel, KernelConfig};
use zclaw_runtime::test_util::MockLlmDriver;
use zclaw_runtime::stream::StreamChunk;
use zclaw_runtime::{LoopEvent, LlmDriver};
use zclaw_types::AgentConfig;
// ---------------------------------------------------------------------------
// Seam 1: Tool routing — LLM tool_call triggers HandTool dispatch
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_hand_tool_routing() {
// First stream: tool_use for hand_quiz
let mock = MockLlmDriver::new()
.with_stream_chunks(vec![
StreamChunk::TextDelta { delta: "Let me generate a quiz.".to_string() },
StreamChunk::ToolUseStart { id: "call_quiz_1".to_string(), name: "hand_quiz".to_string() },
StreamChunk::ToolUseEnd {
id: "call_quiz_1".to_string(),
input: serde_json::json!({ "topic": "math", "count": 3 }),
},
StreamChunk::Complete {
input_tokens: 10,
output_tokens: 20,
stop_reason: "tool_use".to_string(),
},
])
// Second stream: final text after tool executes
.with_stream_chunks(vec![
StreamChunk::TextDelta { delta: "Here is your quiz!".to_string() },
StreamChunk::Complete {
input_tokens: 10,
output_tokens: 5,
stop_reason: "end_turn".to_string(),
},
]);
let config = KernelConfig::default();
let kernel = Kernel::boot_with_driver(config, Arc::new(mock) as Arc<dyn LlmDriver>)
.await
.expect("kernel boot");
let agent_config = AgentConfig::new("test-agent")
.with_system_prompt("You are a test assistant.");
let id = agent_config.id;
kernel.spawn_agent(agent_config).await.expect("spawn agent");
let mut rx = kernel
.send_message_stream(&id, "Generate a math quiz".to_string())
.await
.expect("stream");
let mut tool_starts = Vec::new();
let mut tool_ends = Vec::new();
let mut got_complete = false;
while let Some(event) = rx.recv().await {
match &event {
LoopEvent::ToolStart { name, input } => {
tool_starts.push((name.clone(), input.clone()));
}
LoopEvent::ToolEnd { name, output } => {
tool_ends.push((name.clone(), output.clone()));
}
LoopEvent::Complete(_) => {
got_complete = true;
break;
}
LoopEvent::Error(msg) => {
panic!("unexpected error: {}", msg);
}
_ => {}
}
}
assert!(got_complete, "stream should complete");
assert!(
tool_starts.iter().any(|(n, _)| n == "hand_quiz"),
"should see hand_quiz tool_start, got: {:?}",
tool_starts
);
}
// ---------------------------------------------------------------------------
// Seam 2: Execution callback — Hand completes and produces tool_end
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_hand_execution_callback() {
let mock = MockLlmDriver::new()
.with_stream_chunks(vec![
StreamChunk::ToolUseStart { id: "call_quiz_1".to_string(), name: "hand_quiz".to_string() },
StreamChunk::ToolUseEnd {
id: "call_quiz_1".to_string(),
input: serde_json::json!({ "topic": "math" }),
},
StreamChunk::Complete {
input_tokens: 10,
output_tokens: 5,
stop_reason: "tool_use".to_string(),
},
])
.with_stream_chunks(vec![
StreamChunk::TextDelta { delta: "Done!".to_string() },
StreamChunk::Complete {
input_tokens: 5,
output_tokens: 1,
stop_reason: "end_turn".to_string(),
},
]);
let config = KernelConfig::default();
let kernel = Kernel::boot_with_driver(config, Arc::new(mock) as Arc<dyn LlmDriver>)
.await
.expect("kernel boot");
let agent_config = AgentConfig::new("test-agent");
let id = agent_config.id;
kernel.spawn_agent(agent_config).await.expect("spawn agent");
let mut rx = kernel
.send_message_stream(&id, "Quiz me".to_string())
.await
.expect("stream");
let mut got_tool_end = false;
let mut got_complete = false;
while let Some(event) = rx.recv().await {
match &event {
LoopEvent::ToolEnd { name, output } => {
got_tool_end = true;
assert!(name.starts_with("hand_"), "tool_end should be hand tool, got: {}", name);
// Quiz hand returns structured JSON output
assert!(output.is_object() || output.is_string(), "output should be JSON, got: {}", output);
}
LoopEvent::Complete(_) => {
got_complete = true;
break;
}
LoopEvent::Error(msg) => {
panic!("unexpected error: {}", msg);
}
_ => {}
}
}
assert!(got_tool_end, "should receive tool_end after hand execution");
assert!(got_complete, "should complete after tool_end");
}
// ---------------------------------------------------------------------------
// Seam 3: Non-hand tool call (generic tool) routes correctly
// ---------------------------------------------------------------------------
#[tokio::test]
async fn seam_generic_tool_routing() {
// Mock with a generic tool call (web_search)
let mock = MockLlmDriver::new()
.with_stream_chunks(vec![
StreamChunk::ToolUseStart { id: "call_ws_1".to_string(), name: "web_search".to_string() },
StreamChunk::ToolUseEnd {
id: "call_ws_1".to_string(),
input: serde_json::json!({ "query": "test query" }),
},
StreamChunk::Complete {
input_tokens: 10,
output_tokens: 5,
stop_reason: "tool_use".to_string(),
},
])
.with_stream_chunks(vec![
StreamChunk::TextDelta { delta: "Search results found.".to_string() },
StreamChunk::Complete {
input_tokens: 5,
output_tokens: 3,
stop_reason: "end_turn".to_string(),
},
]);
let config = KernelConfig::default();
let kernel = Kernel::boot_with_driver(config, Arc::new(mock) as Arc<dyn LlmDriver>)
.await
.expect("kernel boot");
let agent_config = AgentConfig::new("test-agent");
let id = agent_config.id;
kernel.spawn_agent(agent_config).await.expect("spawn agent");
let mut rx = kernel
.send_message_stream(&id, "Search for test".to_string())
.await
.expect("stream");
let mut tool_names = Vec::new();
let mut got_complete = false;
while let Some(event) = rx.recv().await {
match &event {
LoopEvent::ToolStart { name, .. } => tool_names.push(name.clone()),
LoopEvent::ToolEnd { name, .. } => tool_names.push(format!("end:{}", name)),
LoopEvent::Complete(_) => {
got_complete = true;
break;
}
LoopEvent::Error(msg) => {
panic!("unexpected error: {}", msg);
}
_ => {}
}
}
assert!(got_complete, "stream should complete");
assert!(
tool_names.iter().any(|n| n.contains("web_search")),
"should see web_search tool events, got: {:?}",
tool_names
);
}

View File

@@ -0,0 +1,59 @@
//! Chat smoke test — full lifecycle: send → stream → persist
//!
//! Uses MockLlmDriver to verify the complete chat pipeline without a real LLM.
use std::sync::Arc;
use zclaw_kernel::{Kernel, KernelConfig};
use zclaw_runtime::test_util::MockLlmDriver;
use zclaw_runtime::{LoopEvent, LlmDriver};
use zclaw_types::AgentConfig;
#[tokio::test]
async fn smoke_chat_full_lifecycle() {
let mock = MockLlmDriver::new().with_text_response("Hello! I am the mock assistant.");
let config = KernelConfig::default();
let kernel = Kernel::boot_with_driver(config, Arc::new(mock) as Arc<dyn LlmDriver>)
.await
.expect("kernel boot");
let agent = AgentConfig::new("smoke-agent")
.with_system_prompt("You are a test assistant.");
let id = agent.id;
kernel.spawn_agent(agent).await.expect("spawn agent");
// 1. Non-streaming: send and get response
let resp = kernel.send_message(&id, "Hello".to_string()).await.expect("send");
assert!(!resp.content.is_empty());
assert!(resp.output_tokens > 0);
// 2. Streaming: send and collect all events
let mut rx = kernel
.send_message_stream(&id, "Tell me more".to_string())
.await
.expect("stream");
let mut delta_count = 0;
let mut complete_result = None;
while let Some(event) = rx.recv().await {
match event {
LoopEvent::Delta(text) => {
delta_count += 1;
assert!(!text.is_empty(), "delta should have content");
}
LoopEvent::Complete(result) => {
complete_result = Some(result);
break;
}
LoopEvent::Error(msg) => panic!("unexpected error: {}", msg),
_ => {}
}
}
assert!(delta_count > 0, "should receive at least one delta");
let result = complete_result.expect("should receive complete");
assert!(result.output_tokens > 0);
// 3. Verify session persistence — messages were saved
let agent_info = kernel.get_agent(&id).expect("agent should exist");
assert!(agent_info.message_count >= 2, "at least 2 messages should be tracked");
}

Some files were not shown because too many files have changed in this diff Show More