create-atlas-agent 0.2.5
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 +69 -0
- package/index.ts +526 -0
- package/package.json +33 -0
- package/template/.env.example +49 -0
- package/template/Dockerfile +31 -0
- package/template/bin/atlas.ts +1092 -0
- package/template/bin/enrich.ts +551 -0
- package/template/data/.gitkeep +0 -0
- package/template/data/demo-sqlite.sql +372 -0
- package/template/data/demo.sql +371 -0
- package/template/docker-compose.yml +23 -0
- package/template/docs/deploy.md +341 -0
- package/template/eslint.config.mjs +18 -0
- package/template/fly.toml +46 -0
- package/template/gitignore +5 -0
- package/template/next.config.ts +8 -0
- package/template/package.json +55 -0
- package/template/postcss.config.mjs +8 -0
- package/template/public/.gitkeep +0 -0
- package/template/railway.json +13 -0
- package/template/render.yaml +19 -0
- package/template/semantic/catalog.yml +5 -0
- package/template/semantic/entities/.gitkeep +0 -0
- package/template/semantic/glossary.yml +6 -0
- package/template/semantic/metrics/.gitkeep +0 -0
- package/template/src/app/api/chat/route.ts +107 -0
- package/template/src/app/api/health/route.ts +97 -0
- package/template/src/app/error.tsx +24 -0
- package/template/src/app/globals.css +1 -0
- package/template/src/app/layout.tsx +19 -0
- package/template/src/app/page.tsx +650 -0
- package/template/src/global.d.ts +1 -0
- package/template/src/lib/agent.ts +112 -0
- package/template/src/lib/db/connection.ts +150 -0
- package/template/src/lib/providers.ts +63 -0
- package/template/src/lib/semantic.ts +53 -0
- package/template/src/lib/startup.ts +211 -0
- package/template/src/lib/tools/__tests__/sql.test.ts +538 -0
- package/template/src/lib/tools/explore-sandbox.ts +189 -0
- package/template/src/lib/tools/explore.ts +164 -0
- package/template/src/lib/tools/report.ts +33 -0
- package/template/src/lib/tools/sql.ts +202 -0
- package/template/src/types/vercel-sandbox.d.ts +54 -0
- package/template/tsconfig.json +41 -0
- package/template/vercel.json +3 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useChat } from "@ai-sdk/react";
|
|
4
|
+
import { DefaultChatTransport, isToolUIPart, getToolName } from "ai";
|
|
5
|
+
import { useState, useRef, useEffect } from "react";
|
|
6
|
+
import ReactMarkdown from "react-markdown";
|
|
7
|
+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
|
8
|
+
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
|
9
|
+
|
|
10
|
+
const transport = new DefaultChatTransport({ api: "/api/chat" });
|
|
11
|
+
|
|
12
|
+
/* ------------------------------------------------------------------ */
|
|
13
|
+
/* Helpers */
|
|
14
|
+
/* ------------------------------------------------------------------ */
|
|
15
|
+
|
|
16
|
+
/** Extract tool invocation input from a ToolUIPart. Returns empty object if unavailable. */
|
|
17
|
+
function getToolArgs(part: unknown): Record<string, unknown> {
|
|
18
|
+
if (part == null || typeof part !== "object") return {};
|
|
19
|
+
const input = (part as Record<string, unknown>).input;
|
|
20
|
+
if (input == null || typeof input !== "object") return {};
|
|
21
|
+
return input as Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Extract tool output from a ToolUIPart. Returns null if not yet available. */
|
|
25
|
+
function getToolResult(part: unknown): unknown {
|
|
26
|
+
if (part == null || typeof part !== "object") return null;
|
|
27
|
+
return (part as Record<string, unknown>).output ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when the tool invocation has finished successfully (state is "output-available"). */
|
|
31
|
+
function isToolComplete(part: unknown): boolean {
|
|
32
|
+
if (part == null || typeof part !== "object") return false;
|
|
33
|
+
return (part as Record<string, unknown>).state === "output-available";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse a CSV string into headers + rows. Handles basic quoting and escaped quotes (""). */
|
|
37
|
+
function parseCSV(csv: string): { headers: string[]; rows: string[][] } {
|
|
38
|
+
if (!csv || !csv.trim()) return { headers: [], rows: [] };
|
|
39
|
+
|
|
40
|
+
const lines = csv.trim().split("\n");
|
|
41
|
+
if (lines.length === 0) return { headers: [], rows: [] };
|
|
42
|
+
|
|
43
|
+
function parseLine(line: string): string[] {
|
|
44
|
+
const result: string[] = [];
|
|
45
|
+
let current = "";
|
|
46
|
+
let inQuotes = false;
|
|
47
|
+
for (let k = 0; k < line.length; k++) {
|
|
48
|
+
const char = line[k];
|
|
49
|
+
if (char === '"') {
|
|
50
|
+
if (inQuotes && line[k + 1] === '"') {
|
|
51
|
+
current += '"';
|
|
52
|
+
k++;
|
|
53
|
+
} else {
|
|
54
|
+
inQuotes = !inQuotes;
|
|
55
|
+
}
|
|
56
|
+
} else if (char === "," && !inQuotes) {
|
|
57
|
+
result.push(current.trim());
|
|
58
|
+
current = "";
|
|
59
|
+
} else {
|
|
60
|
+
current += char;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
result.push(current.trim());
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
headers: parseLine(lines[0]),
|
|
69
|
+
rows: lines
|
|
70
|
+
.slice(1)
|
|
71
|
+
.filter((l) => l.trim())
|
|
72
|
+
.map(parseLine),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Trigger a CSV download in the browser. */
|
|
77
|
+
function downloadCSV(csv: string, filename = "atlas-results.csv") {
|
|
78
|
+
let url: string | null = null;
|
|
79
|
+
try {
|
|
80
|
+
const blob = new Blob([csv], { type: "text/csv" });
|
|
81
|
+
url = URL.createObjectURL(blob);
|
|
82
|
+
const a = document.createElement("a");
|
|
83
|
+
a.href = url;
|
|
84
|
+
a.download = filename;
|
|
85
|
+
a.click();
|
|
86
|
+
} catch {
|
|
87
|
+
// Download failed — no good way to surface this without a toast system
|
|
88
|
+
} finally {
|
|
89
|
+
if (url) {
|
|
90
|
+
const blobUrl = url;
|
|
91
|
+
setTimeout(() => URL.revokeObjectURL(blobUrl), 10_000);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Format a cell value: null as em-dash, numbers with locale formatting, else stringified. */
|
|
97
|
+
function formatCell(value: unknown): string {
|
|
98
|
+
if (value == null) return "\u2014";
|
|
99
|
+
if (typeof value === "number") {
|
|
100
|
+
return Number.isInteger(value)
|
|
101
|
+
? value.toLocaleString()
|
|
102
|
+
: value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
103
|
+
}
|
|
104
|
+
return String(value);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ------------------------------------------------------------------ */
|
|
108
|
+
/* Starter prompts — curated examples inspired by the demo dataset */
|
|
109
|
+
/* ------------------------------------------------------------------ */
|
|
110
|
+
|
|
111
|
+
const STARTER_PROMPTS = [
|
|
112
|
+
"What are the top 10 companies by revenue?",
|
|
113
|
+
"Show me the distribution of account types",
|
|
114
|
+
"What is the headcount breakdown by department?",
|
|
115
|
+
"What is total MRR by plan type?",
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/* ------------------------------------------------------------------ */
|
|
119
|
+
/* Shared components */
|
|
120
|
+
/* ------------------------------------------------------------------ */
|
|
121
|
+
|
|
122
|
+
function LoadingCard({ label }: { label: string }) {
|
|
123
|
+
return (
|
|
124
|
+
<div className="my-2 flex items-center gap-2 rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-500">
|
|
125
|
+
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-zinc-600 border-t-zinc-300" />
|
|
126
|
+
{label}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function CopyButton({ text, label = "Copy" }: { text: string; label?: string }) {
|
|
132
|
+
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
|
|
133
|
+
return (
|
|
134
|
+
<button
|
|
135
|
+
onClick={async () => {
|
|
136
|
+
try {
|
|
137
|
+
await navigator.clipboard.writeText(text);
|
|
138
|
+
setState("copied");
|
|
139
|
+
setTimeout(() => setState("idle"), 2000);
|
|
140
|
+
} catch {
|
|
141
|
+
setState("failed");
|
|
142
|
+
setTimeout(() => setState("idle"), 2000);
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
className="rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-400 transition-colors hover:border-zinc-500 hover:text-zinc-200"
|
|
146
|
+
>
|
|
147
|
+
{state === "copied" ? "Copied!" : state === "failed" ? "Failed" : label}
|
|
148
|
+
</button>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Renders a data table from columns + rows.
|
|
154
|
+
* Rows can be Record<string, unknown>[] (from executeSQL) or string[][] (from CSV).
|
|
155
|
+
* Truncates to maxRows (default 10) with a footer count.
|
|
156
|
+
*/
|
|
157
|
+
function DataTable({
|
|
158
|
+
columns,
|
|
159
|
+
rows,
|
|
160
|
+
maxRows = 10,
|
|
161
|
+
}: {
|
|
162
|
+
columns: string[];
|
|
163
|
+
rows: (Record<string, unknown> | unknown[])[];
|
|
164
|
+
maxRows?: number;
|
|
165
|
+
}) {
|
|
166
|
+
const display = rows.slice(0, maxRows);
|
|
167
|
+
const hasMore = rows.length > maxRows;
|
|
168
|
+
|
|
169
|
+
const cell = (row: Record<string, unknown> | unknown[], colIdx: number): unknown => {
|
|
170
|
+
if (Array.isArray(row)) return row[colIdx];
|
|
171
|
+
return (row as Record<string, unknown>)[columns[colIdx]];
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className="overflow-x-auto rounded-lg border border-zinc-700">
|
|
176
|
+
<table className="min-w-full text-xs">
|
|
177
|
+
<thead>
|
|
178
|
+
<tr className="border-b border-zinc-700 bg-zinc-800/80">
|
|
179
|
+
{columns.map((col, i) => (
|
|
180
|
+
<th
|
|
181
|
+
key={i}
|
|
182
|
+
className="whitespace-nowrap px-3 py-2 text-left font-medium text-zinc-400"
|
|
183
|
+
>
|
|
184
|
+
{col}
|
|
185
|
+
</th>
|
|
186
|
+
))}
|
|
187
|
+
</tr>
|
|
188
|
+
</thead>
|
|
189
|
+
<tbody>
|
|
190
|
+
{display.map((row, i) => (
|
|
191
|
+
<tr
|
|
192
|
+
key={i}
|
|
193
|
+
className={i % 2 === 0 ? "bg-zinc-900/60" : "bg-zinc-900/30"}
|
|
194
|
+
>
|
|
195
|
+
{columns.map((_, j) => (
|
|
196
|
+
<td key={j} className="whitespace-nowrap px-3 py-1.5 text-zinc-300">
|
|
197
|
+
{formatCell(cell(row, j))}
|
|
198
|
+
</td>
|
|
199
|
+
))}
|
|
200
|
+
</tr>
|
|
201
|
+
))}
|
|
202
|
+
</tbody>
|
|
203
|
+
</table>
|
|
204
|
+
{hasMore && (
|
|
205
|
+
<div className="border-t border-zinc-700 px-3 py-1.5 text-xs text-zinc-500">
|
|
206
|
+
Showing {maxRows} of {rows.length} rows
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** SQL code block with syntax highlighting and a copy button. */
|
|
214
|
+
function SQLBlock({ sql }: { sql: string }) {
|
|
215
|
+
return (
|
|
216
|
+
<div className="relative">
|
|
217
|
+
<SyntaxHighlighter
|
|
218
|
+
language="sql"
|
|
219
|
+
style={oneDark}
|
|
220
|
+
customStyle={{
|
|
221
|
+
margin: 0,
|
|
222
|
+
borderRadius: "0.5rem",
|
|
223
|
+
fontSize: "0.75rem",
|
|
224
|
+
padding: "0.75rem 1rem",
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{sql}
|
|
228
|
+
</SyntaxHighlighter>
|
|
229
|
+
<div className="absolute right-2 top-2">
|
|
230
|
+
<CopyButton text={sql} label="Copy SQL" />
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ------------------------------------------------------------------ */
|
|
237
|
+
/* Markdown renderer */
|
|
238
|
+
/* ------------------------------------------------------------------ */
|
|
239
|
+
|
|
240
|
+
function Markdown({ content }: { content: string }) {
|
|
241
|
+
return (
|
|
242
|
+
<ReactMarkdown
|
|
243
|
+
components={{
|
|
244
|
+
p: ({ children }) => (
|
|
245
|
+
<p className="mb-3 leading-relaxed last:mb-0">{children}</p>
|
|
246
|
+
),
|
|
247
|
+
h1: ({ children }) => (
|
|
248
|
+
<h1 className="mb-2 mt-4 text-lg font-bold first:mt-0">{children}</h1>
|
|
249
|
+
),
|
|
250
|
+
h2: ({ children }) => (
|
|
251
|
+
<h2 className="mb-2 mt-3 text-base font-semibold first:mt-0">{children}</h2>
|
|
252
|
+
),
|
|
253
|
+
h3: ({ children }) => (
|
|
254
|
+
<h3 className="mb-1 mt-2 font-semibold first:mt-0">{children}</h3>
|
|
255
|
+
),
|
|
256
|
+
ul: ({ children }) => (
|
|
257
|
+
<ul className="mb-3 list-disc space-y-1 pl-4">{children}</ul>
|
|
258
|
+
),
|
|
259
|
+
ol: ({ children }) => (
|
|
260
|
+
<ol className="mb-3 list-decimal space-y-1 pl-4">{children}</ol>
|
|
261
|
+
),
|
|
262
|
+
strong: ({ children }) => (
|
|
263
|
+
<strong className="font-semibold text-zinc-50">{children}</strong>
|
|
264
|
+
),
|
|
265
|
+
blockquote: ({ children }) => (
|
|
266
|
+
<blockquote className="my-2 border-l-2 border-zinc-600 pl-3 text-zinc-400">
|
|
267
|
+
{children}
|
|
268
|
+
</blockquote>
|
|
269
|
+
),
|
|
270
|
+
pre: ({ children }) => <>{children}</>,
|
|
271
|
+
code({ className, children, ...props }) {
|
|
272
|
+
const match = /language-(\w+)/.exec(className || "");
|
|
273
|
+
if (match) {
|
|
274
|
+
return (
|
|
275
|
+
<SyntaxHighlighter
|
|
276
|
+
language={match[1]}
|
|
277
|
+
style={oneDark}
|
|
278
|
+
customStyle={{
|
|
279
|
+
margin: "0.5rem 0",
|
|
280
|
+
borderRadius: "0.5rem",
|
|
281
|
+
fontSize: "0.75rem",
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
{String(children).replace(/\n$/, "")}
|
|
285
|
+
</SyntaxHighlighter>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return (
|
|
289
|
+
<code
|
|
290
|
+
className="rounded bg-zinc-700/50 px-1.5 py-0.5 text-xs text-zinc-200"
|
|
291
|
+
{...props}
|
|
292
|
+
>
|
|
293
|
+
{children}
|
|
294
|
+
</code>
|
|
295
|
+
);
|
|
296
|
+
},
|
|
297
|
+
}}
|
|
298
|
+
>
|
|
299
|
+
{content}
|
|
300
|
+
</ReactMarkdown>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/* ------------------------------------------------------------------ */
|
|
305
|
+
/* Tool result cards */
|
|
306
|
+
/* ------------------------------------------------------------------ */
|
|
307
|
+
|
|
308
|
+
/** Explore tool — terminal-style card showing command + output. */
|
|
309
|
+
function ExploreCard({ part }: { part: unknown }) {
|
|
310
|
+
const args = getToolArgs(part);
|
|
311
|
+
const result = getToolResult(part);
|
|
312
|
+
const done = isToolComplete(part);
|
|
313
|
+
const [open, setOpen] = useState(false);
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div className="my-2 overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900">
|
|
317
|
+
<button
|
|
318
|
+
onClick={() => done && setOpen(!open)}
|
|
319
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-zinc-800/60"
|
|
320
|
+
>
|
|
321
|
+
<span className="font-mono text-green-400">$</span>
|
|
322
|
+
<span className="flex-1 truncate font-mono text-zinc-300">
|
|
323
|
+
{String(args.command ?? "")}
|
|
324
|
+
</span>
|
|
325
|
+
{done ? (
|
|
326
|
+
<span className="text-zinc-600">{open ? "\u25BE" : "\u25B8"}</span>
|
|
327
|
+
) : (
|
|
328
|
+
<span className="animate-pulse text-zinc-500">running...</span>
|
|
329
|
+
)}
|
|
330
|
+
</button>
|
|
331
|
+
{open && done && (
|
|
332
|
+
<div className="border-t border-zinc-800 bg-zinc-950 px-3 py-2">
|
|
333
|
+
<pre className="max-h-60 overflow-auto whitespace-pre-wrap font-mono text-xs leading-relaxed text-zinc-400">
|
|
334
|
+
{result != null
|
|
335
|
+
? typeof result === "string"
|
|
336
|
+
? result
|
|
337
|
+
: JSON.stringify(result, null, 2)
|
|
338
|
+
: "(no output received)"}
|
|
339
|
+
</pre>
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** ExecuteSQL tool — compact table card. */
|
|
347
|
+
function SQLResultCard({ part }: { part: unknown }) {
|
|
348
|
+
const args = getToolArgs(part);
|
|
349
|
+
const result = getToolResult(part) as Record<string, unknown> | null;
|
|
350
|
+
const done = isToolComplete(part);
|
|
351
|
+
const [open, setOpen] = useState(false);
|
|
352
|
+
|
|
353
|
+
if (!done) return <LoadingCard label="Executing query..." />;
|
|
354
|
+
|
|
355
|
+
if (!result) {
|
|
356
|
+
return (
|
|
357
|
+
<div className="my-2 rounded-lg border border-yellow-900/50 bg-yellow-950/20 px-3 py-2 text-xs text-yellow-400">
|
|
358
|
+
Query completed but no result was returned.
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (!result.success) {
|
|
364
|
+
return (
|
|
365
|
+
<div className="my-2 rounded-lg border border-red-900/50 bg-red-950/20 px-3 py-2 text-xs text-red-400">
|
|
366
|
+
Query failed. Check the query and try again.
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const columns = (result.columns as string[]) ?? [];
|
|
372
|
+
const rows = (result.rows as Record<string, unknown>[]) ?? [];
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<div className="my-2 overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900">
|
|
376
|
+
<button
|
|
377
|
+
onClick={() => setOpen(!open)}
|
|
378
|
+
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-zinc-800/60"
|
|
379
|
+
>
|
|
380
|
+
<span className="rounded bg-blue-600/20 px-1.5 py-0.5 font-medium text-blue-400">
|
|
381
|
+
SQL
|
|
382
|
+
</span>
|
|
383
|
+
<span className="flex-1 truncate text-zinc-400">
|
|
384
|
+
{String(args.explanation ?? "Query result")}
|
|
385
|
+
</span>
|
|
386
|
+
<span className="text-zinc-500">
|
|
387
|
+
{rows.length} row{rows.length !== 1 ? "s" : ""}
|
|
388
|
+
{result.truncated ? "+" : ""}
|
|
389
|
+
</span>
|
|
390
|
+
<span className="text-zinc-600">{open ? "\u25BE" : "\u25B8"}</span>
|
|
391
|
+
</button>
|
|
392
|
+
{open && (
|
|
393
|
+
<div className="border-t border-zinc-800">
|
|
394
|
+
<DataTable columns={columns} rows={rows} />
|
|
395
|
+
</div>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** FinalizeReport tool — full report with narrative, data table, SQL, and actions. */
|
|
402
|
+
function ReportCard({ part }: { part: unknown }) {
|
|
403
|
+
const result = getToolResult(part) as Record<string, unknown> | null;
|
|
404
|
+
const done = isToolComplete(part);
|
|
405
|
+
const [sqlOpen, setSqlOpen] = useState(false);
|
|
406
|
+
|
|
407
|
+
if (!done) return <LoadingCard label="Preparing report..." />;
|
|
408
|
+
|
|
409
|
+
if (!result) {
|
|
410
|
+
return (
|
|
411
|
+
<div className="my-2 rounded-lg border border-yellow-900/50 bg-yellow-950/20 px-3 py-2 text-xs text-yellow-400">
|
|
412
|
+
Report completed but no data was returned.
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const narrative = String(result.narrative ?? "");
|
|
418
|
+
const sql = String(result.sql ?? "");
|
|
419
|
+
const csv = String(result.csvResults ?? "");
|
|
420
|
+
const { headers, rows } = parseCSV(csv);
|
|
421
|
+
|
|
422
|
+
if (!narrative && !csv) {
|
|
423
|
+
return (
|
|
424
|
+
<div className="my-2 rounded-lg border border-yellow-900/50 bg-yellow-950/20 px-3 py-2 text-xs text-yellow-400">
|
|
425
|
+
Report was generated but contains no data.
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<div className="my-3 space-y-3">
|
|
432
|
+
{narrative && (
|
|
433
|
+
<div className="text-sm leading-relaxed text-zinc-200">
|
|
434
|
+
<Markdown content={narrative} />
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
437
|
+
|
|
438
|
+
{headers.length > 0 && rows.length > 0 && (
|
|
439
|
+
<DataTable columns={headers} rows={rows} maxRows={20} />
|
|
440
|
+
)}
|
|
441
|
+
|
|
442
|
+
<div className="flex items-center gap-2">
|
|
443
|
+
{sql && (
|
|
444
|
+
<button
|
|
445
|
+
onClick={() => setSqlOpen(!sqlOpen)}
|
|
446
|
+
className="rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-400 transition-colors hover:border-zinc-500 hover:text-zinc-200"
|
|
447
|
+
>
|
|
448
|
+
{sqlOpen ? "Hide SQL" : "Show SQL"}
|
|
449
|
+
</button>
|
|
450
|
+
)}
|
|
451
|
+
{csv && (
|
|
452
|
+
<button
|
|
453
|
+
onClick={() => downloadCSV(csv)}
|
|
454
|
+
className="rounded border border-zinc-700 px-2 py-1 text-xs text-zinc-400 transition-colors hover:border-zinc-500 hover:text-zinc-200"
|
|
455
|
+
>
|
|
456
|
+
Download CSV
|
|
457
|
+
</button>
|
|
458
|
+
)}
|
|
459
|
+
</div>
|
|
460
|
+
{sqlOpen && sql && <SQLBlock sql={sql} />}
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* ------------------------------------------------------------------ */
|
|
466
|
+
/* Typing indicator */
|
|
467
|
+
/* ------------------------------------------------------------------ */
|
|
468
|
+
|
|
469
|
+
function TypingIndicator() {
|
|
470
|
+
return (
|
|
471
|
+
<div className="flex justify-start">
|
|
472
|
+
<div className="flex items-center gap-1 rounded-xl bg-zinc-800 px-4 py-3">
|
|
473
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-500" />
|
|
474
|
+
<span
|
|
475
|
+
className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-500"
|
|
476
|
+
style={{ animationDelay: "150ms" }}
|
|
477
|
+
/>
|
|
478
|
+
<span
|
|
479
|
+
className="h-1.5 w-1.5 animate-bounce rounded-full bg-zinc-500"
|
|
480
|
+
style={{ animationDelay: "300ms" }}
|
|
481
|
+
/>
|
|
482
|
+
</div>
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/* ------------------------------------------------------------------ */
|
|
488
|
+
/* Tool part dispatcher */
|
|
489
|
+
/* ------------------------------------------------------------------ */
|
|
490
|
+
|
|
491
|
+
function ToolPart({ part }: { part: unknown }) {
|
|
492
|
+
let name: string;
|
|
493
|
+
try {
|
|
494
|
+
name = getToolName(part as Parameters<typeof getToolName>[0]);
|
|
495
|
+
} catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
switch (name) {
|
|
500
|
+
case "explore":
|
|
501
|
+
return <ExploreCard part={part} />;
|
|
502
|
+
case "executeSQL":
|
|
503
|
+
return <SQLResultCard part={part} />;
|
|
504
|
+
case "finalizeReport":
|
|
505
|
+
return <ReportCard part={part} />;
|
|
506
|
+
default:
|
|
507
|
+
return (
|
|
508
|
+
<div className="my-2 rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs text-zinc-500">
|
|
509
|
+
Tool: {name}
|
|
510
|
+
</div>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/* ================================================================== */
|
|
516
|
+
/* Main page */
|
|
517
|
+
/* ================================================================== */
|
|
518
|
+
|
|
519
|
+
export default function Home() {
|
|
520
|
+
const [input, setInput] = useState("");
|
|
521
|
+
const { messages, sendMessage, status, error } = useChat({ transport });
|
|
522
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
523
|
+
|
|
524
|
+
const isLoading = status === "streaming" || status === "submitted";
|
|
525
|
+
|
|
526
|
+
// Auto-scroll only when user is already near the bottom
|
|
527
|
+
useEffect(() => {
|
|
528
|
+
const el = scrollRef.current;
|
|
529
|
+
if (!el) return;
|
|
530
|
+
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
|
|
531
|
+
if (isNearBottom) el.scrollTop = el.scrollHeight;
|
|
532
|
+
}, [messages, status]);
|
|
533
|
+
|
|
534
|
+
function handleSend(text: string) {
|
|
535
|
+
if (!text.trim()) return;
|
|
536
|
+
const saved = text;
|
|
537
|
+
setInput("");
|
|
538
|
+
sendMessage({ text: saved }).catch(() => {
|
|
539
|
+
setInput(saved);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return (
|
|
544
|
+
<div className="mx-auto flex h-dvh max-w-4xl flex-col p-4">
|
|
545
|
+
<header className="mb-4 flex-none border-b border-zinc-800 pb-3">
|
|
546
|
+
<h1 className="text-xl font-semibold tracking-tight">Atlas</h1>
|
|
547
|
+
<p className="text-sm text-zinc-500">Ask your data anything</p>
|
|
548
|
+
</header>
|
|
549
|
+
|
|
550
|
+
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto pb-4">
|
|
551
|
+
{messages.length === 0 && !error && (
|
|
552
|
+
<div className="flex h-full flex-col items-center justify-center gap-6">
|
|
553
|
+
<div className="text-center">
|
|
554
|
+
<p className="text-lg font-medium text-zinc-400">
|
|
555
|
+
What would you like to know?
|
|
556
|
+
</p>
|
|
557
|
+
<p className="mt-1 text-sm text-zinc-600">
|
|
558
|
+
Ask a question about your data to get started
|
|
559
|
+
</p>
|
|
560
|
+
</div>
|
|
561
|
+
<div className="grid w-full max-w-lg grid-cols-2 gap-2">
|
|
562
|
+
{STARTER_PROMPTS.map((prompt) => (
|
|
563
|
+
<button
|
|
564
|
+
key={prompt}
|
|
565
|
+
onClick={() => handleSend(prompt)}
|
|
566
|
+
className="rounded-lg border border-zinc-800 bg-zinc-900 px-3 py-2.5 text-left text-sm text-zinc-400 transition-colors hover:border-zinc-600 hover:bg-zinc-800 hover:text-zinc-200"
|
|
567
|
+
>
|
|
568
|
+
{prompt}
|
|
569
|
+
</button>
|
|
570
|
+
))}
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
)}
|
|
574
|
+
|
|
575
|
+
{messages.map((m) => {
|
|
576
|
+
if (m.role === "user") {
|
|
577
|
+
return (
|
|
578
|
+
<div key={m.id} className="flex justify-end">
|
|
579
|
+
<div className="max-w-[85%] rounded-xl bg-blue-600 px-4 py-3 text-sm text-white">
|
|
580
|
+
{m.parts?.map((part, i) =>
|
|
581
|
+
part.type === "text" ? (
|
|
582
|
+
<p key={i} className="whitespace-pre-wrap">
|
|
583
|
+
{part.text}
|
|
584
|
+
</p>
|
|
585
|
+
) : null,
|
|
586
|
+
)}
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return (
|
|
593
|
+
<div key={m.id} className="space-y-2">
|
|
594
|
+
{m.parts?.map((part, i) => {
|
|
595
|
+
if (part.type === "text" && part.text.trim()) {
|
|
596
|
+
return (
|
|
597
|
+
<div key={i} className="max-w-[90%]">
|
|
598
|
+
<div className="rounded-xl bg-zinc-800 px-4 py-3 text-sm text-zinc-200">
|
|
599
|
+
<Markdown content={part.text} />
|
|
600
|
+
</div>
|
|
601
|
+
</div>
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
if (isToolUIPart(part)) {
|
|
605
|
+
return (
|
|
606
|
+
<div key={i} className="max-w-[95%]">
|
|
607
|
+
<ToolPart part={part} />
|
|
608
|
+
</div>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
return null;
|
|
612
|
+
})}
|
|
613
|
+
</div>
|
|
614
|
+
);
|
|
615
|
+
})}
|
|
616
|
+
|
|
617
|
+
{isLoading && messages.length > 0 && <TypingIndicator />}
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
{error && (
|
|
621
|
+
<div className="mb-2 rounded-lg border border-red-900/50 bg-red-950/20 px-4 py-3 text-sm text-red-400">
|
|
622
|
+
Failed to get a response. Please try again.
|
|
623
|
+
</div>
|
|
624
|
+
)}
|
|
625
|
+
|
|
626
|
+
<form
|
|
627
|
+
onSubmit={(e) => {
|
|
628
|
+
e.preventDefault();
|
|
629
|
+
handleSend(input);
|
|
630
|
+
}}
|
|
631
|
+
className="flex flex-none gap-2 border-t border-zinc-800 pt-4"
|
|
632
|
+
>
|
|
633
|
+
<input
|
|
634
|
+
value={input}
|
|
635
|
+
onChange={(e) => setInput(e.target.value)}
|
|
636
|
+
placeholder="Ask a question about your data..."
|
|
637
|
+
className="flex-1 rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-3 text-sm text-zinc-100 placeholder-zinc-600 outline-none focus:border-blue-500"
|
|
638
|
+
disabled={isLoading}
|
|
639
|
+
/>
|
|
640
|
+
<button
|
|
641
|
+
type="submit"
|
|
642
|
+
disabled={isLoading || !input.trim()}
|
|
643
|
+
className="rounded-lg bg-blue-600 px-5 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:opacity-40"
|
|
644
|
+
>
|
|
645
|
+
Ask
|
|
646
|
+
</button>
|
|
647
|
+
</form>
|
|
648
|
+
</div>
|
|
649
|
+
);
|
|
650
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module "*.css";
|