Skip to main content

construct/tools/
canvas.rs

1//! Live Canvas (A2UI) tool — push rendered content to a web canvas in real time.
2//!
3//! The agent can render HTML/SVG/Markdown to a named canvas, snapshot its
4//! current state, clear it, or evaluate a JavaScript expression in the canvas
5//! context. Content is stored in a shared [`CanvasStore`] and broadcast to
6//! connected WebSocket clients via per-canvas channels.
7
8use 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
17/// Process-global canvas store shared between the gateway (HTTP/WS routes
18/// subscribe to broadcast channels here) and in-process agents (the native
19/// `canvas` tool writes frames here). Both run in the same `construct daemon`
20/// process, so a singleton ensures every render reaches connected viewers.
21static GLOBAL_STORE: OnceLock<CanvasStore> = OnceLock::new();
22
23/// Return the process-global `CanvasStore`, creating it on first call.
24pub fn global_store() -> CanvasStore {
25    GLOBAL_STORE.get_or_init(CanvasStore::new).clone()
26}
27
28/// Maximum content size per canvas frame (256 KB).
29pub const MAX_CONTENT_SIZE: usize = 256 * 1024;
30
31/// Maximum number of history frames kept per canvas.
32const MAX_HISTORY_FRAMES: usize = 50;
33
34/// Broadcast channel capacity per canvas.
35const BROADCAST_CAPACITY: usize = 64;
36
37/// Maximum number of concurrent canvases to prevent memory exhaustion.
38const MAX_CANVAS_COUNT: usize = 100;
39
40/// Allowed content types for canvas frames via the REST API.
41pub const ALLOWED_CONTENT_TYPES: &[&str] = &["html", "svg", "markdown", "text"];
42
43/// A single canvas frame (one render).
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CanvasFrame {
46    /// Unique frame identifier.
47    pub frame_id: String,
48    /// Content type: `html`, `svg`, `markdown`, or `text`.
49    pub content_type: String,
50    /// The rendered content.
51    pub content: String,
52    /// ISO-8601 timestamp of when the frame was created.
53    pub timestamp: String,
54}
55
56/// Per-canvas state: current content + history + broadcast sender.
57struct CanvasEntry {
58    current: Option<CanvasFrame>,
59    history: Vec<CanvasFrame>,
60    tx: broadcast::Sender<CanvasFrame>,
61}
62
63/// Shared canvas store — holds all active canvases.
64///
65/// Thread-safe and cheaply cloneable (wraps `Arc`).
66#[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    /// Push a new frame to a canvas. Creates the canvas if it does not exist.
85    /// Returns `None` if the maximum canvas count has been reached and this is a new canvas.
86    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        // Enforce canvas count limit for new canvases.
102        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        // Best-effort broadcast — ignore errors (no receivers is fine).
122        let _ = entry.tx.send(frame.clone());
123
124        Some(frame)
125    }
126
127    /// Get the current (most recent) frame for a canvas.
128    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    /// Get the frame history for a canvas.
134    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    /// Clear a canvas (removes current content and history).
143    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            // Send an empty frame to signal clear to subscribers.
149            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    /// Subscribe to real-time updates for a canvas.
163    /// Creates the canvas entry if it does not exist (subject to canvas count limit).
164    /// Returns `None` if the canvas does not exist and the limit has been reached.
165    pub fn subscribe(&self, canvas_id: &str) -> Option<broadcast::Receiver<CanvasFrame>> {
166        let mut store = self.inner.write();
167
168        // Enforce canvas count limit for new entries.
169        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    /// List all canvas IDs that currently have content.
184    pub fn list(&self) -> Vec<String> {
185        let store = self.inner.read();
186        store.keys().cloned().collect()
187    }
188}
189
190/// `CanvasTool` — agent-callable tool for the Live Canvas (A2UI) system.
191pub 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                // Eval is handled client-side. We store an eval request as a special frame
344                // that the web viewer interprets.
345                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                // Push a special eval frame so connected clients know to evaluate it.
360                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        // Oldest frames should have been dropped
455        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        // Create MAX_CANVAS_COUNT canvases
625        for i in 0..MAX_CANVAS_COUNT {
626            assert!(
627                store
628                    .render(&format!("canvas_{i}"), "html", "content")
629                    .is_some()
630            );
631        }
632        // The next new canvas should be rejected
633        assert!(store.render("one_too_many", "html", "content").is_none());
634        // But rendering to an existing canvas should still work
635        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}