mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🏪 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:
parent
ed4546c5dc
commit
5c00bbe28c
10 changed files with 176 additions and 14 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ export * from './EndpointModelItem';
|
|||
export * from './EndpointItem';
|
||||
export * from './SearchResults';
|
||||
export * from './CustomGroup';
|
||||
export * from './Marketplace';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue