release(v0.2.0): streaming, MCP protocol, Browser Hand, security enhancements
## Major Features ### Streaming Response System - Implement LlmDriver trait with `stream()` method returning async Stream - Add SSE parsing for Anthropic and OpenAI API streaming - Integrate Tauri event system for frontend streaming (`stream:chunk` events) - Add StreamChunk types: Delta, ToolStart, ToolEnd, Complete, Error ### MCP Protocol Implementation - Add MCP JSON-RPC 2.0 types (mcp_types.rs) - Implement stdio-based MCP transport (mcp_transport.rs) - Support tool discovery, execution, and resource operations ### Browser Hand Implementation - Complete browser automation with Playwright-style actions - Support Navigate, Click, Type, Scrape, Screenshot, Wait actions - Add educational Hands: Whiteboard, Slideshow, Speech, Quiz ### Security Enhancements - Implement command whitelist/blacklist for shell_exec tool - Add SSRF protection with private IP blocking - Create security.toml configuration file ## Test Improvements - Fix test import paths (security-utils, setup) - Fix vi.mock hoisting issues with vi.hoisted() - Update test expectations for validateUrl and sanitizeFilename - Add getUnsupportedLocalGatewayStatus mock ## Documentation Updates - Update architecture documentation - Improve configuration reference - Add quick-start guide updates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
813
crates/zclaw-hands/src/hands/quiz.rs
Normal file
813
crates/zclaw-hands/src/hands/quiz.rs
Normal file
@@ -0,0 +1,813 @@
|
||||
//! Quiz Hand - Assessment and evaluation capabilities
|
||||
//!
|
||||
//! Provides quiz functionality for teaching:
|
||||
//! - generate: Generate quiz questions from content
|
||||
//! - show: Display a quiz to users
|
||||
//! - submit: Submit an answer
|
||||
//! - grade: Grade submitted answers
|
||||
//! - analyze: Analyze quiz performance
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// Trait for generating quiz questions using LLM or other AI
|
||||
#[async_trait]
|
||||
pub trait QuizGenerator: Send + Sync {
|
||||
/// Generate quiz questions based on topic and content
|
||||
async fn generate_questions(
|
||||
&self,
|
||||
topic: &str,
|
||||
content: Option<&str>,
|
||||
count: usize,
|
||||
difficulty: &DifficultyLevel,
|
||||
question_types: &[QuestionType],
|
||||
) -> Result<Vec<QuizQuestion>>;
|
||||
}
|
||||
|
||||
/// Default placeholder generator (used when no LLM is configured)
|
||||
pub struct DefaultQuizGenerator;
|
||||
|
||||
#[async_trait]
|
||||
impl QuizGenerator for DefaultQuizGenerator {
|
||||
async fn generate_questions(
|
||||
&self,
|
||||
topic: &str,
|
||||
_content: Option<&str>,
|
||||
count: usize,
|
||||
difficulty: &DifficultyLevel,
|
||||
_question_types: &[QuestionType],
|
||||
) -> Result<Vec<QuizQuestion>> {
|
||||
// Generate placeholder questions
|
||||
Ok((0..count)
|
||||
.map(|i| QuizQuestion {
|
||||
id: uuid_v4(),
|
||||
question_type: QuestionType::MultipleChoice,
|
||||
question: format!("Question {} about {}", i + 1, topic),
|
||||
options: Some(vec![
|
||||
"Option A".to_string(),
|
||||
"Option B".to_string(),
|
||||
"Option C".to_string(),
|
||||
"Option D".to_string(),
|
||||
]),
|
||||
correct_answer: Answer::Single("Option A".to_string()),
|
||||
explanation: Some(format!("Explanation for question {}", i + 1)),
|
||||
hints: Some(vec![format!("Hint 1 for question {}", i + 1)]),
|
||||
points: 10.0,
|
||||
difficulty: difficulty.clone(),
|
||||
tags: vec![topic.to_string()],
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Quiz action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum QuizAction {
|
||||
/// Generate quiz from content
|
||||
Generate {
|
||||
topic: String,
|
||||
content: Option<String>,
|
||||
question_count: Option<usize>,
|
||||
difficulty: Option<DifficultyLevel>,
|
||||
question_types: Option<Vec<QuestionType>>,
|
||||
},
|
||||
/// Show quiz question
|
||||
Show {
|
||||
quiz_id: String,
|
||||
question_index: Option<usize>,
|
||||
},
|
||||
/// Submit answer
|
||||
Submit {
|
||||
quiz_id: String,
|
||||
question_id: String,
|
||||
answer: Answer,
|
||||
},
|
||||
/// Grade quiz
|
||||
Grade {
|
||||
quiz_id: String,
|
||||
show_correct: Option<bool>,
|
||||
show_explanation: Option<bool>,
|
||||
},
|
||||
/// Analyze results
|
||||
Analyze {
|
||||
quiz_id: String,
|
||||
},
|
||||
/// Get hint
|
||||
Hint {
|
||||
quiz_id: String,
|
||||
question_id: String,
|
||||
hint_level: Option<u32>,
|
||||
},
|
||||
/// Show explanation
|
||||
Explain {
|
||||
quiz_id: String,
|
||||
question_id: String,
|
||||
},
|
||||
/// Get next question (adaptive)
|
||||
NextQuestion {
|
||||
quiz_id: String,
|
||||
current_score: Option<f64>,
|
||||
},
|
||||
/// Create quiz from template
|
||||
CreateFromTemplate {
|
||||
template: QuizTemplate,
|
||||
},
|
||||
/// Export quiz
|
||||
Export {
|
||||
quiz_id: String,
|
||||
format: ExportFormat,
|
||||
},
|
||||
}
|
||||
|
||||
/// Question types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum QuestionType {
|
||||
#[default]
|
||||
MultipleChoice,
|
||||
TrueFalse,
|
||||
FillBlank,
|
||||
ShortAnswer,
|
||||
Matching,
|
||||
Ordering,
|
||||
Essay,
|
||||
}
|
||||
|
||||
/// Difficulty levels
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DifficultyLevel {
|
||||
Easy,
|
||||
#[default]
|
||||
Medium,
|
||||
Hard,
|
||||
Adaptive,
|
||||
}
|
||||
|
||||
/// Answer types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Answer {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
Text(String),
|
||||
Ordering(Vec<String>),
|
||||
Matching(Vec<(String, String)>),
|
||||
}
|
||||
|
||||
/// Quiz template
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuizTemplate {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub time_limit_seconds: Option<u32>,
|
||||
pub passing_score: Option<f64>,
|
||||
pub allow_retry: bool,
|
||||
pub show_feedback: bool,
|
||||
pub shuffle_questions: bool,
|
||||
pub shuffle_options: bool,
|
||||
}
|
||||
|
||||
/// Quiz question
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuizQuestion {
|
||||
pub id: String,
|
||||
pub question_type: QuestionType,
|
||||
pub question: String,
|
||||
pub options: Option<Vec<String>>,
|
||||
pub correct_answer: Answer,
|
||||
pub explanation: Option<String>,
|
||||
pub hints: Option<Vec<String>>,
|
||||
pub points: f64,
|
||||
pub difficulty: DifficultyLevel,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// Quiz definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Quiz {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub questions: Vec<QuizQuestion>,
|
||||
pub time_limit_seconds: Option<u32>,
|
||||
pub passing_score: f64,
|
||||
pub allow_retry: bool,
|
||||
pub show_feedback: bool,
|
||||
pub shuffle_questions: bool,
|
||||
pub shuffle_options: bool,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// Quiz attempt
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct QuizAttempt {
|
||||
pub quiz_id: String,
|
||||
pub answers: Vec<AnswerSubmission>,
|
||||
pub score: Option<f64>,
|
||||
pub started_at: i64,
|
||||
pub completed_at: Option<i64>,
|
||||
pub time_spent_seconds: Option<u32>,
|
||||
}
|
||||
|
||||
/// Answer submission
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnswerSubmission {
|
||||
pub question_id: String,
|
||||
pub answer: Answer,
|
||||
pub is_correct: Option<bool>,
|
||||
pub points_earned: Option<f64>,
|
||||
pub feedback: Option<String>,
|
||||
}
|
||||
|
||||
/// Export format
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ExportFormat {
|
||||
#[default]
|
||||
Json,
|
||||
Qti,
|
||||
Gift,
|
||||
Markdown,
|
||||
}
|
||||
|
||||
/// Quiz state
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct QuizState {
|
||||
pub quizzes: Vec<Quiz>,
|
||||
pub attempts: Vec<QuizAttempt>,
|
||||
pub current_quiz_id: Option<String>,
|
||||
pub current_question_index: usize,
|
||||
}
|
||||
|
||||
/// Quiz Hand implementation
|
||||
pub struct QuizHand {
|
||||
config: HandConfig,
|
||||
state: Arc<RwLock<QuizState>>,
|
||||
quiz_generator: Arc<dyn QuizGenerator>,
|
||||
}
|
||||
|
||||
impl QuizHand {
|
||||
/// Create a new quiz hand with default generator
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "quiz".to_string(),
|
||||
name: "Quiz".to_string(),
|
||||
description: "Generate and manage quizzes for assessment".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"quiz_id": { "type": "string" },
|
||||
"topic": { "type": "string" },
|
||||
}
|
||||
})),
|
||||
tags: vec!["assessment".to_string(), "education".to_string()],
|
||||
enabled: true,
|
||||
},
|
||||
state: Arc::new(RwLock::new(QuizState::default())),
|
||||
quiz_generator: Arc::new(DefaultQuizGenerator),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a quiz hand with custom generator (e.g., LLM-powered)
|
||||
pub fn with_generator(generator: Arc<dyn QuizGenerator>) -> Self {
|
||||
let mut hand = Self::new();
|
||||
hand.quiz_generator = generator;
|
||||
hand
|
||||
}
|
||||
|
||||
/// Set the quiz generator at runtime
|
||||
pub fn set_generator(&mut self, generator: Arc<dyn QuizGenerator>) {
|
||||
self.quiz_generator = generator;
|
||||
}
|
||||
|
||||
/// Execute a quiz action
|
||||
pub async fn execute_action(&self, action: QuizAction) -> Result<HandResult> {
|
||||
match action {
|
||||
QuizAction::Generate { topic, content, question_count, difficulty, question_types } => {
|
||||
let count = question_count.unwrap_or(5);
|
||||
let diff = difficulty.unwrap_or_default();
|
||||
let types = question_types.unwrap_or_else(|| vec![QuestionType::MultipleChoice]);
|
||||
|
||||
// Use the configured generator (LLM or placeholder)
|
||||
let questions = self.quiz_generator
|
||||
.generate_questions(&topic, content.as_deref(), count, &diff, &types)
|
||||
.await?;
|
||||
|
||||
let quiz = Quiz {
|
||||
id: uuid_v4(),
|
||||
title: format!("Quiz: {}", topic),
|
||||
description: format!("Test your knowledge of {}", topic),
|
||||
questions,
|
||||
time_limit_seconds: None,
|
||||
passing_score: 60.0,
|
||||
allow_retry: true,
|
||||
show_feedback: true,
|
||||
shuffle_questions: false,
|
||||
shuffle_options: true,
|
||||
created_at: current_timestamp(),
|
||||
};
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
state.quizzes.push(quiz.clone());
|
||||
state.current_quiz_id = Some(quiz.id.clone());
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "generated",
|
||||
"quiz": quiz,
|
||||
})))
|
||||
}
|
||||
other => self.execute_other_action(other).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute non-generate actions (requires lock)
|
||||
async fn execute_other_action(&self, action: QuizAction) -> Result<HandResult> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match action {
|
||||
QuizAction::Show { quiz_id, question_index } => {
|
||||
let quiz = state.quizzes.iter()
|
||||
.find(|q| q.id == quiz_id);
|
||||
|
||||
match quiz {
|
||||
Some(quiz) => {
|
||||
let idx = question_index.unwrap_or(state.current_question_index);
|
||||
if idx < quiz.questions.len() {
|
||||
let question = &quiz.questions[idx];
|
||||
// Hide correct answer when showing
|
||||
let question_for_display = serde_json::json!({
|
||||
"id": question.id,
|
||||
"type": question.question_type,
|
||||
"question": question.question,
|
||||
"options": question.options,
|
||||
"points": question.points,
|
||||
"difficulty": question.difficulty,
|
||||
});
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "showing",
|
||||
"question": question_for_display,
|
||||
"question_index": idx,
|
||||
"total_questions": quiz.questions.len(),
|
||||
})))
|
||||
} else {
|
||||
Ok(HandResult::error("Question index out of range"))
|
||||
}
|
||||
}
|
||||
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
|
||||
}
|
||||
}
|
||||
QuizAction::Submit { quiz_id, question_id, answer } => {
|
||||
let submission = AnswerSubmission {
|
||||
question_id,
|
||||
answer,
|
||||
is_correct: None,
|
||||
points_earned: None,
|
||||
feedback: None,
|
||||
};
|
||||
|
||||
// Find or create attempt
|
||||
let attempt = state.attempts.iter_mut()
|
||||
.find(|a| a.quiz_id == quiz_id && a.completed_at.is_none());
|
||||
|
||||
match attempt {
|
||||
Some(attempt) => {
|
||||
attempt.answers.push(submission);
|
||||
}
|
||||
None => {
|
||||
let mut new_attempt = QuizAttempt {
|
||||
quiz_id,
|
||||
started_at: current_timestamp(),
|
||||
..Default::default()
|
||||
};
|
||||
new_attempt.answers.push(submission);
|
||||
state.attempts.push(new_attempt);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "submitted",
|
||||
})))
|
||||
}
|
||||
QuizAction::Grade { quiz_id, show_correct, show_explanation } => {
|
||||
// First, find the quiz and clone necessary data
|
||||
let quiz_data = state.quizzes.iter()
|
||||
.find(|q| q.id == quiz_id)
|
||||
.map(|quiz| (quiz.clone(), quiz.passing_score));
|
||||
|
||||
let attempt = state.attempts.iter_mut()
|
||||
.find(|a| a.quiz_id == quiz_id && a.completed_at.is_none());
|
||||
|
||||
match (quiz_data, attempt) {
|
||||
(Some((quiz, passing_score)), Some(attempt)) => {
|
||||
let mut total_points = 0.0;
|
||||
let mut earned_points = 0.0;
|
||||
|
||||
for submission in &mut attempt.answers {
|
||||
if let Some(question) = quiz.questions.iter()
|
||||
.find(|q| q.id == submission.question_id)
|
||||
{
|
||||
let is_correct = self.check_answer(&submission.answer, &question.correct_answer);
|
||||
submission.is_correct = Some(is_correct);
|
||||
submission.points_earned = Some(if is_correct { question.points } else { 0.0 });
|
||||
total_points += question.points;
|
||||
earned_points += submission.points_earned.unwrap();
|
||||
|
||||
if show_explanation.unwrap_or(true) {
|
||||
submission.feedback = question.explanation.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let score = if total_points > 0.0 {
|
||||
(earned_points / total_points) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
attempt.score = Some(score);
|
||||
attempt.completed_at = Some(current_timestamp());
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "graded",
|
||||
"score": score,
|
||||
"total_points": total_points,
|
||||
"earned_points": earned_points,
|
||||
"passed": score >= passing_score,
|
||||
"answers": if show_correct.unwrap_or(true) {
|
||||
serde_json::to_value(&attempt.answers).unwrap_or(serde_json::Value::Null)
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
},
|
||||
})))
|
||||
}
|
||||
_ => Ok(HandResult::error("Quiz or attempt not found")),
|
||||
}
|
||||
}
|
||||
QuizAction::Analyze { quiz_id } => {
|
||||
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
|
||||
let attempts: Vec<_> = state.attempts.iter()
|
||||
.filter(|a| a.quiz_id == quiz_id && a.completed_at.is_some())
|
||||
.collect();
|
||||
|
||||
match quiz {
|
||||
Some(quiz) => {
|
||||
let scores: Vec<f64> = attempts.iter()
|
||||
.filter_map(|a| a.score)
|
||||
.collect();
|
||||
|
||||
let avg_score = if !scores.is_empty() {
|
||||
scores.iter().sum::<f64>() / scores.len() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "analyzed",
|
||||
"quiz_title": quiz.title,
|
||||
"total_attempts": attempts.len(),
|
||||
"average_score": avg_score,
|
||||
"pass_rate": scores.iter().filter(|&&s| s >= quiz.passing_score).count() as f64 / scores.len().max(1) as f64 * 100.0,
|
||||
})))
|
||||
}
|
||||
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
|
||||
}
|
||||
}
|
||||
QuizAction::Hint { quiz_id, question_id, hint_level } => {
|
||||
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
|
||||
|
||||
match quiz {
|
||||
Some(quiz) => {
|
||||
let question = quiz.questions.iter()
|
||||
.find(|q| q.id == question_id);
|
||||
|
||||
match question {
|
||||
Some(q) => {
|
||||
let level = hint_level.unwrap_or(1) as usize;
|
||||
let hint = q.hints.as_ref()
|
||||
.and_then(|h| h.get(level.saturating_sub(1)))
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("No hint available at this level");
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "hint",
|
||||
"hint": hint,
|
||||
"level": level,
|
||||
})))
|
||||
}
|
||||
None => Ok(HandResult::error("Question not found")),
|
||||
}
|
||||
}
|
||||
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
|
||||
}
|
||||
}
|
||||
QuizAction::Explain { quiz_id, question_id } => {
|
||||
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
|
||||
|
||||
match quiz {
|
||||
Some(quiz) => {
|
||||
let question = quiz.questions.iter()
|
||||
.find(|q| q.id == question_id);
|
||||
|
||||
match question {
|
||||
Some(q) => {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "explanation",
|
||||
"question": q.question,
|
||||
"correct_answer": q.correct_answer,
|
||||
"explanation": q.explanation,
|
||||
})))
|
||||
}
|
||||
None => Ok(HandResult::error("Question not found")),
|
||||
}
|
||||
}
|
||||
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
|
||||
}
|
||||
}
|
||||
QuizAction::NextQuestion { quiz_id, current_score } => {
|
||||
// Adaptive quiz - select next question based on performance
|
||||
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
|
||||
|
||||
match quiz {
|
||||
Some(quiz) => {
|
||||
let score = current_score.unwrap_or(0.0);
|
||||
let next_idx = state.current_question_index + 1;
|
||||
|
||||
if next_idx < quiz.questions.len() {
|
||||
state.current_question_index = next_idx;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "next",
|
||||
"question_index": next_idx,
|
||||
})))
|
||||
} else {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "complete",
|
||||
})))
|
||||
}
|
||||
}
|
||||
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
|
||||
}
|
||||
}
|
||||
QuizAction::CreateFromTemplate { template } => {
|
||||
let quiz = Quiz {
|
||||
id: uuid_v4(),
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
questions: Vec::new(), // Would be filled in
|
||||
time_limit_seconds: template.time_limit_seconds,
|
||||
passing_score: template.passing_score.unwrap_or(60.0),
|
||||
allow_retry: template.allow_retry,
|
||||
show_feedback: template.show_feedback,
|
||||
shuffle_questions: template.shuffle_questions,
|
||||
shuffle_options: template.shuffle_options,
|
||||
created_at: current_timestamp(),
|
||||
};
|
||||
|
||||
state.quizzes.push(quiz.clone());
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "created",
|
||||
"quiz_id": quiz.id,
|
||||
})))
|
||||
}
|
||||
QuizAction::Export { quiz_id, format } => {
|
||||
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
|
||||
|
||||
match quiz {
|
||||
Some(quiz) => {
|
||||
let content = match format {
|
||||
ExportFormat::Json => serde_json::to_string_pretty(&quiz).unwrap_or_default(),
|
||||
ExportFormat::Markdown => self.export_markdown(quiz),
|
||||
_ => format!("{:?}", quiz),
|
||||
};
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "exported",
|
||||
"format": format,
|
||||
"content": content,
|
||||
})))
|
||||
}
|
||||
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
|
||||
}
|
||||
}
|
||||
// Generate is handled in execute_action, this is just for exhaustiveness
|
||||
QuizAction::Generate { .. } => {
|
||||
Ok(HandResult::error("Generate action should be handled in execute_action"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if answer is correct
|
||||
fn check_answer(&self, submitted: &Answer, correct: &Answer) -> bool {
|
||||
match (submitted, correct) {
|
||||
(Answer::Single(s), Answer::Single(c)) => s == c,
|
||||
(Answer::Multiple(s), Answer::Multiple(c)) => {
|
||||
let mut s_sorted = s.clone();
|
||||
let mut c_sorted = c.clone();
|
||||
s_sorted.sort();
|
||||
c_sorted.sort();
|
||||
s_sorted == c_sorted
|
||||
}
|
||||
(Answer::Text(s), Answer::Text(c)) => s.trim().to_lowercase() == c.trim().to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Export quiz as markdown
|
||||
fn export_markdown(&self, quiz: &Quiz) -> String {
|
||||
let mut md = format!("# {}\n\n{}\n\n", quiz.title, quiz.description);
|
||||
|
||||
for (i, q) in quiz.questions.iter().enumerate() {
|
||||
md.push_str(&format!("## Question {}\n\n{}\n\n", i + 1, q.question));
|
||||
|
||||
if let Some(options) = &q.options {
|
||||
for opt in options {
|
||||
md.push_str(&format!("- {}\n", opt));
|
||||
}
|
||||
md.push_str("\n");
|
||||
}
|
||||
|
||||
if let Some(explanation) = &q.explanation {
|
||||
md.push_str(&format!("**Explanation:** {}\n\n", explanation));
|
||||
}
|
||||
}
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub async fn get_state(&self) -> QuizState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for QuizHand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for QuizHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
let action: QuizAction = match serde_json::from_value(input) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return Ok(HandResult::error(format!("Invalid quiz action: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
self.execute_action(action).await
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
/// Generate a cryptographically secure UUID v4
|
||||
fn uuid_v4() -> String {
|
||||
Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
fn current_timestamp() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_quiz_creation() {
|
||||
let hand = QuizHand::new();
|
||||
assert_eq!(hand.config().id, "quiz");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_generate_quiz() {
|
||||
let hand = QuizHand::new();
|
||||
let action = QuizAction::Generate {
|
||||
topic: "Rust Ownership".to_string(),
|
||||
content: None,
|
||||
question_count: Some(5),
|
||||
difficulty: Some(DifficultyLevel::Medium),
|
||||
question_types: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let state = hand.get_state().await;
|
||||
assert_eq!(state.quizzes.len(), 1);
|
||||
assert_eq!(state.quizzes[0].questions.len(), 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_show_question() {
|
||||
let hand = QuizHand::new();
|
||||
|
||||
// Generate first
|
||||
hand.execute_action(QuizAction::Generate {
|
||||
topic: "Test".to_string(),
|
||||
content: None,
|
||||
question_count: Some(3),
|
||||
difficulty: None,
|
||||
question_types: None,
|
||||
}).await.unwrap();
|
||||
|
||||
let quiz_id = hand.get_state().await.quizzes[0].id.clone();
|
||||
|
||||
let result = hand.execute_action(QuizAction::Show {
|
||||
quiz_id,
|
||||
question_index: Some(0),
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_and_grade() {
|
||||
let hand = QuizHand::new();
|
||||
|
||||
// Generate
|
||||
hand.execute_action(QuizAction::Generate {
|
||||
topic: "Test".to_string(),
|
||||
content: None,
|
||||
question_count: Some(2),
|
||||
difficulty: None,
|
||||
question_types: None,
|
||||
}).await.unwrap();
|
||||
|
||||
let state = hand.get_state().await;
|
||||
let quiz_id = state.quizzes[0].id.clone();
|
||||
let q1_id = state.quizzes[0].questions[0].id.clone();
|
||||
let q2_id = state.quizzes[0].questions[1].id.clone();
|
||||
|
||||
// Submit answers
|
||||
hand.execute_action(QuizAction::Submit {
|
||||
quiz_id: quiz_id.clone(),
|
||||
question_id: q1_id,
|
||||
answer: Answer::Single("Option A".to_string()),
|
||||
}).await.unwrap();
|
||||
|
||||
hand.execute_action(QuizAction::Submit {
|
||||
quiz_id: quiz_id.clone(),
|
||||
question_id: q2_id,
|
||||
answer: Answer::Single("Option A".to_string()),
|
||||
}).await.unwrap();
|
||||
|
||||
// Grade
|
||||
let result = hand.execute_action(QuizAction::Grade {
|
||||
quiz_id,
|
||||
show_correct: Some(true),
|
||||
show_explanation: Some(true),
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_export_markdown() {
|
||||
let hand = QuizHand::new();
|
||||
|
||||
hand.execute_action(QuizAction::Generate {
|
||||
topic: "Test".to_string(),
|
||||
content: None,
|
||||
question_count: Some(2),
|
||||
difficulty: None,
|
||||
question_types: None,
|
||||
}).await.unwrap();
|
||||
|
||||
let quiz_id = hand.get_state().await.quizzes[0].id.clone();
|
||||
|
||||
let result = hand.execute_action(QuizAction::Export {
|
||||
quiz_id,
|
||||
format: ExportFormat::Markdown,
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
}
|
||||
}
|
||||
425
crates/zclaw-hands/src/hands/slideshow.rs
Normal file
425
crates/zclaw-hands/src/hands/slideshow.rs
Normal file
@@ -0,0 +1,425 @@
|
||||
//! Slideshow Hand - Presentation control capabilities
|
||||
//!
|
||||
//! Provides slideshow control for teaching:
|
||||
//! - next_slide/prev_slide: Navigation
|
||||
//! - goto_slide: Jump to specific slide
|
||||
//! - spotlight: Highlight elements
|
||||
//! - laser: Show laser pointer
|
||||
//! - highlight: Highlight areas
|
||||
//! - play_animation: Trigger animations
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// Slideshow action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum SlideshowAction {
|
||||
/// Go to next slide
|
||||
NextSlide,
|
||||
/// Go to previous slide
|
||||
PrevSlide,
|
||||
/// Go to specific slide
|
||||
GotoSlide {
|
||||
slide_number: usize,
|
||||
},
|
||||
/// Spotlight/highlight an element
|
||||
Spotlight {
|
||||
element_id: String,
|
||||
#[serde(default = "default_spotlight_duration")]
|
||||
duration_ms: u64,
|
||||
},
|
||||
/// Show laser pointer at position
|
||||
Laser {
|
||||
x: f64,
|
||||
y: f64,
|
||||
#[serde(default = "default_laser_duration")]
|
||||
duration_ms: u64,
|
||||
},
|
||||
/// Highlight a rectangular area
|
||||
Highlight {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
#[serde(default = "default_highlight_duration")]
|
||||
duration_ms: u64,
|
||||
},
|
||||
/// Play animation
|
||||
PlayAnimation {
|
||||
animation_id: String,
|
||||
},
|
||||
/// Pause auto-play
|
||||
Pause,
|
||||
/// Resume auto-play
|
||||
Resume,
|
||||
/// Start auto-play
|
||||
AutoPlay {
|
||||
#[serde(default = "default_interval")]
|
||||
interval_ms: u64,
|
||||
},
|
||||
/// Stop auto-play
|
||||
StopAutoPlay,
|
||||
/// Get current state
|
||||
GetState,
|
||||
/// Set slide content (for dynamic slides)
|
||||
SetContent {
|
||||
slide_number: usize,
|
||||
content: SlideContent,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_spotlight_duration() -> u64 { 2000 }
|
||||
fn default_laser_duration() -> u64 { 3000 }
|
||||
fn default_highlight_duration() -> u64 { 2000 }
|
||||
fn default_interval() -> u64 { 5000 }
|
||||
|
||||
/// Slide content structure
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlideContent {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub subtitle: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content: Vec<ContentBlock>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub background: Option<String>,
|
||||
}
|
||||
|
||||
/// Content block types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlock {
|
||||
Text { text: String, style: Option<TextStyle> },
|
||||
Image { url: String, alt: Option<String> },
|
||||
List { items: Vec<String>, ordered: bool },
|
||||
Code { code: String, language: Option<String> },
|
||||
Math { latex: String },
|
||||
Table { headers: Vec<String>, rows: Vec<Vec<String>> },
|
||||
Chart { chart_type: String, data: serde_json::Value },
|
||||
}
|
||||
|
||||
/// Text style options
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TextStyle {
|
||||
#[serde(default)]
|
||||
pub bold: bool,
|
||||
#[serde(default)]
|
||||
pub italic: bool,
|
||||
#[serde(default)]
|
||||
pub size: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// Slideshow state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlideshowState {
|
||||
pub current_slide: usize,
|
||||
pub total_slides: usize,
|
||||
pub is_playing: bool,
|
||||
pub auto_play_interval_ms: u64,
|
||||
pub slides: Vec<SlideContent>,
|
||||
}
|
||||
|
||||
impl Default for SlideshowState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_slide: 0,
|
||||
total_slides: 0,
|
||||
is_playing: false,
|
||||
auto_play_interval_ms: 5000,
|
||||
slides: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slideshow Hand implementation
|
||||
pub struct SlideshowHand {
|
||||
config: HandConfig,
|
||||
state: Arc<RwLock<SlideshowState>>,
|
||||
}
|
||||
|
||||
impl SlideshowHand {
|
||||
/// Create a new slideshow hand
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "slideshow".to_string(),
|
||||
name: "Slideshow".to_string(),
|
||||
description: "Control presentation slides and highlights".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"slide_number": { "type": "integer" },
|
||||
"element_id": { "type": "string" },
|
||||
}
|
||||
})),
|
||||
tags: vec!["presentation".to_string(), "education".to_string()],
|
||||
enabled: true,
|
||||
},
|
||||
state: Arc::new(RwLock::new(SlideshowState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with slides (async version)
|
||||
pub async fn with_slides_async(slides: Vec<SlideContent>) -> Self {
|
||||
let hand = Self::new();
|
||||
let mut state = hand.state.write().await;
|
||||
state.total_slides = slides.len();
|
||||
state.slides = slides;
|
||||
drop(state);
|
||||
hand
|
||||
}
|
||||
|
||||
/// Execute a slideshow action
|
||||
pub async fn execute_action(&self, action: SlideshowAction) -> Result<HandResult> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match action {
|
||||
SlideshowAction::NextSlide => {
|
||||
if state.current_slide < state.total_slides.saturating_sub(1) {
|
||||
state.current_slide += 1;
|
||||
}
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "next",
|
||||
"current_slide": state.current_slide,
|
||||
"total_slides": state.total_slides,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::PrevSlide => {
|
||||
if state.current_slide > 0 {
|
||||
state.current_slide -= 1;
|
||||
}
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "prev",
|
||||
"current_slide": state.current_slide,
|
||||
"total_slides": state.total_slides,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::GotoSlide { slide_number } => {
|
||||
if slide_number < state.total_slides {
|
||||
state.current_slide = slide_number;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "goto",
|
||||
"current_slide": state.current_slide,
|
||||
"slide_content": state.slides.get(slide_number),
|
||||
})))
|
||||
} else {
|
||||
Ok(HandResult::error(format!("Slide {} out of range", slide_number)))
|
||||
}
|
||||
}
|
||||
SlideshowAction::Spotlight { element_id, duration_ms } => {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "spotlight",
|
||||
"element_id": element_id,
|
||||
"duration_ms": duration_ms,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::Laser { x, y, duration_ms } => {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "laser",
|
||||
"x": x,
|
||||
"y": y,
|
||||
"duration_ms": duration_ms,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::Highlight { x, y, width, height, color, duration_ms } => {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "highlight",
|
||||
"x": x, "y": y,
|
||||
"width": width, "height": height,
|
||||
"color": color.unwrap_or_else(|| "#ffcc00".to_string()),
|
||||
"duration_ms": duration_ms,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::PlayAnimation { animation_id } => {
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "animation",
|
||||
"animation_id": animation_id,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::Pause => {
|
||||
state.is_playing = false;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "paused",
|
||||
})))
|
||||
}
|
||||
SlideshowAction::Resume => {
|
||||
state.is_playing = true;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "resumed",
|
||||
})))
|
||||
}
|
||||
SlideshowAction::AutoPlay { interval_ms } => {
|
||||
state.is_playing = true;
|
||||
state.auto_play_interval_ms = interval_ms;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "autoplay",
|
||||
"interval_ms": interval_ms,
|
||||
})))
|
||||
}
|
||||
SlideshowAction::StopAutoPlay => {
|
||||
state.is_playing = false;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "stopped",
|
||||
})))
|
||||
}
|
||||
SlideshowAction::GetState => {
|
||||
Ok(HandResult::success(serde_json::to_value(&*state).unwrap_or(Value::Null)))
|
||||
}
|
||||
SlideshowAction::SetContent { slide_number, content } => {
|
||||
if slide_number < state.slides.len() {
|
||||
state.slides[slide_number] = content.clone();
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "content_set",
|
||||
"slide_number": slide_number,
|
||||
})))
|
||||
} else if slide_number == state.slides.len() {
|
||||
state.slides.push(content);
|
||||
state.total_slides = state.slides.len();
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "slide_added",
|
||||
"slide_number": slide_number,
|
||||
})))
|
||||
} else {
|
||||
Ok(HandResult::error(format!("Invalid slide number: {}", slide_number)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub async fn get_state(&self) -> SlideshowState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Add a slide
|
||||
pub async fn add_slide(&self, content: SlideContent) {
|
||||
let mut state = self.state.write().await;
|
||||
state.slides.push(content);
|
||||
state.total_slides = state.slides.len();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SlideshowHand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for SlideshowHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
let action: SlideshowAction = match serde_json::from_value(input) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return Ok(HandResult::error(format!("Invalid slideshow action: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
self.execute_action(action).await
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_slideshow_creation() {
|
||||
let hand = SlideshowHand::new();
|
||||
assert_eq!(hand.config().id, "slideshow");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_navigation() {
|
||||
let hand = SlideshowHand::with_slides_async(vec![
|
||||
SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
SlideContent { title: "Slide 3".to_string(), subtitle: None, content: vec![], notes: None, background: None },
|
||||
]).await;
|
||||
|
||||
// Next
|
||||
hand.execute_action(SlideshowAction::NextSlide).await.unwrap();
|
||||
assert_eq!(hand.get_state().await.current_slide, 1);
|
||||
|
||||
// Goto
|
||||
hand.execute_action(SlideshowAction::GotoSlide { slide_number: 2 }).await.unwrap();
|
||||
assert_eq!(hand.get_state().await.current_slide, 2);
|
||||
|
||||
// Prev
|
||||
hand.execute_action(SlideshowAction::PrevSlide).await.unwrap();
|
||||
assert_eq!(hand.get_state().await.current_slide, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_spotlight() {
|
||||
let hand = SlideshowHand::new();
|
||||
let action = SlideshowAction::Spotlight {
|
||||
element_id: "title".to_string(),
|
||||
duration_ms: 2000,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_laser() {
|
||||
let hand = SlideshowHand::new();
|
||||
let action = SlideshowAction::Laser {
|
||||
x: 100.0,
|
||||
y: 200.0,
|
||||
duration_ms: 3000,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_content() {
|
||||
let hand = SlideshowHand::new();
|
||||
|
||||
let content = SlideContent {
|
||||
title: "Test Slide".to_string(),
|
||||
subtitle: Some("Subtitle".to_string()),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Hello".to_string(),
|
||||
style: None,
|
||||
}],
|
||||
notes: Some("Speaker notes".to_string()),
|
||||
background: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(SlideshowAction::SetContent {
|
||||
slide_number: 0,
|
||||
content,
|
||||
}).await.unwrap();
|
||||
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.total_slides, 1);
|
||||
}
|
||||
}
|
||||
425
crates/zclaw-hands/src/hands/speech.rs
Normal file
425
crates/zclaw-hands/src/hands/speech.rs
Normal file
@@ -0,0 +1,425 @@
|
||||
//! Speech Hand - Text-to-Speech synthesis capabilities
|
||||
//!
|
||||
//! Provides speech synthesis for teaching:
|
||||
//! - speak: Convert text to speech
|
||||
//! - speak_ssml: Advanced speech with SSML markup
|
||||
//! - pause/resume/stop: Playback control
|
||||
//! - list_voices: Get available voices
|
||||
//! - set_voice: Configure voice settings
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// TTS Provider types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TtsProvider {
|
||||
#[default]
|
||||
Browser,
|
||||
Azure,
|
||||
OpenAI,
|
||||
ElevenLabs,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// Speech action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum SpeechAction {
|
||||
/// Speak text
|
||||
Speak {
|
||||
text: String,
|
||||
#[serde(default)]
|
||||
voice: Option<String>,
|
||||
#[serde(default = "default_rate")]
|
||||
rate: f32,
|
||||
#[serde(default = "default_pitch")]
|
||||
pitch: f32,
|
||||
#[serde(default = "default_volume")]
|
||||
volume: f32,
|
||||
#[serde(default)]
|
||||
language: Option<String>,
|
||||
},
|
||||
/// Speak with SSML markup
|
||||
SpeakSsml {
|
||||
ssml: String,
|
||||
#[serde(default)]
|
||||
voice: Option<String>,
|
||||
},
|
||||
/// Pause playback
|
||||
Pause,
|
||||
/// Resume playback
|
||||
Resume,
|
||||
/// Stop playback
|
||||
Stop,
|
||||
/// List available voices
|
||||
ListVoices {
|
||||
#[serde(default)]
|
||||
language: Option<String>,
|
||||
},
|
||||
/// Set default voice
|
||||
SetVoice {
|
||||
voice: String,
|
||||
#[serde(default)]
|
||||
language: Option<String>,
|
||||
},
|
||||
/// Set provider
|
||||
SetProvider {
|
||||
provider: TtsProvider,
|
||||
#[serde(default)]
|
||||
api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
region: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_rate() -> f32 { 1.0 }
|
||||
fn default_pitch() -> f32 { 1.0 }
|
||||
fn default_volume() -> f32 { 1.0 }
|
||||
|
||||
/// Voice information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VoiceInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub language: String,
|
||||
pub gender: String,
|
||||
#[serde(default)]
|
||||
pub preview_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Playback state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub enum PlaybackState {
|
||||
#[default]
|
||||
Idle,
|
||||
Playing,
|
||||
Paused,
|
||||
}
|
||||
|
||||
/// Speech configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpeechConfig {
|
||||
pub provider: TtsProvider,
|
||||
pub default_voice: Option<String>,
|
||||
pub default_language: String,
|
||||
pub default_rate: f32,
|
||||
pub default_pitch: f32,
|
||||
pub default_volume: f32,
|
||||
}
|
||||
|
||||
impl Default for SpeechConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: TtsProvider::Browser,
|
||||
default_voice: None,
|
||||
default_language: "zh-CN".to_string(),
|
||||
default_rate: 1.0,
|
||||
default_pitch: 1.0,
|
||||
default_volume: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Speech state
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SpeechState {
|
||||
pub config: SpeechConfig,
|
||||
pub playback: PlaybackState,
|
||||
pub current_text: Option<String>,
|
||||
pub position_ms: u64,
|
||||
pub available_voices: Vec<VoiceInfo>,
|
||||
}
|
||||
|
||||
/// Speech Hand implementation
|
||||
pub struct SpeechHand {
|
||||
config: HandConfig,
|
||||
state: Arc<RwLock<SpeechState>>,
|
||||
}
|
||||
|
||||
impl SpeechHand {
|
||||
/// Create a new speech hand
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "speech".to_string(),
|
||||
name: "Speech".to_string(),
|
||||
description: "Text-to-speech synthesis for voice output".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"text": { "type": "string" },
|
||||
"voice": { "type": "string" },
|
||||
"rate": { "type": "number" },
|
||||
}
|
||||
})),
|
||||
tags: vec!["audio".to_string(), "tts".to_string(), "education".to_string()],
|
||||
enabled: true,
|
||||
},
|
||||
state: Arc::new(RwLock::new(SpeechState {
|
||||
config: SpeechConfig::default(),
|
||||
playback: PlaybackState::Idle,
|
||||
available_voices: Self::get_default_voices(),
|
||||
..Default::default()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom provider
|
||||
pub fn with_provider(provider: TtsProvider) -> Self {
|
||||
let mut hand = Self::new();
|
||||
let mut state = hand.state.blocking_write();
|
||||
state.config.provider = provider;
|
||||
drop(state);
|
||||
hand
|
||||
}
|
||||
|
||||
/// Get default voices
|
||||
fn get_default_voices() -> Vec<VoiceInfo> {
|
||||
vec![
|
||||
VoiceInfo {
|
||||
id: "zh-CN-XiaoxiaoNeural".to_string(),
|
||||
name: "Xiaoxiao".to_string(),
|
||||
language: "zh-CN".to_string(),
|
||||
gender: "female".to_string(),
|
||||
preview_url: None,
|
||||
},
|
||||
VoiceInfo {
|
||||
id: "zh-CN-YunxiNeural".to_string(),
|
||||
name: "Yunxi".to_string(),
|
||||
language: "zh-CN".to_string(),
|
||||
gender: "male".to_string(),
|
||||
preview_url: None,
|
||||
},
|
||||
VoiceInfo {
|
||||
id: "en-US-JennyNeural".to_string(),
|
||||
name: "Jenny".to_string(),
|
||||
language: "en-US".to_string(),
|
||||
gender: "female".to_string(),
|
||||
preview_url: None,
|
||||
},
|
||||
VoiceInfo {
|
||||
id: "en-US-GuyNeural".to_string(),
|
||||
name: "Guy".to_string(),
|
||||
language: "en-US".to_string(),
|
||||
gender: "male".to_string(),
|
||||
preview_url: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Execute a speech action
|
||||
pub async fn execute_action(&self, action: SpeechAction) -> Result<HandResult> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match action {
|
||||
SpeechAction::Speak { text, voice, rate, pitch, volume, language } => {
|
||||
let voice_id = voice.or(state.config.default_voice.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
let lang = language.unwrap_or_else(|| state.config.default_language.clone());
|
||||
let actual_rate = if rate == 1.0 { state.config.default_rate } else { rate };
|
||||
let actual_pitch = if pitch == 1.0 { state.config.default_pitch } else { pitch };
|
||||
let actual_volume = if volume == 1.0 { state.config.default_volume } else { volume };
|
||||
|
||||
state.playback = PlaybackState::Playing;
|
||||
state.current_text = Some(text.clone());
|
||||
|
||||
// In real implementation, would call TTS API
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "speaking",
|
||||
"text": text,
|
||||
"voice": voice_id,
|
||||
"language": lang,
|
||||
"rate": actual_rate,
|
||||
"pitch": actual_pitch,
|
||||
"volume": actual_volume,
|
||||
"provider": state.config.provider,
|
||||
"duration_ms": text.len() as u64 * 80, // Rough estimate
|
||||
})))
|
||||
}
|
||||
SpeechAction::SpeakSsml { ssml, voice } => {
|
||||
let voice_id = voice.or(state.config.default_voice.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
state.playback = PlaybackState::Playing;
|
||||
state.current_text = Some(ssml.clone());
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "speaking_ssml",
|
||||
"ssml": ssml,
|
||||
"voice": voice_id,
|
||||
"provider": state.config.provider,
|
||||
})))
|
||||
}
|
||||
SpeechAction::Pause => {
|
||||
state.playback = PlaybackState::Paused;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "paused",
|
||||
"position_ms": state.position_ms,
|
||||
})))
|
||||
}
|
||||
SpeechAction::Resume => {
|
||||
state.playback = PlaybackState::Playing;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "resumed",
|
||||
"position_ms": state.position_ms,
|
||||
})))
|
||||
}
|
||||
SpeechAction::Stop => {
|
||||
state.playback = PlaybackState::Idle;
|
||||
state.current_text = None;
|
||||
state.position_ms = 0;
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "stopped",
|
||||
})))
|
||||
}
|
||||
SpeechAction::ListVoices { language } => {
|
||||
let voices: Vec<_> = state.available_voices.iter()
|
||||
.filter(|v| {
|
||||
language.as_ref()
|
||||
.map(|l| v.language.starts_with(l))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"voices": voices,
|
||||
"count": voices.len(),
|
||||
})))
|
||||
}
|
||||
SpeechAction::SetVoice { voice, language } => {
|
||||
state.config.default_voice = Some(voice.clone());
|
||||
if let Some(lang) = language {
|
||||
state.config.default_language = lang;
|
||||
}
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "voice_set",
|
||||
"voice": voice,
|
||||
"language": state.config.default_language,
|
||||
})))
|
||||
}
|
||||
SpeechAction::SetProvider { provider, api_key, region } => {
|
||||
state.config.provider = provider.clone();
|
||||
// In real implementation, would configure provider
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"status": "provider_set",
|
||||
"provider": provider,
|
||||
"configured": api_key.is_some(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub async fn get_state(&self) -> SpeechState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SpeechHand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for SpeechHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
let action: SpeechAction = match serde_json::from_value(input) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return Ok(HandResult::error(format!("Invalid speech action: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
self.execute_action(action).await
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_speech_creation() {
|
||||
let hand = SpeechHand::new();
|
||||
assert_eq!(hand.config().id, "speech");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_speak() {
|
||||
let hand = SpeechHand::new();
|
||||
let action = SpeechAction::Speak {
|
||||
text: "Hello, world!".to_string(),
|
||||
voice: None,
|
||||
rate: 1.0,
|
||||
pitch: 1.0,
|
||||
volume: 1.0,
|
||||
language: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pause_resume() {
|
||||
let hand = SpeechHand::new();
|
||||
|
||||
// Speak first
|
||||
hand.execute_action(SpeechAction::Speak {
|
||||
text: "Test".to_string(),
|
||||
voice: None, rate: 1.0, pitch: 1.0, volume: 1.0, language: None,
|
||||
}).await.unwrap();
|
||||
|
||||
// Pause
|
||||
let result = hand.execute_action(SpeechAction::Pause).await.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
// Resume
|
||||
let result = hand.execute_action(SpeechAction::Resume).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_voices() {
|
||||
let hand = SpeechHand::new();
|
||||
let action = SpeechAction::ListVoices { language: Some("zh".to_string()) };
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_set_voice() {
|
||||
let hand = SpeechHand::new();
|
||||
let action = SpeechAction::SetVoice {
|
||||
voice: "zh-CN-XiaoxiaoNeural".to_string(),
|
||||
language: Some("zh-CN".to_string()),
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let state = hand.get_state().await;
|
||||
assert_eq!(state.config.default_voice, Some("zh-CN-XiaoxiaoNeural".to_string()));
|
||||
}
|
||||
}
|
||||
420
crates/zclaw-hands/src/hands/whiteboard.rs
Normal file
420
crates/zclaw-hands/src/hands/whiteboard.rs
Normal file
@@ -0,0 +1,420 @@
|
||||
//! Whiteboard Hand - Drawing and annotation capabilities
|
||||
//!
|
||||
//! Provides whiteboard drawing actions for teaching:
|
||||
//! - draw_text: Draw text on the whiteboard
|
||||
//! - draw_shape: Draw shapes (rectangle, circle, arrow, etc.)
|
||||
//! - draw_line: Draw lines and curves
|
||||
//! - draw_chart: Draw charts (bar, line, pie)
|
||||
//! - draw_latex: Render LaTeX formulas
|
||||
//! - draw_table: Draw data tables
|
||||
//! - clear: Clear the whiteboard
|
||||
//! - export: Export as image
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// Whiteboard action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum WhiteboardAction {
|
||||
/// Draw text
|
||||
DrawText {
|
||||
x: f64,
|
||||
y: f64,
|
||||
text: String,
|
||||
#[serde(default = "default_font_size")]
|
||||
font_size: u32,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
#[serde(default)]
|
||||
font_family: Option<String>,
|
||||
},
|
||||
/// Draw a shape
|
||||
DrawShape {
|
||||
shape: ShapeType,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
fill: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke: Option<String>,
|
||||
#[serde(default = "default_stroke_width")]
|
||||
stroke_width: u32,
|
||||
},
|
||||
/// Draw a line
|
||||
DrawLine {
|
||||
points: Vec<Point>,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
#[serde(default = "default_stroke_width")]
|
||||
stroke_width: u32,
|
||||
},
|
||||
/// Draw a chart
|
||||
DrawChart {
|
||||
chart_type: ChartType,
|
||||
data: ChartData,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
},
|
||||
/// Draw LaTeX formula
|
||||
DrawLatex {
|
||||
latex: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
#[serde(default = "default_font_size")]
|
||||
font_size: u32,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
},
|
||||
/// Draw a table
|
||||
DrawTable {
|
||||
headers: Vec<String>,
|
||||
rows: Vec<Vec<String>>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
#[serde(default)]
|
||||
column_widths: Option<Vec<f64>>,
|
||||
},
|
||||
/// Erase area
|
||||
Erase {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
},
|
||||
/// Clear whiteboard
|
||||
Clear,
|
||||
/// Undo last action
|
||||
Undo,
|
||||
/// Redo last undone action
|
||||
Redo,
|
||||
/// Export as image
|
||||
Export {
|
||||
#[serde(default = "default_export_format")]
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_font_size() -> u32 { 16 }
|
||||
fn default_stroke_width() -> u32 { 2 }
|
||||
fn default_export_format() -> String { "png".to_string() }
|
||||
|
||||
/// Shape types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShapeType {
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Circle,
|
||||
Ellipse,
|
||||
Triangle,
|
||||
Arrow,
|
||||
Star,
|
||||
Checkmark,
|
||||
Cross,
|
||||
}
|
||||
|
||||
/// Point for line drawing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// Chart types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChartType {
|
||||
Bar,
|
||||
Line,
|
||||
Pie,
|
||||
Scatter,
|
||||
Area,
|
||||
Radar,
|
||||
}
|
||||
|
||||
/// Chart data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChartData {
|
||||
pub labels: Vec<String>,
|
||||
pub datasets: Vec<Dataset>,
|
||||
}
|
||||
|
||||
/// Dataset for charts
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Dataset {
|
||||
pub label: String,
|
||||
pub values: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// Whiteboard state (for undo/redo)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WhiteboardState {
|
||||
pub actions: Vec<WhiteboardAction>,
|
||||
pub undone: Vec<WhiteboardAction>,
|
||||
pub canvas_width: f64,
|
||||
pub canvas_height: f64,
|
||||
}
|
||||
|
||||
/// Whiteboard Hand implementation
|
||||
pub struct WhiteboardHand {
|
||||
config: HandConfig,
|
||||
state: std::sync::Arc<tokio::sync::RwLock<WhiteboardState>>,
|
||||
}
|
||||
|
||||
impl WhiteboardHand {
|
||||
/// Create a new whiteboard hand
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "whiteboard".to_string(),
|
||||
name: "Whiteboard".to_string(),
|
||||
description: "Draw and annotate on a virtual whiteboard".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"text": { "type": "string" },
|
||||
}
|
||||
})),
|
||||
tags: vec!["presentation".to_string(), "education".to_string()],
|
||||
enabled: true,
|
||||
},
|
||||
state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState {
|
||||
canvas_width: 1920.0,
|
||||
canvas_height: 1080.0,
|
||||
..Default::default()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom canvas size
|
||||
pub fn with_size(width: f64, height: f64) -> Self {
|
||||
let mut hand = Self::new();
|
||||
let mut state = hand.state.blocking_write();
|
||||
state.canvas_width = width;
|
||||
state.canvas_height = height;
|
||||
drop(state);
|
||||
hand
|
||||
}
|
||||
|
||||
/// Execute a whiteboard action
|
||||
pub async fn execute_action(&self, action: WhiteboardAction) -> Result<HandResult> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match &action {
|
||||
WhiteboardAction::Clear => {
|
||||
state.actions.clear();
|
||||
state.undone.clear();
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "cleared",
|
||||
"action_count": 0
|
||||
})));
|
||||
}
|
||||
WhiteboardAction::Undo => {
|
||||
if let Some(last) = state.actions.pop() {
|
||||
state.undone.push(last);
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "undone",
|
||||
"remaining_actions": state.actions.len()
|
||||
})));
|
||||
}
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "no_action_to_undo"
|
||||
})));
|
||||
}
|
||||
WhiteboardAction::Redo => {
|
||||
if let Some(redone) = state.undone.pop() {
|
||||
state.actions.push(redone);
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "redone",
|
||||
"total_actions": state.actions.len()
|
||||
})));
|
||||
}
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "no_action_to_redo"
|
||||
})));
|
||||
}
|
||||
WhiteboardAction::Export { format } => {
|
||||
// In real implementation, would render to image
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "exported",
|
||||
"format": format,
|
||||
"data_url": format!("data:image/{};base64,<rendered_data>", format)
|
||||
})));
|
||||
}
|
||||
_ => {
|
||||
// Regular drawing action
|
||||
state.actions.push(action.clone());
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "drawn",
|
||||
"action": action,
|
||||
"total_actions": state.actions.len()
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub async fn get_state(&self) -> WhiteboardState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get all actions
|
||||
pub async fn get_actions(&self) -> Vec<WhiteboardAction> {
|
||||
self.state.read().await.actions.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WhiteboardHand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for WhiteboardHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
// Parse action from input
|
||||
let action: WhiteboardAction = match serde_json::from_value(input.clone()) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return Ok(HandResult::error(format!("Invalid whiteboard action: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
self.execute_action(action).await
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
// Check if there are any actions
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whiteboard_creation() {
|
||||
let hand = WhiteboardHand::new();
|
||||
assert_eq!(hand.config().id, "whiteboard");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_draw_text() {
|
||||
let hand = WhiteboardHand::new();
|
||||
let action = WhiteboardAction::DrawText {
|
||||
x: 100.0,
|
||||
y: 100.0,
|
||||
text: "Hello World".to_string(),
|
||||
font_size: 24,
|
||||
color: Some("#333333".to_string()),
|
||||
font_family: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let state = hand.get_state().await;
|
||||
assert_eq!(state.actions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_draw_shape() {
|
||||
let hand = WhiteboardHand::new();
|
||||
let action = WhiteboardAction::DrawShape {
|
||||
shape: ShapeType::Rectangle,
|
||||
x: 50.0,
|
||||
y: 50.0,
|
||||
width: 200.0,
|
||||
height: 100.0,
|
||||
fill: Some("#4CAF50".to_string()),
|
||||
stroke: None,
|
||||
stroke_width: 2,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_undo_redo() {
|
||||
let hand = WhiteboardHand::new();
|
||||
|
||||
// Draw something
|
||||
hand.execute_action(WhiteboardAction::DrawText {
|
||||
x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
|
||||
}).await.unwrap();
|
||||
|
||||
// Undo
|
||||
let result = hand.execute_action(WhiteboardAction::Undo).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.actions.len(), 0);
|
||||
|
||||
// Redo
|
||||
let result = hand.execute_action(WhiteboardAction::Redo).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.actions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear() {
|
||||
let hand = WhiteboardHand::new();
|
||||
|
||||
// Draw something
|
||||
hand.execute_action(WhiteboardAction::DrawText {
|
||||
x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
|
||||
}).await.unwrap();
|
||||
|
||||
// Clear
|
||||
let result = hand.execute_action(WhiteboardAction::Clear).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.actions.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chart() {
|
||||
let hand = WhiteboardHand::new();
|
||||
let action = WhiteboardAction::DrawChart {
|
||||
chart_type: ChartType::Bar,
|
||||
data: ChartData {
|
||||
labels: vec!["A".to_string(), "B".to_string(), "C".to_string()],
|
||||
datasets: vec![Dataset {
|
||||
label: "Values".to_string(),
|
||||
values: vec![10.0, 20.0, 15.0],
|
||||
color: Some("#2196F3".to_string()),
|
||||
}],
|
||||
},
|
||||
x: 100.0,
|
||||
y: 100.0,
|
||||
width: 400.0,
|
||||
height: 300.0,
|
||||
title: Some("Test Chart".to_string()),
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
907
crates/zclaw-kernel/src/director.rs
Normal file
907
crates/zclaw-kernel/src/director.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
822
crates/zclaw-kernel/src/export/html.rs
Normal file
822
crates/zclaw-kernel/src/export/html.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// 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 <World>");
|
||||
assert_eq!(html_escape("A & B"), "A & B");
|
||||
assert_eq!(html_escape("Say \"Hi\""), "Say "Hi"");
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
677
crates/zclaw-kernel/src/export/markdown.rs
Normal file
677
crates/zclaw-kernel/src/export/markdown.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
178
crates/zclaw-kernel/src/export/mod.rs
Normal file
178
crates/zclaw-kernel/src/export/mod.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
640
crates/zclaw-kernel/src/export/pptx.rs
Normal file
640
crates/zclaw-kernel/src/export/pptx.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
/// 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 <World>");
|
||||
assert_eq!(xml_escape("A & B"), "A & B");
|
||||
assert_eq!(xml_escape("Say \"Hi\""), "Say "Hi"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pptx_format() {
|
||||
let exporter = PptxExporter::new();
|
||||
assert_eq!(exporter.extension(), "pptx");
|
||||
assert_eq!(exporter.format(), super::super::ExportFormat::Pptx);
|
||||
}
|
||||
}
|
||||
1292
crates/zclaw-kernel/src/generation.rs
Normal file
1292
crates/zclaw-kernel/src/generation.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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};
|
||||
|
||||
@@ -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 = ?
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -17,3 +17,4 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user