@assistant-ui/react-generative-ui 0.0.16 → 0.0.18
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.
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +11 -2
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/a2ui/reducer.d.ts.map +1 -1
- package/dist/a2ui/reducer.js +63 -10
- package/dist/a2ui/reducer.js.map +1 -1
- package/dist/buildPresentParameters.js +4 -3
- package/dist/buildPresentParameters.js.map +1 -1
- package/dist/convert/boundSpec.d.ts +5 -1
- package/dist/convert/boundSpec.d.ts.map +1 -1
- package/dist/convert/boundSpec.js +13 -4
- package/dist/convert/boundSpec.js.map +1 -1
- package/dist/renderGenerativeUI.js +1 -1
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.js +2 -5
- package/dist/teams/toTeamsAttachments.js.map +1 -1
- package/dist/vocabulary/alert.d.ts +1 -1
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/alert.js +21 -16
- package/dist/vocabulary/alert.js.map +1 -1
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/data.js +20 -12
- package/dist/vocabulary/data.js.map +1 -1
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/fact.js +3 -2
- package/dist/vocabulary/fact.js.map +1 -1
- package/dist/vocabulary/interactive.d.ts +6 -5
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +28 -23
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/layout.js +7 -5
- package/dist/vocabulary/layout.js.map +1 -1
- package/dist/vocabulary/text.d.ts.map +1 -1
- package/dist/vocabulary/text.js +4 -3
- package/dist/vocabulary/text.js.map +1 -1
- package/dist/vocabulary/toTextContent.d.ts +5 -0
- package/dist/vocabulary/toTextContent.d.ts.map +1 -0
- package/dist/vocabulary/toTextContent.js +6 -0
- package/dist/vocabulary/toTextContent.js.map +1 -0
- package/package.json +5 -5
- package/src/a2ui/convert.test.ts +36 -0
- package/src/a2ui/convert.ts +21 -2
- package/src/a2ui/reducer.test.ts +223 -0
- package/src/a2ui/reducer.ts +76 -12
- package/src/buildPresentParameters.ts +7 -4
- package/src/convert/boundSpec.test.ts +47 -2
- package/src/convert/boundSpec.ts +12 -14
- package/src/renderGenerativeUI.test.tsx +57 -0
- package/src/renderGenerativeUI.tsx +3 -1
- package/src/teams/toTeamsAttachments.ts +2 -9
- package/src/vocabulary/alert.test.tsx +10 -0
- package/src/vocabulary/alert.tsx +16 -7
- package/src/vocabulary/data.test.tsx +40 -0
- package/src/vocabulary/data.tsx +47 -26
- package/src/vocabulary/fact.test.tsx +12 -0
- package/src/vocabulary/fact.tsx +3 -2
- package/src/vocabulary/interactive.test.tsx +41 -0
- package/src/vocabulary/interactive.tsx +42 -28
- package/src/vocabulary/layout.test.tsx +16 -0
- package/src/vocabulary/layout.tsx +8 -4
- package/src/vocabulary/text.test.tsx +29 -0
- package/src/vocabulary/text.tsx +4 -3
- package/src/vocabulary/toTextContent.ts +2 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"text.d.ts","names":[],"sources":["../../src/vocabulary/text.tsx"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"text.d.ts","names":[],"sources":["../../src/vocabulary/text.tsx"],"mappings":";;cAKa"}
|
package/dist/vocabulary/text.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { COLORS, TEXT_SIZES, WEIGHTS } from "../ir.js";
|
|
2
|
+
import { toTextContent } from "./toTextContent.js";
|
|
2
3
|
import { jsxs } from "react/jsx-runtime";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
//#region src/vocabulary/text.tsx
|
|
@@ -12,7 +13,7 @@ const textVocabulary = {
|
|
|
12
13
|
render: ({ text, size, children }) => /* @__PURE__ */ jsxs("h2", {
|
|
13
14
|
"data-aui": "header",
|
|
14
15
|
"data-aui-size": size ?? "lg",
|
|
15
|
-
children: [text, children]
|
|
16
|
+
children: [toTextContent(text), children]
|
|
16
17
|
})
|
|
17
18
|
},
|
|
18
19
|
Text: {
|
|
@@ -29,7 +30,7 @@ const textVocabulary = {
|
|
|
29
30
|
"data-aui-size": size ?? "md",
|
|
30
31
|
"data-aui-weight": weight,
|
|
31
32
|
"data-aui-color": color,
|
|
32
|
-
children: [value, children]
|
|
33
|
+
children: [toTextContent(value), children]
|
|
33
34
|
})
|
|
34
35
|
},
|
|
35
36
|
Caption: {
|
|
@@ -38,7 +39,7 @@ const textVocabulary = {
|
|
|
38
39
|
streamProperties: true,
|
|
39
40
|
render: ({ value, children }) => /* @__PURE__ */ jsxs("p", {
|
|
40
41
|
"data-aui": "caption",
|
|
41
|
-
children: [value, children]
|
|
42
|
+
children: [toTextContent(value), children]
|
|
42
43
|
})
|
|
43
44
|
}
|
|
44
45
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"text.js","names":[],"sources":["../../src/vocabulary/text.tsx"],"sourcesContent":["import { z } from \"zod\";\nimport type { GenerativeUILibrary } from \"../types\";\nimport { COLORS, TEXT_SIZES, WEIGHTS } from \"../ir\";\n\nexport const textVocabulary = {\n Header: {\n description: \"A section heading. Renders as a bold title.\",\n properties: z.object({\n text: z.string().describe(\"The heading text.\"),\n size: z\n .enum(TEXT_SIZES)\n .optional()\n .describe(\"Heading size; defaults to `lg`.\"),\n }),\n render: ({ text, size, children }) => (\n <h2 data-aui=\"header\" data-aui-size={size ?? \"lg\"}>\n {text}\n {children}\n </h2>\n ),\n },\n Text: {\n description: \"A run of text. Use for paragraphs, labels, or inline copy.\",\n properties: z.object({\n value: z.string().describe(\"The text content.\"),\n size: z\n .enum(TEXT_SIZES)\n .optional()\n .describe(\"Text size; defaults to `md`.\"),\n weight: z.enum(WEIGHTS).optional().describe(\"Font weight.\"),\n color: z.enum(COLORS).optional().describe(\"Foreground color token.\"),\n }),\n streamProperties: true,\n render: ({ value, size, weight, color, children }) => (\n <span\n data-aui=\"text\"\n data-aui-size={size ?? \"md\"}\n data-aui-weight={weight}\n data-aui-color={color}\n >\n {value}\n {children}\n </span>\n ),\n },\n Caption: {\n description: \"Secondary, de-emphasized text under a primary element.\",\n properties: z.object({\n value: z.string().describe(\"The caption text.\"),\n }),\n streamProperties: true,\n render: ({ value, children }) => (\n <p data-aui=\"caption\">\n {value}\n {children}\n </p>\n ),\n },\n} satisfies GenerativeUILibrary;\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"text.js","names":[],"sources":["../../src/vocabulary/text.tsx"],"sourcesContent":["import { z } from \"zod\";\nimport type { GenerativeUILibrary } from \"../types\";\nimport { COLORS, TEXT_SIZES, WEIGHTS } from \"../ir\";\nimport { toTextContent } from \"./toTextContent\";\n\nexport const textVocabulary = {\n Header: {\n description: \"A section heading. Renders as a bold title.\",\n properties: z.object({\n text: z.string().describe(\"The heading text.\"),\n size: z\n .enum(TEXT_SIZES)\n .optional()\n .describe(\"Heading size; defaults to `lg`.\"),\n }),\n render: ({ text, size, children }) => (\n <h2 data-aui=\"header\" data-aui-size={size ?? \"lg\"}>\n {toTextContent(text)}\n {children}\n </h2>\n ),\n },\n Text: {\n description: \"A run of text. Use for paragraphs, labels, or inline copy.\",\n properties: z.object({\n value: z.string().describe(\"The text content.\"),\n size: z\n .enum(TEXT_SIZES)\n .optional()\n .describe(\"Text size; defaults to `md`.\"),\n weight: z.enum(WEIGHTS).optional().describe(\"Font weight.\"),\n color: z.enum(COLORS).optional().describe(\"Foreground color token.\"),\n }),\n streamProperties: true,\n render: ({ value, size, weight, color, children }) => (\n <span\n data-aui=\"text\"\n data-aui-size={size ?? \"md\"}\n data-aui-weight={weight}\n data-aui-color={color}\n >\n {toTextContent(value)}\n {children}\n </span>\n ),\n },\n Caption: {\n description: \"Secondary, de-emphasized text under a primary element.\",\n properties: z.object({\n value: z.string().describe(\"The caption text.\"),\n }),\n streamProperties: true,\n render: ({ value, children }) => (\n <p data-aui=\"caption\">\n {toTextContent(value)}\n {children}\n </p>\n ),\n },\n} satisfies GenerativeUILibrary;\n"],"mappings":";;;;;AAKA,MAAa,iBAAiB;CAC5B,QAAQ;EACN,aAAa;EACb,YAAY,EAAE,OAAO;GACnB,MAAM,EAAE,OAAO,CAAC,CAAC,SAAS,mBAAmB;GAC7C,MAAM,EACH,KAAK,UAAU,CAAC,CAChB,SAAS,CAAC,CACV,SAAS,iCAAiC;EAC/C,CAAC;EACD,SAAS,EAAE,MAAM,MAAM,eACrB,qBAAC,MAAD;GAAI,YAAS;GAAS,iBAAe,QAAQ;GAA7C,UAAA,CACG,cAAc,IAAI,GAClB,QACC;;CAER;CACA,MAAM;EACJ,aAAa;EACb,YAAY,EAAE,OAAO;GACnB,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS,mBAAmB;GAC9C,MAAM,EACH,KAAK,UAAU,CAAC,CAChB,SAAS,CAAC,CACV,SAAS,8BAA8B;GAC1C,QAAQ,EAAE,KAAK,OAAO,CAAC,CAAC,SAAS,CAAC,CAAC,SAAS,cAAc;GAC1D,OAAO,EAAE,KAAK,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC,SAAS,yBAAyB;EACrE,CAAC;EACD,kBAAkB;EAClB,SAAS,EAAE,OAAO,MAAM,QAAQ,OAAO,eACrC,qBAAC,QAAD;GACE,YAAS;GACT,iBAAe,QAAQ;GACvB,mBAAiB;GACjB,kBAAgB;GAJlB,UAAA,CAMG,cAAc,KAAK,GACnB,QACG;;CAEV;CACA,SAAS;EACP,aAAa;EACb,YAAY,EAAE,OAAO,EACnB,OAAO,EAAE,OAAO,CAAC,CAAC,SAAS,mBAAmB,EAChD,CAAC;EACD,kBAAkB;EAClB,SAAS,EAAE,OAAO,eAChB,qBAAC,KAAD;GAAG,YAAS;GAAZ,UAAA,CACG,cAAc,KAAK,GACnB,QACA;;CAEP;AACF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"toTextContent.d.ts","names":[],"sources":["../../src/vocabulary/toTextContent.ts"],"mappings":";cAAa,gBAAiB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"toTextContent.js","names":[],"sources":["../../src/vocabulary/toTextContent.ts"],"sourcesContent":["export const toTextContent = (value: unknown): string | number | null =>\n typeof value === \"string\" || typeof value === \"number\" ? value : null;\n"],"mappings":";AAAA,MAAa,iBAAiB,UAC5B,OAAO,UAAU,YAAY,OAAO,UAAU,WAAW,QAAQ"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/react-generative-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"description": "Generative UI components for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"generative-ui",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
],
|
|
51
51
|
"sideEffects": false,
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"assistant-stream": "^0.3.
|
|
53
|
+
"assistant-stream": "^0.3.42"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@assistant-ui/react": "^0.15.0",
|
|
@@ -64,14 +64,14 @@
|
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@assistant-ui/react": "0.15.
|
|
68
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
67
|
+
"@assistant-ui/react": "0.15.19",
|
|
68
|
+
"@assistant-ui/x-buildutils": "0.0.26",
|
|
69
69
|
"@types/json-schema": "^7.0.15",
|
|
70
70
|
"@types/react": "^19.2.18",
|
|
71
71
|
"react": "^19.2.8",
|
|
72
72
|
"react-dom": "^19.2.8",
|
|
73
73
|
"vitest": "^4.1.11",
|
|
74
|
-
"zod": "^4.4
|
|
74
|
+
"zod": "^4.5.4"
|
|
75
75
|
},
|
|
76
76
|
"publishConfig": {
|
|
77
77
|
"access": "public",
|
package/src/a2ui/convert.test.ts
CHANGED
|
@@ -201,6 +201,42 @@ describe("convertSurfaceToUISpec", () => {
|
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
it("preserves prototype-named action context fields", () => {
|
|
205
|
+
const context = JSON.parse(
|
|
206
|
+
'{"__proto__":{"admin":true},"literal":"kept"}',
|
|
207
|
+
) as Record<string, unknown>;
|
|
208
|
+
const surface = surfaceFrom([
|
|
209
|
+
{
|
|
210
|
+
id: "root",
|
|
211
|
+
component: "Button",
|
|
212
|
+
label: "Run",
|
|
213
|
+
action: { name: "run", context },
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
const result = convertSurfaceToUISpec(surface);
|
|
218
|
+
const convertedContext = (result.spec as UIElement).$action
|
|
219
|
+
?.context as Record<string, unknown>;
|
|
220
|
+
|
|
221
|
+
expect(Object.getPrototypeOf(convertedContext)).toBe(Object.prototype);
|
|
222
|
+
expect(Object.hasOwn(convertedContext, "__proto__")).toBe(true);
|
|
223
|
+
expect(convertedContext["__proto__"]).toEqual({ admin: true });
|
|
224
|
+
expect(JSON.stringify(convertedContext)).toBe(
|
|
225
|
+
'{"__proto__":{"admin":true},"literal":"kept"}',
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("does not read component fields through a prototype-named prop", () => {
|
|
230
|
+
const component = JSON.parse(
|
|
231
|
+
'{"id":"root","component":"Text","__proto__":{"text":"injected"}}',
|
|
232
|
+
) as Record<string, unknown>;
|
|
233
|
+
|
|
234
|
+
expect(convertSurfaceToUISpec(surfaceFrom([component]))).toEqual({
|
|
235
|
+
spec: { $type: "Markdown" },
|
|
236
|
+
warnings: [],
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
204
240
|
it("expands template children relative to each bound item", () => {
|
|
205
241
|
const surface = surfaceFrom(
|
|
206
242
|
[
|
package/src/a2ui/convert.ts
CHANGED
|
@@ -30,6 +30,23 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
|
30
30
|
return prototype === Object.prototype || prototype === null;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
const setOwnProperty = (
|
|
34
|
+
target: Record<string, unknown>,
|
|
35
|
+
key: string,
|
|
36
|
+
value: unknown,
|
|
37
|
+
) => {
|
|
38
|
+
if (key === "__proto__") {
|
|
39
|
+
Object.defineProperty(target, key, {
|
|
40
|
+
value,
|
|
41
|
+
enumerable: true,
|
|
42
|
+
configurable: true,
|
|
43
|
+
writable: true,
|
|
44
|
+
});
|
|
45
|
+
} else {
|
|
46
|
+
target[key] = value;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
33
50
|
const isBinding = (value: unknown): value is { readonly path: string } =>
|
|
34
51
|
isPlainObject(value) &&
|
|
35
52
|
Object.keys(value).length === 1 &&
|
|
@@ -90,7 +107,9 @@ const materialize = (value: unknown, source: unknown): unknown => {
|
|
|
90
107
|
const result: Record<string, unknown> = {};
|
|
91
108
|
for (const [key, entry] of Object.entries(value)) {
|
|
92
109
|
const resolved = materialize(entry, source);
|
|
93
|
-
if (resolved !== undefined)
|
|
110
|
+
if (resolved !== undefined) {
|
|
111
|
+
setOwnProperty(result, key, resolved);
|
|
112
|
+
}
|
|
94
113
|
}
|
|
95
114
|
return result;
|
|
96
115
|
};
|
|
@@ -419,7 +438,7 @@ function convertComponent(
|
|
|
419
438
|
for (const [key, value] of Object.entries(node)) {
|
|
420
439
|
if (key === "id" || key === "component" || key === "children") continue;
|
|
421
440
|
const resolved = materialize(value, dataSource);
|
|
422
|
-
if (resolved !== undefined) props
|
|
441
|
+
if (resolved !== undefined) setOwnProperty(props, key, resolved);
|
|
423
442
|
}
|
|
424
443
|
const mapped = mappedProps(node, props, dataSource, context);
|
|
425
444
|
if (!mapped) return null;
|
package/src/a2ui/reducer.test.ts
CHANGED
|
@@ -82,6 +82,229 @@ describe("applyA2uiOperations", () => {
|
|
|
82
82
|
});
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
it.each(["10001", "4294967294"])(
|
|
86
|
+
"rejects array pointer index %s beyond the auto-vivification limit",
|
|
87
|
+
(index) => {
|
|
88
|
+
const dataModel = { items: [] };
|
|
89
|
+
const state: A2uiState = new Map([
|
|
90
|
+
["main", { components: new Map(), dataModel }],
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const result = applyA2uiOperations(state, [
|
|
94
|
+
{
|
|
95
|
+
version: "v1.0",
|
|
96
|
+
updateDataModel: {
|
|
97
|
+
surfaceId: "main",
|
|
98
|
+
path: `/items/${index}`,
|
|
99
|
+
value: "unsafe",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
expect(result.warnings).toEqual([
|
|
105
|
+
"Operation at index 0 has an invalid JSON Pointer path.",
|
|
106
|
+
]);
|
|
107
|
+
expect(result.state.get("main")?.dataModel).toBe(dataModel);
|
|
108
|
+
expect(dataModel.items).toHaveLength(0);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
it("allows bounded expansion, existing large indices, and appends", () => {
|
|
113
|
+
const items = new Array<string | undefined>(10_002);
|
|
114
|
+
items[10_001] = "existing";
|
|
115
|
+
const state: A2uiState = new Map([
|
|
116
|
+
["main", { components: new Map(), dataModel: { bounded: [], items } }],
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
const result = applyA2uiOperations(state, [
|
|
120
|
+
{
|
|
121
|
+
version: "v1.0",
|
|
122
|
+
updateDataModel: {
|
|
123
|
+
surfaceId: "main",
|
|
124
|
+
path: "/bounded/10000",
|
|
125
|
+
value: "limit",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
version: "v1.0",
|
|
130
|
+
updateDataModel: {
|
|
131
|
+
surfaceId: "main",
|
|
132
|
+
path: "/items/10001",
|
|
133
|
+
value: "updated",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
version: "v1.0",
|
|
138
|
+
updateDataModel: {
|
|
139
|
+
surfaceId: "main",
|
|
140
|
+
path: "/items/-",
|
|
141
|
+
value: "appended",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
const resultModel = result.state.get("main")!.dataModel as {
|
|
147
|
+
bounded: string[];
|
|
148
|
+
items: string[];
|
|
149
|
+
};
|
|
150
|
+
expect(result.warnings).toEqual([]);
|
|
151
|
+
expect(resultModel.bounded).toHaveLength(10_001);
|
|
152
|
+
expect(resultModel.bounded[10_000]).toBe("limit");
|
|
153
|
+
expect(resultModel.items).toHaveLength(10_003);
|
|
154
|
+
expect(resultModel.items[10_001]).toBe("updated");
|
|
155
|
+
expect(resultModel.items[10_002]).toBe("appended");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("stores prototype-named JSON Pointer segments as own properties", () => {
|
|
159
|
+
const state: A2uiState = new Map([
|
|
160
|
+
["main", { components: new Map(), dataModel: {} }],
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const result = applyA2uiOperations(state, [
|
|
164
|
+
{
|
|
165
|
+
version: "v1.0",
|
|
166
|
+
updateDataModel: {
|
|
167
|
+
surfaceId: "main",
|
|
168
|
+
path: "/__proto__/admin",
|
|
169
|
+
value: true,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
const resultModel = result.state.get("main")?.dataModel as Record<
|
|
175
|
+
string,
|
|
176
|
+
unknown
|
|
177
|
+
>;
|
|
178
|
+
expect(result.warnings).toEqual([]);
|
|
179
|
+
expect(Object.getPrototypeOf(resultModel)).toBe(Object.prototype);
|
|
180
|
+
expect(Object.hasOwn(resultModel, "__proto__")).toBe(true);
|
|
181
|
+
expect(resultModel["__proto__"]).toEqual({ admin: true });
|
|
182
|
+
expect(JSON.stringify(resultModel)).toBe('{"__proto__":{"admin":true}}');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("deletes object properties updated to null", () => {
|
|
186
|
+
const dataModel = {
|
|
187
|
+
profile: { name: "Ada", role: "admin" },
|
|
188
|
+
tags: ["first", "second"],
|
|
189
|
+
};
|
|
190
|
+
const state: A2uiState = new Map([
|
|
191
|
+
["main", { components: new Map(), dataModel }],
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const result = applyA2uiOperations(state, [
|
|
195
|
+
{
|
|
196
|
+
version: "v1.0",
|
|
197
|
+
updateDataModel: {
|
|
198
|
+
surfaceId: "main",
|
|
199
|
+
path: "/profile/name",
|
|
200
|
+
value: null,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
version: "v1.0",
|
|
205
|
+
updateDataModel: {
|
|
206
|
+
surfaceId: "main",
|
|
207
|
+
path: "/tags/1",
|
|
208
|
+
value: null,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
expect(result.warnings).toEqual([]);
|
|
214
|
+
const resultModel = result.state.get("main")?.dataModel as typeof dataModel;
|
|
215
|
+
expect(resultModel.profile).toEqual({ role: "admin" });
|
|
216
|
+
expect(resultModel.tags).toHaveLength(2);
|
|
217
|
+
expect(resultModel.tags[0]).toBe("first");
|
|
218
|
+
expect(Object.prototype.hasOwnProperty.call(resultModel.tags, 1)).toBe(
|
|
219
|
+
false,
|
|
220
|
+
);
|
|
221
|
+
expect(dataModel).toEqual({
|
|
222
|
+
profile: { name: "Ada", role: "admin" },
|
|
223
|
+
tags: ["first", "second"],
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("preserves null values in v0.9 data model updates", () => {
|
|
228
|
+
const state: A2uiState = new Map([
|
|
229
|
+
[
|
|
230
|
+
"main",
|
|
231
|
+
{ components: new Map(), dataModel: { profile: { name: "Ada" } } },
|
|
232
|
+
],
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
const result = applyA2uiOperations(state, [
|
|
236
|
+
{
|
|
237
|
+
version: "v0.9",
|
|
238
|
+
updateDataModel: {
|
|
239
|
+
surfaceId: "main",
|
|
240
|
+
path: "/profile/name",
|
|
241
|
+
value: null,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
expect(result.warnings).toEqual([]);
|
|
247
|
+
expect(result.state.get("main")?.dataModel).toEqual({
|
|
248
|
+
profile: { name: null },
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("does not create missing paths for null updates", () => {
|
|
253
|
+
const dataModel = { profile: { name: "Ada" }, tags: ["first", "second"] };
|
|
254
|
+
const state: A2uiState = new Map([
|
|
255
|
+
["main", { components: new Map(), dataModel }],
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
const result = applyA2uiOperations(state, [
|
|
259
|
+
{
|
|
260
|
+
version: "v1.0",
|
|
261
|
+
updateDataModel: {
|
|
262
|
+
surfaceId: "main",
|
|
263
|
+
path: "/missing/value",
|
|
264
|
+
value: null,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
version: "v1.0",
|
|
269
|
+
updateDataModel: {
|
|
270
|
+
surfaceId: "main",
|
|
271
|
+
path: "/tags/-",
|
|
272
|
+
value: null,
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
version: "v1.0",
|
|
277
|
+
updateDataModel: {
|
|
278
|
+
surfaceId: "main",
|
|
279
|
+
path: "/tags/7",
|
|
280
|
+
value: null,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
]);
|
|
284
|
+
|
|
285
|
+
expect(result.warnings).toEqual([]);
|
|
286
|
+
expect(result.state.get("main")?.dataModel).toBe(dataModel);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("allows null to replace the data model root", () => {
|
|
290
|
+
const state: A2uiState = new Map([
|
|
291
|
+
[
|
|
292
|
+
"main",
|
|
293
|
+
{ components: new Map(), dataModel: { profile: { name: "Ada" } } },
|
|
294
|
+
],
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
const result = applyA2uiOperations(state, [
|
|
298
|
+
{
|
|
299
|
+
version: "v1.0",
|
|
300
|
+
updateDataModel: { surfaceId: "main", value: null },
|
|
301
|
+
},
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
expect(result.warnings).toEqual([]);
|
|
305
|
+
expect(result.state.get("main")?.dataModel).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
85
308
|
it("clones the touched surface and component map", () => {
|
|
86
309
|
const components = new Map<string, Record<string, unknown>>([
|
|
87
310
|
["root", { id: "root", component: "Divider" }],
|
package/src/a2ui/reducer.ts
CHANGED
|
@@ -11,6 +11,9 @@ const OPERATION_KEYS = new Set([
|
|
|
11
11
|
"updateDataModel",
|
|
12
12
|
"deleteSurface",
|
|
13
13
|
]);
|
|
14
|
+
// This defensive ceiling is well above the renderer's displayed-item limit.
|
|
15
|
+
const MAX_AUTO_VIVIFY_ARRAY_INDEX = 10_000;
|
|
16
|
+
const INVALID_POINTER = Symbol("invalidPointer");
|
|
14
17
|
|
|
15
18
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
16
19
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -104,30 +107,82 @@ const setAtPointer = (
|
|
|
104
107
|
model: unknown,
|
|
105
108
|
path: string,
|
|
106
109
|
value: unknown,
|
|
110
|
+
nullDeletes: boolean,
|
|
107
111
|
): { readonly ok: boolean; readonly value: unknown } => {
|
|
108
112
|
const segments = decodePointer(path);
|
|
109
113
|
if (!segments) return { ok: false, value: model };
|
|
110
114
|
if (segments.length === 0) return { ok: true, value };
|
|
111
115
|
|
|
116
|
+
const remove = (current: unknown, index: number): unknown => {
|
|
117
|
+
const segment = segments[index]!;
|
|
118
|
+
const isLast = index === segments.length - 1;
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(current)) {
|
|
121
|
+
if (segment !== "-" && !isArrayIndex(segment)) return current;
|
|
122
|
+
const targetIndex = segment === "-" ? current.length : Number(segment);
|
|
123
|
+
if (isLast) {
|
|
124
|
+
if (
|
|
125
|
+
segment === "-" ||
|
|
126
|
+
!Object.prototype.hasOwnProperty.call(current, targetIndex)
|
|
127
|
+
) {
|
|
128
|
+
return current;
|
|
129
|
+
}
|
|
130
|
+
const clone = current.slice();
|
|
131
|
+
// Preserve indices referenced by other JSON Pointers.
|
|
132
|
+
delete clone[targetIndex];
|
|
133
|
+
return clone;
|
|
134
|
+
}
|
|
135
|
+
const child = current[targetIndex];
|
|
136
|
+
if (!Array.isArray(child) && !isRecord(child)) return current;
|
|
137
|
+
const next = remove(child, index + 1);
|
|
138
|
+
if (next === child) return current;
|
|
139
|
+
const clone = current.slice();
|
|
140
|
+
clone[targetIndex] = next;
|
|
141
|
+
return clone;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!isRecord(current)) return current;
|
|
145
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment)) return current;
|
|
146
|
+
if (isLast) {
|
|
147
|
+
const clone = { ...current };
|
|
148
|
+
delete clone[segment];
|
|
149
|
+
return clone;
|
|
150
|
+
}
|
|
151
|
+
const child = current[segment];
|
|
152
|
+
if (!Array.isArray(child) && !isRecord(child)) return current;
|
|
153
|
+
const next = remove(child, index + 1);
|
|
154
|
+
if (next === child) return current;
|
|
155
|
+
return { ...current, [segment]: next };
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (nullDeletes && value === null) {
|
|
159
|
+
return { ok: true, value: remove(model, 0) };
|
|
160
|
+
}
|
|
161
|
+
|
|
112
162
|
const update = (current: unknown, index: number): unknown => {
|
|
113
163
|
const segment = segments[index]!;
|
|
114
164
|
const isLast = index === segments.length - 1;
|
|
115
165
|
|
|
116
166
|
if (Array.isArray(current)) {
|
|
117
167
|
if (segment !== "-" && !isArrayIndex(segment)) return current;
|
|
168
|
+
const targetIndex = segment === "-" ? current.length : Number(segment);
|
|
169
|
+
if (
|
|
170
|
+
segment !== "-" &&
|
|
171
|
+
targetIndex >= current.length &&
|
|
172
|
+
targetIndex > MAX_AUTO_VIVIFY_ARRAY_INDEX
|
|
173
|
+
) {
|
|
174
|
+
return INVALID_POINTER;
|
|
175
|
+
}
|
|
176
|
+
const next = isLast ? value : update(current[targetIndex], index + 1);
|
|
177
|
+
if (next === INVALID_POINTER) return INVALID_POINTER;
|
|
118
178
|
const clone = current.slice();
|
|
119
|
-
|
|
120
|
-
clone[targetIndex] = isLast
|
|
121
|
-
? value
|
|
122
|
-
: update(clone[targetIndex], index + 1);
|
|
179
|
+
clone[targetIndex] = next;
|
|
123
180
|
return clone;
|
|
124
181
|
}
|
|
125
182
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const child = clone[segment];
|
|
130
|
-
clone[segment] = isLast
|
|
183
|
+
const record: Record<string, unknown> = isRecord(current) ? current : {};
|
|
184
|
+
const child = Object.hasOwn(record, segment) ? record[segment] : undefined;
|
|
185
|
+
const next = isLast
|
|
131
186
|
? value
|
|
132
187
|
: update(
|
|
133
188
|
child ??
|
|
@@ -137,10 +192,14 @@ const setAtPointer = (
|
|
|
137
192
|
: {}),
|
|
138
193
|
index + 1,
|
|
139
194
|
);
|
|
140
|
-
return
|
|
195
|
+
if (next === INVALID_POINTER) return INVALID_POINTER;
|
|
196
|
+
return { ...record, [segment]: next };
|
|
141
197
|
};
|
|
142
198
|
|
|
143
|
-
|
|
199
|
+
const result = update(model, 0);
|
|
200
|
+
return result === INVALID_POINTER
|
|
201
|
+
? { ok: false, value: model }
|
|
202
|
+
: { ok: true, value: result };
|
|
144
203
|
};
|
|
145
204
|
|
|
146
205
|
const dataModelValue = (
|
|
@@ -264,7 +323,12 @@ export function applyA2uiOperations(
|
|
|
264
323
|
);
|
|
265
324
|
continue;
|
|
266
325
|
}
|
|
267
|
-
const result = setAtPointer(
|
|
326
|
+
const result = setAtPointer(
|
|
327
|
+
surface.dataModel,
|
|
328
|
+
path,
|
|
329
|
+
update.value,
|
|
330
|
+
version === "v1.0",
|
|
331
|
+
);
|
|
268
332
|
if (!result.ok) {
|
|
269
333
|
warnings.push(
|
|
270
334
|
`Operation at index ${index} has an invalid JSON Pointer path.`,
|
|
@@ -29,7 +29,7 @@ export function buildPresentParameters(
|
|
|
29
29
|
// `children` are framework-reserved (see ir.ts), so drop any author-declared
|
|
30
30
|
// copies. On a name clash the first component's schema wins — props are an
|
|
31
31
|
// advisory hint here, not a strict per-component contract.
|
|
32
|
-
const props
|
|
32
|
+
const props = new Map<string, JSONSchema7Definition>();
|
|
33
33
|
const propOwners = new Map<string, string[]>();
|
|
34
34
|
for (const name of names) {
|
|
35
35
|
const propsSchema = toJSONSchema(library[name]!.properties);
|
|
@@ -41,8 +41,11 @@ export function buildPresentParameters(
|
|
|
41
41
|
}
|
|
42
42
|
for (const [key, schema] of Object.entries(propsSchema.properties ?? {})) {
|
|
43
43
|
if (key.startsWith("$") || key === "children") continue;
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// secure-json-parse rejects the whole tool-argument payload on this key,
|
|
45
|
+
// so advertising it would cost the model the node rather than one prop.
|
|
46
|
+
if (key === "__proto__") continue;
|
|
47
|
+
if (!props.has(key)) {
|
|
48
|
+
props.set(key, schema);
|
|
46
49
|
}
|
|
47
50
|
propOwners.set(key, [...(propOwners.get(key) ?? []), name]);
|
|
48
51
|
}
|
|
@@ -78,7 +81,7 @@ export function buildPresentParameters(
|
|
|
78
81
|
"Stable identity for this UI node. Use it for list items that may reorder.",
|
|
79
82
|
anyOf: [{ type: "string" }, { type: "number" }],
|
|
80
83
|
},
|
|
81
|
-
...props,
|
|
84
|
+
...Object.fromEntries(props),
|
|
82
85
|
children: { $ref: "#/$defs/children" },
|
|
83
86
|
},
|
|
84
87
|
required: [TYPE_KEY],
|
|
@@ -20,10 +20,12 @@ const nest = (levels: number): unknown =>
|
|
|
20
20
|
: { type: "Box", children: [nest(levels - 1)] };
|
|
21
21
|
|
|
22
22
|
describe("boundSpec", () => {
|
|
23
|
-
it("
|
|
23
|
+
it("copies an object without children and passes primitives through", () => {
|
|
24
24
|
const leaf = { type: "Text", props: { value: "hi" } };
|
|
25
|
+
const { reasons, result } = walk(leaf);
|
|
25
26
|
|
|
26
|
-
expect(
|
|
27
|
+
expect({ reasons, result }).toEqual({ reasons: [], result: leaf });
|
|
28
|
+
expect(result).not.toBe(leaf);
|
|
27
29
|
expect(walk("text").result).toBe("text");
|
|
28
30
|
expect(walk(null).result).toBe(null);
|
|
29
31
|
});
|
|
@@ -86,6 +88,49 @@ describe("boundSpec", () => {
|
|
|
86
88
|
expect(result).toEqual([{ type: "Text" }, { type: "Text" }]);
|
|
87
89
|
});
|
|
88
90
|
|
|
91
|
+
it("bounds a record that hides its children behind a has trap", () => {
|
|
92
|
+
const hostileChildren = new Proxy([{ type: "Text" }], {
|
|
93
|
+
get: (target, prop, receiver) =>
|
|
94
|
+
prop === "length"
|
|
95
|
+
? Number.MAX_SAFE_INTEGER
|
|
96
|
+
: Reflect.get(target, prop, receiver),
|
|
97
|
+
});
|
|
98
|
+
const hostile = new Proxy(
|
|
99
|
+
{ type: "Box", children: hostileChildren } as Record<string, unknown>,
|
|
100
|
+
{
|
|
101
|
+
has: (target, prop) => prop !== "children" && Reflect.has(target, prop),
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const { reasons, result } = walk(hostile);
|
|
106
|
+
|
|
107
|
+
expect(reasons).toEqual(["children"]);
|
|
108
|
+
expect((result as { children: unknown[] }).children).toHaveLength(
|
|
109
|
+
CHILDREN_CAP,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("bounds a record that answers a later read of children differently", () => {
|
|
114
|
+
let reads = 0;
|
|
115
|
+
const hostile = new Proxy(
|
|
116
|
+
{ type: "Box", children: undefined } as Record<string, unknown>,
|
|
117
|
+
{
|
|
118
|
+
get: (target, prop, receiver) => {
|
|
119
|
+
if (prop !== "children") return Reflect.get(target, prop, receiver);
|
|
120
|
+
reads += 1;
|
|
121
|
+
return reads === 1
|
|
122
|
+
? undefined
|
|
123
|
+
: Array(CHILDREN_CAP * 2).fill({ type: "Text" });
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const { reasons, result } = walk(hostile);
|
|
129
|
+
|
|
130
|
+
expect(reasons).toEqual([]);
|
|
131
|
+
expect((result as { children: unknown }).children).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
89
134
|
it("accepts nesting exactly at the element-depth ceiling", () => {
|
|
90
135
|
expect(walk(nest(MAX_ELEMENT_DEPTH)).reasons).toEqual([]);
|
|
91
136
|
});
|