Files
zclaw_openfang/tmp_audit_diff.txt
iven fa5ab4e161
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor(middleware): 移除数据脱敏中间件及相关代码
移除不再使用的数据脱敏功能,包括:
1. 删除data_masking模块
2. 清理loop_runner中的unmask逻辑
3. 移除前端saas-relay-client.ts中的mask/unmask实现
4. 更新中间件层数从15层降为14层
5. 同步更新相关文档(CLAUDE.md、TRUTH.md、wiki等)

此次变更简化了系统架构,移除了不再需要的敏感数据处理逻辑。所有相关测试证据和截图已归档。
2026-04-22 19:19:07 +08:00

3284 lines
113 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String>,
- #[serde(default = "default_highlight_duration")]
- duration_ms: u64,
- },
- /// Play animation
- PlayAnimation {
- animation_id: String,
- },
- /// Pause auto-play
- Pause,
- /// Resume auto-play
- Resume,
- /// Start auto-play
- AutoPlay {
- #[serde(default = "default_interval")]
- interval_ms: u64,
- },
- /// Stop auto-play
- StopAutoPlay,
- /// Get current state
- GetState,
- /// Set slide content (for dynamic slides)
- SetContent {
- slide_number: usize,
- content: SlideContent,
- },
-}
-
-fn default_spotlight_duration() -> u64 { 2000 }
-fn default_laser_duration() -> u64 { 3000 }
-fn default_highlight_duration() -> u64 { 2000 }
-fn default_interval() -> u64 { 5000 }
-
-/// Slide content structure
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct SlideContent {
- pub title: String,
- #[serde(default)]
- pub subtitle: Option<String>,
- #[serde(default)]
- pub content: Vec<ContentBlock>,
- #[serde(default)]
- pub notes: Option<String>,
- #[serde(default)]
- pub background: Option<String>,
-}
-
-/// Presentation/slideshow rendering content block. Domain-specific for slide content.
-/// Distinct from zclaw_types::ContentBlock (LLM messages) and zclaw_protocols::ContentBlock (MCP).
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-pub enum ContentBlock {
- Text { text: String, style: Option<TextStyle> },
- Image { url: String, alt: Option<String> },
- List { items: Vec<String>, ordered: bool },
- Code { code: String, language: Option<String> },
- Math { latex: String },
- Table { headers: Vec<String>, rows: Vec<Vec<String>> },
- Chart { chart_type: String, data: serde_json::Value },
-}
-
-/// Text style options
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub struct TextStyle {
- #[serde(default)]
- pub bold: bool,
- #[serde(default)]
- pub italic: bool,
- #[serde(default)]
- pub size: Option<u32>,
- #[serde(default)]
- pub color: Option<String>,
-}
-
-/// Slideshow state
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct SlideshowState {
- pub current_slide: usize,
- pub total_slides: usize,
- pub is_playing: bool,
- pub auto_play_interval_ms: u64,
- pub slides: Vec<SlideContent>,
-}
-
-impl Default for SlideshowState {
- fn default() -> Self {
- Self {
- current_slide: 0,
- total_slides: 0,
- is_playing: false,
- auto_play_interval_ms: 5000,
- slides: Vec::new(),
- }
- }
-}
-
-/// Slideshow Hand implementation
-pub struct SlideshowHand {
- config: HandConfig,
- state: Arc<RwLock<SlideshowState>>,
-}
-
-impl SlideshowHand {
- /// Create a new slideshow hand
- pub fn new() -> Self {
- Self {
- config: HandConfig {
- id: "slideshow".to_string(),
- name: "幻灯片".to_string(),
- description: "控制演示文稿的播放、导航和标注".to_string(),
- needs_approval: false,
- dependencies: vec![],
- input_schema: Some(serde_json::json!({
- "type": "object",
- "properties": {
- "action": { "type": "string" },
- "slide_number": { "type": "integer" },
- "element_id": { "type": "string" },
- }
- })),
- tags: vec!["presentation".to_string(), "education".to_string()],
- enabled: true,
- max_concurrent: 0,
- timeout_secs: 0,
- },
- state: Arc::new(RwLock::new(SlideshowState::default())),
- }
- }
-
- /// Create with slides (async version)
- pub async fn with_slides_async(slides: Vec<SlideContent>) -> Self {
- let hand = Self::new();
- let mut state = hand.state.write().await;
- state.total_slides = slides.len();
- state.slides = slides;
- drop(state);
- hand
- }
-
- /// Execute a slideshow action
- pub async fn execute_action(&self, action: SlideshowAction) -> Result<HandResult> {
- let mut state = self.state.write().await;
-
- match action {
- SlideshowAction::NextSlide => {
- if state.current_slide < state.total_slides.saturating_sub(1) {
- state.current_slide += 1;
- }
- Ok(HandResult::success(serde_json::json!({
- "status": "next",
- "current_slide": state.current_slide,
- "total_slides": state.total_slides,
- })))
- }
- SlideshowAction::PrevSlide => {
- if state.current_slide > 0 {
- state.current_slide -= 1;
- }
- Ok(HandResult::success(serde_json::json!({
- "status": "prev",
- "current_slide": state.current_slide,
- "total_slides": state.total_slides,
- })))
- }
- SlideshowAction::GotoSlide { slide_number } => {
- if slide_number < state.total_slides {
- state.current_slide = slide_number;
- Ok(HandResult::success(serde_json::json!({
- "status": "goto",
- "current_slide": state.current_slide,
- "slide_content": state.slides.get(slide_number),
- })))
- } else {
- Ok(HandResult::error(format!("Slide {} out of range", slide_number)))
- }
- }
- SlideshowAction::Spotlight { element_id, duration_ms } => {
- Ok(HandResult::success(serde_json::json!({
- "status": "spotlight",
- "element_id": element_id,
- "duration_ms": duration_ms,
- })))
- }
- SlideshowAction::Laser { x, y, duration_ms } => {
- Ok(HandResult::success(serde_json::json!({
- "status": "laser",
- "x": x,
- "y": y,
- "duration_ms": duration_ms,
- })))
- }
- SlideshowAction::Highlight { x, y, width, height, color, duration_ms } => {
- Ok(HandResult::success(serde_json::json!({
- "status": "highlight",
- "x": x, "y": y,
- "width": width, "height": height,
- "color": color.unwrap_or_else(|| "#ffcc00".to_string()),
- "duration_ms": duration_ms,
- })))
- }
- SlideshowAction::PlayAnimation { animation_id } => {
- Ok(HandResult::success(serde_json::json!({
- "status": "animation",
- "animation_id": animation_id,
- })))
- }
- SlideshowAction::Pause => {
- state.is_playing = false;
- Ok(HandResult::success(serde_json::json!({
- "status": "paused",
- })))
- }
- SlideshowAction::Resume => {
- state.is_playing = true;
- Ok(HandResult::success(serde_json::json!({
- "status": "resumed",
- })))
- }
- SlideshowAction::AutoPlay { interval_ms } => {
- state.is_playing = true;
- state.auto_play_interval_ms = interval_ms;
- Ok(HandResult::success(serde_json::json!({
- "status": "autoplay",
- "interval_ms": interval_ms,
- })))
- }
- SlideshowAction::StopAutoPlay => {
- state.is_playing = false;
- Ok(HandResult::success(serde_json::json!({
- "status": "stopped",
- })))
- }
- SlideshowAction::GetState => {
- Ok(HandResult::success(serde_json::to_value(&*state).unwrap_or(Value::Null)))
- }
- SlideshowAction::SetContent { slide_number, content } => {
- if slide_number < state.slides.len() {
- state.slides[slide_number] = content.clone();
- Ok(HandResult::success(serde_json::json!({
- "status": "content_set",
- "slide_number": slide_number,
- })))
- } else if slide_number == state.slides.len() {
- state.slides.push(content);
- state.total_slides = state.slides.len();
- Ok(HandResult::success(serde_json::json!({
- "status": "slide_added",
- "slide_number": slide_number,
- })))
- } else {
- Ok(HandResult::error(format!("Invalid slide number: {}", slide_number)))
- }
- }
- }
- }
-
- /// Get current state
- pub async fn get_state(&self) -> SlideshowState {
- self.state.read().await.clone()
- }
-
- /// Add a slide
- pub async fn add_slide(&self, content: SlideContent) {
- let mut state = self.state.write().await;
- state.slides.push(content);
- state.total_slides = state.slides.len();
- }
-}
-
-impl Default for SlideshowHand {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[async_trait]
-impl Hand for SlideshowHand {
- fn config(&self) -> &HandConfig {
- &self.config
- }
-
- async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
- let action: SlideshowAction = match serde_json::from_value(input) {
- Ok(a) => a,
- Err(e) => {
- return Ok(HandResult::error(format!("Invalid slideshow action: {}", e)));
- }
- };
-
- self.execute_action(action).await
- }
-
- fn status(&self) -> HandStatus {
- HandStatus::Idle
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use serde_json::json;
-
- // === Config & Defaults ===
-
- #[tokio::test]
- async fn test_slideshow_creation() {
- let hand = SlideshowHand::new();
- assert_eq!(hand.config().id, "slideshow");
- assert_eq!(hand.config().name, "幻灯片");
- assert!(!hand.config().needs_approval);
- assert!(hand.config().enabled);
- assert!(hand.config().tags.contains(&"presentation".to_string()));
- }
-
- #[test]
- fn test_default_impl() {
- let hand = SlideshowHand::default();
- assert_eq!(hand.config().id, "slideshow");
- }
-
- #[test]
- fn test_needs_approval() {
- let hand = SlideshowHand::new();
- assert!(!hand.needs_approval());
- }
-
- #[test]
- fn test_status() {
- let hand = SlideshowHand::new();
- assert_eq!(hand.status(), HandStatus::Idle);
- }
-
- #[test]
- fn test_default_state() {
- let state = SlideshowState::default();
- assert_eq!(state.current_slide, 0);
- assert_eq!(state.total_slides, 0);
- assert!(!state.is_playing);
- assert_eq!(state.auto_play_interval_ms, 5000);
- assert!(state.slides.is_empty());
- }
-
- // === Navigation ===
-
- #[tokio::test]
- async fn test_navigation() {
- let hand = SlideshowHand::with_slides_async(vec![
- SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- SlideContent { title: "Slide 3".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- ]).await;
-
- // Next
- hand.execute_action(SlideshowAction::NextSlide).await.unwrap();
- assert_eq!(hand.get_state().await.current_slide, 1);
-
- // Goto
- hand.execute_action(SlideshowAction::GotoSlide { slide_number: 2 }).await.unwrap();
- assert_eq!(hand.get_state().await.current_slide, 2);
-
- // Prev
- hand.execute_action(SlideshowAction::PrevSlide).await.unwrap();
- assert_eq!(hand.get_state().await.current_slide, 1);
- }
-
- #[tokio::test]
- async fn test_next_slide_at_end() {
- let hand = SlideshowHand::with_slides_async(vec![
- SlideContent { title: "Only Slide".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- ]).await;
-
- // At slide 0, should not advance past last slide
- hand.execute_action(SlideshowAction::NextSlide).await.unwrap();
- assert_eq!(hand.get_state().await.current_slide, 0);
- }
-
- #[tokio::test]
- async fn test_prev_slide_at_beginning() {
- let hand = SlideshowHand::with_slides_async(vec![
- SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- ]).await;
-
- // At slide 0, should not go below 0
- hand.execute_action(SlideshowAction::PrevSlide).await.unwrap();
- assert_eq!(hand.get_state().await.current_slide, 0);
- }
-
- #[tokio::test]
- async fn test_goto_slide_out_of_range() {
- let hand = SlideshowHand::with_slides_async(vec![
- SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- ]).await;
-
- let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 5 }).await.unwrap();
- assert!(!result.success);
- }
-
- #[tokio::test]
- async fn test_goto_slide_returns_content() {
- let hand = SlideshowHand::with_slides_async(vec![
- SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- SlideContent { title: "Second".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- ]).await;
-
- let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 1 }).await.unwrap();
- assert!(result.success);
- assert_eq!(result.output["slide_content"]["title"], "Second");
- }
-
- // === Spotlight & Laser & Highlight ===
-
- #[tokio::test]
- async fn test_spotlight() {
- let hand = SlideshowHand::new();
- let action = SlideshowAction::Spotlight {
- element_id: "title".to_string(),
- duration_ms: 2000,
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert!(result.success);
- assert_eq!(result.output["element_id"], "title");
- assert_eq!(result.output["duration_ms"], 2000);
- }
-
- #[tokio::test]
- async fn test_spotlight_default_duration() {
- let hand = SlideshowHand::new();
- let action = SlideshowAction::Spotlight {
- element_id: "elem".to_string(),
- duration_ms: default_spotlight_duration(),
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert_eq!(result.output["duration_ms"], 2000);
- }
-
- #[tokio::test]
- async fn test_laser() {
- let hand = SlideshowHand::new();
- let action = SlideshowAction::Laser {
- x: 100.0,
- y: 200.0,
- duration_ms: 3000,
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert!(result.success);
- assert_eq!(result.output["x"], 100.0);
- assert_eq!(result.output["y"], 200.0);
- }
-
- #[tokio::test]
- async fn test_highlight_default_color() {
- let hand = SlideshowHand::new();
- let action = SlideshowAction::Highlight {
- x: 10.0, y: 20.0, width: 100.0, height: 50.0,
- color: None, duration_ms: 2000,
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert!(result.success);
- assert_eq!(result.output["color"], "#ffcc00");
- }
-
- #[tokio::test]
- async fn test_highlight_custom_color() {
- let hand = SlideshowHand::new();
- let action = SlideshowAction::Highlight {
- x: 0.0, y: 0.0, width: 50.0, height: 50.0,
- color: Some("#ff0000".to_string()), duration_ms: 1000,
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert_eq!(result.output["color"], "#ff0000");
- }
-
- // === AutoPlay / Pause / Resume ===
-
- #[tokio::test]
- async fn test_autoplay_pause_resume() {
- let hand = SlideshowHand::new();
-
- // AutoPlay
- let result = hand.execute_action(SlideshowAction::AutoPlay { interval_ms: 3000 }).await.unwrap();
- assert!(result.success);
- assert!(hand.get_state().await.is_playing);
- assert_eq!(hand.get_state().await.auto_play_interval_ms, 3000);
-
- // Pause
- hand.execute_action(SlideshowAction::Pause).await.unwrap();
- assert!(!hand.get_state().await.is_playing);
-
- // Resume
- hand.execute_action(SlideshowAction::Resume).await.unwrap();
- assert!(hand.get_state().await.is_playing);
-
- // Stop
- hand.execute_action(SlideshowAction::StopAutoPlay).await.unwrap();
- assert!(!hand.get_state().await.is_playing);
- }
-
- #[tokio::test]
- async fn test_autoplay_default_interval() {
- let hand = SlideshowHand::new();
- hand.execute_action(SlideshowAction::AutoPlay { interval_ms: default_interval() }).await.unwrap();
- assert_eq!(hand.get_state().await.auto_play_interval_ms, 5000);
- }
-
- // === PlayAnimation ===
-
- #[tokio::test]
- async fn test_play_animation() {
- let hand = SlideshowHand::new();
- let result = hand.execute_action(SlideshowAction::PlayAnimation {
- animation_id: "fade_in".to_string(),
- }).await.unwrap();
-
- assert!(result.success);
- assert_eq!(result.output["animation_id"], "fade_in");
- }
-
- // === GetState ===
-
- #[tokio::test]
- async fn test_get_state() {
- let hand = SlideshowHand::with_slides_async(vec![
- SlideContent { title: "A".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- ]).await;
-
- let result = hand.execute_action(SlideshowAction::GetState).await.unwrap();
- assert!(result.success);
- assert_eq!(result.output["total_slides"], 1);
- assert_eq!(result.output["current_slide"], 0);
- }
-
- // === SetContent ===
-
- #[tokio::test]
- async fn test_set_content() {
- let hand = SlideshowHand::new();
-
- let content = SlideContent {
- title: "Test Slide".to_string(),
- subtitle: Some("Subtitle".to_string()),
- content: vec![ContentBlock::Text {
- text: "Hello".to_string(),
- style: None,
- }],
- notes: Some("Speaker notes".to_string()),
- background: None,
- };
-
- let result = hand.execute_action(SlideshowAction::SetContent {
- slide_number: 0,
- content,
- }).await.unwrap();
-
- assert!(result.success);
- assert_eq!(hand.get_state().await.total_slides, 1);
- assert_eq!(hand.get_state().await.slides[0].title, "Test Slide");
- }
-
- #[tokio::test]
- async fn test_set_content_append() {
- let hand = SlideshowHand::with_slides_async(vec![
- SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- ]).await;
-
- let content = SlideContent {
- title: "Appended".to_string(), subtitle: None, content: vec![], notes: None, background: None,
- };
-
- let result = hand.execute_action(SlideshowAction::SetContent {
- slide_number: 1,
- content,
- }).await.unwrap();
-
- assert!(result.success);
- assert_eq!(result.output["status"], "slide_added");
- assert_eq!(hand.get_state().await.total_slides, 2);
- }
-
- #[tokio::test]
- async fn test_set_content_invalid_index() {
- let hand = SlideshowHand::new();
-
- let content = SlideContent {
- title: "Gap".to_string(), subtitle: None, content: vec![], notes: None, background: None,
- };
-
- let result = hand.execute_action(SlideshowAction::SetContent {
- slide_number: 5,
- content,
- }).await.unwrap();
-
- assert!(!result.success);
- }
-
- // === Action Deserialization ===
-
- #[test]
- fn test_deserialize_next_slide() {
- let action: SlideshowAction = serde_json::from_value(json!({"action": "next_slide"})).unwrap();
- assert!(matches!(action, SlideshowAction::NextSlide));
- }
-
- #[test]
- fn test_deserialize_goto_slide() {
- let action: SlideshowAction = serde_json::from_value(json!({"action": "goto_slide", "slide_number": 3})).unwrap();
- match action {
- SlideshowAction::GotoSlide { slide_number } => assert_eq!(slide_number, 3),
- _ => panic!("Expected GotoSlide"),
- }
- }
-
- #[test]
- fn test_deserialize_laser() {
- let action: SlideshowAction = serde_json::from_value(json!({
- "action": "laser", "x": 50.0, "y": 75.0
- })).unwrap();
- match action {
- SlideshowAction::Laser { x, y, .. } => {
- assert_eq!(x, 50.0);
- assert_eq!(y, 75.0);
- }
- _ => panic!("Expected Laser"),
- }
- }
-
- #[test]
- fn test_deserialize_autoplay() {
- let action: SlideshowAction = serde_json::from_value(json!({"action": "auto_play"})).unwrap();
- match action {
- SlideshowAction::AutoPlay { interval_ms } => assert_eq!(interval_ms, 5000),
- _ => panic!("Expected AutoPlay"),
- }
- }
-
- #[test]
- fn test_deserialize_invalid_action() {
- let result = serde_json::from_value::<SlideshowAction>(json!({"action": "nonexistent"}));
- assert!(result.is_err());
- }
-
- // === ContentBlock Deserialization ===
-
- #[test]
- fn test_content_block_text() {
- let block: ContentBlock = serde_json::from_value(json!({
- "type": "text", "text": "Hello"
- })).unwrap();
- match block {
- ContentBlock::Text { text, style } => {
- assert_eq!(text, "Hello");
- assert!(style.is_none());
- }
- _ => panic!("Expected Text"),
- }
- }
-
- #[test]
- fn test_content_block_list() {
- let block: ContentBlock = serde_json::from_value(json!({
- "type": "list", "items": ["A", "B"], "ordered": true
- })).unwrap();
- match block {
- ContentBlock::List { items, ordered } => {
- assert_eq!(items, vec!["A", "B"]);
- assert!(ordered);
- }
- _ => panic!("Expected List"),
- }
- }
-
- #[test]
- fn test_content_block_code() {
- let block: ContentBlock = serde_json::from_value(json!({
- "type": "code", "code": "fn main() {}", "language": "rust"
- })).unwrap();
- match block {
- ContentBlock::Code { code, language } => {
- assert_eq!(code, "fn main() {}");
- assert_eq!(language, Some("rust".to_string()));
- }
- _ => panic!("Expected Code"),
- }
- }
-
- #[test]
- fn test_content_block_table() {
- let block: ContentBlock = serde_json::from_value(json!({
- "type": "table",
- "headers": ["Name", "Age"],
- "rows": [["Alice", "30"]]
- })).unwrap();
- match block {
- ContentBlock::Table { headers, rows } => {
- assert_eq!(headers, vec!["Name", "Age"]);
- assert_eq!(rows, vec![vec!["Alice", "30"]]);
- }
- _ => panic!("Expected Table"),
- }
- }
-
- // === Hand trait via execute ===
-
- #[tokio::test]
- async fn test_hand_execute_dispatch() {
- let hand = SlideshowHand::with_slides_async(vec![
- SlideContent { title: "S1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- SlideContent { title: "S2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
- ]).await;
-
- let ctx = HandContext::default();
- let result = hand.execute(&ctx, json!({"action": "next_slide"})).await.unwrap();
- assert!(result.success);
- assert_eq!(result.output["current_slide"], 1);
- }
-
- #[tokio::test]
- async fn test_hand_execute_invalid_action() {
- let hand = SlideshowHand::new();
- let ctx = HandContext::default();
- let result = hand.execute(&ctx, json!({"action": "invalid"})).await.unwrap();
- assert!(!result.success);
- }
-
- // === add_slide helper ===
-
- #[tokio::test]
- async fn test_add_slide() {
- let hand = SlideshowHand::new();
- hand.add_slide(SlideContent {
- title: "Dynamic".to_string(), subtitle: None, content: vec![], notes: None, background: None,
- }).await;
- hand.add_slide(SlideContent {
- title: "Dynamic 2".to_string(), subtitle: None, content: vec![], notes: None, background: None,
- }).await;
-
- let state = hand.get_state().await;
- assert_eq!(state.total_slides, 2);
- assert_eq!(state.slides.len(), 2);
- }
-}
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<String>,
- #[serde(default = "default_rate")]
- rate: f32,
- #[serde(default = "default_pitch")]
- pitch: f32,
- #[serde(default = "default_volume")]
- volume: f32,
- #[serde(default)]
- language: Option<String>,
- },
- /// Speak with SSML markup
- SpeakSsml {
- ssml: String,
- #[serde(default)]
- voice: Option<String>,
- },
- /// Pause playback
- Pause,
- /// Resume playback
- Resume,
- /// Stop playback
- Stop,
- /// List available voices
- ListVoices {
- #[serde(default)]
- language: Option<String>,
- },
- /// Set default voice
- SetVoice {
- voice: String,
- #[serde(default)]
- language: Option<String>,
- },
- /// Set provider
- SetProvider {
- provider: TtsProvider,
- #[serde(default)]
- api_key: Option<String>,
- #[serde(default)]
- region: Option<String>,
- },
-}
-
-fn default_rate() -> f32 { 1.0 }
-fn default_pitch() -> f32 { 1.0 }
-fn default_volume() -> f32 { 1.0 }
-
-/// Voice information
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct VoiceInfo {
- pub id: String,
- pub name: String,
- pub language: String,
- pub gender: String,
- #[serde(default)]
- pub preview_url: Option<String>,
-}
-
-/// Playback state
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
-pub enum PlaybackState {
- #[default]
- Idle,
- Playing,
- Paused,
-}
-
-/// Speech configuration
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct SpeechConfig {
- pub provider: TtsProvider,
- pub default_voice: Option<String>,
- pub default_language: String,
- pub default_rate: f32,
- pub default_pitch: f32,
- pub default_volume: f32,
-}
-
-impl Default for SpeechConfig {
- fn default() -> Self {
- Self {
- provider: TtsProvider::Browser,
- default_voice: None,
- default_language: "zh-CN".to_string(),
- default_rate: 1.0,
- default_pitch: 1.0,
- default_volume: 1.0,
- }
- }
-}
-
-/// Speech state
-#[derive(Debug, Clone, Default)]
-pub struct SpeechState {
- pub config: SpeechConfig,
- pub playback: PlaybackState,
- pub current_text: Option<String>,
- pub position_ms: u64,
- pub available_voices: Vec<VoiceInfo>,
-}
-
-/// Speech Hand implementation
-pub struct SpeechHand {
- config: HandConfig,
- state: Arc<RwLock<SpeechState>>,
-}
-
-impl SpeechHand {
- /// Create a new speech hand
- pub fn new() -> Self {
- Self {
- config: HandConfig {
- id: "speech".to_string(),
- name: "语音合成".to_string(),
- description: "文本转语音合成输出".to_string(),
- needs_approval: false,
- dependencies: vec![],
- input_schema: Some(serde_json::json!({
- "type": "object",
- "properties": {
- "action": { "type": "string" },
- "text": { "type": "string" },
- "voice": { "type": "string" },
- "rate": { "type": "number" },
- }
- })),
- tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string(), "demo".to_string()],
- enabled: true,
- max_concurrent: 0,
- timeout_secs: 0,
- },
- state: Arc::new(RwLock::new(SpeechState {
- config: SpeechConfig::default(),
- playback: PlaybackState::Idle,
- available_voices: Self::get_default_voices(),
- ..Default::default()
- })),
- }
- }
-
- /// Create with custom provider
- pub fn with_provider(provider: TtsProvider) -> Self {
- let hand = Self::new();
- let mut state = hand.state.blocking_write();
- state.config.provider = provider;
- drop(state);
- hand
- }
-
- /// Get default voices
- fn get_default_voices() -> Vec<VoiceInfo> {
- vec![
- VoiceInfo {
- id: "zh-CN-XiaoxiaoNeural".to_string(),
- name: "Xiaoxiao".to_string(),
- language: "zh-CN".to_string(),
- gender: "female".to_string(),
- preview_url: None,
- },
- VoiceInfo {
- id: "zh-CN-YunxiNeural".to_string(),
- name: "Yunxi".to_string(),
- language: "zh-CN".to_string(),
- gender: "male".to_string(),
- preview_url: None,
- },
- VoiceInfo {
- id: "en-US-JennyNeural".to_string(),
- name: "Jenny".to_string(),
- language: "en-US".to_string(),
- gender: "female".to_string(),
- preview_url: None,
- },
- VoiceInfo {
- id: "en-US-GuyNeural".to_string(),
- name: "Guy".to_string(),
- language: "en-US".to_string(),
- gender: "male".to_string(),
- preview_url: None,
- },
- ]
- }
-
- /// Execute a speech action
- pub async fn execute_action(&self, action: SpeechAction) -> Result<HandResult> {
- let mut state = self.state.write().await;
-
- match action {
- SpeechAction::Speak { text, voice, rate, pitch, volume, language } => {
- let voice_id = voice.or(state.config.default_voice.clone())
- .unwrap_or_else(|| "default".to_string());
- let lang = language.unwrap_or_else(|| state.config.default_language.clone());
- let actual_rate = if rate == 1.0 { state.config.default_rate } else { rate };
- let actual_pitch = if pitch == 1.0 { state.config.default_pitch } else { pitch };
- let actual_volume = if volume == 1.0 { state.config.default_volume } else { volume };
-
- state.playback = PlaybackState::Playing;
- state.current_text = Some(text.clone());
-
- // Determine TTS method based on provider:
- // - Browser: frontend uses Web Speech API (zero deps, works offline)
- // - OpenAI: frontend calls speech_tts command (high-quality, needs API key)
- // - Others: future support
- let tts_method = match state.config.provider {
- TtsProvider::Browser => "browser",
- TtsProvider::OpenAI => "openai_api",
- TtsProvider::Azure => "azure_api",
- TtsProvider::ElevenLabs => "elevenlabs_api",
- TtsProvider::Local => "local_engine",
- };
-
- let estimated_duration_ms = (text.chars().count() as f64 / 5.0 * 1000.0) as u64;
-
- Ok(HandResult::success(serde_json::json!({
- "status": "speaking",
- "tts_method": tts_method,
- "text": text,
- "voice": voice_id,
- "language": lang,
- "rate": actual_rate,
- "pitch": actual_pitch,
- "volume": actual_volume,
- "provider": format!("{:?}", state.config.provider).to_lowercase(),
- "duration_ms": estimated_duration_ms,
- "instruction": "Frontend should play this via TTS engine"
- })))
- }
- SpeechAction::SpeakSsml { ssml, voice } => {
- let voice_id = voice.or(state.config.default_voice.clone())
- .unwrap_or_else(|| "default".to_string());
-
- state.playback = PlaybackState::Playing;
- state.current_text = Some(ssml.clone());
-
- Ok(HandResult::success(serde_json::json!({
- "status": "speaking_ssml",
- "ssml": ssml,
- "voice": voice_id,
- "provider": state.config.provider,
- })))
- }
- SpeechAction::Pause => {
- state.playback = PlaybackState::Paused;
- Ok(HandResult::success(serde_json::json!({
- "status": "paused",
- "position_ms": state.position_ms,
- })))
- }
- SpeechAction::Resume => {
- state.playback = PlaybackState::Playing;
- Ok(HandResult::success(serde_json::json!({
- "status": "resumed",
- "position_ms": state.position_ms,
- })))
- }
- SpeechAction::Stop => {
- state.playback = PlaybackState::Idle;
- state.current_text = None;
- state.position_ms = 0;
- Ok(HandResult::success(serde_json::json!({
- "status": "stopped",
- })))
- }
- SpeechAction::ListVoices { language } => {
- let voices: Vec<_> = state.available_voices.iter()
- .filter(|v| {
- language.as_ref()
- .map(|l| v.language.starts_with(l))
- .unwrap_or(true)
- })
- .cloned()
- .collect();
-
- Ok(HandResult::success(serde_json::json!({
- "voices": voices,
- "count": voices.len(),
- })))
- }
- SpeechAction::SetVoice { voice, language } => {
- state.config.default_voice = Some(voice.clone());
- if let Some(lang) = language {
- state.config.default_language = lang;
- }
- Ok(HandResult::success(serde_json::json!({
- "status": "voice_set",
- "voice": voice,
- "language": state.config.default_language,
- })))
- }
- SpeechAction::SetProvider { provider, api_key, region: _ } => {
- state.config.provider = provider.clone();
- // In real implementation, would configure provider
- Ok(HandResult::success(serde_json::json!({
- "status": "provider_set",
- "provider": provider,
- "configured": api_key.is_some(),
- })))
- }
- }
- }
-
- /// Get current state
- pub async fn get_state(&self) -> SpeechState {
- self.state.read().await.clone()
- }
-}
-
-impl Default for SpeechHand {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[async_trait]
-impl Hand for SpeechHand {
- fn config(&self) -> &HandConfig {
- &self.config
- }
-
- async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
- let action: SpeechAction = match serde_json::from_value(input) {
- Ok(a) => a,
- Err(e) => {
- return Ok(HandResult::error(format!("Invalid speech action: {}", e)));
- }
- };
-
- self.execute_action(action).await
- }
-
- fn status(&self) -> HandStatus {
- HandStatus::Idle
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[tokio::test]
- async fn test_speech_creation() {
- let hand = SpeechHand::new();
- assert_eq!(hand.config().id, "speech");
- }
-
- #[tokio::test]
- async fn test_speak() {
- let hand = SpeechHand::new();
- let action = SpeechAction::Speak {
- text: "Hello, world!".to_string(),
- voice: None,
- rate: 1.0,
- pitch: 1.0,
- volume: 1.0,
- language: None,
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert!(result.success);
- }
-
- #[tokio::test]
- async fn test_pause_resume() {
- let hand = SpeechHand::new();
-
- // Speak first
- hand.execute_action(SpeechAction::Speak {
- text: "Test".to_string(),
- voice: None, rate: 1.0, pitch: 1.0, volume: 1.0, language: None,
- }).await.unwrap();
-
- // Pause
- let result = hand.execute_action(SpeechAction::Pause).await.unwrap();
- assert!(result.success);
-
- // Resume
- let result = hand.execute_action(SpeechAction::Resume).await.unwrap();
- assert!(result.success);
- }
-
- #[tokio::test]
- async fn test_list_voices() {
- let hand = SpeechHand::new();
- let action = SpeechAction::ListVoices { language: Some("zh".to_string()) };
-
- let result = hand.execute_action(action).await.unwrap();
- assert!(result.success);
- }
-
- #[tokio::test]
- async fn test_set_voice() {
- let hand = SpeechHand::new();
- let action = SpeechAction::SetVoice {
- voice: "zh-CN-XiaoxiaoNeural".to_string(),
- language: Some("zh-CN".to_string()),
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert!(result.success);
-
- let state = hand.get_state().await;
- assert_eq!(state.config.default_voice, Some("zh-CN-XiaoxiaoNeural".to_string()));
- }
-}
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<String>,
- #[serde(default)]
- font_family: Option<String>,
- },
- /// Draw a shape
- DrawShape {
- shape: ShapeType,
- x: f64,
- y: f64,
- width: f64,
- height: f64,
- #[serde(default)]
- fill: Option<String>,
- #[serde(default)]
- stroke: Option<String>,
- #[serde(default = "default_stroke_width")]
- stroke_width: u32,
- },
- /// Draw a line
- DrawLine {
- points: Vec<Point>,
- #[serde(default)]
- color: Option<String>,
- #[serde(default = "default_stroke_width")]
- stroke_width: u32,
- },
- /// Draw a chart
- DrawChart {
- chart_type: ChartType,
- data: ChartData,
- x: f64,
- y: f64,
- width: f64,
- height: f64,
- #[serde(default)]
- title: Option<String>,
- },
- /// Draw LaTeX formula
- DrawLatex {
- latex: String,
- x: f64,
- y: f64,
- #[serde(default = "default_font_size")]
- font_size: u32,
- #[serde(default)]
- color: Option<String>,
- },
- /// Draw a table
- DrawTable {
- headers: Vec<String>,
- rows: Vec<Vec<String>>,
- x: f64,
- y: f64,
- #[serde(default)]
- column_widths: Option<Vec<f64>>,
- },
- /// Erase area
- Erase {
- x: f64,
- y: f64,
- width: f64,
- height: f64,
- },
- /// Clear whiteboard
- Clear,
- /// Undo last action
- Undo,
- /// Redo last undone action
- Redo,
- /// Export as image
- Export {
- #[serde(default = "default_export_format")]
- format: String,
- },
-}
-
-fn default_font_size() -> u32 { 16 }
-fn default_stroke_width() -> u32 { 2 }
-fn default_export_format() -> String { "png".to_string() }
-
-/// Shape types
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum ShapeType {
- Rectangle,
- RoundedRectangle,
- Circle,
- Ellipse,
- Triangle,
- Arrow,
- Star,
- Checkmark,
- Cross,
-}
-
-/// Point for line drawing
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Point {
- pub x: f64,
- pub y: f64,
-}
-
-/// Chart types
-#[derive(Debug, Clone, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum ChartType {
- Bar,
- Line,
- Pie,
- Scatter,
- Area,
- Radar,
-}
-
-/// Chart data
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct ChartData {
- pub labels: Vec<String>,
- pub datasets: Vec<Dataset>,
-}
-
-/// Dataset for charts
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Dataset {
- pub label: String,
- pub values: Vec<f64>,
- #[serde(default)]
- pub color: Option<String>,
-}
-
-/// Whiteboard state (for undo/redo)
-#[derive(Debug, Clone, Default)]
-pub struct WhiteboardState {
- pub actions: Vec<WhiteboardAction>,
- pub undone: Vec<WhiteboardAction>,
- pub canvas_width: f64,
- pub canvas_height: f64,
-}
-
-/// Whiteboard Hand implementation
-pub struct WhiteboardHand {
- config: HandConfig,
- state: std::sync::Arc<tokio::sync::RwLock<WhiteboardState>>,
-}
-
-impl WhiteboardHand {
- /// Create a new whiteboard hand
- pub fn new() -> Self {
- Self {
- config: HandConfig {
- id: "whiteboard".to_string(),
- name: "白板".to_string(),
- description: "在虚拟白板上绘制和标注".to_string(),
- needs_approval: false,
- dependencies: vec![],
- input_schema: Some(serde_json::json!({
- "type": "object",
- "properties": {
- "action": { "type": "string" },
- "x": { "type": "number" },
- "y": { "type": "number" },
- "text": { "type": "string" },
- }
- })),
- tags: vec!["presentation".to_string(), "education".to_string()],
- enabled: true,
- max_concurrent: 0,
- timeout_secs: 0,
- },
- state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState {
- canvas_width: 1920.0,
- canvas_height: 1080.0,
- ..Default::default()
- })),
- }
- }
-
- /// Create with custom canvas size
- pub fn with_size(width: f64, height: f64) -> Self {
- let hand = Self::new();
- let mut state = hand.state.blocking_write();
- state.canvas_width = width;
- state.canvas_height = height;
- drop(state);
- hand
- }
-
- /// Execute a whiteboard action
- pub async fn execute_action(&self, action: WhiteboardAction) -> Result<HandResult> {
- let mut state = self.state.write().await;
-
- match &action {
- WhiteboardAction::Clear => {
- state.actions.clear();
- state.undone.clear();
- return Ok(HandResult::success(serde_json::json!({
- "status": "cleared",
- "action_count": 0
- })));
- }
- WhiteboardAction::Undo => {
- if let Some(last) = state.actions.pop() {
- state.undone.push(last);
- return Ok(HandResult::success(serde_json::json!({
- "status": "undone",
- "remaining_actions": state.actions.len()
- })));
- }
- return Ok(HandResult::success(serde_json::json!({
- "status": "no_action_to_undo"
- })));
- }
- WhiteboardAction::Redo => {
- if let Some(redone) = state.undone.pop() {
- state.actions.push(redone);
- return Ok(HandResult::success(serde_json::json!({
- "status": "redone",
- "total_actions": state.actions.len()
- })));
- }
- return Ok(HandResult::success(serde_json::json!({
- "status": "no_action_to_redo"
- })));
- }
- WhiteboardAction::Export { format } => {
- // In real implementation, would render to image
- return Ok(HandResult::success(serde_json::json!({
- "status": "exported",
- "format": format,
- "data_url": format!("data:image/{};base64,<rendered_data>", format)
- })));
- }
- _ => {
- // Regular drawing action
- state.actions.push(action.clone());
- return Ok(HandResult::success(serde_json::json!({
- "status": "drawn",
- "action": action,
- "total_actions": state.actions.len()
- })));
- }
- }
- }
-
- /// Get current state
- pub async fn get_state(&self) -> WhiteboardState {
- self.state.read().await.clone()
- }
-
- /// Get all actions
- pub async fn get_actions(&self) -> Vec<WhiteboardAction> {
- self.state.read().await.actions.clone()
- }
-}
-
-impl Default for WhiteboardHand {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[async_trait]
-impl Hand for WhiteboardHand {
- fn config(&self) -> &HandConfig {
- &self.config
- }
-
- async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
- // Parse action from input
- let action: WhiteboardAction = match serde_json::from_value(input.clone()) {
- Ok(a) => a,
- Err(e) => {
- return Ok(HandResult::error(format!("Invalid whiteboard action: {}", e)));
- }
- };
-
- self.execute_action(action).await
- }
-
- fn status(&self) -> HandStatus {
- // Check if there are any actions
- HandStatus::Idle
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[tokio::test]
- async fn test_whiteboard_creation() {
- let hand = WhiteboardHand::new();
- assert_eq!(hand.config().id, "whiteboard");
- }
-
- #[tokio::test]
- async fn test_draw_text() {
- let hand = WhiteboardHand::new();
- let action = WhiteboardAction::DrawText {
- x: 100.0,
- y: 100.0,
- text: "Hello World".to_string(),
- font_size: 24,
- color: Some("#333333".to_string()),
- font_family: None,
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert!(result.success);
-
- let state = hand.get_state().await;
- assert_eq!(state.actions.len(), 1);
- }
-
- #[tokio::test]
- async fn test_draw_shape() {
- let hand = WhiteboardHand::new();
- let action = WhiteboardAction::DrawShape {
- shape: ShapeType::Rectangle,
- x: 50.0,
- y: 50.0,
- width: 200.0,
- height: 100.0,
- fill: Some("#4CAF50".to_string()),
- stroke: None,
- stroke_width: 2,
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert!(result.success);
- }
-
- #[tokio::test]
- async fn test_undo_redo() {
- let hand = WhiteboardHand::new();
-
- // Draw something
- hand.execute_action(WhiteboardAction::DrawText {
- x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
- }).await.unwrap();
-
- // Undo
- let result = hand.execute_action(WhiteboardAction::Undo).await.unwrap();
- assert!(result.success);
- assert_eq!(hand.get_state().await.actions.len(), 0);
-
- // Redo
- let result = hand.execute_action(WhiteboardAction::Redo).await.unwrap();
- assert!(result.success);
- assert_eq!(hand.get_state().await.actions.len(), 1);
- }
-
- #[tokio::test]
- async fn test_clear() {
- let hand = WhiteboardHand::new();
-
- // Draw something
- hand.execute_action(WhiteboardAction::DrawText {
- x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
- }).await.unwrap();
-
- // Clear
- let result = hand.execute_action(WhiteboardAction::Clear).await.unwrap();
- assert!(result.success);
- assert_eq!(hand.get_state().await.actions.len(), 0);
- }
-
- #[tokio::test]
- async fn test_chart() {
- let hand = WhiteboardHand::new();
- let action = WhiteboardAction::DrawChart {
- chart_type: ChartType::Bar,
- data: ChartData {
- labels: vec!["A".to_string(), "B".to_string(), "C".to_string()],
- datasets: vec![Dataset {
- label: "Values".to_string(),
- values: vec![10.0, 20.0, 15.0],
- color: Some("#2196F3".to_string()),
- }],
- },
- x: 100.0,
- y: 100.0,
- width: 400.0,
- height: 300.0,
- title: Some("Test Chart".to_string()),
- };
-
- let result = hand.execute_action(action).await.unwrap();
- assert!(result.success);
- }
-}
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<zclaw_protocols::A2aEnvelope>,
pub(crate) pending: std::collections::VecDeque<zclaw_protocols::A2aEnvelope>,
}
-#[cfg(feature = "multi-agent")]
impl AgentInbox {
pub(crate) fn new(rx: tokio::sync::mpsc::Receiver<zclaw_protocols::A2aEnvelope>) -> Self {
Self { rx, pending: std::collections::VecDeque::new() }
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<std::sync::RwLock<Vec<zclaw_protocols::McpToolAdapter>>>,
/// Dynamic industry keyword configs — shared with Tauri frontend, loaded from SaaS
industry_keywords: Arc<tokio::sync::RwLock<Vec<zclaw_runtime::IndustryKeywordConfig>>>,
- /// A2A router for inter-agent messaging (gated by multi-agent feature)
- #[cfg(feature = "multi-agent")]
+ /// A2A router for inter-agent messaging
a2a_router: Arc<A2aRouter>,
/// Per-agent A2A inbox receivers (supports re-queuing non-matching messages)
- #[cfg(feature = "multi-agent")]
a2a_inboxes: Arc<dashmap::DashMap<AgentId, Arc<Mutex<adapters::AgentInbox>>>>,
}
@@ -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<zclaw_runtime::middleware::MiddlewareChain> {
+ pub(crate) fn create_middleware_chain(&self) -> zclaw_runtime::middleware::MiddlewareChain {
let mut chain = zclaw_runtime::middleware::MiddlewareChain::new();
// Butler router — semantic skill routing context injection
@@ -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<dyn LlmDriver>,
tools: ToolRegistry,
memory: Arc<MemoryStore>,
- loop_guard: Mutex<LoopGuard>,
model: String,
system_prompt: Option<String>,
/// 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<MiddlewareChain>,
+ /// 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<AgentLoopResult> = None;
let mut clarification_result: Option<AgentLoopResult> = 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<WasiP1Ctx>, _network_allowed: bool) -> Result<()> {
+fn add_host_functions(linker: &mut Linker<WasiP1Ctx>, 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<WasiP1Ctx>, _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<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
+ ErrorDetail::from(self).serialize(serializer)
+ }
+}
+
/// Result type alias for ZCLAW operations
pub type Result<T> = std::result::Result<T, ZclawError>;
@@ -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?" }