@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
379 lines • 20.3 kB
JavaScript
import { createElement as _createElement } from "react";
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
import { Text, View } from 'react-native';
import { WebView, } from 'react-native-webview';
import { buildSandboxDocument, previewHtml, SANDBOX_DEFAULTS, SANDBOX_MESSAGE, sandboxDrawsJsx, sandboxPolicyOf, } from '../../genui/sandbox.js';
import { GenuiActionContext, useGenuiNode, useGenuiProvider, } from '../../react/core/genui/generative-ui.js';
import { useNativeSandboxAsset, useNativeSandboxConfig, } from './sandbox-assets.js';
import { checkSandboxSend, clampSandboxHeight, hostMessageScript, nativeThemeCss, parseFrameMessage, sandboxToken, shouldStartSandboxLoad, } from './sandbox-host.js';
/**
* The WebView props every sandbox gets. JavaScript runs inside it; it stores nothing (no DOM
* storage, incognito, no cache, no cookies), reads no file, opens no window, loads no mixed content,
* shows no link preview, does not scroll (the host sizes it to its content) and goes nowhere: every
* URL reaches `onShouldStartLoadWithRequest` (the whitelist lets them through — a URL it refuses
* would be handed to `Linking.openURL`), which allows only the inline document.
*/
export const NATIVE_SANDBOX_WEBVIEW_PROPS = Object.freeze({
// Frozen with the rest: one shared array must not be widened by anyone.
originWhitelist: Object.freeze(['*']),
onShouldStartLoadWithRequest: shouldStartSandboxLoad,
javaScriptEnabled: true,
javaScriptCanOpenWindowsAutomatically: false,
setSupportMultipleWindows: false,
domStorageEnabled: false,
allowFileAccess: false,
allowFileAccessFromFileURLs: false,
allowUniversalAccessFromFileURLs: false,
allowsLinkPreview: false,
incognito: true,
cacheEnabled: false,
cacheMode: 'LOAD_NO_CACHE',
mixedContentMode: 'never',
sharedCookiesEnabled: false,
thirdPartyCookiesEnabled: false,
geolocationEnabled: false,
mediaCapturePermissionGrantType: 'deny',
mediaPlaybackRequiresUserAction: true,
allowsFullscreenVideo: false,
allowsAirPlayForMediaPlayback: false,
allowsProtectedMedia: false,
allowsBackForwardNavigationGestures: false,
pullToRefreshEnabled: false,
// An array, never the bare string: react-native-webview's Fabric codegen types this as an
// array and only its iOS wrapper boxes a string, so on Android 'none' aborts the app on mount.
dataDetectorTypes: Object.freeze(['none']),
saveFormDataDisabled: true,
setBuiltInZoomControls: false,
setDisplayZoomControls: false,
textZoom: 100,
paymentRequestEnabled: false,
enableApplePay: false,
webviewDebuggingEnabled: false,
scrollEnabled: false,
bounces: false,
overScrollMode: 'never',
nestedScrollEnabled: false,
showsVerticalScrollIndicator: false,
showsHorizontalScrollIndicator: false,
automaticallyAdjustContentInsets: false,
contentInsetAdjustmentBehavior: 'never',
startInLoadingState: false,
});
// The package's class declaration (`Component<WebViewProps & P>`, `P = undefined`) types its props
// as `never` under strict null checks; this is the component as it is.
const SandboxWebView = WebView;
/** A URL that is neither the inline document nor a `data:` one — the WebView left the sandbox. */
function isForeignUrl(url) {
// Android reports the inline document's (opaque) origin as the string 'null' in `onMessage`.
return typeof url === 'string' && url !== '' && url !== 'null' && !/^(about|data):/i.test(url);
}
/** How many times a view whose WebView process died is drawn afresh before it gives up. */
const MAX_RESTARTS = 2;
const placeholderBox = {
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(127,127,127,0.35)',
borderRadius: 8,
padding: 12,
};
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(View, { testID: "sandbox-placeholder", accessibilityRole: "progressbar", accessibilityState: { busy: true }, style: [placeholderBox, { height }], children: _jsx(Text, { style: { color: 'rgba(90,90,90,0.9)', fontSize: 13 }, children: title !== undefined ? `${title} — ${line}` : line }) }));
}
function Note({ testID, title, text }) {
return (_jsxs(View, { testID: testID, style: [placeholderBox, { alignItems: 'flex-start' }], children: [title !== undefined ? _jsx(Text, { style: { fontWeight: '600' }, children: title }) : null, _jsx(Text, { children: text })] }));
}
/**
* A React Native renderer for the sandbox component (`Sandbox`): the model's HTML, CSS and JS in a
* hardened `react-native-webview` ({@link NATIVE_SANDBOX_WEBVIEW_PROPS}) — the same document as the
* web renderer's iframe (`buildSandboxDocument`, the same CSP: no network unless the policy lists an
* origin), sized to its content.
*
* It streams like the web one: a placeholder of `initialHeight`, a preview of the styled markup (no
* code runs), then the live view, whose code is posted in as it closes. The only bridge is the one
* the document carries: `ReactNativeWebView.postMessage` out (ready, resize, error, `agent.send`),
* `injectJavaScript` dispatching a `message` event in (render, run, theme, jsx). Everything the
* WebView says is checked as the web host checks it — length capped before parsing, the frame's
* token, size, interval and count limits, JSON only, `schema`, `confirm` — and an accepted
* `agent.send` is handed on as a `UiAction`.
*
* ```tsx
* <GenuiProvider registry={{ Sandbox: createNativeSandboxRenderer({ theme: () => useSandboxTheme() }) }} onAction={chat.sendUiAction}>
* ```
*/
export function createNativeSandboxRenderer(options = {}) {
const themeOption = options.theme;
// Fixed per renderer, so the hook below is always (or never) called.
const useThemeOption = typeof themeOption === 'function'
? themeOption
: () => (themeOption === false ? null : themeOption);
function NativeSandboxView(props) {
const node = useGenuiNode();
const provided = useGenuiProvider();
const contextAction = useContext(GenuiActionContext);
const definition = provided?.catalog?.get?.(node?.type ?? 'Sandbox');
const policy = useMemo(() => options.policy ?? sandboxPolicyOf(definition) ?? {}, [definition]);
const incomplete = node?.incomplete === true;
const client = useNativeSandboxConfig(options.config, options.baseUrl, options.fetch);
const view = definition?.sandboxView;
const themeOn = themeOption !== false && (client.config?.theme ?? view?.theme ?? true) !== false;
const themeInput = useThemeOption();
const themeKey = themeOn && themeInput ? JSON.stringify(themeInput) : '';
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the theme's content
const theme = useMemo(() => (themeOn && themeInput ? nativeThemeCss(themeInput) : null), [themeKey]);
const jsxMode = sandboxDrawsJsx(props);
const tailwindConfig = client.config?.tailwind;
const tailwindText = useNativeSandboxAsset(tailwindConfig !== undefined
? { inline: tailwindConfig.runtime, url: tailwindConfig.url }
: undefined, options.baseUrl, options.fetch);
const cssConfig = client.config?.css;
const cssText = useNativeSandboxAsset(cssConfig !== undefined ? { inline: cssConfig.source, url: cssConfig.url } : undefined, options.baseUrl, options.fetch);
const kitConfig = client.config?.kit;
const kitText = useNativeSandboxAsset(jsxMode && kitConfig !== undefined
? { inline: kitConfig.source, url: kitConfig.url }
: undefined, options.baseUrl, options.fetch);
const assetsPending = (tailwindConfig !== undefined && tailwindText === undefined) ||
(cssConfig !== undefined && cssText === undefined) ||
(jsxMode && (client.loading || kitText === undefined));
const themeRef = useRef(theme);
themeRef.current = theme;
// 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(sandboxToken, []);
const web = useRef(null);
const ready = useRef(false);
const ran = useRef({ functions: false, expressions: 0 });
const sends = useRef({ count: 0, last: 0 });
const initial = clampSandboxHeight(props.initialHeight ?? SANDBOX_DEFAULTS.initialHeight, policy);
const [measured, setMeasured] = useState(null);
const height = measured ?? initial;
const streamedIn = useRef(incomplete);
// The WebView left the inline document (it never should), or its process died too often.
const [lost, setLost] = useState(null);
const [generation, setGeneration] = useState(0);
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 inlineJs = !streamedIn.current;
const css = props.css;
const html = props.html;
const kit = jsxMode && typeof kitText === 'string' ? kitText : undefined;
const documentPhase = jsxMode && phase !== 'placeholder' ? 'jsx' : phase;
// Built once per markup, like the web renderer's: code that arrives later is posted in.
// biome-ignore lint/correctness/useExhaustiveDependencies: rebuilt on the markup only
const documentHtml = useMemo(() => {
if (phase === 'placeholder')
return '';
const current = themeRef.current;
const look = {
...(current !== null
? { theme: { css: current.css, dark: current.dark, attributes: current.attributes } }
: {}),
...(typeof cssText === 'string' ? { styles: cssText } : {}),
...(typeof tailwindText === 'string'
? {
tailwind: {
runtime: tailwindText,
theme: [current?.tailwind ?? '', tailwindConfig?.css ?? '']
.filter((part) => part !== '')
.join('\n'),
},
}
: {}),
};
const base = { token, hostOrigin: '', policy, transport: 'react-native', ...look };
if (jsxMode) {
return buildSandboxDocument({ css: css ?? '' }, { ...base, mode: 'live', inlineJs: false, ...(kit !== undefined ? { kit } : {}) });
}
return phase === 'preview'
? buildSandboxDocument({}, { ...base, mode: 'preview' })
: buildSandboxDocument(props, { ...base, mode: 'live', inlineJs });
}, [
documentPhase,
token,
jsxMode ? '' : css,
html,
inlineJs,
policy,
tailwindText,
cssText,
kit,
]);
const source = useMemo(() => ({ html: documentHtml, baseUrl: 'about:blank' }), [documentHtml]);
const post = useCallback((message) => {
web.current?.injectJavaScript(hostMessageScript({ ...message, token }));
}, [token]);
// 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;
}
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 app's theme changed: the view follows, without reloading.
useEffect(() => {
if (theme === null || !ready.current)
return;
post({
type: SANDBOX_MESSAGE.theme,
css: theme.css,
dark: theme.dark,
attributes: theme.attributes,
});
}, [theme, post]);
// biome-ignore lint/correctness/useExhaustiveDependencies: a new document starts from nothing
useEffect(() => {
ready.current = false;
ran.current = { functions: false, expressions: 0 };
}, [documentHtml, generation]);
useEffect(() => {
preview();
runPending();
}, [preview, runPending]);
const onAction = options.onAction ?? contextAction;
const title = props.title;
const nodeId = node?.id;
const send = useCallback(async (payload) => {
const result = await checkSandboxSend({
payload,
state: sends.current,
policy,
incomplete,
...(options.schema !== undefined ? { schema: options.schema } : {}),
...(options.confirm !== undefined ? { confirm: options.confirm } : {}),
hasHandler: onAction !== null && onAction !== undefined,
...(nodeId !== undefined ? { componentId: nodeId } : {}),
...(title !== undefined ? { title } : {}),
});
if (!result.ok)
return options.onRefused?.(result.refusal);
onAction?.(result.action);
}, [policy, nodeId, title, onAction, incomplete]);
const limit = policy.maxPayloadBytes ?? SANDBOX_DEFAULTS.maxPayloadBytes;
const onMessage = useCallback((event) => {
const native = event.nativeEvent;
// Only the inline document speaks for the sandbox (it cannot navigate; this is the backstop).
if (isForeignUrl(native.url))
return;
const message = parseFrameMessage(native.data, token, limit);
if (message.kind === 'too-large') {
options.onRefused?.({ reason: 'too-large', bytes: message.bytes, limit });
return;
}
if (message.kind === 'drop')
return;
const data = message.data;
switch (data.type) {
case SANDBOX_MESSAGE.ready:
ready.current = true;
if (typeof data.height === 'number' && Number.isFinite(data.height) && data.height > 0)
setMeasured(clampSandboxHeight(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(clampSandboxHeight(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;
}
}, [token, limit, policy, post, preview, runPending, send]);
const onNavigation = useCallback((navigation) => {
if (!isForeignUrl(navigation.url))
return;
setLost('navigated');
options.onError?.('The view tried to leave the sandbox.');
}, []);
// Handled (Android crashes the app on a render process gone that nobody handles): draw afresh.
const restarts = useRef(0);
const onProcessGone = useCallback(() => {
if (restarts.current >= MAX_RESTARTS) {
setLost('crashed');
options.onError?.('The view stopped working.');
return;
}
restarts.current += 1;
setGeneration((value) => value + 1);
}, []);
const messages = (props.placeholderMessages ?? []).filter((line) => typeof line === 'string');
if (lost !== null) {
return (_jsx(Note, { testID: "sandbox-lost", ...(title !== undefined ? { title } : {}), text: props.summary ?? 'This interactive view is unavailable.' }));
}
if (jsxMode && kitText === null && !assetsPending) {
return (_jsx(Note, { testID: "sandbox-no-kit", ...(title !== undefined ? { title } : {}), text: props.summary ?? "An interactive view that needs the app's components." }));
}
if (phase === 'placeholder') {
const state = { height: initial, messages, ...(title !== undefined ? { title } : {}) };
return (_jsx(_Fragment, { children: options.placeholder ? options.placeholder(state) : _jsx(DefaultPlaceholder, { ...state }) }));
}
return (_createElement(SandboxWebView, { ...NATIVE_SANDBOX_WEBVIEW_PROPS, ref: web, key: `${documentPhase}:${generation}`, testID: "sandbox-frame", accessibilityLabel: title ?? 'Interactive view', source: source, onMessage: onMessage, onNavigationStateChange: onNavigation, onRenderProcessGone: onProcessGone, onContentProcessDidTerminate: onProcessGone, style: [{ width: '100%', height, backgroundColor: 'transparent' }, options.style] }));
}
return NativeSandboxView;
}
/** The native sandbox renderer with its defaults (theme from nothing, no Tailwind, no kit). */
export const NativeSandboxView = createNativeSandboxRenderer();
//# sourceMappingURL=sandbox.js.map