@dbx-tools/ui-mastra 0.1.9
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 +198 -0
- package/index.ts +32 -0
- package/package.json +62 -0
- package/src/react/bubbles.tsx +428 -0
- package/src/react/chat-view.tsx +574 -0
- package/src/react/data-grid.tsx +342 -0
- package/src/react/embed-slots.tsx +258 -0
- package/src/react/export-menu.tsx +71 -0
- package/src/react/feedback-controls.tsx +139 -0
- package/src/react/index.ts +42 -0
- package/src/react/markdown.tsx +387 -0
- package/src/react/mastra-chat.tsx +1376 -0
- package/src/react/suggestion-pills.tsx +46 -0
- package/src/react/suggestions.ts +109 -0
- package/src/react/thread-sidebar.tsx +315 -0
- package/src/react/tool-pill.tsx +684 -0
- package/src/react/types.ts +295 -0
- package/src/styles.css +22 -0
- package/src/support/chart-option.ts +130 -0
- package/src/support/export.ts +485 -0
- package/src/support/mastra-client.ts +883 -0
- package/src/support/mastra-stream.ts +66 -0
- package/src/support/shiki-plugin.ts +134 -0
- package/src/support/thread-sessions.ts +55 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,1376 @@
|
|
|
1
|
+
import {
|
|
2
|
+
feedback,
|
|
3
|
+
type MastraThread,
|
|
4
|
+
} from "@dbx-tools/shared-mastra";
|
|
5
|
+
import { error as errorUtil, log } from "@dbx-tools/shared-core";
|
|
6
|
+
import type { UIMessage } from "ai";
|
|
7
|
+
import { nanoid } from "nanoid";
|
|
8
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { exportChat, type EmbedResolver, type ExportFormat } from "../support/export";
|
|
10
|
+
import {
|
|
11
|
+
useMastraClient,
|
|
12
|
+
useMastraModels,
|
|
13
|
+
useMastraSuggestions,
|
|
14
|
+
useMastraThreads,
|
|
15
|
+
} from "../support/mastra-client";
|
|
16
|
+
import type { MastraStreamResponse } from "../support/mastra-stream";
|
|
17
|
+
import {
|
|
18
|
+
createThreadSession,
|
|
19
|
+
DEFAULT_THREAD_SESSION_KEY,
|
|
20
|
+
isSessionRunning,
|
|
21
|
+
sessionKey,
|
|
22
|
+
type ThreadSession,
|
|
23
|
+
} from "../support/thread-sessions";
|
|
24
|
+
import { ChatView } from "./chat-view";
|
|
25
|
+
import { dedupeSuggestions } from "./suggestions";
|
|
26
|
+
import type {
|
|
27
|
+
ApprovalDecision,
|
|
28
|
+
ChatViewProps,
|
|
29
|
+
FeedbackSubmission,
|
|
30
|
+
MessageFeedback,
|
|
31
|
+
ThreadSummary,
|
|
32
|
+
ToolEvent,
|
|
33
|
+
ToolProgress,
|
|
34
|
+
} from "./types";
|
|
35
|
+
|
|
36
|
+
// Self-contained drop-in chat. `useMastraChat` drives the conversation
|
|
37
|
+
// over `@mastra/client-js`: `agent.stream()` returns a Response
|
|
38
|
+
// augmented with `processDataStream()`, which pushes typed Mastra
|
|
39
|
+
// chunks (text-delta, reasoning-delta, tool-*, ...) that we translate
|
|
40
|
+
// into `UIMessage` parts for `ChatView` to render.
|
|
41
|
+
//
|
|
42
|
+
// Approval gates ride the same channel: a paused `requireApproval: true`
|
|
43
|
+
// tool call emits a `tool-call-approval` chunk carrying
|
|
44
|
+
// `{ runId, payload: { toolCallId, toolName, args } }`. We surface that
|
|
45
|
+
// as an out-of-band entry in `pendingApprovalsByMessage` and wire
|
|
46
|
+
// `onResolveToolApproval` to {@link MastraPluginClient.approveToolCallStream}
|
|
47
|
+
// / `declineToolCallStream`, which read SSE directly and avoid a stock
|
|
48
|
+
// `@mastra/client-js` bug on resumed approval streams.
|
|
49
|
+
// both of which return a fresh stream Response we run through the same
|
|
50
|
+
// chunk handler.
|
|
51
|
+
//
|
|
52
|
+
// On mount the transcript hydrates with the most recent page of thread
|
|
53
|
+
// history from the Mastra plugin's `/history` endpoint; scrolling near
|
|
54
|
+
// the top lazy-loads and prepends the next older page, with `ChatView`
|
|
55
|
+
// preserving the visual scroll position across the prepend.
|
|
56
|
+
|
|
57
|
+
const logger = log.logger("ui-mastra/chat");
|
|
58
|
+
|
|
59
|
+
const HISTORY_PAGE_SIZE = 20;
|
|
60
|
+
|
|
61
|
+
const makeUserMessage = (text: string): UIMessage => ({
|
|
62
|
+
id: nanoid(),
|
|
63
|
+
role: "user",
|
|
64
|
+
parts: [{ type: "text", text }],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** Project a wire {@link MastraThread} down to the sidebar's view. */
|
|
68
|
+
const toThreadSummary = (thread: MastraThread): ThreadSummary => ({
|
|
69
|
+
id: thread.id,
|
|
70
|
+
...(thread.title ? { title: thread.title } : {}),
|
|
71
|
+
updatedAt: thread.updatedAt,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** Max characters of the first user message used as a provisional thread title. */
|
|
75
|
+
const TITLE_PREVIEW_MAX = 60;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Derive a provisional sidebar title from a user's first message:
|
|
79
|
+
* whitespace-collapsed and truncated with an ellipsis. Shown the instant
|
|
80
|
+
* a brand-new conversation gets its first question so the row stops
|
|
81
|
+
* reading "New conversation", until the server's auto-generated title
|
|
82
|
+
* lands and supersedes it.
|
|
83
|
+
*/
|
|
84
|
+
const deriveThreadTitle = (text: string): string => {
|
|
85
|
+
const clean = text.trim().replace(/\s+/g, " ");
|
|
86
|
+
return clean.length > TITLE_PREVIEW_MAX
|
|
87
|
+
? `${clean.slice(0, TITLE_PREVIEW_MAX - 1)}…`
|
|
88
|
+
: clean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `localStorage` key the active thread id is persisted under, namespaced
|
|
93
|
+
* by plugin mount + agent so two agents (or two apps on one origin)
|
|
94
|
+
* don't clobber each other's "current conversation".
|
|
95
|
+
*/
|
|
96
|
+
const threadStorageKey = (basePath: string, agentId: string): string =>
|
|
97
|
+
`dbx-mastra-thread:${basePath}:${agentId}`;
|
|
98
|
+
|
|
99
|
+
/** Read the persisted active thread id, tolerating storage being unavailable. */
|
|
100
|
+
const readStoredThreadId = (key: string): string | undefined => {
|
|
101
|
+
try {
|
|
102
|
+
return window.localStorage.getItem(key) ?? undefined;
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Persist the active thread id, silently ignoring storage failures. */
|
|
109
|
+
const storeStoredThreadId = (key: string, id: string): void => {
|
|
110
|
+
try {
|
|
111
|
+
window.localStorage.setItem(key, id);
|
|
112
|
+
} catch {
|
|
113
|
+
// Private-mode / disabled storage: persistence is best-effort, the
|
|
114
|
+
// in-memory selection still works for the session.
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `localStorage` key the conversation sidebar's open/closed state is
|
|
120
|
+
* persisted under, namespaced by plugin mount + agent so the user's
|
|
121
|
+
* show/hide choice survives reloads without clobbering other mounts.
|
|
122
|
+
*/
|
|
123
|
+
const sidebarStorageKey = (basePath: string, agentId: string): string =>
|
|
124
|
+
`dbx-mastra-sidebar:${basePath}:${agentId}`;
|
|
125
|
+
|
|
126
|
+
/** Read the persisted sidebar open flag, falling back when unset / unavailable. */
|
|
127
|
+
const readStoredSidebarOpen = (key: string, fallback: boolean): boolean => {
|
|
128
|
+
try {
|
|
129
|
+
const value = window.localStorage.getItem(key);
|
|
130
|
+
return value === null ? fallback : value === "1";
|
|
131
|
+
} catch {
|
|
132
|
+
return fallback;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Persist the sidebar open flag, silently ignoring storage failures. */
|
|
137
|
+
const storeStoredSidebarOpen = (key: string, open: boolean): void => {
|
|
138
|
+
try {
|
|
139
|
+
window.localStorage.setItem(key, open ? "1" : "0");
|
|
140
|
+
} catch {
|
|
141
|
+
// Best-effort; the in-session toggle still works without persistence.
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Pull the MLflow trace id (`tr-<hex>`) the server stamped on a stream
|
|
147
|
+
* response, if present. `@mastra/client-js`'s `agent.stream()` returns
|
|
148
|
+
* a Response-shaped object, so the header is read defensively (the
|
|
149
|
+
* shape isn't guaranteed across client versions). Returns `undefined`
|
|
150
|
+
* when absent - which is the "no feedback for this turn" signal.
|
|
151
|
+
*/
|
|
152
|
+
const readMlflowTraceId = (stream: unknown): string | undefined => {
|
|
153
|
+
const headers = (stream as { headers?: { get?: (name: string) => string | null } })
|
|
154
|
+
?.headers;
|
|
155
|
+
const raw = headers?.get?.(feedback.MLFLOW_TRACE_ID_HEADER);
|
|
156
|
+
return raw?.trim() || undefined;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// `tool-output` chunks carry arbitrary tool-defined payloads; only the
|
|
160
|
+
// `{type: ...}` shape we know how to render in `ToolSessionPill` is
|
|
161
|
+
// surfaced. Anything else (other tools, raw data, etc.) is ignored.
|
|
162
|
+
const isToolProgress = (value: unknown): value is ToolProgress =>
|
|
163
|
+
typeof value === "object" &&
|
|
164
|
+
value !== null &&
|
|
165
|
+
typeof (value as { type?: unknown }).type === "string";
|
|
166
|
+
|
|
167
|
+
/** Options for {@link useMastraChat}. */
|
|
168
|
+
export interface UseMastraChatOptions {
|
|
169
|
+
/**
|
|
170
|
+
* Agent to converse with. Defaults to the Mastra plugin's registered
|
|
171
|
+
* default agent (`clientConfig().defaultAgent`).
|
|
172
|
+
*/
|
|
173
|
+
agentId?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Surface the built-in model picker in the header, letting the user
|
|
176
|
+
* override the serving endpoint per turn (via `X-Mastra-Model`).
|
|
177
|
+
* Off by default so the drop-in renders a clean single-model chat;
|
|
178
|
+
* when `false` the model catalogue isn't even fetched.
|
|
179
|
+
*/
|
|
180
|
+
showModelPicker?: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* Starter questions shown as one-tap buttons on the empty state.
|
|
183
|
+
* When omitted, the drop-in auto-sources them from the agent's
|
|
184
|
+
* Genie space sample questions (via the plugin's `/suggestions`
|
|
185
|
+
* endpoint); when the agent has no Genie space the empty state
|
|
186
|
+
* stays bare. Pass an explicit list to override that lookup, or
|
|
187
|
+
* `[]` to force no suggestions.
|
|
188
|
+
*/
|
|
189
|
+
suggestions?: string[];
|
|
190
|
+
/**
|
|
191
|
+
* Enable built-in conversation (thread) management: the chat tracks a
|
|
192
|
+
* client-selected thread id, persists it across reloads, lists the
|
|
193
|
+
* resource's conversations, and renders a sidebar to switch between
|
|
194
|
+
* them / start new ones / delete them. On by default. Set `false` for
|
|
195
|
+
* the classic single-thread chat anchored to the per-session cookie
|
|
196
|
+
* (no sidebar, no thread tracking).
|
|
197
|
+
*/
|
|
198
|
+
enableThreads?: boolean;
|
|
199
|
+
/**
|
|
200
|
+
* Enable chat export. Off by default (opt-in). When on, the header
|
|
201
|
+
* shows an "Export" menu for the whole conversation and each assistant
|
|
202
|
+
* bubble shows a per-message export menu. Both offer PDF (via the
|
|
203
|
+
* browser print dialog) and Markdown; charts and data tables are
|
|
204
|
+
* inlined into the export so it renders reliably offline.
|
|
205
|
+
*/
|
|
206
|
+
enableExport?: boolean;
|
|
207
|
+
/**
|
|
208
|
+
* Surface per-message feedback controls (thumbs up/down + a comment
|
|
209
|
+
* popover) that log to MLflow as trace assessments.
|
|
210
|
+
*
|
|
211
|
+
* Defaults to whatever {@link enableExport} is (feedback and export
|
|
212
|
+
* are the two "quality loop" affordances, so turning on export opts
|
|
213
|
+
* into feedback too); pass an explicit `true` / `false` to override.
|
|
214
|
+
* Regardless of this flag, controls only actually render when the
|
|
215
|
+
* server reports MLflow logging is enabled (`clientConfig.feedbackEnabled`)
|
|
216
|
+
* and the turn produced a trace id - so enabling it on a deployment
|
|
217
|
+
* without MLflow tracing is a safe no-op.
|
|
218
|
+
*/
|
|
219
|
+
enableFeedback?: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Thrown out of the chunk handler to unwind `processDataStream` when
|
|
224
|
+
* the user stops a turn. Callers treat it as a clean stop, not an
|
|
225
|
+
* error, so the composer just returns to idle.
|
|
226
|
+
*/
|
|
227
|
+
class StreamAborted extends Error {}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Headless driver for the Mastra chat experience. Owns the full
|
|
231
|
+
* conversation lifecycle (streaming, tool-event tracking, approvals,
|
|
232
|
+
* model selection, clear, and infinite-scroll-up history) over
|
|
233
|
+
* `@mastra/client-js` and returns the exact prop bag {@link ChatView}
|
|
234
|
+
* consumes. Use this when you want the drop-in behaviour but need to
|
|
235
|
+
* render the view yourself; otherwise reach for {@link MastraChat}.
|
|
236
|
+
*/
|
|
237
|
+
export const useMastraChat = (
|
|
238
|
+
options: UseMastraChatOptions = {},
|
|
239
|
+
): Omit<ChatViewProps, "className"> => {
|
|
240
|
+
const [model, setModel] = useState("");
|
|
241
|
+
// One client drives both the agent stream and the plugin's custom
|
|
242
|
+
// routes (history / threads / models / suggestions / feedback /
|
|
243
|
+
// embeds). Its identity is
|
|
244
|
+
// stable across renders (memoized on `basePath` / `defaultAgent`) so
|
|
245
|
+
// using it as a hook dep doesn't refire the initial-history fetch on
|
|
246
|
+
// every parent render.
|
|
247
|
+
const mastraClient = useMastraClient();
|
|
248
|
+
// Apply the selected model as a per-request override header in place,
|
|
249
|
+
// so the next `agent.stream()` picks it up without rebuilding the
|
|
250
|
+
// client (which would refire history loads on every model change).
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
mastraClient.setModelOverride(model || undefined);
|
|
253
|
+
}, [mastraClient, model]);
|
|
254
|
+
const agentId = options.agentId ?? mastraClient.defaultAgent;
|
|
255
|
+
// Built-in conversation management. When on, the chat always drives an
|
|
256
|
+
// explicit client thread id (rather than leaning on the per-session
|
|
257
|
+
// cookie) so it can reference, persist, and switch between the
|
|
258
|
+
// conversations a user owns. Selecting a thread routes every call
|
|
259
|
+
// (stream + history + clear) at it via `mastraClient.setThreadId`.
|
|
260
|
+
const enableThreads = options.enableThreads !== false;
|
|
261
|
+
// Export is opt-in (default off): the host turns it on explicitly.
|
|
262
|
+
const enableExport = options.enableExport === true;
|
|
263
|
+
// Feedback defaults to export's setting; an explicit option overrides.
|
|
264
|
+
// It only actually surfaces when the server reports MLflow logging is
|
|
265
|
+
// wired (so a trace exists to attach the assessment to).
|
|
266
|
+
const enableFeedback = options.enableFeedback ?? enableExport;
|
|
267
|
+
const feedbackAvailable = enableFeedback && mastraClient.feedbackEnabled;
|
|
268
|
+
const threadKey = threadStorageKey(mastraClient.basePath, agentId);
|
|
269
|
+
const [activeThreadId, setActiveThreadId] = useState<string | undefined>(() =>
|
|
270
|
+
enableThreads ? (readStoredThreadId(threadKey) ?? nanoid()) : undefined,
|
|
271
|
+
);
|
|
272
|
+
const {
|
|
273
|
+
threads,
|
|
274
|
+
loading: isLoadingThreads,
|
|
275
|
+
refresh: refreshThreads,
|
|
276
|
+
} = useMastraThreads(options.agentId, enableThreads);
|
|
277
|
+
// Persist the active thread id so a reload reopens the same
|
|
278
|
+
// conversation. Best-effort; storage may be unavailable.
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
if (enableThreads && activeThreadId) storeStoredThreadId(threadKey, activeThreadId);
|
|
281
|
+
}, [enableThreads, threadKey, activeThreadId]);
|
|
282
|
+
// Conversation sidebar show/hide, persisted so the user's choice
|
|
283
|
+
// sticks across reloads. The view exposes a header toggle wired to
|
|
284
|
+
// `onToggleSidebar`; defaults open the first time.
|
|
285
|
+
const sidebarKey = sidebarStorageKey(mastraClient.basePath, agentId);
|
|
286
|
+
const [sidebarOpen, setSidebarOpen] = useState<boolean>(() =>
|
|
287
|
+
enableThreads ? readStoredSidebarOpen(sidebarKey, true) : true,
|
|
288
|
+
);
|
|
289
|
+
const toggleSidebar = useCallback(() => {
|
|
290
|
+
setSidebarOpen((prev) => {
|
|
291
|
+
const next = !prev;
|
|
292
|
+
storeStoredSidebarOpen(sidebarKey, next);
|
|
293
|
+
return next;
|
|
294
|
+
});
|
|
295
|
+
}, [sidebarKey]);
|
|
296
|
+
// Refresh the conversation list after a turn (a brand-new thread
|
|
297
|
+
// appears, or its auto-generated title lands), a clear, or a delete.
|
|
298
|
+
// Titles are generated server-side after the turn settles, so a short
|
|
299
|
+
// delayed second pass picks them up.
|
|
300
|
+
const refreshThreadsSoon = useCallback(() => {
|
|
301
|
+
if (!enableThreads) return;
|
|
302
|
+
refreshThreads();
|
|
303
|
+
window.setTimeout(refreshThreads, 2000);
|
|
304
|
+
}, [enableThreads, refreshThreads]);
|
|
305
|
+
// Optimistic sidebar rows for conversations the user just started but
|
|
306
|
+
// that the server list hasn't returned yet. A new thread's id is
|
|
307
|
+
// client-minted, and the server only materializes the thread row once
|
|
308
|
+
// the first turn lands (with its auto-title arriving a beat later), so
|
|
309
|
+
// without this the sidebar wouldn't show a brand-new conversation
|
|
310
|
+
// until the delayed post-turn refresh. Keyed by thread id; each entry
|
|
311
|
+
// is pruned once the real server row supersedes it.
|
|
312
|
+
const [optimisticThreads, setOptimisticThreads] = useState<
|
|
313
|
+
Record<string, ThreadSummary>
|
|
314
|
+
>({});
|
|
315
|
+
// Optimistic title overrides for threads the user just renamed, keyed
|
|
316
|
+
// by thread id. Applied over the server rows in `sidebarThreads` so the
|
|
317
|
+
// new name shows instantly; each entry is dropped once the server list
|
|
318
|
+
// reports the matching title (or the rename request fails).
|
|
319
|
+
const [renamedThreads, setRenamedThreads] = useState<Record<string, string>>({});
|
|
320
|
+
// Provisional titles derived from a thread's first user message, keyed
|
|
321
|
+
// by thread id. Shown the instant a brand-new conversation gets its
|
|
322
|
+
// first question so the row stops reading "New conversation"; dropped
|
|
323
|
+
// as soon as the server's auto-generated title lands (see the prune
|
|
324
|
+
// effect below). A manual rename (`renamedThreads`) still wins.
|
|
325
|
+
const [provisionalTitles, setProvisionalTitles] = useState<Record<string, string>>(
|
|
326
|
+
{},
|
|
327
|
+
);
|
|
328
|
+
// Surface the active thread in the sidebar immediately (called when its
|
|
329
|
+
// first message is sent). Upserts an untitled row stamped "now" so it
|
|
330
|
+
// sorts to the top; the server row replaces it on the next refresh.
|
|
331
|
+
const noteThreadActivity = useCallback(
|
|
332
|
+
(threadId: string) => {
|
|
333
|
+
if (!enableThreads) return;
|
|
334
|
+
setOptimisticThreads((prev) => ({
|
|
335
|
+
...prev,
|
|
336
|
+
[threadId]: {
|
|
337
|
+
...prev[threadId],
|
|
338
|
+
id: threadId,
|
|
339
|
+
updatedAt: new Date().toISOString(),
|
|
340
|
+
},
|
|
341
|
+
}));
|
|
342
|
+
},
|
|
343
|
+
[enableThreads],
|
|
344
|
+
);
|
|
345
|
+
// Picker is opt-in: an omitted (or falsy) `showModelPicker` keeps it
|
|
346
|
+
// hidden and skips the catalogue fetch entirely.
|
|
347
|
+
const showModelPicker = Boolean(options.showModelPicker);
|
|
348
|
+
const { models } = useMastraModels(showModelPicker);
|
|
349
|
+
// Starter suggestions: an explicit `options.suggestions` always
|
|
350
|
+
// wins (including `[]` to force none) and is rendered verbatim;
|
|
351
|
+
// otherwise auto-source the agent's Genie space sample questions.
|
|
352
|
+
// The fetch is skipped when the caller passed an explicit list so we
|
|
353
|
+
// never round-trip for a value we won't use. Genie-sourced questions
|
|
354
|
+
// run through the same dedupe + cap as in-conversation follow-ups so
|
|
355
|
+
// initial and follow-up suggestions behave identically.
|
|
356
|
+
const explicitSuggestions = options.suggestions;
|
|
357
|
+
const { questions: genieSuggestions } = useMastraSuggestions(
|
|
358
|
+
options.agentId,
|
|
359
|
+
explicitSuggestions === undefined,
|
|
360
|
+
);
|
|
361
|
+
const suggestions = useMemo(
|
|
362
|
+
() => explicitSuggestions ?? dedupeSuggestions(genieSuggestions),
|
|
363
|
+
[explicitSuggestions, genieSuggestions],
|
|
364
|
+
);
|
|
365
|
+
const [sessionsTick, setSessionsTick] = useState(0);
|
|
366
|
+
const sessionsRef = useRef<Map<string, ThreadSession>>(new Map());
|
|
367
|
+
const notifySessions = useCallback(() => {
|
|
368
|
+
setSessionsTick((tick) => tick + 1);
|
|
369
|
+
}, []);
|
|
370
|
+
const getSession = useCallback((threadId: string): ThreadSession => {
|
|
371
|
+
let session = sessionsRef.current.get(threadId);
|
|
372
|
+
if (!session) {
|
|
373
|
+
session = createThreadSession();
|
|
374
|
+
sessionsRef.current.set(threadId, session);
|
|
375
|
+
}
|
|
376
|
+
return session;
|
|
377
|
+
}, []);
|
|
378
|
+
const updateSession = useCallback(
|
|
379
|
+
(threadId: string, updater: (session: ThreadSession) => ThreadSession) => {
|
|
380
|
+
const next = updater(getSession(threadId));
|
|
381
|
+
sessionsRef.current.set(threadId, next);
|
|
382
|
+
notifySessions();
|
|
383
|
+
},
|
|
384
|
+
[getSession, notifySessions],
|
|
385
|
+
);
|
|
386
|
+
const activeKey = sessionKey(activeThreadId);
|
|
387
|
+
const activeSession = useMemo(
|
|
388
|
+
() => getSession(activeKey),
|
|
389
|
+
[activeKey, getSession, sessionsTick],
|
|
390
|
+
);
|
|
391
|
+
const streamingThreadIds = useMemo(() => {
|
|
392
|
+
const ids: string[] = [];
|
|
393
|
+
for (const [id, session] of sessionsRef.current.entries()) {
|
|
394
|
+
if (id === DEFAULT_THREAD_SESSION_KEY) continue;
|
|
395
|
+
if (isSessionRunning(session)) ids.push(id);
|
|
396
|
+
}
|
|
397
|
+
return ids;
|
|
398
|
+
}, [sessionsTick]);
|
|
399
|
+
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
|
400
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
401
|
+
const historyInFlightRef = useRef(false);
|
|
402
|
+
const feedbackByMessageRef = useRef<Record<string, MessageFeedback>>({});
|
|
403
|
+
feedbackByMessageRef.current = activeSession.feedbackByMessage;
|
|
404
|
+
|
|
405
|
+
const writeMessages = useCallback(
|
|
406
|
+
(threadId: string, next: UIMessage[]) => {
|
|
407
|
+
updateSession(threadId, (session) => ({ ...session, messages: next }));
|
|
408
|
+
},
|
|
409
|
+
[updateSession],
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Pipe a Mastra stream Response through the same chunk handler used
|
|
414
|
+
* for the initial turn. `assistantId` identifies the in-progress
|
|
415
|
+
* assistant message so resumed streams (from approveToolCall /
|
|
416
|
+
* declineToolCall) keep mutating the same bubble instead of
|
|
417
|
+
* spawning a new one. `runId` is captured in a ref so the approval
|
|
418
|
+
* handler can later resume the suspended workflow.
|
|
419
|
+
*/
|
|
420
|
+
const processStream = useCallback(
|
|
421
|
+
async (
|
|
422
|
+
threadId: string,
|
|
423
|
+
stream: MastraStreamResponse,
|
|
424
|
+
assistantId: string,
|
|
425
|
+
runIdRef: { current: string | null },
|
|
426
|
+
signal: AbortSignal,
|
|
427
|
+
) => {
|
|
428
|
+
const traceId = readMlflowTraceId(stream);
|
|
429
|
+
if (traceId) {
|
|
430
|
+
updateSession(threadId, (session) =>
|
|
431
|
+
session.feedbackByMessage[assistantId]?.traceId === traceId
|
|
432
|
+
? session
|
|
433
|
+
: {
|
|
434
|
+
...session,
|
|
435
|
+
feedbackByMessage: {
|
|
436
|
+
...session.feedbackByMessage,
|
|
437
|
+
[assistantId]: {
|
|
438
|
+
...session.feedbackByMessage[assistantId],
|
|
439
|
+
traceId,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
const existing = getSession(threadId).messages.find((m) => m.id === assistantId);
|
|
446
|
+
// Text is tracked as ordered segments, one per `text-start` the
|
|
447
|
+
// agent emits. In a multi-step turn the model opens a fresh text
|
|
448
|
+
// block in each step (a short preamble before each tool call),
|
|
449
|
+
// and those blocks read as distinct "updates". Keeping them as
|
|
450
|
+
// separate segments lets the bubble render each as its own block
|
|
451
|
+
// instead of mashing "...summary.This is..." into one paragraph.
|
|
452
|
+
const textSegments: string[] = [];
|
|
453
|
+
let assistantReasoning = "";
|
|
454
|
+
if (existing) {
|
|
455
|
+
for (const part of existing.parts) {
|
|
456
|
+
if (part.type === "text") textSegments.push(part.text);
|
|
457
|
+
else if (part.type === "reasoning") {
|
|
458
|
+
assistantReasoning += (part as { text?: string }).text ?? "";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// Append a text delta to the current (most recent) segment,
|
|
463
|
+
// opening one if none exists yet (defensive: a provider could
|
|
464
|
+
// stream deltas without a leading `text-start`).
|
|
465
|
+
const appendText = (delta: string) => {
|
|
466
|
+
if (textSegments.length === 0) textSegments.push("");
|
|
467
|
+
textSegments[textSegments.length - 1] += delta;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const upsertAssistant = () => {
|
|
471
|
+
const prev = getSession(threadId).messages;
|
|
472
|
+
const next = [...prev];
|
|
473
|
+
const idx = next.findIndex((m) => m.id === assistantId);
|
|
474
|
+
const parts: UIMessage["parts"] = [];
|
|
475
|
+
if (assistantReasoning) {
|
|
476
|
+
parts.push({ type: "reasoning", text: assistantReasoning });
|
|
477
|
+
}
|
|
478
|
+
for (const segment of textSegments) {
|
|
479
|
+
if (segment.length > 0) parts.push({ type: "text", text: segment });
|
|
480
|
+
}
|
|
481
|
+
const message: UIMessage = {
|
|
482
|
+
id: assistantId,
|
|
483
|
+
role: "assistant",
|
|
484
|
+
parts: parts.length > 0 ? parts : [{ type: "text", text: "" }],
|
|
485
|
+
};
|
|
486
|
+
if (idx === -1) next.push(message);
|
|
487
|
+
else next[idx] = message;
|
|
488
|
+
writeMessages(threadId, next);
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const patchToolEvents = (update: (list: ToolEvent[]) => ToolEvent[]) => {
|
|
492
|
+
updateSession(threadId, (session) => ({
|
|
493
|
+
...session,
|
|
494
|
+
toolEventsByMessage: {
|
|
495
|
+
...session.toolEventsByMessage,
|
|
496
|
+
[assistantId]: update(session.toolEventsByMessage[assistantId] ?? []),
|
|
497
|
+
},
|
|
498
|
+
}));
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
let started = false;
|
|
502
|
+
const markStreaming = () => {
|
|
503
|
+
if (started) return;
|
|
504
|
+
started = true;
|
|
505
|
+
updateSession(threadId, (session) =>
|
|
506
|
+
session.status === "streaming"
|
|
507
|
+
? session
|
|
508
|
+
: { ...session, status: "streaming" },
|
|
509
|
+
);
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
try {
|
|
513
|
+
await stream.processDataStream({
|
|
514
|
+
onChunk: async (chunk: { type: string; payload?: any; runId?: string }) => {
|
|
515
|
+
// The user hit Stop: unwind the read loop. Throwing (rather
|
|
516
|
+
// than returning) is what actually stops `processDataStream`
|
|
517
|
+
// from pulling the next chunk; the wrapper below swallows it.
|
|
518
|
+
if (signal.aborted) throw new StreamAborted();
|
|
519
|
+
// Mastra stamps the stream's runId on most chunks. Capturing
|
|
520
|
+
// it the first time we see it (rather than relying on a
|
|
521
|
+
// separate API) keeps approve/decline calls correct even if
|
|
522
|
+
// the client-supplied runId got overridden server-side.
|
|
523
|
+
if (chunk.runId && !runIdRef.current) {
|
|
524
|
+
runIdRef.current = chunk.runId;
|
|
525
|
+
updateSession(threadId, (session) => ({
|
|
526
|
+
...session,
|
|
527
|
+
runId: chunk.runId!,
|
|
528
|
+
}));
|
|
529
|
+
}
|
|
530
|
+
switch (chunk.type) {
|
|
531
|
+
case "text-start":
|
|
532
|
+
// Open a new text segment so each step's preamble stays
|
|
533
|
+
// a separate part (and thus a separate rendered block).
|
|
534
|
+
textSegments.push("");
|
|
535
|
+
break;
|
|
536
|
+
case "text-delta":
|
|
537
|
+
appendText(chunk.payload?.text ?? "");
|
|
538
|
+
upsertAssistant();
|
|
539
|
+
markStreaming();
|
|
540
|
+
break;
|
|
541
|
+
case "text-end":
|
|
542
|
+
// Segment boundary is driven by `text-start`; nothing to
|
|
543
|
+
// do on end - the next start opens the next segment.
|
|
544
|
+
break;
|
|
545
|
+
case "reasoning-delta":
|
|
546
|
+
assistantReasoning += chunk.payload?.text ?? "";
|
|
547
|
+
upsertAssistant();
|
|
548
|
+
markStreaming();
|
|
549
|
+
break;
|
|
550
|
+
case "tool-call": {
|
|
551
|
+
const { toolCallId, toolName } = chunk.payload ?? {};
|
|
552
|
+
if (typeof toolCallId !== "string") break;
|
|
553
|
+
patchToolEvents((list) => [
|
|
554
|
+
...list,
|
|
555
|
+
{ id: toolCallId, toolName, status: "running" },
|
|
556
|
+
]);
|
|
557
|
+
// Make sure the assistant message exists in `messages`
|
|
558
|
+
// even when the model goes straight to a tool call with
|
|
559
|
+
// no preceding text, so the bubble (and its inline
|
|
560
|
+
// tool indicator) renders right away.
|
|
561
|
+
upsertAssistant();
|
|
562
|
+
markStreaming();
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
case "tool-call-approval": {
|
|
566
|
+
// Mastra paused the agent loop on a `requireApproval`
|
|
567
|
+
// tool call. The chunk carries the runId we'll need to
|
|
568
|
+
// resume the suspended workflow later. We surface the
|
|
569
|
+
// approval card via `pendingApprovalsByMessage` so the
|
|
570
|
+
// existing ChatView UI lights up without us having to
|
|
571
|
+
// inject a synthetic data part.
|
|
572
|
+
const { toolCallId, toolName, args } = chunk.payload ?? {};
|
|
573
|
+
const approvalRunId = chunk.runId ?? runIdRef.current;
|
|
574
|
+
if (
|
|
575
|
+
typeof toolCallId !== "string" ||
|
|
576
|
+
typeof toolName !== "string" ||
|
|
577
|
+
!approvalRunId
|
|
578
|
+
) {
|
|
579
|
+
logger.warn("malformed tool-call-approval chunk", {
|
|
580
|
+
toolCallId,
|
|
581
|
+
toolName,
|
|
582
|
+
hasRunId: Boolean(approvalRunId),
|
|
583
|
+
});
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
updateSession(threadId, (session) => {
|
|
587
|
+
const existingApprovals =
|
|
588
|
+
session.pendingApprovalsByMessage[assistantId] ?? [];
|
|
589
|
+
if (existingApprovals.some((a) => a.toolCallId === toolCallId)) {
|
|
590
|
+
return session;
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
...session,
|
|
594
|
+
pendingApprovalsByMessage: {
|
|
595
|
+
...session.pendingApprovalsByMessage,
|
|
596
|
+
[assistantId]: [
|
|
597
|
+
...existingApprovals,
|
|
598
|
+
{
|
|
599
|
+
toolName,
|
|
600
|
+
toolCallId,
|
|
601
|
+
runId: approvalRunId,
|
|
602
|
+
input: args,
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
},
|
|
606
|
+
};
|
|
607
|
+
});
|
|
608
|
+
upsertAssistant();
|
|
609
|
+
markStreaming();
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
case "tool-result": {
|
|
613
|
+
const toolCallId = chunk.payload?.toolCallId;
|
|
614
|
+
if (typeof toolCallId !== "string") break;
|
|
615
|
+
// Charts resolve from `[chart:<id>]` markers in the
|
|
616
|
+
// assistant's prose (the model embeds the id returned
|
|
617
|
+
// by `prepare_chart`), so the tool-result payload is
|
|
618
|
+
// opaque here - we only need it to flip the pill.
|
|
619
|
+
// Genie tools (`ask_genie`, `get_statement`,
|
|
620
|
+
// `prepare_chart`) stream their entire progress
|
|
621
|
+
// surface through `ctx.writer` and arrive on this
|
|
622
|
+
// page via the `tool-output` path. The settled
|
|
623
|
+
// tool-result return value is opaque to the UI -
|
|
624
|
+
// we only need it to flip the pill to `done`.
|
|
625
|
+
patchToolEvents((list) =>
|
|
626
|
+
list.map((e) => (e.id === toolCallId ? { ...e, status: "done" } : e)),
|
|
627
|
+
);
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
case "tool-error": {
|
|
631
|
+
const toolCallId = chunk.payload?.toolCallId;
|
|
632
|
+
if (typeof toolCallId !== "string") break;
|
|
633
|
+
patchToolEvents((list) =>
|
|
634
|
+
list.map((e) =>
|
|
635
|
+
e.id === toolCallId ? { ...e, status: "error" } : e,
|
|
636
|
+
),
|
|
637
|
+
);
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
case "tool-output": {
|
|
641
|
+
// Mid-flight progress pushed by a tool via `ctx.writer`
|
|
642
|
+
// (e.g. genie.ts forwarding `status`/`sql`/`data` events
|
|
643
|
+
// from the Genie space). Append to the matching pill so
|
|
644
|
+
// the user sees SQL/row info as soon as Genie publishes
|
|
645
|
+
// it, not only when the LLM call completes.
|
|
646
|
+
const { toolCallId, output } = chunk.payload ?? {};
|
|
647
|
+
if (typeof toolCallId !== "string") break;
|
|
648
|
+
if (!isToolProgress(output)) break;
|
|
649
|
+
patchToolEvents((list) =>
|
|
650
|
+
list.map((e) =>
|
|
651
|
+
e.id === toolCallId
|
|
652
|
+
? { ...e, progress: [...(e.progress ?? []), output] }
|
|
653
|
+
: e,
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
case "error": {
|
|
659
|
+
// Surface a stream-reported error through the same path as
|
|
660
|
+
// a thrown one: throwing here propagates out of
|
|
661
|
+
// `processDataStream` to `driveStream`, which records the
|
|
662
|
+
// message and pins `status` to "error" (a plain
|
|
663
|
+
// setStatus would be clobbered by the clean-close "ready").
|
|
664
|
+
const detail = chunk.payload?.error ?? chunk.payload?.message;
|
|
665
|
+
throw new Error(
|
|
666
|
+
typeof detail === "string" && detail
|
|
667
|
+
? detail
|
|
668
|
+
: "The assistant stream reported an error.",
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
default:
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
} catch (error) {
|
|
677
|
+
// A stop (signal aborted) unwinds the loop cleanly - not a
|
|
678
|
+
// failure. Anything else is a real stream error and propagates
|
|
679
|
+
// to the driver's catch.
|
|
680
|
+
if (error instanceof StreamAborted || signal.aborted) return;
|
|
681
|
+
throw error;
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
[getSession, updateSession, writeMessages],
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
const driveStream = useCallback(
|
|
688
|
+
async (
|
|
689
|
+
threadId: string,
|
|
690
|
+
assistantId: string,
|
|
691
|
+
open: () => Promise<MastraStreamResponse>,
|
|
692
|
+
) => {
|
|
693
|
+
const controller = new AbortController();
|
|
694
|
+
let token = 0;
|
|
695
|
+
updateSession(threadId, (session) => {
|
|
696
|
+
session.abortController?.abort();
|
|
697
|
+
token = session.runToken + 1;
|
|
698
|
+
return {
|
|
699
|
+
...session,
|
|
700
|
+
abortController: controller,
|
|
701
|
+
assistantId,
|
|
702
|
+
runId: session.runId,
|
|
703
|
+
error: null,
|
|
704
|
+
status: "submitted",
|
|
705
|
+
runToken: token,
|
|
706
|
+
};
|
|
707
|
+
});
|
|
708
|
+
const runIdRef = { current: getSession(threadId).runId };
|
|
709
|
+
try {
|
|
710
|
+
const streamThreadId =
|
|
711
|
+
threadId === DEFAULT_THREAD_SESSION_KEY ? undefined : threadId;
|
|
712
|
+
mastraClient.setThreadId(streamThreadId);
|
|
713
|
+
const stream = await open();
|
|
714
|
+
await processStream(threadId, stream, assistantId, runIdRef, controller.signal);
|
|
715
|
+
updateSession(threadId, (session) => {
|
|
716
|
+
if (session.runToken !== token) return session;
|
|
717
|
+
return {
|
|
718
|
+
...session,
|
|
719
|
+
status: "ready",
|
|
720
|
+
abortController:
|
|
721
|
+
session.abortController === controller ? null : session.abortController,
|
|
722
|
+
runId: runIdRef.current,
|
|
723
|
+
};
|
|
724
|
+
});
|
|
725
|
+
if (getSession(threadId).runToken === token) {
|
|
726
|
+
refreshThreadsSoon();
|
|
727
|
+
}
|
|
728
|
+
} catch (caught) {
|
|
729
|
+
if (getSession(threadId).runToken !== token) return;
|
|
730
|
+
logger.error("stream error", {
|
|
731
|
+
error: errorUtil.errorMessage(caught),
|
|
732
|
+
});
|
|
733
|
+
updateSession(threadId, (session) => ({
|
|
734
|
+
...session,
|
|
735
|
+
error: errorUtil.toError(caught),
|
|
736
|
+
status: "error",
|
|
737
|
+
abortController:
|
|
738
|
+
session.abortController === controller ? null : session.abortController,
|
|
739
|
+
runId: runIdRef.current,
|
|
740
|
+
}));
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
[getSession, mastraClient, processStream, refreshThreadsSoon, updateSession],
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const runStream = useCallback(
|
|
747
|
+
(threadId: string, history: UIMessage[]) => {
|
|
748
|
+
const assistantId = nanoid();
|
|
749
|
+
const runId = nanoid();
|
|
750
|
+
updateSession(threadId, (session) => ({
|
|
751
|
+
...session,
|
|
752
|
+
assistantId,
|
|
753
|
+
runId,
|
|
754
|
+
}));
|
|
755
|
+
return driveStream(threadId, assistantId, () => {
|
|
756
|
+
const agent = mastraClient.getAgent(agentId);
|
|
757
|
+
const messagesForAgent = history.flatMap((m) =>
|
|
758
|
+
m.parts
|
|
759
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
760
|
+
.map((p) => ({ role: m.role, content: p.text })),
|
|
761
|
+
) as Parameters<typeof agent.stream>[0];
|
|
762
|
+
return agent.stream(messagesForAgent, {
|
|
763
|
+
runId,
|
|
764
|
+
}) as Promise<MastraStreamResponse>;
|
|
765
|
+
});
|
|
766
|
+
},
|
|
767
|
+
[driveStream, mastraClient, agentId, updateSession],
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
const stop = useCallback(() => {
|
|
771
|
+
const threadId = activeKey;
|
|
772
|
+
updateSession(threadId, (session) => {
|
|
773
|
+
if (!isSessionRunning(session)) return session;
|
|
774
|
+
session.abortController?.abort();
|
|
775
|
+
return {
|
|
776
|
+
...session,
|
|
777
|
+
abortController: null,
|
|
778
|
+
runToken: session.runToken + 1,
|
|
779
|
+
error: null,
|
|
780
|
+
status: "ready",
|
|
781
|
+
};
|
|
782
|
+
});
|
|
783
|
+
}, [activeKey, updateSession]);
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Approve or deny an in-flight `requireApproval` tool call. The
|
|
787
|
+
* suspended workflow lives on the server keyed by `runId`; we resume
|
|
788
|
+
* via {@link MastraPluginClient.approveToolCallStream} /
|
|
789
|
+
* `declineToolCallStream` and pipe the SSE chunk stream back into
|
|
790
|
+
* the same bubble `runStream` was building.
|
|
791
|
+
*/
|
|
792
|
+
const handleApproval = useCallback(
|
|
793
|
+
async (decision: ApprovalDecision) => {
|
|
794
|
+
const { runId: decisionRunId, toolCallId, toolName } = decision;
|
|
795
|
+
const session = getSession(activeKey);
|
|
796
|
+
const assistantId = session.assistantId;
|
|
797
|
+
const runId = decisionRunId ?? session.runId;
|
|
798
|
+
if (!runId || !assistantId) {
|
|
799
|
+
logger.warn("approval missing runId or assistantId, cannot resume", {
|
|
800
|
+
tool: toolName,
|
|
801
|
+
toolCallId,
|
|
802
|
+
hasRunId: Boolean(runId),
|
|
803
|
+
hasAssistantId: Boolean(assistantId),
|
|
804
|
+
});
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
updateSession(activeKey, (current) => {
|
|
808
|
+
const existing = current.pendingApprovalsByMessage[assistantId];
|
|
809
|
+
if (!existing) return current;
|
|
810
|
+
const next = existing.filter((a) => a.toolCallId !== toolCallId);
|
|
811
|
+
if (next.length === 0) {
|
|
812
|
+
const { [assistantId]: _drop, ...rest } = current.pendingApprovalsByMessage;
|
|
813
|
+
return { ...current, pendingApprovalsByMessage: rest };
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
...current,
|
|
817
|
+
pendingApprovalsByMessage: {
|
|
818
|
+
...current.pendingApprovalsByMessage,
|
|
819
|
+
[assistantId]: next,
|
|
820
|
+
},
|
|
821
|
+
};
|
|
822
|
+
});
|
|
823
|
+
logger.info(decision.approved ? "approved" : "denied", {
|
|
824
|
+
tool: toolName,
|
|
825
|
+
toolCallId,
|
|
826
|
+
runId,
|
|
827
|
+
});
|
|
828
|
+
await driveStream(activeKey, assistantId, () =>
|
|
829
|
+
decision.approved
|
|
830
|
+
? mastraClient.approveToolCallStream(agentId, { runId, toolCallId })
|
|
831
|
+
: mastraClient.declineToolCallStream(agentId, { runId, toolCallId }),
|
|
832
|
+
);
|
|
833
|
+
},
|
|
834
|
+
[activeKey, driveStream, getSession, mastraClient, agentId, updateSession],
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
const sendMessage = useCallback<ChatViewProps["sendMessage"]>(
|
|
838
|
+
(message) => {
|
|
839
|
+
const text = message.text ?? "";
|
|
840
|
+
if (!text) return;
|
|
841
|
+
const threadId = activeKey;
|
|
842
|
+
updateSession(threadId, (session) => ({ ...session, lastUserText: text }));
|
|
843
|
+
if (activeThreadId) {
|
|
844
|
+
noteThreadActivity(activeThreadId);
|
|
845
|
+
if (getSession(threadId).messages.length === 0) {
|
|
846
|
+
const provisional = deriveThreadTitle(text);
|
|
847
|
+
if (provisional) {
|
|
848
|
+
setProvisionalTitles((prev) =>
|
|
849
|
+
prev[activeThreadId] ? prev : { ...prev, [activeThreadId]: provisional },
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const next = [...getSession(threadId).messages, makeUserMessage(text)];
|
|
855
|
+
writeMessages(threadId, next);
|
|
856
|
+
void runStream(threadId, next);
|
|
857
|
+
},
|
|
858
|
+
[
|
|
859
|
+
runStream,
|
|
860
|
+
writeMessages,
|
|
861
|
+
activeThreadId,
|
|
862
|
+
activeKey,
|
|
863
|
+
getSession,
|
|
864
|
+
noteThreadActivity,
|
|
865
|
+
updateSession,
|
|
866
|
+
],
|
|
867
|
+
);
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Wipe the current thread on the server and reset every piece of
|
|
871
|
+
* client-side state that mirrored it. The session cookie that
|
|
872
|
+
* anchors the thread id is preserved by the server, so the next
|
|
873
|
+
* turn opens against the same (now empty) thread - no reload
|
|
874
|
+
* needed. Suspended approval cards belong to the cleared turns
|
|
875
|
+
* and would be unresolvable anyway, so we drop them too.
|
|
876
|
+
*/
|
|
877
|
+
const handleClear = useCallback(async () => {
|
|
878
|
+
const threadId = activeKey;
|
|
879
|
+
try {
|
|
880
|
+
const result = await mastraClient.clearHistory({ agentId });
|
|
881
|
+
logger.info("history cleared", { cleared: result.cleared });
|
|
882
|
+
} catch (error) {
|
|
883
|
+
logger.error("history clear error", {
|
|
884
|
+
error: errorUtil.errorMessage(error),
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
const session = getSession(threadId);
|
|
888
|
+
session.abortController?.abort();
|
|
889
|
+
updateSession(threadId, () => ({
|
|
890
|
+
...createThreadSession(),
|
|
891
|
+
historyLoaded: true,
|
|
892
|
+
}));
|
|
893
|
+
if (activeThreadId) {
|
|
894
|
+
setOptimisticThreads((prev) => {
|
|
895
|
+
if (!prev[activeThreadId]) return prev;
|
|
896
|
+
const { [activeThreadId]: _drop, ...rest } = prev;
|
|
897
|
+
return rest;
|
|
898
|
+
});
|
|
899
|
+
setProvisionalTitles((prev) => {
|
|
900
|
+
if (!(activeThreadId in prev)) return prev;
|
|
901
|
+
const { [activeThreadId]: _drop, ...rest } = prev;
|
|
902
|
+
return rest;
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
refreshThreadsSoon();
|
|
906
|
+
}, [
|
|
907
|
+
mastraClient,
|
|
908
|
+
agentId,
|
|
909
|
+
activeKey,
|
|
910
|
+
activeThreadId,
|
|
911
|
+
getSession,
|
|
912
|
+
refreshThreadsSoon,
|
|
913
|
+
updateSession,
|
|
914
|
+
]);
|
|
915
|
+
|
|
916
|
+
const selectThread = useCallback(
|
|
917
|
+
(threadId: string) => {
|
|
918
|
+
if (threadId === activeThreadId) return;
|
|
919
|
+
setActiveThreadId(threadId);
|
|
920
|
+
},
|
|
921
|
+
[activeThreadId],
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
const newThread = useCallback(() => {
|
|
925
|
+
const id = nanoid();
|
|
926
|
+
sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
|
|
927
|
+
notifySessions();
|
|
928
|
+
setActiveThreadId(id);
|
|
929
|
+
}, [notifySessions]);
|
|
930
|
+
|
|
931
|
+
const deleteThread = useCallback(
|
|
932
|
+
async (threadId: string) => {
|
|
933
|
+
try {
|
|
934
|
+
const result = await mastraClient.removeThread(threadId, { agentId });
|
|
935
|
+
logger.info("thread deleted", { threadId, deleted: result.deleted });
|
|
936
|
+
} catch (error) {
|
|
937
|
+
logger.error("thread delete error", {
|
|
938
|
+
threadId,
|
|
939
|
+
error: errorUtil.errorMessage(error),
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
const session = sessionsRef.current.get(threadId);
|
|
943
|
+
session?.abortController?.abort();
|
|
944
|
+
sessionsRef.current.delete(threadId);
|
|
945
|
+
notifySessions();
|
|
946
|
+
setOptimisticThreads((prev) => {
|
|
947
|
+
if (!prev[threadId]) return prev;
|
|
948
|
+
const { [threadId]: _drop, ...rest } = prev;
|
|
949
|
+
return rest;
|
|
950
|
+
});
|
|
951
|
+
setProvisionalTitles((prev) => {
|
|
952
|
+
if (!(threadId in prev)) return prev;
|
|
953
|
+
const { [threadId]: _drop, ...rest } = prev;
|
|
954
|
+
return rest;
|
|
955
|
+
});
|
|
956
|
+
if (threadId === activeThreadId) {
|
|
957
|
+
const id = nanoid();
|
|
958
|
+
sessionsRef.current.set(id, { ...createThreadSession(), historyLoaded: true });
|
|
959
|
+
notifySessions();
|
|
960
|
+
setActiveThreadId(id);
|
|
961
|
+
}
|
|
962
|
+
refreshThreads();
|
|
963
|
+
},
|
|
964
|
+
[mastraClient, agentId, activeThreadId, notifySessions, refreshThreads],
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Rename a conversation. Optimistically overlays the new title so the
|
|
969
|
+
* sidebar updates instantly (both the server-row overlay and any
|
|
970
|
+
* still-pending optimistic row for a brand-new thread), persists it,
|
|
971
|
+
* then refreshes the list. On failure the overlay is dropped so the
|
|
972
|
+
* real (unchanged) title reappears.
|
|
973
|
+
*/
|
|
974
|
+
const renameThread = useCallback(
|
|
975
|
+
async (threadId: string, rawTitle: string) => {
|
|
976
|
+
const title = rawTitle.trim();
|
|
977
|
+
if (!title) return;
|
|
978
|
+
setRenamedThreads((prev) => ({ ...prev, [threadId]: title }));
|
|
979
|
+
setOptimisticThreads((prev) =>
|
|
980
|
+
prev[threadId]
|
|
981
|
+
? { ...prev, [threadId]: { ...prev[threadId], id: threadId, title } }
|
|
982
|
+
: prev,
|
|
983
|
+
);
|
|
984
|
+
try {
|
|
985
|
+
await mastraClient.renameThread(threadId, title, { agentId });
|
|
986
|
+
logger.info("thread renamed", { threadId });
|
|
987
|
+
} catch (error) {
|
|
988
|
+
logger.error("thread rename error", {
|
|
989
|
+
threadId,
|
|
990
|
+
error: errorUtil.errorMessage(error),
|
|
991
|
+
});
|
|
992
|
+
setRenamedThreads((prev) => {
|
|
993
|
+
if (!(threadId in prev)) return prev;
|
|
994
|
+
const { [threadId]: _drop, ...rest } = prev;
|
|
995
|
+
return rest;
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
refreshThreads();
|
|
999
|
+
},
|
|
1000
|
+
[mastraClient, agentId, refreshThreads],
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
const regenerate = useCallback(() => {
|
|
1004
|
+
const threadId = activeKey;
|
|
1005
|
+
const lastUserText = getSession(threadId).lastUserText;
|
|
1006
|
+
if (!lastUserText) return;
|
|
1007
|
+
const prev = getSession(threadId).messages;
|
|
1008
|
+
const lastAssistant =
|
|
1009
|
+
prev.length > 0 && prev.at(-1)?.role === "assistant" ? prev.at(-1) : null;
|
|
1010
|
+
const trimmed = lastAssistant ? prev.slice(0, -1) : prev;
|
|
1011
|
+
if (lastAssistant) {
|
|
1012
|
+
updateSession(threadId, (session) => {
|
|
1013
|
+
const { [lastAssistant.id]: _events, ...toolEvents } =
|
|
1014
|
+
session.toolEventsByMessage;
|
|
1015
|
+
const { [lastAssistant.id]: _approvals, ...pendingApprovals } =
|
|
1016
|
+
session.pendingApprovalsByMessage;
|
|
1017
|
+
const { [lastAssistant.id]: _feedback, ...feedback } =
|
|
1018
|
+
session.feedbackByMessage;
|
|
1019
|
+
return {
|
|
1020
|
+
...session,
|
|
1021
|
+
toolEventsByMessage: toolEvents,
|
|
1022
|
+
pendingApprovalsByMessage: pendingApprovals,
|
|
1023
|
+
feedbackByMessage: feedback,
|
|
1024
|
+
};
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
writeMessages(threadId, trimmed);
|
|
1028
|
+
void runStream(threadId, trimmed);
|
|
1029
|
+
}, [activeKey, getSession, runStream, updateSession, writeMessages]);
|
|
1030
|
+
|
|
1031
|
+
// Hydrate the active thread from the server when it has no local
|
|
1032
|
+
// session yet. In-flight streams keep updating their session in the
|
|
1033
|
+
// background, so switching back shows live partial text without
|
|
1034
|
+
// refetching or aborting other threads' runs.
|
|
1035
|
+
useEffect(() => {
|
|
1036
|
+
const threadId = activeKey;
|
|
1037
|
+
mastraClient.setThreadId(activeThreadId);
|
|
1038
|
+
const session = getSession(threadId);
|
|
1039
|
+
if (session.historyLoaded) {
|
|
1040
|
+
setIsLoadingHistory(false);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
let cancelled = false;
|
|
1045
|
+
const controller = new AbortController();
|
|
1046
|
+
historyInFlightRef.current = true;
|
|
1047
|
+
setIsLoadingHistory(true);
|
|
1048
|
+
mastraClient
|
|
1049
|
+
.history({
|
|
1050
|
+
agentId,
|
|
1051
|
+
page: 0,
|
|
1052
|
+
perPage: HISTORY_PAGE_SIZE,
|
|
1053
|
+
signal: controller.signal,
|
|
1054
|
+
})
|
|
1055
|
+
.then((response) => {
|
|
1056
|
+
if (cancelled) return;
|
|
1057
|
+
updateSession(threadId, (current) => ({
|
|
1058
|
+
...current,
|
|
1059
|
+
messages: response.uiMessages as unknown as UIMessage[],
|
|
1060
|
+
historyLoaded: true,
|
|
1061
|
+
hasMoreHistory: response.hasMore,
|
|
1062
|
+
historyPage: 1,
|
|
1063
|
+
toolEventsByMessage: {},
|
|
1064
|
+
pendingApprovalsByMessage: {},
|
|
1065
|
+
feedbackByMessage: {},
|
|
1066
|
+
}));
|
|
1067
|
+
})
|
|
1068
|
+
.catch((error: unknown) => {
|
|
1069
|
+
if (cancelled || (error as { name?: string }).name === "AbortError") return;
|
|
1070
|
+
logger.error("history load error", {
|
|
1071
|
+
error: errorUtil.errorMessage(error),
|
|
1072
|
+
});
|
|
1073
|
+
updateSession(threadId, (current) => ({ ...current, hasMoreHistory: false }));
|
|
1074
|
+
})
|
|
1075
|
+
.finally(() => {
|
|
1076
|
+
historyInFlightRef.current = false;
|
|
1077
|
+
if (!cancelled) setIsLoadingHistory(false);
|
|
1078
|
+
});
|
|
1079
|
+
return () => {
|
|
1080
|
+
cancelled = true;
|
|
1081
|
+
controller.abort();
|
|
1082
|
+
};
|
|
1083
|
+
}, [mastraClient, agentId, activeThreadId, activeKey, getSession, updateSession]);
|
|
1084
|
+
|
|
1085
|
+
const loadOlderHistory = useCallback(() => {
|
|
1086
|
+
const threadId = activeKey;
|
|
1087
|
+
const session = getSession(threadId);
|
|
1088
|
+
if (historyInFlightRef.current || !session.hasMoreHistory) return;
|
|
1089
|
+
historyInFlightRef.current = true;
|
|
1090
|
+
setIsLoadingMore(true);
|
|
1091
|
+
const page = session.historyPage;
|
|
1092
|
+
updateSession(threadId, (current) => ({ ...current, historyPage: page + 1 }));
|
|
1093
|
+
mastraClient
|
|
1094
|
+
.history({ agentId, page, perPage: HISTORY_PAGE_SIZE })
|
|
1095
|
+
.then((response) => {
|
|
1096
|
+
const uiMessages = response.uiMessages as unknown as UIMessage[];
|
|
1097
|
+
if (uiMessages.length > 0) {
|
|
1098
|
+
const currentMessages = getSession(threadId).messages;
|
|
1099
|
+
writeMessages(threadId, [...uiMessages, ...currentMessages]);
|
|
1100
|
+
}
|
|
1101
|
+
updateSession(threadId, (current) => ({
|
|
1102
|
+
...current,
|
|
1103
|
+
hasMoreHistory: response.hasMore,
|
|
1104
|
+
}));
|
|
1105
|
+
})
|
|
1106
|
+
.catch((error: unknown) => {
|
|
1107
|
+
logger.error("history load-more error", {
|
|
1108
|
+
page,
|
|
1109
|
+
error: errorUtil.errorMessage(error),
|
|
1110
|
+
});
|
|
1111
|
+
updateSession(threadId, (current) => ({
|
|
1112
|
+
...current,
|
|
1113
|
+
historyPage: page,
|
|
1114
|
+
hasMoreHistory: false,
|
|
1115
|
+
}));
|
|
1116
|
+
})
|
|
1117
|
+
.finally(() => {
|
|
1118
|
+
historyInFlightRef.current = false;
|
|
1119
|
+
setIsLoadingMore(false);
|
|
1120
|
+
});
|
|
1121
|
+
}, [activeKey, getSession, mastraClient, agentId, updateSession, writeMessages]);
|
|
1122
|
+
|
|
1123
|
+
// Chat export (opt-in). Resolves `[chart:<id>]` / `[data:<id>]` embeds
|
|
1124
|
+
// straight off the client so the export inlines the same charts /
|
|
1125
|
+
// tables the UI renders. Handlers are defined unconditionally (rules of
|
|
1126
|
+
// hooks) and only surfaced to ChatView when `enableExport` is on.
|
|
1127
|
+
const exportResolver = useMemo<EmbedResolver>(
|
|
1128
|
+
() => ({
|
|
1129
|
+
chart: (id) => mastraClient.chart(id),
|
|
1130
|
+
statement: (id) => mastraClient.statement(id),
|
|
1131
|
+
}),
|
|
1132
|
+
[mastraClient],
|
|
1133
|
+
);
|
|
1134
|
+
// Speaker label for the human turns in an export. The resource id is
|
|
1135
|
+
// the signed-in user's identity (every thread the client lists is its
|
|
1136
|
+
// own), so any owned thread supplies it; fall back to a bare "User"
|
|
1137
|
+
// before the first thread lands.
|
|
1138
|
+
const exportUserLabel = useMemo(() => {
|
|
1139
|
+
const resourceId = threads.find((t) => t.resourceId)?.resourceId;
|
|
1140
|
+
return resourceId ? `User (${resourceId})` : "User";
|
|
1141
|
+
}, [threads]);
|
|
1142
|
+
const exportConversation = useCallback(
|
|
1143
|
+
async (format: ExportFormat) => {
|
|
1144
|
+
const title =
|
|
1145
|
+
(activeThreadId && threads.find((t) => t.id === activeThreadId)?.title) ||
|
|
1146
|
+
"Conversation";
|
|
1147
|
+
try {
|
|
1148
|
+
await exportChat({
|
|
1149
|
+
messages: getSession(activeKey).messages,
|
|
1150
|
+
format,
|
|
1151
|
+
resolver: exportResolver,
|
|
1152
|
+
title,
|
|
1153
|
+
userLabel: exportUserLabel,
|
|
1154
|
+
});
|
|
1155
|
+
} catch (error) {
|
|
1156
|
+
logger.error("conversation export error", {
|
|
1157
|
+
format,
|
|
1158
|
+
error: errorUtil.errorMessage(error),
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
[exportResolver, activeThreadId, activeKey, getSession, threads, exportUserLabel],
|
|
1163
|
+
);
|
|
1164
|
+
const exportMessage = useCallback(
|
|
1165
|
+
async (message: UIMessage, format: ExportFormat) => {
|
|
1166
|
+
try {
|
|
1167
|
+
await exportChat({
|
|
1168
|
+
messages: [message],
|
|
1169
|
+
format,
|
|
1170
|
+
resolver: exportResolver,
|
|
1171
|
+
title: "Message",
|
|
1172
|
+
filename: "message",
|
|
1173
|
+
userLabel: exportUserLabel,
|
|
1174
|
+
});
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
logger.error("message export error", {
|
|
1177
|
+
format,
|
|
1178
|
+
error: errorUtil.errorMessage(error),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
},
|
|
1182
|
+
[exportResolver, exportUserLabel],
|
|
1183
|
+
);
|
|
1184
|
+
|
|
1185
|
+
// Submit thumbs / comment feedback for an assistant message to MLflow
|
|
1186
|
+
// via the plugin's feedback route. The message's captured trace id
|
|
1187
|
+
// scopes the assessment; without one there's nothing to attach to, so
|
|
1188
|
+
// the call is skipped. A thumbs value is reflected optimistically so
|
|
1189
|
+
// the active button highlights immediately; a soft "not recorded"
|
|
1190
|
+
// (e.g. the trace is still exporting) is logged, not surfaced as an
|
|
1191
|
+
// error, to keep the chat calm.
|
|
1192
|
+
const submitFeedback = useCallback(
|
|
1193
|
+
async (message: UIMessage, submission: FeedbackSubmission) => {
|
|
1194
|
+
const traceId = feedbackByMessageRef.current[message.id]?.traceId;
|
|
1195
|
+
if (!traceId) return;
|
|
1196
|
+
if (submission.value) {
|
|
1197
|
+
updateSession(activeKey, (session) => ({
|
|
1198
|
+
...session,
|
|
1199
|
+
feedbackByMessage: {
|
|
1200
|
+
...session.feedbackByMessage,
|
|
1201
|
+
[message.id]: { traceId, value: submission.value },
|
|
1202
|
+
},
|
|
1203
|
+
}));
|
|
1204
|
+
}
|
|
1205
|
+
try {
|
|
1206
|
+
const result = await mastraClient.feedback({
|
|
1207
|
+
traceId,
|
|
1208
|
+
...(submission.value !== undefined
|
|
1209
|
+
? { value: submission.value === "up" }
|
|
1210
|
+
: {}),
|
|
1211
|
+
...(submission.comment ? { comment: submission.comment } : {}),
|
|
1212
|
+
});
|
|
1213
|
+
if (!result.ok) {
|
|
1214
|
+
logger.warn("feedback not recorded (trace may still be exporting)", {
|
|
1215
|
+
traceId,
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
logger.error("feedback error", {
|
|
1220
|
+
traceId,
|
|
1221
|
+
error: errorUtil.errorMessage(error),
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
},
|
|
1225
|
+
[activeKey, mastraClient, updateSession],
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
// Merge optimistic rows
|
|
1229
|
+
// the server list, newest first, dropping any optimistic entry the
|
|
1230
|
+
// server already returns so a thread is never listed twice.
|
|
1231
|
+
const sidebarThreads = useMemo<ThreadSummary[]>(() => {
|
|
1232
|
+
// Title overlay precedence per row: a manual rename always wins;
|
|
1233
|
+
// otherwise a provisional first-message title fills an untitled row
|
|
1234
|
+
// until the server titles the thread.
|
|
1235
|
+
const withOverlay = (t: ThreadSummary): ThreadSummary => {
|
|
1236
|
+
const renamed = renamedThreads[t.id];
|
|
1237
|
+
if (renamed !== undefined) return { ...t, title: renamed };
|
|
1238
|
+
if (!t.title && provisionalTitles[t.id] !== undefined) {
|
|
1239
|
+
return { ...t, title: provisionalTitles[t.id] };
|
|
1240
|
+
}
|
|
1241
|
+
return t;
|
|
1242
|
+
};
|
|
1243
|
+
const server = threads.map(toThreadSummary).map(withOverlay);
|
|
1244
|
+
const serverIds = new Set(server.map((t) => t.id));
|
|
1245
|
+
const pending = Object.values(optimisticThreads)
|
|
1246
|
+
.filter((t) => !serverIds.has(t.id))
|
|
1247
|
+
.map(withOverlay)
|
|
1248
|
+
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
1249
|
+
return [...pending, ...server];
|
|
1250
|
+
}, [threads, optimisticThreads, renamedThreads, provisionalTitles]);
|
|
1251
|
+
// Once the server list includes a thread we were tracking optimistically,
|
|
1252
|
+
// drop the optimistic copy so the map doesn't grow without bound.
|
|
1253
|
+
useEffect(() => {
|
|
1254
|
+
const serverIds = new Set(threads.map((t) => t.id));
|
|
1255
|
+
const stale = Object.keys(optimisticThreads).filter((id) => serverIds.has(id));
|
|
1256
|
+
if (stale.length === 0) return;
|
|
1257
|
+
setOptimisticThreads((prev) => {
|
|
1258
|
+
const next = { ...prev };
|
|
1259
|
+
for (const id of stale) delete next[id];
|
|
1260
|
+
return next;
|
|
1261
|
+
});
|
|
1262
|
+
}, [threads, optimisticThreads]);
|
|
1263
|
+
// Drop a rename overlay once the server list reports the new title, so
|
|
1264
|
+
// the map doesn't grow without bound and later server-side title
|
|
1265
|
+
// changes aren't masked by a stale override.
|
|
1266
|
+
useEffect(() => {
|
|
1267
|
+
const settled = threads
|
|
1268
|
+
.filter(
|
|
1269
|
+
(t) => renamedThreads[t.id] !== undefined && t.title === renamedThreads[t.id],
|
|
1270
|
+
)
|
|
1271
|
+
.map((t) => t.id);
|
|
1272
|
+
if (settled.length === 0) return;
|
|
1273
|
+
setRenamedThreads((prev) => {
|
|
1274
|
+
const next = { ...prev };
|
|
1275
|
+
for (const id of settled) delete next[id];
|
|
1276
|
+
return next;
|
|
1277
|
+
});
|
|
1278
|
+
}, [threads, renamedThreads]);
|
|
1279
|
+
// Drop a provisional first-message title once the server reports any
|
|
1280
|
+
// real title for that thread, so the auto-generated title takes over.
|
|
1281
|
+
useEffect(() => {
|
|
1282
|
+
const settled = threads
|
|
1283
|
+
.filter((t) => t.title && provisionalTitles[t.id] !== undefined)
|
|
1284
|
+
.map((t) => t.id);
|
|
1285
|
+
if (settled.length === 0) return;
|
|
1286
|
+
setProvisionalTitles((prev) => {
|
|
1287
|
+
const next = { ...prev };
|
|
1288
|
+
for (const id of settled) delete next[id];
|
|
1289
|
+
return next;
|
|
1290
|
+
});
|
|
1291
|
+
}, [threads, provisionalTitles]);
|
|
1292
|
+
|
|
1293
|
+
return {
|
|
1294
|
+
messages: activeSession.messages,
|
|
1295
|
+
status: activeSession.status,
|
|
1296
|
+
error: activeSession.error,
|
|
1297
|
+
sendMessage,
|
|
1298
|
+
regenerate,
|
|
1299
|
+
onStop: stop,
|
|
1300
|
+
suggestions,
|
|
1301
|
+
toolEventsByMessage: activeSession.toolEventsByMessage,
|
|
1302
|
+
pendingApprovalsByMessage: activeSession.pendingApprovalsByMessage,
|
|
1303
|
+
onResolveToolApproval: handleApproval,
|
|
1304
|
+
// Picker is opt-in: only hand ChatView the catalogue + change
|
|
1305
|
+
// handler when `showModelPicker` is on, otherwise the header hides
|
|
1306
|
+
// it (ChatView shows it only when both are present).
|
|
1307
|
+
models: showModelPicker ? models : undefined,
|
|
1308
|
+
model,
|
|
1309
|
+
onModelChange: showModelPicker ? setModel : undefined,
|
|
1310
|
+
onLoadMore: loadOlderHistory,
|
|
1311
|
+
isLoadingMore,
|
|
1312
|
+
hasMore: activeSession.hasMoreHistory,
|
|
1313
|
+
isLoadingHistory,
|
|
1314
|
+
onClear: handleClear,
|
|
1315
|
+
// Conversation management: hand ChatView the thread list + handlers
|
|
1316
|
+
// only when enabled, so the sidebar stays hidden for the classic
|
|
1317
|
+
// single-thread chat (ChatView keys the sidebar off these props).
|
|
1318
|
+
...(enableThreads
|
|
1319
|
+
? {
|
|
1320
|
+
threads: sidebarThreads,
|
|
1321
|
+
...(activeThreadId ? { activeThreadId } : {}),
|
|
1322
|
+
streamingThreadIds,
|
|
1323
|
+
isLoadingThreads,
|
|
1324
|
+
onSelectThread: selectThread,
|
|
1325
|
+
onNewThread: newThread,
|
|
1326
|
+
onDeleteThread: deleteThread,
|
|
1327
|
+
onRenameThread: renameThread,
|
|
1328
|
+
// Persisted sidebar visibility, controlled from the driver so
|
|
1329
|
+
// the show/hide choice survives reloads.
|
|
1330
|
+
sidebarOpen,
|
|
1331
|
+
onToggleSidebar: toggleSidebar,
|
|
1332
|
+
}
|
|
1333
|
+
: {}),
|
|
1334
|
+
// Export is opt-in: only expose the handlers (which light up the
|
|
1335
|
+
// header + per-message export menus in ChatView) when enabled.
|
|
1336
|
+
...(enableExport
|
|
1337
|
+
? {
|
|
1338
|
+
onExportConversation: exportConversation,
|
|
1339
|
+
onExportMessage: exportMessage,
|
|
1340
|
+
}
|
|
1341
|
+
: {}),
|
|
1342
|
+
// Feedback: only expose the state + handler (which light up the
|
|
1343
|
+
// per-bubble thumbs / comment controls in ChatView) when feedback
|
|
1344
|
+
// is enabled AND the server can log to MLflow. `feedbackByMessage`
|
|
1345
|
+
// still gates per-message on a captured trace id.
|
|
1346
|
+
...(feedbackAvailable
|
|
1347
|
+
? {
|
|
1348
|
+
feedbackByMessage: activeSession.feedbackByMessage,
|
|
1349
|
+
onFeedback: submitFeedback,
|
|
1350
|
+
}
|
|
1351
|
+
: {}),
|
|
1352
|
+
};
|
|
1353
|
+
};
|
|
1354
|
+
|
|
1355
|
+
/** Props for {@link MastraChat}. */
|
|
1356
|
+
export interface MastraChatProps extends UseMastraChatOptions {
|
|
1357
|
+
/** Extra classes merged onto the chat's root layout container. */
|
|
1358
|
+
className?: string;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Self-contained chat component. Mount it anywhere under the Mastra
|
|
1363
|
+
* plugin and it wires itself from the plugin's published client config
|
|
1364
|
+
* (mount paths + default agent) via {@link useMastraChat}, then renders
|
|
1365
|
+
* the conversation through {@link ChatView}. The GenieChat-equivalent
|
|
1366
|
+
* drop-in: full streaming, tool-session pills, approvals, stop control,
|
|
1367
|
+
* history pagination, and built-in conversation management (a sidebar
|
|
1368
|
+
* of the resource's threads with select / new / delete, persisted
|
|
1369
|
+
* across reloads) - all with no host wiring. The model picker is opt-in
|
|
1370
|
+
* via `showModelPicker`; thread management is on by default and can be
|
|
1371
|
+
* turned off with `enableThreads: false`.
|
|
1372
|
+
*/
|
|
1373
|
+
export const MastraChat = ({ className, ...options }: MastraChatProps) => {
|
|
1374
|
+
const chat = useMastraChat(options);
|
|
1375
|
+
return <ChatView {...chat} className={className} />;
|
|
1376
|
+
};
|