larkup 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env +2 -0
- package/app/actions/vercel.ts +417 -0
- package/app/api/chat/route.ts +192 -0
- package/app/api/chat/status/route.ts +55 -0
- package/app/api/chat/suggestions/route.ts +115 -0
- package/app/api/config/route.ts +73 -0
- package/app/api/config/test/route.ts +67 -0
- package/app/api/config/test-embedding/route.ts +40 -0
- package/app/api/config/test-llm/route.ts +47 -0
- package/app/api/config/test-provider/route.ts +36 -0
- package/app/api/demo/route.ts +176 -0
- package/app/api/deploy/ssh/route.ts +340 -0
- package/app/api/documents/route.ts +90 -0
- package/app/api/firecrawl/local/route.ts +46 -0
- package/app/api/index/route.ts +113 -0
- package/app/api/jobs/[id]/route.ts +44 -0
- package/app/api/jobs/route.ts +116 -0
- package/app/api/parse-file/route.ts +39 -0
- package/app/api/search/google/route.ts +162 -0
- package/app/api/search/route.ts +29 -0
- package/app/api/server/download/route.ts +112 -0
- package/app/api/server/generate/route.ts +46 -0
- package/app/api/server/local/route.ts +42 -0
- package/app/api/servers/route.ts +137 -0
- package/app/api/vector-stores/install/route.ts +65 -0
- package/app/api/vector-stores/status/route.ts +26 -0
- package/app/chat/page.tsx +15 -0
- package/app/configure/page.tsx +53 -0
- package/app/data/page.tsx +15 -0
- package/app/demo/page.tsx +15 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +190 -0
- package/app/index-data/page.tsx +15 -0
- package/app/layout.tsx +51 -0
- package/app/page.tsx +34 -0
- package/app/server/page.tsx +22 -0
- package/app/simple/chat/page.tsx +17 -0
- package/app/simple/docs/page.tsx +21 -0
- package/app/simple/layout.tsx +9 -0
- package/app/simple/settings/page.tsx +627 -0
- package/app/template.tsx +16 -0
- package/app/themes.css +733 -0
- package/components/alerts/generic-alert.tsx +74 -0
- package/components/app-sidebar.tsx +187 -0
- package/components/app-topnav.tsx +195 -0
- package/components/chat/chat-settings-modal.tsx +209 -0
- package/components/chat/chat-workspace.tsx +348 -0
- package/components/chat/knowledge-base-result.tsx +97 -0
- package/components/chat/message-item.tsx +103 -0
- package/components/client-layout-wrapper.tsx +182 -0
- package/components/configure/configure-form.tsx +1683 -0
- package/components/configure/store-fields.tsx +152 -0
- package/components/data/corpus-panel.tsx +499 -0
- package/components/data/data-workspace.tsx +272 -0
- package/components/data/firecrawl-notice.tsx +236 -0
- package/components/data/jobs-panel.tsx +485 -0
- package/components/data/paste-panel.tsx +79 -0
- package/components/data/scrape-panel.tsx +964 -0
- package/components/data/upload-panel.tsx +714 -0
- package/components/demo/demo-workspace.tsx +419 -0
- package/components/global-settings.tsx +286 -0
- package/components/index/index-workspace.tsx +482 -0
- package/components/onboarding/simple-setup.tsx +702 -0
- package/components/onboarding/tech-setup.tsx +121 -0
- package/components/onboarding/welcome-screen.tsx +183 -0
- package/components/page-header.tsx +44 -0
- package/components/server/code-viewer.tsx +87 -0
- package/components/server/deploy-button.tsx +1390 -0
- package/components/server/server-workspace.tsx +392 -0
- package/components/simple/sdk-connect-dialog.tsx +201 -0
- package/components/simple/simple-index-button.tsx +83 -0
- package/components/simple/simple-sidebar.tsx +161 -0
- package/components/theme-customizer-provider.tsx +171 -0
- package/components/theme-switcher.tsx +445 -0
- package/components/ui/alert-dialog.tsx +187 -0
- package/components/ui/alert.tsx +76 -0
- package/components/ui/badge.tsx +52 -0
- package/components/ui/button.tsx +59 -0
- package/components/ui/card.tsx +103 -0
- package/components/ui/checkbox.tsx +28 -0
- package/components/ui/collapsible.tsx +21 -0
- package/components/ui/dialog.tsx +159 -0
- package/components/ui/dropdown-menu.tsx +281 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +20 -0
- package/components/ui/popover.tsx +90 -0
- package/components/ui/progress.tsx +83 -0
- package/components/ui/provider-icon.tsx +94 -0
- package/components/ui/scroll-area.tsx +55 -0
- package/components/ui/select.tsx +248 -0
- package/components/ui/separator.tsx +25 -0
- package/components/ui/sheet.tsx +138 -0
- package/components/ui/skeleton.tsx +13 -0
- package/components/ui/sonner.tsx +63 -0
- package/components/ui/switch.tsx +32 -0
- package/components/ui/table.tsx +116 -0
- package/components/ui/tabs.tsx +84 -0
- package/components/ui/textarea.tsx +18 -0
- package/components/ui/tooltip.tsx +66 -0
- package/components/workspace/delete-server-dialog.tsx +71 -0
- package/components/workspace/server-form-dialog.tsx +103 -0
- package/components/workspace/server-switcher.tsx +163 -0
- package/components/workspace/workspace-provider.tsx +177 -0
- package/components/workspace/workspace-top-bar.tsx +23 -0
- package/components.json +21 -0
- package/lib/error-formatter.ts +15 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +18 -0
- package/package.json +65 -0
- package/postcss.config.mjs +8 -0
- package/public/docker.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/github.svg +3 -0
- package/public/hetzner.svg +1 -0
- package/public/icons/aws.svg +38 -0
- package/public/icons/azure.svg +1 -0
- package/public/icons/chroma.png +0 -0
- package/public/icons/cohere.svg +30 -0
- package/public/icons/digital-ocean.webp +0 -0
- package/public/icons/firecrawl2.png +0 -0
- package/public/icons/gcp.svg +1 -0
- package/public/icons/gemini.svg +1 -0
- package/public/icons/jina.svg +1 -0
- package/public/icons/lancedb2.png +0 -0
- package/public/icons/mistral.svg +469 -0
- package/public/icons/nomic.png +0 -0
- package/public/icons/openai.svg +1 -0
- package/public/icons/pgvector2.png +0 -0
- package/public/icons/pinecone.png +0 -0
- package/public/icons/qdrant.svg +35 -0
- package/public/icons/supabase.png +0 -0
- package/public/icons/vercel copy.svg +1 -0
- package/public/icons/vercel.svg +1 -0
- package/public/icons/voyage-light.png +0 -0
- package/public/icons/weaviate.webp +0 -0
- package/public/logo-light.png +0 -0
- package/public/logo.png +0 -0
- package/public/logo9.png +0 -0
- package/public/vercel.svg +1 -0
- package/tsconfig.json +33 -0
|
@@ -0,0 +1,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
|
+
}
|