@dbx-tools/ui-mastra 0.9.129 → 0.9.132
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 +155 -166
- package/index.ts +6 -3
- package/package.json +7 -6
- package/src/react/bubbles.tsx +3 -1
- package/src/react/chat-composer.tsx +93 -38
- package/src/react/chat-sessions.ts +9 -0
- package/src/react/chat-stream.ts +12 -0
- package/src/react/chat-view.tsx +2 -0
- package/src/react/embed-slots.tsx +40 -10
- package/src/react/index.ts +5 -1
- package/src/react/mastra-chat.tsx +330 -55
- package/src/react/types.ts +2 -0
- package/src/support/active-runs.ts +8 -2
- package/src/support/export.ts +30 -15
- package/src/support/genie-client.ts +220 -0
- package/src/support/index.ts +4 -1
- package/src/support/mastra-client.ts +49 -14
- package/src/support/thread-persistence.ts +9 -0
- package/src/support/thread-sessions.ts +19 -0
|
@@ -256,6 +256,7 @@ type ChatComposerProps = {
|
|
|
256
256
|
composerActions: ChatViewProps["composerActions"];
|
|
257
257
|
modelSelectorActions: ChatViewProps["modelSelectorActions"];
|
|
258
258
|
composerLeadingActions: ChatViewProps["composerLeadingActions"];
|
|
259
|
+
allowAttachments: NonNullable<ChatViewProps["allowAttachments"]>;
|
|
259
260
|
isLoadingHistory: NonNullable<ChatViewProps["isLoadingHistory"]>;
|
|
260
261
|
onClear: ChatViewProps["onClear"];
|
|
261
262
|
onExportConversation: ChatViewProps["onExportConversation"];
|
|
@@ -284,6 +285,7 @@ export const ChatComposer = ({
|
|
|
284
285
|
composerActions,
|
|
285
286
|
modelSelectorActions,
|
|
286
287
|
composerLeadingActions,
|
|
288
|
+
allowAttachments,
|
|
287
289
|
isLoadingHistory,
|
|
288
290
|
onClear,
|
|
289
291
|
onExportConversation,
|
|
@@ -293,7 +295,9 @@ export const ChatComposer = ({
|
|
|
293
295
|
const [files, setFiles] = useState<FileUIPart[]>([]);
|
|
294
296
|
const [fileError, setFileError] = useState<string | null>(null);
|
|
295
297
|
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
|
|
298
|
+
const [isDraggingFiles, setIsDraggingFiles] = useState(false);
|
|
296
299
|
const inputId = useId();
|
|
300
|
+
const dragDepthRef = useRef(0);
|
|
297
301
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
298
302
|
const inputGroupRef = useRef<HTMLDivElement>(null);
|
|
299
303
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -324,8 +328,14 @@ export const ChatComposer = ({
|
|
|
324
328
|
onResumeFollow();
|
|
325
329
|
};
|
|
326
330
|
|
|
327
|
-
const addFiles = async (selected: FileList | null) => {
|
|
331
|
+
const addFiles = async (selected: FileList | readonly File[] | null) => {
|
|
328
332
|
if (!selected?.length) return;
|
|
333
|
+
const selectedFiles = Array.from(selected);
|
|
334
|
+
const imageFiles = selectedFiles.filter((file) => file.type.startsWith("image/"));
|
|
335
|
+
if (imageFiles.length === 0) {
|
|
336
|
+
setFileError("Only image files can be attached");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
329
339
|
const remaining = MAX_COMPOSER_IMAGES - files.length;
|
|
330
340
|
if (remaining <= 0) {
|
|
331
341
|
setFileError(`You can attach up to ${MAX_COMPOSER_IMAGES} images`);
|
|
@@ -335,12 +345,12 @@ export const ChatComposer = ({
|
|
|
335
345
|
setFileError(null);
|
|
336
346
|
try {
|
|
337
347
|
const next = await Promise.all(
|
|
338
|
-
|
|
339
|
-
.slice(0, remaining)
|
|
340
|
-
.map((file) => imageFileToUIPart(file)),
|
|
348
|
+
imageFiles.slice(0, remaining).map((file) => imageFileToUIPart(file)),
|
|
341
349
|
);
|
|
342
350
|
setFiles((current) => [...current, ...next]);
|
|
343
|
-
if (
|
|
351
|
+
if (selectedFiles.length !== imageFiles.length) {
|
|
352
|
+
setFileError("Only image files were added");
|
|
353
|
+
} else if (imageFiles.length > remaining) {
|
|
344
354
|
setFileError(`Only the first ${remaining} images were added`);
|
|
345
355
|
}
|
|
346
356
|
} catch (error) {
|
|
@@ -376,9 +386,39 @@ export const ChatComposer = ({
|
|
|
376
386
|
/>
|
|
377
387
|
<InputGroup
|
|
378
388
|
ref={inputGroupRef}
|
|
379
|
-
className=
|
|
389
|
+
className={cn(
|
|
390
|
+
"rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md",
|
|
391
|
+
isDraggingFiles && "border-primary ring-2 ring-primary/20",
|
|
392
|
+
)}
|
|
393
|
+
onDragEnter={(event) => {
|
|
394
|
+
if (!event.dataTransfer.types.includes("Files")) return;
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
if (!allowAttachments) return;
|
|
397
|
+
dragDepthRef.current += 1;
|
|
398
|
+
setIsDraggingFiles(true);
|
|
399
|
+
}}
|
|
400
|
+
onDragOver={(event) => {
|
|
401
|
+
if (!event.dataTransfer.types.includes("Files")) return;
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
if (!allowAttachments) {
|
|
404
|
+
event.dataTransfer.dropEffect = "none";
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
event.dataTransfer.dropEffect = "copy";
|
|
408
|
+
}}
|
|
409
|
+
onDragLeave={(event) => {
|
|
410
|
+
if (!event.dataTransfer.types.includes("Files")) return;
|
|
411
|
+
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
412
|
+
if (dragDepthRef.current === 0) setIsDraggingFiles(false);
|
|
413
|
+
}}
|
|
414
|
+
onDrop={(event) => {
|
|
415
|
+
event.preventDefault();
|
|
416
|
+
dragDepthRef.current = 0;
|
|
417
|
+
setIsDraggingFiles(false);
|
|
418
|
+
if (allowAttachments) void addFiles(event.dataTransfer.files);
|
|
419
|
+
}}
|
|
380
420
|
>
|
|
381
|
-
{files.length > 0 && (
|
|
421
|
+
{allowAttachments && files.length > 0 && (
|
|
382
422
|
<InputGroupAddon
|
|
383
423
|
align="block-start"
|
|
384
424
|
className="max-w-full justify-start gap-2 overflow-x-auto pb-0"
|
|
@@ -415,6 +455,15 @@ export const ChatComposer = ({
|
|
|
415
455
|
ref={textareaRef}
|
|
416
456
|
value={input}
|
|
417
457
|
onChange={(event) => setInput(event.target.value)}
|
|
458
|
+
onPaste={(event) => {
|
|
459
|
+
if (!allowAttachments) return;
|
|
460
|
+
const pastedImages = Array.from(event.clipboardData.files).filter((file) =>
|
|
461
|
+
file.type.startsWith("image/"),
|
|
462
|
+
);
|
|
463
|
+
if (pastedImages.length === 0) return;
|
|
464
|
+
event.preventDefault();
|
|
465
|
+
void addFiles(pastedImages);
|
|
466
|
+
}}
|
|
418
467
|
onKeyDown={(event) => {
|
|
419
468
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
420
469
|
event.preventDefault();
|
|
@@ -425,7 +474,7 @@ export const ChatComposer = ({
|
|
|
425
474
|
rows={1}
|
|
426
475
|
className="field-sizing-fixed min-h-10 max-h-64 w-full flex-none overflow-y-auto px-4 pb-2 pt-4 text-sm"
|
|
427
476
|
/>
|
|
428
|
-
{fileError && (
|
|
477
|
+
{allowAttachments && fileError && (
|
|
429
478
|
<div className="px-4 text-xs text-destructive" role="alert">
|
|
430
479
|
{fileError}
|
|
431
480
|
</div>
|
|
@@ -435,36 +484,42 @@ export const ChatComposer = ({
|
|
|
435
484
|
className="flex w-full flex-row flex-wrap items-center justify-between gap-2 px-3 pb-3 pt-1"
|
|
436
485
|
>
|
|
437
486
|
<div className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
<
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
487
|
+
{allowAttachments && (
|
|
488
|
+
<>
|
|
489
|
+
<input
|
|
490
|
+
ref={fileInputRef}
|
|
491
|
+
type="file"
|
|
492
|
+
accept="image/*"
|
|
493
|
+
multiple
|
|
494
|
+
className="sr-only"
|
|
495
|
+
onChange={(event) => void addFiles(event.currentTarget.files)}
|
|
496
|
+
/>
|
|
497
|
+
<Tooltip>
|
|
498
|
+
<TooltipTrigger asChild>
|
|
499
|
+
<Button
|
|
500
|
+
type="button"
|
|
501
|
+
variant="outline"
|
|
502
|
+
size="icon"
|
|
503
|
+
className="size-7 rounded-full [&_svg]:size-3"
|
|
504
|
+
onClick={() => fileInputRef.current?.click()}
|
|
505
|
+
disabled={
|
|
506
|
+
isLoadingHistory ||
|
|
507
|
+
isPreparingFiles ||
|
|
508
|
+
files.length >= MAX_COMPOSER_IMAGES
|
|
509
|
+
}
|
|
510
|
+
aria-label="Upload images"
|
|
511
|
+
>
|
|
512
|
+
{isPreparingFiles ? (
|
|
513
|
+
<Spinner className="size-3" />
|
|
514
|
+
) : (
|
|
515
|
+
<ImagePlusIcon className="size-3" />
|
|
516
|
+
)}
|
|
517
|
+
</Button>
|
|
518
|
+
</TooltipTrigger>
|
|
519
|
+
<TooltipContent>Upload images</TooltipContent>
|
|
520
|
+
</Tooltip>
|
|
521
|
+
</>
|
|
522
|
+
)}
|
|
468
523
|
{composerLeadingActions}
|
|
469
524
|
{onExportConversation && (
|
|
470
525
|
<ExportMenu
|
|
@@ -55,6 +55,14 @@ export function useChatSessions(activeThreadId: string | undefined) {
|
|
|
55
55
|
sessionsRef.current.delete(threadId);
|
|
56
56
|
setRevision((current) => current + 1);
|
|
57
57
|
}, []);
|
|
58
|
+
/** Re-key one session, keeping its transcript and run state intact. */
|
|
59
|
+
const moveSession = useCallback((fromThreadId: string, toThreadId: string) => {
|
|
60
|
+
const session = sessionsRef.current.get(fromThreadId);
|
|
61
|
+
if (!session || fromThreadId === toThreadId) return;
|
|
62
|
+
sessionsRef.current.delete(fromThreadId);
|
|
63
|
+
sessionsRef.current.set(toThreadId, session);
|
|
64
|
+
setRevision((current) => current + 1);
|
|
65
|
+
}, []);
|
|
58
66
|
const activeKey = sessionKey(activeThreadId);
|
|
59
67
|
const activeSession = useMemo(() => getSession(activeKey), [activeKey, getSession, revision]);
|
|
60
68
|
const streamingThreadIds = useMemo(() => {
|
|
@@ -70,6 +78,7 @@ export function useChatSessions(activeThreadId: string | undefined) {
|
|
|
70
78
|
activeKey,
|
|
71
79
|
activeSession,
|
|
72
80
|
getSession,
|
|
81
|
+
moveSession,
|
|
73
82
|
removeSession,
|
|
74
83
|
resetSession,
|
|
75
84
|
streamingThreadIds,
|
package/src/react/chat-stream.ts
CHANGED
|
@@ -59,6 +59,18 @@ export function closeOnTerminalChunk(
|
|
|
59
59
|
});
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** Drain a run observation until its terminal finish or abort chunk arrives. */
|
|
63
|
+
export async function waitForTerminalChunk(stream: ReadableStream<UIMessageChunk>): Promise<void> {
|
|
64
|
+
const reader = closeOnTerminalChunk(stream).getReader();
|
|
65
|
+
try {
|
|
66
|
+
while (!(await reader.read()).done) {
|
|
67
|
+
// Draining is the synchronization point; the replacement turn does not replay this output.
|
|
68
|
+
}
|
|
69
|
+
} finally {
|
|
70
|
+
reader.releaseLock();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
/** Replace or append one assistant message without disturbing the transcript. */
|
|
63
75
|
function upsertAssistant(messages: UIMessage[], assistant: UIMessage): UIMessage[] {
|
|
64
76
|
const next = [...messages];
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -35,6 +35,7 @@ export const ChatView = ({
|
|
|
35
35
|
composerActions,
|
|
36
36
|
modelSelectorActions,
|
|
37
37
|
composerLeadingActions,
|
|
38
|
+
allowAttachments = true,
|
|
38
39
|
onLoadMore,
|
|
39
40
|
isLoadingMore = false,
|
|
40
41
|
hasMore = false,
|
|
@@ -136,6 +137,7 @@ export const ChatView = ({
|
|
|
136
137
|
composerActions={composerActions}
|
|
137
138
|
modelSelectorActions={modelSelectorActions}
|
|
138
139
|
composerLeadingActions={composerLeadingActions}
|
|
140
|
+
allowAttachments={allowAttachments}
|
|
139
141
|
isLoadingHistory={isLoadingHistory}
|
|
140
142
|
onClear={onClear}
|
|
141
143
|
onExportConversation={onExportConversation}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GenieQueryVisualization } from "@databricks/appkit-ui/react";
|
|
1
2
|
import { markers, type ParsedMarker } from "@dbx-tools/shared-mastra";
|
|
2
3
|
import { Spinner } from "@dbx-tools/ui/react";
|
|
3
4
|
import { ClockIcon } from "lucide-react";
|
|
@@ -6,13 +7,16 @@ import { DataGrid, humanizeLabel } from "./data-grid.tsx";
|
|
|
6
7
|
import { AssistantMarkdown } from "./markdown.tsx";
|
|
7
8
|
import { normalizeChartOption } from "../support/chart-option.ts";
|
|
8
9
|
import { useChartChrome } from "../support/chart-theme.ts";
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
useChartFetch,
|
|
12
|
+
useStatementFetch,
|
|
13
|
+
useVisualizationFetch,
|
|
14
|
+
} from "../support/mastra-client.ts";
|
|
10
15
|
|
|
11
16
|
const ReactECharts = lazy(() => import("echarts-for-react"));
|
|
12
17
|
|
|
13
|
-
// Inline embed slots
|
|
14
|
-
//
|
|
15
|
-
// that interleaves those slots with the surrounding markdown.
|
|
18
|
+
// Inline embed slots resolved from markers in assistant prose, plus the
|
|
19
|
+
// splitter that interleaves those slots with surrounding markdown.
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
22
|
* Frame shared by every chart-slot state so the layout stays
|
|
@@ -170,11 +174,27 @@ const DataSlot = ({ statementId }: { statementId: string }) => {
|
|
|
170
174
|
);
|
|
171
175
|
};
|
|
172
176
|
|
|
173
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Native AppKit Genie visualization for a query Genie chose to visualize.
|
|
179
|
+
* The same statement payload backs its Chart and Table tabs.
|
|
180
|
+
*/
|
|
181
|
+
const GenieVisualizationSlot = ({ statementId }: { statementId: string }) => {
|
|
182
|
+
const { data, loading, error } = useVisualizationFetch(statementId);
|
|
183
|
+
if (!loading && !error && data === undefined) return <ExpiredSlot type="visualization" />;
|
|
184
|
+
if (!data) return null;
|
|
185
|
+
return (
|
|
186
|
+
<div className="not-prose my-3 max-w-full rounded border border-border bg-card p-3">
|
|
187
|
+
<GenieQueryVisualization data={data.visualization} />
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** One slice of an assistant message. */
|
|
174
193
|
type RenderSegment =
|
|
175
194
|
| { kind: "text"; text: string }
|
|
176
195
|
| { kind: "chart"; chartId: string }
|
|
177
|
-
| { kind: "data"; statementId: string }
|
|
196
|
+
| { kind: "data"; statementId: string }
|
|
197
|
+
| { kind: "visualization"; statementId: string };
|
|
178
198
|
|
|
179
199
|
/**
|
|
180
200
|
* Map one parsed marker onto its render segment. The marker grammar
|
|
@@ -185,8 +205,7 @@ type RenderSegment =
|
|
|
185
205
|
* collapses to an empty text segment: the marker is consumed so no
|
|
186
206
|
* literal `[<type>:...]` leaks into the prose, but no slot renders and
|
|
187
207
|
* no `/embed/<type>/:id` request fires. Only a UUID-shaped id with a
|
|
188
|
-
* known, renderable type
|
|
189
|
-
* resolves to a slot.
|
|
208
|
+
* known, renderable type resolves to a slot.
|
|
190
209
|
*/
|
|
191
210
|
const markerSegment = (marker: ParsedMarker): RenderSegment => {
|
|
192
211
|
if (!markers.isUuid(marker.id)) return { kind: "text", text: "" };
|
|
@@ -195,6 +214,8 @@ const markerSegment = (marker: ParsedMarker): RenderSegment => {
|
|
|
195
214
|
return { kind: "chart", chartId: marker.id };
|
|
196
215
|
case "data":
|
|
197
216
|
return { kind: "data", statementId: marker.id };
|
|
217
|
+
case "visualization":
|
|
218
|
+
return { kind: "visualization", statementId: marker.id };
|
|
198
219
|
default:
|
|
199
220
|
return { kind: "text", text: "" };
|
|
200
221
|
}
|
|
@@ -220,8 +241,7 @@ const splitTextWithEmbeds = (text: string): RenderSegment[] => {
|
|
|
220
241
|
};
|
|
221
242
|
|
|
222
243
|
/**
|
|
223
|
-
* Render the assistant's markdown with
|
|
224
|
-
* placed at their inline marker positions. Each prose segment
|
|
244
|
+
* Render the assistant's markdown with embeds at their marker positions. Each prose segment
|
|
225
245
|
* is its own {@link AssistantMarkdown} so streaming chunks still
|
|
226
246
|
* incrementally parse correctly; embed slots break the markdown
|
|
227
247
|
* flow with full-width block elements.
|
|
@@ -234,6 +254,8 @@ const splitTextWithEmbeds = (text: string): RenderSegment[] => {
|
|
|
234
254
|
* - `[data:<statement_id>]` -> {@link DataSlot} fetches the
|
|
235
255
|
* statement rows OBO-scoped and renders an inline Table
|
|
236
256
|
* (or nothing on 404 / empty result).
|
|
257
|
+
* - `[visualization:<statement_id>]` -> {@link GenieVisualizationSlot}
|
|
258
|
+
* renders AppKit UI's native Genie Chart/Table view.
|
|
237
259
|
*
|
|
238
260
|
* Stale markers in persisted transcript text are silently
|
|
239
261
|
* dropped on reload so the prose around them stays clean.
|
|
@@ -265,6 +287,14 @@ export const MarkdownWithEmbeds = ({
|
|
|
265
287
|
if (seg.kind === "chart") {
|
|
266
288
|
return <ChartSlot key={`c-${i}-${seg.chartId}`} chartId={seg.chartId} />;
|
|
267
289
|
}
|
|
290
|
+
if (seg.kind === "visualization") {
|
|
291
|
+
return (
|
|
292
|
+
<GenieVisualizationSlot
|
|
293
|
+
key={`v-${i}-${seg.statementId}`}
|
|
294
|
+
statementId={seg.statementId}
|
|
295
|
+
/>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
268
298
|
return <DataSlot key={`d-${i}-${seg.statementId}`} statementId={seg.statementId} />;
|
|
269
299
|
})}
|
|
270
300
|
</>
|
package/src/react/index.ts
CHANGED
|
@@ -45,7 +45,11 @@ export type {
|
|
|
45
45
|
} from "../support/request-context.ts";
|
|
46
46
|
export { dedupeSuggestions } from "./suggestions.ts";
|
|
47
47
|
export { MastraChat, useMastraChat } from "./mastra-chat.tsx";
|
|
48
|
-
export type {
|
|
48
|
+
export type {
|
|
49
|
+
MastraChatProps,
|
|
50
|
+
ThreadIdChangeReason,
|
|
51
|
+
UseMastraChatOptions,
|
|
52
|
+
} from "./mastra-chat.tsx";
|
|
49
53
|
export { ThreadSidebar } from "./thread-sidebar.tsx";
|
|
50
54
|
export type { ThreadSidebarProps } from "./thread-sidebar.tsx";
|
|
51
55
|
export { ThreadActions, ThreadTabs } from "./thread-tabs.tsx";
|