@assistant-ui/react-generative-ui 0.0.17 → 0.0.18

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/dist/a2ui/convert.d.ts.map +1 -1
  2. package/dist/a2ui/convert.js +11 -2
  3. package/dist/a2ui/convert.js.map +1 -1
  4. package/dist/a2ui/reducer.d.ts.map +1 -1
  5. package/dist/a2ui/reducer.js +63 -10
  6. package/dist/a2ui/reducer.js.map +1 -1
  7. package/dist/buildPresentParameters.js +4 -3
  8. package/dist/buildPresentParameters.js.map +1 -1
  9. package/dist/renderGenerativeUI.js +1 -1
  10. package/dist/renderGenerativeUI.js.map +1 -1
  11. package/dist/vocabulary/alert.d.ts +1 -1
  12. package/dist/vocabulary/alert.d.ts.map +1 -1
  13. package/dist/vocabulary/alert.js +21 -16
  14. package/dist/vocabulary/alert.js.map +1 -1
  15. package/dist/vocabulary/data.d.ts.map +1 -1
  16. package/dist/vocabulary/data.js +20 -12
  17. package/dist/vocabulary/data.js.map +1 -1
  18. package/dist/vocabulary/fact.d.ts.map +1 -1
  19. package/dist/vocabulary/fact.js +3 -2
  20. package/dist/vocabulary/fact.js.map +1 -1
  21. package/dist/vocabulary/interactive.d.ts +6 -5
  22. package/dist/vocabulary/interactive.d.ts.map +1 -1
  23. package/dist/vocabulary/interactive.js +28 -23
  24. package/dist/vocabulary/interactive.js.map +1 -1
  25. package/dist/vocabulary/layout.d.ts.map +1 -1
  26. package/dist/vocabulary/layout.js +7 -5
  27. package/dist/vocabulary/layout.js.map +1 -1
  28. package/dist/vocabulary/text.d.ts.map +1 -1
  29. package/dist/vocabulary/text.js +4 -3
  30. package/dist/vocabulary/text.js.map +1 -1
  31. package/dist/vocabulary/toTextContent.d.ts +5 -0
  32. package/dist/vocabulary/toTextContent.d.ts.map +1 -0
  33. package/dist/vocabulary/toTextContent.js +6 -0
  34. package/dist/vocabulary/toTextContent.js.map +1 -0
  35. package/package.json +4 -4
  36. package/src/a2ui/convert.test.ts +36 -0
  37. package/src/a2ui/convert.ts +21 -2
  38. package/src/a2ui/reducer.test.ts +223 -0
  39. package/src/a2ui/reducer.ts +76 -12
  40. package/src/buildPresentParameters.ts +7 -4
  41. package/src/renderGenerativeUI.test.tsx +57 -0
  42. package/src/renderGenerativeUI.tsx +3 -1
  43. package/src/vocabulary/alert.test.tsx +10 -0
  44. package/src/vocabulary/alert.tsx +16 -7
  45. package/src/vocabulary/data.test.tsx +40 -0
  46. package/src/vocabulary/data.tsx +47 -26
  47. package/src/vocabulary/fact.test.tsx +12 -0
  48. package/src/vocabulary/fact.tsx +3 -2
  49. package/src/vocabulary/interactive.test.tsx +41 -0
  50. package/src/vocabulary/interactive.tsx +42 -28
  51. package/src/vocabulary/layout.test.tsx +16 -0
  52. package/src/vocabulary/layout.tsx +8 -4
  53. package/src/vocabulary/text.test.tsx +29 -0
  54. package/src/vocabulary/text.tsx +4 -3
  55. package/src/vocabulary/toTextContent.ts +2 -0
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { GenerativeUILibrary } from "../types";
3
+ import { toTextContent } from "./toTextContent";
3
4
 
4
5
  const columnSchema = z.object({
5
6
  label: z.string().describe("Column header label."),
@@ -9,6 +10,20 @@ const cellSchema = z
9
10
  .union([z.string(), z.number(), z.boolean()])
10
11
  .describe("A cell value.");
11
12
 
13
+ type TableColumn = { label: string };
14
+ type TableCell = string | number | boolean;
15
+
16
+ const isTableColumn = (column: unknown): column is TableColumn =>
17
+ column !== null &&
18
+ typeof column === "object" &&
19
+ "label" in column &&
20
+ typeof column.label === "string";
21
+
22
+ const isTableCell = (cell: unknown): cell is TableCell =>
23
+ typeof cell === "string" ||
24
+ typeof cell === "number" ||
25
+ typeof cell === "boolean";
26
+
12
27
  const CHART_HEIGHT = 40;
13
28
  const CHART_WIDTH = 100;
14
29
 
@@ -282,33 +297,39 @@ export const dataVocabulary = {
282
297
  columns: z.array(columnSchema).optional().describe("Column definitions."),
283
298
  rows: z.array(z.array(cellSchema)).optional().describe("Rows of cells."),
284
299
  }),
285
- render: ({ columns, rows, children }) => (
286
- <table data-aui="table">
287
- {columns?.length ? (
288
- <thead>
289
- <tr>
290
- {columns.map((c: { label: string }, i: number) => (
291
- <th key={i} data-aui="table-col">
292
- {c.label}
293
- </th>
294
- ))}
295
- </tr>
296
- </thead>
297
- ) : null}
298
- {rows?.length ? (
299
- <tbody>
300
- {rows.map((row: (string | number | boolean)[], r: number) => (
301
- <tr key={r}>
302
- {row.map((cell: string | number | boolean, c: number) => (
303
- <td key={c}>{String(cell)}</td>
300
+ render: ({ columns, rows, children }) => {
301
+ const safeColumns = Array.isArray(columns) ? columns : [];
302
+ const hasColumns = safeColumns.some(isTableColumn);
303
+ const safeRows = Array.isArray(rows) ? rows.filter(Array.isArray) : [];
304
+
305
+ return (
306
+ <table data-aui="table">
307
+ {hasColumns ? (
308
+ <thead>
309
+ <tr>
310
+ {safeColumns.map((column, i) => (
311
+ <th key={i} data-aui="table-col">
312
+ {isTableColumn(column) ? column.label : ""}
313
+ </th>
304
314
  ))}
305
315
  </tr>
306
- ))}
307
- </tbody>
308
- ) : null}
309
- {children}
310
- </table>
311
- ),
316
+ </thead>
317
+ ) : null}
318
+ {safeRows.length ? (
319
+ <tbody>
320
+ {safeRows.map((row, r) => (
321
+ <tr key={r}>
322
+ {row.map((cell, c) => (
323
+ <td key={c}>{isTableCell(cell) ? String(cell) : ""}</td>
324
+ ))}
325
+ </tr>
326
+ ))}
327
+ </tbody>
328
+ ) : null}
329
+ {children}
330
+ </table>
331
+ );
332
+ },
312
333
  },
313
334
  Markdown: {
314
335
  description:
@@ -319,7 +340,7 @@ export const dataVocabulary = {
319
340
  streamProperties: true,
320
341
  render: ({ value, children }) => (
321
342
  <div data-aui="markdown">
322
- {value}
343
+ {toTextContent(value)}
323
344
  {children}
324
345
  </div>
325
346
  ),
@@ -12,4 +12,16 @@ describe("factVocabulary", () => {
12
12
  '<dl data-aui="fact"><dt data-aui="fact-label">Status</dt><dd data-aui="fact-value">open</dd></dl>',
13
13
  );
14
14
  });
15
+
16
+ it("Fact ignores malformed text properties", () => {
17
+ expect(
18
+ render({
19
+ $type: "Fact",
20
+ label: { unexpected: true },
21
+ value: { unexpected: true },
22
+ }),
23
+ ).toBe(
24
+ '<dl data-aui="fact"><dt data-aui="fact-label"></dt><dd data-aui="fact-value"></dd></dl>',
25
+ );
26
+ });
15
27
  });
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { GenerativeUILibrary } from "../types";
3
+ import { toTextContent } from "./toTextContent";
3
4
 
4
5
  export const factVocabulary = {
5
6
  Fact: {
@@ -11,9 +12,9 @@ export const factVocabulary = {
11
12
  }),
12
13
  render: ({ label, value, children }) => (
13
14
  <dl data-aui="fact">
14
- <dt data-aui="fact-label">{label}</dt>
15
+ <dt data-aui="fact-label">{toTextContent(label)}</dt>
15
16
  <dd data-aui="fact-value">
16
- {value}
17
+ {toTextContent(value)}
17
18
  {children}
18
19
  </dd>
19
20
  </dl>
@@ -103,6 +103,26 @@ describe("interactiveVocabulary", () => {
103
103
  expect(html).toContain('<option value="b">B</option>');
104
104
  });
105
105
 
106
+ it("Select ignores malformed options instead of throwing", () => {
107
+ expect(render({ $type: "Select" })).toBe(
108
+ '<select data-aui="select"></select>',
109
+ );
110
+ expect(render({ $type: "Select", options: "not-an-array" })).toBe(
111
+ '<select data-aui="select"></select>',
112
+ );
113
+ expect(
114
+ render({
115
+ $type: "Select",
116
+ options: [
117
+ null,
118
+ { label: "Missing value" },
119
+ { value: "Missing label" },
120
+ { label: "A", value: "a" },
121
+ ],
122
+ }),
123
+ ).toBe('<select data-aui="select"><option value="a">A</option></select>');
124
+ });
125
+
106
126
  it("Input renders a single-line input by default", () => {
107
127
  expect(render({ $type: "Input", placeholder: "type here" })).toBe(
108
128
  '<input data-aui="input" placeholder="type here"/>',
@@ -157,6 +177,27 @@ describe("interactiveVocabulary", () => {
157
177
  expect(html).toContain('checked=""');
158
178
  });
159
179
 
180
+ it.each([
181
+ [
182
+ { $type: "Button", label: { unexpected: true } },
183
+ '<button type="button" data-aui="button"></button>',
184
+ ],
185
+ [
186
+ {
187
+ $type: "Select",
188
+ placeholder: { unexpected: true },
189
+ options: [],
190
+ },
191
+ '<select data-aui="select"></select>',
192
+ ],
193
+ [
194
+ { $type: "Checkbox", label: { unexpected: true } },
195
+ '<label data-aui="checkbox"><input type="checkbox"/><span data-aui="checkbox-label"></span></label>',
196
+ ],
197
+ ])("ignores malformed control text properties", (node, expected) => {
198
+ expect(render(node)).toBe(expected);
199
+ });
200
+
160
201
  it("RadioGroup renders a fieldset with one radio per option", () => {
161
202
  const html = render({
162
203
  $type: "RadioGroup",
@@ -4,14 +4,25 @@ import type { Action } from "../ir";
4
4
  import { BUTTON_STYLES } from "../ir";
5
5
  import type { GenerativeUIDispatch, GenerativeUILibrary } from "../types";
6
6
  import { actionAttr, fire } from "./dispatch";
7
+ import { toTextContent } from "./toTextContent";
7
8
 
8
9
  const optionSchema = z.object({
9
10
  label: z.string(),
10
11
  value: z.string(),
11
12
  });
12
13
 
14
+ type Option = { label: string; value: string };
15
+
16
+ const isOption = (option: unknown): option is Option =>
17
+ option !== null &&
18
+ typeof option === "object" &&
19
+ "label" in option &&
20
+ typeof option.label === "string" &&
21
+ "value" in option &&
22
+ typeof option.value === "string";
23
+
13
24
  type RadioGroupRenderProps = {
14
- options: { label: string; value: string }[];
25
+ options: Option[];
15
26
  name?: string;
16
27
  label?: string;
17
28
  defaultValue?: string;
@@ -37,9 +48,7 @@ function RadioGroupRender({
37
48
  aria-label={label}
38
49
  >
39
50
  {safeOptions.map((option, i) =>
40
- option &&
41
- typeof option.label === "string" &&
42
- typeof option.value === "string" ? (
51
+ isOption(option) ? (
43
52
  <label key={i} data-aui="radiogroup-option">
44
53
  <input
45
54
  type="radio"
@@ -92,7 +101,7 @@ export const interactiveVocabulary = {
92
101
  data-aui-action={actionAttr($action)}
93
102
  onClick={submit ? undefined : () => fire($action, $dispatch)}
94
103
  >
95
- {label}
104
+ {toTextContent(label)}
96
105
  {children}
97
106
  </button>
98
107
  ),
@@ -120,28 +129,33 @@ export const interactiveVocabulary = {
120
129
  $action,
121
130
  $dispatch,
122
131
  children,
123
- }) => (
124
- <select
125
- data-aui="select"
126
- data-aui-action={actionAttr($action)}
127
- name={name}
128
- aria-label={label}
129
- defaultValue=""
130
- onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
131
- >
132
- {placeholder ? (
133
- <option value="" disabled>
134
- {placeholder}
135
- </option>
136
- ) : null}
137
- {options.map((o: { label: string; value: string }, i: number) => (
138
- <option key={i} value={o.value}>
139
- {o.label}
140
- </option>
141
- ))}
142
- {children}
143
- </select>
144
- ),
132
+ }) => {
133
+ const placeholderText = toTextContent(placeholder);
134
+ return (
135
+ <select
136
+ data-aui="select"
137
+ data-aui-action={actionAttr($action)}
138
+ name={name}
139
+ aria-label={label}
140
+ defaultValue=""
141
+ onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
142
+ >
143
+ {placeholderText ? (
144
+ <option value="" disabled>
145
+ {placeholderText}
146
+ </option>
147
+ ) : null}
148
+ {(Array.isArray(options) ? options : []).map((option, i) =>
149
+ isOption(option) ? (
150
+ <option key={i} value={option.value}>
151
+ {option.label}
152
+ </option>
153
+ ) : null,
154
+ )}
155
+ {children}
156
+ </select>
157
+ );
158
+ },
145
159
  },
146
160
  Input: {
147
161
  description:
@@ -251,7 +265,7 @@ export const interactiveVocabulary = {
251
265
  defaultChecked={defaultChecked}
252
266
  onChange={(e) => fire($action, $dispatch, e.currentTarget.checked)}
253
267
  />
254
- <span data-aui="checkbox-label">{label}</span>
268
+ <span data-aui="checkbox-label">{toTextContent(label)}</span>
255
269
  </label>
256
270
  ),
257
271
  },
@@ -114,6 +114,22 @@ describe("layoutVocabulary", () => {
114
114
  '<span data-aui="badge" data-aui-variant="success">new</span>',
115
115
  );
116
116
  });
117
+
118
+ it("ignores malformed card and badge text properties", () => {
119
+ expect(
120
+ render({
121
+ $type: "Card",
122
+ title: { unexpected: true },
123
+ confirm: { label: { unexpected: true } },
124
+ cancel: { label: { unexpected: true } },
125
+ }),
126
+ ).toBe(
127
+ '<section data-aui="card" data-aui-surface=""><footer data-aui="card-footer"><button type="button" data-aui="card-confirm"></button><button type="button" data-aui="card-cancel"></button></footer></section>',
128
+ );
129
+ expect(render({ $type: "Badge", value: { unexpected: true } })).toBe(
130
+ '<span data-aui="badge"></span>',
131
+ );
132
+ });
117
133
  });
118
134
 
119
135
  describe("layoutVocabulary Box", () => {
@@ -4,6 +4,7 @@ import type { GenerativeUILibrary } from "../types";
4
4
  import { ALIGNS, JUSTIFIES } from "../ir";
5
5
  import { fire } from "./dispatch";
6
6
  import { collectFormValuesFromEvent } from "./collectFormValues";
7
+ import { toTextContent } from "./toTextContent";
7
8
 
8
9
  const toCssLength = (value: string | number): string =>
9
10
  typeof value === "number" ? `${value}px` : value;
@@ -65,6 +66,7 @@ export const layoutVocabulary = {
65
66
  children,
66
67
  }) => {
67
68
  const Root = asForm ? "form" : "section";
69
+ const cardTitle = toTextContent(title);
68
70
  const footer =
69
71
  confirm || cancel ? (
70
72
  <footer data-aui="card-footer">
@@ -76,7 +78,7 @@ export const layoutVocabulary = {
76
78
  asForm ? undefined : () => fire(confirm.$action, $dispatch)
77
79
  }
78
80
  >
79
- {confirm.label}
81
+ {toTextContent(confirm.label)}
80
82
  </button>
81
83
  ) : null}
82
84
  {cancel ? (
@@ -85,7 +87,7 @@ export const layoutVocabulary = {
85
87
  data-aui="card-cancel"
86
88
  onClick={() => fire(cancel.$action, $dispatch)}
87
89
  >
88
- {cancel.label}
90
+ {toTextContent(cancel.label)}
89
91
  </button>
90
92
  ) : null}
91
93
  </footer>
@@ -118,7 +120,9 @@ export const layoutVocabulary = {
118
120
  : undefined
119
121
  }
120
122
  >
121
- {title ? <header data-aui="card-title">{title}</header> : null}
123
+ {cardTitle ? (
124
+ <header data-aui="card-title">{cardTitle}</header>
125
+ ) : null}
122
126
  {children}
123
127
  {footer}
124
128
  </Root>
@@ -182,7 +186,7 @@ export const layoutVocabulary = {
182
186
  }),
183
187
  render: ({ value, variant, children }) => (
184
188
  <span data-aui="badge" data-aui-variant={variant}>
185
- {value}
189
+ {toTextContent(value)}
186
190
  {children}
187
191
  </span>
188
192
  ),
@@ -70,4 +70,33 @@ describe("textVocabulary", () => {
70
70
  '<p data-aui="caption">muted note</p>',
71
71
  );
72
72
  });
73
+
74
+ it.each([
75
+ [
76
+ { $type: "Header", text: { unexpected: true } },
77
+ undefined,
78
+ '<h2 data-aui="header" data-aui-size="lg"></h2>',
79
+ ],
80
+ [
81
+ { $type: "Text", value: { unexpected: true } },
82
+ { status: "streaming" as const },
83
+ '<span data-aui="text" data-aui-size="md"></span>',
84
+ ],
85
+ [
86
+ { $type: "Caption", value: { unexpected: true } },
87
+ { status: "streaming" as const },
88
+ '<p data-aui="caption"></p>',
89
+ ],
90
+ [
91
+ { $type: "Text", value: 42 },
92
+ { status: "streaming" as const },
93
+ '<span data-aui="text" data-aui-size="md">42</span>',
94
+ ],
95
+ ])("renders only supported text properties", (node, options, expected) => {
96
+ expect(
97
+ renderToStaticMarkup(
98
+ <>{renderGenerativeUI(node, textVocabulary, options)}</>,
99
+ ),
100
+ ).toBe(expected);
101
+ });
73
102
  });
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import type { GenerativeUILibrary } from "../types";
3
3
  import { COLORS, TEXT_SIZES, WEIGHTS } from "../ir";
4
+ import { toTextContent } from "./toTextContent";
4
5
 
5
6
  export const textVocabulary = {
6
7
  Header: {
@@ -14,7 +15,7 @@ export const textVocabulary = {
14
15
  }),
15
16
  render: ({ text, size, children }) => (
16
17
  <h2 data-aui="header" data-aui-size={size ?? "lg"}>
17
- {text}
18
+ {toTextContent(text)}
18
19
  {children}
19
20
  </h2>
20
21
  ),
@@ -38,7 +39,7 @@ export const textVocabulary = {
38
39
  data-aui-weight={weight}
39
40
  data-aui-color={color}
40
41
  >
41
- {value}
42
+ {toTextContent(value)}
42
43
  {children}
43
44
  </span>
44
45
  ),
@@ -51,7 +52,7 @@ export const textVocabulary = {
51
52
  streamProperties: true,
52
53
  render: ({ value, children }) => (
53
54
  <p data-aui="caption">
54
- {value}
55
+ {toTextContent(value)}
55
56
  {children}
56
57
  </p>
57
58
  ),
@@ -0,0 +1,2 @@
1
+ export const toTextContent = (value: unknown): string | number | null =>
2
+ typeof value === "string" || typeof value === "number" ? value : null;