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,152 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { KeyRound, Eye, EyeOff } from "lucide-react"
5
+ import { Input } from "@/components/ui/input"
6
+ import { Label } from "@/components/ui/label"
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@/components/ui/select"
14
+ import { Badge } from "@/components/ui/badge"
15
+ import { cn } from "@/lib/utils"
16
+ import { visibleFields } from "@larkup/vector-stores/registry"
17
+ import type { IndexType, VectorStoreDescriptor } from "@larkup/core/types"
18
+
19
+ interface StoreFieldsProps {
20
+ store: VectorStoreDescriptor
21
+ values: Record<string, string>
22
+ errors: Record<string, string>
23
+ onChange: (key: string, value: string) => void
24
+ /** Global index type — controls showWhenIndexType visibility (e.g. sparse model for hybrid/lexical) */
25
+ indexType?: IndexType
26
+ }
27
+
28
+ /**
29
+ * Renders ONLY the fields the selected store needs, honoring `showWhen`
30
+ * dependencies (e.g. LanceDB local-vs-cloud). Driven entirely by the
31
+ * registry, so adding a new store requires no changes here.
32
+ */
33
+ export function StoreFields({
34
+ store,
35
+ values,
36
+ errors,
37
+ onChange,
38
+ indexType,
39
+ }: StoreFieldsProps) {
40
+ const fields = visibleFields(store, values, indexType)
41
+ const [showPasswords, setShowPasswords] = useState<Record<string, boolean>>({})
42
+
43
+ const togglePassword = (key: string) => {
44
+ setShowPasswords((prev) => ({ ...prev, [key]: !prev[key] }))
45
+ }
46
+
47
+ return (
48
+ <div className="grid gap-5 sm:grid-cols-2">
49
+ {fields.map((field) => {
50
+ const value = values[field.key] ?? field.defaultValue ?? ""
51
+ const error = errors[field.key]
52
+ const fieldId = `store-${field.key}`
53
+
54
+ return (
55
+ <div
56
+ key={field.key}
57
+ className={cn(
58
+ "space-y-1.5",
59
+ field.type === "select" && "sm:col-span-1",
60
+ )}
61
+ >
62
+ <div className="flex items-center gap-1.5">
63
+ <Label htmlFor={fieldId}>{field.label}</Label>
64
+ {field.required && (
65
+ <span className="text-destructive" aria-hidden>
66
+ *
67
+ </span>
68
+ )}
69
+ {field.secret && (
70
+ <Badge
71
+ variant="outline"
72
+ className="h-4 gap-1 px-1 text-[9px] font-mono uppercase text-muted-foreground"
73
+ >
74
+ <KeyRound className="size-2.5" />
75
+ secret
76
+ </Badge>
77
+ )}
78
+ </div>
79
+
80
+ {field.type === "select" ? (
81
+ <Select
82
+ value={value}
83
+ onValueChange={(v) => onChange(field.key, (v as string) ?? "")}
84
+ >
85
+ <SelectTrigger id={fieldId} className="w-full">
86
+ <SelectValue placeholder={`Select ${field.label}`} />
87
+ </SelectTrigger>
88
+ <SelectContent>
89
+ {field.options?.map((opt) => (
90
+ <SelectItem key={opt.value} value={opt.value}>
91
+ {opt.label}
92
+ </SelectItem>
93
+ ))}
94
+ </SelectContent>
95
+ </Select>
96
+ ) : field.type === "password" ? (
97
+ <div className="relative">
98
+ <Input
99
+ id={fieldId}
100
+ type={showPasswords[field.key] ? "text" : "password"}
101
+ placeholder={field.placeholder}
102
+ value={value}
103
+ spellCheck={false}
104
+ autoComplete="off"
105
+ className={cn(
106
+ "font-mono text-sm pr-9",
107
+ error && "border-destructive",
108
+ )}
109
+ onChange={(e) => onChange(field.key, e.target.value)}
110
+ />
111
+ <button
112
+ type="button"
113
+ onClick={() => togglePassword(field.key)}
114
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
115
+ tabIndex={-1}
116
+ >
117
+ {showPasswords[field.key] ? (
118
+ <EyeOff className="size-4" />
119
+ ) : (
120
+ <Eye className="size-4" />
121
+ )}
122
+ </button>
123
+ </div>
124
+ ) : (
125
+ <Input
126
+ id={fieldId}
127
+ type="text"
128
+ placeholder={field.placeholder}
129
+ value={value}
130
+ spellCheck={false}
131
+ autoComplete="off"
132
+ className={cn(
133
+ field.type === "path" && "font-mono text-sm",
134
+ error && "border-destructive",
135
+ )}
136
+ onChange={(e) => onChange(field.key, e.target.value)}
137
+ />
138
+ )}
139
+
140
+ {error ? (
141
+ <p className="text-xs text-destructive">{error}</p>
142
+ ) : field.help ? (
143
+ <p className="text-xs text-muted-foreground leading-relaxed">
144
+ {field.help}
145
+ </p>
146
+ ) : null}
147
+ </div>
148
+ )
149
+ })}
150
+ </div>
151
+ )
152
+ }
@@ -0,0 +1,499 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { formatErrorMessage } from "@/lib/error-formatter";
6
+ import {
7
+ FileText,
8
+ Trash2,
9
+ ClipboardPaste,
10
+ FileUp,
11
+ Globe,
12
+ Pencil,
13
+ Loader2,
14
+ X,
15
+ ChevronLeft,
16
+ ChevronRight,
17
+ } from "lucide-react";
18
+ import type { DocumentSource, SourceDocument } from "@larkup/core/types";
19
+ import { GenericAlert } from "@/components/alerts/generic-alert";
20
+ import { Badge } from "@/components/ui/badge";
21
+ import { Button } from "@/components/ui/button";
22
+ import { Input } from "@/components/ui/input";
23
+ import { Label } from "@/components/ui/label";
24
+ import { Textarea } from "@/components/ui/textarea";
25
+ import {
26
+ Table,
27
+ TableBody,
28
+ TableCell,
29
+ TableHead,
30
+ TableHeader,
31
+ TableRow,
32
+ } from "@/components/ui/table";
33
+ import {
34
+ Dialog,
35
+ DialogContent,
36
+ DialogDescription,
37
+ DialogFooter,
38
+ DialogHeader,
39
+ DialogTitle,
40
+ } from "@/components/ui/dialog";
41
+ import { ScrollArea } from "@/components/ui/scroll-area";
42
+ import { Checkbox } from "@/components/ui/checkbox";
43
+ import { cn } from "@/lib/utils";
44
+
45
+ const SOURCE_META: Record<
46
+ DocumentSource,
47
+ { label: string; icon: typeof Globe }
48
+ > = {
49
+ scrape: { label: "Scraped", icon: Globe },
50
+ paste: { label: "Pasted", icon: ClipboardPaste },
51
+ upload: { label: "Uploaded", icon: FileUp },
52
+ };
53
+
54
+ export function CorpusPanel({
55
+ documents,
56
+ onChanged,
57
+ }: {
58
+ documents: SourceDocument[];
59
+ onChanged: () => void;
60
+ }) {
61
+ const [active, setActive] = useState<SourceDocument | null>(null);
62
+ const [deleteTask, setDeleteTask] = useState<
63
+ | { type: "single"; doc: SourceDocument }
64
+ | { type: "all" }
65
+ | { type: "selected" }
66
+ | null
67
+ >(null);
68
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
69
+
70
+ const [page, setPage] = useState(0);
71
+
72
+ const PAGE_SIZE = 10;
73
+ const totalPages = Math.max(1, Math.ceil(documents.length / PAGE_SIZE));
74
+ const safePage = Math.min(page, totalPages - 1);
75
+ const pageDocuments = documents.slice(
76
+ safePage * PAGE_SIZE,
77
+ (safePage + 1) * PAGE_SIZE,
78
+ );
79
+
80
+ const allPageSelected =
81
+ pageDocuments.length > 0 &&
82
+ pageDocuments.every((d) => selectedIds.has(d.id));
83
+
84
+ async function del(id: string) {
85
+ await fetch(`/api/documents?id=${id}`, { method: "DELETE" });
86
+ toast.message("Document deleted");
87
+ setSelectedIds((prev) => {
88
+ const next = new Set(prev);
89
+ next.delete(id);
90
+ return next;
91
+ });
92
+ onChanged();
93
+ }
94
+ async function delSelected() {
95
+ if (selectedIds.size === 0) return;
96
+ const ids = Array.from(selectedIds).join(",");
97
+ await fetch(`/api/documents?ids=${ids}`, { method: "DELETE" });
98
+ toast.message(`Deleted ${selectedIds.size} document(s)`);
99
+ setSelectedIds(new Set());
100
+ onChanged();
101
+ }
102
+ async function clearAll() {
103
+ await fetch("/api/documents", { method: "DELETE" });
104
+ toast.message("Corpus cleared");
105
+ setSelectedIds(new Set());
106
+ onChanged();
107
+ }
108
+
109
+ if (documents.length === 0) {
110
+ return (
111
+ <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border px-6 py-14 text-center">
112
+ <FileText className="size-7 text-muted-foreground/60" />
113
+ <p className="mt-3 text-sm font-medium">Your corpus is empty</p>
114
+ <p className="mt-1 max-w-sm text-xs text-muted-foreground">
115
+ Scrape the web, paste text, or upload files. Everything you collect is
116
+ stored locally and ready to index in the next step.
117
+ </p>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <>
124
+ <div className="flex items-center justify-between pb-3">
125
+ <p className="text-sm text-muted-foreground">
126
+ <span className="font-medium text-foreground">
127
+ {documents.length}
128
+ </span>{" "}
129
+ document{documents.length === 1 ? "" : "s"} in corpus
130
+ </p>
131
+ <div className="flex items-center gap-2">
132
+ {selectedIds.size > 0 && (
133
+ <Button
134
+ size="sm"
135
+ variant="ghost"
136
+ className="h-8 text-xs text-muted-foreground hover:text-destructive"
137
+ onClick={() => setDeleteTask({ type: "selected" })}
138
+ >
139
+ <Trash2 className="size-3.5" />
140
+ Delete {selectedIds.size} selected
141
+ </Button>
142
+ )}
143
+ <Button
144
+ size="sm"
145
+ variant="ghost"
146
+ className="h-8 text-xs text-muted-foreground hover:text-destructive"
147
+ onClick={() => setDeleteTask({ type: "all" })}
148
+ >
149
+ <Trash2 className="size-3.5" />
150
+ Clear corpus
151
+ </Button>
152
+ </div>
153
+ </div>
154
+
155
+ <div className="overflow-hidden rounded-lg border border-border">
156
+ <Table>
157
+ <TableHeader>
158
+ <TableRow className="bg-muted/50">
159
+ <TableHead className="w-12">
160
+ <Checkbox
161
+ checked={allPageSelected}
162
+ onCheckedChange={(checked) => {
163
+ if (checked) {
164
+ const next = new Set(selectedIds);
165
+ pageDocuments.forEach((d) => next.add(d.id));
166
+ setSelectedIds(next);
167
+ } else {
168
+ const next = new Set(selectedIds);
169
+ pageDocuments.forEach((d) => next.delete(d.id));
170
+ setSelectedIds(next);
171
+ }
172
+ }}
173
+ aria-label="Select all on page"
174
+ />
175
+ </TableHead>
176
+ <TableHead>Title</TableHead>
177
+ <TableHead className="w-28">Source</TableHead>
178
+ <TableHead className="w-24 text-right">Chars</TableHead>
179
+ <TableHead className="w-20" />
180
+ </TableRow>
181
+ </TableHeader>
182
+ <TableBody>
183
+ {pageDocuments.map((doc) => {
184
+ const meta = SOURCE_META[doc.source];
185
+ const Icon = meta.icon;
186
+ return (
187
+ <TableRow key={doc.id}>
188
+ <TableCell>
189
+ <Checkbox
190
+ checked={selectedIds.has(doc.id)}
191
+ onCheckedChange={(checked) => {
192
+ const next = new Set(selectedIds);
193
+ if (checked) next.add(doc.id);
194
+ else next.delete(doc.id);
195
+ setSelectedIds(next);
196
+ }}
197
+ aria-label="Select document"
198
+ />
199
+ </TableCell>
200
+ <TableCell>
201
+ <button
202
+ type="button"
203
+ onClick={() => setActive(doc)}
204
+ className="group flex flex-col items-start text-left"
205
+ >
206
+ <span className="line-clamp-1 text-sm font-medium group-hover:text-primary">
207
+ {doc.title}
208
+ </span>
209
+ {doc.url && (
210
+ <span className="line-clamp-1 text-xs text-muted-foreground">
211
+ {doc.url}
212
+ </span>
213
+ )}
214
+ </button>
215
+ </TableCell>
216
+ <TableCell>
217
+ <Badge variant="secondary" className="gap-1 font-normal">
218
+ <Icon className="size-3" />
219
+ {meta.label}
220
+ </Badge>
221
+ </TableCell>
222
+ <TableCell className="text-right text-xs text-muted-foreground tabular-nums">
223
+ {doc.charCount.toLocaleString()}
224
+ </TableCell>
225
+ <TableCell>
226
+ <div className="flex items-center justify-end gap-4">
227
+ <button
228
+ type="button"
229
+ aria-label="Edit document"
230
+ onClick={() => setActive(doc)}
231
+ className="text-muted-foreground transition-colors hover:text-primary"
232
+ >
233
+ <Pencil className="size-4" />
234
+ </button>
235
+ <button
236
+ type="button"
237
+ aria-label="Delete document"
238
+ onClick={() => setDeleteTask({ type: "single", doc })}
239
+ className="text-muted-foreground transition-colors hover:text-destructive"
240
+ >
241
+ <Trash2 className="size-4" />
242
+ </button>
243
+ </div>
244
+ </TableCell>
245
+ </TableRow>
246
+ );
247
+ })}
248
+ </TableBody>
249
+ </Table>
250
+ </div>
251
+
252
+ {/* Pagination */}
253
+ {totalPages > 1 && (
254
+ <div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
255
+ <span>
256
+ {safePage * PAGE_SIZE + 1}–
257
+ {Math.min((safePage + 1) * PAGE_SIZE, documents.length)} of{" "}
258
+ {documents.length}
259
+ </span>
260
+ <div className="flex items-center gap-1">
261
+ <button
262
+ disabled={safePage === 0}
263
+ onClick={() => setPage((p) => Math.max(0, p - 1))}
264
+ className="rounded p-1 hover:bg-muted disabled:opacity-40 transition-colors cursor-pointer"
265
+ >
266
+ <ChevronLeft className="size-3.5" />
267
+ </button>
268
+ <span className="tabular-nums">
269
+ {safePage + 1} / {totalPages}
270
+ </span>
271
+ <button
272
+ disabled={safePage >= totalPages - 1}
273
+ onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
274
+ className="rounded p-1 hover:bg-muted disabled:opacity-40 transition-colors cursor-pointer"
275
+ >
276
+ <ChevronRight className="size-3.5" />
277
+ </button>
278
+ </div>
279
+ </div>
280
+ )}
281
+
282
+ <DocumentDialog
283
+ doc={active}
284
+ onOpenChange={(o) => !o && setActive(null)}
285
+ onSaved={() => {
286
+ setActive(null);
287
+ onChanged();
288
+ }}
289
+ onDelete={(id) => {
290
+ if (active) {
291
+ setDeleteTask({ type: "single", doc: active });
292
+ }
293
+ }}
294
+ />
295
+
296
+ <GenericAlert
297
+ open={deleteTask !== null}
298
+ onOpenChange={(open) => {
299
+ if (!open) setDeleteTask(null);
300
+ }}
301
+ title={
302
+ deleteTask?.type === "all"
303
+ ? "Clear corpus"
304
+ : deleteTask?.type === "selected"
305
+ ? "Delete selected documents"
306
+ : "Delete document"
307
+ }
308
+ description={
309
+ deleteTask?.type === "all"
310
+ ? "Are you sure you want to delete all documents in the corpus? This action cannot be undone."
311
+ : deleteTask?.type === "selected"
312
+ ? `Are you sure you want to delete ${selectedIds.size} selected documents?`
313
+ : `Are you sure you want to delete "${
314
+ deleteTask?.type === "single" ? deleteTask.doc.title : ""
315
+ }"?`
316
+ }
317
+ actionText="Delete"
318
+ variant="destructive"
319
+ onAction={() => {
320
+ if (deleteTask?.type === "all") {
321
+ clearAll();
322
+ } else if (deleteTask?.type === "selected") {
323
+ delSelected();
324
+ } else if (deleteTask?.type === "single") {
325
+ del(deleteTask.doc.id);
326
+ if (active?.id === deleteTask.doc.id) {
327
+ setActive(null);
328
+ }
329
+ }
330
+ }}
331
+ />
332
+ </>
333
+ );
334
+ }
335
+
336
+ function DocumentDialog({
337
+ doc,
338
+ onOpenChange,
339
+ onSaved,
340
+ onDelete,
341
+ }: {
342
+ doc: SourceDocument | null;
343
+ onOpenChange: (open: boolean) => void;
344
+ onSaved: () => void;
345
+ onDelete: (id: string) => void;
346
+ }) {
347
+ const [editing, setEditing] = useState(false);
348
+ const [title, setTitle] = useState("");
349
+ const [url, setUrl] = useState("");
350
+ const [content, setContent] = useState("");
351
+ const [saving, setSaving] = useState(false);
352
+
353
+ // Reset local state whenever a different document is opened.
354
+ useEffect(() => {
355
+ if (doc) {
356
+ setEditing(false);
357
+ setTitle(doc.title);
358
+ setUrl(doc.url ?? "");
359
+ setContent(doc.content);
360
+ }
361
+ }, [doc]);
362
+
363
+ async function save() {
364
+ if (!doc) return;
365
+ if (!content.trim()) {
366
+ toast.error("Content can't be empty.");
367
+ return;
368
+ }
369
+ setSaving(true);
370
+ try {
371
+ const res = await fetch("/api/documents", {
372
+ method: "PATCH",
373
+ headers: { "Content-Type": "application/json" },
374
+ body: JSON.stringify({
375
+ id: doc.id,
376
+ title: title.trim() || "Untitled",
377
+ url: url.trim(),
378
+ content,
379
+ }),
380
+ });
381
+ if (!res.ok) {
382
+ const body = await res.json().catch(() => ({}));
383
+ toast.error(body.error ? formatErrorMessage(new Error(body.error)) : "Failed to save document.");
384
+ return;
385
+ }
386
+ toast.success("Document saved");
387
+ onSaved();
388
+ } catch (err) {
389
+ toast.error(formatErrorMessage(err) || "Could not reach the server.");
390
+ } finally {
391
+ setSaving(false);
392
+ }
393
+ }
394
+
395
+ return (
396
+ <Dialog open={!!doc} onOpenChange={onOpenChange}>
397
+ <DialogContent className="w-full max-w-2xl max-h-[80%]">
398
+ <DialogHeader>
399
+ <DialogTitle className="text-balance">
400
+ {editing ? "Edit document" : doc?.title}
401
+ </DialogTitle>
402
+ {!editing && doc?.url && (
403
+ <DialogDescription className="break-all">
404
+ {doc.url}
405
+ </DialogDescription>
406
+ )}
407
+ {editing && (
408
+ <DialogDescription>
409
+ Changes are saved to this server&apos;s corpus. Re-index to apply
410
+ them to search.
411
+ </DialogDescription>
412
+ )}
413
+ </DialogHeader>
414
+
415
+ {editing ? (
416
+ <div className="grid gap-4 overflow-y-auto pr-2 pb-2" style={{ maxHeight: "calc(80vh - 150px)" }}>
417
+ <div className="grid gap-2">
418
+ <Label htmlFor="doc-title">Title</Label>
419
+ <Input
420
+ id="doc-title"
421
+ value={title}
422
+ onChange={(e) => setTitle(e.target.value)}
423
+ placeholder="Untitled"
424
+ />
425
+ </div>
426
+ <div className="grid gap-2">
427
+ <Label htmlFor="doc-url">URL (optional)</Label>
428
+ <Input
429
+ id="doc-url"
430
+ value={url}
431
+ onChange={(e) => setUrl(e.target.value)}
432
+ placeholder="https://…"
433
+ />
434
+ </div>
435
+ <div className="grid gap-2">
436
+ <div className="flex items-center justify-between">
437
+ <Label htmlFor="doc-content">Content</Label>
438
+ <span className="text-xs text-muted-foreground tabular-nums ">
439
+ {content.length.toLocaleString()} chars
440
+ </span>
441
+ </div>
442
+ <Textarea
443
+ id="doc-content"
444
+ value={content}
445
+ onChange={(e) => setContent(e.target.value)}
446
+ rows={14}
447
+ className="resize-none font-mono text-xs leading-relaxed"
448
+ />
449
+ </div>
450
+ </div>
451
+ ) : (
452
+ <ScrollArea className="max-h-[60vh] rounded-md border border-border bg-muted/30">
453
+ <pre
454
+ className={cn(
455
+ "whitespace-pre-wrap break-all px-4 py-3 font-mono text-xs leading-relaxed text-foreground",
456
+ )}
457
+ >
458
+ {doc?.content}
459
+ </pre>
460
+ </ScrollArea>
461
+ )}
462
+
463
+ <DialogFooter className="flex-row items-center justify-between gap-2 sm:justify-between">
464
+ {editing ? (
465
+ <>
466
+ <Button
467
+ variant="ghost"
468
+ onClick={() => setEditing(false)}
469
+ disabled={saving}
470
+ >
471
+ <X className="size-4" />
472
+ Cancel
473
+ </Button>
474
+ <Button onClick={() => void save()} disabled={saving}>
475
+ {saving && <Loader2 className="size-4 animate-spin" />}
476
+ Save changes
477
+ </Button>
478
+ </>
479
+ ) : (
480
+ <>
481
+ <Button
482
+ variant="ghost"
483
+ className="text-muted-foreground hover:text-destructive"
484
+ onClick={() => doc && onDelete(doc.id)}
485
+ >
486
+ <Trash2 className="size-4" />
487
+ Delete
488
+ </Button>
489
+ <Button onClick={() => setEditing(true)}>
490
+ <Pencil className="size-4" />
491
+ Edit
492
+ </Button>
493
+ </>
494
+ )}
495
+ </DialogFooter>
496
+ </DialogContent>
497
+ </Dialog>
498
+ );
499
+ }