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:
420
crates/zclaw-hands/src/hands/whiteboard.rs
Normal file
420
crates/zclaw-hands/src/hands/whiteboard.rs
Normal file
@@ -0,0 +1,420 @@
|
||||
//! Whiteboard Hand - Drawing and annotation capabilities
|
||||
//!
|
||||
//! Provides whiteboard drawing actions for teaching:
|
||||
//! - draw_text: Draw text on the whiteboard
|
||||
//! - draw_shape: Draw shapes (rectangle, circle, arrow, etc.)
|
||||
//! - draw_line: Draw lines and curves
|
||||
//! - draw_chart: Draw charts (bar, line, pie)
|
||||
//! - draw_latex: Render LaTeX formulas
|
||||
//! - draw_table: Draw data tables
|
||||
//! - clear: Clear the whiteboard
|
||||
//! - export: Export as image
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// Whiteboard action types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum WhiteboardAction {
|
||||
/// Draw text
|
||||
DrawText {
|
||||
x: f64,
|
||||
y: f64,
|
||||
text: String,
|
||||
#[serde(default = "default_font_size")]
|
||||
font_size: u32,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
#[serde(default)]
|
||||
font_family: Option<String>,
|
||||
},
|
||||
/// Draw a shape
|
||||
DrawShape {
|
||||
shape: ShapeType,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
fill: Option<String>,
|
||||
#[serde(default)]
|
||||
stroke: Option<String>,
|
||||
#[serde(default = "default_stroke_width")]
|
||||
stroke_width: u32,
|
||||
},
|
||||
/// Draw a line
|
||||
DrawLine {
|
||||
points: Vec<Point>,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
#[serde(default = "default_stroke_width")]
|
||||
stroke_width: u32,
|
||||
},
|
||||
/// Draw a chart
|
||||
DrawChart {
|
||||
chart_type: ChartType,
|
||||
data: ChartData,
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
},
|
||||
/// Draw LaTeX formula
|
||||
DrawLatex {
|
||||
latex: String,
|
||||
x: f64,
|
||||
y: f64,
|
||||
#[serde(default = "default_font_size")]
|
||||
font_size: u32,
|
||||
#[serde(default)]
|
||||
color: Option<String>,
|
||||
},
|
||||
/// Draw a table
|
||||
DrawTable {
|
||||
headers: Vec<String>,
|
||||
rows: Vec<Vec<String>>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
#[serde(default)]
|
||||
column_widths: Option<Vec<f64>>,
|
||||
},
|
||||
/// Erase area
|
||||
Erase {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
},
|
||||
/// Clear whiteboard
|
||||
Clear,
|
||||
/// Undo last action
|
||||
Undo,
|
||||
/// Redo last undone action
|
||||
Redo,
|
||||
/// Export as image
|
||||
Export {
|
||||
#[serde(default = "default_export_format")]
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_font_size() -> u32 { 16 }
|
||||
fn default_stroke_width() -> u32 { 2 }
|
||||
fn default_export_format() -> String { "png".to_string() }
|
||||
|
||||
/// Shape types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ShapeType {
|
||||
Rectangle,
|
||||
RoundedRectangle,
|
||||
Circle,
|
||||
Ellipse,
|
||||
Triangle,
|
||||
Arrow,
|
||||
Star,
|
||||
Checkmark,
|
||||
Cross,
|
||||
}
|
||||
|
||||
/// Point for line drawing
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// Chart types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChartType {
|
||||
Bar,
|
||||
Line,
|
||||
Pie,
|
||||
Scatter,
|
||||
Area,
|
||||
Radar,
|
||||
}
|
||||
|
||||
/// Chart data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChartData {
|
||||
pub labels: Vec<String>,
|
||||
pub datasets: Vec<Dataset>,
|
||||
}
|
||||
|
||||
/// Dataset for charts
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Dataset {
|
||||
pub label: String,
|
||||
pub values: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// Whiteboard state (for undo/redo)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WhiteboardState {
|
||||
pub actions: Vec<WhiteboardAction>,
|
||||
pub undone: Vec<WhiteboardAction>,
|
||||
pub canvas_width: f64,
|
||||
pub canvas_height: f64,
|
||||
}
|
||||
|
||||
/// Whiteboard Hand implementation
|
||||
pub struct WhiteboardHand {
|
||||
config: HandConfig,
|
||||
state: std::sync::Arc<tokio::sync::RwLock<WhiteboardState>>,
|
||||
}
|
||||
|
||||
impl WhiteboardHand {
|
||||
/// Create a new whiteboard hand
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "whiteboard".to_string(),
|
||||
name: "Whiteboard".to_string(),
|
||||
description: "Draw and annotate on a virtual whiteboard".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"x": { "type": "number" },
|
||||
"y": { "type": "number" },
|
||||
"text": { "type": "string" },
|
||||
}
|
||||
})),
|
||||
tags: vec!["presentation".to_string(), "education".to_string()],
|
||||
enabled: true,
|
||||
},
|
||||
state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState {
|
||||
canvas_width: 1920.0,
|
||||
canvas_height: 1080.0,
|
||||
..Default::default()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom canvas size
|
||||
pub fn with_size(width: f64, height: f64) -> Self {
|
||||
let mut hand = Self::new();
|
||||
let mut state = hand.state.blocking_write();
|
||||
state.canvas_width = width;
|
||||
state.canvas_height = height;
|
||||
drop(state);
|
||||
hand
|
||||
}
|
||||
|
||||
/// Execute a whiteboard action
|
||||
pub async fn execute_action(&self, action: WhiteboardAction) -> Result<HandResult> {
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
match &action {
|
||||
WhiteboardAction::Clear => {
|
||||
state.actions.clear();
|
||||
state.undone.clear();
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "cleared",
|
||||
"action_count": 0
|
||||
})));
|
||||
}
|
||||
WhiteboardAction::Undo => {
|
||||
if let Some(last) = state.actions.pop() {
|
||||
state.undone.push(last);
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "undone",
|
||||
"remaining_actions": state.actions.len()
|
||||
})));
|
||||
}
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "no_action_to_undo"
|
||||
})));
|
||||
}
|
||||
WhiteboardAction::Redo => {
|
||||
if let Some(redone) = state.undone.pop() {
|
||||
state.actions.push(redone);
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "redone",
|
||||
"total_actions": state.actions.len()
|
||||
})));
|
||||
}
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "no_action_to_redo"
|
||||
})));
|
||||
}
|
||||
WhiteboardAction::Export { format } => {
|
||||
// In real implementation, would render to image
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "exported",
|
||||
"format": format,
|
||||
"data_url": format!("data:image/{};base64,<rendered_data>", format)
|
||||
})));
|
||||
}
|
||||
_ => {
|
||||
// Regular drawing action
|
||||
state.actions.push(action.clone());
|
||||
return Ok(HandResult::success(serde_json::json!({
|
||||
"status": "drawn",
|
||||
"action": action,
|
||||
"total_actions": state.actions.len()
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current state
|
||||
pub async fn get_state(&self) -> WhiteboardState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Get all actions
|
||||
pub async fn get_actions(&self) -> Vec<WhiteboardAction> {
|
||||
self.state.read().await.actions.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WhiteboardHand {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for WhiteboardHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
// Parse action from input
|
||||
let action: WhiteboardAction = match serde_json::from_value(input.clone()) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
return Ok(HandResult::error(format!("Invalid whiteboard action: {}", e)));
|
||||
}
|
||||
};
|
||||
|
||||
self.execute_action(action).await
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
// Check if there are any actions
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_whiteboard_creation() {
|
||||
let hand = WhiteboardHand::new();
|
||||
assert_eq!(hand.config().id, "whiteboard");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_draw_text() {
|
||||
let hand = WhiteboardHand::new();
|
||||
let action = WhiteboardAction::DrawText {
|
||||
x: 100.0,
|
||||
y: 100.0,
|
||||
text: "Hello World".to_string(),
|
||||
font_size: 24,
|
||||
color: Some("#333333".to_string()),
|
||||
font_family: None,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
|
||||
let state = hand.get_state().await;
|
||||
assert_eq!(state.actions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_draw_shape() {
|
||||
let hand = WhiteboardHand::new();
|
||||
let action = WhiteboardAction::DrawShape {
|
||||
shape: ShapeType::Rectangle,
|
||||
x: 50.0,
|
||||
y: 50.0,
|
||||
width: 200.0,
|
||||
height: 100.0,
|
||||
fill: Some("#4CAF50".to_string()),
|
||||
stroke: None,
|
||||
stroke_width: 2,
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_undo_redo() {
|
||||
let hand = WhiteboardHand::new();
|
||||
|
||||
// Draw something
|
||||
hand.execute_action(WhiteboardAction::DrawText {
|
||||
x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
|
||||
}).await.unwrap();
|
||||
|
||||
// Undo
|
||||
let result = hand.execute_action(WhiteboardAction::Undo).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.actions.len(), 0);
|
||||
|
||||
// Redo
|
||||
let result = hand.execute_action(WhiteboardAction::Redo).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.actions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear() {
|
||||
let hand = WhiteboardHand::new();
|
||||
|
||||
// Draw something
|
||||
hand.execute_action(WhiteboardAction::DrawText {
|
||||
x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None,
|
||||
}).await.unwrap();
|
||||
|
||||
// Clear
|
||||
let result = hand.execute_action(WhiteboardAction::Clear).await.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(hand.get_state().await.actions.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_chart() {
|
||||
let hand = WhiteboardHand::new();
|
||||
let action = WhiteboardAction::DrawChart {
|
||||
chart_type: ChartType::Bar,
|
||||
data: ChartData {
|
||||
labels: vec!["A".to_string(), "B".to_string(), "C".to_string()],
|
||||
datasets: vec![Dataset {
|
||||
label: "Values".to_string(),
|
||||
values: vec![10.0, 20.0, 15.0],
|
||||
color: Some("#2196F3".to_string()),
|
||||
}],
|
||||
},
|
||||
x: 100.0,
|
||||
y: 100.0,
|
||||
width: 400.0,
|
||||
height: 300.0,
|
||||
title: Some("Test Chart".to_string()),
|
||||
};
|
||||
|
||||
let result = hand.execute_action(action).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user