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,1390 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useMemo, useRef } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import {
6
+ Rocket,
7
+ Loader2,
8
+ ChevronDown,
9
+ Eye,
10
+ EyeOff,
11
+ CheckCircle2,
12
+ Dices,
13
+ Copy,
14
+ Settings2,
15
+ KeyRound,
16
+ ShieldAlert,
17
+ } from "lucide-react";
18
+ import { toast } from "sonner";
19
+ import { deployToVercel, getServerEnvRequirements } from "@/app/actions/vercel";
20
+ // import removed
21
+ import {
22
+ DropdownMenu,
23
+ DropdownMenuContent,
24
+ DropdownMenuItem,
25
+ DropdownMenuTrigger,
26
+ DropdownMenuSub,
27
+ DropdownMenuSubTrigger,
28
+ DropdownMenuSubContent,
29
+ } from "@/components/ui/dropdown-menu";
30
+ import {
31
+ Dialog,
32
+ DialogContent,
33
+ DialogDescription,
34
+ DialogHeader,
35
+ DialogTitle,
36
+ DialogFooter,
37
+ } from "@/components/ui/dialog";
38
+ import {
39
+ Sheet,
40
+ SheetContent,
41
+ SheetDescription,
42
+ SheetHeader,
43
+ SheetTitle,
44
+ SheetFooter,
45
+ } from "@/components/ui/sheet";
46
+ import { Input } from "@/components/ui/input";
47
+ import { Label } from "@/components/ui/label";
48
+ import { Badge } from "@/components/ui/badge";
49
+ import {
50
+ Tooltip,
51
+ TooltipContent,
52
+ TooltipProvider,
53
+ TooltipTrigger,
54
+ } from "../ui/tooltip";
55
+
56
+ // ─── Storage helpers ──────────────────────────────────────────────────────────
57
+ // Token is stored globally so it loads on every server automatically.
58
+ // Project name and deployed URL are stored per-server (keyed by serverId).
59
+
60
+ const GLOBAL_TOKEN_KEY = "vercel_token";
61
+
62
+ function projectKey(serverId: string) {
63
+ return `vercel_project_${serverId}`;
64
+ }
65
+ function deployedUrlKey(serverId: string) {
66
+ return `vercel_deployed_url_${serverId}`;
67
+ }
68
+ function deployedProviderKey(serverId: string) {
69
+ return `vercel_deployed_provider_${serverId}`;
70
+ }
71
+
72
+ // ─── Vercel triangle SVG icon ─────────────────────────────────────────────────
73
+ function VercelIcon({ className }: { className?: string }) {
74
+ return (
75
+ <svg
76
+ viewBox="0 0 24 24"
77
+ fill="currentColor"
78
+ className={className}
79
+ aria-hidden="true"
80
+ >
81
+ <path d="M12 2L2 19.778h20L12 2z" />
82
+ </svg>
83
+ );
84
+ }
85
+
86
+ // ─── Component ────────────────────────────────────────────────────────────────
87
+
88
+ interface DeployButtonProps {
89
+ /** Unique identifier for this server instance (used to scope the project name). */
90
+ serverId?: string;
91
+ }
92
+
93
+ export function DeployButton({ serverId = "default" }: DeployButtonProps) {
94
+ const [vercelModalOpen, setVercelModalOpen] = useState(false);
95
+ const [envModalOpen, setEnvModalOpen] = useState(false);
96
+ const [vercelToken, setVercelToken] = useState("");
97
+ const [vercelProject, setVercelProject] = useState("");
98
+ const [apiKey, setApiKey] = useState("");
99
+ const [isDeploying, setIsDeploying] = useState(false);
100
+ const [showToken, setShowToken] = useState(false);
101
+ const [showApiKey, setShowApiKey] = useState(false);
102
+ const [showEnvVars, setShowEnvVars] = useState<Record<string, boolean>>({});
103
+
104
+ // ── SSH Deployment State
105
+ const [sshModalOpen, setSshModalOpen] = useState(false);
106
+ const [sshHost, setSshHost] = useState("");
107
+ const [sshUsername, setSshUsername] = useState("root");
108
+ const [sshAuthType, setSshAuthType] = useState<"key" | "password">(
109
+ "password",
110
+ );
111
+ const [sshKeyOrPassword, setSshKeyOrPassword] = useState("");
112
+ const [showSshPassword, setShowSshPassword] = useState(false);
113
+
114
+ // ── Password Change Dialog State (Hetzner first-login)
115
+ const [passwordChangeOpen, setPasswordChangeOpen] = useState(false);
116
+ const [newSshPassword, setNewSshPassword] = useState("");
117
+ const [confirmNewSshPassword, setConfirmNewSshPassword] = useState("");
118
+ const [showNewSshPassword, setShowNewSshPassword] = useState(false);
119
+ const [showConfirmSshPassword, setShowConfirmSshPassword] = useState(false);
120
+ // Store deploy params so we can retry after password change
121
+ const pendingDeployRef = useRef<{
122
+ host: string;
123
+ username: string;
124
+ auth: { type: string; value: string };
125
+ envVars: Record<string, string>;
126
+ } | null>(null);
127
+
128
+ // Dynamic Environment Variables
129
+ const [requiredEnv, setRequiredEnv] = useState<
130
+ { key: string; help: string; required: boolean; defaultValue?: string }[]
131
+ >([]);
132
+ const [envValues, setEnvValues] = useState<Record<string, string>>({});
133
+ const [sshLogs, setSshLogs] = useState<string[]>([]);
134
+
135
+ // ── Was the token already saved globally?
136
+ const [tokenWasSaved, setTokenWasSaved] = useState(false);
137
+
138
+ // ── Persisted deployment info (shown in UI after successful deploy)
139
+ const [deployedUrl, setDeployedUrl] = useState<string | null>(null);
140
+ const [deployedProvider, setDeployedProvider] = useState<string | null>(null);
141
+
142
+ // ── Env summary counts
143
+ const envSummary = useMemo(() => {
144
+ const total = requiredEnv.length + 1; // +1 for SERVER_API_KEY
145
+ const filled =
146
+ requiredEnv.filter((e) => !!envValues[e.key]).length + (apiKey ? 1 : 0);
147
+ const requiredMissing = requiredEnv.filter(
148
+ (e) => e.required && !envValues[e.key],
149
+ ).length;
150
+ return { total, filled, requiredMissing };
151
+ }, [requiredEnv, envValues, apiKey]);
152
+
153
+ // Load on mount: token is global, project/url are per-server
154
+ useEffect(() => {
155
+ const savedToken = localStorage.getItem(GLOBAL_TOKEN_KEY);
156
+ const savedProject = localStorage.getItem(projectKey(serverId));
157
+ const savedUrl = localStorage.getItem(deployedUrlKey(serverId));
158
+ const savedProvider = localStorage.getItem(deployedProviderKey(serverId));
159
+ const savedApiKey = localStorage.getItem("rag_server_api_key");
160
+
161
+ if (savedApiKey) {
162
+ setApiKey(savedApiKey);
163
+ }
164
+ if (savedToken) {
165
+ setVercelToken(savedToken);
166
+ setTokenWasSaved(true);
167
+ }
168
+ if (savedProject) {
169
+ setVercelProject(savedProject);
170
+ }
171
+ if (savedUrl) {
172
+ setDeployedUrl(savedUrl);
173
+ }
174
+ if (savedProvider) {
175
+ setDeployedProvider(savedProvider);
176
+ }
177
+ }, [serverId]);
178
+
179
+ useEffect(() => {
180
+ if ((vercelModalOpen || envModalOpen || sshModalOpen) && serverId) {
181
+ getServerEnvRequirements(serverId).then((reqs) => {
182
+ setRequiredEnv(reqs);
183
+ // Only pre-fill keys that are present in the CURRENT config's .env.example.
184
+ // This prevents stale keys from a previously-selected store (e.g. LanceDB vars
185
+ // bleeding into a Pinecone configuration) from appearing in the sheet.
186
+ const savedEnvStr = localStorage.getItem("rag_server_env_vars");
187
+ const savedEnv = savedEnvStr ? JSON.parse(savedEnvStr) : {};
188
+
189
+ const initialVals: Record<string, string> = {};
190
+ for (const req of reqs) {
191
+ // Only use the saved value if the key is actually required by the current config.
192
+ initialVals[req.key] = savedEnv[req.key] || req.defaultValue || "";
193
+ }
194
+ // Replace envValues wholesale — clear any keys from a previous store selection.
195
+ setEnvValues(initialVals);
196
+ });
197
+ }
198
+ }, [vercelModalOpen, envModalOpen, sshModalOpen, serverId]);
199
+
200
+ const handleSaveEnv = async () => {
201
+ if (apiKey) {
202
+ localStorage.setItem("rag_server_api_key", apiKey);
203
+ } else {
204
+ localStorage.removeItem("rag_server_api_key");
205
+ }
206
+ localStorage.setItem("rag_server_env_vars", JSON.stringify(envValues));
207
+ setEnvModalOpen(false);
208
+
209
+ // Check if the local server is running — if so, restart it with the new key.
210
+ try {
211
+ const statusRes = await fetch("/api/server/local");
212
+ const { state } = await statusRes.json();
213
+
214
+ if (state?.running) {
215
+ toast.loading("Restarting local server with new API key…", {
216
+ id: "restart",
217
+ });
218
+ const restartRes = await fetch("/api/server/local", {
219
+ method: "POST",
220
+ headers: { "Content-Type": "application/json" },
221
+ body: JSON.stringify({ action: "start", serverApiKey: apiKey || "" }),
222
+ });
223
+ const body = await restartRes.json();
224
+ if (body.state?.running) {
225
+ toast.success("Local server restarted with new API key.", {
226
+ id: "restart",
227
+ });
228
+ } else {
229
+ toast.error(
230
+ body.state?.lastError ?? "Restart failed — relaunch manually.",
231
+ { id: "restart" },
232
+ );
233
+ }
234
+ } else {
235
+ toast.success("Environment configuration saved.");
236
+ }
237
+ } catch {
238
+ toast.success("Environment configuration saved.");
239
+ }
240
+ };
241
+
242
+ const handleClearToken = () => {
243
+ localStorage.removeItem(GLOBAL_TOKEN_KEY);
244
+ setVercelToken("");
245
+ setTokenWasSaved(false);
246
+ toast.info("Vercel token cleared.");
247
+ };
248
+
249
+ const handleSSHDeploy = () => {
250
+ setSshLogs([]);
251
+ setSshModalOpen(true);
252
+ };
253
+
254
+ const handleDownload = (provider: string) => {
255
+ toast.success(`Downloading deployment package for ${provider}...`);
256
+ window.open(`/api/server/download?provider=${provider}`);
257
+ };
258
+
259
+ const handleGenerateKey = () => {
260
+ const randomKey =
261
+ "larkup-" +
262
+ Math.random().toString(36).substring(2, 15) +
263
+ Math.random().toString(36).substring(2, 15);
264
+ setApiKey(randomKey);
265
+ localStorage.setItem("rag_server_api_key", randomKey);
266
+ };
267
+
268
+ const handleCopyKey = () => {
269
+ if (apiKey) {
270
+ navigator.clipboard.writeText(apiKey);
271
+ toast.success("Server API Key copied to clipboard.");
272
+ }
273
+ };
274
+
275
+ const handleDeployToVercel = async () => {
276
+ if (!vercelToken || !vercelProject) {
277
+ toast.error("Please enter both a Vercel Token and a Project name.");
278
+ return;
279
+ }
280
+
281
+ for (const req of requiredEnv) {
282
+ if (req.required && !envValues[req.key]) {
283
+ toast.error(
284
+ `Please provide a value for ${req.key}. Open "Configure Environment" to set it.`,
285
+ );
286
+ return;
287
+ }
288
+ }
289
+
290
+ setIsDeploying(true);
291
+ toast.info("Uploading files and triggering deployment on Vercel…");
292
+
293
+ try {
294
+ // Merge user inputs
295
+ const finalEnvVars = { ...envValues };
296
+ if (apiKey) {
297
+ finalEnvVars["SERVER_API_KEY"] = apiKey;
298
+ localStorage.setItem("rag_server_api_key", apiKey);
299
+ }
300
+
301
+ const res = await deployToVercel(
302
+ vercelToken,
303
+ vercelProject,
304
+ serverId,
305
+ finalEnvVars,
306
+ );
307
+
308
+ if (res.success) {
309
+ // ── Persist: token globally, project + url per-server
310
+ localStorage.setItem(GLOBAL_TOKEN_KEY, vercelToken);
311
+ localStorage.setItem(projectKey(serverId), vercelProject);
312
+ localStorage.setItem(deployedUrlKey(serverId), res.url!);
313
+ localStorage.setItem(deployedProviderKey(serverId), "vercel");
314
+ setTokenWasSaved(true);
315
+ setDeployedUrl(res.url!);
316
+ setDeployedProvider("vercel");
317
+
318
+ const title = res.projectCreated
319
+ ? "Project created & files deployed!"
320
+ : "Server files deployed successfully!";
321
+
322
+ toast.success(
323
+ <div className="flex flex-col gap-1">
324
+ <span>{title}</span>
325
+ {res.url && (
326
+ <a
327
+ href={res.url}
328
+ target="_blank"
329
+ rel="noopener noreferrer"
330
+ className="underline font-medium hover:text-blue-500 transition-colors"
331
+ >
332
+ View on Vercel ↗
333
+ </a>
334
+ )}
335
+ {res.projectCreated && (
336
+ <span className="text-xs text-muted-foreground">
337
+ Your backend is now live.
338
+ </span>
339
+ )}
340
+ </div>,
341
+ );
342
+ setVercelModalOpen(false);
343
+ } else {
344
+ toast.error(res.error || "Failed to deploy files to Vercel.");
345
+ }
346
+ } catch (e: any) {
347
+ toast.error(e.message || "An unexpected error occurred.");
348
+ } finally {
349
+ setIsDeploying(false);
350
+ }
351
+ };
352
+
353
+ /** Core SSH deploy logic — optionally accepts a newPassword for expired-password retry */
354
+ const executeSSHDeploy = async (overrideNewPassword?: string) => {
355
+ setIsDeploying(true);
356
+ toast.info("Connecting to server and deploying via SSH...", {
357
+ duration: 10000,
358
+ });
359
+
360
+ const deployHost = pendingDeployRef.current?.host || sshHost;
361
+ const deployUser = pendingDeployRef.current?.username || sshUsername;
362
+ const deployAuth = pendingDeployRef.current?.auth || {
363
+ type: sshAuthType,
364
+ value: sshKeyOrPassword,
365
+ };
366
+ const deployEnv =
367
+ pendingDeployRef.current?.envVars ||
368
+ (() => {
369
+ const finalEnvVars = { ...envValues };
370
+ if (apiKey) {
371
+ finalEnvVars["SERVER_API_KEY"] = apiKey;
372
+ localStorage.setItem("rag_server_api_key", apiKey);
373
+ }
374
+ return finalEnvVars;
375
+ })();
376
+
377
+ try {
378
+ const bodyPayload: Record<string, any> = {
379
+ serverId,
380
+ host: deployHost,
381
+ username: deployUser,
382
+ privateKeyOrPassword: deployAuth,
383
+ envVars: deployEnv,
384
+ };
385
+ if (overrideNewPassword) {
386
+ bodyPayload.newPassword = overrideNewPassword;
387
+ }
388
+
389
+ const response = await fetch("/api/deploy/ssh", {
390
+ method: "POST",
391
+ headers: { "Content-Type": "application/json" },
392
+ body: JSON.stringify(bodyPayload),
393
+ });
394
+
395
+ if (!response.body) throw new Error("No response from server.");
396
+
397
+ const reader = response.body.getReader();
398
+ const decoder = new TextDecoder();
399
+ let doneReading = false;
400
+ let buffer = "";
401
+
402
+ while (!doneReading) {
403
+ const { value, done } = await reader.read();
404
+ doneReading = done;
405
+ if (value) {
406
+ buffer += decoder.decode(value, { stream: true });
407
+ const parts = buffer.split("\n\n");
408
+ buffer = parts.pop() || "";
409
+ for (const part of parts) {
410
+ if (part.startsWith("data: ")) {
411
+ try {
412
+ const data = JSON.parse(part.slice(6));
413
+ if (data.type === "log") {
414
+ setSshLogs((prev) => [...prev, data.message]);
415
+ } else if (data.type === "password_change_required") {
416
+ // Server's password is expired — ask the user for a new one
417
+ pendingDeployRef.current = {
418
+ host: deployHost,
419
+ username: deployUser,
420
+ auth: deployAuth,
421
+ envVars: deployEnv,
422
+ };
423
+ setIsDeploying(false);
424
+ setNewSshPassword("");
425
+ setConfirmNewSshPassword("");
426
+ setPasswordChangeOpen(true);
427
+ return; // stop processing this stream
428
+ } else if (data.type === "password_changed") {
429
+ navigator.clipboard.writeText(data.newPassword);
430
+ toast.warning(
431
+ <div className="flex flex-col gap-1">
432
+ <span className="font-semibold">🔑 Password Changed</span>
433
+ <p className="text-xs">
434
+ Server password updated successfully:
435
+ </p>
436
+ <code className="block p-1 bg-background border border-border rounded font-mono text-xs select-all">
437
+ {data.newPassword}
438
+ </code>
439
+ <p className="text-xs text-muted-foreground">
440
+ Copied to clipboard. Save this!
441
+ </p>
442
+ </div>,
443
+ { duration: 60000 },
444
+ );
445
+ } else if (data.type === "done") {
446
+ if (data.success) {
447
+ localStorage.setItem(deployedUrlKey(serverId), data.url!);
448
+ localStorage.setItem(
449
+ deployedProviderKey(serverId),
450
+ "hetzner",
451
+ );
452
+ setDeployedUrl(data.url!);
453
+ setDeployedProvider("hetzner");
454
+
455
+ toast.success(
456
+ <div className="flex flex-col gap-1">
457
+ <span>Server deployed via SSH successfully!</span>
458
+ {data.url && (
459
+ <a
460
+ href={data.url}
461
+ target="_blank"
462
+ rel="noopener noreferrer"
463
+ className="underline font-medium hover:text-blue-500 transition-colors"
464
+ >
465
+ View Live Deployment ↗
466
+ </a>
467
+ )}
468
+ {data.newPassword && (
469
+ <div className="mt-2 p-2 bg-black/10 dark:bg-white/10 rounded text-xs">
470
+ <p className="font-semibold text-destructive mb-1">
471
+ Password was changed during deployment
472
+ </p>
473
+ <p>Your new root password is:</p>
474
+ <code className="block mt-1 p-1 bg-background border border-border rounded font-mono select-all">
475
+ {data.newPassword}
476
+ </code>
477
+ <p className="mt-1 text-muted-foreground">
478
+ It has been copied to your clipboard.
479
+ </p>
480
+ </div>
481
+ )}
482
+ </div>,
483
+ { duration: data.newPassword ? 30000 : undefined },
484
+ );
485
+
486
+ if (data.newPassword) {
487
+ navigator.clipboard.writeText(data.newPassword);
488
+ }
489
+ pendingDeployRef.current = null;
490
+ setSshModalOpen(false);
491
+ } else {
492
+ toast.error(data.error || "Failed to deploy via SSH.");
493
+ }
494
+ } else if (data.type === "error") {
495
+ toast.error(data.error || "Failed to deploy via SSH.");
496
+ }
497
+ } catch (e) {}
498
+ }
499
+ }
500
+ }
501
+ }
502
+ } catch (e: any) {
503
+ toast.error(e.message || "An unexpected error occurred.");
504
+ } finally {
505
+ setIsDeploying(false);
506
+ }
507
+ };
508
+
509
+ const handleDeployToSSH = async () => {
510
+ if (!sshHost || !sshUsername || !sshKeyOrPassword) {
511
+ toast.error("Please fill in all SSH connection details.");
512
+ return;
513
+ }
514
+
515
+ for (const req of requiredEnv) {
516
+ if (req.required && !envValues[req.key]) {
517
+ toast.error(`Please provide a value for ${req.key}.`);
518
+ return;
519
+ }
520
+ }
521
+
522
+ setSshLogs([]);
523
+ pendingDeployRef.current = null;
524
+ await executeSSHDeploy();
525
+ };
526
+
527
+ /** Called when the user submits the password-change dialog */
528
+ const handlePasswordChangeAndRedeploy = async () => {
529
+ if (!newSshPassword) {
530
+ toast.error("Please enter a new password.");
531
+ return;
532
+ }
533
+ if (newSshPassword !== confirmNewSshPassword) {
534
+ toast.error("Passwords do not match.");
535
+ return;
536
+ }
537
+ if (newSshPassword.length < 8) {
538
+ toast.error("Password must be at least 8 characters.");
539
+ return;
540
+ }
541
+ setPasswordChangeOpen(false);
542
+ setSshLogs((prev) => [...prev, "", "--- Retrying with new password ---"]);
543
+ await executeSSHDeploy(newSshPassword);
544
+ };
545
+
546
+ return (
547
+ <div className="flex items-center gap-2">
548
+ <TooltipProvider>
549
+ <Tooltip>
550
+ <TooltipTrigger
551
+ delay={0}
552
+ render={
553
+ <Button
554
+ variant="outline"
555
+ size="icon"
556
+ className="h-9 w-9 shrink-0 text-muted-foreground hover:text-foreground"
557
+ onClick={() => setEnvModalOpen(true)}
558
+ >
559
+ <Settings2 className="size-4" />
560
+ </Button>
561
+ }
562
+ />
563
+ <TooltipContent>
564
+ <p>Environment Variables</p>
565
+ </TooltipContent>
566
+ </Tooltip>
567
+ </TooltipProvider>
568
+ <Button
569
+ variant="outline"
570
+ className="h-9 gap-1.5 bg-muted/80 text-xs text-black/80 hover:text-black hover:bg-muted "
571
+ onClick={() => {
572
+ const currentKey =
573
+ apiKey || localStorage.getItem("rag_server_api_key");
574
+ if (currentKey) {
575
+ navigator.clipboard.writeText(currentKey);
576
+ toast.success("Server API Key copied to clipboard.");
577
+ } else {
578
+ toast.error("No Server API Key is set.");
579
+ }
580
+ }}
581
+ title="Copy Server API Key"
582
+ >
583
+ <KeyRound className="size-3.5" />
584
+ Copy API Key
585
+ </Button>
586
+
587
+ <DropdownMenu>
588
+ <DropdownMenuTrigger
589
+ render={
590
+ <Button
591
+ variant="default"
592
+ size="default"
593
+ className="h-9 w-[130px] justify-start gap-1"
594
+ />
595
+ }
596
+ >
597
+ <div className="w-full flex items-center justify-between">
598
+ <div className="flex items-center">
599
+ <Rocket className="size-4 mr-2" />
600
+ Deploy
601
+ </div>
602
+ <ChevronDown />
603
+ </div>
604
+ </DropdownMenuTrigger>
605
+ <DropdownMenuContent align="end" className={"w-[200px]"}>
606
+ <DropdownMenuItem onClick={() => setVercelModalOpen(true)}>
607
+ <p className="flex items-center">
608
+ <img src={"/vercel.svg"} className="size-4 mr-2" alt="vercel" />
609
+ Vercel
610
+ </p>
611
+ </DropdownMenuItem>
612
+ <DropdownMenuItem onClick={handleSSHDeploy}>
613
+ <p className="flex items-center">
614
+ <img src={"/hetzner.svg"} className="size-4 mr-2" alt="vercel" />
615
+ Hetzner
616
+ </p>
617
+ </DropdownMenuItem>
618
+ <DropdownMenuSub>
619
+ <DropdownMenuSubTrigger>
620
+ <img src="/icons/azure.svg" className="size-4 mr-2" alt="Azure" />
621
+ Azure
622
+ </DropdownMenuSubTrigger>
623
+ <DropdownMenuSubContent className="w-[240px]">
624
+ <DropdownMenuItem
625
+ disabled
626
+ className="h-auto"
627
+ onClick={() => handleDownload("azure-app-service")}
628
+ >
629
+ <div className="flex flex-col gap-0.5">
630
+ <span>App Service</span>
631
+ <span className="text-[10px] text-muted-foreground">
632
+ Managed PaaS
633
+ </span>
634
+ </div>
635
+ </DropdownMenuItem>
636
+ <DropdownMenuItem
637
+ disabled
638
+ className="h-auto"
639
+ onClick={() => handleDownload("azure-container-apps")}
640
+ >
641
+ <div className="flex flex-col gap-0.5">
642
+ <span>Container Apps</span>
643
+ <span className="text-[10px] text-muted-foreground">
644
+ Serverless containers
645
+ </span>
646
+ </div>
647
+ </DropdownMenuItem>
648
+ <DropdownMenuItem className="h-auto" onClick={handleSSHDeploy}>
649
+ <div className="flex flex-col gap-0.5">
650
+ <span>Virtual Machines</span>
651
+ <span className="text-[10px] text-muted-foreground">
652
+ Full IaaS
653
+ </span>
654
+ </div>
655
+ </DropdownMenuItem>
656
+ </DropdownMenuSubContent>
657
+ </DropdownMenuSub>
658
+
659
+ <DropdownMenuSub>
660
+ <DropdownMenuSubTrigger>
661
+ <img src="/icons/aws.svg" className="size-4 mr-2" alt="AWS" />
662
+ AWS
663
+ </DropdownMenuSubTrigger>
664
+ <DropdownMenuSubContent className="w-[240px] ">
665
+ <DropdownMenuItem
666
+ disabled
667
+ className="h-auto"
668
+ onClick={() => handleDownload("aws-elastic-beanstalk")}
669
+ >
670
+ <div className="flex flex-col gap-0.5">
671
+ <span>Elastic Beanstalk</span>
672
+ <span className="text-[10px] text-muted-foreground">
673
+ Managed PaaS
674
+ </span>
675
+ </div>
676
+ </DropdownMenuItem>
677
+ <DropdownMenuItem
678
+ disabled
679
+ className="h-auto"
680
+ onClick={() => handleDownload("aws-app-runner")}
681
+ >
682
+ <div className="flex flex-col gap-0.5">
683
+ <span>App Runner / Fargate</span>
684
+ <span className="text-[10px] text-muted-foreground">
685
+ Serverless containers
686
+ </span>
687
+ </div>
688
+ </DropdownMenuItem>
689
+ <DropdownMenuItem className="h-auto" onClick={handleSSHDeploy}>
690
+ <div className="flex flex-col gap-0.5">
691
+ <span>EC2</span>
692
+ <span className="text-[10px] text-muted-foreground">
693
+ Full IaaS
694
+ </span>
695
+ </div>
696
+ </DropdownMenuItem>
697
+ </DropdownMenuSubContent>
698
+ </DropdownMenuSub>
699
+
700
+ <DropdownMenuSub>
701
+ <DropdownMenuSubTrigger>
702
+ <img src="/icons/gcp.svg" className="size-4 mr-2" alt="GCP" />
703
+ GCP
704
+ </DropdownMenuSubTrigger>
705
+ <DropdownMenuSubContent className="w-[240px] ">
706
+ <DropdownMenuItem
707
+ disabled
708
+ className="h-auto"
709
+ onClick={() => handleDownload("gcp-app-engine")}
710
+ >
711
+ <div className="flex flex-col gap-0.5">
712
+ <span>App Engine</span>
713
+ <span className="text-[10px] text-muted-foreground">
714
+ Managed PaaS
715
+ </span>
716
+ </div>
717
+ </DropdownMenuItem>
718
+ <DropdownMenuItem
719
+ disabled
720
+ className="h-auto"
721
+ onClick={() => handleDownload("gcp-cloud-run")}
722
+ >
723
+ <div className="flex flex-col gap-0.5">
724
+ <span>Cloud Run</span>
725
+ <span className="text-[10px] text-muted-foreground">
726
+ Serverless containers
727
+ </span>
728
+ </div>
729
+ </DropdownMenuItem>
730
+ <DropdownMenuItem className="h-auto" onClick={handleSSHDeploy}>
731
+ <div className="flex flex-col gap-0.5">
732
+ <span>Compute Engine</span>
733
+ <span className="text-[10px] text-muted-foreground">
734
+ Full IaaS
735
+ </span>
736
+ </div>
737
+ </DropdownMenuItem>
738
+ </DropdownMenuSubContent>
739
+ </DropdownMenuSub>
740
+
741
+ <DropdownMenuSub>
742
+ <DropdownMenuSubTrigger>
743
+ <img
744
+ src="/icons/digital-ocean.webp"
745
+ className="size-4 mr-2"
746
+ alt="DigitalOcean"
747
+ />
748
+ DigitalOcean
749
+ </DropdownMenuSubTrigger>
750
+ <DropdownMenuSubContent className="w-[240px] ">
751
+ <DropdownMenuItem
752
+ disabled
753
+ className="h-auto"
754
+ onClick={() => handleDownload("do-app-platform")}
755
+ >
756
+ <div className="flex flex-col gap-0.5">
757
+ <span>App Platform</span>
758
+ <span className="text-[10px] text-muted-foreground">
759
+ Managed PaaS / Containers
760
+ </span>
761
+ </div>
762
+ </DropdownMenuItem>
763
+ <DropdownMenuItem className="h-auto" onClick={handleSSHDeploy}>
764
+ <div className="flex flex-col gap-0.5">
765
+ <span>Droplets</span>
766
+ <span className="text-[10px] text-muted-foreground">
767
+ Full IaaS
768
+ </span>
769
+ </div>
770
+ </DropdownMenuItem>
771
+ </DropdownMenuSubContent>
772
+ </DropdownMenuSub>
773
+ </DropdownMenuContent>
774
+ </DropdownMenu>
775
+
776
+ {/* ── Main Deploy Modal ── */}
777
+ <Dialog open={vercelModalOpen} onOpenChange={setVercelModalOpen}>
778
+ <DialogContent className="max-w-md">
779
+ <DialogHeader>
780
+ <DialogTitle>Deploy to Vercel</DialogTitle>
781
+ <DialogDescription>
782
+ Configure your Vercel project and deploy your RAG server.
783
+ </DialogDescription>
784
+ </DialogHeader>
785
+
786
+ <div className="grid gap-5 py-4">
787
+ {/* ── Token ── */}
788
+ <div className="grid gap-2">
789
+ <div className="flex items-center justify-between">
790
+ <div className="flex items-center gap-2">
791
+ <Label htmlFor="vercel-token">Vercel Access Token</Label>
792
+ {tokenWasSaved && (
793
+ <span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400 font-medium">
794
+ <CheckCircle2 className="size-3" />
795
+ Saved
796
+ </span>
797
+ )}
798
+ </div>
799
+ <div className="flex items-center gap-2">
800
+ <a
801
+ href="https://vercel.com/account/tokens"
802
+ target="_blank"
803
+ rel="noopener noreferrer"
804
+ className="text-xs text-muted-foreground underline hover:text-foreground transition-colors"
805
+ >
806
+ Get token ↗
807
+ </a>
808
+ {tokenWasSaved && (
809
+ <button
810
+ type="button"
811
+ onClick={handleClearToken}
812
+ className="text-xs text-destructive underline hover:opacity-80 transition-opacity"
813
+ >
814
+ Clear
815
+ </button>
816
+ )}
817
+ </div>
818
+ </div>
819
+
820
+ <div className="relative">
821
+ <Input
822
+ id="vercel-token"
823
+ type={showToken ? "text" : "password"}
824
+ placeholder="Enter your Vercel Access Token"
825
+ value={vercelToken}
826
+ onChange={(e) => {
827
+ setVercelToken(e.target.value);
828
+ setTokenWasSaved(false);
829
+ }}
830
+ className="pr-9"
831
+ />
832
+ <button
833
+ type="button"
834
+ onClick={() => setShowToken((v) => !v)}
835
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
836
+ tabIndex={-1}
837
+ >
838
+ {showToken ? (
839
+ <EyeOff className="size-4" />
840
+ ) : (
841
+ <Eye className="size-4" />
842
+ )}
843
+ </button>
844
+ </div>
845
+ </div>
846
+
847
+ {/* ── Project ── */}
848
+ <div className="grid gap-2">
849
+ <Label htmlFor="vercel-project">
850
+ Project ID or Name
851
+ <span className="ml-2 text-xs text-muted-foreground font-normal">
852
+ (saved per server)
853
+ </span>
854
+ </Label>
855
+ <Input
856
+ id="vercel-project"
857
+ placeholder="my-rag-server"
858
+ value={vercelProject}
859
+ onChange={(e) => setVercelProject(e.target.value)}
860
+ />
861
+ </div>
862
+
863
+ {/* ── Environment Variables Summary + Button ── */}
864
+ <div className="border-t border-border pt-4 mt-1">
865
+ <div className="flex items-center justify-between">
866
+ <div className="flex items-center gap-2">
867
+ <h4 className="text-sm font-medium">Environment Variables</h4>
868
+ {envSummary.filled > 0 && (
869
+ <Badge
870
+ variant="secondary"
871
+ className="text-[11px] font-normal tabular-nums"
872
+ >
873
+ {envSummary.filled}/{envSummary.total} configured
874
+ </Badge>
875
+ )}
876
+ {envSummary.requiredMissing > 0 && (
877
+ <Badge
878
+ variant="destructive"
879
+ className="text-[11px] font-normal"
880
+ >
881
+ {envSummary.requiredMissing} required
882
+ </Badge>
883
+ )}
884
+ {envSummary.requiredMissing === 0 &&
885
+ envSummary.filled > 0 && (
886
+ <CheckCircle2 className="size-3.5 text-emerald-500" />
887
+ )}
888
+ </div>
889
+ <Button
890
+ variant="outline"
891
+ size="sm"
892
+ className="h-6 text-[11px] gap-1.5"
893
+ onClick={() => setEnvModalOpen(true)}
894
+ >
895
+ <Settings2 className="size-3.5" />
896
+ Configure
897
+ </Button>
898
+ </div>
899
+ <p className="text-xs text-muted-foreground mt-1.5">
900
+ API keys and settings your deployed server needs to run.
901
+ </p>
902
+ </div>
903
+
904
+ {/* ── Current deployed URL ── */}
905
+ {deployedUrl && (
906
+ <div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-xs">
907
+ <VercelIcon className="size-3 shrink-0 text-foreground" />
908
+ <span className="text-muted-foreground">
909
+ Currently deployed at:
910
+ </span>
911
+ <a
912
+ href={deployedUrl}
913
+ target="_blank"
914
+ rel="noopener noreferrer"
915
+ className="font-mono font-medium text-foreground hover:underline truncate"
916
+ >
917
+ {deployedUrl.replace(/^https?:\/\//, "")}
918
+ </a>
919
+ </div>
920
+ )}
921
+ </div>
922
+
923
+ <DialogFooter>
924
+ <Button
925
+ variant="outline"
926
+ onClick={() => setVercelModalOpen(false)}
927
+ disabled={isDeploying}
928
+ >
929
+ Cancel
930
+ </Button>
931
+ <Button onClick={handleDeployToVercel} disabled={isDeploying}>
932
+ {isDeploying && <Loader2 className="mr-2 size-4 animate-spin" />}
933
+ Deploy
934
+ </Button>
935
+ </DialogFooter>
936
+ </DialogContent>
937
+ </Dialog>
938
+
939
+ {/* ── Main SSH Modal ── */}
940
+ <Dialog open={sshModalOpen} onOpenChange={setSshModalOpen}>
941
+ <DialogContent className="max-w-md">
942
+ <DialogHeader>
943
+ <DialogTitle>Deploy via SSH (Hetzner, EC2, Droplets)</DialogTitle>
944
+ <DialogDescription>
945
+ BuddyHere will automatically connect, install Docker, and launch
946
+ your server.
947
+ </DialogDescription>
948
+ </DialogHeader>
949
+
950
+ <div className="grid gap-5 py-4">
951
+ <div className="grid gap-2">
952
+ <Label htmlFor="ssh-host">Server IP / Hostname</Label>
953
+ <Input
954
+ id="ssh-host"
955
+ placeholder="e.g. 192.168.1.1 or myserver.com"
956
+ value={sshHost}
957
+ onChange={(e) => setSshHost(e.target.value)}
958
+ />
959
+ </div>
960
+
961
+ <div className="grid gap-2">
962
+ <Label htmlFor="ssh-user">SSH Username</Label>
963
+ <Input
964
+ id="ssh-user"
965
+ placeholder="root"
966
+ value={sshUsername}
967
+ onChange={(e) => setSshUsername(e.target.value)}
968
+ />
969
+ </div>
970
+
971
+ <div className="grid gap-2">
972
+ <div className="flex items-center justify-between">
973
+ <Label>Authentication Type</Label>
974
+ <div className="flex gap-2">
975
+ <Badge
976
+ variant={sshAuthType === "key" ? "default" : "outline"}
977
+ className="cursor-pointer"
978
+ onClick={() => setSshAuthType("key")}
979
+ >
980
+ Private Key
981
+ </Badge>
982
+ <Badge
983
+ variant={sshAuthType === "password" ? "default" : "outline"}
984
+ className="cursor-pointer"
985
+ onClick={() => setSshAuthType("password")}
986
+ >
987
+ Password
988
+ </Badge>
989
+ </div>
990
+ </div>
991
+
992
+ {sshAuthType === "key" ? (
993
+ <textarea
994
+ className="flex min-h-[120px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 font-mono"
995
+ placeholder="-----BEGIN OPENSSH PRIVATE KEY-----\n..."
996
+ value={sshKeyOrPassword}
997
+ onChange={(e) => setSshKeyOrPassword(e.target.value)}
998
+ />
999
+ ) : (
1000
+ <div className="relative">
1001
+ <Input
1002
+ type={showSshPassword ? "text" : "password"}
1003
+ placeholder="SSH Password"
1004
+ value={sshKeyOrPassword}
1005
+ onChange={(e) => setSshKeyOrPassword(e.target.value)}
1006
+ className="pr-10"
1007
+ />
1008
+ <Button
1009
+ type="button"
1010
+ variant="ghost"
1011
+ size="sm"
1012
+ className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
1013
+ onClick={() => setShowSshPassword(!showSshPassword)}
1014
+ >
1015
+ {showSshPassword ? (
1016
+ <EyeOff className="h-4 w-4 text-muted-foreground" />
1017
+ ) : (
1018
+ <Eye className="h-4 w-4 text-muted-foreground" />
1019
+ )}
1020
+ </Button>
1021
+ </div>
1022
+ )}
1023
+ </div>
1024
+
1025
+ {isDeploying && sshLogs.length > 0 && (
1026
+ <div className="mt-2 bg-black text-[#00ff00] font-mono text-[10px] p-3 rounded-md h-[200px] overflow-y-auto break-all whitespace-pre-wrap leading-tight shadow-inner flex flex-col-reverse">
1027
+ <div>
1028
+ {sshLogs.map((log, i) => (
1029
+ <div key={i}>{log}</div>
1030
+ ))}
1031
+ </div>
1032
+ </div>
1033
+ )}
1034
+
1035
+ {/* ── Environment Variables Summary + Button ── */}
1036
+ <div className="border-t border-border pt-4 mt-1">
1037
+ <div className="flex items-center justify-between">
1038
+ <div className="flex items-center gap-2">
1039
+ <h4 className="text-sm font-medium">Environment Variables</h4>
1040
+ {envSummary.filled > 0 && (
1041
+ <Badge
1042
+ variant="secondary"
1043
+ className="text-[11px] font-normal tabular-nums"
1044
+ >
1045
+ {envSummary.filled}/{envSummary.total} configured
1046
+ </Badge>
1047
+ )}
1048
+ {envSummary.requiredMissing > 0 && (
1049
+ <Badge
1050
+ variant="destructive"
1051
+ className="text-[11px] font-normal"
1052
+ >
1053
+ {envSummary.requiredMissing} required
1054
+ </Badge>
1055
+ )}
1056
+ {envSummary.requiredMissing === 0 &&
1057
+ envSummary.filled > 0 && (
1058
+ <CheckCircle2 className="size-3.5 text-emerald-500" />
1059
+ )}
1060
+ </div>
1061
+ <Button
1062
+ variant="outline"
1063
+ size="sm"
1064
+ className="h-6 text-[11px] gap-1.5"
1065
+ onClick={() => setEnvModalOpen(true)}
1066
+ >
1067
+ <Settings2 className="size-3.5" />
1068
+ Configure
1069
+ </Button>
1070
+ </div>
1071
+ </div>
1072
+ </div>
1073
+
1074
+ <DialogFooter>
1075
+ <Button
1076
+ variant="outline"
1077
+ onClick={() => setSshModalOpen(false)}
1078
+ disabled={isDeploying}
1079
+ >
1080
+ Cancel
1081
+ </Button>
1082
+ <Button onClick={handleDeployToSSH} disabled={isDeploying}>
1083
+ {isDeploying && <Loader2 className="mr-2 size-4 animate-spin" />}
1084
+ Deploy via SSH
1085
+ </Button>
1086
+ </DialogFooter>
1087
+ </DialogContent>
1088
+ </Dialog>
1089
+
1090
+ {/* ── Password Change Dialog (Hetzner first-login) ── */}
1091
+ <Dialog
1092
+ open={passwordChangeOpen}
1093
+ onOpenChange={(open) => {
1094
+ if (!open) pendingDeployRef.current = null;
1095
+ setPasswordChangeOpen(open);
1096
+ }}
1097
+ >
1098
+ <DialogContent className="max-w-sm">
1099
+ <DialogHeader>
1100
+ <div className="flex items-center gap-2 mb-1">
1101
+ <div className="flex items-center justify-center size-9 rounded-full bg-amber-500/10 text-amber-500">
1102
+ <ShieldAlert className="size-4.5" />
1103
+ </div>
1104
+ <DialogTitle>Password Change Required</DialogTitle>
1105
+ </div>
1106
+ <DialogDescription>
1107
+ Your server requires a password change on first login. Please set
1108
+ a new root password to continue the deployment.
1109
+ </DialogDescription>
1110
+ </DialogHeader>
1111
+
1112
+ <div className="grid gap-4 py-3">
1113
+ <div className="grid gap-2">
1114
+ <Label htmlFor="new-ssh-password">New Password</Label>
1115
+ <div className="relative">
1116
+ <Input
1117
+ id="new-ssh-password"
1118
+ type={showNewSshPassword ? "text" : "password"}
1119
+ placeholder="Enter new root password"
1120
+ value={newSshPassword}
1121
+ onChange={(e) => setNewSshPassword(e.target.value)}
1122
+ className="pr-10"
1123
+ autoFocus
1124
+ />
1125
+ <Button
1126
+ type="button"
1127
+ variant="ghost"
1128
+ size="sm"
1129
+ className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
1130
+ onClick={() => setShowNewSshPassword(!showNewSshPassword)}
1131
+ >
1132
+ {showNewSshPassword ? (
1133
+ <EyeOff className="h-4 w-4 text-muted-foreground" />
1134
+ ) : (
1135
+ <Eye className="h-4 w-4 text-muted-foreground" />
1136
+ )}
1137
+ </Button>
1138
+ </div>
1139
+ </div>
1140
+
1141
+ <div className="grid gap-2">
1142
+ <Label htmlFor="confirm-ssh-password">Confirm Password</Label>
1143
+ <div className="relative">
1144
+ <Input
1145
+ id="confirm-ssh-password"
1146
+ type={showConfirmSshPassword ? "text" : "password"}
1147
+ placeholder="Re-enter new password"
1148
+ value={confirmNewSshPassword}
1149
+ onChange={(e) => setConfirmNewSshPassword(e.target.value)}
1150
+ className="pr-10"
1151
+ onKeyDown={(e) => {
1152
+ if (e.key === "Enter") handlePasswordChangeAndRedeploy();
1153
+ }}
1154
+ />
1155
+ <Button
1156
+ type="button"
1157
+ variant="ghost"
1158
+ size="sm"
1159
+ className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
1160
+ onClick={() =>
1161
+ setShowConfirmSshPassword(!showConfirmSshPassword)
1162
+ }
1163
+ >
1164
+ {showConfirmSshPassword ? (
1165
+ <EyeOff className="h-4 w-4 text-muted-foreground" />
1166
+ ) : (
1167
+ <Eye className="h-4 w-4 text-muted-foreground" />
1168
+ )}
1169
+ </Button>
1170
+ </div>
1171
+ </div>
1172
+
1173
+ {newSshPassword &&
1174
+ confirmNewSshPassword &&
1175
+ newSshPassword !== confirmNewSshPassword && (
1176
+ <p className="text-xs text-destructive">
1177
+ Passwords do not match.
1178
+ </p>
1179
+ )}
1180
+
1181
+ <p className="text-xs text-muted-foreground">
1182
+ This is common for first-time logins on Hetzner, AWS EC2, and
1183
+ similar providers. Your chosen password will be used to continue
1184
+ deployment.
1185
+ </p>
1186
+ </div>
1187
+
1188
+ <DialogFooter>
1189
+ <Button
1190
+ variant="outline"
1191
+ onClick={() => {
1192
+ setPasswordChangeOpen(false);
1193
+ pendingDeployRef.current = null;
1194
+ }}
1195
+ >
1196
+ Cancel
1197
+ </Button>
1198
+ <Button
1199
+ onClick={handlePasswordChangeAndRedeploy}
1200
+ disabled={
1201
+ !newSshPassword ||
1202
+ !confirmNewSshPassword ||
1203
+ newSshPassword !== confirmNewSshPassword
1204
+ }
1205
+ >
1206
+ Change Password & Deploy
1207
+ </Button>
1208
+ </DialogFooter>
1209
+ </DialogContent>
1210
+ </Dialog>
1211
+
1212
+ {/* ── Environment Variables Sheet (nested) ── */}
1213
+ <Sheet open={envModalOpen} onOpenChange={setEnvModalOpen}>
1214
+ <SheetContent className="max-w-lg overflow-y-auto sm:max-w-xl! w-full p-1 px-3 pb-0 mb-0">
1215
+ <SheetHeader>
1216
+ <SheetTitle>Environment Variables</SheetTitle>
1217
+ <SheetDescription>
1218
+ Configure the API keys and settings your RAG server needs. Values
1219
+ from your workspace{" "}
1220
+ <code className="font-mono text-[13px]">.env</code> are pre-filled
1221
+ automatically.
1222
+ </SheetDescription>
1223
+ </SheetHeader>
1224
+
1225
+ <div className="grid gap-5 py-6">
1226
+ {/* ── SERVER_API_KEY (with generate / copy) ── */}
1227
+ <div className="grid gap-2">
1228
+ <Label htmlFor="server-api-key">
1229
+ SERVER_API_KEY
1230
+ <span className="ml-2 text-xs text-muted-foreground font-normal">
1231
+ (Optional — secures your deployed endpoints)
1232
+ </span>
1233
+ </Label>
1234
+ <div className="flex gap-2">
1235
+ <div className="relative flex-1">
1236
+ <Input
1237
+ id="server-api-key"
1238
+ type={showApiKey ? "text" : "password"}
1239
+ placeholder="Leave blank for public access"
1240
+ value={apiKey}
1241
+ onChange={(e) => {
1242
+ setApiKey(e.target.value);
1243
+ localStorage.setItem(
1244
+ "rag_server_api_key",
1245
+ e.target.value,
1246
+ );
1247
+ }}
1248
+ className="pr-9"
1249
+ />
1250
+ <button
1251
+ type="button"
1252
+ onClick={() => setShowApiKey((v) => !v)}
1253
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
1254
+ tabIndex={-1}
1255
+ >
1256
+ {showApiKey ? (
1257
+ <EyeOff className="size-4" />
1258
+ ) : (
1259
+ <Eye className="size-4" />
1260
+ )}
1261
+ </button>
1262
+ </div>
1263
+ <TooltipProvider>
1264
+ <Tooltip>
1265
+ <TooltipTrigger
1266
+ render={
1267
+ <Button
1268
+ variant="outline"
1269
+ size="icon"
1270
+ onClick={handleGenerateKey}
1271
+ >
1272
+ <Dices className="size-4" />
1273
+ </Button>
1274
+ }
1275
+ />
1276
+ <TooltipContent>
1277
+ <p>Generate random key</p>
1278
+ </TooltipContent>
1279
+ </Tooltip>
1280
+ </TooltipProvider>
1281
+ <Button
1282
+ variant="outline"
1283
+ size="icon"
1284
+ onClick={handleCopyKey}
1285
+ disabled={!apiKey}
1286
+ title="Copy Token"
1287
+ >
1288
+ <Copy className="size-4" />
1289
+ </Button>
1290
+ </div>
1291
+ </div>
1292
+
1293
+ {/* ── Divider ── */}
1294
+ {requiredEnv.length > 0 && (
1295
+ <div className="border-t border-border" />
1296
+ )}
1297
+
1298
+ {/* ── Dynamic Env Vars from .env.example ── */}
1299
+ {requiredEnv.map((env) => (
1300
+ <div key={env.key} className="grid gap-2">
1301
+ <Label htmlFor={`env-${env.key}`}>
1302
+ {env.key}
1303
+ {!env.required && (
1304
+ <span className="ml-2 text-xs text-muted-foreground font-normal">
1305
+ (Optional)
1306
+ </span>
1307
+ )}
1308
+ {env.required && envValues[env.key] && (
1309
+ <span className="inline-flex items-center gap-1 ml-2 text-xs text-emerald-600 dark:text-emerald-400 font-medium">
1310
+ <CheckCircle2 className="size-3" />
1311
+ Pre-filled
1312
+ </span>
1313
+ )}
1314
+ </Label>
1315
+ {env.key.toLowerCase().includes("key") ||
1316
+ env.key.toLowerCase().includes("secret") ? (
1317
+ <div className="relative">
1318
+ <Input
1319
+ id={`env-${env.key}`}
1320
+ type={showEnvVars[env.key] ? "text" : "password"}
1321
+ placeholder={env.help || `Enter ${env.key}`}
1322
+ value={envValues[env.key] || ""}
1323
+ onChange={(e) =>
1324
+ setEnvValues({
1325
+ ...envValues,
1326
+ [env.key]: e.target.value,
1327
+ })
1328
+ }
1329
+ className="pr-9"
1330
+ />
1331
+ <button
1332
+ type="button"
1333
+ onClick={() =>
1334
+ setShowEnvVars((prev) => ({
1335
+ ...prev,
1336
+ [env.key]: !prev[env.key],
1337
+ }))
1338
+ }
1339
+ className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
1340
+ tabIndex={-1}
1341
+ >
1342
+ {showEnvVars[env.key] ? (
1343
+ <EyeOff className="size-4" />
1344
+ ) : (
1345
+ <Eye className="size-4" />
1346
+ )}
1347
+ </button>
1348
+ </div>
1349
+ ) : (
1350
+ <Input
1351
+ id={`env-${env.key}`}
1352
+ type="text"
1353
+ placeholder={env.help || `Enter ${env.key}`}
1354
+ value={envValues[env.key] || ""}
1355
+ onChange={(e) =>
1356
+ setEnvValues({ ...envValues, [env.key]: e.target.value })
1357
+ }
1358
+ />
1359
+ )}
1360
+ {env.help && (
1361
+ <p className="text-xs text-muted-foreground">{env.help}</p>
1362
+ )}
1363
+ </div>
1364
+ ))}
1365
+
1366
+ {requiredEnv.length === 0 && (
1367
+ <p className="text-xs text-muted-foreground italic">
1368
+ No additional environment variables required for this server
1369
+ configuration.
1370
+ </p>
1371
+ )}
1372
+ </div>
1373
+
1374
+ <SheetFooter className="sticky bottom-0 z-10 -mx-3 mt-auto flex-row justify-between border-t bg-background px-4 py-4 sm:flex-row sm:justify-between sm:space-x-0">
1375
+ <Button
1376
+ variant="outline"
1377
+ className={"bg-white px-5"}
1378
+ onClick={() => setEnvModalOpen(false)}
1379
+ >
1380
+ Cancel
1381
+ </Button>
1382
+ <Button className={"px-7"} onClick={handleSaveEnv}>
1383
+ Save
1384
+ </Button>
1385
+ </SheetFooter>
1386
+ </SheetContent>
1387
+ </Sheet>
1388
+ </div>
1389
+ );
1390
+ }