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.
Files changed (45) hide show
  1. package/README.md +69 -0
  2. package/index.ts +526 -0
  3. package/package.json +33 -0
  4. package/template/.env.example +49 -0
  5. package/template/Dockerfile +31 -0
  6. package/template/bin/atlas.ts +1092 -0
  7. package/template/bin/enrich.ts +551 -0
  8. package/template/data/.gitkeep +0 -0
  9. package/template/data/demo-sqlite.sql +372 -0
  10. package/template/data/demo.sql +371 -0
  11. package/template/docker-compose.yml +23 -0
  12. package/template/docs/deploy.md +341 -0
  13. package/template/eslint.config.mjs +18 -0
  14. package/template/fly.toml +46 -0
  15. package/template/gitignore +5 -0
  16. package/template/next.config.ts +8 -0
  17. package/template/package.json +55 -0
  18. package/template/postcss.config.mjs +8 -0
  19. package/template/public/.gitkeep +0 -0
  20. package/template/railway.json +13 -0
  21. package/template/render.yaml +19 -0
  22. package/template/semantic/catalog.yml +5 -0
  23. package/template/semantic/entities/.gitkeep +0 -0
  24. package/template/semantic/glossary.yml +6 -0
  25. package/template/semantic/metrics/.gitkeep +0 -0
  26. package/template/src/app/api/chat/route.ts +107 -0
  27. package/template/src/app/api/health/route.ts +97 -0
  28. package/template/src/app/error.tsx +24 -0
  29. package/template/src/app/globals.css +1 -0
  30. package/template/src/app/layout.tsx +19 -0
  31. package/template/src/app/page.tsx +650 -0
  32. package/template/src/global.d.ts +1 -0
  33. package/template/src/lib/agent.ts +112 -0
  34. package/template/src/lib/db/connection.ts +150 -0
  35. package/template/src/lib/providers.ts +63 -0
  36. package/template/src/lib/semantic.ts +53 -0
  37. package/template/src/lib/startup.ts +211 -0
  38. package/template/src/lib/tools/__tests__/sql.test.ts +538 -0
  39. package/template/src/lib/tools/explore-sandbox.ts +189 -0
  40. package/template/src/lib/tools/explore.ts +164 -0
  41. package/template/src/lib/tools/report.ts +33 -0
  42. package/template/src/lib/tools/sql.ts +202 -0
  43. package/template/src/types/vercel-sandbox.d.ts +54 -0
  44. package/template/tsconfig.json +41 -0
  45. 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";