@dbx-tools/ui-mastra 0.1.9

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.
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Client-side chat export.
3
+ *
4
+ * Turns chat messages into a portable, self-contained document at either
5
+ * the single-message or the whole-conversation level, in one of two
6
+ * formats:
7
+ *
8
+ * - `"pdf"` - a print-ready HTML document opened in a new tab with
9
+ * the browser's print dialog triggered, so the user saves
10
+ * a real PDF ("Save as PDF"). Renders reliably offline.
11
+ * - `"markdown"` - a `.md` file download.
12
+ *
13
+ * Charts are included, not dropped: each `[chart:<id>]` marker is
14
+ * resolved against the plugin's chart cache and rendered to an inline
15
+ * SVG via Echarts' server-side renderer (no DOM needed), so a PDF/HTML
16
+ * export carries the chart itself rather than a placeholder.
17
+ * `[data:<id>]` markers resolve to a real table. Unresolved / expired
18
+ * ids are skipped so the surrounding prose stays clean.
19
+ */
20
+
21
+ import {
22
+ marker as markers,
23
+ type Chart,
24
+ type StatementData,
25
+ } from "@dbx-tools/shared-mastra";
26
+ import { string } from "@dbx-tools/shared-core";
27
+ import type { UIMessage } from "ai";
28
+ import * as echarts from "echarts";
29
+ import { marked } from "marked";
30
+ import { normalizeChartOption } from "./chart-option";
31
+
32
+ /** Output formats {@link exportChat} can produce. */
33
+ export type ExportFormat = "pdf" | "markdown";
34
+
35
+ /**
36
+ * Resolves the embeds referenced by `[chart:<id>]` / `[data:<id>]`
37
+ * markers in message prose. Satisfied by `MastraPluginClient` (its
38
+ * `chart` / `statement` methods) - the driver passes those straight
39
+ * through.
40
+ */
41
+ export interface EmbedResolver {
42
+ chart(id: string): Promise<Chart | undefined>;
43
+ statement(id: string): Promise<StatementData | undefined>;
44
+ }
45
+
46
+ /** Options accepted by {@link exportChat}. */
47
+ export interface ExportChatOptions {
48
+ /** Messages to export, oldest first (one entry for a message-level export). */
49
+ messages: UIMessage[];
50
+ /** Target format. */
51
+ format: ExportFormat;
52
+ /** Embed resolver used to inline charts and data tables. */
53
+ resolver: EmbedResolver;
54
+ /** Document title / heading. Defaults to `"Conversation"`. */
55
+ title?: string;
56
+ /** Download filename stem (no extension). Defaults to a slug of the title. */
57
+ filename?: string;
58
+ /**
59
+ * Label for the human speaker (the `user` role). Defaults to
60
+ * `"User"`; callers that know the signed-in identity pass a resource
61
+ * id / email form like `"User (someone@example.com)"`.
62
+ */
63
+ userLabel?: string;
64
+ }
65
+
66
+ /** Fixed Echarts SSR canvas; the SVG scales to the print column via CSS. */
67
+ const CHART_WIDTH_PX = 760;
68
+ const CHART_HEIGHT_PX = 380;
69
+
70
+ /** Delay before firing `print()` so the new tab lays the document out first. */
71
+ const PRINT_SETTLE_MS = 300;
72
+
73
+ /**
74
+ * Export `messages` as a PDF (via a print-ready tab) or a Markdown file.
75
+ *
76
+ * For `"pdf"` a new tab is opened **synchronously** (before any async
77
+ * embed resolution) so browser popup blockers - which only allow
78
+ * `window.open` inside a user gesture - don't swallow it; the resolved
79
+ * document is written in once charts / tables are ready. If the popup is
80
+ * blocked anyway, the HTML is downloaded as a file instead so the export
81
+ * still succeeds.
82
+ */
83
+ export async function exportChat(options: ExportChatOptions): Promise<void> {
84
+ const { messages, format, resolver } = options;
85
+ const title = options.title?.trim() || "Conversation";
86
+ const stem = options.filename?.trim() || slugify(title);
87
+ const userLabel = options.userLabel?.trim() || "User";
88
+
89
+ if (format === "markdown") {
90
+ const md = await buildMarkdown(messages, resolver, title, userLabel);
91
+ downloadTextFile(`${stem}.md`, md, "text/markdown;charset=utf-8");
92
+ return;
93
+ }
94
+
95
+ // Open the tab up-front (user-gesture safe), show a placeholder while
96
+ // embeds resolve, then swap in the finished document and print.
97
+ const win = window.open("", "_blank");
98
+ win?.document.write(PREPARING_HTML);
99
+ const html = await buildHtmlDocument(messages, resolver, title, userLabel);
100
+ if (!win) {
101
+ // Popup blocked: fall back to an HTML file download so the export
102
+ // (charts included) still lands.
103
+ downloadTextFile(`${stem}.html`, html, "text/html;charset=utf-8");
104
+ return;
105
+ }
106
+ win.document.open();
107
+ win.document.write(html);
108
+ win.document.close();
109
+ win.focus();
110
+ win.setTimeout(() => win.print(), PRINT_SETTLE_MS);
111
+ }
112
+
113
+ /* ------------------------------- segments -------------------------------- */
114
+
115
+ /** One slice of a message: prose, a chart embed, or a data embed. */
116
+ type Segment =
117
+ | { kind: "text"; text: string }
118
+ | { kind: "chart"; id: string }
119
+ | { kind: "data"; id: string };
120
+
121
+ /**
122
+ * Split message text into prose / chart / data segments at
123
+ * `[chart:<uuid>]` / `[data:<uuid>]` marker positions. Mirrors the live
124
+ * `MarkdownWithEmbeds` splitter so an export matches what's on screen:
125
+ * non-UUID (fabricated) ids and unknown marker types collapse away so no
126
+ * literal `[type:...]` leaks into the output.
127
+ */
128
+ function splitSegments(text: string): Segment[] {
129
+ const segments: Segment[] = [];
130
+ let last = 0;
131
+ for (const marker of markers.parseMarkers(text)) {
132
+ if (marker.start > last) {
133
+ segments.push({ kind: "text", text: text.slice(last, marker.start) });
134
+ }
135
+ if (markers.isUuid(marker.id) && (marker.type === "chart" || marker.type === "data")) {
136
+ segments.push({ kind: marker.type, id: marker.id });
137
+ }
138
+ last = marker.end;
139
+ }
140
+ if (last < text.length) segments.push({ kind: "text", text: text.slice(last) });
141
+ return segments;
142
+ }
143
+
144
+ /** Concatenate a message's `text` parts (matches the on-screen bubbles). */
145
+ function messageText(message: UIMessage): string {
146
+ return message.parts
147
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
148
+ .map((p) => p.text)
149
+ .join("");
150
+ }
151
+
152
+ /**
153
+ * Human label for a message role. The `user` role uses `userLabel`
154
+ * (the signed-in identity, e.g. `"User (someone@example.com)"`);
155
+ * `assistant` is fixed; any other role is title-cased.
156
+ */
157
+ function roleLabel(role: UIMessage["role"], userLabel: string): string {
158
+ if (role === "user") return userLabel;
159
+ if (role === "assistant") return "Assistant";
160
+ return role.charAt(0).toUpperCase() + role.slice(1);
161
+ }
162
+
163
+ /* --------------------------------- HTML ---------------------------------- */
164
+
165
+ /** Build the full standalone HTML document string. */
166
+ async function buildHtmlDocument(
167
+ messages: UIMessage[],
168
+ resolver: EmbedResolver,
169
+ title: string,
170
+ userLabel: string,
171
+ ): Promise<string> {
172
+ const articles: string[] = [];
173
+ for (const message of messages) {
174
+ const body = await messageBodyHtml(message, resolver);
175
+ if (!body) continue;
176
+ articles.push(
177
+ `<article class="msg msg-${escapeHtml(message.role)}">` +
178
+ `<div class="role">${escapeHtml(roleLabel(message.role, userLabel))}</div>` +
179
+ `<div class="content">${body}</div></article>`,
180
+ );
181
+ }
182
+ return (
183
+ `<!doctype html><html lang="en"><head><meta charset="utf-8">` +
184
+ `<meta name="viewport" content="width=device-width, initial-scale=1">` +
185
+ `<title>${escapeHtml(title)}</title><style>${PRINT_CSS}</style></head>` +
186
+ `<body><header class="doc-header"><h1>${escapeHtml(title)}</h1>` +
187
+ `<div class="doc-meta">Exported ${escapeHtml(new Date().toLocaleString())}</div>` +
188
+ `</header><main>${articles.join("")}</main></body></html>`
189
+ );
190
+ }
191
+
192
+ /** Render one message's body (prose + inlined charts / tables) to HTML. */
193
+ async function messageBodyHtml(
194
+ message: UIMessage,
195
+ resolver: EmbedResolver,
196
+ ): Promise<string> {
197
+ const isAssistant = message.role === "assistant";
198
+ const parts: string[] = [];
199
+ for (const seg of splitSegments(messageText(message))) {
200
+ if (seg.kind === "text") {
201
+ if (!seg.text.trim()) continue;
202
+ parts.push(
203
+ isAssistant
204
+ ? markdownToHtml(seg.text)
205
+ : `<p class="plain">${escapeHtml(seg.text)}</p>`,
206
+ );
207
+ continue;
208
+ }
209
+ if (seg.kind === "chart") {
210
+ const svg = await chartSvg(resolver, seg.id);
211
+ if (svg) parts.push(`<div class="embed embed-chart">${svg}</div>`);
212
+ continue;
213
+ }
214
+ const table = await dataTableHtml(resolver, seg.id);
215
+ if (table) parts.push(table);
216
+ }
217
+ return parts.join("\n");
218
+ }
219
+
220
+ /** Render a markdown fragment to an HTML string (GFM tables, line breaks). */
221
+ function markdownToHtml(md: string): string {
222
+ return marked.parse(md, { async: false, gfm: true, breaks: true }) as string;
223
+ }
224
+
225
+ /**
226
+ * Resolve a chart id and render its Echarts spec to an inline SVG string
227
+ * via server-side rendering (no DOM). The JSON-safe planner spec is run
228
+ * through {@link normalizeChartOption} first - same as the live inline
229
+ * chart - so the export gets compact value ticks, conventionally-placed
230
+ * axis names, and legible category labels rather than a raw spec. Returns
231
+ * `null` when the id is unknown / expired / still processing, or if
232
+ * rendering throws.
233
+ */
234
+ async function chartSvg(resolver: EmbedResolver, id: string): Promise<string | null> {
235
+ try {
236
+ const chart = await resolver.chart(id);
237
+ if (!chart?.result) return null;
238
+ const instance = echarts.init(null, undefined, {
239
+ renderer: "svg",
240
+ ssr: true,
241
+ width: CHART_WIDTH_PX,
242
+ height: CHART_HEIGHT_PX,
243
+ });
244
+ try {
245
+ instance.setOption(
246
+ normalizeChartOption(chart.result.option) as echarts.EChartsCoreOption,
247
+ );
248
+ return instance.renderToSVGString();
249
+ } finally {
250
+ instance.dispose();
251
+ }
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /** Resolve a data id and render its rows to an HTML table. `null` on miss. */
258
+ async function dataTableHtml(
259
+ resolver: EmbedResolver,
260
+ id: string,
261
+ ): Promise<string | null> {
262
+ const data = await safeStatement(resolver, id);
263
+ if (!data || data.rows.length === 0) return null;
264
+ const head = data.columns
265
+ .map((c) => `<th>${escapeHtml(humanizeHeader(c))}</th>`)
266
+ .join("");
267
+ const body = data.rows
268
+ .map(
269
+ (row) =>
270
+ `<tr>${data.columns
271
+ .map((c) => `<td>${escapeHtml(cellText(row[c]))}</td>`)
272
+ .join("")}</tr>`,
273
+ )
274
+ .join("");
275
+ const note = data.truncated
276
+ ? `<div class="embed-note">Showing ${data.rows.length} of ${data.rowCount} rows.</div>`
277
+ : "";
278
+ return (
279
+ `<div class="embed embed-table"><table><thead><tr>${head}</tr></thead>` +
280
+ `<tbody>${body}</tbody></table>${note}</div>`
281
+ );
282
+ }
283
+
284
+ /* ------------------------------- Markdown -------------------------------- */
285
+
286
+ /** Build the whole document as a Markdown string. */
287
+ async function buildMarkdown(
288
+ messages: UIMessage[],
289
+ resolver: EmbedResolver,
290
+ title: string,
291
+ userLabel: string,
292
+ ): Promise<string> {
293
+ const blocks: string[] = [`# ${title}`, `_Exported ${new Date().toLocaleString()}_`];
294
+ for (const message of messages) {
295
+ const body = await messageBodyMarkdown(message, resolver);
296
+ if (!body.trim()) continue;
297
+ blocks.push(`## ${roleLabel(message.role, userLabel)}`);
298
+ blocks.push(body);
299
+ }
300
+ return `${blocks.join("\n\n")}\n`;
301
+ }
302
+
303
+ /** Render one message's body to Markdown (charts noted, tables as GFM). */
304
+ async function messageBodyMarkdown(
305
+ message: UIMessage,
306
+ resolver: EmbedResolver,
307
+ ): Promise<string> {
308
+ const parts: string[] = [];
309
+ for (const seg of splitSegments(messageText(message))) {
310
+ if (seg.kind === "text") {
311
+ if (seg.text.trim()) parts.push(seg.text.trim());
312
+ continue;
313
+ }
314
+ if (seg.kind === "chart") {
315
+ const chart = await safeChart(resolver, seg.id);
316
+ if (chart?.result) parts.push(`> **Chart:** ${chartTitle(chart)}`);
317
+ continue;
318
+ }
319
+ const table = await dataTableMarkdown(resolver, seg.id);
320
+ if (table) parts.push(table);
321
+ }
322
+ return parts.join("\n\n");
323
+ }
324
+
325
+ /** Best-effort chart title from the Echarts spec, for the Markdown note. */
326
+ function chartTitle(chart: Chart): string {
327
+ const option = chart.result?.option as { title?: unknown } | undefined;
328
+ const title = option?.title;
329
+ const text =
330
+ (Array.isArray(title) ? title[0]?.text : (title as { text?: unknown })?.text) ??
331
+ undefined;
332
+ const label = typeof text === "string" ? text.trim() : "";
333
+ return label || `${chart.result?.chartType ?? "chart"} chart`;
334
+ }
335
+
336
+ /** Render statement rows to a GFM table. `null` on miss / empty. */
337
+ async function dataTableMarkdown(
338
+ resolver: EmbedResolver,
339
+ id: string,
340
+ ): Promise<string | null> {
341
+ const data = await safeStatement(resolver, id);
342
+ if (!data || data.rows.length === 0) return null;
343
+ const header = `| ${data.columns.map((c) => mdCell(humanizeHeader(c))).join(" | ")} |`;
344
+ const sep = `| ${data.columns.map(() => "---").join(" | ")} |`;
345
+ const rows = data.rows.map(
346
+ (row) => `| ${data.columns.map((c) => mdCell(cellText(row[c]))).join(" | ")} |`,
347
+ );
348
+ const note = data.truncated
349
+ ? `\n\n_Showing ${data.rows.length} of ${data.rowCount} rows._`
350
+ : "";
351
+ return `${[header, sep, ...rows].join("\n")}${note}`;
352
+ }
353
+
354
+ /* -------------------------------- helpers -------------------------------- */
355
+
356
+ /** Resolve a chart id, swallowing errors (best-effort export). */
357
+ async function safeChart(
358
+ resolver: EmbedResolver,
359
+ id: string,
360
+ ): Promise<Chart | undefined> {
361
+ try {
362
+ return await resolver.chart(id);
363
+ } catch {
364
+ return undefined;
365
+ }
366
+ }
367
+
368
+ /** Resolve a statement id, swallowing errors (best-effort export). */
369
+ async function safeStatement(
370
+ resolver: EmbedResolver,
371
+ id: string,
372
+ ): Promise<StatementData | undefined> {
373
+ try {
374
+ return await resolver.statement(id);
375
+ } catch {
376
+ return undefined;
377
+ }
378
+ }
379
+
380
+ /** Stringify a table cell value for display. */
381
+ function cellText(value: unknown): string {
382
+ if (value == null) return "";
383
+ if (typeof value === "object") return JSON.stringify(value);
384
+ return String(value);
385
+ }
386
+
387
+ /** Escape a Markdown table cell (pipes / newlines would break the row). */
388
+ function mdCell(value: string): string {
389
+ return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
390
+ }
391
+
392
+ /** Title-case a snake/kebab/camel column name for a header label. */
393
+ function humanizeHeader(name: string): string {
394
+ const tokens = [
395
+ ...string.tokenizeWithOptions({ lowerCase: true, capitalize: true }, name),
396
+ ];
397
+ return tokens.length > 0 ? tokens.join(" ") : name;
398
+ }
399
+
400
+ /** Escape HTML-significant characters (from the shared string utils). */
401
+ const escapeHtml = string.escapeHtml;
402
+
403
+ /** Turn a title into a safe, lowercase filename stem. */
404
+ function slugify(value: string): string {
405
+ const slug = value
406
+ .toLowerCase()
407
+ .replace(/[^a-z0-9]+/g, "-")
408
+ .replace(/^-+|-+$/g, "")
409
+ .slice(0, 60);
410
+ return slug || "conversation";
411
+ }
412
+
413
+ /** Trigger a browser download of an in-memory text file. */
414
+ function downloadTextFile(filename: string, content: string, mime: string): void {
415
+ const url = URL.createObjectURL(new Blob([content], { type: mime }));
416
+ const anchor = document.createElement("a");
417
+ anchor.href = url;
418
+ anchor.download = filename;
419
+ document.body.appendChild(anchor);
420
+ anchor.click();
421
+ anchor.remove();
422
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
423
+ }
424
+
425
+ /** Placeholder shown in the print tab while embeds resolve. */
426
+ const PREPARING_HTML =
427
+ '<!doctype html><html><head><meta charset="utf-8"><title>Preparing export...</title>' +
428
+ "<style>body{font:14px system-ui,sans-serif;color:#475569;display:flex;" +
429
+ "height:100vh;margin:0;align-items:center;justify-content:center}</style></head>" +
430
+ "<body>Preparing export...</body></html>";
431
+
432
+ /**
433
+ * Inline stylesheet for the exported document. Tuned for print:
434
+ *
435
+ * - A readable measure with role-labelled message blocks.
436
+ * - Content flows naturally across pages: whole messages are NOT
437
+ * `break-inside: avoid` (a long turn that couldn't fit the
438
+ * remaining space would otherwise be shoved to the next page,
439
+ * leaving the current one half-blank). Only atomic units stay
440
+ * unbroken - a chart image, a code block, and individual table
441
+ * rows - while long tables split across pages and repeat their
442
+ * header (`thead { display: table-header-group }`).
443
+ * - `@page { margin: 0 }` collapses the page margin box so the
444
+ * browser's own print header/footer (the `about:blank` URL, the
445
+ * date, and `n/N` page numbers) have nowhere to render and drop
446
+ * out; the visible page margin is re-supplied as body padding.
447
+ */
448
+ const PRINT_CSS = `
449
+ :root { color-scheme: light; }
450
+ * { box-sizing: border-box; }
451
+ body {
452
+ font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
453
+ color: #0f172a; margin: 0; padding: 32px; background: #fff;
454
+ }
455
+ main { max-width: 820px; margin: 0 auto; }
456
+ .doc-header { max-width: 820px; margin: 0 auto 24px; border-bottom: 1px solid #e2e8f0; padding-bottom: 12px; }
457
+ .doc-header h1 { font-size: 22px; margin: 0 0 4px; }
458
+ .doc-meta { font-size: 12px; color: #64748b; }
459
+ .msg { margin: 0 0 20px; }
460
+ .msg .role { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #64748b; margin-bottom: 4px; break-after: avoid; }
461
+ .msg-user .content { background: #f1f5f9; border-radius: 8px; padding: 10px 14px; }
462
+ .msg .content > *:first-child { margin-top: 0; }
463
+ .msg .content > *:last-child { margin-bottom: 0; }
464
+ .plain { white-space: pre-wrap; margin: 0; }
465
+ p { margin: 0 0 10px; }
466
+ h1, h2, h3, h4 { line-height: 1.3; margin: 18px 0 8px; break-after: avoid; break-inside: avoid; }
467
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; background: #f1f5f9; padding: .1em .3em; border-radius: 4px; }
468
+ pre { background: #0f172a; color: #e2e8f0; padding: 12px 14px; border-radius: 8px; overflow-x: auto; break-inside: avoid; }
469
+ pre code { background: none; padding: 0; color: inherit; }
470
+ a { color: #2563eb; }
471
+ .embed { margin: 12px 0; }
472
+ .embed-chart { border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; break-inside: avoid; }
473
+ .embed-chart svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
474
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
475
+ thead { display: table-header-group; }
476
+ tr { break-inside: avoid; }
477
+ th, td { border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; vertical-align: top; }
478
+ th { background: #f8fafc; font-weight: 600; }
479
+ .embed-note { font-size: 12px; color: #64748b; margin-top: 6px; }
480
+ @page { margin: 0; }
481
+ @media print {
482
+ body { padding: 16mm 18mm; }
483
+ a { color: inherit; text-decoration: none; }
484
+ }
485
+ `;