1use async_trait::async_trait;
6use rustant_core::canvas::{CanvasManager, CanvasMessage, CanvasTarget, ContentType};
7use rustant_core::error::ToolError;
8use rustant_core::types::{RiskLevel, ToolOutput};
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::Mutex;
12
13use crate::registry::Tool;
14
15pub type SharedCanvas = Arc<Mutex<CanvasManager>>;
17
18pub fn create_shared_canvas() -> SharedCanvas {
20 Arc::new(Mutex::new(CanvasManager::new()))
21}
22
23pub struct CanvasPushTool {
27 canvas: SharedCanvas,
28}
29
30impl CanvasPushTool {
31 pub fn new(canvas: SharedCanvas) -> Self {
32 Self { canvas }
33 }
34}
35
36#[async_trait]
37impl Tool for CanvasPushTool {
38 fn name(&self) -> &str {
39 "canvas_push"
40 }
41
42 fn description(&self) -> &str {
43 "Push content to the canvas UI. Supports HTML, Markdown, Code, Chart, Table, Form, Image, and Diagram content types."
44 }
45
46 fn parameters_schema(&self) -> serde_json::Value {
47 serde_json::json!({
48 "type": "object",
49 "properties": {
50 "content_type": {
51 "type": "string",
52 "enum": ["html", "markdown", "code", "chart", "table", "form", "image", "diagram"],
53 "description": "The type of content to push"
54 },
55 "content": {
56 "type": "string",
57 "description": "The content to display"
58 },
59 "target": {
60 "type": "string",
61 "description": "Canvas target name (empty for broadcast)"
62 }
63 },
64 "required": ["content_type", "content"]
65 })
66 }
67
68 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
69 let content_type_str =
70 args["content_type"]
71 .as_str()
72 .ok_or_else(|| ToolError::InvalidArguments {
73 name: "canvas_push".into(),
74 reason: "content_type is required".into(),
75 })?;
76 let content = args["content"]
77 .as_str()
78 .ok_or_else(|| ToolError::InvalidArguments {
79 name: "canvas".into(),
80 reason: "content is required".into(),
81 })?;
82 let target = match args["target"].as_str() {
83 Some(t) if !t.is_empty() => CanvasTarget::Named(t.into()),
84 _ => CanvasTarget::Broadcast,
85 };
86 let ct = ContentType::from_str_loose(content_type_str).ok_or_else(|| {
87 ToolError::InvalidArguments {
88 name: "canvas".into(),
89 reason: format!("Unknown content_type: {}", content_type_str),
90 }
91 })?;
92
93 let mut canvas = self.canvas.lock().await;
94 let id = canvas.push(&target, ct, content.to_string()).map_err(|e| {
95 ToolError::ExecutionFailed {
96 name: "canvas".into(),
97 message: e.to_string(),
98 }
99 })?;
100
101 Ok(ToolOutput::text(format!(
102 "Content pushed to canvas (id: {})",
103 id
104 )))
105 }
106
107 fn risk_level(&self) -> RiskLevel {
108 RiskLevel::ReadOnly
109 }
110
111 fn timeout(&self) -> Duration {
112 Duration::from_secs(5)
113 }
114}
115
116pub struct CanvasClearTool {
120 canvas: SharedCanvas,
121}
122
123impl CanvasClearTool {
124 pub fn new(canvas: SharedCanvas) -> Self {
125 Self { canvas }
126 }
127}
128
129#[async_trait]
130impl Tool for CanvasClearTool {
131 fn name(&self) -> &str {
132 "canvas_clear"
133 }
134
135 fn description(&self) -> &str {
136 "Clear all content from the canvas."
137 }
138
139 fn parameters_schema(&self) -> serde_json::Value {
140 serde_json::json!({
141 "type": "object",
142 "properties": {
143 "target": {
144 "type": "string",
145 "description": "Canvas target to clear (empty for broadcast)"
146 }
147 }
148 })
149 }
150
151 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
152 let target = match args["target"].as_str() {
153 Some(t) if !t.is_empty() => CanvasTarget::Named(t.into()),
154 _ => CanvasTarget::Broadcast,
155 };
156
157 let mut canvas = self.canvas.lock().await;
158 canvas.clear(&target);
159 Ok(ToolOutput::text("Canvas cleared"))
160 }
161
162 fn risk_level(&self) -> RiskLevel {
163 RiskLevel::Write
164 }
165
166 fn timeout(&self) -> Duration {
167 Duration::from_secs(5)
168 }
169}
170
171pub struct CanvasUpdateTool {
175 canvas: SharedCanvas,
176}
177
178impl CanvasUpdateTool {
179 pub fn new(canvas: SharedCanvas) -> Self {
180 Self { canvas }
181 }
182}
183
184#[async_trait]
185impl Tool for CanvasUpdateTool {
186 fn name(&self) -> &str {
187 "canvas_update"
188 }
189
190 fn description(&self) -> &str {
191 "Update content on the canvas (push updated content)."
192 }
193
194 fn parameters_schema(&self) -> serde_json::Value {
195 serde_json::json!({
196 "type": "object",
197 "properties": {
198 "content_type": {
199 "type": "string",
200 "enum": ["html", "markdown", "code", "chart", "table", "form", "image", "diagram"]
201 },
202 "content": {
203 "type": "string",
204 "description": "The updated content"
205 },
206 "target": {
207 "type": "string",
208 "description": "Canvas target name"
209 }
210 },
211 "required": ["content_type", "content"]
212 })
213 }
214
215 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
216 let content_type_str =
217 args["content_type"]
218 .as_str()
219 .ok_or_else(|| ToolError::InvalidArguments {
220 name: "canvas_push".into(),
221 reason: "content_type is required".into(),
222 })?;
223 let content = args["content"]
224 .as_str()
225 .ok_or_else(|| ToolError::InvalidArguments {
226 name: "canvas".into(),
227 reason: "content is required".into(),
228 })?;
229 let target = match args["target"].as_str() {
230 Some(t) if !t.is_empty() => CanvasTarget::Named(t.into()),
231 _ => CanvasTarget::Broadcast,
232 };
233 let ct = ContentType::from_str_loose(content_type_str).ok_or_else(|| {
234 ToolError::InvalidArguments {
235 name: "canvas".into(),
236 reason: format!("Unknown content_type: {}", content_type_str),
237 }
238 })?;
239
240 let mut canvas = self.canvas.lock().await;
241 let id = canvas.push(&target, ct, content.to_string()).map_err(|e| {
242 ToolError::ExecutionFailed {
243 name: "canvas".into(),
244 message: e.to_string(),
245 }
246 })?;
247
248 Ok(ToolOutput::text(format!("Canvas updated (id: {})", id)))
249 }
250
251 fn risk_level(&self) -> RiskLevel {
252 RiskLevel::Write
253 }
254
255 fn timeout(&self) -> Duration {
256 Duration::from_secs(5)
257 }
258}
259
260pub struct CanvasSnapshotTool {
264 canvas: SharedCanvas,
265}
266
267impl CanvasSnapshotTool {
268 pub fn new(canvas: SharedCanvas) -> Self {
269 Self { canvas }
270 }
271}
272
273#[async_trait]
274impl Tool for CanvasSnapshotTool {
275 fn name(&self) -> &str {
276 "canvas_snapshot"
277 }
278
279 fn description(&self) -> &str {
280 "Get a snapshot of the current canvas state."
281 }
282
283 fn parameters_schema(&self) -> serde_json::Value {
284 serde_json::json!({
285 "type": "object",
286 "properties": {
287 "target": {
288 "type": "string",
289 "description": "Canvas target to snapshot"
290 }
291 }
292 })
293 }
294
295 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
296 let target = match args["target"].as_str() {
297 Some(t) if !t.is_empty() => CanvasTarget::Named(t.into()),
298 _ => CanvasTarget::Broadcast,
299 };
300
301 let canvas = self.canvas.lock().await;
302 let items = canvas.snapshot(&target);
303
304 let snapshot: Vec<serde_json::Value> = items
305 .iter()
306 .map(|item| {
307 serde_json::json!({
308 "id": item.id.to_string(),
309 "content_type": item.content_type,
310 "content": item.content,
311 "created_at": item.created_at.to_rfc3339(),
312 })
313 })
314 .collect();
315
316 let output = serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| "[]".into());
317 Ok(ToolOutput::text(output))
318 }
319
320 fn risk_level(&self) -> RiskLevel {
321 RiskLevel::ReadOnly
322 }
323
324 fn timeout(&self) -> Duration {
325 Duration::from_secs(5)
326 }
327}
328
329#[allow(dead_code)]
333pub struct CanvasInteractTool {
334 canvas: SharedCanvas,
335}
336
337impl CanvasInteractTool {
338 pub fn new(canvas: SharedCanvas) -> Self {
339 Self { canvas }
340 }
341}
342
343#[async_trait]
344impl Tool for CanvasInteractTool {
345 fn name(&self) -> &str {
346 "canvas_interact"
347 }
348
349 fn description(&self) -> &str {
350 "Send an interaction event to the canvas (click, submit, etc.)."
351 }
352
353 fn parameters_schema(&self) -> serde_json::Value {
354 serde_json::json!({
355 "type": "object",
356 "properties": {
357 "action": {
358 "type": "string",
359 "description": "Interaction action (e.g. click, submit, select)"
360 },
361 "selector": {
362 "type": "string",
363 "description": "CSS selector or element ID"
364 },
365 "data": {
366 "type": "object",
367 "description": "Additional interaction data"
368 }
369 },
370 "required": ["action", "selector"]
371 })
372 }
373
374 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
375 let action = args["action"]
376 .as_str()
377 .ok_or_else(|| ToolError::InvalidArguments {
378 name: "canvas_interact".into(),
379 reason: "action is required".into(),
380 })?;
381 let selector = args["selector"]
382 .as_str()
383 .ok_or_else(|| ToolError::InvalidArguments {
384 name: "canvas_interact".into(),
385 reason: "selector is required".into(),
386 })?;
387 let data = args.get("data").cloned().unwrap_or(serde_json::json!({}));
388
389 let _msg = CanvasMessage::Interact {
391 action: action.into(),
392 selector: selector.into(),
393 data,
394 };
395
396 Ok(ToolOutput::text(format!(
397 "Interaction sent: {} on {}",
398 action, selector
399 )))
400 }
401
402 fn risk_level(&self) -> RiskLevel {
403 RiskLevel::Write
404 }
405
406 fn timeout(&self) -> Duration {
407 Duration::from_secs(5)
408 }
409}
410
411pub fn register_canvas_tools(registry: &mut crate::registry::ToolRegistry, canvas: SharedCanvas) {
413 let tools: Vec<Arc<dyn Tool>> = vec![
414 Arc::new(CanvasPushTool::new(canvas.clone())),
415 Arc::new(CanvasClearTool::new(canvas.clone())),
416 Arc::new(CanvasUpdateTool::new(canvas.clone())),
417 Arc::new(CanvasSnapshotTool::new(canvas.clone())),
418 Arc::new(CanvasInteractTool::new(canvas)),
419 ];
420
421 for tool in tools {
422 if let Err(e) = registry.register(tool) {
423 tracing::warn!("Failed to register canvas tool: {}", e);
424 }
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[tokio::test]
433 async fn test_canvas_push_tool() {
434 let canvas = create_shared_canvas();
435 let tool = CanvasPushTool::new(canvas.clone());
436
437 let result = tool
438 .execute(serde_json::json!({
439 "content_type": "html",
440 "content": "<h1>Hello</h1>"
441 }))
442 .await
443 .unwrap();
444
445 assert!(result.content.contains("pushed"));
446 let mgr = canvas.lock().await;
447 assert_eq!(mgr.total_items(), 1);
448 }
449
450 #[tokio::test]
451 async fn test_canvas_clear_tool() {
452 let canvas = create_shared_canvas();
453 {
454 let mut mgr = canvas.lock().await;
455 mgr.push(&CanvasTarget::Broadcast, ContentType::Html, "test".into())
456 .unwrap();
457 }
458
459 let tool = CanvasClearTool::new(canvas.clone());
460 let result = tool.execute(serde_json::json!({})).await.unwrap();
461 assert!(result.content.contains("cleared"));
462
463 let mgr = canvas.lock().await;
464 assert_eq!(mgr.total_items(), 0);
465 }
466
467 #[tokio::test]
468 async fn test_canvas_update_tool() {
469 let canvas = create_shared_canvas();
470 let tool = CanvasUpdateTool::new(canvas.clone());
471
472 let result = tool
473 .execute(serde_json::json!({
474 "content_type": "markdown",
475 "content": "# Updated"
476 }))
477 .await
478 .unwrap();
479
480 assert!(result.content.contains("updated"));
481 }
482
483 #[tokio::test]
484 async fn test_canvas_snapshot_tool() {
485 let canvas = create_shared_canvas();
486 {
487 let mut mgr = canvas.lock().await;
488 mgr.push(
489 &CanvasTarget::Broadcast,
490 ContentType::Html,
491 "<p>1</p>".into(),
492 )
493 .unwrap();
494 mgr.push(
495 &CanvasTarget::Broadcast,
496 ContentType::Code,
497 "let x = 1;".into(),
498 )
499 .unwrap();
500 }
501
502 let tool = CanvasSnapshotTool::new(canvas);
503 let result = tool.execute(serde_json::json!({})).await.unwrap();
504 let parsed: Vec<serde_json::Value> = serde_json::from_str(&result.content).unwrap();
505 assert_eq!(parsed.len(), 2);
506 }
507
508 #[tokio::test]
509 async fn test_canvas_interact_tool() {
510 let canvas = create_shared_canvas();
511 let tool = CanvasInteractTool::new(canvas);
512
513 let result = tool
514 .execute(serde_json::json!({
515 "action": "click",
516 "selector": "#submit-btn"
517 }))
518 .await
519 .unwrap();
520
521 assert!(result.content.contains("click"));
522 assert!(result.content.contains("#submit-btn"));
523 }
524
525 #[tokio::test]
526 async fn test_canvas_push_invalid_content_type() {
527 let canvas = create_shared_canvas();
528 let tool = CanvasPushTool::new(canvas);
529
530 let result = tool
531 .execute(serde_json::json!({
532 "content_type": "invalid",
533 "content": "test"
534 }))
535 .await;
536
537 assert!(result.is_err());
538 }
539}