@assistant-ui/react-generative-ui 0.0.19 → 0.0.21
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/JSONGenerativeUI.client.d.ts +14 -15
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.client.js +28 -8
- package/dist/JSONGenerativeUI.client.js.map +1 -1
- package/dist/JSONGenerativeUI.server.d.ts +5 -7
- package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.d.ts +35 -36
- package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
- package/dist/a2ui/convert.d.ts +7 -7
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +148 -28
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/a2ui/reducer.d.ts +1 -3
- package/dist/a2ui/reducer.d.ts.map +1 -1
- package/dist/a2ui/reducer.js +8 -0
- package/dist/a2ui/reducer.js.map +1 -1
- package/dist/a2ui/snapshot.d.ts +3 -0
- package/dist/a2ui/snapshot.d.ts.map +1 -0
- package/dist/a2ui/snapshot.js +33 -0
- package/dist/a2ui/snapshot.js.map +1 -0
- package/dist/a2ui/types.d.ts +50 -40
- package/dist/a2ui/types.d.ts.map +1 -1
- package/dist/a2ui/types.js.map +1 -1
- package/dist/a2ui.d.ts +5 -4
- package/dist/a2ui.d.ts.map +1 -0
- package/dist/a2ui.js +2 -1
- package/dist/actionRegistry.d.ts +12 -14
- package/dist/actionRegistry.d.ts.map +1 -1
- package/dist/actionRegistry.js.map +1 -1
- package/dist/buildPresentParameters.d.ts +2 -4
- package/dist/buildPresentParameters.d.ts.map +1 -1
- package/dist/buildPresentParameters.js +16 -4
- package/dist/buildPresentParameters.js.map +1 -1
- package/dist/constants.d.ts +0 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/convert/boundSpec.d.ts +0 -2
- package/dist/convert/boundSpec.d.ts.map +1 -1
- package/dist/convert/copyBounded.d.ts +2 -4
- package/dist/convert/copyBounded.d.ts.map +1 -1
- package/dist/convert/isElement.d.ts +1 -3
- package/dist/convert/isElement.d.ts.map +1 -1
- package/dist/convert/takeRun.d.ts +3 -5
- package/dist/convert/takeRun.d.ts.map +1 -1
- package/dist/defineGenerativeComponents.d.ts +1 -3
- package/dist/defineGenerativeComponents.d.ts.map +1 -1
- package/dist/generativeUIToJSX.d.ts +4 -6
- package/dist/generativeUIToJSX.d.ts.map +1 -1
- package/dist/index.d.ts +3 -13
- package/dist/index.d.ts.map +1 -0
- package/dist/index.server.d.ts +3 -13
- package/dist/index.server.d.ts.map +1 -0
- package/dist/index.shared.d.ts +15 -11
- package/dist/index.shared.d.ts.map +1 -0
- package/dist/ir.d.ts +17 -19
- package/dist/ir.d.ts.map +1 -1
- package/dist/renderGenerativeUI.d.ts +2 -4
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/scopeSchema.d.ts +6 -0
- package/dist/scopeSchema.d.ts.map +1 -0
- package/dist/scopeSchema.js +56 -0
- package/dist/scopeSchema.js.map +1 -0
- package/dist/slack/constants.d.ts +8 -9
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/decodeBlockAction.d.ts +1 -3
- package/dist/slack/decodeBlockAction.d.ts.map +1 -1
- package/dist/slack/fromSlackBlocks.d.ts +1 -3
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts +1 -3
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +1 -1
- package/dist/slack/types.d.ts +78 -80
- package/dist/slack/types.d.ts.map +1 -1
- package/dist/slack.d.ts +5 -5
- package/dist/slack.d.ts.map +1 -0
- package/dist/teams/constants.d.ts +1 -3
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/decodeSubmitData.d.ts +1 -3
- package/dist/teams/decodeSubmitData.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.d.ts +4 -6
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts +1 -3
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/types.d.ts +108 -109
- package/dist/teams/types.d.ts.map +1 -1
- package/dist/teams.d.ts +5 -5
- package/dist/teams.d.ts.map +1 -0
- package/dist/types.d.ts +36 -37
- package/dist/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +21 -23
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/collectFormValues.d.ts +8 -10
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
- package/dist/vocabulary/data.d.ts +45 -47
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/dispatch.d.ts +2 -4
- package/dist/vocabulary/dispatch.d.ts.map +1 -1
- package/dist/vocabulary/fact.d.ts +8 -10
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/form.d.ts +7 -9
- package/dist/vocabulary/form.d.ts.map +1 -1
- package/dist/vocabulary/icon.d.ts +37 -39
- package/dist/vocabulary/icon.d.ts.map +1 -1
- package/dist/vocabulary/index.d.ts +1 -3
- package/dist/vocabulary/index.d.ts.map +1 -1
- package/dist/vocabulary/interactive.d.ts +87 -86
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +4 -4
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts +75 -77
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/list.d.ts +10 -12
- package/dist/vocabulary/list.d.ts.map +1 -1
- package/dist/vocabulary/media.d.ts +21 -23
- package/dist/vocabulary/media.d.ts.map +1 -1
- package/dist/vocabulary/text.d.ts +53 -55
- package/dist/vocabulary/text.d.ts.map +1 -1
- package/dist/vocabulary/toTextContent.d.ts +0 -2
- package/dist/vocabulary/toTextContent.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/JSONGenerativeUI.client.tsx +57 -15
- package/src/JSONGenerativeUI.test.tsx +298 -4
- package/src/a2ui/convert.test.ts +717 -0
- package/src/a2ui/convert.ts +229 -41
- package/src/a2ui/reducer.test.ts +18 -0
- package/src/a2ui/reducer.ts +16 -0
- package/src/a2ui/snapshot.test.ts +52 -0
- package/src/a2ui/snapshot.ts +52 -0
- package/src/a2ui/types.ts +16 -0
- package/src/a2ui.ts +2 -0
- package/src/actionRegistry.ts +5 -5
- package/src/buildPresentParameters.test.ts +246 -0
- package/src/buildPresentParameters.ts +18 -3
- package/src/scopeSchema.test.ts +72 -0
- package/src/scopeSchema.ts +71 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +17 -3
- package/src/vocabulary/interactive.test.tsx +19 -0
- package/src/vocabulary/interactive.tsx +4 -1
package/src/a2ui/convert.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { ICON_NAMES, type UIElement } from "../ir";
|
|
2
2
|
import {
|
|
3
3
|
A2UI_SURFACE_ID,
|
|
4
4
|
type A2uiSurfaceState,
|
|
@@ -12,15 +12,22 @@ const NODE_BUDGET = 5000;
|
|
|
12
12
|
const SUPPORTED_COMPONENTS = new Set([
|
|
13
13
|
"Text",
|
|
14
14
|
"Image",
|
|
15
|
+
"Icon",
|
|
15
16
|
"Row",
|
|
16
17
|
"Column",
|
|
18
|
+
"List",
|
|
17
19
|
"Card",
|
|
18
20
|
"Divider",
|
|
19
21
|
"Button",
|
|
20
22
|
"TextField",
|
|
21
23
|
"CheckBox",
|
|
24
|
+
"ChoicePicker",
|
|
25
|
+
"DateTimeInput",
|
|
22
26
|
]);
|
|
23
27
|
|
|
28
|
+
const ICON_NAME_SET: ReadonlySet<string> = new Set(ICON_NAMES);
|
|
29
|
+
const ICON_SIZE_SET: ReadonlySet<string> = new Set(["sm", "md", "lg"]);
|
|
30
|
+
|
|
24
31
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
25
32
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
33
|
|
|
@@ -145,6 +152,7 @@ type ConversionContext = {
|
|
|
145
152
|
depthWarned: boolean;
|
|
146
153
|
budgetWarned: boolean;
|
|
147
154
|
templateCapWarned: boolean;
|
|
155
|
+
readonly keepUnknownComponents: boolean;
|
|
148
156
|
};
|
|
149
157
|
|
|
150
158
|
const reserveNode = (context: ConversionContext): boolean => {
|
|
@@ -159,6 +167,21 @@ const reserveNode = (context: ConversionContext): boolean => {
|
|
|
159
167
|
return true;
|
|
160
168
|
};
|
|
161
169
|
|
|
170
|
+
const childReferences = (node: Record<string, unknown>): unknown[] => {
|
|
171
|
+
const children = node["children"];
|
|
172
|
+
const references: unknown[] = Array.isArray(children) ? [...children] : [];
|
|
173
|
+
if (node["child"] !== undefined) references.push(node["child"]);
|
|
174
|
+
if (node["component"] === "Modal") {
|
|
175
|
+
references.push(node["trigger"], node["content"]);
|
|
176
|
+
}
|
|
177
|
+
if (node["component"] === "Tabs" && Array.isArray(node["tabs"])) {
|
|
178
|
+
for (const tab of node["tabs"]) {
|
|
179
|
+
references.push(isRecord(tab) ? tab["child"] : undefined);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return references;
|
|
183
|
+
};
|
|
184
|
+
|
|
162
185
|
const childrenOf = (
|
|
163
186
|
node: Record<string, unknown>,
|
|
164
187
|
dataSource: unknown,
|
|
@@ -166,10 +189,8 @@ const childrenOf = (
|
|
|
166
189
|
depth: number,
|
|
167
190
|
visited: Set<string>,
|
|
168
191
|
): UIElement[] => {
|
|
169
|
-
const children = node["children"];
|
|
170
|
-
if (!Array.isArray(children)) return [];
|
|
171
192
|
const result: UIElement[] = [];
|
|
172
|
-
for (const childId of
|
|
193
|
+
for (const childId of childReferences(node)) {
|
|
173
194
|
if (typeof childId !== "string") {
|
|
174
195
|
context.warnings.push(
|
|
175
196
|
`Component "${String(node["id"] ?? "")}" has a malformed child reference.`,
|
|
@@ -188,36 +209,70 @@ const childrenOf = (
|
|
|
188
209
|
return result;
|
|
189
210
|
};
|
|
190
211
|
|
|
212
|
+
const textLabel = (
|
|
213
|
+
node: Record<string, unknown>,
|
|
214
|
+
children: readonly UIElement[],
|
|
215
|
+
context: ConversionContext,
|
|
216
|
+
): string | undefined => {
|
|
217
|
+
const references = childReferences(node);
|
|
218
|
+
const [reference] = references;
|
|
219
|
+
const [child] = children;
|
|
220
|
+
if (
|
|
221
|
+
references.length !== 1 ||
|
|
222
|
+
children.length !== 1 ||
|
|
223
|
+
typeof reference !== "string" ||
|
|
224
|
+
!child ||
|
|
225
|
+
context.surface.components.get(reference)?.["component"] !== "Text"
|
|
226
|
+
) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const text = child.$type === "Header" ? child["text"] : child["value"];
|
|
230
|
+
return typeof text === "string" ? text : undefined;
|
|
231
|
+
};
|
|
232
|
+
|
|
191
233
|
const mappedAction = (
|
|
192
234
|
node: Record<string, unknown>,
|
|
193
235
|
props: Record<string, unknown>,
|
|
194
|
-
dataSource: unknown,
|
|
195
236
|
context: ConversionContext,
|
|
196
237
|
): UIElement["$action"] | undefined => {
|
|
197
238
|
const action = props["action"];
|
|
239
|
+
const event =
|
|
240
|
+
isRecord(action) && isRecord(action["event"]) ? action["event"] : action;
|
|
198
241
|
const actionName =
|
|
199
|
-
typeof
|
|
200
|
-
?
|
|
201
|
-
: isRecord(
|
|
202
|
-
?
|
|
242
|
+
typeof event === "string"
|
|
243
|
+
? event
|
|
244
|
+
: isRecord(event) && typeof event["name"] === "string"
|
|
245
|
+
? event["name"]
|
|
203
246
|
: undefined;
|
|
204
247
|
if (!actionName) return undefined;
|
|
205
|
-
const
|
|
206
|
-
const resolvedContext =
|
|
207
|
-
rawContext === undefined ? undefined : materialize(rawContext, dataSource);
|
|
248
|
+
const actionContext = isRecord(event) ? event["context"] : undefined;
|
|
208
249
|
return {
|
|
209
250
|
type: "a2ui:action",
|
|
210
251
|
name: actionName,
|
|
211
252
|
surfaceId: context.surfaceId,
|
|
212
253
|
sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
|
|
213
|
-
...(
|
|
254
|
+
...(actionContext !== undefined ? { context: actionContext } : {}),
|
|
214
255
|
};
|
|
215
256
|
};
|
|
216
257
|
|
|
258
|
+
const choiceOptions = (value: unknown): { label: string; value: string }[] => {
|
|
259
|
+
if (!Array.isArray(value)) return [];
|
|
260
|
+
const options: { label: string; value: string }[] = [];
|
|
261
|
+
for (const option of value) {
|
|
262
|
+
if (
|
|
263
|
+
isRecord(option) &&
|
|
264
|
+
typeof option["label"] === "string" &&
|
|
265
|
+
typeof option["value"] === "string"
|
|
266
|
+
) {
|
|
267
|
+
options.push({ label: option["label"], value: option["value"] });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return options;
|
|
271
|
+
};
|
|
272
|
+
|
|
217
273
|
const mappedProps = (
|
|
218
274
|
node: Record<string, unknown>,
|
|
219
275
|
props: Record<string, unknown>,
|
|
220
|
-
dataSource: unknown,
|
|
221
276
|
context: ConversionContext,
|
|
222
277
|
): UIElement | undefined => {
|
|
223
278
|
const component = node["component"];
|
|
@@ -245,7 +300,7 @@ const mappedProps = (
|
|
|
245
300
|
|
|
246
301
|
if (component === "Image") {
|
|
247
302
|
const src = stringProp(props, ["src", "url"]);
|
|
248
|
-
const alt = stringProp(props, ["alt", "altText"]);
|
|
303
|
+
const alt = stringProp(props, ["alt", "altText", "description"]);
|
|
249
304
|
const size = props["size"];
|
|
250
305
|
const round = props["round"];
|
|
251
306
|
return {
|
|
@@ -257,6 +312,17 @@ const mappedProps = (
|
|
|
257
312
|
};
|
|
258
313
|
}
|
|
259
314
|
|
|
315
|
+
if (component === "Icon") {
|
|
316
|
+
const name = stringProp(props, ["name"]);
|
|
317
|
+
if (!name || !ICON_NAME_SET.has(name)) return undefined;
|
|
318
|
+
const size = props["size"];
|
|
319
|
+
return {
|
|
320
|
+
$type: "Icon",
|
|
321
|
+
name,
|
|
322
|
+
...(typeof size === "string" && ICON_SIZE_SET.has(size) ? { size } : {}),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
260
326
|
if (component === "Row") {
|
|
261
327
|
const gap = props["gap"];
|
|
262
328
|
const align = firstDefined(props, ["align", "alignment"]);
|
|
@@ -279,6 +345,18 @@ const mappedProps = (
|
|
|
279
345
|
};
|
|
280
346
|
}
|
|
281
347
|
|
|
348
|
+
if (component === "List") {
|
|
349
|
+
const direction = props["direction"];
|
|
350
|
+
const align = props["align"];
|
|
351
|
+
if (direction === "horizontal") {
|
|
352
|
+
return {
|
|
353
|
+
$type: "Row",
|
|
354
|
+
...(typeof align === "string" ? { align } : {}),
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
return { $type: "ListView" };
|
|
358
|
+
}
|
|
359
|
+
|
|
282
360
|
if (component === "Card") {
|
|
283
361
|
const title = props["title"];
|
|
284
362
|
const padding = props["padding"];
|
|
@@ -303,7 +381,7 @@ const mappedProps = (
|
|
|
303
381
|
const buttonStyle = props["buttonStyle"];
|
|
304
382
|
const block = props["block"];
|
|
305
383
|
const submit = props["submit"];
|
|
306
|
-
const action = mappedAction(node, props,
|
|
384
|
+
const action = mappedAction(node, props, context);
|
|
307
385
|
return {
|
|
308
386
|
$type: "Button",
|
|
309
387
|
...(label !== undefined ? { label } : {}),
|
|
@@ -345,6 +423,55 @@ const mappedProps = (
|
|
|
345
423
|
};
|
|
346
424
|
}
|
|
347
425
|
|
|
426
|
+
if (component === "ChoicePicker") {
|
|
427
|
+
const options = choiceOptions(props["options"]);
|
|
428
|
+
const value = props["value"];
|
|
429
|
+
const defaultValue =
|
|
430
|
+
typeof value === "string"
|
|
431
|
+
? value
|
|
432
|
+
: Array.isArray(value) && typeof value[0] === "string"
|
|
433
|
+
? value[0]
|
|
434
|
+
: undefined;
|
|
435
|
+
const radioStyle =
|
|
436
|
+
props["variant"] === "radio" ||
|
|
437
|
+
props["variant"] === "single" ||
|
|
438
|
+
props["variant"] === "singleSelection" ||
|
|
439
|
+
props["displayStyle"] === "radio" ||
|
|
440
|
+
(props["variant"] === "mutuallyExclusive" &&
|
|
441
|
+
props["displayStyle"] !== "chips");
|
|
442
|
+
if (radioStyle) {
|
|
443
|
+
return {
|
|
444
|
+
$type: "RadioGroup",
|
|
445
|
+
options,
|
|
446
|
+
...(label !== undefined ? { label } : {}),
|
|
447
|
+
...(name !== undefined ? { name } : {}),
|
|
448
|
+
...(defaultValue !== undefined ? { defaultValue } : {}),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const placeholder = stringProp(props, ["placeholder"]);
|
|
452
|
+
return {
|
|
453
|
+
$type: "Select",
|
|
454
|
+
options,
|
|
455
|
+
...(placeholder !== undefined ? { placeholder } : {}),
|
|
456
|
+
...(label !== undefined ? { label } : {}),
|
|
457
|
+
...(name !== undefined ? { name } : {}),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (component === "DateTimeInput") {
|
|
462
|
+
const value = stringProp(props, ["value"]);
|
|
463
|
+
const min = stringProp(props, ["min"]);
|
|
464
|
+
const max = stringProp(props, ["max"]);
|
|
465
|
+
return {
|
|
466
|
+
$type: "DatePicker",
|
|
467
|
+
...(value !== undefined ? { value } : {}),
|
|
468
|
+
...(min !== undefined ? { min } : {}),
|
|
469
|
+
...(max !== undefined ? { max } : {}),
|
|
470
|
+
...(label !== undefined ? { label } : {}),
|
|
471
|
+
...(name !== undefined ? { name } : {}),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
348
475
|
return undefined;
|
|
349
476
|
};
|
|
350
477
|
|
|
@@ -355,14 +482,23 @@ const convertTemplate = (
|
|
|
355
482
|
context: ConversionContext,
|
|
356
483
|
depth: number,
|
|
357
484
|
visited: Set<string>,
|
|
485
|
+
retained?: UIElement,
|
|
486
|
+
mappedContainer?: UIElement,
|
|
358
487
|
): UIElement | null => {
|
|
359
488
|
if (!reserveNode(context)) return null;
|
|
489
|
+
const horizontalList =
|
|
490
|
+
node["component"] === "List" &&
|
|
491
|
+
materialize(node["direction"], dataSource) === "horizontal";
|
|
492
|
+
const container = mappedContainer ??
|
|
493
|
+
retained ?? {
|
|
494
|
+
$type: horizontalList ? "Row" : "ListView",
|
|
495
|
+
};
|
|
360
496
|
const list = resolvePointer(dataSource, templateChildren.template.path);
|
|
361
497
|
if (!Array.isArray(list)) {
|
|
362
498
|
context.warnings.push(
|
|
363
499
|
`Template on component "${String(node["id"] ?? "")}" did not resolve to a list.`,
|
|
364
500
|
);
|
|
365
|
-
return {
|
|
501
|
+
return { ...container, children: [] };
|
|
366
502
|
}
|
|
367
503
|
const itemCount = Math.min(list.length, TEMPLATE_ITEM_CAP);
|
|
368
504
|
if (list.length > TEMPLATE_ITEM_CAP && !context.templateCapWarned) {
|
|
@@ -373,7 +509,7 @@ const convertTemplate = (
|
|
|
373
509
|
}
|
|
374
510
|
const children: UIElement[] = [];
|
|
375
511
|
for (let index = 0; index < itemCount; index++) {
|
|
376
|
-
if (!reserveNode(context)) break;
|
|
512
|
+
if (!retained && !horizontalList && !reserveNode(context)) break;
|
|
377
513
|
const child = convertComponent(
|
|
378
514
|
templateChildren.template.componentId,
|
|
379
515
|
list[index],
|
|
@@ -381,12 +517,16 @@ const convertTemplate = (
|
|
|
381
517
|
depth + 1,
|
|
382
518
|
visited,
|
|
383
519
|
);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
520
|
+
if (retained || horizontalList) {
|
|
521
|
+
if (child) children.push(child);
|
|
522
|
+
} else {
|
|
523
|
+
children.push({
|
|
524
|
+
$type: "ListViewItem",
|
|
525
|
+
...(child ? { children: child } : {}),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
388
528
|
}
|
|
389
|
-
return {
|
|
529
|
+
return { ...container, children };
|
|
390
530
|
};
|
|
391
531
|
|
|
392
532
|
function convertComponent(
|
|
@@ -416,43 +556,90 @@ function convertComponent(
|
|
|
416
556
|
|
|
417
557
|
visited.add(componentId);
|
|
418
558
|
try {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
node,
|
|
423
|
-
templateChildren,
|
|
424
|
-
dataSource,
|
|
425
|
-
context,
|
|
426
|
-
depth,
|
|
427
|
-
visited,
|
|
559
|
+
if (typeof component !== "string") {
|
|
560
|
+
context.warnings.push(
|
|
561
|
+
`Unknown A2UI component "${String(component ?? "")}" was skipped.`,
|
|
428
562
|
);
|
|
563
|
+
return null;
|
|
429
564
|
}
|
|
430
|
-
|
|
565
|
+
const templateChildren = node["children"];
|
|
566
|
+
const hasTemplate = isTemplateChildren(templateChildren);
|
|
567
|
+
if (
|
|
568
|
+
!SUPPORTED_COMPONENTS.has(component) &&
|
|
569
|
+
!context.keepUnknownComponents &&
|
|
570
|
+
!hasTemplate
|
|
571
|
+
) {
|
|
431
572
|
context.warnings.push(
|
|
432
|
-
`Unknown A2UI component "${
|
|
573
|
+
`Unknown A2UI component "${component}" was skipped.`,
|
|
433
574
|
);
|
|
434
575
|
return null;
|
|
435
576
|
}
|
|
436
|
-
if (!reserveNode(context)) return null;
|
|
437
577
|
const props: Record<string, unknown> = {};
|
|
438
578
|
for (const [key, value] of Object.entries(node)) {
|
|
439
|
-
if (
|
|
579
|
+
if (
|
|
580
|
+
key === "id" ||
|
|
581
|
+
key === "component" ||
|
|
582
|
+
key === "children" ||
|
|
583
|
+
key === "child"
|
|
584
|
+
) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
440
587
|
const resolved = materialize(value, dataSource);
|
|
441
588
|
if (resolved !== undefined) setOwnProperty(props, key, resolved);
|
|
442
589
|
}
|
|
443
|
-
const mapped = mappedProps(node, props,
|
|
444
|
-
if (!mapped)
|
|
590
|
+
const mapped = mappedProps(node, props, context);
|
|
591
|
+
if (!mapped && SUPPORTED_COMPONENTS.has(component)) {
|
|
592
|
+
context.warnings.push(
|
|
593
|
+
`A2UI component "${component}" could not be mapped and was skipped.`,
|
|
594
|
+
);
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const retained =
|
|
598
|
+
!mapped && context.keepUnknownComponents
|
|
599
|
+
? {
|
|
600
|
+
$type: component,
|
|
601
|
+
...Object.fromEntries(
|
|
602
|
+
Object.entries(props).filter(([key]) => !key.startsWith("$")),
|
|
603
|
+
),
|
|
604
|
+
}
|
|
605
|
+
: undefined;
|
|
606
|
+
if (hasTemplate) {
|
|
607
|
+
return convertTemplate(
|
|
608
|
+
node,
|
|
609
|
+
templateChildren,
|
|
610
|
+
dataSource,
|
|
611
|
+
context,
|
|
612
|
+
depth,
|
|
613
|
+
visited,
|
|
614
|
+
retained,
|
|
615
|
+
component === "List" ? mapped : undefined,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
if (!reserveNode(context)) return null;
|
|
619
|
+
const converted = mapped ?? retained;
|
|
620
|
+
if (!converted) return null;
|
|
445
621
|
const children = childrenOf(node, dataSource, context, depth, visited);
|
|
622
|
+
if (mapped?.$type === "Button" && mapped["label"] === undefined) {
|
|
623
|
+
const label = textLabel(node, children, context);
|
|
624
|
+
if (label !== undefined) return { ...mapped, label };
|
|
625
|
+
}
|
|
626
|
+
const listChildren =
|
|
627
|
+
component === "List" && mapped?.$type === "ListView"
|
|
628
|
+
? children.map((child) => ({ $type: "ListViewItem", children: child }))
|
|
629
|
+
: children;
|
|
446
630
|
return {
|
|
447
|
-
...
|
|
448
|
-
...(
|
|
631
|
+
...converted,
|
|
632
|
+
...(listChildren.length > 0 ? { children: listChildren } : {}),
|
|
449
633
|
};
|
|
450
634
|
} finally {
|
|
451
635
|
visited.delete(componentId);
|
|
452
636
|
}
|
|
453
637
|
}
|
|
454
638
|
|
|
455
|
-
export function convertSurfaceToUISpec(
|
|
639
|
+
export function convertSurfaceToUISpec(
|
|
640
|
+
surface: A2uiSurfaceState,
|
|
641
|
+
options: { readonly keepUnknownComponents?: boolean } = {},
|
|
642
|
+
): {
|
|
456
643
|
spec: UIElement | null;
|
|
457
644
|
warnings: string[];
|
|
458
645
|
} {
|
|
@@ -471,6 +658,7 @@ export function convertSurfaceToUISpec(surface: A2uiSurfaceState): {
|
|
|
471
658
|
depthWarned: false,
|
|
472
659
|
budgetWarned: false,
|
|
473
660
|
templateCapWarned: false,
|
|
661
|
+
keepUnknownComponents: options.keepUnknownComponents === true,
|
|
474
662
|
};
|
|
475
663
|
try {
|
|
476
664
|
const spec = convertComponent(
|
package/src/a2ui/reducer.test.ts
CHANGED
|
@@ -35,6 +35,7 @@ describe("applyA2uiOperations", () => {
|
|
|
35
35
|
expect(initial.size).toBe(0);
|
|
36
36
|
expect(created.warnings).toEqual([]);
|
|
37
37
|
expect(created.state.get("main")).toMatchObject({
|
|
38
|
+
catalogId: "default",
|
|
38
39
|
dataModel: { profile: { name: "Ada" } },
|
|
39
40
|
});
|
|
40
41
|
expect(created.state.get("main")?.components.get("root")).toEqual({
|
|
@@ -61,6 +62,7 @@ describe("applyA2uiOperations", () => {
|
|
|
61
62
|
version: "v1.0",
|
|
62
63
|
createSurface: {
|
|
63
64
|
surfaceId: "inline",
|
|
65
|
+
catalogId: "basic",
|
|
64
66
|
surfaceProperties: { ignored: true },
|
|
65
67
|
sendDataModel: true,
|
|
66
68
|
components: [
|
|
@@ -73,6 +75,7 @@ describe("applyA2uiOperations", () => {
|
|
|
73
75
|
|
|
74
76
|
expect(result.warnings).toEqual([]);
|
|
75
77
|
expect(result.state.get("inline")).toMatchObject({
|
|
78
|
+
catalogId: "basic",
|
|
76
79
|
dataModel: { message: "hello" },
|
|
77
80
|
});
|
|
78
81
|
expect(result.state.get("inline")?.components.get("root")).toEqual({
|
|
@@ -82,6 +85,21 @@ describe("applyA2uiOperations", () => {
|
|
|
82
85
|
});
|
|
83
86
|
});
|
|
84
87
|
|
|
88
|
+
it("keeps a v1.0 catalog id from surface properties", () => {
|
|
89
|
+
const result = applyA2uiOperations(new Map(), [
|
|
90
|
+
{
|
|
91
|
+
version: "v1.0",
|
|
92
|
+
createSurface: {
|
|
93
|
+
surfaceId: "properties",
|
|
94
|
+
surfaceProperties: { catalogId: "basic" },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
expect(result.warnings).toEqual([]);
|
|
100
|
+
expect(result.state.get("properties")?.catalogId).toBe("basic");
|
|
101
|
+
});
|
|
102
|
+
|
|
85
103
|
it.each(["10001", "4294967294"])(
|
|
86
104
|
"rejects array pointer index %s beyond the auto-vivification limit",
|
|
87
105
|
(index) => {
|
package/src/a2ui/reducer.ts
CHANGED
|
@@ -28,6 +28,17 @@ const surfaceIdOf = (payload: Record<string, unknown>): string | undefined => {
|
|
|
28
28
|
: undefined;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
const catalogIdOf = (payload: Record<string, unknown>): string | undefined => {
|
|
32
|
+
if (typeof payload["catalogId"] === "string") {
|
|
33
|
+
return payload["catalogId"];
|
|
34
|
+
}
|
|
35
|
+
const surfaceProperties = payload["surfaceProperties"];
|
|
36
|
+
return isRecord(surfaceProperties) &&
|
|
37
|
+
typeof surfaceProperties["catalogId"] === "string"
|
|
38
|
+
? surfaceProperties["catalogId"]
|
|
39
|
+
: undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
31
42
|
const withSurfaceId = (
|
|
32
43
|
surface: A2uiSurfaceState,
|
|
33
44
|
surfaceId: string,
|
|
@@ -50,6 +61,9 @@ const cloneSurface = (
|
|
|
50
61
|
): A2uiSurfaceState =>
|
|
51
62
|
withSurfaceId(
|
|
52
63
|
{
|
|
64
|
+
...(surface.catalogId !== undefined
|
|
65
|
+
? { catalogId: surface.catalogId }
|
|
66
|
+
: {}),
|
|
53
67
|
components: new Map(surface.components),
|
|
54
68
|
dataModel: surface.dataModel,
|
|
55
69
|
},
|
|
@@ -274,8 +288,10 @@ export function applyA2uiOperations(
|
|
|
274
288
|
}
|
|
275
289
|
|
|
276
290
|
if (operationKey === "createSurface") {
|
|
291
|
+
const catalogId = catalogIdOf(payload);
|
|
277
292
|
const surface = withSurfaceId(
|
|
278
293
|
{
|
|
294
|
+
...(catalogId !== undefined ? { catalogId } : {}),
|
|
279
295
|
components: new Map(),
|
|
280
296
|
dataModel:
|
|
281
297
|
version === "v1.0" && payload["dataModel"] !== undefined
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { surfaceToOperations } from "./snapshot";
|
|
3
|
+
import { A2UI_SURFACE_ID, type A2uiSurfaceState } from "./types";
|
|
4
|
+
|
|
5
|
+
const surface = (dataModel: unknown): A2uiSurfaceState => ({
|
|
6
|
+
components: new Map([["root", { id: "root", component: "Text" }]]),
|
|
7
|
+
dataModel,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("surfaceToOperations", () => {
|
|
11
|
+
it("uses an explicit surface id and omits an undefined data model", () => {
|
|
12
|
+
const value = Object.defineProperty(surface(undefined), A2UI_SURFACE_ID, {
|
|
13
|
+
value: "stored",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(surfaceToOperations(value, "provided")).toEqual([
|
|
17
|
+
{
|
|
18
|
+
version: "v0.9",
|
|
19
|
+
createSurface: { surfaceId: "provided" },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
version: "v0.9",
|
|
23
|
+
updateComponents: {
|
|
24
|
+
surfaceId: "provided",
|
|
25
|
+
components: [{ id: "root", component: "Text" }],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("uses the reducer surface id when no explicit id is supplied", () => {
|
|
32
|
+
const value = Object.defineProperty(surface(null), A2UI_SURFACE_ID, {
|
|
33
|
+
value: "stored",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(surfaceToOperations(value)).toMatchObject([
|
|
37
|
+
{ createSurface: { surfaceId: "stored" } },
|
|
38
|
+
{ updateComponents: { surfaceId: "stored" } },
|
|
39
|
+
{ updateDataModel: { surfaceId: "stored", contents: null } },
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("requires either an explicit or reducer surface id", () => {
|
|
44
|
+
expect(() => surfaceToOperations(surface({}))).toThrow(
|
|
45
|
+
"A2UI surfaces must have a surface id to be replayed.",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
it("rejects an empty surface id", () => {
|
|
49
|
+
const value = { components: new Map(), dataModel: undefined };
|
|
50
|
+
expect(() => surfaceToOperations(value, "")).toThrow();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
A2UI_SURFACE_ID,
|
|
3
|
+
type A2uiSurfaceState,
|
|
4
|
+
type A2uiSurfaceSnapshotOperation,
|
|
5
|
+
type ComponentNode,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
const surfaceIdOf = (surface: A2uiSurfaceState): string | undefined =>
|
|
9
|
+
(surface as A2uiSurfaceState & { [A2UI_SURFACE_ID]?: string })[
|
|
10
|
+
A2UI_SURFACE_ID
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function surfaceToOperations(
|
|
14
|
+
surface: A2uiSurfaceState,
|
|
15
|
+
surfaceId?: string,
|
|
16
|
+
): readonly A2uiSurfaceSnapshotOperation[] {
|
|
17
|
+
const resolvedSurfaceId = surfaceId ?? surfaceIdOf(surface);
|
|
18
|
+
if (!resolvedSurfaceId) {
|
|
19
|
+
throw new Error("A2UI surfaces must have a surface id to be replayed.");
|
|
20
|
+
}
|
|
21
|
+
const operations: A2uiSurfaceSnapshotOperation[] = [
|
|
22
|
+
{
|
|
23
|
+
version: "v0.9",
|
|
24
|
+
createSurface: {
|
|
25
|
+
surfaceId: resolvedSurfaceId,
|
|
26
|
+
...(surface.catalogId !== undefined
|
|
27
|
+
? { catalogId: surface.catalogId }
|
|
28
|
+
: {}),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
version: "v0.9",
|
|
33
|
+
updateComponents: {
|
|
34
|
+
surfaceId: resolvedSurfaceId,
|
|
35
|
+
components: [...surface.components.values()].map((component) => ({
|
|
36
|
+
...component,
|
|
37
|
+
})) as ComponentNode[],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
if (surface.dataModel !== undefined) {
|
|
42
|
+
operations.push({
|
|
43
|
+
version: "v0.9",
|
|
44
|
+
updateDataModel: {
|
|
45
|
+
surfaceId: resolvedSurfaceId,
|
|
46
|
+
path: "/",
|
|
47
|
+
contents: surface.dataModel,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return operations;
|
|
52
|
+
}
|
package/src/a2ui/types.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface A2uiCreateSurfaceV09Payload {
|
|
|
24
24
|
|
|
25
25
|
export interface A2uiCreateSurfaceV10Payload {
|
|
26
26
|
readonly surfaceId: string;
|
|
27
|
+
readonly catalogId?: string;
|
|
27
28
|
readonly surfaceProperties?: unknown;
|
|
28
29
|
readonly sendDataModel?: unknown;
|
|
29
30
|
readonly components?: readonly ComponentNode[];
|
|
@@ -78,7 +79,22 @@ export type A2uiOperation =
|
|
|
78
79
|
| A2uiUpdateDataModelOperation
|
|
79
80
|
| A2uiDeleteSurfaceOperation;
|
|
80
81
|
|
|
82
|
+
export type A2uiSurfaceSnapshotOperation =
|
|
83
|
+
| {
|
|
84
|
+
readonly version: "v0.9";
|
|
85
|
+
readonly createSurface: A2uiCreateSurfaceV09Payload;
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
readonly version: "v0.9";
|
|
89
|
+
readonly updateComponents: A2uiUpdateComponentsPayload;
|
|
90
|
+
}
|
|
91
|
+
| {
|
|
92
|
+
readonly version: "v0.9";
|
|
93
|
+
readonly updateDataModel: A2uiUpdateDataModelPayload;
|
|
94
|
+
};
|
|
95
|
+
|
|
81
96
|
export type A2uiSurfaceState = {
|
|
97
|
+
catalogId?: string;
|
|
82
98
|
components: Map<string, Record<string, unknown>>;
|
|
83
99
|
dataModel: unknown;
|
|
84
100
|
};
|
package/src/a2ui.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { applyA2uiOperations } from "./a2ui/reducer";
|
|
2
2
|
export { convertSurfaceToUISpec } from "./a2ui/convert";
|
|
3
|
+
export { surfaceToOperations } from "./a2ui/snapshot";
|
|
3
4
|
export type {
|
|
4
5
|
A2uiCreateSurfaceOperation,
|
|
5
6
|
A2uiCreateSurfaceV09Payload,
|
|
@@ -9,6 +10,7 @@ export type {
|
|
|
9
10
|
A2uiOperation,
|
|
10
11
|
A2uiOperationResult,
|
|
11
12
|
A2uiState,
|
|
13
|
+
A2uiSurfaceSnapshotOperation,
|
|
12
14
|
A2uiSurfaceState,
|
|
13
15
|
A2uiTemplateChildren,
|
|
14
16
|
A2uiUpdateComponentsOperation,
|
package/src/actionRegistry.ts
CHANGED
|
@@ -10,17 +10,17 @@ export type ActionDispatchContext = {
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Resolves a single `$action.type`. Fire-and-forget actions return `void` or a
|
|
13
|
-
* promise that resolves to it
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* handed back to `dispatch`'s caller as `unknown`.
|
|
13
|
+
* promise that resolves to it. For a `prompt_user` tool whose call has no
|
|
14
|
+
* result yet, a non-`undefined` return value becomes the tool result. Whether
|
|
15
|
+
* that resumes the run depends on the runtime, including how it treats human
|
|
16
|
+
* tools. The return value is handed back to `dispatch`'s caller as `unknown`.
|
|
17
17
|
*/
|
|
18
18
|
export type ActionHandler = (
|
|
19
19
|
ctx: ActionDispatchContext,
|
|
20
20
|
) => unknown | Promise<unknown>;
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* The host-provided map from `$action.type` to {@link ActionHandler}. This is the single dispatch target for rendered generative UI: every interactive component fires its `$action` here through the injected `$dispatch` (a submit Button defers to its ancestor form's dispatch instead). The payload's `$input` takes one of two shapes: a single value for a standalone control's dispatch (`Select`, `Input`, `DatePicker`, `Checkbox`, `RadioGroup`), or an object keyed by field `name` for a `Form` or a `Card` with `asForm` set. Construct it with {@link createActionRegistry} and pass it to `JSONGenerativeUI`.
|
|
23
|
+
* The host-provided map from `$action.type` to {@link ActionHandler}. This is the single dispatch target for rendered generative UI: every interactive component fires its `$action` here through the injected `$dispatch` (a submit Button defers to its ancestor form's dispatch instead). The payload's `$input` takes one of two shapes: a single value for a standalone control's dispatch (`Select`, `Input`, `DatePicker`, `Checkbox`, `RadioGroup`), or an object keyed by field `name` for a `Form` or a `Card` with `asForm` set. When a registry is used in a `JSONGenerativeUI` tool, each dispatched payload, including `$input`, is recorded on the tool call on a best-effort basis. Recording depends on the runtime and interaction log limits, and never delays or prevents dispatch. Construct it with {@link createActionRegistry} and pass it to `JSONGenerativeUI`.
|
|
24
24
|
*/
|
|
25
25
|
export type ActionRegistry = {
|
|
26
26
|
dispatch(action: Action): unknown;
|