From 5c00bbe28c1cf6e49cb570b7b39446b568b2fbd3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 6 Jun 2026 14:20:15 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=AA=20feat:=20Surface=20Agent=20Market?= =?UTF-8?q?place=20in=20Model=20Selector=20(#13553)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: surface marketplace in model selector * chore: sort marketplace selector imports * fix: localize marketplace selector search --- client/src/common/selector.ts | 2 + .../Menus/Endpoints/ModelSelectorContext.tsx | 6 +-- .../Menus/Endpoints/__tests__/utils.test.ts | 34 +++++++++++++ .../Endpoints/components/EndpointItem.tsx | 9 +++- .../Endpoints/components/Marketplace.tsx | 49 +++++++++++++++++++ .../Endpoints/components/SearchResults.tsx | 22 +++++++-- .../__tests__/SearchResults.test.tsx | 36 +++++++++++++- .../Chat/Menus/Endpoints/components/index.ts | 1 + .../components/Chat/Menus/Endpoints/utils.ts | 11 ++++- client/src/hooks/Endpoint/useEndpoints.ts | 20 ++++++-- 10 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts create mode 100644 client/src/components/Chat/Menus/Endpoints/components/Marketplace.tsx diff --git a/client/src/common/selector.ts b/client/src/common/selector.ts index af69ca4af5..002755d114 100644 --- a/client/src/common/selector.ts +++ b/client/src/common/selector.ts @@ -10,6 +10,8 @@ export interface Endpoint { agentNames?: Record; assistantNames?: Record; modelIcons?: Record; + showMarketplace?: boolean; + searchAliases?: string[]; } export interface SelectedValues { diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index 5a51db6ce9..b09bef94be 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -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( () => diff --git a/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts b/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts new file mode 100644 index 0000000000..2a60e3a36c --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts @@ -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; + + const results = filterItems([agentsEndpoint], 'tienda', undefined, undefined, localize); + expect(results).toEqual([agentsEndpoint]); + }); +}); diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx index c8cef36010..5d6811b21d 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx @@ -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 && } + {showMarketplace && hasSelectableRows && } {endpointSpecs.map((spec: TModelSpec) => ( ))} diff --git a/client/src/components/Chat/Menus/Endpoints/components/Marketplace.tsx b/client/src/components/Chat/Menus/Endpoints/components/Marketplace.tsx new file mode 100644 index 0000000000..577e537866 --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/Marketplace.tsx @@ -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 ( + 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, + )} + > +
+
+
+ {label} +
+
+ ); +} diff --git a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx index 26831a577e..e3b45adc05 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx @@ -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} + {showMarketplace && ( + + )} {filteredModels.map((model) => { const modelId = model.name; diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx index 8ab9235f6f..0be7766867 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx @@ -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('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( + , + ); + + const item = screen.getByRole('menuitem', { name: 'com_agents_marketplace' }); + expect(item).toBeInTheDocument(); + + fireEvent.click(item); + expect(mockNavigate).toHaveBeenCalledWith('/agents'); + expect(mockHandleSelectModel).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/components/Chat/Menus/Endpoints/components/index.ts b/client/src/components/Chat/Menus/Endpoints/components/index.ts index bc08e6a8a1..a2e3478bfd 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/index.ts +++ b/client/src/components/Chat/Menus/Endpoints/components/index.ts @@ -3,3 +3,4 @@ export * from './EndpointModelItem'; export * from './EndpointItem'; export * from './SearchResults'; export * from './CustomGroup'; +export * from './Marketplace'; diff --git a/client/src/components/Chat/Menus/Endpoints/utils.ts b/client/src/components/Chat/Menus/Endpoints/utils.ts index 1681ed7f1d..7712bd838f 100644 --- a/client/src/components/Chat/Menus/Endpoints/utils.ts +++ b/client/src/components/Chat/Menus/Endpoints/utils.ts @@ -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, ): 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; diff --git a/client/src/hooks/Endpoint/useEndpoints.ts b/client/src/hooks/Endpoint/useEndpoints.ts index dc72c0ddec..361ec25156 100644 --- a/client/src/hooks/Endpoint/useEndpoints.ts +++ b/client/src/hooks/Endpoint/useEndpoints.ts @@ -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,