@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,428 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Avatar,
|
|
3
|
+
AvatarFallback,
|
|
4
|
+
Button,
|
|
5
|
+
Collapsible,
|
|
6
|
+
CollapsibleContent,
|
|
7
|
+
CollapsibleTrigger,
|
|
8
|
+
Item,
|
|
9
|
+
ItemContent,
|
|
10
|
+
ItemMedia,
|
|
11
|
+
Tooltip,
|
|
12
|
+
TooltipContent,
|
|
13
|
+
TooltipTrigger,
|
|
14
|
+
} from "@dbx-tools/ui-appkit/react";
|
|
15
|
+
import type { UIMessage } from "ai";
|
|
16
|
+
import {
|
|
17
|
+
CheckIcon,
|
|
18
|
+
ChevronDownIcon,
|
|
19
|
+
CopyIcon,
|
|
20
|
+
MessageSquareIcon,
|
|
21
|
+
RefreshCcwIcon,
|
|
22
|
+
SparklesIcon,
|
|
23
|
+
UserIcon,
|
|
24
|
+
XIcon,
|
|
25
|
+
} from "lucide-react";
|
|
26
|
+
import { useState } from "react";
|
|
27
|
+
import { MarkdownWithEmbeds } from "./embed-slots";
|
|
28
|
+
import { ExportMenu } from "./export-menu";
|
|
29
|
+
import { FeedbackControls } from "./feedback-controls";
|
|
30
|
+
import { SuggestionPills } from "./suggestion-pills";
|
|
31
|
+
import { collectSuggestions } from "./suggestions";
|
|
32
|
+
import { ToolSessionPill, humanizeToolName } from "./tool-pill";
|
|
33
|
+
import type {
|
|
34
|
+
ApprovalDecision,
|
|
35
|
+
ChatStatus,
|
|
36
|
+
ExportFormat,
|
|
37
|
+
FeedbackSubmission,
|
|
38
|
+
FeedbackValue,
|
|
39
|
+
PendingApproval,
|
|
40
|
+
ToolEvent,
|
|
41
|
+
} from "./types";
|
|
42
|
+
|
|
43
|
+
// User / assistant message bubbles plus the inline approval card and
|
|
44
|
+
// the helpers that surface approval-gated tool calls out of a message's
|
|
45
|
+
// parts.
|
|
46
|
+
|
|
47
|
+
const getReasoningText = (parts: UIMessage["parts"]): string =>
|
|
48
|
+
parts
|
|
49
|
+
.filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning")
|
|
50
|
+
.map((p) => p.text)
|
|
51
|
+
.join("\n\n");
|
|
52
|
+
|
|
53
|
+
const RoleAvatar = ({ role }: { role: UIMessage["role"] }) => (
|
|
54
|
+
<Avatar className="size-7">
|
|
55
|
+
<AvatarFallback>
|
|
56
|
+
{role === "assistant" ? (
|
|
57
|
+
<SparklesIcon className="size-4" />
|
|
58
|
+
) : (
|
|
59
|
+
<UserIcon className="size-4" />
|
|
60
|
+
)}
|
|
61
|
+
</AvatarFallback>
|
|
62
|
+
</Avatar>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Inline approval prompt rendered when a `requireApproval: true` tool is
|
|
67
|
+
* paused in the agent loop. Approve / deny flows through
|
|
68
|
+
* {@link ApprovalDecision} to the parent resume handler.
|
|
69
|
+
*/
|
|
70
|
+
const ToolApprovalCard = ({
|
|
71
|
+
toolName,
|
|
72
|
+
toolCallId,
|
|
73
|
+
runId,
|
|
74
|
+
input,
|
|
75
|
+
onResolve,
|
|
76
|
+
disabled,
|
|
77
|
+
}: {
|
|
78
|
+
toolName: string;
|
|
79
|
+
toolCallId: string;
|
|
80
|
+
runId?: string;
|
|
81
|
+
input: unknown;
|
|
82
|
+
onResolve?: (decision: ApprovalDecision) => void | Promise<void>;
|
|
83
|
+
disabled?: boolean;
|
|
84
|
+
}) => {
|
|
85
|
+
const [pending, setPending] = useState(false);
|
|
86
|
+
const [decision, setDecision] = useState<"approved" | "denied" | null>(null);
|
|
87
|
+
const [expired, setExpired] = useState(false);
|
|
88
|
+
const handle = (approved: boolean) => {
|
|
89
|
+
if (!onResolve || pending || decision) return;
|
|
90
|
+
setPending(true);
|
|
91
|
+
setDecision(approved ? "approved" : "denied");
|
|
92
|
+
Promise.resolve(
|
|
93
|
+
approved
|
|
94
|
+
? onResolve({ approved: true, toolName, toolCallId, runId, input })
|
|
95
|
+
: onResolve({
|
|
96
|
+
approved: false,
|
|
97
|
+
toolName,
|
|
98
|
+
toolCallId,
|
|
99
|
+
runId,
|
|
100
|
+
input,
|
|
101
|
+
reason: "User denied the request from the chat UI.",
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
.catch(() => setExpired(true))
|
|
105
|
+
.finally(() => setPending(false));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="not-prose my-2 rounded-md border border-warning/40 bg-warning/5 p-3">
|
|
110
|
+
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-warning">
|
|
111
|
+
<MessageSquareIcon className="size-3.5" />
|
|
112
|
+
<span>Approval needed: {humanizeToolName(toolName)}</span>
|
|
113
|
+
</div>
|
|
114
|
+
<pre className="overflow-x-auto rounded bg-background/40 p-2 text-[11px]">
|
|
115
|
+
{JSON.stringify(input, null, 2)}
|
|
116
|
+
</pre>
|
|
117
|
+
{expired ? (
|
|
118
|
+
<div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
119
|
+
<MessageSquareIcon className="size-3.5" />
|
|
120
|
+
<span>This approval is no longer available.</span>
|
|
121
|
+
</div>
|
|
122
|
+
) : decision ? (
|
|
123
|
+
<div className="mt-3 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
124
|
+
{decision === "approved" ? (
|
|
125
|
+
<CheckIcon className="size-3.5 text-success" />
|
|
126
|
+
) : (
|
|
127
|
+
<XIcon className="size-3.5 text-destructive" />
|
|
128
|
+
)}
|
|
129
|
+
<span>
|
|
130
|
+
{decision === "approved"
|
|
131
|
+
? pending
|
|
132
|
+
? "Approving…"
|
|
133
|
+
: "Approved"
|
|
134
|
+
: pending
|
|
135
|
+
? "Denying…"
|
|
136
|
+
: "Denied"}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
) : (
|
|
140
|
+
<div className="mt-3 flex items-center gap-2">
|
|
141
|
+
<Button
|
|
142
|
+
type="button"
|
|
143
|
+
size="sm"
|
|
144
|
+
variant="default"
|
|
145
|
+
disabled={disabled || pending || !onResolve}
|
|
146
|
+
onClick={() => handle(true)}
|
|
147
|
+
>
|
|
148
|
+
<CheckIcon className="size-3" />
|
|
149
|
+
Approve
|
|
150
|
+
</Button>
|
|
151
|
+
<Button
|
|
152
|
+
type="button"
|
|
153
|
+
size="sm"
|
|
154
|
+
variant="outline"
|
|
155
|
+
disabled={disabled || pending || !onResolve}
|
|
156
|
+
onClick={() => handle(false)}
|
|
157
|
+
>
|
|
158
|
+
<XIcon className="size-3" />
|
|
159
|
+
Deny
|
|
160
|
+
</Button>
|
|
161
|
+
{!onResolve && (
|
|
162
|
+
<span className="ml-1 text-[11px] text-muted-foreground">
|
|
163
|
+
(approval handler not wired on this page)
|
|
164
|
+
</span>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Pull every approval-pending tool call out of an assistant message's
|
|
174
|
+
* `data-tool-call-approval` parts.
|
|
175
|
+
*/
|
|
176
|
+
const collectPendingApprovals = (parts: UIMessage["parts"]): PendingApproval[] => {
|
|
177
|
+
const resolvedToolCallIds = new Set<string>();
|
|
178
|
+
for (const part of parts ?? []) {
|
|
179
|
+
const p = part as { toolCallId?: unknown; state?: unknown };
|
|
180
|
+
if (typeof p.toolCallId !== "string") continue;
|
|
181
|
+
if (p.state === "output-available" || p.state === "output-error") {
|
|
182
|
+
resolvedToolCallIds.add(p.toolCallId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const out: PendingApproval[] = [];
|
|
187
|
+
for (const part of parts ?? []) {
|
|
188
|
+
const type = (part as { type?: unknown }).type;
|
|
189
|
+
if (type !== "data-tool-call-approval") continue;
|
|
190
|
+
const data = (part as { data?: unknown }).data;
|
|
191
|
+
if (!data || typeof data !== "object") continue;
|
|
192
|
+
const d = data as {
|
|
193
|
+
toolName?: unknown;
|
|
194
|
+
toolCallId?: unknown;
|
|
195
|
+
runId?: unknown;
|
|
196
|
+
args?: unknown;
|
|
197
|
+
};
|
|
198
|
+
if (typeof d.toolName !== "string") continue;
|
|
199
|
+
if (typeof d.toolCallId !== "string") continue;
|
|
200
|
+
if (resolvedToolCallIds.has(d.toolCallId)) continue;
|
|
201
|
+
out.push({
|
|
202
|
+
toolName: d.toolName,
|
|
203
|
+
toolCallId: d.toolCallId,
|
|
204
|
+
runId: typeof d.runId === "string" ? d.runId : undefined,
|
|
205
|
+
input: d.args,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Merge inline approvals (from `message.parts`) with out-of-band
|
|
213
|
+
* approvals supplied by the parent (used by the `/stream` page,
|
|
214
|
+
* which doesn't store approvals as data parts). De-dupes by
|
|
215
|
+
* `toolCallId` so a card never appears twice.
|
|
216
|
+
*/
|
|
217
|
+
const mergePendingApprovals = (
|
|
218
|
+
fromParts: PendingApproval[],
|
|
219
|
+
fromExternal: PendingApproval[] | undefined,
|
|
220
|
+
): PendingApproval[] => {
|
|
221
|
+
if (!fromExternal || fromExternal.length === 0) return fromParts;
|
|
222
|
+
const seen = new Set(fromParts.map((a) => a.toolCallId));
|
|
223
|
+
return [...fromParts, ...fromExternal.filter((a) => !seen.has(a.toolCallId))];
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
type AssistantBubbleProps = {
|
|
227
|
+
message: UIMessage;
|
|
228
|
+
isLast: boolean;
|
|
229
|
+
status: ChatStatus;
|
|
230
|
+
events?: ToolEvent[];
|
|
231
|
+
regenerate?: () => void;
|
|
232
|
+
/** Click handler for tool-suggested follow-up questions. */
|
|
233
|
+
onSuggestionClick?: (question: string) => void;
|
|
234
|
+
/** Approve / deny handler for inline {@link ToolApprovalCard}s. */
|
|
235
|
+
onResolveToolApproval?: (decision: ApprovalDecision) => void | Promise<void>;
|
|
236
|
+
/**
|
|
237
|
+
* Out-of-band approvals for this specific message (Stream page).
|
|
238
|
+
* Merged with inline `data-tool-call-approval` parts.
|
|
239
|
+
*/
|
|
240
|
+
externalApprovals?: PendingApproval[];
|
|
241
|
+
/**
|
|
242
|
+
* Export this message in the chosen format. When set, the bubble's
|
|
243
|
+
* action row shows a per-message export menu (charts / tables inlined).
|
|
244
|
+
*/
|
|
245
|
+
onExport?: (format: ExportFormat) => void;
|
|
246
|
+
/**
|
|
247
|
+
* Submit thumbs / comment feedback for this message. When set, the
|
|
248
|
+
* action row shows the feedback controls; only wired by the parent
|
|
249
|
+
* when MLflow logging is enabled and the turn has a captured trace id.
|
|
250
|
+
*/
|
|
251
|
+
onFeedback?: (submission: FeedbackSubmission) => void | Promise<void>;
|
|
252
|
+
/** Last thumbs the user left on this message, for highlighting. */
|
|
253
|
+
feedbackValue?: FeedbackValue;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export const AssistantBubble = ({
|
|
257
|
+
message,
|
|
258
|
+
isLast,
|
|
259
|
+
status,
|
|
260
|
+
events,
|
|
261
|
+
regenerate,
|
|
262
|
+
onSuggestionClick,
|
|
263
|
+
onResolveToolApproval,
|
|
264
|
+
externalApprovals,
|
|
265
|
+
onExport,
|
|
266
|
+
onFeedback,
|
|
267
|
+
feedbackValue,
|
|
268
|
+
}: AssistantBubbleProps) => {
|
|
269
|
+
const reasoning = getReasoningText(message.parts);
|
|
270
|
+
const isReasoningStreaming =
|
|
271
|
+
isLast && status === "streaming" && message.parts.at(-1)?.type === "reasoning";
|
|
272
|
+
const textParts = message.parts.filter(
|
|
273
|
+
(p): p is { type: "text"; text: string } => p.type === "text",
|
|
274
|
+
);
|
|
275
|
+
const fullText = textParts.map((p) => p.text).join("");
|
|
276
|
+
const hasText = fullText.length > 0;
|
|
277
|
+
// Suggestions are deferred until the turn is settled so they don't
|
|
278
|
+
// pop in mid-stream. A bubble is "settled" if it isn't the active
|
|
279
|
+
// streaming target - either the agent has returned to `ready` /
|
|
280
|
+
// `error`, or a newer message has taken over the `isLast` slot.
|
|
281
|
+
const isStreamingThisBubble =
|
|
282
|
+
isLast && (status === "streaming" || status === "submitted");
|
|
283
|
+
const suggestions = isStreamingThisBubble ? [] : collectSuggestions(events);
|
|
284
|
+
// Charts and tables are placed at inline marker positions in
|
|
285
|
+
// the assistant's prose. `prepare_chart` / `render_data` mint
|
|
286
|
+
// a `chartId` and the model embeds `[chart:<chartId>]`; for raw
|
|
287
|
+
// data the model embeds `[data:<statement_id>]` and the host UI fetches the
|
|
288
|
+
// rows on its own. {@link MarkdownWithEmbeds} splits the text
|
|
289
|
+
// on both markers and renders {@link ChartSlot} /
|
|
290
|
+
// {@link DataSlot} inline; unknown / expired ids resolve as
|
|
291
|
+
// nothing so the prose flows unaffected. Suggested questions
|
|
292
|
+
// stay gated on settle to avoid pop-in mid-stream.
|
|
293
|
+
const pendingApprovals = mergePendingApprovals(
|
|
294
|
+
collectPendingApprovals(message.parts),
|
|
295
|
+
externalApprovals,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<Item className="items-start gap-3 border-none bg-transparent p-0">
|
|
300
|
+
<ItemMedia>
|
|
301
|
+
<RoleAvatar role="assistant" />
|
|
302
|
+
</ItemMedia>
|
|
303
|
+
{/*
|
|
304
|
+
* `min-w-0` is required so wide content (markdown tables, long
|
|
305
|
+
* code blocks) can shrink to fit instead of pushing the whole
|
|
306
|
+
* bubble past the chat's `max-w-4xl`. Flex children default to
|
|
307
|
+
* `min-width: auto`, which sizes to content and overflows.
|
|
308
|
+
*/}
|
|
309
|
+
<ItemContent className="min-w-0 gap-2">
|
|
310
|
+
{/*
|
|
311
|
+
* Tool session pill leads the message. Charts and tables are
|
|
312
|
+
* embedded inline in the prose (via `[chart:<id>]` / `[data:<id>]`
|
|
313
|
+
* markers rendered by {@link MarkdownWithEmbeds}), so keeping the
|
|
314
|
+
* pill above the text parts guarantees the "what the agent did"
|
|
315
|
+
* summary always sits before its resulting charts and answer.
|
|
316
|
+
*/}
|
|
317
|
+
{events && events.length > 0 && <ToolSessionPill events={events} />}
|
|
318
|
+
{reasoning && (
|
|
319
|
+
<Collapsible defaultOpen={isReasoningStreaming}>
|
|
320
|
+
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
|
321
|
+
<ChevronDownIcon className="size-3" />
|
|
322
|
+
<span>{isReasoningStreaming ? "Thinking..." : "Thoughts"}</span>
|
|
323
|
+
</CollapsibleTrigger>
|
|
324
|
+
<CollapsibleContent>
|
|
325
|
+
<div className="mt-1 border-l-2 border-border/60 pl-3 text-xs text-muted-foreground whitespace-pre-wrap">
|
|
326
|
+
{reasoning}
|
|
327
|
+
</div>
|
|
328
|
+
</CollapsibleContent>
|
|
329
|
+
</Collapsible>
|
|
330
|
+
)}
|
|
331
|
+
{/*
|
|
332
|
+
* Render each text part as its own block. A multi-step turn
|
|
333
|
+
* carries one text part per step (see Stream.tsx segmentation),
|
|
334
|
+
* so this keeps each step's preamble visually separate instead
|
|
335
|
+
* of concatenating them into a single run of prose.
|
|
336
|
+
*/}
|
|
337
|
+
{textParts.map((part, i) =>
|
|
338
|
+
part.text.trim().length > 0 ? (
|
|
339
|
+
<MarkdownWithEmbeds
|
|
340
|
+
key={`text-${i}`}
|
|
341
|
+
text={part.text}
|
|
342
|
+
streaming={isStreamingThisBubble}
|
|
343
|
+
/>
|
|
344
|
+
) : null,
|
|
345
|
+
)}
|
|
346
|
+
{pendingApprovals.map((p) => (
|
|
347
|
+
<ToolApprovalCard
|
|
348
|
+
key={p.toolCallId}
|
|
349
|
+
toolName={p.toolName}
|
|
350
|
+
toolCallId={p.toolCallId}
|
|
351
|
+
runId={p.runId}
|
|
352
|
+
input={p.input}
|
|
353
|
+
onResolve={onResolveToolApproval}
|
|
354
|
+
/>
|
|
355
|
+
))}
|
|
356
|
+
{hasText && (isLast || onExport || onFeedback) && (
|
|
357
|
+
<div className="flex items-center gap-1">
|
|
358
|
+
{isLast && regenerate && (
|
|
359
|
+
<Tooltip>
|
|
360
|
+
<TooltipTrigger asChild>
|
|
361
|
+
<Button
|
|
362
|
+
type="button"
|
|
363
|
+
size="icon"
|
|
364
|
+
variant="ghost"
|
|
365
|
+
className="size-7"
|
|
366
|
+
onClick={() => regenerate()}
|
|
367
|
+
>
|
|
368
|
+
<RefreshCcwIcon className="size-3" />
|
|
369
|
+
</Button>
|
|
370
|
+
</TooltipTrigger>
|
|
371
|
+
<TooltipContent>Retry</TooltipContent>
|
|
372
|
+
</Tooltip>
|
|
373
|
+
)}
|
|
374
|
+
{isLast && (
|
|
375
|
+
<Tooltip>
|
|
376
|
+
<TooltipTrigger asChild>
|
|
377
|
+
<Button
|
|
378
|
+
type="button"
|
|
379
|
+
size="icon"
|
|
380
|
+
variant="ghost"
|
|
381
|
+
className="size-7"
|
|
382
|
+
onClick={() => navigator.clipboard.writeText(fullText)}
|
|
383
|
+
>
|
|
384
|
+
<CopyIcon className="size-3" />
|
|
385
|
+
</Button>
|
|
386
|
+
</TooltipTrigger>
|
|
387
|
+
<TooltipContent>Copy</TooltipContent>
|
|
388
|
+
</Tooltip>
|
|
389
|
+
)}
|
|
390
|
+
{onExport && (
|
|
391
|
+
<ExportMenu onExport={onExport} iconOnly tooltip="Export message" />
|
|
392
|
+
)}
|
|
393
|
+
{onFeedback && (
|
|
394
|
+
<FeedbackControls
|
|
395
|
+
onSubmit={onFeedback}
|
|
396
|
+
{...(feedbackValue ? { value: feedbackValue } : {})}
|
|
397
|
+
/>
|
|
398
|
+
)}
|
|
399
|
+
</div>
|
|
400
|
+
)}
|
|
401
|
+
<SuggestionPills
|
|
402
|
+
questions={suggestions}
|
|
403
|
+
onSelect={onSuggestionClick}
|
|
404
|
+
className="mt-1"
|
|
405
|
+
/>
|
|
406
|
+
</ItemContent>
|
|
407
|
+
</Item>
|
|
408
|
+
);
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
export const UserBubble = ({ message }: { message: UIMessage }) => {
|
|
412
|
+
const text = message.parts
|
|
413
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
414
|
+
.map((p) => p.text)
|
|
415
|
+
.join("");
|
|
416
|
+
return (
|
|
417
|
+
<Item className="items-start gap-3 border-none bg-transparent p-0">
|
|
418
|
+
<ItemMedia>
|
|
419
|
+
<RoleAvatar role="user" />
|
|
420
|
+
</ItemMedia>
|
|
421
|
+
<ItemContent className="min-w-0">
|
|
422
|
+
<div className="rounded-lg bg-muted px-3 py-2 text-sm whitespace-pre-wrap break-words">
|
|
423
|
+
{text}
|
|
424
|
+
</div>
|
|
425
|
+
</ItemContent>
|
|
426
|
+
</Item>
|
|
427
|
+
);
|
|
428
|
+
};
|