@assistant-ui/react-generative-ui 0.0.21 → 0.0.22
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/LICENSE +1 -1
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.client.js +30 -13
- package/dist/JSONGenerativeUI.client.js.map +1 -1
- package/dist/RadioGroupScope.d.ts +6 -0
- package/dist/RadioGroupScope.d.ts.map +1 -0
- package/dist/RadioGroupScope.js +25 -0
- package/dist/RadioGroupScope.js.map +1 -0
- package/dist/a2ui/BindingContext.d.ts +11 -0
- package/dist/a2ui/BindingContext.d.ts.map +1 -0
- package/dist/a2ui/BindingContext.js +12 -0
- package/dist/a2ui/BindingContext.js.map +1 -0
- package/dist/a2ui/PresentRenderer.d.ts +11 -0
- package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
- package/dist/a2ui/PresentRenderer.js +39 -0
- package/dist/a2ui/PresentRenderer.js.map +1 -0
- package/dist/a2ui/convert.d.ts +6 -0
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +344 -83
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/a2ui/dataModel.d.ts +9 -0
- package/dist/a2ui/dataModel.d.ts.map +1 -0
- package/dist/a2ui/dataModel.js +44 -0
- package/dist/a2ui/dataModel.js.map +1 -0
- package/dist/a2ui/reducer.d.ts +5 -0
- package/dist/a2ui/reducer.d.ts.map +1 -1
- package/dist/a2ui/reducer.js +3 -3
- package/dist/a2ui/reducer.js.map +1 -1
- package/dist/a2ui/types.d.ts +8 -2
- package/dist/a2ui/types.d.ts.map +1 -1
- package/dist/a2ui/types.js.map +1 -1
- package/dist/a2ui/valueFunctions.d.ts +16 -0
- package/dist/a2ui/valueFunctions.d.ts.map +1 -0
- package/dist/a2ui/valueFunctions.js +332 -0
- package/dist/a2ui/valueFunctions.js.map +1 -0
- package/dist/actionRegistry.d.ts +1 -1
- package/dist/actionRegistry.js.map +1 -1
- package/dist/answeredValues.d.ts +16 -0
- package/dist/answeredValues.d.ts.map +1 -0
- package/dist/answeredValues.js +37 -0
- package/dist/answeredValues.js.map +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +3 -1
- package/dist/constants.js.map +1 -1
- package/dist/fieldReferences.d.ts +9 -0
- package/dist/fieldReferences.d.ts.map +1 -0
- package/dist/fieldReferences.js +53 -0
- package/dist/fieldReferences.js.map +1 -0
- package/dist/generativeUIToJSX.js +3 -3
- package/dist/generativeUIToJSX.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.server.js +2 -2
- package/dist/index.shared.js +2 -2
- package/dist/ir.d.ts +1 -1
- package/dist/ir.js.map +1 -1
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/renderGenerativeUI.js +12 -1
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/slack/constants.d.ts +2 -0
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/constants.js +3 -1
- package/dist/slack/constants.js.map +1 -1
- package/dist/slack/decodeBlockAction.d.ts +3 -1
- package/dist/slack/decodeBlockAction.d.ts.map +1 -1
- package/dist/slack/decodeBlockAction.js +58 -7
- package/dist/slack/decodeBlockAction.js.map +1 -1
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
- package/dist/slack/fromSlackBlocks.js +20 -3
- package/dist/slack/fromSlackBlocks.js.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +187 -19
- package/dist/slack/toSlackBlocks.js.map +1 -1
- package/dist/slack/types.d.ts +4 -0
- package/dist/slack/types.d.ts.map +1 -1
- package/dist/teams/constants.d.ts +1 -1
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/constants.js +1 -1
- package/dist/teams/constants.js.map +1 -1
- package/dist/teams/decodeSubmitData.d.ts +4 -0
- package/dist/teams/decodeSubmitData.d.ts.map +1 -1
- package/dist/teams/decodeSubmitData.js +6 -1
- package/dist/teams/decodeSubmitData.js.map +1 -1
- package/dist/teams/toAdaptiveCard.d.ts +5 -4
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.js +56 -17
- package/dist/teams/toAdaptiveCard.js.map +1 -1
- package/dist/teams/types.d.ts +4 -1
- package/dist/teams/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +9 -2
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/alert.js +116 -19
- package/dist/vocabulary/alert.js.map +1 -1
- package/dist/vocabulary/collectFormValues.d.ts +2 -1
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
- package/dist/vocabulary/collectFormValues.js +10 -3
- package/dist/vocabulary/collectFormValues.js.map +1 -1
- package/dist/vocabulary/data.d.ts +22 -5
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/data.js +278 -83
- package/dist/vocabulary/data.js.map +1 -1
- package/dist/vocabulary/dispatch.d.ts +3 -2
- package/dist/vocabulary/dispatch.d.ts.map +1 -1
- package/dist/vocabulary/dispatch.js +33 -4
- package/dist/vocabulary/dispatch.js.map +1 -1
- package/dist/vocabulary/fact.d.ts +8 -1
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/fact.js +37 -11
- package/dist/vocabulary/fact.js.map +1 -1
- package/dist/vocabulary/form.d.ts.map +1 -1
- package/dist/vocabulary/form.js +3 -3
- package/dist/vocabulary/form.js.map +1 -1
- package/dist/vocabulary/formatValue.d.ts +7 -0
- package/dist/vocabulary/formatValue.d.ts.map +1 -0
- package/dist/vocabulary/formatValue.js +76 -0
- package/dist/vocabulary/formatValue.js.map +1 -0
- package/dist/vocabulary/icon.d.ts +1 -1
- package/dist/vocabulary/interactive.d.ts +137 -8
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +404 -103
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/layout.js +4 -4
- package/dist/vocabulary/layout.js.map +1 -1
- package/dist/vocabulary/list.js +2 -2
- package/dist/vocabulary/list.js.map +1 -1
- package/dist/vocabulary/text.d.ts +1 -1
- package/package.json +6 -3
- package/src/JSONGenerativeUI.client.tsx +84 -40
- package/src/JSONGenerativeUI.test.tsx +964 -68
- package/src/RadioGroupScope.server.test.tsx +24 -0
- package/src/RadioGroupScope.tsx +34 -0
- package/src/a2ui/BindingContext.ts +26 -0
- package/src/a2ui/PresentRenderer.tsx +83 -0
- package/src/a2ui/convert.test.ts +1795 -179
- package/src/a2ui/convert.ts +629 -132
- package/src/a2ui/dataModel.ts +69 -0
- package/src/a2ui/reducer.test.ts +35 -0
- package/src/a2ui/reducer.ts +5 -4
- package/src/a2ui/types.ts +9 -2
- package/src/a2ui/valueFunctions.test.ts +271 -0
- package/src/a2ui/valueFunctions.ts +523 -0
- package/src/actionRegistry.ts +1 -1
- package/src/answeredValues.test.ts +33 -0
- package/src/answeredValues.tsx +70 -0
- package/src/constants.ts +4 -0
- package/src/fieldReferences.test.ts +66 -0
- package/src/fieldReferences.ts +83 -0
- package/src/generativeUIToJSX.test.ts +18 -0
- package/src/generativeUIToJSX.ts +4 -6
- package/src/index.server.test.ts +106 -0
- package/src/ir.ts +1 -1
- package/src/renderGenerativeUI.test.tsx +14 -5
- package/src/renderGenerativeUI.tsx +30 -1
- package/src/slack/a2uiSlack.test.ts +198 -0
- package/src/slack/constants.ts +3 -0
- package/src/slack/decodeBlockAction.test.ts +98 -0
- package/src/slack/decodeBlockAction.ts +110 -17
- package/src/slack/fromSlackBlocks.test.ts +222 -3
- package/src/slack/fromSlackBlocks.ts +53 -2
- package/src/slack/toSlackBlocks.test.ts +682 -3
- package/src/slack/toSlackBlocks.ts +374 -23
- package/src/slack/types.ts +4 -0
- package/src/teams/constants.ts +1 -1
- package/src/teams/decodeSubmitData.test.ts +33 -0
- package/src/teams/decodeSubmitData.ts +9 -1
- package/src/teams/toAdaptiveCard.test.ts +219 -0
- package/src/teams/toAdaptiveCard.ts +93 -19
- package/src/teams/types.ts +4 -1
- package/src/vocabulary/alert.test.tsx +288 -7
- package/src/vocabulary/alert.tsx +168 -26
- package/src/vocabulary/collectFormValues.test.ts +28 -1
- package/src/vocabulary/collectFormValues.ts +25 -5
- package/src/vocabulary/data.test.tsx +248 -6
- package/src/vocabulary/data.tsx +398 -100
- package/src/vocabulary/dispatch.ts +63 -2
- package/src/vocabulary/fact.test.tsx +67 -0
- package/src/vocabulary/fact.tsx +40 -9
- package/src/vocabulary/form.tsx +7 -2
- package/src/vocabulary/formatValue.test.ts +72 -0
- package/src/vocabulary/formatValue.ts +127 -0
- package/src/vocabulary/index.test.ts +2 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
- package/src/vocabulary/interactive.dom.test.tsx +1699 -0
- package/src/vocabulary/interactive.test.tsx +60 -4
- package/src/vocabulary/interactive.tsx +770 -151
- package/src/vocabulary/layout.test.tsx +6 -4
- package/src/vocabulary/layout.tsx +13 -2
- package/src/vocabulary/list.tsx +2 -2
package/src/a2ui/convert.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { ICON_NAMES, type UIElement } from "../ir";
|
|
2
|
+
import { A2UI_SURFACE_ID, type A2uiSurfaceState } from "./types";
|
|
2
3
|
import {
|
|
3
|
-
|
|
4
|
-
type
|
|
5
|
-
|
|
6
|
-
} from "./
|
|
4
|
+
evaluateA2uiValueFunction,
|
|
5
|
+
type ExpressionPart,
|
|
6
|
+
} from "./valueFunctions";
|
|
7
|
+
import { MAX_AUTO_VIVIFY_ARRAY_INDEX } from "./reducer";
|
|
8
|
+
import type { A2uiBinding } from "./BindingContext";
|
|
9
|
+
import { decodePointer, resolvePointer } from "./dataModel";
|
|
7
10
|
|
|
8
11
|
const DEPTH_CAP = 32;
|
|
9
12
|
const TEMPLATE_ITEM_CAP = 100;
|
|
10
13
|
const NODE_BUDGET = 5000;
|
|
14
|
+
const EVALUATION_BUDGET = 20_000;
|
|
11
15
|
|
|
12
16
|
const SUPPORTED_COMPONENTS = new Set([
|
|
13
17
|
"Text",
|
|
@@ -23,6 +27,13 @@ const SUPPORTED_COMPONENTS = new Set([
|
|
|
23
27
|
"CheckBox",
|
|
24
28
|
"ChoicePicker",
|
|
25
29
|
"DateTimeInput",
|
|
30
|
+
"Slider",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const CHILD_LIST_COMPONENTS: ReadonlySet<string> = new Set([
|
|
34
|
+
"Row",
|
|
35
|
+
"Column",
|
|
36
|
+
"List",
|
|
26
37
|
]);
|
|
27
38
|
|
|
28
39
|
const ICON_NAME_SET: ReadonlySet<string> = new Set(ICON_NAMES);
|
|
@@ -54,66 +65,130 @@ const setOwnProperty = (
|
|
|
54
65
|
}
|
|
55
66
|
};
|
|
56
67
|
|
|
68
|
+
const FUNCTION_CALL_KEYS: ReadonlySet<string> = new Set([
|
|
69
|
+
"call",
|
|
70
|
+
"args",
|
|
71
|
+
"returnType",
|
|
72
|
+
"catalogId",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
const isFunctionCall = (
|
|
76
|
+
value: unknown,
|
|
77
|
+
): value is {
|
|
78
|
+
readonly call: string;
|
|
79
|
+
readonly args?: Record<string, unknown>;
|
|
80
|
+
} =>
|
|
81
|
+
isPlainObject(value) &&
|
|
82
|
+
typeof value["call"] === "string" &&
|
|
83
|
+
(value["args"] === undefined || isPlainObject(value["args"])) &&
|
|
84
|
+
Object.keys(value).every((key) => FUNCTION_CALL_KEYS.has(key));
|
|
85
|
+
|
|
57
86
|
const isBinding = (value: unknown): value is { readonly path: string } =>
|
|
58
87
|
isPlainObject(value) &&
|
|
59
88
|
Object.keys(value).length === 1 &&
|
|
60
89
|
typeof value["path"] === "string";
|
|
61
90
|
|
|
62
|
-
|
|
63
|
-
isPlainObject(value) &&
|
|
64
|
-
Object.keys(value).length === 1 &&
|
|
65
|
-
isPlainObject(value["template"]) &&
|
|
66
|
-
Object.keys(value["template"]).length === 2 &&
|
|
67
|
-
typeof value["template"]["componentId"] === "string" &&
|
|
68
|
-
typeof value["template"]["path"] === "string";
|
|
69
|
-
|
|
70
|
-
const decodePointer = (path: string): string[] | undefined => {
|
|
71
|
-
if (path === "" || path === "/") return [];
|
|
72
|
-
if (!path.startsWith("/")) return undefined;
|
|
73
|
-
return path
|
|
74
|
-
.slice(1)
|
|
75
|
-
.split("/")
|
|
76
|
-
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
77
|
-
};
|
|
91
|
+
type ChildTemplate = { readonly componentId: string; readonly path: string };
|
|
78
92
|
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
current = current[segment];
|
|
93
|
-
}
|
|
94
|
-
return current;
|
|
93
|
+
const childTemplateOf = (children: unknown): ChildTemplate | undefined => {
|
|
94
|
+
const template =
|
|
95
|
+
isPlainObject(children) &&
|
|
96
|
+
Object.keys(children).length === 1 &&
|
|
97
|
+
isPlainObject(children["template"])
|
|
98
|
+
? children["template"]
|
|
99
|
+
: children;
|
|
100
|
+
return isPlainObject(template) &&
|
|
101
|
+
Object.keys(template).length === 2 &&
|
|
102
|
+
typeof template["componentId"] === "string" &&
|
|
103
|
+
typeof template["path"] === "string"
|
|
104
|
+
? { componentId: template["componentId"], path: template["path"] }
|
|
105
|
+
: undefined;
|
|
95
106
|
};
|
|
96
107
|
|
|
97
108
|
const bindingPath = (value: unknown): string | undefined =>
|
|
98
109
|
isBinding(value) ? value.path : undefined;
|
|
99
110
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
111
|
+
type Scope = { readonly data: unknown; readonly path: string };
|
|
112
|
+
|
|
113
|
+
const pointerIn = (scope: Scope, path: string): string =>
|
|
114
|
+
(path.startsWith("/") ? "" : scope.path) +
|
|
115
|
+
decodePointer(path)
|
|
116
|
+
.map((segment) => `/${segment.replaceAll("~", "~0").replaceAll("/", "~1")}`)
|
|
117
|
+
.join("");
|
|
118
|
+
|
|
119
|
+
const setIn = (
|
|
120
|
+
value: unknown,
|
|
121
|
+
segments: readonly string[],
|
|
122
|
+
leaf: unknown,
|
|
123
|
+
): unknown => {
|
|
124
|
+
const [head, ...rest] = segments;
|
|
125
|
+
if (head === undefined) return leaf;
|
|
126
|
+
if (
|
|
127
|
+
/^(0|[1-9]\d*)$/.test(head) &&
|
|
128
|
+
(Array.isArray(value) || value === undefined || value === null)
|
|
129
|
+
) {
|
|
130
|
+
const list = Array.isArray(value) ? value : [];
|
|
131
|
+
const index = Number(head);
|
|
132
|
+
if (index >= list.length && index > MAX_AUTO_VIVIFY_ARRAY_INDEX) {
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
const copy = [...list];
|
|
136
|
+
copy[index] = setIn(copy[index], rest, leaf);
|
|
137
|
+
return copy;
|
|
138
|
+
}
|
|
139
|
+
const copy: Record<string, unknown> = isRecord(value) ? { ...value } : {};
|
|
140
|
+
setOwnProperty(
|
|
141
|
+
copy,
|
|
142
|
+
head,
|
|
143
|
+
setIn(Object.hasOwn(copy, head) ? copy[head] : undefined, rest, leaf),
|
|
144
|
+
);
|
|
145
|
+
return copy;
|
|
104
146
|
};
|
|
105
147
|
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
148
|
+
const withFieldReferences = (
|
|
149
|
+
value: unknown,
|
|
150
|
+
pointer: string,
|
|
151
|
+
fields: ReadonlyMap<string, unknown>,
|
|
152
|
+
): unknown => {
|
|
153
|
+
if (fields.has(pointer)) return fields.get(pointer);
|
|
154
|
+
let result = value;
|
|
155
|
+
for (const [name, field] of fields) {
|
|
156
|
+
if (name.startsWith(`${pointer}/`)) {
|
|
157
|
+
result = setIn(result, decodePointer(name.slice(pointer.length)), field);
|
|
158
|
+
}
|
|
112
159
|
}
|
|
113
|
-
|
|
160
|
+
return result;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const materializeEntries = (
|
|
164
|
+
value: Record<string, unknown>,
|
|
165
|
+
source: unknown,
|
|
166
|
+
context: ConversionContext,
|
|
167
|
+
evaluate: boolean,
|
|
168
|
+
depth: number,
|
|
169
|
+
positional = false,
|
|
170
|
+
): Record<string, unknown> => {
|
|
114
171
|
const result: Record<string, unknown> = {};
|
|
115
172
|
for (const [key, entry] of Object.entries(value)) {
|
|
116
|
-
|
|
173
|
+
// An action's functionCall runs when the button fires, so only its arguments resolve here.
|
|
174
|
+
const resolved =
|
|
175
|
+
key === "functionCall" && isFunctionCall(entry)
|
|
176
|
+
? {
|
|
177
|
+
...entry,
|
|
178
|
+
...(entry.args !== undefined
|
|
179
|
+
? {
|
|
180
|
+
args: materializeEntries(
|
|
181
|
+
entry.args,
|
|
182
|
+
source,
|
|
183
|
+
context,
|
|
184
|
+
evaluate,
|
|
185
|
+
depth,
|
|
186
|
+
true,
|
|
187
|
+
),
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
}
|
|
191
|
+
: materialize(entry, source, context, evaluate, depth, positional);
|
|
117
192
|
if (resolved !== undefined) {
|
|
118
193
|
setOwnProperty(result, key, resolved);
|
|
119
194
|
}
|
|
@@ -121,6 +196,73 @@ const materialize = (value: unknown, source: unknown): unknown => {
|
|
|
121
196
|
return result;
|
|
122
197
|
};
|
|
123
198
|
|
|
199
|
+
function materialize(
|
|
200
|
+
value: unknown,
|
|
201
|
+
source: unknown,
|
|
202
|
+
context: ConversionContext,
|
|
203
|
+
evaluate = true,
|
|
204
|
+
depth = 0,
|
|
205
|
+
positional = false,
|
|
206
|
+
): unknown {
|
|
207
|
+
if (isBinding(value)) {
|
|
208
|
+
const pointer = pointerIn(
|
|
209
|
+
{ data: source, path: context.scopePath },
|
|
210
|
+
value.path,
|
|
211
|
+
);
|
|
212
|
+
const resolved = resolvePointer(context.surface.dataModel, pointer);
|
|
213
|
+
context.dependencies?.set(pointer, resolved);
|
|
214
|
+
return resolved;
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(value)) {
|
|
217
|
+
const entries = value.map((entry) =>
|
|
218
|
+
materialize(entry, source, context, evaluate, depth, positional),
|
|
219
|
+
);
|
|
220
|
+
return positional
|
|
221
|
+
? entries
|
|
222
|
+
: entries.filter((entry) => entry !== undefined);
|
|
223
|
+
}
|
|
224
|
+
if (!isPlainObject(value)) return value;
|
|
225
|
+
if (!evaluate || !isFunctionCall(value)) {
|
|
226
|
+
return materializeEntries(
|
|
227
|
+
value,
|
|
228
|
+
source,
|
|
229
|
+
context,
|
|
230
|
+
evaluate,
|
|
231
|
+
depth,
|
|
232
|
+
positional,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (!spendEvaluation(context)) return undefined;
|
|
236
|
+
if (depth >= DEPTH_CAP) {
|
|
237
|
+
if (!context.functionDepthWarned) {
|
|
238
|
+
context.warnings.push(
|
|
239
|
+
`A2UI function nesting cap of ${DEPTH_CAP} was reached.`,
|
|
240
|
+
);
|
|
241
|
+
context.functionDepthWarned = true;
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
return evaluateA2uiValueFunction(
|
|
246
|
+
value.call,
|
|
247
|
+
materializeEntries(
|
|
248
|
+
value.args ?? {},
|
|
249
|
+
source,
|
|
250
|
+
context,
|
|
251
|
+
true,
|
|
252
|
+
depth + 1,
|
|
253
|
+
true,
|
|
254
|
+
),
|
|
255
|
+
{
|
|
256
|
+
resolve: (part) =>
|
|
257
|
+
spendEvaluation(context)
|
|
258
|
+
? materialize(part, source, context, true, depth + 1)
|
|
259
|
+
: undefined,
|
|
260
|
+
warn: (message) => context.warnings.push(message),
|
|
261
|
+
templates: context.templates,
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
124
266
|
const firstDefined = (
|
|
125
267
|
props: Record<string, unknown>,
|
|
126
268
|
keys: readonly string[],
|
|
@@ -146,13 +288,158 @@ const sourceSurfaceId = (surface: A2uiSurfaceState): string =>
|
|
|
146
288
|
|
|
147
289
|
type ConversionContext = {
|
|
148
290
|
readonly surface: A2uiSurfaceState;
|
|
291
|
+
readonly stepModel: unknown;
|
|
149
292
|
readonly surfaceId: string;
|
|
150
293
|
readonly warnings: string[];
|
|
151
294
|
emittedNodes: number;
|
|
152
295
|
depthWarned: boolean;
|
|
153
296
|
budgetWarned: boolean;
|
|
154
297
|
templateCapWarned: boolean;
|
|
298
|
+
evaluations: number;
|
|
299
|
+
evaluationBudgetWarned: boolean;
|
|
300
|
+
functionDepthWarned: boolean;
|
|
301
|
+
readonly templates: Map<string, ExpressionPart[] | null>;
|
|
302
|
+
readonly inputFields: Map<string, unknown>;
|
|
303
|
+
readonly textFields: Map<string, boolean>;
|
|
304
|
+
readonly boundActionEntries: {
|
|
305
|
+
readonly target: Record<string, unknown>;
|
|
306
|
+
readonly key: string;
|
|
307
|
+
readonly pointer: string;
|
|
308
|
+
}[];
|
|
309
|
+
readonly boundUserMessages: {
|
|
310
|
+
readonly action: Record<string, unknown>;
|
|
311
|
+
readonly pointer: string;
|
|
312
|
+
}[];
|
|
155
313
|
readonly keepUnknownComponents: boolean;
|
|
314
|
+
readonly bindings: Map<string, A2uiBinding> | undefined;
|
|
315
|
+
readonly cache: Map<string, CachedComponent> | undefined;
|
|
316
|
+
dependencies: Map<string, unknown> | undefined;
|
|
317
|
+
scopePath: string;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
type CachedComponent = {
|
|
321
|
+
readonly props: Record<string, unknown>;
|
|
322
|
+
readonly mapped: UIElement | undefined;
|
|
323
|
+
readonly dependencies: ReadonlyMap<string, unknown>;
|
|
324
|
+
readonly evaluations: number;
|
|
325
|
+
children?: readonly UIElement[];
|
|
326
|
+
result?: UIElement;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const BOUND_KEYS = ["text", "value", "binding"] as const;
|
|
330
|
+
|
|
331
|
+
const INPUT_COMPONENTS: ReadonlySet<string> = new Set([
|
|
332
|
+
"TextField",
|
|
333
|
+
"CheckBox",
|
|
334
|
+
"ChoicePicker",
|
|
335
|
+
"DateTimeInput",
|
|
336
|
+
"Slider",
|
|
337
|
+
]);
|
|
338
|
+
|
|
339
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
340
|
+
|
|
341
|
+
const finiteNumber = (value: unknown): number | undefined =>
|
|
342
|
+
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
343
|
+
|
|
344
|
+
const decimalPlaces = (value: number): number => {
|
|
345
|
+
const [mantissa = "", exponent = "0"] = String(value).split("e");
|
|
346
|
+
return Math.max(0, (mantissa.split(".")[1]?.length ?? 0) - Number(exponent));
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// A spec slider without `steps` is continuous, so its step leaves at least a hundred divisions and keeps every bound value on the grid.
|
|
350
|
+
const sliderStep = (
|
|
351
|
+
min: number,
|
|
352
|
+
max: number,
|
|
353
|
+
value: number | undefined,
|
|
354
|
+
steps: unknown,
|
|
355
|
+
): number | undefined => {
|
|
356
|
+
if (max <= min) return undefined;
|
|
357
|
+
if (typeof steps === "number" && Number.isInteger(steps) && steps >= 1) {
|
|
358
|
+
return (max - min) / steps;
|
|
359
|
+
}
|
|
360
|
+
const precision = Math.max(
|
|
361
|
+
decimalPlaces(min),
|
|
362
|
+
value === undefined ? 0 : decimalPlaces(value),
|
|
363
|
+
);
|
|
364
|
+
return Math.min(
|
|
365
|
+
10 ** Math.floor(Math.log10((max - min) / 100)),
|
|
366
|
+
10 ** -precision,
|
|
367
|
+
);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const recordBindings = (
|
|
371
|
+
node: Record<string, unknown>,
|
|
372
|
+
props: Record<string, unknown>,
|
|
373
|
+
mapped: UIElement,
|
|
374
|
+
scope: Scope,
|
|
375
|
+
context: ConversionContext,
|
|
376
|
+
) => {
|
|
377
|
+
const component = node["component"];
|
|
378
|
+
const name = mapped["name"];
|
|
379
|
+
const boundKey = BOUND_KEYS.find((key) => node[key] !== undefined);
|
|
380
|
+
const value = boundKey === undefined ? undefined : props[boundKey];
|
|
381
|
+
if (INPUT_COMPONENTS.has(String(component)) && typeof name === "string") {
|
|
382
|
+
context.textFields.set(
|
|
383
|
+
name,
|
|
384
|
+
(!context.textFields.has(name) || context.bindings !== undefined) &&
|
|
385
|
+
(component === "TextField" || component === "DateTimeInput"),
|
|
386
|
+
);
|
|
387
|
+
const field = (fallback: unknown) =>
|
|
388
|
+
fallback === undefined ? { $field: name } : { $field: name, fallback };
|
|
389
|
+
// A single-choice picker collects one string, while the spec binds it to a string list.
|
|
390
|
+
const listValued =
|
|
391
|
+
component === "ChoicePicker" &&
|
|
392
|
+
mapped.$type !== "CheckboxGroup" &&
|
|
393
|
+
typeof value !== "string";
|
|
394
|
+
context.inputFields.set(
|
|
395
|
+
name,
|
|
396
|
+
listValued
|
|
397
|
+
? [field(Array.isArray(value) ? value[0] : undefined)]
|
|
398
|
+
: field(value),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
const action = mapped.$action;
|
|
402
|
+
const raw = node["action"];
|
|
403
|
+
if (!action || !isRecord(raw)) return;
|
|
404
|
+
const functionCall = action.type === "a2ui:functionCall";
|
|
405
|
+
const event = isRecord(raw["event"]) ? raw["event"] : raw;
|
|
406
|
+
const userMessage = event["userMessage"];
|
|
407
|
+
if (!functionCall && isBinding(userMessage)) {
|
|
408
|
+
context.boundUserMessages.push({
|
|
409
|
+
action,
|
|
410
|
+
pointer: pointerIn(scope, userMessage.path),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const rawEntries = functionCall
|
|
414
|
+
? isRecord(raw["functionCall"])
|
|
415
|
+
? raw["functionCall"]["args"]
|
|
416
|
+
: undefined
|
|
417
|
+
: event["context"];
|
|
418
|
+
const target = functionCall ? action["args"] : action["context"];
|
|
419
|
+
if (!isRecord(rawEntries) || !isRecord(target)) return;
|
|
420
|
+
for (const [key, entry] of Object.entries(rawEntries)) {
|
|
421
|
+
if (isBinding(entry)) {
|
|
422
|
+
context.boundActionEntries.push({
|
|
423
|
+
target,
|
|
424
|
+
key,
|
|
425
|
+
pointer: pointerIn(scope, entry.path),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const spendEvaluation = (context: ConversionContext): boolean => {
|
|
432
|
+
if (context.evaluations < EVALUATION_BUDGET) {
|
|
433
|
+
context.evaluations++;
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
if (!context.evaluationBudgetWarned) {
|
|
437
|
+
context.warnings.push(
|
|
438
|
+
`A2UI function evaluation budget of ${EVALUATION_BUDGET} was reached.`,
|
|
439
|
+
);
|
|
440
|
+
context.evaluationBudgetWarned = true;
|
|
441
|
+
}
|
|
442
|
+
return false;
|
|
156
443
|
};
|
|
157
444
|
|
|
158
445
|
const reserveNode = (context: ConversionContext): boolean => {
|
|
@@ -184,7 +471,7 @@ const childReferences = (node: Record<string, unknown>): unknown[] => {
|
|
|
184
471
|
|
|
185
472
|
const childrenOf = (
|
|
186
473
|
node: Record<string, unknown>,
|
|
187
|
-
|
|
474
|
+
scope: Scope,
|
|
188
475
|
context: ConversionContext,
|
|
189
476
|
depth: number,
|
|
190
477
|
visited: Set<string>,
|
|
@@ -197,13 +484,7 @@ const childrenOf = (
|
|
|
197
484
|
);
|
|
198
485
|
continue;
|
|
199
486
|
}
|
|
200
|
-
const child = convertComponent(
|
|
201
|
-
childId,
|
|
202
|
-
dataSource,
|
|
203
|
-
context,
|
|
204
|
-
depth + 1,
|
|
205
|
-
visited,
|
|
206
|
-
);
|
|
487
|
+
const child = convertComponent(childId, scope, context, depth + 1, visited);
|
|
207
488
|
if (child) result.push(child);
|
|
208
489
|
}
|
|
209
490
|
return result;
|
|
@@ -236,6 +517,11 @@ const mappedAction = (
|
|
|
236
517
|
context: ConversionContext,
|
|
237
518
|
): UIElement["$action"] | undefined => {
|
|
238
519
|
const action = props["action"];
|
|
520
|
+
if (action === undefined) return undefined;
|
|
521
|
+
const source = {
|
|
522
|
+
surfaceId: context.surfaceId,
|
|
523
|
+
sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
|
|
524
|
+
};
|
|
239
525
|
const event =
|
|
240
526
|
isRecord(action) && isRecord(action["event"]) ? action["event"] : action;
|
|
241
527
|
const actionName =
|
|
@@ -244,15 +530,31 @@ const mappedAction = (
|
|
|
244
530
|
: isRecord(event) && typeof event["name"] === "string"
|
|
245
531
|
? event["name"]
|
|
246
532
|
: undefined;
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
533
|
+
if (actionName) {
|
|
534
|
+
const actionContext = isRecord(event) ? event["context"] : undefined;
|
|
535
|
+
const userMessage = isRecord(event) ? event["userMessage"] : undefined;
|
|
536
|
+
return {
|
|
537
|
+
type: "a2ui:action",
|
|
538
|
+
name: actionName,
|
|
539
|
+
...source,
|
|
540
|
+
...(typeof userMessage === "string" ? { userMessage } : {}),
|
|
541
|
+
...(actionContext !== undefined ? { context: actionContext } : {}),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const functionCall = isRecord(action) ? action["functionCall"] : undefined;
|
|
545
|
+
if (isRecord(functionCall) && typeof functionCall["call"] === "string") {
|
|
546
|
+
const args = functionCall["args"];
|
|
547
|
+
return {
|
|
548
|
+
type: "a2ui:functionCall",
|
|
549
|
+
call: functionCall["call"],
|
|
550
|
+
...source,
|
|
551
|
+
...(isRecord(args) ? { args } : {}),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
context.warnings.push(
|
|
555
|
+
`Component "${String(node["id"] ?? "")}" has a malformed action.`,
|
|
556
|
+
);
|
|
557
|
+
return undefined;
|
|
256
558
|
};
|
|
257
559
|
|
|
258
560
|
const choiceOptions = (value: unknown): { label: string; value: string }[] => {
|
|
@@ -274,6 +576,7 @@ const mappedProps = (
|
|
|
274
576
|
node: Record<string, unknown>,
|
|
275
577
|
props: Record<string, unknown>,
|
|
276
578
|
context: ConversionContext,
|
|
579
|
+
scope: Scope,
|
|
277
580
|
): UIElement | undefined => {
|
|
278
581
|
const component = node["component"];
|
|
279
582
|
|
|
@@ -378,38 +681,63 @@ const mappedProps = (
|
|
|
378
681
|
|
|
379
682
|
if (component === "Button") {
|
|
380
683
|
const label = stringProp(props, ["label", "text"]);
|
|
381
|
-
const
|
|
684
|
+
const variantStyle =
|
|
685
|
+
props["variant"] === "primary"
|
|
686
|
+
? "primary"
|
|
687
|
+
: props["variant"] === "borderless"
|
|
688
|
+
? "ghost"
|
|
689
|
+
: undefined;
|
|
690
|
+
const buttonStyle =
|
|
691
|
+
typeof props["buttonStyle"] === "string"
|
|
692
|
+
? props["buttonStyle"]
|
|
693
|
+
: variantStyle;
|
|
382
694
|
const block = props["block"];
|
|
383
695
|
const submit = props["submit"];
|
|
384
696
|
const action = mappedAction(node, props, context);
|
|
385
697
|
return {
|
|
386
698
|
$type: "Button",
|
|
387
699
|
...(label !== undefined ? { label } : {}),
|
|
388
|
-
...(
|
|
700
|
+
...(buttonStyle !== undefined ? { buttonStyle } : {}),
|
|
389
701
|
...(typeof block === "boolean" ? { block } : {}),
|
|
390
702
|
...(typeof submit === "boolean" ? { submit } : {}),
|
|
391
703
|
...(action ? { $action: action } : {}),
|
|
392
704
|
};
|
|
393
705
|
}
|
|
394
706
|
|
|
395
|
-
const
|
|
396
|
-
const name =
|
|
707
|
+
const bound = bindingPath(firstDefined(node, BOUND_KEYS));
|
|
708
|
+
const name = bound === undefined ? undefined : pointerIn(scope, bound);
|
|
397
709
|
const label = stringProp(props, ["label"]);
|
|
398
710
|
|
|
399
711
|
if (component === "TextField") {
|
|
400
712
|
const placeholder = stringProp(props, ["placeholder"]);
|
|
713
|
+
const inputType =
|
|
714
|
+
props["variant"] === "obscured"
|
|
715
|
+
? "password"
|
|
716
|
+
: props["variant"] === "number"
|
|
717
|
+
? "number"
|
|
718
|
+
: undefined;
|
|
401
719
|
const multiline =
|
|
402
720
|
typeof props["multiline"] === "boolean"
|
|
403
721
|
? props["multiline"]
|
|
404
|
-
: props["
|
|
722
|
+
: props["variant"] === "longText" ||
|
|
723
|
+
props["textFieldType"] === "longText"
|
|
405
724
|
? true
|
|
406
725
|
: undefined;
|
|
726
|
+
const initial = firstDefined(props, ["value", "text"]);
|
|
727
|
+
const defaultValue =
|
|
728
|
+
typeof initial === "string"
|
|
729
|
+
? initial
|
|
730
|
+
: typeof initial === "number" && Number.isFinite(initial)
|
|
731
|
+
? String(initial)
|
|
732
|
+
: undefined;
|
|
407
733
|
return {
|
|
408
734
|
$type: "Input",
|
|
735
|
+
...(inputType !== undefined ? { inputType } : {}),
|
|
409
736
|
...(placeholder !== undefined ? { placeholder } : {}),
|
|
410
737
|
...(multiline !== undefined ? { multiline } : {}),
|
|
411
738
|
...(label !== undefined ? { label } : {}),
|
|
412
739
|
...(name !== undefined ? { name } : {}),
|
|
740
|
+
...(defaultValue !== undefined ? { defaultValue } : {}),
|
|
413
741
|
};
|
|
414
742
|
}
|
|
415
743
|
|
|
@@ -423,43 +751,75 @@ const mappedProps = (
|
|
|
423
751
|
};
|
|
424
752
|
}
|
|
425
753
|
|
|
754
|
+
if (component === "Slider") {
|
|
755
|
+
const min = finiteNumber(props["min"]) ?? 0;
|
|
756
|
+
const max = finiteNumber(props["max"]);
|
|
757
|
+
if (max === undefined || max < min) return undefined;
|
|
758
|
+
const value = finiteNumber(props["value"]);
|
|
759
|
+
const step = sliderStep(
|
|
760
|
+
min,
|
|
761
|
+
max,
|
|
762
|
+
name === undefined
|
|
763
|
+
? value
|
|
764
|
+
: finiteNumber(resolvePointer(context.stepModel, name)),
|
|
765
|
+
props["steps"],
|
|
766
|
+
);
|
|
767
|
+
return {
|
|
768
|
+
$type: "Slider",
|
|
769
|
+
min,
|
|
770
|
+
max,
|
|
771
|
+
...(step !== undefined ? { step } : {}),
|
|
772
|
+
...(value !== undefined ? { defaultValue: value } : {}),
|
|
773
|
+
...(label !== undefined ? { label } : {}),
|
|
774
|
+
...(name !== undefined ? { name } : {}),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
426
778
|
if (component === "ChoicePicker") {
|
|
427
779
|
const options = choiceOptions(props["options"]);
|
|
428
780
|
const value = props["value"];
|
|
429
|
-
const
|
|
781
|
+
const selected =
|
|
430
782
|
typeof value === "string"
|
|
431
|
-
? value
|
|
432
|
-
: Array.isArray(value)
|
|
433
|
-
? value
|
|
434
|
-
:
|
|
435
|
-
|
|
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) {
|
|
783
|
+
? [value]
|
|
784
|
+
: Array.isArray(value)
|
|
785
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
786
|
+
: [];
|
|
787
|
+
if (props["variant"] === "multipleSelection") {
|
|
443
788
|
return {
|
|
444
|
-
$type: "
|
|
789
|
+
$type: "CheckboxGroup",
|
|
445
790
|
options,
|
|
446
791
|
...(label !== undefined ? { label } : {}),
|
|
447
792
|
...(name !== undefined ? { name } : {}),
|
|
448
|
-
...(
|
|
793
|
+
...(selected.length > 0 ? { defaultValue: selected } : {}),
|
|
449
794
|
};
|
|
450
795
|
}
|
|
451
|
-
const
|
|
796
|
+
const [defaultValue] = selected;
|
|
452
797
|
return {
|
|
453
|
-
$type: "
|
|
798
|
+
$type: "RadioGroup",
|
|
454
799
|
options,
|
|
455
|
-
...(placeholder !== undefined ? { placeholder } : {}),
|
|
456
800
|
...(label !== undefined ? { label } : {}),
|
|
457
801
|
...(name !== undefined ? { name } : {}),
|
|
802
|
+
...(defaultValue !== undefined ? { defaultValue } : {}),
|
|
458
803
|
};
|
|
459
804
|
}
|
|
460
805
|
|
|
461
806
|
if (component === "DateTimeInput") {
|
|
462
807
|
const value = stringProp(props, ["value"]);
|
|
808
|
+
const shapeValue =
|
|
809
|
+
name === undefined ? value : resolvePointer(context.stepModel, name);
|
|
810
|
+
if (
|
|
811
|
+
props["enableTime"] === true ||
|
|
812
|
+
(typeof shapeValue === "string" &&
|
|
813
|
+
shapeValue !== "" &&
|
|
814
|
+
!DATE_PATTERN.test(shapeValue))
|
|
815
|
+
) {
|
|
816
|
+
return {
|
|
817
|
+
$type: "Input",
|
|
818
|
+
...(value !== undefined ? { defaultValue: value } : {}),
|
|
819
|
+
...(label !== undefined ? { label } : {}),
|
|
820
|
+
...(name !== undefined ? { name } : {}),
|
|
821
|
+
};
|
|
822
|
+
}
|
|
463
823
|
const min = stringProp(props, ["min"]);
|
|
464
824
|
const max = stringProp(props, ["max"]);
|
|
465
825
|
return {
|
|
@@ -477,23 +837,21 @@ const mappedProps = (
|
|
|
477
837
|
|
|
478
838
|
const convertTemplate = (
|
|
479
839
|
node: Record<string, unknown>,
|
|
480
|
-
|
|
481
|
-
|
|
840
|
+
template: ChildTemplate,
|
|
841
|
+
scope: Scope,
|
|
482
842
|
context: ConversionContext,
|
|
483
843
|
depth: number,
|
|
484
844
|
visited: Set<string>,
|
|
485
|
-
|
|
486
|
-
mappedContainer?: UIElement,
|
|
845
|
+
converted: UIElement | undefined,
|
|
487
846
|
): UIElement | null => {
|
|
488
847
|
if (!reserveNode(context)) return null;
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const list = resolvePointer(dataSource, templateChildren.template.path);
|
|
848
|
+
const container = converted ?? { $type: "ListView" };
|
|
849
|
+
const itemized = container.$type === "ListView";
|
|
850
|
+
const list = resolvePointer(
|
|
851
|
+
context.surface.dataModel,
|
|
852
|
+
pointerIn(scope, template.path),
|
|
853
|
+
);
|
|
854
|
+
const listPointer = pointerIn(scope, template.path);
|
|
497
855
|
if (!Array.isArray(list)) {
|
|
498
856
|
context.warnings.push(
|
|
499
857
|
`Template on component "${String(node["id"] ?? "")}" did not resolve to a list.`,
|
|
@@ -509,21 +867,21 @@ const convertTemplate = (
|
|
|
509
867
|
}
|
|
510
868
|
const children: UIElement[] = [];
|
|
511
869
|
for (let index = 0; index < itemCount; index++) {
|
|
512
|
-
if (
|
|
870
|
+
if (itemized && !reserveNode(context)) break;
|
|
513
871
|
const child = convertComponent(
|
|
514
|
-
|
|
515
|
-
list[index],
|
|
872
|
+
template.componentId,
|
|
873
|
+
{ data: list[index], path: `${listPointer}/${index}` },
|
|
516
874
|
context,
|
|
517
875
|
depth + 1,
|
|
518
876
|
visited,
|
|
519
877
|
);
|
|
520
|
-
if (
|
|
521
|
-
if (child) children.push(child);
|
|
522
|
-
} else {
|
|
878
|
+
if (itemized) {
|
|
523
879
|
children.push({
|
|
524
880
|
$type: "ListViewItem",
|
|
525
881
|
...(child ? { children: child } : {}),
|
|
526
882
|
});
|
|
883
|
+
} else if (child) {
|
|
884
|
+
children.push(child);
|
|
527
885
|
}
|
|
528
886
|
}
|
|
529
887
|
return { ...container, children };
|
|
@@ -531,7 +889,7 @@ const convertTemplate = (
|
|
|
531
889
|
|
|
532
890
|
function convertComponent(
|
|
533
891
|
componentId: string,
|
|
534
|
-
|
|
892
|
+
scope: Scope,
|
|
535
893
|
context: ConversionContext,
|
|
536
894
|
depth: number,
|
|
537
895
|
visited: Set<string>,
|
|
@@ -562,32 +920,98 @@ function convertComponent(
|
|
|
562
920
|
);
|
|
563
921
|
return null;
|
|
564
922
|
}
|
|
565
|
-
const
|
|
566
|
-
const hasTemplate = isTemplateChildren(templateChildren);
|
|
923
|
+
const template = childTemplateOf(node["children"]);
|
|
567
924
|
if (
|
|
568
925
|
!SUPPORTED_COMPONENTS.has(component) &&
|
|
569
926
|
!context.keepUnknownComponents &&
|
|
570
|
-
!
|
|
927
|
+
!template
|
|
571
928
|
) {
|
|
572
929
|
context.warnings.push(
|
|
573
930
|
`Unknown A2UI component "${component}" was skipped.`,
|
|
574
931
|
);
|
|
575
932
|
return null;
|
|
576
933
|
}
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
934
|
+
const cacheKey = context.cache
|
|
935
|
+
? JSON.stringify([componentId, scope.path, depth])
|
|
936
|
+
: "";
|
|
937
|
+
let cached = context.cache?.get(cacheKey);
|
|
938
|
+
if (
|
|
939
|
+
cached &&
|
|
940
|
+
(context.evaluations + cached.evaluations > EVALUATION_BUDGET ||
|
|
941
|
+
[...cached.dependencies].some(
|
|
942
|
+
([path, value]) =>
|
|
943
|
+
!Object.is(resolvePointer(context.surface.dataModel, path), value),
|
|
944
|
+
))
|
|
945
|
+
)
|
|
946
|
+
cached = undefined;
|
|
947
|
+
let props: Record<string, unknown>;
|
|
948
|
+
let mapped: UIElement | undefined;
|
|
949
|
+
if (cached) {
|
|
950
|
+
props = cached.props;
|
|
951
|
+
mapped = cached.mapped;
|
|
952
|
+
context.evaluations += cached.evaluations;
|
|
953
|
+
} else {
|
|
954
|
+
props = {};
|
|
955
|
+
const dependencies = new Map<string, unknown>();
|
|
956
|
+
const evaluations = context.evaluations;
|
|
957
|
+
context.dependencies = dependencies;
|
|
958
|
+
context.scopePath = scope.path;
|
|
959
|
+
for (const [key, value] of Object.entries(node)) {
|
|
960
|
+
if (
|
|
961
|
+
key === "id" ||
|
|
962
|
+
key === "component" ||
|
|
963
|
+
key === "children" ||
|
|
964
|
+
key === "child"
|
|
965
|
+
)
|
|
966
|
+
continue;
|
|
967
|
+
const resolved = materialize(
|
|
968
|
+
value,
|
|
969
|
+
scope.data,
|
|
970
|
+
context,
|
|
971
|
+
key !== "checks",
|
|
972
|
+
);
|
|
973
|
+
if (resolved !== undefined) setOwnProperty(props, key, resolved);
|
|
974
|
+
}
|
|
975
|
+
context.dependencies = undefined;
|
|
976
|
+
mapped = mappedProps(node, props, context, scope);
|
|
977
|
+
if (context.cache && context.warnings.length === 0 && !mapped?.$action) {
|
|
978
|
+
cached = {
|
|
979
|
+
props,
|
|
980
|
+
mapped,
|
|
981
|
+
dependencies,
|
|
982
|
+
evaluations: context.evaluations - evaluations,
|
|
983
|
+
};
|
|
984
|
+
context.cache.set(cacheKey, cached);
|
|
586
985
|
}
|
|
587
|
-
const resolved = materialize(value, dataSource);
|
|
588
|
-
if (resolved !== undefined) setOwnProperty(props, key, resolved);
|
|
589
986
|
}
|
|
590
|
-
|
|
987
|
+
if (
|
|
988
|
+
mapped &&
|
|
989
|
+
context.bindings &&
|
|
990
|
+
typeof mapped["name"] === "string" &&
|
|
991
|
+
INPUT_COMPONENTS.has(component)
|
|
992
|
+
) {
|
|
993
|
+
const field = mapped.$type === "Checkbox" ? "checked" : "value";
|
|
994
|
+
const defaultField =
|
|
995
|
+
mapped.$type === "Checkbox" ? "defaultChecked" : "defaultValue";
|
|
996
|
+
const fallback =
|
|
997
|
+
mapped.$type === "Checkbox"
|
|
998
|
+
? false
|
|
999
|
+
: mapped.$type === "CheckboxGroup"
|
|
1000
|
+
? []
|
|
1001
|
+
: mapped.$type === "Slider"
|
|
1002
|
+
? mapped["min"]
|
|
1003
|
+
: "";
|
|
1004
|
+
const { [defaultField]: initialValue, ...rest } = mapped;
|
|
1005
|
+
mapped = { ...rest, [field]: initialValue ?? mapped[field] ?? fallback };
|
|
1006
|
+
context.bindings.set(mapped["name"] as string, {
|
|
1007
|
+
value: mapped[field],
|
|
1008
|
+
arrayValue:
|
|
1009
|
+
component === "ChoicePicker" &&
|
|
1010
|
+
mapped.$type !== "CheckboxGroup" &&
|
|
1011
|
+
typeof props["value"] !== "string",
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
591
1015
|
if (!mapped && SUPPORTED_COMPONENTS.has(component)) {
|
|
592
1016
|
context.warnings.push(
|
|
593
1017
|
`A2UI component "${component}" could not be mapped and was skipped.`,
|
|
@@ -603,22 +1027,22 @@ function convertComponent(
|
|
|
603
1027
|
),
|
|
604
1028
|
}
|
|
605
1029
|
: undefined;
|
|
606
|
-
if (
|
|
1030
|
+
if (template) {
|
|
607
1031
|
return convertTemplate(
|
|
608
1032
|
node,
|
|
609
|
-
|
|
610
|
-
|
|
1033
|
+
template,
|
|
1034
|
+
scope,
|
|
611
1035
|
context,
|
|
612
1036
|
depth,
|
|
613
1037
|
visited,
|
|
614
|
-
retained,
|
|
615
|
-
component === "List" ? mapped : undefined,
|
|
1038
|
+
CHILD_LIST_COMPONENTS.has(component) ? mapped : retained,
|
|
616
1039
|
);
|
|
617
1040
|
}
|
|
618
1041
|
if (!reserveNode(context)) return null;
|
|
619
1042
|
const converted = mapped ?? retained;
|
|
620
1043
|
if (!converted) return null;
|
|
621
|
-
|
|
1044
|
+
recordBindings(node, props, converted, scope, context);
|
|
1045
|
+
const children = childrenOf(node, scope, context, depth, visited);
|
|
622
1046
|
if (mapped?.$type === "Button" && mapped["label"] === undefined) {
|
|
623
1047
|
const label = textLabel(node, children, context);
|
|
624
1048
|
if (label !== undefined) return { ...mapped, label };
|
|
@@ -627,10 +1051,21 @@ function convertComponent(
|
|
|
627
1051
|
component === "List" && mapped?.$type === "ListView"
|
|
628
1052
|
? children.map((child) => ({ $type: "ListViewItem", children: child }))
|
|
629
1053
|
: children;
|
|
630
|
-
|
|
1054
|
+
if (
|
|
1055
|
+
cached?.result &&
|
|
1056
|
+
cached.children?.length === listChildren.length &&
|
|
1057
|
+
cached.children.every((child, index) => child === listChildren[index])
|
|
1058
|
+
)
|
|
1059
|
+
return cached.result;
|
|
1060
|
+
const result = {
|
|
631
1061
|
...converted,
|
|
632
1062
|
...(listChildren.length > 0 ? { children: listChildren } : {}),
|
|
633
1063
|
};
|
|
1064
|
+
if (cached) {
|
|
1065
|
+
cached.children = listChildren;
|
|
1066
|
+
cached.result = result;
|
|
1067
|
+
}
|
|
1068
|
+
return result;
|
|
634
1069
|
} finally {
|
|
635
1070
|
visited.delete(componentId);
|
|
636
1071
|
}
|
|
@@ -638,11 +1073,41 @@ function convertComponent(
|
|
|
638
1073
|
|
|
639
1074
|
export function convertSurfaceToUISpec(
|
|
640
1075
|
surface: A2uiSurfaceState,
|
|
641
|
-
options: {
|
|
1076
|
+
options: {
|
|
1077
|
+
readonly keepUnknownComponents?: boolean;
|
|
1078
|
+
} = {},
|
|
642
1079
|
): {
|
|
643
1080
|
spec: UIElement | null;
|
|
644
1081
|
warnings: string[];
|
|
645
1082
|
} {
|
|
1083
|
+
return convertSurface(surface, options.keepUnknownComponents === true);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
export const createLiveSurfaceConverter = (surface: A2uiSurfaceState) => {
|
|
1087
|
+
const cache = new Map<string, CachedComponent>();
|
|
1088
|
+
const templates = new Map<string, ExpressionPart[] | null>();
|
|
1089
|
+
return (dataModel: unknown) => {
|
|
1090
|
+
const bindings = new Map<string, A2uiBinding>();
|
|
1091
|
+
const converted = convertSurface(
|
|
1092
|
+
{ ...surface, dataModel, [A2UI_SURFACE_ID]: sourceSurfaceId(surface) },
|
|
1093
|
+
false,
|
|
1094
|
+
bindings,
|
|
1095
|
+
cache,
|
|
1096
|
+
templates,
|
|
1097
|
+
surface.dataModel,
|
|
1098
|
+
);
|
|
1099
|
+
return { ...converted, bindings };
|
|
1100
|
+
};
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
function convertSurface(
|
|
1104
|
+
surface: A2uiSurfaceState & { [A2UI_SURFACE_ID]?: string },
|
|
1105
|
+
keepUnknownComponents: boolean,
|
|
1106
|
+
bindings?: Map<string, A2uiBinding>,
|
|
1107
|
+
cache?: Map<string, CachedComponent>,
|
|
1108
|
+
templates = new Map<string, ExpressionPart[] | null>(),
|
|
1109
|
+
stepModel: unknown = surface.dataModel,
|
|
1110
|
+
): { spec: UIElement | null; warnings: string[] } {
|
|
646
1111
|
const warnings: string[] = [];
|
|
647
1112
|
if (!surface.components.has("root")) {
|
|
648
1113
|
return {
|
|
@@ -652,22 +1117,54 @@ export function convertSurfaceToUISpec(
|
|
|
652
1117
|
}
|
|
653
1118
|
const context: ConversionContext = {
|
|
654
1119
|
surface,
|
|
1120
|
+
stepModel,
|
|
655
1121
|
surfaceId: sourceSurfaceId(surface),
|
|
656
1122
|
warnings,
|
|
657
1123
|
emittedNodes: 0,
|
|
658
1124
|
depthWarned: false,
|
|
659
1125
|
budgetWarned: false,
|
|
660
1126
|
templateCapWarned: false,
|
|
661
|
-
|
|
1127
|
+
evaluations: 0,
|
|
1128
|
+
evaluationBudgetWarned: false,
|
|
1129
|
+
functionDepthWarned: false,
|
|
1130
|
+
templates,
|
|
1131
|
+
inputFields: new Map(),
|
|
1132
|
+
textFields: new Map(),
|
|
1133
|
+
boundActionEntries: [],
|
|
1134
|
+
boundUserMessages: [],
|
|
1135
|
+
keepUnknownComponents,
|
|
1136
|
+
bindings,
|
|
1137
|
+
cache,
|
|
1138
|
+
dependencies: undefined,
|
|
1139
|
+
scopePath: "",
|
|
662
1140
|
};
|
|
663
1141
|
try {
|
|
664
1142
|
const spec = convertComponent(
|
|
665
1143
|
"root",
|
|
666
|
-
surface.dataModel,
|
|
1144
|
+
{ data: surface.dataModel, path: "" },
|
|
667
1145
|
context,
|
|
668
1146
|
0,
|
|
669
1147
|
new Set(),
|
|
670
1148
|
);
|
|
1149
|
+
for (const { target, key, pointer } of context.boundActionEntries) {
|
|
1150
|
+
const value = withFieldReferences(
|
|
1151
|
+
target[key],
|
|
1152
|
+
pointer,
|
|
1153
|
+
context.inputFields,
|
|
1154
|
+
);
|
|
1155
|
+
if (value !== undefined) setOwnProperty(target, key, value);
|
|
1156
|
+
}
|
|
1157
|
+
for (const { action, pointer } of context.boundUserMessages) {
|
|
1158
|
+
if (!context.textFields.get(pointer)) continue;
|
|
1159
|
+
const fallback = action["userMessage"];
|
|
1160
|
+
setOwnProperty(
|
|
1161
|
+
action,
|
|
1162
|
+
"userMessage",
|
|
1163
|
+
typeof fallback === "string"
|
|
1164
|
+
? { $field: pointer, fallback }
|
|
1165
|
+
: { $field: pointer },
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
671
1168
|
return { spec, warnings };
|
|
672
1169
|
} catch {
|
|
673
1170
|
warnings.push("A2UI surface conversion encountered malformed input.");
|