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,964 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState, useEffect, useRef } from "react";
4
+ import { toast } from "sonner";
5
+ import { formatErrorMessage } from "@/lib/error-formatter";
6
+ import {
7
+ Globe,
8
+ Loader2,
9
+ Plus,
10
+ Rocket,
11
+ Search,
12
+ ChevronDown,
13
+ ChevronsDown,
14
+ Info,
15
+ Clock,
16
+ X,
17
+ } from "lucide-react";
18
+ import type { CrawlScope, SearchResultItem } from "@larkup/core/types";
19
+ import { Button } from "@/components/ui/button";
20
+ import { Input } from "@/components/ui/input";
21
+ import { Label } from "@/components/ui/label";
22
+ import { Checkbox } from "@/components/ui/checkbox";
23
+ import { Badge } from "@/components/ui/badge";
24
+
25
+ import { Switch } from "@/components/ui/switch";
26
+ import {
27
+ Select,
28
+ SelectContent,
29
+ SelectItem,
30
+ SelectTrigger,
31
+ SelectValue,
32
+ } from "@/components/ui/select";
33
+ import {
34
+ Tooltip,
35
+ TooltipContent,
36
+ TooltipProvider,
37
+ TooltipTrigger,
38
+ } from "@/components/ui/tooltip";
39
+ import { GenericAlert } from "@/components/alerts/generic-alert";
40
+ import { cn } from "@/lib/utils";
41
+
42
+ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
43
+
44
+ function domainOf(url: string) {
45
+ try {
46
+ return new URL(url).hostname;
47
+ } catch {
48
+ return url;
49
+ }
50
+ }
51
+
52
+ function formatCount(n: number): string {
53
+ if (n >= 1_000_000_000) return `~${(n / 1_000_000_000).toFixed(1)}B`;
54
+ if (n >= 1_000_000) return `~${(n / 1_000_000).toFixed(1)}M`;
55
+ if (n >= 1_000) return `~${(n / 1_000).toFixed(0)}K`;
56
+ return n.toLocaleString();
57
+ }
58
+
59
+ function formatDuration(seconds: number): string {
60
+ if (seconds < 60) return `~${Math.ceil(seconds)}s`;
61
+ if (seconds < 3600) return `~${Math.ceil(seconds / 60)}min`;
62
+ const h = Math.floor(seconds / 3600);
63
+ const m = Math.ceil((seconds % 3600) / 60);
64
+ return `~${h}h ${m}min`;
65
+ }
66
+
67
+ /** Estimate ETL duration based on URL count, scope, and page limit */
68
+ function estimateEtlDuration(
69
+ urlCount: number,
70
+ scope: CrawlScope,
71
+ pageLimit: number,
72
+ ): { totalPages: number; estimatedSeconds: number } {
73
+ // ~3s per page scrape, ~5s per domain page (crawl overhead)
74
+ const pagesPerUrl = scope === "domain" ? pageLimit : 1;
75
+ const totalPages = urlCount * pagesPerUrl;
76
+ const perPageSeconds = scope === "domain" ? 5 : 3;
77
+ const estimatedSeconds = totalPages * perPageSeconds;
78
+ return { totalPages, estimatedSeconds };
79
+ }
80
+
81
+ interface SearchState {
82
+ results: SearchResultItem[];
83
+ totalResults: number;
84
+ totalResultsIsEstimate: boolean;
85
+ currentPage: number;
86
+ totalPages: number;
87
+ hasMore: boolean;
88
+ query: string;
89
+ searchProvider: "firecrawl" | "serper";
90
+ }
91
+
92
+ export function ScrapePanel({
93
+ disabled,
94
+ onStarted,
95
+ }: {
96
+ disabled: boolean;
97
+ onStarted: () => void;
98
+ }) {
99
+ const [query, setQuery] = useState("");
100
+ const [searching, setSearching] = useState(false);
101
+ const [loadingMore, setLoadingMore] = useState(false);
102
+ const [gatheringAll, setGatheringAll] = useState(false);
103
+ const [gatherProgress, setGatherProgress] = useState(0);
104
+ const [searchState, setSearchState] = useState<SearchState | null>(null);
105
+ const [selected, setSelected] = useState<Record<string, boolean>>({});
106
+ const [scope, setScope] = useState<CrawlScope>("domain");
107
+ const [pageLimit, setPageLimit] = useState(25);
108
+ const [manualUrl, setManualUrl] = useState("");
109
+ const [starting, setStarting] = useState(false);
110
+ /** When true: only the exact custom URLs are scraped — no deep crawl/pagination */
111
+ const [specificUrls, setSpecificUrls] = useState(false);
112
+ const [serperConfigured, setSerperConfigured] = useState<boolean | null>(
113
+ null,
114
+ );
115
+ const [firecrawlConfigured, setFirecrawlConfigured] = useState<
116
+ boolean | null
117
+ >(null);
118
+ const [confirmOpen, setConfirmOpen] = useState(false);
119
+ const [fetchingCount, setFetchingCount] = useState(false);
120
+ const [serperTotalForQuery, setSerperTotalForQuery] = useState<{
121
+ query: string;
122
+ total: number;
123
+ totalPages: number;
124
+ } | null>(null);
125
+ const [cachedQueries, setCachedQueries] = useState<string[]>([]);
126
+ const [showDropdown, setShowDropdown] = useState(false);
127
+ const [searchLimit, setSearchLimit] = useState(15);
128
+ const inputRef = useRef<HTMLInputElement>(null);
129
+ const dropdownRef = useRef<HTMLDivElement>(null);
130
+
131
+ useEffect(() => {
132
+ function handleClickOutside(event: MouseEvent) {
133
+ if (
134
+ inputRef.current &&
135
+ !inputRef.current.contains(event.target as Node) &&
136
+ dropdownRef.current &&
137
+ !dropdownRef.current.contains(event.target as Node)
138
+ ) {
139
+ setShowDropdown(false);
140
+ }
141
+ }
142
+ document.addEventListener("mousedown", handleClickOutside);
143
+ return () => document.removeEventListener("mousedown", handleClickOutside);
144
+ }, []);
145
+
146
+ useEffect(() => {
147
+ try {
148
+ const saved = localStorage.getItem("scrape_recent_queries");
149
+ if (saved) setCachedQueries(JSON.parse(saved));
150
+ } catch {
151
+ // ignore
152
+ }
153
+ }, []);
154
+
155
+ function saveQuery(q: string) {
156
+ const trimmed = q.trim();
157
+ if (!trimmed) return;
158
+ setCachedQueries((prev) => {
159
+ const newCache = [
160
+ trimmed,
161
+ ...prev.filter((item) => item !== trimmed),
162
+ ].slice(0, 10);
163
+ try {
164
+ localStorage.setItem("scrape_recent_queries", JSON.stringify(newCache));
165
+ } catch {
166
+ // ignore
167
+ }
168
+ return newCache;
169
+ });
170
+ }
171
+
172
+ function removeQuery(q: string, e: React.MouseEvent) {
173
+ e.stopPropagation();
174
+ setCachedQueries((prev) => {
175
+ const newCache = prev.filter((item) => item !== q);
176
+ try {
177
+ localStorage.setItem("scrape_recent_queries", JSON.stringify(newCache));
178
+ } catch {
179
+ // ignore
180
+ }
181
+ return newCache;
182
+ });
183
+ }
184
+
185
+ // Check providers on mount
186
+ useEffect(() => {
187
+ // Check Serper
188
+ fetch("/api/search/google")
189
+ .then((r) => r.json())
190
+ .then((d) => setSerperConfigured(d.configured ?? false))
191
+ .catch(() => setSerperConfigured(false));
192
+ // Check Firecrawl
193
+ fetch("/api/search")
194
+ .then((r) => r.json())
195
+ .then((d) => setFirecrawlConfigured(d.configured ?? false))
196
+ .catch(() => setFirecrawlConfigured(false));
197
+ }, []);
198
+
199
+ const selectedUrls = useMemo(
200
+ () => Object.keys(selected).filter((u) => selected[u]),
201
+ [selected],
202
+ );
203
+
204
+ /** Search using Firecrawl (preferred — no Serper credits used for search). */
205
+ async function searchFirecrawl(q: string, isMulti: boolean) {
206
+ try {
207
+ const res = await fetch("/api/search", {
208
+ method: "POST",
209
+ headers: { "Content-Type": "application/json" },
210
+ body: JSON.stringify({ query: q, limit: searchLimit }),
211
+ });
212
+ const data = await res.json();
213
+ if (!res.ok) throw new Error(data.error ?? "Search failed");
214
+ const newItems = (data.results as SearchResultItem[]) ?? [];
215
+
216
+ setSearchState((prev) => {
217
+ const existingResults = prev ? prev.results : [];
218
+ const existingUrls = new Set(existingResults.map((r) => r.url));
219
+ const fresh = newItems.filter((r) => !existingUrls.has(r.url));
220
+ return {
221
+ results: [...existingResults, ...fresh],
222
+ totalResults: isMulti
223
+ ? (prev?.totalResults ?? 0) + fresh.length
224
+ : fresh.length,
225
+ totalResultsIsEstimate: false,
226
+ currentPage: 1,
227
+ totalPages: 1,
228
+ hasMore: false,
229
+ query: isMulti ? "Multiple queries" : q,
230
+ searchProvider: "firecrawl",
231
+ };
232
+ });
233
+
234
+ const newSelected = Object.fromEntries(newItems.map((r) => [r.url, true]));
235
+ setSelected((prev) => ({ ...prev, ...newSelected }));
236
+
237
+ if (newItems.length === 0 && !isMulti)
238
+ toast.message("No results — try different keywords.");
239
+
240
+ // If Serper is also configured, fetch the total count in background
241
+ if (serperConfigured && !isMulti) {
242
+ fetchSerperTotalCount(q);
243
+ }
244
+ } catch (err) {
245
+ toast.error(formatErrorMessage(err));
246
+ }
247
+ }
248
+
249
+ /** Search using Serper (fallback if Firecrawl not available). */
250
+ async function searchSerper(
251
+ q: string,
252
+ page: number,
253
+ isMulti: boolean,
254
+ appendPagination = false,
255
+ ) {
256
+ if (!appendPagination) {
257
+ // It's the initial search, searching flag is handled in runSearch
258
+ } else {
259
+ setLoadingMore(true);
260
+ }
261
+
262
+ try {
263
+ const res = await fetch("/api/search/google", {
264
+ method: "POST",
265
+ headers: { "Content-Type": "application/json" },
266
+ body: JSON.stringify({ query: q, page }),
267
+ });
268
+ const data = await res.json();
269
+
270
+ if (!res.ok) throw new Error(data.error ?? "Search failed");
271
+
272
+ const newItems: SearchResultItem[] = (data.items ?? []).map(
273
+ (item: { url: string; title: string; description?: string }) => ({
274
+ url: item.url,
275
+ title: item.title || item.url,
276
+ description: item.description,
277
+ }),
278
+ );
279
+
280
+ setSearchState((prev) => {
281
+ const existingResults = prev ? prev.results : [];
282
+ const existingUrls = new Set(existingResults.map((r) => r.url));
283
+ const fresh = newItems.filter((r) => !existingUrls.has(r.url));
284
+ return {
285
+ results: [...existingResults, ...fresh],
286
+ totalResults: (prev?.totalResults ?? 0) + (data.totalResults ?? 0),
287
+ totalResultsIsEstimate: data.totalResultsIsEstimate ?? true,
288
+ currentPage: data.currentPage ?? page,
289
+ totalPages: data.totalPages ?? 1,
290
+ hasMore: data.hasMore ?? false,
291
+ query: data.query ?? q,
292
+ searchProvider: "serper",
293
+ };
294
+ });
295
+
296
+ const newSelected = Object.fromEntries(newItems.map((r) => [r.url, true]));
297
+ setSelected((prev) => ({ ...prev, ...newSelected }));
298
+
299
+ if (!isMulti) {
300
+ // Update Serper total
301
+ setSerperTotalForQuery({
302
+ query: q,
303
+ total: data.totalResults ?? 0,
304
+ totalPages: data.totalPages ?? 1,
305
+ });
306
+ }
307
+
308
+ if (page === 1 && (data.totalResults ?? 0) === 0 && !isMulti) {
309
+ toast.message("No results — try different keywords.");
310
+ }
311
+ } catch (err) {
312
+ toast.error(formatErrorMessage(err));
313
+ } finally {
314
+ if (appendPagination) setLoadingMore(false);
315
+ }
316
+ }
317
+
318
+ /** Fetch total count from Serper without using it for results (saves on Firecrawl credits) */
319
+ async function fetchSerperTotalCount(q: string) {
320
+ setFetchingCount(true);
321
+ try {
322
+ const res = await fetch("/api/search/google", {
323
+ method: "POST",
324
+ headers: { "Content-Type": "application/json" },
325
+ body: JSON.stringify({ query: q, page: 1 }),
326
+ });
327
+ const data = await res.json();
328
+ if (res.ok) {
329
+ setSerperTotalForQuery({
330
+ query: q,
331
+ total: data.totalResults ?? 0,
332
+ totalPages: data.totalPages ?? 1,
333
+ });
334
+ }
335
+ } catch {
336
+ // Silent fail — count is optional
337
+ } finally {
338
+ setFetchingCount(false);
339
+ }
340
+ }
341
+
342
+ async function runSearch() {
343
+ setShowDropdown(false);
344
+ if (!query.trim()) {
345
+ toast.error("Enter one or more keywords to search.");
346
+ return;
347
+ }
348
+
349
+ // We do NOT clear selected URLs here so users can keep them across searches
350
+ setSerperTotalForQuery(null);
351
+ saveQuery(query);
352
+
353
+ const queries = query
354
+ .split(",")
355
+ .map((q) => q.trim())
356
+ .filter(Boolean);
357
+ if (queries.length === 0) return;
358
+
359
+ setSearching(true);
360
+ // Removed setSearchState(null) so we append instead of clear
361
+
362
+ try {
363
+ if (firecrawlConfigured) {
364
+ await Promise.all(
365
+ queries.map((q) => searchFirecrawl(q, queries.length > 1)),
366
+ );
367
+ } else if (serperConfigured) {
368
+ await Promise.all(
369
+ queries.map((q) => searchSerper(q, 1, queries.length > 1, false)),
370
+ );
371
+ } else {
372
+ toast.error(
373
+ formatErrorMessage(
374
+ new Error(
375
+ "No search provider available. Configure Firecrawl or add SERPER_API_KEY.",
376
+ ),
377
+ ),
378
+ );
379
+ }
380
+ } finally {
381
+ setSearching(false);
382
+ }
383
+ }
384
+
385
+ async function loadNextPage() {
386
+ if (!searchState?.hasMore || loadingMore) return;
387
+ if (searchState.searchProvider === "serper") {
388
+ await searchSerper(
389
+ searchState.query,
390
+ searchState.currentPage + 1,
391
+ false,
392
+ true,
393
+ );
394
+ }
395
+ }
396
+
397
+ /** Automatically paginate through ALL available Serper results. */
398
+ async function gatherAll() {
399
+ if (!searchState || searchState.searchProvider !== "serper") return;
400
+ setGatheringAll(true);
401
+ setGatherProgress(searchState.currentPage);
402
+
403
+ let page = searchState.currentPage + 1;
404
+ const total = searchState.totalPages;
405
+
406
+ try {
407
+ while (page <= total) {
408
+ setGatherProgress(page);
409
+ const res = await fetch("/api/search/google", {
410
+ method: "POST",
411
+ headers: { "Content-Type": "application/json" },
412
+ body: JSON.stringify({ query: query.trim(), page }),
413
+ });
414
+ const data = await res.json();
415
+ if (!res.ok) throw new Error(data.error ?? "Search failed");
416
+
417
+ const newItems: SearchResultItem[] = (data.items ?? []).map(
418
+ (item: { url: string; title: string; description?: string }) => ({
419
+ url: item.url,
420
+ title: item.title || item.url,
421
+ description: item.description,
422
+ }),
423
+ );
424
+
425
+ setSearchState((prev) => {
426
+ if (!prev) return prev;
427
+ const existingUrls = new Set(prev.results.map((r) => r.url));
428
+ const fresh = newItems.filter((r) => !existingUrls.has(r.url));
429
+ return {
430
+ ...prev,
431
+ results: [...prev.results, ...fresh],
432
+ totalResultsIsEstimate:
433
+ data.totalResultsIsEstimate ?? prev.totalResultsIsEstimate,
434
+ currentPage: data.currentPage ?? page,
435
+ totalPages: data.totalPages ?? prev.totalPages,
436
+ hasMore: data.hasMore ?? false,
437
+ };
438
+ });
439
+
440
+ const newSelected = Object.fromEntries(newItems.map((r) => [r.url, true]));
441
+ setSelected((prev) => ({ ...prev, ...newSelected }));
442
+
443
+ if (!data.hasMore) break;
444
+ page++;
445
+ await new Promise((r) => setTimeout(r, 300));
446
+ }
447
+ toast.success("All available results gathered!");
448
+ } catch (err) {
449
+ toast.error(formatErrorMessage(err));
450
+ } finally {
451
+ setGatheringAll(false);
452
+ setGatherProgress(0);
453
+ }
454
+ }
455
+
456
+ function addManual() {
457
+ const urls = manualUrl
458
+ .split(/[\s,]+/)
459
+ .map((u) => u.trim())
460
+ .filter((u) => /^https?:\/\//i.test(u));
461
+
462
+ if (urls.length === 0) {
463
+ toast.error("Enter at least one full URL starting with http(s)://");
464
+ return;
465
+ }
466
+
467
+ const items: SearchResultItem[] = urls.map((url) => ({
468
+ url,
469
+ title: url,
470
+ }));
471
+
472
+ setSearchState((prev) => {
473
+ const existingUrls = new Set(prev?.results.map((r) => r.url) ?? []);
474
+ const newItems = items.filter((item) => !existingUrls.has(item.url));
475
+ const combined = [...newItems, ...(prev?.results ?? [])];
476
+
477
+ return prev
478
+ ? {
479
+ ...prev,
480
+ results: combined,
481
+ totalResults: prev.totalResults + newItems.length,
482
+ }
483
+ : {
484
+ results: combined,
485
+ totalResults: combined.length,
486
+ totalResultsIsEstimate: false,
487
+ currentPage: 1,
488
+ totalPages: 1,
489
+ hasMore: false,
490
+ query: "",
491
+ searchProvider: "firecrawl",
492
+ };
493
+ });
494
+
495
+ const newSelected = Object.fromEntries(urls.map((u) => [u, true]));
496
+ setSelected((prev) => ({ ...prev, ...newSelected }));
497
+ setManualUrl("");
498
+ }
499
+
500
+ const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
501
+ const file = e.target.files?.[0];
502
+ if (!file) return;
503
+ const reader = new FileReader();
504
+ reader.onload = (e) => {
505
+ const text = e.target?.result as string;
506
+ if (text) {
507
+ setManualUrl((prev) => prev + (prev ? "\n" : "") + text);
508
+ }
509
+ };
510
+ reader.readAsText(file);
511
+ e.target.value = ""; // reset input
512
+ };
513
+
514
+ /** Open confirm modal before starting ETL */
515
+ function handleStartClick() {
516
+ if (selectedUrls.length === 0) {
517
+ toast.error("Select at least one URL to scrape.");
518
+ return;
519
+ }
520
+ setConfirmOpen(true);
521
+ }
522
+
523
+ async function startJob() {
524
+ setStarting(true);
525
+ setConfirmOpen(false);
526
+ // When "Specific URLs" is on, force page-level scrape (no crawl)
527
+ const effectiveScope: CrawlScope = specificUrls ? "page" : scope;
528
+ try {
529
+ const res = await fetch("/api/jobs", {
530
+ method: "POST",
531
+ headers: { "Content-Type": "application/json" },
532
+ body: JSON.stringify({
533
+ keywords: query || selectedUrls[0],
534
+ pageLimit: specificUrls ? 1 : pageLimit,
535
+ targets: selectedUrls.map((url) => ({ url, scope: effectiveScope })),
536
+ }),
537
+ });
538
+ const data = await res.json();
539
+ if (!res.ok) throw new Error(data.error ?? "Could not start job");
540
+
541
+ const { totalPages, estimatedSeconds } = estimateEtlDuration(
542
+ selectedUrls.length,
543
+ effectiveScope,
544
+ specificUrls ? 1 : pageLimit,
545
+ );
546
+
547
+ toast.success("ETL job started — running in background", {
548
+ description: specificUrls
549
+ ? `Scraping ${selectedUrls.length} specific URL(s) only — no deep crawl. You can navigate away — progress is tracked automatically.`
550
+ : `${selectedUrls.length} ${effectiveScope === "domain" ? "domain(s)" : "page(s)"} · ~${totalPages} pages · ETA ${formatDuration(estimatedSeconds)}. You can navigate away — progress is tracked automatically.`,
551
+ duration: 8000,
552
+ });
553
+
554
+ setSelected({});
555
+ onStarted();
556
+ } catch (err) {
557
+ toast.error(formatErrorMessage(err));
558
+ } finally {
559
+ setStarting(false);
560
+ }
561
+ }
562
+
563
+ const results = searchState?.results ?? [];
564
+ const gatherAllAvailable =
565
+ searchState?.searchProvider === "serper" &&
566
+ searchState.hasMore &&
567
+ !gatheringAll;
568
+
569
+ // Estimate for confirmation modal
570
+ const effectiveScopeForEstimate: CrawlScope = specificUrls ? "page" : scope;
571
+ const estimate = useMemo(
572
+ () =>
573
+ estimateEtlDuration(
574
+ selectedUrls.length,
575
+ effectiveScopeForEstimate,
576
+ specificUrls ? 1 : pageLimit,
577
+ ),
578
+ [selectedUrls.length, effectiveScopeForEstimate, specificUrls, pageLimit],
579
+ );
580
+
581
+ const [inputMode, setInputMode] = useState<"search" | "url">("search");
582
+
583
+ return (
584
+ <div className="space-y-4">
585
+ {/* Unified input area */}
586
+ <div className="space-y-3">
587
+ {/* Mode toggle + input */}
588
+ <div className="flex items-center gap-2">
589
+ <Tabs
590
+ value={inputMode}
591
+ onValueChange={(v) => setInputMode(v as "search" | "url")}
592
+ className="shrink-0"
593
+ >
594
+ <TabsList className="inline-flex h-9 items-center justify-center rounded-lg bg-muted/40 border border-border p-0.5 text-muted-foreground">
595
+ <TabsTrigger
596
+ value="search"
597
+ className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-all focus-visible:outline-none data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm hover:text-foreground h-full"
598
+ >
599
+ <Search className="size-3.5 mr-1.5" />
600
+ Search
601
+ </TabsTrigger>
602
+ <TabsTrigger
603
+ value="url"
604
+ className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium transition-all focus-visible:outline-none data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm hover:text-foreground h-full"
605
+ >
606
+ <Globe className="size-3.5 mr-1.5" />
607
+ Direct URL
608
+ </TabsTrigger>
609
+ </TabsList>
610
+ </Tabs>
611
+
612
+ {/* Input field */}
613
+ <div className="relative flex-1">
614
+ {inputMode === "search" ? (
615
+ <>
616
+ <Input
617
+ ref={inputRef}
618
+ id="kw"
619
+ placeholder='Search keywords, e.g. "RAG pipelines"'
620
+ value={query}
621
+ disabled={disabled}
622
+ onChange={(e) => {
623
+ setQuery(e.target.value);
624
+ setShowDropdown(true);
625
+ }}
626
+ onFocus={() => setShowDropdown(true)}
627
+ onKeyDown={(e) => {
628
+ if (e.key === "Enter") {
629
+ setShowDropdown(false);
630
+ runSearch();
631
+ }
632
+ }}
633
+ />
634
+ {showDropdown && cachedQueries.length > 0 && (
635
+ <div
636
+ ref={dropdownRef}
637
+ className="absolute z-10 top-full left-0 mt-1 w-full rounded-md border border-border bg-popover shadow-md overflow-hidden"
638
+ >
639
+ <div className="py-1">
640
+ {cachedQueries.map((cq) => (
641
+ <div
642
+ key={cq}
643
+ className="flex items-center justify-between px-3 py-1.5 text-sm hover:bg-muted cursor-pointer group"
644
+ onClick={() => {
645
+ setQuery(cq);
646
+ setShowDropdown(false);
647
+ }}
648
+ >
649
+ <div className="flex items-center gap-2 truncate mr-2">
650
+ <Clock className="size-3 text-muted-foreground shrink-0" />
651
+ <span className="truncate text-xs">{cq}</span>
652
+ </div>
653
+ <button
654
+ className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity p-0.5"
655
+ onClick={(e) => removeQuery(cq, e)}
656
+ >
657
+ <X className="size-3" />
658
+ </button>
659
+ </div>
660
+ ))}
661
+ </div>
662
+ </div>
663
+ )}
664
+ </>
665
+ ) : (
666
+ <Input
667
+ placeholder="Paste URLs (comma or space separated)"
668
+ value={manualUrl}
669
+ disabled={disabled}
670
+ onChange={(e) => setManualUrl(e.target.value)}
671
+ onKeyDown={(e) => e.key === "Enter" && addManual()}
672
+ />
673
+ )}
674
+ </div>
675
+
676
+ {/* Search limit (only in search mode) */}
677
+ {inputMode === "search" && (
678
+ <Input
679
+ type="number"
680
+ min={1}
681
+ max={100}
682
+ value={searchLimit}
683
+ disabled={disabled || searching}
684
+ onChange={(e) => setSearchLimit(Number(e.target.value) || 15)}
685
+ title="Max results"
686
+ className="w-20 shrink-0 tabular-nums"
687
+ />
688
+ )}
689
+
690
+ {/* Action button */}
691
+ {inputMode === "search" ? (
692
+ <Button
693
+ onClick={() => {
694
+ setShowDropdown(false);
695
+ runSearch();
696
+ }}
697
+ disabled={disabled || searching}
698
+ className="shrink-0"
699
+ >
700
+ {searching ? (
701
+ <Loader2 className="size-4 animate-spin" />
702
+ ) : (
703
+ <Search className="size-4" />
704
+ )}
705
+ Search
706
+ </Button>
707
+ ) : (
708
+ <Button
709
+ variant="outline"
710
+ onClick={addManual}
711
+ disabled={disabled}
712
+ className="shrink-0"
713
+ >
714
+ <Plus className="size-4" />
715
+ Add
716
+ </Button>
717
+ )}
718
+ </div>
719
+ </div>
720
+
721
+ {/* No provider configured hint */}
722
+ {firecrawlConfigured === false && serperConfigured === false && (
723
+ <div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground">
724
+ <Info className="mt-0.5 size-3.5 shrink-0" />
725
+ <span>
726
+ <strong className="text-foreground">
727
+ No search provider configured.
728
+ </strong>{" "}
729
+ Set up Firecrawl (recommended) for search and scraping, or add{" "}
730
+ <code className="rounded bg-muted px-1 font-mono">
731
+ SERPER_API_KEY
732
+ </code>{" "}
733
+ to your{" "}
734
+ <code className="rounded bg-muted px-1 font-mono">.env</code> for
735
+ Google search.
736
+ </span>
737
+ </div>
738
+ )}
739
+
740
+ {/* Scope + launch — compact single row */}
741
+ <div className="flex items-center gap-3 flex-wrap">
742
+ <Select
743
+ defaultValue={scope}
744
+ value={scope}
745
+ onValueChange={(v) => setScope(v as CrawlScope)}
746
+ disabled={specificUrls || disabled}
747
+ >
748
+ <SelectTrigger className="w-auto min-w-[180px] flex-1 bg-white h-9 text-xs">
749
+ <SelectValue />
750
+ </SelectTrigger>
751
+ <SelectContent>
752
+ <SelectItem value="page">Scrape pages only</SelectItem>
753
+ <SelectItem value="domain">Crawl whole domain</SelectItem>
754
+ </SelectContent>
755
+ </Select>
756
+
757
+ {scope === "domain" && !specificUrls && (
758
+ <div className="flex items-center gap-1.5">
759
+ <Label
760
+ htmlFor="limit"
761
+ className="text-xs text-muted-foreground whitespace-nowrap"
762
+ >
763
+ Max pages
764
+ </Label>
765
+ <Input
766
+ id="limit"
767
+ type="number"
768
+ min={1}
769
+ max={500}
770
+ value={pageLimit}
771
+ disabled={specificUrls || disabled}
772
+ onChange={(e) => setPageLimit(Number(e.target.value) || 1)}
773
+ className="w-20 h-9 tabular-nums"
774
+ />
775
+ </div>
776
+ )}
777
+
778
+ <div className="flex items-center gap-1.5">
779
+ <Switch
780
+ id="specific-urls-inline"
781
+ checked={specificUrls}
782
+ onCheckedChange={setSpecificUrls}
783
+ disabled={disabled}
784
+ size="sm"
785
+ />
786
+ <Label
787
+ htmlFor="specific-urls-inline"
788
+ className="text-xs font-medium cursor-pointer flex items-center gap-1"
789
+ >
790
+ Exact URLs
791
+ <TooltipProvider delay={0}>
792
+ <Tooltip>
793
+ <TooltipTrigger
794
+ type="button"
795
+ className="text-muted-foreground hover:text-foreground"
796
+ onClick={(e) => e.preventDefault()}
797
+ >
798
+ <Info className="size-3.5" />
799
+ </TooltipTrigger>
800
+ <TooltipContent>
801
+ <p className="max-w-[200px] text-center">
802
+ Scrape exact URLs only — no deep crawl or pagination.
803
+ </p>
804
+ </TooltipContent>
805
+ </Tooltip>
806
+ </TooltipProvider>
807
+ </Label>
808
+ </div>
809
+
810
+ <div className="flex items-center gap-2 ml-auto">
811
+ {/* ETL estimation inline */}
812
+ {selectedUrls.length > 0 && (
813
+ <span className="text-xs text-muted-foreground flex items-center gap-1.5">
814
+ <Clock className="size-3" />
815
+ {specificUrls
816
+ ? `${selectedUrls.length} URL${selectedUrls.length !== 1 ? "s" : ""}`
817
+ : `~${estimate.totalPages.toLocaleString()} pages · ${formatDuration(estimate.estimatedSeconds)}`}
818
+ </span>
819
+ )}
820
+
821
+ <Button
822
+ onClick={handleStartClick}
823
+ disabled={disabled || starting || selectedUrls.length === 0}
824
+ className="shrink-0"
825
+ >
826
+ {starting ? (
827
+ <Loader2 className="size-4 animate-spin" />
828
+ ) : (
829
+ <Rocket className="size-4" />
830
+ )}
831
+ Start ETL
832
+ {selectedUrls.length > 0 && (
833
+ <Badge variant="secondary" className="ml-1">
834
+ {selectedUrls.length}
835
+ </Badge>
836
+ )}
837
+ </Button>
838
+ </div>
839
+ </div>
840
+
841
+ {/* Results list */}
842
+ {results.length > 0 && (
843
+ <div className="rounded-lg border border-border">
844
+ <div className="flex items-center justify-between border-b border-border px-3 py-2">
845
+ <span className="text-xs font-medium text-muted-foreground">
846
+ {results.length} source{results.length === 1 ? "" : "s"} ·{" "}
847
+ {selectedUrls.length} selected
848
+ </span>
849
+ <button
850
+ type="button"
851
+ className="text-xs text-primary hover:underline"
852
+ onClick={() => {
853
+ const all = selectedUrls.length !== results.length;
854
+ setSelected(
855
+ all
856
+ ? Object.fromEntries(results.map((r) => [r.url, true]))
857
+ : {},
858
+ );
859
+ }}
860
+ >
861
+ {selectedUrls.length === results.length
862
+ ? "Clear all"
863
+ : "Select all"}
864
+ </button>
865
+ </div>
866
+ <ul className="max-h-72 divide-y divide-border overflow-y-auto">
867
+ {results.map((r) => {
868
+ const checked = !!selected[r.url];
869
+ return (
870
+ <li key={r.url}>
871
+ <label
872
+ className={cn(
873
+ "flex cursor-pointer items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/50",
874
+ checked && "bg-accent/50",
875
+ )}
876
+ >
877
+ <Checkbox
878
+ checked={checked}
879
+ onCheckedChange={(v) =>
880
+ setSelected((prev) => ({ ...prev, [r.url]: !!v }))
881
+ }
882
+ className="mt-0.5"
883
+ />
884
+ <span className="min-w-0 flex-1">
885
+ <span className="block truncate text-sm font-medium">
886
+ {r.title}
887
+ </span>
888
+ <span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
889
+ <Globe className="size-3 shrink-0" />
890
+ {domainOf(r.url)}
891
+ </span>
892
+ {r.description && (
893
+ <span className="mt-0.5 line-clamp-1 block text-xs text-muted-foreground/80">
894
+ {r.description}
895
+ </span>
896
+ )}
897
+ </span>
898
+ </label>
899
+ </li>
900
+ );
901
+ })}
902
+ </ul>
903
+
904
+ {/* Pagination footer */}
905
+ {searchState?.searchProvider === "serper" &&
906
+ (searchState.hasMore || loadingMore || gatheringAll) && (
907
+ <div className="flex items-center justify-between border-t border-border px-3 py-2">
908
+ <span className="text-xs text-muted-foreground">
909
+ {gatheringAll
910
+ ? `Gathering page ${gatherProgress} of ${searchState.totalPages}…`
911
+ : `Page ${searchState.currentPage} of ${searchState.totalPages}`}
912
+ </span>
913
+ <div className="flex items-center gap-2">
914
+ {gatherAllAvailable && (
915
+ <Button
916
+ size="sm"
917
+ variant="ghost"
918
+ className="h-7 gap-1 text-xs"
919
+ onClick={gatherAll}
920
+ >
921
+ <ChevronsDown className="size-3.5" />
922
+ Gather all (
923
+ {searchState.totalPages - searchState.currentPage} more
924
+ pages)
925
+ </Button>
926
+ )}
927
+ <Button
928
+ size="sm"
929
+ variant="outline"
930
+ className="h-7 gap-1 text-xs"
931
+ onClick={loadNextPage}
932
+ disabled={
933
+ !searchState?.hasMore || loadingMore || gatheringAll
934
+ }
935
+ >
936
+ {loadingMore ? (
937
+ <Loader2 className="size-3.5 animate-spin" />
938
+ ) : (
939
+ <ChevronDown className="size-3.5" />
940
+ )}
941
+ Load next page
942
+ </Button>
943
+ </div>
944
+ </div>
945
+ )}
946
+ </div>
947
+ )}
948
+
949
+ {/* Confirmation modal */}
950
+ <GenericAlert
951
+ open={confirmOpen}
952
+ onOpenChange={setConfirmOpen}
953
+ title="Start ETL Job?"
954
+ description={
955
+ specificUrls
956
+ ? `This will scrape ${selectedUrls.length} specific URL${selectedUrls.length !== 1 ? "s" : ""} exactly as provided — no deep crawl or pagination.\n\nThe job will run in the background. You can navigate away and check progress anytime.`
957
+ : `This will scrape ${selectedUrls.length} ${scope === "domain" ? "domain(s)" : "page(s)"}${scope === "domain" ? ` with up to ${pageLimit} pages each` : ""}.\n\n• Total pages: ~${estimate.totalPages.toLocaleString()}\n• Estimated time: ${formatDuration(estimate.estimatedSeconds)}\n\nThe job will run in the background. You can navigate away and check progress anytime.`
958
+ }
959
+ actionText="Start ETL Job"
960
+ onAction={startJob}
961
+ />
962
+ </div>
963
+ );
964
+ }