Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_/migrate-page-memory-9b2c09

# Conflicts:
#	ui/litellm-dashboard/eslint-suppressions.json
This commit is contained in:
Yuneng Jiang 2026-07-22 22:52:48 -07:00
commit 5a3ab862ee
No known key found for this signature in database
6 changed files with 325 additions and 397 deletions

View file

@ -1841,21 +1841,6 @@
"count": 1
}
},
"src/app/(dashboard)/search-tools/_components/SearchConnectionTest.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/search-tools/_components/SearchToolTester.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/search-tools/_components/SearchToolView.tsx": {
"no-restricted-imports": {
"count": 2
}
},
"src/app/(dashboard)/search-tools/_components/SearchTools.tsx": {
"local/no-complex-jsx-arrow": {
"count": 2

View file

@ -0,0 +1,130 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SearchConnectionTest from "./SearchConnectionTest";
import * as networking from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
vi.mock("@/components/networking", () => ({
testSearchToolConnection: vi.fn(),
}));
const defaultProps = {
litellmParams: { search_provider: "tavily" },
accessToken: "test-token",
};
describe("SearchConnectionTest", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("passes the access token and params to the connection test", async () => {
vi.mocked(networking.testSearchToolConnection).mockResolvedValue({
status: "success",
message: "ok",
});
render(<SearchConnectionTest {...defaultProps} />);
await waitFor(() => {
expect(networking.testSearchToolConnection).toHaveBeenCalledWith(
defaultProps.accessToken,
defaultProps.litellmParams,
);
});
});
it("shows a loading state naming the provider while the test is pending", () => {
vi.mocked(networking.testSearchToolConnection).mockReturnValue(new Promise(() => {}));
render(<SearchConnectionTest {...defaultProps} />);
expect(screen.getByText(/Testing connection to tavily/i)).toBeInTheDocument();
});
it("renders a success state with the test query and result count", async () => {
vi.mocked(networking.testSearchToolConnection).mockResolvedValue({
status: "success",
message: "ok",
test_query: "hello world",
results_count: 3,
});
render(<SearchConnectionTest {...defaultProps} />);
expect(await screen.findByText(/Connection to tavily successful/i)).toBeInTheDocument();
expect(screen.getByText("hello world")).toBeInTheDocument();
expect(screen.getByText(/Results retrieved: 3/i)).toBeInTheDocument();
});
it("fires a success notification and completion callback on a successful test", async () => {
const onTestComplete = vi.fn();
vi.mocked(networking.testSearchToolConnection).mockResolvedValue({
status: "success",
message: "ok",
});
render(<SearchConnectionTest {...defaultProps} onTestComplete={onTestComplete} />);
await waitFor(() => {
expect(NotificationsManager.success).toHaveBeenCalledWith("Connection test successful!");
});
expect(onTestComplete).toHaveBeenCalledTimes(1);
});
it("renders a failure state with a cleaned error message and error type", async () => {
vi.mocked(networking.testSearchToolConnection).mockResolvedValue({
status: "error",
message: "litellm.AuthenticationError: Invalid API key\nstack trace: deep internals",
error_type: "AuthenticationError",
});
render(<SearchConnectionTest {...defaultProps} />);
expect(await screen.findByText(/Connection to tavily failed/i)).toBeInTheDocument();
expect(screen.getByText("Invalid API key")).toBeInTheDocument();
expect(screen.getByText("AuthenticationError")).toBeInTheDocument();
expect(screen.getByText("Verify your API key is correct and active")).toBeInTheDocument();
});
it("reveals the raw error details when Show Details is toggled", async () => {
const user = userEvent.setup();
vi.mocked(networking.testSearchToolConnection).mockResolvedValue({
status: "error",
message: "litellm.AuthenticationError: Invalid API key\nstack trace: deep internals",
error_type: "AuthenticationError",
});
render(<SearchConnectionTest {...defaultProps} />);
const toggle = await screen.findByRole("button", { name: /show details/i });
expect(screen.queryByText("Full Error Details")).not.toBeInTheDocument();
await user.click(toggle);
expect(screen.getByText("Full Error Details")).toBeInTheDocument();
expect(screen.getByText(/stack trace: deep internals/i)).toBeInTheDocument();
});
it("treats a rejected request as a connection failure", async () => {
vi.mocked(networking.testSearchToolConnection).mockRejectedValue(new Error("network down"));
render(<SearchConnectionTest {...defaultProps} />);
expect(await screen.findByText(/Connection to tavily failed/i)).toBeInTheDocument();
expect(screen.getByText("network down")).toBeInTheDocument();
});
it("links out to the search documentation", async () => {
vi.mocked(networking.testSearchToolConnection).mockResolvedValue({
status: "success",
message: "ok",
});
render(<SearchConnectionTest {...defaultProps} />);
const docLink = await screen.findByRole("link", { name: /View Search Documentation/i });
expect(docLink).toHaveAttribute("href", "https://docs.litellm.ai/docs/search");
});
});

View file

@ -1,10 +1,10 @@
import { InfoCircleOutlined, WarningOutlined } from "@ant-design/icons";
import { Button, Divider, Typography } from "antd";
import { AlertTriangle, CheckCircle2, Info } from "lucide-react";
import React, { useEffect, useState } from "react";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { testSearchToolConnection } from "@/components/networking";
const { Text } = Typography;
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
interface SearchConnectionTestProps {
litellmParams: Record<string, any>;
@ -52,30 +52,23 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
const getCleanErrorMessage = (errorMsg: string) => {
if (!errorMsg) return "Unknown error";
// Remove stack traces
const mainError = errorMsg.split("stack trace:")[0].trim();
// Remove litellm error prefixes
const cleanedError = mainError.replace(/^litellm\.(.*?)Error:\s*/, "");
// Remove AuthenticationError prefix if it exists
const finalError = cleanedError.replace(/^AuthenticationError:\s*/, "");
// If the error contains HTML (like a 401 page), extract just the key info
if (finalError.includes("<html>") || finalError.includes("<!DOCTYPE")) {
// Try to extract the title or main error from HTML
const titleMatch = finalError.match(/<title>(.*?)<\/title>/);
if (titleMatch) {
return titleMatch[1];
}
// If it's a 401 error
if (finalError.includes("401") || finalError.includes("Authorization Required")) {
return "Authentication failed: Invalid API key or credentials";
}
return "Authentication error - please check your API key";
}
// Limit very long error messages
if (finalError.length > 200) {
return finalError.substring(0, 200) + "...";
}
@ -87,34 +80,12 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
if (isLoading) {
return (
<div style={{ padding: "24px", borderRadius: "8px", backgroundColor: "#fff" }}>
<div style={{ textAlign: "center", padding: "32px 20px" }}>
<div className="loading-spinner" style={{ marginBottom: "16px" }}>
<div
style={{
border: "3px solid #f3f3f3",
borderTop: "3px solid #1890ff",
borderRadius: "50%",
width: "30px",
height: "30px",
animation: "spin 1s linear infinite",
margin: "0 auto",
}}
/>
</div>
<Text style={{ fontSize: "16px" }}>
<div className="rounded-lg bg-card p-6">
<div className="flex flex-col items-center justify-center px-5 py-8">
<UiLoadingSpinner className="mb-4 size-8 text-primary" />
<p className="text-base text-foreground">
Testing connection to {litellmParams.search_provider || "search provider"}...
</Text>
<style jsx>{`
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`}</style>
</p>
</div>
</div>
);
@ -125,147 +96,88 @@ const SearchConnectionTest: React.FC<SearchConnectionTestProps> = ({ litellmPara
}
return (
<div style={{ padding: "24px", borderRadius: "8px", backgroundColor: "#fff" }}>
<div className="rounded-lg bg-card p-6">
{testResult.status === "success" ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "32px 20px" }}>
<div style={{ color: "#52c41a", fontSize: "24px", display: "flex", alignItems: "center" }}>
<svg
viewBox="64 64 896 896"
focusable="false"
data-icon="check-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
>
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z"></path>
</svg>
</div>
<div style={{ marginLeft: "12px" }}>
<Text type="success" style={{ fontSize: "18px", fontWeight: 500, display: "block" }}>
<div className="flex items-center justify-center px-5 py-8">
<CheckCircle2 className="size-6 text-emerald-600" />
<div className="ml-3">
<p className="text-lg font-medium text-emerald-600">
Connection to {litellmParams.search_provider} successful!
</Text>
</p>
{testResult.test_query && (
<Text style={{ fontSize: "14px", color: "#666", marginTop: "8px", display: "block" }}>
Test query:{" "}
<code style={{ backgroundColor: "#f0f0f0", padding: "2px 6px", borderRadius: "4px" }}>
{testResult.test_query}
</code>
</Text>
<p className="mt-2 text-sm text-muted-foreground">
Test query: <code className="rounded bg-muted px-1.5 py-0.5">{testResult.test_query}</code>
</p>
)}
{testResult.results_count !== undefined && (
<Text style={{ fontSize: "14px", color: "#666", display: "block" }}>
Results retrieved: {testResult.results_count}
</Text>
<p className="text-sm text-muted-foreground">Results retrieved: {testResult.results_count}</p>
)}
</div>
</div>
) : (
<>
<div>
<div style={{ display: "flex", alignItems: "center", marginBottom: "20px" }}>
<WarningOutlined style={{ color: "#ff4d4f", fontSize: "24px", marginRight: "12px" }} />
<Text type="danger" style={{ fontSize: "18px", fontWeight: 500 }}>
Connection to {litellmParams.search_provider || "search provider"} failed
</Text>
</div>
<div>
<div className="mb-5 flex items-center">
<AlertTriangle className="mr-3 size-6 text-destructive" />
<p className="text-lg font-medium text-destructive">
Connection to {litellmParams.search_provider || "search provider"} failed
</p>
</div>
<div
style={{
backgroundColor: "#fff2f0",
border: "1px solid #ffccc7",
borderRadius: "8px",
padding: "16px",
marginBottom: "20px",
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.03)",
}}
>
<Text strong style={{ display: "block", marginBottom: "8px" }}>
Error:{" "}
</Text>
<Text type="danger" style={{ fontSize: "14px", lineHeight: "1.5" }}>
{errorMessage}
</Text>
<div className="mb-5 rounded-lg border border-destructive/30 bg-destructive/10 p-4">
<p className="mb-2 font-semibold text-foreground">Error: </p>
<p className="text-sm leading-relaxed text-destructive">{errorMessage}</p>
{testResult.error_type && (
<div style={{ marginTop: "8px" }}>
<Text style={{ fontSize: "13px", color: "#666" }}>
Error type:{" "}
<code
style={{ backgroundColor: "#ffebee", padding: "2px 6px", borderRadius: "4px", color: "#d32f2f" }}
>
{testResult.error_type}
</code>
</Text>
</div>
)}
{testResult.message && (
<div style={{ marginTop: "12px" }}>
<Button
type="link"
onClick={() => setShowDetails(!showDetails)}
style={{ paddingLeft: 0, height: "auto" }}
>
{showDetails ? "Hide Details" : "Show Details"}
</Button>
</div>
)}
</div>
{showDetails && (
<div style={{ marginBottom: "20px" }}>
<Text strong style={{ display: "block", marginBottom: "8px", fontSize: "15px" }}>
Full Error Details
</Text>
<pre
style={{
backgroundColor: "#f5f5f5",
padding: "16px",
borderRadius: "8px",
fontSize: "13px",
maxHeight: "200px",
overflow: "auto",
border: "1px solid #e8e8e8",
lineHeight: "1.5",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{testResult.message}
</pre>
{testResult.error_type && (
<div className="mt-2">
<p className="text-[13px] text-muted-foreground">
Error type:{" "}
<code className="rounded bg-destructive/10 px-1.5 py-0.5 text-destructive">
{testResult.error_type}
</code>
</p>
</div>
)}
<div
style={{
backgroundColor: "#fffbf0",
border: "1px solid #ffe58f",
borderLeft: "4px solid #faad14",
borderRadius: "8px",
padding: "16px",
}}
>
<Text strong style={{ display: "block", marginBottom: "8px", color: "#d48806" }}>
Troubleshooting tips:
</Text>
<ul style={{ margin: "8px 0", paddingLeft: "20px", color: "#ad6800" }}>
<li style={{ marginBottom: "6px" }}>Verify your API key is correct and active</li>
<li style={{ marginBottom: "6px" }}>Check if the search provider service is operational</li>
<li style={{ marginBottom: "6px" }}>Ensure you have sufficient credits/quota with the provider</li>
<li style={{ marginBottom: "6px" }}>
Review the provider&apos;s documentation for any additional requirements
</li>
</ul>
</div>
{testResult.message && (
<div className="mt-3">
<Button variant="link" size="sm" className="h-auto p-0" onClick={() => setShowDetails(!showDetails)}>
{showDetails ? "Hide Details" : "Show Details"}
</Button>
</div>
)}
</div>
</>
{showDetails && (
<div className="mb-5">
<p className="mb-2 text-[15px] font-semibold text-foreground">Full Error Details</p>
<pre className="max-h-52 overflow-auto rounded-lg border border-border bg-muted p-4 text-[13px] leading-relaxed break-words whitespace-pre-wrap">
{testResult.message}
</pre>
</div>
)}
<div className="rounded-lg border border-amber-200 border-l-4 border-l-amber-500 bg-amber-50 p-4">
<p className="mb-2 font-semibold text-amber-700">Troubleshooting tips:</p>
<ul className="my-2 list-disc pl-5 text-amber-800">
<li className="mb-1.5">Verify your API key is correct and active</li>
<li className="mb-1.5">Check if the search provider service is operational</li>
<li className="mb-1.5">Ensure you have sufficient credits/quota with the provider</li>
<li className="mb-1.5">Review the provider&apos;s documentation for any additional requirements</li>
</ul>
</div>
</div>
)}
<Divider style={{ margin: "24px 0 16px" }} />
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Button type="link" href="https://docs.litellm.ai/docs/search" target="_blank" icon={<InfoCircleOutlined />}>
<Separator className="mt-6 mb-4" />
<div className="flex items-center justify-between">
<a
href="https://docs.litellm.ai/docs/search"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<Info className="size-4" />
View Search Documentation
</Button>
</a>
</div>
</div>
);

View file

@ -1,12 +1,12 @@
import React, { useState } from "react";
import { Button, Input, Typography, Spin } from "antd";
import { ExternalLink, Search } from "lucide-react";
import MessageManager from "@/components/molecules/message_manager";
import { SearchOutlined, LoadingOutlined } from "@ant-design/icons";
import { searchToolQueryCall } from "@/components/networking";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { Card, Title as TremorTitle } from "@tremor/react";
const { Text } = Typography;
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
interface SearchResult {
title: string;
@ -36,7 +36,6 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
}[]
>([]);
const [expandedResults, setExpandedResults] = useState<Record<string, boolean>>({});
const [isInputFocused, setIsInputFocused] = useState(false);
const handleSearch = async () => {
if (!query.trim()) {
@ -60,7 +59,6 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
};
setSearchHistory((prev) => [historyEntry, ...prev]);
// Don't clear query after search so user can modify it
} catch (error) {
console.error("Error querying search tool:", error);
NotificationsManager.fromBackend("Failed to query search tool");
@ -87,113 +85,79 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
}));
};
const antIcon = <LoadingOutlined style={{ fontSize: 24 }} spin />;
const latestResults = searchHistory.length > 0 ? searchHistory[0] : null;
return (
<Card className="mt-6">
<div className="mb-6">
<TremorTitle>Test Search Tool</TremorTitle>
<Card className={`mt-6 ${className}`}>
<div className="px-6">
<h2 className="text-lg font-semibold text-foreground">Test Search Tool</h2>
</div>
<div className="flex flex-col" style={{ minHeight: "600px" }}>
{/* Search Bar at Top */}
<div className="flex min-h-[600px] flex-col px-6">
<div className="mb-6">
<div className="flex items-stretch gap-3">
<div
className="flex items-center flex-1 bg-white rounded-lg px-4 transition-all duration-200"
style={{
border: isInputFocused ? "2px solid #3b82f6" : "2px solid #e5e7eb",
boxShadow: isInputFocused ? "0 0 0 3px rgba(59, 130, 246, 0.1)" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
height: "48px",
}}
>
<SearchOutlined className="text-gray-400 mr-3" style={{ fontSize: "18px" }} />
<div className="relative flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3 size-[18px] -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onPressEnter={(e) => {
if (!e.shiftKey) {
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSearch();
}
}}
placeholder="Enter your search query..."
disabled={isLoading}
bordered={false}
style={{ fontSize: "15px", padding: 0, height: "100%", boxShadow: "none" }}
className="h-12 pl-11 text-[15px]"
/>
</div>
<Button
type="primary"
onClick={handleSearch}
disabled={isLoading || !query.trim()}
icon={<SearchOutlined />}
loading={isLoading}
style={{
height: "48px",
paddingLeft: "24px",
paddingRight: "24px",
borderRadius: "8px",
fontWeight: 500,
fontSize: "15px",
backgroundColor: isLoading || !query.trim() ? undefined : "#1890ff",
borderColor: isLoading || !query.trim() ? undefined : "#1890ff",
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
}}
>
<Button onClick={handleSearch} disabled={isLoading || !query.trim()} className="h-12 px-6 text-[15px]">
{isLoading ? <UiLoadingSpinner className="size-4" /> : <Search className="size-4" />}
Search
</Button>
</div>
</div>
{/* Results Area */}
<div className="flex-1">
{!latestResults && !isLoading ? (
<div className="h-full flex flex-col items-center justify-center p-8">
<div className="flex items-center justify-center w-24 h-24 rounded-full bg-gray-100 mb-6">
<SearchOutlined style={{ fontSize: "48px", color: "#9ca3af" }} />
<div className="flex h-full flex-col items-center justify-center p-8">
<div className="mb-6 flex size-24 items-center justify-center rounded-full bg-muted">
<Search className="size-12 text-muted-foreground" />
</div>
<Text className="text-lg text-gray-600 font-medium">Test your search tool</Text>
<Text className="text-sm text-gray-500 mt-2">Enter a query above to see search results</Text>
<p className="text-lg font-medium text-foreground">Test your search tool</p>
<p className="mt-2 text-sm text-muted-foreground">Enter a query above to see search results</p>
</div>
) : (
<div>
{isLoading && (
<div className="flex flex-col justify-center items-center py-16">
<Spin indicator={antIcon} />
<Text className="mt-4 text-gray-600 font-medium">Searching...</Text>
<div className="flex flex-col items-center justify-center py-16">
<UiLoadingSpinner className="size-8 text-primary" />
<p className="mt-4 font-medium text-muted-foreground">Searching...</p>
</div>
)}
{latestResults && !isLoading && (
<>
{/* Query Info Bar */}
<div
className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg"
style={{ boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)" }}
>
<div className="mb-6 rounded-lg border border-border bg-muted/50 p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<Text className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
<p className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Search Query
</Text>
<div className="text-base font-semibold text-gray-900 mt-1.5">{latestResults.query}</div>
</p>
<div className="mt-1.5 text-base font-semibold text-foreground">{latestResults.query}</div>
</div>
<div className="text-right ml-4">
<Text className="text-xs text-gray-500">{formatTimestamp(latestResults.timestamp)}</Text>
<div className="flex items-center gap-3 mt-1">
<div className="text-sm font-semibold text-blue-600">
<div className="ml-4 text-right">
<p className="text-xs text-muted-foreground">{formatTimestamp(latestResults.timestamp)}</p>
<div className="mt-1 flex items-center gap-3">
<div className="text-sm font-semibold text-primary">
{latestResults.response?.results?.length || 0}{" "}
{latestResults.response?.results?.length === 1 ? "result" : "results"}
</div>
{latestResults.latency !== undefined && (
<>
<span className="text-gray-400"></span>
<div className="text-sm font-semibold text-green-600">{latestResults.latency}ms</div>
<span className="text-muted-foreground"></span>
<div className="text-sm font-semibold text-emerald-600">{latestResults.latency}ms</div>
</>
)}
</div>
@ -201,7 +165,6 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
</div>
</div>
{/* Search Results */}
{latestResults.response &&
latestResults.response.results &&
latestResults.response.results.length > 0 ? (
@ -212,73 +175,43 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
return (
<div
key={resultIndex}
className="bg-white border border-gray-200 rounded-lg overflow-hidden transition-all duration-200"
style={{ boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)" }}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow =
"0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)";
e.currentTarget.style.borderColor = "#e0e7ff";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
e.currentTarget.style.borderColor = "#e5e7eb";
}}
className="rounded-lg border border-border bg-card transition-shadow hover:shadow-md"
>
<div className="p-5">
{/* Title and External Link */}
<div className="flex items-start justify-between gap-3 mb-2">
<div className="mb-2 flex items-start justify-between gap-3">
<a
href={result.url}
target="_blank"
rel="noopener noreferrer"
className="text-lg font-semibold text-blue-600 hover:text-blue-700 flex-1 leading-snug"
style={{ textDecoration: "none" }}
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = "underline")}
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = "none")}
className="flex-1 text-lg leading-snug font-semibold text-primary hover:underline"
>
{result.title}
</a>
<Button
type="text"
size="small"
className="shrink-0"
icon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
}
variant="ghost"
size="icon-sm"
aria-label="Open result in new tab"
className="shrink-0 text-muted-foreground"
onClick={() => window.open(result.url, "_blank")}
style={{ color: "#6b7280" }}
/>
>
<ExternalLink className="size-4" />
</Button>
</div>
{/* URL */}
<div className="text-sm text-green-700 mb-3 truncate font-medium">{result.url}</div>
<div className="mb-3 truncate text-sm font-medium text-emerald-700">{result.url}</div>
{/* Snippet Preview */}
<div className="text-sm text-gray-700 leading-relaxed">
<div className="text-sm leading-relaxed text-foreground">
{isResultExpanded
? result.snippet
: `${result.snippet.substring(0, 200)}${result.snippet.length > 200 ? "..." : ""}`}
</div>
{/* Expand/Collapse */}
{result.snippet.length > 200 && (
<Button
type="link"
size="small"
className="mt-3 p-0 h-auto"
variant="link"
size="sm"
className="mt-3 h-auto p-0"
onClick={() => toggleResultExpansion(0, resultIndex)}
style={{
fontSize: "13px",
fontWeight: 500,
color: "#3b82f6",
}}
>
{isResultExpanded ? "Show less" : "Show more"}
</Button>
@ -289,31 +222,22 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
})}
</div>
) : (
<div className="text-center py-12 bg-gray-50 border border-gray-200 rounded-lg">
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-gray-100 mx-auto mb-4">
<SearchOutlined style={{ fontSize: "24px", color: "#9ca3af" }} />
<div className="rounded-lg border border-border bg-muted/50 py-12 text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
<Search className="size-6 text-muted-foreground" />
</div>
<Text className="text-gray-600 font-medium">No results found</Text>
<Text className="text-sm text-gray-500 mt-1">Try a different search query</Text>
<p className="font-medium text-foreground">No results found</p>
<p className="mt-1 text-sm text-muted-foreground">Try a different search query</p>
</div>
)}
</>
)}
{/* Search History Sidebar */}
{searchHistory.length > 1 && (
<div className="mt-8 pt-6 border-t border-gray-200">
<div className="flex items-center justify-between mb-4">
<Text className="text-sm font-semibold text-gray-700">Previous Searches</Text>
<Button
onClick={clearHistory}
size="small"
type="link"
style={{
fontSize: "13px",
fontWeight: 500,
}}
>
<div className="mt-8 border-t border-border pt-6">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-foreground">Previous Searches</p>
<Button variant="link" size="sm" className="h-auto p-0" onClick={clearHistory}>
Clear All
</Button>
</div>
@ -321,21 +245,21 @@ export const SearchToolTester: React.FC<SearchToolTesterProps> = ({ searchToolNa
{searchHistory.slice(1, 6).map((entry, index) => (
<div
key={index + 1}
className="p-3 bg-gray-50 border border-gray-200 rounded-lg cursor-pointer transition-all duration-200 hover:bg-gray-100 hover:border-gray-300"
className="cursor-pointer rounded-lg border border-border bg-muted/50 p-3 transition-colors hover:bg-muted"
onClick={() => {
setQuery(entry.query);
}}
>
<div className="text-sm font-medium text-gray-800 truncate">{entry.query}</div>
<div className="text-xs text-gray-500 mt-1.5 flex items-center gap-2">
<span className="font-medium text-blue-600">
<div className="truncate text-sm font-medium text-foreground">{entry.query}</div>
<div className="mt-1.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-primary">
{entry.response?.results?.length || 0}{" "}
{entry.response?.results?.length === 1 ? "result" : "results"}
</span>
{entry.latency !== undefined && (
<>
<span></span>
<span className="font-medium text-green-600">{entry.latency}ms</span>
<span className="font-medium text-emerald-600">{entry.latency}ms</span>
</>
)}
<span></span>

View file

@ -155,13 +155,8 @@ describe("SearchToolView", () => {
const toolNameContainer = screen.getByText("Test Search Tool").closest("div");
expect(toolNameContainer).toBeInTheDocument();
const copyButtons = within(toolNameContainer!).getAllByRole("button");
const nameCopyButton = copyButtons.find((button) => {
return button.querySelector("svg") !== null;
});
expect(nameCopyButton).toBeInTheDocument();
await user.click(nameCopyButton!);
const nameCopyButton = within(toolNameContainer!).getByRole("button");
await user.click(nameCopyButton);
await waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith("Test Search Tool");
@ -176,13 +171,8 @@ describe("SearchToolView", () => {
const toolIdContainer = screen.getByText("test-tool-id-123").closest("div");
expect(toolIdContainer).toBeInTheDocument();
const copyButtons = within(toolIdContainer!).getAllByRole("button");
const idCopyButton = copyButtons.find((button) => {
return button.querySelector("svg") !== null;
});
expect(idCopyButton).toBeInTheDocument();
await user.click(idCopyButton!);
const idCopyButton = within(toolIdContainer!).getByRole("button");
await user.click(idCopyButton);
await waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith("test-tool-id-123");
@ -197,22 +187,14 @@ describe("SearchToolView", () => {
render(<SearchToolView {...defaultProps} />);
const toolNameContainer = screen.getByText("Test Search Tool").closest("div");
const copyButtons = within(toolNameContainer!).getAllByRole("button");
const nameCopyButton = copyButtons.find((button) => {
return button.querySelector("svg") !== null;
});
const nameCopyButton = within(toolNameContainer!).getByRole("button");
expect(nameCopyButton).toBeInTheDocument();
expect(nameCopyButton.querySelector(".lucide-copy")).toBeInTheDocument();
const initialSvg = nameCopyButton!.querySelector("svg");
expect(initialSvg).toBeInTheDocument();
await user.click(nameCopyButton!);
await user.click(nameCopyButton);
await waitFor(() => {
const updatedSvg = nameCopyButton!.querySelector("svg");
expect(updatedSvg).toBeInTheDocument();
expect(nameCopyButton).toHaveClass("text-green-600");
expect(nameCopyButton.querySelector(".lucide-check")).toBeInTheDocument();
});
});
@ -224,13 +206,9 @@ describe("SearchToolView", () => {
render(<SearchToolView {...defaultProps} />);
const toolNameContainer = screen.getByText("Test Search Tool").closest("div");
const copyButtons = within(toolNameContainer!).getAllByRole("button");
const nameCopyButton = copyButtons.find((button) => {
return button.querySelector("svg") !== null;
});
const nameCopyButton = within(toolNameContainer!).getByRole("button");
expect(nameCopyButton).toBeInTheDocument();
await user.click(nameCopyButton!);
await user.click(nameCopyButton);
await waitFor(
() => {
@ -239,7 +217,7 @@ describe("SearchToolView", () => {
{ timeout: 3000 },
);
expect(nameCopyButton).not.toHaveClass("text-green-600");
expect(nameCopyButton.querySelector(".lucide-check")).not.toBeInTheDocument();
});
it("should render SearchToolTester when accessToken is provided", () => {

View file

@ -1,9 +1,8 @@
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { Button, Card, Grid, Text, Title } from "@tremor/react";
import { Button as AntdButton } from "antd";
import { CheckIcon, CopyIcon } from "lucide-react";
import { ArrowLeft, Check, Copy } from "lucide-react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { SearchToolTester } from "./SearchToolTester";
import { AvailableSearchProvider, SearchTool } from "./types";
@ -43,73 +42,73 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
<div className="p-4 max-w-full">
<div className="flex justify-between items-center mb-6">
<div>
<Button icon={ArrowLeftIcon} variant="light" className="mb-4" onClick={onBack}>
<Button variant="ghost" size="sm" className="mb-4 -ml-2 text-muted-foreground" onClick={onBack}>
<ArrowLeft className="mr-2 size-4" />
Back to All Search Tools
</Button>
<div className="flex items-center cursor-pointer">
<Title>{searchTool.search_tool_name}</Title>
<AntdButton
type="text"
size="small"
icon={copiedStates["search-tool-name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
<div className="flex items-center gap-1">
<h1 className="text-2xl font-semibold text-foreground">{searchTool.search_tool_name}</h1>
<Button
variant="ghost"
size="icon-xs"
aria-label="Copy search tool name"
className="text-muted-foreground"
onClick={() => copyToClipboard(searchTool.search_tool_name, "search-tool-name")}
className={`left-2 z-10 transition-all duration-200 ${
copiedStates["search-tool-name"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
>
{copiedStates["search-tool-name"] ? <Check /> : <Copy />}
</Button>
</div>
<div className="flex items-center cursor-pointer">
<Text className="text-gray-500 font-mono">{searchTool.search_tool_id}</Text>
<AntdButton
type="text"
size="small"
icon={copiedStates["search-tool-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
<div className="flex items-center gap-1">
<p className="font-mono text-sm text-muted-foreground">{searchTool.search_tool_id}</p>
<Button
variant="ghost"
size="icon-xs"
aria-label="Copy search tool ID"
className="text-muted-foreground"
onClick={() => copyToClipboard(searchTool.search_tool_id, "search-tool-id")}
className={`left-2 z-10 transition-all duration-200 ${
copiedStates["search-tool-id"]
? "text-green-600 bg-green-50 border-green-200"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
/>
>
{copiedStates["search-tool-id"] ? <Check /> : <Copy />}
</Button>
</div>
</div>
</div>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card>
<Text>Provider</Text>
<div className="mt-2">
<Title>{getProviderDisplayName(searchTool.litellm_params.search_provider)}</Title>
</div>
<CardContent>
<p className="text-sm text-muted-foreground">Provider</p>
<p className="mt-2 text-lg font-semibold text-foreground">
{getProviderDisplayName(searchTool.litellm_params.search_provider)}
</p>
</CardContent>
</Card>
<Card>
<Text>API Key</Text>
<div className="mt-2">
<Text>{searchTool.litellm_params.api_key ? "****" : "Not set"}</Text>
</div>
<CardContent>
<p className="text-sm text-muted-foreground">API Key</p>
<p className="mt-2 text-foreground">{searchTool.litellm_params.api_key ? "****" : "Not set"}</p>
</CardContent>
</Card>
<Card>
<Text>Created At</Text>
<div className="mt-2">
<Text>{searchTool.created_at ? new Date(searchTool.created_at).toLocaleString() : "Unknown"}</Text>
</div>
<CardContent>
<p className="text-sm text-muted-foreground">Created At</p>
<p className="mt-2 text-foreground">
{searchTool.created_at ? new Date(searchTool.created_at).toLocaleString() : "Unknown"}
</p>
</CardContent>
</Card>
</Grid>
</div>
{searchTool.search_tool_info?.description && (
<Card className="mt-6">
<Text>Description</Text>
<div className="mt-2">
<Text>{searchTool.search_tool_info.description}</Text>
</div>
<CardContent>
<p className="text-sm text-muted-foreground">Description</p>
<p className="mt-2 text-foreground">{searchTool.search_tool_info.description}</p>
</CardContent>
</Card>
)}
{/* Search Tool Tester */}
<div className="mt-6">
{accessToken && <SearchToolTester searchToolName={searchTool.search_tool_name} accessToken={accessToken} />}
</div>