release(v0.2.0): streaming, MCP protocol, Browser Hand, security enhancements

## Major Features

### Streaming Response System
- Implement LlmDriver trait with `stream()` method returning async Stream
- Add SSE parsing for Anthropic and OpenAI API streaming
- Integrate Tauri event system for frontend streaming (`stream:chunk` events)
- Add StreamChunk types: Delta, ToolStart, ToolEnd, Complete, Error

### MCP Protocol Implementation
- Add MCP JSON-RPC 2.0 types (mcp_types.rs)
- Implement stdio-based MCP transport (mcp_transport.rs)
- Support tool discovery, execution, and resource operations

### Browser Hand Implementation
- Complete browser automation with Playwright-style actions
- Support Navigate, Click, Type, Scrape, Screenshot, Wait actions
- Add educational Hands: Whiteboard, Slideshow, Speech, Quiz

### Security Enhancements
- Implement command whitelist/blacklist for shell_exec tool
- Add SSRF protection with private IP blocking
- Create security.toml configuration file

## Test Improvements
- Fix test import paths (security-utils, setup)
- Fix vi.mock hoisting issues with vi.hoisted()
- Update test expectations for validateUrl and sanitizeFilename
- Add getUnsupportedLocalGatewayStatus mock

## Documentation Updates
- Update architecture documentation
- Improve configuration reference
- Add quick-start guide updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-24 03:24:24 +08:00
parent e49ba4460b
commit 3ff08faa56
78 changed files with 29575 additions and 1682 deletions

View File

@@ -0,0 +1,813 @@
//! Quiz Hand - Assessment and evaluation capabilities
//!
//! Provides quiz functionality for teaching:
//! - generate: Generate quiz questions from content
//! - show: Display a quiz to users
//! - submit: Submit an answer
//! - grade: Grade submitted answers
//! - analyze: Analyze quiz performance
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
use zclaw_types::Result;
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
/// Trait for generating quiz questions using LLM or other AI
#[async_trait]
pub trait QuizGenerator: Send + Sync {
/// Generate quiz questions based on topic and content
async fn generate_questions(
&self,
topic: &str,
content: Option<&str>,
count: usize,
difficulty: &DifficultyLevel,
question_types: &[QuestionType],
) -> Result<Vec<QuizQuestion>>;
}
/// Default placeholder generator (used when no LLM is configured)
pub struct DefaultQuizGenerator;
#[async_trait]
impl QuizGenerator for DefaultQuizGenerator {
async fn generate_questions(
&self,
topic: &str,
_content: Option<&str>,
count: usize,
difficulty: &DifficultyLevel,
_question_types: &[QuestionType],
) -> Result<Vec<QuizQuestion>> {
// Generate placeholder questions
Ok((0..count)
.map(|i| QuizQuestion {
id: uuid_v4(),
question_type: QuestionType::MultipleChoice,
question: format!("Question {} about {}", i + 1, topic),
options: Some(vec![
"Option A".to_string(),
"Option B".to_string(),
"Option C".to_string(),
"Option D".to_string(),
]),
correct_answer: Answer::Single("Option A".to_string()),
explanation: Some(format!("Explanation for question {}", i + 1)),
hints: Some(vec![format!("Hint 1 for question {}", i + 1)]),
points: 10.0,
difficulty: difficulty.clone(),
tags: vec![topic.to_string()],
})
.collect())
}
}
/// Quiz action types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum QuizAction {
/// Generate quiz from content
Generate {
topic: String,
content: Option<String>,
question_count: Option<usize>,
difficulty: Option<DifficultyLevel>,
question_types: Option<Vec<QuestionType>>,
},
/// Show quiz question
Show {
quiz_id: String,
question_index: Option<usize>,
},
/// Submit answer
Submit {
quiz_id: String,
question_id: String,
answer: Answer,
},
/// Grade quiz
Grade {
quiz_id: String,
show_correct: Option<bool>,
show_explanation: Option<bool>,
},
/// Analyze results
Analyze {
quiz_id: String,
},
/// Get hint
Hint {
quiz_id: String,
question_id: String,
hint_level: Option<u32>,
},
/// Show explanation
Explain {
quiz_id: String,
question_id: String,
},
/// Get next question (adaptive)
NextQuestion {
quiz_id: String,
current_score: Option<f64>,
},
/// Create quiz from template
CreateFromTemplate {
template: QuizTemplate,
},
/// Export quiz
Export {
quiz_id: String,
format: ExportFormat,
},
}
/// Question types
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum QuestionType {
#[default]
MultipleChoice,
TrueFalse,
FillBlank,
ShortAnswer,
Matching,
Ordering,
Essay,
}
/// Difficulty levels
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DifficultyLevel {
Easy,
#[default]
Medium,
Hard,
Adaptive,
}
/// Answer types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Answer {
Single(String),
Multiple(Vec<String>),
Text(String),
Ordering(Vec<String>),
Matching(Vec<(String, String)>),
}
/// Quiz template
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuizTemplate {
pub title: String,
pub description: String,
pub time_limit_seconds: Option<u32>,
pub passing_score: Option<f64>,
pub allow_retry: bool,
pub show_feedback: bool,
pub shuffle_questions: bool,
pub shuffle_options: bool,
}
/// Quiz question
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuizQuestion {
pub id: String,
pub question_type: QuestionType,
pub question: String,
pub options: Option<Vec<String>>,
pub correct_answer: Answer,
pub explanation: Option<String>,
pub hints: Option<Vec<String>>,
pub points: f64,
pub difficulty: DifficultyLevel,
pub tags: Vec<String>,
}
/// Quiz definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Quiz {
pub id: String,
pub title: String,
pub description: String,
pub questions: Vec<QuizQuestion>,
pub time_limit_seconds: Option<u32>,
pub passing_score: f64,
pub allow_retry: bool,
pub show_feedback: bool,
pub shuffle_questions: bool,
pub shuffle_options: bool,
pub created_at: i64,
}
/// Quiz attempt
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct QuizAttempt {
pub quiz_id: String,
pub answers: Vec<AnswerSubmission>,
pub score: Option<f64>,
pub started_at: i64,
pub completed_at: Option<i64>,
pub time_spent_seconds: Option<u32>,
}
/// Answer submission
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnswerSubmission {
pub question_id: String,
pub answer: Answer,
pub is_correct: Option<bool>,
pub points_earned: Option<f64>,
pub feedback: Option<String>,
}
/// Export format
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ExportFormat {
#[default]
Json,
Qti,
Gift,
Markdown,
}
/// Quiz state
#[derive(Debug, Clone, Default)]
pub struct QuizState {
pub quizzes: Vec<Quiz>,
pub attempts: Vec<QuizAttempt>,
pub current_quiz_id: Option<String>,
pub current_question_index: usize,
}
/// Quiz Hand implementation
pub struct QuizHand {
config: HandConfig,
state: Arc<RwLock<QuizState>>,
quiz_generator: Arc<dyn QuizGenerator>,
}
impl QuizHand {
/// Create a new quiz hand with default generator
pub fn new() -> Self {
Self {
config: HandConfig {
id: "quiz".to_string(),
name: "Quiz".to_string(),
description: "Generate and manage quizzes for assessment".to_string(),
needs_approval: false,
dependencies: vec![],
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string" },
"quiz_id": { "type": "string" },
"topic": { "type": "string" },
}
})),
tags: vec!["assessment".to_string(), "education".to_string()],
enabled: true,
},
state: Arc::new(RwLock::new(QuizState::default())),
quiz_generator: Arc::new(DefaultQuizGenerator),
}
}
/// Create a quiz hand with custom generator (e.g., LLM-powered)
pub fn with_generator(generator: Arc<dyn QuizGenerator>) -> Self {
let mut hand = Self::new();
hand.quiz_generator = generator;
hand
}
/// Set the quiz generator at runtime
pub fn set_generator(&mut self, generator: Arc<dyn QuizGenerator>) {
self.quiz_generator = generator;
}
/// Execute a quiz action
pub async fn execute_action(&self, action: QuizAction) -> Result<HandResult> {
match action {
QuizAction::Generate { topic, content, question_count, difficulty, question_types } => {
let count = question_count.unwrap_or(5);
let diff = difficulty.unwrap_or_default();
let types = question_types.unwrap_or_else(|| vec![QuestionType::MultipleChoice]);
// Use the configured generator (LLM or placeholder)
let questions = self.quiz_generator
.generate_questions(&topic, content.as_deref(), count, &diff, &types)
.await?;
let quiz = Quiz {
id: uuid_v4(),
title: format!("Quiz: {}", topic),
description: format!("Test your knowledge of {}", topic),
questions,
time_limit_seconds: None,
passing_score: 60.0,
allow_retry: true,
show_feedback: true,
shuffle_questions: false,
shuffle_options: true,
created_at: current_timestamp(),
};
let mut state = self.state.write().await;
state.quizzes.push(quiz.clone());
state.current_quiz_id = Some(quiz.id.clone());
Ok(HandResult::success(serde_json::json!({
"status": "generated",
"quiz": quiz,
})))
}
other => self.execute_other_action(other).await,
}
}
/// Execute non-generate actions (requires lock)
async fn execute_other_action(&self, action: QuizAction) -> Result<HandResult> {
let mut state = self.state.write().await;
match action {
QuizAction::Show { quiz_id, question_index } => {
let quiz = state.quizzes.iter()
.find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let idx = question_index.unwrap_or(state.current_question_index);
if idx < quiz.questions.len() {
let question = &quiz.questions[idx];
// Hide correct answer when showing
let question_for_display = serde_json::json!({
"id": question.id,
"type": question.question_type,
"question": question.question,
"options": question.options,
"points": question.points,
"difficulty": question.difficulty,
});
Ok(HandResult::success(serde_json::json!({
"status": "showing",
"question": question_for_display,
"question_index": idx,
"total_questions": quiz.questions.len(),
})))
} else {
Ok(HandResult::error("Question index out of range"))
}
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::Submit { quiz_id, question_id, answer } => {
let submission = AnswerSubmission {
question_id,
answer,
is_correct: None,
points_earned: None,
feedback: None,
};
// Find or create attempt
let attempt = state.attempts.iter_mut()
.find(|a| a.quiz_id == quiz_id && a.completed_at.is_none());
match attempt {
Some(attempt) => {
attempt.answers.push(submission);
}
None => {
let mut new_attempt = QuizAttempt {
quiz_id,
started_at: current_timestamp(),
..Default::default()
};
new_attempt.answers.push(submission);
state.attempts.push(new_attempt);
}
}
Ok(HandResult::success(serde_json::json!({
"status": "submitted",
})))
}
QuizAction::Grade { quiz_id, show_correct, show_explanation } => {
// First, find the quiz and clone necessary data
let quiz_data = state.quizzes.iter()
.find(|q| q.id == quiz_id)
.map(|quiz| (quiz.clone(), quiz.passing_score));
let attempt = state.attempts.iter_mut()
.find(|a| a.quiz_id == quiz_id && a.completed_at.is_none());
match (quiz_data, attempt) {
(Some((quiz, passing_score)), Some(attempt)) => {
let mut total_points = 0.0;
let mut earned_points = 0.0;
for submission in &mut attempt.answers {
if let Some(question) = quiz.questions.iter()
.find(|q| q.id == submission.question_id)
{
let is_correct = self.check_answer(&submission.answer, &question.correct_answer);
submission.is_correct = Some(is_correct);
submission.points_earned = Some(if is_correct { question.points } else { 0.0 });
total_points += question.points;
earned_points += submission.points_earned.unwrap();
if show_explanation.unwrap_or(true) {
submission.feedback = question.explanation.clone();
}
}
}
let score = if total_points > 0.0 {
(earned_points / total_points) * 100.0
} else {
0.0
};
attempt.score = Some(score);
attempt.completed_at = Some(current_timestamp());
Ok(HandResult::success(serde_json::json!({
"status": "graded",
"score": score,
"total_points": total_points,
"earned_points": earned_points,
"passed": score >= passing_score,
"answers": if show_correct.unwrap_or(true) {
serde_json::to_value(&attempt.answers).unwrap_or(serde_json::Value::Null)
} else {
serde_json::Value::Null
},
})))
}
_ => Ok(HandResult::error("Quiz or attempt not found")),
}
}
QuizAction::Analyze { quiz_id } => {
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
let attempts: Vec<_> = state.attempts.iter()
.filter(|a| a.quiz_id == quiz_id && a.completed_at.is_some())
.collect();
match quiz {
Some(quiz) => {
let scores: Vec<f64> = attempts.iter()
.filter_map(|a| a.score)
.collect();
let avg_score = if !scores.is_empty() {
scores.iter().sum::<f64>() / scores.len() as f64
} else {
0.0
};
Ok(HandResult::success(serde_json::json!({
"status": "analyzed",
"quiz_title": quiz.title,
"total_attempts": attempts.len(),
"average_score": avg_score,
"pass_rate": scores.iter().filter(|&&s| s >= quiz.passing_score).count() as f64 / scores.len().max(1) as f64 * 100.0,
})))
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::Hint { quiz_id, question_id, hint_level } => {
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let question = quiz.questions.iter()
.find(|q| q.id == question_id);
match question {
Some(q) => {
let level = hint_level.unwrap_or(1) as usize;
let hint = q.hints.as_ref()
.and_then(|h| h.get(level.saturating_sub(1)))
.map(|s| s.as_str())
.unwrap_or("No hint available at this level");
Ok(HandResult::success(serde_json::json!({
"status": "hint",
"hint": hint,
"level": level,
})))
}
None => Ok(HandResult::error("Question not found")),
}
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::Explain { quiz_id, question_id } => {
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let question = quiz.questions.iter()
.find(|q| q.id == question_id);
match question {
Some(q) => {
Ok(HandResult::success(serde_json::json!({
"status": "explanation",
"question": q.question,
"correct_answer": q.correct_answer,
"explanation": q.explanation,
})))
}
None => Ok(HandResult::error("Question not found")),
}
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::NextQuestion { quiz_id, current_score } => {
// Adaptive quiz - select next question based on performance
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let score = current_score.unwrap_or(0.0);
let next_idx = state.current_question_index + 1;
if next_idx < quiz.questions.len() {
state.current_question_index = next_idx;
Ok(HandResult::success(serde_json::json!({
"status": "next",
"question_index": next_idx,
})))
} else {
Ok(HandResult::success(serde_json::json!({
"status": "complete",
})))
}
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::CreateFromTemplate { template } => {
let quiz = Quiz {
id: uuid_v4(),
title: template.title,
description: template.description,
questions: Vec::new(), // Would be filled in
time_limit_seconds: template.time_limit_seconds,
passing_score: template.passing_score.unwrap_or(60.0),
allow_retry: template.allow_retry,
show_feedback: template.show_feedback,
shuffle_questions: template.shuffle_questions,
shuffle_options: template.shuffle_options,
created_at: current_timestamp(),
};
state.quizzes.push(quiz.clone());
Ok(HandResult::success(serde_json::json!({
"status": "created",
"quiz_id": quiz.id,
})))
}
QuizAction::Export { quiz_id, format } => {
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let content = match format {
ExportFormat::Json => serde_json::to_string_pretty(&quiz).unwrap_or_default(),
ExportFormat::Markdown => self.export_markdown(quiz),
_ => format!("{:?}", quiz),
};
Ok(HandResult::success(serde_json::json!({
"status": "exported",
"format": format,
"content": content,
})))
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
// Generate is handled in execute_action, this is just for exhaustiveness
QuizAction::Generate { .. } => {
Ok(HandResult::error("Generate action should be handled in execute_action"))
}
}
}
/// Check if answer is correct
fn check_answer(&self, submitted: &Answer, correct: &Answer) -> bool {
match (submitted, correct) {
(Answer::Single(s), Answer::Single(c)) => s == c,
(Answer::Multiple(s), Answer::Multiple(c)) => {
let mut s_sorted = s.clone();
let mut c_sorted = c.clone();
s_sorted.sort();
c_sorted.sort();
s_sorted == c_sorted
}
(Answer::Text(s), Answer::Text(c)) => s.trim().to_lowercase() == c.trim().to_lowercase(),
_ => false,
}
}
/// Export quiz as markdown
fn export_markdown(&self, quiz: &Quiz) -> String {
let mut md = format!("# {}\n\n{}\n\n", quiz.title, quiz.description);
for (i, q) in quiz.questions.iter().enumerate() {
md.push_str(&format!("## Question {}\n\n{}\n\n", i + 1, q.question));
if let Some(options) = &q.options {
for opt in options {
md.push_str(&format!("- {}\n", opt));
}
md.push_str("\n");
}
if let Some(explanation) = &q.explanation {
md.push_str(&format!("**Explanation:** {}\n\n", explanation));
}
}
md
}
/// Get current state
pub async fn get_state(&self) -> QuizState {
self.state.read().await.clone()
}
}
impl Default for QuizHand {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Hand for QuizHand {
fn config(&self) -> &HandConfig {
&self.config
}
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
let action: QuizAction = match serde_json::from_value(input) {
Ok(a) => a,
Err(e) => {
return Ok(HandResult::error(format!("Invalid quiz action: {}", e)));
}
};
self.execute_action(action).await
}
fn status(&self) -> HandStatus {
HandStatus::Idle
}
}
// Helper functions
/// Generate a cryptographically secure UUID v4
fn uuid_v4() -> String {
Uuid::new_v4().to_string()
}
fn current_timestamp() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_quiz_creation() {
let hand = QuizHand::new();
assert_eq!(hand.config().id, "quiz");
}
#[tokio::test]
async fn test_generate_quiz() {
let hand = QuizHand::new();
let action = QuizAction::Generate {
topic: "Rust Ownership".to_string(),
content: None,
question_count: Some(5),
difficulty: Some(DifficultyLevel::Medium),
question_types: None,
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
let state = hand.get_state().await;
assert_eq!(state.quizzes.len(), 1);
assert_eq!(state.quizzes[0].questions.len(), 5);
}
#[tokio::test]
async fn test_show_question() {
let hand = QuizHand::new();
// Generate first
hand.execute_action(QuizAction::Generate {
topic: "Test".to_string(),
content: None,
question_count: Some(3),
difficulty: None,
question_types: None,
}).await.unwrap();
let quiz_id = hand.get_state().await.quizzes[0].id.clone();
let result = hand.execute_action(QuizAction::Show {
quiz_id,
question_index: Some(0),
}).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_submit_and_grade() {
let hand = QuizHand::new();
// Generate
hand.execute_action(QuizAction::Generate {
topic: "Test".to_string(),
content: None,
question_count: Some(2),
difficulty: None,
question_types: None,
}).await.unwrap();
let state = hand.get_state().await;
let quiz_id = state.quizzes[0].id.clone();
let q1_id = state.quizzes[0].questions[0].id.clone();
let q2_id = state.quizzes[0].questions[1].id.clone();
// Submit answers
hand.execute_action(QuizAction::Submit {
quiz_id: quiz_id.clone(),
question_id: q1_id,
answer: Answer::Single("Option A".to_string()),
}).await.unwrap();
hand.execute_action(QuizAction::Submit {
quiz_id: quiz_id.clone(),
question_id: q2_id,
answer: Answer::Single("Option A".to_string()),
}).await.unwrap();
// Grade
let result = hand.execute_action(QuizAction::Grade {
quiz_id,
show_correct: Some(true),
show_explanation: Some(true),
}).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_export_markdown() {
let hand = QuizHand::new();
hand.execute_action(QuizAction::Generate {
topic: "Test".to_string(),
content: None,
question_count: Some(2),
difficulty: None,
question_types: None,
}).await.unwrap();
let quiz_id = hand.get_state().await.quizzes[0].id.clone();
let result = hand.execute_action(QuizAction::Export {
quiz_id,
format: ExportFormat::Markdown,
}).await.unwrap();
assert!(result.success);
}
}

View File

@@ -0,0 +1,425 @@
//! 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>,
}
/// Content block types
#[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: "Slideshow".to_string(),
description: "Control presentation slides and highlights".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,
},
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::*;
#[tokio::test]
async fn test_slideshow_creation() {
let hand = SlideshowHand::new();
assert_eq!(hand.config().id, "slideshow");
}
#[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_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);
}
#[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);
}
#[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);
}
}

View File

@@ -0,0 +1,425 @@
//! 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: "Speech".to_string(),
description: "Text-to-speech synthesis for voice output".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()],
enabled: true,
},
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 mut 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());
// In real implementation, would call TTS API
Ok(HandResult::success(serde_json::json!({
"status": "speaking",
"text": text,
"voice": voice_id,
"language": lang,
"rate": actual_rate,
"pitch": actual_pitch,
"volume": actual_volume,
"provider": state.config.provider,
"duration_ms": text.len() as u64 * 80, // Rough estimate
})))
}
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()));
}
}

View File

@@ -0,0 +1,420 @@
//! 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: "Whiteboard".to_string(),
description: "Draw and annotate on a virtual whiteboard".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,
},
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 mut 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);
}
}