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

View File

@@ -5,7 +5,9 @@
mod hand;
mod registry;
mod trigger;
pub mod hands;
pub use hand::*;
pub use registry::*;
pub use trigger::*;
pub use hands::*;

View File

@@ -11,6 +11,8 @@ description = "ZCLAW kernel - central coordinator for all subsystems"
zclaw-types = { workspace = true }
zclaw-memory = { workspace = true }
zclaw-runtime = { workspace = true }
zclaw-protocols = { workspace = true }
zclaw-hands = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
@@ -32,3 +34,6 @@ secrecy = { workspace = true }
# Home directory
dirs = { workspace = true }
# Archive (for PPTX export)
zip = { version = "2", default-features = false, features = ["deflate"] }

View File

@@ -1,4 +1,9 @@
//! Kernel configuration
//!
//! Design principles:
//! - Model ID is passed directly to the API without any transformation
//! - No provider prefix or alias mapping
//! - Simple, unified configuration structure
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -6,6 +11,104 @@ use secrecy::SecretString;
use zclaw_types::{Result, ZclawError};
use zclaw_runtime::{LlmDriver, AnthropicDriver, OpenAiDriver, GeminiDriver, LocalDriver};
/// API protocol type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApiProtocol {
OpenAI,
Anthropic,
}
impl Default for ApiProtocol {
fn default() -> Self {
Self::OpenAI
}
}
/// LLM configuration - unified config for all providers
///
/// This is the single source of truth for LLM configuration.
/// Model ID is passed directly to the API without any transformation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
/// API base URL (e.g., "https://api.openai.com/v1")
pub base_url: String,
/// API key
#[serde(skip_serializing)]
pub api_key: String,
/// Model identifier - passed directly to the API
/// Examples: "gpt-4o", "glm-4-flash", "glm-4-plus", "claude-3-opus-20240229"
pub model: String,
/// API protocol (OpenAI-compatible or Anthropic)
#[serde(default)]
pub api_protocol: ApiProtocol,
/// Maximum tokens per response
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
/// Temperature
#[serde(default = "default_temperature")]
pub temperature: f32,
}
impl LlmConfig {
/// Create a new LLM config
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
api_key: api_key.into(),
model: model.into(),
api_protocol: ApiProtocol::OpenAI,
max_tokens: default_max_tokens(),
temperature: default_temperature(),
}
}
/// Set API protocol
pub fn with_protocol(mut self, protocol: ApiProtocol) -> Self {
self.api_protocol = protocol;
self
}
/// Set max tokens
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
self.max_tokens = max_tokens;
self
}
/// Set temperature
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = temperature;
self
}
/// Create driver from this config
pub fn create_driver(&self) -> Result<Arc<dyn LlmDriver>> {
match self.api_protocol {
ApiProtocol::Anthropic => {
if self.base_url.is_empty() {
Ok(Arc::new(AnthropicDriver::new(SecretString::new(self.api_key.clone()))))
} else {
Ok(Arc::new(AnthropicDriver::with_base_url(
SecretString::new(self.api_key.clone()),
self.base_url.clone(),
)))
}
}
ApiProtocol::OpenAI => {
Ok(Arc::new(OpenAiDriver::with_base_url(
SecretString::new(self.api_key.clone()),
self.base_url.clone(),
)))
}
}
}
}
/// Kernel configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KernelConfig {
@@ -13,33 +116,9 @@ pub struct KernelConfig {
#[serde(default = "default_database_url")]
pub database_url: String,
/// Default LLM provider
#[serde(default = "default_provider")]
pub default_provider: String,
/// Default model
#[serde(default = "default_model")]
pub default_model: String,
/// API keys (loaded from environment)
#[serde(skip)]
pub anthropic_api_key: Option<String>,
#[serde(skip)]
pub openai_api_key: Option<String>,
#[serde(skip)]
pub gemini_api_key: Option<String>,
/// Local LLM base URL
#[serde(default)]
pub local_base_url: Option<String>,
/// Maximum tokens per response
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
/// Default temperature
#[serde(default = "default_temperature")]
pub temperature: f32,
/// LLM configuration
#[serde(flatten)]
pub llm: LlmConfig,
}
fn default_database_url() -> String {
@@ -48,14 +127,6 @@ fn default_database_url() -> String {
format!("sqlite:{}/data.db?mode=rwc", dir.display())
}
fn default_provider() -> String {
"anthropic".to_string()
}
fn default_model() -> String {
"claude-sonnet-4-20250514".to_string()
}
fn default_max_tokens() -> u32 {
4096
}
@@ -68,14 +139,14 @@ impl Default for KernelConfig {
fn default() -> Self {
Self {
database_url: default_database_url(),
default_provider: default_provider(),
default_model: default_model(),
anthropic_api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
openai_api_key: std::env::var("OPENAI_API_KEY").ok(),
gemini_api_key: std::env::var("GEMINI_API_KEY").ok(),
local_base_url: None,
max_tokens: default_max_tokens(),
temperature: default_temperature(),
llm: LlmConfig {
base_url: "https://api.openai.com/v1".to_string(),
api_key: String::new(),
model: "gpt-4o-mini".to_string(),
api_protocol: ApiProtocol::OpenAI,
max_tokens: default_max_tokens(),
temperature: default_temperature(),
},
}
}
}
@@ -87,35 +158,183 @@ impl KernelConfig {
Ok(Self::default())
}
/// Create the default LLM driver
/// Create the LLM driver
pub fn create_driver(&self) -> Result<Arc<dyn LlmDriver>> {
let driver: Arc<dyn LlmDriver> = match self.default_provider.as_str() {
"anthropic" => {
let key = self.anthropic_api_key.clone()
.ok_or_else(|| ZclawError::ConfigError("ANTHROPIC_API_KEY not set".into()))?;
Arc::new(AnthropicDriver::new(SecretString::new(key)))
}
"openai" => {
let key = self.openai_api_key.clone()
.ok_or_else(|| ZclawError::ConfigError("OPENAI_API_KEY not set".into()))?;
Arc::new(OpenAiDriver::new(SecretString::new(key)))
}
"gemini" => {
let key = self.gemini_api_key.clone()
.ok_or_else(|| ZclawError::ConfigError("GEMINI_API_KEY not set".into()))?;
Arc::new(GeminiDriver::new(SecretString::new(key)))
}
"local" | "ollama" => {
let base_url = self.local_base_url.clone()
.unwrap_or_else(|| "http://localhost:11434/v1".to_string());
Arc::new(LocalDriver::new(base_url))
}
_ => {
return Err(ZclawError::ConfigError(
format!("Unknown provider: {}", self.default_provider)
));
}
};
Ok(driver)
self.llm.create_driver()
}
/// Get the model ID (passed directly to API)
pub fn model(&self) -> &str {
&self.llm.model
}
/// Get max tokens
pub fn max_tokens(&self) -> u32 {
self.llm.max_tokens
}
/// Get temperature
pub fn temperature(&self) -> f32 {
self.llm.temperature
}
}
// === Preset configurations for common providers ===
impl LlmConfig {
/// OpenAI GPT-4
pub fn openai(api_key: impl Into<String>) -> Self {
Self::new("https://api.openai.com/v1", api_key, "gpt-4o")
}
/// Anthropic Claude
pub fn anthropic(api_key: impl Into<String>) -> Self {
Self::new("https://api.anthropic.com", api_key, "claude-sonnet-4-20250514")
.with_protocol(ApiProtocol::Anthropic)
}
/// 智谱 GLM
pub fn zhipu(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new("https://open.bigmodel.cn/api/paas/v4", api_key, model)
}
/// 智谱 GLM Coding Plan
pub fn zhipu_coding(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new("https://open.bigmodel.cn/api/coding/paas/v4", api_key, model)
}
/// Kimi (Moonshot)
pub fn kimi(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new("https://api.moonshot.cn/v1", api_key, model)
}
/// Kimi Coding Plan
pub fn kimi_coding(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new("https://api.kimi.com/coding/v1", api_key, model)
}
/// 阿里云百炼 (Qwen)
pub fn qwen(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new("https://dashscope.aliyuncs.com/compatible-mode/v1", api_key, model)
}
/// 阿里云百炼 Coding Plan
pub fn qwen_coding(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new("https://coding.dashscope.aliyuncs.com/v1", api_key, model)
}
/// DeepSeek
pub fn deepseek(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self::new("https://api.deepseek.com/v1", api_key, model)
}
/// Ollama / Local
pub fn local(base_url: impl Into<String>, model: impl Into<String>) -> Self {
Self::new(base_url, "", model)
}
}
// === Backward compatibility ===
/// Provider type for backward compatibility
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provider {
OpenAI,
Anthropic,
Gemini,
Zhipu,
Kimi,
Qwen,
DeepSeek,
Local,
Custom,
}
impl KernelConfig {
/// Create config from provider type (for backward compatibility with Tauri commands)
pub fn from_provider(
provider: &str,
api_key: &str,
model: &str,
base_url: Option<&str>,
api_protocol: &str,
) -> Self {
let llm = match provider {
"anthropic" => LlmConfig::anthropic(api_key).with_model(model),
"openai" => {
if let Some(url) = base_url.filter(|u| !u.is_empty()) {
LlmConfig::new(url, api_key, model)
} else {
LlmConfig::openai(api_key).with_model(model)
}
}
"gemini" => LlmConfig::new(
base_url.unwrap_or("https://generativelanguage.googleapis.com/v1beta"),
api_key,
model,
),
"zhipu" => {
let url = base_url.unwrap_or("https://open.bigmodel.cn/api/paas/v4");
LlmConfig::zhipu(api_key, model).with_base_url(url)
}
"zhipu-coding" => {
let url = base_url.unwrap_or("https://open.bigmodel.cn/api/coding/paas/v4");
LlmConfig::zhipu_coding(api_key, model).with_base_url(url)
}
"kimi" => {
let url = base_url.unwrap_or("https://api.moonshot.cn/v1");
LlmConfig::kimi(api_key, model).with_base_url(url)
}
"kimi-coding" => {
let url = base_url.unwrap_or("https://api.kimi.com/coding/v1");
LlmConfig::kimi_coding(api_key, model).with_base_url(url)
}
"qwen" => {
let url = base_url.unwrap_or("https://dashscope.aliyuncs.com/compatible-mode/v1");
LlmConfig::qwen(api_key, model).with_base_url(url)
}
"qwen-coding" => {
let url = base_url.unwrap_or("https://coding.dashscope.aliyuncs.com/v1");
LlmConfig::qwen_coding(api_key, model).with_base_url(url)
}
"deepseek" => LlmConfig::deepseek(api_key, model),
"local" | "ollama" => {
let url = base_url.unwrap_or("http://localhost:11434/v1");
LlmConfig::local(url, model)
}
_ => {
// Custom provider
let protocol = if api_protocol == "anthropic" {
ApiProtocol::Anthropic
} else {
ApiProtocol::OpenAI
};
LlmConfig::new(
base_url.unwrap_or("https://api.openai.com/v1"),
api_key,
model,
)
.with_protocol(protocol)
}
};
Self {
database_url: default_database_url(),
llm,
}
}
}
impl LlmConfig {
/// Set model
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}
/// Set base URL
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
}

View File

@@ -0,0 +1,907 @@
//! Director - Multi-Agent Orchestration
//!
//! The Director manages multi-agent conversations by:
//! - Determining which agent speaks next
//! - Managing conversation state and turn order
//! - Supporting multiple scheduling strategies
//! - Coordinating agent responses
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::{RwLock, Mutex, mpsc};
use zclaw_types::{AgentId, Result, ZclawError};
use zclaw_protocols::{A2aEnvelope, A2aMessageType, A2aRecipient, A2aRouter, A2aAgentProfile, A2aCapability};
use zclaw_runtime::{LlmDriver, CompletionRequest};
/// Director configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectorConfig {
/// Maximum turns before ending conversation
pub max_turns: usize,
/// Scheduling strategy
pub strategy: ScheduleStrategy,
/// Whether to include user in the loop
pub include_user: bool,
/// Timeout for agent response (seconds)
pub response_timeout: u64,
/// Whether to allow parallel agent responses
pub allow_parallel: bool,
}
impl Default for DirectorConfig {
fn default() -> Self {
Self {
max_turns: 50,
strategy: ScheduleStrategy::Priority,
include_user: true,
response_timeout: 30,
allow_parallel: false,
}
}
}
/// Scheduling strategy for determining next speaker
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScheduleStrategy {
/// Round-robin through all agents
RoundRobin,
/// Priority-based selection (higher priority speaks first)
Priority,
/// LLM decides who speaks next
LlmDecision,
/// Random selection
Random,
/// Manual (external controller decides)
Manual,
}
/// Agent role in the conversation
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum AgentRole {
/// Main teacher/instructor
Teacher,
/// Teaching assistant
Assistant,
/// Student participant
Student,
/// Moderator/facilitator
Moderator,
/// Expert consultant
Expert,
/// Observer (receives messages but doesn't speak)
Observer,
}
impl AgentRole {
/// Get default priority for this role
pub fn default_priority(&self) -> u8 {
match self {
AgentRole::Teacher => 10,
AgentRole::Moderator => 9,
AgentRole::Expert => 8,
AgentRole::Assistant => 7,
AgentRole::Student => 5,
AgentRole::Observer => 0,
}
}
}
/// Agent configuration for director
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectorAgent {
/// Agent ID
pub id: AgentId,
/// Display name
pub name: String,
/// Agent role
pub role: AgentRole,
/// Priority (higher = speaks first)
pub priority: u8,
/// System prompt / persona
pub persona: String,
/// Whether this agent is active
pub active: bool,
/// Maximum turns this agent can speak consecutively
pub max_consecutive_turns: usize,
}
impl DirectorAgent {
/// Create a new director agent
pub fn new(id: AgentId, name: impl Into<String>, role: AgentRole, persona: impl Into<String>) -> Self {
let priority = role.default_priority();
Self {
id,
name: name.into(),
role,
priority,
persona: persona.into(),
active: true,
max_consecutive_turns: 2,
}
}
}
/// Conversation state
#[derive(Debug, Clone, Default)]
pub struct ConversationState {
/// Current turn number
pub turn: usize,
/// Current speaker ID
pub current_speaker: Option<AgentId>,
/// Turn history (agent_id, message_summary)
pub history: Vec<(AgentId, String)>,
/// Consecutive turns by current agent
pub consecutive_turns: usize,
/// Whether conversation is active
pub active: bool,
/// Conversation topic/goal
pub topic: Option<String>,
}
impl ConversationState {
/// Create new conversation state
pub fn new() -> Self {
Self {
turn: 0,
current_speaker: None,
history: Vec::new(),
consecutive_turns: 0,
active: false,
topic: None,
}
}
/// Record a turn
pub fn record_turn(&mut self, agent_id: AgentId, summary: String) {
if self.current_speaker == Some(agent_id) {
self.consecutive_turns += 1;
} else {
self.consecutive_turns = 1;
self.current_speaker = Some(agent_id);
}
self.history.push((agent_id, summary));
self.turn += 1;
}
/// Get last N turns
pub fn get_recent_history(&self, n: usize) -> &[(AgentId, String)] {
let start = self.history.len().saturating_sub(n);
&self.history[start..]
}
/// Check if agent has spoken too many consecutive turns
pub fn is_over_consecutive_limit(&self, agent_id: &AgentId, max: usize) -> bool {
if self.current_speaker == Some(*agent_id) {
self.consecutive_turns >= max
} else {
false
}
}
}
/// The Director orchestrates multi-agent conversations
pub struct Director {
/// Director configuration
config: DirectorConfig,
/// Registered agents
agents: Arc<RwLock<Vec<DirectorAgent>>>,
/// Conversation state
state: Arc<RwLock<ConversationState>>,
/// A2A router for messaging
router: Arc<A2aRouter>,
/// Agent ID for the director itself
director_id: AgentId,
/// Optional LLM driver for intelligent scheduling
llm_driver: Option<Arc<dyn LlmDriver>>,
/// Inbox for receiving responses (stores pending request IDs and their response channels)
pending_requests: Arc<Mutex<std::collections::HashMap<String, mpsc::Sender<A2aEnvelope>>>>,
/// Receiver for incoming messages
inbox: Arc<Mutex<Option<mpsc::Receiver<A2aEnvelope>>>>,
}
impl Director {
/// Create a new director
pub fn new(config: DirectorConfig) -> Self {
let director_id = AgentId::new();
let router = Arc::new(A2aRouter::new(director_id.clone()));
Self {
config,
agents: Arc::new(RwLock::new(Vec::new())),
state: Arc::new(RwLock::new(ConversationState::new())),
router,
director_id,
llm_driver: None,
pending_requests: Arc::new(Mutex::new(std::collections::HashMap::new())),
inbox: Arc::new(Mutex::new(None)),
}
}
/// Create director with existing router
pub fn with_router(config: DirectorConfig, router: Arc<A2aRouter>) -> Self {
let director_id = AgentId::new();
Self {
config,
agents: Arc::new(RwLock::new(Vec::new())),
state: Arc::new(RwLock::new(ConversationState::new())),
router,
director_id,
llm_driver: None,
pending_requests: Arc::new(Mutex::new(std::collections::HashMap::new())),
inbox: Arc::new(Mutex::new(None)),
}
}
/// Initialize the director's inbox (must be called after creation)
pub async fn initialize(&self) -> Result<()> {
let profile = A2aAgentProfile {
id: self.director_id.clone(),
name: "Director".to_string(),
description: "Multi-agent conversation orchestrator".to_string(),
capabilities: vec![A2aCapability {
name: "orchestration".to_string(),
description: "Multi-agent conversation management".to_string(),
input_schema: None,
output_schema: None,
requires_approval: false,
version: "1.0.0".to_string(),
tags: vec!["orchestration".to_string()],
}],
protocols: vec!["a2a".to_string()],
role: "orchestrator".to_string(),
priority: 10,
metadata: Default::default(),
groups: vec![],
last_seen: 0,
};
let rx = self.router.register_agent(profile).await;
*self.inbox.lock().await = Some(rx);
Ok(())
}
/// Set LLM driver for intelligent scheduling
pub fn with_llm_driver(mut self, driver: Arc<dyn LlmDriver>) -> Self {
self.llm_driver = Some(driver);
self
}
/// Set LLM driver (mutable)
pub fn set_llm_driver(&mut self, driver: Arc<dyn LlmDriver>) {
self.llm_driver = Some(driver);
}
/// Register an agent
pub async fn register_agent(&self, agent: DirectorAgent) {
let mut agents = self.agents.write().await;
agents.push(agent);
// Sort by priority (descending)
agents.sort_by(|a, b| b.priority.cmp(&a.priority));
}
/// Remove an agent
pub async fn remove_agent(&self, agent_id: &AgentId) {
let mut agents = self.agents.write().await;
agents.retain(|a| &a.id != agent_id);
}
/// Get all registered agents
pub async fn get_agents(&self) -> Vec<DirectorAgent> {
self.agents.read().await.clone()
}
/// Get active agents sorted by priority
pub async fn get_active_agents(&self) -> Vec<DirectorAgent> {
self.agents
.read()
.await
.iter()
.filter(|a| a.active)
.cloned()
.collect()
}
/// Start a new conversation
pub async fn start_conversation(&self, topic: Option<String>) {
let mut state = self.state.write().await;
state.turn = 0;
state.current_speaker = None;
state.history.clear();
state.consecutive_turns = 0;
state.active = true;
state.topic = topic;
}
/// End the conversation
pub async fn end_conversation(&self) {
let mut state = self.state.write().await;
state.active = false;
}
/// Get current conversation state
pub async fn get_state(&self) -> ConversationState {
self.state.read().await.clone()
}
/// Select the next speaker based on strategy
pub async fn select_next_speaker(&self) -> Option<DirectorAgent> {
let agents = self.get_active_agents().await;
let state = self.state.read().await;
if agents.is_empty() || state.turn >= self.config.max_turns {
return None;
}
match self.config.strategy {
ScheduleStrategy::RoundRobin => {
// Round-robin through active agents
let idx = state.turn % agents.len();
Some(agents[idx].clone())
}
ScheduleStrategy::Priority => {
// Select highest priority agent that hasn't exceeded consecutive limit
for agent in &agents {
if !state.is_over_consecutive_limit(&agent.id, agent.max_consecutive_turns) {
return Some(agent.clone());
}
}
// If all exceeded, pick the highest priority anyway
agents.first().cloned()
}
ScheduleStrategy::Random => {
// Random selection
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let idx = (now as usize) % agents.len();
Some(agents[idx].clone())
}
ScheduleStrategy::LlmDecision => {
// LLM-based decision making
self.select_speaker_with_llm(&agents, &state).await
.or_else(|| agents.first().cloned())
}
ScheduleStrategy::Manual => {
// External controller decides
None
}
}
}
/// Use LLM to select the next speaker
async fn select_speaker_with_llm(
&self,
agents: &[DirectorAgent],
state: &ConversationState,
) -> Option<DirectorAgent> {
let driver = self.llm_driver.as_ref()?;
// Build context for LLM decision
let agent_descriptions: String = agents
.iter()
.enumerate()
.map(|(i, a)| format!("{}. {} ({}) - {}", i + 1, a.name, a.role.as_str(), a.persona))
.collect::<Vec<_>>()
.join("\n");
let recent_history: String = state
.get_recent_history(5)
.iter()
.map(|(id, msg)| {
let agent = agents.iter().find(|a| &a.id == id);
let name = agent.map(|a| a.name.as_str()).unwrap_or("Unknown");
format!("- {}: {}", name, msg)
})
.collect::<Vec<_>>()
.join("\n");
let topic = state.topic.as_deref().unwrap_or("General discussion");
let prompt = format!(
r#"You are a conversation director. Select the best agent to speak next.
Topic: {}
Available Agents:
{}
Recent Conversation:
{}
Current turn: {}
Last speaker: {}
Instructions:
1. Consider the conversation flow and topic
2. Choose the agent who should speak next to advance the conversation
3. Avoid having the same agent speak too many times consecutively
4. Consider which role would be most valuable at this point
Respond with ONLY the number (1-{}) of the agent who should speak next. No explanation."#,
topic,
agent_descriptions,
recent_history,
state.turn,
state.current_speaker
.and_then(|id| agents.iter().find(|a| a.id == id))
.map(|a| &a.name)
.unwrap_or(&"None".to_string()),
agents.len()
);
let request = CompletionRequest {
model: "default".to_string(),
system: Some("You are a conversation director. You respond with only a single number.".to_string()),
messages: vec![zclaw_types::Message::User { content: prompt }],
tools: vec![],
max_tokens: Some(10),
temperature: Some(0.3),
stop: vec![],
stream: false,
};
match driver.complete(request).await {
Ok(response) => {
// Extract text from response
let text = response.content.iter()
.filter_map(|block| match block {
zclaw_runtime::ContentBlock::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
// Parse the number
if let Ok(idx) = text.trim().parse::<usize>() {
if idx >= 1 && idx <= agents.len() {
return Some(agents[idx - 1].clone());
}
}
// Fallback to first agent
agents.first().cloned()
}
Err(e) => {
tracing::warn!("LLM speaker selection failed: {}", e);
agents.first().cloned()
}
}
}
/// Send message to selected agent and wait for response
pub async fn send_to_agent(
&self,
agent: &DirectorAgent,
message: String,
) -> Result<String> {
// Create a response channel for this request
let (_response_tx, mut _response_rx) = mpsc::channel::<A2aEnvelope>(1);
let envelope = A2aEnvelope::new(
self.director_id.clone(),
A2aRecipient::Direct { agent_id: agent.id.clone() },
A2aMessageType::Request,
serde_json::json!({
"message": message,
"persona": agent.persona.clone(),
"role": agent.role.clone(),
}),
);
// Store the request ID with its response channel
let request_id = envelope.id.clone();
{
let mut pending = self.pending_requests.lock().await;
pending.insert(request_id.clone(), _response_tx);
}
// Send the request
self.router.route(envelope).await?;
// Wait for response with timeout
let timeout_duration = std::time::Duration::from_secs(self.config.response_timeout);
let request_id_clone = request_id.clone();
let response = tokio::time::timeout(timeout_duration, async {
// Poll the inbox for responses
let mut inbox_guard = self.inbox.lock().await;
if let Some(ref mut rx) = *inbox_guard {
while let Some(msg) = rx.recv().await {
// Check if this is a response to our request
if msg.message_type == A2aMessageType::Response {
if let Some(ref reply_to) = msg.reply_to {
if reply_to == &request_id_clone {
// Found our response
return Some(msg);
}
}
}
// Not our response, continue waiting
// (In a real implementation, we'd re-queue non-matching messages)
}
}
None
}).await;
// Clean up pending request
{
let mut pending = self.pending_requests.lock().await;
pending.remove(&request_id);
}
match response {
Ok(Some(envelope)) => {
// Extract response text from payload
let response_text = envelope.payload
.get("response")
.and_then(|v: &serde_json::Value| v.as_str())
.unwrap_or(&format!("[{}] Response from {}", agent.role.as_str(), agent.name))
.to_string();
Ok(response_text)
}
Ok(None) => {
Err(ZclawError::Timeout("No response received".into()))
}
Err(_) => {
Err(ZclawError::Timeout(format!(
"Agent {} did not respond within {} seconds",
agent.name, self.config.response_timeout
)))
}
}
}
/// Broadcast message to all agents
pub async fn broadcast(&self, message: String) -> Result<()> {
let envelope = A2aEnvelope::new(
self.director_id,
A2aRecipient::Broadcast,
A2aMessageType::Notification,
serde_json::json!({ "message": message }),
);
self.router.route(envelope).await
}
/// Run one turn of the conversation
pub async fn run_turn(&self, input: Option<String>) -> Result<Option<DirectorAgent>> {
let state = self.state.read().await;
if !state.active {
return Err(ZclawError::InvalidInput("Conversation not active".into()));
}
drop(state);
// Select next speaker
let speaker = self.select_next_speaker().await;
if let Some(ref agent) = speaker {
// Build context from recent history
let state = self.state.read().await;
let context = Self::build_context(&state, &input);
// Send message to agent
let response = self.send_to_agent(agent, context).await?;
// Update state
let mut state = self.state.write().await;
let summary = if response.len() > 100 {
format!("{}...", &response[..100])
} else {
response
};
state.record_turn(agent.id, summary);
}
Ok(speaker)
}
/// Build context string for agent
fn build_context(state: &ConversationState, input: &Option<String>) -> String {
let mut context = String::new();
if let Some(ref topic) = state.topic {
context.push_str(&format!("Topic: {}\n\n", topic));
}
if let Some(ref user_input) = input {
context.push_str(&format!("User: {}\n\n", user_input));
}
// Add recent history
if !state.history.is_empty() {
context.push_str("Recent conversation:\n");
for (agent_id, summary) in state.get_recent_history(5) {
context.push_str(&format!("- {}: {}\n", agent_id, summary));
}
}
context
}
/// Run full conversation until complete
pub async fn run_conversation(
&self,
topic: String,
initial_input: Option<String>,
) -> Result<Vec<(AgentId, String)>> {
self.start_conversation(Some(topic.clone())).await;
let mut input = initial_input;
let mut results = Vec::new();
loop {
let state = self.state.read().await;
// Check termination conditions
if state.turn >= self.config.max_turns {
break;
}
if !state.active {
break;
}
drop(state);
// Run one turn
match self.run_turn(input.take()).await {
Ok(Some(_agent)) => {
let state = self.state.read().await;
if let Some((agent_id, summary)) = state.history.last() {
results.push((*agent_id, summary.clone()));
}
}
Ok(None) => {
// Manual mode or no speaker selected
break;
}
Err(e) => {
tracing::error!("Turn error: {}", e);
break;
}
}
// In a real implementation, we would wait for user input here
// if config.include_user is true
}
self.end_conversation().await;
Ok(results)
}
/// Get the director's agent ID
pub fn director_id(&self) -> &AgentId {
&self.director_id
}
}
impl AgentRole {
/// Get role as string
pub fn as_str(&self) -> &'static str {
match self {
AgentRole::Teacher => "teacher",
AgentRole::Assistant => "assistant",
AgentRole::Student => "student",
AgentRole::Moderator => "moderator",
AgentRole::Expert => "expert",
AgentRole::Observer => "observer",
}
}
/// Parse role from string
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"teacher" | "instructor" => Some(AgentRole::Teacher),
"assistant" | "ta" => Some(AgentRole::Assistant),
"student" => Some(AgentRole::Student),
"moderator" | "facilitator" => Some(AgentRole::Moderator),
"expert" | "consultant" => Some(AgentRole::Expert),
"observer" => Some(AgentRole::Observer),
_ => None,
}
}
}
/// Builder for creating director configurations
pub struct DirectorBuilder {
config: DirectorConfig,
agents: Vec<DirectorAgent>,
}
impl DirectorBuilder {
/// Create a new builder
pub fn new() -> Self {
Self {
config: DirectorConfig::default(),
agents: Vec::new(),
}
}
/// Set scheduling strategy
pub fn strategy(mut self, strategy: ScheduleStrategy) -> Self {
self.config.strategy = strategy;
self
}
/// Set max turns
pub fn max_turns(mut self, max_turns: usize) -> Self {
self.config.max_turns = max_turns;
self
}
/// Include user in conversation
pub fn include_user(mut self, include: bool) -> Self {
self.config.include_user = include;
self
}
/// Add a teacher agent
pub fn teacher(mut self, id: AgentId, name: impl Into<String>, persona: impl Into<String>) -> Self {
let mut agent = DirectorAgent::new(id, name, AgentRole::Teacher, persona);
agent.priority = 10;
self.agents.push(agent);
self
}
/// Add an assistant agent
pub fn assistant(mut self, id: AgentId, name: impl Into<String>, persona: impl Into<String>) -> Self {
let mut agent = DirectorAgent::new(id, name, AgentRole::Assistant, persona);
agent.priority = 7;
self.agents.push(agent);
self
}
/// Add a student agent
pub fn student(mut self, id: AgentId, name: impl Into<String>, persona: impl Into<String>) -> Self {
let mut agent = DirectorAgent::new(id, name, AgentRole::Student, persona);
agent.priority = 5;
self.agents.push(agent);
self
}
/// Add a custom agent
pub fn agent(mut self, agent: DirectorAgent) -> Self {
self.agents.push(agent);
self
}
/// Build the director
pub async fn build(self) -> Director {
let director = Director::new(self.config);
for agent in self.agents {
director.register_agent(agent).await;
}
director
}
}
impl Default for DirectorBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_director_creation() {
let director = Director::new(DirectorConfig::default());
let agents = director.get_agents().await;
assert!(agents.is_empty());
}
#[tokio::test]
async fn test_register_agents() {
let director = Director::new(DirectorConfig::default());
director.register_agent(DirectorAgent::new(
AgentId::new(),
"Teacher",
AgentRole::Teacher,
"You are a helpful teacher.",
)).await;
director.register_agent(DirectorAgent::new(
AgentId::new(),
"Student",
AgentRole::Student,
"You are a curious student.",
)).await;
let agents = director.get_agents().await;
assert_eq!(agents.len(), 2);
// Teacher should be first (higher priority)
assert_eq!(agents[0].role, AgentRole::Teacher);
}
#[tokio::test]
async fn test_conversation_state() {
let mut state = ConversationState::new();
assert_eq!(state.turn, 0);
let agent1 = AgentId::new();
let agent2 = AgentId::new();
state.record_turn(agent1, "Hello".to_string());
assert_eq!(state.turn, 1);
assert_eq!(state.consecutive_turns, 1);
state.record_turn(agent1, "World".to_string());
assert_eq!(state.turn, 2);
assert_eq!(state.consecutive_turns, 2);
state.record_turn(agent2, "Goodbye".to_string());
assert_eq!(state.turn, 3);
assert_eq!(state.consecutive_turns, 1);
assert_eq!(state.current_speaker, Some(agent2));
}
#[tokio::test]
async fn test_select_next_speaker_priority() {
let config = DirectorConfig {
strategy: ScheduleStrategy::Priority,
..Default::default()
};
let director = Director::new(config);
let teacher_id = AgentId::new();
let student_id = AgentId::new();
director.register_agent(DirectorAgent::new(
teacher_id,
"Teacher",
AgentRole::Teacher,
"Teaching",
)).await;
director.register_agent(DirectorAgent::new(
student_id,
"Student",
AgentRole::Student,
"Learning",
)).await;
let speaker = director.select_next_speaker().await;
assert!(speaker.is_some());
assert_eq!(speaker.unwrap().role, AgentRole::Teacher);
}
#[tokio::test]
async fn test_director_builder() {
let director = DirectorBuilder::new()
.strategy(ScheduleStrategy::RoundRobin)
.max_turns(10)
.teacher(AgentId::new(), "AI Teacher", "You teach students.")
.student(AgentId::new(), "Curious Student", "You ask questions.")
.build()
.await;
let agents = director.get_agents().await;
assert_eq!(agents.len(), 2);
let state = director.get_state().await;
assert_eq!(state.turn, 0);
}
#[test]
fn test_agent_role_priority() {
assert_eq!(AgentRole::Teacher.default_priority(), 10);
assert_eq!(AgentRole::Assistant.default_priority(), 7);
assert_eq!(AgentRole::Student.default_priority(), 5);
assert_eq!(AgentRole::Observer.default_priority(), 0);
}
#[test]
fn test_agent_role_parse() {
assert_eq!(AgentRole::from_str("teacher"), Some(AgentRole::Teacher));
assert_eq!(AgentRole::from_str("STUDENT"), Some(AgentRole::Student));
assert_eq!(AgentRole::from_str("unknown"), None);
}
}

View File

@@ -0,0 +1,822 @@
//! HTML Exporter - Interactive web-based classroom export
//!
//! Generates a self-contained HTML file with:
//! - Responsive layout
//! - Scene navigation
//! - Speaker notes toggle
//! - Table of contents
//! - Embedded CSS/JS
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction};
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
use zclaw_types::Result;
use zclaw_types::ZclawError;
/// HTML exporter
pub struct HtmlExporter {
/// Template name
template: String,
}
impl HtmlExporter {
/// Create new HTML exporter
pub fn new() -> Self {
Self {
template: "default".to_string(),
}
}
/// Create with specific template
pub fn with_template(template: &str) -> Self {
Self {
template: template.to_string(),
}
}
/// Generate HTML content
fn generate_html(&self, classroom: &Classroom, options: &ExportOptions) -> Result<String> {
let mut html = String::new();
// HTML header
html.push_str(&self.generate_header(classroom, options));
// Body content
html.push_str("<body>\n");
html.push_str(&self.generate_body_start(classroom, options));
// Title slide
if options.title_slide {
html.push_str(&self.generate_title_slide(classroom));
}
// Table of contents
if options.table_of_contents {
html.push_str(&self.generate_toc(classroom));
}
// Scenes
html.push_str("<main class=\"scenes\">\n");
for scene in &classroom.scenes {
html.push_str(&self.generate_scene(scene, options));
}
html.push_str("</main>\n");
// Footer
html.push_str(&self.generate_footer(classroom));
html.push_str(&self.generate_body_end());
html.push_str("</body>\n</html>");
Ok(html)
}
/// Generate HTML header with embedded CSS
fn generate_header(&self, classroom: &Classroom, options: &ExportOptions) -> String {
let custom_css = options.custom_css.as_deref().unwrap_or("");
format!(
r#"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
{default_css}
{custom_css}
</style>
</head>
"#,
title = html_escape(&classroom.title),
default_css = get_default_css(),
custom_css = custom_css,
)
}
/// Generate body start with navigation
fn generate_body_start(&self, classroom: &Classroom, _options: &ExportOptions) -> String {
format!(
r#"
<nav class="top-nav">
<div class="nav-brand">{title}</div>
<div class="nav-controls">
<button id="toggle-notes" class="btn">Notes</button>
<button id="toggle-toc" class="btn">Contents</button>
<button id="prev-scene" class="btn">← Prev</button>
<span id="scene-counter">1 / {total}</span>
<button id="next-scene" class="btn">Next →</button>
</div>
</nav>
"#,
title = html_escape(&classroom.title),
total = classroom.scenes.len(),
)
}
/// Generate title slide
fn generate_title_slide(&self, classroom: &Classroom) -> String {
format!(
r#"
<section class="scene title-slide" id="scene-0">
<div class="scene-content">
<h1>{title}</h1>
<p class="description">{description}</p>
<div class="meta">
<span class="topic">{topic}</span>
<span class="level">{level}</span>
<span class="duration">{duration}</span>
</div>
<div class="objectives">
<h3>Learning Objectives</h3>
<ul>
{objectives}
</ul>
</div>
</div>
</section>
"#,
title = html_escape(&classroom.title),
description = html_escape(&classroom.description),
topic = html_escape(&classroom.topic),
level = format_level(&classroom.level),
duration = format_duration(classroom.total_duration),
objectives = classroom.objectives.iter()
.map(|o| format!(" <li>{}</li>", html_escape(o)))
.collect::<Vec<_>>()
.join("\n"),
)
}
/// Generate table of contents
fn generate_toc(&self, classroom: &Classroom) -> String {
let items: String = classroom.scenes.iter()
.enumerate()
.map(|(i, scene)| {
format!(
" <li><a href=\"#scene-{}\">{}</a></li>",
i + 1,
html_escape(&scene.content.title)
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
r#"
<aside class="toc" id="toc-panel">
<h2>Contents</h2>
<ol>
{}
</ol>
</aside>
"#,
items
)
}
/// Generate a single scene
fn generate_scene(&self, scene: &GeneratedScene, options: &ExportOptions) -> String {
let notes_html = if options.include_notes {
scene.content.notes.as_ref()
.map(|n| format!(
r#" <aside class="speaker-notes">{}</aside>"#,
html_escape(n)
))
.unwrap_or_default()
} else {
String::new()
};
let actions_html = self.generate_actions(&scene.content.actions);
format!(
r#"
<section class="scene scene-{type}" id="scene-{order}" data-duration="{duration}">
<div class="scene-header">
<h2>{title}</h2>
<span class="scene-type">{type}</span>
</div>
<div class="scene-body">
{content}
{actions}
</div>
{notes}
</section>
"#,
type = format_scene_type(&scene.content.scene_type),
order = scene.order + 1,
duration = scene.content.duration_seconds,
title = html_escape(&scene.content.title),
content = self.format_scene_content(&scene.content),
actions = actions_html,
notes = notes_html,
)
}
/// Format scene content based on type
fn format_scene_content(&self, content: &SceneContent) -> String {
match content.scene_type {
SceneType::Slide => {
if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) {
format!("<p class=\"slide-description\">{}</p>", html_escape(desc))
} else {
String::new()
}
}
SceneType::Quiz => {
let questions = content.content.get("questions")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|q| {
let text = q.get("text").and_then(|t| t.as_str()).unwrap_or("");
Some(format!("<li>{}</li>", html_escape(text)))
})
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
format!(
r#"<div class="quiz-questions"><ol>{}</ol></div>"#,
questions
)
}
SceneType::Discussion => {
if let Some(topic) = content.content.get("discussion_topic").and_then(|v| v.as_str()) {
format!("<p class=\"discussion-topic\">Discussion: {}</p>", html_escape(topic))
} else {
String::new()
}
}
_ => {
if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) {
format!("<p>{}</p>", html_escape(desc))
} else {
String::new()
}
}
}
}
/// Generate actions section
fn generate_actions(&self, actions: &[SceneAction]) -> String {
if actions.is_empty() {
return String::new();
}
let actions_html: String = actions.iter()
.filter_map(|action| match action {
SceneAction::Speech { text, agent_role } => Some(format!(
r#" <div class="action speech" data-role="{}">
<span class="role">{}</span>
<p>{}</p>
</div>"#,
html_escape(agent_role),
html_escape(agent_role),
html_escape(text)
)),
SceneAction::WhiteboardDrawText { text, .. } => Some(format!(
r#" <div class="action whiteboard-text">
<span class="label">Whiteboard:</span>
<code>{}</code>
</div>"#,
html_escape(text)
)),
SceneAction::WhiteboardDrawShape { shape, .. } => Some(format!(
r#" <div class="action whiteboard-shape">
<span class="label">Draw:</span>
<span>{}</span>
</div>"#,
html_escape(shape)
)),
SceneAction::QuizShow { quiz_id } => Some(format!(
r#" <div class="action quiz-show" data-quiz-id="{}">
<span class="label">Quiz:</span>
<span>{}</span>
</div>"#,
html_escape(quiz_id),
html_escape(quiz_id)
)),
SceneAction::Discussion { topic, duration_seconds } => Some(format!(
r#" <div class="action discussion">
<span class="label">Discussion:</span>
<span>{}</span>
<span class="duration">({}s)</span>
</div>"#,
html_escape(topic),
duration_seconds.unwrap_or(300)
)),
_ => None,
})
.collect();
if actions_html.is_empty() {
String::new()
} else {
format!(
r#"<div class="actions">
{}
</div>"#,
actions_html
)
}
}
/// Generate footer
fn generate_footer(&self, classroom: &Classroom) -> String {
format!(
r#"
<footer class="classroom-footer">
<p>Generated by ZCLAW</p>
<p>Topic: {topic} | Duration: {duration} | Style: {style}</p>
</footer>
"#,
topic = html_escape(&classroom.topic),
duration = format_duration(classroom.total_duration),
style = format_style(&classroom.style),
)
}
/// Generate body end with JavaScript
fn generate_body_end(&self) -> String {
format!(
r#"
<script>
{js}
</script>
"#,
js = get_default_js()
)
}
}
impl Default for HtmlExporter {
fn default() -> Self {
Self::new()
}
}
impl Exporter for HtmlExporter {
fn export(&self, classroom: &Classroom, options: &ExportOptions) -> Result<ExportResult> {
let html = self.generate_html(classroom, options)?;
let filename = format!("{}.html", sanitize_filename(&classroom.title));
Ok(ExportResult {
content: html.into_bytes(),
mime_type: "text/html".to_string(),
filename,
extension: "html".to_string(),
})
}
fn format(&self) -> super::ExportFormat {
super::ExportFormat::Html
}
fn extension(&self) -> &str {
"html"
}
fn mime_type(&self) -> &str {
"text/html"
}
}
// Helper functions
/// Escape HTML special characters
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// Format duration in minutes
fn format_duration(seconds: u32) -> String {
let minutes = seconds / 60;
let secs = seconds % 60;
if secs > 0 {
format!("{}m {}s", minutes, secs)
} else {
format!("{}m", minutes)
}
}
/// Format difficulty level
fn format_level(level: &crate::generation::DifficultyLevel) -> String {
match level {
crate::generation::DifficultyLevel::Beginner => "Beginner",
crate::generation::DifficultyLevel::Intermediate => "Intermediate",
crate::generation::DifficultyLevel::Advanced => "Advanced",
crate::generation::DifficultyLevel::Expert => "Expert",
}.to_string()
}
/// Format teaching style
fn format_style(style: &crate::generation::TeachingStyle) -> String {
match style {
crate::generation::TeachingStyle::Lecture => "Lecture",
crate::generation::TeachingStyle::Discussion => "Discussion",
crate::generation::TeachingStyle::Pbl => "Project-Based",
crate::generation::TeachingStyle::Flipped => "Flipped Classroom",
crate::generation::TeachingStyle::Socratic => "Socratic",
}.to_string()
}
/// Format scene type
fn format_scene_type(scene_type: &SceneType) -> String {
match scene_type {
SceneType::Slide => "slide",
SceneType::Quiz => "quiz",
SceneType::Interactive => "interactive",
SceneType::Pbl => "pbl",
SceneType::Discussion => "discussion",
SceneType::Media => "media",
SceneType::Text => "text",
}.to_string()
}
/// Get default CSS styles
fn get_default_css() -> &'static str {
r#"
:root {
--primary: #3b82f6;
--secondary: #64748b;
--background: #f8fafc;
--surface: #ffffff;
--text: #1e293b;
--border: #e2e8f0;
--accent: #10b981;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--background);
color: var(--text);
line-height: 1.6;
}
.top-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 100;
}
.nav-brand {
font-weight: 600;
font-size: 18px;
}
.nav-controls {
display: flex;
gap: 12px;
align-items: center;
}
.btn {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.btn:hover {
background: var(--background);
}
.scenes {
margin-top: 80px;
padding: 24px;
max-width: 900px;
margin-left: auto;
margin-right: auto;
}
.scene {
background: var(--surface);
border-radius: 12px;
padding: 32px;
margin-bottom: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.title-slide {
text-align: center;
padding: 64px 32px;
}
.title-slide h1 {
font-size: 36px;
margin-bottom: 16px;
}
.scene-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.scene-header h2 {
font-size: 24px;
}
.scene-type {
padding: 4px 12px;
background: var(--primary);
color: white;
border-radius: 4px;
font-size: 12px;
text-transform: uppercase;
}
.scene-body {
font-size: 16px;
}
.actions {
margin-top: 24px;
padding: 16px;
background: var(--background);
border-radius: 8px;
}
.action {
padding: 12px;
margin-bottom: 8px;
background: var(--surface);
border-radius: 6px;
}
.action .role {
font-weight: 600;
color: var(--primary);
text-transform: capitalize;
}
.speaker-notes {
margin-top: 24px;
padding: 16px;
background: #fef3c7;
border-left: 4px solid #f59e0b;
border-radius: 4px;
display: none;
}
.speaker-notes.visible {
display: block;
}
.toc {
position: fixed;
top: 60px;
right: -300px;
width: 280px;
height: calc(100vh - 60px);
background: var(--surface);
border-left: 1px solid var(--border);
padding: 24px;
overflow-y: auto;
transition: right 0.3s ease;
z-index: 99;
}
.toc.visible {
right: 0;
}
.toc ol {
list-style: decimal;
padding-left: 20px;
}
.toc li {
margin-bottom: 8px;
}
.toc a {
color: var(--text);
text-decoration: none;
}
.toc a:hover {
color: var(--primary);
}
.classroom-footer {
text-align: center;
padding: 32px;
color: var(--secondary);
font-size: 14px;
}
.meta {
display: flex;
gap: 16px;
justify-content: center;
margin: 16px 0;
}
.meta span {
padding: 4px 12px;
background: var(--background);
border-radius: 4px;
font-size: 14px;
}
.objectives {
text-align: left;
max-width: 500px;
margin: 24px auto;
}
.objectives ul {
list-style: disc;
padding-left: 24px;
}
.objectives li {
margin-bottom: 8px;
}
"#
}
/// Get default JavaScript
fn get_default_js() -> &'static str {
r#"
let currentScene = 0;
const scenes = document.querySelectorAll('.scene');
const totalScenes = scenes.length;
function showScene(index) {
scenes.forEach((s, i) => {
s.style.display = i === index ? 'block' : 'none';
});
document.getElementById('scene-counter').textContent = `${index + 1} / ${totalScenes}`;
}
document.getElementById('prev-scene').addEventListener('click', () => {
if (currentScene > 0) {
currentScene--;
showScene(currentScene);
}
});
document.getElementById('next-scene').addEventListener('click', () => {
if (currentScene < totalScenes - 1) {
currentScene++;
showScene(currentScene);
}
});
document.getElementById('toggle-notes').addEventListener('click', () => {
document.querySelectorAll('.speaker-notes').forEach(n => {
n.classList.toggle('visible');
});
});
document.getElementById('toggle-toc').addEventListener('click', () => {
document.getElementById('toc-panel').classList.toggle('visible');
});
// Initialize
showScene(0);
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === ' ') {
if (currentScene < totalScenes - 1) {
currentScene++;
showScene(currentScene);
}
} else if (e.key === 'ArrowLeft') {
if (currentScene > 0) {
currentScene--;
showScene(currentScene);
}
}
});
"#
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generation::{ClassroomMetadata, TeachingStyle, DifficultyLevel};
fn create_test_classroom() -> Classroom {
Classroom {
id: "test-1".to_string(),
title: "Test Classroom".to_string(),
description: "A test classroom".to_string(),
topic: "Testing".to_string(),
style: TeachingStyle::Lecture,
level: DifficultyLevel::Beginner,
total_duration: 1800,
objectives: vec!["Learn A".to_string(), "Learn B".to_string()],
scenes: vec![
GeneratedScene {
id: "scene-1".to_string(),
outline_id: "outline-1".to_string(),
content: SceneContent {
title: "Introduction".to_string(),
scene_type: SceneType::Slide,
content: serde_json::json!({"description": "Intro slide"}),
actions: vec![SceneAction::Speech {
text: "Welcome!".to_string(),
agent_role: "teacher".to_string(),
}],
duration_seconds: 600,
notes: Some("Speaker notes here".to_string()),
},
order: 0,
},
],
metadata: ClassroomMetadata::default(),
}
}
#[test]
fn test_html_export() {
let exporter = HtmlExporter::new();
let classroom = create_test_classroom();
let options = ExportOptions::default();
let result = exporter.export(&classroom, &options).unwrap();
assert_eq!(result.extension, "html");
assert_eq!(result.mime_type, "text/html");
assert!(result.filename.ends_with(".html"));
let html = String::from_utf8(result.content).unwrap();
assert!(html.contains("<!DOCTYPE html>"));
assert!(html.contains("Test Classroom"));
assert!(html.contains("Introduction"));
}
#[test]
fn test_html_escape() {
assert_eq!(html_escape("Hello <World>"), "Hello &lt;World&gt;");
assert_eq!(html_escape("A & B"), "A &amp; B");
assert_eq!(html_escape("Say \"Hi\""), "Say &quot;Hi&quot;");
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(1800), "30m");
assert_eq!(format_duration(3665), "61m 5s");
assert_eq!(format_duration(60), "1m");
}
#[test]
fn test_format_level() {
assert_eq!(format_level(&DifficultyLevel::Beginner), "Beginner");
assert_eq!(format_level(&DifficultyLevel::Expert), "Expert");
}
#[test]
fn test_include_notes() {
let exporter = HtmlExporter::new();
let classroom = create_test_classroom();
let options_with_notes = ExportOptions {
include_notes: true,
..Default::default()
};
let result = exporter.export(&classroom, &options_with_notes).unwrap();
let html = String::from_utf8(result.content).unwrap();
assert!(html.contains("Speaker notes here"));
let options_no_notes = ExportOptions {
include_notes: false,
..Default::default()
};
let result = exporter.export(&classroom, &options_no_notes).unwrap();
let html = String::from_utf8(result.content).unwrap();
assert!(!html.contains("Speaker notes here"));
}
}

View File

@@ -0,0 +1,677 @@
//! Markdown Exporter - Plain text documentation export
//!
//! Generates a Markdown file containing:
//! - Title and metadata
//! - Table of contents
//! - Scene content with formatting
//! - Speaker notes (optional)
//! - Quiz questions (optional)
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction};
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
use zclaw_types::Result;
/// Markdown exporter
pub struct MarkdownExporter {
/// Include front matter
include_front_matter: bool,
}
impl MarkdownExporter {
/// Create new Markdown exporter
pub fn new() -> Self {
Self {
include_front_matter: true,
}
}
/// Create without front matter
pub fn without_front_matter() -> Self {
Self {
include_front_matter: false,
}
}
/// Generate Markdown content
fn generate_markdown(&self, classroom: &Classroom, options: &ExportOptions) -> String {
let mut md = String::new();
// Front matter
if self.include_front_matter {
md.push_str(&self.generate_front_matter(classroom));
}
// Title
md.push_str(&format!("# {}\n\n", &classroom.title));
// Metadata
md.push_str(&self.generate_metadata_section(classroom));
// Learning objectives
md.push_str(&self.generate_objectives_section(classroom));
// Table of contents
if options.table_of_contents {
md.push_str(&self.generate_toc(classroom));
}
// Scenes
md.push_str("\n---\n\n");
for scene in &classroom.scenes {
md.push_str(&self.generate_scene(scene, options));
md.push_str("\n---\n\n");
}
// Footer
md.push_str(&self.generate_footer(classroom));
md
}
/// Generate YAML front matter
fn generate_front_matter(&self, classroom: &Classroom) -> String {
let created = chrono::DateTime::from_timestamp_millis(classroom.metadata.generated_at)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| "Unknown".to_string());
format!(
r#"---
title: "{}"
topic: "{}"
style: "{}"
level: "{}"
duration: "{}"
generated: "{}"
version: "{}"
---
"#,
escape_yaml_string(&classroom.title),
escape_yaml_string(&classroom.topic),
format_style(&classroom.style),
format_level(&classroom.level),
format_duration(classroom.total_duration),
created,
classroom.metadata.version
)
}
/// Generate metadata section
fn generate_metadata_section(&self, classroom: &Classroom) -> String {
format!(
r#"> **Topic**: {} | **Level**: {} | **Duration**: {} | **Style**: {}
"#,
&classroom.topic,
format_level(&classroom.level),
format_duration(classroom.total_duration),
format_style(&classroom.style)
)
}
/// Generate learning objectives section
fn generate_objectives_section(&self, classroom: &Classroom) -> String {
if classroom.objectives.is_empty() {
return String::new();
}
let objectives: String = classroom.objectives.iter()
.map(|o| format!("- {}\n", o))
.collect();
format!(
r#"## Learning Objectives
{}
"#,
objectives
)
}
/// Generate table of contents
fn generate_toc(&self, classroom: &Classroom) -> String {
let mut toc = String::from("## Table of Contents\n\n");
for (i, scene) in classroom.scenes.iter().enumerate() {
toc.push_str(&format!(
"{}. [{}](#scene-{}-{})\n",
i + 1,
&scene.content.title,
i + 1,
slugify(&scene.content.title)
));
}
toc.push_str("\n");
toc
}
/// Generate a single scene
fn generate_scene(&self, scene: &GeneratedScene, options: &ExportOptions) -> String {
let mut md = String::new();
// Scene header
md.push_str(&format!(
"## Scene {}: {}\n\n",
scene.order + 1,
&scene.content.title
));
// Scene metadata
md.push_str(&format!(
"> **Type**: {} | **Duration**: {}\n\n",
format_scene_type(&scene.content.scene_type),
format_duration(scene.content.duration_seconds)
));
// Scene content based on type
md.push_str(&self.format_scene_content(&scene.content, options));
// Actions
if !scene.content.actions.is_empty() {
md.push_str("\n### Actions\n\n");
md.push_str(&self.format_actions(&scene.content.actions, options));
}
// Speaker notes
if options.include_notes {
if let Some(notes) = &scene.content.notes {
md.push_str(&format!(
"\n> **Speaker Notes**: {}\n",
notes
));
}
}
md
}
/// Format scene content based on type
fn format_scene_content(&self, content: &SceneContent, options: &ExportOptions) -> String {
let mut md = String::new();
// Add description
if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) {
md.push_str(&format!("{}\n\n", desc));
}
// Add key points
if let Some(points) = content.content.get("key_points").and_then(|v| v.as_array()) {
md.push_str("**Key Points:**\n\n");
for point in points {
if let Some(text) = point.as_str() {
md.push_str(&format!("- {}\n", text));
}
}
md.push_str("\n");
}
// Type-specific content
match content.scene_type {
SceneType::Slide => {
if let Some(slides) = content.content.get("slides").and_then(|v| v.as_array()) {
for (i, slide) in slides.iter().enumerate() {
if let (Some(title), Some(slide_content)) = (
slide.get("title").and_then(|t| t.as_str()),
slide.get("content").and_then(|c| c.as_str())
) {
md.push_str(&format!("#### Slide {}: {}\n\n{}\n\n", i + 1, title, slide_content));
}
}
}
}
SceneType::Quiz => {
md.push_str(&self.format_quiz_content(&content.content, options));
}
SceneType::Discussion => {
if let Some(topic) = content.content.get("discussion_topic").and_then(|v| v.as_str()) {
md.push_str(&format!("**Discussion Topic:** {}\n\n", topic));
}
if let Some(prompts) = content.content.get("discussion_prompts").and_then(|v| v.as_array()) {
md.push_str("**Discussion Prompts:**\n\n");
for prompt in prompts {
if let Some(text) = prompt.as_str() {
md.push_str(&format!("> {}\n\n", text));
}
}
}
}
SceneType::Pbl => {
if let Some(problem) = content.content.get("problem_statement").and_then(|v| v.as_str()) {
md.push_str(&format!("**Problem Statement:**\n\n{}\n\n", problem));
}
if let Some(tasks) = content.content.get("tasks").and_then(|v| v.as_array()) {
md.push_str("**Tasks:**\n\n");
for (i, task) in tasks.iter().enumerate() {
if let Some(text) = task.as_str() {
md.push_str(&format!("{}. {}\n", i + 1, text));
}
}
md.push_str("\n");
}
}
SceneType::Interactive => {
if let Some(instructions) = content.content.get("instructions").and_then(|v| v.as_str()) {
md.push_str(&format!("**Instructions:**\n\n{}\n\n", instructions));
}
}
SceneType::Media => {
if let Some(url) = content.content.get("media_url").and_then(|v| v.as_str()) {
md.push_str(&format!("**Media:** [View Media]({})\n\n", url));
}
}
SceneType::Text => {
if let Some(text) = content.content.get("text_content").and_then(|v| v.as_str()) {
md.push_str(&format!("```\n{}\n```\n\n", text));
}
}
}
md
}
/// Format quiz content
fn format_quiz_content(&self, content: &serde_json::Value, options: &ExportOptions) -> String {
let mut md = String::new();
if let Some(questions) = content.get("questions").and_then(|v| v.as_array()) {
md.push_str("### Quiz Questions\n\n");
for (i, q) in questions.iter().enumerate() {
if let Some(text) = q.get("text").and_then(|t| t.as_str()) {
md.push_str(&format!("**Q{}:** {}\n\n", i + 1, text));
// Options
if let Some(options_arr) = q.get("options").and_then(|o| o.as_array()) {
for (j, opt) in options_arr.iter().enumerate() {
if let Some(opt_text) = opt.as_str() {
let letter = (b'A' + j as u8) as char;
md.push_str(&format!("- {} {}\n", letter, opt_text));
}
}
md.push_str("\n");
}
// Answer (if include_answers is true)
if options.include_answers {
if let Some(answer) = q.get("correct_answer").and_then(|a| a.as_str()) {
md.push_str(&format!("*Answer: {}*\n\n", answer));
} else if let Some(idx) = q.get("correct_index").and_then(|i| i.as_u64()) {
let letter = (b'A' + idx as u8) as char;
md.push_str(&format!("*Answer: {}*\n\n", letter));
}
}
}
}
}
md
}
/// Format actions
fn format_actions(&self, actions: &[SceneAction], _options: &ExportOptions) -> String {
let mut md = String::new();
for action in actions {
match action {
SceneAction::Speech { text, agent_role } => {
md.push_str(&format!(
"> **{}**: \"{}\"\n\n",
capitalize_first(agent_role),
text
));
}
SceneAction::WhiteboardDrawText { text, x, y, font_size, color } => {
md.push_str(&format!(
"- Whiteboard Text: \"{}\" at ({}, {})",
text, x, y
));
if let Some(size) = font_size {
md.push_str(&format!(" [size: {}]", size));
}
if let Some(c) = color {
md.push_str(&format!(" [color: {}]", c));
}
md.push_str("\n");
}
SceneAction::WhiteboardDrawShape { shape, x, y, width, height, fill } => {
md.push_str(&format!(
"- Draw {}: ({}, {}) {}x{}",
shape, x, y, width, height
));
if let Some(f) = fill {
md.push_str(&format!(" [fill: {}]", f));
}
md.push_str("\n");
}
SceneAction::WhiteboardDrawChart { chart_type, x, y, width, height, .. } => {
md.push_str(&format!(
"- Chart ({}): ({}, {}) {}x{}\n",
chart_type, x, y, width, height
));
}
SceneAction::WhiteboardDrawLatex { latex, x, y } => {
md.push_str(&format!(
"- LaTeX: `{}` at ({}, {})\n",
latex, x, y
));
}
SceneAction::WhiteboardClear => {
md.push_str("- Clear whiteboard\n");
}
SceneAction::SlideshowSpotlight { element_id } => {
md.push_str(&format!("- Spotlight: {}\n", element_id));
}
SceneAction::SlideshowNext => {
md.push_str("- Next slide\n");
}
SceneAction::QuizShow { quiz_id } => {
md.push_str(&format!("- Show quiz: {}\n", quiz_id));
}
SceneAction::Discussion { topic, duration_seconds } => {
md.push_str(&format!(
"- Discussion: \"{}\" ({}s)\n",
topic,
duration_seconds.unwrap_or(300)
));
}
}
}
md
}
/// Generate footer
fn generate_footer(&self, classroom: &Classroom) -> String {
format!(
r#"---
*Generated by ZCLAW Classroom Generator*
*Topic: {} | Total Duration: {}*
"#,
&classroom.topic,
format_duration(classroom.total_duration)
)
}
}
impl Default for MarkdownExporter {
fn default() -> Self {
Self::new()
}
}
impl Exporter for MarkdownExporter {
fn export(&self, classroom: &Classroom, options: &ExportOptions) -> Result<ExportResult> {
let markdown = self.generate_markdown(classroom, options);
let filename = format!("{}.md", sanitize_filename(&classroom.title));
Ok(ExportResult {
content: markdown.into_bytes(),
mime_type: "text/markdown".to_string(),
filename,
extension: "md".to_string(),
})
}
fn format(&self) -> super::ExportFormat {
super::ExportFormat::Markdown
}
fn extension(&self) -> &str {
"md"
}
fn mime_type(&self) -> &str {
"text/markdown"
}
}
// Helper functions
/// Escape YAML string
fn escape_yaml_string(s: &str) -> String {
if s.contains('"') || s.contains('\\') || s.contains('\n') {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} else {
s.to_string()
}
}
/// Format duration
fn format_duration(seconds: u32) -> String {
let minutes = seconds / 60;
let secs = seconds % 60;
if secs > 0 {
format!("{}m {}s", minutes, secs)
} else {
format!("{}m", minutes)
}
}
/// Format difficulty level
fn format_level(level: &crate::generation::DifficultyLevel) -> String {
match level {
crate::generation::DifficultyLevel::Beginner => "Beginner",
crate::generation::DifficultyLevel::Intermediate => "Intermediate",
crate::generation::DifficultyLevel::Advanced => "Advanced",
crate::generation::DifficultyLevel::Expert => "Expert",
}.to_string()
}
/// Format teaching style
fn format_style(style: &crate::generation::TeachingStyle) -> String {
match style {
crate::generation::TeachingStyle::Lecture => "Lecture",
crate::generation::TeachingStyle::Discussion => "Discussion",
crate::generation::TeachingStyle::Pbl => "Project-Based",
crate::generation::TeachingStyle::Flipped => "Flipped Classroom",
crate::generation::TeachingStyle::Socratic => "Socratic",
}.to_string()
}
/// Format scene type
fn format_scene_type(scene_type: &SceneType) -> String {
match scene_type {
SceneType::Slide => "Slide",
SceneType::Quiz => "Quiz",
SceneType::Interactive => "Interactive",
SceneType::Pbl => "Project-Based Learning",
SceneType::Discussion => "Discussion",
SceneType::Media => "Media",
SceneType::Text => "Text",
}.to_string()
}
/// Convert string to URL slug
fn slugify(s: &str) -> String {
s.to_lowercase()
.replace(' ', "-")
.replace(|c: char| !c.is_alphanumeric() && c != '-', "")
.trim_matches('-')
.to_string()
}
/// Capitalize first letter
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generation::{ClassroomMetadata, TeachingStyle, DifficultyLevel};
fn create_test_classroom() -> Classroom {
Classroom {
id: "test-1".to_string(),
title: "Test Classroom".to_string(),
description: "A test classroom".to_string(),
topic: "Testing".to_string(),
style: TeachingStyle::Lecture,
level: DifficultyLevel::Beginner,
total_duration: 1800,
objectives: vec!["Learn A".to_string(), "Learn B".to_string()],
scenes: vec![
GeneratedScene {
id: "scene-1".to_string(),
outline_id: "outline-1".to_string(),
content: SceneContent {
title: "Introduction".to_string(),
scene_type: SceneType::Slide,
content: serde_json::json!({
"description": "Intro slide content",
"key_points": ["Point 1", "Point 2"]
}),
actions: vec![SceneAction::Speech {
text: "Welcome!".to_string(),
agent_role: "teacher".to_string(),
}],
duration_seconds: 600,
notes: Some("Speaker notes here".to_string()),
},
order: 0,
},
GeneratedScene {
id: "scene-2".to_string(),
outline_id: "outline-2".to_string(),
content: SceneContent {
title: "Quiz Time".to_string(),
scene_type: SceneType::Quiz,
content: serde_json::json!({
"questions": [
{
"text": "What is 2+2?",
"options": ["3", "4", "5", "6"],
"correct_index": 1
}
]
}),
actions: vec![SceneAction::QuizShow {
quiz_id: "quiz-1".to_string(),
}],
duration_seconds: 300,
notes: None,
},
order: 1,
},
],
metadata: ClassroomMetadata::default(),
}
}
#[test]
fn test_markdown_export() {
let exporter = MarkdownExporter::new();
let classroom = create_test_classroom();
let options = ExportOptions::default();
let result = exporter.export(&classroom, &options).unwrap();
assert_eq!(result.extension, "md");
assert_eq!(result.mime_type, "text/markdown");
assert!(result.filename.ends_with(".md"));
let md = String::from_utf8(result.content).unwrap();
assert!(md.contains("# Test Classroom"));
assert!(md.contains("Introduction"));
assert!(md.contains("Quiz Time"));
}
#[test]
fn test_include_answers() {
let exporter = MarkdownExporter::new();
let classroom = create_test_classroom();
let options_with_answers = ExportOptions {
include_answers: true,
..Default::default()
};
let result = exporter.export(&classroom, &options_with_answers).unwrap();
let md = String::from_utf8(result.content).unwrap();
assert!(md.contains("Answer:"));
let options_no_answers = ExportOptions {
include_answers: false,
..Default::default()
};
let result = exporter.export(&classroom, &options_no_answers).unwrap();
let md = String::from_utf8(result.content).unwrap();
assert!(!md.contains("Answer:"));
}
#[test]
fn test_slugify() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("Test 123!"), "test-123");
}
#[test]
fn test_capitalize_first() {
assert_eq!(capitalize_first("teacher"), "Teacher");
assert_eq!(capitalize_first("STUDENT"), "STUDENT");
assert_eq!(capitalize_first(""), "");
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(1800), "30m");
assert_eq!(format_duration(3665), "61m 5s");
}
#[test]
fn test_include_notes() {
let exporter = MarkdownExporter::new();
let classroom = create_test_classroom();
let options_with_notes = ExportOptions {
include_notes: true,
..Default::default()
};
let result = exporter.export(&classroom, &options_with_notes).unwrap();
let md = String::from_utf8(result.content).unwrap();
assert!(md.contains("Speaker Notes"));
let options_no_notes = ExportOptions {
include_notes: false,
..Default::default()
};
let result = exporter.export(&classroom, &options_no_notes).unwrap();
let md = String::from_utf8(result.content).unwrap();
assert!(!md.contains("Speaker Notes"));
}
#[test]
fn test_table_of_contents() {
let exporter = MarkdownExporter::new();
let classroom = create_test_classroom();
let options_with_toc = ExportOptions {
table_of_contents: true,
..Default::default()
};
let result = exporter.export(&classroom, &options_with_toc).unwrap();
let md = String::from_utf8(result.content).unwrap();
assert!(md.contains("Table of Contents"));
let options_no_toc = ExportOptions {
table_of_contents: false,
..Default::default()
};
let result = exporter.export(&classroom, &options_no_toc).unwrap();
let md = String::from_utf8(result.content).unwrap();
assert!(!md.contains("Table of Contents"));
}
}

View File

@@ -0,0 +1,178 @@
//! Export functionality for ZCLAW classroom content
//!
//! This module provides export capabilities for:
//! - HTML: Interactive web-based classroom
//! - PPTX: PowerPoint presentation
//! - Markdown: Plain text documentation
//! - JSON: Raw data export
mod html;
mod pptx;
mod markdown;
use serde::{Deserialize, Serialize};
use zclaw_types::Result;
use crate::generation::Classroom;
/// Export format
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ExportFormat {
#[default]
Html,
Pptx,
Markdown,
Json,
}
/// Export options
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportOptions {
/// Output format
pub format: ExportFormat,
/// Include speaker notes
#[serde(default = "default_true")]
pub include_notes: bool,
/// Include quiz answers
#[serde(default)]
pub include_answers: bool,
/// Theme for HTML export
#[serde(default)]
pub theme: Option<String>,
/// Custom CSS (for HTML)
#[serde(default)]
pub custom_css: Option<String>,
/// Title slide
#[serde(default = "default_true")]
pub title_slide: bool,
/// Table of contents
#[serde(default = "default_true")]
pub table_of_contents: bool,
}
fn default_true() -> bool {
true
}
impl Default for ExportOptions {
fn default() -> Self {
Self {
format: ExportFormat::default(),
include_notes: true,
include_answers: false,
theme: None,
custom_css: None,
title_slide: true,
table_of_contents: true,
}
}
}
/// Export result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportResult {
/// Output content (as bytes for binary formats)
pub content: Vec<u8>,
/// MIME type
pub mime_type: String,
/// Suggested filename
pub filename: String,
/// File extension
pub extension: String,
}
/// Exporter trait
pub trait Exporter: Send + Sync {
/// Export a classroom
fn export(&self, classroom: &Classroom, options: &ExportOptions) -> Result<ExportResult>;
/// Get supported format
fn format(&self) -> ExportFormat;
/// Get file extension
fn extension(&self) -> &str;
/// Get MIME type
fn mime_type(&self) -> &str;
}
/// Export a classroom
pub fn export_classroom(
classroom: &Classroom,
options: &ExportOptions,
) -> Result<ExportResult> {
let exporter: Box<dyn Exporter> = match options.format {
ExportFormat::Html => Box::new(html::HtmlExporter::new()),
ExportFormat::Pptx => Box::new(pptx::PptxExporter::new()),
ExportFormat::Markdown => Box::new(markdown::MarkdownExporter::new()),
ExportFormat::Json => Box::new(JsonExporter::new()),
};
exporter.export(classroom, options)
}
/// JSON exporter (simple passthrough)
pub struct JsonExporter;
impl JsonExporter {
pub fn new() -> Self {
Self
}
}
impl Default for JsonExporter {
fn default() -> Self {
Self::new()
}
}
impl Exporter for JsonExporter {
fn export(&self, classroom: &Classroom, _options: &ExportOptions) -> Result<ExportResult> {
let content = serde_json::to_string_pretty(classroom)?;
let filename = format!("{}.json", sanitize_filename(&classroom.title));
Ok(ExportResult {
content: content.into_bytes(),
mime_type: "application/json".to_string(),
filename,
extension: "json".to_string(),
})
}
fn format(&self) -> ExportFormat {
ExportFormat::Json
}
fn extension(&self) -> &str {
"json"
}
fn mime_type(&self) -> &str {
"application/json"
}
}
/// Sanitize filename
pub fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| match c {
'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => c,
' ' => '_',
_ => '_',
})
.take(100)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("Hello World"), "Hello_World");
assert_eq!(sanitize_filename("Test@123!"), "Test_123_");
assert_eq!(sanitize_filename("Simple"), "Simple");
}
}

View File

@@ -0,0 +1,640 @@
//! PPTX Exporter - PowerPoint presentation export
//!
//! Generates a .pptx file (Office Open XML format) containing:
//! - Title slide
//! - Content slides for each scene
//! - Speaker notes (optional)
//! - Quiz slides
//!
//! Note: This is a simplified implementation that creates a valid PPTX structure
//! without external dependencies. For more advanced features, consider using
//! a dedicated library like `pptx-rs` or `office` crate.
use crate::generation::{Classroom, GeneratedScene, SceneContent, SceneType, SceneAction};
use super::{ExportOptions, ExportResult, Exporter, sanitize_filename};
use zclaw_types::{Result, ZclawError};
use std::collections::HashMap;
/// PPTX exporter
pub struct PptxExporter;
impl PptxExporter {
/// Create new PPTX exporter
pub fn new() -> Self {
Self
}
/// Generate PPTX content (as bytes)
fn generate_pptx(&self, classroom: &Classroom, options: &ExportOptions) -> Result<Vec<u8>> {
let mut files: HashMap<String, Vec<u8>> = HashMap::new();
// [Content_Types].xml
files.insert(
"[Content_Types].xml".to_string(),
self.generate_content_types().into_bytes(),
);
// _rels/.rels
files.insert(
"_rels/.rels".to_string(),
self.generate_rels().into_bytes(),
);
// docProps/app.xml
files.insert(
"docProps/app.xml".to_string(),
self.generate_app_xml(classroom).into_bytes(),
);
// docProps/core.xml
files.insert(
"docProps/core.xml".to_string(),
self.generate_core_xml(classroom).into_bytes(),
);
// ppt/presentation.xml
files.insert(
"ppt/presentation.xml".to_string(),
self.generate_presentation_xml(classroom).into_bytes(),
);
// ppt/_rels/presentation.xml.rels
files.insert(
"ppt/_rels/presentation.xml.rels".to_string(),
self.generate_presentation_rels(classroom, options).into_bytes(),
);
// Generate slides
let mut slide_files = self.generate_slides(classroom, options);
for (path, content) in slide_files.drain() {
files.insert(path, content);
}
// Create ZIP archive
self.create_zip_archive(files)
}
/// Generate [Content_Types].xml
fn generate_content_types(&self) -> String {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>
<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>
<Override PartName="/ppt/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
</Types>"#.to_string()
}
/// Generate _rels/.rels
fn generate_rels(&self) -> String {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>"#.to_string()
}
/// Generate docProps/app.xml
fn generate_app_xml(&self, classroom: &Classroom) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
<Application>ZCLAW Classroom Generator</Application>
<Slides>{}</Slides>
<Title>{}</Title>
<Subject>{}</Subject>
</Properties>"#,
classroom.scenes.len() + 1, // +1 for title slide
xml_escape(&classroom.title),
xml_escape(&classroom.topic)
)
}
/// Generate docProps/core.xml
fn generate_core_xml(&self, classroom: &Classroom) -> String {
let created = chrono::DateTime::from_timestamp_millis(classroom.metadata.generated_at)
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_else(|| "2024-01-01T00:00:00Z".to_string());
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dc:title>{}</dc:title>
<dc:subject>{}</dc:subject>
<dc:description>{}</dc:description>
<dcterms:created xsi:type="dcterms:W3CDTF">{}</dcterms:created>
<cp:revision>1</cp:revision>
</cp:coreProperties>"#,
xml_escape(&classroom.title),
xml_escape(&classroom.topic),
xml_escape(&classroom.description),
created
)
}
/// Generate ppt/presentation.xml
fn generate_presentation_xml(&self, classroom: &Classroom) -> String {
let slide_count = classroom.scenes.len() + 1; // +1 for title slide
let slide_ids: String = (1..=slide_count)
.map(|i| format!(r#" <p:sldId id="{}" r:id="rId{}"/>"#, 255 + i, i))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:sldIdLst>
{}
</p:sldIdLst>
<p:sldSz cx="9144000" cy="6858000"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>"#,
slide_ids
)
}
/// Generate ppt/_rels/presentation.xml.rels
fn generate_presentation_rels(&self, classroom: &Classroom, _options: &ExportOptions) -> String {
let slide_count = classroom.scenes.len() + 1;
let relationships: String = (1..=slide_count)
.map(|i| {
format!(
r#" <Relationship Id="rId{}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide{}.xml"/>"#,
i, i
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
{}
</Relationships>"#,
relationships
)
}
/// Generate all slide files
fn generate_slides(&self, classroom: &Classroom, options: &ExportOptions) -> HashMap<String, Vec<u8>> {
let mut files = HashMap::new();
// Title slide (slide1.xml)
let title_slide = self.generate_title_slide(classroom);
files.insert("ppt/slides/slide1.xml".to_string(), title_slide.into_bytes());
// Content slides
for (i, scene) in classroom.scenes.iter().enumerate() {
let slide_num = i + 2; // Start from 2 (1 is title)
let slide_xml = self.generate_content_slide(scene, options);
files.insert(
format!("ppt/slides/slide{}.xml", slide_num),
slide_xml.into_bytes(),
);
}
// Slide relationships
let slide_count = classroom.scenes.len() + 1;
for i in 1..=slide_count {
let rels = self.generate_slide_rels(i);
files.insert(
format!("ppt/slides/_rels/slide{}.xml.rels", i),
rels.into_bytes(),
);
}
files
}
/// Generate title slide XML
fn generate_title_slide(&self, classroom: &Classroom) -> String {
let objectives = classroom.objectives.iter()
.map(|o| format!("- {}", o))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="2" name="Title"/>
<p:nvPr>
<p:ph type="ctrTitle"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr>
<a:xfrm>
<a:off x="457200" y="2746388"/>
<a:ext cx="8229600" cy="1143000"/>
</a:xfrm>
</p:spPr>
<p:txBody>
<a:bodyPr/>
<a:p>
<a:r>
<a:rPr lang="zh-CN"/>
<a:t>{}</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="3" name="Subtitle"/>
<p:nvPr>
<p:ph type="subTitle"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr>
<a:xfrm>
<a:off x="457200" y="4039388"/>
<a:ext cx="8229600" cy="609600"/>
</a:xfrm>
</p:spPr>
<p:txBody>
<a:bodyPr/>
<a:p>
<a:r>
<a:rPr lang="zh-CN"/>
<a:t>{}</a:t>
</a:r>
</a:p>
<a:p>
<a:r>
<a:rPr lang="zh-CN"/>
<a:t>Duration: {}</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>"#,
xml_escape(&classroom.title),
xml_escape(&classroom.description),
format_duration(classroom.total_duration)
)
}
/// Generate content slide XML
fn generate_content_slide(&self, scene: &GeneratedScene, options: &ExportOptions) -> String {
let content_text = self.extract_scene_content(&scene.content);
let notes = if options.include_notes {
scene.content.notes.as_ref()
.map(|n| self.generate_notes(n))
.unwrap_or_default()
} else {
String::new()
};
format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:nvGrpSpPr>
<p:cNvPr id="1" name=""/>
<p:nvPr/>
</p:nvGrpSpPr>
<p:grpSpPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="0" cy="0"/>
<a:chOff x="0" y="0"/>
<a:chExt cx="0" cy="0"/>
</a:xfrm>
</p:grpSpPr>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="2" name="Title"/>
<p:nvPr>
<p:ph type="title"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr>
<a:xfrm>
<a:off x="457200" y="274638"/>
<a:ext cx="8229600" cy="1143000"/>
</a:xfrm>
</p:spPr>
<p:txBody>
<a:bodyPr/>
<a:p>
<a:r>
<a:rPr lang="zh-CN"/>
<a:t>{}</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="3" name="Content"/>
<p:nvPr>
<p:ph type="body"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr>
<a:xfrm>
<a:off x="457200" y="1600200"/>
<a:ext cx="8229600" cy="4572000"/>
</a:xfrm>
</p:spPr>
<p:txBody>
<a:bodyPr/>
{}
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
{}
</p:sld>"#,
xml_escape(&scene.content.title),
content_text,
notes
)
}
/// Extract scene content as PPTX paragraphs
fn extract_scene_content(&self, content: &SceneContent) -> String {
let mut paragraphs = String::new();
// Add description
if let Some(desc) = content.content.get("description").and_then(|v| v.as_str()) {
paragraphs.push_str(&self.text_to_paragraphs(desc));
}
// Add key points
if let Some(points) = content.content.get("key_points").and_then(|v| v.as_array()) {
for point in points {
if let Some(text) = point.as_str() {
paragraphs.push_str(&self.bullet_point_paragraph(text));
}
}
}
// Add speech content
for action in &content.actions {
if let SceneAction::Speech { text, agent_role } = action {
let prefix = if agent_role != "teacher" {
format!("[{}]: ", agent_role)
} else {
String::new()
};
paragraphs.push_str(&self.text_to_paragraphs(&format!("{}{}", prefix, text)));
}
}
if paragraphs.is_empty() {
paragraphs.push_str(&self.text_to_paragraphs("Content for this scene."));
}
paragraphs
}
/// Convert text to PPTX paragraphs
fn text_to_paragraphs(&self, text: &str) -> String {
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|line| {
format!(
r#" <a:p>
<a:r>
<a:rPr lang="zh-CN"/>
<a:t>{}</a:t>
</a:r>
</a:p>
"#,
xml_escape(line.trim())
)
})
.collect()
}
/// Create bullet point paragraph
fn bullet_point_paragraph(&self, text: &str) -> String {
format!(
r#" <a:p>
<a:pPr lvl="1">
<a:buFont typeface="Arial"/>
<a:buChar char="•"/>
</a:pPr>
<a:r>
<a:rPr lang="zh-CN"/>
<a:t>{}</a:t>
</a:r>
</a:p>
"#,
xml_escape(text)
)
}
/// Generate speaker notes XML
fn generate_notes(&self, notes: &str) -> String {
format!(
r#" <p:notes>
<p:cSld>
<p:spTree>
<p:sp>
<p:txBody>
<a:bodyPr/>
<a:p>
<a:r>
<a:t>{}</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:notes>"#,
xml_escape(notes)
)
}
/// Generate slide relationships
fn generate_slide_rels(&self, _slide_num: usize) -> String {
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
</Relationships>"#.to_string()
}
/// Create ZIP archive from files
fn create_zip_archive(&self, files: HashMap<String, Vec<u8>>) -> Result<Vec<u8>> {
use std::io::{Cursor, Write};
let mut buffer = Cursor::new(Vec::new());
{
let mut writer = ZipWriter::new(&mut buffer);
// Add files in sorted order (required by ZIP spec for deterministic output)
let mut paths: Vec<_> = files.keys().collect();
paths.sort();
for path in paths {
let content = files.get(path).unwrap();
let options = SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
writer.start_file(path, options)
.map_err(|e| ZclawError::ExportError(e.to_string()))?;
writer.write_all(content)
.map_err(|e| ZclawError::ExportError(e.to_string()))?;
}
writer.finish()
.map_err(|e| ZclawError::ExportError(e.to_string()))?;
}
Ok(buffer.into_inner())
}
}
impl Default for PptxExporter {
fn default() -> Self {
Self::new()
}
}
impl Exporter for PptxExporter {
fn export(&self, classroom: &Classroom, options: &ExportOptions) -> Result<ExportResult> {
let content = self.generate_pptx(classroom, options)?;
let filename = format!("{}.pptx", sanitize_filename(&classroom.title));
Ok(ExportResult {
content,
mime_type: "application/vnd.openxmlformats-officedocument.presentationml.presentation".to_string(),
filename,
extension: "pptx".to_string(),
})
}
fn format(&self) -> super::ExportFormat {
super::ExportFormat::Pptx
}
fn extension(&self) -> &str {
"pptx"
}
fn mime_type(&self) -> &str {
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
}
}
// Helper functions
/// Escape XML special characters
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// Format duration
fn format_duration(seconds: u32) -> String {
let minutes = seconds / 60;
let secs = seconds % 60;
if secs > 0 {
format!("{}m {}s", minutes, secs)
} else {
format!("{}m", minutes)
}
}
// ZIP writing (minimal implementation)
use zip::{ZipWriter, write::SimpleFileOptions};
#[cfg(test)]
mod tests {
use super::*;
use crate::generation::{ClassroomMetadata, TeachingStyle, DifficultyLevel};
fn create_test_classroom() -> Classroom {
Classroom {
id: "test-1".to_string(),
title: "Test Classroom".to_string(),
description: "A test classroom".to_string(),
topic: "Testing".to_string(),
style: TeachingStyle::Lecture,
level: DifficultyLevel::Beginner,
total_duration: 1800,
objectives: vec!["Learn A".to_string(), "Learn B".to_string()],
scenes: vec![
GeneratedScene {
id: "scene-1".to_string(),
outline_id: "outline-1".to_string(),
content: SceneContent {
title: "Introduction".to_string(),
scene_type: SceneType::Slide,
content: serde_json::json!({
"description": "Intro slide content",
"key_points": ["Point 1", "Point 2"]
}),
actions: vec![SceneAction::Speech {
text: "Welcome!".to_string(),
agent_role: "teacher".to_string(),
}],
duration_seconds: 600,
notes: Some("Speaker notes here".to_string()),
},
order: 0,
},
],
metadata: ClassroomMetadata::default(),
}
}
#[test]
fn test_pptx_export() {
let exporter = PptxExporter::new();
let classroom = create_test_classroom();
let options = ExportOptions::default();
let result = exporter.export(&classroom, &options).unwrap();
assert_eq!(result.extension, "pptx");
assert!(result.filename.ends_with(".pptx"));
assert!(!result.content.is_empty());
// Verify it's a valid ZIP file
let cursor = std::io::Cursor::new(&result.content);
let mut archive = zip::ZipArchive::new(cursor).unwrap();
assert!(archive.by_name("[Content_Types].xml").is_ok());
assert!(archive.by_name("ppt/presentation.xml").is_ok());
}
#[test]
fn test_xml_escape() {
assert_eq!(xml_escape("Hello <World>"), "Hello &lt;World&gt;");
assert_eq!(xml_escape("A & B"), "A &amp; B");
assert_eq!(xml_escape("Say \"Hi\""), "Say &quot;Hi&quot;");
}
#[test]
fn test_pptx_format() {
let exporter = PptxExporter::new();
assert_eq!(exporter.extension(), "pptx");
assert_eq!(exporter.format(), super::super::ExportFormat::Pptx);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,15 @@ mod registry;
mod capabilities;
mod events;
pub mod config;
pub mod director;
pub mod generation;
pub mod export;
pub use kernel::*;
pub use registry::*;
pub use capabilities::*;
pub use events::*;
pub use config::*;
pub use director::*;
pub use generation::*;
pub use export::{ExportFormat, ExportOptions, ExportResult, Exporter, export_classroom};

View File

@@ -11,6 +11,9 @@ pub struct MemoryStore {
impl MemoryStore {
/// Create a new memory store with the given database path
pub async fn new(database_url: &str) -> Result<Self> {
// Ensure parent directory exists for file-based SQLite databases
Self::ensure_database_dir(database_url)?;
let pool = SqlitePool::connect(database_url).await
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
let store = Self { pool };
@@ -18,6 +21,37 @@ impl MemoryStore {
Ok(store)
}
/// Ensure the parent directory for the database file exists
fn ensure_database_dir(database_url: &str) -> Result<()> {
// Parse SQLite URL to extract file path
// Format: sqlite:/path/to/db or sqlite://path/to/db
if database_url.starts_with("sqlite:") {
let path_part = database_url.strip_prefix("sqlite:").unwrap();
// Skip in-memory databases
if path_part == ":memory:" {
return Ok(());
}
// Remove query parameters (e.g., ?mode=rwc)
let path_without_query = path_part.split('?').next().unwrap();
// Handle both absolute and relative paths
let path = std::path::Path::new(path_without_query);
// Get parent directory
if let Some(parent) = path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent)
.map_err(|e| ZclawError::StorageError(
format!("Failed to create database directory {}: {}", parent.display(), e)
))?;
}
}
}
Ok(())
}
/// Create an in-memory database (for testing)
pub async fn in_memory() -> Result<Self> {
Self::new("sqlite::memory:").await
@@ -141,7 +175,7 @@ impl MemoryStore {
sqlx::query(
r#"
INSERT INTO messages (session_id, seq, content, created_at)
SELECT ?, COALESCE(MAX(seq), 0) + 1, datetime('now')
SELECT ?, COALESCE(MAX(seq), 0) + 1, ?, datetime('now')
FROM messages WHERE session_id = ?
"#,
)

View File

@@ -17,3 +17,4 @@ thiserror = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
uuid = { workspace = true }

View File

@@ -1,50 +1,122 @@
//! A2A (Agent-to-Agent) protocol support
//!
//! Implements communication between AI agents.
//! Implements communication between AI agents with support for:
//! - Direct messaging (point-to-point)
//! - Group messaging (multicast)
//! - Broadcast messaging (all agents)
//! - Capability discovery and advertisement
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use zclaw_types::{Result, AgentId};
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
use uuid::Uuid;
use zclaw_types::{AgentId, Result, ZclawError};
/// Default channel buffer size
const DEFAULT_CHANNEL_SIZE: usize = 256;
/// A2A message envelope
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2aEnvelope {
/// Message ID
/// Message ID (UUID recommended)
pub id: String,
/// Sender agent ID
pub from: AgentId,
/// Recipient agent ID (or broadcast)
/// Recipient specification
pub to: A2aRecipient,
/// Message type
pub message_type: A2aMessageType,
/// Message payload
/// Message payload (JSON)
pub payload: serde_json::Value,
/// Timestamp
/// Timestamp (Unix epoch milliseconds)
pub timestamp: i64,
/// Conversation/thread ID
/// Conversation/thread ID for grouping related messages
pub conversation_id: Option<String>,
/// Reply-to message ID
/// Reply-to message ID for threading
pub reply_to: Option<String>,
/// Priority (0 = normal, higher = more urgent)
#[serde(default)]
pub priority: u8,
/// Time-to-live in seconds (0 = no expiry)
#[serde(default)]
pub ttl: u32,
}
impl A2aEnvelope {
/// Create a new envelope with auto-generated ID and timestamp
pub fn new(from: AgentId, to: A2aRecipient, message_type: A2aMessageType, payload: serde_json::Value) -> Self {
Self {
id: uuid_v4(),
from,
to,
message_type,
payload,
timestamp: current_timestamp(),
conversation_id: None,
reply_to: None,
priority: 0,
ttl: 0,
}
}
/// Set conversation ID
pub fn with_conversation(mut self, conversation_id: impl Into<String>) -> Self {
self.conversation_id = Some(conversation_id.into());
self
}
/// Set reply-to message ID
pub fn with_reply_to(mut self, reply_to: impl Into<String>) -> Self {
self.reply_to = Some(reply_to.into());
self
}
/// Set priority
pub fn with_priority(mut self, priority: u8) -> Self {
self.priority = priority;
self
}
/// Check if message has expired
pub fn is_expired(&self) -> bool {
if self.ttl == 0 {
return false;
}
let now = current_timestamp();
let expiry = self.timestamp + (self.ttl as i64 * 1000);
now > expiry
}
}
/// Recipient specification
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum A2aRecipient {
/// Direct message to specific agent
Direct { agent_id: AgentId },
/// Broadcast to all agents in a group
/// Message to all agents in a group
Group { group_id: String },
/// Broadcast to all agents
Broadcast,
}
impl std::fmt::Display for A2aRecipient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
A2aRecipient::Direct { agent_id } => write!(f, "direct:{}", agent_id),
A2aRecipient::Group { group_id } => write!(f, "group:{}", group_id),
A2aRecipient::Broadcast => write!(f, "broadcast"),
}
}
}
/// A2A message types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum A2aMessageType {
/// Request for information or action
/// Request for information or action (expects response)
Request,
/// Response to a request
Response,
@@ -56,21 +128,31 @@ pub enum A2aMessageType {
Heartbeat,
/// Capability advertisement
Capability,
/// Task delegation
Task,
/// Task status update
TaskStatus,
}
/// Agent capability advertisement
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2aCapability {
/// Capability name
/// Capability name (e.g., "code-generation", "web-search")
pub name: String,
/// Capability description
/// Human-readable description
pub description: String,
/// Input schema
/// JSON Schema for input validation
pub input_schema: Option<serde_json::Value>,
/// Output schema
/// JSON Schema for output validation
pub output_schema: Option<serde_json::Value>,
/// Whether this capability requires approval
/// Whether this capability requires human approval
pub requires_approval: bool,
/// Capability version
#[serde(default)]
pub version: String,
/// Tags for categorization
#[serde(default)]
pub tags: Vec<String>,
}
/// Agent profile for A2A
@@ -78,16 +160,41 @@ pub struct A2aCapability {
pub struct A2aAgentProfile {
/// Agent ID
pub id: AgentId,
/// Agent name
/// Display name
pub name: String,
/// Agent description
pub description: String,
/// Agent capabilities
/// Advertised capabilities
pub capabilities: Vec<A2aCapability>,
/// Supported protocols
pub protocols: Vec<String>,
/// Agent metadata
/// Agent role (e.g., "teacher", "assistant", "worker")
#[serde(default)]
pub role: String,
/// Priority for task assignment (higher = more priority)
#[serde(default)]
pub priority: u8,
/// Additional metadata
#[serde(default)]
pub metadata: HashMap<String, String>,
/// Groups this agent belongs to
#[serde(default)]
pub groups: Vec<String>,
/// Last seen timestamp
#[serde(default)]
pub last_seen: i64,
}
impl A2aAgentProfile {
/// Check if agent has a specific capability
pub fn has_capability(&self, name: &str) -> bool {
self.capabilities.iter().any(|c| c.name == name)
}
/// Get capability by name
pub fn get_capability(&self, name: &str) -> Option<&A2aCapability> {
self.capabilities.iter().find(|c| c.name == name)
}
}
/// A2A client trait
@@ -96,61 +203,487 @@ pub trait A2aClient: Send + Sync {
/// Send a message to another agent
async fn send(&self, envelope: A2aEnvelope) -> Result<()>;
/// Receive messages (streaming)
async fn receive(&self) -> Result<tokio::sync::mpsc::Receiver<A2aEnvelope>>;
/// Receive the next message (blocking)
async fn recv(&self) -> Option<A2aEnvelope>;
/// Get agent profile
/// Try to receive a message without blocking
fn try_recv(&self) -> Result<A2aEnvelope>;
/// Get agent profile by ID
async fn get_profile(&self, agent_id: &AgentId) -> Result<Option<A2aAgentProfile>>;
/// Discover agents with specific capabilities
/// Discover agents with specific capability
async fn discover(&self, capability: &str) -> Result<Vec<A2aAgentProfile>>;
/// Advertise own capabilities
async fn advertise(&self, profile: A2aAgentProfile) -> Result<()>;
/// Join a group
async fn join_group(&self, group_id: &str) -> Result<()>;
/// Leave a group
async fn leave_group(&self, group_id: &str) -> Result<()>;
/// Get all agents in a group
async fn get_group_members(&self, group_id: &str) -> Result<Vec<AgentId>>;
/// Get all online agents
async fn get_online_agents(&self) -> Result<Vec<A2aAgentProfile>>;
}
/// A2A Router - manages message routing between agents
pub struct A2aRouter {
/// Agent ID for this router instance
agent_id: AgentId,
/// Agent profiles registry
profiles: Arc<RwLock<HashMap<AgentId, A2aAgentProfile>>>,
/// Agent message queues (inbox for each agent) - using broadcast for multiple subscribers
queues: Arc<RwLock<HashMap<AgentId, mpsc::Sender<A2aEnvelope>>>>,
/// Group membership mapping (group_id -> agent_ids)
groups: Arc<RwLock<HashMap<String, Vec<AgentId>>>>,
/// Capability index (capability_name -> agent_ids)
capability_index: Arc<RwLock<HashMap<String, Vec<AgentId>>>>,
/// Channel size for message queues
channel_size: usize,
}
/// Handle for receiving A2A messages
///
/// This struct provides a way to receive messages from the A2A router.
/// It stores the receiver internally and provides methods to receive messages.
pub struct A2aReceiver {
receiver: Option<mpsc::Receiver<A2aEnvelope>>,
}
impl A2aReceiver {
fn new(rx: mpsc::Receiver<A2aEnvelope>) -> Self {
Self { receiver: Some(rx) }
}
/// Receive the next message (async)
pub async fn recv(&mut self) -> Option<A2aEnvelope> {
if let Some(ref mut rx) = self.receiver {
rx.recv().await
} else {
None
}
}
/// Try to receive a message without blocking
pub fn try_recv(&mut self) -> Result<A2aEnvelope> {
if let Some(ref mut rx) = self.receiver {
rx.try_recv()
.map_err(|e| ZclawError::Internal(format!("Receive error: {}", e)))
} else {
Err(ZclawError::Internal("No receiver available".into()))
}
}
/// Check if receiver is still active
pub fn is_active(&self) -> bool {
self.receiver.is_some()
}
}
impl A2aRouter {
/// Create a new A2A router
pub fn new(agent_id: AgentId) -> Self {
Self {
agent_id,
profiles: Arc::new(RwLock::new(HashMap::new())),
queues: Arc::new(RwLock::new(HashMap::new())),
groups: Arc::new(RwLock::new(HashMap::new())),
capability_index: Arc::new(RwLock::new(HashMap::new())),
channel_size: DEFAULT_CHANNEL_SIZE,
}
}
/// Create router with custom channel size
pub fn with_channel_size(agent_id: AgentId, channel_size: usize) -> Self {
Self {
agent_id,
profiles: Arc::new(RwLock::new(HashMap::new())),
queues: Arc::new(RwLock::new(HashMap::new())),
groups: Arc::new(RwLock::new(HashMap::new())),
capability_index: Arc::new(RwLock::new(HashMap::new())),
channel_size,
}
}
/// Register an agent with the router
pub async fn register_agent(&self, profile: A2aAgentProfile) -> mpsc::Receiver<A2aEnvelope> {
let agent_id = profile.id.clone();
// Create inbox for this agent
let (tx, rx) = mpsc::channel(self.channel_size);
// Update capability index
{
let mut cap_index = self.capability_index.write().await;
for cap in &profile.capabilities {
cap_index
.entry(cap.name.clone())
.or_insert_with(Vec::new)
.push(agent_id.clone());
}
}
// Update last seen
let mut profile = profile;
profile.last_seen = current_timestamp();
// Store profile and queue
{
let mut profiles = self.profiles.write().await;
profiles.insert(agent_id.clone(), profile);
}
{
let mut queues = self.queues.write().await;
queues.insert(agent_id, tx);
}
rx
}
/// Unregister an agent
pub async fn unregister_agent(&self, agent_id: &AgentId) {
// Remove from profiles
let profile = {
let mut profiles = self.profiles.write().await;
profiles.remove(agent_id)
};
// Remove from capability index
if let Some(profile) = profile {
let mut cap_index = self.capability_index.write().await;
for cap in &profile.capabilities {
if let Some(agents) = cap_index.get_mut(&cap.name) {
agents.retain(|id| id != agent_id);
}
}
}
// Remove from all groups
{
let mut groups = self.groups.write().await;
for members in groups.values_mut() {
members.retain(|id| id != agent_id);
}
}
// Remove queue
{
let mut queues = self.queues.write().await;
queues.remove(agent_id);
}
}
/// Route a message to recipient(s)
pub async fn route(&self, envelope: A2aEnvelope) -> Result<()> {
// Check if message has expired
if envelope.is_expired() {
return Err(ZclawError::InvalidInput("Message has expired".into()));
}
let queues = self.queues.read().await;
match &envelope.to {
A2aRecipient::Direct { agent_id } => {
// Direct message to single agent
if let Some(tx) = queues.get(agent_id) {
tx.send(envelope.clone())
.await
.map_err(|e| ZclawError::Internal(format!("Failed to send message: {}", e)))?;
} else {
tracing::warn!("Agent {} not found for direct message", agent_id);
}
}
A2aRecipient::Group { group_id } => {
// Message to all agents in group
let groups = self.groups.read().await;
if let Some(members) = groups.get(group_id) {
for agent_id in members {
if let Some(tx) = queues.get(agent_id) {
let _ = tx.send(envelope.clone()).await;
}
}
}
}
A2aRecipient::Broadcast => {
// Broadcast to all registered agents
for (agent_id, tx) in queues.iter() {
if agent_id != &envelope.from {
let _ = tx.send(envelope.clone()).await;
}
}
}
}
Ok(())
}
/// Get router's agent ID
pub fn agent_id(&self) -> &AgentId {
&self.agent_id
}
}
/// Basic A2A client implementation
pub struct BasicA2aClient {
/// Agent ID
agent_id: AgentId,
profiles: std::sync::Arc<tokio::sync::RwLock<HashMap<AgentId, A2aAgentProfile>>>,
/// Shared router reference
router: Arc<A2aRouter>,
/// Receiver for incoming messages
receiver: Arc<tokio::sync::Mutex<Option<mpsc::Receiver<A2aEnvelope>>>>,
}
impl BasicA2aClient {
pub fn new(agent_id: AgentId) -> Self {
/// Create a new A2A client with shared router
pub fn new(agent_id: AgentId, router: Arc<A2aRouter>) -> Self {
Self {
agent_id,
profiles: std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new())),
router,
receiver: Arc::new(tokio::sync::Mutex::new(None)),
}
}
/// Initialize the client (register with router)
pub async fn initialize(&self, profile: A2aAgentProfile) -> Result<()> {
let rx = self.router.register_agent(profile).await;
let mut receiver = self.receiver.lock().await;
*receiver = Some(rx);
Ok(())
}
/// Shutdown the client
pub async fn shutdown(&self) {
self.router.unregister_agent(&self.agent_id).await;
}
}
#[async_trait]
impl A2aClient for BasicA2aClient {
async fn send(&self, _envelope: A2aEnvelope) -> Result<()> {
// TODO: Implement actual A2A protocol communication
tracing::info!("A2A send called");
Ok(())
async fn send(&self, envelope: A2aEnvelope) -> Result<()> {
tracing::debug!(
from = %envelope.from,
to = %envelope.to,
type = ?envelope.message_type,
"A2A send"
);
self.router.route(envelope).await
}
async fn receive(&self) -> Result<tokio::sync::mpsc::Receiver<A2aEnvelope>> {
let (_tx, rx) = tokio::sync::mpsc::channel(100);
// TODO: Implement actual A2A protocol communication
Ok(rx)
async fn recv(&self) -> Option<A2aEnvelope> {
let mut receiver = self.receiver.lock().await;
if let Some(ref mut rx) = *receiver {
rx.recv().await
} else {
// Wait a bit and return None if no receiver
None
}
}
fn try_recv(&self) -> Result<A2aEnvelope> {
// Use blocking lock for try_recv
let mut receiver = self.receiver
.try_lock()
.map_err(|_| ZclawError::Internal("Receiver locked".into()))?;
if let Some(ref mut rx) = *receiver {
rx.try_recv()
.map_err(|e| ZclawError::Internal(format!("Receive error: {}", e)))
} else {
Err(ZclawError::Internal("No receiver available".into()))
}
}
async fn get_profile(&self, agent_id: &AgentId) -> Result<Option<A2aAgentProfile>> {
let profiles = self.profiles.read().await;
let profiles = self.router.profiles.read().await;
Ok(profiles.get(agent_id).cloned())
}
async fn discover(&self, _capability: &str) -> Result<Vec<A2aAgentProfile>> {
let profiles = self.profiles.read().await;
Ok(profiles.values().cloned().collect())
async fn discover(&self, capability: &str) -> Result<Vec<A2aAgentProfile>> {
let cap_index = self.router.capability_index.read().await;
let profiles = self.router.profiles.read().await;
if let Some(agent_ids) = cap_index.get(capability) {
let result: Vec<A2aAgentProfile> = agent_ids
.iter()
.filter_map(|id| profiles.get(id).cloned())
.collect();
Ok(result)
} else {
Ok(Vec::new())
}
}
async fn advertise(&self, profile: A2aAgentProfile) -> Result<()> {
let mut profiles = self.profiles.write().await;
profiles.insert(profile.id.clone(), profile);
tracing::info!(agent_id = %profile.id, capabilities = ?profile.capabilities.len(), "A2A advertise");
self.router.register_agent(profile).await;
Ok(())
}
async fn join_group(&self, group_id: &str) -> Result<()> {
let mut groups = self.router.groups.write().await;
groups
.entry(group_id.to_string())
.or_insert_with(Vec::new)
.push(self.agent_id.clone());
tracing::info!(agent_id = %self.agent_id, group = %group_id, "A2A join group");
Ok(())
}
async fn leave_group(&self, group_id: &str) -> Result<()> {
let mut groups = self.router.groups.write().await;
if let Some(members) = groups.get_mut(group_id) {
members.retain(|id| id != &self.agent_id);
}
tracing::info!(agent_id = %self.agent_id, group = %group_id, "A2A leave group");
Ok(())
}
async fn get_group_members(&self, group_id: &str) -> Result<Vec<AgentId>> {
let groups = self.router.groups.read().await;
Ok(groups.get(group_id).cloned().unwrap_or_default())
}
async fn get_online_agents(&self) -> Result<Vec<A2aAgentProfile>> {
let profiles = self.router.profiles.read().await;
Ok(profiles.values().cloned().collect())
}
}
// Helper functions
/// Generate a UUID v4 string using cryptographically secure random
fn uuid_v4() -> String {
Uuid::new_v4().to_string()
}
/// Get current timestamp in milliseconds
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::*;
#[test]
fn test_envelope_creation() {
let from = AgentId::new();
let to = A2aRecipient::Direct { agent_id: AgentId::new() };
let envelope = A2aEnvelope::new(
from,
to,
A2aMessageType::Request,
serde_json::json!({"action": "test"}),
);
assert!(!envelope.id.is_empty());
assert!(envelope.timestamp > 0);
assert!(envelope.conversation_id.is_none());
}
#[test]
fn test_envelope_expiry() {
let from = AgentId::new();
let to = A2aRecipient::Broadcast;
let mut envelope = A2aEnvelope::new(
from,
to,
A2aMessageType::Notification,
serde_json::json!({}),
);
envelope.ttl = 1; // 1 second
assert!(!envelope.is_expired());
// After TTL should be expired (in practice, this test might be flaky)
// We just verify the logic exists
}
#[test]
fn test_recipient_display() {
let agent_id = AgentId::new();
let direct = A2aRecipient::Direct { agent_id };
assert!(format!("{}", direct).starts_with("direct:"));
let group = A2aRecipient::Group { group_id: "teachers".to_string() };
assert_eq!(format!("{}", group), "group:teachers");
let broadcast = A2aRecipient::Broadcast;
assert_eq!(format!("{}", broadcast), "broadcast");
}
#[tokio::test]
async fn test_router_registration() {
let router = A2aRouter::new(AgentId::new());
let agent_id = AgentId::new();
let profile = A2aAgentProfile {
id: agent_id,
name: "Test Agent".to_string(),
description: "A test agent".to_string(),
capabilities: vec![A2aCapability {
name: "test".to_string(),
description: "Test capability".to_string(),
input_schema: None,
output_schema: None,
requires_approval: false,
version: "1.0.0".to_string(),
tags: vec![],
}],
protocols: vec!["a2a".to_string()],
role: "worker".to_string(),
priority: 5,
metadata: HashMap::new(),
groups: vec![],
last_seen: 0,
};
let _rx = router.register_agent(profile.clone()).await;
// Verify registration
let profiles = router.profiles.read().await;
assert!(profiles.contains_key(&agent_id));
}
#[tokio::test]
async fn test_capability_discovery() {
let router = A2aRouter::new(AgentId::new());
let agent_id = AgentId::new();
let profile = A2aAgentProfile {
id: agent_id,
name: "Test Agent".to_string(),
description: "A test agent".to_string(),
capabilities: vec![A2aCapability {
name: "code-generation".to_string(),
description: "Generate code".to_string(),
input_schema: None,
output_schema: None,
requires_approval: false,
version: "1.0.0".to_string(),
tags: vec!["coding".to_string()],
}],
protocols: vec!["a2a".to_string()],
role: "worker".to_string(),
priority: 5,
metadata: HashMap::new(),
groups: vec![],
last_seen: 0,
};
router.register_agent(profile).await;
// Check capability index
let cap_index = router.capability_index.read().await;
assert!(cap_index.contains_key("code-generation"));
}
}

View File

@@ -108,6 +108,32 @@ pub enum Event {
source: String,
message: String,
},
/// A2A message sent
A2aMessageSent {
from: AgentId,
to: String, // Recipient string representation
message_type: String,
},
/// A2A message received
A2aMessageReceived {
from: AgentId,
to: String,
message_type: String,
},
/// A2A agent discovered
A2aAgentDiscovered {
agent_id: AgentId,
capabilities: Vec<String>,
},
/// A2A capability advertised
A2aCapabilityAdvertised {
agent_id: AgentId,
capability: String,
},
}
impl Event {
@@ -131,6 +157,10 @@ impl Event {
Event::HandTriggered { .. } => "hand_triggered",
Event::HealthCheckFailed { .. } => "health_check_failed",
Event::Error { .. } => "error",
Event::A2aMessageSent { .. } => "a2a_message_sent",
Event::A2aMessageReceived { .. } => "a2a_message_received",
Event::A2aAgentDiscovered { .. } => "a2a_agent_discovered",
Event::A2aCapabilityAdvertised { .. } => "a2a_capability_advertised",
}
}
}