From e61690202d2ddfc60fd08f6756b0b7077a68a684 Mon Sep 17 00:00:00 2001 From: Sherry-hue <37186915+Sherry-hue@users.noreply.github.com> Date: Mon, 25 May 2026 19:47:48 +0800 Subject: [PATCH 1/6] feat(a2ui): stream protocol messages in playground --- .../a2ui-playground/lynx-src/a2ui/App.tsx | 15 + .../src/components/CopyToast.tsx | 55 +++ .../src/components/PreviewPanel.tsx | 5 + .../a2ui-playground/src/pages/AIChatPage.css | 111 +++-- .../a2ui-playground/src/pages/AIChatPage.tsx | 286 +++++++++---- packages/genui/a2ui-playground/src/render.tsx | 16 + packages/genui/a2ui-playground/src/styles.css | 49 +++ .../genui/a2ui/src/catalog/Image/index.tsx | 22 +- .../genui/server/agent/a2ui-stream-parser.ts | 385 ++++++++++++++++++ packages/genui/server/app/a2ui/_shared.ts | 2 +- .../server/app/a2ui/action/stream/route.ts | 28 +- .../genui/server/app/a2ui/stream/route.ts | 28 +- 12 files changed, 842 insertions(+), 160 deletions(-) create mode 100644 packages/genui/a2ui-playground/src/components/CopyToast.tsx create mode 100644 packages/genui/server/agent/a2ui-stream-parser.ts diff --git a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx index 5692440a8a..6a77bf92c5 100644 --- a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx @@ -442,6 +442,21 @@ export function App() { }, ); + useLynxGlobalEventListener( + 'A2UI_LIVE_MESSAGES', + (messages: unknown) => { + const normalized = normalizeProtocolMessages(messages); + const next = createMessageStore(); + for (const msg of normalized) { + next.push(msg); + } + agentRef.current?.stop(); + agentRef.current = null; + storeRef.current = next; + setStore(next); + }, + ); + useEffect(() => { playbackPausedRef.current = isPlaybackPaused; }, [isPlaybackPaused]); diff --git a/packages/genui/a2ui-playground/src/components/CopyToast.tsx b/packages/genui/a2ui-playground/src/components/CopyToast.tsx new file mode 100644 index 0000000000..8777f5af7b --- /dev/null +++ b/packages/genui/a2ui-playground/src/components/CopyToast.tsx @@ -0,0 +1,55 @@ +// 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, useRef, useState } from 'react'; + +interface CopyToastState { + message: string; + tone: 'success' | 'error'; + id: number; +} + +export function useCopyToast(timeoutMs = 1400) { + const [toast, setToast] = useState(null); + const timeoutRef = useRef | null>(null); + + const showCopyToast = useCallback( + (ok: boolean) => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + setToast({ + id: Date.now(), + message: ok ? 'Copy succeeded' : 'Copy failed', + tone: ok ? 'success' : 'error', + }); + timeoutRef.current = window.setTimeout(() => { + setToast(null); + timeoutRef.current = null; + }, timeoutMs); + }, + [timeoutMs], + ); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + return { toast, showCopyToast }; +} + +export function CopyToast(props: { toast: CopyToastState | null }) { + const { toast } = props; + if (!toast) return null; + return ( +
+
+ {toast.message} +
+
+ ); +} diff --git a/packages/genui/a2ui-playground/src/components/PreviewPanel.tsx b/packages/genui/a2ui-playground/src/components/PreviewPanel.tsx index 36db5f5858..07a5eeaf08 100644 --- a/packages/genui/a2ui-playground/src/components/PreviewPanel.tsx +++ b/packages/genui/a2ui-playground/src/components/PreviewPanel.tsx @@ -11,6 +11,7 @@ import { } from 'react'; import type { CSSProperties, ReactNode } from 'react'; +import { CopyToast, useCopyToast } from './CopyToast.js'; import { PreviewSimulationBar } from './PreviewSimulationBar.js'; import { QrCode } from './QrCode.js'; import { componentsByMessage } from '../demos.js'; @@ -198,6 +199,7 @@ export function PreviewPanel(props: PreviewPanelProps) { const [nativeCopied, setNativeCopied] = useState(false); const [nativeCopyFailed, setNativeCopyFailed] = useState(false); const [nativeQrError, setNativeQrError] = useState(''); + const { showCopyToast, toast: copyToast } = useCopyToast(); const [liveComponents, setLiveComponents] = useState([]); const liveTimersRef = useRef[]>([]); const buildSeqRef = useRef(0); @@ -483,6 +485,7 @@ export function PreviewPanel(props: PreviewPanelProps) { const handleCopyUrl = (key: string, value: string) => { if (key === 'webPreview') { void copyToClipboard(value).then((ok) => { + showCopyToast(ok); setWebCopyFailed(false); if (!ok) { setWebCopied(false); @@ -497,6 +500,7 @@ export function PreviewPanel(props: PreviewPanelProps) { } void copyToClipboard(value).then((ok) => { + showCopyToast(ok); setNativeCopyFailed(false); if (!ok) { setNativeCopied(false); @@ -520,6 +524,7 @@ export function PreviewPanel(props: PreviewPanelProps) { : 'previewPanel')} style={panelStyle} > +
{title} {headerAfterTitle} diff --git a/packages/genui/a2ui-playground/src/pages/AIChatPage.css b/packages/genui/a2ui-playground/src/pages/AIChatPage.css index e5330982ea..25d69b79f9 100644 --- a/packages/genui/a2ui-playground/src/pages/AIChatPage.css +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.css @@ -724,6 +724,9 @@ } .chatMessageAction.chatMessageActionExpanded .chatMessageBody { + display: flex; + align-items: center; + gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--geist-border); background: var(--geist-background); @@ -760,45 +763,52 @@ flex-direction: column; } -.chatMessagePayloadLabel { - padding: 6px 12px; - border-bottom: 1px solid var(--geist-border); - background: var(--geist-background); - color: var(--geist-secondary); - font-size: 10px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.chatMessagePayloadEditor .cm-editor { - height: auto; - max-height: 320px; - font-family: var(--geist-mono); - font-size: 12px; +.chatMessageChunks { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + max-height: 520px; + overflow-y: auto; background: var(--geist-surface); - color: var(--geist-foreground); } -.chatMessagePayloadEditor .cm-scroller { - max-height: 320px; - overflow: auto; +.chatMessageChunk { + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-md); + overflow: hidden; + background: var(--geist-background); + flex-shrink: 0; } -.chatMessagePayloadEditor .cm-gutters { - background: color-mix(in srgb, var(--geist-surface) 88%, var(--geist-border)); - color: var(--geist-secondary); - border-right: 1px solid - color-mix(in srgb, var(--geist-border) 82%, transparent); +.chatMessageChunkHeader { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 4px 10px; + border-bottom: 1px solid var(--geist-border); + background: color-mix(in srgb, var(--geist-surface) 88%, transparent); } -.chatMessagePayloadEditor .cm-content { - caret-color: var(--geist-foreground); +.chatMessageChunkIndex { + font-family: var(--geist-mono); + font-size: 11px; + font-weight: 700; + color: var(--geist-secondary); } -.chatMessagePayloadEditor .cm-activeLine, -.chatMessagePayloadEditor .cm-activeLineGutter { - background: color-mix(in srgb, var(--geist-foreground) 4%, transparent); +.chatMessageChunkJson { + margin: 0; + max-height: 260px; + overflow: auto; + padding: 8px 10px; + color: var(--geist-secondary); + font-family: var(--geist-mono); + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; } .chatGeneratedJson { @@ -827,41 +837,24 @@ text-transform: uppercase; } -.chatGeneratedJsonBadge { - padding: 1px 6px; +.chatJsonCopyButton { + margin-left: auto; + min-width: 52px; + height: 24px; + padding: 0 8px; border: 1px solid var(--geist-border); border-radius: 4px; background: var(--geist-surface); - color: var(--geist-secondary); - font-size: 10px; - font-weight: 500; - letter-spacing: 0; -} - -.chatGeneratedJsonEditor .cm-editor { - height: auto; - max-height: 480px; - font-family: var(--geist-mono); - font-size: 13px; - background: var(--geist-surface); color: var(--geist-foreground); + font-size: 11px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; + cursor: pointer; } -.chatGeneratedJsonEditor .cm-scroller { - max-height: 480px; - overflow: auto; -} - -.chatGeneratedJsonEditor .cm-gutters { - background: color-mix(in srgb, var(--geist-surface) 88%, var(--geist-border)); - color: var(--geist-secondary); - border-right: 1px solid - color-mix(in srgb, var(--geist-border) 82%, transparent); -} - -.chatGeneratedJsonEditor .cm-activeLine, -.chatGeneratedJsonEditor .cm-activeLineGutter { - background: color-mix(in srgb, var(--geist-foreground) 4%, transparent); +.chatJsonCopyButton:hover { + background: color-mix(in srgb, var(--geist-foreground) 6%, transparent); } .chatInputArea { diff --git a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx index d89ec428f8..eebcbfa54a 100644 --- a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx @@ -1,14 +1,13 @@ // 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 { json } from '@codemirror/lang-json'; -import CodeMirror, { EditorView } from '@uiw/react-codemirror'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import './AIChatPage.css'; import { ConfirmDialog } from '../components/ConfirmDialog.js'; import { ConversationListPanel } from '../components/ConversationListPanel.js'; +import { CopyToast, useCopyToast } from '../components/CopyToast.js'; import { PageHeader } from '../components/PageHeader.js'; import { PanelResizeHandle } from '../components/PanelResizeHandle.js'; import { PreviewPanel } from '../components/PreviewPanel.js'; @@ -16,6 +15,7 @@ import { PreviewViewport } from '../components/PreviewViewport.js'; import { useConversation } from '../hooks/useConversation.js'; import type { ModelChatMessage } from '../hooks/useConversation.js'; import { useResizablePanels } from '../hooks/useResizablePanels.js'; +import { copyToClipboard } from '../utils/clipboard.js'; import { DEFAULT_A2UI_DEMO_URL } from '../utils/demoUrl.js'; import type { Protocol } from '../utils/protocol.js'; import { buildRenderUrl } from '../utils/renderUrl.js'; @@ -24,7 +24,6 @@ interface ChatMessage { role: 'user' | 'ai' | 'action' | 'json' | 'status'; content: string | React.ReactNode; payload?: unknown; - payloadLabel?: string; tone?: 'info' | 'pending' | 'success' | 'error'; } @@ -139,7 +138,6 @@ const ONLINE_A2UI_SERVER_ORIGIN = 'https://genui-server.vercel.app'; const ONLINE_A2UI_CHAT_URL = `${ONLINE_A2UI_SERVER_ORIGIN}/a2ui/stream`; const LOCAL_A2UI_SERVER_PORT = '3060'; const PROVIDER_SETTINGS_STORAGE_KEY = 'a2ui-playground-provider-settings'; -const jsonExtensions = [json(), EditorView.lineWrapping]; const PROVIDER_PRESETS = [ { id: 'gpt-5.4', label: 'gpt5.4', model: 'gpt-5.4' }, @@ -286,7 +284,7 @@ function parseSseData(raw: string): unknown { function safeStringifyPayload(value: unknown): string { if (typeof value === 'string') { // Streaming JSON often arrives minified (no spaces/newlines) — try to - // re-pretty-print it so CodeMirror can show it across multiple lines. + // re-pretty-print it so the generated output is easy to scan. try { return JSON.stringify(JSON.parse(value), null, 2); } catch { @@ -300,6 +298,49 @@ function safeStringifyPayload(value: unknown): string { } } +function payloadToChunks(value: unknown): unknown[] { + if (Array.isArray(value)) return value; + if (typeof value !== 'string') return [value]; + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return [value]; + } +} + +function JsonPayloadViewer( + props: { payload: unknown; onCopy: (text: string) => void }, +) { + const { onCopy, payload } = props; + const chunks = payloadToChunks(payload); + + return ( +
+
+ {chunks.map((message, index) => { + const messageStr = JSON.stringify(message, null, 2); + return ( +
+
+ #{index + 1} + +
+
{messageStr}
+
+ ); + })} +
+
+ ); +} + function readProviderSettings(): ProviderSettings { if (typeof window === 'undefined') return EMPTY_PROVIDER_SETTINGS; try { @@ -498,6 +539,15 @@ async function readA2UIResponse( continue; } + if (parsed.event === 'message') { + const messages = normalizeA2UIMessages(parsed.data); + if (messages.length > 0) { + latestMessages = messages; + onMessages(latestMessages); + } + continue; + } + if (parsed.event === 'done') { const doneMessages = normalizeA2UIMessages(parsed.data); if (parsed.data && typeof parsed.data === 'object') { @@ -544,23 +594,70 @@ const SUGGESTED_PROMPTS: Array<{ label: string; text: string }> = [ }, ]; +function parsePersistedUserAction(content: string): { + action: Record; + name: string; +} | null { + const prefix = 'A2UI_USER_ACTION:'; + if (!content.startsWith(prefix)) return null; + try { + const parsed = JSON.parse(content.slice(prefix.length).trim()) as unknown; + if (!parsed || typeof parsed !== 'object') return null; + const action = (parsed as { action?: unknown }).action; + if (!action || typeof action !== 'object') return null; + const record = action as Record; + return { + action: record, + name: typeof record.name === 'string' ? record.name : 'unknown', + }; + } catch { + return null; + } +} + function buildChatMessagesFromHistory( history: ModelChatMessage[], ): ChatMessage[] { if (history.length === 0) return [WELCOME_MESSAGE]; const next: ChatMessage[] = [WELCOME_MESSAGE]; + let previousWasAction = false; for (const message of history) { if (message.role === 'user') { + const action = parsePersistedUserAction(message.content); + if (action) { + next.push({ + role: 'action', + content: `⚡ Action: ${action.name}`, + payload: action.action, + }); + previousWasAction = true; + continue; + } next.push({ role: 'user', content: message.content }); + previousWasAction = false; continue; } if (message.role === 'assistant') { + if (previousWasAction) { + const actionMessages = normalizeA2UIMessages(message.content); + if (actionMessages.length > 0) { + next.push({ + role: 'action', + content: `✅ Applied ${actionMessages.length} ${ + actionMessages.length === 1 ? 'message' : 'messages' + } to Lynx Preview`, + payload: actionMessages, + }); + previousWasAction = false; + continue; + } + } next.push({ role: 'json', content: 'Generated Output', payload: message.content, - payloadLabel: 'JSON', }); + previousWasAction = false; } } return next; @@ -604,6 +701,7 @@ export function AIChatPage( completionTokens: 0, totalTokens: 0, }); + const { showCopyToast, toast: copyToast } = useCopyToast(); const messagesEndRef = useRef(null); const chatMessagesRef = useRef(null); const followBottomRef = useRef(true); @@ -629,6 +727,13 @@ export function AIChatPage( initialSecondarySize: 560, }); + const handleCopyText = useCallback( + (text: string) => { + void copyToClipboard(text).then(showCopyToast); + }, + [showCopyToast], + ); + const providerRequestOptions = useMemo( () => toProviderRequestOptions(providerSettings), [providerSettings], @@ -659,25 +764,25 @@ export function AIChatPage( }, [providerSettings]); useEffect(() => { - // Re-run on every render so streaming text growth & async editor mounts - // both keep the chat pinned to the latest message. + // Re-run on streaming updates so generated chunks keep the chat pinned to + // the latest message. void messages; void generatedJson; void isGenerating; + void previewMessages; if (!followBottomRef.current) return; const container = chatMessagesRef.current; if (!container) return; container.scrollTop = container.scrollHeight; - }, [messages, generatedJson, isGenerating]); + }, [messages, generatedJson, isGenerating, previewMessages]); useEffect(() => { const container = chatMessagesRef.current; if (!container) return; if (typeof ResizeObserver === 'undefined') return; - // Async-mounted CodeMirror editors and streaming JSON expand the container - // height after React commits. ResizeObserver fires for those layout shifts - // and lets us keep the chat pinned to the bottom while the user is in - // "follow" mode. + // Streaming generated output expands the container height after React + // commits. ResizeObserver fires for those layout shifts and lets us keep + // the chat pinned to the bottom while the user is in "follow" mode. const sizeObserver = new ResizeObserver(() => { if (!followBottomRef.current) return; container.scrollTop = container.scrollHeight; @@ -686,8 +791,8 @@ export function AIChatPage( Array.from(container.children).forEach((child: Element) => { sizeObserver.observe(child); }); - // Newly inserted message rows must also be observed so their delayed - // CodeMirror layout still triggers the bottom-pin behavior. We use a + // Newly inserted message rows must also be observed so delayed chunk + // rendering still triggers the bottom-pin behavior. We use a // MutationObserver instead of re-running this effect on every messages // change so it stays a one-time setup. const childObserver = new MutationObserver((entries) => { @@ -717,7 +822,7 @@ export function AIChatPage( const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - // 32px hysteresis: small upward scrolls inside CodeMirror still count as + // 32px hysteresis: small upward scrolls inside generated output still count as // "at bottom"; once the user is clearly above we stop auto-following so // their reading position is respected. followBottomRef.current = distanceFromBottom <= 32; @@ -753,7 +858,7 @@ export function AIChatPage( setRenderUrl((current) => { if (current) { previewFrameRef.current?.contentWindow?.postMessage( - { type: 'INIT_LYNX_VIEW', data: initData }, + { type: 'A2UI_LIVE_MESSAGES', messages: nextMessages }, window.location.origin, ); return current; @@ -853,11 +958,9 @@ export function AIChatPage( throw new Error(`A2UI agent request failed: ${response.status}`); } - let latestText = ''; const finalMessages = await readA2UIResponse( response, (nextText) => { - latestText = nextText; setGeneratedJson(nextText); setMessages((prev) => { const next = prev.slice(); @@ -884,12 +987,9 @@ export function AIChatPage( throw new Error('A2UI agent did not return valid messages'); } - const assistantContent = latestText.length > 0 - ? latestText - : JSON.stringify(finalMessages); await recordTurn({ userMessage, - assistantContent, + assistantContent: JSON.stringify(finalMessages), a2uiMessages: finalMessages, previewMessages: finalMessages, }); @@ -897,15 +997,14 @@ export function AIChatPage( const next = prev.slice(); next[next.length - 1] = { role: 'ai', - content: `Done. Rendered ${finalMessages.length} A2UI message${ + content: `✅ Rendered ${finalMessages.length} A2UI message${ finalMessages.length === 1 ? '' : 's' - }.`, + } to Lynx Preview`, }; next.push({ role: 'json', content: 'Generated Output', - payload: assistantContent, - payloadLabel: 'JSON', + payload: finalMessages, }); return next; }); @@ -993,7 +1092,6 @@ export function AIChatPage( role: 'action' as const, content: `⚡ Action: ${actionName}`, payload: action, - payloadLabel: 'REQUEST', }, { role: 'status' as const, @@ -1044,14 +1142,13 @@ export function AIChatPage( } let responseMessages: unknown[] = []; - let latestActionText = ''; await readA2UIResponse( response, (text) => { if (!text) return; if (signal.aborted) return; - latestActionText = text; + if (responseMessages.length > 0) return; setMessages((prev) => { const next = prev.slice(); if (streamingIndex < 0 || streamingIndex >= next.length) { @@ -1066,7 +1163,6 @@ export function AIChatPage( role: 'action' as const, content: '✨ Streaming RESPONSE...', payload: text, - payloadLabel: 'RESPONSE (streaming)', }); streamingIndex = insertAt; return next; @@ -1081,6 +1177,31 @@ export function AIChatPage( (msgs) => { if (signal.aborted) return; responseMessages = msgs; + setMessages((prev) => { + const next = prev.slice(); + if (streamingIndex < 0 || streamingIndex >= next.length) { + const insertAt = pendingIndex >= 0 + && pendingIndex < next.length + ? pendingIndex + 1 + : next.length; + next.splice(insertAt, 0, { + role: 'action' as const, + content: '✨ Streaming RESPONSE...', + payload: responseMessages, + }); + streamingIndex = insertAt; + return next; + } + next[streamingIndex] = { + ...next[streamingIndex], + payload: responseMessages, + }; + return next; + }); + previewFrameRef.current?.contentWindow?.postMessage( + { type: 'A2UI_ACTION_RESPONSE', messages: responseMessages }, + window.location.origin, + ); }, (usage) => { if (signal.aborted) return; @@ -1091,6 +1212,7 @@ export function AIChatPage( totalTokens: prev.totalTokens + usage.totalTokens, })); }, + { publishPartialMessages: false }, ); if (signal.aborted) return; @@ -1099,18 +1221,10 @@ export function AIChatPage( throw new Error('Agent returned no A2UI messages'); } - previewFrameRef.current?.contentWindow?.postMessage( - { type: 'A2UI_ACTION_RESPONSE', messages: responseMessages }, - window.location.origin, - ); - const count = responseMessages.length; - const assistantContent = latestActionText.length > 0 - ? latestActionText - : JSON.stringify(responseMessages); await recordTurn({ userMessage: userActionMessage, - assistantContent, + assistantContent: JSON.stringify(responseMessages), a2uiMessages: responseMessages, previewMessages: responseMessages, }); @@ -1139,7 +1253,6 @@ export function AIChatPage( count === 1 ? 'message' : 'messages' } to Lynx Preview`, payload: responseMessages, - payloadLabel: 'RESPONSE', }; if (streamingIndex >= 0 && streamingIndex < next.length) { next[streamingIndex] = finalCard; @@ -1257,6 +1370,7 @@ export function AIChatPage( ref={pageRef} className={isPanelResizing ? 'chatPage resizing' : 'chatPage'} > + -
{msg.content}
- {payloadStr === null - ? null - : ( -
- {msg.payloadLabel - ? ( -
- {msg.payloadLabel} -
- ) - : null} - -
- )} +
+ {msg.content} + {(msg.role === 'json' || isAppliedActionResponse) + && hasPayload + ? ( + + ) + : null} +
+ {hasPayload + ? ( + + ) + : null}
); })} - {isGenerating && generatedJson + {isGenerating && previewMessages && previewMessages.length > 0 ? (
- Generated Output - JSON + Generated Output +
-
) diff --git a/packages/genui/a2ui-playground/src/render.tsx b/packages/genui/a2ui-playground/src/render.tsx index b890a199ce..3190eae427 100644 --- a/packages/genui/a2ui-playground/src/render.tsx +++ b/packages/genui/a2ui-playground/src/render.tsx @@ -58,6 +58,11 @@ interface ActionResponseMessage { messages: unknown[]; } +interface LiveMessagesMessage { + type: 'A2UI_LIVE_MESSAGES'; + messages: unknown[]; +} + interface LynxViewElement extends HTMLElement { initData?: InitData; globalProps?: unknown; @@ -316,6 +321,17 @@ function Render() { ]); return; } + if ( + e.data + && typeof e.data === 'object' + && (e.data as LiveMessagesMessage).type === 'A2UI_LIVE_MESSAGES' + ) { + const lynxView = lynxViewRef.current; + lynxView?.sendGlobalEvent?.('A2UI_LIVE_MESSAGES', [ + (e.data as LiveMessagesMessage).messages, + ]); + return; + } if (!isInitLynxViewMessage(e.data)) { if (!isPlaybackControlMessage(e.data)) return; setPlaybackPaused(e.data.action === 'pause'); diff --git a/packages/genui/a2ui-playground/src/styles.css b/packages/genui/a2ui-playground/src/styles.css index 712d7f053e..37b55746a8 100644 --- a/packages/genui/a2ui-playground/src/styles.css +++ b/packages/genui/a2ui-playground/src/styles.css @@ -55,6 +55,55 @@ html[data-theme="light"] { color-scheme: light; } +/* ── Shared Feedback ── */ +.copyToastViewport { + position: fixed; + z-index: 10000; + top: 18px; + left: 50%; + transform: translateX(-50%); + pointer-events: none; +} + +.copyToast { + min-width: 148px; + padding: 9px 14px; + border: 1px solid var(--geist-border); + border-radius: 999px; + background: color-mix(in srgb, var(--geist-background) 94%, transparent); + box-shadow: var(--geist-shadow-lg); + color: var(--geist-foreground); + font-size: 13px; + font-weight: 600; + text-align: center; + backdrop-filter: blur(12px); + animation: copyToastIn 160ms ease-out; +} + +.copyToast-success { + border-color: color-mix( + in srgb, + var(--geist-success) 35%, + var(--geist-border) + ); +} + +.copyToast-error { + border-color: color-mix(in srgb, var(--geist-error) 45%, var(--geist-border)); + color: var(--geist-error); +} + +@keyframes copyToastIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + /* ── Reset & Base ── */ *, *::before, diff --git a/packages/genui/a2ui/src/catalog/Image/index.tsx b/packages/genui/a2ui/src/catalog/Image/index.tsx index 2ce33881c1..eceea2cb9c 100644 --- a/packages/genui/a2ui/src/catalog/Image/index.tsx +++ b/packages/genui/a2ui/src/catalog/Image/index.tsx @@ -28,6 +28,15 @@ export interface ImageProps extends GenericComponentProps { const fallbackImage = 'https://lf3-static.bytednsdoc.com/obj/eden-cn/zalzzh-ukj-lapzild-shpjpmmv-eufs/ljhwZthlaukjlkulzlp/built-in-images/logo.png'; +function isLoadableImageSource(value: unknown): value is string { + if (typeof value !== 'string') return false; + const src = value.trim(); + if (!src) return false; + if (/^(?:https?:|data:image\/|blob:|file:)/iu.test(src)) return true; + if (/^(?:\/|\.\/|\.\.\/)/u.test(src)) return true; + return /\.(?:avif|gif|jpe?g|png|svg|webp)(?:[?#].*)?$/iu.test(src); +} + export function Image( props: ImageProps, ): import('@lynx-js/react').ReactNode { @@ -48,19 +57,22 @@ export function Image( })(); const [hasError, setHasError] = useState(false); + const variant = props.variant ?? 'mediumFeature'; + const className = `a2ui-image image-variant-${variant} ${ + typeof props.weight === 'number' ? 'image-weighted' : '' + }`; + const loadableSrc = isLoadableImageSource(src) ? src.trim() : undefined; useEffect(() => { setHasError(false); - }, [src]); + }, [loadableSrc]); return ( setHasError(true)} /> diff --git a/packages/genui/server/agent/a2ui-stream-parser.ts b/packages/genui/server/agent/a2ui-stream-parser.ts new file mode 100644 index 0000000000..0a7193433b --- /dev/null +++ b/packages/genui/server/agent/a2ui-stream-parser.ts @@ -0,0 +1,385 @@ +// 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 type { A2UIMessage } from './a2ui-validator'; + +type A2UIUpdateComponentsMessage = Extract< + A2UIMessage, + { updateComponents: unknown } +>; +type A2UIComponent = A2UIUpdateComponentsMessage['updateComponents'][ + 'components' +][number]; +type ComponentRecord = A2UIComponent & Record; + +const ROOT_COMPONENT_ID = 'root'; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasRecordKey( + value: Record, + key: string, +): value is Record> { + return isRecord(value[key]); +} + +function isA2UIMessage(value: unknown): value is A2UIMessage { + if (!isRecord(value) || value.version !== 'v0.9') return false; + + if (hasRecordKey(value, 'createSurface')) { + const createSurface = value.createSurface; + return typeof createSurface.surfaceId === 'string' + && typeof createSurface.catalogId === 'string'; + } + + if (hasRecordKey(value, 'updateComponents')) { + const updateComponents = value.updateComponents; + return typeof updateComponents.surfaceId === 'string' + && Array.isArray(updateComponents.components); + } + + if (hasRecordKey(value, 'updateDataModel')) { + const updateDataModel = value.updateDataModel; + return typeof updateDataModel.surfaceId === 'string'; + } + + if (hasRecordKey(value, 'deleteSurface')) { + return typeof value.deleteSurface.surfaceId === 'string'; + } + + return false; +} + +function isUpdateComponentsMessage( + message: A2UIMessage, +): message is A2UIUpdateComponentsMessage { + return 'updateComponents' in message && Boolean(message.updateComponents); +} + +function isA2UIComponent(value: unknown): value is Record & { + id: string; + component: string; +} { + return isRecord(value) + && typeof value.id === 'string' + && value.id.length > 0 + && typeof value.component === 'string' + && value.component.length > 0; +} + +function sniffUpdateComponentsSurfaceId(buffer: string): string | null { + const updateIndex = buffer.lastIndexOf('"updateComponents"'); + if (updateIndex === -1) return null; + const fragment = buffer.slice(updateIndex); + const match = /"surfaceId"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/u.exec( + fragment, + ); + if (!match) return null; + try { + return JSON.parse(`"${match[1]}"`) as string; + } catch { + return null; + } +} + +function placeholderId(id: string): string { + return `loading_${id}`; +} + +function createPlaceholderComponent(id: string): ComponentRecord { + return { + id: placeholderId(id), + component: 'Text', + text: 'Loading...', + variant: 'caption', + }; +} + +function collectChildRefs(component: ComponentRecord): string[] { + const refs: string[] = []; + const child = component.child; + if (typeof child === 'string') refs.push(child); + + const trigger = component.trigger; + if (typeof trigger === 'string') refs.push(trigger); + + const content = component.content; + if (typeof content === 'string') refs.push(content); + + const children = component.children; + if (Array.isArray(children)) { + for (const item of children) { + if (typeof item === 'string') refs.push(item); + } + } else if (isRecord(children)) { + const componentId = children.componentId; + if (typeof componentId === 'string') refs.push(componentId); + const template = children.template; + if (isRecord(template) && typeof template.componentId === 'string') { + refs.push(template.componentId); + } + } + + const tabs = component.tabs; + if (Array.isArray(tabs)) { + for (const tab of tabs) { + if (isRecord(tab) && typeof tab.child === 'string') { + refs.push(tab.child); + } + } + } + + return refs; +} + +function replaceMissingChildRefs( + component: ComponentRecord, + seen: Map, + placeholders: Map, +): ComponentRecord { + const next = { ...component }; + + const replaceRef = (id: string) => { + if (seen.has(id)) return id; + const placeholder = createPlaceholderComponent(id); + placeholders.set(placeholder.id, placeholder); + return placeholder.id; + }; + + if (typeof next.child === 'string') { + next.child = replaceRef(next.child); + } + if (typeof next.trigger === 'string') { + next.trigger = replaceRef(next.trigger); + } + if (typeof next.content === 'string') { + next.content = replaceRef(next.content); + } + if (Array.isArray(next.children)) { + const children = next.children as unknown[]; + next.children = children.map((item) => + typeof item === 'string' ? replaceRef(item) : item + ); + } else if (isRecord(next.children)) { + const children = { ...next.children }; + if (typeof children.componentId === 'string') { + children.componentId = replaceRef(children.componentId); + } + const template = children.template; + if (isRecord(template) && typeof template.componentId === 'string') { + children.template = { + ...template, + componentId: replaceRef(template.componentId), + }; + } + next.children = children; + } + if (Array.isArray(next.tabs)) { + const tabs = next.tabs as unknown[]; + next.tabs = tabs.map((tab) => { + if (!isRecord(tab) || typeof tab.child !== 'string') return tab; + return { ...tab, child: replaceRef(tab.child) }; + }); + } + + return next; +} + +function buildReachableComponentSnapshot( + seen: Map, +): ComponentRecord[] { + const root = seen.get(ROOT_COMPONENT_ID) ?? seen.values().next().value; + if (!root) return []; + + const reachableIds = new Set(); + const visit = (id: string) => { + if (reachableIds.has(id)) return; + const component = seen.get(id); + if (!component) return; + reachableIds.add(id); + for (const childId of collectChildRefs(component)) { + visit(childId); + } + }; + visit(root.id); + + const placeholders = new Map(); + const components: ComponentRecord[] = []; + for (const component of seen.values()) { + if (!reachableIds.has(component.id)) continue; + components.push(replaceMissingChildRefs(component, seen, placeholders)); + } + components.push(...placeholders.values()); + return components; +} + +export class A2UIProtocolMessageStreamParser { + private buffer = ''; + private cursor = 0; + private depth = 0; + private inArray = false; + private inString = false; + private escaped = false; + private itemStart = -1; + private objectStack: number[] = []; + private seenComponentsBySurface = new Map< + string, + Map + >(); + + public push(chunk: string): A2UIMessage[] { + this.buffer += chunk; + const messages: A2UIMessage[] = []; + + for (let i = this.cursor; i < this.buffer.length; i++) { + const ch = this.buffer[i]; + + if (this.inString) { + if (this.escaped) { + this.escaped = false; + } else if (ch === '\\') { + this.escaped = true; + } else if (ch === '"') { + this.inString = false; + } + continue; + } + + if (ch === '"') { + this.inString = true; + continue; + } + + if (!this.inArray) { + if (ch === '[') { + this.inArray = true; + this.depth = 1; + } + continue; + } + + if (ch === '{' || ch === '[') { + this.depth++; + if (this.depth === 2 && ch === '{') { + this.itemStart = i; + } + if (ch === '{') { + this.objectStack.push(i); + } + continue; + } + + if (ch !== '}' && ch !== ']') continue; + + const objectStart = ch === '}' ? this.objectStack.pop() : undefined; + if (objectStart !== undefined) { + this.pushComponentMessage(objectStart, i, messages); + } + + if (this.depth === 2 && ch === '}' && this.itemStart !== -1) { + const candidate = this.buffer.slice(this.itemStart, i + 1); + try { + const parsed = JSON.parse(candidate) as unknown; + if (isA2UIMessage(parsed) && !isUpdateComponentsMessage(parsed)) { + messages.push(parsed); + } + } catch { + // Keep scanning. Final validation still owns complete-response errors. + } + this.itemStart = -1; + } + + this.depth--; + if (this.depth <= 0) { + this.inArray = false; + this.depth = 0; + this.objectStack = []; + } + } + + this.cursor = this.buffer.length; + return messages; + } + + private pushComponentMessage( + start: number, + end: number, + messages: A2UIMessage[], + ): void { + const componentsIndex = this.buffer.lastIndexOf('"components"', start); + if (componentsIndex === -1) return; + const updateIndex = this.buffer.lastIndexOf( + '"updateComponents"', + componentsIndex, + ); + if (updateIndex === -1) return; + + const between = this.buffer.slice(componentsIndex, start); + if (!between.includes('[')) return; + + let parsed: unknown; + try { + parsed = JSON.parse(this.buffer.slice(start, end + 1)) as unknown; + } catch { + return; + } + if (!isA2UIComponent(parsed)) return; + + const surfaceId = sniffUpdateComponentsSurfaceId( + this.buffer.slice(0, start), + ); + if (!surfaceId) return; + const seen = this.seenComponentsBySurface.get(surfaceId) + ?? new Map(); + seen.set(parsed.id, parsed as ComponentRecord); + this.seenComponentsBySurface.set(surfaceId, seen); + + const components = buildReachableComponentSnapshot(seen); + if (components.length === 0) return; + + messages.push({ + version: 'v0.9', + updateComponents: { + surfaceId, + components, + }, + }); + } +} + +export function splitA2UIProtocolMessages( + messages: A2UIMessage[], +): A2UIMessage[] { + const result: A2UIMessage[] = []; + const seenComponentsBySurface = new Map< + string, + Map + >(); + for (const message of messages) { + if (!isUpdateComponentsMessage(message)) { + result.push(message); + continue; + } + + const { surfaceId, components } = message.updateComponents; + const seen = seenComponentsBySurface.get(surfaceId) + ?? new Map(); + for (const component of components) { + seen.set(component.id, component as ComponentRecord); + seenComponentsBySurface.set(surfaceId, seen); + const snapshot = buildReachableComponentSnapshot(seen); + if (snapshot.length === 0) continue; + result.push({ + version: 'v0.9', + updateComponents: { + surfaceId, + components: snapshot, + }, + }); + } + } + return result; +} diff --git a/packages/genui/server/app/a2ui/_shared.ts b/packages/genui/server/app/a2ui/_shared.ts index f445d3faba..f1e85d890c 100644 --- a/packages/genui/server/app/a2ui/_shared.ts +++ b/packages/genui/server/app/a2ui/_shared.ts @@ -65,7 +65,7 @@ export function pickChatOptions(body: { const allowOverride = clientOverridesAllowed(); return { resourceId: body.resourceId, - model: body.model, + model: allowOverride ? body.model : undefined, apiKey: allowOverride ? body.apiKey : undefined, baseURL: allowOverride ? body.baseURL : undefined, catalog: body.catalog, diff --git a/packages/genui/server/app/a2ui/action/stream/route.ts b/packages/genui/server/app/a2ui/action/stream/route.ts index dfedd0502a..9c51996ed4 100644 --- a/packages/genui/server/app/a2ui/action/stream/route.ts +++ b/packages/genui/server/app/a2ui/action/stream/route.ts @@ -4,6 +4,10 @@ import type { A2UICatalog } from '../../../../agent/a2ui-catalog'; import { BASIC_CATALOG } from '../../../../agent/a2ui-catalog'; +import { + A2UIProtocolMessageStreamParser, + splitA2UIProtocolMessages, +} from '../../../../agent/a2ui-stream-parser'; import { validateA2UIOutput } from '../../../../agent/a2ui-validator'; import { resolveA2UIImageUrls } from '../../../../agent/image-resolver'; import { getA2UIAgentService } from '../../../../service/a2ui-agent'; @@ -134,12 +138,22 @@ export async function POST(req: Request) { opts, validatedConversation.conversation, ); + const protocolParser = new A2UIProtocolMessageStreamParser(); + const streamedMessages: unknown[] = []; + let streamedText = ''; for await (const chunk of textStream) { + streamedText += chunk; enqueue('delta', { text: chunk }); + const newMessages = protocolParser.push(chunk); + if (newMessages.length > 0) { + streamedMessages.push(...newMessages); + enqueue('message', { messages: streamedMessages }); + } } let { text: finalText, usage, finishReason } = await finalize(); + finalText ??= streamedText; let repair: | { attempted: true; @@ -166,7 +180,7 @@ export async function POST(req: Request) { validationOptions, ); let resolvedMessages = v.ok - ? await resolveA2UIImageUrls(v.messages) + ? splitA2UIProtocolMessages(await resolveA2UIImageUrls(v.messages)) : []; validation = { ok: v.ok, @@ -192,8 +206,8 @@ export async function POST(req: Request) { finalText = repaired.text; usage = repaired.usage; finishReason = repaired.finishReason; - resolvedMessages = await resolveA2UIImageUrls( - repaired.messages, + resolvedMessages = splitA2UIProtocolMessages( + await resolveA2UIImageUrls(repaired.messages), ); validation = { ok: true, @@ -208,12 +222,18 @@ export async function POST(req: Request) { }; } } catch (err: unknown) { + const repairError = errorMessage(err).message; repair = { attempted: true, sourceErrors: v.errors, ok: false, attempts: 0, - errors: [errorMessage(err).message], + errors: [repairError], + }; + validation = { + ok: false, + errors: [repairError], + messages: [], }; enqueue('repair', repair); } diff --git a/packages/genui/server/app/a2ui/stream/route.ts b/packages/genui/server/app/a2ui/stream/route.ts index 65173f934b..782a4dc939 100644 --- a/packages/genui/server/app/a2ui/stream/route.ts +++ b/packages/genui/server/app/a2ui/stream/route.ts @@ -3,6 +3,10 @@ // LICENSE file in the root directory of this source tree. import { BASIC_CATALOG } from '../../../agent/a2ui-catalog'; +import { + A2UIProtocolMessageStreamParser, + splitA2UIProtocolMessages, +} from '../../../agent/a2ui-stream-parser'; import { validateA2UIOutput } from '../../../agent/a2ui-validator'; import { resolveA2UIImageUrls } from '../../../agent/image-resolver'; import { getA2UIAgentService } from '../../../service/a2ui-agent'; @@ -86,12 +90,22 @@ export async function POST(req: Request) { opts, validatedConversation.conversation, ); + const protocolParser = new A2UIProtocolMessageStreamParser(); + const streamedMessages: unknown[] = []; + let streamedText = ''; for await (const chunk of textStream) { + streamedText += chunk; enqueue('delta', { text: chunk }); + const newMessages = protocolParser.push(chunk); + if (newMessages.length > 0) { + streamedMessages.push(...newMessages); + enqueue('message', { messages: streamedMessages }); + } } let { text: finalText, usage, finishReason } = await finalize(); + finalText ??= streamedText; let repair: | { attempted: true; @@ -113,7 +127,7 @@ export async function POST(req: Request) { opts.catalog ?? BASIC_CATALOG, ); let resolvedMessages = v.ok - ? await resolveA2UIImageUrls(v.messages) + ? splitA2UIProtocolMessages(await resolveA2UIImageUrls(v.messages)) : []; validation = { ok: v.ok, @@ -138,8 +152,8 @@ export async function POST(req: Request) { finalText = repaired.text; usage = repaired.usage; finishReason = repaired.finishReason; - resolvedMessages = await resolveA2UIImageUrls( - repaired.messages, + resolvedMessages = splitA2UIProtocolMessages( + await resolveA2UIImageUrls(repaired.messages), ); validation = { ok: true, @@ -154,12 +168,18 @@ export async function POST(req: Request) { }; } } catch (err: unknown) { + const repairError = errorMessage(err).message; repair = { attempted: true, sourceErrors: v.errors, ok: false, attempts: 0, - errors: [errorMessage(err).message], + errors: [repairError], + }; + validation = { + ok: false, + errors: [repairError], + messages: [], }; enqueue('repair', repair); } From 261e2b24e422b466aa570d19b84ded699328868b Mon Sep 17 00:00:00 2001 From: Sherry-hue <37186915+Sherry-hue@users.noreply.github.com> Date: Mon, 25 May 2026 21:08:37 +0800 Subject: [PATCH 2/6] fix(a2ui): stabilize preview replay and images --- .../a2ui-playground/lynx-src/a2ui/App.tsx | 42 ++- .../a2ui-playground/src/pages/AIChatPage.css | 8 + .../a2ui-playground/src/pages/AIChatPage.tsx | 245 ++++++++++++------ packages/genui/a2ui-playground/src/render.tsx | 106 +++++++- .../genui/a2ui/src/catalog/Image/index.tsx | 25 +- packages/genui/server/agent/a2ui-prompt.ts | 48 ++-- .../genui/server/agent/a2ui-stream-parser.ts | 84 +++--- packages/genui/server/agent/a2ui-validator.ts | 60 +++++ .../server/app/a2ui/action/stream/route.ts | 100 ++++++- .../genui/server/app/a2ui/stream/route.ts | 98 ++++++- 10 files changed, 654 insertions(+), 162 deletions(-) diff --git a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx index 6a77bf92c5..52af06162b 100644 --- a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx @@ -336,6 +336,7 @@ export function App() { const storeRef = useRef(null); const agentRef = useRef | null>(null); + const pendingLiveMessagesRef = useRef(null); const [store, setStore] = useState(null); const [error, setError] = useState(''); const playbackMode = useMemo( @@ -378,6 +379,15 @@ export function App() { () => effectiveData.playbackPaused === true, [effectiveData.playbackPaused], ); + const pushLiveMessagesToStore = useCallback( + (targetStore: MessageStore, messages: unknown) => { + const normalized = normalizeProtocolMessages(messages); + for (const msg of normalized) { + targetStore.push(msg); + } + }, + [], + ); const postPlaybackSync = useCallback((state: MockAgentProgress) => { NativeModules.bridge?.call?.( 'A2UI_PLAYBACK_SYNC', @@ -445,15 +455,16 @@ export function App() { useLynxGlobalEventListener( 'A2UI_LIVE_MESSAGES', (messages: unknown) => { - const normalized = normalizeProtocolMessages(messages); - const next = createMessageStore(); - for (const msg of normalized) { - next.push(msg); + const currentStore = storeRef.current; + if (!currentStore) { + pendingLiveMessagesRef.current = Array.isArray(messages) + ? messages + : [messages]; + return; } + pushLiveMessagesToStore(currentStore, messages); agentRef.current?.stop(); agentRef.current = null; - storeRef.current = next; - setStore(next); }, ); @@ -508,9 +519,18 @@ export function App() { storeRef.current = next; agentRef.current = agent; setStore(next); + const pendingLiveMessages = pendingLiveMessagesRef.current; + if (pendingLiveMessages) { + pendingLiveMessagesRef.current = null; + pushLiveMessagesToStore(next, pendingLiveMessages); + agent.stop(); + agentRef.current = null; + } syncPlaybackAgent(); // Begin streaming the demo's initial messages into the buffer. - void agent.start(); + if (agentRef.current === agent) { + void agent.start(); + } }; run() @@ -527,7 +547,13 @@ export function App() { storeRef.current = null; agentRef.current = null; }; - }, [isInstantPreview, postPlaybackSync, streamConfig, streamDelay]); + }, [ + isInstantPreview, + postPlaybackSync, + pushLiveMessagesToStore, + streamConfig, + streamDelay, + ]); return ( void }, + props: { + payload: unknown; + onCopy: (text: string) => void; + singleBlock?: boolean; + }, ) { - const { onCopy, payload } = props; + const { onCopy, payload, singleBlock = false } = props; + if (singleBlock) { + const payloadStr = safeStringifyPayload(payload); + return ( +
+
+
+ Request + +
+
{payloadStr}
+
+
+ ); + } + const chunks = payloadToChunks(payload); return ( @@ -429,6 +462,27 @@ function normalizeA2UIMessages(payload: unknown): unknown[] { return []; } +function includesCreateSurface(messages: unknown[]): boolean { + return messages.some((message) => + Boolean( + message + && typeof message === 'object' + && 'createSurface' in message + && (message as { createSurface?: unknown }).createSurface, + ) + ); +} + +function buildPreviewMessagesFromHistory( + history: ModelChatMessage[], +): unknown[] { + return history.flatMap((message) => + message.role === 'assistant' + ? normalizeA2UIMessages(message.content) + : [] + ); +} + function parseCompletedArrayItems(raw: string): unknown[] { const trimmed = raw.trimStart(); if (!trimmed.startsWith('[')) return []; @@ -540,6 +594,7 @@ async function readA2UIResponse( } if (parsed.event === 'message') { + if (!publishPartialMessages) continue; const messages = normalizeA2UIMessages(parsed.data); if (messages.length > 0) { latestMessages = messages; @@ -615,6 +670,58 @@ function parsePersistedUserAction(content: string): { } } +function createActionForwardingStatus(actionName: string): ChatMessage { + return { + role: 'status', + tone: 'info', + content: ( + <> + + + Lynx Preview triggered{' '} + {actionName}, + forwarding request to agent... + + + ), + }; +} + +function createAgentRespondedStatus(count: number): ChatMessage { + return { + role: 'status', + tone: 'success', + content: ( + <> + + + Agent responded with {count} A2UI{' '} + {count === 1 ? 'message' : 'messages'}. + + + ), + }; +} + +function createPreviewReadyStatus(): ChatMessage { + return { + role: 'status', + tone: 'info', + content: ( + <> + + UI updated. Ready for the next action. + + ), + }; +} + function buildChatMessagesFromHistory( history: ModelChatMessage[], ): ChatMessage[] { @@ -625,6 +732,7 @@ function buildChatMessagesFromHistory( if (message.role === 'user') { const action = parsePersistedUserAction(message.content); if (action) { + next.push(createActionForwardingStatus(action.name)); next.push({ role: 'action', content: `⚡ Action: ${action.name}`, @@ -641,6 +749,7 @@ function buildChatMessagesFromHistory( if (previousWasAction) { const actionMessages = normalizeA2UIMessages(message.content); if (actionMessages.length > 0) { + next.push(createAgentRespondedStatus(actionMessages.length)); next.push({ role: 'action', content: `✅ Applied ${actionMessages.length} ${ @@ -648,6 +757,7 @@ function buildChatMessagesFromHistory( } to Lynx Preview`, payload: actionMessages, }); + next.push(createPreviewReadyStatus()); previousWasAction = false; continue; } @@ -710,6 +820,7 @@ export function AIChatPage( const actionAbortRef = useRef(null); const hydratedActiveIdRef = useRef(null); const latestPreviewMessagesRef = useRef([]); + const renderUrlRef = useRef(''); const { containerRef: pageRef, handleResizeStart: handlePanelResizeStart, @@ -746,6 +857,10 @@ export function AIChatPage( providerRequestOptionsRef.current = providerRequestOptions; }, [providerRequestOptions]); + useEffect(() => { + renderUrlRef.current = renderUrl; + }, [renderUrl]); + useEffect(() => { try { window.localStorage.setItem( @@ -849,7 +964,7 @@ export function AIChatPage( const initData = { protocol, demoUrl: DEFAULT_A2UI_DEMO_URL, - messages: nextMessages, + messages: [], theme, instant: true, liveAction: nextMessages.length > 0, @@ -857,14 +972,16 @@ export function AIChatPage( setRenderUrl((current) => { if (current) { + const targetOrigin = targetOriginForFrame(current); previewFrameRef.current?.contentWindow?.postMessage( { type: 'A2UI_LIVE_MESSAGES', messages: nextMessages }, - window.location.origin, + targetOrigin, ); return current; } - return buildRenderUrl(initData, baseUrl); + const nextRenderUrl = buildRenderUrl(initData, baseUrl); + return nextRenderUrl; }); }, [baseUrl, protocol, theme], @@ -876,7 +993,17 @@ export function AIChatPage( useEffect(() => { if (!isReady || isGenerating) return; - if (hydratedActiveIdRef.current === activeId) return; + const replayMessages = includesCreateSurface(persistedPreviewMessages) + ? persistedPreviewMessages + : buildPreviewMessagesFromHistory(persistedMessages); + + if (hydratedActiveIdRef.current === activeId) { + if (!renderUrl && replayMessages.length > 0) { + publishPreviewMessages(replayMessages); + } + return; + } + hydratedActiveIdRef.current = activeId; setMessages(buildChatMessagesFromHistory(persistedMessages)); setGeneratedJson(''); @@ -885,8 +1012,8 @@ export function AIChatPage( completionTokens: 0, totalTokens: 0, }); - if (persistedPreviewMessages.length > 0) { - publishPreviewMessages(persistedPreviewMessages); + if (replayMessages.length > 0) { + publishPreviewMessages(replayMessages); } else { latestPreviewMessagesRef.current = []; setPreviewMessages(null); @@ -899,6 +1026,7 @@ export function AIChatPage( persistedMessages, persistedPreviewMessages, publishPreviewMessages, + renderUrl, ]); useEffect(() => { @@ -980,7 +1108,6 @@ export function AIChatPage( totalTokens: prev.totalTokens + usage.totalTokens, })); }, - { publishPartialMessages: false }, ); if (finalMessages.length === 0) { @@ -1038,6 +1165,10 @@ export function AIChatPage( const handleMessage = (e: MessageEvent) => { if (!e.data || typeof e.data !== 'object') return; const msg = e.data as Record; + if (msg.type === 'A2UI_RENDER_READY') { + publishPreviewMessages(latestPreviewMessagesRef.current); + return; + } if (msg.type !== 'A2UI_USER_ACTION') return; const action = msg.action as { @@ -1072,22 +1203,7 @@ export function AIChatPage( setMessages((prev) => { const next: ChatMessage[] = [ ...prev, - { - role: 'status' as const, - tone: 'info', - content: ( - <> - - - Lynx Preview triggered{' '} - {actionName} - , forwarding request to agent... - - - ), - }, + createActionForwardingStatus(actionName), { role: 'action' as const, content: `⚡ Action: ${actionName}`, @@ -1148,28 +1264,25 @@ export function AIChatPage( (text) => { if (!text) return; if (signal.aborted) return; - if (responseMessages.length > 0) return; setMessages((prev) => { const next = prev.slice(); - if (streamingIndex < 0 || streamingIndex >= next.length) { - // First non-empty delta — insert the streaming card right - // after the pending status row so the card appears only - // when there is actual data to show. - const insertAt = pendingIndex >= 0 - && pendingIndex < next.length - ? pendingIndex + 1 - : next.length; - next.splice(insertAt, 0, { - role: 'action' as const, - content: '✨ Streaming RESPONSE...', - payload: text, - }); - streamingIndex = insertAt; + if (pendingIndex < 0 || pendingIndex >= next.length) { return next; } - next[streamingIndex] = { - ...next[streamingIndex], - payload: text, + next[pendingIndex] = { + role: 'status' as const, + tone: 'pending', + content: ( + <> +