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,419 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import useSWR from "swr";
5
+ import {
6
+ AlertTriangle,
7
+ CornerDownLeft,
8
+ FileText,
9
+ Loader2,
10
+ Search,
11
+ Server,
12
+ Sparkles,
13
+ Cpu,
14
+ Database,
15
+ ExternalLink,
16
+ } from "lucide-react";
17
+ import type { IndexType, VectorStoreId } from "@larkup/core/types";
18
+ import type { QueryHit } from "@larkup/vector-stores/adapter";
19
+ import { getEmbeddingModel } from "@larkup/core/embeddings/registry";
20
+ import { getVectorStore } from "@larkup/vector-stores/registry";
21
+ import { Card, CardContent } from "@/components/ui/card";
22
+ import { Button } from "@/components/ui/button";
23
+ import { Badge } from "@/components/ui/badge";
24
+ import { Textarea } from "@/components/ui/textarea";
25
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
26
+ import { Skeleton } from "@/components/ui/skeleton";
27
+
28
+ import { useWorkspace } from "@/components/workspace/workspace-provider";
29
+ import { cn } from "@/lib/utils";
30
+
31
+ const fetcher = (url: string) => fetch(url).then((r) => r.json());
32
+
33
+ interface DemoStatus {
34
+ ready: boolean;
35
+ blockers: string[];
36
+ indexed: boolean;
37
+ server: { running: boolean; endpoint: string };
38
+ config: {
39
+ projectName: string;
40
+ embeddingModelId: string;
41
+ vectorStore: VectorStoreId;
42
+ indexType: IndexType;
43
+ topK: number;
44
+ };
45
+ }
46
+
47
+ interface DemoResult {
48
+ query: string;
49
+ hits: QueryHit[];
50
+ source: "server" | "direct";
51
+ endpoint?: string;
52
+ tookMs: number;
53
+ }
54
+
55
+ const TOP_K_PRESETS = [1, 3, 5, 8, 10];
56
+
57
+ const SAMPLE_QUERIES = [
58
+ "What is this corpus about?",
59
+ "Summarize the key concepts.",
60
+ "How does retrieval work?",
61
+ ];
62
+
63
+ export function DemoWorkspace() {
64
+ const { activeServer } = useWorkspace();
65
+ const serverId = activeServer?.id ?? null;
66
+
67
+ const statusKey = serverId
68
+ ? `/api/demo?serverId=${encodeURIComponent(serverId)}`
69
+ : "/api/demo";
70
+ const { data, isLoading } = useSWR<DemoStatus>(statusKey, fetcher, {
71
+ refreshInterval: 5000,
72
+ });
73
+
74
+ const [query, setQuery] = useState("");
75
+ const [topK, setTopK] = useState<number>(5);
76
+ const [running, setRunning] = useState(false);
77
+ const [error, setError] = useState<string | null>(null);
78
+ const [result, setResult] = useState<DemoResult | null>(null);
79
+
80
+ // Clear any stale result when switching the target server.
81
+ useEffect(() => {
82
+ setResult(null);
83
+ setError(null);
84
+ }, [serverId]);
85
+
86
+ if (isLoading || !data) {
87
+ return (
88
+ <div className="px-6 py-6 md:px-8">
89
+ <Skeleton className="h-40 w-full" />
90
+ </div>
91
+ );
92
+ }
93
+
94
+ const { ready, blockers, server, config } = data;
95
+
96
+ async function run(q: string) {
97
+ const question = q.trim();
98
+ if (!question) return;
99
+ setRunning(true);
100
+ setError(null);
101
+ try {
102
+ const res = await fetch("/api/demo", {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({ query: question, topK, serverId }),
106
+ });
107
+ const body = await res.json();
108
+ if (!res.ok) {
109
+ setError(body.error ?? "Query failed.");
110
+ setResult(null);
111
+ return;
112
+ }
113
+ setResult(body as DemoResult);
114
+ } catch {
115
+ setError("Could not reach the retrieval endpoint.");
116
+ setResult(null);
117
+ } finally {
118
+ setRunning(false);
119
+ }
120
+ }
121
+
122
+ return (
123
+ <div className="space-y-6 px-6 py-6 md:px-8">
124
+ <SourceSummary server={server} config={config} />
125
+
126
+ {!ready && (
127
+ <Alert variant="default" className="text-orange-500">
128
+ <AlertTriangle className="size-4" />
129
+ <AlertTitle>Nothing to query yet</AlertTitle>
130
+ <AlertDescription>
131
+ <ul className="list-disc pl-4">
132
+ {blockers.map((b) => (
133
+ <li key={b}>{b}</li>
134
+ ))}
135
+ </ul>
136
+ </AlertDescription>
137
+ </Alert>
138
+ )}
139
+
140
+ <Card>
141
+ <CardContent className="space-y-4 pt-6">
142
+ <div className="relative">
143
+ <Textarea
144
+ value={query}
145
+ onChange={(e) => setQuery(e.target.value)}
146
+ onKeyDown={(e) => {
147
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
148
+ e.preventDefault();
149
+ run(query);
150
+ }
151
+ }}
152
+ placeholder="Ask a question to retrieve the most relevant chunks…"
153
+ disabled={!ready || running}
154
+ rows={3}
155
+ className="resize-none pr-3 text-sm leading-relaxed"
156
+ />
157
+ </div>
158
+
159
+ <div className="flex flex-wrap items-center justify-between gap-3">
160
+ <div className="flex items-center gap-2">
161
+ <span className="text-xs text-muted-foreground">Top-k</span>
162
+ <div className="flex items-center gap-1 rounded-lg border border-border bg-muted/40 p-0.5">
163
+ {TOP_K_PRESETS.map((k) => (
164
+ <button
165
+ key={k}
166
+ type="button"
167
+ onClick={() => setTopK(k)}
168
+ className={cn(
169
+ "min-w-8 rounded-md px-2.5 py-1 text-xs font-medium tabular-nums transition-colors",
170
+ topK === k
171
+ ? "bg-white border text-foreground shadow-sm"
172
+ : "text-muted-foreground hover:text-foreground",
173
+ )}
174
+ >
175
+ {k}
176
+ </button>
177
+ ))}
178
+ </div>
179
+ </div>
180
+
181
+ <Button
182
+ onClick={() => run(query)}
183
+ disabled={!ready || running || !query.trim()}
184
+ >
185
+ {running ? (
186
+ <Loader2 className="size-4 animate-spin" />
187
+ ) : (
188
+ <Search className="size-4" />
189
+ )}
190
+ {running ? "Searching…" : "Search"}
191
+ {!running && (
192
+ <kbd className="ml-1 hidden items-center gap-0.5 rounded border border-primary-foreground/30 px-1 text-[10px] opacity-80 sm:inline-flex">
193
+ <CornerDownLeft className="size-2.5" />
194
+ </kbd>
195
+ )}
196
+ </Button>
197
+ </div>
198
+
199
+ {ready && !result && !running && (
200
+ <div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
201
+ <span className="text-xs text-muted-foreground">Try:</span>
202
+ {SAMPLE_QUERIES.map((s) => (
203
+ <button
204
+ key={s}
205
+ type="button"
206
+ onClick={() => {
207
+ setQuery(s);
208
+ run(s);
209
+ }}
210
+ className="rounded-full border border-border bg-muted/40 px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
211
+ >
212
+ {s}
213
+ </button>
214
+ ))}
215
+ </div>
216
+ )}
217
+ </CardContent>
218
+ </Card>
219
+
220
+ {error && (
221
+ <Alert variant="destructive">
222
+ <AlertTriangle className="size-4" />
223
+ <AlertTitle>Query failed</AlertTitle>
224
+ <AlertDescription>{error}</AlertDescription>
225
+ </Alert>
226
+ )}
227
+
228
+ {running && <ResultsSkeleton count={topK} />}
229
+
230
+ {!running && result && <Results result={result} />}
231
+ </div>
232
+ );
233
+ }
234
+
235
+ function SourceSummary({
236
+ server,
237
+ config,
238
+ }: {
239
+ server: DemoStatus["server"];
240
+ config: DemoStatus["config"];
241
+ }) {
242
+ const model = getEmbeddingModel(config.embeddingModelId);
243
+ const store = getVectorStore(config.vectorStore);
244
+
245
+ return (
246
+ <Card>
247
+ <CardContent className="flex flex-wrap items-center gap-x-6 gap-y-4 py-4">
248
+ <div className="flex items-center gap-2">
249
+ {server.running ? (
250
+ <Badge className="gap-1.5 bg-green-600/12 text-green-600 hover:bg-green-600/12">
251
+ <span className="relative flex size-2">
252
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
253
+ <span className="relative inline-flex size-2 rounded-full bg-green-500" />
254
+ </span>
255
+ Querying live server
256
+ </Badge>
257
+ ) : (
258
+ <Badge variant="secondary" className="gap-1.5">
259
+ <Database className="size-3" />
260
+ Direct retrieval
261
+ </Badge>
262
+ )}
263
+ </div>
264
+
265
+ <Meta
266
+ icon={Cpu}
267
+ label="Model"
268
+ value={model?.label ?? config.embeddingModelId}
269
+ />
270
+ <Meta
271
+ icon={Database}
272
+ label="Store"
273
+ value={store?.label ?? config.vectorStore}
274
+ />
275
+ <Meta
276
+ icon={Sparkles}
277
+ label="Index"
278
+ value={config.indexType.toUpperCase()}
279
+ />
280
+ {server.running && (
281
+ <Meta icon={Server} label="Endpoint" value={server.endpoint} mono />
282
+ )}
283
+ </CardContent>
284
+ </Card>
285
+ );
286
+ }
287
+
288
+ function Meta({
289
+ icon: Icon,
290
+ label,
291
+ value,
292
+ mono,
293
+ }: {
294
+ icon: typeof Cpu;
295
+ label: string;
296
+ value: string;
297
+ mono?: boolean;
298
+ }) {
299
+ return (
300
+ <div className="flex flex-col gap-1">
301
+ <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
302
+ <Icon className="size-3.5" />
303
+ {label}
304
+ </span>
305
+ <span
306
+ className={cn(
307
+ "text-sm font-medium leading-tight",
308
+ mono && "font-mono text-xs",
309
+ )}
310
+ >
311
+ {value}
312
+ </span>
313
+ </div>
314
+ );
315
+ }
316
+
317
+ function Results({ result }: { result: DemoResult }) {
318
+ if (result.hits.length === 0) {
319
+ return (
320
+ <Card>
321
+ <CardContent className="flex flex-col items-center justify-center gap-1 py-12 text-center">
322
+ <Search className="mb-1 size-6 text-muted-foreground/60" />
323
+ <p className="text-sm font-medium">No matches found</p>
324
+ <p className="text-sm text-muted-foreground">
325
+ Nothing in the index scored against that query. Try rephrasing or
326
+ indexing more content.
327
+ </p>
328
+ </CardContent>
329
+ </Card>
330
+ );
331
+ }
332
+
333
+ return (
334
+ <div className="space-y-3">
335
+ <div className="flex items-center justify-between">
336
+ <h2 className="text-sm font-medium">
337
+ {result.hits.length} result{result.hits.length === 1 ? "" : "s"}
338
+ </h2>
339
+ <span className="font-mono text-xs text-muted-foreground tabular-nums">
340
+ {result.source === "server" ? "via server" : "via direct"} ·{" "}
341
+ {result.tookMs.toLocaleString()}ms
342
+ </span>
343
+ </div>
344
+
345
+ {result.hits.map((hit, i) => (
346
+ <HitCard key={hit.id || i} hit={hit} rank={i + 1} />
347
+ ))}
348
+ </div>
349
+ );
350
+ }
351
+
352
+ function HitCard({ hit, rank }: { hit: QueryHit; rank: number }) {
353
+ const pct = Math.round(Math.min(Math.max(hit.score, 0), 1) * 100);
354
+ return (
355
+ <Card className="overflow-hidden">
356
+ <CardContent className="flex gap-4 py-4">
357
+ <div className="flex flex-col items-center gap-1 pt-0.5">
358
+ <span className="flex size-7 items-center justify-center rounded-md bg-muted text-xs font-semibold tabular-nums text-muted-foreground">
359
+ {rank}
360
+ </span>
361
+ </div>
362
+
363
+ <div className="min-w-0 flex-1 space-y-2">
364
+ <div className="flex items-start justify-between gap-3">
365
+ <div className="flex min-w-0 items-center gap-2">
366
+ <FileText className="size-4 shrink-0 text-muted-foreground" />
367
+ <h3 className="truncate text-sm font-medium">
368
+ {hit.title || "Untitled"}
369
+ </h3>
370
+ </div>
371
+ <Badge
372
+ variant="secondary"
373
+ className="shrink-0 font-mono text-[11px] tabular-nums"
374
+ >
375
+ {pct}%
376
+ </Badge>
377
+ </div>
378
+
379
+ <p className="text-sm leading-relaxed text-muted-foreground">
380
+ {hit.text.length > 360
381
+ ? `${hit.text.slice(0, 360).trim()}…`
382
+ : hit.text}
383
+ </p>
384
+
385
+ {hit.url && (
386
+ <a
387
+ href={hit.url}
388
+ target="_blank"
389
+ rel="noreferrer"
390
+ className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
391
+ >
392
+ <ExternalLink className="size-3" />
393
+ <span className="truncate">{hit.url}</span>
394
+ </a>
395
+ )}
396
+ </div>
397
+ </CardContent>
398
+ </Card>
399
+ );
400
+ }
401
+
402
+ function ResultsSkeleton({ count }: { count: number }) {
403
+ return (
404
+ <div className="space-y-3">
405
+ {Array.from({ length: Math.min(count, 4) }).map((_, i) => (
406
+ <Card key={i}>
407
+ <CardContent className="flex gap-4 py-4">
408
+ <Skeleton className="size-7 rounded-md" />
409
+ <div className="flex-1 space-y-2">
410
+ <Skeleton className="h-4 w-1/3" />
411
+ <Skeleton className="h-3 w-full" />
412
+ <Skeleton className="h-3 w-4/5" />
413
+ </div>
414
+ </CardContent>
415
+ </Card>
416
+ ))}
417
+ </div>
418
+ );
419
+ }
@@ -0,0 +1,286 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import useSWR from "swr";
5
+ import { Loader2, Save, ChevronLeft } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Label } from "@/components/ui/label";
9
+ import { toast } from "sonner";
10
+ import { RagConfig } from "@larkup/core/types";
11
+ import {
12
+ Select,
13
+ SelectContent,
14
+ SelectItem,
15
+ SelectTrigger,
16
+ } from "@/components/ui/select";
17
+ import { useSearchParams, useRouter, usePathname } from "next/navigation";
18
+ import { useWorkspace } from "@/components/workspace/workspace-provider";
19
+
20
+ const fetcher = (url: string) =>
21
+ fetch(url).then((r) => r.json() as Promise<{ config: RagConfig }>);
22
+
23
+ export function GlobalSettings({ onBack }: { onBack: () => void }) {
24
+ const { data, isLoading, mutate } = useSWR("/api/config", fetcher);
25
+ const [form, setForm] = useState<Partial<RagConfig>>({});
26
+ const [saving, setSaving] = useState(false);
27
+
28
+ const searchParams = useSearchParams();
29
+ const highlightSerper = searchParams.get("settings") === "serper";
30
+ const router = useRouter();
31
+ const pathname = usePathname();
32
+ const { setMode } = useWorkspace();
33
+
34
+ useEffect(() => {
35
+ if (data?.config) {
36
+ setForm(data.config);
37
+ }
38
+ }, [data]);
39
+
40
+ const handleSave = async () => {
41
+ setSaving(true);
42
+ try {
43
+ const res = await fetch("/api/config", {
44
+ method: "PUT",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ ...data?.config, ...form }),
47
+ });
48
+ const json = await res.json();
49
+ if (!res.ok)
50
+ throw new Error(json.error ?? "Failed to save configuration");
51
+
52
+ await mutate(json, { revalidate: false });
53
+ toast.success("Global Settings saved");
54
+
55
+ if (highlightSerper) {
56
+ // Remove the query param if it was set
57
+ const params = new URLSearchParams(searchParams);
58
+ params.delete("settings");
59
+ router.replace(`${pathname}?${params.toString()}`);
60
+ }
61
+ } catch (err) {
62
+ toast.error(err instanceof Error ? err.message : "Failed to save");
63
+ } finally {
64
+ setSaving(false);
65
+ }
66
+ };
67
+
68
+ if (isLoading) {
69
+ return (
70
+ <div className="flex items-center justify-center h-40">
71
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
72
+ </div>
73
+ );
74
+ }
75
+
76
+ return (
77
+ <div className="flex flex-col min-h-0 h-full">
78
+ <div className="border-b px-4 py-3 font-semibold flex items-center gap-2 shrink-0 bg-muted/30">
79
+ <Button
80
+ variant="ghost"
81
+ size="icon"
82
+ className="h-6 w-6 shrink-0"
83
+ onClick={onBack}
84
+ >
85
+ <ChevronLeft className="h-4 w-4" />
86
+ </Button>
87
+ <span className="flex-1 truncate">Global Settings</span>
88
+ <Button
89
+ size="sm"
90
+ onClick={handleSave}
91
+ disabled={saving}
92
+ className="h-7 px-3 text-xs"
93
+ >
94
+ {saving ? (
95
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
96
+ ) : (
97
+ <Save className="h-3 w-3 mr-1" />
98
+ )}
99
+ Save
100
+ </Button>
101
+ </div>
102
+
103
+ <div className="flex-1 min-h-0 p-4 space-y-6 overflow-y-auto ">
104
+ <div className="space-y-4">
105
+ <div className=" bg-card text-card-foreground p-3 space-y-3">
106
+ <h4 className="font-semibold leading-none tracking-tight text-sm">
107
+ Embedding
108
+ </h4>
109
+ <div className="space-y-1.5">
110
+ <Label className="text-xs">Provider</Label>
111
+ <Select
112
+ value={form.embeddingProvider ?? undefined}
113
+ onValueChange={(v) =>
114
+ setForm({ ...form, embeddingProvider: v! })
115
+ }
116
+ >
117
+ <SelectTrigger className="h-8! text-sm w-full">
118
+ <span>{form.embeddingProvider || "Select provider"}</span>
119
+ </SelectTrigger>
120
+ <SelectContent>
121
+ <SelectItem value="openai">OpenAI</SelectItem>
122
+ <SelectItem value="google">Google</SelectItem>
123
+ <SelectItem value="cohere">Cohere</SelectItem>
124
+ <SelectItem value="mistral">Mistral</SelectItem>
125
+ <SelectItem value="deepseek">DeepSeek</SelectItem>
126
+ <SelectItem value="vercel_ai_gateway">
127
+ Vercel AI Gateway
128
+ </SelectItem>
129
+ </SelectContent>
130
+ </Select>
131
+ </div>
132
+ <div className="space-y-1.5">
133
+ <Label className="text-xs">API Key</Label>
134
+ <Input
135
+ type="password"
136
+ className="h-8 text-sm"
137
+ value={form.embeddingApiKey || ""}
138
+ onChange={(e) =>
139
+ setForm({ ...form, embeddingApiKey: e.target.value })
140
+ }
141
+ placeholder="sk-..."
142
+ />
143
+ </div>
144
+ </div>
145
+
146
+ <div className=" text-card-foreground p-3 space-y-3">
147
+ <h4 className="font-semibold leading-none tracking-tight text-sm">
148
+ Chat (LLM)
149
+ </h4>
150
+ <div className="space-y-1.5">
151
+ <Label className="text-xs">Provider</Label>
152
+ <Select
153
+ value={form.chatProvider ?? form.embeddingProvider ?? undefined}
154
+ onValueChange={(v) => setForm({ ...form, chatProvider: v! })}
155
+ >
156
+ <SelectTrigger className="h-8! w-full text-sm">
157
+ <span>
158
+ {form.chatProvider ||
159
+ form.embeddingProvider ||
160
+ "Select provider"}
161
+ </span>
162
+ </SelectTrigger>
163
+ <SelectContent>
164
+ <SelectItem value="openai">OpenAI</SelectItem>
165
+ <SelectItem value="google">Google</SelectItem>
166
+ <SelectItem value="cohere">Cohere</SelectItem>
167
+ <SelectItem value="mistral">Mistral</SelectItem>
168
+ <SelectItem value="deepseek">DeepSeek</SelectItem>
169
+ <SelectItem value="vercel_ai_gateway">
170
+ Vercel AI Gateway
171
+ </SelectItem>
172
+ </SelectContent>
173
+ </Select>
174
+ </div>
175
+ <div className="space-y-1.5">
176
+ <Label className="text-xs">API Key</Label>
177
+ <Input
178
+ type="password"
179
+ className="h-8 text-sm"
180
+ value={form.chatApiKey || ""}
181
+ onChange={(e) =>
182
+ setForm({ ...form, chatApiKey: e.target.value })
183
+ }
184
+ placeholder="Leave blank to use Embedding Key"
185
+ />
186
+ </div>
187
+ </div>
188
+
189
+ <div className=" bg-card text-card-foreground p-3 space-y-3">
190
+ <h4 className="font-semibold leading-none tracking-tight text-sm">
191
+ Web Search
192
+ </h4>
193
+ <div className="space-y-1.5">
194
+ <Label className="text-xs">Serper.dev API Key</Label>
195
+ <Input
196
+ type="password"
197
+ className={`h-8 text-sm ${highlightSerper && !form.serperApiKey ? "border-red-500 focus-visible:ring-red-500" : ""}`}
198
+ value={form.serperApiKey || ""}
199
+ onChange={(e) =>
200
+ setForm({ ...form, serperApiKey: e.target.value })
201
+ }
202
+ placeholder="Serper API Key"
203
+ />
204
+ {highlightSerper && !form.serperApiKey && (
205
+ <p className="text-[10px] text-red-500">
206
+ API Key is required for web search.
207
+ </p>
208
+ )}
209
+ </div>
210
+ </div>
211
+
212
+ <div className=" bg-card text-card-foreground p-3 space-y-3">
213
+ <h4 className="font-semibold leading-none tracking-tight text-sm">
214
+ Scraper
215
+ </h4>
216
+ <div className="space-y-1.5">
217
+ <Label className="text-xs">Proxy Server</Label>
218
+ <Input
219
+ className="h-8 text-sm"
220
+ value={form.scraperProxyServer || ""}
221
+ onChange={(e) =>
222
+ setForm({ ...form, scraperProxyServer: e.target.value })
223
+ }
224
+ placeholder="http://proxy.example.com:8080"
225
+ />
226
+ </div>
227
+ <div className="space-y-1.5">
228
+ <Label className="text-xs">Proxy Username</Label>
229
+ <Input
230
+ className="h-8 text-sm"
231
+ value={form.scraperProxyUsername || ""}
232
+ onChange={(e) =>
233
+ setForm({ ...form, scraperProxyUsername: e.target.value })
234
+ }
235
+ />
236
+ </div>
237
+ <div className="space-y-1.5">
238
+ <Label className="text-xs">Proxy Password</Label>
239
+ <Input
240
+ type="password"
241
+ className="h-8 text-sm"
242
+ value={form.scraperProxyPassword || ""}
243
+ onChange={(e) =>
244
+ setForm({ ...form, scraperProxyPassword: e.target.value })
245
+ }
246
+ />
247
+ </div>
248
+ <div className="space-y-1.5 pt-2">
249
+ <Label className="text-xs">Firecrawl API Key (Cloud)</Label>
250
+ <Input
251
+ type="password"
252
+ className="h-8 text-sm"
253
+ value={form.firecrawlApiKey || ""}
254
+ onChange={(e) =>
255
+ setForm({ ...form, firecrawlApiKey: e.target.value })
256
+ }
257
+ placeholder="fc-..."
258
+ />
259
+ </div>
260
+ </div>
261
+
262
+ <div className="bg-card text-card-foreground p-3 space-y-3 rounded-lg border">
263
+ <h4 className="font-semibold leading-none tracking-tight text-sm">
264
+ Workspace Mode
265
+ </h4>
266
+ <p className="text-xs text-muted-foreground">
267
+ Switch to simple mode for a streamlined experience without
268
+ pipeline configuration.
269
+ </p>
270
+ <Button
271
+ variant="outline"
272
+ size="sm"
273
+ className="w-full text-xs"
274
+ onClick={async () => {
275
+ await setMode("simple");
276
+ router.push("/simple/chat");
277
+ }}
278
+ >
279
+ Switch to Simple Mode
280
+ </Button>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ );
286
+ }