@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.
- package/README.md +198 -0
- package/index.ts +32 -0
- package/package.json +62 -0
- package/src/react/bubbles.tsx +428 -0
- package/src/react/chat-view.tsx +574 -0
- package/src/react/data-grid.tsx +342 -0
- package/src/react/embed-slots.tsx +258 -0
- package/src/react/export-menu.tsx +71 -0
- package/src/react/feedback-controls.tsx +139 -0
- package/src/react/index.ts +42 -0
- package/src/react/markdown.tsx +387 -0
- package/src/react/mastra-chat.tsx +1376 -0
- package/src/react/suggestion-pills.tsx +46 -0
- package/src/react/suggestions.ts +109 -0
- package/src/react/thread-sidebar.tsx +315 -0
- package/src/react/tool-pill.tsx +684 -0
- package/src/react/types.ts +295 -0
- package/src/styles.css +22 -0
- package/src/support/chart-option.ts +130 -0
- package/src/support/export.ts +485 -0
- package/src/support/mastra-client.ts +883 -0
- package/src/support/mastra-stream.ts +66 -0
- package/src/support/shiki-plugin.ts +134 -0
- package/src/support/thread-sessions.ts +55 -0
- package/tsconfig.json +43 -0
|
@@ -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
|
+
`;
|