@assistant-ui/react-generative-ui 0.0.2

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/dist/JSONGenerativeUI.client.d.ts +23 -0
  4. package/dist/JSONGenerativeUI.client.d.ts.map +1 -0
  5. package/dist/JSONGenerativeUI.client.js +48 -0
  6. package/dist/JSONGenerativeUI.client.js.map +1 -0
  7. package/dist/JSONGenerativeUI.server.d.ts +25 -0
  8. package/dist/JSONGenerativeUI.server.d.ts.map +1 -0
  9. package/dist/JSONGenerativeUI.server.js +32 -0
  10. package/dist/JSONGenerativeUI.server.js.map +1 -0
  11. package/dist/JSONGenerativeUI.shared.d.ts +53 -0
  12. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -0
  13. package/dist/JSONGenerativeUI.shared.js +31 -0
  14. package/dist/JSONGenerativeUI.shared.js.map +1 -0
  15. package/dist/buildPresentParameters.d.ts +24 -0
  16. package/dist/buildPresentParameters.d.ts.map +1 -0
  17. package/dist/buildPresentParameters.js +68 -0
  18. package/dist/buildPresentParameters.js.map +1 -0
  19. package/dist/constants.d.ts +14 -0
  20. package/dist/constants.d.ts.map +1 -0
  21. package/dist/constants.js +15 -0
  22. package/dist/constants.js.map +1 -0
  23. package/dist/defineGenerativeComponents.d.ts +33 -0
  24. package/dist/defineGenerativeComponents.d.ts.map +1 -0
  25. package/dist/defineGenerativeComponents.js +34 -0
  26. package/dist/defineGenerativeComponents.js.map +1 -0
  27. package/dist/generativeUIToJSX.d.ts +16 -0
  28. package/dist/generativeUIToJSX.d.ts.map +1 -0
  29. package/dist/generativeUIToJSX.js +46 -0
  30. package/dist/generativeUIToJSX.js.map +1 -0
  31. package/dist/index.d.ts +9 -0
  32. package/dist/index.js +7 -0
  33. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +141 -0
  34. package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -0
  35. package/dist/renderGenerativeUI.d.ts +17 -0
  36. package/dist/renderGenerativeUI.d.ts.map +1 -0
  37. package/dist/renderGenerativeUI.js +85 -0
  38. package/dist/renderGenerativeUI.js.map +1 -0
  39. package/dist/types.d.ts +94 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +0 -0
  42. package/package.json +82 -0
  43. package/src/JSONGenerativeUI.client.tsx +66 -0
  44. package/src/JSONGenerativeUI.server.tsx +39 -0
  45. package/src/JSONGenerativeUI.shared.ts +74 -0
  46. package/src/JSONGenerativeUI.test.tsx +89 -0
  47. package/src/buildPresentParameters.ts +90 -0
  48. package/src/constants.ts +10 -0
  49. package/src/defineGenerativeComponents.ts +38 -0
  50. package/src/generativeUIToJSX.test.ts +64 -0
  51. package/src/generativeUIToJSX.ts +63 -0
  52. package/src/index.ts +26 -0
  53. package/src/renderGenerativeUI.test.tsx +204 -0
  54. package/src/renderGenerativeUI.tsx +135 -0
  55. package/src/types.ts +104 -0
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { z } from "zod";
4
+ import { renderGenerativeUI } from "./renderGenerativeUI";
5
+ import { buildPresentParameters } from "./buildPresentParameters";
6
+ import type { GenerativeUILibrary } from "./types";
7
+
8
+ const library: GenerativeUILibrary = {
9
+ Card: {
10
+ description: "A card container.",
11
+ properties: z.object({ title: z.string() }),
12
+ render: ({ title, children }: any) => (
13
+ <section data-title={title}>{children}</section>
14
+ ),
15
+ },
16
+ Text: {
17
+ description: "A run of text.",
18
+ properties: z.object({ tone: z.enum(["muted", "normal"]).optional() }),
19
+ render: ({ tone, children }: any) => <p data-tone={tone}>{children}</p>,
20
+ },
21
+ Button: {
22
+ description: "A button with its own `type` prop.",
23
+ properties: z.object({ type: z.enum(["button", "submit"]) }),
24
+ render: ({ type, children }: any) => (
25
+ <button type={type}>{children}</button>
26
+ ),
27
+ },
28
+ Live: {
29
+ description: "Renders from partial props while streaming.",
30
+ properties: z.object({ label: z.string() }),
31
+ streamProperties: true,
32
+ render: (props) => (
33
+ <span data-status={props.$status}>{props.label ?? "…"}</span>
34
+ ),
35
+ },
36
+ };
37
+
38
+ describe("renderGenerativeUI", () => {
39
+ it("renders a component and passes its props", () => {
40
+ const html = renderToStaticMarkup(
41
+ <>{renderGenerativeUI({ $type: "Text", tone: "muted" }, library)}</>,
42
+ );
43
+ expect(html).toBe('<p data-tone="muted"></p>');
44
+ });
45
+
46
+ it("renders children recursively", () => {
47
+ const html = renderToStaticMarkup(
48
+ <>
49
+ {renderGenerativeUI(
50
+ {
51
+ $type: "Card",
52
+ title: "Hello",
53
+ children: [
54
+ { $type: "Text", children: "first" },
55
+ { $type: "Text", tone: "muted", children: "second" },
56
+ ],
57
+ },
58
+ library,
59
+ )}
60
+ </>,
61
+ );
62
+ expect(html).toBe(
63
+ '<section data-title="Hello"><p>first</p><p data-tone="muted">second</p></section>',
64
+ );
65
+ });
66
+
67
+ it("passes a component's own `type` prop through without collision", () => {
68
+ const html = renderToStaticMarkup(
69
+ <>
70
+ {renderGenerativeUI(
71
+ { $type: "Button", type: "submit", children: "Go" },
72
+ library,
73
+ )}
74
+ </>,
75
+ );
76
+ expect(html).toBe('<button type="submit">Go</button>');
77
+ });
78
+
79
+ it("renders a string child directly", () => {
80
+ const html = renderToStaticMarkup(
81
+ <>{renderGenerativeUI({ $type: "Card", children: "plain" }, library)}</>,
82
+ );
83
+ expect(html).toBe("<section><p></p></section>".replace("<p></p>", "plain"));
84
+ });
85
+
86
+ it("renders nothing for an unknown component", () => {
87
+ const html = renderToStaticMarkup(
88
+ <>{renderGenerativeUI({ $type: "Missing" }, library)}</>,
89
+ );
90
+ expect(html).toBe("");
91
+ });
92
+
93
+ it("renders nothing for a node without a resolved type", () => {
94
+ const html = renderToStaticMarkup(<>{renderGenerativeUI({}, library)}</>);
95
+ expect(html).toBe("");
96
+ });
97
+
98
+ it("bounds deeply nested trees instead of overflowing the stack", () => {
99
+ let node: any = { $type: "Text", children: "deep" };
100
+ for (let i = 0; i < 5000; i++) node = { $type: "Card", children: node };
101
+ expect(() =>
102
+ renderToStaticMarkup(<>{renderGenerativeUI(node, library)}</>),
103
+ ).not.toThrow();
104
+ });
105
+
106
+ it("passes the status to render and tolerates partial props while streaming", () => {
107
+ const html = renderToStaticMarkup(
108
+ <>
109
+ {renderGenerativeUI({ $type: "Live" }, library, {
110
+ status: "streaming",
111
+ })}
112
+ </>,
113
+ );
114
+ expect(html).toBe('<span data-status="streaming">…</span>');
115
+ });
116
+
117
+ it("gates opt-out components until their props are complete", () => {
118
+ const streaming = renderToStaticMarkup(
119
+ <>
120
+ {renderGenerativeUI({ $type: "Card", title: "x" }, library, {
121
+ status: "streaming",
122
+ })}
123
+ </>,
124
+ );
125
+ expect(streaming).toBe("");
126
+
127
+ const done = renderToStaticMarkup(
128
+ <>
129
+ {renderGenerativeUI({ $type: "Card", title: "x" }, library, {
130
+ status: "done",
131
+ })}
132
+ </>,
133
+ );
134
+ expect(done).toBe('<section data-title="x"></section>');
135
+ });
136
+ });
137
+
138
+ describe("buildPresentParameters", () => {
139
+ it("produces a flat object schema with no top-level oneOf/anyOf", () => {
140
+ const schema = buildPresentParameters(library) as any;
141
+
142
+ expect(schema.type).toBe("object");
143
+ expect(schema.required).toEqual(["$type"]);
144
+ // Tool/function-call schemas reject these at the top level.
145
+ expect(schema.oneOf).toBeUndefined();
146
+ expect(schema.anyOf).toBeUndefined();
147
+
148
+ expect(schema.properties.$type.enum).toEqual([
149
+ "Card",
150
+ "Text",
151
+ "Button",
152
+ "Live",
153
+ ]);
154
+ // each component's description rides along on the $type enum.
155
+ expect(schema.properties.$type.description).toContain("Card");
156
+ expect(schema.properties.children.$ref).toBe("#/$defs/children");
157
+
158
+ // every component's props are merged into the one flat property bag.
159
+ expect(schema.properties.title).toBeDefined(); // Card
160
+ expect(schema.properties.tone).toBeDefined(); // Text
161
+ expect(schema.properties.type).toBeDefined(); // Button's own `type` prop
162
+ expect(schema.properties.label).toBeDefined(); // Live
163
+
164
+ // children recurses back into a node.
165
+ expect(schema.$defs.children.anyOf).toContainEqual({
166
+ $ref: "#/$defs/node",
167
+ });
168
+ expect(schema.$defs.node.type).toBe("object");
169
+ expect(schema.$defs.node.oneOf).toBeUndefined();
170
+ });
171
+
172
+ it("drops author-declared `$type`/`children` and keeps the discriminator", () => {
173
+ const schema = buildPresentParameters({
174
+ Reserved: {
175
+ description: "Declares reserved keys that must not leak through.",
176
+ properties: z.object({
177
+ $type: z.number(),
178
+ children: z.number(),
179
+ label: z.string(),
180
+ }),
181
+ render: () => null,
182
+ },
183
+ }) as any;
184
+
185
+ // The discriminator is the framework enum, not the author's `$type`; the
186
+ // author's `children` is dropped (the root `children` $ref owns that slot).
187
+ expect(schema.properties.$type.enum).toEqual(["Reserved"]);
188
+ expect(schema.properties.children.$ref).toBe("#/$defs/children");
189
+ expect(schema.properties.label).toBeDefined();
190
+ expect(schema.required).toEqual(["$type"]);
191
+ });
192
+
193
+ it("throws when a component's properties is not an object schema", () => {
194
+ expect(() =>
195
+ buildPresentParameters({
196
+ Bad: {
197
+ description: "Non-object props.",
198
+ properties: z.string() as never,
199
+ render: () => null,
200
+ },
201
+ }),
202
+ ).toThrow(/must be an object schema/);
203
+ });
204
+ });
@@ -0,0 +1,135 @@
1
+ import { Fragment, type ReactNode } from "react";
2
+ import { TYPE_KEY } from "./constants";
3
+ import type {
4
+ GenerativeUIElement,
5
+ GenerativeUILibrary,
6
+ GenerativeUINode,
7
+ GenerativeUIRenderContext,
8
+ } from "./types";
9
+
10
+ const DEFAULT_CONTEXT: GenerativeUIRenderContext = { status: "done" };
11
+
12
+ /**
13
+ * Renders a generative-ui tree against a {@link GenerativeUILibrary}.
14
+ *
15
+ * The model emits each node as a flat object `{ $type, ...props }`. We first
16
+ * normalize that wire form into React-shaped elements (`{ type, props }`), then
17
+ * render: each `type` is looked up in the library and its `props` are passed
18
+ * to the component's `render(props, context)`, with `children` rendered
19
+ * recursively so components can nest.
20
+ */
21
+ export function renderGenerativeUI(
22
+ node: unknown,
23
+ library: GenerativeUILibrary,
24
+ context: GenerativeUIRenderContext = DEFAULT_CONTEXT,
25
+ ): ReactNode {
26
+ return renderNode(normalizeNode(node), library, context);
27
+ }
28
+
29
+ /**
30
+ * The deepest tree we normalize. The input comes from the model, so a runaway
31
+ * or adversarial response could nest arbitrarily deep and overflow the stack;
32
+ * past this depth we stop (far beyond any real UI). Bounding normalization
33
+ * bounds rendering too, since it only walks the normalized tree.
34
+ */
35
+ const MAX_DEPTH = 64;
36
+
37
+ /** Converts the flat wire form into a normalized {@link GenerativeUINode}. */
38
+ function normalizeNode(node: unknown, depth = 0): GenerativeUINode {
39
+ if (depth > MAX_DEPTH) return null;
40
+ if (node == null || typeof node === "boolean") return null;
41
+ if (typeof node === "string" || typeof node === "number") return node;
42
+ if (Array.isArray(node))
43
+ return node.map((child) => normalizeNode(child, depth + 1));
44
+ if (typeof node !== "object") return null;
45
+
46
+ const { [TYPE_KEY]: type, ...props } = node as Record<string, unknown>;
47
+ // Args stream in incrementally; a node whose `$type` has not arrived yet is
48
+ // not an error, it just isn't renderable.
49
+ if (typeof type !== "string") return null;
50
+
51
+ if ("children" in props) {
52
+ props["children"] = normalizeNode(props["children"], depth + 1);
53
+ }
54
+ return { type, props } as GenerativeUIElement;
55
+ }
56
+
57
+ function renderNode(
58
+ node: GenerativeUINode,
59
+ library: GenerativeUILibrary,
60
+ context: GenerativeUIRenderContext,
61
+ ): ReactNode {
62
+ if (node == null || typeof node === "boolean") return null;
63
+ if (typeof node === "string" || typeof node === "number") return node;
64
+ if (Array.isArray(node)) {
65
+ // The wire format has no per-node id, so the key is positional. Pairing the
66
+ // index with the node's kind means that when the model splices or reorders
67
+ // `children` and the kind at an index changes, the key changes and React
68
+ // remounts instead of handing a streaming node's hook state to a different
69
+ // component.
70
+ return node.map((child, index) => (
71
+ <Fragment key={`${index}:${nodeKind(child)}`}>
72
+ {renderNode(child, library, context)}
73
+ </Fragment>
74
+ ));
75
+ }
76
+ return renderElement(node, library, context);
77
+ }
78
+
79
+ function renderElement(
80
+ element: GenerativeUIElement,
81
+ library: GenerativeUILibrary,
82
+ context: GenerativeUIRenderContext,
83
+ ): ReactNode {
84
+ const entry = library[element.type];
85
+ if (!entry) {
86
+ reportUnknownComponent(element.type, Object.keys(library));
87
+ return null;
88
+ }
89
+
90
+ // Components that opt out of prop streaming wait until their props are
91
+ // complete rather than rendering from a partial parse.
92
+ if (!entry.streamProperties && context.status === "streaming") return null;
93
+
94
+ // Inject the framework props last so the model can never override them.
95
+ const { children, ...rest } = element.props;
96
+ const props: Record<string, unknown> = { ...rest, $status: context.status };
97
+ if (children !== undefined) {
98
+ props["children"] = renderNode(children, library, context);
99
+ }
100
+
101
+ return <GenerativeUIComponentRenderer render={entry.render} props={props} />;
102
+ }
103
+
104
+ /**
105
+ * Mounts a single node's `render` on its own fiber so the function may use
106
+ * hooks and hold state independently of its siblings and parent.
107
+ */
108
+ function GenerativeUIComponentRenderer({
109
+ render,
110
+ props,
111
+ }: {
112
+ render: (props: any) => ReactNode;
113
+ props: Record<string, unknown>;
114
+ }): ReactNode {
115
+ return render(props);
116
+ }
117
+
118
+ /** A coarse kind tag for a child, used in its list key so a node changing kind
119
+ * at a given index forces a remount rather than a wrong-fiber reuse. */
120
+ function nodeKind(node: GenerativeUINode): string {
121
+ if (node == null || typeof node === "boolean") return "";
122
+ if (typeof node === "string" || typeof node === "number") return "#text";
123
+ if (Array.isArray(node)) return "#array";
124
+ return node.type;
125
+ }
126
+
127
+ function reportUnknownComponent(type: string, available: string[]): void {
128
+ if (process.env["NODE_ENV"] !== "production") {
129
+ // eslint-disable-next-line no-console
130
+ console.error(
131
+ `[@assistant-ui/react-generative-ui] Unknown component "${type}". ` +
132
+ `Available components: ${available.join(", ") || "(none)"}.`,
133
+ );
134
+ }
135
+ }
package/src/types.ts ADDED
@@ -0,0 +1,104 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ZodType } from "zod";
3
+
4
+ /** Whether a node's props are still streaming in or have fully arrived. */
5
+ export type GenerativeUIStatus = "streaming" | "done";
6
+
7
+ /** The render context threaded through {@link renderGenerativeUI}. */
8
+ export type GenerativeUIRenderContext = {
9
+ /** Whether the tool call's arguments are still streaming or are complete. */
10
+ status: GenerativeUIStatus;
11
+ };
12
+
13
+ /**
14
+ * Props a component's `render` receives: its model props, rendered `children`,
15
+ * and the injected `$status`. `$status` is the discriminant — when it is
16
+ * `"done"`, `P` is complete; while `"streaming"`, `P` is partial. It is named
17
+ * `$status` (not `status`) so it never collides with a real `status` prop, the
18
+ * same reservation as `$type`.
19
+ */
20
+ type StreamingRenderProps<P> =
21
+ | (Partial<P> & { children?: ReactNode; $status: "streaming" })
22
+ | (P & { children?: ReactNode; $status: "done" });
23
+
24
+ /** Props for a component that only renders once complete — `$status` is always `"done"`. */
25
+ type StaticRenderProps<P> = P & { children?: ReactNode; $status: "done" };
26
+
27
+ /**
28
+ * A component the model is allowed to render, with the schema for its props.
29
+ *
30
+ * Components opt into prop streaming with `streamProperties`: they render as
31
+ * props arrive, so `render` sees `Partial<P>` while `$status` is `"streaming"`
32
+ * and the full `P` once it is `"done"`. By default a component opts out and is
33
+ * only rendered once its props are complete.
34
+ *
35
+ * `render` is a function of `props`, not a React component. Direct hook use is
36
+ * fine (each node is mounted on its own fiber), but a given node must keep its
37
+ * `type` across renders for hook identity to be stable.
38
+ */
39
+ export type GenerativeUIComponent<P = any> =
40
+ | {
41
+ /** Natural-language description shown to the model when selecting the component. */
42
+ description: string;
43
+ /** Schema for the props the model must provide. Drives the tool parameters. */
44
+ properties: ZodType<P>;
45
+ /**
46
+ * Render from partially-streamed props. Widened to `boolean | undefined`
47
+ * so a non-literal value (e.g. a variable) still resolves to this branch
48
+ * cleanly rather than matching neither; the strict `false | undefined`
49
+ * branch below is the only one that promises complete props.
50
+ */
51
+ streamProperties: boolean | undefined;
52
+ render: (props: StreamingRenderProps<P>) => ReactNode;
53
+ }
54
+ | {
55
+ description: string;
56
+ properties: ZodType<P>;
57
+ /** Render only once props are complete (the default). */
58
+ streamProperties?: false | undefined;
59
+ render: (props: StaticRenderProps<P>) => ReactNode;
60
+ };
61
+
62
+ /**
63
+ * The consumer-provided allowlist of components the model is permitted to
64
+ * render. Keys are the `type` values referenced in the generative-ui tree
65
+ * (e.g. `"Card"`, `"Button"`); values describe each component.
66
+ *
67
+ * This registry is the security boundary — any `type` not present is rejected.
68
+ */
69
+ export type GenerativeUILibrary = Record<string, GenerativeUIComponent>;
70
+
71
+ /**
72
+ * A component invocation — mirrors React's `ReactElement`.
73
+ *
74
+ * `type` selects a component from the {@link GenerativeUILibrary}; `props` are
75
+ * passed to it. The `children` prop is special: it is itself a renderable
76
+ * {@link GenerativeUINode}, drawn as generative UI rather than passed as data.
77
+ *
78
+ * On the wire the model emits the flattened form `{ $type, ...props }`, where
79
+ * `$type` names the component so a real `type` prop never collides. This is the
80
+ * normalized shape the renderer works with, the same way React normalizes
81
+ * `createElement` arguments into an element.
82
+ */
83
+ export type GenerativeUIElement = {
84
+ type: string;
85
+ props: GenerativeUIProps;
86
+ };
87
+
88
+ /** Props passed to a component — mirrors a React component's props. */
89
+ export type GenerativeUIProps = {
90
+ children?: GenerativeUINode;
91
+ } & Record<string, unknown>;
92
+
93
+ /**
94
+ * Anything renderable as generative UI — mirrors React's `ReactNode`: an
95
+ * element, primitive text, or a list of nodes.
96
+ */
97
+ export type GenerativeUINode =
98
+ | GenerativeUIElement
99
+ | string
100
+ | number
101
+ | boolean
102
+ | null
103
+ | undefined
104
+ | GenerativeUINode[];