@useatlas/react 0.0.1
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 +95 -0
- package/dist/chunk-2WFDP7G5.js +231 -0
- package/dist/chunk-2WFDP7G5.js.map +1 -0
- package/dist/chunk-44HBZYKP.js +224 -0
- package/dist/chunk-44HBZYKP.js.map +1 -0
- package/dist/chunk-5SEVKHS5.cjs +229 -0
- package/dist/chunk-5SEVKHS5.cjs.map +1 -0
- package/dist/chunk-UIRB6L36.cjs +249 -0
- package/dist/chunk-UIRB6L36.cjs.map +1 -0
- package/dist/hooks.cjs +251 -0
- package/dist/hooks.cjs.map +1 -0
- package/dist/hooks.d.cts +132 -0
- package/dist/hooks.d.ts +132 -0
- package/dist/hooks.js +237 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.cjs +2976 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +69 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +2926 -0
- package/dist/index.js.map +1 -0
- package/dist/result-chart-NFAJ4IQ5.js +398 -0
- package/dist/result-chart-NFAJ4IQ5.js.map +1 -0
- package/dist/result-chart-YLCKBNV4.cjs +400 -0
- package/dist/result-chart-YLCKBNV4.cjs.map +1 -0
- package/dist/styles.css +59 -0
- package/dist/use-dark-mode-rFxawUv1.d.cts +123 -0
- package/dist/use-dark-mode-rFxawUv1.d.ts +123 -0
- package/dist/widget.css +2 -0
- package/dist/widget.js +445 -0
- package/package.json +113 -0
- package/src/components/__tests__/tool-renderers.test.tsx +239 -0
- package/src/components/actions/action-approval-card.tsx +296 -0
- package/src/components/actions/action-status-badge.tsx +50 -0
- package/src/components/admin/change-password-dialog.tsx +128 -0
- package/src/components/atlas-chat.tsx +656 -0
- package/src/components/chart/chart-detection.ts +318 -0
- package/src/components/chart/result-chart.tsx +590 -0
- package/src/components/chat/api-key-bar.tsx +66 -0
- package/src/components/chat/copy-button.tsx +25 -0
- package/src/components/chat/data-table.tsx +104 -0
- package/src/components/chat/error-banner.tsx +32 -0
- package/src/components/chat/explore-card.tsx +41 -0
- package/src/components/chat/follow-up-chips.tsx +29 -0
- package/src/components/chat/loading-card.tsx +10 -0
- package/src/components/chat/managed-auth-card.tsx +116 -0
- package/src/components/chat/markdown.tsx +146 -0
- package/src/components/chat/python-result-card.tsx +245 -0
- package/src/components/chat/sql-block.tsx +54 -0
- package/src/components/chat/sql-result-card.tsx +163 -0
- package/src/components/chat/starter-prompts.ts +6 -0
- package/src/components/chat/tool-part.tsx +106 -0
- package/src/components/chat/typing-indicator.tsx +22 -0
- package/src/components/conversations/conversation-item.tsx +135 -0
- package/src/components/conversations/conversation-list.tsx +69 -0
- package/src/components/conversations/conversation-sidebar.tsx +113 -0
- package/src/components/conversations/delete-confirmation.tsx +27 -0
- package/src/components/schema-explorer/schema-explorer.tsx +517 -0
- package/src/components/ui/alert-dialog.tsx +196 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/scroll-area.tsx +62 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +47 -0
- package/src/context.tsx +85 -0
- package/src/env.d.ts +9 -0
- package/src/hooks/__tests__/provider.test.tsx +83 -0
- package/src/hooks/__tests__/use-atlas-auth.test.tsx +283 -0
- package/src/hooks/__tests__/use-atlas-chat.test.tsx +157 -0
- package/src/hooks/__tests__/use-atlas-conversations.test.tsx +159 -0
- package/src/hooks/__tests__/use-atlas-theme.test.tsx +56 -0
- package/src/hooks/index.ts +47 -0
- package/src/hooks/provider.tsx +77 -0
- package/src/hooks/theme-init-script.ts +17 -0
- package/src/hooks/use-atlas-auth.ts +131 -0
- package/src/hooks/use-atlas-chat.ts +102 -0
- package/src/hooks/use-atlas-conversations.ts +61 -0
- package/src/hooks/use-atlas-theme.ts +34 -0
- package/src/hooks/use-conversations.ts +189 -0
- package/src/hooks/use-dark-mode.ts +150 -0
- package/src/index.ts +36 -0
- package/src/lib/action-types.ts +11 -0
- package/src/lib/helpers.ts +198 -0
- package/src/lib/tool-renderer-types.ts +76 -0
- package/src/lib/types.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +59 -0
- package/src/test-setup.ts +55 -0
- package/src/widget-entry.ts +20 -0
- package/src/widget.css +12 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Component, type ReactNode, type ErrorInfo, useContext, useState } from "react";
|
|
4
|
+
import { getToolArgs, getToolResult, isToolComplete } from "../../lib/helpers";
|
|
5
|
+
import { DarkModeContext } from "../../hooks/use-dark-mode";
|
|
6
|
+
import { LoadingCard } from "./loading-card";
|
|
7
|
+
import { DataTable } from "./data-table";
|
|
8
|
+
import type { ChartDetectionResult, ChartType } from "../chart/chart-detection";
|
|
9
|
+
import { lazy, Suspense } from "react";
|
|
10
|
+
|
|
11
|
+
const ResultChart = lazy(() => import("../chart/result-chart").then((m) => ({ default: m.ResultChart })));
|
|
12
|
+
|
|
13
|
+
interface RechartsChartConfig {
|
|
14
|
+
type: ChartType;
|
|
15
|
+
data: Record<string, unknown>[];
|
|
16
|
+
categoryKey: string;
|
|
17
|
+
valueKeys: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PythonChart {
|
|
21
|
+
base64: string;
|
|
22
|
+
mimeType: "image/png";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg"]);
|
|
26
|
+
|
|
27
|
+
/* ------------------------------------------------------------------ */
|
|
28
|
+
/* Error boundary */
|
|
29
|
+
/* ------------------------------------------------------------------ */
|
|
30
|
+
|
|
31
|
+
class PythonErrorBoundary extends Component<
|
|
32
|
+
{ children: ReactNode },
|
|
33
|
+
{ hasError: boolean; error?: Error }
|
|
34
|
+
> {
|
|
35
|
+
constructor(props: { children: ReactNode }) {
|
|
36
|
+
super(props);
|
|
37
|
+
this.state = { hasError: false };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static getDerivedStateFromError(error: Error) {
|
|
41
|
+
return { hasError: true, error };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
45
|
+
console.error("PythonResultCard rendering failed:", error, info.componentStack);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
render() {
|
|
49
|
+
if (this.state.hasError) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="my-2 rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/50 dark:bg-red-950/20 dark:text-red-400">
|
|
52
|
+
Python result could not be rendered: {this.state.error?.message ?? "unknown error"}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return this.props.children;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* ------------------------------------------------------------------ */
|
|
61
|
+
/* Main component */
|
|
62
|
+
/* ------------------------------------------------------------------ */
|
|
63
|
+
|
|
64
|
+
export function PythonResultCard({ part }: { part: unknown }) {
|
|
65
|
+
return (
|
|
66
|
+
<PythonErrorBoundary>
|
|
67
|
+
<PythonResultCardInner part={part} />
|
|
68
|
+
</PythonErrorBoundary>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function PythonResultCardInner({ part }: { part: unknown }) {
|
|
73
|
+
const dark = useContext(DarkModeContext);
|
|
74
|
+
const args = getToolArgs(part);
|
|
75
|
+
const raw = getToolResult(part);
|
|
76
|
+
const done = isToolComplete(part);
|
|
77
|
+
const [open, setOpen] = useState(true);
|
|
78
|
+
|
|
79
|
+
if (!done) return <LoadingCard label="Running Python..." />;
|
|
80
|
+
|
|
81
|
+
// Structural validation — result must be a non-null object
|
|
82
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
83
|
+
return (
|
|
84
|
+
<div className="my-2 rounded-lg border border-yellow-300 bg-yellow-50 px-3 py-2 text-xs text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-400">
|
|
85
|
+
Python executed but returned an unexpected result format.
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = raw as Record<string, unknown>;
|
|
91
|
+
|
|
92
|
+
if (!result.success) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="my-2 overflow-hidden rounded-lg border border-red-300 bg-red-50 dark:border-red-900/50 dark:bg-red-950/20">
|
|
95
|
+
<div className="px-3 py-2 text-xs font-medium text-red-700 dark:text-red-400">
|
|
96
|
+
Python execution failed
|
|
97
|
+
</div>
|
|
98
|
+
<pre className="border-t border-red-200 px-3 py-2 text-xs whitespace-pre-wrap text-red-600 dark:border-red-900/50 dark:text-red-300">
|
|
99
|
+
{String(result.error ?? "Unknown error")}
|
|
100
|
+
</pre>
|
|
101
|
+
{!!result.output && (
|
|
102
|
+
<pre className="border-t border-red-200 px-3 py-2 text-xs whitespace-pre-wrap text-red-500 dark:border-red-900/50 dark:text-red-400">
|
|
103
|
+
{String(result.output)}
|
|
104
|
+
</pre>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const output = result.output ? String(result.output) : null;
|
|
111
|
+
const table = result.table as { columns: string[]; rows: unknown[][] } | undefined;
|
|
112
|
+
const charts = Array.isArray(result.charts) ? (result.charts as PythonChart[]) : undefined;
|
|
113
|
+
const rechartsCharts = Array.isArray(result.rechartsCharts) ? (result.rechartsCharts as RechartsChartConfig[]) : undefined;
|
|
114
|
+
|
|
115
|
+
const hasTable = table && Array.isArray(table.columns) && Array.isArray(table.rows)
|
|
116
|
+
&& table.columns.length > 0 && table.rows.length > 0;
|
|
117
|
+
const hasCharts = charts && charts.length > 0;
|
|
118
|
+
const hasRechartsCharts = rechartsCharts && rechartsCharts.length > 0;
|
|
119
|
+
|
|
120
|
+
// Filter charts to only safe image MIME types
|
|
121
|
+
const safeCharts = hasCharts
|
|
122
|
+
? charts.filter((c) => c.base64 && ALLOWED_IMAGE_MIME.has(c.mimeType))
|
|
123
|
+
: [];
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="my-2 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
|
127
|
+
<button
|
|
128
|
+
onClick={() => setOpen(!open)}
|
|
129
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60"
|
|
130
|
+
>
|
|
131
|
+
<span className="rounded bg-emerald-100 px-1.5 py-0.5 font-medium text-emerald-700 dark:bg-emerald-600/20 dark:text-emerald-400">
|
|
132
|
+
Python
|
|
133
|
+
</span>
|
|
134
|
+
<span className="flex-1 truncate text-zinc-500 dark:text-zinc-400">
|
|
135
|
+
{String(args.explanation ?? "Python result")}
|
|
136
|
+
</span>
|
|
137
|
+
<span className="text-zinc-400 dark:text-zinc-600">{open ? "\u25BE" : "\u25B8"}</span>
|
|
138
|
+
</button>
|
|
139
|
+
|
|
140
|
+
{open && (
|
|
141
|
+
<div className="space-y-2 border-t border-zinc-100 px-3 py-2 dark:border-zinc-800">
|
|
142
|
+
{output && (
|
|
143
|
+
<pre className="rounded-md bg-zinc-100 px-3 py-2 text-xs whitespace-pre-wrap text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
|
144
|
+
{output}
|
|
145
|
+
</pre>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{hasTable && <DataTable columns={table.columns} rows={table.rows} />}
|
|
149
|
+
|
|
150
|
+
{hasRechartsCharts &&
|
|
151
|
+
rechartsCharts.map((chart, i) => (
|
|
152
|
+
<RechartsChartSection key={i} chart={chart} dark={dark} />
|
|
153
|
+
))}
|
|
154
|
+
|
|
155
|
+
{safeCharts.length > 0 &&
|
|
156
|
+
safeCharts.map((chart, i) => (
|
|
157
|
+
<ChartImage key={i} chart={chart} index={i} />
|
|
158
|
+
))}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ------------------------------------------------------------------ */
|
|
166
|
+
/* Chart image with error handling */
|
|
167
|
+
/* ------------------------------------------------------------------ */
|
|
168
|
+
|
|
169
|
+
function ChartImage({ chart, index }: { chart: PythonChart; index: number }) {
|
|
170
|
+
const [failed, setFailed] = useState(false);
|
|
171
|
+
|
|
172
|
+
if (failed) {
|
|
173
|
+
return (
|
|
174
|
+
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-3 py-2 text-xs text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-400">
|
|
175
|
+
Chart {index + 1} failed to render.
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
// eslint-disable-next-line @next/next/no-img-element -- @useatlas/react is framework-agnostic
|
|
182
|
+
<img
|
|
183
|
+
src={`data:${chart.mimeType};base64,${chart.base64}`}
|
|
184
|
+
alt={`Python chart ${index + 1}`}
|
|
185
|
+
className="max-w-full rounded-lg border border-zinc-200 dark:border-zinc-700"
|
|
186
|
+
onError={() => setFailed(true)}
|
|
187
|
+
/>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------ */
|
|
192
|
+
/* Recharts section — bypasses auto-detection with synthetic result */
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
|
|
195
|
+
function RechartsChartSection({ chart, dark }: { chart: RechartsChartConfig; dark: boolean }) {
|
|
196
|
+
if (!chart.categoryKey || !Array.isArray(chart.valueKeys) || !Array.isArray(chart.data)) {
|
|
197
|
+
return (
|
|
198
|
+
<div className="rounded-lg border border-yellow-300 bg-yellow-50 px-3 py-2 text-xs text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-400">
|
|
199
|
+
Chart data is incomplete or malformed.
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const headers = [chart.categoryKey, ...chart.valueKeys];
|
|
205
|
+
const rows: string[][] = chart.data.map((row) =>
|
|
206
|
+
headers.map((key) => (row[key] == null ? "" : String(row[key]))),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Build a synthetic detection result so ResultChart uses the backend's
|
|
210
|
+
// chart config directly, bypassing auto-detection that might reject it.
|
|
211
|
+
const detectionResult: ChartDetectionResult = {
|
|
212
|
+
chartable: true,
|
|
213
|
+
columns: headers.map((h, i) => ({
|
|
214
|
+
header: h,
|
|
215
|
+
type: i === 0 ? "categorical" as const : "numeric" as const,
|
|
216
|
+
index: i,
|
|
217
|
+
uniqueCount: i === 0 ? chart.data.length : 0,
|
|
218
|
+
})),
|
|
219
|
+
recommendations: [{
|
|
220
|
+
type: chart.type,
|
|
221
|
+
categoryColumn: { header: chart.categoryKey, type: "categorical" as const, index: 0, uniqueCount: chart.data.length },
|
|
222
|
+
valueColumns: chart.valueKeys.map((k, i) => ({
|
|
223
|
+
header: k,
|
|
224
|
+
type: "numeric" as const,
|
|
225
|
+
index: i + 1,
|
|
226
|
+
uniqueCount: 0,
|
|
227
|
+
})) as [{ header: string; type: "numeric"; index: number; uniqueCount: number }, ...{ header: string; type: "numeric"; index: number; uniqueCount: number }[]],
|
|
228
|
+
reason: "Python-generated chart",
|
|
229
|
+
}],
|
|
230
|
+
data: chart.data.map((row) => {
|
|
231
|
+
const out: Record<string, string | number> = {};
|
|
232
|
+
for (const key of headers) {
|
|
233
|
+
const val = row[key];
|
|
234
|
+
out[key] = typeof val === "number" ? val : String(val ?? "");
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}),
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<Suspense fallback={<div className="h-64 animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800" />}>
|
|
242
|
+
<ResultChart headers={headers} rows={rows} dark={dark} detectionResult={detectionResult} />
|
|
243
|
+
</Suspense>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useContext, useState, useEffect } from "react";
|
|
4
|
+
import { DarkModeContext } from "../../hooks/use-dark-mode";
|
|
5
|
+
import { CopyButton } from "./copy-button";
|
|
6
|
+
|
|
7
|
+
type SyntaxHighlighterModule = typeof import("react-syntax-highlighter");
|
|
8
|
+
type StyleModule = typeof import("react-syntax-highlighter/dist/esm/styles/prism");
|
|
9
|
+
|
|
10
|
+
let _cache: { Prism: SyntaxHighlighterModule["Prism"]; oneDark: StyleModule["oneDark"]; oneLight: StyleModule["oneLight"] } | null = null;
|
|
11
|
+
|
|
12
|
+
const SQL_BLOCK_STYLE = {
|
|
13
|
+
margin: 0,
|
|
14
|
+
borderRadius: "0.5rem",
|
|
15
|
+
fontSize: "0.75rem",
|
|
16
|
+
padding: "0.75rem 1rem",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export function SQLBlock({ sql }: { sql: string }) {
|
|
20
|
+
const dark = useContext(DarkModeContext);
|
|
21
|
+
const [mod, setMod] = useState(_cache);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (_cache) return;
|
|
25
|
+
Promise.all([
|
|
26
|
+
import("react-syntax-highlighter"),
|
|
27
|
+
import("react-syntax-highlighter/dist/esm/styles/prism"),
|
|
28
|
+
]).then(([sh, styles]) => {
|
|
29
|
+
_cache = { Prism: sh.Prism, oneDark: styles.oneDark, oneLight: styles.oneLight };
|
|
30
|
+
setMod(_cache);
|
|
31
|
+
});
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="relative">
|
|
36
|
+
{mod ? (
|
|
37
|
+
<mod.Prism
|
|
38
|
+
language="sql"
|
|
39
|
+
style={dark ? mod.oneDark : mod.oneLight}
|
|
40
|
+
customStyle={SQL_BLOCK_STYLE}
|
|
41
|
+
>
|
|
42
|
+
{sql}
|
|
43
|
+
</mod.Prism>
|
|
44
|
+
) : (
|
|
45
|
+
<pre className="overflow-x-auto rounded-lg bg-zinc-100 p-3 text-xs dark:bg-zinc-800">
|
|
46
|
+
<code>{sql}</code>
|
|
47
|
+
</pre>
|
|
48
|
+
)}
|
|
49
|
+
<div className="absolute right-2 top-2">
|
|
50
|
+
<CopyButton text={sql} label="Copy SQL" />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useContext, useMemo, useState } from "react";
|
|
4
|
+
import { getToolArgs, getToolResult, isToolComplete, downloadCSV, downloadExcel, toCsvString } from "../../lib/helpers";
|
|
5
|
+
import { FileDown, FileSpreadsheet } from "lucide-react";
|
|
6
|
+
import { DarkModeContext } from "../../hooks/use-dark-mode";
|
|
7
|
+
import { detectCharts } from "../chart/chart-detection";
|
|
8
|
+
import { lazy, Suspense } from "react";
|
|
9
|
+
|
|
10
|
+
const ResultChart = lazy(() => import("../chart/result-chart").then((m) => ({ default: m.ResultChart })));
|
|
11
|
+
const ChartFallback = <div className="h-64 animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800" />;
|
|
12
|
+
|
|
13
|
+
import { LoadingCard } from "./loading-card";
|
|
14
|
+
import { DataTable } from "./data-table";
|
|
15
|
+
import { SQLBlock } from "./sql-block";
|
|
16
|
+
|
|
17
|
+
/** Convert structured rows (Record<string, unknown>[]) to string[][] for chart detection. */
|
|
18
|
+
function toStringRows(columns: string[], rows: Record<string, unknown>[]): string[][] {
|
|
19
|
+
return rows.map((row) => columns.map((col) => (row[col] == null ? "" : String(row[col]))));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export function SQLResultCard({ part }: { part: unknown }) {
|
|
24
|
+
const dark = useContext(DarkModeContext);
|
|
25
|
+
const args = getToolArgs(part);
|
|
26
|
+
const result = getToolResult(part) as Record<string, unknown> | null;
|
|
27
|
+
const done = isToolComplete(part);
|
|
28
|
+
const [open, setOpen] = useState(true);
|
|
29
|
+
const [sqlOpen, setSqlOpen] = useState(false);
|
|
30
|
+
const [viewMode, setViewMode] = useState<"both" | "chart" | "table">("both");
|
|
31
|
+
|
|
32
|
+
const columns = useMemo(
|
|
33
|
+
() => (done && result?.success ? ((result.columns as string[]) ?? []) : []),
|
|
34
|
+
[done, result],
|
|
35
|
+
);
|
|
36
|
+
const rows = useMemo(
|
|
37
|
+
() => (done && result?.success ? ((result.rows as Record<string, unknown>[]) ?? []) : []),
|
|
38
|
+
[done, result],
|
|
39
|
+
);
|
|
40
|
+
const sql = String(args.sql ?? "");
|
|
41
|
+
|
|
42
|
+
const stringRows = useMemo(() => toStringRows(columns, rows), [columns, rows]);
|
|
43
|
+
const chartResult = useMemo(
|
|
44
|
+
() => (columns.length > 0 ? detectCharts(columns, stringRows) : { chartable: false as const, columns: [] }),
|
|
45
|
+
[columns, stringRows],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (!done) return <LoadingCard label="Executing query..." />;
|
|
49
|
+
|
|
50
|
+
if (!result) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="my-2 rounded-lg border border-yellow-300 bg-yellow-50 text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-400 px-3 py-2 text-xs">
|
|
53
|
+
Query completed but no result was returned.
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!result.success) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="my-2 rounded-lg border border-red-300 bg-red-50 text-red-700 dark:border-red-900/50 dark:bg-red-950/20 dark:text-red-400 px-3 py-2 text-xs">
|
|
61
|
+
Query failed. Check the query and try again.
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hasData = columns.length > 0 && rows.length > 0;
|
|
67
|
+
const showChart = chartResult.chartable && (viewMode === "chart" || viewMode === "both");
|
|
68
|
+
const showTable = viewMode === "table" || viewMode === "both" || !chartResult.chartable;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="my-2 overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
|
72
|
+
<button
|
|
73
|
+
onClick={() => setOpen(!open)}
|
|
74
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-zinc-100/60 dark:hover:bg-zinc-800/60"
|
|
75
|
+
>
|
|
76
|
+
<span className="rounded bg-blue-100 text-blue-700 dark:bg-blue-600/20 dark:text-blue-400 px-1.5 py-0.5 font-medium">
|
|
77
|
+
SQL
|
|
78
|
+
</span>
|
|
79
|
+
<span className="flex-1 truncate text-zinc-500 dark:text-zinc-400">
|
|
80
|
+
{String(args.explanation ?? "Query result")}
|
|
81
|
+
</span>
|
|
82
|
+
<span className="text-zinc-500">
|
|
83
|
+
{rows.length} row{rows.length !== 1 ? "s" : ""}
|
|
84
|
+
{result.truncated ? "+" : ""}
|
|
85
|
+
</span>
|
|
86
|
+
<span className="text-zinc-400 dark:text-zinc-600">{open ? "\u25BE" : "\u25B8"}</span>
|
|
87
|
+
</button>
|
|
88
|
+
{open && (
|
|
89
|
+
<div className="border-t border-zinc-100 dark:border-zinc-800">
|
|
90
|
+
{hasData && chartResult.chartable && (
|
|
91
|
+
<div className="flex gap-1 px-3 pt-2">
|
|
92
|
+
{(["chart", "both", "table"] as const).map((mode) => (
|
|
93
|
+
<button
|
|
94
|
+
key={mode}
|
|
95
|
+
onClick={() => setViewMode(mode)}
|
|
96
|
+
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
97
|
+
viewMode === mode
|
|
98
|
+
? "bg-zinc-200 text-zinc-800 dark:bg-zinc-700 dark:text-zinc-200"
|
|
99
|
+
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
|
|
100
|
+
}`}
|
|
101
|
+
>
|
|
102
|
+
{mode === "chart" ? "Chart" : mode === "both" ? "Both" : "Table"}
|
|
103
|
+
</button>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{hasData && showChart && (
|
|
109
|
+
<div className="px-3 py-2">
|
|
110
|
+
<Suspense fallback={ChartFallback}>
|
|
111
|
+
<ResultChart headers={columns} rows={stringRows} dark={dark} detectionResult={chartResult} />
|
|
112
|
+
</Suspense>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{hasData && showTable && <DataTable columns={columns} rows={rows} />}
|
|
117
|
+
|
|
118
|
+
{!hasData && (
|
|
119
|
+
<div className="px-3 py-2 text-xs text-zinc-500 dark:text-zinc-400">
|
|
120
|
+
Query returned 0 rows.
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
|
125
|
+
{sql && (
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => setSqlOpen(!sqlOpen)}
|
|
128
|
+
className="rounded border border-zinc-200 px-2 py-1.5 text-xs text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
129
|
+
>
|
|
130
|
+
{sqlOpen ? "Hide SQL" : "Show SQL"}
|
|
131
|
+
</button>
|
|
132
|
+
)}
|
|
133
|
+
{hasData && (
|
|
134
|
+
<button
|
|
135
|
+
onClick={() => downloadCSV(toCsvString(columns, rows))}
|
|
136
|
+
className="inline-flex items-center gap-1.5 rounded border border-zinc-200 px-2 py-1.5 text-xs text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
137
|
+
title="Download CSV"
|
|
138
|
+
>
|
|
139
|
+
<FileDown className="size-3.5" />
|
|
140
|
+
<span className="hidden sm:inline">CSV</span>
|
|
141
|
+
</button>
|
|
142
|
+
)}
|
|
143
|
+
{hasData && (
|
|
144
|
+
<button
|
|
145
|
+
onClick={() => { downloadExcel(columns, rows).catch((err) => { console.warn("Excel download failed:", err); }); }}
|
|
146
|
+
className="inline-flex items-center gap-1.5 rounded border border-zinc-200 px-2 py-1.5 text-xs text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-800 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-zinc-500 dark:hover:text-zinc-200"
|
|
147
|
+
title="Download Excel"
|
|
148
|
+
>
|
|
149
|
+
<FileSpreadsheet className="size-3.5" />
|
|
150
|
+
<span className="hidden sm:inline">Excel</span>
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
{sqlOpen && sql && (
|
|
155
|
+
<div className="px-3 pb-2">
|
|
156
|
+
<SQLBlock sql={sql} />
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Component, memo, type ReactNode, type ErrorInfo } from "react";
|
|
4
|
+
import { getToolName } from "ai";
|
|
5
|
+
import { getToolArgs, getToolResult, isToolComplete } from "../../lib/helpers";
|
|
6
|
+
import { isActionToolResult } from "../../lib/action-types";
|
|
7
|
+
import { ExploreCard } from "./explore-card";
|
|
8
|
+
import { SQLResultCard } from "./sql-result-card";
|
|
9
|
+
import { ActionApprovalCard } from "../actions/action-approval-card";
|
|
10
|
+
import { PythonResultCard } from "./python-result-card";
|
|
11
|
+
import type { ToolRenderers } from "../../lib/tool-renderer-types";
|
|
12
|
+
|
|
13
|
+
export interface ToolPartProps {
|
|
14
|
+
part: unknown;
|
|
15
|
+
toolRenderers?: ToolRenderers;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Error boundary that catches rendering failures in custom tool renderers. */
|
|
19
|
+
class ToolRendererErrorBoundary extends Component<
|
|
20
|
+
{ toolName: string; children: ReactNode },
|
|
21
|
+
{ hasError: boolean; error?: Error }
|
|
22
|
+
> {
|
|
23
|
+
constructor(props: { toolName: string; children: ReactNode }) {
|
|
24
|
+
super(props);
|
|
25
|
+
this.state = { hasError: false };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
static getDerivedStateFromError(error: Error) {
|
|
29
|
+
return { hasError: true, error };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
componentDidCatch(error: Error, info: ErrorInfo) {
|
|
33
|
+
console.error(
|
|
34
|
+
`Custom renderer for tool "${this.props.toolName}" failed:`,
|
|
35
|
+
error,
|
|
36
|
+
info.componentStack,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
render() {
|
|
41
|
+
if (this.state.hasError) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="my-2 rounded-lg border border-red-300 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/50 dark:bg-red-950/20 dark:text-red-400">
|
|
44
|
+
Custom renderer for “{this.props.toolName}” failed: {this.state.error?.message ?? "unknown error"}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return this.props.children;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const ToolPart = memo(function ToolPart({ part, toolRenderers }: ToolPartProps) {
|
|
53
|
+
let name: string;
|
|
54
|
+
try {
|
|
55
|
+
name = getToolName(part as Parameters<typeof getToolName>[0]);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.warn("Failed to determine tool name:", err);
|
|
58
|
+
return (
|
|
59
|
+
<div className="my-2 rounded-lg border border-yellow-300 bg-yellow-50 px-3 py-2 text-xs text-yellow-700 dark:border-yellow-900/50 dark:bg-yellow-950/20 dark:text-yellow-400">
|
|
60
|
+
Tool result (unknown type)
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Custom renderers take precedence over built-in defaults.
|
|
66
|
+
// Note: this also overrides the ActionApprovalCard for action tools —
|
|
67
|
+
// if you register a renderer for a tool that uses the action approval flow,
|
|
68
|
+
// you are responsible for handling the approval UI yourself.
|
|
69
|
+
const CustomRenderer = toolRenderers?.[name];
|
|
70
|
+
if (CustomRenderer) {
|
|
71
|
+
const args = getToolArgs(part);
|
|
72
|
+
const result = getToolResult(part);
|
|
73
|
+
const isLoading = !isToolComplete(part);
|
|
74
|
+
return (
|
|
75
|
+
<ToolRendererErrorBoundary toolName={name}>
|
|
76
|
+
<CustomRenderer toolName={name} args={args} result={result} isLoading={isLoading} />
|
|
77
|
+
</ToolRendererErrorBoundary>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
switch (name) {
|
|
82
|
+
case "explore":
|
|
83
|
+
return <ExploreCard part={part} />;
|
|
84
|
+
case "executeSQL":
|
|
85
|
+
return <SQLResultCard part={part} />;
|
|
86
|
+
case "executePython":
|
|
87
|
+
return <PythonResultCard part={part} />;
|
|
88
|
+
default: {
|
|
89
|
+
const result = getToolResult(part);
|
|
90
|
+
if (isActionToolResult(result)) {
|
|
91
|
+
return <ActionApprovalCard part={part} />;
|
|
92
|
+
}
|
|
93
|
+
return (
|
|
94
|
+
<div className="my-2 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900">
|
|
95
|
+
Tool: {name}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}, (prev, next) => {
|
|
101
|
+
// Once a tool part is complete, its output won't change — skip re-renders.
|
|
102
|
+
// This prevents the Recharts render tree from contributing to React's update depth limit.
|
|
103
|
+
// Also check toolRenderers identity so swapping renderers at runtime triggers a re-render.
|
|
104
|
+
if (isToolComplete(prev.part) && isToolComplete(next.part) && prev.toolRenderers === next.toolRenderers) return true;
|
|
105
|
+
return false;
|
|
106
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
const DELAY_1 = { animationDelay: "150ms" } as const;
|
|
4
|
+
const DELAY_2 = { animationDelay: "300ms" } as const;
|
|
5
|
+
|
|
6
|
+
export function TypingIndicator() {
|
|
7
|
+
return (
|
|
8
|
+
<div className="flex justify-start">
|
|
9
|
+
<div className="flex items-center gap-1 rounded-xl bg-zinc-100 px-4 py-3 dark:bg-zinc-800">
|
|
10
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-400 dark:bg-zinc-500" />
|
|
11
|
+
<span
|
|
12
|
+
className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-400 dark:bg-zinc-500"
|
|
13
|
+
style={DELAY_1}
|
|
14
|
+
/>
|
|
15
|
+
<span
|
|
16
|
+
className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-400 dark:bg-zinc-500"
|
|
17
|
+
style={DELAY_2}
|
|
18
|
+
/>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|