diff --git a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx index 3c4cb840a4..7890979a43 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx @@ -2,6 +2,7 @@ import React, { memo, useState } from 'react'; import { AlertCircle } from 'lucide-react'; import type { IconMapProps } from '~/common'; import { getKnownEndpointAsset, hasKnownEndpointIcon } from '~/hooks/Endpoint/UnknownIcon'; +import CustomIcon from '~/components/ui/CustomIcon'; import { icons } from '~/hooks/Endpoint/Icons'; interface GroupIconProps { @@ -59,10 +60,10 @@ const GroupIcon: React.FC = ({ iconURL, groupName }) => { return (
- ); } diff --git a/client/src/components/MCP/MCPServerMenuItem.tsx b/client/src/components/MCP/MCPServerMenuItem.tsx index 7fcb773bb9..95a3ccdf0f 100644 --- a/client/src/components/MCP/MCPServerMenuItem.tsx +++ b/client/src/components/MCP/MCPServerMenuItem.tsx @@ -1,15 +1,16 @@ -import * as Ariakit from '@ariakit/react'; import { Check } from 'lucide-react'; +import * as Ariakit from '@ariakit/react'; import { MCPIcon } from '@librechat/client'; import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; import type { MCPServerStatusIconProps } from './MCPServerStatusIcon'; -import MCPServerStatusIcon from './MCPServerStatusIcon'; import { getStatusColor, getStatusTextKey, shouldShowActionButton, type ConnectionStatusMap, } from './mcpServerUtils'; +import MCPServerStatusIcon from './MCPServerStatusIcon'; +import CustomIcon from '~/components/ui/CustomIcon'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -58,9 +59,9 @@ export default function MCPServerMenuItem({ {/* Server Icon with Status Dot */}
{server.config?.iconPath ? ( - {displayName} ) : ( diff --git a/client/src/components/MCP/StackedMCPIcons.tsx b/client/src/components/MCP/StackedMCPIcons.tsx index fa04928210..e5a02aad39 100644 --- a/client/src/components/MCP/StackedMCPIcons.tsx +++ b/client/src/components/MCP/StackedMCPIcons.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { MCPIcon } from '@librechat/client'; import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; import { getSelectedServerIcons } from './mcpServerUtils'; +import CustomIcon from '~/components/ui/CustomIcon'; import { cn } from '~/utils'; interface StackedMCPIconsProps { @@ -74,10 +75,10 @@ export default function StackedMCPIcons({ style={{ zIndex: icons.length - index }} > {icon.iconPath ? ( - {icon.displayName} ) : ( diff --git a/client/src/components/SidePanel/Agents/MCPIcon.tsx b/client/src/components/SidePanel/Agents/MCPIcon.tsx index c6ca84f806..296c142661 100644 --- a/client/src/components/SidePanel/Agents/MCPIcon.tsx +++ b/client/src/components/SidePanel/Agents/MCPIcon.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { SquirclePlusIcon } from '@librechat/client'; +import CustomIcon from '~/components/ui/CustomIcon'; import { useLocalize } from '~/hooks'; interface MCPIconProps { @@ -8,18 +9,9 @@ interface MCPIconProps { } export default function MCPIcon({ icon, onIconChange }: MCPIconProps) { - const [previewUrl, setPreviewUrl] = useState(''); const fileInputRef = useRef(null); const localize = useLocalize(); - useEffect(() => { - if (icon) { - setPreviewUrl(icon); - } else { - setPreviewUrl(''); - } - }, [icon]); - const handleClick = () => { if (fileInputRef.current) { fileInputRef.current.value = ''; @@ -44,13 +36,11 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) { aria-label={localize('com_ui_upload_icon')} className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-xl border-2 border-dashed focus:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy" > - {previewUrl ? ( - MCP Icon ) : ( @@ -63,7 +53,7 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) { {localize('com_agents_mcp_icon_size')}
{server.config?.iconPath ? ( - ) : (
diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/BasicInfoSection.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/BasicInfoSection.tsx index fd78d0bced..c9a92f778d 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/BasicInfoSection.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/BasicInfoSection.tsx @@ -2,8 +2,8 @@ import { useFormContext } from 'react-hook-form'; import { Input, Label, Textarea } from '@librechat/client'; import type { MCPServerFormData } from '../hooks/useMCPServerForm'; import MCPIcon from '~/components/SidePanel/Agents/MCPIcon'; +import { cn, sanitizeSvg, svgToDataUri } from '~/utils'; import { useLocalize } from '~/hooks'; -import { cn } from '~/utils'; export default function BasicInfoSection() { const localize = useLocalize(); @@ -18,14 +18,25 @@ export default function BasicInfoSection() { const handleIconChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (file) { - const reader = new FileReader(); - reader.onloadend = () => { - const base64String = reader.result as string; - setValue('icon', base64String); - }; - reader.readAsDataURL(file); + if (!file) { + return; } + + const reader = new FileReader(); + const isSvg = file.type === 'image/svg+xml' || /\.svg$/i.test(file.name); + if (isSvg) { + reader.onloadend = () => { + const sanitized = sanitizeSvg(reader.result as string); + setValue('icon', svgToDataUri(sanitized)); + }; + reader.readAsText(file); + return; + } + + reader.onloadend = () => { + setValue('icon', reader.result as string); + }; + reader.readAsDataURL(file); }; return ( diff --git a/client/src/components/ui/CustomIcon.tsx b/client/src/components/ui/CustomIcon.tsx new file mode 100644 index 0000000000..8e18107b63 --- /dev/null +++ b/client/src/components/ui/CustomIcon.tsx @@ -0,0 +1,55 @@ +import type { ReactEventHandler } from 'react'; +import useAdaptiveIcon from '~/hooks/useAdaptiveIcon'; +import { cn } from '~/utils'; + +interface CustomIconProps { + src: string; + alt?: string; + className?: string; + onError?: ReactEventHandler; +} + +/** + * Renders a user-provided custom icon (e.g. an MCP server `iconPath` or a model + * group `groupIcon`). Monochrome SVG glyphs are masked with `currentColor` so + * they follow the active theme, while raster images and multi-color SVG logos + * keep their original colors. The tint color is inherited from the element's + * text color, so set a `text-*` class on `className`. + */ +export default function CustomIcon({ src, alt = '', className, onError }: CustomIconProps) { + const { shouldTint } = useAdaptiveIcon(src); + const decorative = alt === ''; + + if (shouldTint) { + const maskUrl = `url("${src.replace(/"/g, '%22')}")`; + return ( + + ); + } + + return ( + {alt} + ); +} diff --git a/client/src/components/ui/__tests__/CustomIcon.test.tsx b/client/src/components/ui/__tests__/CustomIcon.test.tsx new file mode 100644 index 0000000000..635321e70c --- /dev/null +++ b/client/src/components/ui/__tests__/CustomIcon.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import CustomIcon from '../CustomIcon'; + +describe('CustomIcon', () => { + it('renders a raster image with its source and accessible label', () => { + render(); + + const img = screen.getByRole('img', { name: 'My Server' }); + expect(img.tagName).toBe('IMG'); + expect(img).toHaveAttribute('src', '/assets/logo.png'); + }); + + it('hides decorative raster images from assistive technology', () => { + const { container } = render(); + + const img = container.querySelector('img'); + expect(img).not.toBeNull(); + expect(img).toHaveAttribute('aria-hidden', 'true'); + expect(img).toHaveAttribute('src', '/assets/logo.png'); + }); +}); diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts index 1412cf4aff..35a62fe19b 100644 --- a/client/src/components/ui/index.ts +++ b/client/src/components/ui/index.ts @@ -1,4 +1,5 @@ export { Button } from '@librechat/client'; +export { default as CustomIcon } from './CustomIcon'; export { default as TermsAndConditionsModal } from './TermsAndConditionsModal'; export { default as AdminSettingsDialog } from './AdminSettingsDialog'; export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog'; diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 87c62f1728..4be08f2b6f 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -31,6 +31,7 @@ export { default as useFocusTrap } from './useFocusTrap'; export { default as useFavorites } from './useFavorites'; export { default as useSkillFavorites } from './useSkillFavorites'; export { default as useChatBadges } from './useChatBadges'; +export { default as useAdaptiveIcon } from './useAdaptiveIcon'; export { default as useScrollToRef } from './useScrollToRef'; export { default as useIsActiveItem } from './useIsActiveItem'; export { default as useLocalStorage } from './useLocalStorage'; diff --git a/client/src/hooks/useAdaptiveIcon.ts b/client/src/hooks/useAdaptiveIcon.ts new file mode 100644 index 0000000000..4b46c4a38c --- /dev/null +++ b/client/src/hooks/useAdaptiveIcon.ts @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react'; +import { isSvgIcon, isMonochromeSvg } from '~/utils'; + +/** Resolved monochrome verdicts, keyed by icon source, shared across instances. */ +const monochromeCache = new Map(); +/** In-flight resolutions, so concurrent instances of the same icon fetch once. */ +const inFlight = new Map>(); + +function decodeDataUri(uri: string): string { + const comma = uri.indexOf(','); + if (comma === -1) { + return ''; + } + const meta = uri.slice(0, comma); + const content = uri.slice(comma + 1); + if (/;base64/i.test(meta)) { + return atob(content); + } + return decodeURIComponent(content); +} + +async function loadSvgContent(src: string): Promise { + if (src.startsWith('data:')) { + return decodeDataUri(src); + } + const response = await fetch(src); + if (!response.ok) { + throw new Error(`Failed to load SVG icon (${response.status})`); + } + return response.text(); +} + +function resolveMonochrome(src: string): Promise { + const cached = monochromeCache.get(src); + if (cached !== undefined) { + return Promise.resolve(cached); + } + const existing = inFlight.get(src); + if (existing) { + return existing; + } + const promise = loadSvgContent(src) + .then(isMonochromeSvg) + .catch(() => false) + .then((monochrome) => { + monochromeCache.set(src, monochrome); + inFlight.delete(src); + return monochrome; + }); + inFlight.set(src, promise); + return promise; +} + +/** + * Determines whether a custom icon should be tinted to `currentColor` so it + * adapts to the active theme. Only monochrome SVG glyphs are tinted; raster + * images and multi-color SVG logos keep their original colors. SVG content is + * fetched once and cached; any fetch failure (e.g. CORS) leaves the icon + * untinted. + */ +export default function useAdaptiveIcon(src?: string | null): { shouldTint: boolean } { + const key = isSvgIcon(src) ? src : null; + const [isMonochrome, setIsMonochrome] = useState(() => + key != null ? (monochromeCache.get(key) ?? false) : false, + ); + + useEffect(() => { + if (key == null) { + setIsMonochrome(false); + return; + } + let active = true; + resolveMonochrome(key).then((monochrome) => { + if (active) { + setIsMonochrome(monochrome); + } + }); + return () => { + active = false; + }; + }, [key]); + + return { shouldTint: isMonochrome }; +} diff --git a/client/src/utils/__tests__/icons.test.ts b/client/src/utils/__tests__/icons.test.ts index 490b968f68..212267b2d7 100644 --- a/client/src/utils/__tests__/icons.test.ts +++ b/client/src/utils/__tests__/icons.test.ts @@ -1,4 +1,4 @@ -import { isImageURL } from '../icons'; +import { isImageURL, isSvgIcon } from '../icons'; describe('isImageURL', () => { it.each(['https://example.com/icon.png', 'http://example.com/icon.png', '/assets/icon.svg'])( @@ -15,3 +15,29 @@ describe('isImageURL', () => { }, ); }); + +describe('isSvgIcon', () => { + it.each([ + 'https://example.com/icon.svg', + '/assets/icon.svg', + '/assets/icon.SVG', + 'https://example.com/icon.svg?v=2', + 'https://example.com/icon.svg#hash', + 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', + 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E', + ])('accepts SVG icon %s', (iconURL) => { + expect(isSvgIcon(iconURL)).toBe(true); + }); + + it.each([ + 'https://example.com/icon.png', + '/assets/icon.jpg', + 'data:image/png;base64,abc', + 'https://example.com/svg-logo.png', + '', + null, + undefined, + ])('rejects non-SVG icon %s', (iconURL) => { + expect(isSvgIcon(iconURL)).toBe(false); + }); +}); diff --git a/client/src/utils/__tests__/svg.test.ts b/client/src/utils/__tests__/svg.test.ts new file mode 100644 index 0000000000..5cfa80418a --- /dev/null +++ b/client/src/utils/__tests__/svg.test.ts @@ -0,0 +1,110 @@ +import { isMonochromeSvg, sanitizeSvg } from '../svg'; + +describe('isMonochromeSvg', () => { + describe('monochrome icons (tinted to currentColor)', () => { + it('treats an SVG with no explicit colors as monochrome (default black fill)', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats a black fill as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats shorthand black hex as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats grayscale shades as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats named grayscale colors as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats grayscale rgb() as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('treats grayscale hsl() (zero saturation) as monochrome', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('ignores none/transparent and currentColor', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + + it('handles colors defined inside a style block', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(true); + }); + }); + + describe('multi-color icons (colors preserved)', () => { + it('treats a saturated hex color as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats a mix of saturated colors as multi-color', () => { + const svg = + ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats named chromatic colors as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats saturated rgb() as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats saturated hsl() as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + + it('treats gradient (url reference) fills as multi-color', () => { + const svg = ''; + expect(isMonochromeSvg(svg)).toBe(false); + }); + }); +}); + +describe('sanitizeSvg', () => { + it('strips script tags but keeps drawing elements', () => { + const dirty = ''; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain(' { + const dirty = ''; + const clean = sanitizeSvg(dirty); + expect(clean).not.toContain('onload'); + expect(clean).not.toContain('onclick'); + expect(clean).toContain('circle'); + }); + + it('removes foreignObject and embedded HTML', () => { + const dirty = + ''; + const clean = sanitizeSvg(dirty); + expect(clean.toLowerCase()).not.toContain('foreignobject'); + expect(clean.toLowerCase()).not.toContain('iframe'); + }); +}); diff --git a/client/src/utils/icons.ts b/client/src/utils/icons.ts index a518ba52f3..d28172c461 100644 --- a/client/src/utils/icons.ts +++ b/client/src/utils/icons.ts @@ -5,3 +5,16 @@ export function isImageURL(iconURL?: string | null): iconURL is string { return /^https?:\/\//i.test(iconURL) || (iconURL.startsWith('/') && !iconURL.startsWith('//')); } + +export function isSvgIcon(iconURL?: string | null): iconURL is string { + if (!iconURL) { + return false; + } + + if (/^data:image\/svg\+xml/i.test(iconURL)) { + return true; + } + + const path = iconURL.split(/[?#]/)[0]; + return /\.svg$/i.test(path); +} diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index ea322b047f..7e066ca6e8 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -6,6 +6,7 @@ import logger from './logger'; export * from './map'; export * from './json'; export * from './icons'; +export * from './svg'; export * from './email'; export * from './share'; export * from './files'; diff --git a/client/src/utils/svg.ts b/client/src/utils/svg.ts new file mode 100644 index 0000000000..5d5fb4bd64 --- /dev/null +++ b/client/src/utils/svg.ts @@ -0,0 +1,134 @@ +import DOMPurify from 'dompurify'; + +/** + * Heuristics for deciding whether a custom SVG icon is a monochrome glyph that + * should be tinted to `currentColor` (so it follows the active theme) or a + * multi-color logo that must keep its original colors. + */ + +const COLOR_REGEX = + /(?:fill|stroke|stop-color|flood-color|lighting-color|color)\s*[:=]\s*["']?\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+)/gi; + +/** Color keywords that carry no chromatic information and are ignored. */ +const IGNORABLE_COLORS = new Set(['none', 'transparent', 'inherit', 'currentcolor']); + +/** Named CSS colors that are pure grayscale. Unknown names are treated as chromatic. */ +const GRAY_NAMES = new Set([ + 'black', + 'white', + 'gray', + 'grey', + 'silver', + 'gainsboro', + 'whitesmoke', + 'lightgray', + 'lightgrey', + 'darkgray', + 'darkgrey', + 'dimgray', + 'dimgrey', +]); + +function hexToRgb(hex: string): [number, number, number] | null { + let value = hex.slice(1); + if (value.length === 3 || value.length === 4) { + value = value + .split('') + .map((char) => char + char) + .join(''); + } + if (value.length !== 6 && value.length !== 8) { + return null; + } + const r = parseInt(value.slice(0, 2), 16); + const g = parseInt(value.slice(2, 4), 16); + const b = parseInt(value.slice(4, 6), 16); + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) { + return null; + } + return [r, g, b]; +} + +function functionalToValues(color: string): number[] | null { + const open = color.indexOf('('); + const close = color.indexOf(')'); + if (open === -1 || close === -1) { + return null; + } + const parts = color + .slice(open + 1, close) + .split(/[,/\s]+/) + .map((part) => part.trim()) + .filter(Boolean); + if (parts.length < 3) { + return null; + } + const toNumber = (part: string) => + part.endsWith('%') ? (parseFloat(part) / 100) * 255 : parseFloat(part); + const values = parts.slice(0, 3).map(toNumber); + return values.some(Number.isNaN) ? null : values; +} + +function isGrayscaleColor(color: string): boolean { + if (color.startsWith('#')) { + const rgb = hexToRgb(color); + return rgb ? rgb[0] === rgb[1] && rgb[1] === rgb[2] : false; + } + if (color.startsWith('rgb')) { + const rgb = functionalToValues(color); + if (!rgb) { + return false; + } + const [r, g, b] = rgb.map(Math.round); + return r === g && g === b; + } + if (color.startsWith('hsl')) { + const hsl = functionalToValues(color); + return hsl ? hsl[1] === 0 : false; + } + return GRAY_NAMES.has(color); +} + +function extractColors(svg: string): string[] { + const colors: string[] = []; + COLOR_REGEX.lastIndex = 0; + let match: RegExpExecArray | null = COLOR_REGEX.exec(svg); + while (match !== null) { + const token = match[1].trim().toLowerCase(); + if (token && !IGNORABLE_COLORS.has(token)) { + colors.push(token); + } + match = COLOR_REGEX.exec(svg); + } + return colors; +} + +/** + * Returns true when an SVG only contains grayscale colors (or relies on the + * default black fill / `currentColor`), meaning it can be safely tinted to match + * the theme. Multi-color logos return false so their colors are preserved. + */ +export function isMonochromeSvg(svg: string): boolean { + const colors = extractColors(svg); + if (colors.length === 0) { + return true; + } + return colors.every(isGrayscaleColor); +} + +/** + * Strips scripts, event handlers, and other active content from user-provided + * SVG markup using an allowlist sanitizer, leaving only safe drawing elements. + */ +export function sanitizeSvg(svg: string): string { + return DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true }, + FORBID_TAGS: ['script', 'foreignObject'], + FORBID_ATTR: ['onload', 'onerror', 'onclick', 'onmouseover', 'onmouseenter', 'onfocus'], + }); +} + +/** Encodes SVG markup as a URL-encoded `image/svg+xml` data URI. */ +export function svgToDataUri(svg: string): string { + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +}