@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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { hasFieldReference, resolveFieldReferences } from "./fieldReferences";
|
|
3
|
+
|
|
4
|
+
describe("field references", () => {
|
|
5
|
+
it("resolve from own fields only and drop what does not resolve", () => {
|
|
6
|
+
const fields = Object.assign(Object.create({ inherited: "no" }), {
|
|
7
|
+
note: "hi",
|
|
8
|
+
empty: undefined,
|
|
9
|
+
}) as Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
expect(
|
|
12
|
+
resolveFieldReferences(
|
|
13
|
+
{
|
|
14
|
+
note: { $field: "note" },
|
|
15
|
+
list: [
|
|
16
|
+
{ $field: "note" },
|
|
17
|
+
{ $field: "empty" },
|
|
18
|
+
{ $field: "inherited" },
|
|
19
|
+
],
|
|
20
|
+
literal: { $field: "note", other: 1 },
|
|
21
|
+
},
|
|
22
|
+
fields,
|
|
23
|
+
),
|
|
24
|
+
).toEqual({
|
|
25
|
+
note: "hi",
|
|
26
|
+
list: ["hi"],
|
|
27
|
+
literal: { $field: "note", other: 1 },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("fall back to the reference's own fallback, and read only $field and fallback as a reference", () => {
|
|
32
|
+
expect(
|
|
33
|
+
resolveFieldReferences(
|
|
34
|
+
{
|
|
35
|
+
filled: { $field: "note", fallback: "old" },
|
|
36
|
+
missing: { $field: "gone", fallback: "kept" },
|
|
37
|
+
empty: { $field: "gone" },
|
|
38
|
+
list: [{ $field: "gone", fallback: 0 }],
|
|
39
|
+
data: { $field: "note", fallback: "old", extra: true },
|
|
40
|
+
},
|
|
41
|
+
{ note: "new" },
|
|
42
|
+
),
|
|
43
|
+
).toEqual({
|
|
44
|
+
filled: "new",
|
|
45
|
+
missing: "kept",
|
|
46
|
+
list: [0],
|
|
47
|
+
data: { $field: "note", fallback: "old", extra: true },
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("terminate on a cyclic value and keep the cycle", () => {
|
|
52
|
+
const cyclic: Record<string, unknown> = { note: { $field: "note" } };
|
|
53
|
+
cyclic["self"] = cyclic;
|
|
54
|
+
const acyclic: Record<string, unknown> = { a: 1 };
|
|
55
|
+
acyclic["self"] = { back: [acyclic] };
|
|
56
|
+
|
|
57
|
+
expect(hasFieldReference(cyclic)).toBe(true);
|
|
58
|
+
expect(hasFieldReference(acyclic)).toBe(false);
|
|
59
|
+
const resolved = resolveFieldReferences(cyclic, { note: "hi" }) as Record<
|
|
60
|
+
string,
|
|
61
|
+
unknown
|
|
62
|
+
>;
|
|
63
|
+
expect(resolved["note"]).toBe("hi");
|
|
64
|
+
expect(resolved["self"]).toBe(cyclic);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
2
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3
|
+
|
|
4
|
+
const MAX_DEPTH = 64;
|
|
5
|
+
|
|
6
|
+
const isFieldReference = (
|
|
7
|
+
value: unknown,
|
|
8
|
+
): value is { readonly $field: string; readonly fallback?: unknown } =>
|
|
9
|
+
isRecord(value) &&
|
|
10
|
+
typeof value["$field"] === "string" &&
|
|
11
|
+
Object.keys(value).every((key) => key === "$field" || key === "fallback");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether `value` contains a `{ "$field": name }` or `{ "$field": name, "fallback": value }` reference within 64 levels.
|
|
15
|
+
*/
|
|
16
|
+
export const hasFieldReference = (value: unknown): boolean => {
|
|
17
|
+
const seen = new Set<object>();
|
|
18
|
+
const visit = (entry: unknown, depth: number): boolean => {
|
|
19
|
+
if (
|
|
20
|
+
typeof entry !== "object" ||
|
|
21
|
+
entry === null ||
|
|
22
|
+
depth > MAX_DEPTH ||
|
|
23
|
+
seen.has(entry)
|
|
24
|
+
) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (isFieldReference(entry)) return true;
|
|
28
|
+
seen.add(entry);
|
|
29
|
+
return Object.values(entry).some((item) => visit(item, depth + 1));
|
|
30
|
+
};
|
|
31
|
+
return visit(value, 0);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Replaces each `{ "$field": name }` reference inside `value` with the own property `name` of `fields`, or with the reference's own `fallback` when that is `undefined`. A reference that still resolves to `undefined` is dropped from its object or array, and a cyclic reference or anything nested deeper than 64 levels is kept as is.
|
|
36
|
+
*/
|
|
37
|
+
export const resolveFieldReferences = (
|
|
38
|
+
value: unknown,
|
|
39
|
+
fields: Readonly<Record<string, unknown>>,
|
|
40
|
+
): unknown => {
|
|
41
|
+
const ancestors = new Set<object>();
|
|
42
|
+
const resolve = (entry: unknown, depth: number): unknown => {
|
|
43
|
+
if (isFieldReference(entry)) {
|
|
44
|
+
const current = Object.hasOwn(fields, entry.$field)
|
|
45
|
+
? fields[entry.$field]
|
|
46
|
+
: undefined;
|
|
47
|
+
if (current !== undefined) return current;
|
|
48
|
+
return Object.hasOwn(entry, "fallback") ? entry.fallback : undefined;
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
typeof entry !== "object" ||
|
|
52
|
+
entry === null ||
|
|
53
|
+
depth > MAX_DEPTH ||
|
|
54
|
+
ancestors.has(entry)
|
|
55
|
+
) {
|
|
56
|
+
return entry;
|
|
57
|
+
}
|
|
58
|
+
ancestors.add(entry);
|
|
59
|
+
try {
|
|
60
|
+
if (Array.isArray(entry)) {
|
|
61
|
+
return entry
|
|
62
|
+
.map((item) => resolve(item, depth + 1))
|
|
63
|
+
.filter((item) => item !== undefined);
|
|
64
|
+
}
|
|
65
|
+
const result: Record<string, unknown> = {};
|
|
66
|
+
for (const [key, item] of Object.entries(entry)) {
|
|
67
|
+
const resolved = resolve(item, depth + 1);
|
|
68
|
+
if (resolved !== undefined) {
|
|
69
|
+
Object.defineProperty(result, key, {
|
|
70
|
+
value: resolved,
|
|
71
|
+
enumerable: true,
|
|
72
|
+
configurable: true,
|
|
73
|
+
writable: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
} finally {
|
|
79
|
+
ancestors.delete(entry);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
return resolve(value, 0);
|
|
83
|
+
};
|
|
@@ -83,6 +83,24 @@ describe("generativeUIToJSX", () => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
describe("generativeUIToJSX with escape: true", () => {
|
|
86
|
+
it.each([false, true])(
|
|
87
|
+
"preserves literal entities in string props and keys with pretty=%s",
|
|
88
|
+
(pretty) => {
|
|
89
|
+
expect(
|
|
90
|
+
generativeUIToJSX(
|
|
91
|
+
{ $type: "Text", $key: "A", value: "& A A" },
|
|
92
|
+
{ escape: true, pretty },
|
|
93
|
+
),
|
|
94
|
+
).toBe('<Text key={"A"} value={"& A A"} />');
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
it("keeps entity spelling in the default display form", () => {
|
|
99
|
+
expect(generativeUIToJSX({ $type: "Text", value: "& A" })).toBe(
|
|
100
|
+
'<Text value="& A" />',
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
86
104
|
it("escapes a string child containing HTML-like tags", () => {
|
|
87
105
|
expect(
|
|
88
106
|
generativeUIToJSX(
|
package/src/generativeUIToJSX.ts
CHANGED
|
@@ -64,9 +64,9 @@ function toJSX(
|
|
|
64
64
|
if (typeof type !== "string") return "";
|
|
65
65
|
|
|
66
66
|
const attrs =
|
|
67
|
-
formatAttr("key", $key) +
|
|
67
|
+
formatAttr("key", $key, escape) +
|
|
68
68
|
Object.entries(props)
|
|
69
|
-
.map(([key, value]) => formatAttr(key, value))
|
|
69
|
+
.map(([key, value]) => formatAttr(key, value, escape))
|
|
70
70
|
.join("");
|
|
71
71
|
|
|
72
72
|
if (!pretty) {
|
|
@@ -140,13 +140,11 @@ function formatChildText(value: string, escape: boolean): string {
|
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
/** Formats one prop as a JSX attribute (`id="x"`, `count={3}`, `open`, …). */
|
|
143
|
-
function formatAttr(key: string, value: unknown): string {
|
|
143
|
+
function formatAttr(key: string, value: unknown, escape: boolean): string {
|
|
144
144
|
if (value === undefined) return "";
|
|
145
145
|
if (value === true) return ` ${key}`;
|
|
146
146
|
if (typeof value === "string") {
|
|
147
|
-
|
|
148
|
-
// newline would break the attribute.
|
|
149
|
-
return /["\n]/.test(value)
|
|
147
|
+
return /["\n]/.test(value) || (escape && value.includes("&"))
|
|
150
148
|
? ` ${key}={${JSON.stringify(value)}}`
|
|
151
149
|
: ` ${key}="${value}"`;
|
|
152
150
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const SRC_DIR = resolve(__dirname);
|
|
7
|
+
|
|
8
|
+
// Next.js fails the build when a module reachable from a Server Component
|
|
9
|
+
// names one of these React APIs in an import; the namespace import is allowed.
|
|
10
|
+
const CLIENT_ONLY_REACT_APIS = new Set([
|
|
11
|
+
"Component",
|
|
12
|
+
"PureComponent",
|
|
13
|
+
"createContext",
|
|
14
|
+
"createFactory",
|
|
15
|
+
"useActionState",
|
|
16
|
+
"useEffect",
|
|
17
|
+
"useEffectEvent",
|
|
18
|
+
"useImperativeHandle",
|
|
19
|
+
"useInsertionEffect",
|
|
20
|
+
"useLayoutEffect",
|
|
21
|
+
"useOptimistic",
|
|
22
|
+
"useReducer",
|
|
23
|
+
"useRef",
|
|
24
|
+
"useState",
|
|
25
|
+
"useSyncExternalStore",
|
|
26
|
+
"useTransition",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const RELATIVE_SPECIFIER_RE = /\b(?:from|import)\s*\(?\s*["'](\.[^"']*)["']/g;
|
|
30
|
+
|
|
31
|
+
const NAMED_FROM_REACT_RE =
|
|
32
|
+
/(?:import|export)\s+(type\s+)?(?:\w+\s*,\s*)?\{([^}]*)\}\s+from\s+["']react["']/g;
|
|
33
|
+
|
|
34
|
+
function resolveRelative(fromFile: string, spec: string): string | undefined {
|
|
35
|
+
const base = resolve(dirname(fromFile), spec);
|
|
36
|
+
return [
|
|
37
|
+
base,
|
|
38
|
+
`${base}.ts`,
|
|
39
|
+
`${base}.tsx`,
|
|
40
|
+
join(base, "index.ts"),
|
|
41
|
+
join(base, "index.tsx"),
|
|
42
|
+
].find((candidate) => existsSync(candidate) && statSync(candidate).isFile());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function collectGraph(entry: string): Set<string> {
|
|
46
|
+
const visited = new Set<string>();
|
|
47
|
+
const pending = [entry];
|
|
48
|
+
while (pending.length > 0) {
|
|
49
|
+
const file = pending.pop()!;
|
|
50
|
+
if (visited.has(file)) continue;
|
|
51
|
+
visited.add(file);
|
|
52
|
+
for (const [, spec] of readFileSync(file, "utf8").matchAll(
|
|
53
|
+
RELATIVE_SPECIFIER_RE,
|
|
54
|
+
)) {
|
|
55
|
+
const resolved = resolveRelative(file, spec!);
|
|
56
|
+
if (resolved) pending.push(resolved);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return visited;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function clientOnlyReactImports(file: string): string[] {
|
|
63
|
+
const names: string[] = [];
|
|
64
|
+
for (const [, typeOnly, specifiers] of readFileSync(file, "utf8").matchAll(
|
|
65
|
+
NAMED_FROM_REACT_RE,
|
|
66
|
+
)) {
|
|
67
|
+
if (typeOnly) continue;
|
|
68
|
+
for (const specifier of specifiers!.split(",")) {
|
|
69
|
+
const name = specifier.trim().split(/\s+as\s+/)[0]!;
|
|
70
|
+
if (CLIENT_ONLY_REACT_APIS.has(name)) names.push(name);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return names;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Server code reaches every entry: the subpaths carry no react-server
|
|
77
|
+
// condition, and the docs app resolves the root to its source through
|
|
78
|
+
// tsconfig paths, which skip that condition.
|
|
79
|
+
const ENTRIES = [
|
|
80
|
+
"index.server.ts",
|
|
81
|
+
"index.ts",
|
|
82
|
+
"ir.ts",
|
|
83
|
+
"a2ui.ts",
|
|
84
|
+
"slack.ts",
|
|
85
|
+
"teams.ts",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
describe("published entries", () => {
|
|
89
|
+
const graph = new Set(
|
|
90
|
+
ENTRIES.flatMap((entry) => [...collectGraph(join(SRC_DIR, entry))]),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
it("reach the vocabulary and the client renderer", () => {
|
|
94
|
+
expect(graph).toContain(join(SRC_DIR, "vocabulary", "interactive.tsx"));
|
|
95
|
+
expect(graph).toContain(join(SRC_DIR, "JSONGenerativeUI.client.tsx"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("never name a client-only React API in an import", () => {
|
|
99
|
+
const violations = [...graph].flatMap((file) =>
|
|
100
|
+
clientOnlyReactImports(file).map(
|
|
101
|
+
(name) => `${relative(SRC_DIR, file)}: ${name}`,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
expect(violations).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/ir.ts
CHANGED
|
@@ -85,7 +85,7 @@ export const ICON_NAMES = [
|
|
|
85
85
|
export type IconName = (typeof ICON_NAMES)[number];
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* Behavior payload carried by an interactive node. `type` is resolved by the host's action registry, not the renderer; keeping behavior as data keeps the tree serializable, so the same node renders on web while converters may bind the type to a native action id on other platforms.
|
|
88
|
+
* Behavior payload carried by an interactive node. `type` is resolved by the host's action registry, not the renderer; keeping behavior as data keeps the tree serializable, so the same node renders on web while converters may bind the type to a native action id on other platforms. A `{ "$field": name }` value anywhere in the payload refers to the current value of the control with that `name`, resolved when the action fires, and `{ "$field": name, "fallback": value }` resolves to `fallback` when no control holds a value.
|
|
89
89
|
*/
|
|
90
90
|
export interface Action {
|
|
91
91
|
readonly type: string;
|
|
@@ -77,8 +77,10 @@ describe("renderGenerativeUI", () => {
|
|
|
77
77
|
library,
|
|
78
78
|
);
|
|
79
79
|
|
|
80
|
-
expect(
|
|
81
|
-
const elements = out as ReactElement[]
|
|
80
|
+
expect(isValidElement(out)).toBe(true);
|
|
81
|
+
const elements = (out as ReactElement<{ children: ReactElement[] }>).props
|
|
82
|
+
.children;
|
|
83
|
+
expect(Array.isArray(elements)).toBe(true);
|
|
82
84
|
expect(elements.every(isValidElement)).toBe(true);
|
|
83
85
|
expect(elements.map((element) => element.key)).toEqual([
|
|
84
86
|
"model:1:Text",
|
|
@@ -108,12 +110,19 @@ describe("renderGenerativeUI", () => {
|
|
|
108
110
|
});
|
|
109
111
|
|
|
110
112
|
it("renders nothing for an unknown component", () => {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
);
|
|
113
|
+
const rendered = renderGenerativeUI({ $type: "Missing" }, library);
|
|
114
|
+
expect(rendered).toBeNull();
|
|
115
|
+
const html = renderToStaticMarkup(<>{rendered}</>);
|
|
114
116
|
expect(html).toBe("");
|
|
115
117
|
});
|
|
116
118
|
|
|
119
|
+
it.each(["plain", 42, null])(
|
|
120
|
+
"preserves a primitive root result: %j",
|
|
121
|
+
(value) => {
|
|
122
|
+
expect(renderGenerativeUI(value, library)).toBe(value);
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
117
126
|
it.each(["toString", "constructor"])(
|
|
118
127
|
"treats inherited %s as an unknown component",
|
|
119
128
|
(type) => {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { RadioGroupScope } from "./RadioGroupScope";
|
|
1
2
|
import { getPartialJsonObjectMeta } from "assistant-stream/utils";
|
|
2
3
|
import { Fragment, type ReactNode } from "react";
|
|
4
|
+
import { hasFieldReference, resolveFieldReferences } from "./fieldReferences";
|
|
3
5
|
import {
|
|
4
6
|
normalizeUINode,
|
|
7
|
+
type Action,
|
|
5
8
|
type NormalizedUIElement,
|
|
6
9
|
type NormalizedUINode,
|
|
7
10
|
} from "./ir";
|
|
@@ -32,9 +35,35 @@ export function renderGenerativeUI(
|
|
|
32
35
|
// string has not finished streaming.
|
|
33
36
|
const meta = getPartialJsonObjectMeta(node as Record<symbol, unknown>);
|
|
34
37
|
const partialPath = meta?.state === "partial" ? meta.partialPath : undefined;
|
|
35
|
-
|
|
38
|
+
const rendered = renderNode(
|
|
39
|
+
normalizeUINode(node, partialPath),
|
|
40
|
+
library,
|
|
41
|
+
withFieldFallbacks(context),
|
|
42
|
+
);
|
|
43
|
+
return rendered !== null && typeof rendered === "object" ? (
|
|
44
|
+
<RadioGroupScope>{rendered}</RadioGroupScope>
|
|
45
|
+
) : (
|
|
46
|
+
rendered
|
|
47
|
+
);
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
// A component that dispatches `$action` itself reads no control, so each `$field` reference left in it becomes its fallback.
|
|
51
|
+
const withFieldFallbacks = (
|
|
52
|
+
context: GenerativeUIRenderContext,
|
|
53
|
+
): GenerativeUIRenderContext => {
|
|
54
|
+
const { dispatch } = context;
|
|
55
|
+
if (dispatch === undefined) return context;
|
|
56
|
+
return {
|
|
57
|
+
...context,
|
|
58
|
+
dispatch: (action) =>
|
|
59
|
+
dispatch(
|
|
60
|
+
hasFieldReference(action)
|
|
61
|
+
? (resolveFieldReferences(action, {}) as Action)
|
|
62
|
+
: action,
|
|
63
|
+
),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
38
67
|
function renderNode(
|
|
39
68
|
node: NormalizedUINode,
|
|
40
69
|
library: GenerativeUILibrary,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { convertSurfaceToUISpec } from "../a2ui/convert";
|
|
3
|
+
import { decodeBlockAction } from "./decodeBlockAction";
|
|
4
|
+
import { toSlackBlocks } from "./toSlackBlocks";
|
|
5
|
+
|
|
6
|
+
const convertForm = () => {
|
|
7
|
+
const { spec, warnings: a2uiWarnings } = convertSurfaceToUISpec({
|
|
8
|
+
components: new Map([
|
|
9
|
+
["root", { id: "root", component: "Column", children: ["name", "send"] }],
|
|
10
|
+
[
|
|
11
|
+
"name",
|
|
12
|
+
{
|
|
13
|
+
id: "name",
|
|
14
|
+
component: "TextField",
|
|
15
|
+
label: "Name",
|
|
16
|
+
value: { path: "/form/name" },
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
[
|
|
20
|
+
"send",
|
|
21
|
+
{
|
|
22
|
+
id: "send",
|
|
23
|
+
component: "Button",
|
|
24
|
+
label: "Send",
|
|
25
|
+
action: {
|
|
26
|
+
event: {
|
|
27
|
+
name: "send",
|
|
28
|
+
context: { name: { path: "/form/name" } },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
]),
|
|
34
|
+
dataModel: { form: { name: "Ada" } },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(a2uiWarnings).toEqual([]);
|
|
38
|
+
expect(spec).toMatchObject({
|
|
39
|
+
children: [
|
|
40
|
+
{ $type: "Input", name: "/form/name", defaultValue: "Ada" },
|
|
41
|
+
{
|
|
42
|
+
$type: "Button",
|
|
43
|
+
$action: {
|
|
44
|
+
context: { name: { $field: "/form/name", fallback: "Ada" } },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
if (!spec) throw new Error("Expected an A2UI tree");
|
|
50
|
+
|
|
51
|
+
const { blocks, warnings: slackWarnings } = toSlackBlocks(spec);
|
|
52
|
+
expect(slackWarnings).toEqual([]);
|
|
53
|
+
const input = blocks.find((block) => block.type === "input");
|
|
54
|
+
const actions = blocks.find((block) => block.type === "actions");
|
|
55
|
+
if (!input || input.type !== "input" || !input.block_id)
|
|
56
|
+
throw new Error("Expected a named Slack input block");
|
|
57
|
+
if (!actions || actions.type !== "actions")
|
|
58
|
+
throw new Error("Expected a Slack actions block");
|
|
59
|
+
const button = actions.elements[0];
|
|
60
|
+
if (!button || button.type !== "button")
|
|
61
|
+
throw new Error("Expected a Slack button");
|
|
62
|
+
|
|
63
|
+
return { blockId: input.block_id, actionId: input.element.action_id, button };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe("A2UI actions on Slack", () => {
|
|
67
|
+
it("resolves a bound CheckBox as a boolean when a button is pressed", () => {
|
|
68
|
+
const { spec, warnings } = convertSurfaceToUISpec({
|
|
69
|
+
components: new Map([
|
|
70
|
+
[
|
|
71
|
+
"root",
|
|
72
|
+
{ id: "root", component: "Column", children: ["accepted", "send"] },
|
|
73
|
+
],
|
|
74
|
+
[
|
|
75
|
+
"accepted",
|
|
76
|
+
{
|
|
77
|
+
id: "accepted",
|
|
78
|
+
component: "CheckBox",
|
|
79
|
+
label: "Accepted",
|
|
80
|
+
value: { path: "/form/accepted" },
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
"send",
|
|
85
|
+
{
|
|
86
|
+
id: "send",
|
|
87
|
+
component: "Button",
|
|
88
|
+
label: "Send",
|
|
89
|
+
action: {
|
|
90
|
+
event: {
|
|
91
|
+
name: "send",
|
|
92
|
+
context: { accepted: { path: "/form/accepted" } },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
]),
|
|
98
|
+
dataModel: { form: { accepted: true } },
|
|
99
|
+
});
|
|
100
|
+
expect(warnings).toEqual([]);
|
|
101
|
+
if (!spec) throw new Error("Expected an A2UI tree");
|
|
102
|
+
const { blocks, warnings: slackWarnings } = toSlackBlocks(spec);
|
|
103
|
+
expect(slackWarnings).toEqual([]);
|
|
104
|
+
const checkboxBlock = blocks.find(
|
|
105
|
+
(block) =>
|
|
106
|
+
block.type === "actions" &&
|
|
107
|
+
block.elements.some((element) => element.type === "checkboxes"),
|
|
108
|
+
);
|
|
109
|
+
const buttonBlock = blocks.find(
|
|
110
|
+
(block) =>
|
|
111
|
+
block.type === "actions" &&
|
|
112
|
+
block.elements.some((element) => element.type === "button"),
|
|
113
|
+
);
|
|
114
|
+
if (
|
|
115
|
+
!checkboxBlock ||
|
|
116
|
+
checkboxBlock.type !== "actions" ||
|
|
117
|
+
!checkboxBlock.block_id ||
|
|
118
|
+
!buttonBlock ||
|
|
119
|
+
buttonBlock.type !== "actions"
|
|
120
|
+
) {
|
|
121
|
+
throw new Error("Expected named checkbox and button blocks");
|
|
122
|
+
}
|
|
123
|
+
const checkbox = checkboxBlock.elements.find(
|
|
124
|
+
(element) => element.type === "checkboxes",
|
|
125
|
+
);
|
|
126
|
+
const button = buttonBlock.elements.find(
|
|
127
|
+
(element) => element.type === "button",
|
|
128
|
+
);
|
|
129
|
+
if (!checkbox || checkbox.type !== "checkboxes" || !button) {
|
|
130
|
+
throw new Error("Expected a checkbox and button");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const [selectedOptions, accepted] of [
|
|
134
|
+
[[], false],
|
|
135
|
+
[checkbox.options[0] ? [checkbox.options[0]] : [], true],
|
|
136
|
+
] as const) {
|
|
137
|
+
expect(
|
|
138
|
+
decodeBlockAction(button, {
|
|
139
|
+
[checkboxBlock.block_id]: {
|
|
140
|
+
[checkbox.action_id]: {
|
|
141
|
+
type: "checkboxes",
|
|
142
|
+
selected_options: selectedOptions,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
).toMatchObject({ context: { accepted } });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("decodes a named Checkbox action with a list in $input", () => {
|
|
151
|
+
const blockId = `aui:0:${JSON.stringify([["toggle", "agree", "Checkbox"]])}`;
|
|
152
|
+
for (const [selectedOptions, input] of [
|
|
153
|
+
[[], []],
|
|
154
|
+
[[{ value: "agree" }], ["agree"]],
|
|
155
|
+
] as const) {
|
|
156
|
+
expect(
|
|
157
|
+
decodeBlockAction({
|
|
158
|
+
type: "checkboxes",
|
|
159
|
+
block_id: blockId,
|
|
160
|
+
action_id: "toggle",
|
|
161
|
+
selected_options: selectedOptions,
|
|
162
|
+
}),
|
|
163
|
+
).toEqual({ type: "toggle", $input: input });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("sends the user's edited TextField value", () => {
|
|
168
|
+
const { blockId, actionId, button } = convertForm();
|
|
169
|
+
const stateValues = {
|
|
170
|
+
[blockId]: {
|
|
171
|
+
[actionId]: {
|
|
172
|
+
type: "plain_text_input",
|
|
173
|
+
value: "Grace",
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
expect(decodeBlockAction(button, stateValues)).toEqual({
|
|
179
|
+
type: "a2ui:action",
|
|
180
|
+
name: "send",
|
|
181
|
+
surfaceId: "",
|
|
182
|
+
sourceComponentId: "send",
|
|
183
|
+
context: { name: "Grace" },
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("uses the agent's fallback when Slack provides no state", () => {
|
|
188
|
+
const { button } = convertForm();
|
|
189
|
+
|
|
190
|
+
expect(decodeBlockAction(button)).toEqual({
|
|
191
|
+
type: "a2ui:action",
|
|
192
|
+
name: "send",
|
|
193
|
+
surfaceId: "",
|
|
194
|
+
sourceComponentId: "send",
|
|
195
|
+
context: { name: "Ada" },
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
package/src/slack/constants.ts
CHANGED
|
@@ -54,6 +54,9 @@ export const SELECT_OPTION_CAP = 100;
|
|
|
54
54
|
/** The maximum number of options in a radio-button group. */
|
|
55
55
|
export const RADIO_OPTION_CAP = 10;
|
|
56
56
|
|
|
57
|
+
/** The maximum number of options in a checkbox group. */
|
|
58
|
+
export const CHECKBOX_OPTION_CAP = 10;
|
|
59
|
+
|
|
57
60
|
/** The character limit for a select or input placeholder. */
|
|
58
61
|
export const PLACEHOLDER_TEXT_CAP = 150;
|
|
59
62
|
|