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,1683 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
|
4
|
+
import useSWR from "swr";
|
|
5
|
+
import {
|
|
6
|
+
ChevronDown,
|
|
7
|
+
Cloud,
|
|
8
|
+
Loader2,
|
|
9
|
+
Save,
|
|
10
|
+
Sparkles,
|
|
11
|
+
Database,
|
|
12
|
+
Plus,
|
|
13
|
+
Eye,
|
|
14
|
+
EyeOff,
|
|
15
|
+
Trash2,
|
|
16
|
+
AlertCircle,
|
|
17
|
+
ExternalLink,
|
|
18
|
+
CheckCircle2,
|
|
19
|
+
Download,
|
|
20
|
+
Clock,
|
|
21
|
+
Settings,
|
|
22
|
+
} from "lucide-react";
|
|
23
|
+
import { GenericAlert } from "@/components/alerts/generic-alert";
|
|
24
|
+
import {
|
|
25
|
+
Dialog,
|
|
26
|
+
DialogContent,
|
|
27
|
+
DialogDescription,
|
|
28
|
+
DialogFooter,
|
|
29
|
+
DialogHeader,
|
|
30
|
+
DialogTitle,
|
|
31
|
+
} from "@/components/ui/dialog";
|
|
32
|
+
import { toast } from "sonner";
|
|
33
|
+
import { Button } from "@/components/ui/button";
|
|
34
|
+
import {
|
|
35
|
+
Tooltip,
|
|
36
|
+
TooltipContent,
|
|
37
|
+
TooltipProvider,
|
|
38
|
+
TooltipTrigger,
|
|
39
|
+
} from "@/components/ui/tooltip";
|
|
40
|
+
import {
|
|
41
|
+
Card,
|
|
42
|
+
CardContent,
|
|
43
|
+
CardDescription,
|
|
44
|
+
CardHeader,
|
|
45
|
+
CardTitle,
|
|
46
|
+
} from "@/components/ui/card";
|
|
47
|
+
import { Input } from "@/components/ui/input";
|
|
48
|
+
import { Label } from "@/components/ui/label";
|
|
49
|
+
import {
|
|
50
|
+
Select,
|
|
51
|
+
SelectContent,
|
|
52
|
+
SelectItem,
|
|
53
|
+
SelectTrigger,
|
|
54
|
+
SelectGroup,
|
|
55
|
+
SelectLabel,
|
|
56
|
+
} from "@/components/ui/select";
|
|
57
|
+
import { Badge } from "@/components/ui/badge";
|
|
58
|
+
import {
|
|
59
|
+
Collapsible,
|
|
60
|
+
CollapsibleContent,
|
|
61
|
+
CollapsibleTrigger,
|
|
62
|
+
} from "@/components/ui/collapsible";
|
|
63
|
+
import { cn } from "@/lib/utils";
|
|
64
|
+
import {
|
|
65
|
+
DEFAULT_CONFIG,
|
|
66
|
+
type IndexType,
|
|
67
|
+
type RagConfig,
|
|
68
|
+
type VectorStoreId,
|
|
69
|
+
type EmbeddingProvider,
|
|
70
|
+
type CustomEmbeddingConfig,
|
|
71
|
+
} from "@larkup/core/types";
|
|
72
|
+
import {
|
|
73
|
+
EMBEDDING_MODELS,
|
|
74
|
+
getEmbeddingModel,
|
|
75
|
+
} from "@larkup/core/embeddings/registry";
|
|
76
|
+
import {
|
|
77
|
+
getVectorStore,
|
|
78
|
+
validateStoreConfig,
|
|
79
|
+
VECTOR_STORE_LIST,
|
|
80
|
+
} from "@larkup/vector-stores/registry";
|
|
81
|
+
import { StoreFields } from "@/components/configure/store-fields";
|
|
82
|
+
import { useRouter } from "next/navigation";
|
|
83
|
+
import { PROVIDER_META, ProviderIcon } from "@/components/ui/provider-icon";
|
|
84
|
+
|
|
85
|
+
const fetcher = (url: string) =>
|
|
86
|
+
fetch(url).then((r) => r.json() as Promise<{ config: RagConfig }>);
|
|
87
|
+
|
|
88
|
+
// ── Index types ───────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const INDEX_TYPES: { value: IndexType; label: string; hint: string }[] = [
|
|
91
|
+
{ value: "lexical", label: "Lexical", hint: "Keyword / BM25 matching" },
|
|
92
|
+
{ value: "semantic", label: "Semantic", hint: "Pure vector similarity" },
|
|
93
|
+
{ value: "hybrid", label: "Hybrid", hint: "Lexical + semantic, reranked" },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// ── Vector-store branding (real icons from /icons/) ───────────────────────────
|
|
97
|
+
|
|
98
|
+
type StoreMeta = {
|
|
99
|
+
iconSrc: string;
|
|
100
|
+
pillBg: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const STORE_META: Record<VectorStoreId, StoreMeta> = {
|
|
104
|
+
lancedb: {
|
|
105
|
+
iconSrc: "/icons/lancedb2.png",
|
|
106
|
+
pillBg: "bg-yellow-50 dark:bg-yellow-950/40",
|
|
107
|
+
},
|
|
108
|
+
pinecone: {
|
|
109
|
+
iconSrc: "/icons/pinecone.png",
|
|
110
|
+
pillBg: "bg-green-50 dark:bg-green-950/40",
|
|
111
|
+
},
|
|
112
|
+
weaviate: {
|
|
113
|
+
iconSrc: "/icons/weaviate.webp",
|
|
114
|
+
pillBg: "bg-teal-50 dark:bg-teal-950/40",
|
|
115
|
+
},
|
|
116
|
+
qdrant: {
|
|
117
|
+
iconSrc: "/icons/qdrant.svg",
|
|
118
|
+
pillBg: "bg-red-50 dark:bg-red-950/40",
|
|
119
|
+
},
|
|
120
|
+
chroma: {
|
|
121
|
+
iconSrc: "/icons/chroma.png",
|
|
122
|
+
pillBg: "bg-purple-50 dark:bg-purple-950/40",
|
|
123
|
+
},
|
|
124
|
+
pgvector: {
|
|
125
|
+
iconSrc: "/icons/pgvector2.png",
|
|
126
|
+
pillBg: "bg-blue-50 dark:bg-blue-950/40",
|
|
127
|
+
},
|
|
128
|
+
supabase: {
|
|
129
|
+
iconSrc: "/icons/supabase.png",
|
|
130
|
+
pillBg: "bg-emerald-50 dark:bg-emerald-950/40",
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
// Group embedding models by provider for the grouped select
|
|
137
|
+
const EMBEDDING_BY_PROVIDER = EMBEDDING_MODELS.reduce<
|
|
138
|
+
Record<string, typeof EMBEDDING_MODELS>
|
|
139
|
+
>((acc, m) => {
|
|
140
|
+
(acc[m.provider] ??= []).push(m);
|
|
141
|
+
return acc;
|
|
142
|
+
}, {});
|
|
143
|
+
|
|
144
|
+
// ── Main component ────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
const indexFetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
147
|
+
|
|
148
|
+
// Expose dirty state + save trigger so the configure page can place the button in the header
|
|
149
|
+
export type ConfigureFormHandle = {
|
|
150
|
+
dirty: boolean;
|
|
151
|
+
saving: boolean;
|
|
152
|
+
requestSave: () => void;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export function ConfigureForm({
|
|
156
|
+
onHandleReady,
|
|
157
|
+
}: {
|
|
158
|
+
onHandleReady?: (handle: ConfigureFormHandle) => void;
|
|
159
|
+
}) {
|
|
160
|
+
const router = useRouter();
|
|
161
|
+
const { data, isLoading, mutate } = useSWR("/api/config", fetcher);
|
|
162
|
+
const { data: indexData } = useSWR("/api/index", indexFetcher, {
|
|
163
|
+
refreshInterval: 0,
|
|
164
|
+
});
|
|
165
|
+
// Fetch install status for optional vector stores
|
|
166
|
+
const { data: storeStatusData, mutate: mutateStoreStatus } = useSWR(
|
|
167
|
+
"/api/vector-stores/status",
|
|
168
|
+
(url: string) => fetch(url).then((r) => r.json()),
|
|
169
|
+
{ refreshInterval: 0 },
|
|
170
|
+
);
|
|
171
|
+
const [form, setForm] = useState<RagConfig>(DEFAULT_CONFIG);
|
|
172
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
173
|
+
const [saving, setSaving] = useState(false);
|
|
174
|
+
const [testing, setTesting] = useState(false);
|
|
175
|
+
const [installing, setInstalling] = useState(false);
|
|
176
|
+
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
177
|
+
const [hydrated, setHydrated] = useState(false);
|
|
178
|
+
const [customModalOpen, setCustomModalOpen] = useState(false);
|
|
179
|
+
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
180
|
+
// Confirmation summary dialog before saving
|
|
181
|
+
const [saveConfirmOpen, setSaveConfirmOpen] = useState(false);
|
|
182
|
+
// Unsaved-changes navigation blocker
|
|
183
|
+
const [navBlockOpen, setNavBlockOpen] = useState(false);
|
|
184
|
+
const [pendingHref, setPendingHref] = useState<string | null>(null);
|
|
185
|
+
// Blocking alert dialogs when index already exists
|
|
186
|
+
const [storeBlockAlertOpen, setStoreBlockAlertOpen] = useState(false);
|
|
187
|
+
const [modelBlockAlertOpen, setModelBlockAlertOpen] = useState(false);
|
|
188
|
+
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
|
|
189
|
+
const [testingKey, setTestingKey] = useState(false);
|
|
190
|
+
const [showProviderApiKey, setShowProviderApiKey] = useState(false);
|
|
191
|
+
|
|
192
|
+
// Is the currently selected store actually installed (for installable stores)?
|
|
193
|
+
const isStoreInstalled = useCallback(
|
|
194
|
+
(storeId: VectorStoreId) => {
|
|
195
|
+
const desc = getVectorStore(storeId);
|
|
196
|
+
if (desc.installStatus === "installed") return true;
|
|
197
|
+
if (desc.installStatus === "coming-soon") return false;
|
|
198
|
+
// installable — check runtime status
|
|
199
|
+
return storeStatusData?.stores?.[storeId]?.installed === true;
|
|
200
|
+
},
|
|
201
|
+
[storeStatusData],
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const currentStoreInstalled = isStoreInstalled(form.vectorStore);
|
|
205
|
+
// Cache storeConfig per store id so switching A→B→A restores A's values.
|
|
206
|
+
const storeConfigCache = useRef<Record<string, Record<string, string>>>({});
|
|
207
|
+
|
|
208
|
+
// Hydrate local form from the persisted config once it loads.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (data?.config && !hydrated) {
|
|
211
|
+
setForm(data.config);
|
|
212
|
+
storeConfigCache.current[data.config.vectorStore] =
|
|
213
|
+
data.config.storeConfig;
|
|
214
|
+
setHydrated(true);
|
|
215
|
+
}
|
|
216
|
+
}, [data, hydrated]);
|
|
217
|
+
|
|
218
|
+
const store = getVectorStore(form.vectorStore);
|
|
219
|
+
// Resolve the active custom model from the array when id starts with "custom:"
|
|
220
|
+
const activeCustomModel = form.embeddingModelId.startsWith("custom:")
|
|
221
|
+
? (form.customEmbeddings ?? []).find(
|
|
222
|
+
(m) => m.modelName === form.embeddingModelId.slice("custom:".length),
|
|
223
|
+
)
|
|
224
|
+
: undefined;
|
|
225
|
+
const embeddingModel = activeCustomModel
|
|
226
|
+
? null
|
|
227
|
+
: getEmbeddingModel(form.embeddingModelId);
|
|
228
|
+
const embeddingMeta = embeddingModel
|
|
229
|
+
? PROVIDER_META[embeddingModel.provider as EmbeddingProvider]
|
|
230
|
+
: null;
|
|
231
|
+
const storeMeta = STORE_META[form.vectorStore];
|
|
232
|
+
const indexTypeMeta = INDEX_TYPES.find((t) => t.value === form.indexType);
|
|
233
|
+
|
|
234
|
+
const set = <K extends keyof RagConfig>(key: K, value: RagConfig[K]) =>
|
|
235
|
+
setForm((f) => ({ ...f, [key]: value }));
|
|
236
|
+
|
|
237
|
+
const setStoreValue = (key: string, value: string) => {
|
|
238
|
+
setForm((f) => ({ ...f, storeConfig: { ...f.storeConfig, [key]: value } }));
|
|
239
|
+
setErrors((e) => {
|
|
240
|
+
const next = { ...e };
|
|
241
|
+
delete next[key];
|
|
242
|
+
return next;
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Whether a completed index already exists
|
|
247
|
+
const indexedRun =
|
|
248
|
+
indexData?.run?.status === "completed" ? indexData.run : null;
|
|
249
|
+
const indexedDimensions: number | null = indexedRun?.dimensions ?? null;
|
|
250
|
+
const indexedVectorStore: VectorStoreId | null =
|
|
251
|
+
indexedRun?.vectorStore ?? null;
|
|
252
|
+
|
|
253
|
+
const selectStore = (id: VectorStoreId) => {
|
|
254
|
+
if (id === form.vectorStore) return;
|
|
255
|
+
// Block "coming soon" stores
|
|
256
|
+
const desc = getVectorStore(id);
|
|
257
|
+
if (desc.installStatus === "coming-soon") {
|
|
258
|
+
toast.info(`${desc.label} support is coming soon!`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Block if a completed index exists with a different vector store
|
|
262
|
+
if (indexedVectorStore && id !== indexedVectorStore) {
|
|
263
|
+
setStoreBlockAlertOpen(true);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
storeConfigCache.current[form.vectorStore] = form.storeConfig;
|
|
267
|
+
const next = getVectorStore(id);
|
|
268
|
+
const cached = storeConfigCache.current[id];
|
|
269
|
+
if (cached) {
|
|
270
|
+
setForm((f) => ({ ...f, vectorStore: id, storeConfig: cached }));
|
|
271
|
+
} else {
|
|
272
|
+
const seeded: Record<string, string> = {};
|
|
273
|
+
for (const field of next.fields) {
|
|
274
|
+
if (field.defaultValue) seeded[field.key] = field.defaultValue;
|
|
275
|
+
}
|
|
276
|
+
setForm((f) => ({ ...f, vectorStore: id, storeConfig: seeded }));
|
|
277
|
+
}
|
|
278
|
+
setErrors({});
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const handleInstallStore = async (storeId: VectorStoreId) => {
|
|
282
|
+
setInstalling(true);
|
|
283
|
+
try {
|
|
284
|
+
const res = await fetch("/api/vector-stores/install", {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: { "Content-Type": "application/json" },
|
|
287
|
+
body: JSON.stringify({ storeId }),
|
|
288
|
+
});
|
|
289
|
+
const json = await res.json();
|
|
290
|
+
if (!res.ok) {
|
|
291
|
+
throw new Error(json.error ?? "Installation failed");
|
|
292
|
+
}
|
|
293
|
+
toast.success(
|
|
294
|
+
`${getVectorStore(storeId).label} installed successfully!`,
|
|
295
|
+
{
|
|
296
|
+
description: `The ${json.package} package has been added to your project.`,
|
|
297
|
+
},
|
|
298
|
+
);
|
|
299
|
+
// Refresh install status optimistically so the UI instantly hides the install card
|
|
300
|
+
await mutateStoreStatus(
|
|
301
|
+
(current: any) => ({
|
|
302
|
+
...current,
|
|
303
|
+
stores: {
|
|
304
|
+
...(current?.stores || {}),
|
|
305
|
+
[storeId]: { installed: true },
|
|
306
|
+
},
|
|
307
|
+
}),
|
|
308
|
+
{ revalidate: true },
|
|
309
|
+
);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
toast.error(err instanceof Error ? err.message : "Installation failed");
|
|
312
|
+
} finally {
|
|
313
|
+
setInstalling(false);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// Returns true if the given model ID would have different dimensions than the indexed run
|
|
318
|
+
function wouldBreakDimensions(newModelId: string): boolean {
|
|
319
|
+
if (!indexedDimensions) return false;
|
|
320
|
+
if (newModelId.startsWith("custom:")) {
|
|
321
|
+
const name = newModelId.slice("custom:".length);
|
|
322
|
+
const custom = (form.customEmbeddings ?? []).find(
|
|
323
|
+
(m) => m.modelName === name,
|
|
324
|
+
);
|
|
325
|
+
if (!custom) return false;
|
|
326
|
+
return custom.dimensions !== indexedDimensions;
|
|
327
|
+
}
|
|
328
|
+
const model = getEmbeddingModel(newModelId);
|
|
329
|
+
if (!model) return false;
|
|
330
|
+
return model.dimensions !== indexedDimensions;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const handleEmbeddingModelChange = (v: string) => {
|
|
334
|
+
if (wouldBreakDimensions(v)) {
|
|
335
|
+
setModelBlockAlertOpen(true);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
set("embeddingModelId", v);
|
|
339
|
+
if (!form.embeddingApiKey) {
|
|
340
|
+
toast.error("API Key Required", {
|
|
341
|
+
description:
|
|
342
|
+
"Please set an API key for this provider. Recommended: Vercel AI Gateway Key.",
|
|
343
|
+
});
|
|
344
|
+
setApiKeyModalOpen(true);
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const dirty = useMemo(
|
|
349
|
+
() => hydrated && JSON.stringify(form) !== JSON.stringify(data?.config),
|
|
350
|
+
[form, data, hydrated],
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// Unsaved changes: block browser refresh / close
|
|
354
|
+
useEffect(() => {
|
|
355
|
+
const handler = (e: BeforeUnloadEvent) => {
|
|
356
|
+
if (dirty) {
|
|
357
|
+
e.preventDefault();
|
|
358
|
+
e.returnValue = "";
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
window.addEventListener("beforeunload", handler);
|
|
362
|
+
return () => window.removeEventListener("beforeunload", handler);
|
|
363
|
+
}, [dirty]);
|
|
364
|
+
|
|
365
|
+
// Intercept Next.js App Router client-side link clicks when there are unsaved changes
|
|
366
|
+
const dirtyRef = useRef(dirty);
|
|
367
|
+
dirtyRef.current = dirty;
|
|
368
|
+
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
const handleClick = (e: MouseEvent) => {
|
|
371
|
+
if (!dirtyRef.current) return;
|
|
372
|
+
const anchor = (e.target as Element)?.closest("a[href]");
|
|
373
|
+
if (!anchor) return;
|
|
374
|
+
const href = anchor.getAttribute("href") ?? "";
|
|
375
|
+
// Only intercept same-origin relative navigation (not # links or external)
|
|
376
|
+
if (!href || href.startsWith("#") || href.startsWith("http")) return;
|
|
377
|
+
// Skip if it's already the configure page
|
|
378
|
+
if (href === "/configure" || href === "/") return;
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
e.stopPropagation();
|
|
381
|
+
setPendingHref(href);
|
|
382
|
+
setNavBlockOpen(true);
|
|
383
|
+
};
|
|
384
|
+
document.addEventListener("click", handleClick, { capture: true });
|
|
385
|
+
return () =>
|
|
386
|
+
document.removeEventListener("click", handleClick, { capture: true });
|
|
387
|
+
}, []);
|
|
388
|
+
|
|
389
|
+
// Expose handle to parent
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
if (onHandleReady) {
|
|
392
|
+
onHandleReady({
|
|
393
|
+
dirty,
|
|
394
|
+
saving,
|
|
395
|
+
requestSave: () => setSaveConfirmOpen(true),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}, [dirty, saving, onHandleReady]);
|
|
399
|
+
|
|
400
|
+
async function performSave() {
|
|
401
|
+
const fieldErrors = validateStoreConfig(
|
|
402
|
+
store,
|
|
403
|
+
form.storeConfig,
|
|
404
|
+
form.indexType,
|
|
405
|
+
);
|
|
406
|
+
if (Object.keys(fieldErrors).length > 0) {
|
|
407
|
+
setErrors(fieldErrors);
|
|
408
|
+
toast.error("Please complete the required vector store fields.");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
setSaving(true);
|
|
412
|
+
try {
|
|
413
|
+
const testRes = await fetch("/api/config/test", {
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers: { "Content-Type": "application/json" },
|
|
416
|
+
body: JSON.stringify(form),
|
|
417
|
+
});
|
|
418
|
+
const testJson = await testRes.json();
|
|
419
|
+
if (!testRes.ok) {
|
|
420
|
+
if (testJson.fieldErrors) setErrors(testJson.fieldErrors);
|
|
421
|
+
throw new Error(
|
|
422
|
+
`Connection test failed: ${testJson.error ?? "Invalid credentials"}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const res = await fetch("/api/config", {
|
|
426
|
+
method: "PUT",
|
|
427
|
+
headers: { "Content-Type": "application/json" },
|
|
428
|
+
body: JSON.stringify(form),
|
|
429
|
+
});
|
|
430
|
+
const json = await res.json();
|
|
431
|
+
if (!res.ok) {
|
|
432
|
+
if (json.fieldErrors) setErrors(json.fieldErrors);
|
|
433
|
+
throw new Error(json.error ?? "Failed to save configuration");
|
|
434
|
+
}
|
|
435
|
+
await mutate(json, { revalidate: false });
|
|
436
|
+
setForm(json.config);
|
|
437
|
+
toast.success("Configuration saved", {
|
|
438
|
+
description: "Written to .larkup/config.json",
|
|
439
|
+
});
|
|
440
|
+
} catch (err) {
|
|
441
|
+
toast.error(err instanceof Error ? err.message : "Failed to save");
|
|
442
|
+
} finally {
|
|
443
|
+
setSaving(false);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function handleTestConnection() {
|
|
448
|
+
const fieldErrors = validateStoreConfig(
|
|
449
|
+
store,
|
|
450
|
+
form.storeConfig,
|
|
451
|
+
form.indexType,
|
|
452
|
+
);
|
|
453
|
+
if (Object.keys(fieldErrors).length > 0) {
|
|
454
|
+
setErrors(fieldErrors);
|
|
455
|
+
toast.error("Please complete the required vector store fields.");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
setTesting(true);
|
|
459
|
+
try {
|
|
460
|
+
const res = await fetch("/api/config/test", {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { "Content-Type": "application/json" },
|
|
463
|
+
body: JSON.stringify(form),
|
|
464
|
+
});
|
|
465
|
+
const json = await res.json();
|
|
466
|
+
if (!res.ok) {
|
|
467
|
+
if (json.fieldErrors) setErrors(json.fieldErrors);
|
|
468
|
+
throw new Error(json.error ?? "Connection failed");
|
|
469
|
+
}
|
|
470
|
+
toast.success("Connection successful", {
|
|
471
|
+
description: "Credentials and settings are valid.",
|
|
472
|
+
});
|
|
473
|
+
} catch (err) {
|
|
474
|
+
toast.error(err instanceof Error ? err.message : "Connection failed");
|
|
475
|
+
} finally {
|
|
476
|
+
setTesting(false);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (isLoading && !hydrated) {
|
|
481
|
+
return (
|
|
482
|
+
<div className="flex items-center gap-2 px-6 py-16 text-muted-foreground md:px-8">
|
|
483
|
+
<Loader2 className="size-4 animate-spin" />
|
|
484
|
+
<span className="text-sm">Loading configuration…</span>
|
|
485
|
+
</div>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Build summary rows for confirm dialog
|
|
490
|
+
const summaryItems = [
|
|
491
|
+
{ label: "Project", value: form.projectName },
|
|
492
|
+
{
|
|
493
|
+
label: "Embedding model",
|
|
494
|
+
value:
|
|
495
|
+
activeCustomModel?.modelName ??
|
|
496
|
+
embeddingModel?.label ??
|
|
497
|
+
form.embeddingModelId,
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
label: "Provider",
|
|
501
|
+
value: activeCustomModel
|
|
502
|
+
? "Custom (OpenAI-compatible)"
|
|
503
|
+
: (embeddingMeta?.label ?? embeddingModel?.provider ?? "—"),
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
label: "Dimensions",
|
|
507
|
+
value: String(
|
|
508
|
+
activeCustomModel?.dimensions ?? embeddingModel?.dimensions ?? "—",
|
|
509
|
+
),
|
|
510
|
+
mono: true,
|
|
511
|
+
},
|
|
512
|
+
{ label: "Index type", value: indexTypeMeta?.label ?? form.indexType },
|
|
513
|
+
{ label: "Vector store", value: store.label },
|
|
514
|
+
{
|
|
515
|
+
label: "Chunk size / overlap",
|
|
516
|
+
value: `${form.chunking.chunkSize} / ${form.chunking.chunkOverlap} tokens`,
|
|
517
|
+
mono: true,
|
|
518
|
+
},
|
|
519
|
+
{ label: "Top-K", value: String(form.topK), mono: true },
|
|
520
|
+
];
|
|
521
|
+
|
|
522
|
+
return (
|
|
523
|
+
<div className="px-6 py-6 md:px-8">
|
|
524
|
+
{/* ── Cards: first two side-by-side, last card full-width below ── */}
|
|
525
|
+
<div className="mx-auto space-y-5">
|
|
526
|
+
{/* Row 1: Project + Embedding side-by-side */}
|
|
527
|
+
<div className="grid gap-5 lg:grid-cols-2">
|
|
528
|
+
{/* Project */}
|
|
529
|
+
<Card>
|
|
530
|
+
<CardHeader className="pb-3">
|
|
531
|
+
<CardTitle className="text-base">Project</CardTitle>
|
|
532
|
+
<CardDescription className="text-xs">
|
|
533
|
+
Identifies this pipeline and names the generated server.
|
|
534
|
+
</CardDescription>
|
|
535
|
+
</CardHeader>
|
|
536
|
+
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
537
|
+
<div className="space-y-1.5">
|
|
538
|
+
<Label htmlFor="projectName">Project name</Label>
|
|
539
|
+
<Input
|
|
540
|
+
id="projectName"
|
|
541
|
+
value={form.projectName}
|
|
542
|
+
spellCheck={false}
|
|
543
|
+
onChange={(e) => set("projectName", e.target.value)}
|
|
544
|
+
/>
|
|
545
|
+
</div>
|
|
546
|
+
<div className="space-y-1.5">
|
|
547
|
+
<Label htmlFor="topK">Default top-K</Label>
|
|
548
|
+
<Input
|
|
549
|
+
id="topK"
|
|
550
|
+
type="number"
|
|
551
|
+
min={1}
|
|
552
|
+
max={50}
|
|
553
|
+
value={form.topK}
|
|
554
|
+
onChange={(e) =>
|
|
555
|
+
set("topK", Math.max(1, Number(e.target.value) || 1))
|
|
556
|
+
}
|
|
557
|
+
/>
|
|
558
|
+
<p className="text-xs text-muted-foreground">
|
|
559
|
+
Documents returned per query.
|
|
560
|
+
</p>
|
|
561
|
+
</div>
|
|
562
|
+
</CardContent>
|
|
563
|
+
</Card>
|
|
564
|
+
|
|
565
|
+
{/* Embedding model */}
|
|
566
|
+
<Card>
|
|
567
|
+
<CardHeader className="pb-3">
|
|
568
|
+
<CardTitle className="flex items-center justify-between text-base">
|
|
569
|
+
<div className="flex items-center gap-2">
|
|
570
|
+
<Sparkles className="size-4 text-primary" />
|
|
571
|
+
Embedding model
|
|
572
|
+
</div>
|
|
573
|
+
<Button
|
|
574
|
+
variant="ghost"
|
|
575
|
+
size="icon"
|
|
576
|
+
onClick={() => setApiKeyModalOpen(true)}
|
|
577
|
+
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
|
578
|
+
>
|
|
579
|
+
<Settings className="size-4" />
|
|
580
|
+
</Button>
|
|
581
|
+
</CardTitle>
|
|
582
|
+
<CardDescription className="text-xs">
|
|
583
|
+
Used to embed chunks at index time and queries at runtime.
|
|
584
|
+
</CardDescription>
|
|
585
|
+
</CardHeader>
|
|
586
|
+
<CardContent className="space-y-3">
|
|
587
|
+
{/* Model selector */}
|
|
588
|
+
<div className="space-y-1.5">
|
|
589
|
+
<Label>Model</Label>
|
|
590
|
+
<div className="flex gap-2 items-start">
|
|
591
|
+
<Select
|
|
592
|
+
value={form.embeddingModelId}
|
|
593
|
+
onValueChange={(v) =>
|
|
594
|
+
handleEmbeddingModelChange((v as string) ?? "")
|
|
595
|
+
}
|
|
596
|
+
>
|
|
597
|
+
<SelectTrigger
|
|
598
|
+
className="w-full flex-1"
|
|
599
|
+
onPointerDown={(e) => {
|
|
600
|
+
if (!form.embeddingApiKey) {
|
|
601
|
+
e.preventDefault();
|
|
602
|
+
toast.info("API Key Required", {
|
|
603
|
+
description:
|
|
604
|
+
"Please set an API key first. Recommended: Vercel AI Gateway Key.",
|
|
605
|
+
});
|
|
606
|
+
setApiKeyModalOpen(true);
|
|
607
|
+
}
|
|
608
|
+
}}
|
|
609
|
+
>
|
|
610
|
+
{activeCustomModel ? (
|
|
611
|
+
<span className="flex items-center gap-2.5">
|
|
612
|
+
<ProviderIcon
|
|
613
|
+
src={PROVIDER_META.custom.iconSrc}
|
|
614
|
+
alt="Custom"
|
|
615
|
+
pillBg={PROVIDER_META.custom.pillBg}
|
|
616
|
+
size={20}
|
|
617
|
+
/>
|
|
618
|
+
<span className="flex flex-col items-start leading-none">
|
|
619
|
+
<span className="font-medium text-sm">
|
|
620
|
+
{activeCustomModel.modelName}
|
|
621
|
+
</span>
|
|
622
|
+
</span>
|
|
623
|
+
</span>
|
|
624
|
+
) : embeddingModel && embeddingMeta ? (
|
|
625
|
+
<span className="flex items-center gap-2.5">
|
|
626
|
+
<ProviderIcon
|
|
627
|
+
src={embeddingMeta.iconSrc}
|
|
628
|
+
alt={embeddingMeta.label}
|
|
629
|
+
pillBg={embeddingMeta.pillBg}
|
|
630
|
+
size={20}
|
|
631
|
+
/>
|
|
632
|
+
<span className="flex flex-col items-start leading-none">
|
|
633
|
+
<span className="font-medium text-sm ">
|
|
634
|
+
{embeddingModel.label}
|
|
635
|
+
</span>
|
|
636
|
+
</span>
|
|
637
|
+
</span>
|
|
638
|
+
) : (
|
|
639
|
+
<span className="text-muted-foreground">
|
|
640
|
+
Select a model…
|
|
641
|
+
</span>
|
|
642
|
+
)}
|
|
643
|
+
</SelectTrigger>
|
|
644
|
+
<SelectContent className="max-h-[320px]">
|
|
645
|
+
{Object.entries(EMBEDDING_BY_PROVIDER)
|
|
646
|
+
.filter(
|
|
647
|
+
([provider]) =>
|
|
648
|
+
form.embeddingProvider === "vercel_ai_gateway" ||
|
|
649
|
+
provider === form.embeddingProvider ||
|
|
650
|
+
provider === "deepseek",
|
|
651
|
+
)
|
|
652
|
+
.map(([provider, models]) => {
|
|
653
|
+
const meta =
|
|
654
|
+
PROVIDER_META[
|
|
655
|
+
provider as keyof typeof PROVIDER_META
|
|
656
|
+
];
|
|
657
|
+
return (
|
|
658
|
+
<SelectGroup key={provider}>
|
|
659
|
+
<SelectLabel className="flex items-center gap-2 py-1.5">
|
|
660
|
+
{meta && (
|
|
661
|
+
<ProviderIcon
|
|
662
|
+
src={meta.iconSrc}
|
|
663
|
+
alt={meta.label}
|
|
664
|
+
pillBg={meta.pillBg}
|
|
665
|
+
size={18}
|
|
666
|
+
/>
|
|
667
|
+
)}
|
|
668
|
+
<span className="font-medium">
|
|
669
|
+
{meta?.label ?? provider}
|
|
670
|
+
</span>
|
|
671
|
+
</SelectLabel>
|
|
672
|
+
{models.map((m) => (
|
|
673
|
+
<SelectItem
|
|
674
|
+
key={m.id}
|
|
675
|
+
value={m.id}
|
|
676
|
+
className="pl-8"
|
|
677
|
+
>
|
|
678
|
+
<span className="flex items-center gap-2">
|
|
679
|
+
<span>{m.label}</span>
|
|
680
|
+
<span className="text-[10px] text-muted-foreground font-mono">
|
|
681
|
+
{m.dimensions}d
|
|
682
|
+
</span>
|
|
683
|
+
</span>
|
|
684
|
+
</SelectItem>
|
|
685
|
+
))}
|
|
686
|
+
</SelectGroup>
|
|
687
|
+
);
|
|
688
|
+
})}
|
|
689
|
+
{/* Custom models group — one entry per saved custom model */}
|
|
690
|
+
{(form.customEmbeddings ?? []).length > 0 && (
|
|
691
|
+
<SelectGroup>
|
|
692
|
+
<SelectLabel className="flex items-center gap-2 py-1.5">
|
|
693
|
+
<ProviderIcon
|
|
694
|
+
src={PROVIDER_META.custom.iconSrc}
|
|
695
|
+
alt="Custom"
|
|
696
|
+
pillBg={PROVIDER_META.custom.pillBg}
|
|
697
|
+
size={18}
|
|
698
|
+
/>
|
|
699
|
+
<span className="font-medium">Custom</span>
|
|
700
|
+
</SelectLabel>
|
|
701
|
+
{(form.customEmbeddings ?? []).map((m) => (
|
|
702
|
+
<SelectItem
|
|
703
|
+
key={`custom:${m.modelName}`}
|
|
704
|
+
value={`custom:${m.modelName}`}
|
|
705
|
+
className="pl-8"
|
|
706
|
+
>
|
|
707
|
+
<span className="flex items-center gap-2">
|
|
708
|
+
<span>{m.modelName}</span>
|
|
709
|
+
<span className="text-[10px] text-muted-foreground font-mono">
|
|
710
|
+
{m.dimensions}d
|
|
711
|
+
</span>
|
|
712
|
+
</span>
|
|
713
|
+
</SelectItem>
|
|
714
|
+
))}
|
|
715
|
+
</SelectGroup>
|
|
716
|
+
)}
|
|
717
|
+
</SelectContent>
|
|
718
|
+
</Select>
|
|
719
|
+
<TooltipProvider delay={0}>
|
|
720
|
+
<Tooltip>
|
|
721
|
+
<TooltipTrigger
|
|
722
|
+
render={
|
|
723
|
+
<Button
|
|
724
|
+
variant="outline"
|
|
725
|
+
size="icon"
|
|
726
|
+
onClick={() => setCustomModalOpen(true)}
|
|
727
|
+
type="button"
|
|
728
|
+
>
|
|
729
|
+
<Plus className="size-4" />
|
|
730
|
+
</Button>
|
|
731
|
+
}
|
|
732
|
+
/>
|
|
733
|
+
<TooltipContent>
|
|
734
|
+
<p>Add custom embedding model</p>
|
|
735
|
+
</TooltipContent>
|
|
736
|
+
</Tooltip>
|
|
737
|
+
</TooltipProvider>
|
|
738
|
+
{/* Trash button — only visible when a custom model is selected */}
|
|
739
|
+
{activeCustomModel && (
|
|
740
|
+
<TooltipProvider delay={0}>
|
|
741
|
+
<Tooltip>
|
|
742
|
+
<TooltipTrigger
|
|
743
|
+
render={
|
|
744
|
+
<Button
|
|
745
|
+
variant="outline"
|
|
746
|
+
size="icon"
|
|
747
|
+
type="button"
|
|
748
|
+
onClick={() => setDeleteConfirmOpen(true)}
|
|
749
|
+
className="text-destructive hover:text-destructive hover:bg-destructive/10 hover:border-destructive/50"
|
|
750
|
+
>
|
|
751
|
+
<Trash2 className="size-4" />
|
|
752
|
+
</Button>
|
|
753
|
+
}
|
|
754
|
+
/>
|
|
755
|
+
<TooltipContent>
|
|
756
|
+
<p>Delete "{activeCustomModel.modelName}"</p>
|
|
757
|
+
</TooltipContent>
|
|
758
|
+
</Tooltip>
|
|
759
|
+
</TooltipProvider>
|
|
760
|
+
)}
|
|
761
|
+
</div>
|
|
762
|
+
|
|
763
|
+
{/* Model info badges */}
|
|
764
|
+
{activeCustomModel ? (
|
|
765
|
+
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
766
|
+
<Badge variant="secondary" className="font-mono">
|
|
767
|
+
{activeCustomModel.dimensions} dims
|
|
768
|
+
</Badge>
|
|
769
|
+
<Badge variant="outline" className="font-mono">
|
|
770
|
+
OpenAI Compatible
|
|
771
|
+
</Badge>
|
|
772
|
+
<span className="text-muted-foreground">
|
|
773
|
+
Custom configuration
|
|
774
|
+
</span>
|
|
775
|
+
</div>
|
|
776
|
+
) : embeddingModel ? (
|
|
777
|
+
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
778
|
+
<Badge variant="secondary" className="font-mono">
|
|
779
|
+
{embeddingModel.dimensions} dims
|
|
780
|
+
</Badge>
|
|
781
|
+
<Badge variant="outline" className="font-mono">
|
|
782
|
+
{embeddingModel.maxInputTokens.toLocaleString()} max
|
|
783
|
+
tokens
|
|
784
|
+
</Badge>
|
|
785
|
+
<span className="text-muted-foreground">
|
|
786
|
+
{embeddingModel.description}
|
|
787
|
+
</span>
|
|
788
|
+
</div>
|
|
789
|
+
) : null}
|
|
790
|
+
</div>
|
|
791
|
+
|
|
792
|
+
{/* Advanced chunking */}
|
|
793
|
+
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
|
794
|
+
<CollapsibleTrigger className="-ml-2 inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
|
|
795
|
+
<ChevronDown
|
|
796
|
+
className={cn(
|
|
797
|
+
"size-4 transition-transform duration-200",
|
|
798
|
+
advancedOpen && "rotate-180",
|
|
799
|
+
)}
|
|
800
|
+
/>
|
|
801
|
+
Advanced chunking
|
|
802
|
+
</CollapsibleTrigger>
|
|
803
|
+
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1 pt-3">
|
|
804
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
805
|
+
<div className="space-y-1.5">
|
|
806
|
+
<Label htmlFor="chunkSize">Chunk size</Label>
|
|
807
|
+
<Input
|
|
808
|
+
id="chunkSize"
|
|
809
|
+
type="number"
|
|
810
|
+
min={64}
|
|
811
|
+
value={form.chunking.chunkSize}
|
|
812
|
+
onChange={(e) =>
|
|
813
|
+
set("chunking", {
|
|
814
|
+
...form.chunking,
|
|
815
|
+
chunkSize: Number(e.target.value) || 0,
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
/>
|
|
819
|
+
<p className="text-xs text-muted-foreground">tokens</p>
|
|
820
|
+
</div>
|
|
821
|
+
<div className="space-y-1.5">
|
|
822
|
+
<Label htmlFor="chunkOverlap">Overlap</Label>
|
|
823
|
+
<Input
|
|
824
|
+
id="chunkOverlap"
|
|
825
|
+
type="number"
|
|
826
|
+
min={0}
|
|
827
|
+
value={form.chunking.chunkOverlap}
|
|
828
|
+
onChange={(e) =>
|
|
829
|
+
set("chunking", {
|
|
830
|
+
...form.chunking,
|
|
831
|
+
chunkOverlap: Number(e.target.value) || 0,
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
/>
|
|
835
|
+
<p className="text-xs text-muted-foreground">tokens</p>
|
|
836
|
+
</div>
|
|
837
|
+
<div className="space-y-1.5">
|
|
838
|
+
<Label htmlFor="strategy">Strategy</Label>
|
|
839
|
+
<Select
|
|
840
|
+
value={form.chunking.strategy}
|
|
841
|
+
onValueChange={(v) =>
|
|
842
|
+
set("chunking", {
|
|
843
|
+
...form.chunking,
|
|
844
|
+
strategy: v as RagConfig["chunking"]["strategy"],
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
>
|
|
848
|
+
<SelectTrigger id="strategy" className="w-full">
|
|
849
|
+
<span>{form.chunking.strategy}</span>
|
|
850
|
+
</SelectTrigger>
|
|
851
|
+
<SelectContent>
|
|
852
|
+
<SelectItem value="recursive">Recursive</SelectItem>
|
|
853
|
+
<SelectItem value="sentence">Sentence</SelectItem>
|
|
854
|
+
<SelectItem value="fixed">Fixed</SelectItem>
|
|
855
|
+
</SelectContent>
|
|
856
|
+
</Select>
|
|
857
|
+
</div>
|
|
858
|
+
</div>
|
|
859
|
+
</CollapsibleContent>
|
|
860
|
+
</Collapsible>
|
|
861
|
+
</CardContent>
|
|
862
|
+
</Card>
|
|
863
|
+
</div>
|
|
864
|
+
|
|
865
|
+
{/* Row 2: Indexing & Vector Store — full width */}
|
|
866
|
+
<Card>
|
|
867
|
+
<CardHeader className="pb-3">
|
|
868
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
869
|
+
<Database className="size-4 text-primary" />
|
|
870
|
+
Indexing & Vector store
|
|
871
|
+
</CardTitle>
|
|
872
|
+
<CardDescription className="text-xs">
|
|
873
|
+
Choose how documents are matched at retrieval time and where
|
|
874
|
+
vectors are stored.
|
|
875
|
+
</CardDescription>
|
|
876
|
+
</CardHeader>
|
|
877
|
+
<CardContent className="space-y-5">
|
|
878
|
+
{/* Side-by-side row: index type select + vector store select */}
|
|
879
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
880
|
+
{/* Index type */}
|
|
881
|
+
<div className="space-y-2">
|
|
882
|
+
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
883
|
+
Index type
|
|
884
|
+
</Label>
|
|
885
|
+
<Select
|
|
886
|
+
value={form.indexType}
|
|
887
|
+
onValueChange={(v) => set("indexType", v as IndexType)}
|
|
888
|
+
>
|
|
889
|
+
<SelectTrigger className="w-full">
|
|
890
|
+
{indexTypeMeta ? (
|
|
891
|
+
<span className="flex flex-col items-start leading-none">
|
|
892
|
+
<span className="font-medium text-sm">
|
|
893
|
+
{indexTypeMeta.label}
|
|
894
|
+
</span>
|
|
895
|
+
</span>
|
|
896
|
+
) : (
|
|
897
|
+
<span className="text-muted-foreground">
|
|
898
|
+
Select index type…
|
|
899
|
+
</span>
|
|
900
|
+
)}
|
|
901
|
+
</SelectTrigger>
|
|
902
|
+
<SelectContent>
|
|
903
|
+
{INDEX_TYPES.map((t) => (
|
|
904
|
+
<SelectItem key={t.value} value={t.value}>
|
|
905
|
+
<span className="flex flex-col">
|
|
906
|
+
<span className="font-medium">{t.label}</span>
|
|
907
|
+
<span className="text-[11px] text-muted-foreground">
|
|
908
|
+
{t.hint}
|
|
909
|
+
</span>
|
|
910
|
+
</span>
|
|
911
|
+
</SelectItem>
|
|
912
|
+
))}
|
|
913
|
+
</SelectContent>
|
|
914
|
+
</Select>
|
|
915
|
+
</div>
|
|
916
|
+
|
|
917
|
+
{/* Vector store */}
|
|
918
|
+
<div className="space-y-2">
|
|
919
|
+
<Label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
920
|
+
Vector store
|
|
921
|
+
</Label>
|
|
922
|
+
<Select
|
|
923
|
+
value={form.vectorStore}
|
|
924
|
+
onValueChange={(v) => selectStore(v as VectorStoreId)}
|
|
925
|
+
>
|
|
926
|
+
<SelectTrigger className="w-full">
|
|
927
|
+
{storeMeta ? (
|
|
928
|
+
<span className="flex items-center gap-2.5">
|
|
929
|
+
<ProviderIcon
|
|
930
|
+
src={storeMeta.iconSrc}
|
|
931
|
+
alt={store.label}
|
|
932
|
+
pillBg={storeMeta.pillBg}
|
|
933
|
+
size={20}
|
|
934
|
+
/>
|
|
935
|
+
<span className="flex flex-col items-start leading-none">
|
|
936
|
+
<span className="font-medium text-sm">
|
|
937
|
+
{store.label}
|
|
938
|
+
</span>
|
|
939
|
+
</span>
|
|
940
|
+
</span>
|
|
941
|
+
) : (
|
|
942
|
+
<span className="text-muted-foreground">
|
|
943
|
+
Select a vector store…
|
|
944
|
+
</span>
|
|
945
|
+
)}
|
|
946
|
+
</SelectTrigger>
|
|
947
|
+
<SelectContent>
|
|
948
|
+
{VECTOR_STORE_LIST.map((s) => {
|
|
949
|
+
const meta = STORE_META[s.id];
|
|
950
|
+
const isComingSoon = s.installStatus === "coming-soon";
|
|
951
|
+
const isInstallable = s.installStatus === "installable";
|
|
952
|
+
const installed = isStoreInstalled(s.id);
|
|
953
|
+
return (
|
|
954
|
+
<SelectItem
|
|
955
|
+
key={s.id}
|
|
956
|
+
value={s.id}
|
|
957
|
+
disabled={isComingSoon}
|
|
958
|
+
className={isComingSoon ? "opacity-50" : ""}
|
|
959
|
+
>
|
|
960
|
+
<span className="flex items-center gap-2.5 w-full">
|
|
961
|
+
{meta && (
|
|
962
|
+
<ProviderIcon
|
|
963
|
+
src={meta.iconSrc}
|
|
964
|
+
alt={s.label}
|
|
965
|
+
pillBg={meta.pillBg}
|
|
966
|
+
size={22}
|
|
967
|
+
/>
|
|
968
|
+
)}
|
|
969
|
+
<span className="flex flex-col flex-1 min-w-0">
|
|
970
|
+
<span className="flex items-center gap-2">
|
|
971
|
+
<span className="font-medium text-sm leading-tight">
|
|
972
|
+
{s.label}
|
|
973
|
+
</span>
|
|
974
|
+
{isComingSoon && (
|
|
975
|
+
<Badge
|
|
976
|
+
variant="outline"
|
|
977
|
+
className="h-4 px-1.5 text-[9px] font-medium shrink-0 text-muted-foreground border-muted-foreground/30"
|
|
978
|
+
>
|
|
979
|
+
<Clock className="size-2.5 mr-0.5" />
|
|
980
|
+
Coming Soon
|
|
981
|
+
</Badge>
|
|
982
|
+
)}
|
|
983
|
+
{isInstallable && !installed && (
|
|
984
|
+
<Badge
|
|
985
|
+
variant="outline"
|
|
986
|
+
className="h-4 px-1.5 text-[9px] font-medium shrink-0 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
|
987
|
+
>
|
|
988
|
+
<Download className="size-2.5 mr-0.5" />
|
|
989
|
+
Not Installed
|
|
990
|
+
</Badge>
|
|
991
|
+
)}
|
|
992
|
+
</span>
|
|
993
|
+
<span className="text-[10px] text-muted-foreground leading-tight">
|
|
994
|
+
{s.description.split(".")[0]}
|
|
995
|
+
</span>
|
|
996
|
+
</span>
|
|
997
|
+
</span>
|
|
998
|
+
</SelectItem>
|
|
999
|
+
);
|
|
1000
|
+
})}
|
|
1001
|
+
</SelectContent>
|
|
1002
|
+
</Select>
|
|
1003
|
+
|
|
1004
|
+
{/* Store badges */}
|
|
1005
|
+
<div className="flex items-center gap-2">
|
|
1006
|
+
<Badge
|
|
1007
|
+
variant="outline"
|
|
1008
|
+
className="text-[10px] font-mono uppercase"
|
|
1009
|
+
>
|
|
1010
|
+
{store.runtime}
|
|
1011
|
+
</Badge>
|
|
1012
|
+
{store.docsUrl && (
|
|
1013
|
+
<a
|
|
1014
|
+
href={store.docsUrl}
|
|
1015
|
+
target="_blank"
|
|
1016
|
+
rel="noopener noreferrer"
|
|
1017
|
+
className="text-[11px] text-muted-foreground underline-offset-2 hover:underline hover:text-foreground transition-colors"
|
|
1018
|
+
>
|
|
1019
|
+
docs
|
|
1020
|
+
</a>
|
|
1021
|
+
)}
|
|
1022
|
+
</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
</div>
|
|
1025
|
+
|
|
1026
|
+
{/* Store description */}
|
|
1027
|
+
<p className="text-xs leading-relaxed text-muted-foreground border-t pt-4">
|
|
1028
|
+
{store.description}
|
|
1029
|
+
</p>
|
|
1030
|
+
|
|
1031
|
+
{/* Store config fields — or install prompt if not yet installed */}
|
|
1032
|
+
{store.installStatus === "installable" && !currentStoreInstalled ? (
|
|
1033
|
+
/* ── Install panel for installable stores ─────────────────── */
|
|
1034
|
+
<div className="rounded-lg border border-dashed border-amber-500/40 bg-amber-50/50 dark:bg-amber-950/20 p-6 space-y-4 transition-all duration-300">
|
|
1035
|
+
<div className="flex items-start gap-3">
|
|
1036
|
+
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/40 p-2 shrink-0">
|
|
1037
|
+
<Download className="size-5 text-amber-600 dark:text-amber-400" />
|
|
1038
|
+
</div>
|
|
1039
|
+
<div className="space-y-1">
|
|
1040
|
+
<p className="text-sm font-medium text-foreground">
|
|
1041
|
+
{store.label} is not installed
|
|
1042
|
+
</p>
|
|
1043
|
+
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
1044
|
+
{store.label} is an optional dependency and not included
|
|
1045
|
+
by default. Click <strong>Install</strong> to add the{" "}
|
|
1046
|
+
<code className="text-[11px] font-mono bg-muted px-1 py-0.5 rounded">
|
|
1047
|
+
{Object.keys(store.serverDependencies)[0]}
|
|
1048
|
+
</code>{" "}
|
|
1049
|
+
package to your project.
|
|
1050
|
+
</p>
|
|
1051
|
+
<p className="text-[11px] text-amber-700 dark:text-amber-400/80 leading-relaxed mt-2">
|
|
1052
|
+
⚠ Install only works in local development. For production
|
|
1053
|
+
deployments, add the package to your dependencies before
|
|
1054
|
+
deploying.
|
|
1055
|
+
</p>
|
|
1056
|
+
</div>
|
|
1057
|
+
</div>
|
|
1058
|
+
<div className="flex justify-end">
|
|
1059
|
+
<Button
|
|
1060
|
+
size="sm"
|
|
1061
|
+
onClick={() => handleInstallStore(form.vectorStore)}
|
|
1062
|
+
disabled={installing}
|
|
1063
|
+
className="gap-2"
|
|
1064
|
+
>
|
|
1065
|
+
{installing ? (
|
|
1066
|
+
<Loader2 className="size-4 animate-spin" />
|
|
1067
|
+
) : (
|
|
1068
|
+
<Download className="size-4" />
|
|
1069
|
+
)}
|
|
1070
|
+
{installing ? "Installing…" : `Install ${store.label}`}
|
|
1071
|
+
</Button>
|
|
1072
|
+
</div>
|
|
1073
|
+
</div>
|
|
1074
|
+
) : store.installStatus === "coming-soon" ? (
|
|
1075
|
+
/* ── Coming Soon panel ──────────────────────────────────── */
|
|
1076
|
+
<div className="rounded-lg border border-dashed border-muted-foreground/20 bg-muted/20 p-6 space-y-2 transition-all duration-300">
|
|
1077
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
1078
|
+
<Clock className="size-4" />
|
|
1079
|
+
<p className="text-sm font-medium">Coming Soon</p>
|
|
1080
|
+
</div>
|
|
1081
|
+
<p className="text-xs text-muted-foreground">
|
|
1082
|
+
{store.label} support is under development. Stay tuned!
|
|
1083
|
+
</p>
|
|
1084
|
+
</div>
|
|
1085
|
+
) : (
|
|
1086
|
+
/* ── Normal config fields ──────────────────────────────── */
|
|
1087
|
+
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-4 transition-all duration-300">
|
|
1088
|
+
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
|
1089
|
+
{store.label} configuration
|
|
1090
|
+
</p>
|
|
1091
|
+
<StoreFields
|
|
1092
|
+
store={store}
|
|
1093
|
+
values={form.storeConfig}
|
|
1094
|
+
errors={errors}
|
|
1095
|
+
onChange={setStoreValue}
|
|
1096
|
+
indexType={form.indexType}
|
|
1097
|
+
/>
|
|
1098
|
+
<div className="flex justify-end pt-1">
|
|
1099
|
+
<Button
|
|
1100
|
+
variant="default"
|
|
1101
|
+
size="sm"
|
|
1102
|
+
onClick={handleTestConnection}
|
|
1103
|
+
disabled={testing}
|
|
1104
|
+
>
|
|
1105
|
+
{testing ? (
|
|
1106
|
+
<Loader2 className="mr-1 size-4 animate-spin" />
|
|
1107
|
+
) : (
|
|
1108
|
+
<Cloud className="mr-1 size-4" />
|
|
1109
|
+
)}
|
|
1110
|
+
Test Connection
|
|
1111
|
+
</Button>
|
|
1112
|
+
</div>
|
|
1113
|
+
</div>
|
|
1114
|
+
)}
|
|
1115
|
+
</CardContent>
|
|
1116
|
+
</Card>
|
|
1117
|
+
|
|
1118
|
+
{/* Last saved timestamp */}
|
|
1119
|
+
{data?.config?.updatedAt &&
|
|
1120
|
+
data.config.updatedAt !== new Date(0).toISOString() && (
|
|
1121
|
+
<p className="text-center text-xs text-muted-foreground pb-2">
|
|
1122
|
+
Last saved {new Date(data.config.updatedAt).toLocaleString()}
|
|
1123
|
+
</p>
|
|
1124
|
+
)}
|
|
1125
|
+
</div>
|
|
1126
|
+
|
|
1127
|
+
{/* ── Save confirmation dialog ─────────────────────────────────────── */}
|
|
1128
|
+
<Dialog open={saveConfirmOpen} onOpenChange={setSaveConfirmOpen}>
|
|
1129
|
+
<DialogContent className="max-w-md">
|
|
1130
|
+
<DialogHeader>
|
|
1131
|
+
<DialogTitle className="flex items-center gap-2">
|
|
1132
|
+
<CheckCircle2 className="size-5 text-primary" />
|
|
1133
|
+
Confirm configuration
|
|
1134
|
+
</DialogTitle>
|
|
1135
|
+
<DialogDescription>
|
|
1136
|
+
Review your settings before saving.
|
|
1137
|
+
</DialogDescription>
|
|
1138
|
+
</DialogHeader>
|
|
1139
|
+
<div className="divide-y divide-border rounded-lg border border-border overflow-hidden text-sm">
|
|
1140
|
+
{summaryItems.map((item) => (
|
|
1141
|
+
<div
|
|
1142
|
+
key={item.label}
|
|
1143
|
+
className="flex items-center justify-between px-3 py-2 gap-3"
|
|
1144
|
+
>
|
|
1145
|
+
<span className="text-muted-foreground text-xs">
|
|
1146
|
+
{item.label}
|
|
1147
|
+
</span>
|
|
1148
|
+
<span
|
|
1149
|
+
className={cn(
|
|
1150
|
+
"font-medium text-right truncate",
|
|
1151
|
+
item.mono && "font-mono text-xs",
|
|
1152
|
+
)}
|
|
1153
|
+
>
|
|
1154
|
+
{item.value}
|
|
1155
|
+
</span>
|
|
1156
|
+
</div>
|
|
1157
|
+
))}
|
|
1158
|
+
</div>
|
|
1159
|
+
<DialogFooter className="mt-2 gap-2">
|
|
1160
|
+
<Button variant="outline" onClick={() => setSaveConfirmOpen(false)}>
|
|
1161
|
+
Cancel
|
|
1162
|
+
</Button>
|
|
1163
|
+
<Button
|
|
1164
|
+
onClick={async () => {
|
|
1165
|
+
setSaveConfirmOpen(false);
|
|
1166
|
+
await performSave();
|
|
1167
|
+
}}
|
|
1168
|
+
disabled={saving}
|
|
1169
|
+
>
|
|
1170
|
+
{saving ? (
|
|
1171
|
+
<Loader2 className="size-4 animate-spin mr-2" />
|
|
1172
|
+
) : (
|
|
1173
|
+
<Save className="size-4 mr-2" />
|
|
1174
|
+
)}
|
|
1175
|
+
Save
|
|
1176
|
+
</Button>
|
|
1177
|
+
</DialogFooter>
|
|
1178
|
+
</DialogContent>
|
|
1179
|
+
</Dialog>
|
|
1180
|
+
|
|
1181
|
+
{/* ── Unsaved changes navigation blocker ──────────────────────────── */}
|
|
1182
|
+
<GenericAlert
|
|
1183
|
+
open={navBlockOpen}
|
|
1184
|
+
onOpenChange={setNavBlockOpen}
|
|
1185
|
+
contentClassName="max-w-xl! w-full!"
|
|
1186
|
+
icon={<AlertCircle className="size-5 text-amber-500" />}
|
|
1187
|
+
title="Unsaved changes"
|
|
1188
|
+
description="You have unsaved changes to your configuration. If you leave now, your changes will be lost."
|
|
1189
|
+
cancelText="Stay on page"
|
|
1190
|
+
actionText="Leave without saving"
|
|
1191
|
+
variant="destructive"
|
|
1192
|
+
onAction={() => {
|
|
1193
|
+
if (pendingHref) router.push(pendingHref);
|
|
1194
|
+
}}
|
|
1195
|
+
/>
|
|
1196
|
+
|
|
1197
|
+
<CustomEmbeddingModal
|
|
1198
|
+
open={customModalOpen}
|
|
1199
|
+
onOpenChange={setCustomModalOpen}
|
|
1200
|
+
onSave={async (cfg) => {
|
|
1201
|
+
// Upsert: replace existing model with same name, or append a new one.
|
|
1202
|
+
const existing = form.customEmbeddings ?? [];
|
|
1203
|
+
const updatedList = existing.some(
|
|
1204
|
+
(m) => m.modelName === cfg.modelName,
|
|
1205
|
+
)
|
|
1206
|
+
? existing.map((m) => (m.modelName === cfg.modelName ? cfg : m))
|
|
1207
|
+
: [...existing, cfg];
|
|
1208
|
+
|
|
1209
|
+
const nextForm = {
|
|
1210
|
+
...form,
|
|
1211
|
+
embeddingModelId: `custom:${cfg.modelName}`,
|
|
1212
|
+
customEmbeddings: updatedList,
|
|
1213
|
+
};
|
|
1214
|
+
setForm(nextForm);
|
|
1215
|
+
|
|
1216
|
+
// Auto-save: attempt to persist immediately.
|
|
1217
|
+
const storeToSave = getVectorStore(nextForm.vectorStore);
|
|
1218
|
+
const fieldErrors = validateStoreConfig(
|
|
1219
|
+
storeToSave,
|
|
1220
|
+
nextForm.storeConfig,
|
|
1221
|
+
nextForm.indexType,
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
if (Object.keys(fieldErrors).length === 0) {
|
|
1225
|
+
setSaving(true);
|
|
1226
|
+
try {
|
|
1227
|
+
const res = await fetch("/api/config", {
|
|
1228
|
+
method: "PUT",
|
|
1229
|
+
headers: { "Content-Type": "application/json" },
|
|
1230
|
+
body: JSON.stringify(nextForm),
|
|
1231
|
+
});
|
|
1232
|
+
if (res.ok) {
|
|
1233
|
+
const json = await res.json();
|
|
1234
|
+
await mutate(json, { revalidate: false });
|
|
1235
|
+
setForm(json.config);
|
|
1236
|
+
toast.success(`Custom model "${cfg.modelName}" saved`);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
} catch {
|
|
1240
|
+
// fall through to reminder
|
|
1241
|
+
} finally {
|
|
1242
|
+
setSaving(false);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
toast.info("Custom model added", {
|
|
1247
|
+
description: "Click 'Save configuration' to persist your changes.",
|
|
1248
|
+
});
|
|
1249
|
+
}}
|
|
1250
|
+
/>
|
|
1251
|
+
|
|
1252
|
+
{/* ── Block: vector store change after index ─────────────────── */}
|
|
1253
|
+
<GenericAlert
|
|
1254
|
+
open={storeBlockAlertOpen}
|
|
1255
|
+
onOpenChange={setStoreBlockAlertOpen}
|
|
1256
|
+
icon={<AlertCircle className="size-5 text-destructive" />}
|
|
1257
|
+
title="Cannot change vector store"
|
|
1258
|
+
description={
|
|
1259
|
+
<>
|
|
1260
|
+
<span className="block">
|
|
1261
|
+
Your index was built with{" "}
|
|
1262
|
+
<span className="font-semibold text-foreground">
|
|
1263
|
+
{indexedVectorStore
|
|
1264
|
+
? (getVectorStore(indexedVectorStore)?.label ??
|
|
1265
|
+
indexedVectorStore)
|
|
1266
|
+
: form.vectorStore}
|
|
1267
|
+
</span>
|
|
1268
|
+
. Switching to a different vector store would make the existing
|
|
1269
|
+
index incompatible — vectors are bound to the store they were
|
|
1270
|
+
written to.
|
|
1271
|
+
</span>
|
|
1272
|
+
<span className="block">
|
|
1273
|
+
To use a different vector store, go to the{" "}
|
|
1274
|
+
<strong className="text-foreground">Index</strong> page and run a
|
|
1275
|
+
full re-index with your new settings.
|
|
1276
|
+
</span>
|
|
1277
|
+
</>
|
|
1278
|
+
}
|
|
1279
|
+
cancelText="Keep current store"
|
|
1280
|
+
actionText={
|
|
1281
|
+
<>
|
|
1282
|
+
<ExternalLink className="size-4" />
|
|
1283
|
+
Re-index from scratch
|
|
1284
|
+
</>
|
|
1285
|
+
}
|
|
1286
|
+
variant="destructive"
|
|
1287
|
+
onAction={() => {
|
|
1288
|
+
window.location.href = "/index-data";
|
|
1289
|
+
}}
|
|
1290
|
+
/>
|
|
1291
|
+
|
|
1292
|
+
{/* ── Block: embedding model dimension change after index ──────── */}
|
|
1293
|
+
<GenericAlert
|
|
1294
|
+
open={modelBlockAlertOpen}
|
|
1295
|
+
onOpenChange={setModelBlockAlertOpen}
|
|
1296
|
+
icon={<AlertCircle className="size-5 text-destructive" />}
|
|
1297
|
+
title="Incompatible embedding dimensions"
|
|
1298
|
+
description={
|
|
1299
|
+
<>
|
|
1300
|
+
<span className="block">
|
|
1301
|
+
Your index was built with{" "}
|
|
1302
|
+
<span className="font-semibold text-foreground">
|
|
1303
|
+
{indexedDimensions} dimensions
|
|
1304
|
+
</span>
|
|
1305
|
+
. The selected model outputs a different vector size, which is
|
|
1306
|
+
incompatible with the existing index.
|
|
1307
|
+
</span>
|
|
1308
|
+
<span className="block">
|
|
1309
|
+
✓ You can freely switch to any model that also produces{" "}
|
|
1310
|
+
<span className="font-semibold text-foreground">
|
|
1311
|
+
{indexedDimensions}d
|
|
1312
|
+
</span>{" "}
|
|
1313
|
+
vectors. To use a different dimension size, go to the{" "}
|
|
1314
|
+
<strong className="text-foreground">Index</strong> page and run a
|
|
1315
|
+
full re-index.
|
|
1316
|
+
</span>
|
|
1317
|
+
</>
|
|
1318
|
+
}
|
|
1319
|
+
cancelText="Keep current model"
|
|
1320
|
+
actionText={
|
|
1321
|
+
<>
|
|
1322
|
+
<ExternalLink className="size-4" />
|
|
1323
|
+
Re-index from scratch
|
|
1324
|
+
</>
|
|
1325
|
+
}
|
|
1326
|
+
variant="destructive"
|
|
1327
|
+
onAction={() => {
|
|
1328
|
+
window.location.href = "/index-data";
|
|
1329
|
+
}}
|
|
1330
|
+
/>
|
|
1331
|
+
|
|
1332
|
+
{/* ── Delete custom model confirmation ─────────────────────────── */}
|
|
1333
|
+
<GenericAlert
|
|
1334
|
+
open={deleteConfirmOpen}
|
|
1335
|
+
onOpenChange={setDeleteConfirmOpen}
|
|
1336
|
+
title="Delete custom model?"
|
|
1337
|
+
description={
|
|
1338
|
+
<>
|
|
1339
|
+
This will remove{" "}
|
|
1340
|
+
<span className="font-semibold text-foreground">
|
|
1341
|
+
"{activeCustomModel?.modelName}"
|
|
1342
|
+
</span>{" "}
|
|
1343
|
+
from your saved custom models. This action cannot be undone.
|
|
1344
|
+
</>
|
|
1345
|
+
}
|
|
1346
|
+
cancelText="Cancel"
|
|
1347
|
+
actionText="Delete"
|
|
1348
|
+
variant="destructive"
|
|
1349
|
+
onAction={async () => {
|
|
1350
|
+
if (!activeCustomModel) return;
|
|
1351
|
+
const modelName = activeCustomModel.modelName;
|
|
1352
|
+
const updatedList = (form.customEmbeddings ?? []).filter(
|
|
1353
|
+
(m) => m.modelName !== modelName,
|
|
1354
|
+
);
|
|
1355
|
+
const nextId =
|
|
1356
|
+
updatedList.length > 0
|
|
1357
|
+
? `custom:${updatedList[0].modelName}`
|
|
1358
|
+
: DEFAULT_CONFIG.embeddingModelId;
|
|
1359
|
+
const nextForm = {
|
|
1360
|
+
...form,
|
|
1361
|
+
embeddingModelId: nextId,
|
|
1362
|
+
customEmbeddings: updatedList,
|
|
1363
|
+
};
|
|
1364
|
+
setForm(nextForm);
|
|
1365
|
+
|
|
1366
|
+
setSaving(true);
|
|
1367
|
+
try {
|
|
1368
|
+
const res = await fetch("/api/config", {
|
|
1369
|
+
method: "PUT",
|
|
1370
|
+
headers: { "Content-Type": "application/json" },
|
|
1371
|
+
body: JSON.stringify(nextForm),
|
|
1372
|
+
});
|
|
1373
|
+
if (res.ok) {
|
|
1374
|
+
const json = await res.json();
|
|
1375
|
+
await mutate(json, { revalidate: false });
|
|
1376
|
+
setForm(json.config);
|
|
1377
|
+
toast.success(`Custom model "${modelName}" deleted`);
|
|
1378
|
+
}
|
|
1379
|
+
} catch {
|
|
1380
|
+
toast.error("Failed to save after deletion");
|
|
1381
|
+
} finally {
|
|
1382
|
+
setSaving(false);
|
|
1383
|
+
}
|
|
1384
|
+
}}
|
|
1385
|
+
/>
|
|
1386
|
+
|
|
1387
|
+
{/* API Key settings modal */}
|
|
1388
|
+
<Dialog open={apiKeyModalOpen} onOpenChange={setApiKeyModalOpen}>
|
|
1389
|
+
<DialogContent className="sm:max-w-[425px]">
|
|
1390
|
+
<DialogHeader>
|
|
1391
|
+
<DialogTitle>Provider Settings</DialogTitle>
|
|
1392
|
+
<DialogDescription>
|
|
1393
|
+
Configure the active embedding provider and its API key.
|
|
1394
|
+
</DialogDescription>
|
|
1395
|
+
</DialogHeader>
|
|
1396
|
+
<div className="grid gap-4 py-4">
|
|
1397
|
+
<div className="space-y-2">
|
|
1398
|
+
<Label>Provider</Label>
|
|
1399
|
+
<Select
|
|
1400
|
+
value={form.embeddingProvider || "openai"}
|
|
1401
|
+
onValueChange={(v) => {
|
|
1402
|
+
set("embeddingProvider", v as string);
|
|
1403
|
+
// Reset model when provider changes (except for vercel_ai_gateway which shows all)
|
|
1404
|
+
if (v !== "vercel_ai_gateway" && v !== "custom") {
|
|
1405
|
+
const firstModel = EMBEDDING_MODELS.find(
|
|
1406
|
+
(m) => m.provider === v,
|
|
1407
|
+
)?.id;
|
|
1408
|
+
if (firstModel) {
|
|
1409
|
+
set("embeddingModelId", firstModel);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}}
|
|
1413
|
+
>
|
|
1414
|
+
<SelectTrigger className="w-full">
|
|
1415
|
+
<span className="flex items-center gap-2">
|
|
1416
|
+
{PROVIDER_META[
|
|
1417
|
+
form.embeddingProvider as keyof typeof PROVIDER_META
|
|
1418
|
+
]?.label || form.embeddingProvider}
|
|
1419
|
+
</span>
|
|
1420
|
+
</SelectTrigger>
|
|
1421
|
+
<SelectContent>
|
|
1422
|
+
{[
|
|
1423
|
+
"vercel_ai_gateway",
|
|
1424
|
+
"openai",
|
|
1425
|
+
"deepseek",
|
|
1426
|
+
"google",
|
|
1427
|
+
// "cohere",
|
|
1428
|
+
"voyage",
|
|
1429
|
+
"mistral",
|
|
1430
|
+
"jina",
|
|
1431
|
+
"nomic",
|
|
1432
|
+
"custom",
|
|
1433
|
+
].map((providerKey) => {
|
|
1434
|
+
const meta =
|
|
1435
|
+
PROVIDER_META[providerKey as keyof typeof PROVIDER_META];
|
|
1436
|
+
if (!meta) return null;
|
|
1437
|
+
return (
|
|
1438
|
+
<SelectItem key={providerKey} value={providerKey}>
|
|
1439
|
+
<div className="flex items-center gap-2">
|
|
1440
|
+
<ProviderIcon
|
|
1441
|
+
src={meta.iconSrc}
|
|
1442
|
+
alt={meta.label}
|
|
1443
|
+
pillBg={meta.pillBg}
|
|
1444
|
+
size={16}
|
|
1445
|
+
/>
|
|
1446
|
+
<span>
|
|
1447
|
+
{meta.label}
|
|
1448
|
+
{providerKey === "vercel_ai_gateway" &&
|
|
1449
|
+
" (Recommended)"}
|
|
1450
|
+
</span>
|
|
1451
|
+
</div>
|
|
1452
|
+
</SelectItem>
|
|
1453
|
+
);
|
|
1454
|
+
})}
|
|
1455
|
+
</SelectContent>
|
|
1456
|
+
</Select>
|
|
1457
|
+
</div>
|
|
1458
|
+
<div className="space-y-2">
|
|
1459
|
+
<Label htmlFor="apiKey">API Key</Label>
|
|
1460
|
+
<div className="relative">
|
|
1461
|
+
<Input
|
|
1462
|
+
id="apiKey"
|
|
1463
|
+
type={showProviderApiKey ? "text" : "password"}
|
|
1464
|
+
placeholder="Enter API Key..."
|
|
1465
|
+
value={form.embeddingApiKey || ""}
|
|
1466
|
+
onChange={(e) => set("embeddingApiKey", e.target.value)}
|
|
1467
|
+
className="pr-10"
|
|
1468
|
+
/>
|
|
1469
|
+
<button
|
|
1470
|
+
type="button"
|
|
1471
|
+
onClick={() => setShowProviderApiKey(!showProviderApiKey)}
|
|
1472
|
+
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
|
1473
|
+
>
|
|
1474
|
+
{showProviderApiKey ? (
|
|
1475
|
+
<EyeOff className="size-4" />
|
|
1476
|
+
) : (
|
|
1477
|
+
<Eye className="size-4" />
|
|
1478
|
+
)}
|
|
1479
|
+
</button>
|
|
1480
|
+
</div>
|
|
1481
|
+
<p className="text-xs text-muted-foreground mt-2">
|
|
1482
|
+
Recommended: Use the Vercel AI Gateway key for best performance.
|
|
1483
|
+
</p>
|
|
1484
|
+
</div>
|
|
1485
|
+
</div>
|
|
1486
|
+
<DialogFooter>
|
|
1487
|
+
<Button
|
|
1488
|
+
type="button"
|
|
1489
|
+
disabled={testingKey}
|
|
1490
|
+
onClick={async () => {
|
|
1491
|
+
setTestingKey(true);
|
|
1492
|
+
try {
|
|
1493
|
+
const res = await fetch("/api/config/test-provider", {
|
|
1494
|
+
method: "POST",
|
|
1495
|
+
headers: { "Content-Type": "application/json" },
|
|
1496
|
+
body: JSON.stringify({
|
|
1497
|
+
embeddingProvider: form.embeddingProvider,
|
|
1498
|
+
embeddingApiKey: form.embeddingApiKey,
|
|
1499
|
+
embeddingModelId: form.embeddingModelId,
|
|
1500
|
+
customEmbeddings: form.customEmbeddings,
|
|
1501
|
+
}),
|
|
1502
|
+
});
|
|
1503
|
+
const data = await res.json();
|
|
1504
|
+
if (!res.ok)
|
|
1505
|
+
throw new Error(data.error || "Connection failed");
|
|
1506
|
+
|
|
1507
|
+
toast.success("Settings saved", {
|
|
1508
|
+
description: `Connection verified for ${form.embeddingProvider}.`,
|
|
1509
|
+
});
|
|
1510
|
+
setApiKeyModalOpen(false);
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
toast.error("Invalid API Key", {
|
|
1513
|
+
description:
|
|
1514
|
+
err instanceof Error
|
|
1515
|
+
? err.message
|
|
1516
|
+
: "Could not connect to provider.",
|
|
1517
|
+
});
|
|
1518
|
+
} finally {
|
|
1519
|
+
setTestingKey(false);
|
|
1520
|
+
}
|
|
1521
|
+
}}
|
|
1522
|
+
>
|
|
1523
|
+
{testingKey ? (
|
|
1524
|
+
<Loader2 className="size-4 animate-spin mr-2" />
|
|
1525
|
+
) : null}
|
|
1526
|
+
Save changes
|
|
1527
|
+
</Button>
|
|
1528
|
+
</DialogFooter>
|
|
1529
|
+
</DialogContent>
|
|
1530
|
+
</Dialog>
|
|
1531
|
+
</div>
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
function CustomEmbeddingModal({
|
|
1536
|
+
open,
|
|
1537
|
+
onOpenChange,
|
|
1538
|
+
onSave,
|
|
1539
|
+
}: {
|
|
1540
|
+
open: boolean;
|
|
1541
|
+
onOpenChange: (open: boolean) => void;
|
|
1542
|
+
onSave: (config: CustomEmbeddingConfig) => void;
|
|
1543
|
+
}) {
|
|
1544
|
+
const [baseUrl, setBaseUrl] = useState("");
|
|
1545
|
+
const [apiKey, setApiKey] = useState("");
|
|
1546
|
+
const [showApiKey, setShowApiKey] = useState(false);
|
|
1547
|
+
const [modelName, setModelName] = useState("");
|
|
1548
|
+
const [dimensions, setDimensions] = useState<number | null>(null);
|
|
1549
|
+
const [testing, setTesting] = useState(false);
|
|
1550
|
+
|
|
1551
|
+
// Reset form every time the modal opens so users always start fresh.
|
|
1552
|
+
useEffect(() => {
|
|
1553
|
+
if (open) {
|
|
1554
|
+
setBaseUrl("");
|
|
1555
|
+
setApiKey("");
|
|
1556
|
+
setModelName("");
|
|
1557
|
+
setDimensions(null);
|
|
1558
|
+
setShowApiKey(false);
|
|
1559
|
+
}
|
|
1560
|
+
}, [open]);
|
|
1561
|
+
|
|
1562
|
+
const handleTest = async () => {
|
|
1563
|
+
if (!baseUrl || !modelName) {
|
|
1564
|
+
toast.error("Base URL and Model Name are required");
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
setTesting(true);
|
|
1568
|
+
try {
|
|
1569
|
+
const res = await fetch("/api/config/test-embedding", {
|
|
1570
|
+
method: "POST",
|
|
1571
|
+
headers: { "Content-Type": "application/json" },
|
|
1572
|
+
body: JSON.stringify({ baseUrl, apiKey, modelName }),
|
|
1573
|
+
});
|
|
1574
|
+
const data = await res.json();
|
|
1575
|
+
if (!res.ok) throw new Error(data.error || "Connection failed");
|
|
1576
|
+
|
|
1577
|
+
setDimensions(data.dimensions);
|
|
1578
|
+
toast.success(`Success! Detected ${data.dimensions} dimensions.`);
|
|
1579
|
+
} catch (err) {
|
|
1580
|
+
toast.error(err instanceof Error ? err.message : "Test failed");
|
|
1581
|
+
setDimensions(null);
|
|
1582
|
+
} finally {
|
|
1583
|
+
setTesting(false);
|
|
1584
|
+
}
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
const handleSave = () => {
|
|
1588
|
+
if (!baseUrl || !modelName || !dimensions) {
|
|
1589
|
+
toast.error("Please test the connection to fetch dimensions first.");
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
onSave({ baseUrl, apiKey, modelName, dimensions });
|
|
1593
|
+
onOpenChange(false);
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
return (
|
|
1597
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
1598
|
+
<DialogContent className={"max-w-xl "}>
|
|
1599
|
+
<DialogHeader>
|
|
1600
|
+
<DialogTitle>Custom Embedding Model</DialogTitle>
|
|
1601
|
+
<DialogDescription>
|
|
1602
|
+
Connect an OpenAI-compatible embedding model.
|
|
1603
|
+
</DialogDescription>
|
|
1604
|
+
</DialogHeader>
|
|
1605
|
+
<div className="grid gap-4 py-4">
|
|
1606
|
+
<div className="grid gap-2">
|
|
1607
|
+
<Label htmlFor="baseUrl">Base URL</Label>
|
|
1608
|
+
<Input
|
|
1609
|
+
id="baseUrl"
|
|
1610
|
+
placeholder="https://api.example.com/v1"
|
|
1611
|
+
value={baseUrl}
|
|
1612
|
+
onChange={(e) => {
|
|
1613
|
+
setBaseUrl(e.target.value);
|
|
1614
|
+
setDimensions(null); // Require re-test on change
|
|
1615
|
+
}}
|
|
1616
|
+
/>
|
|
1617
|
+
</div>
|
|
1618
|
+
<div className="grid gap-2">
|
|
1619
|
+
<Label htmlFor="apiKey">API Key (Optional)</Label>
|
|
1620
|
+
<div className="relative">
|
|
1621
|
+
<Input
|
|
1622
|
+
id="apiKey"
|
|
1623
|
+
type={showApiKey ? "text" : "password"}
|
|
1624
|
+
placeholder="sk-..."
|
|
1625
|
+
value={apiKey}
|
|
1626
|
+
onChange={(e) => {
|
|
1627
|
+
setApiKey(e.target.value);
|
|
1628
|
+
setDimensions(null);
|
|
1629
|
+
}}
|
|
1630
|
+
className="pr-10"
|
|
1631
|
+
/>
|
|
1632
|
+
<button
|
|
1633
|
+
type="button"
|
|
1634
|
+
onClick={() => setShowApiKey(!showApiKey)}
|
|
1635
|
+
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
|
1636
|
+
>
|
|
1637
|
+
{showApiKey ? (
|
|
1638
|
+
<EyeOff className="size-4" />
|
|
1639
|
+
) : (
|
|
1640
|
+
<Eye className="size-4" />
|
|
1641
|
+
)}
|
|
1642
|
+
</button>
|
|
1643
|
+
</div>
|
|
1644
|
+
</div>
|
|
1645
|
+
<div className="grid gap-2">
|
|
1646
|
+
<Label htmlFor="modelName">Model Name</Label>
|
|
1647
|
+
<Input
|
|
1648
|
+
id="modelName"
|
|
1649
|
+
placeholder="my-embedding-model"
|
|
1650
|
+
value={modelName}
|
|
1651
|
+
onChange={(e) => {
|
|
1652
|
+
setModelName(e.target.value);
|
|
1653
|
+
setDimensions(null);
|
|
1654
|
+
}}
|
|
1655
|
+
/>
|
|
1656
|
+
</div>
|
|
1657
|
+
{dimensions && (
|
|
1658
|
+
<div className="text-sm text-green-600 dark:text-green-400 font-medium">
|
|
1659
|
+
✓ Connection verified ({dimensions} dimensions)
|
|
1660
|
+
</div>
|
|
1661
|
+
)}
|
|
1662
|
+
</div>
|
|
1663
|
+
<DialogFooter className="flex flex-row justify-between sm:justify-between items-center gap-2">
|
|
1664
|
+
<Button variant="outline" onClick={handleTest} disabled={testing}>
|
|
1665
|
+
{testing ? (
|
|
1666
|
+
<Loader2 className="size-4 animate-spin mr-2" />
|
|
1667
|
+
) : (
|
|
1668
|
+
<Cloud className="size-4 mr-2" />
|
|
1669
|
+
)}
|
|
1670
|
+
Test Connection
|
|
1671
|
+
</Button>
|
|
1672
|
+
<Button
|
|
1673
|
+
className={"px-5"}
|
|
1674
|
+
onClick={handleSave}
|
|
1675
|
+
disabled={!dimensions}
|
|
1676
|
+
>
|
|
1677
|
+
Add Model
|
|
1678
|
+
</Button>
|
|
1679
|
+
</DialogFooter>
|
|
1680
|
+
</DialogContent>
|
|
1681
|
+
</Dialog>
|
|
1682
|
+
);
|
|
1683
|
+
}
|