mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
feat(ui): show search provider logos across search tools views
Reuse the provider logo label in the search tools table, detail view, and edit form, and fix the provider dropdown search, which matched nothing because antd stringified the JSX option children. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
4fd7a73ef5
commit
2d3be8dc7b
10 changed files with 210 additions and 39 deletions
|
|
@ -0,0 +1,74 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
|
||||
import CreateSearchTool from "./CreateSearchTools";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
createSearchTool: vi.fn(),
|
||||
fetchAvailableSearchProviders: vi.fn(async () => ({
|
||||
providers: [
|
||||
{ provider_name: "perplexity", ui_friendly_name: "Perplexity AI" },
|
||||
{ provider_name: "searxng", ui_friendly_name: "SearXNG" },
|
||||
],
|
||||
})),
|
||||
}));
|
||||
|
||||
const renderModal = () =>
|
||||
renderWithProviders(
|
||||
<CreateSearchTool
|
||||
userRole="Admin"
|
||||
accessToken="sk-test"
|
||||
onCreateSuccess={vi.fn()}
|
||||
isModalVisible
|
||||
setModalVisible={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const openProviderDropdown = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(await screen.findByRole("combobox"));
|
||||
return screen.getByRole("listbox").parentElement as HTMLElement;
|
||||
};
|
||||
|
||||
describe("CreateSearchTool provider dropdown", () => {
|
||||
beforeEach(() => {
|
||||
testQueryClient.clear();
|
||||
});
|
||||
|
||||
it("keeps a provider listed when its slug is typed into the search box", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const dropdown = await openProviderDropdown(user);
|
||||
expect(await within(dropdown).findByTitle("SearXNG")).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByRole("combobox"), "searxng");
|
||||
|
||||
expect(within(dropdown).getByTitle("SearXNG")).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByTitle("Perplexity AI")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a provider listed when its display name is typed into the search box", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const dropdown = await openProviderDropdown(user);
|
||||
await within(dropdown).findByTitle("Perplexity AI");
|
||||
|
||||
await user.type(screen.getByRole("combobox"), "perplexity ai");
|
||||
|
||||
expect(within(dropdown).getByTitle("Perplexity AI")).toBeInTheDocument();
|
||||
expect(within(dropdown).queryByTitle("SearXNG")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the provider logo in the dropdown options", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
const dropdown = await openProviderDropdown(user);
|
||||
expect(await within(dropdown).findByRole("img", { name: "Perplexity AI logo" })).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("perplexity.png"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,43 +4,15 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { Button, TextInput } from "@tremor/react";
|
||||
import { Form, Input, Modal, Select, Tooltip, Typography } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { createSearchTool, fetchAvailableSearchProviders } from "@/components/networking";
|
||||
import SearchConnectionTest from "./SearchConnectionTest";
|
||||
import { SearchProviderLabel } from "./SearchProviderLabel";
|
||||
import { searchProviderFilterOption } from "./searchProviderFilterOption";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
import dataforseoLogo from "../../../../../public/assets/logos/dataforseo.png";
|
||||
import exaAiLogo from "../../../../../public/assets/logos/exa_ai.png";
|
||||
import googlePseLogo from "../../../../../public/assets/logos/google_pse.png";
|
||||
import nimbleLogo from "../../../../../public/assets/logos/nimble.png";
|
||||
import parallelAiLogo from "../../../../../public/assets/logos/parallel_ai.png";
|
||||
import perplexityLogo from "../../../../../public/assets/logos/perplexity.png";
|
||||
import tavilyLogo from "../../../../../public/assets/logos/tavily.png";
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const searchProviderLogoMap: Record<string, string> = {
|
||||
perplexity: perplexityLogo.src,
|
||||
tavily: tavilyLogo.src,
|
||||
parallel_ai: parallelAiLogo.src,
|
||||
exa_ai: exaAiLogo.src,
|
||||
google_pse: googlePseLogo.src,
|
||||
dataforseo: dataforseoLogo.src,
|
||||
nimble: nimbleLogo.src,
|
||||
};
|
||||
|
||||
interface SearchProviderLabelProps {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export const SearchProviderLabel: React.FC<SearchProviderLabelProps> = ({ providerName, displayName }) => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<Logo src={searchProviderLogoMap[providerName]} label={displayName} className="w-5 h-5 object-contain" />
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface CreateSearchToolProps {
|
||||
userRole: string;
|
||||
accessToken: string | null;
|
||||
|
|
@ -212,13 +184,14 @@ const CreateSearchTool: React.FC<CreateSearchToolProps> = ({
|
|||
size="large"
|
||||
loading={isLoadingProviders}
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
filterOption={searchProviderFilterOption}
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<Select.Option
|
||||
key={provider.provider_name}
|
||||
value={provider.provider_name}
|
||||
title={provider.ui_friendly_name}
|
||||
label={
|
||||
<SearchProviderLabel
|
||||
providerName={provider.provider_name}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SearchProviderLabel } from "./CreateSearchTools";
|
||||
import { SearchProviderLabel } from "./SearchProviderLabel";
|
||||
|
||||
describe("SearchProviderLabel", () => {
|
||||
it("renders the tavily logo from the static bundle, untouched by server-root prefixing", () => {
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import React from "react";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import dataforseoLogo from "../../../../../public/assets/logos/dataforseo.png";
|
||||
import exaAiLogo from "../../../../../public/assets/logos/exa_ai.png";
|
||||
import googlePseLogo from "../../../../../public/assets/logos/google_pse.png";
|
||||
import nimbleLogo from "../../../../../public/assets/logos/nimble.png";
|
||||
import parallelAiLogo from "../../../../../public/assets/logos/parallel_ai.png";
|
||||
import perplexityLogo from "../../../../../public/assets/logos/perplexity.png";
|
||||
import tavilyLogo from "../../../../../public/assets/logos/tavily.png";
|
||||
|
||||
const searchProviderLogoMap: Readonly<Record<string, string>> = {
|
||||
perplexity: perplexityLogo.src,
|
||||
tavily: tavilyLogo.src,
|
||||
parallel_ai: parallelAiLogo.src,
|
||||
exa_ai: exaAiLogo.src,
|
||||
google_pse: googlePseLogo.src,
|
||||
dataforseo: dataforseoLogo.src,
|
||||
nimble: nimbleLogo.src,
|
||||
};
|
||||
|
||||
interface SearchProviderLabelProps {
|
||||
providerName: string;
|
||||
displayName: string;
|
||||
className?: string;
|
||||
logoClassName?: string;
|
||||
}
|
||||
|
||||
export const SearchProviderLabel: React.FC<SearchProviderLabelProps> = ({
|
||||
providerName,
|
||||
displayName,
|
||||
className = "text-sm",
|
||||
logoClassName = "w-5 h-5 object-contain",
|
||||
}) => (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<Logo src={searchProviderLogoMap[providerName]} label={displayName} className={logoClassName} />
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -42,6 +42,27 @@ describe("SearchToolTable", () => {
|
|||
expect(screen.getByText("DB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the provider logo next to the provider name", () => {
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} />);
|
||||
expect(screen.getByRole("img", { name: "Perplexity AI logo" })).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("perplexity.png"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should fall back to a letter avatar for a provider with no bundled logo", () => {
|
||||
const tool = makeSearchTool({ litellm_params: { search_provider: "searxng" } });
|
||||
renderWithProviders(
|
||||
<SearchToolTable
|
||||
{...defaultProps}
|
||||
searchTools={[tool]}
|
||||
availableProviders={[{ provider_name: "searxng", ui_friendly_name: "SearXNG" }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("img", { name: /logo/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("SearXNG")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onView when the search tool ID is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} />);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { SearchProviderLabel } from "./SearchProviderLabel";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
const CONFIG_EDIT_HINT = "Config search tools cannot be edited on the dashboard. Please edit the config file.";
|
||||
|
|
@ -120,7 +121,7 @@ export const getSearchToolTableColumns = ({
|
|||
cell: ({ row }) => {
|
||||
const provider = row.original.litellm_params.search_provider;
|
||||
const providerInfo = availableProviders.find((candidate) => candidate.provider_name === provider);
|
||||
return <span className="text-sm">{providerInfo?.ui_friendly_name || provider}</span>;
|
||||
return <SearchProviderLabel providerName={provider} displayName={providerInfo?.ui_friendly_name || provider} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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 { SearchProviderLabel } from "./SearchProviderLabel";
|
||||
import { SearchToolTester } from "./SearchToolTester";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
|
|
@ -77,9 +78,12 @@ export const SearchToolView: React.FC<SearchToolViewProps> = ({
|
|||
<Card>
|
||||
<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>
|
||||
<SearchProviderLabel
|
||||
className="mt-2 text-lg font-semibold text-foreground"
|
||||
logoClassName="w-6 h-6 object-contain"
|
||||
providerName={searchTool.litellm_params.search_provider}
|
||||
displayName={getProviderDisplayName(searchTool.litellm_params.search_provider)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
updateSearchTool,
|
||||
} from "@/components/networking";
|
||||
import CreateSearchTool from "./CreateSearchTools";
|
||||
import { SearchProviderLabel } from "./SearchProviderLabel";
|
||||
import { searchProviderFilterOption } from "./searchProviderFilterOption";
|
||||
import SearchToolTable from "./SearchToolTable";
|
||||
import { SearchToolView } from "./SearchToolView";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
|
@ -168,10 +170,23 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
label="Search Provider"
|
||||
rules={[{ required: true, message: "Please select a search provider" }]}
|
||||
>
|
||||
<Select placeholder="Select a search provider" loading={isLoadingProviders}>
|
||||
<Select
|
||||
placeholder="Select a search provider"
|
||||
loading={isLoadingProviders}
|
||||
showSearch
|
||||
filterOption={searchProviderFilterOption}
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{availableProviders.map((provider) => (
|
||||
<Select.Option key={provider.provider_name} value={provider.provider_name}>
|
||||
{provider.ui_friendly_name}
|
||||
<Select.Option
|
||||
key={provider.provider_name}
|
||||
value={provider.provider_name}
|
||||
title={provider.ui_friendly_name}
|
||||
label={
|
||||
<SearchProviderLabel providerName={provider.provider_name} displayName={provider.ui_friendly_name} />
|
||||
}
|
||||
>
|
||||
<SearchProviderLabel providerName={provider.provider_name} displayName={provider.ui_friendly_name} />
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { searchProviderFilterOption } from "./searchProviderFilterOption";
|
||||
|
||||
const searxng = { value: "searxng", title: "SearXNG" };
|
||||
const googlePse = { value: "google_pse", title: "Google PSE" };
|
||||
|
||||
describe("searchProviderFilterOption", () => {
|
||||
it("matches a provider by its slug", () => {
|
||||
expect(searchProviderFilterOption("searxng", searxng)).toBe(true);
|
||||
expect(searchProviderFilterOption("searxng", googlePse)).toBe(false);
|
||||
});
|
||||
|
||||
it("matches a provider by its display name, case-insensitively", () => {
|
||||
expect(searchProviderFilterOption("google pse", googlePse)).toBe(true);
|
||||
expect(searchProviderFilterOption("SEARXNG", searxng)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches on a partial slug so results narrow as the user types", () => {
|
||||
expect(searchProviderFilterOption("sear", searxng)).toBe(true);
|
||||
expect(searchProviderFilterOption("sear", googlePse)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps every option when the query is empty or whitespace", () => {
|
||||
expect(searchProviderFilterOption("", googlePse)).toBe(true);
|
||||
expect(searchProviderFilterOption(" ", googlePse)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match an option that has no slug or title", () => {
|
||||
expect(searchProviderFilterOption("searxng", {})).toBe(false);
|
||||
expect(searchProviderFilterOption("searxng", undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
interface SearchProviderOption {
|
||||
value?: string | number | null;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// antd's default filter stringifies option children, which are React elements here, so it matches nothing
|
||||
export const searchProviderFilterOption = (input: string, option?: SearchProviderOption): boolean => {
|
||||
const needle = input.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return [option?.value, option?.title].some(
|
||||
(part) => typeof part === "string" && part.toLowerCase().includes(needle),
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue