mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
feat(ui): add search to Agent Hub tab and admin agents table
Ports the Model Hub search to the AI Hub Agent Hub tab and the admin /agents toolbar as a client-side filter over agent name and description. Extracts the hub search matching into utils/searchUtils and fixes the public Model Hub rendering the whole catalog when a search matches nothing (LIT-5230)
This commit is contained in:
parent
82dd36c1a4
commit
d9f7f9ea16
9 changed files with 282 additions and 134 deletions
|
|
@ -74,6 +74,42 @@ describe("AgentsTable", () => {
|
|||
expect(onDeleteClick).toHaveBeenCalledWith("agent-9", "Doomed Agent");
|
||||
});
|
||||
|
||||
it("filters agents by name or by agent card description", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AgentsTable
|
||||
agents={[
|
||||
makeAgent({ agent_id: "a1", agent_name: "Billing Router" }),
|
||||
makeAgent({
|
||||
agent_id: "a2",
|
||||
agent_name: "Second Agent",
|
||||
agent_card_params: { description: "handles support tickets" },
|
||||
}),
|
||||
]}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
const search = screen.getByPlaceholderText("Search agent names or descriptions...");
|
||||
await user.type(search, "billing");
|
||||
expect(screen.getByText("Billing Router")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Second Agent")).not.toBeInTheDocument();
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "support tickets");
|
||||
expect(screen.getByText("Second Agent")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Billing Router")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the no-match empty state when the search matches nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AgentsTable agents={[makeAgent()]} {...baseProps} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Search agent names or descriptions..."), "zzzz");
|
||||
expect(screen.queryByText("Test Agent")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No matching agents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the actions column entirely for non-admins", () => {
|
||||
const agent = makeAgent({ agent_id: "agent-2" });
|
||||
render(<AgentsTable agents={[agent]} {...baseProps} isAdmin={false} />);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Bot, CircleCheck } from "lucide-react";
|
||||
import { Bot, CircleCheck, Search as SearchIcon, X } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { Agent } from "@/components/agents/types";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { filterBySearchTerm } from "@/utils/searchUtils";
|
||||
|
||||
import { getAgentsTableColumns } from "./AgentsTableColumns";
|
||||
|
||||
|
|
@ -24,14 +26,18 @@ interface AgentsTableProps {
|
|||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
function EmptyState({ isFiltered }: { isFiltered: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Bot className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No agents yet</div>
|
||||
<div className="text-sm text-muted-foreground">Add an agent to make it available in your organization.</div>
|
||||
<div className="text-sm font-medium text-foreground">{isFiltered ? "No matching agents" : "No agents yet"}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isFiltered
|
||||
? "Adjust the search to see more agents."
|
||||
: "Add an agent to make it available in your organization."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -47,6 +53,11 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
onDeleteClick,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const filteredAgents = useMemo(
|
||||
() => filterBySearchTerm(agents, searchTerm, (agent) => [agent.agent_name, agent.agent_card_params?.description]),
|
||||
[agents, searchTerm],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getAgentsTableColumns({ isAdmin, onAgentClick, onDeleteClick }),
|
||||
|
|
@ -55,7 +66,7 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
|
||||
return (
|
||||
<DataTable
|
||||
data={agents}
|
||||
data={filteredAgents}
|
||||
columns={columns}
|
||||
getRowId={(agent, index) => agent.agent_id || String(index)}
|
||||
sortingMode="client"
|
||||
|
|
@ -63,10 +74,27 @@ const AgentsTable: React.FC<AgentsTableProps> = ({
|
|||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading agents…"
|
||||
noDataMessage={<EmptyState />}
|
||||
noDataMessage={<EmptyState isFiltered={agents.length > 0} />}
|
||||
size="compact"
|
||||
toolbar={() => (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<InputGroup className="max-w-sm">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search agent names or descriptions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchTerm("")}>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
|
|||
|
|
@ -203,12 +203,12 @@ describe("ModelHubTable", () => {
|
|||
});
|
||||
|
||||
describe("hub tabs", () => {
|
||||
const renderHub = async () => {
|
||||
const renderHub = async (agents: object[] = []) => {
|
||||
vi.mocked(networking.modelHubCall).mockResolvedValue({
|
||||
data: [{ model_group: "claude-opus-4-8", providers: ["anthropic"], mode: "chat" }],
|
||||
});
|
||||
vi.mocked(networking.getConfigFieldSetting).mockResolvedValue({ field_value: false });
|
||||
vi.mocked(networking.getAgentsList).mockResolvedValue({ agents: [] });
|
||||
vi.mocked(networking.getAgentsList).mockResolvedValue({ agents });
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([]);
|
||||
vi.mocked(networking.getUiSettings).mockResolvedValue({ values: {} });
|
||||
mockUseUISettings.mockReturnValue({ data: { values: {} }, isLoading: false });
|
||||
|
|
@ -230,6 +230,37 @@ describe("ModelHubTable", () => {
|
|||
expect(await screen.findByPlaceholderText("Search model names...")).toHaveValue("opus");
|
||||
});
|
||||
|
||||
it("filters the Agent Hub table by name or description and shows the no-match state", async () => {
|
||||
const { user } = await renderHub([
|
||||
{
|
||||
agent_id: "a1",
|
||||
agent_card_params: { name: "Billing Router", description: "routes billing questions" },
|
||||
litellm_params: { is_public: false },
|
||||
},
|
||||
{
|
||||
agent_id: "a2",
|
||||
agent_card_params: { name: "Support Bot", description: "handles support tickets" },
|
||||
litellm_params: { is_public: false },
|
||||
},
|
||||
]);
|
||||
const agentCount = (expected: string) =>
|
||||
screen.getByText((_, el) => el?.tagName === "P" && el.textContent === expected);
|
||||
|
||||
await user.click(screen.getByRole("tab", { name: "Agent Hub" }));
|
||||
expect(await screen.findByText("Billing Router")).toBeInTheDocument();
|
||||
|
||||
const search = screen.getByPlaceholderText("Search agent names or descriptions...");
|
||||
await user.type(search, "support tickets");
|
||||
expect(screen.queryByText("Billing Router")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Support Bot")).toBeInTheDocument();
|
||||
expect(agentCount("Showing 1 of 2 agents")).toBeInTheDocument();
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "zzzz");
|
||||
expect(screen.getByText("No matching agents")).toBeInTheDocument();
|
||||
expect(agentCount("Showing 0 of 2 agents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the hub strip as underlined tabs rather than a segmented pill", async () => {
|
||||
await renderHub();
|
||||
|
||||
|
|
|
|||
|
|
@ -23,13 +23,15 @@ import {
|
|||
import PublicModelHub from "@/components/public_model_hub";
|
||||
import { copyToClipboard } from "@/utils/dataUtils";
|
||||
import { isAdminRole, isProxyAdminRole } from "@/utils/roles";
|
||||
import { filterBySearchTerm } from "@/utils/searchUtils";
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Copy, Inbox } from "lucide-react";
|
||||
import { Copy, Inbox, Search as SearchIcon, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
|
|
@ -80,6 +82,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
|||
const [agentLoading, setAgentLoading] = useState<boolean>(true);
|
||||
const [selectedAgent, setSelectedAgent] = useState<null | AgentHubData>(null);
|
||||
const [isAgentModalVisible, setIsAgentModalVisible] = useState(false);
|
||||
const [agentSearchTerm, setAgentSearchTerm] = useState("");
|
||||
// MCP Hub state
|
||||
const [mcpHubData, setMcpHubData] = useState<MCPServerData[] | null>(null);
|
||||
const [mcpLoading, setMcpLoading] = useState<boolean>(true);
|
||||
|
|
@ -385,6 +388,10 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
|||
|
||||
const modelColumns = useMemo(() => getModelHubTableColumns({ onModelClick: showModal }), [showModal]);
|
||||
const agentColumns = useMemo(() => getAgentHubTableColumns({ onAgentClick: showAgentModal }), [showAgentModal]);
|
||||
const filteredAgentData = useMemo(
|
||||
() => filterBySearchTerm(agentHubData ?? [], agentSearchTerm, (agent) => [agent.name, agent.description]),
|
||||
[agentHubData, agentSearchTerm],
|
||||
);
|
||||
const mcpColumns = useMemo(() => getMCPHubTableColumns({ onServerClick: showMcpModal }), [showMcpModal]);
|
||||
|
||||
// If this is a public page, use the dedicated PublicModelHub component
|
||||
|
|
@ -505,9 +512,34 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium mb-2">Search Agents:</p>
|
||||
<InputGroup className="max-w-sm">
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search agent names or descriptions..."
|
||||
value={agentSearchTerm}
|
||||
onChange={(e) => setAgentSearchTerm(e.target.value)}
|
||||
/>
|
||||
{agentSearchTerm && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="Clear search"
|
||||
onClick={() => setAgentSearchTerm("")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
{/* Agent Table */}
|
||||
<DataTable
|
||||
data={agentHubData || []}
|
||||
data={filteredAgentData}
|
||||
columns={agentColumns}
|
||||
getRowId={(agent, index) => agent.agent_id || agent.name || String(index)}
|
||||
sortingMode="client"
|
||||
|
|
@ -516,7 +548,14 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
|||
isLoading={agentLoading}
|
||||
loadingMessage="Loading agents…"
|
||||
noDataMessage={
|
||||
<HubEmptyState title="No agents yet" body="Agents added to this proxy will appear here." />
|
||||
<HubEmptyState
|
||||
title={agentHubData?.length ? "No matching agents" : "No agents yet"}
|
||||
body={
|
||||
agentHubData?.length
|
||||
? "Adjust the search to see more agents."
|
||||
: "Agents added to this proxy will appear here."
|
||||
}
|
||||
/>
|
||||
}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
@ -524,7 +563,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({ accessToken, publicPage,
|
|||
|
||||
<div className="mt-4 text-center space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {agentHubData?.length || 0} agent{agentHubData?.length !== 1 ? "s" : ""}
|
||||
Showing {filteredAgentData.length} of {agentHubData?.length || 0} agents
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { matchesSearchTerm } from "@/utils/searchUtils";
|
||||
|
||||
interface ModelGroupInfo {
|
||||
model_group: string;
|
||||
|
|
@ -76,7 +77,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
|
|||
const filteredData = useMemo(() => {
|
||||
return (
|
||||
modelHubData?.filter((model) => {
|
||||
const matchesSearch = model.model_group.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesSearch = matchesSearchTerm(searchTerm, [model.model_group]);
|
||||
const matchesProvider = selectedProvider === "" || model.providers.includes(selectedProvider);
|
||||
const matchesMode = selectedMode === "" || model.mode === selectedMode;
|
||||
|
||||
|
|
|
|||
|
|
@ -134,6 +134,27 @@ describe("PublicModelHub", () => {
|
|||
expect(within(gpt35Row as HTMLElement).getByText("Unknown")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows no models when the search has no matches (LIT-5230 regression)", async () => {
|
||||
const networkingModule = await import("./networking");
|
||||
vi.mocked(networkingModule.modelHubPublicModelsCall).mockResolvedValue([
|
||||
{ model_group: "gpt-4", providers: ["openai"], mode: "chat" },
|
||||
{ model_group: "claude-3", providers: ["anthropic"], mode: "chat" },
|
||||
]);
|
||||
|
||||
render(<PublicModelHub />);
|
||||
expect(await screen.findByText("gpt-4")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search model names... (smart search enabled)"), {
|
||||
target: { value: "zzzz" },
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("gpt-4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("claude-3")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No matching models")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("handles non-array response gracefully (regression test for e.filter crash)", async () => {
|
||||
const networkingModule = await import("./networking");
|
||||
// Mock the API to return an object (like an error response) instead of an array
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets";
|
|||
import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import { MessageType } from "@/components/chat_ui/types";
|
||||
import { getProviderLogoAndName } from "./provider_info_helpers";
|
||||
import { filterBySearchTerm, rankBySearchRelevance } from "@/utils/searchUtils";
|
||||
|
||||
interface PublicModelHubProps {
|
||||
accessToken?: string | null;
|
||||
|
|
@ -236,52 +237,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
const filteredData = useMemo(() => {
|
||||
if (!modelHubData || !Array.isArray(modelHubData)) return [];
|
||||
|
||||
let searchResults = modelHubData;
|
||||
|
||||
// Apply search if there's a search term
|
||||
if (searchTerm.trim()) {
|
||||
const lowercaseSearch = searchTerm.toLowerCase();
|
||||
const searchWords = lowercaseSearch.split(/\s+/);
|
||||
|
||||
// First, try flexible matching that handles different separators
|
||||
const exactMatches = modelHubData.filter((model) => {
|
||||
const modelName = model.model_group.toLowerCase();
|
||||
|
||||
// Check if it contains the exact search term
|
||||
if (modelName.includes(lowercaseSearch)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it contains all search words (handles spaces vs slashes/dashes)
|
||||
return searchWords.every((word) => modelName.includes(word));
|
||||
});
|
||||
|
||||
// If we have exact matches, rank them by relevance
|
||||
if (exactMatches.length > 0) {
|
||||
searchResults = exactMatches.sort((a, b) => {
|
||||
const aName = a.model_group.toLowerCase();
|
||||
const bName = b.model_group.toLowerCase();
|
||||
|
||||
// Calculate relevance scores
|
||||
const aExactMatch = aName === lowercaseSearch ? 1000 : 0;
|
||||
const bExactMatch = bName === lowercaseSearch ? 1000 : 0;
|
||||
|
||||
const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0;
|
||||
const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0;
|
||||
|
||||
const aContainsWords = lowercaseSearch.split(/\s+/).every((word) => aName.includes(word)) ? 50 : 0;
|
||||
const bContainsWords = lowercaseSearch.split(/\s+/).every((word) => bName.includes(word)) ? 50 : 0;
|
||||
|
||||
const aLength = aName.length;
|
||||
const bLength = bName.length;
|
||||
|
||||
const aScore = aExactMatch + aStartsWith + aContainsWords + (1000 - aLength);
|
||||
const bScore = bExactMatch + bStartsWith + bContainsWords + (1000 - bLength);
|
||||
|
||||
return bScore - aScore; // Higher score first
|
||||
});
|
||||
}
|
||||
}
|
||||
const searchResults = rankBySearchRelevance(
|
||||
filterBySearchTerm(modelHubData, searchTerm, (model) => [model.model_group]),
|
||||
searchTerm,
|
||||
(model) => model.model_group,
|
||||
);
|
||||
|
||||
// Apply other filters
|
||||
return searchResults.filter((model) => {
|
||||
|
|
@ -310,43 +270,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
const filteredAgentData = useMemo(() => {
|
||||
if (!agentHubData || !Array.isArray(agentHubData)) return [];
|
||||
|
||||
let searchResults = agentHubData;
|
||||
|
||||
// Apply search if there's a search term
|
||||
if (agentSearchTerm.trim()) {
|
||||
const lowercaseSearch = agentSearchTerm.toLowerCase();
|
||||
const searchWords = lowercaseSearch.split(/\s+/);
|
||||
|
||||
searchResults = agentHubData.filter((agent) => {
|
||||
const agentName = agent.name.toLowerCase();
|
||||
const agentDescription = agent.description.toLowerCase();
|
||||
|
||||
// Check if it contains the exact search term
|
||||
if (agentName.includes(lowercaseSearch) || agentDescription.includes(lowercaseSearch)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it contains all search words
|
||||
return searchWords.every((word) => agentName.includes(word) || agentDescription.includes(word));
|
||||
});
|
||||
|
||||
// Sort by relevance
|
||||
searchResults = searchResults.sort((a, b) => {
|
||||
const aName = a.name.toLowerCase();
|
||||
const bName = b.name.toLowerCase();
|
||||
|
||||
const aExactMatch = aName === lowercaseSearch ? 1000 : 0;
|
||||
const bExactMatch = bName === lowercaseSearch ? 1000 : 0;
|
||||
|
||||
const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0;
|
||||
const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0;
|
||||
|
||||
const aScore = aExactMatch + aStartsWith + (1000 - aName.length);
|
||||
const bScore = bExactMatch + bStartsWith + (1000 - bName.length);
|
||||
|
||||
return bScore - aScore;
|
||||
});
|
||||
}
|
||||
const searchResults = rankBySearchRelevance(
|
||||
filterBySearchTerm(agentHubData, agentSearchTerm, (agent) => [agent.name, agent.description]),
|
||||
agentSearchTerm,
|
||||
(agent) => agent.name,
|
||||
);
|
||||
|
||||
// Apply skill filters
|
||||
return searchResults.filter((agent) => {
|
||||
|
|
@ -361,43 +289,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
|
|||
const filteredMcpData = useMemo(() => {
|
||||
if (!mcpHubData || !Array.isArray(mcpHubData)) return [];
|
||||
|
||||
let searchResults = mcpHubData;
|
||||
|
||||
// Apply search if there's a search term
|
||||
if (mcpSearchTerm.trim()) {
|
||||
const lowercaseSearch = mcpSearchTerm.toLowerCase();
|
||||
const searchWords = lowercaseSearch.split(/\s+/);
|
||||
|
||||
searchResults = mcpHubData.filter((server) => {
|
||||
const serverName = server.server_name.toLowerCase();
|
||||
const serverDescription = (server.mcp_info?.description || "").toLowerCase();
|
||||
|
||||
// Check if it contains the exact search term
|
||||
if (serverName.includes(lowercaseSearch) || serverDescription.includes(lowercaseSearch)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it contains all search words
|
||||
return searchWords.every((word) => serverName.includes(word) || serverDescription.includes(word));
|
||||
});
|
||||
|
||||
// Sort by relevance
|
||||
searchResults = searchResults.sort((a, b) => {
|
||||
const aName = a.server_name.toLowerCase();
|
||||
const bName = b.server_name.toLowerCase();
|
||||
|
||||
const aExactMatch = aName === lowercaseSearch ? 1000 : 0;
|
||||
const bExactMatch = bName === lowercaseSearch ? 1000 : 0;
|
||||
|
||||
const aStartsWith = aName.startsWith(lowercaseSearch) ? 100 : 0;
|
||||
const bStartsWith = bName.startsWith(lowercaseSearch) ? 100 : 0;
|
||||
|
||||
const aScore = aExactMatch + aStartsWith + (1000 - aName.length);
|
||||
const bScore = bExactMatch + bStartsWith + (1000 - bName.length);
|
||||
|
||||
return bScore - aScore;
|
||||
});
|
||||
}
|
||||
const searchResults = rankBySearchRelevance(
|
||||
filterBySearchTerm(mcpHubData, mcpSearchTerm, (server) => [server.server_name, server.mcp_info?.description]),
|
||||
mcpSearchTerm,
|
||||
(server) => server.server_name,
|
||||
);
|
||||
|
||||
// Apply transport filters
|
||||
return searchResults.filter((server) => {
|
||||
|
|
|
|||
64
ui/litellm-dashboard/src/utils/searchUtils.test.ts
Normal file
64
ui/litellm-dashboard/src/utils/searchUtils.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { filterBySearchTerm, matchesSearchTerm, rankBySearchRelevance } from "./searchUtils";
|
||||
|
||||
describe("matchesSearchTerm", () => {
|
||||
it("matches everything on an empty or whitespace-only term", () => {
|
||||
expect(matchesSearchTerm("", ["anything"])).toBe(true);
|
||||
expect(matchesSearchTerm(" ", ["anything"])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches a substring of any field, case-insensitively", () => {
|
||||
expect(matchesSearchTerm("BILL", ["Billing Router", "routes invoices"])).toBe(true);
|
||||
expect(matchesSearchTerm("invoice", ["Billing Router", "routes invoices"])).toBe(true);
|
||||
});
|
||||
|
||||
it("matches when every word appears in some field", () => {
|
||||
expect(matchesSearchTerm("router invoices", ["Billing Router", "routes invoices"])).toBe(true);
|
||||
expect(matchesSearchTerm("router refunds", ["Billing Router", "routes invoices"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when nothing matches", () => {
|
||||
expect(matchesSearchTerm("zzzz", ["Billing Router", "routes invoices"])).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores null and undefined fields", () => {
|
||||
expect(matchesSearchTerm("billing", [null, undefined, "Billing Router"])).toBe(true);
|
||||
expect(matchesSearchTerm("billing", [null, undefined])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterBySearchTerm", () => {
|
||||
const agents = [
|
||||
{ name: "Billing Router", description: "routes invoices" },
|
||||
{ name: "Support Bot", description: "handles tickets" },
|
||||
];
|
||||
|
||||
it("keeps only items whose fields match", () => {
|
||||
expect(filterBySearchTerm(agents, "tickets", (a) => [a.name, a.description])).toEqual([agents[1]]);
|
||||
});
|
||||
|
||||
it("returns an empty list when nothing matches", () => {
|
||||
expect(filterBySearchTerm(agents, "zzzz", (a) => [a.name, a.description])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all items for an empty term", () => {
|
||||
expect(filterBySearchTerm(agents, "", (a) => [a.name, a.description])).toEqual(agents);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankBySearchRelevance", () => {
|
||||
it("orders exact match, then prefix match, then shorter names", () => {
|
||||
const items = [{ name: "gpt-4o-mini-transcribe" }, { name: "gpt-4o" }, { name: "chatgpt-4o-latest" }];
|
||||
expect(rankBySearchRelevance(items, "gpt-4o", (m) => m.name).map((m) => m.name)).toEqual([
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini-transcribe",
|
||||
"chatgpt-4o-latest",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the original order for an empty term", () => {
|
||||
const items = [{ name: "b" }, { name: "a" }];
|
||||
expect(rankBySearchRelevance(items, "", (m) => m.name)).toEqual(items);
|
||||
});
|
||||
});
|
||||
32
ui/litellm-dashboard/src/utils/searchUtils.ts
Normal file
32
ui/litellm-dashboard/src/utils/searchUtils.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
type SearchField = string | null | undefined;
|
||||
|
||||
const normalizeTerm = (term: string): string => term.trim().toLowerCase();
|
||||
|
||||
export function matchesSearchTerm(term: string, fields: ReadonlyArray<SearchField>): boolean {
|
||||
const needle = normalizeTerm(term);
|
||||
if (needle === "") return true;
|
||||
|
||||
const haystacks = fields.filter((field): field is string => typeof field === "string").map((f) => f.toLowerCase());
|
||||
if (haystacks.some((haystack) => haystack.includes(needle))) return true;
|
||||
|
||||
return needle.split(/\s+/).every((word) => haystacks.some((haystack) => haystack.includes(word)));
|
||||
}
|
||||
|
||||
export function filterBySearchTerm<T>(
|
||||
items: ReadonlyArray<T>,
|
||||
term: string,
|
||||
fields: (item: T) => ReadonlyArray<SearchField>,
|
||||
): T[] {
|
||||
return items.filter((item) => matchesSearchTerm(term, fields(item)));
|
||||
}
|
||||
|
||||
export function rankBySearchRelevance<T>(items: ReadonlyArray<T>, term: string, name: (item: T) => string): T[] {
|
||||
const needle = normalizeTerm(term);
|
||||
if (needle === "") return [...items];
|
||||
|
||||
const score = (item: T): number => {
|
||||
const candidate = name(item).toLowerCase();
|
||||
return (candidate === needle ? 1000 : 0) + (candidate.startsWith(needle) ? 100 : 0) + (1000 - candidate.length);
|
||||
};
|
||||
return [...items].sort((a, b) => score(b) - score(a));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue