@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,342 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
DropdownMenu,
|
|
4
|
+
DropdownMenuCheckboxItem,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuTrigger,
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableRow,
|
|
13
|
+
cn,
|
|
14
|
+
} from "@dbx-tools/ui-appkit/react";
|
|
15
|
+
import { string } from "@dbx-tools/shared-core";
|
|
16
|
+
import {
|
|
17
|
+
flexRender,
|
|
18
|
+
getCoreRowModel,
|
|
19
|
+
getSortedRowModel,
|
|
20
|
+
useReactTable,
|
|
21
|
+
type ColumnDef,
|
|
22
|
+
type SortingState,
|
|
23
|
+
type VisibilityState,
|
|
24
|
+
} from "@tanstack/react-table";
|
|
25
|
+
import {
|
|
26
|
+
ArrowDownIcon,
|
|
27
|
+
ArrowUpIcon,
|
|
28
|
+
ChevronsUpDownIcon,
|
|
29
|
+
Columns3Icon,
|
|
30
|
+
DownloadIcon,
|
|
31
|
+
} from "lucide-react";
|
|
32
|
+
import React, { useMemo, useState } from "react";
|
|
33
|
+
|
|
34
|
+
// Interactive result table plus the cell/label/CSV helpers it and the
|
|
35
|
+
// markdown table renderer share. Built on `@tanstack/react-table` over
|
|
36
|
+
// AppKit's `Table` primitives so every table in the chat - statement
|
|
37
|
+
// results and lifted markdown tables alike - reads as the same block.
|
|
38
|
+
|
|
39
|
+
/** A statement row: column name -> cell value, as `StatementData.rows` arrives. */
|
|
40
|
+
export type DataRow = Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Color-code numeric deltas like `+1.8%`, `-3.1%`, or `+0.6 pts` inside
|
|
44
|
+
* a single table cell. Matches the *first* signed numeric token in the
|
|
45
|
+
* cell; if no match, returns the children unchanged.
|
|
46
|
+
*
|
|
47
|
+
* Patterns recognized (case insensitive, allow comma/decimal):
|
|
48
|
+
* +1.8% -3.1% +0.6 pts -0.9 pts
|
|
49
|
+
*/
|
|
50
|
+
const DELTA_PATTERN = /^([+\u2212-])\s*\d[\d,.\s]*(?:%|\s*pts?)?$/i;
|
|
51
|
+
|
|
52
|
+
export function colorizeDelta(content: React.ReactNode): React.ReactNode {
|
|
53
|
+
if (typeof content !== "string") return content;
|
|
54
|
+
const text = content.trim();
|
|
55
|
+
const match = DELTA_PATTERN.exec(text);
|
|
56
|
+
if (!match) return content;
|
|
57
|
+
const sign = match[1];
|
|
58
|
+
if (sign === "+") return <span className="font-medium text-success">{content}</span>;
|
|
59
|
+
if (sign === "-" || sign === "\u2212")
|
|
60
|
+
return <span className="font-medium text-destructive">{content}</span>;
|
|
61
|
+
return content;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Render a cell value: blank for nullish, otherwise the string form
|
|
66
|
+
* run through {@link colorizeDelta} so signed deltas keep their
|
|
67
|
+
* green/red treatment.
|
|
68
|
+
*/
|
|
69
|
+
export function renderDataCell(value: unknown): React.ReactNode {
|
|
70
|
+
return colorizeDelta(value == null ? "" : String(value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Turn a raw statement column name into a human-readable header by
|
|
75
|
+
* tokenizing it (camelCase / snake_case / kebab / etc. all split) and
|
|
76
|
+
* Title-Casing each token: `total_revenue` -> "Total Revenue",
|
|
77
|
+
* `aiScore` -> "AI Score" (the tokenizer special-cases `ai`). Falls
|
|
78
|
+
* back to the original string when tokenization yields nothing (e.g. a
|
|
79
|
+
* punctuation-only column name). The raw name is still used as the
|
|
80
|
+
* column id, accessor key, and CSV header, so only the on-screen label
|
|
81
|
+
* is prettified.
|
|
82
|
+
*/
|
|
83
|
+
export function humanizeLabel(
|
|
84
|
+
value: string,
|
|
85
|
+
options?: string.TokenizeOptions,
|
|
86
|
+
): string {
|
|
87
|
+
const tokens = [
|
|
88
|
+
...string.tokenizeWithOptions(
|
|
89
|
+
{ lowerCase: true, capitalize: true, ...options },
|
|
90
|
+
value,
|
|
91
|
+
),
|
|
92
|
+
];
|
|
93
|
+
return tokens.length > 0 ? tokens.join(" ") : value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Serialize already-ordered `rows` to a CSV over `columns` and trigger
|
|
98
|
+
* a browser download. Fields are quoted only when they contain a
|
|
99
|
+
* comma, double-quote, or newline (RFC-4180 minimal quoting); embedded
|
|
100
|
+
* quotes are doubled. The blob URL is revoked right after the click so
|
|
101
|
+
* we don't leak object URLs across repeated exports.
|
|
102
|
+
*/
|
|
103
|
+
function downloadCsv(columns: string[], rows: DataRow[], filename: string): void {
|
|
104
|
+
const escape = (value: unknown): string => {
|
|
105
|
+
const s = value == null ? "" : String(value);
|
|
106
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
107
|
+
};
|
|
108
|
+
const csv = [
|
|
109
|
+
columns.map(escape).join(","),
|
|
110
|
+
...rows.map((row) => columns.map((c) => escape(row[c])).join(",")),
|
|
111
|
+
].join("\n");
|
|
112
|
+
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8;" }));
|
|
113
|
+
const anchor = document.createElement("a");
|
|
114
|
+
anchor.href = url;
|
|
115
|
+
anchor.download = filename;
|
|
116
|
+
anchor.click();
|
|
117
|
+
URL.revokeObjectURL(url);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Wrapper class layered on every chat table (markdown + statement
|
|
122
|
+
* results). It frames the table as a distinct card so it reads as a
|
|
123
|
+
* separate block in the conversation. AppKit's `Table` family already
|
|
124
|
+
* owns row borders, hover, and color tokens - on top of that we add:
|
|
125
|
+
* - `not-prose` to escape `@tailwindcss/typography`'s table styles
|
|
126
|
+
* (margins, font-weight, etc.) which fight the AppKit defaults
|
|
127
|
+
* - a `rounded-lg` border + `bg-card` + `shadow-sm` card frame with
|
|
128
|
+
* `overflow-hidden` so the rounded corners clip the scroll area
|
|
129
|
+
* - compact `text-xs` + `tabular-nums` so columns of numbers align
|
|
130
|
+
* - right-alignment for every column except the first label column
|
|
131
|
+
* - a `max-h-[60vh]` vertical cap so tall tables scroll their body
|
|
132
|
+
* instead of running off the viewport, plus `overflow-auto` so wide
|
|
133
|
+
* tables scroll horizontally *inside* the card rather than pushing
|
|
134
|
+
* the chat past its max width. AppKit's `Table` nests the `<table>`
|
|
135
|
+
* in its own scroll container (`<div>` - the wrapper's only child),
|
|
136
|
+
* so the cap + overflow land there, making it the scroll ancestor
|
|
137
|
+
* the sticky header pins to.
|
|
138
|
+
* - a prominent, sticky header: tinted (`bg-muted`), bold, opaque
|
|
139
|
+
* (so rows don't bleed through while scrolling), and divided from
|
|
140
|
+
* the body with a bottom border.
|
|
141
|
+
*/
|
|
142
|
+
export const TABLE_WRAPPER_CLASSES = cn(
|
|
143
|
+
// Card-like frame so each table reads as a distinct block in the
|
|
144
|
+
// chat rather than bleeding into the surrounding prose.
|
|
145
|
+
"not-prose my-4 max-w-full overflow-hidden rounded-lg border border-border bg-card shadow-sm",
|
|
146
|
+
"text-xs tabular-nums",
|
|
147
|
+
"[&_th:not(:first-child)]:text-right [&_td:not(:first-child)]:text-right",
|
|
148
|
+
// The inner AppKit scroll container is the scroll surface for both
|
|
149
|
+
// axes; cap its height so tall tables scroll their body in place.
|
|
150
|
+
"[&>div]:max-h-[60vh] [&>div]:overflow-auto",
|
|
151
|
+
// Make the header read as a header: opaque, tinted, bold, and pinned
|
|
152
|
+
// to the top of the scroll container with a divider beneath it.
|
|
153
|
+
"[&_thead_th]:sticky [&_thead_th]:top-0 [&_thead_th]:z-10",
|
|
154
|
+
"[&_thead_th]:bg-muted [&_thead_th]:font-semibold [&_thead_th]:text-foreground",
|
|
155
|
+
"[&_thead_th]:border-b [&_thead_th]:border-border",
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Card frame for {@link DataGrid} - the same rounded/bordered/elevated
|
|
160
|
+
* treatment as {@link TABLE_WRAPPER_CLASSES} so interactive statement
|
|
161
|
+
* tables and static markdown tables read as the same kind of block.
|
|
162
|
+
*/
|
|
163
|
+
const DATA_GRID_CARD_CLASSES = cn(
|
|
164
|
+
"not-prose my-4 max-w-full overflow-hidden rounded-lg border border-border bg-card shadow-sm",
|
|
165
|
+
"text-xs tabular-nums",
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/** Toolbar strip across the top of a {@link DataGrid} card. */
|
|
169
|
+
const DATA_GRID_TOOLBAR_CLASSES = cn(
|
|
170
|
+
"flex items-center justify-between gap-2",
|
|
171
|
+
"border-b border-border bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Scroll surface wrapping the {@link DataGrid} table. AppKit's `Table`
|
|
176
|
+
* nests its `<table>` inside its own scroll container `<div>` (this
|
|
177
|
+
* wrapper's only child), so the height cap + `overflow` must land on
|
|
178
|
+
* THAT div (`[&>div]`) - not this wrapper - to make it the single
|
|
179
|
+
* scroll box. Otherwise the inner container becomes a nested scroll
|
|
180
|
+
* box the sticky header pins to, and the header rides up with the body
|
|
181
|
+
* as the outer wrapper scrolls. With the cap on the inner div the
|
|
182
|
+
* sticky header pins to it and stays put. Header cells are tinted,
|
|
183
|
+
* bold, and opaque so rows don't bleed through while scrolling.
|
|
184
|
+
*/
|
|
185
|
+
const DATA_GRID_SCROLL_CLASSES = cn(
|
|
186
|
+
"[&>div]:max-h-[60vh] [&>div]:overflow-auto",
|
|
187
|
+
"[&_th:not(:first-child)]:text-right [&_td:not(:first-child)]:text-right",
|
|
188
|
+
"[&_thead_th]:sticky [&_thead_th]:top-0 [&_thead_th]:z-10",
|
|
189
|
+
"[&_thead_th]:bg-muted [&_thead_th]:font-semibold [&_thead_th]:text-foreground",
|
|
190
|
+
"[&_thead_th]:border-b [&_thead_th]:border-border",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Interactive table for a settled statement result, built on
|
|
195
|
+
* `@tanstack/react-table` over AppKit's `Table` primitives so it
|
|
196
|
+
* matches the rest of the chat. A toolbar in the card header carries
|
|
197
|
+
* the row count, a column show/hide menu, and a CSV export of the
|
|
198
|
+
* visible columns in the current sort order. Header cells are sort
|
|
199
|
+
* toggles (click to cycle asc -> desc -> none): the active column
|
|
200
|
+
* shows a direction arrow, idle columns a faded up/down glyph. All
|
|
201
|
+
* state is client-side - the rows arrive once from {@link DataSlot}.
|
|
202
|
+
*/
|
|
203
|
+
export const DataGrid = ({
|
|
204
|
+
columns,
|
|
205
|
+
rows,
|
|
206
|
+
truncated,
|
|
207
|
+
rowCount,
|
|
208
|
+
humanizeHeaders = false,
|
|
209
|
+
}: {
|
|
210
|
+
columns: string[];
|
|
211
|
+
rows: DataRow[];
|
|
212
|
+
truncated: boolean;
|
|
213
|
+
rowCount: number;
|
|
214
|
+
/**
|
|
215
|
+
* Title-Case raw identifier column names for display (statement
|
|
216
|
+
* results). Off for markdown tables, whose headers are already
|
|
217
|
+
* human-authored and would be mangled by the tokenizer.
|
|
218
|
+
*/
|
|
219
|
+
humanizeHeaders?: boolean;
|
|
220
|
+
}) => {
|
|
221
|
+
const [sorting, setSorting] = useState<SortingState>([]);
|
|
222
|
+
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
223
|
+
|
|
224
|
+
const columnDefs = useMemo<ColumnDef<DataRow>[]>(
|
|
225
|
+
() =>
|
|
226
|
+
columns.map((col): ColumnDef<DataRow> => ({
|
|
227
|
+
id: col,
|
|
228
|
+
accessorFn: (row) => row[col],
|
|
229
|
+
header: humanizeHeaders ? humanizeLabel(col) : col,
|
|
230
|
+
cell: (info) => renderDataCell(info.getValue()),
|
|
231
|
+
sortUndefined: "last",
|
|
232
|
+
})),
|
|
233
|
+
[columns, humanizeHeaders],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const table = useReactTable({
|
|
237
|
+
data: rows,
|
|
238
|
+
columns: columnDefs,
|
|
239
|
+
state: { sorting, columnVisibility },
|
|
240
|
+
onSortingChange: setSorting,
|
|
241
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
242
|
+
getCoreRowModel: getCoreRowModel(),
|
|
243
|
+
getSortedRowModel: getSortedRowModel(),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const exportCsv = () =>
|
|
247
|
+
downloadCsv(
|
|
248
|
+
table.getVisibleLeafColumns().map((c) => c.id),
|
|
249
|
+
table.getSortedRowModel().rows.map((r) => r.original),
|
|
250
|
+
"statement.csv",
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<div className={DATA_GRID_CARD_CLASSES}>
|
|
255
|
+
<div className={DATA_GRID_TOOLBAR_CLASSES}>
|
|
256
|
+
<span>
|
|
257
|
+
{truncated
|
|
258
|
+
? `Showing ${rows.length} of ${rowCount} rows`
|
|
259
|
+
: `${rows.length} ${rows.length === 1 ? "row" : "rows"}`}
|
|
260
|
+
</span>
|
|
261
|
+
<div className="flex items-center gap-1">
|
|
262
|
+
<DropdownMenu>
|
|
263
|
+
<DropdownMenuTrigger asChild>
|
|
264
|
+
<Button variant="ghost" size="sm" className="h-7 gap-1 px-2 text-xs">
|
|
265
|
+
<Columns3Icon className="size-3.5" />
|
|
266
|
+
Columns
|
|
267
|
+
</Button>
|
|
268
|
+
</DropdownMenuTrigger>
|
|
269
|
+
<DropdownMenuContent align="end" className="max-h-72 overflow-y-auto">
|
|
270
|
+
{table.getAllLeafColumns().map((column) => (
|
|
271
|
+
<DropdownMenuCheckboxItem
|
|
272
|
+
key={column.id}
|
|
273
|
+
className="text-xs"
|
|
274
|
+
checked={column.getIsVisible()}
|
|
275
|
+
// Keep the menu open while toggling several columns.
|
|
276
|
+
onSelect={(e) => e.preventDefault()}
|
|
277
|
+
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
|
278
|
+
>
|
|
279
|
+
{String(column.columnDef.header)}
|
|
280
|
+
</DropdownMenuCheckboxItem>
|
|
281
|
+
))}
|
|
282
|
+
</DropdownMenuContent>
|
|
283
|
+
</DropdownMenu>
|
|
284
|
+
<Button
|
|
285
|
+
variant="ghost"
|
|
286
|
+
size="sm"
|
|
287
|
+
className="h-7 gap-1 px-2 text-xs"
|
|
288
|
+
onClick={exportCsv}
|
|
289
|
+
>
|
|
290
|
+
<DownloadIcon className="size-3.5" />
|
|
291
|
+
Export
|
|
292
|
+
</Button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
<div className={DATA_GRID_SCROLL_CLASSES}>
|
|
296
|
+
<Table>
|
|
297
|
+
<TableHeader>
|
|
298
|
+
{table.getHeaderGroups().map((group) => (
|
|
299
|
+
<TableRow key={group.id}>
|
|
300
|
+
{group.headers.map((header) => {
|
|
301
|
+
const sorted = header.column.getIsSorted();
|
|
302
|
+
return (
|
|
303
|
+
<TableHead key={header.id}>
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
className="inline-flex items-center gap-1 hover:text-foreground"
|
|
307
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
308
|
+
>
|
|
309
|
+
{flexRender(
|
|
310
|
+
header.column.columnDef.header,
|
|
311
|
+
header.getContext(),
|
|
312
|
+
)}
|
|
313
|
+
{sorted === "asc" ? (
|
|
314
|
+
<ArrowUpIcon className="size-3" />
|
|
315
|
+
) : sorted === "desc" ? (
|
|
316
|
+
<ArrowDownIcon className="size-3" />
|
|
317
|
+
) : (
|
|
318
|
+
<ChevronsUpDownIcon className="size-3 opacity-40" />
|
|
319
|
+
)}
|
|
320
|
+
</button>
|
|
321
|
+
</TableHead>
|
|
322
|
+
);
|
|
323
|
+
})}
|
|
324
|
+
</TableRow>
|
|
325
|
+
))}
|
|
326
|
+
</TableHeader>
|
|
327
|
+
<TableBody>
|
|
328
|
+
{table.getRowModel().rows.map((row) => (
|
|
329
|
+
<TableRow key={row.id}>
|
|
330
|
+
{row.getVisibleCells().map((cell) => (
|
|
331
|
+
<TableCell key={cell.id}>
|
|
332
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
333
|
+
</TableCell>
|
|
334
|
+
))}
|
|
335
|
+
</TableRow>
|
|
336
|
+
))}
|
|
337
|
+
</TableBody>
|
|
338
|
+
</Table>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { Spinner } from "@dbx-tools/ui-appkit/react";
|
|
2
|
+
import {
|
|
3
|
+
marker as markers,
|
|
4
|
+
type ParsedMarker,
|
|
5
|
+
} from "@dbx-tools/shared-mastra";
|
|
6
|
+
import ReactECharts from "echarts-for-react";
|
|
7
|
+
import { ClockIcon } from "lucide-react";
|
|
8
|
+
import { useMemo } from "react";
|
|
9
|
+
import { normalizeChartOption } from "../support/chart-option";
|
|
10
|
+
import { useChartFetch, useStatementFetch } from "../support/mastra-client";
|
|
11
|
+
import { DataGrid, humanizeLabel } from "./data-grid";
|
|
12
|
+
import { AssistantMarkdown } from "./markdown";
|
|
13
|
+
|
|
14
|
+
// Inline embed slots: chart / data tables resolved from `[chart:<id>]`
|
|
15
|
+
// and `[data:<id>]` markers in the assistant's prose, plus the splitter
|
|
16
|
+
// that interleaves those slots with the surrounding markdown.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Frame shared by every chart-slot state so the layout stays
|
|
20
|
+
* stable as a slot transitions from queueing → rendering →
|
|
21
|
+
* rendered (or → render-failed). Width tracks the bubble; height
|
|
22
|
+
* is fixed so Echarts has a deterministic canvas regardless of
|
|
23
|
+
* the parent's flex layout. `not-prose` opts out of Tailwind
|
|
24
|
+
* Typography.
|
|
25
|
+
*/
|
|
26
|
+
const CHART_FRAME_CLASSES =
|
|
27
|
+
"not-prose my-3 max-w-full rounded border border-border/60 bg-background/40 p-2";
|
|
28
|
+
const CHART_HEIGHT_PX = 320;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compact inline notice rendered in place of an embed - a chart, data
|
|
32
|
+
* table, or any future `[<type>:<id>]` marker - whose backing cache
|
|
33
|
+
* entry 404'd, i.e. the embed id expired (TTL elapsed) or was never
|
|
34
|
+
* minted. The displayed noun is the marker `type` run through
|
|
35
|
+
* {@link humanizeLabel}, keeping this slot agnostic to which kinds
|
|
36
|
+
* exist. Deliberately small so an expired artifact in a long
|
|
37
|
+
* transcript reads as a quiet footnote rather than a broken,
|
|
38
|
+
* full-height frame. Only the genuine "settled 404" case reaches here;
|
|
39
|
+
* in-flight fetches and hard errors still render nothing (see
|
|
40
|
+
* {@link ChartSlot} / {@link DataSlot}).
|
|
41
|
+
*/
|
|
42
|
+
const ExpiredSlot = ({ type }: { type: string }) => (
|
|
43
|
+
<div className="not-prose my-3 inline-flex items-center gap-2 rounded-md border border-dashed border-border bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground">
|
|
44
|
+
<ClockIcon className="size-3.5 shrink-0" />
|
|
45
|
+
<span>
|
|
46
|
+
This {humanizeLabel(type, { capitalize: false })} has expired and is no longer
|
|
47
|
+
available.
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Inline chart slot. Each `[chart:<chartId>]` marker in the
|
|
54
|
+
* assistant's reply resolves to one of these. The Mastra plugin
|
|
55
|
+
* caches the resolved Echarts
|
|
56
|
+
* spec under the chartId; this slot fetches it via the generic
|
|
57
|
+
* `embedPathTemplate` (`/embed/chart/:id`, long-poll until ready /
|
|
58
|
+
* error / 404).
|
|
59
|
+
*
|
|
60
|
+
* Render contract:
|
|
61
|
+
*
|
|
62
|
+
* - Cache entry settled with `result` -> render the full
|
|
63
|
+
* Echarts spec.
|
|
64
|
+
* - Fetch / long-poll in flight -> render the chart frame at its
|
|
65
|
+
* known fixed footprint (`CHART_HEIGHT_PX`) with a centered
|
|
66
|
+
* spinner, so the slot reserves the chart's eventual space and
|
|
67
|
+
* the prose below doesn't reflow when it lands.
|
|
68
|
+
* - 404 (unknown / TTL-expired id) -> render a small
|
|
69
|
+
* {@link ExpiredSlot} notice so the reader knows the chart
|
|
70
|
+
* used to be here but has aged out, instead of a silent gap.
|
|
71
|
+
* - Settled with `error`, or a non-terminal payload that has
|
|
72
|
+
* neither `result` nor `error` -> render NOTHING. Genuine
|
|
73
|
+
* planner failures are silently dropped rather than left as
|
|
74
|
+
* placeholder frames. The Echarts `option` already carries its
|
|
75
|
+
* own `title.text`, so no separate header is needed above the
|
|
76
|
+
* chart frame.
|
|
77
|
+
*/
|
|
78
|
+
const ChartSlot = ({ chartId }: { chartId: string }) => {
|
|
79
|
+
const { data: chart, loading, error } = useChartFetch(chartId);
|
|
80
|
+
// Patch presentation (compact ticks, axis-name placement, legible
|
|
81
|
+
// category labels) into the JSON-safe planner spec before rendering,
|
|
82
|
+
// matching the export path. Memoized on the resolved option identity.
|
|
83
|
+
const option = useMemo(
|
|
84
|
+
() => (chart?.result ? normalizeChartOption(chart.result.option) : undefined),
|
|
85
|
+
[chart?.result],
|
|
86
|
+
);
|
|
87
|
+
if (option) {
|
|
88
|
+
return (
|
|
89
|
+
<div className={CHART_FRAME_CLASSES}>
|
|
90
|
+
<ReactECharts
|
|
91
|
+
option={option}
|
|
92
|
+
style={{ height: CHART_HEIGHT_PX, width: "100%" }}
|
|
93
|
+
notMerge
|
|
94
|
+
lazyUpdate
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
// In-flight fetch: the chart's footprint is known ahead of time, so
|
|
100
|
+
// reserve the same frame + height with a spinner instead of
|
|
101
|
+
// collapsing - the chart fades in without shifting the prose below.
|
|
102
|
+
if (loading) {
|
|
103
|
+
return (
|
|
104
|
+
<div className={CHART_FRAME_CLASSES}>
|
|
105
|
+
<div
|
|
106
|
+
className="flex items-center justify-center"
|
|
107
|
+
style={{ height: CHART_HEIGHT_PX, width: "100%" }}
|
|
108
|
+
>
|
|
109
|
+
<Spinner className="size-5 text-muted-foreground" />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
// Settled 404 (unknown / TTL-expired id) -> small "expired" notice.
|
|
115
|
+
// Hard-error / non-terminal payloads render nothing.
|
|
116
|
+
if (!error && chart === undefined) return <ExpiredSlot type="chart" />;
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Inline data-table slot. Each `[data:<statement_id>]` marker in
|
|
122
|
+
* the assistant's reply resolves to one of these. A single
|
|
123
|
+
* OBO-scoped fetch against `embedPathTemplate` (`/embed/data/:id`)
|
|
124
|
+
* returns the columns + rows; the slot hands them to {@link DataGrid}
|
|
125
|
+
* for an interactive (sortable, column-toggle, CSV-export) table.
|
|
126
|
+
*
|
|
127
|
+
* Render contract (matches {@link ChartSlot}):
|
|
128
|
+
*
|
|
129
|
+
* - 404 (unknown / TTL-expired id) -> render a small
|
|
130
|
+
* {@link ExpiredSlot} notice so the reader knows a table aged
|
|
131
|
+
* out rather than seeing a silent gap.
|
|
132
|
+
* - Fetch in flight, hard error, or empty rows -> render
|
|
133
|
+
* NOTHING. Stale markers in persisted transcripts stay quiet
|
|
134
|
+
* so a reload doesn't leave dead frames.
|
|
135
|
+
* - Data settled with rows -> render the {@link DataGrid}, whose
|
|
136
|
+
* toolbar surfaces the "showing N of M rows" affordance when the
|
|
137
|
+
* server-side cap truncated the result set.
|
|
138
|
+
*/
|
|
139
|
+
const DataSlot = ({ statementId }: { statementId: string }) => {
|
|
140
|
+
const { data, loading, error } = useStatementFetch(statementId);
|
|
141
|
+
// Settled 404 (unknown / TTL-expired id) -> small "expired" notice.
|
|
142
|
+
// In-flight fetches, hard errors, and empty result sets render
|
|
143
|
+
// nothing so stale markers in reloaded transcripts stay quiet.
|
|
144
|
+
if (!loading && !error && data === undefined) return <ExpiredSlot type="data" />;
|
|
145
|
+
if (!data || data.rows.length === 0) return null;
|
|
146
|
+
return (
|
|
147
|
+
<DataGrid
|
|
148
|
+
columns={data.columns}
|
|
149
|
+
rows={data.rows}
|
|
150
|
+
truncated={data.truncated}
|
|
151
|
+
rowCount={data.rowCount}
|
|
152
|
+
humanizeHeaders
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** One slice of an assistant message: prose, a chart slot, or a data slot. */
|
|
158
|
+
type RenderSegment =
|
|
159
|
+
| { kind: "text"; text: string }
|
|
160
|
+
| { kind: "chart"; chartId: string }
|
|
161
|
+
| { kind: "data"; statementId: string };
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Map one parsed marker onto its render segment. The marker grammar
|
|
165
|
+
* matches ANY `[<type>:<id>]`, including fabricated ids the model
|
|
166
|
+
* glued together from a label (e.g. `[chart:placeholder]`), so the id
|
|
167
|
+
* is validated with {@link isUuid} before it's treated as a real
|
|
168
|
+
* embed. A non-UUID id - or a UUID with a type this UI can't render -
|
|
169
|
+
* collapses to an empty text segment: the marker is consumed so no
|
|
170
|
+
* literal `[<type>:...]` leaks into the prose, but no slot renders and
|
|
171
|
+
* no `/embed/<type>/:id` request fires. Only a UUID-shaped id with a
|
|
172
|
+
* known, renderable type (charts need ECharts, data needs a Table)
|
|
173
|
+
* resolves to a slot.
|
|
174
|
+
*/
|
|
175
|
+
const markerSegment = (marker: ParsedMarker): RenderSegment => {
|
|
176
|
+
if (!markers.isUuid(marker.id)) return { kind: "text", text: "" };
|
|
177
|
+
switch (marker.type) {
|
|
178
|
+
case "chart":
|
|
179
|
+
return { kind: "chart", chartId: marker.id };
|
|
180
|
+
case "data":
|
|
181
|
+
return { kind: "data", statementId: marker.id };
|
|
182
|
+
default:
|
|
183
|
+
return { kind: "text", text: "" };
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const splitTextWithEmbeds = (text: string): RenderSegment[] => {
|
|
188
|
+
const segments: RenderSegment[] = [];
|
|
189
|
+
let lastIdx = 0;
|
|
190
|
+
// `parseMarkers` yields hits in source order with no overlaps (one
|
|
191
|
+
// regex pass), so the spans splice in directly - no sort or
|
|
192
|
+
// overlap guard needed.
|
|
193
|
+
for (const marker of markers.parseMarkers(text)) {
|
|
194
|
+
if (marker.start > lastIdx) {
|
|
195
|
+
segments.push({ kind: "text", text: text.slice(lastIdx, marker.start) });
|
|
196
|
+
}
|
|
197
|
+
segments.push(markerSegment(marker));
|
|
198
|
+
lastIdx = marker.end;
|
|
199
|
+
}
|
|
200
|
+
if (lastIdx < text.length) {
|
|
201
|
+
segments.push({ kind: "text", text: text.slice(lastIdx) });
|
|
202
|
+
}
|
|
203
|
+
return segments;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Render the assistant's markdown with chart and data tables
|
|
208
|
+
* placed at their inline marker positions. Each prose segment
|
|
209
|
+
* is its own {@link AssistantMarkdown} so streaming chunks still
|
|
210
|
+
* incrementally parse correctly; embed slots break the markdown
|
|
211
|
+
* flow with full-width block elements.
|
|
212
|
+
*
|
|
213
|
+
* Each marker resolves through its own slot:
|
|
214
|
+
*
|
|
215
|
+
* - `[chart:<id>]` -> {@link ChartSlot} long-polls the
|
|
216
|
+
* server-side chart cache and renders the Echarts spec
|
|
217
|
+
* inline once ready (or nothing on miss / TTL-expired).
|
|
218
|
+
* - `[data:<statement_id>]` -> {@link DataSlot} fetches the
|
|
219
|
+
* statement rows OBO-scoped and renders an inline Table
|
|
220
|
+
* (or nothing on 404 / empty result).
|
|
221
|
+
*
|
|
222
|
+
* Stale markers in persisted transcript text are silently
|
|
223
|
+
* dropped on reload so the prose around them stays clean.
|
|
224
|
+
*
|
|
225
|
+
* `streaming` flags the bubble as still receiving tokens; it opts the
|
|
226
|
+
* prose segments into Streamdown's word-by-word fade-in so the reply
|
|
227
|
+
* eases in rather than snapping in whole chunks. Settled bubbles pass
|
|
228
|
+
* `false` and render plain markdown.
|
|
229
|
+
*/
|
|
230
|
+
export const MarkdownWithEmbeds = ({
|
|
231
|
+
text,
|
|
232
|
+
streaming = false,
|
|
233
|
+
}: {
|
|
234
|
+
text: string;
|
|
235
|
+
streaming?: boolean;
|
|
236
|
+
}) => {
|
|
237
|
+
const segments = splitTextWithEmbeds(markers.stripIncompleteMarkerTail(text));
|
|
238
|
+
return (
|
|
239
|
+
<>
|
|
240
|
+
{segments.map((seg, i) => {
|
|
241
|
+
if (seg.kind === "text") {
|
|
242
|
+
if (seg.text.trim().length === 0) return null;
|
|
243
|
+
return (
|
|
244
|
+
<AssistantMarkdown key={`t-${i}`} animate={streaming}>
|
|
245
|
+
{seg.text}
|
|
246
|
+
</AssistantMarkdown>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (seg.kind === "chart") {
|
|
250
|
+
return <ChartSlot key={`c-${i}-${seg.chartId}`} chartId={seg.chartId} />;
|
|
251
|
+
}
|
|
252
|
+
return (
|
|
253
|
+
<DataSlot key={`d-${i}-${seg.statementId}`} statementId={seg.statementId} />
|
|
254
|
+
);
|
|
255
|
+
})}
|
|
256
|
+
</>
|
|
257
|
+
);
|
|
258
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
DropdownMenu,
|
|
4
|
+
DropdownMenuContent,
|
|
5
|
+
DropdownMenuItem,
|
|
6
|
+
DropdownMenuTrigger,
|
|
7
|
+
Tooltip,
|
|
8
|
+
TooltipContent,
|
|
9
|
+
TooltipTrigger,
|
|
10
|
+
} from "@dbx-tools/ui-appkit/react";
|
|
11
|
+
import { DownloadIcon } from "lucide-react";
|
|
12
|
+
import type { ExportFormat } from "../support/export";
|
|
13
|
+
|
|
14
|
+
// Shared export affordance: a download button that opens a small menu of
|
|
15
|
+
// output formats. Reused for both the whole-conversation export (header,
|
|
16
|
+
// labelled) and per-message export (bubble action row, icon-only).
|
|
17
|
+
|
|
18
|
+
/** Menu entries, in display order. */
|
|
19
|
+
const FORMATS: ReadonlyArray<{ format: ExportFormat; label: string }> = [
|
|
20
|
+
{ format: "pdf", label: "PDF" },
|
|
21
|
+
{ format: "markdown", label: "Markdown" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Export dropdown. Fires {@link onExport} with the chosen
|
|
26
|
+
* {@link ExportFormat}. `iconOnly` renders a compact icon trigger (used
|
|
27
|
+
* inside message bubbles) with the label surfaced as a tooltip; the
|
|
28
|
+
* default renders an icon + "Export" text button (used in the header).
|
|
29
|
+
*/
|
|
30
|
+
export const ExportMenu = ({
|
|
31
|
+
onExport,
|
|
32
|
+
iconOnly = false,
|
|
33
|
+
tooltip = "Export",
|
|
34
|
+
}: {
|
|
35
|
+
onExport: (format: ExportFormat) => void;
|
|
36
|
+
iconOnly?: boolean;
|
|
37
|
+
tooltip?: string;
|
|
38
|
+
}) => {
|
|
39
|
+
const trigger = iconOnly ? (
|
|
40
|
+
<Button type="button" size="icon" variant="ghost" className="size-7">
|
|
41
|
+
<DownloadIcon className="size-3" />
|
|
42
|
+
</Button>
|
|
43
|
+
) : (
|
|
44
|
+
<Button type="button" size="sm" variant="outline" className="gap-1.5">
|
|
45
|
+
<DownloadIcon className="size-3" />
|
|
46
|
+
Export
|
|
47
|
+
</Button>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<DropdownMenu>
|
|
52
|
+
{iconOnly ? (
|
|
53
|
+
<Tooltip>
|
|
54
|
+
<TooltipTrigger asChild>
|
|
55
|
+
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
56
|
+
</TooltipTrigger>
|
|
57
|
+
<TooltipContent>{tooltip}</TooltipContent>
|
|
58
|
+
</Tooltip>
|
|
59
|
+
) : (
|
|
60
|
+
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
61
|
+
)}
|
|
62
|
+
<DropdownMenuContent align="end">
|
|
63
|
+
{FORMATS.map(({ format, label }) => (
|
|
64
|
+
<DropdownMenuItem key={format} onClick={() => onExport(format)}>
|
|
65
|
+
{label}
|
|
66
|
+
</DropdownMenuItem>
|
|
67
|
+
))}
|
|
68
|
+
</DropdownMenuContent>
|
|
69
|
+
</DropdownMenu>
|
|
70
|
+
);
|
|
71
|
+
};
|