Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/genui/a2ui-playground/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,42 @@ agent.start(); // streams initial messages into the buffer
agent.onAction(action); // pushes the canned response to a user action
```

## Supabase Storage payload publishing

The A2UI server keeps AI-generated preview URLs short by uploading final
validated `messages` to Supabase Storage before emitting the `done` SSE event.
The playground still receives the full `messages` for immediate rendering, and
uses `done.preview.messagesUrl` for Web Preview and Native Preview links.

To test this locally, create a public bucket for preview payloads and start the
server with Supabase S3 credentials:

```bash
SUPABASE_URL=https://koaijebcyqjpnvxajqhe.supabase.co \
SUPABASE_S3_ACCESS_KEY_ID=<s3-access-key-id> \
SUPABASE_S3_SECRET_ACCESS_KEY=<s3-secret-access-key> \
SUPABASE_STORAGE_BUCKET=genui \
pnpm dev
```

`SUPABASE_STORAGE_BUCKET` defaults to `genui`, and
`SUPABASE_STORAGE_PREFIX` defaults to `a2ui`.
`SUPABASE_STORAGE_REGION` defaults to `us-east-1`. The server writes through
Supabase's S3-compatible Storage endpoint:

```text
a2ui/<id>/messages.json
```

Those objects must be in a public bucket and CORS-readable by the preview
runtime.

In local playground development, generated preview links use the playground
dev server's in-memory payload store by default. Set
`A2UI_PLAYGROUND_CLIENT_PAYLOAD_PUBLISH=0` when you want local development to
exercise the server-side Supabase upload path instead. Production builds do
not enable the dev-server payload store.

## Multi-turn chat shell pattern

For chat UIs, give each turn (user prompt + agent response) its own
Expand Down
7 changes: 7 additions & 0 deletions packages/genui/a2ui-playground/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { RsbuildPlugin } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';

const PORT = Number(process.env.PORT ?? 3000);
const CLIENT_PAYLOAD_STORE_ENABLED = process.env.NODE_ENV !== 'production'
&& process.env.A2UI_PLAYGROUND_CLIENT_PAYLOAD_PUBLISH !== '0';

// In-memory A2UI payload store. Keeps the dev-bundle / render URLs short
// enough to fit inside a scannable QR code.
Expand Down Expand Up @@ -166,6 +168,11 @@ function buildRspeedyBundleUrl(port: number): string {
export default defineConfig({
plugins: [pluginReact(), a2uiPayloadPlugin],
source: {
define: {
__A2UI_PLAYGROUND_CLIENT_PAYLOAD_STORE__: JSON.stringify(
CLIENT_PAYLOAD_STORE_ENABLED,
),
},
entry: {
index: './src/entry.tsx',
render: './src/render.tsx',
Expand Down
159 changes: 148 additions & 11 deletions packages/genui/a2ui-playground/src/components/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { DEFAULT_A2UI_DEMO_URL } from '../utils/demoUrl.js';
import type { Protocol } from '../utils/protocol.js';
import { buildRenderUrl } from '../utils/renderUrl.js';

declare const __A2UI_PLAYGROUND_CLIENT_PAYLOAD_STORE__: boolean;

export type PreviewMode = 'phone' | 'full';

export interface PreviewPanelPreviewModeContextValue {
Expand Down Expand Up @@ -58,7 +60,9 @@ interface A2UIPreviewSource {
demoUrl: string;
theme: 'light' | 'dark';
messages: unknown;
messagesUrl?: string;
actionMocks?: Record<string, unknown>;
actionMocksUrl?: string;
demoId?: string;
/**
* When true, build the render URL in playback mode so the Lynx app waits
Expand Down Expand Up @@ -158,13 +162,6 @@ function useRspeedyDevUrl(): string {
return url;
}

function formatUrlForDisplay(url: string): string {
if (url.length <= 80) return url;
const head = url.slice(0, 44);
const tail = url.slice(-24);
return `${head}…${tail}`;
}

function buildOpenUIRenderUrl(
rawText: string,
baseUrl: string,
Expand All @@ -180,6 +177,18 @@ function buildOpenUIRenderUrl(
return url.toString();
}

function absoluteUrl(url: string, origin: string): string {
try {
return new URL(url, origin).toString();
} catch {
return url;
}
}

function shouldUseClientPayloadStore(): boolean {
return __A2UI_PLAYGROUND_CLIENT_PAYLOAD_STORE__;
}

export function PreviewPanel(props: PreviewPanelProps) {
const {
afterBody,
Expand Down Expand Up @@ -306,11 +315,27 @@ export function PreviewPanel(props: PreviewPanelProps) {
}

if (previewSource.kind === 'a2ui') {
const useClientPayloadStore = shouldUseClientPayloadStore();
const canSharePayload = !!previewSource.demoId
|| !!previewSource.messagesUrl
|| useClientPayloadStore;
const hasInlineMessages = Array.isArray(previewSource.messages)
? previewSource.messages.length > 0
: previewSource.messages !== undefined;
if (!canSharePayload && !hasInlineMessages) {
setRenderUrl('');
setRenderShareUrl('');
setLynxDevUrl('');
return;
}

const url = buildRenderUrl(
{
protocol: previewSource.protocol,
demoUrl: previewSource.demoUrl ?? DEFAULT_A2UI_DEMO_URL,
messagesUrl: previewSource.messagesUrl,
messages: previewSource.messages,
actionMocksUrl: previewSource.actionMocksUrl,
actionMocks: previewSource.actionMocks,
theme: previewSource.theme,
demoId: previewSource.demoId,
Expand All @@ -325,7 +350,9 @@ export function PreviewPanel(props: PreviewPanelProps) {
{
protocol: previewSource.protocol,
demoUrl: previewSource.demoUrl ?? DEFAULT_A2UI_DEMO_URL,
messagesUrl: previewSource.messagesUrl,
messages: previewSource.messages,
actionMocksUrl: previewSource.actionMocksUrl,
actionMocks: previewSource.actionMocks,
theme: previewSource.theme,
demoId: previewSource.demoId,
Expand All @@ -334,7 +361,12 @@ export function PreviewPanel(props: PreviewPanelProps) {
shareBaseUrl,
);
setRenderUrl(url);
setRenderShareUrl(shareUrl);
setRenderShareUrl(canSharePayload ? shareUrl : '');

if (!canSharePayload) {
setLynxDevUrl('');
return;
}

if (!rspeedyDevUrl) {
setLynxDevUrl('');
Expand All @@ -354,6 +386,22 @@ export function PreviewPanel(props: PreviewPanelProps) {
'messagesUrl',
new URL(`demos/${previewSource.demoId}.json`, demosBase).toString(),
);
} else if (previewSource.messagesUrl) {
uInline.searchParams.set('messagesUrl', previewSource.messagesUrl);
uInline.searchParams.delete('messages');
if (previewSource.actionMocksUrl) {
uInline.searchParams.set(
'actionMocksUrl',
previewSource.actionMocksUrl,
);
uInline.searchParams.delete('actionMocks');
} else if (previewSource.actionMocks) {
uInline.searchParams.set(
'actionMocks',
JSON.stringify(previewSource.actionMocks),
);
uInline.searchParams.delete('actionMocksUrl');
}
} else {
uInline.searchParams.set(
'messages',
Expand All @@ -367,6 +415,93 @@ export function PreviewPanel(props: PreviewPanelProps) {
}
}
setLynxDevUrl(uInline.toString());

if (
previewSource.demoId
|| previewSource.messagesUrl
|| !useClientPayloadStore
) {
return;
}

void (async () => {
try {
const payloadOrigin = new URL(baseUrl).origin;
const res = await window.fetch(`${payloadOrigin}/__a2ui_payload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: previewSource.messages,
actionMocks: previewSource.actionMocks,
}),
});
if (!res.ok) return;
const data = (await res.json()) as {
messagesUrl?: string;
actionMocksUrl?: string;
};

if (seq !== buildSeqRef.current) return;
if (typeof data.messagesUrl !== 'string') return;

const messagesUrl = absoluteUrl(data.messagesUrl, payloadOrigin);
const actionMocksUrl = typeof data.actionMocksUrl === 'string'
? absoluteUrl(data.actionMocksUrl, payloadOrigin)
: undefined;

const shortUrl = buildRenderUrl(
{
protocol: previewSource.protocol,
demoUrl: previewSource.demoUrl ?? DEFAULT_A2UI_DEMO_URL,
messagesUrl,
messages: previewSource.messages,
actionMocksUrl,
theme: previewSource.theme,
speed,
playbackMode: previewSource.playbackMode,
},
baseUrl,
);
const shortShareUrl = buildRenderUrl(
{
protocol: previewSource.protocol,
demoUrl: previewSource.demoUrl ?? DEFAULT_A2UI_DEMO_URL,
messagesUrl,
messages: previewSource.messages,
actionMocksUrl,
theme: previewSource.theme,
speed,
},
shareBaseUrl,
);
setRenderUrl(shortUrl);
setRenderShareUrl(shortShareUrl);

const u = new URL(rspeedyDevUrl);
if (speed !== 1) {
u.searchParams.set('speed', String(speed));
}
u.searchParams.set('theme', previewSource.theme);
u.searchParams.set('messagesUrl', messagesUrl);
u.searchParams.delete('messages');
if (actionMocksUrl) {
u.searchParams.set('actionMocksUrl', actionMocksUrl);
u.searchParams.delete('actionMocks');
} else if (previewSource.actionMocks) {
u.searchParams.set(
'actionMocks',
JSON.stringify(previewSource.actionMocks),
);
u.searchParams.delete('actionMocksUrl');
} else {
u.searchParams.delete('actionMocksUrl');
u.searchParams.delete('actionMocks');
}
setLynxDevUrl(u.toString());
} catch {
// Keep the inline URLs above if the local dev payload store is unavailable.
}
})();
return;
}

Expand Down Expand Up @@ -460,7 +595,9 @@ export function PreviewPanel(props: PreviewPanelProps) {
return [];
}

const showQrCode = previewSource.kind !== 'a2ui' || !!previewSource.demoId;
const showQrCode = previewSource.kind !== 'a2ui'
|| !!previewSource.demoId
|| !!previewSource.messagesUrl;

const cards: Array<{ key: string; item: PreviewQrItem }> = [];
if (renderShareUrl) {
Expand All @@ -470,7 +607,7 @@ export function PreviewPanel(props: PreviewPanelProps) {
title: 'Web Preview',
description: 'Opens in any mobile browser via Lynx for Web.',
url: renderShareUrl,
urlTitle: formatUrlForDisplay(renderShareUrl),
urlTitle: renderShareUrl,
copyButtonTitle: 'Copy render URL',
showQrCode,
},
Expand All @@ -483,7 +620,7 @@ export function PreviewPanel(props: PreviewPanelProps) {
title: 'Native Preview',
description: 'Opens in LynxExplorer for native rendering.',
url: lynxDevUrl,
urlTitle: formatUrlForDisplay(lynxDevUrl),
urlTitle: lynxDevUrl,
copyButtonTitle: 'Copy Lynx dev bundle URL',
variant: 'alt',
showQrCode,
Expand Down
Loading
Loading