1use super::traits::{Tool, ToolResult};
9use async_trait::async_trait;
10use parking_lot::RwLock;
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::collections::HashMap;
14use std::sync::{Arc, OnceLock};
15use tokio::sync::broadcast;
16
17static GLOBAL_STORE: OnceLock<CanvasStore> = OnceLock::new();
22
23pub fn global_store() -> CanvasStore {
25 GLOBAL_STORE.get_or_init(CanvasStore::new).clone()
26}
27
28pub const MAX_CONTENT_SIZE: usize = 256 * 1024;
30
31const MAX_HISTORY_FRAMES: usize = 50;
33
34const BROADCAST_CAPACITY: usize = 64;
36
37const MAX_CANVAS_COUNT: usize = 100;
39
40pub const ALLOWED_CONTENT_TYPES: &[&str] = &["html", "svg", "markdown", "text"];
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CanvasFrame {
46 pub frame_id: String,
48 pub content_type: String,
50 pub content: String,
52 pub timestamp: String,
54}
55
56struct CanvasEntry {
58 current: Option<CanvasFrame>,
59 history: Vec<CanvasFrame>,
60 tx: broadcast::Sender<CanvasFrame>,
61}
62
63#[derive(Clone)]
67pub struct CanvasStore {
68 inner: Arc<RwLock<HashMap<String, CanvasEntry>>>,
69}
70
71impl Default for CanvasStore {
72 fn default() -> Self {
73 Self::new()
74 }
75}
76
77impl CanvasStore {
78 pub fn new() -> Self {
79 Self {
80 inner: Arc::new(RwLock::new(HashMap::new())),
81 }
82 }
83
84 pub fn render(
87 &self,
88 canvas_id: &str,
89 content_type: &str,
90 content: &str,
91 ) -> Option<CanvasFrame> {
92 let frame = CanvasFrame {
93 frame_id: uuid::Uuid::new_v4().to_string(),
94 content_type: content_type.to_string(),
95 content: content.to_string(),
96 timestamp: chrono::Utc::now().to_rfc3339(),
97 };
98
99 let mut store = self.inner.write();
100
101 if !store.contains_key(canvas_id) && store.len() >= MAX_CANVAS_COUNT {
103 return None;
104 }
105
106 let entry = store
107 .entry(canvas_id.to_string())
108 .or_insert_with(|| CanvasEntry {
109 current: None,
110 history: Vec::new(),
111 tx: broadcast::channel(BROADCAST_CAPACITY).0,
112 });
113
114 entry.current = Some(frame.clone());
115 entry.history.push(frame.clone());
116 if entry.history.len() > MAX_HISTORY_FRAMES {
117 let excess = entry.history.len() - MAX_HISTORY_FRAMES;
118 entry.history.drain(..excess);
119 }
120
121 let _ = entry.tx.send(frame.clone());
123
124 Some(frame)
125 }
126
127 pub fn snapshot(&self, canvas_id: &str) -> Option<CanvasFrame> {
129 let store = self.inner.read();
130 store.get(canvas_id).and_then(|entry| entry.current.clone())
131 }
132
133 pub fn history(&self, canvas_id: &str) -> Vec<CanvasFrame> {
135 let store = self.inner.read();
136 store
137 .get(canvas_id)
138 .map(|entry| entry.history.clone())
139 .unwrap_or_default()
140 }
141
142 pub fn clear(&self, canvas_id: &str) -> bool {
144 let mut store = self.inner.write();
145 if let Some(entry) = store.get_mut(canvas_id) {
146 entry.current = None;
147 entry.history.clear();
148 let clear_frame = CanvasFrame {
150 frame_id: uuid::Uuid::new_v4().to_string(),
151 content_type: "clear".to_string(),
152 content: String::new(),
153 timestamp: chrono::Utc::now().to_rfc3339(),
154 };
155 let _ = entry.tx.send(clear_frame);
156 true
157 } else {
158 false
159 }
160 }
161
162 pub fn subscribe(&self, canvas_id: &str) -> Option<broadcast::Receiver<CanvasFrame>> {
166 let mut store = self.inner.write();
167
168 if !store.contains_key(canvas_id) && store.len() >= MAX_CANVAS_COUNT {
170 return None;
171 }
172
173 let entry = store
174 .entry(canvas_id.to_string())
175 .or_insert_with(|| CanvasEntry {
176 current: None,
177 history: Vec::new(),
178 tx: broadcast::channel(BROADCAST_CAPACITY).0,
179 });
180 Some(entry.tx.subscribe())
181 }
182
183 pub fn list(&self) -> Vec<String> {
185 let store = self.inner.read();
186 store.keys().cloned().collect()
187 }
188}
189
190pub struct CanvasTool {
192 store: CanvasStore,
193}
194
195impl CanvasTool {
196 pub fn new(store: CanvasStore) -> Self {
197 Self { store }
198 }
199}
200
201#[async_trait]
202impl Tool for CanvasTool {
203 fn name(&self) -> &str {
204 "canvas"
205 }
206
207 fn description(&self) -> &str {
208 "Push rendered content (HTML, SVG, Markdown) to a live web canvas that users can see \
209 in real-time. Actions: render (push content), snapshot (get current content), \
210 clear (reset canvas), eval (evaluate JS expression in canvas context). \
211 Each canvas is identified by a canvas_id string."
212 }
213
214 fn parameters_schema(&self) -> serde_json::Value {
215 json!({
216 "type": "object",
217 "properties": {
218 "action": {
219 "type": "string",
220 "description": "Action to perform on the canvas.",
221 "enum": ["render", "snapshot", "clear", "eval"]
222 },
223 "canvas_id": {
224 "type": "string",
225 "description": "Unique identifier for the canvas. Defaults to 'default'."
226 },
227 "content_type": {
228 "type": "string",
229 "description": "Content type for render action: html, svg, markdown, or text.",
230 "enum": ["html", "svg", "markdown", "text"]
231 },
232 "content": {
233 "type": "string",
234 "description": "Content to render (for render action)."
235 },
236 "expression": {
237 "type": "string",
238 "description": "JavaScript expression to evaluate (for eval action). \
239 The result is returned as text. Evaluated client-side in the canvas iframe."
240 }
241 },
242 "required": ["action"]
243 })
244 }
245
246 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
247 let action = match args.get("action").and_then(|v| v.as_str()) {
248 Some(a) => a,
249 None => {
250 return Ok(ToolResult {
251 success: false,
252 output: String::new(),
253 error: Some("Missing required parameter: action".to_string()),
254 });
255 }
256 };
257
258 let canvas_id = args
259 .get("canvas_id")
260 .and_then(|v| v.as_str())
261 .unwrap_or("default");
262
263 match action {
264 "render" => {
265 let content_type = args
266 .get("content_type")
267 .and_then(|v| v.as_str())
268 .unwrap_or("html");
269
270 let content = match args.get("content").and_then(|v| v.as_str()) {
271 Some(c) => c,
272 None => {
273 return Ok(ToolResult {
274 success: false,
275 output: String::new(),
276 error: Some(
277 "Missing required parameter: content (for render action)"
278 .to_string(),
279 ),
280 });
281 }
282 };
283
284 if content.len() > MAX_CONTENT_SIZE {
285 return Ok(ToolResult {
286 success: false,
287 output: String::new(),
288 error: Some(format!(
289 "Content exceeds maximum size of {} bytes",
290 MAX_CONTENT_SIZE
291 )),
292 });
293 }
294
295 match self.store.render(canvas_id, content_type, content) {
296 Some(frame) => Ok(ToolResult {
297 success: true,
298 output: format!(
299 "Rendered {} content to canvas '{}' (frame: {})",
300 content_type, canvas_id, frame.frame_id
301 ),
302 error: None,
303 }),
304 None => Ok(ToolResult {
305 success: false,
306 output: String::new(),
307 error: Some(format!(
308 "Maximum canvas count ({}) reached. Clear unused canvases first.",
309 MAX_CANVAS_COUNT
310 )),
311 }),
312 }
313 }
314
315 "snapshot" => match self.store.snapshot(canvas_id) {
316 Some(frame) => Ok(ToolResult {
317 success: true,
318 output: serde_json::to_string_pretty(&frame)
319 .unwrap_or_else(|_| frame.content.clone()),
320 error: None,
321 }),
322 None => Ok(ToolResult {
323 success: true,
324 output: format!("Canvas '{}' is empty", canvas_id),
325 error: None,
326 }),
327 },
328
329 "clear" => {
330 let existed = self.store.clear(canvas_id);
331 Ok(ToolResult {
332 success: true,
333 output: if existed {
334 format!("Canvas '{}' cleared", canvas_id)
335 } else {
336 format!("Canvas '{}' was already empty", canvas_id)
337 },
338 error: None,
339 })
340 }
341
342 "eval" => {
343 let expression = match args.get("expression").and_then(|v| v.as_str()) {
346 Some(e) => e,
347 None => {
348 return Ok(ToolResult {
349 success: false,
350 output: String::new(),
351 error: Some(
352 "Missing required parameter: expression (for eval action)"
353 .to_string(),
354 ),
355 });
356 }
357 };
358
359 match self.store.render(canvas_id, "eval", expression) {
361 Some(frame) => Ok(ToolResult {
362 success: true,
363 output: format!(
364 "Eval request sent to canvas '{}' (frame: {}). \
365 Result will be available to connected viewers.",
366 canvas_id, frame.frame_id
367 ),
368 error: None,
369 }),
370 None => Ok(ToolResult {
371 success: false,
372 output: String::new(),
373 error: Some(format!(
374 "Maximum canvas count ({}) reached. Clear unused canvases first.",
375 MAX_CANVAS_COUNT
376 )),
377 }),
378 }
379 }
380
381 other => Ok(ToolResult {
382 success: false,
383 output: String::new(),
384 error: Some(format!(
385 "Unknown action: '{}'. Valid actions: render, snapshot, clear, eval",
386 other
387 )),
388 }),
389 }
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn canvas_store_render_and_snapshot() {
399 let store = CanvasStore::new();
400 let frame = store.render("test", "html", "<h1>Hello</h1>").unwrap();
401 assert_eq!(frame.content_type, "html");
402 assert_eq!(frame.content, "<h1>Hello</h1>");
403
404 let snapshot = store.snapshot("test").unwrap();
405 assert_eq!(snapshot.frame_id, frame.frame_id);
406 assert_eq!(snapshot.content, "<h1>Hello</h1>");
407 }
408
409 #[test]
410 fn canvas_store_snapshot_empty_returns_none() {
411 let store = CanvasStore::new();
412 assert!(store.snapshot("nonexistent").is_none());
413 }
414
415 #[test]
416 fn canvas_store_clear_removes_content() {
417 let store = CanvasStore::new();
418 store.render("test", "html", "<p>content</p>");
419 assert!(store.snapshot("test").is_some());
420
421 let cleared = store.clear("test");
422 assert!(cleared);
423 assert!(store.snapshot("test").is_none());
424 }
425
426 #[test]
427 fn canvas_store_clear_nonexistent_returns_false() {
428 let store = CanvasStore::new();
429 assert!(!store.clear("nonexistent"));
430 }
431
432 #[test]
433 fn canvas_store_history_tracks_frames() {
434 let store = CanvasStore::new();
435 store.render("test", "html", "frame1");
436 store.render("test", "html", "frame2");
437 store.render("test", "html", "frame3");
438
439 let history = store.history("test");
440 assert_eq!(history.len(), 3);
441 assert_eq!(history[0].content, "frame1");
442 assert_eq!(history[2].content, "frame3");
443 }
444
445 #[test]
446 fn canvas_store_history_limit_enforced() {
447 let store = CanvasStore::new();
448 for i in 0..60 {
449 store.render("test", "html", &format!("frame{i}"));
450 }
451
452 let history = store.history("test");
453 assert_eq!(history.len(), MAX_HISTORY_FRAMES);
454 assert_eq!(history[0].content, "frame10");
456 }
457
458 #[test]
459 fn canvas_store_list_returns_canvas_ids() {
460 let store = CanvasStore::new();
461 store.render("alpha", "html", "a");
462 store.render("beta", "svg", "b");
463
464 let mut ids = store.list();
465 ids.sort();
466 assert_eq!(ids, vec!["alpha", "beta"]);
467 }
468
469 #[test]
470 fn canvas_store_subscribe_receives_updates() {
471 let store = CanvasStore::new();
472 let mut rx = store.subscribe("test").unwrap();
473 store.render("test", "html", "<p>live</p>");
474
475 let frame = rx.try_recv().unwrap();
476 assert_eq!(frame.content, "<p>live</p>");
477 }
478
479 #[tokio::test]
480 async fn canvas_tool_render_action() {
481 let store = CanvasStore::new();
482 let tool = CanvasTool::new(store.clone());
483 let result = tool
484 .execute(json!({
485 "action": "render",
486 "canvas_id": "test",
487 "content_type": "html",
488 "content": "<h1>Hello World</h1>"
489 }))
490 .await
491 .unwrap();
492 assert!(result.success);
493 assert!(result.output.contains("Rendered html content"));
494
495 let snapshot = store.snapshot("test").unwrap();
496 assert_eq!(snapshot.content, "<h1>Hello World</h1>");
497 }
498
499 #[tokio::test]
500 async fn canvas_tool_snapshot_action() {
501 let store = CanvasStore::new();
502 store.render("test", "html", "<p>snap</p>");
503 let tool = CanvasTool::new(store);
504 let result = tool
505 .execute(json!({"action": "snapshot", "canvas_id": "test"}))
506 .await
507 .unwrap();
508 assert!(result.success);
509 assert!(result.output.contains("<p>snap</p>"));
510 }
511
512 #[tokio::test]
513 async fn canvas_tool_snapshot_empty() {
514 let store = CanvasStore::new();
515 let tool = CanvasTool::new(store);
516 let result = tool
517 .execute(json!({"action": "snapshot", "canvas_id": "empty"}))
518 .await
519 .unwrap();
520 assert!(result.success);
521 assert!(result.output.contains("empty"));
522 }
523
524 #[tokio::test]
525 async fn canvas_tool_clear_action() {
526 let store = CanvasStore::new();
527 store.render("test", "html", "<p>clear me</p>");
528 let tool = CanvasTool::new(store.clone());
529 let result = tool
530 .execute(json!({"action": "clear", "canvas_id": "test"}))
531 .await
532 .unwrap();
533 assert!(result.success);
534 assert!(result.output.contains("cleared"));
535 assert!(store.snapshot("test").is_none());
536 }
537
538 #[tokio::test]
539 async fn canvas_tool_eval_action() {
540 let store = CanvasStore::new();
541 let tool = CanvasTool::new(store.clone());
542 let result = tool
543 .execute(json!({
544 "action": "eval",
545 "canvas_id": "test",
546 "expression": "document.title"
547 }))
548 .await
549 .unwrap();
550 assert!(result.success);
551 assert!(result.output.contains("Eval request sent"));
552
553 let snapshot = store.snapshot("test").unwrap();
554 assert_eq!(snapshot.content_type, "eval");
555 assert_eq!(snapshot.content, "document.title");
556 }
557
558 #[tokio::test]
559 async fn canvas_tool_unknown_action() {
560 let store = CanvasStore::new();
561 let tool = CanvasTool::new(store);
562 let result = tool.execute(json!({"action": "invalid"})).await.unwrap();
563 assert!(!result.success);
564 assert!(result.error.as_ref().unwrap().contains("Unknown action"));
565 }
566
567 #[tokio::test]
568 async fn canvas_tool_missing_action() {
569 let store = CanvasStore::new();
570 let tool = CanvasTool::new(store);
571 let result = tool.execute(json!({})).await.unwrap();
572 assert!(!result.success);
573 assert!(result.error.as_ref().unwrap().contains("action"));
574 }
575
576 #[tokio::test]
577 async fn canvas_tool_render_missing_content() {
578 let store = CanvasStore::new();
579 let tool = CanvasTool::new(store);
580 let result = tool
581 .execute(json!({"action": "render", "canvas_id": "test"}))
582 .await
583 .unwrap();
584 assert!(!result.success);
585 assert!(result.error.as_ref().unwrap().contains("content"));
586 }
587
588 #[tokio::test]
589 async fn canvas_tool_render_content_too_large() {
590 let store = CanvasStore::new();
591 let tool = CanvasTool::new(store);
592 let big_content = "x".repeat(MAX_CONTENT_SIZE + 1);
593 let result = tool
594 .execute(json!({
595 "action": "render",
596 "canvas_id": "test",
597 "content": big_content
598 }))
599 .await
600 .unwrap();
601 assert!(!result.success);
602 assert!(result.error.as_ref().unwrap().contains("maximum size"));
603 }
604
605 #[tokio::test]
606 async fn canvas_tool_default_canvas_id() {
607 let store = CanvasStore::new();
608 let tool = CanvasTool::new(store.clone());
609 let result = tool
610 .execute(json!({
611 "action": "render",
612 "content_type": "html",
613 "content": "<p>default</p>"
614 }))
615 .await
616 .unwrap();
617 assert!(result.success);
618 assert!(store.snapshot("default").is_some());
619 }
620
621 #[test]
622 fn canvas_store_enforces_max_canvas_count() {
623 let store = CanvasStore::new();
624 for i in 0..MAX_CANVAS_COUNT {
626 assert!(
627 store
628 .render(&format!("canvas_{i}"), "html", "content")
629 .is_some()
630 );
631 }
632 assert!(store.render("one_too_many", "html", "content").is_none());
634 assert!(store.render("canvas_0", "html", "updated").is_some());
636 }
637
638 #[tokio::test]
639 async fn canvas_tool_eval_missing_expression() {
640 let store = CanvasStore::new();
641 let tool = CanvasTool::new(store);
642 let result = tool
643 .execute(json!({"action": "eval", "canvas_id": "test"}))
644 .await
645 .unwrap();
646 assert!(!result.success);
647 assert!(result.error.as_ref().unwrap().contains("expression"));
648 }
649}