@assistant-ui/react-generative-ui 0.0.6 → 0.0.8
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 -2
- 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.server.d.ts +0 -1
- package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.d.ts +8 -1
- package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.js.map +1 -1
- package/dist/actionRegistry.d.ts +32 -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.d.ts +5 -5
- package/dist/buildPresentParameters.d.ts.map +1 -1
- package/dist/buildPresentParameters.js +20 -6
- package/dist/buildPresentParameters.js.map +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/defineGenerativeComponents.d.ts +0 -1
- package/dist/defineGenerativeComponents.d.ts.map +1 -1
- package/dist/generativeUIToJSX.d.ts +15 -10
- package/dist/generativeUIToJSX.d.ts.map +1 -1
- package/dist/generativeUIToJSX.js +47 -17
- package/dist/generativeUIToJSX.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +5 -2
- package/dist/index.server.d.ts +13 -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 +175 -0
- package/dist/ir.js.map +1 -0
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
- package/dist/renderGenerativeUI.d.ts +5 -5
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/renderGenerativeUI.js +18 -45
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/slack/constants.d.ts +111 -0
- package/dist/slack/constants.d.ts.map +1 -0
- package/dist/slack/constants.js +168 -0
- package/dist/slack/constants.js.map +1 -0
- package/dist/slack/decodeBlockAction.d.ts +11 -0
- package/dist/slack/decodeBlockAction.d.ts.map +1 -0
- package/dist/slack/decodeBlockAction.js +40 -0
- package/dist/slack/decodeBlockAction.js.map +1 -0
- package/dist/slack/fromSlackBlocks.d.ts +30 -0
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -0
- package/dist/slack/fromSlackBlocks.js +447 -0
- package/dist/slack/fromSlackBlocks.js.map +1 -0
- package/dist/slack/toSlackBlocks.d.ts +7 -0
- package/dist/slack/toSlackBlocks.d.ts.map +1 -0
- package/dist/slack/toSlackBlocks.js +649 -0
- package/dist/slack/toSlackBlocks.js.map +1 -0
- package/dist/slack/types.d.ts +178 -0
- package/dist/slack/types.d.ts.map +1 -0
- package/dist/slack/types.js +0 -0
- package/dist/slack.d.ts +5 -0
- package/dist/slack.js +4 -0
- package/dist/teams/boundSpec.d.ts +20 -0
- package/dist/teams/boundSpec.d.ts.map +1 -0
- package/dist/teams/boundSpec.js +67 -0
- package/dist/teams/boundSpec.js.map +1 -0
- package/dist/teams/constants.d.ts +57 -0
- package/dist/teams/constants.d.ts.map +1 -0
- package/dist/teams/constants.js +92 -0
- package/dist/teams/constants.js.map +1 -0
- package/dist/teams/decodeSubmitData.d.ts +25 -0
- package/dist/teams/decodeSubmitData.d.ts.map +1 -0
- package/dist/teams/decodeSubmitData.js +44 -0
- package/dist/teams/decodeSubmitData.js.map +1 -0
- package/dist/teams/toAdaptiveCard.d.ts +39 -0
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -0
- package/dist/teams/toAdaptiveCard.js +496 -0
- package/dist/teams/toAdaptiveCard.js.map +1 -0
- package/dist/teams/toTeamsAttachments.d.ts +13 -0
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -0
- package/dist/teams/toTeamsAttachments.js +95 -0
- package/dist/teams/toTeamsAttachments.js.map +1 -0
- package/dist/teams/types.d.ts +196 -0
- package/dist/teams/types.d.ts.map +1 -0
- package/dist/teams/types.js +0 -0
- package/dist/teams.d.ts +5 -0
- package/dist/teams.js +4 -0
- package/dist/types.d.ts +41 -34
- package/dist/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +28 -0
- package/dist/vocabulary/alert.d.ts.map +1 -0
- package/dist/vocabulary/alert.js +58 -0
- package/dist/vocabulary/alert.js.map +1 -0
- package/dist/vocabulary/collectFormValues.d.ts +23 -0
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -0
- package/dist/vocabulary/collectFormValues.js +32 -0
- package/dist/vocabulary/collectFormValues.js.map +1 -0
- package/dist/vocabulary/data.d.ts +52 -0
- package/dist/vocabulary/data.d.ts.map +1 -0
- package/dist/vocabulary/data.js +294 -0
- package/dist/vocabulary/data.js.map +1 -0
- package/dist/vocabulary/dispatch.d.ts +11 -0
- package/dist/vocabulary/dispatch.d.ts.map +1 -0
- package/dist/vocabulary/dispatch.js +21 -0
- package/dist/vocabulary/dispatch.js.map +1 -0
- package/dist/vocabulary/fact.d.ts +15 -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/form.d.ts +14 -0
- package/dist/vocabulary/form.d.ts.map +1 -0
- package/dist/vocabulary/form.js +23 -0
- package/dist/vocabulary/form.js.map +1 -0
- package/dist/vocabulary/icon.d.ts +44 -0
- package/dist/vocabulary/icon.d.ts.map +1 -0
- package/dist/vocabulary/icon.js +272 -0
- package/dist/vocabulary/icon.js.map +1 -0
- package/dist/vocabulary/index.d.ts +15 -0
- package/dist/vocabulary/index.d.ts.map +1 -0
- package/dist/vocabulary/index.js +36 -0
- package/dist/vocabulary/index.js.map +1 -0
- package/dist/vocabulary/interactive.d.ts +93 -0
- package/dist/vocabulary/interactive.d.ts.map +1 -0
- package/dist/vocabulary/interactive.js +172 -0
- package/dist/vocabulary/interactive.js.map +1 -0
- package/dist/vocabulary/layout.d.ts +82 -0
- package/dist/vocabulary/layout.d.ts.map +1 -0
- package/dist/vocabulary/layout.js +136 -0
- package/dist/vocabulary/layout.js.map +1 -0
- package/dist/vocabulary/list.d.ts +17 -0
- package/dist/vocabulary/list.d.ts.map +1 -0
- package/dist/vocabulary/list.js +48 -0
- package/dist/vocabulary/list.js.map +1 -0
- package/dist/vocabulary/media.d.ts +28 -0
- package/dist/vocabulary/media.d.ts.map +1 -0
- package/dist/vocabulary/media.js +42 -0
- package/dist/vocabulary/media.js.map +1 -0
- package/dist/vocabulary/text.d.ts +60 -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 +21 -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 +88 -0
- package/src/buildPresentParameters.ts +38 -16
- package/src/generativeUIToJSX.test.ts +186 -0
- package/src/generativeUIToJSX.ts +112 -21
- package/src/index.server.ts +2 -0
- package/src/index.shared.ts +61 -0
- package/src/index.ts +2 -26
- package/src/ir.test.ts +203 -0
- package/src/ir.ts +279 -0
- package/src/renderGenerativeUI.test.tsx +69 -2
- package/src/renderGenerativeUI.tsx +49 -82
- package/src/slack/constants.ts +217 -0
- package/src/slack/decodeBlockAction.test.ts +135 -0
- package/src/slack/decodeBlockAction.ts +66 -0
- package/src/slack/fromSlackBlocks.test.ts +1145 -0
- package/src/slack/fromSlackBlocks.ts +709 -0
- package/src/slack/toSlackBlocks.test.ts +1936 -0
- package/src/slack/toSlackBlocks.ts +1319 -0
- package/src/slack/types.ts +230 -0
- package/src/slack.ts +37 -0
- package/src/teams/boundSpec.ts +102 -0
- package/src/teams/constants.ts +127 -0
- package/src/teams/decodeSubmitData.test.ts +148 -0
- package/src/teams/decodeSubmitData.ts +57 -0
- package/src/teams/toAdaptiveCard.test.ts +1410 -0
- package/src/teams/toAdaptiveCard.ts +762 -0
- package/src/teams/toTeamsAttachments.test.ts +29 -0
- package/src/teams/toTeamsAttachments.ts +137 -0
- package/src/teams/types.ts +237 -0
- package/src/teams.ts +34 -0
- package/src/types.ts +57 -42
- package/src/vocabulary/alert.test.tsx +136 -0
- package/src/vocabulary/alert.tsx +66 -0
- package/src/vocabulary/collectFormValues.test.ts +127 -0
- package/src/vocabulary/collectFormValues.ts +58 -0
- package/src/vocabulary/data.test.tsx +567 -0
- package/src/vocabulary/data.tsx +494 -0
- package/src/vocabulary/dispatch.ts +22 -0
- package/src/vocabulary/fact.test.tsx +15 -0
- package/src/vocabulary/fact.tsx +22 -0
- package/src/vocabulary/form.test.tsx +112 -0
- package/src/vocabulary/form.tsx +34 -0
- package/src/vocabulary/icon.test.tsx +64 -0
- package/src/vocabulary/icon.tsx +174 -0
- package/src/vocabulary/index.test.ts +63 -0
- package/src/vocabulary/index.ts +33 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +352 -0
- package/src/vocabulary/interactive.test.tsx +248 -0
- package/src/vocabulary/interactive.tsx +269 -0
- package/src/vocabulary/layout.test.tsx +289 -0
- package/src/vocabulary/layout.tsx +229 -0
- package/src/vocabulary/list.test.tsx +195 -0
- package/src/vocabulary/list.tsx +48 -0
- package/src/vocabulary/media.test.tsx +85 -0
- package/src/vocabulary/media.tsx +56 -0
- package/src/vocabulary/text.test.tsx +73 -0
- package/src/vocabulary/text.tsx +59 -0
package/src/ir.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
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
|
+
/** A severity level, from informational to destructive. */
|
|
56
|
+
export type AlertTone = (typeof ALERT_TONES)[number];
|
|
57
|
+
|
|
58
|
+
export const ICON_NAMES = [
|
|
59
|
+
"sun",
|
|
60
|
+
"moon",
|
|
61
|
+
"cloud",
|
|
62
|
+
"rain",
|
|
63
|
+
"snow",
|
|
64
|
+
"wind",
|
|
65
|
+
"play",
|
|
66
|
+
"pause",
|
|
67
|
+
"check",
|
|
68
|
+
"x",
|
|
69
|
+
"star",
|
|
70
|
+
"heart",
|
|
71
|
+
"arrow-right",
|
|
72
|
+
"arrow-up-right",
|
|
73
|
+
"chevron-right",
|
|
74
|
+
"calendar",
|
|
75
|
+
"clock",
|
|
76
|
+
"map-pin",
|
|
77
|
+
"plane",
|
|
78
|
+
"truck",
|
|
79
|
+
"credit-card",
|
|
80
|
+
"user",
|
|
81
|
+
"search",
|
|
82
|
+
"bell",
|
|
83
|
+
] as const;
|
|
84
|
+
/** A name from the built-in icon set; the closed enum lets the model see exactly which icons exist. */
|
|
85
|
+
export type IconName = (typeof ICON_NAMES)[number];
|
|
86
|
+
|
|
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.
|
|
89
|
+
*/
|
|
90
|
+
export interface Action {
|
|
91
|
+
readonly type: string;
|
|
92
|
+
readonly [payload: string]: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Anything renderable as generative UI, as the model emits it. The renderer
|
|
97
|
+
* also accepts `number`, `boolean`, `null`, `undefined`, and arrays at the
|
|
98
|
+
* input boundary (numbers render as text, falsy/boolean as nothing, arrays as
|
|
99
|
+
* lists); {@link normalizeUINode} accepts that full range.
|
|
100
|
+
*/
|
|
101
|
+
export type UINode = string | number | UIElement | LegacyComponentNode;
|
|
102
|
+
|
|
103
|
+
export type UIChildren = UINode | readonly UINode[];
|
|
104
|
+
|
|
105
|
+
/** The flat node shape: inline props keep the tree compact and natural for a
|
|
106
|
+
* model to emit, instead of a nested `{ type, props }` bag. Reserved keys are
|
|
107
|
+
* stripped before props reach the component (see the module header). */
|
|
108
|
+
export interface UIElement {
|
|
109
|
+
readonly $type: string;
|
|
110
|
+
readonly $key?: string | number;
|
|
111
|
+
readonly children?: UIChildren;
|
|
112
|
+
readonly $action?: Action;
|
|
113
|
+
readonly [prop: string]: unknown;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The legacy node shape: a `component` name plus a nested `props` bag. Kept
|
|
118
|
+
* for backward compatibility. New code authors the flat {@link UIElement}
|
|
119
|
+
* shape instead.
|
|
120
|
+
*/
|
|
121
|
+
export interface LegacyComponentNode {
|
|
122
|
+
readonly component: string;
|
|
123
|
+
readonly props?: Record<string, unknown>;
|
|
124
|
+
readonly children?: UIChildren;
|
|
125
|
+
readonly key?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type UISpec = UINode | readonly UINode[];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A node normalized to a single canonical shape: a `type` string, an inline
|
|
132
|
+
* `props` bag, recursive `children`, an optional `key`, and an optional
|
|
133
|
+
* `action`. Renderers and platform converters consume this form, so they never
|
|
134
|
+
* branch on whether the model emitted the flat `$type` shape or the legacy
|
|
135
|
+
* `component` shape, and they never see the reserved `$`-prefixed keys leak
|
|
136
|
+
* into component props.
|
|
137
|
+
*/
|
|
138
|
+
export interface NormalizedUIElement {
|
|
139
|
+
readonly type: string;
|
|
140
|
+
readonly props: Readonly<Record<string, unknown>>;
|
|
141
|
+
readonly children?: NormalizedUINode | undefined;
|
|
142
|
+
readonly key?: string | number | undefined;
|
|
143
|
+
readonly action?: Action | undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type NormalizedUINode =
|
|
147
|
+
| string
|
|
148
|
+
| number
|
|
149
|
+
| readonly NormalizedUINode[]
|
|
150
|
+
| NormalizedUIElement
|
|
151
|
+
| null;
|
|
152
|
+
|
|
153
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
154
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
155
|
+
|
|
156
|
+
type LegacyNodeRecord = { component: string } & Record<string, unknown>;
|
|
157
|
+
|
|
158
|
+
const isLegacyNode = (
|
|
159
|
+
node: Record<string, unknown>,
|
|
160
|
+
): node is LegacyNodeRecord => typeof node["component"] === "string";
|
|
161
|
+
|
|
162
|
+
const isTypeNode = (node: Record<string, unknown>): node is UIElement =>
|
|
163
|
+
typeof node[TYPE_KEY] === "string";
|
|
164
|
+
|
|
165
|
+
/** Bounds recursion so a runaway or adversarial model response cannot overflow
|
|
166
|
+
* the stack; past this depth (far beyond any real UI) we stop. */
|
|
167
|
+
const MAX_DEPTH = 64;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Steps a streaming partial path down into `key`. `partialPath` is the remaining
|
|
171
|
+
* segment of the parse meta's partial path relative to `node` (`undefined` once
|
|
172
|
+
* the walk leaves the partial frontier, i.e. everything below is complete).
|
|
173
|
+
*/
|
|
174
|
+
function descend(
|
|
175
|
+
partialPath: readonly string[] | undefined,
|
|
176
|
+
key: string,
|
|
177
|
+
): readonly string[] | undefined {
|
|
178
|
+
return partialPath?.[0] === key ? partialPath.slice(1) : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Normalizes a generative-ui input to {@link NormalizedUINode}. The flat
|
|
183
|
+
* `$type` shape and the legacy `component` shape both map to the same canonical
|
|
184
|
+
* element, with reserved keys (`$type`, `$key`, `$action`, `children`) stripped
|
|
185
|
+
* from the prop bag. A node that carries neither a `$type` nor a `component`
|
|
186
|
+
* string is not renderable and resolves to `null` rather than throwing, so a
|
|
187
|
+
* partially-streamed or malformed node degrades to "render nothing".
|
|
188
|
+
*
|
|
189
|
+
* `partialPath` carries streaming state from the tool-args parse meta: a node
|
|
190
|
+
* whose `$type` is still mid-arrival is held back (resolves to `null`) until it
|
|
191
|
+
* completes, and the path is threaded into `children` so a nested streaming
|
|
192
|
+
* node is held back while completed siblings render. Omit it for a
|
|
193
|
+
* non-streaming (converter) normalize.
|
|
194
|
+
*/
|
|
195
|
+
export function normalizeUINode(
|
|
196
|
+
node: unknown,
|
|
197
|
+
partialPath?: readonly string[] | undefined,
|
|
198
|
+
depth = 0,
|
|
199
|
+
): NormalizedUINode {
|
|
200
|
+
if (depth > MAX_DEPTH) return null;
|
|
201
|
+
if (node == null || typeof node === "boolean") return null;
|
|
202
|
+
if (typeof node === "string" || typeof node === "number") return node;
|
|
203
|
+
if (Array.isArray(node))
|
|
204
|
+
return node.map((child, index) =>
|
|
205
|
+
normalizeUINode(child, descend(partialPath, String(index)), depth + 1),
|
|
206
|
+
);
|
|
207
|
+
if (!isRecord(node)) return null;
|
|
208
|
+
|
|
209
|
+
// The flat `$type` shape is the canonical form; detect it first so a flat
|
|
210
|
+
// node that happens to use `component` as an ordinary prop is not swallowed
|
|
211
|
+
// by the legacy `component`-shape branch.
|
|
212
|
+
if (isTypeNode(node)) {
|
|
213
|
+
if (partialPath?.length === 1 && partialPath[0] === TYPE_KEY) return null;
|
|
214
|
+
const { [TYPE_KEY]: type, $key, $action, children, ...rest } = node;
|
|
215
|
+
// The `$`-prefixed namespace is framework-reserved (see the module header),
|
|
216
|
+
// so any model-supplied `$`-prefixed key is stripped from the prop bag the
|
|
217
|
+
// component sees. `$type`/`$key`/`$action` are pulled above; sweep the rest
|
|
218
|
+
// (e.g. a stray `$status`) so it never leaks to converters or components.
|
|
219
|
+
const props = stripReservedProps(rest);
|
|
220
|
+
return {
|
|
221
|
+
type,
|
|
222
|
+
props,
|
|
223
|
+
children: normalizeChildren(children, partialPath, depth),
|
|
224
|
+
key: $key,
|
|
225
|
+
action: $action,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (isLegacyNode(node)) {
|
|
230
|
+
const props = stripReservedProps(
|
|
231
|
+
(node.props ?? {}) as Record<string, unknown>,
|
|
232
|
+
);
|
|
233
|
+
return {
|
|
234
|
+
type: node.component,
|
|
235
|
+
props,
|
|
236
|
+
children: normalizeChildren(node.children, partialPath, depth),
|
|
237
|
+
key: node.key as string | undefined,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function stripReservedProps(
|
|
245
|
+
props: Record<string, unknown>,
|
|
246
|
+
): Record<string, unknown> {
|
|
247
|
+
let out: Record<string, unknown> | undefined;
|
|
248
|
+
for (const key of Object.keys(props)) {
|
|
249
|
+
if (key.startsWith("$")) {
|
|
250
|
+
out ??= { ...props };
|
|
251
|
+
delete out[key];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return out ?? props;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeChildren(
|
|
258
|
+
children: unknown,
|
|
259
|
+
partialPath: readonly string[] | undefined,
|
|
260
|
+
depth: number,
|
|
261
|
+
): NormalizedUINode | undefined {
|
|
262
|
+
if (children === undefined) return undefined;
|
|
263
|
+
return normalizeUINode(children, descend(partialPath, "children"), depth + 1);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Normalizes the root of a {@link UISpec}, preserving whether the root was a
|
|
268
|
+
* single node or a list.
|
|
269
|
+
*/
|
|
270
|
+
export function normalizeSpec(spec: UISpec): {
|
|
271
|
+
readonly root: NormalizedUINode | readonly NormalizedUINode[];
|
|
272
|
+
} {
|
|
273
|
+
if (Array.isArray(spec)) {
|
|
274
|
+
return {
|
|
275
|
+
root: (spec as readonly UINode[]).map((node) => normalizeUINode(node)),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return { root: normalizeUINode(spec) };
|
|
279
|
+
}
|
|
@@ -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
|
<>
|
|
@@ -189,6 +212,11 @@ describe("buildPresentParameters", () => {
|
|
|
189
212
|
]);
|
|
190
213
|
// each component's description rides along on the $type enum.
|
|
191
214
|
expect(schema.properties.$type.description).toContain("Card");
|
|
215
|
+
expect(schema.properties.$key).toEqual({
|
|
216
|
+
description:
|
|
217
|
+
"Stable identity for this UI node. Use it for list items that may reorder.",
|
|
218
|
+
anyOf: [{ type: "string" }, { type: "number" }],
|
|
219
|
+
});
|
|
192
220
|
expect(schema.properties.children.$ref).toBe("#/$defs/children");
|
|
193
221
|
|
|
194
222
|
// every component's props are merged into the one flat property bag.
|
|
@@ -205,12 +233,14 @@ describe("buildPresentParameters", () => {
|
|
|
205
233
|
expect(schema.$defs.node.oneOf).toBeUndefined();
|
|
206
234
|
});
|
|
207
235
|
|
|
208
|
-
it("drops author-declared
|
|
236
|
+
it("drops author-declared `$`-prefixed and `children` props, keeping framework fields", () => {
|
|
209
237
|
const schema = buildPresentParameters({
|
|
210
238
|
Reserved: {
|
|
211
239
|
description: "Declares reserved keys that must not leak through.",
|
|
212
240
|
properties: z.object({
|
|
213
241
|
$type: z.number(),
|
|
242
|
+
$key: z.boolean(),
|
|
243
|
+
$action: z.string(),
|
|
214
244
|
children: z.number(),
|
|
215
245
|
label: z.string(),
|
|
216
246
|
}),
|
|
@@ -219,13 +249,50 @@ describe("buildPresentParameters", () => {
|
|
|
219
249
|
}) as any;
|
|
220
250
|
|
|
221
251
|
// The discriminator is the framework enum, not the author's `$type`; the
|
|
222
|
-
// author's `children`
|
|
252
|
+
// author's `$`-prefixed props and `children` are dropped in favor of the
|
|
253
|
+
// framework fields.
|
|
223
254
|
expect(schema.properties.$type.enum).toEqual(["Reserved"]);
|
|
255
|
+
expect(schema.properties.$key).toEqual({
|
|
256
|
+
description:
|
|
257
|
+
"Stable identity for this UI node. Use it for list items that may reorder.",
|
|
258
|
+
anyOf: [{ type: "string" }, { type: "number" }],
|
|
259
|
+
});
|
|
260
|
+
expect(schema.properties.$action).toBeUndefined();
|
|
224
261
|
expect(schema.properties.children.$ref).toBe("#/$defs/children");
|
|
225
262
|
expect(schema.properties.label).toBeDefined();
|
|
226
263
|
expect(schema.required).toEqual(["$type"]);
|
|
227
264
|
});
|
|
228
265
|
|
|
266
|
+
it("names every component that declares the same prop in the dev warning", () => {
|
|
267
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
268
|
+
try {
|
|
269
|
+
buildPresentParameters({
|
|
270
|
+
Select: {
|
|
271
|
+
description: "Selects an option.",
|
|
272
|
+
properties: z.object({ value: z.string() }),
|
|
273
|
+
render: () => null,
|
|
274
|
+
},
|
|
275
|
+
DatePicker: {
|
|
276
|
+
description: "Picks a date.",
|
|
277
|
+
properties: z.object({ value: z.string() }),
|
|
278
|
+
render: () => null,
|
|
279
|
+
},
|
|
280
|
+
Combobox: {
|
|
281
|
+
description: "Chooses from filtered options.",
|
|
282
|
+
properties: z.object({ value: z.string() }),
|
|
283
|
+
render: () => null,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
288
|
+
expect(warn).toHaveBeenCalledWith(
|
|
289
|
+
'[@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.',
|
|
290
|
+
);
|
|
291
|
+
} finally {
|
|
292
|
+
warn.mockRestore();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
229
296
|
it("throws when a component's properties is not an object schema", () => {
|
|
230
297
|
expect(() =>
|
|
231
298
|
buildPresentParameters({
|
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import { getPartialJsonObjectMeta } from "assistant-stream/utils";
|
|
2
2
|
import { Fragment, type ReactNode } from "react";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from "./types";
|
|
3
|
+
import {
|
|
4
|
+
normalizeUINode,
|
|
5
|
+
type NormalizedUIElement,
|
|
6
|
+
type NormalizedUINode,
|
|
7
|
+
} from "./ir";
|
|
8
|
+
import type { GenerativeUILibrary, GenerativeUIRenderContext } from "./types";
|
|
10
9
|
|
|
11
10
|
const DEFAULT_CONTEXT: GenerativeUIRenderContext = { status: "done" };
|
|
12
11
|
|
|
12
|
+
const isElement = (node: NormalizedUINode): node is NormalizedUIElement =>
|
|
13
|
+
typeof node === "object" && node !== null && !Array.isArray(node);
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
16
|
* Renders a generative-ui tree against a {@link GenerativeUILibrary}.
|
|
15
17
|
*
|
|
16
18
|
* The model emits each node as a flat object `{ $type, ...props }`. We first
|
|
17
|
-
* normalize that wire form into
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* recursively so components
|
|
19
|
+
* normalize that wire form into the canonical {@link NormalizedUINode} (with
|
|
20
|
+
* `children` lifted to a reserved top-level key), then render: each `type` is
|
|
21
|
+
* looked up in the library and its `props` are passed to the component's
|
|
22
|
+
* `render(props, context)`, with `children` rendered recursively so components
|
|
23
|
+
* can nest.
|
|
21
24
|
*/
|
|
22
25
|
export function renderGenerativeUI(
|
|
23
26
|
node: unknown,
|
|
@@ -29,87 +32,32 @@ export function renderGenerativeUI(
|
|
|
29
32
|
// string has not finished streaming.
|
|
30
33
|
const meta = getPartialJsonObjectMeta(node as Record<symbol, unknown>);
|
|
31
34
|
const partialPath = meta?.state === "partial" ? meta.partialPath : undefined;
|
|
32
|
-
return renderNode(
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The deepest tree we normalize. The input comes from the model, so a runaway
|
|
37
|
-
* or adversarial response could nest arbitrarily deep and overflow the stack;
|
|
38
|
-
* past this depth we stop (far beyond any real UI). Bounding normalization
|
|
39
|
-
* bounds rendering too, since it only walks the normalized tree.
|
|
40
|
-
*/
|
|
41
|
-
const MAX_DEPTH = 64;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Converts the flat wire form into a normalized {@link GenerativeUINode}.
|
|
45
|
-
*
|
|
46
|
-
* `partialPath` is the remaining segment of the parse meta's partial path
|
|
47
|
-
* relative to `node` (`undefined` once the walk leaves the partial frontier,
|
|
48
|
-
* i.e. everything below is complete).
|
|
49
|
-
*/
|
|
50
|
-
function normalizeNode(
|
|
51
|
-
node: unknown,
|
|
52
|
-
partialPath: readonly string[] | undefined,
|
|
53
|
-
depth = 0,
|
|
54
|
-
): GenerativeUINode {
|
|
55
|
-
if (depth > MAX_DEPTH) return null;
|
|
56
|
-
if (node == null || typeof node === "boolean") return null;
|
|
57
|
-
if (typeof node === "string" || typeof node === "number") return node;
|
|
58
|
-
if (Array.isArray(node))
|
|
59
|
-
return node.map((child, index) =>
|
|
60
|
-
normalizeNode(child, descend(partialPath, String(index)), depth + 1),
|
|
61
|
-
);
|
|
62
|
-
if (typeof node !== "object") return null;
|
|
63
|
-
|
|
64
|
-
const { [TYPE_KEY]: type, ...props } = node as Record<string, unknown>;
|
|
65
|
-
// Args stream in incrementally; a node whose `$type` has not arrived yet
|
|
66
|
-
// (or whose `$type` string is still mid-arrival) is not an error, it just
|
|
67
|
-
// isn't renderable.
|
|
68
|
-
if (typeof type !== "string") return null;
|
|
69
|
-
if (partialPath?.length === 1 && partialPath[0] === TYPE_KEY) return null;
|
|
70
|
-
|
|
71
|
-
if ("children" in props) {
|
|
72
|
-
props["children"] = normalizeNode(
|
|
73
|
-
props["children"],
|
|
74
|
-
descend(partialPath, "children"),
|
|
75
|
-
depth + 1,
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
return { type, props } as GenerativeUIElement;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Steps the partial path down into `key`; siblings of the path are complete. */
|
|
82
|
-
function descend(
|
|
83
|
-
partialPath: readonly string[] | undefined,
|
|
84
|
-
key: string,
|
|
85
|
-
): readonly string[] | undefined {
|
|
86
|
-
return partialPath?.[0] === key ? partialPath.slice(1) : undefined;
|
|
35
|
+
return renderNode(normalizeUINode(node, partialPath), library, context);
|
|
87
36
|
}
|
|
88
37
|
|
|
89
38
|
function renderNode(
|
|
90
|
-
node:
|
|
39
|
+
node: NormalizedUINode,
|
|
91
40
|
library: GenerativeUILibrary,
|
|
92
41
|
context: GenerativeUIRenderContext,
|
|
93
42
|
): ReactNode {
|
|
94
43
|
if (node == null || typeof node === "boolean") return null;
|
|
95
44
|
if (typeof node === "string" || typeof node === "number") return node;
|
|
96
45
|
if (Array.isArray(node)) {
|
|
97
|
-
//
|
|
98
|
-
// index with the node's kind means that
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
// component.
|
|
46
|
+
// Use a model-provided stable key when present. Otherwise, keep the
|
|
47
|
+
// positional fallback: pairing the index with the node's kind means that
|
|
48
|
+
// when the kind at an index changes, React remounts instead of handing a
|
|
49
|
+
// streaming node's hook state to a different component.
|
|
102
50
|
return node.map((child, index) => (
|
|
103
|
-
<Fragment key={
|
|
51
|
+
<Fragment key={nodeKey(child, index)}>
|
|
104
52
|
{renderNode(child, library, context)}
|
|
105
53
|
</Fragment>
|
|
106
54
|
));
|
|
107
55
|
}
|
|
108
|
-
return renderElement(node, library, context);
|
|
56
|
+
return isElement(node) ? renderElement(node, library, context) : null;
|
|
109
57
|
}
|
|
110
58
|
|
|
111
59
|
function renderElement(
|
|
112
|
-
element:
|
|
60
|
+
element: NormalizedUIElement,
|
|
113
61
|
library: GenerativeUILibrary,
|
|
114
62
|
context: GenerativeUIRenderContext,
|
|
115
63
|
): ReactNode {
|
|
@@ -123,11 +71,23 @@ function renderElement(
|
|
|
123
71
|
// complete rather than rendering from a partial parse.
|
|
124
72
|
if (!entry.streamProperties && context.status === "streaming") return null;
|
|
125
73
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
74
|
+
// `children` is a reserved top-level key on the normalized element, not a
|
|
75
|
+
// prop, so it is not in `props`. Inject the framework props last so the model
|
|
76
|
+
// can never override them. `$action` is likewise reserved and stripped from
|
|
77
|
+
// the prop bag during normalization, so it is re-injected here for components
|
|
78
|
+
// that carry behavior (e.g. `Button`).
|
|
79
|
+
const props: Record<string, unknown> = {
|
|
80
|
+
...element.props,
|
|
81
|
+
$status: context.status,
|
|
82
|
+
};
|
|
83
|
+
if (context.dispatch !== undefined) {
|
|
84
|
+
props["$dispatch"] = context.dispatch;
|
|
85
|
+
}
|
|
86
|
+
if (element.action !== undefined) {
|
|
87
|
+
props["$action"] = element.action;
|
|
88
|
+
}
|
|
89
|
+
if (element.children !== undefined) {
|
|
90
|
+
props["children"] = renderNode(element.children, library, context);
|
|
131
91
|
}
|
|
132
92
|
|
|
133
93
|
return <GenerativeUIComponentRenderer render={entry.render} props={props} />;
|
|
@@ -149,11 +109,18 @@ function GenerativeUIComponentRenderer({
|
|
|
149
109
|
|
|
150
110
|
/** A coarse kind tag for a child, used in its list key so a node changing kind
|
|
151
111
|
* at a given index forces a remount rather than a wrong-fiber reuse. */
|
|
152
|
-
function nodeKind(node:
|
|
112
|
+
function nodeKind(node: NormalizedUINode): string {
|
|
153
113
|
if (node == null || typeof node === "boolean") return "";
|
|
154
114
|
if (typeof node === "string" || typeof node === "number") return "#text";
|
|
155
115
|
if (Array.isArray(node)) return "#array";
|
|
156
|
-
return node.type;
|
|
116
|
+
return isElement(node) ? node.type : "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function nodeKey(node: NormalizedUINode, index: number): string {
|
|
120
|
+
return isElement(node) &&
|
|
121
|
+
(typeof node.key === "string" || typeof node.key === "number")
|
|
122
|
+
? `model:${node.key}`
|
|
123
|
+
: `${index}:${nodeKind(node)}`;
|
|
157
124
|
}
|
|
158
125
|
|
|
159
126
|
function reportUnknownComponent(type: string, available: string[]): void {
|