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.
- package/.env +2 -0
- package/app/actions/vercel.ts +417 -0
- package/app/api/chat/route.ts +192 -0
- package/app/api/chat/status/route.ts +55 -0
- package/app/api/chat/suggestions/route.ts +115 -0
- package/app/api/config/route.ts +73 -0
- package/app/api/config/test/route.ts +67 -0
- package/app/api/config/test-embedding/route.ts +40 -0
- package/app/api/config/test-llm/route.ts +47 -0
- package/app/api/config/test-provider/route.ts +36 -0
- package/app/api/demo/route.ts +176 -0
- package/app/api/deploy/ssh/route.ts +340 -0
- package/app/api/documents/route.ts +90 -0
- package/app/api/firecrawl/local/route.ts +46 -0
- package/app/api/index/route.ts +113 -0
- package/app/api/jobs/[id]/route.ts +44 -0
- package/app/api/jobs/route.ts +116 -0
- package/app/api/parse-file/route.ts +39 -0
- package/app/api/search/google/route.ts +162 -0
- package/app/api/search/route.ts +29 -0
- package/app/api/server/download/route.ts +112 -0
- package/app/api/server/generate/route.ts +46 -0
- package/app/api/server/local/route.ts +42 -0
- package/app/api/servers/route.ts +137 -0
- package/app/api/vector-stores/install/route.ts +65 -0
- package/app/api/vector-stores/status/route.ts +26 -0
- package/app/chat/page.tsx +15 -0
- package/app/configure/page.tsx +53 -0
- package/app/data/page.tsx +15 -0
- package/app/demo/page.tsx +15 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +190 -0
- package/app/index-data/page.tsx +15 -0
- package/app/layout.tsx +51 -0
- package/app/page.tsx +34 -0
- package/app/server/page.tsx +22 -0
- package/app/simple/chat/page.tsx +17 -0
- package/app/simple/docs/page.tsx +21 -0
- package/app/simple/layout.tsx +9 -0
- package/app/simple/settings/page.tsx +627 -0
- package/app/template.tsx +16 -0
- package/app/themes.css +733 -0
- package/components/alerts/generic-alert.tsx +74 -0
- package/components/app-sidebar.tsx +187 -0
- package/components/app-topnav.tsx +195 -0
- package/components/chat/chat-settings-modal.tsx +209 -0
- package/components/chat/chat-workspace.tsx +348 -0
- package/components/chat/knowledge-base-result.tsx +97 -0
- package/components/chat/message-item.tsx +103 -0
- package/components/client-layout-wrapper.tsx +182 -0
- package/components/configure/configure-form.tsx +1683 -0
- package/components/configure/store-fields.tsx +152 -0
- package/components/data/corpus-panel.tsx +499 -0
- package/components/data/data-workspace.tsx +272 -0
- package/components/data/firecrawl-notice.tsx +236 -0
- package/components/data/jobs-panel.tsx +485 -0
- package/components/data/paste-panel.tsx +79 -0
- package/components/data/scrape-panel.tsx +964 -0
- package/components/data/upload-panel.tsx +714 -0
- package/components/demo/demo-workspace.tsx +419 -0
- package/components/global-settings.tsx +286 -0
- package/components/index/index-workspace.tsx +482 -0
- package/components/onboarding/simple-setup.tsx +702 -0
- package/components/onboarding/tech-setup.tsx +121 -0
- package/components/onboarding/welcome-screen.tsx +183 -0
- package/components/page-header.tsx +44 -0
- package/components/server/code-viewer.tsx +87 -0
- package/components/server/deploy-button.tsx +1390 -0
- package/components/server/server-workspace.tsx +392 -0
- package/components/simple/sdk-connect-dialog.tsx +201 -0
- package/components/simple/simple-index-button.tsx +83 -0
- package/components/simple/simple-sidebar.tsx +161 -0
- package/components/theme-customizer-provider.tsx +171 -0
- package/components/theme-switcher.tsx +445 -0
- package/components/ui/alert-dialog.tsx +187 -0
- package/components/ui/alert.tsx +76 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/checkbox.tsx +28 -0
- package/components/ui/collapsible.tsx +21 -0
- package/components/ui/dialog.tsx +159 -0
- package/components/ui/dropdown-menu.tsx +281 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +20 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/progress.tsx +83 -0
- package/components/ui/provider-icon.tsx +94 -0
- package/components/ui/scroll-area.tsx +55 -0
- package/components/ui/select.tsx +248 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +138 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/sonner.tsx +63 -0
- package/components/ui/switch.tsx +32 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/tabs.tsx +84 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/workspace/delete-server-dialog.tsx +71 -0
- package/components/workspace/server-form-dialog.tsx +103 -0
- package/components/workspace/server-switcher.tsx +163 -0
- package/components/workspace/workspace-provider.tsx +177 -0
- package/components/workspace/workspace-top-bar.tsx +23 -0
- package/components.json +21 -0
- package/lib/error-formatter.ts +15 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +18 -0
- package/package.json +65 -0
- package/postcss.config.mjs +8 -0
- package/public/docker.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/github.svg +3 -0
- package/public/hetzner.svg +1 -0
- package/public/icons/aws.svg +38 -0
- package/public/icons/azure.svg +1 -0
- package/public/icons/chroma.png +0 -0
- package/public/icons/cohere.svg +30 -0
- package/public/icons/digital-ocean.webp +0 -0
- package/public/icons/firecrawl2.png +0 -0
- package/public/icons/gcp.svg +1 -0
- package/public/icons/gemini.svg +1 -0
- package/public/icons/jina.svg +1 -0
- package/public/icons/lancedb2.png +0 -0
- package/public/icons/mistral.svg +469 -0
- package/public/icons/nomic.png +0 -0
- package/public/icons/openai.svg +1 -0
- package/public/icons/pgvector2.png +0 -0
- package/public/icons/pinecone.png +0 -0
- package/public/icons/qdrant.svg +35 -0
- package/public/icons/supabase.png +0 -0
- package/public/icons/vercel copy.svg +1 -0
- package/public/icons/vercel.svg +1 -0
- package/public/icons/voyage-light.png +0 -0
- package/public/icons/weaviate.webp +0 -0
- package/public/logo-light.png +0 -0
- package/public/logo.png +0 -0
- package/public/logo9.png +0 -0
- package/public/vercel.svg +1 -0
- 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'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
|
+
}
|