diff --git a/.github/a2ui-catalog.instructions.md b/.github/a2ui-catalog.instructions.md index 1c3aaf2ec9..373ef6dd3f 100644 --- a/.github/a2ui-catalog.instructions.md +++ b/.github/a2ui-catalog.instructions.md @@ -32,10 +32,14 @@ When a GenUI package builds a CLI or other generated artifact that another works When implementing A2UI v0.9 functions in `packages/genui/a2ui`, keep function resolution scoped to the active catalog first, with the global `FunctionRegistry` only as an escape hatch. Dynamic component props, checks, and function-call actions should all go through the same `resolveDynamicValue` / `executeFunctionCall` path so data bindings, nested function calls, zod argument coercion from `@a2ui/web_core`, and `formatString` data-context interpolation stay consistent. -When verifying `packages/genui/a2ui-playground`, remember that `pnpm -F @lynx-js/a2ui-reactlynx build` regenerates catalog JSON only. The playground consumes `@lynx-js/a2ui-reactlynx` through package exports under `dist/**`, so run `pnpm -F @lynx-js/a2ui-reactlynx exec tsc -p tsconfig.build.json` before rebuilding the playground if runtime TypeScript changed. +When verifying `packages/genui/a2ui-playground`, remember that `pnpm -F @lynx-js/genui-a2ui build` first runs `tsc --project tsconfig.build.json` and then regenerates catalog JSON through `build:catalog`. The playground consumes `@lynx-js/genui/a2ui` through package exports under `dist/**`, so you normally do not need a separate `tsc` step unless you intentionally skipped the package `build` step. For known A2UI playground examples, keep the web preview URL on `?demo=` instead of swapping it to the payload-store `messagesUrl`. `render.html` intentionally fetches known demo JSON in the browser shell and passes resolved messages into Lynx, avoiding fetch differences in the Lynx worker runtime; use payload-store URLs for custom edited JSON. For interactive A2UI playground component examples, bind mutable props through `{ path: ... }` and provide matching example data so the component preview emits an initial `updateDataModel` before `updateComponents`. Literal values render the initial state but cannot be changed by `setValue`, which only writes back to data-bound props. For the built-in `DateTimeInput`, keep date-enabled default output as `YYYY-MM-DD` unless `outputFormat` is explicitly provided. Implement calendar behavior inside `packages/genui/a2ui` with local helpers that borrow the `lynx-ui-calendar` windowing/date patterns as needed, because `@lynx-js/lynx-ui-calendar` is not an available package dependency here. + +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. diff --git a/packages/genui/a2ui/README.md b/packages/genui/a2ui/README.md index 186e8c240c..b15e4c7895 100644 --- a/packages/genui/a2ui/README.md +++ b/packages/genui/a2ui/README.md @@ -2,816 +2,113 @@ 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. +`@lynx-js/genui/a2ui` is the ReactLynx client runtime for A2UI v0.9. It +consumes validated A2UI server-to-client JSON messages and renders trusted +ReactLynx components in your app. -If you have never heard of A2UI, think of it this way: +Use this package when you already have, or plan to build, an Agent service that +returns A2UI messages. The package does not host an Agent, call an LLM, own a +backend route, or provide a chat shell. Your app owns the transport layer and +pushes messages into the renderer. -- 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 +If you have never used A2UI before, think of it this way: -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. +- In React, your code chooses components and passes props. +- In A2UI, an Agent chooses from a component catalog that your app publishes. +- The client still renders real ReactLynx components. The model only sends data + that says which approved component to render and what props to use. 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/genui/a2ui` | ReactLynx renderer for A2UI v0.9. It provides ``, `MessageStore`, catalog APIs, built-in components, and protocol helpers. | -| `genui a2ui` | 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: +## Install -- 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 GenUI package. The CLI requires Node.js 22 -or newer and is exposed by the same package. +Install the published GenUI package in a ReactLynx app: ```sh -pnpm add @lynx-js/genui -genui a2ui --help +pnpm add @lynx-js/genui @lynx-js/react ``` -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 +Import the optional default theme tokens once if you want to use the built-in +light/dark CSS variables: -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 -genui a2ui 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/genui/a2ui'; -import buttonManifest from '@lynx-js/genui/a2ui/catalog/Button/catalog.json' - with { type: 'json' }; -import columnManifest from '@lynx-js/genui/a2ui/catalog/Column/catalog.json' - with { type: 'json' }; -import textManifest from '@lynx-js/genui/a2ui/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 -genui a2ui generate catalog \ - --catalog-dir src/catalog \ - --source src/functions \ - --out-dir dist/catalog - -genui a2ui 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 -`genui a2ui` command prefix and pass that file to `generate catalog`: - -```sh -genui a2ui 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 `genui a2ui 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" - } - ] - } - } -] +```ts +import '@lynx-js/genui/a2ui/styles/theme.css'; ``` -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 +## Quick Start -The client fetches agent output and pushes each message into the store. -`` does the protocol processing and renders the matching ReactLynx -components. +Create a `MessageStore`, register the components that generated UI is allowed +to render, and forward generated actions back to your Agent service. ```tsx import { A2UI, Button, - Column, Text, + basicFunctions, createMessageStore, + normalizePayloadToMessages, } from '@lynx-js/genui/a2ui'; -import type { UserActionPayload } from '@lynx-js/genui/a2ui'; const store = createMessageStore(); -const catalogs = [Text, Column, Button]; +const catalogs = [Text, Button, ...basicFunctions]; -async function sendPrompt(content: string) { - const response = await fetch('/api/a2ui/chat', { +async function sendPrompt(input: string) { + const res = await fetch('/a2ui/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - messages: [{ role: 'user', content }], - }), + body: JSON.stringify({ messages: [{ role: 'user', content: input }] }), }); - 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}} - /> - ); -} -``` - -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 should not own: - -- 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. - -### Interface Best Practices - -Keep the transport small and explicit: - -```ts -import type { MessageStore, UserActionPayload } from '@lynx-js/genui/a2ui'; - -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); - } -} -``` - -This keeps generated UI as data until the last step. The renderer remains the -only place that interprets A2UI messages. - -### REST Baseline - -Use routes such as `/api/a2ui/chat` and `/api/a2ui/action` when you want a -simple request/response implementation: - -```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; -} -``` - -Then wire it to the renderer: - -```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 Streaming - -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); -} + store.push(normalizePayloadToMessages(await res.json())); +} + + {children}} + onAction={(action) => { + void fetch('/a2ui/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(action), + }) + .then((res) => res.json()) + .then((payload) => store.push(normalizePayloadToMessages(payload))); + }} +/>; ``` -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. +`MessageStore` stores raw protocol messages in arrival order. `` +subscribes to it, processes new messages, renders the active surface, and emits +generated UI actions through `onAction`. + +## What You Own + +| Part | Owner | Role | +| ---------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `@lynx-js/genui/a2ui` | This package | ReactLynx renderer, `MessageStore`, catalog APIs, built-in components, protocol helpers, and client function entries. | +| `genui a2ui` | GenUI CLI | Build-time commands for generating custom catalog artifacts and A2UI system prompts. | +| Your Agent service | Your application | Receives user prompts/actions, calls a model with the A2UI prompt and catalog, validates output, and returns messages. | +| Your transport adapter | Your application | Calls the Agent service, handles REST or streaming responses, writes messages into `MessageStore`, and forwards actions. | + +## First Things To Know + +- Pass only the components you want generated UI to use through `catalogs`. +- Include `...basicFunctions` when messages may use A2UI function calls such as + `formatString`, `formatDate`, `required`, `email`, or `and`. +- Pair components with their `catalog.json` manifests when you need + `serializeCatalog(...)` to send JSON schemas to an Agent. +- There is intentionally no `@lynx-js/genui/a2ui/catalog/all` export. Compose + the exact catalog you want at the integration site so bundle cost is visible. +- For a new turn or session, mount `` with a different `key`; the + component owns its `MessageProcessor` for the lifetime of the mount. + +## More Docs + +- [Architecture and exports](./docs/architecture.md) +- [Catalogs and manifests](./docs/catalogs.md) +- [Custom components](./docs/custom-components.md) +- [Built-in catalog composition](./src/catalog/README.md) +- [Open the A2UI playground](https://lynxjs.org/a2ui) diff --git a/packages/genui/a2ui/README_zh.md b/packages/genui/a2ui/README_zh.md index e6ad0b3b2d..cfad0b1cc0 100644 --- a/packages/genui/a2ui/README_zh.md +++ b/packages/genui/a2ui/README_zh.md @@ -1,726 +1,104 @@ -# Lynx GenUI +# @lynx-js/genui/a2ui [English](./README.md) | 简体中文 -Lynx GenUI 面向已经熟悉 React 的开发者:你继续写可信的 ReactLynx 组件,AI 只负责从这些组件中选择、组合,并生成 Lynx -原生界面。 +`@lynx-js/genui/a2ui` 是面向 A2UI v0.9 的 ReactLynx 客户端运行时。它消费经过校验的 +A2UI server-to-client JSON messages,并在你的应用中渲染可信的 ReactLynx 组件。 -如果你第一次听说 A2UI,可以先这样理解: +当你已经有、或准备构建一个返回 A2UI messages 的 Agent 服务时,使用这个包。它不托管 Agent,不调用 +LLM,不拥有后端路由,也不提供 chat shell。你的应用负责传输层,并把消息写入 renderer。 + +如果你第一次接触 A2UI,可以先这样理解: - 在 React 里,是你的代码选择组件并传入 props。 -- 在 GenUI 里,是 Agent 从你发布的组件 Catalog 中选择组件。 +- 在 A2UI 里,是 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/genui/a2ui` | 面向 A2UI v0.9 的 ReactLynx 渲染器,提供 ``、`MessageStore`、Catalog API、内置组件和协议辅助能力。 | -| `genui a2ui` | 构建期命令,用来从 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 或更新版本。 +在 ReactLynx 应用中安装公开的 GenUI 包: ```sh -pnpm add @lynx-js/genui -genui a2ui --help +pnpm add @lynx-js/genui @lynx-js/react ``` -后续流程都发生在你的应用里:定义面向 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 -genui a2ui generate catalog --catalog-dir src/catalog --out-dir dist/catalog -``` +如果想使用内置 light/dark CSS variables,可以在入口处引入一次可选的默认主题 tokens: -然后把组件和 manifest 配对: - -```tsx -import { - Button, - Column, - Text, - createMessageStore, - defineCatalog, - serializeCatalog, -} from '@lynx-js/genui/a2ui'; -import buttonManifest from '@lynx-js/genui/a2ui/catalog/Button/catalog.json' - with { type: 'json' }; -import columnManifest from '@lynx-js/genui/a2ui/catalog/Column/catalog.json' - with { type: 'json' }; -import textManifest from '@lynx-js/genui/a2ui/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 -genui a2ui generate catalog \ - --catalog-dir src/catalog \ - --source src/functions \ - --out-dir dist/catalog - -genui a2ui generate prompt \ - --catalog-dir dist/catalog \ - --catalog-id https://example.com/catalogs/custom/v1/catalog.json \ - --out dist/a2ui-system-prompt.txt -``` - -如果你的构建流程已经产出 TypeDoc JSON,仍然使用同一个 -`genui a2ui` 命令前缀,把该文件传给 `generate catalog`: - -```sh -genui a2ui 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 服务应该是产品自己的后端路由,而不是浏览器代码。它应该: - -- 读取 `genui a2ui 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" - } - ] - } - } -] +```ts +import '@lynx-js/genui/a2ui/styles/theme.css'; ``` -正常开发时不需要手写这些 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 组件。 +创建 `MessageStore`,注册 generated UI 允许渲染的组件,并把 generated actions 转发回你的 Agent 服务。 ```tsx import { A2UI, Button, - Column, Text, + basicFunctions, createMessageStore, + normalizePayloadToMessages, } from '@lynx-js/genui/a2ui'; -import type { UserActionPayload } from '@lynx-js/genui/a2ui'; 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); - } -} +const catalogs = [Text, Button, ...basicFunctions]; -async function sendAction(action: UserActionPayload) { - const response = await fetch('/api/a2ui/action', { +async function sendPrompt(input: string) { + const res = await fetch('/a2ui/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - surfaceId: action.surfaceId, - action, - }), + body: JSON.stringify({ messages: [{ role: 'user', content: input }] }), }); - 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/genui/a2ui'; - -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); -} + store.push(normalizePayloadToMessages(await res.json())); +} + + {children}} + onAction={(action) => { + void fetch('/a2ui/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(action), + }) + .then((res) => res.json()) + .then((payload) => store.push(normalizePayloadToMessages(payload))); + }} +/>; ``` -### 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 的更新消息。 - -## 测试与质量 +`MessageStore` 按到达顺序保存原始 protocol messages。`` 订阅它、处理新消息、渲染 active surface,并通过 +`onAction` 抛出 generated UI actions。 -使用你应用已有的测试工具即可。最有价值的检查包括: +## 你需要负责什么 -- 单测 Catalog 注册,确保 generated messages 里的每个组件名都能映射到预期的 ReactLynx 组件。 -- 用确定性的 JSON 和 SSE fixtures 单测传输层解析,包括异常响应和取消请求。 -- 将保存下来的 A2UI message arrays 回放到 ``,不用请求模型也能发现渲染回归。 -- 在引入真实模型测试前,先用 mock agent response 做一条 prompt 流程和一条 action 流程的 E2E。 +| 部分 | 负责人 | 作用 | +| ---------------------- | --------- | ------------------------------------------------------------------------------------------------------- | +| `@lynx-js/genui/a2ui` | 这个包 | ReactLynx renderer、`MessageStore`、Catalog API、内置组件、协议辅助能力,以及 client function entries。 | +| `genui a2ui` | GenUI CLI | 构建期命令,用来生成自定义 catalog artifacts 和 A2UI system prompts。 | +| 你的 Agent 服务 | 你的应用 | 接收用户 prompt/action,带着 A2UI prompt 和 catalog 调用模型,校验输出,然后返回 messages。 | +| 你的 transport adapter | 你的应用 | 调用 Agent 服务,处理 REST 或流式响应,把 messages 写入 `MessageStore`,并转发 generated UI actions。 | -## 产品方向 +## 首次接入要知道 -GenUI 围绕几个原则演进: +- 只把 generated UI 允许使用的组件传给 `catalogs`。 +- 当 messages 可能使用 `formatString`、`formatDate`、`required`、`email` 或 `and` 等 A2UI function calls 时,加入 + `...basicFunctions`。 +- 需要通过 `serializeCatalog(...)` 把 JSON schemas 发送给 Agent 时,把组件和它的 `catalog.json` manifest 配对。 +- 包里故意没有 `@lynx-js/genui/a2ui/catalog/all` 导出。请在接入点组合你真正需要的 catalog,让 bundle 成本保持可见。 +- 新 turn 或新 session 需要重置 `` 时,传入不同的 `key`;组件会在一次 mount 生命周期内持有自己的 + `MessageProcessor`。 -- React 仍然是实现层。Agent 只从你拥有的组件里选择。 -- Catalog 是产品契约,用来让生成 UI 对齐设计系统和平台约束。 -- 渐进式渲染应该让用户在完整响应结束前已经看到有价值的界面。 -- 传输层可以替换。REST、SSE、WebSocket、A2A、AG UI 或 MCP 都可以承载同样的 A2UI 消息。 -- 生成式 UI 应该能被检查、回放,并进入自动化评估流程。 +## 更多文档 -先从线上 Playground 开始体验,然后在你的应用里生成一个小 Catalog,接通一条 prompt route 和一条 action route,再逐步扩展更丰富的组件。 +- [架构与导出](./docs/architecture_zh.md) +- [Catalog 与 manifests](./docs/catalogs_zh.md) +- [自定义组件](./docs/custom-components_zh.md) +- [内置 Catalog 组合](./src/catalog/readme_zh.md) +- [打开 A2UI Playground](https://lynxjs.org/a2ui) diff --git a/packages/genui/a2ui/docs/architecture.md b/packages/genui/a2ui/docs/architecture.md new file mode 100644 index 0000000000..a22fe3e6d6 --- /dev/null +++ b/packages/genui/a2ui/docs/architecture.md @@ -0,0 +1,67 @@ +# Architecture and exports + +This page is for developers who already finished the quick start and want to +understand which part of the A2UI stack owns each responsibility. + +## Responsibilities + +| Piece | Runs in | Responsibility | +| ----------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Agent service | Server | Turns user prompts and client actions into validated A2UI message arrays. It should prompt the model with the same catalog contract that the client can render. | +| Transport adapter | Client shell | Sends prompts/actions to the Agent service over REST, SSE, WebSocket, or another transport, then writes returned messages into a `MessageStore`. | +| `MessageStore` | Client | Stores raw A2UI protocol messages in arrival order and notifies subscribers. It does not parse or interpret the protocol. | +| `` | Client | Owns a `MessageProcessor` per mount, consumes new messages from `MessageStore`, renders the active surface, and forwards generated UI actions through `onAction`. | +| Catalog API | Client and Agent handshake | Maps protocol component/function names to local implementations and optional JSON schemas. Use `defineCatalog`, `mergeCatalogs`, `serializeCatalog`, and `defineFunction` to compose that contract. | +| Built-ins | Client | Provides A2UI v0.9 basic catalog component renderers, JSON-Schema manifests, and client-side basic-catalog function implementations. | +| `genui a2ui` | Build/setup time | Generates custom catalog artifacts and system prompts. It is not required when both the Agent and renderer use the built-in basic catalog. | + +## Package contents + +- ``: 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, such as fetch, SSE, + WebSocket, or an in-process mock. +- `defineCatalog`, `mergeCatalogs`, `serializeCatalog`, and + `defineFunction`: the catalog API. There is no global component catalog; + every consumer composes the component and function entries it wants. +- `catalog/`: built-in component renderers (`Text`, `Image`, `Row`, + `Column`, `List`, `Card`, `Modal`, `Button`, `Divider`, `Icon`, + `CheckBox`, `ChoicePicker`, `DateTimeInput`, `LineChart`, `PieChart`, + `RadioGroup`, `Slider`, `TextField`, and `Tabs`). +- `catalog//catalog.json`: per-component JSON-Schema manifests for + Agent handshakes. +- `basicFunctions`: A2UI v0.9 basic-catalog client function entries, ready + to spread into `catalogs`. + +## Exports + +- `@lynx-js/genui/a2ui`: ``, `createMessageStore`, + `defineCatalog`, built-ins, basic functions, and protocol types. +- `@lynx-js/genui/a2ui/catalog`: re-exports of the catalog API and + built-ins for tree-shake-friendly subpath access. +- `@lynx-js/genui/a2ui/catalog/`: import a single built-in. +- `@lynx-js/genui/a2ui/catalog//catalog.json`: import the + per-component manifest. +- `@lynx-js/genui/a2ui/store`: `MessageStore`, `MessageProcessor`, + `Resource`, payload normalizers — the pure data layer. +- `@lynx-js/genui/a2ui/react`: helpers used by custom catalog + components, including `NodeRenderer`, `useAction`, `useDataBinding`, and + `useChecks`. +- `@lynx-js/genui/a2ui/functions`: basic-catalog function entries and + registration helpers. +- `@lynx-js/genui/a2ui/styles/theme.css`: optional default CSS tokens + for `.a2ui-light` and `.a2ui-dark`. + +## `` lifecycle notes + +- It owns its own `MessageProcessor` per mount. Passing a different + `messageStore` instance does not reset internal state; use a `key` derived + from your turn or session id when you want a fresh session. +- `onAction` is fire-and-forget. The renderer does not 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. +- `className` and `wrapSurface` can both support theme switching; choose the + layer that matches your styling strategy. diff --git a/packages/genui/a2ui/docs/architecture_zh.md b/packages/genui/a2ui/docs/architecture_zh.md new file mode 100644 index 0000000000..5ce52c8e29 --- /dev/null +++ b/packages/genui/a2ui/docs/architecture_zh.md @@ -0,0 +1,63 @@ +# 架构和 exports + +这篇文档面向已经完成 quick start、并希望理解 A2UI stack 各部分职责边界的 +开发者。 + +## 职责划分 + +| 部分 | 运行位置 | 职责 | +| -------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Agent 服务 | Server | 把用户 prompt 和客户端 action 转换成经过校验的 A2UI message 数组。它应该使用与客户端可渲染能力一致的 catalog contract 来提示模型。 | +| 传输适配层 | Client shell | 通过 REST、SSE、WebSocket 或其他传输方式把 prompt/action 发给 Agent 服务,再把返回的 messages 写入 `MessageStore`。 | +| `MessageStore` | Client | 按到达顺序保存原始 A2UI protocol messages,并通知订阅者。它不解析也不解释协议语义。 | +| `` | Client | 每次 mount 拥有一个 `MessageProcessor`,从 `MessageStore` 消费新 messages,渲染当前 active surface,并通过 `onAction` 把 generated UI action 转发出去。 | +| Catalog API | Client 和 Agent handshake | 把协议中的 component/function 名称映射到本地实现和可选 JSON schema。使用 `defineCatalog`、`mergeCatalogs`、`serializeCatalog` 和 `defineFunction` 组合这份 contract。 | +| 内置能力 | Client | 提供 A2UI v0.9 basic catalog 的组件 renderer、JSON-Schema manifest,以及客户端 basic-catalog function 实现。 | +| `genui a2ui` | 构建/接入阶段 | 生成自定义 catalog artifacts 和 system prompt。如果 Agent 和 renderer 都使用内置 basic catalog,则不需要它。 | + +## 包含内容 + +- ``:all-in-one 组件。它拥有 `MessageProcessor`,订阅开发者传入的 + `MessageStore`,并渲染最新的 surface。 +- `MessageStore`:原始 protocol messages 的 append-only buffer。开发者可以从 + fetch、SSE、WebSocket、in-process mock 等任意 IO 传输层写入 messages。 +- `defineCatalog`、`mergeCatalogs`、`serializeCatalog` 和 `defineFunction`: + catalog API。这里没有全局 component catalog;每个消费者都要显式组合自己 + 想开放的 component 和 function entries。 +- `catalog/`:内置组件 renderers(`Text`、`Image`、`Row`、`Column`、 + `List`、`Card`、`Modal`、`Button`、`Divider`、`Icon`、`CheckBox`、 + `ChoicePicker`、`DateTimeInput`、`LineChart`、`PieChart`、`RadioGroup`、 + `Slider`、`TextField` 和 `Tabs`)。 +- `catalog//catalog.json`:用于 Agent handshake 的逐组件 JSON-Schema + manifests。 +- `basicFunctions`:A2UI v0.9 basic-catalog 的客户端 function entries,可以 + 直接展开到 `catalogs` 中。 + +## Exports + +- `@lynx-js/genui/a2ui`:``、`createMessageStore`、 + `defineCatalog`、内置组件、basic functions 和 protocol types。 +- `@lynx-js/genui/a2ui/catalog`:catalog API 和内置组件的 re-export, + 适合 tree-shake-friendly 的 subpath 访问。 +- `@lynx-js/genui/a2ui/catalog/`:导入单个内置组件。 +- `@lynx-js/genui/a2ui/catalog//catalog.json`:导入单个组件的 + manifest。 +- `@lynx-js/genui/a2ui/store`:`MessageStore`、`MessageProcessor`、 + `Resource`、payload normalizers 等纯数据层能力。 +- `@lynx-js/genui/a2ui/react`:自定义 catalog 组件会用到的 helper,包括 + `NodeRenderer`、`useAction`、`useDataBinding` 和 `useChecks`。 +- `@lynx-js/genui/a2ui/functions`:basic-catalog function entries 和注册 + helper。 +- `@lynx-js/genui/a2ui/styles/theme.css`:可选的默认 CSS tokens,提供 + `.a2ui-light` 和 `.a2ui-dark`。 + +## `` 生命周期说明 + +- 每次 mount 都拥有自己的 `MessageProcessor`。传入另一个 `messageStore` 实例 + 不会重置内部状态;如果你想开启新的 session/turn,请使用由 turn/session id + 派生出的 `key`。 +- `onAction` 是 fire-and-forget。renderer 不等待响应;你的 Agent 把后续 + messages 写回同一个 `MessageStore`。 +- `className` 会加在 surface root view(`surface-${surfaceId}`)上。 +- `wrapSurface` 会在渲染出的 surface 外面包一层。 +- 两者都可以用于多主题切换;选择与你的样式策略匹配的那一层。 diff --git a/packages/genui/a2ui/docs/catalogs.md b/packages/genui/a2ui/docs/catalogs.md new file mode 100644 index 0000000000..e88e004d18 --- /dev/null +++ b/packages/genui/a2ui/docs/catalogs.md @@ -0,0 +1,59 @@ +# Catalogs and manifests + +Catalogs define which protocol components and functions the renderer can use. +They also provide the optional JSON schemas that an Agent can use during a +handshake. + +## Start with renderer-only components + +If your app only needs to render, pass bare components. The protocol name comes +from `displayName ?? component.name`. + +```ts +import { defineCatalog, Text, Button } from '@lynx-js/genui/a2ui'; + +const catalog = defineCatalog([Text, Button]); +``` + +Production minifiers can rewrite function names. For production safety, set an +explicit `displayName` on every custom component, or pair the component with +its `catalog.json` manifest. The manifest key is authoritative. + +## Add manifests for Agent handshakes + +If you want `serializeCatalog(...)` to emit JSON Schema for each component, +pair each component with the JSON emitted at `dist/catalog//catalog.json`. + +```ts +import { Text, defineCatalog, serializeCatalog } from '@lynx-js/genui/a2ui'; +import textManifest from '@lynx-js/genui/a2ui/catalog/Text/catalog.json' + with { type: 'json' }; + +const catalog = defineCatalog([[Text, textManifest]]); +agentChannel.handshake({ catalog: serializeCatalog(catalog) }); +``` + +## Include basic functions when messages use function calls + +A2UI messages may use basic-catalog function calls such as `formatDate`, +`formatString`, or `required`. Include `...basicFunctions` in the same catalog +input list so the client can execute those calls at render time. + +```ts +import { Text, basicFunctions, defineCatalog } from '@lynx-js/genui/a2ui'; + +const catalog = defineCatalog([ + Text, + ...basicFunctions, +]); +``` + +## No `catalog/all` + +The package intentionally does not ship a `catalog/all` aggregate. A single +top-level array that references every component forces consumers to bundle +every built-in even when they render only a few. Compose the catalog at the +call site so the bundle cost is explicit. + +For the complete "every built-in" recipe, see +[../src/catalog/README.md](../src/catalog/README.md). diff --git a/packages/genui/a2ui/docs/catalogs_zh.md b/packages/genui/a2ui/docs/catalogs_zh.md new file mode 100644 index 0000000000..d0bb65ef4e --- /dev/null +++ b/packages/genui/a2ui/docs/catalogs_zh.md @@ -0,0 +1,57 @@ +# Catalogs 和 manifests + +Catalog 定义 renderer 可以使用哪些 protocol components 和 functions。它也可以 +提供 Agent handshake 所需的 JSON schemas。 + +## 从 renderer-only components 开始 + +如果你的应用只负责渲染,可以直接传 bare components。协议名来自 +`displayName ?? component.name`。 + +```ts +import { defineCatalog, Text, Button } from '@lynx-js/genui/a2ui'; + +const catalog = defineCatalog([Text, Button]); +``` + +生产环境 minifier 可能改写 function 名称。为了生产安全,请给每个自定义组件 +设置显式 `displayName`,或者把组件与它的 `catalog.json` manifest 配对使用。 +manifest 的顶层 key 是权威名称。 + +## 为 Agent handshake 加入 manifests + +如果你希望 `serializeCatalog(...)` 为每个组件输出 JSON Schema,请把组件和 +`dist/catalog//catalog.json` 产出的 JSON 配对: + +```ts +import { Text, defineCatalog, serializeCatalog } from '@lynx-js/genui/a2ui'; +import textManifest from '@lynx-js/genui/a2ui/catalog/Text/catalog.json' + with { type: 'json' }; + +const catalog = defineCatalog([[Text, textManifest]]); +agentChannel.handshake({ catalog: serializeCatalog(catalog) }); +``` + +## Messages 使用 function calls 时加入 basic functions + +A2UI messages 可能使用 `formatDate`、`formatString` 或 `required` 这样的 +basic-catalog function calls。请在同一个 catalog input 列表里加入 +`...basicFunctions`,这样客户端才能在渲染时执行这些 function。 + +```ts +import { Text, basicFunctions, defineCatalog } from '@lynx-js/genui/a2ui'; + +const catalog = defineCatalog([ + Text, + ...basicFunctions, +]); +``` + +## 没有 `catalog/all` + +这个包故意不提供 `catalog/all` 聚合导出。一个引用所有组件的顶层数组会迫使 +消费者打包全部内置组件,即使实际只渲染其中几个。请在接入点显式组合 catalog, +让 bundle 成本保持可见。 + +完整 “every built-in” 配方见 +[../src/catalog/readme_zh.md](../src/catalog/readme_zh.md)。 diff --git a/packages/genui/a2ui/docs/custom-components.md b/packages/genui/a2ui/docs/custom-components.md new file mode 100644 index 0000000000..941e972225 --- /dev/null +++ b/packages/genui/a2ui/docs/custom-components.md @@ -0,0 +1,34 @@ +# Custom components + +Any function returning a `ReactNode` can be a catalog component. The function's +name, or its `displayName`, is the protocol name the Agent will use. + +```tsx +function MyChart(props: { data: number[] }) { ... } +MyChart.displayName = 'MyChart'; + +; +// Agent emits `{ component: 'MyChart', data: [...] }` -> renders MyChart. +``` + +Set `displayName` for production-safe naming. Minifiers can rewrite function +names, but string literals survive. + +## Add schema introspection + +If the Agent needs to know the component's props, generate a manifest with +`@lynx-js/genui/a2ui-catalog-extractor` and pair it with the component. + +```tsx +import myChartManifest from './dist/catalog/MyChart/catalog.json' with { + type: 'json', +}; + +const catalog = defineCatalog([ + [MyChart, myChartManifest], +]); +``` + +Use `@a2uiCatalog ` on the TypeScript interface that describes +the props you want the Agent to see. For extractor details, see +[`@lynx-js/genui/a2ui-catalog-extractor`](../../a2ui-catalog-extractor/README.md). diff --git a/packages/genui/a2ui/docs/custom-components_zh.md b/packages/genui/a2ui/docs/custom-components_zh.md new file mode 100644 index 0000000000..554f5a7fd5 --- /dev/null +++ b/packages/genui/a2ui/docs/custom-components_zh.md @@ -0,0 +1,34 @@ +# 自定义组件 + +任何返回 `ReactNode` 的函数都可以成为 catalog component。函数名,或它的 +`displayName`,就是 Agent 会使用的协议名。 + +```tsx +function MyChart(props: { data: number[] }) { ... } +MyChart.displayName = 'MyChart'; + +; +// Agent emits `{ component: 'MyChart', data: [...] }` -> renders MyChart. +``` + +为了生产安全,请设置 `displayName`。Minifier 可能改写 function 名称,但字符串 +字面量会保留下来。 + +## 加入 schema introspection + +如果 Agent 需要知道组件 props,请使用 `@lynx-js/genui/a2ui-catalog-extractor` 生成 +manifest,并把 manifest 与组件配对。 + +```tsx +import myChartManifest from './dist/catalog/MyChart/catalog.json' with { + type: 'json', +}; + +const catalog = defineCatalog([ + [MyChart, myChartManifest], +]); +``` + +在描述 Agent 可见 props 的 TypeScript interface 上使用 +`@a2uiCatalog `。Extractor 详情见 +[`@lynx-js/genui/a2ui-catalog-extractor`](../../a2ui-catalog-extractor/readme.zh_cn.md)。 diff --git a/packages/genui/a2ui/src/catalog/README.md b/packages/genui/a2ui/src/catalog/README.md index 9b4e1fc651..4b45e03732 100644 --- a/packages/genui/a2ui/src/catalog/README.md +++ b/packages/genui/a2ui/src/catalog/README.md @@ -57,10 +57,16 @@ agentChannel.handshake({ catalog: serializeCatalog(catalog) }); The protocol name lives in the JSON as the top-level key, so the runtime never duplicates it. -## "I really want every built-in" — the paste-able recipe +## "I really want every built-in" - the paste-able recipe + +This includes every built-in component and the A2UI v0.9 basic-catalog +function entries. The package intentionally does not export this as +`catalog/all`; keep the list at the integration site so the bundle cost stays +visible. ```tsx import { + basicFunctions, defineCatalog, Button, Card, @@ -160,11 +166,13 @@ export const allBuiltins = defineCatalog([ [RadioGroup, radioGroupManifest], [Slider, sliderManifest], [Tabs, tabsManifest], + ...basicFunctions, ]); ``` Drop the `manifest` import + tuple form for any component whose schema you -don't need to ship to the agent. +don't need to ship to the agent. Keep `...basicFunctions` if your A2UI +messages use function calls in dynamic props, actions, or validation checks. ## Custom components diff --git a/packages/genui/a2ui/src/catalog/readme_zh.md b/packages/genui/a2ui/src/catalog/readme_zh.md new file mode 100644 index 0000000000..7ebcc592d1 --- /dev/null +++ b/packages/genui/a2ui/src/catalog/readme_zh.md @@ -0,0 +1,203 @@ +# Catalog 组合 + +这个包故意不提供 “all-in-one” catalog 常量。一个引用所有内置组件的顶层数组 +会破坏 tree-shaking:只要消费者引用这个聚合,就会把全部组件打进 bundle,即使 +实际只用其中几个。Catalog 应该按组件组合,成本应当在 import 处可见。 + +## Renderer 最少需要什么 + +如果你的应用只负责渲染,组件名称就足够了。直接传 bare components;协议名来自 +`displayName ?? component.name`。 + +> 生产环境 minifier 会重写 function declaration 名称,这会破坏 +> `component.name` fallback。为了生产安全,请给每个自定义组件设置显式 +> `displayName`(字符串字面量能在 minification 后保留下来),或者用下面的 +> tuple 形式把组件和 `catalog.json` manifest 配对;manifest key 是权威名称。 + +```tsx +import { A2UI, Button, Text, createMessageStore } from '@lynx-js/genui/a2ui'; + +const store = createMessageStore(); + +// 从你的 IO 模块(fetch、SSE 等)写入原始 protocol messages。 +// async function streamFromAgent(input) { +// for await (const msg of myAgent.stream(input)) store.push(msg); +// } + + { + /* 转发给你的 Agent,并把 response messages 写回 store */ + }} +/>; +``` + +Bundler 可以 tree-shake 未使用组件;导入 `Text` 不会自动带上 `Button`、`Card` +等组件。 + +## 为 Agent handshake 加入 schemas + +如果你希望 `serializeCatalog(...)` 为每个组件输出 JSON Schema,让 Agent 知道 +可以发送哪些 props,请把组件和 extractor 在 `dist/catalog//catalog.json` +产出的 JSON 配对: + +```tsx +import { Text } from '@lynx-js/genui/a2ui/catalog/Text'; +import textManifest from '@lynx-js/genui/a2ui/catalog/Text/catalog.json' + with { type: 'json' }; + +const catalog = defineCatalog([[Text, textManifest]]); +agentChannel.handshake({ catalog: serializeCatalog(catalog) }); +``` + +协议名存在于 JSON 的顶层 key 中,runtime 不需要再复制一份名称。 + +## “我就是想用所有内置组件” - 可复制配方 + +这个配方包含所有内置组件和 A2UI v0.9 basic-catalog function entries。包本身 +故意不导出 `catalog/all`;请把列表保留在接入点,让 bundle 成本保持可见。 + +```tsx +import { + basicFunctions, + defineCatalog, + Button, + Card, + CheckBox, + ChoicePicker, + DateTimeInput, + Column, + Divider, + Icon, + Image, + LineChart, + PieChart, + List, + Modal, + RadioGroup, + Row, + Slider, + Tabs, + Text, + TextField, +} from '@lynx-js/genui/a2ui'; +import buttonManifest from '@lynx-js/genui/a2ui/catalog/Button/catalog.json' with { + type: 'json', +}; +import cardManifest from '@lynx-js/genui/a2ui/catalog/Card/catalog.json' with { + type: 'json', +}; +import checkBoxManifest from '@lynx-js/genui/a2ui/catalog/CheckBox/catalog.json' with { + type: 'json', +}; +import choicePickerManifest from '@lynx-js/genui/a2ui/catalog/ChoicePicker/catalog.json' with { + type: 'json', +}; +import dateTimeInputManifest from '@lynx-js/genui/a2ui/catalog/DateTimeInput/catalog.json' with { + type: 'json', +}; +import columnManifest from '@lynx-js/genui/a2ui/catalog/Column/catalog.json' with { + type: 'json', +}; +import dividerManifest from '@lynx-js/genui/a2ui/catalog/Divider/catalog.json' with { + type: 'json', +}; +import iconManifest from '@lynx-js/genui/a2ui/catalog/Icon/catalog.json' with { + type: 'json', +}; +import imageManifest from '@lynx-js/genui/a2ui/catalog/Image/catalog.json' with { + type: 'json', +}; +import lineChartManifest from '@lynx-js/genui/a2ui/catalog/LineChart/catalog.json' with { + type: 'json', +}; +import pieChartManifest from '@lynx-js/genui/a2ui/catalog/PieChart/catalog.json' with { + type: 'json', +}; +import listManifest from '@lynx-js/genui/a2ui/catalog/List/catalog.json' with { + type: 'json', +}; +import modalManifest from '@lynx-js/genui/a2ui/catalog/Modal/catalog.json' with { + type: 'json', +}; +import radioGroupManifest from '@lynx-js/genui/a2ui/catalog/RadioGroup/catalog.json' with { + type: 'json', +}; +import rowManifest from '@lynx-js/genui/a2ui/catalog/Row/catalog.json' with { + type: 'json', +}; +import sliderManifest from '@lynx-js/genui/a2ui/catalog/Slider/catalog.json' with { + type: 'json', +}; +import tabsManifest from '@lynx-js/genui/a2ui/catalog/Tabs/catalog.json' with { + type: 'json', +}; +import textManifest from '@lynx-js/genui/a2ui/catalog/Text/catalog.json' with { + type: 'json', +}; +import textFieldManifest from '@lynx-js/genui/a2ui/catalog/TextField/catalog.json' with { + type: 'json', +}; + +export const allBuiltins = defineCatalog([ + [Text, textManifest], + [Image, imageManifest], + [Row, rowManifest], + [Column, columnManifest], + [List, listManifest], + [Card, cardManifest], + [Modal, modalManifest], + [Button, buttonManifest], + [Divider, dividerManifest], + [LineChart, lineChartManifest], + [PieChart, pieChartManifest], + [TextField, textFieldManifest], + [CheckBox, checkBoxManifest], + [ChoicePicker, choicePickerManifest], + [DateTimeInput, dateTimeInputManifest], + [Icon, iconManifest], + [RadioGroup, radioGroupManifest], + [Slider, sliderManifest], + [Tabs, tabsManifest], + ...basicFunctions, +]); +``` + +如果某个组件的 schema 不需要发给 Agent,可以去掉对应的 `manifest` import 和 +tuple 形式。只要你的 A2UI messages 在 dynamic props、actions 或 validation +checks 中使用 function calls,就保留 `...basicFunctions`。 + +## 自定义组件 + +组件可以是任何接收单个 props object 并返回 `ReactNode` 的函数。函数名(或 +`displayName`)就是 Agent 使用的协议名: + +```tsx +function MyChart(props: { data: number[] }) { ... } +// 生产安全命名需要 displayName。minifier 会重写 function 名称,但字符串 +// 字面量会保留下来。 +MyChart.displayName = 'MyChart'; + + +// Agent sends `{ component: 'MyChart', data: [...] }` -> renders MyChart. +``` + +如果自定义组件需要 schema introspection,请用 +`@lynx-js/genui/a2ui-catalog-extractor` 基于 interface 生成 manifest,然后用同样方式 +配对: + +```tsx +defineCatalog([[MyChart, myChartManifest]]); +``` + +## API surface + +- `defineCatalog(inputs)`:构建 runtime catalog。输入可以混合 bare + components、`[component, manifest]` tuples,以及已经 resolved 的 entries + (例如来自 `mergeCatalogs`)。 +- `mergeCatalogs(...catalogs)`:重复名称采用 last-write-wins。 +- `serializeCatalog(catalog)`:输出发给 Agent handshake 的 JSON manifest。 + 没有关联 schema 的 component 会序列化成 `{ name }`。 +- `resolveCatalog(catalog)`:生成 name -> component map。Renderer 内部使用, + 也开放给高级场景。 diff --git a/packages/genui/package.json b/packages/genui/package.json index beda12ea49..72efaada61 100644 --- a/packages/genui/package.json +++ b/packages/genui/package.json @@ -80,6 +80,8 @@ "a2ui/dist", "a2ui/styles", "a2ui/*.md", + "a2ui/docs/*.md", + "a2ui/src/catalog/*.md", "cli/bin", "cli/README.md", "a2ui-prompt/dist", diff --git a/packages/genui/scripts/run-api-extractor.mjs b/packages/genui/scripts/run-api-extractor.mjs index 3f021a519a..0c9649e786 100644 --- a/packages/genui/scripts/run-api-extractor.mjs +++ b/packages/genui/scripts/run-api-extractor.mjs @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url'; const genuiRoot = dirname(dirname(fileURLToPath(import.meta.url))); const lockPath = join(genuiRoot, '.api-extractor.lock'); const lockTimeoutMs = 10 * 60 * 1000; +const entryPointTimeoutMs = 5 * 1000; const retryDelayMs = 500; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -76,10 +77,61 @@ const run = (command, args) => { } }; +const getMainEntryPointFilePath = async () => { + const configPath = join(process.cwd(), 'api-extractor.json'); + const config = await readFile(configPath, 'utf8'); + const match = /"mainEntryPointFilePath"\s*:\s*"([^"]+)"/.exec(config); + + if (!match) { + return null; + } + + return match[1].replace('', process.cwd()); +}; + +const waitForMainEntryPoint = async () => { + const mainEntryPointFilePath = await getMainEntryPointFilePath(); + + if (!mainEntryPointFilePath) { + return true; + } + + const start = Date.now(); + + while (Date.now() - start < entryPointTimeoutMs) { + if (existsSync(mainEntryPointFilePath)) { + return true; + } + + await sleep(retryDelayMs); + } + + return false; +}; + +const ensureMainEntryPoint = async () => { + if (await waitForMainEntryPoint()) { + return; + } + + run('pnpm', ['run', 'build']); + + if (await waitForMainEntryPoint()) { + return; + } + + const mainEntryPointFilePath = await getMainEntryPointFilePath(); + + throw new Error( + `API Extractor entry point does not exist after build: ${mainEntryPointFilePath}`, + ); +}; + await acquireLock(); try { run('pnpm', ['run', 'build']); + await ensureMainEntryPoint(); run('api-extractor', ['run', '--verbose']); } finally { if (existsSync(lockPath)) { diff --git a/website/rspress.config.ts b/website/rspress.config.ts index adeb5ae6c6..aff9b22206 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -16,6 +16,7 @@ import { import { camelCase } from 'change-case'; import { + A2UI_EN_NAV_ITEMS, createAPI, createChangelogs, createGenUIGuideReadmeDocs, @@ -754,6 +755,8 @@ const config: UserConfig = defineConfig({ { text: 'A2UI', link: '/a2ui', + activeMatch: '^/(a2ui|guide/genui/a2ui)', + items: A2UI_EN_NAV_ITEMS, }, { text: 'API', diff --git a/website/sidebars/genui.ts b/website/sidebars/genui.ts index e993a4c431..99a42d8b1a 100644 --- a/website/sidebars/genui.ts +++ b/website/sidebars/genui.ts @@ -17,27 +17,75 @@ export function createGenUIGuideReadmeDocs(options: { options.repositoryRoot, 'packages/genui/a2ui', ); + const enGuideRoot = path.join( + options.websiteRoot, + 'docs/en/guide/genui', + ); + const zhGuideRoot = path.join( + options.websiteRoot, + 'docs/zh/guide/genui', + ); + + removeGeneratedDoc(path.join(enGuideRoot, 'a2ui')); + removeGeneratedDoc(path.join(zhGuideRoot, 'a2ui')); syncReadme({ languageSwitch: 'English | 简体中文', - outFile: path.join( - options.websiteRoot, - 'docs/en/guide/genui/a2ui.md', - ), + outFile: path.join(enGuideRoot, 'a2ui.md'), + replacements: A2UI_EN_LINK_REPLACEMENTS, sourceFile: path.join(a2uiPackageRoot, 'README.md'), switchPattern: /^English \| \[简体中文\]\(\.\/README_zh\.md\)$/m, }); syncReadme({ languageSwitch: 'English | 简体中文', - outFile: path.join( - options.websiteRoot, - 'docs/zh/guide/genui/a2ui.md', - ), + outFile: path.join(zhGuideRoot, 'a2ui.md'), + replacements: A2UI_ZH_LINK_REPLACEMENTS, sourceFile: path.join(a2uiPackageRoot, 'README_zh.md'), switchPattern: /^\[English\]\(\.\/README\.md\) \| 简体中文$/m, }); + syncDoc({ + outFile: path.join(enGuideRoot, 'a2ui/architecture.md'), + replacements: A2UI_EN_LINK_REPLACEMENTS, + sourceFile: path.join(a2uiPackageRoot, 'docs/architecture.md'), + }); + syncDoc({ + outFile: path.join(zhGuideRoot, 'a2ui/architecture.md'), + replacements: A2UI_ZH_LINK_REPLACEMENTS, + sourceFile: path.join(a2uiPackageRoot, 'docs/architecture_zh.md'), + }); + syncDoc({ + outFile: path.join(enGuideRoot, 'a2ui/catalogs.md'), + replacements: A2UI_EN_LINK_REPLACEMENTS, + sourceFile: path.join(a2uiPackageRoot, 'docs/catalogs.md'), + }); + syncDoc({ + outFile: path.join(zhGuideRoot, 'a2ui/catalogs.md'), + replacements: A2UI_ZH_LINK_REPLACEMENTS, + sourceFile: path.join(a2uiPackageRoot, 'docs/catalogs_zh.md'), + }); + syncDoc({ + outFile: path.join(enGuideRoot, 'a2ui/custom-components.md'), + replacements: A2UI_EN_LINK_REPLACEMENTS, + sourceFile: path.join(a2uiPackageRoot, 'docs/custom-components.md'), + }); + syncDoc({ + outFile: path.join(zhGuideRoot, 'a2ui/custom-components.md'), + replacements: A2UI_ZH_LINK_REPLACEMENTS, + sourceFile: path.join(a2uiPackageRoot, 'docs/custom-components_zh.md'), + }); + syncDoc({ + outFile: path.join(enGuideRoot, 'a2ui/catalog.md'), + replacements: A2UI_EN_LINK_REPLACEMENTS, + sourceFile: path.join(a2uiPackageRoot, 'src/catalog/README.md'), + }); + syncDoc({ + outFile: path.join(zhGuideRoot, 'a2ui/catalog.md'), + replacements: A2UI_ZH_LINK_REPLACEMENTS, + sourceFile: path.join(a2uiPackageRoot, 'src/catalog/readme_zh.md'), + }); + removeGeneratedDoc( path.join( options.websiteRoot, @@ -57,7 +105,7 @@ export function createGenUIGuideReadmeDocs(options: { items: [ { text: 'A2UI', - link: '/guide/genui/a2ui', + items: A2UI_EN_SIDEBAR_ITEMS, }, ], }, @@ -66,20 +114,120 @@ export function createGenUIGuideReadmeDocs(options: { items: [ { text: 'A2UI', - link: '/zh/guide/genui/a2ui', + items: A2UI_ZH_SIDEBAR_ITEMS, }, ], }, }; } +export const A2UI_EN_NAV_ITEMS = [ + { + text: 'Overview README', + link: '/guide/genui/a2ui', + }, + { + text: 'Architecture', + link: '/guide/genui/a2ui/architecture', + }, + { + text: 'Catalogs', + link: '/guide/genui/a2ui/catalogs', + }, + { + text: 'Custom Components', + link: '/guide/genui/a2ui/custom-components', + }, + { + text: 'Built-in Catalog README', + link: '/guide/genui/a2ui/catalog', + }, + { + text: 'Playground', + link: '/a2ui', + }, +]; + +export const A2UI_ZH_NAV_ITEMS = [ + { + text: '概览 README', + link: '/zh/guide/genui/a2ui', + }, + { + text: '架构与导出', + link: '/zh/guide/genui/a2ui/architecture', + }, + { + text: 'Catalog 与 Manifests', + link: '/zh/guide/genui/a2ui/catalogs', + }, + { + text: '自定义组件', + link: '/zh/guide/genui/a2ui/custom-components', + }, + { + text: '内置 Catalog README', + link: '/zh/guide/genui/a2ui/catalog', + }, + { + text: 'Playground', + link: '/a2ui', + }, +]; + +const A2UI_EN_SIDEBAR_ITEMS = A2UI_EN_NAV_ITEMS.map(item => ({ + ...item, + text: item.text.replace(' README', ''), +})); + +const A2UI_ZH_SIDEBAR_ITEMS = A2UI_ZH_NAV_ITEMS.map(item => ({ + ...item, + text: item.text.replace(' README', ''), +})); + +const A2UI_EN_LINK_REPLACEMENTS = [ + ['./docs/architecture.md', '/guide/genui/a2ui/architecture'], + ['./docs/catalogs.md', '/guide/genui/a2ui/catalogs'], + ['./docs/custom-components.md', '/guide/genui/a2ui/custom-components'], + [ + '[../src/catalog/README.md](../src/catalog/README.md)', + '[built-in catalog README](/guide/genui/a2ui/catalog)', + ], + ['../src/catalog/README.md', '/guide/genui/a2ui/catalog'], + ['./src/catalog/README.md', '/guide/genui/a2ui/catalog'], + [ + '../../a2ui-catalog-extractor/README.md', + 'https://github.com/lynx-family/lynx-stack/tree/main/packages/genui/a2ui-catalog-extractor#readme', + ], +] as const; + +const A2UI_ZH_LINK_REPLACEMENTS = [ + ['./docs/architecture_zh.md', '/zh/guide/genui/a2ui/architecture'], + ['./docs/catalogs_zh.md', '/zh/guide/genui/a2ui/catalogs'], + ['./docs/custom-components_zh.md', '/zh/guide/genui/a2ui/custom-components'], + [ + '[../src/catalog/readme_zh.md](../src/catalog/readme_zh.md)', + '[内置 Catalog README](/zh/guide/genui/a2ui/catalog)', + ], + ['../src/catalog/readme_zh.md', '/zh/guide/genui/a2ui/catalog'], + ['./src/catalog/readme_zh.md', '/zh/guide/genui/a2ui/catalog'], + [ + '../../a2ui-catalog-extractor/readme.zh_cn.md', + 'https://github.com/lynx-family/lynx-stack/blob/main/packages/genui/a2ui-catalog-extractor/readme.zh_cn.md', + ], +] as const; + function syncReadme(options: { languageSwitch: string; outFile: string; + replacements?: readonly (readonly [string, string])[]; sourceFile: string; switchPattern: RegExp; }): void { - const content = fs.readFileSync(options.sourceFile, 'utf8'); + const content = applyLinkReplacements( + fs.readFileSync(options.sourceFile, 'utf8'), + options.replacements ?? [], + ); const nextContent = content.replace( options.switchPattern, options.languageSwitch, @@ -95,8 +243,32 @@ function syncReadme(options: { fs.writeFileSync(options.outFile, nextContent); } +function syncDoc(options: { + outFile: string; + replacements?: readonly (readonly [string, string])[]; + sourceFile: string; +}): void { + const content = applyLinkReplacements( + fs.readFileSync(options.sourceFile, 'utf8'), + options.replacements ?? [], + ); + + fs.mkdirSync(path.dirname(options.outFile), { recursive: true }); + fs.writeFileSync(options.outFile, content); +} + +function applyLinkReplacements( + content: string, + replacements: readonly (readonly [string, string])[], +): string { + return replacements.reduce( + (current, [from, to]) => current.split(from).join(to), + content, + ); +} + function removeGeneratedDoc(outFile: string): void { if (fs.existsSync(outFile)) { - fs.rmSync(outFile); + fs.rmSync(outFile, { recursive: true, force: true }); } }