@assistant-ui/react-generative-ui 0.0.20 → 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 +369 -87
- 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 +1982 -145
- package/src/a2ui/convert.ts +677 -137
- 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 => {
|
|
@@ -167,59 +454,107 @@ const reserveNode = (context: ConversionContext): boolean => {
|
|
|
167
454
|
return true;
|
|
168
455
|
};
|
|
169
456
|
|
|
457
|
+
const childReferences = (node: Record<string, unknown>): unknown[] => {
|
|
458
|
+
const children = node["children"];
|
|
459
|
+
const references: unknown[] = Array.isArray(children) ? [...children] : [];
|
|
460
|
+
if (node["child"] !== undefined) references.push(node["child"]);
|
|
461
|
+
if (node["component"] === "Modal") {
|
|
462
|
+
references.push(node["trigger"], node["content"]);
|
|
463
|
+
}
|
|
464
|
+
if (node["component"] === "Tabs" && Array.isArray(node["tabs"])) {
|
|
465
|
+
for (const tab of node["tabs"]) {
|
|
466
|
+
references.push(isRecord(tab) ? tab["child"] : undefined);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return references;
|
|
470
|
+
};
|
|
471
|
+
|
|
170
472
|
const childrenOf = (
|
|
171
473
|
node: Record<string, unknown>,
|
|
172
|
-
|
|
474
|
+
scope: Scope,
|
|
173
475
|
context: ConversionContext,
|
|
174
476
|
depth: number,
|
|
175
477
|
visited: Set<string>,
|
|
176
478
|
): UIElement[] => {
|
|
177
|
-
const children = node["children"];
|
|
178
|
-
if (!Array.isArray(children)) return [];
|
|
179
479
|
const result: UIElement[] = [];
|
|
180
|
-
for (const childId of
|
|
480
|
+
for (const childId of childReferences(node)) {
|
|
181
481
|
if (typeof childId !== "string") {
|
|
182
482
|
context.warnings.push(
|
|
183
483
|
`Component "${String(node["id"] ?? "")}" has a malformed child reference.`,
|
|
184
484
|
);
|
|
185
485
|
continue;
|
|
186
486
|
}
|
|
187
|
-
const child = convertComponent(
|
|
188
|
-
childId,
|
|
189
|
-
dataSource,
|
|
190
|
-
context,
|
|
191
|
-
depth + 1,
|
|
192
|
-
visited,
|
|
193
|
-
);
|
|
487
|
+
const child = convertComponent(childId, scope, context, depth + 1, visited);
|
|
194
488
|
if (child) result.push(child);
|
|
195
489
|
}
|
|
196
490
|
return result;
|
|
197
491
|
};
|
|
198
492
|
|
|
493
|
+
const textLabel = (
|
|
494
|
+
node: Record<string, unknown>,
|
|
495
|
+
children: readonly UIElement[],
|
|
496
|
+
context: ConversionContext,
|
|
497
|
+
): string | undefined => {
|
|
498
|
+
const references = childReferences(node);
|
|
499
|
+
const [reference] = references;
|
|
500
|
+
const [child] = children;
|
|
501
|
+
if (
|
|
502
|
+
references.length !== 1 ||
|
|
503
|
+
children.length !== 1 ||
|
|
504
|
+
typeof reference !== "string" ||
|
|
505
|
+
!child ||
|
|
506
|
+
context.surface.components.get(reference)?.["component"] !== "Text"
|
|
507
|
+
) {
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
const text = child.$type === "Header" ? child["text"] : child["value"];
|
|
511
|
+
return typeof text === "string" ? text : undefined;
|
|
512
|
+
};
|
|
513
|
+
|
|
199
514
|
const mappedAction = (
|
|
200
515
|
node: Record<string, unknown>,
|
|
201
516
|
props: Record<string, unknown>,
|
|
202
|
-
dataSource: unknown,
|
|
203
517
|
context: ConversionContext,
|
|
204
518
|
): UIElement["$action"] | undefined => {
|
|
205
519
|
const action = props["action"];
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
? action
|
|
209
|
-
: isRecord(action) && typeof action["name"] === "string"
|
|
210
|
-
? action["name"]
|
|
211
|
-
: undefined;
|
|
212
|
-
if (!actionName) return undefined;
|
|
213
|
-
const rawContext = isRecord(action) ? action["context"] : undefined;
|
|
214
|
-
const resolvedContext =
|
|
215
|
-
rawContext === undefined ? undefined : materialize(rawContext, dataSource);
|
|
216
|
-
return {
|
|
217
|
-
type: "a2ui:action",
|
|
218
|
-
name: actionName,
|
|
520
|
+
if (action === undefined) return undefined;
|
|
521
|
+
const source = {
|
|
219
522
|
surfaceId: context.surfaceId,
|
|
220
523
|
sourceComponentId: typeof node["id"] === "string" ? node["id"] : "",
|
|
221
|
-
...(resolvedContext !== undefined ? { context: resolvedContext } : {}),
|
|
222
524
|
};
|
|
525
|
+
const event =
|
|
526
|
+
isRecord(action) && isRecord(action["event"]) ? action["event"] : action;
|
|
527
|
+
const actionName =
|
|
528
|
+
typeof event === "string"
|
|
529
|
+
? event
|
|
530
|
+
: isRecord(event) && typeof event["name"] === "string"
|
|
531
|
+
? event["name"]
|
|
532
|
+
: undefined;
|
|
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;
|
|
223
558
|
};
|
|
224
559
|
|
|
225
560
|
const choiceOptions = (value: unknown): { label: string; value: string }[] => {
|
|
@@ -240,8 +575,8 @@ const choiceOptions = (value: unknown): { label: string; value: string }[] => {
|
|
|
240
575
|
const mappedProps = (
|
|
241
576
|
node: Record<string, unknown>,
|
|
242
577
|
props: Record<string, unknown>,
|
|
243
|
-
dataSource: unknown,
|
|
244
578
|
context: ConversionContext,
|
|
579
|
+
scope: Scope,
|
|
245
580
|
): UIElement | undefined => {
|
|
246
581
|
const component = node["component"];
|
|
247
582
|
|
|
@@ -346,38 +681,63 @@ const mappedProps = (
|
|
|
346
681
|
|
|
347
682
|
if (component === "Button") {
|
|
348
683
|
const label = stringProp(props, ["label", "text"]);
|
|
349
|
-
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;
|
|
350
694
|
const block = props["block"];
|
|
351
695
|
const submit = props["submit"];
|
|
352
|
-
const action = mappedAction(node, props,
|
|
696
|
+
const action = mappedAction(node, props, context);
|
|
353
697
|
return {
|
|
354
698
|
$type: "Button",
|
|
355
699
|
...(label !== undefined ? { label } : {}),
|
|
356
|
-
...(
|
|
700
|
+
...(buttonStyle !== undefined ? { buttonStyle } : {}),
|
|
357
701
|
...(typeof block === "boolean" ? { block } : {}),
|
|
358
702
|
...(typeof submit === "boolean" ? { submit } : {}),
|
|
359
703
|
...(action ? { $action: action } : {}),
|
|
360
704
|
};
|
|
361
705
|
}
|
|
362
706
|
|
|
363
|
-
const
|
|
364
|
-
const name =
|
|
707
|
+
const bound = bindingPath(firstDefined(node, BOUND_KEYS));
|
|
708
|
+
const name = bound === undefined ? undefined : pointerIn(scope, bound);
|
|
365
709
|
const label = stringProp(props, ["label"]);
|
|
366
710
|
|
|
367
711
|
if (component === "TextField") {
|
|
368
712
|
const placeholder = stringProp(props, ["placeholder"]);
|
|
713
|
+
const inputType =
|
|
714
|
+
props["variant"] === "obscured"
|
|
715
|
+
? "password"
|
|
716
|
+
: props["variant"] === "number"
|
|
717
|
+
? "number"
|
|
718
|
+
: undefined;
|
|
369
719
|
const multiline =
|
|
370
720
|
typeof props["multiline"] === "boolean"
|
|
371
721
|
? props["multiline"]
|
|
372
|
-
: props["
|
|
722
|
+
: props["variant"] === "longText" ||
|
|
723
|
+
props["textFieldType"] === "longText"
|
|
373
724
|
? true
|
|
374
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;
|
|
375
733
|
return {
|
|
376
734
|
$type: "Input",
|
|
735
|
+
...(inputType !== undefined ? { inputType } : {}),
|
|
377
736
|
...(placeholder !== undefined ? { placeholder } : {}),
|
|
378
737
|
...(multiline !== undefined ? { multiline } : {}),
|
|
379
738
|
...(label !== undefined ? { label } : {}),
|
|
380
739
|
...(name !== undefined ? { name } : {}),
|
|
740
|
+
...(defaultValue !== undefined ? { defaultValue } : {}),
|
|
381
741
|
};
|
|
382
742
|
}
|
|
383
743
|
|
|
@@ -391,43 +751,75 @@ const mappedProps = (
|
|
|
391
751
|
};
|
|
392
752
|
}
|
|
393
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
|
+
|
|
394
778
|
if (component === "ChoicePicker") {
|
|
395
779
|
const options = choiceOptions(props["options"]);
|
|
396
780
|
const value = props["value"];
|
|
397
|
-
const
|
|
781
|
+
const selected =
|
|
398
782
|
typeof value === "string"
|
|
399
|
-
? value
|
|
400
|
-
: Array.isArray(value)
|
|
401
|
-
? value
|
|
402
|
-
:
|
|
403
|
-
|
|
404
|
-
props["variant"] === "radio" ||
|
|
405
|
-
props["variant"] === "single" ||
|
|
406
|
-
props["variant"] === "singleSelection" ||
|
|
407
|
-
props["displayStyle"] === "radio" ||
|
|
408
|
-
(props["variant"] === "mutuallyExclusive" &&
|
|
409
|
-
props["displayStyle"] !== "chips");
|
|
410
|
-
if (radioStyle) {
|
|
783
|
+
? [value]
|
|
784
|
+
: Array.isArray(value)
|
|
785
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
786
|
+
: [];
|
|
787
|
+
if (props["variant"] === "multipleSelection") {
|
|
411
788
|
return {
|
|
412
|
-
$type: "
|
|
789
|
+
$type: "CheckboxGroup",
|
|
413
790
|
options,
|
|
414
791
|
...(label !== undefined ? { label } : {}),
|
|
415
792
|
...(name !== undefined ? { name } : {}),
|
|
416
|
-
...(
|
|
793
|
+
...(selected.length > 0 ? { defaultValue: selected } : {}),
|
|
417
794
|
};
|
|
418
795
|
}
|
|
419
|
-
const
|
|
796
|
+
const [defaultValue] = selected;
|
|
420
797
|
return {
|
|
421
|
-
$type: "
|
|
798
|
+
$type: "RadioGroup",
|
|
422
799
|
options,
|
|
423
|
-
...(placeholder !== undefined ? { placeholder } : {}),
|
|
424
800
|
...(label !== undefined ? { label } : {}),
|
|
425
801
|
...(name !== undefined ? { name } : {}),
|
|
802
|
+
...(defaultValue !== undefined ? { defaultValue } : {}),
|
|
426
803
|
};
|
|
427
804
|
}
|
|
428
805
|
|
|
429
806
|
if (component === "DateTimeInput") {
|
|
430
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
|
+
}
|
|
431
823
|
const min = stringProp(props, ["min"]);
|
|
432
824
|
const max = stringProp(props, ["max"]);
|
|
433
825
|
return {
|
|
@@ -445,23 +837,21 @@ const mappedProps = (
|
|
|
445
837
|
|
|
446
838
|
const convertTemplate = (
|
|
447
839
|
node: Record<string, unknown>,
|
|
448
|
-
|
|
449
|
-
|
|
840
|
+
template: ChildTemplate,
|
|
841
|
+
scope: Scope,
|
|
450
842
|
context: ConversionContext,
|
|
451
843
|
depth: number,
|
|
452
844
|
visited: Set<string>,
|
|
453
|
-
|
|
454
|
-
mappedContainer?: UIElement,
|
|
845
|
+
converted: UIElement | undefined,
|
|
455
846
|
): UIElement | null => {
|
|
456
847
|
if (!reserveNode(context)) return null;
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
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);
|
|
465
855
|
if (!Array.isArray(list)) {
|
|
466
856
|
context.warnings.push(
|
|
467
857
|
`Template on component "${String(node["id"] ?? "")}" did not resolve to a list.`,
|
|
@@ -477,21 +867,21 @@ const convertTemplate = (
|
|
|
477
867
|
}
|
|
478
868
|
const children: UIElement[] = [];
|
|
479
869
|
for (let index = 0; index < itemCount; index++) {
|
|
480
|
-
if (
|
|
870
|
+
if (itemized && !reserveNode(context)) break;
|
|
481
871
|
const child = convertComponent(
|
|
482
|
-
|
|
483
|
-
list[index],
|
|
872
|
+
template.componentId,
|
|
873
|
+
{ data: list[index], path: `${listPointer}/${index}` },
|
|
484
874
|
context,
|
|
485
875
|
depth + 1,
|
|
486
876
|
visited,
|
|
487
877
|
);
|
|
488
|
-
if (
|
|
489
|
-
if (child) children.push(child);
|
|
490
|
-
} else {
|
|
878
|
+
if (itemized) {
|
|
491
879
|
children.push({
|
|
492
880
|
$type: "ListViewItem",
|
|
493
881
|
...(child ? { children: child } : {}),
|
|
494
882
|
});
|
|
883
|
+
} else if (child) {
|
|
884
|
+
children.push(child);
|
|
495
885
|
}
|
|
496
886
|
}
|
|
497
887
|
return { ...container, children };
|
|
@@ -499,7 +889,7 @@ const convertTemplate = (
|
|
|
499
889
|
|
|
500
890
|
function convertComponent(
|
|
501
891
|
componentId: string,
|
|
502
|
-
|
|
892
|
+
scope: Scope,
|
|
503
893
|
context: ConversionContext,
|
|
504
894
|
depth: number,
|
|
505
895
|
visited: Set<string>,
|
|
@@ -530,25 +920,98 @@ function convertComponent(
|
|
|
530
920
|
);
|
|
531
921
|
return null;
|
|
532
922
|
}
|
|
533
|
-
const
|
|
534
|
-
const hasTemplate = isTemplateChildren(templateChildren);
|
|
923
|
+
const template = childTemplateOf(node["children"]);
|
|
535
924
|
if (
|
|
536
925
|
!SUPPORTED_COMPONENTS.has(component) &&
|
|
537
926
|
!context.keepUnknownComponents &&
|
|
538
|
-
!
|
|
927
|
+
!template
|
|
539
928
|
) {
|
|
540
929
|
context.warnings.push(
|
|
541
930
|
`Unknown A2UI component "${component}" was skipped.`,
|
|
542
931
|
);
|
|
543
932
|
return null;
|
|
544
933
|
}
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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);
|
|
985
|
+
}
|
|
550
986
|
}
|
|
551
|
-
|
|
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
|
+
|
|
552
1015
|
if (!mapped && SUPPORTED_COMPONENTS.has(component)) {
|
|
553
1016
|
context.warnings.push(
|
|
554
1017
|
`A2UI component "${component}" could not be mapped and was skipped.`,
|
|
@@ -564,30 +1027,45 @@ function convertComponent(
|
|
|
564
1027
|
),
|
|
565
1028
|
}
|
|
566
1029
|
: undefined;
|
|
567
|
-
if (
|
|
1030
|
+
if (template) {
|
|
568
1031
|
return convertTemplate(
|
|
569
1032
|
node,
|
|
570
|
-
|
|
571
|
-
|
|
1033
|
+
template,
|
|
1034
|
+
scope,
|
|
572
1035
|
context,
|
|
573
1036
|
depth,
|
|
574
1037
|
visited,
|
|
575
|
-
retained,
|
|
576
|
-
component === "List" ? mapped : undefined,
|
|
1038
|
+
CHILD_LIST_COMPONENTS.has(component) ? mapped : retained,
|
|
577
1039
|
);
|
|
578
1040
|
}
|
|
579
1041
|
if (!reserveNode(context)) return null;
|
|
580
1042
|
const converted = mapped ?? retained;
|
|
581
1043
|
if (!converted) return null;
|
|
582
|
-
|
|
1044
|
+
recordBindings(node, props, converted, scope, context);
|
|
1045
|
+
const children = childrenOf(node, scope, context, depth, visited);
|
|
1046
|
+
if (mapped?.$type === "Button" && mapped["label"] === undefined) {
|
|
1047
|
+
const label = textLabel(node, children, context);
|
|
1048
|
+
if (label !== undefined) return { ...mapped, label };
|
|
1049
|
+
}
|
|
583
1050
|
const listChildren =
|
|
584
1051
|
component === "List" && mapped?.$type === "ListView"
|
|
585
1052
|
? children.map((child) => ({ $type: "ListViewItem", children: child }))
|
|
586
1053
|
: children;
|
|
587
|
-
|
|
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 = {
|
|
588
1061
|
...converted,
|
|
589
1062
|
...(listChildren.length > 0 ? { children: listChildren } : {}),
|
|
590
1063
|
};
|
|
1064
|
+
if (cached) {
|
|
1065
|
+
cached.children = listChildren;
|
|
1066
|
+
cached.result = result;
|
|
1067
|
+
}
|
|
1068
|
+
return result;
|
|
591
1069
|
} finally {
|
|
592
1070
|
visited.delete(componentId);
|
|
593
1071
|
}
|
|
@@ -595,11 +1073,41 @@ function convertComponent(
|
|
|
595
1073
|
|
|
596
1074
|
export function convertSurfaceToUISpec(
|
|
597
1075
|
surface: A2uiSurfaceState,
|
|
598
|
-
options: {
|
|
1076
|
+
options: {
|
|
1077
|
+
readonly keepUnknownComponents?: boolean;
|
|
1078
|
+
} = {},
|
|
599
1079
|
): {
|
|
600
1080
|
spec: UIElement | null;
|
|
601
1081
|
warnings: string[];
|
|
602
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[] } {
|
|
603
1111
|
const warnings: string[] = [];
|
|
604
1112
|
if (!surface.components.has("root")) {
|
|
605
1113
|
return {
|
|
@@ -609,22 +1117,54 @@ export function convertSurfaceToUISpec(
|
|
|
609
1117
|
}
|
|
610
1118
|
const context: ConversionContext = {
|
|
611
1119
|
surface,
|
|
1120
|
+
stepModel,
|
|
612
1121
|
surfaceId: sourceSurfaceId(surface),
|
|
613
1122
|
warnings,
|
|
614
1123
|
emittedNodes: 0,
|
|
615
1124
|
depthWarned: false,
|
|
616
1125
|
budgetWarned: false,
|
|
617
1126
|
templateCapWarned: false,
|
|
618
|
-
|
|
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: "",
|
|
619
1140
|
};
|
|
620
1141
|
try {
|
|
621
1142
|
const spec = convertComponent(
|
|
622
1143
|
"root",
|
|
623
|
-
surface.dataModel,
|
|
1144
|
+
{ data: surface.dataModel, path: "" },
|
|
624
1145
|
context,
|
|
625
1146
|
0,
|
|
626
1147
|
new Set(),
|
|
627
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
|
+
}
|
|
628
1168
|
return { spec, warnings };
|
|
629
1169
|
} catch {
|
|
630
1170
|
warnings.push("A2UI surface conversion encountered malformed input.");
|