@dbx-tools/ui-mastra 0.1.22 → 0.1.24
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 +1 -1
- package/package.json +7 -6
- package/src/react/chat-view.tsx +200 -62
- package/src/react/export-menu.tsx +17 -6
- package/src/react/mastra-chat.tsx +28 -2
- package/src/react/thread-sidebar.tsx +1 -1
- package/src/react/types.ts +7 -2
- package/src/support/export.ts +119 -44
package/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ export type { UseMastraChatOptions, MastraChatProps } from "./src/react/mastra-c
|
|
|
26
26
|
export type { SuggestionPillsProps } from "./src/react/suggestion-pills";
|
|
27
27
|
export type { ThreadSidebarProps } from "./src/react/thread-sidebar";
|
|
28
28
|
export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types";
|
|
29
|
-
export type { ExportFormat, EmbedResolver, ExportChatOptions } from "./src/support/export";
|
|
29
|
+
export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./src/support/export";
|
|
30
30
|
export type { ByIdFetchState } from "./src/support/mastra-client";
|
|
31
31
|
export type { MastraStreamChunk, MastraStreamResponse } from "./src/support/mastra-stream";
|
|
32
32
|
export type { ThreadSession } from "./src/support/thread-sessions";
|
package/package.json
CHANGED
|
@@ -26,18 +26,19 @@
|
|
|
26
26
|
"shiki": "^3.0.0",
|
|
27
27
|
"sql-formatter": "^15.6.9",
|
|
28
28
|
"streamdown": "^2.5.0",
|
|
29
|
-
"@dbx-tools/shared-core": "0.1.
|
|
30
|
-
"@dbx-tools/shared-genie": "0.1.
|
|
31
|
-
"@dbx-tools/shared-mastra": "0.1.
|
|
32
|
-
"@dbx-tools/shared-model": "0.1.
|
|
33
|
-
"@dbx-tools/ui-appkit": "0.1.
|
|
29
|
+
"@dbx-tools/shared-core": "0.1.24",
|
|
30
|
+
"@dbx-tools/shared-genie": "0.1.24",
|
|
31
|
+
"@dbx-tools/shared-mastra": "0.1.24",
|
|
32
|
+
"@dbx-tools/shared-model": "0.1.24",
|
|
33
|
+
"@dbx-tools/ui-appkit": "0.1.24",
|
|
34
|
+
"@dbx-tools/ui-branding": "0.1.24"
|
|
34
35
|
},
|
|
35
36
|
"main": "index.ts",
|
|
36
37
|
"license": "UNLICENSED",
|
|
37
38
|
"publishConfig": {
|
|
38
39
|
"access": "public"
|
|
39
40
|
},
|
|
40
|
-
"version": "0.1.
|
|
41
|
+
"version": "0.1.24",
|
|
41
42
|
"types": "index.ts",
|
|
42
43
|
"type": "module",
|
|
43
44
|
"exports": {
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -63,6 +63,30 @@ const TOP_LOAD_MORE_THRESHOLD_PX = 120;
|
|
|
63
63
|
*/
|
|
64
64
|
const DEFAULT_MODEL_VALUE = "__default__";
|
|
65
65
|
|
|
66
|
+
/** Tailwind's `md` breakpoint (px). Below this the sidebar becomes a drawer. */
|
|
67
|
+
const MOBILE_BREAKPOINT_PX = 768;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `true` on a phone-width viewport (< {@link MOBILE_BREAKPOINT_PX}). Tracks
|
|
71
|
+
* `matchMedia` so the layout switches live on resize/rotate. SSR-safe: defaults
|
|
72
|
+
* to `false` (desktop) when `window` is unavailable.
|
|
73
|
+
*/
|
|
74
|
+
const useIsMobile = (): boolean => {
|
|
75
|
+
const query = `(max-width: ${MOBILE_BREAKPOINT_PX - 1}px)`;
|
|
76
|
+
const [isMobile, setIsMobile] = useState(() =>
|
|
77
|
+
typeof window === "undefined" ? false : window.matchMedia(query).matches,
|
|
78
|
+
);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (typeof window === "undefined") return;
|
|
81
|
+
const mql = window.matchMedia(query);
|
|
82
|
+
const onChange = () => setIsMobile(mql.matches);
|
|
83
|
+
onChange();
|
|
84
|
+
mql.addEventListener("change", onChange);
|
|
85
|
+
return () => mql.removeEventListener("change", onChange);
|
|
86
|
+
}, [query]);
|
|
87
|
+
return isMobile;
|
|
88
|
+
};
|
|
89
|
+
|
|
66
90
|
export const ChatView = ({
|
|
67
91
|
messages,
|
|
68
92
|
status,
|
|
@@ -101,6 +125,8 @@ export const ChatView = ({
|
|
|
101
125
|
const [input, setInput] = useState("");
|
|
102
126
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
103
127
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
128
|
+
// Composer textarea, auto-grown with its content up to the CSS `max-h`.
|
|
129
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
104
130
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
105
131
|
// Mirror `isAtBottom` into a ref so the ResizeObserver below (created
|
|
106
132
|
// per transcript mount, not per render) reads the latest value
|
|
@@ -194,6 +220,16 @@ export const ChatView = ({
|
|
|
194
220
|
});
|
|
195
221
|
};
|
|
196
222
|
|
|
223
|
+
// Grow the composer with its content (up to the textarea's CSS `max-h`,
|
|
224
|
+
// after which it scrolls internally), then shrink back as text is removed.
|
|
225
|
+
// Runs on every `input` change so paste / multi-line typing feels native.
|
|
226
|
+
useLayoutEffect(() => {
|
|
227
|
+
const el = textareaRef.current;
|
|
228
|
+
if (!el) return;
|
|
229
|
+
el.style.height = "auto";
|
|
230
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
231
|
+
}, [input]);
|
|
232
|
+
|
|
197
233
|
// A turn is in flight from the moment the run opens (`submitted`)
|
|
198
234
|
// until the server signals done (`ready`/`error`). Used to gate new
|
|
199
235
|
// submissions and to swap the composer's Send button for Stop.
|
|
@@ -208,6 +244,15 @@ export const ChatView = ({
|
|
|
208
244
|
if (!text) return;
|
|
209
245
|
sendMessage({ text });
|
|
210
246
|
setInput("");
|
|
247
|
+
// Sending is an explicit "I want to see the response" action, so
|
|
248
|
+
// re-pin to the bottom even if the user had scrolled up. Without this
|
|
249
|
+
// the freshly-appended user turn + waiting row can leave `isAtBottom`
|
|
250
|
+
// false, suppressing auto-scroll and forcing a manual scroll down.
|
|
251
|
+
setIsAtBottom(true);
|
|
252
|
+
requestAnimationFrame(() => {
|
|
253
|
+
const el = scrollRef.current;
|
|
254
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
255
|
+
});
|
|
211
256
|
};
|
|
212
257
|
|
|
213
258
|
const lastMessage = messages.at(-1);
|
|
@@ -233,7 +278,18 @@ export const ChatView = ({
|
|
|
233
278
|
? "Working..."
|
|
234
279
|
: "Composing response...";
|
|
235
280
|
|
|
236
|
-
|
|
281
|
+
// Model display is intent-driven, not load-driven: as soon as the host
|
|
282
|
+
// wires `onModelChange` we reserve the row and show the current model,
|
|
283
|
+
// so it never "pops in" once the async catalogue lands. It renders as a
|
|
284
|
+
// clickable picker only when there's an actual choice (models loaded),
|
|
285
|
+
// otherwise as static text.
|
|
286
|
+
const showModelDisplay = Boolean(onModelChange);
|
|
287
|
+
const modelChangeable = Boolean(models && models.length > 0);
|
|
288
|
+
// Label the current model by its human-readable name when the catalogue
|
|
289
|
+
// carries one (`displayName`), falling back to the raw endpoint id, then
|
|
290
|
+
// to "Server default" when no model is pinned.
|
|
291
|
+
const currentModelLabel =
|
|
292
|
+
models?.find((m) => m.name === model)?.displayName || model || "Server default";
|
|
237
293
|
const showClear = Boolean(onClear);
|
|
238
294
|
const showExport = Boolean(onExportConversation);
|
|
239
295
|
// The conversation sidebar turns on once the host wires both the
|
|
@@ -244,12 +300,35 @@ export const ChatView = ({
|
|
|
244
300
|
// open flag. Defaults to open.
|
|
245
301
|
const showSidebar = Boolean(threads && onSelectThread);
|
|
246
302
|
const [internalSidebarOpen, setInternalSidebarOpen] = useState(true);
|
|
247
|
-
|
|
248
|
-
|
|
303
|
+
// Desktop inline-sidebar open state (persisted by the driver when it
|
|
304
|
+
// supplies `sidebarOpen`/`onToggleSidebar`, else a session-only flag).
|
|
305
|
+
const desktopSidebarOpen = sidebarOpenProp ?? internalSidebarOpen;
|
|
306
|
+
const toggleDesktopSidebar = () => {
|
|
249
307
|
if (onToggleSidebar) onToggleSidebar();
|
|
250
308
|
else setInternalSidebarOpen((open) => !open);
|
|
251
309
|
};
|
|
252
|
-
|
|
310
|
+
// Are we on a phone-width viewport (< md / 768px)? Drives whether the
|
|
311
|
+
// sidebar renders inline (desktop) or as an overlay drawer (mobile), and
|
|
312
|
+
// which open-state the header toggle flips.
|
|
313
|
+
const isMobile = useIsMobile();
|
|
314
|
+
// Mobile drawer is a SESSION-only, default-closed state so a persisted
|
|
315
|
+
// "open" desktop preference never auto-opens the drawer over the chat on a
|
|
316
|
+
// phone. Reset closed whenever we drop back to a mobile viewport.
|
|
317
|
+
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (!isMobile) setMobileDrawerOpen(false);
|
|
320
|
+
}, [isMobile]);
|
|
321
|
+
// Unified state/handlers the render + header use, resolved by viewport.
|
|
322
|
+
const sidebarOpen = isMobile ? mobileDrawerOpen : desktopSidebarOpen;
|
|
323
|
+
const toggleSidebar = () => {
|
|
324
|
+
if (isMobile) setMobileDrawerOpen((open) => !open);
|
|
325
|
+
else toggleDesktopSidebar();
|
|
326
|
+
};
|
|
327
|
+
// Top bar carries only the sidebar toggle + Clear now; the model picker and
|
|
328
|
+
// export live in a toolbar row just above the composer (Janna-style), closer
|
|
329
|
+
// to where the user is typing.
|
|
330
|
+
const showHeader = showClear || showSidebar;
|
|
331
|
+
const showComposerToolbar = showModelDisplay || showExport;
|
|
253
332
|
|
|
254
333
|
// Local "in-flight" + confirm latch for the clear button so the
|
|
255
334
|
// user can't double-fire the DELETE and so a stray click doesn't
|
|
@@ -283,29 +362,77 @@ export const ChatView = ({
|
|
|
283
362
|
* with the message column regardless of whether a scrollbar is
|
|
284
363
|
* showing.
|
|
285
364
|
*/}
|
|
286
|
-
<div className={cn("flex h-full", className)}>
|
|
287
|
-
{showSidebar &&
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
365
|
+
<div className={cn("flex h-full min-h-0", className)}>
|
|
366
|
+
{showSidebar &&
|
|
367
|
+
(isMobile ? (
|
|
368
|
+
/*
|
|
369
|
+
* Mobile: a fixed overlay drawer with a tap-to-close backdrop, so
|
|
370
|
+
* the conversation list never eats horizontal space from the chat
|
|
371
|
+
* on a phone. Selecting a thread / starting a new one closes it so
|
|
372
|
+
* the transcript comes back into view. Session-only + default
|
|
373
|
+
* closed (see `mobileDrawerOpen`).
|
|
374
|
+
*/
|
|
375
|
+
mobileDrawerOpen && (
|
|
376
|
+
<div className="fixed inset-0 z-40 flex">
|
|
377
|
+
<div
|
|
378
|
+
className="absolute inset-0 bg-black/50"
|
|
379
|
+
onClick={toggleSidebar}
|
|
380
|
+
aria-hidden="true"
|
|
381
|
+
/>
|
|
382
|
+
<ThreadSidebar
|
|
383
|
+
threads={threads ?? []}
|
|
384
|
+
{...(activeThreadId ? { activeThreadId } : {})}
|
|
385
|
+
streamingThreadIds={streamingThreadIds}
|
|
386
|
+
isLoading={isLoadingThreads}
|
|
387
|
+
onSelect={(id) => {
|
|
388
|
+
onSelectThread?.(id);
|
|
389
|
+
toggleSidebar();
|
|
390
|
+
}}
|
|
391
|
+
{...(onNewThread
|
|
392
|
+
? {
|
|
393
|
+
onNew: () => {
|
|
394
|
+
onNewThread();
|
|
395
|
+
toggleSidebar();
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
: {})}
|
|
399
|
+
{...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
|
|
400
|
+
{...(onRenameThread ? { onRename: onRenameThread } : {})}
|
|
401
|
+
onHide={toggleSidebar}
|
|
402
|
+
className="relative z-10 w-[85vw] max-w-xs shadow-xl"
|
|
403
|
+
/>
|
|
404
|
+
</div>
|
|
405
|
+
)
|
|
406
|
+
) : (
|
|
407
|
+
/*
|
|
408
|
+
* Desktop: an inline flex child sharing the row with the chat
|
|
409
|
+
* column, using the persisted open/hide preference.
|
|
410
|
+
*/
|
|
411
|
+
desktopSidebarOpen && (
|
|
412
|
+
<ThreadSidebar
|
|
413
|
+
threads={threads ?? []}
|
|
414
|
+
{...(activeThreadId ? { activeThreadId } : {})}
|
|
415
|
+
streamingThreadIds={streamingThreadIds}
|
|
416
|
+
isLoading={isLoadingThreads}
|
|
417
|
+
onSelect={(id) => onSelectThread?.(id)}
|
|
418
|
+
{...(onNewThread ? { onNew: onNewThread } : {})}
|
|
419
|
+
{...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
|
|
420
|
+
{...(onRenameThread ? { onRename: onRenameThread } : {})}
|
|
421
|
+
onHide={toggleSidebar}
|
|
422
|
+
/>
|
|
423
|
+
)
|
|
424
|
+
))}
|
|
425
|
+
<div className="flex h-full min-w-0 flex-1 flex-col">
|
|
301
426
|
{showHeader && (
|
|
302
|
-
<div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-
|
|
303
|
-
<div className="flex items-center gap-2">
|
|
427
|
+
<div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-2 px-3 pb-2 pt-1 text-xs text-muted-foreground md:gap-3 md:px-6">
|
|
428
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
304
429
|
{/*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
430
|
+
* Sidebar toggle. On mobile it's a persistent hamburger (the
|
|
431
|
+
* overlay drawer has no always-visible hide button). On desktop
|
|
432
|
+
* the panel hides itself, so the header only needs a "show"
|
|
433
|
+
* affordance while the inline sidebar is collapsed.
|
|
307
434
|
*/}
|
|
308
|
-
{showSidebar && !
|
|
435
|
+
{showSidebar && (isMobile || !desktopSidebarOpen) && (
|
|
309
436
|
<Tooltip>
|
|
310
437
|
<TooltipTrigger asChild>
|
|
311
438
|
<Button
|
|
@@ -313,47 +440,18 @@ export const ChatView = ({
|
|
|
313
440
|
variant="ghost"
|
|
314
441
|
size="icon-sm"
|
|
315
442
|
onClick={toggleSidebar}
|
|
316
|
-
aria-label="Show conversations"
|
|
443
|
+
aria-label={sidebarOpen ? "Hide conversations" : "Show conversations"}
|
|
317
444
|
>
|
|
318
445
|
<PanelLeftIcon className="size-4" />
|
|
319
446
|
</Button>
|
|
320
447
|
</TooltipTrigger>
|
|
321
|
-
<TooltipContent>
|
|
448
|
+
<TooltipContent>
|
|
449
|
+
{sidebarOpen ? "Hide conversations" : "Show conversations"}
|
|
450
|
+
</TooltipContent>
|
|
322
451
|
</Tooltip>
|
|
323
452
|
)}
|
|
324
453
|
</div>
|
|
325
|
-
<div className="flex items-center gap-3">
|
|
326
|
-
{showModelPicker && (
|
|
327
|
-
<div className="flex items-center gap-2">
|
|
328
|
-
<span>Model</span>
|
|
329
|
-
<Select
|
|
330
|
-
value={model ? model : DEFAULT_MODEL_VALUE}
|
|
331
|
-
onValueChange={(v) =>
|
|
332
|
-
onModelChange?.(v === DEFAULT_MODEL_VALUE ? "" : v)
|
|
333
|
-
}
|
|
334
|
-
>
|
|
335
|
-
<SelectTrigger size="sm" className="w-[260px]">
|
|
336
|
-
<SelectValue placeholder="Server default" />
|
|
337
|
-
</SelectTrigger>
|
|
338
|
-
<SelectContent>
|
|
339
|
-
<SelectItem value={DEFAULT_MODEL_VALUE}>
|
|
340
|
-
Server default
|
|
341
|
-
</SelectItem>
|
|
342
|
-
{models!.map((m) => (
|
|
343
|
-
<SelectItem key={m.name} value={m.name}>
|
|
344
|
-
{m.name}
|
|
345
|
-
</SelectItem>
|
|
346
|
-
))}
|
|
347
|
-
</SelectContent>
|
|
348
|
-
</Select>
|
|
349
|
-
</div>
|
|
350
|
-
)}
|
|
351
|
-
{showExport && (
|
|
352
|
-
<ExportMenu
|
|
353
|
-
onExport={(format) => void onExportConversation?.(format)}
|
|
354
|
-
tooltip="Export conversation"
|
|
355
|
-
/>
|
|
356
|
-
)}
|
|
454
|
+
<div className="flex min-w-0 items-center justify-end gap-2 md:gap-3">
|
|
357
455
|
{showClear && (
|
|
358
456
|
<Tooltip>
|
|
359
457
|
<TooltipTrigger asChild>
|
|
@@ -397,7 +495,7 @@ export const ChatView = ({
|
|
|
397
495
|
<div
|
|
398
496
|
ref={scrollRef}
|
|
399
497
|
onScroll={handleScroll}
|
|
400
|
-
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
|
498
|
+
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain [scrollbar-gutter:stable]"
|
|
401
499
|
>
|
|
402
500
|
{messages.length === 0 && !isLoadingHistory ? (
|
|
403
501
|
<Empty className="mx-auto h-full w-full max-w-4xl px-4 md:px-6">
|
|
@@ -499,7 +597,7 @@ export const ChatView = ({
|
|
|
499
597
|
)}
|
|
500
598
|
</div>
|
|
501
599
|
{!isAtBottom && (
|
|
502
|
-
<div className="pointer-events-none absolute inset-x-0 bottom-4 mx-auto flex w-full max-w-4xl justify-end px-4 md:px-6">
|
|
600
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-20 mx-auto flex w-full max-w-4xl justify-end px-4 md:px-6">
|
|
503
601
|
<Button
|
|
504
602
|
type="button"
|
|
505
603
|
variant="outline"
|
|
@@ -523,10 +621,11 @@ export const ChatView = ({
|
|
|
523
621
|
|
|
524
622
|
<form
|
|
525
623
|
onSubmit={handleSubmit}
|
|
526
|
-
className="mx-auto w-full max-w-4xl px-
|
|
624
|
+
className="mx-auto w-full max-w-4xl px-3 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6"
|
|
527
625
|
>
|
|
528
|
-
<InputGroup>
|
|
626
|
+
<InputGroup className="rounded-2xl border-border/80 shadow-sm transition-shadow focus-within:shadow-md">
|
|
529
627
|
<InputGroupTextarea
|
|
628
|
+
ref={textareaRef}
|
|
530
629
|
value={input}
|
|
531
630
|
onChange={(e) => setInput(e.target.value)}
|
|
532
631
|
onKeyDown={(e) => {
|
|
@@ -537,6 +636,7 @@ export const ChatView = ({
|
|
|
537
636
|
}}
|
|
538
637
|
placeholder="Send a message..."
|
|
539
638
|
rows={1}
|
|
639
|
+
className="max-h-48 text-base md:text-sm"
|
|
540
640
|
/>
|
|
541
641
|
<InputGroupAddon align="inline-end">
|
|
542
642
|
{isRunning && onStop ? (
|
|
@@ -566,6 +666,44 @@ export const ChatView = ({
|
|
|
566
666
|
)}
|
|
567
667
|
</InputGroupAddon>
|
|
568
668
|
</InputGroup>
|
|
669
|
+
{showComposerToolbar && (
|
|
670
|
+
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
|
671
|
+
{showModelDisplay &&
|
|
672
|
+
(modelChangeable ? (
|
|
673
|
+
<Select
|
|
674
|
+
value={model ? model : DEFAULT_MODEL_VALUE}
|
|
675
|
+
onValueChange={(v) =>
|
|
676
|
+
onModelChange?.(v === DEFAULT_MODEL_VALUE ? "" : v)
|
|
677
|
+
}
|
|
678
|
+
>
|
|
679
|
+
<SelectTrigger
|
|
680
|
+
size="sm"
|
|
681
|
+
className="h-7 w-auto max-w-[200px] gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
|
|
682
|
+
>
|
|
683
|
+
<SelectValue placeholder="Server default" />
|
|
684
|
+
</SelectTrigger>
|
|
685
|
+
<SelectContent>
|
|
686
|
+
<SelectItem value={DEFAULT_MODEL_VALUE}>Server default</SelectItem>
|
|
687
|
+
{models!.map((m) => (
|
|
688
|
+
<SelectItem key={m.name} value={m.name}>
|
|
689
|
+
{m.displayName || m.name}
|
|
690
|
+
</SelectItem>
|
|
691
|
+
))}
|
|
692
|
+
</SelectContent>
|
|
693
|
+
</Select>
|
|
694
|
+
) : (
|
|
695
|
+
<span className="max-w-[200px] truncate px-2.5 text-xs text-muted-foreground">
|
|
696
|
+
{currentModelLabel}
|
|
697
|
+
</span>
|
|
698
|
+
))}
|
|
699
|
+
{showExport && (
|
|
700
|
+
<ExportMenu
|
|
701
|
+
onExport={(format) => void onExportConversation?.(format)}
|
|
702
|
+
tooltip="Export conversation"
|
|
703
|
+
/>
|
|
704
|
+
)}
|
|
705
|
+
</div>
|
|
706
|
+
)}
|
|
569
707
|
</form>
|
|
570
708
|
</div>
|
|
571
709
|
</div>
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
TooltipContent,
|
|
9
9
|
TooltipTrigger,
|
|
10
10
|
} from "@dbx-tools/ui-appkit/react";
|
|
11
|
-
import { DownloadIcon } from "lucide-react";
|
|
11
|
+
import { DownloadIcon, FileTextIcon, PrinterIcon } from "lucide-react";
|
|
12
12
|
import type { ExportFormat } from "../support/export";
|
|
13
13
|
|
|
14
14
|
// Shared export affordance: a download button that opens a small menu of
|
|
@@ -16,9 +16,14 @@ import type { ExportFormat } from "../support/export";
|
|
|
16
16
|
// labelled) and per-message export (bubble action row, icon-only).
|
|
17
17
|
|
|
18
18
|
/** Menu entries, in display order. */
|
|
19
|
-
const FORMATS: ReadonlyArray<{
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const FORMATS: ReadonlyArray<{
|
|
20
|
+
format: ExportFormat;
|
|
21
|
+
label: string;
|
|
22
|
+
Icon: typeof DownloadIcon;
|
|
23
|
+
}> = [
|
|
24
|
+
{ format: "pdf", label: "PDF", Icon: DownloadIcon },
|
|
25
|
+
{ format: "print", label: "Print", Icon: PrinterIcon },
|
|
26
|
+
{ format: "markdown", label: "Markdown", Icon: FileTextIcon },
|
|
22
27
|
];
|
|
23
28
|
|
|
24
29
|
/**
|
|
@@ -41,7 +46,12 @@ export const ExportMenu = ({
|
|
|
41
46
|
<DownloadIcon className="size-3" />
|
|
42
47
|
</Button>
|
|
43
48
|
) : (
|
|
44
|
-
<Button
|
|
49
|
+
<Button
|
|
50
|
+
type="button"
|
|
51
|
+
size="sm"
|
|
52
|
+
variant="outline"
|
|
53
|
+
className="h-7 gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
|
|
54
|
+
>
|
|
45
55
|
<DownloadIcon className="size-3" />
|
|
46
56
|
Export
|
|
47
57
|
</Button>
|
|
@@ -60,8 +70,9 @@ export const ExportMenu = ({
|
|
|
60
70
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
61
71
|
)}
|
|
62
72
|
<DropdownMenuContent align="end">
|
|
63
|
-
{FORMATS.map(({ format, label }) => (
|
|
73
|
+
{FORMATS.map(({ format, label, Icon }) => (
|
|
64
74
|
<DropdownMenuItem key={format} onClick={() => onExport(format)}>
|
|
75
|
+
<Icon className="size-3.5" />
|
|
65
76
|
{label}
|
|
66
77
|
</DropdownMenuItem>
|
|
67
78
|
))}
|
|
@@ -7,6 +7,7 @@ import type { UIMessage } from "ai";
|
|
|
7
7
|
import { nanoid } from "nanoid";
|
|
8
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
9
|
import { exportChat, type EmbedResolver, type ExportFormat } from "../support/export";
|
|
10
|
+
import { useBrand } from "@dbx-tools/ui-branding/react";
|
|
10
11
|
import {
|
|
11
12
|
useMastraClient,
|
|
12
13
|
useMastraModels,
|
|
@@ -1139,6 +1140,21 @@ export const useMastraChat = (
|
|
|
1139
1140
|
const resourceId = threads.find((t) => t.resourceId)?.resourceId;
|
|
1140
1141
|
return resourceId ? `User (${resourceId})` : "User";
|
|
1141
1142
|
}, [threads]);
|
|
1143
|
+
// Brand styling for the exported document, resolved from the active
|
|
1144
|
+
// BrandProvider (or the built-in dbx-tools default when the host wired
|
|
1145
|
+
// none). The logo asset id is resolved to a portable data URL so the
|
|
1146
|
+
// export is self-contained; colors/font come straight from the context.
|
|
1147
|
+
const { context: brandContext, resolveAsset: resolveBrandAsset } = useBrand();
|
|
1148
|
+
const exportBrand = useMemo(
|
|
1149
|
+
() => ({
|
|
1150
|
+
logoDataUrl: resolveBrandAsset(brandContext.assets.logo.light),
|
|
1151
|
+
primary: brandContext.colors.primary,
|
|
1152
|
+
accent: brandContext.colors.accent,
|
|
1153
|
+
foreground: brandContext.colors.foreground,
|
|
1154
|
+
fontSans: brandContext.typography.sans,
|
|
1155
|
+
}),
|
|
1156
|
+
[brandContext, resolveBrandAsset],
|
|
1157
|
+
);
|
|
1142
1158
|
const exportConversation = useCallback(
|
|
1143
1159
|
async (format: ExportFormat) => {
|
|
1144
1160
|
const title =
|
|
@@ -1151,6 +1167,7 @@ export const useMastraChat = (
|
|
|
1151
1167
|
resolver: exportResolver,
|
|
1152
1168
|
title,
|
|
1153
1169
|
userLabel: exportUserLabel,
|
|
1170
|
+
brand: exportBrand,
|
|
1154
1171
|
});
|
|
1155
1172
|
} catch (error) {
|
|
1156
1173
|
logger.error("conversation export error", {
|
|
@@ -1159,7 +1176,15 @@ export const useMastraChat = (
|
|
|
1159
1176
|
});
|
|
1160
1177
|
}
|
|
1161
1178
|
},
|
|
1162
|
-
[
|
|
1179
|
+
[
|
|
1180
|
+
exportResolver,
|
|
1181
|
+
activeThreadId,
|
|
1182
|
+
activeKey,
|
|
1183
|
+
getSession,
|
|
1184
|
+
threads,
|
|
1185
|
+
exportUserLabel,
|
|
1186
|
+
exportBrand,
|
|
1187
|
+
],
|
|
1163
1188
|
);
|
|
1164
1189
|
const exportMessage = useCallback(
|
|
1165
1190
|
async (message: UIMessage, format: ExportFormat) => {
|
|
@@ -1171,6 +1196,7 @@ export const useMastraChat = (
|
|
|
1171
1196
|
title: "Message",
|
|
1172
1197
|
filename: "message",
|
|
1173
1198
|
userLabel: exportUserLabel,
|
|
1199
|
+
brand: exportBrand,
|
|
1174
1200
|
});
|
|
1175
1201
|
} catch (error) {
|
|
1176
1202
|
logger.error("message export error", {
|
|
@@ -1179,7 +1205,7 @@ export const useMastraChat = (
|
|
|
1179
1205
|
});
|
|
1180
1206
|
}
|
|
1181
1207
|
},
|
|
1182
|
-
[exportResolver, exportUserLabel],
|
|
1208
|
+
[exportResolver, exportUserLabel, exportBrand],
|
|
1183
1209
|
);
|
|
1184
1210
|
|
|
1185
1211
|
// Submit thumbs / comment feedback for an assistant message to MLflow
|
|
@@ -111,7 +111,7 @@ export const ThreadSidebar = ({
|
|
|
111
111
|
return (
|
|
112
112
|
<div
|
|
113
113
|
className={cn(
|
|
114
|
-
"flex h-full w-64 shrink-0 flex-col border-r border-border bg-
|
|
114
|
+
"flex h-full w-64 shrink-0 flex-col border-r border-border bg-background",
|
|
115
115
|
className,
|
|
116
116
|
)}
|
|
117
117
|
>
|
package/src/react/types.ts
CHANGED
|
@@ -33,8 +33,13 @@ export type ToolEvent = {
|
|
|
33
33
|
*/
|
|
34
34
|
export type ToolProgress = GenieWriterEvent;
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Subset of a Model Serving endpoint surfaced in the model picker. `name`
|
|
38
|
+
* is the invoke id; `displayName` is the optional human-readable label the
|
|
39
|
+
* `/models` endpoint derives (Databricks-provided name, else a title-cased
|
|
40
|
+
* rendering of `name`). The picker shows `displayName ?? name`.
|
|
41
|
+
*/
|
|
42
|
+
export type ChatModelOption = { name: string; displayName?: string };
|
|
38
43
|
|
|
39
44
|
/** Thumbs reaction a user can leave on an assistant turn. */
|
|
40
45
|
export type FeedbackValue = "up" | "down";
|
package/src/support/export.ts
CHANGED
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
* the single-message or the whole-conversation level, in one of two
|
|
6
6
|
* formats:
|
|
7
7
|
*
|
|
8
|
-
* - `"pdf"` - a print-ready HTML document
|
|
9
|
-
* the browser's print dialog triggered, so the user
|
|
10
|
-
* a real PDF ("Save as PDF")
|
|
8
|
+
* - `"pdf"` - a print-ready HTML document rendered in a hidden iframe
|
|
9
|
+
* with the browser's print dialog triggered, so the user
|
|
10
|
+
* saves a real PDF ("Save as PDF") with no popup tab.
|
|
11
|
+
* - `"print"` - the same document routed to a paper printer.
|
|
11
12
|
* - `"markdown"` - a `.md` file download.
|
|
12
13
|
*
|
|
14
|
+
* The document can be brand-styled (logo, colors, font) via the optional
|
|
15
|
+
* `brand` option; the driver resolves it from `@dbx-tools/ui-branding`.
|
|
16
|
+
*
|
|
13
17
|
* Charts are included, not dropped: each `[chart:<id>]` marker is
|
|
14
18
|
* resolved against the plugin's chart cache and rendered to an inline
|
|
15
19
|
* SVG via Echarts' server-side renderer (no DOM needed), so a PDF/HTML
|
|
@@ -29,8 +33,33 @@ import * as echarts from "echarts";
|
|
|
29
33
|
import { marked } from "marked";
|
|
30
34
|
import { normalizeChartOption } from "./chart-option";
|
|
31
35
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Output formats {@link exportChat} can produce.
|
|
38
|
+
* - `"pdf"` - Save-as-PDF via a hidden print iframe (dialog defaults to
|
|
39
|
+
* "Save as PDF"); no new tab.
|
|
40
|
+
* - `"print"` - same rendered document + print dialog, for a paper printer.
|
|
41
|
+
* - `"markdown"` - a `.md` file download.
|
|
42
|
+
*/
|
|
43
|
+
export type ExportFormat = "pdf" | "print" | "markdown";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Optional brand styling for the exported document. Plain data (no React /
|
|
47
|
+
* DOM), so this module stays framework-free; the driver resolves it from
|
|
48
|
+
* `@dbx-tools/ui-branding` (`useBrand()` + `resolveBrandAsset`) and passes it
|
|
49
|
+
* through. Any field omitted falls back to the neutral default styling.
|
|
50
|
+
*/
|
|
51
|
+
export interface ExportBrand {
|
|
52
|
+
/** Logo image src (a data URL) rendered in the document header. */
|
|
53
|
+
logoDataUrl?: string;
|
|
54
|
+
/** Primary brand color for the header rule, headings, and role labels. */
|
|
55
|
+
primary?: string;
|
|
56
|
+
/** Accent brand color (links). */
|
|
57
|
+
accent?: string;
|
|
58
|
+
/** Body text color. */
|
|
59
|
+
foreground?: string;
|
|
60
|
+
/** Sans-serif font stack for the document body. */
|
|
61
|
+
fontSans?: string;
|
|
62
|
+
}
|
|
34
63
|
|
|
35
64
|
/**
|
|
36
65
|
* Resolves the embeds referenced by `[chart:<id>]` / `[data:<id>]`
|
|
@@ -61,6 +90,8 @@ export interface ExportChatOptions {
|
|
|
61
90
|
* id / email form like `"User (someone@example.com)"`.
|
|
62
91
|
*/
|
|
63
92
|
userLabel?: string;
|
|
93
|
+
/** Optional brand styling for the exported document. */
|
|
94
|
+
brand?: ExportBrand;
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
/** Fixed Echarts SSR canvas; the SVG scales to the print column via CSS. */
|
|
@@ -71,14 +102,14 @@ const CHART_HEIGHT_PX = 380;
|
|
|
71
102
|
const PRINT_SETTLE_MS = 300;
|
|
72
103
|
|
|
73
104
|
/**
|
|
74
|
-
* Export `messages` as a PDF
|
|
105
|
+
* Export `messages` as a PDF, a print job, or a Markdown file.
|
|
75
106
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* still
|
|
107
|
+
* `"pdf"` and `"print"` render the same branded, self-contained HTML
|
|
108
|
+
* document and drive it through a hidden `<iframe>` + `print()` - so the
|
|
109
|
+
* browser's print dialog (defaulting to "Save as PDF" for `pdf`) opens
|
|
110
|
+
* directly, with no popup tab. If the iframe path can't run (e.g. no DOM
|
|
111
|
+
* body), the document is downloaded as a self-contained `.html` file so the
|
|
112
|
+
* export - charts included - still lands.
|
|
82
113
|
*/
|
|
83
114
|
export async function exportChat(options: ExportChatOptions): Promise<void> {
|
|
84
115
|
const { messages, format, resolver } = options;
|
|
@@ -92,22 +123,59 @@ export async function exportChat(options: ExportChatOptions): Promise<void> {
|
|
|
92
123
|
return;
|
|
93
124
|
}
|
|
94
125
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
126
|
+
// pdf / print: build the branded document, then print it from a hidden
|
|
127
|
+
// iframe so the Save-as-PDF / print dialog opens without a stray tab.
|
|
128
|
+
const html = await buildHtmlDocument(messages, resolver, title, userLabel, options.brand);
|
|
129
|
+
printViaHiddenIframe(html, `${stem}.html`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render `html` in an off-screen iframe and trigger its print dialog. The
|
|
134
|
+
* iframe is same-origin via `srcdoc`, so `contentWindow.print()` is allowed;
|
|
135
|
+
* it's removed after printing. Falls back to a file download when there's no
|
|
136
|
+
* document body to attach to.
|
|
137
|
+
*/
|
|
138
|
+
function printViaHiddenIframe(html: string, downloadName: string): void {
|
|
139
|
+
if (typeof document === "undefined" || !document.body) {
|
|
140
|
+
downloadTextFile(downloadName, html, "text/html;charset=utf-8");
|
|
104
141
|
return;
|
|
105
142
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
143
|
+
const iframe = document.createElement("iframe");
|
|
144
|
+
iframe.setAttribute("aria-hidden", "true");
|
|
145
|
+
iframe.style.position = "fixed";
|
|
146
|
+
iframe.style.right = "0";
|
|
147
|
+
iframe.style.bottom = "0";
|
|
148
|
+
iframe.style.width = "0";
|
|
149
|
+
iframe.style.height = "0";
|
|
150
|
+
iframe.style.border = "0";
|
|
151
|
+
iframe.srcdoc = html;
|
|
152
|
+
|
|
153
|
+
let cleaned = false;
|
|
154
|
+
const cleanup = () => {
|
|
155
|
+
if (cleaned) return;
|
|
156
|
+
cleaned = true;
|
|
157
|
+
window.setTimeout(() => iframe.remove(), 1000);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
iframe.onload = () => {
|
|
161
|
+
const win = iframe.contentWindow;
|
|
162
|
+
if (!win) {
|
|
163
|
+
iframe.remove();
|
|
164
|
+
downloadTextFile(downloadName, html, "text/html;charset=utf-8");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Settle so charts / fonts lay out, then print. `afterprint` removes the
|
|
168
|
+
// iframe once the dialog closes (a timeout backstops browsers that don't
|
|
169
|
+
// fire it).
|
|
170
|
+
win.addEventListener("afterprint", cleanup);
|
|
171
|
+
win.setTimeout(() => {
|
|
172
|
+
win.focus();
|
|
173
|
+
win.print();
|
|
174
|
+
window.setTimeout(cleanup, 60000);
|
|
175
|
+
}, PRINT_SETTLE_MS);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
document.body.appendChild(iframe);
|
|
111
179
|
}
|
|
112
180
|
|
|
113
181
|
/* ------------------------------- segments -------------------------------- */
|
|
@@ -168,6 +236,7 @@ async function buildHtmlDocument(
|
|
|
168
236
|
resolver: EmbedResolver,
|
|
169
237
|
title: string,
|
|
170
238
|
userLabel: string,
|
|
239
|
+
brand?: ExportBrand,
|
|
171
240
|
): Promise<string> {
|
|
172
241
|
const articles: string[] = [];
|
|
173
242
|
for (const message of messages) {
|
|
@@ -179,11 +248,14 @@ async function buildHtmlDocument(
|
|
|
179
248
|
`<div class="content">${body}</div></article>`,
|
|
180
249
|
);
|
|
181
250
|
}
|
|
251
|
+
const logo = brand?.logoDataUrl
|
|
252
|
+
? `<img class="doc-logo" src="${escapeHtml(brand.logoDataUrl)}" alt="">`
|
|
253
|
+
: "";
|
|
182
254
|
return (
|
|
183
255
|
`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
|
|
184
256
|
`<meta name="viewport" content="width=device-width, initial-scale=1">` +
|
|
185
|
-
`<title>${escapeHtml(title)}</title><style>${
|
|
186
|
-
`<body><header class="doc-header"
|
|
257
|
+
`<title>${escapeHtml(title)}</title><style>${buildDocumentCss(brand)}</style></head>` +
|
|
258
|
+
`<body><header class="doc-header">${logo}<h1>${escapeHtml(title)}</h1>` +
|
|
187
259
|
`<div class="doc-meta">Exported ${escapeHtml(new Date().toLocaleString())}</div>` +
|
|
188
260
|
`</header><main>${articles.join("")}</main></body></html>`
|
|
189
261
|
);
|
|
@@ -417,15 +489,10 @@ function downloadTextFile(filename: string, content: string, mime: string): void
|
|
|
417
489
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
418
490
|
}
|
|
419
491
|
|
|
420
|
-
/** Placeholder shown in the print tab while embeds resolve. */
|
|
421
|
-
const PREPARING_HTML =
|
|
422
|
-
'<!doctype html><html><head><meta charset="utf-8"><title>Preparing export...</title>' +
|
|
423
|
-
"<style>body{font:14px system-ui,sans-serif;color:#475569;display:flex;" +
|
|
424
|
-
"height:100vh;margin:0;align-items:center;justify-content:center}</style></head>" +
|
|
425
|
-
"<body>Preparing export...</body></html>";
|
|
426
|
-
|
|
427
492
|
/**
|
|
428
|
-
*
|
|
493
|
+
* Build the inline stylesheet for the exported document, folding in optional
|
|
494
|
+
* brand color / font overrides (falling back to the neutral slate/blue theme
|
|
495
|
+
* and a system font stack). Tuned for print:
|
|
429
496
|
*
|
|
430
497
|
* - A readable measure with role-labelled message blocks.
|
|
431
498
|
* - Content flows naturally across pages: whole messages are NOT
|
|
@@ -436,23 +503,30 @@ const PREPARING_HTML =
|
|
|
436
503
|
* rows - while long tables split across pages and repeat their
|
|
437
504
|
* header (`thead { display: table-header-group }`).
|
|
438
505
|
* - `@page { margin: 0 }` collapses the page margin box so the
|
|
439
|
-
* browser's own print header/footer (the
|
|
506
|
+
* browser's own print header/footer (the source URL, the
|
|
440
507
|
* date, and `n/N` page numbers) have nowhere to render and drop
|
|
441
508
|
* out; the visible page margin is re-supplied as body padding.
|
|
442
509
|
*/
|
|
443
|
-
|
|
510
|
+
function buildDocumentCss(brand?: ExportBrand): string {
|
|
511
|
+
const fg = brand?.foreground || "#0f172a";
|
|
512
|
+
const primary = brand?.primary || "#0f172a";
|
|
513
|
+
const link = brand?.accent || "#2563eb";
|
|
514
|
+
const font =
|
|
515
|
+
brand?.fontSans || 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';
|
|
516
|
+
return `
|
|
444
517
|
:root { color-scheme: light; }
|
|
445
518
|
* { box-sizing: border-box; }
|
|
446
519
|
body {
|
|
447
|
-
font: 15px/1.6
|
|
448
|
-
color:
|
|
520
|
+
font: 15px/1.6 ${font};
|
|
521
|
+
color: ${fg}; margin: 0; padding: 32px; background: #fff;
|
|
449
522
|
}
|
|
450
523
|
main { max-width: 820px; margin: 0 auto; }
|
|
451
|
-
.doc-header { max-width: 820px; margin: 0 auto 24px; border-bottom:
|
|
452
|
-
.doc-
|
|
524
|
+
.doc-header { max-width: 820px; margin: 0 auto 24px; border-bottom: 2px solid ${primary}; padding-bottom: 12px; }
|
|
525
|
+
.doc-logo { height: 28px; width: auto; display: block; margin: 0 0 10px; }
|
|
526
|
+
.doc-header h1 { font-size: 22px; margin: 0 0 4px; color: ${primary}; }
|
|
453
527
|
.doc-meta { font-size: 12px; color: #64748b; }
|
|
454
528
|
.msg { margin: 0 0 20px; }
|
|
455
|
-
.msg .role { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color:
|
|
529
|
+
.msg .role { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: ${primary}; margin-bottom: 4px; break-after: avoid; }
|
|
456
530
|
.msg-user .content { background: #f1f5f9; border-radius: 8px; padding: 10px 14px; }
|
|
457
531
|
.msg .content > *:first-child { margin-top: 0; }
|
|
458
532
|
.msg .content > *:last-child { margin-bottom: 0; }
|
|
@@ -462,7 +536,7 @@ const PRINT_CSS = `
|
|
|
462
536
|
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; background: #f1f5f9; padding: .1em .3em; border-radius: 4px; }
|
|
463
537
|
pre { background: #0f172a; color: #e2e8f0; padding: 12px 14px; border-radius: 8px; overflow-x: auto; break-inside: avoid; }
|
|
464
538
|
pre code { background: none; padding: 0; color: inherit; }
|
|
465
|
-
a { color:
|
|
539
|
+
a { color: ${link}; }
|
|
466
540
|
.embed { margin: 12px 0; }
|
|
467
541
|
.embed-chart { border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; break-inside: avoid; }
|
|
468
542
|
.embed-chart svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
|
|
@@ -478,3 +552,4 @@ const PRINT_CSS = `
|
|
|
478
552
|
a { color: inherit; text-decoration: none; }
|
|
479
553
|
}
|
|
480
554
|
`;
|
|
555
|
+
}
|