@dbx-tools/ui-mastra 0.3.28 → 0.3.29
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 +19 -0
- package/index.ts +2 -0
- package/package.json +7 -8
- package/src/react/bubbles.tsx +1 -33
- package/src/react/data-grid.tsx +2 -6
- package/src/react/markdown.tsx +3 -1
- package/src/react/mastra-chat.tsx +8 -9
- package/src/react/tool-pill.tsx +4 -23
- package/src/support/clipboard.ts +41 -0
- package/src/support/download.ts +29 -0
- package/src/support/export.ts +5 -16
- package/src/support/mastra-stream.ts +4 -7
package/README.md
CHANGED
|
@@ -204,6 +204,25 @@ resolved during export: charts are rendered to inline SVG with ECharts' server
|
|
|
204
204
|
renderer, and data markers become real tables. Expired or missing embeds are
|
|
205
205
|
skipped so old transcripts still export cleanly.
|
|
206
206
|
|
|
207
|
+
## Copying And Downloading
|
|
208
|
+
|
|
209
|
+
Copy and download affordances appear in several places - message bubbles, code
|
|
210
|
+
blocks, conversation export, and the data-grid CSV button - and they behave the
|
|
211
|
+
same everywhere because each is backed by one internal module rather than
|
|
212
|
+
per-component code.
|
|
213
|
+
|
|
214
|
+
Copying works on hosts served over plain HTTP. An insecure context has no
|
|
215
|
+
Clipboard API, so a bare `navigator.clipboard.writeText` silently does nothing on
|
|
216
|
+
a local or intranet deployment; the shared helper falls back to a hidden
|
|
217
|
+
`<textarea>` plus `document.execCommand("copy")` and reports whether the copy
|
|
218
|
+
actually succeeded. Downloads revoke their object URL after the temporary anchor
|
|
219
|
+
fires, so a long session does not accumulate blobs.
|
|
220
|
+
|
|
221
|
+
If you build a controlled surface with `ChatView`, you get both without wiring
|
|
222
|
+
anything. Adding a new copy or download button anywhere in this package should
|
|
223
|
+
reuse `src/support/clipboard.ts` and `src/support/download.ts` instead of
|
|
224
|
+
touching `navigator.clipboard` or `URL.createObjectURL` again.
|
|
225
|
+
|
|
207
226
|
## Modules
|
|
208
227
|
|
|
209
228
|
- `MastraChat` - self-contained drop-in chat component.
|
package/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ export * as reactThreadSidebar from "./src/react/thread-sidebar";
|
|
|
16
16
|
export * as reactToolPill from "./src/react/tool-pill";
|
|
17
17
|
export * as reactTypes from "./src/react/types";
|
|
18
18
|
export * as supportChartOption from "./src/support/chart-option";
|
|
19
|
+
export * as supportClipboard from "./src/support/clipboard";
|
|
20
|
+
export * as supportDownload from "./src/support/download";
|
|
19
21
|
export * as supportExport from "./src/support/export";
|
|
20
22
|
export * as supportMastraClient from "./src/support/mastra-client";
|
|
21
23
|
export * as supportMastraStream from "./src/support/mastra-stream";
|
package/package.json
CHANGED
|
@@ -20,25 +20,24 @@
|
|
|
20
20
|
"echarts-for-react": "^3.0.2",
|
|
21
21
|
"lucide-react": "^0.554.0",
|
|
22
22
|
"marked": "^18.0.5",
|
|
23
|
-
"nanoid": "^5.1.6",
|
|
24
23
|
"react": "^19.2.4",
|
|
25
24
|
"react-dom": "^19.2.4",
|
|
26
25
|
"shiki": "^3.0.0",
|
|
27
26
|
"sql-formatter": "^15.6.9",
|
|
28
27
|
"streamdown": "^2.5.0",
|
|
29
|
-
"@dbx-tools/shared-core": "0.3.
|
|
30
|
-
"@dbx-tools/shared-genie": "0.3.
|
|
31
|
-
"@dbx-tools/shared-mastra": "0.3.
|
|
32
|
-
"@dbx-tools/shared-model": "0.3.
|
|
33
|
-
"@dbx-tools/ui-appkit": "0.3.
|
|
34
|
-
"@dbx-tools/ui-branding": "0.3.
|
|
28
|
+
"@dbx-tools/shared-core": "0.3.29",
|
|
29
|
+
"@dbx-tools/shared-genie": "0.3.29",
|
|
30
|
+
"@dbx-tools/shared-mastra": "0.3.29",
|
|
31
|
+
"@dbx-tools/shared-model": "0.3.29",
|
|
32
|
+
"@dbx-tools/ui-appkit": "0.3.29",
|
|
33
|
+
"@dbx-tools/ui-branding": "0.3.29"
|
|
35
34
|
},
|
|
36
35
|
"main": "index.ts",
|
|
37
36
|
"license": "UNLICENSED",
|
|
38
37
|
"publishConfig": {
|
|
39
38
|
"access": "public"
|
|
40
39
|
},
|
|
41
|
-
"version": "0.3.
|
|
40
|
+
"version": "0.3.29",
|
|
42
41
|
"types": "index.ts",
|
|
43
42
|
"type": "module",
|
|
44
43
|
"exports": {
|
package/src/react/bubbles.tsx
CHANGED
|
@@ -27,6 +27,7 @@ import { useState } from "react";
|
|
|
27
27
|
import { MarkdownWithEmbeds } from "./embed-slots";
|
|
28
28
|
import { ExportMenu } from "./export-menu";
|
|
29
29
|
import { FeedbackControls } from "./feedback-controls";
|
|
30
|
+
import { copyText } from "../support/clipboard";
|
|
30
31
|
import { ToolMarkdown } from "./markdown";
|
|
31
32
|
import { SuggestionPills } from "./suggestion-pills";
|
|
32
33
|
import { collectSuggestions } from "./suggestions";
|
|
@@ -45,39 +46,6 @@ import type {
|
|
|
45
46
|
// the helpers that surface approval-gated tool calls out of a message's
|
|
46
47
|
// parts.
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
-
* Copy `text` to the clipboard, resolving `true` on success. Prefers the
|
|
50
|
-
* async Clipboard API, but that is only available in a secure context
|
|
51
|
-
* (HTTPS / localhost) - over plain HTTP (e.g. a LAN / ZeroTier IP) it is
|
|
52
|
-
* `undefined`, which is why a tap on mobile could silently do nothing. Falls
|
|
53
|
-
* back to a hidden `<textarea>` + `document.execCommand("copy")` so copy works
|
|
54
|
-
* off a non-secure origin too.
|
|
55
|
-
*/
|
|
56
|
-
async function copyText(text: string): Promise<boolean> {
|
|
57
|
-
try {
|
|
58
|
-
if (navigator.clipboard?.writeText) {
|
|
59
|
-
await navigator.clipboard.writeText(text);
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
} catch {
|
|
63
|
-
// Fall through to the execCommand path.
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
const area = document.createElement("textarea");
|
|
67
|
-
area.value = text;
|
|
68
|
-
area.setAttribute("readonly", "");
|
|
69
|
-
area.style.position = "fixed";
|
|
70
|
-
area.style.opacity = "0";
|
|
71
|
-
document.body.appendChild(area);
|
|
72
|
-
area.select();
|
|
73
|
-
const ok = document.execCommand("copy");
|
|
74
|
-
area.remove();
|
|
75
|
-
return ok;
|
|
76
|
-
} catch {
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
49
|
const getReasoningText = (parts: UIMessage["parts"]): string =>
|
|
82
50
|
parts
|
|
83
51
|
.filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
|
package/src/react/data-grid.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
DownloadIcon,
|
|
31
31
|
} from "lucide-react";
|
|
32
32
|
import React, { useMemo, useState } from "react";
|
|
33
|
+
import { downloadFile } from "../support/download";
|
|
33
34
|
|
|
34
35
|
// Interactive result table plus the cell/label/CSV helpers it and the
|
|
35
36
|
// markdown table renderer share. Built on `@tanstack/react-table` over
|
|
@@ -101,12 +102,7 @@ function downloadCsv(columns: string[], rows: DataRow[], filename: string): void
|
|
|
101
102
|
columns.map(escape).join(","),
|
|
102
103
|
...rows.map((row) => columns.map((c) => escape(row[c])).join(",")),
|
|
103
104
|
].join("\n");
|
|
104
|
-
|
|
105
|
-
const anchor = document.createElement("a");
|
|
106
|
-
anchor.href = url;
|
|
107
|
-
anchor.download = filename;
|
|
108
|
-
anchor.click();
|
|
109
|
-
URL.revokeObjectURL(url);
|
|
105
|
+
downloadFile(filename, csv, "text/csv;charset=utf-8");
|
|
110
106
|
}
|
|
111
107
|
|
|
112
108
|
/**
|
package/src/react/markdown.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
|
17
17
|
import { format as formatSql } from "sql-formatter";
|
|
18
18
|
import { Streamdown } from "streamdown";
|
|
19
19
|
import { DataGrid, TABLE_WRAPPER_CLASSES, colorizeDelta, type DataRow } from "./data-grid";
|
|
20
|
+
import { copyText } from "../support/clipboard";
|
|
20
21
|
import { createShikiPlugin, highlightToHtml } from "../support/shiki-plugin";
|
|
21
22
|
|
|
22
23
|
// Markdown rendering for the chat: the streaming `Streamdown` engine
|
|
@@ -307,7 +308,8 @@ const CopyButton = ({ value, className }: { value: string; className?: string })
|
|
|
307
308
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
308
309
|
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
|
|
309
310
|
const onCopy = () => {
|
|
310
|
-
void
|
|
311
|
+
void copyText(value).then((ok) => {
|
|
312
|
+
if (!ok) return;
|
|
311
313
|
setCopied(true);
|
|
312
314
|
clearTimeout(timer.current ?? undefined);
|
|
313
315
|
timer.current = setTimeout(() => setCopied(false), 1500);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { error as errorUtil, log } from "@dbx-tools/shared-core";
|
|
1
|
+
import { error as errorUtil, hash, log } from "@dbx-tools/shared-core";
|
|
2
2
|
import { feedback, type MastraThread } from "@dbx-tools/shared-mastra";
|
|
3
3
|
import { useBrand } from "@dbx-tools/ui-branding/react";
|
|
4
4
|
import type { UIMessage } from "ai";
|
|
5
|
-
import { nanoid } from "nanoid";
|
|
6
5
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
6
|
import { ChatView } from "./chat-view";
|
|
8
7
|
import { dedupeSuggestions } from "./suggestions";
|
|
@@ -61,7 +60,7 @@ const logger = log.logger("ui-mastra/chat");
|
|
|
61
60
|
const HISTORY_PAGE_SIZE = 20;
|
|
62
61
|
|
|
63
62
|
const makeUserMessage = (text: string): UIMessage => ({
|
|
64
|
-
id:
|
|
63
|
+
id: hash.id(),
|
|
65
64
|
role: "user",
|
|
66
65
|
parts: [{ type: "text", text }],
|
|
67
66
|
});
|
|
@@ -268,7 +267,7 @@ export const useMastraChat = (
|
|
|
268
267
|
const feedbackAvailable = enableFeedback && mastraClient.feedbackEnabled;
|
|
269
268
|
const threadKey = threadStorageKey(mastraClient.basePath, agentId);
|
|
270
269
|
const [activeThreadId, setActiveThreadId] = useState<string | undefined>(() =>
|
|
271
|
-
enableThreads ? (readStoredThreadId(threadKey) ??
|
|
270
|
+
enableThreads ? (readStoredThreadId(threadKey) ?? hash.id()) : undefined,
|
|
272
271
|
);
|
|
273
272
|
const {
|
|
274
273
|
threads,
|
|
@@ -743,8 +742,8 @@ export const useMastraChat = (
|
|
|
743
742
|
|
|
744
743
|
const runStream = useCallback(
|
|
745
744
|
(threadId: string, history: UIMessage[]) => {
|
|
746
|
-
const assistantId =
|
|
747
|
-
const runId =
|
|
745
|
+
const assistantId = hash.id();
|
|
746
|
+
const runId = hash.id();
|
|
748
747
|
updateSession(threadId, (session) => ({
|
|
749
748
|
...session,
|
|
750
749
|
assistantId,
|
|
@@ -917,7 +916,7 @@ export const useMastraChat = (
|
|
|
917
916
|
if (isSessionRunning(getSession(threadId))) {
|
|
918
917
|
updateSession(threadId, (session) => ({
|
|
919
918
|
...session,
|
|
920
|
-
queuedSteers: enqueueSteer(session.queuedSteers, { id:
|
|
919
|
+
queuedSteers: enqueueSteer(session.queuedSteers, { id: hash.id(), text }),
|
|
921
920
|
}));
|
|
922
921
|
return;
|
|
923
922
|
}
|
|
@@ -1026,7 +1025,7 @@ export const useMastraChat = (
|
|
|
1026
1025
|
);
|
|
1027
1026
|
|
|
1028
1027
|
const newThread = useCallback(() => {
|
|
1029
|
-
const id =
|
|
1028
|
+
const id = hash.id();
|
|
1030
1029
|
sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
|
|
1031
1030
|
notifySessions();
|
|
1032
1031
|
setActiveThreadId(id);
|
|
@@ -1058,7 +1057,7 @@ export const useMastraChat = (
|
|
|
1058
1057
|
return rest;
|
|
1059
1058
|
});
|
|
1060
1059
|
if (threadId === activeThreadId) {
|
|
1061
|
-
const id =
|
|
1060
|
+
const id = hash.id();
|
|
1062
1061
|
sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
|
|
1063
1062
|
notifySessions();
|
|
1064
1063
|
setActiveThreadId(id);
|
package/src/react/tool-pill.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { string } from "@dbx-tools/shared-core";
|
|
1
2
|
import { genieModel } from "@dbx-tools/shared-genie";
|
|
2
3
|
import {
|
|
3
4
|
Collapsible,
|
|
@@ -25,21 +26,7 @@ import type { ToolEvent, ToolProgress } from "./types";
|
|
|
25
26
|
* `ask_genie_sales` -> `Ask Genie Sales`
|
|
26
27
|
* `myCoolTool` -> `My Cool Tool`
|
|
27
28
|
*/
|
|
28
|
-
export const humanizeToolName = (toolName: string): string =>
|
|
29
|
-
toolName
|
|
30
|
-
.replace(/[._]/g, " ")
|
|
31
|
-
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
32
|
-
.split(/\s+/)
|
|
33
|
-
.filter(Boolean)
|
|
34
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
35
|
-
.join(" ");
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Capitalize the first character of a label without touching the rest
|
|
39
|
-
* (preserves `EXECUTING_QUERY`-style backend status labels).
|
|
40
|
-
*/
|
|
41
|
-
const capitalizeFirst = (s: string): string =>
|
|
42
|
-
s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
|
|
29
|
+
export const humanizeToolName = (toolName: string): string => string.toLabel(toolName);
|
|
43
30
|
|
|
44
31
|
/**
|
|
45
32
|
* Track the freshest status label a running tool has published so the
|
|
@@ -54,7 +41,7 @@ const runningLabelFor = (event: ToolEvent): string => {
|
|
|
54
41
|
.reverse()
|
|
55
42
|
.find((p): p is Extract<ToolProgress, { type: "status" }> => p.type === "status");
|
|
56
43
|
return latest
|
|
57
|
-
?
|
|
44
|
+
? string.capitalize(genieModel.humanizeStatus(latest.status))
|
|
58
45
|
: `Calling ${humanizeToolName(event.toolName)}`;
|
|
59
46
|
};
|
|
60
47
|
|
|
@@ -256,13 +243,7 @@ const askGenieQuestion = (event: ToolEvent): string | undefined => {
|
|
|
256
243
|
* `THOUGHT_TYPE_UNDERSTANDING` -> `Understanding`
|
|
257
244
|
*/
|
|
258
245
|
const humanizeThoughtType = (kind: string): string =>
|
|
259
|
-
kind
|
|
260
|
-
.replace(/^THOUGHT_TYPE_/i, "")
|
|
261
|
-
.toLowerCase()
|
|
262
|
-
.split("_")
|
|
263
|
-
.filter(Boolean)
|
|
264
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
265
|
-
.join(" ");
|
|
246
|
+
string.toLabel(kind.replace(/^THOUGHT_TYPE_/i, ""));
|
|
266
247
|
|
|
267
248
|
/**
|
|
268
249
|
* Genie attaches one of three payload kinds per attachment slot:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clipboard write that also works off a non-secure origin.
|
|
3
|
+
*
|
|
4
|
+
* `navigator.clipboard` only exists in a secure context (HTTPS / localhost).
|
|
5
|
+
* Over plain HTTP - a LAN IP, a ZeroTier address, a port-forwarded dev host -
|
|
6
|
+
* it is `undefined`, so a copy button wired straight to it silently does
|
|
7
|
+
* nothing. {@link copyText} falls back to a hidden `<textarea>` +
|
|
8
|
+
* `document.execCommand("copy")`, which is deprecated but still the only thing
|
|
9
|
+
* that works there.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Copy `text` to the clipboard, returning whether it succeeded so the caller
|
|
16
|
+
* can decide whether to show a confirmation.
|
|
17
|
+
*/
|
|
18
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
19
|
+
try {
|
|
20
|
+
if (navigator.clipboard?.writeText) {
|
|
21
|
+
await navigator.clipboard.writeText(text);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// Permission denied or non-secure context; fall through to execCommand.
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const area = document.createElement("textarea");
|
|
29
|
+
area.value = text;
|
|
30
|
+
area.setAttribute("readonly", "");
|
|
31
|
+
area.style.position = "fixed";
|
|
32
|
+
area.style.opacity = "0";
|
|
33
|
+
document.body.appendChild(area);
|
|
34
|
+
area.select();
|
|
35
|
+
const ok = document.execCommand("copy");
|
|
36
|
+
area.remove();
|
|
37
|
+
return ok;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triggering a browser download of an in-memory file.
|
|
3
|
+
*
|
|
4
|
+
* The chat surface exports from two places - the transcript exporter
|
|
5
|
+
* (markdown / HTML) and the data-grid CSV button - which both need the same
|
|
6
|
+
* blob-URL + synthetic-anchor dance, including revoking the URL so repeated
|
|
7
|
+
* exports do not leak object URLs.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Download `content` as `filename`. `mime` should include the charset for text
|
|
14
|
+
* formats (e.g. `"text/csv;charset=utf-8"`) so the file opens correctly.
|
|
15
|
+
*
|
|
16
|
+
* The anchor is attached to the document before clicking because Firefox
|
|
17
|
+
* ignores a click on a detached element, and the object URL is revoked on a
|
|
18
|
+
* delay so the download has started before the blob is released.
|
|
19
|
+
*/
|
|
20
|
+
export function downloadFile(filename: string, content: BlobPart, mime: string): void {
|
|
21
|
+
const url = URL.createObjectURL(new Blob([content], { type: mime }));
|
|
22
|
+
const anchor = document.createElement("a");
|
|
23
|
+
anchor.href = url;
|
|
24
|
+
anchor.download = filename;
|
|
25
|
+
document.body.appendChild(anchor);
|
|
26
|
+
anchor.click();
|
|
27
|
+
anchor.remove();
|
|
28
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
29
|
+
}
|
package/src/support/export.ts
CHANGED
|
@@ -29,6 +29,7 @@ import type { UIMessage } from "ai";
|
|
|
29
29
|
import * as echarts from "echarts";
|
|
30
30
|
import { marked } from "marked";
|
|
31
31
|
import { normalizeChartOption } from "./chart-option";
|
|
32
|
+
import { downloadFile } from "./download";
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Output formats {@link exportChat} can produce.
|
|
@@ -114,7 +115,7 @@ export async function exportChat(options: ExportChatOptions): Promise<void> {
|
|
|
114
115
|
|
|
115
116
|
if (format === "markdown") {
|
|
116
117
|
const md = await buildMarkdown(messages, resolver, title, userLabel);
|
|
117
|
-
|
|
118
|
+
downloadFile(`${stem}.md`, md, "text/markdown;charset=utf-8");
|
|
118
119
|
return;
|
|
119
120
|
}
|
|
120
121
|
|
|
@@ -132,7 +133,7 @@ export async function exportChat(options: ExportChatOptions): Promise<void> {
|
|
|
132
133
|
*/
|
|
133
134
|
function printViaHiddenIframe(html: string, downloadName: string): void {
|
|
134
135
|
if (typeof document === "undefined" || !document.body) {
|
|
135
|
-
|
|
136
|
+
downloadFile(downloadName, html, "text/html;charset=utf-8");
|
|
136
137
|
return;
|
|
137
138
|
}
|
|
138
139
|
const iframe = document.createElement("iframe");
|
|
@@ -156,7 +157,7 @@ function printViaHiddenIframe(html: string, downloadName: string): void {
|
|
|
156
157
|
const win = iframe.contentWindow;
|
|
157
158
|
if (!win) {
|
|
158
159
|
iframe.remove();
|
|
159
|
-
|
|
160
|
+
downloadFile(downloadName, html, "text/html;charset=utf-8");
|
|
160
161
|
return;
|
|
161
162
|
}
|
|
162
163
|
// Settle so charts / fonts lay out, then print. `afterprint` removes the
|
|
@@ -172,7 +173,7 @@ function printViaHiddenIframe(html: string, downloadName: string): void {
|
|
|
172
173
|
win.print();
|
|
173
174
|
} catch {
|
|
174
175
|
cleanup();
|
|
175
|
-
|
|
176
|
+
downloadFile(downloadName, html, "text/html;charset=utf-8");
|
|
176
177
|
}
|
|
177
178
|
}, PRINT_SETTLE_MS);
|
|
178
179
|
};
|
|
@@ -443,18 +444,6 @@ function slugify(value: string): string {
|
|
|
443
444
|
return string.toSlugWithOptions({ maxLength: 60 }, value) || "conversation";
|
|
444
445
|
}
|
|
445
446
|
|
|
446
|
-
/** Trigger a browser download of an in-memory text file. */
|
|
447
|
-
function downloadTextFile(filename: string, content: string, mime: string): void {
|
|
448
|
-
const url = URL.createObjectURL(new Blob([content], { type: mime }));
|
|
449
|
-
const anchor = document.createElement("a");
|
|
450
|
-
anchor.href = url;
|
|
451
|
-
anchor.download = filename;
|
|
452
|
-
document.body.appendChild(anchor);
|
|
453
|
-
anchor.click();
|
|
454
|
-
anchor.remove();
|
|
455
|
-
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
447
|
/**
|
|
459
448
|
* A brand color / font value safe to interpolate into a `<style>` block.
|
|
460
449
|
* Brand colors are hex-validated upstream, but the font stack is only checked
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { json } from "@dbx-tools/shared-core";
|
|
2
|
+
|
|
1
3
|
/** One chunk from a Mastra agent SSE stream (`data: { type, payload, ... }`). */
|
|
2
4
|
export interface MastraStreamChunk {
|
|
3
5
|
type: string;
|
|
@@ -40,13 +42,8 @@ export async function processMastraStream(options: {
|
|
|
40
42
|
if (!line.startsWith("data: ")) continue;
|
|
41
43
|
const data = line.slice(6);
|
|
42
44
|
if (data === "[DONE]") return;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
json = JSON.parse(data) as MastraStreamChunk;
|
|
46
|
-
} catch {
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
if (json) await options.onChunk(json);
|
|
45
|
+
const chunk = json.parse<MastraStreamChunk>(data);
|
|
46
|
+
if (chunk) await options.onChunk(chunk);
|
|
50
47
|
}
|
|
51
48
|
}
|
|
52
49
|
} finally {
|