diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx index b72c4f2dcc..c761b02a8a 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx @@ -49,8 +49,9 @@ function ModelSelectorContent() { selectedValues, modelSpecs, endpointsConfig, + agentsMap, }), - [mappedEndpoints, selectedValues, modelSpecs, endpointsConfig], + [mappedEndpoints, selectedValues, modelSpecs, endpointsConfig, agentsMap], ); const selectedDisplayValue = useMemo( () => diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index 9e450fc75e..13e70cc0a4 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -68,6 +68,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const localize = useLocalize(); const { announcePolite } = useLiveAnnouncer(); const modelSpecs = useMemo(() => { + /** Labels are normalized at the startup-config query boundary. */ const specs = startupConfig?.modelSpecs?.list ?? []; if (!agentsMap) { return specs; diff --git a/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx index e85b4b62de..c181d0f752 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx @@ -5,9 +5,9 @@ import type { TModelSpec } from 'librechat-data-provider'; import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; +import { cn, getSpecAgentAvatarURL } from '~/utils'; import SpecDescription from './SpecDescription'; import SpecIcon from './SpecIcon'; -import { cn } from '~/utils'; interface ModelSpecItemProps { spec: TModelSpec; @@ -19,9 +19,10 @@ interface ModelSpecItemProps { export function ModelSpecItem({ spec, isSelected, posInSet, setSize }: ModelSpecItemProps) { const localize = useLocalize(); - const { handleSelectSpec, endpointsConfig } = useModelSelectorContext(); + const { handleSelectSpec, endpointsConfig, agentsMap } = useModelSelectorContext(); const { isFavoriteSpec, toggleFavoriteSpec } = useFavorites(); const { showIconInMenu = true } = spec; + const agentAvatarURL = getSpecAgentAvatarURL(spec, agentsMap); const { ref: itemRef, isActive } = useIsActiveItem(); @@ -49,7 +50,11 @@ export function ModelSpecItem({ spec, isSelected, posInSet, setSize }: ModelSpec > {showIconInMenu && (
- +
)}
diff --git a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx index b5676cfa7f..72541f80ab 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SearchResults.tsx @@ -8,9 +8,9 @@ import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; import { shouldRenderEndpointOption } from '../utils'; +import { cn, getSpecAgentAvatarURL } from '~/utils'; import SpecDescription from './SpecDescription'; import SpecIcon from './SpecIcon'; -import { cn } from '~/utils'; interface SearchResultsProps { results: (TModelSpec | Endpoint)[] | null; @@ -25,6 +25,7 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP handleSelectModel, handleSelectEndpoint, endpointsConfig, + agentsMap, } = useModelSelectorContext(); const { @@ -78,7 +79,11 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP > {(spec.showIconInMenu ?? true) && (
- +
)}
diff --git a/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx index 2e354db08b..8370808ee2 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx @@ -10,12 +10,14 @@ import { isImageURL } from '~/utils/icons'; interface SpecIconProps { currentSpec: TModelSpec; endpointsConfig: TEndpointsConfig; + /** Avatar of the agent this spec targets, used when the spec defines no icon of its own. */ + agentAvatarURL?: string; } type IconType = (props: IconMapProps) => React.JSX.Element; -const SpecIcon: React.FC = ({ currentSpec, endpointsConfig }) => { - const iconURL = getModelSpecIconURL(currentSpec); +const SpecIcon: React.FC = ({ currentSpec, endpointsConfig, agentAvatarURL }) => { + const iconURL = getModelSpecIconURL(currentSpec, agentAvatarURL); const endpoint = currentSpec.preset?.endpoint; const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL }); diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx index e480a4bde7..45b0f3f001 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx @@ -87,4 +87,73 @@ describe('SpecIcon', () => { expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown'); }); + + it("renders the agent's avatar when the spec defines no icon of its own", () => { + const currentSpec = { + name: 'agent-spec', + label: 'Agent Spec', + preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_abc' }, + } as TModelSpec; + + render( + , + ); + + expect(screen.getByTestId('url-icon')).toHaveAttribute( + 'data-icon-url', + '/images/agent-avatar.png', + ); + }); + + /** + * Form-authored specs persist untouched icon fields as empty strings, which + * must not suppress the avatar the way a real icon would. + */ + it('treats empty icon fields as unset and still uses the agent avatar', () => { + const currentSpec = { + name: 'agent-spec', + label: 'Agent Spec', + iconURL: '', + preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_abc', iconURL: '' }, + } as unknown as TModelSpec; + + render( + , + ); + + expect(screen.getByTestId('url-icon')).toHaveAttribute( + 'data-icon-url', + '/images/agent-avatar.png', + ); + }); + + it('prefers an explicit spec icon over the agent avatar', () => { + const currentSpec = { + name: 'agent-spec', + label: 'Agent Spec', + iconURL: '/assets/explicit-logo.svg', + preset: { endpoint: EModelEndpoint.agents, agent_id: 'agent_abc' }, + } as TModelSpec; + + render( + , + ); + + expect(screen.getByTestId('url-icon')).toHaveAttribute( + 'data-icon-url', + '/assets/explicit-logo.svg', + ); + }); }); diff --git a/client/src/components/Chat/Menus/Endpoints/utils.ts b/client/src/components/Chat/Menus/Endpoints/utils.ts index 474d3f23ea..8a478321a2 100644 --- a/client/src/components/Chat/Menus/Endpoints/utils.ts +++ b/client/src/components/Chat/Menus/Endpoints/utils.ts @@ -10,6 +10,7 @@ import type { import type { useLocalize } from '~/hooks'; import SpecIcon from '~/components/Chat/Menus/Endpoints/components/SpecIcon'; import { Endpoint, SelectedValues } from '~/common'; +import { getSpecAgentAvatarURL } from '~/utils'; export function filterItems< T extends { @@ -124,11 +125,13 @@ export function getSelectedIcon({ selectedValues, modelSpecs, endpointsConfig, + agentsMap, }: { mappedEndpoints: Endpoint[]; selectedValues: SelectedValues; modelSpecs: TModelSpec[]; endpointsConfig: TEndpointsConfig; + agentsMap?: TAgentsMap; }): React.ReactNode | null { const { endpoint, model, modelSpec } = selectedValues; @@ -144,6 +147,7 @@ export function getSelectedIcon({ return React.createElement(SpecIcon, { currentSpec: spec, endpointsConfig, + agentAvatarURL: getSpecAgentAvatarURL(spec, agentsMap), }); } diff --git a/client/src/components/Nav/Favorites/FavoriteItem.tsx b/client/src/components/Nav/Favorites/FavoriteItem.tsx index f29ca8d440..c0a76628a6 100644 --- a/client/src/components/Nav/Favorites/FavoriteItem.tsx +++ b/client/src/components/Nav/Favorites/FavoriteItem.tsx @@ -37,6 +37,8 @@ type SpecFavoriteProps = FavoriteItemBaseProps & { item: TModelSpec; onSelectSpec?: (spec: TModelSpec) => void; endpointsConfig?: TEndpointsConfig; + /** Avatar of the agent the spec targets, used when the spec defines no icon of its own. */ + agentAvatarURL?: string; }; type FavoriteItemProps = AgentFavoriteProps | ModelFavoriteProps | SpecFavoriteProps; @@ -91,7 +93,11 @@ export default function FavoriteItem(props: FavoriteItemProps) { if (props.type === 'spec') { return (
- +
); } diff --git a/client/src/components/Nav/Favorites/FavoritesList.tsx b/client/src/components/Nav/Favorites/FavoritesList.tsx index e156b5e46a..889d72a91a 100644 --- a/client/src/components/Nav/Favorites/FavoritesList.tsx +++ b/client/src/components/Nav/Favorites/FavoritesList.tsx @@ -17,6 +17,7 @@ import { import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import useSelectMention from '~/hooks/Input/useSelectMention'; +import { getSpecAgentAvatarURL } from '~/utils'; import FavoriteItem from './FavoriteItem'; import store from '~/store'; @@ -458,6 +459,7 @@ function FavoritesList({ onSelectSpec={onSelectSpec} onRemoveFocus={handleRemoveFocus} endpointsConfig={endpointsConfig} + agentAvatarURL={getSpecAgentAvatarURL(spec, agentsMap)} /> ); diff --git a/client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx b/client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx index ff79dc4354..ed018c471a 100644 --- a/client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx +++ b/client/src/components/Nav/Favorites/tests/FavoriteItem.spec.tsx @@ -18,7 +18,9 @@ jest.mock('~/hooks', () => ({ jest.mock('~/components/Chat/Menus/Endpoints/components/SpecIcon', () => ({ __esModule: true, - default: () => , + default: ({ agentAvatarURL }: { agentAvatarURL?: string }) => ( + + ), })); jest.mock('~/components/Endpoints/MinimalIcon', () => ({ @@ -108,6 +110,17 @@ describe('FavoriteItem', () => { expect(screen.getByTestId('spec-icon')).toBeInTheDocument(); }); + /** The pinned row must show the same agent avatar as the selector row. */ + it('threads the agent avatar through to SpecIcon', () => { + render( + , + ); + expect(screen.getByTestId('spec-icon')).toHaveAttribute( + 'data-agent-avatar-url', + '/images/agent-avatar.png', + ); + }); + it('has aria-label formatted as "