diff --git a/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx index 6eef7656c3..2e354db08b 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx @@ -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 = ({ 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 ( = ({ currentSpec, endpointsConfig }) => endpoint={endpoint || undefined} /> ); - } else { - Icon = (icons[endpoint ?? ''] ?? icons[iconKey] ?? icons.unknown) as IconType; } return ( 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 2549d8c5e3..e480a4bde7 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 @@ -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(); + + 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', diff --git a/client/src/components/Chat/Messages/MessageIcon.tsx b/client/src/components/Chat/Messages/MessageIcon.tsx index 1c4401c338..0e4d1f4ffc 100644 --- a/client/src/components/Chat/Messages/MessageIcon.tsx +++ b/client/src/components/Chat/Messages/MessageIcon.tsx @@ -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 ( { expect(iconRenderCount.current).toBe(1); }); + it('renders same-origin absolute model spec icon URLs directly', () => { + render( + , + ); + + 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(); diff --git a/client/src/components/Endpoints/ConvoIcon.tsx b/client/src/components/Endpoints/ConvoIcon.tsx index 0ac36924a6..859f3b957b 100644 --- a/client/src/components/Endpoints/ConvoIcon.tsx +++ b/client/src/components/Endpoints/ConvoIcon.tsx @@ -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) ? ( = ({ 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 ( & { @@ -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 ( { + 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); + }, + ); +}); diff --git a/client/src/utils/icons.ts b/client/src/utils/icons.ts new file mode 100644 index 0000000000..a518ba52f3 --- /dev/null +++ b/client/src/utils/icons.ts @@ -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('//')); +} diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index befcef81a1..60d87a3a23 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -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';