UNPKG

@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
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