@dbx-tools/ui-mastra 0.6.211 → 0.6.212
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/LICENSE +202 -0
- package/index.ts +17 -2
- package/package.json +15 -10
- package/src/react/chat-approvals.ts +92 -0
- package/src/react/chat-composer.tsx +419 -0
- package/src/react/chat-feedback.ts +60 -0
- package/src/react/chat-history.ts +141 -0
- package/src/react/chat-sessions.ts +76 -0
- package/src/react/chat-stream-reducer.ts +241 -0
- package/src/react/chat-stream.ts +117 -0
- package/src/react/chat-thread-layout.tsx +214 -0
- package/src/react/chat-transcript.tsx +309 -0
- package/src/react/chat-view.tsx +69 -964
- package/src/react/mastra-chat.tsx +48 -587
- package/src/support/mastra-stream.ts +4 -8
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import { error as sharedError, hash, log } from "@dbx-tools/shared-core";
|
|
2
|
-
import {
|
|
2
|
+
import type { MastraThread } from "@dbx-tools/shared-mastra";
|
|
3
3
|
import { useBrand } from "@dbx-tools/ui-branding/react";
|
|
4
4
|
import type { UIMessage } from "ai";
|
|
5
5
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { useChatApprovals } from "./chat-approvals.ts";
|
|
6
7
|
import { ChatView } from "./chat-view.tsx";
|
|
8
|
+
import { useChatFeedback } from "./chat-feedback.ts";
|
|
9
|
+
import { useChatHistory } from "./chat-history.ts";
|
|
10
|
+
import { useChatSessions } from "./chat-sessions.ts";
|
|
11
|
+
import { useChatStream } from "./chat-stream.ts";
|
|
7
12
|
import { dedupeSuggestions } from "./suggestions.ts";
|
|
8
|
-
import type {
|
|
9
|
-
ApprovalDecision,
|
|
10
|
-
ChatViewProps,
|
|
11
|
-
FeedbackSubmission,
|
|
12
|
-
MessageFeedback,
|
|
13
|
-
ThreadPlacement,
|
|
14
|
-
ThreadSummary,
|
|
15
|
-
ToolEvent,
|
|
16
|
-
ToolProgress,
|
|
17
|
-
} from "./types.ts";
|
|
13
|
+
import type { ChatViewProps, ThreadPlacement, ThreadSummary } from "./types.ts";
|
|
18
14
|
import type { EmbedResolver, ExportFormat } from "../support/export.ts";
|
|
19
15
|
import {
|
|
20
16
|
useMastraClient,
|
|
@@ -30,13 +26,11 @@ import {
|
|
|
30
26
|
storeSelectedModel,
|
|
31
27
|
} from "../support/model-selection.ts";
|
|
32
28
|
import {
|
|
33
|
-
createThreadSession,
|
|
34
29
|
DEFAULT_THREAD_SESSION_KEY,
|
|
35
30
|
enqueueSteer,
|
|
36
31
|
isSessionRunning,
|
|
37
32
|
removeSteer as removeSteerFromQueue,
|
|
38
33
|
reorderSteers as reorderSteerQueue,
|
|
39
|
-
sessionKey,
|
|
40
34
|
terminateRunningToolEvents,
|
|
41
35
|
type ThreadSession,
|
|
42
36
|
} from "../support/thread-sessions.ts";
|
|
@@ -65,8 +59,6 @@ const _loadChatExport = () => import("../support/export.ts");
|
|
|
65
59
|
|
|
66
60
|
const logger = log.logger("ui-mastra/chat");
|
|
67
61
|
|
|
68
|
-
const HISTORY_PAGE_SIZE = 20;
|
|
69
|
-
|
|
70
62
|
const makeUserMessage = (text: string): UIMessage => ({
|
|
71
63
|
id: hash.id(),
|
|
72
64
|
role: "user",
|
|
@@ -149,27 +141,6 @@ const storeStoredSidebarOpen = (key: string, open: boolean): void => {
|
|
|
149
141
|
}
|
|
150
142
|
};
|
|
151
143
|
|
|
152
|
-
/**
|
|
153
|
-
* Pull the MLflow trace id (`tr-<hex>`) the server stamped on a stream
|
|
154
|
-
* response, if present. `@mastra/client-js`'s `agent.stream()` returns
|
|
155
|
-
* a Response-shaped object, so the header is read defensively (the
|
|
156
|
-
* shape isn't guaranteed across client versions). Returns `undefined`
|
|
157
|
-
* when absent - which is the "no feedback for this turn" signal.
|
|
158
|
-
*/
|
|
159
|
-
const readMlflowTraceId = (stream: unknown): string | undefined => {
|
|
160
|
-
const headers = (stream as { headers?: { get?: (name: string) => string | null } })?.headers;
|
|
161
|
-
const raw = headers?.get?.(feedback.MLFLOW_TRACE_ID_HEADER);
|
|
162
|
-
return raw?.trim() || undefined;
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
// `tool-output` chunks carry arbitrary tool-defined payloads; only the
|
|
166
|
-
// `{type: ...}` shape we know how to render in `ToolSessionPill` is
|
|
167
|
-
// surfaced. Anything else (other tools, raw data, etc.) is ignored.
|
|
168
|
-
const isToolProgress = (value: unknown): value is ToolProgress =>
|
|
169
|
-
typeof value === "object" &&
|
|
170
|
-
value !== null &&
|
|
171
|
-
typeof (value as { type?: unknown }).type === "string";
|
|
172
|
-
|
|
173
144
|
/** Options for {@link useMastraChat}. */
|
|
174
145
|
export interface UseMastraChatOptions {
|
|
175
146
|
/**
|
|
@@ -237,13 +208,6 @@ export interface UseMastraChatOptions {
|
|
|
237
208
|
enableFeedback?: boolean;
|
|
238
209
|
}
|
|
239
210
|
|
|
240
|
-
/**
|
|
241
|
-
* Thrown out of the chunk handler to unwind `processDataStream` when
|
|
242
|
-
* the user stops a turn. Callers treat it as a clean stop, not an
|
|
243
|
-
* error, so the composer just returns to idle.
|
|
244
|
-
*/
|
|
245
|
-
class StreamAborted extends Error {}
|
|
246
|
-
|
|
247
211
|
/**
|
|
248
212
|
* Headless driver for the Mastra chat experience. Owns the full
|
|
249
213
|
* conversation lifecycle (streaming, tool-event tracking, approvals,
|
|
@@ -397,327 +361,38 @@ export const useMastraChat = (
|
|
|
397
361
|
() => explicitSuggestions ?? dedupeSuggestions(genieSuggestions),
|
|
398
362
|
[explicitSuggestions, genieSuggestions],
|
|
399
363
|
);
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
for (const [id, session] of sessionsRef.current.entries()) {
|
|
426
|
-
if (id === DEFAULT_THREAD_SESSION_KEY) continue;
|
|
427
|
-
if (isSessionRunning(session)) ids.push(id);
|
|
428
|
-
}
|
|
429
|
-
return ids;
|
|
430
|
-
}, [sessionsTick]);
|
|
431
|
-
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
|
432
|
-
const [loadingMoreThreads, setLoadingMoreThreads] = useState<ReadonlySet<string>>(
|
|
433
|
-
() => new Set(),
|
|
434
|
-
);
|
|
435
|
-
const isLoadingMore = loadingMoreThreads.has(activeKey);
|
|
436
|
-
const historyInFlightRef = useRef(new Set<string>());
|
|
437
|
-
const feedbackByMessageRef = useRef<Record<string, MessageFeedback>>({});
|
|
438
|
-
feedbackByMessageRef.current = activeSession.feedbackByMessage;
|
|
364
|
+
const {
|
|
365
|
+
activeKey,
|
|
366
|
+
activeSession,
|
|
367
|
+
getSession,
|
|
368
|
+
removeSession,
|
|
369
|
+
resetSession,
|
|
370
|
+
streamingThreadIds,
|
|
371
|
+
updateSession,
|
|
372
|
+
writeMessages,
|
|
373
|
+
} = useChatSessions(activeThreadId);
|
|
374
|
+
const { isLoadingHistory, isLoadingMore, loadOlderHistory } = useChatHistory({
|
|
375
|
+
activeKey,
|
|
376
|
+
activeThreadId,
|
|
377
|
+
agentId,
|
|
378
|
+
getSession,
|
|
379
|
+
mastraClient,
|
|
380
|
+
updateSession,
|
|
381
|
+
writeMessages,
|
|
382
|
+
});
|
|
383
|
+
const submitFeedback = useChatFeedback({
|
|
384
|
+
activeKey,
|
|
385
|
+
feedbackByMessage: activeSession.feedbackByMessage,
|
|
386
|
+
mastraClient,
|
|
387
|
+
updateSession,
|
|
388
|
+
});
|
|
439
389
|
// Drains the next queued steer when a turn ends. Held in a ref because it
|
|
440
390
|
// closes over `runStream`, which is defined below and itself calls
|
|
441
391
|
// `driveStream` (which invokes this) - the ref breaks that cycle without a
|
|
442
392
|
// stale-closure hazard (assigned each render, same pattern as `loadMoreRef`).
|
|
443
393
|
const drainQueueRef = useRef<(threadId: string) => void>(() => {});
|
|
444
394
|
|
|
445
|
-
const
|
|
446
|
-
(threadId: string, next: UIMessage[]) => {
|
|
447
|
-
updateSession(threadId, (session) => ({ ...session, messages: next }));
|
|
448
|
-
},
|
|
449
|
-
[updateSession],
|
|
450
|
-
);
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Pipe a Mastra stream Response through the same chunk handler used
|
|
454
|
-
* for the initial turn. `assistantId` identifies the in-progress
|
|
455
|
-
* assistant message so resumed streams (from approveToolCall /
|
|
456
|
-
* declineToolCall) keep mutating the same bubble instead of
|
|
457
|
-
* spawning a new one. `runId` is captured in a ref so the approval
|
|
458
|
-
* handler can later resume the suspended workflow.
|
|
459
|
-
*/
|
|
460
|
-
const processStream = useCallback(
|
|
461
|
-
async (
|
|
462
|
-
threadId: string,
|
|
463
|
-
stream: MastraStreamResponse,
|
|
464
|
-
assistantId: string,
|
|
465
|
-
runIdRef: { current: string | null },
|
|
466
|
-
signal: AbortSignal,
|
|
467
|
-
) => {
|
|
468
|
-
const traceId = readMlflowTraceId(stream);
|
|
469
|
-
if (traceId) {
|
|
470
|
-
updateSession(threadId, (session) =>
|
|
471
|
-
session.feedbackByMessage[assistantId]?.traceId === traceId
|
|
472
|
-
? session
|
|
473
|
-
: {
|
|
474
|
-
...session,
|
|
475
|
-
feedbackByMessage: {
|
|
476
|
-
...session.feedbackByMessage,
|
|
477
|
-
[assistantId]: {
|
|
478
|
-
...session.feedbackByMessage[assistantId],
|
|
479
|
-
traceId,
|
|
480
|
-
},
|
|
481
|
-
},
|
|
482
|
-
},
|
|
483
|
-
);
|
|
484
|
-
}
|
|
485
|
-
const existing = getSession(threadId).messages.find((m) => m.id === assistantId);
|
|
486
|
-
// Text is tracked as ordered segments, one per `text-start` the
|
|
487
|
-
// agent emits. In a multi-step turn the model opens a fresh text
|
|
488
|
-
// block in each step (a short preamble before each tool call),
|
|
489
|
-
// and those blocks read as distinct "updates". Keeping them as
|
|
490
|
-
// separate segments lets the bubble render each as its own block
|
|
491
|
-
// instead of mashing "...summary.This is..." into one paragraph.
|
|
492
|
-
const textSegments: string[] = [];
|
|
493
|
-
let assistantReasoning = "";
|
|
494
|
-
if (existing) {
|
|
495
|
-
for (const part of existing.parts) {
|
|
496
|
-
if (part.type === "text") {
|
|
497
|
-
textSegments.push(part.text);
|
|
498
|
-
} else if (part.type === "reasoning") {
|
|
499
|
-
assistantReasoning += (part as { text?: string }).text ?? "";
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
// Append a text delta to the current (most recent) segment,
|
|
504
|
-
// opening one if none exists yet (defensive: a provider could
|
|
505
|
-
// stream deltas without a leading `text-start`).
|
|
506
|
-
const appendText = (delta: string) => {
|
|
507
|
-
if (textSegments.length === 0) textSegments.push("");
|
|
508
|
-
textSegments[textSegments.length - 1] += delta;
|
|
509
|
-
};
|
|
510
|
-
|
|
511
|
-
const upsertAssistant = () => {
|
|
512
|
-
const prev = getSession(threadId).messages;
|
|
513
|
-
const next = [...prev];
|
|
514
|
-
const idx = next.findIndex((m) => m.id === assistantId);
|
|
515
|
-
const parts: UIMessage["parts"] = [];
|
|
516
|
-
if (assistantReasoning) {
|
|
517
|
-
parts.push({ type: "reasoning", text: assistantReasoning });
|
|
518
|
-
}
|
|
519
|
-
for (const segment of textSegments) {
|
|
520
|
-
if (segment.length > 0) parts.push({ type: "text", text: segment });
|
|
521
|
-
}
|
|
522
|
-
const message: UIMessage = {
|
|
523
|
-
id: assistantId,
|
|
524
|
-
role: "assistant",
|
|
525
|
-
parts: parts.length > 0 ? parts : [{ type: "text", text: "" }],
|
|
526
|
-
};
|
|
527
|
-
if (idx === -1) next.push(message);
|
|
528
|
-
else next[idx] = message;
|
|
529
|
-
writeMessages(threadId, next);
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
const patchToolEvents = (update: (list: ToolEvent[]) => ToolEvent[]) => {
|
|
533
|
-
updateSession(threadId, (session) => ({
|
|
534
|
-
...session,
|
|
535
|
-
toolEventsByMessage: {
|
|
536
|
-
...session.toolEventsByMessage,
|
|
537
|
-
[assistantId]: update(session.toolEventsByMessage[assistantId] ?? []),
|
|
538
|
-
},
|
|
539
|
-
}));
|
|
540
|
-
};
|
|
541
|
-
|
|
542
|
-
let started = false;
|
|
543
|
-
const markStreaming = () => {
|
|
544
|
-
if (started) return;
|
|
545
|
-
started = true;
|
|
546
|
-
updateSession(threadId, (session) =>
|
|
547
|
-
session.status === "streaming" ? session : { ...session, status: "streaming" },
|
|
548
|
-
);
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
try {
|
|
552
|
-
await stream.processDataStream({
|
|
553
|
-
onChunk: async (chunk: { type: string; payload?: any; runId?: string }) => {
|
|
554
|
-
// The user hit Stop: unwind the read loop. Throwing (rather
|
|
555
|
-
// than returning) is what actually stops `processDataStream`
|
|
556
|
-
// from pulling the next chunk; the wrapper below swallows it.
|
|
557
|
-
if (signal.aborted) throw new StreamAborted();
|
|
558
|
-
// Mastra stamps the stream's runId on most chunks. Capturing
|
|
559
|
-
// it the first time we see it (rather than relying on a
|
|
560
|
-
// separate API) keeps approve/decline calls correct even if
|
|
561
|
-
// the client-supplied runId got overridden server-side.
|
|
562
|
-
if (chunk.runId && !runIdRef.current) {
|
|
563
|
-
runIdRef.current = chunk.runId;
|
|
564
|
-
updateSession(threadId, (session) => ({
|
|
565
|
-
...session,
|
|
566
|
-
runId: chunk.runId!,
|
|
567
|
-
}));
|
|
568
|
-
}
|
|
569
|
-
switch (chunk.type) {
|
|
570
|
-
case "text-start":
|
|
571
|
-
// Open a new text segment so each step's preamble stays
|
|
572
|
-
// a separate part (and thus a separate rendered block).
|
|
573
|
-
textSegments.push("");
|
|
574
|
-
break;
|
|
575
|
-
case "text-delta":
|
|
576
|
-
appendText(chunk.payload?.text ?? "");
|
|
577
|
-
upsertAssistant();
|
|
578
|
-
markStreaming();
|
|
579
|
-
break;
|
|
580
|
-
case "text-end":
|
|
581
|
-
// Segment boundary is driven by `text-start`; nothing to
|
|
582
|
-
// do on end - the next start opens the next segment.
|
|
583
|
-
break;
|
|
584
|
-
case "reasoning-delta":
|
|
585
|
-
assistantReasoning += chunk.payload?.text ?? "";
|
|
586
|
-
upsertAssistant();
|
|
587
|
-
markStreaming();
|
|
588
|
-
break;
|
|
589
|
-
case "tool-call": {
|
|
590
|
-
const { toolCallId, toolName } = chunk.payload ?? {};
|
|
591
|
-
if (typeof toolCallId !== "string") break;
|
|
592
|
-
patchToolEvents((list) => [
|
|
593
|
-
...list,
|
|
594
|
-
{ id: toolCallId, toolName, status: "running" },
|
|
595
|
-
]);
|
|
596
|
-
// Make sure the assistant message exists in `messages`
|
|
597
|
-
// even when the model goes straight to a tool call with
|
|
598
|
-
// no preceding text, so the bubble (and its inline
|
|
599
|
-
// tool indicator) renders right away.
|
|
600
|
-
upsertAssistant();
|
|
601
|
-
markStreaming();
|
|
602
|
-
break;
|
|
603
|
-
}
|
|
604
|
-
case "tool-call-approval": {
|
|
605
|
-
// Mastra paused the agent loop on a `requireApproval`
|
|
606
|
-
// tool call. The chunk carries the runId we'll need to
|
|
607
|
-
// resume the suspended workflow later. We surface the
|
|
608
|
-
// approval card via `pendingApprovalsByMessage` so the
|
|
609
|
-
// existing ChatView UI lights up without us having to
|
|
610
|
-
// inject a synthetic data part.
|
|
611
|
-
const { toolCallId, toolName, args } = chunk.payload ?? {};
|
|
612
|
-
const approvalRunId = chunk.runId ?? runIdRef.current;
|
|
613
|
-
if (
|
|
614
|
-
typeof toolCallId !== "string" ||
|
|
615
|
-
typeof toolName !== "string" ||
|
|
616
|
-
!approvalRunId
|
|
617
|
-
) {
|
|
618
|
-
logger.warn("malformed tool-call-approval chunk", {
|
|
619
|
-
toolCallId,
|
|
620
|
-
toolName,
|
|
621
|
-
hasRunId: Boolean(approvalRunId),
|
|
622
|
-
});
|
|
623
|
-
break;
|
|
624
|
-
}
|
|
625
|
-
updateSession(threadId, (session) => {
|
|
626
|
-
const existingApprovals = session.pendingApprovalsByMessage[assistantId] ?? [];
|
|
627
|
-
if (existingApprovals.some((a) => a.toolCallId === toolCallId)) {
|
|
628
|
-
return session;
|
|
629
|
-
}
|
|
630
|
-
return {
|
|
631
|
-
...session,
|
|
632
|
-
pendingApprovalsByMessage: {
|
|
633
|
-
...session.pendingApprovalsByMessage,
|
|
634
|
-
[assistantId]: [
|
|
635
|
-
...existingApprovals,
|
|
636
|
-
{
|
|
637
|
-
toolName,
|
|
638
|
-
toolCallId,
|
|
639
|
-
runId: approvalRunId,
|
|
640
|
-
input: args,
|
|
641
|
-
},
|
|
642
|
-
],
|
|
643
|
-
},
|
|
644
|
-
};
|
|
645
|
-
});
|
|
646
|
-
upsertAssistant();
|
|
647
|
-
markStreaming();
|
|
648
|
-
break;
|
|
649
|
-
}
|
|
650
|
-
case "tool-result": {
|
|
651
|
-
const toolCallId = chunk.payload?.toolCallId;
|
|
652
|
-
if (typeof toolCallId !== "string") break;
|
|
653
|
-
// Charts resolve from `[chart:<id>]` markers in the
|
|
654
|
-
// assistant's prose (the model embeds the id returned
|
|
655
|
-
// by `prepare_chart`), so the tool-result payload is
|
|
656
|
-
// opaque here - we only need it to flip the pill.
|
|
657
|
-
// Genie tools (`ask_genie`, `get_statement`,
|
|
658
|
-
// `prepare_chart`) stream their entire progress
|
|
659
|
-
// surface through `ctx.writer` and arrive on this
|
|
660
|
-
// page via the `tool-output` path. The settled
|
|
661
|
-
// tool-result return value is opaque to the UI -
|
|
662
|
-
// we only need it to flip the pill to `done`.
|
|
663
|
-
patchToolEvents((list) =>
|
|
664
|
-
list.map((e) => (e.id === toolCallId ? { ...e, status: "done" } : e)),
|
|
665
|
-
);
|
|
666
|
-
break;
|
|
667
|
-
}
|
|
668
|
-
case "tool-error": {
|
|
669
|
-
const toolCallId = chunk.payload?.toolCallId;
|
|
670
|
-
if (typeof toolCallId !== "string") break;
|
|
671
|
-
patchToolEvents((list) =>
|
|
672
|
-
list.map((e) => (e.id === toolCallId ? { ...e, status: "error" } : e)),
|
|
673
|
-
);
|
|
674
|
-
break;
|
|
675
|
-
}
|
|
676
|
-
case "tool-output": {
|
|
677
|
-
// Mid-flight progress pushed by a tool via `ctx.writer`
|
|
678
|
-
// (e.g. genie.ts forwarding `status`/`sql`/`data` events
|
|
679
|
-
// from the Genie space). Append to the matching pill so
|
|
680
|
-
// the user sees SQL/row info as soon as Genie publishes
|
|
681
|
-
// it, not only when the LLM call completes.
|
|
682
|
-
const { toolCallId, output } = chunk.payload ?? {};
|
|
683
|
-
if (typeof toolCallId !== "string") break;
|
|
684
|
-
if (!isToolProgress(output)) break;
|
|
685
|
-
patchToolEvents((list) =>
|
|
686
|
-
list.map((e) =>
|
|
687
|
-
e.id === toolCallId ? { ...e, progress: [...(e.progress ?? []), output] } : e,
|
|
688
|
-
),
|
|
689
|
-
);
|
|
690
|
-
break;
|
|
691
|
-
}
|
|
692
|
-
case "error": {
|
|
693
|
-
// Surface a stream-reported error through the same path as
|
|
694
|
-
// a thrown one: throwing here propagates out of
|
|
695
|
-
// `processDataStream` to `driveStream`, which records the
|
|
696
|
-
// message and pins `status` to "error" (a plain
|
|
697
|
-
// setStatus would be clobbered by the clean-close "ready").
|
|
698
|
-
const detail = chunk.payload?.error ?? chunk.payload?.message;
|
|
699
|
-
throw new Error(
|
|
700
|
-
typeof detail === "string" && detail
|
|
701
|
-
? detail
|
|
702
|
-
: "The assistant stream reported an error.",
|
|
703
|
-
);
|
|
704
|
-
}
|
|
705
|
-
default:
|
|
706
|
-
break;
|
|
707
|
-
}
|
|
708
|
-
},
|
|
709
|
-
});
|
|
710
|
-
} catch (error) {
|
|
711
|
-
// A stop (signal aborted) unwinds the loop cleanly - not a
|
|
712
|
-
// failure. Anything else is a real stream error and propagates
|
|
713
|
-
// to the driver's catch.
|
|
714
|
-
if (error instanceof StreamAborted || signal.aborted) return;
|
|
715
|
-
throw error;
|
|
716
|
-
}
|
|
717
|
-
},
|
|
718
|
-
[getSession, updateSession, writeMessages],
|
|
719
|
-
);
|
|
720
|
-
|
|
395
|
+
const processStream = useChatStream({ getSession, updateSession, writeMessages });
|
|
721
396
|
const driveStream = useCallback(
|
|
722
397
|
async (
|
|
723
398
|
threadId: string,
|
|
@@ -853,68 +528,14 @@ export const useMastraChat = (
|
|
|
853
528
|
[activeKey, updateSession],
|
|
854
529
|
);
|
|
855
530
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
async (decision: ApprovalDecision) => {
|
|
865
|
-
const { runId: decisionRunId, toolCallId, toolName } = decision;
|
|
866
|
-
const session = getSession(activeKey);
|
|
867
|
-
const assistantId = session.assistantId;
|
|
868
|
-
const runId = decisionRunId ?? session.runId;
|
|
869
|
-
if (!runId || !assistantId) {
|
|
870
|
-
logger.warn("approval missing runId or assistantId, cannot resume", {
|
|
871
|
-
tool: toolName,
|
|
872
|
-
toolCallId,
|
|
873
|
-
hasRunId: Boolean(runId),
|
|
874
|
-
hasAssistantId: Boolean(assistantId),
|
|
875
|
-
});
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
878
|
-
updateSession(activeKey, (current) => {
|
|
879
|
-
const existing = current.pendingApprovalsByMessage[assistantId];
|
|
880
|
-
if (!existing) return current;
|
|
881
|
-
const next = existing.filter((a) => a.toolCallId !== toolCallId);
|
|
882
|
-
if (next.length === 0) {
|
|
883
|
-
const { [assistantId]: _drop, ...rest } = current.pendingApprovalsByMessage;
|
|
884
|
-
return { ...current, pendingApprovalsByMessage: rest };
|
|
885
|
-
}
|
|
886
|
-
return {
|
|
887
|
-
...current,
|
|
888
|
-
pendingApprovalsByMessage: {
|
|
889
|
-
...current.pendingApprovalsByMessage,
|
|
890
|
-
[assistantId]: next,
|
|
891
|
-
},
|
|
892
|
-
};
|
|
893
|
-
});
|
|
894
|
-
logger.info(decision.approved ? "approved" : "denied", {
|
|
895
|
-
tool: toolName,
|
|
896
|
-
toolCallId,
|
|
897
|
-
runId,
|
|
898
|
-
});
|
|
899
|
-
const streamThreadId = activeKey === DEFAULT_THREAD_SESSION_KEY ? undefined : activeKey;
|
|
900
|
-
await driveStream(activeKey, assistantId, (signal) =>
|
|
901
|
-
decision.approved
|
|
902
|
-
? mastraClient.approveToolCallStream(agentId, {
|
|
903
|
-
runId,
|
|
904
|
-
toolCallId,
|
|
905
|
-
threadId: streamThreadId,
|
|
906
|
-
signal,
|
|
907
|
-
})
|
|
908
|
-
: mastraClient.declineToolCallStream(agentId, {
|
|
909
|
-
runId,
|
|
910
|
-
toolCallId,
|
|
911
|
-
threadId: streamThreadId,
|
|
912
|
-
signal,
|
|
913
|
-
}),
|
|
914
|
-
);
|
|
915
|
-
},
|
|
916
|
-
[activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
|
|
917
|
-
);
|
|
531
|
+
const handleApproval = useChatApprovals({
|
|
532
|
+
activeKey,
|
|
533
|
+
agentId,
|
|
534
|
+
driveStream,
|
|
535
|
+
getSession,
|
|
536
|
+
mastraClient,
|
|
537
|
+
updateSession,
|
|
538
|
+
});
|
|
918
539
|
|
|
919
540
|
// Append a user message to a thread's transcript, stamping `lastUserText`,
|
|
920
541
|
// thread-activity, and a provisional title for a brand-new thread. Returns
|
|
@@ -1025,12 +646,7 @@ export const useMastraChat = (
|
|
|
1025
646
|
error: sharedError.errorMessage(error),
|
|
1026
647
|
});
|
|
1027
648
|
}
|
|
1028
|
-
|
|
1029
|
-
session.abortController?.abort();
|
|
1030
|
-
updateSession(threadId, () => ({
|
|
1031
|
-
...createThreadSession(),
|
|
1032
|
-
historyLoaded: true,
|
|
1033
|
-
}));
|
|
649
|
+
resetSession(threadId, true);
|
|
1034
650
|
if (activeThreadId) {
|
|
1035
651
|
setOptimisticThreads((prev) => {
|
|
1036
652
|
if (!prev[activeThreadId]) return prev;
|
|
@@ -1044,15 +660,7 @@ export const useMastraChat = (
|
|
|
1044
660
|
});
|
|
1045
661
|
}
|
|
1046
662
|
refreshThreadsSoon();
|
|
1047
|
-
}, [
|
|
1048
|
-
mastraClient,
|
|
1049
|
-
agentId,
|
|
1050
|
-
activeKey,
|
|
1051
|
-
activeThreadId,
|
|
1052
|
-
getSession,
|
|
1053
|
-
refreshThreadsSoon,
|
|
1054
|
-
updateSession,
|
|
1055
|
-
]);
|
|
663
|
+
}, [mastraClient, agentId, activeKey, activeThreadId, refreshThreadsSoon, resetSession]);
|
|
1056
664
|
|
|
1057
665
|
const selectThread = useCallback(
|
|
1058
666
|
(threadId: string) => {
|
|
@@ -1064,10 +672,9 @@ export const useMastraChat = (
|
|
|
1064
672
|
|
|
1065
673
|
const newThread = useCallback(() => {
|
|
1066
674
|
const id = hash.id();
|
|
1067
|
-
|
|
1068
|
-
notifySessions();
|
|
675
|
+
resetSession(id, true);
|
|
1069
676
|
setActiveThreadId(id);
|
|
1070
|
-
}, [
|
|
677
|
+
}, [resetSession]);
|
|
1071
678
|
|
|
1072
679
|
const deleteThread = useCallback(
|
|
1073
680
|
async (threadId: string) => {
|
|
@@ -1080,10 +687,7 @@ export const useMastraChat = (
|
|
|
1080
687
|
error: sharedError.errorMessage(error),
|
|
1081
688
|
});
|
|
1082
689
|
}
|
|
1083
|
-
|
|
1084
|
-
session?.abortController?.abort();
|
|
1085
|
-
sessionsRef.current.delete(threadId);
|
|
1086
|
-
notifySessions();
|
|
690
|
+
removeSession(threadId);
|
|
1087
691
|
setOptimisticThreads((prev) => {
|
|
1088
692
|
if (!prev[threadId]) return prev;
|
|
1089
693
|
const { [threadId]: _drop, ...rest } = prev;
|
|
@@ -1096,13 +700,12 @@ export const useMastraChat = (
|
|
|
1096
700
|
});
|
|
1097
701
|
if (threadId === activeThreadId) {
|
|
1098
702
|
const id = hash.id();
|
|
1099
|
-
|
|
1100
|
-
notifySessions();
|
|
703
|
+
resetSession(id, true);
|
|
1101
704
|
setActiveThreadId(id);
|
|
1102
705
|
}
|
|
1103
706
|
refreshThreads();
|
|
1104
707
|
},
|
|
1105
|
-
[
|
|
708
|
+
[activeThreadId, agentId, mastraClient, refreshThreads, removeSession, resetSession],
|
|
1106
709
|
);
|
|
1107
710
|
|
|
1108
711
|
/**
|
|
@@ -1164,107 +767,6 @@ export const useMastraChat = (
|
|
|
1164
767
|
void runStream(threadId, trimmed);
|
|
1165
768
|
}, [activeKey, getSession, runStream, updateSession, writeMessages]);
|
|
1166
769
|
|
|
1167
|
-
// Hydrate the active thread from the server when it has no local
|
|
1168
|
-
// session yet. In-flight streams keep updating their session in the
|
|
1169
|
-
// background, so switching back shows live partial text without
|
|
1170
|
-
// refetching or aborting other threads' runs.
|
|
1171
|
-
useEffect(() => {
|
|
1172
|
-
const threadId = activeKey;
|
|
1173
|
-
const session = getSession(threadId);
|
|
1174
|
-
if (session.historyLoaded) {
|
|
1175
|
-
setIsLoadingHistory(false);
|
|
1176
|
-
return;
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
|
-
let cancelled = false;
|
|
1180
|
-
const controller = new AbortController();
|
|
1181
|
-
historyInFlightRef.current.add(threadId);
|
|
1182
|
-
setIsLoadingHistory(true);
|
|
1183
|
-
mastraClient
|
|
1184
|
-
.history({
|
|
1185
|
-
agentId,
|
|
1186
|
-
threadId: activeThreadId,
|
|
1187
|
-
page: 0,
|
|
1188
|
-
perPage: HISTORY_PAGE_SIZE,
|
|
1189
|
-
signal: controller.signal,
|
|
1190
|
-
})
|
|
1191
|
-
.then((response) => {
|
|
1192
|
-
if (cancelled) return;
|
|
1193
|
-
updateSession(threadId, (current) => ({
|
|
1194
|
-
...current,
|
|
1195
|
-
messages: response.uiMessages as unknown as UIMessage[],
|
|
1196
|
-
historyLoaded: true,
|
|
1197
|
-
hasMoreHistory: response.hasMore,
|
|
1198
|
-
historyPage: 1,
|
|
1199
|
-
toolEventsByMessage: {},
|
|
1200
|
-
pendingApprovalsByMessage: {},
|
|
1201
|
-
feedbackByMessage: {},
|
|
1202
|
-
}));
|
|
1203
|
-
})
|
|
1204
|
-
.catch((error: unknown) => {
|
|
1205
|
-
if (cancelled || (error as { name?: string }).name === "AbortError") return;
|
|
1206
|
-
logger.error("history load error", {
|
|
1207
|
-
error: sharedError.errorMessage(error),
|
|
1208
|
-
});
|
|
1209
|
-
updateSession(threadId, (current) => ({
|
|
1210
|
-
...current,
|
|
1211
|
-
historyLoaded: true,
|
|
1212
|
-
hasMoreHistory: false,
|
|
1213
|
-
}));
|
|
1214
|
-
})
|
|
1215
|
-
.finally(() => {
|
|
1216
|
-
historyInFlightRef.current.delete(threadId);
|
|
1217
|
-
if (!cancelled) setIsLoadingHistory(false);
|
|
1218
|
-
});
|
|
1219
|
-
return () => {
|
|
1220
|
-
cancelled = true;
|
|
1221
|
-
controller.abort();
|
|
1222
|
-
};
|
|
1223
|
-
}, [mastraClient, agentId, activeThreadId, activeKey, getSession, updateSession]);
|
|
1224
|
-
|
|
1225
|
-
const loadOlderHistory = useCallback(() => {
|
|
1226
|
-
const threadId = activeKey;
|
|
1227
|
-
const session = getSession(threadId);
|
|
1228
|
-
if (historyInFlightRef.current.has(threadId) || !session.hasMoreHistory) return;
|
|
1229
|
-
historyInFlightRef.current.add(threadId);
|
|
1230
|
-
setLoadingMoreThreads((current) => new Set(current).add(threadId));
|
|
1231
|
-
const page = session.historyPage;
|
|
1232
|
-
updateSession(threadId, (current) => ({ ...current, historyPage: page + 1 }));
|
|
1233
|
-
mastraClient
|
|
1234
|
-
.history({ agentId, threadId: activeThreadId, page, perPage: HISTORY_PAGE_SIZE })
|
|
1235
|
-
.then((response) => {
|
|
1236
|
-
const uiMessages = response.uiMessages as unknown as UIMessage[];
|
|
1237
|
-
if (uiMessages.length > 0) {
|
|
1238
|
-
const currentMessages = getSession(threadId).messages;
|
|
1239
|
-
writeMessages(threadId, [...uiMessages, ...currentMessages]);
|
|
1240
|
-
}
|
|
1241
|
-
updateSession(threadId, (current) => ({
|
|
1242
|
-
...current,
|
|
1243
|
-
hasMoreHistory: response.hasMore,
|
|
1244
|
-
}));
|
|
1245
|
-
})
|
|
1246
|
-
.catch((error: unknown) => {
|
|
1247
|
-
logger.error("history load-more error", {
|
|
1248
|
-
page,
|
|
1249
|
-
error: sharedError.errorMessage(error),
|
|
1250
|
-
});
|
|
1251
|
-
updateSession(threadId, (current) => ({
|
|
1252
|
-
...current,
|
|
1253
|
-
historyPage: page,
|
|
1254
|
-
hasMoreHistory: false,
|
|
1255
|
-
}));
|
|
1256
|
-
})
|
|
1257
|
-
.finally(() => {
|
|
1258
|
-
historyInFlightRef.current.delete(threadId);
|
|
1259
|
-
setLoadingMoreThreads((current) => {
|
|
1260
|
-
if (!current.has(threadId)) return current;
|
|
1261
|
-
const next = new Set(current);
|
|
1262
|
-
next.delete(threadId);
|
|
1263
|
-
return next;
|
|
1264
|
-
});
|
|
1265
|
-
});
|
|
1266
|
-
}, [activeKey, activeThreadId, getSession, mastraClient, agentId, updateSession, writeMessages]);
|
|
1267
|
-
|
|
1268
770
|
// Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
|
|
1269
771
|
// straight off the client so the export inlines the same charts /
|
|
1270
772
|
// tables the UI renders. Handlers are defined unconditionally (rules of
|
|
@@ -1345,47 +847,6 @@ export const useMastraChat = (
|
|
|
1345
847
|
[exportResolver, exportUserLabel, exportBrand],
|
|
1346
848
|
);
|
|
1347
849
|
|
|
1348
|
-
// Submit thumbs / comment feedback for an assistant message to MLflow
|
|
1349
|
-
// via the plugin's feedback route. The message's captured trace id
|
|
1350
|
-
// scopes the assessment; without one there's nothing to attach to, so
|
|
1351
|
-
// the call is skipped. A thumbs value is reflected optimistically so
|
|
1352
|
-
// the active button highlights immediately; a soft "not recorded"
|
|
1353
|
-
// (e.g. the trace is still exporting) is logged, not surfaced as an
|
|
1354
|
-
// error, to keep the chat calm.
|
|
1355
|
-
const submitFeedback = useCallback(
|
|
1356
|
-
async (message: UIMessage, submission: FeedbackSubmission) => {
|
|
1357
|
-
const traceId = feedbackByMessageRef.current[message.id]?.traceId;
|
|
1358
|
-
if (!traceId) return;
|
|
1359
|
-
if (submission.value) {
|
|
1360
|
-
updateSession(activeKey, (session) => ({
|
|
1361
|
-
...session,
|
|
1362
|
-
feedbackByMessage: {
|
|
1363
|
-
...session.feedbackByMessage,
|
|
1364
|
-
[message.id]: { traceId, value: submission.value },
|
|
1365
|
-
},
|
|
1366
|
-
}));
|
|
1367
|
-
}
|
|
1368
|
-
try {
|
|
1369
|
-
const result = await mastraClient.feedback({
|
|
1370
|
-
traceId,
|
|
1371
|
-
...(submission.value !== undefined ? { value: submission.value === "up" } : {}),
|
|
1372
|
-
...(submission.comment ? { comment: submission.comment } : {}),
|
|
1373
|
-
});
|
|
1374
|
-
if (!result.ok) {
|
|
1375
|
-
logger.warn("feedback not recorded (trace may still be exporting)", {
|
|
1376
|
-
traceId,
|
|
1377
|
-
});
|
|
1378
|
-
}
|
|
1379
|
-
} catch (error) {
|
|
1380
|
-
logger.error("feedback error", {
|
|
1381
|
-
traceId,
|
|
1382
|
-
error: sharedError.errorMessage(error),
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
},
|
|
1386
|
-
[activeKey, mastraClient, updateSession],
|
|
1387
|
-
);
|
|
1388
|
-
|
|
1389
850
|
// Merge optimistic rows over the server list, newest first, dropping any
|
|
1390
851
|
// optimistic entry the server already returns so a thread is never listed
|
|
1391
852
|
// twice.
|