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:
813
crates/zclaw-hands/src/hands/quiz.rs
Normal file
813
crates/zclaw-hands/src/hands/quiz.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
425
crates/zclaw-hands/src/hands/slideshow.rs
Normal file
425
crates/zclaw-hands/src/hands/slideshow.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
425
crates/zclaw-hands/src/hands/speech.rs
Normal file
425
crates/zclaw-hands/src/hands/speech.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
420
crates/zclaw-hands/src/hands/whiteboard.rs
Normal file
420
crates/zclaw-hands/src/hands/whiteboard.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user