Merge pull request #443 from Regis-RCR/feat/search-playground

feat(search): add search playground, search tools, and local rerank routing
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-03-18 12:09:51 -03:00
committed by GitHub
16 changed files with 2245 additions and 310 deletions

View File

@@ -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<string, unknown>;
credentials: Record<string, any>;
@@ -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,

View File

@@ -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

View File

@@ -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<SearchProvider[]>([]);
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<SearchResponse | null>(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<AbortController | null>(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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">upload</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST /v1/search
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => navigator.clipboard.writeText(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() =>
setRequestBody(
JSON.stringify(
{
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
null,
2
)
)
}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme={editorTheme}
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
<div className="flex items-center gap-3">
<div className="flex-1">
<Select
value={selectedProvider}
onChange={(e: any) => setSelectedProvider(e.target.value)}
options={providers.map((p) => ({
value: p.id,
label: `${p.name}${p.status === "no_credentials" ? " (no key)" : ""}`,
}))}
className="w-full"
/>
</div>
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="search"
onClick={handleSend}
disabled={noProviders || !requestBody.trim()}
>
{t("webSearch")}
</Button>
)}
</div>
{noProviders && <p className="text-xs text-text-muted">{t("noSearchProviders")}</p>}
</div>
</Card>
{/* Response panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{statusCode > 0 && (
<>
<Badge variant={statusCode < 400 ? "success" : "error"} size="sm">
{statusCode}
</Badge>
<span className="text-xs text-text-muted">{duration}ms</span>
</>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
{response && (
<div className="flex gap-1">
<button
className={`text-xs px-3 py-1 rounded-md ${
!showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(false)}
>
{t("formatted")}
</button>
<button
className={`text-xs px-3 py-1 rounded-md ${
showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(true)}
>
{t("rawJson")}
</button>
</div>
)}
</div>
<div className="border border-border rounded-lg overflow-hidden min-h-[400px]">
{loading && (
<div className="flex items-center justify-center h-[400px]">
<span className="material-symbols-outlined text-[24px] text-primary animate-spin">
progress_activity
</span>
</div>
)}
{error && !loading && (
<div className="p-4">
<div className="text-error text-sm">{error}</div>
</div>
)}
{response && !showJson && !loading && (
<div className="p-4 space-y-3">
{/* Meta bar */}
<div className="flex justify-between items-center p-2 bg-bg-alt rounded-lg">
<div className="flex items-center gap-3 text-xs text-text-muted">
<span>
{response.results.length} {t("searchResults").toLowerCase()}
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
{response.provider}
</span>
<span>${response.usage?.search_cost_usd?.toFixed(4)}</span>
<span>{formatBytes(rawResponse.length)}</span>
</div>
<span
className={`text-xs flex items-center gap-1 ${
response.cached ? "text-success" : "text-warning"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
response.cached ? "bg-success" : "bg-warning"
}`}
/>
{response.cached ? t("cacheHit") : t("cacheMiss")}
</span>
</div>
{/* Results */}
{response.results.map((r, i) => (
<div
key={i}
className="border-l-[3px] border-l-primary p-3 bg-surface rounded-r-lg border border-border"
>
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-text-main">
{i + 1}. {r.title}
</span>
{r.score != null && (
<span
className={`text-[10px] px-2 py-0.5 rounded-md ml-2 whitespace-nowrap ${getScoreBg(r.score)} ${getScoreColor(r.score)}`}
>
{r.score.toFixed(2)}
</span>
)}
</div>
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent text-[11px] block mt-0.5"
>
{r.url}
</a>
<p className="text-xs text-text-muted mt-1 leading-relaxed">{r.snippet}</p>
</div>
))}
</div>
)}
{response && showJson && !loading && (
<Editor
height="400px"
defaultLanguage="json"
value={rawResponse}
theme={editorTheme}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
}}
/>
)}
{!loading && !error && !response && (
<div className="flex items-center justify-center h-[400px] text-text-muted text-sm">
{t("emptyState")}
</div>
)}
</div>
</div>
</Card>
</div>
);
}

View File

@@ -5,6 +5,9 @@ import { Card, Button, Select, Badge } from "@/shared/components";
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
const SearchPlayground = dynamic(() => import("./SearchPlayground"), {
ssr: false,
});
interface ModelInfo {
id: string;
@@ -27,6 +30,7 @@ const ENDPOINT_OPTIONS = [
{ value: "video", label: "Video Generation" },
{ value: "music", label: "Music Generation" },
{ value: "rerank", label: "Rerank" },
{ value: "search", label: "Web Search" },
];
const DEFAULT_BODIES: Record<string, object> = {
@@ -83,6 +87,11 @@ const DEFAULT_BODIES: Record<string, object> = {
],
top_n: 2,
},
search: {
query: "latest AI developments",
max_results: 5,
search_type: "web",
},
};
const ENDPOINT_PATHS: Record<string, string> = {
@@ -95,6 +104,7 @@ const ENDPOINT_PATHS: Record<string, string> = {
video: "/v1/videos/generations",
music: "/v1/music/generations",
rerank: "/v1/rerank",
search: "/v1/search",
};
// Models known to support vision (image input)
@@ -189,6 +199,7 @@ export default function PlaygroundPage() {
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const [uploadedImages, setUploadedImages] = useState<string[]>([]); // base64 URIs for vision
const isSearchEndpoint = selectedEndpoint === "search";
const isTranscriptionEndpoint = selectedEndpoint === "transcription";
const isChatEndpoint = selectedEndpoint === "chat";
const isImageEndpoint = selectedEndpoint === "images";
@@ -419,33 +430,7 @@ export default function PlaygroundPage() {
{/* Controls */}
<Card>
<div className="p-4 flex flex-col sm:flex-row items-end gap-4">
{/* Provider */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Provider
</label>
<Select
value={selectedProvider}
onChange={(e: any) => handleProviderChange(e.target.value)}
options={providers}
className="w-full"
/>
</div>
{/* Model */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
</label>
<Select
value={selectedModel}
onChange={(e: any) => handleModelChange(e.target.value)}
options={filteredModels}
className="w-full"
/>
</div>
{/* Endpoint */}
{/* Endpoint — always first */}
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Endpoint
@@ -458,274 +443,315 @@ export default function PlaygroundPage() {
/>
</div>
{/* Send Button */}
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="send"
onClick={handleSend}
disabled={
(!requestBody.trim() && !isTranscriptionEndpoint) ||
(!selectedModel && !isTranscriptionEndpoint)
}
>
Send
</Button>
)}
</div>
{/* Provider — hidden in search mode */}
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Provider
</label>
<Select
value={selectedProvider}
onChange={(e: any) => handleProviderChange(e.target.value)}
options={providers}
className="w-full"
/>
</div>
)}
{/* Model — hidden in search mode */}
{!isSearchEndpoint && (
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
Model
</label>
<Select
value={selectedModel}
onChange={(e: any) => handleModelChange(e.target.value)}
options={filteredModels}
className="w-full"
/>
</div>
)}
{/* Send Button — hidden in search mode (SearchPlayground has its own) */}
{!isSearchEndpoint && (
<div className="shrink-0">
{loading ? (
<Button icon="stop" variant="secondary" onClick={handleCancel}>
Cancel
</Button>
) : (
<Button
icon="send"
onClick={handleSend}
disabled={
(!requestBody.trim() && !isTranscriptionEndpoint) ||
(!selectedModel && !isTranscriptionEndpoint)
}
>
Send
</Button>
)}
</div>
)}
</div>
</Card>
{/* File Upload Zone — shown for transcription and vision models */}
{(isTranscriptionEndpoint || supportsVision) && (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
attach_file
</span>
<h3 className="text-sm font-semibold text-text-main">
{isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"}
</h3>
{isTranscriptionEndpoint && (
<Badge variant="info" size="sm">
multipart/form-data
</Badge>
)}
{supportsVision && (
<Badge variant="info" size="sm">
up to 4 images
</Badge>
)}
</div>
{isTranscriptionEndpoint && (
<div>
<input
type="file"
accept="audio/*,video/*"
onChange={handleAudioFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedFile && (
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px] text-green-500">
check_circle
</span>
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
</p>
{/* Search mode — isolated sub-component */}
{isSearchEndpoint ? (
<SearchPlayground />
) : (
<>
{/* File Upload Zone — shown for transcription and vision models */}
{(isTranscriptionEndpoint || supportsVision) && (
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
attach_file
</span>
<h3 className="text-sm font-semibold text-text-main">
{isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"}
</h3>
{isTranscriptionEndpoint && (
<Badge variant="info" size="sm">
multipart/form-data
</Badge>
)}
{supportsVision && (
<Badge variant="info" size="sm">
up to 4 images
</Badge>
)}
</div>
{isTranscriptionEndpoint && (
<div>
<input
type="file"
accept="audio/*,video/*"
onChange={handleAudioFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedFile && (
<p className="text-xs text-text-muted mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px] text-green-500">
check_circle
</span>
{uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB)
</p>
)}
{!uploadedFile && (
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">info</span>
Select an audio file to transcribe (mp3, wav, m4a, ogg, flac)
</p>
)}
</div>
)}
{!uploadedFile && (
<p className="text-xs text-amber-500 mt-1 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">info</span>
Select an audio file to transcribe (mp3, wav, m4a, ogg, flac)
</p>
)}
</div>
)}
{supportsVision && (
<div>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedImages.length > 0 && (
<div className="flex gap-2 mt-2 flex-wrap">
{uploadedImages.map((src, i) => (
<div
key={i}
className="relative group size-16 rounded overflow-hidden border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`Attached ${i + 1}`}
className="w-full h-full object-cover"
/>
{supportsVision && (
<div>
<input
type="file"
accept="image/*"
multiple
onChange={handleImageFileChange}
className="w-full px-3 py-2 rounded-lg bg-surface border border-border text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:bg-primary/10 file:text-primary file:text-sm"
/>
{uploadedImages.length > 0 && (
<div className="flex gap-2 mt-2 flex-wrap">
{uploadedImages.map((src, i) => (
<div
key={i}
className="relative group size-16 rounded overflow-hidden border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`Attached ${i + 1}`}
className="w-full h-full object-cover"
/>
<button
onClick={() =>
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
}
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
<button
onClick={() =>
setUploadedImages((prev) => prev.filter((_, idx) => idx !== i))
}
className="absolute inset-0 bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
>
<span className="material-symbols-outlined text-[16px]">close</span>
Clear all
</button>
</div>
))}
)}
</div>
)}
</div>
</Card>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setUploadedImages([])}
className="text-xs text-text-muted hover:text-red-500 self-center ml-1"
onClick={() => handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
Clear all
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as any).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
)}
</div>
)}
</div>
</Card>
)}
{/* Split Editor View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Request Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
upload
</span>
<h3 className="text-sm font-semibold text-text-main">Request</h3>
<Badge variant="info" size="sm">
POST {ENDPOINT_PATHS[selectedEndpoint]}
</Badge>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(requestBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
<button
onClick={() => {
const template = { ...DEFAULT_BODIES[selectedEndpoint] };
if ("model" in template) (template as any).model = selectedModel;
setRequestBody(JSON.stringify(template, null, 2));
}}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Reset to default"
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
</button>
</div>
</div>
{isTranscriptionEndpoint && (
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
info
</span>
Transcription uses multipart/form-data. Upload the audio file above JSON below
controls extra params (model, language).
</p>
)}
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
</div>
</Card>
{/* Response Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{responseStatus !== null && (
<Badge
variant={responseStatus >= 200 && responseStatus < 300 ? "success" : "error"}
size="sm"
>
{responseStatus}
</Badge>
)}
{responseDuration !== null && (
<span className="text-xs text-text-muted">{responseDuration}ms</span>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
{audioUrl ? (
<div className="p-4 space-y-3">
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
<a
href={audioUrl}
download="speech.mp3"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
Download audio
</a>
</div>
) : imageData ? (
<ImageResultsInline data={imageData} />
) : transcriptionText !== null ? (
<div className="p-4 space-y-2">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
Transcription
{isTranscriptionEndpoint && (
<p className="text-xs text-text-muted bg-amber-500/10 border border-amber-500/20 rounded px-2 py-1.5 flex items-start gap-1">
<span className="material-symbols-outlined text-[12px] text-amber-500 mt-0.5">
info
</span>
Transcription uses multipart/form-data. Upload the audio file above JSON below
controls extra params (model, language).
</p>
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{transcriptionText}
</div>
<button
onClick={() => handleCopy(transcriptionText)}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
Copy text
</button>
)}
<div className="border border-border rounded-lg overflow-hidden">
<Editor
height="400px"
defaultLanguage="json"
value={requestBody}
onChange={(value: string | undefined) => setRequestBody(value || "")}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
formatOnPaste: true,
}}
/>
</div>
) : (
<Editor
height="400px"
defaultLanguage="json"
value={responseBody}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
)}
</div>
</div>
</Card>
{/* Response Panel */}
<Card>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">
download
</span>
<h3 className="text-sm font-semibold text-text-main">Response</h3>
{responseStatus !== null && (
<Badge
variant={
responseStatus >= 200 && responseStatus < 300 ? "success" : "error"
}
size="sm"
>
{responseStatus}
</Badge>
)}
{responseDuration !== null && (
<span className="text-xs text-text-muted">{responseDuration}ms</span>
)}
{loading && (
<span className="material-symbols-outlined text-[14px] text-primary animate-spin">
progress_activity
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleCopy(responseBody)}
className="p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Copy"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</div>
</div>
<div className="border border-border rounded-lg overflow-hidden">
{audioUrl ? (
<div className="p-4 space-y-3">
<audio controls src={audioUrl} className="w-full rounded-lg" autoPlay />
<a
href={audioUrl}
download="speech.mp3"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
<span className="material-symbols-outlined text-[16px]">download</span>
Download audio
</a>
</div>
) : imageData ? (
<ImageResultsInline data={imageData} />
) : transcriptionText !== null ? (
<div className="p-4 space-y-2">
<p className="text-xs text-text-muted font-medium uppercase tracking-wider">
Transcription
</p>
<div className="bg-surface/50 rounded p-3 text-sm text-text-main leading-relaxed whitespace-pre-wrap">
{transcriptionText}
</div>
<button
onClick={() => handleCopy(transcriptionText)}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<span className="material-symbols-outlined text-[12px]">content_copy</span>
Copy text
</button>
</div>
) : (
<Editor
height="400px"
defaultLanguage="json"
value={responseBody}
theme="vs-dark"
options={{
minimap: { enabled: false },
fontSize: 12,
lineNumbers: "on",
scrollBeyondLastLine: false,
wordWrap: "on",
automaticLayout: true,
readOnly: true,
}}
/>
)}
</div>
</div>
</Card>
</div>
</Card>
</div>
</>
)}
</div>
);
}

View File

@@ -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<SearchProvider[]>([]);
const [response, setResponse] = useState<SearchResponse | null>(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<SearchFormData | null>(null);
const abortRef = useRef<AbortController | null>(null);
const [showCompare, setShowCompare] = useState(false);
const [compareLoading, setCompareLoading] = useState(false);
const [compareResults, setCompareResults] = useState<CompareResult[]>([]);
const [initialCompareResult, setInitialCompareResult] = useState<CompareResult | null>(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 (
<div className="flex h-[calc(100vh-120px)]">
<div className="w-[340px] flex-shrink-0 bg-bg-alt border-r border-border overflow-y-auto flex flex-col">
<SearchForm
onSearch={handleSearch}
loading={loading}
onCancel={handleCancel}
providers={providers}
/>
<SearchHistory onReplay={handleHistoryReplay} />
</div>
<div className="flex-1 overflow-y-auto">
<ResultsPanel
response={response}
rawJson={rawJson}
loading={loading}
error={error}
statusCode={statusCode}
duration={duration}
/>
{response && (
<div className="px-4 py-2 flex gap-2">
<button
className="flex-1 bg-surface border border-border rounded-lg p-2 text-center hover:border-accent/30 transition-colors flex items-center justify-center gap-2"
onClick={handleCompare}
disabled={compareLoading}
>
<span className="text-accent text-sm">&#8693;</span>
<span className="text-xs text-text-muted">{t("compareProviders")}</span>
</button>
<button
className="flex-1 bg-surface border border-border rounded-lg p-2 text-center hover:border-primary/30 transition-colors flex items-center justify-center gap-2"
onClick={() => setShowRerank(!showRerank)}
>
<span className="text-primary text-sm">&#8645;</span>
<span className="text-xs text-text-muted">{t("rerankResults")}</span>
</button>
</div>
)}
{showCompare && initialCompareResult && (
<div className="px-4 pb-3">
<ProviderComparison
initialProvider={response!.provider}
initialResult={initialCompareResult}
otherResults={compareResults}
loading={compareLoading}
onClose={() => setShowCompare(false)}
/>
</div>
)}
{showRerank && response && (
<div className="px-4 pb-3">
<RerankPanel
query={response.query}
results={response.results}
onClose={() => setShowRerank(false)}
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="bg-surface border border-accent/20 rounded-lg overflow-hidden">
<div className="flex justify-between items-center px-4 py-2.5 bg-accent/5 border-b border-accent/15">
<span className="text-xs font-semibold text-accent flex items-center gap-1.5">
{t("compareProviders")}
</span>
<button onClick={onClose} className="text-text-muted text-xs hover:text-text-main">
</button>
</div>
<div className="p-3 overflow-x-auto">
{loading ? (
<div className="flex items-center justify-center py-8">
<span className="material-symbols-outlined text-[20px] text-accent animate-spin">
progress_activity
</span>
<span className="text-xs text-text-muted ml-2">{t("compareProviders")}...</span>
</div>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left p-2 text-text-muted font-semibold" />
{allResults.map((r) => (
<th
key={r.provider}
className={`text-center p-2 font-semibold ${
r.provider === initialProvider ? "text-primary" : "text-text-muted"
}`}
>
{r.provider.replace("-search", "")}
{r.provider === initialProvider && " ✓"}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("latency")}</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getLatencyColor(r.latency)}`}
>
{r.error ? "Error" : `${r.latency}ms`}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("cost")}</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getCostColor(r.cost)}`}
>
{r.error ? "Error" : `$${r.cost.toFixed(4)}`}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("results")}</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : "text-text-main"}`}
>
{r.error ? "Error" : r.resultCount}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">Size</td>
{allResults.map((r) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getSizeColor(r.responseSize)}`}
>
{r.error ? "Error" : formatBytes(r.responseSize)}
</td>
))}
</tr>
<tr>
<td className="p-2 text-text-muted">{t("urlOverlap")}</td>
{allResults.map((r) => (
<td key={r.provider} className="text-center p-2 text-text-main">
{r.provider === initialProvider
? "—"
: r.error
? "Error"
: `${r.urls.filter((u) => initialUrls.has(u)).length}/${r.resultCount}`}
</td>
))}
</tr>
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@@ -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<RerankResult[]>([]);
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 <span className="text-success">{delta}</span>;
if (delta < 0) return <span className="text-error">{Math.abs(delta)}</span>;
return <span className="text-text-muted">=</span>;
};
const noModels = models.length === 0;
return (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border">
<span className="text-xs font-semibold text-text-main flex items-center gap-1.5">
{t("rerankResults")}
</span>
<button onClick={onClose} className="text-text-muted text-xs hover:text-text-main">
</button>
</div>
<div className="p-4">
{noModels ? (
<p className="text-xs text-text-muted">{t("noRerankModels")}</p>
) : (
<>
<div className="flex gap-2 items-end mb-3">
<div className="flex-1">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("rerankModel")}
</label>
<Select
value={selectedModel}
onChange={(e: any) => setSelectedModel(e.target.value)}
options={models}
className="w-full"
/>
</div>
<Button variant="primary" onClick={handleRerank} disabled={loading || !selectedModel}>
{loading ? "Reranking..." : t("rerank")}
</Button>
</div>
{error && <p className="text-xs text-error mb-2">{error}</p>}
{reranked.length > 0 && (
<div className="space-y-2">
{reranked.map((r) => (
<div key={r.index} className="flex items-start gap-3 p-2 bg-bg-alt rounded-lg">
<div className="flex flex-col items-center min-w-[32px]">
<span className="text-xs font-medium text-text-main">#{r.index + 1}</span>
<span className="text-[10px]">{getDeltaDisplay(r.delta)}</span>
</div>
<div className="flex-1">
<div className="text-xs font-medium text-text-main">{r.title}</div>
<div className="text-[10px] text-text-muted mt-0.5 line-clamp-2">
{r.snippet}
</div>
</div>
<span className="text-[10px] text-accent whitespace-nowrap">
{r.score.toFixed(4)}
</span>
</div>
))}
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
import { Badge } from "@/shared/components";
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
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;
};
}
interface ResultsPanelProps {
response: SearchResponse | null;
rawJson: string;
loading: boolean;
error: string;
statusCode: number;
duration: number;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${(bytes / 1024).toFixed(1)} KB`;
}
export default function ResultsPanel({
response,
rawJson,
loading,
error,
statusCode,
duration,
}: ResultsPanelProps) {
const t = useTranslations("search");
const [showJson, setShowJson] = useState(false);
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 editorTheme =
typeof document !== "undefined" && document.documentElement.classList.contains("dark")
? "vs-dark"
: "light";
return (
<div className="flex flex-col">
{/* Header */}
<div className="flex justify-between items-center p-3 border-b border-border">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("searchResults")}
</span>
{statusCode > 0 && (
<>
<Badge variant={statusCode < 400 ? "success" : "error"} size="sm">
{statusCode}
</Badge>
<span className="text-xs text-text-muted">{duration}ms</span>
</>
)}
</div>
{response && (
<div className="flex gap-1">
<button
className={`text-xs px-3 py-1 rounded-md ${
!showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(false)}
>
{t("formatted")}
</button>
<button
className={`text-xs px-3 py-1 rounded-md ${
showJson
? "bg-primary/15 text-primary font-medium"
: "bg-black/5 dark:bg-white/5 text-text-muted"
}`}
onClick={() => setShowJson(true)}
>
{t("rawJson")}
</button>
</div>
)}
</div>
{/* Content */}
{loading && (
<div className="flex items-center justify-center py-20">
<span className="material-symbols-outlined text-[24px] text-primary animate-spin">
progress_activity
</span>
</div>
)}
{error && !loading && (
<div className="p-4">
<div className="text-error text-sm">{error}</div>
</div>
)}
{response && !showJson && !loading && (
<div className="p-4 space-y-3">
{/* Meta bar */}
<div className="flex justify-between items-center p-2 bg-bg-alt rounded-lg">
<div className="flex items-center gap-3 text-xs text-text-muted">
<span>
{response.results.length} {t("results").toLowerCase()}
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
{response.provider}
</span>
<span>{response.metrics?.response_time_ms}ms</span>
<span>${response.usage?.search_cost_usd?.toFixed(4)}</span>
<span>{formatBytes(rawJson.length)}</span>
</div>
<span
className={`text-xs flex items-center gap-1 ${
response.cached ? "text-success" : "text-warning"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
response.cached ? "bg-success" : "bg-warning"
}`}
/>
{response.cached ? t("cacheHit") : t("cacheMiss")}
</span>
</div>
{/* Results list */}
{response.results.map((r, i) => (
<div
key={i}
className="border-l-[3px] border-l-primary p-3 bg-surface rounded-r-lg border border-border"
>
<div className="flex justify-between items-start">
<span className="text-sm font-medium text-text-main">
{i + 1}. {r.title}
</span>
{r.score != null && (
<span
className={`text-[10px] px-2 py-0.5 rounded-md ml-2 whitespace-nowrap ${getScoreBg(r.score)} ${getScoreColor(r.score)}`}
>
{r.score.toFixed(2)}
</span>
)}
</div>
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent text-[11px] block mt-0.5"
>
{r.url}
</a>
<p className="text-xs text-text-muted mt-1 leading-relaxed">{r.snippet}</p>
</div>
))}
</div>
)}
{response && showJson && !loading && (
<div className="h-64">
<Editor
height="100%"
language="json"
value={rawJson}
theme={editorTheme}
options={{
readOnly: true,
minimap: { enabled: false },
fontSize: 12,
automaticLayout: true,
wordWrap: "on",
}}
/>
</div>
)}
{!loading && !error && !response && (
<div className="flex items-center justify-center py-20 text-text-muted text-sm">
{t("emptyState")}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,308 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Select } from "@/shared/components";
interface SearchProvider {
id: string;
name: string;
status: "active" | "no_credentials";
cost_per_query: number;
}
export interface SearchFormData {
query: string;
provider: string;
search_type: string;
max_results: number;
country?: string;
language?: string;
time_range?: string;
include_domains?: string[];
exclude_domains?: string[];
safe_search?: string;
}
interface SearchFormProps {
onSearch: (data: SearchFormData) => void;
loading: boolean;
onCancel: () => void;
providers: SearchProvider[];
}
export default function SearchForm({ onSearch, loading, onCancel, providers }: SearchFormProps) {
const t = useTranslations("search");
const [query, setQuery] = useState("");
const [provider, setProvider] = useState("auto");
const [searchType, setSearchType] = useState("web");
const [maxResults, setMaxResults] = useState(5);
const [showFilters, setShowFilters] = useState(false);
const [country, setCountry] = useState("");
const [language, setLanguage] = useState("");
const [timeRange, setTimeRange] = useState("");
const [includeDomains, setIncludeDomains] = useState<string[]>([]);
const [excludeDomains, setExcludeDomains] = useState<string[]>([]);
const [safeSearch, setSafeSearch] = useState("moderate");
const [domainInput, setDomainInput] = useState("");
const [excludeDomainInput, setExcludeDomainInput] = useState("");
const activeProviders = providers.filter((p) => p.status === "active");
const noProviders = activeProviders.length === 0;
const handleSubmit = () => {
const data: SearchFormData = {
query,
provider: provider === "auto" ? "" : provider,
search_type: searchType,
max_results: maxResults,
};
if (country) data.country = country;
if (language) data.language = language;
if (timeRange) data.time_range = timeRange;
if (includeDomains.length > 0) data.include_domains = includeDomains;
if (excludeDomains.length > 0) data.exclude_domains = excludeDomains;
if (safeSearch !== "moderate") data.safe_search = safeSearch;
onSearch(data);
};
const addDomain = (type: "include" | "exclude") => {
const input = type === "include" ? domainInput : excludeDomainInput;
const setter = type === "include" ? setIncludeDomains : setExcludeDomains;
const list = type === "include" ? includeDomains : excludeDomains;
if (input.trim() && !list.includes(input.trim())) {
setter([...list, input.trim()]);
}
type === "include" ? setDomainInput("") : setExcludeDomainInput("");
};
const removeDomain = (domain: string, type: "include" | "exclude") => {
const setter = type === "include" ? setIncludeDomains : setExcludeDomains;
const list = type === "include" ? includeDomains : excludeDomains;
setter(list.filter((d) => d !== domain));
};
return (
<div className="flex flex-col h-full">
{/* Query */}
<div className="p-4 border-b border-border">
<label className="block text-[10px] font-semibold text-text-muted uppercase tracking-wider mb-2">
{t("searchQuery")}
</label>
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Enter search query..."
className="w-full bg-surface border border-border rounded-lg p-2.5 text-sm text-text-main resize-none h-16 focus:outline-none focus:ring-2 focus:ring-primary/30"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!noProviders && query.trim()) handleSubmit();
}
}}
/>
</div>
{/* Provider + Type + Max Results */}
<div className="p-4 border-b border-border space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("provider")}
</label>
<Select
value={provider}
onChange={(e: any) => setProvider(e.target.value)}
options={[
{ value: "auto", label: "auto (cheapest)" },
...activeProviders.map((p) => ({
value: p.id,
label: p.name,
})),
]}
className="w-full"
/>
</div>
<div className="flex-1">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("searchType")}
</label>
<Select
value={searchType}
onChange={(e: any) => setSearchType(e.target.value)}
options={[
{ value: "web", label: "web" },
{ value: "news", label: "news" },
]}
className="w-full"
/>
</div>
</div>
<div className="w-20">
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
{t("maxResults")}
</label>
<input
type="number"
value={maxResults}
onChange={(e) => setMaxResults(parseInt(e.target.value) || 5)}
min={1}
max={100}
className="w-full bg-surface border border-border rounded-lg px-2.5 py-1.5 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
</div>
{/* Filters (collapsible) */}
<div className="p-4 border-b border-border">
<button
className="flex justify-between items-center w-full"
onClick={() => setShowFilters(!showFilters)}
>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
{t("filters")}
</span>
<span className="text-text-muted text-xs">{showFilters ? "▼" : "▶"}</span>
</button>
{showFilters && (
<div className="mt-3 space-y-2">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-[10px] text-text-muted mb-1">{t("country")}</label>
<input
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="any"
className="w-full bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
/>
</div>
<div className="flex-1">
<label className="block text-[10px] text-text-muted mb-1">{t("language")}</label>
<input
value={language}
onChange={(e) => setLanguage(e.target.value)}
placeholder="any"
className="w-full bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
/>
</div>
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">{t("timeRange")}</label>
<Select
value={timeRange}
onChange={(e: any) => setTimeRange(e.target.value)}
options={[
{ value: "", label: "any" },
{ value: "day", label: "Past day" },
{ value: "week", label: "Past week" },
{ value: "month", label: "Past month" },
{ value: "year", label: "Past year" },
]}
className="w-full"
/>
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">
{t("includeDomains")}
</label>
<div className="flex gap-1">
<input
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
placeholder="example.com"
className="flex-1 bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
onKeyDown={(e) => e.key === "Enter" && addDomain("include")}
/>
<button onClick={() => addDomain("include")} className="text-primary text-lg px-1">
+
</button>
</div>
{includeDomains.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{includeDomains.map((d) => (
<span
key={d}
className="text-[10px] bg-primary/10 text-primary px-2 py-0.5 rounded-full flex items-center gap-1"
>
{d}
<button
onClick={() => removeDomain(d, "include")}
className="text-primary/60"
>
×
</button>
</span>
))}
</div>
)}
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">
{t("excludeDomains")}
</label>
<div className="flex gap-1">
<input
value={excludeDomainInput}
onChange={(e) => setExcludeDomainInput(e.target.value)}
placeholder="example.com"
className="flex-1 bg-surface border border-border rounded-md px-2 py-1.5 text-xs text-text-main focus:outline-none"
onKeyDown={(e) => e.key === "Enter" && addDomain("exclude")}
/>
<button onClick={() => addDomain("exclude")} className="text-primary text-lg px-1">
+
</button>
</div>
{excludeDomains.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{excludeDomains.map((d) => (
<span
key={d}
className="text-[10px] bg-error/10 text-error px-2 py-0.5 rounded-full flex items-center gap-1"
>
{d}
<button onClick={() => removeDomain(d, "exclude")} className="text-error/60">
×
</button>
</span>
))}
</div>
)}
</div>
<div>
<label className="block text-[10px] text-text-muted mb-1">{t("safeSearch")}</label>
<Select
value={safeSearch}
onChange={(e: any) => setSafeSearch(e.target.value)}
options={[
{ value: "off", label: "Off" },
{ value: "moderate", label: "Moderate" },
{ value: "strict", label: "Strict" },
]}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Search button */}
<div className="p-4 border-b border-border">
{loading ? (
<Button variant="danger" onClick={onCancel} className="w-full">
Cancel
</Button>
) : (
<Button
variant="primary"
onClick={handleSubmit}
disabled={noProviders || !query.trim()}
className="w-full"
>
Search
</Button>
)}
{noProviders && <p className="text-xs text-text-muted mt-2">{t("noSearchProviders")}</p>}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
interface HistoryEntry {
query: string;
provider: string;
timestamp: string;
filters: Record<string, any>;
}
interface SearchHistoryProps {
onReplay: (entry: HistoryEntry) => void;
}
function timeAgo(timestamp: string): string {
try {
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const diff = Date.now() - new Date(timestamp).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return rtf.format(0, "minute");
if (minutes < 60) return rtf.format(-minutes, "minute");
const hours = Math.floor(minutes / 60);
if (hours < 24) return rtf.format(-hours, "hour");
return rtf.format(-Math.floor(hours / 24), "day");
} catch {
return new Date(timestamp).toLocaleString();
}
}
export default function SearchHistory({ onReplay }: SearchHistoryProps) {
const t = useTranslations("search");
const [entries, setEntries] = useState<HistoryEntry[]>([]);
useEffect(() => {
fetch("/api/search/stats")
.then((res) => res.json())
.then((data) => setEntries(data.recent_searches || []))
.catch(() => {});
}, []);
if (entries.length === 0) return null;
return (
<div className="p-4 flex-1">
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-wider">
{t("searchHistory")}
</span>
<div className="mt-2 space-y-1.5">
{entries.map((entry, i) => (
<button
key={`${entry.timestamp}:${entry.provider}:${entry.query}`}
onClick={() => onReplay(entry)}
className="w-full text-left p-2 bg-surface border border-border rounded-lg hover:border-primary/30 transition-colors"
>
<div className="text-xs text-text-main truncate">{entry.query}</div>
<div className="flex justify-between mt-0.5">
<span className="text-[10px] text-text-muted">{entry.provider}</span>
<span className="text-[10px] text-text-muted">{timeAgo(entry.timestamp)}</span>
</div>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import SearchToolsClient from "./SearchToolsClient";
export default function SearchToolsPage() {
return <SearchToolsClient />;
}

View File

@@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import {
SEARCH_PROVIDERS,
SEARCH_CREDENTIAL_FALLBACKS,
} from "@omniroute/open-sse/config/searchRegistry.ts";
import { getDbInstance } from "@/lib/db/core";
import { isAuthenticated } from "@/shared/utils/apiAuth";
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const db = getDbInstance();
const providers = Object.values(SEARCH_PROVIDERS).map((p) => {
let status: "active" | "no_credentials" = "no_credentials";
try {
const cred = db
.prepare(
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
)
.get(p.id);
// Use canonical fallback mapping (e.g. perplexity-search → perplexity)
const fallbackId = SEARCH_CREDENTIAL_FALLBACKS[p.id];
const fallbackCred =
!cred && fallbackId
? db
.prepare(
"SELECT id FROM provider_connections WHERE provider = ? AND is_active = 1 LIMIT 1"
)
.get(fallbackId)
: null;
if (cred || fallbackCred) status = "active";
} catch {
// DB error — report as no_credentials
}
return {
id: p.id,
name: p.name,
status,
cost_per_query: p.costPerQuery,
};
});
return NextResponse.json({ providers });
} catch (error) {
return NextResponse.json({ error: "Failed to list providers" }, { status: 500 });
}
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { getCacheStats } from "@omniroute/open-sse/services/searchCache.ts";
import { SEARCH_PROVIDERS } from "@omniroute/open-sse/config/searchRegistry.ts";
import { getDbInstance } from "@/lib/db/core";
import { isAuthenticated } from "@/shared/utils/apiAuth";
export async function GET(request: Request) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const db = getDbInstance();
const cache = getCacheStats();
// Provider aggregate stats — cost is per-query from registry
const providerStats = db
.prepare(
`
SELECT provider, COUNT(*) as requests,
CAST(AVG(duration) AS INTEGER) as avg_latency_ms
FROM call_logs
WHERE request_type = 'search'
GROUP BY provider
`
)
.all();
const providers: Record<
string,
{ requests: number; avg_latency_ms: number; total_cost: number }
> = {};
for (const row of providerStats as any[]) {
const costPerQuery = SEARCH_PROVIDERS[row.provider]?.costPerQuery || 0;
providers[row.provider] = {
requests: row.requests,
avg_latency_ms: row.avg_latency_ms,
total_cost: parseFloat((row.requests * costPerQuery).toFixed(4)),
};
}
// Recent searches
const recentRows = db
.prepare(
`
SELECT request_body, provider, timestamp
FROM call_logs
WHERE request_type = 'search'
ORDER BY timestamp DESC
LIMIT 10
`
)
.all();
const recent_searches = (recentRows as any[]).map((row) => {
let query = "";
let filters = {};
try {
const body = JSON.parse(row.request_body);
query = body.query || "";
const { query: _q, provider: _p, ...rest } = body;
filters = rest;
} catch {
// Unparseable request_body
}
return {
query,
provider: row.provider,
timestamp: row.timestamp,
filters,
};
});
return NextResponse.json({ cache, providers, recent_searches });
} catch (error) {
return NextResponse.json({ error: "Failed to get stats" }, { status: 500 });
}
}

View File

@@ -6,12 +6,13 @@ import {
extractApiKey,
isValidApiKey,
} from "@/sse/services/auth";
import { parseRerankModel } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { parseRerankModel, getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import { enforceApiKeyPolicy } from "@/shared/utils/apiKeyPolicy";
import { v1RerankSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getProviderNodes } from "@/lib/localDb";
/**
* Handle CORS preflight
@@ -26,11 +27,29 @@ export async function OPTIONS() {
});
}
/**
* Build dynamic rerank provider from a local provider_node.
* Local OpenAI-compatible backends (oMLX, vLLM, etc.) expose /v1/rerank
* under the same base URL as chat.
*/
function buildDynamicRerankProvider(node: any) {
// Strip trailing /v1 if present — we'll add /rerank
let base = node.baseUrl || "";
if (base.endsWith("/v1")) base = base.slice(0, -3);
return {
id: node.prefix,
baseUrl: `${base}/v1/rerank`,
authType: "apikey",
authHeader: "bearer",
providerId: node.id, // full provider connection ID for credential lookup
};
}
/**
* POST /v1/rerank - Cohere-compatible rerank endpoint
*
* Reranks a list of documents against a query using the specified model.
* Supports providers: Cohere, Together AI, NVIDIA, Fireworks AI.
* Supports cloud providers (Cohere, Together, NVIDIA, Fireworks)
* and local provider_nodes (oMLX, vLLM, etc.) via dynamic routing.
*/
export async function POST(request) {
// Optional API key validation
@@ -58,29 +77,113 @@ export async function POST(request) {
const policy = await enforceApiKeyPolicy(request, body.model);
if (policy.rejection) return policy.rejection;
const { provider } = parseRerankModel(body.model);
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Invalid rerank model: ${body.model}. Use format: provider/model`
);
// Load local provider_nodes for rerank routing (localhost only)
let localProviders: ReturnType<typeof buildDynamicRerankProvider>[] = [];
try {
const nodes = await getProviderNodes();
localProviders = (Array.isArray(nodes) ? nodes : [])
.filter((n: any) => {
try {
const hostname = new URL(n.baseUrl).hostname;
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch {
return false;
}
})
.map((n) => {
try {
return buildDynamicRerankProvider(n);
} catch {
return null;
}
})
.filter((p): p is NonNullable<typeof p> => p !== null);
} catch {
// Non-critical — continue with cloud providers only
}
const credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
// Try cloud registry first
const { provider, model: modelId } = parseRerankModel(body.model);
if (provider) {
// Cloud provider matched
const credentials = await getProviderCredentials(provider);
if (!credentials) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
const response = await handleRerank({
model: body.model,
query: body.query,
documents: body.documents,
top_n: body.top_n,
return_documents: body.return_documents,
credentials,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
}
return response;
}
const response = await handleRerank({
model: body.model,
query: body.query,
documents: body.documents,
top_n: body.top_n,
return_documents: body.return_documents,
credentials,
});
if (response?.ok) {
await clearRecoveredProviderState(credentials);
// Try local provider_nodes (model format: prefix/model-name)
const parts = body.model.split("/");
if (parts.length >= 2) {
const prefix = parts[0];
const localModel = parts.slice(1).join("/");
const localProvider = localProviders.find((p) => p.id === prefix);
if (localProvider) {
const credentials = await getProviderCredentials(localProvider.providerId);
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for local provider: ${prefix}`
);
}
const token = credentials?.apiKey || credentials?.accessToken;
try {
const res = await fetch(localProvider.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
model: localModel,
query: body.query,
documents: body.documents,
top_n: body.top_n || body.documents.length,
return_documents: body.return_documents !== false,
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
return errorResponse(
res.status,
errData.message || errData.detail || `Provider returned HTTP ${res.status}`
);
}
const data = await res.json();
return Response.json(data, {
headers: { "Access-Control-Allow-Origin": CORS_ORIGIN },
});
} catch (err: any) {
return errorResponse(500, `Rerank request failed: ${err.message}`);
}
}
}
return response;
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`Invalid rerank model: ${body.model}. Use format: provider/model`
);
}

View File

@@ -74,6 +74,7 @@
"settings": "Settings",
"translator": "Translator",
"playground": "Playground",
"searchTools": "Search Tools",
"agents": "Agents",
"docs": "Docs",
"issues": "Issues",
@@ -328,6 +329,42 @@
"videoDescription": "Create videos with AnimateDiff, Stable Video Diffusion via ComfyUI or SD WebUI.",
"musicDescription": "Compose music using Stable Audio Open or MusicGen via ComfyUI."
},
"search": {
"searchQuery": "Search Query",
"searchResults": "Search Results",
"cachedResult": "Cached",
"searchCost": "Cost",
"searchTools": "Search Tools",
"searchToolsDesc": "Advanced search testing with provider comparison",
"compareProviders": "Compare Providers",
"rerankResults": "Rerank Results",
"searchHistory": "Search History",
"urlOverlap": "URL Overlap",
"noSearchProviders": "No search providers configured. Add providers in Settings.",
"noRerankModels": "No rerank model available",
"webSearch": "Web Search",
"provider": "Provider",
"searchType": "Search Type",
"maxResults": "Max Results",
"filters": "Filters",
"country": "Country",
"language": "Language",
"timeRange": "Time Range",
"includeDomains": "Include Domains",
"excludeDomains": "Exclude Domains",
"safeSearch": "Safe Search",
"formatted": "Formatted",
"rawJson": "JSON",
"cacheMiss": "cache miss",
"cacheHit": "cache hit",
"latency": "Latency",
"cost": "Cost",
"results": "Results",
"rerank": "Rerank",
"rerankModel": "Rerank Model",
"positionDelta": "Position Change",
"emptyState": "Send a search query to see results"
},
"cliTools": {
"title": "CLI Tools",
"noActiveProviders": "No active providers",

View File

@@ -32,6 +32,7 @@ const debugItemDefs = [
{ href: "/dashboard/translator", i18nKey: "translator", icon: "translate" },
{ href: "/dashboard/playground", i18nKey: "playground", icon: "science" },
{ href: "/dashboard/media", i18nKey: "media", icon: "auto_awesome" },
{ href: "/dashboard/search-tools", i18nKey: "searchTools", icon: "manage_search" },
];
const systemItemDefs = [