@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,883 @@
|
|
|
1
|
+
import { usePluginClientConfig } from "@dbx-tools/ui-appkit/react";
|
|
2
|
+
import {
|
|
3
|
+
feedback,
|
|
4
|
+
override,
|
|
5
|
+
routes,
|
|
6
|
+
thread,
|
|
7
|
+
wire,
|
|
8
|
+
type Chart,
|
|
9
|
+
type MastraClearHistoryResponse,
|
|
10
|
+
type MastraClientConfig,
|
|
11
|
+
type MastraDeleteThreadResponse,
|
|
12
|
+
type MastraFeedbackRequest,
|
|
13
|
+
type MastraFeedbackResponse,
|
|
14
|
+
type MastraHistoryResponse,
|
|
15
|
+
type MastraThread,
|
|
16
|
+
type MastraThreadsResponse,
|
|
17
|
+
type MastraUpdateThreadResponse,
|
|
18
|
+
type StatementData,
|
|
19
|
+
} from "@dbx-tools/shared-mastra";
|
|
20
|
+
import { error as errorUtil } from "@dbx-tools/shared-core";
|
|
21
|
+
import type { ServingEndpointSummary } from "@dbx-tools/shared-model";
|
|
22
|
+
import { MastraClient } from "@mastra/client-js";
|
|
23
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
24
|
+
import { asMastraStreamResponse, type MastraStreamResponse } from "./mastra-stream";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `@mastra/client-js` `MastraClient` extended with the Mastra plugin's
|
|
28
|
+
* custom routes. One client drives everything the chat UI needs:
|
|
29
|
+
*
|
|
30
|
+
* - Conversation streaming via the inherited
|
|
31
|
+
* `getAgent(id).stream()` (the standard Mastra agent route).
|
|
32
|
+
* - Thread history (`history` / `clearHistory`), the conversation
|
|
33
|
+
* list (`threads` / `removeThread` / `renameThread`), the model
|
|
34
|
+
* catalogue (`models`),
|
|
35
|
+
* Genie starter prompts (`suggestions`), MLflow feedback logging
|
|
36
|
+
* (`feedback`), and inline embed resolution (`chart` / `statement`)
|
|
37
|
+
* over the plugin's own routes - all derived from `basePath` +
|
|
38
|
+
* {@link routes.MASTRA_ROUTES}.
|
|
39
|
+
*
|
|
40
|
+
* Built from the plugin's published `clientConfig()` payload
|
|
41
|
+
* ({@link MastraClientConfig}). `credentials: "include"` is set once at
|
|
42
|
+
* construction so the session cookie (which pins the server-side
|
|
43
|
+
* thread id) travels with every request, streaming included.
|
|
44
|
+
*/
|
|
45
|
+
export class MastraPluginClient extends MastraClient {
|
|
46
|
+
/** Plugin mount prefix (`/api/<plugin-name>`); all custom routes derive from it. */
|
|
47
|
+
readonly basePath: string;
|
|
48
|
+
/** Agent the bare (un-suffixed) routes resolve to. */
|
|
49
|
+
readonly defaultAgent: string;
|
|
50
|
+
/** Registered agent ids, surfaced for pickers. */
|
|
51
|
+
readonly agents: readonly string[];
|
|
52
|
+
/**
|
|
53
|
+
* Whether the server can log feedback to MLflow. When `false`, the
|
|
54
|
+
* chat UI hides thumbs / comment controls and {@link feedback} would
|
|
55
|
+
* be rejected server-side, so callers gate on this before offering
|
|
56
|
+
* feedback affordances.
|
|
57
|
+
*/
|
|
58
|
+
readonly feedbackEnabled: boolean;
|
|
59
|
+
|
|
60
|
+
constructor(config: MastraClientConfig) {
|
|
61
|
+
super({
|
|
62
|
+
baseUrl:
|
|
63
|
+
typeof window !== "undefined" ? window.location.origin : "http://localhost",
|
|
64
|
+
apiPrefix: config.basePath,
|
|
65
|
+
credentials: "include",
|
|
66
|
+
headers: {},
|
|
67
|
+
});
|
|
68
|
+
this.basePath = config.basePath;
|
|
69
|
+
this.defaultAgent = config.defaultAgent;
|
|
70
|
+
this.agents = config.agents;
|
|
71
|
+
this.feedbackEnabled = config.feedbackEnabled;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Set (or clear) the per-request model override sent on every
|
|
76
|
+
* streaming call as `X-Mastra-Model`. The Mastra plugin's middleware
|
|
77
|
+
* reads it and overrides the resolved model for that request without
|
|
78
|
+
* an agent redeploy. `model` is either a concrete endpoint name
|
|
79
|
+
* (fuzzy-matched server-side) or a model class slug
|
|
80
|
+
* (`ModelClass.ChatFast` / `"chat-thinking"`); pass `undefined` to
|
|
81
|
+
* fall back to the agent's configured default.
|
|
82
|
+
*
|
|
83
|
+
* Mutates the shared `options.headers` in place (rather than
|
|
84
|
+
* rebuilding the client) so the client identity stays stable across
|
|
85
|
+
* model changes - hooks can depend on it without refiring history
|
|
86
|
+
* loads when only the model changes.
|
|
87
|
+
*/
|
|
88
|
+
setModelOverride(model?: string): void {
|
|
89
|
+
const headers = (this.options.headers ??= {});
|
|
90
|
+
if (model) headers[override.MODEL_OVERRIDE_HEADER] = model;
|
|
91
|
+
else delete headers[override.MODEL_OVERRIDE_HEADER];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Select (or clear) the conversation thread every subsequent request
|
|
96
|
+
* targets, sent as the thread-selection header (`THREAD_ID_HEADER`).
|
|
97
|
+
* The plugin's middleware pins `RequestContext`'s thread id to it, so
|
|
98
|
+
* the agent stream persists into - and `history()` reads from - the
|
|
99
|
+
* chosen thread instead of the default per-session one. Pass
|
|
100
|
+
* `undefined` to fall back to the session thread.
|
|
101
|
+
*
|
|
102
|
+
* Mutates the shared `options.headers` in place (like
|
|
103
|
+
* {@link setModelOverride}) so the client identity stays stable; the
|
|
104
|
+
* inherited `agent.stream()` and the custom routes
|
|
105
|
+
* ({@link history} / {@link clearHistory} / {@link threads}) all read
|
|
106
|
+
* these headers, so selecting a thread routes every call at once.
|
|
107
|
+
*/
|
|
108
|
+
setThreadId(threadId?: string): void {
|
|
109
|
+
const headers = (this.options.headers ??= {});
|
|
110
|
+
if (threadId) headers[thread.THREAD_ID_HEADER] = threadId;
|
|
111
|
+
else delete headers[thread.THREAD_ID_HEADER];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resume a suspended `requireApproval` tool via `approve-tool-call`.
|
|
116
|
+
* Reads SSE directly instead of `agent.approveToolCall()` so the
|
|
117
|
+
* stock client's internal `processChatResponse_vNext` tee does not
|
|
118
|
+
* throw when the resume stream emits `tool-result` without a new
|
|
119
|
+
* `tool-call`.
|
|
120
|
+
*/
|
|
121
|
+
async approveToolCallStream(
|
|
122
|
+
agentId: string,
|
|
123
|
+
params: { runId: string; toolCallId: string },
|
|
124
|
+
): Promise<MastraStreamResponse> {
|
|
125
|
+
return this.#toolApprovalStream(agentId, "approve-tool-call", params);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Deny a suspended `requireApproval` tool via `decline-tool-call`.
|
|
130
|
+
* Same direct SSE reader as {@link approveToolCallStream}.
|
|
131
|
+
*/
|
|
132
|
+
async declineToolCallStream(
|
|
133
|
+
agentId: string,
|
|
134
|
+
params: { runId: string; toolCallId: string },
|
|
135
|
+
): Promise<MastraStreamResponse> {
|
|
136
|
+
return this.#toolApprovalStream(agentId, "decline-tool-call", params);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async #toolApprovalStream(
|
|
140
|
+
agentId: string,
|
|
141
|
+
route: "approve-tool-call" | "decline-tool-call",
|
|
142
|
+
params: { runId: string; toolCallId: string },
|
|
143
|
+
): Promise<MastraStreamResponse> {
|
|
144
|
+
const response = (await this.request(
|
|
145
|
+
`/agents/${encodeURIComponent(agentId)}/${route}`,
|
|
146
|
+
{
|
|
147
|
+
method: "POST",
|
|
148
|
+
body: params,
|
|
149
|
+
stream: true,
|
|
150
|
+
},
|
|
151
|
+
)) as Response;
|
|
152
|
+
if (!response.body) throw new Error("No response body");
|
|
153
|
+
return asMastraStreamResponse(
|
|
154
|
+
new Response(response.body, {
|
|
155
|
+
status: response.status,
|
|
156
|
+
statusText: response.statusText,
|
|
157
|
+
headers: response.headers,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Fetch the cached Model Serving endpoint catalogue from
|
|
164
|
+
* `GET ${basePath}/models`. Returns every endpoint the plugin
|
|
165
|
+
* publishes (server-cached for ~5 minutes); callers filter to
|
|
166
|
+
* chat-capable models for a picker.
|
|
167
|
+
*/
|
|
168
|
+
async models(signal?: AbortSignal): Promise<ServingEndpointSummary[]> {
|
|
169
|
+
const payload = await this.#getJson(
|
|
170
|
+
`${this.basePath}${routes.MASTRA_ROUTES.models}`,
|
|
171
|
+
wire.ServingEndpointsResponseSchema,
|
|
172
|
+
signal,
|
|
173
|
+
);
|
|
174
|
+
return payload.endpoints;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Fetch the curated starter questions for `agentId`'s Genie space
|
|
179
|
+
* from `GET ${basePath}/suggestions`. Empty when the agent has no
|
|
180
|
+
* Genie space (or it defines none).
|
|
181
|
+
*/
|
|
182
|
+
async suggestions(agentId?: string, signal?: AbortSignal): Promise<string[]> {
|
|
183
|
+
const payload = await this.#getJson(
|
|
184
|
+
this.#agentScoped(routes.MASTRA_ROUTES.suggestions, agentId),
|
|
185
|
+
wire.MastraSuggestionsResponseSchema,
|
|
186
|
+
signal,
|
|
187
|
+
);
|
|
188
|
+
return payload.questions;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fetch one page of thread history from `GET ${basePath}/history`.
|
|
193
|
+
* Messages come back oldest -> newest so the caller can prepend them
|
|
194
|
+
* to a live transcript.
|
|
195
|
+
*/
|
|
196
|
+
async history(
|
|
197
|
+
options: {
|
|
198
|
+
agentId?: string;
|
|
199
|
+
page?: number;
|
|
200
|
+
perPage?: number;
|
|
201
|
+
signal?: AbortSignal;
|
|
202
|
+
} = {},
|
|
203
|
+
): Promise<MastraHistoryResponse> {
|
|
204
|
+
const params = new URLSearchParams();
|
|
205
|
+
if (options.page !== undefined) params.set("page", String(options.page));
|
|
206
|
+
if (options.perPage !== undefined) params.set("perPage", String(options.perPage));
|
|
207
|
+
const qs = params.toString();
|
|
208
|
+
const base = this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId);
|
|
209
|
+
return this.#getJson(
|
|
210
|
+
qs ? `${base}?${qs}` : base,
|
|
211
|
+
wire.MastraHistoryResponseSchema,
|
|
212
|
+
options.signal,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Wipe the caller's thread history (`DELETE ${basePath}/history`).
|
|
218
|
+
* The session cookie that anchors the thread id is preserved - only
|
|
219
|
+
* the messages go away. Idempotent: a fresh thread reports
|
|
220
|
+
* `cleared: 0` without erroring.
|
|
221
|
+
*/
|
|
222
|
+
async clearHistory(
|
|
223
|
+
options: { agentId?: string; signal?: AbortSignal } = {},
|
|
224
|
+
): Promise<MastraClearHistoryResponse> {
|
|
225
|
+
return this.#mutateJson(
|
|
226
|
+
this.#agentScoped(routes.MASTRA_ROUTES.history, options.agentId),
|
|
227
|
+
"DELETE",
|
|
228
|
+
wire.MastraClearHistoryResponseSchema,
|
|
229
|
+
options.signal ? { signal: options.signal } : {},
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Fetch one page of the caller's conversation threads from
|
|
235
|
+
* `GET ${basePath}/threads`, newest first. Used to render the
|
|
236
|
+
* conversation list / sidebar so the user can switch between the
|
|
237
|
+
* threads they own for this resource. Scoped server-side to the
|
|
238
|
+
* caller's resource id, so it only ever returns the user's own
|
|
239
|
+
* conversations.
|
|
240
|
+
*/
|
|
241
|
+
async threads(
|
|
242
|
+
options: {
|
|
243
|
+
agentId?: string;
|
|
244
|
+
page?: number;
|
|
245
|
+
perPage?: number;
|
|
246
|
+
signal?: AbortSignal;
|
|
247
|
+
} = {},
|
|
248
|
+
): Promise<MastraThreadsResponse> {
|
|
249
|
+
const params = new URLSearchParams();
|
|
250
|
+
if (options.page !== undefined) params.set("page", String(options.page));
|
|
251
|
+
if (options.perPage !== undefined) params.set("perPage", String(options.perPage));
|
|
252
|
+
const qs = params.toString();
|
|
253
|
+
const base = this.#agentScoped(routes.MASTRA_ROUTES.threads, options.agentId);
|
|
254
|
+
return this.#getJson(
|
|
255
|
+
qs ? `${base}?${qs}` : base,
|
|
256
|
+
wire.MastraThreadsResponseSchema,
|
|
257
|
+
options.signal,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Delete a single conversation thread by id via the plugin's own
|
|
263
|
+
* `DELETE ${basePath}/threads` route (named `removeThread` to avoid
|
|
264
|
+
* clashing with the inherited `MastraClient.deleteThread`, which hits
|
|
265
|
+
* Mastra's stock thread route rather than our OBO-scoped, custom
|
|
266
|
+
* mount). The id is sent as the thread-selection header for this one
|
|
267
|
+
* call (without disturbing the client's currently-selected thread,
|
|
268
|
+
* set via {@link setThreadId}), so the sidebar can remove any thread
|
|
269
|
+
* while the user stays on another. Idempotent: deleting an unknown /
|
|
270
|
+
* already-removed thread reports `deleted: false` without erroring.
|
|
271
|
+
*/
|
|
272
|
+
async removeThread(
|
|
273
|
+
threadId: string,
|
|
274
|
+
options: { agentId?: string; signal?: AbortSignal } = {},
|
|
275
|
+
): Promise<MastraDeleteThreadResponse> {
|
|
276
|
+
return this.#mutateJson(
|
|
277
|
+
this.#agentScoped(routes.MASTRA_ROUTES.threads, options.agentId),
|
|
278
|
+
"DELETE",
|
|
279
|
+
wire.MastraDeleteThreadResponseSchema,
|
|
280
|
+
{
|
|
281
|
+
headers: { [thread.THREAD_ID_HEADER]: threadId },
|
|
282
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Rename a single conversation thread via the plugin's own
|
|
289
|
+
* `PATCH ${basePath}/threads` route. The id travels as the
|
|
290
|
+
* thread-selection header for this one call (mirroring
|
|
291
|
+
* {@link removeThread}, so the sidebar can rename any thread without
|
|
292
|
+
* disturbing the client's currently-selected thread) and the new
|
|
293
|
+
* `title` rides in the JSON body. The server enforces ownership and
|
|
294
|
+
* echoes back the updated thread, so the caller can reflect the new
|
|
295
|
+
* title immediately. Throws on an unknown / unowned thread (HTTP 404).
|
|
296
|
+
*/
|
|
297
|
+
async renameThread(
|
|
298
|
+
threadId: string,
|
|
299
|
+
title: string,
|
|
300
|
+
options: { agentId?: string; signal?: AbortSignal } = {},
|
|
301
|
+
): Promise<MastraUpdateThreadResponse> {
|
|
302
|
+
return this.#mutateJson(
|
|
303
|
+
this.#agentScoped(routes.MASTRA_ROUTES.threads, options.agentId),
|
|
304
|
+
"PATCH",
|
|
305
|
+
wire.MastraUpdateThreadResponseSchema,
|
|
306
|
+
{
|
|
307
|
+
body: { title },
|
|
308
|
+
headers: { [thread.THREAD_ID_HEADER]: threadId },
|
|
309
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
310
|
+
},
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Log user feedback for a turn to `POST ${basePath}/route/feedback`.
|
|
316
|
+
* `traceId` is the `tr-<hex>` value the server sent on the stream
|
|
317
|
+
* response (via `MLFLOW_TRACE_ID_HEADER`) for that assistant message;
|
|
318
|
+
* `value` carries a thumbs (boolean) / rating, and/or `comment`
|
|
319
|
+
* carries freeform text. The server logs it as a HUMAN assessment on
|
|
320
|
+
* the trace, attributed to the signed-in user.
|
|
321
|
+
*
|
|
322
|
+
* Resolves with `{ ok: false }` (not a throw) when the assessment
|
|
323
|
+
* couldn't be recorded yet - most often because the trace is still
|
|
324
|
+
* exporting to MLflow - so callers can prompt a retry. Throws only on
|
|
325
|
+
* a transport / HTTP-level failure.
|
|
326
|
+
*/
|
|
327
|
+
async feedback(input: MastraFeedbackRequest): Promise<MastraFeedbackResponse> {
|
|
328
|
+
return this.#mutateJson(
|
|
329
|
+
`${this.basePath}${routes.MASTRA_ROUTES.feedback}`,
|
|
330
|
+
"POST",
|
|
331
|
+
feedback.MastraFeedbackResponseSchema,
|
|
332
|
+
{ body: input },
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resolve a `[chart:<id>]` marker from
|
|
338
|
+
* `GET ${basePath}/embed/chart/:id`. The chart planner runs in the
|
|
339
|
+
* background, so the server long-polls the cache and returns the
|
|
340
|
+
* settled entry in one response. A 404 (unknown / expired id)
|
|
341
|
+
* resolves to `undefined` so the slot renders nothing.
|
|
342
|
+
*/
|
|
343
|
+
chart(id: string, signal?: AbortSignal): Promise<Chart | undefined> {
|
|
344
|
+
return this.#embed("chart", id, wire.ChartSchema.parse, signal);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Resolve a `[data:<id>]` marker from
|
|
349
|
+
* `GET ${basePath}/embed/data/:id`. Returns the same coerced rows the
|
|
350
|
+
* `get_statement` tool produced for the model. A 404 resolves to
|
|
351
|
+
* `undefined`.
|
|
352
|
+
*/
|
|
353
|
+
statement(id: string, signal?: AbortSignal): Promise<StatementData | undefined> {
|
|
354
|
+
return this.#embed("data", id, wire.StatementDataSchema.parse, signal);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Compose an agent-scoped URL: `${basePath}${segment}` for the
|
|
359
|
+
* default agent (the mount that does not require an `:agentId`), or
|
|
360
|
+
* `${basePath}${segment}/<encoded id>` for any other agent.
|
|
361
|
+
*/
|
|
362
|
+
#agentScoped(segment: string, agentId: string | undefined): string {
|
|
363
|
+
const path = `${this.basePath}${segment}`;
|
|
364
|
+
const id = agentId ?? this.defaultAgent;
|
|
365
|
+
return !id || id === this.defaultAgent ? path : `${path}/${encodeURIComponent(id)}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Snapshot of the client's per-request override headers (model +
|
|
370
|
+
* thread selection) for the custom-route fetches that don't go
|
|
371
|
+
* through `@mastra/client-js`'s own request pipeline. Returns a fresh
|
|
372
|
+
* object each call so a caller can safely add one-off headers without
|
|
373
|
+
* mutating the shared `options.headers`. The thread-selection header
|
|
374
|
+
* here is what routes `history()` / `clearHistory()` / `threads()` to
|
|
375
|
+
* the currently-selected thread (see {@link setThreadId}).
|
|
376
|
+
*/
|
|
377
|
+
#defaultHeaders(): Record<string, string> {
|
|
378
|
+
return { ...((this.options.headers as Record<string, string>) ?? {}) };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** GET + JSON-parse + schema-validate against a route that always 200s. */
|
|
382
|
+
async #getJson<T>(
|
|
383
|
+
url: string,
|
|
384
|
+
schema: { parse: (raw: unknown) => T },
|
|
385
|
+
signal?: AbortSignal,
|
|
386
|
+
): Promise<T> {
|
|
387
|
+
const init: RequestInit = {
|
|
388
|
+
credentials: "include",
|
|
389
|
+
headers: this.#defaultHeaders(),
|
|
390
|
+
};
|
|
391
|
+
if (signal) init.signal = signal;
|
|
392
|
+
const res = await fetch(url, init);
|
|
393
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
394
|
+
return schema.parse(await res.json());
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* `POST` / `DELETE` / `PATCH` + JSON-parse + schema-validate for the
|
|
399
|
+
* mutating routes (`clearHistory` / `removeThread` / `renameThread` /
|
|
400
|
+
* `feedback`). A JSON body, when present, sets `Content-Type`;
|
|
401
|
+
* `options.headers` add one-off headers (e.g. the thread-selection
|
|
402
|
+
* header for a targeted delete / rename) over the client's default
|
|
403
|
+
* override headers.
|
|
404
|
+
*/
|
|
405
|
+
async #mutateJson<T>(
|
|
406
|
+
url: string,
|
|
407
|
+
method: "POST" | "DELETE" | "PATCH",
|
|
408
|
+
schema: { parse: (raw: unknown) => T },
|
|
409
|
+
options: {
|
|
410
|
+
body?: unknown;
|
|
411
|
+
headers?: Record<string, string>;
|
|
412
|
+
signal?: AbortSignal;
|
|
413
|
+
} = {},
|
|
414
|
+
): Promise<T> {
|
|
415
|
+
const headers = { ...this.#defaultHeaders(), ...options.headers };
|
|
416
|
+
const init: RequestInit = { method, credentials: "include", headers };
|
|
417
|
+
if (options.body !== undefined) {
|
|
418
|
+
headers["Content-Type"] = "application/json";
|
|
419
|
+
init.body = JSON.stringify(options.body);
|
|
420
|
+
}
|
|
421
|
+
if (options.signal) init.signal = options.signal;
|
|
422
|
+
const res = await fetch(url, init);
|
|
423
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
424
|
+
return schema.parse(await res.json());
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Single-shot fetch of an embed marker of kind `type`. A 404
|
|
429
|
+
* (unknown embed type, or unknown / expired id) resolves to
|
|
430
|
+
* `undefined` so the host treats it as a missing slot. Caching /
|
|
431
|
+
* long-poll retry policy is the caller's concern.
|
|
432
|
+
*/
|
|
433
|
+
async #embed<T>(
|
|
434
|
+
type: string,
|
|
435
|
+
id: string,
|
|
436
|
+
parse: (raw: unknown) => T,
|
|
437
|
+
signal?: AbortSignal,
|
|
438
|
+
): Promise<T | undefined> {
|
|
439
|
+
const url = `${this.basePath}${routes.MASTRA_ROUTES.embed}/${encodeURIComponent(
|
|
440
|
+
type,
|
|
441
|
+
)}/${encodeURIComponent(id)}`;
|
|
442
|
+
const init: RequestInit = { credentials: "include" };
|
|
443
|
+
if (signal) init.signal = signal;
|
|
444
|
+
const res = await fetch(url, init);
|
|
445
|
+
if (res.status === 404) return undefined;
|
|
446
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
447
|
+
return parse(await res.json());
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Read the Mastra plugin's `clientConfig()` payload (`basePath`,
|
|
453
|
+
* default agent, registered agent list). One call per render; values
|
|
454
|
+
* are cached at boot by `usePluginClientConfig`.
|
|
455
|
+
*/
|
|
456
|
+
export const useMastraConfig = (): MastraClientConfig =>
|
|
457
|
+
usePluginClientConfig<MastraClientConfig>("mastra");
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The single {@link MastraPluginClient} for the plugin, built once from
|
|
461
|
+
* the published `basePath`. Use it for everything: stream a turn with
|
|
462
|
+
* `client.getAgent(agentId).stream(...)`, page history with
|
|
463
|
+
* `client.history()`, resolve embeds with `client.chart(id)`, etc.
|
|
464
|
+
* Rebuilt only when `basePath` / `defaultAgent` change (e.g. a custom
|
|
465
|
+
* mount), so it's safe as a `useMemo` / `useCallback` / `useEffect`
|
|
466
|
+
* dependency; the per-request model override is applied in place via
|
|
467
|
+
* {@link MastraPluginClient.setModelOverride} without changing identity.
|
|
468
|
+
*/
|
|
469
|
+
export const useMastraClient = (): MastraPluginClient => {
|
|
470
|
+
const config = useMastraConfig();
|
|
471
|
+
return useMemo(
|
|
472
|
+
() => new MastraPluginClient(config),
|
|
473
|
+
// `config` identity can churn across renders; the route layout is
|
|
474
|
+
// fully determined by these two scalars.
|
|
475
|
+
[config.basePath, config.defaultAgent],
|
|
476
|
+
);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Fetch the cached Model Serving endpoint catalogue exposed by the
|
|
481
|
+
* Mastra plugin (`client.models()`). Filters out non-LLM endpoints
|
|
482
|
+
* (anything without a `llm/v1/*` task) so the dropdown doesn't surface
|
|
483
|
+
* embedding / vision / agent-bricks endpoints. The response itself is
|
|
484
|
+
* server-cached for 5 minutes so polling cost is negligible.
|
|
485
|
+
*
|
|
486
|
+
* Pass `enabled: false` to skip the fetch entirely (e.g. when the
|
|
487
|
+
* model picker is hidden), in which case `models` stays empty.
|
|
488
|
+
*/
|
|
489
|
+
export const useMastraModels = (
|
|
490
|
+
enabled = true,
|
|
491
|
+
): {
|
|
492
|
+
models: ServingEndpointSummary[];
|
|
493
|
+
loading: boolean;
|
|
494
|
+
error: Error | null;
|
|
495
|
+
} => {
|
|
496
|
+
const client = useMastraClient();
|
|
497
|
+
const [models, setModels] = useState<ServingEndpointSummary[]>([]);
|
|
498
|
+
const [loading, setLoading] = useState(enabled);
|
|
499
|
+
const [error, setError] = useState<Error | null>(null);
|
|
500
|
+
|
|
501
|
+
useEffect(() => {
|
|
502
|
+
if (!enabled) {
|
|
503
|
+
setLoading(false);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const controller = new AbortController();
|
|
507
|
+
setLoading(true);
|
|
508
|
+
setError(null);
|
|
509
|
+
client
|
|
510
|
+
.models(controller.signal)
|
|
511
|
+
.then((endpoints) => {
|
|
512
|
+
if (controller.signal.aborted) return;
|
|
513
|
+
// Filter to chat-capable endpoints, dropping embeddings (which
|
|
514
|
+
// carry the `llm/v1/embeddings` task) so the chat picker never
|
|
515
|
+
// lists a vectoriser. If the server didn't tag tasks at all,
|
|
516
|
+
// pass everything through so we don't show an empty list.
|
|
517
|
+
const llms = endpoints.filter(
|
|
518
|
+
(e) =>
|
|
519
|
+
e.task !== "llm/v1/embeddings" && (!e.task || e.task.startsWith("llm/v1/")),
|
|
520
|
+
);
|
|
521
|
+
setModels(llms.length > 0 ? llms : endpoints);
|
|
522
|
+
})
|
|
523
|
+
.catch((e: unknown) => {
|
|
524
|
+
if (controller.signal.aborted) return;
|
|
525
|
+
setError(errorUtil.toError(e));
|
|
526
|
+
})
|
|
527
|
+
.finally(() => {
|
|
528
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
529
|
+
});
|
|
530
|
+
return () => controller.abort();
|
|
531
|
+
}, [client, enabled]);
|
|
532
|
+
|
|
533
|
+
return { models, loading, error };
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Fetch the curated starter questions for an agent's Genie space via
|
|
538
|
+
* `client.suggestions(agentId)`. These are the `sample_questions` an
|
|
539
|
+
* author configured on the space, surfaced as one-tap prompts on the
|
|
540
|
+
* chat empty state. The server returns an empty list when the agent
|
|
541
|
+
* has no Genie space, so `questions` stays empty in that case and the
|
|
542
|
+
* UI shows a bare empty state rather than built-in example prompts.
|
|
543
|
+
*
|
|
544
|
+
* Pass `enabled: false` to skip the fetch (e.g. when the caller
|
|
545
|
+
* supplies its own explicit suggestion list), in which case
|
|
546
|
+
* `questions` stays empty. Any fetch error degrades silently to no
|
|
547
|
+
* suggestions - they're a non-critical enhancement.
|
|
548
|
+
*/
|
|
549
|
+
export const useMastraSuggestions = (
|
|
550
|
+
agentId?: string,
|
|
551
|
+
enabled = true,
|
|
552
|
+
): { questions: string[]; loading: boolean } => {
|
|
553
|
+
const client = useMastraClient();
|
|
554
|
+
const [questions, setQuestions] = useState<string[]>([]);
|
|
555
|
+
const [loading, setLoading] = useState(enabled);
|
|
556
|
+
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
if (!enabled) {
|
|
559
|
+
setLoading(false);
|
|
560
|
+
setQuestions([]);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const controller = new AbortController();
|
|
564
|
+
setLoading(true);
|
|
565
|
+
client
|
|
566
|
+
.suggestions(agentId, controller.signal)
|
|
567
|
+
.then((qs) => {
|
|
568
|
+
if (controller.signal.aborted) return;
|
|
569
|
+
setQuestions(Array.isArray(qs) ? qs : []);
|
|
570
|
+
})
|
|
571
|
+
.catch(() => {
|
|
572
|
+
// Non-critical: a failed lookup just means no starter prompts.
|
|
573
|
+
if (!controller.signal.aborted) setQuestions([]);
|
|
574
|
+
})
|
|
575
|
+
.finally(() => {
|
|
576
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
577
|
+
});
|
|
578
|
+
return () => controller.abort();
|
|
579
|
+
}, [client, agentId, enabled]);
|
|
580
|
+
|
|
581
|
+
return { questions, loading };
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Fetch (and re-fetch) the caller's conversation threads for an agent
|
|
586
|
+
* via `client.threads(agentId)`. Drives the conversation list / sidebar
|
|
587
|
+
* so the user can switch between the threads they own. Newest first
|
|
588
|
+
* (`updatedAt` DESC), scoped server-side to the caller's resource.
|
|
589
|
+
*
|
|
590
|
+
* Returns a `refresh()` the chat driver calls after a turn completes
|
|
591
|
+
* (a new thread appears, or its auto-generated title lands) and after a
|
|
592
|
+
* clear / delete, so the list stays in sync without polling. Pass
|
|
593
|
+
* `enabled: false` to skip the fetch entirely (e.g. when the sidebar is
|
|
594
|
+
* hidden), in which case `threads` stays empty. A failed fetch degrades
|
|
595
|
+
* to an empty list - the conversation list is a non-critical
|
|
596
|
+
* enhancement layered over the always-available default thread.
|
|
597
|
+
*/
|
|
598
|
+
export const useMastraThreads = (
|
|
599
|
+
agentId?: string,
|
|
600
|
+
enabled = true,
|
|
601
|
+
): {
|
|
602
|
+
threads: MastraThread[];
|
|
603
|
+
loading: boolean;
|
|
604
|
+
error: Error | null;
|
|
605
|
+
refresh: () => void;
|
|
606
|
+
} => {
|
|
607
|
+
const client = useMastraClient();
|
|
608
|
+
const [threads, setThreads] = useState<MastraThread[]>([]);
|
|
609
|
+
const [loading, setLoading] = useState(enabled);
|
|
610
|
+
const [error, setError] = useState<Error | null>(null);
|
|
611
|
+
// Bumped by `refresh()` to force a re-fetch without changing any of
|
|
612
|
+
// the natural deps (client / agentId / enabled).
|
|
613
|
+
const [nonce, setNonce] = useState(0);
|
|
614
|
+
const refresh = useCallback(() => setNonce((n) => n + 1), []);
|
|
615
|
+
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
if (!enabled) {
|
|
618
|
+
setLoading(false);
|
|
619
|
+
setThreads([]);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const controller = new AbortController();
|
|
623
|
+
setLoading(true);
|
|
624
|
+
client
|
|
625
|
+
.threads({ agentId, signal: controller.signal })
|
|
626
|
+
.then((res) => {
|
|
627
|
+
if (controller.signal.aborted) return;
|
|
628
|
+
setThreads(res.threads);
|
|
629
|
+
setError(null);
|
|
630
|
+
})
|
|
631
|
+
.catch((e: unknown) => {
|
|
632
|
+
if (controller.signal.aborted || (e as { name?: string }).name === "AbortError")
|
|
633
|
+
return;
|
|
634
|
+
setError(errorUtil.toError(e));
|
|
635
|
+
setThreads([]);
|
|
636
|
+
})
|
|
637
|
+
.finally(() => {
|
|
638
|
+
if (!controller.signal.aborted) setLoading(false);
|
|
639
|
+
});
|
|
640
|
+
return () => controller.abort();
|
|
641
|
+
}, [client, agentId, enabled, nonce]);
|
|
642
|
+
|
|
643
|
+
return { threads, loading, error, refresh };
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Hook state for {@link useByIdFetch}. Generic over the
|
|
648
|
+
* resource's payload shape; consumers read `data` / `loading` /
|
|
649
|
+
* `error` exactly as the chart and statement slots do. A 404 on
|
|
650
|
+
* the server resolves as `data === undefined` with `error ===
|
|
651
|
+
* null` so the slot renders nothing (matches "expired /
|
|
652
|
+
* unknown id" semantics across all by-id resources).
|
|
653
|
+
*/
|
|
654
|
+
export interface ByIdFetchState<T> {
|
|
655
|
+
data: T | undefined;
|
|
656
|
+
loading: boolean;
|
|
657
|
+
error: Error | null;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Module-level by-key cache for `/<resource>/:id` fetches.
|
|
662
|
+
*
|
|
663
|
+
* Statements are immutable by construction (a `statement_id`
|
|
664
|
+
* always materializes the same rows), and a settled chart entry
|
|
665
|
+
* (`result` or `error` set) never changes either, so caching for
|
|
666
|
+
* the tab's lifetime is safe. The cache solves two problems the
|
|
667
|
+
* chat UI hits constantly:
|
|
668
|
+
*
|
|
669
|
+
* 1. Re-mount churn: streaming assistant text re-renders the
|
|
670
|
+
* markdown on every chunk, React StrictMode double-mounts
|
|
671
|
+
* effects in dev, and any parent re-render can shift slot
|
|
672
|
+
* identity. Without a cache, each remount cancels the
|
|
673
|
+
* in-flight fetch and starts another. With it, remounts hit
|
|
674
|
+
* the cache synchronously - zero network, zero spinner flash.
|
|
675
|
+
* 2. Concurrent consumers: two slots pointing at the same id
|
|
676
|
+
* (e.g. user toggles a thread that already rendered the
|
|
677
|
+
* same chart in history) dedupe to a single in-flight
|
|
678
|
+
* promise instead of racing parallel fetches.
|
|
679
|
+
*
|
|
680
|
+
* Two-state entry shape: `pending` while a fetch is in flight,
|
|
681
|
+
* `ready` once it settles (including 404 -> `data: undefined`).
|
|
682
|
+
* Errors are NOT cached - the entry is deleted on rejection so
|
|
683
|
+
* the next consumer retries. Per-consumer cancellation only
|
|
684
|
+
* detaches that consumer from the promise; the fetch keeps
|
|
685
|
+
* running for any other live consumer. Page reload clears
|
|
686
|
+
* everything. Keyed by `<type>:<id>` (e.g. `chart:abc123`).
|
|
687
|
+
*/
|
|
688
|
+
type FetchCacheEntry<T> =
|
|
689
|
+
| { kind: "pending"; promise: Promise<T | undefined> }
|
|
690
|
+
| { kind: "ready"; data: T | undefined };
|
|
691
|
+
|
|
692
|
+
const fetchCache = new Map<string, FetchCacheEntry<unknown>>();
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Resolve `key` through the by-id cache. See {@link fetchCache}
|
|
696
|
+
* for the lifecycle. `fetcher` performs the underlying single-shot
|
|
697
|
+
* request ({@link MastraPluginClient.chart} /
|
|
698
|
+
* {@link MastraPluginClient.statement}), resolving to `undefined` on a
|
|
699
|
+
* 404. `isTerminal` lets callers opt out of caching responses that
|
|
700
|
+
* aren't actually settled yet (e.g. a chart whose planner is still
|
|
701
|
+
* working): only `true` answers land as `kind: "ready"`, everything
|
|
702
|
+
* else is dropped so the next mount refetches.
|
|
703
|
+
*
|
|
704
|
+
* 404s always cache (as `data: undefined`) - unknown ids stay
|
|
705
|
+
* unknown for the tab's lifetime, mirroring the slot semantics
|
|
706
|
+
* ("expired / never minted -> render nothing").
|
|
707
|
+
*/
|
|
708
|
+
async function fetchByIdCached<T>(
|
|
709
|
+
key: string,
|
|
710
|
+
fetcher: () => Promise<T | undefined>,
|
|
711
|
+
isTerminal: (data: T) => boolean,
|
|
712
|
+
): Promise<T | undefined> {
|
|
713
|
+
const existing = fetchCache.get(key) as FetchCacheEntry<T> | undefined;
|
|
714
|
+
if (existing?.kind === "ready") return existing.data;
|
|
715
|
+
if (existing?.kind === "pending") return existing.promise;
|
|
716
|
+
|
|
717
|
+
const promise = (async (): Promise<T | undefined> => {
|
|
718
|
+
const data = await fetcher();
|
|
719
|
+
if (data === undefined) {
|
|
720
|
+
fetchCache.set(key, { kind: "ready", data: undefined });
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
723
|
+
if (isTerminal(data)) {
|
|
724
|
+
fetchCache.set(key, { kind: "ready", data });
|
|
725
|
+
} else {
|
|
726
|
+
fetchCache.delete(key);
|
|
727
|
+
}
|
|
728
|
+
return data;
|
|
729
|
+
})().catch((err: unknown) => {
|
|
730
|
+
fetchCache.delete(key);
|
|
731
|
+
throw err;
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
fetchCache.set(key, { kind: "pending", promise } as FetchCacheEntry<unknown>);
|
|
735
|
+
return promise;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Synchronous cache peek used to seed initial state on mount. */
|
|
739
|
+
function readFetchCache<T>(key: string | undefined): T | undefined {
|
|
740
|
+
if (!key) return undefined;
|
|
741
|
+
const e = fetchCache.get(key) as FetchCacheEntry<T> | undefined;
|
|
742
|
+
return e?.kind === "ready" ? e.data : undefined;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* React hook for any resource the Mastra plugin exposes as
|
|
747
|
+
* `/<resource>/:id`. Powers both the chart and statement slots
|
|
748
|
+
* in the chat UI; the slot-specific hooks below are thin
|
|
749
|
+
* adapters that pick the right cache `key`, `fetcher`, and
|
|
750
|
+
* terminal check.
|
|
751
|
+
*
|
|
752
|
+
* Cached through {@link fetchByIdCached} - re-mounts (StrictMode,
|
|
753
|
+
* markdown re-parses during streaming, parent re-renders) hit
|
|
754
|
+
* the cache synchronously instead of re-firing the network call.
|
|
755
|
+
* Per-consumer unmount only detaches that consumer from the
|
|
756
|
+
* shared promise; the underlying fetch keeps running for any
|
|
757
|
+
* other live consumer.
|
|
758
|
+
*
|
|
759
|
+
* `key`, `fetcher`, and `isTerminal` must be referentially stable
|
|
760
|
+
* (memoized by the caller) so the effect doesn't refire every render.
|
|
761
|
+
*/
|
|
762
|
+
function useByIdFetch<T>(
|
|
763
|
+
key: string | undefined,
|
|
764
|
+
fetcher: () => Promise<T | undefined>,
|
|
765
|
+
isTerminal: (data: T) => boolean,
|
|
766
|
+
): ByIdFetchState<T> {
|
|
767
|
+
const [data, setData] = useState<T | undefined>(() => readFetchCache<T>(key));
|
|
768
|
+
const [loading, setLoading] = useState(
|
|
769
|
+
() => key !== undefined && readFetchCache<T>(key) === undefined,
|
|
770
|
+
);
|
|
771
|
+
const [error, setError] = useState<Error | null>(null);
|
|
772
|
+
|
|
773
|
+
useEffect(() => {
|
|
774
|
+
if (!key) {
|
|
775
|
+
setData(undefined);
|
|
776
|
+
setLoading(false);
|
|
777
|
+
setError(null);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
const cached = readFetchCache<T>(key);
|
|
781
|
+
if (cached !== undefined) {
|
|
782
|
+
setData(cached);
|
|
783
|
+
setLoading(false);
|
|
784
|
+
setError(null);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
let cancelled = false;
|
|
788
|
+
setData(undefined);
|
|
789
|
+
setError(null);
|
|
790
|
+
setLoading(true);
|
|
791
|
+
|
|
792
|
+
fetchByIdCached<T>(key, fetcher, isTerminal)
|
|
793
|
+
.then((payload) => {
|
|
794
|
+
if (cancelled) return;
|
|
795
|
+
setData(payload);
|
|
796
|
+
setLoading(false);
|
|
797
|
+
})
|
|
798
|
+
.catch((e: unknown) => {
|
|
799
|
+
if (cancelled) return;
|
|
800
|
+
setError(errorUtil.toError(e));
|
|
801
|
+
setLoading(false);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
return () => {
|
|
805
|
+
cancelled = true;
|
|
806
|
+
};
|
|
807
|
+
}, [key, fetcher, isTerminal]);
|
|
808
|
+
|
|
809
|
+
return { data, loading, error };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* `isTerminal` for {@link useChartFetch}. A chart entry is
|
|
814
|
+
* "ready to cache forever" only once the planner has either
|
|
815
|
+
* landed an Echarts spec (`result`) or surfaced a failure
|
|
816
|
+
* (`error`). Entries with neither field are pathologically slow
|
|
817
|
+
* planners that exceeded the server's long-poll budget; leaving
|
|
818
|
+
* them uncached means a re-mount can retry instead of being
|
|
819
|
+
* stuck on a stale "in-flight" snapshot for the tab's lifetime.
|
|
820
|
+
*
|
|
821
|
+
* Module-level so the {@link useByIdFetch} effect's dep array
|
|
822
|
+
* stays stable - inlining a fresh closure would refire the
|
|
823
|
+
* effect on every render.
|
|
824
|
+
*/
|
|
825
|
+
const chartIsTerminal = (c: Chart): boolean =>
|
|
826
|
+
c.result !== undefined || c.error !== undefined;
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* `isTerminal` for {@link useStatementFetch}. A statement
|
|
830
|
+
* response is always terminal - the Statement Execution API
|
|
831
|
+
* returns the rows in one shot, so any 200 is cacheable.
|
|
832
|
+
*/
|
|
833
|
+
const statementIsTerminal = (_: StatementData): boolean => true;
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Fetch a chart by id from the Mastra plugin's generic
|
|
837
|
+
* `/embed/chart/:id` endpoint via {@link MastraPluginClient.chart}.
|
|
838
|
+
* Used by the chat UI to resolve `[chart:<chartId>]` markers the agent
|
|
839
|
+
* embeds in prose.
|
|
840
|
+
*
|
|
841
|
+
* The chart planner runs in the background after `prepare_chart`
|
|
842
|
+
* mints the id, so the server long-polls the cache until the
|
|
843
|
+
* entry settles (`result` or `error` set) and returns the final
|
|
844
|
+
* payload in a single response. The hook performs ONE cached
|
|
845
|
+
* fetch and surfaces:
|
|
846
|
+
*
|
|
847
|
+
* - `data.result` set → render the Echarts spec.
|
|
848
|
+
* - `data.error` set → surface via `data.error`.
|
|
849
|
+
* - `data` with neither field (planner exceeded the server's
|
|
850
|
+
* long-poll budget) → render nothing; this answer is NOT
|
|
851
|
+
* cached, so a re-mount will retry.
|
|
852
|
+
* - `data === undefined` (404) → unknown / expired id; render
|
|
853
|
+
* nothing.
|
|
854
|
+
*/
|
|
855
|
+
export const useChartFetch = (chartId: string | undefined): ByIdFetchState<Chart> => {
|
|
856
|
+
const client = useMastraClient();
|
|
857
|
+
const key = chartId ? `chart:${chartId}` : undefined;
|
|
858
|
+
const fetcher = useCallback(() => client.chart(chartId as string), [client, chartId]);
|
|
859
|
+
return useByIdFetch<Chart>(key, fetcher, chartIsTerminal);
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Fetch the rows of a Databricks statement by id from the Mastra
|
|
864
|
+
* plugin's generic `/embed/data/:id` endpoint via
|
|
865
|
+
* {@link MastraPluginClient.statement}. Used by the chat UI to resolve
|
|
866
|
+
* `[data:<statement_id>]` markers the agent embeds in prose.
|
|
867
|
+
* Server-side, the route reuses the `get_statement` tool's fetch +
|
|
868
|
+
* coercion pipeline so the shape matches what the LLM saw for the same
|
|
869
|
+
* statement; `data.truncated` signals the server clipped to its row
|
|
870
|
+
* cap. Cached for the tab's lifetime because a `statement_id`
|
|
871
|
+
* materializes the same rows forever.
|
|
872
|
+
*/
|
|
873
|
+
export const useStatementFetch = (
|
|
874
|
+
statementId: string | undefined,
|
|
875
|
+
): ByIdFetchState<StatementData> => {
|
|
876
|
+
const client = useMastraClient();
|
|
877
|
+
const key = statementId ? `data:${statementId}` : undefined;
|
|
878
|
+
const fetcher = useCallback(
|
|
879
|
+
() => client.statement(statementId as string),
|
|
880
|
+
[client, statementId],
|
|
881
|
+
);
|
|
882
|
+
return useByIdFetch<StatementData>(key, fetcher, statementIsTerminal);
|
|
883
|
+
};
|