feat: add groupIcon property to modelSpecs for custom group icons

Added the ability to define icons for custom model spec groups in the UI selector.

Changes:
- Added  property to TModelSpec type and schema in data-provider
- Created GroupIcon component to render URL or built-in endpoint icons
- Updated CustomGroup component to display group icons
- Added documentation and examples in librechat.example.yaml

Usage:

The groupIcon can be:
- A built-in endpoint key (e.g., "openAI", "anthropic", "groq")
- A URL to a custom icon image

Only the first spec in a group needs groupIcon - all specs share the same icon.
This commit is contained in:
odrec 2025-12-03 10:51:18 +01:00
parent 8bdc808074
commit 308347a7ad
4 changed files with 102 additions and 8 deletions

View file

@ -3,13 +3,15 @@ import type { TModelSpec } from 'librechat-data-provider';
import { CustomMenu as Menu } from '../CustomMenu';
import { ModelSpecItem } from './ModelSpecItem';
import { useModelSelectorContext } from '../ModelSelectorContext';
import GroupIcon from './GroupIcon';
interface CustomGroupProps {
groupName: string;
specs: TModelSpec[];
groupIcon?: string;
}
export function CustomGroup({ groupName, specs }: CustomGroupProps) {
export function CustomGroup({ groupName, specs, groupIcon }: CustomGroupProps) {
const { selectedValues } = useModelSelectorContext();
const { modelSpec: selectedSpec } = selectedValues;
@ -25,6 +27,11 @@ export function CustomGroup({ groupName, specs }: CustomGroupProps) {
label={
<div className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm">
<div className="flex items-center gap-2">
{groupIcon && (
<div className="flex-shrink-0">
<GroupIcon iconURL={groupIcon} groupName={groupName} />
</div>
)}
<span className="truncate text-left">{groupName}</span>
</div>
</div>
@ -45,22 +52,27 @@ export function renderCustomGroups(
const endpointValues = new Set(mappedEndpoints.map((ep) => ep.value));
// Group specs by their group field (excluding endpoint-matched groups and ungrouped)
// Also track the groupIcon for each group (first spec with groupIcon wins)
const customGroups = modelSpecs.reduce(
(acc, spec) => {
if (!spec.group || endpointValues.has(spec.group)) {
return acc;
}
if (!acc[spec.group]) {
acc[spec.group] = [];
acc[spec.group] = { specs: [], groupIcon: undefined };
}
acc[spec.group].specs.push(spec);
// Use the first groupIcon found for the group
if (!acc[spec.group].groupIcon && spec.groupIcon) {
acc[spec.group].groupIcon = spec.groupIcon;
}
acc[spec.group].push(spec);
return acc;
},
{} as Record<string, TModelSpec[]>,
{} as Record<string, { specs: TModelSpec[]; groupIcon?: string }>,
);
// Render each custom group
return Object.entries(customGroups).map(([groupName, specs]) => (
<CustomGroup key={groupName} groupName={groupName} specs={specs} />
return Object.entries(customGroups).map(([groupName, { specs, groupIcon }]) => (
<CustomGroup key={groupName} groupName={groupName} specs={specs} groupIcon={groupIcon} />
));
}

View file

@ -0,0 +1,60 @@
import React, { memo, useState } from 'react';
import { AlertCircle } from 'lucide-react';
import type { IconMapProps } from '~/common';
import { icons } from '~/hooks/Endpoint/Icons';
interface GroupIconProps {
iconURL: string;
groupName: string;
}
type IconType = (props: IconMapProps) => React.JSX.Element;
const GroupIcon: React.FC<GroupIconProps> = ({ iconURL, groupName }) => {
const [imageError, setImageError] = useState(false);
const handleImageError = () => {
setImageError(true);
};
// Check if the iconURL is a URL or a built-in icon key
if (!iconURL.includes('http')) {
const Icon: IconType = (icons[iconURL] ?? icons.unknown) as IconType;
return <Icon size={20} context="menu-item" className="icon-md shrink-0 text-text-primary" />;
}
if (imageError || !iconURL) {
const DefaultIcon: IconType = icons.unknown as IconType;
return (
<div className="relative" style={{ width: 20, height: 20, margin: '2px' }}>
<div className="icon-md shrink-0 overflow-hidden rounded-full">
<DefaultIcon context="menu-item" size={20} />
</div>
{imageError && iconURL && (
<div
className="absolute flex items-center justify-center rounded-full bg-red-500"
style={{ width: '14px', height: '14px', top: 0, right: 0 }}
>
<AlertCircle size={10} className="text-white" />
</div>
)}
</div>
);
}
return (
<div
className="icon-md shrink-0 overflow-hidden rounded-full"
style={{ width: 20, height: 20 }}
>
<img
src={iconURL}
alt={groupName}
className="h-full w-full object-cover"
onError={handleImageError}
/>
</div>
);
};
export default memo(GroupIcon);