@assistant-ui/react-generative-ui 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/JSONGenerativeUI.client.d.ts +4 -1
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.client.js +9 -2
- package/dist/JSONGenerativeUI.client.js.map +1 -1
- package/dist/JSONGenerativeUI.shared.d.ts +8 -0
- package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.js.map +1 -1
- package/dist/actionRegistry.d.ts +41 -0
- package/dist/actionRegistry.d.ts.map +1 -0
- package/dist/actionRegistry.js +41 -0
- package/dist/actionRegistry.js.map +1 -0
- package/dist/buildPresentParameters.js +10 -1
- package/dist/buildPresentParameters.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +5 -2
- package/dist/index.server.d.ts +12 -0
- package/dist/index.server.js +10 -0
- package/dist/index.shared.d.ts +11 -0
- package/dist/index.shared.js +9 -0
- package/dist/ir.d.ts +115 -0
- package/dist/ir.d.ts.map +1 -0
- package/dist/ir.js +149 -0
- package/dist/ir.js.map +1 -0
- package/dist/renderGenerativeUI.d.ts +5 -4
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/renderGenerativeUI.js +18 -45
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/types.d.ts +34 -30
- package/dist/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +34 -0
- package/dist/vocabulary/alert.d.ts.map +1 -0
- package/dist/vocabulary/alert.js +49 -0
- package/dist/vocabulary/alert.js.map +1 -0
- package/dist/vocabulary/data.d.ts +53 -0
- package/dist/vocabulary/data.d.ts.map +1 -0
- package/dist/vocabulary/data.js +63 -0
- package/dist/vocabulary/data.js.map +1 -0
- package/dist/vocabulary/fact.d.ts +20 -0
- package/dist/vocabulary/fact.d.ts.map +1 -0
- package/dist/vocabulary/fact.js +24 -0
- package/dist/vocabulary/fact.js.map +1 -0
- package/dist/vocabulary/index.d.ts +16 -0
- package/dist/vocabulary/index.d.ts.map +1 -0
- package/dist/vocabulary/index.js +30 -0
- package/dist/vocabulary/index.js.map +1 -0
- package/dist/vocabulary/interactive.d.ts +81 -0
- package/dist/vocabulary/interactive.d.ts.map +1 -0
- package/dist/vocabulary/interactive.js +123 -0
- package/dist/vocabulary/interactive.js.map +1 -0
- package/dist/vocabulary/layout.d.ts +78 -0
- package/dist/vocabulary/layout.d.ts.map +1 -0
- package/dist/vocabulary/layout.js +72 -0
- package/dist/vocabulary/layout.js.map +1 -0
- package/dist/vocabulary/media.d.ts +34 -0
- package/dist/vocabulary/media.d.ts.map +1 -0
- package/dist/vocabulary/media.js +32 -0
- package/dist/vocabulary/media.js.map +1 -0
- package/dist/vocabulary/text.d.ts +74 -0
- package/dist/vocabulary/text.d.ts.map +1 -0
- package/dist/vocabulary/text.js +48 -0
- package/dist/vocabulary/text.js.map +1 -0
- package/package.json +13 -13
- package/src/JSONGenerativeUI.client.tsx +10 -2
- package/src/JSONGenerativeUI.shared.ts +8 -0
- package/src/actionRegistry.test.ts +90 -0
- package/src/actionRegistry.ts +96 -0
- package/src/buildPresentParameters.ts +23 -7
- package/src/index.server.ts +2 -0
- package/src/index.shared.ts +58 -0
- package/src/index.ts +2 -26
- package/src/ir.test.ts +203 -0
- package/src/ir.ts +253 -0
- package/src/renderGenerativeUI.test.tsx +53 -0
- package/src/renderGenerativeUI.tsx +49 -82
- package/src/types.ts +57 -42
- package/src/vocabulary/alert.test.tsx +66 -0
- package/src/vocabulary/alert.tsx +47 -0
- package/src/vocabulary/data.test.tsx +67 -0
- package/src/vocabulary/data.tsx +86 -0
- package/src/vocabulary/fact.test.tsx +15 -0
- package/src/vocabulary/fact.tsx +22 -0
- package/src/vocabulary/index.test.ts +56 -0
- package/src/vocabulary/index.ts +27 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +110 -0
- package/src/vocabulary/interactive.test.tsx +108 -0
- package/src/vocabulary/interactive.tsx +165 -0
- package/src/vocabulary/layout.test.tsx +61 -0
- package/src/vocabulary/layout.tsx +76 -0
- package/src/vocabulary/media.test.tsx +53 -0
- package/src/vocabulary/media.tsx +37 -0
- package/src/vocabulary/text.test.tsx +73 -0
- package/src/vocabulary/text.tsx +59 -0
package/src/ir.test.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
normalizeSpec,
|
|
4
|
+
normalizeUINode,
|
|
5
|
+
type NormalizedUIElement,
|
|
6
|
+
type NormalizedUINode,
|
|
7
|
+
} from "./ir";
|
|
8
|
+
|
|
9
|
+
/** Narrows a normalized node to its element form for property assertions. */
|
|
10
|
+
const asElement = (node: NormalizedUINode): NormalizedUIElement => {
|
|
11
|
+
if (node === null || typeof node === "string" || typeof node === "number") {
|
|
12
|
+
throw new Error(`expected an element, got ${node as unknown as string}`);
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(node)) {
|
|
15
|
+
throw new Error("expected an element, got an array");
|
|
16
|
+
}
|
|
17
|
+
return node as NormalizedUIElement;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("normalizeUINode", () => {
|
|
21
|
+
describe("text leaf", () => {
|
|
22
|
+
it("passes a string through", () => {
|
|
23
|
+
expect(normalizeUINode("hello")).toBe("hello");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("legacy component shape", () => {
|
|
28
|
+
it("maps component + nested props to the canonical element", () => {
|
|
29
|
+
const node = asElement(
|
|
30
|
+
normalizeUINode({
|
|
31
|
+
component: "Card",
|
|
32
|
+
props: { padding: 4 },
|
|
33
|
+
children: ["inside"],
|
|
34
|
+
key: "c1",
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(node).toEqual({
|
|
39
|
+
type: "Card",
|
|
40
|
+
props: { padding: 4 },
|
|
41
|
+
children: ["inside"],
|
|
42
|
+
key: "c1",
|
|
43
|
+
action: undefined,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("defaults missing props to an empty bag and carries no action", () => {
|
|
48
|
+
const node = asElement(normalizeUINode({ component: "Divider" }));
|
|
49
|
+
expect(node.props).toEqual({});
|
|
50
|
+
expect(node.children).toBeUndefined();
|
|
51
|
+
expect(node.action).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("flat $type shape", () => {
|
|
56
|
+
it("keeps inline props and strips reserved keys", () => {
|
|
57
|
+
const node = asElement(
|
|
58
|
+
normalizeUINode({
|
|
59
|
+
$type: "Text",
|
|
60
|
+
value: "hi",
|
|
61
|
+
size: "md",
|
|
62
|
+
weight: "semibold",
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(node.type).toBe("Text");
|
|
67
|
+
expect(node.props).toEqual({
|
|
68
|
+
value: "hi",
|
|
69
|
+
size: "md",
|
|
70
|
+
weight: "semibold",
|
|
71
|
+
});
|
|
72
|
+
expect(node.children).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("lets a component use `type` as an ordinary prop (no collision)", () => {
|
|
76
|
+
const node = asElement(
|
|
77
|
+
normalizeUINode({
|
|
78
|
+
$type: "Button",
|
|
79
|
+
type: "submit",
|
|
80
|
+
label: "Go",
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(node.type).toBe("Button");
|
|
85
|
+
expect(node.props).toEqual({ type: "submit", label: "Go" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("routes a flat `$type` node with a `component` prop to the flat branch, not the legacy one", () => {
|
|
89
|
+
const node = asElement(
|
|
90
|
+
normalizeUINode({
|
|
91
|
+
$type: "Schema",
|
|
92
|
+
component: "display-name",
|
|
93
|
+
label: "Go",
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
expect(node.type).toBe("Schema");
|
|
98
|
+
expect(node.props).toEqual({ component: "display-name", label: "Go" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("threads $action and $key through as action/key", () => {
|
|
102
|
+
const node = asElement(
|
|
103
|
+
normalizeUINode({
|
|
104
|
+
$type: "Button",
|
|
105
|
+
$key: "buy",
|
|
106
|
+
label: "Purchase",
|
|
107
|
+
$action: { type: "purchase", itemId: "sku-1" },
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(node.key).toBe("buy");
|
|
112
|
+
expect(node.action).toEqual({ type: "purchase", itemId: "sku-1" });
|
|
113
|
+
expect(node.props).toEqual({ label: "Purchase" });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("strips the whole $-prefixed namespace from props (not just the named keys)", () => {
|
|
117
|
+
const node = asElement(
|
|
118
|
+
normalizeUINode({
|
|
119
|
+
$type: "Card",
|
|
120
|
+
$status: "done",
|
|
121
|
+
$custom: 1,
|
|
122
|
+
title: "hi",
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(node.props).toEqual({ title: "hi" });
|
|
127
|
+
expect("$status" in node.props).toBe(false);
|
|
128
|
+
expect("$custom" in node.props).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("nesting", () => {
|
|
133
|
+
it("recurses through children of both shapes", () => {
|
|
134
|
+
const node = asElement(
|
|
135
|
+
normalizeUINode({
|
|
136
|
+
$type: "Col",
|
|
137
|
+
children: [
|
|
138
|
+
{ component: "Text", props: { value: "a" } },
|
|
139
|
+
{ $type: "Text", value: "b" },
|
|
140
|
+
"leaf",
|
|
141
|
+
],
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(node.children).toEqual([
|
|
146
|
+
{
|
|
147
|
+
type: "Text",
|
|
148
|
+
props: { value: "a" },
|
|
149
|
+
children: undefined,
|
|
150
|
+
key: undefined,
|
|
151
|
+
action: undefined,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
type: "Text",
|
|
155
|
+
props: { value: "b" },
|
|
156
|
+
children: undefined,
|
|
157
|
+
key: undefined,
|
|
158
|
+
action: undefined,
|
|
159
|
+
},
|
|
160
|
+
"leaf",
|
|
161
|
+
]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("malformed input", () => {
|
|
166
|
+
it("resolves a node without $type or component to null", () => {
|
|
167
|
+
expect(normalizeUINode({ foo: "bar" } as unknown as never)).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("resolves non-record input: null and boolean to null, number to a text leaf", () => {
|
|
171
|
+
expect(normalizeUINode(null as unknown as never)).toBeNull();
|
|
172
|
+
expect(normalizeUINode(true as unknown as never)).toBeNull();
|
|
173
|
+
expect(normalizeUINode(42 as unknown as never)).toBe(42);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("normalizeSpec", () => {
|
|
179
|
+
it("normalizes a single root", () => {
|
|
180
|
+
expect(normalizeSpec({ $type: "Text", value: "x" }).root).toEqual({
|
|
181
|
+
type: "Text",
|
|
182
|
+
props: { value: "x" },
|
|
183
|
+
children: undefined,
|
|
184
|
+
key: undefined,
|
|
185
|
+
action: undefined,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("normalizes a list root", () => {
|
|
190
|
+
const { root } = normalizeSpec([{ $type: "Text", value: "a" }, "b"]);
|
|
191
|
+
expect(Array.isArray(root)).toBe(true);
|
|
192
|
+
expect(root).toEqual([
|
|
193
|
+
{
|
|
194
|
+
type: "Text",
|
|
195
|
+
props: { value: "a" },
|
|
196
|
+
children: undefined,
|
|
197
|
+
key: undefined,
|
|
198
|
+
action: undefined,
|
|
199
|
+
},
|
|
200
|
+
"b",
|
|
201
|
+
]);
|
|
202
|
+
});
|
|
203
|
+
});
|
package/src/ir.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generative-ui intermediate representation: a model-emitted, cross-platform
|
|
3
|
+
* UI tree. React-free, so converters and non-web runtimes consume it without
|
|
4
|
+
* pulling React. The flat `$type` shape is the canonical form; the legacy
|
|
5
|
+
* `component` shape is accepted as a backward-compatible alias.
|
|
6
|
+
*
|
|
7
|
+
* Reserved keys are partitioned off from component props so the component prop
|
|
8
|
+
* namespace stays fully free:
|
|
9
|
+
*
|
|
10
|
+
* - `$`-prefixed keys are framework-reserved (`$type`, `$key`, `$action`,
|
|
11
|
+
* `$status`). Components never declare `$`-prefixed props, so a component
|
|
12
|
+
* can use `type`, `status`, `variant`, etc. as ordinary props without
|
|
13
|
+
* colliding with the framework.
|
|
14
|
+
* - `children` is additionally reserved (the JSX convention).
|
|
15
|
+
* - every other key is an inline prop passed straight to the component.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { TYPE_KEY } from "./constants";
|
|
19
|
+
|
|
20
|
+
export const TEXT_SIZES = ["sm", "md", "lg", "xl", "2xl", "3xl"] as const;
|
|
21
|
+
export type TextSize = (typeof TEXT_SIZES)[number];
|
|
22
|
+
|
|
23
|
+
export const IMAGE_SIZE_TOKENS = ["sm", "md", "lg"] as const;
|
|
24
|
+
export type ImageSize = (typeof IMAGE_SIZE_TOKENS)[number] | number;
|
|
25
|
+
|
|
26
|
+
export const WEIGHTS = ["normal", "medium", "semibold", "bold"] as const;
|
|
27
|
+
export type Weight = (typeof WEIGHTS)[number];
|
|
28
|
+
|
|
29
|
+
export const COLORS = [
|
|
30
|
+
"emphasis",
|
|
31
|
+
"secondary",
|
|
32
|
+
"alpha-70",
|
|
33
|
+
"white",
|
|
34
|
+
"white-70",
|
|
35
|
+
"white-50",
|
|
36
|
+
] as const;
|
|
37
|
+
export type Color = (typeof COLORS)[number];
|
|
38
|
+
|
|
39
|
+
export const ALIGNS = ["start", "center", "end"] as const;
|
|
40
|
+
export type Align = (typeof ALIGNS)[number];
|
|
41
|
+
|
|
42
|
+
export const JUSTIFIES = ["start", "center", "end", "between"] as const;
|
|
43
|
+
export type Justify = (typeof JUSTIFIES)[number];
|
|
44
|
+
|
|
45
|
+
export const BUTTON_STYLES = [
|
|
46
|
+
"primary",
|
|
47
|
+
"secondary",
|
|
48
|
+
"outline",
|
|
49
|
+
"ghost",
|
|
50
|
+
"danger",
|
|
51
|
+
] as const;
|
|
52
|
+
export type ButtonStyle = (typeof BUTTON_STYLES)[number];
|
|
53
|
+
|
|
54
|
+
export const ALERT_TONES = ["info", "success", "warning", "danger"] as const;
|
|
55
|
+
/** Maps to ChatKit `alert` severity levels. */
|
|
56
|
+
export type AlertTone = (typeof ALERT_TONES)[number];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Behavior payload carried by an interactive node. `type` is resolved by the
|
|
60
|
+
* host's action registry, not the renderer; keeping behavior as data keeps the
|
|
61
|
+
* tree serializable so the same node renders on web and converts to a native
|
|
62
|
+
* action id on Slack.
|
|
63
|
+
*/
|
|
64
|
+
export interface Action {
|
|
65
|
+
readonly type: string;
|
|
66
|
+
readonly [payload: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Anything renderable as generative UI, as the model emits it. The renderer
|
|
71
|
+
* also accepts `number`, `boolean`, `null`, `undefined`, and arrays at the
|
|
72
|
+
* input boundary (numbers render as text, falsy/boolean as nothing, arrays as
|
|
73
|
+
* lists); {@link normalizeUINode} accepts that full range.
|
|
74
|
+
*/
|
|
75
|
+
export type UINode = string | number | UIElement | LegacyComponentNode;
|
|
76
|
+
|
|
77
|
+
export type UIChildren = UINode | readonly UINode[];
|
|
78
|
+
|
|
79
|
+
/** The flat node shape: inline props keep the tree compact and natural for a
|
|
80
|
+
* model to emit, instead of a nested `{ type, props }` bag. Reserved keys are
|
|
81
|
+
* stripped before props reach the component (see the module header). */
|
|
82
|
+
export interface UIElement {
|
|
83
|
+
readonly $type: string;
|
|
84
|
+
readonly $key?: string | number;
|
|
85
|
+
readonly children?: UIChildren;
|
|
86
|
+
readonly $action?: Action;
|
|
87
|
+
readonly [prop: string]: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The legacy node shape: a `component` name plus a nested `props` bag. Kept
|
|
92
|
+
* for backward compatibility. New code authors the flat {@link UIElement}
|
|
93
|
+
* shape instead.
|
|
94
|
+
*/
|
|
95
|
+
export interface LegacyComponentNode {
|
|
96
|
+
readonly component: string;
|
|
97
|
+
readonly props?: Record<string, unknown>;
|
|
98
|
+
readonly children?: UIChildren;
|
|
99
|
+
readonly key?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type UISpec = UINode | readonly UINode[];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A node normalized to a single canonical shape: a `type` string, an inline
|
|
106
|
+
* `props` bag, recursive `children`, an optional `key`, and an optional
|
|
107
|
+
* `action`. Renderers and platform converters consume this form, so they never
|
|
108
|
+
* branch on whether the model emitted the flat `$type` shape or the legacy
|
|
109
|
+
* `component` shape, and they never see the reserved `$`-prefixed keys leak
|
|
110
|
+
* into component props.
|
|
111
|
+
*/
|
|
112
|
+
export interface NormalizedUIElement {
|
|
113
|
+
readonly type: string;
|
|
114
|
+
readonly props: Readonly<Record<string, unknown>>;
|
|
115
|
+
readonly children?: NormalizedUINode | undefined;
|
|
116
|
+
readonly key?: string | number | undefined;
|
|
117
|
+
readonly action?: Action | undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type NormalizedUINode =
|
|
121
|
+
| string
|
|
122
|
+
| number
|
|
123
|
+
| readonly NormalizedUINode[]
|
|
124
|
+
| NormalizedUIElement
|
|
125
|
+
| null;
|
|
126
|
+
|
|
127
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
128
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
129
|
+
|
|
130
|
+
type LegacyNodeRecord = { component: string } & Record<string, unknown>;
|
|
131
|
+
|
|
132
|
+
const isLegacyNode = (
|
|
133
|
+
node: Record<string, unknown>,
|
|
134
|
+
): node is LegacyNodeRecord => typeof node["component"] === "string";
|
|
135
|
+
|
|
136
|
+
const isTypeNode = (node: Record<string, unknown>): node is UIElement =>
|
|
137
|
+
typeof node[TYPE_KEY] === "string";
|
|
138
|
+
|
|
139
|
+
/** Bounds recursion so a runaway or adversarial model response cannot overflow
|
|
140
|
+
* the stack; past this depth (far beyond any real UI) we stop. */
|
|
141
|
+
const MAX_DEPTH = 64;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Steps a streaming partial path down into `key`. `partialPath` is the remaining
|
|
145
|
+
* segment of the parse meta's partial path relative to `node` (`undefined` once
|
|
146
|
+
* the walk leaves the partial frontier, i.e. everything below is complete).
|
|
147
|
+
*/
|
|
148
|
+
function descend(
|
|
149
|
+
partialPath: readonly string[] | undefined,
|
|
150
|
+
key: string,
|
|
151
|
+
): readonly string[] | undefined {
|
|
152
|
+
return partialPath?.[0] === key ? partialPath.slice(1) : undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Normalizes a generative-ui input to {@link NormalizedUINode}. The flat
|
|
157
|
+
* `$type` shape and the legacy `component` shape both map to the same canonical
|
|
158
|
+
* element, with reserved keys (`$type`, `$key`, `$action`, `children`) stripped
|
|
159
|
+
* from the prop bag. A node that carries neither a `$type` nor a `component`
|
|
160
|
+
* string is not renderable and resolves to `null` rather than throwing, so a
|
|
161
|
+
* partially-streamed or malformed node degrades to "render nothing".
|
|
162
|
+
*
|
|
163
|
+
* `partialPath` carries streaming state from the tool-args parse meta: a node
|
|
164
|
+
* whose `$type` is still mid-arrival is held back (resolves to `null`) until it
|
|
165
|
+
* completes, and the path is threaded into `children` so a nested streaming
|
|
166
|
+
* node is held back while completed siblings render. Omit it for a
|
|
167
|
+
* non-streaming (converter) normalize.
|
|
168
|
+
*/
|
|
169
|
+
export function normalizeUINode(
|
|
170
|
+
node: unknown,
|
|
171
|
+
partialPath?: readonly string[] | undefined,
|
|
172
|
+
depth = 0,
|
|
173
|
+
): NormalizedUINode {
|
|
174
|
+
if (depth > MAX_DEPTH) return null;
|
|
175
|
+
if (node == null || typeof node === "boolean") return null;
|
|
176
|
+
if (typeof node === "string" || typeof node === "number") return node;
|
|
177
|
+
if (Array.isArray(node))
|
|
178
|
+
return node.map((child, index) =>
|
|
179
|
+
normalizeUINode(child, descend(partialPath, String(index)), depth + 1),
|
|
180
|
+
);
|
|
181
|
+
if (!isRecord(node)) return null;
|
|
182
|
+
|
|
183
|
+
// The flat `$type` shape is the canonical form; detect it first so a flat
|
|
184
|
+
// node that happens to use `component` as an ordinary prop is not swallowed
|
|
185
|
+
// by the legacy `component`-shape branch.
|
|
186
|
+
if (isTypeNode(node)) {
|
|
187
|
+
if (partialPath?.length === 1 && partialPath[0] === TYPE_KEY) return null;
|
|
188
|
+
const { [TYPE_KEY]: type, $key, $action, children, ...rest } = node;
|
|
189
|
+
// The `$`-prefixed namespace is framework-reserved (see the module header),
|
|
190
|
+
// so any model-supplied `$`-prefixed key is stripped from the prop bag the
|
|
191
|
+
// component sees. `$type`/`$key`/`$action` are pulled above; sweep the rest
|
|
192
|
+
// (e.g. a stray `$status`) so it never leaks to converters or components.
|
|
193
|
+
const props = stripReservedProps(rest);
|
|
194
|
+
return {
|
|
195
|
+
type,
|
|
196
|
+
props,
|
|
197
|
+
children: normalizeChildren(children, partialPath, depth),
|
|
198
|
+
key: $key,
|
|
199
|
+
action: $action,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (isLegacyNode(node)) {
|
|
204
|
+
const props = stripReservedProps(
|
|
205
|
+
(node.props ?? {}) as Record<string, unknown>,
|
|
206
|
+
);
|
|
207
|
+
return {
|
|
208
|
+
type: node.component,
|
|
209
|
+
props,
|
|
210
|
+
children: normalizeChildren(node.children, partialPath, depth),
|
|
211
|
+
key: node.key as string | undefined,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function stripReservedProps(
|
|
219
|
+
props: Record<string, unknown>,
|
|
220
|
+
): Record<string, unknown> {
|
|
221
|
+
let out: Record<string, unknown> | undefined;
|
|
222
|
+
for (const key of Object.keys(props)) {
|
|
223
|
+
if (key.startsWith("$")) {
|
|
224
|
+
out ??= { ...props };
|
|
225
|
+
delete out[key];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out ?? props;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeChildren(
|
|
232
|
+
children: unknown,
|
|
233
|
+
partialPath: readonly string[] | undefined,
|
|
234
|
+
depth: number,
|
|
235
|
+
): NormalizedUINode | undefined {
|
|
236
|
+
if (children === undefined) return undefined;
|
|
237
|
+
return normalizeUINode(children, descend(partialPath, "children"), depth + 1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Normalizes the root of a {@link UISpec}, preserving whether the root was a
|
|
242
|
+
* single node or a list.
|
|
243
|
+
*/
|
|
244
|
+
export function normalizeSpec(spec: UISpec): {
|
|
245
|
+
readonly root: NormalizedUINode | readonly NormalizedUINode[];
|
|
246
|
+
} {
|
|
247
|
+
if (Array.isArray(spec)) {
|
|
248
|
+
return {
|
|
249
|
+
root: (spec as readonly UINode[]).map((node) => normalizeUINode(node)),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { root: normalizeUINode(spec) };
|
|
253
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { isValidElement, type ReactElement } from "react";
|
|
3
4
|
import { parsePartialJsonObject } from "assistant-stream/utils";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { renderGenerativeUI } from "./renderGenerativeUI";
|
|
@@ -65,6 +66,28 @@ describe("renderGenerativeUI", () => {
|
|
|
65
66
|
);
|
|
66
67
|
});
|
|
67
68
|
|
|
69
|
+
it("uses stable $key for array items and keeps the positional fallback", () => {
|
|
70
|
+
const out = renderGenerativeUI(
|
|
71
|
+
[
|
|
72
|
+
{ $type: "Text", $key: "1:Text", children: "first" },
|
|
73
|
+
{ $type: "Text", children: "second" },
|
|
74
|
+
"plain",
|
|
75
|
+
{ $type: "Text", $key: { id: "bad" }, children: "bad key" },
|
|
76
|
+
],
|
|
77
|
+
library,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(Array.isArray(out)).toBe(true);
|
|
81
|
+
const elements = out as ReactElement[];
|
|
82
|
+
expect(elements.every(isValidElement)).toBe(true);
|
|
83
|
+
expect(elements.map((element) => element.key)).toEqual([
|
|
84
|
+
"model:1:Text",
|
|
85
|
+
"1:Text",
|
|
86
|
+
"2:#text",
|
|
87
|
+
"3:Text",
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
68
91
|
it("passes a component's own `type` prop through without collision", () => {
|
|
69
92
|
const html = renderToStaticMarkup(
|
|
70
93
|
<>
|
|
@@ -226,6 +249,36 @@ describe("buildPresentParameters", () => {
|
|
|
226
249
|
expect(schema.required).toEqual(["$type"]);
|
|
227
250
|
});
|
|
228
251
|
|
|
252
|
+
it("names every component that declares the same prop in the dev warning", () => {
|
|
253
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
254
|
+
try {
|
|
255
|
+
buildPresentParameters({
|
|
256
|
+
Select: {
|
|
257
|
+
description: "Selects an option.",
|
|
258
|
+
properties: z.object({ value: z.string() }),
|
|
259
|
+
render: () => null,
|
|
260
|
+
},
|
|
261
|
+
DatePicker: {
|
|
262
|
+
description: "Picks a date.",
|
|
263
|
+
properties: z.object({ value: z.string() }),
|
|
264
|
+
render: () => null,
|
|
265
|
+
},
|
|
266
|
+
Combobox: {
|
|
267
|
+
description: "Chooses from filtered options.",
|
|
268
|
+
properties: z.object({ value: z.string() }),
|
|
269
|
+
render: () => null,
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
274
|
+
expect(warn).toHaveBeenCalledWith(
|
|
275
|
+
'[@assistant-ui/react-generative-ui] Prop "value" is declared by "Select", "DatePicker", and "Combobox"; keeping "Select"\'s schema. Rename or align the prop type to avoid an ambiguous schema.',
|
|
276
|
+
);
|
|
277
|
+
} finally {
|
|
278
|
+
warn.mockRestore();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
229
282
|
it("throws when a component's properties is not an object schema", () => {
|
|
230
283
|
expect(() =>
|
|
231
284
|
buildPresentParameters({
|