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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions apps/website/content/docs/a2ui/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@
{
"name": "push",
"signature": "push(chunk: string): A2uiMessage[]",
"description": "",
"description": "Push a JSONL stream chunk and return every complete A2UI envelope parsed from it.",
"params": [
{
"name": "chunk",
Expand Down Expand Up @@ -881,19 +881,21 @@
{
"name": "createA2uiMessageParser",
"kind": "function",
"description": "",
"description": "Creates a stateful parser for newline-delimited A2UI message streams.\n\nThe parser buffers incomplete lines, skips malformed JSON, and returns only\nrecognized A2UI envelopes: `surfaceUpdate`, `dataModelUpdate`,\n`beginRendering`, and `deleteSurface`.",
"signature": "createA2uiMessageParser(): A2uiMessageParser",
"params": [],
"returns": {
"type": "A2uiMessageParser",
"description": ""
},
"examples": []
"examples": [
"```ts\nconst parser = createA2uiMessageParser();\nconst messages = parser.push('{\"beginRendering\":{\"surfaceId\":\"s1\",\"root\":\"root\"}}\\n');\n```"
]
},
{
"name": "deleteByPointer",
"kind": "function",
"description": "",
"description": "Returns a new model with the key at `pointer` removed.\n\nEmpty string and `/` clear the whole model. Missing parent paths leave the\ninput model unchanged.",
"signature": "deleteByPointer(model: Record<string, unknown>, pointer: string): Record<string, unknown>",
"params": [
{
Expand All @@ -913,12 +915,14 @@
"type": "Record<string, unknown>",
"description": ""
},
"examples": []
"examples": [
"```ts\nconst next = deleteByPointer({ user: { name: 'Ada' } }, '/user/name');\n```"
]
},
{
"name": "getByPointer",
"kind": "function",
"description": "",
"description": "Reads a value from a model using the package's simple slash-separated pointer syntax.\n\nEmpty string and `/` target the root. Segments are not RFC 6901-unescaped;\nnormalize keys before using pointers that would require `~0` or `~1`.",
"signature": "getByPointer(model: Record<string, unknown>, pointer: string): unknown",
"params": [
{
Expand All @@ -938,12 +942,14 @@
"type": "unknown",
"description": ""
},
"examples": []
"examples": [
"```ts\ngetByPointer({ user: { name: 'Ada' } }, '/user/name'); // 'Ada'\n```"
]
},
{
"name": "isLiteralBoolean",
"kind": "function",
"description": "",
"description": "Returns true when `value` is an A2UI boolean literal wrapper.",
"signature": "isLiteralBoolean(value: unknown): value is { literalBoolean: boolean }",
"params": [
{
Expand All @@ -962,7 +968,7 @@
{
"name": "isLiteralNumber",
"kind": "function",
"description": "",
"description": "Returns true when `value` is an A2UI number literal wrapper.",
"signature": "isLiteralNumber(value: unknown): value is { literalNumber: number }",
"params": [
{
Expand All @@ -981,7 +987,7 @@
{
"name": "isLiteralString",
"kind": "function",
"description": "",
"description": "Returns true when `value` is an A2UI string literal wrapper.",
"signature": "isLiteralString(value: unknown): value is { literalString: string }",
"params": [
{
Expand All @@ -1000,7 +1006,7 @@
{
"name": "isPathRef",
"kind": "function",
"description": "",
"description": "Returns true when `value` is an A2UI dynamic path reference.",
"signature": "isPathRef(value: unknown): value is { path: string }",
"params": [
{
Expand All @@ -1019,7 +1025,7 @@
{
"name": "resolveDynamic",
"kind": "function",
"description": "",
"description": "Resolves an A2UI dynamic value against a client data model.\n\nLiteral wrappers unwrap to their inner values, `{ path }` references read\nfrom the model by JSON-pointer path, arrays resolve recursively, and\nunrecognized plain values pass through unchanged.",
"signature": "resolveDynamic(value: unknown, model: Record<string, unknown>, scope: A2uiScope): unknown",
"params": [
{
Expand All @@ -1045,12 +1051,14 @@
"type": "unknown",
"description": ""
},
"examples": []
"examples": [
"```ts\nconst model = { customer: { name: 'Ada' } };\nresolveDynamic({ path: '/customer/name' }, model); // 'Ada'\nresolveDynamic({ literalString: 'Checkout' }, model); // 'Checkout'\n```"
]
},
{
"name": "setByPointer",
"kind": "function",
"description": "",
"description": "Returns a new model with `value` written at `pointer`.\n\nThe input model is not mutated. Missing intermediate objects are created.",
"signature": "setByPointer(model: Record<string, unknown>, pointer: string, value: unknown): Record<string, unknown>",
"params": [
{
Expand All @@ -1076,6 +1084,8 @@
"type": "Record<string, unknown>",
"description": ""
},
"examples": []
"examples": [
"```ts\nconst next = setByPointer({}, '/user/name', 'Ada');\n```"
]
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Let's start with a real stream. An agent emits A2UI as newline-delimited JSON
```text
---a2ui_JSON---
{"dataModelUpdate":{"surfaceId":"booking","contents":[{"key":"origin","valueString":"LAX"},{"key":"dest","valueString":"JFK"},{"key":"passengers","valueNumber":1}]}}
{"surfaceUpdate":{"surfaceId":"booking","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","origin","submit"]}}}},{"id":"title","component":{"Text":{"text":"Book a flight","usageHint":"h2"}}},{"id":"origin","component":{"MultipleChoice":{"label":"Origin","options":[{"label":"LAX","value":"LAX"},{"label":"JFK","value":"JFK"}],"selections":{"path":"/origin"},"maxAllowedSelections":1}}},{"id":"submit_label","component":{"Text":{"text":"Search flights"}}},{"id":"submit","component":{"Button":{"child":"submit_label","primary":true,"action":{"name":"bookingSubmit","context":[{"key":"origin","value":{"path":"/origin"}},{"key":"dest","value":{"path":"/dest"}}]}}}}]}}
{"surfaceUpdate":{"surfaceId":"booking","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","origin","submit"]}}}},{"id":"title","component":{"Text":{"text":{"literalString":"Book a flight"},"usageHint":"h2"}}},{"id":"origin","component":{"MultipleChoice":{"label":{"literalString":"Origin"},"options":[{"label":{"literalString":"LAX"},"value":"LAX"},{"label":{"literalString":"JFK"},"value":"JFK"}],"selections":{"path":"/origin"},"maxAllowedSelections":1}}},{"id":"submit_label","component":{"Text":{"text":{"literalString":"Search flights"}}}},{"id":"submit","component":{"Button":{"child":"submit_label","primary":true,"action":{"name":"bookingSubmit","context":[{"key":"origin","value":{"path":"/origin"}},{"key":"dest","value":{"path":"/dest"}}]}}}}]}}
{"beginRendering":{"surfaceId":"booking","root":"root"}}
```

Expand Down
4 changes: 2 additions & 2 deletions apps/website/content/docs/a2ui/guides/message-protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Every envelope carries that `surfaceId`. A `dataModelUpdate` for `"booking"` onl
As an **id-keyed adjacency list**. A `surfaceUpdate` carries a flat `components` array. Each entry has an `id` and a single `component` definition. Parent-child links are by id reference, not by nesting.

```json
{"surfaceUpdate":{"surfaceId":"booking","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","origin","submit"]}}}},{"id":"title","component":{"Text":{"text":"Book a flight","usageHint":"h2"}}}]}}
{"surfaceUpdate":{"surfaceId":"booking","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","origin","submit"]}}}},{"id":"title","component":{"Text":{"text":{"literalString":"Book a flight"},"usageHint":"h2"}}}]}}
```

The `component` value is a **keyed union**: a single-key object where the key names the component type and the value holds its props — `{ "<Name>": { props } }`. `{ "Text": { ... } }` is a Text, `{ "Column": { ... } }` is a Column. There's no separate `type` field; the key *is* the type.
Expand Down Expand Up @@ -69,7 +69,7 @@ The stream is a sequence of single-key envelope objects. The parser recognizes e
Defines (or replaces) the components for a surface.

```json
{"surfaceUpdate":{"surfaceId":"booking","components":[{"id":"submit_label","component":{"Text":{"text":"Search flights"}}},{"id":"submit","component":{"Button":{"child":"submit_label","primary":true,"action":{"name":"bookingSubmit","context":[{"key":"origin","value":{"path":"/origin"}},{"key":"dest","value":{"path":"/dest"}}]}}}}]}}
{"surfaceUpdate":{"surfaceId":"booking","components":[{"id":"submit_label","component":{"Text":{"text":{"literalString":"Search flights"}}}},{"id":"submit","component":{"Button":{"child":"submit_label","primary":true,"action":{"name":"bookingSubmit","context":[{"key":"origin","value":{"path":"/origin"}},{"key":"dest","value":{"path":"/dest"}}]}}}}]}}
```

### `dataModelUpdate`
Expand Down
4 changes: 4 additions & 0 deletions libs/a2ui/src/lib/guards.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
// SPDX-License-Identifier: MIT

/** Returns true when `value` is an A2UI dynamic path reference. */
export function isPathRef(value: unknown): value is { path: string } {
return typeof value === 'object' && value !== null
&& 'path' in value && typeof (value as { path: unknown }).path === 'string';
}

/** Returns true when `value` is an A2UI string literal wrapper. */
export function isLiteralString(value: unknown): value is { literalString: string } {
return typeof value === 'object' && value !== null && 'literalString' in value;
}

/** Returns true when `value` is an A2UI number literal wrapper. */
export function isLiteralNumber(value: unknown): value is { literalNumber: number } {
return typeof value === 'object' && value !== null && 'literalNumber' in value;
}

/** Returns true when `value` is an A2UI boolean literal wrapper. */
export function isLiteralBoolean(value: unknown): value is { literalBoolean: boolean } {
return typeof value === 'object' && value !== null && 'literalBoolean' in value;
}
14 changes: 14 additions & 0 deletions libs/a2ui/src/lib/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,23 @@ import type { A2uiMessage } from './types.js';
const ENVELOPE_KEYS = ['surfaceUpdate', 'dataModelUpdate', 'beginRendering', 'deleteSurface'] as const;

export interface A2uiMessageParser {
/** Push a JSONL stream chunk and return every complete A2UI envelope parsed from it. */
push(chunk: string): A2uiMessage[];
}

/**
* Creates a stateful parser for newline-delimited A2UI message streams.
*
* The parser buffers incomplete lines, skips malformed JSON, and returns only
* recognized A2UI envelopes: `surfaceUpdate`, `dataModelUpdate`,
* `beginRendering`, and `deleteSurface`.
*
* @example
* ```ts
* const parser = createA2uiMessageParser();
* const messages = parser.push('{"beginRendering":{"surfaceId":"s1","root":"root"}}\n');
* ```
*/
export function createA2uiMessageParser(): A2uiMessageParser {
let buffer = '';

Expand Down
32 changes: 32 additions & 0 deletions libs/a2ui/src/lib/pointer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ function parsePointer(pointer: string): string[] {
return pointer.split('/').filter(Boolean);
}

/**
* Reads a value from a model using the package's simple slash-separated pointer syntax.
*
* Empty string and `/` target the root. Segments are not RFC 6901-unescaped;
* normalize keys before using pointers that would require `~0` or `~1`.
*
* @example
* ```ts
* getByPointer({ user: { name: 'Ada' } }, '/user/name'); // 'Ada'
* ```
*/
export function getByPointer(model: Record<string, unknown>, pointer: string): unknown {
const segments = parsePointer(pointer);
let current: unknown = model;
Expand All @@ -15,6 +26,16 @@ export function getByPointer(model: Record<string, unknown>, pointer: string): u
return current;
}

/**
* Returns a new model with `value` written at `pointer`.
*
* The input model is not mutated. Missing intermediate objects are created.
*
* @example
* ```ts
* const next = setByPointer({}, '/user/name', 'Ada');
* ```
*/
export function setByPointer(
model: Record<string, unknown>,
pointer: string,
Expand All @@ -40,6 +61,17 @@ export function setByPointer(
return clone(model, segments, value) as Record<string, unknown>;
}

/**
* Returns a new model with the key at `pointer` removed.
*
* Empty string and `/` clear the whole model. Missing parent paths leave the
* input model unchanged.
*
* @example
* ```ts
* const next = deleteByPointer({ user: { name: 'Ada' } }, '/user/name');
* ```
*/
export function deleteByPointer(
model: Record<string, unknown>,
pointer: string,
Expand Down
14 changes: 14 additions & 0 deletions libs/a2ui/src/lib/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ function resolvePathRef(ref: PathRef, model: Record<string, unknown>, scope?: A2
return getByPointer(model, '/' + path);
}

/**
* Resolves an A2UI dynamic value against a client data model.
*
* Literal wrappers unwrap to their inner values, `{ path }` references read
* from the model by JSON-pointer path, arrays resolve recursively, and
* unrecognized plain values pass through unchanged.
*
* @example
* ```ts
* const model = { customer: { name: 'Ada' } };
* resolveDynamic({ path: '/customer/name' }, model); // 'Ada'
* resolveDynamic({ literalString: 'Checkout' }, model); // 'Checkout'
* ```
*/
export function resolveDynamic(
value: unknown,
model: Record<string, unknown>,
Expand Down
6 changes: 4 additions & 2 deletions libs/render/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,19 @@
## What it does

- Renders a JSON spec tree to Angular components via a named view registry (`<render-spec>`) or a single node (`<render-element>`).
- Registry composition utilities (`views`, `withViews`, `withoutViews`) let you build, extend, and trim registries without mutation.
- Registry composition utilities (`views`, `withViews`, `overrideViews`, `withoutViews`) let you build, extend, replace, and trim registries without mutation.
- Signal-based state store (`signalStateStore`) and per-component fallback support keep UI consistent during streaming.

## Install

```bash
npm install @threadplane/render
npm install @threadplane/render @json-render/core
```

**Peer dependencies:** `@angular/core ^20.0.0 || ^21.0.0`, `@angular/common ^20.0.0 || ^21.0.0`, `@json-render/core ^0.16.0`

`@json-render/core` supplies the spec types and evaluation engine that `@threadplane/render` adapts to Angular.

## Quick start

**1. Define your view registry and provide it.**
Expand Down
Loading