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
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) 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 * * * 确认正确显示
This commit is contained in:
@@ -20,6 +20,7 @@ mod researcher;
|
|||||||
mod collector;
|
mod collector;
|
||||||
mod clip;
|
mod clip;
|
||||||
mod twitter;
|
mod twitter;
|
||||||
|
pub mod reminder;
|
||||||
|
|
||||||
pub use whiteboard::*;
|
pub use whiteboard::*;
|
||||||
pub use slideshow::*;
|
pub use slideshow::*;
|
||||||
@@ -30,3 +31,4 @@ pub use researcher::*;
|
|||||||
pub use collector::*;
|
pub use collector::*;
|
||||||
pub use clip::*;
|
pub use clip::*;
|
||||||
pub use twitter::*;
|
pub use twitter::*;
|
||||||
|
pub use reminder::*;
|
||||||
|
|||||||
77
crates/zclaw-hands/src/hands/reminder.rs
Normal file
77
crates/zclaw-hands/src/hands/reminder.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, SlideshowHand, SpeechHand, QuizHand, WhiteboardHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, ReminderHand, quiz::LlmQuizGenerator}};
|
||||||
|
|
||||||
pub use adapters::KernelSkillExecutor;
|
pub use adapters::KernelSkillExecutor;
|
||||||
pub use messaging::ChatModeConfig;
|
pub use messaging::ChatModeConfig;
|
||||||
@@ -101,6 +101,7 @@ impl Kernel {
|
|||||||
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()));
|
||||||
|
|||||||
@@ -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)
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -217,8 +217,90 @@ 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 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
|
// 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_lock = state.lock().await;
|
||||||
let kernel = kernel_lock.as_ref()
|
let kernel = kernel_lock.as_ref()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ tags: [module, hands, skills, mcp]
|
|||||||
| Slideshow | 幻灯片生成 | — | 13 | `hands/slideshow.HAND.toml` |
|
| Slideshow | 幻灯片生成 | — | 13 | `hands/slideshow.HAND.toml` |
|
||||||
| Speech | 语音合成 | Browser TTS | — | `hands/speech.HAND.toml` |
|
| Speech | 语音合成 | Browser TTS | — | `hands/speech.HAND.toml` |
|
||||||
| Quiz | 测验生成 | — | — | `hands/quiz.HAND.toml` |
|
| Quiz | 测验生成 | — | — | `hands/quiz.HAND.toml` |
|
||||||
|
| _reminder | 定时提醒 (系统内部) | — | — | 无 TOML(代码注册) |
|
||||||
|
|
||||||
Hands 测试分布(前 5): Clip(30), Twitter(25), Researcher(22), Slideshow(13), Browser(8)
|
Hands 测试分布(前 5): Clip(30), Twitter(25), Researcher(22), Slideshow(13), Browser(8)
|
||||||
|
|
||||||
@@ -56,6 +57,32 @@ UI 触发 → handStore.trigger(handName, params)
|
|||||||
→ handStore 更新状态 + 记录日志
|
→ 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 相关 Tauri 命令 (8 个):
|
||||||
`hand_list, hand_execute, hand_approve, hand_cancel, hand_get, hand_run_status, hand_run_list, hand_run_cancel`
|
`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/semantic_router.rs` | TF-IDF 语义路由 |
|
||||||
| `crates/zclaw-skills/src/` | 技能解析和索引 |
|
| `crates/zclaw-skills/src/` | 技能解析和索引 |
|
||||||
| `skills/*/SKILL.md` | 75 个技能定义 |
|
| `skills/*/SKILL.md` | 75 个技能定义 |
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ tags: [log, history]
|
|||||||
|
|
||||||
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
|
> 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项标注 + 文档同步
|
## 2026-04-15 fix | 发布前冲刺 Day1 — 5项修复 + 2项标注 + 文档同步
|
||||||
|
|
||||||
- **fix(saas)**: SSE 用量统计一致性 — 回写 usage_records 真实 token + 消除 relay_requests 双重计数
|
- **fix(saas)**: SSE 用量统计一致性 — 回写 usage_records 真实 token + 消除 relay_requests 双重计数
|
||||||
|
|||||||
Reference in New Issue
Block a user