@dbx-tools/ui-mastra 0.9.129 → 0.9.132
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +155 -166
- package/index.ts +6 -3
- package/package.json +7 -6
- package/src/react/bubbles.tsx +3 -1
- package/src/react/chat-composer.tsx +93 -38
- package/src/react/chat-sessions.ts +9 -0
- package/src/react/chat-stream.ts +12 -0
- package/src/react/chat-view.tsx +2 -0
- package/src/react/embed-slots.tsx +40 -10
- package/src/react/index.ts +5 -1
- package/src/react/mastra-chat.tsx +330 -55
- package/src/react/types.ts +2 -0
- package/src/support/active-runs.ts +8 -2
- package/src/support/export.ts +30 -15
- package/src/support/genie-client.ts +220 -0
- package/src/support/index.ts +4 -1
- package/src/support/mastra-client.ts +49 -14
- package/src/support/thread-persistence.ts +9 -0
- package/src/support/thread-sessions.ts +19 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { errorUtils, hash, log } from "@dbx-tools/shared-core";
|
|
2
2
|
import { isStaleMastraResumeError } from "@dbx-tools/shared-mastra/resume";
|
|
3
3
|
import type { ReasoningEffort } from "@dbx-tools/shared-model";
|
|
4
|
+
import { getAuthStatus } from "@dbx-tools/ui/auth/react";
|
|
4
5
|
import { useBrand } from "@dbx-tools/ui/branding/react";
|
|
5
6
|
import type { FileUIPart, UIMessage } from "ai";
|
|
6
7
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
@@ -9,7 +10,7 @@ import { useChatApprovals } from "./chat-approvals.ts";
|
|
|
9
10
|
import { useChatFeedback } from "./chat-feedback.ts";
|
|
10
11
|
import { useChatHistory } from "./chat-history.ts";
|
|
11
12
|
import { useChatSessions } from "./chat-sessions.ts";
|
|
12
|
-
import { useChatStream } from "./chat-stream.ts";
|
|
13
|
+
import { useChatStream, waitForTerminalChunk } from "./chat-stream.ts";
|
|
13
14
|
import { ChatView } from "./chat-view.tsx";
|
|
14
15
|
import { dedupeSuggestions } from "./suggestions.ts";
|
|
15
16
|
import type { ChatViewProps, ThreadPlacement, ThreadSummary } from "./types.ts";
|
|
@@ -20,8 +21,10 @@ import {
|
|
|
20
21
|
storeActiveRun,
|
|
21
22
|
} from "../support/active-runs.ts";
|
|
22
23
|
import type { EmbedResolver, ExportFormat } from "../support/export.ts";
|
|
24
|
+
import { GenieChatClient } from "../support/genie-client.ts";
|
|
23
25
|
import {
|
|
24
26
|
useMastraClient,
|
|
27
|
+
useMastraConfig,
|
|
25
28
|
useMastraDefaultModel,
|
|
26
29
|
useMastraMlflowExperiment,
|
|
27
30
|
useMastraModels,
|
|
@@ -42,10 +45,13 @@ import {
|
|
|
42
45
|
type MastraRequestContextSnapshot,
|
|
43
46
|
type MastraRequestContextSource,
|
|
44
47
|
} from "../support/request-context.ts";
|
|
48
|
+
import { threadStorageKey } from "../support/thread-persistence.ts";
|
|
45
49
|
import {
|
|
46
50
|
DEFAULT_THREAD_SESSION_KEY,
|
|
47
51
|
enqueueSteer,
|
|
52
|
+
interruptedTurnMessages,
|
|
48
53
|
isSessionRunning,
|
|
54
|
+
lastUserMessage,
|
|
49
55
|
removeSteer as removeSteerFromQueue,
|
|
50
56
|
reorderSteers as reorderSteerQueue,
|
|
51
57
|
} from "../support/thread-sessions.ts";
|
|
@@ -53,11 +59,13 @@ import {
|
|
|
53
59
|
const _loadChatExport = () => import("../support/export.ts");
|
|
54
60
|
const EMPTY_REASONING_EFFORTS: readonly ReasoningEffort[] = [];
|
|
55
61
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
//
|
|
62
|
+
/** Why the active managed thread changed. */
|
|
63
|
+
export type ThreadIdChangeReason = "hydrate" | "select" | "new" | "delete" | "missing" | "resolve";
|
|
64
|
+
|
|
65
|
+
// Self-contained drop-in chat. `useMastraChat` consumes native AI SDK
|
|
66
|
+
// `UIMessage` streams through one client contract: Mastra agents use the
|
|
67
|
+
// official chat route and direct Genie projects its events onto that wire.
|
|
68
|
+
// `ChatView` and the thread session state are transport-agnostic.
|
|
61
69
|
//
|
|
62
70
|
// Approval gates ride the same channel: a paused `requireApproval: true`
|
|
63
71
|
// tool call emits a `tool-call-approval` chunk carrying
|
|
@@ -102,14 +110,6 @@ const deriveThreadTitle = (text: string): string => {
|
|
|
102
110
|
return clean.length > TITLE_PREVIEW_MAX ? `${clean.slice(0, TITLE_PREVIEW_MAX - 1)}…` : clean;
|
|
103
111
|
};
|
|
104
112
|
|
|
105
|
-
/**
|
|
106
|
-
* `localStorage` key the active thread id is persisted under, namespaced
|
|
107
|
-
* by plugin mount + agent so two agents (or two apps on one origin)
|
|
108
|
-
* don't clobber each other's "current conversation".
|
|
109
|
-
*/
|
|
110
|
-
const threadStorageKey = (basePath: string, agentId: string): string =>
|
|
111
|
-
`dbx-mastra-thread:${basePath}:${agentId}`;
|
|
112
|
-
|
|
113
113
|
/** Read the persisted active thread id, tolerating storage being unavailable. */
|
|
114
114
|
const readStoredThreadId = (key: string): string | undefined => {
|
|
115
115
|
try {
|
|
@@ -162,9 +162,19 @@ export interface UseMastraChatOptions<
|
|
|
162
162
|
> {
|
|
163
163
|
/**
|
|
164
164
|
* Agent to converse with. Defaults to the Mastra plugin's registered
|
|
165
|
-
* default agent (`clientConfig().defaultAgent`).
|
|
165
|
+
* default agent (`clientConfig().defaultAgent`). Ignored when
|
|
166
|
+
* {@link genieSpace} is set.
|
|
166
167
|
*/
|
|
167
168
|
agentId?: string;
|
|
169
|
+
/**
|
|
170
|
+
* Converse with this Genie space alias directly instead of an agent. Every
|
|
171
|
+
* turn is one Genie message streamed as an `ask_genie` tool call, so pills,
|
|
172
|
+
* thoughts, suggestions, and embeds render exactly as in an agent chat.
|
|
173
|
+
* Genie owns the state: its conversations are the threads and its messages
|
|
174
|
+
* are the history; nothing is stored in Mastra Memory. Conversations cannot
|
|
175
|
+
* be renamed, turns take no attachments, and feedback is unavailable.
|
|
176
|
+
*/
|
|
177
|
+
genieSpace?: string;
|
|
168
178
|
/**
|
|
169
179
|
* Surface the built-in model picker in the header, letting the user
|
|
170
180
|
* override the serving endpoint per turn (via `X-Mastra-Model`).
|
|
@@ -202,6 +212,17 @@ export interface UseMastraChatOptions<
|
|
|
202
212
|
* chat's own width, so an embedded panel decides on its own space).
|
|
203
213
|
*/
|
|
204
214
|
threadPlacement?: ThreadPlacement;
|
|
215
|
+
/**
|
|
216
|
+
* Host-selected managed thread id. Use this to restore a thread from a URL
|
|
217
|
+
* or another external state owner. Changes after mount switch the active
|
|
218
|
+
* conversation without remounting the chat.
|
|
219
|
+
*/
|
|
220
|
+
threadId?: string;
|
|
221
|
+
/**
|
|
222
|
+
* Receives every chat-owned thread change. Missing externally selected
|
|
223
|
+
* threads are replaced with a new local thread and reported as `"missing"`.
|
|
224
|
+
*/
|
|
225
|
+
onThreadIdChange?: (threadId: string, reason: ThreadIdChangeReason) => void;
|
|
205
226
|
/**
|
|
206
227
|
* Enable chat export. Off by default (opt-in). When on, the header
|
|
207
228
|
* shows an "Export" menu for the whole conversation and each assistant
|
|
@@ -251,9 +272,20 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
251
272
|
// stable across renders (memoized on `basePath` / `defaultAgent`) so
|
|
252
273
|
// using it as a hook dep doesn't refire the initial-history fetch on
|
|
253
274
|
// every parent render.
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
const
|
|
275
|
+
const pluginClient = useMastraClient();
|
|
276
|
+
const mastraConfig = useMastraConfig();
|
|
277
|
+
const genieSpace = options.genieSpace?.trim() || undefined;
|
|
278
|
+
const genieClient = useMemo(
|
|
279
|
+
() => (genieSpace ? new GenieChatClient(mastraConfig, genieSpace) : undefined),
|
|
280
|
+
// Same scalars `useMastraClient` keys on; `mastraConfig` identity churns.
|
|
281
|
+
[genieSpace, mastraConfig.basePath, mastraConfig.defaultAgent],
|
|
282
|
+
);
|
|
283
|
+
const mastraClient = genieClient ?? pluginClient;
|
|
284
|
+
// Genie chats key persisted state (active thread, sidebar) by space alias.
|
|
285
|
+
const agentId = genieSpace
|
|
286
|
+
? `genie:${genieSpace}`
|
|
287
|
+
: (options.agentId ?? mastraClient.defaultAgent);
|
|
288
|
+
const showModelPicker = Boolean(options.showModelPicker) && !genieSpace;
|
|
257
289
|
const modelKey = modelStorageKey(mastraClient.basePath, agentId);
|
|
258
290
|
const [model, setModel] = useState(() => (showModelPicker ? readStoredModel(modelKey) : ""));
|
|
259
291
|
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort | undefined>();
|
|
@@ -299,21 +331,89 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
299
331
|
const enableFeedback = options.enableFeedback !== false;
|
|
300
332
|
const feedbackAvailable = enableFeedback && mastraClient.feedbackEnabled;
|
|
301
333
|
const { url: mlflowExperimentUrl } = useMastraMlflowExperiment(feedbackAvailable);
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
334
|
+
const [resourceScope, setResourceScope] = useState<string | null>(null);
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
let cancelled = false;
|
|
337
|
+
void getAuthStatus()
|
|
338
|
+
.then((status) => {
|
|
339
|
+
if (cancelled) return;
|
|
340
|
+
setResourceScope(
|
|
341
|
+
status.enabled && status.authenticated ? (status.email?.trim().toLowerCase() ?? "") : "",
|
|
342
|
+
);
|
|
343
|
+
})
|
|
344
|
+
.catch(() => {
|
|
345
|
+
if (!cancelled) setResourceScope("");
|
|
346
|
+
});
|
|
347
|
+
return () => {
|
|
348
|
+
cancelled = true;
|
|
349
|
+
};
|
|
350
|
+
}, []);
|
|
351
|
+
const persistenceReady = resourceScope !== null;
|
|
352
|
+
const threadKey = persistenceReady
|
|
353
|
+
? threadStorageKey(mastraClient.basePath, agentId, resourceScope || undefined)
|
|
354
|
+
: undefined;
|
|
355
|
+
const activeRunsKey = activeRunStorageKey(
|
|
356
|
+
mastraClient.basePath,
|
|
357
|
+
agentId,
|
|
358
|
+
persistenceReady ? resourceScope || undefined : "pending",
|
|
306
359
|
);
|
|
360
|
+
const requestedThreadId = options.threadId?.trim() || undefined;
|
|
361
|
+
const localThreadIdsRef = useRef(new Set<string>());
|
|
362
|
+
const emittedThreadIdRef = useRef<string | undefined>(undefined);
|
|
363
|
+
const threadIdChangeRef = useRef(options.onThreadIdChange);
|
|
364
|
+
threadIdChangeRef.current = options.onThreadIdChange;
|
|
365
|
+
const [activeThreadId, setActiveThreadId] = useState<string>(() => {
|
|
366
|
+
const id = requestedThreadId ?? hash.id();
|
|
367
|
+
if (!requestedThreadId) localThreadIdsRef.current.add(id);
|
|
368
|
+
return id;
|
|
369
|
+
});
|
|
370
|
+
const mintThreadId = useCallback(() => {
|
|
371
|
+
const id = hash.id();
|
|
372
|
+
localThreadIdsRef.current.add(id);
|
|
373
|
+
return id;
|
|
374
|
+
}, []);
|
|
375
|
+
const activateThread = useCallback((threadId: string, reason: ThreadIdChangeReason) => {
|
|
376
|
+
emittedThreadIdRef.current = threadId;
|
|
377
|
+
setActiveThreadId(threadId);
|
|
378
|
+
threadIdChangeRef.current?.(threadId, reason);
|
|
379
|
+
}, []);
|
|
380
|
+
const initializedThreadKeyRef = useRef<string | undefined>(undefined);
|
|
381
|
+
const [hydratedThreadKey, setHydratedThreadKey] = useState<string | undefined>(undefined);
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
if (!enableThreads || !threadKey || initializedThreadKeyRef.current === threadKey) return;
|
|
384
|
+
initializedThreadKeyRef.current = threadKey;
|
|
385
|
+
const storedThreadId = readStoredThreadId(threadKey);
|
|
386
|
+
const threadId = requestedThreadId ?? storedThreadId ?? mintThreadId();
|
|
387
|
+
setActiveThreadId(threadId);
|
|
388
|
+
threadIdChangeRef.current?.(threadId, "hydrate");
|
|
389
|
+
setHydratedThreadKey(threadKey);
|
|
390
|
+
}, [enableThreads, mintThreadId, requestedThreadId, threadKey]);
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (
|
|
393
|
+
enableThreads &&
|
|
394
|
+
hydratedThreadKey === threadKey &&
|
|
395
|
+
requestedThreadId &&
|
|
396
|
+
requestedThreadId !== activeThreadId
|
|
397
|
+
) {
|
|
398
|
+
if (emittedThreadIdRef.current === activeThreadId) return;
|
|
399
|
+
setActiveThreadId(requestedThreadId);
|
|
400
|
+
} else if (requestedThreadId === activeThreadId) {
|
|
401
|
+
emittedThreadIdRef.current = undefined;
|
|
402
|
+
}
|
|
403
|
+
}, [activeThreadId, enableThreads, hydratedThreadKey, requestedThreadId, threadKey]);
|
|
307
404
|
const {
|
|
308
405
|
threads,
|
|
309
406
|
loading: isLoadingThreads,
|
|
407
|
+
error: threadsError,
|
|
310
408
|
refresh: refreshThreads,
|
|
311
|
-
} = useMastraThreads(options.agentId, enableThreads);
|
|
409
|
+
} = useMastraThreads(options.agentId, enableThreads, genieClient);
|
|
312
410
|
// Persist the active thread id so a reload reopens the same
|
|
313
411
|
// conversation. Best-effort; storage may be unavailable.
|
|
314
412
|
useEffect(() => {
|
|
315
|
-
if (enableThreads &&
|
|
316
|
-
|
|
413
|
+
if (enableThreads && threadKey && hydratedThreadKey === threadKey && activeThreadId) {
|
|
414
|
+
storeStoredThreadId(threadKey, activeThreadId);
|
|
415
|
+
}
|
|
416
|
+
}, [enableThreads, threadKey, hydratedThreadKey, activeThreadId]);
|
|
317
417
|
// Conversation sidebar show/hide, persisted so the user's choice
|
|
318
418
|
// sticks across reloads. The view exposes a header toggle wired to
|
|
319
419
|
// `onToggleSidebar`; defaults open the first time.
|
|
@@ -409,6 +509,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
409
509
|
const { questions: genieSuggestions } = useMastraSuggestions(
|
|
410
510
|
options.agentId,
|
|
411
511
|
explicitSuggestions === undefined,
|
|
512
|
+
genieClient,
|
|
412
513
|
);
|
|
413
514
|
const suggestions = useMemo(
|
|
414
515
|
() => explicitSuggestions ?? dedupeSuggestions(genieSuggestions),
|
|
@@ -418,12 +519,46 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
418
519
|
activeKey,
|
|
419
520
|
activeSession,
|
|
420
521
|
getSession,
|
|
522
|
+
moveSession,
|
|
421
523
|
removeSession,
|
|
422
524
|
resetSession,
|
|
423
525
|
streamingThreadIds,
|
|
424
526
|
updateSession,
|
|
425
527
|
writeMessages,
|
|
426
528
|
} = useChatSessions(activeThreadId);
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
if (
|
|
531
|
+
!enableThreads ||
|
|
532
|
+
hydratedThreadKey !== threadKey ||
|
|
533
|
+
!requestedThreadId ||
|
|
534
|
+
activeThreadId !== requestedThreadId ||
|
|
535
|
+
isLoadingThreads ||
|
|
536
|
+
threadsError ||
|
|
537
|
+
localThreadIdsRef.current.has(requestedThreadId) ||
|
|
538
|
+
optimisticThreads[requestedThreadId] ||
|
|
539
|
+
threads.some((thread) => thread.id === requestedThreadId) ||
|
|
540
|
+
readStoredActiveRuns(activeRunsKey).some((run) => run.threadId === requestedThreadId)
|
|
541
|
+
) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const replacement = mintThreadId();
|
|
545
|
+
resetSession(replacement, true);
|
|
546
|
+
activateThread(replacement, "missing");
|
|
547
|
+
}, [
|
|
548
|
+
activateThread,
|
|
549
|
+
activeRunsKey,
|
|
550
|
+
activeThreadId,
|
|
551
|
+
enableThreads,
|
|
552
|
+
hydratedThreadKey,
|
|
553
|
+
isLoadingThreads,
|
|
554
|
+
mintThreadId,
|
|
555
|
+
optimisticThreads,
|
|
556
|
+
requestedThreadId,
|
|
557
|
+
resetSession,
|
|
558
|
+
threadKey,
|
|
559
|
+
threads,
|
|
560
|
+
threadsError,
|
|
561
|
+
]);
|
|
427
562
|
const historyAvailability = isLoadingThreads
|
|
428
563
|
? "loading"
|
|
429
564
|
: threads.some((thread) => thread.id === activeThreadId) || optimisticThreads[activeThreadId]
|
|
@@ -451,6 +586,33 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
451
586
|
// stale-closure hazard (assigned each render, same pattern as `loadMoreRef`).
|
|
452
587
|
const drainQueueRef = useRef<(threadId: string) => void>(() => {});
|
|
453
588
|
|
|
589
|
+
const activeThreadIdRef = useRef(activeThreadId);
|
|
590
|
+
activeThreadIdRef.current = activeThreadId;
|
|
591
|
+
// Adopt the server-assigned id of a thread that streamed under a local id
|
|
592
|
+
// (a new Genie conversation): move its session and sidebar overlays, then
|
|
593
|
+
// switch to it when it is still the active thread.
|
|
594
|
+
const adoptThreadId = useCallback(
|
|
595
|
+
(localId: string, assignedId: string) => {
|
|
596
|
+
moveSession(localId, assignedId);
|
|
597
|
+
localThreadIdsRef.current.delete(localId);
|
|
598
|
+
localThreadIdsRef.current.add(assignedId);
|
|
599
|
+
setOptimisticThreads((prev) => {
|
|
600
|
+
const row = prev[localId];
|
|
601
|
+
if (!row) return prev;
|
|
602
|
+
const { [localId]: _drop, ...rest } = prev;
|
|
603
|
+
return { ...rest, [assignedId]: { ...row, id: assignedId } };
|
|
604
|
+
});
|
|
605
|
+
setProvisionalTitles((prev) => {
|
|
606
|
+
const title = prev[localId];
|
|
607
|
+
if (title === undefined) return prev;
|
|
608
|
+
const { [localId]: _drop, ...rest } = prev;
|
|
609
|
+
return { ...rest, [assignedId]: title };
|
|
610
|
+
});
|
|
611
|
+
if (activeThreadIdRef.current === localId) activateThread(assignedId, "resolve");
|
|
612
|
+
},
|
|
613
|
+
[activateThread, moveSession],
|
|
614
|
+
);
|
|
615
|
+
|
|
454
616
|
const processStream = useChatStream({ getSession, updateSession, writeMessages });
|
|
455
617
|
const driveStream = useCallback(
|
|
456
618
|
async (
|
|
@@ -474,10 +636,20 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
474
636
|
};
|
|
475
637
|
});
|
|
476
638
|
const runIdRef = { current: getSession(threadId).runId };
|
|
639
|
+
// The session's key; changes when the server assigns the thread its id.
|
|
640
|
+
let sessionThreadId = threadId;
|
|
641
|
+
let openedStream: MastraStreamResponse | undefined;
|
|
642
|
+
const adoptAssignedThreadId = () => {
|
|
643
|
+
const assigned = openedStream?.threadId?.();
|
|
644
|
+
if (!assigned || assigned === sessionThreadId) return;
|
|
645
|
+
adoptThreadId(sessionThreadId, assigned);
|
|
646
|
+
sessionThreadId = assigned;
|
|
647
|
+
};
|
|
477
648
|
try {
|
|
478
649
|
try {
|
|
479
|
-
|
|
480
|
-
await processStream(threadId,
|
|
650
|
+
openedStream = await open(controller.signal);
|
|
651
|
+
await processStream(threadId, openedStream, assistantId, runIdRef, controller.signal);
|
|
652
|
+
adoptAssignedThreadId();
|
|
481
653
|
} catch (initialError) {
|
|
482
654
|
const runId = runIdRef.current;
|
|
483
655
|
if (
|
|
@@ -512,7 +684,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
512
684
|
throw reconnectError;
|
|
513
685
|
}
|
|
514
686
|
}
|
|
515
|
-
updateSession(
|
|
687
|
+
updateSession(sessionThreadId, (session) => {
|
|
516
688
|
if (session.runToken !== token) return session;
|
|
517
689
|
return {
|
|
518
690
|
...session,
|
|
@@ -522,14 +694,17 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
522
694
|
runId: runIdRef.current,
|
|
523
695
|
};
|
|
524
696
|
});
|
|
525
|
-
if (getSession(
|
|
697
|
+
if (getSession(sessionThreadId).runToken === token) {
|
|
526
698
|
const completedRunId = runIdRef.current;
|
|
527
699
|
if (completedRunId) removeStoredActiveRun(activeRunsKey, completedRunId);
|
|
528
700
|
refreshThreadsSoon();
|
|
529
701
|
// Turn finished on its own: start the oldest queued steer, if any.
|
|
530
|
-
drainQueueRef.current(
|
|
702
|
+
drainQueueRef.current(sessionThreadId);
|
|
531
703
|
}
|
|
532
704
|
} catch (caught) {
|
|
705
|
+
// A failed turn may still have created its server-side thread.
|
|
706
|
+
adoptAssignedThreadId();
|
|
707
|
+
const threadId = sessionThreadId;
|
|
533
708
|
if (getSession(threadId).runToken !== token) return;
|
|
534
709
|
if (isStaleMastraResumeError(caught)) {
|
|
535
710
|
logger.warn("ignored stale mastra resume", {
|
|
@@ -567,6 +742,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
567
742
|
}
|
|
568
743
|
},
|
|
569
744
|
[
|
|
745
|
+
adoptThreadId,
|
|
570
746
|
agentId,
|
|
571
747
|
activeRunsKey,
|
|
572
748
|
getSession,
|
|
@@ -579,16 +755,25 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
579
755
|
);
|
|
580
756
|
|
|
581
757
|
const runStream = useCallback(
|
|
582
|
-
(
|
|
758
|
+
(
|
|
759
|
+
threadId: string,
|
|
760
|
+
messages: UIMessage | UIMessage[],
|
|
761
|
+
requestContext?: MastraRequestContextSnapshot,
|
|
762
|
+
) => {
|
|
763
|
+
const turnMessages = Array.isArray(messages) ? messages : [messages];
|
|
764
|
+
const userMessage = lastUserMessage(turnMessages);
|
|
765
|
+
if (!userMessage) throw new Error("A Mastra chat turn requires a user message");
|
|
583
766
|
const assistantId = hash.id();
|
|
584
767
|
const runId = hash.id();
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
768
|
+
if (mastraClient.backgroundTurns) {
|
|
769
|
+
storeActiveRun(activeRunsKey, {
|
|
770
|
+
threadId,
|
|
771
|
+
runId,
|
|
772
|
+
assistantId,
|
|
773
|
+
userMessage,
|
|
774
|
+
startedAt: Date.now(),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
592
777
|
updateSession(threadId, (session) => ({
|
|
593
778
|
...session,
|
|
594
779
|
assistantId,
|
|
@@ -612,7 +797,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
612
797
|
return driveStream(threadId, assistantId, (signal) => {
|
|
613
798
|
return mastraClient.streamAgent({
|
|
614
799
|
agentId,
|
|
615
|
-
messages:
|
|
800
|
+
messages: turnMessages,
|
|
616
801
|
runId,
|
|
617
802
|
threadId: streamThreadId,
|
|
618
803
|
model,
|
|
@@ -627,7 +812,19 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
627
812
|
|
|
628
813
|
const recoveredRunsKeyRef = useRef<string | undefined>(undefined);
|
|
629
814
|
useEffect(() => {
|
|
630
|
-
if (!
|
|
815
|
+
if (!persistenceReady || mastraClient.backgroundTurns) return;
|
|
816
|
+
for (const run of readStoredActiveRuns(activeRunsKey)) {
|
|
817
|
+
removeStoredActiveRun(activeRunsKey, run.runId);
|
|
818
|
+
}
|
|
819
|
+
}, [activeRunsKey, mastraClient.backgroundTurns, persistenceReady]);
|
|
820
|
+
useEffect(() => {
|
|
821
|
+
if (
|
|
822
|
+
!persistenceReady ||
|
|
823
|
+
!mastraClient.backgroundTurns ||
|
|
824
|
+
recoveredRunsKeyRef.current === activeRunsKey
|
|
825
|
+
) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
631
828
|
recoveredRunsKeyRef.current = activeRunsKey;
|
|
632
829
|
for (const run of readStoredActiveRuns(activeRunsKey)) {
|
|
633
830
|
const session = getSession(run.threadId);
|
|
@@ -664,7 +861,16 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
664
861
|
if (history) writeMessages(run.threadId, toChronologicalUiMessages(history));
|
|
665
862
|
});
|
|
666
863
|
}
|
|
667
|
-
}, [
|
|
864
|
+
}, [
|
|
865
|
+
activeRunsKey,
|
|
866
|
+
agentId,
|
|
867
|
+
driveStream,
|
|
868
|
+
getSession,
|
|
869
|
+
mastraClient,
|
|
870
|
+
persistenceReady,
|
|
871
|
+
updateSession,
|
|
872
|
+
writeMessages,
|
|
873
|
+
]);
|
|
668
874
|
|
|
669
875
|
// Start the oldest queued steer as the next turn (FIFO drain). Called from
|
|
670
876
|
// `driveStream` when a turn ends normally; a no-op when the queue is empty.
|
|
@@ -734,6 +940,56 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
734
940
|
updateSession,
|
|
735
941
|
});
|
|
736
942
|
|
|
943
|
+
/** Abort the active run and wait until its durable stream reaches a terminal event. */
|
|
944
|
+
const interruptActiveRun = useCallback(
|
|
945
|
+
async (threadId: string): Promise<void> => {
|
|
946
|
+
const current = getSession(threadId);
|
|
947
|
+
if (!isSessionRunning(current)) return;
|
|
948
|
+
const streamThreadId = threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
|
|
949
|
+
const runId = current.runId;
|
|
950
|
+
const settlement =
|
|
951
|
+
mastraClient.backgroundTurns && runId
|
|
952
|
+
? mastraClient
|
|
953
|
+
.observeAgentStream({ agentId, runId, threadId: streamThreadId })
|
|
954
|
+
.then((stream) => waitForTerminalChunk(stream.stream))
|
|
955
|
+
.catch((error: unknown) => {
|
|
956
|
+
logger.warn("failed to observe interrupted background run", {
|
|
957
|
+
runId,
|
|
958
|
+
error: errorUtils.toError(error),
|
|
959
|
+
});
|
|
960
|
+
})
|
|
961
|
+
: undefined;
|
|
962
|
+
const serverAbort =
|
|
963
|
+
mastraClient.backgroundTurns && runId
|
|
964
|
+
? mastraClient
|
|
965
|
+
.abortAgentRun({ agentId, runId, threadId: streamThreadId })
|
|
966
|
+
.then((aborted) => {
|
|
967
|
+
if (aborted) removeStoredActiveRun(activeRunsKey, runId);
|
|
968
|
+
})
|
|
969
|
+
.catch((error: unknown) => {
|
|
970
|
+
logger.warn("failed to abort interrupted background run", {
|
|
971
|
+
runId,
|
|
972
|
+
error: errorUtils.toError(error),
|
|
973
|
+
});
|
|
974
|
+
})
|
|
975
|
+
: undefined;
|
|
976
|
+
current.abortController?.abort();
|
|
977
|
+
updateSession(threadId, (session) => {
|
|
978
|
+
if (!isSessionRunning(session)) return session;
|
|
979
|
+
return {
|
|
980
|
+
...session,
|
|
981
|
+
abortController: null,
|
|
982
|
+
runToken: session.runToken + 1,
|
|
983
|
+
error: null,
|
|
984
|
+
status: "submitted",
|
|
985
|
+
};
|
|
986
|
+
});
|
|
987
|
+
await serverAbort;
|
|
988
|
+
await settlement;
|
|
989
|
+
},
|
|
990
|
+
[activeRunsKey, agentId, getSession, mastraClient, updateSession],
|
|
991
|
+
);
|
|
992
|
+
|
|
737
993
|
// Append a user message to a thread's transcript, update thread activity,
|
|
738
994
|
// and derive a provisional title for a brand-new thread.
|
|
739
995
|
const appendUserMessage = useCallback(
|
|
@@ -800,17 +1056,25 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
800
1056
|
const sendSteerNow = useCallback(
|
|
801
1057
|
(steerId: string) => {
|
|
802
1058
|
const threadId = activeKey;
|
|
803
|
-
const
|
|
1059
|
+
const current = getSession(threadId);
|
|
1060
|
+
const steer = current.queuedSteers.find((s) => s.id === steerId);
|
|
804
1061
|
if (!steer) return;
|
|
805
1062
|
updateSession(threadId, (session) => ({
|
|
806
1063
|
...session,
|
|
807
1064
|
queuedSteers: removeSteerFromQueue(session.queuedSteers, steerId),
|
|
808
1065
|
}));
|
|
809
1066
|
logger.info("steer:send-now", { threadId });
|
|
810
|
-
|
|
811
|
-
|
|
1067
|
+
void (async () => {
|
|
1068
|
+
await interruptActiveRun(threadId);
|
|
1069
|
+
const { message } = appendUserMessage(threadId, steer.text ?? "", steer.files);
|
|
1070
|
+
void runStream(
|
|
1071
|
+
threadId,
|
|
1072
|
+
interruptedTurnMessages(current.messages, message),
|
|
1073
|
+
steer.requestContext,
|
|
1074
|
+
);
|
|
1075
|
+
})();
|
|
812
1076
|
},
|
|
813
|
-
[activeKey, appendUserMessage, getSession, runStream, updateSession],
|
|
1077
|
+
[activeKey, appendUserMessage, getSession, interruptActiveRun, runStream, updateSession],
|
|
814
1078
|
);
|
|
815
1079
|
|
|
816
1080
|
// Drop a queued steer without sending it.
|
|
@@ -870,16 +1134,16 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
870
1134
|
const selectThread = useCallback(
|
|
871
1135
|
(threadId: string) => {
|
|
872
1136
|
if (threadId === activeThreadId) return;
|
|
873
|
-
|
|
1137
|
+
activateThread(threadId, "select");
|
|
874
1138
|
},
|
|
875
|
-
[activeThreadId],
|
|
1139
|
+
[activateThread, activeThreadId],
|
|
876
1140
|
);
|
|
877
1141
|
|
|
878
1142
|
const newThread = useCallback(() => {
|
|
879
|
-
const id =
|
|
1143
|
+
const id = mintThreadId();
|
|
880
1144
|
resetSession(id, true);
|
|
881
|
-
|
|
882
|
-
}, [resetSession]);
|
|
1145
|
+
activateThread(id, "new");
|
|
1146
|
+
}, [activateThread, mintThreadId, resetSession]);
|
|
883
1147
|
|
|
884
1148
|
const deleteThread = useCallback(
|
|
885
1149
|
async (threadId: string) => {
|
|
@@ -896,9 +1160,9 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
896
1160
|
return rest;
|
|
897
1161
|
});
|
|
898
1162
|
if (threadId === activeThreadId) {
|
|
899
|
-
const id =
|
|
1163
|
+
const id = mintThreadId();
|
|
900
1164
|
resetSession(id, true);
|
|
901
|
-
|
|
1165
|
+
activateThread(id, "delete");
|
|
902
1166
|
}
|
|
903
1167
|
try {
|
|
904
1168
|
await mastraClient.removeThread(threadId, { agentId });
|
|
@@ -917,7 +1181,16 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
917
1181
|
});
|
|
918
1182
|
}
|
|
919
1183
|
},
|
|
920
|
-
[
|
|
1184
|
+
[
|
|
1185
|
+
activateThread,
|
|
1186
|
+
activeThreadId,
|
|
1187
|
+
agentId,
|
|
1188
|
+
mastraClient,
|
|
1189
|
+
mintThreadId,
|
|
1190
|
+
refreshThreads,
|
|
1191
|
+
removeSession,
|
|
1192
|
+
resetSession,
|
|
1193
|
+
],
|
|
921
1194
|
);
|
|
922
1195
|
|
|
923
1196
|
/**
|
|
@@ -1011,6 +1284,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
1011
1284
|
() => ({
|
|
1012
1285
|
chart: (id) => mastraClient.chart(id),
|
|
1013
1286
|
statement: (id) => mastraClient.statement(id),
|
|
1287
|
+
visualization: (id) => mastraClient.visualization(id),
|
|
1014
1288
|
}),
|
|
1015
1289
|
[mastraClient],
|
|
1016
1290
|
);
|
|
@@ -1185,7 +1459,8 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
1185
1459
|
onLoadMore: loadOlderHistory,
|
|
1186
1460
|
isLoadingMore,
|
|
1187
1461
|
hasMore: activeSession.hasMoreHistory,
|
|
1188
|
-
isLoadingHistory,
|
|
1462
|
+
isLoadingHistory: !persistenceReady || isLoadingHistory,
|
|
1463
|
+
...(mastraClient.attachments ? {} : { allowAttachments: false }),
|
|
1189
1464
|
...(enableClear ? { onClear: handleClear } : {}),
|
|
1190
1465
|
threadPlacement,
|
|
1191
1466
|
// Conversation management: hand ChatView the thread list + handlers
|
|
@@ -1200,7 +1475,7 @@ export const useMastraChat = <TValues extends Record<string, unknown> = Record<s
|
|
|
1200
1475
|
onSelectThread: selectThread,
|
|
1201
1476
|
onNewThread: newThread,
|
|
1202
1477
|
onDeleteThread: deleteThread,
|
|
1203
|
-
onRenameThread: renameThread,
|
|
1478
|
+
...(mastraClient.threadRename ? { onRenameThread: renameThread } : {}),
|
|
1204
1479
|
// Cancel a background thread's run from the sidebar without
|
|
1205
1480
|
// switching to it.
|
|
1206
1481
|
onCancelThread: stop,
|
package/src/react/types.ts
CHANGED
|
@@ -207,6 +207,8 @@ export type ChatViewProps = {
|
|
|
207
207
|
* footer, after Upload and before Export and Clear.
|
|
208
208
|
*/
|
|
209
209
|
composerLeadingActions?: ReactNode;
|
|
210
|
+
/** Whether the composer accepts image attachments. Defaults to true. */
|
|
211
|
+
allowAttachments?: boolean;
|
|
210
212
|
/**
|
|
211
213
|
* Optional infinite-scroll-up handler. Fired when the user scrolls
|
|
212
214
|
* within `TOP_LOAD_MORE_THRESHOLD_PX` of the top of the
|
|
@@ -37,8 +37,14 @@ const StoredActiveRunFileSchema = z
|
|
|
37
37
|
type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
38
38
|
|
|
39
39
|
/** Build the browser storage key for one Mastra agent's active runs. */
|
|
40
|
-
export const activeRunStorageKey = (
|
|
41
|
-
|
|
40
|
+
export const activeRunStorageKey = (
|
|
41
|
+
basePath: string,
|
|
42
|
+
agentId: string,
|
|
43
|
+
resourceScope?: string,
|
|
44
|
+
): string => {
|
|
45
|
+
const prefix = `dbx-mastra-active-runs:${basePath}:${agentId}`;
|
|
46
|
+
return resourceScope ? `${prefix}:${encodeURIComponent(resourceScope)}` : prefix;
|
|
47
|
+
};
|
|
42
48
|
|
|
43
49
|
function browserStorage(): StorageLike | undefined {
|
|
44
50
|
try {
|