@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,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Label,
|
|
4
|
+
Popover,
|
|
5
|
+
PopoverContent,
|
|
6
|
+
PopoverTrigger,
|
|
7
|
+
Textarea,
|
|
8
|
+
Tooltip,
|
|
9
|
+
TooltipContent,
|
|
10
|
+
TooltipTrigger,
|
|
11
|
+
cn,
|
|
12
|
+
} from "@dbx-tools/ui-appkit/react";
|
|
13
|
+
import { MessageSquareTextIcon, ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import type { FeedbackSubmission, FeedbackValue } from "./types";
|
|
16
|
+
|
|
17
|
+
// Per-message feedback action row: thumbs up/down that log immediately,
|
|
18
|
+
// plus a separate comment affordance that opens a popover for freeform
|
|
19
|
+
// text. Rendered inside the assistant bubble's action row when the host
|
|
20
|
+
// wires feedback (which only happens when MLflow logging is enabled and
|
|
21
|
+
// the turn has a captured trace id).
|
|
22
|
+
|
|
23
|
+
export const FeedbackControls = ({
|
|
24
|
+
value,
|
|
25
|
+
onSubmit,
|
|
26
|
+
}: {
|
|
27
|
+
/** Last thumbs the user chose, so the active button stays highlighted. */
|
|
28
|
+
value?: FeedbackValue;
|
|
29
|
+
onSubmit: (submission: FeedbackSubmission) => void | Promise<void>;
|
|
30
|
+
}) => {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const [comment, setComment] = useState("");
|
|
33
|
+
const [sending, setSending] = useState(false);
|
|
34
|
+
|
|
35
|
+
const submitComment = async () => {
|
|
36
|
+
const text = comment.trim();
|
|
37
|
+
if (!text || sending) return;
|
|
38
|
+
setSending(true);
|
|
39
|
+
try {
|
|
40
|
+
await onSubmit({ comment: text });
|
|
41
|
+
setComment("");
|
|
42
|
+
setOpen(false);
|
|
43
|
+
} finally {
|
|
44
|
+
setSending(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<Tooltip>
|
|
51
|
+
<TooltipTrigger asChild>
|
|
52
|
+
<Button
|
|
53
|
+
type="button"
|
|
54
|
+
size="icon"
|
|
55
|
+
variant="ghost"
|
|
56
|
+
className={cn("size-7", value === "up" && "text-success")}
|
|
57
|
+
aria-label="Good response"
|
|
58
|
+
aria-pressed={value === "up"}
|
|
59
|
+
onClick={() => void onSubmit({ value: "up" })}
|
|
60
|
+
>
|
|
61
|
+
<ThumbsUpIcon className="size-3" />
|
|
62
|
+
</Button>
|
|
63
|
+
</TooltipTrigger>
|
|
64
|
+
<TooltipContent>Good response</TooltipContent>
|
|
65
|
+
</Tooltip>
|
|
66
|
+
<Tooltip>
|
|
67
|
+
<TooltipTrigger asChild>
|
|
68
|
+
<Button
|
|
69
|
+
type="button"
|
|
70
|
+
size="icon"
|
|
71
|
+
variant="ghost"
|
|
72
|
+
className={cn("size-7", value === "down" && "text-destructive")}
|
|
73
|
+
aria-label="Bad response"
|
|
74
|
+
aria-pressed={value === "down"}
|
|
75
|
+
onClick={() => void onSubmit({ value: "down" })}
|
|
76
|
+
>
|
|
77
|
+
<ThumbsDownIcon className="size-3" />
|
|
78
|
+
</Button>
|
|
79
|
+
</TooltipTrigger>
|
|
80
|
+
<TooltipContent>Bad response</TooltipContent>
|
|
81
|
+
</Tooltip>
|
|
82
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
83
|
+
<Tooltip>
|
|
84
|
+
<TooltipTrigger asChild>
|
|
85
|
+
<PopoverTrigger asChild>
|
|
86
|
+
<Button
|
|
87
|
+
type="button"
|
|
88
|
+
size="icon"
|
|
89
|
+
variant="ghost"
|
|
90
|
+
className="size-7"
|
|
91
|
+
aria-label="Leave a comment"
|
|
92
|
+
>
|
|
93
|
+
<MessageSquareTextIcon className="size-3" />
|
|
94
|
+
</Button>
|
|
95
|
+
</PopoverTrigger>
|
|
96
|
+
</TooltipTrigger>
|
|
97
|
+
<TooltipContent>Leave a comment</TooltipContent>
|
|
98
|
+
</Tooltip>
|
|
99
|
+
<PopoverContent align="start" className="w-80">
|
|
100
|
+
<div className="grid gap-2">
|
|
101
|
+
<Label className="text-xs">Share feedback</Label>
|
|
102
|
+
<Textarea
|
|
103
|
+
value={comment}
|
|
104
|
+
onChange={(e) => setComment(e.target.value)}
|
|
105
|
+
onKeyDown={(e) => {
|
|
106
|
+
// Cmd/Ctrl+Enter submits, matching the composer's feel.
|
|
107
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
void submitComment();
|
|
110
|
+
}
|
|
111
|
+
}}
|
|
112
|
+
placeholder="What worked well, or what could be better?"
|
|
113
|
+
rows={3}
|
|
114
|
+
autoFocus
|
|
115
|
+
/>
|
|
116
|
+
<div className="flex justify-end gap-2">
|
|
117
|
+
<Button
|
|
118
|
+
type="button"
|
|
119
|
+
size="sm"
|
|
120
|
+
variant="ghost"
|
|
121
|
+
onClick={() => setOpen(false)}
|
|
122
|
+
>
|
|
123
|
+
Cancel
|
|
124
|
+
</Button>
|
|
125
|
+
<Button
|
|
126
|
+
type="button"
|
|
127
|
+
size="sm"
|
|
128
|
+
disabled={!comment.trim() || sending}
|
|
129
|
+
onClick={() => void submitComment()}
|
|
130
|
+
>
|
|
131
|
+
{sending ? "Sending..." : "Send"}
|
|
132
|
+
</Button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</PopoverContent>
|
|
136
|
+
</Popover>
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Public surface of @dbx-tools/ui-mastra/react.
|
|
2
|
+
//
|
|
3
|
+
// - `MastraChat` / `useMastraChat`: the self-contained drop-in (and its
|
|
4
|
+
// headless driver) that wire themselves from the Mastra plugin config.
|
|
5
|
+
// - `ChatView`: the controlled, presentational shell for callers that
|
|
6
|
+
// own message state and transport themselves.
|
|
7
|
+
// - The Mastra plugin client + hooks (model catalogue, history paging,
|
|
8
|
+
// suggestions, embed fetches) the controlled path needs.
|
|
9
|
+
//
|
|
10
|
+
// Internal building blocks (bubbles, tool pills, markdown, data grid,
|
|
11
|
+
// embed slots, suggestions) are intentionally not re-exported.
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
MastraPluginClient,
|
|
15
|
+
useChartFetch,
|
|
16
|
+
useMastraClient,
|
|
17
|
+
useMastraConfig,
|
|
18
|
+
useMastraModels,
|
|
19
|
+
useMastraSuggestions,
|
|
20
|
+
useMastraThreads,
|
|
21
|
+
useStatementFetch,
|
|
22
|
+
} from "../support/mastra-client";
|
|
23
|
+
export type { ByIdFetchState } from "../support/mastra-client";
|
|
24
|
+
export { ChatView } from "./chat-view";
|
|
25
|
+
export { ExportMenu } from "./export-menu";
|
|
26
|
+
export { MastraChat, useMastraChat } from "./mastra-chat";
|
|
27
|
+
export type { MastraChatProps, UseMastraChatOptions } from "./mastra-chat";
|
|
28
|
+
export { ThreadSidebar } from "./thread-sidebar";
|
|
29
|
+
export type {
|
|
30
|
+
ApprovalDecision,
|
|
31
|
+
ChatModelOption,
|
|
32
|
+
ChatStatus,
|
|
33
|
+
ChatViewProps,
|
|
34
|
+
ExportFormat,
|
|
35
|
+
FeedbackSubmission,
|
|
36
|
+
FeedbackValue,
|
|
37
|
+
MessageFeedback,
|
|
38
|
+
PendingApproval,
|
|
39
|
+
ThreadSummary,
|
|
40
|
+
ToolEvent,
|
|
41
|
+
ToolProgress,
|
|
42
|
+
} from "./types";
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Table,
|
|
4
|
+
TableBody,
|
|
5
|
+
TableCell,
|
|
6
|
+
TableFooter,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableRow,
|
|
10
|
+
Tooltip,
|
|
11
|
+
TooltipContent,
|
|
12
|
+
TooltipTrigger,
|
|
13
|
+
cn,
|
|
14
|
+
} from "@dbx-tools/ui-appkit/react";
|
|
15
|
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
16
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
17
|
+
import { format as formatSql } from "sql-formatter";
|
|
18
|
+
import { Streamdown } from "streamdown";
|
|
19
|
+
import { createShikiPlugin, highlightToHtml } from "../support/shiki-plugin";
|
|
20
|
+
import {
|
|
21
|
+
DataGrid,
|
|
22
|
+
TABLE_WRAPPER_CLASSES,
|
|
23
|
+
colorizeDelta,
|
|
24
|
+
type DataRow,
|
|
25
|
+
} from "./data-grid";
|
|
26
|
+
|
|
27
|
+
// Markdown rendering for the chat: the streaming `Streamdown` engine
|
|
28
|
+
// wired with shiki highlighting and AppKit table primitives, plus the
|
|
29
|
+
// standalone syntax-highlighted SQL block and a compact copy button.
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minimal hast node shape we walk to lift a GFM markdown table out of
|
|
33
|
+
* Streamdown's parsed tree (handed to component overrides as the
|
|
34
|
+
* `node` prop). Only the fields the extractor reads are modeled; the
|
|
35
|
+
* real node carries more.
|
|
36
|
+
*/
|
|
37
|
+
interface MarkdownNode {
|
|
38
|
+
type?: string;
|
|
39
|
+
tagName?: string;
|
|
40
|
+
value?: string;
|
|
41
|
+
children?: MarkdownNode[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Concatenate all descendant text of a hast node (cell -> plain string). */
|
|
45
|
+
function markdownNodeText(node: MarkdownNode): string {
|
|
46
|
+
if (node.type === "text") return node.value ?? "";
|
|
47
|
+
return (node.children ?? []).map(markdownNodeText).join("");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lift a markdown `<table>` hast node into the column/row shape
|
|
52
|
+
* {@link DataGrid} consumes. Header cells become column keys; blank or
|
|
53
|
+
* duplicate headers are made unique (`Column N`, `Name (2)`) so each
|
|
54
|
+
* key can double as both the row-record key and the tanstack column
|
|
55
|
+
* id. Rich cell content (links, bold, code) is flattened to text - the
|
|
56
|
+
* grid sorts and exports on plain values. Returns `null` for anything
|
|
57
|
+
* that isn't a parseable table with at least one header cell.
|
|
58
|
+
*/
|
|
59
|
+
function markdownTableData(
|
|
60
|
+
node: MarkdownNode | undefined,
|
|
61
|
+
): { columns: string[]; rows: DataRow[] } | null {
|
|
62
|
+
if (!node || node.tagName !== "table") return null;
|
|
63
|
+
const sections = node.children ?? [];
|
|
64
|
+
const sectionRows = (tag: string): MarkdownNode[] =>
|
|
65
|
+
sections
|
|
66
|
+
.find((s) => s.tagName === tag)
|
|
67
|
+
?.children?.filter((r) => r.tagName === "tr") ?? [];
|
|
68
|
+
|
|
69
|
+
const headerCells = (sectionRows("thead")[0]?.children ?? []).filter(
|
|
70
|
+
(c) => c.tagName === "th" || c.tagName === "td",
|
|
71
|
+
);
|
|
72
|
+
if (headerCells.length === 0) return null;
|
|
73
|
+
|
|
74
|
+
const columns: string[] = [];
|
|
75
|
+
const seen = new Map<string, number>();
|
|
76
|
+
for (const [i, cell] of headerCells.entries()) {
|
|
77
|
+
let name = markdownNodeText(cell).trim() || `Column ${i + 1}`;
|
|
78
|
+
const count = seen.get(name) ?? 0;
|
|
79
|
+
seen.set(name, count + 1);
|
|
80
|
+
if (count > 0) name = `${name} (${count + 1})`;
|
|
81
|
+
columns.push(name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const rows: DataRow[] = sectionRows("tbody").map((tr) => {
|
|
85
|
+
const cells = (tr.children ?? []).filter(
|
|
86
|
+
(c) => c.tagName === "td" || c.tagName === "th",
|
|
87
|
+
);
|
|
88
|
+
const row: DataRow = {};
|
|
89
|
+
columns.forEach((col, i) => {
|
|
90
|
+
const cell = cells[i];
|
|
91
|
+
row[col] = cell ? markdownNodeText(cell).trim() : "";
|
|
92
|
+
});
|
|
93
|
+
return row;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return { columns, rows };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Static AppKit-Table rendering of a markdown table - the fallback
|
|
101
|
+
* when a table can't be lifted into a {@link DataGrid}, and the
|
|
102
|
+
* renderer tool-detail copy uses unconditionally (a sort/column/export
|
|
103
|
+
* toolbar would dwarf the tiny inline pills it renders in).
|
|
104
|
+
*/
|
|
105
|
+
const plainMarkdownTable = ({
|
|
106
|
+
children,
|
|
107
|
+
...rest
|
|
108
|
+
}: React.HTMLAttributes<HTMLTableElement>) => (
|
|
109
|
+
<div className={TABLE_WRAPPER_CLASSES}>
|
|
110
|
+
<Table {...rest}>{children}</Table>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Cell/section overrides shared by every markdown table renderer: map
|
|
116
|
+
* the GFM table parts onto AppKit's Table primitives so chat tables
|
|
117
|
+
* match the rest of the app instead of inheriting
|
|
118
|
+
* `@tailwindcss/typography`'s defaults. The `td` override also runs
|
|
119
|
+
* each cell through `colorizeDelta` so signed numeric tokens (e.g.
|
|
120
|
+
* `+1.8%`, `-3.1 pts`) render in green/red. These only take effect on
|
|
121
|
+
* the {@link plainMarkdownTable} path; the {@link DataGrid} path builds
|
|
122
|
+
* its own cells from the parsed data.
|
|
123
|
+
*/
|
|
124
|
+
const MARKDOWN_TABLE_PARTS = {
|
|
125
|
+
thead: ({ children, ...rest }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
|
126
|
+
<TableHeader {...rest}>{children}</TableHeader>
|
|
127
|
+
),
|
|
128
|
+
tbody: ({ children, ...rest }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
|
129
|
+
<TableBody {...rest}>{children}</TableBody>
|
|
130
|
+
),
|
|
131
|
+
tfoot: ({ children, ...rest }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
|
132
|
+
<TableFooter {...rest}>{children}</TableFooter>
|
|
133
|
+
),
|
|
134
|
+
tr: ({ children, ...rest }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
|
135
|
+
<TableRow {...rest}>{children}</TableRow>
|
|
136
|
+
),
|
|
137
|
+
th: ({ children, ...rest }: React.HTMLAttributes<HTMLTableCellElement>) => (
|
|
138
|
+
<TableHead {...rest}>{children}</TableHead>
|
|
139
|
+
),
|
|
140
|
+
td: ({ children, ...rest }: React.HTMLAttributes<HTMLTableCellElement>) => {
|
|
141
|
+
const colored = Array.isArray(children)
|
|
142
|
+
? children.map((c, i) => (
|
|
143
|
+
<React.Fragment key={i}>{colorizeDelta(c)}</React.Fragment>
|
|
144
|
+
))
|
|
145
|
+
: colorizeDelta(children as React.ReactNode);
|
|
146
|
+
return <TableCell {...rest}>{colored}</TableCell>;
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Markdown component map for the main assistant reply. Tables are
|
|
152
|
+
* lifted out of the parsed `node` and rendered through the interactive
|
|
153
|
+
* {@link DataGrid} (sortable, column show/hide, CSV export) so they
|
|
154
|
+
* behave exactly like statement-result tables; anything that doesn't
|
|
155
|
+
* parse cleanly falls back to {@link plainMarkdownTable}.
|
|
156
|
+
*/
|
|
157
|
+
const MARKDOWN_COMPONENTS = {
|
|
158
|
+
...MARKDOWN_TABLE_PARTS,
|
|
159
|
+
table: ({
|
|
160
|
+
node,
|
|
161
|
+
children,
|
|
162
|
+
...rest
|
|
163
|
+
}: React.HTMLAttributes<HTMLTableElement> & { node?: MarkdownNode }) => {
|
|
164
|
+
const parsed = markdownTableData(node);
|
|
165
|
+
if (parsed && parsed.columns.length > 0) {
|
|
166
|
+
return (
|
|
167
|
+
<DataGrid
|
|
168
|
+
columns={parsed.columns}
|
|
169
|
+
rows={parsed.rows}
|
|
170
|
+
truncated={false}
|
|
171
|
+
rowCount={parsed.rows.length}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return plainMarkdownTable({ children, ...rest });
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Markdown component map for tool-detail copy (Genie summaries, SQL
|
|
181
|
+
* descriptions). Same cell/section overrides, but tables stay static
|
|
182
|
+
* via {@link plainMarkdownTable} - these render inside tiny muted pills
|
|
183
|
+
* where a full {@link DataGrid} toolbar would be oversized.
|
|
184
|
+
*/
|
|
185
|
+
const TOOL_MARKDOWN_COMPONENTS = {
|
|
186
|
+
...MARKDOWN_TABLE_PARTS,
|
|
187
|
+
table: plainMarkdownTable,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Shared shiki highlighter for every `Streamdown` instance in the chat.
|
|
192
|
+
* Streamdown 2.x ships highlighting as an opt-in plugin (no built-in
|
|
193
|
+
* shiki), so without this the SQL/code blocks render as uncolored
|
|
194
|
+
* plaintext. One instance keeps a single lazily-loaded highlighter.
|
|
195
|
+
*/
|
|
196
|
+
const SHIKI_PLUGIN = { code: createShikiPlugin() };
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Per-word fade-in applied while a reply is still streaming. Streamdown
|
|
200
|
+
* wraps each newly arrived token in a `[data-sd-animate]` span and only
|
|
201
|
+
* animates the delta since the previous render (tokens already on
|
|
202
|
+
* screen don't re-animate), so the text eases in instead of snapping
|
|
203
|
+
* in whole SSE chunks. Kept short with an `ease-out` curve so the
|
|
204
|
+
* reveal is quick and smooth. The keyframes ship in
|
|
205
|
+
* `streamdown/styles.css`, imported by this package's `styles.css`.
|
|
206
|
+
*
|
|
207
|
+
* `stagger: 0` is load-bearing. Streamdown 2.5 defaults `stagger` to
|
|
208
|
+
* 40ms, and stagger has NO inter-block coordination: each block's
|
|
209
|
+
* delay counter restarts at 0, so a freshly-appeared paragraph starts
|
|
210
|
+
* fading at delay 0 while the previous block's staggered tail words are
|
|
211
|
+
* still queued at 40ms x N - making sibling sections visibly animate in
|
|
212
|
+
* parallel ("new paragraphs reveal before the previous finishes").
|
|
213
|
+
* Pinning `stagger: 0` makes each batch of newly-arrived words fade
|
|
214
|
+
* together within `duration`, so blocks reveal in order with no overlap
|
|
215
|
+
* window. See https://github.com/vercel/streamdown/issues/482.
|
|
216
|
+
*/
|
|
217
|
+
const ANIMATE_OPTIONS = {
|
|
218
|
+
animation: "fadeIn",
|
|
219
|
+
sep: "word",
|
|
220
|
+
duration: 120,
|
|
221
|
+
easing: "ease-out",
|
|
222
|
+
stagger: 0,
|
|
223
|
+
} as const;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Streamdown ships GFM (tables, task lists, strikethrough, autolink),
|
|
227
|
+
* KaTeX math, Mermaid diagrams, copy/download controls on code +
|
|
228
|
+
* tables, and incremental-parse handling for partial markdown chunks -
|
|
229
|
+
* all out of the box. Syntax highlighting is provided via the
|
|
230
|
+
* {@link SHIKI_PLUGIN} `code` plugin. We layer on the project's heading
|
|
231
|
+
* rhythm and route tables through AppKit's Table primitives via
|
|
232
|
+
* {@link MARKDOWN_COMPONENTS}, then disable the noisy in-block copy/
|
|
233
|
+
* download buttons since this UI lives inside a chat bubble that
|
|
234
|
+
* already has its own copy button. `animate` opts the block into the
|
|
235
|
+
* {@link ANIMATE_OPTIONS} word-by-word fade-in (and drives
|
|
236
|
+
* `isAnimating` for the streaming caret); callers pass it only for the
|
|
237
|
+
* actively streaming bubble so settled history renders plain.
|
|
238
|
+
*/
|
|
239
|
+
export const AssistantMarkdown = ({
|
|
240
|
+
children,
|
|
241
|
+
animate = false,
|
|
242
|
+
}: {
|
|
243
|
+
children: string;
|
|
244
|
+
animate?: boolean;
|
|
245
|
+
}) => (
|
|
246
|
+
<Streamdown
|
|
247
|
+
components={MARKDOWN_COMPONENTS}
|
|
248
|
+
plugins={SHIKI_PLUGIN}
|
|
249
|
+
controls={false}
|
|
250
|
+
animated={animate ? ANIMATE_OPTIONS : false}
|
|
251
|
+
isAnimating={animate}
|
|
252
|
+
className={cn(
|
|
253
|
+
"prose prose-sm dark:prose-invert max-w-none break-words",
|
|
254
|
+
"prose-headings:font-semibold prose-headings:tracking-tight",
|
|
255
|
+
"prose-h1:text-lg prose-h1:mt-4 prose-h1:mb-2",
|
|
256
|
+
"prose-h2:text-base prose-h2:mt-4 prose-h2:mb-2",
|
|
257
|
+
"prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1.5 prose-h3:text-muted-foreground prose-h3:uppercase prose-h3:tracking-wider",
|
|
258
|
+
)}
|
|
259
|
+
>
|
|
260
|
+
{children}
|
|
261
|
+
</Streamdown>
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Tighter, muted markdown variant for inline tool detail copy: Genie
|
|
266
|
+
* attachment text (the natural-language summary that arrives below
|
|
267
|
+
* the SQL) and SQL descriptions. Same `Streamdown` engine as
|
|
268
|
+
* {@link AssistantMarkdown} so we keep GFM, shiki, and table primitive
|
|
269
|
+
* mapping, but everything is squeezed: smaller font, tighter leading,
|
|
270
|
+
* and near-zero block margins so a few short lines don't take more
|
|
271
|
+
* vertical space than the SQL block above them. Lists get a shallow
|
|
272
|
+
* indent (`pl-4`) because the default `prose-sm` indent is sized for
|
|
273
|
+
* chat-bubble copy and looks oversized inside a sub-pill. Strong
|
|
274
|
+
* (bold) gets the foreground color so KPI names still pop against
|
|
275
|
+
* the muted body.
|
|
276
|
+
*/
|
|
277
|
+
export const ToolMarkdown = ({ children }: { children: string }) => (
|
|
278
|
+
<Streamdown
|
|
279
|
+
components={TOOL_MARKDOWN_COMPONENTS}
|
|
280
|
+
plugins={SHIKI_PLUGIN}
|
|
281
|
+
controls={false}
|
|
282
|
+
className={cn(
|
|
283
|
+
"prose prose-sm dark:prose-invert max-w-none break-words",
|
|
284
|
+
"text-[11px] leading-snug text-muted-foreground",
|
|
285
|
+
"prose-p:my-0.5 prose-p:leading-snug",
|
|
286
|
+
"prose-ul:my-0.5 prose-ul:pl-4 prose-ol:my-0.5 prose-ol:pl-4",
|
|
287
|
+
"prose-li:my-0 prose-li:leading-snug prose-li:marker:text-muted-foreground/60",
|
|
288
|
+
"prose-headings:my-1 prose-headings:text-xs prose-headings:font-semibold",
|
|
289
|
+
"prose-strong:text-foreground/90 prose-strong:font-medium",
|
|
290
|
+
"prose-code:text-[10px] prose-code:font-medium",
|
|
291
|
+
)}
|
|
292
|
+
>
|
|
293
|
+
{children}
|
|
294
|
+
</Streamdown>
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Pretty-print a Genie SQL string for display. Genie often emits the
|
|
299
|
+
* query as one long line; `sql-formatter`'s Spark dialect (the closest
|
|
300
|
+
* fit to Databricks SQL) re-indents it with uppercased keywords. The
|
|
301
|
+
* formatter throws on syntax it can't parse (e.g. Databricks-specific
|
|
302
|
+
* constructs or a partial query), so we fall back to the raw string
|
|
303
|
+
* rather than dropping the preview.
|
|
304
|
+
*/
|
|
305
|
+
function prettySql(sql: string): string {
|
|
306
|
+
try {
|
|
307
|
+
return formatSql(sql, { language: "spark", keywordCase: "upper" });
|
|
308
|
+
} catch {
|
|
309
|
+
return sql;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Copy-to-clipboard button with a transient confirmation state: the
|
|
315
|
+
* icon flips to a check for ~1.5s after a successful copy. Shared by
|
|
316
|
+
* the SQL preview (and available to any block that needs a compact
|
|
317
|
+
* copy affordance).
|
|
318
|
+
*/
|
|
319
|
+
const CopyButton = ({ value, className }: { value: string; className?: string }) => {
|
|
320
|
+
const [copied, setCopied] = useState(false);
|
|
321
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
322
|
+
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
|
|
323
|
+
const onCopy = () => {
|
|
324
|
+
void navigator.clipboard.writeText(value).then(() => {
|
|
325
|
+
setCopied(true);
|
|
326
|
+
clearTimeout(timer.current ?? undefined);
|
|
327
|
+
timer.current = setTimeout(() => setCopied(false), 1500);
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
return (
|
|
331
|
+
<Tooltip>
|
|
332
|
+
<TooltipTrigger asChild>
|
|
333
|
+
<Button
|
|
334
|
+
type="button"
|
|
335
|
+
size="icon"
|
|
336
|
+
variant="ghost"
|
|
337
|
+
className={cn("size-6", className)}
|
|
338
|
+
onClick={onCopy}
|
|
339
|
+
>
|
|
340
|
+
{copied ? <CheckIcon className="size-3" /> : <CopyIcon className="size-3" />}
|
|
341
|
+
</Button>
|
|
342
|
+
</TooltipTrigger>
|
|
343
|
+
<TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
|
|
344
|
+
</Tooltip>
|
|
345
|
+
);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Render a SQL string as a syntax-highlighted code block. The query is
|
|
350
|
+
* first run through {@link prettySql} so one-line Genie output reads as
|
|
351
|
+
* formatted SQL, then highlighted to minimal inline HTML via
|
|
352
|
+
* {@link highlightToHtml} (shiki). Unlike Streamdown's code renderer,
|
|
353
|
+
* this emits a plain `<pre><code>` with only per-token color spans - no
|
|
354
|
+
* line-number gutter or per-line wrappers - so the SQL selects and
|
|
355
|
+
* copies cleanly. A {@link CopyButton} copies the formatted source. The
|
|
356
|
+
* highlighter loads asynchronously, so we render uncolored text until
|
|
357
|
+
* the tokens are ready to avoid a flash of empty space.
|
|
358
|
+
*/
|
|
359
|
+
export const SqlBlock = ({ sql }: { sql: string }) => {
|
|
360
|
+
const formatted = useMemo(() => prettySql(sql), [sql]);
|
|
361
|
+
const [html, setHtml] = useState<string | null>(null);
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
let active = true;
|
|
364
|
+
setHtml(null);
|
|
365
|
+
void highlightToHtml(formatted, "sql").then((result) => {
|
|
366
|
+
if (active) setHtml(result);
|
|
367
|
+
});
|
|
368
|
+
return () => {
|
|
369
|
+
active = false;
|
|
370
|
+
};
|
|
371
|
+
}, [formatted]);
|
|
372
|
+
return (
|
|
373
|
+
<div className="group relative">
|
|
374
|
+
<CopyButton
|
|
375
|
+
value={formatted}
|
|
376
|
+
className="absolute right-1.5 top-1.5 z-10 bg-background/70 opacity-0 backdrop-blur transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
|
377
|
+
/>
|
|
378
|
+
<pre className="max-w-full overflow-x-auto rounded-md border border-border bg-background p-3 font-mono text-[11px] leading-relaxed">
|
|
379
|
+
{html === null ? (
|
|
380
|
+
<code>{formatted}</code>
|
|
381
|
+
) : (
|
|
382
|
+
<code dangerouslySetInnerHTML={{ __html: html }} />
|
|
383
|
+
)}
|
|
384
|
+
</pre>
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
};
|