diff --git a/.changeset/few-monkeys-juggle.md b/.changeset/few-monkeys-juggle.md new file mode 100644 index 0000000000..853d812bb3 --- /dev/null +++ b/.changeset/few-monkeys-juggle.md @@ -0,0 +1,3 @@ +--- + +--- diff --git a/packages/genui/a2ui-playground/lynx-src/App.tsx b/packages/genui/a2ui-playground/lynx-src/App.tsx index d07c042362..c81db5c17d 100644 --- a/packages/genui/a2ui-playground/lynx-src/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/App.tsx @@ -6,6 +6,7 @@ import type { Resource } from '@lynx-js/a2ui-reactlynx/core'; import '@lynx-js/a2ui-reactlynx/catalog/all'; import { useEffect, + useGlobalProps, useInitData, useMemo, useRef, @@ -32,6 +33,73 @@ function randomId(prefix: string) { + Math.random().toString(36).slice(2, 10); } +function parseJsonLikeString(input: string): unknown { + try { + return JSON.parse(input) as unknown; + } catch { + // ignore + } + + // Query params may arrive URL-encoded one or more times in native globalProps. + let current = input; + for (let i = 0; i < 3; i++) { + try { + const decoded = decodeURIComponent(current); + if (decoded === current) break; + current = decoded; + try { + return JSON.parse(current) as unknown; + } catch { + // keep decoding + } + } catch { + break; + } + } + + return input; +} + +function normalizeInitDataLike(raw: unknown): InitData { + if (raw === null || raw === undefined) return {}; + + if (typeof raw !== 'object') return {}; + + const obj = raw as Record; + const out: InitData = {}; + + const messagesUrl = obj.messagesUrl; + if (typeof messagesUrl === 'string') out.messagesUrl = messagesUrl; + + const actionMocksUrl = obj.actionMocksUrl; + if (typeof actionMocksUrl === 'string') out.actionMocksUrl = actionMocksUrl; + + const messages = obj.messages; + if (messages !== undefined) { + out.messages = typeof messages === 'string' + ? parseJsonLikeString(messages) + : messages; + } + + const actionMocks = obj.actionMocks; + if (actionMocks !== undefined) { + out.actionMocks = typeof actionMocks === 'string' + ? parseJsonLikeString(actionMocks) + : actionMocks; + } + + return out; +} + +function mergeInitDataPreferLeft(a: InitData, b: InitData): InitData { + return { + messagesUrl: a.messagesUrl ?? b.messagesUrl, + messages: a.messages ?? b.messages, + actionMocksUrl: a.actionMocksUrl ?? b.actionMocksUrl, + actionMocks: a.actionMocks ?? b.actionMocks, + }; +} + function normalizePayloadToMessages(payload: unknown): ResponseMessages { if (payload === null || payload === undefined) { return []; @@ -105,6 +173,7 @@ async function loadActionMocks(initData: InitData): Promise { } export function App() { + const globalProps = useGlobalProps(); const rawInitData = useInitData(); const initData = useMemo(() => { @@ -118,6 +187,18 @@ export function App() { return (rawInitData ?? {}) as InitData; }, [rawInitData]); + const globalPropsData = useMemo( + () => normalizeInitDataLike(globalProps), + [globalProps], + ); + + // Native in-app preview passes A2UI payload via `globalProps` (often from URL query). + // Web preview may still provide `initData`, so keep fallback for compatibility. + const effectiveData = useMemo( + () => mergeInitDataPreferLeft(globalPropsData, initData), + [globalPropsData, initData], + ); + // biome-ignore lint/suspicious/noExplicitAny: const clientRef = useRef(null); @@ -133,8 +214,8 @@ export function App() { setError(''); const [rawMessages, actionMocks] = await Promise.all([ - loadMessages(initData ?? {}), - loadActionMocks(initData ?? {}), + loadMessages(effectiveData ?? {}), + loadActionMocks(effectiveData ?? {}), ]); const messageId = randomId('demo_'); @@ -228,7 +309,7 @@ export function App() { return () => { cancelled = true; }; - }, [initData]); + }, [effectiveData]); return ( (); +const PAYLOAD_TTL_MS = 30 * 60 * 1000; // 30 minutes + +function gcPayloads(): void { + const now = Date.now(); + for (const [id, p] of payloadStore) { + if (now - p.createdAt > PAYLOAD_TTL_MS) { + payloadStore.delete(id); + } + } +} + +function readJsonBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8'); + resolve(raw ? JSON.parse(raw) : {}); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + } + }); + req.on('error', reject); + }); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(body)); +} + +function a2uiPayloadMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: () => void, +): void { + const url = req.url ?? ''; + + if ( + req.method === 'OPTIONS' + && (url.startsWith('/__a2ui_payload') || url.startsWith('/__a2ui/')) + ) { + res.statusCode = 204; + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.end(); + return; + } + + if (req.method === 'POST' && url.startsWith('/__a2ui_payload')) { + void (async () => { + try { + gcPayloads(); + const body = (await readJsonBody(req)) as { + messages?: unknown; + actionMocks?: unknown; + }; + const id = randomUUID(); + payloadStore.set(id, { + messages: body.messages, + actionMocks: body.actionMocks, + createdAt: Date.now(), + }); + sendJson(res, 200, { + id, + messagesUrl: `/__a2ui/${id}/messages`, + actionMocksUrl: body.actionMocks === undefined + ? undefined + : `/__a2ui/${id}/actionMocks`, + }); + } catch (e) { + sendJson(res, 400, { + error: e instanceof Error ? e.message : 'bad request', + }); + } + })(); + return; + } + + if (req.method === 'GET' && url.startsWith('/__a2ui/')) { + const m = /^\/__a2ui\/([^/]+)\/(messages|actionMocks)(?:\?|$)/.exec(url); + if (m) { + const [, id, field] = m; + const entry = id ? payloadStore.get(id) : undefined; + if (entry) { + const value = field === 'messages' + ? entry.messages + : entry.actionMocks; + sendJson(res, 200, value ?? null); + return; + } + } + sendJson(res, 404, { error: 'not found' }); + return; + } + + next(); +} + +const a2uiPayloadPlugin: RsbuildPlugin = { + name: 'a2ui-playground:payload-store', + setup(api) { + api.onBeforeStartDevServer(({ server }) => { + server.middlewares.use(a2uiPayloadMiddleware); + }); + }, +}; + export default defineConfig({ plugins: [ pluginQRCode({ @@ -18,6 +146,7 @@ export default defineConfig({ pluginReactLynx({ defaultDisplayLinear: false, }), + a2uiPayloadPlugin, ], source: { entry: { diff --git a/packages/genui/a2ui-playground/package.json b/packages/genui/a2ui-playground/package.json index 584e995056..1045883264 100644 --- a/packages/genui/a2ui-playground/package.json +++ b/packages/genui/a2ui-playground/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "rsbuild build", "build:lynx": "rspeedy build", - "dev": "rsbuild dev", + "dev": "pnpm run build:lynx && rsbuild dev", "dev:lynx": "rspeedy dev", "preview": "rsbuild preview", "preview:lynx": "rspeedy preview" diff --git a/packages/genui/a2ui-playground/rsbuild.config.ts b/packages/genui/a2ui-playground/rsbuild.config.ts index f2d4487d21..e339c77bae 100644 --- a/packages/genui/a2ui-playground/rsbuild.config.ts +++ b/packages/genui/a2ui-playground/rsbuild.config.ts @@ -1,9 +1,40 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { networkInterfaces } from 'node:os'; + import { defineConfig } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; +const PORT = Number(process.env.PORT ?? 3000); + +function findLocalIp(): string { + const ifaces = networkInterfaces(); + for (const name of Object.keys(ifaces)) { + const list = ifaces[name] ?? []; + for ( + const net of list as Array<{ + address: string; + family: string | number; + internal: boolean; + }> + ) { + const family = typeof net.family === 'string' + ? net.family + : `IPv${net.family}`; + if (family === 'IPv4' && !net.internal) { + return net.address; + } + } + } + return '127.0.0.1'; +} + +function buildRspeedyBundleUrl(port: number): string { + const ip = findLocalIp(); + return `http://${ip}:${port}/main.lynx.js`; +} + export default defineConfig({ plugins: [pluginReact()], source: { @@ -13,6 +44,7 @@ export default defineConfig({ }, }, server: { + port: PORT, host: '0.0.0.0', cors: { origin: '*', @@ -25,4 +57,20 @@ export default defineConfig({ }, ], }, + dev: { + setupMiddlewares: [ + (middlewares) => { + middlewares.unshift((req, res, next) => { + if (req.url?.startsWith('/__rspeedy_url')) { + const url = buildRspeedyBundleUrl(req.socket.localPort ?? PORT); + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify({ url })); + return; + } + next(); + }); + }, + ], + }, }); diff --git a/packages/genui/a2ui-playground/src/components/QrCode.tsx b/packages/genui/a2ui-playground/src/components/QrCode.tsx index 897a432c36..25eb4efe67 100644 --- a/packages/genui/a2ui-playground/src/components/QrCode.tsx +++ b/packages/genui/a2ui-playground/src/components/QrCode.tsx @@ -4,14 +4,22 @@ import { toDataURL } from 'qrcode'; import { useEffect, useMemo, useState } from 'react'; -export function QrCode(props: { value: string; size?: number }) { - const { value, size = 144 } = props; +export function QrCode(props: { + value: string; + size?: number; + onErrorChange?: (error: string) => void; +}) { + const { value, size = 144, onErrorChange } = props; const [src, setSrc] = useState(''); + const [error, setError] = useState(''); const options = useMemo( () => ({ width: size, margin: 1, + // Lowest error correction level lets us encode longer URLs (playground + // render URLs can embed full A2UI messages as query params). + errorCorrectionLevel: 'L' as const, color: { dark: '#111111', light: '#ffffff', @@ -22,16 +30,30 @@ export function QrCode(props: { value: string; size?: number }) { useEffect(() => { let cancelled = false; + setError(''); + onErrorChange?.(''); + + if (!value) { + setSrc(''); + return; + } void (async () => { try { const url = await toDataURL(value, options); if (!cancelled) { setSrc(url); + setError(''); + onErrorChange?.(''); } - } catch { + } catch (e) { if (!cancelled) { setSrc(''); + const msg = e instanceof Error + ? e.message + : 'Failed to encode QR code'; + setError(msg); + onErrorChange?.(msg); } } })(); @@ -39,11 +61,19 @@ export function QrCode(props: { value: string; size?: number }) { return () => { cancelled = true; }; - }, [options, value]); + }, [onErrorChange, options, value]); if (!src) { + // Hide the entire QR block on error; parent can choose where/how to show copy. + if (error) return null; + return ( -
+
+ {null} +
); } diff --git a/packages/genui/a2ui-playground/src/css.d.ts b/packages/genui/a2ui-playground/src/css.d.ts new file mode 100644 index 0000000000..6c89ef3255 --- /dev/null +++ b/packages/genui/a2ui-playground/src/css.d.ts @@ -0,0 +1,4 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +declare module '*.css'; diff --git a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx index 69e0a69448..7228845025 100644 --- a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx @@ -3,7 +3,7 @@ // LICENSE file in the root directory of this source tree. import { json } from '@codemirror/lang-json'; import CodeMirror from '@uiw/react-codemirror'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Chip } from '../components/Chip.js'; import { MobilePreview } from '../components/MobilePreview.js'; @@ -23,6 +23,50 @@ interface Scenario { const jsonExtensions = [json()]; +function formatUrlForDisplay(url: string): string { + // Keep it readable without changing the actual link we copy / QR. + if (url.length <= 80) return url; + const head = url.slice(0, 44); + const tail = url.slice(-24); + return `${head}…${tail}`; +} + +async function copyToClipboard(text: string): Promise { + try { + const clipboard = window.navigator?.clipboard; + if (!clipboard) return false; + await clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +function useRspeedyDevUrl(): string { + const [url, setUrl] = useState(''); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await window.fetch('/__rspeedy_url', { + cache: 'no-store', + }); + if (!res.ok) return; + const data = (await res.json()) as { url?: string }; + if (!cancelled && typeof data.url === 'string') { + setUrl(data.url); + } + } catch { + return; + } + })(); + return () => { + cancelled = true; + }; + }, []); + return url; +} + const ALL_SCENARIOS: Scenario[] = [ ...STATIC_DEMOS.map((d) => ({ ...d, actionMocks: undefined })), ...DYNAMIC_PRESETS, @@ -43,8 +87,14 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { ); const [error, setError] = useState(''); const [renderUrl, setRenderUrl] = useState(''); + const [lynxDevUrl, setLynxDevUrl] = useState(''); + const [, setRenderQrError] = useState(''); + const [lynxDevQrError, setLynxDevQrError] = useState(''); + const [lynxDevCopied, setLynxDevCopied] = useState(false); const origin = window.location.origin; + const rspeedyDevUrl = useRspeedyDevUrl(); + const lynxUrlSeqRef = useRef(0); const currentScenario = useMemo( () => ALL_SCENARIOS.find((s) => s.id === scenarioId) ?? ALL_SCENARIOS[0], @@ -54,6 +104,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const doRender = useCallback( (json: string, scenario: Scenario | undefined) => { setError(''); + setRenderQrError(''); let parsed: unknown; try { parsed = JSON.parse(json); @@ -67,8 +118,78 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { origin, ); setRenderUrl(url); + + // Native in-app preview: pass A2UI payload via global props, directly through URL query. + // In Lynx, query params are exposed in `lynx.__globalProps` / `useGlobalProps()`. + const seq = ++lynxUrlSeqRef.current; + if (rspeedyDevUrl) { + const uInline = new URL(rspeedyDevUrl); + uInline.searchParams.set('messages', JSON.stringify(parsed)); + if (actionMocks) { + uInline.searchParams.set('actionMocks', JSON.stringify(actionMocks)); + } + setLynxDevUrl(uInline.toString()); + } else { + setLynxDevUrl(''); + } + + // Try to swap both URLs to short (reference-based) variants using the + // rspeedy dev server's payload store. When available, both QR codes + // become small enough to be scannable. + void (async () => { + if (!rspeedyDevUrl) return; + try { + const rspeedyOrigin = new URL(rspeedyDevUrl).origin; + const res = await window.fetch(`${rspeedyOrigin}/__a2ui_payload`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: parsed, actionMocks }), + }); + if (!res.ok) return; + const data = (await res.json()) as { + messagesUrl?: string; + actionMocksUrl?: string; + }; + + const messagesUrlAbs = typeof data.messagesUrl === 'string' + ? `${rspeedyOrigin}${data.messagesUrl}` + : undefined; + const actionMocksUrlAbs = typeof data.actionMocksUrl === 'string' + ? `${rspeedyOrigin}${data.actionMocksUrl}` + : undefined; + + if (seq !== lynxUrlSeqRef.current) return; + + // Lynx dev bundle URL: drop inline messages, use references. + const u = new URL(rspeedyDevUrl); + if (messagesUrlAbs) u.searchParams.set('messagesUrl', messagesUrlAbs); + if (actionMocksUrlAbs && actionMocks) { + u.searchParams.set('actionMocksUrl', actionMocksUrlAbs); + } + u.searchParams.delete('messages'); + u.searchParams.delete('actionMocks'); + setLynxDevUrl(u.toString()); + + // Web render URL: also swap to reference-based form so the + // "View on Device" QR is scannable. render.html already supports + // messagesUrl / actionMocksUrl query params. + if (messagesUrlAbs) { + const r = new URL('/render.html', origin); + r.searchParams.set('protocol', protocol); + r.searchParams.set('demoUrl', DEFAULT_DEMO_URL); + r.searchParams.set('messagesUrl', messagesUrlAbs); + if (actionMocksUrlAbs && actionMocks) { + r.searchParams.set('actionMocksUrl', actionMocksUrlAbs); + } + setRenderUrl(r.toString()); + } + } catch { + // If the payload store is unavailable, fall back to the inline URLs + // already set above; QR may show "URL too long" in that case. + } + })(); }, - [origin, protocol], + [origin, protocol, rspeedyDevUrl], ); useEffect(() => { @@ -108,6 +229,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const handleClear = useCallback(() => { setCustomJson('[]'); setRenderUrl(''); + setRenderQrError(''); setError(''); }, []); @@ -218,19 +340,66 @@ export function DemosPage(props: { protocol: ProtocolVersion }) {
View on Device
-
- Scan the QR code to preview this A2UI rendering natively on your - mobile device. -
{renderUrl - ? + ? ( + + ) : (
No render
)}
+ {lynxDevUrl + ? ( +
+
+
Lynx Dev Bundle
+
+ {lynxDevQrError + ? 'QR code unavailable. Open this link with LynxExplorer instead.' + : 'Scan with LynxExplorer to load the rspeedy dev bundle.'} +
+
+
+ {formatUrlForDisplay(lynxDevUrl)} +
+ +
+
+ +
+ ) + : null}
diff --git a/packages/genui/a2ui-playground/src/pages/Dynamic.tsx b/packages/genui/a2ui-playground/src/pages/Dynamic.tsx deleted file mode 100644 index 7840bf4c81..0000000000 --- a/packages/genui/a2ui-playground/src/pages/Dynamic.tsx +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { Chip } from '../components/Chip.js'; -import { MobilePreview } from '../components/MobilePreview.js'; -import { QrCode } from '../components/QrCode.js'; -import { UsageSection } from '../components/UsageSection.js'; -import { DYNAMIC_PRESETS } from '../demos.js'; -import type { ProtocolVersion } from '../utils/protocol.js'; -import { buildRenderUrl } from '../utils/renderUrl.js'; - -type Mode = 'preset' | 'custom'; - -function formatJson(value: unknown): string { - return JSON.stringify(value ?? [], null, 2); -} - -export function DynamicPage( - props: { protocol: ProtocolVersion; demoUrl: string }, -) { - const { protocol, demoUrl } = props; - - const hasPresets = DYNAMIC_PRESETS.length > 0; - const [mode, setMode] = useState(hasPresets ? 'preset' : 'custom'); - const [presetId, setPresetId] = useState( - DYNAMIC_PRESETS[0]?.id ?? '', - ); - const [customJson, setCustomJson] = useState(() => { - const first = DYNAMIC_PRESETS[0]?.messages ?? []; - return formatJson(first); - }); - const customJsonEditedRef = useRef(false); - const [error, setError] = useState(''); - const [renderUrl, setRenderUrl] = useState(''); - - const origin = window.location.origin; - const homeHref = `#/${protocol}`; - - const currentPreset = useMemo(() => { - return DYNAMIC_PRESETS.find((p) => p.id === presetId) ?? DYNAMIC_PRESETS[0]; - }, [presetId]); - - const presetMessages = currentPreset?.messages; - const presetActions = currentPreset?.actionMocks; - - useEffect(() => { - if (customJsonEditedRef.current) return; - setCustomJson(formatJson(presetMessages ?? [])); - }, [presetMessages]); - - const handleStart = useCallback(() => { - setError(''); - - if (mode === 'preset') { - if (!currentPreset) { - setError('No preset selected'); - return; - } - - const url = buildRenderUrl( - { - protocol, - demoUrl, - messages: presetMessages, - actionMocks: presetActions, - }, - origin, - ); - setRenderUrl(url); - return; - } - - let parsed: unknown; - try { - parsed = JSON.parse(customJson); - } catch (e) { - setError(`Invalid JSON: ${String(e)}`); - return; - } - - const url = buildRenderUrl( - { - protocol, - demoUrl, - messages: parsed, - }, - origin, - ); - setRenderUrl(url); - }, [ - customJson, - currentPreset, - demoUrl, - mode, - origin, - presetActions, - presetMessages, - protocol, - ]); - - const handleSwitchToPreset = useCallback(() => { - if (!hasPresets) return; - setMode('preset'); - setError(''); - }, [hasPresets]); - - const handleSwitchToCustom = useCallback(() => { - setMode('custom'); - setError(''); - - const trimmed = customJson.trim(); - if (trimmed === '' || trimmed === '[]') { - setCustomJson(formatJson(presetMessages ?? [])); - customJsonEditedRef.current = false; - } - }, [customJson, presetMessages]); - - const handleFillExample = useCallback(() => { - setMode('custom'); - setError(''); - setCustomJson(formatJson(presetMessages ?? [])); - customJsonEditedRef.current = false; - }, [presetMessages]); - - const handleClear = useCallback(() => { - setCustomJson('[]'); - setRenderUrl(''); - setError(''); - customJsonEditedRef.current = false; - }, []); - - return ( -
-
- - ← Back to Home - -

Dynamic Rendering

-

- Presets and custom JSON, with action triggering and incremental - updates. -

-
- -
-
-
- {hasPresets - ? ( - - ) - : null} - -
- - {mode === 'preset' && hasPresets - ? ( -
-
Choose a Preset
- - - {currentPreset - ? ( -
-
- {currentPreset.description} -
-
- {currentPreset.tags.map((t) => {t} - )} -
-
- ) - : null} -
- ) - : ( -
-
A2UI messages JSON
-