🏪 feat: Surface Agent Marketplace in Model Selector (#13553)

* feat: surface marketplace in model selector

* chore: sort marketplace selector imports

* fix: localize marketplace selector search
This commit is contained in:
Danny Avila 2026-06-06 14:20:15 -04:00 committed by GitHub
parent ed4546c5dc
commit 5c00bbe28c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 176 additions and 14 deletions

View file

@ -10,6 +10,8 @@ export interface Endpoint {
agentNames?: Record<string, string>;
assistantNames?: Record<string, string>;
modelIcons?: Record<string, string | undefined>;
showMarketplace?: boolean;
searchAliases?: string[];
}
export interface SelectedValues {

View file

@ -1,5 +1,5 @@
import debounce from 'lodash/debounce';
import React, { createContext, useContext, useState, useMemo, useCallback } from 'react';
import debounce from 'lodash/debounce';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
import type { Endpoint, SelectedValues } from '~/common';
@ -161,8 +161,8 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
return null;
}
const allItems = [...modelSpecs, ...mappedEndpoints];
return filterItems(allItems, searchValue, agentsMap, assistantsMap || {});
}, [searchValue, modelSpecs, mappedEndpoints, agentsMap, assistantsMap]);
return filterItems(allItems, searchValue, agentsMap, assistantsMap || {}, localize);
}, [searchValue, modelSpecs, mappedEndpoints, agentsMap, assistantsMap, localize]);
const setDebouncedSearchValue = useMemo(
() =>

View file

@ -0,0 +1,34 @@
import type { useLocalize } from '~/hooks';
import type { Endpoint } from '~/common';
import { filterItems } from '../utils';
const agentsEndpoint: Endpoint = {
value: 'agents',
label: 'My Agents',
hasModels: true,
icon: null,
showMarketplace: true,
searchAliases: ['agent marketplace', 'marketplace'],
};
describe('model selector utilities', () => {
it('matches endpoint search aliases', () => {
const results = filterItems([agentsEndpoint], 'marketplace', undefined, undefined);
expect(results).toEqual([agentsEndpoint]);
});
it('matches localized Marketplace labels', () => {
const localize = ((key: string) => {
if (key === 'com_agents_marketplace') {
return 'Tienda de Agentes';
}
if (key === 'com_ui_marketplace') {
return 'Tienda';
}
return key;
}) as ReturnType<typeof useLocalize>;
const results = filterItems([agentsEndpoint], 'tienda', undefined, undefined, localize);
expect(results).toEqual([agentsEndpoint]);
});
});

View file

@ -5,7 +5,8 @@ import { CheckCircle2, MousePointerClick, SettingsIcon } from 'lucide-react';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
import { CustomMenu as Menu, CustomMenuItem as MenuItem, CustomMenuSeparator } from '../CustomMenu';
import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { renderEndpointModels } from './EndpointModelItem';
import { ModelSpecItem } from './ModelSpecItem';
@ -127,9 +128,15 @@ function EndpointMenuContent({
assistantsMap,
)
: null;
const renderedModels = filteredModels ?? endpoint.models?.map((model) => model.name) ?? [];
const showMarketplace =
endpoint.showMarketplace === true && marketplaceSearchMatches(searchValue, localize);
const hasSelectableRows = endpointSpecs.length > 0 || renderedModels.length > 0;
return (
<>
{showMarketplace && <MarketplaceItem label={localize('com_agents_marketplace')} />}
{showMarketplace && hasSelectableRows && <CustomMenuSeparator />}
{endpointSpecs.map((spec: TModelSpec) => (
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
))}

View file

@ -0,0 +1,49 @@
import { LayoutGrid } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import type { LocalizeFunction } from '~/common';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import { cn } from '~/utils';
const marketplaceSearchAliases = ['agent marketplace', 'marketplace'];
export function marketplaceSearchMatches(searchValue: string, localize: LocalizeFunction): boolean {
const searchTerm = searchValue.trim().toLowerCase();
if (!searchTerm) {
return true;
}
return [
localize('com_agents_marketplace'),
localize('com_ui_marketplace'),
...marketplaceSearchAliases,
].some((label) => label.toLowerCase().includes(searchTerm));
}
export default function MarketplaceItem({
className,
label,
}: {
className?: string;
label: string;
}) {
const navigate = useNavigate();
return (
<MenuItem
onClick={() => navigate('/agents')}
aria-label={label}
data-testid="model-selector-marketplace-item"
className={cn(
'flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm',
className,
)}
>
<div className="flex w-full min-w-0 items-center gap-2 px-1 py-1">
<div className="flex h-5 w-5 shrink-0 items-center justify-center">
<LayoutGrid className="h-5 w-5 text-text-primary" aria-hidden="true" />
</div>
<span className="truncate text-left">{label}</span>
</div>
</MenuItem>
);
}

View file

@ -4,6 +4,7 @@ import { CheckCircle2, EarthIcon } from 'lucide-react';
import { isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import SpecIcon from './SpecIcon';
@ -102,11 +103,16 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
} else {
// For an endpoint item
const endpoint = item as Endpoint;
if (endpoint.hasModels && endpoint.models && endpoint.models.length > 0) {
if (endpoint.hasModels) {
const lowerQuery = searchValue.toLowerCase();
const filteredModels = endpoint.label.toLowerCase().includes(lowerQuery)
? endpoint.models
: endpoint.models.filter((model) => {
const endpointMatches = endpoint.label.toLowerCase().includes(lowerQuery);
const showMarketplace =
endpoint.showMarketplace === true &&
(endpointMatches || marketplaceSearchMatches(searchValue, localize));
const models = endpoint.models ?? [];
const filteredModels = endpointMatches
? models
: models.filter((model) => {
let modelName = model.name;
if (
isAgentsEndpoint(endpoint.value) &&
@ -124,7 +130,7 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
return modelName.toLowerCase().includes(lowerQuery);
});
if (!filteredModels.length) {
if (!filteredModels.length && !showMarketplace) {
return null; // skip if no models match
}
@ -138,6 +144,12 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
)}
{endpoint.label}
</div>
{showMarketplace && (
<MarketplaceItem
className="px-3 py-2 pl-6"
label={localize('com_agents_marketplace')}
/>
)}
{filteredModels.map((model) => {
const modelId = model.name;

View file

@ -1,10 +1,11 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import type { Endpoint, SelectedValues } from '~/common';
import { SearchResults } from '../SearchResults';
const mockHandleSelectSpec = jest.fn();
const mockHandleSelectModel = jest.fn();
const mockHandleSelectEndpoint = jest.fn();
const mockNavigate = jest.fn();
let mockSelectedValues: SelectedValues;
jest.mock('~/components/Chat/Menus/Endpoints/ModelSelectorContext', () => ({
@ -29,6 +30,10 @@ jest.mock('~/components/Chat/Menus/Endpoints/CustomMenu', () => {
};
});
jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
}));
jest.mock('../SpecIcon', () => {
const React = jest.requireActual<typeof import('react')>('react');
return {
@ -54,6 +59,17 @@ const noModelsEndpoint: Endpoint = {
icon: null,
};
const agentsMarketplaceEndpoint: Endpoint = {
value: 'agents',
label: 'My Agents',
hasModels: true,
models: [{ name: 'agent-1' }],
agentNames: { 'agent-1': 'Support Agent' },
showMarketplace: true,
searchAliases: ['agent marketplace', 'marketplace'],
icon: null,
};
describe('SearchResults', () => {
beforeEach(() => {
jest.clearAllMocks();
@ -106,4 +122,22 @@ describe('SearchResults', () => {
const item = screen.getByRole('menuitem');
expect(item).toHaveAttribute('aria-selected', 'true');
});
it('renders Marketplace from agent endpoint search results and navigates to agents', () => {
mockSelectedValues = { endpoint: '', model: '', modelSpec: '' };
render(
<SearchResults
results={[agentsMarketplaceEndpoint]}
localize={localize}
searchValue="marketplace"
/>,
);
const item = screen.getByRole('menuitem', { name: 'com_agents_marketplace' });
expect(item).toBeInTheDocument();
fireEvent.click(item);
expect(mockNavigate).toHaveBeenCalledWith('/agents');
expect(mockHandleSelectModel).not.toHaveBeenCalled();
});
});

View file

@ -3,3 +3,4 @@ export * from './EndpointModelItem';
export * from './EndpointItem';
export * from './SearchResults';
export * from './CustomGroup';
export * from './Marketplace';

View file

@ -17,12 +17,15 @@ export function filterItems<
name?: string;
value?: string;
models?: Array<{ name: string; isGlobal?: boolean }>;
searchAliases?: string[];
showMarketplace?: boolean;
},
>(
items: T[],
searchValue: string,
agentsMap: TAgentsMap | undefined,
assistantsMap: TAssistantsMap | undefined,
localize?: ReturnType<typeof useLocalize>,
): T[] | null {
const searchTermLower = searchValue.trim().toLowerCase();
if (!searchTermLower) {
@ -33,7 +36,13 @@ export function filterItems<
const itemMatches =
item.label.toLowerCase().includes(searchTermLower) ||
(item.name && item.name.toLowerCase().includes(searchTermLower)) ||
(item.value && item.value.toLowerCase().includes(searchTermLower));
(item.value && item.value.toLowerCase().includes(searchTermLower)) ||
item.searchAliases?.some((alias) => alias.toLowerCase().includes(searchTermLower)) ||
(item.showMarketplace === true &&
localize != null &&
[localize('com_agents_marketplace'), localize('com_ui_marketplace')].some((label) =>
label.toLowerCase().includes(searchTermLower),
));
if (itemMatches) {
return true;

View file

@ -16,9 +16,9 @@ import type {
Agent,
} from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { useHasAccess, useShowMarketplace } from '~/hooks';
import { useGetEndpointsQuery } from '~/data-provider';
import { mapEndpoints, getIconKey } from '~/utils';
import { useHasAccess } from '~/hooks';
import { icons } from './Icons';
const defaultInterface = getConfigDefaults().interface;
@ -46,6 +46,7 @@ export const useEndpoints = ({
permissionType: PermissionTypes.AGENTS,
permission: Permissions.USE,
});
const showAgentMarketplace = useShowMarketplace();
const assistants: Assistant[] = useMemo(
() => Object.values(assistantsMap?.[EModelEndpoint.assistants] ?? {}),
@ -89,7 +90,7 @@ export const useEndpoints = ({
const Icon = icons[iconKey];
const endpointIconURL = getEndpointField(endpointsConfig, ep, 'iconURL');
const hasModels =
(ep === EModelEndpoint.agents && (agents?.length ?? 0) > 0) ||
(ep === EModelEndpoint.agents && ((agents?.length ?? 0) > 0 || showAgentMarketplace)) ||
(ep === EModelEndpoint.assistants && assistants?.length > 0) ||
(ep !== EModelEndpoint.assistants &&
ep !== EModelEndpoint.agents &&
@ -110,6 +111,11 @@ export const useEndpoints = ({
: null,
};
if (ep === EModelEndpoint.agents && showAgentMarketplace) {
result.showMarketplace = true;
result.searchAliases = ['agent marketplace', 'marketplace'];
}
// Handle agents case
if (ep === EModelEndpoint.agents && (agents?.length ?? 0) > 0) {
result.models = agents?.map((agent) => ({
@ -181,7 +187,15 @@ export const useEndpoints = ({
return result;
});
}, [filteredEndpoints, endpointsConfig, modelsQuery.data, agents, assistants, azureAssistants]);
}, [
agents,
assistants,
azureAssistants,
endpointsConfig,
filteredEndpoints,
modelsQuery.data,
showAgentMarketplace,
]);
return {
mappedEndpoints,