@assistant-ui/react-generative-ui 0.0.12 → 0.0.14
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 +5 -0
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.client.js +12 -3
- package/dist/JSONGenerativeUI.client.js.map +1 -1
- package/dist/renderGenerativeUI.js +2 -1
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/slack/constants.d.ts +11 -4
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/constants.js +11 -4
- package/dist/slack/constants.js.map +1 -1
- package/dist/slack/fromSlackBlocks.js +2 -2
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +114 -19
- package/dist/slack/toSlackBlocks.js.map +1 -1
- package/dist/slack/types.d.ts +7 -1
- package/dist/slack/types.d.ts.map +1 -1
- package/dist/teams/constants.d.ts +9 -2
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/constants.js +10 -3
- package/dist/teams/constants.js.map +1 -1
- package/dist/teams/toAdaptiveCard.d.ts +10 -2
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.js +54 -15
- package/dist/teams/toAdaptiveCard.js.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.js +21 -13
- package/dist/teams/toTeamsAttachments.js.map +1 -1
- package/dist/teams/types.d.ts +11 -2
- package/dist/teams/types.d.ts.map +1 -1
- package/dist/vocabulary/data.js +3 -2
- package/dist/vocabulary/data.js.map +1 -1
- package/dist/vocabulary/layout.js +2 -1
- package/dist/vocabulary/layout.js.map +1 -1
- package/package.json +5 -5
- package/src/JSONGenerativeUI.client.tsx +13 -5
- package/src/JSONGenerativeUI.test.tsx +21 -2
- package/src/slack/constants.ts +11 -3
- package/src/slack/toSlackBlocks.test.ts +374 -8
- package/src/slack/toSlackBlocks.ts +181 -24
- package/src/slack/types.ts +7 -1
- package/src/teams/constants.ts +10 -2
- package/src/teams/toAdaptiveCard.test.ts +204 -10
- package/src/teams/toAdaptiveCard.ts +79 -15
- package/src/teams/toTeamsAttachments.test.ts +47 -2
- package/src/teams/toTeamsAttachments.ts +17 -11
- package/src/teams/types.ts +11 -2
- package/src/vocabulary/layout.test.tsx +16 -1
- package/src/vocabulary/layout.tsx +4 -1
|
@@ -108,14 +108,14 @@ function reservedSafeId(
|
|
|
108
108
|
if (reserved) {
|
|
109
109
|
warn(
|
|
110
110
|
context,
|
|
111
|
-
"
|
|
111
|
+
"fallback",
|
|
112
112
|
component,
|
|
113
113
|
`the input id "${RESERVED_INPUT_ID}" collides with the submit envelope's reserved key and was renamed to "${candidate}".`,
|
|
114
114
|
);
|
|
115
115
|
} else if (candidate !== base) {
|
|
116
116
|
warn(
|
|
117
117
|
context,
|
|
118
|
-
"
|
|
118
|
+
"fallback",
|
|
119
119
|
component,
|
|
120
120
|
`the input id "${base}" was already used on this card and was renamed to "${candidate}".`,
|
|
121
121
|
);
|
|
@@ -191,6 +191,15 @@ const choicesFrom = (
|
|
|
191
191
|
const choice = toChoice(item);
|
|
192
192
|
if (choice !== undefined) choices.push(choice);
|
|
193
193
|
}
|
|
194
|
+
const dropped = items.length - choices.length;
|
|
195
|
+
if (dropped > 0) {
|
|
196
|
+
warn(
|
|
197
|
+
context,
|
|
198
|
+
"dropped",
|
|
199
|
+
component,
|
|
200
|
+
`${dropped} ${dropped === 1 ? "option was" : "options were"} dropped for want of a string value.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
194
203
|
return choices;
|
|
195
204
|
};
|
|
196
205
|
|
|
@@ -211,7 +220,7 @@ function convertButtons(
|
|
|
211
220
|
if (buttons.length > PRIMARY_ACTION_CAP) {
|
|
212
221
|
warn(
|
|
213
222
|
context,
|
|
214
|
-
"
|
|
223
|
+
"fallback",
|
|
215
224
|
"Button",
|
|
216
225
|
`actions beyond ${PRIMARY_ACTION_CAP} were set to secondary mode.`,
|
|
217
226
|
);
|
|
@@ -302,6 +311,17 @@ function convertTable(
|
|
|
302
311
|
);
|
|
303
312
|
}
|
|
304
313
|
|
|
314
|
+
const unlabeled = rawColumns.filter(
|
|
315
|
+
(column) => !isRecord(column) || typeof column["label"] !== "string",
|
|
316
|
+
).length;
|
|
317
|
+
if (unlabeled > 0) {
|
|
318
|
+
warn(
|
|
319
|
+
context,
|
|
320
|
+
"dropped",
|
|
321
|
+
"Table",
|
|
322
|
+
`${unlabeled} column ${unlabeled === 1 ? "header was" : "headers were"} left blank for want of a string label.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
305
325
|
const hasColumns = rawColumns.length > 0;
|
|
306
326
|
const columns: TeamsTableColumnDefinition[] = rawColumns.map(() => ({
|
|
307
327
|
width: 1 as const,
|
|
@@ -349,6 +369,29 @@ function convertListViewItem(
|
|
|
349
369
|
};
|
|
350
370
|
}
|
|
351
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Converts a child whose output is thrown away, and reports whether anything
|
|
374
|
+
* was lost with it. A scratch context keeps the ids it claims out of the card,
|
|
375
|
+
* where they would rename a control that survives; only its `dropped` warnings
|
|
376
|
+
* are forwarded, because those describe the tree the caller wrote, while a
|
|
377
|
+
* clamp or a rename would describe content never delivered.
|
|
378
|
+
*/
|
|
379
|
+
export const discardedChild = (
|
|
380
|
+
child: NormalizedUINode,
|
|
381
|
+
context: ConversionContext,
|
|
382
|
+
depth: number,
|
|
383
|
+
): boolean => {
|
|
384
|
+
const scratch: ConversionContext = {
|
|
385
|
+
...context,
|
|
386
|
+
warnings: [],
|
|
387
|
+
usedInputIds: new Set(context.usedInputIds),
|
|
388
|
+
};
|
|
389
|
+
const produced = convertSequence(child, scratch, depth).length > 0;
|
|
390
|
+
const lost = scratch.warnings.filter((warning) => warning.code === "dropped");
|
|
391
|
+
context.warnings.push(...lost);
|
|
392
|
+
return produced || lost.length > 0;
|
|
393
|
+
};
|
|
394
|
+
|
|
352
395
|
export function convertElement(
|
|
353
396
|
element: NormalizedUIElement,
|
|
354
397
|
context: ConversionContext,
|
|
@@ -539,7 +582,7 @@ export function convertElement(
|
|
|
539
582
|
case "Row": {
|
|
540
583
|
const children = normalizedList(element.children);
|
|
541
584
|
if (children.length > 3) {
|
|
542
|
-
warn(context, "
|
|
585
|
+
warn(context, "advisory", "Row", "Teams recommends at most 3 columns.");
|
|
543
586
|
}
|
|
544
587
|
const columns: TeamsColumn[] = children.map((child) => ({
|
|
545
588
|
type: "Column",
|
|
@@ -550,13 +593,21 @@ export function convertElement(
|
|
|
550
593
|
}
|
|
551
594
|
case "ListView": {
|
|
552
595
|
const containers: TeamsContainer[] = [];
|
|
596
|
+
let discarded = 0;
|
|
553
597
|
for (const child of normalizedList(element.children)) {
|
|
554
|
-
if (
|
|
555
|
-
if (child.type === "ListViewItem") {
|
|
598
|
+
if (isElement(child) && child.type === "ListViewItem") {
|
|
556
599
|
containers.push(convertListViewItem(child, context, depth + 1));
|
|
557
|
-
|
|
558
|
-
convertElement(child, context, depth + 1);
|
|
600
|
+
continue;
|
|
559
601
|
}
|
|
602
|
+
if (discardedChild(child, context, depth + 1)) discarded += 1;
|
|
603
|
+
}
|
|
604
|
+
if (discarded > 0) {
|
|
605
|
+
warn(
|
|
606
|
+
context,
|
|
607
|
+
"dropped",
|
|
608
|
+
"ListView",
|
|
609
|
+
`${discarded} non-item ${discarded === 1 ? "child was" : "children were"} dropped.`,
|
|
610
|
+
);
|
|
560
611
|
}
|
|
561
612
|
return containers.map((container, index) =>
|
|
562
613
|
index > 0 ? { ...container, separator: true } : container,
|
|
@@ -586,10 +637,23 @@ export function convertElement(
|
|
|
586
637
|
"Carousel",
|
|
587
638
|
"A carousel was rendered as sequential cards because it is not at the root.",
|
|
588
639
|
);
|
|
589
|
-
const cards =
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
640
|
+
const cards: NormalizedUIElement[] = [];
|
|
641
|
+
let droppedCards = 0;
|
|
642
|
+
for (const child of normalizedList(element.children)) {
|
|
643
|
+
if (isElement(child) && child.type === "Card") {
|
|
644
|
+
cards.push(child);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (discardedChild(child, context, depth + 1)) droppedCards += 1;
|
|
648
|
+
}
|
|
649
|
+
if (droppedCards > 0) {
|
|
650
|
+
warn(
|
|
651
|
+
context,
|
|
652
|
+
"dropped",
|
|
653
|
+
"Carousel",
|
|
654
|
+
`${droppedCards} non-card ${droppedCards === 1 ? "child was" : "children were"} dropped.`,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
593
657
|
return cards.flatMap((card) => convertElement(card, context, depth + 1));
|
|
594
658
|
}
|
|
595
659
|
case "Chart":
|
|
@@ -716,9 +780,9 @@ export function convertRootToCard(
|
|
|
716
780
|
if (size > PAYLOAD_SOFT_CAP) {
|
|
717
781
|
warn(
|
|
718
782
|
context,
|
|
719
|
-
"
|
|
783
|
+
"advisory",
|
|
720
784
|
"Root",
|
|
721
|
-
`the card is ${size} bytes,
|
|
785
|
+
`the card is ${size} bytes, over the ${PAYLOAD_SOFT_CAP}-byte soft budget kept below Teams' 100 KB bot message limit.`,
|
|
722
786
|
);
|
|
723
787
|
}
|
|
724
788
|
return card;
|
|
@@ -726,7 +790,7 @@ export function convertRootToCard(
|
|
|
726
790
|
|
|
727
791
|
/**
|
|
728
792
|
* Converts a generative-ui tree into a Microsoft Teams Adaptive Card and
|
|
729
|
-
* non-fatal
|
|
793
|
+
* non-fatal conversion warnings. Sizes, weights, and colors map to Adaptive
|
|
730
794
|
* Card's semantic enums rather than raw values. An Input/Select/RadioGroup/
|
|
731
795
|
* Checkbox/DatePicker whose id would be the reserved {@link RESERVED_INPUT_ID}
|
|
732
796
|
* is renamed with a warning (see `decodeSubmitData`). Never throws: an
|
|
@@ -19,11 +19,56 @@ describe("toTeamsAttachments payload budget", () => {
|
|
|
19
19
|
}
|
|
20
20
|
expect(
|
|
21
21
|
warnings.some(
|
|
22
|
-
(warning) =>
|
|
22
|
+
(warning) =>
|
|
23
|
+
warning.code === "advisory" && warning.component === "Root",
|
|
23
24
|
),
|
|
24
25
|
).toBe(false);
|
|
25
26
|
expect(warnings).toContainEqual(
|
|
26
|
-
expect.objectContaining({
|
|
27
|
+
expect.objectContaining({
|
|
28
|
+
code: "advisory",
|
|
29
|
+
component: "Carousel",
|
|
30
|
+
detail: expect.stringContaining(
|
|
31
|
+
`over the ${PAYLOAD_SOFT_CAP}-byte soft budget`,
|
|
32
|
+
),
|
|
33
|
+
}),
|
|
27
34
|
);
|
|
28
35
|
});
|
|
29
36
|
});
|
|
37
|
+
|
|
38
|
+
describe("toTeamsAttachments root carousel children", () => {
|
|
39
|
+
it("reports a renderable non-card child instead of dropping it silently", () => {
|
|
40
|
+
const { attachments, warnings } = toTeamsAttachments({
|
|
41
|
+
$type: "Carousel",
|
|
42
|
+
children: [
|
|
43
|
+
{ $type: "Text", value: "lost" },
|
|
44
|
+
{ $type: "Card", title: "kept" },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
expect(attachments).toHaveLength(1);
|
|
48
|
+
expect(warnings).toContainEqual({
|
|
49
|
+
code: "dropped",
|
|
50
|
+
component: "Carousel",
|
|
51
|
+
detail: "1 non-card child was dropped.",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("forwards a discarded child's own dropped warning", () => {
|
|
56
|
+
const { warnings } = toTeamsAttachments({
|
|
57
|
+
$type: "Carousel",
|
|
58
|
+
children: [{ $type: "Mystery" }, { $type: "Card", title: "kept" }],
|
|
59
|
+
});
|
|
60
|
+
expect(warnings).toContainEqual({
|
|
61
|
+
code: "dropped",
|
|
62
|
+
component: "Mystery",
|
|
63
|
+
detail: "Unknown component type was dropped.",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("stays silent for a child that renders nothing anyway", () => {
|
|
68
|
+
const { warnings } = toTeamsAttachments({
|
|
69
|
+
$type: "Carousel",
|
|
70
|
+
children: [{ $type: "Spacer" }, { $type: "Card", title: "kept" }],
|
|
71
|
+
});
|
|
72
|
+
expect(warnings).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
utf8ByteLength,
|
|
13
13
|
} from "./constants";
|
|
14
14
|
import {
|
|
15
|
-
convertElement,
|
|
16
15
|
convertRootToCard,
|
|
16
|
+
discardedChild,
|
|
17
17
|
type ConversionContext,
|
|
18
18
|
} from "./toAdaptiveCard";
|
|
19
19
|
import type {
|
|
@@ -47,10 +47,9 @@ const clampArray = <T>(
|
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
49
|
* Recognizes a root that is a single `Carousel` element, returning its `Card`
|
|
50
|
-
* children.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* children are then dropped. `undefined` means the root is not exclusively a
|
|
50
|
+
* children. A non-card child is routed through {@link discardedChild}, which
|
|
51
|
+
* reports it and forwards whatever it lost, rather than being filtered out
|
|
52
|
+
* silently. `undefined` means the root is not exclusively a
|
|
54
53
|
* root-level carousel, so the caller falls back to converting the whole tree
|
|
55
54
|
* as one card.
|
|
56
55
|
*/
|
|
@@ -61,13 +60,20 @@ function rootCarouselCards(
|
|
|
61
60
|
const element = Array.isArray(root) && root.length === 1 ? root[0] : root;
|
|
62
61
|
if (!isElement(element) || element.type !== "Carousel") return undefined;
|
|
63
62
|
const cards: NormalizedUIElement[] = [];
|
|
63
|
+
let discarded = 0;
|
|
64
64
|
for (const child of normalizedList(element.children)) {
|
|
65
|
-
if (
|
|
66
|
-
if (child.type === "Card") {
|
|
65
|
+
if (isElement(child) && child.type === "Card") {
|
|
67
66
|
cards.push(child);
|
|
68
|
-
|
|
69
|
-
convertElement(child, context, 1);
|
|
67
|
+
continue;
|
|
70
68
|
}
|
|
69
|
+
if (discardedChild(child, context, 1)) discarded += 1;
|
|
70
|
+
}
|
|
71
|
+
if (discarded > 0) {
|
|
72
|
+
context.warnings.push({
|
|
73
|
+
code: "dropped",
|
|
74
|
+
component: "Carousel",
|
|
75
|
+
detail: `${discarded} non-card ${discarded === 1 ? "child was" : "children were"} dropped.`,
|
|
76
|
+
});
|
|
71
77
|
}
|
|
72
78
|
return cards;
|
|
73
79
|
}
|
|
@@ -113,9 +119,9 @@ export function toTeamsAttachments(
|
|
|
113
119
|
const size = utf8ByteLength(JSON.stringify(attachments));
|
|
114
120
|
if (size > PAYLOAD_SOFT_CAP) {
|
|
115
121
|
warnings.push({
|
|
116
|
-
code: "
|
|
122
|
+
code: "advisory",
|
|
117
123
|
component: "Carousel",
|
|
118
|
-
detail: `the carousel attachments total ${size} bytes,
|
|
124
|
+
detail: `the carousel attachments total ${size} bytes, over the ${PAYLOAD_SOFT_CAP}-byte soft budget kept below Teams' 100 KB bot message limit.`,
|
|
119
125
|
});
|
|
120
126
|
}
|
|
121
127
|
return { attachments, attachmentLayout: "carousel", warnings };
|
package/src/teams/types.ts
CHANGED
|
@@ -213,9 +213,18 @@ export interface TeamsCardAttachment {
|
|
|
213
213
|
readonly content: TeamsAdaptiveCard;
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
/**
|
|
216
|
+
/**
|
|
217
|
+
* A non-fatal note reported during Teams conversion.
|
|
218
|
+
*
|
|
219
|
+
* `clamped` removed content to fit a limit, `dropped` discarded a node or one
|
|
220
|
+
* of its props and may leave a placeholder note behind, and `fallback`
|
|
221
|
+
* emitted the node in a different form than requested. `advisory` changed
|
|
222
|
+
* nothing at all and notes content Teams may still render poorly or refuse,
|
|
223
|
+
* such as a payload over the byte budget or a `Row` past the recommended
|
|
224
|
+
* column count.
|
|
225
|
+
*/
|
|
217
226
|
export interface TeamsConversionWarning {
|
|
218
|
-
readonly code: "clamped" | "dropped" | "fallback";
|
|
227
|
+
readonly code: "clamped" | "dropped" | "fallback" | "advisory";
|
|
219
228
|
readonly component: string;
|
|
220
229
|
readonly detail: string;
|
|
221
230
|
}
|
|
@@ -38,7 +38,22 @@ describe("layoutVocabulary", () => {
|
|
|
38
38
|
background: "var(--muted)",
|
|
39
39
|
}),
|
|
40
40
|
).toBe(
|
|
41
|
-
'<section data-aui="card" data-aui-padding="2" data-aui-background="var(--muted)" style="background:var(--muted);color:white"><header data-aui="card-title">T</header></section>',
|
|
41
|
+
'<section data-aui="card" data-aui-padding="2" data-aui-background="var(--muted)" data-aui-surface="" style="background:var(--muted);color:white"><header data-aui="card-title">T</header></section>',
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("Card flags itself as a surface only when a background or a footer makes it one", () => {
|
|
46
|
+
expect(render({ $type: "Card", title: "Plain" })).not.toContain(
|
|
47
|
+
"data-aui-surface",
|
|
48
|
+
);
|
|
49
|
+
expect(render({ $type: "Card", background: "var(--muted)" })).toContain(
|
|
50
|
+
"data-aui-surface",
|
|
51
|
+
);
|
|
52
|
+
expect(render({ $type: "Card", confirm: { label: "Yes" } })).toContain(
|
|
53
|
+
"data-aui-surface",
|
|
54
|
+
);
|
|
55
|
+
expect(render({ $type: "Card", cancel: { label: "No" } })).toContain(
|
|
56
|
+
"data-aui-surface",
|
|
42
57
|
);
|
|
43
58
|
});
|
|
44
59
|
|
|
@@ -24,7 +24,7 @@ const cardFooterButtonSchema = z.object({
|
|
|
24
24
|
export const layoutVocabulary = {
|
|
25
25
|
Card: {
|
|
26
26
|
description:
|
|
27
|
-
"A
|
|
27
|
+
"A titled section of related content, the default way to break a response into parts. It renders as plain content, so several in a row read as one answer rather than a stack of boxes; it takes on a framed surface only when `background` is set, when `confirm`/`cancel` add a footer, or when it is a slot in a `Carousel`. Set `asForm` to collect named child control values on submit.",
|
|
28
28
|
properties: z.object({
|
|
29
29
|
title: z.string().optional().describe("Optional card title."),
|
|
30
30
|
padding: z
|
|
@@ -96,6 +96,9 @@ export const layoutVocabulary = {
|
|
|
96
96
|
data-aui="card"
|
|
97
97
|
data-aui-padding={padding}
|
|
98
98
|
data-aui-background={background}
|
|
99
|
+
data-aui-surface={
|
|
100
|
+
background !== undefined || footer !== null ? "" : undefined
|
|
101
|
+
}
|
|
99
102
|
style={
|
|
100
103
|
background !== undefined
|
|
101
104
|
? { background, color: "white" }
|