diff --git a/.github/workflows/publish-npm-package.yml b/.github/workflows/publish-npm-package.yml index 958705f95..a16d97cea 100644 --- a/.github/workflows/publish-npm-package.yml +++ b/.github/workflows/publish-npm-package.yml @@ -12,6 +12,7 @@ on: required: true type: choice options: + - a2ui - react-ui - react-headless - react-lang diff --git a/README.md b/README.md index 1d05f6ed4..ba60e8ebe 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Try it yourself in the [Playground](https://www.openui.com/playground): generate | Package | Best for | Description | | :--------------------------------------------------------------------------------------------------------- | :----------------------------------------------- | :----------------------------------------------------------------------------------------------------------- | | [`@openuidev/lang-core`](./packages/lang-core) | Framework-agnostic parsing and prompt generation | Core parser, prompt-generation, runtime-evaluation, and type layer with no React, Vue, or Svelte dependency | +| [`@openuidev/cli`](./packages/openui-cli) | Project scaffolding and prompt generation | CLI for creating new apps and generating system prompts or JSON schema from a library definition | | [`@openuidev/langchain`](./packages/langchain) | LangChain and LangGraph agents | Agent transformer and server helpers that stream OpenUI through AG-UI | | [`@openuidev/react-lang`](./packages/react-lang) | React rendering runtimes | Define component libraries, generate prompts, and render streamed OpenUI Lang in React | | [`@openuidev/react-headless`](./packages/react-headless) | Bring-your-own React chat UI | Headless chat state, streaming adapters, and message format converters | @@ -102,7 +103,7 @@ Try it yourself in the [Playground](https://www.openui.com/playground): generate | [`@openuidev/vue-lang`](./packages/vue-lang) | Vue integrations | Vue 3 bindings for defining model-renderable components and rendering streamed OpenUI Lang | | [`@openuidev/svelte-lang`](./packages/svelte-lang) | Svelte integrations | Svelte 5 bindings for defining model-renderable components and rendering streamed OpenUI Lang | | [`@openuidev/browser-bundle`](./packages/browser-bundle) | CDN, iframe, and no-build embeds | Prebuilt browser bundle that ships the renderer, UI library, React, and styles as script + stylesheet assets | -| [`@openuidev/cli`](./packages/openui-cli) | Project scaffolding and prompt generation | CLI for creating new apps and generating system prompts or JSON schema from a library definition | +| [`@openuidev/a2ui`](./packages/a2ui) | A2UI with OpenUI Lang component payloads | Framework-agnostic A2UI v1.0 protocol client with an optional React renderer | | [`@openuidev/openclaw-os-plugin`](https://github.com/thesysdev/openclaw-os/tree/main/packages/claw-plugin) | OpenClaw workspaces | OpenClaw OS plugin for serving OpenUI-powered OpenClaw workspaces | Common starting points: @@ -120,6 +121,9 @@ npm install @openuidev/langchain @langchain/langgraph # Vue or Svelte runtime npm install @openuidev/vue-lang npm install @openuidev/svelte-lang + +# A2UI protocol with OpenUI Lang component payloads +npm install @openuidev/a2ui zod ``` ## Why OpenUI Lang @@ -157,16 +161,17 @@ Detailed documentation is available at [openui.com](https://openui.com). ``` openui/ ├── packages/ +│ ├── lang-core/ # Framework-agnostic parser, prompt, and runtime layer +│ ├── openui-cli/ # CLI for scaffolding & prompt generation │ ├── react-lang/ # Core runtime (parser, renderer, prompt generation) │ ├── react-headless/ # Headless chat state & streaming adapters │ ├── react-ui/ # Prebuilt chat layouts & component libraries │ ├── react-email/ # React Email component library for generated emails -│ ├── lang-core/ # Framework-agnostic parser, prompt, and runtime layer │ ├── langchain/ # LangChain/LangGraph streaming integration │ ├── vue-lang/ # Vue runtime bindings for OpenUI Lang │ ├── svelte-lang/ # Svelte runtime bindings for OpenUI Lang │ ├── browser-bundle/ # Script-tag bundle for CDN / iframe / no-build embeds -│ └── openui-cli/ # CLI for scaffolding & prompt generation +│ └── a2ui/ # A2UI protocol with OpenUI Lang component payloads ├── skills/ │ └── openui/ # Claude Code skill for AI-assisted development ├── examples/ diff --git a/benchmarks/A2UI_BENCHMARK.md b/benchmarks/A2UI_BENCHMARK.md new file mode 100644 index 000000000..93a686f0c --- /dev/null +++ b/benchmarks/A2UI_BENCHMARK.md @@ -0,0 +1,42 @@ +# A2UI JSON vs A2UI + OpenUI Lang + +Across seven existing UI fixtures, replacing A2UI's JSON component objects with OpenUI Lang statements reduced the complete protocol streams from **6,894 to 5,526 tokens**: **1,368 fewer tokens (19.8%)**. At a fixed 60 output tokens/second, that is **22.8 seconds less estimated decode time** across the corpus, or a **1.25x aggregate speedup**. + +| Scenario | A2UI + JSON | A2UI + OpenUI Lang | Tokens saved | Reduction | JSON latency | OpenUI latency | Speedup | +| ------------------ | ----------: | -----------------: | -----------: | ---------: | -----------: | -------------: | --------: | +| simple-table | 249 | 210 | 39 | -15.7% | 4.15s | 3.50s | 1.19x | +| chart-with-data | 363 | 294 | 69 | -19.0% | 6.05s | 4.90s | 1.23x | +| contact-form | 570 | 370 | 200 | -35.1% | 9.50s | 6.17s | 1.54x | +| dashboard | 1574 | 1349 | 225 | -14.3% | 26.23s | 22.48s | 1.17x | +| pricing-page | 1656 | 1383 | 273 | -16.5% | 27.60s | 23.05s | 1.20x | +| settings-panel | 837 | 628 | 209 | -25.0% | 13.95s | 10.47s | 1.33x | +| e-commerce-product | 1645 | 1292 | 353 | -21.5% | 27.42s | 21.53s | 1.27x | +| **TOTAL** | **6894** | **5526** | **1368** | **-19.8%** | **114.90s** | **92.10s** | **1.25x** | + +## What was held constant + +- Both variants use A2UI v1.0 and contain the same compact `createSurface` plus `updateComponents` JSONL messages. +- Both encode the same parsed component tree from the existing OpenUI benchmark fixtures. +- The surface ID, catalog ID, message boundaries, and JSON serialization are identical. +- The only variable is `updateComponents.components`: flat catalog component objects for A2UI + JSON, statement strings for A2UI + OpenUI Lang. + +The A2UI JSON side uses a custom catalog with the same component and prop names as the OpenUI fixture library. Its component graph is flattened with deterministic IDs, matching A2UI's adjacency-list model. Both streams are minified, so the comparison does not rely on JSON whitespace. This is a same-library encoding comparison, not a comparison between A2UI's Basic Catalog and the OpenUI library. + +## Reproduce it + +Build the workspace packages once from the repository root, then run the offline benchmark: + +```bash +pnpm install --frozen-lockfile +cd benchmarks +pnpm install --ignore-workspace +pnpm generate:a2ui +pnpm test:a2ui +pnpm bench:a2ui +``` + +Generation and measurement are offline and use the checked-in `.oui` fixtures. `test:a2ui` checks that both protocol envelopes are otherwise identical and replays all seven OpenUI Lang streams through the new client and Lang parser. Token counts use `tiktoken` with the `gpt-5` encoding. Latency is an estimate at 60 output tokens/second; it does not include catalog or prompt tokens, model time-to-first-token, parsing/rendering CPU, transport overhead, or compression. + +## Issue-ready summary + +Add an experimental `@openuidev/a2ui` package that preserves the A2UI v1.0 lifecycle, data-model, action, RPC, capability, and error messages, while representing component lists as OpenUI Lang statement strings. The package maintains per-surface Lang source through statement-level patches, parses it with `@openuidev/lang-core`, and exposes an optional React surface renderer. The reproducible seven-scenario benchmark shows a 19.8% full-stream token reduction and 1.25x estimated decode speedup versus compact A2UI JSON. diff --git a/benchmarks/README.md b/benchmarks/README.md index 19f4aaf63..fe3b6d8c3 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -17,6 +17,10 @@ Measures token efficiency and estimated generation latency of **OpenUI Lang** vs All four formats encode exactly the same UI. The LLM always generates OpenUI Lang, then the parsed AST is projected into the other three formats. +The same fixture set also includes a focused **A2UI v1.0 + JSON vs A2UI v1.0 + OpenUI Lang** comparison. Both sides contain the same compact `createSurface` and `updateComponents` JSONL envelopes and the same parsed UI. The only variable is `updateComponents.components`: A2UI catalog component objects on one side, OpenUI Lang statement strings on the other. + +See [A2UI_BENCHMARK.md](./A2UI_BENCHMARK.md) for the focused results and methodology. + ## Methodology 1. Use a fixed set of seven prompts in `generate-samples.ts` (`simple-table`, `chart-with-data`, `contact-form`, `dashboard`, `pricing-page`, `settings-panel`, `e-commerce-product`). @@ -44,7 +48,6 @@ Measured with `tiktoken` (`gpt-5` model encoder). Generated by GPT-5.2 at temper | e-commerce-product | 2145 | 2449 | 2381 | 1166 | -45.6% | -52.4% | -51.0% | | **TOTAL** | **9122** | **10180** | **9948** | **4800** | **-47.4%** | **-52.8%** | **-51.7%** | - ## Running ### Prerequisites @@ -55,7 +58,6 @@ Export `OPENAI_API_KEY` in your shell: export OPENAI_API_KEY=sk-... ``` - ### 1. Generate samples (calls OpenAI) ```bash @@ -72,11 +74,28 @@ pnpm bench Reads the files in `samples/`, counts tokens with `tiktoken`, and prints token and latency tables. +### Focused A2UI comparison + +The checked-in `.oui` fixtures are enough to reproduce the A2UI comparison without an API key: + +```bash +pnpm generate:a2ui +pnpm test:a2ui +pnpm bench:a2ui +``` + +The first command projects each parsed fixture to both complete A2UI streams. The test verifies identical envelopes and replays every OpenUI Lang stream through `@openuidev/a2ui`. The final command counts full-stream tokens, prints the table, and writes machine-readable results to `a2ui-results.json`. Estimated latency uses the same fixed 60 output tokens/second as the main benchmark. + ## File Layout ```text benchmarks/ ├── generate-samples.ts # Calls OpenAI, converts AST to all four formats +├── generate-a2ui-samples.ts # Offline projection to both A2UI streams +├── a2ui-converter.ts # AST -> A2UI JSON and Lang protocol streams +├── run-a2ui-benchmark.ts # Focused A2UI token and latency report +├── check-a2ui.ts # Envelope invariance and Lang replay checks +├── a2ui-results.json # Machine-readable focused benchmark results ├── run-benchmark.ts # Reads samples/, prints token/latency tables ├── thesys-c1-converter.ts # AST -> normalized Thesys C1 JSON converter ├── vercel-spec-converter.ts # AST -> shared json-render spec projection @@ -91,6 +110,8 @@ benchmarks/ ├── .c1.json ├── .vercel.jsonl ├── .yaml + ├── .a2ui.jsonl + ├── .a2ui-openui.jsonl └── metrics.json ``` diff --git a/benchmarks/a2ui-converter.ts b/benchmarks/a2ui-converter.ts new file mode 100644 index 000000000..82903dc29 --- /dev/null +++ b/benchmarks/a2ui-converter.ts @@ -0,0 +1,150 @@ +import type { ElementNode } from "@openuidev/lang-core"; + +export const A2UI_BENCHMARK_CATALOG_ID = "https://openui.com/catalog/default"; + +interface A2UIComponent { + id: string; + component: string; + [key: string]: unknown; +} + +function isElementNode(value: unknown): value is ElementNode { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const object = value as Record; + return object.type === "element" && typeof object.typeName === "string"; +} + +function sanitizeId(value: string): string { + return value.replace(/[^A-Za-z0-9_-]/g, "_"); +} + +export function astToA2UIComponents(root: ElementNode): A2UIComponent[] { + const components: A2UIComponent[] = []; + const ids = new WeakMap(); + const usedIds = new Set(); + let anonymousId = 0; + + const reserveId = (node: ElementNode, preferred?: string): string => { + const existing = ids.get(node); + if (existing) return existing; + + let candidate = preferred ? sanitizeId(preferred) : ""; + if (!candidate || usedIds.has(candidate)) { + do { + anonymousId += 1; + candidate = `c${anonymousId}`; + } while (usedIds.has(candidate)); + } + usedIds.add(candidate); + ids.set(node, candidate); + return candidate; + }; + + const projectValue = (value: unknown): unknown => { + if (isElementNode(value)) return visit(value); + if (Array.isArray(value)) return value.map(projectValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, child]) => child !== undefined) + .map(([key, child]) => [key, projectValue(child)]), + ); + } + return value; + }; + + const visit = (node: ElementNode, forcedId?: string): string => { + const id = reserveId(node, forcedId ?? node.statementId); + if (components.some((component) => component.id === id)) return id; + + const component: A2UIComponent = { id, component: node.typeName }; + components.push(component); + for (const [key, value] of Object.entries(node.props)) { + if (value !== undefined) component[key] = projectValue(value); + } + return id; + }; + + visit(root, "root"); + return components; +} + +export function splitOpenUIStatements(source: string): string[] { + const statements: string[] = []; + let depth = 0; + let quote: false | '"' | "'" = false; + let escaped = false; + let start = 0; + + for (let index = 0; index < source.length; index++) { + const character = source[index]!; + if (escaped) { + escaped = false; + continue; + } + if (quote && character === "\\") { + escaped = true; + continue; + } + if (quote) { + if (character === quote) quote = false; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (character === "(" || character === "[" || character === "{") depth += 1; + else if (character === ")" || character === "]" || character === "}") { + depth = Math.max(0, depth - 1); + } else if (character === "\n" && depth === 0) { + const statement = source.slice(start, index).trim(); + if (statement) statements.push(statement); + start = index + 1; + } + } + + const finalStatement = source.slice(start).trim(); + if (finalStatement) statements.push(finalStatement); + return statements; +} + +function createSurface(surfaceId: string) { + return { + version: "v1.0", + createSurface: { + surfaceId, + catalogId: A2UI_BENCHMARK_CATALOG_ID, + }, + } as const; +} + +function toJsonl(messages: unknown[]): string { + return `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`; +} + +export function astToA2UIJsonStream(root: ElementNode, surfaceId = "main"): string { + return toJsonl([ + createSurface(surfaceId), + { + version: "v1.0", + updateComponents: { + surfaceId, + components: astToA2UIComponents(root), + }, + }, + ]); +} + +export function openUIToA2UILangStream(source: string, surfaceId = "main"): string { + return toJsonl([ + createSurface(surfaceId), + { + version: "v1.0", + updateComponents: { + surfaceId, + components: splitOpenUIStatements(source), + }, + }, + ]); +} diff --git a/benchmarks/a2ui-results.json b/benchmarks/a2ui-results.json new file mode 100644 index 000000000..56b3674bf --- /dev/null +++ b/benchmarks/a2ui-results.json @@ -0,0 +1,86 @@ +{ + "tokenizer": "tiktoken:gpt-5", + "estimatedTokensPerSecond": 60, + "protocol": "A2UI v1.0", + "measuredArtifact": "createSurface + updateComponents JSONL stream", + "invariant": "Same A2UI envelopes and same parsed UI; only updateComponents.components encoding differs.", + "totals": { + "a2uiJsonTokens": 6894, + "a2uiOpenUITokens": 5526, + "tokensSaved": 1368, + "reductionPercent": 19.8, + "estimatedSpeedup": 1.25 + }, + "scenarios": [ + { + "scenario": "simple-table", + "a2uiJsonTokens": 249, + "a2uiOpenUITokens": 210, + "tokensSaved": 39, + "reductionPercent": 15.7, + "a2uiJsonLatencySeconds": 4.15, + "a2uiOpenUILatencySeconds": 3.5, + "speedup": 1.19 + }, + { + "scenario": "chart-with-data", + "a2uiJsonTokens": 363, + "a2uiOpenUITokens": 294, + "tokensSaved": 69, + "reductionPercent": 19, + "a2uiJsonLatencySeconds": 6.05, + "a2uiOpenUILatencySeconds": 4.9, + "speedup": 1.23 + }, + { + "scenario": "contact-form", + "a2uiJsonTokens": 570, + "a2uiOpenUITokens": 370, + "tokensSaved": 200, + "reductionPercent": 35.1, + "a2uiJsonLatencySeconds": 9.5, + "a2uiOpenUILatencySeconds": 6.17, + "speedup": 1.54 + }, + { + "scenario": "dashboard", + "a2uiJsonTokens": 1574, + "a2uiOpenUITokens": 1349, + "tokensSaved": 225, + "reductionPercent": 14.3, + "a2uiJsonLatencySeconds": 26.23, + "a2uiOpenUILatencySeconds": 22.48, + "speedup": 1.17 + }, + { + "scenario": "pricing-page", + "a2uiJsonTokens": 1656, + "a2uiOpenUITokens": 1383, + "tokensSaved": 273, + "reductionPercent": 16.5, + "a2uiJsonLatencySeconds": 27.6, + "a2uiOpenUILatencySeconds": 23.05, + "speedup": 1.2 + }, + { + "scenario": "settings-panel", + "a2uiJsonTokens": 837, + "a2uiOpenUITokens": 628, + "tokensSaved": 209, + "reductionPercent": 25, + "a2uiJsonLatencySeconds": 13.95, + "a2uiOpenUILatencySeconds": 10.47, + "speedup": 1.33 + }, + { + "scenario": "e-commerce-product", + "a2uiJsonTokens": 1645, + "a2uiOpenUITokens": 1292, + "tokensSaved": 353, + "reductionPercent": 21.5, + "a2uiJsonLatencySeconds": 27.42, + "a2uiOpenUILatencySeconds": 21.53, + "speedup": 1.27 + } + ] +} diff --git a/benchmarks/check-a2ui.ts b/benchmarks/check-a2ui.ts new file mode 100644 index 000000000..6cf2503a3 --- /dev/null +++ b/benchmarks/check-a2ui.ts @@ -0,0 +1,75 @@ +import { createA2UIClient, type AgentToRendererMessage } from "@openuidev/a2ui"; +import { readFileSync } from "fs"; +import assert from "node:assert/strict"; +import { join } from "path"; +import { splitOpenUIStatements } from "./a2ui-converter.js"; +import { A2UI_SCENARIOS } from "./generate-a2ui-samples.js"; + +function readJsonl(file: string): unknown[] { + return readFileSync(file, "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); +} + +async function main(): Promise { + const schema = JSON.parse(readFileSync("schema.json", "utf-8")); + + assert.deepEqual(splitOpenUIStatements('root = Card("line 1\\nline 2")\nnext = Text("ok")'), [ + 'root = Card("line 1\\nline 2")', + 'next = Text("ok")', + ]); + + for (const scenario of A2UI_SCENARIOS) { + const jsonMessages = readJsonl(join("samples", `${scenario}.a2ui.jsonl`)) as [ + Record, + Record, + ]; + const langMessages = readJsonl(join("samples", `${scenario}.a2ui-openui.jsonl`)) as [ + Record, + Record, + ]; + + assert.equal(jsonMessages.length, 2); + assert.equal(langMessages.length, 2); + assert.deepEqual(jsonMessages[0], langMessages[0]); + assert.deepEqual( + { + ...jsonMessages[1], + updateComponents: { ...jsonMessages[1].updateComponents, components: [] }, + }, + { + ...langMessages[1], + updateComponents: { ...langMessages[1].updateComponents, components: [] }, + }, + ); + assert.ok( + jsonMessages[1].updateComponents.components.every( + (component: unknown) => typeof component === "object" && component !== null, + ), + ); + assert.ok( + langMessages[1].updateComponents.components.every( + (component: unknown) => typeof component === "string", + ), + ); + + const client = createA2UIClient({ schema }); + for (const message of langMessages) { + const result = await client.process(message as AgentToRendererMessage); + assert.equal(result.ok, true, `${scenario} failed hybrid protocol processing`); + } + const surface = client.getSurface("main"); + assert.ok(surface?.parseResult?.root, `${scenario} has no rendered root`); + assert.deepEqual(surface.errors, []); + } + + console.log( + `Validated both A2UI envelopes and replayed ${A2UI_SCENARIOS.length} OpenUI Lang streams.`, + ); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/benchmarks/generate-a2ui-samples.ts b/benchmarks/generate-a2ui-samples.ts new file mode 100644 index 000000000..56aa944cd --- /dev/null +++ b/benchmarks/generate-a2ui-samples.ts @@ -0,0 +1,40 @@ +import { createParser } from "@openuidev/lang-core"; +import { readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { astToA2UIJsonStream, openUIToA2UILangStream } from "./a2ui-converter.js"; + +export const A2UI_SCENARIOS = [ + "simple-table", + "chart-with-data", + "contact-form", + "dashboard", + "pricing-page", + "settings-panel", + "e-commerce-product", +] as const; + +export function generateA2UISamples(): void { + const schema = JSON.parse(readFileSync(join("schema.json"), "utf-8")); + const parser = createParser(schema); + + for (const scenario of A2UI_SCENARIOS) { + const source = readFileSync(join("samples", `${scenario}.oui`), "utf-8"); + const result = parser.parse(source); + if (!result.root) throw new Error(`OpenUI sample has no root: ${scenario}`); + if (result.meta.errors.length > 0) { + throw new Error( + `OpenUI sample failed to parse: ${scenario}\n${JSON.stringify(result.meta.errors, null, 2)}`, + ); + } + + writeFileSync(join("samples", `${scenario}.a2ui.jsonl`), astToA2UIJsonStream(result.root)); + writeFileSync(join("samples", `${scenario}.a2ui-openui.jsonl`), openUIToA2UILangStream(source)); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + generateA2UISamples(); + console.log( + `Generated A2UI JSON and A2UI + OpenUI Lang streams for ${A2UI_SCENARIOS.length} scenarios.`, + ); +} diff --git a/benchmarks/package.json b/benchmarks/package.json index 892f30b66..d2b7d6704 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -5,15 +5,21 @@ "type": "module", "scripts": { "generate": "tsx generate-samples.ts", + "generate:a2ui": "tsx generate-a2ui-samples.ts", "bench": "tsx run-benchmark.ts", + "bench:a2ui": "tsx run-a2ui-benchmark.ts", + "test:a2ui": "tsx check-a2ui.ts", "format:fix": "prettier --write ." }, "dependencies": { - "@openuidev/react-lang": "workspace:*", - "@openuidev/react-ui": "workspace:*", + "@openuidev/a2ui": "link:../packages/a2ui", + "@openuidev/lang-core": "link:../packages/lang-core", + "@openuidev/react-lang": "link:../packages/react-lang", + "@openuidev/react-ui": "link:../packages/react-ui", "openai": "^6.22.0", "tiktoken": "^1.0.14", - "yaml": "^2.8.1" + "yaml": "^2.8.1", + "zod": "^4.0.0" }, "devDependencies": { "tsx": "^4.20.3" diff --git a/benchmarks/pnpm-lock.yaml b/benchmarks/pnpm-lock.yaml index 64b2f19c5..757654574 100644 --- a/benchmarks/pnpm-lock.yaml +++ b/benchmarks/pnpm-lock.yaml @@ -7,15 +7,30 @@ settings: importers: .: dependencies: + "@openuidev/a2ui": + specifier: link:../packages/a2ui + version: link:../packages/a2ui + "@openuidev/lang-core": + specifier: link:../packages/lang-core + version: link:../packages/lang-core + "@openuidev/react-lang": + specifier: link:../packages/react-lang + version: link:../packages/react-lang + "@openuidev/react-ui": + specifier: link:../packages/react-ui + version: link:../packages/react-ui openai: specifier: ^6.22.0 - version: 6.29.0 + version: 6.29.0(zod@4.4.3) tiktoken: specifier: ^1.0.14 version: 1.0.22 yaml: specifier: ^2.8.1 version: 2.8.2 + zod: + specifier: ^4.0.0 + version: 4.4.3 devDependencies: tsx: specifier: ^4.20.3 @@ -321,6 +336,12 @@ packages: engines: { node: ">= 14.6" } hasBin: true + zod@4.4.3: + resolution: + { + integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==, + } + snapshots: "@esbuild/aix-ppc64@0.27.4": optional: true @@ -436,7 +457,9 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - openai@6.29.0: {} + openai@6.29.0(zod@4.4.3): + optionalDependencies: + zod: 4.4.3 resolve-pkg-maps@1.0.0: {} @@ -450,3 +473,5 @@ snapshots: fsevents: 2.3.3 yaml@2.8.2: {} + + zod@4.4.3: {} diff --git a/benchmarks/run-a2ui-benchmark.ts b/benchmarks/run-a2ui-benchmark.ts new file mode 100644 index 000000000..b67b28647 --- /dev/null +++ b/benchmarks/run-a2ui-benchmark.ts @@ -0,0 +1,91 @@ +import { readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { encoding_for_model } from "tiktoken"; +import { A2UI_SCENARIOS } from "./generate-a2ui-samples.js"; + +const TOKENS_PER_SECOND = 60; +const enc = encoding_for_model("gpt-5"); + +interface ScenarioResult { + scenario: string; + a2uiJsonTokens: number; + a2uiOpenUITokens: number; + tokensSaved: number; + reductionPercent: number; + a2uiJsonLatencySeconds: number; + a2uiOpenUILatencySeconds: number; + speedup: number; +} + +function countTokens(text: string): number { + return enc.encode(text).length; +} + +function fixed(value: number, precision = 1): number { + return Number(value.toFixed(precision)); +} + +function markdown(results: ScenarioResult[]): string { + const totalJson = results.reduce((total, result) => total + result.a2uiJsonTokens, 0); + const totalOpenUI = results.reduce((total, result) => total + result.a2uiOpenUITokens, 0); + const rows = results.map( + (result) => + `| ${result.scenario} | ${result.a2uiJsonTokens} | ${result.a2uiOpenUITokens} | ${result.tokensSaved} | -${result.reductionPercent.toFixed(1)}% | ${result.a2uiJsonLatencySeconds.toFixed(2)}s | ${result.a2uiOpenUILatencySeconds.toFixed(2)}s | ${result.speedup.toFixed(2)}x |`, + ); + rows.push( + `| **TOTAL** | **${totalJson}** | **${totalOpenUI}** | **${totalJson - totalOpenUI}** | **-${(((totalJson - totalOpenUI) / totalJson) * 100).toFixed(1)}%** | **${(totalJson / TOKENS_PER_SECOND).toFixed(2)}s** | **${(totalOpenUI / TOKENS_PER_SECOND).toFixed(2)}s** | **${(totalJson / totalOpenUI).toFixed(2)}x** |`, + ); + + return [ + "| Scenario | A2UI + JSON | A2UI + OpenUI Lang | Tokens saved | Reduction | JSON latency | OpenUI latency | Speedup |", + "|---|---:|---:|---:|---:|---:|---:|---:|", + ...rows, + ].join("\n"); +} + +function main(): void { + const results: ScenarioResult[] = A2UI_SCENARIOS.map((scenario) => { + const json = readFileSync(join("samples", `${scenario}.a2ui.jsonl`), "utf-8"); + const openui = readFileSync(join("samples", `${scenario}.a2ui-openui.jsonl`), "utf-8"); + const a2uiJsonTokens = countTokens(json); + const a2uiOpenUITokens = countTokens(openui); + return { + scenario, + a2uiJsonTokens, + a2uiOpenUITokens, + tokensSaved: a2uiJsonTokens - a2uiOpenUITokens, + reductionPercent: fixed(((a2uiJsonTokens - a2uiOpenUITokens) / a2uiJsonTokens) * 100), + a2uiJsonLatencySeconds: fixed(a2uiJsonTokens / TOKENS_PER_SECOND, 2), + a2uiOpenUILatencySeconds: fixed(a2uiOpenUITokens / TOKENS_PER_SECOND, 2), + speedup: fixed(a2uiJsonTokens / a2uiOpenUITokens, 2), + }; + }); + const totalJson = results.reduce((total, result) => total + result.a2uiJsonTokens, 0); + const totalOpenUI = results.reduce((total, result) => total + result.a2uiOpenUITokens, 0); + const report = { + tokenizer: "tiktoken:gpt-5", + estimatedTokensPerSecond: TOKENS_PER_SECOND, + protocol: "A2UI v1.0", + measuredArtifact: "createSurface + updateComponents JSONL stream", + invariant: + "Same A2UI envelopes and same parsed UI; only updateComponents.components encoding differs.", + totals: { + a2uiJsonTokens: totalJson, + a2uiOpenUITokens: totalOpenUI, + tokensSaved: totalJson - totalOpenUI, + reductionPercent: fixed(((totalJson - totalOpenUI) / totalJson) * 100), + estimatedSpeedup: fixed(totalJson / totalOpenUI, 2), + }, + scenarios: results, + }; + + writeFileSync(join("a2ui-results.json"), `${JSON.stringify(report, null, 2)}\n`); + console.log("\n# A2UI JSON vs A2UI + OpenUI Lang\n"); + console.log(markdown(results)); + console.log( + `\nToken counts use tiktoken's gpt-5 encoding. Latency is estimated at ${TOKENS_PER_SECOND} output tokens/second.`, + ); + enc.free(); +} + +main(); diff --git a/benchmarks/samples/chart-with-data.a2ui-openui.jsonl b/benchmarks/samples/chart-with-data.a2ui-openui.jsonl new file mode 100644 index 000000000..d90f2e0cc --- /dev/null +++ b/benchmarks/samples/chart-with-data.a2ui-openui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":["root = Stack([header, layout], \"column\", \"l\")","header = TextContent(\"Revenue Dashboard\", \"large-heavy\")","layout = Stack([metricCard, chartCard], \"row\", \"l\", \"stretch\", \"start\", true)","metricCard = Card([metricHeader, metricValue, metricDelta], \"card\")","metricHeader = CardHeader(\"Total Revenue\", \"Last 6 months\")","metricValue = TextContent(\"$1,284,000\", \"large-heavy\")","metricDelta = TextCallout(\"success\", \"Up 8.4%\", \"Compared to the previous 6 months\")","chartCard = Card([chartHeader, revenueChart], \"card\")","chartHeader = CardHeader(\"Monthly Revenue\", \"Last 6 months\")","revenueChart = BarChart(monthLabels, [revenueSeries], \"grouped\", \"Month\", \"Revenue (USD)\")","monthLabels = [\"Oct\", \"Nov\", \"Dec\", \"Jan\", \"Feb\", \"Mar\"]","revenueSeries = Series(\"Revenue\", [198000, 205000, 214000, 210000, 223000, 234000])"]}} diff --git a/benchmarks/samples/chart-with-data.a2ui.jsonl b/benchmarks/samples/chart-with-data.a2ui.jsonl new file mode 100644 index 000000000..0b1662f51 --- /dev/null +++ b/benchmarks/samples/chart-with-data.a2ui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":[{"id":"root","component":"Stack","children":["header","layout"],"direction":"column","gap":"l"},{"id":"header","component":"TextContent","text":"Revenue Dashboard","size":"large-heavy"},{"id":"layout","component":"Stack","children":["metricCard","chartCard"],"direction":"row","gap":"l","align":"stretch","justify":"start","wrap":true},{"id":"metricCard","component":"Card","children":["metricHeader","metricValue","metricDelta"],"variant":"card"},{"id":"metricHeader","component":"CardHeader","title":"Total Revenue","subtitle":"Last 6 months"},{"id":"metricValue","component":"TextContent","text":"$1,284,000","size":"large-heavy"},{"id":"metricDelta","component":"TextCallout","variant":"success","title":"Up 8.4%","description":"Compared to the previous 6 months"},{"id":"chartCard","component":"Card","children":["chartHeader","revenueChart"],"variant":"card"},{"id":"chartHeader","component":"CardHeader","title":"Monthly Revenue","subtitle":"Last 6 months"},{"id":"revenueChart","component":"BarChart","labels":["Oct","Nov","Dec","Jan","Feb","Mar"],"series":["revenueSeries"],"variant":"grouped","xLabel":"Month","yLabel":"Revenue (USD)"},{"id":"revenueSeries","component":"Series","category":"Revenue","values":[198000,205000,214000,210000,223000,234000]}]}} diff --git a/benchmarks/samples/contact-form.a2ui-openui.jsonl b/benchmarks/samples/contact-form.a2ui-openui.jsonl new file mode 100644 index 000000000..09b86b7a7 --- /dev/null +++ b/benchmarks/samples/contact-form.a2ui-openui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":["root = Stack([title, form], \"column\", \"l\")","title = TextContent(\"Contact Us\", \"large-heavy\")","form = Form(\"contact\", [nameField, emailField, phoneField, subjectField, messageField], formButtons)","nameField = FormControl(\"Name\", Input(\"name\", \"Your full name\", \"text\", [\"required\", \"minLength:2\"]))","emailField = FormControl(\"Email\", Input(\"email\", \"you@example.com\", \"email\", [\"required\", \"email\"]))","phoneField = FormControl(\"Phone\", Input(\"phone\", \"e.g., +1 555 123 4567\", \"text\", [\"required\", \"minLength:7\", \"maxLength:20\"]))","subjectField = FormControl(\"Subject\", Select(\"subject\", subjectOptions, \"Select a subject...\", [\"required\"]))","messageField = FormControl(\"Message\", TextArea(\"message\", \"How can we help?\", 6, [\"required\", \"minLength:10\"]))","subjectOptions = [SelectItem(\"general\", \"General inquiry\"), SelectItem(\"support\", \"Support\"), SelectItem(\"sales\", \"Sales\"), SelectItem(\"billing\", \"Billing\"), SelectItem(\"feedback\", \"Feedback\")]","formButtons = Buttons([submitBtn, cancelBtn], \"row\")","submitBtn = Button(\"Submit\", \"submit:contact\", \"primary\")","cancelBtn = Button(\"Cancel\", \"action:cancel_contact\", \"secondary\")"]}} diff --git a/benchmarks/samples/contact-form.a2ui.jsonl b/benchmarks/samples/contact-form.a2ui.jsonl new file mode 100644 index 000000000..4e723f9c8 --- /dev/null +++ b/benchmarks/samples/contact-form.a2ui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":[{"id":"root","component":"Stack","children":["title","form"],"direction":"column","gap":"l"},{"id":"title","component":"TextContent","text":"Contact Us","size":"large-heavy"},{"id":"form","component":"Form","name":"contact","fields":["nameField","emailField","phoneField","subjectField","messageField"],"buttons":"formButtons"},{"id":"nameField","component":"FormControl","label":"Name","input":"c1"},{"id":"c1","component":"Input","name":"name","placeholder":"Your full name","type":"text","rules":["required","minLength:2"]},{"id":"emailField","component":"FormControl","label":"Email","input":"c2"},{"id":"c2","component":"Input","name":"email","placeholder":"you@example.com","type":"email","rules":["required","email"]},{"id":"phoneField","component":"FormControl","label":"Phone","input":"c3"},{"id":"c3","component":"Input","name":"phone","placeholder":"e.g., +1 555 123 4567","type":"text","rules":["required","minLength:7","maxLength:20"]},{"id":"subjectField","component":"FormControl","label":"Subject","input":"c4"},{"id":"c4","component":"Select","name":"subject","items":["c5","c6","c7","c8","c9"],"placeholder":"Select a subject...","rules":["required"]},{"id":"c5","component":"SelectItem","value":"general","label":"General inquiry"},{"id":"c6","component":"SelectItem","value":"support","label":"Support"},{"id":"c7","component":"SelectItem","value":"sales","label":"Sales"},{"id":"c8","component":"SelectItem","value":"billing","label":"Billing"},{"id":"c9","component":"SelectItem","value":"feedback","label":"Feedback"},{"id":"messageField","component":"FormControl","label":"Message","input":"c10"},{"id":"c10","component":"TextArea","name":"message","placeholder":"How can we help?","rows":6,"rules":["required","minLength:10"]},{"id":"formButtons","component":"Buttons","buttons":["submitBtn","cancelBtn"],"direction":"row"},{"id":"submitBtn","component":"Button","label":"Submit","action":"submit:contact","variant":"primary"},{"id":"cancelBtn","component":"Button","label":"Cancel","action":"action:cancel_contact","variant":"secondary"}]}} diff --git a/benchmarks/samples/dashboard.a2ui-openui.jsonl b/benchmarks/samples/dashboard.a2ui-openui.jsonl new file mode 100644 index 000000000..ef651719b --- /dev/null +++ b/benchmarks/samples/dashboard.a2ui-openui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":["root = Stack([headerCard, kpiRow, chartsRow1, featuresCard, revenueCard], \"column\", \"l\")","headerCard = Card([header], \"card\")","header = CardHeader(\"Product Analytics Dashboard\", \"Usage, acquisition, feature adoption, and revenue trends\")","kpiRow = Stack([kpi1, kpi2, kpi3, kpi4], \"row\", \"m\", \"stretch\", \"start\", true)","kpi1 = Card([kpi1Title, kpi1Value, kpi1Note], \"sunk\")","kpi1Title = TextContent(\"Monthly Active Users (MAU)\", \"small-heavy\")","kpi1Value = TextContent(\"128,400\", \"large-heavy\")","kpi1Note = TextContent(\"+6.2% vs last month\", \"small\")","kpi2 = Card([kpi2Title, kpi2Value, kpi2Note], \"sunk\")","kpi2Title = TextContent(\"New Users (30d)\", \"small-heavy\")","kpi2Value = TextContent(\"24,950\", \"large-heavy\")","kpi2Note = TextContent(\"+3.1% vs last 30d\", \"small\")","kpi3 = Card([kpi3Title, kpi3Value, kpi3Note], \"sunk\")","kpi3Title = TextContent(\"MRR\", \"small-heavy\")","kpi3Value = TextContent(\"$412,000\", \"large-heavy\")","kpi3Note = TextContent(\"+4.4% MoM\", \"small\")","kpi4 = Card([kpi4Title, kpi4Value, kpi4Note], \"sunk\")","kpi4Title = TextContent(\"ARR\", \"small-heavy\")","kpi4Value = TextContent(\"$4.94M\", \"large-heavy\")","kpi4Note = TextContent(\"+18.7% YoY\", \"small\")","chartsRow1 = Stack([mauCard, acquisitionCard], \"row\", \"l\", \"stretch\", \"start\", true)","mauCard = Card([mauHeader, mauChart], \"card\")","mauHeader = CardHeader(\"Monthly Active Users\", \"Last 12 months\")","mauChart = BarChart(mauLabels, [mauSeries], \"grouped\", \"Month\", \"Users\")","mauLabels = [\"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\", \"Jan\", \"Feb\", \"Mar\"]","mauSeries = Series(\"MAU\", [84500, 87200, 90100, 93800, 96500, 100200, 104800, 109600, 114300, 119900, 123700, 128400])","acquisitionCard = Card([acqHeader, acqChart, acqLegendNote], \"card\")","acqHeader = CardHeader(\"User Acquisition\", \"Share of new users (last 30 days)\")","acqChart = PieChart(acqSlices, \"donut\")","acqSlices = [acq1, acq2, acq3, acq4, acq5]","acq1 = Slice(\"Organic Search\", 34)","acq2 = Slice(\"Paid Search\", 22)","acq3 = Slice(\"Referrals\", 16)","acq4 = Slice(\"Social\", 12)","acq5 = Slice(\"Direct / Other\", 16)","acqLegendNote = TextContent(\"Tip: Track CAC and conversion rate by channel to explain mix shifts.\", \"small\")","featuresCard = Card([featuresHeader, featuresTable], \"card\")","featuresHeader = CardHeader(\"Top Features\", \"Adoption and engagement (last 30 days)\")","featuresTable = Table(featureCols, featureRows)","featureCols = [Col(\"Feature\", \"string\"), Col(\"Weekly Active Users\", \"number\"), Col(\"Adoption Rate (%)\", \"number\"), Col(\"Avg. Uses / User\", \"number\")]","featureRows = [[\"Dashboards\", 48200, 62.5, 5.8], [\"Automations\", 31750, 41.2, 3.1], [\"Integrations\", 28900, 37.5, 2.4], [\"Team Collaboration\", 27100, 35.2, 4.6], [\"Exports\", 19850, 25.8, 1.7], [\"Alerts\", 17600, 22.9, 2.0], [\"API Access\", 12150, 15.8, 6.3]]","revenueCard = Card([revenueHeader, revenueChart, revenueNote], \"card\")","revenueHeader = CardHeader(\"Revenue Trend\", \"MRR and ARR (last 12 months)\")","revenueChart = LineChart(revLabels, [mrrSeries, arrSeries], \"natural\", \"Month\", \"USD\")","revLabels = [\"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\", \"Jan\", \"Feb\", \"Mar\"]","mrrSeries = Series(\"MRR ($)\", [332000, 341000, 349000, 356000, 364000, 372000, 381000, 389000, 397000, 404000, 408000, 412000])","arrSeries = Series(\"ARR ($)\", [3984000, 4092000, 4188000, 4272000, 4368000, 4464000, 4572000, 4668000, 4764000, 4848000, 4896000, 4944000])","revenueNote = TextContent(\"ARR shown as 12×MRR for directional tracking; replace with contracted ARR if you track annual commitments.\", \"small\")"]}} diff --git a/benchmarks/samples/dashboard.a2ui.jsonl b/benchmarks/samples/dashboard.a2ui.jsonl new file mode 100644 index 000000000..adb599780 --- /dev/null +++ b/benchmarks/samples/dashboard.a2ui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":[{"id":"root","component":"Stack","children":["headerCard","kpiRow","chartsRow1","featuresCard","revenueCard"],"direction":"column","gap":"l"},{"id":"headerCard","component":"Card","children":["header"],"variant":"card"},{"id":"header","component":"CardHeader","title":"Product Analytics Dashboard","subtitle":"Usage, acquisition, feature adoption, and revenue trends"},{"id":"kpiRow","component":"Stack","children":["kpi1","kpi2","kpi3","kpi4"],"direction":"row","gap":"m","align":"stretch","justify":"start","wrap":true},{"id":"kpi1","component":"Card","children":["kpi1Title","kpi1Value","kpi1Note"],"variant":"sunk"},{"id":"kpi1Title","component":"TextContent","text":"Monthly Active Users (MAU)","size":"small-heavy"},{"id":"kpi1Value","component":"TextContent","text":"128,400","size":"large-heavy"},{"id":"kpi1Note","component":"TextContent","text":"+6.2% vs last month","size":"small"},{"id":"kpi2","component":"Card","children":["kpi2Title","kpi2Value","kpi2Note"],"variant":"sunk"},{"id":"kpi2Title","component":"TextContent","text":"New Users (30d)","size":"small-heavy"},{"id":"kpi2Value","component":"TextContent","text":"24,950","size":"large-heavy"},{"id":"kpi2Note","component":"TextContent","text":"+3.1% vs last 30d","size":"small"},{"id":"kpi3","component":"Card","children":["kpi3Title","kpi3Value","kpi3Note"],"variant":"sunk"},{"id":"kpi3Title","component":"TextContent","text":"MRR","size":"small-heavy"},{"id":"kpi3Value","component":"TextContent","text":"$412,000","size":"large-heavy"},{"id":"kpi3Note","component":"TextContent","text":"+4.4% MoM","size":"small"},{"id":"kpi4","component":"Card","children":["kpi4Title","kpi4Value","kpi4Note"],"variant":"sunk"},{"id":"kpi4Title","component":"TextContent","text":"ARR","size":"small-heavy"},{"id":"kpi4Value","component":"TextContent","text":"$4.94M","size":"large-heavy"},{"id":"kpi4Note","component":"TextContent","text":"+18.7% YoY","size":"small"},{"id":"chartsRow1","component":"Stack","children":["mauCard","acquisitionCard"],"direction":"row","gap":"l","align":"stretch","justify":"start","wrap":true},{"id":"mauCard","component":"Card","children":["mauHeader","mauChart"],"variant":"card"},{"id":"mauHeader","component":"CardHeader","title":"Monthly Active Users","subtitle":"Last 12 months"},{"id":"mauChart","component":"BarChart","labels":["Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec","Jan","Feb","Mar"],"series":["mauSeries"],"variant":"grouped","xLabel":"Month","yLabel":"Users"},{"id":"mauSeries","component":"Series","category":"MAU","values":[84500,87200,90100,93800,96500,100200,104800,109600,114300,119900,123700,128400]},{"id":"acquisitionCard","component":"Card","children":["acqHeader","acqChart","acqLegendNote"],"variant":"card"},{"id":"acqHeader","component":"CardHeader","title":"User Acquisition","subtitle":"Share of new users (last 30 days)"},{"id":"acqChart","component":"PieChart","slices":["acq1","acq2","acq3","acq4","acq5"],"variant":"donut"},{"id":"acq1","component":"Slice","category":"Organic Search","value":34},{"id":"acq2","component":"Slice","category":"Paid Search","value":22},{"id":"acq3","component":"Slice","category":"Referrals","value":16},{"id":"acq4","component":"Slice","category":"Social","value":12},{"id":"acq5","component":"Slice","category":"Direct / Other","value":16},{"id":"acqLegendNote","component":"TextContent","text":"Tip: Track CAC and conversion rate by channel to explain mix shifts.","size":"small"},{"id":"featuresCard","component":"Card","children":["featuresHeader","featuresTable"],"variant":"card"},{"id":"featuresHeader","component":"CardHeader","title":"Top Features","subtitle":"Adoption and engagement (last 30 days)"},{"id":"featuresTable","component":"Table","columns":["c1","c2","c3","c4"],"rows":[["Dashboards",48200,62.5,5.8],["Automations",31750,41.2,3.1],["Integrations",28900,37.5,2.4],["Team Collaboration",27100,35.2,4.6],["Exports",19850,25.8,1.7],["Alerts",17600,22.9,2],["API Access",12150,15.8,6.3]]},{"id":"c1","component":"Col","label":"Feature","type":"string"},{"id":"c2","component":"Col","label":"Weekly Active Users","type":"number"},{"id":"c3","component":"Col","label":"Adoption Rate (%)","type":"number"},{"id":"c4","component":"Col","label":"Avg. Uses / User","type":"number"},{"id":"revenueCard","component":"Card","children":["revenueHeader","revenueChart","revenueNote"],"variant":"card"},{"id":"revenueHeader","component":"CardHeader","title":"Revenue Trend","subtitle":"MRR and ARR (last 12 months)"},{"id":"revenueChart","component":"LineChart","labels":["Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec","Jan","Feb","Mar"],"series":["mrrSeries","arrSeries"],"variant":"natural","xLabel":"Month","yLabel":"USD"},{"id":"mrrSeries","component":"Series","category":"MRR ($)","values":[332000,341000,349000,356000,364000,372000,381000,389000,397000,404000,408000,412000]},{"id":"arrSeries","component":"Series","category":"ARR ($)","values":[3984000,4092000,4188000,4272000,4368000,4464000,4572000,4668000,4764000,4848000,4896000,4944000]},{"id":"revenueNote","component":"TextContent","text":"ARR shown as 12×MRR for directional tracking; replace with contracted ARR if you track annual commitments.","size":"small"}]}} diff --git a/benchmarks/samples/e-commerce-product.a2ui-openui.jsonl b/benchmarks/samples/e-commerce-product.a2ui-openui.jsonl new file mode 100644 index 000000000..2c10a3abb --- /dev/null +++ b/benchmarks/samples/e-commerce-product.a2ui-openui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":["root = Stack([header, main], \"column\", \"l\")","header = Card([headerText], \"clear\")","headerText = TextContent(\"Product Details\", \"large-heavy\")","main = Stack([topRow, detailsRow], \"column\", \"l\")","topRow = Stack([galleryCard, buyCard], \"row\", \"l\", \"start\", \"start\", true)","galleryCard = Card([galleryHeader, gallery], \"card\")","galleryHeader = CardHeader(\"AeroStride Runner Sneakers\", \"Lightweight daily trainers with responsive cushioning\")","gallery = ImageGallery(galleryImages)","buyCard = Card([buyHeader, priceRow, separator1, sizeForm, separator2, colorCard, separator3, ctaButtons, finePrint], \"card\")","buyHeader = CardHeader(\"AeroStride Runner\", \"Men's / Women's • Road & casual\")","priceRow = Stack([priceText, priceTag], \"row\", \"m\", \"center\", \"between\", true)","priceText = TextContent(\"$129.00\", \"large-heavy\")","priceTag = Tag(\"In stock\", \"check\", \"md\", \"success\")","separator1 = Separator(\"horizontal\", true)","sizeForm = Form(\"select_size\", [sizeField])","sizeField = FormControl(\"Size\", Select(\"size\", sizeOptions, \"Select your size...\", [\"required\"]))","sizeOptions = [SelectItem(\"7\", \"US 7\"), SelectItem(\"7.5\", \"US 7.5\"), SelectItem(\"8\", \"US 8\"), SelectItem(\"8.5\", \"US 8.5\"), SelectItem(\"9\", \"US 9\"), SelectItem(\"9.5\", \"US 9.5\"), SelectItem(\"10\", \"US 10\"), SelectItem(\"10.5\", \"US 10.5\"), SelectItem(\"11\", \"US 11\"), SelectItem(\"12\", \"US 12\")]","separator2 = Separator(\"horizontal\", true)","colorCard = Stack([colorTitle, colorOptionsGroup], \"column\", \"s\")","colorTitle = TextContent(\"Color\", \"small-heavy\")","colorOptionsGroup = RadioGroup(\"color\", colorItems, \"black_white\", [\"required\"])","colorItems = [RadioItem(\"Black / White\", \"Classic contrast with white midsole\", \"black_white\"), RadioItem(\"Sand / Gum\", \"Neutral upper with gum outsole\", \"sand_gum\"), RadioItem(\"Slate / Neon\", \"Cool tones with neon accents\", \"slate_neon\")]","separator3 = Separator(\"horizontal\", true)","ctaButtons = Buttons([addToCartBtn, wishlistBtn], \"row\")","addToCartBtn = Button(\"Add to Cart\", \"action:add_to_cart_sneakers\", \"primary\")","wishlistBtn = Button(\"Save\", \"action:save_sneakers\", \"secondary\")","finePrint = TextContent(\"Free returns within 30 days. Ships in 1–2 business days.\", \"small\")","detailsRow = Stack([detailsCard, specsCard], \"row\", \"l\", \"start\", \"start\", true)","detailsCard = Card([detailsHeader, detailsText, featureTags], \"sunk\")","detailsHeader = CardHeader(\"About this item\", \"Built for comfort from morning commutes to weekend miles\")","detailsText = MarkDownRenderer(detailsMarkdown, \"clear\")","detailsMarkdown = \"The **AeroStride Runner** blends breathable mesh with a supportive heel counter for a secure fit. A responsive foam midsole delivers soft landings and quick transitions, while the durable rubber outsole adds traction on city streets.\\n\\n**Fit note:** True to size. If you’re between sizes, consider going up 0.5.\"","featureTags = TagBlock(featureTagList)","featureTagList = [\"Breathable mesh upper\", \"Responsive foam cushioning\", \"Rubber traction outsole\", \"Padded collar & tongue\", \"Everyday training\"]","specsCard = Card([specsHeader, specsTable], \"sunk\")","specsHeader = CardHeader(\"Specs\", \"Materials, weight, and care\")","specsTable = Table(specCols, specRows)","specCols = [Col(\"Detail\", \"string\"), Col(\"Value\", \"string\")]","specRows = [[\"Weight\", \"9.8 oz (US 9)\"], [\"Drop\", \"8 mm\"], [\"Upper\", \"Engineered mesh\"], [\"Midsole\", \"EVA-based foam\"], [\"Outsole\", \"High-abrasion rubber\"], [\"Care\", \"Spot clean; air dry\"]]","galleryImages = [{src: \"https://images.unsplash.com/photo-1542291026-7eec264c27ff?auto=format&fit=crop&w=1200&q=80\", alt: \"Sneakers angled side view\", details: \"Side profile showing breathable mesh and cushioned midsole\"}, {src: \"https://images.unsplash.com/photo-1528701800489-20be3c1ea3b1?auto=format&fit=crop&w=1200&q=80\", alt: \"Sneakers top view\", details: \"Top view highlighting laces and padded tongue\"}, {src: \"https://images.unsplash.com/photo-1600269452121-4f2416e55c28?auto=format&fit=crop&w=1200&q=80\", alt: \"Sneakers outsole view\", details: \"Rubber outsole pattern for traction\"}, {src: \"https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=1200&q=80\", alt: \"Sneakers lifestyle shot\", details: \"Everyday styling in an urban setting\"}]"]}} diff --git a/benchmarks/samples/e-commerce-product.a2ui.jsonl b/benchmarks/samples/e-commerce-product.a2ui.jsonl new file mode 100644 index 000000000..48e9a6b79 --- /dev/null +++ b/benchmarks/samples/e-commerce-product.a2ui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":[{"id":"root","component":"Stack","children":["header","main"],"direction":"column","gap":"l"},{"id":"header","component":"Card","children":["headerText"],"variant":"clear"},{"id":"headerText","component":"TextContent","text":"Product Details","size":"large-heavy"},{"id":"main","component":"Stack","children":["topRow","detailsRow"],"direction":"column","gap":"l"},{"id":"topRow","component":"Stack","children":["galleryCard","buyCard"],"direction":"row","gap":"l","align":"start","justify":"start","wrap":true},{"id":"galleryCard","component":"Card","children":["galleryHeader","gallery"],"variant":"card"},{"id":"galleryHeader","component":"CardHeader","title":"AeroStride Runner Sneakers","subtitle":"Lightweight daily trainers with responsive cushioning"},{"id":"gallery","component":"ImageGallery","images":[{"src":"https://images.unsplash.com/photo-1542291026-7eec264c27ff?auto=format&fit=crop&w=1200&q=80","alt":"Sneakers angled side view","details":"Side profile showing breathable mesh and cushioned midsole"},{"src":"https://images.unsplash.com/photo-1528701800489-20be3c1ea3b1?auto=format&fit=crop&w=1200&q=80","alt":"Sneakers top view","details":"Top view highlighting laces and padded tongue"},{"src":"https://images.unsplash.com/photo-1600269452121-4f2416e55c28?auto=format&fit=crop&w=1200&q=80","alt":"Sneakers outsole view","details":"Rubber outsole pattern for traction"},{"src":"https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=1200&q=80","alt":"Sneakers lifestyle shot","details":"Everyday styling in an urban setting"}]},{"id":"buyCard","component":"Card","children":["buyHeader","priceRow","separator1","sizeForm","separator2","colorCard","separator3","ctaButtons","finePrint"],"variant":"card"},{"id":"buyHeader","component":"CardHeader","title":"AeroStride Runner","subtitle":"Men's / Women's • Road & casual"},{"id":"priceRow","component":"Stack","children":["priceText","priceTag"],"direction":"row","gap":"m","align":"center","justify":"between","wrap":true},{"id":"priceText","component":"TextContent","text":"$129.00","size":"large-heavy"},{"id":"priceTag","component":"Tag","text":"In stock","icon":"check","size":"md","variant":"success"},{"id":"separator1","component":"Separator","orientation":"horizontal","decorative":true},{"id":"sizeForm","component":"Form","name":"select_size","fields":["sizeField"]},{"id":"sizeField","component":"FormControl","label":"Size","input":"c1"},{"id":"c1","component":"Select","name":"size","items":["c2","c3","c4","c5","c6","c7","c8","c9","c10","c11"],"placeholder":"Select your size...","rules":["required"]},{"id":"c2","component":"SelectItem","value":"7","label":"US 7"},{"id":"c3","component":"SelectItem","value":"7.5","label":"US 7.5"},{"id":"c4","component":"SelectItem","value":"8","label":"US 8"},{"id":"c5","component":"SelectItem","value":"8.5","label":"US 8.5"},{"id":"c6","component":"SelectItem","value":"9","label":"US 9"},{"id":"c7","component":"SelectItem","value":"9.5","label":"US 9.5"},{"id":"c8","component":"SelectItem","value":"10","label":"US 10"},{"id":"c9","component":"SelectItem","value":"10.5","label":"US 10.5"},{"id":"c10","component":"SelectItem","value":"11","label":"US 11"},{"id":"c11","component":"SelectItem","value":"12","label":"US 12"},{"id":"separator2","component":"Separator","orientation":"horizontal","decorative":true},{"id":"colorCard","component":"Stack","children":["colorTitle","colorOptionsGroup"],"direction":"column","gap":"s"},{"id":"colorTitle","component":"TextContent","text":"Color","size":"small-heavy"},{"id":"colorOptionsGroup","component":"RadioGroup","name":"color","items":["c12","c13","c14"],"defaultValue":"black_white","rules":["required"]},{"id":"c12","component":"RadioItem","label":"Black / White","description":"Classic contrast with white midsole","value":"black_white"},{"id":"c13","component":"RadioItem","label":"Sand / Gum","description":"Neutral upper with gum outsole","value":"sand_gum"},{"id":"c14","component":"RadioItem","label":"Slate / Neon","description":"Cool tones with neon accents","value":"slate_neon"},{"id":"separator3","component":"Separator","orientation":"horizontal","decorative":true},{"id":"ctaButtons","component":"Buttons","buttons":["addToCartBtn","wishlistBtn"],"direction":"row"},{"id":"addToCartBtn","component":"Button","label":"Add to Cart","action":"action:add_to_cart_sneakers","variant":"primary"},{"id":"wishlistBtn","component":"Button","label":"Save","action":"action:save_sneakers","variant":"secondary"},{"id":"finePrint","component":"TextContent","text":"Free returns within 30 days. Ships in 1–2 business days.","size":"small"},{"id":"detailsRow","component":"Stack","children":["detailsCard","specsCard"],"direction":"row","gap":"l","align":"start","justify":"start","wrap":true},{"id":"detailsCard","component":"Card","children":["detailsHeader","detailsText","featureTags"],"variant":"sunk"},{"id":"detailsHeader","component":"CardHeader","title":"About this item","subtitle":"Built for comfort from morning commutes to weekend miles"},{"id":"detailsText","component":"MarkDownRenderer","textMarkdown":"The **AeroStride Runner** blends breathable mesh with a supportive heel counter for a secure fit. A responsive foam midsole delivers soft landings and quick transitions, while the durable rubber outsole adds traction on city streets.\n\n**Fit note:** True to size. If you’re between sizes, consider going up 0.5.","variant":"clear"},{"id":"featureTags","component":"TagBlock","tags":["Breathable mesh upper","Responsive foam cushioning","Rubber traction outsole","Padded collar & tongue","Everyday training"]},{"id":"specsCard","component":"Card","children":["specsHeader","specsTable"],"variant":"sunk"},{"id":"specsHeader","component":"CardHeader","title":"Specs","subtitle":"Materials, weight, and care"},{"id":"specsTable","component":"Table","columns":["c15","c16"],"rows":[["Weight","9.8 oz (US 9)"],["Drop","8 mm"],["Upper","Engineered mesh"],["Midsole","EVA-based foam"],["Outsole","High-abrasion rubber"],["Care","Spot clean; air dry"]]},{"id":"c15","component":"Col","label":"Detail","type":"string"},{"id":"c16","component":"Col","label":"Value","type":"string"}]}} diff --git a/benchmarks/samples/pricing-page.a2ui-openui.jsonl b/benchmarks/samples/pricing-page.a2ui-openui.jsonl new file mode 100644 index 000000000..4e60d7a19 --- /dev/null +++ b/benchmarks/samples/pricing-page.a2ui-openui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":["root = Stack([hero, pricingRow, tableTitle, featureTable, faq], \"column\", \"xl\")","hero = Stack([heroTitle, heroSubtitle, heroTags], \"column\", \"s\")","heroTitle = TextContent(\"Pricing\", \"large-heavy\")","heroSubtitle = TextContent(\"Choose the plan that fits your team today. Upgrade, downgrade, or cancel anytime.\", \"default\")","heroTags = TagBlock([\"Monthly billing\", \"Annual discounts available\", \"No hidden fees\"])","pricingRow = Stack([basicCard, proCard, enterpriseCard], \"row\", \"l\", \"stretch\", \"start\", true)","basicCard = Card([basicHeader, basicPrice, basicDesc, basicFeaturesTitle, basicFeatures, basicCta], \"card\")","basicHeader = CardHeader(\"Basic\", \"For individuals and small projects\")","basicPrice = TextContent(\"$12 / month\", \"large-heavy\")","basicDesc = TextContent(\"Core features to get started with reliable essentials.\", \"default\")","basicFeaturesTitle = TextContent(\"Includes:\", \"small-heavy\")","basicFeatures = MarkDownRenderer(\"- 1 workspace\\n- Up to 3 projects\\n- 5 GB storage\\n- Community support\\n- Basic analytics\", \"clear\")","basicCta = Buttons([Button(\"Start Basic\", \"action:select_plan_basic\", \"secondary\"), Button(\"Try free\", \"action:trial_basic\", \"tertiary\")])","proCard = Card([proHeader, proPrice, proDesc, proFeaturesTitle, proFeatures, proCta, proCallout], \"sunk\")","proHeader = CardHeader(\"Pro\", \"For growing teams that need more power\")","proPrice = TextContent(\"$29 / month\", \"large-heavy\")","proDesc = TextContent(\"Advanced collaboration, automation, and deeper insights.\", \"default\")","proFeaturesTitle = TextContent(\"Everything in Basic, plus:\", \"small-heavy\")","proFeatures = MarkDownRenderer(\"- 5 workspaces\\n- Unlimited projects\\n- 100 GB storage\\n- Team roles & permissions\\n- Automations (1,000 runs/month)\\n- Integrations (Slack, GitHub, Zapier)\\n- Priority email support\", \"clear\")","proCta = Buttons([Button(\"Choose Pro\", \"action:select_plan_pro\", \"primary\"), Button(\"Try free\", \"action:trial_pro\", \"secondary\")])","proCallout = Callout(\"success\", \"Most popular\", \"Best value for teams that want to scale without switching tools later.\")","enterpriseCard = Card([entHeader, entPrice, entDesc, entFeaturesTitle, entFeatures, entCta, entNote], \"card\")","entHeader = CardHeader(\"Enterprise\", \"For organizations with advanced security and scale\")","entPrice = TextContent(\"Custom pricing\", \"large-heavy\")","entDesc = TextContent(\"Security, compliance, and dedicated support tailored to your needs.\", \"default\")","entFeaturesTitle = TextContent(\"Everything in Pro, plus:\", \"small-heavy\")","entFeatures = MarkDownRenderer(\"- Unlimited workspaces\\n- Unlimited storage (fair use)\\n- SSO/SAML & SCIM provisioning\\n- Audit logs & data retention controls\\n- Dedicated success manager\\n- 99.9% uptime SLA\\n- Custom legal & invoicing\", \"clear\")","entCta = Buttons([Button(\"Contact sales\", \"action:contact_sales_enterprise\", \"primary\"), Button(\"Request demo\", \"action:request_demo_enterprise\", \"secondary\")])","entNote = TextCallout(\"neutral\", \"Need procurement support?\", \"We can provide security docs, vendor onboarding, and custom terms.\")","tableTitle = TextContent(\"Feature comparison\", \"large-heavy\")","featureTable = Table(featureCols, featureRows)","featureCols = [Col(\"Feature\", \"string\"), Col(\"Basic\", \"string\"), Col(\"Pro\", \"string\"), Col(\"Enterprise\", \"string\")]","featureRows = [\n [\"Price (monthly)\", \"$12\", \"$29\", \"Custom\"],\n [\"Workspaces\", \"1\", \"5\", \"Unlimited\"],\n [\"Projects\", \"Up to 3\", \"Unlimited\", \"Unlimited\"],\n [\"Storage\", \"5 GB\", \"100 GB\", \"Unlimited (fair use)\"],\n [\"Team members\", \"1\", \"Up to 25\", \"Unlimited\"],\n [\"Roles & permissions\", \"—\", \"Yes\", \"Advanced (custom roles)\"],\n [\"Automations\", \"—\", \"1,000 runs/mo\", \"Unlimited (policy-based)\"],\n [\"Integrations\", \"Limited\", \"Standard\", \"Standard + custom\"],\n [\"Analytics\", \"Basic\", \"Advanced\", \"Advanced + exports\"],\n [\"SSO (SAML)\", \"—\", \"—\", \"Yes\"],\n [\"SCIM provisioning\", \"—\", \"—\", \"Yes\"],\n [\"Audit logs\", \"—\", \"—\", \"Yes\"],\n [\"Support\", \"Community\", \"Priority email\", \"Dedicated + SLA\"]\n]","faq = Accordion([faq1, faq2, faq3, faq4])","faq1 = AccordionItem(\"billing\", \"Can I switch plans later?\", [TextContent(\"Yes. You can upgrade or downgrade at any time. Changes take effect immediately, and we prorate when applicable.\")])","faq2 = AccordionItem(\"trial\", \"Do you offer a free trial?\", [TextContent(\"Yes. Basic and Pro include a free trial. Enterprise trials are available upon request via Sales.\")])","faq3 = AccordionItem(\"annual\", \"Do you offer annual billing?\", [TextContent(\"Yes. Annual billing includes a discount. Contact Sales for Enterprise annual terms and invoicing.\")])","faq4 = AccordionItem(\"security\", \"What about security and compliance?\", [TextContent(\"Enterprise includes SSO/SAML, SCIM, audit logs, and data retention controls. We can share security documentation during procurement.\")])"]}} diff --git a/benchmarks/samples/pricing-page.a2ui.jsonl b/benchmarks/samples/pricing-page.a2ui.jsonl new file mode 100644 index 000000000..233be7833 --- /dev/null +++ b/benchmarks/samples/pricing-page.a2ui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":[{"id":"root","component":"Stack","children":["hero","pricingRow","tableTitle","featureTable","faq"],"direction":"column","gap":"xl"},{"id":"hero","component":"Stack","children":["heroTitle","heroSubtitle","heroTags"],"direction":"column","gap":"s"},{"id":"heroTitle","component":"TextContent","text":"Pricing","size":"large-heavy"},{"id":"heroSubtitle","component":"TextContent","text":"Choose the plan that fits your team today. Upgrade, downgrade, or cancel anytime.","size":"default"},{"id":"heroTags","component":"TagBlock","tags":["Monthly billing","Annual discounts available","No hidden fees"]},{"id":"pricingRow","component":"Stack","children":["basicCard","proCard","enterpriseCard"],"direction":"row","gap":"l","align":"stretch","justify":"start","wrap":true},{"id":"basicCard","component":"Card","children":["basicHeader","basicPrice","basicDesc","basicFeaturesTitle","basicFeatures","basicCta"],"variant":"card"},{"id":"basicHeader","component":"CardHeader","title":"Basic","subtitle":"For individuals and small projects"},{"id":"basicPrice","component":"TextContent","text":"$12 / month","size":"large-heavy"},{"id":"basicDesc","component":"TextContent","text":"Core features to get started with reliable essentials.","size":"default"},{"id":"basicFeaturesTitle","component":"TextContent","text":"Includes:","size":"small-heavy"},{"id":"basicFeatures","component":"MarkDownRenderer","textMarkdown":"- 1 workspace\n- Up to 3 projects\n- 5 GB storage\n- Community support\n- Basic analytics","variant":"clear"},{"id":"basicCta","component":"Buttons","buttons":["c1","c2"]},{"id":"c1","component":"Button","label":"Start Basic","action":"action:select_plan_basic","variant":"secondary"},{"id":"c2","component":"Button","label":"Try free","action":"action:trial_basic","variant":"tertiary"},{"id":"proCard","component":"Card","children":["proHeader","proPrice","proDesc","proFeaturesTitle","proFeatures","proCta","proCallout"],"variant":"sunk"},{"id":"proHeader","component":"CardHeader","title":"Pro","subtitle":"For growing teams that need more power"},{"id":"proPrice","component":"TextContent","text":"$29 / month","size":"large-heavy"},{"id":"proDesc","component":"TextContent","text":"Advanced collaboration, automation, and deeper insights.","size":"default"},{"id":"proFeaturesTitle","component":"TextContent","text":"Everything in Basic, plus:","size":"small-heavy"},{"id":"proFeatures","component":"MarkDownRenderer","textMarkdown":"- 5 workspaces\n- Unlimited projects\n- 100 GB storage\n- Team roles & permissions\n- Automations (1,000 runs/month)\n- Integrations (Slack, GitHub, Zapier)\n- Priority email support","variant":"clear"},{"id":"proCta","component":"Buttons","buttons":["c3","c4"]},{"id":"c3","component":"Button","label":"Choose Pro","action":"action:select_plan_pro","variant":"primary"},{"id":"c4","component":"Button","label":"Try free","action":"action:trial_pro","variant":"secondary"},{"id":"proCallout","component":"Callout","variant":"success","title":"Most popular","description":"Best value for teams that want to scale without switching tools later."},{"id":"enterpriseCard","component":"Card","children":["entHeader","entPrice","entDesc","entFeaturesTitle","entFeatures","entCta","entNote"],"variant":"card"},{"id":"entHeader","component":"CardHeader","title":"Enterprise","subtitle":"For organizations with advanced security and scale"},{"id":"entPrice","component":"TextContent","text":"Custom pricing","size":"large-heavy"},{"id":"entDesc","component":"TextContent","text":"Security, compliance, and dedicated support tailored to your needs.","size":"default"},{"id":"entFeaturesTitle","component":"TextContent","text":"Everything in Pro, plus:","size":"small-heavy"},{"id":"entFeatures","component":"MarkDownRenderer","textMarkdown":"- Unlimited workspaces\n- Unlimited storage (fair use)\n- SSO/SAML & SCIM provisioning\n- Audit logs & data retention controls\n- Dedicated success manager\n- 99.9% uptime SLA\n- Custom legal & invoicing","variant":"clear"},{"id":"entCta","component":"Buttons","buttons":["c5","c6"]},{"id":"c5","component":"Button","label":"Contact sales","action":"action:contact_sales_enterprise","variant":"primary"},{"id":"c6","component":"Button","label":"Request demo","action":"action:request_demo_enterprise","variant":"secondary"},{"id":"entNote","component":"TextCallout","variant":"neutral","title":"Need procurement support?","description":"We can provide security docs, vendor onboarding, and custom terms."},{"id":"tableTitle","component":"TextContent","text":"Feature comparison","size":"large-heavy"},{"id":"featureTable","component":"Table","columns":["c7","c8","c9","c10"],"rows":[["Price (monthly)","$12","$29","Custom"],["Workspaces","1","5","Unlimited"],["Projects","Up to 3","Unlimited","Unlimited"],["Storage","5 GB","100 GB","Unlimited (fair use)"],["Team members","1","Up to 25","Unlimited"],["Roles & permissions","—","Yes","Advanced (custom roles)"],["Automations","—","1,000 runs/mo","Unlimited (policy-based)"],["Integrations","Limited","Standard","Standard + custom"],["Analytics","Basic","Advanced","Advanced + exports"],["SSO (SAML)","—","—","Yes"],["SCIM provisioning","—","—","Yes"],["Audit logs","—","—","Yes"],["Support","Community","Priority email","Dedicated + SLA"]]},{"id":"c7","component":"Col","label":"Feature","type":"string"},{"id":"c8","component":"Col","label":"Basic","type":"string"},{"id":"c9","component":"Col","label":"Pro","type":"string"},{"id":"c10","component":"Col","label":"Enterprise","type":"string"},{"id":"faq","component":"Accordion","items":["faq1","faq2","faq3","faq4"]},{"id":"faq1","component":"AccordionItem","value":"billing","trigger":"Can I switch plans later?","content":["c11"]},{"id":"c11","component":"TextContent","text":"Yes. You can upgrade or downgrade at any time. Changes take effect immediately, and we prorate when applicable."},{"id":"faq2","component":"AccordionItem","value":"trial","trigger":"Do you offer a free trial?","content":["c12"]},{"id":"c12","component":"TextContent","text":"Yes. Basic and Pro include a free trial. Enterprise trials are available upon request via Sales."},{"id":"faq3","component":"AccordionItem","value":"annual","trigger":"Do you offer annual billing?","content":["c13"]},{"id":"c13","component":"TextContent","text":"Yes. Annual billing includes a discount. Contact Sales for Enterprise annual terms and invoicing."},{"id":"faq4","component":"AccordionItem","value":"security","trigger":"What about security and compliance?","content":["c14"]},{"id":"c14","component":"TextContent","text":"Enterprise includes SSO/SAML, SCIM, audit logs, and data retention controls. We can share security documentation during procurement."}]}} diff --git a/benchmarks/samples/settings-panel.a2ui-openui.jsonl b/benchmarks/samples/settings-panel.a2ui-openui.jsonl new file mode 100644 index 000000000..dae66d29f --- /dev/null +++ b/benchmarks/samples/settings-panel.a2ui-openui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":["root = Stack([title, tabs], \"column\", \"l\")","title = TextContent(\"User Settings\", \"large-heavy\")","tabs = Tabs([tabProfile, tabSecurity, tabNotifications])","tabProfile = TabItem(\"profile\", \"Profile\", profileContent)","profileContent = [profileCard]","profileCard = Card([profileHeader, profileForm], \"card\")","profileHeader = CardHeader(\"Profile\", \"Update your personal information and avatar.\")","profileForm = Form(\"profile_update\", [nameField, avatarField], profileButtons)","nameField = FormControl(\"Display name\", Input(\"display_name\", \"e.g., Alex Johnson\", \"text\", [\"required\", \"minLength:2\", \"maxLength:60\"]))","avatarField = FormControl(\"Avatar URL\", Input(\"avatar_url\", \"https://example.com/avatar.png\", \"url\", [\"url\"]))","profileButtons = Buttons([saveProfileBtn, resetProfileBtn])","saveProfileBtn = Button(\"Save changes\", \"submit:profile_update\", \"primary\")","resetProfileBtn = Button(\"Reset\", \"action:reset_profile_form\", \"secondary\")","tabSecurity = TabItem(\"security\", \"Security\", securityContent)","securityContent = [securityCard]","securityCard = Card([securityHeader, securityToggles, securityHint], \"card\")","securityHeader = CardHeader(\"Security\", \"Manage sign-in protection and account security.\")","securityToggles = SwitchGroup([twoFaSwitch], \"sunk\")","twoFaSwitch = SwitchItem(\"Two-factor authentication (2FA)\", \"Require a verification code when signing in.\", \"two_fa\", \"enabled\", false, true, false)","securityHint = TextCallout(\"info\", \"Tip\", \"After enabling 2FA, you may be asked to set up an authenticator app or backup codes.\")","tabNotifications = TabItem(\"notifications\", \"Notifications\", notificationsContent)","notificationsContent = [notificationsCard]","notificationsCard = Card([notificationsHeader, notificationsToggles], \"card\")","notificationsHeader = CardHeader(\"Notifications\", \"Choose what you want to be notified about.\")","notificationsToggles = SwitchGroup([emailSwitch, productSwitch, securityAlertsSwitch], \"sunk\")","emailSwitch = SwitchItem(\"Email notifications\", \"Receive updates and account messages by email.\", \"notif_email\", \"enabled\", true, true, false)","productSwitch = SwitchItem(\"Product updates\", \"Get notified about new features and improvements.\", \"notif_product\", \"enabled\", false, false, false)","securityAlertsSwitch = SwitchItem(\"Security alerts\", \"Important alerts about sign-ins and security changes.\", \"notif_security\", \"enabled\", true, true, false)"]}} diff --git a/benchmarks/samples/settings-panel.a2ui.jsonl b/benchmarks/samples/settings-panel.a2ui.jsonl new file mode 100644 index 000000000..cbcdda313 --- /dev/null +++ b/benchmarks/samples/settings-panel.a2ui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":[{"id":"root","component":"Stack","children":["title","tabs"],"direction":"column","gap":"l"},{"id":"title","component":"TextContent","text":"User Settings","size":"large-heavy"},{"id":"tabs","component":"Tabs","items":["tabProfile","tabSecurity","tabNotifications"]},{"id":"tabProfile","component":"TabItem","value":"profile","trigger":"Profile","content":["profileCard"]},{"id":"profileCard","component":"Card","children":["profileHeader","profileForm"],"variant":"card"},{"id":"profileHeader","component":"CardHeader","title":"Profile","subtitle":"Update your personal information and avatar."},{"id":"profileForm","component":"Form","name":"profile_update","fields":["nameField","avatarField"],"buttons":"profileButtons"},{"id":"nameField","component":"FormControl","label":"Display name","input":"c1"},{"id":"c1","component":"Input","name":"display_name","placeholder":"e.g., Alex Johnson","type":"text","rules":["required","minLength:2","maxLength:60"]},{"id":"avatarField","component":"FormControl","label":"Avatar URL","input":"c2"},{"id":"c2","component":"Input","name":"avatar_url","placeholder":"https://example.com/avatar.png","type":"url","rules":["url"]},{"id":"profileButtons","component":"Buttons","buttons":["saveProfileBtn","resetProfileBtn"]},{"id":"saveProfileBtn","component":"Button","label":"Save changes","action":"submit:profile_update","variant":"primary"},{"id":"resetProfileBtn","component":"Button","label":"Reset","action":"action:reset_profile_form","variant":"secondary"},{"id":"tabSecurity","component":"TabItem","value":"security","trigger":"Security","content":["securityCard"]},{"id":"securityCard","component":"Card","children":["securityHeader","securityToggles","securityHint"],"variant":"card"},{"id":"securityHeader","component":"CardHeader","title":"Security","subtitle":"Manage sign-in protection and account security."},{"id":"securityToggles","component":"SwitchGroup","items":["twoFaSwitch"],"variant":"sunk"},{"id":"twoFaSwitch","component":"SwitchItem","label":"Two-factor authentication (2FA)","description":"Require a verification code when signing in.","name":"two_fa","value":"enabled","checked":false,"defaultChecked":true,"disabled":false},{"id":"securityHint","component":"TextCallout","variant":"info","title":"Tip","description":"After enabling 2FA, you may be asked to set up an authenticator app or backup codes."},{"id":"tabNotifications","component":"TabItem","value":"notifications","trigger":"Notifications","content":["notificationsCard"]},{"id":"notificationsCard","component":"Card","children":["notificationsHeader","notificationsToggles"],"variant":"card"},{"id":"notificationsHeader","component":"CardHeader","title":"Notifications","subtitle":"Choose what you want to be notified about."},{"id":"notificationsToggles","component":"SwitchGroup","items":["emailSwitch","productSwitch","securityAlertsSwitch"],"variant":"sunk"},{"id":"emailSwitch","component":"SwitchItem","label":"Email notifications","description":"Receive updates and account messages by email.","name":"notif_email","value":"enabled","checked":true,"defaultChecked":true,"disabled":false},{"id":"productSwitch","component":"SwitchItem","label":"Product updates","description":"Get notified about new features and improvements.","name":"notif_product","value":"enabled","checked":false,"defaultChecked":false,"disabled":false},{"id":"securityAlertsSwitch","component":"SwitchItem","label":"Security alerts","description":"Important alerts about sign-ins and security changes.","name":"notif_security","value":"enabled","checked":true,"defaultChecked":true,"disabled":false}]}} diff --git a/benchmarks/samples/simple-table.a2ui-openui.jsonl b/benchmarks/samples/simple-table.a2ui-openui.jsonl new file mode 100644 index 000000000..e9ce40e8f --- /dev/null +++ b/benchmarks/samples/simple-table.a2ui-openui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":["root = Stack([title, tbl])","title = TextContent(\"Employees (Sample)\", \"large-heavy\")","tbl = Table(cols, rows)","cols = [Col(\"Name\", \"string\"), Col(\"Department\", \"string\"), Col(\"Salary\", \"number\"), Col(\"YoY change (%)\", \"number\")]","rows = [[\"Ava Patel\", \"Engineering\", 132000, 6.5], [\"Marcus Lee\", \"Sales\", 98000, 4.2], [\"Sofia Ramirez\", \"Marketing\", 105000, 3.1], [\"Ethan Brooks\", \"Finance\", 118500, 5.0], [\"Nina Chen\", \"HR\", 89000, 2.4]]"]}} diff --git a/benchmarks/samples/simple-table.a2ui.jsonl b/benchmarks/samples/simple-table.a2ui.jsonl new file mode 100644 index 000000000..1da5e722f --- /dev/null +++ b/benchmarks/samples/simple-table.a2ui.jsonl @@ -0,0 +1,2 @@ +{"version":"v1.0","createSurface":{"surfaceId":"main","catalogId":"https://openui.com/catalog/default"}} +{"version":"v1.0","updateComponents":{"surfaceId":"main","components":[{"id":"root","component":"Stack","children":["title","tbl"]},{"id":"title","component":"TextContent","text":"Employees (Sample)","size":"large-heavy"},{"id":"tbl","component":"Table","columns":["c1","c2","c3","c4"],"rows":[["Ava Patel","Engineering",132000,6.5],["Marcus Lee","Sales",98000,4.2],["Sofia Ramirez","Marketing",105000,3.1],["Ethan Brooks","Finance",118500,5],["Nina Chen","HR",89000,2.4]]},{"id":"c1","component":"Col","label":"Name","type":"string"},{"id":"c2","component":"Col","label":"Department","type":"string"},{"id":"c3","component":"Col","label":"Salary","type":"number"},{"id":"c4","component":"Col","label":"YoY change (%)","type":"number"}]}} diff --git a/docs/content/docs/api-reference/a2ui.mdx b/docs/content/docs/api-reference/a2ui.mdx new file mode 100644 index 000000000..41ee7a2f2 --- /dev/null +++ b/docs/content/docs/api-reference/a2ui.mdx @@ -0,0 +1,544 @@ +--- +title: "@openuidev/a2ui" +description: A2UI v1.0 protocol support with OpenUI Lang component updates and an optional React renderer. +--- + +`@openuidev/a2ui` implements the [A2UI v1.0](https://a2ui.org/specification/v1.0-a2ui/) surface lifecycle while using OpenUI Lang for component payloads. + +The package deliberately changes one part of the protocol: `createSurface.components` and `updateComponents.components` are arrays of OpenUI Lang statements instead of A2UI component JSON. All other lifecycle and message envelopes retain their A2UI v1.0 shapes, including surface creation and deletion, data-model updates, actions, function calls and responses, errors, capabilities, and renderer metadata. + +```json +{ + "version": "v1.0", + "updateComponents": { + "surfaceId": "main", + "components": [ + "root = Stack([title, save])", + "title = TextContent(\"Account settings\")", + "save = Button(\"Save\", onClick: @ToAssistant(\"Save these settings\"))" + ] + } +} +``` + +This gives you A2UI's stateful, bidirectional protocol and OpenUI Lang's compact, streaming-friendly component representation. + +## Package structure + +The package has two public entrypoints: + +| Entrypoint | Environment | Responsibility | +| :---------------------- | :----------------- | :--------------------------------------------------------------------------------------------- | +| `@openuidev/a2ui` | Framework-agnostic | Protocol validation, surfaces, statement patches, data model, actions, functions, and metadata | +| `@openuidev/a2ui/react` | React (optional) | Subscribes to a surface and renders its OpenUI Lang source with `@openuidev/react-lang` | + +The core client depends on `@openuidev/lang-core`; it does not depend on React. A Vue, Svelte, native, or server-side host can use the same client and provide its own rendering adapter. + +## Installation + +Install the framework-neutral client and its Zod peer: + +```bash +npm install @openuidev/a2ui zod +``` + +For React rendering, also install the optional renderer peers: + +```bash +npm install react @openuidev/react-lang +``` + +Install the package that provides your component library as well. For example, the built-in OpenUI library is exported by `@openuidev/react-ui`: + +```bash +npm install @openuidev/react-ui +``` + +## Protocol flow + +A typical surface moves through the following lifecycle: + +1. The agent sends `createSurface` to allocate a surface and optionally seed its components and data model. +2. The agent sends one or more `updateComponents` messages. Each message patches OpenUI Lang statements by statement ID. +3. The agent can send `updateDataModel` at any time. Renderer state changes can flow back through action context or renderer metadata. +4. User interactions produce A2UI `action` messages from the renderer to the agent. +5. The agent can respond with more component or data-model updates, or with `actionResponse` when a response was requested. +6. The agent sends `deleteSurface` when the UI is no longer needed. + +The package owns protocol state, not transport. Your application is responsible for carrying messages over SSE, NDJSON, WebSocket, AG-UI, an agent SDK, or another channel, and for passing each decoded message to `client.process()`. + +### Supported messages + +| Direction | Message | Purpose | +| :--------------- | :----------------- | :------------------------------------------------------------- | +| Agent → renderer | `createSurface` | Creates a surface, optionally with components and a data model | +| Agent → renderer | `updateComponents` | Adds, replaces, or removes OpenUI Lang statements | +| Agent → renderer | `updateDataModel` | Applies a JSON Pointer update to surface data | +| Agent → renderer | `deleteSurface` | Removes a surface and rejects its pending actions | +| Agent → renderer | `callFunction` | Invokes a registered renderer function | +| Agent → renderer | `actionResponse` | Resolves or rejects an action that requested a reply | +| Renderer → agent | `action` | Reports a user or component interaction | +| Renderer → agent | `functionResponse` | Returns the result of a requested renderer function | +| Renderer → agent | `error` | Reports validation, lifecycle, or function errors | + +## Create a client + +Create one client for the set of surfaces handled by a renderer: + +```ts +import { createA2UIClient } from "@openuidev/a2ui"; +import { openuiLibrary } from "@openuidev/react-ui"; + +const client = createA2UIClient({ + schema: openuiLibrary.toJSONSchema(), + rootName: openuiLibrary.root, + rendererCapabilities: { + "v1.0": { + supportedCatalogIds: ["com.example:openui"], + }, + }, + onMessage(message, metadata) { + transport.send({ message, metadata }); + }, +}); +``` + +### `A2UIClientOptions` + +| Option | Type | Description | +| :--------------------- | :----------------------------------------- | :------------------------------------------------------------------------------------- | +| `schema` | `LibraryJSONSchema` | Required OpenUI component schema, normally from `library.toJSONSchema()` | +| `rootName` | `string` | Expected root component name from the library | +| `functions` | `Record` | Renderer functions that incoming `callFunction` messages may invoke | +| `rendererCapabilities` | `RendererCapabilities` | Catalog IDs and optional inline catalogs advertised in outgoing metadata | +| `onMessage` | `(message, metadata) => void` | Receives renderer-to-agent messages emitted by actions, functions, and protocol errors | +| `now` | `() => Date` | Optional clock override, useful for deterministic action timestamps in tests | +| `createId` | `() => string` | Optional ID generator for actions that request a response | + +The schema and the rendered library must describe the same components. `catalogId` is a protocol identifier and capability check; the client does not download a catalog or choose a component library from that ID. + +## Process agent messages + +Pass decoded agent-to-renderer messages to `process()` in arrival order: + +```ts +await client.process({ + version: "v1.0", + createSurface: { + surfaceId: "main", + catalogId: "com.example:openui", + sendDataModel: true, + dataModel: { + user: { name: "Alice" }, + }, + }, +}); + +await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: ["root = Stack([greeting])", 'greeting = TextContent("Hello, " + $user.name)'], + }, +}); +``` + +`process(input)` validates the envelope at runtime and returns: + +```ts +interface ProcessResult { + ok: boolean; + outbound: RendererToAgentMessage[]; + issues?: Array<{ path: string; message: string }>; +} +``` + +`outbound` contains the messages emitted while processing that input. The same messages are also delivered to `onMessage` and `subscribeMessages()` listeners. A host can use either mechanism, but should avoid sending the same outbound message twice. + +## Component updates + +Each `components` entry is an OpenUI Lang statement or multiline statement block. Updates are merged by statement ID: + +```json +{ + "version": "v1.0", + "updateComponents": { + "surfaceId": "main", + "components": ["status = Badge(\"Saving\")"] + } +} +``` + +- A new ID adds a statement. +- An existing ID replaces its previous statement without changing its position. +- `statementId = null` removes that statement. +- `root` remains the surface root. +- Statements may arrive before they are reachable from `root`; the client preserves them so a later patch can attach them. +- Entries are applied in array order, so a later entry for the same ID wins. + +For example: + +```ts +await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: [ + 'status = Badge("Saved")', // replace status + "oldHelpText = null", // remove oldHelpText + ], + }, +}); +``` + +### Streaming component generation + +An `updateComponents` message is a statement patch, not a raw text-delta event. When an LLM is generating OpenUI Lang, keep the current accumulated statement or statement block and send replacements under the same statement ID: + +```ts +for await (const sourceSoFar of generateOpenUILang()) { + await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: [sourceSoFar], + }, + }); +} +``` + +For true progressive rendering: + +- Send `createSurface` as soon as the surface is known. +- Forward patches as model output arrives instead of buffering the complete generation. +- Send the accumulated source for an ID, not only the latest token delta. Statement replacement then produces a coherent surface snapshot. +- Stream raw OpenUI Lang without Markdown code fences. A fenced block cannot be interpreted until its closing fence arrives. +- Set the React adapter's `isStreaming` prop while the turn is active. The underlying OpenUI renderer can then render the usable prefix and tolerate incomplete trailing syntax. +- Mark streaming complete only after the final patch has been processed. + +Transport boundaries and Lang statement boundaries do not need to match, but every `client.process()` call must receive a valid A2UI JSON envelope. + +## Surface state + +Read or subscribe to the framework-neutral surface store: + +```ts +const unsubscribe = client.subscribe(() => { + const surface = client.getSurface("main"); + console.log(surface?.source, surface?.dataModel, surface?.revision); +}); + +const allSurfaces = client.getSurfaces(); + +unsubscribe(); +``` + +Each snapshot has the following shape: + +```ts +interface SurfaceSnapshot { + surfaceId: string; + catalogId?: string; + surfaceProperties?: JsonObject; + sendDataModel: boolean; + source: string; + dataModel: JsonObject; + parseResult: ParseResult | null; + errors: OpenUIError[]; + revision: number; +} +``` + +`source` is the merged OpenUI Lang document. `parseResult` and `errors` come from `@openuidev/lang-core`. `revision` increases whenever any surface changes and is suitable for external-store subscriptions. + +## Data-model updates + +`updateDataModel` applies a JSON Pointer patch to a surface: + +```ts +await client.process({ + version: "v1.0", + updateDataModel: { + surfaceId: "main", + path: "/user/name", + value: "Bob", + }, +}); +``` + +The data model is always an object. The update behavior is: + +| Input | Result | +| :----------------------------- | :----------------------------------------------------------------- | +| Missing `path`, `""`, or `"/"` | Replaces the root object | +| Nested object path | Creates missing object or array containers as needed | +| `null` at a property path | Deletes that property | +| `null` at an array index | Removes that item with `splice` semantics | +| `null` at the root | Resets the model to `{}` | +| Primitive or array at the root | Produces a validation error because the root must remain an object | + +OpenUI Lang bindings use the same top-level keys. A data-model value such as `{ user: { name: "Bob" } }` is exposed to Lang as `$user`, so `$user.name` resolves to `"Bob"`. + +The React adapter also synchronizes form state back into the surface data model. Use the framework-neutral method directly when building another adapter: + +```ts +client.updateSurfaceFromOpenUIState("main", openUIState); +``` + +## Render a surface in React + +Import the renderer from the optional React entrypoint: + +```tsx +import { A2UIRenderer } from "@openuidev/a2ui/react"; +import { openuiLibrary } from "@openuidev/react-ui"; + +export function Surface({ isStreaming }: { isStreaming: boolean }) { + return ( + console.error(errors)} + /> + ); +} +``` + +The component subscribes to the client with `useSyncExternalStore`, reads the requested surface, and passes its merged source and data model to the `@openuidev/react-lang` renderer. It returns `null` until that surface exists. + +### `A2UIRendererProps` + +| Prop | Type | Description | +| :---------------------- | :------------------------------ | :----------------------------------------------------------------------------------- | +| `client` | `A2UIClient` | Required protocol client | +| `surfaceId` | `string` | Surface to subscribe to and render | +| `library` | `Library` | React component library matching the schema given to the client | +| `isStreaming` | `boolean` | Whether the surrounding transport is still delivering the current agent turn | +| `mapAction` | `(event, surface) => options` | Maps an OpenUI action to A2UI name, source component, context, and response behavior | +| `onAction` | `(event, surface) => void` | Observes actions after built-in handling | +| `onActionResponse` | `(value, event) => void` | Receives a successful requested action response | +| `onActionResponseError` | `(error, event) => void` | Receives a failed requested action response | +| `onOpenUrl` | `(url, event, surface) => void` | Handles `@OpenUrl`; defaults to a safe `window.open` in browsers | +| `onStateUpdate` | `(state, surface) => void` | Observes OpenUI state after it has been merged into the A2UI data model | +| `onParseResult` | `(result) => void` | Receives OpenUI Lang parse results | +| `onError` | `(errors) => void` | Receives OpenUI parser/runtime errors | +| `formStateKeys` | `readonly string[]` | Adds top-level data-model keys that should also hydrate form namespaces | +| `toolProvider` | `RendererProps["toolProvider"]` | Handles OpenUI Lang `Query()` and `Mutation()` calls | +| `queryLoader` | `ReactNode` | UI shown while a query is loading | + +`@OpenUrl` is handled locally and is not emitted as an A2UI action. Other OpenUI actions are converted to A2UI actions automatically. + +`toolProvider` and the client's `functions` option serve different layers: `toolProvider` executes `Query()` and `Mutation()` references inside OpenUI Lang, while `functions` handles A2UI `callFunction` messages sent by the agent. + +## Actions + +You can dispatch an action directly from any renderer adapter: + +```ts +client.dispatchAction({ + surfaceId: "main", + sourceComponentId: "saveButton", + name: "save", + context: { section: "profile" }, +}); +``` + +This emits an A2UI renderer-to-agent message through `onMessage`: + +```json +{ + "version": "v1.0", + "action": { + "name": "save", + "surfaceId": "main", + "sourceComponentId": "saveButton", + "timestamp": "2026-01-01T12:00:00.000Z", + "context": { "section": "profile" } + } +} +``` + +The React adapter calls `dispatchOpenUIAction()` for you. By default it uses the OpenUI action type as `name`, includes action params and form state in `context`, and uses the event's component ID or `"root"` as the source. Customize that mapping when your agent expects domain-specific action names: + +```tsx + ({ + name: event.type === "continue_conversation" ? "submit" : event.type, + sourceComponentId: "profileForm", + context: { origin: "settings" }, + })} +/> +``` + +### Request an action response + +Set `wantResponse` when the renderer needs a correlated reply: + +```ts +const response = await client.dispatchAction({ + surfaceId: "main", + sourceComponentId: "saveButton", + name: "save", + wantResponse: true, + responsePath: "/lastSave", +}); +``` + +The outgoing action receives an `actionId`. Later, pass the agent's response back to the client: + +```ts +await client.process({ + version: "v1.0", + actionId: "the-action-id", + actionResponse: { + value: { saved: true }, + }, +}); +``` + +The promise resolves with `value`. If `responsePath` was supplied, that value is also written into the surface data model. An error response rejects with `A2UIActionError`, whose `code` matches the protocol error code. + +## Renderer functions + +Register functions that the agent may ask the renderer to execute: + +```ts +const client = createA2UIClient({ + schema: openuiLibrary.toJSONSchema(), + functions: { + getLocation: { + callableFrom: "agentOnly", + async handler() { + return { latitude: 37.7749, longitude: -122.4194 }; + }, + }, + }, + onMessage(message, metadata) { + transport.send({ message, metadata }); + }, +}); +``` + +Incoming call: + +```json +{ + "version": "v1.0", + "functionCallId": "call-1", + "wantResponse": true, + "callFunction": { + "call": "getLocation", + "args": {} + } +} +``` + +When `wantResponse` is true, the client emits: + +```json +{ + "version": "v1.0", + "functionResponse": { + "functionCallId": "call-1", + "call": "getLocation", + "value": { "latitude": 37.7749, "longitude": -122.4194 } + } +} +``` + +Registrations support `"rendererOnly"`, `"agentOnly"`, and `"rendererOrAgent"`. Object registrations default to `"rendererOnly"`, so set the boundary explicitly for agent-callable functions. A direct function value is supported as shorthand and is callable by incoming agent messages; prefer object registrations when the trust boundary matters. + +Unknown functions, disallowed calls, and handler failures produce protocol error messages correlated by `functionCallId`. + +## Capabilities and renderer metadata + +Configure capabilities when creating the client: + +```ts +const client = createA2UIClient({ + schema, + rendererCapabilities: { + "v1.0": { + supportedCatalogIds: ["com.example:openui"], + inlineCatalogs: [], + }, + }, +}); +``` + +If the renderer advertises at least one catalog ID, `createSurface` rejects an unsupported `catalogId`. The package does not automatically negotiate or install component libraries; your agent and renderer must be configured with the same OpenUI Lang component schema. + +When a surface sets `sendDataModel: true`, `getRendererMetadata()` includes its current data model: + +```ts +const metadata = client.getRendererMetadata(); +// { +// a2uiRendererCapabilities: { ... }, +// a2uiRendererDataModel: { +// version: "v1.0", +// surfaces: { main: { ... } } +// } +// } +``` + +The metadata is supplied as the second argument to `onMessage` and message subscribers. Your transport decides whether it is attached to a model request, agent context, or another protocol-specific location. + +## Runtime validation and TypeScript types + +Zod is the source of truth for runtime validation and inferred TypeScript types. + +```ts +import { + agentToRendererMessageSchema, + rendererToAgentMessageSchema, + validateAgentToRendererMessage, + type AgentToRendererMessage, + type RendererToAgentMessage, +} from "@openuidev/a2ui"; + +const result = agentToRendererMessageSchema.safeParse(input); +const validated = validateAgentToRendererMessage(input); +``` + +The package exports runtime Zod schemas for every message and shared protocol structure, including capabilities and renderer data-model metadata. The public TypeScript message types are inferred from those definitions, keeping runtime behavior and static types aligned without maintaining a second schema format. + +## Client API + +| Method | Description | +| :------------------------------------------------- | :---------------------------------------------------------------------- | +| `process(input)` | Validates and applies one agent-to-renderer message | +| `getSurface(surfaceId)` | Returns one surface snapshot, or `undefined` | +| `getSurfaces()` | Returns all current surface snapshots | +| `subscribe(listener)` | Subscribes to surface-store changes and returns an unsubscribe function | +| `subscribeMessages(listener)` | Subscribes to emitted renderer-to-agent messages and metadata | +| `dispatchAction(input)` | Emits an A2UI action and optionally returns a response promise | +| `dispatchOpenUIAction(surfaceId, event, options?)` | Converts an OpenUI action event into an A2UI action | +| `updateSurfaceFromOpenUIState(surfaceId, state)` | Merges OpenUI bindings and form state into a surface data model | +| `getRendererDataModel()` | Returns opted-in surface models in the A2UI renderer-data-model shape | +| `getRendererMetadata()` | Returns configured capabilities and opted-in data-model metadata | +| `dispose()` | Rejects pending actions, clears surfaces, and removes listeners | + +## Errors and cleanup + +Malformed envelopes are rejected before they mutate state. When the invalid input identifies a surface or function call, the client also emits a correlated A2UI error message. OpenUI Lang validation errors are stored on the surface and emitted as `VALIDATION_FAILED` messages. + +Call `dispose()` when the client is no longer needed: + +```ts +client.dispose(); +``` + +Deleting a surface or disposing the client rejects any pending action-response promises for that surface with `A2UIActionError`. + +## Compatibility notes + +- This package is currently experimental and implements the v1.0 message profile described above. +- It is not wire-compatible with a renderer that expects A2UI JSON component objects in `components`; both sides must agree that the array contains OpenUI Lang. +- The protocol client is framework-neutral. Only `@openuidev/a2ui/react` requires React and `@openuidev/react-lang`. +- The protocol does not define your transport. Preserve message order per surface and pass complete JSON envelopes to `process()`. +- The agent and renderer must use the same OpenUI component library/schema. When benchmarking standard A2UI against A2UI + OpenUI Lang, keep the component vocabulary, props, task, model, and generation settings equivalent so the serialization is the meaningful variable. diff --git a/docs/content/docs/api-reference/index.mdx b/docs/content/docs/api-reference/index.mdx index 68e40ef49..0253d344a 100644 --- a/docs/content/docs/api-reference/index.mdx +++ b/docs/content/docs/api-reference/index.mdx @@ -5,31 +5,34 @@ description: Package guide for the full OpenUI SDK surface, including framework- The OpenUI SDK is split into packages that build on each other: -- **`@openuidev/lang-core`** — Framework-agnostic parser, prompt-generation, runtime-evaluation, and type layer. Use this when you need OpenUI Lang outside React, or when a backend/Edge function needs to generate prompts and parse streamed output without pulling in UI bindings. +- **`@openuidev/lang-core`**: Framework-agnostic parser, prompt-generation, runtime-evaluation, and type layer. Use this when you need OpenUI Lang outside React, or when a backend/Edge function needs to generate prompts and parse streamed output without pulling in UI bindings. -- **`@openuidev/react-lang`** — React runtime. Define component libraries with Zod schemas, generate system prompts, parse OpenUI Lang, and render streamed output to React. This is the foundation for React integrations. +- **`@openuidev/cli`**: Command-line tool for scaffolding new OpenUI chat apps and generating system prompts or JSON schemas from library definitions. -- **`@openuidev/react-headless`** — Headless chat state management. Provides `ChatProvider`, thread/message hooks, streaming protocol adapters (OpenAI, AG-UI), and message format converters. Use this when you want full control over your chat UI. +- **`@openuidev/react-lang`**: React runtime. Define component libraries with Zod schemas, generate system prompts, parse OpenUI Lang, and render streamed output to React. This is the foundation for React integrations. -- **`@openuidev/react-ui`** — `AgentInterface`, a ready-to-use artifact chat surface with thread history, plus two built-in component libraries (general-purpose and chat-optimized). Depends on both packages above. Use this for the fastest path to a working chat interface. +- **`@openuidev/react-headless`**: Headless chat state management. Provides `ChatProvider`, thread/message hooks, streaming protocol adapters (OpenAI, AG-UI), and message format converters. Use this when you want full control over your chat UI. -- **`@openuidev/react-email`** — Pre-built email component library and prompt options for model-generated emails that can be rendered to HTML with React Email. +- **`@openuidev/react-ui`**: `AgentInterface`, a ready-to-use artifact chat surface with thread history, plus two built-in component libraries (general-purpose and chat-optimized). Depends on both packages above. Use this for the fastest path to a working chat interface. -- **`@openuidev/vue-lang`** — Vue 3 bindings for defining model-renderable components, generating prompts from those definitions, and rendering streamed OpenUI Lang in Vue apps. +- **`@openuidev/react-email`**: Pre-built email component library and prompt options for model-generated emails that can be rendered to HTML with React Email. -- **`@openuidev/svelte-lang`** — Svelte 5 bindings for defining model-renderable components, generating prompts, and rendering streamed OpenUI Lang in Svelte apps. +- **`@openuidev/vue-lang`**: Vue 3 bindings for defining model-renderable components, generating prompts from those definitions, and rendering streamed OpenUI Lang in Vue apps. -- **`@openuidev/browser-bundle`** — Prebuilt browser bundle for CDN, iframe, and no-build integrations. It packages the renderer, UI library, React, ReactDOM, and styles into script-tag-friendly assets. +- **`@openuidev/svelte-lang`**: Svelte 5 bindings for defining model-renderable components, generating prompts, and rendering streamed OpenUI Lang in Svelte apps. -- **`@openuidev/devtools`** — Development-only floating widget that surfaces the events captured by `@openuidev/observability`, with error messages and stack traces. +- **`@openuidev/browser-bundle`**: Prebuilt browser bundle for CDN, iframe, and no-build integrations. It packages the renderer, UI library, React, ReactDOM, and styles into script-tag-friendly assets. -- **`@openuidev/cli`** — Command-line tool for scaffolding new OpenUI chat apps and generating system prompts or JSON schemas from library definitions. +- **`@openuidev/devtools`**: Development-only floating widget that surfaces the events captured by `@openuidev/observability`, with error messages and stack traces. + +- **`@openuidev/a2ui`**: Framework-agnostic A2UI v1.0 protocol client that uses OpenUI Lang statements for component payloads. Add the optional React entrypoint to render surfaces with `@openuidev/react-lang`. ## Choosing a package | If you need... | Start here | | :----------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------- | | Prompt generation or parsing without a UI framework | [`@openuidev/lang-core`](https://github.com/thesysdev/openui/tree/main/packages/lang-core) | +| App scaffolding and prompt/schema generation from the command line | [`@openuidev/cli`](/docs/api-reference/cli) | | React rendering primitives for OpenUI Lang | [`@openuidev/react-lang`](/docs/api-reference/react-lang) | | Chat state and streaming without prebuilt UI | [`@openuidev/react-headless`](/docs/api-reference/react-headless) | | A ready-made React chat surface | [`@openuidev/react-ui`](/docs/api-reference/react-ui) | @@ -38,7 +41,7 @@ The OpenUI SDK is split into packages that build on each other: | Svelte integration | [`@openuidev/svelte-lang`](https://github.com/thesysdev/openui/tree/main/packages/svelte-lang) | | Script-tag, CDN, or iframe embeds | [`@openuidev/browser-bundle`](https://github.com/thesysdev/openui/tree/main/packages/browser-bundle) | | An in-app panel showing captured errors during development | [`@openuidev/devtools`](/docs/api-reference/devtools) | -| App scaffolding and prompt/schema generation from the command line | [`@openuidev/cli`](/docs/api-reference/cli) | +| A2UI lifecycle and messages with OpenUI Lang component payloads | [`@openuidev/a2ui`](/docs/api-reference/a2ui) | ## Packages @@ -49,6 +52,10 @@ The OpenUI SDK is split into packages that build on each other: > Framework-agnostic parser, prompt-generation, runtime-evaluation, and type APIs for OpenUI Lang. + + openui create (scaffold a Next.js app) and openui generate (system prompt + library spec from a + library definition). + defineComponent, createLibrary, Renderer, parser APIs, action types, context hooks, and form validation. @@ -85,8 +92,8 @@ The OpenUI SDK is split into packages that build on each other: Development-only floating widget surfacing captured events with error messages and stack traces. - - openui create (scaffold a Next.js app) and openui generate (system prompt + library spec from a - library definition). + + A2UI v1.0 surface lifecycle, messages, actions, data model, functions, and schemas with OpenUI + Lang component updates. diff --git a/docs/content/docs/api-reference/meta.json b/docs/content/docs/api-reference/meta.json index c23a08535..adb32a09f 100644 --- a/docs/content/docs/api-reference/meta.json +++ b/docs/content/docs/api-reference/meta.json @@ -1,5 +1,14 @@ { "title": "API Reference", "root": true, - "pages": ["index", "react-lang", "react-headless", "react-ui", "react-email", "devtools", "cli"] + "pages": [ + "index", + "cli", + "react-lang", + "react-headless", + "react-ui", + "react-email", + "devtools", + "a2ui" + ] } diff --git a/packages/a2ui/README.md b/packages/a2ui/README.md new file mode 100644 index 000000000..b8fd21976 --- /dev/null +++ b/packages/a2ui/README.md @@ -0,0 +1,95 @@ +# `@openuidev/a2ui` + +An experimental A2UI v1.0 profile that keeps the protocol lifecycle and envelopes intact while representing component lists as OpenUI Lang statements. + +```json +{ + "version": "v1.0", + "updateComponents": { + "surfaceId": "main", + "components": ["root = Stack([title])", "title = TextContent(\"Hello\")"] + } +} +``` + +`updateDataModel`, `deleteSurface`, actions, `callFunction`, `functionResponse`, `actionResponse`, errors, capabilities, and renderer data-model metadata retain their A2UI v1.0 shapes. For v1.0 single-message creation, optional `createSurface.components` uses the same Lang statement array as `updateComponents.components`, so there is exactly one component representation on the wire. + +Incoming protocol messages are validated with the exported Zod schemas in the package. The public TypeScript message types are inferred from the same definitions, so runtime validation and static types stay aligned. + +## Installation + +Install the framework-neutral protocol client with its runtime schema peer: + +```bash +npm install @openuidev/a2ui zod +``` + +The React adapter is optional. Install its peers only when importing `@openuidev/a2ui/react`: + +```bash +npm install react @openuidev/react-lang +``` + +## How updates work + +Each array item is one complete OpenUI Lang statement (a multiline statement block is also accepted). Repeated messages are merged by statement ID and the resulting source is parsed by `@openuidev/lang-core`. A newer assignment replaces the previous statement, and `statementId = null` deletes it. The `root` statement remains the surface root. + +## Usage + +```tsx +import { createA2UIClient } from "@openuidev/a2ui"; +import { A2UIRenderer } from "@openuidev/a2ui/react"; +import { openuiLibrary } from "@openuidev/react-ui"; + +const client = createA2UIClient({ + schema: openuiLibrary.toJSONSchema(), + rootName: openuiLibrary.root, + rendererCapabilities: { + "v1.0": { supportedCatalogIds: ["com.example:openui"] }, + }, + onMessage(message, metadata) { + transport.send(message, metadata); + }, +}); + +await client.process({ + version: "v1.0", + createSurface: { + surfaceId: "main", + catalogId: "com.example:openui", + dataModel: { user: { name: "Alice" } }, + }, +}); + +await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: ["root = Stack([greeting])", 'greeting = TextContent("Hello, " + $user.name)'], + }, +}); + +export function Surface() { + return ( + ({ name: event.type })} + /> + ); +} +``` + +The client is framework-neutral: it owns surface lifecycle, runtime envelope validation, Lang parsing and merging, data-model patches, actions, RPC, and protocol errors. `A2UIRenderer` is the React adapter; it subscribes to one surface and delegates the parsed OpenUI Lang UI to `@openuidev/react-lang`. + +When `sendDataModel` is enabled, the second `onMessage` argument contains `a2uiRendererDataModel` metadata filtered to opted-in surfaces. Configured renderer capabilities are exposed in the same metadata object. The host transport decides where to attach this metadata. + +Renderer functions can be registered as a direct function shorthand or as `{ handler, callableFrom }`. Explicit registrations default to `rendererOnly`; incoming `callFunction` messages are accepted only for `agentOnly` and `rendererOrAgent` registrations. + +Use `mapAction` to provide `sourceComponentId` when the host renderer does not include one in its action event. Otherwise actions fall back to the surface `root` ID. + +## Development + +The `check:attw` script intentionally ignores the `no-resolution` rule. `./react` is exported through modern package `exports`; `attw` reports `no-resolution` only for legacy Node 10 resolution, which this package does not support. diff --git a/packages/a2ui/eslint.config.cjs b/packages/a2ui/eslint.config.cjs new file mode 100644 index 000000000..3276216a5 --- /dev/null +++ b/packages/a2ui/eslint.config.cjs @@ -0,0 +1,3 @@ +const base = require("../../eslint.config.cjs"); + +module.exports = [...base]; diff --git a/packages/a2ui/package.json b/packages/a2ui/package.json new file mode 100644 index 000000000..583954858 --- /dev/null +++ b/packages/a2ui/package.json @@ -0,0 +1,95 @@ +{ + "name": "@openuidev/a2ui", + "version": "0.1.0", + "description": "A2UI v1.0 protocol support with OpenUI Lang component updates and React rendering", + "license": "MIT", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.mjs", + "types": "dist/index.d.cts", + "sideEffects": false, + "files": [ + "dist", + "README.md" + ], + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./react": { + "import": { + "types": "./dist/react.d.mts", + "default": "./dist/react.mjs" + }, + "require": { + "types": "./dist/react.d.cts", + "default": "./dist/react.cjs" + } + } + }, + "scripts": { + "test": "vitest run", + "build": "tsdown", + "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", + "lint:check": "eslint ./src", + "lint:fix": "eslint ./src --fix", + "format:fix": "prettier --write ./src README.md", + "format:check": "prettier --check ./src README.md", + "check:publint": "publint", + "check:attw": "attw --pack . --ignore-rules=no-resolution", + "prepare": "pnpm run build", + "prepublishOnly": "pnpm run check:publint && pnpm run check:attw", + "ci": "pnpm run lint:check && pnpm run format:check" + }, + "keywords": [ + "a2ui", + "openui", + "openui-lang", + "generative-ui", + "renderer", + "protocol" + ], + "homepage": "https://openui.com", + "repository": { + "type": "git", + "url": "https://github.com/thesysdev/openui.git", + "directory": "packages/a2ui" + }, + "bugs": { + "url": "https://github.com/thesysdev/openui/issues" + }, + "author": "engineering@thesys.dev", + "dependencies": { + "@openuidev/lang-core": "workspace:^" + }, + "peerDependencies": { + "@openuidev/react-lang": "workspace:^", + "react": "catalog:", + "zod": "catalog:" + }, + "peerDependenciesMeta": { + "@openuidev/react-lang": { + "optional": true + }, + "react": { + "optional": true + } + }, + "devDependencies": { + "@openuidev/react-lang": "workspace:^", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "jsdom": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "vitest": "^4.0.18" + } +} diff --git a/packages/a2ui/src/Renderer.tsx b/packages/a2ui/src/Renderer.tsx new file mode 100644 index 000000000..4388c23a9 --- /dev/null +++ b/packages/a2ui/src/Renderer.tsx @@ -0,0 +1,140 @@ +import { + BuiltinActionType, + type ActionEvent, + type OpenUIError, + type ParseResult, +} from "@openuidev/lang-core"; +import { + Renderer as OpenUIRenderer, + type Library, + type RendererProps as OpenUIRendererProps, +} from "@openuidev/react-lang"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; +import type { A2UIClient } from "./client"; +import { dataModelToOpenUIState } from "./json-pointer"; +import type { JsonValue, MapOpenUIAction, OpenUIActionOptions, SurfaceSnapshot } from "./types"; + +export interface A2UIRendererProps { + client: A2UIClient; + surfaceId: string; + library: Library; + mapAction?: MapOpenUIAction; + onAction?: (event: ActionEvent, surface: SurfaceSnapshot) => void; + onActionResponse?: (value: JsonValue, event: ActionEvent) => void; + onActionResponseError?: (error: Error, event: ActionEvent) => void; + /** Handles OpenUI @OpenUrl locally. Defaults to window.open in browsers. */ + onOpenUrl?: (url: string, event: ActionEvent, surface: SurfaceSnapshot) => void; + onStateUpdate?: (state: Record, surface: SurfaceSnapshot) => void; + onParseResult?: (result: ParseResult | null) => void; + onError?: (errors: OpenUIError[]) => void; + /** Whether the surrounding A2UI transport is still delivering this agent turn. */ + isStreaming?: boolean; + /** Additional top-level data-model keys that should hydrate form namespaces. */ + formStateKeys?: readonly string[]; + toolProvider?: OpenUIRendererProps["toolProvider"]; + queryLoader?: OpenUIRendererProps["queryLoader"]; +} + +function collectFormStateKeys(result: ParseResult | null): string[] { + const keys = new Set(); + const seen = new WeakSet(); + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!value || typeof value !== "object" || seen.has(value)) return; + seen.add(value); + const object = value as Record; + if (object.type === "element" && object.typeName === "Form") { + const props = object.props as Record | undefined; + if (typeof props?.name === "string") keys.add(props.name); + } + for (const child of Object.values(object)) visit(child); + }; + visit(result?.root); + return [...keys]; +} + +export function A2UIRenderer({ + client, + surfaceId, + library, + mapAction, + onAction, + onActionResponse, + onActionResponseError, + onOpenUrl, + onStateUpdate, + onParseResult, + onError, + isStreaming = false, + formStateKeys, + toolProvider, + queryLoader, +}: A2UIRendererProps) { + const subscribe = useCallback((notify: () => void) => client.subscribe(notify), [client]); + const getSnapshot = useCallback(() => client.getSurface(surfaceId), [client, surfaceId]); + const surface = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const initialState = useMemo(() => { + if (!surface) return undefined; + const keys = new Set([...collectFormStateKeys(surface.parseResult), ...(formStateKeys ?? [])]); + return dataModelToOpenUIState(surface.dataModel, keys); + }, [formStateKeys, surface]); + + const handleAction = useCallback( + (event: ActionEvent) => { + const current = client.getSurface(surfaceId); + if (!current) return; + if (event.type === BuiltinActionType.OpenUrl) { + const url = event.params?.url; + if (typeof url === "string") { + if (onOpenUrl) onOpenUrl(url, event, current); + else if (typeof window !== "undefined") window.open(url, "_blank", "noopener,noreferrer"); + } + onAction?.(event, current); + return; + } + const options: OpenUIActionOptions = mapAction?.(event, current) ?? {}; + const response = client.dispatchOpenUIAction(surfaceId, event, options); + if (response) { + void response + .then((value) => onActionResponse?.(value, event)) + .catch((error: unknown) => + onActionResponseError?.( + error instanceof Error ? error : new Error(String(error)), + event, + ), + ); + } + onAction?.(event, current); + }, + [client, mapAction, onAction, onActionResponse, onActionResponseError, onOpenUrl, surfaceId], + ); + + const handleStateUpdate = useCallback( + (state: Record) => { + if (!client.updateSurfaceFromOpenUIState(surfaceId, state)) return; + const current = client.getSurface(surfaceId); + if (current) onStateUpdate?.(state, current); + }, + [client, onStateUpdate, surfaceId], + ); + + if (!surface) return null; + + return ( + + ); +} diff --git a/packages/a2ui/src/__tests__/Renderer.test.tsx b/packages/a2ui/src/__tests__/Renderer.test.tsx new file mode 100644 index 000000000..df6564ace --- /dev/null +++ b/packages/a2ui/src/__tests__/Renderer.test.tsx @@ -0,0 +1,199 @@ +import { + createLibrary, + defineComponent, + FormNameContext, + useIsStreaming, + useStateField, +} from "@openuidev/react-lang"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod/v4"; +import { createA2UIClient } from "../client"; +import { A2UIRenderer } from "../Renderer"; + +const TextContent = defineComponent({ + name: "TextContent", + description: "Text", + props: z.object({ text: z.string() }), + component: ({ props, statementId }) => {props.text}, +}); + +const Stack = defineComponent({ + name: "Stack", + description: "Stack", + props: z.object({ children: z.array(TextContent.ref) }), + component: ({ props, renderNode }) =>
{renderNode(props.children)}
, +}); + +const library = createLibrary({ components: [Stack, TextContent], root: "Stack" }); + +const Field = defineComponent({ + name: "Field", + description: "Interactive test field", + props: z.object({ name: z.string() }), + component: ({ props }) => { + const field = useStateField(props.name); + const isStreaming = useIsStreaming(); + return ( + + ); + }, +}); + +const Form = defineComponent({ + name: "Form", + description: "Test form", + props: z.object({ name: z.string(), fields: z.array(Field.ref) }), + component: ({ props, renderNode }) => ( + + {renderNode(props.fields)} + + ), +}); + +const formLibrary = createLibrary({ components: [Form, Field], root: "Form" }); + +describe("A2UIRenderer", () => { + it("renders the active A2UI surface through the OpenUI Lang React renderer", async () => { + const client = createA2UIClient({ + schema: library.toJSONSchema(), + rootName: library.root, + }); + await client.process({ + version: "v1.0", + createSurface: { surfaceId: "main", catalogId: "com.example:test" }, + }); + await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: ["root = Stack([greeting])", 'greeting = TextContent("Hello A2UI")'], + }, + }); + + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Hello A2UI"); + expect(html).toContain('data-statement="greeting"'); + }); + + it("renders nothing after the surface is deleted", async () => { + const client = createA2UIClient({ schema: library.toJSONSchema() }); + await client.process({ + version: "v1.0", + createSurface: { surfaceId: "main", catalogId: "com.example:test" }, + }); + await client.process({ + version: "v1.0", + deleteSurface: { surfaceId: "main" }, + }); + + expect( + renderToStaticMarkup(), + ).toBe(""); + }); + + it("keeps live form input and the A2UI data model converged", async () => { + const client = createA2UIClient({ + schema: formLibrary.toJSONSchema(), + rootName: formLibrary.root, + }); + await client.process({ + version: "v1.0", + createSurface: { + surfaceId: "main", + dataModel: { contact: { email: "seed@x.com" } }, + components: ['root = Form("contact", [email])', 'email = Field("email")'], + }, + }); + + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const onStateUpdate = vi.fn(); + const revisionBeforeInput = client.getSurface("main")!.revision; + + await act(async () => { + root.render( + , + ); + }); + const button = container.querySelector("button")!; + expect(button.textContent).toBe("seed@x.com"); + + await act(async () => { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(button.textContent).toBe("typed@x.com"); + expect(client.getSurface("main")?.dataModel).toEqual({ + contact: { email: "typed@x.com" }, + }); + expect(client.getSurface("main")?.revision).toBe(revisionBeforeInput + 1); + expect(onStateUpdate).toHaveBeenCalledTimes(1); + + await act(async () => { + await Promise.resolve(); + }); + expect(client.getSurface("main")?.dataModel).toEqual({ + contact: { email: "typed@x.com" }, + }); + + await act(async () => { + await client.process({ + version: "v1.0", + updateDataModel: { + surfaceId: "main", + path: "/contact/email", + value: "agent@x.com", + }, + }); + }); + expect(button.textContent).toBe("agent@x.com"); + expect(client.getSurface("main")?.dataModel).toEqual({ + contact: { email: "agent@x.com" }, + }); + + await act(async () => root.unmount()); + container.remove(); + }); + + it("forwards the A2UI transport streaming state", async () => { + const client = createA2UIClient({ + schema: formLibrary.toJSONSchema(), + rootName: formLibrary.root, + }); + await client.process({ + version: "v1.0", + createSurface: { + surfaceId: "main", + components: ['root = Form("contact", [email])', 'email = Field("email")'], + }, + }); + const container = document.createElement("div"); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + }); + expect(container.querySelector("button")?.disabled).toBe(true); + expect(container.querySelector("button")?.dataset.streaming).toBe("true"); + + await act(async () => root.unmount()); + }); +}); diff --git a/packages/a2ui/src/__tests__/client.test.ts b/packages/a2ui/src/__tests__/client.test.ts new file mode 100644 index 000000000..dab4da20f --- /dev/null +++ b/packages/a2ui/src/__tests__/client.test.ts @@ -0,0 +1,508 @@ +import type { LibraryJSONSchema } from "@openuidev/lang-core"; +import { describe, expect, it, vi } from "vitest"; +import { A2UIActionError, createA2UIClient } from "../client"; +import { + applyDataModelUpdate, + dataModelToOpenUIState, + mergeOpenUIStateIntoDataModel, +} from "../json-pointer"; +import type { RendererToAgentMessage } from "../types"; + +const schema: LibraryJSONSchema = { + $defs: { + Stack: { + properties: { children: {} }, + required: ["children"], + }, + TextContent: { + properties: { text: {}, variant: {} }, + required: ["text"], + }, + }, +}; + +function createSurface(client: ReturnType) { + return client.process({ + version: "v1.0", + createSurface: { + surfaceId: "main", + catalogId: "com.example:openui", + dataModel: { user: { name: "Alice" } }, + }, + }); +} + +describe("A2UIClient", () => { + it("runs the A2UI surface lifecycle with incremental OpenUI Lang updates", async () => { + const client = createA2UIClient({ schema }); + + expect((await createSurface(client)).ok).toBe(true); + expect( + ( + await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: ["root = Stack([title])", 'title = TextContent("Hello")'], + }, + }) + ).ok, + ).toBe(true); + + let surface = client.getSurface("main"); + expect(surface?.parseResult?.root?.typeName).toBe("Stack"); + expect(surface?.parseResult?.root?.props.children).toEqual([ + expect.objectContaining({ + typeName: "TextContent", + statementId: "title", + props: { text: "Hello", variant: undefined }, + }), + ]); + + await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: ['title = TextContent("Updated")'], + }, + }); + expect(client.getSurface("main")?.source).toContain('title = TextContent("Updated")'); + expect(client.getSurface("main")?.source).not.toContain('TextContent("Hello")'); + + await client.process({ + version: "v1.0", + updateDataModel: { surfaceId: "main", path: "/user/name", value: "Bob" }, + }); + surface = client.getSurface("main"); + expect(surface?.dataModel).toEqual({ user: { name: "Bob" } }); + + expect( + ( + await client.process({ + version: "v1.0", + deleteSurface: { surfaceId: "main" }, + }) + ).ok, + ).toBe(true); + expect(client.getSurface("main")).toBeUndefined(); + }); + + it("supports single-message creation with Lang components and no catalogId", async () => { + const client = createA2UIClient({ schema }); + const result = await client.process({ + version: "v1.0", + createSurface: { + surfaceId: "main", + components: ["root = Stack([title])", 'title = TextContent("Created inline")'], + }, + }); + + expect(result.ok).toBe(true); + expect(client.getSurface("main")?.catalogId).toBeUndefined(); + expect(client.getSurface("main")?.parseResult?.root?.props.children).toEqual([ + expect.objectContaining({ props: { text: "Created inline", variant: undefined } }), + ]); + }); + + it("preserves components that are referenced by a later incremental update", async () => { + const client = createA2UIClient({ schema }); + await createSurface(client); + await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: ["root = Stack([a])", 'a = TextContent("A")'], + }, + }); + await client.process({ + version: "v1.0", + updateComponents: { surfaceId: "main", components: ['b = TextContent("B")'] }, + }); + const result = await client.process({ + version: "v1.0", + updateComponents: { surfaceId: "main", components: ["root = Stack([a, b])"] }, + }); + + expect(result.ok).toBe(true); + expect(client.getSurface("main")?.source).toContain('b = TextContent("B")'); + expect(client.getSurface("main")?.parseResult?.root?.props.children).toEqual([ + expect.objectContaining({ statementId: "a" }), + expect.objectContaining({ statementId: "b" }), + ]); + }); + + it("merges multiline blocks and deletes statements by ID", async () => { + const client = createA2UIClient({ schema }); + await createSurface(client); + + await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: [ + `root = Stack([ + title + ]) + title = TextContent("Before")`, + ], + }, + }); + const result = await client.process({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: ["title = null", "root = Stack([])"], + }, + }); + + expect(result.ok).toBe(true); + expect(client.getSurface("main")?.source).toBe("root = Stack([])"); + }); + + it("validates malformed transport input without throwing or corrupting state", async () => { + const client = createA2UIClient({ schema }); + await createSurface(client); + + const missingComponents = await client.process({ + version: "v1.0", + updateComponents: { surfaceId: "main" }, + }); + expect(missingComponents.ok).toBe(false); + expect(missingComponents.issues?.[0]?.path).toBe("/updateComponents/components"); + expect(missingComponents.outbound[0]).toMatchObject({ + error: { + code: "VALIDATION_FAILED", + surfaceId: "main", + path: "/updateComponents/components", + }, + }); + + const missingSurfaceId = await client.process({ + version: "v1.0", + createSurface: { catalogId: "com.example:openui" }, + }); + expect(missingSurfaceId.ok).toBe(false); + expect(missingSurfaceId.issues?.length).toBeGreaterThan(0); + expect(client.getSurfaces()).toHaveLength(1); + + const wrongVersion = await client.process({ version: "v0.9", unknown: {} }); + expect(wrongVersion).toMatchObject({ ok: false, outbound: [], issues: expect.any(Array) }); + }); + + it("emits A2UI actions, resolves actionResponse, and stores responsePath", async () => { + const messages: RendererToAgentMessage[] = []; + const client = createA2UIClient({ + schema, + onMessage: (message) => messages.push(message), + now: () => new Date("2026-07-24T10:00:00.000Z"), + createId: () => "action-1", + }); + await createSurface(client); + + const response = client.dispatchAction({ + surfaceId: "main", + sourceComponentId: "submitButton", + name: "submit", + context: { intent: "save" }, + wantResponse: true, + responsePath: "/result", + }); + + expect(messages.at(-1)).toEqual({ + version: "v1.0", + action: { + name: "submit", + surfaceId: "main", + sourceComponentId: "submitButton", + timestamp: "2026-07-24T10:00:00.000Z", + context: { intent: "save" }, + wantResponse: true, + actionId: "action-1", + }, + }); + + await client.process({ + version: "v1.0", + actionId: "action-1", + actionResponse: { value: { saved: true } }, + }); + await expect(response).resolves.toEqual({ saved: true }); + expect(client.getSurface("main")?.dataModel.result).toEqual({ saved: true }); + }); + + it("rejects a pending action when actionResponse contains an error", async () => { + const client = createA2UIClient({ schema, createId: () => "action-2" }); + await createSurface(client); + const response = client.dispatchAction({ + surfaceId: "main", + sourceComponentId: "button", + name: "submit", + wantResponse: true, + }); + + await client.process({ + version: "v1.0", + actionId: "action-2", + actionResponse: { error: { code: "REJECTED", message: "Nope" } }, + }); + + await expect(response).rejects.toEqual( + expect.objectContaining({ code: "REJECTED", message: "Nope" }), + ); + }); + + it("executes callFunction and returns the official functionResponse shape", async () => { + const lookup = vi.fn(async ({ id }: { id?: unknown }) => ({ id: String(id), found: true })); + const client = createA2UIClient({ + schema, + functions: { lookup: lookup as never }, + }); + + const result = await client.process({ + version: "v1.0", + functionCallId: "function-1", + wantResponse: true, + callFunction: { call: "lookup", args: { id: 42 } }, + }); + + expect(lookup).toHaveBeenCalledWith({ id: 42 }); + expect(result).toEqual({ + ok: true, + outbound: [ + { + version: "v1.0", + functionResponse: { + functionCallId: "function-1", + call: "lookup", + value: { id: "42", found: true }, + }, + }, + ], + }); + }); + + it("enforces callableFrom for agent-initiated function calls", async () => { + const client = createA2UIClient({ + schema, + functions: { + localOnly: { + callableFrom: "rendererOnly", + handler: () => ({ ok: true }), + }, + }, + }); + + const result = await client.process({ + version: "v1.0", + functionCallId: "function-local", + callFunction: { call: "localOnly" }, + }); + + expect(result.ok).toBe(false); + expect(result.outbound[0]).toMatchObject({ + error: { + code: "INVALID_FUNCTION_CALL", + functionCallId: "function-local", + }, + }); + }); + + it("maps OpenUI action context, form state, and source statement to A2UI", async () => { + const messages: RendererToAgentMessage[] = []; + const client = createA2UIClient({ schema, onMessage: (message) => messages.push(message) }); + await createSurface(client); + + client.dispatchOpenUIAction( + "main", + { + type: "submit", + params: { mode: "fast" }, + humanFriendlyMessage: "Submit", + formState: { contact: { email: { value: "a@example.com", componentType: "Input" } } }, + formName: "contact", + }, + { sourceComponentId: "submitButton" }, + ); + + expect(messages.at(-1)).toMatchObject({ + action: { + name: "submit", + sourceComponentId: "submitButton", + context: { + mode: "fast", + formState: { contact: { email: "a@example.com" } }, + }, + }, + }); + }); + + it("keeps renderer data-model metadata in sync with local OpenUI state", async () => { + const client = createA2UIClient({ schema }); + await client.process({ + version: "v1.0", + createSurface: { + surfaceId: "main", + catalogId: "com.example:openui", + sendDataModel: true, + dataModel: { user: { name: "Alice" } }, + }, + }); + + expect( + client.updateSurfaceFromOpenUIState("main", { + $user: { name: "Carol" }, + contact: { email: { value: "c@example.com", componentType: "Input" } }, + }), + ).toBe(true); + expect(client.getRendererDataModel()).toEqual({ + version: "v1.0", + surfaces: { + main: { + user: { name: "Carol" }, + contact: { email: "c@example.com" }, + }, + }, + }); + }); + + it("filters renderer metadata to opted-in surfaces and includes capabilities", async () => { + const metadata: unknown[] = []; + const client = createA2UIClient({ + schema, + rendererCapabilities: { + "v1.0": { supportedCatalogIds: ["com.example:openui"] }, + }, + onMessage: (_message, nextMetadata) => metadata.push(nextMetadata), + }); + await client.process({ + version: "v1.0", + createSurface: { + surfaceId: "shared", + catalogId: "com.example:openui", + sendDataModel: true, + dataModel: { shared: true }, + }, + }); + await client.process({ + version: "v1.0", + createSurface: { + surfaceId: "private", + catalogId: "com.example:openui", + dataModel: { private: true }, + }, + }); + + client.dispatchAction({ + surfaceId: "shared", + sourceComponentId: "button", + name: "submit", + }); + + expect(metadata.at(-1)).toEqual({ + a2uiRendererCapabilities: { + "v1.0": { supportedCatalogIds: ["com.example:openui"] }, + }, + a2uiRendererDataModel: { + version: "v1.0", + surfaces: { shared: { shared: true } }, + }, + }); + }); + + it("rejects a catalog outside configured renderer capabilities", async () => { + const client = createA2UIClient({ + schema, + rendererCapabilities: { + "v1.0": { supportedCatalogIds: ["com.example:supported"] }, + }, + }); + + const result = await client.process({ + version: "v1.0", + createSurface: { surfaceId: "main", catalogId: "com.example:unsupported" }, + }); + + expect(result.ok).toBe(false); + expect(result.outbound[0]).toMatchObject({ + error: { code: "UNSUPPORTED_CATALOG", surfaceId: "main" }, + }); + }); +}); + +describe("A2UI data model bridge", () => { + it("supports RFC 6901 escaping, arrays, deletion, and root replacement", () => { + let model = applyDataModelUpdate({}, "/a~1b/~0key", ["first", "second"]); + expect(model).toEqual({ "a/b": { "~key": ["first", "second"] } }); + + model = applyDataModelUpdate(model, "/a~1b/~0key/0", null); + expect(model).toEqual({ "a/b": { "~key": ["second"] } }); + + model = applyDataModelUpdate(model, "/", { replaced: true }); + expect(model).toEqual({ replaced: true }); + }); + + it("converts A2UI data-model keys to Lang bindings and unwraps form state", () => { + expect(dataModelToOpenUIState({ user: { name: "Alice" } })).toEqual({ + $user: { name: "Alice" }, + }); + expect(dataModelToOpenUIState({ user: { name: "Alice" } }, ["user"])).toEqual({ + $user: { name: "Alice" }, + user: { name: "Alice" }, + }); + expect( + mergeOpenUIStateIntoDataModel( + { untouched: true }, + { form: { field: { value: "hello", componentType: "Input" } } }, + ), + ).toEqual({ untouched: true, form: { field: "hello" } }); + }); + + it("selects whichever binding or form copy changed from the current model", () => { + expect( + mergeOpenUIStateIntoDataModel( + { contact: { email: "old@example.com" } }, + { + $contact: { email: "old@example.com" }, + contact: { email: { value: "form@example.com", componentType: "Input" } }, + }, + ), + ).toEqual({ contact: { email: "form@example.com" } }); + + expect( + mergeOpenUIStateIntoDataModel({ filter: "all" }, { $filter: "active", filter: "all" }), + ).toEqual({ filter: "all" }); + + expect(mergeOpenUIStateIntoDataModel({ filter: "all" }, { $filter: "active" })).toEqual({ + filter: "active", + }); + }); + + it("does not let a stale binding mirror revert a user-edited form", async () => { + const client = createA2UIClient({ schema }); + await client.process({ + version: "v1.0", + createSurface: { + surfaceId: "main", + dataModel: { contact: { email: "seed@x.com" } }, + }, + }); + const state = { + $contact: { email: "seed@x.com" }, + contact: { + email: { value: "typed@x.com", componentType: "Input" }, + }, + }; + + expect(client.updateSurfaceFromOpenUIState("main", state)).toBe(true); + const revision = client.getSurface("main")?.revision; + expect(client.getSurface("main")?.dataModel).toEqual({ + contact: { email: "typed@x.com" }, + }); + + expect(client.updateSurfaceFromOpenUIState("main", state)).toBe(false); + expect(client.getSurface("main")?.revision).toBe(revision); + expect(client.getSurface("main")?.dataModel).toEqual({ + contact: { email: "typed@x.com" }, + }); + }); +}); diff --git a/packages/a2ui/src/__tests__/protocol-schema.test.ts b/packages/a2ui/src/__tests__/protocol-schema.test.ts new file mode 100644 index 000000000..55476bf42 --- /dev/null +++ b/packages/a2ui/src/__tests__/protocol-schema.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { + actionMessageSchema, + agentCapabilitiesSchema, + agentToRendererMessageSchema, + genericErrorMessageSchema, + rendererCapabilitiesSchema, + rendererDataModelSchema, + rendererToAgentMessageSchema, + updateComponentsMessageSchema, + validationFailedErrorMessageSchema, +} from "../protocol-schema"; + +describe("canonical A2UI protocol schemas", () => { + it("validates OpenUI Lang component updates", () => { + expect( + updateComponentsMessageSchema.safeParse({ + version: "v1.0", + updateComponents: { + surfaceId: "main", + components: ["root = Stack([title])", 'title = TextContent("Hello")'], + }, + }).success, + ).toBe(true); + + expect( + updateComponentsMessageSchema.safeParse({ + version: "v1.0", + updateComponents: { surfaceId: "main", components: [] }, + }).success, + ).toBe(false); + expect( + updateComponentsMessageSchema.safeParse({ + version: "v1.0", + updateComponents: { surfaceId: "main", components: [""] }, + }).success, + ).toBe(false); + }); + + it("validates both protocol directions from the top-level schemas", () => { + expect( + agentToRendererMessageSchema.safeParse({ + version: "v1.0", + callFunction: { call: "lookup", args: { id: 42 } }, + functionCallId: "call-1", + }).success, + ).toBe(true); + + const action = { + version: "v1.0", + action: { + name: "submit", + surfaceId: "main", + sourceComponentId: "submit-button", + timestamp: "2026-07-31T10:00:00.000Z", + context: { intent: "save" }, + }, + }; + expect(actionMessageSchema.safeParse(action).success).toBe(true); + expect(rendererToAgentMessageSchema.safeParse(action).success).toBe(true); + expect( + actionMessageSchema.safeParse({ + ...action, + action: { ...action.action, timestamp: "2026-07-31T15:30:00+05:30" }, + }).success, + ).toBe(true); + expect( + actionMessageSchema.safeParse({ + ...action, + action: { ...action.action, timestamp: "not-a-timestamp" }, + }).success, + ).toBe(false); + }); + + it("keeps validation failures distinct from generic protocol errors", () => { + const validationFailure = { + version: "v1.0", + error: { + code: "VALIDATION_FAILED", + surfaceId: "main", + path: "/updateComponents/components", + message: "Required", + }, + }; + expect(validationFailedErrorMessageSchema.safeParse(validationFailure).success).toBe(true); + expect(genericErrorMessageSchema.safeParse(validationFailure).success).toBe(false); + expect( + genericErrorMessageSchema.safeParse({ + version: "v1.0", + error: { code: "FUNCTION_NOT_FOUND", functionCallId: "call-1", message: "Unknown" }, + }).success, + ).toBe(true); + }); + + it("validates capabilities and renderer data-model metadata", () => { + expect( + rendererCapabilitiesSchema.safeParse({ + "v1.0": { supportedCatalogIds: ["com.example:openui"] }, + }).success, + ).toBe(true); + expect( + agentCapabilitiesSchema.safeParse({ "v1.0": { acceptsInlineCatalogs: true } }).success, + ).toBe(true); + expect( + rendererDataModelSchema.safeParse({ + version: "v1.0", + surfaces: { main: { user: { name: "Alice" } } }, + }).success, + ).toBe(true); + }); +}); diff --git a/packages/a2ui/src/__tests__/statement-patch.test.ts b/packages/a2ui/src/__tests__/statement-patch.test.ts new file mode 100644 index 000000000..67cafbb3f --- /dev/null +++ b/packages/a2ui/src/__tests__/statement-patch.test.ts @@ -0,0 +1,92 @@ +import { createParser, type LibraryJSONSchema } from "@openuidev/lang-core"; +import { describe, expect, it } from "vitest"; +import { mergeComponentStatements } from "../statement-patch"; + +const schema: LibraryJSONSchema = { + $defs: { + Stack: { + properties: { children: {} }, + required: ["children"], + }, + TextContent: { + properties: { text: {} }, + required: ["text"], + }, + }, +}; + +const parser = createParser(schema); + +describe("mergeComponentStatements", () => { + it("compacts repeated statement IDs using last-write-wins semantics", () => { + const source = mergeComponentStatements("", [ + "root = Stack([title])", + 'title = TextContent("Before")', + 'title = TextContent("After")', + ]); + + expect(source).toBe('root = Stack([title])\ntitle = TextContent("After")'); + expect(parser.parse(source).meta.errors).toEqual([]); + }); + + it("preserves orphaned statements until a later patch references them", () => { + const initial = mergeComponentStatements("", ["root = Stack([a])", 'a = TextContent("A")']); + const withOrphan = mergeComponentStatements(initial, ['b = TextContent("B")']); + + expect(withOrphan).toContain('b = TextContent("B")'); + expect(parser.parse(withOrphan).meta.orphaned).toContain("b"); + + const attached = mergeComponentStatements(withOrphan, ["root = Stack([a, b])"]); + expect(parser.parse(attached).root?.props.children).toEqual([ + expect.objectContaining({ statementId: "a" }), + expect.objectContaining({ statementId: "b" }), + ]); + }); + + it("deletes statements only when a patch assigns null", () => { + const existing = ["root = Stack([a, b])", 'a = TextContent("A")', 'b = TextContent("B")'].join( + "\n", + ); + const source = mergeComponentStatements(existing, ["b = null", "root = Stack([a])"]); + + expect(source).toBe('root = Stack([a])\na = TextContent("A")'); + expect(parser.parse(source).meta.errors).toEqual([]); + }); + + it("supports state IDs and top-level multiline ternaries accepted by lang-core", () => { + const source = mergeComponentStatements("", [ + `$ready = true + label = $ready + ? "Ready" + : "Waiting" + root = TextContent(label)`, + ]); + const result = parser.parse(source); + + expect(source).toContain("$ready = true"); + expect(source).toContain('? "Ready"\n : "Waiting"'); + expect(result.meta.errors).toEqual([]); + expect(result.root?.props.text).toMatchObject({ + k: "Ternary", + cond: { k: "StateRef", n: "$ready" }, + then: { k: "Str", v: "Ready" }, + else: { k: "Str", v: "Waiting" }, + }); + }); + + it("keeps quoted newlines intact and strips a per-item Markdown fence", () => { + const source = mergeComponentStatements("", [ + `\`\`\`openui + message = 'line one + line two' + root = TextContent(message) + \`\`\``, + ]); + const result = parser.parse(source); + + expect(source).not.toContain("```"); + expect(result.meta.errors).toEqual([]); + expect(result.root?.props.text).toContain("line one"); + expect(result.root?.props.text).toContain("line two"); + }); +}); diff --git a/packages/a2ui/src/client.ts b/packages/a2ui/src/client.ts new file mode 100644 index 000000000..dbaaeb354 --- /dev/null +++ b/packages/a2ui/src/client.ts @@ -0,0 +1,588 @@ +import { + createParser, + type ActionEvent, + type OpenUIError, + type Parser, + type ValidationError, +} from "@openuidev/lang-core"; +import { applyDataModelUpdate, mergeOpenUIStateIntoDataModel, toJsonObject } from "./json-pointer"; +import { validateAgentToRendererMessage } from "./runtime-schema"; +import { mergeComponentStatements } from "./statement-patch"; +import type { + A2UIClientOptions, + ActionMessage, + ActionResponseMessage, + AgentToRendererMessage, + DispatchActionInput, + GenericErrorMessage, + JsonObject, + JsonValue, + OpenUIActionOptions, + ProcessResult, + ProtocolValidationIssue, + RendererDataModel, + RendererMetadata, + RendererToAgentMessage, + SurfaceSnapshot, + ValidationFailedErrorMessage, +} from "./types"; + +type SurfaceListener = () => void; +type MessageListener = (message: RendererToAgentMessage, metadata: RendererMetadata) => void; + +interface PendingAction { + surfaceId: string; + responsePath?: string; + resolve: (value: JsonValue) => void; + reject: (error: Error) => void; +} + +export class A2UIActionError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "A2UIActionError"; + this.code = code; + } +} + +function defaultId(): string { + return ( + globalThis.crypto?.randomUUID?.() ?? `a2ui-${Date.now()}-${Math.random().toString(16).slice(2)}` + ); +} + +function parseError(error: ValidationError): OpenUIError { + return { + source: "parser", + code: error.code, + message: error.message, + statementId: error.statementId, + component: error.component, + path: error.path, + }; +} + +function isCreateSurface( + message: AgentToRendererMessage, +): message is Extract { + return "createSurface" in message; +} + +function isUpdateComponents( + message: AgentToRendererMessage, +): message is Extract { + return "updateComponents" in message; +} + +function isUpdateDataModel( + message: AgentToRendererMessage, +): message is Extract { + return "updateDataModel" in message; +} + +function isDeleteSurface( + message: AgentToRendererMessage, +): message is Extract { + return "deleteSurface" in message; +} + +function isCallFunction( + message: AgentToRendererMessage, +): message is Extract { + return "callFunction" in message; +} + +function record(value: unknown): Record | undefined { + return value != null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function validationTarget(input: unknown): { + surfaceId?: string; + functionCallId?: string; +} { + const message = record(input); + if (!message) return {}; + const functionCallId = + typeof message.functionCallId === "string" ? message.functionCallId : undefined; + for (const key of ["createSurface", "updateComponents", "updateDataModel", "deleteSurface"]) { + const payload = record(message[key]); + if (typeof payload?.surfaceId === "string") { + return { surfaceId: payload.surfaceId, functionCallId }; + } + } + return { functionCallId }; +} + +export class A2UIClient { + readonly #parser: Parser; + readonly #functions: A2UIClientOptions["functions"]; + readonly #onMessage?: A2UIClientOptions["onMessage"]; + readonly #rendererCapabilities?: A2UIClientOptions["rendererCapabilities"]; + readonly #now: () => Date; + readonly #createId: () => string; + readonly #surfaces = new Map(); + readonly #surfaceListeners = new Set(); + readonly #messageListeners = new Set(); + readonly #pendingActions = new Map(); + #revision = 0; + + constructor(options: A2UIClientOptions) { + this.#parser = createParser(options.schema, options.rootName); + this.#functions = options.functions; + this.#onMessage = options.onMessage; + this.#rendererCapabilities = options.rendererCapabilities; + this.#now = options.now ?? (() => new Date()); + this.#createId = options.createId ?? defaultId; + } + + subscribe(listener: SurfaceListener): () => void { + this.#surfaceListeners.add(listener); + return () => this.#surfaceListeners.delete(listener); + } + + subscribeMessages(listener: MessageListener): () => void { + this.#messageListeners.add(listener); + return () => this.#messageListeners.delete(listener); + } + + getSurface(surfaceId: string): SurfaceSnapshot | undefined { + return this.#surfaces.get(surfaceId); + } + + getSurfaces(): SurfaceSnapshot[] { + return [...this.#surfaces.values()]; + } + + getRendererDataModel(): RendererDataModel | undefined { + const surfaces = Object.fromEntries( + [...this.#surfaces] + .filter(([, surface]) => surface.sendDataModel) + .map(([surfaceId, surface]) => [surfaceId, structuredClone(surface.dataModel)]), + ); + if (Object.keys(surfaces).length === 0) return undefined; + return { + version: "v1.0", + surfaces, + }; + } + + getRendererMetadata(): RendererMetadata { + const dataModel = this.getRendererDataModel(); + return { + ...(this.#rendererCapabilities + ? { a2uiRendererCapabilities: structuredClone(this.#rendererCapabilities) } + : {}), + ...(dataModel ? { a2uiRendererDataModel: dataModel } : {}), + }; + } + + async process(input: unknown): Promise { + const outbound: RendererToAgentMessage[] = []; + const capture = (next: RendererToAgentMessage) => outbound.push(next); + this.#messageListeners.add(capture); + try { + const validated = validateAgentToRendererMessage(input); + if (!validated.success) return this.#invalidMessage(input, validated.issues, outbound); + const message = validated.message; + + if (isCreateSurface(message)) return this.#createSurface(message, outbound); + if (isUpdateComponents(message)) return this.#updateComponents(message, outbound); + if (isUpdateDataModel(message)) return this.#updateDataModel(message, outbound); + if (isDeleteSurface(message)) return this.#deleteSurface(message, outbound); + if (isCallFunction(message)) return await this.#callFunction(message, outbound); + return this.#actionResponse(message, outbound); + } finally { + this.#messageListeners.delete(capture); + } + } + + updateSurfaceFromOpenUIState(surfaceId: string, state: Record): boolean { + const surface = this.#surfaces.get(surfaceId); + if (!surface) return false; + const dataModel = mergeOpenUIStateIntoDataModel(surface.dataModel, state); + if (JSON.stringify(dataModel) === JSON.stringify(surface.dataModel)) return false; + this.#replaceSurface({ + ...surface, + dataModel, + }); + return true; + } + + dispatchOpenUIAction( + surfaceId: string, + event: ActionEvent, + options: OpenUIActionOptions = {}, + ): Promise | undefined { + const eventSourceComponentId = (event as ActionEvent & { sourceComponentId?: unknown }) + .sourceComponentId; + const context: JsonObject = { + ...toJsonObject(event.params), + ...(event.formState ? { formState: toJsonObject(event.formState) } : {}), + ...options.context, + }; + return this.dispatchAction({ + surfaceId, + sourceComponentId: + options.sourceComponentId ?? + (typeof eventSourceComponentId === "string" ? eventSourceComponentId : "root"), + name: options.name ?? event.type, + context, + wantResponse: options.wantResponse, + responsePath: options.responsePath, + }); + } + + dispatchAction(input: DispatchActionInput): Promise | undefined { + if (!this.#surfaces.has(input.surfaceId)) { + this.#emitGenericError( + "SURFACE_NOT_FOUND", + `Unknown surface: ${input.surfaceId}`, + input.surfaceId, + ); + return undefined; + } + + const actionId = input.wantResponse ? this.#createId() : undefined; + const message: ActionMessage = { + version: "v1.0", + action: { + name: input.name, + surfaceId: input.surfaceId, + sourceComponentId: input.sourceComponentId, + timestamp: this.#now().toISOString(), + context: input.context ?? {}, + ...(input.wantResponse ? { wantResponse: true, actionId } : {}), + }, + }; + this.#emit(message); + + if (!actionId) return undefined; + return new Promise((resolve, reject) => { + this.#pendingActions.set(actionId, { + surfaceId: input.surfaceId, + responsePath: input.responsePath, + resolve, + reject, + }); + }); + } + + dispose(): void { + for (const pending of this.#pendingActions.values()) { + pending.reject(new A2UIActionError("CLIENT_DISPOSED", "A2UI client was disposed")); + } + this.#pendingActions.clear(); + this.#surfaces.clear(); + this.#surfaceListeners.clear(); + this.#messageListeners.clear(); + } + + #createSurface( + message: Extract, + outbound: RendererToAgentMessage[], + ): ProcessResult { + const input = message.createSurface; + if (this.#surfaces.has(input.surfaceId)) { + this.#emitGenericError( + "SURFACE_ALREADY_EXISTS", + `Surface already exists: ${input.surfaceId}`, + input.surfaceId, + ); + return { ok: false, outbound }; + } + const supportedCatalogIds = this.#rendererCapabilities?.["v1.0"].supportedCatalogIds ?? []; + if ( + input.catalogId && + supportedCatalogIds.length > 0 && + !supportedCatalogIds.includes(input.catalogId) + ) { + this.#emitGenericError( + "UNSUPPORTED_CATALOG", + `Renderer does not support catalog: ${input.catalogId}`, + input.surfaceId, + ); + return { ok: false, outbound }; + } + + let source = ""; + let parseResult: SurfaceSnapshot["parseResult"] = null; + let errors: OpenUIError[] = []; + if (input.components) { + try { + ({ source, parseResult, errors } = this.#mergeComponents("", input.components)); + } catch (error) { + this.#emitValidationError( + input.surfaceId, + "/createSurface/components", + error instanceof Error ? error.message : String(error), + ); + return { ok: false, outbound }; + } + } + this.#replaceSurface({ + surfaceId: input.surfaceId, + catalogId: input.catalogId, + surfaceProperties: input.surfaceProperties, + sendDataModel: input.sendDataModel ?? false, + source, + dataModel: structuredClone(input.dataModel ?? {}), + parseResult, + errors, + revision: 0, + }); + for (const error of errors) { + this.#emitValidationError( + input.surfaceId, + "/createSurface/components", + error.statementId ? `${error.statementId}: ${error.message}` : error.message, + ); + } + return { ok: errors.length === 0, outbound }; + } + + #updateComponents( + message: Extract, + outbound: RendererToAgentMessage[], + ): ProcessResult { + const input = message.updateComponents; + const surface = this.#requireSurface(input.surfaceId); + if (!surface) return { ok: false, outbound }; + try { + const { source, parseResult, errors } = this.#mergeComponents( + surface.source, + input.components, + ); + this.#replaceSurface({ ...surface, source, parseResult, errors }); + for (const error of errors) { + this.#emitValidationError( + input.surfaceId, + "/updateComponents/components", + error.statementId ? `${error.statementId}: ${error.message}` : error.message, + ); + } + return { ok: errors.length === 0, outbound }; + } catch (error) { + this.#emitValidationError( + input.surfaceId, + "/updateComponents/components", + error instanceof Error ? error.message : String(error), + ); + return { ok: false, outbound }; + } + } + + #updateDataModel( + message: Extract, + outbound: RendererToAgentMessage[], + ): ProcessResult { + const input = message.updateDataModel; + const surface = this.#requireSurface(input.surfaceId); + if (!surface) return { ok: false, outbound }; + try { + const dataModel = applyDataModelUpdate(surface.dataModel, input.path, input.value); + this.#replaceSurface({ ...surface, dataModel }); + return { ok: true, outbound }; + } catch (error) { + this.#emitValidationError( + input.surfaceId, + "/updateDataModel/path", + error instanceof Error ? error.message : String(error), + ); + return { ok: false, outbound }; + } + } + + #deleteSurface( + message: Extract, + outbound: RendererToAgentMessage[], + ): ProcessResult { + const surfaceId = message.deleteSurface.surfaceId; + if (!this.#requireSurface(surfaceId)) return { ok: false, outbound }; + this.#surfaces.delete(surfaceId); + for (const [actionId, pending] of this.#pendingActions) { + if (pending.surfaceId === surfaceId) { + pending.reject(new A2UIActionError("SURFACE_DELETED", `Surface was deleted: ${surfaceId}`)); + this.#pendingActions.delete(actionId); + } + } + this.#notify(); + return { ok: true, outbound }; + } + + async #callFunction( + message: Extract, + outbound: RendererToAgentMessage[], + ): Promise { + const { call, args = {} } = message.callFunction; + const registration = this.#functions?.[call]; + if (!registration) { + this.#emitGenericError( + "INVALID_FUNCTION_CALL", + `Renderer function is not registered: ${call}`, + undefined, + message.functionCallId, + ); + return { ok: false, outbound }; + } + const fn = typeof registration === "function" ? registration : registration.handler; + if ( + typeof registration !== "function" && + (registration.callableFrom ?? "rendererOnly") === "rendererOnly" + ) { + this.#emitGenericError( + "INVALID_FUNCTION_CALL", + `Renderer function is not callable from the agent: ${call}`, + undefined, + message.functionCallId, + ); + return { ok: false, outbound }; + } + try { + const value = await fn(args); + if (message.wantResponse) { + this.#emit({ + version: "v1.0", + functionResponse: { functionCallId: message.functionCallId, call, value }, + }); + } + return { ok: true, outbound }; + } catch (error) { + this.#emitGenericError( + "FUNCTION_CALL_FAILED", + error instanceof Error ? error.message : String(error), + undefined, + message.functionCallId, + ); + return { ok: false, outbound }; + } + } + + #actionResponse( + message: ActionResponseMessage, + outbound: RendererToAgentMessage[], + ): ProcessResult { + const pending = this.#pendingActions.get(message.actionId); + if (!pending) { + return { + ok: false, + outbound, + issues: [{ path: "/actionId", message: `Unknown actionId: ${message.actionId}` }], + }; + } + this.#pendingActions.delete(message.actionId); + if ("error" in message.actionResponse) { + pending.reject( + new A2UIActionError( + message.actionResponse.error.code, + message.actionResponse.error.message, + ), + ); + return { ok: true, outbound }; + } + const value = message.actionResponse.value; + if (pending.responsePath) { + const surface = this.#surfaces.get(pending.surfaceId); + if (surface) { + try { + const dataModel = applyDataModelUpdate(surface.dataModel, pending.responsePath, value); + this.#replaceSurface({ ...surface, dataModel }); + } catch (error) { + pending.reject(error instanceof Error ? error : new Error(String(error))); + return { ok: false, outbound }; + } + } + } + pending.resolve(value); + return { ok: true, outbound }; + } + + #replaceSurface(surface: Omit & { revision?: number }): void { + this.#revision += 1; + this.#surfaces.set(surface.surfaceId, { ...surface, revision: this.#revision }); + this.#notify(); + } + + #mergeComponents( + existing: string, + components: string[], + ): Pick { + const source = mergeComponentStatements(existing, components); + const parseResult = this.#parser.parse(source); + return { + source, + parseResult, + errors: parseResult.meta.errors.map(parseError), + }; + } + + #invalidMessage( + input: unknown, + issues: ProtocolValidationIssue[], + outbound: RendererToAgentMessage[], + ): ProcessResult { + const target = validationTarget(input); + const issue = issues[0] ?? { path: "/", message: "Invalid A2UI message" }; + if (target.surfaceId !== undefined) { + this.#emitValidationError(target.surfaceId, issue.path, issue.message); + return { ok: false, outbound, issues }; + } + if (target.functionCallId !== undefined) { + this.#emitGenericError( + "INVALID_MESSAGE", + `${issue.path}: ${issue.message}`, + undefined, + target.functionCallId, + ); + return { ok: false, outbound, issues }; + } + return { ok: false, outbound, issues }; + } + + #requireSurface(surfaceId: string): SurfaceSnapshot | undefined { + const surface = this.#surfaces.get(surfaceId); + if (!surface) { + this.#emitGenericError("SURFACE_NOT_FOUND", `Unknown surface: ${surfaceId}`, surfaceId); + } + return surface; + } + + #emitValidationError(surfaceId: string, path: string, message: string): void { + const error: ValidationFailedErrorMessage = { + version: "v1.0", + error: { code: "VALIDATION_FAILED", surfaceId, path, message }, + }; + this.#emit(error); + } + + #emitGenericError( + code: string, + message: string, + surfaceId?: string, + functionCallId?: string, + ): void { + if (surfaceId === undefined && functionCallId === undefined) return; + const error: GenericErrorMessage = + surfaceId !== undefined + ? { version: "v1.0", error: { code, message, surfaceId } } + : { version: "v1.0", error: { code, message, functionCallId: functionCallId! } }; + this.#emit(error); + } + + #emit(message: RendererToAgentMessage): void { + const metadata = this.getRendererMetadata(); + this.#onMessage?.(message, metadata); + for (const listener of this.#messageListeners) listener(message, metadata); + } + + #notify(): void { + for (const listener of this.#surfaceListeners) listener(); + } +} + +export function createA2UIClient(options: A2UIClientOptions): A2UIClient { + return new A2UIClient(options); +} diff --git a/packages/a2ui/src/index.ts b/packages/a2ui/src/index.ts new file mode 100644 index 000000000..dd51b11d5 --- /dev/null +++ b/packages/a2ui/src/index.ts @@ -0,0 +1,59 @@ +export { A2UIActionError, A2UIClient, createA2UIClient } from "./client"; +export { + applyDataModelUpdate, + dataModelToOpenUIState, + mergeOpenUIStateIntoDataModel, +} from "./json-pointer"; +export { + a2uiFunctionCallSchema, + actionMessageSchema, + actionResponseMessageSchema, + agentCapabilitiesSchema, + agentToRendererMessageSchema, + callFunctionMessageSchema, + createSurfaceMessageSchema, + deleteSurfaceMessageSchema, + functionResponseMessageSchema, + genericErrorMessageSchema, + jsonObjectSchema, + jsonValueSchema, + rendererCapabilitiesSchema, + rendererDataModelSchema, + rendererToAgentMessageSchema, + updateComponentsMessageSchema, + updateDataModelMessageSchema, + validationFailedErrorMessageSchema, +} from "./protocol-schema"; +export { validateAgentToRendererMessage } from "./runtime-schema"; +export type { + A2UIClientOptions, + A2UIFunctionCall, + A2UIFunctionCallableFrom, + A2UIRendererFunction, + A2UIRendererFunctionRegistration, + ActionMessage, + ActionResponseMessage, + AgentCapabilities, + AgentToRendererMessage, + CallFunctionMessage, + CreateSurfaceMessage, + DeleteSurfaceMessage, + DispatchActionInput, + FunctionResponseMessage, + GenericErrorMessage, + JsonObject, + JsonPrimitive, + JsonValue, + MapOpenUIAction, + OpenUIActionOptions, + ProcessResult, + ProtocolValidationIssue, + RendererCapabilities, + RendererDataModel, + RendererMetadata, + RendererToAgentMessage, + SurfaceSnapshot, + UpdateComponentsMessage, + UpdateDataModelMessage, + ValidationFailedErrorMessage, +} from "./types"; diff --git a/packages/a2ui/src/json-pointer.ts b/packages/a2ui/src/json-pointer.ts new file mode 100644 index 000000000..6a01d1ea2 --- /dev/null +++ b/packages/a2ui/src/json-pointer.ts @@ -0,0 +1,149 @@ +import type { JsonObject, JsonValue } from "./types"; + +function decodeToken(token: string): string { + return token.replace(/~1/g, "/").replace(/~0/g, "~"); +} + +function tokensFor(path: string | undefined): string[] { + if (path == null || path === "" || path === "/") return []; + if (!path.startsWith("/")) throw new Error(`JSON Pointer must start with "/": ${path}`); + return path.slice(1).split("/").map(decodeToken); +} + +function isArrayIndex(value: string): boolean { + return /^(0|[1-9]\d*)$/.test(value); +} + +function containerFor(nextToken: string): JsonObject | JsonValue[] { + return isArrayIndex(nextToken) ? [] : {}; +} + +export function applyDataModelUpdate( + current: JsonObject, + path: string | undefined, + value: JsonValue, +): JsonObject { + const tokens = tokensFor(path); + if (tokens.length === 0) { + if (value === null) return {}; + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("The root A2UI data model must be an object"); + } + return value; + } + + const root = structuredClone(current); + let cursor: JsonObject | JsonValue[] = root; + + for (let i = 0; i < tokens.length - 1; i++) { + const token = tokens[i]!; + const nextToken = tokens[i + 1]!; + if (Array.isArray(cursor)) { + if (!isArrayIndex(token)) throw new Error(`Invalid array index in JSON Pointer: ${token}`); + const index = Number(token); + const existing = cursor[index]; + if (existing == null || typeof existing !== "object") { + cursor[index] = containerFor(nextToken); + } + const child = cursor[index]; + if (child == null || typeof child !== "object") { + throw new Error(`Cannot traverse JSON Pointer segment: ${token}`); + } + cursor = child as JsonObject | JsonValue[]; + } else { + const existing = cursor[token]; + if (existing == null || typeof existing !== "object") { + cursor[token] = containerFor(nextToken); + } + const child = cursor[token]; + if (child == null || typeof child !== "object") { + throw new Error(`Cannot traverse JSON Pointer segment: ${token}`); + } + cursor = child as JsonObject | JsonValue[]; + } + } + + const leaf = tokens[tokens.length - 1]!; + if (Array.isArray(cursor)) { + if (!isArrayIndex(leaf)) throw new Error(`Invalid array index in JSON Pointer: ${leaf}`); + const index = Number(leaf); + if (value === null) cursor.splice(index, 1); + else cursor[index] = value; + } else if (value === null) { + delete cursor[leaf]; + } else { + cursor[leaf] = value; + } + + return root; +} + +function unwrapFormValue(value: unknown): JsonValue | undefined { + if (value === undefined) return undefined; + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (Array.isArray(value)) { + return value.map(unwrapFormValue).filter((item): item is JsonValue => item !== undefined); + } + if (typeof value === "object") { + const object = value as Record; + if ("value" in object && "componentType" in object) return unwrapFormValue(object.value); + const result: JsonObject = {}; + for (const [key, child] of Object.entries(object)) { + const unwrapped = unwrapFormValue(child); + if (unwrapped !== undefined) result[key] = unwrapped; + } + return result; + } + return undefined; +} + +export function dataModelToOpenUIState( + dataModel: JsonObject, + formStateKeys: Iterable = [], +): Record { + const state: Record = {}; + for (const [key, value] of Object.entries(dataModel)) { + // $key is the canonical owner for Lang bindings such as $user.name. + state[`$${key}`] = value; + } + // Form namespaces are opt-in so a stale form mirror cannot overwrite a + // reactive $binding with the same top-level data-model key. + for (const key of formStateKeys) { + if (key in dataModel) state[key] = dataModel[key]; + } + return state; +} + +export function mergeOpenUIStateIntoDataModel( + current: JsonObject, + state: Record, +): JsonObject { + const next = structuredClone(current); + const keys = new Set([ + ...Object.keys(current), + ...Object.keys(state).map((key) => (key.startsWith("$") ? key.slice(1) : key)), + ]); + for (const key of keys) { + const hasFormState = Object.prototype.hasOwnProperty.call(state, key); + const hasBindingState = Object.prototype.hasOwnProperty.call(state, `$${key}`); + const formValue = hasFormState ? unwrapFormValue(state[key]) : undefined; + const bindingValue = hasBindingState ? unwrapFormValue(state[`$${key}`]) : undefined; + + // Once a real form namespace exists it owns that data-model key. Bindings + // own every other key. This avoids change-detection races between mirrors. + if (hasFormState && formValue !== undefined) next[key] = formValue; + else if (hasBindingState && bindingValue !== undefined) next[key] = bindingValue; + } + return next; +} + +export function toJsonObject(value: Record | undefined): JsonObject { + return (unwrapFormValue(value ?? {}) as JsonObject | undefined) ?? {}; +} diff --git a/packages/a2ui/src/protocol-schema.ts b/packages/a2ui/src/protocol-schema.ts new file mode 100644 index 000000000..b68f2c417 --- /dev/null +++ b/packages/a2ui/src/protocol-schema.ts @@ -0,0 +1,160 @@ +import { z } from "zod/v4"; + +export const jsonValueSchema = z.json(); +export const jsonObjectSchema = z.record(z.string(), jsonValueSchema); + +const versionSchema = z.literal("v1.0"); +const surfaceIdSchema = z.string(); +const langComponentsSchema = z + .array(z.string().min(1)) + .min(1) + .describe( + "Complete OpenUI Lang statements or statement blocks, merged by statement ID in array order.", + ); + +export const a2uiFunctionCallSchema = z.strictObject({ + call: z.string(), + args: jsonObjectSchema.optional(), +}); + +export const createSurfaceMessageSchema = z.strictObject({ + version: versionSchema, + createSurface: z.strictObject({ + surfaceId: surfaceIdSchema, + catalogId: z.string().optional(), + surfaceProperties: jsonObjectSchema.optional(), + sendDataModel: z.boolean().optional().meta({ default: false }), + components: langComponentsSchema + .optional() + .describe("Optional initial OpenUI Lang statements for single-message surface creation."), + dataModel: jsonObjectSchema.optional(), + }), +}); + +export const updateComponentsMessageSchema = z.strictObject({ + version: versionSchema, + updateComponents: z.strictObject({ + surfaceId: surfaceIdSchema, + components: langComponentsSchema, + }), +}); + +export const updateDataModelMessageSchema = z.strictObject({ + version: versionSchema, + updateDataModel: z.strictObject({ + surfaceId: surfaceIdSchema, + path: z.string().optional(), + value: jsonValueSchema, + }), +}); + +export const deleteSurfaceMessageSchema = z.strictObject({ + version: versionSchema, + deleteSurface: z.strictObject({ surfaceId: surfaceIdSchema }), +}); + +export const callFunctionMessageSchema = z.strictObject({ + version: versionSchema, + functionCallId: z.string(), + wantResponse: z.boolean().optional().meta({ default: false }), + callFunction: a2uiFunctionCallSchema, +}); + +export const actionResponseMessageSchema = z.strictObject({ + version: versionSchema, + actionId: z.string(), + actionResponse: z.union([ + z.strictObject({ value: jsonValueSchema }), + z.strictObject({ + error: z.strictObject({ code: z.string(), message: z.string() }), + }), + ]), +}); + +export const agentToRendererMessageSchema = z.union([ + createSurfaceMessageSchema, + updateComponentsMessageSchema, + updateDataModelMessageSchema, + deleteSurfaceMessageSchema, + callFunctionMessageSchema, + actionResponseMessageSchema, +]); + +export const actionMessageSchema = z.strictObject({ + version: versionSchema, + action: z.strictObject({ + name: z.string(), + surfaceId: surfaceIdSchema, + sourceComponentId: z.string(), + timestamp: z.iso.datetime({ offset: true }), + context: jsonObjectSchema, + wantResponse: z.boolean().optional().meta({ default: false }), + actionId: z.string().optional(), + }), +}); + +export const functionResponseMessageSchema = z.strictObject({ + version: versionSchema, + functionResponse: z.strictObject({ + functionCallId: z.string(), + call: z.string(), + value: jsonValueSchema, + }), +}); + +export const validationFailedErrorMessageSchema = z.strictObject({ + version: versionSchema, + error: z.strictObject({ + code: z.literal("VALIDATION_FAILED"), + surfaceId: surfaceIdSchema, + path: z.string(), + message: z.string(), + }), +}); + +const genericErrorCodeSchema = z.string().regex(/^(?!VALIDATION_FAILED$).*$/); + +export const genericErrorMessageSchema = z.union([ + z.strictObject({ + version: versionSchema, + error: z.strictObject({ + code: genericErrorCodeSchema, + message: z.string(), + surfaceId: surfaceIdSchema, + }), + }), + z.strictObject({ + version: versionSchema, + error: z.strictObject({ + code: genericErrorCodeSchema, + message: z.string(), + functionCallId: z.string(), + }), + }), +]); + +export const rendererToAgentMessageSchema = z.union([ + actionMessageSchema, + functionResponseMessageSchema, + validationFailedErrorMessageSchema, + genericErrorMessageSchema, +]); + +export const rendererCapabilitiesSchema = z.strictObject({ + "v1.0": z.strictObject({ + supportedCatalogIds: z.array(z.string()), + inlineCatalogs: z.array(jsonObjectSchema).optional(), + }), +}); + +export const agentCapabilitiesSchema = z.strictObject({ + "v1.0": z.strictObject({ + supportedCatalogIds: z.array(z.string()).optional(), + acceptsInlineCatalogs: z.boolean().optional().meta({ default: false }), + }), +}); + +export const rendererDataModelSchema = z.strictObject({ + version: versionSchema, + surfaces: z.record(z.string(), jsonObjectSchema), +}); diff --git a/packages/a2ui/src/react.ts b/packages/a2ui/src/react.ts new file mode 100644 index 000000000..5f4f4bffd --- /dev/null +++ b/packages/a2ui/src/react.ts @@ -0,0 +1,2 @@ +export { A2UIRenderer } from "./Renderer"; +export type { A2UIRendererProps } from "./Renderer"; diff --git a/packages/a2ui/src/runtime-schema.ts b/packages/a2ui/src/runtime-schema.ts new file mode 100644 index 000000000..cede15ac4 --- /dev/null +++ b/packages/a2ui/src/runtime-schema.ts @@ -0,0 +1,62 @@ +import { + actionResponseMessageSchema, + agentToRendererMessageSchema, + callFunctionMessageSchema, + createSurfaceMessageSchema, + deleteSurfaceMessageSchema, + updateComponentsMessageSchema, + updateDataModelMessageSchema, +} from "./protocol-schema"; +import type { AgentToRendererMessage, ProtocolValidationIssue } from "./types"; + +function escapePointerToken(token: PropertyKey): string { + return String(token).replace(/~/g, "~0").replace(/\//g, "~1"); +} + +function issuePath(path: PropertyKey[]): string { + return path.length === 0 ? "/" : `/${path.map(escapePointerToken).join("/")}`; +} + +export function validateAgentToRendererMessage( + input: unknown, +): + | { success: true; message: AgentToRendererMessage } + | { success: false; issues: ProtocolValidationIssue[] } { + const object = + input != null && typeof input === "object" && !Array.isArray(input) + ? (input as Record) + : undefined; + const messageKeys = [ + "createSurface", + "updateComponents", + "updateDataModel", + "deleteSurface", + "callFunction", + "actionResponse", + ] as const; + const presentKeys = object + ? messageKeys.filter((key) => Object.prototype.hasOwnProperty.call(object, key)) + : []; + const selectedSchema = + presentKeys.length === 1 + ? { + createSurface: createSurfaceMessageSchema, + updateComponents: updateComponentsMessageSchema, + updateDataModel: updateDataModelMessageSchema, + deleteSurface: deleteSurfaceMessageSchema, + callFunction: callFunctionMessageSchema, + actionResponse: actionResponseMessageSchema, + }[presentKeys[0]!] + : agentToRendererMessageSchema; + const result = selectedSchema.safeParse(input); + if (result.success) { + return { success: true, message: result.data as AgentToRendererMessage }; + } + return { + success: false, + issues: result.error.issues.map((issue) => ({ + path: issuePath(issue.path), + message: issue.message, + })), + }; +} diff --git a/packages/a2ui/src/statement-patch.ts b/packages/a2ui/src/statement-patch.ts new file mode 100644 index 000000000..286c4a8f2 --- /dev/null +++ b/packages/a2ui/src/statement-patch.ts @@ -0,0 +1,96 @@ +const STATEMENT_PATTERN = /^(\$?[A-Za-z_][A-Za-z0-9_]*)\s*=\s*([\s\S]*)$/u; + +function stripFences(source: string): string { + const trimmed = source.trim(); + if (!trimmed.startsWith("```")) return trimmed; + + const firstLineEnd = trimmed.indexOf("\n"); + const lastFence = trimmed.lastIndexOf("```"); + if (firstLineEnd === -1 || lastFence <= firstLineEnd) return trimmed; + return trimmed.slice(firstLineEnd + 1, lastFence).trim(); +} + +function splitStatements(source: string): string[] { + const statements: string[] = []; + let depth = 0; + let ternaryDepth = 0; + let quote: false | '"' | "'" = false; + let escaped = false; + let start = 0; + + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (escaped) { + escaped = false; + continue; + } + if (character === "\\" && quote) { + escaped = true; + continue; + } + if (quote) { + if (character === quote) quote = false; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (character === "(" || character === "[" || character === "{") depth += 1; + else if (character === ")" || character === "]" || character === "}") { + depth = Math.max(0, depth - 1); + } else if (character === "?" && depth === 0) { + ternaryDepth += 1; + } else if (character === ":" && depth === 0 && ternaryDepth > 0) { + ternaryDepth -= 1; + } else if (character === "\n" && depth === 0 && ternaryDepth === 0) { + // A top-level ternary may begin on the next line after its condition. + let next = index + 1; + while (next < source.length && /\s/u.test(source[next]!)) next += 1; + if (source[next] === "?") continue; + + const statement = source.slice(start, index).trim(); + if (statement) statements.push(statement); + start = index + 1; + } + } + + const finalStatement = source.slice(start).trim(); + if (finalStatement) statements.push(finalStatement); + return statements; +} + +function parseStatements(source: string): Array<{ id: string; expression: string; raw: string }> { + return splitStatements(stripFences(source)).map((raw) => { + const match = STATEMENT_PATTERN.exec(raw); + if (!match) throw new Error(`Invalid OpenUI Lang statement: ${raw}`); + return { id: match[1]!, expression: match[2]!.trim(), raw }; + }); +} + +/** + * Applies statement-level A2UI component patches without removing temporarily + * unreachable statements. A later update may attach those statements to root. + */ +export function mergeComponentStatements(existing: string, components: readonly string[]): string { + const statements = new Map(); + const order: string[] = []; + + const upsert = (source: string, allowDeletion: boolean): void => { + for (const statement of parseStatements(source)) { + if (allowDeletion && statement.expression === "null") { + statements.delete(statement.id); + const index = order.indexOf(statement.id); + if (index !== -1) order.splice(index, 1); + continue; + } + if (!statements.has(statement.id)) order.push(statement.id); + statements.set(statement.id, statement.raw); + } + }; + + if (existing.trim()) upsert(existing, false); + for (const component of components) upsert(component, true); + + return order.map((id) => statements.get(id)!).join("\n"); +} diff --git a/packages/a2ui/src/types.ts b/packages/a2ui/src/types.ts new file mode 100644 index 000000000..9085ffcbc --- /dev/null +++ b/packages/a2ui/src/types.ts @@ -0,0 +1,116 @@ +import type { + ActionEvent, + LibraryJSONSchema, + OpenUIError, + ParseResult, +} from "@openuidev/lang-core"; +import type { z } from "zod/v4"; +import type { + a2uiFunctionCallSchema, + actionMessageSchema, + actionResponseMessageSchema, + agentCapabilitiesSchema, + agentToRendererMessageSchema, + callFunctionMessageSchema, + createSurfaceMessageSchema, + deleteSurfaceMessageSchema, + functionResponseMessageSchema, + genericErrorMessageSchema, + jsonObjectSchema, + jsonValueSchema, + rendererCapabilitiesSchema, + rendererDataModelSchema, + rendererToAgentMessageSchema, + updateComponentsMessageSchema, + updateDataModelMessageSchema, + validationFailedErrorMessageSchema, +} from "./protocol-schema"; + +export type JsonValue = z.infer; +export type JsonPrimitive = Extract; +export type JsonObject = z.infer; +export type A2UIFunctionCall = z.infer; +export type CreateSurfaceMessage = z.infer; +export type UpdateComponentsMessage = z.infer; +export type UpdateDataModelMessage = z.infer; +export type DeleteSurfaceMessage = z.infer; +export type CallFunctionMessage = z.infer; +export type ActionResponseMessage = z.infer; +export type AgentToRendererMessage = z.infer; +export type ActionMessage = z.infer; +export type FunctionResponseMessage = z.infer; +export type ValidationFailedErrorMessage = z.infer; +export type GenericErrorMessage = z.infer; +export type RendererToAgentMessage = z.infer; +export type RendererCapabilities = z.infer; +export type AgentCapabilities = z.infer; +export type RendererDataModel = z.infer; + +export interface SurfaceSnapshot { + surfaceId: string; + catalogId?: string; + surfaceProperties?: JsonObject; + sendDataModel: boolean; + source: string; + dataModel: JsonObject; + parseResult: ParseResult | null; + errors: OpenUIError[]; + revision: number; +} + +export type A2UIRendererFunction = (args: JsonObject) => JsonValue | Promise; + +export type A2UIFunctionCallableFrom = "rendererOnly" | "agentOnly" | "rendererOrAgent"; + +export interface A2UIRendererFunctionRegistration { + handler: A2UIRendererFunction; + /** Matches A2UI catalog callableFrom semantics. Defaults to rendererOnly. */ + callableFrom?: A2UIFunctionCallableFrom; +} + +export interface A2UIClientOptions { + schema: LibraryJSONSchema; + rootName?: string; + functions?: Record; + rendererCapabilities?: RendererCapabilities; + onMessage?: (message: RendererToAgentMessage, metadata: RendererMetadata) => void; + now?: () => Date; + createId?: () => string; +} + +export interface ProtocolValidationIssue { + path: string; + message: string; +} + +export interface ProcessResult { + ok: boolean; + outbound: RendererToAgentMessage[]; + /** Present when malformed input cannot be represented as an A2UI error envelope. */ + issues?: ProtocolValidationIssue[]; +} + +export interface DispatchActionInput { + surfaceId: string; + sourceComponentId: string; + name: string; + context?: JsonObject; + wantResponse?: boolean; + responsePath?: string; +} + +export interface OpenUIActionOptions { + name?: string; + /** A2UI component ID to report for the action. Defaults to the Lang statement ID, then root. */ + sourceComponentId?: string; + context?: JsonObject; + wantResponse?: boolean; + responsePath?: string; +} + +export type MapOpenUIAction = (event: ActionEvent, surface: SurfaceSnapshot) => OpenUIActionOptions; + +export interface RendererMetadata { + a2uiRendererCapabilities?: RendererCapabilities; + a2uiRendererDataModel?: RendererDataModel; +} diff --git a/packages/a2ui/tsconfig.json b/packages/a2ui/tsconfig.json new file mode 100644 index 000000000..d2feca156 --- /dev/null +++ b/packages/a2ui/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/**/*.test.tsx"], + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": true, + "noPropertyAccessFromIndexSignature": false, + "noUncheckedIndexedAccess": false, + "noImplicitReturns": false, + "noImplicitOverride": false + } +} diff --git a/packages/a2ui/tsconfig.test.json b/packages/a2ui/tsconfig.test.json new file mode 100644 index 000000000..60b3001d5 --- /dev/null +++ b/packages/a2ui/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/a2ui/tsdown.config.ts b/packages/a2ui/tsdown.config.ts new file mode 100644 index 000000000..72ca11681 --- /dev/null +++ b/packages/a2ui/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts", "src/react.ts"], + format: ["esm", "cjs"], + dts: true, + sourcemap: true, + target: "es2022", + outDir: "dist", + clean: true, + deps: { + neverBundle: [/^(?![./]|[A-Za-z]:[/\\])/], + }, +}); diff --git a/packages/a2ui/vitest.config.ts b/packages/a2ui/vitest.config.ts new file mode 100644 index 000000000..9f6250a33 --- /dev/null +++ b/packages/a2ui/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c1404a3a..4a0c0aefe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1711,6 +1711,37 @@ importers: specifier: ^4.5.0 version: 4.6.4(vue@3.5.40(typescript@5.9.3)) + packages/a2ui: + dependencies: + '@openuidev/lang-core': + specifier: workspace:^ + version: link:../lang-core + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@openuidev/react-lang': + specifier: workspace:^ + version: link:../react-lang + '@types/react': + specifier: 'catalog:' + version: 19.2.17 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.17) + jsdom: + specifier: 'catalog:' + version: 26.1.0 + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + vitest: + specifier: ^4.0.18 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(jsdom@26.1.0)(vite@7.3.6(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.33.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/browser-bundle: dependencies: '@openuidev/react-lang':