@dbx-tools/ui-mastra 0.3.6 → 0.3.8
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 +16 -3
- package/package.json +7 -7
- package/src/react/chat-view.tsx +37 -32
- package/src/react/mastra-chat.tsx +87 -48
- package/src/react/thread-sidebar.tsx +36 -6
- package/src/react/types.ts +8 -1
- package/src/support/export.ts +32 -9
- package/src/support/mastra-client.ts +95 -77
package/README.md
CHANGED
|
@@ -140,13 +140,26 @@ import {
|
|
|
140
140
|
} from "@dbx-tools/ui-mastra/react";
|
|
141
141
|
|
|
142
142
|
const client = new MastraPluginClient(clientConfig);
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
|
|
144
|
+
// Routing (thread + model) is passed per call, so concurrent runs on
|
|
145
|
+
// different threads never share state.
|
|
146
|
+
const stream = await client.streamAgent({
|
|
147
|
+
agentId: client.defaultAgent,
|
|
148
|
+
messages: [{ role: "user", content: "Hello" }],
|
|
149
|
+
runId,
|
|
150
|
+
threadId: activeThreadId,
|
|
151
|
+
model: "claude sonnet",
|
|
152
|
+
signal: controller.signal,
|
|
153
|
+
});
|
|
145
154
|
|
|
146
155
|
const models = await client.models();
|
|
147
|
-
const history = await client.history({ page: 0, perPage: 20 });
|
|
156
|
+
const history = await client.history({ threadId: activeThreadId, page: 0, perPage: 20 });
|
|
148
157
|
```
|
|
149
158
|
|
|
159
|
+
Each conversation thread runs independently: start a turn on one thread, switch
|
|
160
|
+
to another and start a second, and both stream concurrently. Cancel one via its
|
|
161
|
+
`AbortSignal` (or the driver's `onCancelThread`) without touching the others.
|
|
162
|
+
|
|
150
163
|
`MastraPluginClient` extends `@mastra/client-js` with the AppKit-Mastra custom
|
|
151
164
|
routes. It uses `credentials: "include"` so session cookies travel with streaming
|
|
152
165
|
and REST calls. The React hooks wrap common route calls for model catalogues,
|
package/package.json
CHANGED
|
@@ -26,19 +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.3.
|
|
30
|
-
"@dbx-tools/shared-
|
|
31
|
-
"@dbx-tools/shared-
|
|
32
|
-
"@dbx-tools/ui-appkit": "0.3.
|
|
33
|
-
"@dbx-tools/shared-
|
|
34
|
-
"@dbx-tools/ui-branding": "0.3.
|
|
29
|
+
"@dbx-tools/shared-core": "0.3.8",
|
|
30
|
+
"@dbx-tools/shared-mastra": "0.3.8",
|
|
31
|
+
"@dbx-tools/shared-model": "0.3.8",
|
|
32
|
+
"@dbx-tools/ui-appkit": "0.3.8",
|
|
33
|
+
"@dbx-tools/shared-genie": "0.3.8",
|
|
34
|
+
"@dbx-tools/ui-branding": "0.3.8"
|
|
35
35
|
},
|
|
36
36
|
"main": "index.ts",
|
|
37
37
|
"license": "UNLICENSED",
|
|
38
38
|
"publishConfig": {
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
|
-
"version": "0.3.
|
|
41
|
+
"version": "0.3.8",
|
|
42
42
|
"types": "index.ts",
|
|
43
43
|
"type": "module",
|
|
44
44
|
"exports": {
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -124,6 +124,7 @@ export const ChatView = ({
|
|
|
124
124
|
onNewThread,
|
|
125
125
|
onDeleteThread,
|
|
126
126
|
onRenameThread,
|
|
127
|
+
onCancelThread,
|
|
127
128
|
sidebarOpen: sidebarOpenProp,
|
|
128
129
|
onToggleSidebar,
|
|
129
130
|
onExportConversation,
|
|
@@ -349,10 +350,14 @@ export const ChatView = ({
|
|
|
349
350
|
if (isMobile) setMobileDrawerOpen((open) => !open);
|
|
350
351
|
else toggleDesktopSidebar();
|
|
351
352
|
};
|
|
352
|
-
//
|
|
353
|
-
// clear
|
|
354
|
-
// user is typing.
|
|
355
|
-
|
|
353
|
+
// The top bar carries only the sidebar toggle; the model picker, export, and
|
|
354
|
+
// clear controls live in a toolbar row below the composer, closer to where
|
|
355
|
+
// the user is typing. The toggle is a mobile hamburger, or a desktop "show"
|
|
356
|
+
// affordance while the inline sidebar is collapsed - so the bar renders only
|
|
357
|
+
// when that toggle would actually be visible (an open desktop sidebar has its
|
|
358
|
+
// own hide button, leaving nothing for the bar to hold).
|
|
359
|
+
const showSidebarToggle = showSidebar && (isMobile || !desktopSidebarOpen);
|
|
360
|
+
const showHeader = showSidebarToggle;
|
|
356
361
|
const showComposerToolbar = showModelDisplay || showExport || showClear;
|
|
357
362
|
|
|
358
363
|
// Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
|
|
@@ -420,6 +425,7 @@ export const ChatView = ({
|
|
|
420
425
|
: {})}
|
|
421
426
|
{...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
|
|
422
427
|
{...(onRenameThread ? { onRename: onRenameThread } : {})}
|
|
428
|
+
{...(onCancelThread ? { onCancel: onCancelThread } : {})}
|
|
423
429
|
onHide={toggleSidebar}
|
|
424
430
|
className="relative z-10 w-[85vw] max-w-xs shadow-xl"
|
|
425
431
|
/>
|
|
@@ -440,39 +446,38 @@ export const ChatView = ({
|
|
|
440
446
|
{...(onNewThread ? { onNew: onNewThread } : {})}
|
|
441
447
|
{...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
|
|
442
448
|
{...(onRenameThread ? { onRename: onRenameThread } : {})}
|
|
449
|
+
{...(onCancelThread ? { onCancel: onCancelThread } : {})}
|
|
443
450
|
onHide={toggleSidebar}
|
|
444
451
|
/>
|
|
445
452
|
)
|
|
446
453
|
))}
|
|
447
454
|
<div className="flex h-full min-w-0 flex-1 flex-col">
|
|
448
455
|
{showHeader && (
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
)}
|
|
475
|
-
</div>
|
|
456
|
+
/*
|
|
457
|
+
* Slim top bar holding the sidebar toggle. On mobile the toggle is
|
|
458
|
+
* a persistent hamburger (the overlay drawer has no always-visible
|
|
459
|
+
* hide button); on desktop it's a "show" affordance rendered only
|
|
460
|
+
* while the inline sidebar is collapsed (an open sidebar has its
|
|
461
|
+
* own hide button). `showHeader` already tracks that visibility, so
|
|
462
|
+
* the bar never renders empty.
|
|
463
|
+
*/
|
|
464
|
+
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 px-3 pb-2 pt-1 text-xs text-muted-foreground md:gap-3 md:px-6">
|
|
465
|
+
<Tooltip>
|
|
466
|
+
<TooltipTrigger asChild>
|
|
467
|
+
<Button
|
|
468
|
+
type="button"
|
|
469
|
+
variant="ghost"
|
|
470
|
+
size="icon-sm"
|
|
471
|
+
onClick={toggleSidebar}
|
|
472
|
+
aria-label={sidebarOpen ? "Hide conversations" : "Show conversations"}
|
|
473
|
+
>
|
|
474
|
+
<PanelLeftIcon className="size-4" />
|
|
475
|
+
</Button>
|
|
476
|
+
</TooltipTrigger>
|
|
477
|
+
<TooltipContent>
|
|
478
|
+
{sidebarOpen ? "Hide conversations" : "Show conversations"}
|
|
479
|
+
</TooltipContent>
|
|
480
|
+
</Tooltip>
|
|
476
481
|
</div>
|
|
477
482
|
)}
|
|
478
483
|
<div className="relative flex flex-1 flex-col overflow-hidden">
|
|
@@ -628,7 +633,7 @@ export const ChatView = ({
|
|
|
628
633
|
type="button"
|
|
629
634
|
size="icon-sm"
|
|
630
635
|
variant="default"
|
|
631
|
-
onClick={onStop}
|
|
636
|
+
onClick={() => onStop()}
|
|
632
637
|
aria-label="Stop response"
|
|
633
638
|
>
|
|
634
639
|
<SquareIcon className="size-3 fill-current" />
|
|
@@ -46,10 +46,9 @@ import type {
|
|
|
46
46
|
// `{ runId, payload: { toolCallId, toolName, args } }`. We surface that
|
|
47
47
|
// as an out-of-band entry in `pendingApprovalsByMessage` and wire
|
|
48
48
|
// `onResolveToolApproval` to {@link MastraPluginClient.approveToolCallStream}
|
|
49
|
-
// / `declineToolCallStream
|
|
50
|
-
// `@mastra/client-js` bug on resumed approval streams
|
|
51
|
-
//
|
|
52
|
-
// chunk handler.
|
|
49
|
+
// / `declineToolCallStream` - both of which read SSE directly (avoiding a
|
|
50
|
+
// stock `@mastra/client-js` bug on resumed approval streams) and return a
|
|
51
|
+
// fresh stream Response we run through the same chunk handler.
|
|
53
52
|
//
|
|
54
53
|
// On mount the transcript hydrates with the most recent page of thread
|
|
55
54
|
// history from the Mastra plugin's `/history` endpoint; scrolling near
|
|
@@ -247,18 +246,20 @@ export const useMastraChat = (
|
|
|
247
246
|
// using it as a hook dep doesn't refire the initial-history fetch on
|
|
248
247
|
// every parent render.
|
|
249
248
|
const mastraClient = useMastraClient();
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
249
|
+
// The selected model rides each turn as a per-call override (see
|
|
250
|
+
// `runStream`), never stored on the shared client - so two threads can
|
|
251
|
+
// stream under different models without clobbering each other. Mirror it
|
|
252
|
+
// into a ref so the send path reads the latest selection without adding
|
|
253
|
+
// `model` to its dependency list.
|
|
254
|
+
const modelRef = useRef(model);
|
|
255
|
+
modelRef.current = model;
|
|
256
256
|
const agentId = options.agentId ?? mastraClient.defaultAgent;
|
|
257
257
|
// Built-in conversation management. When on, the chat always drives an
|
|
258
258
|
// explicit client thread id (rather than leaning on the per-session
|
|
259
259
|
// cookie) so it can reference, persist, and switch between the
|
|
260
|
-
// conversations a user owns.
|
|
261
|
-
//
|
|
260
|
+
// conversations a user owns. Each call (stream / history / clear) carries
|
|
261
|
+
// its thread id per request, so many threads can run concurrently without
|
|
262
|
+
// sharing routing state.
|
|
262
263
|
const enableThreads = options.enableThreads !== false;
|
|
263
264
|
// Export is opt-in (default off): the host turns it on explicitly.
|
|
264
265
|
const enableExport = options.enableExport === true;
|
|
@@ -694,7 +695,7 @@ export const useMastraChat = (
|
|
|
694
695
|
async (
|
|
695
696
|
threadId: string,
|
|
696
697
|
assistantId: string,
|
|
697
|
-
open: () => Promise<MastraStreamResponse>,
|
|
698
|
+
open: (signal: AbortSignal) => Promise<MastraStreamResponse>,
|
|
698
699
|
) => {
|
|
699
700
|
const controller = new AbortController();
|
|
700
701
|
let token = 0;
|
|
@@ -713,10 +714,7 @@ export const useMastraChat = (
|
|
|
713
714
|
});
|
|
714
715
|
const runIdRef = { current: getSession(threadId).runId };
|
|
715
716
|
try {
|
|
716
|
-
const
|
|
717
|
-
threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
|
|
718
|
-
mastraClient.setThreadId(streamThreadId);
|
|
719
|
-
const stream = await open();
|
|
717
|
+
const stream = await open(controller.signal);
|
|
720
718
|
await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
|
|
721
719
|
updateSession(threadId, (session) => {
|
|
722
720
|
if (session.runToken !== token) return session;
|
|
@@ -746,7 +744,7 @@ export const useMastraChat = (
|
|
|
746
744
|
}));
|
|
747
745
|
}
|
|
748
746
|
},
|
|
749
|
-
[getSession,
|
|
747
|
+
[getSession, processStream, refreshThreadsSoon, updateSession],
|
|
750
748
|
);
|
|
751
749
|
|
|
752
750
|
const runStream = useCallback(
|
|
@@ -758,35 +756,53 @@ export const useMastraChat = (
|
|
|
758
756
|
assistantId,
|
|
759
757
|
runId,
|
|
760
758
|
}));
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
759
|
+
// Capture this run's thread + model at send time and pass them per
|
|
760
|
+
// call, so a run keeps its own routing even if the user switches
|
|
761
|
+
// threads or changes the model picker while it streams.
|
|
762
|
+
const streamThreadId =
|
|
763
|
+
threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
|
|
764
|
+
const model = modelRef.current || undefined;
|
|
765
|
+
return driveStream(threadId, assistantId, (signal) => {
|
|
766
|
+
const messages = history.flatMap((m) =>
|
|
764
767
|
m.parts
|
|
765
768
|
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
766
769
|
.map((p) => ({ role: m.role, content: p.text })),
|
|
767
|
-
)
|
|
768
|
-
return
|
|
770
|
+
);
|
|
771
|
+
return mastraClient.streamAgent({
|
|
772
|
+
agentId,
|
|
773
|
+
messages,
|
|
769
774
|
runId,
|
|
770
|
-
|
|
775
|
+
threadId: streamThreadId,
|
|
776
|
+
model,
|
|
777
|
+
signal,
|
|
778
|
+
});
|
|
771
779
|
});
|
|
772
780
|
},
|
|
773
781
|
[driveStream, mastraClient, agentId, updateSession],
|
|
774
782
|
);
|
|
775
783
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
784
|
+
// Cancel a thread's in-flight run. Defaults to the active thread (the
|
|
785
|
+
// composer stop button), but takes an explicit thread id so the sidebar
|
|
786
|
+
// can cancel a background thread without switching to it. Aborting one
|
|
787
|
+
// thread's controller leaves every other run streaming - each run owns
|
|
788
|
+
// its own controller and routing.
|
|
789
|
+
const stop = useCallback(
|
|
790
|
+
(threadId?: string) => {
|
|
791
|
+
const key = threadId ?? activeKey;
|
|
792
|
+
updateSession(key, (session) => {
|
|
793
|
+
if (!isSessionRunning(session)) return session;
|
|
794
|
+
session.abortController?.abort();
|
|
795
|
+
return {
|
|
796
|
+
...session,
|
|
797
|
+
abortController: null,
|
|
798
|
+
runToken: session.runToken + 1,
|
|
799
|
+
error: null,
|
|
800
|
+
status: "ready",
|
|
801
|
+
};
|
|
802
|
+
});
|
|
803
|
+
},
|
|
804
|
+
[activeKey, updateSession],
|
|
805
|
+
);
|
|
790
806
|
|
|
791
807
|
/**
|
|
792
808
|
* Approve or deny an in-flight `requireApproval` tool call. The
|
|
@@ -831,10 +847,22 @@ export const useMastraChat = (
|
|
|
831
847
|
toolCallId,
|
|
832
848
|
runId,
|
|
833
849
|
});
|
|
834
|
-
|
|
850
|
+
const streamThreadId =
|
|
851
|
+
activeKey === DEFAULT_THREAD_SESSION_KEY ? undefined : activeKey;
|
|
852
|
+
await driveStream(activeKey, assistantId, (signal) =>
|
|
835
853
|
decision.approved
|
|
836
|
-
? mastraClient.approveToolCallStream(agentId, {
|
|
837
|
-
|
|
854
|
+
? mastraClient.approveToolCallStream(agentId, {
|
|
855
|
+
runId,
|
|
856
|
+
toolCallId,
|
|
857
|
+
threadId: streamThreadId,
|
|
858
|
+
signal,
|
|
859
|
+
})
|
|
860
|
+
: mastraClient.declineToolCallStream(agentId, {
|
|
861
|
+
runId,
|
|
862
|
+
toolCallId,
|
|
863
|
+
threadId: streamThreadId,
|
|
864
|
+
signal,
|
|
865
|
+
}),
|
|
838
866
|
);
|
|
839
867
|
},
|
|
840
868
|
[activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
|
|
@@ -883,7 +911,7 @@ export const useMastraChat = (
|
|
|
883
911
|
const handleClear = useCallback(async () => {
|
|
884
912
|
const threadId = activeKey;
|
|
885
913
|
try {
|
|
886
|
-
const result = await mastraClient.clearHistory({ agentId });
|
|
914
|
+
const result = await mastraClient.clearHistory({ agentId, threadId: activeThreadId });
|
|
887
915
|
logger.info("history cleared", { cleared: result.cleared });
|
|
888
916
|
} catch (error) {
|
|
889
917
|
logger.error("history clear error", {
|
|
@@ -1040,7 +1068,6 @@ export const useMastraChat = (
|
|
|
1040
1068
|
// refetching or aborting other threads' runs.
|
|
1041
1069
|
useEffect(() => {
|
|
1042
1070
|
const threadId = activeKey;
|
|
1043
|
-
mastraClient.setThreadId(activeThreadId);
|
|
1044
1071
|
const session = getSession(threadId);
|
|
1045
1072
|
if (session.historyLoaded) {
|
|
1046
1073
|
setIsLoadingHistory(false);
|
|
@@ -1054,6 +1081,7 @@ export const useMastraChat = (
|
|
|
1054
1081
|
mastraClient
|
|
1055
1082
|
.history({
|
|
1056
1083
|
agentId,
|
|
1084
|
+
threadId: activeThreadId,
|
|
1057
1085
|
page: 0,
|
|
1058
1086
|
perPage: HISTORY_PAGE_SIZE,
|
|
1059
1087
|
signal: controller.signal,
|
|
@@ -1097,7 +1125,7 @@ export const useMastraChat = (
|
|
|
1097
1125
|
const page = session.historyPage;
|
|
1098
1126
|
updateSession(threadId, (current) => ({ ...current, historyPage: page + 1 }));
|
|
1099
1127
|
mastraClient
|
|
1100
|
-
.history({ agentId, page, perPage: HISTORY_PAGE_SIZE })
|
|
1128
|
+
.history({ agentId, threadId: activeThreadId, page, perPage: HISTORY_PAGE_SIZE })
|
|
1101
1129
|
.then((response) => {
|
|
1102
1130
|
const uiMessages = response.uiMessages as unknown as UIMessage[];
|
|
1103
1131
|
if (uiMessages.length > 0) {
|
|
@@ -1124,7 +1152,15 @@ export const useMastraChat = (
|
|
|
1124
1152
|
historyInFlightRef.current = false;
|
|
1125
1153
|
setIsLoadingMore(false);
|
|
1126
1154
|
});
|
|
1127
|
-
}, [
|
|
1155
|
+
}, [
|
|
1156
|
+
activeKey,
|
|
1157
|
+
activeThreadId,
|
|
1158
|
+
getSession,
|
|
1159
|
+
mastraClient,
|
|
1160
|
+
agentId,
|
|
1161
|
+
updateSession,
|
|
1162
|
+
writeMessages,
|
|
1163
|
+
]);
|
|
1128
1164
|
|
|
1129
1165
|
// Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
|
|
1130
1166
|
// straight off the client so the export inlines the same charts /
|
|
@@ -1256,9 +1292,9 @@ export const useMastraChat = (
|
|
|
1256
1292
|
[activeKey, mastraClient, updateSession],
|
|
1257
1293
|
);
|
|
1258
1294
|
|
|
1259
|
-
// Merge optimistic rows
|
|
1260
|
-
// the server
|
|
1261
|
-
//
|
|
1295
|
+
// Merge optimistic rows over the server list, newest first, dropping any
|
|
1296
|
+
// optimistic entry the server already returns so a thread is never listed
|
|
1297
|
+
// twice.
|
|
1262
1298
|
const sidebarThreads = useMemo<ThreadSummary[]>(() => {
|
|
1263
1299
|
// Title overlay precedence per row: a manual rename always wins;
|
|
1264
1300
|
// otherwise a provisional first-message title fills an untitled row
|
|
@@ -1357,6 +1393,9 @@ export const useMastraChat = (
|
|
|
1357
1393
|
onNewThread: newThread,
|
|
1358
1394
|
onDeleteThread: deleteThread,
|
|
1359
1395
|
onRenameThread: renameThread,
|
|
1396
|
+
// Cancel a background thread's run from the sidebar without
|
|
1397
|
+
// switching to it.
|
|
1398
|
+
onCancelThread: stop,
|
|
1360
1399
|
// Persisted sidebar visibility, controlled from the driver so
|
|
1361
1400
|
// the show/hide choice survives reloads.
|
|
1362
1401
|
sidebarOpen,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
MessageSquarePlusIcon,
|
|
14
14
|
PanelLeftIcon,
|
|
15
15
|
PencilIcon,
|
|
16
|
+
SquareIcon,
|
|
16
17
|
Trash2Icon,
|
|
17
18
|
} from "lucide-react";
|
|
18
19
|
import { useState } from "react";
|
|
@@ -42,6 +43,12 @@ export interface ThreadSidebarProps {
|
|
|
42
43
|
onDelete?: (threadId: string) => void;
|
|
43
44
|
/** Rename a thread. Per-row edit affordance (inline text field) hidden when omitted. */
|
|
44
45
|
onRename?: (threadId: string, title: string) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Cancel a thread's in-flight run. When provided, a streaming row (one in
|
|
48
|
+
* {@link streamingThreadIds}) shows a stop button in place of its spinner
|
|
49
|
+
* on hover, so a background run can be cancelled without switching to it.
|
|
50
|
+
*/
|
|
51
|
+
onCancel?: (threadId: string) => void;
|
|
45
52
|
/** Collapse the sidebar. Renders the hide button in the header when provided. */
|
|
46
53
|
onHide?: () => void;
|
|
47
54
|
/** Extra classes merged onto the sidebar root. */
|
|
@@ -67,6 +74,7 @@ export const ThreadSidebar = ({
|
|
|
67
74
|
onNew,
|
|
68
75
|
onDelete,
|
|
69
76
|
onRename,
|
|
77
|
+
onCancel,
|
|
70
78
|
onHide,
|
|
71
79
|
className,
|
|
72
80
|
}: ThreadSidebarProps) => {
|
|
@@ -212,12 +220,34 @@ export const ThreadSidebar = ({
|
|
|
212
220
|
>
|
|
213
221
|
<div className="min-w-0 flex-1">
|
|
214
222
|
<div className="flex items-center gap-1.5 truncate">
|
|
215
|
-
{isStreaming &&
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
223
|
+
{isStreaming &&
|
|
224
|
+
(onCancel ? (
|
|
225
|
+
<Tooltip>
|
|
226
|
+
<TooltipTrigger asChild>
|
|
227
|
+
<Button
|
|
228
|
+
type="button"
|
|
229
|
+
variant="ghost"
|
|
230
|
+
size="icon"
|
|
231
|
+
onClick={(e) => {
|
|
232
|
+
e.stopPropagation();
|
|
233
|
+
onCancel(thread.id);
|
|
234
|
+
}}
|
|
235
|
+
aria-label="Stop generating"
|
|
236
|
+
className="size-4 shrink-0 text-primary"
|
|
237
|
+
>
|
|
238
|
+
{/* Spinner by default; swap to a stop square on hover/focus. */}
|
|
239
|
+
<Loader2Icon className="size-3 animate-spin group-hover:hidden" />
|
|
240
|
+
<SquareIcon className="hidden size-3 fill-current group-hover:block" />
|
|
241
|
+
</Button>
|
|
242
|
+
</TooltipTrigger>
|
|
243
|
+
<TooltipContent>Stop generating</TooltipContent>
|
|
244
|
+
</Tooltip>
|
|
245
|
+
) : (
|
|
246
|
+
<Loader2Icon
|
|
247
|
+
aria-label="Streaming"
|
|
248
|
+
className="size-3 shrink-0 animate-spin text-primary"
|
|
249
|
+
/>
|
|
250
|
+
))}
|
|
221
251
|
<span className="truncate">{threadTitle(thread)}</span>
|
|
222
252
|
</div>
|
|
223
253
|
{thread.updatedAt && (
|
package/src/react/types.ts
CHANGED
|
@@ -99,7 +99,7 @@ export type ChatViewProps = {
|
|
|
99
99
|
* to hide the Stop affordance (the Send button just disables while a
|
|
100
100
|
* response streams).
|
|
101
101
|
*/
|
|
102
|
-
onStop?: () => void;
|
|
102
|
+
onStop?: (threadId?: string) => void;
|
|
103
103
|
/** Extra classes merged onto the root layout container. */
|
|
104
104
|
className?: string;
|
|
105
105
|
/**
|
|
@@ -220,6 +220,13 @@ export type ChatViewProps = {
|
|
|
220
220
|
* affordance that swaps the title into an inline text field.
|
|
221
221
|
*/
|
|
222
222
|
onRenameThread?: (threadId: string, title: string) => void;
|
|
223
|
+
/**
|
|
224
|
+
* Cancel a thread's in-flight run by id, without switching to it. When
|
|
225
|
+
* provided, each running sidebar row shows a stop affordance next to its
|
|
226
|
+
* streaming indicator (see {@link streamingThreadIds}). Isolated to that
|
|
227
|
+
* thread - other threads keep streaming.
|
|
228
|
+
*/
|
|
229
|
+
onCancelThread?: (threadId: string) => void;
|
|
223
230
|
/**
|
|
224
231
|
* Controlled open/closed state for the conversation sidebar. When
|
|
225
232
|
* omitted the view manages its own (session-only) open state; pass
|
package/src/support/export.ts
CHANGED
|
@@ -162,13 +162,20 @@ function printViaHiddenIframe(html: string, downloadName: string): void {
|
|
|
162
162
|
return;
|
|
163
163
|
}
|
|
164
164
|
// Settle so charts / fonts lay out, then print. `afterprint` removes the
|
|
165
|
-
// iframe once the dialog closes
|
|
166
|
-
// fire it)
|
|
165
|
+
// iframe once the dialog closes; a 60s timer backstops browsers that don't
|
|
166
|
+
// fire it. The backstop is armed BEFORE `print()` so a throw (sandboxed /
|
|
167
|
+
// policy-restricted frames can reject `print()`) still cleans up, and the
|
|
168
|
+
// export falls back to a file download rather than silently vanishing.
|
|
167
169
|
win.addEventListener("afterprint", cleanup);
|
|
168
170
|
win.setTimeout(() => {
|
|
169
|
-
win.focus();
|
|
170
|
-
win.print();
|
|
171
171
|
window.setTimeout(cleanup, 60000);
|
|
172
|
+
try {
|
|
173
|
+
win.focus();
|
|
174
|
+
win.print();
|
|
175
|
+
} catch {
|
|
176
|
+
cleanup();
|
|
177
|
+
downloadTextFile(downloadName, html, "text/html;charset=utf-8");
|
|
178
|
+
}
|
|
172
179
|
}, PRINT_SETTLE_MS);
|
|
173
180
|
};
|
|
174
181
|
|
|
@@ -478,6 +485,20 @@ function downloadTextFile(filename: string, content: string, mime: string): void
|
|
|
478
485
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
479
486
|
}
|
|
480
487
|
|
|
488
|
+
/**
|
|
489
|
+
* A brand color / font value safe to interpolate into a `<style>` block.
|
|
490
|
+
* Brand colors are hex-validated upstream, but the font stack is only checked
|
|
491
|
+
* for non-blankness, so strip the characters that could close a declaration or
|
|
492
|
+
* rule (`{ } ; < > \` plus HTML-comment openers) to prevent CSS injection into
|
|
493
|
+
* the export document. Falls back to `fallback` when the value is blank.
|
|
494
|
+
*/
|
|
495
|
+
function cssValue(value: string | undefined, fallback: string): string {
|
|
496
|
+
const trimmed = value?.trim();
|
|
497
|
+
if (!trimmed) return fallback;
|
|
498
|
+
const safe = trimmed.replace(/[{}<>;\\]/g, "");
|
|
499
|
+
return safe || fallback;
|
|
500
|
+
}
|
|
501
|
+
|
|
481
502
|
/**
|
|
482
503
|
* Build the inline stylesheet for the exported document, folding in optional
|
|
483
504
|
* brand color / font overrides (falling back to the neutral slate/blue theme
|
|
@@ -497,11 +518,13 @@ function downloadTextFile(filename: string, content: string, mime: string): void
|
|
|
497
518
|
* out; the visible page margin is re-supplied as body padding.
|
|
498
519
|
*/
|
|
499
520
|
function buildDocumentCss(brand?: ExportBrand): string {
|
|
500
|
-
const fg = brand?.foreground
|
|
501
|
-
const primary = brand?.primary
|
|
502
|
-
const link = brand?.accent
|
|
503
|
-
const font =
|
|
504
|
-
brand?.fontSans
|
|
521
|
+
const fg = cssValue(brand?.foreground, "#0f172a");
|
|
522
|
+
const primary = cssValue(brand?.primary, "#0f172a");
|
|
523
|
+
const link = cssValue(brand?.accent, "#2563eb");
|
|
524
|
+
const font = cssValue(
|
|
525
|
+
brand?.fontSans,
|
|
526
|
+
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
|
527
|
+
);
|
|
505
528
|
return `
|
|
506
529
|
:root { color-scheme: light; }
|
|
507
530
|
* { box-sizing: border-box; }
|
|
@@ -72,43 +72,55 @@ export class MastraPluginClient extends MastraClient {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* (fuzzy-matched server-side) or a model class slug
|
|
80
|
-
* (`ModelClass.ChatFast` / `"chat-thinking"`); pass `undefined` to
|
|
81
|
-
* fall back to the agent's configured default.
|
|
75
|
+
* Build the per-request routing headers for a call: the thread-selection
|
|
76
|
+
* header (`THREAD_ID_HEADER`) so the server pins `RequestContext`'s thread
|
|
77
|
+
* id, and the model-override header (`MODEL_OVERRIDE_HEADER`) so the server
|
|
78
|
+
* swaps the resolved model for that request without an agent redeploy.
|
|
82
79
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
80
|
+
* Routing is per-CALL - never stored on the shared client - so concurrent
|
|
81
|
+
* runs on different threads / models can't clobber each other's headers.
|
|
82
|
+
* `threadId` is a conversation id (omit to fall back to the per-session
|
|
83
|
+
* cookie thread); `model` is a concrete endpoint name (fuzzy-matched
|
|
84
|
+
* server-side) or a model-class slug (`"chat-fast"` / `"chat-thinking"`).
|
|
87
85
|
*/
|
|
88
|
-
|
|
89
|
-
const headers
|
|
90
|
-
if (
|
|
91
|
-
|
|
86
|
+
#routingHeaders(routing: { threadId?: string; model?: string }): Record<string, string> {
|
|
87
|
+
const headers: Record<string, string> = {};
|
|
88
|
+
if (routing.threadId) headers[thread.THREAD_ID_HEADER] = routing.threadId;
|
|
89
|
+
if (routing.model) headers[override.MODEL_OVERRIDE_HEADER] = routing.model;
|
|
90
|
+
return headers;
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* Mutates the shared `options.headers` in place (like
|
|
103
|
-
* {@link setModelOverride}) so the client identity stays stable; the
|
|
104
|
-
* inherited `agent.stream()` and the custom routes
|
|
105
|
-
* ({@link history} / {@link clearHistory} / {@link threads}) all read
|
|
106
|
-
* these headers, so selecting a thread routes every call at once.
|
|
94
|
+
* Open an agent stream for one conversation turn, routed and cancelled
|
|
95
|
+
* PER CALL. Posts to the agent `/stream` route with this run's own thread
|
|
96
|
+
* id + model as request headers and its own `AbortSignal` on the fetch, so
|
|
97
|
+
* many threads can stream at once and aborting one leaves the others
|
|
98
|
+
* untouched. Reads the SSE directly (like the tool-approval streams) rather
|
|
99
|
+
* than through the inherited `agent.stream()`, whose shared-client headers
|
|
100
|
+
* and single client-level abort signal can't isolate concurrent runs.
|
|
107
101
|
*/
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
102
|
+
async streamAgent(params: {
|
|
103
|
+
agentId: string;
|
|
104
|
+
messages: Array<{ role: string; content: string }>;
|
|
105
|
+
runId: string;
|
|
106
|
+
threadId?: string;
|
|
107
|
+
model?: string;
|
|
108
|
+
signal?: AbortSignal;
|
|
109
|
+
}): Promise<MastraStreamResponse> {
|
|
110
|
+
const url = `${this.basePath}/agents/${encodeURIComponent(params.agentId)}/stream`;
|
|
111
|
+
const init: RequestInit = {
|
|
112
|
+
method: "POST",
|
|
113
|
+
credentials: "include",
|
|
114
|
+
headers: {
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
...this.#routingHeaders(params),
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({ messages: params.messages, runId: params.runId }),
|
|
119
|
+
};
|
|
120
|
+
if (params.signal) init.signal = params.signal;
|
|
121
|
+
const response = await fetch(url, init);
|
|
122
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
123
|
+
return asMastraStreamResponse(response);
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
/**
|
|
@@ -120,7 +132,7 @@ export class MastraPluginClient extends MastraClient {
|
|
|
120
132
|
*/
|
|
121
133
|
async approveToolCallStream(
|
|
122
134
|
agentId: string,
|
|
123
|
-
params: { runId: string; toolCallId: string },
|
|
135
|
+
params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
|
|
124
136
|
): Promise<MastraStreamResponse> {
|
|
125
137
|
return this.#toolApprovalStream(agentId, "approve-tool-call", params);
|
|
126
138
|
}
|
|
@@ -131,7 +143,7 @@ export class MastraPluginClient extends MastraClient {
|
|
|
131
143
|
*/
|
|
132
144
|
async declineToolCallStream(
|
|
133
145
|
agentId: string,
|
|
134
|
-
params: { runId: string; toolCallId: string },
|
|
146
|
+
params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
|
|
135
147
|
): Promise<MastraStreamResponse> {
|
|
136
148
|
return this.#toolApprovalStream(agentId, "decline-tool-call", params);
|
|
137
149
|
}
|
|
@@ -139,24 +151,22 @@ export class MastraPluginClient extends MastraClient {
|
|
|
139
151
|
async #toolApprovalStream(
|
|
140
152
|
agentId: string,
|
|
141
153
|
route: "approve-tool-call" | "decline-tool-call",
|
|
142
|
-
params: { runId: string; toolCallId: string },
|
|
154
|
+
params: { runId: string; toolCallId: string; threadId?: string; signal?: AbortSignal },
|
|
143
155
|
): Promise<MastraStreamResponse> {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
const url = `${this.basePath}/agents/${encodeURIComponent(agentId)}/${route}`;
|
|
157
|
+
const init: RequestInit = {
|
|
158
|
+
method: "POST",
|
|
159
|
+
credentials: "include",
|
|
160
|
+
headers: {
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
...this.#routingHeaders({ threadId: params.threadId }),
|
|
150
163
|
},
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
headers: response.headers,
|
|
158
|
-
}),
|
|
159
|
-
);
|
|
164
|
+
body: JSON.stringify({ runId: params.runId, toolCallId: params.toolCallId }),
|
|
165
|
+
};
|
|
166
|
+
if (params.signal) init.signal = params.signal;
|
|
167
|
+
const response = await fetch(url, init);
|
|
168
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
169
|
+
return asMastraStreamResponse(response);
|
|
160
170
|
}
|
|
161
171
|
|
|
162
172
|
/**
|
|
@@ -209,11 +219,14 @@ export class MastraPluginClient extends MastraClient {
|
|
|
209
219
|
/**
|
|
210
220
|
* Fetch one page of thread history from `GET ${basePath}/history`.
|
|
211
221
|
* Messages come back oldest -> newest so the caller can prepend them
|
|
212
|
-
* to a live transcript.
|
|
222
|
+
* to a live transcript. `threadId` targets a specific conversation
|
|
223
|
+
* (sent as the `?threadId=` query so it doesn't depend on shared client
|
|
224
|
+
* state); omit it to read the per-session cookie thread.
|
|
213
225
|
*/
|
|
214
226
|
async history(
|
|
215
227
|
options: {
|
|
216
228
|
agentId?: string;
|
|
229
|
+
threadId?: string;
|
|
217
230
|
page?: number;
|
|
218
231
|
perPage?: number;
|
|
219
232
|
signal?: AbortSignal;
|
|
@@ -222,6 +235,7 @@ export class MastraPluginClient extends MastraClient {
|
|
|
222
235
|
const params = new URLSearchParams();
|
|
223
236
|
if (options.page !== undefined) params.set("page", String(options.page));
|
|
224
237
|
if (options.perPage !== undefined) params.set("perPage", String(options.perPage));
|
|
238
|
+
if (options.threadId) params.set(thread.THREAD_ID_QUERY, options.threadId);
|
|
225
239
|
const qs = params.toString();
|
|
226
240
|
const base = this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId);
|
|
227
241
|
return this.#getJson(
|
|
@@ -232,19 +246,26 @@ export class MastraPluginClient extends MastraClient {
|
|
|
232
246
|
}
|
|
233
247
|
|
|
234
248
|
/**
|
|
235
|
-
* Wipe
|
|
236
|
-
*
|
|
237
|
-
*
|
|
249
|
+
* Wipe a thread's history (`DELETE ${basePath}/history`). `threadId`
|
|
250
|
+
* targets a specific conversation via the thread-selection header (so it
|
|
251
|
+
* doesn't depend on shared client state); omit it to clear the per-session
|
|
252
|
+
* cookie thread. The session cookie that anchors the thread id is
|
|
253
|
+
* preserved - only the messages go away. Idempotent: a fresh thread reports
|
|
238
254
|
* `cleared: 0` without erroring.
|
|
239
255
|
*/
|
|
240
256
|
async clearHistory(
|
|
241
|
-
options: { agentId?: string; signal?: AbortSignal } = {},
|
|
257
|
+
options: { agentId?: string; threadId?: string; signal?: AbortSignal } = {},
|
|
242
258
|
): Promise<MastraClearHistoryResponse> {
|
|
243
259
|
return this.#mutateJson(
|
|
244
260
|
this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId),
|
|
245
261
|
"DELETE",
|
|
246
262
|
wire.MastraClearHistoryResponseSchema,
|
|
247
|
-
|
|
263
|
+
{
|
|
264
|
+
...(options.threadId
|
|
265
|
+
? { headers: { [thread.THREAD_ID_HEADER]: options.threadId } }
|
|
266
|
+
: {}),
|
|
267
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
268
|
+
},
|
|
248
269
|
);
|
|
249
270
|
}
|
|
250
271
|
|
|
@@ -282,9 +303,8 @@ export class MastraPluginClient extends MastraClient {
|
|
|
282
303
|
* clashing with the inherited `MastraClient.deleteThread`, which hits
|
|
283
304
|
* Mastra's stock thread route rather than our OBO-scoped, custom
|
|
284
305
|
* mount). The id is sent as the thread-selection header for this one
|
|
285
|
-
* call
|
|
286
|
-
*
|
|
287
|
-
* while the user stays on another. Idempotent: deleting an unknown /
|
|
306
|
+
* call, so the sidebar can remove any thread while the user stays on
|
|
307
|
+
* another (routing is per call, never shared). Idempotent: deleting an unknown /
|
|
288
308
|
* already-removed thread reports `deleted: false` without erroring.
|
|
289
309
|
*/
|
|
290
310
|
async removeThread(
|
|
@@ -306,9 +326,8 @@ export class MastraPluginClient extends MastraClient {
|
|
|
306
326
|
* Rename a single conversation thread via the plugin's own
|
|
307
327
|
* `PATCH ${basePath}/threads` route. The id travels as the
|
|
308
328
|
* thread-selection header for this one call (mirroring
|
|
309
|
-
* {@link removeThread}, so the sidebar can rename any thread
|
|
310
|
-
*
|
|
311
|
-
* `title` rides in the JSON body. The server enforces ownership and
|
|
329
|
+
* {@link removeThread}, so the sidebar can rename any thread; routing is
|
|
330
|
+
* per call, never shared) and the new `title` rides in the JSON body. The server enforces ownership and
|
|
312
331
|
* echoes back the updated thread, so the caller can reflect the new
|
|
313
332
|
* title immediately. Throws on an unknown / unowned thread (HTTP 404).
|
|
314
333
|
*/
|
|
@@ -384,13 +403,13 @@ export class MastraPluginClient extends MastraClient {
|
|
|
384
403
|
}
|
|
385
404
|
|
|
386
405
|
/**
|
|
387
|
-
* Snapshot of the client's
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
406
|
+
* Snapshot of the client's base headers for the custom-route fetches
|
|
407
|
+
* that don't go through `@mastra/client-js`'s own request pipeline.
|
|
408
|
+
* Returns a fresh object each call so a caller can safely add one-off
|
|
409
|
+
* headers without mutating the shared `options.headers`. Routing
|
|
410
|
+
* (thread selection, model override) is passed per call - via a
|
|
411
|
+
* `?threadId=` query or the thread-selection header on the individual
|
|
412
|
+
* request - not stored here, so concurrent calls never share routing.
|
|
394
413
|
*/
|
|
395
414
|
#defaultHeaders(): Record<string, string> {
|
|
396
415
|
return { ...((this.options.headers as Record<string, string>) ?? {}) };
|
|
@@ -416,9 +435,8 @@ export class MastraPluginClient extends MastraClient {
|
|
|
416
435
|
* `POST` / `DELETE` / `PATCH` + JSON-parse + schema-validate for the
|
|
417
436
|
* mutating routes (`clearHistory` / `removeThread` / `renameThread` /
|
|
418
437
|
* `feedback`). A JSON body, when present, sets `Content-Type`;
|
|
419
|
-
* `options.headers` add
|
|
420
|
-
* header for a targeted delete / rename) over the client's
|
|
421
|
-
* override headers.
|
|
438
|
+
* `options.headers` add per-call headers (e.g. the thread-selection
|
|
439
|
+
* header for a targeted delete / rename) over the client's base headers.
|
|
422
440
|
*/
|
|
423
441
|
async #mutateJson<T>(
|
|
424
442
|
url: string,
|
|
@@ -477,12 +495,12 @@ export const useMastraConfig = (): MastraClientConfig =>
|
|
|
477
495
|
/**
|
|
478
496
|
* The single {@link MastraPluginClient} for the plugin, built once from
|
|
479
497
|
* the published `basePath`. Use it for everything: stream a turn with
|
|
480
|
-
* `client.
|
|
481
|
-
* `client.history()`, resolve embeds with
|
|
482
|
-
* Rebuilt only when `basePath` / `defaultAgent`
|
|
483
|
-
* mount), so it's safe as a `useMemo` / `useCallback`
|
|
484
|
-
* dependency;
|
|
485
|
-
*
|
|
498
|
+
* `client.streamAgent({ agentId, messages, runId, threadId, model, signal })`,
|
|
499
|
+
* page history with `client.history()`, resolve embeds with
|
|
500
|
+
* `client.chart(id)`, etc. Rebuilt only when `basePath` / `defaultAgent`
|
|
501
|
+
* change (e.g. a custom mount), so it's safe as a `useMemo` / `useCallback`
|
|
502
|
+
* / `useEffect` dependency; per-turn thread + model routing is passed per
|
|
503
|
+
* call rather than stored on the client, so concurrent runs stay isolated.
|
|
486
504
|
*/
|
|
487
505
|
export const useMastraClient = (): MastraPluginClient => {
|
|
488
506
|
const config = useMastraConfig();
|