Files
zclaw_openfang/crates/zclaw-hands/src/hands/slideshow.rs
iven 59f660b93b
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(hands): add max_concurrent + timeout_secs fields + hand timeout enforcement
M3-04/M3-05 audit fixes:
- HandConfig: add max_concurrent (u32) and timeout_secs (u64) with serde defaults
- Kernel execute_hand: enforce timeout via tokio::time::timeout, cancel on expiry
- All 9 hand implementations: add max_concurrent: 0, timeout_secs: 0
- Agent createClone: pass soul field through to kernel
- Fix duplicate soul block in agent_create command
2026-04-04 18:41:15 +08:00

798 lines
26 KiB
Rust

//! 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);
}
}