From e3ed29aab6c051fda308d1201c9766ebbf48f2df Mon Sep 17 00:00:00 2001 From: Regis <92858615+Regis-RCR@users.noreply.github.com> Date: Wed, 18 Mar 2026 12:43:24 +0100 Subject: [PATCH] feat(search): add search playground, search tools, and local rerank routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search Playground (Phase 1): - Web Search as 10th endpoint in Playground with isolated SearchPlayground component - Endpoint selector moved first; Provider/Model/Send hidden when search selected - Provider dropdown via GET /api/search/providers, formatted results with cache indicator Search Tools page (Phase 2) at /dashboard/search-tools: - Split panel: SearchForm (left) with query, provider, filters + ResultsPanel (right) - Compare Providers: parallel queries with latency, cost, response size, URL overlap - Rerank Pipeline: model selector from /v1/models, results with position delta - Search History: last 10 searches from call_logs with replay - Sidebar entry under Debug section Backend: - GET /api/search/providers — list providers with auth guard + SEARCH_CREDENTIAL_FALLBACKS - GET /api/search/stats — cache stats, provider aggregates, recent searches (auth guard) - Add local provider_nodes routing for /v1/rerank (oMLX, vLLM support) Bug fixes (from F-27 PR #432): - Fix Brave news normalizer: data.results directly, not data.news.results - Enforce max_results truncation after normalization for all providers - Fix EndpointPageClient: use /api/search/providers instead of /api/v1/search - Add isAuthenticated() guards on /api/search/providers and /api/search/stats Response size metric in results meta bar and compare table. i18n: 30+ keys in search namespace (en.json) --- open-sse/handlers/search.ts | 28 +- .../dashboard/endpoint/EndpointPageClient.tsx | 4 +- .../dashboard/playground/SearchPlayground.tsx | 406 ++++++++++++ .../(dashboard)/dashboard/playground/page.tsx | 584 +++++++++--------- .../search-tools/SearchToolsClient.tsx | 297 +++++++++ .../components/ProviderComparison.tsx | 168 +++++ .../search-tools/components/RerankPanel.tsx | 152 +++++ .../search-tools/components/ResultsPanel.tsx | 223 +++++++ .../search-tools/components/SearchForm.tsx | 308 +++++++++ .../search-tools/components/SearchHistory.tsx | 67 ++ .../dashboard/search-tools/page.tsx | 5 + src/app/api/search/providers/route.ts | 49 ++ src/app/api/search/stats/route.ts | 77 +++ src/app/api/v1/rerank/route.ts | 149 ++++- src/i18n/messages/en.json | 37 ++ src/shared/components/Sidebar.tsx | 1 + 16 files changed, 2245 insertions(+), 310 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/playground/SearchPlayground.tsx create mode 100644 src/app/(dashboard)/dashboard/search-tools/SearchToolsClient.tsx create mode 100644 src/app/(dashboard)/dashboard/search-tools/components/ProviderComparison.tsx create mode 100644 src/app/(dashboard)/dashboard/search-tools/components/RerankPanel.tsx create mode 100644 src/app/(dashboard)/dashboard/search-tools/components/ResultsPanel.tsx create mode 100644 src/app/(dashboard)/dashboard/search-tools/components/SearchForm.tsx create mode 100644 src/app/(dashboard)/dashboard/search-tools/components/SearchHistory.tsx create mode 100644 src/app/(dashboard)/dashboard/search-tools/page.tsx create mode 100644 src/app/api/search/providers/route.ts create mode 100644 src/app/api/search/stats/route.ts diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index ab9a8b2668..baf0e7febe 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -75,7 +75,12 @@ interface SearchHandlerOptions { timeRange?: string; offset?: number; domainFilter?: string[]; - contentOptions?: { snippet?: boolean; full_page?: boolean; format?: string; max_characters?: number }; + contentOptions?: { + snippet?: boolean; + full_page?: boolean; + format?: string; + max_characters?: number; + }; strictFilters?: boolean; providerOptions?: Record; credentials: Record; @@ -189,7 +194,9 @@ function normalizeBraveResponse( searchType: string ): { results: SearchResult[]; totalResults: number | null } { const now = new Date().toISOString(); - const container = searchType === "news" ? data.news : data.web; + // Brave news endpoint returns { results: [...] } directly, + // while web endpoint returns { web: { results: [...] } } + const container = searchType === "news" ? data.news || data : data.web; const items = container?.results; if (!Array.isArray(items)) return { results: [], totalResults: null }; @@ -593,7 +600,9 @@ async function tryProvider( search_type: searchType, max_results: maxResults, }, - }).catch(() => { /* non-critical — logging must not block search response */ }); + }).catch(() => { + /* non-critical — logging must not block search response */ + }); return { success: false, @@ -603,7 +612,10 @@ async function tryProvider( } const data = await response.json(); - const { results, totalResults } = normalizeResponse(config.id, data, query, searchType); + const normalized = normalizeResponse(config.id, data, query, searchType); + // Enforce max_results — some providers return more than requested + const results = normalized.results.slice(0, maxResults); + const totalResults = normalized.totalResults; const duration = Date.now() - startTime; saveCallLog({ @@ -617,7 +629,9 @@ async function tryProvider( tokens: { prompt_tokens: 0, completion_tokens: 0 }, requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults }, responseBody: { results_count: results.length, cached: false }, - }).catch(() => { /* non-critical — logging must not block search response */ }); + }).catch(() => { + /* non-critical — logging must not block search response */ + }); return { success: true, @@ -653,7 +667,9 @@ async function tryProvider( requestType: "search", error: err.message, requestBody: { query: query.slice(0, 200), search_type: searchType, max_results: maxResults }, - }).catch(() => { /* non-critical — logging must not block search response */ }); + }).catch(() => { + /* non-critical — logging must not block search response */ + }); return { success: false, diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index b2c552f531..cc8cf8d3ea 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -39,10 +39,10 @@ export default function APIPageClient({ machineId }) { const fetchSearchProviders = async () => { try { - const res = await fetch("/v1/search"); + const res = await fetch("/api/search/providers"); if (res.ok) { const data = await res.json(); - setSearchProviders(data.data || []); + setSearchProviders(data.providers || []); } } catch { // Search endpoint may not be available diff --git a/src/app/(dashboard)/dashboard/playground/SearchPlayground.tsx b/src/app/(dashboard)/dashboard/playground/SearchPlayground.tsx new file mode 100644 index 0000000000..52ba7babaa --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/SearchPlayground.tsx @@ -0,0 +1,406 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import dynamic from "next/dynamic"; +import { useTranslations } from "next-intl"; +import { Card, Button, Select, Badge } from "@/shared/components"; + +const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); + +interface SearchProvider { + id: string; + name: string; + status: "active" | "no_credentials"; + cost_per_query: number; +} + +interface SearchResult { + title: string; + url: string; + snippet: string; + score?: number; + date?: string; +} + +interface SearchResponse { + id: string; + provider: string; + results: SearchResult[]; + query: string; + answer: string | null; + cached: boolean; + usage: { + queries_used: number; + search_cost_usd: number; + }; + metrics: { + response_time_ms: number; + upstream_latency_ms: number; + total_results_available: number | null; + }; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; +} + +export default function SearchPlayground() { + const t = useTranslations("search"); + const [providers, setProviders] = useState([]); + const [selectedProvider, setSelectedProvider] = useState(""); + const [requestBody, setRequestBody] = useState( + JSON.stringify( + { + query: "latest AI developments", + max_results: 5, + search_type: "web", + }, + null, + 2 + ) + ); + const [response, setResponse] = useState(null); + const [rawResponse, setRawResponse] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [duration, setDuration] = useState(0); + const [statusCode, setStatusCode] = useState(0); + const [showJson, setShowJson] = useState(false); + const abortRef = useRef(null); + + useEffect(() => { + fetch("/api/search/providers") + .then((res) => res.json()) + .then((data) => { + const allProviders = data.providers || []; + setProviders(allProviders); + const firstActive = allProviders.find((p: SearchProvider) => p.status === "active"); + if (firstActive) setSelectedProvider(firstActive.id); + }) + .catch(() => {}); + }, []); + + const handleSend = async () => { + setLoading(true); + setError(""); + setResponse(null); + setRawResponse(""); + setStatusCode(0); + + const controller = new AbortController(); + abortRef.current = controller; + const timeout = setTimeout(() => controller.abort(), 15_000); + const start = Date.now(); + + try { + let body: any; + try { + body = JSON.parse(requestBody); + } catch { + setError("Invalid JSON in request body"); + setLoading(false); + clearTimeout(timeout); + return; + } + + if (selectedProvider) body.provider = selectedProvider; + + 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(); + setRawResponse(JSON.stringify(data, null, 2)); + + 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("Request timed out (15s)"); + } else { + setError(err.message || "Network error"); + } + } finally { + setLoading(false); + clearTimeout(timeout); + } + }; + + const handleCancel = () => { + abortRef.current?.abort(); + }; + + const getScoreColor = (score: number) => { + if (score >= 0.9) return "text-success"; + if (score >= 0.7) return "text-warning"; + return "text-error"; + }; + + const getScoreBg = (score: number) => { + if (score >= 0.9) return "bg-green-500/10"; + if (score >= 0.7) return "bg-yellow-500/10"; + return "bg-red-500/10"; + }; + + const noProviders = providers.filter((p) => p.status === "active").length === 0; + + const editorTheme = + typeof document !== "undefined" && document.documentElement.classList.contains("dark") + ? "vs-dark" + : "light"; + + return ( +
+ {/* Request panel */} + +
+
+
+ upload +

Request

+ + POST /v1/search + +
+
+ + +
+
+
+ setRequestBody(value || "")} + theme={editorTheme} + options={{ + minimap: { enabled: false }, + fontSize: 12, + lineNumbers: "on", + scrollBeyondLastLine: false, + wordWrap: "on", + automaticLayout: true, + formatOnPaste: true, + }} + /> +
+
+
+ handleProviderChange(e.target.value)} - options={providers} - className="w-full" - /> -
- - {/* Model */} -
- - handleProviderChange(e.target.value)} + options={providers} + className="w-full" + /> +
+ )} + + {/* Model — hidden in search mode */} + {!isSearchEndpoint && ( +
+ + - {uploadedFile && ( -

- - check_circle - - {uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB) -

+ {/* Search mode — isolated sub-component */} + {isSearchEndpoint ? ( + + ) : ( + <> + {/* File Upload Zone — shown for transcription and vision models */} + {(isTranscriptionEndpoint || supportsVision) && ( + +
+
+ + attach_file + +

+ {isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"} +

+ {isTranscriptionEndpoint && ( + + multipart/form-data + + )} + {supportsVision && ( + + up to 4 images + + )} +
+ {isTranscriptionEndpoint && ( +
+ + {uploadedFile && ( +

+ + check_circle + + {uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB) +

+ )} + {!uploadedFile && ( +

+ info + Select an audio file to transcribe (mp3, wav, m4a, ogg, flac…) +

+ )} +
)} - {!uploadedFile && ( -

- info - Select an audio file to transcribe (mp3, wav, m4a, ogg, flac…) -

- )} -
- )} - {supportsVision && ( -
- - {uploadedImages.length > 0 && ( -
- {uploadedImages.map((src, i) => ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`Attached + {supportsVision && ( +
+ + {uploadedImages.length > 0 && ( +
+ {uploadedImages.map((src, i) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Attached + +
+ ))}
- ))} + )} +
+ )} +
+ + )} + + {/* Split Editor View */} +
+ {/* Request Panel */} + +
+
+
+ + upload + +

Request

+ + POST {ENDPOINT_PATHS[selectedEndpoint]} + +
+
+
- )} -
- )} -
-
- )} - - {/* Split Editor View */} -
- {/* Request Panel */} - -
-
-
- - upload - -

Request

- - POST {ENDPOINT_PATHS[selectedEndpoint]} - -
-
- - -
-
- {isTranscriptionEndpoint && ( -

- - info - - Transcription uses multipart/form-data. Upload the audio file above — JSON below - controls extra params (model, language). -

- )} -
- setRequestBody(value || "")} - theme="vs-dark" - options={{ - minimap: { enabled: false }, - fontSize: 12, - lineNumbers: "on", - scrollBeyondLastLine: false, - wordWrap: "on", - automaticLayout: true, - formatOnPaste: true, - }} - /> -
-
-
- - {/* Response Panel */} - -
-
-
- - download - -

Response

- {responseStatus !== null && ( - = 200 && responseStatus < 300 ? "success" : "error"} - size="sm" - > - {responseStatus} - - )} - {responseDuration !== null && ( - {responseDuration}ms - )} - {loading && ( - - progress_activity - - )} -
-
- -
-
-
- {audioUrl ? ( - - ) : imageData ? ( - - ) : transcriptionText !== null ? ( -
-

- Transcription + {isTranscriptionEndpoint && ( +

+ + info + + Transcription uses multipart/form-data. Upload the audio file above — JSON below + controls extra params (model, language).

-
- {transcriptionText} -
- + )} +
+ setRequestBody(value || "")} + theme="vs-dark" + options={{ + minimap: { enabled: false }, + fontSize: 12, + lineNumbers: "on", + scrollBeyondLastLine: false, + wordWrap: "on", + automaticLayout: true, + formatOnPaste: true, + }} + />
- ) : ( - - )} -
+
+ + + {/* Response Panel */} + +
+
+
+ + download + +

Response

+ {responseStatus !== null && ( + = 200 && responseStatus < 300 ? "success" : "error" + } + size="sm" + > + {responseStatus} + + )} + {responseDuration !== null && ( + {responseDuration}ms + )} + {loading && ( + + progress_activity + + )} +
+
+ +
+
+
+ {audioUrl ? ( + + ) : imageData ? ( + + ) : transcriptionText !== null ? ( +
+

+ Transcription +

+
+ {transcriptionText} +
+ +
+ ) : ( + + )} +
+
+
-
-
+ + )}
); } diff --git a/src/app/(dashboard)/dashboard/search-tools/SearchToolsClient.tsx b/src/app/(dashboard)/dashboard/search-tools/SearchToolsClient.tsx new file mode 100644 index 0000000000..52f3ff9be7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/search-tools/SearchToolsClient.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { useTranslations } from "next-intl"; +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"; + +interface SearchProvider { + id: string; + name: string; + status: "active" | "no_credentials"; + cost_per_query: number; +} + +interface SearchResult { + title: string; + url: string; + snippet: string; + score?: number; +} + +interface SearchResponse { + id: string; + provider: string; + query: 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; + }; +} + +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); + + useEffect(() => { + fetch("/api/search/providers") + .then((res) => res.json()) + .then((data) => setProviders(data.providers || [])) + .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("Request timed out (15s)"); + } else { + setError(err.message || "Network error"); + } + } finally { + setLoading(false); + clearTimeout(timeout); + } + }; + + 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, + }); + }; + + return ( +
+
+ + +
+ +
+ + + {response && ( +
+ + +
+ )} + + {showCompare && initialCompareResult && ( +
+ setShowCompare(false)} + /> +
+ )} + + {showRerank && response && ( +
+ setShowRerank(false)} + /> +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/search-tools/components/ProviderComparison.tsx b/src/app/(dashboard)/dashboard/search-tools/components/ProviderComparison.tsx new file mode 100644 index 0000000000..da75351471 --- /dev/null +++ b/src/app/(dashboard)/dashboard/search-tools/components/ProviderComparison.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +export interface CompareResult { + provider: string; + latency: number; + cost: number; + resultCount: number; + responseSize: number; + urls: string[]; + error?: string; +} + +interface ProviderComparisonProps { + initialProvider: string; + initialResult: CompareResult; + otherResults: CompareResult[]; + loading: boolean; + onClose: () => void; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; +} + +export default function ProviderComparison({ + initialProvider, + initialResult, + otherResults, + loading, + onClose, +}: ProviderComparisonProps) { + const t = useTranslations("search"); + + const allResults = [initialResult, ...otherResults]; + const initialUrls = new Set(initialResult.urls); + + const valid = allResults.filter((r) => !r.error); + const latencies = valid.map((r) => r.latency); + const costs = valid.map((r) => r.cost); + const sizes = valid.map((r) => r.responseSize); + const bestLatency = Math.min(...latencies); + const worstLatency = Math.max(...latencies); + const bestCost = Math.min(...costs); + const worstCost = Math.max(...costs); + const bestSize = Math.min(...sizes); + const worstSize = Math.max(...sizes); + + const getLatencyColor = (val: number) => { + if (val === bestLatency) return "text-success font-medium"; + if (val === worstLatency) return "text-warning"; + return "text-text-main"; + }; + + const getCostColor = (val: number) => { + if (val === bestCost) return "text-success font-medium"; + if (val === worstCost) return "text-warning"; + return "text-text-main"; + }; + + const getSizeColor = (val: number) => { + if (val === bestSize) return "text-success font-medium"; + if (val === worstSize) return "text-warning"; + return "text-text-main"; + }; + + return ( +
+
+ + ⇕ {t("compareProviders")} + + +
+
+ {loading ? ( +
+ + progress_activity + + {t("compareProviders")}... +
+ ) : ( + + + + + ))} + + + + + + {allResults.map((r) => ( + + ))} + + + + {allResults.map((r) => ( + + ))} + + + + {allResults.map((r) => ( + + ))} + + + + {allResults.map((r) => ( + + ))} + + + + {allResults.map((r) => ( + + ))} + + +
+ {allResults.map((r) => ( + + {r.provider.replace("-search", "")} + {r.provider === initialProvider && " ✓"} +
{t("latency")} + {r.error ? "Error" : `${r.latency}ms`} +
{t("cost")} + {r.error ? "Error" : `$${r.cost.toFixed(4)}`} +
{t("results")} + {r.error ? "Error" : r.resultCount} +
Size + {r.error ? "Error" : formatBytes(r.responseSize)} +
{t("urlOverlap")} + {r.provider === initialProvider + ? "—" + : r.error + ? "Error" + : `${r.urls.filter((u) => initialUrls.has(u)).length}/${r.resultCount}`} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/search-tools/components/RerankPanel.tsx b/src/app/(dashboard)/dashboard/search-tools/components/RerankPanel.tsx new file mode 100644 index 0000000000..6c6d60746f --- /dev/null +++ b/src/app/(dashboard)/dashboard/search-tools/components/RerankPanel.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Select } from "@/shared/components"; + +interface RerankResult { + index: number; + originalIndex: number; + title: string; + snippet: string; + score: number; + delta: number; +} + +interface RerankPanelProps { + query: string; + results: { title: string; snippet: string; url: string }[]; + onClose: () => void; +} + +export default function RerankPanel({ query, results, onClose }: RerankPanelProps) { + const t = useTranslations("search"); + const [models, setModels] = useState<{ value: string; label: string }[]>([]); + const [selectedModel, setSelectedModel] = useState(""); + const [reranked, setReranked] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + fetch("/v1/models") + .then((res) => res.json()) + .then((data) => { + const rerankModels = (data?.data || []) + .filter((m: any) => m.id.toLowerCase().includes("rerank")) + .map((m: any) => ({ value: m.id, label: m.id })); + setModels(rerankModels); + if (rerankModels.length > 0) setSelectedModel(rerankModels[0].value); + }) + .catch(() => {}); + }, []); + + const handleRerank = async () => { + setLoading(true); + setError(""); + try { + const res = await fetch("/api/v1/rerank", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: selectedModel, + query, + documents: results.map((r) => r.snippet), + top_n: results.length, + }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error?.message || data.error || `Error ${res.status}`); + return; + } + + const rerankedResults: RerankResult[] = (data.results || []).map( + (r: any, newIndex: number) => { + const origIndex = r.index; + return { + index: newIndex, + originalIndex: origIndex, + title: results[origIndex]?.title || "", + snippet: results[origIndex]?.snippet || "", + score: r.relevance_score, + delta: origIndex - newIndex, + }; + } + ); + setReranked(rerankedResults); + } catch (err: any) { + setError(err.message || "Rerank failed"); + } finally { + setLoading(false); + } + }; + + const getDeltaDisplay = (delta: number) => { + if (delta > 0) return ↑{delta}; + if (delta < 0) return ↓{Math.abs(delta)}; + return =; + }; + + const noModels = models.length === 0; + + return ( +
+
+ + ⇅ {t("rerankResults")} + + +
+
+ {noModels ? ( +

{t("noRerankModels")}

+ ) : ( + <> +
+
+ +