Skip to content
Closed
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
65 changes: 65 additions & 0 deletions packages/genui/a2ui-playground/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# A2UI Playground

Interactive playground for the Lynx **GenUI** toolchain. Chat with an agent to
generate A2UI / OpenUI surfaces, browse ready-made examples, and preview the
result on the web or a real device — then rename, delete, or **share** any
conversation as a durable preview link.

> Private development app; it is not published to npm. For the published library
> see [`@lynx-js/genui`](../README.md).

## Quick Start

Run everything from the **repo root**.

```bash
# 1. Install workspace dependencies (first time only)
pnpm install
```

The **Create** (chat) tab talks to the GenUI server for agent responses and
preview publishing. Start it on port `3060` with at least an OpenAI key:

```bash
# 2. Start the GenUI server → http://localhost:3060
OPENAI_API_KEY=sk-... pnpm -C packages/genui/server dev
```

Then start the playground and open the URL it prints (defaults to
`http://localhost:3000`):

```bash
# 3. Start the playground
pnpm -C packages/genui/a2ui-playground dev
```

On `localhost`, the Create tab automatically targets your local server on
`:3060`. To use the **hosted** agent without running a server of your own,
append the endpoint override to the playground URL:

```text
?a2uiEndpoint=https://genui-server.vercel.app/a2ui/stream
```

### Server environment

| Variable | Purpose | Default |
| ---------------------------------------------------------------------------- | -------------------------------------------------- | ------------------- |
| `OPENAI_API_KEY` | Agent model access (required for the Create tab) | — |
| `OPENAI_MODEL` | Model id | `gpt-4o-mini` |
| `OPENAI_BASE_URL` | Custom OpenAI-compatible endpoint | OpenAI |
| `SUPABASE_URL`, `SUPABASE_S3_ACCESS_KEY_ID`, `SUPABASE_S3_SECRET_ACCESS_KEY` | Short, shareable preview URLs via Supabase Storage | in-memory dev store |
| `PEXELS_API_KEY` | Stock-image search in generated UIs | — |

Conversation **share** links and Web / Native Preview reuse the Supabase Storage
payload-publishing path — see [`examples/README.md`](./examples/README.md) for
the bucket setup and local toggles.

## Scripts

| Command | Description |
| -------------- | -------------------------------------------------------- |
| `pnpm dev` | Build the Lynx preview bundle, then start the dev server |
| `pnpm build` | Production build |
| `pnpm preview` | Serve the production build locally |
| `pnpm test` | Run the `rstest` suite |
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface ConversationListPanelProps {
isPersistent: boolean;
onCreate: () => void;
onSwitch: (id: string) => void;
onShare: (id: string) => void;
onRename: (id: string, title: string) => void;
onRemove: (id: string) => void;
}
Expand All @@ -33,6 +34,7 @@ export function ConversationListPanel(props: ConversationListPanelProps) {
onCreate,
onRemove,
onRename,
onShare,
onSwitch,
} = props;
const [editingId, setEditingId] = useState<string | null>(null);
Expand Down Expand Up @@ -137,6 +139,15 @@ export function ConversationListPanel(props: ConversationListPanelProps) {
</button>
)}
<div className='conversationListItemActions'>
<button
type='button'
className='conversationIconButton'
disabled={disabled || editing}
title='Copy share link'
onClick={() => onShare(conversation.id)}
>
Share
</button>
<button
type='button'
className='conversationIconButton'
Expand Down
71 changes: 71 additions & 0 deletions packages/genui/a2ui-playground/src/pages/AIChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ import type { StaticDemo } from '../demos.js';
import { useConversation } from '../hooks/useConversation.js';
import type { ModelChatMessage } from '../hooks/useConversation.js';
import { useResizablePanels } from '../hooks/useResizablePanels.js';
import { loadConversation } from '../storage/conversationRepo.js';
import { copyToClipboard } from '../utils/clipboard.js';
import { DEFAULT_A2UI_DEMO_URL } from '../utils/demoUrl.js';
import type { Protocol } from '../utils/protocol.js';
import { publishA2UIPayload } from '../utils/publishPayload.js';
import { buildRenderUrl } from '../utils/renderUrl.js';

interface ChatMessage {
Expand Down Expand Up @@ -1786,6 +1788,74 @@ export function AIChatPage(
void switchTo(id);
}, [isGenerating, switchTo]);

const handleShareConversation = useCallback(
async (id: string) => {
try {
let urls: PreviewPayloadUrls | null = null;
let fallbackMessages: unknown[] | null = null;

// Active conversation: the freshest URLs/messages live in refs.
if (id === activeId) {
urls = latestPreviewPayloadUrlsRef.current;
fallbackMessages = latestPreviewMessagesRef.current;
}

// Otherwise (or if the active one hasn't published yet) read the
// durable Supabase URLs persisted on the conversation snapshot.
if (!urls?.messagesUrl) {
const record = await loadConversation(id);
const snapshot = record?.snapshot;
const fromSnapshot = snapshot?.previewPayloadUrls;
const fromMessage = record
? [...record.messages]
.reverse()
.find((message) => message.previewPayloadUrls?.messagesUrl)
?.previewPayloadUrls
: undefined;
Comment thread
PupilTong marked this conversation as resolved.
urls = (fromSnapshot?.messagesUrl ? fromSnapshot : fromMessage)
?? urls;
if (
!urls?.messagesUrl
&& (!fallbackMessages || fallbackMessages.length === 0)
) {
const previewMessages = snapshot?.previewMessages;
if (Array.isArray(previewMessages) && previewMessages.length > 0) {
fallbackMessages = previewMessages;
}
}
}

// No durable URL yet: publish the raw A2UI messages to Supabase.
if (
!urls?.messagesUrl && fallbackMessages && fallbackMessages.length > 0
) {
urls = await publishA2UIPayload(fallbackMessages);
}

if (!urls?.messagesUrl) {
showCopyToast(false);
return;
}

const link = buildRenderUrl(
{
protocol,
demoUrl: DEFAULT_A2UI_DEMO_URL,
messages: [],
messagesUrl: urls.messagesUrl,
actionMocksUrl: urls.actionMocksUrl,
theme,
},
baseUrl,
);
showCopyToast(await copyToClipboard(link));
} catch {
showCopyToast(false);
}
},
[activeId, baseUrl, protocol, showCopyToast, theme],
);

const handleRenameConversation = useCallback((id: string, title: string) => {
void rename(id, title);
}, [rename]);
Expand Down Expand Up @@ -1838,6 +1908,7 @@ export function AIChatPage(
isPersistent={isPersistent}
onCreate={handleCreateConversation}
onSwitch={handleSwitchConversation}
onShare={handleShareConversation}
onRename={handleRenameConversation}
onRemove={handleRemoveConversation}
/>
Expand Down
61 changes: 2 additions & 59 deletions packages/genui/a2ui-playground/src/pages/DemosPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { DYNAMIC_PRESETS, STATIC_DEMOS } from '../demos.js';
import { useResizablePanels } from '../hooks/useResizablePanels.js';
import { DEFAULT_A2UI_DEMO_URL } from '../utils/demoUrl.js';
import type { Protocol } from '../utils/protocol.js';
import { publishA2UIPayload } from '../utils/publishPayload.js';

interface Scenario {
id: string;
Expand All @@ -34,17 +35,10 @@ interface PreviewInput {
demoId?: string;
}

interface PublishedPayload {
messagesUrl: string;
actionMocksUrl?: string;
}

type PlayState = 'idle' | 'playing' | 'paused' | 'done';
type PlaybackProgressStatus = 'idle' | 'streaming' | 'paused' | 'done';

const jsonExtensions = [json()];
const ONLINE_A2UI_SERVER_ORIGIN = 'https://genui-server.vercel.app';
const LOCAL_A2UI_SERVER_PORT = '3060';

declare const __A2UI_PLAYGROUND_CLIENT_PAYLOAD_STORE__: boolean;

Expand All @@ -61,57 +55,6 @@ function findScenarioById(id?: string): Scenario | undefined {
return ALL_SCENARIOS.find((s) => s.id === id);
}

function isDevHost(hostname: string): boolean {
return (
hostname === 'localhost'
|| hostname === '127.0.0.1'
|| hostname === '0.0.0.0'
|| hostname.startsWith('10.')
|| hostname.startsWith('192.168.')
|| /^172\.(?:1[6-9]|2\d|3[01])\./u.test(hostname)
);
}

function getA2UIPayloadEndpoint(): string {
if (
window.location.protocol === 'http:' && isDevHost(window.location.hostname)
) {
return `http://${window.location.hostname}:${LOCAL_A2UI_SERVER_PORT}/a2ui/payload`;
}
return `${ONLINE_A2UI_SERVER_ORIGIN}/a2ui/payload`;
}

async function publishA2UIPayloadForPreview(
messages: unknown,
actionMocks?: Record<string, unknown>,
): Promise<PublishedPayload> {
const res = await window.fetch(getA2UIPayloadEndpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, actionMocks }),
});
const payload = await res.json().catch(() => ({})) as {
preview?: {
messagesUrl?: unknown;
actionMocksUrl?: unknown;
};
error?: unknown;
};
if (!res.ok || typeof payload.preview?.messagesUrl !== 'string') {
throw new Error(
typeof payload.error === 'string'
? payload.error
: 'Failed to publish A2UI messages',
);
}
return {
messagesUrl: payload.preview.messagesUrl,
actionMocksUrl: typeof payload.preview.actionMocksUrl === 'string'
? payload.preview.actionMocksUrl
: undefined,
};
}

const ALL_SCENARIOS: Scenario[] = [
...STATIC_DEMOS.map((d) => ({ ...d, actionMocks: undefined })),
...DYNAMIC_PRESETS,
Expand Down Expand Up @@ -342,7 +285,7 @@ export function DemosPage(props: {
if (committed) {
if (!committed.isKnownDemo && !__A2UI_PLAYGROUND_CLIENT_PAYLOAD_STORE__) {
setIsPublishingPayload(true);
void publishA2UIPayloadForPreview(
void publishA2UIPayload(
committed.parsed,
currentScenario?.actionMocks,
).then((preview) => {
Expand Down
67 changes: 67 additions & 0 deletions packages/genui/a2ui-playground/src/utils/publishPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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.

const ONLINE_A2UI_SERVER_ORIGIN = 'https://genui-server.vercel.app';
const LOCAL_A2UI_SERVER_PORT = '3060';

export interface PublishedPayload {
messagesUrl: string;
actionMocksUrl?: string;
}

function isDevHost(hostname: string): boolean {
return (
hostname === 'localhost'
|| hostname === '127.0.0.1'
|| hostname === '0.0.0.0'
|| hostname.startsWith('10.')
|| hostname.startsWith('192.168.')
|| /^172\.(?:1[6-9]|2\d|3[01])\./u.test(hostname)
);
}

export function getA2UIPayloadEndpoint(): string {
if (
window.location.protocol === 'http:' && isDevHost(window.location.hostname)
) {
return `http://${window.location.hostname}:${LOCAL_A2UI_SERVER_PORT}/a2ui/payload`;
}
return `${ONLINE_A2UI_SERVER_ORIGIN}/a2ui/payload`;
}

/**
* Upload an A2UI payload to the GenUI server (Supabase Storage) and return the
* durable public URLs. The returned `messagesUrl` can be fed to
* `buildRenderUrl()` to produce a shareable `render.html` link.
*/
export async function publishA2UIPayload(
messages: unknown,
actionMocks?: Record<string, unknown>,
): Promise<PublishedPayload> {
const res = await window.fetch(getA2UIPayloadEndpoint(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, actionMocks }),
});
const payload = await res.json().catch(() => ({})) as {
preview?: {
messagesUrl?: unknown;
actionMocksUrl?: unknown;
};
error?: unknown;
};
if (!res.ok || typeof payload.preview?.messagesUrl !== 'string') {
throw new Error(
typeof payload.error === 'string'
? payload.error
: 'Failed to publish A2UI messages',
);
}
return {
messagesUrl: payload.preview.messagesUrl,
actionMocksUrl: typeof payload.preview.actionMocksUrl === 'string'
? payload.preview.actionMocksUrl
: undefined,
};
}