diff --git a/src/app/(dashboard)/dashboard/search-tools/SearchToolsClient.tsx b/src/app/(dashboard)/dashboard/search-tools/SearchToolsClient.tsx index 6cedad890c..96e5ff4b46 100644 --- a/src/app/(dashboard)/dashboard/search-tools/SearchToolsClient.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/SearchToolsClient.tsx @@ -1,26 +1,19 @@ "use client"; -import { useState, useEffect, useRef } from "react"; -import { useTranslations } from "next-intl"; +import { useState, useEffect } from "react"; +import type { SearchProviderCatalogItem } from "@/shared/schemas/searchTools"; + +import SearchToolsTopBar, { type ActiveTab } from "./components/SearchToolsTopBar"; +import SearchToolsConfigPane, { type ConfigState } from "./components/SearchToolsConfigPane"; +import SearchConceptCard from "./components/SearchConceptCard"; + import dynamic from "next/dynamic"; -const SearchForm = dynamic(() => import("./components/SearchForm"), { - ssr: false, -}); -const SearchHistory = dynamic(() => import("./components/SearchHistory"), { - ssr: false, -}); -const ResultsPanel = dynamic(() => import("./components/ResultsPanel"), { - ssr: false, -}); -const ProviderComparison = dynamic(() => import("./components/ProviderComparison"), { ssr: false }); -const RerankPanel = dynamic(() => import("./components/RerankPanel"), { - ssr: false, -}); - -import type { SearchFormData } from "./components/SearchForm"; -import type { CompareResult } from "./components/ProviderComparison"; +const SearchTab = dynamic(() => import("./components/tabs/SearchTab"), { ssr: false }); +const ScrapeTab = dynamic(() => import("./components/tabs/ScrapeTab"), { ssr: false }); +const CompareTab = dynamic(() => import("./components/tabs/CompareTab"), { ssr: false }); +/** Minimal legacy-compatible shape used by SearchTab (fetched from /api/search/providers). */ interface SearchProvider { id: string; name: string; @@ -28,270 +21,115 @@ interface SearchProvider { cost_per_query: number; } -interface SearchResult { - title: string; - url: string; - snippet: string; - score?: number; -} - -interface SearchResponse { - id: string; - provider: string; - query: string; - answer?: string; - results: SearchResult[]; - cached: boolean; - usage: { - queries_used: number; - search_cost_usd: number; - }; - metrics: { - response_time_ms: number; - upstream_latency_ms: number; - total_results_available: number | null; - }; -} +const DEFAULT_CONFIG: ConfigState = { + provider: "auto", + searchType: "web", + fetchFormat: "markdown", + fullPage: false, + rerankModel: "", +}; export default function SearchToolsClient() { - const t = useTranslations("search"); - const [providers, setProviders] = useState([]); - const [response, setResponse] = useState(null); - const [rawJson, setRawJson] = useState(""); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const [statusCode, setStatusCode] = useState(0); - const [duration, setDuration] = useState(0); - const [lastQuery, setLastQuery] = useState(null); - const abortRef = useRef(null); - - const [showCompare, setShowCompare] = useState(false); - const [compareLoading, setCompareLoading] = useState(false); - const [compareResults, setCompareResults] = useState([]); - const [initialCompareResult, setInitialCompareResult] = useState(null); - const [showRerank, setShowRerank] = useState(false); + const [activeTab, setActiveTab] = useState("search"); + const [configState, setConfigState] = useState(DEFAULT_CONFIG); + const [catalogProviders, setCatalogProviders] = useState([]); + const [legacyProviders, setLegacyProviders] = useState([]); + // Metrics shared across tabs (updated by active tab) + const [latencyMs, setLatencyMs] = useState(null); + const [costUsd, setCostUsd] = useState(null); useEffect(() => { - fetch("/api/search/providers") + globalThis + .fetch("/api/search/providers") .then((res) => res.json()) - .then((data) => setProviders(data.providers || [])) + .then((data: { providers?: SearchProviderCatalogItem[] }) => { + const providers = data.providers ?? []; + setCatalogProviders(providers); + // Build legacy-compatible list for SearchTab/SearchForm + const legacy: SearchProvider[] = providers + .filter((p) => p.kind === "search") + .map((p) => ({ + id: p.id, + name: p.name, + status: p.status === "configured" ? "active" : "no_credentials", + cost_per_query: p.costPerQuery, + })); + setLegacyProviders(legacy); + }) .catch(() => {}); }, []); - const handleSearch = async (formData: SearchFormData) => { - setLoading(true); - setError(""); - setResponse(null); - setRawJson(""); - setStatusCode(0); - setShowCompare(false); - setShowRerank(false); - setCompareResults([]); - - const controller = new AbortController(); - abortRef.current = controller; - const timeout = setTimeout(() => controller.abort(), 15_000); - const start = Date.now(); - - try { - const body: any = { ...formData }; - if (!body.provider) delete body.provider; - - const res = await fetch("/api/v1/search", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - signal: controller.signal, - }); - - setDuration(Date.now() - start); - setStatusCode(res.status); - - const data = await res.json(); - setRawJson(JSON.stringify(data, null, 2)); - setLastQuery(formData); - - if (res.ok) { - setResponse(data); - } else { - setError(data.error?.message || data.error || `Error ${res.status}`); - } - } catch (err: any) { - setDuration(Date.now() - start); - if (err.name === "AbortError") { - setError(t("requestTimedOut", { seconds: 15 })); - } else { - setError(err?.message || t("networkError")); - } - } finally { - setLoading(false); - clearTimeout(timeout); - } + const handleConfigChange = (patch: Partial) => { + setConfigState((prev) => ({ ...prev, ...patch })); }; - const handleCompare = async () => { - if (!response || !lastQuery) return; - - const usedProvider = response.provider; - const otherProviders = providers - .filter((p) => p.status === "active" && p.id !== usedProvider) - .map((p) => p.id); - - if (otherProviders.length === 0) return; - - const initial: CompareResult = { - provider: usedProvider, - latency: response.metrics.response_time_ms, - cost: response.usage.search_cost_usd, - resultCount: response.results.length, - responseSize: rawJson.length, - urls: response.results.map((r) => r.url), - }; - setInitialCompareResult(initial); - setShowCompare(true); - setCompareLoading(true); - - const promises = otherProviders.map(async (providerId) => { - const start = Date.now(); - try { - const res = await fetch("/api/v1/search", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...lastQuery, provider: providerId }), - }); - const data = await res.json(); - const elapsed = Date.now() - start; - - if (!res.ok) { - return { - provider: providerId, - latency: elapsed, - cost: 0, - resultCount: 0, - responseSize: 0, - urls: [], - error: data.error?.message || `Error ${res.status}`, - } as CompareResult; - } - - const respJson = JSON.stringify(data); - return { - provider: providerId, - latency: data.metrics?.response_time_ms || elapsed, - cost: data.usage?.search_cost_usd || 0, - resultCount: data.results?.length || 0, - responseSize: respJson.length, - urls: (data.results || []).map((r: any) => r.url), - } as CompareResult; - } catch (err: any) { - return { - provider: providerId, - latency: Date.now() - start, - cost: 0, - resultCount: 0, - responseSize: 0, - urls: [], - error: err.message, - } as CompareResult; - } - }); - - const results = await Promise.allSettled(promises); - setCompareResults( - results.map((r) => - r.status === "fulfilled" - ? r.value - : { - provider: "unknown", - latency: 0, - cost: 0, - resultCount: 0, - responseSize: 0, - urls: [], - error: "Failed", - } - ) - ); - setCompareLoading(false); - }; - - const handleCancel = () => { - abortRef.current?.abort(); - }; - - const handleHistoryReplay = (entry: any) => { - handleSearch({ - query: entry.query, - provider: entry.provider || "", - search_type: entry.filters?.search_type || "web", - max_results: entry.filters?.max_results || 5, - ...entry.filters, - }); + // Build export state from current config (passed to TopBar for ExportCodeModal) + const exportState: Record = { + endpoint: activeTab === "scrape" ? "web.fetch" : "search", + provider: configState.provider, + searchType: configState.searchType, + fetchFormat: configState.fetchFormat, + fullPage: configState.fullPage, }; return ( -
-
- - +
+ {/* Top bar: tabs + metrics + export */} + + + {/* Concept card β€” always visible, collapsible */} +
+
-
- + {/* Tab content */} +
+ {activeTab === "search" && ( + { + setLatencyMs(lMs); + setCostUsd(cUsd); + }} + /> + )} + {activeTab === "scrape" && ( + { + setLatencyMs(lMs); + setCostUsd(cUsd); + }} + /> + )} + {activeTab === "compare" && ( + { + setLatencyMs(lMs); + setCostUsd(cUsd); + }} + /> + )} +
+ + {/* Config pane */} + - - {response && ( -
- - -
- )} - - {showCompare && initialCompareResult && ( -
- setShowCompare(false)} - /> -
- )} - - {showRerank && response && ( -
- setShowRerank(false)} - /> -
- )}
); diff --git a/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx b/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx new file mode 100644 index 0000000000..6304232561 --- /dev/null +++ b/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import type { SearchProviderCatalogItem } from "@/shared/schemas/searchTools"; + +interface ProviderCatalogProps { + /** If provided, highlight this provider as selected */ + selectedProvider?: string; + onSelectProvider?: (id: string) => void; +} + +function StatusBadge({ status }: { status: SearchProviderCatalogItem["status"] }) { + if (status === "configured") { + return ( + + + ); + } + if (status === "rate_limited") { + return ( + + + ); + } + return ( + + + ); +} + +function ProviderCard({ + item, + selected, + onSelect, +}: { + item: SearchProviderCatalogItem; + selected: boolean; + onSelect?: () => void; +}) { + const isClickable = item.status !== "missing" && onSelect; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") onSelect?.(); + } + : undefined + } + data-testid={`provider-card-${item.id}`} + > +
+
+
{item.name}
+
+ {item.kind === "search" ? "Search" : "Fetch/Scrape"} +
+
+ +
+ +
+ + ${item.costPerQuery.toFixed(4)}/query + + {item.freeMonthlyQuota > 0 && ( + + Free{" "} + + {item.freeMonthlyQuota >= 1000 + ? `${(item.freeMonthlyQuota / 1000).toFixed(0)}k` + : item.freeMonthlyQuota} + + /mo + + )} +
+ + {item.searchTypes && item.searchTypes.length > 0 && ( +
+ {item.searchTypes.map((t) => ( + + {t} + + ))} +
+ )} + + {item.fetchFormats && item.fetchFormats.length > 0 && ( +
+ {item.fetchFormats.map((f) => ( + + {f} + + ))} +
+ )} + + {item.status === "missing" && ( + + Configure β†’ + + )} +
+ ); +} + +export default function ProviderCatalog({ selectedProvider, onSelectProvider }: ProviderCatalogProps) { + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterKind, setFilterKind] = useState<"all" | "search" | "fetch">("all"); + + useEffect(() => { + // loading is initialized to true β€” no need to setLoading(true) here + // (setLoading in the body of useEffect triggers the lint rule react-hooks/set-state-in-effect) + globalThis + .fetch("/api/search/providers") + .then((res) => res.json()) + .then((data: { providers?: SearchProviderCatalogItem[] }) => { + setProviders(data.providers ?? []); + setError(null); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Failed to load providers"); + }) + .finally(() => setLoading(false)); + }, []); + + const filtered = + filterKind === "all" ? providers : providers.filter((p) => p.kind === filterKind); + + const searchCount = providers.filter((p) => p.kind === "search").length; + const fetchCount = providers.filter((p) => p.kind === "fetch").length; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ Erro ao carregar providers: {error} +
+ ); + } + + return ( +
+ {/* Filter tabs */} +
+ {(["all", "search", "fetch"] as const).map((kind) => ( + + ))} +
+ + {filtered.length === 0 && ( +
+ Nenhum provider encontrado.{" "} + + Configurar providers β†’ + +
+ )} + +
+ {filtered.map((item) => ( + onSelectProvider(item.id) : undefined} + /> + ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/search-tools/components/ProviderComparison.tsx b/src/app/(dashboard)/dashboard/search-tools/components/ProviderComparison.tsx index da75351471..2e46349806 100644 --- a/src/app/(dashboard)/dashboard/search-tools/components/ProviderComparison.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/components/ProviderComparison.tsx @@ -137,7 +137,10 @@ export default function ProviderComparison({ ))} - Size + + {/* TODO(F9): replace with t("search.size") once i18n keys are added */} + Size + {allResults.map((r) => ( )} - {!loading && !error && !response && ( -
+ {!loading && !error && !response && noProvidersConfigured && ( +
+ +

Nenhum provider de search ativo

+ + Configurar mais providers β†’ + +
+ )} + + {!loading && !error && !response && !noProvidersConfigured && ( +
{t("emptyState")}
)} diff --git a/src/app/(dashboard)/dashboard/search-tools/components/ScrapeResult.tsx b/src/app/(dashboard)/dashboard/search-tools/components/ScrapeResult.tsx new file mode 100644 index 0000000000..638d5cb164 --- /dev/null +++ b/src/app/(dashboard)/dashboard/search-tools/components/ScrapeResult.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useState, lazy, Suspense } from "react"; +import type { ScrapeResult as ScrapeResultType } from "@/shared/schemas/searchTools"; + +/** D21 β€” cap at 256 KB to avoid freezing the renderer */ +const CONTENT_CAP_BYTES = 256 * 1024; + +// Lazy-load MarkdownMessage to avoid increasing initial bundle size +const MarkdownMessage = lazy( + () => + import( + "@/app/(dashboard)/dashboard/playground/components/MarkdownMessage" + ), +); + +interface ScrapeResultProps { + result: ScrapeResultType; + latencyMs?: number | null; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +export default function ScrapeResult({ result, latencyMs }: ScrapeResultProps) { + const [mode, setMode] = useState<"markdown" | "raw">("markdown"); + const [rawModalOpen, setRawModalOpen] = useState(false); + + const contentSize = new TextEncoder().encode(result.content).length; + const isTruncated = contentSize > CONTENT_CAP_BYTES; + const displayContent = isTruncated + ? result.content.slice(0, CONTENT_CAP_BYTES) + : result.content; + + return ( +
+ {/* Meta bar */} +
+
+ {result.provider && ( + + Provider:{" "} + {result.provider} + + )} + {latencyMs != null && ( + + LatΓͺncia:{" "} + {latencyMs}ms + + )} + + Tamanho:{" "} + {formatBytes(contentSize)} + + {result.links.length > 0 && ( + + Links:{" "} + {result.links.length} + + )} +
+ + {/* View toggle */} +
+ + +
+
+ + {/* Metadata card */} + {result.metadata && (result.metadata.title || result.metadata.description) && ( +
+ {result.metadata.title && ( +
{result.metadata.title}
+ )} + {result.metadata.description && ( +
{result.metadata.description}
+ )} + + {result.url} + +
+ )} + + {/* Truncation warning */} + {isTruncated && ( +
+ + ConteΓΊdo truncado a 256 KB (tamanho original: {formatBytes(contentSize)}) + + +
+ )} + + {/* Content area */} + {mode === "markdown" ? ( +
+ {displayContent} + } + > + + +
+ ) : ( +