From 28c892fd3109ab0d62ee8ae58671b0a72c56271f Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 15 Apr 2026 09:45:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E8=81=8A=E5=A4=A9=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E5=8A=9F=E8=83=BD=E6=96=AD=E9=93=BE=E6=8E=A5=E9=80=9A?= =?UTF-8?q?=20=E2=80=94=20NlScheduleParser=20+=20=5Freminder=20Hand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接通"写了没接"的定时功能断链: - 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 * * * 确认正确显示 --- crates/zclaw-hands/src/hands/mod.rs | 2 + crates/zclaw-hands/src/hands/reminder.rs | 77 +++++++++++++++++ crates/zclaw-kernel/src/kernel/mod.rs | 3 +- crates/zclaw-kernel/src/trigger_manager.rs | 6 +- desktop/src-tauri/src/kernel_commands/chat.rs | 84 ++++++++++++++++++- wiki/hands-skills.md | 30 ++++++- wiki/log.md | 9 ++ 7 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 crates/zclaw-hands/src/hands/reminder.rs diff --git a/crates/zclaw-hands/src/hands/mod.rs b/crates/zclaw-hands/src/hands/mod.rs index 5c83b09..686c1b5 100644 --- a/crates/zclaw-hands/src/hands/mod.rs +++ b/crates/zclaw-hands/src/hands/mod.rs @@ -20,6 +20,7 @@ mod researcher; mod collector; mod clip; mod twitter; +pub mod reminder; pub use whiteboard::*; pub use slideshow::*; @@ -30,3 +31,4 @@ pub use researcher::*; pub use collector::*; pub use clip::*; pub use twitter::*; +pub use reminder::*; diff --git a/crates/zclaw-hands/src/hands/reminder.rs b/crates/zclaw-hands/src/hands/reminder.rs new file mode 100644 index 0000000..98db298 --- /dev/null +++ b/crates/zclaw-hands/src/hands/reminder.rs @@ -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 { + 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 + } +} diff --git a/crates/zclaw-kernel/src/kernel/mod.rs b/crates/zclaw-kernel/src/kernel/mod.rs index 8768b78..6e5477f 100644 --- a/crates/zclaw-kernel/src/kernel/mod.rs +++ b/crates/zclaw-kernel/src/kernel/mod.rs @@ -27,7 +27,7 @@ use crate::config::KernelConfig; use zclaw_memory::MemoryStore; use zclaw_runtime::{LlmDriver, ToolRegistry, tool::SkillExecutor}; use zclaw_skills::SkillRegistry; -use zclaw_hands::{HandRegistry, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, quiz::LlmQuizGenerator}}; +use zclaw_hands::{HandRegistry, hands::{BrowserHand, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, ReminderHand, quiz::LlmQuizGenerator}}; pub use adapters::KernelSkillExecutor; pub use messaging::ChatModeConfig; @@ -101,6 +101,7 @@ impl Kernel { hands.register(Arc::new(CollectorHand::new())).await; hands.register(Arc::new(ClipHand::new())).await; hands.register(Arc::new(TwitterHand::new())).await; + hands.register(Arc::new(ReminderHand::new())).await; // Create skill executor let skill_executor = Arc::new(KernelSkillExecutor::new(skills.clone(), driver.clone())); diff --git a/crates/zclaw-kernel/src/trigger_manager.rs b/crates/zclaw-kernel/src/trigger_manager.rs index 9d0ecf8..8f926fb 100644 --- a/crates/zclaw-kernel/src/trigger_manager.rs +++ b/crates/zclaw-kernel/src/trigger_manager.rs @@ -134,7 +134,9 @@ impl TriggerManager { /// Create a new trigger pub async fn create_trigger(&self, config: TriggerConfig) -> Result { // Validate hand exists (outside of our lock to avoid holding two locks) - if self.hand_registry.get(&config.hand_id).await.is_none() { + // System hands (prefixed with '_') are exempt from validation — they are + // registered at boot but may not appear in the hand registry scan path. + if !config.hand_id.starts_with('_') && self.hand_registry.get(&config.hand_id).await.is_none() { return Err(zclaw_types::ZclawError::InvalidInput( format!("Hand '{}' not found", config.hand_id) )); @@ -170,7 +172,7 @@ impl TriggerManager { ) -> Result { // Validate hand exists if being updated (outside of our lock) if let Some(hand_id) = &updates.hand_id { - if self.hand_registry.get(hand_id).await.is_none() { + if !hand_id.starts_with('_') && self.hand_registry.get(hand_id).await.is_none() { return Err(zclaw_types::ZclawError::InvalidInput( format!("Hand '{}' not found", hand_id) )); diff --git a/desktop/src-tauri/src/kernel_commands/chat.rs b/desktop/src-tauri/src/kernel_commands/chat.rs index aadb8d1..09c60dc 100644 --- a/desktop/src-tauri/src/kernel_commands/chat.rs +++ b/desktop/src-tauri/src/kernel_commands/chat.rs @@ -217,8 +217,90 @@ pub async fn agent_chat_stream( &identity_state, ).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 schedule_intercepted = false; + + 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() { + let trigger_id = format!("sched_{}", chrono::Utc::now().timestamp_millis()); + let trigger_config = zclaw_hands::TriggerConfig { + id: trigger_id.clone(), + name: parsed.task_description.clone(), + hand_id: "_reminder".to_string(), + trigger_type: zclaw_hands::TriggerType::Schedule { + cron: parsed.cron_expression.clone(), + }, + enabled: true, + max_executions_per_hour: 60, + }; + + match kernel.create_trigger(trigger_config).await { + Ok(_entry) => { + tracing::info!( + "[agent_chat_stream] Schedule trigger created: {} (cron: {})", + trigger_id, parsed.cron_expression + ); + schedule_intercepted = true; + } + Err(e) => { + tracing::warn!( + "[agent_chat_stream] Failed to create schedule trigger: {}", + 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 - let (mut rx, llm_driver) = { + let (mut rx, llm_driver) = if schedule_intercepted { + // Schedule was intercepted — build confirmation message directly + let confirm_msg = { + let parsed = match zclaw_runtime::nl_schedule::parse_nl_schedule(&message, &id) { + zclaw_runtime::nl_schedule::ScheduleParseResult::Exact(p) => p, + _ => unreachable!("schedule_intercepted is only true for Exact results"), + }; + 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 = kernel_lock.as_ref() .ok_or_else(|| { diff --git a/wiki/hands-skills.md b/wiki/hands-skills.md index 47ef7d1..f594ad5 100644 --- a/wiki/hands-skills.md +++ b/wiki/hands-skills.md @@ -35,6 +35,7 @@ tags: [module, hands, skills, mcp] | Slideshow | 幻灯片生成 | — | 13 | `hands/slideshow.HAND.toml` | | Speech | 语音合成 | Browser TTS | — | `hands/speech.HAND.toml` | | Quiz | 测验生成 | — | — | `hands/quiz.HAND.toml` | +| _reminder | 定时提醒 (系统内部) | — | — | 无 TOML(代码注册) | Hands 测试分布(前 5): Clip(30), Twitter(25), Researcher(22), Slideshow(13), Browser(8) @@ -56,6 +57,32 @@ UI 触发 → handStore.trigger(handName, params) → handStore 更新状态 + 记录日志 ``` +### 定时提醒链路(NlScheduleParser → _reminder Hand) + +用户在聊天中输入包含定时意图的消息(如"每天早上9点提醒我查房"),系统自动拦截并创建定时触发器: + +``` +用户消息 "每天早上9点提醒我查房" + → agent_chat_stream (chat.rs) + → has_schedule_intent() 检测关键词(提醒我/定时/每天/每周等) + → parse_nl_schedule() 解析为 cron 表达式 + → ScheduleParseResult::Exact (confidence >= 0.8) + → TriggerConfig { hand_id: "_reminder", trigger_type: Schedule { cron: "0 9 * * *" } } + → kernel.create_trigger() → TriggerManager 存储 + → LoopEvent::Delta(确认消息) → 前端流式显示 + → 跳过 LLM 调用(省 token) + → SchedulerService 每60秒轮询 + → should_fire_cron() 匹配 → execute_hand_with_source("_reminder") + → ReminderHand.execute() → 记录日志 +``` + +关键组件: +- `crates/zclaw-runtime/src/nl_schedule.rs` — 中文时间→cron 转换(支持6种模式) +- `crates/zclaw-hands/src/hands/reminder.rs` — 系统内部 Hand(id=`_reminder`) +- `crates/zclaw-kernel/src/trigger_manager.rs` — 触发器 CRUD(`_` 前缀 hand_id 免验证) +- `crates/zclaw-kernel/src/scheduler.rs` — 60秒轮询 + cron 匹配 +- `desktop/src-tauri/src/kernel_commands/chat.rs` — 定时意图拦截入口 + Hand 相关 Tauri 命令 (8 个): `hand_list, hand_execute, hand_approve, hand_cancel, hand_get, hand_run_status, hand_run_list, hand_run_cancel` @@ -167,7 +194,8 @@ MCP 工具在 ToolRegistry 中使用限定名 `service_name.tool_name` 避免冲 | 文件 | 职责 | |------|------| -| `crates/zclaw-hands/src/hands/` | 9 个 Hand 实现 | +| `crates/zclaw-hands/src/hands/` | 10 个 Hand 实现 (含 _reminder) | +| `crates/zclaw-runtime/src/nl_schedule.rs` | 中文时间→cron 解析器 | | `crates/zclaw-skills/src/semantic_router.rs` | TF-IDF 语义路由 | | `crates/zclaw-skills/src/` | 技能解析和索引 | | `skills/*/SKILL.md` | 75 个技能定义 | diff --git a/wiki/log.md b/wiki/log.md index ac68072..2346bce 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -9,6 +9,15 @@ tags: [log, history] > Append-only 操作记录。格式: `## [日期] 类型 | 描述` +## 2026-04-15 fix | 聊天定时功能断链接通 — NlScheduleParser + _reminder Hand + +- **fix(runtime)**: NlScheduleParser 接入 chat.rs — has_schedule_intent() 意图检测 + parse_nl_schedule() cron 解析 +- **fix(hands)**: 新增 _reminder 系统内部 Hand — 定时触发器桥接 +- **fix(kernel)**: TriggerManager hand_id 验证放宽 — `_` 前缀系统 Hand 免验证 +- **fix(desktop)**: agent_chat_stream 定时拦截 — 确认消息通过 LoopEvent::Delta 流式返回 +- **docs(wiki)**: hands-skills.md 新增定时提醒链路说明 +- 验证: cargo check 0 error, 49 tests passed, Tauri MCP 实操验证 "每天早上9点提醒我查房" → cron `0 9 * * *` 确认消息正确显示 + ## 2026-04-15 fix | 发布前冲刺 Day1 — 5项修复 + 2项标注 + 文档同步 - **fix(saas)**: SSE 用量统计一致性 — 回写 usage_records 真实 token + 消除 relay_requests 双重计数