🖼️ fix: Preserve Model Spec Icon URLs (#13370)

This commit is contained in:
Danny Avila 2026-05-30 09:50:27 -04:00 committed by GitHub
parent ee709c8498
commit 069d867092
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 78 additions and 14 deletions

View file

@ -5,6 +5,7 @@ import type { IconMapProps } from '~/common';
import { getModelSpecIconURL, getIconKey } from '~/utils';
import { URLIcon } from '~/components/Endpoints/URLIcon';
import { icons } from '~/hooks/Endpoint/Icons';
import { isImageURL } from '~/utils/icons';
interface SpecIconProps {
currentSpec: TModelSpec;
@ -18,11 +19,12 @@ const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) =>
const endpoint = currentSpec.preset?.endpoint;
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
const shouldRenderURLIcon = isImageURL(iconURL);
let Icon: IconType;
if (!iconURL.includes('http')) {
if (!shouldRenderURLIcon) {
Icon = (icons[iconURL] ?? icons[iconKey] ?? icons.unknown) as IconType;
} else if (iconURL) {
} else {
return (
<URLIcon
iconURL={iconURL}
@ -32,8 +34,6 @@ const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) =>
endpoint={endpoint || undefined}
/>
);
} else {
Icon = (icons[endpoint ?? ''] ?? icons[iconKey] ?? icons.unknown) as IconType;
}
return (

View file

@ -55,6 +55,28 @@ describe('SpecIcon', () => {
expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-endpoint', '');
});
it('renders same-origin absolute spec icon URLs as images', () => {
const currentSpec = {
name: 'clickhouse-test',
label: 'ClickHouse Test',
iconURL: '/assets/clickhouse-logo.svg',
preset: {
endpoint: EModelEndpoint.anthropic,
},
} as TModelSpec;
render(<SpecIcon currentSpec={currentSpec} endpointsConfig={endpointsConfig} />);
expect(screen.getByTestId('url-icon')).toHaveAttribute(
'data-icon-url',
'/assets/clickhouse-logo.svg',
);
expect(screen.getByTestId('url-icon')).toHaveAttribute(
'data-endpoint',
EModelEndpoint.anthropic,
);
});
it('falls back to the unknown icon when runtime spec data has no icon or preset', () => {
const currentSpec = {
name: 'gemini-test',

View file

@ -5,6 +5,7 @@ import type { TMessageIcon } from '~/common';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { useGetEndpointsQuery } from '~/data-provider';
import { getIconEndpoint } from '~/utils';
import { isImageURL } from '~/utils/icons';
import Icon from '~/components/Endpoints/Icon';
type MessageIconProps = {
@ -64,7 +65,7 @@ const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => {
[endpointsConfig, endpoint],
);
if (iconData?.isCreatedByUser !== true && iconURL != null && iconURL.includes('http')) {
if (iconData?.isCreatedByUser !== true && isImageURL(iconURL)) {
return (
<ConvoIconURL
iconURL={iconURL}

View file

@ -1,5 +1,5 @@
import React from 'react';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { Agent } from 'librechat-data-provider';
import type { TMessageIcon } from '~/common';
@ -62,6 +62,22 @@ describe('MessageIcon render cycles', () => {
expect(iconRenderCount.current).toBe(1);
});
it('renders same-origin absolute model spec icon URLs directly', () => {
render(
<MessageIcon
iconData={{
...baseIconData,
iconURL: '/assets/clickhouse-logo.svg',
}}
/>,
);
expect(screen.getByTestId('convo-icon-url')).toHaveAttribute(
'data-icon-url',
'/assets/clickhouse-logo.svg',
);
});
it('does not re-render when parent re-renders with same field values but new object references', () => {
const agent = makeAgent();
const { rerender } = render(<MessageIcon iconData={baseIconData} agent={agent} />);

View file

@ -4,6 +4,7 @@ import type * as t from 'librechat-data-provider';
import { getIconKey, getEntity, getIconEndpoint } from '~/utils';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { icons } from '~/hooks/Endpoint/Icons';
import { isImageURL } from '~/utils/icons';
export default function ConvoIcon({
conversation,
@ -51,7 +52,7 @@ export default function ConvoIcon({
return (
<>
{iconURL && iconURL.includes('http') ? (
{isImageURL(iconURL) ? (
<ConvoIconURL
iconURL={iconURL}
modelLabel={conversation?.chatGptLabel ?? conversation?.modelLabel ?? ''}

View file

@ -1,7 +1,7 @@
import { memo, useMemo } from 'react';
import type { IconMapProps } from '~/common';
import { URLIcon } from '~/components/Endpoints/URLIcon';
import { icons } from '~/hooks/Endpoint/Icons';
import { isImageURL } from '~/utils/icons';
interface ConvoIconURLProps {
iconURL?: string;
@ -40,10 +40,7 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
context,
}) => {
const Icon = useMemo(() => icons[iconURL] ?? icons.unknown, [iconURL]);
const isURL = useMemo(
() => !!(iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'))),
[iconURL],
);
const isURL = useMemo(() => isImageURL(iconURL), [iconURL]);
if (isURL) {
return (
<URLIcon

View file

@ -8,6 +8,7 @@ import type {
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
import { getIconEndpoint } from '~/utils';
import { isImageURL } from '~/utils/icons';
export default function EndpointIcon({
conversation,
@ -39,7 +40,7 @@ export default function EndpointIcon({
const iconURL = assistantAvatar || convoIconURL;
if (iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'))) {
if (isImageURL(iconURL)) {
return (
<ConvoIconURL
iconURL={iconURL}

View file

@ -5,6 +5,7 @@ import type { TMessageProps } from '~/common';
import MessageEndpointIcon from '../Endpoints/MessageEndpointIcon';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getIconEndpoint, logger } from '~/utils';
import { isImageURL } from '~/utils/icons';
export default function MessageIcon(
props: Pick<TMessageProps, 'message' | 'conversation'> & {
@ -49,7 +50,7 @@ export default function MessageIcon(
agentName,
agentAvatar,
});
if (message?.isCreatedByUser !== true && iconURL && iconURL.includes('http')) {
if (message?.isCreatedByUser !== true && isImageURL(iconURL)) {
return (
<ConvoIconURL
iconURL={iconURL}

View file

@ -0,0 +1,17 @@
import { isImageURL } from '../icons';
describe('isImageURL', () => {
it.each(['https://example.com/icon.png', 'http://example.com/icon.png', '/assets/icon.svg'])(
'accepts image URL %s',
(iconURL) => {
expect(isImageURL(iconURL)).toBe(true);
},
);
it.each(['openAI', 'anthropic', 'assets/icon.svg', '//example.com/icon.png', '', null])(
'rejects non-image URL %s',
(iconURL) => {
expect(isImageURL(iconURL)).toBe(false);
},
);
});

View file

@ -0,0 +1,7 @@
export function isImageURL(iconURL?: string | null): iconURL is string {
if (!iconURL) {
return false;
}
return /^https?:\/\//i.test(iconURL) || (iconURL.startsWith('/') && !iconURL.startsWith('//'));
}

View file

@ -5,6 +5,7 @@ import logger from './logger';
export * from './map';
export * from './json';
export * from './icons';
export * from './email';
export * from './share';
export * from './files';