@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
389 lines • 20.4 kB
JavaScript
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
import { jsonByteLength, sandboxAction, validateUiActionContext, } from '../../../genui/actions.js';
import { buildSandboxDocument, previewHtml, SANDBOX_DEFAULTS, SANDBOX_IFRAME_FLAGS, SANDBOX_MESSAGE, sandboxDrawsJsx, sandboxPolicyOf, } from '../../../genui/sandbox.js';
import { GenuiActionContext, useGenuiNode, useGenuiProvider } from './generative-ui.js';
import { useHostTheme, useSandboxAsset, useSandboxClientConfig, } from './sandbox-assets.js';
/**
* Where UI actions go — a sandbox's `agent.send(…)`, an app component's button. Typically the chat's
* `sendUiAction`, which sends the action as the next user message:
*
* ```tsx
* const chat = useAgentChat()
* <GenuiActionProvider onAction={chat.sendUiAction}>…</GenuiActionProvider>
* ```
*/
export function GenuiActionProvider({ onAction, children, }) {
return _jsx(GenuiActionContext.Provider, { value: onAction, children: children });
}
/** The enclosing {@link GenuiActionProvider}'s handler, or `null` outside one. */
export function useGenuiAction() {
return useContext(GenuiActionContext);
}
function randomToken() {
const bytes = new Uint8Array(16);
globalThis.crypto.getRandomValues(bytes);
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function DefaultPlaceholder({ height, messages, title, }) {
const [index, setIndex] = useState(0);
useEffect(() => {
if (messages.length < 2)
return;
const timer = setInterval(() => setIndex((value) => (value + 1) % messages.length), 1800);
return () => clearInterval(timer);
}, [messages.length]);
const line = messages[index % Math.max(1, messages.length)] ?? 'Building the view…';
return (_jsx("div", { role: "status", "aria-busy": "true", "data-genui-skeleton": "", "data-testid": "sandbox-placeholder", "data-sandbox-phase": "placeholder", style: {
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px dashed rgba(127,127,127,.35)',
borderRadius: 8,
color: 'rgba(90,90,90,.9)',
fontSize: 13,
}, children: title !== undefined ? `${title} — ${line}` : line }));
}
/**
* A renderer for the sandbox component (`Sandbox`): the model's HTML, CSS and JS in an isolated
* iframe — `srcdoc`, `sandbox="allow-scripts"` (never `allow-same-origin`, so its origin is opaque
* and it reaches no cookie, storage or DOM of the page), a strict CSP, sized to its content.
*
* While the model writes it (a tree node flagged `incomplete`): a placeholder of `initialHeight`,
* then a preview of the styled markup (no code runs), then — once the markup is done — the live
* view, whose `jsFunctions` and `jsExpressions` run as each one closes (only for a node the server
* trimmed; anything else runs its code once complete).
*
* The frame's only way out is `agent.send(values)`. The host accepts it only from this frame's
* window, with this frame's token, from the opaque origin, within the size, rate and count limits,
* past `schema` and `confirm` — and hands it on as a {@link UiAction}.
*/
export function createSandboxRenderer(options = {}) {
function SandboxView(props) {
const node = useGenuiNode();
const provided = useGenuiProvider();
const contextAction = useGenuiAction();
const definition = provided?.catalog?.get?.(node?.type ?? 'Sandbox');
const policy = useMemo(() => options.policy ?? sandboxPolicyOf(definition) ?? {}, [definition]);
const incomplete = node?.incomplete === true;
const client = useSandboxClientConfig(options.config);
const view = definition?.sandboxView;
const themeOn = options.theme ?? client.config?.theme ?? view?.theme ?? true;
const hostTheme = useHostTheme(themeOn);
const jsxMode = sandboxDrawsJsx(props);
const tailwindText = useSandboxAsset(client.config?.tailwind?.url);
const kitName = client.config?.name !== undefined ? { name: client.config.name } : {};
const cssText = useSandboxAsset(client.config?.css?.url, { css: true, ...kitName });
const kitText = useSandboxAsset(jsxMode ? client.config?.kit?.url : undefined, {
kit: true,
...kitName,
});
// Assets still on their way: the frame waits for them rather than drawing twice.
// A plain view does not wait for the config (it is redrawn if the app's styles turn out to be
// on); a view that needs the styles, Tailwind or the kit waits for them.
const assetsPending = (client.config?.tailwind !== undefined && tailwindText === undefined) ||
(client.config?.css !== undefined && cssText === undefined) ||
(jsxMode && (client.loading || kitText === undefined));
const themeRef = useRef(hostTheme);
themeRef.current = hostTheme;
// The server drops half-written code from what it previews (`sandboxPartialProps`); props
// from anywhere else may hold it, so their code waits for the whole view.
const trimmed = incomplete && node?.streamSafe === true;
const token = useMemo(randomToken, []);
const frame = useRef(null);
const ready = useRef(false);
const ran = useRef({ functions: false, expressions: 0 });
const sends = useRef({ count: 0, last: 0 });
const initial = clampHeight(props.initialHeight ?? SANDBOX_DEFAULTS.initialHeight, policy);
// The content's own height once the frame has reported it; the declared one until then.
const [measured, setMeasured] = useState(null);
const height = measured ?? initial;
// Mounted while the model was still writing: code arrives by message, never inlined.
const streamedIn = useRef(incomplete);
const hasMarkup = typeof props.html === 'string' && props.html.length > 0;
const markupDone = !incomplete || props.jsFunctions !== undefined || props.jsExpressions !== undefined;
const hasJsx = jsxMode && props.jsx.trim().length > 0;
const phase = assetsPending
? 'placeholder'
: jsxMode
? !hasJsx
? 'placeholder'
: incomplete
? 'preview'
: 'live'
: !incomplete
? 'live'
: markupDone && hasMarkup && trimmed
? 'live'
: props.css !== undefined || hasMarkup
? 'preview'
: 'placeholder';
const hostOrigin = typeof window === 'undefined' ? '' : window.location.origin;
const inlineJs = !streamedIn.current;
const css = props.css;
const html = props.html;
const tailwind = typeof tailwindText === 'string'
? {
runtime: tailwindText,
theme: [themeRef.current?.tailwind ?? '', client.config?.tailwind?.css ?? '']
.filter((part) => part !== '')
.join('\n'),
}
: undefined;
const kit = jsxMode && typeof kitText === 'string' ? kitText : undefined;
const sharing = (options.assets ?? 'shared') === 'shared';
// A JSX view is one document from its first line to its last: what streams in is posted.
const documentPhase = jsxMode && phase !== 'placeholder' ? 'jsx' : phase;
// The live document is built once per markup: code that arrives later is posted in, so the
// frame (and what the user did in it) stays. A JSX view is built once, its code posted.
// biome-ignore lint/correctness/useExhaustiveDependencies: rebuilt on the markup only, see above
const srcDoc = useMemo(() => {
if (phase === 'placeholder' || hostOrigin === '')
return '';
const look = {
...(themeRef.current !== null
? {
theme: {
css: themeRef.current.css,
dark: themeRef.current.dark,
attributes: themeRef.current.attributes,
},
}
: {}),
...(tailwind !== undefined ? { tailwind } : {}),
...(typeof cssText === 'string' ? { styles: cssText } : {}),
// Shared: the document names the assets it needs; the page posts them (`load` → `assets`).
...(sharing
? {
sharedAssets: {
styles: typeof cssText === 'string' && cssText !== '',
tailwind: tailwind !== undefined,
kit: jsxMode && kit !== undefined,
},
}
: {}),
};
if (jsxMode) {
return buildSandboxDocument({ css: css ?? '' }, {
token,
hostOrigin,
policy,
mode: 'live',
inlineJs: false,
...look,
...(kit !== undefined ? { kit } : {}),
});
}
return phase === 'preview'
? buildSandboxDocument({}, { token, hostOrigin, policy, mode: 'preview', ...look })
: buildSandboxDocument(props, {
token,
hostOrigin,
policy,
mode: 'live',
inlineJs,
...look,
});
}, [
documentPhase,
token,
hostOrigin,
jsxMode ? '' : css,
html,
inlineJs,
policy,
tailwindText,
cssText,
kit,
]);
const post = useCallback((message) => {
frame.current?.contentWindow?.postMessage({ ...message, token }, '*');
}, [token]);
// The shared assets, as the page fetched them once (a frame that asked for none gets nothing).
const postAssets = useCallback(() => {
if (!sharing)
return;
post({
type: SANDBOX_MESSAGE.assets,
...(typeof cssText === 'string' && cssText !== '' ? { styles: cssText } : {}),
...(typeof tailwindText === 'string' ? { tailwind: tailwindText } : {}),
...(kit !== undefined ? { kit } : {}),
});
}, [sharing, post, cssText, tailwindText, kit]);
// biome-ignore lint/correctness/useExhaustiveDependencies: jsxMode only gates it off
const runPending = useCallback(() => {
if (!ready.current || inlineJs || phase !== 'live' || jsxMode)
return;
if (!trimmed && incomplete)
return;
const done = ran.current;
if (!done.functions && typeof props.jsFunctions === 'string') {
post({ type: SANDBOX_MESSAGE.run, code: props.jsFunctions });
done.functions = true;
}
// Expressions call the functions: while the model writes, wait for them — unless it has
// already moved on to the expressions (it wrote no functions).
if (!done.functions && incomplete && props.jsExpressions === undefined)
return;
const expressions = props.jsExpressions ?? [];
while (done.expressions < expressions.length) {
const code = expressions[done.expressions];
done.expressions += 1;
if (typeof code === 'string')
post({ type: SANDBOX_MESSAGE.run, code });
}
}, [inlineJs, phase, trimmed, incomplete, props.jsFunctions, props.jsExpressions, post]);
const preview = useCallback(() => {
if (!ready.current)
return;
if (jsxMode) {
if (phase !== 'placeholder')
post({ type: SANDBOX_MESSAGE.jsx, code: props.jsx ?? '', final: phase === 'live' });
return;
}
if (phase !== 'preview')
return;
post({ type: SANDBOX_MESSAGE.render, css: css ?? '', html: previewHtml(html ?? '') });
}, [jsxMode, phase, props.jsx, css, html, post]);
// The host's theme changed (light/dark, a stylesheet): the frame follows, without reloading.
useEffect(() => {
if (hostTheme === null || !ready.current)
return;
post({
type: SANDBOX_MESSAGE.theme,
css: hostTheme.css,
dark: hostTheme.dark,
attributes: hostTheme.attributes,
});
}, [hostTheme, post]);
// biome-ignore lint/correctness/useExhaustiveDependencies: a new document starts from nothing
useEffect(() => {
ready.current = false;
ran.current = { functions: false, expressions: 0 };
}, [srcDoc]);
useEffect(() => {
preview();
runPending();
}, [preview, runPending]);
const onAction = options.onAction ?? contextAction;
const title = props.title;
const nodeId = node?.id;
const send = useCallback(async (payload) => {
// A view still being written is a preview: what it sends is not the user's yet.
if (incomplete)
return options.onRefused?.({ reason: 'declined' });
const limits = sends.current;
const maxSends = policy.maxSends ?? SANDBOX_DEFAULTS.maxSends;
if (limits.count >= maxSends)
return options.onRefused?.({ reason: 'too-many', limit: maxSends });
const now = Date.now();
const interval = policy.minSendIntervalMs ?? SANDBOX_DEFAULTS.minSendIntervalMs;
if (limits.last > 0 && now - limits.last < interval) {
return options.onRefused?.({
reason: 'too-soon',
waitMs: interval - (now - limits.last),
});
}
const bytes = jsonByteLength(payload);
if (bytes === undefined)
return options.onRefused?.({ reason: 'not-json' });
const limit = policy.maxPayloadBytes ?? SANDBOX_DEFAULTS.maxPayloadBytes;
if (bytes > limit)
return options.onRefused?.({ reason: 'too-large', bytes, limit });
limits.last = now;
let action = sandboxAction(payload, {
...(nodeId !== undefined ? { componentId: nodeId } : {}),
...(title !== undefined ? { title } : {}),
});
if (options.schema !== undefined) {
const checked = await validateUiActionContext(action, options.schema);
if (!checked.ok)
return options.onRefused?.({ reason: 'invalid', issues: checked.issues });
action = { ...action, context: checked.context };
}
if (options.confirm !== undefined && !(await options.confirm(action))) {
return options.onRefused?.({ reason: 'declined' });
}
if (onAction === null || onAction === undefined)
return options.onRefused?.({ reason: 'no-handler' });
limits.count += 1;
onAction(action);
}, [policy, nodeId, title, onAction, incomplete]);
useEffect(() => {
if (phase === 'placeholder')
return;
const listener = (event) => {
// Only this frame's window, from its opaque origin, with its token.
if (frame.current === null || event.source !== frame.current.contentWindow)
return;
if (event.origin !== 'null')
return;
const data = event.data;
if (data === null || typeof data !== 'object' || data.token !== token)
return;
switch (data.type) {
case SANDBOX_MESSAGE.load:
postAssets();
return;
case SANDBOX_MESSAGE.ready:
ready.current = true;
// The first measure rides `ready` too: a `resize` sent before this listener was up is
// not sent again while the content keeps that height.
if (typeof data.height === 'number' && Number.isFinite(data.height) && data.height > 0)
setMeasured(clampHeight(data.height, policy));
if (themeRef.current !== null)
post({
type: SANDBOX_MESSAGE.theme,
css: themeRef.current.css,
dark: themeRef.current.dark,
attributes: themeRef.current.attributes,
});
preview();
runPending();
return;
case SANDBOX_MESSAGE.resize:
if (typeof data.height === 'number' && Number.isFinite(data.height)) {
setMeasured(clampHeight(data.height, policy));
}
return;
case SANDBOX_MESSAGE.send:
void send(data.payload);
return;
case SANDBOX_MESSAGE.error:
options.onError?.(typeof data.message === 'string' ? data.message.slice(0, 500) : 'error');
return;
}
};
window.addEventListener('message', listener);
return () => window.removeEventListener('message', listener);
}, [phase, token, policy, preview, runPending, send, post, postAssets]);
const messages = (props.placeholderMessages ?? []).filter((line) => typeof line === 'string');
if (jsxMode && kitText === null && !assetsPending) {
// JSX needs the app's kit, and this page has none (no `sandbox({ kit })`, or it did not load).
return (_jsxs("div", { role: "note", "data-testid": "sandbox-no-kit", style: { padding: 12, border: '1px dashed rgba(127,127,127,.35)', borderRadius: 8 }, children: [title !== undefined ? _jsx("strong", { children: title }) : null, _jsx("div", { children: props.summary ?? "An interactive view that needs the app's components." })] }));
}
if (phase === 'placeholder' || hostOrigin === '') {
const state = { height: initial, messages, ...(title !== undefined ? { title } : {}) };
return (_jsx(_Fragment, { children: options.placeholder ? options.placeholder(state) : _jsx(DefaultPlaceholder, { ...state }) }));
}
return (_jsx("iframe", { ref: frame, title: title ?? 'Interactive view', srcDoc: srcDoc,
// A `load` the listener missed: the frame applies its assets once, whichever comes first.
onLoad: sharing ? postAssets : undefined, sandbox: SANDBOX_IFRAME_FLAGS, referrerPolicy: "no-referrer", allow: "", "data-testid": "sandbox-frame", "data-sandbox-phase": phase, "data-sandbox-kit": jsxMode ? '' : undefined, "aria-busy": incomplete || undefined, className: options.className, style: {
display: 'block',
width: '100%',
border: 0,
height,
transition: 'height 120ms ease-out',
...options.style,
} }, documentPhase));
}
return SandboxView;
}
function clampHeight(value, policy) {
const max = policy.maxHeight ?? SANDBOX_DEFAULTS.maxHeight;
return Math.max(24, Math.min(max, Math.ceil(value)));
}
/** The sandbox renderer with its defaults — register it as `Sandbox` in a genui registry. */
export const SandboxView = createSandboxRenderer();
//# sourceMappingURL=sandbox.js.map