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,702 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import {
6
+ ArrowLeft,
7
+ ArrowRight,
8
+ Cloud,
9
+ Eye,
10
+ EyeOff,
11
+ Loader2,
12
+ Monitor,
13
+ CheckCircle2,
14
+ ShieldCheck,
15
+ } from "lucide-react";
16
+ import { Button } from "@/components/ui/button";
17
+ import { Input } from "@/components/ui/input";
18
+ import { Label } from "@/components/ui/label";
19
+ import { useWorkspace } from "@/components/workspace/workspace-provider";
20
+ import { PROVIDER_META, ProviderIcon } from "@/components/ui/provider-icon";
21
+ import type { EmbeddingProvider } from "@larkup/core/types";
22
+ import { cn } from "@/lib/utils";
23
+
24
+ interface SimpleSetupProps {
25
+ onBack: () => void;
26
+ }
27
+
28
+ type ConnectionMode = "cloud" | "custom";
29
+
30
+ /** Ordered provider list — only Vercel AI Gateway is recommended. */
31
+ const CLOUD_PROVIDERS: { key: EmbeddingProvider; recommended?: boolean }[] = [
32
+ { key: "vercel_ai_gateway", recommended: true },
33
+ { key: "openai" },
34
+ { key: "google" },
35
+ { key: "mistral" },
36
+ { key: "voyage" },
37
+ { key: "jina" },
38
+ { key: "nomic" },
39
+ { key: "deepseek" },
40
+ ];
41
+
42
+ /**
43
+ * Default embedding model IDs for each provider.
44
+ * OpenAI and Vercel AI Gateway get the large model; others get the first in the registry.
45
+ */
46
+ const DEFAULT_EMBEDDING_MODEL: Partial<Record<EmbeddingProvider, string>> = {
47
+ openai: "openai/text-embedding-3-large",
48
+ vercel_ai_gateway: "openai/text-embedding-3-large",
49
+ google: "google/text-embedding-004",
50
+ mistral: "mistral/mistral-embed",
51
+ voyage: "voyage/voyage-3",
52
+ jina: "jina/jina-embeddings-v3",
53
+ nomic: "nomic/nomic-embed-text-v1.5",
54
+ deepseek: "openai/text-embedding-3-large",
55
+ };
56
+
57
+ export function SimpleSetup({ onBack }: SimpleSetupProps) {
58
+ const { createServer, setMode } = useWorkspace();
59
+
60
+ // Connection mode
61
+ const [connectionMode, setConnectionMode] = useState<ConnectionMode>("cloud");
62
+
63
+ // Cloud mode state
64
+ const [provider, setProvider] = useState<EmbeddingProvider>("openai");
65
+ const [apiKey, setApiKey] = useState("");
66
+ const [showApiKey, setShowApiKey] = useState(false);
67
+ const [cloudVerified, setCloudVerified] = useState(false);
68
+ const [testingCloud, setTestingCloud] = useState(false);
69
+
70
+ // Custom model state
71
+ const [embeddingBaseUrl, setEmbeddingBaseUrl] = useState(
72
+ "http://localhost:11434/v1",
73
+ );
74
+ const [embeddingModelName, setEmbeddingModelName] = useState("");
75
+ const [embeddingApiKey, setEmbeddingApiKey] = useState("");
76
+ const [showEmbeddingApiKey, setShowEmbeddingApiKey] = useState(false);
77
+ const [llmBaseUrl, setLlmBaseUrl] = useState("http://localhost:11434/v1");
78
+ const [llmModelName, setLlmModelName] = useState("");
79
+ const [llmApiKey, setLlmApiKey] = useState("");
80
+ const [showLlmApiKey, setShowLlmApiKey] = useState(false);
81
+
82
+ const [busy, setBusy] = useState(false);
83
+ const [testingEmbedding, setTestingEmbedding] = useState(false);
84
+ const [embeddingVerified, setEmbeddingVerified] = useState(false);
85
+ const [detectedDims, setDetectedDims] = useState<number | null>(null);
86
+
87
+ const [testingLlm, setTestingLlm] = useState(false);
88
+ const [llmVerified, setLlmVerified] = useState(false);
89
+
90
+ // Reset cloud verification when provider or key changes
91
+ function onProviderChange(p: EmbeddingProvider) {
92
+ setProvider(p);
93
+ setCloudVerified(false);
94
+ }
95
+ function onApiKeyChange(v: string) {
96
+ setApiKey(v);
97
+ setCloudVerified(false);
98
+ }
99
+
100
+ async function testCloudProvider() {
101
+ if (!apiKey.trim()) {
102
+ toast.error("Please enter your API key first.");
103
+ return;
104
+ }
105
+ setTestingCloud(true);
106
+ try {
107
+ const embeddingModelId =
108
+ DEFAULT_EMBEDDING_MODEL[provider] ?? "openai/text-embedding-3-large";
109
+ const res = await fetch("/api/config/test-provider", {
110
+ method: "POST",
111
+ headers: { "Content-Type": "application/json" },
112
+ body: JSON.stringify({
113
+ embeddingProvider: provider,
114
+ embeddingApiKey: apiKey,
115
+ embeddingModelId,
116
+ }),
117
+ });
118
+ const data = await res.json();
119
+ if (!res.ok) throw new Error(data.error || "Connection failed");
120
+ setCloudVerified(true);
121
+ toast.success("API key verified successfully!");
122
+ } catch (err) {
123
+ toast.error(
124
+ err instanceof Error
125
+ ? err.message
126
+ : "Invalid API key. Please check and try again.",
127
+ );
128
+ setCloudVerified(false);
129
+ } finally {
130
+ setTestingCloud(false);
131
+ }
132
+ }
133
+
134
+ async function testCustomEmbedding() {
135
+ if (!embeddingBaseUrl || !embeddingModelName) {
136
+ toast.error("Base URL and model name are required.");
137
+ return;
138
+ }
139
+ setTestingEmbedding(true);
140
+ try {
141
+ const res = await fetch("/api/config/test-embedding", {
142
+ method: "POST",
143
+ headers: { "Content-Type": "application/json" },
144
+ body: JSON.stringify({
145
+ baseUrl: embeddingBaseUrl,
146
+ apiKey: embeddingApiKey || undefined,
147
+ modelName: embeddingModelName,
148
+ }),
149
+ });
150
+ const data = await res.json();
151
+ if (!res.ok) throw new Error(data.error || "Connection failed");
152
+ setDetectedDims(data.dimensions);
153
+ setEmbeddingVerified(true);
154
+ toast.success(`Connected! Detected ${data.dimensions} dimensions.`);
155
+ } catch (err) {
156
+ toast.error(err instanceof Error ? err.message : "Test failed");
157
+ setEmbeddingVerified(false);
158
+ setDetectedDims(null);
159
+ } finally {
160
+ setTestingEmbedding(false);
161
+ }
162
+ }
163
+
164
+ async function testCustomLlm() {
165
+ if (!llmBaseUrl || !llmModelName) {
166
+ toast.error("Base URL and model name are required.");
167
+ return;
168
+ }
169
+ setTestingLlm(true);
170
+ try {
171
+ const res = await fetch("/api/config/test-llm", {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/json" },
174
+ body: JSON.stringify({
175
+ baseUrl: llmBaseUrl,
176
+ apiKey: llmApiKey || undefined,
177
+ modelName: llmModelName,
178
+ }),
179
+ });
180
+ const data = await res.json();
181
+ if (!res.ok) throw new Error(data.error || "Connection failed");
182
+ setLlmVerified(true);
183
+ toast.success("LLM connected successfully!");
184
+ } catch (err) {
185
+ toast.error(err instanceof Error ? err.message : "Test failed");
186
+ setLlmVerified(false);
187
+ } finally {
188
+ setTestingLlm(false);
189
+ }
190
+ }
191
+
192
+ async function handleFinish() {
193
+ // Validate cloud mode
194
+ if (connectionMode === "cloud") {
195
+ if (!apiKey.trim()) {
196
+ toast.error("Please enter your API key.");
197
+ return;
198
+ }
199
+ if (!cloudVerified) {
200
+ toast.error("Please verify your API key first.");
201
+ return;
202
+ }
203
+ }
204
+ // Validate custom mode
205
+ if (connectionMode === "custom") {
206
+ if (!embeddingVerified) {
207
+ toast.error("Please test your embedding connection first.");
208
+ return;
209
+ }
210
+ if (!llmVerified) {
211
+ toast.error("Please test your LLM connection first.");
212
+ return;
213
+ }
214
+ }
215
+
216
+ setBusy(true);
217
+ try {
218
+ // Create a default server
219
+ const server = await createServer("my-rag");
220
+
221
+ // Build config
222
+ const isCloud = connectionMode === "cloud";
223
+ const embeddingModelId = isCloud
224
+ ? (DEFAULT_EMBEDDING_MODEL[provider] ?? "openai/text-embedding-3-large")
225
+ : `custom:${embeddingModelName}`;
226
+
227
+ const configBody: Record<string, unknown> = {
228
+ projectName: "my-rag",
229
+ embeddingProvider: isCloud ? provider : "custom",
230
+ embeddingApiKey: isCloud ? apiKey : "",
231
+ embeddingModelId,
232
+ indexType: "hybrid",
233
+ chunking: { chunkSize: 512, chunkOverlap: 64, strategy: "recursive" },
234
+ vectorStore: "lancedb",
235
+ storeConfig: {
236
+ mode: "local",
237
+ dbPath: `./.larkup/servers/${server?.id}/lancedb`,
238
+ tableName: "documents",
239
+ },
240
+ topK: 5,
241
+ // Chat configuration
242
+ chatProvider: isCloud ? provider : "custom",
243
+ chatApiKey: isCloud ? apiKey : "",
244
+ chatModelId: isCloud ? "" : undefined, // empty string lets the chat router pick default
245
+ updatedAt: new Date().toISOString(),
246
+ };
247
+
248
+ // For custom mode, add custom embedding config and LLM config
249
+ if (!isCloud) {
250
+ configBody.customEmbeddings = [
251
+ {
252
+ baseUrl: embeddingBaseUrl,
253
+ apiKey: embeddingApiKey || undefined,
254
+ modelName: embeddingModelName,
255
+ dimensions: detectedDims ?? 1536,
256
+ },
257
+ ];
258
+ configBody.chatProvider = "custom";
259
+ configBody.chatApiKey = llmApiKey || "";
260
+ configBody.chatModelId = llmModelName;
261
+ }
262
+
263
+ // Save config via API
264
+ if (server?.id) {
265
+ const configRes = await fetch(`/api/config?serverId=${encodeURIComponent(server.id)}`, {
266
+ method: "PUT",
267
+ headers: { "Content-Type": "application/json" },
268
+ body: JSON.stringify(configBody),
269
+ });
270
+ if (!configRes.ok) {
271
+ const err = await configRes.json().catch(() => ({}));
272
+ throw new Error(err.error || "Failed to save configuration");
273
+ }
274
+ }
275
+
276
+ await setMode("simple");
277
+
278
+ // Fire-and-forget: generate + launch RAG server so it's ready for chat
279
+ (async () => {
280
+ try {
281
+ // Generate server code
282
+ await fetch("/api/server/generate");
283
+ // Launch it
284
+ await fetch("/api/server/local", {
285
+ method: "POST",
286
+ headers: { "Content-Type": "application/json" },
287
+ body: JSON.stringify({ action: "start" }),
288
+ });
289
+ } catch {
290
+ // Non-blocking — server can be launched manually from settings
291
+ }
292
+ })();
293
+
294
+ toast.success("All set! Let's get started.");
295
+ } catch (err) {
296
+ toast.error(err instanceof Error ? err.message : "Setup failed. Please try again.");
297
+ setBusy(false);
298
+ }
299
+ }
300
+
301
+ const canProceed =
302
+ connectionMode === "cloud"
303
+ ? cloudVerified
304
+ : embeddingVerified && llmVerified;
305
+
306
+ return (
307
+ <div
308
+ className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
309
+ style={{ backgroundColor: "#FCFCFB" }}
310
+ >
311
+ {/* Background */}
312
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
313
+ <div className="absolute -left-20 -top-20 h-[500px] w-[500px] rounded-full bg-stone-200/40 blur-3xl animate-pulse" />
314
+ <div className="absolute -bottom-32 -right-32 h-[600px] w-[600px] rounded-full bg-amber-100/30 blur-3xl animate-pulse [animation-delay:1s]" />
315
+ </div>
316
+
317
+ <div className="relative z-10 flex w-full max-w-lg flex-col items-center gap-6 px-6 py-10">
318
+ {/* Back */}
319
+ <button
320
+ type="button"
321
+ onClick={onBack}
322
+ className="self-start flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
323
+ >
324
+ <ArrowLeft className="size-4" />
325
+ Back
326
+ </button>
327
+
328
+ {/* Header */}
329
+ <div className="flex flex-col items-center gap-3 text-center">
330
+ <div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
331
+ <img src="/logo9.png" className="size-8" alt="Larkup" />
332
+ </div>
333
+ <div>
334
+ <h1 className="text-2xl font-bold tracking-tight text-foreground">
335
+ Connect your AI
336
+ </h1>
337
+ <p className="mt-1.5 text-sm text-muted-foreground text-pretty max-w-sm">
338
+ Choose how to connect to an AI provider. We&apos;ll handle
339
+ everything else automatically.
340
+ </p>
341
+ </div>
342
+ </div>
343
+
344
+ {/* Connection Mode Tabs */}
345
+ <div className="w-full rounded-xl border bg-white p-1 flex gap-1">
346
+ <button
347
+ type="button"
348
+ onClick={() => setConnectionMode("cloud")}
349
+ className={cn(
350
+ "flex-1 flex items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-medium transition-all",
351
+ connectionMode === "cloud"
352
+ ? "bg-primary text-primary-foreground"
353
+ : "text-muted-foreground hover:text-foreground hover:bg-muted/40",
354
+ )}
355
+ >
356
+ <Cloud className="size-4" />
357
+ Cloud Provider
358
+ </button>
359
+ <button
360
+ type="button"
361
+ onClick={() => setConnectionMode("custom")}
362
+ className={cn(
363
+ "flex-1 flex items-center justify-center gap-2 rounded-lg py-2.5 text-sm font-medium transition-all",
364
+ connectionMode === "custom"
365
+ ? "bg-primary text-primary-foreground"
366
+ : "text-muted-foreground hover:text-foreground hover:bg-muted/40",
367
+ )}
368
+ >
369
+ <Monitor className="size-4" />
370
+ Custom Model
371
+ </button>
372
+ </div>
373
+
374
+ {/* Form */}
375
+ <div className="w-full rounded-2xl border bg-transparent p-6 space-y-5">
376
+ {connectionMode === "cloud" ? (
377
+ <>
378
+ {/* Provider Selection */}
379
+ <div className="space-y-2.5">
380
+ <Label className="text-sm font-medium">AI Provider</Label>
381
+ <div className="grid grid-cols-2 gap-2">
382
+ {CLOUD_PROVIDERS.map(({ key, recommended }) => {
383
+ const meta = PROVIDER_META[key];
384
+ if (!meta) return null;
385
+ const selected = provider === key;
386
+ return (
387
+ <button
388
+ key={key}
389
+ type="button"
390
+ onClick={() => onProviderChange(key)}
391
+ className={cn(
392
+ "relative flex items-center gap-2.5 rounded-xl border-2 px-3 py-2.5 text-left transition-all duration-200",
393
+ selected
394
+ ? "border-primary bg-primary/5"
395
+ : "border-border hover:border-primary/30 hover:bg-muted/30",
396
+ )}
397
+ >
398
+ <ProviderIcon
399
+ src={meta.iconSrc}
400
+ alt={meta.label}
401
+ pillBg={meta.pillBg}
402
+ size={22}
403
+ />
404
+ <div className="flex flex-col min-w-0">
405
+ <span className="text-sm font-medium text-foreground truncate">
406
+ {meta.label}
407
+ </span>
408
+ {recommended && (
409
+ <span className="text-[10px] font-medium text-primary">
410
+ Recommended
411
+ </span>
412
+ )}
413
+ </div>
414
+ {selected && (
415
+ <CheckCircle2 className="absolute right-2.5 top-2.5 size-4 text-primary" />
416
+ )}
417
+ </button>
418
+ );
419
+ })}
420
+ </div>
421
+ </div>
422
+
423
+ {/* API Key */}
424
+ <div className="space-y-2">
425
+ <Label htmlFor="simple-apikey">API Key</Label>
426
+ <div className="relative">
427
+ <Input
428
+ id="simple-apikey"
429
+ type={showApiKey ? "text" : "password"}
430
+ placeholder="sk-..."
431
+ value={apiKey}
432
+ onChange={(e) => onApiKeyChange(e.target.value)}
433
+ className="pr-10"
434
+ />
435
+ <button
436
+ type="button"
437
+ onClick={() => setShowApiKey(!showApiKey)}
438
+ className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
439
+ >
440
+ {showApiKey ? (
441
+ <EyeOff className="size-4" />
442
+ ) : (
443
+ <Eye className="size-4" />
444
+ )}
445
+ </button>
446
+ </div>
447
+ <p className="text-xs text-muted-foreground">
448
+ Your key is stored locally and never sent to our servers.
449
+ </p>
450
+ </div>
451
+
452
+ {/* Test Connection */}
453
+ {cloudVerified ? (
454
+ <div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-3 py-2.5 text-sm font-medium text-green-700">
455
+ <CheckCircle2 className="size-4" />
456
+ API key verified. Ready to go!
457
+ </div>
458
+ ) : (
459
+ <Button
460
+ variant="outline"
461
+ className="w-full gap-2"
462
+ onClick={testCloudProvider}
463
+ disabled={testingCloud || !apiKey.trim()}
464
+ >
465
+ {testingCloud ? (
466
+ <Loader2 className="size-4 animate-spin" />
467
+ ) : (
468
+ <ShieldCheck className="size-4" />
469
+ )}
470
+ Verify API Key
471
+ </Button>
472
+ )}
473
+ </>
474
+ ) : (
475
+ <>
476
+ {/* Embedding Connection */}
477
+ <div className="space-y-3">
478
+ <div className="flex items-center gap-2">
479
+ <span className="flex size-6 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-bold">
480
+ 1
481
+ </span>
482
+ <Label className="text-sm font-medium">Embedding Model</Label>
483
+ </div>
484
+ <div className="grid gap-2.5 pl-8">
485
+ <div className="space-y-1.5">
486
+ <Label
487
+ htmlFor="emb-url"
488
+ className="text-xs text-muted-foreground"
489
+ >
490
+ Base URL
491
+ </Label>
492
+ <Input
493
+ id="emb-url"
494
+ placeholder="http://localhost:11434/v1"
495
+ value={embeddingBaseUrl}
496
+ onChange={(e) => {
497
+ setEmbeddingBaseUrl(e.target.value);
498
+ setEmbeddingVerified(false);
499
+ }}
500
+ />
501
+ </div>
502
+ <div className="space-y-1.5">
503
+ <Label
504
+ htmlFor="emb-model"
505
+ className="text-xs text-muted-foreground"
506
+ >
507
+ Model Name
508
+ </Label>
509
+ <Input
510
+ id="emb-model"
511
+ placeholder="nomic-embed-text"
512
+ value={embeddingModelName}
513
+ onChange={(e) => {
514
+ setEmbeddingModelName(e.target.value);
515
+ setEmbeddingVerified(false);
516
+ }}
517
+ />
518
+ </div>
519
+ <div className="space-y-1.5">
520
+ <Label
521
+ htmlFor="emb-apikey"
522
+ className="text-xs text-muted-foreground"
523
+ >
524
+ API Key{" "}
525
+ <span className="text-muted-foreground/60">
526
+ (optional)
527
+ </span>
528
+ </Label>
529
+ <div className="relative">
530
+ <Input
531
+ id="emb-apikey"
532
+ type={showEmbeddingApiKey ? "text" : "password"}
533
+ placeholder="sk-..."
534
+ value={embeddingApiKey}
535
+ onChange={(e) => {
536
+ setEmbeddingApiKey(e.target.value);
537
+ setEmbeddingVerified(false);
538
+ }}
539
+ className="pr-10"
540
+ />
541
+ <button
542
+ type="button"
543
+ onClick={() =>
544
+ setShowEmbeddingApiKey(!showEmbeddingApiKey)
545
+ }
546
+ className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
547
+ >
548
+ {showEmbeddingApiKey ? (
549
+ <EyeOff className="size-4" />
550
+ ) : (
551
+ <Eye className="size-4" />
552
+ )}
553
+ </button>
554
+ </div>
555
+ </div>
556
+ {embeddingVerified ? (
557
+ <div className="flex items-center gap-1.5 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm font-medium text-green-700">
558
+ <CheckCircle2 className="size-4" />
559
+ Connected — {detectedDims} dimensions
560
+ </div>
561
+ ) : (
562
+ <Button
563
+ variant="outline"
564
+ size="sm"
565
+ onClick={testCustomEmbedding}
566
+ disabled={testingEmbedding}
567
+ >
568
+ {testingEmbedding ? (
569
+ <Loader2 className="size-3.5 animate-spin mr-1.5" />
570
+ ) : (
571
+ <ShieldCheck className="size-3.5 mr-1.5" />
572
+ )}
573
+ Test Connection
574
+ </Button>
575
+ )}
576
+ </div>
577
+ </div>
578
+
579
+ {/* LLM Connection */}
580
+ <div className="space-y-3 border-t pt-4">
581
+ <div className="flex items-center gap-2">
582
+ <span className="flex size-6 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-bold">
583
+ 2
584
+ </span>
585
+ <Label className="text-sm font-medium">
586
+ Chat / LLM Model
587
+ </Label>
588
+ </div>
589
+ <div className="grid gap-2.5 pl-8">
590
+ <div className="space-y-1.5">
591
+ <Label
592
+ htmlFor="llm-url"
593
+ className="text-xs text-muted-foreground"
594
+ >
595
+ Base URL
596
+ </Label>
597
+ <Input
598
+ id="llm-url"
599
+ placeholder="http://localhost:11434/v1"
600
+ value={llmBaseUrl}
601
+ onChange={(e) => setLlmBaseUrl(e.target.value)}
602
+ />
603
+ </div>
604
+ <div className="space-y-1.5">
605
+ <Label
606
+ htmlFor="llm-model"
607
+ className="text-xs text-muted-foreground"
608
+ >
609
+ Model Name
610
+ </Label>
611
+ <Input
612
+ id="llm-model"
613
+ placeholder="llama3.2"
614
+ value={llmModelName}
615
+ onChange={(e) => {
616
+ setLlmModelName(e.target.value);
617
+ setLlmVerified(false);
618
+ }}
619
+ />
620
+ </div>
621
+ <div className="space-y-1.5">
622
+ <Label
623
+ htmlFor="llm-apikey"
624
+ className="text-xs text-muted-foreground"
625
+ >
626
+ API Key{" "}
627
+ <span className="text-muted-foreground/60">
628
+ (optional)
629
+ </span>
630
+ </Label>
631
+ <div className="relative">
632
+ <Input
633
+ id="llm-apikey"
634
+ type={showLlmApiKey ? "text" : "password"}
635
+ placeholder="sk-..."
636
+ value={llmApiKey}
637
+ onChange={(e) => {
638
+ setLlmApiKey(e.target.value);
639
+ setLlmVerified(false);
640
+ }}
641
+ className="pr-10"
642
+ />
643
+ <button
644
+ type="button"
645
+ onClick={() => setShowLlmApiKey(!showLlmApiKey)}
646
+ className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
647
+ >
648
+ {showLlmApiKey ? (
649
+ <EyeOff className="size-4" />
650
+ ) : (
651
+ <Eye className="size-4" />
652
+ )}
653
+ </button>
654
+ </div>
655
+ </div>
656
+ {llmVerified ? (
657
+ <div className="flex items-center gap-1.5 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm font-medium text-green-700">
658
+ <CheckCircle2 className="size-4" />
659
+ LLM connected successfully
660
+ </div>
661
+ ) : (
662
+ <Button
663
+ variant="outline"
664
+ size="sm"
665
+ onClick={testCustomLlm}
666
+ disabled={testingLlm}
667
+ >
668
+ {testingLlm ? (
669
+ <Loader2 className="size-3.5 animate-spin mr-1.5" />
670
+ ) : (
671
+ <ShieldCheck className="size-3.5 mr-1.5" />
672
+ )}
673
+ Test LLM Connection
674
+ </Button>
675
+ )}
676
+ <p className="text-xs text-muted-foreground">
677
+ E.g. Ollama, LM Studio, or any OpenAI-compatible endpoint.
678
+ </p>
679
+ </div>
680
+ </div>
681
+ </>
682
+ )}
683
+ </div>
684
+
685
+ {/* Submit */}
686
+ <Button
687
+ className="w-full"
688
+ size="lg"
689
+ onClick={() => void handleFinish()}
690
+ disabled={busy || !canProceed}
691
+ >
692
+ {busy ? (
693
+ <Loader2 className="size-4 animate-spin" />
694
+ ) : (
695
+ <ArrowRight className="size-4" />
696
+ )}
697
+ Get Started
698
+ </Button>
699
+ </div>
700
+ </div>
701
+ );
702
+ }