@dbx-tools/ui-mastra 0.3.8 → 0.3.10
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/package.json +7 -7
- package/src/react/bubbles.tsx +52 -3
- package/src/react/chat-view.tsx +97 -48
- package/src/react/mastra-chat.tsx +66 -15
- package/src/react/types.ts +9 -0
- package/src/support/mastra-client.ts +44 -0
- package/src/support/thread-sessions.ts +22 -0
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/
|
|
32
|
-
"@dbx-tools/ui-
|
|
33
|
-
"@dbx-tools/shared-
|
|
34
|
-
"@dbx-tools/
|
|
29
|
+
"@dbx-tools/shared-core": "0.3.10",
|
|
30
|
+
"@dbx-tools/shared-genie": "0.3.10",
|
|
31
|
+
"@dbx-tools/ui-appkit": "0.3.10",
|
|
32
|
+
"@dbx-tools/ui-branding": "0.3.10",
|
|
33
|
+
"@dbx-tools/shared-mastra": "0.3.10",
|
|
34
|
+
"@dbx-tools/shared-model": "0.3.10"
|
|
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.10",
|
|
42
42
|
"types": "index.ts",
|
|
43
43
|
"type": "module",
|
|
44
44
|
"exports": {
|
package/src/react/bubbles.tsx
CHANGED
|
@@ -44,6 +44,39 @@ import type {
|
|
|
44
44
|
// the helpers that surface approval-gated tool calls out of a message's
|
|
45
45
|
// parts.
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Copy `text` to the clipboard, resolving `true` on success. Prefers the
|
|
49
|
+
* async Clipboard API, but that is only available in a secure context
|
|
50
|
+
* (HTTPS / localhost) - over plain HTTP (e.g. a LAN / ZeroTier IP) it is
|
|
51
|
+
* `undefined`, which is why a tap on mobile could silently do nothing. Falls
|
|
52
|
+
* back to a hidden `<textarea>` + `document.execCommand("copy")` so copy works
|
|
53
|
+
* off a non-secure origin too.
|
|
54
|
+
*/
|
|
55
|
+
async function copyText(text: string): Promise<boolean> {
|
|
56
|
+
try {
|
|
57
|
+
if (navigator.clipboard?.writeText) {
|
|
58
|
+
await navigator.clipboard.writeText(text);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Fall through to the execCommand path.
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const area = document.createElement("textarea");
|
|
66
|
+
area.value = text;
|
|
67
|
+
area.setAttribute("readonly", "");
|
|
68
|
+
area.style.position = "fixed";
|
|
69
|
+
area.style.opacity = "0";
|
|
70
|
+
document.body.appendChild(area);
|
|
71
|
+
area.select();
|
|
72
|
+
const ok = document.execCommand("copy");
|
|
73
|
+
area.remove();
|
|
74
|
+
return ok;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
const getReasoningText = (parts: UIMessage["parts"]): string =>
|
|
48
81
|
parts
|
|
49
82
|
.filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
|
|
@@ -274,6 +307,17 @@ export const AssistantBubble = ({
|
|
|
274
307
|
);
|
|
275
308
|
const fullText = textParts.map((p) => p.text).join("");
|
|
276
309
|
const hasText = fullText.length > 0;
|
|
310
|
+
// Brief "copied" acknowledgement on the copy button (swaps the icon to a
|
|
311
|
+
// check for ~1.5s), so a tap gives visible feedback on touch where there's
|
|
312
|
+
// no hover/tooltip.
|
|
313
|
+
const [copied, setCopied] = useState(false);
|
|
314
|
+
const handleCopy = () => {
|
|
315
|
+
void copyText(fullText).then((ok) => {
|
|
316
|
+
if (!ok) return;
|
|
317
|
+
setCopied(true);
|
|
318
|
+
window.setTimeout(() => setCopied(false), 1500);
|
|
319
|
+
});
|
|
320
|
+
};
|
|
277
321
|
// Suggestions are deferred until the turn is settled so they don't
|
|
278
322
|
// pop in mid-stream. A bubble is "settled" if it isn't the active
|
|
279
323
|
// streaming target - either the agent has returned to `ready` /
|
|
@@ -379,12 +423,17 @@ export const AssistantBubble = ({
|
|
|
379
423
|
size="icon"
|
|
380
424
|
variant="ghost"
|
|
381
425
|
className="size-7"
|
|
382
|
-
onClick={
|
|
426
|
+
onClick={handleCopy}
|
|
427
|
+
aria-label={copied ? "Copied" : "Copy"}
|
|
383
428
|
>
|
|
384
|
-
|
|
429
|
+
{copied ? (
|
|
430
|
+
<CheckIcon className="size-3" />
|
|
431
|
+
) : (
|
|
432
|
+
<CopyIcon className="size-3" />
|
|
433
|
+
)}
|
|
385
434
|
</Button>
|
|
386
435
|
</TooltipTrigger>
|
|
387
|
-
<TooltipContent>Copy</TooltipContent>
|
|
436
|
+
<TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
|
|
388
437
|
</Tooltip>
|
|
389
438
|
)}
|
|
390
439
|
{onExport && (
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
import { error as errorUtil } from "@dbx-tools/shared-core";
|
|
36
36
|
import {
|
|
37
37
|
ArrowDownIcon,
|
|
38
|
+
FastForwardIcon,
|
|
38
39
|
MessageSquareIcon,
|
|
39
40
|
PanelLeftIcon,
|
|
40
41
|
RefreshCwIcon,
|
|
@@ -47,7 +48,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
|
47
48
|
import { AssistantBubble, UserBubble } from "./bubbles";
|
|
48
49
|
import { ExportMenu } from "./export-menu";
|
|
49
50
|
import { SuggestionPills } from "./suggestion-pills";
|
|
50
|
-
import { ThreadSidebar } from "./thread-sidebar";
|
|
51
|
+
import { ThreadSidebar, type ThreadSidebarProps } from "./thread-sidebar";
|
|
51
52
|
import type { ChatViewProps } from "./types";
|
|
52
53
|
|
|
53
54
|
// Controlled, presentational chat shell: the scroll container, header
|
|
@@ -100,6 +101,7 @@ export const ChatView = ({
|
|
|
100
101
|
status,
|
|
101
102
|
error,
|
|
102
103
|
sendMessage,
|
|
104
|
+
onInterrupt,
|
|
103
105
|
regenerate,
|
|
104
106
|
onStop,
|
|
105
107
|
className,
|
|
@@ -247,24 +249,46 @@ export const ChatView = ({
|
|
|
247
249
|
|
|
248
250
|
const handleSubmit = (e: React.FormEvent) => {
|
|
249
251
|
e.preventDefault();
|
|
250
|
-
// Don't queue a new turn while one is streaming - the Enter-key
|
|
251
|
-
// path would otherwise bypass the disabled Send button.
|
|
252
|
-
if (isRunning) return;
|
|
253
252
|
const text = input.trim();
|
|
254
253
|
if (!text) return;
|
|
254
|
+
// Submitting while a turn streams is a steer: the driver hands the text
|
|
255
|
+
// to the live run (or interrupts + resends). Idle submits start a turn.
|
|
255
256
|
sendMessage({ text });
|
|
256
257
|
setInput("");
|
|
257
|
-
// Sending is an explicit "I want to see the response" action, so
|
|
258
|
-
//
|
|
259
|
-
// the
|
|
260
|
-
//
|
|
258
|
+
// Sending is an explicit "I want to see the response" action, so re-pin to
|
|
259
|
+
// the bottom even if the user had scrolled up. `setIsAtBottom(true)` makes
|
|
260
|
+
// the message-dep + ResizeObserver auto-scroll effects follow the new turn;
|
|
261
|
+
// the double-rAF is a belt-and-suspenders jump that runs AFTER React
|
|
262
|
+
// commits the appended message (a single frame can fire pre-commit).
|
|
261
263
|
setIsAtBottom(true);
|
|
262
|
-
|
|
264
|
+
pinToBottom();
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Re-pin the transcript to the bottom after a submit. `setIsAtBottom(true)`
|
|
268
|
+
// drives the auto-scroll effects; the double-rAF jump runs after React
|
|
269
|
+
// commits the appended message (a single frame can fire pre-commit).
|
|
270
|
+
const pinToBottom = () => {
|
|
271
|
+
const pin = () => {
|
|
263
272
|
const el = scrollRef.current;
|
|
264
273
|
if (el) el.scrollTop = el.scrollHeight;
|
|
274
|
+
};
|
|
275
|
+
requestAnimationFrame(() => {
|
|
276
|
+
pin();
|
|
277
|
+
requestAnimationFrame(pin);
|
|
265
278
|
});
|
|
266
279
|
};
|
|
267
280
|
|
|
281
|
+
// Interrupt-submit: force an immediate course-correction (abort the current
|
|
282
|
+
// run, resend with this text) rather than queuing to the live turn.
|
|
283
|
+
const handleInterrupt = () => {
|
|
284
|
+
const text = input.trim();
|
|
285
|
+
if (!text || !onInterrupt) return;
|
|
286
|
+
onInterrupt({ text });
|
|
287
|
+
setInput("");
|
|
288
|
+
setIsAtBottom(true);
|
|
289
|
+
pinToBottom();
|
|
290
|
+
};
|
|
291
|
+
|
|
268
292
|
const lastMessage = messages.at(-1);
|
|
269
293
|
const lastEvents = lastMessage ? toolEventsByMessage[lastMessage.id] : undefined;
|
|
270
294
|
// Single in-flight indicator for the whole turn: visible from the
|
|
@@ -360,6 +384,23 @@ export const ChatView = ({
|
|
|
360
384
|
const showHeader = showSidebarToggle;
|
|
361
385
|
const showComposerToolbar = showModelDisplay || showExport || showClear;
|
|
362
386
|
|
|
387
|
+
// Props shared by the mobile drawer and the desktop inline sidebar - the two
|
|
388
|
+
// render the SAME `ThreadSidebar`, differing only in framing (overlay vs.
|
|
389
|
+
// inline) and, on mobile, closing the drawer after select / new. Building
|
|
390
|
+
// the prop bag once keeps the two call sites from drifting.
|
|
391
|
+
const sidebarProps: ThreadSidebarProps = {
|
|
392
|
+
threads: threads ?? [],
|
|
393
|
+
...(activeThreadId ? { activeThreadId } : {}),
|
|
394
|
+
streamingThreadIds,
|
|
395
|
+
isLoading: isLoadingThreads,
|
|
396
|
+
onSelect: (id) => onSelectThread?.(id),
|
|
397
|
+
onHide: toggleSidebar,
|
|
398
|
+
...(onNewThread ? { onNew: onNewThread } : {}),
|
|
399
|
+
...(onDeleteThread ? { onDelete: onDeleteThread } : {}),
|
|
400
|
+
...(onRenameThread ? { onRename: onRenameThread } : {}),
|
|
401
|
+
...(onCancelThread ? { onCancel: onCancelThread } : {}),
|
|
402
|
+
};
|
|
403
|
+
|
|
363
404
|
// Clear confirmation is an AppKit `AlertDialog` (a real modal), plus an
|
|
364
405
|
// in-flight flag so the DELETE can't be double-fired. `clearing` disables
|
|
365
406
|
// the confirm action while `onClear` runs; the dialog closes on settle.
|
|
@@ -395,9 +436,9 @@ export const ChatView = ({
|
|
|
395
436
|
/*
|
|
396
437
|
* Mobile: a fixed overlay drawer with a tap-to-close backdrop, so
|
|
397
438
|
* the conversation list never eats horizontal space from the chat
|
|
398
|
-
* on a phone. Selecting a thread / starting a new one closes
|
|
399
|
-
* the transcript comes back into view. Session-only
|
|
400
|
-
* closed (see `mobileDrawerOpen`).
|
|
439
|
+
* on a phone. Selecting a thread / starting a new one also closes
|
|
440
|
+
* the drawer so the transcript comes back into view. Session-only
|
|
441
|
+
* + default closed (see `mobileDrawerOpen`).
|
|
401
442
|
*/
|
|
402
443
|
mobileDrawerOpen && (
|
|
403
444
|
<div className="fixed inset-0 z-40 flex">
|
|
@@ -407,10 +448,7 @@ export const ChatView = ({
|
|
|
407
448
|
aria-hidden="true"
|
|
408
449
|
/>
|
|
409
450
|
<ThreadSidebar
|
|
410
|
-
|
|
411
|
-
{...(activeThreadId ? { activeThreadId } : {})}
|
|
412
|
-
streamingThreadIds={streamingThreadIds}
|
|
413
|
-
isLoading={isLoadingThreads}
|
|
451
|
+
{...sidebarProps}
|
|
414
452
|
onSelect={(id) => {
|
|
415
453
|
onSelectThread?.(id);
|
|
416
454
|
toggleSidebar();
|
|
@@ -423,10 +461,6 @@ export const ChatView = ({
|
|
|
423
461
|
},
|
|
424
462
|
}
|
|
425
463
|
: {})}
|
|
426
|
-
{...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
|
|
427
|
-
{...(onRenameThread ? { onRename: onRenameThread } : {})}
|
|
428
|
-
{...(onCancelThread ? { onCancel: onCancelThread } : {})}
|
|
429
|
-
onHide={toggleSidebar}
|
|
430
464
|
className="relative z-10 w-[85vw] max-w-xs shadow-xl"
|
|
431
465
|
/>
|
|
432
466
|
</div>
|
|
@@ -434,22 +468,11 @@ export const ChatView = ({
|
|
|
434
468
|
) : (
|
|
435
469
|
/*
|
|
436
470
|
* Desktop: an inline flex child sharing the row with the chat
|
|
437
|
-
* column, using the persisted open/hide preference.
|
|
471
|
+
* column, using the persisted open/hide preference. Same
|
|
472
|
+
* `sidebarProps` as mobile - only the framing + close-on-select
|
|
473
|
+
* differ.
|
|
438
474
|
*/
|
|
439
|
-
desktopSidebarOpen &&
|
|
440
|
-
<ThreadSidebar
|
|
441
|
-
threads={threads ?? []}
|
|
442
|
-
{...(activeThreadId ? { activeThreadId } : {})}
|
|
443
|
-
streamingThreadIds={streamingThreadIds}
|
|
444
|
-
isLoading={isLoadingThreads}
|
|
445
|
-
onSelect={(id) => onSelectThread?.(id)}
|
|
446
|
-
{...(onNewThread ? { onNew: onNewThread } : {})}
|
|
447
|
-
{...(onDeleteThread ? { onDelete: onDeleteThread } : {})}
|
|
448
|
-
{...(onRenameThread ? { onRename: onRenameThread } : {})}
|
|
449
|
-
{...(onCancelThread ? { onCancel: onCancelThread } : {})}
|
|
450
|
-
onHide={toggleSidebar}
|
|
451
|
-
/>
|
|
452
|
-
)
|
|
475
|
+
desktopSidebarOpen && <ThreadSidebar {...sidebarProps} />
|
|
453
476
|
))}
|
|
454
477
|
<div className="flex h-full min-w-0 flex-1 flex-col">
|
|
455
478
|
{showHeader && (
|
|
@@ -628,7 +651,8 @@ export const ChatView = ({
|
|
|
628
651
|
className="max-h-48 text-base md:text-sm"
|
|
629
652
|
/>
|
|
630
653
|
<InputGroupAddon align="inline-end">
|
|
631
|
-
{isRunning && onStop ? (
|
|
654
|
+
{isRunning && onStop && !input.trim() ? (
|
|
655
|
+
// Running with an empty composer: the button stops the turn.
|
|
632
656
|
<InputGroupButton
|
|
633
657
|
type="button"
|
|
634
658
|
size="icon-sm"
|
|
@@ -639,19 +663,44 @@ export const ChatView = ({
|
|
|
639
663
|
<SquareIcon className="size-3 fill-current" />
|
|
640
664
|
</InputGroupButton>
|
|
641
665
|
) : (
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
{isRunning
|
|
650
|
-
<
|
|
651
|
-
|
|
652
|
-
|
|
666
|
+
<>
|
|
667
|
+
{/*
|
|
668
|
+
* Running with typed text: offer an immediate interrupt
|
|
669
|
+
* alongside the steer Send. Interrupt aborts the current
|
|
670
|
+
* turn and resends now; Send folds the message into the
|
|
671
|
+
* live turn (queue-steer).
|
|
672
|
+
*/}
|
|
673
|
+
{isRunning && onInterrupt && input.trim() && (
|
|
674
|
+
<Tooltip>
|
|
675
|
+
<TooltipTrigger asChild>
|
|
676
|
+
<InputGroupButton
|
|
677
|
+
type="button"
|
|
678
|
+
size="icon-sm"
|
|
679
|
+
variant="outline"
|
|
680
|
+
onClick={handleInterrupt}
|
|
681
|
+
aria-label="Interrupt and send now"
|
|
682
|
+
>
|
|
683
|
+
<FastForwardIcon className="size-3" />
|
|
684
|
+
</InputGroupButton>
|
|
685
|
+
</TooltipTrigger>
|
|
686
|
+
<TooltipContent>Interrupt & send now</TooltipContent>
|
|
687
|
+
</Tooltip>
|
|
653
688
|
)}
|
|
654
|
-
|
|
689
|
+
{/*
|
|
690
|
+
* Idle, or running with typed text: the primary button
|
|
691
|
+
* sends. A send mid-run steers the live turn (see the
|
|
692
|
+
* driver's sendMessage).
|
|
693
|
+
*/}
|
|
694
|
+
<InputGroupButton
|
|
695
|
+
type="submit"
|
|
696
|
+
size="icon-sm"
|
|
697
|
+
variant="default"
|
|
698
|
+
disabled={!input.trim()}
|
|
699
|
+
aria-label={isRunning ? "Steer response" : "Send message"}
|
|
700
|
+
>
|
|
701
|
+
<SendIcon className="size-3" />
|
|
702
|
+
</InputGroupButton>
|
|
703
|
+
</>
|
|
655
704
|
)}
|
|
656
705
|
</InputGroupAddon>
|
|
657
706
|
</InputGroup>
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
DEFAULT_THREAD_SESSION_KEY,
|
|
22
22
|
isSessionRunning,
|
|
23
23
|
sessionKey,
|
|
24
|
+
terminateRunningToolEvents,
|
|
24
25
|
type ThreadSession,
|
|
25
26
|
} from "../support/thread-sessions";
|
|
26
27
|
import { ChatView } from "./chat-view";
|
|
@@ -710,6 +711,9 @@ export const useMastraChat = (
|
|
|
710
711
|
error: null,
|
|
711
712
|
status: "submitted",
|
|
712
713
|
runToken: token,
|
|
714
|
+
// Superseding an in-flight run (a steer that interrupts, or a rapid
|
|
715
|
+
// re-send) stops its stream, so close any pills it left running.
|
|
716
|
+
toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
|
|
713
717
|
};
|
|
714
718
|
});
|
|
715
719
|
const runIdRef = { current: getSession(threadId).runId };
|
|
@@ -798,6 +802,9 @@ export const useMastraChat = (
|
|
|
798
802
|
runToken: session.runToken + 1,
|
|
799
803
|
error: null,
|
|
800
804
|
status: "ready",
|
|
805
|
+
// Cancelling stops the stream, so close any tool pills still marked
|
|
806
|
+
// running - the closing chunks will never arrive.
|
|
807
|
+
toolEventsByMessage: terminateRunningToolEvents(session.toolEventsByMessage),
|
|
801
808
|
};
|
|
802
809
|
});
|
|
803
810
|
},
|
|
@@ -868,11 +875,12 @@ export const useMastraChat = (
|
|
|
868
875
|
[activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
|
|
869
876
|
);
|
|
870
877
|
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
878
|
+
// Append a user message to a thread's transcript, stamping `lastUserText`,
|
|
879
|
+
// thread-activity, and a provisional title for a brand-new thread. Returns
|
|
880
|
+
// the pre-append session (so callers can see whether a run was in flight)
|
|
881
|
+
// and the new message list. Shared by send + interrupt.
|
|
882
|
+
const appendUserMessage = useCallback(
|
|
883
|
+
(threadId: string, text: string): { before: ThreadSession; next: UIMessage[] } => {
|
|
876
884
|
updateSession(threadId, (session) => ({ ...session, lastUserText: text }));
|
|
877
885
|
if (activeThreadId) {
|
|
878
886
|
noteThreadActivity(activeThreadId);
|
|
@@ -885,19 +893,61 @@ export const useMastraChat = (
|
|
|
885
893
|
}
|
|
886
894
|
}
|
|
887
895
|
}
|
|
888
|
-
const
|
|
896
|
+
const before = getSession(threadId);
|
|
897
|
+
const next = [...before.messages, makeUserMessage(text)];
|
|
889
898
|
writeMessages(threadId, next);
|
|
899
|
+
return { before, next };
|
|
900
|
+
},
|
|
901
|
+
[activeThreadId, getSession, noteThreadActivity, updateSession, writeMessages],
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
|
|
905
|
+
(message) => {
|
|
906
|
+
const text = message.text ?? "";
|
|
907
|
+
if (!text) return;
|
|
908
|
+
const threadId = activeKey;
|
|
909
|
+
const { before, next } = appendUserMessage(threadId, text);
|
|
910
|
+
// Steering: if a turn is already streaming on this thread, hand the
|
|
911
|
+
// message to the live run so the agent folds it into the current turn
|
|
912
|
+
// (no restart). If the server won't deliver it, fall back to
|
|
913
|
+
// interrupting + resending so a steer never silently drops.
|
|
914
|
+
if (isSessionRunning(before) && before.runId) {
|
|
915
|
+
const runId = before.runId;
|
|
916
|
+
const steerThreadId =
|
|
917
|
+
threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
|
|
918
|
+
void mastraClient
|
|
919
|
+
.queueMessage({ agentId, runId, threadId: steerThreadId, message: text })
|
|
920
|
+
.then((accepted) => {
|
|
921
|
+
if (accepted) {
|
|
922
|
+
logger.info("steer:queued", { runId });
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
logger.info("steer:fallback-interrupt", { runId });
|
|
926
|
+
void runStream(threadId, getSession(threadId).messages);
|
|
927
|
+
});
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
890
930
|
void runStream(threadId, next);
|
|
891
931
|
},
|
|
892
|
-
[
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
932
|
+
[appendUserMessage, runStream, activeKey, getSession, mastraClient, agentId],
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
// Interrupt-and-resend: unconditionally abort any in-flight run on the
|
|
936
|
+
// active thread and start a fresh turn with `text` included immediately.
|
|
937
|
+
// The composer offers this alongside the queue-steer send so the user can
|
|
938
|
+
// force an immediate course-correction rather than waiting for the current
|
|
939
|
+
// turn to fold in a queued message. `runStream`/`driveStream` supersede the
|
|
940
|
+
// prior run (abort + runToken bump + pill cleanup).
|
|
941
|
+
const interrupt = useCallback(
|
|
942
|
+
(message: { text?: string }) => {
|
|
943
|
+
const text = message.text ?? "";
|
|
944
|
+
if (!text) return;
|
|
945
|
+
const threadId = activeKey;
|
|
946
|
+
const { next } = appendUserMessage(threadId, text);
|
|
947
|
+
logger.info("steer:interrupt", { threadId });
|
|
948
|
+
void runStream(threadId, next);
|
|
949
|
+
},
|
|
950
|
+
[appendUserMessage, runStream, activeKey],
|
|
901
951
|
);
|
|
902
952
|
|
|
903
953
|
/**
|
|
@@ -1362,6 +1412,7 @@ export const useMastraChat = (
|
|
|
1362
1412
|
status: activeSession.status,
|
|
1363
1413
|
error: activeSession.error,
|
|
1364
1414
|
sendMessage,
|
|
1415
|
+
onInterrupt: interrupt,
|
|
1365
1416
|
regenerate,
|
|
1366
1417
|
onStop: stop,
|
|
1367
1418
|
suggestions,
|
package/src/react/types.ts
CHANGED
|
@@ -90,6 +90,15 @@ export type ChatViewProps = {
|
|
|
90
90
|
*/
|
|
91
91
|
error?: Error | null;
|
|
92
92
|
sendMessage: (message: { text: string }) => void;
|
|
93
|
+
/**
|
|
94
|
+
* Send a message that interrupts the in-flight turn immediately: abort the
|
|
95
|
+
* current run and start a fresh turn with this message. When provided and
|
|
96
|
+
* the chat is running, the composer shows a small "interrupt" action next
|
|
97
|
+
* to the queue-steer Send, so the user can force an immediate
|
|
98
|
+
* course-correction instead of folding the message into the live turn.
|
|
99
|
+
* Omit to offer only the queue-steer send while running.
|
|
100
|
+
*/
|
|
101
|
+
onInterrupt?: (message: { text: string }) => void;
|
|
93
102
|
regenerate?: () => void;
|
|
94
103
|
/**
|
|
95
104
|
* Abort the in-flight response. When provided and the chat is running
|
|
@@ -123,6 +123,50 @@ export class MastraPluginClient extends MastraClient {
|
|
|
123
123
|
return asMastraStreamResponse(response);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Steer a live run by handing it another message mid-turn ("queue"), via
|
|
128
|
+
* the agent `/queue-message` route. `behavior` controls what happens when
|
|
129
|
+
* the run is active: `deliver` (default) folds the message into the current
|
|
130
|
+
* turn so the agent picks it up without restarting; `persist` holds it for
|
|
131
|
+
* the next turn; `discard` drops it. Routed per call (own thread header, no
|
|
132
|
+
* shared client state) like {@link streamAgent}.
|
|
133
|
+
*
|
|
134
|
+
* Resolves `true` when the server accepted the message for delivery, `false`
|
|
135
|
+
* otherwise (unknown run / route unsupported / non-2xx) - never throws for a
|
|
136
|
+
* rejected steer, so the caller can fall back to interrupting + resending.
|
|
137
|
+
* Queue / signal routes are `@experimental` in Mastra.
|
|
138
|
+
*/
|
|
139
|
+
async queueMessage(params: {
|
|
140
|
+
agentId: string;
|
|
141
|
+
runId: string;
|
|
142
|
+
threadId?: string;
|
|
143
|
+
message: string;
|
|
144
|
+
behavior?: "deliver" | "persist" | "discard";
|
|
145
|
+
}): Promise<boolean> {
|
|
146
|
+
const url = `${this.basePath}/agents/${encodeURIComponent(params.agentId)}/queue-message`;
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetch(url, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
credentials: "include",
|
|
151
|
+
headers: {
|
|
152
|
+
"Content-Type": "application/json",
|
|
153
|
+
...this.#routingHeaders({ threadId: params.threadId }),
|
|
154
|
+
},
|
|
155
|
+
body: JSON.stringify({
|
|
156
|
+
runId: params.runId,
|
|
157
|
+
...(params.threadId ? { threadId: params.threadId } : {}),
|
|
158
|
+
message: params.message,
|
|
159
|
+
ifActive: { behavior: params.behavior ?? "deliver" },
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
if (!response.ok) return false;
|
|
163
|
+
const body = (await response.json().catch(() => null)) as { accepted?: boolean } | null;
|
|
164
|
+
return body?.accepted === true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
126
170
|
/**
|
|
127
171
|
* Resume a suspended `requireApproval` tool via `approve-tool-call`.
|
|
128
172
|
* Reads SSE directly instead of `agent.approveToolCall()` so the
|
|
@@ -50,6 +50,28 @@ export function isSessionRunning(session: ThreadSession): boolean {
|
|
|
50
50
|
return session.status === "submitted" || session.status === "streaming";
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Settle any tool-progress pills still marked `running` to `done`. A cancelled
|
|
55
|
+
* or interrupted turn stops delivering the `tool-result` / `tool-error` chunks
|
|
56
|
+
* that would otherwise close them, so without this a Genie / chart pill would
|
|
57
|
+
* spin forever after the user hits stop. Returns the same map when nothing was
|
|
58
|
+
* running so callers can skip a needless state update.
|
|
59
|
+
*/
|
|
60
|
+
export function terminateRunningToolEvents(
|
|
61
|
+
toolEventsByMessage: Record<string, ToolEvent[]>,
|
|
62
|
+
): Record<string, ToolEvent[]> {
|
|
63
|
+
let changed = false;
|
|
64
|
+
const next: Record<string, ToolEvent[]> = {};
|
|
65
|
+
for (const [messageId, events] of Object.entries(toolEventsByMessage)) {
|
|
66
|
+
next[messageId] = events.map((event) => {
|
|
67
|
+
if (event.status !== "running") return event;
|
|
68
|
+
changed = true;
|
|
69
|
+
return { ...event, status: "done" as const };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return changed ? next : toolEventsByMessage;
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
export function sessionKey(activeThreadId: string | undefined): string {
|
|
54
76
|
return activeThreadId ?? DEFAULT_THREAD_SESSION_KEY;
|
|
55
77
|
}
|