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>
|
||||
Reference in New Issue
Block a user