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
移除不再使用的数据脱敏功能,包括: 1. 删除data_masking模块 2. 清理loop_runner中的unmask逻辑 3. 移除前端saas-relay-client.ts中的mask/unmask实现 4. 更新中间件层数从15层降为14层 5. 同步更新相关文档(CLAUDE.md、TRUTH.md、wiki等) 此次变更简化了系统架构,移除了不再需要的敏感数据处理逻辑。所有相关测试证据和截图已归档。
3284 lines
113 KiB
Plaintext
3284 lines
113 KiB
Plaintext
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?" }
|