@assistant-ui/react-generative-ui 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,469 @@
1
+ import type { UIElement } from "../ir";
2
+ import {
3
+ A2UI_SURFACE_ID,
4
+ type A2uiSurfaceState,
5
+ type A2uiTemplateChildren,
6
+ } from "./types";
7
+
8
+ const DEPTH_CAP = 32;
9
+ const TEMPLATE_ITEM_CAP = 100;
10
+ const NODE_BUDGET = 5000;
11
+
12
+ const SUPPORTED_COMPONENTS = new Set([
13
+ "Text",
14
+ "Image",
15
+ "Row",
16
+ "Column",
17
+ "Card",
18
+ "Divider",
19
+ "Button",
20
+ "TextField",
21
+ "CheckBox",
22
+ ]);
23
+
24
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
25
+ typeof value === "object" && value !== null && !Array.isArray(value);
26
+
27
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
28
+ if (!isRecord(value)) return false;
29
+ const prototype = Object.getPrototypeOf(value);
30
+ return prototype === Object.prototype || prototype === null;
31
+ };
32
+
33
+ const isBinding = (value: unknown): value is { readonly path: string } =>
34
+ isPlainObject(value) &&
35
+ Object.keys(value).length === 1 &&
36
+ typeof value["path"] === "string";
37
+
38
+ const isTemplateChildren = (value: unknown): value is A2uiTemplateChildren =>
39
+ isPlainObject(value) &&
40
+ Object.keys(value).length === 1 &&
41
+ isPlainObject(value["template"]) &&
42
+ Object.keys(value["template"]).length === 2 &&
43
+ typeof value["template"]["componentId"] === "string" &&
44
+ typeof value["template"]["path"] === "string";
45
+
46
+ const decodePointer = (path: string): string[] | undefined => {
47
+ if (path === "" || path === "/") return [];
48
+ if (!path.startsWith("/")) return undefined;
49
+ return path
50
+ .slice(1)
51
+ .split("/")
52
+ .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
53
+ };
54
+
55
+ const resolvePointer = (source: unknown, path: string): unknown => {
56
+ const segments = decodePointer(path);
57
+ if (!segments) return undefined;
58
+ let current = source;
59
+ for (const segment of segments) {
60
+ if (Array.isArray(current)) {
61
+ if (!/^(0|[1-9]\d*)$/.test(segment)) return undefined;
62
+ current = current[Number(segment)];
63
+ continue;
64
+ }
65
+ if (!isRecord(current) || !Object.hasOwn(current, segment)) {
66
+ return undefined;
67
+ }
68
+ current = current[segment];
69
+ }
70
+ return current;
71
+ };
72
+
73
+ const bindingPath = (value: unknown): string | undefined =>
74
+ isBinding(value) ? value.path : undefined;
75
+
76
+ const lastPointerSegment = (path: string | undefined): string | undefined => {
77
+ if (!path) return undefined;
78
+ const segments = decodePointer(path);
79
+ return segments?.at(-1);
80
+ };
81
+
82
+ const materialize = (value: unknown, source: unknown): unknown => {
83
+ if (isBinding(value)) return resolvePointer(source, value.path);
84
+ if (Array.isArray(value)) {
85
+ return value
86
+ .map((entry) => materialize(entry, source))
87
+ .filter((entry) => entry !== undefined);
88
+ }
89
+ if (!isPlainObject(value)) return value;
90
+ const result: Record<string, unknown> = {};
91
+ for (const [key, entry] of Object.entries(value)) {
92
+ const resolved = materialize(entry, source);
93
+ if (resolved !== undefined) result[key] = resolved;
94
+ }
95
+ return result;
96
+ };
97
+
98
+ const firstDefined = (
99
+ props: Record<string, unknown>,
100
+ keys: readonly string[],
101
+ ): unknown => {
102
+ for (const key of keys) {
103
+ if (props[key] !== undefined) return props[key];
104
+ }
105
+ return undefined;
106
+ };
107
+
108
+ const stringProp = (
109
+ props: Record<string, unknown>,
110
+ keys: readonly string[],
111
+ ): string | undefined => {
112
+ const value = firstDefined(props, keys);
113
+ return typeof value === "string" ? value : undefined;
114
+ };
115
+
116
+ const sourceSurfaceId = (surface: A2uiSurfaceState): string =>
117
+ (surface as A2uiSurfaceState & { [A2UI_SURFACE_ID]?: string })[
118
+ A2UI_SURFACE_ID
119
+ ] ?? "";
120
+
121
+ type ConversionContext = {
122
+ readonly surface: A2uiSurfaceState;
123
+ readonly surfaceId: string;
124
+ readonly warnings: string[];
125
+ emittedNodes: number;
126
+ depthWarned: boolean;
127
+ budgetWarned: boolean;
128
+ templateCapWarned: boolean;
129
+ };
130
+
131
+ const reserveNode = (context: ConversionContext): boolean => {
132
+ if (context.emittedNodes >= NODE_BUDGET) {
133
+ if (!context.budgetWarned) {
134
+ context.warnings.push(`A2UI node budget of ${NODE_BUDGET} was reached.`);
135
+ context.budgetWarned = true;
136
+ }
137
+ return false;
138
+ }
139
+ context.emittedNodes++;
140
+ return true;
141
+ };
142
+
143
+ const childrenOf = (
144
+ node: Record<string, unknown>,
145
+ dataSource: unknown,
146
+ context: ConversionContext,
147
+ depth: number,
148
+ visited: Set<string>,
149
+ ): UIElement[] => {
150
+ const children = node["children"];
151
+ if (!Array.isArray(children)) return [];
152
+ const result: UIElement[] = [];
153
+ for (const childId of children) {
154
+ if (typeof childId !== "string") {
155
+ context.warnings.push(
156
+ `Component "${String(node["id"] ?? "")}" has a malformed child reference.`,
157
+ );
158
+ continue;
159
+ }
160
+ const child = convertComponent(
161
+ childId,
162
+ dataSource,
163
+ context,
164
+ depth + 1,
165
+ visited,
166
+ );
167
+ if (child) result.push(child);
168
+ }
169
+ return result;
170
+ };
171
+
172
+ const mappedAction = (
173
+ node: Record<string, unknown>,
174
+ props: Record<string, unknown>,
175
+ dataSource: unknown,
176
+ context: ConversionContext,
177
+ ): UIElement["$action"] | undefined => {
178
+ const action = props["action"];
179
+ const actionName =
180
+ typeof action === "string"
181
+ ? action
182
+ : isRecord(action) && typeof action["name"] === "string"
183
+ ? action["name"]
184
+ : undefined;
185
+ if (!actionName) return undefined;
186
+ const rawContext = isRecord(action) ? action["context"] : undefined;
187
+ const resolvedContext =
188
+ rawContext === undefined ? undefined : materialize(rawContext, dataSource);
189
+ return {
190
+ type: "a2ui:action",
191
+ name: actionName,
192
+ surfaceId: context.surfaceId,
193
+ sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
194
+ ...(resolvedContext !== undefined ? { context: resolvedContext } : {}),
195
+ };
196
+ };
197
+
198
+ const mappedProps = (
199
+ node: Record<string, unknown>,
200
+ props: Record<string, unknown>,
201
+ dataSource: unknown,
202
+ context: ConversionContext,
203
+ ): UIElement | undefined => {
204
+ const component = node["component"];
205
+
206
+ if (component === "Text") {
207
+ const text = stringProp(props, ["text", "value"]);
208
+ const variant = props["variant"];
209
+ if (typeof variant === "string" && /^h[1-6]$/.test(variant)) {
210
+ return {
211
+ $type: "Header",
212
+ ...(text !== undefined ? { text } : {}),
213
+ };
214
+ }
215
+ if (variant === "caption") {
216
+ return {
217
+ $type: "Caption",
218
+ ...(text !== undefined ? { value: text } : {}),
219
+ };
220
+ }
221
+ return {
222
+ $type: "Markdown",
223
+ ...(text !== undefined ? { value: text } : {}),
224
+ };
225
+ }
226
+
227
+ if (component === "Image") {
228
+ const src = stringProp(props, ["src", "url"]);
229
+ const alt = stringProp(props, ["alt", "altText"]);
230
+ const size = props["size"];
231
+ const round = props["round"];
232
+ return {
233
+ $type: "Image",
234
+ ...(src !== undefined ? { src } : {}),
235
+ ...(alt !== undefined ? { alt } : {}),
236
+ ...(typeof size === "string" || typeof size === "number" ? { size } : {}),
237
+ ...(typeof round === "boolean" ? { round } : {}),
238
+ };
239
+ }
240
+
241
+ if (component === "Row") {
242
+ const gap = props["gap"];
243
+ const align = firstDefined(props, ["align", "alignment"]);
244
+ const justify = firstDefined(props, ["justify", "distribution"]);
245
+ return {
246
+ $type: "Row",
247
+ ...(typeof gap === "number" ? { gap } : {}),
248
+ ...(typeof align === "string" ? { align } : {}),
249
+ ...(typeof justify === "string" ? { justify } : {}),
250
+ };
251
+ }
252
+
253
+ if (component === "Column") {
254
+ const gap = props["gap"];
255
+ const align = firstDefined(props, ["align", "alignment"]);
256
+ return {
257
+ $type: "Col",
258
+ ...(typeof gap === "number" ? { gap } : {}),
259
+ ...(typeof align === "string" ? { align } : {}),
260
+ };
261
+ }
262
+
263
+ if (component === "Card") {
264
+ const title = props["title"];
265
+ const padding = props["padding"];
266
+ const background = props["background"];
267
+ return {
268
+ $type: "Card",
269
+ ...(typeof title === "string" ? { title } : {}),
270
+ ...(typeof padding === "number" ? { padding } : {}),
271
+ ...(typeof background === "string" ? { background } : {}),
272
+ };
273
+ }
274
+
275
+ if (component === "Divider") {
276
+ return {
277
+ $type: "Divider",
278
+ ...(typeof props["flush"] === "boolean" ? { flush: props["flush"] } : {}),
279
+ };
280
+ }
281
+
282
+ if (component === "Button") {
283
+ const label = stringProp(props, ["label", "text"]);
284
+ const buttonStyle = props["buttonStyle"];
285
+ const block = props["block"];
286
+ const submit = props["submit"];
287
+ const action = mappedAction(node, props, dataSource, context);
288
+ return {
289
+ $type: "Button",
290
+ ...(label !== undefined ? { label } : {}),
291
+ ...(typeof buttonStyle === "string" ? { buttonStyle } : {}),
292
+ ...(typeof block === "boolean" ? { block } : {}),
293
+ ...(typeof submit === "boolean" ? { submit } : {}),
294
+ ...(action ? { $action: action } : {}),
295
+ };
296
+ }
297
+
298
+ const binding = firstDefined(node, ["text", "value", "binding"]);
299
+ const name = lastPointerSegment(bindingPath(binding));
300
+ const label = stringProp(props, ["label"]);
301
+
302
+ if (component === "TextField") {
303
+ const placeholder = stringProp(props, ["placeholder"]);
304
+ const multiline =
305
+ typeof props["multiline"] === "boolean"
306
+ ? props["multiline"]
307
+ : props["textFieldType"] === "longText"
308
+ ? true
309
+ : undefined;
310
+ return {
311
+ $type: "Input",
312
+ ...(placeholder !== undefined ? { placeholder } : {}),
313
+ ...(multiline !== undefined ? { multiline } : {}),
314
+ ...(label !== undefined ? { label } : {}),
315
+ ...(name !== undefined ? { name } : {}),
316
+ };
317
+ }
318
+
319
+ if (component === "CheckBox") {
320
+ const defaultChecked = firstDefined(props, ["defaultChecked", "value"]);
321
+ return {
322
+ $type: "Checkbox",
323
+ ...(label !== undefined ? { label } : {}),
324
+ ...(name !== undefined ? { name } : {}),
325
+ ...(typeof defaultChecked === "boolean" ? { defaultChecked } : {}),
326
+ };
327
+ }
328
+
329
+ return undefined;
330
+ };
331
+
332
+ const convertTemplate = (
333
+ node: Record<string, unknown>,
334
+ templateChildren: A2uiTemplateChildren,
335
+ dataSource: unknown,
336
+ context: ConversionContext,
337
+ depth: number,
338
+ visited: Set<string>,
339
+ ): UIElement | null => {
340
+ if (!reserveNode(context)) return null;
341
+ const list = resolvePointer(dataSource, templateChildren.template.path);
342
+ if (!Array.isArray(list)) {
343
+ context.warnings.push(
344
+ `Template on component "${String(node["id"] ?? "")}" did not resolve to a list.`,
345
+ );
346
+ return { $type: "ListView", children: [] };
347
+ }
348
+ const itemCount = Math.min(list.length, TEMPLATE_ITEM_CAP);
349
+ if (list.length > TEMPLATE_ITEM_CAP && !context.templateCapWarned) {
350
+ context.warnings.push(
351
+ `A2UI template expansion was capped at ${TEMPLATE_ITEM_CAP} items.`,
352
+ );
353
+ context.templateCapWarned = true;
354
+ }
355
+ const children: UIElement[] = [];
356
+ for (let index = 0; index < itemCount; index++) {
357
+ if (!reserveNode(context)) break;
358
+ const child = convertComponent(
359
+ templateChildren.template.componentId,
360
+ list[index],
361
+ context,
362
+ depth + 1,
363
+ visited,
364
+ );
365
+ children.push({
366
+ $type: "ListViewItem",
367
+ ...(child ? { children: child } : {}),
368
+ });
369
+ }
370
+ return { $type: "ListView", children };
371
+ };
372
+
373
+ function convertComponent(
374
+ componentId: string,
375
+ dataSource: unknown,
376
+ context: ConversionContext,
377
+ depth: number,
378
+ visited: Set<string>,
379
+ ): UIElement | null {
380
+ if (depth >= DEPTH_CAP) {
381
+ if (!context.depthWarned) {
382
+ context.warnings.push(`A2UI depth cap of ${DEPTH_CAP} was reached.`);
383
+ context.depthWarned = true;
384
+ }
385
+ return null;
386
+ }
387
+ if (visited.has(componentId)) {
388
+ context.warnings.push(`A2UI component cycle detected at "${componentId}".`);
389
+ return null;
390
+ }
391
+ const node = context.surface.components.get(componentId);
392
+ if (!node) {
393
+ context.warnings.push(`A2UI component "${componentId}" was not found.`);
394
+ return null;
395
+ }
396
+ const component = node["component"];
397
+
398
+ visited.add(componentId);
399
+ try {
400
+ const templateChildren = node["children"];
401
+ if (isTemplateChildren(templateChildren)) {
402
+ return convertTemplate(
403
+ node,
404
+ templateChildren,
405
+ dataSource,
406
+ context,
407
+ depth,
408
+ visited,
409
+ );
410
+ }
411
+ if (typeof component !== "string" || !SUPPORTED_COMPONENTS.has(component)) {
412
+ context.warnings.push(
413
+ `Unknown A2UI component "${String(component ?? "")}" was skipped.`,
414
+ );
415
+ return null;
416
+ }
417
+ if (!reserveNode(context)) return null;
418
+ const props: Record<string, unknown> = {};
419
+ for (const [key, value] of Object.entries(node)) {
420
+ if (key === "id" || key === "component" || key === "children") continue;
421
+ const resolved = materialize(value, dataSource);
422
+ if (resolved !== undefined) props[key] = resolved;
423
+ }
424
+ const mapped = mappedProps(node, props, dataSource, context);
425
+ if (!mapped) return null;
426
+ const children = childrenOf(node, dataSource, context, depth, visited);
427
+ return {
428
+ ...mapped,
429
+ ...(children.length > 0 ? { children } : {}),
430
+ };
431
+ } finally {
432
+ visited.delete(componentId);
433
+ }
434
+ }
435
+
436
+ export function convertSurfaceToUISpec(surface: A2uiSurfaceState): {
437
+ spec: UIElement | null;
438
+ warnings: string[];
439
+ } {
440
+ const warnings: string[] = [];
441
+ if (!surface.components.has("root")) {
442
+ return {
443
+ spec: null,
444
+ warnings: ['A2UI root component "root" was not found.'],
445
+ };
446
+ }
447
+ const context: ConversionContext = {
448
+ surface,
449
+ surfaceId: sourceSurfaceId(surface),
450
+ warnings,
451
+ emittedNodes: 0,
452
+ depthWarned: false,
453
+ budgetWarned: false,
454
+ templateCapWarned: false,
455
+ };
456
+ try {
457
+ const spec = convertComponent(
458
+ "root",
459
+ surface.dataModel,
460
+ context,
461
+ 0,
462
+ new Set(),
463
+ );
464
+ return { spec, warnings };
465
+ } catch {
466
+ warnings.push("A2UI surface conversion encountered malformed input.");
467
+ return { spec: null, warnings };
468
+ }
469
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyA2uiOperations } from "./reducer";
3
+ import type { A2uiState, A2uiSurfaceState } from "./types";
4
+
5
+ describe("applyA2uiOperations", () => {
6
+ it("applies create, component upsert, data model update, and delete operations without mutating prior state", () => {
7
+ const initial: A2uiState = new Map();
8
+ const created = applyA2uiOperations(initial, [
9
+ {
10
+ version: "v0.9",
11
+ createSurface: { surfaceId: "main", catalogId: "default" },
12
+ },
13
+ {
14
+ version: "v0.9",
15
+ updateComponents: {
16
+ surfaceId: "main",
17
+ components: [
18
+ { id: "root", component: "Text", text: "first" },
19
+ { id: "root", component: "Text", text: "second" },
20
+ ],
21
+ },
22
+ },
23
+ {
24
+ version: "v0.9",
25
+ updateDataModel: {
26
+ surfaceId: "main",
27
+ path: "/profile/name",
28
+ contents: "Ada",
29
+ value: "ignored",
30
+ data: "ignored",
31
+ },
32
+ },
33
+ ]);
34
+
35
+ expect(initial.size).toBe(0);
36
+ expect(created.warnings).toEqual([]);
37
+ expect(created.state.get("main")).toMatchObject({
38
+ dataModel: { profile: { name: "Ada" } },
39
+ });
40
+ expect(created.state.get("main")?.components.get("root")).toEqual({
41
+ id: "root",
42
+ component: "Text",
43
+ text: "second",
44
+ });
45
+
46
+ const removed = applyA2uiOperations(created.state, [
47
+ {
48
+ version: "v1.0",
49
+ deleteSurface: { surfaceId: "main" },
50
+ },
51
+ ]);
52
+
53
+ expect(removed.warnings).toEqual([]);
54
+ expect(removed.state.has("main")).toBe(false);
55
+ expect(created.state.has("main")).toBe(true);
56
+ });
57
+
58
+ it("accepts v1.0 inline components and data model as initial state", () => {
59
+ const result = applyA2uiOperations(new Map(), [
60
+ {
61
+ version: "v1.0",
62
+ createSurface: {
63
+ surfaceId: "inline",
64
+ surfaceProperties: { ignored: true },
65
+ sendDataModel: true,
66
+ components: [
67
+ { id: "root", component: "Text", text: { path: "/message" } },
68
+ ],
69
+ dataModel: { message: "hello" },
70
+ },
71
+ },
72
+ ]);
73
+
74
+ expect(result.warnings).toEqual([]);
75
+ expect(result.state.get("inline")).toMatchObject({
76
+ dataModel: { message: "hello" },
77
+ });
78
+ expect(result.state.get("inline")?.components.get("root")).toEqual({
79
+ id: "root",
80
+ component: "Text",
81
+ text: { path: "/message" },
82
+ });
83
+ });
84
+
85
+ it("clones the touched surface and component map", () => {
86
+ const components = new Map<string, Record<string, unknown>>([
87
+ ["root", { id: "root", component: "Divider" }],
88
+ ]);
89
+ const surface: A2uiSurfaceState = {
90
+ components,
91
+ dataModel: { value: 1 },
92
+ };
93
+ const state: A2uiState = new Map([["main", surface]]);
94
+
95
+ const result = applyA2uiOperations(state, [
96
+ {
97
+ version: "v1.0",
98
+ updateComponents: {
99
+ surfaceId: "main",
100
+ components: [{ id: "next", component: "Divider" }],
101
+ },
102
+ },
103
+ ]);
104
+
105
+ expect(result.state.get("main")).not.toBe(surface);
106
+ expect(result.state.get("main")?.components).not.toBe(components);
107
+ expect(components.has("next")).toBe(false);
108
+ expect(result.state.get("main")?.components.has("next")).toBe(true);
109
+ });
110
+
111
+ it("skips unknown operations with a warning naming the key", () => {
112
+ const result = applyA2uiOperations(new Map(), [
113
+ {
114
+ version: "v1.0",
115
+ animateSurface: { surfaceId: "main" },
116
+ },
117
+ ]);
118
+
119
+ expect(result.state.size).toBe(0);
120
+ expect(result.warnings).toHaveLength(1);
121
+ expect(result.warnings[0]).toContain("animateSurface");
122
+ });
123
+
124
+ it("tolerates non-array input and malformed entries", () => {
125
+ const nonArray = applyA2uiOperations(new Map(), { version: "v1.0" });
126
+ expect(nonArray.state.size).toBe(0);
127
+ expect(nonArray.warnings).toHaveLength(1);
128
+
129
+ const malformed = applyA2uiOperations(new Map(), [
130
+ null,
131
+ 42,
132
+ {},
133
+ { version: "v2.0", deleteSurface: { surfaceId: "main" } },
134
+ {
135
+ version: "v1.0",
136
+ createSurface: { surfaceId: "main" },
137
+ deleteSurface: { surfaceId: "main" },
138
+ },
139
+ { version: "v1.0", updateComponents: null },
140
+ { version: "v1.0", deleteSurface: {} },
141
+ ]);
142
+
143
+ expect(malformed.state.size).toBe(0);
144
+ expect(malformed.warnings).toHaveLength(7);
145
+ });
146
+ });