@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
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
const MAX_TEMPLATE_LENGTH = 10_000;
|
|
2
|
+
const MAX_TEMPLATE_PARTS = 1_000;
|
|
3
|
+
const MAX_EXPRESSION_DEPTH = 32;
|
|
4
|
+
|
|
5
|
+
export type ExpressionPart =
|
|
6
|
+
| string
|
|
7
|
+
| number
|
|
8
|
+
| boolean
|
|
9
|
+
| null
|
|
10
|
+
| { readonly path: string }
|
|
11
|
+
| { readonly call: string; readonly args: Record<string, ExpressionPart> };
|
|
12
|
+
|
|
13
|
+
export type ValueFunctionContext = {
|
|
14
|
+
readonly resolve: (value: ExpressionPart) => unknown;
|
|
15
|
+
readonly warn: (message: string) => void;
|
|
16
|
+
readonly templates: Map<string, ExpressionPart[] | null>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class ExpressionSyntaxError extends Error {}
|
|
20
|
+
|
|
21
|
+
const FUNCTION_NAME = /^[A-Za-z_@]\w*$/;
|
|
22
|
+
|
|
23
|
+
const isSpace = (character: string) => /\s/.test(character);
|
|
24
|
+
|
|
25
|
+
const isPathCharacter = (character: string) =>
|
|
26
|
+
!isSpace(character) && !"{}(),:'\"$".includes(character);
|
|
27
|
+
|
|
28
|
+
const isDigit = (character: string | undefined) =>
|
|
29
|
+
character !== undefined && character >= "0" && character <= "9";
|
|
30
|
+
|
|
31
|
+
class ExpressionReader {
|
|
32
|
+
private readonly text: string;
|
|
33
|
+
position: number;
|
|
34
|
+
|
|
35
|
+
constructor(text: string, position: number) {
|
|
36
|
+
this.text = text;
|
|
37
|
+
this.position = position;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
expression(depth: number): ExpressionPart {
|
|
41
|
+
if (depth > MAX_EXPRESSION_DEPTH) this.fail("it nests too deeply");
|
|
42
|
+
this.skipSpace();
|
|
43
|
+
const character = this.text[this.position];
|
|
44
|
+
if (this.text.startsWith("${", this.position)) {
|
|
45
|
+
this.position += 2;
|
|
46
|
+
this.skipSpace();
|
|
47
|
+
if (this.text[this.position] === "}") {
|
|
48
|
+
this.position++;
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
const value = this.expression(depth + 1);
|
|
52
|
+
this.skipSpace();
|
|
53
|
+
if (this.text[this.position] !== "}") {
|
|
54
|
+
this.fail(
|
|
55
|
+
this.position >= this.text.length
|
|
56
|
+
? "an interpolation is not closed"
|
|
57
|
+
: "characters follow a complete expression",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
this.position++;
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
if (character === "'" || character === '"') return this.string(character);
|
|
64
|
+
if (
|
|
65
|
+
isDigit(character) ||
|
|
66
|
+
(character === "-" && isDigit(this.text[this.position + 1]))
|
|
67
|
+
) {
|
|
68
|
+
return this.number();
|
|
69
|
+
}
|
|
70
|
+
const word = this.word();
|
|
71
|
+
if (word === "") this.fail("an expression is missing");
|
|
72
|
+
const end = this.position;
|
|
73
|
+
this.skipSpace();
|
|
74
|
+
if (this.text[this.position] === "(") {
|
|
75
|
+
if (!FUNCTION_NAME.test(word)) this.fail(`"${word}" is not a function`);
|
|
76
|
+
this.position++;
|
|
77
|
+
return { call: word, args: this.arguments(depth) };
|
|
78
|
+
}
|
|
79
|
+
this.position = end;
|
|
80
|
+
if (word === "true") return true;
|
|
81
|
+
if (word === "false") return false;
|
|
82
|
+
if (word === "null") return null;
|
|
83
|
+
return { path: word };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private arguments(depth: number): Record<string, ExpressionPart> {
|
|
87
|
+
const args: Record<string, ExpressionPart> = {};
|
|
88
|
+
this.skipSpace();
|
|
89
|
+
if (this.text[this.position] === ")") {
|
|
90
|
+
this.position++;
|
|
91
|
+
return args;
|
|
92
|
+
}
|
|
93
|
+
for (;;) {
|
|
94
|
+
this.skipSpace();
|
|
95
|
+
const name = this.word();
|
|
96
|
+
if (!FUNCTION_NAME.test(name)) this.fail("an argument name is missing");
|
|
97
|
+
this.skipSpace();
|
|
98
|
+
if (this.text[this.position] !== ":") {
|
|
99
|
+
this.fail(`argument "${name}" has no colon`);
|
|
100
|
+
}
|
|
101
|
+
this.position++;
|
|
102
|
+
Object.defineProperty(args, name, {
|
|
103
|
+
value: this.expression(depth + 1),
|
|
104
|
+
enumerable: true,
|
|
105
|
+
configurable: true,
|
|
106
|
+
writable: true,
|
|
107
|
+
});
|
|
108
|
+
this.skipSpace();
|
|
109
|
+
const separator = this.text[this.position++];
|
|
110
|
+
if (separator === ")") return args;
|
|
111
|
+
if (separator !== ",") {
|
|
112
|
+
this.fail(
|
|
113
|
+
separator === undefined
|
|
114
|
+
? "a function call is not closed"
|
|
115
|
+
: "arguments are not separated by commas",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
this.skipSpace();
|
|
119
|
+
if (this.text[this.position] === ")") {
|
|
120
|
+
this.position++;
|
|
121
|
+
return args;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private string(quote: string): string {
|
|
127
|
+
let value = "";
|
|
128
|
+
this.position++;
|
|
129
|
+
for (;;) {
|
|
130
|
+
const character = this.text[this.position++];
|
|
131
|
+
if (character === undefined) this.fail("a string is not closed");
|
|
132
|
+
if (character === quote) return value;
|
|
133
|
+
if (character !== "\\") {
|
|
134
|
+
value += character;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const escaped = this.text[this.position++];
|
|
138
|
+
if (escaped === undefined) this.fail("a string is not closed");
|
|
139
|
+
value +=
|
|
140
|
+
escaped === "n"
|
|
141
|
+
? "\n"
|
|
142
|
+
: escaped === "t"
|
|
143
|
+
? "\t"
|
|
144
|
+
: escaped === "r"
|
|
145
|
+
? "\r"
|
|
146
|
+
: escaped;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private number(): number {
|
|
151
|
+
const start = this.position;
|
|
152
|
+
if (this.text[this.position] === "-") this.position++;
|
|
153
|
+
while (isDigit(this.text[this.position])) this.position++;
|
|
154
|
+
if (this.text[this.position] === ".") {
|
|
155
|
+
this.position++;
|
|
156
|
+
while (isDigit(this.text[this.position])) this.position++;
|
|
157
|
+
}
|
|
158
|
+
const next = this.text[this.position];
|
|
159
|
+
if (next !== undefined && isPathCharacter(next)) {
|
|
160
|
+
this.fail("a number is malformed");
|
|
161
|
+
}
|
|
162
|
+
return Number(this.text.slice(start, this.position));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private word(): string {
|
|
166
|
+
const start = this.position;
|
|
167
|
+
while (
|
|
168
|
+
this.position < this.text.length &&
|
|
169
|
+
isPathCharacter(this.text[this.position]!)
|
|
170
|
+
) {
|
|
171
|
+
this.position++;
|
|
172
|
+
}
|
|
173
|
+
return this.text.slice(start, this.position);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private skipSpace() {
|
|
177
|
+
while (
|
|
178
|
+
this.position < this.text.length &&
|
|
179
|
+
isSpace(this.text[this.position]!)
|
|
180
|
+
) {
|
|
181
|
+
this.position++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private fail(reason: string): never {
|
|
186
|
+
throw new ExpressionSyntaxError(reason);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const parseExpressionTemplate = (template: string): ExpressionPart[] => {
|
|
191
|
+
if (template.length > MAX_TEMPLATE_LENGTH) {
|
|
192
|
+
throw new ExpressionSyntaxError(
|
|
193
|
+
`it is longer than ${MAX_TEMPLATE_LENGTH} characters`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
const parts: ExpressionPart[] = [];
|
|
197
|
+
const push = (part: ExpressionPart) => {
|
|
198
|
+
if (parts.length >= MAX_TEMPLATE_PARTS) {
|
|
199
|
+
throw new ExpressionSyntaxError(
|
|
200
|
+
`it has more than ${MAX_TEMPLATE_PARTS} parts`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
parts.push(part);
|
|
204
|
+
};
|
|
205
|
+
let literal = "";
|
|
206
|
+
let index = 0;
|
|
207
|
+
while (index < template.length) {
|
|
208
|
+
if (template.startsWith("\\${", index)) {
|
|
209
|
+
literal += "${";
|
|
210
|
+
index += 3;
|
|
211
|
+
} else if (template.startsWith("${", index)) {
|
|
212
|
+
if (literal) push(literal);
|
|
213
|
+
literal = "";
|
|
214
|
+
const reader = new ExpressionReader(template, index);
|
|
215
|
+
push(reader.expression(0));
|
|
216
|
+
index = reader.position;
|
|
217
|
+
} else {
|
|
218
|
+
literal += template[index];
|
|
219
|
+
index++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (literal) push(literal);
|
|
223
|
+
return parts;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const isMissing = (value: unknown) =>
|
|
227
|
+
value === undefined || value === null || value === "";
|
|
228
|
+
|
|
229
|
+
const toNumber = (value: unknown): number | undefined => {
|
|
230
|
+
const number =
|
|
231
|
+
typeof value === "number"
|
|
232
|
+
? value
|
|
233
|
+
: typeof value === "string" && value.trim() !== ""
|
|
234
|
+
? Number(value)
|
|
235
|
+
: Number.NaN;
|
|
236
|
+
return Number.isFinite(number) ? number : undefined;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
240
|
+
|
|
241
|
+
const toDate = (value: unknown): Date | undefined => {
|
|
242
|
+
if (typeof value === "number") return new Date(value);
|
|
243
|
+
if (typeof value !== "string") return undefined;
|
|
244
|
+
const dateOnly = DATE_ONLY.exec(value);
|
|
245
|
+
if (!dateOnly) return new Date(value);
|
|
246
|
+
const [year, month, day] = dateOnly.slice(1).map(Number) as [
|
|
247
|
+
number,
|
|
248
|
+
number,
|
|
249
|
+
number,
|
|
250
|
+
];
|
|
251
|
+
const date = new Date(0);
|
|
252
|
+
date.setFullYear(year, month - 1, day);
|
|
253
|
+
date.setHours(0, 0, 0, 0);
|
|
254
|
+
return date.getMonth() === month - 1 && date.getDate() === day
|
|
255
|
+
? date
|
|
256
|
+
: undefined;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const toText = (value: unknown): string => {
|
|
260
|
+
if (value === undefined || value === null) return "";
|
|
261
|
+
if (typeof value !== "object") return String(value);
|
|
262
|
+
try {
|
|
263
|
+
return JSON.stringify(value) ?? "";
|
|
264
|
+
} catch {
|
|
265
|
+
return "";
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const cached = <T>(cache: Map<string, T>, key: string, create: () => T): T => {
|
|
270
|
+
let value = cache.get(key);
|
|
271
|
+
if (value === undefined) {
|
|
272
|
+
value = create();
|
|
273
|
+
if (cache.size >= 64) cache.clear();
|
|
274
|
+
cache.set(key, value);
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const numberFormats = new Map<string, Intl.NumberFormat>();
|
|
280
|
+
const dateFormats = new Map<string, Intl.DateTimeFormat>();
|
|
281
|
+
const pluralRules = new Map<string, Intl.PluralRules>();
|
|
282
|
+
|
|
283
|
+
const invalidArguments = (name: string, context: ValueFunctionContext) => {
|
|
284
|
+
context.warn(`A2UI function "${name}" received invalid arguments.`);
|
|
285
|
+
return undefined;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const formatAmount = (
|
|
289
|
+
name: string,
|
|
290
|
+
args: Record<string, unknown>,
|
|
291
|
+
context: ValueFunctionContext,
|
|
292
|
+
currency?: string,
|
|
293
|
+
): unknown => {
|
|
294
|
+
if (isMissing(args["value"])) return "";
|
|
295
|
+
const value = toNumber(args["value"]);
|
|
296
|
+
const decimals =
|
|
297
|
+
args["decimals"] === undefined ? undefined : toNumber(args["decimals"]);
|
|
298
|
+
if (
|
|
299
|
+
value === undefined ||
|
|
300
|
+
(args["decimals"] !== undefined &&
|
|
301
|
+
(decimals === undefined ||
|
|
302
|
+
!Number.isInteger(decimals) ||
|
|
303
|
+
decimals < 0 ||
|
|
304
|
+
decimals > 20))
|
|
305
|
+
) {
|
|
306
|
+
return invalidArguments(name, context);
|
|
307
|
+
}
|
|
308
|
+
const options: Intl.NumberFormatOptions = {
|
|
309
|
+
useGrouping: args["grouping"] !== false,
|
|
310
|
+
...(currency !== undefined ? { style: "currency", currency } : {}),
|
|
311
|
+
...(decimals !== undefined
|
|
312
|
+
? { minimumFractionDigits: decimals, maximumFractionDigits: decimals }
|
|
313
|
+
: {}),
|
|
314
|
+
};
|
|
315
|
+
try {
|
|
316
|
+
return cached(
|
|
317
|
+
numberFormats,
|
|
318
|
+
JSON.stringify(options),
|
|
319
|
+
() => new Intl.NumberFormat(undefined, options),
|
|
320
|
+
).format(value);
|
|
321
|
+
} catch {
|
|
322
|
+
return invalidArguments(name, context);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const dateName = (
|
|
327
|
+
date: Date,
|
|
328
|
+
options: Intl.DateTimeFormatOptions,
|
|
329
|
+
type: Intl.DateTimeFormatPartTypes,
|
|
330
|
+
): string | undefined => {
|
|
331
|
+
try {
|
|
332
|
+
return cached(
|
|
333
|
+
dateFormats,
|
|
334
|
+
JSON.stringify(options),
|
|
335
|
+
() => new Intl.DateTimeFormat(undefined, options),
|
|
336
|
+
)
|
|
337
|
+
.formatToParts(date)
|
|
338
|
+
.find((part) => part.type === type)?.value;
|
|
339
|
+
} catch {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const pad = (value: number, width: number) =>
|
|
345
|
+
String(value).padStart(width, "0");
|
|
346
|
+
|
|
347
|
+
const dateField = (
|
|
348
|
+
date: Date,
|
|
349
|
+
letter: string,
|
|
350
|
+
width: number,
|
|
351
|
+
): string | undefined => {
|
|
352
|
+
switch (letter) {
|
|
353
|
+
case "y":
|
|
354
|
+
return width === 2
|
|
355
|
+
? pad(date.getFullYear() % 100, 2)
|
|
356
|
+
: pad(date.getFullYear(), width);
|
|
357
|
+
case "M":
|
|
358
|
+
return width <= 2
|
|
359
|
+
? pad(date.getMonth() + 1, width)
|
|
360
|
+
: dateName(
|
|
361
|
+
date,
|
|
362
|
+
{ month: width === 3 ? "short" : width === 4 ? "long" : "narrow" },
|
|
363
|
+
"month",
|
|
364
|
+
);
|
|
365
|
+
case "d":
|
|
366
|
+
return pad(date.getDate(), width);
|
|
367
|
+
case "E":
|
|
368
|
+
return dateName(
|
|
369
|
+
date,
|
|
370
|
+
{ weekday: width <= 3 ? "short" : width === 4 ? "long" : "narrow" },
|
|
371
|
+
"weekday",
|
|
372
|
+
);
|
|
373
|
+
case "h":
|
|
374
|
+
return pad(date.getHours() % 12 || 12, width);
|
|
375
|
+
case "H":
|
|
376
|
+
return pad(date.getHours(), width);
|
|
377
|
+
case "m":
|
|
378
|
+
return pad(date.getMinutes(), width);
|
|
379
|
+
case "s":
|
|
380
|
+
return pad(date.getSeconds(), width);
|
|
381
|
+
case "a":
|
|
382
|
+
return (
|
|
383
|
+
dateName(date, { hour: "numeric", hour12: true }, "dayPeriod") ??
|
|
384
|
+
(date.getHours() < 12 ? "AM" : "PM")
|
|
385
|
+
);
|
|
386
|
+
default:
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const formatDatePattern = (date: Date, pattern: string): string | undefined => {
|
|
392
|
+
let result = "";
|
|
393
|
+
let index = 0;
|
|
394
|
+
while (index < pattern.length) {
|
|
395
|
+
const character = pattern[index]!;
|
|
396
|
+
if (character === "'") {
|
|
397
|
+
index++;
|
|
398
|
+
if (pattern[index] === "'") {
|
|
399
|
+
result += "'";
|
|
400
|
+
index++;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
while (index < pattern.length) {
|
|
404
|
+
if (pattern[index] !== "'") {
|
|
405
|
+
result += pattern[index++];
|
|
406
|
+
} else if (pattern[index + 1] === "'") {
|
|
407
|
+
result += "'";
|
|
408
|
+
index += 2;
|
|
409
|
+
} else {
|
|
410
|
+
index++;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} else if (/[A-Za-z]/.test(character)) {
|
|
415
|
+
let end = index + 1;
|
|
416
|
+
while (pattern[end] === character) end++;
|
|
417
|
+
const field = dateField(date, character, end - index);
|
|
418
|
+
if (field === undefined) return undefined;
|
|
419
|
+
result += field;
|
|
420
|
+
index = end;
|
|
421
|
+
} else {
|
|
422
|
+
result += character;
|
|
423
|
+
index++;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return result;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const pluralCategory = (value: number): string => {
|
|
430
|
+
try {
|
|
431
|
+
return cached(pluralRules, "", () => new Intl.PluralRules()).select(value);
|
|
432
|
+
} catch {
|
|
433
|
+
return value === 1 ? "one" : "other";
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
type ValueFunction = (
|
|
438
|
+
args: Record<string, unknown>,
|
|
439
|
+
context: ValueFunctionContext,
|
|
440
|
+
) => unknown;
|
|
441
|
+
|
|
442
|
+
const VALUE_FUNCTIONS: Readonly<Record<string, ValueFunction>> = {
|
|
443
|
+
formatString: (args, context) => {
|
|
444
|
+
const template = args["value"];
|
|
445
|
+
if (template === undefined || template === null) return "";
|
|
446
|
+
if (typeof template !== "string") {
|
|
447
|
+
return invalidArguments("formatString", context);
|
|
448
|
+
}
|
|
449
|
+
let parts = context.templates.get(template);
|
|
450
|
+
if (parts === undefined) {
|
|
451
|
+
try {
|
|
452
|
+
parts = parseExpressionTemplate(template);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (!(error instanceof ExpressionSyntaxError)) throw error;
|
|
455
|
+
context.warn(
|
|
456
|
+
`A2UI formatString template is malformed: ${error.message}.`,
|
|
457
|
+
);
|
|
458
|
+
parts = null;
|
|
459
|
+
}
|
|
460
|
+
context.templates.set(template, parts);
|
|
461
|
+
}
|
|
462
|
+
if (parts === null) return undefined;
|
|
463
|
+
return parts
|
|
464
|
+
.map((part) =>
|
|
465
|
+
toText(
|
|
466
|
+
typeof part === "object" && part !== null
|
|
467
|
+
? context.resolve(part)
|
|
468
|
+
: part,
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
.join("");
|
|
472
|
+
},
|
|
473
|
+
formatNumber: (args, context) => formatAmount("formatNumber", args, context),
|
|
474
|
+
formatCurrency: (args, context) =>
|
|
475
|
+
typeof args["currency"] === "string"
|
|
476
|
+
? formatAmount("formatCurrency", args, context, args["currency"])
|
|
477
|
+
: invalidArguments("formatCurrency", context),
|
|
478
|
+
formatDate: (args, context) => {
|
|
479
|
+
const value = args["value"];
|
|
480
|
+
if (isMissing(value)) return "";
|
|
481
|
+
const format = args["format"];
|
|
482
|
+
const date = toDate(value);
|
|
483
|
+
if (typeof format !== "string" || !date || Number.isNaN(date.getTime())) {
|
|
484
|
+
return invalidArguments("formatDate", context);
|
|
485
|
+
}
|
|
486
|
+
const formatted = formatDatePattern(date, format);
|
|
487
|
+
if (formatted !== undefined) return formatted;
|
|
488
|
+
context.warn(
|
|
489
|
+
`A2UI function "formatDate" does not support the pattern "${format}", so it returned an ISO date.`,
|
|
490
|
+
);
|
|
491
|
+
return date.toISOString();
|
|
492
|
+
},
|
|
493
|
+
pluralize: (args, context) => {
|
|
494
|
+
if (isMissing(args["value"])) return "";
|
|
495
|
+
const value = toNumber(args["value"]);
|
|
496
|
+
if (value === undefined || typeof args["other"] !== "string") {
|
|
497
|
+
return invalidArguments("pluralize", context);
|
|
498
|
+
}
|
|
499
|
+
const selected = args[pluralCategory(value)];
|
|
500
|
+
return typeof selected === "string" ? selected : args["other"];
|
|
501
|
+
},
|
|
502
|
+
and: (args, context) =>
|
|
503
|
+
Array.isArray(args["values"])
|
|
504
|
+
? args["values"].every(Boolean)
|
|
505
|
+
: invalidArguments("and", context),
|
|
506
|
+
or: (args, context) =>
|
|
507
|
+
Array.isArray(args["values"])
|
|
508
|
+
? args["values"].some(Boolean)
|
|
509
|
+
: invalidArguments("or", context),
|
|
510
|
+
not: (args) => !args["value"],
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
export const evaluateA2uiValueFunction = (
|
|
514
|
+
name: string,
|
|
515
|
+
args: Record<string, unknown>,
|
|
516
|
+
context: ValueFunctionContext,
|
|
517
|
+
): unknown => {
|
|
518
|
+
if (!Object.hasOwn(VALUE_FUNCTIONS, name)) {
|
|
519
|
+
context.warn(`A2UI function "${name}" is not supported and was skipped.`);
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
return VALUE_FUNCTIONS[name]!(args, context);
|
|
523
|
+
};
|
package/src/actionRegistry.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type ActionHandler = (
|
|
|
20
20
|
) => unknown | Promise<unknown>;
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* The host-provided map from `$action.type` to {@link ActionHandler}. This is the single dispatch target for rendered generative UI: every interactive component fires its `$action` here through the injected `$dispatch` (a submit Button defers to its ancestor form's dispatch instead). The payload's `$input` takes one of two shapes: a single value for a standalone control's dispatch (`Select`, `Input`, `DatePicker`, `Checkbox`, `RadioGroup`), or an object keyed by field `name` for a `Form` or a `Card` with `asForm` set. When a registry is used in a `JSONGenerativeUI` tool, each dispatched payload, including `$input`, is recorded on the tool call on a best-effort basis. Recording depends on the runtime and interaction log limits, and never delays or prevents dispatch. Construct it with {@link createActionRegistry} and pass it to `JSONGenerativeUI`.
|
|
23
|
+
* The host-provided map from `$action.type` to {@link ActionHandler}. This is the single dispatch target for rendered generative UI: every interactive component fires its `$action` here through the injected `$dispatch` (a submit Button defers to its ancestor form's dispatch instead). The payload's `$input` takes one of two shapes: a single value for a standalone control's dispatch (`Select`, `Input`, `DatePicker`, `Checkbox`, `RadioGroup`, or a `CheckboxGroup`'s array of checked values), or an object keyed by field `name` for a `Form` or a `Card` with `asForm` set. When a registry is used in a `JSONGenerativeUI` tool, each dispatched payload, including `$input`, is recorded on the tool call on a best-effort basis. Recording depends on the runtime and interaction log limits, and never delays or prevents dispatch. Construct it with {@link createActionRegistry} and pass it to `JSONGenerativeUI`.
|
|
24
24
|
*/
|
|
25
25
|
export type ActionRegistry = {
|
|
26
26
|
dispatch(action: Action): unknown;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { findAnsweredValues } from "./answeredValues";
|
|
3
|
+
|
|
4
|
+
describe("findAnsweredValues", () => {
|
|
5
|
+
it("returns the latest action's object input", () => {
|
|
6
|
+
expect(
|
|
7
|
+
findAnsweredValues({
|
|
8
|
+
entries: [
|
|
9
|
+
{
|
|
10
|
+
type: "action",
|
|
11
|
+
payload: { $input: { choice: "first" } },
|
|
12
|
+
},
|
|
13
|
+
{ type: "human-response", payload: { choice: "ignored" } },
|
|
14
|
+
{
|
|
15
|
+
type: "action",
|
|
16
|
+
payload: { $input: { choice: "last" } },
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
}),
|
|
20
|
+
).toEqual({ choice: "last" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("ignores actions whose input is not a plain object", () => {
|
|
24
|
+
expect(
|
|
25
|
+
findAnsweredValues({
|
|
26
|
+
entries: [
|
|
27
|
+
{ type: "action", payload: { $input: { choice: "kept" } } },
|
|
28
|
+
{ type: "action", payload: { $input: ["not", "an", "object"] } },
|
|
29
|
+
],
|
|
30
|
+
}),
|
|
31
|
+
).toEqual({ choice: "kept" });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
type AnsweredValues = Readonly<Record<string, unknown>>;
|
|
4
|
+
|
|
5
|
+
type ToolInteractionLog = {
|
|
6
|
+
readonly entries: readonly {
|
|
7
|
+
readonly type: string;
|
|
8
|
+
readonly payload: unknown;
|
|
9
|
+
}[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let answeredValuesContext:
|
|
13
|
+
| React.Context<AnsweredValues | undefined>
|
|
14
|
+
| undefined;
|
|
15
|
+
const supportsContext = typeof React.createContext === "function";
|
|
16
|
+
const useValuesContext = supportsContext ? React.useContext : () => undefined;
|
|
17
|
+
|
|
18
|
+
const getAnsweredValuesContext = () =>
|
|
19
|
+
supportsContext
|
|
20
|
+
? (answeredValuesContext ??= React.createContext<
|
|
21
|
+
AnsweredValues | undefined
|
|
22
|
+
>(undefined))
|
|
23
|
+
: undefined;
|
|
24
|
+
|
|
25
|
+
const isPlainObject = (value: unknown): value is AnsweredValues => {
|
|
26
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const prototype = Object.getPrototypeOf(value);
|
|
30
|
+
return prototype === Object.prototype || prototype === null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const findAnsweredValues = (
|
|
34
|
+
interactions: ToolInteractionLog | undefined,
|
|
35
|
+
): AnsweredValues | undefined => {
|
|
36
|
+
const entries = interactions?.entries;
|
|
37
|
+
if (!entries) return undefined;
|
|
38
|
+
|
|
39
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
40
|
+
const interaction = entries[index]!;
|
|
41
|
+
if (interaction.type !== "action" || !isPlainObject(interaction.payload)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const input = interaction.payload["$input"];
|
|
45
|
+
if (isPlainObject(input)) return input;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const AnsweredValuesProvider = ({
|
|
51
|
+
values,
|
|
52
|
+
children,
|
|
53
|
+
}: {
|
|
54
|
+
values: AnsweredValues | undefined;
|
|
55
|
+
children: React.ReactNode;
|
|
56
|
+
}) => {
|
|
57
|
+
const Context = getAnsweredValuesContext();
|
|
58
|
+
return Context ? (
|
|
59
|
+
<Context.Provider value={values}>{children}</Context.Provider>
|
|
60
|
+
) : (
|
|
61
|
+
children
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const useAnsweredValue = (name: string | undefined): unknown => {
|
|
66
|
+
const values = useValuesContext(getAnsweredValuesContext()!);
|
|
67
|
+
return name !== undefined && values && Object.hasOwn(values, name)
|
|
68
|
+
? values[name]
|
|
69
|
+
: undefined;
|
|
70
|
+
};
|