feat: visual artifact runtime for AI-generated inline visualizations
- Add bundle/apps/runtime.html: universal runtime that executes AI-generated visualization code in a sandboxed context - Pre-loads CDN libraries: Tabler CSS, Leaflet CSS+JS, Chart.js v4, React 19 UMD, ReactDOM 19 UMD, Babel standalone 7 - Implements mcpBridge with JSON-RPC 2.0 protocol: sendToHost, callTool (with UUID ids + Promise), sendMessage, resize - executeVisual detects JSX/HTML/plain-JS paths via regex - Auto-resize via requestAnimationFrame + scrollHeight + 32, capped at 800 - Initial ui/initialize handshake with runtime='visual-artifact' v1.0.0 - Listens for 'render' and legacy 'tool/input' methods - Add tests/test_runtime_html.py: 61 tests covering all spec requirements
This commit is contained in:
@@ -0,0 +1,202 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Visual Artifact Runtime</title>
|
||||||
|
|
||||||
|
<!-- Tabler CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css" />
|
||||||
|
<!-- Leaflet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.css" />
|
||||||
|
|
||||||
|
<!-- Chart.js v4 -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
<!-- Leaflet JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.js"></script>
|
||||||
|
<!-- React 19 production UMD -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/react@19/umd/react.production.min.js"></script>
|
||||||
|
<!-- ReactDOM 19 production UMD -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/react-dom@19/umd/react-dom.production.min.js"></script>
|
||||||
|
<!-- Babel standalone 7 for JSX transpilation -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@7/babel.min.js"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
background: transparent;
|
||||||
|
color-scheme: dark;
|
||||||
|
color: #e0e0e0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-error {
|
||||||
|
color: #ff4444;
|
||||||
|
background: #1a0000;
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 12px 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #550000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ─── MCP Bridge ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mcpBridge = {
|
||||||
|
_pending: new Map(),
|
||||||
|
|
||||||
|
sendToHost(msg) {
|
||||||
|
window.parent.postMessage(msg, "*");
|
||||||
|
},
|
||||||
|
|
||||||
|
callTool(name, args) {
|
||||||
|
const id = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
mcpBridge._pending.set(id, { resolve, reject });
|
||||||
|
mcpBridge.sendToHost({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "tools/call",
|
||||||
|
id,
|
||||||
|
params: { name, arguments: args },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage(content) {
|
||||||
|
mcpBridge.sendToHost({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "ui/sendMessage",
|
||||||
|
params: { role: "user", content },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resize(height) {
|
||||||
|
mcpBridge.sendToHost({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "ui/resize",
|
||||||
|
params: { height: Math.min(height, 800) },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Execute Visual ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function executeVisual(code, data) {
|
||||||
|
const root = document.getElementById("root");
|
||||||
|
window.__VISUAL_DATA__ = data || {};
|
||||||
|
window.__MCP__ = mcpBridge;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isJSX = (
|
||||||
|
/<[A-Z]/.test(code) ||
|
||||||
|
/React\.createElement/.test(code) ||
|
||||||
|
/type="text\/babel"/.test(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isHTML = !isJSX && /<[a-z]/.test(code);
|
||||||
|
|
||||||
|
if (isJSX) {
|
||||||
|
// JSX path — compile with Babel then render
|
||||||
|
const compiled = Babel.transform(code, {
|
||||||
|
presets: ["react"],
|
||||||
|
filename: "visual.jsx",
|
||||||
|
}).code;
|
||||||
|
|
||||||
|
const autoRender = `
|
||||||
|
if (typeof App !== 'undefined') {
|
||||||
|
ReactDOM.createRoot(root).render(React.createElement(App, { data, mcp }));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fn = new Function("React", "ReactDOM", "data", "mcp", "root", compiled + "\n" + autoRender);
|
||||||
|
fn(React, ReactDOM, window.__VISUAL_DATA__, mcpBridge, root);
|
||||||
|
|
||||||
|
} else if (isHTML) {
|
||||||
|
// HTML path — inject markup and re-execute inline scripts
|
||||||
|
root.innerHTML = code;
|
||||||
|
|
||||||
|
// Re-execute all script tags (browsers don't execute scripts set via innerHTML)
|
||||||
|
const scripts = root.querySelectorAll("script");
|
||||||
|
scripts.forEach((oldScript) => {
|
||||||
|
const newScript = document.createElement("script");
|
||||||
|
if (oldScript.src) {
|
||||||
|
// External script — preserve src
|
||||||
|
newScript.src = oldScript.src;
|
||||||
|
} else {
|
||||||
|
// Inline script — wrap with data/mcp context
|
||||||
|
const body = oldScript.textContent;
|
||||||
|
newScript.textContent = `(function(data, mcp) {\n${body}\n})(window.__VISUAL_DATA__, window.__MCP__);`;
|
||||||
|
}
|
||||||
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Plain JS path — execute with explicit arguments
|
||||||
|
const fn = new Function("data", "mcp", "root", "Chart", "L", code);
|
||||||
|
fn(window.__VISUAL_DATA__, mcpBridge, root, window.Chart, window.L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resize after render
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
mcpBridge.resize(Math.max(root.scrollHeight + 32, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
// Render error state
|
||||||
|
const errorDiv = document.createElement("div");
|
||||||
|
errorDiv.className = "visual-error";
|
||||||
|
errorDiv.textContent = String(err);
|
||||||
|
root.innerHTML = "";
|
||||||
|
root.appendChild(errorDiv);
|
||||||
|
mcpBridge.resize(120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Message Listener ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
const msg = event.data;
|
||||||
|
if (!msg) return;
|
||||||
|
|
||||||
|
// Response to a pending tools/call
|
||||||
|
if (msg.id && mcpBridge._pending.has(msg.id)) {
|
||||||
|
const { resolve } = mcpBridge._pending.get(msg.id);
|
||||||
|
mcpBridge._pending.delete(msg.id);
|
||||||
|
resolve(msg.result ?? msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render command
|
||||||
|
if (msg.method === "render") {
|
||||||
|
const code = (msg.params && msg.params.code) || "";
|
||||||
|
const data = (msg.params && msg.params.data) || {};
|
||||||
|
executeVisual(code, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy: tool/input with a code param treated as render
|
||||||
|
if (msg.method === "tool/input" && msg.params && msg.params.code) {
|
||||||
|
executeVisual(msg.params.code, msg.params.data || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Initial Handshake ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
mcpBridge.sendToHost({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "ui/initialize",
|
||||||
|
params: {
|
||||||
|
runtime: "visual-artifact",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""
|
||||||
|
Tests for bundle/apps/runtime.html
|
||||||
|
|
||||||
|
Verifies the visual artifact runtime HTML file exists with all required
|
||||||
|
CDN references, script structures, and message protocol implementations
|
||||||
|
as specified in task-5.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
HTML_FILE = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"..",
|
||||||
|
"bundle",
|
||||||
|
"apps",
|
||||||
|
"runtime.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def html_content():
|
||||||
|
assert os.path.exists(HTML_FILE), f"runtime.html not found at {HTML_FILE}"
|
||||||
|
with open(HTML_FILE) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileExists:
|
||||||
|
def test_runtime_html_exists(self):
|
||||||
|
assert os.path.exists(HTML_FILE), f"runtime.html not found at {HTML_FILE}"
|
||||||
|
|
||||||
|
def test_is_complete_html_document(self, html_content):
|
||||||
|
assert "<!DOCTYPE html>" in html_content or "<!doctype html>" in html_content.lower()
|
||||||
|
assert "<html" in html_content
|
||||||
|
assert "</html>" in html_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestCDNLibraries:
|
||||||
|
"""Verify all required CDN libraries are present in the head section."""
|
||||||
|
|
||||||
|
def test_tabler_css_cdn(self, html_content):
|
||||||
|
assert "https://cdn.jsdelivr.net/npm/@tabler/core@1.0.0-beta20/dist/css/tabler.min.css" in html_content
|
||||||
|
|
||||||
|
def test_leaflet_css_cdn(self, html_content):
|
||||||
|
assert "https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.css" in html_content
|
||||||
|
|
||||||
|
def test_chartjs_cdn(self, html_content):
|
||||||
|
assert "https://cdn.jsdelivr.net/npm/chart.js@4" in html_content
|
||||||
|
|
||||||
|
def test_leaflet_js_cdn(self, html_content):
|
||||||
|
assert "https://cdn.jsdelivr.net/npm/leaflet@1.9/dist/leaflet.js" in html_content
|
||||||
|
|
||||||
|
def test_react_19_umd(self, html_content):
|
||||||
|
# React 19 production UMD
|
||||||
|
assert "react@19" in html_content
|
||||||
|
assert "umd" in html_content.lower() or "unpkg.com" in html_content or "cdn.jsdelivr.net" in html_content
|
||||||
|
|
||||||
|
def test_react_dom_19_umd(self, html_content):
|
||||||
|
# ReactDOM 19 production UMD
|
||||||
|
assert "react-dom@19" in html_content
|
||||||
|
|
||||||
|
def test_babel_standalone_7(self, html_content):
|
||||||
|
assert "@babel/standalone@7" in html_content
|
||||||
|
assert "babel.min.js" in html_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestStyles:
|
||||||
|
"""Verify required CSS styles."""
|
||||||
|
|
||||||
|
def test_transparent_body_background(self, html_content):
|
||||||
|
# transparent bg
|
||||||
|
assert "transparent" in html_content
|
||||||
|
|
||||||
|
def test_dark_color_scheme(self, html_content):
|
||||||
|
assert "color-scheme: dark" in html_content or "color-scheme:dark" in html_content
|
||||||
|
|
||||||
|
def test_body_padding_16px(self, html_content):
|
||||||
|
assert "16px" in html_content
|
||||||
|
|
||||||
|
def test_visual_error_class(self, html_content):
|
||||||
|
assert ".visual-error" in html_content
|
||||||
|
|
||||||
|
def test_visual_error_red_color(self, html_content):
|
||||||
|
# red text color for error class
|
||||||
|
assert "#ff" in html_content.lower() or "red" in html_content or "color: #" in html_content
|
||||||
|
|
||||||
|
def test_visual_error_monospace(self, html_content):
|
||||||
|
assert "monospace" in html_content
|
||||||
|
|
||||||
|
def test_visual_error_pre_wrap(self, html_content):
|
||||||
|
assert "pre-wrap" in html_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestBodyStructure:
|
||||||
|
"""Verify body has root div."""
|
||||||
|
|
||||||
|
def test_root_div_exists(self, html_content):
|
||||||
|
assert '<div id="root">' in html_content or "<div id='root'>" in html_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestMcpBridge:
|
||||||
|
"""Verify mcpBridge object implementation."""
|
||||||
|
|
||||||
|
def test_mcp_bridge_declared(self, html_content):
|
||||||
|
assert "mcpBridge" in html_content
|
||||||
|
|
||||||
|
def test_pending_map(self, html_content):
|
||||||
|
assert "_pending" in html_content
|
||||||
|
assert "Map" in html_content
|
||||||
|
|
||||||
|
def test_send_to_host_method(self, html_content):
|
||||||
|
assert "sendToHost" in html_content
|
||||||
|
assert "window.parent.postMessage" in html_content
|
||||||
|
|
||||||
|
def test_call_tool_method(self, html_content):
|
||||||
|
assert "callTool" in html_content
|
||||||
|
|
||||||
|
def test_call_tool_jsonrpc_20(self, html_content):
|
||||||
|
# jsonrpc key may appear as unquoted JS property (jsonrpc:) or quoted ("jsonrpc" / 'jsonrpc')
|
||||||
|
assert '"jsonrpc"' in html_content or "'jsonrpc'" in html_content or "jsonrpc:" in html_content
|
||||||
|
assert '"2.0"' in html_content or "'2.0'" in html_content
|
||||||
|
|
||||||
|
def test_call_tool_method_name(self, html_content):
|
||||||
|
assert "tools/call" in html_content
|
||||||
|
|
||||||
|
def test_call_tool_returns_promise(self, html_content):
|
||||||
|
assert "Promise" in html_content
|
||||||
|
|
||||||
|
def test_call_tool_uses_uuid(self, html_content):
|
||||||
|
# Random UUID for call id
|
||||||
|
assert "crypto" in html_content or "Math.random" in html_content or "uuid" in html_content.lower()
|
||||||
|
|
||||||
|
def test_send_message_method(self, html_content):
|
||||||
|
assert "sendMessage" in html_content
|
||||||
|
assert "ui/sendMessage" in html_content
|
||||||
|
|
||||||
|
def test_send_message_role_user(self, html_content):
|
||||||
|
assert "role" in html_content
|
||||||
|
assert "user" in html_content
|
||||||
|
|
||||||
|
def test_resize_method(self, html_content):
|
||||||
|
assert "resize" in html_content
|
||||||
|
assert "ui/resize" in html_content
|
||||||
|
|
||||||
|
def test_resize_capped_at_800(self, html_content):
|
||||||
|
assert "800" in html_content
|
||||||
|
assert "Math.min" in html_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteVisual:
|
||||||
|
"""Verify executeVisual function implementation."""
|
||||||
|
|
||||||
|
def test_execute_visual_function(self, html_content):
|
||||||
|
assert "executeVisual" in html_content
|
||||||
|
|
||||||
|
def test_sets_visual_data_global(self, html_content):
|
||||||
|
assert "__VISUAL_DATA__" in html_content
|
||||||
|
|
||||||
|
def test_sets_mcp_global(self, html_content):
|
||||||
|
assert "__MCP__" in html_content
|
||||||
|
|
||||||
|
def test_jsx_detection_capital_tag_regex(self, html_content):
|
||||||
|
# Regex for JSX detection: /<[A-Z]/
|
||||||
|
assert "<[A-Z]" in html_content
|
||||||
|
|
||||||
|
def test_jsx_detection_react_create_element(self, html_content):
|
||||||
|
assert "React.createElement" in html_content
|
||||||
|
|
||||||
|
def test_jsx_detection_type_text_babel(self, html_content):
|
||||||
|
# The regex pattern in JS source: /type="text\/babel"/ contains text\/babel
|
||||||
|
assert 'text/babel' in html_content or r'text\/babel' in html_content
|
||||||
|
|
||||||
|
def test_jsx_path_uses_babel_transform(self, html_content):
|
||||||
|
assert "Babel.transform" in html_content
|
||||||
|
assert "react" in html_content
|
||||||
|
|
||||||
|
def test_jsx_path_auto_render_app(self, html_content):
|
||||||
|
assert "App" in html_content
|
||||||
|
assert "ReactDOM.createRoot" in html_content
|
||||||
|
|
||||||
|
def test_html_path_detection(self, html_content):
|
||||||
|
# HTML path: code contains <[a-z]
|
||||||
|
assert "<[a-z]" in html_content
|
||||||
|
|
||||||
|
def test_html_path_sets_inner_html(self, html_content):
|
||||||
|
assert "innerHTML" in html_content
|
||||||
|
|
||||||
|
def test_html_path_clones_script_tags(self, html_content):
|
||||||
|
# Clone script elements for HTML path
|
||||||
|
assert "createElement" in html_content
|
||||||
|
assert "script" in html_content.lower()
|
||||||
|
|
||||||
|
def test_html_path_inline_scripts_wrapped(self, html_content):
|
||||||
|
# Inline scripts wrapped with data and mcp params
|
||||||
|
assert "__VISUAL_DATA__" in html_content
|
||||||
|
assert "__MCP__" in html_content
|
||||||
|
|
||||||
|
def test_plain_js_uses_new_function(self, html_content):
|
||||||
|
assert "new Function" in html_content
|
||||||
|
|
||||||
|
def test_plain_js_passes_chart_arg(self, html_content):
|
||||||
|
assert '"Chart"' in html_content or "'Chart'" in html_content or "Chart" in html_content
|
||||||
|
|
||||||
|
def test_plain_js_passes_leaflet_arg(self, html_content):
|
||||||
|
assert '"L"' in html_content or "'L'" in html_content
|
||||||
|
|
||||||
|
def test_auto_resize_via_raf(self, html_content):
|
||||||
|
assert "requestAnimationFrame" in html_content
|
||||||
|
|
||||||
|
def test_auto_resize_scroll_height(self, html_content):
|
||||||
|
assert "scrollHeight" in html_content
|
||||||
|
|
||||||
|
def test_auto_resize_adds_32(self, html_content):
|
||||||
|
assert "32" in html_content
|
||||||
|
|
||||||
|
def test_auto_resize_min_100(self, html_content):
|
||||||
|
assert "100" in html_content
|
||||||
|
assert "Math.max" in html_content
|
||||||
|
|
||||||
|
def test_error_renders_visual_error_div(self, html_content):
|
||||||
|
assert "visual-error" in html_content
|
||||||
|
|
||||||
|
def test_error_resize_120(self, html_content):
|
||||||
|
assert "120" in html_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessageListener:
|
||||||
|
"""Verify window message event listener."""
|
||||||
|
|
||||||
|
def test_message_event_listener(self, html_content):
|
||||||
|
assert "addEventListener" in html_content
|
||||||
|
assert '"message"' in html_content or "'message'" in html_content
|
||||||
|
|
||||||
|
def test_pending_response_handler(self, html_content):
|
||||||
|
# If msg.id in _pending, resolve and delete
|
||||||
|
assert "_pending" in html_content
|
||||||
|
assert "resolve" in html_content
|
||||||
|
assert "delete" in html_content
|
||||||
|
|
||||||
|
def test_render_method_handler(self, html_content):
|
||||||
|
assert '"render"' in html_content or "'render'" in html_content
|
||||||
|
|
||||||
|
def test_render_calls_execute_visual(self, html_content):
|
||||||
|
assert "executeVisual" in html_content
|
||||||
|
|
||||||
|
def test_render_params_code(self, html_content):
|
||||||
|
assert "params" in html_content
|
||||||
|
assert "code" in html_content
|
||||||
|
|
||||||
|
def test_legacy_tool_input_method(self, html_content):
|
||||||
|
assert "tool/input" in html_content
|
||||||
|
|
||||||
|
def test_legacy_tool_input_triggers_render(self, html_content):
|
||||||
|
# Legacy tool/input with code param should call executeVisual
|
||||||
|
assert "tool/input" in html_content
|
||||||
|
assert "executeVisual" in html_content
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitialHandshake:
|
||||||
|
"""Verify initial ui/initialize handshake is sent on load."""
|
||||||
|
|
||||||
|
def test_handshake_sent_on_load(self, html_content):
|
||||||
|
assert "ui/initialize" in html_content
|
||||||
|
|
||||||
|
def test_handshake_runtime_visual_artifact(self, html_content):
|
||||||
|
assert "visual-artifact" in html_content
|
||||||
|
|
||||||
|
def test_handshake_version_1_0_0(self, html_content):
|
||||||
|
assert "1.0.0" in html_content
|
||||||
|
|
||||||
|
def test_handshake_uses_send_to_host(self, html_content):
|
||||||
|
assert "sendToHost" in html_content or "window.parent.postMessage" in html_content
|
||||||
Reference in New Issue
Block a user