diff --git a/packages/genui/a2ui-playground/examples/README.md b/packages/genui/a2ui-playground/examples/README.md new file mode 100644 index 0000000000..7953b1076a --- /dev/null +++ b/packages/genui/a2ui-playground/examples/README.md @@ -0,0 +1,70 @@ +# A2UI playground examples + +Reference implementations that intentionally live **outside** +`@lynx-js/a2ui-reactlynx`. The package itself ships only: + +- `` — the protocol-naive renderer. +- `MessageStore` — a pure raw-message buffer. +- The catalog + custom-component-author API. + +Everything else — talking to an agent, chunking turns, theming the chat +shell — is the developer's choice. These examples show common shapes; +copy and adapt them. + +## `io-mock/` + +`createMockAgent(store, opts)` returns a driver that pushes a fixed +initial stream into the store and serves canned responses to user +actions. Used by the playground's `lynx-src/App.tsx` to exercise demos +without a real agent. + +```ts +const store = createMessageStore(); +const agent = createMockAgent(store, { initialMessages, actionMocks }); +agent.start(); // streams initial messages into the buffer +agent.onAction(action); // pushes the canned response to a user action +``` + +## `io-sse/` + +`createSseAgent(store, { url })` opens an SSE connection and pushes the +parsed `delta` / `complete` events into the store. Roughly the +implementation that used to live inside the package's `BaseClient`, +re-targeted at the dumb-buffer store. + +```ts +const store = createMessageStore(); +const agent = createSseAgent(store, { url: '/api/agent' }); +await agent.send('hello'); // streams response into the buffer +await agent.onAction(action); // forwards a user action over SSE +``` + +## Multi-turn chat shell pattern + +For chat UIs, give each turn (user prompt + agent response) its own +`MessageStore` and render one `` per +agent turn. The shell only tracks turns; the renderer handles +everything inside an agent turn. + +```tsx +function Conversation({ catalogs, respond }) { + const [turns, setTurns] = useState([]); + const send = async (input) => { + const store = createMessageStore(); + setTurns((t) => [ + ...t, + { kind: 'user', content: input }, + { kind: 'agent', store }, + ]); + await respond(input, store); + }; + return turns.map((t) => + t.kind === 'user' + ? {t.content} + : + ); +} +``` + +Each `` only sees a bounded buffer; history is just a list of +turns the shell maintains. diff --git a/packages/genui/a2ui-playground/examples/io-mock/mockAgent.ts b/packages/genui/a2ui-playground/examples/io-mock/mockAgent.ts new file mode 100644 index 0000000000..454a43352e --- /dev/null +++ b/packages/genui/a2ui-playground/examples/io-mock/mockAgent.ts @@ -0,0 +1,96 @@ +// 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. +// +// Reference mock IO module. Pushes a fixed initial stream into the store +// and serves canned responses to user actions. NOT shipped from +// `@lynx-js/a2ui-reactlynx` — copy as a starting point for tests / demos. +import type { + MessageStore, + ServerToClientMessage, + UserActionPayload, +} from '@lynx-js/a2ui-reactlynx'; + +export interface MockAgentOptions { + /** Streamed once after `start()`. */ + initialMessages?: readonly ServerToClientMessage[]; + /** Per-action response messages, keyed by action name. */ + actionMocks?: Record< + string, + | readonly ServerToClientMessage[] + | ((ctx: UserActionPayload) => readonly ServerToClientMessage[]) + >; + /** Delay between successive batches when streaming. */ + delayMs?: number; +} + +export interface MockAgent { + /** + * Begin streaming the initial messages. Idempotent — calling twice + * returns the original promise. + */ + start(): Promise; + /** Forward a user action; pushes the canned response, if any. */ + onAction(action: UserActionPayload): Promise; + /** Stop streaming and discard any pending messages. */ + stop(): void; +} + +/** + * Build a mock agent driver bound to a `MessageStore`. The driver + * streams raw protocol messages into the store with a small delay + * between each, simulating an SSE-like server. + */ +export function createMockAgent( + store: MessageStore, + options: MockAgentOptions = {}, +): MockAgent { + const { initialMessages, actionMocks = {}, delayMs = 800 } = options; + const abort = new AbortController(); + let started: Promise | null = null; + + function sleep(ms: number): Promise { + if (ms <= 0 || abort.signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + abort.signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + abort.signal.addEventListener('abort', onAbort, { once: true }); + }); + } + + async function streamInto( + messages: readonly ServerToClientMessage[], + ): Promise { + for (const msg of messages) { + if (abort.signal.aborted) return; + store.push(msg); + if (delayMs > 0) { + await sleep(delayMs); + if (abort.signal.aborted) return; + } + } + } + + return { + start() { + if (started) return started; + started = streamInto(initialMessages ?? []); + return started; + }, + async onAction(action) { + const mock = actionMocks[action.name]; + if (!mock) return; + const stream = typeof mock === 'function' ? mock(action) : mock; + await streamInto(stream); + }, + stop() { + abort.abort(); + }, + }; +} diff --git a/packages/genui/a2ui-playground/examples/io-sse/sseAgent.ts b/packages/genui/a2ui-playground/examples/io-sse/sseAgent.ts new file mode 100644 index 0000000000..802cf3e840 --- /dev/null +++ b/packages/genui/a2ui-playground/examples/io-sse/sseAgent.ts @@ -0,0 +1,231 @@ +// 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. +// +// Reference SSE IO module. Opens an EventSource, parses `delta` / +// `complete` events, normalizes their payloads, and pushes the resulting +// raw protocol messages into the store. NOT shipped from +// `@lynx-js/a2ui-reactlynx` — copy and adapt the URL building, event +// names, and queueing for your agent. +import type { + A2UIClientEventMessage, + MessageStore, + ServerToClientMessage, + UserActionPayload, +} from '@lynx-js/a2ui-reactlynx'; +import { normalizePayloadToMessages } from '@lynx-js/a2ui-reactlynx'; + +const MESSAGE_PROCESS_DELAY = 300; + +interface TypedEventSource { + addEventListener( + type: string, + listener: ( + event: { data?: unknown; target?: unknown; type?: string }, + ) => void, + ): void; + close(): void; + readyState: number; +} + +declare const lynx: { + EventSource?: new(url: string) => TypedEventSource; +} | undefined; + +function buildSseParams( + message: A2UIClientEventMessage, + messageId: string, +): Record { + const params: Record = { messageId }; + const anyMessage = message as Record; + + if (typeof message === 'string') { + params.text = message; + } else if (anyMessage) { + if (typeof anyMessage.text === 'string') { + params.text = anyMessage.text; + } else if (anyMessage.text) { + params.text = JSON.stringify(anyMessage.text); + } else if (anyMessage.userAction) { + const userAction = anyMessage.userAction as { + name: string; + context?: Record; + }; + const actionName = userAction.name || 'unknownAction'; + const context = userAction.context ?? {}; + params.text = `USER_ACTION: ${actionName}, Context: ${ + JSON.stringify(context) + }`; + } else { + params.text = JSON.stringify(message); + } + + if (typeof anyMessage.sessionId === 'string') { + params.sessionId = anyMessage.sessionId; + } else if (anyMessage.sessionId) { + params.sessionId = JSON.stringify(anyMessage.sessionId); + } + } + + return params; +} + +function randomId(prefix: string) { + return prefix + Date.now().toString(36) + + Math.random().toString(36).slice(2, 10); +} + +function toError(e: unknown): Error { + return e instanceof Error ? e : new Error(String(e)); +} + +export interface SseAgentOptions { + url: string; +} + +export interface SseAgent { + /** + * Send an input to the agent and stream the response messages into the + * store. Returns when the SSE connection emits its `complete` event. + */ + send(input: A2UIClientEventMessage | string): Promise; + /** + * Forward a user action — convenience over `send({ userAction })`. + */ + onAction(action: UserActionPayload): Promise; + /** Abort any open connections. */ + stop(): void; +} + +export function createSseAgent( + store: MessageStore, + options: SseAgentOptions, +): SseAgent { + const { url: baseUrl } = options; + const abort = new AbortController(); + + const send = (input: A2UIClientEventMessage | string): Promise => + new Promise((resolve, reject) => { + if (abort.signal.aborted) { + resolve(); + return; + } + const messageId = randomId('task_'); + const params = new URLSearchParams(buildSseParams(input, messageId)); + const url = `${baseUrl}?${params.toString()}`; + + const g = globalThis as Record; + // eslint-disable-next-line n/no-unsupported-features/node-builtins + const NativeES = g.EventSource as + | (new(url: string) => TypedEventSource) + | undefined; + const EventSourceImpl = NativeES + ?? (typeof lynx !== 'undefined' && lynx?.EventSource); + if (!EventSourceImpl) { + reject(new Error('No EventSource implementation available.')); + return; + } + + const eventSource = new EventSourceImpl(url); + let settled = false; + const queue: ServerToClientMessage[][] = []; + + const cleanup = () => { + eventSource.close(); + abort.signal.removeEventListener('abort', onAbort); + queue.length = 0; + }; + const succeed = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const fail = (e: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(toError(e)); + }; + const onAbort = () => succeed(); + abort.signal.addEventListener('abort', onAbort, { once: true }); + + let isCompleted = false; + let hasReceivedDelta = false; + let isProcessing = false; + + const flush = async () => { + if (isProcessing) return; + isProcessing = true; + while (queue.length > 0 && !settled) { + const batch = queue.shift(); + if (batch && batch.length > 0) { + for (const msg of batch) { + msg.messageId ??= messageId; + store.push(msg); + } + } + await new Promise((r) => setTimeout(r, MESSAGE_PROCESS_DELAY)); + } + isProcessing = false; + }; + + const ingestPayload = (raw: unknown) => { + let payload = raw; + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + /* leave as string */ + } + } + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + /* leave as string */ + } + } + const messages = normalizePayloadToMessages(payload); + if (messages.length > 0) { + queue.push(messages); + void flush(); + } + }; + + eventSource.addEventListener('delta', (event) => { + try { + ingestPayload(event.data); + hasReceivedDelta = true; + } catch (e) { + fail(e); + } + }); + + eventSource.addEventListener('complete', (event) => { + if (isCompleted) return; + isCompleted = true; + try { + if (!hasReceivedDelta) ingestPayload(event.data); + } catch (e) { + fail(e); + return; + } + succeed(); + }); + + eventSource.addEventListener('error', (event) => { + fail(new Error(`SSE error: ${JSON.stringify(event)}`)); + }); + }); + + return { + send, + async onAction(action) { + await send({ userAction: action }); + }, + stop() { + abort.abort(); + }, + }; +} diff --git a/packages/genui/a2ui-playground/lynx-src/App.tsx b/packages/genui/a2ui-playground/lynx-src/App.tsx index d8a0bc3400..af29d4eced 100644 --- a/packages/genui/a2ui-playground/lynx-src/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/App.tsx @@ -1,9 +1,36 @@ // 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 { A2UIRender, BaseClient } from '@lynx-js/a2ui-reactlynx/core'; -import type { Resource } from '@lynx-js/a2ui-reactlynx/core'; -import '@lynx-js/a2ui-reactlynx/catalog/all'; +import { + A2UI, + Button, + Card, + CheckBox, + Column, + Divider, + Image, + List, + RadioGroup, + Row, + Text, + createMessageStore, +} from '@lynx-js/a2ui-reactlynx'; +import type { + CatalogInput, + MessageStore, + ServerToClientMessage, + UserActionPayload, +} from '@lynx-js/a2ui-reactlynx'; +import buttonManifest from '@lynx-js/a2ui-reactlynx/catalog/Button/catalog.json'; +import cardManifest from '@lynx-js/a2ui-reactlynx/catalog/Card/catalog.json'; +import checkBoxManifest from '@lynx-js/a2ui-reactlynx/catalog/CheckBox/catalog.json'; +import columnManifest from '@lynx-js/a2ui-reactlynx/catalog/Column/catalog.json'; +import dividerManifest from '@lynx-js/a2ui-reactlynx/catalog/Divider/catalog.json'; +import imageManifest from '@lynx-js/a2ui-reactlynx/catalog/Image/catalog.json'; +import listManifest from '@lynx-js/a2ui-reactlynx/catalog/List/catalog.json'; +import radioGroupManifest from '@lynx-js/a2ui-reactlynx/catalog/RadioGroup/catalog.json'; +import rowManifest from '@lynx-js/a2ui-reactlynx/catalog/Row/catalog.json'; +import textManifest from '@lynx-js/a2ui-reactlynx/catalog/Text/catalog.json'; import { useEffect, useGlobalProps, @@ -13,6 +40,29 @@ import { useState, } from '@lynx-js/react'; +import { createMockAgent } from '../examples/io-mock/mockAgent.js'; + +// Compose every built-in. There is intentionally no all-in-one aggregate +// shipped from the package — this list makes the cost of "everything" +// visible and lets the bundler tree-shake when you only need a few. +// +// Schemas are not attached because the playground doesn't perform an +// agent handshake. To include schemas, pair each component with its +// `catalog.json` manifest — see +// `packages/genui/a2ui/src/catalog/README.md`. +const ALL_BUILTINS: readonly CatalogInput[] = [ + [Text, textManifest], + [Image, imageManifest], + [Row, rowManifest], + [Column, columnManifest], + [List, listManifest], + [Card, cardManifest], + [Button, buttonManifest], + [Divider, dividerManifest], + [CheckBox, checkBoxManifest], + [RadioGroup, radioGroupManifest], +]; + interface InitData { messagesUrl?: string; messages?: unknown; @@ -21,17 +71,12 @@ interface InitData { } type A2uiMessage = Record & { messageId?: string }; - -type ActionMocks = Record; - type ResponseMessages = A2uiMessage[]; - -const DEFAULT_STREAM_DELAY_MS = 800; - -function randomId(prefix: string) { - return prefix + Date.now().toString(36) - + Math.random().toString(36).slice(2, 10); -} +type ActionMocks = Record< + string, + | ServerToClientMessage[] + | ((ctx: UserActionPayload) => ServerToClientMessage[]) +>; function parseJsonLikeString(input: string): unknown { try { @@ -40,7 +85,8 @@ function parseJsonLikeString(input: string): unknown { // ignore } - // Query params may arrive URL-encoded one or more times in native globalProps. + // Query params may arrive URL-encoded one or more times in native + // globalProps. let current = input; for (let i = 0; i < 3; i++) { try { @@ -62,7 +108,6 @@ function parseJsonLikeString(input: string): unknown { function normalizeInitDataLike(raw: unknown): InitData { if (raw === null || raw === undefined) return {}; - if (typeof raw !== 'object') return {}; const obj = raw as Record; @@ -101,14 +146,8 @@ function mergeInitDataPreferLeft(a: InitData, b: InitData): InitData { } function normalizePayloadToMessages(payload: unknown): ResponseMessages { - if (payload === null || payload === undefined) { - return []; - } - - if (Array.isArray(payload)) { - return payload as ResponseMessages; - } - + if (payload === null || payload === undefined) return []; + if (Array.isArray(payload)) return payload as ResponseMessages; if (typeof payload === 'string') { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -118,14 +157,12 @@ function normalizePayloadToMessages(payload: unknown): ResponseMessages { return []; } } - if ( typeof payload === 'object' && Array.isArray((payload as Record).messages) ) { return (payload as Record).messages as ResponseMessages; } - return []; } @@ -140,15 +177,15 @@ async function loadMessages(initData: InitData): Promise { return normalizePayloadToMessages(text); } } - if (initData.messages !== undefined) { return normalizePayloadToMessages(initData.messages); } - return []; } -async function loadActionMocks(initData: InitData): Promise { +async function loadActionMocks( + initData: InitData, +): Promise> { if (initData.actionMocksUrl) { // eslint-disable-next-line n/no-unsupported-features/node-builtins const res = await fetch(initData.actionMocksUrl, { cache: 'no-store' }); @@ -157,23 +194,22 @@ async function loadActionMocks(initData: InitData): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(text); if (parsed && typeof parsed === 'object') { - return parsed as ActionMocks; + return parsed as Record; } return {}; } catch { return {}; } } - if (initData.actionMocks && typeof initData.actionMocks === 'object') { - return initData.actionMocks as ActionMocks; + return initData.actionMocks as Record; } - return {}; } export function App() { const globalProps = useGlobalProps(); + const rawInitData = useInitData(); const initData = useMemo(() => { @@ -192,132 +228,69 @@ export function App() { [globalProps], ); - // Native in-app preview passes A2UI payload via `globalProps` (often from URL query). - // Web preview may still provide `initData`, so keep fallback for compatibility. + // Native in-app preview passes A2UI payload via `globalProps` (often + // from URL query). Web preview may still provide `initData`, so keep the + // fallback for compatibility. const effectiveData = useMemo( () => mergeInitDataPreferLeft(globalPropsData, initData), [globalPropsData, initData], ); - // Speed multiplier from URL query (e.g. ?speed=2 → 2x faster). - const streamDelay = useMemo(() => { - const raw = (globalProps as Record | null)?.speed - ?? (rawInitData as Record | null)?.speed; - const speed = typeof raw === 'string' - ? Number(raw) - : (typeof raw === 'number' ? raw : 1); - if (!speed || speed <= 0) return DEFAULT_STREAM_DELAY_MS; - return DEFAULT_STREAM_DELAY_MS / speed; - }, [globalProps, rawInitData]); - - // biome-ignore lint/suspicious/noExplicitAny: - const clientRef = useRef(null); - - const [resource, setResource] = useState(null); + const storeRef = useRef(null); + const agentRef = useRef | null>(null); + const [store, setStore] = useState(null); const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call useEffect(() => { let cancelled = false; const run = async () => { - setLoading(true); setError(''); - const [rawMessages, actionMocks] = await Promise.all([ + const [rawMessages, rawActionMocks] = await Promise.all([ loadMessages(effectiveData ?? {}), loadActionMocks(effectiveData ?? {}), ]); - const messageId = randomId('demo_'); - const messages = rawMessages.map((msg) => ({ - ...msg, - messageId: messageId, - })); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const client = clientRef.current ?? new BaseClient(''); - - clientRef.current ??= client; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - client.processUserAction = async ( - userAction: Record, - ) => { - const name = userAction?.name as string | undefined; - if (!name || !actionMocks[name]) { - return []; - } - - const rawResponseMessages = normalizePayloadToMessages( - actionMocks[name], - ); - const actionMessageId = randomId('action_'); - const responseMessages = rawResponseMessages.map((msg) => ({ - ...msg, - messageId: actionMessageId, - })); - - void (async () => { - for (const msg of responseMessages) { - if (cancelled) break; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.processor?.processMessages?.([msg]); - await new Promise((resolve) => setTimeout(resolve, streamDelay)); - } - })(); - - return responseMessages; - }; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.processor?.clearSurfaces?.(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.resources?.clear?.(); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - const { resource: newResource } = await client.send( - '' as unknown, - messageId, - ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.resources?.set?.(messageId, newResource); - - if (!cancelled) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - setResource(newResource); + const initialMessages = rawMessages as ServerToClientMessage[]; + const actionMocks: ActionMocks = {}; + for (const [name, value] of Object.entries(rawActionMocks)) { + actionMocks[name] = normalizePayloadToMessages( + value, + ) as ServerToClientMessage[]; } - const simulateStream = async () => { - for (const msg of messages) { - if (cancelled) break; - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - client.processor?.processMessages?.([msg]); - await new Promise((resolve) => setTimeout(resolve, streamDelay)); - } - }; + const next = createMessageStore(); + const agent = createMockAgent(next, { + initialMessages, + actionMocks, + delayMs: 800, + }); - void simulateStream(); + // Begin streaming the demo's initial messages into the buffer. + void agent.start(); + + if (cancelled) { + agent.stop(); + return; + } + agentRef.current?.stop(); + storeRef.current = next; + agentRef.current = agent; + setStore(next); }; - run() - .catch((e) => { - if (!cancelled) { - setError(String(e)); - setResource(null); - } - }) - .finally(() => { - if (!cancelled) { - setLoading(false); - } - }); + run().catch((e) => { + if (!cancelled) setError(String(e)); + }); return () => { cancelled = true; + agentRef.current?.stop(); + storeRef.current = null; + agentRef.current = null; }; - }, [effectiveData, streamDelay]); + }, [effectiveData]); return ( ) : null} - - {loading - ? ( - - Loading... - - ) - : null} - - {resource + {store ? ( - + { + // Forward user actions to the mock agent — it pushes the + // canned response messages back into the same store. + void agentRef.current?.onAction(action); + }} + wrapSurface={(c) => {c}} + renderEmpty={() => ( + + Loading... + + )} + renderFallback={() => ( + + Streaming... + + )} + /> ) - : null} + : ( + + Loading... + + )} ); } diff --git a/packages/genui/a2ui-playground/lynx-src/tsconfig.json b/packages/genui/a2ui-playground/lynx-src/tsconfig.json index 7d8eed26ef..491b46eca4 100644 --- a/packages/genui/a2ui-playground/lynx-src/tsconfig.json +++ b/packages/genui/a2ui-playground/lynx-src/tsconfig.json @@ -6,7 +6,13 @@ "jsxImportSource": "@lynx-js/react", "module": "ESNext", "moduleResolution": "Bundler", + "resolveJsonModule": true, "noEmit": true, }, - "include": ["./**/*.ts", "./**/*.tsx"], + "include": [ + "./**/*.ts", + "./**/*.tsx", + "../examples/**/*.ts", + "../examples/**/*.tsx", + ], } diff --git a/packages/genui/a2ui-playground/package.json b/packages/genui/a2ui-playground/package.json index 1045883264..584e995056 100644 --- a/packages/genui/a2ui-playground/package.json +++ b/packages/genui/a2ui-playground/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "rsbuild build", "build:lynx": "rspeedy build", - "dev": "pnpm run build:lynx && rsbuild dev", + "dev": "rsbuild dev", "dev:lynx": "rspeedy dev", "preview": "rsbuild preview", "preview:lynx": "rspeedy preview" diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index baf148a10b..1d0dcdcfb7 100644 --- a/packages/genui/a2ui/package.json +++ b/packages/genui/a2ui/package.json @@ -3,97 +3,83 @@ "version": "0.0.0", "private": true, "license": "Apache-2.0", + "sideEffects": [ + "**/*.css" + ], "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./core": { - "types": "./dist/core/index.d.ts", - "default": "./dist/core/index.js" + "./store": { + "types": "./dist/store/index.d.ts", + "default": "./dist/store/index.js" }, - "./chat": { - "types": "./dist/chat/index.d.ts", - "default": "./dist/chat/index.js" - }, - "./catalog/all": { - "types": "./dist/catalog/all.d.ts", - "default": "./dist/catalog/all.js" + "./catalog": { + "types": "./dist/catalog/index.d.ts", + "default": "./dist/catalog/index.js" }, "./catalog/Text": { - "types": "./dist/catalog/Text.d.ts", - "default": "./dist/catalog/Text.js" + "types": "./dist/catalog/Text/index.d.ts", + "default": "./dist/catalog/Text/index.js" }, + "./catalog/Text/catalog.json": "./dist/catalog/Text/catalog.json", "./catalog/Image": { - "types": "./dist/catalog/Image.d.ts", - "default": "./dist/catalog/Image.js" + "types": "./dist/catalog/Image/index.d.ts", + "default": "./dist/catalog/Image/index.js" }, + "./catalog/Image/catalog.json": "./dist/catalog/Image/catalog.json", "./catalog/Button": { - "types": "./dist/catalog/Button.d.ts", - "default": "./dist/catalog/Button.js" + "types": "./dist/catalog/Button/index.d.ts", + "default": "./dist/catalog/Button/index.js" }, + "./catalog/Button/catalog.json": "./dist/catalog/Button/catalog.json", "./catalog/Row": { - "types": "./dist/catalog/Row.d.ts", - "default": "./dist/catalog/Row.js" + "types": "./dist/catalog/Row/index.d.ts", + "default": "./dist/catalog/Row/index.js" }, + "./catalog/Row/catalog.json": "./dist/catalog/Row/catalog.json", "./catalog/Column": { - "types": "./dist/catalog/Column.d.ts", - "default": "./dist/catalog/Column.js" + "types": "./dist/catalog/Column/index.d.ts", + "default": "./dist/catalog/Column/index.js" }, + "./catalog/Column/catalog.json": "./dist/catalog/Column/catalog.json", "./catalog/List": { - "types": "./dist/catalog/List.d.ts", - "default": "./dist/catalog/List.js" + "types": "./dist/catalog/List/index.d.ts", + "default": "./dist/catalog/List/index.js" }, + "./catalog/List/catalog.json": "./dist/catalog/List/catalog.json", "./catalog/Card": { - "types": "./dist/catalog/Card.d.ts", - "default": "./dist/catalog/Card.js" + "types": "./dist/catalog/Card/index.d.ts", + "default": "./dist/catalog/Card/index.js" }, + "./catalog/Card/catalog.json": "./dist/catalog/Card/catalog.json", "./catalog/Divider": { - "types": "./dist/catalog/Divider.d.ts", - "default": "./dist/catalog/Divider.js" - }, - "./catalog/TextField": { - "types": "./dist/catalog/TextField.d.ts", - "default": "./dist/catalog/TextField.js" - }, - "./catalog/Icon": { - "types": "./dist/catalog/Icon.d.ts", - "default": "./dist/catalog/Icon.js" - }, - "./catalog/Tabs": { - "types": "./dist/catalog/Tabs.d.ts", - "default": "./dist/catalog/Tabs.js" - }, - "./catalog/Modal": { - "types": "./dist/catalog/Modal.d.ts", - "default": "./dist/catalog/Modal.js" - }, - "./catalog/AudioPlayer": { - "types": "./dist/catalog/AudioPlayer.d.ts", - "default": "./dist/catalog/AudioPlayer.js" - }, - "./catalog/Slider": { - "types": "./dist/catalog/Slider.d.ts", - "default": "./dist/catalog/Slider.js" - }, - "./catalog/DateTimeInput": { - "types": "./dist/catalog/DateTimeInput.d.ts", - "default": "./dist/catalog/DateTimeInput.js" - }, - "./catalog/ChoicePicker": { - "types": "./dist/catalog/ChoicePicker.d.ts", - "default": "./dist/catalog/ChoicePicker.js" + "types": "./dist/catalog/Divider/index.d.ts", + "default": "./dist/catalog/Divider/index.js" }, + "./catalog/Divider/catalog.json": "./dist/catalog/Divider/catalog.json", "./catalog/CheckBox": { - "types": "./dist/catalog/CheckBox.d.ts", - "default": "./dist/catalog/CheckBox.js" + "types": "./dist/catalog/CheckBox/index.d.ts", + "default": "./dist/catalog/CheckBox/index.js" + }, + "./catalog/CheckBox/catalog.json": "./dist/catalog/CheckBox/catalog.json", + "./catalog/RadioGroup": { + "types": "./dist/catalog/RadioGroup/index.d.ts", + "default": "./dist/catalog/RadioGroup/index.js" + }, + "./catalog/RadioGroup/catalog.json": "./dist/catalog/RadioGroup/catalog.json", + "./react": { + "types": "./dist/react/index.d.ts", + "default": "./dist/react/index.js" } }, "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "build": "a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog" + "build": "a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog", + "test": "rstest" }, "dependencies": { "@a2ui/web_core": "0.9.1", @@ -102,14 +88,18 @@ "devDependencies": { "@lynx-js/a2ui-catalog-extractor": "workspace:*", "@lynx-js/lynx-ui": "^3.130.0", - "@lynx-js/lynx-ui-input": "^3.130.0", "@lynx-js/react": "workspace:*", "@lynx-js/types": "3.7.0", + "@rstest/core": "catalog:rstest", "@types/react": "^18.3.28" }, "peerDependencies": { "@lynx-js/lynx-ui": "^3.130.0", - "@lynx-js/lynx-ui-input": "^3.130.0", "@lynx-js/react": "workspace:^" + }, + "peerDependenciesMeta": { + "@lynx-js/lynx-ui": { + "optional": true + } } } diff --git a/packages/genui/a2ui/rstest.config.ts b/packages/genui/a2ui/rstest.config.ts new file mode 100644 index 0000000000..5d0b0bdfc4 --- /dev/null +++ b/packages/genui/a2ui/rstest.config.ts @@ -0,0 +1,12 @@ +// 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 { defineConfig } from '@rstest/core'; + +const config: ReturnType = defineConfig({ + name: 'genui/a2ui', + globals: true, + include: ['test/**/*.test.ts'], +}); + +export default config; diff --git a/packages/genui/a2ui/src/catalog/Button.ts b/packages/genui/a2ui/src/catalog/Button.ts deleted file mode 100755 index 655fcf6f70..0000000000 --- a/packages/genui/a2ui/src/catalog/Button.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 { Button } from './Button/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Button', Button as unknown as ComponentRenderer); - -export { Button }; diff --git a/packages/genui/a2ui/src/catalog/Card.ts b/packages/genui/a2ui/src/catalog/Card.ts deleted file mode 100755 index 8046e98e46..0000000000 --- a/packages/genui/a2ui/src/catalog/Card.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 { Card } from './Card/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Card', Card as unknown as ComponentRenderer); - -export { Card }; diff --git a/packages/genui/a2ui/src/catalog/CheckBox.ts b/packages/genui/a2ui/src/catalog/CheckBox.ts deleted file mode 100755 index 03c4ac6567..0000000000 --- a/packages/genui/a2ui/src/catalog/CheckBox.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 { CheckBox } from './CheckBox/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register( - 'CheckBox', - CheckBox as unknown as ComponentRenderer, -); - -export { CheckBox }; diff --git a/packages/genui/a2ui/src/catalog/Column.ts b/packages/genui/a2ui/src/catalog/Column.ts deleted file mode 100755 index 04a7c48ff5..0000000000 --- a/packages/genui/a2ui/src/catalog/Column.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 { Column } from './Column/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Column', Column as unknown as ComponentRenderer); - -export { Column }; diff --git a/packages/genui/a2ui/src/catalog/Divider.ts b/packages/genui/a2ui/src/catalog/Divider.ts deleted file mode 100755 index 44daa6fcf8..0000000000 --- a/packages/genui/a2ui/src/catalog/Divider.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 { Divider } from './Divider/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Divider', Divider as unknown as ComponentRenderer); - -export { Divider }; diff --git a/packages/genui/a2ui/src/catalog/Image.ts b/packages/genui/a2ui/src/catalog/Image.ts deleted file mode 100755 index 96fcf68142..0000000000 --- a/packages/genui/a2ui/src/catalog/Image.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 { Image } from './Image/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Image', Image as unknown as ComponentRenderer); - -export { Image }; diff --git a/packages/genui/a2ui/src/catalog/List.ts b/packages/genui/a2ui/src/catalog/List.ts deleted file mode 100755 index 4b0460fa0a..0000000000 --- a/packages/genui/a2ui/src/catalog/List.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 { List } from './List/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('List', List as unknown as ComponentRenderer); - -export { List }; diff --git a/packages/genui/a2ui/src/catalog/README.md b/packages/genui/a2ui/src/catalog/README.md index cf447761a6..fd22be832c 100644 --- a/packages/genui/a2ui/src/catalog/README.md +++ b/packages/genui/a2ui/src/catalog/README.md @@ -1,17 +1,38 @@ # Catalog composition -`defineCatalog` builds the runtime catalog the renderer uses. Composition -is per-component so bundlers can tree-shake what you don't reference. +The package intentionally **does not** ship an "all-in-one" catalog +constant. A top-level array referencing every built-in defeats +tree-shaking — every consumer of such an aggregate would bundle every +component, even the nine you don't use. Composition is per-component, and +the cost is visible at the import site. ## The minimum a renderer needs -If you only need to render, names alone are enough. Pass bare components — +If your app only renders, names alone are enough. Pass bare components — the protocol name comes from `displayName ?? component.name`: ```tsx -import { defineCatalog, Text, Button } from '@lynx-js/a2ui-reactlynx'; +import { + A2UI, + Text, + Button, + createMessageStore, +} from '@lynx-js/a2ui-reactlynx'; + +const store = createMessageStore(); + +// Push raw protocol messages from your IO module (fetch, SSE, ...). +// async function streamFromAgent(input) { +// for await (const msg of myAgent.stream(input)) store.push(msg); +// } -const catalog = defineCatalog([Text, Button]); + { + /* forward to your agent and push response messages back */ + }} +/>; ``` Bundlers tree-shake unused components — pulling `Text` does not drag in @@ -20,8 +41,8 @@ Bundlers tree-shake unused components — pulling `Text` does not drag in ## Adding schemas for the agent handshake If you want `serializeCatalog(...)` to emit JSON Schema for each component -(so the agent knows what props to send), pair each component with the JSON -the extractor emitted at `dist/catalog//catalog.json`: +(for the agent to know what props to send), pair each component with the +JSON the extractor emitted at `dist/catalog//catalog.json`: ```tsx import { Text } from '@lynx-js/a2ui-reactlynx/catalog/Text'; @@ -108,7 +129,7 @@ agent will use: ```tsx function MyChart(props: { data: number[] }) { ... } -const catalog = defineCatalog([Text, Button, MyChart]); + // Agent sends `{ component: 'MyChart', data: [...] }` → renders MyChart. ``` diff --git a/packages/genui/a2ui/src/catalog/RadioGroup.ts b/packages/genui/a2ui/src/catalog/RadioGroup.ts deleted file mode 100644 index 1f2e2f2a14..0000000000 --- a/packages/genui/a2ui/src/catalog/RadioGroup.ts +++ /dev/null @@ -1,13 +0,0 @@ -// 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 { RadioGroup } from './RadioGroup/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register( - 'RadioGroup', - RadioGroup as unknown as ComponentRenderer, -); - -export { RadioGroup }; diff --git a/packages/genui/a2ui/src/catalog/Row.ts b/packages/genui/a2ui/src/catalog/Row.ts deleted file mode 100755 index 6a025a3221..0000000000 --- a/packages/genui/a2ui/src/catalog/Row.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 { Row } from './Row/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Row', Row as unknown as ComponentRenderer); - -export { Row }; diff --git a/packages/genui/a2ui/src/catalog/Text.ts b/packages/genui/a2ui/src/catalog/Text.ts deleted file mode 100755 index 5e61540015..0000000000 --- a/packages/genui/a2ui/src/catalog/Text.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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 { Text } from './Text/index.jsx'; -import { componentRegistry } from '../core/ComponentRegistry.js'; -import type { ComponentRenderer } from '../core/ComponentRegistry.js'; - -componentRegistry.register('Text', Text as unknown as ComponentRenderer); - -export { Text }; diff --git a/packages/genui/a2ui/src/catalog/all.ts b/packages/genui/a2ui/src/catalog/all.ts deleted file mode 100755 index bf0c2369e9..0000000000 --- a/packages/genui/a2ui/src/catalog/all.ts +++ /dev/null @@ -1,14 +0,0 @@ -// 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. -// Exporting from these modules also runs their side effects (component registration). -export * from './Text.js'; -export * from './Image.js'; -export * from './Row.js'; -export * from './Column.js'; -export * from './List.js'; -export * from './Card.js'; -export * from './Button.js'; -export * from './Divider.js'; -export * from './CheckBox.js'; -export * from './RadioGroup.js'; diff --git a/packages/genui/a2ui/src/catalog/index.ts b/packages/genui/a2ui/src/catalog/index.ts index 5f1f1657cd..021ab941ba 100755 --- a/packages/genui/a2ui/src/catalog/index.ts +++ b/packages/genui/a2ui/src/catalog/index.ts @@ -17,7 +17,16 @@ export type { SerializedCatalog, } from './defineCatalog.js'; -// Existing global-registry exports — kept for back-compat with the -// current `core/A2UIRender` path. The new `defineCatalog` API above is -// the pluggable, side-effect-free alternative. -export * from './all.js'; +// Per-component re-exports so consumers can pick exactly what they need. +// Each is an independently tree-shakeable ESM re-export — pulling `Text` +// does not drag `Button` into the bundle. +export { Button } from './Button/index.jsx'; +export { Card } from './Card/index.jsx'; +export { CheckBox } from './CheckBox/index.jsx'; +export { Column } from './Column/index.jsx'; +export { Divider } from './Divider/index.jsx'; +export { Image } from './Image/index.jsx'; +export { List } from './List/index.jsx'; +export { RadioGroup } from './RadioGroup/index.jsx'; +export { Row } from './Row/index.jsx'; +export { Text } from './Text/index.jsx'; diff --git a/packages/genui/a2ui/src/chat/Conversation.tsx b/packages/genui/a2ui/src/chat/Conversation.tsx deleted file mode 100755 index aa07e4ab42..0000000000 --- a/packages/genui/a2ui/src/chat/Conversation.tsx +++ /dev/null @@ -1,134 +0,0 @@ -// 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 { - Input, - KeyboardAwareResponder, - KeyboardAwareRoot, - KeyboardAwareTrigger, -} from '@lynx-js/lynx-ui-input'; -import type { InputRef } from '@lynx-js/lynx-ui-input'; -import { useCallback, useRef, useState } from '@lynx-js/react'; - -import { useLynxClient } from './useLynxClient.js'; -import { A2UIRender } from '../core/A2UIRender.jsx'; -import type { Resource } from '../core/types.js'; - -export interface Message { - id: string; - role: 'user' | 'agent'; - content: string; - resource?: Resource; -} - -export interface ConversationProps { - initialInput?: string; - messages?: Message[]; - sendMessage?: (content: string) => Promise; - url?: string; -} - -export function Conversation( - props: ConversationProps, -): import('@lynx-js/react').ReactNode { - const { initialInput, url } = props; - - // Logic to handle self-managed state if url is provided - // We pass a dummy string if url is missing to satisfy the hook type, but we won't use the result if controlled - const hookResult = useLynxClient(url ?? ''); - - // If controlled props are provided, use them; otherwise use hook result if url is present - const messages = props.messages ?? (url ? hookResult.messages : []); - const sendMessage = props.sendMessage - ?? (url ? hookResult.sendMessage : undefined); - const inputRef = useRef(null); - - if (!sendMessage && !props.messages) { - // Fallback or error if neither controlled nor uncontrolled props are valid - console.warn( - 'Conversation requires either `messages` and `sendMessage` OR `url`.', - ); - } - - const [inputValue, setInputValue] = useState(initialInput); - const [isLoading, setIsLoading] = useState(false); - - const handleInput = useCallback((e: string) => { - setInputValue(e); - }, []); - - const handleSend = useCallback(() => { - setIsLoading(true); - const content = inputValue ?? 'Introduce yourself.'; - setInputValue(''); - void inputRef.current?.blur(); - void inputRef.current?.setValue(''); - try { - if (sendMessage) { - void sendMessage(content); - } - } catch (e) { - console.error('sendMessage error:', e); - } finally { - setIsLoading(false); - } - }, [inputValue, sendMessage]); - - return ( - - - - {messages?.map((item: Message) => - item.role === 'user' - ? ( - - {item.content} - - ) - : ( - - ( - Thinking... - )} - /> - - ) - )} - {isLoading && ( - - Thinking... - - )} - - - - - { - void handleSend(); - }} - > - - - - - - - ); -} diff --git a/packages/genui/a2ui/src/chat/index.ts b/packages/genui/a2ui/src/chat/index.ts deleted file mode 100644 index 107469a136..0000000000 --- a/packages/genui/a2ui/src/chat/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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. -export { Conversation, type Message } from './Conversation.jsx'; -export { useLynxClient } from './useLynxClient.js'; diff --git a/packages/genui/a2ui/src/chat/useLynxClient.ts b/packages/genui/a2ui/src/chat/useLynxClient.ts deleted file mode 100644 index b18375f069..0000000000 --- a/packages/genui/a2ui/src/chat/useLynxClient.ts +++ /dev/null @@ -1,112 +0,0 @@ -// 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, useRef, useState } from '@lynx-js/react'; - -import type { Message } from './Conversation.jsx'; -import { BaseClient } from '../core/BaseClient.js'; -import type { Resource } from '../core/types.js'; - -export interface UseLynxClientOptions { - keepHistory?: boolean; -} - -export interface UseLynxClientResult { - messages: Message[]; - sendMessage: ( - content: string, - ) => Promise<{ messageId: string; resource: Resource }>; - setMessages: import('@lynx-js/react').Dispatch< - import('@lynx-js/react').SetStateAction - >; - clientRef: import('@lynx-js/react').MutableRefObject; -} - -export function useLynxClient( - url: string, - options: UseLynxClientOptions = {}, -): UseLynxClientResult { - const { keepHistory = true } = options; - const clientRef = useRef(null); - const [messages, setMessages] = useState([]); - - const getClient = useCallback(() => { - if (!clientRef.current) { - const client = new BaseClient(url); - - client.onResourceCreated = (resource, id) => { - if (!keepHistory) return; - setMessages((prev) => [ - ...prev, - { - id: `${id}-agent-new`, - role: 'agent', - content: '', - resource, - }, - ]); - }; - - client.onResponseComplete = (messageId, { hasBeginRendering }) => { - if (!keepHistory) return; - if (!hasBeginRendering) { - setMessages((prev) => - prev.filter( - (m) => - m.id !== `${messageId}-agent` - && m.id !== `${messageId}-agent-new`, - ) - ); - } - }; - - clientRef.current = client; - } - return clientRef.current; - }, [url, keepHistory]); - - const sendMessage = useCallback( - async (content: string) => { - const client = getClient(); - - if (keepHistory) { - const userMsgId = 'user_' - + Date.now().toString(36) - + Math.random().toString(36).slice(2, 10); - setMessages((prev) => [ - ...prev, - { - id: userMsgId, - role: 'user', - content, - }, - ]); - } - - const result = await client.makeRequest(content); - const { messageId, resource } = result; - - if (keepHistory) { - setMessages((prev) => [ - ...prev, - { - id: `${messageId}-agent`, - role: 'agent', - content: '', - resource, - }, - ]); - } - - return result; - }, - [getClient, keepHistory], - ); - - return { - messages, - sendMessage, - setMessages, - clientRef, - }; -} diff --git a/packages/genui/a2ui/src/core/A2UIRender.tsx b/packages/genui/a2ui/src/core/A2UIRender.tsx deleted file mode 100644 index 8981189f02..0000000000 --- a/packages/genui/a2ui/src/core/A2UIRender.tsx +++ /dev/null @@ -1,211 +0,0 @@ -// 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 { memo, useEffect, useState } from '@lynx-js/react'; -import type { ReactNode } from '@lynx-js/react'; - -import { componentRegistry } from './ComponentRegistry.js'; -import type { ComponentInstance, Resource, Surface } from './types.js'; -import { useAction } from './useAction.js'; -import { useResolvedProps } from './useDataBinding.js'; - -function Loading(props: { id: string }) { - const content = `loading ${props.id}...`; - return ( - - {content} - - ); -} - -function buildNodeRecursive( - component: ComponentInstance, - surface: Surface, - resolvedProps?: Record, - setValue?: (key: string, value: unknown) => void, - sendAction?: (action: Record) => void, -): ReactNode { - const tag = component.component; - - if (componentRegistry.has(tag)) { - const Component = componentRegistry.get(tag)! as unknown as ( - props: Record, - ) => import('@lynx-js/react').ReactNode; - return ( - ) => { - void sendAction?.(a); - }} - dataContextPath={component.dataContextPath} - {...resolvedProps} - /> - ); - } - - console.warn(`Component ${tag} not registered in v0.9 registry.`); - return null; -} - -function A2UIRenderImpl( - props: { - resource: Resource; - renderFallback?: () => import('@lynx-js/react').ReactNode; - }, -): import('@lynx-js/react').ReactNode { - const { resource } = props; - const [data, setData] = useState(() => { - if (resource.completed) { - try { - return resource.read(); - } catch { - return null; - } - } - return null; - }); - const [loading, setLoading] = useState(!resource.completed); - const [error, setError] = useState(null); - - useEffect(() => { - let active = true; - - if (resource.completed) { - try { - const res = resource.read(); - if (active) { - setData(res); - setLoading(false); - } - } catch (err) { - if (active) { - setError(err as Error); - setLoading(false); - } - } - } else { - resource.promise - .then((res) => { - if (active) { - setData(res); - setLoading(false); - } - }) - .catch((err) => { - if (active) { - setError(err as Error); - setLoading(false); - } - }); - } - - const unsubscribe = resource.onUpdate((newData) => { - if (active) { - setData(newData); - } - }); - - return () => { - active = false; - unsubscribe(); - }; - }, [resource]); - - if (loading) { - return props.renderFallback?.() ?? ; - } - - if (error) { - return Error: {String(error)}; - } - - if (!data) return null; - - const dataObj = data as Record; - const type = dataObj['type'] as string; - const surfaceId = dataObj['surfaceId'] as string; - const surface = dataObj['surface'] as Surface; - const component = dataObj['component'] as ComponentInstance | undefined; - if (type === 'beginRendering') { - const id = surface.rootComponentId!; - const childResource = surface.resources.get(id); - if (!childResource) return null; - return ( - - - - ); - } - - if (type === 'surfaceUpdate' && component) { - return ; - } - - if (type === 'deleteSurface') { - return null; - } - - return null; -} - -export const A2UIRender = memo(A2UIRenderImpl); - -export function NodeRenderer( - props: { component: ComponentInstance; surface: Surface }, -): import('@lynx-js/react').ReactNode { - const { component: initialComponent, surface } = props; - const [component, setComponent] = useState(initialComponent); - - useEffect(() => { - setComponent(initialComponent); - }, [initialComponent]); - - useEffect(() => { - const resource = surface.resources.get(component.id!); - if (!resource) return; - - return resource.onUpdate((data) => { - const dataMap = data as unknown as Readonly>; - if (dataMap['type'] === 'surfaceUpdate' && dataMap['component']) { - setComponent({ ...(dataMap['component'] as ComponentInstance) }); - } - }); - }, [component.id, surface]); - - const [resolvedProps, setValue] = useResolvedProps( - component, - surface, - component.dataContextPath, - ); - - const { sendAction } = useAction({ - id: component.id!, - surfaceId: surface.surfaceId, - dataContext: component.dataContextPath, - }); - - return ( - <> - {buildNodeRecursive( - component, - surface, - resolvedProps, - setValue, - (a: Record) => { - void sendAction(a as unknown as Parameters[0]); - }, - )} - - ); -} diff --git a/packages/genui/a2ui/src/core/BaseClient.ts b/packages/genui/a2ui/src/core/BaseClient.ts deleted file mode 100755 index dcbdf52248..0000000000 --- a/packages/genui/a2ui/src/core/BaseClient.ts +++ /dev/null @@ -1,590 +0,0 @@ -// 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 * as v0_9 from '@a2ui/web_core/v0_9'; - -import { processor } from './processor.js'; -import type { MessageProcessor } from './processor.js'; -import type { - A2UIClientEventMessage, - Resource, - ServerToClientMessage, - UserActionPayload, -} from './types.js'; -import { createResource } from '../utils/createResource.js'; - -const MESSAGE_PROCESS_DELAY = 300; - -function randomId(prefix = '') { - return prefix + Date.now().toString(36) - + Math.random().toString(36).slice(2, 10); -} - -function buildSseParams( - message: A2UIClientEventMessage, - messageId: string, -): Record { - const params: Record = { messageId }; - const anyMessage: Record = message as Record< - string, - unknown - >; - - if (typeof message === 'string') { - params['text'] = message; - } else if (anyMessage) { - if (typeof anyMessage['text'] === 'string') { - params['text'] = anyMessage['text']; - } else if (anyMessage['text']) { - params['text'] = JSON.stringify(anyMessage['text']); - } else if (anyMessage['userAction']) { - const userAction = anyMessage['userAction'] as { - name: string; - context?: Record; - }; - const actionName = userAction.name || 'unknownAction'; - const context = userAction.context ?? {}; - params['text'] = `USER_ACTION: ${actionName}, Context: ${ - JSON.stringify(context) - }`; - } else { - params['text'] = JSON.stringify(message); - } - - if (typeof anyMessage['sessionId'] === 'string') { - params['sessionId'] = anyMessage['sessionId']; - } else if (anyMessage['sessionId']) { - params['sessionId'] = JSON.stringify(anyMessage['sessionId']); - } - } - - return params; -} - -function createFallbackMessagesFromPlainText(text: string) { - const surfaceId = 'default'; - const rootId = 'root-text'; - - return [ - { - createSurface: { - surfaceId, - catalogId: 'inline-text', - }, - }, - { - updateComponents: { - surfaceId, - components: [ - { - id: rootId, - component: 'Text', - text, - }, - ], - }, - }, - ]; -} - -function createTextCardMessages(text: string) { - const surfaceId = randomId('text_surface_'); - const rootId = 'root'; - const textId = 'text'; - - return [ - { - createSurface: { - surfaceId, - catalogId: 'inline-text', - }, - }, - { - updateComponents: { - surfaceId, - components: [ - { - id: rootId, - component: 'Card', - child: textId, - }, - { - id: textId, - component: 'Text', - text, - variant: 'body', - }, - ], - }, - }, - ]; -} - -function normalizePayloadToMessages(payload: unknown): ServerToClientMessage[] { - const messages: ServerToClientMessage[] = []; - - const add = (value: unknown) => { - if (!value) return; - if (Array.isArray(value)) { - for (const item of value) { - add(item); - } - } else { - messages.push(value as ServerToClientMessage); - } - }; - - const handle = (value: unknown): void => { - if (value === null || value === undefined) return; - - if (Array.isArray(value)) { - for (const item of value) { - handle(item); - } - return; - } - - if (typeof value === 'string') { - add(createFallbackMessagesFromPlainText(value)); - return; - } - - if (typeof value === 'object') { - const v = value as Record; - - if ( - v['createSurface'] || v['updateComponents'] || v['updateDataModel'] - || v['deleteSurface'] - ) { - add(v); - return; - } - - if ('kind' in v && 'data' in v) { - if (v['kind'] === 'data') { - handle(v['data']); - } else if (v['kind'] === 'text') { - add( - createTextCardMessages( - typeof v['data'] === 'string' ? v['data'] : String(v['data']), - ), - ); - } - return; - } - - if (Array.isArray(v['messages'])) { - handle(v['messages']); - return; - } - - add(createFallbackMessagesFromPlainText(JSON.stringify(v))); - return; - } - }; - - handle(payload); - return messages; -} - -function prepareMessagesForProcessing( - rawMessages: ServerToClientMessage[], - messageId: string, - activeSurfaceIds: Set, -) { - let hasComponentUpdate = false; - const messages = rawMessages.filter((msg: ServerToClientMessage) => { - const deletedSurfaceId = (msg as { deleteSurface?: { surfaceId?: string } }) - .deleteSurface?.surfaceId; - if (typeof deletedSurfaceId === 'string') { - activeSurfaceIds.delete(deletedSurfaceId); - } - - const createdSurfaceId = (msg as { createSurface?: { surfaceId?: string } }) - .createSurface?.surfaceId; - if (typeof createdSurfaceId === 'string') { - if (activeSurfaceIds.has(createdSurfaceId)) { - return false; - } - activeSurfaceIds.add(createdSurfaceId); - } - - if ( - ((msg as { updateComponents?: { components?: unknown[] } }) - .updateComponents - && Array.isArray( - (msg as { updateComponents?: { components?: unknown[] } }) - .updateComponents?.components, - ) - && (((msg as { updateComponents?: { components: unknown[] } }) - .updateComponents?.components ?? []).length > 0)) - ) { - hasComponentUpdate = true; - } - - msg.messageId ??= messageId; - - return true; - }); - - return { messages, hasComponentUpdate }; -} - -export class BaseClient { - protected processor: MessageProcessor; - protected resources: Map; - protected resolves: Map void>; - protected baseUrl: string; - public onResponseComplete?: ( - messageId: string, - info: { hasBeginRendering: boolean }, - ) => void; - public onResourceCreated?: (resource: Resource, messageId: string) => void; - - constructor(baseUrl: string) { - this.processor = processor as unknown as MessageProcessor; - this.resources = new Map(); - this.resolves = new Map(); - this.baseUrl = baseUrl; - - this.processor.onUpdate((data) => { - const { type, surfaceId, messageId, updates = [] } = data as { - surfaceId: string; - type: string; - messageId: string; - updates?: v0_9.AnyComponent[]; - targetId?: string; - }; - - const surface = this.processor.getOrCreateSurface(surfaceId); - - if (type === 'beginRendering') { - const resource = this.resources.get(messageId); - resource?.complete({ type: 'beginRendering', surfaceId, surface }); - } else if (type === 'surfaceUpdate') { - (updates || []).forEach((update) => { - if (!update.id) return; - const resource = surface.resources.get(update.id); - resource?.complete({ - type: 'surfaceUpdate', - surfaceId, - surface, - component: update as import('./types.js').ComponentInstance, - }); - }); - } else if (type === 'deleteSurface') { - const { targetId } = data as { targetId?: string }; - const target = targetId ?? surface.rootComponentId; - if (target && surface.resources.has(target)) { - const resource = surface.resources.get(target)!; - resource.complete({ type: 'deleteSurface', surfaceId, surface }); - } - } - - const resolve = this.resolves.get(messageId); - if (resolve) { - resolve({ type, surfaceId, surface }); - this.resolves.delete(messageId); - } - }); - - this.processor.onEvent( - ({ message, resolve }: import('./processor.js').A2UIEvent) => { - void (async () => { - if ( - typeof message === 'object' && message !== null - && 'userAction' in message - && (message as { userAction: unknown }).userAction - ) { - try { - const response = await this.processUserAction( - (message as { userAction: unknown }) - .userAction as UserActionPayload, - ); - resolve(response); - } catch (e) { - console.error('Error processing userAction', e); - resolve([]); - } - } else { - resolve([]); - } - })(); - }, - ); - } - - async processUserAction(userAction: UserActionPayload): Promise { - const response = await this.send({ userAction } as A2UIClientEventMessage); - const { messageId, resource, startStreaming, promise } = response; - this.resources.set(messageId, resource); - if (this.onResourceCreated) { - this.onResourceCreated(resource, messageId); - } - startStreaming(); - return promise; - } - - async makeRequest( - request: string, - ): Promise< - { messageId: string; resource: Resource; promise: Promise } - > { - const response = await this.send( - request as unknown as A2UIClientEventMessage, - ); - const { messageId, resource, startStreaming, promise } = response; - this.resources.set(messageId, resource); - startStreaming(); - return { messageId, resource, promise }; - } - - async send( - message: A2UIClientEventMessage, - id?: string, - ): Promise<{ - messageId: string; - resource: Resource; - startStreaming: () => void; - promise: Promise; - }> { - const messageId = id ?? randomId('task_'); - const promise = new Promise((resolve) => { - this.resolves.set(messageId, resolve); - }); - - const resource = createResource(messageId) as unknown as Resource; - - const startStreaming = () => { - void (async () => { - const params = new URLSearchParams(buildSseParams(message, messageId)); - - interface TypedEventSource { - addEventListener( - type: string, - listener: ( - event: { data?: unknown; target?: unknown; type?: string }, - ) => void, - ): void; - close(): void; - readyState: number; - } - const g = globalThis as Record; - const tsKey = 'Event' + 'Source'; - const NativeES = g[tsKey] as - | (new(url: string) => TypedEventSource) - | undefined; - const EventSourceImpl = NativeES - ?? (lynx.EventSource as unknown as new( - url: string, - ) => TypedEventSource); - - const url = `${this.baseUrl}?${params.toString()}`; - - console.info('[BaseClient v0.9] streaming answer message', message); - - if (url.includes('localhost') && typeof lynx !== 'undefined') { - console.warn( - '[BaseClient v0.9] You are using \'localhost\' in Lynx environment. This may not work on a physical device. Please use your computer\'s IP address.', - ); - } - - if (url.length > 2048) { - console.warn( - `[BaseClient v0.9] URL is too long (${url.length} chars), request might fail. Consider using POST or shortening the payload.`, - ); - } - - console.info( - '[BaseClient v0.9] Using EventSource implementation:', - EventSourceImpl === NativeES - ? 'Native EventSource' - : 'Custom/Lynx EventSource', - ); - - const eventSource = new EventSourceImpl(url); - - console.info( - '[BaseClient v0.9] EventSource created, readyState:', - (eventSource as unknown as { readyState: number }).readyState, - ); - - let isCompleted = false; - let hasBeginRendering = false; - let hasReceivedProcessedPayload = false; - const activeSurfaceIds = new Set(this.processor.getSurfaces().keys()); - - const messageQueue: ServerToClientMessage[][] = []; - let isProcessingQueue = false; - - const processQueue = async () => { - if (isProcessingQueue) return; - isProcessingQueue = true; - while (messageQueue.length > 0) { - const msgs = messageQueue.shift(); - if (msgs && msgs.length > 0) { - this.processor.processMessages(msgs); - } - await new Promise((resolve) => - setTimeout(resolve, MESSAGE_PROCESS_DELAY) - ); - } - isProcessingQueue = false; - }; - - eventSource.addEventListener( - 'open', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.info('[BaseClient v0.9] SSE connection opened', event); - }, - ); - - eventSource.addEventListener( - 'update', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.info( - '[BaseClient v0.9] SSE update event', - event.data, - event, - ); - }, - ); - - eventSource.addEventListener( - 'delta', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.info( - '[BaseClient v0.9] SSE delta event', - event.data, - event, - ); - try { - let payload = event.data; - if (typeof payload === 'string') { - try { - payload = JSON.parse(payload); - } catch { - // ignore - } - } - - if (typeof payload === 'string') { - try { - payload = JSON.parse(payload); - } catch { - // ignore - } - } - - const messages = normalizePayloadToMessages(payload); - console.info( - '[BaseClient v0.9] Normalized delta messages', - messages, - ); - const prepared = prepareMessagesForProcessing( - messages, - messageId, - activeSurfaceIds, - ); - if (prepared.hasComponentUpdate) { - hasBeginRendering = true; - } - - if (prepared.messages.length > 0) { - hasReceivedProcessedPayload = true; - messageQueue.push(prepared.messages); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - processQueue(); - } - } catch (e) { - console.error('Error processing delta', e); - } - }, - ); - - eventSource.addEventListener( - 'complete', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.info( - '[BaseClient v0.9] SSE complete event', - event.data, - event, - ); - if (isCompleted) return; - isCompleted = true; - - try { - let payload = event.data; - if (typeof payload === 'string') { - try { - payload = JSON.parse(payload); - } catch { - // ignore - } - } - - const messages = normalizePayloadToMessages(payload); - console.info( - '[BaseClient v0.9] Normalized complete messages', - messages, - ); - - if (!hasReceivedProcessedPayload && messages.length > 0) { - const prepared = prepareMessagesForProcessing( - messages, - messageId, - activeSurfaceIds, - ); - if (prepared.hasComponentUpdate) { - hasBeginRendering = true; - } - - if (prepared.messages.length > 0) { - messageQueue.push(prepared.messages); - - // eslint-disable-next-line @typescript-eslint/no-floating-promises - processQueue(); - } - } - } catch (e) { - console.error( - '[BaseClient v0.9] Error processing complete payload', - e, - ); - } - - eventSource.close(); - - if (this.onResponseComplete) { - this.onResponseComplete(messageId, { hasBeginRendering }); - } - }, - ); - - eventSource.addEventListener( - 'error', - (event: { data?: unknown; target?: unknown; type?: string }) => { - console.error('[BaseClient v0.9] SSE error details:', event); - if ( - event && typeof event === 'object' && 'target' in event - && event.target && typeof event.target === 'object' - && 'readyState' in event.target - && typeof (event.target as Record)['readyState'] - !== 'undefined' - ) { - const target = event.target as Record; - console.error( - '[BaseClient v0.9] SSE readyState:', - target['readyState'], - ); - } - eventSource.close(); - }, - ); - })(); - }; - - return { messageId, resource, startStreaming, promise }; - } -} diff --git a/packages/genui/a2ui/src/core/ComponentRegistry.ts b/packages/genui/a2ui/src/core/ComponentRegistry.ts deleted file mode 100644 index 10daf8f8fd..0000000000 --- a/packages/genui/a2ui/src/core/ComponentRegistry.ts +++ /dev/null @@ -1,37 +0,0 @@ -// 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 { ComponentType } from '@lynx-js/react'; - -import type { ComponentInstance, Surface } from './types.js'; - -export class BaseComponentRegistry { - private registry = new Map(); - register(name: string, component: T): void { - this.registry.set(name, component); - } - has(name: string): boolean { - return this.registry.has(name); - } - get(name: string): T | undefined { - return this.registry.get(name); - } -} - -export interface ComponentProps { - id: string; - surfaceId: string; - surface: Surface; - /** - * The full v0.9 component instance as defined by the protocol. - */ - component: ComponentInstance; -} - -export type ComponentRenderer = ComponentType; - -export class ComponentRegistry - extends BaseComponentRegistry -{} - -export const componentRegistry = new ComponentRegistry(); diff --git a/packages/genui/a2ui/src/core/index.ts b/packages/genui/a2ui/src/core/index.ts deleted file mode 100644 index ddcd260280..0000000000 --- a/packages/genui/a2ui/src/core/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// 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. -export * from './types.js'; -export { A2UIRender } from './A2UIRender.jsx'; -export { BaseClient } from './BaseClient.js'; -export { componentRegistry, type ComponentProps } from './ComponentRegistry.js'; -export { processor, type MessageProcessor } from './processor.js'; -export { useAction } from './useAction.js'; -export { useDataBinding, useResolvedProps } from './useDataBinding.js'; diff --git a/packages/genui/a2ui/src/core/types.ts b/packages/genui/a2ui/src/core/types.ts deleted file mode 100644 index 2a7bfd4db9..0000000000 --- a/packages/genui/a2ui/src/core/types.ts +++ /dev/null @@ -1,84 +0,0 @@ -// 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 * as v0_9 from '@a2ui/web_core/v0_9'; - -import type { Resource as GenericResource } from '../utils/createResource.js'; -import type { SignalStore } from '../utils/SignalStore.js'; - -export type SurfaceId = string; - -export type ComponentInstance = v0_9.AnyComponent & { - /** - * Absolute data context path for this component when created via a template. - * Used for resolving relative bindings inside the component tree. - */ - dataContextPath?: string; - /** - * Internal metadata for templated containers so we can re-expand on - * data model updates. - */ - __template?: { - componentId: v0_9.ComponentId; - path: string; - }; -}; - -export interface Surface { - surfaceId: SurfaceId; - catalogId?: string; - theme?: Readonly>; - sendDataModel?: boolean | undefined; - /** id of the root component for this surface (must be 'root'). */ - rootComponentId?: string | null; - components: Map; - resources: Map; - store: SignalStore; -} - -export interface ResourceInfo { - /** - * Internal event type emitted by the processor. - */ - type: 'beginRendering' | 'surfaceUpdate' | 'deleteSurface'; - surfaceId: string; - surface: Surface; - component?: ComponentInstance; -} - -export type Resource = GenericResource; - -export type ServerToClientMessage = v0_9.A2uiMessage & { - /** - * Message id injected by the client (SSE messageId / task id). - */ - messageId?: string; -}; - -export interface UserActionPayload { - name: string; - surfaceId: string; - sourceComponentId: string; - timestamp: string; // ISO 8601 - context: Record; -} - -export type A2UIClientEventMessage = - | string - | { - text?: string; - sessionId?: string; - } - | { - userAction: UserActionPayload; - sessionId?: string; - }; - -export interface GenericComponentProps { - id?: string; - surface: Surface; - setValue?: (key: string, value: unknown) => void; - sendAction?: (action: Record) => void; - dataContextPath?: string; - [key: string]: unknown; -} diff --git a/packages/genui/a2ui/src/core/useAction.ts b/packages/genui/a2ui/src/core/useAction.ts deleted file mode 100644 index cd318e301b..0000000000 --- a/packages/genui/a2ui/src/core/useAction.ts +++ /dev/null @@ -1,153 +0,0 @@ -// 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 * as v0_9 from '@a2ui/web_core/v0_9'; - -import { processor } from './processor.js'; -import type { UserActionPayload } from './types.js'; - -export interface ActionProps { - id: string; - surfaceId: string; - dataContext?: string | undefined; -} - -function isDataBinding(value: unknown): value is v0_9.DataBinding { - return !!value && typeof value === 'object' - && 'path' in (value as Record); -} - -function isFunctionCall(value: unknown): value is v0_9.FunctionCall { - return !!value && typeof value === 'object' - && 'call' in (value as Record); -} - -function resolveFromStore( - path: string, - surfaceId: string, - dataContextPath?: string, -): unknown { - const surface = processor.getOrCreateSurface(surfaceId); - const store = surface.store; - const resolvedPath = processor.resolvePath(path, dataContextPath); - const signal = store.getSignal(resolvedPath); - const raw = signal.value; - if (!raw) return raw; - try { - return JSON.parse(raw as string); - } catch { - return raw; - } -} - -function resolveDynamicValue( - value: v0_9.DynamicValue, - surfaceId: string, - dataContextPath?: string, -): unknown { - if ( - typeof value === 'string' || typeof value === 'number' - || typeof value === 'boolean' - ) { - return value; - } - - if (Array.isArray(value)) { - return value.map((v: unknown) => v); - } - - if (isDataBinding(value)) { - return resolveFromStore(value.path, surfaceId, dataContextPath); - } - - if (isFunctionCall(value)) { - return resolveFunctionCall(value, surfaceId, dataContextPath); - } - - return value; -} - -function resolveFunctionArguments( - args: Record | undefined, - surfaceId: string, - dataContextPath?: string, -): Record | undefined { - if (!args) return undefined; - const resolved: Record = {}; - for (const [key, val] of Object.entries(args)) { - if (isObject(val) && !isDataBinding(val) && !isFunctionCall(val)) { - // Literal object configuration; do a shallow copy. - resolved[key] = { ...val }; - } else { - resolved[key] = resolveDynamicValue( - val as v0_9.DynamicValue, - surfaceId, - dataContextPath, - ); - } - } - return resolved; -} - -function resolveFunctionCall( - fn: v0_9.FunctionCall, - surfaceId: string, - dataContextPath?: string, -): Record { - return { - call: fn.call, - args: resolveFunctionArguments(fn.args, surfaceId, dataContextPath), - returnType: fn.returnType, - }; -} - -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -export function useAction( - props: ActionProps, -): { sendAction: (action: v0_9.Action) => Promise } { - const { id, surfaceId, dataContext } = props; - - const sendAction = (action: v0_9.Action) => { - let name = 'unknownAction'; - let context: Record = {}; - - if ('event' in action && action.event) { - name = action.event.name; - const ctx = action.event.context as Record | undefined; - if (ctx) { - const resolvedContext: Record = {}; - for (const [key, value] of Object.entries(ctx)) { - resolvedContext[key] = resolveDynamicValue( - value as v0_9.DynamicValue, - surfaceId, - dataContext, - ); - } - context = resolvedContext; - } - } else if ('functionCall' in action && action.functionCall) { - const fn = action.functionCall; - name = fn.call; - context = { - functionCall: resolveFunctionCall(fn, surfaceId, dataContext), - }; - } - - const userAction: UserActionPayload = { - name, - surfaceId, - sourceComponentId: id, - timestamp: new Date().toISOString(), - context, - }; - - return processor.dispatch({ userAction }); - }; - - return { - sendAction, - }; -} diff --git a/packages/genui/a2ui/src/core/useDataBinding.ts b/packages/genui/a2ui/src/core/useDataBinding.ts deleted file mode 100644 index 52d96c4415..0000000000 --- a/packages/genui/a2ui/src/core/useDataBinding.ts +++ /dev/null @@ -1,155 +0,0 @@ -// 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 { effect } from '@preact/signals'; - -import { useEffect, useState } from '@lynx-js/react'; - -import type { Surface } from './types.js'; - -export function useDataBinding( - dynamicValue: unknown, - surface: Surface | undefined, - dataContextPath?: string, - fallbackValue?: T, -): [T | undefined, (newValue: T) => void, string | undefined] { - let path: string | undefined; - let initialValue: string | undefined; - - if (typeof dynamicValue === 'string') { - initialValue = dynamicValue; - } else if ( - dynamicValue - && typeof dynamicValue === 'object' - && 'path' in dynamicValue - ) { - path = (dynamicValue as Record)['path'] as - | string - | undefined; - } else if ( - typeof dynamicValue === 'number' || typeof dynamicValue === 'boolean' - ) { - initialValue = String(dynamicValue); - } - - if (path && !path.startsWith('/')) { - if (dataContextPath) { - path = `${dataContextPath}/${path}`; - } else { - path = `/${path}`; - } - } - - const signal = surface?.store && path - ? surface.store.getSignal(path, initialValue) - : undefined; - - const [signalValue, setSignalValue] = useState(() => - signal?.value as T | undefined - ); - - useEffect(() => { - if (!signal) return; - const dispose = effect(() => { - setSignalValue(signal.value as T | undefined); - }); - return dispose; - }, [signal]); - - const currentValue = path - ? (signalValue ?? (initialValue as T | undefined)) - : (initialValue as T | undefined ?? fallbackValue); - - const setValue = (newValue: T) => { - if (path && surface?.store) { - surface.store.update(path, newValue); - } - }; - - return [currentValue, setValue, path]; -} - -function isDataBinding(prop: unknown): boolean { - // In 0.9, a binding is typically { path: string } - // Exclude template configurations like { path: string, componentId: string } - return Boolean( - prop - && typeof prop === 'object' - && 'path' in prop - && !('componentId' in prop), - ); -} - -function resolveProperties( - properties: Record, - surface: Surface | undefined, - dataContextPath?: string, -) { - if (!properties) return properties; - const result: Record = {}; - for (const key in properties) { - const prop = properties[key]; - if (isDataBinding(prop)) { - let path = (prop as Record)['path'] as - | string - | undefined; - if (path && typeof path === 'string' && !path.startsWith('/')) { - path = dataContextPath ? `${dataContextPath}/${path}` : `/${path}`; - } - - if (path && surface?.store) { - const signal = surface.store.getSignal(path); - result[key] = signal.value; - } else { - result[key] = undefined; - } - } else if ( - typeof prop === 'string' - || typeof prop === 'number' - || typeof prop === 'boolean' - ) { - result[key] = prop; - } else { - result[key] = prop; - } - } - return result; -} - -export function useResolvedProps( - properties: Record, - surface: Surface | undefined, - dataContextPath?: string, -): readonly [Record, (key: string, value: unknown) => void] { - const [resolved, setResolved] = useState(() => - resolveProperties(properties, surface, dataContextPath) - ); - - useEffect(() => { - if (!surface?.store) return; - const dispose = effect(() => { - setResolved(resolveProperties(properties, surface, dataContextPath)); - }); - return dispose; - }, [properties, surface, dataContextPath]); - - const setValue = (key: string, value: unknown) => { - const prop = properties?.[key]; - if (isDataBinding(prop)) { - let path = (prop as Record)['path'] as - | string - | undefined; - if (path && surface?.store) { - if (!path.startsWith('/')) { - path = dataContextPath ? `${dataContextPath}/${path}` : `/${path}`; - } - surface.store.update(path, value); - } - } - }; - - return [resolved, setValue] as readonly [ - Record, - (key: string, value: unknown) => void, - ]; -} diff --git a/packages/genui/a2ui/src/index.ts b/packages/genui/a2ui/src/index.ts new file mode 100644 index 0000000000..66af08327a --- /dev/null +++ b/packages/genui/a2ui/src/index.ts @@ -0,0 +1,79 @@ +// 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. + +// React surface — `` is the all-in-one entry point. The hooks + +// `NodeRenderer` are the contract for custom catalog components. +export { + A2UI, + NodeRenderer, + useAction, + useDataBinding, + useResolvedProps, +} from './react/index.js'; +export type { A2UIProps, ActionProps } from './react/index.js'; + +// Store — a pure raw-message buffer. The developer's IO module pushes +// protocol messages into it; `` subscribes and processes them. +// `MessageProcessor` is exposed for protocol-aware consumers who want to +// build their own renderer instead of using ``. +export { createMessageStore, MessageProcessor } from './store/index.js'; +export type { + A2UIClientEventMessage, + ComponentInstance, + GenericComponentProps, + MessageStore, + MessageStoreOptions, + Resource, + ResourceInfo, + ServerToClientMessage, + Surface, + SurfaceId, + UserActionPayload, +} from './store/index.js'; +// Helpers for IO that returns free-form text instead of structured +// protocol messages. +export { + createFallbackMessagesFromPlainText, + createTextCardMessages, + normalizePayloadToMessages, + prepareMessagesForProcessing, +} from './store/index.js'; + +// Catalog — declarative composition. +export { + defineCatalog, + mergeCatalogs, + resolveCatalog, + serializeCatalog, +} from './catalog/index.js'; +export type { + Catalog, + CatalogComponent, + CatalogInput, + CatalogManifest, + CatalogSchema, + ResolvedCatalogEntry, + SerializedCatalog, +} from './catalog/index.js'; + +// Built-in components — re-exported individually so apps can pick exactly +// what they need: +// +// import { Text, Button } from '@lynx-js/a2ui-reactlynx'; +// +// +// There is intentionally no all-in-one aggregate — see +// `packages/genui/a2ui/src/catalog/README.md`. +export { + Button, + Card, + CheckBox, + Column, + Divider, + Image, + List, + RadioGroup, + Row, + Text, +} from './catalog/index.js'; diff --git a/packages/genui/a2ui/src/react/A2UI.tsx b/packages/genui/a2ui/src/react/A2UI.tsx new file mode 100644 index 0000000000..12a6c94de2 --- /dev/null +++ b/packages/genui/a2ui/src/react/A2UI.tsx @@ -0,0 +1,264 @@ +// 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 * as v0_9 from '@a2ui/web_core/v0_9'; + +import { + memo, + useEffect, + useMemo, + useReducer, + useRef, + useSyncExternalStore, +} from '@lynx-js/react'; +import type { ReactNode } from '@lynx-js/react'; + +import { A2UIProvider } from './A2UIProvider.jsx'; +import { A2UIRenderer } from './A2UIRenderer.jsx'; +import type { Catalog, CatalogInput } from '../catalog/defineCatalog.js'; +import { defineCatalog } from '../catalog/defineCatalog.js'; +import { MessageProcessor } from '../store/MessageProcessor.js'; +import type { MessageStore } from '../store/MessageStore.js'; +import { createResource } from '../store/Resource.js'; +import type { + Resource, + ResourceInfo, + UserActionPayload, +} from '../store/types.js'; + +// Mark v0_9 used so the namespace import doesn't get pruned. Kept around so +// future enhancements (typed protocol message guards) don't need a fresh +// import diff. +void v0_9; + +export interface A2UIProps { + /** + * The raw-message buffer the developer pushes protocol messages into. + * `` subscribes via `useSyncExternalStore` and processes new + * messages incrementally. + * + * The internal `MessageProcessor` (surfaces, signals, resources) is + * created once per mount and is **not reset** if `messageStore` is + * later replaced with a different instance. Pass a `key` prop derived + * from the store's identity if you want a fresh session, e.g. + * ``. + */ + messageStore: MessageStore; + /** + * Components the renderer is allowed to instantiate. Each item is either + * a bare component (name read from `displayName ?? name`) or a tuple + * `[component, manifest]` where the manifest is the JSON the extractor + * emits at `dist/catalog//catalog.json`. + */ + catalogs: readonly CatalogInput[]; + /** + * Called when a user action fires inside the rendered tree (button + * tap, etc.). Forward to your agent and push the response messages + * back into the same `messageStore` to update the UI. Fire-and-forget; + * the renderer never awaits this. + */ + onAction?: (action: UserActionPayload) => void; + /** + * Wrap each top-level surface so consumers can apply theme/wrapper + * className/styles. The renderer ships no className of its own. + */ + wrapSurface?: ( + children: ReactNode, + ctx: { surfaceId: string }, + ) => ReactNode; + /** Render before the first `beginRendering` arrives from the buffer. */ + renderEmpty?: () => ReactNode; + /** Render while the active resource is pending. */ + renderFallback?: () => ReactNode; + /** Render when the active resource fails. */ + renderError?: (err: unknown) => ReactNode; +} + +interface InternalSession { + processor: MessageProcessor; + resources: Map; + activeMessageId: string | null; + /** How many messages from the buffer have been processed so far. */ + processedCount: number; +} + +/** + * The all-in-one A2UI component. Owns a `MessageProcessor` internally, + * processes raw messages from the buffer on each render, and renders + * the most recent `beginRendering` surface tree. + * + * @example + * const store = createMessageStore(); + * + * // Developer's IO module pushes raw messages into the store. + * async function streamFromAgent(input: string) { + * for await (const msg of myAgent.stream(input)) store.push(msg); + * } + * + * { void streamFromAgent(serializeAction(action)); }} + * wrapSurface={(c) => {c}} + * /> + */ +function A2UIImpl(props: A2UIProps): import('@lynx-js/react').ReactNode { + const { + messageStore, + catalogs, + onAction, + wrapSurface, + renderEmpty, + renderFallback, + renderError, + } = props; + + // Keep the latest onAction in a ref so the once-mounted processor.onEvent + // listener always calls the up-to-date prop without re-binding. + const onActionRef = useRef(onAction); + + useEffect(() => { + onActionRef.current = onAction; + }, [onAction]); + + // Per-instance session. Created once; mutated as messages arrive. + const sessionRef = useRef(null); + sessionRef.current ??= { + processor: new MessageProcessor(), + resources: new Map(), + activeMessageId: null, + processedCount: 0, + }; + const session = sessionRef.current; + + // Counter used to force a re-render whenever the processor emits an + // update (new beginRendering, surfaceUpdate that mutated the active + // surface, deleteSurface). The processor's per-resource `onUpdate` + // already handles fine-grained updates inside the active surface; + // this is just for the activeResource pointer + first paint. + const [, forceUpdate] = useReducer((n: number) => n + 1, 0); + + // Subscribe to the developer's raw buffer. + const messages = useSyncExternalStore( + messageStore.subscribe, + messageStore.getSnapshot, + messageStore.getSnapshot, + ); + + // One-time wiring of processor → resource bookkeeping + onAction + // dispatch. The processor instance itself is stable across renders. + + useEffect(() => { + const proc = session.processor; + + const offUpdate = proc.onUpdate((data) => { + const { type, surfaceId, messageId } = data as { + type: string; + surfaceId: string; + messageId: string; + targetId?: string; + }; + const surface = proc.getOrCreateSurface(surfaceId); + + if (type === 'beginRendering') { + let resource = session.resources.get(messageId); + if (!resource) { + resource = createResource(messageId); + session.resources.set(messageId, resource); + } + resource.complete({ type: 'beginRendering', surfaceId, surface }); + session.activeMessageId = messageId; + forceUpdate(); + } else if (type === 'surfaceUpdate') { + const updates = + (data as { updates?: ReadonlyArray<{ id?: string }> }).updates ?? []; + for (const update of updates) { + if (!update.id) continue; + const r = surface.resources.get(update.id); + r?.complete({ + type: 'surfaceUpdate', + surfaceId, + surface, + component: update as ResourceInfo['component'] & object, + } as ResourceInfo); + } + } else if (type === 'deleteSurface') { + const targetId = (data as { targetId?: string }).targetId + ?? surface.rootComponentId; + if (targetId && surface.resources.has(targetId)) { + surface.resources.get(targetId)!.complete({ + type: 'deleteSurface', + surfaceId, + surface, + }); + } + forceUpdate(); + } + }); + + const offEvent = proc.onEvent(({ message, resolve }) => { + // Empty resolve — there is no "response" channel from the renderer + // back into the protocol. Responses arrive via the buffer. + resolve([]); + if ( + typeof message === 'object' && message !== null + && 'userAction' in message + && (message as { userAction: unknown }).userAction + ) { + const action = + (message as { userAction: UserActionPayload }).userAction; + try { + onActionRef.current?.(action); + } catch (e) { + console.error('[a2ui] onAction handler threw:', e); + } + } + }); + + return () => { + offUpdate(); + offEvent(); + }; + }, [session]); + + // Process any new messages in the buffer. + + useEffect(() => { + if (messages.length === session.processedCount) return; + const slice = messages.slice( + session.processedCount, + ); + session.processedCount = messages.length; + if (slice.length > 0) { + session.processor.processMessages(slice); + } + }, [messages, session]); + + const catalog = useMemo( + () => defineCatalog(catalogs), + [catalogs], + ); + + const activeResource = session.activeMessageId + ? (session.resources.get(session.activeMessageId) ?? null) + : null; + + if (!activeResource) { + return renderEmpty?.() ?? null; + } + + const rendererProps: import('./A2UIRenderer.jsx').A2UIRendererProps = { + resource: activeResource, + }; + if (wrapSurface) rendererProps.wrapSurface = wrapSurface; + if (renderFallback) rendererProps.renderFallback = renderFallback; + if (renderError) rendererProps.renderError = renderError; + + return ( + + + + ); +} + +export const A2UI = memo(A2UIImpl); diff --git a/packages/genui/a2ui/src/react/A2UIProvider.tsx b/packages/genui/a2ui/src/react/A2UIProvider.tsx new file mode 100644 index 0000000000..c96b8e9444 --- /dev/null +++ b/packages/genui/a2ui/src/react/A2UIProvider.tsx @@ -0,0 +1,47 @@ +// 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 { createContext, useMemo } from '@lynx-js/react'; +import type { ReactNode } from '@lynx-js/react'; + +import type { Catalog, CatalogComponent } from '../catalog/defineCatalog.js'; +import { resolveCatalog } from '../catalog/defineCatalog.js'; +import type { MessageProcessor } from '../store/MessageProcessor.js'; + +/** + * The context value `` provides to its catalog-component subtree. + * Internal — neither the context nor the provider is part of the public + * API. Catalog components reach this value via `useAction`, + * `useDataBinding`, etc. + */ +export interface A2UIInternalContext { + processor: MessageProcessor; + catalog: Catalog; + catalogMap: ReadonlyMap; +} + +export const A2UIContext = createContext(null); + +interface ProviderProps { + processor: MessageProcessor; + catalog: Catalog; + children: ReactNode; +} + +/** + * Internal provider mounted by ``. Not exported from the package. + */ +export function A2UIProvider( + props: ProviderProps, +): import('@lynx-js/react').ReactNode { + const { processor, catalog, children } = props; + const value = useMemo( + () => ({ + processor, + catalog, + catalogMap: resolveCatalog(catalog), + }), + [processor, catalog], + ); + return {children}; +} diff --git a/packages/genui/a2ui/src/react/A2UIRenderer.tsx b/packages/genui/a2ui/src/react/A2UIRenderer.tsx index 4dd71310d1..75cb2aa7b3 100644 --- a/packages/genui/a2ui/src/react/A2UIRenderer.tsx +++ b/packages/genui/a2ui/src/react/A2UIRenderer.tsx @@ -1,12 +1,215 @@ // 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. -// Compatibility re-export. The headless renderer refactor exposes the -// renderer under `src/react/`; this barrel keeps the new import path -// stable while the implementation still lives under `core/`. The -// renamed export (`A2UIRenderer`) matches the post-refactor symbol so -// catalog components can adopt the new path now. -export { - A2UIRender as A2UIRenderer, - NodeRenderer, -} from '../core/A2UIRender.jsx'; +import { memo, useEffect, useMemo, useSyncExternalStore } from '@lynx-js/react'; +import type { ReactNode } from '@lynx-js/react'; + +import { useAction } from './useAction.js'; +import { useCatalog } from './useCatalog.js'; +import { useResolvedProps } from './useDataBinding.js'; +import type { ComponentInstance, Resource, Surface } from '../store/types.js'; + +const noop = () => { + /* no-op subscribe disposer */ +}; +const noopSubscribe = (): () => void => noop; +const emptySnapshot = { + status: 'pending' as const, + value: undefined, + error: undefined, +}; +const returnEmptySnapshot = () => emptySnapshot; +const warnedTags = new Set(); + +function DefaultLoading(props: { id: string }) { + const content = `loading ${props.id}...`; + return ( + + {content} + + ); +} + +function buildNodeRecursive( + component: ComponentInstance, + surface: Surface, + catalog: ReadonlyMap) => ReactNode>, + resolvedProps?: Record, + setValue?: (key: string, value: unknown) => void, + sendAction?: (action: Record) => void, +): ReactNode { + const tag = component.component; + const Component = catalog.get(tag); + if (!Component) return null; + + return ( + ) => { + void sendAction?.(a); + }} + dataContextPath={component.dataContextPath} + /> + ); +} + +export interface A2UIRendererProps { + resource: Resource; + renderFallback?: () => ReactNode; + renderError?: (e: unknown) => ReactNode; + /** + * Wrap each top-level surface so consumers can apply theme/wrapper + * className/styles. The default does not wrap — that is intentional, the + * renderer is headless. Lynx-themed shells should pass + * `wrapSurface={(c, ctx) => {c}}`. + */ + wrapSurface?: ( + children: ReactNode, + ctx: { surfaceId: string }, + ) => ReactNode; +} + +function A2UIRendererImpl( + props: A2UIRendererProps, +): import('@lynx-js/react').ReactNode { + const { resource, wrapSurface, renderFallback, renderError } = props; + // Eagerly read context so the renderer fails clearly outside . + useCatalog(); + + const snapshot = useSyncExternalStore( + resource.subscribe, + resource.getSnapshot, + resource.getSnapshot, + ); + const data = snapshot.value; + const status = snapshot.status; + const error = snapshot.error; + + if (status === 'pending' && data === undefined) { + return renderFallback?.() ?? ; + } + + if (status === 'error') { + return renderError?.(error) ?? Error: {String(error)}; + } + + if (!data) return null; + + const dataObj = data as unknown as Record; + const type = dataObj['type'] as string; + const surfaceId = dataObj['surfaceId'] as string; + const surface = dataObj['surface'] as Surface; + const component = dataObj['component'] as ComponentInstance | undefined; + + if (type === 'beginRendering') { + const id = surface.rootComponentId!; + const childResource = surface.resources.get(id); + if (!childResource) return null; + const childProps: A2UIRendererProps = { resource: childResource }; + if (wrapSurface) childProps.wrapSurface = wrapSurface; + if (renderFallback) childProps.renderFallback = renderFallback; + if (renderError) childProps.renderError = renderError; + const inner = ( + + + + ); + return wrapSurface ? wrapSurface(inner, { surfaceId }) : inner; + } + + if (type === 'surfaceUpdate' && component) { + return ; + } + + if (type === 'deleteSurface') { + return null; + } + + return null; +} + +export const A2UIRenderer = memo(A2UIRendererImpl); + +function NodeRendererImpl( + props: { + component: ComponentInstance; + surface: Surface; + }, +): import('@lynx-js/react').ReactNode { + const { component: initialComponent, surface } = props; + const catalog = useCatalog(); + + const resource = surface.resources.get(initialComponent.id!); + + const latest = useSyncExternalStore( + resource ? resource.subscribe : noopSubscribe, + resource ? resource.getSnapshot : returnEmptySnapshot, + resource ? resource.getSnapshot : returnEmptySnapshot, + ); + + const component = + (latest.value as { component?: ComponentInstance } | undefined)?.component + ?? initialComponent; + + useEffect(() => { + const tag = component.component; + if (!catalog.has(tag) && !warnedTags.has(tag)) { + warnedTags.add(tag); + console.warn(`[a2ui] Component "${tag}" is not in the active catalog.`); + } + }, [component.component, catalog]); + + const [resolvedProps, setValue] = useResolvedProps( + component, + surface, + component.dataContextPath, + ); + + const actionProps = useMemo( + () => ({ + id: component.id!, + surfaceId: surface.surfaceId, + dataContext: component.dataContextPath, + }), + [component.id, surface.surfaceId, component.dataContextPath], + ); + const { sendAction } = useAction(actionProps); + + return ( + <> + {buildNodeRecursive( + component, + surface, + catalog as ReadonlyMap< + string, + (props: Record) => ReactNode + >, + resolvedProps, + setValue, + (a: Record) => { + void sendAction(a as unknown as Parameters[0]); + }, + )} + + ); +} + +export const NodeRenderer = NodeRendererImpl; diff --git a/packages/genui/a2ui/src/react/index.ts b/packages/genui/a2ui/src/react/index.ts new file mode 100644 index 0000000000..285641b4de --- /dev/null +++ b/packages/genui/a2ui/src/react/index.ts @@ -0,0 +1,17 @@ +// 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. +// +// Public React surface. `` is the all-in-one component for +// developers without protocol knowledge. The hooks + `NodeRenderer` are +// the contract that custom catalog components plug into. +// +// `A2UIProvider`, `A2UIRenderer`, `A2UIContext`, `useA2UIContext`, and +// `useCatalog` are intentionally NOT exported — they're internal details +// of how `` mounts itself. Custom components don't need them. +export { A2UI } from './A2UI.jsx'; +export type { A2UIProps } from './A2UI.jsx'; +export { NodeRenderer } from './A2UIRenderer.jsx'; +export { useAction } from './useAction.js'; +export type { ActionProps } from './useAction.js'; +export { useDataBinding, useResolvedProps } from './useDataBinding.js'; diff --git a/packages/genui/a2ui/src/react/useA2UIContext.ts b/packages/genui/a2ui/src/react/useA2UIContext.ts new file mode 100644 index 0000000000..2f238992ff --- /dev/null +++ b/packages/genui/a2ui/src/react/useA2UIContext.ts @@ -0,0 +1,23 @@ +// 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 { useContext } from '@lynx-js/react'; + +import { A2UIContext } from './A2UIProvider.jsx'; +import type { A2UIInternalContext } from './A2UIProvider.jsx'; + +/** + * Internal helper used by catalog-component hooks (`useAction`, the + * renderer, …) to reach the ``-owned context. NOT exported from + * the package. + */ +export function useA2UIContext(): A2UIInternalContext { + const ctx = useContext(A2UIContext); + if (!ctx) { + throw new Error( + '[a2ui] Catalog-component hooks must be used inside a tree rendered ' + + 'by ``.', + ); + } + return ctx; +} diff --git a/packages/genui/a2ui/src/react/useAction.ts b/packages/genui/a2ui/src/react/useAction.ts new file mode 100644 index 0000000000..6860da4a17 --- /dev/null +++ b/packages/genui/a2ui/src/react/useAction.ts @@ -0,0 +1,175 @@ +// 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 * as v0_9 from '@a2ui/web_core/v0_9'; + +import { useCallback } from '@lynx-js/react'; + +import { useA2UIContext } from './useA2UIContext.js'; +import type { UserActionPayload } from '../store/types.js'; + +export interface ActionProps { + id: string; + surfaceId: string; + dataContext?: string | undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isDataBinding(value: unknown): value is v0_9.DataBinding { + return !!value && typeof value === 'object' + && 'path' in (value as Record); +} + +function isFunctionCall(value: unknown): value is v0_9.FunctionCall { + return !!value && typeof value === 'object' + && 'call' in (value as Record); +} + +function makeResolvers(processor: { + getOrCreateSurface( + id: string, + ): { store: { getSignal(p: string): { value: unknown } } }; + resolvePath(path: string, ctx?: string): string; +}) { + const resolveFromStore = ( + path: string, + surfaceId: string, + dataContextPath?: string, + ): unknown => { + const surface = processor.getOrCreateSurface(surfaceId); + const store = surface.store; + const resolvedPath = processor.resolvePath(path, dataContextPath); + const signal = store.getSignal(resolvedPath); + const raw = signal.value; + if (!raw) return raw; + try { + return JSON.parse(raw as string); + } catch { + return raw; + } + }; + + const resolveDynamicValue = ( + value: v0_9.DynamicValue, + surfaceId: string, + dataContextPath?: string, + ): unknown => { + if ( + typeof value === 'string' || typeof value === 'number' + || typeof value === 'boolean' + ) { + return value; + } + + if (Array.isArray(value)) { + return value.map((v) => + resolveDynamicValue( + v as v0_9.DynamicValue, + surfaceId, + dataContextPath, + ) + ); + } + + if (isDataBinding(value)) { + return resolveFromStore(value.path, surfaceId, dataContextPath); + } + + if (isFunctionCall(value)) { + return resolveFunctionCall(value, surfaceId, dataContextPath); + } + + return value; + }; + + const resolveFunctionArguments = ( + args: Record | undefined, + surfaceId: string, + dataContextPath?: string, + ): Record | undefined => { + if (!args) return undefined; + const resolved: Record = {}; + for (const [key, val] of Object.entries(args)) { + if (isObject(val) && !isDataBinding(val) && !isFunctionCall(val)) { + resolved[key] = { ...val }; + } else { + resolved[key] = resolveDynamicValue( + val as v0_9.DynamicValue, + surfaceId, + dataContextPath, + ); + } + } + return resolved; + }; + + const resolveFunctionCall = ( + fn: v0_9.FunctionCall, + surfaceId: string, + dataContextPath?: string, + ): Record => ({ + call: fn.call, + args: resolveFunctionArguments(fn.args, surfaceId, dataContextPath), + returnType: fn.returnType, + }); + + return { resolveDynamicValue, resolveFunctionCall }; +} + +export function useAction( + props: ActionProps, +): { sendAction: (action: v0_9.Action) => Promise } { + const { id, surfaceId, dataContext } = props; + const { processor } = useA2UIContext(); + + const sendAction = useCallback( + (action: v0_9.Action) => { + let name = 'unknownAction'; + let context: Record = {}; + const { resolveDynamicValue, resolveFunctionCall } = makeResolvers( + processor, + ); + + if ('event' in action && action.event) { + name = action.event.name; + const ctx = action.event.context as Record | undefined; + if (ctx) { + const resolvedContext: Record = {}; + for (const [key, value] of Object.entries(ctx)) { + resolvedContext[key] = resolveDynamicValue( + value as v0_9.DynamicValue, + surfaceId, + dataContext, + ); + } + context = resolvedContext; + } + } else if ('functionCall' in action && action.functionCall) { + const fn = action.functionCall; + name = fn.call; + context = { + functionCall: resolveFunctionCall(fn, surfaceId, dataContext), + }; + } + + const userAction: UserActionPayload = { + name, + surfaceId, + sourceComponentId: id, + timestamp: new Date().toISOString(), + context, + }; + + // Dispatch through the processor — `` listens via + // `processor.onEvent` and forwards the action to its `onAction` + // prop, which the developer wires to their agent. + return processor.dispatch({ userAction }); + }, + [id, surfaceId, dataContext, processor], + ); + + return { sendAction }; +} diff --git a/packages/genui/a2ui/src/react/useCatalog.ts b/packages/genui/a2ui/src/react/useCatalog.ts new file mode 100644 index 0000000000..bac97ce9b6 --- /dev/null +++ b/packages/genui/a2ui/src/react/useCatalog.ts @@ -0,0 +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 { useA2UIContext } from './useA2UIContext.js'; +import type { CatalogComponent } from '../catalog/defineCatalog.js'; + +/** + * Internal hook — returns the resolved name → component map. Used by the + * renderer and exposed for advanced custom components that want to peek + * at the active catalog. + */ +export function useCatalog(): ReadonlyMap { + return useA2UIContext().catalogMap; +} diff --git a/packages/genui/a2ui/src/react/useDataBinding.ts b/packages/genui/a2ui/src/react/useDataBinding.ts index 5c7b1c3900..f510dd56e3 100644 --- a/packages/genui/a2ui/src/react/useDataBinding.ts +++ b/packages/genui/a2ui/src/react/useDataBinding.ts @@ -1,5 +1,217 @@ // 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. -// Compatibility re-export. See `react/A2UIRenderer.tsx` for context. -export * from '../core/useDataBinding.js'; +import { effect } from '@preact/signals'; +import type { Signal } from '@preact/signals'; + +import { + useCallback, + useMemo, + useRef, + useSyncExternalStore, +} from '@lynx-js/react'; + +import type { Surface } from '../store/types.js'; + +const noop = () => { + /* no-op subscribe disposer */ +}; +const noopSubscribe = (): () => void => noop; + +function subscribeToSignal( + signal: Signal | undefined, +): (cb: () => void) => () => void { + if (!signal) return noopSubscribe; + return (cb: () => void) => + effect(() => { + void signal.value; + cb(); + }); +} + +export function useDataBinding( + dynamicValue: unknown, + surface: Surface | undefined, + dataContextPath?: string, + fallbackValue?: T, +): [T | undefined, (newValue: T) => void, string | undefined] { + let path: string | undefined; + let initialValue: T | undefined; + + if ( + typeof dynamicValue === 'string' + || typeof dynamicValue === 'number' + || typeof dynamicValue === 'boolean' + ) { + // Preserve primitive type. A static `false` must stay falsy and + // numeric props must stay numeric — stringifying breaks consumers that + // expect `boolean | number` (e.g., `if (props.disabled)`). + initialValue = dynamicValue as T; + } else if ( + dynamicValue + && typeof dynamicValue === 'object' + && 'path' in dynamicValue + ) { + path = (dynamicValue as Record)['path'] as + | string + | undefined; + } + + if (path && !path.startsWith('/')) { + if (dataContextPath) { + path = `${dataContextPath}/${path}`; + } else { + path = `/${path}`; + } + } + + const signal = surface?.store && path + ? surface.store.getSignal( + path, + typeof initialValue === 'string' ? initialValue : undefined, + ) + : undefined; + + const subscribe = useMemo(() => subscribeToSignal(signal), [signal]); + const getSnapshot = useCallback( + () => signal?.value as T | undefined, + [signal], + ); + + const signalValue = useSyncExternalStore( + subscribe, + getSnapshot, + getSnapshot, + ); + + const currentValue = path + ? (signalValue ?? initialValue) + : (initialValue ?? fallbackValue); + + const setValue = useCallback( + (newValue: T) => { + if (path && surface?.store) { + surface.store.update(path, newValue); + } + }, + [path, surface], + ); + + return [currentValue, setValue, path]; +} + +function isDataBinding(prop: unknown): boolean { + return Boolean( + prop + && typeof prop === 'object' + && 'path' in prop + && !('componentId' in prop), + ); +} + +function resolveProperties( + properties: Record, + surface: Surface | undefined, + dataContextPath?: string, +) { + if (!properties) return properties; + const result: Record = {}; + for (const key in properties) { + const prop = properties[key]; + if (isDataBinding(prop)) { + let path = (prop as Record)['path'] as + | string + | undefined; + if (path && typeof path === 'string' && !path.startsWith('/')) { + path = dataContextPath ? `${dataContextPath}/${path}` : `/${path}`; + } + + if (path && surface?.store) { + const signal = surface.store.getSignal(path); + result[key] = signal.value; + } else { + result[key] = undefined; + } + } else if ( + typeof prop === 'string' + || typeof prop === 'number' + || typeof prop === 'boolean' + ) { + result[key] = prop; + } else { + result[key] = prop; + } + } + return result; +} + +function shallowEqual( + a: Record, + b: Record, +): boolean { + if (a === b) return true; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (const k of keysA) { + if (a[k] !== b[k]) return false; + } + return true; +} + +export function useResolvedProps( + properties: Record, + surface: Surface | undefined, + dataContextPath?: string, +): readonly [Record, (key: string, value: unknown) => void] { + const cacheRef = useRef | null>(null); + + const computeSnapshot = useCallback(() => { + const next = resolveProperties(properties, surface, dataContextPath); + if (cacheRef.current && shallowEqual(cacheRef.current, next)) { + return cacheRef.current; + } + cacheRef.current = next; + return next; + }, [properties, surface, dataContextPath]); + + const subscribe = useCallback( + (cb: () => void) => { + if (!surface?.store) return noop; + return effect(() => { + resolveProperties(properties, surface, dataContextPath); + cb(); + }); + }, + [properties, surface, dataContextPath], + ); + + const resolved = useSyncExternalStore( + subscribe, + computeSnapshot, + computeSnapshot, + ); + + const setValue = useCallback( + (key: string, value: unknown) => { + const prop = properties?.[key]; + if (isDataBinding(prop)) { + let path = (prop as Record)['path'] as + | string + | undefined; + if (path && surface?.store) { + if (!path.startsWith('/')) { + path = dataContextPath ? `${dataContextPath}/${path}` : `/${path}`; + } + surface.store.update(path, value); + } + } + }, + [properties, surface, dataContextPath], + ); + + return [resolved, setValue] as readonly [ + Record, + (key: string, value: unknown) => void, + ]; +} diff --git a/packages/genui/a2ui/src/core/processor.ts b/packages/genui/a2ui/src/store/MessageProcessor.ts similarity index 86% rename from packages/genui/a2ui/src/core/processor.ts rename to packages/genui/a2ui/src/store/MessageProcessor.ts index 883d9860fe..b0bb2dfa14 100644 --- a/packages/genui/a2ui/src/core/processor.ts +++ b/packages/genui/a2ui/src/store/MessageProcessor.ts @@ -3,13 +3,13 @@ // LICENSE file in the root directory of this source tree. import type * as v0_9 from '@a2ui/web_core/v0_9'; +import { createResource } from './Resource.js'; +import { SignalStore } from './SignalStore.js'; import type { ComponentInstance, ServerToClientMessage, Surface, } from './types.js'; -import { createResource } from '../utils/createResource.js'; -import { SignalStore } from '../utils/SignalStore.js'; export interface A2UIEvent { message: Record; @@ -22,33 +22,39 @@ function isObject(value: unknown): value is Record { export class MessageProcessor { surfaces: Map; - private listener: ((event: A2UIEvent) => void) | null = null; - private updateListener: ((data: Record) => void) | null = - null; + private eventListeners: Set<(event: A2UIEvent) => void> = new Set(); + private updateListeners: Set<(data: Record) => void> = + new Set(); constructor() { this.surfaces = new Map(); } - onUpdate(callback: (data: Record) => void): void { - this.updateListener = callback; + onUpdate(callback: (data: Record) => void): () => void { + this.updateListeners.add(callback); + return () => { + this.updateListeners.delete(callback); + }; + } + + private emitUpdate(data: Record): void { + for (const cb of this.updateListeners) cb(data); } dispatch(message: Record): Promise { return new Promise((resolve) => { - if (this.listener) { - this.listener({ message, resolve }); - } else { - console.warn('No host listener attached!'); + if (this.eventListeners.size === 0) { resolve([]); + return; } + for (const cb of this.eventListeners) cb({ message, resolve }); }); } onEvent(callback: (event: A2UIEvent) => void): () => void { - this.listener = callback; + this.eventListeners.add(callback); return () => { - this.listener = null; + this.eventListeners.delete(callback); }; } @@ -127,7 +133,6 @@ export class MessageProcessor { surface.resources.set(newId, createResource(newId)); } - // Recursively clone the subtree below this component. const childIds: string[] = []; const anyCloned = cloned as unknown as Record; @@ -228,7 +233,6 @@ export class MessageProcessor { return; } - // Primitive at the base path. updates.push({ path: normalizedBase, value: String(value) }); } @@ -292,8 +296,6 @@ export class MessageProcessor { }; } - // this.resolveComponentPaths(instance, dataContextPath); - surface.components.set(instance.id!, instance); updatesMap.set(instance.id!, instance); @@ -302,12 +304,10 @@ export class MessageProcessor { } } - // Determine the root component if not already set. if (!surface.rootComponentId) { if (surface.components.has('root')) { surface.rootComponentId = 'root'; } else if (updatesMap.size > 0) { - // Fallback: use the first updated component as root if not specified surface.rootComponentId = updatesMap.keys().next().value ?? null; } @@ -318,20 +318,23 @@ export class MessageProcessor { createResource(surface.rootComponentId), ); } - // Signal that rendering can begin for this surface. - if (this.updateListener) { - this.updateListener({ - type: 'beginRendering', - surfaceId, - messageId: (message as { messageId?: string }).messageId, - }); - } + // Fall back to a surface-derived id so consumers that key + // resources by `messageId` still get a non-empty key when the + // protocol message lacks one (the v0.9 stream does not require + // `messageId` on every message). + const messageId = (message as { messageId?: string }).messageId + ?? `surface:${surfaceId}`; + this.emitUpdate({ + type: 'beginRendering', + surfaceId, + messageId, + }); } } const updates = Array.from(updatesMap.values()); - if (updates.length > 0 && this.updateListener) { - this.updateListener({ + if (updates.length > 0) { + this.emitUpdate({ type: 'surfaceUpdate', updates, surfaceId, @@ -353,7 +356,6 @@ export class MessageProcessor { const basePath = path && path !== '' ? path : '/'; this.flattenValue(value, basePath, updates); } else if (path) { - // Deletion semantics: we simply clear the value at the path. updates.push({ path, value: '' }); } @@ -361,7 +363,6 @@ export class MessageProcessor { surface.store.updateBatch(updates); } - // Re-expand any templated containers based on the updated data model. const componentUpdates: ComponentInstance[] = []; for (const component of surface.components.values()) { @@ -424,8 +425,8 @@ export class MessageProcessor { } } - if (componentUpdates.length > 0 && this.updateListener) { - this.updateListener({ + if (componentUpdates.length > 0) { + this.emitUpdate({ type: 'surfaceUpdate', updates: componentUpdates, surfaceId, @@ -437,20 +438,15 @@ export class MessageProcessor { const { surfaceId } = message.deleteSurface; const surface = this.surfaces.get(surfaceId); - if (this.updateListener) { - this.updateListener({ - type: 'deleteSurface', - surfaceId, - targetId: surface?.rootComponentId ?? surfaceId, - messageId: (message as { messageId?: string }).messageId, - }); - } + this.emitUpdate({ + type: 'deleteSurface', + surfaceId, + targetId: surface?.rootComponentId ?? surfaceId, + messageId: (message as { messageId?: string }).messageId, + }); - // Optionally clear local state for this surface. this.surfaces.delete(surfaceId); } } } } - -export const processor: MessageProcessor = new MessageProcessor(); diff --git a/packages/genui/a2ui/src/store/MessageStore.ts b/packages/genui/a2ui/src/store/MessageStore.ts new file mode 100644 index 0000000000..70b1accaf7 --- /dev/null +++ b/packages/genui/a2ui/src/store/MessageStore.ts @@ -0,0 +1,82 @@ +// 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 { ServerToClientMessage } from './types.js'; + +/** + * A pure append-only buffer of raw protocol messages produced by the + * developer's IO module. The store knows nothing about the v0.9 protocol — + * it does not parse, process, or interpret messages. It only: + * + * 1. Stores them in arrival order. + * 2. Notifies subscribers when new ones land. + * 3. Hands the current snapshot back via `getSnapshot()` (referentially + * stable between mutations — required by `useSyncExternalStore`). + * + * Protocol-aware processing — surfaces, signals, resources, action + * dispatch — is the responsibility of `` (the renderer component). + * Developers who don't want to learn the protocol should use ``; + * developers who do can run their own `MessageProcessor` against the + * snapshot directly. + */ +export interface MessageStore { + /** `useSyncExternalStore` subscribe contract. */ + readonly subscribe: (cb: () => void) => () => void; + /** `useSyncExternalStore` getSnapshot contract — stable between pushes. */ + readonly getSnapshot: () => readonly ServerToClientMessage[]; + /** + * Append one or more raw messages to the buffer. Notifies subscribers + * once per call (batches a single array argument into a single notify). + */ + push( + message: ServerToClientMessage | readonly ServerToClientMessage[], + ): void; + /** Reset the buffer. Notifies subscribers. */ + clear(): void; +} + +export interface MessageStoreOptions { + /** + * Optional initial buffer contents. Useful when rehydrating a previous + * agent response or replaying a fixture stream. + */ + initialMessages?: readonly ServerToClientMessage[]; +} + +export function createMessageStore( + options: MessageStoreOptions = {}, +): MessageStore { + let snapshot: readonly ServerToClientMessage[] = options.initialMessages + ? Object.freeze([...options.initialMessages]) + : Object.freeze([]); + const subscribers = new Set<() => void>(); + + const notify = () => { + for (const cb of subscribers) cb(); + }; + + return { + subscribe(cb) { + subscribers.add(cb); + return () => { + subscribers.delete(cb); + }; + }, + getSnapshot() { + return snapshot; + }, + push(message) { + const incoming = Array.isArray(message) + ? (message as ServerToClientMessage[]) + : [message as ServerToClientMessage]; + if (incoming.length === 0) return; + snapshot = Object.freeze([...snapshot, ...incoming]); + notify(); + }, + clear() { + if (snapshot.length === 0) return; + snapshot = Object.freeze([]); + notify(); + }, + }; +} diff --git a/packages/genui/a2ui/src/store/Resource.ts b/packages/genui/a2ui/src/store/Resource.ts new file mode 100644 index 0000000000..0ab59693cb --- /dev/null +++ b/packages/genui/a2ui/src/store/Resource.ts @@ -0,0 +1,127 @@ +// 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. +export type ResourceStatus = 'pending' | 'success' | 'error'; + +/** + * Immutable, transition-keyed snapshot returned by + * {@link Resource.getSnapshot}. The object reference changes on every + * `complete()` / `fail()` call so `useSyncExternalStore` re-renders even + * for `pending → error` transitions where `value` stays `undefined`. + */ +export interface ResourceSnapshot { + status: ResourceStatus; + value: T | undefined; + error: unknown; +} + +export interface Resource { + id: string; + readonly completed: boolean; + readonly status: ResourceStatus; + readonly value: T | undefined; + readonly error: unknown; + /** + * @deprecated Read `value` / `status` directly. Retained for backwards + * compatibility; in the success state it returns the value, otherwise it + * returns undefined. It does not throw. + */ + read: () => T | undefined; + complete: (result: T) => void; + fail: (err: unknown) => void; + /** + * @deprecated Use `subscribe`. Behaves identically. + */ + onUpdate: (callback: (result: T) => void) => () => void; + /** + * Subscribe to state changes. The callback is invoked after every + * `complete()` or `fail()` (the same value may be re-published when the + * processor re-emits an update for the same id). Returns a disposer. + */ + subscribe: (callback: () => void) => () => void; + /** + * Synchronous read of the current snapshot, suitable for + * `useSyncExternalStore`. The returned object reference changes on every + * transition. + */ + getSnapshot: () => ResourceSnapshot; + promise: Promise; +} + +export function createResource(id: string): Resource { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + // Swallow unhandled rejection — listeners may attach later. + promise.catch(() => { + /* deferred: callers attach via subscribe / onUpdate */ + }); + + let status: ResourceStatus = 'pending'; + let value: T | undefined; + let error: unknown; + // Snapshot object whose reference changes on every transition so that + // `useSyncExternalStore`'s `Object.is` bail-out doesn't miss + // `pending → error` (where `value` stays `undefined`). + let snapshot: ResourceSnapshot = { status, value, error }; + const listeners = new Set<() => void>(); + + const notify = () => { + snapshot = { status, value, error }; + for (const fn of listeners) fn(); + }; + + return { + id, + promise, + get completed() { + return status === 'success'; + }, + get status() { + return status; + }, + get value() { + return value; + }, + get error() { + return error; + }, + read: () => value, + complete: (res: T) => { + // `error` is terminal — refuse error → success transitions so + // observers don't see `{ status: 'success', error: }`. + if (status === 'error') return; + const wasPending = status === 'pending'; + value = res; + status = 'success'; + if (wasPending) resolve(res); + notify(); + }, + fail: (err: unknown) => { + if (status !== 'pending') return; + error = err; + status = 'error'; + reject(err); + notify(); + }, + onUpdate: (fn: (data: T) => void) => { + const wrapped = () => { + if (value !== undefined) fn(value); + }; + listeners.add(wrapped); + return () => { + listeners.delete(wrapped); + }; + }, + subscribe: (fn: () => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + getSnapshot: () => snapshot, + }; +} diff --git a/packages/genui/a2ui/src/utils/SignalStore.ts b/packages/genui/a2ui/src/store/SignalStore.ts similarity index 100% rename from packages/genui/a2ui/src/utils/SignalStore.ts rename to packages/genui/a2ui/src/store/SignalStore.ts diff --git a/packages/genui/a2ui/src/store/index.ts b/packages/genui/a2ui/src/store/index.ts new file mode 100644 index 0000000000..b8c7d6882b --- /dev/null +++ b/packages/genui/a2ui/src/store/index.ts @@ -0,0 +1,27 @@ +// 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. +export { createMessageStore } from './MessageStore.js'; +export type { MessageStore, MessageStoreOptions } from './MessageStore.js'; +export { MessageProcessor } from './MessageProcessor.js'; +export type { A2UIEvent } from './MessageProcessor.js'; +export { createResource } from './Resource.js'; +export type { Resource as RawResource, ResourceStatus } from './Resource.js'; +export { SignalStore } from './SignalStore.js'; +export { + createFallbackMessagesFromPlainText, + createTextCardMessages, + normalizePayloadToMessages, + prepareMessagesForProcessing, +} from './payloadNormalizer.js'; +export type { + A2UIClientEventMessage, + ComponentInstance, + GenericComponentProps, + Resource, + ResourceInfo, + ServerToClientMessage, + Surface, + SurfaceId, + UserActionPayload, +} from './types.js'; diff --git a/packages/genui/a2ui/src/store/payloadNormalizer.ts b/packages/genui/a2ui/src/store/payloadNormalizer.ts new file mode 100644 index 0000000000..b5fbddd664 --- /dev/null +++ b/packages/genui/a2ui/src/store/payloadNormalizer.ts @@ -0,0 +1,211 @@ +// 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 { ServerToClientMessage } from './types.js'; + +function randomId(prefix = '') { + return prefix + Date.now().toString(36) + + Math.random().toString(36).slice(2, 10); +} + +/** + * Build a single-Text fallback message stream from a plain string. Used by + * transports / `Session.ingest` callers that receive free-form text from + * the agent instead of structured protocol messages. + */ +export function createFallbackMessagesFromPlainText( + text: string, +): ServerToClientMessage[] { + const surfaceId = 'default'; + const rootId = 'root-text'; + + return [ + { + createSurface: { + surfaceId, + catalogId: 'inline-text', + }, + }, + { + updateComponents: { + surfaceId, + components: [ + { + id: rootId, + component: 'Text', + text, + }, + ], + }, + }, + ] as ServerToClientMessage[]; +} + +/** + * Build a Card-wrapped Text fallback message stream from a plain string. + * Used when the agent emits text payloads with `kind: 'text'`. + */ +export function createTextCardMessages( + text: string, +): ServerToClientMessage[] { + const surfaceId = randomId('text_surface_'); + const rootId = 'root'; + const textId = 'text'; + + return [ + { + createSurface: { + surfaceId, + catalogId: 'inline-text', + }, + }, + { + updateComponents: { + surfaceId, + components: [ + { + id: rootId, + component: 'Card', + child: textId, + }, + { + id: textId, + component: 'Text', + text, + variant: 'body', + }, + ], + }, + }, + ] as ServerToClientMessage[]; +} + +/** + * Normalize an arbitrary payload (string, array, object) into a flat list of + * `ServerToClientMessage` records. Pass-through for already-structured + * messages; falls back to wrapping plain text in a Text/Card surface. + */ +export function normalizePayloadToMessages( + payload: unknown, +): ServerToClientMessage[] { + const messages: ServerToClientMessage[] = []; + + const add = (value: unknown) => { + // Treat only `null` / `undefined` as no-ops — `0`, `false`, `''` are + // legitimate fallback payloads upstream callers may produce. + if (value === null || value === undefined) return; + if (Array.isArray(value)) { + for (const item of value) add(item); + } else { + messages.push(value as ServerToClientMessage); + } + }; + + const handle = (value: unknown): void => { + if (value === null || value === undefined) return; + + if (Array.isArray(value)) { + for (const item of value) handle(item); + return; + } + + if (typeof value === 'string') { + add(createFallbackMessagesFromPlainText(value)); + return; + } + + if ( + typeof value === 'number' || typeof value === 'boolean' + || typeof value === 'bigint' + ) { + // Coerce non-string primitives so payloads like `0` or `false` + // surface as visible fallback messages instead of disappearing. + add(createFallbackMessagesFromPlainText(String(value))); + return; + } + + if (typeof value === 'object') { + const v = value as Record; + + if ( + v['createSurface'] || v['updateComponents'] || v['updateDataModel'] + || v['deleteSurface'] + ) { + add(v); + return; + } + + if ('kind' in v && 'data' in v) { + if (v['kind'] === 'data') { + handle(v['data']); + } else if (v['kind'] === 'text') { + add( + createTextCardMessages( + typeof v['data'] === 'string' ? v['data'] : String(v['data']), + ), + ); + } + return; + } + + if (Array.isArray(v['messages'])) { + handle(v['messages']); + return; + } + + add(createFallbackMessagesFromPlainText(JSON.stringify(v))); + return; + } + }; + + handle(payload); + return messages; +} + +/** + * Tag messages with the given messageId and report whether any of them + * carries a non-empty `updateComponents`. Also dedupes `createSurface` + * messages against the set of currently-active surfaces. + */ +export function prepareMessagesForProcessing( + rawMessages: ServerToClientMessage[], + messageId: string, + activeSurfaceIds: Set, +): { messages: ServerToClientMessage[]; hasComponentUpdate: boolean } { + let hasComponentUpdate = false; + const messages = rawMessages.filter((msg) => { + const deletedSurfaceId = (msg as { deleteSurface?: { surfaceId?: string } }) + .deleteSurface?.surfaceId; + if (typeof deletedSurfaceId === 'string') { + activeSurfaceIds.delete(deletedSurfaceId); + } + + const createdSurfaceId = (msg as { createSurface?: { surfaceId?: string } }) + .createSurface?.surfaceId; + if (typeof createdSurfaceId === 'string') { + if (activeSurfaceIds.has(createdSurfaceId)) { + return false; + } + activeSurfaceIds.add(createdSurfaceId); + } + + if ( + ((msg as { updateComponents?: { components?: unknown[] } }) + .updateComponents + && Array.isArray( + (msg as { updateComponents?: { components?: unknown[] } }) + .updateComponents?.components, + ) + && (((msg as { updateComponents?: { components: unknown[] } }) + .updateComponents?.components ?? []).length > 0)) + ) { + hasComponentUpdate = true; + } + + msg.messageId ??= messageId; + + return true; + }); + + return { messages, hasComponentUpdate }; +} diff --git a/packages/genui/a2ui/src/store/types.ts b/packages/genui/a2ui/src/store/types.ts index c4b7efe110..9adce8aa90 100644 --- a/packages/genui/a2ui/src/store/types.ts +++ b/packages/genui/a2ui/src/store/types.ts @@ -1,7 +1,84 @@ // 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. -// Compatibility re-export. The headless renderer refactor moves these -// types to `src/store/`; this barrel keeps the new import path stable -// while the implementation still lives under `core/`. -export * from '../core/types.js'; +import type * as v0_9 from '@a2ui/web_core/v0_9'; + +import type { Resource as GenericResource } from './Resource.js'; +import type { SignalStore } from './SignalStore.js'; + +export type SurfaceId = string; + +export type ComponentInstance = v0_9.AnyComponent & { + /** + * Absolute data context path for this component when created via a template. + * Used for resolving relative bindings inside the component tree. + */ + dataContextPath?: string; + /** + * Internal metadata for templated containers so we can re-expand on + * data model updates. + */ + __template?: { + componentId: v0_9.ComponentId; + path: string; + }; +}; + +export interface Surface { + surfaceId: SurfaceId; + catalogId?: string; + theme?: Readonly>; + sendDataModel?: boolean | undefined; + /** id of the root component for this surface (must be 'root'). */ + rootComponentId?: string | null; + components: Map; + resources: Map; + store: SignalStore; +} + +export interface ResourceInfo { + /** + * Internal event type emitted by the processor. + */ + type: 'beginRendering' | 'surfaceUpdate' | 'deleteSurface'; + surfaceId: string; + surface: Surface; + component?: ComponentInstance; +} + +export type Resource = GenericResource; + +export type ServerToClientMessage = v0_9.A2uiMessage & { + /** + * Message id injected by the client. + */ + messageId?: string; +}; + +export interface UserActionPayload { + name: string; + surfaceId: string; + sourceComponentId: string; + timestamp: string; // ISO 8601 + context: Record; +} + +export type A2UIClientEventMessage = + | string + | { + text?: string; + sessionId?: string; + } + | { + userAction: UserActionPayload; + sessionId?: string; + }; + +export interface GenericComponentProps { + id?: string; + surface: Surface; + setValue?: (key: string, value: unknown) => void; + sendAction?: (action: Record) => void; + dataContextPath?: string; + [key: string]: unknown; +} diff --git a/packages/genui/a2ui/src/utils/ComponentRegistry.ts b/packages/genui/a2ui/src/utils/ComponentRegistry.ts deleted file mode 100644 index b16640d543..0000000000 --- a/packages/genui/a2ui/src/utils/ComponentRegistry.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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. -export class ComponentRegistry { - private components = new Map(); - - register(tag: string, component: T) { - this.components.set(tag, component); - } - - get(tag: string): T | undefined { - return this.components.get(tag); - } - - has(tag: string): boolean { - return this.components.has(tag); - } -} diff --git a/packages/genui/a2ui/src/utils/createResource.ts b/packages/genui/a2ui/src/utils/createResource.ts deleted file mode 100644 index c0f8d0eec3..0000000000 --- a/packages/genui/a2ui/src/utils/createResource.ts +++ /dev/null @@ -1,68 +0,0 @@ -// 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. -export interface Resource { - id: string; - readonly completed: boolean; - read: () => T; - complete: (result: T) => void; - onUpdate: (callback: (result: T) => void) => () => void; - promise: Promise; -} - -export function createResource(id: string): Resource { - let resolve: (value: T) => void; - const mockFn = new Promise((_resolve) => { - resolve = _resolve; - }); - let status: 'pending' | 'success' | 'error' = 'pending'; - let result: T; - let error: unknown; - let listeners: ((data: T) => void)[] = []; - - const promise = mockFn - .then((res) => { - status = 'success'; - result = res; - return res; - }) - .catch((err) => { - status = 'error'; - error = err; - throw err; - }); - - return { - id, - promise, - get completed() { - return status === 'success'; - }, - read: () => { - switch (status) { - case 'pending': - throw promise; - case 'error': - throw error; - case 'success': - return result; - default: - throw new Error('Unknown status'); - } - }, - complete: (res: T) => { - if (status === 'pending') { - resolve(res); - } else { - result = res; - listeners.forEach((fn) => fn(res)); - } - }, - onUpdate: (fn: (data: T) => void) => { - listeners.push(fn); - return () => { - listeners = listeners.filter((l) => l !== fn); - }; - }, - }; -} diff --git a/packages/genui/a2ui/src/utils/index.ts b/packages/genui/a2ui/src/utils/index.ts deleted file mode 100644 index 18876cfe32..0000000000 --- a/packages/genui/a2ui/src/utils/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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. -export * from './ComponentRegistry.js'; -export * from './createResource.js'; -export * from './SignalStore.js'; diff --git a/packages/genui/a2ui/test/catalog.test.ts b/packages/genui/a2ui/test/catalog.test.ts new file mode 100644 index 0000000000..d57bfdc173 --- /dev/null +++ b/packages/genui/a2ui/test/catalog.test.ts @@ -0,0 +1,137 @@ +// 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 { describe, expect, test } from '@rstest/core'; + +import { + defineCatalog, + mergeCatalogs, + resolveCatalog, + serializeCatalog, +} from '../src/catalog/defineCatalog.js'; +import type { + CatalogComponent, + CatalogManifest, +} from '../src/catalog/defineCatalog.js'; + +function namedStub(name: string): CatalogComponent { + // Use Object.defineProperty to give a stable function name across + // engines (function expressions can otherwise inherit the variable name). + const fn = (() => null) as unknown as CatalogComponent; + Object.defineProperty(fn, 'name', { value: name }); + return fn; +} + +const Text = namedStub('Text'); +const Button = namedStub('Button'); + +const TEXT_MANIFEST: CatalogManifest = { + Text: { type: 'object', properties: { text: { type: 'string' } } }, +}; +const BUTTON_MANIFEST: CatalogManifest = { + Button: { type: 'object', properties: { child: { type: 'string' } } }, +}; + +describe('defineCatalog', () => { + test('bare component derives name from displayName ?? function name', () => { + const cat = defineCatalog([Text, Button]); + expect(cat.map((e) => e.name)).toEqual(['Text', 'Button']); + expect(cat[0]!.component).toBe(Text); + expect(cat[0]!.schema).toBeUndefined(); + }); + + test('tuple form derives name + schema from manifest', () => { + const cat = defineCatalog([ + [Text, TEXT_MANIFEST], + [Button, BUTTON_MANIFEST], + ]); + expect(cat[0]!.name).toBe('Text'); + expect(cat[0]!.schema).toEqual(TEXT_MANIFEST.Text); + expect(cat[1]!.name).toBe('Button'); + expect(cat[1]!.schema).toEqual(BUTTON_MANIFEST.Button); + }); + + test('mixes bare and tuple inputs in one call', () => { + const cat = defineCatalog([Text, [Button, BUTTON_MANIFEST]]); + expect(cat[0]!.schema).toBeUndefined(); + expect(cat[1]!.schema).toEqual(BUTTON_MANIFEST.Button); + }); + + test('passes through already-resolved entries', () => { + const inner = defineCatalog([[Text, TEXT_MANIFEST]]); + const outer = defineCatalog(inner); + expect(outer).toEqual(inner); + }); + + test('rejects duplicate names', () => { + expect(() => defineCatalog([Text, Text])).toThrow(/Duplicate/); + }); + + test('rejects components with no derivable name', () => { + const anonymous = (() => null) as unknown as CatalogComponent; + Object.defineProperty(anonymous, 'name', { value: '' }); + expect(() => defineCatalog([anonymous])).toThrow(/displayName/); + }); + + test('respects displayName when set', () => { + const fn = (() => null) as unknown as CatalogComponent; + Object.defineProperty(fn, 'name', { value: 'Mangled' }); + (fn as { displayName?: string }).displayName = 'Custom'; + const cat = defineCatalog([fn]); + expect(cat[0]!.name).toBe('Custom'); + }); +}); + +describe('mergeCatalogs', () => { + test('last-write-wins on duplicate names', () => { + const a = defineCatalog([[Text, TEXT_MANIFEST]]); + const Override = namedStub('Text'); + const b = defineCatalog([Override]); + const merged = mergeCatalogs(a, b); + const text = merged.find((e) => e.name === 'Text')!; + expect(text.component).toBe(Override); + expect(text.schema).toBeUndefined(); + }); +}); + +describe('resolveCatalog', () => { + test('returns a name → component map', () => { + const cat = defineCatalog([Text, Button]); + const map = resolveCatalog(cat); + expect(map.get('Text')).toBe(Text); + expect(map.get('Button')).toBe(Button); + }); +}); + +describe('serializeCatalog', () => { + test('emits version + components, omitting schema when absent', () => { + const cat = defineCatalog([Text]); + const out = serializeCatalog(cat); + expect(out.version).toBe('0.9'); + expect(out.components).toEqual([{ name: 'Text' }]); + }); + + test('attaches schema when present', () => { + const cat = defineCatalog([[Text, TEXT_MANIFEST]]); + const out = serializeCatalog(cat); + expect(out.components[0]).toEqual({ + name: 'Text', + schema: TEXT_MANIFEST.Text, + }); + }); +}); + +describe('user composes their own all-builtins catalog', () => { + // Snapshot of the recipe documented in + // packages/genui/a2ui/src/catalog/README.md. If you change this list, + // update the README too. + test('paste-able recipe builds the expected manifest', () => { + const all = defineCatalog([ + [Text, TEXT_MANIFEST], + [Button, BUTTON_MANIFEST], + ]); + const manifest = serializeCatalog(all); + expect(manifest.components.map((c) => c.name)).toEqual(['Text', 'Button']); + expect(manifest.components.every((c) => c.schema)).toBe(true); + }); +}); diff --git a/packages/genui/a2ui/test/createResource.test.ts b/packages/genui/a2ui/test/createResource.test.ts new file mode 100644 index 0000000000..63b714e085 --- /dev/null +++ b/packages/genui/a2ui/test/createResource.test.ts @@ -0,0 +1,118 @@ +// 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 { describe, expect, test } from '@rstest/core'; + +import { createResource } from '../src/store/Resource.js'; + +describe('createResource', () => { + test('starts in pending status with undefined value', () => { + const r = createResource('a'); + expect(r.status).toBe('pending'); + expect(r.value).toBeUndefined(); + expect(r.completed).toBe(false); + expect(r.getSnapshot()).toEqual({ + status: 'pending', + value: undefined, + error: undefined, + }); + }); + + test('complete moves to success and exposes value via getSnapshot', () => { + const r = createResource('a'); + r.complete('hello'); + expect(r.status).toBe('success'); + expect(r.value).toBe('hello'); + expect(r.completed).toBe(true); + expect(r.getSnapshot()).toEqual({ + status: 'success', + value: 'hello', + error: undefined, + }); + }); + + test('getSnapshot reference changes on every transition', () => { + const r = createResource('a'); + const s1 = r.getSnapshot(); + r.complete('hello'); + const s2 = r.getSnapshot(); + expect(s2).not.toBe(s1); + }); + + test('pending → error transition produces a new snapshot reference', () => { + const r = createResource('a'); + const s1 = r.getSnapshot(); + r.fail(new Error('boom')); + const s2 = r.getSnapshot(); + expect(s2).not.toBe(s1); + expect(s2.status).toBe('error'); + expect((s2.error as Error).message).toBe('boom'); + }); + + test('complete after fail is rejected — error is terminal', () => { + const r = createResource('a'); + r.fail(new Error('boom')); + r.complete('hello'); + expect(r.status).toBe('error'); + expect(r.value).toBeUndefined(); + }); + + test('read does not throw when pending', () => { + const r = createResource('a'); + expect(() => r.read()).not.toThrow(); + expect(r.read()).toBeUndefined(); + }); + + test('subscribe is invoked on every complete and disposers stop firing', () => { + const r = createResource('a'); + let count1 = 0; + let count2 = 0; + const d1 = r.subscribe(() => { + count1++; + }); + r.subscribe(() => { + count2++; + }); + + r.complete('first'); + expect(count1).toBe(1); + expect(count2).toBe(1); + + d1(); + r.complete('second'); + expect(count1).toBe(1); + expect(count2).toBe(2); + }); + + test('promise resolves on first complete', async () => { + const r = createResource('a'); + setTimeout(() => r.complete('x'), 0); + const v = await r.promise; + expect(v).toBe('x'); + }); + + test('fail moves to error and rejects promise', async () => { + const r = createResource('a'); + r.fail(new Error('boom')); + expect(r.status).toBe('error'); + await expect(r.promise).rejects.toThrow('boom'); + }); + + test('subsequent completes update value but only first resolves promise', async () => { + const r = createResource('a'); + r.complete('first'); + r.complete('second'); + expect(r.value).toBe('second'); + const v = await r.promise; + expect(v).toBe('first'); + }); + + test('onUpdate (deprecated) still receives updates', () => { + const r = createResource('a'); + const seen: string[] = []; + r.onUpdate((v) => seen.push(v)); + r.complete('x'); + r.complete('y'); + expect(seen).toEqual(['x', 'y']); + }); +}); diff --git a/packages/genui/a2ui/test/messageStore.test.ts b/packages/genui/a2ui/test/messageStore.test.ts new file mode 100644 index 0000000000..76cdbef20c --- /dev/null +++ b/packages/genui/a2ui/test/messageStore.test.ts @@ -0,0 +1,97 @@ +// 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 { describe, expect, test } from '@rstest/core'; + +import { createMessageStore } from '../src/store/MessageStore.js'; +import type { ServerToClientMessage } from '../src/store/types.js'; + +const A: ServerToClientMessage = { + createSurface: { surfaceId: 's1' }, +} as ServerToClientMessage; +const B: ServerToClientMessage = { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text', text: 'hi' }], + }, +} as ServerToClientMessage; + +describe('createMessageStore', () => { + test('starts empty when no initial messages are provided', () => { + const store = createMessageStore(); + expect(store.getSnapshot()).toHaveLength(0); + }); + + test('seeds from `initialMessages`', () => { + const store = createMessageStore({ initialMessages: [A, B] }); + expect(store.getSnapshot()).toEqual([A, B]); + }); + + test('getSnapshot is referentially stable between mutations', () => { + const store = createMessageStore(); + const a = store.getSnapshot(); + const b = store.getSnapshot(); + expect(a).toBe(b); + }); + + test('push appends a single message and notifies subscribers', () => { + const store = createMessageStore(); + let calls = 0; + store.subscribe(() => calls++); + store.push(A); + expect(store.getSnapshot()).toEqual([A]); + expect(calls).toBe(1); + }); + + test('push appends a batch in one notification', () => { + const store = createMessageStore(); + let calls = 0; + store.subscribe(() => calls++); + store.push([A, B]); + expect(store.getSnapshot()).toEqual([A, B]); + expect(calls).toBe(1); + }); + + test('push of an empty array is a no-op', () => { + const store = createMessageStore(); + let calls = 0; + store.subscribe(() => calls++); + store.push([]); + expect(store.getSnapshot()).toEqual([]); + expect(calls).toBe(0); + }); + + test('subscribers can unsubscribe', () => { + const store = createMessageStore(); + let calls = 0; + const dispose = store.subscribe(() => calls++); + dispose(); + store.push(A); + expect(calls).toBe(0); + }); + + test('clear empties the buffer and notifies', () => { + const store = createMessageStore({ initialMessages: [A, B] }); + let calls = 0; + store.subscribe(() => calls++); + store.clear(); + expect(store.getSnapshot()).toEqual([]); + expect(calls).toBe(1); + }); + + test('clear is a no-op when already empty', () => { + const store = createMessageStore(); + let calls = 0; + store.subscribe(() => calls++); + store.clear(); + expect(calls).toBe(0); + }); + + test('snapshot is frozen — mutations throw in strict mode', () => { + const store = createMessageStore({ initialMessages: [A] }); + const snap = store.getSnapshot(); + expect(() => { + (snap as ServerToClientMessage[]).push(B); + }).toThrow(); + }); +}); diff --git a/packages/genui/a2ui/test/payloadNormalizer.test.ts b/packages/genui/a2ui/test/payloadNormalizer.test.ts new file mode 100644 index 0000000000..6bf0f3a7ee --- /dev/null +++ b/packages/genui/a2ui/test/payloadNormalizer.test.ts @@ -0,0 +1,75 @@ +// 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 { describe, expect, test } from '@rstest/core'; + +import { + createFallbackMessagesFromPlainText, + createTextCardMessages, + normalizePayloadToMessages, + prepareMessagesForProcessing, +} from '../src/store/payloadNormalizer.js'; +import type { ServerToClientMessage } from '../src/store/types.js'; + +describe('payloadNormalizer', () => { + test('createFallbackMessagesFromPlainText wraps text in a Text component', () => { + const msgs = createFallbackMessagesFromPlainText('hello'); + expect(msgs).toHaveLength(2); + const update = msgs[1] as { updateComponents: { components: unknown[] } }; + expect(update.updateComponents.components[0]).toMatchObject({ + component: 'Text', + text: 'hello', + }); + }); + + test('createTextCardMessages wraps text in a Card with Text child', () => { + const msgs = createTextCardMessages('greetings'); + expect(msgs).toHaveLength(2); + const update = msgs[1] as { updateComponents: { components: unknown[] } }; + expect(update.updateComponents.components).toHaveLength(2); + const [card, text] = update.updateComponents + .components as Record[]; + expect(card['component']).toBe('Card'); + expect(text['component']).toBe('Text'); + expect(text['text']).toBe('greetings'); + }); + + test('normalizePayloadToMessages passes through structured messages', () => { + const input = [{ createSurface: { surfaceId: 's' } }]; + expect(normalizePayloadToMessages(input)).toEqual(input); + }); + + test('normalizePayloadToMessages wraps plain text in fallback', () => { + const out = normalizePayloadToMessages('hi'); + expect(out).toHaveLength(2); + expect((out[0] as { createSurface: unknown }).createSurface).toBeDefined(); + }); + + test('normalizePayloadToMessages handles { kind: "text", data: "..." }', () => { + const out = normalizePayloadToMessages({ kind: 'text', data: 'yo' }); + expect(out).toHaveLength(2); + const update = out[1] as { updateComponents: { components: unknown[] } }; + expect(update.updateComponents.components).toHaveLength(2); + }); + + test('prepareMessagesForProcessing tags messageId and dedupes createSurface', () => { + const messages = [ + { createSurface: { surfaceId: 's1' } }, + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text' }], + }, + }, + ] as ServerToClientMessage[]; + const active = new Set(); + const result = prepareMessagesForProcessing(messages, 'task_1', active); + expect(result.messages).toHaveLength(2); + expect(result.hasComponentUpdate).toBe(true); + for (const m of result.messages) { + expect(m.messageId).toBe('task_1'); + } + expect(active.has('s1')).toBe(true); + }); +}); diff --git a/packages/genui/a2ui/test/processor.test.ts b/packages/genui/a2ui/test/processor.test.ts new file mode 100644 index 0000000000..47b534cad5 --- /dev/null +++ b/packages/genui/a2ui/test/processor.test.ts @@ -0,0 +1,147 @@ +// 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 { describe, expect, test } from '@rstest/core'; + +import { MessageProcessor } from '../src/store/MessageProcessor.js'; +import type { ServerToClientMessage } from '../src/store/types.js'; + +describe('MessageProcessor', () => { + test('onUpdate supports multiple listeners', () => { + const proc = new MessageProcessor(); + const calls1: unknown[] = []; + const calls2: unknown[] = []; + + proc.onUpdate((data) => calls1.push(data)); + proc.onUpdate((data) => calls2.push(data)); + + proc.processMessages([ + { createSurface: { surfaceId: 's1', catalogId: 'test' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text', text: 'hi' }], + }, + }, + ] as ServerToClientMessage[]); + + expect(calls1.length).toBeGreaterThan(0); + expect(calls2.length).toBe(calls1.length); + }); + + test('onUpdate disposer unsubscribes', () => { + const proc = new MessageProcessor(); + const calls1: unknown[] = []; + const calls2: unknown[] = []; + + const dispose1 = proc.onUpdate((d) => calls1.push(d)); + proc.onUpdate((d) => calls2.push(d)); + dispose1(); + + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text' }], + }, + }, + ] as ServerToClientMessage[]); + + expect(calls1.length).toBe(0); + expect(calls2.length).toBeGreaterThan(0); + }); + + test('beginRendering fires when root component lands', () => { + const proc = new MessageProcessor(); + const events: { type: string; surfaceId: string }[] = []; + proc.onUpdate((d) => { + events.push(d as { type: string; surfaceId: string }); + }); + + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text', text: 'hi' }], + }, + }, + ] as ServerToClientMessage[]); + + expect(events.some((e) => e.type === 'beginRendering')).toBe(true); + expect(events.some((e) => e.type === 'surfaceUpdate')).toBe(true); + }); + + test('deleteSurface emits and clears state', () => { + const proc = new MessageProcessor(); + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text' }], + }, + }, + ] as ServerToClientMessage[]); + + expect(proc.getSurfaces().has('s1')).toBe(true); + + const events: string[] = []; + proc.onUpdate((d) => events.push((d as { type: string }).type)); + proc.processMessages([ + { deleteSurface: { surfaceId: 's1' } }, + ] as ServerToClientMessage[]); + + expect(events).toContain('deleteSurface'); + expect(proc.getSurfaces().has('s1')).toBe(false); + }); + + test('updateDataModel writes to surface store', () => { + const proc = new MessageProcessor(); + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [{ id: 'root', component: 'Text' }], + }, + }, + { + updateDataModel: { surfaceId: 's1', path: '/title', value: 'hello' }, + }, + ] as ServerToClientMessage[]); + + const surface = proc.getOrCreateSurface('s1'); + expect(surface.store.getSignal('/title').value).toBe('hello'); + }); + + test('dispatch with no listeners resolves with empty array', async () => { + const proc = new MessageProcessor(); + const result = await proc.dispatch({ userAction: { name: 'x' } }); + expect(result).toEqual([]); + }); + + test('onEvent multi-listener with disposer', () => { + const proc = new MessageProcessor(); + const calls1: unknown[] = []; + const calls2: unknown[] = []; + const d1 = proc.onEvent((e) => { + calls1.push(e.message); + e.resolve(null); + }); + proc.onEvent((e) => { + calls2.push(e.message); + e.resolve(null); + }); + + void proc.dispatch({ x: 1 }); + expect(calls1.length).toBe(1); + expect(calls2.length).toBe(1); + + d1(); + void proc.dispatch({ x: 2 }); + expect(calls1.length).toBe(1); + expect(calls2.length).toBe(2); + }); +}); diff --git a/packages/genui/a2ui/tsconfig.build.json b/packages/genui/a2ui/tsconfig.build.json new file mode 100644 index 0000000000..4e664712ac --- /dev/null +++ b/packages/genui/a2ui/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src", + }, + "include": ["src"], + "exclude": ["test"], +} diff --git a/packages/genui/a2ui/tsconfig.json b/packages/genui/a2ui/tsconfig.json index ade6f5c77e..5fb4fe359b 100644 --- a/packages/genui/a2ui/tsconfig.json +++ b/packages/genui/a2ui/tsconfig.json @@ -7,8 +7,9 @@ "target": "esnext", "jsx": "preserve", "jsxImportSource": "@lynx-js/react", - "rootDir": "./src", + "noEmit": true, + "types": ["@rstest/core/globals"], }, - "include": ["src"], + "include": ["src", "test", "rstest.config.ts"], "references": [], } diff --git a/packages/genui/tsconfig.json b/packages/genui/tsconfig.json index b0b0c22371..7c00a7fc35 100644 --- a/packages/genui/tsconfig.json +++ b/packages/genui/tsconfig.json @@ -11,7 +11,7 @@ "references": [ /** packages-start */ { "path": "./a2ui-catalog-extractor/tsconfig.json" }, - { "path": "./a2ui/tsconfig.json" }, + { "path": "./a2ui/tsconfig.build.json" }, /** packages-end */ ], "include": [], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee32fd4883..c736a66d22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,7 +323,7 @@ importers: version: 3.7.0 '@rsbuild/plugin-babel': specifier: 1.1.0 - version: 1.1.0(@rsbuild/core@1.7.5) + version: 1.1.0(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0)) '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -571,15 +571,15 @@ importers: '@lynx-js/lynx-ui': specifier: ^3.130.0 version: 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) - '@lynx-js/lynx-ui-input': - specifier: ^3.130.0 - version: 3.130.0(@lynx-js/react@packages+react)(@lynx-js/types@3.7.0)(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.5))(react@19.2.5) '@lynx-js/react': specifier: workspace:* version: link:../../react '@lynx-js/types': specifier: 3.7.0 version: 3.7.0 + '@rstest/core': + specifier: catalog:rstest + version: 0.8.1(jsdom@27.4.0) '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -686,7 +686,7 @@ importers: version: 1.54.2(@swc/helpers@0.5.21)(@types/node@24.10.13)(i18next@26.0.6(typescript@5.9.3))(typescript@5.9.3) rsbuild-plugin-i18next-extractor: specifier: 0.2.1 - version: 0.2.1(@rsbuild/core@1.7.5)(i18next-cli@1.54.2(@swc/helpers@0.5.21)(@types/node@24.10.13)(i18next@26.0.6(typescript@5.9.3))(typescript@5.9.3))(rollup@4.34.9) + version: 0.2.1(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))(i18next-cli@1.54.2(@swc/helpers@0.5.21)(@types/node@24.10.13)(i18next@26.0.6(typescript@5.9.3))(typescript@5.9.3))(rollup@4.34.9) packages/lynx/benchx_cli: dependencies: @@ -1397,10 +1397,10 @@ importers: version: link:../../web-platform/web-elements '@rsbuild/plugin-less': specifier: 1.6.0 - version: 1.6.0(@rsbuild/core@1.7.5) + version: 1.6.0(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0)) '@rsbuild/plugin-sass': specifier: 1.5.0 - version: 1.5.0(@rsbuild/core@1.7.5) + version: 1.5.0(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0)) commander: specifier: ^13.1.0 version: 13.1.0 @@ -1415,7 +1415,7 @@ importers: version: 1.1.1 rsbuild-plugin-tailwindcss: specifier: 0.2.4 - version: 0.2.4(@rsbuild/core@1.7.5)(tailwindcss@4.2.1) + version: 0.2.4(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))(tailwindcss@4.2.1) rslog: specifier: ^1.3.2 version: 1.3.2 @@ -1509,7 +1509,7 @@ importers: version: 3.7.0 '@rsbuild/plugin-babel': specifier: 1.1.0 - version: 1.1.0(@rsbuild/core@1.7.5) + version: 1.1.0(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -1887,7 +1887,7 @@ importers: version: link:../test-tools '@rspack/test-tools': specifier: catalog:rspack - version: 1.5.6(@rspack/core@1.7.9(@swc/helpers@0.5.21)) + version: 1.5.6(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@0.22.0)(@swc/helpers@0.5.21)) '@rstest/core': specifier: catalog:rstest version: 0.8.1(jsdom@27.4.0) @@ -2068,7 +2068,7 @@ importers: version: 7.58.2(@types/node@24.10.13) '@rspack/test-tools': specifier: catalog:rspack - version: 1.5.6(@rspack/core@1.7.9(@swc/helpers@0.5.21)) + version: 1.5.6(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@0.22.0)(@swc/helpers@0.5.21)) '@rstest/core': specifier: catalog:rstest version: 0.8.1(jsdom@27.4.0) @@ -2111,7 +2111,7 @@ importers: version: 7.58.2(@types/node@24.10.13) '@rspack/test-tools': specifier: catalog:rspack - version: 1.5.6(@rspack/core@1.7.9(@swc/helpers@0.5.21)) + version: 1.5.6(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@0.22.0)(@swc/helpers@0.5.21)) '@rstest/core': specifier: catalog:rstest version: 0.8.1(jsdom@27.4.0) @@ -2123,7 +2123,7 @@ importers: version: 1.0.4 css-loader: specifier: ^7.1.4 - version: 7.1.4(@rspack/core@1.7.9(@swc/helpers@0.5.21))(webpack@5.105.2) + version: 7.1.4(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@0.22.0)(@swc/helpers@0.5.21))(webpack@5.105.2) webpack: specifier: ^5.105.2 version: 5.105.2 @@ -2259,13 +2259,13 @@ importers: version: 7.33.6(@types/node@24.10.13) '@rsbuild/plugin-sass': specifier: 1.5.0 - version: 1.5.0(@rsbuild/core@1.7.5) + version: 1.5.0(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0)) '@rsbuild/plugin-type-check': specifier: 1.3.4 - version: 1.3.4(@rsbuild/core@1.7.5)(@rspack/core@1.7.9(@swc/helpers@0.5.21))(tslib@2.8.1)(typescript@5.9.3) + version: 1.3.4(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))(@rspack/core@1.7.9(@swc/helpers@0.5.21))(tslib@2.8.1)(typescript@5.9.3) '@rsbuild/plugin-typed-css-modules': specifier: 1.2.2 - version: 1.2.2(@rsbuild/core@1.7.5) + version: 1.2.2(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0)) '@rspress/core': specifier: 2.0.3 version: 2.0.3(@module-federation/runtime-tools@0.22.0)(@types/react@19.2.14)(core-js@3.48.0) @@ -13710,13 +13710,13 @@ snapshots: transitivePeerDependencies: - '@module-federation/runtime-tools' - '@rsbuild/plugin-babel@1.1.0(@rsbuild/core@1.7.5)': + '@rsbuild/plugin-babel@1.1.0(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@rsbuild/core': 1.7.5 + '@rsbuild/core': 2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0) '@types/babel__core': 7.20.5 deepmerge: 4.3.1 reduce-configs: 1.1.1 @@ -13756,9 +13756,9 @@ snapshots: optionalDependencies: '@rsbuild/core': 1.7.5 - '@rsbuild/plugin-less@1.6.0(@rsbuild/core@1.7.5)': + '@rsbuild/plugin-less@1.6.0(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))': dependencies: - '@rsbuild/core': 1.7.5 + '@rsbuild/core': 2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0) deepmerge: 4.3.1 reduce-configs: 1.1.1 @@ -13787,6 +13787,15 @@ snapshots: reduce-configs: 1.1.1 sass-embedded: 1.97.3 + '@rsbuild/plugin-sass@1.5.0(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))': + dependencies: + '@rsbuild/core': 2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0) + deepmerge: 4.3.1 + loader-utils: 2.0.4 + postcss: 8.5.6 + reduce-configs: 1.1.1 + sass-embedded: 1.97.3 + '@rsbuild/plugin-source-build@1.0.4(@rsbuild/core@1.7.5)': dependencies: fast-glob: 3.3.3 @@ -13795,19 +13804,6 @@ snapshots: optionalDependencies: '@rsbuild/core': 1.7.5 - '@rsbuild/plugin-type-check@1.3.4(@rsbuild/core@1.7.5)(@rspack/core@1.7.9(@swc/helpers@0.5.21))(tslib@2.8.1)(typescript@5.9.3)': - dependencies: - deepmerge: 4.3.1 - json5: 2.2.3 - reduce-configs: 1.1.1 - ts-checker-rspack-plugin: 1.3.0(@rspack/core@1.7.9(@swc/helpers@0.5.21))(tslib@2.8.1)(typescript@5.9.3) - optionalDependencies: - '@rsbuild/core': 1.7.5 - transitivePeerDependencies: - - '@rspack/core' - - tslib - - typescript - '@rsbuild/plugin-type-check@1.3.4(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))(@rspack/core@1.7.9(@swc/helpers@0.5.21))(tslib@2.8.1)(typescript@5.9.3)': dependencies: deepmerge: 4.3.1 @@ -13825,6 +13821,10 @@ snapshots: optionalDependencies: '@rsbuild/core': 1.7.5 + '@rsbuild/plugin-typed-css-modules@1.2.2(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))': + optionalDependencies: + '@rsbuild/core': 2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0) + '@rsdoctor/client@1.5.6': {} '@rsdoctor/core@1.5.6(@emnapi/core@1.7.1)(@emnapi/runtime@1.7.1)(@rsbuild/core@1.7.5)(@rspack/core@1.7.9(@swc/helpers@0.5.21))(webpack@5.105.2)': @@ -14243,6 +14243,45 @@ snapshots: - utf-8-validate - webpack-cli + '@rspack/test-tools@1.5.6(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@0.22.0)(@swc/helpers@0.5.21))': + dependencies: + '@babel/generator': 7.28.3 + '@babel/parser': 7.28.4 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + '@rspack/core': 2.0.0-beta.0(@module-federation/runtime-tools@0.22.0)(@swc/helpers@0.5.21) + cross-env: 10.1.0 + csv-to-markdown-table: 1.6.3 + deepmerge: 4.3.1 + filenamify: 4.3.0 + fs-extra: 11.3.3 + glob: 11.1.0 + graceful-fs: 4.2.11 + iconv-lite: 0.6.3 + jest-diff: 29.7.0 + jest-snapshot: 29.7.0 + jsdom: 26.1.0 + loader-utils: 2.0.4 + memfs: 4.38.2 + path-serializer: 0.5.1 + pretty-format: 29.7.0 + rimraf: 5.0.10 + source-map: 0.7.6 + terser-webpack-plugin: 5.3.16(webpack@5.99.9) + wast-loader: 1.14.1 + webpack: 5.99.9 + webpack-merge: 6.0.1 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - bufferutil + - canvas + - esbuild + - supports-color + - uglify-js + - utf-8-validate + - webpack-cli + '@rspress/core@2.0.3(@module-federation/runtime-tools@0.22.0)(@types/react@19.2.14)(core-js@3.48.0)': dependencies: '@mdx-js/mdx': 3.1.1 @@ -16038,6 +16077,20 @@ snapshots: '@rspack/core': 1.7.9(@swc/helpers@0.5.21) webpack: 5.105.2 + css-loader@7.1.4(@rspack/core@2.0.0-beta.0(@module-federation/runtime-tools@0.22.0)(@swc/helpers@0.5.21))(webpack@5.105.2): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) + postcss-modules-local-by-default: 4.0.5(postcss@8.5.6) + postcss-modules-scope: 3.2.0(postcss@8.5.6) + postcss-modules-values: 4.0.0(postcss@8.5.6) + postcss-value-parser: 4.2.0 + semver: 7.7.4 + optionalDependencies: + '@rspack/core': 2.0.0-beta.0(@module-federation/runtime-tools@0.22.0)(@swc/helpers@0.5.21) + webpack: 5.105.2 + css-minimizer-webpack-plugin@7.0.2(lightningcss@1.31.1)(webpack@5.105.2): dependencies: '@jridgewell/trace-mapping': 0.3.29 @@ -20119,10 +20172,10 @@ snapshots: '@typescript/native-preview': 7.0.0-dev.20260212.1 typescript: 5.9.3 - rsbuild-plugin-i18next-extractor@0.2.1(@rsbuild/core@1.7.5)(i18next-cli@1.54.2(@swc/helpers@0.5.21)(@types/node@24.10.13)(i18next@26.0.6(typescript@5.9.3))(typescript@5.9.3))(rollup@4.34.9): + rsbuild-plugin-i18next-extractor@0.2.1(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))(i18next-cli@1.54.2(@swc/helpers@0.5.21)(@types/node@24.10.13)(i18next@26.0.6(typescript@5.9.3))(typescript@5.9.3))(rollup@4.34.9): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.34.9) - '@rsbuild/core': 1.7.5 + '@rsbuild/core': 2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0) '@rspack/lite-tapable': 1.1.0 i18next-cli: 1.54.2(@swc/helpers@0.5.21)(@types/node@24.10.13)(i18next@26.0.6(typescript@5.9.3))(typescript@5.9.3) transitivePeerDependencies: @@ -20142,15 +20195,15 @@ snapshots: optionalDependencies: '@rsbuild/core': 2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0) - rsbuild-plugin-tailwindcss@0.2.4(@rsbuild/core@1.7.5)(tailwindcss@4.2.1): + rsbuild-plugin-tailwindcss@0.2.4(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))(tailwindcss@3.4.19): dependencies: - tailwindcss: 4.2.1 + tailwindcss: 3.4.19 optionalDependencies: - '@rsbuild/core': 1.7.5 + '@rsbuild/core': 2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0) - rsbuild-plugin-tailwindcss@0.2.4(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))(tailwindcss@3.4.19): + rsbuild-plugin-tailwindcss@0.2.4(@rsbuild/core@2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0))(tailwindcss@4.2.1): dependencies: - tailwindcss: 3.4.19 + tailwindcss: 4.2.1 optionalDependencies: '@rsbuild/core': 2.0.0-beta.3(@module-federation/runtime-tools@0.22.0)(core-js@3.48.0)