@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
|
@@ -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 {
|
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
|
|
16
|
-
* `"done"`, `P` is complete; while `"streaming"`, `P` is partial.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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> & {
|
|
22
|
-
|
|
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 & {
|
|
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(""value":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
|
+
});
|