diff --git a/apps/website/content/docs/a2ui/getting-started/quickstart.mdx b/apps/website/content/docs/a2ui/getting-started/quickstart.mdx index dbb5aedb4..321ff8db0 100644 --- a/apps/website/content/docs/a2ui/getting-started/quickstart.mdx +++ b/apps/website/content/docs/a2ui/getting-started/quickstart.mdx @@ -58,19 +58,50 @@ That buffering is deliberate. Agent output streams in fragments, and a half-fini A `dataModelUpdate` carries `contents` — an array of typed entries, each with a `key` and one of `valueString` / `valueNumber` / `valueBoolean` / `valueMap`. The parser hands you those entries verbatim. Turning them into a plain object the resolver can read is your code. -For the booking stream that's three entries: `origin`, `dest`, `passengers`. `setByPointer` builds the object immutably — each call returns a new object, the input is untouched. +The library gives you the pointer helpers but doesn't ship a `contents` -> object reducer — assembling the model from `contents` (reading `valueString` vs `valueNumber`, recursing into `valueMap`) is the caller's job. Here's a small one that walks the entries and writes the reduced object at the envelope's optional `path` (defaulting to the root): ```ts import { setByPointer } from '@threadplane/a2ui'; +import type { A2uiDataModelEntry, A2uiDataModelUpdate } from '@threadplane/a2ui'; + +// Branch on the entry's value field; recurse into valueMap for nesting. +function entriesToObject(entries: A2uiDataModelEntry[]): Record { + const out: Record = {}; + for (const e of entries) { + if (e.valueString !== undefined) out[e.key] = e.valueString; + else if (e.valueNumber !== undefined) out[e.key] = e.valueNumber; + else if (e.valueBoolean !== undefined) out[e.key] = e.valueBoolean; + else if (e.valueMap !== undefined) out[e.key] = entriesToObject(e.valueMap); + } + return out; +} + +// Apply a dataModelUpdate to a model, honoring its optional `path`. +function applyDataModelUpdate( + model: Record, + update: A2uiDataModelUpdate, +): Record { + const obj = entriesToObject(update.contents); + return setByPointer(model, update.path ?? '/', obj); +} +``` + +Run it against the booking stream's `dataModelUpdate` and you get the model back, derived from the entries you just parsed — not re-typed by hand: +```ts let model: Record = {}; -model = setByPointer(model, '/origin', 'LAX'); -model = setByPointer(model, '/dest', 'JFK'); -model = setByPointer(model, '/passengers', 1); +model = applyDataModelUpdate(model, { + surfaceId: 'booking', + contents: [ + { key: 'origin', valueString: 'LAX' }, + { key: 'dest', valueString: 'JFK' }, + { key: 'passengers', valueNumber: 1 }, + ], +}); // model -> { origin: 'LAX', dest: 'JFK', passengers: 1 } ``` -To be clear: assembling the model from `contents` (reading `valueString` vs `valueNumber`, honoring the entry's optional `path`, recursing into `valueMap`) is the caller's job. This library gives you the pointer helpers; it doesn't ship a `contents` -> object reducer. The [data model guide](/docs/a2ui/guides/data-model) covers the helpers in depth. +`setByPointer` builds the object immutably — each call returns a new object, the input is untouched. The [data model guide](/docs/a2ui/guides/data-model) covers the reducer, `path` scoping, and the pointer helpers in depth. ## Resolve a value diff --git a/apps/website/content/docs/a2ui/guides/data-model.mdx b/apps/website/content/docs/a2ui/guides/data-model.mdx index 727f0dfb2..a1b8bc3d1 100644 --- a/apps/website/content/docs/a2ui/guides/data-model.mdx +++ b/apps/website/content/docs/a2ui/guides/data-model.mdx @@ -48,6 +48,54 @@ If the parent of the target doesn't exist, `deleteByPointer` returns the origina These helpers use JSON-Pointer-style syntax but do **not** implement RFC 6901's `~0` / `~1` unescaping. A path is split on `/` and the segments are used as literal keys. So keys that themselves contain `/` or `~` aren't addressable — there's no escape sequence to reach them. +## From contents to model + +The pointer helpers are the primitives. The most common real task is one level up: turning a `dataModelUpdate`'s `contents` array into the plain object the resolver reads. The library doesn't ship that reducer — you write it. Each `A2uiDataModelEntry` has a `key` and exactly one of `valueString`, `valueNumber`, `valueBoolean`, or `valueMap` (a nested array of entries). Branch on which is set, and recurse on `valueMap`: + +```ts +import type { A2uiDataModelEntry } from '@threadplane/a2ui'; + +function entriesToObject(entries: A2uiDataModelEntry[]): Record { + const out: Record = {}; + for (const e of entries) { + if (e.valueString !== undefined) out[e.key] = e.valueString; + else if (e.valueNumber !== undefined) out[e.key] = e.valueNumber; + else if (e.valueBoolean !== undefined) out[e.key] = e.valueBoolean; + else if (e.valueMap !== undefined) out[e.key] = entriesToObject(e.valueMap); + } + return out; +} + +entriesToObject([ + { key: 'name', valueString: 'Ada' }, + { key: 'address', valueMap: [{ key: 'city', valueString: 'London' }] }, +]); +// { name: 'Ada', address: { city: 'London' } } +``` + +A `dataModelUpdate` may also carry an optional top-level `path`. It scopes where the reduced object lands in the model — without it the entries write at the root; with `path: '/customer'` they nest under `customer`. Honor it by reducing first, then writing the result with `setByPointer`: + +```ts +import { setByPointer } from '@threadplane/a2ui'; +import type { A2uiDataModelUpdate } from '@threadplane/a2ui'; + +function applyDataModelUpdate( + model: Record, + update: A2uiDataModelUpdate, +): Record { + const obj = entriesToObject(update.contents); + return setByPointer(model, update.path ?? '/', obj); +} + +applyDataModelUpdate( + {}, + { surfaceId: 's1', path: '/customer', contents: [{ key: 'name', valueString: 'Ada' }] }, +); +// { customer: { name: 'Ada' } } +``` + +When `path` is absent, `setByPointer(model, '/', obj)` replaces the root with the reduced object. Pass a pointer like `/customer` to merge under a key instead. + ## Resolving dynamic values `resolveDynamic` collapses a component's prop to a concrete value against the model. The order is fixed: diff --git a/apps/website/content/docs/a2ui/guides/message-protocol.mdx b/apps/website/content/docs/a2ui/guides/message-protocol.mdx index f3cfe40b9..478751660 100644 --- a/apps/website/content/docs/a2ui/guides/message-protocol.mdx +++ b/apps/website/content/docs/a2ui/guides/message-protocol.mdx @@ -80,6 +80,14 @@ Sets data for a surface. `contents` is an array of typed entries — each has a {"dataModelUpdate":{"surfaceId":"booking","contents":[{"key":"origin","valueString":"LAX"},{"key":"dest","valueString":"JFK"},{"key":"passengers","valueNumber":1}]}} ``` +`valueMap` nests. An entry whose value is a `valueMap` holds its own array of entries, which reduce to a nested object. And the envelope's optional top-level `path` scopes where the whole batch lands: + +```json +{"dataModelUpdate":{"surfaceId":"booking","path":"/customer","contents":[{"key":"name","valueString":"Ada"},{"key":"address","valueMap":[{"key":"city","valueString":"London"}]}]}} +``` + +That reduces to `{ customer: { name: 'Ada', address: { city: 'London' } } }` — the `valueMap` becomes the nested `address` object, and `path: '/customer'` nests the batch under `customer`. The [data model guide](/docs/a2ui/guides/data-model#from-contents-to-model) shows the reducer that walks these entries. + ### `beginRendering` Names the `root` component id for the surface — the entry point the renderer mounts. It may also carry `styles` (`font`, `primaryColor`). @@ -111,7 +119,7 @@ Two details are worth pinning down, because the inbound and outbound shapes diff - **Context flips from list to map.** The inbound Button's `action.context` is a *list* of `{ key, value }` entries, where each `value` is still a dynamic value (often a `{ path }`). The outbound message's `action.context` is a *map* keyed by those keys, with each value already a wrapped literal — the path references resolved against the current model and re-wrapped (here `{ path: '/origin' }` became `{ literalString: 'LAX' }`). - **`label` is derived.** It comes from the source component's authored text — for a Button-with-Text-child, the child Text's literal string ("Search flights"). It's optional; the transcript renderer uses it to label the user bubble, and backends may ignore it. -The client's current data model is only attached as `metadata.a2uiClientDataModel` when the surface opts in. It's omitted otherwise. +The client's current data model is only attached as `metadata.a2uiClientDataModel` when the surface opts in. It's omitted otherwise. When present, it's an `A2uiClientDataModel` — `{ version: 'v1', surfaces: Record> }`, the per-surface model keyed by `surfaceId`. See [the schema reference](/docs/a2ui/reference/schema#outbound-action-messages) for the full outbound shape. ## Relationship to Google's A2UI diff --git a/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx b/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx index d8883cc5f..d40b23001 100644 --- a/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx +++ b/apps/website/content/docs/a2ui/reference/parser-resolver-guards.mdx @@ -52,6 +52,8 @@ resolveDynamic({ literalNumber: 2 }, model); // 2 | `null` or `undefined` | returned as-is | | unrecognized shapes | returned as-is | +Resolution order is fixed: literal wrappers are checked and unwrapped **first**, then a `{ path }` reference, then plain passthrough. So a value carrying both a literal key and a `path` key resolves as the literal — the `path` is never reached. + Absolute paths start with `/`. Relative paths resolve against `scope.basePath` when a scope is supplied. Without a scope, a relative path is treated as root-relative by prefixing `/`. diff --git a/apps/website/content/docs/a2ui/reference/schema.mdx b/apps/website/content/docs/a2ui/reference/schema.mdx index 439564603..c85802262 100644 --- a/apps/website/content/docs/a2ui/reference/schema.mdx +++ b/apps/website/content/docs/a2ui/reference/schema.mdx @@ -86,6 +86,26 @@ The exported component definitions are: | `MultipleChoice` | `selections`, `options`, `maxAllowedSelections`, `label` | | `Slider` | `value`, `minValue`, `maxValue`, `step`, `label` | +Several of these fields are constrained to a fixed enum. Emit one of the listed values — an unknown value isn't validated at the protocol layer, but a renderer may ignore it or fall back to a default: + +| Field | On | Allowed values | +|-------|----|----------------| +| `usageHint` | `Text` | `'h1'` \| `'h2'` \| `'h3'` \| `'h4'` \| `'h5'` \| `'caption'` \| `'body'` | +| `textFieldType` | `TextField` | `'date'` \| `'longText'` \| `'number'` \| `'shortText'` \| `'obscured'` | +| `alignment` | `Row`, `Column` | `'start'` \| `'center'` \| `'end'` \| `'stretch'` | +| `distribution` | `Row` | `'start'` \| `'center'` \| `'end'` \| `'space-between'` \| `'space-around'` | +| `direction` | `List` | `'vertical'` \| `'horizontal'` | +| `direction` | `Divider` | `'horizontal'` \| `'vertical'` | + +`Tabs` is the one container that doesn't use `A2uiChildren`. Its `tabItems` is an array of `A2uiTabItem`, each pairing a `title` (a `DynamicString`) with a single `child` id: + +```ts +interface A2uiTabItem { + title: DynamicString; + child: string; +} +``` + The schema exposes `validationRegexp` on `TextField`, but validation execution is not implemented in this package. Treat schema fields as protocol data until a renderer wires behavior. ## Message envelopes