diff --git a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx index 5692440a8a..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', @@ -442,6 +452,22 @@ export function App() { }, ); + useLynxGlobalEventListener( + 'A2UI_LIVE_MESSAGES', + (messages: unknown) => { + const currentStore = storeRef.current; + if (!currentStore) { + pendingLiveMessagesRef.current = Array.isArray(messages) + ? messages + : [messages]; + return; + } + pushLiveMessagesToStore(currentStore, messages); + agentRef.current?.stop(); + agentRef.current = null; + }, + ); + useEffect(() => { playbackPausedRef.current = isPlaybackPaused; }, [isPlaybackPaused]); @@ -493,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() @@ -512,7 +547,13 @@ export function App() { storeRef.current = null; agentRef.current = null; }; - }, [isInstantPreview, postPlaybackSync, streamConfig, streamDelay]); + }, [ + isInstantPreview, + postPlaybackSync, + pushLiveMessagesToStore, + streamConfig, + streamDelay, + ]); return ( (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..f021707b1e 100644 --- a/packages/genui/a2ui-playground/src/pages/AIChatPage.css +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.css @@ -595,7 +595,8 @@ border-radius: var(--geist-radius-lg); font-size: 14px; line-height: 1.5; - word-break: break-word; + overflow-wrap: anywhere; + word-break: normal; flex-shrink: 0; } @@ -724,6 +725,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 +764,61 @@ 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; +.chatMessageChunks { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + max-height: 520px; + overflow-y: auto; + background: var(--geist-surface); } -.chatMessagePayloadEditor .cm-editor { - height: auto; - max-height: 320px; - font-family: var(--geist-mono); - font-size: 12px; - background: var(--geist-surface); - color: var(--geist-foreground); +.chatMessageChunk { + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-md); + overflow: hidden; + background: var(--geist-background); + flex-shrink: 0; } -.chatMessagePayloadEditor .cm-scroller { - max-height: 320px; - overflow: auto; +.chatMessageSingleChunk { + margin: 10px; + border: 1px solid var(--geist-border); + border-radius: var(--geist-radius-md); + overflow: hidden; + background: var(--geist-background); } -.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; + overflow-wrap: anywhere; + word-break: normal; } .chatGeneratedJson { @@ -827,41 +847,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..ec244caf66 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'; } @@ -53,6 +52,10 @@ interface TokenUsage { totalTokens: number; } +interface A2UIResponseMessageMeta { + final: boolean; +} + interface ProviderSettings { preset: ProviderPresetId; apiKey: string; @@ -139,7 +142,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' }, @@ -224,6 +226,14 @@ function getA2UIActionStreamEndpoint(): string { ); } +function targetOriginForFrame(src: string): string { + try { + return new URL(src, window.location.href).origin; + } catch { + return window.location.origin; + } +} + function canForwardApiKeyToEndpoint(raw: string): boolean { try { const endpoint = new URL(raw, window.location.origin); @@ -286,7 +296,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 +310,74 @@ 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; + singleBlock?: boolean; + }, +) { + const { onCopy, payload, singleBlock = false } = props; + if (singleBlock) { + const payloadStr = safeStringifyPayload(payload); + return ( +
+
+
+ Request + +
+
{payloadStr}
+
+
+ ); + } + + 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 { @@ -388,6 +466,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 []; @@ -445,9 +544,13 @@ function parseCompletedArrayItems(raw: string): unknown[] { async function readA2UIResponse( response: BrowserResponse, onText: (text: string) => void, - onMessages: (messages: unknown[]) => void, + onMessages: (messages: unknown[], meta: A2UIResponseMessageMeta) => void, onUsage?: (usage: TokenUsage) => void, - options: { publishPartialMessages?: boolean } = {}, + options: { + parseDeltaMessages?: boolean; + publishPartialMessages?: boolean; + publishText?: boolean; + } = {}, ): Promise { const contentType = response.headers.get('content-type') ?? ''; if (!contentType.includes('text/event-stream')) { @@ -460,7 +563,7 @@ async function readA2UIResponse( const usage = parseUsage((payload as A2UIDonePayload).usage); if (usage) onUsage?.(usage); } - onMessages(messages); + onMessages(messages, { final: true }); return messages; } @@ -471,7 +574,9 @@ async function readA2UIResponse( let buffer = ''; let generatedText = ''; let latestMessages: unknown[] = []; + const parseDeltaMessages = options.parseDeltaMessages ?? true; const publishPartialMessages = options.publishPartialMessages ?? true; + const publishText = options.publishText ?? true; while (true) { const { done, value } = await reader.read(); @@ -487,17 +592,27 @@ async function readA2UIResponse( const deltaData = parsed.data as { text?: unknown }; if (typeof deltaData.text === 'string') { generatedText += deltaData.text; - onText(generatedText); - if (!publishPartialMessages) continue; + if (publishText) onText(generatedText); + if (!publishPartialMessages || !parseDeltaMessages) continue; const completed = parseCompletedArrayItems(generatedText); if (completed.length > latestMessages.length) { latestMessages = completed; - onMessages(latestMessages); + onMessages(latestMessages, { final: false }); } } continue; } + if (parsed.event === 'message') { + if (!publishPartialMessages) continue; + const messages = normalizeA2UIMessages(parsed.data); + if (messages.length > 0) { + latestMessages = messages; + onMessages(latestMessages, { final: false }); + } + continue; + } + if (parsed.event === 'done') { const doneMessages = normalizeA2UIMessages(parsed.data); if (parsed.data && typeof parsed.data === 'object') { @@ -506,7 +621,7 @@ async function readA2UIResponse( } if (doneMessages.length > 0) { latestMessages = doneMessages; - onMessages(latestMessages); + onMessages(latestMessages, { final: true }); } else { throw new Error(normalizeErrorPayload(parsed.data)); } @@ -535,7 +650,7 @@ const SUGGESTED_PROMPTS: Array<{ label: string; text: string }> = [ { label: '🛍️ Product card with Buy', text: - 'Create a product card for a limited-edition sneaker. Include name, a photo, price ($189), a short description, and a "Buy Now" button. When tapped, show an order confirmation with a fake order number and estimated delivery.', + 'Create a product card for a limited-edition sneaker. Include name, a photo, price ($189), a short description, and a "Buy Now" button. When tapped, show a purchase confirmation step with a "Confirm Purchase" button. Only the Confirm Purchase button should submit the action; after the action response, replace the card with an order success page showing a fake order number and estimated delivery.', }, { label: '⚡ Quiz card with actions', @@ -544,23 +659,131 @@ 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; + const event = record.event; + const eventName = event && typeof event === 'object' + ? (event as { name?: unknown }).name + : undefined; + return { + action: record, + name: typeof record.name === 'string' + ? record.name + : (typeof eventName === 'string' ? eventName : 'unknown'), + }; + } catch { + return null; + } +} + +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[] { 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(createActionForwardingStatus(action.name)); + 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(createAgentRespondedStatus(actionMessages.length)); + next.push({ + role: 'action', + content: `✅ Applied ${actionMessages.length} ${ + actionMessages.length === 1 ? 'message' : 'messages' + } to Lynx Preview`, + payload: actionMessages, + }); + next.push(createPreviewReadyStatus()); + previousWasAction = false; + continue; + } + } next.push({ role: 'json', content: 'Generated Output', payload: message.content, - payloadLabel: 'JSON', }); + previousWasAction = false; } } return next; @@ -591,7 +814,6 @@ export function AIChatPage( () => readProviderSettings(), ); const [renderUrl, setRenderUrl] = useState(''); - const [generatedJson, setGeneratedJson] = useState(''); const [previewMessages, setPreviewMessages] = useState( null, ); @@ -604,6 +826,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); @@ -612,6 +835,7 @@ export function AIChatPage( const actionAbortRef = useRef(null); const hydratedActiveIdRef = useRef(null); const latestPreviewMessagesRef = useRef([]); + const renderUrlRef = useRef(''); const { containerRef: pageRef, handleResizeStart: handlePanelResizeStart, @@ -629,6 +853,13 @@ export function AIChatPage( initialSecondarySize: 560, }); + const handleCopyText = useCallback( + (text: string) => { + void copyToClipboard(text).then(showCopyToast); + }, + [showCopyToast], + ); + const providerRequestOptions = useMemo( () => toProviderRequestOptions(providerSettings), [providerSettings], @@ -641,6 +872,10 @@ export function AIChatPage( providerRequestOptionsRef.current = providerRequestOptions; }, [providerRequestOptions]); + useEffect(() => { + renderUrlRef.current = renderUrl; + }, [renderUrl]); + useEffect(() => { try { window.localStorage.setItem( @@ -659,25 +894,24 @@ 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, 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 +920,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 +951,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; @@ -744,7 +978,7 @@ export function AIChatPage( const initData = { protocol, demoUrl: DEFAULT_A2UI_DEMO_URL, - messages: nextMessages, + messages: [], theme, instant: true, liveAction: nextMessages.length > 0, @@ -752,9 +986,46 @@ export function AIChatPage( setRenderUrl((current) => { if (current) { + const targetOrigin = targetOriginForFrame(current); + previewFrameRef.current?.contentWindow?.postMessage( + { type: 'A2UI_LIVE_MESSAGES', messages: nextMessages }, + targetOrigin, + ); + return current; + } + + const nextRenderUrl = buildRenderUrl(initData, baseUrl); + return nextRenderUrl; + }); + }, + [baseUrl, protocol, theme], + ); + + const publishStreamingPreviewMessages = useCallback( + (deltaMessages: unknown[]) => { + if (deltaMessages.length === 0) return; + const accumulatedMessages = [ + ...latestPreviewMessagesRef.current, + ...deltaMessages, + ]; + latestPreviewMessagesRef.current = accumulatedMessages; + setPreviewMessages(accumulatedMessages); + + const initData = { + protocol, + demoUrl: DEFAULT_A2UI_DEMO_URL, + messages: [], + theme, + instant: true, + liveAction: deltaMessages.length > 0, + }; + + setRenderUrl((current) => { + if (current) { + const targetOrigin = targetOriginForFrame(current); previewFrameRef.current?.contentWindow?.postMessage( - { type: 'INIT_LYNX_VIEW', data: initData }, - window.location.origin, + { type: 'A2UI_LIVE_MESSAGES', messages: deltaMessages }, + targetOrigin, ); return current; } @@ -771,17 +1042,26 @@ 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(''); setTokenUsage({ promptTokens: 0, completionTokens: 0, totalTokens: 0, }); - if (persistedPreviewMessages.length > 0) { - publishPreviewMessages(persistedPreviewMessages); + if (replayMessages.length > 0) { + publishPreviewMessages(replayMessages); } else { latestPreviewMessagesRef.current = []; setPreviewMessages(null); @@ -794,6 +1074,7 @@ export function AIChatPage( persistedMessages, persistedPreviewMessages, publishPreviewMessages, + renderUrl, ]); useEffect(() => { @@ -821,7 +1102,6 @@ export function AIChatPage( { role: 'ai', content: 'Connecting to A2UI agent...' }, ]); setInputValue(''); - setGeneratedJson(''); setPreviewMessages(null); latestPreviewMessagesRef.current = []; setTokenUsage({ @@ -853,22 +1133,37 @@ 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(); + next[next.length - 1] = { + role: 'ai', + content: 'Streaming A2UI messages...', + }; + return next; + }); + }, + (nextMessages, meta) => { + if (controller.signal.aborted) return; + if (meta.final) { + publishPreviewMessages(nextMessages); + return; + } + publishStreamingPreviewMessages(nextMessages); setMessages((prev) => { const next = prev.slice(); next[next.length - 1] = { role: 'ai', - content: `Generating A2UI JSON... ${nextText.length} chars`, + content: + `Streaming ${latestPreviewMessagesRef.current.length} A2UI message${ + latestPreviewMessagesRef.current.length === 1 ? '' : 's' + }...`, }; return next; }); }, - publishPreviewMessages, (usage) => { if (controller.signal.aborted) return; setTokenUsage((prev) => ({ @@ -877,19 +1172,19 @@ export function AIChatPage( totalTokens: prev.totalTokens + usage.totalTokens, })); }, - { publishPartialMessages: false }, + { + parseDeltaMessages: false, + publishText: false, + }, ); if (finalMessages.length === 0) { 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 +1192,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; }); @@ -931,14 +1225,22 @@ export function AIChatPage( inputValue, isGenerating, publishPreviewMessages, + publishStreamingPreviewMessages, providerRequestOptions, recordTurn, ]); useEffect(() => { const handleMessage = (e: MessageEvent) => { + const frameWindow = previewFrameRef.current?.contentWindow; + if (!frameWindow || e.source !== frameWindow) return; + if (e.origin !== targetOriginForFrame(renderUrlRef.current)) return; 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 { @@ -973,27 +1275,11 @@ 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}`, payload: action, - payloadLabel: 'REQUEST', }, { role: 'status' as const, @@ -1044,20 +1330,41 @@ export function AIChatPage( } let responseMessages: unknown[] = []; - let latestActionText = ''; await readA2UIResponse( response, (text) => { if (!text) return; if (signal.aborted) return; - latestActionText = text; + setMessages((prev) => { + const next = prev.slice(); + if (pendingIndex < 0 || pendingIndex >= next.length) { + return next; + } + next[pendingIndex] = { + role: 'status' as const, + tone: 'pending', + content: ( + <> +
); })} - {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..0535570105 100644 --- a/packages/genui/a2ui-playground/src/render.tsx +++ b/packages/genui/a2ui-playground/src/render.tsx @@ -1,7 +1,14 @@ // 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 { createElement, useEffect, useMemo, useRef, useState } from 'react'; +import { + createElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import ReactDOM from 'react-dom/client'; import './styles.css'; @@ -58,6 +65,11 @@ interface ActionResponseMessage { messages: unknown[]; } +interface LiveMessagesMessage { + type: 'A2UI_LIVE_MESSAGES'; + messages: unknown[]; +} + interface LynxViewElement extends HTMLElement { initData?: InitData; globalProps?: unknown; @@ -223,6 +235,58 @@ function Render() { const [playbackMode, setPlaybackMode] = useState(false); const lynxViewRef = useRef(null); const lastPlaybackPausedRef = useRef(null); + const pendingLiveMessagesRef = useRef(null); + const pendingActionResponsesRef = useRef([]); + const pendingFlushTimerRef = useRef(null); + const pendingFlushAttemptsRef = useRef(0); + + const postRenderReady = useCallback(() => { + if (!window.parent || window.parent === window) return; + window.parent.postMessage({ type: 'A2UI_RENDER_READY' }, '*'); + }, []); + + const hasPendingA2UIEvents = useCallback(() => { + return pendingLiveMessagesRef.current !== null + || pendingActionResponsesRef.current.length > 0; + }, []); + + const flushPendingA2UIEvents = useCallback(() => { + const lynxView = lynxViewRef.current; + if (!lynxView || typeof lynxView.sendGlobalEvent !== 'function') { + return false; + } + + const liveMessages = pendingLiveMessagesRef.current; + if (liveMessages) { + pendingLiveMessagesRef.current = null; + lynxView.sendGlobalEvent('A2UI_LIVE_MESSAGES', [liveMessages]); + } + + const actionResponses = pendingActionResponsesRef.current.splice(0); + for (const messages of actionResponses) { + lynxView.sendGlobalEvent('A2UI_ACTION_RESPONSE', [messages]); + } + + return true; + }, []); + + const schedulePendingA2UIFlush = useCallback(() => { + if (pendingFlushTimerRef.current !== null) return; + + pendingFlushTimerRef.current = window.setTimeout(() => { + pendingFlushTimerRef.current = null; + const flushed = flushPendingA2UIEvents(); + if (flushed || !hasPendingA2UIEvents()) { + pendingFlushAttemptsRef.current = 0; + return; + } + + pendingFlushAttemptsRef.current += 1; + if (pendingFlushAttemptsRef.current < 200) { + schedulePendingA2UIFlush(); + } + }, 50); + }, [flushPendingA2UIEvents, hasPendingA2UIEvents]); // 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. @@ -310,10 +374,26 @@ function Render() { && typeof e.data === 'object' && (e.data as ActionResponseMessage).type === 'A2UI_ACTION_RESPONSE' ) { - const lynxView = lynxViewRef.current; - lynxView?.sendGlobalEvent?.('A2UI_ACTION_RESPONSE', [ + pendingActionResponsesRef.current.push( (e.data as ActionResponseMessage).messages, - ]); + ); + pendingFlushAttemptsRef.current = 0; + if (!flushPendingA2UIEvents()) { + schedulePendingA2UIFlush(); + } + return; + } + if ( + e.data + && typeof e.data === 'object' + && (e.data as LiveMessagesMessage).type === 'A2UI_LIVE_MESSAGES' + ) { + pendingLiveMessagesRef.current = (e.data as LiveMessagesMessage) + .messages; + pendingFlushAttemptsRef.current = 0; + if (!flushPendingA2UIEvents()) { + schedulePendingA2UIFlush(); + } return; } if (!isInitLynxViewMessage(e.data)) { @@ -327,8 +407,9 @@ function Render() { }; window.addEventListener('message', handleMessage); + postRenderReady(); return () => window.removeEventListener('message', handleMessage); - }, []); + }, [flushPendingA2UIEvents, postRenderReady, schedulePendingA2UIFlush]); useEffect(() => { const lynxView = lynxViewRef.current; @@ -346,7 +427,9 @@ function Render() { if (typeof lynxView.reload === 'function') { lynxView.reload(); } - }, [globalProps, initData]); + schedulePendingA2UIFlush(); + postRenderReady(); + }, [globalProps, initData, postRenderReady, schedulePendingA2UIFlush]); useEffect(() => { const lynxView = lynxViewRef.current; @@ -373,7 +456,24 @@ function Render() { playbackPaused ? 'pause' : 'resume', ]); } - }, [globalProps, initData, playbackMode, playbackPaused]); + schedulePendingA2UIFlush(); + postRenderReady(); + }, [ + globalProps, + initData, + playbackMode, + playbackPaused, + postRenderReady, + schedulePendingA2UIFlush, + ]); + + useEffect(() => { + return () => { + if (pendingFlushTimerRef.current !== null) { + window.clearTimeout(pendingFlushTimerRef.current); + } + }; + }, []); return createElement('lynx-view', { ref: lynxViewRef, diff --git a/packages/genui/a2ui-playground/src/styles.css b/packages/genui/a2ui-playground/src/styles.css index 712d7f053e..5dec06c5f3 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: copy-toast-in 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 copy-toast-in { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + /* ── Reset & Base ── */ *, *::before, diff --git a/packages/genui/a2ui/src/catalog/Column/index.tsx b/packages/genui/a2ui/src/catalog/Column/index.tsx index 90b8a3efcf..ec815535ca 100644 --- a/packages/genui/a2ui/src/catalog/Column/index.tsx +++ b/packages/genui/a2ui/src/catalog/Column/index.tsx @@ -1,8 +1,6 @@ // 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 { useMemo } from '@lynx-js/react'; - import { NodeRenderer } from '../../react/A2UIRenderer.jsx'; import { useDataBinding } from '../../react/useDataBinding.js'; import type { @@ -78,13 +76,11 @@ export function Column( [], ); - const childList = useMemo(() => { - if (Array.isArray(children)) { - return children.map((childId: string) => - buildChild(surface, childId, dataContextPath) - ); - } - return (Array.isArray(columnData) ? columnData : []).map((item, index) => { + const childList = Array.isArray(children) + ? children.map((childId: string) => + buildChild(surface, childId, dataContextPath) + ) + : (Array.isArray(columnData) ? columnData : []).map((item, index) => { const key = item && typeof item === 'object' && 'key' in item ? String(item['key']) : `${index}`; @@ -97,7 +93,6 @@ export function Column( key, ); }); - }, [children, surface, dataContextPath, columnData, fullPath, template]); return ( { switch (fit) { @@ -47,22 +41,17 @@ export function Image( } })(); - const [hasError, setHasError] = useState(false); - - useEffect(() => { - setHasError(false); - }, [src]); - + const variant = props.variant ?? 'mediumFeature'; + const className = `a2ui-image image-variant-${variant} ${ + typeof props.weight === 'number' ? 'image-weighted' : '' + }`; return ( setHasError(true)} /> ); } diff --git a/packages/genui/a2ui/src/catalog/Modal/index.tsx b/packages/genui/a2ui/src/catalog/Modal/index.tsx index b7aa9bd5ee..0ed6f72125 100644 --- a/packages/genui/a2ui/src/catalog/Modal/index.tsx +++ b/packages/genui/a2ui/src/catalog/Modal/index.tsx @@ -74,7 +74,13 @@ export function Modal( > {trigger - ? + ? ( + + ) : null} diff --git a/packages/genui/a2ui/src/react/A2UIRenderer.tsx b/packages/genui/a2ui/src/react/A2UIRenderer.tsx index 4fd943841b..b729298d69 100644 --- a/packages/genui/a2ui/src/react/A2UIRenderer.tsx +++ b/packages/genui/a2ui/src/react/A2UIRenderer.tsx @@ -79,6 +79,7 @@ function buildNodeRecursive( props?: Record, setValue?: (key: string, value: unknown) => void, sendAction?: (action: Record) => void, + suppressActionDispatch = false, ): ReactNode { const tag = component.component; const Component = catalog.get(tag); @@ -120,9 +121,11 @@ function buildNodeRecursive( id={component.id ?? ''} surface={surface} setValue={setValue} - sendAction={(a: Record) => { - void sendAction?.(a); - }} + sendAction={suppressActionDispatch + ? undefined + : (a: Record) => { + void sendAction?.(a); + }} dataContextPath={component.dataContextPath} /> @@ -234,6 +237,7 @@ function NodeRendererImpl( props: { component: ComponentInstance; surface: Surface; + suppressActionDispatch?: boolean; renderUnsupported?: | ((info: UnsupportedInfo) => ReactNode) | undefined; @@ -242,6 +246,7 @@ function NodeRendererImpl( const { component: initialComponent, surface, + suppressActionDispatch = false, renderUnsupported, } = props; const { catalog: activeCatalog, processor } = useA2UIContext(); @@ -307,6 +312,7 @@ function NodeRendererImpl( (a: Record) => { void sendAction(a as unknown as Parameters[0]); }, + suppressActionDispatch, ) ); } diff --git a/packages/genui/server/agent/a2ui-prompt.ts b/packages/genui/server/agent/a2ui-prompt.ts index eca343472a..d98f9dea8b 100644 --- a/packages/genui/server/agent/a2ui-prompt.ts +++ b/packages/genui/server/agent/a2ui-prompt.ts @@ -50,6 +50,10 @@ and exactly ONE of the following keys: may reference data paths that will be populated by updateDataModel. - updateDataModel replaces the whole data model when "path" is omitted or "/". With a specific "path", it replaces only the value at that JSON Pointer. + Its fields MUST be nested inside "updateDataModel": + { "version": "v0.9", + "updateDataModel": { "surfaceId": "main", "path": "/", "value": {} } } + Never put "path" beside "updateDataModel" at the top level of the message. - deleteSurface removes a surface when the UI is no longer needed. ## Component model @@ -96,35 +100,59 @@ function buildHardRules(catalogId: string): string { 1. Output MUST be a JSON ARRAY of A2UI messages. No prose, no Markdown, no code fences, no XML. First character '[' – last character ']'. 2. Each element MUST include "version": "v0.9". -3. For a fresh non-action response, the first message MUST be createSurface with +3. Output pretty-printed JSON with 2-space indentation. Do NOT emit minified + single-line JSON. Put each message object and each component object on its + own lines so brackets and braces stay balanced. +4. Before finishing, check the final characters: every component object closes + once, every "components" array closes once, every message object closes + once, and the outer array closes exactly once. +5. For a fresh non-action response, the first message MUST be createSurface with catalogId = "${catalogId}". Use surfaceId "main" unless the user specifies otherwise. -4. For a fresh non-action response, the second message MUST be +6. For a fresh non-action response, the second message MUST be updateComponents; its components list MUST contain exactly one component with id "root". -5. Use property-based component discriminators: "component": "Text", not +7. Use property-based component discriminators: "component": "Text", not wrapper objects such as { "Text": {...} }. -6. Children are referenced by id only. NEVER inline a child component. -7. Container references MUST point to components present in the same response. -8. Card.child is exactly one id; wrap multiple elements in Row/Column/List. -9. Buttons MUST include a non-empty "action.event.name". Button has NO "label" +8. Children are referenced by id only. NEVER inline a child component. +9. Container references MUST point to components present in the same response. +10. Card.child is exactly one id; wrap multiple elements in Row/Column/List. +11. Buttons MUST include a non-empty "action.event.name". Button has NO "label" prop – provide the label via a child Text component ("child": ""). -10. Any "{path:...}" reference MUST be populated by some updateDataModel in the +12. When using Modal for a confirmation flow, do NOT put the server action on + the Modal trigger. The trigger only opens the modal. Put a separate confirm + Button inside Modal.content, and attach the action to that confirm Button. +13. Render a Modal by placing the Modal component itself where the trigger + should appear. Do NOT also list the trigger component as a sibling in the + parent container, because Modal renders its trigger internally. +14. The "weight" prop is a small layout ratio for Row/Column children, not CSS + font-weight. Do NOT use values like 400, 500, 600, or 700 for typography. + Use text variants for typography, and use small weights such as 1, 1.5, 2, + 3, or 5 only when balancing sibling layout. +15. Any "{path:...}" reference MUST be populated by some updateDataModel in the same response. -11. Ids are kebab-case, unique per surface ("root", "title-text", "submit-btn"). -12. Do not invent components outside the catalog. -13. No comments, trailing commas or unknown fields. -14. If the user asks for impossible, unsafe, or unsupported UI, render a concise +16. In an updateDataModel message, "path" MUST be inside "updateDataModel", + never at the top level. Correct: + { "version": "v0.9", "updateDataModel": { "surfaceId": "main", "path": "/", "value": {} } } + Wrong: + { "version": "v0.9", "updateDataModel": { "surfaceId": "main", "value": {} }, "path": "/" } +17. Ids are kebab-case, unique per surface ("root", "title-text", "submit-btn"). +18. Do not invent components outside the catalog. +19. No comments, trailing commas or unknown fields. +20. If the user asks for impossible, unsafe, or unsupported UI, render a concise explanatory A2UI surface using supported components rather than prose. -15. If the latest user message starts with "A2UI_USER_ACTION:", this is an +21. If the latest user message starts with "A2UI_USER_ACTION:", this is an action response for an existing surface. Return a non-empty JSON array with updateDataModel and/or updateComponents for that same surfaceId. Do NOT return [] and do NOT create a new surface unless the action explicitly asks to replace the whole UI. -16. For UI that should change after a button tap, keep the initial response in +22. For action responses, prefer the smallest valid patch: one updateDataModel + for changed data, plus one updateComponents only if the visible structure + needs to change. +23. For UI that should change after a button tap, keep the initial response in the pre-action state. Put confirmation, success, or result details in the action response instead of showing them before the action happens. -17. For Image.url, provide a short English image search query such as +24. For Image.url, provide a short English image search query such as "fresh pasta on a table" or "city skyline at night". Do NOT invent photo CDN URLs. The server resolves Image.url values through its image provider. `; 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..626e11a83e --- /dev/null +++ b/packages/genui/server/agent/a2ui-stream-parser.ts @@ -0,0 +1,422 @@ +// 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 stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + + if (isRecord(value)) { + const entries = Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`); + return `{${entries.join(',')}}`; + } + + return JSON.stringify(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 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); +} + +function isStreamRenderableComponent( + component: Record, +): boolean { + if (component.component !== 'Image') return true; + const url = component.url; + if (isRecord(url) && typeof url.path === 'string') return true; + return isLoadableImageSource(url); +} + +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, + options: { placeholders: boolean }, +): ComponentRecord[] { + const root = seen.get(ROOT_COMPONENT_ID); + if (!root) return [...seen.values()]; + + 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( + options.placeholders + ? replaceMissingChildRefs(component, seen, placeholders) + : component, + ); + } + if (options.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 + >(); + private yieldedComponentContentBySurface = new Map< + string, + Map + >(); + private createdSurfaceIds = new Set(); + + 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)) { + if ('createSurface' in parsed && parsed.createSurface) { + this.createdSurfaceIds.add(parsed.createSurface.surfaceId); + } + if (!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; + if (!isStreamRenderableComponent(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, { + placeholders: this.createdSurfaceIds.has(surfaceId), + }); + if (components.length === 0) return; + + const yielded = this.yieldedComponentContentBySurface.get(surfaceId) + ?? new Map(); + const changedComponents = components.filter((component) => { + const content = stableStringify(component); + return yielded.get(component.id) !== content; + }); + if (changedComponents.length === 0) return; + + for (const component of changedComponents) { + yielded.set(component.id, stableStringify(component)); + } + this.yieldedComponentContentBySurface.set(surfaceId, yielded); + + messages.push({ + version: 'v0.9', + updateComponents: { + surfaceId, + components: changedComponents, + }, + }); + } +} + +export function splitA2UIProtocolMessages( + messages: A2UIMessage[], +): A2UIMessage[] { + const parser = new A2UIProtocolMessageStreamParser(); + return parser.push(JSON.stringify(messages)); +} diff --git a/packages/genui/server/agent/a2ui-validator.ts b/packages/genui/server/agent/a2ui-validator.ts index 122df54a37..70f337dbec 100644 --- a/packages/genui/server/agent/a2ui-validator.ts +++ b/packages/genui/server/agent/a2ui-validator.ts @@ -111,6 +111,24 @@ export interface ValidationResult { export interface ValidationOptions { requireCreateSurface?: boolean; existingSurfaceIds?: string[]; + existingDataModelBySurface?: Record; +} + +export interface A2UIValidationDebugEntry { + error: string; + path: string; + value: unknown; +} + +export interface A2UIValidationDebugData { + parsedType: string; + entries: A2UIValidationDebugEntry[]; + rawText?: string; +} + +export interface A2UIValidationDebugOptions { + includeRaw?: boolean; + previewChars?: number; } function stripCodeFenceWrapper(text: string): string { @@ -210,6 +228,39 @@ export function extractJsonArray(text: string): unknown { return null; } +export function getA2UIValidationDebugData( + raw: string, + errors: string[], + options: A2UIValidationDebugOptions = {}, +): A2UIValidationDebugData { + const parsed = extractJsonArray(raw); + const parsedType = parsed === null + ? 'null' + : (Array.isArray(parsed) + ? 'array' + : typeof parsed); + const hasJsonParseError = errors.some((error) => + error.startsWith('Response was not valid JSON.') + ); + const rawText = options.includeRaw + ? raw + : (hasJsonParseError + ? previewText(raw, options.previewChars ?? 500) + : undefined); + return { + parsedType, + ...(rawText === undefined ? {} : { rawText }), + entries: errors.map((error) => { + const path = extractValidationErrorPath(error); + return { + error, + path, + value: valueAtPath(parsed, path), + }; + }), + }; +} + export function validateA2UIOutput( raw: string, catalog: A2UICatalog, @@ -279,6 +330,16 @@ export function validateA2UIOutput( const allPaths: { surfaceId: string; path: string }[] = []; const providedPaths: { surfaceId: string; path: string }[] = []; + for ( + const [surfaceId, dataModel] of Object.entries( + options.existingDataModelBySurface ?? {}, + ) + ) { + for (const path of flattenProvidedPaths('/', dataModel)) { + providedPaths.push({ surfaceId, path }); + } + } + for (const msg of messages) { if ('createSurface' in msg && msg.createSurface) { surfaces.add(msg.createSurface.surfaceId); @@ -299,6 +360,7 @@ export function validateA2UIOutput( componentSpecs.get(comp.component)!, errors, ); + validateRendererSemantics(comp, errors); } else { errors.push( `Unknown component "${comp.component}" (id=${comp.id}). Allowed: ${ @@ -496,6 +558,35 @@ function flattenProvidedPaths(basePath: string, value: unknown): string[] { return paths.length > 0 ? paths : [normalized]; } +function extractValidationErrorPath(error: string): string { + const match = /^Schema violation at ([^:]+):/u.exec(error) + ?? /^Prop ([^ ]+) /u.exec(error); + return match?.[1] ?? ''; +} + +function valueAtPath(value: unknown, path: string): unknown { + if (path === '' || path === '') return value; + let current = value; + for (const segment of path.match(/[^.[\]]+/gu) ?? []) { + if (Array.isArray(current)) { + const index = Number(segment); + if (!Number.isInteger(index)) return undefined; + current = current[index]; + continue; + } + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +function previewText(raw: string, maxChars: number): string { + if (raw.length <= maxChars) return raw; + return `${raw.slice(0, maxChars)}... [truncated ${ + raw.length - maxChars + } chars]`; +} + function validateComponentAgainstCatalog( comp: A2UIComponent, spec: A2UIComponentSpec, @@ -535,6 +626,25 @@ function validateComponentAgainstCatalog( } } +function validateRendererSemantics( + comp: A2UIComponent, + errors: string[], +): void { + const weight = (comp as { weight?: unknown }).weight; + if (typeof weight !== 'number') return; + if (!Number.isFinite(weight) || weight <= 0) { + errors.push( + `Component "${comp.id}" (${comp.component}) has invalid weight "${weight}". Use a positive finite layout ratio.`, + ); + return; + } + if (weight > 12) { + errors.push( + `Component "${comp.id}" (${comp.component}) has weight "${weight}", but weight is a small Row/Column layout ratio, not CSS font-weight. Use values like 1, 1.5, 2, 3, or 5.`, + ); + } +} + function validateValueAgainstSchema( value: unknown, schema: JsonSchema, 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..0879d56d5d 100644 --- a/packages/genui/server/app/a2ui/action/stream/route.ts +++ b/packages/genui/server/app/a2ui/action/stream/route.ts @@ -4,7 +4,14 @@ import type { A2UICatalog } from '../../../../agent/a2ui-catalog'; import { BASIC_CATALOG } from '../../../../agent/a2ui-catalog'; -import { validateA2UIOutput } from '../../../../agent/a2ui-validator'; +import { + A2UIProtocolMessageStreamParser, + splitA2UIProtocolMessages, +} from '../../../../agent/a2ui-stream-parser'; +import { + getA2UIValidationDebugData, + validateA2UIOutput, +} from '../../../../agent/a2ui-validator'; import { resolveA2UIImageUrls } from '../../../../agent/image-resolver'; import { getA2UIAgentService } from '../../../../service/a2ui-agent'; import type { ChatMessage } from '../../../../service/a2ui-agent'; @@ -21,6 +28,28 @@ import { checkRateLimit, rateLimitSseResponse } from '../../rate-limit'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +function createStreamLogger(route: string) { + const requestId = crypto.randomUUID(); + const startedAt = Date.now(); + const log = (event: string, details: Record = {}) => { + console.info('[a2ui:stream]'); + console.dir({ + route, + requestId, + event, + elapsedMs: Date.now() - startedAt, + ...details, + }, { + breakLength: 120, + depth: null, + maxArrayLength: null, + maxStringLength: 20000, + }); + }; + + return { log, requestId }; +} + interface A2UIActionStreamBody { conversation?: unknown; surfaceId?: string; @@ -121,6 +150,22 @@ export async function POST(req: Request) { }; const opts = pickChatOptions(body); + const { log, requestId } = createStreamLogger('/a2ui/action/stream'); + + log('request.accepted', { + surfaceId: body.surfaceId, + actionName: body.action.name, + conversationHistoryCount: validatedConversation.conversation?.history.length + ?? 0, + dataModelKeyCount: validatedConversation.conversation + ? Object.keys(validatedConversation.conversation.dataModel).length + : 0, + userContentLength: userContent.length, + model: opts.model, + hasBaseURL: Boolean(opts.baseURL), + catalogId: opts.catalog?.id ?? BASIC_CATALOG.id, + maxRepairAttempts: opts.maxRepairAttempts, + }); const stream = new ReadableStream({ async start(controller) { @@ -134,12 +179,43 @@ export async function POST(req: Request) { opts, validatedConversation.conversation, ); + const protocolParser = new A2UIProtocolMessageStreamParser(); + const streamedMessages: unknown[] = []; + let streamedText = ''; + let chunkCount = 0; + + log('upstream.stream.started'); for await (const chunk of textStream) { + chunkCount += 1; + streamedText += chunk; enqueue('delta', { text: chunk }); + const newMessages = protocolParser.push(chunk); + if (newMessages.length > 0) { + streamedMessages.push(...newMessages); + enqueue('message', { messages: newMessages }); + log('protocol.messages', { + chunkCount, + newMessageCount: newMessages.length, + streamedMessageCount: streamedMessages.length, + streamedTextLength: streamedText.length, + }); + } } + log('upstream.stream.ended', { + chunkCount, + streamedTextLength: streamedText.length, + streamedMessageCount: streamedMessages.length, + }); + let { text: finalText, usage, finishReason } = await finalize(); + finalText ??= streamedText; + log('upstream.finalized', { + finalTextLength: finalText?.length ?? 0, + finishReason, + hasUsage: usage !== undefined, + }); let repair: | { attempted: true; @@ -159,6 +235,12 @@ export async function POST(req: Request) { const validationOptions = { requireCreateSurface: false, existingSurfaceIds: body.surfaceId ? [body.surfaceId] : [], + existingDataModelBySurface: body.surfaceId + ? { + [body.surfaceId]: validatedConversation.conversation?.dataModel + ?? {}, + } + : {}, }; const v = validateA2UIOutput( finalText ?? '', @@ -166,8 +248,17 @@ export async function POST(req: Request) { validationOptions, ); let resolvedMessages = v.ok - ? await resolveA2UIImageUrls(v.messages) + ? splitA2UIProtocolMessages(await resolveA2UIImageUrls(v.messages)) : []; + log('validation.completed', { + ok: v.ok, + errorCount: v.errors.length, + errors: v.errors, + invalidData: v.ok + ? undefined + : getA2UIValidationDebugData(finalText ?? '', v.errors), + resolvedMessageCount: resolvedMessages.length, + }); validation = { ok: v.ok, errors: v.errors, @@ -175,6 +266,9 @@ export async function POST(req: Request) { }; if (!v.ok) { try { + log('repair.started', { + sourceErrors: v.errors, + }); const repaired = await service.generateValidated( [userMessage], opts, @@ -188,12 +282,20 @@ export async function POST(req: Request) { attempts: repaired.attempts, }; enqueue('repair', repair); + log('repair.completed', { + ok: repaired.ok, + attempts: repaired.attempts, + errorCount: repaired.errors.length, + errors: repaired.errors, + textLength: repaired.text.length, + messageCount: repaired.messages.length, + }); if (repaired.ok) { finalText = repaired.text; usage = repaired.usage; finishReason = repaired.finishReason; - resolvedMessages = await resolveA2UIImageUrls( - repaired.messages, + resolvedMessages = splitA2UIProtocolMessages( + await resolveA2UIImageUrls(repaired.messages), ); validation = { ok: true, @@ -208,17 +310,34 @@ 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); + log('repair.error', { + error: repairError, + }); } } + log('done.enqueued', { + validationOk: validation.ok, + validationErrorCount: validation.errors.length, + messageCount: validation.messages.length, + repairAttempted: repair?.attempted ?? false, + repairOk: repair?.ok, + requestId, + }); enqueue('done', { text: finalText, usage, @@ -227,8 +346,11 @@ export async function POST(req: Request) { repair, }); } catch (err: unknown) { - enqueue('error', errorMessage(err)); + const error = errorMessage(err); + log('error.enqueued', error); + enqueue('error', error); } finally { + log('stream.closed'); controller.close(); } }, diff --git a/packages/genui/server/app/a2ui/stream/route.ts b/packages/genui/server/app/a2ui/stream/route.ts index 65173f934b..27ee424dee 100644 --- a/packages/genui/server/app/a2ui/stream/route.ts +++ b/packages/genui/server/app/a2ui/stream/route.ts @@ -3,7 +3,14 @@ // LICENSE file in the root directory of this source tree. import { BASIC_CATALOG } from '../../../agent/a2ui-catalog'; -import { validateA2UIOutput } from '../../../agent/a2ui-validator'; +import { + A2UIProtocolMessageStreamParser, + splitA2UIProtocolMessages, +} from '../../../agent/a2ui-stream-parser'; +import { + getA2UIValidationDebugData, + validateA2UIOutput, +} from '../../../agent/a2ui-validator'; import { resolveA2UIImageUrls } from '../../../agent/image-resolver'; import { getA2UIAgentService } from '../../../service/a2ui-agent'; import { @@ -20,6 +27,28 @@ import { checkRateLimit, rateLimitSseResponse } from '../rate-limit'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; +function createStreamLogger(route: string) { + const requestId = crypto.randomUUID(); + const startedAt = Date.now(); + const log = (event: string, details: Record = {}) => { + console.info('[a2ui:stream]'); + console.dir({ + route, + requestId, + event, + elapsedMs: Date.now() - startedAt, + ...details, + }, { + breakLength: 120, + depth: null, + maxArrayLength: null, + maxStringLength: 20000, + }); + }; + + return { log, requestId }; +} + function encodeSSE(event: string, data: unknown): Uint8Array { const payload = typeof data === 'string' ? data : JSON.stringify(data); return new TextEncoder().encode(`event: ${event}\ndata: ${payload}\n\n`); @@ -73,6 +102,20 @@ export async function POST(req: Request) { } const opts = pickChatOptions(body); const service = getA2UIAgentService(); + const { log, requestId } = createStreamLogger('/a2ui/stream'); + + log('request.accepted', { + messageCount: messages.length, + conversationHistoryCount: validatedConversation.conversation?.history.length + ?? 0, + dataModelKeyCount: validatedConversation.conversation + ? Object.keys(validatedConversation.conversation.dataModel).length + : 0, + model: opts.model, + hasBaseURL: Boolean(opts.baseURL), + catalogId: opts.catalog?.id ?? BASIC_CATALOG.id, + maxRepairAttempts: opts.maxRepairAttempts, + }); const stream = new ReadableStream({ async start(controller) { @@ -86,12 +129,43 @@ export async function POST(req: Request) { opts, validatedConversation.conversation, ); + const protocolParser = new A2UIProtocolMessageStreamParser(); + const streamedMessages: unknown[] = []; + let streamedText = ''; + let chunkCount = 0; + + log('upstream.stream.started'); for await (const chunk of textStream) { + chunkCount += 1; + streamedText += chunk; enqueue('delta', { text: chunk }); + const newMessages = protocolParser.push(chunk); + if (newMessages.length > 0) { + streamedMessages.push(...newMessages); + enqueue('message', { messages: newMessages }); + log('protocol.messages', { + chunkCount, + newMessageCount: newMessages.length, + streamedMessageCount: streamedMessages.length, + streamedTextLength: streamedText.length, + }); + } } + log('upstream.stream.ended', { + chunkCount, + streamedTextLength: streamedText.length, + streamedMessageCount: streamedMessages.length, + }); + let { text: finalText, usage, finishReason } = await finalize(); + finalText ??= streamedText; + log('upstream.finalized', { + finalTextLength: finalText?.length ?? 0, + finishReason, + hasUsage: usage !== undefined, + }); let repair: | { attempted: true; @@ -113,8 +187,17 @@ export async function POST(req: Request) { opts.catalog ?? BASIC_CATALOG, ); let resolvedMessages = v.ok - ? await resolveA2UIImageUrls(v.messages) + ? splitA2UIProtocolMessages(await resolveA2UIImageUrls(v.messages)) : []; + log('validation.completed', { + ok: v.ok, + errorCount: v.errors.length, + errors: v.errors, + invalidData: v.ok + ? undefined + : getA2UIValidationDebugData(finalText ?? '', v.errors), + resolvedMessageCount: resolvedMessages.length, + }); validation = { ok: v.ok, errors: v.errors, @@ -122,6 +205,9 @@ export async function POST(req: Request) { }; if (!v.ok) { try { + log('repair.started', { + sourceErrors: v.errors, + }); const repaired = await service.generateValidated( messages, opts, @@ -134,12 +220,20 @@ export async function POST(req: Request) { attempts: repaired.attempts, }; enqueue('repair', repair); + log('repair.completed', { + ok: repaired.ok, + attempts: repaired.attempts, + errorCount: repaired.errors.length, + errors: repaired.errors, + textLength: repaired.text.length, + messageCount: repaired.messages.length, + }); if (repaired.ok) { finalText = repaired.text; usage = repaired.usage; finishReason = repaired.finishReason; - resolvedMessages = await resolveA2UIImageUrls( - repaired.messages, + resolvedMessages = splitA2UIProtocolMessages( + await resolveA2UIImageUrls(repaired.messages), ); validation = { ok: true, @@ -154,17 +248,34 @@ 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); + log('repair.error', { + error: repairError, + }); } } + log('done.enqueued', { + validationOk: validation.ok, + validationErrorCount: validation.errors.length, + messageCount: validation.messages.length, + repairAttempted: repair?.attempted ?? false, + repairOk: repair?.ok, + requestId, + }); enqueue('done', { text: finalText, usage, @@ -173,8 +284,11 @@ export async function POST(req: Request) { repair, }); } catch (err: unknown) { - enqueue('error', errorMessage(err)); + const error = errorMessage(err); + log('error.enqueued', error); + enqueue('error', error); } finally { + log('stream.closed'); controller.close(); } },