@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.
Files changed (92) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +4 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +9 -2
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/JSONGenerativeUI.shared.d.ts +8 -0
  6. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  7. package/dist/JSONGenerativeUI.shared.js.map +1 -1
  8. package/dist/actionRegistry.d.ts +41 -0
  9. package/dist/actionRegistry.d.ts.map +1 -0
  10. package/dist/actionRegistry.js +41 -0
  11. package/dist/actionRegistry.js.map +1 -0
  12. package/dist/buildPresentParameters.js +10 -1
  13. package/dist/buildPresentParameters.js.map +1 -1
  14. package/dist/index.d.ts +6 -3
  15. package/dist/index.js +5 -2
  16. package/dist/index.server.d.ts +12 -0
  17. package/dist/index.server.js +10 -0
  18. package/dist/index.shared.d.ts +11 -0
  19. package/dist/index.shared.js +9 -0
  20. package/dist/ir.d.ts +115 -0
  21. package/dist/ir.d.ts.map +1 -0
  22. package/dist/ir.js +149 -0
  23. package/dist/ir.js.map +1 -0
  24. package/dist/renderGenerativeUI.d.ts +5 -4
  25. package/dist/renderGenerativeUI.d.ts.map +1 -1
  26. package/dist/renderGenerativeUI.js +18 -45
  27. package/dist/renderGenerativeUI.js.map +1 -1
  28. package/dist/types.d.ts +34 -30
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/vocabulary/alert.d.ts +34 -0
  31. package/dist/vocabulary/alert.d.ts.map +1 -0
  32. package/dist/vocabulary/alert.js +49 -0
  33. package/dist/vocabulary/alert.js.map +1 -0
  34. package/dist/vocabulary/data.d.ts +53 -0
  35. package/dist/vocabulary/data.d.ts.map +1 -0
  36. package/dist/vocabulary/data.js +63 -0
  37. package/dist/vocabulary/data.js.map +1 -0
  38. package/dist/vocabulary/fact.d.ts +20 -0
  39. package/dist/vocabulary/fact.d.ts.map +1 -0
  40. package/dist/vocabulary/fact.js +24 -0
  41. package/dist/vocabulary/fact.js.map +1 -0
  42. package/dist/vocabulary/index.d.ts +16 -0
  43. package/dist/vocabulary/index.d.ts.map +1 -0
  44. package/dist/vocabulary/index.js +30 -0
  45. package/dist/vocabulary/index.js.map +1 -0
  46. package/dist/vocabulary/interactive.d.ts +81 -0
  47. package/dist/vocabulary/interactive.d.ts.map +1 -0
  48. package/dist/vocabulary/interactive.js +123 -0
  49. package/dist/vocabulary/interactive.js.map +1 -0
  50. package/dist/vocabulary/layout.d.ts +78 -0
  51. package/dist/vocabulary/layout.d.ts.map +1 -0
  52. package/dist/vocabulary/layout.js +72 -0
  53. package/dist/vocabulary/layout.js.map +1 -0
  54. package/dist/vocabulary/media.d.ts +34 -0
  55. package/dist/vocabulary/media.d.ts.map +1 -0
  56. package/dist/vocabulary/media.js +32 -0
  57. package/dist/vocabulary/media.js.map +1 -0
  58. package/dist/vocabulary/text.d.ts +74 -0
  59. package/dist/vocabulary/text.d.ts.map +1 -0
  60. package/dist/vocabulary/text.js +48 -0
  61. package/dist/vocabulary/text.js.map +1 -0
  62. package/package.json +13 -13
  63. package/src/JSONGenerativeUI.client.tsx +10 -2
  64. package/src/JSONGenerativeUI.shared.ts +8 -0
  65. package/src/actionRegistry.test.ts +90 -0
  66. package/src/actionRegistry.ts +96 -0
  67. package/src/buildPresentParameters.ts +23 -7
  68. package/src/index.server.ts +2 -0
  69. package/src/index.shared.ts +58 -0
  70. package/src/index.ts +2 -26
  71. package/src/ir.test.ts +203 -0
  72. package/src/ir.ts +253 -0
  73. package/src/renderGenerativeUI.test.tsx +53 -0
  74. package/src/renderGenerativeUI.tsx +49 -82
  75. package/src/types.ts +57 -42
  76. package/src/vocabulary/alert.test.tsx +66 -0
  77. package/src/vocabulary/alert.tsx +47 -0
  78. package/src/vocabulary/data.test.tsx +67 -0
  79. package/src/vocabulary/data.tsx +86 -0
  80. package/src/vocabulary/fact.test.tsx +15 -0
  81. package/src/vocabulary/fact.tsx +22 -0
  82. package/src/vocabulary/index.test.ts +56 -0
  83. package/src/vocabulary/index.ts +27 -0
  84. package/src/vocabulary/interactive.dispatch.test.tsx +110 -0
  85. package/src/vocabulary/interactive.test.tsx +108 -0
  86. package/src/vocabulary/interactive.tsx +165 -0
  87. package/src/vocabulary/layout.test.tsx +61 -0
  88. package/src/vocabulary/layout.tsx +76 -0
  89. package/src/vocabulary/media.test.tsx +53 -0
  90. package/src/vocabulary/media.tsx +37 -0
  91. package/src/vocabulary/text.test.tsx +73 -0
  92. package/src/vocabulary/text.tsx +59 -0
@@ -1,23 +1,26 @@
1
1
  import { getPartialJsonObjectMeta } from "assistant-stream/utils";
2
2
  import { Fragment, type ReactNode } from "react";
3
- import { TYPE_KEY } from "./constants";
4
- import type {
5
- GenerativeUIElement,
6
- GenerativeUILibrary,
7
- GenerativeUINode,
8
- GenerativeUIRenderContext,
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 React-shaped elements (`{ type, props }`), then
18
- * render: each `type` is looked up in the library and its `props` are passed
19
- * to the component's `render(props, context)`, with `children` rendered
20
- * recursively so components can nest.
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(normalizeNode(node, partialPath), library, context);
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: GenerativeUINode,
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
- // The wire format has no per-node id, so the key is positional. Pairing the
98
- // index with the node's kind means that when the model splices or reorders
99
- // `children` and the kind at an index changes, the key changes and React
100
- // remounts instead of handing a streaming node's hook state to a different
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={`${index}:${nodeKind(child)}`}>
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: GenerativeUIElement,
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
- // Inject the framework props last so the model can never override them.
127
- const { children, ...rest } = element.props;
128
- const props: Record<string, unknown> = { ...rest, $status: context.status };
129
- if (children !== undefined) {
130
- props["children"] = renderNode(children, library, context);
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: GenerativeUINode): string {
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 {
package/src/types.ts CHANGED
@@ -1,28 +1,78 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { ZodType } from "zod";
3
+ import type { Action, NormalizedUIElement } from "./ir";
4
+
5
+ /**
6
+ * The canonical generative-ui element, normalized from the flat `$type` shape
7
+ * or the legacy `component` shape (see {@link normalizeUINode}). `children` is
8
+ * a reserved top-level key, not a prop, so it lives here rather than inside
9
+ * `props`. Alias of {@link NormalizedUIElement}; kept under its old name so the
10
+ * package's public surface stays append-only.
11
+ */
12
+ export type GenerativeUIElement = NormalizedUIElement;
13
+
14
+ export type GenerativeUIProps = NormalizedUIElement["props"];
15
+
16
+ /**
17
+ * Anything renderable as generative UI — mirrors React's `ReactNode`: an
18
+ * element, primitive text, a list of nodes, or nothing. Widened beyond the
19
+ * model payload (a {@link GenerativeUIElement}) with the renderer's
20
+ * null/boolean/undefined inputs.
21
+ */
22
+ export type GenerativeUINode =
23
+ | GenerativeUIElement
24
+ | string
25
+ | number
26
+ | boolean
27
+ | null
28
+ | undefined
29
+ | GenerativeUINode[];
30
+
31
+ export type GenerativeUIAction = Action;
3
32
 
4
33
  /** Whether a node's props are still streaming in or have fully arrived. */
5
34
  export type GenerativeUIStatus = "streaming" | "done";
6
35
 
36
+ /** The dispatcher an interactive component calls to fire its `$action`. */
37
+ export type GenerativeUIDispatch = (action: Action) => unknown;
38
+
7
39
  /** The render context threaded through {@link renderGenerativeUI}. */
8
40
  export type GenerativeUIRenderContext = {
9
41
  /** Whether the tool call's arguments are still streaming or are complete. */
10
42
  status: GenerativeUIStatus;
43
+ dispatch?: GenerativeUIDispatch;
11
44
  };
12
45
 
13
46
  /**
14
47
  * 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`.
48
+ * and the injected framework keys. `$status` is the discriminant — when it is
49
+ * `"done"`, `P` is complete; while `"streaming"`, `P` is partial. `$action` is
50
+ * the node's behavior payload, present only on interactive nodes. `$dispatch`
51
+ * fires that payload through the host's {@link ActionRegistry}; it is present
52
+ * only when a registry was wired. All three are `$`-prefixed so they never
53
+ * collide with a real `status`/`action`/`dispatch` prop.
19
54
  */
20
55
  type StreamingRenderProps<P> =
21
- | (Partial<P> & { children?: ReactNode; $status: "streaming" })
22
- | (P & { children?: ReactNode; $status: "done" });
56
+ | (Partial<P> & {
57
+ children?: ReactNode;
58
+ $status: "streaming";
59
+ $action?: Action;
60
+ $dispatch?: GenerativeUIDispatch;
61
+ })
62
+ | (P & {
63
+ children?: ReactNode;
64
+ $status: "done";
65
+ $action?: Action;
66
+ $dispatch?: GenerativeUIDispatch;
67
+ });
23
68
 
24
69
  /** Props for a component that only renders once complete — `$status` is always `"done"`. */
25
- type StaticRenderProps<P> = P & { children?: ReactNode; $status: "done" };
70
+ type StaticRenderProps<P> = P & {
71
+ children?: ReactNode;
72
+ $status: "done";
73
+ $action?: Action;
74
+ $dispatch?: GenerativeUIDispatch;
75
+ };
26
76
 
27
77
  /**
28
78
  * A component the model is allowed to render, with the schema for its props.
@@ -67,38 +117,3 @@ export type GenerativeUIComponent<P = any> =
67
117
  * This registry is the security boundary — any `type` not present is rejected.
68
118
  */
69
119
  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[];
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { renderGenerativeUI } from "../renderGenerativeUI";
4
+ import { alertVocabulary } from "./alert";
5
+ import { defaultGenerativeUILibrary } from "./index";
6
+
7
+ const render = (node: unknown) =>
8
+ renderToStaticMarkup(<>{renderGenerativeUI(node, alertVocabulary)}</>);
9
+
10
+ describe("alertVocabulary", () => {
11
+ it("Alert renders with title/description/tone, defaulting tone to info", () => {
12
+ expect(
13
+ render({
14
+ $type: "Alert",
15
+ title: "Heads up",
16
+ description: "Something happened",
17
+ }),
18
+ ).toBe(
19
+ '<div data-aui="alert" data-aui-tone="info" role="alert"><header data-aui="alert-title">Heads up</header><p data-aui="alert-desc">Something happened</p></div>',
20
+ );
21
+ });
22
+
23
+ it("Alert renders an explicit tone", () => {
24
+ expect(
25
+ render({ $type: "Alert", tone: "danger", title: "Error" }),
26
+ ).toContain('data-aui-tone="danger"');
27
+ });
28
+
29
+ it("Carousel wraps each child Card in a slide, capped at 10", () => {
30
+ const children = Array.from({ length: 12 }, (_, i) => ({
31
+ $type: "Card",
32
+ title: `c${i}`,
33
+ }));
34
+ const html = renderToStaticMarkup(
35
+ <>
36
+ {renderGenerativeUI(
37
+ { $type: "Carousel", children },
38
+ defaultGenerativeUILibrary,
39
+ )}
40
+ </>,
41
+ );
42
+ const slideCount = (html.match(/data-aui="carousel-slide"/g) ?? []).length;
43
+ expect(slideCount).toBe(10);
44
+ });
45
+
46
+ it("Carousel renders a single non-array child Card in one slide", () => {
47
+ const html = renderToStaticMarkup(
48
+ <>
49
+ {renderGenerativeUI(
50
+ {
51
+ $type: "Carousel",
52
+ children: { $type: "Card", title: "only" } as never,
53
+ },
54
+ defaultGenerativeUILibrary,
55
+ )}
56
+ </>,
57
+ );
58
+ expect((html.match(/data-aui="carousel-slide"/g) ?? []).length).toBe(1);
59
+ });
60
+
61
+ it("Carousel with no children renders an empty container", () => {
62
+ expect(render({ $type: "Carousel" })).toBe(
63
+ '<div data-aui="carousel"></div>',
64
+ );
65
+ });
66
+ });
@@ -0,0 +1,47 @@
1
+ import { Children } from "react";
2
+ import { z } from "zod";
3
+ import type { GenerativeUILibrary } from "../types";
4
+ import { ALERT_TONES } from "../ir";
5
+
6
+ const MAX_CARDS = 10;
7
+
8
+ export const alertVocabulary = {
9
+ Alert: {
10
+ description:
11
+ "A highlighted message conveying urgency. `tone` drives the severity.",
12
+ properties: z.object({
13
+ title: z.string().optional().describe("Alert title."),
14
+ description: z
15
+ .string()
16
+ .optional()
17
+ .describe("Supporting description text."),
18
+ tone: z
19
+ .enum(ALERT_TONES)
20
+ .optional()
21
+ .describe("Severity tone; defaults to `info`."),
22
+ }),
23
+ render: ({ title, description, tone, children }) => (
24
+ <div data-aui="alert" data-aui-tone={tone ?? "info"} role="alert">
25
+ {title ? <header data-aui="alert-title">{title}</header> : null}
26
+ {description ? <p data-aui="alert-desc">{description}</p> : null}
27
+ {children}
28
+ </div>
29
+ ),
30
+ },
31
+ Carousel: {
32
+ description: "A horizontally scrollable group of `Card` children (max 10).",
33
+ properties: z.object({}),
34
+ render: ({ children }) => {
35
+ const cards = Children.toArray(children).slice(0, MAX_CARDS);
36
+ return (
37
+ <div data-aui="carousel">
38
+ {cards.map((card, i) => (
39
+ <div key={i} data-aui="carousel-slide">
40
+ {card}
41
+ </div>
42
+ ))}
43
+ </div>
44
+ );
45
+ },
46
+ },
47
+ } satisfies GenerativeUILibrary;
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { renderGenerativeUI } from "../renderGenerativeUI";
4
+ import { dataVocabulary } from "./data";
5
+
6
+ const render = (node: unknown) =>
7
+ renderToStaticMarkup(<>{renderGenerativeUI(node, dataVocabulary)}</>);
8
+
9
+ describe("dataVocabulary", () => {
10
+ it("Table renders columns and rows", () => {
11
+ const html = render({
12
+ $type: "Table",
13
+ columns: [{ label: "Name" }, { label: "Age" }],
14
+ rows: [
15
+ ["Ada", 36],
16
+ ["Bob", 24],
17
+ ],
18
+ });
19
+ expect(html).toContain('data-aui="table"');
20
+ expect(html).toContain('<th data-aui="table-col">Name</th>');
21
+ expect(html).toContain('<th data-aui="table-col">Age</th>');
22
+ expect(html).toContain("<td>Ada</td>");
23
+ expect(html).toContain("<td>36</td>");
24
+ expect(html).toContain("<td>Bob</td>");
25
+ expect(html).toContain("<td>24</td>");
26
+ });
27
+
28
+ it("Table renders with only rows (no header)", () => {
29
+ const html = render({ $type: "Table", rows: [["only"]] });
30
+ expect(html).toContain('data-aui="table"');
31
+ expect(html).not.toContain("<thead>");
32
+ expect(html).toContain("<td>only</td>");
33
+ });
34
+
35
+ it("Markdown renders the value in a div", () => {
36
+ expect(render({ $type: "Markdown", value: "# hi" })).toBe(
37
+ '<div data-aui="markdown"># hi</div>',
38
+ );
39
+ });
40
+
41
+ it("Markdown renders partial value while streaming", () => {
42
+ expect(
43
+ renderToStaticMarkup(
44
+ <>
45
+ {renderGenerativeUI(
46
+ { $type: "Markdown", value: "partial" },
47
+ dataVocabulary,
48
+ { status: "streaming" },
49
+ )}
50
+ </>,
51
+ ),
52
+ ).toBe('<div data-aui="markdown">partial</div>');
53
+ });
54
+
55
+ it("Chart renders a placeholder div with variant/color/data hooks", () => {
56
+ const html = render({
57
+ $type: "Chart",
58
+ variant: "bar",
59
+ data: [{ label: "a", value: 1 }],
60
+ color: "#f00",
61
+ });
62
+ expect(html).toContain('data-aui="chart"');
63
+ expect(html).toContain('data-aui-variant="bar"');
64
+ expect(html).toContain('data-aui-color="#f00"');
65
+ expect(html).toContain("&quot;value&quot;:1");
66
+ });
67
+ });
@@ -0,0 +1,86 @@
1
+ import { z } from "zod";
2
+ import type { GenerativeUILibrary } from "../types";
3
+
4
+ const columnSchema = z.object({
5
+ label: z.string().describe("Column header label."),
6
+ });
7
+
8
+ const cellSchema = z
9
+ .union([z.string(), z.number(), z.boolean()])
10
+ .describe("A cell value.");
11
+
12
+ export const dataVocabulary = {
13
+ Table: {
14
+ description:
15
+ "Tabular data. Provide `columns` and `rows`; each row is an array of cells matching the columns.",
16
+ properties: z.object({
17
+ columns: z.array(columnSchema).optional().describe("Column definitions."),
18
+ rows: z.array(z.array(cellSchema)).optional().describe("Rows of cells."),
19
+ }),
20
+ render: ({ columns, rows, children }) => (
21
+ <table data-aui="table">
22
+ {columns?.length ? (
23
+ <thead>
24
+ <tr>
25
+ {columns.map((c: { label: string }, i: number) => (
26
+ <th key={i} data-aui="table-col">
27
+ {c.label}
28
+ </th>
29
+ ))}
30
+ </tr>
31
+ </thead>
32
+ ) : null}
33
+ {rows?.length ? (
34
+ <tbody>
35
+ {rows.map((row: (string | number | boolean)[], r: number) => (
36
+ <tr key={r}>
37
+ {row.map((cell: string | number | boolean, c: number) => (
38
+ <td key={c}>{String(cell)}</td>
39
+ ))}
40
+ </tr>
41
+ ))}
42
+ </tbody>
43
+ ) : null}
44
+ {children}
45
+ </table>
46
+ ),
47
+ },
48
+ Markdown: {
49
+ description:
50
+ "A markdown string. Rendered as-is by default; override this component for a full markdown renderer.",
51
+ properties: z.object({
52
+ value: z.string().describe("Markdown source."),
53
+ }),
54
+ streamProperties: true,
55
+ render: ({ value, children }) => (
56
+ <div data-aui="markdown">
57
+ {value}
58
+ {children}
59
+ </div>
60
+ ),
61
+ },
62
+ Chart: {
63
+ description:
64
+ "A chart placeholder. Carries variant and data for a client renderer to draw.",
65
+ properties: z.object({
66
+ variant: z.enum(["bar", "line", "sparkline"]).describe("Chart variant."),
67
+ data: z
68
+ .array(
69
+ z.object({
70
+ label: z.string().optional(),
71
+ value: z.number(),
72
+ }),
73
+ )
74
+ .describe("Data points."),
75
+ color: z.string().optional().describe("Series color."),
76
+ }),
77
+ render: ({ variant, data, color }) => (
78
+ <div
79
+ data-aui="chart"
80
+ data-aui-variant={variant}
81
+ data-aui-color={color}
82
+ data-aui-data={JSON.stringify(data)}
83
+ />
84
+ ),
85
+ },
86
+ } satisfies GenerativeUILibrary;
@@ -0,0 +1,15 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { renderGenerativeUI } from "../renderGenerativeUI";
4
+ import { factVocabulary } from "./fact";
5
+
6
+ const render = (node: unknown) =>
7
+ renderToStaticMarkup(<>{renderGenerativeUI(node, factVocabulary)}</>);
8
+
9
+ describe("factVocabulary", () => {
10
+ it("Fact renders a label/value pair inside a dl", () => {
11
+ expect(render({ $type: "Fact", label: "Status", value: "open" })).toBe(
12
+ '<dl data-aui="fact"><dt data-aui="fact-label">Status</dt><dd data-aui="fact-value">open</dd></dl>',
13
+ );
14
+ });
15
+ });
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import type { GenerativeUILibrary } from "../types";
3
+
4
+ export const factVocabulary = {
5
+ Fact: {
6
+ description:
7
+ "A label/value pair, rendered as a key followed by its value. Use for compact metadata.",
8
+ properties: z.object({
9
+ label: z.string().describe("The fact label (key)."),
10
+ value: z.string().describe("The fact value."),
11
+ }),
12
+ render: ({ label, value, children }) => (
13
+ <dl data-aui="fact">
14
+ <dt data-aui="fact-label">{label}</dt>
15
+ <dd data-aui="fact-value">
16
+ {value}
17
+ {children}
18
+ </dd>
19
+ </dl>
20
+ ),
21
+ },
22
+ } satisfies GenerativeUILibrary;
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildPresentParameters } from "../buildPresentParameters";
3
+ import { defaultGenerativeUILibrary } from "./index";
4
+
5
+ const EXPECTED_KEYS = [
6
+ "Header",
7
+ "Text",
8
+ "Caption",
9
+ "Image",
10
+ "Divider",
11
+ "Fact",
12
+ "Button",
13
+ "Select",
14
+ "Input",
15
+ "DatePicker",
16
+ "Card",
17
+ "Col",
18
+ "Row",
19
+ "Spacer",
20
+ "Badge",
21
+ "Table",
22
+ "Markdown",
23
+ "Chart",
24
+ "Alert",
25
+ "Carousel",
26
+ ] as const;
27
+
28
+ describe("defaultGenerativeUILibrary", () => {
29
+ it("exposes exactly the closed vocabulary set, no stray or missing entries", () => {
30
+ expect(Object.keys(defaultGenerativeUILibrary).sort()).toEqual(
31
+ [...EXPECTED_KEYS].sort(),
32
+ );
33
+ });
34
+
35
+ it("every entry has a description, a zod properties schema, and a render fn", () => {
36
+ for (const [name, entry] of Object.entries(defaultGenerativeUILibrary)) {
37
+ expect(typeof entry.description, `${name}.description`).toBe("string");
38
+ expect(typeof entry.render, `${name}.render`).toBe("function");
39
+ expect(
40
+ typeof (entry.properties as { safeParse?: unknown }).safeParse,
41
+ `${name}.properties is a zod schema`,
42
+ ).toBe("function");
43
+ }
44
+ });
45
+
46
+ it("buildPresentParameters produces a $type enum of the vocabulary names", () => {
47
+ const schema = buildPresentParameters(defaultGenerativeUILibrary) as Record<
48
+ string,
49
+ unknown
50
+ >;
51
+ const typeProp = (schema.properties as Record<string, unknown>)[
52
+ "$type"
53
+ ] as { enum?: unknown[] };
54
+ expect(typeProp.enum).toEqual([...EXPECTED_KEYS]);
55
+ });
56
+ });