larkup 0.1.14

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 (141) hide show
  1. package/.env +2 -0
  2. package/app/actions/vercel.ts +417 -0
  3. package/app/api/chat/route.ts +192 -0
  4. package/app/api/chat/status/route.ts +55 -0
  5. package/app/api/chat/suggestions/route.ts +115 -0
  6. package/app/api/config/route.ts +73 -0
  7. package/app/api/config/test/route.ts +67 -0
  8. package/app/api/config/test-embedding/route.ts +40 -0
  9. package/app/api/config/test-llm/route.ts +47 -0
  10. package/app/api/config/test-provider/route.ts +36 -0
  11. package/app/api/demo/route.ts +176 -0
  12. package/app/api/deploy/ssh/route.ts +340 -0
  13. package/app/api/documents/route.ts +90 -0
  14. package/app/api/firecrawl/local/route.ts +46 -0
  15. package/app/api/index/route.ts +113 -0
  16. package/app/api/jobs/[id]/route.ts +44 -0
  17. package/app/api/jobs/route.ts +116 -0
  18. package/app/api/parse-file/route.ts +39 -0
  19. package/app/api/search/google/route.ts +162 -0
  20. package/app/api/search/route.ts +29 -0
  21. package/app/api/server/download/route.ts +112 -0
  22. package/app/api/server/generate/route.ts +46 -0
  23. package/app/api/server/local/route.ts +42 -0
  24. package/app/api/servers/route.ts +137 -0
  25. package/app/api/vector-stores/install/route.ts +65 -0
  26. package/app/api/vector-stores/status/route.ts +26 -0
  27. package/app/chat/page.tsx +15 -0
  28. package/app/configure/page.tsx +53 -0
  29. package/app/data/page.tsx +15 -0
  30. package/app/demo/page.tsx +15 -0
  31. package/app/favicon.ico +0 -0
  32. package/app/globals.css +190 -0
  33. package/app/index-data/page.tsx +15 -0
  34. package/app/layout.tsx +51 -0
  35. package/app/page.tsx +34 -0
  36. package/app/server/page.tsx +22 -0
  37. package/app/simple/chat/page.tsx +17 -0
  38. package/app/simple/docs/page.tsx +21 -0
  39. package/app/simple/layout.tsx +9 -0
  40. package/app/simple/settings/page.tsx +627 -0
  41. package/app/template.tsx +16 -0
  42. package/app/themes.css +733 -0
  43. package/components/alerts/generic-alert.tsx +74 -0
  44. package/components/app-sidebar.tsx +187 -0
  45. package/components/app-topnav.tsx +195 -0
  46. package/components/chat/chat-settings-modal.tsx +209 -0
  47. package/components/chat/chat-workspace.tsx +348 -0
  48. package/components/chat/knowledge-base-result.tsx +97 -0
  49. package/components/chat/message-item.tsx +103 -0
  50. package/components/client-layout-wrapper.tsx +182 -0
  51. package/components/configure/configure-form.tsx +1683 -0
  52. package/components/configure/store-fields.tsx +152 -0
  53. package/components/data/corpus-panel.tsx +499 -0
  54. package/components/data/data-workspace.tsx +272 -0
  55. package/components/data/firecrawl-notice.tsx +236 -0
  56. package/components/data/jobs-panel.tsx +485 -0
  57. package/components/data/paste-panel.tsx +79 -0
  58. package/components/data/scrape-panel.tsx +964 -0
  59. package/components/data/upload-panel.tsx +714 -0
  60. package/components/demo/demo-workspace.tsx +419 -0
  61. package/components/global-settings.tsx +286 -0
  62. package/components/index/index-workspace.tsx +482 -0
  63. package/components/onboarding/simple-setup.tsx +702 -0
  64. package/components/onboarding/tech-setup.tsx +121 -0
  65. package/components/onboarding/welcome-screen.tsx +183 -0
  66. package/components/page-header.tsx +44 -0
  67. package/components/server/code-viewer.tsx +87 -0
  68. package/components/server/deploy-button.tsx +1390 -0
  69. package/components/server/server-workspace.tsx +392 -0
  70. package/components/simple/sdk-connect-dialog.tsx +201 -0
  71. package/components/simple/simple-index-button.tsx +83 -0
  72. package/components/simple/simple-sidebar.tsx +161 -0
  73. package/components/theme-customizer-provider.tsx +171 -0
  74. package/components/theme-switcher.tsx +445 -0
  75. package/components/ui/alert-dialog.tsx +187 -0
  76. package/components/ui/alert.tsx +76 -0
  77. package/components/ui/badge.tsx +52 -0
  78. package/components/ui/button.tsx +59 -0
  79. package/components/ui/card.tsx +103 -0
  80. package/components/ui/checkbox.tsx +28 -0
  81. package/components/ui/collapsible.tsx +21 -0
  82. package/components/ui/dialog.tsx +159 -0
  83. package/components/ui/dropdown-menu.tsx +281 -0
  84. package/components/ui/input.tsx +22 -0
  85. package/components/ui/label.tsx +20 -0
  86. package/components/ui/popover.tsx +90 -0
  87. package/components/ui/progress.tsx +83 -0
  88. package/components/ui/provider-icon.tsx +94 -0
  89. package/components/ui/scroll-area.tsx +55 -0
  90. package/components/ui/select.tsx +248 -0
  91. package/components/ui/separator.tsx +25 -0
  92. package/components/ui/sheet.tsx +138 -0
  93. package/components/ui/skeleton.tsx +13 -0
  94. package/components/ui/sonner.tsx +63 -0
  95. package/components/ui/switch.tsx +32 -0
  96. package/components/ui/table.tsx +116 -0
  97. package/components/ui/tabs.tsx +84 -0
  98. package/components/ui/textarea.tsx +18 -0
  99. package/components/ui/tooltip.tsx +66 -0
  100. package/components/workspace/delete-server-dialog.tsx +71 -0
  101. package/components/workspace/server-form-dialog.tsx +103 -0
  102. package/components/workspace/server-switcher.tsx +163 -0
  103. package/components/workspace/workspace-provider.tsx +177 -0
  104. package/components/workspace/workspace-top-bar.tsx +23 -0
  105. package/components.json +21 -0
  106. package/lib/error-formatter.ts +15 -0
  107. package/lib/utils.ts +6 -0
  108. package/next-env.d.ts +6 -0
  109. package/next.config.mjs +18 -0
  110. package/package.json +65 -0
  111. package/postcss.config.mjs +8 -0
  112. package/public/docker.png +0 -0
  113. package/public/favicon.ico +0 -0
  114. package/public/github.svg +3 -0
  115. package/public/hetzner.svg +1 -0
  116. package/public/icons/aws.svg +38 -0
  117. package/public/icons/azure.svg +1 -0
  118. package/public/icons/chroma.png +0 -0
  119. package/public/icons/cohere.svg +30 -0
  120. package/public/icons/digital-ocean.webp +0 -0
  121. package/public/icons/firecrawl2.png +0 -0
  122. package/public/icons/gcp.svg +1 -0
  123. package/public/icons/gemini.svg +1 -0
  124. package/public/icons/jina.svg +1 -0
  125. package/public/icons/lancedb2.png +0 -0
  126. package/public/icons/mistral.svg +469 -0
  127. package/public/icons/nomic.png +0 -0
  128. package/public/icons/openai.svg +1 -0
  129. package/public/icons/pgvector2.png +0 -0
  130. package/public/icons/pinecone.png +0 -0
  131. package/public/icons/qdrant.svg +35 -0
  132. package/public/icons/supabase.png +0 -0
  133. package/public/icons/vercel copy.svg +1 -0
  134. package/public/icons/vercel.svg +1 -0
  135. package/public/icons/voyage-light.png +0 -0
  136. package/public/icons/weaviate.webp +0 -0
  137. package/public/logo-light.png +0 -0
  138. package/public/logo.png +0 -0
  139. package/public/logo9.png +0 -0
  140. package/public/vercel.svg +1 -0
  141. package/tsconfig.json +33 -0
@@ -0,0 +1,485 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import {
6
+ CheckCircle2,
7
+ CircleDashed,
8
+ Loader2,
9
+ Octagon,
10
+ TriangleAlert,
11
+ Trash2,
12
+ ChevronLeft,
13
+ ChevronRight,
14
+ AlertTriangle,
15
+ Copy,
16
+ XIcon,
17
+ Square,
18
+ } from "lucide-react";
19
+ import type { CrawlJob, CrawlJobStatus } from "@larkup/core/types";
20
+ import { Badge } from "@/components/ui/badge";
21
+ import { Button } from "@/components/ui/button";
22
+ import { Checkbox } from "@/components/ui/checkbox";
23
+ import {
24
+ Dialog,
25
+ DialogContent,
26
+ DialogDescription,
27
+ DialogFooter,
28
+ DialogHeader,
29
+ DialogTitle,
30
+ } from "@/components/ui/dialog";
31
+ import { cn } from "@/lib/utils";
32
+
33
+ const PAGE_SIZE = 8;
34
+
35
+ const STATUS: Record<
36
+ CrawlJobStatus,
37
+ {
38
+ label: string;
39
+ icon: typeof Loader2;
40
+ className: string;
41
+ badgeVariant: string;
42
+ }
43
+ > = {
44
+ queued: {
45
+ label: "Queued",
46
+ icon: CircleDashed,
47
+ className: "text-orange-500 border-orange-500/30 bg-orange-500/10",
48
+ badgeVariant: "outline",
49
+ },
50
+ running: {
51
+ label: "Running",
52
+ icon: Loader2,
53
+ className: "text-primary border-primary/30 bg-primary/10",
54
+ badgeVariant: "outline",
55
+ },
56
+ completed: {
57
+ label: "Completed",
58
+ icon: CheckCircle2,
59
+ className: "text-green-600 border-green-600/50 bg-green-600/10",
60
+ badgeVariant: "outline",
61
+ },
62
+ failed: {
63
+ label: "Failed",
64
+ icon: TriangleAlert,
65
+ className: "text-destructive border-destructive/30 bg-destructive/10",
66
+ badgeVariant: "outline",
67
+ },
68
+ cancelled: {
69
+ label: "Cancelled",
70
+ icon: Octagon,
71
+ className: "text-muted-foreground border-muted-foreground/30 bg-muted/30",
72
+ badgeVariant: "outline",
73
+ },
74
+ };
75
+
76
+ function progressFor(job: CrawlJob): number {
77
+ const domainTargets = job.targets.filter((t) => t.scope === "domain").length;
78
+ const denom =
79
+ domainTargets > 0 ? domainTargets * job.pageLimit : job.targets.length;
80
+ if (denom === 0) return 0;
81
+ if (job.status === "completed") return 100;
82
+ return Math.min(99, Math.round((job.pagesCrawled / denom) * 100));
83
+ }
84
+
85
+ export function JobsPanel({
86
+ jobs,
87
+ onChanged,
88
+ }: {
89
+ jobs: CrawlJob[];
90
+ onChanged: () => void;
91
+ }) {
92
+ const [page, setPage] = useState(0);
93
+ const [selected, setSelected] = useState<Set<string>>(new Set());
94
+ const [confirmDelete, setConfirmDelete] = useState<{
95
+ ids: string[];
96
+ label: string;
97
+ } | null>(null);
98
+ const [deleting, setDeleting] = useState(false);
99
+
100
+ const [confirmCancel, setConfirmCancel] = useState<{
101
+ id: string;
102
+ label: string;
103
+ } | null>(null);
104
+ const [cancelling, setCancelling] = useState(false);
105
+
106
+ const totalPages = Math.max(1, Math.ceil(jobs.length / PAGE_SIZE));
107
+ const safePage = Math.min(page, totalPages - 1);
108
+ const pageJobs = jobs.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
109
+
110
+ const allPageSelected =
111
+ pageJobs.length > 0 && pageJobs.every((j) => selected.has(j.id));
112
+ const somePageSelected = pageJobs.some((j) => selected.has(j.id));
113
+
114
+ function toggleAll() {
115
+ const next = new Set(selected);
116
+ if (allPageSelected) {
117
+ pageJobs.forEach((j) => next.delete(j.id));
118
+ } else {
119
+ pageJobs.forEach((j) => next.add(j.id));
120
+ }
121
+ setSelected(next);
122
+ }
123
+
124
+ function toggleOne(id: string) {
125
+ const next = new Set(selected);
126
+ next.has(id) ? next.delete(id) : next.add(id);
127
+ setSelected(next);
128
+ }
129
+
130
+ async function executeCancel(id: string) {
131
+ setCancelling(true);
132
+ try {
133
+ await fetch(`/api/jobs/${id}`, { method: "DELETE" });
134
+ toast.message("Job cancelled");
135
+ onChanged();
136
+ } finally {
137
+ setCancelling(false);
138
+ setConfirmCancel(null);
139
+ }
140
+ }
141
+
142
+ function requestCancel(id: string) {
143
+ const label = `"${jobs.find((j) => j.id === id)?.keywords ?? id}"`;
144
+ setConfirmCancel({ id, label });
145
+ }
146
+
147
+ async function executeDelete(ids: string[]) {
148
+ setDeleting(true);
149
+ try {
150
+ await Promise.all(
151
+ ids.map((id) =>
152
+ fetch(`/api/jobs/${id}?remove=1`, { method: "DELETE" }),
153
+ ),
154
+ );
155
+ setSelected((prev) => {
156
+ const next = new Set(prev);
157
+ ids.forEach((id) => next.delete(id));
158
+ return next;
159
+ });
160
+ toast.success(
161
+ ids.length === 1 ? "Job removed" : `${ids.length} jobs removed`,
162
+ );
163
+ onChanged();
164
+ } finally {
165
+ setDeleting(false);
166
+ setConfirmDelete(null);
167
+ }
168
+ }
169
+
170
+ function requestDelete(ids: string[]) {
171
+ const label =
172
+ ids.length === 1
173
+ ? `"${jobs.find((j) => j.id === ids[0])?.keywords ?? ids[0]}"`
174
+ : `${ids.length} selected jobs`;
175
+ setConfirmDelete({ ids, label });
176
+ }
177
+
178
+ if (jobs.length === 0) {
179
+ return (
180
+ <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border py-10 text-center">
181
+ <p className="text-sm text-muted-foreground">No ETL jobs yet.</p>
182
+ <p className="mt-1 text-xs text-muted-foreground/60">
183
+ Start a web scrape to aggregate sources here.
184
+ </p>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ const selectedArray = Array.from(selected);
190
+ const removableSelected = selectedArray.filter((id) => {
191
+ const j = jobs.find((j) => j.id === id);
192
+ return j && j.status !== "running" && j.status !== "queued";
193
+ });
194
+
195
+ return (
196
+ <>
197
+ {/* Bulk action bar */}
198
+ {selected.size > 0 && (
199
+ <div className="mb-2 flex items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-1.5">
200
+ <span className="text-xs text-muted-foreground">
201
+ {selected.size} selected
202
+ </span>
203
+ <Button
204
+ size="sm"
205
+ variant="ghost"
206
+ className="h-6 gap-1.5 text-xs text-destructive hover:text-destructive"
207
+ disabled={removableSelected.length === 0}
208
+ onClick={() => requestDelete(removableSelected)}
209
+ >
210
+ <Trash2 className="size-3" />
211
+ Delete ({removableSelected.length})
212
+ </Button>
213
+ </div>
214
+ )}
215
+
216
+ {/* Table */}
217
+ <div className="w-full overflow-auto">
218
+ <table className="w-full text-xs">
219
+ <thead>
220
+ <tr className="border-b border-border">
221
+ <th className="pb-2 pl-1 pr-2 text-left">
222
+ <Checkbox
223
+ checked={allPageSelected}
224
+ onCheckedChange={toggleAll}
225
+ aria-label="Select all on page"
226
+ className="size-3.5"
227
+ />
228
+ </th>
229
+ <th className="pb-2 pr-3 text-left font-medium text-muted-foreground">
230
+ Title / Keywords
231
+ </th>
232
+ <th className="pb-2 pr-3 text-right font-medium text-muted-foreground whitespace-nowrap">
233
+ Pages
234
+ </th>
235
+ <th className="pb-2 pr-3 text-left font-medium text-muted-foreground">
236
+ Status
237
+ </th>
238
+ <th className="pb-2 text-right font-medium text-muted-foreground" />
239
+ </tr>
240
+ </thead>
241
+ <tbody>
242
+ {pageJobs.map((job) => {
243
+ const s = STATUS[job.status];
244
+ const Icon = s.icon;
245
+ const active =
246
+ job.status === "running" || job.status === "queued";
247
+ const pct = progressFor(job);
248
+
249
+ const domainTargets = job.targets.filter(
250
+ (t) => t.scope === "domain",
251
+ ).length;
252
+ const denom =
253
+ domainTargets > 0
254
+ ? domainTargets * job.pageLimit
255
+ : job.targets.length;
256
+
257
+ return (
258
+ <tr
259
+ key={job.id}
260
+ className={cn(
261
+ "border-b border-border/50 transition-colors last:border-0",
262
+ selected.has(job.id) && "bg-muted/30",
263
+ )}
264
+ >
265
+ {/* Checkbox */}
266
+ <td className="py-2 pl-1 pr-2">
267
+ <Checkbox
268
+ checked={selected.has(job.id)}
269
+ onCheckedChange={() => toggleOne(job.id)}
270
+ aria-label={`Select job ${job.keywords}`}
271
+ className="size-3.5"
272
+ />
273
+ </td>
274
+
275
+ {/* Title */}
276
+ <td className="py-2 pr-3 max-w-36">
277
+ <p className="truncate font-medium text-foreground leading-tight">
278
+ {job.keywords}
279
+ </p>
280
+ <p className="text-muted-foreground/70 text-[10px]">
281
+ {job.targets.length} target
282
+ {job.targets.length === 1 ? "" : "s"}
283
+ </p>
284
+ </td>
285
+
286
+ {/* Pages crawled + progress */}
287
+ <td className="py-2 pr-3 text-right tabular-nums">
288
+ <span className="text-green-700">
289
+ {job.pagesCrawled} / {denom}
290
+ </span>
291
+ {active && (
292
+ <div className="mt-1 h-0.5 w-full rounded-full bg-muted overflow-hidden">
293
+ <div
294
+ className="h-full rounded-full bg-green-600 transition-all"
295
+ style={{ width: `${pct}%` }}
296
+ />
297
+ </div>
298
+ )}
299
+ </td>
300
+
301
+ {/* Status badge */}
302
+ <td className="py-2 pr-3">
303
+ <Badge
304
+ variant="outline"
305
+ className={cn(
306
+ s.className,
307
+ "gap-1 px-1.5 py-0.5 font-mono text-[10px] whitespace-nowrap",
308
+ )}
309
+ >
310
+ <Icon
311
+ className={cn(
312
+ "size-2.5 shrink-0",
313
+ job.status === "running" && "animate-spin",
314
+ )}
315
+ />
316
+ {s.label}
317
+ </Badge>
318
+ </td>
319
+
320
+ {/* Action */}
321
+ <td className="py-2 text-right">
322
+ <div className="flex items-center justify-end gap-1">
323
+ {job.targets.some((t) => t.status === "failed") && (
324
+ <button
325
+ onClick={() => {
326
+ const failedUrls = job.targets
327
+ .filter((t) => t.status === "failed")
328
+ .map((t) => t.url)
329
+ .join("\n");
330
+ navigator.clipboard.writeText(failedUrls);
331
+ toast.success("Failed URLs copied to clipboard");
332
+ }}
333
+ className="rounded p-1 text-muted-foreground hover:text-foreground transition-colors"
334
+ title="Copy failed URLs"
335
+ >
336
+ <Copy className="size-3" />
337
+ </button>
338
+ )}
339
+ {active ? (
340
+ <button
341
+ onClick={() => requestCancel(job.id)}
342
+ className="cursor-pointer rounded-xl p-1 text-muted-foreground hover:text-foreground transition-colors"
343
+ title="Cancel job"
344
+ >
345
+ <Square className="size-3.5 text-orange-400" />
346
+ </button>
347
+ ) : (
348
+ <button
349
+ onClick={() => requestDelete([job.id])}
350
+ className="rounded p-1 text-muted-foreground hover:text-destructive transition-colors"
351
+ title="Remove job"
352
+ >
353
+ <Trash2 className="size-3" />
354
+ </button>
355
+ )}
356
+ </div>
357
+ </td>
358
+ </tr>
359
+ );
360
+ })}
361
+ </tbody>
362
+ </table>
363
+ </div>
364
+
365
+ {/* Pagination */}
366
+ {totalPages > 1 && (
367
+ <div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
368
+ <span>
369
+ {safePage * PAGE_SIZE + 1}–
370
+ {Math.min((safePage + 1) * PAGE_SIZE, jobs.length)} of {jobs.length}
371
+ </span>
372
+ <div className="flex items-center gap-1">
373
+ <button
374
+ disabled={safePage === 0}
375
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
376
+ className="rounded p-1 hover:bg-muted disabled:opacity-40 transition-colors"
377
+ >
378
+ <ChevronLeft className="size-3.5" />
379
+ </button>
380
+ <span className="tabular-nums">
381
+ {safePage + 1} / {totalPages}
382
+ </span>
383
+ <button
384
+ disabled={safePage >= totalPages - 1}
385
+ onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
386
+ className="rounded p-1 hover:bg-muted disabled:opacity-40 transition-colors"
387
+ >
388
+ <ChevronRight className="size-3.5" />
389
+ </button>
390
+ </div>
391
+ </div>
392
+ )}
393
+
394
+ {/* Confirm delete dialog */}
395
+ <Dialog
396
+ open={!!confirmDelete}
397
+ onOpenChange={(open) => !open && setConfirmDelete(null)}
398
+ >
399
+ <DialogContent className="max-w-sm">
400
+ <DialogHeader>
401
+ <DialogTitle className="flex items-center gap-2">
402
+ <AlertTriangle className="size-4 text-destructive" />
403
+ Confirm deletion
404
+ </DialogTitle>
405
+ <DialogDescription>
406
+ Are you sure you want to remove{" "}
407
+ <span className="font-medium text-foreground">
408
+ {confirmDelete?.label}
409
+ </span>
410
+ ? This action cannot be undone.
411
+ </DialogDescription>
412
+ </DialogHeader>
413
+ <DialogFooter className="gap-2 sm:gap-0">
414
+ <Button
415
+ variant="outline"
416
+ size="sm"
417
+ onClick={() => setConfirmDelete(null)}
418
+ disabled={deleting}
419
+ >
420
+ Cancel
421
+ </Button>
422
+ <Button
423
+ variant="destructive"
424
+ size="sm"
425
+ disabled={deleting}
426
+ onClick={() => confirmDelete && executeDelete(confirmDelete.ids)}
427
+ >
428
+ {deleting ? (
429
+ <Loader2 className="size-3 animate-spin" />
430
+ ) : (
431
+ <Trash2 className="size-3" />
432
+ )}
433
+ Delete
434
+ </Button>
435
+ </DialogFooter>
436
+ </DialogContent>
437
+ </Dialog>
438
+
439
+ {/* Confirm cancel dialog */}
440
+ <Dialog
441
+ open={!!confirmCancel}
442
+ onOpenChange={(open) => !open && setConfirmCancel(null)}
443
+ >
444
+ <DialogContent className="max-w-lg">
445
+ <DialogHeader>
446
+ <DialogTitle className="flex items-center gap-2">
447
+ <AlertTriangle className="size-4 text-orange-500" />
448
+ Confirm cancellation
449
+ </DialogTitle>
450
+ <DialogDescription>
451
+ Are you sure you want to cancel the job{" "}
452
+ <span className="font-medium text-foreground">
453
+ {confirmCancel?.label}
454
+ </span>
455
+ ? In-progress pages will be discarded.
456
+ </DialogDescription>
457
+ </DialogHeader>
458
+ <DialogFooter className="gap-2">
459
+ <Button
460
+ variant="outline"
461
+ size="sm"
462
+ onClick={() => setConfirmCancel(null)}
463
+ disabled={cancelling}
464
+ >
465
+ Go Back
466
+ </Button>
467
+ <Button
468
+ variant="default"
469
+ size="sm"
470
+ disabled={cancelling}
471
+ onClick={() => confirmCancel && executeCancel(confirmCancel.id)}
472
+ >
473
+ {cancelling ? (
474
+ <Loader2 className="size-3 animate-spin" />
475
+ ) : (
476
+ <Octagon className="size-3" />
477
+ )}
478
+ Cancel Job
479
+ </Button>
480
+ </DialogFooter>
481
+ </DialogContent>
482
+ </Dialog>
483
+ </>
484
+ );
485
+ }
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { formatErrorMessage } from "@/lib/error-formatter";
6
+ import { Plus, Loader2 } from "lucide-react";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Label } from "@/components/ui/label";
10
+ import { Textarea } from "@/components/ui/textarea";
11
+
12
+ export function PastePanel({ onAdded }: { onAdded: () => void }) {
13
+ const [title, setTitle] = useState("");
14
+ const [content, setContent] = useState("");
15
+ const [saving, setSaving] = useState(false);
16
+
17
+ async function submit() {
18
+ if (!content.trim()) {
19
+ toast.error("Nothing to add — paste some text first.");
20
+ return;
21
+ }
22
+ setSaving(true);
23
+ try {
24
+ const res = await fetch("/api/documents", {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: JSON.stringify({
28
+ title: title.trim() || "Pasted text",
29
+ content,
30
+ source: "paste",
31
+ }),
32
+ });
33
+ if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
34
+ toast.success("Added to corpus");
35
+ setTitle("");
36
+ setContent("");
37
+ onAdded();
38
+ } catch (err) {
39
+ toast.error(formatErrorMessage(err));
40
+ } finally {
41
+ setSaving(false);
42
+ }
43
+ }
44
+
45
+ return (
46
+ <div className="space-y-4">
47
+ <div className="space-y-2">
48
+ <Label htmlFor="paste-title">Title</Label>
49
+ <Input
50
+ id="paste-title"
51
+ placeholder="e.g. TUHH — About page"
52
+ value={title}
53
+ onChange={(e) => setTitle(e.target.value)}
54
+ />
55
+ </div>
56
+ <div className="space-y-2">
57
+ <Label htmlFor="paste-content">Content</Label>
58
+ <Textarea
59
+ id="paste-content"
60
+ placeholder="Paste any text, markdown, or notes here…"
61
+ className="min-h-48 font-mono text-xs leading-relaxed bg-white"
62
+ value={content}
63
+ onChange={(e) => setContent(e.target.value)}
64
+ />
65
+ <p className="text-xs text-muted-foreground tabular-nums">
66
+ {content.length.toLocaleString()} characters
67
+ </p>
68
+ </div>
69
+ <Button onClick={submit} disabled={saving}>
70
+ {saving ? (
71
+ <Loader2 className="size-4 animate-spin" />
72
+ ) : (
73
+ <Plus className="size-4" />
74
+ )}
75
+ Add to corpus
76
+ </Button>
77
+ </div>
78
+ );
79
+ }