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,714 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { formatErrorMessage } from "@/lib/error-formatter";
6
+ import { FileUp, Loader2, X, Settings2, Columns, Plus } from "lucide-react";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import { cn } from "@/lib/utils";
10
+ import Papa from "papaparse";
11
+ import * as XLSX from "xlsx";
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogFooter,
18
+ } from "@/components/ui/dialog";
19
+ import {
20
+ Select,
21
+ SelectContent,
22
+ SelectItem,
23
+ SelectTrigger,
24
+ SelectValue,
25
+ } from "@/components/ui/select";
26
+ import { Checkbox } from "@/components/ui/checkbox";
27
+ import { Label } from "@/components/ui/label";
28
+ import { Switch } from "@/components/ui/switch";
29
+ import { Progress } from "@/components/ui/progress";
30
+
31
+ const ACCEPT = ".txt,.md,.markdown,.json,.csv,.html,.htm,.log,.xlsx,.xls,.pdf,.doc,.docx";
32
+
33
+ type FileFormat = "plain" | "lines" | "structured";
34
+
35
+ interface StagedFile {
36
+ id: string;
37
+ name: string;
38
+ size: number;
39
+ format: FileFormat;
40
+ rawContent?: string;
41
+ rows?: any[];
42
+ keys?: string[];
43
+ // mapping for structured
44
+ titleKey?: string;
45
+ contentKeys?: string[];
46
+ contentSeparator?: string;
47
+ metadataKeys?: string[];
48
+ globalMetadata?: { key: string; value: string }[];
49
+ }
50
+
51
+ export function UploadPanel({ onAdded }: { onAdded: () => void }) {
52
+ const inputRef = useRef<HTMLInputElement>(null);
53
+ const [staged, setStaged] = useState<StagedFile[]>([]);
54
+ const [dragging, setDragging] = useState(false);
55
+ const [saving, setSaving] = useState(false);
56
+ const [progress, setProgress] = useState<{
57
+ current: number;
58
+ total: number;
59
+ } | null>(null);
60
+
61
+ // Mapping state
62
+ const [editingFileId, setEditingFileId] = useState<string | null>(null);
63
+ const editingFile = staged.find((f) => f.id === editingFileId);
64
+
65
+ async function readFiles(files: FileList | File[]) {
66
+ const next: StagedFile[] = [];
67
+ for (const file of Array.from(files)) {
68
+ const ext = file.name.split(".").pop()?.toLowerCase();
69
+ const id = Math.random().toString(36).slice(2);
70
+ try {
71
+ if (ext === "csv") {
72
+ const content = await file.text();
73
+ const result = Papa.parse(content, {
74
+ header: true,
75
+ skipEmptyLines: true,
76
+ });
77
+ if (result.data.length > 0) {
78
+ const keys = Object.keys(result.data[0] as object);
79
+ next.push({
80
+ id,
81
+ name: file.name,
82
+ size: file.size,
83
+ format: "structured",
84
+ rows: result.data,
85
+ keys,
86
+ titleKey: keys[0],
87
+ contentKeys: keys,
88
+ contentSeparator: ", ",
89
+ metadataKeys: [],
90
+ globalMetadata: [],
91
+ });
92
+ }
93
+ } else if (ext === "xlsx" || ext === "xls") {
94
+ const data = await file.arrayBuffer();
95
+ const workbook = XLSX.read(data, { type: "array" });
96
+ const sheet = workbook.Sheets[workbook.SheetNames[0]];
97
+ const rows = XLSX.utils.sheet_to_json(sheet);
98
+ if (rows.length > 0) {
99
+ const keys = Object.keys(rows[0] as object);
100
+ next.push({
101
+ id,
102
+ name: file.name,
103
+ size: file.size,
104
+ format: "structured",
105
+ rows,
106
+ keys,
107
+ titleKey: keys[0],
108
+ contentKeys: keys,
109
+ contentSeparator: ", ",
110
+ metadataKeys: [],
111
+ globalMetadata: [],
112
+ });
113
+ }
114
+ } else if (ext === "json") {
115
+ const content = await file.text();
116
+ try {
117
+ const parsed = JSON.parse(content);
118
+ if (
119
+ Array.isArray(parsed) &&
120
+ parsed.length > 0 &&
121
+ typeof parsed[0] === "object"
122
+ ) {
123
+ const keys = Object.keys(parsed[0]);
124
+ next.push({
125
+ id,
126
+ name: file.name,
127
+ size: file.size,
128
+ format: "structured",
129
+ rows: parsed,
130
+ keys,
131
+ titleKey: keys[0],
132
+ contentKeys: keys,
133
+ contentSeparator: ", ",
134
+ metadataKeys: [],
135
+ globalMetadata: [],
136
+ });
137
+ } else {
138
+ // fallback to plain
139
+ next.push({
140
+ id,
141
+ name: file.name,
142
+ size: file.size,
143
+ format: "plain",
144
+ rawContent: content,
145
+ });
146
+ }
147
+ } catch {
148
+ next.push({
149
+ id,
150
+ name: file.name,
151
+ size: file.size,
152
+ format: "plain",
153
+ rawContent: content,
154
+ });
155
+ }
156
+ } else if (ext === "pdf" || ext === "doc" || ext === "docx") {
157
+ const formData = new FormData();
158
+ formData.append("file", file);
159
+
160
+ const res = await fetch("/api/parse-file", {
161
+ method: "POST",
162
+ body: formData,
163
+ });
164
+
165
+ if (!res.ok) {
166
+ throw new Error(`Failed to parse ${file.name}`);
167
+ }
168
+
169
+ const { text } = await res.json();
170
+ next.push({
171
+ id,
172
+ name: file.name,
173
+ size: file.size,
174
+ format: "plain",
175
+ rawContent: text,
176
+ });
177
+ } else {
178
+ const content = await file.text();
179
+ next.push({
180
+ id,
181
+ name: file.name,
182
+ size: file.size,
183
+ format: "plain",
184
+ rawContent: content,
185
+ });
186
+ }
187
+ } catch (err) {
188
+ toast.error(`Could not read ${file.name}. ${formatErrorMessage(err)}`);
189
+ }
190
+ }
191
+ setStaged((prev) => [...prev, ...next]);
192
+ }
193
+
194
+ async function ingest() {
195
+ if (staged.length === 0) return;
196
+ setSaving(true);
197
+ let ok = 0;
198
+
199
+ const payloads: any[] = [];
200
+
201
+ for (const f of staged) {
202
+ if (f.format === "plain" && f.rawContent) {
203
+ payloads.push({
204
+ title: f.name,
205
+ content: f.rawContent,
206
+ source: "upload",
207
+ });
208
+ } else if (f.format === "lines" && f.rawContent) {
209
+ const lines = f.rawContent
210
+ .split(/\r?\n/)
211
+ .map((l) => l.trim())
212
+ .filter(Boolean);
213
+ lines.forEach((line, i) => {
214
+ payloads.push({
215
+ title: `${f.name} - Line ${i + 1}`,
216
+ content: line,
217
+ source: "upload",
218
+ });
219
+ });
220
+ } else if (f.format === "structured" && f.rows) {
221
+ for (let i = 0; i < f.rows.length; i++) {
222
+ const row = f.rows[i];
223
+ const title = f.titleKey
224
+ ? String(row[f.titleKey] || `Row ${i + 1}`)
225
+ : `Row ${i + 1}`;
226
+
227
+ let content = "";
228
+ if (f.contentKeys && f.contentKeys.length > 0) {
229
+ content = f.contentKeys
230
+ .map((k) => String(row[k] || ""))
231
+ .filter(Boolean)
232
+ .join(f.contentSeparator || " ");
233
+ } else {
234
+ content = JSON.stringify(row);
235
+ }
236
+
237
+ const metadata: Record<string, any> = {};
238
+ if (f.metadataKeys) {
239
+ for (const mk of f.metadataKeys) {
240
+ metadata[mk] = row[mk];
241
+ }
242
+ }
243
+ if (f.globalMetadata) {
244
+ for (const gm of f.globalMetadata) {
245
+ if (gm.key.trim()) {
246
+ metadata[gm.key.trim()] = gm.value;
247
+ }
248
+ }
249
+ }
250
+
251
+ if (content.trim()) {
252
+ payloads.push({ title, content, metadata, source: "upload" });
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ setProgress({ current: 0, total: payloads.length });
259
+
260
+ for (const [index, p] of payloads.entries()) {
261
+ try {
262
+ const res = await fetch("/api/documents", {
263
+ method: "POST",
264
+ headers: { "Content-Type": "application/json" },
265
+ body: JSON.stringify(p),
266
+ });
267
+ if (res.ok) ok++;
268
+ } catch {
269
+ /* continue */
270
+ }
271
+ setProgress({ current: index + 1, total: payloads.length });
272
+ }
273
+
274
+ setSaving(false);
275
+ setProgress(null);
276
+ setStaged([]);
277
+ if (ok > 0) {
278
+ toast.success(
279
+ `Added ${ok} document${ok > 1 ? "s" : ""} from ${staged.length} file${staged.length > 1 ? "s" : ""}`,
280
+ );
281
+ onAdded();
282
+ } else {
283
+ toast.error("No documents could be added.");
284
+ }
285
+ }
286
+
287
+ function updateEditingFile(patch: Partial<StagedFile>) {
288
+ setStaged((prev) =>
289
+ prev.map((f) => (f.id === editingFileId ? { ...f, ...patch } : f)),
290
+ );
291
+ }
292
+
293
+ return (
294
+ <div className="space-y-4 cursor-pointer">
295
+ <button
296
+ type="button"
297
+ onClick={() => inputRef.current?.click()}
298
+ onDragOver={(e) => {
299
+ e.preventDefault();
300
+ setDragging(true);
301
+ }}
302
+ onDragLeave={() => setDragging(false)}
303
+ onDrop={(e) => {
304
+ e.preventDefault();
305
+ setDragging(false);
306
+ if (e.dataTransfer.files?.length) readFiles(e.dataTransfer.files);
307
+ }}
308
+ className={cn(
309
+ "flex w-full flex-col cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-muted/40 px-6 py-10 text-center transition-colors hover:bg-muted/70",
310
+ dragging && "border-primary bg-accent",
311
+ )}
312
+ >
313
+ <FileUp className="size-6 text-muted-foreground" />
314
+ <span className="text-sm font-medium">
315
+ Drop files here or click to browse
316
+ </span>
317
+ <span className="text-xs text-muted-foreground">
318
+ Text, JSON, CSV, Excel, PDF, and Word files
319
+ </span>
320
+ </button>
321
+ <input
322
+ ref={inputRef}
323
+ type="file"
324
+ multiple
325
+ accept={ACCEPT}
326
+ className="sr-only"
327
+ onChange={(e) => {
328
+ if (e.target.files?.length) readFiles(e.target.files);
329
+ e.target.value = "";
330
+ }}
331
+ />
332
+
333
+ {staged.length > 0 && (
334
+ <ul className="space-y-1.5">
335
+ {staged.map((f) => (
336
+ <li
337
+ key={f.id}
338
+ className="flex items-center justify-between gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm"
339
+ >
340
+ <div className="min-w-0 flex-1 flex flex-col gap-0.5">
341
+ <span className="truncate font-mono text-xs">{f.name}</span>
342
+ <span className="text-[10px] text-muted-foreground uppercase tracking-wider">
343
+ {f.format === "structured"
344
+ ? "STRUCTURED"
345
+ : f.format === "lines"
346
+ ? "SPLIT BY LINE"
347
+ : "PLAIN TEXT"}
348
+ {f.format === "structured" && ` • ${f.rows?.length} ROWS`}
349
+ </span>
350
+ </div>
351
+ <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
352
+ {(f.size / 1024).toFixed(1)} KB
353
+ </span>
354
+
355
+ <div className="flex items-center gap-1 shrink-0">
356
+ <button
357
+ type="button"
358
+ aria-label={`Configure ${f.name}`}
359
+ onClick={() => setEditingFileId(f.id)}
360
+ className="p-1.5 bg-secondary cursor-pointer border text-muted-foreground hover:bg-muted/50 hover:text-foreground rounded-md transition-colors"
361
+ >
362
+ <Settings2 className="size-3.5" />
363
+ </button>
364
+ <button
365
+ type="button"
366
+ aria-label={`Remove ${f.name}`}
367
+ onClick={() =>
368
+ setStaged((p) => p.filter((item) => item.id !== f.id))
369
+ }
370
+ className="p-1.5 border bg-muted hover:bg-muted/50 cursor-pointer text-muted-foreground hover:text-foreground rounded-md transition-colors"
371
+ >
372
+ <X className="size-3.5 text-red-500" />
373
+ </button>
374
+ </div>
375
+ </li>
376
+ ))}
377
+ </ul>
378
+ )}
379
+
380
+ {progress && (
381
+ <div className="space-y-2">
382
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
383
+ <span>Uploading documents...</span>
384
+ <span className="tabular-nums font-mono">
385
+ {progress.current} / {progress.total}
386
+ </span>
387
+ </div>
388
+ <Progress value={(progress.current / progress.total) * 100} />
389
+ </div>
390
+ )}
391
+
392
+ <Button onClick={ingest} disabled={saving || staged.length === 0}>
393
+ {saving ? (
394
+ <Loader2 className="size-4 animate-spin" />
395
+ ) : (
396
+ <FileUp className="size-4" />
397
+ )}
398
+ {saving && progress
399
+ ? `Adding ${progress.current} of ${progress.total}`
400
+ : `Add ${staged.length > 0 ? staged.length : ""} file${staged.length === 1 ? "" : "s"} to corpus`}
401
+ </Button>
402
+
403
+ {/* Mapping Dialog */}
404
+ <Dialog
405
+ open={!!editingFile}
406
+ onOpenChange={(open) => !open && setEditingFileId(null)}
407
+ >
408
+ <DialogContent className="sm:max-w-xl max-h-[80%]">
409
+ <DialogHeader>
410
+ <DialogTitle className="flex items-center gap-2">
411
+ <Columns className="size-4 text-primary" />
412
+ Configure Ingestion
413
+ </DialogTitle>
414
+ </DialogHeader>
415
+
416
+ {editingFile && (
417
+ <div className="py-4 space-y-6 overflow-y-auto pr-2" style={{ maxHeight: "calc(80vh - 120px)" }}>
418
+ {editingFile.format === "structured" ? (
419
+ <>
420
+ <div className="space-y-4">
421
+ <div className="space-y-2">
422
+ <Label>Title Column</Label>
423
+ <Select
424
+ value={editingFile.titleKey}
425
+ onValueChange={(val) =>
426
+ updateEditingFile({ titleKey: val || undefined })
427
+ }
428
+ >
429
+ <SelectTrigger>
430
+ <SelectValue placeholder="Select column" />
431
+ </SelectTrigger>
432
+ <SelectContent>
433
+ {editingFile.keys?.map((k) => (
434
+ <SelectItem key={k} value={k}>
435
+ {k}
436
+ </SelectItem>
437
+ ))}
438
+ </SelectContent>
439
+ </Select>
440
+ <p className="text-[11px] text-muted-foreground">
441
+ Used as the document name.
442
+ </p>
443
+ </div>
444
+
445
+ <div className="space-y-3 pt-2 border-t border-border">
446
+ <div className="flex items-center justify-between">
447
+ <Label>Content Columns</Label>
448
+ <div className="flex items-center gap-2">
449
+ <button
450
+ type="button"
451
+ className="text-[10px] uppercase font-medium text-primary hover:underline cursor-pointer"
452
+ onClick={() => {
453
+ updateEditingFile({
454
+ contentKeys: editingFile.keys || []
455
+ });
456
+ }}
457
+ >
458
+ Select All
459
+ </button>
460
+ <span className="text-muted-foreground text-[10px]">|</span>
461
+ <button
462
+ type="button"
463
+ className="text-[10px] uppercase font-medium text-muted-foreground hover:underline cursor-pointer"
464
+ onClick={() => {
465
+ updateEditingFile({ contentKeys: [] });
466
+ }}
467
+ >
468
+ Clear
469
+ </button>
470
+ </div>
471
+ </div>
472
+ <p className="text-[11px] text-muted-foreground mb-2 -mt-1">
473
+ Selected columns will be combined to form the searchable
474
+ content.
475
+ </p>
476
+ <div className="max-h-[120px] overflow-y-auto space-y-2 pr-2">
477
+ {editingFile.keys?.map((k) => {
478
+ const isSelected =
479
+ editingFile.contentKeys?.includes(k);
480
+ return (
481
+ <div
482
+ key={k}
483
+ className="flex items-center space-x-2"
484
+ >
485
+ <Checkbox
486
+ id={`content-${k}`}
487
+ checked={isSelected}
488
+ onCheckedChange={(checked) => {
489
+ const current = editingFile.contentKeys || [];
490
+ if (checked) {
491
+ updateEditingFile({
492
+ contentKeys: [...current, k],
493
+ });
494
+ } else {
495
+ updateEditingFile({
496
+ contentKeys: current.filter(
497
+ (x) => x !== k,
498
+ ),
499
+ });
500
+ }
501
+ }}
502
+ />
503
+ <label
504
+ htmlFor={`content-${k}`}
505
+ className="text-sm font-medium leading-none"
506
+ >
507
+ {k}
508
+ </label>
509
+ </div>
510
+ );
511
+ })}
512
+ </div>
513
+
514
+ {editingFile.contentKeys &&
515
+ editingFile.contentKeys.length > 1 && (
516
+ <div className="pt-2">
517
+ <Label className="mb-2 block">Separator</Label>
518
+ <Select
519
+ value={editingFile.contentSeparator || " "}
520
+ onValueChange={(val) =>
521
+ updateEditingFile({
522
+ contentSeparator: val || undefined,
523
+ })
524
+ }
525
+ >
526
+ <SelectTrigger>
527
+ <SelectValue placeholder="Select separator" />
528
+ </SelectTrigger>
529
+ <SelectContent>
530
+ <SelectItem value=" ">Space</SelectItem>
531
+ <SelectItem value=", ">Comma</SelectItem>
532
+ <SelectItem value="\n">Newline</SelectItem>
533
+ <SelectItem value=" - ">Hyphen</SelectItem>
534
+ </SelectContent>
535
+ </Select>
536
+ </div>
537
+ )}
538
+ </div>
539
+
540
+ <div className="space-y-3 pt-2 border-t border-border">
541
+ <div className="flex items-center justify-between">
542
+ <Label>Metadata Columns</Label>
543
+ <div className="flex items-center gap-2">
544
+ <button
545
+ type="button"
546
+ className="text-[10px] uppercase font-medium text-primary hover:underline cursor-pointer"
547
+ onClick={() => {
548
+ const availableKeys = editingFile.keys?.filter(k => k !== editingFile.titleKey) || [];
549
+ updateEditingFile({
550
+ metadataKeys: availableKeys
551
+ });
552
+ }}
553
+ >
554
+ Select All
555
+ </button>
556
+ <span className="text-muted-foreground text-[10px]">|</span>
557
+ <button
558
+ type="button"
559
+ className="text-[10px] uppercase font-medium text-muted-foreground hover:underline cursor-pointer"
560
+ onClick={() => {
561
+ updateEditingFile({ metadataKeys: [] });
562
+ }}
563
+ >
564
+ Clear
565
+ </button>
566
+ </div>
567
+ </div>
568
+ <p className="text-[11px] text-muted-foreground mb-2 -mt-1">
569
+ These columns will be stored as searchable metadata.
570
+ </p>
571
+ <div className="max-h-[120px] overflow-y-auto space-y-2 pr-2">
572
+ {editingFile.keys?.map((k) => {
573
+ const isSelected =
574
+ editingFile.metadataKeys?.includes(k);
575
+ const disabled = k === editingFile.titleKey;
576
+ return (
577
+ <div
578
+ key={k}
579
+ className="flex items-center space-x-2"
580
+ >
581
+ <Checkbox
582
+ id={`meta-${k}`}
583
+ checked={isSelected}
584
+ disabled={disabled}
585
+ onCheckedChange={(checked) => {
586
+ const current =
587
+ editingFile.metadataKeys || [];
588
+ if (checked) {
589
+ updateEditingFile({
590
+ metadataKeys: [...current, k],
591
+ });
592
+ } else {
593
+ updateEditingFile({
594
+ metadataKeys: current.filter(
595
+ (x) => x !== k,
596
+ ),
597
+ });
598
+ }
599
+ }}
600
+ />
601
+ <label
602
+ htmlFor={`meta-${k}`}
603
+ className={cn(
604
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
605
+ disabled && "text-muted-foreground",
606
+ )}
607
+ >
608
+ {k} {disabled && "(used)"}
609
+ </label>
610
+ </div>
611
+ );
612
+ })}
613
+ </div>
614
+ </div>
615
+ </div>
616
+ </>
617
+ ) : (
618
+ <div className="space-y-4">
619
+ <div className="flex items-center justify-between rounded-lg border border-border p-3">
620
+ <div className="space-y-0.5">
621
+ <Label>Split by Line</Label>
622
+ <p className="text-[11px] text-muted-foreground">
623
+ Treat each line as a completely separate document.
624
+ </p>
625
+ </div>
626
+ <Switch
627
+ checked={editingFile.format === "lines"}
628
+ onCheckedChange={(checked) =>
629
+ updateEditingFile({
630
+ format: checked ? "lines" : "plain",
631
+ })
632
+ }
633
+ />
634
+ </div>
635
+ </div>
636
+ )}
637
+
638
+ <div className="space-y-3 pt-4 border-t border-border">
639
+ <div className="flex items-center justify-between">
640
+ <Label>Global Custom Metadata</Label>
641
+ <Button
642
+ variant="ghost"
643
+ size="sm"
644
+ className="h-6 px-2 text-xs"
645
+ onClick={() => {
646
+ const current = editingFile.globalMetadata || [];
647
+ updateEditingFile({
648
+ globalMetadata: [...current, { key: "", value: "" }],
649
+ });
650
+ }}
651
+ >
652
+ <Plus className="size-3 mr-1" /> Add Field
653
+ </Button>
654
+ </div>
655
+ <p className="text-[11px] text-muted-foreground mb-2">
656
+ Apply custom key-value pairs to every document generated from
657
+ this file.
658
+ </p>
659
+ <div className="space-y-2">
660
+ {(editingFile.globalMetadata || []).map((gm, i) => (
661
+ <div key={i} className="flex items-center gap-2">
662
+ <Input
663
+ placeholder="Key"
664
+ value={gm.key}
665
+ className="h-8 text-xs font-mono"
666
+ onChange={(e) => {
667
+ const next = [...(editingFile.globalMetadata || [])];
668
+ next[i].key = e.target.value;
669
+ updateEditingFile({ globalMetadata: next });
670
+ }}
671
+ />
672
+ <Input
673
+ placeholder="Value"
674
+ value={gm.value}
675
+ className="h-8 text-xs font-mono"
676
+ onChange={(e) => {
677
+ const next = [...(editingFile.globalMetadata || [])];
678
+ next[i].value = e.target.value;
679
+ updateEditingFile({ globalMetadata: next });
680
+ }}
681
+ />
682
+ <Button
683
+ variant="ghost"
684
+ size="icon"
685
+ className="h-8 w-8 text-muted-foreground shrink-0"
686
+ onClick={() => {
687
+ const next = [...(editingFile.globalMetadata || [])];
688
+ next.splice(i, 1);
689
+ updateEditingFile({ globalMetadata: next });
690
+ }}
691
+ >
692
+ <X className="size-3.5" />
693
+ </Button>
694
+ </div>
695
+ ))}
696
+ {(!editingFile.globalMetadata ||
697
+ editingFile.globalMetadata.length === 0) && (
698
+ <p className="text-xs text-muted-foreground italic text-center py-2">
699
+ No custom metadata.
700
+ </p>
701
+ )}
702
+ </div>
703
+ </div>
704
+ </div>
705
+ )}
706
+
707
+ <DialogFooter>
708
+ <Button onClick={() => setEditingFileId(null)}>Done</Button>
709
+ </DialogFooter>
710
+ </DialogContent>
711
+ </Dialog>
712
+ </div>
713
+ );
714
+ }