diff --git a/.github/genui-docs.instructions.md b/.github/genui-docs.instructions.md new file mode 100644 index 0000000000..dc5fb7f441 --- /dev/null +++ b/.github/genui-docs.instructions.md @@ -0,0 +1,17 @@ +--- +applyTo: "packages/genui/a2ui/README*.md" +--- + +Write `packages/genui/a2ui/README*.md` for external app developers who use A2UI from their own ReactLynx projects, not for contributors developing inside `lynx-stack`. Avoid monorepo setup, Turbo commands, package folder maps, private package names, local sample-server startup, localhost-only endpoints, and repository test commands in these user-facing docs. + +When documenting the GenUI workflow, frame the happy path as Catalog -> Agent -> Client. Catalog docs should distinguish the client renderer catalog built with `defineCatalog` / `serializeCatalog` from whatever catalog-reference format an agent backend uses for prompts; do not imply the server accepts the client `SerializedCatalog` payload directly unless a conversion layer exists. + +Assume the reader knows React but does not know A2UI. Introduce A2UI as a JSON message protocol for safely asking an agent to assemble approved ReactLynx components, and explain GenUI terms by mapping them back to familiar React ideas such as components, props, state updates, external stores, and event handlers. + +When documenting GenUI transport implementations, describe the transport as the adapter between product state and `MessageStore`. Cover both REST and SSE paths, make the SSE `done` event the final validated render source, call out `AbortController` cancellation for prompt and action requests, and warn against passing provider credentials or endpoint overrides from untrusted browser clients. + +When documenting GenUI CLI usage, use `npx @lynx-js/a2ui-cli` as the user-facing command prefix. Explain both command families: `generate catalog` for TypeScript-derived catalog artifacts and `generate prompt` for A2UI system prompts. Treat `@lynx-js/a2ui-catalog-extractor` as an internal implementation detail used by the catalog generation command, not as an external API or recommended binary, and remind readers that generated prompts and client catalogs must agree on component names and props. + +When documenting the GenUI playground, only present the hosted URL `https://lynx-stack.dev/a2ui/` as the trial path. Do not document local `a2ui-playground` package usage, local server startup, or local playground endpoint overrides in user-facing docs; the package is not planned as a published product surface. + +Avoid literal wording such as "recommended shape" / "推荐形状" in user-facing docs. Prefer "interface best practice", "implementation pattern", "接口设计最佳实践", or other product-facing phrases that read naturally to React developers. diff --git a/.github/genui-website.instructions.md b/.github/genui-website.instructions.md new file mode 100644 index 0000000000..790f30d37a --- /dev/null +++ b/.github/genui-website.instructions.md @@ -0,0 +1,5 @@ +--- +applyTo: "website/sidebars/genui.ts" +--- + +Expose the website GenUI guide through the A2UI README-generated pages. Do not add a separate `A2UI Catalog Extractor` sidebar entry or generated website page unless the product direction changes; public extractor behavior should be summarized inside `packages/genui/a2ui/README*.md` and surfaced through the A2UI guide. diff --git a/packages/genui/a2ui-catalog-extractor/README.md b/packages/genui/a2ui-catalog-extractor/README.md index bea6083197..b75f09b5ab 100644 --- a/packages/genui/a2ui-catalog-extractor/README.md +++ b/packages/genui/a2ui-catalog-extractor/README.md @@ -2,12 +2,16 @@ English | [简体中文](./readme.zh_cn.md) -`@lynx-js/a2ui-catalog-extractor` turns TypeScript component -interfaces into A2UI component catalog JSON. You write the public -component contract once as a TypeScript `interface`, describe it with -normal TypeDoc comments, and let this package generate the JSON Schema +`@lynx-js/a2ui-catalog-extractor` is the internal TypeDoc-powered extraction +engine behind `npx @lynx-js/a2ui-cli generate catalog`. It turns TypeScript component +interfaces into A2UI component catalog JSON. You write the public component +contract once as a TypeScript `interface`, describe it with normal TypeDoc +comments, and run the public `npx @lynx-js/a2ui-cli` command to generate the JSON Schema that an A2UI agent can read. +For user-facing scripts, use `npx @lynx-js/a2ui-cli generate catalog`. Treat +this package as the implementation layer for extraction behavior and tests. + ## What It Does A2UI catalogs describe what components a renderer supports. For each @@ -54,10 +58,10 @@ the marked interface. ### Package manager -Install the extractor as a development dependency: +Run the public CLI package through `npx`: ```bash -pnpm add -D @lynx-js/a2ui-catalog-extractor +npx @lynx-js/a2ui-cli --help ``` Then add a script to your package: @@ -65,7 +69,7 @@ Then add a script to your package: ```json { "scripts": { - "build:catalog": "a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog" + "build:catalog": "npx @lynx-js/a2ui-cli generate catalog --catalog-dir src/catalog --out-dir dist/catalog" } } ``` @@ -128,7 +132,7 @@ extractor that this interface should become a catalog component named Run: ```bash -a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog +npx @lynx-js/a2ui-cli generate catalog --catalog-dir src/catalog --out-dir dist/catalog ``` The extractor scans the catalog directory, finds interfaces marked with @@ -386,16 +390,14 @@ export interface CardProps { ## CLI Reference -The package exposes the standalone `a2ui-catalog-extractor` binary. The -separate `@lynx-js/a2ui-cli` package also exposes this flow as -`a2ui-cli generate catalog`. +The public CLI entry point is +`npx @lynx-js/a2ui-cli generate catalog`. It delegates to this package +internally. ### Generate catalog artifacts ```bash -a2ui-catalog-extractor [options] -# or -a2ui-cli generate catalog [options] +npx @lynx-js/a2ui-cli generate catalog [options] ``` | Option | Description | Default | @@ -413,7 +415,13 @@ all inputs, removes duplicates, sorts them, and then runs TypeDoc. The scanner accepts `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, and `.cts` files. It ignores `.d.ts`, `node_modules`, `dist`, and `.turbo`. -## Programmatic API +## Repository-internal Programmatic API + +These exports are documented for maintainers of `@lynx-js/a2ui-cli`, extractor +tests, and repository-internal tooling. They are not the external integration +surface for product code. Product build scripts should call +`npx @lynx-js/a2ui-cli generate catalog` instead of importing this package +directly. ### Generate components from source files @@ -479,7 +487,7 @@ writeCatalogComponents(components, { The equivalent CLI command is: ```bash -a2ui-catalog-extractor --typedoc-json typedoc.json --out-dir dist/catalog +npx @lynx-js/a2ui-cli generate catalog --typedoc-json typedoc.json --out-dir dist/catalog ``` ### Create a full A2UI catalog object diff --git a/packages/genui/a2ui-catalog-extractor/readme.zh_cn.md b/packages/genui/a2ui-catalog-extractor/readme.zh_cn.md index a2ade3aac2..49d1fcf689 100644 --- a/packages/genui/a2ui-catalog-extractor/readme.zh_cn.md +++ b/packages/genui/a2ui-catalog-extractor/readme.zh_cn.md @@ -2,10 +2,14 @@ [English](./README.md) | 简体中文 -`@lynx-js/a2ui-catalog-extractor` 会把 TypeScript 组件接口转换成 A2UI -组件 catalog JSON。你只需要用 TypeScript `interface` 写一次组件的公开 -契约,用普通 TypeDoc 注释描述字段,然后让这个包生成 A2UI agent 可以读取的 -JSON Schema。 +`@lynx-js/a2ui-catalog-extractor` 是 +`npx @lynx-js/a2ui-cli generate catalog` 背后的内部 TypeDoc extraction engine。它会把 TypeScript 组件接口转换成 A2UI +组件 catalog JSON。你只需要用 TypeScript `interface` 写一次组件的公开契约, +用普通 TypeDoc 注释描述字段,然后通过公开的 `npx @lynx-js/a2ui-cli` 命令生成 +A2UI agent 可以读取的 JSON Schema。 + +用户脚本请使用 `npx @lynx-js/a2ui-cli generate catalog`。这个包主要作为 +extraction 行为和测试的实现层。 ## 它解决什么问题 @@ -51,10 +55,10 @@ agent 哪些 props 合法、哪些 props 必填、哪些 enum 值可用,以及 ### 包管理器 -把 extractor 安装为开发依赖: +通过 `npx` 执行公开 CLI 包: ```bash -pnpm add -D @lynx-js/a2ui-catalog-extractor +npx @lynx-js/a2ui-cli --help ``` 然后在你的 package 中加入脚本: @@ -62,7 +66,7 @@ pnpm add -D @lynx-js/a2ui-catalog-extractor ```json { "scripts": { - "build:catalog": "a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog" + "build:catalog": "npx @lynx-js/a2ui-cli generate catalog --catalog-dir src/catalog --out-dir dist/catalog" } } ``` @@ -123,7 +127,7 @@ export interface QuickStartCardProps { 运行: ```bash -a2ui-catalog-extractor --catalog-dir src/catalog --out-dir dist/catalog +npx @lynx-js/a2ui-cli generate catalog --catalog-dir src/catalog --out-dir dist/catalog ``` extractor 会扫描 catalog 目录,找到带 `@a2uiCatalog` 的 interface,并为每个 @@ -378,16 +382,12 @@ export interface CardProps { ## CLI 参考 -这个包暴露独立的 `a2ui-catalog-extractor` binary。单独的 -`@lynx-js/a2ui-cli` 包也把这个流程暴露为 -`a2ui-cli generate catalog`。 +公开 CLI 入口是 `npx @lynx-js/a2ui-cli generate catalog`。它会在内部委托给这个包。 ### 生成 catalog artifacts ```bash -a2ui-catalog-extractor [options] -# 或 -a2ui-cli generate catalog [options] +npx @lynx-js/a2ui-cli generate catalog [options] ``` | 选项 | 说明 | 默认值 | @@ -405,7 +405,10 @@ a2ui-cli generate catalog [options] 扫描器接受 `.ts`、`.tsx`、`.js`、`.jsx`、`.mts` 和 `.cts` 文件。它会忽略 `.d.ts`、`node_modules`、`dist` 和 `.turbo`。 -## 编程 API +## 仓库内部编程 API + +这些导出主要面向 `@lynx-js/a2ui-cli` 维护者、extractor 测试和仓库内部工具;它们不是给产品代码接入的外部集成面。 +产品构建脚本应该调用 `npx @lynx-js/a2ui-cli generate catalog`,而不是直接 import 这个包。 ### 从源码文件生成 components @@ -470,7 +473,7 @@ writeCatalogComponents(components, { 等价的 CLI 命令是: ```bash -a2ui-catalog-extractor --typedoc-json typedoc.json --out-dir dist/catalog +npx @lynx-js/a2ui-cli generate catalog --typedoc-json typedoc.json --out-dir dist/catalog ``` ### 创建完整 A2UI catalog 对象 diff --git a/packages/genui/a2ui-cli/README.md b/packages/genui/a2ui-cli/README.md index de2326f905..33bc2db4bc 100644 --- a/packages/genui/a2ui-cli/README.md +++ b/packages/genui/a2ui-cli/README.md @@ -9,13 +9,13 @@ a system prompt for an A2UI generation agent. Generate a system prompt with the built-in A2UI basic catalog: ```bash -npx @lynx-js/a2ui-cli@latest generate prompt --out dist/a2ui-system-prompt.txt +npx @lynx-js/a2ui-cli generate prompt --out dist/a2ui-system-prompt.txt ``` Generate catalog artifacts for a custom catalog: ```bash -npx @lynx-js/a2ui-cli@latest generate catalog \ +npx @lynx-js/a2ui-cli generate catalog \ --catalog-dir src/catalog \ --source src/functions \ --out-dir dist/catalog @@ -24,7 +24,7 @@ npx @lynx-js/a2ui-cli@latest generate catalog \ Generate a system prompt for a custom catalog: ```bash -npx @lynx-js/a2ui-cli@latest generate prompt \ +npx @lynx-js/a2ui-cli generate prompt \ --catalog-dir dist/catalog \ --catalog-id https://example.com/catalogs/custom/v1/catalog.json \ --out dist/a2ui-system-prompt.txt @@ -39,7 +39,8 @@ like `/catalog.json`. ### `generate catalog` -Delegates catalog extraction to `@lynx-js/a2ui-catalog-extractor`. +Uses the internal `@lynx-js/a2ui-catalog-extractor` engine. Keep user-facing +scripts on `npx @lynx-js/a2ui-cli generate catalog`. Useful options: diff --git a/packages/genui/a2ui-prompt/README.md b/packages/genui/a2ui-prompt/README.md index 467676692a..ca524dcb35 100644 --- a/packages/genui/a2ui-prompt/README.md +++ b/packages/genui/a2ui-prompt/README.md @@ -37,8 +37,7 @@ const prompt = buildA2UISystemPrompt({ catalog }); `readA2UICatalogFromDirectory` expects generated files such as `/catalog.json` and optional function definitions under `functions/`. -Use `@lynx-js/a2ui-cli generate catalog` or -`@lynx-js/a2ui-catalog-extractor` to create those artifacts. +Use `npx @lynx-js/a2ui-cli generate catalog` to create those artifacts. ## Exports diff --git a/packages/genui/a2ui/README.md b/packages/genui/a2ui/README.md index b92f39117f..697369e5cc 100644 --- a/packages/genui/a2ui/README.md +++ b/packages/genui/a2ui/README.md @@ -1,145 +1,817 @@ -# @lynx-js/a2ui-reactlynx - -ReactLynx renderer for the A2UI v0.9 protocol. **Headless** — the package -ships no styles or chrome; consumers wrap surfaces themselves. - -This package includes: - -- ``: all-in-one component that owns a `MessageProcessor`, - subscribes to a developer-supplied `MessageStore`, and renders the - most recent surface. -- `MessageStore`: an append-only buffer of raw protocol messages the - developer pushes into from any IO transport (fetch, SSE, WebSocket, - in-process mock, …). -- `defineCatalog` / `mergeCatalogs` / `serializeCatalog`: the pluggable - catalog API. No global registry — every consumer composes the set of - components they want available. -- `catalog/`: built-in component renderers (`Text`, `Button`, - `Card`, `Column`, `Row`, `List`, `CheckBox`, `RadioGroup`, `Slider`, - `Image`, `Divider`, `Icon`, `Modal`, `Tabs`). -- `catalog//catalog.json`: per-component JSON-Schema manifests - for the agent handshake. - -## Exports - -- `@lynx-js/a2ui-reactlynx`: ``, `createMessageStore`, - `defineCatalog`, the built-ins, plus protocol types. -- `@lynx-js/a2ui-reactlynx/catalog`: re-exports of the catalog API and - built-ins for tree-shake-friendly subpath access. -- `@lynx-js/a2ui-reactlynx/catalog/`: import a single built-in. -- `@lynx-js/a2ui-reactlynx/catalog//catalog.json`: import the - per-component manifest. -- `@lynx-js/a2ui-reactlynx/store`: `MessageStore`, `MessageProcessor`, - `Resource`, payload normalizers — the pure data layer. -- `@lynx-js/a2ui-reactlynx/react`: lower-level renderer pieces for - consumers that want manual surface lifecycle control. - -## Installation - -Make sure your app provides the peer dependencies: - -- `@lynx-js/react` - -## Quick Start - -1. Create a `MessageStore`. -2. Wire your IO module (mock / SSE / fetch / …) to push raw protocol - messages into the store. -3. Render ``. +# Lynx GenUI + +English | [简体中文](./README_zh.md) + +Lynx GenUI is the generated-UI stack for developers who already know React and +want AI to assemble native Lynx interfaces from trusted components. + +If you have never heard of A2UI, think of it this way: + +- In React, your code chooses components and passes props. +- In GenUI, an agent chooses from a component catalog that you publish. +- The client still renders real ReactLynx components. The model only sends + data that says which approved component to render and what props to use. + +A2UI is the message protocol in the middle. It is not a replacement for React, +and it is not a new styling system. It is a safe, JSON-based way for an agent to +say: create a surface, render these components, update this data, and report +this user action back to the agent. + +## Why It Exists + +Generated UI becomes useful when it has product constraints: + +- The agent can only use components your app has registered. +- Component props are described with TypeScript-derived schemas. +- Model output is validated before the client renders it. +- UI can stream in progressively instead of waiting for one giant response. +- User actions are sent back as structured events, similar to React event + handlers crossing a network boundary. + +The result is not arbitrary generated code. It is a ReactLynx UI tree assembled +from a trusted catalog. + +## From React To GenUI + +Here is the React mental model: + +```tsx +function WeatherCard(props: WeatherCardProps) { + return ( + + {props.city} + {props.temperature} + + + ); +} +``` + +Here is the GenUI mental model: + +1. You publish `Card`, `Text`, `Button`, and any custom components into a + catalog. +2. The agent receives the user's request and the catalog description. +3. The agent emits A2UI messages such as "render a Card with these children". +4. The client pushes those messages into a `MessageStore`. +5. `` renders the matching ReactLynx components. +6. When a user taps a generated button, `onAction` fires and your app sends the + action back to the agent. + +The model never imports your code. It only names components that the renderer +has already allowed. + +## What You Use + +For a product app using A2UI, the important surfaces are: + +| Surface | Role | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `@lynx-js/a2ui-reactlynx` | ReactLynx renderer for A2UI v0.9. It provides ``, `MessageStore`, catalog APIs, built-in components, and protocol helpers. | +| `npx @lynx-js/a2ui-cli` | Build-time command for generating catalog artifacts from TypeScript contracts and A2UI system prompts for your agent. | +| Your agent service | A backend you own. It receives user prompts/actions, calls a model with the A2UI prompt and catalog, validates output, and returns A2UI. | +| Your transport implementation | Client code that calls your agent service, handles REST or streaming responses, pushes messages into `MessageStore`, and forwards actions. | + +## The Three Pieces + +```text +Catalog: what can be rendered + -> Agent: what should be rendered + -> Client: render it and send actions back +``` + +### Catalog + +For a React developer, the catalog is your public component API for AI. It is +the generated-UI equivalent of exporting a component plus its prop types. + +The catalog tells the agent: + +- Component names, such as `Text`, `Column`, `ProductTile`. +- Prop names and types. +- Required fields. +- Allowed enum values. +- Optional functions for dynamic formatting and validation. + +The catalog tells the client: + +- Which ReactLynx component to instantiate for each A2UI component name. +- Which component names are safe to render. + +### Agent + +The agent is a UI planner. It receives normal chat messages, reads the catalog, +and returns A2UI JSON messages. Your backend should validate those messages +before returning them to the client. + +The important product rule is: the agent designs within your catalog. If a +component is not in the catalog, it should not appear in the generated UI. + +### Client + +The client owns transport and rendering. It fetches messages from the agent, +pushes them into `MessageStore`, renders ``, and forwards generated user +actions back to your backend. + +If you know `useSyncExternalStore`, the `MessageStore` idea should feel +familiar: it is an append-only external store of protocol messages. `` +subscribes to it and updates the rendered surface as messages arrive. + +## Quickstart + +In your ReactLynx app, install the renderer package and keep the CLI as an +`npx` command. The CLI requires Node.js 22 or newer. + +```sh +pnpm add @lynx-js/a2ui-reactlynx +npx @lynx-js/a2ui-cli --help +``` + +The rest of the flow is app-local: define catalog-facing component contracts, +generate catalog artifacts, give the generated prompt to your agent service, +and render validated A2UI messages in your ReactLynx client. + +### 1. Catalog: Turn React Components Into Agent-Visible Components + +Start with a component contract. This is the part React developers already do +well: name the props and keep the component predictable. + +```tsx +/** + * Product tile for commerce recommendations. + * + * @a2uiCatalog ProductTile + */ +export interface ProductTileProps { + /** Product name shown as the title. */ + title: string; + /** Price text already localized by the caller. */ + price: string; + /** Image search query or resolved URL. */ + imageUrl?: string; +} + +export function ProductTile(props: ProductTileProps) { + return ( + + {props.imageUrl ? : null} + {props.title} + {props.price} + + ); +} + +ProductTile.displayName = 'ProductTile'; +``` + +Generate a schema for the agent: + +```sh +npx @lynx-js/a2ui-cli generate catalog --catalog-dir src/catalog --out-dir dist/catalog +``` + +Then pair the component with its manifest: + +```tsx +import { + Button, + Column, + Text, + createMessageStore, + defineCatalog, + serializeCatalog, +} from '@lynx-js/a2ui-reactlynx'; +import buttonManifest from '@lynx-js/a2ui-reactlynx/catalog/Button/catalog.json' + with { type: 'json' }; +import columnManifest from '@lynx-js/a2ui-reactlynx/catalog/Column/catalog.json' + with { type: 'json' }; +import textManifest from '@lynx-js/a2ui-reactlynx/catalog/Text/catalog.json' + with { type: 'json' }; +import productTileManifest from './dist/catalog/ProductTile/catalog.json' + with { type: 'json' }; + +export const uiCatalog = defineCatalog([ + [Text, textManifest], + [Column, columnManifest], + [Button, buttonManifest], + [ProductTile, productTileManifest], +]); + +export const catalogHandshake = serializeCatalog(uiCatalog); +export const store = createMessageStore(); +``` + +Use `catalogHandshake` when your own transport or agent consumes the client +handshake format. If your agent uses a different internal catalog format to +build prompts, add an explicit backend conversion step so the agent sees the +same component names that the client has registered. + +There is intentionally no exported "all built-ins" constant. Importing every +component makes bundle cost invisible and weakens tree-shaking. Import only the +built-in components and catalog manifests your generated UI should be allowed to +use. + +Production note: minifiers can rewrite function names. Set +`ProductTile.displayName = 'ProductTile'` or pair custom components with their +manifest so the protocol name stays stable. + +### 2. CLI: Generate Catalogs And Prompts + +The CLI is the build-time bridge between React source code and the agent. Use +it when you want repeatable artifacts instead of hand-maintained JSON: + +- `generate catalog` reads TypeScript catalog contracts and writes + `dist/catalog//catalog.json`. +- `generate prompt` reads generated catalog artifacts and writes an A2UI system + prompt for an agent. + +Run the published CLI package through `npx`: + +```sh +npx @lynx-js/a2ui-cli generate catalog \ + --catalog-dir src/catalog \ + --source src/functions \ + --out-dir dist/catalog + +npx @lynx-js/a2ui-cli generate prompt \ + --catalog-dir dist/catalog \ + --catalog-id https://example.com/catalogs/custom/v1/catalog.json \ + --out dist/a2ui-system-prompt.txt +``` + +When your build already produces TypeDoc JSON, keep the same +`npx @lynx-js/a2ui-cli` command prefix and pass that file to `generate catalog`: + +```sh +npx @lynx-js/a2ui-cli generate catalog \ + --typedoc-json typedoc.json \ + --out-dir dist/catalog +``` + +Key options: + +| Option | Use | +| ----------------------- | ---------------------------------------------------------------------------- | +| `--catalog-dir ` | Scan catalog component interfaces, or read generated artifacts for prompts. | +| `--source ` | Add source files or directories, commonly for catalog functions. Repeatable. | +| `--typedoc-json ` | Reuse an existing TypeDoc JSON project instead of running TypeDoc. | +| `--out-dir ` | Write generated catalog artifacts. Defaults to `dist/catalog`. | +| `--catalog-id ` | Set the catalog id expected in generated `createSurface` messages. | +| `--out ` | Write the generated prompt to a file instead of stdout. | +| `--appendix ` | Add extra agent instructions to the generated prompt. | + +Catalog authoring details: + +- Put `@a2uiCatalog` on the props `interface`, not on the component function. + You can pass the component name explicitly, such as `@a2uiCatalog ProductTile`. + If the tag is empty, the generator infers the name by removing a trailing + `Props` or `ComponentProps` from the interface name. +- TypeDoc comments become schema metadata: summary text and `@remarks` become + `description`, `@defaultValue` or `@default` become `default`, + `@deprecated` becomes `deprecated: true`, and optional properties are omitted + from `required`. For object or array defaults, put the JSON value inside a + code span, such as `` @defaultValue `{}` ``. +- Supported prop types include `string`, `number`, `boolean`, string literal + enums, unions, arrays, inline object types, and `Record`. +- Avoid `any`, `unknown`, `null`, `undefined`, `never`, `void`, nullable + unions, most imported aliases, referenced external interfaces, and + non-string `Record` keys. Inline the agent-facing fields directly in the + marked interface. +- The scanner accepts `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, and `.cts` files. + It ignores `.d.ts`, `node_modules`, `dist`, and `.turbo`. + +Operational notes: + +- Keep generated catalog artifacts in your package build output and commit API + reports or generated manifests when the package contract expects them. +- Regenerate catalog artifacts whenever a catalog-facing props interface or + `@a2uiFunction` definition changes. +- `generate prompt` uses the built-in A2UI basic catalog when `--catalog-dir` + is omitted; pass `--catalog-dir` for custom generated catalogs. +- The generated prompt and the client catalog must describe the same component + names and props. A mismatch can pass server validation but render as + unsupported on the client. +- `functions` and `theme` are not inferred from component props. Add them + explicitly through generated function definitions or prompt/catalog helpers. + +Keep the generated prompt with your backend code and the generated catalog +artifacts with your app package so agent and client deployments stay in sync. + +### 3. Agent: Ask For UI, Receive Validated Messages + +Your agent service is a backend route in your product, not browser code. It +should: + +- Load the A2UI system prompt generated by `npx @lynx-js/a2ui-cli generate + prompt`. +- Add conversation history, user intent, and any product state the model needs. +- Call your model provider from the server. +- Validate or repair model output before returning A2UI messages to the client. +- Keep provider credentials, base URLs, and model selection out of untrusted + browser requests. + +A typical request shape looks like this: + +```sh +curl https://your-domain.example/api/a2ui/chat \ + -H 'Content-Type: application/json' \ + -d '{ + "messages": [ + { + "role": "user", + "content": "Create a compact weather card with a photo, temperature, humidity, and a Refresh button." + } + ] + }' +``` + +The response contains `messages`. Those are not React elements. They are data +instructions that the client renderer can process. + +A tiny A2UI response looks like this: + +```json +[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["title"] + }, + { + "id": "title", + "component": "Text", + "text": "Hello from generated UI" + } + ] + } + } +] +``` + +You do not need to hand-write this JSON for normal app development. It is +useful to recognize the structure when debugging. + +Important endpoints: + +| Endpoint | Use | +| ------------------------------ | ----------------------------------------------------------------------------------- | +| `GET /api/a2ui/health` | Optional health/configuration check for your backend. | +| `POST /api/a2ui/chat` | Return one validated JSON response. | +| `POST /api/a2ui/stream` | Stream model deltas as SSE, then emit validated messages in the final `done` event. | +| `POST /api/a2ui/action` | Convert a client action into the next validated A2UI response. | +| `POST /api/a2ui/action/stream` | Stream an action response and final validation payload. | + +Common server-side configuration: + +| Variable | Purpose | +| ------------------------- | ------------------------------------------------------------------------ | +| `OPENAI_API_KEY` | Model credential kept on the server. | +| `OPENAI_MODEL` | Model id chosen by your backend. | +| `OPENAI_BASE_URL` | Optional OpenAI-compatible endpoint. | +| `OPENAI_API_STYLE` | `responses` or `chat`, depending on your provider integration. | +| `IMAGE_PROVIDER_API_KEY` | Optional image provider credential if your agent resolves image queries. | +| `A2UI_CORS_ORIGINS` | Comma-separated browser origins allowed by your server. | +| `A2UI_RATE_LIMIT_PER_MIN` | Per-client request limit for your server. | + +### 4. Client: Render Messages Like React State + +The client fetches agent output and pushes each message into the store. +`` does the protocol processing and renders the matching ReactLynx +components. ```tsx import { A2UI, Button, + Column, Text, createMessageStore, } from '@lynx-js/a2ui-reactlynx'; +import type { UserActionPayload } from '@lynx-js/a2ui-reactlynx'; const store = createMessageStore(); +const catalogs = [Text, Column, Button]; + +async function sendPrompt(content: string) { + const response = await fetch('/api/a2ui/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [{ role: 'user', content }], + }), + }); + const body = await response.json(); + for (const message of body.messages ?? []) { + store.push(message); + } +} -// Your IO module pushes raw v0.9 protocol messages into the store. -// async function streamFromAgent(input: string) { -// for await (const msg of myAgent.stream(input)) store.push(msg); -// } +async function sendAction(action: UserActionPayload) { + const response = await fetch('/api/a2ui/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + surfaceId: action.surfaceId, + action, + }), + }); + const body = await response.json(); + for (const message of body.messages ?? []) { + store.push(message); + } +} -export function A2UIScreen(): import('@lynx-js/react').ReactNode { +export function GeneratedUIScreen(): import('@lynx-js/react').ReactNode { return ( { - // Forward to your agent — push the response messages back into - // the same store. Fire-and-forget; the renderer never awaits. + void sendAction(action); }} - wrapSurface={(c) => {c}} + wrapSurface={(children) => {children}} /> ); } ``` -The `` component is intentionally minimal: +Map this back to React: + +- `MessageStore` is the external state source. +- `store.push(message)` is like receiving the next state update from the + server. +- `catalogs` is the allowlist of components the generated tree may use. +- `onAction` is like an event handler, except the event is serialized and sent + back to the agent. +- Passing a new React `key` to `` starts a fresh renderer session. + +## Transport Layer + +GenUI does not prescribe one transport. The protocol messages can travel over +REST, SSE, WebSocket, A2A, AG UI, MCP, or an in-process mock. In a React app, +the transport layer is the adapter between your product state and +`MessageStore`. + +It owns: + +- Calling the agent endpoint. +- Passing conversation history and data-model snapshots. +- Parsing JSON or streaming SSE responses. +- Pushing validated A2UI messages into the store in order. +- Forwarding `onAction` payloads back to the agent. +- Cancelling stale requests and surfacing errors. -- It owns its own `MessageProcessor` per mount; passing a different - `messageStore` instance does **not** reset internal state — use a - `key` prop derived from your turn/session id when you want a fresh - session. -- `onAction` is fire-and-forget. The renderer doesn't wait for a - response — your agent pushes follow-up messages back into the same - `messageStore`. -- `className` applies to the surface root view (`surface-${surfaceId}`). -- `wrapSurface` applies an outer wrapper around the rendered surface. -- Both can be used for multi-theme switching; choose the layer that - matches your styling strategy. +It should not own: -## Catalogs +- Rendering A2UI components directly. +- Mutating the generated component tree by hand. +- Trusting arbitrary prose from the model as UI. +- Letting browser clients override provider credentials in production. -The package intentionally **does not** ship an "all-in-one" aggregate. -Composition is per-component so bundlers can tree-shake what isn't -referenced. +### Interface Best Practices -### Bare components (renderer-only) +Keep the transport small and explicit: ```ts -import { defineCatalog, Text, Button } from '@lynx-js/a2ui-reactlynx'; +import type { MessageStore, UserActionPayload } from '@lynx-js/a2ui-reactlynx'; -const catalog = defineCatalog([Text, Button]); +interface ConversationContext { + history: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>; + dataModel: Record; +} + +interface A2UITransport { + generate(input: { + prompt: string; + conversation?: ConversationContext; + signal?: AbortSignal; + }): Promise; + respondToAction(input: { + surfaceId: string; + action: UserActionPayload; + conversation?: ConversationContext; + signal?: AbortSignal; + }): Promise; +} + +async function applyMessages( + store: MessageStore, + messages: unknown[], +): Promise { + for (const message of messages) { + store.push(message); + } +} ``` -The protocol name comes from `displayName ?? component.name`. +This keeps generated UI as data until the last step. The renderer remains the +only place that interprets A2UI messages. -> ⚠️ Production minifiers rewrite `function` names. For production -> safety, set an explicit `displayName` on every custom component, or -> pair it with its `catalog.json` manifest (the manifest key is -> authoritative). +### REST Baseline -### Paired with manifests (renderer + agent handshake) +Use routes such as `/api/a2ui/chat` and `/api/a2ui/action` when you want a +simple request/response implementation: ```ts -import { Text, defineCatalog } from '@lynx-js/a2ui-reactlynx'; -import textManifest from '@lynx-js/a2ui-reactlynx/catalog/Text/catalog.json' - with { type: 'json' }; +function extractMessages(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload; + if (typeof payload === 'string') { + try { + return extractMessages(JSON.parse(payload)); + } catch { + return []; + } + } + if (!payload || typeof payload !== 'object') return []; + + const record = payload as { + messages?: unknown; + validation?: { messages?: unknown }; + text?: unknown; + }; + if (Array.isArray(record.messages)) return record.messages; + if (Array.isArray(record.validation?.messages)) { + return record.validation.messages; + } + if (typeof record.text === 'string') return extractMessages(record.text); + return []; +} -const catalog = defineCatalog([[Text, textManifest]]); -agentChannel.handshake({ catalog: serializeCatalog(catalog) }); +async function postA2UI( + url: string, + body: unknown, + signal?: AbortSignal, +): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(`A2UI request failed: ${response.status}`); + } + + const messages = extractMessages(payload); + if (messages.length === 0) { + throw new Error('A2UI response did not include renderable messages'); + } + return messages; +} ``` -See [`src/catalog/README.md`](src/catalog/README.md) for the full -recipe (including the paste-able "every built-in" snippet). +Then wire it to the renderer: -## Custom Components +```ts +async function generate(prompt: string, signal?: AbortSignal) { + const messages = await postA2UI( + '/api/a2ui/chat', + { messages: [{ role: 'user', content: prompt }] }, + signal, + ); + await applyMessages(store, messages); +} -Any function returning a `ReactNode` works. The function's name (or -`displayName`) is the protocol name the agent will use: +async function respondToAction( + action: UserActionPayload, + signal?: AbortSignal, +) { + const messages = await postA2UI( + '/api/a2ui/action', + { surfaceId: action.surfaceId, action }, + signal, + ); + await applyMessages(store, messages); +} +``` -```tsx -function MyChart(props: { data: number[] }) { ... } -MyChart.displayName = 'MyChart'; +### SSE Streaming -; -// Agent emits `{ component: 'MyChart', data: [...] }` → renders MyChart. +Use routes such as `/api/a2ui/stream` and `/api/a2ui/action/stream` when you +want to show generation progress. The server emits: + +- `delta`: raw model text, useful for an inspector or loading state. +- `repair`: optional metadata when the server had to repair invalid model + output. +- `done`: the final validated payload. Use the messages from this event for + rendering. +- `error`: structured failure payload. + +```ts +interface SseFrame { + event: string; + data: unknown; +} + +function parseSseFrame(frame: string): SseFrame | null { + const lines = frame.split(/\r?\n/u); + let event = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + event = line.slice('event:'.length).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()); + } + } + + if (dataLines.length === 0) return null; + const raw = dataLines.join('\n'); + try { + return { event, data: JSON.parse(raw) }; + } catch { + return { event, data: raw }; + } +} + +async function readA2UISse( + response: Response, + onDelta?: (text: string) => void, +): Promise { + const reader = response.body?.getReader(); + if (!reader) return []; + + const decoder = new TextDecoder(); + let buffer = ''; + let generatedText = ''; + + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + + const frames = buffer.split(/\r?\n\r?\n/u); + buffer = frames.pop() ?? ''; + + for (const frame of frames) { + const parsed = parseSseFrame(frame); + if (!parsed) continue; + + if (parsed.event === 'delta') { + const text = (parsed.data as { text?: unknown }).text; + if (typeof text === 'string') { + generatedText += text; + onDelta?.(generatedText); + } + continue; + } + + if (parsed.event === 'done') { + const messages = extractMessages(parsed.data); + if (messages.length === 0) { + throw new Error('A2UI stream finished without renderable messages'); + } + return messages; + } + + if (parsed.event === 'error') { + throw new Error(JSON.stringify(parsed.data)); + } + } + + if (done) break; + } + + return extractMessages(generatedText); +} ``` -If you want schema introspection for a custom component, generate the -manifest with `@lynx-js/a2ui-catalog-extractor` against your interface -and pair it with the component the same way as the built-ins. +Avoid rendering every `delta` as A2UI. During streaming, the model text may be +an incomplete JSON array. Render from the final `done` event by default. If you +choose partial rendering, only publish complete parsed message objects and +replace them with the final validated messages when `done` arrives. + +## Operational Best Practices + +- Keep one active generation per conversation surface. Abort or ignore older + requests when a new prompt starts. +- Use a separate `AbortController` for user actions. An old action response + should not update the UI after a newer action has started. +- Render from `done.validation.messages` or `messages`. Treat `delta` as + progress text for inspectors and loading states. +- Push messages into `MessageStore` in server order. Do not sort, merge, or + deduplicate them unless you understand the protocol consequences. +- Keep conversation history and the current data-model snapshot outside + `MessageStore`; include them in the next agent request when you need coherent + multi-turn updates. +- Send action requests with both `surfaceId` and the full `action` payload. + Action responses normally update the existing surface rather than creating a + new one. +- Normalize all supported response formats: direct arrays, `{ messages }`, + `{ validation: { messages } }`, and stringified JSON. +- Check `content-type`. Your endpoints may return JSON or `text/event-stream` + depending on the route. +- Parse non-2xx responses as structured JSON when possible, then fall back to a + status-based error. +- Keep endpoint allowlists strict. The hosted Playground should only talk to + trusted GenUI endpoints. +- Do not pass model API keys, base URLs, or model ids from a browser in + production. Keep provider selection and credentials on the server. +- Configure CORS and rate limits on the server before exposing the agent to + browsers. +- Version your catalog contract. The agent catalog and client catalog must + agree on component names and props, or validated output may still render as + unsupported on the client. +- Use deterministic mocks for tests. A transport can be an in-process async + generator that pushes known A2UI messages into the store. + +Common mistakes: + +- Rendering raw model prose instead of validated A2UI messages. +- Reusing one `MessageStore` for unrelated conversations without remounting + ``. +- Dropping `conversation.dataModel`, which makes follow-up actions lose state. +- Retrying non-idempotent actions automatically, which can apply the same user + intent twice. +- Allowing generated image URLs, remote endpoints, or provider overrides from + untrusted browser input. + +## Try The Playground + +The hosted playground is the fastest way to see the whole loop before +integrating it into an app: + +[https://lynx-stack.dev/a2ui/](https://lynx-stack.dev/a2ui/) + +Use the hosted page to try the demos, inspect generated A2UI JSON, browse the +catalog, and preview Lynx surfaces. + +Use the playground to: + +- Describe UI in natural language and inspect the generated A2UI JSON. +- Browse the component catalog like a React component library. +- Preview the generated Lynx surface. +- Test action flows such as submit, refresh, and selection. +- Generate preview URLs and QR codes for native Lynx testing. + +## Glossary For React Developers + +| GenUI term | React-friendly meaning | +| ------------------ | -------------------------------------------------------------------------------------- | +| A2UI | JSON messages that describe UI changes. Similar to a serialized, constrained UI tree. | +| Surface | A generated UI root, similar to a mounted app region. | +| Catalog | The approved component library and prop schema exposed to the agent. | +| `MessageStore` | Append-only external store that receives protocol messages. | +| `updateComponents` | "Render these component instances with these props." | +| `updateDataModel` | "Patch the data used by bound props." Similar to remote state updates. | +| Action | A generated UI event, similar to `onClick`, sent back to the agent as structured data. | + +## Protocol Notes + +The current A2UI path targets A2UI v0.9. + +- The model must output a raw JSON array, not Markdown. +- A fresh response starts with `createSurface`, followed by + `updateComponents` containing a `root` component. +- Components form a flat graph. Children are referenced by id rather than + inlined. +- Data bindings use JSON Pointer paths and must be populated by + `updateDataModel`. +- Interactive components emit action payloads. The client posts those actions + to the agent, and the agent returns update messages for the existing surface. + +## Testing And Quality + +Use your app's normal test runner. The high-value checks are: + +- Unit-test catalog registration so every component name in generated messages + maps to the ReactLynx component you expect. +- Unit-test transport parsing with deterministic JSON and SSE fixtures, + including malformed responses and aborts. +- Replay saved A2UI message arrays through `` so renderer regressions are + visible without calling a model. +- E2E-test one prompt flow and one action flow with mocked agent responses + before adding model-backed tests. + +## Product Direction + +GenUI is designed around a few commitments: + +- React remains the implementation layer. The agent chooses from components you + own. +- The catalog is the product contract. It keeps generated UI aligned with your + design system and platform constraints. +- Progressive rendering should make the UI useful before a turn fully + completes. +- Transports are replaceable. REST, SSE, WebSocket, A2A, AG UI, or MCP can all + carry the same A2UI messages. +- Generated UI should be inspectable, replayable, and judgeable in automated + workflows. + +Start with the hosted Playground, then generate a small catalog in your app and +wire one prompt route plus one action route before expanding to richer +components. diff --git a/packages/genui/a2ui/README_zh.md b/packages/genui/a2ui/README_zh.md new file mode 100644 index 0000000000..0097293418 --- /dev/null +++ b/packages/genui/a2ui/README_zh.md @@ -0,0 +1,726 @@ +# Lynx GenUI + +[English](./README.md) | 简体中文 + +Lynx GenUI 面向已经熟悉 React 的开发者:你继续写可信的 ReactLynx 组件,AI 只负责从这些组件中选择、组合,并生成 Lynx +原生界面。 + +如果你第一次听说 A2UI,可以先这样理解: + +- 在 React 里,是你的代码选择组件并传入 props。 +- 在 GenUI 里,是 Agent 从你发布的组件 Catalog 中选择组件。 +- Client 仍然渲染真实的 ReactLynx 组件。模型只发送数据,告诉渲染器用哪个已授权组件、传哪些 props。 + +A2UI 是中间的消息协议。它不是 React 的替代品,也不是新的样式系统。它只是用安全的 JSON 数据表达:创建一个 surface、渲染这些组件、更新这些数据、把这个用户操作回传给 Agent。 + +## 为什么需要它 + +生成式 UI 只有在有产品约束时才真正可用: + +- Agent 只能使用你的应用注册过的组件。 +- 组件 props 由 TypeScript 契约生成 schema。 +- 模型输出会先经过校验,再交给 Client 渲染。 +- UI 可以渐进式流式生成,而不是等一个巨大响应结束。 +- 用户操作会以结构化事件回传,类似跨网络边界的 React event handler。 + +最终产物不是任意生成代码,而是由可信 Catalog 组装出的 ReactLynx UI 树。 + +## 从 React 过渡到 GenUI + +这是 React 心智模型: + +```tsx +function WeatherCard(props: WeatherCardProps) { + return ( + + {props.city} + {props.temperature} + + + ); +} +``` + +这是 GenUI 心智模型: + +1. 你把 `Card`、`Text`、`Button` 以及自定义组件发布到 Catalog。 +2. Agent 收到用户请求和 Catalog 描述。 +3. Agent 输出 A2UI 消息,例如“渲染一个 Card,并带有这些子节点”。 +4. Client 把这些消息写入 `MessageStore`。 +5. `` 渲染对应的 ReactLynx 组件。 +6. 用户点击生成出来的按钮时,`onAction` 触发,应用把 action 发回 Agent。 + +模型不会 import 你的代码。它只能命名渲染器已经授权的组件。 + +## 你会用到什么 + +对于正在接入 A2UI 的产品应用,真正需要关注的是这些对外使用面: + +| 使用面 | 作用 | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `@lynx-js/a2ui-reactlynx` | 面向 A2UI v0.9 的 ReactLynx 渲染器,提供 ``、`MessageStore`、Catalog API、内置组件和协议辅助能力。 | +| `npx @lynx-js/a2ui-cli` | 构建期命令,用来从 TypeScript 契约生成 catalog artifacts,并为你的 Agent 生成 A2UI system prompt。 | +| 你的 Agent 服务 | 你自己维护的后端。它接收用户 prompt/action,带着 A2UI prompt 和 Catalog 请求模型,校验输出,然后返回 A2UI messages。 | +| 你的传输层实现 | Client 侧调用 Agent 服务的适配层,负责处理 REST 或流式响应,把 messages 写入 `MessageStore`,并转发 generated UI 中触发的 actions。 | + +## 三个核心部分 + +```text +Catalog:什么能被渲染 + -> Agent:应该渲染什么 + -> Client:渲染它,并把操作发回去 +``` + +### Catalog + +对 React 开发者来说,Catalog 就是“暴露给 AI 的公开组件 API”。它相当于导出组件以及它的 props 类型。 + +Catalog 告诉 Agent: + +- 组件名,例如 `Text`、`Column`、`ProductTile`。 +- props 名称和类型。 +- 哪些字段必填。 +- enum 字段允许哪些值。 +- 动态格式化和校验里可以调用哪些函数。 + +Catalog 告诉 Client: + +- 每个 A2UI 组件名对应哪个 ReactLynx 组件。 +- 哪些组件名可以安全渲染。 + +### Agent + +Agent 是 UI 规划器。它接收普通 chat messages,读取 Catalog,然后返回 A2UI JSON 消息。你的后端应该先校验这些消息,再交给 +Client。 + +关键产品规则是:Agent 必须在你的 Catalog 里做设计。Catalog 里没有的组件,不应该出现在生成 UI 中。 + +### Client + +Client 负责传输和渲染。它从 Agent 获取消息,把消息写入 `MessageStore`,渲染 ``,并把生成 UI 中的用户操作转发给你的后端。 + +如果你了解 `useSyncExternalStore`,`MessageStore` 会很容易理解:它是一个只追加的外部 store,保存协议消息。`` +订阅它,并在新消息到达时更新界面。 + +## 快速开始 + +在你的 ReactLynx 应用中安装渲染器包,并通过 `npx` 使用 CLI。CLI 需要 Node.js 22 或更新版本。 + +```sh +pnpm add @lynx-js/a2ui-reactlynx +npx @lynx-js/a2ui-cli --help +``` + +后续流程都发生在你的应用里:定义面向 Catalog 的组件契约,生成 catalog artifacts,把生成的 prompt 交给你的 Agent 服务,并在 +ReactLynx Client 中渲染已校验的 A2UI messages。 + +### 1. Catalog:把 React 组件变成 Agent 可见的组件 + +先从组件契约开始。这正是 React 开发者已经熟悉的部分:命名 props,并让组件行为保持可预测。 + +```tsx +/** + * Product tile for commerce recommendations. + * + * @a2uiCatalog ProductTile + */ +export interface ProductTileProps { + /** Product name shown as the title. */ + title: string; + /** Price text already localized by the caller. */ + price: string; + /** Image search query or resolved URL. */ + imageUrl?: string; +} + +export function ProductTile(props: ProductTileProps) { + return ( + + {props.imageUrl ? : null} + {props.title} + {props.price} + + ); +} + +ProductTile.displayName = 'ProductTile'; +``` + +为 Agent 生成 schema: + +```sh +npx @lynx-js/a2ui-cli generate catalog --catalog-dir src/catalog --out-dir dist/catalog +``` + +然后把组件和 manifest 配对: + +```tsx +import { + Button, + Column, + Text, + createMessageStore, + defineCatalog, + serializeCatalog, +} from '@lynx-js/a2ui-reactlynx'; +import buttonManifest from '@lynx-js/a2ui-reactlynx/catalog/Button/catalog.json' + with { type: 'json' }; +import columnManifest from '@lynx-js/a2ui-reactlynx/catalog/Column/catalog.json' + with { type: 'json' }; +import textManifest from '@lynx-js/a2ui-reactlynx/catalog/Text/catalog.json' + with { type: 'json' }; +import productTileManifest from './dist/catalog/ProductTile/catalog.json' + with { type: 'json' }; + +export const uiCatalog = defineCatalog([ + [Text, textManifest], + [Column, columnManifest], + [Button, buttonManifest], + [ProductTile, productTileManifest], +]); + +export const catalogHandshake = serializeCatalog(uiCatalog); +export const store = createMessageStore(); +``` + +当你自己的传输层或 Agent 消费客户端握手格式时,可以使用 `catalogHandshake`。如果你的 Agent 使用另一种内部 catalog +格式构建 prompt,请在后端做显式转换,确保 Agent 看到的组件名和 Client 注册的组件名一致。 + +包里故意没有导出 “all built-ins” 常量。一次性引入所有组件会让包体成本不可见,也会削弱 tree-shaking。只导入生成式 UI +确实允许使用的内置组件和 catalog manifests。 + +生产环境注意:压缩工具可能改写函数名。请设置 `ProductTile.displayName = 'ProductTile'`,或将自定义组件与 manifest +配对,确保协议里的组件名稳定。 + +### 2. CLI:生成 Catalog 和 Prompt + +CLI 是 React 源码和 Agent 之间的构建期桥梁。需要稳定、可重复的 artifacts 时,不要手写 JSON,交给 CLI 生成: + +- `generate catalog` 读取 TypeScript catalog 契约,并写出 + `dist/catalog//catalog.json`。 +- `generate prompt` 读取生成好的 catalog artifacts,并为 Agent 写出 A2UI system + prompt。 + +通过 `npx` 执行公开 CLI 包: + +```sh +npx @lynx-js/a2ui-cli generate catalog \ + --catalog-dir src/catalog \ + --source src/functions \ + --out-dir dist/catalog + +npx @lynx-js/a2ui-cli generate prompt \ + --catalog-dir dist/catalog \ + --catalog-id https://example.com/catalogs/custom/v1/catalog.json \ + --out dist/a2ui-system-prompt.txt +``` + +如果你的构建流程已经产出 TypeDoc JSON,仍然使用同一个 +`npx @lynx-js/a2ui-cli` 命令前缀,把该文件传给 `generate catalog`: + +```sh +npx @lynx-js/a2ui-cli generate catalog \ + --typedoc-json typedoc.json \ + --out-dir dist/catalog +``` + +常用选项: + +| 选项 | 用途 | +| ----------------------- | -------------------------------------------------------------- | +| `--catalog-dir ` | 扫描 catalog 组件接口;生成 prompt 时则读取已生成 artifacts。 | +| `--source ` | 增加要扫描的源码文件或目录,常用于 catalog functions。可重复。 | +| `--typedoc-json ` | 复用已有 TypeDoc JSON project,不重新运行 TypeDoc。 | +| `--out-dir ` | 写出生成的 catalog artifacts,默认 `dist/catalog`。 | +| `--catalog-id ` | 设置生成的 `createSurface` 消息中要求使用的 catalog id。 | +| `--out ` | 将生成的 prompt 写入文件,而不是输出到 stdout。 | +| `--appendix ` | 为生成的 prompt 添加额外 Agent 指令。 | + +Catalog 编写细节: + +- 把 `@a2uiCatalog` 放在 props `interface` 上,不要放在组件函数上。你可以显式写组件名,例如 + `@a2uiCatalog ProductTile`。如果 tag 内容为空,生成器会从 interface 名中去掉结尾的 `Props` 或 + `ComponentProps` 来推断组件名。 +- TypeDoc 注释会变成 schema 元数据:summary 文本和 `@remarks` 会进入 `description`,`@defaultValue` 或 + `@default` 会进入 `default`,`@deprecated` 会变成 `deprecated: true`,可选属性不会放入 + `required`。对象或数组默认值建议把 JSON 放进 code span,例如 `` @defaultValue `{}` ``。 +- 支持的 props 类型包括 `string`、`number`、`boolean`、字符串字面量 enum、union、数组、内联 object type,以及 + `Record`。 +- 避免使用 `any`、`unknown`、`null`、`undefined`、`never`、`void`、nullable union、大多数导入 alias、外部 + interface reference,以及非 string 的 `Record` key。请把 Agent 可见字段直接内联在被标记的 interface 里。 +- 扫描器接受 `.ts`、`.tsx`、`.js`、`.jsx`、`.mts` 和 `.cts` 文件;会忽略 `.d.ts`、`node_modules`、`dist` 和 + `.turbo`。 + +实现注意事项: + +- 将生成的 catalog artifacts 放进包的构建输出;如果包契约依赖这些 manifests,记得随变更一起提交。 +- catalog-facing props interface 或 `@a2uiFunction` 定义变化后,都要重新生成 artifacts。 +- 省略 `--catalog-dir` 时,`generate prompt` 会使用内置 A2UI basic catalog;自定义 catalog 必须传入 `--catalog-dir`。 +- 生成的 prompt 和 Client catalog 必须描述同一组组件名与 props。二者不一致时,server 侧校验可能通过,但 Client 侧仍可能渲染为 unsupported。 +- `functions` 和 `theme` 不会从组件 props 自动推断。需要这些信息时,请通过生成的 function definitions 或 prompt/catalog helper 显式加入。 + +将生成的 prompt 随后端代码一起管理,将生成的 catalog artifacts 随应用包一起管理,确保 Agent 和 Client 发布时保持一致。 + +### 3. Agent:描述 UI,收到已校验消息 + +你的 Agent 服务应该是产品自己的后端路由,而不是浏览器代码。它应该: + +- 读取 `npx @lynx-js/a2ui-cli generate prompt` 生成的 A2UI system prompt。 +- 加入 conversation history、用户意图,以及模型需要的产品状态。 +- 在服务端请求模型供应商。 +- 校验或修复模型输出,再把 A2UI messages 返回给 Client。 +- 不要让不可信浏览器请求传入模型凭证、base URL 或模型选择。 + +典型请求结构如下: + +```sh +curl https://your-domain.example/api/a2ui/chat \ + -H 'Content-Type: application/json' \ + -d '{ + "messages": [ + { + "role": "user", + "content": "Create a compact weather card with a photo, temperature, humidity, and a Refresh button." + } + ] + }' +``` + +响应里会包含 `messages`。这些不是 React elements,而是 Client 渲染器可以处理的数据指令。 + +一个极简 A2UI 响应长这样: + +```json +[ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json" + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["title"] + }, + { + "id": "title", + "component": "Text", + "text": "Hello from generated UI" + } + ] + } + } +] +``` + +正常开发时不需要手写这些 JSON。理解这个结构主要是为了方便调试。 + +主要接口: + +| Endpoint | 用途 | +| ------------------------------ | ------------------------------------------------------------------- | +| `GET /api/a2ui/health` | 可选的后端健康检查和配置检查。 | +| `POST /api/a2ui/chat` | 返回一次已校验的 JSON 响应。 | +| `POST /api/a2ui/stream` | 通过 SSE 流式返回模型 delta,并在最终 `done` 事件里给出已校验消息。 | +| `POST /api/a2ui/action` | 将 Client action 转换成下一轮已校验的 A2UI 响应。 | +| `POST /api/a2ui/action/stream` | 流式返回 action 响应和最终校验结果。 | + +常见服务端配置: + +| Variable | 作用 | +| ------------------------- | -------------------------------------------------- | +| `OPENAI_API_KEY` | 保存在服务端的模型凭证。 | +| `OPENAI_MODEL` | 由你的后端选择的模型 id。 | +| `OPENAI_BASE_URL` | 可选的 OpenAI 兼容 endpoint。 | +| `OPENAI_API_STYLE` | `responses` 或 `chat`,取决于你的模型供应商集成。 | +| `IMAGE_PROVIDER_API_KEY` | 可选图片供应商凭证,如果你的 Agent 需要解析图片。 | +| `A2UI_CORS_ORIGINS` | 允许访问你的服务的浏览器来源,多个来源用逗号分隔。 | +| `A2UI_RATE_LIMIT_PER_MIN` | 单客户端每分钟请求限制。 | + +### 4. Client:像处理 React 状态一样渲染消息 + +Client 获取 Agent 输出,并把每条消息写入 store。`` 负责处理协议并渲染对应的 ReactLynx 组件。 + +```tsx +import { + A2UI, + Button, + Column, + Text, + createMessageStore, +} from '@lynx-js/a2ui-reactlynx'; +import type { UserActionPayload } from '@lynx-js/a2ui-reactlynx'; + +const store = createMessageStore(); +const catalogs = [Text, Column, Button]; + +async function sendPrompt(content: string) { + const response = await fetch('/api/a2ui/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [{ role: 'user', content }], + }), + }); + const body = await response.json(); + for (const message of body.messages ?? []) { + store.push(message); + } +} + +async function sendAction(action: UserActionPayload) { + const response = await fetch('/api/a2ui/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + surfaceId: action.surfaceId, + action, + }), + }); + const body = await response.json(); + for (const message of body.messages ?? []) { + store.push(message); + } +} + +export function GeneratedUIScreen(): import('@lynx-js/react').ReactNode { + return ( + { + void sendAction(action); + }} + wrapSurface={(children) => {children}} + /> + ); +} +``` + +把它映射回 React: + +- `MessageStore` 是外部状态源。 +- `store.push(message)` 类似从 server 收到下一次状态更新。 +- `catalogs` 是生成树允许使用的组件白名单。 +- `onAction` 类似 event handler,只是事件会被序列化并发回 Agent。 +- 给 `` 传入新的 React `key` 可以开启一个全新的渲染会话。 + +## 传输层实现 + +GenUI 不限定传输方式。协议消息可以通过 REST、SSE、WebSocket、A2A、AG UI、MCP,或者进程内 mock 传递。在 React +应用里,传输层是你的产品状态和 `MessageStore` 之间的适配层。 + +它负责: + +- 调用 Agent endpoint。 +- 传递 conversation history 和 data-model snapshot。 +- 解析 JSON 或流式 SSE 响应。 +- 按顺序把已校验 A2UI 消息写入 store。 +- 将 `onAction` payload 转发回 Agent。 +- 取消已失效的请求,并向 UI 返回可展示的错误状态。 + +不要让传输层负责: + +- 直接渲染 A2UI 组件。 +- 手动修改生成出来的组件树。 +- 把未经校验的模型文本当成 UI。 +- 在生产环境允许浏览器覆盖模型供应商凭证。 + +### 接口设计最佳实践 + +让传输层保持小而明确: + +```ts +import type { MessageStore, UserActionPayload } from '@lynx-js/a2ui-reactlynx'; + +interface ConversationContext { + history: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>; + dataModel: Record; +} + +interface A2UITransport { + generate(input: { + prompt: string; + conversation?: ConversationContext; + signal?: AbortSignal; + }): Promise; + respondToAction(input: { + surfaceId: string; + action: UserActionPayload; + conversation?: ConversationContext; + signal?: AbortSignal; + }): Promise; +} + +async function applyMessages( + store: MessageStore, + messages: unknown[], +): Promise { + for (const message of messages) { + store.push(message); + } +} +``` + +这样生成 UI 在最后一步之前始终是数据。只有渲染器负责解释 A2UI 消息。 + +### REST 基线实现 + +如果只需要简单的 request/response,可以使用 `/api/a2ui/chat` 和 `/api/a2ui/action` 这样的路由: + +```ts +function extractMessages(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload; + if (typeof payload === 'string') { + try { + return extractMessages(JSON.parse(payload)); + } catch { + return []; + } + } + if (!payload || typeof payload !== 'object') return []; + + const record = payload as { + messages?: unknown; + validation?: { messages?: unknown }; + text?: unknown; + }; + if (Array.isArray(record.messages)) return record.messages; + if (Array.isArray(record.validation?.messages)) { + return record.validation.messages; + } + if (typeof record.text === 'string') return extractMessages(record.text); + return []; +} + +async function postA2UI( + url: string, + body: unknown, + signal?: AbortSignal, +): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal, + }); + + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(`A2UI request failed: ${response.status}`); + } + + const messages = extractMessages(payload); + if (messages.length === 0) { + throw new Error('A2UI response did not include renderable messages'); + } + return messages; +} +``` + +接入渲染器: + +```ts +async function generate(prompt: string, signal?: AbortSignal) { + const messages = await postA2UI( + '/api/a2ui/chat', + { messages: [{ role: 'user', content: prompt }] }, + signal, + ); + await applyMessages(store, messages); +} + +async function respondToAction( + action: UserActionPayload, + signal?: AbortSignal, +) { + const messages = await postA2UI( + '/api/a2ui/action', + { surfaceId: action.surfaceId, action }, + signal, + ); + await applyMessages(store, messages); +} +``` + +### SSE 流式实现 + +如果希望展示生成进度,可以使用 `/api/a2ui/stream` 和 `/api/a2ui/action/stream` 这样的路由。server 会发出: + +- `delta`:模型原始文本,适合给 inspector 或 loading state 使用。 +- `repair`:可选元数据,表示 server 曾尝试修复无效模型输出。 +- `done`:最终校验后的 payload。渲染时应使用这个事件中的 messages。 +- `error`:结构化错误 payload。 + +```ts +interface SseFrame { + event: string; + data: unknown; +} + +function parseSseFrame(frame: string): SseFrame | null { + const lines = frame.split(/\r?\n/u); + let event = 'message'; + const dataLines: string[] = []; + + for (const line of lines) { + if (line.startsWith('event:')) { + event = line.slice('event:'.length).trim(); + } else if (line.startsWith('data:')) { + dataLines.push(line.slice('data:'.length).trimStart()); + } + } + + if (dataLines.length === 0) return null; + const raw = dataLines.join('\n'); + try { + return { event, data: JSON.parse(raw) }; + } catch { + return { event, data: raw }; + } +} + +async function readA2UISse( + response: Response, + onDelta?: (text: string) => void, +): Promise { + const reader = response.body?.getReader(); + if (!reader) return []; + + const decoder = new TextDecoder(); + let buffer = ''; + let generatedText = ''; + + while (true) { + const { done, value } = await reader.read(); + buffer += decoder.decode(value, { stream: !done }); + + const frames = buffer.split(/\r?\n\r?\n/u); + buffer = frames.pop() ?? ''; + + for (const frame of frames) { + const parsed = parseSseFrame(frame); + if (!parsed) continue; + + if (parsed.event === 'delta') { + const text = (parsed.data as { text?: unknown }).text; + if (typeof text === 'string') { + generatedText += text; + onDelta?.(generatedText); + } + continue; + } + + if (parsed.event === 'done') { + const messages = extractMessages(parsed.data); + if (messages.length === 0) { + throw new Error('A2UI stream finished without renderable messages'); + } + return messages; + } + + if (parsed.event === 'error') { + throw new Error(JSON.stringify(parsed.data)); + } + } + + if (done) break; + } + + return extractMessages(generatedText); +} +``` + +不要默认把每个 `delta` 都当成 A2UI 渲染。流式过程中模型文本经常是不完整 JSON 数组。默认应该从最终 `done` +事件渲染。如果你选择部分渲染,只发布已经完整解析出的 message object,并在 `done` 到达后用最终已校验 messages 覆盖。 + +## 传输层实现注意事项 + +- 每个 conversation surface 保持一个活跃生成请求。新 prompt 开始时,取消或忽略旧请求。 +- 用户 action 使用单独的 `AbortController`。旧的 action response 不应该在新 action 开始后继续更新 UI。 +- 从 `done.validation.messages` 或 `messages` 渲染最终结果。`delta` 只用于进度展示和调试。 +- 按 server 顺序写入 `MessageStore`。除非你非常理解协议后果,否则不要排序、合并或去重。 +- 将 conversation history 和当前 data-model snapshot 保存在 `MessageStore` 之外;需要连贯多轮更新时,在下一次 Agent 请求中带上它们。 +- action 请求要同时携带 `surfaceId` 和完整 `action` payload。action response 通常更新已有 surface,而不是创建新 surface。 +- 统一处理支持的响应格式:直接数组、`{ messages }`、`{ validation: { messages } }`、以及字符串化 JSON。 +- 检查 `content-type`。你的 endpoint 可能根据 route 返回 JSON 或 `text/event-stream`。 +- 非 2xx 响应优先按结构化 JSON 解析错误,再回退到基于 status 的错误。 +- endpoint 白名单要严格。线上 Playground 只应该访问可信 GenUI endpoint。 +- 生产环境不要让浏览器传入模型 API key、base URL 或 model id。模型供应商选择和凭证应留在服务端。 +- 对浏览器暴露 Agent 前,先配置好 CORS 和 rate limit。 +- 为 Catalog 契约做版本管理。Agent Catalog 和 Client Catalog 必须在组件名与 props 上一致,否则已校验输出在 Client 侧仍可能变成 unsupported。 +- 测试里优先使用确定性的 mock。传输层可以是进程内 async generator,把固定 A2UI messages 写入 store。 + +常见错误: + +- 渲染未经校验的模型文本,而不是已校验 A2UI messages。 +- 对无关 conversation 复用同一个 `MessageStore`,但没有通过 `` 重新挂载。 +- 丢失 `conversation.dataModel`,导致后续 action 失去状态上下文。 +- 自动重试非幂等 action,导致同一个用户意图被执行两次。 +- 允许不可信浏览器输入控制图片 URL、远端 endpoint 或模型供应商配置覆盖。 + +## 体验 Playground + +在接入应用前,线上 Playground 是理解完整链路最快的入口: + +[https://lynx-stack.dev/a2ui/](https://lynx-stack.dev/a2ui/) + +通过线上页面可以试用 demo、查看生成出的 A2UI JSON、浏览 Catalog,并预览 Lynx surface。 + +你可以在 Playground 中: + +- 用自然语言描述 UI,并查看生成出的 A2UI JSON。 +- 像浏览 React 组件库一样浏览组件 Catalog。 +- 预览生成出的 Lynx 界面。 +- 测试 submit、refresh、selection 等 action 流程。 +- 生成预览链接和二维码,用于 Lynx 原生调试。 + +## 给 React 开发者的术语表 + +| GenUI 术语 | React 视角下的含义 | +| ------------------ | ---------------------------------------------------------------- | +| A2UI | 描述 UI 变化的 JSON 消息,类似受约束、可序列化的 UI tree。 | +| Surface | 生成式 UI 的根节点,类似一个被挂载的应用区域。 | +| Catalog | 暴露给 Agent 的组件白名单和 props schema。 | +| `MessageStore` | 只追加的外部 store,用来接收协议消息。 | +| `updateComponents` | “用这些 props 渲染这些组件实例”。 | +| `updateDataModel` | “更新绑定 props 使用的数据”,类似远端状态更新。 | +| Action | 生成 UI 中的事件,类似 `onClick`,会作为结构化数据发回给 Agent。 | + +## 协议要点 + +当前 A2UI 路径基于 A2UI v0.9。 + +- 模型必须输出原始 JSON 数组,而不是 Markdown。 +- 全新响应以 `createSurface` 开始,随后是包含 `root` 组件的 `updateComponents`。 +- 组件是扁平图结构,子节点通过 id 引用,不能内联。 +- 数据绑定使用 JSON Pointer,并且必须由 `updateDataModel` 填充。 +- 可交互组件会发出 action payload。Client 将 action 发送给 Agent,Agent 再返回同一个 surface 的更新消息。 + +## 测试与质量 + +使用你应用已有的测试工具即可。最有价值的检查包括: + +- 单测 Catalog 注册,确保 generated messages 里的每个组件名都能映射到预期的 ReactLynx 组件。 +- 用确定性的 JSON 和 SSE fixtures 单测传输层解析,包括异常响应和取消请求。 +- 将保存下来的 A2UI message arrays 回放到 ``,不用请求模型也能发现渲染回归。 +- 在引入真实模型测试前,先用 mock agent response 做一条 prompt 流程和一条 action 流程的 E2E。 + +## 产品方向 + +GenUI 围绕几个原则演进: + +- React 仍然是实现层。Agent 只从你拥有的组件里选择。 +- Catalog 是产品契约,用来让生成 UI 对齐设计系统和平台约束。 +- 渐进式渲染应该让用户在完整响应结束前已经看到有价值的界面。 +- 传输层可以替换。REST、SSE、WebSocket、A2A、AG UI 或 MCP 都可以承载同样的 A2UI 消息。 +- 生成式 UI 应该能被检查、回放,并进入自动化评估流程。 + +先从线上 Playground 开始体验,然后在你的应用里生成一个小 Catalog,接通一条 prompt route 和一条 action route,再逐步扩展更丰富的组件。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2689b891a1..d8191dfd4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2294,6 +2294,12 @@ importers: specifier: 19.2.4 version: 19.2.4(react@19.2.4) devDependencies: + '@lynx-js/a2ui-cli': + specifier: workspace:* + version: link:../packages/genui/a2ui-cli + '@lynx-js/a2ui-reactlynx': + specifier: workspace:* + version: link:../packages/genui/a2ui '@lynx-js/chunk-loading-webpack-plugin': specifier: workspace:* version: link:../packages/webpack/chunk-loading-webpack-plugin diff --git a/website/package.json b/website/package.json index bd7bf70cca..eecf16b975 100644 --- a/website/package.json +++ b/website/package.json @@ -19,6 +19,8 @@ "react-dom": "19.2.4" }, "devDependencies": { + "@lynx-js/a2ui-cli": "workspace:*", + "@lynx-js/a2ui-reactlynx": "workspace:*", "@lynx-js/chunk-loading-webpack-plugin": "workspace:*", "@lynx-js/config-rsbuild-plugin": "workspace:*", "@lynx-js/css-extract-webpack-plugin": "workspace:*", diff --git a/website/sidebars/genui.ts b/website/sidebars/genui.ts index 2ecde8c44d..e993a4c431 100644 --- a/website/sidebars/genui.ts +++ b/website/sidebars/genui.ts @@ -13,40 +13,51 @@ export function createGenUIGuideReadmeDocs(options: { en: SidebarGroup; zh: SidebarGroup; } { - const packageRoot = path.join( + const a2uiPackageRoot = path.join( options.repositoryRoot, - 'packages/genui/a2ui-catalog-extractor', + 'packages/genui/a2ui', ); syncReadme({ - languageSwitch: - 'English | 简体中文', + languageSwitch: 'English | 简体中文', outFile: path.join( options.websiteRoot, - 'docs/en/guide/genui/a2ui-catalog-extractor.md', + 'docs/en/guide/genui/a2ui.md', ), - sourceFile: path.join(packageRoot, 'README.md'), - switchPattern: /^English \| \[简体中文\]\(\.\/readme\.zh_cn\.md\)$/m, + sourceFile: path.join(a2uiPackageRoot, 'README.md'), + switchPattern: /^English \| \[简体中文\]\(\.\/README_zh\.md\)$/m, }); syncReadme({ - languageSwitch: - 'English | 简体中文', + languageSwitch: 'English | 简体中文', outFile: path.join( options.websiteRoot, - 'docs/zh/guide/genui/a2ui-catalog-extractor.md', + 'docs/zh/guide/genui/a2ui.md', ), - sourceFile: path.join(packageRoot, 'readme.zh_cn.md'), + sourceFile: path.join(a2uiPackageRoot, 'README_zh.md'), switchPattern: /^\[English\]\(\.\/README\.md\) \| 简体中文$/m, }); + removeGeneratedDoc( + path.join( + options.websiteRoot, + 'docs/en/guide/genui/a2ui-catalog-extractor.md', + ), + ); + removeGeneratedDoc( + path.join( + options.websiteRoot, + 'docs/zh/guide/genui/a2ui-catalog-extractor.md', + ), + ); + return { en: { text: 'GenUI', items: [ { - text: 'A2UI Catalog Extractor', - link: '/guide/genui/a2ui-catalog-extractor', + text: 'A2UI', + link: '/guide/genui/a2ui', }, ], }, @@ -54,8 +65,8 @@ export function createGenUIGuideReadmeDocs(options: { text: 'GenUI', items: [ { - text: 'A2UI Catalog Extractor', - link: '/zh/guide/genui/a2ui-catalog-extractor', + text: 'A2UI', + link: '/zh/guide/genui/a2ui', }, ], }, @@ -83,3 +94,9 @@ function syncReadme(options: { fs.mkdirSync(path.dirname(options.outFile), { recursive: true }); fs.writeFileSync(options.outFile, nextContent); } + +function removeGeneratedDoc(outFile: string): void { + if (fs.existsSync(outFile)) { + fs.rmSync(outFile); + } +}