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,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
|
+
}
|