Compare commits

...

48 Commits

Author SHA1 Message Date
iven
a38e91935f docs(wiki): Phase 3A loop_runner 双路径合并记录
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
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
205 changed files with 6602 additions and 6646 deletions

View File

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

View File

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

View File

@@ -529,7 +529,7 @@ refactor(store): 统一 Store 数据获取方式
*** ***
<!-- ARCH-SNAPSHOT-START --> <!-- ARCH-SNAPSHOT-START -->
<!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-09 --> <!-- 此区域由 auto-sync 自动更新,请勿手动编辑。更新时间: 2026-04-15 -->
## 13. 当前架构快照 ## 13. 当前架构快照
@@ -539,13 +539,14 @@ refactor(store): 统一 Store 数据获取方式
|--------|------|----------| |--------|------|----------|
| 管家模式 (Butler) | ✅ 活跃 | 04-12 行业配置4行业 + 跨会话连续性 + <butler-context> XML fencing | | 管家模式 (Butler) | ✅ 活跃 | 04-12 行业配置4行业 + 跨会话连续性 + <butler-context> XML fencing |
| Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 | | Hermes 管线 | ✅ 活跃 | 04-12 触发信号持久化 + 经验行业维度 + 注入格式优化 |
| Intelligence Heartbeat | ✅ 活跃 | 04-15 统一健康快照 (health_snapshot.rs) + HeartbeatManager 重构 + HealthPanel 前端 |
| 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) | | 聊天流 (ChatStream) | ✅ 稳定 | 04-02 ChatStore 拆分为 4 Store (stream/conversation/message/chat) |
| 记忆管道 (Memory) | ✅ 稳定 | 04-02 闭环修复: 对话→提取→FTS5+TF-IDF→检索→注入 | | 记忆管道 (Memory) | ✅ 稳定 | 04-02 闭环修复: 对话→提取→FTS5+TF-IDF→检索→注入 |
| SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 | | SaaS 认证 (Auth) | ✅ 稳定 | Token池 RPM/TPM 轮换 + JWT password_version 失效机制 |
| Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 | | Pipeline DSL | ✅ 稳定 | 04-01 17 个 YAML 模板 + DAG 执行器 |
| Hands 系统 | ✅ 稳定 | 9 启用 (Browser/Collector/Researcher/Twitter/Whiteboard/Slideshow/Speech/Quiz/Clip) | | Hands 系统 | ✅ 稳定 | 9 启用 (Browser/Collector/Researcher/Twitter/Whiteboard/Slideshow/Speech/Quiz/Clip) |
| 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 | | 技能系统 (Skills) | ✅ 稳定 | 75 个 SKILL.md + 语义路由 |
| 中间件链 | ✅ 稳定 | 15 层 ( DataMasking@90, ButlerRouter, TrajectoryRecorder@650 — V13注册) | | 中间件链 | ✅ 稳定 | 14 层 (ButlerRouter@80, DataMasking@90, Compaction@100, Memory@150, Title@180, SkillIndex@200, DanglingTool@300, ToolError@350, ToolOutputGuard@360, Guardrail@400, LoopGuard@500, SubagentLimit@550, TrajectoryRecorder@650, TokenCalibration@700) |
### 关键架构模式 ### 关键架构模式
@@ -559,7 +560,8 @@ refactor(store): 统一 Store 数据获取方式
### 最近变更 ### 最近变更
1. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式 1. [04-15] Heartbeat 统一健康系统: health_snapshot.rs 统一收集器(LLM连接/记忆/会话/系统资源) + heartbeat.rs HeartbeatManager 重构 + HealthPanel.tsx 前端面板 + Tauri 命令 182→183 + intelligence 模块 15→16 文件 + 删除 intelligence-client/ 9 废弃文件
2. [04-12] 行业配置+管家主动性 全栈 5 Phase: 行业数据模型+4内置配置+ButlerRouter动态关键词+触发信号+Tauri加载+Admin管理页面+跨会话连续性+XML fencing注入格式
2. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed) 2. [04-09] Hermes Intelligence Pipeline 4 Chunk: ExperienceStore+Extractor, UserProfileStore+Profiler, NlScheduleParser, TrajectoryRecorder+Compressor (684 tests, 0 failed)
3. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档 3. [04-09] 管家模式6交付物完成: ButlerRouter + 冷启动 + 简洁模式UI + 桥测试 + 发布文档
3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite 3. [04-07] @reserved 标注 5 个 butler Tauri 命令 + 痛点持久化 SQLite

View File

@@ -9,6 +9,7 @@ import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components' import { ProTable } from '@ant-design/pro-components'
import { accountService } from '@/services/accounts' import { accountService } from '@/services/accounts'
import { industryService } from '@/services/industries' import { industryService } from '@/services/industries'
import { billingService } from '@/services/billing'
import { PageHeader } from '@/components/PageHeader' import { PageHeader } from '@/components/PageHeader'
import type { AccountPublic } from '@/types' import type { AccountPublic } from '@/types'
@@ -70,6 +71,12 @@ export default function Accounts() {
} }
}, [accountIndustries, editingId, form]) }, [accountIndustries, editingId, form])
// 获取所有活跃计划(用于管理员切换)
const { data: plansData } = useQuery({
queryKey: ['billing-plans'],
queryFn: ({ signal }) => billingService.listPlans(signal),
})
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) => mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
accountService.update(id, data), accountService.update(id, data),
@@ -101,6 +108,14 @@ export default function Accounts() {
onError: (err: Error) => message.error(err.message || '行业授权更新失败'), 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>[] = [ const columns: ProColumns<AccountPublic>[] = [
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' }, { title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true }, { title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
@@ -186,7 +201,7 @@ export default function Accounts() {
try { try {
// 更新基础信息 // 更新基础信息
const { industry_ids, ...accountData } = values const { industry_ids, plan_id, ...accountData } = values
await updateMutation.mutateAsync({ id: editingId, data: accountData }) await updateMutation.mutateAsync({ id: editingId, data: accountData })
// 更新行业授权(如果变更了) // 更新行业授权(如果变更了)
@@ -201,6 +216,11 @@ export default function Accounts() {
queryClient.invalidateQueries({ queryKey: ['account-industries'] }) queryClient.invalidateQueries({ queryKey: ['account-industries'] })
} }
// 切换订阅计划(如果选择了新计划)
if (plan_id) {
await switchPlanMutation.mutateAsync({ accountId: editingId, planId: plan_id })
}
handleClose() handleClose()
} catch { } catch {
// Errors handled by mutation onError callbacks // Errors handled by mutation onError callbacks
@@ -218,6 +238,11 @@ export default function Accounts() {
label: `${item.icon} ${item.name}`, label: `${item.icon} ${item.name}`,
})) }))
const planOptions = (plansData || []).map((plan) => ({
value: plan.id,
label: `${plan.display_name}${(plan.price_cents / 100).toFixed(0)}/月)`,
}))
return ( return (
<div> <div>
<PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" /> <PageHeader title="账号管理" description="管理系统用户账号、角色、权限与行业授权" />
@@ -256,7 +281,7 @@ export default function Accounts() {
open={modalOpen} open={modalOpen}
onOk={handleSave} onOk={handleSave}
onCancel={handleClose} onCancel={handleClose}
confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending} confirmLoading={updateMutation.isPending || setIndustriesMutation.isPending || switchPlanMutation.isPending}
width={560} width={560}
> >
<Form form={form} layout="vertical" className="mt-4"> <Form form={form} layout="vertical" className="mt-4">
@@ -280,6 +305,21 @@ export default function Accounts() {
]} /> ]} />
</Form.Item> </Form.Item>
<Divider></Divider>
<Form.Item
name="plan_id"
label="切换计划"
extra="选择新计划后保存将立即切换。留空则不修改当前计划。"
>
<Select
allowClear
placeholder="不修改当前计划"
options={planOptions}
loading={!plansData}
/>
</Form.Item>
<Divider></Divider> <Divider></Divider>
<Form.Item <Form.Item

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

@@ -144,7 +144,7 @@ function IndustryListPanel() {
rowKey="id" rowKey="id"
search={{ search={{
onReset: () => { setFilters({}); setPage(1) }, onReset: () => { setFilters({}); setPage(1) },
onSearch: (values) => { setFilters(values); setPage(1) }, onSubmit: (values) => { setFilters(values); setPage(1) },
}} }}
toolBarRender={() => [ toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}> <Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
@@ -225,7 +225,7 @@ function IndustryEditModal({ open, industryId, onClose }: {
onOk={() => form.submit()} onOk={() => form.submit()}
confirmLoading={updateMutation.isPending} confirmLoading={updateMutation.isPending}
width={720} width={720}
destroyOnClose destroyOnHidden
> >
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-8"><Spin /></div> <div className="flex justify-center py-8"><Spin /></div>
@@ -300,7 +300,7 @@ function IndustryCreateModal({ open, onClose }: {
onOk={() => form.submit()} onOk={() => form.submit()}
confirmLoading={createMutation.isPending} confirmLoading={createMutation.isPending}
width={640} width={640}
destroyOnClose destroyOnHidden
> >
<Form <Form
form={form} form={form}

View File

@@ -333,7 +333,7 @@ function ItemsPanel() {
rowKey="id" rowKey="id"
search={{ search={{
onReset: () => { setFilters({}); setPage(1) }, onReset: () => { setFilters({}); setPage(1) },
onSearch: (values) => { setFilters(values); setPage(1) }, onSubmit: (values) => { setFilters(values); setPage(1) },
}} }}
toolBarRender={() => [ toolBarRender={() => [
<Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}> <Button key="create" type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>

View File

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

View File

@@ -26,7 +26,7 @@ export const router = createBrowserRouter([
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) }, { path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
{ path: 'models', 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: '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: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
{ path: 'billing', lazy: () => import('@/pages/Billing').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 })) }, { path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },

View File

@@ -1,13 +1,15 @@
import request, { withSignal } from './request' import request, { withSignal } from './request'
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types' 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 = { export const apiKeyService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) => 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) => 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) => 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) => getPaymentStatus: (id: string, signal?: AbortSignal) =>
request.get<PaymentStatus>(`/billing/payments/${id}`, withSignal({}, signal)) request.get<PaymentStatus>(`/billing/payments/${id}`, withSignal({}, signal))
.then((r) => r.data), .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

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

View File

@@ -132,13 +132,16 @@ impl SqliteStorage {
.map_err(|e| ZclawError::StorageError(format!("Failed to create memories table: {}", e)))?; .map_err(|e| ZclawError::StorageError(format!("Failed to create memories table: {}", e)))?;
// Create FTS5 virtual table for full-text search // 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( sqlx::query(
r#" r#"
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
uri, uri,
content, content,
keywords, keywords,
tokenize='unicode61' tokenize='trigram'
) )
"#, "#,
) )
@@ -176,6 +179,36 @@ impl SqliteStorage {
.execute(&self.pool) .execute(&self.pool)
.await; .await;
// 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());
let _ = sqlx::query("UPDATE memories SET content_hash = ? WHERE uri = ?")
.bind(&hash)
.bind(uri)
.execute(&self.pool)
.await;
}
tracing::info!(
"[SqliteStorage] Backfilled content_hash for {} existing entries",
rows.len()
);
}
}
// Create metadata table // Create metadata table
sqlx::query( sqlx::query(
r#" r#"
@@ -189,6 +222,46 @@ impl SqliteStorage {
.await .await
.map_err(|e| ZclawError::StorageError(format!("Failed to create metadata table: {}", e)))?; .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
let _ = sqlx::query("DROP TABLE IF EXISTS memories_fts")
.execute(&self.pool)
.await;
// 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"); tracing::info!("[SqliteStorage] Database schema initialized");
Ok(()) Ok(())
} }
@@ -378,19 +451,37 @@ impl SqliteStorage {
/// Strips these and keeps only alphanumeric + CJK tokens with length > 1, /// Strips these and keeps only alphanumeric + CJK tokens with length > 1,
/// then joins them with `OR` for broad matching. /// then joins them with `OR` for broad matching.
fn sanitize_fts_query(query: &str) -> String { fn sanitize_fts_query(query: &str) -> String {
let terms: Vec<String> = query // trigram tokenizer requires quoted phrases for substring matching
.to_lowercase() // and needs at least 3 characters per term to produce results.
.split(|c: char| !c.is_alphanumeric()) let lower = query.to_lowercase();
.filter(|s| !s.is_empty() && s.len() > 1)
.map(|s| s.to_string())
.collect();
if terms.is_empty() { // Check if query contains CJK characters — trigram handles them natively
return String::new(); 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, use the full query as a quoted phrase for substring matching
// trigram will match any 3-char subsequence
if lower.len() >= 3 {
format!("\"{}\"", lower)
} else {
String::new()
}
} 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. /// Fetch memories by scope with importance-based ordering.

View File

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

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

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

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

@@ -83,10 +83,8 @@ impl Kernel {
loop_runner = loop_runner.with_path_validator(path_validator); loop_runner = loop_runner.with_path_validator(path_validator);
} }
// Inject middleware chain if available // Inject middleware chain
if let Some(chain) = self.create_middleware_chain() { loop_runner = loop_runner.with_middleware_chain(self.create_middleware_chain());
loop_runner = loop_runner.with_middleware_chain(chain);
}
// Apply chat mode configuration (thinking/reasoning/plan mode) // Apply chat mode configuration (thinking/reasoning/plan mode)
if let Some(ref mode) = chat_mode { if let Some(ref mode) = chat_mode {
@@ -198,10 +196,8 @@ impl Kernel {
loop_runner = loop_runner.with_path_validator(path_validator); loop_runner = loop_runner.with_path_validator(path_validator);
} }
// Inject middleware chain if available // Inject middleware chain
if let Some(chain) = self.create_middleware_chain() { loop_runner = loop_runner.with_middleware_chain(self.create_middleware_chain());
loop_runner = loop_runner.with_middleware_chain(chain);
}
// Apply chat mode configuration (thinking/reasoning/plan mode from frontend) // Apply chat mode configuration (thinking/reasoning/plan mode from frontend)
if let Some(ref mode) = chat_mode { if let Some(ref mode) = chat_mode {

View File

@@ -27,7 +27,7 @@ use crate::config::KernelConfig;
use zclaw_memory::MemoryStore; use zclaw_memory::MemoryStore;
use zclaw_runtime::{LlmDriver, ToolRegistry, tool::SkillExecutor}; use zclaw_runtime::{LlmDriver, ToolRegistry, tool::SkillExecutor};
use zclaw_skills::SkillRegistry; 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, quiz::LlmQuizGenerator}};
pub use adapters::KernelSkillExecutor; pub use adapters::KernelSkillExecutor;
pub use messaging::ChatModeConfig; pub use messaging::ChatModeConfig;
@@ -93,14 +93,12 @@ impl Kernel {
let quiz_model = config.model().to_string(); let quiz_model = config.model().to_string();
let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model)); let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model));
hands.register(Arc::new(BrowserHand::new())).await; 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(QuizHand::with_generator(quiz_generator))).await;
hands.register(Arc::new(WhiteboardHand::new())).await;
hands.register(Arc::new(ResearcherHand::new())).await; hands.register(Arc::new(ResearcherHand::new())).await;
hands.register(Arc::new(CollectorHand::new())).await; hands.register(Arc::new(CollectorHand::new())).await;
hands.register(Arc::new(ClipHand::new())).await; hands.register(Arc::new(ClipHand::new())).await;
hands.register(Arc::new(TwitterHand::new())).await; hands.register(Arc::new(TwitterHand::new())).await;
hands.register(Arc::new(ReminderHand::new())).await;
// Create skill executor // Create skill executor
let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone())); let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone()));
@@ -203,7 +201,7 @@ impl Kernel {
/// When middleware is configured, cross-cutting concerns (compaction, loop guard, /// When middleware is configured, cross-cutting concerns (compaction, loop guard,
/// token calibration, etc.) are delegated to the chain. When no middleware is /// token calibration, etc.) are delegated to the chain. When no middleware is
/// registered, the legacy inline path in `AgentLoop` is used instead. /// 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(); let mut chain = zclaw_runtime::middleware::MiddlewareChain::new();
// Butler router — semantic skill routing context injection // Butler router — semantic skill routing context injection
@@ -361,13 +359,11 @@ impl Kernel {
chain.register(Arc::new(mw)); chain.register(Arc::new(mw));
} }
// Only return Some if we actually registered middleware // Always return the chain (empty chain is a no-op)
if chain.is_empty() { if !chain.is_empty() {
None
} else {
tracing::info!("[Kernel] Middleware chain created with {} middlewares", chain.len()); tracing::info!("[Kernel] Middleware chain created with {} middlewares", chain.len());
Some(chain)
} }
chain
} }
/// Subscribe to events /// Subscribe to events

View File

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

View File

@@ -134,7 +134,9 @@ impl TriggerManager {
/// Create a new trigger /// Create a new trigger
pub async fn create_trigger(&self, config: TriggerConfig) -> Result<TriggerEntry> { pub async fn create_trigger(&self, config: TriggerConfig) -> Result<TriggerEntry> {
// Validate hand exists (outside of our lock to avoid holding two locks) // 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( return Err(zclaw_types::ZclawError::InvalidInput(
format!("Hand '{}' not found", config.hand_id) format!("Hand '{}' not found", config.hand_id)
)); ));
@@ -170,7 +172,7 @@ impl TriggerManager {
) -> Result<TriggerEntry> { ) -> Result<TriggerEntry> {
// Validate hand exists if being updated (outside of our lock) // Validate hand exists if being updated (outside of our lock)
if let Some(hand_id) = &updates.hand_id { 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( return Err(zclaw_types::ZclawError::InvalidInput(
format!("Hand '{}' not found", hand_id) 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) // 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 let hand = self.hand_registry.get(&hand_id).await
.ok_or_else(|| zclaw_types::ZclawError::InvalidInput( .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 // Update state before execution

View File

@@ -25,7 +25,6 @@ reqwest = { workspace = true }
# Internal crates # Internal crates
zclaw-types = { workspace = true } zclaw-types = { workspace = true }
zclaw-runtime = { workspace = true } zclaw-runtime = { workspace = true }
zclaw-kernel = { workspace = true }
zclaw-skills = { workspace = true } zclaw-skills = { workspace = true }
zclaw-hands = { workspace = true } zclaw-hands = { workspace = true }

View File

@@ -1,7 +1,6 @@
//! Agent loop implementation //! Agent loop implementation
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex;
use futures::StreamExt; use futures::StreamExt;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use zclaw_types::{AgentId, SessionId, Message, Result}; use zclaw_types::{AgentId, SessionId, Message, Result};
@@ -10,7 +9,6 @@ use crate::driver::{LlmDriver, CompletionRequest, ContentBlock};
use crate::stream::StreamChunk; use crate::stream::StreamChunk;
use crate::tool::{ToolRegistry, ToolContext, SkillExecutor}; use crate::tool::{ToolRegistry, ToolContext, SkillExecutor};
use crate::tool::builtin::PathValidator; use crate::tool::builtin::PathValidator;
use crate::loop_guard::{LoopGuard, LoopGuardResult};
use crate::growth::GrowthIntegration; use crate::growth::GrowthIntegration;
use crate::compaction::{self, CompactionConfig}; use crate::compaction::{self, CompactionConfig};
use crate::middleware::{self, MiddlewareChain}; use crate::middleware::{self, MiddlewareChain};
@@ -23,7 +21,6 @@ pub struct AgentLoop {
driver: Arc<dyn LlmDriver>, driver: Arc<dyn LlmDriver>,
tools: ToolRegistry, tools: ToolRegistry,
memory: Arc<MemoryStore>, memory: Arc<MemoryStore>,
loop_guard: Mutex<LoopGuard>,
model: String, model: String,
system_prompt: Option<String>, system_prompt: Option<String>,
/// Custom agent personality for prompt assembly /// Custom agent personality for prompt assembly
@@ -38,10 +35,9 @@ pub struct AgentLoop {
compaction_threshold: usize, compaction_threshold: usize,
/// Compaction behavior configuration /// Compaction behavior configuration
compaction_config: CompactionConfig, compaction_config: CompactionConfig,
/// Optional middleware chain — when `Some`, cross-cutting logic is /// Middleware chain — cross-cutting concerns are delegated to the chain.
/// delegated to the chain instead of the inline code below. /// An empty chain (Default) is a no-op: all `run_*` methods return Continue/Allow.
/// When `None`, the legacy inline path is used (100% backward compatible). middleware_chain: MiddlewareChain,
middleware_chain: Option<MiddlewareChain>,
/// Chat mode: extended thinking enabled /// Chat mode: extended thinking enabled
thinking_enabled: bool, thinking_enabled: bool,
/// Chat mode: reasoning effort level /// Chat mode: reasoning effort level
@@ -62,7 +58,6 @@ impl AgentLoop {
driver, driver,
tools, tools,
memory, memory,
loop_guard: Mutex::new(LoopGuard::default()),
model: String::new(), // Must be set via with_model() model: String::new(), // Must be set via with_model()
system_prompt: None, system_prompt: None,
soul: None, soul: None,
@@ -73,7 +68,7 @@ impl AgentLoop {
growth: None, growth: None,
compaction_threshold: 0, compaction_threshold: 0,
compaction_config: CompactionConfig::default(), compaction_config: CompactionConfig::default(),
middleware_chain: None, middleware_chain: MiddlewareChain::default(),
thinking_enabled: false, thinking_enabled: false,
reasoning_effort: None, reasoning_effort: None,
plan_mode: false, plan_mode: false,
@@ -167,11 +162,10 @@ impl AgentLoop {
self self
} }
/// Inject a middleware chain. When set, cross-cutting concerns (compaction, /// Inject a middleware chain. Cross-cutting concerns (compaction,
/// loop guard, token calibration, etc.) are delegated to the chain instead /// loop guard, token calibration, etc.) are delegated to the chain.
/// of the inline logic.
pub fn with_middleware_chain(mut self, chain: MiddlewareChain) -> Self { pub fn with_middleware_chain(mut self, chain: MiddlewareChain) -> Self {
self.middleware_chain = Some(chain); self.middleware_chain = chain;
self self
} }
@@ -227,49 +221,19 @@ impl AgentLoop {
// Get all messages for context // Get all messages for context
let mut messages = self.memory.get_messages(&session_id).await?; let mut messages = self.memory.get_messages(&session_id).await?;
let use_middleware = self.middleware_chain.is_some(); // Enhance system prompt via PromptBuilder (middleware may further modify)
let prompt_ctx = PromptContext {
// Apply compaction — skip inline path when middleware chain handles it base_prompt: self.system_prompt.clone(),
if !use_middleware && self.compaction_threshold > 0 { soul: self.soul.clone(),
let needs_async = thinking_enabled: self.thinking_enabled,
self.compaction_config.use_llm || self.compaction_config.memory_flush_enabled; plan_mode: self.plan_mode,
if needs_async { tool_definitions: self.tools.definitions(),
let outcome = compaction::maybe_compact_with_config( agent_name: None,
messages,
self.compaction_threshold,
&self.compaction_config,
&self.agent_id,
&session_id,
Some(&self.driver),
self.growth.as_ref(),
)
.await;
messages = outcome.messages;
} else {
messages = compaction::maybe_compact(messages, self.compaction_threshold);
}
}
// Enhance system prompt — skip when middleware chain handles it
let mut enhanced_prompt = if use_middleware {
let prompt_ctx = PromptContext {
base_prompt: self.system_prompt.clone(),
soul: self.soul.clone(),
thinking_enabled: self.thinking_enabled,
plan_mode: self.plan_mode,
tool_definitions: self.tools.definitions(),
agent_name: None,
};
PromptBuilder::new().build(&prompt_ctx)
} else if let Some(ref growth) = self.growth {
let base = self.system_prompt.as_deref().unwrap_or("");
growth.enhance_prompt(&self.agent_id, base, &input).await?
} else {
self.system_prompt.clone().unwrap_or_default()
}; };
let mut enhanced_prompt = PromptBuilder::new().build(&prompt_ctx);
// Run middleware before_completion hooks (compaction, memory inject, etc.) // Run middleware before_completion hooks (compaction, memory inject, etc.)
if let Some(ref chain) = self.middleware_chain { {
let mut mw_ctx = middleware::MiddlewareContext { let mut mw_ctx = middleware::MiddlewareContext {
agent_id: self.agent_id.clone(), agent_id: self.agent_id.clone(),
session_id: session_id.clone(), session_id: session_id.clone(),
@@ -280,7 +244,7 @@ impl AgentLoop {
input_tokens: 0, input_tokens: 0,
output_tokens: 0, output_tokens: 0,
}; };
match chain.run_before_completion(&mut mw_ctx).await? { match self.middleware_chain.run_before_completion(&mut mw_ctx).await? {
middleware::MiddlewareDecision::Continue => { middleware::MiddlewareDecision::Continue => {
messages = mw_ctx.messages; messages = mw_ctx.messages;
enhanced_prompt = mw_ctx.system_prompt; enhanced_prompt = mw_ctx.system_prompt;
@@ -400,7 +364,6 @@ impl AgentLoop {
// Create tool context and execute all tools // Create tool context and execute all tools
let tool_context = self.create_tool_context(session_id.clone()); let tool_context = self.create_tool_context(session_id.clone());
let mut circuit_breaker_triggered = false;
let mut abort_result: Option<AgentLoopResult> = None; let mut abort_result: Option<AgentLoopResult> = None;
let mut clarification_result: Option<AgentLoopResult> = None; let mut clarification_result: Option<AgentLoopResult> = None;
for (id, name, input) in tool_calls { for (id, name, input) in tool_calls {
@@ -408,8 +371,8 @@ impl AgentLoop {
if abort_result.is_some() { if abort_result.is_some() {
break; break;
} }
// Check tool call safety — via middleware chain or inline loop guard // Check tool call safety — via middleware chain
if let Some(ref chain) = self.middleware_chain { {
let mw_ctx_ref = middleware::MiddlewareContext { let mw_ctx_ref = middleware::MiddlewareContext {
agent_id: self.agent_id.clone(), agent_id: self.agent_id.clone(),
session_id: session_id.clone(), session_id: session_id.clone(),
@@ -420,7 +383,7 @@ impl AgentLoop {
input_tokens: total_input_tokens, input_tokens: total_input_tokens,
output_tokens: total_output_tokens, output_tokens: total_output_tokens,
}; };
match chain.run_before_tool_call(&mw_ctx_ref, &name, &input).await? { match self.middleware_chain.run_before_tool_call(&mw_ctx_ref, &name, &input).await? {
middleware::ToolCallDecision::Allow => {} middleware::ToolCallDecision::Allow => {}
middleware::ToolCallDecision::Block(msg) => { middleware::ToolCallDecision::Block(msg) => {
tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg); tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg);
@@ -456,26 +419,6 @@ impl AgentLoop {
}); });
} }
} }
} else {
// Legacy inline path
let guard_result = self.loop_guard.lock().unwrap_or_else(|e| e.into_inner()).check(&name, &input);
match guard_result {
LoopGuardResult::CircuitBreaker => {
tracing::warn!("[AgentLoop] Circuit breaker triggered by tool '{}'", name);
circuit_breaker_triggered = true;
break;
}
LoopGuardResult::Blocked => {
tracing::warn!("[AgentLoop] Tool '{}' blocked by loop guard", name);
let error_output = serde_json::json!({ "error": "工具调用被循环防护拦截" });
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true));
continue;
}
LoopGuardResult::Warn => {
tracing::warn!("[AgentLoop] Tool '{}' triggered loop guard warning", name);
}
LoopGuardResult::Allowed => {}
}
} }
let tool_result = match tokio::time::timeout( let tool_result = match tokio::time::timeout(
@@ -537,21 +480,10 @@ impl AgentLoop {
break result; break result;
} }
// If circuit breaker was triggered, terminate immediately
if circuit_breaker_triggered {
let msg = "检测到工具调用循环,已自动终止";
self.memory.append_message(&session_id, &Message::assistant(msg)).await?;
break AgentLoopResult {
response: msg.to_string(),
input_tokens: total_input_tokens,
output_tokens: total_output_tokens,
iterations,
};
}
}; };
// Post-completion processing — middleware chain or inline growth // Post-completion processing — middleware chain
if let Some(ref chain) = self.middleware_chain { {
let mw_ctx = middleware::MiddlewareContext { let mw_ctx = middleware::MiddlewareContext {
agent_id: self.agent_id.clone(), agent_id: self.agent_id.clone(),
session_id: session_id.clone(), session_id: session_id.clone(),
@@ -562,16 +494,9 @@ impl AgentLoop {
input_tokens: total_input_tokens, input_tokens: total_input_tokens,
output_tokens: total_output_tokens, output_tokens: total_output_tokens,
}; };
if let Err(e) = chain.run_after_completion(&mw_ctx).await { if let Err(e) = self.middleware_chain.run_after_completion(&mw_ctx).await {
tracing::warn!("[AgentLoop] Middleware after_completion failed: {}", e); tracing::warn!("[AgentLoop] Middleware after_completion failed: {}", e);
} }
} else if let Some(ref growth) = self.growth {
// Legacy inline path
if let Ok(all_messages) = self.memory.get_messages(&session_id).await {
if let Err(e) = growth.process_conversation(&self.agent_id, &all_messages, session_id.clone()).await {
tracing::warn!("[AgentLoop] Growth processing failed: {}", e);
}
}
} }
Ok(result) Ok(result)
@@ -593,49 +518,19 @@ impl AgentLoop {
// Get all messages for context // Get all messages for context
let mut messages = self.memory.get_messages(&session_id).await?; let mut messages = self.memory.get_messages(&session_id).await?;
let use_middleware = self.middleware_chain.is_some(); // Enhance system prompt via PromptBuilder (middleware may further modify)
let prompt_ctx = PromptContext {
// Apply compaction — skip inline path when middleware chain handles it base_prompt: self.system_prompt.clone(),
if !use_middleware && self.compaction_threshold > 0 { soul: self.soul.clone(),
let needs_async = thinking_enabled: self.thinking_enabled,
self.compaction_config.use_llm || self.compaction_config.memory_flush_enabled; plan_mode: self.plan_mode,
if needs_async { tool_definitions: self.tools.definitions(),
let outcome = compaction::maybe_compact_with_config( agent_name: None,
messages,
self.compaction_threshold,
&self.compaction_config,
&self.agent_id,
&session_id,
Some(&self.driver),
self.growth.as_ref(),
)
.await;
messages = outcome.messages;
} else {
messages = compaction::maybe_compact(messages, self.compaction_threshold);
}
}
// Enhance system prompt — skip when middleware chain handles it
let mut enhanced_prompt = if use_middleware {
let prompt_ctx = PromptContext {
base_prompt: self.system_prompt.clone(),
soul: self.soul.clone(),
thinking_enabled: self.thinking_enabled,
plan_mode: self.plan_mode,
tool_definitions: self.tools.definitions(),
agent_name: None,
};
PromptBuilder::new().build(&prompt_ctx)
} else if let Some(ref growth) = self.growth {
let base = self.system_prompt.as_deref().unwrap_or("");
growth.enhance_prompt(&self.agent_id, base, &input).await?
} else {
self.system_prompt.clone().unwrap_or_default()
}; };
let mut enhanced_prompt = PromptBuilder::new().build(&prompt_ctx);
// Run middleware before_completion hooks (compaction, memory inject, etc.) // Run middleware before_completion hooks (compaction, memory inject, etc.)
if let Some(ref chain) = self.middleware_chain { {
let mut mw_ctx = middleware::MiddlewareContext { let mut mw_ctx = middleware::MiddlewareContext {
agent_id: self.agent_id.clone(), agent_id: self.agent_id.clone(),
session_id: session_id.clone(), session_id: session_id.clone(),
@@ -646,18 +541,20 @@ impl AgentLoop {
input_tokens: 0, input_tokens: 0,
output_tokens: 0, output_tokens: 0,
}; };
match chain.run_before_completion(&mut mw_ctx).await? { match self.middleware_chain.run_before_completion(&mut mw_ctx).await? {
middleware::MiddlewareDecision::Continue => { middleware::MiddlewareDecision::Continue => {
messages = mw_ctx.messages; messages = mw_ctx.messages;
enhanced_prompt = mw_ctx.system_prompt; enhanced_prompt = mw_ctx.system_prompt;
} }
middleware::MiddlewareDecision::Stop(reason) => { middleware::MiddlewareDecision::Stop(reason) => {
let _ = tx.send(LoopEvent::Complete(AgentLoopResult { if let Err(e) = tx.send(LoopEvent::Complete(AgentLoopResult {
response: reason, response: reason,
input_tokens: 0, input_tokens: 0,
output_tokens: 0, output_tokens: 0,
iterations: 1, iterations: 1,
})).await; })).await {
tracing::warn!("[AgentLoop] Failed to send Complete event: {}", e);
}
return Ok(rx); return Ok(rx);
} }
} }
@@ -668,7 +565,6 @@ impl AgentLoop {
let memory = self.memory.clone(); let memory = self.memory.clone();
let driver = self.driver.clone(); let driver = self.driver.clone();
let tools = self.tools.clone(); let tools = self.tools.clone();
let loop_guard_clone = self.loop_guard.lock().unwrap_or_else(|e| e.into_inner()).clone();
let middleware_chain = self.middleware_chain.clone(); let middleware_chain = self.middleware_chain.clone();
let skill_executor = self.skill_executor.clone(); let skill_executor = self.skill_executor.clone();
let path_validator = self.path_validator.clone(); let path_validator = self.path_validator.clone();
@@ -682,7 +578,6 @@ impl AgentLoop {
tokio::spawn(async move { tokio::spawn(async move {
let mut messages = messages; let mut messages = messages;
let loop_guard_clone = Mutex::new(loop_guard_clone);
let max_iterations = 10; let max_iterations = 10;
let mut iteration = 0; let mut iteration = 0;
let mut total_input_tokens = 0u32; let mut total_input_tokens = 0u32;
@@ -691,15 +586,19 @@ impl AgentLoop {
'outer: loop { 'outer: loop {
iteration += 1; iteration += 1;
if iteration > max_iterations { if iteration > max_iterations {
let _ = tx.send(LoopEvent::Error("达到最大迭代次数".to_string())).await; if let Err(e) = tx.send(LoopEvent::Error("达到最大迭代次数".to_string())).await {
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
}
break; break;
} }
// Notify iteration start // Notify iteration start
let _ = tx.send(LoopEvent::IterationStart { if let Err(e) = tx.send(LoopEvent::IterationStart {
iteration, iteration,
max_iterations, max_iterations,
}).await; }).await {
tracing::warn!("[AgentLoop] Failed to send IterationStart event: {}", e);
}
// Build completion request // Build completion request
let request = CompletionRequest { let request = CompletionRequest {
@@ -742,13 +641,17 @@ impl AgentLoop {
text_delta_count += 1; text_delta_count += 1;
tracing::debug!("[AgentLoop] TextDelta #{}: {} chars", text_delta_count, delta.len()); tracing::debug!("[AgentLoop] TextDelta #{}: {} chars", text_delta_count, delta.len());
iteration_text.push_str(delta); iteration_text.push_str(delta);
let _ = tx.send(LoopEvent::Delta(delta.clone())).await; if let Err(e) = tx.send(LoopEvent::Delta(delta.clone())).await {
tracing::warn!("[AgentLoop] Failed to send Delta event: {}", e);
}
} }
StreamChunk::ThinkingDelta { delta } => { StreamChunk::ThinkingDelta { delta } => {
thinking_delta_count += 1; thinking_delta_count += 1;
tracing::debug!("[AgentLoop] ThinkingDelta #{}: {} chars", thinking_delta_count, delta.len()); tracing::debug!("[AgentLoop] ThinkingDelta #{}: {} chars", thinking_delta_count, delta.len());
reasoning_text.push_str(delta); reasoning_text.push_str(delta);
let _ = tx.send(LoopEvent::ThinkingDelta(delta.clone())).await; if let Err(e) = tx.send(LoopEvent::ThinkingDelta(delta.clone())).await {
tracing::warn!("[AgentLoop] Failed to send ThinkingDelta event: {}", e);
}
} }
StreamChunk::ToolUseStart { id, name } => { StreamChunk::ToolUseStart { id, name } => {
tracing::debug!("[AgentLoop] ToolUseStart: id={}, name={}", id, name); tracing::debug!("[AgentLoop] ToolUseStart: id={}, name={}", id, name);
@@ -770,7 +673,9 @@ impl AgentLoop {
// Update with final parsed input and emit ToolStart event // Update with final parsed input and emit ToolStart event
if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) { if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) {
tool.2 = input.clone(); tool.2 = input.clone();
let _ = tx.send(LoopEvent::ToolStart { name: tool.1.clone(), input: input.clone() }).await; if let Err(e) = tx.send(LoopEvent::ToolStart { name: tool.1.clone(), input: input.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolStart event: {}", e);
}
} }
} }
StreamChunk::Complete { input_tokens: it, output_tokens: ot, .. } => { StreamChunk::Complete { input_tokens: it, output_tokens: ot, .. } => {
@@ -787,20 +692,26 @@ impl AgentLoop {
} }
StreamChunk::Error { message } => { StreamChunk::Error { message } => {
tracing::error!("[AgentLoop] Stream error: {}", message); tracing::error!("[AgentLoop] Stream error: {}", message);
let _ = tx.send(LoopEvent::Error(message.clone())).await; if let Err(e) = tx.send(LoopEvent::Error(message.clone())).await {
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
}
stream_errored = true; stream_errored = true;
} }
} }
} }
Ok(Some(Err(e))) => { Ok(Some(Err(e))) => {
tracing::error!("[AgentLoop] Chunk error: {}", e); tracing::error!("[AgentLoop] Chunk error: {}", e);
let _ = tx.send(LoopEvent::Error(format!("LLM 响应错误: {}", e.to_string()))).await; if let Err(e) = tx.send(LoopEvent::Error(format!("LLM 响应错误: {}", e.to_string()))).await {
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
}
stream_errored = true; stream_errored = true;
} }
Ok(None) => break, // Stream ended normally Ok(None) => break, // Stream ended normally
Err(_) => { Err(_) => {
tracing::error!("[AgentLoop] Stream chunk timeout ({}s)", chunk_timeout.as_secs()); tracing::error!("[AgentLoop] Stream chunk timeout ({}s)", chunk_timeout.as_secs());
let _ = tx.send(LoopEvent::Error("LLM 响应超时,请重试".to_string())).await; if let Err(e) = tx.send(LoopEvent::Error("LLM 响应超时,请重试".to_string())).await {
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
}
stream_errored = true; stream_errored = true;
} }
} }
@@ -820,7 +731,9 @@ impl AgentLoop {
if iteration_text.is_empty() && !reasoning_text.is_empty() { if iteration_text.is_empty() && !reasoning_text.is_empty() {
tracing::info!("[AgentLoop] Model generated {} chars of reasoning but no text — using reasoning as response", tracing::info!("[AgentLoop] Model generated {} chars of reasoning but no text — using reasoning as response",
reasoning_text.len()); reasoning_text.len());
let _ = tx.send(LoopEvent::Delta(reasoning_text.clone())).await; if let Err(e) = tx.send(LoopEvent::Delta(reasoning_text.clone())).await {
tracing::warn!("[AgentLoop] Failed to send Delta event: {}", e);
}
iteration_text = reasoning_text.clone(); iteration_text = reasoning_text.clone();
} else if iteration_text.is_empty() { } else if iteration_text.is_empty() {
tracing::warn!("[AgentLoop] No text content after {} chunks (thinking_delta={})", tracing::warn!("[AgentLoop] No text content after {} chunks (thinking_delta={})",
@@ -838,15 +751,17 @@ impl AgentLoop {
tracing::warn!("[AgentLoop] Failed to save final assistant message: {}", e); tracing::warn!("[AgentLoop] Failed to save final assistant message: {}", e);
} }
let _ = tx.send(LoopEvent::Complete(AgentLoopResult { if let Err(e) = tx.send(LoopEvent::Complete(AgentLoopResult {
response: iteration_text.clone(), response: iteration_text.clone(),
input_tokens: total_input_tokens, input_tokens: total_input_tokens,
output_tokens: total_output_tokens, output_tokens: total_output_tokens,
iterations: iteration, iterations: iteration,
})).await; })).await {
tracing::warn!("[AgentLoop] Failed to send Complete event: {}", e);
}
// Post-completion: middleware after_completion (memory extraction, etc.) // Post-completion: middleware after_completion (memory extraction, etc.)
if let Some(ref chain) = middleware_chain { {
let mw_ctx = middleware::MiddlewareContext { let mw_ctx = middleware::MiddlewareContext {
agent_id: agent_id.clone(), agent_id: agent_id.clone(),
session_id: session_id_clone.clone(), session_id: session_id_clone.clone(),
@@ -857,7 +772,7 @@ impl AgentLoop {
input_tokens: total_input_tokens, input_tokens: total_input_tokens,
output_tokens: total_output_tokens, output_tokens: total_output_tokens,
}; };
if let Err(e) = chain.run_after_completion(&mw_ctx).await { if let Err(e) = middleware_chain.run_after_completion(&mw_ctx).await {
tracing::warn!("[AgentLoop] Streaming middleware after_completion failed: {}", e); tracing::warn!("[AgentLoop] Streaming middleware after_completion failed: {}", e);
} }
} }
@@ -889,8 +804,8 @@ impl AgentLoop {
for (id, name, input) in pending_tool_calls { for (id, name, input) in pending_tool_calls {
tracing::debug!("[AgentLoop] Executing tool: name={}, input={:?}", name, input); tracing::debug!("[AgentLoop] Executing tool: name={}, input={:?}", name, input);
// Check tool call safety — via middleware chain or inline loop guard // Check tool call safety — via middleware chain
if let Some(ref chain) = middleware_chain { {
let mw_ctx = middleware::MiddlewareContext { let mw_ctx = middleware::MiddlewareContext {
agent_id: agent_id.clone(), agent_id: agent_id.clone(),
session_id: session_id_clone.clone(), session_id: session_id_clone.clone(),
@@ -901,18 +816,22 @@ impl AgentLoop {
input_tokens: total_input_tokens, input_tokens: total_input_tokens,
output_tokens: total_output_tokens, output_tokens: total_output_tokens,
}; };
match chain.run_before_tool_call(&mw_ctx, &name, &input).await { match middleware_chain.run_before_tool_call(&mw_ctx, &name, &input).await {
Ok(middleware::ToolCallDecision::Allow) => {} Ok(middleware::ToolCallDecision::Allow) => {}
Ok(middleware::ToolCallDecision::Block(msg)) => { Ok(middleware::ToolCallDecision::Block(msg)) => {
tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg); tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg);
let error_output = serde_json::json!({ "error": msg }); let error_output = serde_json::json!({ "error": msg });
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await; if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
}
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true)); messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true));
continue; continue;
} }
Ok(middleware::ToolCallDecision::AbortLoop(reason)) => { Ok(middleware::ToolCallDecision::AbortLoop(reason)) => {
tracing::warn!("[AgentLoop] Loop aborted by middleware: {}", reason); tracing::warn!("[AgentLoop] Loop aborted by middleware: {}", reason);
let _ = tx.send(LoopEvent::Error(reason)).await; if let Err(e) = tx.send(LoopEvent::Error(reason)).await {
tracing::warn!("[AgentLoop] Failed to send Error event: {}", e);
}
break 'outer; break 'outer;
} }
Ok(middleware::ToolCallDecision::ReplaceInput(new_input)) => { Ok(middleware::ToolCallDecision::ReplaceInput(new_input)) => {
@@ -936,18 +855,24 @@ impl AgentLoop {
let (result, is_error) = if let Some(tool) = tools.get(&name) { let (result, is_error) = if let Some(tool) = tools.get(&name) {
match tool.execute(new_input, &tool_context).await { match tool.execute(new_input, &tool_context).await {
Ok(output) => { Ok(output) => {
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: output.clone() }).await; if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: output.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
}
(output, false) (output, false)
} }
Err(e) => { Err(e) => {
let error_output = serde_json::json!({ "error": e.to_string() }); let error_output = serde_json::json!({ "error": e.to_string() });
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await; if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
}
(error_output, true) (error_output, true)
} }
} }
} else { } else {
let error_output = serde_json::json!({ "error": format!("Unknown tool: {}", name) }); let error_output = serde_json::json!({ "error": format!("Unknown tool: {}", name) });
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await; if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
}
(error_output, true) (error_output, true)
}; };
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), result, is_error)); messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), result, is_error));
@@ -956,31 +881,13 @@ impl AgentLoop {
Err(e) => { Err(e) => {
tracing::error!("[AgentLoop] Middleware error for tool '{}': {}", name, e); tracing::error!("[AgentLoop] Middleware error for tool '{}': {}", name, e);
let error_output = serde_json::json!({ "error": e.to_string() }); let error_output = serde_json::json!({ "error": e.to_string() });
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await; if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
}
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true)); messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true));
continue; continue;
} }
} }
} else {
// Legacy inline loop guard path
let guard_result = loop_guard_clone.lock().unwrap_or_else(|e| e.into_inner()).check(&name, &input);
match guard_result {
LoopGuardResult::CircuitBreaker => {
let _ = tx.send(LoopEvent::Error("检测到工具调用循环,已自动终止".to_string())).await;
break 'outer;
}
LoopGuardResult::Blocked => {
tracing::warn!("[AgentLoop] Tool '{}' blocked by loop guard", name);
let error_output = serde_json::json!({ "error": "工具调用被循环防护拦截" });
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await;
messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true));
continue;
}
LoopGuardResult::Warn => {
tracing::warn!("[AgentLoop] Tool '{}' triggered loop guard warning", name);
}
LoopGuardResult::Allowed => {}
}
} }
// Use pre-resolved path_validator (already has default fallback from create_tool_context logic) // Use pre-resolved path_validator (already has default fallback from create_tool_context logic)
let pv = path_validator.clone().unwrap_or_else(|| { let pv = path_validator.clone().unwrap_or_else(|| {
@@ -1005,20 +912,26 @@ impl AgentLoop {
match tool.execute(input.clone(), &tool_context).await { match tool.execute(input.clone(), &tool_context).await {
Ok(output) => { Ok(output) => {
tracing::debug!("[AgentLoop] Tool '{}' executed successfully: {:?}", name, output); tracing::debug!("[AgentLoop] Tool '{}' executed successfully: {:?}", name, output);
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: output.clone() }).await; if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: output.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
}
(output, false) (output, false)
} }
Err(e) => { Err(e) => {
tracing::error!("[AgentLoop] Tool '{}' execution failed: {}", name, e); tracing::error!("[AgentLoop] Tool '{}' execution failed: {}", name, e);
let error_output = serde_json::json!({ "error": e.to_string() }); let error_output = serde_json::json!({ "error": e.to_string() });
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await; if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
}
(error_output, true) (error_output, true)
} }
} }
} else { } else {
tracing::error!("[AgentLoop] Tool '{}' not found in registry", name); tracing::error!("[AgentLoop] Tool '{}' not found in registry", name);
let error_output = serde_json::json!({ "error": format!("Unknown tool: {}", name) }); let error_output = serde_json::json!({ "error": format!("Unknown tool: {}", name) });
let _ = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await; if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e);
}
(error_output, true) (error_output, true)
}; };
@@ -1038,13 +951,17 @@ impl AgentLoop {
is_error, is_error,
)); ));
// Send the question as final delta so the user sees it // Send the question as final delta so the user sees it
let _ = tx.send(LoopEvent::Delta(question.clone())).await; if let Err(e) = tx.send(LoopEvent::Delta(question.clone())).await {
let _ = tx.send(LoopEvent::Complete(AgentLoopResult { tracing::warn!("[AgentLoop] Failed to send Delta event: {}", e);
}
if let Err(e) = tx.send(LoopEvent::Complete(AgentLoopResult {
response: question.clone(), response: question.clone(),
input_tokens: total_input_tokens, input_tokens: total_input_tokens,
output_tokens: total_output_tokens, output_tokens: total_output_tokens,
iterations: iteration, iterations: iteration,
})).await; })).await {
tracing::warn!("[AgentLoop] Failed to send Complete event: {}", e);
}
if let Err(e) = memory.append_message(&session_id_clone, &Message::assistant(&question)).await { if let Err(e) = memory.append_message(&session_id_clone, &Message::assistant(&question)).await {
tracing::warn!("[AgentLoop] Failed to save clarification message: {}", e); tracing::warn!("[AgentLoop] Failed to save clarification message: {}", e);
} }

View File

@@ -130,7 +130,7 @@ impl DataMasker {
fn recover_read<T>(lock: &RwLock<T>) -> std::sync::LockResult<std::sync::RwLockReadGuard<'_, T>> { fn recover_read<T>(lock: &RwLock<T>) -> std::sync::LockResult<std::sync::RwLockReadGuard<'_, T>> {
match lock.read() { match lock.read() {
Ok(guard) => Ok(guard), Ok(guard) => Ok(guard),
Err(e) => { Err(_e) => {
tracing::warn!("[DataMasker] RwLock poisoned during read, recovering"); tracing::warn!("[DataMasker] RwLock poisoned during read, recovering");
// Poison error still gives us access to the inner guard // Poison error still gives us access to the inner guard
lock.read() lock.read()
@@ -141,7 +141,7 @@ impl DataMasker {
fn recover_write<T>(lock: &RwLock<T>) -> std::sync::LockResult<std::sync::RwLockWriteGuard<'_, T>> { fn recover_write<T>(lock: &RwLock<T>) -> std::sync::LockResult<std::sync::RwLockWriteGuard<'_, T>> {
match lock.write() { match lock.write() {
Ok(guard) => Ok(guard), Ok(guard) => Ok(guard),
Err(e) => { Err(_e) => {
tracing::warn!("[DataMasker] RwLock poisoned during write, recovering"); tracing::warn!("[DataMasker] RwLock poisoned during write, recovering");
lock.write() lock.write()
} }

View File

@@ -11,7 +11,7 @@ use tokio::sync::RwLock;
use zclaw_memory::trajectory_store::{ use zclaw_memory::trajectory_store::{
TrajectoryEvent, TrajectoryStepType, TrajectoryStore, TrajectoryEvent, TrajectoryStepType, TrajectoryStore,
}; };
use zclaw_types::{Result, SessionId}; use zclaw_types::Result;
use crate::driver::ContentBlock; use crate::driver::ContentBlock;
use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision}; use crate::middleware::{AgentMiddleware, MiddlewareContext, MiddlewareDecision};

View File

@@ -7,7 +7,10 @@
//! //!
//! Lives in `zclaw-runtime` because it's a pure text→cron utility with no kernel dependency. //! Lives in `zclaw-runtime` because it's a pure text→cron utility with no kernel dependency.
use chrono::{Datelike, Timelike}; use std::sync::LazyLock;
use chrono::Timelike;
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use zclaw_types::AgentId; use zclaw_types::AgentId;
@@ -56,20 +59,79 @@ pub enum ScheduleParseResult {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Regex pattern library // Pre-compiled regex patterns (LazyLock — compiled once, reused forever)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A single pattern for matching Chinese time expressions. /// Time-of-day period fragment used across multiple patterns.
struct SchedulePattern { const PERIOD: &str = "(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?";
/// Regex pattern string
regex: &'static str, // extract_task_description
/// Cron template — use {h} for hour, {m} for minute, {dow} for day-of-week, {dom} for day-of-month static RE_TIME_STRIP: LazyLock<Regex> = LazyLock::new(|| {
cron_template: &'static str, Regex::new(
/// Human description template r"^(?:凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?\d{1,2}[点时:]\d{0,2}分?"
description: &'static str, ).unwrap()
/// Base confidence for this pattern });
confidence: f32,
} // try_every_day
static RE_EVERY_DAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?:每天|每日)(?:的)?{}(\d{{1,2}})[点时:](\d{{1,2}})?",
PERIOD
)).unwrap()
});
static RE_EVERY_DAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?:每天|每日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
).unwrap()
});
// try_every_week
static RE_EVERY_WEEK: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?:每周|每个?星期|每个?礼拜)(一|二|三|四|五|六|日|天|周一|周二|周三|周四|周五|周六|周日|周天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|星期天|礼拜一|礼拜二|礼拜三|礼拜四|礼拜五|礼拜六|礼拜日|礼拜天)(?:的)?{}(\d{{1,2}})[点时:](\d{{1,2}})?",
PERIOD
)).unwrap()
});
// try_workday
static RE_WORKDAY_EXACT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?:工作日|每个?工作日|工作日(?:的)?){}(\d{{1,2}})[点时:](\d{{1,2}})?",
PERIOD
)).unwrap()
});
static RE_WORKDAY_PERIOD: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?:工作日|每个?工作日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
).unwrap()
});
// try_interval
static RE_INTERVAL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").unwrap()
});
// try_monthly
static RE_MONTHLY: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?:每月|每个月)(?:的)?(\d{{1,2}})[号日](?:的)?{}(\d{{1,2}})?[点时:]?(\d{{1,2}})?",
PERIOD
)).unwrap()
});
// try_one_shot
static RE_ONE_SHOT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(明天|后天|大后天)(?:的)?{}(\d{{1,2}})[点时:](\d{{1,2}})?",
PERIOD
)).unwrap()
});
// ---------------------------------------------------------------------------
// Helper lookups (pure functions, no allocation)
// ---------------------------------------------------------------------------
/// Chinese time period keywords → hour mapping /// Chinese time period keywords → hour mapping
fn period_to_hour(period: &str) -> Option<u32> { fn period_to_hour(period: &str) -> Option<u32> {
@@ -99,6 +161,23 @@ fn weekday_to_cron(day: &str) -> Option<&'static str> {
} }
} }
/// Adjust hour based on time-of-day period. Chinese 12-hour convention:
/// 下午3点 = 15, 晚上8点 = 20, etc. Morning hours stay as-is.
fn adjust_hour_for_period(hour: u32, period: Option<&str>) -> u32 {
if let Some(p) = period {
match p {
"下午" | "午后" => { if hour < 12 { hour + 12 } else { hour } }
"晚上" | "晚间" | "夜里" | "夜晚" => { if hour < 12 { hour + 12 } else { hour } }
"傍晚" | "黄昏" => { if hour < 12 { hour + 12 } else { hour } }
"中午" => { if hour == 12 { 12 } else if hour < 12 { hour + 12 } else { hour } }
"半夜" | "午夜" => { if hour == 12 { 0 } else { hour } }
_ => hour,
}
} else {
hour
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parser implementation // Parser implementation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -113,35 +192,23 @@ pub fn parse_nl_schedule(input: &str, default_agent_id: &AgentId) -> SchedulePar
return ScheduleParseResult::Unclear; return ScheduleParseResult::Unclear;
} }
// Extract task description (everything after keywords like "提醒我", "帮我")
let task_description = extract_task_description(input); let task_description = extract_task_description(input);
// --- Pattern 1: 每天 + 时间 ---
if let Some(result) = try_every_day(input, &task_description, default_agent_id) { if let Some(result) = try_every_day(input, &task_description, default_agent_id) {
return result; return result;
} }
// --- Pattern 2: 每周N + 时间 ---
if let Some(result) = try_every_week(input, &task_description, default_agent_id) { if let Some(result) = try_every_week(input, &task_description, default_agent_id) {
return result; return result;
} }
// --- Pattern 3: 工作日 + 时间 ---
if let Some(result) = try_workday(input, &task_description, default_agent_id) { if let Some(result) = try_workday(input, &task_description, default_agent_id) {
return result; return result;
} }
// --- Pattern 4: 每N小时/分钟 ---
if let Some(result) = try_interval(input, &task_description, default_agent_id) { if let Some(result) = try_interval(input, &task_description, default_agent_id) {
return result; return result;
} }
// --- Pattern 5: 每月N号 ---
if let Some(result) = try_monthly(input, &task_description, default_agent_id) { if let Some(result) = try_monthly(input, &task_description, default_agent_id) {
return result; return result;
} }
// --- Pattern 6: 明天/后天 + 时间 (one-shot) ---
if let Some(result) = try_one_shot(input, &task_description, default_agent_id) { if let Some(result) = try_one_shot(input, &task_description, default_agent_id) {
return result; return result;
} }
@@ -160,13 +227,7 @@ fn extract_task_description(input: &str) -> String {
let mut desc = input.to_string(); let mut desc = input.to_string();
// Strip prefixes + time expressions in alternating passes until stable
let time_re = regex::Regex::new(
r"^(?:凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?\d{1,2}[点时:]\d{0,2}分?"
).unwrap_or_else(|_| regex::Regex::new("").unwrap());
for _ in 0..3 { for _ in 0..3 {
// Pass 1: strip prefixes
loop { loop {
let mut stripped = false; let mut stripped = false;
for prefix in &strip_prefixes { for prefix in &strip_prefixes {
@@ -177,8 +238,7 @@ fn extract_task_description(input: &str) -> String {
} }
if !stripped { break; } if !stripped { break; }
} }
// Pass 2: strip time expressions let new_desc = RE_TIME_STRIP.replace(&desc, "").to_string();
let new_desc = time_re.replace(&desc, "").to_string();
if new_desc == desc { break; } if new_desc == desc { break; }
desc = new_desc; desc = new_desc;
} }
@@ -186,32 +246,10 @@ fn extract_task_description(input: &str) -> String {
desc.trim().to_string() desc.trim().to_string()
} }
// -- Pattern matchers -- // -- Pattern matchers (all use pre-compiled statics) --
/// Adjust hour based on time-of-day period. Chinese 12-hour convention:
/// 下午3点 = 15, 晚上8点 = 20, etc. Morning hours stay as-is.
fn adjust_hour_for_period(hour: u32, period: Option<&str>) -> u32 {
if let Some(p) = period {
match p {
"下午" | "午后" => { if hour < 12 { hour + 12 } else { hour } }
"晚上" | "晚间" | "夜里" | "夜晚" => { if hour < 12 { hour + 12 } else { hour } }
"傍晚" | "黄昏" => { if hour < 12 { hour + 12 } else { hour } }
"中午" => { if hour == 12 { 12 } else if hour < 12 { hour + 12 } else { hour } }
"半夜" | "午夜" => { if hour == 12 { 0 } else { hour } }
_ => hour,
}
} else {
hour
}
}
const PERIOD_PATTERN: &str = "(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)?";
fn try_every_day(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> { fn try_every_day(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
let re = regex::Regex::new( if let Some(caps) = RE_EVERY_DAY_EXACT.captures(input) {
&format!(r"(?:每天|每日)(?:的)?{}(\d{{1,2}})[点时:](\d{{1,2}})?", PERIOD_PATTERN)
).ok()?;
if let Some(caps) = re.captures(input) {
let period = caps.get(1).map(|m| m.as_str()); let period = caps.get(1).map(|m| m.as_str());
let raw_hour: u32 = caps.get(2)?.as_str().parse().ok()?; let raw_hour: u32 = caps.get(2)?.as_str().parse().ok()?;
let minute: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0); let minute: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
@@ -228,9 +266,7 @@ fn try_every_day(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sch
})); }));
} }
// "每天早上/下午..." without explicit hour if let Some(caps) = RE_EVERY_DAY_PERIOD.captures(input) {
let re2 = regex::Regex::new(r"(?:每天|每日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)").ok()?;
if let Some(caps) = re2.captures(input) {
let period = caps.get(1)?.as_str(); let period = caps.get(1)?.as_str();
if let Some(hour) = period_to_hour(period) { if let Some(hour) = period_to_hour(period) {
return Some(ScheduleParseResult::Exact(ParsedSchedule { return Some(ScheduleParseResult::Exact(ParsedSchedule {
@@ -247,11 +283,7 @@ fn try_every_day(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sch
} }
fn try_every_week(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> { fn try_every_week(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
let re = regex::Regex::new( let caps = RE_EVERY_WEEK.captures(input)?;
&format!(r"(?:每周|每个?星期|每个?礼拜)(一|二|三|四|五|六|日|天|周一|周二|周三|周四|周五|周六|周日|周天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|星期天|礼拜一|礼拜二|礼拜三|礼拜四|礼拜五|礼拜六|礼拜日|礼拜天)(?:的)?{}(\d{{1,2}})[点时:](\d{{1,2}})?", PERIOD_PATTERN)
).ok()?;
let caps = re.captures(input)?;
let day_str = caps.get(1)?.as_str(); let day_str = caps.get(1)?.as_str();
let dow = weekday_to_cron(day_str)?; let dow = weekday_to_cron(day_str)?;
let period = caps.get(2).map(|m| m.as_str()); let period = caps.get(2).map(|m| m.as_str());
@@ -272,11 +304,7 @@ fn try_every_week(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sc
} }
fn try_workday(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> { fn try_workday(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
let re = regex::Regex::new( if let Some(caps) = RE_WORKDAY_EXACT.captures(input) {
&format!(r"(?:工作日|每个?工作日|工作日(?:的)?){}(\d{{1,2}})[点时:](\d{{1,2}})?", PERIOD_PATTERN)
).ok()?;
if let Some(caps) = re.captures(input) {
let period = caps.get(1).map(|m| m.as_str()); let period = caps.get(1).map(|m| m.as_str());
let raw_hour: u32 = caps.get(2)?.as_str().parse().ok()?; let raw_hour: u32 = caps.get(2)?.as_str().parse().ok()?;
let minute: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0); let minute: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
@@ -293,11 +321,7 @@ fn try_workday(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sched
})); }));
} }
// "工作日下午3点" style if let Some(caps) = RE_WORKDAY_PERIOD.captures(input) {
let re2 = regex::Regex::new(
r"(?:工作日|每个?工作日)(?:的)?(凌晨|早上|早晨|上午|中午|下午|午后|傍晚|黄昏|晚上|晚间|夜里|夜晚|半夜|午夜)"
).ok()?;
if let Some(caps) = re2.captures(input) {
let period = caps.get(1)?.as_str(); let period = caps.get(1)?.as_str();
if let Some(hour) = period_to_hour(period) { if let Some(hour) = period_to_hour(period) {
return Some(ScheduleParseResult::Exact(ParsedSchedule { return Some(ScheduleParseResult::Exact(ParsedSchedule {
@@ -314,9 +338,7 @@ fn try_workday(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sched
} }
fn try_interval(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> { fn try_interval(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
// "每2小时", "每30分钟", "每N小时/分钟" if let Some(caps) = RE_INTERVAL.captures(input) {
let re = regex::Regex::new(r"每(\d{1,2})(小时|分钟|分|钟|个小时)").ok()?;
if let Some(caps) = re.captures(input) {
let n: u32 = caps.get(1)?.as_str().parse().ok()?; let n: u32 = caps.get(1)?.as_str().parse().ok()?;
if n == 0 { if n == 0 {
return None; return None;
@@ -340,11 +362,7 @@ fn try_interval(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sche
} }
fn try_monthly(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> { fn try_monthly(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
let re = regex::Regex::new( if let Some(caps) = RE_MONTHLY.captures(input) {
&format!(r"(?:每月|每个月)(?:的)?(\d{{1,2}})[号日](?:的)?{}(\d{{1,2}})?[点时:]?(\d{{1,2}})?", PERIOD_PATTERN)
).ok()?;
if let Some(caps) = re.captures(input) {
let day: u32 = caps.get(1)?.as_str().parse().ok()?; let day: u32 = caps.get(1)?.as_str().parse().ok()?;
let period = caps.get(2).map(|m| m.as_str()); let period = caps.get(2).map(|m| m.as_str());
let raw_hour: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(9)).unwrap_or(9); let raw_hour: u32 = caps.get(3).map(|m| m.as_str().parse().unwrap_or(9)).unwrap_or(9);
@@ -366,11 +384,7 @@ fn try_monthly(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<Sched
} }
fn try_one_shot(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> { fn try_one_shot(input: &str, task_desc: &str, agent_id: &AgentId) -> Option<ScheduleParseResult> {
let re = regex::Regex::new( let caps = RE_ONE_SHOT.captures(input)?;
&format!(r"(明天|后天|大后天)(?:的)?{}(\d{{1,2}})[点时:](\d{{1,2}})?", PERIOD_PATTERN)
).ok()?;
let caps = re.captures(input)?;
let day_offset = match caps.get(1)?.as_str() { let day_offset = match caps.get(1)?.as_str() {
"明天" => 1, "明天" => 1,
"后天" => 2, "后天" => 2,

View File

@@ -16,8 +16,13 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
.route("/api/v1/tokens", post(handlers::create_token)) .route("/api/v1/tokens", post(handlers::create_token))
.route("/api/v1/tokens/:id", delete(handlers::revoke_token)) .route("/api/v1/tokens/:id", delete(handlers::revoke_token))
.route("/api/v1/logs/operations", get(handlers::list_operation_logs)) .route("/api/v1/logs/operations", get(handlers::list_operation_logs))
.route("/api/v1/stats/dashboard", get(handlers::dashboard_stats))
.route("/api/v1/devices", get(handlers::list_devices)) .route("/api/v1/devices", get(handlers::list_devices))
.route("/api/v1/devices/register", post(handlers::register_device)) .route("/api/v1/devices/register", post(handlers::register_device))
.route("/api/v1/devices/heartbeat", post(handlers::device_heartbeat)) .route("/api/v1/devices/heartbeat", post(handlers::device_heartbeat))
} }
/// Admin-only 路由 (需 admin_guard_middleware 保护)
pub fn admin_routes() -> axum::Router<crate::state::AppState> {
axum::Router::new()
.route("/api/v1/admin/dashboard", get(handlers::dashboard_stats))
}

View File

@@ -215,7 +215,10 @@ pub async fn login(
.bind(&r.id) .bind(&r.id)
.fetch_one(&state.db) .fetch_one(&state.db)
.await .await
.unwrap_or(false); .map_err(|e| {
tracing::warn!(account_id = %r.id, error = %e, "Lockout check query failed");
SaasError::Internal("账号状态检查失败,请重试".into())
})?;
if is_locked { if is_locked {
return Err(SaasError::AuthError("账号已被临时锁定,请稍后再试".into())); return Err(SaasError::AuthError("账号已被临时锁定,请稍后再试".into()));
@@ -631,5 +634,32 @@ pub async fn logout(
} }
} }
// Fallback: 如果没有找到 refresh token尝试从 access token cookie 提取 account_id
// Tauri 桌面端使用 Bearer auth 时logout body 可能不含 refresh_token
if tokens_to_check.is_empty() {
if let Some(access_cookie) = jar.get(ACCESS_TOKEN_COOKIE) {
let access_val = access_cookie.value().to_string();
if let Ok(claims) = verify_token_skip_expiry(&access_val, jwt_secret) {
let now = chrono::Utc::now();
let result = sqlx::query(
"UPDATE refresh_tokens SET used_at = $1 WHERE account_id = $2 AND used_at IS NULL"
)
.bind(&now)
.bind(&claims.sub)
.execute(&state.db)
.await;
match result {
Ok(r) => {
tracing::info!(account_id = %claims.sub, n = r.rows_affected(), "Refresh tokens revoked via access token fallback");
}
Err(e) => {
tracing::warn!(account_id = %claims.sub, error = %e, "Failed to revoke refresh tokens (access fallback)");
}
}
}
}
}
(clear_auth_cookies(jar), axum::http::StatusCode::NO_CONTENT) (clear_auth_cookies(jar), axum::http::StatusCode::NO_CONTENT)
} }

View File

@@ -203,6 +203,27 @@ pub async fn auth_middleware(
} }
} }
/// Admin 路由守卫中间件: 确保 AuthContext 具有 admin/super_admin 角色
/// 必须在 auth_middleware 之后使用(依赖 Extension<AuthContext>
pub async fn admin_guard_middleware(
mut req: Request,
next: Next,
) -> Response {
use crate::auth::handlers::check_permission;
let ctx = req.extensions().get::<AuthContext>().cloned();
match ctx {
Some(ctx) => {
if let Err(e) = check_permission(&ctx, "account:admin") {
e.into_response()
} else {
next.run(req).await
}
}
None => SaasError::Unauthorized.into_response(),
}
}
/// 路由 (无需认证的端点) /// 路由 (无需认证的端点)
pub fn routes() -> axum::Router<AppState> { pub fn routes() -> axum::Router<AppState> {
use axum::routing::post; use axum::routing::post;

View File

@@ -7,6 +7,7 @@ use axum::{
use serde::Deserialize; use serde::Deserialize;
use crate::auth::types::AuthContext; use crate::auth::types::AuthContext;
use crate::auth::handlers::{log_operation, check_permission};
use crate::error::{SaasError, SaasResult}; use crate::error::{SaasError, SaasResult};
use crate::state::AppState; use crate::state::AppState;
use super::service; use super::service;
@@ -39,9 +40,23 @@ pub async fn get_subscription(
let sub = service::get_active_subscription(&state.db, &ctx.account_id).await?; let sub = service::get_active_subscription(&state.db, &ctx.account_id).await?;
let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?; let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?;
// P2-14 修复: super_admin 无订阅时合成一个 "active" subscription
let sub_value = if sub.is_none() && ctx.role == "super_admin" {
Some(serde_json::json!({
"id": format!("sub-admin-{}", &ctx.account_id.chars().take(8).collect::<String>()),
"account_id": ctx.account_id,
"plan_id": plan.id,
"status": "active",
"current_period_start": usage.period_start,
"current_period_end": usage.period_end,
}))
} else {
sub.map(|s| serde_json::to_value(s).unwrap_or_default())
};
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"plan": plan, "plan": plan,
"subscription": sub, "subscription": sub_value,
"usage": usage, "usage": usage,
}))) })))
} }
@@ -101,6 +116,41 @@ pub async fn increment_usage_dimension(
}))) })))
} }
/// POST /api/v1/billing/payments — 创建支付订单
/// PUT /api/v1/admin/accounts/:id/subscription — 管理员切换用户订阅计划(仅 super_admin
pub async fn admin_switch_subscription(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(account_id): Path<String>,
Json(req): Json<AdminSwitchPlanRequest>,
) -> SaasResult<Json<serde_json::Value>> {
// 仅 super_admin 可操作
check_permission(&ctx, "admin:full")?;
// 验证 plan_id 非空
if req.plan_id.trim().is_empty() {
return Err(SaasError::InvalidInput("plan_id 不能为空".into()));
}
let sub = service::admin_switch_plan(&state.db, &account_id, &req.plan_id).await?;
log_operation(
&state.db,
&ctx.account_id,
"billing.admin_switch_plan",
"account",
&account_id,
Some(serde_json::json!({ "plan_id": req.plan_id })),
None,
).await.ok(); // 日志失败不影响主流程
Ok(Json(serde_json::json!({
"success": true,
"subscription": sub,
})))
}
/// POST /api/v1/billing/payments — 创建支付订单 /// POST /api/v1/billing/payments — 创建支付订单
pub async fn create_payment( pub async fn create_payment(
State(state): State<AppState>, State(state): State<AppState>,

View File

@@ -6,7 +6,7 @@ pub mod handlers;
pub mod payment; pub mod payment;
pub mod invoice_pdf; pub mod invoice_pdf;
use axum::routing::{get, post}; use axum::routing::{get, post, put};
/// 全部计费路由(用于 main.rs 一次性挂载) /// 全部计费路由(用于 main.rs 一次性挂载)
pub fn routes() -> axum::Router<crate::state::AppState> { pub fn routes() -> axum::Router<crate::state::AppState> {
@@ -51,3 +51,9 @@ pub fn mock_routes() -> axum::Router<crate::state::AppState> {
.route("/api/v1/billing/mock-pay", get(handlers::mock_pay_page)) .route("/api/v1/billing/mock-pay", get(handlers::mock_pay_page))
.route("/api/v1/billing/mock-pay/confirm", post(handlers::mock_pay_confirm)) .route("/api/v1/billing/mock-pay/confirm", post(handlers::mock_pay_confirm))
} }
/// 管理员计费路由(需 super_admin 权限)
pub fn admin_routes() -> axum::Router<crate::state::AppState> {
axum::Router::new()
.route("/api/v1/admin/accounts/:id/subscription", put(handlers::admin_switch_subscription))
}

View File

@@ -101,6 +101,7 @@ pub async fn create_payment(
Ok(PaymentResult { Ok(PaymentResult {
payment_id, payment_id,
invoice_id,
trade_no, trade_no,
pay_url, pay_url,
amount_cents: plan.price_cents, amount_cents: plan.price_cents,
@@ -272,8 +273,8 @@ pub async fn query_payment_status(
payment_id: &str, payment_id: &str,
account_id: &str, account_id: &str,
) -> SaasResult<serde_json::Value> { ) -> SaasResult<serde_json::Value> {
let payment: (String, String, i32, String, String) = sqlx::query_as::<_, (String, String, i32, String, String)>( let payment: (String, String, String, i32, String, String) = sqlx::query_as::<_, (String, String, String, i32, String, String)>(
"SELECT id, method, amount_cents, currency, status \ "SELECT id, invoice_id, method, amount_cents, currency, status \
FROM billing_payments WHERE id = $1 AND account_id = $2" FROM billing_payments WHERE id = $1 AND account_id = $2"
) )
.bind(payment_id) .bind(payment_id)
@@ -282,9 +283,10 @@ pub async fn query_payment_status(
.await? .await?
.ok_or_else(|| SaasError::NotFound("支付记录不存在".into()))?; .ok_or_else(|| SaasError::NotFound("支付记录不存在".into()))?;
let (id, method, amount, currency, status) = payment; let (id, invoice_id, method, amount, currency, status) = payment;
Ok(serde_json::json!({ Ok(serde_json::json!({
"id": id, "id": id,
"invoice_id": invoice_id,
"method": method, "method": method,
"amount_cents": amount, "amount_cents": amount,
"currency": currency, "currency": currency,

View File

@@ -114,7 +114,26 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<
.await?; .await?;
if let Some(usage) = existing { if let Some(usage) = existing {
return Ok(usage); // P1-07 修复: 同步当前计划限额到 max_* 列(防止计划变更后数据不一致)
let plan = get_account_plan(pool, account_id).await?;
let limits: PlanLimits = serde_json::from_value(plan.limits.clone())
.unwrap_or_else(|_| PlanLimits::free());
sqlx::query(
"UPDATE billing_usage_quotas SET max_input_tokens=$2, max_output_tokens=$3, \
max_relay_requests=$4, max_hand_executions=$5, max_pipeline_runs=$6, updated_at=NOW() \
WHERE id=$1"
)
.bind(&usage.id)
.bind(limits.max_input_tokens_monthly)
.bind(limits.max_output_tokens_monthly)
.bind(limits.max_relay_requests_monthly)
.bind(limits.max_hand_executions_monthly)
.bind(limits.max_pipeline_runs_monthly)
.execute(pool).await?;
let updated = sqlx::query_as::<_, UsageQuota>(
"SELECT * FROM billing_usage_quotas WHERE id = $1"
).bind(&usage.id).fetch_one(pool).await?;
return Ok(updated);
} }
// 获取当前计划限额 // 获取当前计划限额
@@ -281,6 +300,93 @@ pub async fn increment_dimension_by(
Ok(()) Ok(())
} }
/// 管理员切换用户订阅计划(仅 super_admin 调用)
///
/// 1. 验证目标 plan_id 存在且 active
/// 2. 取消用户当前 active 订阅
/// 3. 创建新订阅status=active, 30 天周期)
/// 4. 更新当月 usage quota 的 max_* 列
pub async fn admin_switch_plan(
pool: &PgPool,
account_id: &str,
target_plan_id: &str,
) -> SaasResult<Subscription> {
// 1. 验证目标计划存在且 active
let plan = get_plan(pool, target_plan_id).await?
.ok_or_else(|| crate::error::SaasError::NotFound("目标计划不存在或已下架".into()))?;
// 2. 检查是否已订阅该计划
if let Some(current_sub) = get_active_subscription(pool, account_id).await? {
if current_sub.plan_id == target_plan_id {
return Err(crate::error::SaasError::InvalidInput("用户已订阅该计划".into()));
}
}
let mut tx = pool.begin().await
.map_err(|e| crate::error::SaasError::Internal(format!("开启事务失败: {}", e)))?;
let now = chrono::Utc::now();
// 3. 取消当前活跃订阅
sqlx::query(
"UPDATE billing_subscriptions SET status = 'canceled', canceled_at = $1, updated_at = $1 \
WHERE account_id = $2 AND status IN ('trial', 'active', 'past_due')"
)
.bind(&now)
.bind(account_id)
.execute(&mut *tx)
.await?;
// 4. 创建新订阅
let sub_id = uuid::Uuid::new_v4().to_string();
let period_start = now;
let period_end = now + chrono::Duration::days(30);
sqlx::query(
"INSERT INTO billing_subscriptions \
(id, account_id, plan_id, status, current_period_start, current_period_end, created_at, updated_at) \
VALUES ($1, $2, $3, 'active', $4, $5, $6, $6)"
)
.bind(&sub_id)
.bind(account_id)
.bind(&target_plan_id)
.bind(&period_start)
.bind(&period_end)
.bind(&now)
.execute(&mut *tx)
.await?;
// 5. 同步当月 usage quota 的 max_* 列
let limits: PlanLimits = serde_json::from_value(plan.limits.clone())
.unwrap_or_else(|_| PlanLimits::free());
sqlx::query(
"UPDATE billing_usage_quotas SET max_input_tokens=$1, max_output_tokens=$2, \
max_relay_requests=$3, max_hand_executions=$4, max_pipeline_runs=$5, updated_at=NOW() \
WHERE account_id=$6 AND period_start = DATE_TRUNC('month', NOW())"
)
.bind(limits.max_input_tokens_monthly)
.bind(limits.max_output_tokens_monthly)
.bind(limits.max_relay_requests_monthly)
.bind(limits.max_hand_executions_monthly)
.bind(limits.max_pipeline_runs_monthly)
.bind(account_id)
.execute(&mut *tx)
.await?;
tx.commit().await
.map_err(|e| crate::error::SaasError::Internal(format!("事务提交失败: {}", e)))?;
// 查询返回新订阅
let sub = sqlx::query_as::<_, Subscription>(
"SELECT * FROM billing_subscriptions WHERE id = $1"
)
.bind(&sub_id)
.fetch_one(pool)
.await?;
Ok(sub)
}
/// 检查用量配额 /// 检查用量配额
/// ///
/// P1-7 修复: 从当前 Plan 读取限额(而非 stale 的 usage 表冗余列) /// P1-7 修复: 从当前 Plan 读取限额(而非 stale 的 usage 表冗余列)
@@ -288,8 +394,13 @@ pub async fn increment_dimension_by(
pub async fn check_quota( pub async fn check_quota(
pool: &PgPool, pool: &PgPool,
account_id: &str, account_id: &str,
role: &str,
quota_type: &str, quota_type: &str,
) -> SaasResult<QuotaCheck> { ) -> SaasResult<QuotaCheck> {
// P2-14 修复: super_admin 不受配额限制
if role == "super_admin" {
return Ok(QuotaCheck { allowed: true, reason: None, current: 0, limit: None, remaining: None });
}
let usage = get_or_create_usage(pool, account_id).await?; let usage = get_or_create_usage(pool, account_id).await?;
// 从当前 Plan 读取真实限额,而非 usage 表的 stale 冗余列 // 从当前 Plan 读取真实限额,而非 usage 表的 stale 冗余列
let plan = get_account_plan(pool, account_id).await?; let plan = get_account_plan(pool, account_id).await?;

View File

@@ -155,7 +155,14 @@ pub struct CreatePaymentRequest {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct PaymentResult { pub struct PaymentResult {
pub payment_id: String, pub payment_id: String,
pub invoice_id: String,
pub trade_no: String, pub trade_no: String,
pub pay_url: String, pub pay_url: String,
pub amount_cents: i32, pub amount_cents: i32,
} }
/// 管理员切换计划请求
#[derive(Debug, Deserialize)]
pub struct AdminSwitchPlanRequest {
pub plan_id: String,
}

View File

@@ -742,7 +742,7 @@ async fn seed_demo_data(pool: &PgPool) -> SaasResult<()> {
let id = format!("cfg-{}-{}", cat, key); let id = format!("cfg-{}-{}", cat, key);
sqlx::query( sqlx::query(
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, created_at, updated_at) "INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (id) DO NOTHING" VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (category, key_path) DO NOTHING"
).bind(&id).bind(cat).bind(key).bind(vtype).bind(current).bind(default).bind(desc).bind(&ts) ).bind(&id).bind(cat).bind(key).bind(vtype).bind(current).bind(default).bind(desc).bind(&ts)
.execute(pool).await?; .execute(pool).await?;
} }
@@ -854,6 +854,7 @@ async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
let admin_ids: Vec<String> = admins.into_iter().map(|(id,)| id).collect(); let admin_ids: Vec<String> = admins.into_iter().map(|(id,)| id).collect();
// 2. 更新 config_items 分类名(旧 → 新) // 2. 更新 config_items 分类名(旧 → 新)
// 先删除目标 (category, key_path) 已存在的旧 category 行,避免唯一约束冲突
let category_mappings = [ let category_mappings = [
("server", "general"), ("server", "general"),
("llm", "model"), ("llm", "model"),
@@ -862,6 +863,13 @@ async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
("security", "rate_limit"), ("security", "rate_limit"),
]; ];
for (old_cat, new_cat) in &category_mappings { for (old_cat, new_cat) in &category_mappings {
// 删除旧 category 中与目标 category key_path 冲突的行
sqlx::query(
"DELETE FROM config_items WHERE category = $1 AND key_path IN \
(SELECT key_path FROM config_items WHERE category = $2)"
).bind(old_cat).bind(new_cat)
.execute(pool).await?;
let result = sqlx::query( let result = sqlx::query(
"UPDATE config_items SET category = $1, updated_at = $2 WHERE category = $3" "UPDATE config_items SET category = $1, updated_at = $2 WHERE category = $3"
).bind(new_cat).bind(&now).bind(old_cat) ).bind(new_cat).bind(&now).bind(old_cat)
@@ -889,7 +897,7 @@ async fn fix_seed_data(pool: &PgPool) -> SaasResult<()> {
let id = format!("cfg-{}-{}", cat, key); let id = format!("cfg-{}-{}", cat, key);
sqlx::query( sqlx::query(
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, created_at, updated_at) "INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (id) DO NOTHING" VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8, $8) ON CONFLICT (category, key_path) DO NOTHING"
).bind(&id).bind(cat).bind(key).bind(vtype).bind(current).bind(default).bind(desc).bind(&now) ).bind(&id).bind(cat).bind(key).bind(vtype).bind(current).bind(default).bind(desc).bind(&now)
.execute(pool).await?; .execute(pool).await?;
} }

View File

@@ -15,24 +15,48 @@ pub async fn list_industries(
) -> SaasResult<PaginatedResponse<IndustryListItem>> { ) -> SaasResult<PaginatedResponse<IndustryListItem>> {
let (page, page_size, offset) = normalize_pagination(query.page, query.page_size); let (page, page_size, offset) = normalize_pagination(query.page, query.page_size);
// 动态构建参数化查询 — 所有用户输入通过 $N 绑定
let mut where_parts: Vec<String> = vec!["1=1".to_string()];
let mut param_idx = 3; // $1=LIMIT, $2=OFFSET, $3+=filters
let status_param: Option<String> = query.status.clone(); let status_param: Option<String> = query.status.clone();
let source_param: Option<String> = query.source.clone(); let source_param: Option<String> = query.source.clone();
// 构建 WHERE 条件 — 每个查询独立的参数编号
let mut where_parts: Vec<String> = vec!["1=1".to_string()];
// count 查询:参数从 $1 开始
let mut count_params: Vec<String> = Vec::new();
let mut count_idx = 1;
if status_param.is_some() { if status_param.is_some() {
where_parts.push(format!("status = ${}", param_idx)); count_params.push(format!("status = ${}", count_idx));
param_idx += 1; count_idx += 1;
} }
if source_param.is_some() { if source_param.is_some() {
where_parts.push(format!("source = ${}", param_idx)); count_params.push(format!("source = ${}", count_idx));
param_idx += 1; count_idx += 1;
} }
let where_sql = where_parts.join(" AND "); let count_where = if count_params.is_empty() {
"1=1".to_string()
} else {
format!("1=1 AND {}", count_params.join(" AND "))
};
// items 查询:$1=LIMIT, $2=OFFSET, $3+=filters
let mut items_params: Vec<String> = Vec::new();
let mut items_idx = 3;
if status_param.is_some() {
items_params.push(format!("status = ${}", items_idx));
items_idx += 1;
}
if source_param.is_some() {
items_params.push(format!("source = ${}", items_idx));
items_idx += 1;
}
let items_where = if items_params.is_empty() {
"1=1".to_string()
} else {
format!("1=1 AND {}", items_params.join(" AND "))
};
// count 查询 // count 查询
let count_sql = format!("SELECT COUNT(*) FROM industries WHERE {}", where_sql); let count_sql = format!("SELECT COUNT(*) FROM industries WHERE {}", count_where);
let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql); let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql);
if let Some(ref s) = status_param { count_q = count_q.bind(s); } if let Some(ref s) = status_param { count_q = count_q.bind(s); }
if let Some(ref s) = source_param { count_q = count_q.bind(s); } if let Some(ref s) = source_param { count_q = count_q.bind(s); }
@@ -44,7 +68,7 @@ pub async fn list_industries(
COALESCE(jsonb_array_length(keywords), 0) as keywords_count, \ COALESCE(jsonb_array_length(keywords), 0) as keywords_count, \
created_at, updated_at \ created_at, updated_at \
FROM industries WHERE {} ORDER BY source, id LIMIT $1 OFFSET $2", FROM industries WHERE {} ORDER BY source, id LIMIT $1 OFFSET $2",
where_sql items_where
); );
let mut items_q = sqlx::query_as::<_, IndustryListItem>(&items_sql) let mut items_q = sqlx::query_as::<_, IndustryListItem>(&items_sql)
.bind(page_size as i64) .bind(page_size as i64)

View File

@@ -29,7 +29,7 @@ pub struct IndustryListItem {
pub description: String, pub description: String,
pub status: String, pub status: String,
pub source: String, pub source: String,
pub keywords_count: i64, pub keywords_count: i32,
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>, pub updated_at: chrono::DateTime<chrono::Utc>,
} }

View File

@@ -99,6 +99,8 @@ async fn main() -> anyhow::Result<()> {
if let Err(e) = zclaw_saas::crypto::migrate_legacy_totp_secrets(&db, &enc_key).await { if let Err(e) = zclaw_saas::crypto::migrate_legacy_totp_secrets(&db, &enc_key).await {
tracing::warn!("TOTP legacy migration check failed: {}", e); tracing::warn!("TOTP legacy migration check failed: {}", e);
} }
// Self-heal: re-encrypt provider keys with current key
zclaw_saas::relay::key_pool::heal_provider_keys(&db, &enc_key).await;
} else { } else {
drop(config_for_migration); drop(config_for_migration);
} }
@@ -350,6 +352,10 @@ async fn build_router(state: AppState) -> axum::Router {
let protected_routes = zclaw_saas::auth::protected_routes() let protected_routes = zclaw_saas::auth::protected_routes()
.merge(zclaw_saas::account::routes()) .merge(zclaw_saas::account::routes())
.merge(
zclaw_saas::account::admin_routes()
.layer(middleware::from_fn(zclaw_saas::auth::admin_guard_middleware))
)
.merge(zclaw_saas::model_config::routes()) .merge(zclaw_saas::model_config::routes())
// relay::routes() 不在此合并 — SSE 端点需要更长超时,在最终 Router 单独合并 // relay::routes() 不在此合并 — SSE 端点需要更长超时,在最终 Router 单独合并
.merge(zclaw_saas::migration::routes()) .merge(zclaw_saas::migration::routes())
@@ -359,6 +365,10 @@ async fn build_router(state: AppState) -> axum::Router {
.merge(zclaw_saas::scheduled_task::routes()) .merge(zclaw_saas::scheduled_task::routes())
.merge(zclaw_saas::telemetry::routes()) .merge(zclaw_saas::telemetry::routes())
.merge(zclaw_saas::billing::routes()) .merge(zclaw_saas::billing::routes())
.merge(
zclaw_saas::billing::admin_routes()
.layer(middleware::from_fn(zclaw_saas::auth::admin_guard_middleware))
)
.merge(zclaw_saas::knowledge::routes()) .merge(zclaw_saas::knowledge::routes())
.merge(zclaw_saas::industry::routes()) .merge(zclaw_saas::industry::routes())
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(

View File

@@ -119,13 +119,13 @@ pub async fn quota_check_middleware(
} }
// 从扩展中获取认证上下文 // 从扩展中获取认证上下文
let account_id = match req.extensions().get::<AuthContext>() { let (account_id, role) = match req.extensions().get::<AuthContext>() {
Some(ctx) => ctx.account_id.clone(), Some(ctx) => (ctx.account_id.clone(), ctx.role.clone()),
None => return next.run(req).await, None => return next.run(req).await,
}; };
// 检查 relay_requests 配额 // 检查 relay_requests 配额
match crate::billing::service::check_quota(&state.db, &account_id, "relay_requests").await { match crate::billing::service::check_quota(&state.db, &account_id, &role, "relay_requests").await {
Ok(check) if !check.allowed => { Ok(check) if !check.allowed => {
tracing::warn!( tracing::warn!(
"Quota exceeded for account {}: {} ({}/{})", "Quota exceeded for account {}: {} ({}/{})",
@@ -146,7 +146,7 @@ pub async fn quota_check_middleware(
} }
// P1-8 修复: 同时检查 input_tokens 配额 // P1-8 修复: 同时检查 input_tokens 配额
match crate::billing::service::check_quota(&state.db, &account_id, "input_tokens").await { match crate::billing::service::check_quota(&state.db, &account_id, &role, "input_tokens").await {
Ok(check) if !check.allowed => { Ok(check) if !check.allowed => {
tracing::warn!( tracing::warn!(
"Token quota exceeded for account {}: {} ({}/{})", "Token quota exceeded for account {}: {} ({}/{})",

View File

@@ -258,7 +258,8 @@ pub async fn seed_default_config_items(db: &PgPool) -> SaasResult<usize> {
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
sqlx::query( sqlx::query(
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) "INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, false, $8, $8)" VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, false, $8, $8)
ON CONFLICT (category, key_path) DO NOTHING"
) )
.bind(&id).bind(category).bind(key_path).bind(value_type) .bind(&id).bind(category).bind(key_path).bind(value_type)
.bind(current_value).bind(default_value).bind(description).bind(&now) .bind(current_value).bind(default_value).bind(description).bind(&now)
@@ -374,7 +375,8 @@ pub async fn sync_config(
let category = parts.first().unwrap_or(&"general").to_string(); let category = parts.first().unwrap_or(&"general").to_string();
sqlx::query( sqlx::query(
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) "INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at)
VALUES ($1, $2, $3, 'string', $4, $4, 'local', '客户端推送', false, $5, $5)" VALUES ($1, $2, $3, 'string', $4, $4, 'local', '客户端推送', false, $5, $5)
ON CONFLICT (category, key_path) DO NOTHING"
) )
.bind(&id).bind(&category).bind(key).bind(val).bind(&now) .bind(&id).bind(&category).bind(key).bind(val).bind(&now)
.execute(db).await?; .execute(db).await?;

View File

@@ -419,21 +419,33 @@ pub async fn revoke_account_api_key(
pub async fn get_usage_stats( pub async fn get_usage_stats(
db: &PgPool, account_id: &str, query: &UsageQuery, db: &PgPool, account_id: &str, query: &UsageQuery,
) -> SaasResult<UsageStats> { ) -> SaasResult<UsageStats> {
// Optional date filters: pass as TEXT with explicit $N::timestamptz SQL cast. // === Totals: from billing_usage_quotas (authoritative source) ===
// This avoids the sqlx NULL-without-type-OID problem — PG's ::timestamptz // billing_usage_quotas is written to on every relay request (both JSON and SSE),
// gives a typed NULL even when sqlx sends an untyped NULL. // whereas usage_records has 0 tokens for SSE requests. Use billing as the primary source.
let billing_row = sqlx::query(
"SELECT COALESCE(SUM(input_tokens), 0)::bigint,
COALESCE(SUM(output_tokens), 0)::bigint,
COALESCE(SUM(relay_requests), 0)::bigint
FROM billing_usage_quotas WHERE account_id = $1"
)
.bind(account_id)
.fetch_one(db)
.await?;
let total_input: i64 = billing_row.try_get(0).unwrap_or(0);
let total_output: i64 = billing_row.try_get(1).unwrap_or(0);
let total_requests: i64 = billing_row.try_get(2).unwrap_or(0);
// === Breakdowns: from usage_records (per-request detail) ===
// Optional date filters: pass as TEXT with explicit SQL cast.
let from_str: Option<&str> = query.from.as_deref(); let from_str: Option<&str> = query.from.as_deref();
// For 'to' date-only strings, append T23:59:59 to include the entire day
let to_str: Option<String> = query.to.as_ref().map(|s| { let to_str: Option<String> = query.to.as_ref().map(|s| {
if s.len() == 10 { format!("{}T23:59:59", s) } else { s.clone() } if s.len() == 10 { format!("{}T23:59:59", s) } else { s.clone() }
}); });
// Build SQL dynamically to avoid sqlx NULL-without-type-OID problem entirely. // Build SQL dynamically for usage_records breakdowns.
// Date parameters are injected as SQL literals (validated above via chrono parse). // Date parameters are injected as SQL literals (validated via chrono parse).
// Only account_id uses parameterized binding to prevent SQL injection on user input.
let mut where_parts = vec![format!("account_id = '{}'", account_id.replace('\'', "''"))]; let mut where_parts = vec![format!("account_id = '{}'", account_id.replace('\'', "''"))];
if let Some(f) = from_str { if let Some(f) = from_str {
// Validate: must be parseable as a date
let valid = chrono::NaiveDate::parse_from_str(f, "%Y-%m-%d").is_ok() let valid = chrono::NaiveDate::parse_from_str(f, "%Y-%m-%d").is_ok()
|| chrono::NaiveDateTime::parse_from_str(f, "%Y-%m-%dT%H:%M:%S%.f").is_ok(); || chrono::NaiveDateTime::parse_from_str(f, "%Y-%m-%dT%H:%M:%S%.f").is_ok();
if !valid { if !valid {
@@ -457,15 +469,6 @@ pub async fn get_usage_stats(
} }
let where_clause = where_parts.join(" AND "); let where_clause = where_parts.join(" AND ");
let total_sql = format!(
"SELECT COUNT(*)::bigint, COALESCE(SUM(input_tokens), 0)::bigint, COALESCE(SUM(output_tokens), 0)::bigint
FROM usage_records WHERE {}", where_clause
);
let row = sqlx::query(&total_sql).fetch_one(db).await?;
let total_requests: i64 = row.try_get(0).unwrap_or(0);
let total_input: i64 = row.try_get(1).unwrap_or(0);
let total_output: i64 = row.try_get(2).unwrap_or(0);
// 按模型统计 // 按模型统计
let by_model_sql = format!( let by_model_sql = format!(
"SELECT provider_id, model_id, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0)::bigint AS input_tokens, COALESCE(SUM(output_tokens), 0)::bigint AS output_tokens "SELECT provider_id, model_id, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0)::bigint AS input_tokens, COALESCE(SUM(output_tokens), 0)::bigint AS output_tokens

View File

@@ -68,7 +68,7 @@ pub async fn get_prompt(
Ok(Json(service::get_template_by_name(&state.db, &name).await?)) Ok(Json(service::get_template_by_name(&state.db, &name).await?))
} }
/// PUT /api/v1/prompts/{name} — 更新模板元数据 /// PUT /api/v1/prompts/{name} — 更新模板元数据 + 可选自动创建新版本
pub async fn update_prompt( pub async fn update_prompt(
State(state): State<AppState>, State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>, Extension(ctx): Extension<AuthContext>,
@@ -82,6 +82,11 @@ pub async fn update_prompt(
&state.db, &tmpl.id, &state.db, &tmpl.id,
req.description.as_deref(), req.description.as_deref(),
req.status.as_deref(), req.status.as_deref(),
req.system_prompt.as_deref(),
req.user_prompt_template.as_deref(),
req.variables.clone(),
req.changelog.as_deref(),
req.min_app_version.as_deref(),
).await?; ).await?;
log_operation(&state.db, &ctx.account_id, "prompt.update", "prompt", &tmpl.id, log_operation(&state.db, &ctx.account_id, "prompt.update", "prompt", &tmpl.id,
@@ -99,7 +104,7 @@ pub async fn archive_prompt(
check_permission(&ctx, "prompt:admin")?; check_permission(&ctx, "prompt:admin")?;
let tmpl = service::get_template_by_name(&state.db, &name).await?; let tmpl = service::get_template_by_name(&state.db, &name).await?;
let result = service::update_template(&state.db, &tmpl.id, None, Some("archived")).await?; let result = service::update_template(&state.db, &tmpl.id, None, Some("archived"), None, None, None, None, None).await?;
log_operation(&state.db, &ctx.account_id, "prompt.archive", "prompt", &tmpl.id, None, ctx.client_ip.as_deref()).await?; log_operation(&state.db, &ctx.account_id, "prompt.archive", "prompt", &tmpl.id, None, ctx.client_ip.as_deref()).await?;

View File

@@ -108,12 +108,20 @@ pub async fn list_templates(
Ok(PaginatedResponse { items, total, page, page_size }) Ok(PaginatedResponse { items, total, page, page_size })
} }
/// 更新模板元数据(不修改内容) /// 更新模板元数据 + 可选自动创建新版本
///
/// 当传入 `system_prompt` 时,自动创建新版本并递增 `current_version`。
/// 仅更新 `description`/`status` 时不会递增版本号。
pub async fn update_template( pub async fn update_template(
db: &PgPool, db: &PgPool,
id: &str, id: &str,
description: Option<&str>, description: Option<&str>,
status: Option<&str>, status: Option<&str>,
system_prompt: Option<&str>,
user_prompt_template: Option<&str>,
variables: Option<serde_json::Value>,
changelog: Option<&str>,
min_app_version: Option<&str>,
) -> SaasResult<PromptTemplateInfo> { ) -> SaasResult<PromptTemplateInfo> {
let now = chrono::Utc::now(); let now = chrono::Utc::now();
@@ -130,6 +138,11 @@ pub async fn update_template(
.bind(st).bind(&now).bind(id).execute(db).await?; .bind(st).bind(&now).bind(id).execute(db).await?;
} }
// Auto-create version when content is provided
if let Some(sp) = system_prompt {
create_version(db, id, sp, user_prompt_template, variables, changelog, min_app_version).await?;
}
get_template(db, id).await get_template(db, id).await
} }

View File

@@ -33,6 +33,12 @@ pub struct CreatePromptRequest {
pub struct UpdatePromptRequest { pub struct UpdatePromptRequest {
pub description: Option<String>, pub description: Option<String>,
pub status: Option<String>, pub status: Option<String>,
/// If provided, auto-creates a new version with this content
pub system_prompt: Option<String>,
pub user_prompt_template: Option<String>,
pub variables: Option<serde_json::Value>,
pub changelog: Option<String>,
pub min_app_version: Option<String>,
} }
// --- Prompt Version --- // --- Prompt Version ---

View File

@@ -23,6 +23,18 @@ pub async fn chat_completions(
) -> SaasResult<Response> { ) -> SaasResult<Response> {
check_permission(&ctx, "relay:use")?; check_permission(&ctx, "relay:use")?;
// P1-08 修复: 直接配额检查(不依赖中间件,防御性编程)
for quota_type in &["relay_requests", "input_tokens", "output_tokens"] {
let check = crate::billing::service::check_quota(
&state.db, &ctx.account_id, &ctx.role, quota_type,
).await?;
if !check.allowed {
return Err(SaasError::RateLimited(
check.reason.unwrap_or_else(|| format!("{} 配额已用尽", quota_type))
));
}
}
// 队列容量检查:使用内存 AtomicI64 计数器,消除 DB COUNT 查询 // 队列容量检查:使用内存 AtomicI64 计数器,消除 DB COUNT 查询
let max_queue_size = { let max_queue_size = {
let config = state.config.read().await; let config = state.config.read().await;
@@ -321,14 +333,8 @@ pub async fn chat_completions(
} }
} }
// SSE: relay_requests 实时递增tokens 由 AggregateUsageWorker 对账修正)
if let Err(e) = crate::billing::service::increment_dimension(
&state.db, &account_id_usage, "relay_requests",
).await {
tracing::warn!("Failed to increment billing relay_requests for {}: {}", account_id_usage, e);
}
// SSE 流已返回,递减队列计数器(流式任务开始处理) // SSE 流已返回,递减队列计数器(流式任务开始处理)
// 注意: relay_requests 和 tokens 统一由 execute_relay spawned task 中的 increment_usage 递增
state.cache.relay_dequeue(&account_id_usage); state.cache.relay_dequeue(&account_id_usage);
let response = axum::response::Response::builder() let response = axum::response::Response::builder()
@@ -372,13 +378,14 @@ pub async fn list_available_models(
State(state): State<AppState>, State(state): State<AppState>,
_ctx: Extension<AuthContext>, _ctx: Extension<AuthContext>,
) -> SaasResult<Json<Vec<serde_json::Value>>> { ) -> SaasResult<Json<Vec<serde_json::Value>>> {
// 单次 JOIN 查询替代 2 次全量加载 // 单次 JOIN 查询 + provider_keys 过滤:仅返回有活跃 API Key 的 provider 下的模型
let rows: Vec<(String, String, String, i64, i64, bool, bool, bool, String)> = sqlx::query_as( let rows: Vec<(String, String, String, i64, i64, bool, bool, bool, String)> = sqlx::query_as(
"SELECT m.model_id, m.provider_id, m.alias, m.context_window, "SELECT DISTINCT m.model_id, m.provider_id, m.alias, m.context_window,
m.max_output_tokens, m.supports_streaming, m.supports_vision, m.max_output_tokens, m.supports_streaming, m.supports_vision,
m.is_embedding, m.model_type m.is_embedding, m.model_type
FROM models m FROM models m
INNER JOIN providers p ON m.provider_id = p.id INNER JOIN providers p ON m.provider_id = p.id
INNER JOIN provider_keys pk ON pk.provider_id = p.id AND pk.is_active = true
WHERE m.enabled = true AND p.enabled = true WHERE m.enabled = true AND p.enabled = true
ORDER BY m.provider_id, m.model_id" ORDER BY m.provider_id, m.model_id"
) )

View File

@@ -117,7 +117,13 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
} }
// 此 Key 可用 — 解密 key_value // 此 Key 可用 — 解密 key_value
let decrypted_kv = decrypt_key_value(key_value, enc_key)?; let decrypted_kv = match decrypt_key_value(key_value, enc_key) {
Ok(v) => v,
Err(e) => {
tracing::warn!("Key {} decryption failed, skipping: {}", id, e);
continue;
}
};
let selection = KeySelection { let selection = KeySelection {
key: PoolKey { key: PoolKey {
id: id.clone(), id: id.clone(),
@@ -371,3 +377,52 @@ fn parse_cooldown_remaining(cooldown_until: &str, now: &str) -> i64 {
_ => 60, // 默认 60 秒 _ => 60, // 默认 60 秒
} }
} }
/// Startup self-healing: re-encrypt all provider keys with current encryption key.
///
/// For each encrypted key, attempts decryption with the current key.
/// If decryption succeeds, re-encrypts and updates in-place (idempotent).
/// If decryption fails, logs a warning and marks the key inactive.
pub async fn heal_provider_keys(db: &PgPool, enc_key: &[u8; 32]) -> usize {
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT id, key_value FROM provider_keys WHERE key_value LIKE 'enc:%'"
).fetch_all(db).await.unwrap_or_default();
let mut healed = 0usize;
let mut failed = 0usize;
for (id, key_value) in &rows {
match crypto::decrypt_value(key_value, enc_key) {
Ok(plaintext) => {
// Re-encrypt with current key (idempotent if same key)
match crypto::encrypt_value(&plaintext, enc_key) {
Ok(new_encrypted) => {
if let Err(e) = sqlx::query(
"UPDATE provider_keys SET key_value = $1 WHERE id = $2"
).bind(&new_encrypted).bind(id).execute(db).await {
tracing::warn!("[heal] Failed to update key {}: {}", id, e);
} else {
healed += 1;
}
}
Err(e) => {
tracing::warn!("[heal] Failed to re-encrypt key {}: {}", id, e);
failed += 1;
}
}
}
Err(e) => {
tracing::warn!("[heal] Cannot decrypt key {}, marking inactive: {}", id, e);
let _ = sqlx::query(
"UPDATE provider_keys SET is_active = FALSE WHERE id = $1"
).bind(id).execute(db).await;
failed += 1;
}
}
}
if healed > 0 || failed > 0 {
tracing::info!("[heal] Provider keys: {} re-encrypted, {} failed", healed, failed);
}
healed
}

View File

@@ -192,21 +192,39 @@ pub async fn update_task_status(
struct SseUsageCapture { struct SseUsageCapture {
input_tokens: i64, input_tokens: i64,
output_tokens: i64, output_tokens: i64,
/// 标记上游 stream 是否已结束channel 关闭或收到 [DONE]
stream_done: bool,
} }
impl SseUsageCapture { impl SseUsageCapture {
fn parse_sse_line(&mut self, line: &str) { fn parse_sse_line(&mut self, line: &str) {
if let Some(data) = line.strip_prefix("data: ") { // 兼容 "data: " 和 "data:" 两种前缀
if data == "[DONE]" { let data = if let Some(d) = line.strip_prefix("data: ") {
return; d
} } else if let Some(d) = line.strip_prefix("data:") {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) { d.trim_start()
if let Some(usage) = parsed.get("usage") { } else {
if let Some(input) = usage.get("prompt_tokens").and_then(|v| v.as_i64()) { return;
self.input_tokens = input; };
}
if let Some(output) = usage.get("completion_tokens").and_then(|v| v.as_i64()) { if data == "[DONE]" {
self.output_tokens = output; self.stream_done = true;
return;
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
if let Some(usage) = parsed.get("usage") {
// 标准 OpenAI 格式: prompt_tokens / completion_tokens
if let Some(input) = usage.get("prompt_tokens").and_then(|v| v.as_i64()) {
self.input_tokens = input;
}
if let Some(output) = usage.get("completion_tokens").and_then(|v| v.as_i64()) {
self.output_tokens = output;
}
// 兜底: 某些 provider 只返回 total_tokens
if self.input_tokens == 0 && self.output_tokens > 0 {
if let Some(total) = usage.get("total_tokens").and_then(|v| v.as_i64()) {
self.input_tokens = (total - self.output_tokens).max(0);
} }
} }
} }
@@ -315,6 +333,12 @@ pub async fn execute_relay(
let task_id_clone = task_id.to_string(); let task_id_clone = task_id.to_string();
let key_id_for_spawn = key_id.clone(); let key_id_for_spawn = key_id.clone();
let account_id_clone = account_id.to_string(); let account_id_clone = account_id.to_string();
let provider_id_clone = provider_id.to_string();
// 从 request_body 提取 model_id 用于 usage_records 归因
let model_id_clone = serde_json::from_str::<serde_json::Value>(request_body)
.ok()
.and_then(|v| v.get("model").and_then(|m| m.as_str()).map(String::from))
.unwrap_or_default();
// Bounded channel for backpressure: 128 chunks (~128KB) buffer. // Bounded channel for backpressure: 128 chunks (~128KB) buffer.
// If the client reads slowly, the upstream is signaled via // If the client reads slowly, the upstream is signaled via
@@ -350,6 +374,11 @@ pub async fn execute_relay(
} }
} }
} }
// Stream 结束后设置 stream_done 标志,通知 usage 轮询任务
{
let mut capture = usage_capture_clone.lock().await;
capture.stream_done = true;
}
}); });
// Build StreamBridge: wraps the bounded receiver with heartbeat, // Build StreamBridge: wraps the bounded receiver with heartbeat,
@@ -371,8 +400,8 @@ pub async fn execute_relay(
tokio::spawn(async move { tokio::spawn(async move {
let _permit = permit; // 持有 permit 直到任务完成 let _permit = permit; // 持有 permit 直到任务完成
// 等待 SSE 流结束 — 等待 capture 稳定tokens 不再增长) // 等待 SSE 流结束 — 优先等待 stream_done 标志,
// 替代原来固定 500ms 的 race condition // 兜底使用 token 稳定检测 + 最大等待时间
let max_wait = std::time::Duration::from_secs(120); let max_wait = std::time::Duration::from_secs(120);
let poll_interval = std::time::Duration::from_millis(500); let poll_interval = std::time::Duration::from_millis(500);
let start = tokio::time::Instant::now(); let start = tokio::time::Instant::now();
@@ -381,11 +410,15 @@ pub async fn execute_relay(
let (input, output) = loop { let (input, output) = loop {
tokio::time::sleep(poll_interval).await; tokio::time::sleep(poll_interval).await;
let capture = usage_capture.lock().await; let capture = usage_capture.lock().await;
// 优先: stream_done 标志表示上游已结束
if capture.stream_done {
break (capture.input_tokens, capture.output_tokens);
}
let total = capture.input_tokens + capture.output_tokens; let total = capture.input_tokens + capture.output_tokens;
// 兜底: token 数稳定检测(兼容不发送 [DONE] 的 provider
if total == last_tokens && total > 0 { if total == last_tokens && total > 0 {
stable_count += 1; stable_count += 1;
if stable_count >= 3 { if stable_count >= 3 {
// 连续 3 次稳定1.5s),认为流结束
break (capture.input_tokens, capture.output_tokens); break (capture.input_tokens, capture.output_tokens);
} }
} else { } else {
@@ -393,8 +426,13 @@ pub async fn execute_relay(
last_tokens = total; last_tokens = total;
} }
drop(capture); drop(capture);
// 最终兜底: 超时保护
if start.elapsed() >= max_wait { if start.elapsed() >= max_wait {
let capture = usage_capture.lock().await; let capture = usage_capture.lock().await;
tracing::warn!(
"SSE usage capture timed out for task {}, tokens: in={} out={}",
task_id_clone, capture.input_tokens, capture.output_tokens
);
break (capture.input_tokens, capture.output_tokens); break (capture.input_tokens, capture.output_tokens);
} }
}; };
@@ -402,16 +440,23 @@ pub async fn execute_relay(
let input_opt = if input > 0 { Some(input) } else { None }; let input_opt = if input > 0 { Some(input) } else { None };
let output_opt = if output > 0 { Some(output) } else { None }; let output_opt = if output > 0 { Some(output) } else { None };
// Record task status + billing usage + key usage // Record task status + billing usage + key usage + usage_records
let db_op = async { let db_op = async {
if let Err(e) = update_task_status(&db_clone, &task_id_clone, "completed", input_opt, output_opt, None).await { if let Err(e) = update_task_status(&db_clone, &task_id_clone, "completed", input_opt, output_opt, None).await {
tracing::warn!("Failed to update task status after SSE stream: {}", e); tracing::warn!("Failed to update task status after SSE stream: {}", e);
} }
// P2-9 修复: SSE 路径也更新 billing_usage_quotas // SSE 路径回写 usage_records + billing 配额
if input > 0 || output > 0 { if input > 0 || output > 0 {
// 回写 usage_records 真实 token补全 handlers.rs 中 token=0 的占位记录)
if let Err(e) = crate::model_config::service::record_usage(
&db_clone, &account_id_clone, &provider_id_clone, &model_id_clone,
input, output, None, "success", None,
).await {
tracing::warn!("Failed to record SSE usage for task {}: {}", task_id_clone, e);
}
// 更新 billing_usage_quotastokens + relay_requests 同步递增)
if let Err(e) = crate::billing::service::increment_usage( if let Err(e) = crate::billing::service::increment_usage(
&db_clone, &account_id_clone, &db_clone, &account_id_clone, input, output,
input, output,
).await { ).await {
tracing::warn!("Failed to increment billing usage for SSE task {}: {}", task_id_clone, e); tracing::warn!("Failed to increment billing usage for SSE task {}: {}", task_id_clone, e);
} }
@@ -591,6 +636,17 @@ pub async fn execute_relay_with_failover(
candidate.model_id candidate.model_id
); );
} }
// P2-09 修复: 非 SSE 响应在 failover 成功后记录 tokens 并标记 completed
if let RelayResponse::Json(ref body) = response {
let (input_tokens, output_tokens) = extract_token_usage(body);
if input_tokens > 0 || output_tokens > 0 {
if let Err(e) = update_task_status(db, task_id, "completed",
Some(input_tokens), Some(output_tokens), None).await {
tracing::warn!("Failed to update task {} tokens after failover: {}", task_id, e);
}
}
}
// SSE 响应由 StreamBridge 后台任务处理,无需在此更新
return Ok((response, candidate.provider_id.clone(), candidate.model_id.clone())); return Ok((response, candidate.provider_id.clone(), candidate.model_id.clone()));
} }
Err(SaasError::RateLimited(msg)) => { Err(SaasError::RateLimited(msg)) => {

View File

@@ -82,6 +82,7 @@ pub fn start_scheduler(config: &SchedulerConfig, _db: PgPool, dispatcher: Worker
pub fn start_db_cleanup_tasks(db: PgPool) { pub fn start_db_cleanup_tasks(db: PgPool) {
let db_devices = db.clone(); let db_devices = db.clone();
let db_key_pool = db.clone(); let db_key_pool = db.clone();
let db_relay = db.clone();
// 每 24 小时清理不活跃设备 // 每 24 小时清理不活跃设备
tokio::spawn(async move { tokio::spawn(async move {
@@ -128,6 +129,28 @@ pub fn start_db_cleanup_tasks(db: PgPool) {
} }
} }
}); });
// 每 5 分钟清理超时的 relay_tasksstatus=processing 且 updated_at 超过 10 分钟)
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300));
loop {
interval.tick().await;
match sqlx::query(
"UPDATE relay_tasks SET status = 'failed', error_message = 'timeout: upstream not responding', completed_at = NOW() \
WHERE status = 'processing' AND updated_at < NOW() - INTERVAL '10 minutes'"
)
.execute(&db_relay)
.await
{
Ok(result) => {
if result.rows_affected() > 0 {
tracing::warn!("Cleaned up {} timed-out relay tasks (>10m processing)", result.rows_affected());
}
}
Err(e) => tracing::error!("Relay task timeout cleanup failed: {}", e),
}
}
});
} }
/// 用户任务调度器 /// 用户任务调度器

View File

@@ -1,9 +1,95 @@
//! Error types for ZCLAW //! Error types for ZCLAW
//!
//! Provides structured error classification via [`ErrorKind`] and machine-readable
//! error codes alongside human-readable messages. The enum variants are preserved
//! for backward compatibility — all existing construction sites continue to work.
use thiserror::Error; use serde::{Deserialize, Serialize};
/// ZCLAW unified error type // === Error Kind (structured classification) ===
#[derive(Debug, Error)]
/// Machine-readable error category for structured error reporting.
///
/// Each variant maps to a stable error code prefix (e.g., `E404x` for `NotFound`).
/// Frontend code should match on `ErrorKind` rather than string patterns.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
NotFound,
Permission,
Auth,
Llm,
Tool,
Storage,
Config,
Http,
Timeout,
Validation,
LoopDetected,
RateLimit,
Mcp,
Security,
Hand,
Export,
Internal,
}
// === Error Codes ===
/// Stable error codes for machine-readable error matching.
///
/// Format: `E{HTTP_STATUS_MIRROR}{SEQUENCE}`.
/// Frontend should use these codes instead of regex-matching error strings.
pub mod error_codes {
// Not Found (4040-4049)
pub const NOT_FOUND: &str = "E4040";
// Permission (4030-4039)
pub const PERMISSION_DENIED: &str = "E4030";
// Auth (4010-4019)
pub const AUTH_FAILED: &str = "E4010";
// LLM (5000-5009)
pub const LLM_ERROR: &str = "E5001";
pub const LLM_TIMEOUT: &str = "E5002";
pub const LLM_RATE_LIMITED: &str = "E5003";
// Tool (5010-5019)
pub const TOOL_ERROR: &str = "E5010";
pub const TOOL_NOT_FOUND: &str = "E5011";
pub const TOOL_TIMEOUT: &str = "E5012";
// Storage (5020-5029)
pub const STORAGE_ERROR: &str = "E5020";
pub const STORAGE_CORRUPTION: &str = "E5021";
// Config (5030-5039)
pub const CONFIG_ERROR: &str = "E5030";
// HTTP (5040-5049)
pub const HTTP_ERROR: &str = "E5040";
// Timeout (5050-5059)
pub const TIMEOUT: &str = "E5050";
// Validation (4000-4009)
pub const VALIDATION_ERROR: &str = "E4000";
// Loop (5060-5069)
pub const LOOP_DETECTED: &str = "E5060";
// Rate Limit (4290-4299)
pub const RATE_LIMITED: &str = "E4290";
// MCP (5070-5079)
pub const MCP_ERROR: &str = "E5070";
// Security (5080-5089)
pub const SECURITY_ERROR: &str = "E5080";
// Hand (5090-5099)
pub const HAND_ERROR: &str = "E5090";
// Export (5100-5109)
pub const EXPORT_ERROR: &str = "E5100";
// Internal (5110-5119)
pub const INTERNAL: &str = "E5110";
}
// === ZclawError ===
/// ZCLAW unified error type.
///
/// All variants are preserved for backward compatibility.
/// Use `.kind()` and `.code()` for structured classification.
/// Implements [`Serialize`] for JSON transport to frontend.
#[derive(Debug, thiserror::Error)]
pub enum ZclawError { pub enum ZclawError {
#[error("Not found: {0}")] #[error("Not found: {0}")]
NotFound(String), NotFound(String),
@@ -60,6 +146,80 @@ pub enum ZclawError {
HandError(String), HandError(String),
} }
impl ZclawError {
/// Returns the structured error category.
pub fn kind(&self) -> ErrorKind {
match self {
Self::NotFound(_) => ErrorKind::NotFound,
Self::PermissionDenied(_) => ErrorKind::Permission,
Self::LlmError(_) => ErrorKind::Llm,
Self::ToolError(_) => ErrorKind::Tool,
Self::StorageError(_) => ErrorKind::Storage,
Self::ConfigError(_) => ErrorKind::Config,
Self::SerializationError(_) => ErrorKind::Internal,
Self::IoError(_) => ErrorKind::Internal,
Self::HttpError(_) => ErrorKind::Http,
Self::Timeout(_) => ErrorKind::Timeout,
Self::InvalidInput(_) => ErrorKind::Validation,
Self::LoopDetected(_) => ErrorKind::LoopDetected,
Self::RateLimited(_) => ErrorKind::RateLimit,
Self::Internal(_) => ErrorKind::Internal,
Self::ExportError(_) => ErrorKind::Export,
Self::McpError(_) => ErrorKind::Mcp,
Self::SecurityError(_) => ErrorKind::Security,
Self::HandError(_) => ErrorKind::Hand,
}
}
/// Returns the stable error code (e.g., `"E4040"` for `NotFound`).
pub fn code(&self) -> &'static str {
match self {
Self::NotFound(_) => error_codes::NOT_FOUND,
Self::PermissionDenied(_) => error_codes::PERMISSION_DENIED,
Self::LlmError(_) => error_codes::LLM_ERROR,
Self::ToolError(_) => error_codes::TOOL_ERROR,
Self::StorageError(_) => error_codes::STORAGE_ERROR,
Self::ConfigError(_) => error_codes::CONFIG_ERROR,
Self::SerializationError(_) => error_codes::INTERNAL,
Self::IoError(_) => error_codes::INTERNAL,
Self::HttpError(_) => error_codes::HTTP_ERROR,
Self::Timeout(_) => error_codes::TIMEOUT,
Self::InvalidInput(_) => error_codes::VALIDATION_ERROR,
Self::LoopDetected(_) => error_codes::LOOP_DETECTED,
Self::RateLimited(_) => error_codes::RATE_LIMITED,
Self::Internal(_) => error_codes::INTERNAL,
Self::ExportError(_) => error_codes::EXPORT_ERROR,
Self::McpError(_) => error_codes::MCP_ERROR,
Self::SecurityError(_) => error_codes::SECURITY_ERROR,
Self::HandError(_) => error_codes::HAND_ERROR,
}
}
}
/// Structured JSON representation for frontend consumption.
#[derive(Debug, Clone, Serialize)]
pub struct ErrorDetail {
pub kind: ErrorKind,
pub code: &'static str,
pub message: String,
}
impl From<&ZclawError> for ErrorDetail {
fn from(err: &ZclawError) -> Self {
Self {
kind: err.kind(),
code: err.code(),
message: err.to_string(),
}
}
}
impl Serialize for ZclawError {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
ErrorDetail::from(self).serialize(serializer)
}
}
/// Result type alias for ZCLAW operations /// Result type alias for ZCLAW operations
pub type Result<T> = std::result::Result<T, ZclawError>; pub type Result<T> = std::result::Result<T, ZclawError>;
@@ -177,4 +337,63 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ZclawError::NotFound(_))); assert!(matches!(result.unwrap_err(), ZclawError::NotFound(_)));
} }
// === New structured error tests ===
#[test]
fn test_error_kind_mapping() {
assert_eq!(ZclawError::NotFound("x".into()).kind(), ErrorKind::NotFound);
assert_eq!(ZclawError::PermissionDenied("x".into()).kind(), ErrorKind::Permission);
assert_eq!(ZclawError::LlmError("x".into()).kind(), ErrorKind::Llm);
assert_eq!(ZclawError::ToolError("x".into()).kind(), ErrorKind::Tool);
assert_eq!(ZclawError::StorageError("x".into()).kind(), ErrorKind::Storage);
assert_eq!(ZclawError::InvalidInput("x".into()).kind(), ErrorKind::Validation);
assert_eq!(ZclawError::Timeout("x".into()).kind(), ErrorKind::Timeout);
assert_eq!(ZclawError::SecurityError("x".into()).kind(), ErrorKind::Security);
assert_eq!(ZclawError::HandError("x".into()).kind(), ErrorKind::Hand);
assert_eq!(ZclawError::McpError("x".into()).kind(), ErrorKind::Mcp);
assert_eq!(ZclawError::Internal("x".into()).kind(), ErrorKind::Internal);
}
#[test]
fn test_error_code_stability() {
assert_eq!(ZclawError::NotFound("x".into()).code(), "E4040");
assert_eq!(ZclawError::PermissionDenied("x".into()).code(), "E4030");
assert_eq!(ZclawError::LlmError("x".into()).code(), "E5001");
assert_eq!(ZclawError::ToolError("x".into()).code(), "E5010");
assert_eq!(ZclawError::StorageError("x".into()).code(), "E5020");
assert_eq!(ZclawError::InvalidInput("x".into()).code(), "E4000");
assert_eq!(ZclawError::Timeout("x".into()).code(), "E5050");
assert_eq!(ZclawError::SecurityError("x".into()).code(), "E5080");
assert_eq!(ZclawError::HandError("x".into()).code(), "E5090");
assert_eq!(ZclawError::McpError("x".into()).code(), "E5070");
assert_eq!(ZclawError::Internal("x".into()).code(), "E5110");
}
#[test]
fn test_error_serialize_json() {
let err = ZclawError::NotFound("agent-123".to_string());
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["kind"], "not_found");
assert_eq!(json["code"], "E4040");
assert_eq!(json["message"], "Not found: agent-123");
}
#[test]
fn test_error_detail_from() {
let err = ZclawError::LlmError("timeout".to_string());
let detail = ErrorDetail::from(&err);
assert_eq!(detail.kind, ErrorKind::Llm);
assert_eq!(detail.code, "E5001");
assert_eq!(detail.message, "LLM error: timeout");
}
#[test]
fn test_error_kind_serde_roundtrip() {
let kind = ErrorKind::Storage;
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, "\"storage\"");
let back: ErrorKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, kind);
}
} }

View File

@@ -47,6 +47,7 @@ pub struct ClassroomChatCmdRequest {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Send a message in the classroom chat and get multi-agent responses. /// Send a message in the classroom chat and get multi-agent responses.
// @reserved: classroom chat functionality
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn classroom_chat( pub async fn classroom_chat(

View File

@@ -88,6 +88,7 @@ fn stage_name(stage: &GenerationStage) -> &'static str {
/// Start classroom generation (4-stage pipeline). /// Start classroom generation (4-stage pipeline).
/// Progress events are emitted via `classroom:progress`. /// Progress events are emitted via `classroom:progress`.
/// Supports cancellation between stages by removing the task from GenerationTasks. /// Supports cancellation between stages by removing the task from GenerationTasks.
// @reserved: classroom generation
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn classroom_generate( pub async fn classroom_generate(
@@ -270,6 +271,7 @@ pub async fn classroom_cancel_generation(
} }
/// Retrieve a generated classroom by ID /// Retrieve a generated classroom by ID
// @reserved: classroom generation
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn classroom_get( pub async fn classroom_get(

View File

@@ -101,6 +101,7 @@ impl ClassroomPersistence {
} }
/// Delete a classroom and its chat history. /// Delete a classroom and its chat history.
#[allow(dead_code)]
pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> { pub async fn delete_classroom(&self, classroom_id: &str) -> Result<(), String> {
let mut conn = self.conn.lock().await; let mut conn = self.conn.lock().await;
sqlx::query("DELETE FROM classrooms WHERE id = ?") sqlx::query("DELETE FROM classrooms WHERE id = ?")

View File

@@ -52,6 +52,7 @@ pub(crate) struct ProcessLogsResponse {
} }
/// Get ZCLAW Kernel status /// Get ZCLAW Kernel status
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_status(app: AppHandle) -> Result<LocalGatewayStatus, String> { pub fn zclaw_status(app: AppHandle) -> Result<LocalGatewayStatus, String> {
@@ -59,6 +60,7 @@ pub fn zclaw_status(app: AppHandle) -> Result<LocalGatewayStatus, String> {
} }
/// Start ZCLAW Kernel /// Start ZCLAW Kernel
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_start(app: AppHandle) -> Result<LocalGatewayStatus, String> { pub fn zclaw_start(app: AppHandle) -> Result<LocalGatewayStatus, String> {
@@ -69,6 +71,7 @@ pub fn zclaw_start(app: AppHandle) -> Result<LocalGatewayStatus, String> {
} }
/// Stop ZCLAW Kernel /// Stop ZCLAW Kernel
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_stop(app: AppHandle) -> Result<LocalGatewayStatus, String> { pub fn zclaw_stop(app: AppHandle) -> Result<LocalGatewayStatus, String> {
@@ -78,6 +81,7 @@ pub fn zclaw_stop(app: AppHandle) -> Result<LocalGatewayStatus, String> {
} }
/// Restart ZCLAW Kernel /// Restart ZCLAW Kernel
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_restart(app: AppHandle) -> Result<LocalGatewayStatus, String> { pub fn zclaw_restart(app: AppHandle) -> Result<LocalGatewayStatus, String> {
@@ -88,6 +92,7 @@ pub fn zclaw_restart(app: AppHandle) -> Result<LocalGatewayStatus, String> {
} }
/// Get local auth token from ZCLAW config /// Get local auth token from ZCLAW config
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_local_auth() -> Result<LocalGatewayAuth, String> { pub fn zclaw_local_auth() -> Result<LocalGatewayAuth, String> {
@@ -95,6 +100,7 @@ pub fn zclaw_local_auth() -> Result<LocalGatewayAuth, String> {
} }
/// Prepare ZCLAW for Tauri (update allowed origins) /// Prepare ZCLAW for Tauri (update allowed origins)
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_prepare_for_tauri(app: AppHandle) -> Result<LocalGatewayPrepareResult, String> { pub fn zclaw_prepare_for_tauri(app: AppHandle) -> Result<LocalGatewayPrepareResult, String> {
@@ -102,6 +108,7 @@ pub fn zclaw_prepare_for_tauri(app: AppHandle) -> Result<LocalGatewayPrepareResu
} }
/// Approve device pairing request /// Approve device pairing request
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_approve_device_pairing( pub fn zclaw_approve_device_pairing(
@@ -122,6 +129,7 @@ pub fn zclaw_doctor(app: AppHandle) -> Result<String, String> {
} }
/// List ZCLAW processes /// List ZCLAW processes
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_process_list(app: AppHandle) -> Result<ProcessListResponse, String> { pub fn zclaw_process_list(app: AppHandle) -> Result<ProcessListResponse, String> {
@@ -160,6 +168,7 @@ pub fn zclaw_process_list(app: AppHandle) -> Result<ProcessListResponse, String>
} }
/// Get ZCLAW process logs /// Get ZCLAW process logs
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_process_logs( pub fn zclaw_process_logs(
@@ -224,6 +233,7 @@ pub fn zclaw_process_logs(
} }
/// Get ZCLAW version information /// Get ZCLAW version information
// @reserved: system control
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_version(app: AppHandle) -> Result<VersionResponse, String> { pub fn zclaw_version(app: AppHandle) -> Result<VersionResponse, String> {

View File

@@ -112,6 +112,7 @@ fn get_process_uptime(status: &LocalGatewayStatus) -> Option<u64> {
} }
/// Perform comprehensive health check on ZCLAW Kernel /// Perform comprehensive health check on ZCLAW Kernel
// @reserved: system health check
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn zclaw_health_check( pub fn zclaw_health_check(

View File

@@ -10,12 +10,11 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, warn}; use tracing::{debug, warn};
use uuid::Uuid;
use zclaw_growth::ExperienceStore; use zclaw_growth::ExperienceStore;
use zclaw_types::Result; use zclaw_types::Result;
use super::pain_aggregator::PainPoint; use super::pain_aggregator::PainPoint;
use super::solution_generator::{Proposal, ProposalStatus}; use super::solution_generator::Proposal;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared completion status // Shared completion status

View File

@@ -0,0 +1,126 @@
//! Health Snapshot — on-demand query for all subsystem health status
//!
//! Provides a single Tauri command that aggregates health data from:
//! - Intelligence Heartbeat engine (running state, config, alerts)
//! - Memory pipeline (entries count, storage size)
//!
//! Connection and SaaS status are managed by frontend stores and not included here.
use serde::Serialize;
use super::heartbeat::{HeartbeatConfig, HeartbeatEngineState, HeartbeatResult};
/// Aggregated health snapshot from Rust backend
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HealthSnapshot {
pub timestamp: String,
pub intelligence: IntelligenceHealth,
pub memory: MemoryHealth,
}
/// Intelligence heartbeat engine status
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IntelligenceHealth {
pub engine_running: bool,
pub config: HeartbeatConfig,
pub last_tick: Option<String>,
pub alert_count_24h: usize,
pub total_checks: usize,
}
/// Memory pipeline status
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryHealth {
pub total_entries: usize,
pub storage_size_bytes: u64,
pub last_extraction: Option<String>,
}
/// Query a unified health snapshot for an agent
// @connected
#[tauri::command]
pub async fn health_snapshot(
agent_id: String,
heartbeat_state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<HealthSnapshot, String> {
let engines = heartbeat_state.lock().await;
let engine = engines
.get(&agent_id)
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
let engine_running = engine.is_running().await;
let config = engine.get_config().await;
let history: Vec<HeartbeatResult> = engine.get_history(100).await;
// Calculate alert count in the last 24 hours
let now = chrono::Utc::now();
let twenty_four_hours_ago = now - chrono::Duration::hours(24);
let alert_count_24h = history
.iter()
.filter(|r| {
r.timestamp.parse::<chrono::DateTime<chrono::Utc>>()
.map(|t| t > twenty_four_hours_ago)
.unwrap_or(false)
})
.flat_map(|r| r.alerts.iter())
.count();
let last_tick = history.first().map(|r| r.timestamp.clone());
// Memory health from cached stats (fallback to zeros)
// Read cache in a separate scope to ensure RwLockReadGuard is dropped before any .await
let cached_stats: Option<super::heartbeat::MemoryStatsCache> = {
let cache = super::heartbeat::get_memory_stats_cache();
match cache.read() {
Ok(c) => c.get(&agent_id).cloned(),
Err(_) => None,
}
}; // RwLockReadGuard dropped here
let memory = match cached_stats {
Some(s) => MemoryHealth {
total_entries: s.total_entries,
storage_size_bytes: s.storage_size_bytes as u64,
last_extraction: s.last_updated,
},
None => {
// Fallback: try to query VikingStorage directly
match crate::viking_commands::get_storage().await {
Ok(storage) => {
match zclaw_growth::VikingStorage::find_by_prefix(&*storage, &format!("mem:{}", agent_id)).await {
Ok(entries) => MemoryHealth {
total_entries: entries.len(),
storage_size_bytes: 0,
last_extraction: None,
},
Err(_) => MemoryHealth {
total_entries: 0,
storage_size_bytes: 0,
last_extraction: None,
},
}
}
Err(_) => MemoryHealth {
total_entries: 0,
storage_size_bytes: 0,
last_extraction: None,
},
}
}
};
Ok(HealthSnapshot {
timestamp: chrono::Utc::now().to_rfc3339(),
intelligence: IntelligenceHealth {
engine_running,
config,
last_tick,
alert_count_24h,
total_checks: 5, // Fixed: 5 built-in checks
},
memory,
})
}

View File

@@ -13,9 +13,10 @@ use chrono::{Local, Timelike};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration; use std::time::Duration;
use tokio::sync::{broadcast, Mutex}; use tokio::sync::{broadcast, Mutex, Notify};
use tokio::time::interval; use tauri::{AppHandle, Emitter};
// === Types === // === Types ===
@@ -91,9 +92,9 @@ pub enum HeartbeatStatus {
Alert, Alert,
} }
/// Type alias for heartbeat check function /// Global AppHandle for emitting heartbeat alerts to frontend
#[allow(dead_code)] // Reserved for future proactive check registration /// Set by heartbeat_init, used by background tick task
type HeartbeatCheckFn = Box<dyn Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<HeartbeatAlert>> + Send>> + Send + Sync>; static HEARTBEAT_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
// === Default Config === // === Default Config ===
@@ -117,6 +118,7 @@ pub struct HeartbeatEngine {
agent_id: String, agent_id: String,
config: Arc<Mutex<HeartbeatConfig>>, config: Arc<Mutex<HeartbeatConfig>>,
running: Arc<Mutex<bool>>, running: Arc<Mutex<bool>>,
stop_notify: Arc<Notify>,
alert_sender: broadcast::Sender<HeartbeatAlert>, alert_sender: broadcast::Sender<HeartbeatAlert>,
history: Arc<Mutex<Vec<HeartbeatResult>>>, history: Arc<Mutex<Vec<HeartbeatResult>>>,
} }
@@ -129,6 +131,7 @@ impl HeartbeatEngine {
agent_id, agent_id,
config: Arc::new(Mutex::new(config.unwrap_or_default())), config: Arc::new(Mutex::new(config.unwrap_or_default())),
running: Arc::new(Mutex::new(false)), running: Arc::new(Mutex::new(false)),
stop_notify: Arc::new(Notify::new()),
alert_sender, alert_sender,
history: Arc::new(Mutex::new(Vec::new())), history: Arc::new(Mutex::new(Vec::new())),
} }
@@ -146,16 +149,20 @@ impl HeartbeatEngine {
let agent_id = self.agent_id.clone(); let agent_id = self.agent_id.clone();
let config = Arc::clone(&self.config); let config = Arc::clone(&self.config);
let running_clone = Arc::clone(&self.running); let running_clone = Arc::clone(&self.running);
let stop_notify = Arc::clone(&self.stop_notify);
let alert_sender = self.alert_sender.clone(); let alert_sender = self.alert_sender.clone();
let history = Arc::clone(&self.history); let history = Arc::clone(&self.history);
tokio::spawn(async move { tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(
config.lock().await.interval_minutes * 60,
));
loop { loop {
ticker.tick().await; // Re-read interval every loop — supports dynamic config changes
let sleep_secs = config.lock().await.interval_minutes * 60;
// Interruptible sleep: stop_notify wakes immediately on stop()
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(sleep_secs)) => {},
_ = stop_notify.notified() => { break; }
};
if !*running_clone.lock().await { if !*running_clone.lock().await {
break; break;
@@ -199,10 +206,10 @@ impl HeartbeatEngine {
pub async fn stop(&self) { pub async fn stop(&self) {
let mut running = self.running.lock().await; let mut running = self.running.lock().await;
*running = false; *running = false;
self.stop_notify.notify_one(); // Wake up sleep immediately
} }
/// Check if the engine is running /// Check if the engine is running
#[allow(dead_code)] // Reserved for UI status display
pub async fn is_running(&self) -> bool { pub async fn is_running(&self) -> bool {
*self.running.lock().await *self.running.lock().await
} }
@@ -237,12 +244,6 @@ impl HeartbeatEngine {
result result
} }
/// Subscribe to alerts
#[allow(dead_code)] // Reserved for future UI notification integration
pub fn subscribe(&self) -> broadcast::Receiver<HeartbeatAlert> {
self.alert_sender.subscribe()
}
/// Get heartbeat history /// Get heartbeat history
pub async fn get_history(&self, limit: usize) -> Vec<HeartbeatResult> { pub async fn get_history(&self, limit: usize) -> Vec<HeartbeatResult> {
let hist = self.history.lock().await; let hist = self.history.lock().await;
@@ -280,10 +281,22 @@ impl HeartbeatEngine {
} }
} }
/// Update configuration /// Update configuration and persist to VikingStorage
pub async fn update_config(&self, updates: HeartbeatConfig) { pub async fn update_config(&self, updates: HeartbeatConfig) {
let mut config = self.config.lock().await; *self.config.lock().await = updates.clone();
*config = updates; // Persist config to VikingStorage
let key = format!("heartbeat:config:{}", self.agent_id);
tokio::spawn(async move {
if let Ok(storage) = crate::viking_commands::get_storage().await {
if let Ok(json) = serde_json::to_string(&updates) {
if let Err(e) = zclaw_growth::VikingStorage::store_metadata_json(
&*storage, &key, &json,
).await {
tracing::warn!("[heartbeat] Failed to persist config: {}", e);
}
}
}
});
} }
/// Get current configuration /// Get current configuration
@@ -368,11 +381,20 @@ async fn execute_tick(
// Filter by proactivity level // Filter by proactivity level
let filtered_alerts = filter_by_proactivity(&alerts, &cfg.proactivity_level); let filtered_alerts = filter_by_proactivity(&alerts, &cfg.proactivity_level);
// Send alerts // Send alerts via broadcast channel (internal)
for alert in &filtered_alerts { for alert in &filtered_alerts {
let _ = alert_sender.send(alert.clone()); let _ = alert_sender.send(alert.clone());
} }
// Emit alerts to frontend via Tauri event (real-time toast)
if !filtered_alerts.is_empty() {
if let Some(app) = HEARTBEAT_APP_HANDLE.get() {
if let Err(e) = app.emit("heartbeat:alert", &filtered_alerts) {
tracing::warn!("[heartbeat] Failed to emit alert: {}", e);
}
}
}
let status = if filtered_alerts.is_empty() { let status = if filtered_alerts.is_empty() {
HeartbeatStatus::Ok HeartbeatStatus::Ok
} else { } else {
@@ -410,7 +432,6 @@ fn filter_by_proactivity(alerts: &[HeartbeatAlert], level: &ProactivityLevel) ->
/// Pattern detection counters (shared state for personality detection) /// Pattern detection counters (shared state for personality detection)
use std::collections::HashMap as StdHashMap; use std::collections::HashMap as StdHashMap;
use std::sync::RwLock; use std::sync::RwLock;
use std::sync::OnceLock;
/// Global correction counters /// Global correction counters
static CORRECTION_COUNTERS: OnceLock<RwLock<StdHashMap<String, usize>>> = OnceLock::new(); static CORRECTION_COUNTERS: OnceLock<RwLock<StdHashMap<String, usize>>> = OnceLock::new();
@@ -437,7 +458,7 @@ fn get_correction_counters() -> &'static RwLock<StdHashMap<String, usize>> {
CORRECTION_COUNTERS.get_or_init(|| RwLock::new(StdHashMap::new())) CORRECTION_COUNTERS.get_or_init(|| RwLock::new(StdHashMap::new()))
} }
fn get_memory_stats_cache() -> &'static RwLock<StdHashMap<String, MemoryStatsCache>> { pub fn get_memory_stats_cache() -> &'static RwLock<StdHashMap<String, MemoryStatsCache>> {
MEMORY_STATS_CACHE.get_or_init(|| RwLock::new(StdHashMap::new())) MEMORY_STATS_CACHE.get_or_init(|| RwLock::new(StdHashMap::new()))
} }
@@ -537,6 +558,19 @@ fn check_correction_patterns(agent_id: &str) -> Vec<HeartbeatAlert> {
alerts alerts
} }
/// Fallback: query memory stats directly from VikingStorage when frontend cache is empty
fn query_memory_stats_fallback(agent_id: &str) -> Option<MemoryStatsCache> {
// This is a synchronous approximation — we check if we have a recent cache entry
// by probing the global cache one more time with a slightly different approach
// The real fallback is to count VikingStorage entries, but that's async and can't
// be called from sync check functions. Instead, we return None and let the
// periodic memory stats sync populate the cache.
// NOTE: This is intentionally a lightweight no-op fallback. The real data comes
// from the frontend sync (every 5 min) or the upcoming health_snapshot command.
let _ = agent_id;
None
}
/// Check for pending task memories /// Check for pending task memories
/// Uses cached memory stats to detect task backlog /// Uses cached memory stats to detect task backlog
fn check_pending_tasks(agent_id: &str) -> Option<HeartbeatAlert> { fn check_pending_tasks(agent_id: &str) -> Option<HeartbeatAlert> {
@@ -557,15 +591,34 @@ fn check_pending_tasks(agent_id: &str) -> Option<HeartbeatAlert> {
}, },
Some(_) => None, // Stats available but no alert needed Some(_) => None, // Stats available but no alert needed
None => { None => {
// Cache is empty - warn about missing sync // Cache is empty — fallback to VikingStorage direct query
tracing::warn!("[Heartbeat] Memory stats cache is empty for agent {}, waiting for frontend sync", agent_id); let fallback = query_memory_stats_fallback(agent_id);
Some(HeartbeatAlert { match fallback {
title: "记忆统计未同步".to_string(), Some(stats) if stats.task_count >= 5 => {
content: "心跳引擎未能获取记忆统计信息,部分检查被跳过。请确保记忆系统正常运行。".to_string(), Some(HeartbeatAlert {
urgency: Urgency::Low, title: "待办任务积压".to_string(),
source: "pending-tasks".to_string(), content: format!("当前有 {} 个待办任务未完成,建议处理或重新评估优先级", stats.task_count),
timestamp: chrono::Utc::now().to_rfc3339(), urgency: if stats.task_count >= 10 {
}) Urgency::High
} else {
Urgency::Medium
},
source: "pending-tasks".to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
})
},
Some(_) => None, // Fallback stats available but no alert needed
None => {
tracing::warn!("[Heartbeat] Memory stats unavailable for agent {} (cache + fallback empty)", agent_id);
Some(HeartbeatAlert {
title: "记忆统计未同步".to_string(),
content: "心跳引擎未能获取记忆统计信息,部分检查被跳过。请确保记忆系统正常运行。".to_string(),
urgency: Urgency::Low,
source: "pending-tasks".to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
})
}
}
} }
} }
} }
@@ -706,15 +759,21 @@ pub type HeartbeatEngineState = Arc<Mutex<HashMap<String, HeartbeatEngine>>>;
/// Initialize heartbeat engine for an agent /// Initialize heartbeat engine for an agent
/// ///
/// Restores persisted interaction time from VikingStorage so idle-greeting /// Restores persisted interaction time and config from VikingStorage so
/// check works correctly across app restarts. /// idle-greeting check and config changes survive across app restarts.
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn heartbeat_init( pub async fn heartbeat_init(
app: AppHandle,
agent_id: String, agent_id: String,
config: Option<HeartbeatConfig>, config: Option<HeartbeatConfig>,
state: tauri::State<'_, HeartbeatEngineState>, state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<(), String> { ) -> Result<(), String> {
// Store AppHandle globally for real-time alert emission
if let Err(_) = HEARTBEAT_APP_HANDLE.set(app) {
tracing::warn!("[heartbeat] APP_HANDLE already set (multiple init calls)");
}
// P2-06: Validate minimum interval (prevent busy-loop) // P2-06: Validate minimum interval (prevent busy-loop)
const MIN_INTERVAL_MINUTES: u64 = 1; const MIN_INTERVAL_MINUTES: u64 = 1;
if let Some(ref cfg) = config { if let Some(ref cfg) = config {
@@ -726,7 +785,11 @@ pub async fn heartbeat_init(
} }
} }
let engine = HeartbeatEngine::new(agent_id.clone(), config); // Restore config from VikingStorage (overrides passed-in default)
let restored_config = restore_config_from_storage(&agent_id).await
.or(config);
let engine = HeartbeatEngine::new(agent_id.clone(), restored_config);
// Restore last interaction time from VikingStorage metadata // Restore last interaction time from VikingStorage metadata
restore_last_interaction(&agent_id).await; restore_last_interaction(&agent_id).await;
@@ -739,6 +802,38 @@ pub async fn heartbeat_init(
Ok(()) Ok(())
} }
/// Restore config from VikingStorage, returns None if not found
async fn restore_config_from_storage(agent_id: &str) -> Option<HeartbeatConfig> {
let key = format!("heartbeat:config:{}", agent_id);
match crate::viking_commands::get_storage().await {
Ok(storage) => {
match zclaw_growth::VikingStorage::get_metadata_json(&*storage, &key).await {
Ok(Some(json)) => {
match serde_json::from_str::<HeartbeatConfig>(&json) {
Ok(cfg) => {
tracing::info!("[heartbeat] Restored config for {}", agent_id);
Some(cfg)
}
Err(e) => {
tracing::warn!("[heartbeat] Failed to parse persisted config: {}", e);
None
}
}
}
Ok(None) => None,
Err(e) => {
tracing::warn!("[heartbeat] Failed to read persisted config: {}", e);
None
}
}
}
Err(e) => {
tracing::warn!("[heartbeat] Storage unavailable for config restore: {}", e);
None
}
}
}
/// Restore the last interaction timestamp for an agent from VikingStorage. /// Restore the last interaction timestamp for an agent from VikingStorage.
/// Called during heartbeat_init so the idle-greeting check works after restart. /// Called during heartbeat_init so the idle-greeting check works after restart.
pub async fn restore_last_interaction(agent_id: &str) { pub async fn restore_last_interaction(agent_id: &str) {

View File

@@ -18,6 +18,7 @@
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use zclaw_growth::VikingStorage;
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -53,6 +54,7 @@ pub struct IdentityChangeProposal {
pub enum IdentityFile { pub enum IdentityFile {
Soul, Soul,
Instructions, Instructions,
UserProfile,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -270,11 +272,13 @@ impl AgentIdentityManager {
match file { match file {
IdentityFile::Soul => identity.soul, IdentityFile::Soul => identity.soul,
IdentityFile::Instructions => identity.instructions, IdentityFile::Instructions => identity.instructions,
IdentityFile::UserProfile => identity.user_profile,
} }
} }
/// Build system prompt from identity files /// Build system prompt from identity files.
pub fn build_system_prompt(&mut self, agent_id: &str, memory_context: Option<&str>) -> String { /// Async because it may query VikingStorage as a fallback for user preferences.
pub async fn build_system_prompt(&mut self, agent_id: &str, memory_context: Option<&str>) -> String {
let identity = self.get_identity(agent_id); let identity = self.get_identity(agent_id);
let mut sections = Vec::new(); let mut sections = Vec::new();
@@ -284,18 +288,50 @@ impl AgentIdentityManager {
if !identity.instructions.is_empty() { if !identity.instructions.is_empty() {
sections.push(identity.instructions.clone()); sections.push(identity.instructions.clone());
} }
// NOTE: user_profile injection is intentionally disabled. // Inject user_profile into system prompt for cross-session identity continuity.
// The reflection engine may accumulate overly specific details from past // Truncate to first 10 lines to avoid flooding the prompt with overly specific
// conversations (e.g., "广东光华", "汕头玩具产业") into user_profile. // details accumulated by the reflection engine. Core identity (name, role)
// These details then leak into every new conversation's system prompt, // is typically in the first few lines.
// causing the model to think about old topics instead of the current query. if !identity.user_profile.is_empty()
// Memory injection should only happen via MemoryMiddleware with relevance && identity.user_profile != default_user_profile()
// filtering, not unconditionally via user_profile. {
// if !identity.user_profile.is_empty() let truncated: String = identity
// && identity.user_profile != default_user_profile() .user_profile
// { .lines()
// sections.push(format!("## 用户画像\n{}", identity.user_profile)); .take(10)
// } .collect::<Vec<_>>()
.join("\n");
if !truncated.is_empty() {
sections.push(format!("## 用户画像\n{}", truncated));
}
} else {
// Fallback: query VikingStorage for user-related preferences.
// The UserProfiler pipeline stores extracted preferences under agent://{uuid}/preferences/.
// When identity's user_profile is default (never populated), use this as a data source.
if let Ok(storage) = crate::viking_commands::get_storage().await {
let prefix = format!("agent://{}/preferences/", agent_id);
if let Ok(entries) = storage.find_by_prefix(&prefix).await {
if !entries.is_empty() {
let prefs: Vec<String> = entries
.iter()
.filter_map(|e| {
let text = if e.content.len() > 80 {
let truncated: String = e.content.chars().take(80).collect();
format!("{}...", truncated)
} else {
e.content.clone()
};
if text.is_empty() { None } else { Some(format!("- {}", text)) }
})
.take(5)
.collect();
if !prefs.is_empty() {
sections.push(format!("## 用户偏好\n{}", prefs.join("\n")));
}
}
}
}
}
if let Some(ctx) = memory_context { if let Some(ctx) = memory_context {
sections.push(ctx.to_string()); sections.push(ctx.to_string());
} }
@@ -336,6 +372,7 @@ impl AgentIdentityManager {
let current_content = match file { let current_content = match file {
IdentityFile::Soul => identity.soul.clone(), IdentityFile::Soul => identity.soul.clone(),
IdentityFile::Instructions => identity.instructions.clone(), IdentityFile::Instructions => identity.instructions.clone(),
IdentityFile::UserProfile => identity.user_profile.clone(),
}; };
let proposal = IdentityChangeProposal { let proposal = IdentityChangeProposal {
@@ -381,6 +418,9 @@ impl AgentIdentityManager {
IdentityFile::Instructions => { IdentityFile::Instructions => {
updated.instructions = suggested_content updated.instructions = suggested_content
} }
IdentityFile::UserProfile => {
updated.user_profile = suggested_content
}
} }
self.identities.insert(agent_id.clone(), updated.clone()); self.identities.insert(agent_id.clone(), updated.clone());
@@ -601,6 +641,7 @@ pub async fn identity_get_file(
let file_type = match file.as_str() { let file_type = match file.as_str() {
"soul" => IdentityFile::Soul, "soul" => IdentityFile::Soul,
"instructions" => IdentityFile::Instructions, "instructions" => IdentityFile::Instructions,
"userprofile" | "user_profile" => IdentityFile::UserProfile,
_ => return Err(format!("Unknown file: {}", file)), _ => return Err(format!("Unknown file: {}", file)),
}; };
Ok(manager.get_file(&agent_id, file_type)) Ok(manager.get_file(&agent_id, file_type))
@@ -615,7 +656,7 @@ pub async fn identity_build_prompt(
state: tauri::State<'_, IdentityManagerState>, state: tauri::State<'_, IdentityManagerState>,
) -> Result<String, String> { ) -> Result<String, String> {
let mut manager = state.lock().await; let mut manager = state.lock().await;
Ok(manager.build_system_prompt(&agent_id, memory_context.as_deref())) Ok(manager.build_system_prompt(&agent_id, memory_context.as_deref()).await)
} }
/// Update user profile (auto) /// Update user profile (auto)
@@ -657,7 +698,8 @@ pub async fn identity_propose_change(
let file_type = match target.as_str() { let file_type = match target.as_str() {
"soul" => IdentityFile::Soul, "soul" => IdentityFile::Soul,
"instructions" => IdentityFile::Instructions, "instructions" => IdentityFile::Instructions,
_ => return Err(format!("Invalid file type: '{}'. Expected 'soul' or 'instructions'", target)), "userprofile" | "user_profile" => IdentityFile::UserProfile,
_ => return Err(format!("Invalid file type: '{}'. Expected 'soul', 'instructions', or 'user_profile'", target)),
}; };
Ok(manager.propose_change(&agent_id, file_type, &suggested_content, &reason)) Ok(manager.propose_change(&agent_id, file_type, &suggested_content, &reason))
} }

View File

@@ -26,6 +26,10 @@
//! - `trigger_evaluator` - 2026-03-26 //! - `trigger_evaluator` - 2026-03-26
//! - `persona_evolver` - 2026-03-26 //! - `persona_evolver` - 2026-03-26
// Hermes 管线子模块:部分函数由 Tauri 命令或中间件 hooks 按需调用,
// 编译期无法检测到跨 crate 引用,统一抑制 dead_code 警告。
#![allow(dead_code)]
pub mod heartbeat; pub mod heartbeat;
pub mod compactor; pub mod compactor;
pub mod reflection; pub mod reflection;
@@ -40,6 +44,7 @@ pub mod experience;
pub mod triggers; pub mod triggers;
pub mod user_profiler; pub mod user_profiler;
pub mod trajectory_compressor; pub mod trajectory_compressor;
pub mod health_snapshot;
// Re-export main types for convenience // Re-export main types for convenience
pub use heartbeat::HeartbeatEngineState; pub use heartbeat::HeartbeatEngineState;

View File

@@ -610,13 +610,22 @@ mod tests {
#[test] #[test]
fn test_severity_ordering() { fn test_severity_ordering() {
// Single frustration signal → Medium
let messages = vec![
Message::user("这又来了"),
];
let result = analyze_for_pain_signals(&messages);
assert!(result.is_some());
assert_eq!(result.unwrap().severity, PainSeverity::Medium);
// Two frustration signals → High (len >= 2 triggers High)
let messages = vec![ let messages = vec![
Message::user("这又来了"), Message::user("这又来了"),
Message::user("还是不行"), Message::user("还是不行"),
]; ];
let result = analyze_for_pain_signals(&messages); let result = analyze_for_pain_signals(&messages);
assert!(result.is_some()); assert!(result.is_some());
assert_eq!(result.unwrap().severity, PainSeverity::Medium); assert_eq!(result.unwrap().severity, PainSeverity::High);
} }
#[test] #[test]

View File

@@ -9,7 +9,7 @@ use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use tracing::{debug, warn}; use tracing::{debug, warn};
use zclaw_memory::fact::{Fact, FactCategory}; use zclaw_memory::fact::Fact;
use zclaw_memory::user_profile_store::{ use zclaw_memory::user_profile_store::{
CommStyle, Level, UserProfile, UserProfileStore, CommStyle, Level, UserProfile, UserProfileStore,
}; };

View File

@@ -283,7 +283,7 @@ async fn build_identity_prompt(
let prompt = manager.build_system_prompt( let prompt = manager.build_system_prompt(
agent_id, agent_id,
if memory_context.is_empty() { None } else { Some(memory_context) }, if memory_context.is_empty() { None } else { Some(memory_context) },
); ).await;
Ok(prompt) Ok(prompt)
} }

View File

@@ -121,6 +121,7 @@ pub async fn agent_a2a_delegate_task(
/// Butler delegates a user request to expert agents via the Director. /// Butler delegates a user request to expert agents via the Director.
#[cfg(feature = "multi-agent")] #[cfg(feature = "multi-agent")]
// @reserved: butler multi-agent delegation
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn butler_delegate_task( pub async fn butler_delegate_task(

View File

@@ -68,6 +68,7 @@ pub struct AgentUpdateRequest {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Create a new agent /// Create a new agent
// @reserved: agent CRUD management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn agent_create( pub async fn agent_create(
@@ -150,6 +151,7 @@ pub async fn agent_create(
} }
/// List all agents /// List all agents
// @reserved: agent CRUD management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn agent_list( pub async fn agent_list(
@@ -164,6 +166,7 @@ pub async fn agent_list(
} }
/// Get agent info (with optional UserProfile from memory store) /// Get agent info (with optional UserProfile from memory store)
// @reserved: agent CRUD management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn agent_get( pub async fn agent_get(

View File

@@ -89,6 +89,7 @@ pub struct StreamChatRequest {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Send a message to an agent /// Send a message to an agent
// @reserved: agent chat (desktop uses ChatStore/SaaS relay)
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn agent_chat( pub async fn agent_chat(
@@ -216,8 +217,93 @@ pub async fn agent_chat_stream(
&identity_state, &identity_state,
).await.unwrap_or_default(); ).await.unwrap_or_default();
// --- Schedule intent interception ---
// If the user's message contains a schedule intent (e.g. "每天早上9点提醒我查房"),
// parse it with NlScheduleParser, create a trigger, and return confirmation
// directly without calling the LLM.
let mut captured_parsed: Option<zclaw_runtime::nl_schedule::ParsedSchedule> = None;
if zclaw_runtime::nl_schedule::has_schedule_intent(&message) {
let parse_result = zclaw_runtime::nl_schedule::parse_nl_schedule(&message, &id);
match parse_result {
zclaw_runtime::nl_schedule::ScheduleParseResult::Exact(ref parsed)
if parsed.confidence >= 0.8 =>
{
// Try to create a schedule trigger
let kernel_lock = state.lock().await;
if let Some(kernel) = kernel_lock.as_ref() {
// Use UUID fragment to avoid collision under high concurrency
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,
// 60/hour = once per minute max, reasonable for scheduled tasks
max_executions_per_hour: 60,
};
match kernel.create_trigger(trigger_config).await {
Ok(_entry) => {
tracing::info!(
"[agent_chat_stream] Schedule trigger created: {} (cron: {})",
trigger_id, parsed.cron_expression
);
captured_parsed = Some(parsed.clone());
}
Err(e) => {
tracing::warn!(
"[agent_chat_stream] Failed to create schedule trigger, falling through to LLM: {}",
e
);
}
}
}
}
_ => {
// Ambiguous, Unclear, or low confidence — let LLM handle it naturally
tracing::debug!(
"[agent_chat_stream] Schedule intent detected but not confident enough, falling through to LLM"
);
}
}
}
// Get the streaming receiver while holding the lock, then release it // Get the streaming receiver while holding the lock, then release it
let (mut rx, llm_driver) = { // NOTE: When schedule_intercepted, llm_driver is None so post_conversation_hook
// (memory extraction, heartbeat, reflection) is intentionally skipped —
// schedule confirmations are system messages, not user conversations.
let (mut rx, llm_driver) = if let Some(parsed) = captured_parsed {
// Schedule was intercepted — build confirmation message directly
let confirm_msg = format!(
"已为您设置定时任务:\n\n- **任务**{}\n- **时间**{}\n- **Cron**`{}`\n\n任务已激活,将在设定时间自动执行。",
parsed.task_description,
parsed.natural_description,
parsed.cron_expression,
);
let (tx, rx) = tokio::sync::mpsc::channel(32);
let _ = tx.send(zclaw_runtime::LoopEvent::Delta(confirm_msg)).await;
let _ = tx.send(zclaw_runtime::LoopEvent::Complete(
zclaw_runtime::AgentLoopResult {
response: String::new(),
input_tokens: 0,
output_tokens: 0,
iterations: 1,
}
)).await;
drop(tx);
(rx, None)
} else {
// Normal LLM chat path
let kernel_lock = state.lock().await; let kernel_lock = state.lock().await;
let kernel = kernel_lock.as_ref() let kernel = kernel_lock.as_ref()
.ok_or_else(|| { .ok_or_else(|| {

View File

@@ -112,6 +112,7 @@ impl From<zclaw_hands::HandResult> for HandResult {
/// ///
/// Returns hands from the Kernel's HandRegistry. /// Returns hands from the Kernel's HandRegistry.
/// Hands are registered during kernel initialization. /// Hands are registered during kernel initialization.
// @reserved: Hand autonomous capabilities
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn hand_list( pub async fn hand_list(
@@ -142,6 +143,7 @@ pub async fn hand_list(
/// Executes a hand with the given ID and input. /// Executes a hand with the given ID and input.
/// If the hand has `needs_approval = true`, creates a pending approval instead. /// If the hand has `needs_approval = true`, creates a pending approval instead.
/// Returns the hand result as JSON, or a pending status with approval ID. /// Returns the hand result as JSON, or a pending status with approval ID.
// @reserved: Hand autonomous capabilities
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn hand_execute( pub async fn hand_execute(
@@ -209,6 +211,7 @@ pub async fn hand_execute(
/// When approved, the kernel's `respond_to_approval` internally spawns the Hand /// When approved, the kernel's `respond_to_approval` internally spawns the Hand
/// execution. We additionally emit Tauri events so the frontend can track when /// execution. We additionally emit Tauri events so the frontend can track when
/// the execution finishes. /// the execution finishes.
// @reserved: Hand approval workflow
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn hand_approve( pub async fn hand_approve(

View File

@@ -57,6 +57,7 @@ pub struct KernelStatusResponse {
/// ///
/// If kernel already exists with the same config, returns existing status. /// If kernel already exists with the same config, returns existing status.
/// If config changed, reboots kernel with new config. /// If config changed, reboots kernel with new config.
// @reserved: kernel lifecycle management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn kernel_init( pub async fn kernel_init(
@@ -73,15 +74,18 @@ pub async fn kernel_init(
// Get current config from kernel // Get current config from kernel
let current_config = kernel.config(); let current_config = kernel.config();
// Check if config changed // Check if config changed (model, base_url, or api_key)
let config_changed = if let Some(ref req) = config_request { let config_changed = if let Some(ref req) = config_request {
let default_base_url = zclaw_kernel::config::KernelConfig::from_provider( let default_base_url = zclaw_kernel::config::KernelConfig::from_provider(
&req.provider, "", &req.model, None, &req.api_protocol &req.provider, "", &req.model, None, &req.api_protocol
).llm.base_url; ).llm.base_url;
let request_base_url = req.base_url.clone().unwrap_or(default_base_url.clone()); let request_base_url = req.base_url.clone().unwrap_or(default_base_url.clone());
let current_api_key = &current_config.llm.api_key;
let request_api_key = req.api_key.as_deref().unwrap_or("");
current_config.llm.model != req.model || current_config.llm.model != req.model ||
current_config.llm.base_url != request_base_url current_config.llm.base_url != request_base_url ||
current_api_key != request_api_key
} else { } else {
false false
}; };

View File

@@ -33,6 +33,7 @@ impl Default for McpManagerState {
impl McpManagerState { impl McpManagerState {
/// Create with a pre-allocated kernel_adapters Arc for sharing with Kernel. /// Create with a pre-allocated kernel_adapters Arc for sharing with Kernel.
#[allow(dead_code)]
pub fn with_shared_adapters(kernel_adapters: Arc<std::sync::RwLock<Vec<McpToolAdapter>>>) -> Self { pub fn with_shared_adapters(kernel_adapters: Arc<std::sync::RwLock<Vec<McpToolAdapter>>>) -> Self {
Self { Self {
manager: Arc::new(Mutex::new(McpServiceManager::new())), manager: Arc::new(Mutex::new(McpServiceManager::new())),
@@ -81,6 +82,7 @@ pub struct McpServiceStatus {
// ──────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────
/// Start an MCP server and discover its tools /// Start an MCP server and discover its tools
// @reserved: MCP protocol management
/// @connected — frontend: MCPServices.tsx via mcp-client.ts /// @connected — frontend: MCPServices.tsx via mcp-client.ts
#[tauri::command] #[tauri::command]
pub async fn mcp_start_service( pub async fn mcp_start_service(
@@ -127,6 +129,7 @@ pub async fn mcp_start_service(
} }
/// Stop an MCP server and remove its tools /// Stop an MCP server and remove its tools
// @reserved: MCP protocol management
/// @connected — frontend: MCPServices.tsx via mcp-client.ts /// @connected — frontend: MCPServices.tsx via mcp-client.ts
#[tauri::command] #[tauri::command]
pub async fn mcp_stop_service( pub async fn mcp_stop_service(
@@ -144,6 +147,7 @@ pub async fn mcp_stop_service(
} }
/// List all active MCP services and their tools /// List all active MCP services and their tools
// @reserved: MCP protocol management
/// @connected — frontend: MCPServices.tsx via mcp-client.ts /// @connected — frontend: MCPServices.tsx via mcp-client.ts
#[tauri::command] #[tauri::command]
pub async fn mcp_list_services( pub async fn mcp_list_services(
@@ -176,6 +180,7 @@ pub async fn mcp_list_services(
} }
/// Call an MCP tool directly /// Call an MCP tool directly
// @reserved: MCP protocol management
/// @connected — frontend: agent loop via mcp-client.ts /// @connected — frontend: agent loop via mcp-client.ts
#[tauri::command] #[tauri::command]
pub async fn mcp_call_tool( pub async fn mcp_call_tool(

View File

@@ -47,6 +47,7 @@ pub struct ScheduledTaskResponse {
/// ///
/// Tasks are automatically executed by the SchedulerService which checks /// Tasks are automatically executed by the SchedulerService which checks
/// every 60 seconds for due triggers. /// every 60 seconds for due triggers.
// @reserved: scheduled task management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn scheduled_task_create( pub async fn scheduled_task_create(
@@ -95,6 +96,7 @@ pub async fn scheduled_task_create(
} }
/// List all scheduled tasks (kernel triggers of Schedule type) /// List all scheduled tasks (kernel triggers of Schedule type)
// @reserved: scheduled task management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn scheduled_task_list( pub async fn scheduled_task_list(

View File

@@ -85,6 +85,7 @@ pub async fn skill_list(
/// ///
/// Re-scans the skills directory for new or updated skills. /// Re-scans the skills directory for new or updated skills.
/// Optionally accepts a custom directory path to scan. /// Optionally accepts a custom directory path to scan.
// @reserved: skill system management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn skill_refresh( pub async fn skill_refresh(
@@ -136,6 +137,7 @@ pub struct UpdateSkillRequest {
} }
/// Create a new skill in the skills directory /// Create a new skill in the skills directory
// @reserved: skill system management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn skill_create( pub async fn skill_create(
@@ -184,6 +186,7 @@ pub async fn skill_create(
} }
/// Update an existing skill /// Update an existing skill
// @reserved: skill system management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn skill_update( pub async fn skill_update(
@@ -303,6 +306,7 @@ impl From<zclaw_skills::SkillResult> for SkillResult {
/// ///
/// Executes a skill with the given ID and input. /// Executes a skill with the given ID and input.
/// Returns the skill result as JSON. /// Returns the skill result as JSON.
// @reserved: skill system management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn skill_execute( pub async fn skill_execute(

View File

@@ -96,6 +96,7 @@ impl From<zclaw_kernel::trigger_manager::TriggerEntry> for TriggerResponse {
} }
/// List all triggers /// List all triggers
// @reserved: trigger management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn trigger_list( pub async fn trigger_list(
@@ -110,6 +111,7 @@ pub async fn trigger_list(
} }
/// Get a specific trigger /// Get a specific trigger
// @reserved: trigger management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn trigger_get( pub async fn trigger_get(
@@ -127,6 +129,7 @@ pub async fn trigger_get(
} }
/// Create a new trigger /// Create a new trigger
// @reserved: trigger management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn trigger_create( pub async fn trigger_create(
@@ -182,6 +185,7 @@ pub async fn trigger_create(
} }
/// Update a trigger /// Update a trigger
// @reserved: trigger management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn trigger_update( pub async fn trigger_update(
@@ -227,6 +231,7 @@ pub async fn trigger_delete(
} }
/// Execute a trigger manually /// Execute a trigger manually
// @reserved: trigger management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn trigger_execute( pub async fn trigger_execute(

View File

@@ -10,6 +10,7 @@ pub struct DirStats {
} }
/// Count files and total size in a directory (non-recursive, top-level only) /// Count files and total size in a directory (non-recursive, top-level only)
// @reserved: workspace statistics
#[tauri::command] #[tauri::command]
pub async fn workspace_dir_stats(path: String) -> Result<DirStats, String> { pub async fn workspace_dir_stats(path: String) -> Result<DirStats, String> {
let dir = Path::new(&path); let dir = Path::new(&path);

View File

@@ -386,6 +386,8 @@ pub fn run() {
intelligence::heartbeat::heartbeat_update_memory_stats, intelligence::heartbeat::heartbeat_update_memory_stats,
intelligence::heartbeat::heartbeat_record_correction, intelligence::heartbeat::heartbeat_record_correction,
intelligence::heartbeat::heartbeat_record_interaction, intelligence::heartbeat::heartbeat_record_interaction,
// Health Snapshot (on-demand query)
intelligence::health_snapshot::health_snapshot,
// Context Compactor // Context Compactor
intelligence::compactor::compactor_estimate_tokens, intelligence::compactor::compactor_estimate_tokens,
intelligence::compactor::compactor_estimate_messages_tokens, intelligence::compactor::compactor_estimate_messages_tokens,

View File

@@ -453,6 +453,7 @@ impl EmbeddingClient {
} }
} }
// @reserved: embedding vector generation
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn embedding_create( pub async fn embedding_create(
@@ -473,6 +474,7 @@ pub async fn embedding_create(
client.embed(&text).await client.embed(&text).await
} }
// @reserved: embedding provider listing
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn embedding_providers() -> Result<Vec<(String, String, String, usize)>, String> { pub async fn embedding_providers() -> Result<Vec<(String, String, String, usize)>, String> {

View File

@@ -473,6 +473,7 @@ If no significant memories found, return empty array: []"#,
// === Tauri Commands === // === Tauri Commands ===
// @reserved: memory extraction
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn extract_session_memories( pub async fn extract_session_memories(
@@ -490,6 +491,7 @@ pub async fn extract_session_memories(
/// Extract memories from session and store to SqliteStorage /// Extract memories from session and store to SqliteStorage
/// This combines extraction and storage in one command /// This combines extraction and storage in one command
// @reserved: memory extraction and storage
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn extract_and_store_memories( pub async fn extract_and_store_memories(

View File

@@ -55,6 +55,7 @@ pub struct WorkflowStepInput {
} }
/// Create a new pipeline as a YAML file /// Create a new pipeline as a YAML file
// @reserved: pipeline workflow management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn pipeline_create( pub async fn pipeline_create(
@@ -180,6 +181,7 @@ pub async fn pipeline_create(
} }
/// Update an existing pipeline /// Update an existing pipeline
// @reserved: pipeline workflow management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn pipeline_update( pub async fn pipeline_update(

View File

@@ -20,6 +20,7 @@ use super::helpers::{get_pipelines_directory, scan_pipelines_with_paths, scan_pi
use crate::kernel_commands::KernelState; use crate::kernel_commands::KernelState;
/// Discover and list all available pipelines /// Discover and list all available pipelines
// @reserved: pipeline workflow management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn pipeline_list( pub async fn pipeline_list(
@@ -70,6 +71,7 @@ pub async fn pipeline_list(
} }
/// Get pipeline details /// Get pipeline details
// @reserved: pipeline workflow management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn pipeline_get( pub async fn pipeline_get(
@@ -85,6 +87,7 @@ pub async fn pipeline_get(
} }
/// Run a pipeline /// Run a pipeline
// @reserved: pipeline workflow management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn pipeline_run( pub async fn pipeline_run(
@@ -197,6 +200,7 @@ pub async fn pipeline_run(
} }
/// Get pipeline run progress /// Get pipeline run progress
// @reserved: pipeline workflow management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn pipeline_progress( pub async fn pipeline_progress(
@@ -234,6 +238,7 @@ pub async fn pipeline_cancel(
} }
/// Get pipeline run result /// Get pipeline run result
// @reserved: pipeline workflow management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn pipeline_result( pub async fn pipeline_result(
@@ -261,6 +266,7 @@ pub async fn pipeline_result(
} }
/// List all runs /// List all runs
// @reserved: pipeline workflow management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn pipeline_runs( pub async fn pipeline_runs(
@@ -287,6 +293,7 @@ pub async fn pipeline_runs(
} }
/// Refresh pipeline discovery /// Refresh pipeline discovery
// @reserved: pipeline workflow management
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn pipeline_refresh( pub async fn pipeline_refresh(

View File

@@ -62,6 +62,7 @@ pub struct PipelineCandidateInfo {
} }
/// Route user input to matching pipeline /// Route user input to matching pipeline
// @reserved: semantic intent routing
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn route_intent( pub async fn route_intent(

View File

@@ -9,6 +9,7 @@ use super::types::PipelineInputInfo;
use super::PipelineState; use super::PipelineState;
/// Analyze presentation data /// Analyze presentation data
// @reserved: presentation analysis
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn analyze_presentation( pub async fn analyze_presentation(

View File

@@ -32,6 +32,7 @@ pub fn secure_store_set(key: String, value: String) -> Result<(), String> {
} }
/// Retrieve a value from the OS keyring /// Retrieve a value from the OS keyring
// @reserved: secure storage access
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn secure_store_get(key: String) -> Result<String, String> { pub fn secure_store_get(key: String) -> Result<String, String> {
@@ -81,6 +82,7 @@ pub fn secure_store_delete(key: String) -> Result<(), String> {
} }
/// Check if secure storage is available on this platform /// Check if secure storage is available on this platform
// @reserved: secure storage access
// @connected // @connected
#[tauri::command] #[tauri::command]
pub fn secure_store_is_available() -> bool { pub fn secure_store_is_available() -> bool {

View File

@@ -150,6 +150,7 @@ fn get_data_dir_string() -> Option<String> {
// === Tauri Commands === // === Tauri Commands ===
/// Check if memory storage is available /// Check if memory storage is available
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_status() -> Result<VikingStatus, String> { pub async fn viking_status() -> Result<VikingStatus, String> {
@@ -178,6 +179,7 @@ pub async fn viking_status() -> Result<VikingStatus, String> {
} }
/// Add a memory entry /// Add a memory entry
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_add(uri: String, content: String) -> Result<VikingAddResult, String> { pub async fn viking_add(uri: String, content: String) -> Result<VikingAddResult, String> {
@@ -187,6 +189,36 @@ pub async fn viking_add(uri: String, content: String) -> Result<VikingAddResult,
// Expected format: agent://{agent_id}/{type}/{category} // Expected format: agent://{agent_id}/{type}/{category}
let (agent_id, memory_type, category) = parse_uri(&uri)?; let (agent_id, memory_type, category) = parse_uri(&uri)?;
// Pre-check for duplicates via content hash
use std::hash::{Hash, Hasher};
let normalized_content = content.trim().to_lowercase();
let content_hash = {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
normalized_content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
};
let agent_scope = uri.split('/').nth(2).unwrap_or("");
let scope_prefix = format!("agent://{agent_scope}/");
// Check for existing entry with the same content hash in the same agent scope
let pool = storage.pool();
let existing: Option<(String,)> = sqlx::query_as(
"SELECT uri FROM memories WHERE content_hash = ? AND uri LIKE ? LIMIT 1"
)
.bind(&content_hash)
.bind(format!("{}%", scope_prefix))
.fetch_optional(pool)
.await
.map_err(|e| format!("Dedup check failed: {}", e))?;
if existing.is_some() {
return Ok(VikingAddResult {
uri,
status: "deduped".to_string(),
});
}
let entry = MemoryEntry::new(&agent_id, memory_type, &category, content); let entry = MemoryEntry::new(&agent_id, memory_type, &category, content);
storage storage
@@ -201,6 +233,7 @@ pub async fn viking_add(uri: String, content: String) -> Result<VikingAddResult,
} }
/// Add a memory with metadata /// Add a memory with metadata
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_add_with_metadata( pub async fn viking_add_with_metadata(
@@ -232,6 +265,7 @@ pub async fn viking_add_with_metadata(
} }
/// Find memories by semantic search /// Find memories by semantic search
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_find( pub async fn viking_find(
@@ -278,6 +312,7 @@ pub async fn viking_find(
} }
/// Grep memories by pattern (uses FTS5) /// Grep memories by pattern (uses FTS5)
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_grep( pub async fn viking_grep(
@@ -332,6 +367,7 @@ pub async fn viking_grep(
} }
/// List memories at a path /// List memories at a path
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_ls(path: String) -> Result<Vec<VikingResource>, String> { pub async fn viking_ls(path: String) -> Result<Vec<VikingResource>, String> {
@@ -360,6 +396,7 @@ pub async fn viking_ls(path: String) -> Result<Vec<VikingResource>, String> {
} }
/// Read memory content /// Read memory content
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_read(uri: String, level: Option<String>) -> Result<String, String> { pub async fn viking_read(uri: String, level: Option<String>) -> Result<String, String> {
@@ -404,6 +441,7 @@ pub async fn viking_read(uri: String, level: Option<String>) -> Result<String, S
} }
/// Remove a memory /// Remove a memory
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_remove(uri: String) -> Result<(), String> { pub async fn viking_remove(uri: String) -> Result<(), String> {
@@ -418,6 +456,7 @@ pub async fn viking_remove(uri: String) -> Result<(), String> {
} }
/// Get memory tree /// Get memory tree
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_tree(path: String, depth: Option<usize>) -> Result<serde_json::Value, String> { pub async fn viking_tree(path: String, depth: Option<usize>) -> Result<serde_json::Value, String> {
@@ -469,6 +508,7 @@ pub async fn viking_tree(path: String, depth: Option<usize>) -> Result<serde_jso
} }
/// Inject memories into prompt (for agent loop integration) /// Inject memories into prompt (for agent loop integration)
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_inject_prompt( pub async fn viking_inject_prompt(
@@ -611,6 +651,7 @@ pub async fn viking_configure_summary_driver(
} }
/// Store a memory and optionally generate L0/L1 summaries in the background /// Store a memory and optionally generate L0/L1 summaries in the background
// @reserved: VikingStorage persistence
// @connected // @connected
#[tauri::command] #[tauri::command]
pub async fn viking_store_with_summaries( pub async fn viking_store_with_summaries(

View File

@@ -21,6 +21,7 @@ import { isTauriRuntime, getLocalGatewayStatus, startLocalGateway } from './lib/
import { LoginPage } from './components/LoginPage'; import { LoginPage } from './components/LoginPage';
import { useOnboarding } from './lib/use-onboarding'; import { useOnboarding } from './lib/use-onboarding';
import { intelligenceClient } from './lib/intelligence-client'; import { intelligenceClient } from './lib/intelligence-client';
import { safeListen } from './lib/safe-tauri';
import { loadEmbeddingConfig, loadEmbeddingApiKey } from './lib/embedding-client'; import { loadEmbeddingConfig, loadEmbeddingApiKey } from './lib/embedding-client';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { useProposalNotifications, ProposalNotificationHandler } from './lib/useProposalNotifications'; import { useProposalNotifications, ProposalNotificationHandler } from './lib/useProposalNotifications';
@@ -54,6 +55,7 @@ function App() {
const [showOnboarding, setShowOnboarding] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false);
const [showDetailDrawer, setShowDetailDrawer] = useState(false); const [showDetailDrawer, setShowDetailDrawer] = useState(false);
const statsSyncRef = useRef<ReturnType<typeof setInterval> | null>(null); const statsSyncRef = useRef<ReturnType<typeof setInterval> | null>(null);
const alertUnlistenRef = useRef<(() => void) | null>(null);
// Hand Approval state // Hand Approval state
const [pendingApprovalRun, setPendingApprovalRun] = useState<HandRun | null>(null); const [pendingApprovalRun, setPendingApprovalRun] = useState<HandRun | null>(null);
@@ -155,6 +157,11 @@ function App() {
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
// SaaS recovery listener (defined at useEffect scope for cleanup access)
const handleSaasRecovered = () => {
toast('SaaS 服务已恢复连接', 'success');
};
const bootstrap = async () => { const bootstrap = async () => {
// 未登录时不启动 bootstrap直接结束 loading // 未登录时不启动 bootstrap直接结束 loading
if (!useSaaSStore.getState().isLoggedIn) { if (!useSaaSStore.getState().isLoggedIn) {
@@ -208,7 +215,9 @@ function App() {
// Step 4.5: Auto-start heartbeat engine for self-evolution // Step 4.5: Auto-start heartbeat engine for self-evolution
try { try {
const defaultAgentId = 'zclaw-main'; const defaultAgentId = 'zclaw-main';
await intelligenceClient.heartbeat.init(defaultAgentId, { // Restore config from localStorage (Rust side also restores from VikingStorage)
const savedConfig = localStorage.getItem('zclaw-heartbeat-config');
const heartbeatConfig = savedConfig ? JSON.parse(savedConfig) : {
enabled: true, enabled: true,
interval_minutes: 30, interval_minutes: 30,
quiet_hours_start: '22:00', quiet_hours_start: '22:00',
@@ -216,7 +225,8 @@ function App() {
notify_channel: 'ui', notify_channel: 'ui',
proactivity_level: 'standard', proactivity_level: 'standard',
max_alerts_per_tick: 5, max_alerts_per_tick: 5,
}); };
await intelligenceClient.heartbeat.init(defaultAgentId, heartbeatConfig);
// Sync memory stats to heartbeat engine // Sync memory stats to heartbeat engine
try { try {
@@ -236,6 +246,21 @@ function App() {
await intelligenceClient.heartbeat.start(defaultAgentId); await intelligenceClient.heartbeat.start(defaultAgentId);
log.debug('Heartbeat engine started for self-evolution'); log.debug('Heartbeat engine started for self-evolution');
// Listen for real-time heartbeat alerts and show as toast notifications
const unlistenAlerts = await safeListen<Array<{ title: string; content: string; urgency: string }>>(
'heartbeat:alert',
(alerts) => {
for (const alert of alerts) {
const alertType = alert.urgency === 'high' ? 'error'
: alert.urgency === 'medium' ? 'warning'
: 'info';
toast(`[${alert.title}] ${alert.content}`, alertType as 'info' | 'warning' | 'error');
}
}
);
// Store unlisten for cleanup
alertUnlistenRef.current = unlistenAlerts;
// Set up periodic memory stats sync (every 5 minutes) // Set up periodic memory stats sync (every 5 minutes)
const MEMORY_STATS_SYNC_INTERVAL = 5 * 60 * 1000; const MEMORY_STATS_SYNC_INTERVAL = 5 * 60 * 1000;
const statsSyncInterval = setInterval(async () => { const statsSyncInterval = setInterval(async () => {
@@ -261,6 +286,9 @@ function App() {
// Non-critical, continue without heartbeat // Non-critical, continue without heartbeat
} }
// Listen for SaaS recovery events (from saasStore recovery probe)
window.addEventListener('saas-recovered', handleSaasRecovered);
// Step 5: Restore embedding config to Rust backend (Tauri-only) // Step 5: Restore embedding config to Rust backend (Tauri-only)
if (isTauriRuntime()) { if (isTauriRuntime()) {
try { try {
@@ -339,6 +367,12 @@ function App() {
if (statsSyncRef.current) { if (statsSyncRef.current) {
clearInterval(statsSyncRef.current); clearInterval(statsSyncRef.current);
} }
// Clean up heartbeat alert listener
if (alertUnlistenRef.current) {
alertUnlistenRef.current();
}
// Clean up SaaS recovery event listener
window.removeEventListener('saas-recovered', handleSaasRecovered);
}; };
}, [connect, onboardingNeeded, onboardingLoading, isLoggedIn]); }, [connect, onboardingNeeded, onboardingLoading, isLoggedIn]);

View File

@@ -862,7 +862,7 @@ export function AuditLogsPanel() {
{filteredLogs.length === 0 ? ( {filteredLogs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400"> <div className="flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400">
<AlertCircle className="w-8 h-8 mb-2" /> <AlertCircle className="w-8 h-8 mb-2" />
<p>No audit logs found</p> <p></p>
{(searchTerm || Object.keys(filter).length > 0) && ( {(searchTerm || Object.keys(filter).length > 0) && (
<button <button
onClick={handleResetFilters} onClick={handleResetFilters}

View File

@@ -4,6 +4,7 @@ import { listVikingResources } from '../../lib/viking-client';
interface MemorySectionProps { interface MemorySectionProps {
agentId: string; agentId: string;
refreshKey?: number;
} }
interface MemoryEntry { interface MemoryEntry {
@@ -12,7 +13,7 @@ interface MemoryEntry {
resourceType: string; resourceType: string;
} }
export function MemorySection({ agentId }: MemorySectionProps) { export function MemorySection({ agentId, refreshKey }: MemorySectionProps) {
const [memories, setMemories] = useState<MemoryEntry[]>([]); const [memories, setMemories] = useState<MemoryEntry[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -20,7 +21,8 @@ export function MemorySection({ agentId }: MemorySectionProps) {
if (!agentId) return; if (!agentId) return;
setLoading(true); setLoading(true);
listVikingResources(`viking://agent/${agentId}/memories/`) // 查询 agent:// 下的所有记忆资源 (preferences/knowledge/experience/sessions)
listVikingResources(`agent://${agentId}/`)
.then((entries) => { .then((entries) => {
setMemories(entries as MemoryEntry[]); setMemories(entries as MemoryEntry[]);
}) })
@@ -29,7 +31,7 @@ export function MemorySection({ agentId }: MemorySectionProps) {
setMemories([]); setMemories([]);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [agentId]); }, [agentId, refreshKey]);
if (loading) { if (loading) {
return ( return (

View File

@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useButlerInsights } from '../../hooks/useButlerInsights'; import { useButlerInsights } from '../../hooks/useButlerInsights';
import { useChatStore } from '../../store/chatStore'; import { useChatStore } from '../../store/chatStore';
import { useIndustryStore } from '../../store/industryStore'; import { useIndustryStore } from '../../store/industryStore';
import { extractAndStoreMemories } from '../../lib/viking-client';
import { resolveKernelAgentId } from '../../lib/kernel-agent';
import { InsightsSection } from './InsightsSection'; import { InsightsSection } from './InsightsSection';
import { ProposalsSection } from './ProposalsSection'; import { ProposalsSection } from './ProposalsSection';
import { MemorySection } from './MemorySection'; import { MemorySection } from './MemorySection';
@@ -11,10 +13,26 @@ interface ButlerPanelProps {
} }
export function ButlerPanel({ agentId }: ButlerPanelProps) { export function ButlerPanel({ agentId }: ButlerPanelProps) {
const { painPoints, proposals, loading, error, refresh } = useButlerInsights(agentId); const [resolvedAgentId, setResolvedAgentId] = useState<string | null>(null);
// Use resolved kernel UUID for queries — raw agentId may be "1" from SaaS relay
// while pain points/proposals are stored under kernel UUID
const effectiveAgentId = resolvedAgentId ?? agentId;
const { painPoints, proposals, loading, error, refresh } = useButlerInsights(effectiveAgentId);
const messageCount = useChatStore((s) => s.messages.length); const messageCount = useChatStore((s) => s.messages.length);
const { accountIndustries, configs, lastSynced, isLoading: industryLoading, fetchIndustries } = useIndustryStore(); const { accountIndustries, configs, lastSynced, isLoading: industryLoading, fetchIndustries } = useIndustryStore();
const [analyzing, setAnalyzing] = useState(false); const [analyzing, setAnalyzing] = useState(false);
const [memoryRefreshKey, setMemoryRefreshKey] = useState(0);
// Resolve SaaS relay agentId ("1") to kernel UUID for VikingStorage queries
useEffect(() => {
if (!agentId) {
setResolvedAgentId(null);
return;
}
resolveKernelAgentId(agentId)
.then(setResolvedAgentId)
.catch(() => setResolvedAgentId(agentId));
}, [agentId]);
// Auto-fetch industry configs once per session // Auto-fetch industry configs once per session
useEffect(() => { useEffect(() => {
@@ -26,15 +44,30 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) {
const hasData = (painPoints?.length ?? 0) > 0 || (proposals?.length ?? 0) > 0; const hasData = (painPoints?.length ?? 0) > 0 || (proposals?.length ?? 0) > 0;
const canAnalyze = messageCount >= 2; const canAnalyze = messageCount >= 2;
const handleAnalyze = async () => { const handleAnalyze = useCallback(async () => {
if (!canAnalyze || analyzing) return; if (!canAnalyze || analyzing || !resolvedAgentId) return;
setAnalyzing(true); setAnalyzing(true);
try { try {
// 1. Refresh pain points & proposals
await refresh(); await refresh();
// 2. Extract and store memories from current conversation
const messages = useChatStore.getState().messages;
if (messages.length >= 2) {
const extractionMessages = messages.map((m) => ({
role: m.role as 'user' | 'assistant',
content: typeof m.content === 'string' ? m.content : '',
}));
await extractAndStoreMemories(extractionMessages, resolvedAgentId);
// Trigger MemorySection to reload
setMemoryRefreshKey((k) => k + 1);
}
} catch {
// Extraction failure should not block UI — insights still refreshed
} finally { } finally {
setAnalyzing(false); setAnalyzing(false);
} }
}; }, [canAnalyze, analyzing, resolvedAgentId, refresh]);
if (!agentId) { if (!agentId) {
return ( return (
@@ -107,7 +140,7 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) {
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2"> <h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
</h3> </h3>
<MemorySection agentId={agentId} /> <MemorySection agentId={resolvedAgentId || agentId} refreshKey={memoryRefreshKey} />
</div> </div>
{/* Industry section */} {/* Industry section */}

View File

@@ -72,13 +72,27 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD
const saasModels = useSaaSStore((s) => s.availableModels); const saasModels = useSaaSStore((s) => s.availableModels);
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn); const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
// Track models that failed with API key errors in this session
const failedModelIds = useRef<Set<string>>(new Set());
// Scan messages for API key errors to populate failedModelIds
useEffect(() => {
for (const msg of messages) {
if (msg.error && (msg.error.includes('没有可用的 API Key') || msg.error.includes('Key Pool'))) {
failedModelIds.current.add(currentModel);
}
}
}, [messages, currentModel]);
// Merge models: SaaS available models take priority when logged in // Merge models: SaaS available models take priority when logged in
const models = useMemo(() => { const models = useMemo(() => {
const failed = failedModelIds.current;
if (isLoggedIn && saasModels.length > 0) { if (isLoggedIn && saasModels.length > 0) {
return saasModels.map(m => ({ return saasModels.map(m => ({
id: m.alias || m.id, id: m.alias || m.id,
name: m.alias || m.id, name: m.alias || m.id,
provider: m.provider_id, provider: m.provider_id,
available: !failed.has(m.alias || m.id),
})); }));
} }
if (configModels.length > 0) { if (configModels.length > 0) {
@@ -669,14 +683,14 @@ function MessageBubble({ message, onRetry }: { message: Message; setInput?: (tex
// Thinking indicator // Thinking indicator
<div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400"> <div className="flex items-center gap-2 px-4 py-3 text-gray-500 dark:text-gray-400">
<LoadingDots /> <LoadingDots />
<span className="text-sm">Thinking...</span> <span className="text-sm">...</span>
</div> </div>
) : ( ) : (
<div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}> <div className={`p-4 shadow-sm ${isUser ? 'chat-bubble-user shadow-md' : 'chat-bubble-assistant'} relative group`}>
{/* Optimistic sending indicator */} {/* Optimistic sending indicator */}
{isUser && message.optimistic && ( {isUser && message.optimistic && (
<span className="text-xs text-blue-200 dark:text-blue-300 mb-1 block animate-pulse"> <span className="text-xs text-blue-200 dark:text-blue-300 mb-1 block animate-pulse">
Sending... ...
</span> </span>
)} )}
{/* Reasoning block for thinking content (DeerFlow-inspired) */} {/* Reasoning block for thinking content (DeerFlow-inspired) */}

View File

@@ -543,7 +543,7 @@ export function CreateTriggerModal({ isOpen, onClose, onSuccess }: CreateTrigger
{submitStatus === 'success' && ( {submitStatus === 'success' && (
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg text-green-700 dark:text-green-400"> <div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg text-green-700 dark:text-green-400">
<CheckCircle className="w-5 h-5 flex-shrink-0" /> <CheckCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">Trigger created successfully!</span> <span className="text-sm"></span>
</div> </div>
)} )}
{submitStatus === 'error' && ( {submitStatus === 'error' && (

View File

@@ -57,21 +57,21 @@ const RISK_CONFIG: Record<
{ label: string; color: string; bgColor: string; borderColor: string; icon: typeof AlertTriangle } { label: string; color: string; bgColor: string; borderColor: string; icon: typeof AlertTriangle }
> = { > = {
low: { low: {
label: 'Low Risk', label: '低风险',
color: 'text-green-600 dark:text-green-400', color: 'text-green-600 dark:text-green-400',
bgColor: 'bg-green-100 dark:bg-green-900/30', bgColor: 'bg-green-100 dark:bg-green-900/30',
borderColor: 'border-green-300 dark:border-green-700', borderColor: 'border-green-300 dark:border-green-700',
icon: CheckCircle, icon: CheckCircle,
}, },
medium: { medium: {
label: 'Medium Risk', label: '中风险',
color: 'text-yellow-600 dark:text-yellow-400', color: 'text-yellow-600 dark:text-yellow-400',
bgColor: 'bg-yellow-100 dark:bg-yellow-900/30', bgColor: 'bg-yellow-100 dark:bg-yellow-900/30',
borderColor: 'border-yellow-300 dark:border-yellow-700', borderColor: 'border-yellow-300 dark:border-yellow-700',
icon: AlertTriangle, icon: AlertTriangle,
}, },
high: { high: {
label: 'High Risk', label: '高风险',
color: 'text-red-600 dark:text-red-400', color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-100 dark:bg-red-900/30', bgColor: 'bg-red-100 dark:bg-red-900/30',
borderColor: 'border-red-300 dark:border-red-700', borderColor: 'border-red-300 dark:border-red-700',
@@ -135,32 +135,32 @@ function calculateRiskLevel(handId: HandId, params: Record<string, unknown>): Ri
function getExpectedImpact(handId: HandId, params: Record<string, unknown>): string { function getExpectedImpact(handId: HandId, params: Record<string, unknown>): string {
switch (handId) { switch (handId) {
case 'browser': case 'browser':
return `Will perform browser automation on ${params.url || 'specified URL'}`; return `将在 ${params.url || '指定网址'} 执行浏览器自动化`;
case 'twitter': case 'twitter':
if (params.action === 'post') { if (params.action === 'post') {
return 'Will post content to Twitter/X publicly'; return '将公开发布内容到 Twitter/X';
} }
if (params.action === 'engage') { if (params.action === 'engage') {
return 'Will like/reply to tweets'; return '将点赞/回复推文';
} }
return 'Will perform Twitter/X operations'; return '将执行 Twitter/X 操作';
case 'collector': case 'collector':
return `Will collect data from ${params.targetUrl || 'specified source'}`; return `将从 ${params.targetUrl || '指定来源'} 收集数据`;
case 'lead': case 'lead':
return `Will search for leads from ${params.source || 'specified source'}`; return `将从 ${params.source || '指定来源'} 搜索线索`;
case 'clip': case 'clip':
return `Will process video: ${params.inputPath || 'specified input'}`; return `将处理视频: ${params.inputPath || '指定输入'}`;
case 'predictor': case 'predictor':
return `Will run prediction on ${params.dataSource || 'specified data'}`; return `将对 ${params.dataSource || '指定数据'} 运行预测`;
case 'researcher': case 'researcher':
return `Will conduct research on: ${params.topic || 'specified topic'}`; return `将研究: ${params.topic || '指定主题'}`;
default: default:
return 'Will execute Hand operation'; return '将执行 Hand 操作';
} }
} }
function formatTimeRemaining(seconds: number): string { function formatTimeRemaining(seconds: number): string {
if (seconds <= 0) return 'Expired'; if (seconds <= 0) return '已过期';
if (seconds < 60) return `${seconds}s`; if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
const secs = seconds % 60; const secs = seconds % 60;
@@ -218,7 +218,7 @@ function TimeoutProgress({ timeRemaining, totalSeconds }: { timeRemaining: numbe
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1"> <span className="text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
Time Remaining
</span> </span>
<span <span
className={`font-medium ${isUrgent ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-gray-300'}`} className={`font-medium ${isUrgent ? 'text-red-600 dark:text-red-400' : 'text-gray-700 dark:text-gray-300'}`}
@@ -241,7 +241,7 @@ function TimeoutProgress({ timeRemaining, totalSeconds }: { timeRemaining: numbe
function ParamsDisplay({ params }: { params: Record<string, unknown> }) { function ParamsDisplay({ params }: { params: Record<string, unknown> }) {
if (!params || Object.keys(params).length === 0) { if (!params || Object.keys(params).length === 0) {
return ( return (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">No parameters provided</p> <p className="text-sm text-gray-500 dark:text-gray-400 italic"></p>
); );
} }
@@ -282,7 +282,7 @@ export function HandApprovalModal({
runId: handRun.runId, runId: handRun.runId,
handId, handId,
handName: handDef?.name || handId, handName: handDef?.name || handId,
description: handDef?.description || 'Hand execution request', description: handDef?.description || 'Hand 执行请求',
params, params,
riskLevel: calculateRiskLevel(handId, params), riskLevel: calculateRiskLevel(handId, params),
expectedImpact: getExpectedImpact(handId, params), expectedImpact: getExpectedImpact(handId, params),
@@ -329,7 +329,7 @@ export function HandApprovalModal({
await onApprove(approvalData.runId); await onApprove(approvalData.runId);
onClose(); onClose();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to approve'); setError(err instanceof Error ? err.message : '批准失败');
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
@@ -344,7 +344,7 @@ export function HandApprovalModal({
} }
if (!rejectReason.trim()) { if (!rejectReason.trim()) {
setError('Please provide a reason for rejection'); setError('请提供拒绝原因');
return; return;
} }
@@ -355,7 +355,7 @@ export function HandApprovalModal({
await onReject(approvalData.runId, rejectReason.trim()); await onReject(approvalData.runId, rejectReason.trim());
onClose(); onClose();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reject'); setError(err instanceof Error ? err.message : '拒绝失败');
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
@@ -387,10 +387,10 @@ export function HandApprovalModal({
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white"> <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Hand Approval Request Hand
</h2> </h2>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500 dark:text-gray-400">
Review and approve Hand execution Hand
</p> </p>
</div> </div>
</div> </div>
@@ -408,7 +408,7 @@ export function HandApprovalModal({
{isExpired && ( {isExpired && (
<div className="flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-900 rounded-lg text-gray-600 dark:text-gray-400"> <div className="flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-900 rounded-lg text-gray-600 dark:text-gray-400">
<Clock className="w-5 h-5 flex-shrink-0" /> <Clock className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">This approval request has expired</span> <span className="text-sm"></span>
</div> </div>
)} )}
@@ -439,7 +439,7 @@ export function HandApprovalModal({
{/* Parameters */} {/* Parameters */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Execution Parameters
</label> </label>
<ParamsDisplay params={approvalData.params} /> <ParamsDisplay params={approvalData.params} />
</div> </div>
@@ -449,7 +449,7 @@ export function HandApprovalModal({
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1">
<Info className="w-3.5 h-3.5" /> <Info className="w-3.5 h-3.5" />
Expected Impact
</label> </label>
<p className="text-sm text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg"> <p className="text-sm text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
{approvalData.expectedImpact} {approvalData.expectedImpact}
@@ -459,9 +459,9 @@ export function HandApprovalModal({
{/* Request Info */} {/* Request Info */}
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1 pt-2 border-t border-gray-200 dark:border-gray-700"> <div className="text-xs text-gray-500 dark:text-gray-400 space-y-1 pt-2 border-t border-gray-200 dark:border-gray-700">
<p>Run ID: {approvalData.runId}</p> <p> ID: {approvalData.runId}</p>
<p> <p>
Requested: {new Date(approvalData.requestedAt).toLocaleString()} : {new Date(approvalData.requestedAt).toLocaleString()}
</p> </p>
</div> </div>
@@ -469,12 +469,12 @@ export function HandApprovalModal({
{showRejectInput && ( {showRejectInput && (
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Rejection Reason <span className="text-red-500">*</span> <span className="text-red-500">*</span>
</label> </label>
<textarea <textarea
value={rejectReason} value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)} onChange={(e) => setRejectReason(e.target.value)}
placeholder="Please provide a reason for rejecting this request..." placeholder="请提供拒绝此请求的原因..."
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500" className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500"
rows={3} rows={3}
autoFocus autoFocus
@@ -502,7 +502,7 @@ export function HandApprovalModal({
disabled={isProcessing} disabled={isProcessing}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50" className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
> >
Cancel
</button> </button>
<button <button
type="button" type="button"
@@ -513,12 +513,12 @@ export function HandApprovalModal({
{isProcessing ? ( {isProcessing ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Rejecting... ...
</> </>
) : ( ) : (
<> <>
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
Confirm Rejection
</> </>
)} )}
</button> </button>
@@ -531,7 +531,7 @@ export function HandApprovalModal({
disabled={isProcessing} disabled={isProcessing}
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50" className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
> >
Close
</button> </button>
<button <button
type="button" type="button"
@@ -540,7 +540,7 @@ export function HandApprovalModal({
className="px-4 py-2 text-sm border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50 flex items-center gap-2" className="px-4 py-2 text-sm border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50 flex items-center gap-2"
> >
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
Reject
</button> </button>
<button <button
type="button" type="button"
@@ -551,12 +551,12 @@ export function HandApprovalModal({
{isProcessing ? ( {isProcessing ? (
<> <>
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Approving... ...
</> </>
) : ( ) : (
<> <>
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
Approve
</> </>
)} )}
</button> </button>

View File

@@ -368,7 +368,7 @@ function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInput
</div> </div>
{items.length === 0 && !newItem && ( {items.length === 0 && !newItem && (
<p className="text-xs text-gray-400 text-center">No items added yet</p> <p className="text-xs text-gray-400 text-center"></p>
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,441 @@
/**
* HealthPanel — Read-only dashboard for all subsystem health status
*
* Displays:
* - Agent Heartbeat engine status (running, config, alerts)
* - Connection status (mode, SaaS reachability)
* - SaaS device heartbeat status
* - Memory pipeline status
* - Recent alerts history
*
* No config editing (that's HeartbeatConfig tab).
* Uses useState (not Zustand) — component-scoped state.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Activity,
RefreshCw,
Wifi,
WifiOff,
Cloud,
CloudOff,
Database,
AlertTriangle,
CheckCircle,
XCircle,
Clock,
} from 'lucide-react';
import { intelligenceClient, type HeartbeatResult } from '../lib/intelligence-client';
import { useConnectionStore } from '../store/connectionStore';
import { useSaaSStore } from '../store/saasStore';
import { isTauriRuntime } from '../lib/tauri-gateway';
import { safeListen } from '../lib/safe-tauri';
import { createLogger } from '../lib/logger';
const log = createLogger('HealthPanel');
// === Types ===
interface HealthSnapshotData {
timestamp: string;
intelligence: {
engineRunning: boolean;
config: {
enabled: boolean;
interval_minutes: number;
proactivity_level: string;
};
lastTick: string | null;
alertCount24h: number;
totalChecks: number;
};
memory: {
totalEntries: number;
storageSizeBytes: number;
lastExtraction: string | null;
};
}
interface HealthCardProps {
title: string;
icon: React.ReactNode;
status: 'green' | 'yellow' | 'gray' | 'red';
children: React.ReactNode;
}
const STATUS_COLORS = {
green: 'text-green-500',
yellow: 'text-yellow-500',
gray: 'text-gray-400',
red: 'text-red-500',
};
const STATUS_BG = {
green: 'bg-green-50 dark:bg-green-900/20',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20',
gray: 'bg-gray-50 dark:bg-gray-800/50',
red: 'bg-red-50 dark:bg-red-900/20',
};
function HealthCard({ title, icon, status, children }: HealthCardProps) {
return (
<div className={`rounded-lg border border-gray-200 dark:border-gray-700 p-4 ${STATUS_BG[status]}`}>
<div className="flex items-center gap-2 mb-3">
<span className={STATUS_COLORS[status]}>{icon}</span>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</h3>
<span className={`ml-auto text-xs ${STATUS_COLORS[status]}`}>
{status === 'green' ? '正常' : status === 'yellow' ? '降级' : status === 'red' ? '异常' : '未启用'}
</span>
</div>
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
{children}
</div>
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatTime(isoString: string | null): string {
if (!isoString) return '从未';
try {
const date = new Date(isoString);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return isoString;
}
}
function formatUrgency(urgency: string): { label: string; color: string } {
switch (urgency) {
case 'high': return { label: '高', color: 'text-red-500' };
case 'medium': return { label: '中', color: 'text-yellow-500' };
case 'low': return { label: '低', color: 'text-blue-500' };
default: return { label: urgency, color: 'text-gray-500' };
}
}
// === Main Component ===
export function HealthPanel() {
const [snapshot, setSnapshot] = useState<HealthSnapshotData | null>(null);
const [alerts, setAlerts] = useState<HeartbeatResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const alertsEndRef = useRef<HTMLDivElement>(null);
// Get live connection and SaaS state
const connectionState = useConnectionStore((s) => s.connectionState);
const gatewayVersion = useConnectionStore((s) => s.gatewayVersion);
const connectionMode = useSaaSStore((s) => s.connectionMode);
const saasReachable = useSaaSStore((s) => s.saasReachable);
const consecutiveFailures = useSaaSStore((s) => s._consecutiveFailures);
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
// Fetch health snapshot
const fetchSnapshot = useCallback(async () => {
if (!isTauriRuntime()) return;
setLoading(true);
setError(null);
try {
const { invoke } = await import('@tauri-apps/api/core');
const data = await invoke<HealthSnapshotData>('health_snapshot', {
agentId: 'zclaw-main',
});
setSnapshot(data);
} catch (err) {
log.warn('Failed to fetch health snapshot:', err);
setError(String(err));
} finally {
setLoading(false);
}
}, []);
// Fetch alert history
const fetchAlerts = useCallback(async () => {
if (!isTauriRuntime()) return;
try {
const history = await intelligenceClient.heartbeat.getHistory('zclaw-main', 100);
setAlerts(history);
} catch (err) {
log.warn('Failed to fetch alert history:', err);
}
}, []);
// Initial load
useEffect(() => {
fetchSnapshot();
fetchAlerts();
}, [fetchSnapshot, fetchAlerts]);
// Subscribe to real-time alerts
useEffect(() => {
if (!isTauriRuntime()) return;
let unlisten: (() => void) | null = null;
const subscribe = async () => {
unlisten = await safeListen<Array<{ title: string; content: string; urgency: string; source: string; timestamp: string }>>(
'heartbeat:alert',
(newAlerts) => {
// Prepend new alerts to history
setAlerts((prev) => {
const result: HeartbeatResult[] = [
{
status: 'alert',
alerts: newAlerts.map((a) => ({
title: a.title,
content: a.content,
urgency: a.urgency as 'low' | 'medium' | 'high',
source: a.source,
timestamp: a.timestamp,
})),
checked_items: 0,
timestamp: new Date().toISOString(),
},
...prev,
];
// Keep max 100
return result.slice(0, 100);
});
},
);
};
subscribe();
return () => {
if (unlisten) unlisten();
};
}, []);
// Auto-scroll alerts to show latest
useEffect(() => {
alertsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [alerts]);
// Determine SaaS card status
const saasStatus: 'green' | 'yellow' | 'gray' | 'red' = !isLoggedIn
? 'gray'
: saasReachable
? 'green'
: 'red';
// Determine connection card status
const isActuallyConnected = connectionState === 'connected';
const connectionStatus: 'green' | 'yellow' | 'gray' | 'red' = isActuallyConnected
? 'green'
: connectionState === 'connecting' || connectionState === 'reconnecting'
? 'yellow'
: 'red';
// Determine heartbeat card status
const heartbeatStatus: 'green' | 'yellow' | 'gray' | 'red' = !snapshot
? 'gray'
: snapshot.intelligence.engineRunning
? 'green'
: snapshot.intelligence.config.enabled
? 'yellow'
: 'gray';
// Determine memory card status
const memoryStatus: 'green' | 'yellow' | 'gray' | 'red' = !snapshot
? 'gray'
: snapshot.memory.totalEntries === 0
? 'gray'
: snapshot.memory.storageSizeBytes > 50 * 1024 * 1024
? 'yellow'
: 'green';
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
</div>
<button
onClick={() => { fetchSnapshot(); fetchAlerts(); }}
disabled={loading}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg">
: {error}
</div>
)}
{/* Health Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Agent Heartbeat Card */}
<HealthCard
title="Agent 心跳"
icon={<Activity className="w-4 h-4" />}
status={heartbeatStatus}
>
<div className="flex justify-between">
<span></span>
<span className={snapshot?.intelligence.engineRunning ? 'text-green-600' : 'text-gray-400'}>
{snapshot?.intelligence.engineRunning ? '运行中' : '已停止'}
</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{snapshot?.intelligence.config.interval_minutes ?? '-'} </span>
</div>
<div className="flex justify-between">
<span></span>
<span>{formatTime(snapshot?.intelligence.lastTick ?? null)}</span>
</div>
<div className="flex justify-between">
<span>24h </span>
<span>{snapshot?.intelligence.alertCount24h ?? 0}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{snapshot?.intelligence.config.proactivity_level ?? '-'}</span>
</div>
</HealthCard>
{/* Connection Card */}
<HealthCard
title="连接状态"
icon={isActuallyConnected ? <Wifi className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
status={connectionStatus}
>
<div className="flex justify-between">
<span></span>
<span>{connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'tauri' ? '本地模式' : connectionMode}</span>
</div>
<div className="flex justify-between">
<span></span>
<span className={isActuallyConnected ? 'text-green-600' : connectionState === 'connecting' ? 'text-yellow-500' : 'text-red-500'}>
{connectionState === 'connected' ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{gatewayVersion ?? '-'}</span>
</div>
<div className="flex justify-between">
<span>SaaS </span>
<span className={saasReachable ? 'text-green-600' : 'text-red-500'}>
{saasReachable ? '是' : '否'}
</span>
</div>
</HealthCard>
{/* SaaS Device Card */}
<HealthCard
title="SaaS 设备"
icon={saasReachable ? <Cloud className="w-4 h-4" /> : <CloudOff className="w-4 h-4" />}
status={saasStatus}
>
<div className="flex justify-between">
<span></span>
<span>{isLoggedIn ? '已注册' : '未注册'}</span>
</div>
<div className="flex justify-between">
<span></span>
<span className={consecutiveFailures > 0 ? 'text-yellow-500' : 'text-green-600'}>
{consecutiveFailures}
</span>
</div>
<div className="flex justify-between">
<span></span>
<span className={saasReachable ? 'text-green-600' : 'text-red-500'}>
{saasReachable ? '在线' : isLoggedIn ? '离线 (已降级)' : '未连接'}
</span>
</div>
</HealthCard>
{/* Memory Card */}
<HealthCard
title="记忆管道"
icon={<Database className="w-4 h-4" />}
status={memoryStatus}
>
<div className="flex justify-between">
<span></span>
<span>{snapshot?.memory.totalEntries ?? 0}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{formatBytes(snapshot?.memory.storageSizeBytes ?? 0)}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{formatTime(snapshot?.memory.lastExtraction ?? null)}</span>
</div>
</HealthCard>
</div>
{/* Alerts History */}
<div className="rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 p-3 border-b border-gray-200 dark:border-gray-700">
<AlertTriangle className="w-4 h-4 text-yellow-500" />
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"></h3>
<span className="ml-auto text-xs text-gray-400">
{alerts.reduce((sum, r) => sum + r.alerts.length, 0)}
</span>
</div>
<div className="max-h-64 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
{alerts.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-400"></div>
) : (
alerts.map((result, ri) =>
result.alerts.map((alert, ai) => (
<div key={`${ri}-${ai}`} className="flex items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/50">
<span className={`mt-0.5 ${formatUrgency(alert.urgency).color}`}>
{alert.urgency === 'high' ? (
<XCircle className="w-3.5 h-3.5" />
) : alert.urgency === 'medium' ? (
<AlertTriangle className="w-3.5 h-3.5" />
) : (
<CheckCircle className="w-3.5 h-3.5" />
)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
{alert.title}
</span>
<span className={`text-xs px-1 rounded ${formatUrgency(alert.urgency).color} bg-opacity-10`}>
{formatUrgency(alert.urgency).label}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{alert.content}</p>
</div>
<span className="text-xs text-gray-400 whitespace-nowrap flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(alert.timestamp)}
</span>
</div>
))
)
)}
<div ref={alertsEndRef} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -31,6 +31,9 @@ import {
type HeartbeatResult, type HeartbeatResult,
type HeartbeatAlert, type HeartbeatAlert,
} from '../lib/intelligence-client'; } from '../lib/intelligence-client';
import { createLogger } from '../lib/logger';
const log = createLogger('HeartbeatConfig');
// === Default Config === // === Default Config ===
@@ -312,9 +315,15 @@ export function HeartbeatConfig({ className = '', onConfigChange }: HeartbeatCon
}); });
}, []); }, []);
const handleSave = useCallback(() => { const handleSave = useCallback(async () => {
localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config)); localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config));
localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems)); localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems));
// Sync to Rust backend (non-blocking — UI updates immediately)
try {
await intelligenceClient.heartbeat.updateConfig('zclaw-main', config);
} catch (err) {
log.warn('[HeartbeatConfig] Backend sync failed:', err);
}
setHasChanges(false); setHasChanges(false);
}, [config, checkItems]); }, [config, checkItems]);

View File

@@ -428,10 +428,10 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
onChange={(e) => setFilters((prev) => ({ ...prev, timeRange: e.target.value as SearchFilters['timeRange'] }))} onChange={(e) => setFilters((prev) => ({ ...prev, timeRange: e.target.value as SearchFilters['timeRange'] }))}
className="text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-orange-500" className="text-xs bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-orange-500"
> >
<option value="all">All time</option> <option value="all"></option>
<option value="today">Today</option> <option value="today"></option>
<option value="week">This week</option> <option value="week"></option>
<option value="month">This month</option> <option value="month"></option>
</select> </select>
</div> </div>
</div> </div>
@@ -442,7 +442,7 @@ export function MessageSearch({ onNavigateToMessage }: MessageSearchProps) {
{/* Search history */} {/* Search history */}
{!query && searchHistory.length > 0 && ( {!query && searchHistory.length > 0 && (
<div className="mt-2"> <div className="mt-2">
<div className="text-xs text-gray-400 dark:text-gray-500 mb-1">Recent searches:</div> <div className="text-xs text-gray-400 dark:text-gray-500 mb-1">:</div>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{searchHistory.slice(0, 5).map((item, index) => ( {searchHistory.slice(0, 5).map((item, index) => (
<button <button

View File

@@ -179,7 +179,7 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
.catch(() => setUserProfile(null)); .catch(() => setUserProfile(null));
}, [currentAgent?.id]); }, [currentAgent?.id]);
// Listen for profile updates after conversations // Listen for profile updates after conversations (fired after memory extraction completes)
useEffect(() => { useEffect(() => {
const handler = (e: Event) => { const handler = (e: Event) => {
const detail = (e as CustomEvent).detail; const detail = (e as CustomEvent).detail;
@@ -187,6 +187,8 @@ export function RightPanel({ simpleMode = false }: RightPanelProps) {
invoke<AgentInfo | null>('agent_get', { agentId: currentAgent.id }) invoke<AgentInfo | null>('agent_get', { agentId: currentAgent.id })
.then(data => setUserProfile(data?.userProfile ?? null)) .then(data => setUserProfile(data?.userProfile ?? null))
.catch(() => {}); .catch(() => {});
// Refresh clones data so selectedClone (name, role, nickname, etc.) stays current
loadClones();
} }
}; };
window.addEventListener('zclaw:agent-profile-updated', handler); window.addEventListener('zclaw:agent-profile-updated', handler);

View File

@@ -10,6 +10,10 @@ import {
Package, Package,
BarChart, BarChart,
Palette, Palette,
HeartPulse,
GraduationCap,
Landmark,
Scale,
Server, Server,
Search, Search,
Megaphone, Megaphone,
@@ -33,6 +37,10 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Package, Package,
BarChart, BarChart,
Palette, Palette,
HeartPulse,
GraduationCap,
Landmark,
Scale,
Server, Server,
Search, Search,
Megaphone, Megaphone,

View File

@@ -1,53 +0,0 @@
import { useState } from 'react';
export function Credits() {
const [filter, setFilter] = useState<'all' | 'consume' | 'earn'>('all');
return (
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold text-gray-900"></h1>
<div className="flex gap-2">
<button className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors">
</button>
<button className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg transition-colors">
</button>
</div>
</div>
<div className="text-center mb-8 py-12">
<div className="text-xs text-gray-500 mb-1"></div>
<div className="text-3xl font-bold text-gray-900">--</div>
<div className="text-xs text-gray-400 mt-2"></div>
</div>
<div className="p-1 mb-6 flex rounded-lg bg-gray-50 border border-gray-100 shadow-sm">
<button
onClick={() => setFilter('all')}
className={`flex-1 py-2 rounded-md text-xs transition-colors ${filter === 'all' ? 'bg-white shadow-sm font-medium text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
<button
onClick={() => setFilter('consume')}
className={`flex-1 py-2 rounded-md text-xs transition-colors ${filter === 'consume' ? 'bg-white shadow-sm font-medium text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
<button
onClick={() => setFilter('earn')}
className={`flex-1 py-2 rounded-md text-xs transition-colors ${filter === 'earn' ? 'bg-white shadow-sm font-medium text-gray-900' : 'text-gray-500 hover:text-gray-700'}`}
>
</button>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="text-sm text-gray-400"></div>
<div className="text-xs text-gray-300 mt-1">使</div>
</div>
</div>
);
}

View File

@@ -2,14 +2,12 @@ import { useState } from 'react';
import { useSecurityStore } from '../../store/securityStore'; import { useSecurityStore } from '../../store/securityStore';
import { import {
Settings as SettingsIcon, Settings as SettingsIcon,
BarChart3,
Puzzle, Puzzle,
MessageSquare, MessageSquare,
FolderOpen, FolderOpen,
Shield, Shield,
Info, Info,
ArrowLeft, ArrowLeft,
Coins,
Cpu, Cpu,
Zap, Zap,
HelpCircle, HelpCircle,
@@ -18,12 +16,12 @@ import {
Heart, Heart,
Key, Key,
Database, Database,
Activity,
Cloud, Cloud,
CreditCard, CreditCard,
} from 'lucide-react'; } from 'lucide-react';
import { silentErrorHandler } from '../../lib/error-utils'; import { silentErrorHandler } from '../../lib/error-utils';
import { General } from './General'; import { General } from './General';
import { UsageStats } from './UsageStats';
import { ModelsAPI } from './ModelsAPI'; import { ModelsAPI } from './ModelsAPI';
import { MCPServices } from './MCPServices'; import { MCPServices } from './MCPServices';
import { Skills } from './Skills'; import { Skills } from './Skills';
@@ -31,12 +29,12 @@ import { IMChannels } from './IMChannels';
import { Workspace } from './Workspace'; import { Workspace } from './Workspace';
import { Privacy } from './Privacy'; import { Privacy } from './Privacy';
import { About } from './About'; import { About } from './About';
import { Credits } from './Credits';
import { AuditLogsPanel } from '../AuditLogsPanel'; import { AuditLogsPanel } from '../AuditLogsPanel';
import { SecurityStatus } from '../SecurityStatus'; import { SecurityStatus } from '../SecurityStatus';
import { SecurityLayersPanel } from '../SecurityLayersPanel'; import { SecurityLayersPanel } from '../SecurityLayersPanel';
import { TaskList } from '../TaskList'; import { TaskList } from '../TaskList';
import { HeartbeatConfig } from '../HeartbeatConfig'; import { HeartbeatConfig } from '../HeartbeatConfig';
import { HealthPanel } from '../HealthPanel';
import { SecureStorage } from './SecureStorage'; import { SecureStorage } from './SecureStorage';
import { VikingPanel } from '../VikingPanel'; import { VikingPanel } from '../VikingPanel';
import { SaaSSettings } from '../SaaS/SaaSSettings'; import { SaaSSettings } from '../SaaS/SaaSSettings';
@@ -49,8 +47,6 @@ interface SettingsLayoutProps {
type SettingsPage = type SettingsPage =
| 'general' | 'general'
| 'usage'
| 'credits'
| 'models' | 'models'
| 'mcp' | 'mcp'
| 'skills' | 'skills'
@@ -65,14 +61,13 @@ type SettingsPage =
| 'audit' | 'audit'
| 'tasks' | 'tasks'
| 'heartbeat' | 'heartbeat'
| 'health'
| 'feedback' | 'feedback'
| 'about'; | 'about';
const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode; group?: 'advanced' }[] = [ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode; group?: 'advanced' }[] = [
// --- Core settings --- // --- Core settings ---
{ id: 'general', label: '通用', icon: <SettingsIcon className="w-4 h-4" /> }, { id: 'general', label: '通用', icon: <SettingsIcon className="w-4 h-4" /> },
{ id: 'usage', label: '用量统计', icon: <BarChart3 className="w-4 h-4" /> },
{ id: 'credits', label: '积分详情', icon: <Coins className="w-4 h-4" /> },
{ id: 'models', label: '模型与 API', icon: <Cpu className="w-4 h-4" /> }, { id: 'models', label: '模型与 API', icon: <Cpu className="w-4 h-4" /> },
{ id: 'mcp', label: 'MCP 服务', icon: <Puzzle className="w-4 h-4" /> }, { id: 'mcp', label: 'MCP 服务', icon: <Puzzle className="w-4 h-4" /> },
{ id: 'im', label: 'IM 频道', icon: <MessageSquare className="w-4 h-4" /> }, { id: 'im', label: 'IM 频道', icon: <MessageSquare className="w-4 h-4" /> },
@@ -89,6 +84,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode; group
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" />, group: 'advanced' }, { id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" />, group: 'advanced' },
{ id: 'tasks', label: '定时任务', icon: <Clock className="w-4 h-4" />, group: 'advanced' }, { id: 'tasks', label: '定时任务', icon: <Clock className="w-4 h-4" />, group: 'advanced' },
{ id: 'heartbeat', label: '心跳配置', icon: <Heart className="w-4 h-4" />, group: 'advanced' }, { id: 'heartbeat', label: '心跳配置', icon: <Heart className="w-4 h-4" />, group: 'advanced' },
{ id: 'health', label: '系统健康', icon: <Activity className="w-4 h-4" />, group: 'advanced' },
// --- Footer --- // --- Footer ---
{ id: 'feedback', label: '提交反馈', icon: <HelpCircle className="w-4 h-4" /> }, { id: 'feedback', label: '提交反馈', icon: <HelpCircle className="w-4 h-4" /> },
{ id: 'about', label: '关于', icon: <Info className="w-4 h-4" /> }, { id: 'about', label: '关于', icon: <Info className="w-4 h-4" /> },
@@ -101,8 +97,6 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
const renderPage = () => { const renderPage = () => {
switch (activePage) { switch (activePage) {
case 'general': return <General />; case 'general': return <General />;
case 'usage': return <UsageStats />;
case 'credits': return <Credits />;
case 'models': return <ModelsAPI />; case 'models': return <ModelsAPI />;
case 'mcp': return <MCPServices />; case 'mcp': return <MCPServices />;
case 'skills': return <Skills />; case 'skills': return <Skills />;
@@ -175,6 +169,16 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
</div> </div>
</ErrorBoundary> </ErrorBoundary>
); );
case 'health': return (
<ErrorBoundary
fallback={<div className="p-6 text-center text-gray-500"></div>}
onError={(err, info) => console.error('[Settings] Health page error:', err, info.componentStack)}
>
<div className="max-w-3xl h-full">
<HealthPanel />
</div>
</ErrorBoundary>
);
case 'viking': return ( case 'viking': return (
<ErrorBoundary <ErrorBoundary
fallback={<div className="p-6 text-center text-gray-500"></div>} fallback={<div className="p-6 text-center text-gray-500"></div>}

View File

@@ -1,177 +0,0 @@
import { useEffect, useState } from 'react';
import { useAgentStore } from '../../store/agentStore';
import { BarChart3, TrendingUp, Clock, Zap } from 'lucide-react';
export function UsageStats() {
const usageStats = useAgentStore((s) => s.usageStats);
const loadUsageStats = useAgentStore((s) => s.loadUsageStats);
const [timeRange, setTimeRange] = useState<'7d' | '30d' | 'all'>('7d');
useEffect(() => {
loadUsageStats();
}, [loadUsageStats]);
const stats = usageStats || { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} };
const models = Object.entries(stats.byModel || {});
const formatTokens = (n: number) => {
if (n >= 1_000_000) return `~${(n / 1_000_000).toFixed(1)} M`;
if (n >= 1_000) return `~${(n / 1_000).toFixed(1)} k`;
return `${n}`;
};
// 计算总输入和输出 Token
const totalInputTokens = models.reduce((sum, [_, data]) => sum + data.inputTokens, 0);
const totalOutputTokens = models.reduce((sum, [_, data]) => sum + data.outputTokens, 0);
return (
<div className="max-w-3xl">
<div className="flex justify-between items-center mb-6">
<h1 className="text-xl font-bold text-gray-900"></h1>
<div className="flex items-center gap-2">
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
{(['7d', '30d', 'all'] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
timeRange === range
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{range === '7d' ? '近 7 天' : range === '30d' ? '近 30 天' : '全部'}
</button>
))}
</div>
<button
onClick={() => loadUsageStats()}
className="text-xs text-gray-500 hover:text-gray-700 px-3 py-1.5 border border-gray-200 rounded-lg transition-colors"
>
</button>
</div>
</div>
<div className="text-xs text-gray-500 mb-4">使</div>
{/* 主要统计卡片 */}
<div className="grid grid-cols-4 gap-4 mb-8">
<StatCard
icon={BarChart3}
label="会话数"
value={stats.totalSessions}
color="text-blue-500"
/>
<StatCard
icon={Zap}
label="消息数"
value={stats.totalMessages}
color="text-purple-500"
/>
<StatCard
icon={TrendingUp}
label="输入 Token"
value={formatTokens(totalInputTokens)}
color="text-green-500"
/>
<StatCard
icon={Clock}
label="输出 Token"
value={formatTokens(totalOutputTokens)}
color="text-orange-500"
/>
</div>
{/* 总 Token 使用量概览 */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mb-6">
<h3 className="text-sm font-semibold mb-4 text-gray-900">Token 使</h3>
{stats.totalTokens === 0 ? (
<p className="text-xs text-gray-400">Token </p>
) : (
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span></span>
<span></span>
</div>
<div className="h-3 bg-gray-100 rounded-full overflow-hidden flex">
<div
className="bg-gradient-to-r from-green-400 to-green-500 h-full transition-all"
style={{ width: `${(totalInputTokens / Math.max(totalInputTokens + totalOutputTokens, 1)) * 100}%` }}
/>
<div
className="bg-gradient-to-r from-orange-400 to-orange-500 h-full transition-all"
style={{ width: `${(totalOutputTokens / Math.max(totalInputTokens + totalOutputTokens, 1)) * 100}%` }}
/>
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-lg font-bold text-gray-900">{formatTokens(stats.totalTokens)}</div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
)}
</div>
{/* 按模型分组 */}
<h2 className="text-sm font-semibold mb-4 text-gray-900"></h2>
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100 shadow-sm">
{models.length === 0 ? (
<div className="p-8 text-center">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
<BarChart3 className="w-6 h-6 text-gray-400" />
</div>
<p className="text-sm text-gray-400">使</p>
<p className="text-xs text-gray-300 mt-1"></p>
</div>
) : (
models.map(([model, data]) => {
const total = data.inputTokens + data.outputTokens;
const inputPct = (data.inputTokens / Math.max(total, 1)) * 100;
const outputPct = (data.outputTokens / Math.max(total, 1)) * 100;
return (
<div key={model} className="p-4">
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-gray-900">{model}</span>
<span className="text-xs text-gray-500">{data.messages} </span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden mb-2 flex">
<div className="bg-orange-500 h-full" style={{ width: `${inputPct}%` }} />
<div className="bg-orange-200 h-full" style={{ width: `${outputPct}%` }} />
</div>
<div className="flex justify-between text-xs text-gray-500">
<span>: {formatTokens(data.inputTokens)}</span>
<span>: {formatTokens(data.outputTokens)}</span>
<span>: {formatTokens(total)}</span>
</div>
</div>
);
})
)}
</div>
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
color,
}: {
icon: typeof BarChart3;
label: string;
value: string | number;
color: string;
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-4 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<Icon className={`w-4 h-4 ${color}`} />
<span className="text-xs text-gray-500">{label}</span>
</div>
<div className="text-2xl font-bold text-gray-900">{value}</div>
</div>
);
}

View File

@@ -7,10 +7,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Settings, LayoutGrid, Settings, LayoutGrid, SquarePen,
Search, X, Search, X,
} from 'lucide-react'; } from 'lucide-react';
import { ConversationList } from './ConversationList'; import { ConversationList } from './ConversationList';
import { useChatStore } from '../store/chatStore';
interface SimpleSidebarProps { interface SimpleSidebarProps {
onOpenSettings?: () => void; onOpenSettings?: () => void;
@@ -19,6 +20,11 @@ interface SimpleSidebarProps {
export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarProps) { export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarProps) {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const newConversation = useChatStore((s) => s.newConversation);
const handleNewConversation = () => {
newConversation();
};
return ( return (
<aside className="w-64 sidebar-bg border-r border-[#e8e6e1] dark:border-gray-800 flex flex-col h-full shrink-0"> <aside className="w-64 sidebar-bg border-r border-[#e8e6e1] dark:border-gray-800 flex flex-col h-full shrink-0">
@@ -27,11 +33,26 @@ export function SimpleSidebar({ onOpenSettings, onToggleMode }: SimpleSidebarPro
<span className="text-lg font-semibold tracking-tight bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent"> <span className="text-lg font-semibold tracking-tight bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
ZCLAW ZCLAW
</span> </span>
<button
onClick={handleNewConversation}
className="ml-auto p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-md transition-colors text-gray-600 dark:text-gray-400"
title="新对话"
>
<SquarePen className="w-4 h-4" />
</button>
</div> </div>
{/* 内容区域 */} {/* 内容区域 */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<div className="p-2 h-full overflow-y-auto"> <div className="p-2 h-full overflow-y-auto">
{/* 新对话按钮 */}
<button
onClick={handleNewConversation}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-sm font-medium text-gray-900 dark:text-gray-100 hover:bg-black/10 dark:hover:bg-white/10 transition-colors mb-2"
>
<SquarePen className="w-4 h-4" />
</button>
{/* 搜索框 */} {/* 搜索框 */}
<div className="relative mb-2"> <div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />

View File

@@ -196,68 +196,89 @@ export function VikingPanel() {
)} )}
{/* Storage Info */} {/* Storage Info */}
{status?.available && ( <div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 shadow-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 shadow-sm"> <div className="flex items-center gap-3 mb-3">
<div className="flex items-center gap-3 mb-3"> <div className={`w-10 h-10 rounded-xl flex items-center justify-center ${status?.available ? 'bg-gradient-to-br from-blue-500 to-indigo-500' : 'bg-gray-300 dark:bg-gray-600'}`}>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-500 flex items-center justify-center"> <Database className="w-4 h-4 text-white" />
<Database className="w-4 h-4 text-white" /> </div>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
</div> </div>
<div> <div className="text-xs text-gray-500 dark:text-gray-400">
<div className="text-sm font-medium text-gray-900 dark:text-white"> {status?.available
? `${status.version || 'Native'} · ${status.dataDir || '默认路径'}`
</div> : '存储未连接'}
<div className="text-xs text-gray-500 dark:text-gray-400">
{status.version || 'Native'} · {status.dataDir || '默认路径'}
</div>
</div> </div>
</div> </div>
<div className="flex gap-4 text-xs"> {!status?.available && (
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300"> <button
<CheckCircle className="w-3.5 h-3.5 text-green-500" /> onClick={loadStatus}
<span>SQLite + FTS5</span> disabled={isLoading}
</div> className="ml-auto text-xs text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 flex items-center gap-1 disabled:opacity-50"
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300"> >
<CheckCircle className="w-3.5 h-3.5 text-green-500" /> <RefreshCw className={`w-3 h-3 ${isLoading ? 'animate-spin' : ''}`} />
<span>TF-IDF </span> </button>
</div> )}
{memoryCount !== null && (
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
<span>{memoryCount} </span>
</div>
)}
</div>
</div> </div>
)} <div className="flex gap-4 text-xs">
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
{status?.available ? (
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
) : (
<AlertCircle className="w-3.5 h-3.5 text-amber-500" />
)}
<span>SQLite + FTS5</span>
</div>
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
{status?.available ? (
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
) : (
<AlertCircle className="w-3.5 h-3.5 text-amber-500" />
)}
<span>TF-IDF </span>
</div>
{memoryCount !== null && (
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
<span>{memoryCount} </span>
</div>
)}
</div>
</div>
{/* Search Box */} {/* Search Box */}
{status?.available && ( <div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 shadow-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 shadow-sm"> <h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3"></h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3"></h3> {!status?.available && (
<div className="flex gap-2"> <p className="text-xs text-amber-600 dark:text-amber-400 mb-2 flex items-center gap-1">
<input <AlertCircle className="w-3 h-3" />
type="text" </p>
value={searchQuery} )}
onChange={(e) => setSearchQuery(e.target.value)} <div className="flex gap-2">
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} <input
placeholder="输入自然语言查询..." type="text"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" value={searchQuery}
/> onChange={(e) => setSearchQuery(e.target.value)}
<button onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
onClick={handleSearch} placeholder="输入自然语言查询..."
disabled={isSearching || !searchQuery.trim()} disabled={!status?.available}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2 text-sm" className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
> />
{isSearching ? ( <button
<RefreshCw className="w-4 h-4 animate-spin" /> onClick={handleSearch}
) : ( disabled={isSearching || !searchQuery.trim() || !status?.available}
<Search className="w-4 h-4" /> className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2 text-sm"
)} >
{isSearching ? (
</button> <RefreshCw className="w-4 h-4 animate-spin" />
</div> ) : (
<Search className="w-4 h-4" />
)}
</button>
</div> </div>
)} </div>
{/* Search Results */} {/* Search Results */}
{searchResults.length > 0 && ( {searchResults.length > 0 && (
@@ -385,59 +406,64 @@ export function VikingPanel() {
)} )}
{/* Summary Generation */} {/* Summary Generation */}
{status?.available && ( <div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 shadow-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 shadow-sm"> <h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3"></h3>
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3"></h3> <p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3"> LLM L0/L1
LLM L0/L1 </p>
{!status?.available && (
<p className="text-xs text-amber-600 dark:text-amber-400 mb-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
</p> </p>
<div className="space-y-2"> )}
<input <div className="space-y-2">
type="text" <input
value={summaryUri} type="text"
onChange={(e) => setSummaryUri(e.target.value)} value={summaryUri}
placeholder="资源 URI (如: notes/project-plan)" onChange={(e) => setSummaryUri(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent" placeholder="资源 URI (如: notes/project-plan)"
/> disabled={!status?.available}
<textarea className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed"
value={summaryContent} />
onChange={(e) => setSummaryContent(e.target.value)} <textarea
placeholder="资源内容..." value={summaryContent}
rows={3} onChange={(e) => setSummaryContent(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none" placeholder="资源内容..."
/> rows={3}
<button disabled={!status?.available}
onClick={async () => { className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none disabled:opacity-50 disabled:cursor-not-allowed"
if (!summaryUri.trim() || !summaryContent.trim()) return; />
setIsGeneratingSummary(true); <button
setMessage(null); onClick={async () => {
try { if (!summaryUri.trim() || !summaryContent.trim()) return;
await storeWithSummaries(summaryUri, summaryContent); setIsGeneratingSummary(true);
setMessage({ type: 'success', text: `摘要生成完成: ${summaryUri}` }); setMessage(null);
setSummaryUri(''); try {
setSummaryContent(''); await storeWithSummaries(summaryUri, summaryContent);
} catch (error) { setMessage({ type: 'success', text: `摘要生成完成: ${summaryUri}` });
setMessage({ setSummaryUri('');
type: 'error', setSummaryContent('');
text: `摘要生成失败: ${error instanceof Error ? error.message : '未知错误'}`, } catch (error) {
}); setMessage({
} finally { type: 'error',
setIsGeneratingSummary(false); text: `摘要生成失败: ${error instanceof Error ? error.message : '未知错误'}`,
} });
}} } finally {
disabled={isGeneratingSummary || !summaryUri.trim() || !summaryContent.trim()} setIsGeneratingSummary(false);
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2 text-sm" }
> }}
{isGeneratingSummary ? ( disabled={isGeneratingSummary || !summaryUri.trim() || !summaryContent.trim() || !status?.available}
<RefreshCw className="w-4 h-4 animate-spin" /> className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2 text-sm"
) : ( >
<Sparkles className="w-4 h-4" /> {isGeneratingSummary ? (
)} <RefreshCw className="w-4 h-4 animate-spin" />
) : (
</button> <Sparkles className="w-4 h-4" />
</div> )}
</button>
</div> </div>
)} </div>
{/* Info Section */} {/* Info Section */}
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700"> <div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">

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