UNPKG

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