diff --git a/packages/genui/a2ui-playground/lynx-src/App.tsx b/packages/genui/a2ui-playground/lynx-src/App.tsx index c81db5c17d..d8a0bc3400 100644 --- a/packages/genui/a2ui-playground/lynx-src/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/App.tsx @@ -26,7 +26,7 @@ type ActionMocks = Record; type ResponseMessages = A2uiMessage[]; -const STREAM_MESSAGE_DELAY_MS = 800; +const DEFAULT_STREAM_DELAY_MS = 800; function randomId(prefix: string) { return prefix + Date.now().toString(36) @@ -199,6 +199,17 @@ export function App() { [globalPropsData, initData], ); + // Speed multiplier from URL query (e.g. ?speed=2 → 2x faster). + const streamDelay = useMemo(() => { + const raw = (globalProps as Record | null)?.speed + ?? (rawInitData as Record | null)?.speed; + const speed = typeof raw === 'string' + ? Number(raw) + : (typeof raw === 'number' ? raw : 1); + if (!speed || speed <= 0) return DEFAULT_STREAM_DELAY_MS; + return DEFAULT_STREAM_DELAY_MS / speed; + }, [globalProps, rawInitData]); + // biome-ignore lint/suspicious/noExplicitAny: const clientRef = useRef(null); @@ -206,6 +217,7 @@ export function App() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call useEffect(() => { let cancelled = false; @@ -252,9 +264,7 @@ export function App() { if (cancelled) break; // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access client.processor?.processMessages?.([msg]); - await new Promise((resolve) => - setTimeout(resolve, STREAM_MESSAGE_DELAY_MS) - ); + await new Promise((resolve) => setTimeout(resolve, streamDelay)); } })(); @@ -284,9 +294,7 @@ export function App() { if (cancelled) break; // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access client.processor?.processMessages?.([msg]); - await new Promise((resolve) => - setTimeout(resolve, STREAM_MESSAGE_DELAY_MS) - ); + await new Promise((resolve) => setTimeout(resolve, streamDelay)); } }; @@ -309,7 +317,7 @@ export function App() { return () => { cancelled = true; }; - }, [effectiveData]); + }, [effectiveData, streamDelay]); return ( + AI generation is not yet connected. In the meantime, check out the{' '} + Demos{' '} + tab to see pre-recorded A2UI scenarios with simulated streaming — you can + even adjust the playback speed. + + ), }; export function AIChatPage( diff --git a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx index 2b7e7d2899..1915202ad9 100644 --- a/packages/genui/a2ui-playground/src/pages/DemosPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/DemosPage.tsx @@ -91,16 +91,36 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const [, setRenderQrError] = useState(''); const [lynxDevQrError, setLynxDevQrError] = useState(''); const [lynxDevCopied, setLynxDevCopied] = useState(false); + const [speed, setSpeed] = useState(1); + const [showSimTooltip, setShowSimTooltip] = useState(false); + const [jsonEdited, setJsonEdited] = useState(false); const baseUrl = window.location.href.replace(/#.*$/, ''); const rspeedyDevUrl = useRspeedyDevUrl(); const lynxUrlSeqRef = useRef(0); + // For QR codes, replace localhost/127.0.0.1 with the LAN IP so phones can reach it. + const networkBaseUrl = useMemo(() => { + const u = new URL(baseUrl); + if ( + (u.hostname === 'localhost' || u.hostname === '127.0.0.1') + && rspeedyDevUrl + ) { + try { + u.hostname = new URL(rspeedyDevUrl).hostname; + } catch { /* ignore */ } + } + return u.toString(); + }, [baseUrl, rspeedyDevUrl]); + const currentScenario = useMemo( () => ALL_SCENARIOS.find((s) => s.id === scenarioId) ?? ALL_SCENARIOS[0], [scenarioId], ); + // Whether the current render is a known demo (simulated) vs. custom JSON. + const [isSimulated, setIsSimulated] = useState(true); + const doRender = useCallback( (json: string, scenario: Scenario | undefined) => { setError(''); @@ -113,9 +133,20 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { return; } const actionMocks = scenario?.actionMocks; + // Use short demo ID only when the user hasn't edited the JSON. + const isKnownDemo = !jsonEdited + && ALL_SCENARIOS.some((s) => s.id === scenario?.id); + setIsSimulated(isKnownDemo); const url = buildRenderUrl( - { protocol, demoUrl: DEFAULT_DEMO_URL, messages: parsed, actionMocks }, - baseUrl, + { + protocol, + demoUrl: DEFAULT_DEMO_URL, + messages: parsed, + actionMocks, + demoId: isKnownDemo ? scenario!.id : undefined, + speed, + }, + networkBaseUrl, ); setRenderUrl(url); @@ -124,9 +155,25 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { 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)); + if (speed !== 1) { + uInline.searchParams.set('speed', String(speed)); + } + if (isKnownDemo) { + // Known demo: point to the static JSON served by the rsbuild dev server. + // Native Lynx supports fetch, so App.tsx will load it via messagesUrl. + const demosOrigin = new URL(networkBaseUrl).origin; + uInline.searchParams.set( + 'messagesUrl', + `${demosOrigin}/demos/${scenario!.id}.json`, + ); + } else { + uInline.searchParams.set('messages', JSON.stringify(parsed)); + if (actionMocks) { + uInline.searchParams.set( + 'actionMocks', + JSON.stringify(actionMocks), + ); + } } setLynxDevUrl(uInline.toString()); } else { @@ -174,7 +221,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { // "View on Device" QR is scannable. render.html already supports // messagesUrl / actionMocksUrl query params. if (messagesUrlAbs) { - const r = new URL('render.html', baseUrl); + const r = new URL('render.html', networkBaseUrl); r.searchParams.set('protocol', protocol); r.searchParams.set('demoUrl', DEFAULT_DEMO_URL); r.searchParams.set('messagesUrl', messagesUrlAbs); @@ -189,7 +236,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { } })(); }, - [baseUrl, protocol, rspeedyDevUrl], + [jsonEdited, networkBaseUrl, protocol, rspeedyDevUrl, speed], ); useEffect(() => { @@ -203,6 +250,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { (id: string) => { setScenarioId(id); setError(''); + setJsonEdited(false); const scenario = ALL_SCENARIOS.find((s) => s.id === id); if (scenario) { const json = formatJson(scenario.messages); @@ -219,6 +267,7 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const handleFillExample = useCallback(() => { setError(''); + setJsonEdited(false); if (currentScenario) { const json = formatJson(currentScenario.messages); setCustomJson(json); @@ -229,8 +278,10 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { const handleClear = useCallback(() => { setCustomJson('[]'); setRenderUrl(''); + setLynxDevUrl(''); setRenderQrError(''); setError(''); + setJsonEdited(false); }, []); return ( @@ -294,7 +345,10 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { className='codeEditor' value={customJson} extensions={jsonExtensions} - onChange={setCustomJson} + onChange={(v) => { + setCustomJson(v); + setJsonEdited(true); + }} theme='dark' basicSetup={{ lineNumbers: true, @@ -321,6 +375,47 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { ) : null} + {isSimulated + ? ( +
+
+ + {showSimTooltip + ? ( +
+ Pre-recorded messages replayed at simulated speed. No AI + model is running. +
+ ) + : null} +
+
+ + setSpeed(Number(e.target.value))} + /> + {speed}x +
+
+ ) + : null}
{renderUrl ? @@ -335,72 +430,94 @@ export function DemosPage(props: { protocol: ProtocolVersion }) { )}
- {/* QR Code Section */} -
-
-
-
View on 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.'} + {/* QR Code Section — only shown when there's a render URL */} + {renderUrl || lynxDevUrl + ? ( +
+ {renderUrl + ? ( +
+
+
Web Preview
+
+ Opens in any mobile browser via Lynx for Web. +
+
+
+ {formatUrlForDisplay(renderUrl)} +
+ +
+
+
-
-
- {formatUrlForDisplay(lynxDevUrl)} + ) + : null} + {lynxDevUrl + ? ( +
+
+
Native Preview
+
+ {lynxDevQrError + ? 'URL too long for QR. Copy the link and open it in LynxExplorer.' + : 'Opens in LynxExplorer for native rendering.'} +
+
+
+ {formatUrlForDisplay(lynxDevUrl)} +
+ +
- +
-
- -
- ) - : null} -
+ ) + : null} +
+ ) + : null}
); diff --git a/packages/genui/a2ui-playground/src/render.tsx b/packages/genui/a2ui-playground/src/render.tsx index 35eee23205..25248aa39b 100644 --- a/packages/genui/a2ui-playground/src/render.tsx +++ b/packages/genui/a2ui-playground/src/render.tsx @@ -19,6 +19,7 @@ interface InitData { actionMocksUrl?: string; actionMocks?: unknown; demoUrl?: string; + speed?: number; } interface InitLynxViewMessage { @@ -54,19 +55,26 @@ function parseInitDataFromQuery(): InitData | null { const messages = params.get('messages'); const actionMocks = params.get('actionMocks'); const actionMocksUrl = params.get('actionMocksUrl'); + const demo = params.get('demo'); - if (!protocol && !messagesUrl && !messages && !demoUrl) { + if (!protocol && !messagesUrl && !messages && !demoUrl && !demo) { return null; } const protocolValue = protocol === '0.9' ? '0.9' : undefined; + const speedRaw = params.get('speed'); + const speedVal = speedRaw === null ? undefined : Number(speedRaw); + const initData: InitData = { protocol: protocolValue, messagesUrl: messagesUrl ?? undefined, actionMocksUrl: actionMocksUrl ?? undefined, demoUrl: demoUrl ?? undefined, messages: [], // Default to an empty array + speed: speedVal && Number.isFinite(speedVal) && speedVal > 0 + ? speedVal + : undefined, }; if (messages) { @@ -110,6 +118,7 @@ function buildGlobalPropsFromInitData( if (initData.actionMocks !== undefined) { out.actionMocks = initData.actionMocks; } + if (initData.speed !== undefined) out.speed = initData.speed; return Object.keys(out).length > 0 ? out : null; } @@ -135,6 +144,29 @@ function Render() { ); const lynxViewRef = useRef(null); + // Known demo: fetch the static JSON in the browser context (where fetch works) + // and pass the resolved messages as initData, avoiding fetch in Lynx's worker thread. + useEffect(() => { + const demo = new URLSearchParams(window.location.search).get('demo'); + if (!demo) return; + let cancelled = false; + void (async () => { + try { + const res = await window.fetch(`./demos/${demo}.json`); + if (!res.ok || cancelled) return; + const messages = (await res.json()) as unknown; + if (!cancelled) { + setInitData((prev) => (prev ? { ...prev, messages } : prev)); + } + } catch { + // ignore — will show empty + } + })(); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { const handleMessage = (e: MessageEvent) => { if (!isInitLynxViewMessage(e.data)) { diff --git a/packages/genui/a2ui-playground/src/styles.css b/packages/genui/a2ui-playground/src/styles.css index 8f07c49e99..3b590379f1 100644 --- a/packages/genui/a2ui-playground/src/styles.css +++ b/packages/genui/a2ui-playground/src/styles.css @@ -354,6 +354,104 @@ a { overflow: auto; } +/* ── Simulation Controls Bar ── */ +.simulationBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 16px; + border-bottom: 1px solid var(--geist-border); + background: var(--geist-surface); + flex-shrink: 0; + font-size: 12px; +} + +.simInfo { + display: flex; + align-items: center; + gap: 6px; + color: var(--geist-secondary); + position: relative; +} + +.simInfoIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1px solid var(--geist-border-hover); + font-size: 10px; + font-weight: 600; + font-style: italic; + font-family: Georgia, serif; + color: var(--geist-secondary); + cursor: help; + flex-shrink: 0; +} + +.simInfoToggle { + display: inline-flex; + align-items: center; + gap: 6px; + border: none; + background: none; + padding: 0; + cursor: pointer; + font-size: 12px; + font-family: inherit; +} + +.simInfoLabel { + font-weight: 500; + color: var(--geist-secondary); +} + +.simTooltip { + position: absolute; + top: calc(100% + 4px); + left: 16px; + z-index: 10; + width: max-content; + max-width: min(360px, calc(100vw - 48px)); + padding: 8px 12px; + border-radius: var(--geist-radius-md); + border: 1px solid var(--geist-border); + background: var(--geist-background); + box-shadow: var(--geist-shadow-md); + font-size: 12px; + line-height: 1.5; + color: var(--geist-secondary); +} + +.simSpeed { + display: flex; + align-items: center; + gap: 8px; +} + +.simSpeedLabel { + font-weight: 500; + color: var(--geist-secondary); +} + +.simSpeedSlider { + width: 80px; + height: 4px; + accent-color: var(--geist-foreground); + cursor: pointer; +} + +.simSpeedValue { + min-width: 28px; + text-align: right; + font-family: var(--geist-mono); + font-weight: 500; + color: var(--geist-foreground); +} + /* ── QR Code Section (always visible in preview panel) ── */ .previewQrSection { flex-shrink: 0; @@ -522,16 +620,16 @@ a { display: flex; flex-direction: column; gap: 2px; - padding: 12px 14px; - min-height: 44px; - border: none; + padding: 10px 12px; + min-height: 40px; + border: 1px solid transparent; border-radius: var(--geist-radius-sm); background: transparent; cursor: pointer; text-align: left; transition: background var(--geist-transition), - box-shadow var(--geist-transition), + border-color var(--geist-transition), color var(--geist-transition); width: 100%; } @@ -541,12 +639,11 @@ a { } .scenarioItem.active { + border-color: var(--geist-border-hover); background: var(--geist-surface); - box-shadow: inset 3px 0 0 var(--geist-foreground); } .scenarioItem.active .scenarioName { - font-weight: 600; color: var(--geist-foreground); } @@ -739,7 +836,7 @@ a { display: block; width: 100%; padding: 5px 12px 5px 20px; - border: none; + border: 1px solid transparent; border-radius: var(--geist-radius-sm); background: transparent; cursor: pointer; @@ -759,7 +856,7 @@ a { .compSidebarItem.active { color: var(--geist-foreground); background: var(--geist-surface); - box-shadow: inset 2px 0 0 var(--geist-foreground); + border-color: var(--geist-border-hover); } /* ── Content ── */ diff --git a/packages/genui/a2ui-playground/src/utils/renderUrl.ts b/packages/genui/a2ui-playground/src/utils/renderUrl.ts index 4ea1b9b34f..466358a1fd 100644 --- a/packages/genui/a2ui-playground/src/utils/renderUrl.ts +++ b/packages/genui/a2ui-playground/src/utils/renderUrl.ts @@ -9,23 +9,37 @@ export interface RenderInit { demoUrl: string; messages: unknown; actionMocks?: unknown; + /** When set, use a short `?demo=` param instead of inlining the payload. */ + demoId?: string; + /** Simulation speed multiplier (e.g. 0.5, 1, 2, 4). */ + speed?: number; } export function buildRenderUrl(init: RenderInit, baseUrl: string): string { const url = new URL('render.html', baseUrl); url.searchParams.set('protocol', init.protocol); url.searchParams.set('demoUrl', init.demoUrl); - // Use base64url to avoid URL-encoding overhead for JSON payloads. - url.searchParams.set( - 'messages', - encodeBase64Url(JSON.stringify(init.messages)), - ); - if (init.actionMocks !== undefined) { + if (init.demoId) { + // Known demo: reference static JSON file by ID instead of inlining payload. + url.searchParams.set('demo', init.demoId); + } else { + // Custom JSON: inline the payload as base64url. url.searchParams.set( - 'actionMocks', - encodeBase64Url(JSON.stringify(init.actionMocks)), + 'messages', + encodeBase64Url(JSON.stringify(init.messages)), ); + + if (init.actionMocks !== undefined) { + url.searchParams.set( + 'actionMocks', + encodeBase64Url(JSON.stringify(init.actionMocks)), + ); + } + } + + if (init.speed !== undefined && init.speed !== 1) { + url.searchParams.set('speed', String(init.speed)); } return url.toString();