@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
605 lines • 34.8 kB
JavaScript
/**
* Sandboxed generated UI: a catalog component whose props are HTML, CSS and JavaScript the model
* writes for a one-off interactive answer (a calculator, a small simulation, a bespoke chart) when
* no catalog component fits. Opt-in — add {@link Sandbox} (or {@link defineSandbox}`({ … })`) to a
* catalog, or pass `sandbox: true` to `genui({ … })`.
*
* Everything here is framework-free and isomorphic: the server uses the definition (description,
* schema, text fallback, streaming trim), the browser renderer uses the same policy to build the
* iframe document and to check what the frame sends back.
*/
import { defineComponent } from './catalog.js';
import { kitDocsToModelText, kitHtmlInstructions, kitJsxInstructions, sandboxJsxRuntime, } from './sandbox-kit.js';
import { tailwindToModelText, vocabularyToModelText } from './sandbox-styles.js';
import { themeTokens, themeToModelText } from './sandbox-theme.js';
import { formatSandboxIssue, validateSandboxCode } from './sandbox-validate.js';
/** The component name the builtin {@link Sandbox} registers under. */
export const SANDBOX_COMPONENT = 'Sandbox';
/**
* The order the fields stream in, and the order the renderer uses them: a placeholder of the right
* height first, then the styles (so the markup never draws unstyled), the markup, and the behavior
* last — the same sequence as CopilotKit's Open Generative UI.
*/
export const SANDBOX_FIELD_ORDER = [
'initialHeight',
'placeholderMessages',
'title',
'summary',
'css',
'html',
'jsFunctions',
'jsExpressions',
'jsx',
];
export const SANDBOX_DEFAULTS = {
initialHeight: 240,
maxPayloadBytes: 8192,
minSendIntervalMs: 1000,
maxSends: 20,
maxHeight: 1600,
};
const ORIGIN = /^https:\/\/(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)*(:\d{1,5})?$/i;
/** Throws unless every listed origin is a bare `https://host[:port]` (or `https://*.host`). */
export function assertSandboxPolicy(policy) {
for (const [kind, origins] of Object.entries(policy.allow ?? {})) {
for (const origin of origins ?? []) {
if (typeof origin !== 'string' || !ORIGIN.test(origin)) {
throw new TypeError(`genui sandbox: allow.${kind} entry "${String(origin)}" is not an https origin (https://host[:port] or https://*.host)`);
}
}
}
for (const key of ['maxPayloadBytes', 'minSendIntervalMs', 'maxSends', 'maxHeight']) {
const value = policy[key];
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
throw new RangeError(`genui sandbox: ${key} must be a non-negative number`);
}
}
}
/**
* The Content-Security-Policy every sandbox document carries (as a `<meta>`, first thing in its
* head). Inline scripts and styles run — they ARE the generated UI — but nothing else loads, no
* form posts and no navigation of the frame's base, unless the policy lists an origin.
*/
export function sandboxCsp(policy = {}) {
assertSandboxPolicy(policy);
const allow = policy.allow ?? {};
const list = (origins) => origins !== undefined && origins.length > 0 ? ` ${origins.join(' ')}` : '';
const connect = allow.connect !== undefined && allow.connect.length > 0;
return [
"default-src 'none'",
`script-src 'unsafe-inline'${list(allow.scripts)}`,
`style-src 'unsafe-inline'${list(allow.styles)}`,
`img-src data: blob:${list(allow.images)}`,
`font-src data:${list(allow.fonts)}`,
`media-src data: blob:${list(allow.media)}`,
connect ? `connect-src${list(allow.connect)}` : "connect-src 'none'",
"frame-src 'none'",
"worker-src 'none'",
"object-src 'none'",
"form-action 'none'",
"base-uri 'none'",
].join('; ');
}
/** The `sandbox` attribute of the frame: scripts, and nothing else — above all no `allow-same-origin`. */
export const SANDBOX_IFRAME_FLAGS = 'allow-scripts';
/** The type tags of the messages a sandbox frame and its host exchange. */
export const SANDBOX_MESSAGE = {
/** frame → host: the bridge is up. */
ready: 'agora:sandbox:ready',
/** frame → host: `{ height }` — the content's height. */
resize: 'agora:sandbox:resize',
/** frame → host: `{ payload }` — `agent.send(payload)`. */
send: 'agora:sandbox:send',
/** frame → host: `{ message }` — an uncaught error in the generated code. */
error: 'agora:sandbox:error',
/** host → frame: `{ css, html }` — redraw the preview (no scripts run). */
render: 'agora:sandbox:render',
/** host → frame: `{ code }` — run one more classic script. */
run: 'agora:sandbox:run',
/** host → frame: `{ css, dark }` — the host's theme changed (light/dark, a new stylesheet). */
theme: 'agora:sandbox:theme',
/** host → frame: `{ code, final }` — the JSX so far (`final` once the model is done). */
jsx: 'agora:sandbox:jsx',
/**
* frame → host: the frame is up and waits for its shared assets (a document built with
* `sharedAssets`); it says `ready` once they are in.
*/
load: 'agora:sandbox:load',
/** host → frame: `{ styles?, tailwind?, kit? }` — the shared assets' text. */
assets: 'agora:sandbox:assets',
};
/** `</script` inside generated code would close the element it is inlined in. */
function inlineScript(code) {
return code.replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\!--');
}
function inlineStyle(css) {
return css.replace(/<\/(style)/gi, '<\\/$1');
}
function escapeAttribute(value) {
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
}
/**
* The bridge every sandbox document starts with: `window.agent.send(values)`, content-height
* reports, uncaught errors, and — from the host only — `render` (preview redraws) and `run` (one more
* script). It is the only way out of the frame; the host checks everything it says.
*
* The height it reports is the CONTENT's — the bottom of what the body holds, margins included —
* never `scrollHeight`, which does not drop below the frame's own height: a frame mounted at the
* model's `initialHeight` would otherwise keep that height (an empty gap under a short view) and
* could only ever grow. It re-measures on every resize of the page or of a top-level element, on
* every DOM or style change (the Tailwind runtime injecting its CSS, the kit drawing JSX), once
* fonts and images load, and twice more shortly after start; the first measure rides `ready`.
*
* An open overlay — a dialog, a menu, a popover in a portal (`position: fixed`, `role="dialog"`,
* `<dialog open>`, `:popover-open`…) — counts too, for as long as it is open: the frame grows to
* fit it (a viewport-sized overlay, like a modal's backdrop, by what it holds — its content's full
* height and margins — not by its own height, which is the frame's; a box centered in the frame, by
* its full height and the room around it) and shrinks back once it closes.
*
* `shared`: the document's heavy assets come from the host (`load` → `assets`); the bridge applies
* them in order — the styles where the document reserved them, then the Tailwind runtime and the
* kit as inline scripts — runs `window.__agoraAfterAssets` and only then says `ready`.
*/
function bridgeScript(token, hostOrigin, transport = 'parent', shared = false) {
const t = JSON.stringify(token);
const o = JSON.stringify(hostOrigin);
const m = JSON.stringify(SANDBOX_MESSAGE);
const native = transport === 'react-native';
const deliver = native
? '(R||window.ReactNativeWebView).postMessage(JSON.stringify(msg))'
: 'P.postMessage(msg,O)';
const assets = shared
? `var loaded=false;
function apply(d){if(loaded)return;loaded=true;
var at=document.getElementById('agora-sandbox-kit-css');if(at&&typeof d.styles==='string')at.textContent=d.styles;
var order=[['tailwind',d.tailwind],['kit',d.kit]];
for(var i=0;i<order.length;i++){var v=order[i][1];if(typeof v!=='string')continue;var s=document.createElement('script');s.textContent=v;document.head.appendChild(s)}
try{if(typeof window.__agoraAfterAssets==='function')window.__agoraAfterAssets()}catch(e){post(M.error,{message:String(e&&e.message||e)})}
var w=document.getElementById('agora-sandbox-wait');if(w)w.remove();size();post(M.ready,{height:last})}
`
: '';
return `(function(){var T=${t},O=${o},M=${m},P=window.parent${native ? ',R=window.ReactNativeWebView' : ''};
function post(type,data){var msg={type:type,token:T};for(var k in data)msg[k]=data[k];try{${deliver}}catch(e){}}
window.agent=Object.freeze({send:function(payload){var clean;try{clean=JSON.parse(JSON.stringify(payload===undefined?{}:payload))}catch(e){throw new TypeError('agent.send: the payload must be JSON')}post(M.send,{payload:clean});return true}});
var last=-1,queued=false;
var OVERLAY='dialog[open],[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[role="tooltip"],[aria-modal="true"],[data-radix-popper-content-wrapper]';
function shown(el,cs){return cs.display!=='none'&&cs.visibility!=='hidden'&&el.getClientRects().length>0}
function overlays(b){var out=[],kids=b.children;for(var i=0;i<kids.length;i++){var k=kids[i];if(k.tagName==='SCRIPT'||k.tagName==='STYLE')continue;var cs=getComputedStyle(k);if(cs.position==='fixed'&&shown(k,cs))out.push(k)}
var found=[];try{found=document.querySelectorAll(OVERLAY)}catch(e){}for(var j=0;j<found.length;j++){var f=found[j];if(out.indexOf(f)<0&&shown(f,getComputedStyle(f)))out.push(f)}
try{var pops=document.querySelectorAll('[popover]');for(var p=0;p<pops.length;p++){if(pops[p].matches(':popover-open')&&out.indexOf(pops[p])<0)out.push(pops[p])}}catch(e){}return out}
function overlayBottom(b){var need=0,vh=window.innerHeight||0,list=overlays(b);
for(var i=0;i<list.length;i++){var root=list[i],r=root.getBoundingClientRect(),covers=r.top<=1&&r.height>=vh-1;var queue=[root],seen=0;
while(queue.length>0&&seen<400){var el=queue.shift();seen++;var cs=getComputedStyle(el);if(cs.display==='none')continue;
var mt=parseFloat(cs.marginTop)||0,mb=parseFloat(cs.marginBottom)||0,er=el.getBoundingClientRect(),h=Math.max(er.height,el.scrollHeight);
var centered=!covers&&el===root&&er.top>0&&Math.abs(vh-er.bottom-er.top)<=2;var v=covers?(el===root?0:h+mt+mb):centered?h+(vh-er.height):er.top+h+mb;if(covers&&el!==root&&el.scrollHeight>=vh-1&&er.height>=vh-1)v=0;if(v>need)need=v;
for(var c=0;c<el.children.length;c++)queue.push(el.children[c])}}
return need}
function measure(){var b=document.body;if(!b)return 0;var bottom=0;try{var r=document.createRange();r.selectNodeContents(b);bottom=r.getBoundingClientRect().bottom}catch(e){}
var kids=b.children;for(var i=0;i<kids.length;i++){var k=kids[i];if(k.tagName==='SCRIPT'||k.tagName==='STYLE')continue;var cs=getComputedStyle(k);if(cs.display==='none'||cs.position==='fixed')continue;var kb=k.getBoundingClientRect().bottom+(parseFloat(cs.marginBottom)||0);if(kb>bottom)bottom=kb}
var bs=getComputedStyle(b);bottom+=(parseFloat(bs.paddingBottom)||0)+(parseFloat(bs.borderBottomWidth)||0);var content=Math.ceil(bottom+(window.scrollY||0));
var over=Math.ceil(overlayBottom(b));return Math.max(0,content,over)}
function size(){queued=false;var h=measure();if(h!==last){last=h;post(M.resize,{height:h})}}
function later(){if(queued)return;queued=true;if(typeof requestAnimationFrame==='function'){requestAnimationFrame(size)}else{setTimeout(size,16)}}
${assets}window.addEventListener('error',function(e){if(window.__agoraQuiet)return;post(M.error,{message:String(e&&e.message||'error')})});
window.addEventListener('unhandledrejection',function(e){post(M.error,{message:String(e&&e.reason&&e.reason.message||e&&e.reason||'unhandled rejection')})});
window.addEventListener('message',function(e){if(e.source!==P)return;var d=e.data;if(!d||d.token!==T)return;
${shared ? 'if(d.type===M.assets){apply(d);return}' : ''}if(d.type===M.render){var s=document.getElementById('agora-sandbox-css');if(s)s.textContent=String(d.css||'');document.body.innerHTML=String(d.html||'');size()}
else if(d.type===M.run){var el=document.createElement('script');el.textContent=String(d.code||'');document.body.appendChild(el);size()}
else if(d.type===M.theme){var th=document.getElementById('agora-sandbox-theme');if(th)th.textContent=String(d.css||'');var de=document.documentElement;de.classList.toggle('dark',d.dark===true);var at=d.attributes&&typeof d.attributes==='object'?d.attributes:{};
for(var i=de.attributes.length-1;i>=0;i--){var an=de.attributes[i].name;if(/^data-([\\w-]*-)?(theme|mode|scheme)$/.test(an)&&!(an in at))de.removeAttribute(an)}
for(var k in at){if(/^data-([\\w-]*-)?(theme|mode|scheme)$/.test(k))de.setAttribute(k,String(at[k]))}if(!('data-theme' in at))de.setAttribute('data-theme',d.dark===true?'dark':'light');size()}});
function start(){size();var d=document.documentElement,b=document.body;
var ro=null;if(typeof ResizeObserver==='function'){ro=new ResizeObserver(later);ro.observe(d);if(b){ro.observe(b);for(var i=0;i<b.children.length;i++)ro.observe(b.children[i])}}
if(typeof MutationObserver==='function'){new MutationObserver(function(list){if(ro){for(var i=0;i<list.length;i++){var added=list[i].addedNodes;for(var j=0;j<added.length;j++){if(added[j].nodeType===1&&added[j].parentNode===document.body)ro.observe(added[j])}}}later()}).observe(d,{childList:true,subtree:true,attributes:true,characterData:true})}
window.addEventListener('load',later);document.addEventListener('load',later,true);
if(document.fonts){if(document.fonts.ready)document.fonts.ready.then(later,function(){});if(document.fonts.addEventListener)document.fonts.addEventListener('loadingdone',later)}
setTimeout(later,250);setTimeout(later,1000);${shared ? 'post(M.load,{})' : 'post(M.ready,{height:last})'}}
if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',start)}else{start()}})();`;
}
/**
* The frame's base. Its page is see-through whatever the kit's baseline says: the frame sits in the
* host's chat, and a baseline painting the body (MUI's `CssBaseline`, Bootstrap's reboot — emotion
* injects after every stylesheet of the document) would draw a box of its own colour around the view,
* beside a view narrower than the frame. The view's own surfaces (a card) keep theirs.
*/
const BASE_CSS = 'html,body{margin:0;padding:0;overflow:hidden;background:transparent}html,body{background-color:transparent!important;background-image:none!important}body{font-family:var(--font-sans,system-ui,-apple-system,"Segoe UI",sans-serif);font-size:14px;line-height:1.4;color:var(--foreground,#1f2328)}*,*::before,*::after{box-sizing:border-box}';
/** The frame root's attributes: the host's mode (`class="dark"`, `data-theme`, `data-bs-theme`…). */
function rootAttributes(theme) {
if (theme === undefined)
return '';
const attributes = { 'data-theme': theme.dark ? 'dark' : 'light' };
for (const [name, value] of Object.entries(theme.attributes ?? {})) {
if (/^data-([\w-]*-)?(theme|mode|scheme)$/.test(name))
attributes[name] = value;
}
return `${theme.dark ? ' class="dark"' : ''}${Object.entries(attributes)
.map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
.join('')}`;
}
/**
* The `srcdoc` of a sandbox frame. The CSP `<meta>` comes first, then the bridge, the base styles and
* the model's CSS, then its markup — and, live, its `jsFunctions` and each `jsExpressions` entry as
* classic scripts, in that order.
*/
export function buildSandboxDocument(props, options) {
const shared = options.transport !== 'react-native' && options.sharedAssets !== undefined
? options.sharedAssets
: undefined;
const sharing = shared !== undefined &&
(shared.styles === true || shared.tailwind === true || shared.kit === true);
const csp = sandboxCsp(options.policy);
const live = options.mode === 'live';
const html = live ? (props.html ?? '') : previewHtml(props.html ?? '');
const scripts = [];
if (live && options.inlineJs !== false) {
if (props.jsFunctions !== undefined && props.jsFunctions.trim().length > 0) {
scripts.push(props.jsFunctions);
}
for (const expression of props.jsExpressions ?? []) {
if (typeof expression === 'string' && expression.trim().length > 0)
scripts.push(expression);
}
}
const sharedKit = sharing && shared?.kit === true;
const kit = options.kit !== undefined || sharedKit;
const jsxRuntime = kit
? sandboxJsxRuntime({
token: options.token,
jsxMessage: SANDBOX_MESSAGE.jsx,
...(options.initialJsx !== undefined ? { initial: options.initialJsx } : {}),
})
: '';
const styles = sharing && shared?.styles === true
? // Where the shared stylesheet goes once it arrives — before the theme and the model's CSS.
['<style id="agora-sandbox-kit-css"></style>']
: options.styles !== undefined && options.styles !== ''
? [`<style id="agora-sandbox-kit-css">${inlineStyle(options.styles)}</style>`]
: [];
const tailwind = sharing && shared?.tailwind === true
? [
`<style type="text/tailwindcss" id="agora-sandbox-tw">${inlineStyle(options.tailwind?.theme ?? '')}</style>`,
]
: options.tailwind !== undefined
? [
`<style type="text/tailwindcss" id="agora-sandbox-tw">${inlineStyle(options.tailwind.theme ?? '')}</style>`,
`<script>${inlineScript(options.tailwind.runtime)}</script>`,
]
: [];
// Shared: the model's scripts and the JSX runtime run once the kit (React) has arrived.
const afterAssets = sharing
? [
`<script>window.__agoraAfterAssets=function(){window.__agoraAfterAssets=null;${sharedKit ? inlineScript(jsxRuntime) : ''}${scripts
.map((code) => `var s=document.createElement('script');s.textContent=${inlineScript(JSON.stringify(code))};document.body.appendChild(s);`)
.join('')}}</script>`,
]
: [];
return [
`<!doctype html><html${rootAttributes(options.theme)}><head><meta charset="utf-8">`,
`<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(csp)}">`,
'<meta name="referrer" content="no-referrer">',
...(options.transport === 'react-native'
? ['<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">']
: []),
`<script>${bridgeScript(options.token, options.hostOrigin, options.transport, sharing)}</script>`,
`<style>${BASE_CSS}</style>`,
// Nothing draws unstyled while the shared assets are on their way.
...(sharing ? ['<style id="agora-sandbox-wait">body{visibility:hidden}</style>'] : []),
...styles,
`<style id="agora-sandbox-theme">${inlineStyle(options.theme?.css ?? '')}</style>`,
...tailwind,
`<style id="agora-sandbox-css">${inlineStyle(props.css ?? '')}</style>`,
'</head><body>',
html,
...(kit ? ['<div id="agora-sandbox-root"></div>'] : []),
...(kit && !sharedKit
? [
`<script>${inlineScript(options.kit)}</script>`,
`<script>${inlineScript(jsxRuntime)}</script>`,
]
: []),
...(sharing ? afterAssets : scripts.map((code) => `<script>${inlineScript(code)}</script>`)),
'</body></html>',
].join('');
}
/** Which shared assets a document built with {@link SandboxDocumentOptions.sharedAssets} asks for. */
export function sandboxSharedAssetsWanted(options) {
const shared = options.transport === 'react-native' ? undefined : options.sharedAssets;
return {
styles: shared?.styles === true,
tailwind: shared?.tailwind === true,
kit: shared?.kit === true,
};
}
/**
* Markup the model is still writing, made safe to draw as a preview: the half-written tag at the end
* dropped, `<script>`/`<style>`/`<head>` blocks (whole or cut off) removed, a cut-off entity dropped,
* and only what is inside `<body>` kept.
*/
export function previewHtml(html) {
let result = html;
result = result.replace(/<[^>]*$/, '');
result = result.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, '');
result = result.replace(/<(style|script|head)\b[^>]*>[\s\S]*$/gi, '');
result = result.replace(/&[a-zA-Z0-9#]*$/, '');
const body = result.match(/<body[^>]*>([\s\S]*)/i);
if (body !== null)
result = (body[1] ?? '').replace(/<\/body>[\s\S]*/i, '');
return result;
}
/**
* Sandbox props as they are while the model writes them: only what may be shown or run. CSS and
* `jsFunctions` are held until their string closes, a `jsExpressions` entry until it closes (code is
* never run half written), a half-written placeholder line is dropped; the markup streams as it is.
*/
export function sandboxPartialProps(props, input) {
const pending = input.pendingMember(props);
const out = {};
for (const [key, value] of Object.entries(props)) {
if (key === 'css' || key === 'jsFunctions' || key === 'title' || key === 'summary') {
if (pending !== key && typeof value === 'string')
out[key] = value;
continue;
}
if (key === 'html' || key === 'jsx') {
if (typeof value === 'string')
out[key] = value;
continue;
}
if (key === 'initialHeight') {
if (pending !== key && typeof value === 'number')
out.initialHeight = value;
continue;
}
if (key === 'placeholderMessages' || key === 'jsExpressions') {
if (!Array.isArray(value))
continue;
const open = input.isOpen(value);
const cut = open ? input.pendingMember(value) : undefined;
out[key] = value.filter((item, index) => typeof item === 'string' && !(open && cut === index));
}
}
return out;
}
const sandboxSchema = {
type: 'object',
properties: {
initialHeight: {
type: 'integer',
minimum: 40,
maximum: 2000,
description: 'Height in px to reserve while the code streams. Write it FIRST.',
},
placeholderMessages: {
type: 'array',
items: { type: 'string', maxLength: 120 },
maxItems: 5,
description: 'One to three short loading lines shown while the code streams.',
},
title: { type: 'string', maxLength: 120, description: 'Short title of the view.' },
summary: {
type: 'string',
maxLength: 600,
description: 'One or two sentences describing the view, for clients that cannot run it.',
},
css: { type: 'string', maxLength: 60000, description: 'Styles (no @import of remote URLs).' },
html: {
type: 'string',
maxLength: 120000,
description: 'Body markup. Inline SVG for graphics. No <form> submission, no <script src>.',
},
jsFunctions: {
type: 'string',
maxLength: 120000,
description: 'Function declarations, run once after the HTML as a classic script.',
},
jsExpressions: {
type: 'array',
items: { type: 'string', maxLength: 20000 },
maxItems: 50,
description: 'Statements run in order after jsFunctions (wire up listeners, first render).',
},
},
required: ['html'],
additionalProperties: false,
};
function resolveSource(source) {
return typeof source === 'function' ? source() : source;
}
function describe(policy, options) {
const allow = policy.allow ?? {};
const opened = Object.entries(allow)
.filter(([, origins]) => origins !== undefined && origins.length > 0)
.map(([kind, origins]) => `${kind} from ${(origins ?? []).join(', ')}`);
const kitOn = options.kit !== undefined && options.kit !== false;
const docs = kitOn && options.kit !== true ? resolveSource(options.kit) : undefined;
// JSX only when a kit bundle exists (docs say so) — never on the promise of one.
const jsx = docs?.jsx === true;
const components = docs?.components ?? [];
const themeOn = options.theme !== false;
const themeVars = typeof options.theme === 'object'
? resolveSource(options.theme.vars)
: (docs?.theme?.vars ?? undefined);
return [
jsx
? `An interactive mini-app you write yourself — JSX${components.length > 0 ? " with the app's own components" : ''}, or HTML, CSS and JavaScript — drawn in an isolated sandbox${docs?.tailwind !== undefined || docs?.vocabulary !== undefined ? " in the app's own styles" : ''}.`
: `An interactive mini-app you write yourself in HTML, CSS and JavaScript, drawn in an isolated sandbox${docs?.tailwind !== undefined || docs?.vocabulary !== undefined ? " in the app's own styles" : ''}.`,
'LAST RESORT: use it only when no other component of this catalog can present the answer — a calculator, a small simulation, a bespoke visualization. Tables, charts, KPIs and text go in the catalog components, never here.',
jsx
? 'Write the fields in this order: initialHeight, placeholderMessages, title, summary, css (optional), jsx — or html, jsFunctions, jsExpressions instead of jsx.'
: 'Write the fields in this order: initialHeight, placeholderMessages, title, summary, css, html, jsFunctions, jsExpressions.',
opened.length > 0
? `Network: only ${opened.join('; ')}. Everything else (fetch, CDNs, remote fonts) is blocked.`
: 'No network at all: no fetch, no external scripts, fonts or images — inline SVG and data: URLs only.',
'The frame is as wide as the chat column: lay the view out at full width (width: 100%) unless the user asks for something narrower.',
'No <form> submission (use buttons with click handlers). jsFunctions and each jsExpressions entry run as classic scripts after the HTML, so no top-level await. Validate numeric input (no NaN/Infinity on screen), label units, keep it keyboard-accessible.',
'To hand values back to the assistant, call agent.send({ text: "<one short sentence the user is saying>", ...values }) from an explicit button click only — never on load, on a timer or on input. It starts a new turn with those values.',
...(themeOn ? [themeToModelText(themeVars)] : []),
...(docs?.tailwind !== undefined
? [
tailwindToModelText(docs.tailwind.tokens, {
version: docs.tailwind.version,
...(themeOn ? { colors: themeTokens(themeVars ?? []).colors } : {}),
}),
]
: []),
...(docs?.tailwind === undefined &&
docs?.vocabulary !== undefined &&
docs.vocabulary.groups.length > 0
? [`\n${vocabularyToModelText(docs.vocabulary, { jsx })}\n`]
: []),
...(jsx
? [kitJsxInstructions({ components: components.length > 0, wrapper: docs?.wrapper === true })]
: []),
...(kitOn && docs !== undefined && !jsx ? [kitHtmlInstructions()] : []),
...(jsx && components.length > 0 ? [`\n${kitDocsToModelText(docs)}`] : []),
...(docs?.guide !== undefined && docs.guide.trim() !== ''
? [`\nThe app's notes on its styles:\n${docs.guide.trim()}`]
: []),
...(options.instructions !== undefined ? [options.instructions] : []),
].join(' ');
}
const jsxSchema = {
type: 'string',
maxLength: 120000,
description: 'The view as JSX: plain JavaScript + JSX defining function App() (no imports, no TypeScript). Kit components and React hooks are in scope. Write it LAST.',
};
/**
* A sandbox component. `streaming: 'partial'`, so in tree mode it draws while the model writes it:
* a placeholder of `initialHeight`, then the styled markup, then — once the code has closed — the
* live view. With a kit, JSX draws as it streams, each time what has arrived parses.
*
* ```ts
* const catalog = defineCatalog([...myComponents, defineSandbox({ policy: { allow: { images: ['https://tile.openstreetmap.org'] } } })])
* ```
*/
export function defineSandbox(options = {}) {
if (options.tailwind !== undefined) {
throw new TypeError('defineSandbox: `tailwind` was removed in 0.75. `kit: true` turns on whatever genuiSandboxKit() produced — Tailwind included, detected from its `styles`. Replace `{ tailwind: true, kit: true }` with `{ kit: true }` (and pass your Tailwind stylesheet as genuiSandboxKit({ styles })).');
}
const policy = options.policy ?? {};
assertSandboxPolicy(policy);
const kitOn = options.kit !== undefined && options.kit !== false;
const withJsx = {
...sandboxSchema,
properties: { ...sandboxSchema.properties, jsx: jsxSchema },
required: [],
};
const props = kitOn ? withJsx : sandboxSchema;
// With the kit's docs (a server resolving `kit: true`, or docs given), the schema offers `jsx` only
// when the kit has a bundle to draw it with: a stylesheet-only kit is written as html.
const docsSource = kitOn && options.kit !== true ? options.kit : undefined;
// JSX is allowed when the schema offers it: a kit whose docs say it has a bundle, or a kit the
// server has yet to resolve (`kit: true`).
const jsxAllowed = () => kitOn && (docsSource === undefined || resolveSource(docsSource)?.jsx === true);
const definition = defineComponent({
name: options.name ?? SANDBOX_COMPONENT,
// The code, parsed before the call is accepted: a view the frame cannot draw is refused with
// where and how to fix it (see validateSandboxCode).
check: async (props) => {
const docs = docsSource !== undefined ? resolveSource(docsSource) : undefined;
const issues = await validateSandboxCode(props, {
jsx: jsxAllowed(),
...(docs !== undefined ? { kit: docs } : {}),
...(policy.allow?.scripts !== undefined ? { scriptOrigins: policy.allow.scripts } : {}),
});
return issues.map((issue) => ({
severity: issue.severity,
category: issue.category,
message: formatSandboxIssue(issue),
}));
},
title: 'Interactive view',
description: '',
props,
streaming: 'partial',
partialProps: sandboxPartialProps,
...(options.version !== undefined ? { version: options.version } : {}),
fallbackText: (props) => {
const title = typeof props.title === 'string' && props.title.length > 0 ? props.title : '';
const summary = typeof props.summary === 'string' && props.summary.length > 0
? props.summary
: 'An interactive view';
return `${title ? `*${title}*\n` : ''}${summary} (interactive — open the app to use it)`;
},
});
const sandboxView = {
theme: options.theme !== false,
kit: kitOn,
};
const result = {
...definition,
sandbox: Object.freeze({ ...policy }),
sandboxView,
sandboxOptions: Object.freeze({ ...options }),
};
// Read each time: the kit's docs (and the theme's names) may change while the app runs (dev).
Object.defineProperty(result, 'description', {
enumerable: true,
get: () => options.description ?? describe(policy, options),
});
if (docsSource !== undefined) {
Object.defineProperty(result, 'props', {
enumerable: true,
get: () => (resolveSource(docsSource)?.jsx === true ? withJsx : sandboxSchema),
});
}
return Object.freeze(result);
}
/**
* A minimal, valid call of a sandbox's own tool (`ui__sandbox`), as the model should write it: JSX
* when the kit draws JSX (a kit `Button` when there is one), HTML otherwise. The JSX is a JSON
* STRING — quoted, newlines escaped — which is what a model writing code (OpenCode) gets wrong.
*/
export function sandboxCallExample(definition) {
const options = definition?.sandboxOptions;
if (sandboxPolicyOf(definition) === undefined)
return undefined;
const kit = options?.kit;
const docs = kit !== undefined && kit !== false && kit !== true
? resolveSource(kit)
: undefined;
const jsx = kit === true || docs?.jsx === true;
const button = docs?.components.some((component) => component.name === 'Button')
? 'Button'
: 'button';
const call = jsx
? {
initialHeight: 120,
title: 'Counter',
summary: 'A counter the user clicks.',
jsx: `function App() {\n const [n, setN] = useState(0);\n return <${button} onClick={() => setN(n + 1)}>Clicked {n}</${button}>;\n}`,
}
: {
initialHeight: 120,
title: 'Counter',
summary: 'A counter the user clicks.',
html: '<button id="b">Clicked 0</button>',
jsExpressions: [
"const b = document.getElementById('b'); let n = 0; b.onclick = () => { b.textContent = 'Clicked ' + ++n; };",
],
};
return `Example call: ${JSON.stringify(call)}`;
}
/**
* Whether a sandbox's props draw as JSX (the kit) or as HTML. A model under strict tool calling
* (OpenAI's) fills every property, so it may write a whole view in one field and a stub in the
* other (`function App() { return null }` next to the real `html`): the field that holds the view —
* the longer one — wins. JSX only when it has something in it.
*/
export function sandboxDrawsJsx(props) {
const jsx = typeof props.jsx === 'string' ? props.jsx.trim() : '';
if (jsx === '')
return false;
const html = typeof props.html === 'string' ? props.html.trim() : '';
return jsx.length >= html.length;
}
/** The builtin sandbox: no network, default limits. */
export const Sandbox = defineSandbox();
/** The sandbox policy a definition carries (`{}` for a component that is not a sandbox). */
export function sandboxPolicyOf(definition) {
if (typeof definition !== 'object' || definition === null)
return undefined;
const policy = definition.sandbox;
return typeof policy === 'object' && policy !== null ? policy : undefined;
}
//# sourceMappingURL=sandbox.js.map