diff --git a/Cargo.toml b/Cargo.toml index 7f491fc..9746469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,9 @@ libsqlite3-sys = { version = "0.27", features = ["bundled"] } # HTTP client (for LLM drivers) reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } +# Synchronous HTTP (for WASM host functions in blocking threads) +ureq = { version = "3", features = ["rustls"] } + # URL parsing url = "2" diff --git a/crates/zclaw-hands/src/hands/mod.rs b/crates/zclaw-hands/src/hands/mod.rs index 686c1b5..c3f1261 100644 --- a/crates/zclaw-hands/src/hands/mod.rs +++ b/crates/zclaw-hands/src/hands/mod.rs @@ -1,9 +1,6 @@ //! Educational Hands - Teaching and presentation capabilities //! -//! This module provides hands for interactive classroom experiences: -//! - Whiteboard: Drawing and annotation -//! - Slideshow: Presentation control -//! - Speech: Text-to-speech synthesis +//! This module provides hands for interactive experiences: //! - Quiz: Assessment and evaluation //! - Browser: Web automation //! - Researcher: Deep research and analysis @@ -11,9 +8,6 @@ //! - Clip: Video processing //! - Twitter: Social media automation -mod whiteboard; -mod slideshow; -mod speech; pub mod quiz; mod browser; mod researcher; @@ -22,9 +16,6 @@ mod clip; mod twitter; pub mod reminder; -pub use whiteboard::*; -pub use slideshow::*; -pub use speech::*; pub use quiz::*; pub use browser::*; pub use researcher::*; diff --git a/crates/zclaw-hands/src/hands/slideshow.rs b/crates/zclaw-hands/src/hands/slideshow.rs deleted file mode 100644 index 652e788..0000000 --- a/crates/zclaw-hands/src/hands/slideshow.rs +++ /dev/null @@ -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, - #[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, - #[serde(default)] - pub content: Vec, - #[serde(default)] - pub notes: Option, - #[serde(default)] - pub background: Option, -} - -/// 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 }, - Image { url: String, alt: Option }, - List { items: Vec, ordered: bool }, - Code { code: String, language: Option }, - Math { latex: String }, - Table { headers: Vec, rows: Vec> }, - 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, - #[serde(default)] - pub color: Option, -} - -/// 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, -} - -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>, -} - -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) -> 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 { - 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 { - 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::(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); - } -} diff --git a/crates/zclaw-hands/src/hands/speech.rs b/crates/zclaw-hands/src/hands/speech.rs deleted file mode 100644 index ee8d64c..0000000 --- a/crates/zclaw-hands/src/hands/speech.rs +++ /dev/null @@ -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, - #[serde(default = "default_rate")] - rate: f32, - #[serde(default = "default_pitch")] - pitch: f32, - #[serde(default = "default_volume")] - volume: f32, - #[serde(default)] - language: Option, - }, - /// Speak with SSML markup - SpeakSsml { - ssml: String, - #[serde(default)] - voice: Option, - }, - /// Pause playback - Pause, - /// Resume playback - Resume, - /// Stop playback - Stop, - /// List available voices - ListVoices { - #[serde(default)] - language: Option, - }, - /// Set default voice - SetVoice { - voice: String, - #[serde(default)] - language: Option, - }, - /// Set provider - SetProvider { - provider: TtsProvider, - #[serde(default)] - api_key: Option, - #[serde(default)] - region: Option, - }, -} - -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, -} - -/// 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, - 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, - pub position_ms: u64, - pub available_voices: Vec, -} - -/// Speech Hand implementation -pub struct SpeechHand { - config: HandConfig, - state: Arc>, -} - -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 { - 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 { - 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 { - 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())); - } -} diff --git a/crates/zclaw-hands/src/hands/whiteboard.rs b/crates/zclaw-hands/src/hands/whiteboard.rs deleted file mode 100644 index d344f19..0000000 --- a/crates/zclaw-hands/src/hands/whiteboard.rs +++ /dev/null @@ -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, - #[serde(default)] - font_family: Option, - }, - /// Draw a shape - DrawShape { - shape: ShapeType, - x: f64, - y: f64, - width: f64, - height: f64, - #[serde(default)] - fill: Option, - #[serde(default)] - stroke: Option, - #[serde(default = "default_stroke_width")] - stroke_width: u32, - }, - /// Draw a line - DrawLine { - points: Vec, - #[serde(default)] - color: Option, - #[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, - }, - /// Draw LaTeX formula - DrawLatex { - latex: String, - x: f64, - y: f64, - #[serde(default = "default_font_size")] - font_size: u32, - #[serde(default)] - color: Option, - }, - /// Draw a table - DrawTable { - headers: Vec, - rows: Vec>, - x: f64, - y: f64, - #[serde(default)] - column_widths: Option>, - }, - /// 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, - pub datasets: Vec, -} - -/// Dataset for charts -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Dataset { - pub label: String, - pub values: Vec, - #[serde(default)] - pub color: Option, -} - -/// Whiteboard state (for undo/redo) -#[derive(Debug, Clone, Default)] -pub struct WhiteboardState { - pub actions: Vec, - pub undone: Vec, - pub canvas_width: f64, - pub canvas_height: f64, -} - -/// Whiteboard Hand implementation -pub struct WhiteboardHand { - config: HandConfig, - state: std::sync::Arc>, -} - -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 { - 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,", 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 { - 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 { - // 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); - } -} diff --git a/crates/zclaw-kernel/Cargo.toml b/crates/zclaw-kernel/Cargo.toml index 8b273b1..e8fa997 100644 --- a/crates/zclaw-kernel/Cargo.toml +++ b/crates/zclaw-kernel/Cargo.toml @@ -8,7 +8,7 @@ rust-version.workspace = true description = "ZCLAW kernel - central coordinator for all subsystems" [features] -default = [] +default = ["multi-agent"] # Enable multi-agent orchestration (Director, A2A protocol) multi-agent = ["zclaw-protocols/a2a"] diff --git a/crates/zclaw-kernel/src/kernel/a2a.rs b/crates/zclaw-kernel/src/kernel/a2a.rs index c35659e..8679432 100644 --- a/crates/zclaw-kernel/src/kernel/a2a.rs +++ b/crates/zclaw-kernel/src/kernel/a2a.rs @@ -1,16 +1,10 @@ //! A2A (Agent-to-Agent) messaging -//! -//! All items in this module are gated by the `multi-agent` feature flag. -#[cfg(feature = "multi-agent")] use zclaw_types::{AgentId, Capability, Event, Result}; -#[cfg(feature = "multi-agent")] use zclaw_protocols::{A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient}; -#[cfg(feature = "multi-agent")] use super::Kernel; -#[cfg(feature = "multi-agent")] impl Kernel { // ============================================================ // A2A (Agent-to-Agent) Messaging diff --git a/crates/zclaw-kernel/src/kernel/adapters.rs b/crates/zclaw-kernel/src/kernel/adapters.rs index f761514..9686718 100644 --- a/crates/zclaw-kernel/src/kernel/adapters.rs +++ b/crates/zclaw-kernel/src/kernel/adapters.rs @@ -106,13 +106,11 @@ impl SkillExecutor for KernelSkillExecutor { /// Inbox wrapper for A2A message receivers that supports re-queuing /// non-matching messages instead of dropping them. -#[cfg(feature = "multi-agent")] pub(crate) struct AgentInbox { pub(crate) rx: tokio::sync::mpsc::Receiver, pub(crate) pending: std::collections::VecDeque, } -#[cfg(feature = "multi-agent")] impl AgentInbox { pub(crate) fn new(rx: tokio::sync::mpsc::Receiver) -> Self { Self { rx, pending: std::collections::VecDeque::new() } diff --git a/crates/zclaw-kernel/src/kernel/agents.rs b/crates/zclaw-kernel/src/kernel/agents.rs index 7fcb859..bfb1e16 100644 --- a/crates/zclaw-kernel/src/kernel/agents.rs +++ b/crates/zclaw-kernel/src/kernel/agents.rs @@ -2,11 +2,8 @@ use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result}; -#[cfg(feature = "multi-agent")] use std::sync::Arc; -#[cfg(feature = "multi-agent")] use tokio::sync::Mutex; -#[cfg(feature = "multi-agent")] use super::adapters::AgentInbox; use super::Kernel; @@ -23,7 +20,6 @@ impl Kernel { self.memory.save_agent(&config).await?; // Register with A2A router for multi-agent messaging (before config is moved) - #[cfg(feature = "multi-agent")] { let profile = Self::agent_config_to_a2a_profile(&config); let rx = self.a2a_router.register_agent(profile).await; @@ -52,7 +48,6 @@ impl Kernel { self.memory.delete_agent(id).await?; // Unregister from A2A router - #[cfg(feature = "multi-agent")] { self.a2a_router.unregister_agent(id).await; self.a2a_inboxes.remove(id); diff --git a/crates/zclaw-kernel/src/kernel/messaging.rs b/crates/zclaw-kernel/src/kernel/messaging.rs index 8617929..8c5dce9 100644 --- a/crates/zclaw-kernel/src/kernel/messaging.rs +++ b/crates/zclaw-kernel/src/kernel/messaging.rs @@ -83,10 +83,8 @@ impl Kernel { loop_runner = loop_runner.with_path_validator(path_validator); } - // Inject middleware chain if available - if let Some(chain) = self.create_middleware_chain() { - loop_runner = loop_runner.with_middleware_chain(chain); - } + // Inject middleware chain + loop_runner = loop_runner.with_middleware_chain(self.create_middleware_chain()); // Apply chat mode configuration (thinking/reasoning/plan mode) if let Some(ref mode) = chat_mode { @@ -198,10 +196,8 @@ impl Kernel { loop_runner = loop_runner.with_path_validator(path_validator); } - // Inject middleware chain if available - if let Some(chain) = self.create_middleware_chain() { - loop_runner = loop_runner.with_middleware_chain(chain); - } + // Inject middleware chain + loop_runner = loop_runner.with_middleware_chain(self.create_middleware_chain()); // Apply chat mode configuration (thinking/reasoning/plan mode from frontend) if let Some(ref mode) = chat_mode { diff --git a/crates/zclaw-kernel/src/kernel/mod.rs b/crates/zclaw-kernel/src/kernel/mod.rs index 6e5477f..488f188 100644 --- a/crates/zclaw-kernel/src/kernel/mod.rs +++ b/crates/zclaw-kernel/src/kernel/mod.rs @@ -8,16 +8,13 @@ mod hands; mod triggers; mod approvals; mod orchestration; -#[cfg(feature = "multi-agent")] mod a2a; use std::sync::Arc; use tokio::sync::{broadcast, Mutex}; use zclaw_types::{Event, Result, AgentState}; -#[cfg(feature = "multi-agent")] use zclaw_types::AgentId; -#[cfg(feature = "multi-agent")] use zclaw_protocols::A2aRouter; use crate::registry::AgentRegistry; @@ -27,7 +24,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, ReminderHand, quiz::LlmQuizGenerator}}; +use zclaw_hands::{HandRegistry, hands::{BrowserHand, QuizHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, ReminderHand, quiz::LlmQuizGenerator}}; pub use adapters::KernelSkillExecutor; pub use messaging::ChatModeConfig; @@ -56,11 +53,9 @@ pub struct Kernel { mcp_adapters: Arc>>, /// Dynamic industry keyword configs — shared with Tauri frontend, loaded from SaaS industry_keywords: Arc>>, - /// A2A router for inter-agent messaging (gated by multi-agent feature) - #[cfg(feature = "multi-agent")] + /// A2A router for inter-agent messaging a2a_router: Arc, /// Per-agent A2A inbox receivers (supports re-queuing non-matching messages) - #[cfg(feature = "multi-agent")] a2a_inboxes: Arc>>>, } @@ -93,10 +88,7 @@ impl Kernel { let quiz_model = config.model().to_string(); let quiz_generator = Arc::new(LlmQuizGenerator::new(driver.clone(), quiz_model)); hands.register(Arc::new(BrowserHand::new())).await; - hands.register(Arc::new(SlideshowHand::new())).await; - hands.register(Arc::new(SpeechHand::new())).await; hands.register(Arc::new(QuizHand::with_generator(quiz_generator))).await; - hands.register(Arc::new(WhiteboardHand::new())).await; hands.register(Arc::new(ResearcherHand::new())).await; hands.register(Arc::new(CollectorHand::new())).await; hands.register(Arc::new(ClipHand::new())).await; @@ -138,7 +130,6 @@ impl Kernel { } // Initialize A2A router for multi-agent support - #[cfg(feature = "multi-agent")] let a2a_router = { let kernel_agent_id = AgentId::new(); Arc::new(A2aRouter::new(kernel_agent_id)) @@ -162,9 +153,7 @@ impl Kernel { extraction_driver: None, mcp_adapters: Arc::new(std::sync::RwLock::new(Vec::new())), industry_keywords: Arc::new(tokio::sync::RwLock::new(Vec::new())), - #[cfg(feature = "multi-agent")] a2a_router, - #[cfg(feature = "multi-agent")] a2a_inboxes: Arc::new(dashmap::DashMap::new()), }) } @@ -204,7 +193,7 @@ impl Kernel { /// When middleware is configured, cross-cutting concerns (compaction, loop guard, /// token calibration, etc.) are delegated to the chain. When no middleware is /// registered, the legacy inline path in `AgentLoop` is used instead. - pub(crate) fn create_middleware_chain(&self) -> Option { + pub(crate) fn create_middleware_chain(&self) -> zclaw_runtime::middleware::MiddlewareChain { let mut chain = zclaw_runtime::middleware::MiddlewareChain::new(); // Butler router — semantic skill routing context injection @@ -362,13 +351,11 @@ impl Kernel { chain.register(Arc::new(mw)); } - // Only return Some if we actually registered middleware - if chain.is_empty() { - None - } else { + // Always return the chain (empty chain is a no-op) + if !chain.is_empty() { tracing::info!("[Kernel] Middleware chain created with {} middlewares", chain.len()); - Some(chain) } + chain } /// Subscribe to events diff --git a/crates/zclaw-kernel/src/lib.rs b/crates/zclaw-kernel/src/lib.rs index b5c69d3..5cb5e26 100644 --- a/crates/zclaw-kernel/src/lib.rs +++ b/crates/zclaw-kernel/src/lib.rs @@ -10,7 +10,6 @@ pub mod trigger_manager; pub mod config; pub mod scheduler; pub mod skill_router; -#[cfg(feature = "multi-agent")] pub mod director; pub mod generation; pub mod export; @@ -21,13 +20,11 @@ pub use capabilities::*; pub use events::*; pub use config::*; pub use trigger_manager::{TriggerManager, TriggerEntry, TriggerUpdateRequest, TriggerManagerConfig}; -#[cfg(feature = "multi-agent")] pub use director::{ Director, DirectorConfig, DirectorBuilder, DirectorAgent, ConversationState, ScheduleStrategy, // Note: AgentRole is intentionally NOT re-exported here — use generation::AgentRole instead }; -#[cfg(feature = "multi-agent")] pub use zclaw_protocols::{ A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient, A2aReceiver, diff --git a/crates/zclaw-pipeline/Cargo.toml b/crates/zclaw-pipeline/Cargo.toml index ee1ab25..d8a65b9 100644 --- a/crates/zclaw-pipeline/Cargo.toml +++ b/crates/zclaw-pipeline/Cargo.toml @@ -25,7 +25,6 @@ reqwest = { workspace = true } # Internal crates zclaw-types = { workspace = true } zclaw-runtime = { workspace = true } -zclaw-kernel = { workspace = true } zclaw-skills = { workspace = true } zclaw-hands = { workspace = true } diff --git a/crates/zclaw-runtime/src/loop_runner.rs b/crates/zclaw-runtime/src/loop_runner.rs index 361a440..f614cc8 100644 --- a/crates/zclaw-runtime/src/loop_runner.rs +++ b/crates/zclaw-runtime/src/loop_runner.rs @@ -1,7 +1,6 @@ //! Agent loop implementation use std::sync::Arc; -use std::sync::Mutex; use futures::StreamExt; use tokio::sync::mpsc; use zclaw_types::{AgentId, SessionId, Message, Result}; @@ -10,7 +9,6 @@ use crate::driver::{LlmDriver, CompletionRequest, ContentBlock}; use crate::stream::StreamChunk; use crate::tool::{ToolRegistry, ToolContext, SkillExecutor}; use crate::tool::builtin::PathValidator; -use crate::loop_guard::{LoopGuard, LoopGuardResult}; use crate::growth::GrowthIntegration; use crate::compaction::{self, CompactionConfig}; use crate::middleware::{self, MiddlewareChain}; @@ -23,7 +21,6 @@ pub struct AgentLoop { driver: Arc, tools: ToolRegistry, memory: Arc, - loop_guard: Mutex, model: String, system_prompt: Option, /// Custom agent personality for prompt assembly @@ -38,10 +35,9 @@ pub struct AgentLoop { compaction_threshold: usize, /// Compaction behavior configuration compaction_config: CompactionConfig, - /// Optional middleware chain — when `Some`, cross-cutting logic is - /// delegated to the chain instead of the inline code below. - /// When `None`, the legacy inline path is used (100% backward compatible). - middleware_chain: Option, + /// Middleware chain — cross-cutting concerns are delegated to the chain. + /// An empty chain (Default) is a no-op: all `run_*` methods return Continue/Allow. + middleware_chain: MiddlewareChain, /// Chat mode: extended thinking enabled thinking_enabled: bool, /// Chat mode: reasoning effort level @@ -62,7 +58,6 @@ impl AgentLoop { driver, tools, memory, - loop_guard: Mutex::new(LoopGuard::default()), model: String::new(), // Must be set via with_model() system_prompt: None, soul: None, @@ -73,7 +68,7 @@ impl AgentLoop { growth: None, compaction_threshold: 0, compaction_config: CompactionConfig::default(), - middleware_chain: None, + middleware_chain: MiddlewareChain::default(), thinking_enabled: false, reasoning_effort: None, plan_mode: false, @@ -167,11 +162,10 @@ impl AgentLoop { self } - /// Inject a middleware chain. When set, cross-cutting concerns (compaction, - /// loop guard, token calibration, etc.) are delegated to the chain instead - /// of the inline logic. + /// Inject a middleware chain. Cross-cutting concerns (compaction, + /// loop guard, token calibration, etc.) are delegated to the chain. pub fn with_middleware_chain(mut self, chain: MiddlewareChain) -> Self { - self.middleware_chain = Some(chain); + self.middleware_chain = chain; self } @@ -227,49 +221,19 @@ impl AgentLoop { // Get all messages for context let mut messages = self.memory.get_messages(&session_id).await?; - let use_middleware = self.middleware_chain.is_some(); - - // Apply compaction — skip inline path when middleware chain handles it - if !use_middleware && self.compaction_threshold > 0 { - let needs_async = - self.compaction_config.use_llm || self.compaction_config.memory_flush_enabled; - if needs_async { - let outcome = compaction::maybe_compact_with_config( - 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() + // Enhance system prompt via PromptBuilder (middleware may further modify) + 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, }; + let mut enhanced_prompt = PromptBuilder::new().build(&prompt_ctx); // Run middleware before_completion hooks (compaction, memory inject, etc.) - if let Some(ref chain) = self.middleware_chain { + { let mut mw_ctx = middleware::MiddlewareContext { agent_id: self.agent_id.clone(), session_id: session_id.clone(), @@ -280,7 +244,7 @@ impl AgentLoop { input_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 => { messages = mw_ctx.messages; enhanced_prompt = mw_ctx.system_prompt; @@ -400,7 +364,6 @@ impl AgentLoop { // Create tool context and execute all tools let tool_context = self.create_tool_context(session_id.clone()); - let mut circuit_breaker_triggered = false; let mut abort_result: Option = None; let mut clarification_result: Option = None; for (id, name, input) in tool_calls { @@ -408,8 +371,8 @@ impl AgentLoop { if abort_result.is_some() { break; } - // Check tool call safety — via middleware chain or inline loop guard - if let Some(ref chain) = self.middleware_chain { + // Check tool call safety — via middleware chain + { let mw_ctx_ref = middleware::MiddlewareContext { agent_id: self.agent_id.clone(), session_id: session_id.clone(), @@ -420,7 +383,7 @@ impl AgentLoop { input_tokens: total_input_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::Block(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( @@ -537,21 +480,10 @@ impl AgentLoop { 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 - if let Some(ref chain) = self.middleware_chain { + // Post-completion processing — middleware chain + { let mw_ctx = middleware::MiddlewareContext { agent_id: self.agent_id.clone(), session_id: session_id.clone(), @@ -562,16 +494,9 @@ impl AgentLoop { input_tokens: total_input_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); } - } 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) @@ -593,49 +518,19 @@ impl AgentLoop { // Get all messages for context let mut messages = self.memory.get_messages(&session_id).await?; - let use_middleware = self.middleware_chain.is_some(); - - // Apply compaction — skip inline path when middleware chain handles it - if !use_middleware && self.compaction_threshold > 0 { - let needs_async = - self.compaction_config.use_llm || self.compaction_config.memory_flush_enabled; - if needs_async { - let outcome = compaction::maybe_compact_with_config( - 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() + // Enhance system prompt via PromptBuilder (middleware may further modify) + 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, }; + let mut enhanced_prompt = PromptBuilder::new().build(&prompt_ctx); // Run middleware before_completion hooks (compaction, memory inject, etc.) - if let Some(ref chain) = self.middleware_chain { + { let mut mw_ctx = middleware::MiddlewareContext { agent_id: self.agent_id.clone(), session_id: session_id.clone(), @@ -646,7 +541,7 @@ impl AgentLoop { input_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 => { messages = mw_ctx.messages; enhanced_prompt = mw_ctx.system_prompt; @@ -670,7 +565,6 @@ impl AgentLoop { let memory = self.memory.clone(); let driver = self.driver.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 skill_executor = self.skill_executor.clone(); let path_validator = self.path_validator.clone(); @@ -684,7 +578,6 @@ impl AgentLoop { tokio::spawn(async move { let mut messages = messages; - let loop_guard_clone = Mutex::new(loop_guard_clone); let max_iterations = 10; let mut iteration = 0; let mut total_input_tokens = 0u32; @@ -868,7 +761,7 @@ impl AgentLoop { } // Post-completion: middleware after_completion (memory extraction, etc.) - if let Some(ref chain) = middleware_chain { + { let mw_ctx = middleware::MiddlewareContext { agent_id: agent_id.clone(), session_id: session_id_clone.clone(), @@ -879,7 +772,7 @@ impl AgentLoop { input_tokens: total_input_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); } } @@ -911,8 +804,8 @@ impl AgentLoop { for (id, name, input) in pending_tool_calls { tracing::debug!("[AgentLoop] Executing tool: name={}, input={:?}", name, input); - // Check tool call safety — via middleware chain or inline loop guard - if let Some(ref chain) = middleware_chain { + // Check tool call safety — via middleware chain + { let mw_ctx = middleware::MiddlewareContext { agent_id: agent_id.clone(), session_id: session_id_clone.clone(), @@ -923,7 +816,7 @@ impl AgentLoop { input_tokens: total_input_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::Block(msg)) => { tracing::warn!("[AgentLoop] Tool '{}' blocked by middleware: {}", name, msg); @@ -995,30 +888,6 @@ impl AgentLoop { 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 => { - if let Err(e) = tx.send(LoopEvent::Error("检测到工具调用循环,已自动终止".to_string())).await { - tracing::warn!("[AgentLoop] Failed to send Error event: {}", e); - } - break 'outer; - } - LoopGuardResult::Blocked => { - tracing::warn!("[AgentLoop] Tool '{}' blocked by loop guard", name); - let error_output = serde_json::json!({ "error": "工具调用被循环防护拦截" }); - if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output.clone() }).await { - tracing::warn!("[AgentLoop] Failed to send ToolEnd event: {}", e); - } - messages.push(Message::tool_result(id, zclaw_types::ToolId::new(&name), error_output, true)); - continue; - } - 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) let pv = path_validator.clone().unwrap_or_else(|| { diff --git a/crates/zclaw-skills/Cargo.toml b/crates/zclaw-skills/Cargo.toml index 99a1e00..3fd2b6b 100644 --- a/crates/zclaw-skills/Cargo.toml +++ b/crates/zclaw-skills/Cargo.toml @@ -9,7 +9,7 @@ description = "ZCLAW skill system" [features] default = [] -wasm = ["wasmtime", "wasmtime-wasi/p1"] +wasm = ["wasmtime", "wasmtime-wasi/p1", "ureq"] [dependencies] zclaw-types = { workspace = true } @@ -27,3 +27,4 @@ shlex = { workspace = true } # Optional WASM runtime (enable with --features wasm) wasmtime = { workspace = true, optional = true } wasmtime-wasi = { workspace = true, optional = true } +ureq = { workspace = true, optional = true } diff --git a/crates/zclaw-skills/src/wasm_runner.rs b/crates/zclaw-skills/src/wasm_runner.rs index e48d9b3..a75f876 100644 --- a/crates/zclaw-skills/src/wasm_runner.rs +++ b/crates/zclaw-skills/src/wasm_runner.rs @@ -230,49 +230,100 @@ fn create_engine_config() -> Config { } /// Add ZCLAW host functions to the wasmtime linker. -fn add_host_functions(linker: &mut Linker, _network_allowed: bool) -> Result<()> { +fn add_host_functions(linker: &mut Linker, network_allowed: bool) -> Result<()> { linker .func_wrap( "env", "zclaw_log", - |_caller: Caller<'_, WasiP1Ctx>, _ptr: u32, _len: u32| { - debug!("[WasmSkill] guest called zclaw_log"); + |mut caller: Caller<'_, WasiP1Ctx>, ptr: u32, len: u32| { + let msg = read_guest_string(&mut caller, ptr, len); + debug!("[WasmSkill] guest log: {}", msg); }, ) .map_err(|e| { zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_log: {}", e)) })?; + // zclaw_http_fetch(url_ptr, url_len, out_ptr, out_cap) -> bytes_written (-1 = error) + // Performs a synchronous GET request. Result is written to guest memory as JSON string. + let net = network_allowed; linker .func_wrap( "env", "zclaw_http_fetch", - |_caller: Caller<'_, WasiP1Ctx>, - _url_ptr: u32, - _url_len: u32, - _out_ptr: u32, - _out_cap: u32| - -> i32 { - warn!("[WasmSkill] guest called zclaw_http_fetch — denied"); - -1 + move |mut caller: Caller<'_, WasiP1Ctx>, + url_ptr: u32, + url_len: u32, + out_ptr: u32, + out_cap: u32| + -> i32 { + if !net { + warn!("[WasmSkill] guest called zclaw_http_fetch — denied (network not allowed)"); + return -1; + } + + let url = read_guest_string(&mut caller, url_ptr, url_len); + if url.is_empty() { + return -1; + } + + debug!("[WasmSkill] guest http_fetch: {}", url); + + // Synchronous HTTP GET (we're already on a blocking thread) + let agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(10))) + .build() + .new_agent(); + let response = agent.get(&url).call(); + + match response { + Ok(mut resp) => { + let body = resp.body_mut().read_to_string().unwrap_or_default(); + write_guest_bytes(&mut caller, out_ptr, out_cap, body.as_bytes()) + } + Err(e) => { + warn!("[WasmSkill] http_fetch error for {}: {}", url, e); + -1 + } + } }, ) .map_err(|e| { zclaw_types::ZclawError::ToolError(format!("Failed to add zclaw_http_fetch: {}", e)) })?; + // zclaw_file_read(path_ptr, path_len, out_ptr, out_cap) -> bytes_written (-1 = error) + // Reads a file from the preopened /workspace directory. Paths must be relative. linker .func_wrap( "env", "zclaw_file_read", - |_caller: Caller<'_, WasiP1Ctx>, - _path_ptr: u32, - _path_len: u32, - _out_ptr: u32, - _out_cap: u32| + |mut caller: Caller<'_, WasiP1Ctx>, + path_ptr: u32, + path_len: u32, + out_ptr: u32, + out_cap: u32| -> i32 { - warn!("[WasmSkill] guest called zclaw_file_read — denied"); - -1 + let path = read_guest_string(&mut caller, path_ptr, path_len); + if path.is_empty() { + return -1; + } + + // Security: only allow reads under /workspace (preopen root) + if path.starts_with("..") || path.starts_with('/') { + warn!("[WasmSkill] guest file_read denied — path escapes sandbox: {}", path); + return -1; + } + + let full_path = format!("/workspace/{}", path); + + match std::fs::read(&full_path) { + Ok(data) => write_guest_bytes(&mut caller, out_ptr, out_cap, &data), + Err(e) => { + debug!("[WasmSkill] file_read error for {}: {}", path, e); + -1 + } + } }, ) .map_err(|e| { @@ -282,6 +333,38 @@ fn add_host_functions(linker: &mut Linker, _network_allowed: bool) -> Ok(()) } +/// Read a string from WASM guest memory. +fn read_guest_string(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, len: u32) -> String { + let mem = match caller.get_export("memory") { + Some(Extern::Memory(m)) => m, + _ => return String::new(), + }; + let offset = ptr as usize; + let length = len as usize; + let data = mem.data(&caller); + if offset + length > data.len() { + return String::new(); + } + String::from_utf8_lossy(&data[offset..offset + length]).into_owned() +} + +/// Write bytes to WASM guest memory. Returns the number of bytes written, or -1 on overflow. +fn write_guest_bytes(caller: &mut Caller<'_, WasiP1Ctx>, ptr: u32, cap: u32, data: &[u8]) -> i32 { + let mem = match caller.get_export("memory") { + Some(Extern::Memory(m)) => m, + _ => return -1, + }; + let offset = ptr as usize; + let capacity = cap as usize; + let write_len = data.len().min(capacity); + if offset + write_len > mem.data_size(&caller) { + return -1; + } + // Safety: we've bounds-checked the write region. + mem.data_mut(&mut *caller)[offset..offset + write_len].copy_from_slice(&data[..write_len]); + write_len as i32 +} + #[cfg(test)] mod tests { diff --git a/crates/zclaw-types/src/error.rs b/crates/zclaw-types/src/error.rs index 39379f1..32ab251 100644 --- a/crates/zclaw-types/src/error.rs +++ b/crates/zclaw-types/src/error.rs @@ -1,9 +1,95 @@ //! 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 serde::{Deserialize, Serialize}; + +// === Error Kind (structured classification) === + +/// 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"; +} -use thiserror::Error; +// === ZclawError === -/// ZCLAW unified error type -#[derive(Debug, Error)] +/// 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 { #[error("Not found: {0}")] NotFound(String), @@ -60,6 +146,80 @@ pub enum ZclawError { 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(&self, serializer: S) -> std::result::Result { + ErrorDetail::from(self).serialize(serializer) + } +} + /// Result type alias for ZCLAW operations pub type Result = std::result::Result; @@ -177,4 +337,63 @@ mod tests { assert!(result.is_err()); 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); + } } diff --git a/desktop/src-tauri/src/kernel_commands/a2a.rs b/desktop/src-tauri/src/kernel_commands/a2a.rs index 8532797..b2416e1 100644 --- a/desktop/src-tauri/src/kernel_commands/a2a.rs +++ b/desktop/src-tauri/src/kernel_commands/a2a.rs @@ -1,4 +1,4 @@ -//! A2A (Agent-to-Agent) commands — gated behind `multi-agent` feature +//! A2A (Agent-to-Agent) commands use serde_json; use tauri::State; @@ -7,10 +7,9 @@ use zclaw_types::AgentId; use super::KernelState; // ============================================================ -// A2A (Agent-to-Agent) Commands — gated behind multi-agent feature +// A2A (Agent-to-Agent) Commands // ============================================================ -#[cfg(feature = "multi-agent")] /// Send a direct A2A message from one agent to another // @connected #[tauri::command] @@ -44,7 +43,6 @@ pub async fn agent_a2a_send( } /// Broadcast a message from one agent to all other agents -#[cfg(feature = "multi-agent")] // @connected #[tauri::command] pub async fn agent_a2a_broadcast( @@ -66,7 +64,6 @@ pub async fn agent_a2a_broadcast( } /// Discover agents with a specific capability -#[cfg(feature = "multi-agent")] // @connected #[tauri::command] pub async fn agent_a2a_discover( @@ -88,7 +85,6 @@ pub async fn agent_a2a_discover( } /// Delegate a task to another agent and wait for response -#[cfg(feature = "multi-agent")] // @connected #[tauri::command] pub async fn agent_a2a_delegate_task( @@ -116,11 +112,10 @@ pub async fn agent_a2a_delegate_task( } // ============================================================ -// Butler Delegation Command — multi-agent feature +// Butler Delegation Command // ============================================================ /// Butler delegates a user request to expert agents via the Director. -#[cfg(feature = "multi-agent")] // @reserved: butler multi-agent delegation // @connected #[tauri::command] diff --git a/desktop/src-tauri/src/kernel_commands/mod.rs b/desktop/src-tauri/src/kernel_commands/mod.rs index 88535ea..6075abc 100644 --- a/desktop/src-tauri/src/kernel_commands/mod.rs +++ b/desktop/src-tauri/src/kernel_commands/mod.rs @@ -19,7 +19,6 @@ pub mod skill; pub mod trigger; pub mod workspace; -#[cfg(feature = "multi-agent")] pub mod a2a; // --------------------------------------------------------------------------- diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ea0361b..8f9837f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -255,16 +255,11 @@ pub fn run() { kernel_commands::scheduled_task::scheduled_task_create, kernel_commands::scheduled_task::scheduled_task_list, - // A2A commands gated behind multi-agent feature - #[cfg(feature = "multi-agent")] + // A2A commands kernel_commands::a2a::agent_a2a_send, - #[cfg(feature = "multi-agent")] kernel_commands::a2a::agent_a2a_broadcast, - #[cfg(feature = "multi-agent")] kernel_commands::a2a::agent_a2a_discover, - #[cfg(feature = "multi-agent")] kernel_commands::a2a::agent_a2a_delegate_task, - #[cfg(feature = "multi-agent")] kernel_commands::a2a::butler_delegate_task, // Pipeline commands (DSL-based workflows) diff --git a/hands/slideshow.HAND.toml b/hands/slideshow.HAND.toml deleted file mode 100644 index cc93ce4..0000000 --- a/hands/slideshow.HAND.toml +++ /dev/null @@ -1,119 +0,0 @@ -# Slideshow Hand - 幻灯片控制能力包 -# -# ZCLAW Hand 配置 -# 提供幻灯片演示控制能力,支持翻页、聚焦、激光笔等 - -[hand] -name = "slideshow" -version = "1.0.0" -description = "幻灯片控制能力包 - 控制演示文稿的播放、导航和标注" -author = "ZCLAW Team" - -type = "presentation" -requires_approval = false -timeout = 30 -max_concurrent = 1 - -tags = ["slideshow", "presentation", "slides", "education", "teaching"] - -[hand.config] -# 支持的幻灯片格式 -supported_formats = ["pptx", "pdf", "html", "markdown"] - -# 自动翻页间隔(秒),0 表示禁用 -auto_advance_interval = 0 - -# 是否显示进度条 -show_progress = true - -# 是否显示页码 -show_page_number = true - -# 激光笔颜色 -laser_color = "#ff0000" - -# 聚焦框颜色 -spotlight_color = "#ffcc00" - -[hand.triggers] -manual = true -schedule = false -webhook = false - -[[hand.triggers.events]] -type = "chat.intent" -pattern = "幻灯片|演示|翻页|下一页|上一页|slide|presentation|next|prev" -priority = 5 - -[hand.permissions] -requires = [ - "slideshow.navigate", - "slideshow.annotate", - "slideshow.control" -] - -roles = ["operator.read"] - -[hand.rate_limit] -max_requests = 200 -window_seconds = 3600 - -[hand.audit] -log_inputs = true -log_outputs = false -retention_days = 7 - -# 幻灯片动作定义 -[[hand.actions]] -id = "next_slide" -name = "下一页" -description = "切换到下一张幻灯片" -params = {} - -[[hand.actions]] -id = "prev_slide" -name = "上一页" -description = "切换到上一张幻灯片" -params = {} - -[[hand.actions]] -id = "goto_slide" -name = "跳转到指定页" -description = "跳转到指定编号的幻灯片" -params = { slide_number = "number" } - -[[hand.actions]] -id = "spotlight" -name = "聚焦元素" -description = "用高亮框聚焦指定元素" -params = { element_id = "string", duration = "number?" } - -[[hand.actions]] -id = "laser" -name = "激光笔" -description = "在幻灯片上显示激光笔指示" -params = { x = "number", y = "number", duration = "number?" } - -[[hand.actions]] -id = "highlight" -name = "高亮区域" -description = "高亮显示幻灯片上的区域" -params = { x = "number", y = "number", width = "number", height = "number", color = "string?" } - -[[hand.actions]] -id = "play_animation" -name = "播放动画" -description = "触发幻灯片上的动画效果" -params = { animation_id = "string" } - -[[hand.actions]] -id = "pause" -name = "暂停" -description = "暂停自动播放" -params = {} - -[[hand.actions]] -id = "resume" -name = "继续" -description = "继续自动播放" -params = {} diff --git a/hands/speech.HAND.toml b/hands/speech.HAND.toml deleted file mode 100644 index 2b9d5db..0000000 --- a/hands/speech.HAND.toml +++ /dev/null @@ -1,127 +0,0 @@ -# Speech Hand - 语音合成能力包 -# -# ZCLAW Hand 配置 -# 提供文本转语音 (TTS) 能力,支持多种语音和语言 - -[hand] -name = "speech" -version = "1.0.0" -description = "语音合成能力包 - 将文本转换为自然语音输出" -author = "ZCLAW Team" - -type = "media" -requires_approval = false -timeout = 120 -max_concurrent = 3 - -tags = ["speech", "tts", "voice", "audio", "education", "accessibility", "demo"] - -[hand.config] -# TTS 提供商: browser, azure, openai, elevenlabs, local -provider = "browser" - -# 默认语音 -default_voice = "default" - -# 默认语速 (0.5 - 2.0) -default_rate = 1.0 - -# 默认音调 (0.5 - 2.0) -default_pitch = 1.0 - -# 默认音量 (0 - 1.0) -default_volume = 1.0 - -# 语言代码 -default_language = "zh-CN" - -# 是否缓存音频 -cache_audio = true - -# Azure TTS 配置 (如果 provider = "azure") -[hand.config.azure] -# voice_name = "zh-CN-XiaoxiaoNeural" -# region = "eastasia" - -# OpenAI TTS 配置 (如果 provider = "openai") -[hand.config.openai] -# model = "tts-1" -# voice = "alloy" - -# 浏览器 TTS 配置 (如果 provider = "browser") -[hand.config.browser] -# 使用系统默认语音 -use_system_voice = true -# 语音名称映射 -voice_mapping = { "zh-CN" = "Microsoft Huihui", "en-US" = "Microsoft David" } - -[hand.triggers] -manual = true -schedule = false -webhook = false - -[[hand.triggers.events]] -type = "chat.intent" -pattern = "朗读|念|说|播放语音|speak|read|say|tts" -priority = 5 - -[hand.permissions] -requires = [ - "speech.synthesize", - "speech.play", - "speech.stop" -] - -roles = ["operator.read"] - -[hand.rate_limit] -max_requests = 100 -window_seconds = 3600 - -[hand.audit] -log_inputs = true -log_outputs = false # 音频不记录 -retention_days = 3 - -# 语音动作定义 -[[hand.actions]] -id = "speak" -name = "朗读文本" -description = "将文本转换为语音并播放" -params = { text = "string", voice = "string?", rate = "number?", pitch = "number?" } - -[[hand.actions]] -id = "speak_ssml" -name = "朗读 SSML" -description = "使用 SSML 标记朗读文本(支持更精细控制)" -params = { ssml = "string", voice = "string?" } - -[[hand.actions]] -id = "pause" -name = "暂停播放" -description = "暂停当前语音播放" -params = {} - -[[hand.actions]] -id = "resume" -name = "继续播放" -description = "继续暂停的语音播放" -params = {} - -[[hand.actions]] -id = "stop" -name = "停止播放" -description = "停止当前语音播放" -params = {} - -[[hand.actions]] -id = "list_voices" -name = "列出可用语音" -description = "获取可用的语音列表" -params = { language = "string?" } - -[[hand.actions]] -id = "set_voice" -name = "设置默认语音" -description = "更改默认语音设置" -params = { voice = "string", language = "string?" } diff --git a/hands/whiteboard.HAND.toml b/hands/whiteboard.HAND.toml deleted file mode 100644 index 1ca2685..0000000 --- a/hands/whiteboard.HAND.toml +++ /dev/null @@ -1,126 +0,0 @@ -# Whiteboard Hand - 白板绘制能力包 -# -# ZCLAW Hand 配置 -# 提供交互式白板绘制能力,支持文本、图形、公式、图表等 - -[hand] -name = "whiteboard" -version = "1.0.0" -description = "白板绘制能力包 - 绘制文本、图形、公式、图表等教学内容" -author = "ZCLAW Team" - -type = "presentation" -requires_approval = false -timeout = 60 -max_concurrent = 1 - -tags = ["whiteboard", "drawing", "presentation", "education", "teaching"] - -[hand.config] -# 画布尺寸 -canvas_width = 1920 -canvas_height = 1080 - -# 默认画笔颜色 -default_color = "#333333" - -# 默认线宽 -default_line_width = 2 - -# 支持的绘制动作 -supported_actions = [ - "draw_text", - "draw_shape", - "draw_line", - "draw_chart", - "draw_latex", - "draw_table", - "erase", - "clear", - "undo", - "redo" -] - -# 字体配置 -[hand.config.fonts] -text_font = "system-ui" -math_font = "KaTeX_Main" -code_font = "JetBrains Mono" - -[hand.triggers] -manual = true -schedule = false -webhook = false - -[[hand.triggers.events]] -type = "chat.intent" -pattern = "画|绘制|白板|展示|draw|whiteboard|sketch" -priority = 5 - -[hand.permissions] -requires = [ - "whiteboard.draw", - "whiteboard.clear", - "whiteboard.export" -] - -roles = ["operator.read"] - -[hand.rate_limit] -max_requests = 100 -window_seconds = 3600 - -[hand.audit] -log_inputs = true -log_outputs = false # 绘制内容不记录 -retention_days = 7 - -# 绘制动作定义 -[[hand.actions]] -id = "draw_text" -name = "绘制文本" -description = "在白板上绘制文本" -params = { x = "number", y = "number", text = "string", font_size = "number?", color = "string?" } - -[[hand.actions]] -id = "draw_shape" -name = "绘制图形" -description = "绘制矩形、圆形、箭头等基本图形" -params = { shape = "string", x = "number", y = "number", width = "number", height = "number", fill = "string?" } - -[[hand.actions]] -id = "draw_line" -name = "绘制线条" -description = "绘制直线或曲线" -params = { points = "array", color = "string?", line_width = "number?" } - -[[hand.actions]] -id = "draw_chart" -name = "绘制图表" -description = "绘制柱状图、折线图、饼图等" -params = { chart_type = "string", data = "object", x = "number", y = "number", width = "number", height = "number" } - -[[hand.actions]] -id = "draw_latex" -name = "绘制公式" -description = "渲染 LaTeX 数学公式" -params = { latex = "string", x = "number", y = "number", font_size = "number?" } - -[[hand.actions]] -id = "draw_table" -name = "绘制表格" -description = "绘制数据表格" -params = { headers = "array", rows = "array", x = "number", y = "number" } - -[[hand.actions]] -id = "clear" -name = "清空画布" -description = "清空白板所有内容" -params = {} - -[[hand.actions]] -id = "export" -name = "导出图片" -description = "将白板内容导出为图片(⚠️ 导出功能开发中,当前返回占位数据)" -demo = true -params = { format = "string?" }