Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/a2ui-catalog.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions .github/a2ui-server.instructions.md
Original file line number Diff line number Diff line change
@@ -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.
243 changes: 243 additions & 0 deletions packages/genui/a2ui-playground/src/catalog/a2uiAgentCatalog.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> & {
enum?: unknown[];
items?: JsonSchema;
oneOf?: JsonSchema[];
properties?: Record<string, JsonSchema>;
required?: string[];
type?: string;
};

type CatalogManifest = Record<string, JsonSchema>;

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<string, string> = {
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<string, A2UIAgentCatalogComponent['containerShape']>
> = {
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.',
],
};
3 changes: 3 additions & 0 deletions packages/genui/a2ui-playground/src/pages/AIChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1303,6 +1304,7 @@ export function AIChatPage(
body: JSON.stringify({
messages: [userMessage],
conversation: requestConversation,
catalog: A2UI_AGENT_CATALOG,
...requestProviderOptions,
}),
signal: controller.signal,
Expand Down Expand Up @@ -1523,6 +1525,7 @@ export function AIChatPage(
surfaceId: payload.surfaceId,
action,
conversation: requestConversation,
catalog: A2UI_AGENT_CATALOG,
...requestProviderOptions,
}),
signal,
Expand Down
2 changes: 1 addition & 1 deletion packages/genui/server/app/a2ui/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 9 additions & 2 deletions packages/genui/server/service/a2ui-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
): ChatMessage {
Expand All @@ -105,9 +111,10 @@ export default class A2UIAgentService {

private getAgent(opts: ChatOptions): Promise<A2UIAgent> {
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', {
Expand All @@ -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);
Expand Down
Loading