@copilotkit/channels-teams 0.1.0
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/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/adapter.d.ts +133 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +539 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +202 -0
- package/dist/conversation-store.d.ts +36 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +78 -0
- package/dist/conversation-store.test.d.ts +2 -0
- package/dist/conversation-store.test.d.ts.map +1 -0
- package/dist/conversation-store.test.js +72 -0
- package/dist/download-files.d.ts +62 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +190 -0
- package/dist/download-files.test.d.ts +2 -0
- package/dist/download-files.test.d.ts.map +1 -0
- package/dist/download-files.test.js +96 -0
- package/dist/event-renderer.d.ts +23 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +135 -0
- package/dist/event-renderer.test.d.ts +2 -0
- package/dist/event-renderer.test.d.ts.map +1 -0
- package/dist/event-renderer.test.js +95 -0
- package/dist/graph-files.d.ts +50 -0
- package/dist/graph-files.d.ts.map +1 -0
- package/dist/graph-files.js +126 -0
- package/dist/graph-files.test.d.ts +2 -0
- package/dist/graph-files.test.d.ts.map +1 -0
- package/dist/graph-files.test.js +114 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/interaction.d.ts +36 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +35 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +43 -0
- package/dist/listener.d.ts +26 -0
- package/dist/listener.d.ts.map +1 -0
- package/dist/listener.js +55 -0
- package/dist/listener.test.d.ts +2 -0
- package/dist/listener.test.d.ts.map +1 -0
- package/dist/listener.test.js +43 -0
- package/dist/message-stream.d.ts +52 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +78 -0
- package/dist/message-stream.test.d.ts +2 -0
- package/dist/message-stream.test.d.ts.map +1 -0
- package/dist/message-stream.test.js +44 -0
- package/dist/render/adaptive-card.d.ts +43 -0
- package/dist/render/adaptive-card.d.ts.map +1 -0
- package/dist/render/adaptive-card.js +401 -0
- package/dist/render/adaptive-card.test.d.ts +2 -0
- package/dist/render/adaptive-card.test.d.ts.map +1 -0
- package/dist/render/adaptive-card.test.js +240 -0
- package/dist/render/auto-close.d.ts +27 -0
- package/dist/render/auto-close.d.ts.map +1 -0
- package/dist/render/auto-close.js +153 -0
- package/dist/render/auto-close.test.d.ts +2 -0
- package/dist/render/auto-close.test.d.ts.map +1 -0
- package/dist/render/auto-close.test.js +46 -0
- package/dist/render/budget.d.ts +38 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +44 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +25 -0
- package/dist/render/markdown.d.ts +13 -0
- package/dist/render/markdown.d.ts.map +1 -0
- package/dist/render/markdown.js +121 -0
- package/dist/render/markdown.test.d.ts +2 -0
- package/dist/render/markdown.test.d.ts.map +1 -0
- package/dist/render/markdown.test.js +58 -0
- package/dist/sanitizing-http-agent.d.ts +35 -0
- package/dist/sanitizing-http-agent.d.ts.map +1 -0
- package/dist/sanitizing-http-agent.js +58 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { TEAMS_LIMITS, truncateText, clampArray } from "./budget.js";
|
|
2
|
+
/** Teams attachment content type for an Adaptive Card. */
|
|
3
|
+
export const ADAPTIVE_CARD_CONTENT_TYPE = "application/vnd.microsoft.card.adaptive";
|
|
4
|
+
const SCHEMA = "http://adaptivecards.io/schemas/adaptive-card.json";
|
|
5
|
+
const VERSION = "1.5";
|
|
6
|
+
/**
|
|
7
|
+
* Render a cross-platform component IR tree (already expanded by `renderToIR`
|
|
8
|
+
* and pre-bound by the action registry, so event props are `{ id }`) into a
|
|
9
|
+
* Teams **Adaptive Card** (1.5).
|
|
10
|
+
*
|
|
11
|
+
* Structural nodes map to body elements (`<Header>`→bold `TextBlock`,
|
|
12
|
+
* `<Section>`/`<Markdown>`→wrapped `TextBlock`, `<Fields>`→`FactSet`,
|
|
13
|
+
* `<Table>`→native `Table`, `<Image>`→`Image`). Interactive nodes split by
|
|
14
|
+
* Adaptive Card shape: `<Button>`→a top-level `Action.Submit` (per the V1
|
|
15
|
+
* decision to use `Action.Submit`), while `<Input>`/`<Select>` become
|
|
16
|
+
* `Input.Text`/`Input.ChoiceSet` in the body. Each action/input carries the
|
|
17
|
+
* registry-stamped opaque id in its `data`/`id` so a later interaction can be
|
|
18
|
+
* decoded back into the engine (round-trip is a follow-up; rendering is here).
|
|
19
|
+
*
|
|
20
|
+
* The renderer is total: unknown intrinsics are skipped. Collections clamp and
|
|
21
|
+
* text truncates to {@link TEAMS_LIMITS} so the card stays within Teams' payload
|
|
22
|
+
* ceiling.
|
|
23
|
+
*/
|
|
24
|
+
export function renderAdaptiveCard(ir) {
|
|
25
|
+
const body = [];
|
|
26
|
+
const actions = [];
|
|
27
|
+
for (const node of ir)
|
|
28
|
+
renderNode(node, body, actions);
|
|
29
|
+
const card = {
|
|
30
|
+
type: "AdaptiveCard",
|
|
31
|
+
$schema: SCHEMA,
|
|
32
|
+
version: VERSION,
|
|
33
|
+
body: clampArray(body, TEAMS_LIMITS.bodyElements).items,
|
|
34
|
+
};
|
|
35
|
+
const clampedActions = clampArray(actions, TEAMS_LIMITS.actions).items;
|
|
36
|
+
if (clampedActions.length > 0)
|
|
37
|
+
card.actions = clampedActions;
|
|
38
|
+
return card;
|
|
39
|
+
}
|
|
40
|
+
/** Render a single IR node, pushing body elements and/or top-level actions. */
|
|
41
|
+
function renderNode(node, body, actions) {
|
|
42
|
+
if (typeof node.type !== "string")
|
|
43
|
+
return; // non-intrinsic, already expanded
|
|
44
|
+
const props = node.props ?? {};
|
|
45
|
+
switch (node.type) {
|
|
46
|
+
case "message":
|
|
47
|
+
// The message container is not an element; flatten its children.
|
|
48
|
+
for (const child of childNodes(node))
|
|
49
|
+
renderNode(child, body, actions);
|
|
50
|
+
return;
|
|
51
|
+
case "header":
|
|
52
|
+
body.push({
|
|
53
|
+
type: "TextBlock",
|
|
54
|
+
text: truncateText(collectText(node), TEAMS_LIMITS.textBlock),
|
|
55
|
+
size: "Large",
|
|
56
|
+
weight: "Bolder",
|
|
57
|
+
wrap: true,
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
case "section":
|
|
61
|
+
case "markdown":
|
|
62
|
+
body.push(textBlock(collectText(node)));
|
|
63
|
+
return;
|
|
64
|
+
case "text":
|
|
65
|
+
body.push(textBlock(String(props.value ?? "")));
|
|
66
|
+
return;
|
|
67
|
+
case "context":
|
|
68
|
+
body.push({
|
|
69
|
+
type: "TextBlock",
|
|
70
|
+
text: truncateText(collectText(node), TEAMS_LIMITS.textBlock),
|
|
71
|
+
size: "Small",
|
|
72
|
+
isSubtle: true,
|
|
73
|
+
wrap: true,
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
case "divider":
|
|
77
|
+
// Adaptive Cards has no rule element; a separator line is drawn *above*
|
|
78
|
+
// an element via `separator: true`. An empty, separated TextBlock reads
|
|
79
|
+
// as a horizontal divider.
|
|
80
|
+
body.push({
|
|
81
|
+
type: "TextBlock",
|
|
82
|
+
text: " ",
|
|
83
|
+
separator: true,
|
|
84
|
+
spacing: "Medium",
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
case "image":
|
|
88
|
+
body.push({
|
|
89
|
+
type: "Image",
|
|
90
|
+
url: String(props.url ?? props.image_url ?? ""),
|
|
91
|
+
altText: String(props.alt ?? props.altText ?? ""),
|
|
92
|
+
size: "Auto",
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
case "fields":
|
|
96
|
+
body.push(factSet(childNodes(node).filter((c) => c.type === "field")));
|
|
97
|
+
return;
|
|
98
|
+
case "field":
|
|
99
|
+
body.push(factSet([node]));
|
|
100
|
+
return;
|
|
101
|
+
case "table":
|
|
102
|
+
body.push(renderTable(node));
|
|
103
|
+
return;
|
|
104
|
+
case "chart":
|
|
105
|
+
body.push(renderChart(node));
|
|
106
|
+
return;
|
|
107
|
+
case "actions":
|
|
108
|
+
for (const child of childNodes(node))
|
|
109
|
+
renderNode(child, body, actions);
|
|
110
|
+
return;
|
|
111
|
+
case "button":
|
|
112
|
+
actions.push(renderButton(node));
|
|
113
|
+
return;
|
|
114
|
+
case "select":
|
|
115
|
+
body.push(renderSelect(node));
|
|
116
|
+
return;
|
|
117
|
+
case "input":
|
|
118
|
+
body.push(renderInput(node));
|
|
119
|
+
return;
|
|
120
|
+
default:
|
|
121
|
+
// Unknown intrinsic: skip (total renderer).
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function textBlock(text) {
|
|
126
|
+
return {
|
|
127
|
+
type: "TextBlock",
|
|
128
|
+
text: truncateText(text, TEAMS_LIMITS.textBlock),
|
|
129
|
+
wrap: true,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** A `<Fields>`/`<Field>` group → a `FactSet`. Each field's text is split on
|
|
133
|
+
* its first colon into title/value (falling back to a value-only fact). */
|
|
134
|
+
function factSet(fieldNodes) {
|
|
135
|
+
const { items } = clampArray(fieldNodes, TEAMS_LIMITS.factsPerSet);
|
|
136
|
+
const facts = items.map((f) => {
|
|
137
|
+
const text = collectText(f);
|
|
138
|
+
const idx = text.indexOf(":");
|
|
139
|
+
if (idx > 0 && idx <= 60) {
|
|
140
|
+
return {
|
|
141
|
+
title: truncateText(text.slice(0, idx).trim(), TEAMS_LIMITS.factTitle),
|
|
142
|
+
value: truncateText(text.slice(idx + 1).trim(), TEAMS_LIMITS.factValue),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return { title: "", value: truncateText(text, TEAMS_LIMITS.factValue) };
|
|
146
|
+
});
|
|
147
|
+
return { type: "FactSet", facts };
|
|
148
|
+
}
|
|
149
|
+
function renderButton(node) {
|
|
150
|
+
const props = node.props ?? {};
|
|
151
|
+
// Link button → Action.OpenUrl (opens the URL; carries no submit data).
|
|
152
|
+
if (typeof props.url === "string" && props.url.length > 0) {
|
|
153
|
+
return {
|
|
154
|
+
type: "Action.OpenUrl",
|
|
155
|
+
title: truncateText(collectText(node), TEAMS_LIMITS.buttonText),
|
|
156
|
+
url: props.url,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const action = {
|
|
160
|
+
type: "Action.Submit",
|
|
161
|
+
title: truncateText(collectText(node), TEAMS_LIMITS.buttonText),
|
|
162
|
+
};
|
|
163
|
+
// Forward-ready: carry the opaque action id + value so a later
|
|
164
|
+
// `decodeInteraction` can route the submit back into the engine.
|
|
165
|
+
const id = idFromHandler(props.onClick);
|
|
166
|
+
const data = {};
|
|
167
|
+
if (id)
|
|
168
|
+
data.ckActionId = id;
|
|
169
|
+
if (props.value !== undefined)
|
|
170
|
+
data.value = props.value;
|
|
171
|
+
if (Object.keys(data).length > 0)
|
|
172
|
+
action.data = data;
|
|
173
|
+
if (props.style === "danger" || props.style === "destructive") {
|
|
174
|
+
action.style = "destructive";
|
|
175
|
+
}
|
|
176
|
+
else if (props.style === "primary") {
|
|
177
|
+
action.style = "positive";
|
|
178
|
+
}
|
|
179
|
+
return action;
|
|
180
|
+
}
|
|
181
|
+
function renderSelect(node) {
|
|
182
|
+
const props = node.props ?? {};
|
|
183
|
+
const options = props.options ?? [];
|
|
184
|
+
const { items } = clampArray(options, TEAMS_LIMITS.choices);
|
|
185
|
+
const el = {
|
|
186
|
+
type: "Input.ChoiceSet",
|
|
187
|
+
id: idFromHandler(props.onSelect) ?? "select",
|
|
188
|
+
choices: items.map((o) => ({
|
|
189
|
+
title: truncateText(String(o.label), TEAMS_LIMITS.choiceLabel),
|
|
190
|
+
value: String(o.value),
|
|
191
|
+
})),
|
|
192
|
+
};
|
|
193
|
+
// Multi-select: Teams submits the chosen values as a comma-joined string.
|
|
194
|
+
if (props.multi)
|
|
195
|
+
el.isMultiSelect = true;
|
|
196
|
+
if (props.placeholder)
|
|
197
|
+
el.placeholder = String(props.placeholder);
|
|
198
|
+
return el;
|
|
199
|
+
}
|
|
200
|
+
function renderInput(node) {
|
|
201
|
+
const props = node.props ?? {};
|
|
202
|
+
const el = {
|
|
203
|
+
type: "Input.Text",
|
|
204
|
+
id: idFromHandler(props.onSubmit) ?? "input",
|
|
205
|
+
};
|
|
206
|
+
if (props.placeholder)
|
|
207
|
+
el.placeholder = String(props.placeholder);
|
|
208
|
+
if (props.multiline)
|
|
209
|
+
el.isMultiline = true;
|
|
210
|
+
return el;
|
|
211
|
+
}
|
|
212
|
+
/** A `<Table>` → a native Adaptive Cards `Table` (1.5). */
|
|
213
|
+
function renderTable(node) {
|
|
214
|
+
const props = node.props ?? {};
|
|
215
|
+
const cell = (text, header = false) => ({
|
|
216
|
+
type: "TableCell",
|
|
217
|
+
items: [
|
|
218
|
+
{
|
|
219
|
+
type: "TextBlock",
|
|
220
|
+
text: truncateText(text, TEAMS_LIMITS.cellText),
|
|
221
|
+
wrap: true,
|
|
222
|
+
...(header ? { weight: "Bolder" } : {}),
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
const columnsProp = props.columns;
|
|
227
|
+
const columns = columnsProp
|
|
228
|
+
? clampArray(columnsProp, TEAMS_LIMITS.tableColumns).items
|
|
229
|
+
: undefined;
|
|
230
|
+
const rows = [];
|
|
231
|
+
if (columns && columns.length > 0) {
|
|
232
|
+
rows.push({
|
|
233
|
+
type: "TableRow",
|
|
234
|
+
cells: columns.map((c) => cell(c.header, true)),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const rowNodes = childNodes(node).filter((c) => c.type === "row");
|
|
238
|
+
const { items: dataRows } = clampArray(rowNodes, TEAMS_LIMITS.tableRows);
|
|
239
|
+
for (const rowNode of dataRows) {
|
|
240
|
+
const cells = childNodes(rowNode).filter((c) => c.type === "cell");
|
|
241
|
+
rows.push({
|
|
242
|
+
type: "TableRow",
|
|
243
|
+
cells: cells.map((c) => cell(collectText(c))),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const table = {
|
|
247
|
+
type: "Table",
|
|
248
|
+
columns: (columns ?? inferColumns(rowNodes)).map((c) => ({
|
|
249
|
+
width: 1,
|
|
250
|
+
...(typeof c === "object" && "align" in c && c.align
|
|
251
|
+
? { horizontalCellContentAlignment: capitalize(c.align) }
|
|
252
|
+
: {}),
|
|
253
|
+
})),
|
|
254
|
+
rows,
|
|
255
|
+
firstRowAsHeader: !!(columns && columns.length > 0),
|
|
256
|
+
gridStyle: "default",
|
|
257
|
+
};
|
|
258
|
+
return table;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* A `<Chart>` → a native Teams chart element (`Chart.VerticalBar` /
|
|
262
|
+
* `Chart.HorizontalBar` / `Chart.Line` / `Chart.Pie` / `Chart.Donut`). These
|
|
263
|
+
* are a Teams host extension: they render in Teams clients whose app manifest
|
|
264
|
+
* opts into chart support; other Adaptive Card hosts ignore the unknown
|
|
265
|
+
* element. Data points clamp and labels/title truncate to the budget.
|
|
266
|
+
*/
|
|
267
|
+
function renderChart(node) {
|
|
268
|
+
const props = node.props ?? {};
|
|
269
|
+
const type = String(props.type ?? "verticalBar");
|
|
270
|
+
const title = props.title != null && String(props.title).length > 0
|
|
271
|
+
? truncateText(String(props.title), TEAMS_LIMITS.chartTitle)
|
|
272
|
+
: undefined;
|
|
273
|
+
const rawData = Array.isArray(props.data)
|
|
274
|
+
? props.data
|
|
275
|
+
: [];
|
|
276
|
+
const { items } = clampArray(rawData, TEAMS_LIMITS.chartDataPoints);
|
|
277
|
+
const points = items.map((p) => ({
|
|
278
|
+
label: truncateText(String(p?.label ?? ""), TEAMS_LIMITS.chartLabel),
|
|
279
|
+
value: Number.isFinite(Number(p?.value)) ? Number(p?.value) : 0,
|
|
280
|
+
}));
|
|
281
|
+
// Fields shared by every chart kind. `showTitle` is meaningless without a
|
|
282
|
+
// title; `maxWidth` keeps the chart from stretching the whole card.
|
|
283
|
+
const common = { maxWidth: "520px" };
|
|
284
|
+
if (title !== undefined) {
|
|
285
|
+
common.title = title;
|
|
286
|
+
common.showTitle = true;
|
|
287
|
+
}
|
|
288
|
+
// Axis titles apply to the cartesian charts (bar/line), not pie/donut.
|
|
289
|
+
const withAxes = (el) => {
|
|
290
|
+
if (props.xAxisTitle != null)
|
|
291
|
+
el.xAxisTitle = String(props.xAxisTitle);
|
|
292
|
+
if (props.yAxisTitle != null)
|
|
293
|
+
el.yAxisTitle = String(props.yAxisTitle);
|
|
294
|
+
return el;
|
|
295
|
+
};
|
|
296
|
+
const xy = points.map((p) => ({ x: p.label, y: p.value }));
|
|
297
|
+
const slices = points.map((p) => ({ legend: p.label, value: p.value }));
|
|
298
|
+
switch (type) {
|
|
299
|
+
case "horizontalBar":
|
|
300
|
+
return withAxes({ ...common, type: "Chart.HorizontalBar", data: xy });
|
|
301
|
+
case "line":
|
|
302
|
+
return withAxes({
|
|
303
|
+
...common,
|
|
304
|
+
type: "Chart.Line",
|
|
305
|
+
data: [{ legend: title ?? "", values: xy }],
|
|
306
|
+
});
|
|
307
|
+
case "pie":
|
|
308
|
+
return { ...common, type: "Chart.Pie", data: slices };
|
|
309
|
+
case "donut":
|
|
310
|
+
return { ...common, type: "Chart.Donut", data: slices };
|
|
311
|
+
default:
|
|
312
|
+
// verticalBar — also the fallback for any unrecognized type.
|
|
313
|
+
return withAxes({
|
|
314
|
+
...common,
|
|
315
|
+
type: "Chart.VerticalBar",
|
|
316
|
+
showBarValues: true,
|
|
317
|
+
data: xy,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** When no explicit `columns` are given, size the grid to the widest row. */
|
|
322
|
+
function inferColumns(rowNodes) {
|
|
323
|
+
let widest = 0;
|
|
324
|
+
for (const r of rowNodes) {
|
|
325
|
+
const n = childNodes(r).filter((c) => c.type === "cell").length;
|
|
326
|
+
if (n > widest)
|
|
327
|
+
widest = n;
|
|
328
|
+
}
|
|
329
|
+
return Array.from({ length: Math.min(widest, TEAMS_LIMITS.tableColumns) }, () => ({}));
|
|
330
|
+
}
|
|
331
|
+
function capitalize(s) {
|
|
332
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
333
|
+
}
|
|
334
|
+
/** Extract `{ id }` stamped onto an event prop by the action registry, if present. */
|
|
335
|
+
function idFromHandler(handler) {
|
|
336
|
+
if (handler && typeof handler === "object" && "id" in handler) {
|
|
337
|
+
const id = handler.id;
|
|
338
|
+
if (typeof id === "string")
|
|
339
|
+
return id;
|
|
340
|
+
}
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
/** The expanded `children` of an IR node as a `BotNode[]` (empty if none). */
|
|
344
|
+
function childNodes(node) {
|
|
345
|
+
const children = node.props?.children;
|
|
346
|
+
if (Array.isArray(children))
|
|
347
|
+
return children;
|
|
348
|
+
if (children &&
|
|
349
|
+
typeof children === "object" &&
|
|
350
|
+
"type" in children) {
|
|
351
|
+
return [children];
|
|
352
|
+
}
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
/** Concatenate the `value` of all descendant `text` nodes (depth-first). */
|
|
356
|
+
function collectText(node) {
|
|
357
|
+
if (typeof node.type === "string" && node.type === "text") {
|
|
358
|
+
return String(node.props?.value ?? "");
|
|
359
|
+
}
|
|
360
|
+
let acc = "";
|
|
361
|
+
for (const child of childNodes(node))
|
|
362
|
+
acc += collectText(child);
|
|
363
|
+
return acc;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Does this IR collapse to plain text (no structural or interactive elements)?
|
|
367
|
+
* Such replies are sent as a normal Teams text activity rather than wrapped in
|
|
368
|
+
* an Adaptive Card. A bare `Echo: hi` shouldn't render as a card.
|
|
369
|
+
*/
|
|
370
|
+
export function isPlainText(ir) {
|
|
371
|
+
const RICH = new Set([
|
|
372
|
+
"header",
|
|
373
|
+
"fields",
|
|
374
|
+
"field",
|
|
375
|
+
"table",
|
|
376
|
+
"row",
|
|
377
|
+
"cell",
|
|
378
|
+
"chart",
|
|
379
|
+
"image",
|
|
380
|
+
"actions",
|
|
381
|
+
"button",
|
|
382
|
+
"select",
|
|
383
|
+
"input",
|
|
384
|
+
"divider",
|
|
385
|
+
"context",
|
|
386
|
+
]);
|
|
387
|
+
const visit = (node) => {
|
|
388
|
+
if (typeof node.type === "string" && RICH.has(node.type))
|
|
389
|
+
return false;
|
|
390
|
+
return childNodes(node).every(visit);
|
|
391
|
+
};
|
|
392
|
+
return ir.every(visit);
|
|
393
|
+
}
|
|
394
|
+
/** Plain-text projection of an IR tree (depth-first text, blocks joined). */
|
|
395
|
+
export function collectPlainText(ir) {
|
|
396
|
+
return ir
|
|
397
|
+
.map((n) => collectText(n))
|
|
398
|
+
.filter((s) => s.length > 0)
|
|
399
|
+
.join("\n\n")
|
|
400
|
+
.trim();
|
|
401
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"adaptive-card.test.d.ts","sourceRoot":"","sources":["../../src/render/adaptive-card.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderAdaptiveCard, isPlainText, collectPlainText, } from "./adaptive-card.js";
|
|
3
|
+
const text = (value) => ({ type: "text", props: { value } });
|
|
4
|
+
const el = (type, children, props = {}) => ({
|
|
5
|
+
type,
|
|
6
|
+
props: { ...props, children },
|
|
7
|
+
});
|
|
8
|
+
const chart = (props) => ({
|
|
9
|
+
type: "chart",
|
|
10
|
+
props,
|
|
11
|
+
});
|
|
12
|
+
describe("renderAdaptiveCard", () => {
|
|
13
|
+
it("emits a versioned AdaptiveCard envelope", () => {
|
|
14
|
+
const card = renderAdaptiveCard([text("hi")]);
|
|
15
|
+
expect(card.type).toBe("AdaptiveCard");
|
|
16
|
+
expect(card.version).toBe("1.5");
|
|
17
|
+
expect(Array.isArray(card.body)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it("renders a header as a bold large TextBlock", () => {
|
|
20
|
+
const card = renderAdaptiveCard([el("header", [text("Title")])]);
|
|
21
|
+
expect(card.body[0]).toMatchObject({
|
|
22
|
+
type: "TextBlock",
|
|
23
|
+
text: "Title",
|
|
24
|
+
weight: "Bolder",
|
|
25
|
+
size: "Large",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it("renders section/markdown as wrapped TextBlocks", () => {
|
|
29
|
+
const card = renderAdaptiveCard([el("section", [text("Body copy")])]);
|
|
30
|
+
expect(card.body[0]).toMatchObject({
|
|
31
|
+
type: "TextBlock",
|
|
32
|
+
text: "Body copy",
|
|
33
|
+
wrap: true,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
it("renders <Fields> as a FactSet, splitting 'k: v' into title/value", () => {
|
|
37
|
+
const card = renderAdaptiveCard([
|
|
38
|
+
el("fields", [
|
|
39
|
+
el("field", [text("Status: Open")]),
|
|
40
|
+
el("field", [text("just a value")]),
|
|
41
|
+
]),
|
|
42
|
+
]);
|
|
43
|
+
expect(card.body[0]).toMatchObject({
|
|
44
|
+
type: "FactSet",
|
|
45
|
+
facts: [
|
|
46
|
+
{ title: "Status", value: "Open" },
|
|
47
|
+
{ title: "", value: "just a value" },
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it("renders a <Button> as a top-level Action.Submit carrying the opaque id", () => {
|
|
52
|
+
const card = renderAdaptiveCard([
|
|
53
|
+
el("actions", [
|
|
54
|
+
el("button", [text("Approve")], {
|
|
55
|
+
onClick: { id: "ck:approve" },
|
|
56
|
+
value: { decision: "yes" },
|
|
57
|
+
style: "primary",
|
|
58
|
+
}),
|
|
59
|
+
]),
|
|
60
|
+
]);
|
|
61
|
+
expect(card.body).toHaveLength(0);
|
|
62
|
+
expect(card.actions).toEqual([
|
|
63
|
+
{
|
|
64
|
+
type: "Action.Submit",
|
|
65
|
+
title: "Approve",
|
|
66
|
+
data: { ckActionId: "ck:approve", value: { decision: "yes" } },
|
|
67
|
+
style: "positive",
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
it("renders a url <Button> as an Action.OpenUrl", () => {
|
|
72
|
+
const card = renderAdaptiveCard([
|
|
73
|
+
el("actions", [
|
|
74
|
+
el("button", [text("Open")], { url: "https://dash/deploy/42" }),
|
|
75
|
+
]),
|
|
76
|
+
]);
|
|
77
|
+
expect(card.actions).toEqual([
|
|
78
|
+
{ type: "Action.OpenUrl", title: "Open", url: "https://dash/deploy/42" },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
it("marks a multi <Select> ChoiceSet as isMultiSelect", () => {
|
|
82
|
+
const card = renderAdaptiveCard([
|
|
83
|
+
el("select", [], {
|
|
84
|
+
multi: true,
|
|
85
|
+
onSelect: { id: "ck:pick" },
|
|
86
|
+
options: [{ label: "One", value: "1" }],
|
|
87
|
+
}),
|
|
88
|
+
]);
|
|
89
|
+
expect(card.body[0]).toMatchObject({
|
|
90
|
+
type: "Input.ChoiceSet",
|
|
91
|
+
isMultiSelect: true,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
it("renders <Select>/<Input> as body inputs", () => {
|
|
95
|
+
const card = renderAdaptiveCard([
|
|
96
|
+
el("select", [], {
|
|
97
|
+
onSelect: { id: "ck:pick" },
|
|
98
|
+
placeholder: "Choose",
|
|
99
|
+
options: [
|
|
100
|
+
{ label: "One", value: "1" },
|
|
101
|
+
{ label: "Two", value: "2" },
|
|
102
|
+
],
|
|
103
|
+
}),
|
|
104
|
+
el("input", [], { onSubmit: { id: "ck:txt" }, multiline: true }),
|
|
105
|
+
]);
|
|
106
|
+
expect(card.body[0]).toMatchObject({
|
|
107
|
+
type: "Input.ChoiceSet",
|
|
108
|
+
id: "ck:pick",
|
|
109
|
+
placeholder: "Choose",
|
|
110
|
+
choices: [
|
|
111
|
+
{ title: "One", value: "1" },
|
|
112
|
+
{ title: "Two", value: "2" },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
expect(card.body[1]).toMatchObject({
|
|
116
|
+
type: "Input.Text",
|
|
117
|
+
id: "ck:txt",
|
|
118
|
+
isMultiline: true,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
it("renders a <Table> as a native Table with a header row", () => {
|
|
122
|
+
const card = renderAdaptiveCard([
|
|
123
|
+
el("table", [el("row", [el("cell", [text("a1")]), el("cell", [text("b1")])])], {
|
|
124
|
+
columns: [{ header: "A" }, { header: "B", align: "right" }],
|
|
125
|
+
}),
|
|
126
|
+
]);
|
|
127
|
+
const table = card.body[0];
|
|
128
|
+
expect(table.type).toBe("Table");
|
|
129
|
+
expect(table.firstRowAsHeader).toBe(true);
|
|
130
|
+
const rows = table.rows;
|
|
131
|
+
expect(rows).toHaveLength(2); // header + 1 data row
|
|
132
|
+
expect(rows[0].type).toBe("TableRow");
|
|
133
|
+
});
|
|
134
|
+
it("clamps top-level actions to the Teams ceiling", () => {
|
|
135
|
+
const buttons = Array.from({ length: 10 }, (_, i) => el("button", [text(`b${i}`)], { onClick: { id: `ck:${i}` } }));
|
|
136
|
+
const card = renderAdaptiveCard([el("actions", buttons)]);
|
|
137
|
+
expect(card.actions).toHaveLength(6);
|
|
138
|
+
});
|
|
139
|
+
it("skips unknown intrinsics without throwing", () => {
|
|
140
|
+
const card = renderAdaptiveCard([el("mystery", [text("x")])]);
|
|
141
|
+
expect(card.body).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
it("renders a vertical-bar <Chart> with title and axis titles", () => {
|
|
144
|
+
const card = renderAdaptiveCard([
|
|
145
|
+
chart({
|
|
146
|
+
title: "Tickets",
|
|
147
|
+
xAxisTitle: "Month",
|
|
148
|
+
yAxisTitle: "Count",
|
|
149
|
+
data: [
|
|
150
|
+
{ label: "Jan", value: 3 },
|
|
151
|
+
{ label: "Feb", value: 7 },
|
|
152
|
+
],
|
|
153
|
+
}),
|
|
154
|
+
]);
|
|
155
|
+
expect(card.body[0]).toMatchObject({
|
|
156
|
+
type: "Chart.VerticalBar",
|
|
157
|
+
title: "Tickets",
|
|
158
|
+
showTitle: true,
|
|
159
|
+
showBarValues: true,
|
|
160
|
+
maxWidth: "520px",
|
|
161
|
+
xAxisTitle: "Month",
|
|
162
|
+
yAxisTitle: "Count",
|
|
163
|
+
data: [
|
|
164
|
+
{ x: "Jan", y: 3 },
|
|
165
|
+
{ x: "Feb", y: 7 },
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
it("defaults an absent chart type to a vertical bar", () => {
|
|
170
|
+
const card = renderAdaptiveCard([
|
|
171
|
+
chart({ title: "T", data: [{ label: "a", value: 1 }] }),
|
|
172
|
+
]);
|
|
173
|
+
expect(card.body[0].type).toBe("Chart.VerticalBar");
|
|
174
|
+
});
|
|
175
|
+
it("maps pie/donut to legend+value slices (no axes)", () => {
|
|
176
|
+
const data = [
|
|
177
|
+
{ label: "Open", value: 4 },
|
|
178
|
+
{ label: "Closed", value: 6 },
|
|
179
|
+
];
|
|
180
|
+
const pie = renderAdaptiveCard([chart({ type: "pie", data })]);
|
|
181
|
+
expect(pie.body[0]).toMatchObject({
|
|
182
|
+
type: "Chart.Pie",
|
|
183
|
+
data: [
|
|
184
|
+
{ legend: "Open", value: 4 },
|
|
185
|
+
{ legend: "Closed", value: 6 },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
expect(pie.body[0].xAxisTitle).toBeUndefined();
|
|
189
|
+
const donut = renderAdaptiveCard([chart({ type: "donut", data })]);
|
|
190
|
+
expect(donut.body[0].type).toBe("Chart.Donut");
|
|
191
|
+
});
|
|
192
|
+
it("maps line to a single legended series of values", () => {
|
|
193
|
+
const card = renderAdaptiveCard([
|
|
194
|
+
chart({
|
|
195
|
+
type: "line",
|
|
196
|
+
title: "Revenue",
|
|
197
|
+
data: [{ label: "Q1", value: 100 }],
|
|
198
|
+
}),
|
|
199
|
+
]);
|
|
200
|
+
expect(card.body[0]).toMatchObject({
|
|
201
|
+
type: "Chart.Line",
|
|
202
|
+
data: [{ legend: "Revenue", values: [{ x: "Q1", y: 100 }] }],
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
it("maps horizontalBar to an x/y series", () => {
|
|
206
|
+
const card = renderAdaptiveCard([
|
|
207
|
+
chart({ type: "horizontalBar", data: [{ label: "A", value: 5 }] }),
|
|
208
|
+
]);
|
|
209
|
+
expect(card.body[0]).toMatchObject({
|
|
210
|
+
type: "Chart.HorizontalBar",
|
|
211
|
+
data: [{ x: "A", y: 5 }],
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
it("clamps chart data points to the Teams ceiling", () => {
|
|
215
|
+
const data = Array.from({ length: 80 }, (_, i) => ({
|
|
216
|
+
label: `p${i}`,
|
|
217
|
+
value: i,
|
|
218
|
+
}));
|
|
219
|
+
const card = renderAdaptiveCard([chart({ data })]);
|
|
220
|
+
const points = card.body[0].data;
|
|
221
|
+
expect(points).toHaveLength(50);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
describe("isPlainText", () => {
|
|
225
|
+
it("is true for text-only trees", () => {
|
|
226
|
+
expect(isPlainText([text("hi")])).toBe(true);
|
|
227
|
+
expect(isPlainText([el("message", [el("section", [text("hi")])])])).toBe(true);
|
|
228
|
+
});
|
|
229
|
+
it("is false once any rich element appears", () => {
|
|
230
|
+
expect(isPlainText([el("header", [text("hi")])])).toBe(false);
|
|
231
|
+
expect(isPlainText([el("actions", [el("button", [text("x")])])])).toBe(false);
|
|
232
|
+
expect(isPlainText([el("message", [chart({ data: [] })])])).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
describe("collectPlainText", () => {
|
|
236
|
+
it("joins block text depth-first", () => {
|
|
237
|
+
const ir = [el("message", [el("section", [text("a")]), text("b")])];
|
|
238
|
+
expect(collectPlainText(ir)).toBe("ab");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming-polish layer for Teams' post-then-edit streaming.
|
|
3
|
+
*
|
|
4
|
+
* While an agent is mid-stream the buffer is usually an *unfinished* markdown
|
|
5
|
+
* document: an open code fence, an unclosed `**`, etc. Teams renders message
|
|
6
|
+
* text as markdown, so editing the message with unbalanced text makes the rest
|
|
7
|
+
* of the bubble render broken (a stray ``` turns the remainder into a code
|
|
8
|
+
* block until the closer arrives, a dangling `**` bolds the rest, and so on).
|
|
9
|
+
*
|
|
10
|
+
* `autoCloseOpenMarkdown` returns a balanced copy of the buffer by appending the
|
|
11
|
+
* minimum set of closers needed for well-formed display. When the agent later
|
|
12
|
+
* emits the real closer the buffer balances on its own and this adds nothing,
|
|
13
|
+
* so the *committed* (finalized) message has no synthetic closers.
|
|
14
|
+
*
|
|
15
|
+
* Handled, in priority order:
|
|
16
|
+
* 1. Unclosed fenced code blocks ``` (most severe: leaks code styling)
|
|
17
|
+
* 2. Unclosed inline code
|
|
18
|
+
* 3. Unclosed bold `**` / `__`, italic `*` / `_`, strike `~~`
|
|
19
|
+
*
|
|
20
|
+
* Heuristics:
|
|
21
|
+
* - A marker with no content after it (trailing `**`) is NOT closed. The
|
|
22
|
+
* agent may just be opening it; closing would flash a transient `****`.
|
|
23
|
+
* - Markers inside code/fence regions don't count.
|
|
24
|
+
* - Closers are emitted innermost-first so the structure nests correctly.
|
|
25
|
+
*/
|
|
26
|
+
export declare function autoCloseOpenMarkdown(text: string): string;
|
|
27
|
+
//# sourceMappingURL=auto-close.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auto-close.d.ts","sourceRoot":"","sources":["../../src/render/auto-close.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAOH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAkE1D"}
|