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
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 @@ -4,7 +4,7 @@
import { useRef, useState } from 'react';

import { Button } from './Button.js';
import { MessageSquarePlus, Pencil, Trash2 } from './Icon.js';
import { MessageSquarePlus, Pencil, Share2, Trash2 } from './Icon.js';
import type { ConversationMeta } from '../storage/types.js';

interface ConversationListPanelProps {
Expand All @@ -14,6 +14,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 @@ -35,6 +36,7 @@ export function ConversationListPanel(props: ConversationListPanelProps) {
onCreate,
onRemove,
onRename,
onShare,
onSwitch,
} = props;
const [editingId, setEditingId] = useState<string | null>(null);
Expand Down Expand Up @@ -142,6 +144,16 @@ export function ConversationListPanel(props: ConversationListPanelProps) {
</button>
)}
<div className='conversationListItemActions'>
<Button
variant='ghost'
size='sm'
iconOnly
iconBefore={Share2}
disabled={disabled || editing}
title='Copy share link'
aria-label='Share conversation'
onClick={() => onShare(conversation.id)}
/>
<Button
variant='ghost'
size='sm'
Expand Down
2 changes: 2 additions & 0 deletions packages/genui/a2ui-playground/src/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
Play,
RotateCcw,
Send,
Share2,
Smartphone,
Sparkles,
Sun,
Expand All @@ -43,6 +44,7 @@ export {
Play,
RotateCcw,
Send,
Share2,
Smartphone,
Sparkles,
Sun,
Expand Down
105 changes: 93 additions & 12 deletions packages/genui/a2ui-playground/src/pages/AIChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ 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 type { PreviewPerformanceMetrics } from '../storage/types.js';
import { copyToClipboard } from '../utils/clipboard.js';
import { DEFAULT_A2UI_DEMO_URL } from '../utils/demoUrl.js';
import type { Protocol } from '../utils/protocol.js';
import { isDevHost, publishA2UIPayload } from '../utils/publishPayload.js';
import { buildRenderUrl } from '../utils/renderUrl.js';

interface ChatMessage {
Expand Down Expand Up @@ -289,17 +291,6 @@ function compactProviderLabel(settings: ProviderSettings): string {
const preset = PROVIDER_PRESETS.find((item) => item.id === settings.preset);
return preset?.model ?? 'Server default';
}
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 isTrustedOnlineEndpoint(endpoint: URL): boolean {
return endpoint.origin === ONLINE_A2UI_SERVER_ORIGIN;
}
Expand Down Expand Up @@ -2049,6 +2040,9 @@ export function AIChatPage(

setInputValue('');
publishPreviewMessages(messages);
// Loaded example is local-only (unpublished): drop any stale durable URL
// from a previous turn so Share republishes this preview instead.
updatePreviewPayloadUrls(null);

setMessages([
WELCOME_MESSAGE,
Expand Down Expand Up @@ -2083,7 +2077,13 @@ export function AIChatPage(
previewMessages: messages,
});
},
[isGenerating, publishPreviewMessages, recordTurn, resetCreateMetrics],
[
isGenerating,
publishPreviewMessages,
recordTurn,
resetCreateMetrics,
updatePreviewPayloadUrls,
],
);

const handleCreateConversation = useCallback(() => {
Expand All @@ -2099,6 +2099,86 @@ export function AIChatPage(
void switchTo(id);
}, [isGenerating, resetCreateMetrics, switchTo]);

const shareConversation = 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;
let fromMessage: PreviewPayloadUrls | undefined;
if (record) {
for (let i = record.messages.length - 1; i >= 0; i--) {
const message = record.messages[i];
if (message?.previewPayloadUrls?.messagesUrl) {
fromMessage = message.previewPayloadUrls;
break;
}
}
}
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;
} else if (record && record.messages.length > 0) {
// Older snapshots: rebuild A2UI preview from assistant history.
const rebuilt = buildPreviewMessagesFromHistory(record.messages);
if (rebuilt.length > 0) fallbackMessages = rebuilt;
}
}
}

// 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 handleShareConversation = useCallback((id: string) => {
void shareConversation(id);
}, [shareConversation]);

const handleRenameConversation = useCallback((id: string, title: string) => {
void rename(id, title);
}, [rename]);
Expand Down Expand Up @@ -2151,6 +2231,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 @@ -19,6 +19,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 @@ -36,17 +37,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 @@ -63,57 +57,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 @@ -344,7 +287,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
Loading
Loading