diff --git a/.github/a2ui-catalog.instructions.md b/.github/a2ui-catalog.instructions.md index c0f47b0a47..f056033962 100644 --- a/.github/a2ui-catalog.instructions.md +++ b/.github/a2ui-catalog.instructions.md @@ -50,3 +50,5 @@ For the built-in `DateTimeInput`, keep date-enabled default output as `YYYY-MM-D When changing A2UI README content under `packages/genui/a2ui` or `packages/genui/a2ui-playground/examples`, keep the corresponding Chinese README in sync. Keep the `packages/genui/a2ui` entry READMEs focused on first-time developers, and move deeper package material into topic docs under `docs/` with matching Chinese versions. Keep the playground examples README self-contained unless a split is explicitly requested. The Chinese docs should preserve the same technical boundaries as the English docs: `@lynx-js/genui/a2ui` is the client renderer/store/catalog runtime, `genui a2ui` is build/setup tooling, and the Agent service plus transport adapter are owned by the embedding app. When adding A2UI website README link rewrites in `website/sidebars/genui.ts`, put longer relative paths before shorter substrings such as `./src/catalog/README.md`, otherwise `../src/catalog/README.md` can become a broken `./guide/...` URL. Keep generated README links to the `/a2ui` playground as external `https://lynxjs.org/a2ui` Markdown links; the toolbar/nav item can point at `/a2ui`, but Rspress dead-link checking treats Markdown `/a2ui` links as docs pages. + +When changing the A2UI playground renderer catalog, keep the agent-facing catalog in `src/catalog/a2uiAgentCatalog.ts` aligned with the Lynx preview `ALL_BUILTINS` list. Chat and live-action requests send that catalog to the server so the prompt, catalog id hard rule, function definitions, and validator all reflect what the renderer can actually display. diff --git a/.github/a2ui-server.instructions.md b/.github/a2ui-server.instructions.md new file mode 100644 index 0000000000..ad8eab7baf --- /dev/null +++ b/.github/a2ui-server.instructions.md @@ -0,0 +1,5 @@ +--- +applyTo: "packages/genui/server/**" +--- + +For the A2UI server agent, build the model system prompt through `buildA2UISystemPrompt()` in `agent/a2ui-prompt.ts`. The selected catalog is injected by rendering `renderCatalogReference(catalog)` into that prompt, and the same catalog id is passed into `buildHardRules(catalog.id)`. Request handlers should pass any request catalog through `pickChatOptions()` into `A2UIAgentService`; the service defaults to `BASIC_CATALOG`, includes a hash of the effective catalog contents in the agent cache key, and validates output with the same catalog. diff --git a/packages/genui/a2ui-playground/src/catalog/a2uiAgentCatalog.ts b/packages/genui/a2ui-playground/src/catalog/a2uiAgentCatalog.ts new file mode 100644 index 0000000000..61d954d66c --- /dev/null +++ b/packages/genui/a2ui-playground/src/catalog/a2uiAgentCatalog.ts @@ -0,0 +1,243 @@ +// 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 { basicFunctions } from '@lynx-js/genui/a2ui'; +import { catalogManifests } from '@lynx-js/genui/a2ui/catalog'; + +type JsonSchema = Record & { + enum?: unknown[]; + items?: JsonSchema; + oneOf?: JsonSchema[]; + properties?: Record; + required?: string[]; + type?: string; +}; + +type CatalogManifest = Record; + +export interface A2UIAgentCatalogProp { + enums?: string[]; + description?: string; + name: string; + required?: boolean; + schema?: JsonSchema; + type: string; +} + +export interface A2UIAgentCatalogComponent { + containerShape?: 'children' | 'child' | 'tabs' | 'trigger-content' | 'none'; + name: string; + props: A2UIAgentCatalogProp[]; + requiresAction?: boolean; + summary: string; +} + +export interface A2UIAgentFunctionSpec { + description?: string; + name: string; + parameters: JsonSchema; + returnType: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; +} + +export interface A2UIAgentCatalog { + components: A2UIAgentCatalogComponent[]; + extraRules?: string[]; + functions?: A2UIAgentFunctionSpec[]; + id: string; + label: string; + version?: string; +} + +const A2UI_PLAYGROUND_CATALOG_ID = + 'https://lynxjs.org/a2ui/catalogs/playground-builtins/v0_9/catalog.json'; + +const CATALOG_COMPONENT_NAMES = [ + 'Text', + 'Image', + 'Row', + 'Column', + 'List', + 'Card', + 'Modal', + 'Button', + 'Divider', + 'Icon', + 'CheckBox', + 'ChoicePicker', + 'DateTimeInput', + 'LineChart', + 'PieChart', + 'Loading', + 'RadioGroup', + 'Slider', + 'TextField', + 'Tabs', +] as const; + +const CATALOG_MANIFESTS: readonly CatalogManifest[] = CATALOG_COMPONENT_NAMES + .map((name) => catalogManifests[name]) + .filter((manifest): manifest is CatalogManifest => manifest !== undefined); + +const COMPONENT_SUMMARIES: Record = { + Button: + 'Clickable button. MUST always include an action. Has no "label" prop; use a child Text component for the visible label.', + Card: + 'Card container with exactly one child. Wrap multiple elements in a Column/Row/List first.', + CheckBox: 'Boolean checkbox with a label and optional validation checks.', + Column: 'Vertical layout container.', + Divider: 'Horizontal or vertical separator line.', + Icon: 'Display an icon by name.', + Image: 'Display an image by URL.', + LineChart: 'Display one or more numeric line series over shared labels.', + List: 'Repeating layout container, commonly bound to a data path.', + Modal: + 'Modal dialog with a trigger component and a content component. The trigger opens the modal locally when tapped.', + PieChart: 'Display proportional numeric slices as a pie chart.', + RadioGroup: 'Single-choice selector for a list of string options.', + Row: 'Horizontal layout container.', + Slider: 'Numeric slider with an optional label and validation checks.', + Tabs: 'Tabbed container; each tab references a child component id.', + Text: + 'Display styled text. Supports literal text, data bindings, and function calls.', + TextField: 'Single-line or multi-line text input.', +}; + +const CONTAINER_SHAPES: Partial< + Record +> = { + Button: 'child', + Card: 'child', + Column: 'children', + List: 'children', + Modal: 'trigger-content', + Row: 'children', + Tabs: 'tabs', +}; + +function inferType(schema: JsonSchema | undefined): string { + if (!schema) return 'unknown'; + + if (schema.oneOf && schema.oneOf.length > 0) { + return schema.oneOf.map((item) => inferType(item)).join(' | '); + } + + if (schema.type === 'array') { + return `${inferType(schema.items)}[]`; + } + + if (schema.type === 'object') { + const properties = schema.properties ?? {}; + const keys = Object.keys(properties); + if (keys.length === 0) return 'object'; + const required = new Set(schema.required ?? []); + const fields = keys.map((key) => { + const optional = required.has(key) ? '' : '?'; + return `${key}${optional}: ${inferType(properties[key])}`; + }); + return `{ ${fields.join('; ')} }`; + } + + if (schema.type === 'string') { + if (Array.isArray(schema.enum) && schema.enum.length > 0) { + return schema.enum + .filter((item): item is string => typeof item === 'string') + .map((item) => `"${item}"`) + .join(' | '); + } + return 'string'; + } + + if (schema.type === 'number') return 'number'; + if (schema.type === 'boolean') return 'boolean'; + return schema.type ?? 'unknown'; +} + +function inferEnums(schema: JsonSchema | undefined): string[] | undefined { + if (!schema) return undefined; + if (Array.isArray(schema.enum)) { + const values = schema.enum.filter((item): item is string => + typeof item === 'string' + ); + return values.length > 0 ? values : undefined; + } + if (!schema.oneOf) return undefined; + const nested = schema.oneOf.flatMap((item) => inferEnums(item) ?? []); + return nested.length > 0 ? [...new Set(nested)] : undefined; +} + +function componentFromManifest( + manifest: CatalogManifest, +): A2UIAgentCatalogComponent | null { + const [name, schema] = Object.entries(manifest)[0] ?? []; + if (!name || !schema) return null; + + const properties = schema.properties ?? {}; + const required = new Set(schema.required ?? []); + const props = Object.entries(properties).map(([propName, propSchema]) => { + const enums = inferEnums(propSchema); + const description = typeof propSchema.description === 'string' + ? propSchema.description + : ''; + return { + name: propName, + type: inferType(propSchema), + schema: propSchema, + ...(description ? { description } : {}), + ...(required.has(propName) ? { required: true } : {}), + ...(enums ? { enums } : {}), + }; + }); + + return { + name, + summary: COMPONENT_SUMMARIES[name] ?? `${name} component.`, + props, + ...(name === 'Button' ? { requiresAction: true } : {}), + ...(CONTAINER_SHAPES[name] + ? { containerShape: CONTAINER_SHAPES[name] } + : {}), + }; +} + +function basicFunctionDefinitions(): A2UIAgentFunctionSpec[] { + type BasicFunctionDefinition = NonNullable< + (typeof basicFunctions)[number]['definition'] + >; + + return basicFunctions + .map((fn) => fn.definition) + .filter((definition): definition is BasicFunctionDefinition => + definition !== undefined + ) + .map((definition) => ({ + name: definition.name, + ...(definition.description + ? { description: definition.description } + : {}), + parameters: definition.parameters as JsonSchema, + returnType: definition.returnType, + })); +} + +export const A2UI_AGENT_CATALOG: A2UIAgentCatalog = { + id: A2UI_PLAYGROUND_CATALOG_ID, + label: 'Lynx A2UI playground built-in catalog (v0.9)', + version: 'v0.9', + components: CATALOG_MANIFESTS + .map((manifest) => componentFromManifest(manifest)) + .filter((component): component is A2UIAgentCatalogComponent => + component !== null + ), + functions: basicFunctionDefinitions(), + extraRules: [ + 'Use only components listed in this catalog; unsupported examples such as Video, AudioPlayer, DatePicker, or Checkbox are not available unless they appear here.', + 'The implemented checkbox component is named "CheckBox" with a capital B.', + ], +}; diff --git a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx index 9b8a64463f..e4c99777e8 100644 --- a/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx +++ b/packages/genui/a2ui-playground/src/pages/AIChatPage.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import './AIChatPage.css'; +import { A2UI_AGENT_CATALOG } from '../catalog/a2uiAgentCatalog.js'; import { ConfirmDialog } from '../components/ConfirmDialog.js'; import { ConversationListPanel } from '../components/ConversationListPanel.js'; import { CopyToast, useCopyToast } from '../components/CopyToast.js'; @@ -1303,6 +1304,7 @@ export function AIChatPage( body: JSON.stringify({ messages: [userMessage], conversation: requestConversation, + catalog: A2UI_AGENT_CATALOG, ...requestProviderOptions, }), signal: controller.signal, @@ -1523,6 +1525,7 @@ export function AIChatPage( surfaceId: payload.surfaceId, action, conversation: requestConversation, + catalog: A2UI_AGENT_CATALOG, ...requestProviderOptions, }), signal, diff --git a/packages/genui/server/app/a2ui/_shared.ts b/packages/genui/server/app/a2ui/_shared.ts index 80b3b4d556..384d2cdf20 100644 --- a/packages/genui/server/app/a2ui/_shared.ts +++ b/packages/genui/server/app/a2ui/_shared.ts @@ -48,7 +48,7 @@ function clientOverridesAllowed(): boolean { export const MAX_BODY_BYTES = parsePositiveInt( process.env.A2UI_MAX_BODY_BYTES, - 64 * 1024, + 256 * 1024, ); export const MAX_MESSAGE_CHARS = parsePositiveInt( diff --git a/packages/genui/server/service/a2ui-agent.ts b/packages/genui/server/service/a2ui-agent.ts index 9daa220da9..7cc109c99c 100644 --- a/packages/genui/server/service/a2ui-agent.ts +++ b/packages/genui/server/service/a2ui-agent.ts @@ -84,6 +84,12 @@ function hashApiKey(apiKey: string | undefined): string { return createHash('sha256').update(apiKey).digest('hex'); } +function hashCatalog(catalog: A2UICatalog): string { + return `${catalog.id}:${ + createHash('sha256').update(JSON.stringify(catalog)).digest('hex') + }`; +} + function buildDataModelSystemMessage( dataModel: Record, ): ChatMessage { @@ -105,9 +111,10 @@ export default class A2UIAgentService { private getAgent(opts: ChatOptions): Promise { const startedAt = performance.now(); + const catalog = opts.catalog ?? BASIC_CATALOG; const cacheKey = `${opts.baseURL ?? 'default'}:${opts.model ?? 'default'}:${ hashApiKey(opts.apiKey) - }:${opts.catalog?.id ?? 'basic'}`; + }:${hashCatalog(catalog)}`; let cached = this.agentCache.get(cacheKey); if (cached) { opts.onPerformanceEvent?.('agent.cache.hit', { @@ -122,7 +129,7 @@ export default class A2UIAgentService { apiKey: opts.apiKey, baseURL: opts.baseURL, model: opts.model, - catalog: opts.catalog, + catalog, })).agent, ); this.agentCache.set(cacheKey, cached);