larkup 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.env +2 -0
  2. package/app/actions/vercel.ts +417 -0
  3. package/app/api/chat/route.ts +192 -0
  4. package/app/api/chat/status/route.ts +55 -0
  5. package/app/api/chat/suggestions/route.ts +115 -0
  6. package/app/api/config/route.ts +73 -0
  7. package/app/api/config/test/route.ts +67 -0
  8. package/app/api/config/test-embedding/route.ts +40 -0
  9. package/app/api/config/test-llm/route.ts +47 -0
  10. package/app/api/config/test-provider/route.ts +36 -0
  11. package/app/api/demo/route.ts +176 -0
  12. package/app/api/deploy/ssh/route.ts +340 -0
  13. package/app/api/documents/route.ts +90 -0
  14. package/app/api/firecrawl/local/route.ts +46 -0
  15. package/app/api/index/route.ts +113 -0
  16. package/app/api/jobs/[id]/route.ts +44 -0
  17. package/app/api/jobs/route.ts +116 -0
  18. package/app/api/parse-file/route.ts +39 -0
  19. package/app/api/search/google/route.ts +162 -0
  20. package/app/api/search/route.ts +29 -0
  21. package/app/api/server/download/route.ts +112 -0
  22. package/app/api/server/generate/route.ts +46 -0
  23. package/app/api/server/local/route.ts +42 -0
  24. package/app/api/servers/route.ts +137 -0
  25. package/app/api/vector-stores/install/route.ts +65 -0
  26. package/app/api/vector-stores/status/route.ts +26 -0
  27. package/app/chat/page.tsx +15 -0
  28. package/app/configure/page.tsx +53 -0
  29. package/app/data/page.tsx +15 -0
  30. package/app/demo/page.tsx +15 -0
  31. package/app/favicon.ico +0 -0
  32. package/app/globals.css +190 -0
  33. package/app/index-data/page.tsx +15 -0
  34. package/app/layout.tsx +51 -0
  35. package/app/page.tsx +34 -0
  36. package/app/server/page.tsx +22 -0
  37. package/app/simple/chat/page.tsx +17 -0
  38. package/app/simple/docs/page.tsx +21 -0
  39. package/app/simple/layout.tsx +9 -0
  40. package/app/simple/settings/page.tsx +627 -0
  41. package/app/template.tsx +16 -0
  42. package/app/themes.css +733 -0
  43. package/components/alerts/generic-alert.tsx +74 -0
  44. package/components/app-sidebar.tsx +187 -0
  45. package/components/app-topnav.tsx +195 -0
  46. package/components/chat/chat-settings-modal.tsx +209 -0
  47. package/components/chat/chat-workspace.tsx +348 -0
  48. package/components/chat/knowledge-base-result.tsx +97 -0
  49. package/components/chat/message-item.tsx +103 -0
  50. package/components/client-layout-wrapper.tsx +182 -0
  51. package/components/configure/configure-form.tsx +1683 -0
  52. package/components/configure/store-fields.tsx +152 -0
  53. package/components/data/corpus-panel.tsx +499 -0
  54. package/components/data/data-workspace.tsx +272 -0
  55. package/components/data/firecrawl-notice.tsx +236 -0
  56. package/components/data/jobs-panel.tsx +485 -0
  57. package/components/data/paste-panel.tsx +79 -0
  58. package/components/data/scrape-panel.tsx +964 -0
  59. package/components/data/upload-panel.tsx +714 -0
  60. package/components/demo/demo-workspace.tsx +419 -0
  61. package/components/global-settings.tsx +286 -0
  62. package/components/index/index-workspace.tsx +482 -0
  63. package/components/onboarding/simple-setup.tsx +702 -0
  64. package/components/onboarding/tech-setup.tsx +121 -0
  65. package/components/onboarding/welcome-screen.tsx +183 -0
  66. package/components/page-header.tsx +44 -0
  67. package/components/server/code-viewer.tsx +87 -0
  68. package/components/server/deploy-button.tsx +1390 -0
  69. package/components/server/server-workspace.tsx +392 -0
  70. package/components/simple/sdk-connect-dialog.tsx +201 -0
  71. package/components/simple/simple-index-button.tsx +83 -0
  72. package/components/simple/simple-sidebar.tsx +161 -0
  73. package/components/theme-customizer-provider.tsx +171 -0
  74. package/components/theme-switcher.tsx +445 -0
  75. package/components/ui/alert-dialog.tsx +187 -0
  76. package/components/ui/alert.tsx +76 -0
  77. package/components/ui/badge.tsx +52 -0
  78. package/components/ui/button.tsx +59 -0
  79. package/components/ui/card.tsx +103 -0
  80. package/components/ui/checkbox.tsx +28 -0
  81. package/components/ui/collapsible.tsx +21 -0
  82. package/components/ui/dialog.tsx +159 -0
  83. package/components/ui/dropdown-menu.tsx +281 -0
  84. package/components/ui/input.tsx +22 -0
  85. package/components/ui/label.tsx +20 -0
  86. package/components/ui/popover.tsx +90 -0
  87. package/components/ui/progress.tsx +83 -0
  88. package/components/ui/provider-icon.tsx +94 -0
  89. package/components/ui/scroll-area.tsx +55 -0
  90. package/components/ui/select.tsx +248 -0
  91. package/components/ui/separator.tsx +25 -0
  92. package/components/ui/sheet.tsx +138 -0
  93. package/components/ui/skeleton.tsx +13 -0
  94. package/components/ui/sonner.tsx +63 -0
  95. package/components/ui/switch.tsx +32 -0
  96. package/components/ui/table.tsx +116 -0
  97. package/components/ui/tabs.tsx +84 -0
  98. package/components/ui/textarea.tsx +18 -0
  99. package/components/ui/tooltip.tsx +66 -0
  100. package/components/workspace/delete-server-dialog.tsx +71 -0
  101. package/components/workspace/server-form-dialog.tsx +103 -0
  102. package/components/workspace/server-switcher.tsx +163 -0
  103. package/components/workspace/workspace-provider.tsx +177 -0
  104. package/components/workspace/workspace-top-bar.tsx +23 -0
  105. package/components.json +21 -0
  106. package/lib/error-formatter.ts +15 -0
  107. package/lib/utils.ts +6 -0
  108. package/next-env.d.ts +6 -0
  109. package/next.config.mjs +18 -0
  110. package/package.json +65 -0
  111. package/postcss.config.mjs +8 -0
  112. package/public/docker.png +0 -0
  113. package/public/favicon.ico +0 -0
  114. package/public/github.svg +3 -0
  115. package/public/hetzner.svg +1 -0
  116. package/public/icons/aws.svg +38 -0
  117. package/public/icons/azure.svg +1 -0
  118. package/public/icons/chroma.png +0 -0
  119. package/public/icons/cohere.svg +30 -0
  120. package/public/icons/digital-ocean.webp +0 -0
  121. package/public/icons/firecrawl2.png +0 -0
  122. package/public/icons/gcp.svg +1 -0
  123. package/public/icons/gemini.svg +1 -0
  124. package/public/icons/jina.svg +1 -0
  125. package/public/icons/lancedb2.png +0 -0
  126. package/public/icons/mistral.svg +469 -0
  127. package/public/icons/nomic.png +0 -0
  128. package/public/icons/openai.svg +1 -0
  129. package/public/icons/pgvector2.png +0 -0
  130. package/public/icons/pinecone.png +0 -0
  131. package/public/icons/qdrant.svg +35 -0
  132. package/public/icons/supabase.png +0 -0
  133. package/public/icons/vercel copy.svg +1 -0
  134. package/public/icons/vercel.svg +1 -0
  135. package/public/icons/voyage-light.png +0 -0
  136. package/public/icons/weaviate.webp +0 -0
  137. package/public/logo-light.png +0 -0
  138. package/public/logo.png +0 -0
  139. package/public/logo9.png +0 -0
  140. package/public/vercel.svg +1 -0
  141. package/tsconfig.json +33 -0
@@ -0,0 +1,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 &amp; 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
+ &quot;{activeCustomModel?.modelName}&quot;
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
+ }