@dbx-tools/ui-mastra 0.9.111 → 0.9.116
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/index.ts +3 -4
- package/package.json +7 -7
- package/src/react/_image-attachments.ts +112 -0
- package/src/react/bubbles.tsx +20 -2
- package/src/react/chat-composer.tsx +114 -8
- package/src/react/chat-transcript.tsx +3 -8
- package/src/react/index.ts +1 -0
- package/src/react/markdown.tsx +3 -44
- package/src/react/mastra-chat.tsx +18 -10
- package/src/react/tool-pill.tsx +60 -58
- package/src/react/types.ts +10 -8
- package/src/support/index.ts +26 -0
- package/src/support/mastra-client.ts +4 -0
- package/src/support/_shiki-runtime.ts +0 -27
- package/src/support/shiki-plugin.ts +0 -158
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
4
|
|
|
5
5
|
export const PACKAGE_IDENTIFIER = "@dbx-tools/ui-mastra";
|
|
6
|
-
export const PACKAGE_VERSION = "0.9.
|
|
6
|
+
export const PACKAGE_VERSION = "0.9.116";
|
|
7
7
|
export * as reactBubbles from "./src/react/bubbles.tsx";
|
|
8
8
|
export * as reactChatApprovals from "./src/react/chat-approvals.ts";
|
|
9
9
|
export * as reactChatComposer from "./src/react/chat-composer.tsx";
|
|
@@ -35,7 +35,6 @@ export * as supportExport from "./src/support/export.ts";
|
|
|
35
35
|
export * as supportMastraClient from "./src/support/mastra-client.ts";
|
|
36
36
|
export * as supportModelSelection from "./src/support/model-selection.ts";
|
|
37
37
|
export * as supportRequestContext from "./src/support/request-context.ts";
|
|
38
|
-
export * as supportShikiPlugin from "./src/support/shiki-plugin.ts";
|
|
39
38
|
export * as supportThreadLabels from "./src/support/thread-labels.ts";
|
|
40
39
|
export * as supportThreadSessions from "./src/support/thread-sessions.ts";
|
|
41
40
|
export * as supportThreadTabs from "./src/support/thread-tabs.ts";
|
|
@@ -52,7 +51,7 @@ export type { DataRow } from "./src/react/data-grid.tsx";
|
|
|
52
51
|
export { MarkdownWithEmbeds } from "./src/react/embed-slots.tsx";
|
|
53
52
|
export { ExportMenu } from "./src/react/export-menu.tsx";
|
|
54
53
|
export { FeedbackControls } from "./src/react/feedback-controls.tsx";
|
|
55
|
-
export { AssistantMarkdown, ToolMarkdown, SqlBlock,
|
|
54
|
+
export { AssistantMarkdown, ToolMarkdown, SqlBlock, SourceBlock } from "./src/react/markdown.tsx";
|
|
56
55
|
export type { MastraAssistantMode, MastraAssistantSide, MastraAssistantLauncherPosition, MastraAssistantResizeOptions, MastraAssistantLauncherOptions, MastraAssistantController, MastraAssistantProps } from "./src/react/mastra-assistant.tsx";
|
|
57
56
|
export { useMastraChat, MastraChat } from "./src/react/mastra-chat.tsx";
|
|
58
57
|
export type { UseMastraChatOptions, MastraChatProps } from "./src/react/mastra-chat.tsx";
|
|
@@ -65,7 +64,7 @@ export { ThreadActions, ThreadTabs } from "./src/react/thread-tabs.tsx";
|
|
|
65
64
|
export type { ThreadTabsProps, ThreadActionsProps } from "./src/react/thread-tabs.tsx";
|
|
66
65
|
export { humanizeToolName, ToolSessionPill } from "./src/react/tool-pill.tsx";
|
|
67
66
|
export type { ToolInputPresentation, WebSearchProgressGroup } from "./src/react/tool-pill.tsx";
|
|
68
|
-
export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ThreadPlacement, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types.ts";
|
|
67
|
+
export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, ChatInputMessage, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ThreadPlacement, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types.ts";
|
|
69
68
|
export { LIGHT_CHART_CHROME } from "./src/support/chart-theme.ts";
|
|
70
69
|
export type { ChartChrome } from "./src/support/chart-theme.ts";
|
|
71
70
|
export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./src/support/export.ts";
|
package/package.json
CHANGED
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"react-dom": "~19.2.4"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@dbx-tools/shared-core": "^0.9.
|
|
33
|
-
"@dbx-tools/shared-genie": "^0.9.
|
|
34
|
-
"@dbx-tools/shared-mastra": "^0.9.
|
|
35
|
-
"@dbx-tools/shared-model": "^0.9.
|
|
36
|
-
"@dbx-tools/ui": "^0.9.
|
|
32
|
+
"@dbx-tools/shared-core": "^0.9.116",
|
|
33
|
+
"@dbx-tools/shared-genie": "^0.9.116",
|
|
34
|
+
"@dbx-tools/shared-mastra": "^0.9.116",
|
|
35
|
+
"@dbx-tools/shared-model": "^0.9.116",
|
|
36
|
+
"@dbx-tools/ui": "^0.9.116",
|
|
37
37
|
"@mastra/client-js": "1.50.0",
|
|
38
38
|
"@mastra/core": "1.71.0",
|
|
39
39
|
"@tanstack/react-table": "^8.21.3",
|
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
"echarts-for-react": "^3.0.2",
|
|
43
43
|
"lucide-react": "^0.554.0",
|
|
44
44
|
"marked": "^18.0.5",
|
|
45
|
-
"shiki": "^3.0.0",
|
|
46
45
|
"sql-formatter": "^15.6.9",
|
|
47
46
|
"streamdown": "^2.5.0"
|
|
48
47
|
},
|
|
@@ -50,7 +49,7 @@
|
|
|
50
49
|
"publishConfig": {
|
|
51
50
|
"access": "public"
|
|
52
51
|
},
|
|
53
|
-
"version": "0.9.
|
|
52
|
+
"version": "0.9.116",
|
|
54
53
|
"dbxToolsConfig": {
|
|
55
54
|
"tags": [
|
|
56
55
|
"ui"
|
|
@@ -60,6 +59,7 @@
|
|
|
60
59
|
"exports": {
|
|
61
60
|
"./react": "./src/react/index.ts",
|
|
62
61
|
"./styles.css": "./src/styles.css",
|
|
62
|
+
"./*": "./src/*/index.ts",
|
|
63
63
|
"./package.json": "./package.json"
|
|
64
64
|
},
|
|
65
65
|
"files": [
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { FileUIPart } from "ai";
|
|
2
|
+
|
|
3
|
+
export const MAX_COMPOSER_IMAGES = 4;
|
|
4
|
+
export const MAX_COMPOSER_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
5
|
+
export const MAX_COMPOSER_IMAGE_EDGE = 2048;
|
|
6
|
+
|
|
7
|
+
type ImageDimensions = {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Fit image dimensions within a maximum edge while preserving aspect ratio. */
|
|
13
|
+
export function fitImageDimensions(
|
|
14
|
+
width: number,
|
|
15
|
+
height: number,
|
|
16
|
+
maxEdge = MAX_COMPOSER_IMAGE_EDGE,
|
|
17
|
+
): ImageDimensions {
|
|
18
|
+
if (width <= maxEdge && height <= maxEdge) return { width, height };
|
|
19
|
+
const scale = maxEdge / Math.max(width, height);
|
|
20
|
+
return {
|
|
21
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
22
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function dataUrlForBlob(blob: Blob): Promise<string> {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const reader = new FileReader();
|
|
29
|
+
reader.onerror = () => reject(reader.error ?? new Error("Unable to read image"));
|
|
30
|
+
reader.onload = () => resolve(String(reader.result));
|
|
31
|
+
reader.readAsDataURL(blob);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadImage(file: File): Promise<HTMLImageElement> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const url = URL.createObjectURL(file);
|
|
38
|
+
const image = new Image();
|
|
39
|
+
image.onload = () => {
|
|
40
|
+
URL.revokeObjectURL(url);
|
|
41
|
+
resolve(image);
|
|
42
|
+
};
|
|
43
|
+
image.onerror = () => {
|
|
44
|
+
URL.revokeObjectURL(url);
|
|
45
|
+
reject(new Error(`Unable to decode ${file.name || "image"}`));
|
|
46
|
+
};
|
|
47
|
+
image.src = url;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function canvasBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob> {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
canvas.toBlob(
|
|
54
|
+
(blob) => (blob ? resolve(blob) : reject(new Error("Unable to resize image"))),
|
|
55
|
+
type,
|
|
56
|
+
quality,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resizedFilename(filename: string, mediaType: string): string {
|
|
62
|
+
const extension = mediaType === "image/webp" ? "webp" : mediaType === "image/png" ? "png" : "jpg";
|
|
63
|
+
const stem = filename.replace(/\.[^.]+$/, "") || "image";
|
|
64
|
+
return `${stem}.${extension}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function shrinkImage(
|
|
68
|
+
file: File,
|
|
69
|
+
maxBytes: number,
|
|
70
|
+
): Promise<{ blob: Blob; filename: string }> {
|
|
71
|
+
const image = await loadImage(file);
|
|
72
|
+
const fitted = fitImageDimensions(image.naturalWidth, image.naturalHeight);
|
|
73
|
+
const canvas = document.createElement("canvas");
|
|
74
|
+
const context = canvas.getContext("2d");
|
|
75
|
+
if (!context) throw new Error("Image resizing is unavailable in this browser");
|
|
76
|
+
|
|
77
|
+
let smallest: Blob | undefined;
|
|
78
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
79
|
+
const scale = 0.82 ** Math.floor(attempt / 2);
|
|
80
|
+
canvas.width = Math.max(1, Math.round(fitted.width * scale));
|
|
81
|
+
canvas.height = Math.max(1, Math.round(fitted.height * scale));
|
|
82
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
83
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
84
|
+
const quality = attempt % 2 === 0 ? 0.84 : 0.66;
|
|
85
|
+
const blob = await canvasBlob(canvas, "image/webp", quality);
|
|
86
|
+
if (!smallest || blob.size < smallest.size) smallest = blob;
|
|
87
|
+
if (blob.size <= maxBytes) {
|
|
88
|
+
return { blob, filename: resizedFilename(file.name, blob.type) };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!smallest || smallest.size > maxBytes) {
|
|
93
|
+
throw new Error(`${file.name || "Image"} could not be reduced below 4 MB`);
|
|
94
|
+
}
|
|
95
|
+
return { blob: smallest, filename: resizedFilename(file.name, smallest.type) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Convert an image file to the native AI SDK file part, shrinking oversized images first. */
|
|
99
|
+
export async function imageFileToUIPart(
|
|
100
|
+
file: File,
|
|
101
|
+
maxBytes = MAX_COMPOSER_IMAGE_BYTES,
|
|
102
|
+
): Promise<FileUIPart> {
|
|
103
|
+
if (!file.type.startsWith("image/")) throw new Error(`${file.name || "File"} is not an image`);
|
|
104
|
+
const prepared =
|
|
105
|
+
file.size > maxBytes ? await shrinkImage(file, maxBytes) : { blob: file, filename: file.name };
|
|
106
|
+
return {
|
|
107
|
+
type: "file",
|
|
108
|
+
mediaType: prepared.blob.type || file.type,
|
|
109
|
+
filename: prepared.filename || "image",
|
|
110
|
+
url: await dataUrlForBlob(prepared.blob),
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/react/bubbles.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Tooltip,
|
|
13
13
|
TooltipContent,
|
|
14
14
|
TooltipTrigger,
|
|
15
|
+
cn,
|
|
15
16
|
} from "@dbx-tools/ui/react";
|
|
16
17
|
import type { UIMessage } from "ai";
|
|
17
18
|
import {
|
|
@@ -535,14 +536,31 @@ export const UserBubble = ({ message }: { message: UIMessage }) => {
|
|
|
535
536
|
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
536
537
|
.map((p) => p.text)
|
|
537
538
|
.join("");
|
|
539
|
+
const files = message.parts.filter((part) => part.type === "file");
|
|
538
540
|
return (
|
|
539
541
|
<Item className="items-start gap-2.5 border-none bg-transparent p-0">
|
|
540
542
|
<ItemMedia>
|
|
541
543
|
<RoleAvatar role="user" />
|
|
542
544
|
</ItemMedia>
|
|
543
545
|
<ItemContent className="min-w-0">
|
|
544
|
-
<div className="rounded-lg bg-muted
|
|
545
|
-
{
|
|
546
|
+
<div className="max-w-full rounded-lg bg-muted p-2 text-sm">
|
|
547
|
+
{files.length > 0 && (
|
|
548
|
+
<div className={cn("grid gap-1.5", files.length > 1 && "grid-cols-2")}>
|
|
549
|
+
{files.map((file, index) => (
|
|
550
|
+
<img
|
|
551
|
+
key={`${file.filename ?? "image"}-${index}`}
|
|
552
|
+
src={file.url}
|
|
553
|
+
alt={file.filename || "Attached image"}
|
|
554
|
+
className="max-h-64 max-w-full rounded-md object-contain"
|
|
555
|
+
/>
|
|
556
|
+
))}
|
|
557
|
+
</div>
|
|
558
|
+
)}
|
|
559
|
+
{text && (
|
|
560
|
+
<div className={cn("whitespace-pre-wrap break-words px-1", files.length > 0 && "pt-2")}>
|
|
561
|
+
{text}
|
|
562
|
+
</div>
|
|
563
|
+
)}
|
|
546
564
|
</div>
|
|
547
565
|
</ItemContent>
|
|
548
566
|
</Item>
|
|
@@ -18,8 +18,10 @@ import {
|
|
|
18
18
|
TooltipTrigger,
|
|
19
19
|
cn,
|
|
20
20
|
} from "@dbx-tools/ui/react";
|
|
21
|
+
import type { FileUIPart } from "ai";
|
|
21
22
|
import {
|
|
22
23
|
GripVerticalIcon,
|
|
24
|
+
ImagePlusIcon,
|
|
23
25
|
SendHorizontalIcon,
|
|
24
26
|
SendIcon,
|
|
25
27
|
SquareIcon,
|
|
@@ -28,6 +30,7 @@ import {
|
|
|
28
30
|
} from "lucide-react";
|
|
29
31
|
import { useCallback, useId, useLayoutEffect, useRef, useState } from "react";
|
|
30
32
|
import { autosizeComposerTextarea, observeComposerWidth } from "./_composer-autosize.ts";
|
|
33
|
+
import { imageFileToUIPart, MAX_COMPOSER_IMAGES } from "./_image-attachments.ts";
|
|
31
34
|
import { ModelSelector } from "./_model-selector.tsx";
|
|
32
35
|
import { ExportMenu } from "./export-menu.tsx";
|
|
33
36
|
import { SuggestionPills } from "./suggestion-pills.tsx";
|
|
@@ -123,7 +126,9 @@ const QueuedSteerList = ({
|
|
|
123
126
|
</span>
|
|
124
127
|
)}
|
|
125
128
|
<span className="text-muted-foreground">Queued</span>
|
|
126
|
-
<span className="min-w-0 flex-1 truncate">
|
|
129
|
+
<span className="min-w-0 flex-1 truncate">
|
|
130
|
+
{steer.text || steer.files?.map((file) => file.filename || "Image").join(", ")}
|
|
131
|
+
</span>
|
|
127
132
|
{onSendSteerNow && (
|
|
128
133
|
<Tooltip>
|
|
129
134
|
<TooltipTrigger asChild>
|
|
@@ -192,13 +197,13 @@ const ClearConversationAction = ({ onClear, isLoadingHistory }: ClearConversatio
|
|
|
192
197
|
<Button
|
|
193
198
|
type="button"
|
|
194
199
|
variant="outline"
|
|
195
|
-
size="
|
|
200
|
+
size="icon"
|
|
196
201
|
onClick={() => setOpen(true)}
|
|
197
202
|
disabled={isLoadingHistory}
|
|
198
|
-
className="
|
|
203
|
+
className="size-7 rounded-full [&_svg]:size-3"
|
|
204
|
+
aria-label="Clear conversation"
|
|
199
205
|
>
|
|
200
206
|
<Trash2Icon className="size-3" />
|
|
201
|
-
Clear
|
|
202
207
|
</Button>
|
|
203
208
|
</TooltipTrigger>
|
|
204
209
|
<TooltipContent>Clear chat history for this thread</TooltipContent>
|
|
@@ -285,7 +290,11 @@ export const ChatComposer = ({
|
|
|
285
290
|
onResumeFollow,
|
|
286
291
|
}: ChatComposerProps) => {
|
|
287
292
|
const [input, setInput] = useState("");
|
|
293
|
+
const [files, setFiles] = useState<FileUIPart[]>([]);
|
|
294
|
+
const [fileError, setFileError] = useState<string | null>(null);
|
|
295
|
+
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
|
|
288
296
|
const inputId = useId();
|
|
297
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
289
298
|
const inputGroupRef = useRef<HTMLDivElement>(null);
|
|
290
299
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
291
300
|
|
|
@@ -304,14 +313,44 @@ export const ChatComposer = ({
|
|
|
304
313
|
}, [resizeTextarea]);
|
|
305
314
|
|
|
306
315
|
const isRunning = status === "submitted" || status === "streaming";
|
|
316
|
+
const hasDraft = Boolean(input.trim() || files.length > 0);
|
|
307
317
|
const submit = () => {
|
|
308
318
|
const text = input.trim();
|
|
309
|
-
if (!text || isLoadingHistory) return;
|
|
310
|
-
sendMessage({ text });
|
|
319
|
+
if ((!text && files.length === 0) || isLoadingHistory || isPreparingFiles) return;
|
|
320
|
+
sendMessage({ ...(text ? { text } : {}), ...(files.length > 0 ? { files } : {}) });
|
|
311
321
|
setInput("");
|
|
322
|
+
setFiles([]);
|
|
323
|
+
setFileError(null);
|
|
312
324
|
onResumeFollow();
|
|
313
325
|
};
|
|
314
326
|
|
|
327
|
+
const addFiles = async (selected: FileList | null) => {
|
|
328
|
+
if (!selected?.length) return;
|
|
329
|
+
const remaining = MAX_COMPOSER_IMAGES - files.length;
|
|
330
|
+
if (remaining <= 0) {
|
|
331
|
+
setFileError(`You can attach up to ${MAX_COMPOSER_IMAGES} images`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
setIsPreparingFiles(true);
|
|
335
|
+
setFileError(null);
|
|
336
|
+
try {
|
|
337
|
+
const next = await Promise.all(
|
|
338
|
+
Array.from(selected)
|
|
339
|
+
.slice(0, remaining)
|
|
340
|
+
.map((file) => imageFileToUIPart(file)),
|
|
341
|
+
);
|
|
342
|
+
setFiles((current) => [...current, ...next]);
|
|
343
|
+
if (selected.length > remaining) {
|
|
344
|
+
setFileError(`Only the first ${remaining} images were added`);
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
setFileError(error instanceof Error ? error.message : "Unable to attach image");
|
|
348
|
+
} finally {
|
|
349
|
+
setIsPreparingFiles(false);
|
|
350
|
+
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
315
354
|
const modelChangeable = Boolean(models && models.length > 0);
|
|
316
355
|
return (
|
|
317
356
|
<>
|
|
@@ -339,6 +378,37 @@ export const ChatComposer = ({
|
|
|
339
378
|
ref={inputGroupRef}
|
|
340
379
|
className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md"
|
|
341
380
|
>
|
|
381
|
+
{files.length > 0 && (
|
|
382
|
+
<InputGroupAddon
|
|
383
|
+
align="block-start"
|
|
384
|
+
className="max-w-full justify-start gap-2 overflow-x-auto pb-0"
|
|
385
|
+
>
|
|
386
|
+
{files.map((file, index) => (
|
|
387
|
+
<div
|
|
388
|
+
key={`${file.filename ?? "image"}-${index}`}
|
|
389
|
+
className="group relative size-20 shrink-0 overflow-hidden rounded-xl border border-border bg-muted"
|
|
390
|
+
>
|
|
391
|
+
<img
|
|
392
|
+
src={file.url}
|
|
393
|
+
alt={file.filename || "Attached image"}
|
|
394
|
+
className="size-full object-cover"
|
|
395
|
+
/>
|
|
396
|
+
<Button
|
|
397
|
+
type="button"
|
|
398
|
+
size="icon"
|
|
399
|
+
variant="secondary"
|
|
400
|
+
className="absolute right-0.5 top-0.5 size-5 rounded-full opacity-90"
|
|
401
|
+
onClick={() =>
|
|
402
|
+
setFiles((current) => current.filter((_, item) => item !== index))
|
|
403
|
+
}
|
|
404
|
+
aria-label={`Remove ${file.filename || "image"}`}
|
|
405
|
+
>
|
|
406
|
+
<XIcon className="size-3" />
|
|
407
|
+
</Button>
|
|
408
|
+
</div>
|
|
409
|
+
))}
|
|
410
|
+
</InputGroupAddon>
|
|
411
|
+
)}
|
|
342
412
|
<InputGroupTextarea
|
|
343
413
|
id={`${inputId}-message`}
|
|
344
414
|
name="message"
|
|
@@ -355,15 +425,51 @@ export const ChatComposer = ({
|
|
|
355
425
|
rows={1}
|
|
356
426
|
className="field-sizing-fixed min-h-10 max-h-64 w-full flex-none overflow-y-auto px-4 pb-2 pt-4 text-base md:text-xs"
|
|
357
427
|
/>
|
|
428
|
+
{fileError && (
|
|
429
|
+
<div className="px-4 text-xs text-destructive" role="alert">
|
|
430
|
+
{fileError}
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
358
433
|
<InputGroupAddon
|
|
359
434
|
align="block-end"
|
|
360
435
|
className="flex w-full flex-row flex-wrap items-center justify-between gap-2 px-3 pb-3 pt-1"
|
|
361
436
|
>
|
|
362
437
|
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
|
438
|
+
<input
|
|
439
|
+
ref={fileInputRef}
|
|
440
|
+
type="file"
|
|
441
|
+
accept="image/*"
|
|
442
|
+
multiple
|
|
443
|
+
className="sr-only"
|
|
444
|
+
onChange={(event) => void addFiles(event.currentTarget.files)}
|
|
445
|
+
/>
|
|
446
|
+
<Tooltip>
|
|
447
|
+
<TooltipTrigger asChild>
|
|
448
|
+
<Button
|
|
449
|
+
type="button"
|
|
450
|
+
variant="outline"
|
|
451
|
+
size="icon"
|
|
452
|
+
className="size-7 rounded-full [&_svg]:size-3"
|
|
453
|
+
onClick={() => fileInputRef.current?.click()}
|
|
454
|
+
disabled={
|
|
455
|
+
isLoadingHistory || isPreparingFiles || files.length >= MAX_COMPOSER_IMAGES
|
|
456
|
+
}
|
|
457
|
+
aria-label="Upload images"
|
|
458
|
+
>
|
|
459
|
+
{isPreparingFiles ? (
|
|
460
|
+
<Spinner className="size-3" />
|
|
461
|
+
) : (
|
|
462
|
+
<ImagePlusIcon className="size-3" />
|
|
463
|
+
)}
|
|
464
|
+
</Button>
|
|
465
|
+
</TooltipTrigger>
|
|
466
|
+
<TooltipContent>Upload images</TooltipContent>
|
|
467
|
+
</Tooltip>
|
|
363
468
|
{composerLeadingActions}
|
|
364
469
|
{onExportConversation && (
|
|
365
470
|
<ExportMenu
|
|
366
471
|
onExport={(format) => void onExportConversation(format)}
|
|
472
|
+
iconOnly
|
|
367
473
|
tooltip="Export conversation"
|
|
368
474
|
disabled={isLoadingHistory}
|
|
369
475
|
/>
|
|
@@ -399,7 +505,7 @@ export const ChatComposer = ({
|
|
|
399
505
|
)}
|
|
400
506
|
</span>
|
|
401
507
|
))}
|
|
402
|
-
{isRunning && onStop && !
|
|
508
|
+
{isRunning && onStop && !hasDraft ? (
|
|
403
509
|
<InputGroupButton
|
|
404
510
|
type="button"
|
|
405
511
|
size="icon-sm"
|
|
@@ -415,7 +521,7 @@ export const ChatComposer = ({
|
|
|
415
521
|
type="submit"
|
|
416
522
|
size="icon-sm"
|
|
417
523
|
variant="default"
|
|
418
|
-
disabled={!
|
|
524
|
+
disabled={!hasDraft || isLoadingHistory || isPreparingFiles}
|
|
419
525
|
aria-label={isRunning ? "Queue message" : "Send message"}
|
|
420
526
|
className="shrink-0 rounded-full"
|
|
421
527
|
>
|
|
@@ -188,12 +188,7 @@ export const ChatTranscript = ({
|
|
|
188
188
|
(part.type === "text" || part.type === "reasoning") &&
|
|
189
189
|
Boolean((part as { text?: string }).text),
|
|
190
190
|
) || (lastEvents?.length ?? 0) > 0;
|
|
191
|
-
const
|
|
192
|
-
const waitingLabel = !lastAssistantHasContent
|
|
193
|
-
? "Thinking..."
|
|
194
|
-
: hasRunningTool
|
|
195
|
-
? "Working..."
|
|
196
|
-
: "Composing response...";
|
|
191
|
+
const showWaitingIndicator = isRunning && !lastAssistantHasContent;
|
|
197
192
|
|
|
198
193
|
return (
|
|
199
194
|
<div className="relative flex flex-1 flex-col overflow-hidden">
|
|
@@ -259,10 +254,10 @@ export const ChatTranscript = ({
|
|
|
259
254
|
}
|
|
260
255
|
return <UserBubble key={message.id} message={message} />;
|
|
261
256
|
})}
|
|
262
|
-
{
|
|
257
|
+
{showWaitingIndicator && (
|
|
263
258
|
<div className="flex h-7 items-center gap-2 px-3 text-xs text-muted-foreground">
|
|
264
259
|
<Spinner className="size-3" />
|
|
265
|
-
<span className="animate-pulse">
|
|
260
|
+
<span className="animate-pulse">Thinking...</span>
|
|
266
261
|
</div>
|
|
267
262
|
)}
|
|
268
263
|
{status === "error" && (
|
package/src/react/index.ts
CHANGED
package/src/react/markdown.tsx
CHANGED
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
Tooltip,
|
|
11
11
|
TooltipContent,
|
|
12
12
|
TooltipTrigger,
|
|
13
|
+
createShikiPlugin,
|
|
14
|
+
highlightToHtml,
|
|
15
|
+
useHighlightedHtml,
|
|
13
16
|
cn,
|
|
14
17
|
} from "@dbx-tools/ui/react";
|
|
15
18
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
@@ -17,7 +20,6 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
17
20
|
import { Streamdown } from "streamdown";
|
|
18
21
|
import { DataGrid, TABLE_WRAPPER_CLASSES, colorizeDelta, type DataRow } from "./data-grid.tsx";
|
|
19
22
|
import { copyText } from "../support/clipboard.ts";
|
|
20
|
-
import { createShikiPlugin, highlightToHtml } from "../support/shiki-plugin.ts";
|
|
21
23
|
|
|
22
24
|
// Markdown rendering for the chat: the streaming `Streamdown` engine
|
|
23
25
|
// wired with shiki highlighting and AppKit table primitives, plus the
|
|
@@ -181,27 +183,6 @@ const TOOL_MARKDOWN_COMPONENTS = {
|
|
|
181
183
|
*/
|
|
182
184
|
const SHIKI_PLUGIN = { code: createShikiPlugin() };
|
|
183
185
|
|
|
184
|
-
/** Resolve syntax-highlighted HTML while preserving plaintext during lazy load. */
|
|
185
|
-
function useHighlightedHtml(source: string, language: string): string | null {
|
|
186
|
-
const [highlighted, setHighlighted] = useState<{
|
|
187
|
-
source: string;
|
|
188
|
-
language: string;
|
|
189
|
-
html: string;
|
|
190
|
-
} | null>(null);
|
|
191
|
-
useEffect(() => {
|
|
192
|
-
let active = true;
|
|
193
|
-
void highlightToHtml(source, language).then((result) => {
|
|
194
|
-
if (active) setHighlighted({ source, language, html: result });
|
|
195
|
-
});
|
|
196
|
-
return () => {
|
|
197
|
-
active = false;
|
|
198
|
-
};
|
|
199
|
-
}, [language, source]);
|
|
200
|
-
return highlighted?.source === source && highlighted.language === language
|
|
201
|
-
? highlighted.html
|
|
202
|
-
: null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
186
|
/**
|
|
206
187
|
* Per-word fade-in applied while a reply is still streaming. Streamdown
|
|
207
188
|
* wraps each newly arrived token in a `[data-sd-animate]` span and only
|
|
@@ -397,28 +378,6 @@ export const SqlBlock = ({ sql }: { sql: string }) => {
|
|
|
397
378
|
);
|
|
398
379
|
};
|
|
399
380
|
|
|
400
|
-
/**
|
|
401
|
-
* Render formatted JSON with the same lazily loaded Shiki runtime used by
|
|
402
|
-
* SQL and fenced Markdown code blocks.
|
|
403
|
-
*/
|
|
404
|
-
export const JsonBlock = ({ json, className }: { json: string; className?: string }) => {
|
|
405
|
-
const html = useHighlightedHtml(json, "json");
|
|
406
|
-
return (
|
|
407
|
-
<pre
|
|
408
|
-
className={cn(
|
|
409
|
-
"max-w-full overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed",
|
|
410
|
-
className,
|
|
411
|
-
)}
|
|
412
|
-
>
|
|
413
|
-
{html === null ? (
|
|
414
|
-
<code>{json}</code>
|
|
415
|
-
) : (
|
|
416
|
-
<code data-dbx-highlighted-code dangerouslySetInnerHTML={{ __html: html }} />
|
|
417
|
-
)}
|
|
418
|
-
</pre>
|
|
419
|
-
);
|
|
420
|
-
};
|
|
421
|
-
|
|
422
381
|
/** Compact syntax-highlighted source block for tool request previews. */
|
|
423
382
|
export const SourceBlock = ({
|
|
424
383
|
source,
|
|
@@ -2,7 +2,7 @@ import { errorUtils, hash, log } from "@dbx-tools/shared-core";
|
|
|
2
2
|
import { isStaleMastraResumeError } from "@dbx-tools/shared-mastra/resume";
|
|
3
3
|
import type { ReasoningEffort } from "@dbx-tools/shared-model";
|
|
4
4
|
import { useBrand } from "@dbx-tools/ui/branding/react";
|
|
5
|
-
import type { UIMessage } from "ai";
|
|
5
|
+
import type { FileUIPart, UIMessage } from "ai";
|
|
6
6
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { useChatApprovals } from "./chat-approvals.ts";
|
|
8
8
|
import { useChatFeedback } from "./chat-feedback.ts";
|
|
@@ -66,10 +66,10 @@ const EMPTY_REASONING_EFFORTS: readonly ReasoningEffort[] = [];
|
|
|
66
66
|
|
|
67
67
|
const logger = log.logger("ui-mastra/chat");
|
|
68
68
|
|
|
69
|
-
const makeUserMessage = (text: string): UIMessage => ({
|
|
69
|
+
const makeUserMessage = (text: string, files: FileUIPart[] = []): UIMessage => ({
|
|
70
70
|
id: hash.id(),
|
|
71
71
|
role: "user",
|
|
72
|
-
parts: [{ type: "text", text }],
|
|
72
|
+
parts: [...files, ...(text ? [{ type: "text" as const, text }] : [])],
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
/** Project a native Mastra memory thread down to the sidebar's view. */
|
|
@@ -610,7 +610,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
610
610
|
...session,
|
|
611
611
|
queuedSteers: removeSteerFromQueue(session.queuedSteers, head.id),
|
|
612
612
|
}));
|
|
613
|
-
const { message } = appendUserMessage(threadId, head.text);
|
|
613
|
+
const { message } = appendUserMessage(threadId, head.text ?? "", head.files);
|
|
614
614
|
void runStream(threadId, message, head.requestContext);
|
|
615
615
|
};
|
|
616
616
|
|
|
@@ -666,11 +666,17 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
666
666
|
// Append a user message to a thread's transcript, update thread activity,
|
|
667
667
|
// and derive a provisional title for a brand-new thread.
|
|
668
668
|
const appendUserMessage = useCallback(
|
|
669
|
-
(
|
|
669
|
+
(
|
|
670
|
+
threadId: string,
|
|
671
|
+
text: string,
|
|
672
|
+
files: FileUIPart[] = [],
|
|
673
|
+
): { message: UIMessage; next: UIMessage[] } => {
|
|
670
674
|
if (activeThreadId) {
|
|
671
675
|
noteThreadActivity(activeThreadId);
|
|
672
676
|
if (getSession(threadId).messages.length === 0) {
|
|
673
|
-
const provisional = deriveThreadTitle(
|
|
677
|
+
const provisional = deriveThreadTitle(
|
|
678
|
+
text || files.map((file) => file.filename || "Image").join(", "),
|
|
679
|
+
);
|
|
674
680
|
if (provisional) {
|
|
675
681
|
setProvisionalTitles((prev) =>
|
|
676
682
|
prev[activeThreadId] ? prev : { ...prev, [activeThreadId]: provisional },
|
|
@@ -679,7 +685,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
679
685
|
}
|
|
680
686
|
}
|
|
681
687
|
const session = getSession(threadId);
|
|
682
|
-
const message = makeUserMessage(text);
|
|
688
|
+
const message = makeUserMessage(text, files);
|
|
683
689
|
const next = [...session.messages, message];
|
|
684
690
|
writeMessages(threadId, next);
|
|
685
691
|
return { message, next };
|
|
@@ -694,7 +700,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
694
700
|
const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
|
|
695
701
|
(message) => {
|
|
696
702
|
const text = message.text ?? "";
|
|
697
|
-
|
|
703
|
+
const files = message.files ?? [];
|
|
704
|
+
if (!text && files.length === 0) return;
|
|
698
705
|
const threadId = activeKey;
|
|
699
706
|
const requestContext = snapshotRequestContext(requestContextRef.current);
|
|
700
707
|
if (isSessionRunning(getSession(threadId))) {
|
|
@@ -703,12 +710,13 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
703
710
|
queuedSteers: enqueueSteer(session.queuedSteers, {
|
|
704
711
|
id: hash.id(),
|
|
705
712
|
text,
|
|
713
|
+
...(files.length > 0 ? { files } : {}),
|
|
706
714
|
...(requestContext ? { requestContext } : {}),
|
|
707
715
|
}),
|
|
708
716
|
}));
|
|
709
717
|
return;
|
|
710
718
|
}
|
|
711
|
-
const { message: userMessage } = appendUserMessage(threadId, text);
|
|
719
|
+
const { message: userMessage } = appendUserMessage(threadId, text, files);
|
|
712
720
|
void runStream(threadId, userMessage, requestContext);
|
|
713
721
|
},
|
|
714
722
|
[appendUserMessage, runStream, activeKey, getSession, updateSession],
|
|
@@ -728,7 +736,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
728
736
|
queuedSteers: removeSteerFromQueue(session.queuedSteers, steerId),
|
|
729
737
|
}));
|
|
730
738
|
logger.info("steer:send-now", { threadId });
|
|
731
|
-
const { message } = appendUserMessage(threadId, steer.text);
|
|
739
|
+
const { message } = appendUserMessage(threadId, steer.text ?? "", steer.files);
|
|
732
740
|
void runStream(threadId, message, steer.requestContext);
|
|
733
741
|
},
|
|
734
742
|
[activeKey, appendUserMessage, getSession, runStream, updateSession],
|
package/src/react/tool-pill.tsx
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { stringUtils } from "@dbx-tools/shared-core";
|
|
1
|
+
import { json, stringUtils } from "@dbx-tools/shared-core";
|
|
2
2
|
import { genieModel } from "@dbx-tools/shared-genie";
|
|
3
3
|
import {
|
|
4
4
|
Collapsible,
|
|
5
5
|
CollapsibleContent,
|
|
6
6
|
CollapsibleTrigger,
|
|
7
|
+
detectCodeLanguage,
|
|
8
|
+
JsonBlock,
|
|
9
|
+
RecordPreview,
|
|
7
10
|
Spinner,
|
|
8
11
|
cn,
|
|
9
12
|
} from "@dbx-tools/ui/react";
|
|
10
13
|
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
|
11
|
-
import {
|
|
14
|
+
import { SourceBlock, SqlBlock, ToolMarkdown } from "./markdown.tsx";
|
|
12
15
|
import type { ToolEvent, ToolProgress } from "./types.ts";
|
|
13
16
|
|
|
14
17
|
// Consolidated tool-session pill and its Genie progress detail view:
|
|
@@ -260,7 +263,7 @@ const isGroupRenderable = (g: MessageGroup): boolean =>
|
|
|
260
263
|
* 1. The LLM's sub-question for this Genie call, always
|
|
261
264
|
* visible, styled as a blockquote so it reads as provenance.
|
|
262
265
|
* 2. One numbered "Query N" Collapsible per query attachment
|
|
263
|
-
* (or just "Query" when there's one). Opens to
|
|
266
|
+
* (or just "Query" when there's one). Opens directly to the SQL.
|
|
264
267
|
* 3. Errors that landed under this `message_id`, always
|
|
265
268
|
* visible (red text - users need to see failures, not click
|
|
266
269
|
* to find them).
|
|
@@ -289,11 +292,9 @@ const MessageGroupBody = ({
|
|
|
289
292
|
{queryBuckets.map((bucket, i) => (
|
|
290
293
|
// Each query bucket is a single Collapsible (default
|
|
291
294
|
// closed) so the expanded group reads as just the
|
|
292
|
-
// question + a short stack of "Query N" rows.
|
|
293
|
-
//
|
|
294
|
-
// SQL
|
|
295
|
-
// for the same reason: code is the heaviest content here,
|
|
296
|
-
// and most readers only want a glance.
|
|
295
|
+
// question + a short stack of "Query N" rows. The query
|
|
296
|
+
// opens directly to its SQL instead of adding a redundant
|
|
297
|
+
// second "SQL" pill around the only child.
|
|
297
298
|
<Collapsible key={bucket.key} className="rounded border border-border/60 bg-background/40">
|
|
298
299
|
<CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
|
|
299
300
|
<ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
|
|
@@ -303,24 +304,14 @@ const MessageGroupBody = ({
|
|
|
303
304
|
) : null}
|
|
304
305
|
</CollapsibleTrigger>
|
|
305
306
|
<CollapsibleContent>
|
|
306
|
-
<div className="flex flex-col gap-2 px-2 pb-2 text-xs">
|
|
307
|
+
<div className="flex flex-col gap-2 border-t border-border/50 px-2 pb-2 pt-2 text-xs">
|
|
307
308
|
{bucket.query && (
|
|
308
|
-
|
|
309
|
-
<
|
|
310
|
-
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
<div className="px-2 pb-2">
|
|
315
|
-
<SqlBlock sql={bucket.query.sql} />
|
|
316
|
-
</div>
|
|
317
|
-
{bucket.query.description && (
|
|
318
|
-
<div className="px-2 pb-2">
|
|
319
|
-
<ToolMarkdown>{bucket.query.description}</ToolMarkdown>
|
|
320
|
-
</div>
|
|
321
|
-
)}
|
|
322
|
-
</CollapsibleContent>
|
|
323
|
-
</Collapsible>
|
|
309
|
+
<>
|
|
310
|
+
<SqlBlock sql={bucket.query.sql} />
|
|
311
|
+
{bucket.query.description && (
|
|
312
|
+
<ToolMarkdown>{bucket.query.description}</ToolMarkdown>
|
|
313
|
+
)}
|
|
314
|
+
</>
|
|
324
315
|
)}
|
|
325
316
|
</div>
|
|
326
317
|
</CollapsibleContent>
|
|
@@ -388,20 +379,46 @@ export function formatRawToolPayload(value: unknown): string {
|
|
|
388
379
|
return JSON.stringify(value, null, 2) ?? String(value);
|
|
389
380
|
}
|
|
390
381
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
382
|
+
/** Return structured JSON from an object value or a complete JSON string. */
|
|
383
|
+
export function structuredToolPayload(value: unknown): unknown | undefined {
|
|
384
|
+
if (typeof value === "object" && value !== null) return value;
|
|
385
|
+
if (typeof value !== "string") return undefined;
|
|
386
|
+
const trimmed = value.trim();
|
|
387
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return undefined;
|
|
388
|
+
const parsed = json.parse(trimmed);
|
|
389
|
+
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const RawToolPayload = ({
|
|
393
|
+
label,
|
|
394
|
+
value,
|
|
395
|
+
}: {
|
|
396
|
+
label: "Request" | "Response" | "Output";
|
|
397
|
+
value: unknown;
|
|
398
|
+
}) => {
|
|
399
|
+
const structured = structuredToolPayload(value);
|
|
400
|
+
return (
|
|
401
|
+
<Collapsible className="rounded border border-border/60 bg-background/40">
|
|
402
|
+
<CollapsibleTrigger className="group flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground">
|
|
403
|
+
<ChevronDownIcon className="size-3 shrink-0 transition-transform group-data-[state=closed]:-rotate-90" />
|
|
404
|
+
<span>{label}</span>
|
|
405
|
+
</CollapsibleTrigger>
|
|
406
|
+
<CollapsibleContent>
|
|
407
|
+
{structured === undefined ? (
|
|
408
|
+
<JsonBlock
|
|
409
|
+
json={formatRawToolPayload(value)}
|
|
410
|
+
className="max-h-80 border-t border-border/50 px-2 py-2 text-foreground"
|
|
411
|
+
/>
|
|
412
|
+
) : (
|
|
413
|
+
<RecordPreview
|
|
414
|
+
value={structured}
|
|
415
|
+
className="border-t border-border/50 bg-transparent text-foreground"
|
|
416
|
+
/>
|
|
417
|
+
)}
|
|
418
|
+
</CollapsibleContent>
|
|
419
|
+
</Collapsible>
|
|
420
|
+
);
|
|
421
|
+
};
|
|
405
422
|
|
|
406
423
|
export type ToolInputPresentation = {
|
|
407
424
|
label: "Code" | "Command" | "Output";
|
|
@@ -419,24 +436,7 @@ export function detectToolSourceLanguage(
|
|
|
419
436
|
source: string,
|
|
420
437
|
fallback: ToolInputPresentation["language"],
|
|
421
438
|
): ToolInputPresentation["language"] {
|
|
422
|
-
|
|
423
|
-
if (/^(?:select|with|insert|update|delete|merge|create|alter)\b/i.test(trimmed)) return "sql";
|
|
424
|
-
if (/^(?:from\s+\S+\s+import|import\s+\S+|def\s+\w+|class\s+\w+):?/m.test(trimmed)) {
|
|
425
|
-
return "python";
|
|
426
|
-
}
|
|
427
|
-
if (/^(?:const|let|var|interface|type|export|import)\b/m.test(trimmed)) return "typescript";
|
|
428
|
-
if (/^(?:#!.*\b(?:ba|z|k)?sh\b|(?:cd|ls|cat|grep|find|bun|npm|pnpm|yarn)\s)/m.test(trimmed)) {
|
|
429
|
-
return "bash";
|
|
430
|
-
}
|
|
431
|
-
if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && trimmed.length > 1) {
|
|
432
|
-
try {
|
|
433
|
-
JSON.parse(trimmed);
|
|
434
|
-
return "json";
|
|
435
|
-
} catch {
|
|
436
|
-
// Keep the tool-specific fallback for incomplete source.
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
return fallback;
|
|
439
|
+
return detectCodeLanguage(source) ?? fallback;
|
|
440
440
|
}
|
|
441
441
|
|
|
442
442
|
/** Extract a compact code or command preview from a tool request payload. */
|
|
@@ -621,7 +621,9 @@ const ToolCallDetails = ({
|
|
|
621
621
|
) : null}
|
|
622
622
|
<WebSearchProgressDetails event={event} />
|
|
623
623
|
<ToolProgressDetails summary={summary} omitQuestion />
|
|
624
|
-
{outputPresentation ? (
|
|
624
|
+
{outputPresentation?.language === "json" ? (
|
|
625
|
+
<RawToolPayload label="Output" value={outputPresentation.source} />
|
|
626
|
+
) : outputPresentation ? (
|
|
625
627
|
<ToolInputSource presentation={outputPresentation} />
|
|
626
628
|
) : "output" in event ? (
|
|
627
629
|
<RawToolPayload label="Response" value={event.output} />
|
package/src/react/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ToolProgressEvent } from "@dbx-tools/shared-mastra/wire";
|
|
2
2
|
import type { ReasoningEffort, ServingEndpointSummary } from "@dbx-tools/shared-model";
|
|
3
|
-
import type { UIMessage } from "ai";
|
|
3
|
+
import type { FileUIPart, UIMessage } from "ai";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
5
|
import type { ExportFormat } from "../support/export.ts";
|
|
6
6
|
|
|
@@ -52,12 +52,14 @@ export type ChatModelOption = Pick<
|
|
|
52
52
|
>;
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* A
|
|
56
|
-
* per-thread queue until the running turn ends (auto-sent
|
|
57
|
-
* user fires it early with "send now". `id` is a stable
|
|
58
|
-
* removal.
|
|
55
|
+
* A text and/or image message submitted while a turn was already streaming.
|
|
56
|
+
* It waits in a per-thread queue until the running turn ends (auto-sent
|
|
57
|
+
* oldest-first) or the user fires it early with "send now". `id` is a stable
|
|
58
|
+
* key for rendering / removal.
|
|
59
59
|
*/
|
|
60
|
-
export type
|
|
60
|
+
export type ChatInputMessage = { text?: string; files?: FileUIPart[] };
|
|
61
|
+
|
|
62
|
+
export type QueuedSteer = ChatInputMessage & { id: string };
|
|
61
63
|
|
|
62
64
|
/** Thumbs reaction a user can leave on an assistant turn. */
|
|
63
65
|
export type FeedbackValue = "up" | "down";
|
|
@@ -128,7 +130,7 @@ export type ChatViewProps = {
|
|
|
128
130
|
* ENQUEUES the message as a steer (it waits, no interrupt); the queue drains
|
|
129
131
|
* oldest-first when the turn ends. An idle submit starts a turn immediately.
|
|
130
132
|
*/
|
|
131
|
-
sendMessage: (message:
|
|
133
|
+
sendMessage: (message: ChatInputMessage) => void;
|
|
132
134
|
/**
|
|
133
135
|
* Steers submitted mid-turn that are waiting to run (oldest first). When
|
|
134
136
|
* non-empty the composer shows them as chips above the input, each with a
|
|
@@ -202,7 +204,7 @@ export type ChatViewProps = {
|
|
|
202
204
|
modelSelectorActions?: ReactNode;
|
|
203
205
|
/**
|
|
204
206
|
* Host-owned actions rendered on the left side of the composer's fixed
|
|
205
|
-
* footer, before Export and Clear.
|
|
207
|
+
* footer, after Upload and before Export and Clear.
|
|
206
208
|
*/
|
|
207
209
|
composerLeadingActions?: ReactNode;
|
|
208
210
|
/**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// GENERATED by projen watch - DO NOT EDIT.
|
|
2
|
+
// Regenerated from the exporting modules in ./src.
|
|
3
|
+
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
|
+
|
|
5
|
+
export * as chartOption from "./chart-option.ts";
|
|
6
|
+
export * as chartTheme from "./chart-theme.ts";
|
|
7
|
+
export * as clipboard from "./clipboard.ts";
|
|
8
|
+
export * as download from "./download.ts";
|
|
9
|
+
export * as exportModule from "./export.ts";
|
|
10
|
+
export * as mastraClient from "./mastra-client.ts";
|
|
11
|
+
export * as modelSelection from "./model-selection.ts";
|
|
12
|
+
export * as requestContext from "./request-context.ts";
|
|
13
|
+
export * as threadLabels from "./thread-labels.ts";
|
|
14
|
+
export * as threadSessions from "./thread-sessions.ts";
|
|
15
|
+
export * as threadTabs from "./thread-tabs.ts";
|
|
16
|
+
export * as toolEvents from "./tool-events.ts";
|
|
17
|
+
export { LIGHT_CHART_CHROME } from "./chart-theme.ts";
|
|
18
|
+
export type { ChartChrome } from "./chart-theme.ts";
|
|
19
|
+
export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./export.ts";
|
|
20
|
+
export { MastraPluginClient, useMastraConfig, useMastraClient, useMastraMlflowExperiment, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch } from "./mastra-client.ts";
|
|
21
|
+
export type { MastraStreamResponse, MastraMemoryThread, ByIdFetchState } from "./mastra-client.ts";
|
|
22
|
+
export { defaultReasoningEffort, modelStorageKey, readStoredModel, storeSelectedModel } from "./model-selection.ts";
|
|
23
|
+
export type { MastraRequestContextInput, MastraRequestContextSource, MastraRequestContextSnapshot } from "./request-context.ts";
|
|
24
|
+
export { DEFAULT_THREAD_SESSION_KEY } from "./thread-sessions.ts";
|
|
25
|
+
export type { SessionQueuedSteer, SessionRun, ThreadSession } from "./thread-sessions.ts";
|
|
26
|
+
export { THREAD_TAB_SEED_MAX } from "./thread-tabs.ts";
|
|
@@ -26,10 +26,14 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
26
26
|
import type { MastraRequestContextSnapshot } from "./request-context.ts";
|
|
27
27
|
|
|
28
28
|
type NativeAgentClient = ReturnType<MastraClient["getAgent"]>;
|
|
29
|
+
|
|
30
|
+
/** Browser stream and response headers returned by a Mastra chat request. */
|
|
29
31
|
export interface MastraStreamResponse {
|
|
30
32
|
headers: Headers;
|
|
31
33
|
stream: ReadableStream<UIMessageChunk>;
|
|
32
34
|
}
|
|
35
|
+
|
|
36
|
+
/** One memory thread returned by the native Mastra client. */
|
|
33
37
|
export type MastraMemoryThread = ListMemoryThreadsResponse["threads"][number];
|
|
34
38
|
|
|
35
39
|
/**
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fine-grained Shiki runtime containing only the chat's supported languages.
|
|
3
|
-
*
|
|
4
|
-
* This module is itself dynamically imported, so its grammar, theme, and WASM
|
|
5
|
-
* dependencies remain off the network until a supported code block appears.
|
|
6
|
-
*/
|
|
7
|
-
import { createHighlighterCore } from "shiki/core";
|
|
8
|
-
import { createOnigurumaEngine } from "shiki/engine/oniguruma";
|
|
9
|
-
import bash from "shiki/langs/bash.mjs";
|
|
10
|
-
import javascript from "shiki/langs/javascript.mjs";
|
|
11
|
-
import json from "shiki/langs/json.mjs";
|
|
12
|
-
import markdown from "shiki/langs/markdown.mjs";
|
|
13
|
-
import python from "shiki/langs/python.mjs";
|
|
14
|
-
import sql from "shiki/langs/sql.mjs";
|
|
15
|
-
import typescript from "shiki/langs/typescript.mjs";
|
|
16
|
-
import yaml from "shiki/langs/yaml.mjs";
|
|
17
|
-
import githubDarkHighContrast from "shiki/themes/github-dark-high-contrast.mjs";
|
|
18
|
-
import githubLightHighContrast from "shiki/themes/github-light-high-contrast.mjs";
|
|
19
|
-
|
|
20
|
-
/** Create the shared highlighter with the exact configured language set. */
|
|
21
|
-
export function createHighlighter() {
|
|
22
|
-
return createHighlighterCore({
|
|
23
|
-
themes: [githubLightHighContrast, githubDarkHighContrast],
|
|
24
|
-
langs: [sql, python, typescript, javascript, json, bash, yaml, markdown],
|
|
25
|
-
engine: createOnigurumaEngine(import("shiki/wasm")),
|
|
26
|
-
});
|
|
27
|
-
}
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { stringUtils } from "@dbx-tools/shared-core";
|
|
2
|
-
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
3
|
-
import type { HighlighterCore } from "shiki/core";
|
|
4
|
-
import type { CodeHighlighterPlugin } from "streamdown";
|
|
5
|
-
|
|
6
|
-
// Streamdown 2.x ships syntax highlighting as an opt-in plugin and has
|
|
7
|
-
// no shiki dependency of its own; without a `code` plugin every fenced
|
|
8
|
-
// block renders as uncolored plaintext. This module provides a small
|
|
9
|
-
// shiki-backed highlighter to wire in via `plugins={{ code }}`.
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Languages we highlight in the chat. SQL is the primary one (Genie
|
|
13
|
-
* query previews); the rest cover code the assistant might emit. Kept
|
|
14
|
-
* to a curated set so shiki only bundles these grammars.
|
|
15
|
-
*/
|
|
16
|
-
const LANGUAGES = [
|
|
17
|
-
"text",
|
|
18
|
-
"plaintext",
|
|
19
|
-
"txt",
|
|
20
|
-
"sql",
|
|
21
|
-
"python",
|
|
22
|
-
"typescript",
|
|
23
|
-
"javascript",
|
|
24
|
-
"json",
|
|
25
|
-
"bash",
|
|
26
|
-
"yaml",
|
|
27
|
-
"markdown",
|
|
28
|
-
] as const;
|
|
29
|
-
|
|
30
|
-
/** High-contrast theme pair used for every syntax surface. */
|
|
31
|
-
const THEMES = [
|
|
32
|
-
"github-light-high-contrast",
|
|
33
|
-
"github-dark-high-contrast",
|
|
34
|
-
] as const satisfies readonly [BundledTheme, BundledTheme];
|
|
35
|
-
|
|
36
|
-
/** Languages we can tokenize, as a set for O(1) support checks. */
|
|
37
|
-
const SUPPORTED = new Set<string>(LANGUAGES);
|
|
38
|
-
|
|
39
|
-
let _highlighter: HighlighterCore | null = null;
|
|
40
|
-
let _loading: Promise<HighlighterCore> | null = null;
|
|
41
|
-
|
|
42
|
-
/** Lazily create the shared shiki highlighter (singleton, loaded once). */
|
|
43
|
-
function loadHighlighter(): Promise<HighlighterCore> {
|
|
44
|
-
_loading ??= import("./_shiki-runtime.ts")
|
|
45
|
-
.then(({ createHighlighter }) => createHighlighter())
|
|
46
|
-
.then((highlighter) => {
|
|
47
|
-
_highlighter = highlighter;
|
|
48
|
-
return highlighter;
|
|
49
|
-
});
|
|
50
|
-
return _loading;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function splitThemeColor(
|
|
54
|
-
value: string | undefined,
|
|
55
|
-
darkVariable: "--shiki-dark" | "--shiki-dark-bg",
|
|
56
|
-
): { light?: string; dark?: string } {
|
|
57
|
-
if (!value) return {};
|
|
58
|
-
const marker = `;${darkVariable}:`;
|
|
59
|
-
const index = value.indexOf(marker);
|
|
60
|
-
if (index < 0) return { light: value };
|
|
61
|
-
return {
|
|
62
|
-
light: value.slice(0, index),
|
|
63
|
-
dark: value.slice(index + marker.length),
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Tokenize `code` with the active themes into Streamdown's result shape. */
|
|
68
|
-
function highlightTokens(h: HighlighterCore, code: string, language: string) {
|
|
69
|
-
const { tokens, fg, bg, rootStyle } = h.codeToTokens(code, {
|
|
70
|
-
lang: language as BundledLanguage,
|
|
71
|
-
themes: { light: THEMES[0], dark: THEMES[1] },
|
|
72
|
-
});
|
|
73
|
-
const foreground = splitThemeColor(fg, "--shiki-dark");
|
|
74
|
-
const background = splitThemeColor(bg, "--shiki-dark-bg");
|
|
75
|
-
const darkStyles = [
|
|
76
|
-
foreground.dark ? `--shiki-dark:${foreground.dark}` : "",
|
|
77
|
-
background.dark ? `--shiki-dark-bg:${background.dark}` : "",
|
|
78
|
-
]
|
|
79
|
-
.filter(Boolean)
|
|
80
|
-
.join(";");
|
|
81
|
-
return {
|
|
82
|
-
tokens,
|
|
83
|
-
fg: foreground.light,
|
|
84
|
-
bg: background.light,
|
|
85
|
-
rootStyle: [rootStyle, darkStyles].filter(Boolean).join(";"),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Escape HTML-significant characters (from the shared string utils). */
|
|
90
|
-
const escapeHtml = stringUtils.escapeHtml;
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Highlight `code` into minimal inline HTML: one colored `<span>` per
|
|
94
|
-
* token, lines joined by real newlines, with no line-number gutter or
|
|
95
|
-
* per-line wrapper elements. Meant to drop straight into a
|
|
96
|
-
* `<pre><code>` so the rendered text stays cleanly selectable and
|
|
97
|
-
* copyable. Falls back to plain escaped text when the language isn't
|
|
98
|
-
* supported or shiki fails to parse the snippet.
|
|
99
|
-
*/
|
|
100
|
-
export async function highlightToHtml(code: string, language: string): Promise<string> {
|
|
101
|
-
if (!SUPPORTED.has(language)) return escapeHtml(code);
|
|
102
|
-
const h = await loadHighlighter();
|
|
103
|
-
try {
|
|
104
|
-
const { tokens } = highlightTokens(h, code, language);
|
|
105
|
-
return tokens
|
|
106
|
-
.map((line) =>
|
|
107
|
-
line
|
|
108
|
-
.map((token) => {
|
|
109
|
-
const light = token.htmlStyle?.color ?? token.color ?? "inherit";
|
|
110
|
-
const dark = token.htmlStyle?.["--shiki-dark"] ?? light;
|
|
111
|
-
const style = `--sdm-c:${light};--shiki-dark:${dark}`;
|
|
112
|
-
return `<span style="${escapeHtml(style)}">${escapeHtml(token.content)}</span>`;
|
|
113
|
-
})
|
|
114
|
-
.join(""),
|
|
115
|
-
)
|
|
116
|
-
.join("\n");
|
|
117
|
-
} catch {
|
|
118
|
-
return escapeHtml(code);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* shiki-backed code highlighter plugin for Streamdown. The highlighter
|
|
124
|
-
* loads asynchronously: the first call for any block returns `null`
|
|
125
|
-
* and resolves through the `callback` once shiki is ready, after which
|
|
126
|
-
* results are synchronous. Unsupported languages return `null` so the
|
|
127
|
-
* block stays plaintext rather than throwing.
|
|
128
|
-
*/
|
|
129
|
-
export function createShikiPlugin(): CodeHighlighterPlugin {
|
|
130
|
-
const isSupported = (language: string): boolean => SUPPORTED.has(language);
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
name: "shiki",
|
|
134
|
-
type: "code-highlighter",
|
|
135
|
-
getSupportedLanguages: () => [...LANGUAGES],
|
|
136
|
-
getThemes: () => [...THEMES],
|
|
137
|
-
supportsLanguage: (language) => isSupported(language),
|
|
138
|
-
highlight: (options, callback) => {
|
|
139
|
-
if (!isSupported(options.language)) return null;
|
|
140
|
-
const language = options.language;
|
|
141
|
-
if (_highlighter) {
|
|
142
|
-
try {
|
|
143
|
-
return highlightTokens(_highlighter, options.code, language);
|
|
144
|
-
} catch {
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
void loadHighlighter().then((h) => {
|
|
149
|
-
try {
|
|
150
|
-
callback?.(highlightTokens(h, options.code, language));
|
|
151
|
-
} catch {
|
|
152
|
-
// Parse failure for this block - leave it as plaintext.
|
|
153
|
-
}
|
|
154
|
-
});
|
|
155
|
-
return null;
|
|
156
|
-
},
|
|
157
|
-
};
|
|
158
|
-
}
|