🖼️ refactor: Consolidate Provider Icons Into a Single Registry (#15148)
* test: make useIsActiveItem observer assertions deterministic
The two attribute-flip tests mutated inside act() and then raced a 4 second
waitFor against MutationObserver delivery, so they failed once the client
workspace gained enough suites for a worker to stall past that budget.
Wait on actual observer delivery instead. The hook registers its observer on
mount, so it is ahead of the test's in delivery order and has already reacted
by the time the promise resolves. The new helper filters on data-active-item
because React writes data-active onto the same element when it re-renders, and
an unfiltered observer would resolve on that write instead.
This removes the last wall-clock dependence in the file, so the 20 second
jest timeout is no longer needed.
* feat: add canonical ProviderId vocabulary and resolver
* feat: resolve custom endpoint provider identity at config load
* feat: add provider icon registry data
* feat: add ProviderIcon and ProviderAvatar components
* feat: add provider icon resolution hook
* refactor: migrate direct icon lookups to the provider registry
* refactor: migrate composite endpoint icons to the provider registry
* refactor: render message provider icons from the registry
* refactor: remove the duplicated endpoint icon maps
The model selector was the last consumer of the icons map, so it now
resolves art through the provider registry like every other icon call
site. That leaves getIconKey with no callers, and the five icon map
types it depended on with no references, so all of them go too.
* fix: address Codex review findings on provider icons
Move brand tile colors onto theme tokens, accept relative image paths,
pass endpoint config into message icon resolution, keep Cohere padding
on landing only, render configured image URLs in provider-only
consumers, preserve the Gemma label, and publish provider assets with
the shared client package.
* fix: address remaining Codex findings on provider icons
Keep monochrome art white on branded avatar tiles, inline provider
assets as module data URLs so ProviderIcon works outside the SPA, and
recognize api.cohere.ai when resolving custom endpoint brands.
* fix: address the latest Codex review notes
Stop inlining provider logos into the shared bundle, keep agents and
assistants marks on group icons, reject CSS appended to brand
gradients, give brand tokens hex fallbacks for package consumers, and
treat data image URLs as configured artwork.
* fix: honor native provider and theme-controlled avatar contrast
Use an explicit custom-endpoint provider when host branding misses,
keep agents and assistants marks on model specs, and drive branded
avatar foreground from a theme token instead of a raw white class.
* fix: tighten brand validation and inherit SVG fill color
Forward the computed color class into provider SVGs, accept only a
single balanced gradient for brand backgrounds, keep provider
foreground hex-only, recognize relative image fragments, and preserve
percentage sizing in URLIcon fallbacks.
* fix: keep EndpointIcon hook-free and accept protocol-relative icon URLs
useMentions.ts invokes EndpointIcon({...}) as a plain function in seven
places, inside useMemo mappings and a React Query select callback, so the
useProviderIcon call added to it ran a hook outside a render and threw
"Invalid hook call" as soon as the mention list was built. It now uses the
hook-free resolveProviderIcon, and a spec pins the imperative-call contract
those call sites depend on.
isImageURL explicitly rejected protocol-relative URLs, so an endpoint or
model group configured with //cdn.example.com/provider.png fell through to
provider resolution and rendered the generic mark, where the removed
UnknownIcon rendered any nonempty custom iconURL. A leading // followed by
a host is now an image; a bare // or /// still is not.
The ConvoIcon spec's two cohere conversations move to one shared fixture,
since ProviderId.cohere is not an EModelEndpoint and a single-step
assertion to TConversation failed the client type check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv
* fix: annotate themeBrandTokens for isolatedDeclarations
packages/client compiles with isolatedDeclarations, under which
`as const satisfies` is not an explicit type annotation, so the emitted
declaration could not be produced from the initializer alone.
This never surfaced before because the "Type check @librechat/client"
step only runs after "Type check @librechat/api", which was failing on
dev's Agents SDK issue and skipping it.
Annotated as readonly (keyof IThemeBrands)[] and frozen, matching
themeColorTokens directly above it. Both consumers only call .includes()
and .map(), so no literal tuple type is lost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv
* fix: keep nested provider SVGs at their span's size
ProviderIcon sizes component art with an outer span carrying an inline
width/height, then rendered the SVG with cn('h-full w-full', classes).
Because cn is twMerge, a caller's own sizing class won that merge, so the
fraction applied twice: Landing passes size={41} with h-2/3 w-2/3, ConvoIcon
scales to a 27px span, and the SVG then took two thirds of that again, ~18px
where it used to be ~27px.
Only component-backed providers regressed. The asset branch has no wrapping
span, so its fraction still resolves against the 40px container.
Reordering the merge makes the span's size authoritative while leaving every
other caller class in place, including the [color:inherit] that branded
avatars forward. The img branch keeps resolving against its parent, so its
size is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv
* fix: close the image-format and provider-host tables
Two allowlists that the refactor narrowed, fixed as sets rather than one
entry at a time.
isImageURL's extension list had grown by patch four times, each round
restoring one form the old renderer accepted. It now carries every format
browsers actually render, so avif joins apng, bmp, cur, jfif and the jpeg
spellings in a single pass.
The host table had no Azure entry, so an OpenAI-compatible endpoint on
team.openai.azure.com fell through to the generic mark; the custom schema
cannot express provider: azure, so host was its only signal. Both supported
Azure suffixes are added, and enumerating ProviderId against the table
surfaced Google as the same gap, which is added too.
Bedrock, mlx and ollama are the remainder and cannot be host-resolved:
bedrock's hostname is region-scoped under a shared AWS suffix, and the other
two are served from the operator's own machine. That is now recorded next to
the table and pinned by a test, so a provider added later without a host
fails rather than silently rendering the generic mark.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWvn6ezgLmN8D5GDmFVnwv
---------
Co-authored-by: Claude <noreply@anthropic.com>
|
|
@ -103,27 +103,6 @@ export enum IconContext {
|
|||
message = 'message',
|
||||
}
|
||||
|
||||
export type IconMapProps = {
|
||||
className?: string;
|
||||
iconURL?: string;
|
||||
context?: 'landing' | 'menu-item' | 'nav' | 'message';
|
||||
endpoint?: string | null;
|
||||
endpointType?: string;
|
||||
assistantName?: string;
|
||||
agentName?: string;
|
||||
avatar?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type IconComponent = React.ComponentType<IconMapProps>;
|
||||
export type AgentIconComponent = React.ComponentType<AgentIconMapProps>;
|
||||
export type IconComponentTypes = IconComponent | AgentIconComponent;
|
||||
export type IconsRecord = {
|
||||
[key in t.EModelEndpoint | 'unknown' | string]: IconComponentTypes | null | undefined;
|
||||
};
|
||||
|
||||
export type AgentIconMapProps = IconMapProps & { agentName?: string };
|
||||
|
||||
export type NavLink = {
|
||||
title: TranslationKeys;
|
||||
label?: string;
|
||||
|
|
@ -537,6 +516,7 @@ export type IconProps = Pick<t.TMessage, 'isCreatedByUser' | 'model'> &
|
|||
iconClassName?: string;
|
||||
endpoint?: t.EModelEndpoint | string | null;
|
||||
endpointType?: t.EModelEndpoint | null;
|
||||
endpointsConfig?: t.TEndpointsConfig | null;
|
||||
assistantName?: string;
|
||||
agentName?: string;
|
||||
error?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,37 +1,36 @@
|
|||
import React, { memo, useState } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { IconMapProps } from '~/common';
|
||||
import { getKnownEndpointAsset, hasKnownEndpointIcon } from '~/hooks/Endpoint/UnknownIcon';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { ProviderIcon } from '@librechat/client';
|
||||
import { resolveProviderId } from 'librechat-data-provider';
|
||||
import { EntityEndpointMark, isEntityEndpoint } from '~/components/Endpoints/EntityEndpointMark';
|
||||
import { isImageURL } from '~/utils/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 provider = resolveProviderId(iconURL);
|
||||
|
||||
const handleImageError = () => {
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
// Check if the iconURL is a built-in icon key
|
||||
if (iconURL in icons) {
|
||||
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 (isEntityEndpoint(iconURL)) {
|
||||
return (
|
||||
<div className="relative" style={{ width: 20, height: 20, margin: '2px' }} title={groupName}>
|
||||
<EntityEndpointMark endpoint={iconURL} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (imageError) {
|
||||
const DefaultIcon: IconType = icons.unknown as IconType;
|
||||
if (provider || !isImageURL(iconURL) || imageError) {
|
||||
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 && (
|
||||
<ProviderIcon provider={provider} size={20} className="icon-md shrink-0" />
|
||||
{imageError && (
|
||||
<div
|
||||
className="absolute flex items-center justify-center rounded-full bg-surface-destructive"
|
||||
style={{ width: '14px', height: '14px', top: 0, right: 0 }}
|
||||
|
|
@ -43,27 +42,13 @@ const GroupIcon: React.FC<GroupIconProps> = ({ iconURL, groupName }) => {
|
|||
);
|
||||
}
|
||||
|
||||
const resolvedIconURL = getKnownEndpointAsset(iconURL);
|
||||
|
||||
if (!resolvedIconURL && hasKnownEndpointIcon(iconURL)) {
|
||||
const Icon: IconType = icons.unknown as IconType;
|
||||
return (
|
||||
<Icon
|
||||
size={20}
|
||||
endpoint={iconURL}
|
||||
context="menu-item"
|
||||
className="icon-md shrink-0 text-text-primary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="icon-md shrink-0 overflow-hidden rounded-full"
|
||||
style={{ width: 20, height: 20 }}
|
||||
>
|
||||
<img
|
||||
src={resolvedIconURL || iconURL}
|
||||
src={iconURL}
|
||||
alt={groupName}
|
||||
className="h-full w-full object-cover"
|
||||
onError={handleImageError}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import React, { memo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import { ProviderIcon } from '@librechat/client';
|
||||
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import type { IconMapProps } from '~/common';
|
||||
import { getModelSpecIconURL, getIconKey } from '~/utils';
|
||||
import { EntityEndpointMark, isEntityEndpoint } from '~/components/Endpoints/EntityEndpointMark';
|
||||
import { URLIcon } from '~/components/Endpoints/URLIcon';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { isImageURL } from '~/utils/icons';
|
||||
import { useProviderIcon } from '~/hooks/Endpoint';
|
||||
import { getModelSpecIconURL } from '~/utils';
|
||||
|
||||
interface SpecIconProps {
|
||||
currentSpec: TModelSpec;
|
||||
|
|
@ -14,37 +13,34 @@ interface SpecIconProps {
|
|||
agentAvatarURL?: string;
|
||||
}
|
||||
|
||||
type IconType = (props: IconMapProps) => React.JSX.Element;
|
||||
|
||||
const SpecIcon: React.FC<SpecIconProps> = ({ 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 });
|
||||
const shouldRenderURLIcon = isImageURL(iconURL);
|
||||
let Icon: IconType;
|
||||
const { provider, imageURL } = useProviderIcon({ endpoint, endpointsConfig, iconURL });
|
||||
const { provider: fallbackProvider } = useProviderIcon({ endpoint, endpointsConfig });
|
||||
|
||||
if (!shouldRenderURLIcon) {
|
||||
Icon = (icons[iconURL] ?? icons[iconKey] ?? icons.unknown) as IconType;
|
||||
} else {
|
||||
if (imageURL) {
|
||||
return (
|
||||
<URLIcon
|
||||
iconURL={iconURL}
|
||||
iconURL={imageURL}
|
||||
altName={currentSpec.name}
|
||||
containerStyle={{ width: 20, height: 20 }}
|
||||
className="icon-md shrink-0 overflow-hidden rounded-full"
|
||||
endpoint={endpoint || undefined}
|
||||
provider={fallbackProvider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEntityEndpoint(iconURL || endpoint)) {
|
||||
return <EntityEndpointMark endpoint={iconURL || endpoint} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
model={currentSpec.preset?.model}
|
||||
size={20}
|
||||
endpoint={endpoint}
|
||||
context="menu-item"
|
||||
iconURL={endpointIconURL}
|
||||
className="icon-md shrink-0 text-text-primary"
|
||||
className="icon-md shrink-0"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,56 +1,42 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import GroupIcon from '../GroupIcon';
|
||||
|
||||
jest.mock('~/hooks/Endpoint/Icons', () => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
const createIcon =
|
||||
(iconKey: string) =>
|
||||
({ className, endpoint }: { className?: string; endpoint?: string | null }) =>
|
||||
React.createElement('span', {
|
||||
className,
|
||||
'data-testid': 'endpoint-icon',
|
||||
'data-icon-key': iconKey,
|
||||
'data-endpoint': endpoint ?? '',
|
||||
});
|
||||
|
||||
return {
|
||||
icons: {
|
||||
openAI: createIcon('openAI'),
|
||||
unknown: createIcon('unknown'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('GroupIcon', () => {
|
||||
it('renders built-in endpoint icon keys', () => {
|
||||
render(<GroupIcon iconURL="openAI" groupName="OpenAI" />);
|
||||
|
||||
expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'openAI');
|
||||
expect(screen.getByRole('img', { name: 'OpenAI' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the agents mark for an agents group icon', () => {
|
||||
const { container } = render(
|
||||
<GroupIcon iconURL={EModelEndpoint.agents} groupName="My Agents" />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('img', { name: 'Custom' })).not.toBeInTheDocument();
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('My Agents')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resolves known endpoint asset aliases case-insensitively', () => {
|
||||
render(<GroupIcon iconURL="OpenRouter" groupName="OpenRouter" />);
|
||||
|
||||
expect(screen.getByRole('img', { name: 'OpenRouter' })).toHaveAttribute(
|
||||
'src',
|
||||
'assets/openrouter.png',
|
||||
);
|
||||
const src = screen.getByRole('img', { name: 'OpenRouter' }).getAttribute('src');
|
||||
expect(src).toBeTruthy();
|
||||
expect(src).not.toBe('');
|
||||
});
|
||||
|
||||
it('resolves known endpoint asset aliases to shipped file paths', () => {
|
||||
render(<GroupIcon iconURL="Helicone" groupName="Helicone" />);
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Helicone' })).toHaveAttribute(
|
||||
'src',
|
||||
'assets/helicone.svg',
|
||||
);
|
||||
expect(screen.getByRole('img', { name: 'Helicone' })).toHaveAttribute('alt', 'Helicone');
|
||||
});
|
||||
|
||||
it('renders known endpoint aliases backed by components', () => {
|
||||
render(<GroupIcon iconURL="Moonshot" groupName="Moonshot" />);
|
||||
|
||||
expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown');
|
||||
expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-endpoint', 'Moonshot');
|
||||
expect(screen.getByRole('img', { name: 'Moonshot' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders configured image URLs directly', () => {
|
||||
|
|
|
|||
|
|
@ -1,37 +1,16 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { EModelEndpoint, ProviderId } from 'librechat-data-provider';
|
||||
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
|
||||
import SpecIcon from '../SpecIcon';
|
||||
|
||||
jest.mock('~/hooks/Endpoint/Icons', () => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
const createIcon =
|
||||
(iconKey: string) =>
|
||||
({ endpoint, iconURL }: { endpoint?: string | null; iconURL?: string }) =>
|
||||
React.createElement('span', {
|
||||
'data-testid': 'endpoint-icon',
|
||||
'data-icon-key': iconKey,
|
||||
'data-endpoint': endpoint ?? '',
|
||||
'data-icon-url': iconURL ?? '',
|
||||
});
|
||||
|
||||
return {
|
||||
icons: {
|
||||
google: createIcon('google'),
|
||||
openAI: createIcon('openAI'),
|
||||
unknown: createIcon('unknown'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/components/Endpoints/URLIcon', () => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
return {
|
||||
URLIcon: ({ iconURL, endpoint }: { iconURL: string; endpoint?: string }) =>
|
||||
URLIcon: ({ iconURL, provider }: { iconURL: string; provider?: string | null }) =>
|
||||
React.createElement('span', {
|
||||
'data-testid': 'url-icon',
|
||||
'data-icon-url': iconURL,
|
||||
'data-endpoint': endpoint ?? '',
|
||||
'data-provider': provider ?? '',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
|
@ -48,11 +27,7 @@ describe('SpecIcon', () => {
|
|||
|
||||
render(<SpecIcon currentSpec={currentSpec} endpointsConfig={endpointsConfig} />);
|
||||
|
||||
expect(screen.getByTestId('endpoint-icon')).toHaveAttribute(
|
||||
'data-icon-key',
|
||||
EModelEndpoint.google,
|
||||
);
|
||||
expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-endpoint', '');
|
||||
expect(screen.getByRole('img', { name: 'Google' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders same-origin absolute spec icon URLs as images', () => {
|
||||
|
|
@ -71,13 +46,10 @@ describe('SpecIcon', () => {
|
|||
'data-icon-url',
|
||||
'/assets/clickhouse-logo.svg',
|
||||
);
|
||||
expect(screen.getByTestId('url-icon')).toHaveAttribute(
|
||||
'data-endpoint',
|
||||
EModelEndpoint.anthropic,
|
||||
);
|
||||
expect(screen.getByTestId('url-icon')).toHaveAttribute('data-provider', ProviderId.anthropic);
|
||||
});
|
||||
|
||||
it('falls back to the unknown icon when runtime spec data has no icon or preset', () => {
|
||||
it('falls back to the generic icon when runtime spec data has no icon or preset', () => {
|
||||
const currentSpec = {
|
||||
name: 'gemini-test',
|
||||
label: 'Gemini Test',
|
||||
|
|
@ -85,7 +57,7 @@ describe('SpecIcon', () => {
|
|||
|
||||
render(<SpecIcon currentSpec={currentSpec} endpointsConfig={endpointsConfig} />);
|
||||
|
||||
expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown');
|
||||
expect(screen.getByRole('img', { name: 'Custom' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the agent's avatar when the spec defines no icon of its own", () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useRecoilValue } from 'recoil';
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { Close } from '@radix-ui/react-popover';
|
||||
import { Flipper, Flipped } from 'react-flip-toolkit';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import { BookCopy, FileUp, FileX2, Ellipsis } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -25,9 +24,10 @@ import {
|
|||
import type { MenuItemProps } from '@librechat/client';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import type { ChangeEvent, FC } from 'react';
|
||||
import { ResolvedProviderIcon } from '~/components/Endpoints/ResolvedProviderIcon';
|
||||
import { resolveProviderIcon } from '~/hooks/Endpoint';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { getPresetTitle, getIconKey } from '~/utils';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { getPresetTitle } from '~/utils';
|
||||
import { MenuSeparator } from '../UI';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -226,8 +226,10 @@ const PresetItems: FC<{
|
|||
return null;
|
||||
}
|
||||
|
||||
const iconKey = getIconKey({ endpoint: preset.endpoint, endpointsConfig });
|
||||
const Icon = icons[iconKey];
|
||||
const { provider, imageURL } = resolveProviderIcon({
|
||||
endpoint: preset.endpoint,
|
||||
endpointsConfig,
|
||||
});
|
||||
const presetTitle = getPresetTitle(preset);
|
||||
|
||||
return (
|
||||
|
|
@ -243,14 +245,12 @@ const PresetItems: FC<{
|
|||
aria-label={presetTitle}
|
||||
data-testid={`preset-item-${presetId}`}
|
||||
>
|
||||
{Icon != null && (
|
||||
<Icon
|
||||
context="menu-item"
|
||||
iconURL={getEndpointField(endpointsConfig, preset.endpoint, 'iconURL')}
|
||||
className="icon-md shrink-0"
|
||||
endpoint={preset.endpoint}
|
||||
/>
|
||||
)}
|
||||
<ResolvedProviderIcon
|
||||
provider={provider}
|
||||
imageURL={imageURL}
|
||||
size={20}
|
||||
className="icon-md shrink-0"
|
||||
/>
|
||||
<span className="truncate">{presetTitle}</span>
|
||||
</Button>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
|
|
|
|||
|
|
@ -11,11 +11,15 @@ jest.mock('~/hooks', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetEndpointsQuery: () => ({ data: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Endpoint/Icons', () => ({
|
||||
icons: {},
|
||||
useGetEndpointsQuery: () => ({
|
||||
data: {
|
||||
Branded: {
|
||||
type: 'custom',
|
||||
iconURL: 'https://cdn.example.com/x.png',
|
||||
order: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const preset = {
|
||||
|
|
@ -116,3 +120,25 @@ describe('PresetItems clear-all dialog', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PresetItems icons', () => {
|
||||
it('renders a configured endpoint image instead of the generic mark', () => {
|
||||
render(
|
||||
<RecoilRoot>
|
||||
<Popover.Root open={true}>
|
||||
<PresetItems
|
||||
presets={[{ presetId: 'branded', title: 'Branded', endpoint: 'Branded' } as TPreset]}
|
||||
onSetDefaultPreset={jest.fn()}
|
||||
onSelectPreset={jest.fn()}
|
||||
onChangePreset={jest.fn()}
|
||||
onDeletePreset={jest.fn()}
|
||||
clearAllPresets={jest.fn()}
|
||||
onFileSelected={jest.fn()}
|
||||
/>
|
||||
</Popover.Root>
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://cdn.example.com/x.png');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,7 +73,6 @@ const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => {
|
|||
context="message"
|
||||
assistantAvatar={assistantAvatar}
|
||||
agentAvatar={agentAvatar}
|
||||
endpointIconURL={endpointIconURL}
|
||||
assistantName={assistantName}
|
||||
agentName={agentName}
|
||||
/>
|
||||
|
|
@ -85,6 +84,7 @@ const MessageIcon = memo(({ iconData, assistant, agent }: MessageIconProps) => {
|
|||
isCreatedByUser={iconData?.isCreatedByUser ?? false}
|
||||
endpoint={endpoint}
|
||||
iconURL={avatarURL || endpointIconURL}
|
||||
endpointsConfig={endpointsConfig}
|
||||
model={iconData?.model}
|
||||
assistantName={assistantName}
|
||||
agentName={agentName}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,71 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { getEndpointField } from 'librechat-data-provider';
|
||||
import { Feather } from 'lucide-react';
|
||||
import { ProviderId } from 'librechat-data-provider';
|
||||
import { Sparkles, AssistantIcon, ProviderIcon } from '@librechat/client';
|
||||
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 { cn, getEntity, getIconEndpoint } from '~/utils';
|
||||
import { useProviderIcon } from '~/hooks/Endpoint';
|
||||
import { isImageURL } from '~/utils/icons';
|
||||
|
||||
/** Callers frame the mark at two thirds of the round container around it. */
|
||||
const artScale = 2 / 3;
|
||||
|
||||
const entityAvatarClassName =
|
||||
'bg-token-surface-secondary h-full w-full rounded-full object-cover dark:bg-surface-tertiary';
|
||||
|
||||
function AgentAvatar({
|
||||
avatar,
|
||||
agentName,
|
||||
className,
|
||||
size,
|
||||
}: {
|
||||
avatar: string;
|
||||
agentName: string;
|
||||
className: string;
|
||||
size?: number;
|
||||
}) {
|
||||
if (agentName && avatar) {
|
||||
return (
|
||||
<img src={avatar} className={entityAvatarClassName} alt={agentName} width="80" height="80" />
|
||||
);
|
||||
}
|
||||
|
||||
return <Feather className={cn(agentName === '' ? 'icon-2xl' : '', className)} size={size} />;
|
||||
}
|
||||
|
||||
function AssistantAvatar({
|
||||
avatar,
|
||||
assistantName,
|
||||
className,
|
||||
context,
|
||||
size,
|
||||
}: {
|
||||
avatar: string;
|
||||
assistantName: string;
|
||||
className: string;
|
||||
context?: 'message' | 'nav' | 'landing' | 'menu-item';
|
||||
size?: number;
|
||||
}) {
|
||||
if (assistantName && avatar) {
|
||||
return (
|
||||
<img
|
||||
src={avatar}
|
||||
className={entityAvatarClassName}
|
||||
alt={assistantName}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (assistantName) {
|
||||
return <AssistantIcon className={cn('text-text-secondary', className)} size={size} />;
|
||||
}
|
||||
|
||||
return <Sparkles className={cn(context === 'landing' ? 'icon-2xl' : '', className)} />;
|
||||
}
|
||||
|
||||
export default function ConvoIcon({
|
||||
conversation,
|
||||
endpointsConfig,
|
||||
|
|
@ -29,7 +89,7 @@ export default function ConvoIcon({
|
|||
let endpoint = conversation?.endpoint;
|
||||
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
|
||||
|
||||
const { entity, isAgent } = useMemo(
|
||||
const { entity, isAgent, isAssistant } = useMemo(
|
||||
() =>
|
||||
getEntity({
|
||||
endpoint,
|
||||
|
|
@ -46,39 +106,55 @@ export default function ConvoIcon({
|
|||
? (entity as t.Agent | undefined)?.avatar?.filepath
|
||||
: ((entity as t.Assistant | undefined)?.metadata?.avatar as string);
|
||||
|
||||
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
|
||||
const Icon = icons[iconKey] ?? null;
|
||||
const { provider, imageURL } = useProviderIcon({ endpoint, endpointsConfig, iconURL });
|
||||
|
||||
return (
|
||||
<>
|
||||
{isImageURL(iconURL) ? (
|
||||
<ConvoIconURL
|
||||
iconURL={iconURL}
|
||||
modelLabel={conversation?.chatGptLabel ?? conversation?.modelLabel ?? ''}
|
||||
endpointIconURL={endpointIconURL}
|
||||
assistantAvatar={avatar}
|
||||
if (isImageURL(iconURL)) {
|
||||
return (
|
||||
<ConvoIconURL
|
||||
iconURL={iconURL}
|
||||
modelLabel={conversation?.chatGptLabel ?? conversation?.modelLabel ?? ''}
|
||||
provider={provider}
|
||||
assistantAvatar={avatar}
|
||||
assistantName={name}
|
||||
agentAvatar={avatar}
|
||||
agentName={name}
|
||||
context={context}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const renderArt = () => {
|
||||
if (isAgent) {
|
||||
return (
|
||||
<AgentAvatar avatar={avatar ?? ''} agentName={name} className={className} size={size} />
|
||||
);
|
||||
}
|
||||
|
||||
if (isAssistant) {
|
||||
return (
|
||||
<AssistantAvatar
|
||||
avatar={avatar ?? ''}
|
||||
assistantName={name}
|
||||
agentAvatar={avatar}
|
||||
agentName={name}
|
||||
className={className}
|
||||
context={context}
|
||||
size={size}
|
||||
/>
|
||||
) : (
|
||||
<div className={containerClassName}>
|
||||
{endpoint && Icon != null && (
|
||||
<Icon
|
||||
size={size}
|
||||
context={context}
|
||||
endpoint={endpoint}
|
||||
className={className}
|
||||
iconURL={endpointIconURL}
|
||||
assistantName={name}
|
||||
agentName={name}
|
||||
avatar={avatar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (imageURL != null) {
|
||||
return <img src={imageURL} alt={`${endpoint} Icon`} className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
model={conversation?.model}
|
||||
size={size != null ? Math.round(size * artScale) : undefined}
|
||||
className={cn(className, context === 'landing' && provider === ProviderId.cohere && 'p-2')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return <div className={containerClassName}>{endpoint !== '' && renderArt()}</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { memo, useMemo } from 'react';
|
||||
import { ProviderIcon } from '@librechat/client';
|
||||
import type { ProviderId } from 'librechat-data-provider';
|
||||
import { URLIcon } from '~/components/Endpoints/URLIcon';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { isImageURL } from '~/utils/icons';
|
||||
|
||||
interface ConvoIconURLProps {
|
||||
iconURL?: string;
|
||||
modelLabel?: string | null;
|
||||
endpointIconURL?: string;
|
||||
provider?: ProviderId | null;
|
||||
assistantName?: string;
|
||||
agentName?: string;
|
||||
context?: 'landing' | 'menu-item' | 'nav' | 'message';
|
||||
|
|
@ -32,14 +33,9 @@ const styleImageMap = {
|
|||
const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
|
||||
iconURL = '',
|
||||
modelLabel = '',
|
||||
endpointIconURL,
|
||||
assistantAvatar,
|
||||
assistantName,
|
||||
agentAvatar,
|
||||
agentName,
|
||||
provider,
|
||||
context,
|
||||
}) => {
|
||||
const Icon = useMemo(() => icons[iconURL] ?? icons.unknown, [iconURL]);
|
||||
const isURL = useMemo(() => isImageURL(iconURL), [iconURL]);
|
||||
if (isURL) {
|
||||
return (
|
||||
|
|
@ -55,17 +51,7 @@ const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
|
|||
|
||||
return (
|
||||
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-surface-primary text-text-primary">
|
||||
{Icon && (
|
||||
<Icon
|
||||
size={41}
|
||||
context={context}
|
||||
className="h-2/3 w-2/3"
|
||||
agentName={agentName}
|
||||
iconURL={endpointIconURL}
|
||||
assistantName={assistantName}
|
||||
avatar={assistantAvatar || agentAvatar}
|
||||
/>
|
||||
)}
|
||||
<ProviderIcon provider={provider} size={41} className="h-2/3 w-2/3" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { getEndpointField, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
getEndpointField,
|
||||
isAssistantsEndpoint,
|
||||
isAgentsEndpoint,
|
||||
ProviderId,
|
||||
} from 'librechat-data-provider';
|
||||
import type {
|
||||
TPreset,
|
||||
TConversation,
|
||||
|
|
@ -6,9 +11,10 @@ import type {
|
|||
TAssistantsMap,
|
||||
TEndpointsConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import { getAgentAvatarUrl, getIconEndpoint, cn } from '~/utils';
|
||||
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
|
||||
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
|
||||
import { getAgentAvatarUrl, getIconEndpoint } from '~/utils';
|
||||
import { resolveProviderIcon } from '~/hooks/Endpoint';
|
||||
import { isImageURL } from '~/utils/icons';
|
||||
|
||||
const emptyEndpointsConfig = {} as TEndpointsConfig;
|
||||
|
|
@ -36,8 +42,8 @@ export default function EndpointIcon({
|
|||
let endpoint = originalEndpoint;
|
||||
endpoint = getIconEndpoint({ endpointsConfig, iconURL: convoIconURL, endpoint });
|
||||
|
||||
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
||||
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||
const { provider } = resolveProviderIcon({ endpoint, endpointsConfig });
|
||||
|
||||
const agent = isAgentsEndpoint(endpoint) ? agentsMap?.[conversation?.agent_id ?? ''] : null;
|
||||
const assistant = isAssistantsEndpoint(endpoint)
|
||||
|
|
@ -59,28 +65,28 @@ export default function EndpointIcon({
|
|||
<ConvoIconURL
|
||||
iconURL={iconURL}
|
||||
modelLabel={entityName || conversation?.chatGptLabel || conversation?.modelLabel || ''}
|
||||
provider={provider}
|
||||
context={context}
|
||||
endpointIconURL={endpointIconURL}
|
||||
assistantAvatar={assistantAvatar}
|
||||
assistantName={assistantName ?? ''}
|
||||
agentAvatar={agentAvatar}
|
||||
agentName={agentName}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<MinimalIcon
|
||||
iconURL={endpointIconURL}
|
||||
endpoint={endpoint}
|
||||
endpointType={endpointType}
|
||||
model={conversation?.model}
|
||||
error={false}
|
||||
className={className}
|
||||
size={size}
|
||||
isCreatedByUser={false}
|
||||
chatGptLabel={undefined}
|
||||
modelLabel={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MinimalIcon
|
||||
iconURL={endpointIconURL}
|
||||
endpoint={endpoint}
|
||||
endpointsConfig={endpointsConfig}
|
||||
model={conversation?.model}
|
||||
error={false}
|
||||
className={cn(className, context === 'landing' && provider === ProviderId.cohere && 'p-2')}
|
||||
size={size}
|
||||
isCreatedByUser={false}
|
||||
chatGptLabel={undefined}
|
||||
modelLabel={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
29
client/src/components/Endpoints/EntityEndpointMark.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Feather } from 'lucide-react';
|
||||
import { Sparkles } from '@librechat/client';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
export function isEntityEndpoint(endpoint?: string | null): boolean {
|
||||
return (
|
||||
endpoint === EModelEndpoint.agents ||
|
||||
endpoint === EModelEndpoint.assistants ||
|
||||
endpoint === EModelEndpoint.azureAssistants
|
||||
);
|
||||
}
|
||||
|
||||
export function EntityEndpointMark({
|
||||
endpoint,
|
||||
className = 'icon-md shrink-0',
|
||||
}: {
|
||||
endpoint?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
if (endpoint === EModelEndpoint.agents) {
|
||||
return <Feather className={className} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.assistants || endpoint === EModelEndpoint.azureAssistants) {
|
||||
return <Sparkles className={className} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { EModelEndpoint, ProviderId } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig } from 'librechat-data-provider';
|
||||
import MessageEndpointIcon from './MessageEndpointIcon';
|
||||
|
||||
describe('MessageEndpointIcon', () => {
|
||||
|
|
@ -25,7 +26,117 @@ describe('MessageEndpointIcon', () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstElementChild).toHaveClass('text-white');
|
||||
expect(container.firstElementChild).toHaveStyle({
|
||||
color: 'var(--provider-foreground, #ffffff)',
|
||||
});
|
||||
expect(container.firstElementChild).not.toHaveClass('text-text-primary');
|
||||
});
|
||||
|
||||
it('renders the Gemini mark for a Gemini model', () => {
|
||||
render(
|
||||
<MessageEndpointIcon
|
||||
endpoint={EModelEndpoint.google}
|
||||
model="gemini-2.5-pro"
|
||||
size={30}
|
||||
isCreatedByUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Gemini')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Google mark for a non-Gemini Google model', () => {
|
||||
render(
|
||||
<MessageEndpointIcon
|
||||
endpoint={EModelEndpoint.google}
|
||||
model="text-bison"
|
||||
size={30}
|
||||
isCreatedByUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Google')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('paints the modern OpenAI tile black', () => {
|
||||
const { container } = render(
|
||||
<MessageEndpointIcon
|
||||
endpoint={EModelEndpoint.openAI}
|
||||
model="gpt-5.6"
|
||||
size={30}
|
||||
isCreatedByUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[title="OpenAI"]')).toHaveStyle({
|
||||
background: 'var(--provider-openai-reasoning, #000000)',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the tile when the message errored', () => {
|
||||
const { container } = render(
|
||||
<MessageEndpointIcon
|
||||
endpoint={EModelEndpoint.openAI}
|
||||
model="gpt-4"
|
||||
size={30}
|
||||
error={true}
|
||||
isCreatedByUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const badge = container.querySelector('[title="OpenAI"] > .bg-status-error');
|
||||
expect(badge).toHaveTextContent('!');
|
||||
});
|
||||
|
||||
it('keeps a configured endpoint image instead of the generic mark', () => {
|
||||
render(
|
||||
<MessageEndpointIcon
|
||||
endpoint="My Gateway"
|
||||
iconURL="https://cdn.example.com/logo.png"
|
||||
model="some-model"
|
||||
size={30}
|
||||
isCreatedByUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText('My Gateway Icon')).toHaveAttribute(
|
||||
'src',
|
||||
'https://cdn.example.com/logo.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the server providerId when the message has no iconURL', () => {
|
||||
const endpointsConfig = {
|
||||
'My OpenRouter': {
|
||||
type: EModelEndpoint.custom,
|
||||
providerId: ProviderId.openrouter,
|
||||
order: 0,
|
||||
},
|
||||
} as TEndpointsConfig;
|
||||
|
||||
render(
|
||||
<MessageEndpointIcon
|
||||
endpoint="My OpenRouter"
|
||||
endpointsConfig={endpointsConfig}
|
||||
model="some-model"
|
||||
size={30}
|
||||
isCreatedByUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('img', { name: 'OpenRouter' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels a Gemma model as Gemma', () => {
|
||||
render(
|
||||
<MessageEndpointIcon
|
||||
endpoint={EModelEndpoint.google}
|
||||
model="gemma-3-27b"
|
||||
size={30}
|
||||
isCreatedByUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Gemma')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,64 +1,23 @@
|
|||
import { memo } from 'react';
|
||||
import { Feather } from 'lucide-react';
|
||||
import { EModelEndpoint, isAssistantsEndpoint, alternateName } from 'librechat-data-provider';
|
||||
import {
|
||||
Plugin,
|
||||
GPTIcon,
|
||||
PaLMIcon,
|
||||
CodeyIcon,
|
||||
GeminiIcon,
|
||||
BedrockIcon,
|
||||
AssistantIcon,
|
||||
AnthropicIcon,
|
||||
TooltipAnchor,
|
||||
AzureMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
} from '@librechat/client';
|
||||
import UnknownIcon from '~/hooks/Endpoint/UnknownIcon';
|
||||
import { IconProps } from '~/common';
|
||||
import { EModelEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||
import { AssistantIcon, TooltipAnchor, ProviderAvatar } from '@librechat/client';
|
||||
import type { IconProps } from '~/common';
|
||||
import { useProviderIcon } from '~/hooks/Endpoint';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type EndpointIcon = {
|
||||
icon: React.ReactNode | React.JSX.Element;
|
||||
bg?: string;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
function getOpenAIColor(_model: string | null | undefined) {
|
||||
const model = _model?.toLowerCase() ?? '';
|
||||
if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9](?:\.\d+)?\b/i.test(model))) {
|
||||
return '#000000';
|
||||
}
|
||||
return model.includes('gpt-4') ? '#AB68FF' : '#19C37D';
|
||||
}
|
||||
|
||||
function getGoogleIcon(model: string | null | undefined, size: number) {
|
||||
if (model?.toLowerCase().includes('code') === true) {
|
||||
return <CodeyIcon size={size * 0.75} />;
|
||||
} else if (/gemini|learnlm|gemma/.test(model?.toLowerCase() ?? '')) {
|
||||
return <GeminiIcon size={size * 0.7} />;
|
||||
} else {
|
||||
return <PaLMIcon size={size * 0.7} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getGoogleModelName(model: string | null | undefined) {
|
||||
if (model?.toLowerCase().includes('code') === true) {
|
||||
return 'Codey';
|
||||
} else if (
|
||||
model?.toLowerCase().includes('gemini') === true ||
|
||||
model?.toLowerCase().includes('learnlm') === true
|
||||
) {
|
||||
return 'Gemini';
|
||||
} else if (model?.toLowerCase().includes('gemma') === true) {
|
||||
return 'Gemma';
|
||||
} else {
|
||||
return 'PaLM2';
|
||||
}
|
||||
}
|
||||
|
||||
const MessageEndpointIcon: React.FC<IconProps> = (props) => {
|
||||
const { error, iconURL = '', endpoint, size = 30, model = '', assistantName, agentName } = props;
|
||||
const {
|
||||
error,
|
||||
iconURL = '',
|
||||
endpoint,
|
||||
size = 30,
|
||||
model = '',
|
||||
assistantName,
|
||||
agentName,
|
||||
endpointsConfig,
|
||||
} = props;
|
||||
const { provider, imageURL } = useProviderIcon({ endpoint, iconURL, endpointsConfig });
|
||||
|
||||
const assistantsIcon = {
|
||||
icon: iconURL ? (
|
||||
|
|
@ -118,94 +77,47 @@ const MessageEndpointIcon: React.FC<IconProps> = (props) => {
|
|||
name: endpoint,
|
||||
};
|
||||
|
||||
const endpointIcons: {
|
||||
[key: string]: EndpointIcon | undefined;
|
||||
} = {
|
||||
[EModelEndpoint.assistants]: assistantsIcon,
|
||||
[EModelEndpoint.agents]: agentsIcon,
|
||||
[EModelEndpoint.azureAssistants]: assistantsIcon,
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
icon: <AzureMinimalIcon size={size * 0.5555555555555556} />,
|
||||
bg: 'linear-gradient(0.375turn, #61bde2, #4389d0)',
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.openAI]: {
|
||||
icon: <GPTIcon size={size * 0.5555555555555556} />,
|
||||
bg: getOpenAIColor(model),
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.google]: {
|
||||
icon: getGoogleIcon(model, size),
|
||||
name: getGoogleModelName(model),
|
||||
},
|
||||
[EModelEndpoint.anthropic]: {
|
||||
icon: <AnthropicIcon size={size * 0.5555555555555556} />,
|
||||
bg: '#d09a74',
|
||||
name: 'Claude',
|
||||
},
|
||||
[EModelEndpoint.bedrock]: {
|
||||
icon: <BedrockIcon className="icon-xl text-white" />,
|
||||
bg: '#268672',
|
||||
name: alternateName[EModelEndpoint.bedrock],
|
||||
},
|
||||
[EModelEndpoint.custom]: {
|
||||
icon: <CustomMinimalIcon size={size * 0.7} />,
|
||||
name: 'Custom',
|
||||
},
|
||||
null: { icon: <GPTIcon size={size * 0.7} />, bg: 'grey', name: 'N/A' },
|
||||
default: {
|
||||
icon: (
|
||||
<div className="h-6 w-6">
|
||||
<div className="overflow-hidden rounded-full">
|
||||
<UnknownIcon
|
||||
iconURL={iconURL}
|
||||
endpoint={endpoint ?? ''}
|
||||
className="h-full w-full object-contain"
|
||||
context="message"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
name: endpoint,
|
||||
},
|
||||
};
|
||||
|
||||
let { icon, bg, name } =
|
||||
endpoint != null && endpoint && endpointIcons[endpoint]
|
||||
? (endpointIcons[endpoint] ?? {})
|
||||
: (endpointIcons.default as EndpointIcon);
|
||||
|
||||
if (iconURL && endpointIcons[iconURL]) {
|
||||
({ icon, bg, name } = endpointIcons[iconURL]);
|
||||
}
|
||||
const errorBadge = error === true && (
|
||||
<span className="absolute right-0 top-[20px] -mr-2 flex h-3 w-3 items-center justify-center rounded-full border border-white bg-status-error text-[10px] text-white">
|
||||
!
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isAssistantsEndpoint(endpoint)) {
|
||||
return icon;
|
||||
return assistantsIcon.icon;
|
||||
}
|
||||
|
||||
const hasBackground = typeof bg === 'string' && bg.length > 0;
|
||||
if (endpoint === EModelEndpoint.agents) {
|
||||
return agentsIcon.icon;
|
||||
}
|
||||
|
||||
if (imageURL != null) {
|
||||
return (
|
||||
<div
|
||||
title={endpoint ?? ''}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-text-primary',
|
||||
props.className ?? '',
|
||||
)}
|
||||
>
|
||||
<div className="h-6 w-6">
|
||||
<div className="overflow-hidden rounded-full">
|
||||
<img className="h-full w-full object-contain" src={imageURL} alt={`${endpoint} Icon`} />
|
||||
</div>
|
||||
</div>
|
||||
{errorBadge}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
title={name ?? ''}
|
||||
style={{
|
||||
background: bg != null ? bg || 'transparent' : 'transparent',
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex h-9 w-9 items-center justify-center rounded-sm p-1',
|
||||
hasBackground ? 'text-white' : 'text-text-primary',
|
||||
props.className ?? '',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{error === true && (
|
||||
<span className="absolute right-0 top-[20px] -mr-2 flex h-3 w-3 items-center justify-center rounded-full border border-white bg-status-error text-[10px] text-white">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ProviderAvatar provider={provider} model={model} size={size} className={props.className}>
|
||||
{errorBadge}
|
||||
</ProviderAvatar>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +1,28 @@
|
|||
import { Feather } from 'lucide-react';
|
||||
import { EModelEndpoint, alternateName } from 'librechat-data-provider';
|
||||
import {
|
||||
Sparkles,
|
||||
BedrockIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
OpenAIMinimalIcon,
|
||||
GoogleMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
} from '@librechat/client';
|
||||
import UnknownIcon from '~/hooks/Endpoint/UnknownIcon';
|
||||
import { IconProps } from '~/common';
|
||||
import { Sparkles, ProviderIcon, getProviderIconDef } from '@librechat/client';
|
||||
import type { IconProps } from '~/common';
|
||||
import { useProviderIcon } from '~/hooks/Endpoint';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/** The art stays at `icon-sm` and never outgrows the wrapper it sits in. */
|
||||
const maxArtSize = 16;
|
||||
|
||||
const MinimalIcon: React.FC<IconProps> = (props) => {
|
||||
const { size = 30, iconURL = '', iconClassName, error } = props;
|
||||
const {
|
||||
size = 30,
|
||||
iconURL = '',
|
||||
iconClassName,
|
||||
error,
|
||||
model,
|
||||
modelLabel,
|
||||
chatGptLabel,
|
||||
endpointsConfig,
|
||||
} = props;
|
||||
const endpoint = typeof props.endpoint === 'string' ? props.endpoint : '';
|
||||
const { provider, imageURL } = useProviderIcon({ endpoint, iconURL, endpointsConfig });
|
||||
|
||||
let endpoint = 'default'; // Default value for endpoint
|
||||
|
||||
if (typeof props.endpoint === 'string') {
|
||||
endpoint = props.endpoint;
|
||||
}
|
||||
|
||||
const endpointIcons = {
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
icon: <AzureMinimalIcon className={iconClassName} />,
|
||||
name: props.chatGptLabel ?? 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.openAI]: {
|
||||
icon: <OpenAIMinimalIcon className={iconClassName} />,
|
||||
name: props.chatGptLabel ?? 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.google]: { icon: <GoogleMinimalIcon />, name: props.modelLabel ?? 'Google' },
|
||||
[EModelEndpoint.anthropic]: {
|
||||
icon: <AnthropicIcon className="icon-md shrink-0 dark:text-white" />,
|
||||
name: props.modelLabel ?? 'Claude',
|
||||
},
|
||||
[EModelEndpoint.custom]: {
|
||||
icon: <CustomMinimalIcon />,
|
||||
name: 'Custom',
|
||||
},
|
||||
[EModelEndpoint.assistants]: { icon: <Sparkles className="icon-sm" />, name: 'Assistant' },
|
||||
[EModelEndpoint.azureAssistants]: { icon: <Sparkles className="icon-sm" />, name: 'Assistant' },
|
||||
[EModelEndpoint.agents]: {
|
||||
icon: <Feather className="icon-sm" aria-hidden="true" />,
|
||||
name: props.modelLabel ?? alternateName[EModelEndpoint.agents],
|
||||
},
|
||||
[EModelEndpoint.bedrock]: {
|
||||
icon: <BedrockIcon className="icon-xl text-text-primary" />,
|
||||
name: props.modelLabel ?? alternateName[EModelEndpoint.bedrock],
|
||||
},
|
||||
default: {
|
||||
icon: <UnknownIcon iconURL={iconURL} endpoint={endpoint} className="icon-sm" context="nav" />,
|
||||
name: endpoint,
|
||||
},
|
||||
};
|
||||
|
||||
let { icon, name } = endpointIcons[endpoint] ?? endpointIcons.default;
|
||||
if (iconURL && endpointIcons[iconURL] != null) {
|
||||
({ icon, name } = endpointIcons[iconURL]);
|
||||
}
|
||||
|
||||
return (
|
||||
const renderWrapper = (icon: React.ReactNode, name: string) => (
|
||||
<div
|
||||
data-testid="convo-icon"
|
||||
title={name}
|
||||
|
|
@ -83,6 +44,37 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (endpoint === EModelEndpoint.agents) {
|
||||
return renderWrapper(
|
||||
<Feather className="icon-sm" aria-hidden="true" />,
|
||||
modelLabel ?? alternateName[EModelEndpoint.agents],
|
||||
);
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.assistants || endpoint === EModelEndpoint.azureAssistants) {
|
||||
return renderWrapper(<Sparkles className="icon-sm" />, 'Assistant');
|
||||
}
|
||||
|
||||
const def = getProviderIconDef(provider, model);
|
||||
const name = modelLabel ?? chatGptLabel ?? (provider != null ? def.label : endpoint || def.label);
|
||||
|
||||
if (imageURL != null) {
|
||||
return renderWrapper(
|
||||
<img className={cn('icon-sm', iconClassName)} src={imageURL} alt={`${endpoint} Icon`} />,
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
return renderWrapper(
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
model={model}
|
||||
size={Math.min(size, maxArtSize)}
|
||||
className={iconClassName}
|
||||
/>,
|
||||
name,
|
||||
);
|
||||
};
|
||||
|
||||
export default MinimalIcon;
|
||||
|
|
|
|||
33
client/src/components/Endpoints/ResolvedProviderIcon.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { ProviderIcon } from '@librechat/client';
|
||||
import type { ProviderId } from 'librechat-data-provider';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export function ResolvedProviderIcon({
|
||||
provider,
|
||||
imageURL,
|
||||
size = 20,
|
||||
className,
|
||||
model,
|
||||
alt = '',
|
||||
}: {
|
||||
provider?: ProviderId | null;
|
||||
imageURL?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
model?: string | null;
|
||||
alt?: string;
|
||||
}) {
|
||||
if (imageURL) {
|
||||
return (
|
||||
<img
|
||||
src={imageURL}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('object-contain', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProviderIcon provider={provider} model={model} size={size} className={className} />;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { memo, useState } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { ProviderIcon } from '@librechat/client';
|
||||
import type { ProviderId } from 'librechat-data-provider';
|
||||
|
||||
export const URLIcon = memo(
|
||||
({
|
||||
|
|
@ -9,14 +10,14 @@ export const URLIcon = memo(
|
|||
containerStyle = { width: 20, height: 20 },
|
||||
imageStyle = { width: '100%', height: '100%' },
|
||||
className = 'icon-md mr-1 shrink-0 overflow-hidden rounded-full',
|
||||
endpoint,
|
||||
provider,
|
||||
}: {
|
||||
iconURL: string;
|
||||
altName?: string | null;
|
||||
className?: string;
|
||||
containerStyle?: React.CSSProperties;
|
||||
imageStyle?: React.CSSProperties;
|
||||
endpoint?: string;
|
||||
provider?: ProviderId | null;
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
|
|
@ -24,14 +25,13 @@ export const URLIcon = memo(
|
|||
setImageError(true);
|
||||
};
|
||||
|
||||
const DefaultIcon: React.ElementType =
|
||||
endpoint && icons[endpoint] ? icons[endpoint]! : icons.unknown!;
|
||||
|
||||
if (imageError || !iconURL) {
|
||||
const numericSize =
|
||||
typeof containerStyle.width === 'number' ? containerStyle.width : undefined;
|
||||
return (
|
||||
<div className="relative" style={{ ...containerStyle, margin: '2px' }}>
|
||||
<div className={className}>
|
||||
<DefaultIcon endpoint={endpoint} context="menu-item" size={containerStyle.width} />
|
||||
<ProviderIcon provider={provider} size={numericSize} className="h-full w-full" />
|
||||
</div>
|
||||
{imageError && iconURL && (
|
||||
<div
|
||||
|
|
|
|||
111
client/src/components/Endpoints/__tests__/ConvoIcon.test.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { EModelEndpoint, ProviderId } from 'librechat-data-provider';
|
||||
import type {
|
||||
Agent,
|
||||
Assistant,
|
||||
TConversation,
|
||||
TAgentsMap,
|
||||
TAssistantsMap,
|
||||
TEndpointsConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import ConvoIcon from '../ConvoIcon';
|
||||
|
||||
const endpointsConfig = {
|
||||
[EModelEndpoint.agents]: { order: 0 },
|
||||
[EModelEndpoint.assistants]: { order: 1 },
|
||||
[EModelEndpoint.anthropic]: { order: 2 },
|
||||
[ProviderId.cohere]: { order: 3 },
|
||||
} as TEndpointsConfig;
|
||||
|
||||
const agent = {
|
||||
id: 'agent_123',
|
||||
name: 'Research Agent',
|
||||
avatar: { filepath: '/images/agents/agent_123/avatar.png', source: 'local' },
|
||||
} as Agent;
|
||||
|
||||
const agentsMap = { [agent.id]: agent } as TAgentsMap;
|
||||
|
||||
const assistant = {
|
||||
id: 'asst_123',
|
||||
name: 'Support Assistant',
|
||||
metadata: { avatar: '/images/assistants/asst_123/avatar.png' },
|
||||
} as unknown as Assistant;
|
||||
|
||||
const assistantMap = {
|
||||
[EModelEndpoint.assistants]: { [assistant.id]: assistant },
|
||||
} as TAssistantsMap;
|
||||
|
||||
const cohereConversation = { endpoint: ProviderId.cohere } as unknown as TConversation;
|
||||
|
||||
const renderIcon = (conversation: TConversation) =>
|
||||
render(
|
||||
<ConvoIcon
|
||||
conversation={conversation}
|
||||
endpointsConfig={endpointsConfig}
|
||||
assistantMap={assistantMap}
|
||||
agentsMap={agentsMap}
|
||||
className="h-2/3 w-2/3 text-text-primary"
|
||||
context="landing"
|
||||
size={41}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('ConvoIcon', () => {
|
||||
it('renders the provider mark for a first-class endpoint', () => {
|
||||
renderIcon({ endpoint: EModelEndpoint.anthropic } as TConversation);
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Anthropic' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the agent avatar rather than provider art', () => {
|
||||
renderIcon({
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: agent.id,
|
||||
} as TConversation);
|
||||
|
||||
expect(screen.getByAltText('Research Agent')).toHaveAttribute(
|
||||
'src',
|
||||
'/images/agents/agent_123/avatar.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the agent mark when the agent has no avatar', () => {
|
||||
renderIcon({
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: 'missing_agent',
|
||||
} as TConversation);
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the assistant avatar rather than provider art', () => {
|
||||
renderIcon({
|
||||
endpoint: EModelEndpoint.assistants,
|
||||
assistant_id: assistant.id,
|
||||
} as TConversation);
|
||||
|
||||
expect(screen.getByAltText('Support Assistant')).toHaveAttribute(
|
||||
'src',
|
||||
'/images/assistants/asst_123/avatar.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Cohere landing padding off other contexts and on landing', () => {
|
||||
const { container: landing } = renderIcon(cohereConversation);
|
||||
expect(landing.querySelector('img')).toHaveClass('p-2');
|
||||
|
||||
const { container: nav } = render(
|
||||
<ConvoIcon
|
||||
conversation={cohereConversation}
|
||||
endpointsConfig={endpointsConfig}
|
||||
assistantMap={assistantMap}
|
||||
agentsMap={agentsMap}
|
||||
context="nav"
|
||||
size={20}
|
||||
/>,
|
||||
);
|
||||
expect(nav.querySelector('img')).not.toHaveClass('p-2');
|
||||
});
|
||||
});
|
||||
|
|
@ -92,4 +92,22 @@ describe('EndpointIcon', () => {
|
|||
EModelEndpoint.google,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves provider art when invoked as a plain function, as the mention list does', () => {
|
||||
const conversation = { endpoint: EModelEndpoint.google } as TConversation;
|
||||
|
||||
const element = EndpointIcon({
|
||||
conversation,
|
||||
endpointsConfig,
|
||||
context: 'menu-item',
|
||||
size: 20,
|
||||
});
|
||||
|
||||
render(element);
|
||||
|
||||
expect(screen.getByTestId('minimal-icon')).toHaveAttribute(
|
||||
'data-endpoint',
|
||||
EModelEndpoint.google,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { EModelEndpoint, ProviderId } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig } from 'librechat-data-provider';
|
||||
import MinimalIcon from '../MinimalIcon';
|
||||
|
||||
describe('MinimalIcon', () => {
|
||||
it('renders the provider mark for a first-class endpoint', () => {
|
||||
render(<MinimalIcon endpoint={EModelEndpoint.openAI} isCreatedByUser={false} />);
|
||||
|
||||
expect(screen.getByRole('img', { name: 'OpenAI', hidden: true })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('convo-icon')).toHaveAttribute('title', 'OpenAI');
|
||||
});
|
||||
it('resolves a custom endpoint by name', () => {
|
||||
render(<MinimalIcon endpoint="Ollama" isCreatedByUser={false} />);
|
||||
|
||||
const src = screen.getByRole('img', { name: 'Ollama', hidden: true }).getAttribute('src');
|
||||
expect(src).toBeTruthy();
|
||||
expect(src).not.toBe('');
|
||||
});
|
||||
|
||||
it('keeps the agent art out of the provider registry', () => {
|
||||
render(<MinimalIcon endpoint={EModelEndpoint.agents} isCreatedByUser={false} />);
|
||||
|
||||
expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('convo-icon')).toHaveAttribute('title', 'My Agents');
|
||||
});
|
||||
|
||||
it('keeps the assistant art out of the provider registry', () => {
|
||||
render(<MinimalIcon endpoint={EModelEndpoint.assistants} isCreatedByUser={false} />);
|
||||
|
||||
expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('convo-icon')).toHaveAttribute('title', 'Assistant');
|
||||
});
|
||||
|
||||
it('renders a configured image iconURL instead of provider art', () => {
|
||||
render(
|
||||
<MinimalIcon
|
||||
endpoint="Internal Gateway"
|
||||
iconURL="https://cdn.example.com/logo.png"
|
||||
isCreatedByUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('img', { hidden: true })).toHaveAttribute(
|
||||
'src',
|
||||
'https://cdn.example.com/logo.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the server providerId when no iconURL is configured', () => {
|
||||
const endpointsConfig = {
|
||||
'My OpenRouter': {
|
||||
type: EModelEndpoint.custom,
|
||||
providerId: ProviderId.openrouter,
|
||||
order: 0,
|
||||
},
|
||||
} as TEndpointsConfig;
|
||||
|
||||
render(
|
||||
<MinimalIcon
|
||||
endpoint="My OpenRouter"
|
||||
endpointsConfig={endpointsConfig}
|
||||
isCreatedByUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('img', { name: 'OpenRouter', hidden: true })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the decorative wrapper from assistive technology and flags errors', () => {
|
||||
render(<MinimalIcon endpoint={EModelEndpoint.openAI} isCreatedByUser={false} error={true} />);
|
||||
|
||||
const wrapper = screen.getByTestId('convo-icon');
|
||||
expect(wrapper).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(wrapper).toHaveTextContent('!');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button } from '@librechat/client';
|
||||
import { alternateName, getEndpointField } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig } from 'librechat-data-provider';
|
||||
import { ResolvedProviderIcon } from '~/components/Endpoints/ResolvedProviderIcon';
|
||||
import { formatKeyExpiryLabel } from '~/components/Input/SetKeyDialog/utils';
|
||||
import { useUserKey, useLocalize, useClockFormat } from '~/hooks';
|
||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { getIconKey } from '~/utils';
|
||||
import { useProviderIcon } from '~/hooks/Endpoint';
|
||||
|
||||
interface ProviderKeyRowProps {
|
||||
endpoint: string;
|
||||
|
|
@ -19,9 +19,7 @@ export default function ProviderKeyRow({ endpoint, endpointsConfig }: ProviderKe
|
|||
const { getExpiry, checkExpiry } = useUserKey(endpoint);
|
||||
|
||||
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
|
||||
const iconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType });
|
||||
const Icon = icons[iconKey];
|
||||
const { provider, imageURL } = useProviderIcon({ endpoint, endpointsConfig });
|
||||
|
||||
const label = useMemo(() => alternateName[endpoint] || endpoint, [endpoint]);
|
||||
const expiry = getExpiry();
|
||||
|
|
@ -41,16 +39,14 @@ export default function ProviderKeyRow({ endpoint, endpointsConfig }: ProviderKe
|
|||
<>
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{Icon && (
|
||||
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
|
||||
{React.createElement(Icon, {
|
||||
size: 20,
|
||||
className: 'text-text-primary shrink-0 icon-md',
|
||||
iconURL,
|
||||
endpoint,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<ResolvedProviderIcon
|
||||
provider={provider}
|
||||
imageURL={imageURL}
|
||||
size={20}
|
||||
className="icon-md shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-text-primary">{label}</div>
|
||||
<div className="truncate text-xs text-text-secondary">{expiryLabel}</div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig } from 'librechat-data-provider';
|
||||
import { changeLanguageSafely, initializeI18n } from '~/locales/i18n';
|
||||
import { render, screen } from 'test/layout-test-utils';
|
||||
|
|
@ -14,8 +15,6 @@ jest.mock('~/hooks', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Endpoint/Icons', () => ({ icons: {} }));
|
||||
jest.mock('~/utils', () => ({ getIconKey: () => '' }));
|
||||
jest.mock('~/components/Input/SetKeyDialog', () => ({ SetKeyDialog: () => null }));
|
||||
|
||||
describe('ProviderKeyRow', () => {
|
||||
|
|
@ -43,4 +42,23 @@ describe('ProviderKeyRow', () => {
|
|||
|
||||
expect(screen.getByText(new RegExp(new Date(mockExpiry).toLocaleString()))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a configured endpoint image instead of the generic mark', () => {
|
||||
const { container } = render(
|
||||
<ProviderKeyRow
|
||||
endpoint="Branded"
|
||||
endpointsConfig={
|
||||
{
|
||||
Branded: {
|
||||
type: EModelEndpoint.custom,
|
||||
iconURL: 'https://cdn.example.com/x.png',
|
||||
order: 0,
|
||||
},
|
||||
} as TEndpointsConfig
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example.com/x.png');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { Input, Label } from '@librechat/client';
|
||||
import { Controller, useWatch, useFormContext } from 'react-hook-form';
|
||||
import { EModelEndpoint, getEndpointField } from 'librechat-data-provider';
|
||||
import type { AgentForm, IconComponentTypes } from '~/common';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { ResolvedProviderIcon } from '~/components/Endpoints/ResolvedProviderIcon';
|
||||
import AgentCategorySelector from './AgentCategorySelector';
|
||||
import { useLocalize, useAgentCapabilities } from '~/hooks';
|
||||
import { validateEmail, getIconKey, cn } from '~/utils';
|
||||
import { useAgentFileEntries } from './Tools/hooks';
|
||||
import { useAgentPanelContext } from '~/Providers';
|
||||
import { useProviderIcon } from '~/hooks/Endpoint';
|
||||
import ToolsSection from './Tools/ToolsSection';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { validateEmail, cn } from '~/utils';
|
||||
import Instructions from './Instructions';
|
||||
import FileContext from './FileContext';
|
||||
import AgentAvatar from './AgentAvatar';
|
||||
|
|
@ -33,22 +33,10 @@ export default function AgentConfig() {
|
|||
const { contextFiles } = useAgentFileEntries();
|
||||
|
||||
const providerValue = typeof provider === 'string' ? provider : provider?.value;
|
||||
let Icon: IconComponentTypes | null | undefined;
|
||||
let endpointType: EModelEndpoint | undefined;
|
||||
let endpointIconURL: string | undefined;
|
||||
let iconKey: string | undefined;
|
||||
|
||||
if (providerValue !== undefined) {
|
||||
endpointType = getEndpointField(endpointsConfig, providerValue as string, 'type');
|
||||
endpointIconURL = getEndpointField(endpointsConfig, providerValue as string, 'iconURL');
|
||||
iconKey = getIconKey({
|
||||
endpoint: providerValue as string,
|
||||
endpointsConfig,
|
||||
endpointType,
|
||||
endpointIconURL,
|
||||
});
|
||||
Icon = icons[iconKey];
|
||||
}
|
||||
const { provider: providerId, imageURL } = useProviderIcon({
|
||||
endpoint: providerValue as string,
|
||||
endpointsConfig,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-auto pt-1">
|
||||
|
|
@ -122,13 +110,13 @@ export default function AgentConfig() {
|
|||
)}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
{Icon && (
|
||||
{providerValue !== undefined && (
|
||||
<div className="shadow-stroke relative flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-white text-black dark:bg-white">
|
||||
<Icon
|
||||
<ResolvedProviderIcon
|
||||
provider={providerId}
|
||||
imageURL={imageURL}
|
||||
size={16}
|
||||
className="h-2/3 w-2/3"
|
||||
endpoint={providerValue as string}
|
||||
endpointType={endpointType}
|
||||
iconURL={endpointIconURL}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
import { Feather } from 'lucide-react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
GPTIcon,
|
||||
Sparkles,
|
||||
BedrockIcon,
|
||||
AssistantIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
GoogleMinimalIcon,
|
||||
CustomMinimalIcon,
|
||||
} from '@librechat/client';
|
||||
import type { IconMapProps, AgentIconMapProps, IconsRecord } from '~/common';
|
||||
import UnknownIcon from './UnknownIcon';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const AssistantAvatar = ({
|
||||
className = '',
|
||||
assistantName = '',
|
||||
avatar = '',
|
||||
context,
|
||||
size,
|
||||
}: IconMapProps) => {
|
||||
if (assistantName && avatar) {
|
||||
return (
|
||||
<img
|
||||
src={avatar}
|
||||
className="bg-token-surface-secondary h-full w-full rounded-full object-cover dark:bg-surface-tertiary"
|
||||
alt={assistantName}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
);
|
||||
} else if (assistantName) {
|
||||
return <AssistantIcon className={cn('text-text-secondary', className)} size={size} />;
|
||||
}
|
||||
|
||||
return <Sparkles className={cn(context === 'landing' ? 'icon-2xl' : '', className)} />;
|
||||
};
|
||||
|
||||
const AgentAvatar = ({ className = '', avatar = '', agentName, size }: AgentIconMapProps) => {
|
||||
if (agentName != null && agentName && avatar) {
|
||||
return (
|
||||
<img
|
||||
src={avatar}
|
||||
className="bg-token-surface-secondary h-full w-full rounded-full object-cover dark:bg-surface-tertiary"
|
||||
alt={agentName}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Feather className={cn(agentName === '' ? 'icon-2xl' : '', className)} size={size} />;
|
||||
};
|
||||
|
||||
const Bedrock = ({ className = '' }: IconMapProps) => {
|
||||
return <BedrockIcon className={cn(className, 'h-full w-full')} />;
|
||||
};
|
||||
|
||||
export const icons: IconsRecord = {
|
||||
[EModelEndpoint.azureOpenAI]: AzureMinimalIcon,
|
||||
[EModelEndpoint.openAI]: GPTIcon,
|
||||
[EModelEndpoint.anthropic]: AnthropicIcon,
|
||||
[EModelEndpoint.google]: GoogleMinimalIcon,
|
||||
[EModelEndpoint.custom]: CustomMinimalIcon,
|
||||
[EModelEndpoint.assistants]: AssistantAvatar,
|
||||
[EModelEndpoint.azureAssistants]: AssistantAvatar,
|
||||
[EModelEndpoint.agents]: AgentAvatar,
|
||||
[EModelEndpoint.bedrock]: Bedrock,
|
||||
unknown: UnknownIcon,
|
||||
};
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
import { memo } from 'react';
|
||||
import { EModelEndpoint, KnownEndpoints } from 'librechat-data-provider';
|
||||
import { CustomMinimalIcon, XAIcon, MoonshotIcon } from '@librechat/client';
|
||||
import { IconContext } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const knownEndpointAssets: Record<string, string> = {
|
||||
[KnownEndpoints.anyscale]: 'assets/anyscale.png',
|
||||
[KnownEndpoints.apipie]: 'assets/apipie.png',
|
||||
[KnownEndpoints.cohere]: 'assets/cohere.png',
|
||||
[KnownEndpoints.deepseek]: 'assets/deepseek.svg',
|
||||
[KnownEndpoints.fireworks]: 'assets/fireworks.png',
|
||||
google: 'assets/google.svg',
|
||||
[KnownEndpoints.groq]: 'assets/groq.png',
|
||||
[KnownEndpoints.helicone]: 'assets/helicone.svg',
|
||||
[KnownEndpoints.huggingface]: 'assets/huggingface.svg',
|
||||
[KnownEndpoints.mistral]: 'assets/mistral.png',
|
||||
[KnownEndpoints.mlx]: 'assets/mlx.png',
|
||||
[KnownEndpoints.ollama]: 'assets/ollama.png',
|
||||
openai: 'assets/openai.svg',
|
||||
[KnownEndpoints.openrouter]: 'assets/openrouter.png',
|
||||
[KnownEndpoints.perplexity]: 'assets/perplexity.png',
|
||||
qwen: 'assets/qwen.svg',
|
||||
[KnownEndpoints.shuttleai]: 'assets/shuttleai.png',
|
||||
[KnownEndpoints['together.ai']]: 'assets/together.png',
|
||||
[KnownEndpoints.unify]: 'assets/unify.webp',
|
||||
};
|
||||
|
||||
const knownEndpointComponents = new Set<string>([KnownEndpoints.moonshot, KnownEndpoints.xai]);
|
||||
|
||||
export function getKnownEndpointAsset(endpoint?: string | null): string {
|
||||
if (!endpoint) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return knownEndpointAssets[endpoint.toLowerCase()] ?? '';
|
||||
}
|
||||
|
||||
export function hasKnownEndpointIcon(endpoint?: string | null): boolean {
|
||||
if (!endpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentEndpoint = endpoint.toLowerCase();
|
||||
return (
|
||||
getKnownEndpointAsset(currentEndpoint) !== '' || knownEndpointComponents.has(currentEndpoint)
|
||||
);
|
||||
}
|
||||
|
||||
const knownEndpointClasses = {
|
||||
[KnownEndpoints.cohere]: {
|
||||
[IconContext.landing]: 'p-2',
|
||||
},
|
||||
};
|
||||
|
||||
const getKnownClass = ({
|
||||
currentEndpoint,
|
||||
context = '',
|
||||
className,
|
||||
}: {
|
||||
currentEndpoint: string;
|
||||
context?: string;
|
||||
className: string;
|
||||
}) => {
|
||||
if (currentEndpoint === KnownEndpoints.openrouter) {
|
||||
return className;
|
||||
}
|
||||
|
||||
const match = knownEndpointClasses[currentEndpoint]?.[context] ?? '';
|
||||
const defaultClass = context === IconContext.landing ? '' : className;
|
||||
|
||||
return cn(match, defaultClass);
|
||||
};
|
||||
|
||||
function UnknownIcon({
|
||||
className = '',
|
||||
endpoint: _endpoint,
|
||||
iconURL = '',
|
||||
context,
|
||||
}: {
|
||||
iconURL?: string;
|
||||
className?: string;
|
||||
endpoint?: EModelEndpoint | string | null;
|
||||
context?: 'landing' | 'menu-item' | 'nav' | 'message';
|
||||
}) {
|
||||
const endpoint = _endpoint ?? '';
|
||||
if (!endpoint) {
|
||||
return <CustomMinimalIcon className={className} />;
|
||||
}
|
||||
|
||||
const currentEndpoint = endpoint.toLowerCase();
|
||||
|
||||
if (currentEndpoint === KnownEndpoints.xai) {
|
||||
return <XAIcon className={cn(className, 'text-text-primary')} />;
|
||||
}
|
||||
|
||||
if (currentEndpoint === KnownEndpoints.moonshot) {
|
||||
return <MoonshotIcon className={cn(className, 'text-text-primary')} />;
|
||||
}
|
||||
|
||||
if (iconURL) {
|
||||
return <img className={className} src={iconURL} alt={`${endpoint} Icon`} />;
|
||||
}
|
||||
|
||||
const assetPath = getKnownEndpointAsset(currentEndpoint);
|
||||
|
||||
if (!assetPath) {
|
||||
return <CustomMinimalIcon className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className={getKnownClass({
|
||||
currentEndpoint,
|
||||
context: context,
|
||||
className,
|
||||
})}
|
||||
src={assetPath}
|
||||
alt={`${currentEndpoint} Icon`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(UnknownIcon);
|
||||
57
client/src/hooks/Endpoint/__tests__/useProviderIcon.spec.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import { EModelEndpoint, ProviderId } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig } from 'librechat-data-provider';
|
||||
import { useProviderIcon } from '../useProviderIcon';
|
||||
|
||||
const config: TEndpointsConfig = {
|
||||
'My OpenRouter': { type: EModelEndpoint.custom, providerId: ProviderId.openrouter, order: 0 },
|
||||
Branded: { type: EModelEndpoint.custom, iconURL: 'https://cdn.example.com/x.png', order: 1 },
|
||||
Declared: { type: EModelEndpoint.custom, iconURL: 'mistral', order: 2 },
|
||||
Ollama: { type: EModelEndpoint.custom, order: 3 },
|
||||
};
|
||||
|
||||
const run = (endpoint: string, iconURL?: string) =>
|
||||
renderHook(() => useProviderIcon({ endpoint, endpointsConfig: config, iconURL })).result.current;
|
||||
|
||||
describe('useProviderIcon', () => {
|
||||
it('rule 1: an image iconURL wins and suppresses provider art', () => {
|
||||
expect(run('Branded')).toEqual({
|
||||
provider: null,
|
||||
imageURL: 'https://cdn.example.com/x.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a relative asset path as an image', () => {
|
||||
expect(run('Custom', 'assets/company.png')).toEqual({
|
||||
provider: null,
|
||||
imageURL: 'assets/company.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('rule 2: an iconURL naming a provider resolves to that provider', () => {
|
||||
expect(run('Declared')).toEqual({ provider: ProviderId.mistral, imageURL: null });
|
||||
});
|
||||
|
||||
it('rule 3: the server resolved providerId is used when no iconURL applies', () => {
|
||||
expect(run('My OpenRouter')).toEqual({ provider: ProviderId.openrouter, imageURL: null });
|
||||
});
|
||||
|
||||
it('rule 4: a first-class endpoint maps through endpointToProvider', () => {
|
||||
expect(run(EModelEndpoint.anthropic)).toEqual({
|
||||
provider: ProviderId.anthropic,
|
||||
imageURL: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rule 5: an unconfigured endpoint still matches on its name', () => {
|
||||
expect(run('Ollama')).toEqual({ provider: ProviderId.ollama, imageURL: null });
|
||||
});
|
||||
|
||||
it('rule 6: nothing resolves to no provider and no image', () => {
|
||||
expect(run('Totally Unknown')).toEqual({ provider: null, imageURL: null });
|
||||
});
|
||||
|
||||
it('entity endpoints resolve to no provider, so avatars win', () => {
|
||||
expect(run(EModelEndpoint.agents)).toEqual({ provider: null, imageURL: null });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { default as useKeyDialog } from './useKeyDialog';
|
||||
export { default as useEndpoints } from './useEndpoints';
|
||||
export { default as useSelectorEffects } from './useSelectorEffects';
|
||||
export { useProviderIcon, resolveProviderIcon } from './useProviderIcon';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React, { useMemo, useCallback } from 'react';
|
||||
import { Feather } from 'lucide-react';
|
||||
import { Sparkles, ProviderIcon } from '@librechat/client';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
Permissions,
|
||||
|
|
@ -15,14 +17,46 @@ import type {
|
|||
Assistant,
|
||||
Agent,
|
||||
} from 'librechat-data-provider';
|
||||
import type { ProviderIconResolution } from './useProviderIcon';
|
||||
import type { Endpoint } from '~/common';
|
||||
import { useHasAccess, useShowMarketplace } from '~/hooks';
|
||||
import { resolveProviderIcon } from './useProviderIcon';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { mapEndpoints, getIconKey } from '~/utils';
|
||||
import { icons } from './Icons';
|
||||
import { mapEndpoints } from '~/utils';
|
||||
|
||||
const defaultInterface = getConfigDefaults().interface;
|
||||
|
||||
const artSize = 20;
|
||||
const artClassName = 'text-text-primary shrink-0 icon-md';
|
||||
const providerArtClassName = 'icon-md shrink-0';
|
||||
|
||||
const createEndpointIcon = (
|
||||
endpoint: string,
|
||||
{ provider, imageURL }: ProviderIconResolution,
|
||||
): React.ReactNode => {
|
||||
if (endpoint === EModelEndpoint.agents) {
|
||||
return React.createElement(Feather, { size: artSize, className: artClassName });
|
||||
}
|
||||
|
||||
if (endpoint === EModelEndpoint.assistants || endpoint === EModelEndpoint.azureAssistants) {
|
||||
return React.createElement(Sparkles, { className: artClassName });
|
||||
}
|
||||
|
||||
if (imageURL != null) {
|
||||
return React.createElement('img', {
|
||||
src: imageURL,
|
||||
alt: `${endpoint} Icon`,
|
||||
className: artClassName,
|
||||
});
|
||||
}
|
||||
|
||||
return React.createElement(ProviderIcon, {
|
||||
provider,
|
||||
size: artSize,
|
||||
className: providerArtClassName,
|
||||
});
|
||||
};
|
||||
|
||||
export const useEndpoints = ({
|
||||
agents,
|
||||
assistantsMap,
|
||||
|
|
@ -85,10 +119,6 @@ export const useEndpoints = ({
|
|||
|
||||
const mappedEndpoints: Endpoint[] = useMemo(() => {
|
||||
return filteredEndpoints.reduce<Endpoint[]>((acc, ep) => {
|
||||
const endpointType = getEndpointField(endpointsConfig, ep, 'type');
|
||||
const iconKey = getIconKey({ endpoint: ep, endpointsConfig, endpointType });
|
||||
const Icon = icons[iconKey];
|
||||
const endpointIconURL = getEndpointField(endpointsConfig, ep, 'iconURL');
|
||||
const hasModels =
|
||||
(ep === EModelEndpoint.agents && ((agents?.length ?? 0) > 0 || showAgentMarketplace)) ||
|
||||
(ep === EModelEndpoint.assistants && assistants?.length > 0) ||
|
||||
|
|
@ -105,14 +135,7 @@ export const useEndpoints = ({
|
|||
value: ep,
|
||||
label: alternateName[ep] || ep,
|
||||
hasModels,
|
||||
icon: Icon
|
||||
? React.createElement(Icon, {
|
||||
size: 20,
|
||||
className: 'text-text-primary shrink-0 icon-md',
|
||||
iconURL: endpointIconURL,
|
||||
endpoint: ep,
|
||||
})
|
||||
: null,
|
||||
icon: createEndpointIcon(ep, resolveProviderIcon({ endpoint: ep, endpointsConfig })),
|
||||
};
|
||||
|
||||
if (ep === EModelEndpoint.agents && showAgentMarketplace) {
|
||||
|
|
|
|||
62
client/src/hooks/Endpoint/useProviderIcon.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useMemo } from 'react';
|
||||
import { getEndpointField, resolveProviderId, endpointToProvider } from 'librechat-data-provider';
|
||||
import type { TEndpointsConfig, EModelEndpoint, ProviderId } from 'librechat-data-provider';
|
||||
import { isImageURL } from '~/utils/icons';
|
||||
|
||||
export interface ProviderIconResolution {
|
||||
provider: ProviderId | null;
|
||||
imageURL: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderIconParams {
|
||||
endpoint?: string | null;
|
||||
endpointsConfig?: TEndpointsConfig | null;
|
||||
iconURL?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for which art a given endpoint renders. Precedence, highest
|
||||
* first: an image iconURL, an iconURL naming a provider, the server resolved
|
||||
* providerId, the first-class endpoint mapping, then the endpoint name.
|
||||
*/
|
||||
export function resolveProviderIcon({
|
||||
endpoint,
|
||||
endpointsConfig,
|
||||
iconURL,
|
||||
}: ProviderIconParams): ProviderIconResolution {
|
||||
const configured = getEndpointField(endpointsConfig, endpoint, 'iconURL');
|
||||
const candidate = iconURL || configured;
|
||||
|
||||
if (isImageURL(candidate)) {
|
||||
return { provider: null, imageURL: candidate };
|
||||
}
|
||||
|
||||
const declared = resolveProviderId(candidate);
|
||||
if (declared) {
|
||||
return { provider: declared, imageURL: null };
|
||||
}
|
||||
|
||||
const served = getEndpointField(endpointsConfig, endpoint, 'providerId');
|
||||
if (served) {
|
||||
return { provider: served, imageURL: null };
|
||||
}
|
||||
|
||||
const firstClass = endpoint ? endpointToProvider[endpoint as EModelEndpoint] : undefined;
|
||||
if (firstClass) {
|
||||
return { provider: firstClass, imageURL: null };
|
||||
}
|
||||
|
||||
return { provider: resolveProviderId(endpoint), imageURL: null };
|
||||
}
|
||||
|
||||
/** Memoized form of {@link resolveProviderIcon}, for use outside render callbacks. */
|
||||
export function useProviderIcon({
|
||||
endpoint,
|
||||
endpointsConfig,
|
||||
iconURL,
|
||||
}: ProviderIconParams): ProviderIconResolution {
|
||||
return useMemo(
|
||||
() => resolveProviderIcon({ endpoint, endpointsConfig, iconURL }),
|
||||
[endpoint, endpointsConfig, iconURL],
|
||||
);
|
||||
}
|
||||
|
|
@ -244,6 +244,13 @@ html {
|
|||
--theme-motion-fast: 150ms;
|
||||
--theme-motion-normal: 200ms;
|
||||
--switch-unchecked: 0 0% 58%;
|
||||
--provider-openai: #19c37d;
|
||||
--provider-openai-gpt4: #ab68ff;
|
||||
--provider-openai-reasoning: #000000;
|
||||
--provider-anthropic: #d09a74;
|
||||
--provider-azure: linear-gradient(0.375turn, #61bde2, #4389d0);
|
||||
--provider-bedrock: #268672;
|
||||
--provider-foreground: #ffffff;
|
||||
}
|
||||
.dark {
|
||||
--brand-purple: 171 104 255;
|
||||
|
|
@ -326,6 +333,13 @@ html {
|
|||
--surface-fixed-hover: var(--gray-100);
|
||||
--text-fixed: var(--gray-800);
|
||||
--switch-unchecked: 0 0% 40%;
|
||||
--provider-openai: #19c37d;
|
||||
--provider-openai-gpt4: #ab68ff;
|
||||
--provider-openai-reasoning: #000000;
|
||||
--provider-anthropic: #d09a74;
|
||||
--provider-azure: linear-gradient(0.375turn, #61bde2, #4389d0);
|
||||
--provider-bedrock: #268672;
|
||||
--provider-foreground: #ffffff;
|
||||
}
|
||||
.gizmo {
|
||||
--text-primary: var(--gizmo-gray-950);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
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([
|
||||
'https://example.com/icon.png',
|
||||
'http://example.com/icon.png',
|
||||
'//cdn.example.com/provider.png',
|
||||
'/assets/icon.svg',
|
||||
'assets/company.png',
|
||||
'assets/icon.svg',
|
||||
'assets/company.svg#mark',
|
||||
'assets/provider.avif',
|
||||
'assets/provider.apng',
|
||||
'assets/provider.bmp',
|
||||
'assets/provider.jfif',
|
||||
'data:image/png;base64,iVBORw0KGgo=',
|
||||
])('accepts image URL %s', (iconURL) => {
|
||||
expect(isImageURL(iconURL)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['openAI', 'anthropic', 'assets/icon.svg', '//example.com/icon.png', '', null])(
|
||||
it.each(['openAI', 'anthropic', '//', '///icon.png', '', null])(
|
||||
'rejects non-image URL %s',
|
||||
(iconURL) => {
|
||||
expect(isImageURL(iconURL)).toBe(false);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
resolveModelSpecEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { LocalizeFunction, IconsRecord } from '~/common';
|
||||
import type { LocalizeFunction } from '~/common';
|
||||
import { getTimestampedValue } from './timestamps';
|
||||
import { getAgentAvatarUrl } from './agents';
|
||||
|
||||
|
|
@ -664,26 +664,6 @@ export function getIconEndpoint({
|
|||
return (endpointsConfig?.[iconURL ?? ''] ? (iconURL ?? endpoint) : endpoint) ?? '';
|
||||
}
|
||||
|
||||
/** Gets the key to use for the default endpoint iconURL, as defined by the custom config */
|
||||
export function getIconKey({
|
||||
endpoint,
|
||||
endpointType: _eType,
|
||||
endpointsConfig,
|
||||
endpointIconURL: iconURL,
|
||||
}: {
|
||||
endpoint?: string | null;
|
||||
endpointsConfig?: t.TEndpointsConfig | null;
|
||||
endpointType?: string | null;
|
||||
endpointIconURL?: string;
|
||||
}): keyof IconsRecord {
|
||||
const endpointType = _eType ?? getEndpointField(endpointsConfig, endpoint, 'type') ?? '';
|
||||
const endpointIconURL = iconURL ?? getEndpointField(endpointsConfig, endpoint, 'iconURL') ?? '';
|
||||
if (endpointIconURL && EModelEndpoint[endpointIconURL] != null) {
|
||||
return endpointIconURL;
|
||||
}
|
||||
return endpointType ? 'unknown' : (endpoint ?? 'unknown');
|
||||
}
|
||||
|
||||
export const getEntity = ({
|
||||
endpoint,
|
||||
assistant_id,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
/** Every image format browsers render, so a configured path is never mistaken for a provider name. */
|
||||
const IMAGE_EXTENSION =
|
||||
/\.(apng|avif|bmp|cur|gif|ico|jfif|jpe?g|pjp|pjpeg|png|svg|webp)(?:[?#].*)?$/i;
|
||||
|
||||
export function isImageURL(iconURL?: string | null): iconURL is string {
|
||||
if (!iconURL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^https?:\/\//i.test(iconURL) || (iconURL.startsWith('/') && !iconURL.startsWith('//'));
|
||||
if (/^https?:\/\//i.test(iconURL) || /^data:image\/[a-z0-9.+-]+/i.test(iconURL)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (iconURL.startsWith('//')) {
|
||||
return /^\/\/[^/]/.test(iconURL);
|
||||
}
|
||||
|
||||
return iconURL.startsWith('/') || IMAGE_EXTENSION.test(iconURL);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { EModelEndpoint, extractEnvVariable, normalizeEndpointName } from 'librechat-data-provider';
|
||||
import type { TCustomEndpoints, TEndpoint } from 'librechat-data-provider';
|
||||
import type { TCustomEndpointsConfig } from '~/types/endpoints';
|
||||
import { resolveEndpointProviderId } from './providers';
|
||||
import { isUserProvided } from '~/utils';
|
||||
|
||||
/**
|
||||
|
|
@ -63,6 +64,12 @@ export function loadCustomEndpointsConfig(
|
|||
customParams: resolvedCustomParams,
|
||||
modelDisplayLabel,
|
||||
iconURL,
|
||||
providerId: resolveEndpointProviderId({
|
||||
name,
|
||||
baseURL: resolvedBaseURL,
|
||||
iconURL,
|
||||
provider,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
packages/api/src/endpoints/custom/providers.spec.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { ProviderId } from 'librechat-data-provider';
|
||||
import { providerHosts, resolveEndpointProviderId } from './providers';
|
||||
|
||||
describe('resolveEndpointProviderId', () => {
|
||||
it('prefers an iconURL that names a provider', () => {
|
||||
expect(
|
||||
resolveEndpointProviderId({
|
||||
name: 'Internal Gateway',
|
||||
baseURL: 'https://gateway.internal/v1',
|
||||
iconURL: 'openrouter',
|
||||
}),
|
||||
).toBe(ProviderId.openrouter);
|
||||
});
|
||||
|
||||
it('ignores an iconURL that is an image address', () => {
|
||||
expect(
|
||||
resolveEndpointProviderId({
|
||||
name: 'OpenRouter',
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
iconURL: 'https://example.com/logo.png',
|
||||
}),
|
||||
).toBe(ProviderId.openrouter);
|
||||
});
|
||||
|
||||
it('resolves a renamed endpoint from its baseURL host', () => {
|
||||
expect(
|
||||
resolveEndpointProviderId({
|
||||
name: 'My OpenRouter',
|
||||
baseURL: 'https://openrouter.ai/api/v1',
|
||||
}),
|
||||
).toBe(ProviderId.openrouter);
|
||||
});
|
||||
|
||||
it('matches a host on a subdomain', () => {
|
||||
expect(
|
||||
resolveEndpointProviderId({
|
||||
name: 'Groq Proxy',
|
||||
baseURL: 'https://eu.api.groq.com/openai/v1',
|
||||
}),
|
||||
).toBe(ProviderId.groq);
|
||||
});
|
||||
|
||||
it('resolves the Helicone gateway host documented in librechat.example.yaml', () => {
|
||||
expect(
|
||||
resolveEndpointProviderId({
|
||||
name: 'Team Gateway',
|
||||
baseURL: 'https://ai-gateway.helicone.ai',
|
||||
}),
|
||||
).toBe(ProviderId.helicone);
|
||||
});
|
||||
|
||||
it('recognizes the supported Cohere API host', () => {
|
||||
expect(
|
||||
resolveEndpointProviderId({
|
||||
name: 'My Cohere',
|
||||
baseURL: 'https://api.cohere.ai/v1',
|
||||
}),
|
||||
).toBe(ProviderId.cohere);
|
||||
});
|
||||
|
||||
it('honors an explicit native provider when the host is unknown', () => {
|
||||
expect(
|
||||
resolveEndpointProviderId({
|
||||
name: 'My Claude Proxy',
|
||||
baseURL: 'https://gateway.internal/v1',
|
||||
provider: 'anthropic',
|
||||
}),
|
||||
).toBe(ProviderId.anthropic);
|
||||
});
|
||||
|
||||
it('falls back to the endpoint name', () => {
|
||||
expect(resolveEndpointProviderId({ name: 'Mistral' })).toBe(ProviderId.mistral);
|
||||
});
|
||||
|
||||
it('returns undefined for a self-hosted gateway with no signal', () => {
|
||||
expect(
|
||||
resolveEndpointProviderId({
|
||||
name: 'LiteLLM',
|
||||
baseURL: 'http://localhost:4000/v1',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('survives a malformed baseURL', () => {
|
||||
expect(resolveEndpointProviderId({ name: 'Cohere', baseURL: 'not a url' })).toBe(
|
||||
ProviderId.cohere,
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
['https://team.openai.azure.com/openai/v1', ProviderId.azure],
|
||||
['https://team.cognitiveservices.azure.com/openai/v1', ProviderId.azure],
|
||||
['https://generativelanguage.googleapis.com/v1beta', ProviderId.google],
|
||||
['https://aiplatform.googleapis.com/v1', ProviderId.google],
|
||||
])('brands %s from its host alone', (baseURL, expected) => {
|
||||
expect(resolveEndpointProviderId({ name: 'Team Gateway', baseURL })).toBe(expected);
|
||||
});
|
||||
|
||||
it('carries a host for every provider that one can identify', () => {
|
||||
/** Host-unresolvable by nature: bedrock is region-scoped under a shared AWS suffix,
|
||||
* and mlx and ollama are served from the operator's own machine. Anything else added
|
||||
* to ProviderId without a host silently falls through to the generic mark. */
|
||||
const hostUnresolvable: ProviderId[] = [ProviderId.bedrock, ProviderId.mlx, ProviderId.ollama];
|
||||
const covered = new Set(providerHosts.map(([, provider]) => provider));
|
||||
|
||||
const missing = Object.values(ProviderId).filter(
|
||||
(provider) => !covered.has(provider) && !hostUnresolvable.includes(provider),
|
||||
);
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
82
packages/api/src/endpoints/custom/providers.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { ProviderId, resolveProviderId } from 'librechat-data-provider';
|
||||
|
||||
export const providerHosts: ReadonlyArray<readonly [string, ProviderId]> = [
|
||||
['openrouter.ai', ProviderId.openrouter],
|
||||
['api.openai.com', ProviderId.openai],
|
||||
['api.anthropic.com', ProviderId.anthropic],
|
||||
['api.deepseek.com', ProviderId.deepseek],
|
||||
['api.groq.com', ProviderId.groq],
|
||||
['api.mistral.ai', ProviderId.mistral],
|
||||
['api.perplexity.ai', ProviderId.perplexity],
|
||||
['api.together.xyz', ProviderId.together],
|
||||
['api.x.ai', ProviderId.xai],
|
||||
['api.moonshot.cn', ProviderId.moonshot],
|
||||
['api.moonshot.ai', ProviderId.moonshot],
|
||||
['api.cohere.com', ProviderId.cohere],
|
||||
['api.cohere.ai', ProviderId.cohere],
|
||||
['api.fireworks.ai', ProviderId.fireworks],
|
||||
['api-inference.huggingface.co', ProviderId.huggingface],
|
||||
['api.endpoints.anyscale.com', ProviderId.anyscale],
|
||||
['apipie.ai', ProviderId.apipie],
|
||||
['api.shuttleai.app', ProviderId.shuttleai],
|
||||
['api.unify.ai', ProviderId.unify],
|
||||
['helicone.ai', ProviderId.helicone],
|
||||
['ai-gateway.vercel.sh', ProviderId.vercel],
|
||||
['dashscope.aliyuncs.com', ProviderId.qwen],
|
||||
['openai.azure.com', ProviderId.azure],
|
||||
['cognitiveservices.azure.com', ProviderId.azure],
|
||||
['generativelanguage.googleapis.com', ProviderId.google],
|
||||
['aiplatform.googleapis.com', ProviderId.google],
|
||||
];
|
||||
|
||||
/**
|
||||
* The remaining providers cannot be identified by host: bedrock's runtime hostname
|
||||
* carries a region segment under the shared `amazonaws.com` suffix, and mlx and ollama
|
||||
* are served from the operator's own machine. They resolve by iconURL, provider or name.
|
||||
*/
|
||||
|
||||
function providerFromBaseURL(baseURL?: string): ProviderId | undefined {
|
||||
if (!baseURL) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(baseURL).hostname.toLowerCase();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const [candidate, provider] of providerHosts) {
|
||||
if (host === candidate || host.endsWith(`.${candidate}`)) {
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a custom endpoint's brand identity once at config load, where `baseURL`
|
||||
* is available. `endpointType` is not consulted: every custom endpoint is typed
|
||||
* `custom`, so it carries no brand signal.
|
||||
*/
|
||||
export function resolveEndpointProviderId({
|
||||
name,
|
||||
baseURL,
|
||||
iconURL,
|
||||
provider,
|
||||
}: {
|
||||
name: string;
|
||||
baseURL?: string;
|
||||
iconURL?: string;
|
||||
provider?: string;
|
||||
}): ProviderId | undefined {
|
||||
return (
|
||||
resolveProviderId(iconURL) ??
|
||||
providerFromBaseURL(baseURL) ??
|
||||
resolveProviderId(provider) ??
|
||||
resolveProviderId(name) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
48
packages/client/src/icons/provider/Avatar.spec.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import '@testing-library/jest-dom';
|
||||
import { ProviderId } from 'librechat-data-provider';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ProviderAvatar } from './Avatar';
|
||||
|
||||
describe('ProviderAvatar', () => {
|
||||
it('paints the brand background and switches art to white on it', () => {
|
||||
const { container } = render(<ProviderAvatar provider={ProviderId.anthropic} />);
|
||||
const tile = container.firstChild as HTMLElement;
|
||||
expect(tile).toHaveStyle({ background: 'var(--provider-anthropic, #d09a74)' });
|
||||
expect(tile).toHaveStyle({ color: 'var(--provider-foreground, #ffffff)' });
|
||||
});
|
||||
|
||||
it('keeps monochrome art white on a branded tile', () => {
|
||||
const { container } = render(<ProviderAvatar provider={ProviderId.anthropic} />);
|
||||
const icon = container.querySelector('[role="img"]');
|
||||
expect(icon).toHaveClass('[color:inherit]');
|
||||
expect(icon).not.toHaveClass('text-text-primary');
|
||||
});
|
||||
|
||||
it('uses a theme token when the provider has no brand background', () => {
|
||||
const { container } = render(<ProviderAvatar provider={ProviderId.google} />);
|
||||
expect(container.firstChild).toHaveClass('text-text-primary');
|
||||
});
|
||||
|
||||
it('varies the OpenAI background by model generation', () => {
|
||||
const { container: four } = render(
|
||||
<ProviderAvatar provider={ProviderId.openai} model="gpt-4o" />,
|
||||
);
|
||||
const { container: five } = render(
|
||||
<ProviderAvatar provider={ProviderId.openai} model="gpt-5.6" />,
|
||||
);
|
||||
expect(four.firstChild).toHaveStyle({ background: 'var(--provider-openai-gpt4, #AB68FF)' });
|
||||
expect(five.firstChild).toHaveStyle({
|
||||
background: 'var(--provider-openai-reasoning, #000000)',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels the tile with the provider name', () => {
|
||||
render(<ProviderAvatar provider={ProviderId.bedrock} />);
|
||||
expect(screen.getByTitle('AWS Bedrock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes the provider name once, so the tile does not double label its art', () => {
|
||||
render(<ProviderAvatar provider={ProviderId.bedrock} />);
|
||||
expect(screen.getAllByRole('img', { name: 'AWS Bedrock' })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
57
packages/client/src/icons/provider/Avatar.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { memo } from 'react';
|
||||
import type { NamedExoticComponent, ReactNode } from 'react';
|
||||
import type { ProviderId } from 'librechat-data-provider';
|
||||
import type { JSX } from 'react/jsx-runtime';
|
||||
import { getProviderIconDef } from './registry';
|
||||
import { ProviderIcon } from './Icon';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
export interface ProviderAvatarProps {
|
||||
provider?: ProviderId | null;
|
||||
model?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
/** Overlay content positioned against the tile, such as an error badge. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const artScale = 5 / 9;
|
||||
|
||||
function ProviderAvatarComponent({
|
||||
provider,
|
||||
model,
|
||||
size = 30,
|
||||
className,
|
||||
children,
|
||||
}: ProviderAvatarProps): JSX.Element {
|
||||
const def = getProviderIconDef(provider, model);
|
||||
const hasBrand = typeof def.brandColor === 'string' && def.brandColor.length > 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={def.label}
|
||||
style={{
|
||||
background: hasBrand ? def.brandColor : 'transparent',
|
||||
width: size,
|
||||
height: size,
|
||||
color: hasBrand ? 'var(--provider-foreground, #ffffff)' : undefined,
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-sm p-1',
|
||||
hasBrand ? undefined : 'text-text-primary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
model={model}
|
||||
size={size * artScale}
|
||||
className={hasBrand ? '[color:inherit]' : undefined}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const ProviderAvatar: NamedExoticComponent<ProviderAvatarProps> =
|
||||
memo(ProviderAvatarComponent);
|
||||
55
packages/client/src/icons/provider/Icon.spec.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import '@testing-library/jest-dom';
|
||||
import { ProviderId } from 'librechat-data-provider';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ProviderIcon } from './Icon';
|
||||
|
||||
describe('ProviderIcon', () => {
|
||||
it('renders component art with an accessible label', () => {
|
||||
render(<ProviderIcon provider={ProviderId.openai} />);
|
||||
expect(screen.getByRole('img', { name: 'OpenAI' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders asset art as an image from the packaged module', () => {
|
||||
render(<ProviderIcon provider={ProviderId.ollama} />);
|
||||
expect(screen.getByRole('img', { name: 'Ollama' })).toHaveAttribute('src', 'assets/ollama.png');
|
||||
});
|
||||
|
||||
it('gives monochrome art a theme token so it follows light and dark', () => {
|
||||
const { container } = render(<ProviderIcon provider={ProviderId.xai} />);
|
||||
expect(container.firstChild).toHaveClass('text-text-primary');
|
||||
});
|
||||
|
||||
it('does not shrink Cohere art with landing-only padding', () => {
|
||||
const { container } = render(<ProviderIcon provider={ProviderId.cohere} />);
|
||||
expect(container.firstChild).not.toHaveClass('p-2');
|
||||
});
|
||||
|
||||
it('applies the model refinement, so Gemini and Gemma keep distinct labels', () => {
|
||||
render(<ProviderIcon provider={ProviderId.google} model="gemini-2.5-pro" />);
|
||||
expect(screen.getByRole('img', { name: 'Gemini' })).toBeInTheDocument();
|
||||
render(<ProviderIcon provider={ProviderId.google} model="gemma-3-27b" />);
|
||||
expect(screen.getByRole('img', { name: 'Gemma' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the generic mark for an unknown provider', () => {
|
||||
render(<ProviderIcon provider={null} />);
|
||||
expect(screen.getByRole('img', { name: 'Custom' })).toBeInTheDocument();
|
||||
});
|
||||
it('lets the sized span, not a caller size class, govern nested component art', () => {
|
||||
const { container } = render(
|
||||
<ProviderIcon provider={ProviderId.openai} size={27} className="h-2/3 w-2/3" />,
|
||||
);
|
||||
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toHaveClass('h-full', 'w-full');
|
||||
expect(svg).not.toHaveClass('h-2/3', 'w-2/3');
|
||||
});
|
||||
|
||||
it('still forwards a caller color onto nested component art', () => {
|
||||
const { container } = render(
|
||||
<ProviderIcon provider={ProviderId.anthropic} className="[color:inherit]" />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('svg')).toHaveClass('[color:inherit]');
|
||||
});
|
||||
});
|
||||
53
packages/client/src/icons/provider/Icon.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { memo } from 'react';
|
||||
import type { ProviderId } from 'librechat-data-provider';
|
||||
import type { NamedExoticComponent } from 'react';
|
||||
import type { JSX } from 'react/jsx-runtime';
|
||||
import { getProviderIconDef } from './registry';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
export interface ProviderIconProps {
|
||||
provider?: ProviderId | null;
|
||||
model?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ProviderIconComponent({
|
||||
provider,
|
||||
model,
|
||||
size = 20,
|
||||
className,
|
||||
}: ProviderIconProps): JSX.Element {
|
||||
const def = getProviderIconDef(provider, model);
|
||||
const classes = cn(
|
||||
def.mono === true && className == null ? 'text-text-primary' : '',
|
||||
def.className,
|
||||
className,
|
||||
);
|
||||
|
||||
if (def.art.kind === 'component') {
|
||||
const { Component } = def.art;
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={def.label}
|
||||
style={{ width: size, height: size }}
|
||||
className={cn('inline-flex items-center justify-center', classes)}
|
||||
>
|
||||
<Component size={size} className={cn(classes, 'h-full w-full')} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={def.art.src}
|
||||
alt={def.label}
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('object-contain', classes)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const ProviderIcon: NamedExoticComponent<ProviderIconProps> = memo(ProviderIconComponent);
|
||||
BIN
packages/client/src/icons/provider/assets/anyscale.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
packages/client/src/icons/provider/assets/apipie.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
packages/client/src/icons/provider/assets/cohere.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
1
packages/client/src/icons/provider/assets/deepseek.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71.69 52.76"><defs><style>.cls-1{fill:#4d6bfe;}</style></defs><path id="path" class="cls-1" d="M523.77,276.34c-.76-.38-1.08.33-1.53.69a4,4,0,0,0-.41.41,5.07,5.07,0,0,1-4.1,1.87,8,8,0,0,0-6.46,2.53,5.82,5.82,0,0,0-3.72-4.62,6.39,6.39,0,0,1-2.85-1.94,7.76,7.76,0,0,1-.92-2.31c-.16-.48-.32-1-.87-1.05s-.83.41-1.07.82a11,11,0,0,0-1.26,5.5,11.9,11.9,0,0,0,5.49,10.14.75.75,0,0,1,.39,1c-.25.84-.54,1.65-.79,2.49-.17.53-.41.65-1,.42a16.63,16.63,0,0,1-5.18-3.52c-2.56-2.48-4.88-5.21-7.76-7.35-.68-.5-1.36-1-2.06-1.41-2.94-2.86.39-5.2,1.16-5.48s.28-1.29-2.33-1.28-5,.88-8,2a8.23,8.23,0,0,1-1.39.41,28.67,28.67,0,0,0-8.61-.3,18.57,18.57,0,0,0-13.44,7.83c-4,5.47-4.91,11.67-3.76,18.15a27.68,27.68,0,0,0,10,16.88,26.8,26.8,0,0,0,19.23,6.39c4.43-.25,9.36-.84,14.92-5.55a13.84,13.84,0,0,0,5.32,1.18,17.24,17.24,0,0,0,5.09-.38c2.2-.46,2.05-2.5,1.25-2.87-6.43-3-5-1.78-6.3-2.77,3.27-3.87,8.2-7.89,10.13-20.92a12.44,12.44,0,0,0,0-2.52c0-.51.1-.71.68-.76a12.55,12.55,0,0,0,4.62-1.42c4.17-2.28,5.85-6,6.25-10.51A1.57,1.57,0,0,0,523.77,276.34Zm-36.34,40.37c-6.24-4.9-9.27-6.52-10.52-6.45s-1,1.41-.7,2.28a8.49,8.49,0,0,0,1.11,2.21,1.14,1.14,0,0,1-.34,1.8c-2,1.24-5.5-.42-5.66-.5a26.08,26.08,0,0,1-9.87-9.88,30.15,30.15,0,0,1-3.87-13.39c-.06-1.15.28-1.56,1.42-1.77a14.31,14.31,0,0,1,4.57-.11,28.56,28.56,0,0,1,16.33,8.29,54.06,54.06,0,0,1,6.58,8.63,41.46,41.46,0,0,0,7.41,8.71,24.36,24.36,0,0,0,2.66,2C494.16,318.82,490.16,318.87,487.43,316.71Zm3-19.23a.92.92,0,0,1,.92-.92.83.83,0,0,1,.32.06.8.8,0,0,1,.34.22.9.9,0,0,1,.25.64.92.92,0,0,1-1.83,0Zm9.29,4.76a5.27,5.27,0,0,1-1.77.48,3.75,3.75,0,0,1-2.38-.76,3.57,3.57,0,0,1-1.65-2.26,5.16,5.16,0,0,1,0-1.76,2,2,0,0,0-.71-2.17,3.1,3.1,0,0,0-2.06-.59,1.63,1.63,0,0,1-.76-.24.75.75,0,0,1-.34-1.07,3.47,3.47,0,0,1,.57-.62,3.9,3.9,0,0,1,3.43,0,10,10,0,0,1,3,2.34,18.62,18.62,0,0,1,2,2.73,10.9,10.9,0,0,1,1.33,2.53C500.65,301.47,500.4,302,499.71,302.24Z" transform="translate(-452.83 -271.91)"/></svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
BIN
packages/client/src/icons/provider/assets/fireworks.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
packages/client/src/icons/provider/assets/groq.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
16
packages/client/src/icons/provider/assets/helicone.svg
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 34 KiB |
BIN
packages/client/src/icons/provider/assets/mistral.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
packages/client/src/icons/provider/assets/mlx.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
packages/client/src/icons/provider/assets/ollama.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
packages/client/src/icons/provider/assets/openrouter.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
packages/client/src/icons/provider/assets/perplexity.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
1
packages/client/src/icons/provider/assets/qwen.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="56" style="flex: 0 0 auto; line-height: 1;" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#00055F" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
BIN
packages/client/src/icons/provider/assets/shuttleai.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
packages/client/src/icons/provider/assets/together.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
packages/client/src/icons/provider/assets/unify.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
6
packages/client/src/icons/provider/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { ProviderIcon } from './Icon';
|
||||
export { ProviderAvatar } from './Avatar';
|
||||
export { providerIcons, getProviderIconDef } from './registry';
|
||||
export type { ProviderArt, ProviderIconDef } from './registry';
|
||||
export type { ProviderIconProps } from './Icon';
|
||||
export type { ProviderAvatarProps } from './Avatar';
|
||||
71
packages/client/src/icons/provider/registry.spec.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ProviderId } from 'librechat-data-provider';
|
||||
import { getProviderIconDef, providerIcons } from './registry';
|
||||
|
||||
const packageAssets = path.resolve(__dirname, 'assets');
|
||||
|
||||
const packagedAssetFiles = [
|
||||
'anyscale.png',
|
||||
'apipie.png',
|
||||
'cohere.png',
|
||||
'deepseek.svg',
|
||||
'fireworks.png',
|
||||
'groq.png',
|
||||
'helicone.svg',
|
||||
'huggingface.svg',
|
||||
'mistral.png',
|
||||
'mlx.png',
|
||||
'ollama.png',
|
||||
'openrouter.png',
|
||||
'perplexity.png',
|
||||
'qwen.svg',
|
||||
'shuttleai.png',
|
||||
'together.png',
|
||||
'unify.webp',
|
||||
];
|
||||
|
||||
describe('providerIcons', () => {
|
||||
it('has an entry for every ProviderId', () => {
|
||||
for (const id of Object.values(ProviderId)) {
|
||||
expect(providerIcons[id]).toBeDefined();
|
||||
expect(providerIcons[id].label).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('points every asset entry at a file shipped with the package', () => {
|
||||
for (const fileName of packagedAssetFiles) {
|
||||
expect(fs.existsSync(path.join(packageAssets, fileName))).toBe(true);
|
||||
}
|
||||
for (const def of Object.values(providerIcons)) {
|
||||
if (def.art.kind === 'asset') {
|
||||
expect(def.art.src).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('marks raster art as not monochrome', () => {
|
||||
for (const def of Object.values(providerIcons)) {
|
||||
if (def.art.kind === 'asset') {
|
||||
expect(def.mono).not.toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not attach landing padding to every Cohere icon', () => {
|
||||
expect(providerIcons[ProviderId.cohere].className).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refines Google by model so Gemini and Gemma keep distinct labels', () => {
|
||||
expect(getProviderIconDef(ProviderId.google, 'gemini-2.5-pro').label).toBe('Gemini');
|
||||
expect(getProviderIconDef(ProviderId.google, 'gemma-3-27b').label).toBe('Gemma');
|
||||
expect(getProviderIconDef(ProviderId.google, 'some-other-model').label).toBe('Google');
|
||||
});
|
||||
|
||||
it('varies the OpenAI tile color by model generation', () => {
|
||||
const gpt4 = getProviderIconDef(ProviderId.openai, 'gpt-4o');
|
||||
const gpt5 = getProviderIconDef(ProviderId.openai, 'gpt-5.6');
|
||||
expect(gpt4.brandColor).toBe('var(--provider-openai-gpt4, #AB68FF)');
|
||||
expect(gpt5.brandColor).toBe('var(--provider-openai-reasoning, #000000)');
|
||||
});
|
||||
});
|
||||
132
packages/client/src/icons/provider/registry.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { ProviderId } from 'librechat-data-provider';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
import GoogleMinimalIcon from '../../svgs/GoogleMinimalIcon';
|
||||
import CustomMinimalIcon from '../../svgs/CustomMinimalIcon';
|
||||
import AzureMinimalIcon from '../../svgs/AzureMinimalIcon';
|
||||
import AnthropicIcon from '../../svgs/AnthropicIcon';
|
||||
import MoonshotIcon from '../../svgs/MoonshotIcon';
|
||||
import BedrockIcon from '../../svgs/BedrockIcon';
|
||||
import GeminiIcon from '../../svgs/GeminiIcon';
|
||||
import GPTIcon from '../../svgs/GPTIcon';
|
||||
import XAIcon from '../../svgs/XAIcon';
|
||||
|
||||
type ProviderArtComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number }>;
|
||||
|
||||
export type ProviderArt =
|
||||
| { kind: 'component'; Component: ProviderArtComponent }
|
||||
| { kind: 'asset'; src: string };
|
||||
|
||||
export interface ProviderIconDef {
|
||||
art: ProviderArt;
|
||||
label: string;
|
||||
/** Avatar tile background. Absent means the tile renders with no background. */
|
||||
brandColor?: string;
|
||||
/** Art inherits currentColor and follows the active theme. */
|
||||
mono?: boolean;
|
||||
/** Per provider layout correction, replacing the old knownEndpointClasses map. */
|
||||
className?: string;
|
||||
/** Model level refinement merged over the base definition. */
|
||||
byModel?: (model: string) => Partial<ProviderIconDef> | undefined;
|
||||
}
|
||||
|
||||
const asset = (src: string): ProviderArt => ({ kind: 'asset', src });
|
||||
|
||||
const component = (Component: ProviderArtComponent): ProviderArt => ({
|
||||
kind: 'component',
|
||||
Component,
|
||||
});
|
||||
|
||||
const openAIBrandColor = (model: string): string => {
|
||||
const value = model.toLowerCase();
|
||||
if (/\b(o\d)\b/.test(value) || /\bgpt-[5-9](?:\.\d+)?\b/.test(value)) {
|
||||
return 'var(--provider-openai-reasoning, #000000)';
|
||||
}
|
||||
return value.includes('gpt-4')
|
||||
? 'var(--provider-openai-gpt4, #AB68FF)'
|
||||
: 'var(--provider-openai, #19C37D)';
|
||||
};
|
||||
|
||||
const googleByModel = (model: string): Partial<ProviderIconDef> | undefined => {
|
||||
const value = model.toLowerCase();
|
||||
if (/gemini|learnlm/.test(value)) {
|
||||
return { art: component(GeminiIcon), mono: false, label: 'Gemini' };
|
||||
}
|
||||
if (value.includes('gemma')) {
|
||||
return { art: component(GeminiIcon), mono: false, label: 'Gemma' };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const providerIcons: Record<ProviderId, ProviderIconDef> = {
|
||||
[ProviderId.openai]: {
|
||||
art: component(GPTIcon),
|
||||
label: 'OpenAI',
|
||||
mono: true,
|
||||
brandColor: 'var(--provider-openai, #19C37D)',
|
||||
byModel: (model) => ({ brandColor: openAIBrandColor(model) }),
|
||||
},
|
||||
[ProviderId.anthropic]: {
|
||||
art: component(AnthropicIcon),
|
||||
label: 'Anthropic',
|
||||
mono: true,
|
||||
brandColor: 'var(--provider-anthropic, #d09a74)',
|
||||
},
|
||||
[ProviderId.google]: {
|
||||
art: component(GoogleMinimalIcon),
|
||||
label: 'Google',
|
||||
mono: true,
|
||||
byModel: googleByModel,
|
||||
},
|
||||
[ProviderId.azure]: {
|
||||
art: component(AzureMinimalIcon),
|
||||
label: 'Azure OpenAI',
|
||||
mono: true,
|
||||
brandColor: 'var(--provider-azure, linear-gradient(0.375turn, #61bde2, #4389d0))',
|
||||
},
|
||||
[ProviderId.bedrock]: {
|
||||
art: component(BedrockIcon),
|
||||
label: 'AWS Bedrock',
|
||||
mono: true,
|
||||
brandColor: 'var(--provider-bedrock, #268672)',
|
||||
},
|
||||
[ProviderId.xai]: { art: component(XAIcon), label: 'xAI', mono: true },
|
||||
[ProviderId.moonshot]: { art: component(MoonshotIcon), label: 'Moonshot', mono: true },
|
||||
[ProviderId.anyscale]: { art: asset('assets/anyscale.png'), label: 'Anyscale' },
|
||||
[ProviderId.apipie]: { art: asset('assets/apipie.png'), label: 'APIpie' },
|
||||
[ProviderId.cohere]: {
|
||||
art: asset('assets/cohere.png'),
|
||||
label: 'Cohere',
|
||||
},
|
||||
[ProviderId.deepseek]: { art: asset('assets/deepseek.svg'), label: 'DeepSeek' },
|
||||
[ProviderId.fireworks]: { art: asset('assets/fireworks.png'), label: 'Fireworks' },
|
||||
[ProviderId.groq]: { art: asset('assets/groq.png'), label: 'Groq' },
|
||||
[ProviderId.helicone]: { art: asset('assets/helicone.svg'), label: 'Helicone' },
|
||||
[ProviderId.huggingface]: { art: asset('assets/huggingface.svg'), label: 'Hugging Face' },
|
||||
[ProviderId.mistral]: { art: asset('assets/mistral.png'), label: 'Mistral' },
|
||||
[ProviderId.mlx]: { art: asset('assets/mlx.png'), label: 'MLX' },
|
||||
[ProviderId.ollama]: { art: asset('assets/ollama.png'), label: 'Ollama' },
|
||||
[ProviderId.openrouter]: { art: asset('assets/openrouter.png'), label: 'OpenRouter' },
|
||||
[ProviderId.perplexity]: { art: asset('assets/perplexity.png'), label: 'Perplexity' },
|
||||
[ProviderId.qwen]: { art: asset('assets/qwen.svg'), label: 'Qwen' },
|
||||
[ProviderId.shuttleai]: { art: asset('assets/shuttleai.png'), label: 'ShuttleAI' },
|
||||
[ProviderId.together]: { art: asset('assets/together.png'), label: 'Together AI' },
|
||||
[ProviderId.unify]: { art: asset('assets/unify.webp'), label: 'Unify' },
|
||||
[ProviderId.vercel]: {
|
||||
art: component(CustomMinimalIcon),
|
||||
label: 'Vercel',
|
||||
mono: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** Merges any model level refinement over the base definition for a provider. */
|
||||
export function getProviderIconDef(
|
||||
provider?: ProviderId | null,
|
||||
model?: string | null,
|
||||
): ProviderIconDef {
|
||||
const base = provider ? providerIcons[provider] : undefined;
|
||||
if (!base) {
|
||||
return { art: component(CustomMinimalIcon), label: 'Custom', mono: true };
|
||||
}
|
||||
const refinement = model ? base.byModel?.(model) : undefined;
|
||||
return refinement ? { ...base, ...refinement } : base;
|
||||
}
|
||||
|
|
@ -14,6 +14,9 @@ export * from './store';
|
|||
// SVGs
|
||||
export * from './svgs';
|
||||
|
||||
// Provider icons
|
||||
export * from './icons/provider';
|
||||
|
||||
// Utils
|
||||
export * from './utils';
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export default function AnthropicIcon({
|
|||
overflow="visible"
|
||||
width={size}
|
||||
height={size}
|
||||
className={cn('fill-current text-black', className)}
|
||||
className={cn('fill-current', className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default function BedrockIcon({
|
|||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn('fill-current text-black', className)}
|
||||
className={cn('fill-current', className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g fill="currentColor">
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ export {
|
|||
export {
|
||||
THEME_VERSION,
|
||||
defaultAppearance,
|
||||
defaultBrands,
|
||||
fromLegacyTheme,
|
||||
libreChatTheme,
|
||||
resolveTheme,
|
||||
themeAppearanceProperties,
|
||||
themeBrandTokens,
|
||||
themeColorTokens,
|
||||
validateThemeDefinition,
|
||||
} from './registry';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { ThemeDefinition } from './types';
|
||||
import {
|
||||
defaultAppearance,
|
||||
defaultBrands,
|
||||
fromLegacyTheme,
|
||||
libreChatTheme,
|
||||
resolveTheme,
|
||||
themeColorTokens,
|
||||
validateThemeDefinition,
|
||||
|
|
@ -48,6 +50,68 @@ describe('theme registry', () => {
|
|||
expect(dark.appearance).toEqual(defaultAppearance);
|
||||
});
|
||||
|
||||
it('resolves provider brand tokens and lets a theme override them', () => {
|
||||
const defaults = resolveTheme(libreChatTheme, 'light');
|
||||
expect(defaults.brands['provider-anthropic']).toBe('#d09a74');
|
||||
expect(defaults.brands['provider-openai']).toBe(defaultBrands['provider-openai']);
|
||||
|
||||
const custom = resolveTheme(
|
||||
{
|
||||
version: 1,
|
||||
name: 'white-label',
|
||||
modes: { light: {} },
|
||||
brands: { 'provider-anthropic': '#ffffff' },
|
||||
},
|
||||
'light',
|
||||
);
|
||||
expect(custom.brands['provider-anthropic']).toBe('#ffffff');
|
||||
expect(custom.brands['provider-openai']).toBe(defaultBrands['provider-openai']);
|
||||
});
|
||||
|
||||
it('rejects CSS appended to a provider gradient', () => {
|
||||
expect(
|
||||
validateThemeDefinition({
|
||||
version: 1,
|
||||
name: 'invalid',
|
||||
modes: {},
|
||||
brands: {
|
||||
'provider-azure': 'linear-gradient(#000,#000), url(https://example.com/pixel)',
|
||||
},
|
||||
}),
|
||||
).toContain(
|
||||
'Invalid brand value for provider-azure: linear-gradient(#000,#000), url(https://example.com/pixel)',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects stacked CSS after a balanced gradient', () => {
|
||||
expect(
|
||||
validateThemeDefinition({
|
||||
version: 1,
|
||||
name: 'invalid',
|
||||
modes: {},
|
||||
brands: {
|
||||
'provider-azure':
|
||||
'linear-gradient(#000,#000), -webkit-image-set("https://example.com/pixel" 1x)',
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Invalid brand value for provider-azure')]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a gradient for the provider foreground token', () => {
|
||||
expect(
|
||||
validateThemeDefinition({
|
||||
version: 1,
|
||||
name: 'invalid',
|
||||
modes: {},
|
||||
brands: {
|
||||
'provider-foreground': 'linear-gradient(#fff,#fff)',
|
||||
},
|
||||
}),
|
||||
).toContain('Invalid brand value for provider-foreground: linear-gradient(#fff,#fff)');
|
||||
});
|
||||
|
||||
it('preserves hover overrides from themes created before the composer hover token', () => {
|
||||
const storedTheme: ThemeDefinition = {
|
||||
version: 1,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
IThemeAppearance,
|
||||
IThemeBrands,
|
||||
IThemeColors,
|
||||
IThemeVariables,
|
||||
IThemeRGB,
|
||||
|
|
@ -9,7 +10,6 @@ import type {
|
|||
} from './types';
|
||||
import { defaultTheme } from './themes/default';
|
||||
import { darkTheme } from './themes/dark';
|
||||
|
||||
export const THEME_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
|
|
@ -61,6 +61,26 @@ export const defaultAppearance: IThemeAppearance = Object.freeze({
|
|||
motionNormal: '200ms',
|
||||
});
|
||||
|
||||
export const themeBrandTokens: readonly (keyof IThemeBrands)[] = Object.freeze([
|
||||
'provider-openai',
|
||||
'provider-openai-gpt4',
|
||||
'provider-openai-reasoning',
|
||||
'provider-anthropic',
|
||||
'provider-azure',
|
||||
'provider-bedrock',
|
||||
'provider-foreground',
|
||||
]);
|
||||
|
||||
export const defaultBrands: IThemeBrands = Object.freeze({
|
||||
'provider-openai': '#19C37D',
|
||||
'provider-openai-gpt4': '#AB68FF',
|
||||
'provider-openai-reasoning': '#000000',
|
||||
'provider-anthropic': '#d09a74',
|
||||
'provider-azure': 'linear-gradient(0.375turn, #61bde2, #4389d0)',
|
||||
'provider-bedrock': '#268672',
|
||||
'provider-foreground': '#ffffff',
|
||||
});
|
||||
|
||||
export const libreChatTheme: ThemeDefinition = Object.freeze({
|
||||
version: THEME_VERSION,
|
||||
name: 'librechat',
|
||||
|
|
@ -68,11 +88,35 @@ export const libreChatTheme: ThemeDefinition = Object.freeze({
|
|||
light: { colors: defaultTheme },
|
||||
dark: { colors: darkTheme },
|
||||
},
|
||||
brands: defaultBrands,
|
||||
});
|
||||
|
||||
const rgbPattern = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/;
|
||||
const cssLengthPattern = /^(0|\d*\.?\d+(px|rem|em))$/;
|
||||
const cssDurationPattern = /^\d*\.?\d+(ms|s)$/;
|
||||
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
|
||||
function isLinearGradient(value: string): boolean {
|
||||
if (!value.startsWith('linear-gradient(') || /url\s*\(|image-set/i.test(value)) {
|
||||
return false;
|
||||
}
|
||||
let depth = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const char = value[i];
|
||||
if (char === '(') {
|
||||
depth += 1;
|
||||
} else if (char === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return i === value.length - 1;
|
||||
}
|
||||
if (depth < 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const isRGB = (value: unknown): value is string => {
|
||||
if (typeof value !== 'string') {
|
||||
|
|
@ -123,7 +167,7 @@ export function validateThemeDefinition(theme: ThemeDefinition): string[] {
|
|||
}
|
||||
|
||||
Object.keys(theme).forEach((key) => {
|
||||
if (key !== 'version' && key !== 'name' && key !== 'modes') {
|
||||
if (key !== 'version' && key !== 'name' && key !== 'modes' && key !== 'brands') {
|
||||
errors.push(`Unknown theme field: ${key}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -193,6 +237,26 @@ export function validateThemeDefinition(theme: ThemeDefinition): string[] {
|
|||
}
|
||||
});
|
||||
|
||||
if (theme.brands !== undefined && !isPlainRecord(theme.brands)) {
|
||||
errors.push('Theme brands must be an object');
|
||||
} else {
|
||||
Object.entries(theme.brands ?? {}).forEach(([key, value]) => {
|
||||
if (!themeBrandTokens.includes(key as keyof IThemeBrands)) {
|
||||
errors.push(`Unknown brand token: ${key}`);
|
||||
return;
|
||||
}
|
||||
const isColorOnly = key === 'provider-foreground';
|
||||
const isValidBrand =
|
||||
typeof value === 'string' &&
|
||||
(isColorOnly
|
||||
? hexColorPattern.test(value)
|
||||
: hexColorPattern.test(value) || isLinearGradient(value));
|
||||
if (value !== undefined && !isValidBrand) {
|
||||
errors.push(`Invalid brand value for ${key}: ${value}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
|
@ -217,6 +281,7 @@ export function resolveTheme(theme: ThemeDefinition, mode: ThemeMode): ResolvedT
|
|||
mode,
|
||||
colors: { ...baseColors, ...customColors, ...composerHoverFallback } as Required<IThemeRGB>,
|
||||
appearance: { ...defaultAppearance, ...definition?.appearance },
|
||||
brands: { ...defaultBrands, ...theme.brands },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -304,11 +304,22 @@ export interface ThemeModeDefinition {
|
|||
appearance?: Partial<IThemeAppearance>;
|
||||
}
|
||||
|
||||
export interface IThemeBrands {
|
||||
'provider-openai': string;
|
||||
'provider-openai-gpt4': string;
|
||||
'provider-openai-reasoning': string;
|
||||
'provider-anthropic': string;
|
||||
'provider-azure': string;
|
||||
'provider-bedrock': string;
|
||||
'provider-foreground': string;
|
||||
}
|
||||
|
||||
/** Versioned, data-only theme input. Missing values resolve against LibreChat defaults. */
|
||||
export interface ThemeDefinition {
|
||||
version: 1;
|
||||
name: string;
|
||||
modes: Partial<Record<ThemeMode, ThemeModeDefinition>>;
|
||||
brands?: Partial<IThemeBrands>;
|
||||
}
|
||||
|
||||
export interface ResolvedThemeDefinition {
|
||||
|
|
@ -317,4 +328,5 @@ export interface ResolvedThemeDefinition {
|
|||
mode: ThemeMode;
|
||||
colors: Required<IThemeRGB>;
|
||||
appearance: IThemeAppearance;
|
||||
brands: IThemeBrands;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,4 +143,20 @@ describe('applyTheme', () => {
|
|||
expect(root.style.getPropertyValue('--markdown-font-size')).toBe('18px');
|
||||
root.style.removeProperty('--markdown-font-size');
|
||||
});
|
||||
|
||||
it('applies provider brand backgrounds from the theme', () => {
|
||||
applyResolvedTheme(
|
||||
resolveTheme(
|
||||
{
|
||||
version: 1,
|
||||
name: 'white-label',
|
||||
modes: { light: {} },
|
||||
brands: { 'provider-openai': '#123456' },
|
||||
},
|
||||
'light',
|
||||
),
|
||||
);
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue('--provider-openai')).toBe('#123456');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import type { IThemeAppearance, IThemeRGB, ResolvedThemeDefinition } from '../types';
|
||||
import { themeAppearanceProperties, themeColorTokens } from '../registry';
|
||||
import type { IThemeAppearance, IThemeBrands, IThemeRGB, ResolvedThemeDefinition } from '../types';
|
||||
import { themeAppearanceProperties, themeBrandTokens, themeColorTokens } from '../registry';
|
||||
|
||||
const colorProperty = (token: keyof IThemeRGB): `--${string}` => `--${token.slice(4)}`;
|
||||
const brandProperty = (token: keyof IThemeBrands): `--${string}` => `--${token}`;
|
||||
|
||||
export const themeOwnedProperties: readonly string[] = Object.freeze([
|
||||
...themeColorTokens.map(colorProperty),
|
||||
...Object.values(themeAppearanceProperties),
|
||||
...themeBrandTokens.map(brandProperty),
|
||||
]);
|
||||
|
||||
const rgbPattern = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})$/;
|
||||
|
|
@ -50,7 +52,13 @@ export function applyResolvedTheme(
|
|||
theme: ResolvedThemeDefinition,
|
||||
root: HTMLElement = document.documentElement,
|
||||
): void {
|
||||
const variables = [...mapColors(theme.colors), ...mapAppearance(theme.appearance)];
|
||||
const variables = [
|
||||
...mapColors(theme.colors),
|
||||
...mapAppearance(theme.appearance),
|
||||
...themeBrandTokens.map(
|
||||
(token) => [brandProperty(token), theme.brands[token]] as [string, string],
|
||||
),
|
||||
];
|
||||
|
||||
variables.forEach(([property, value]) => root.style.setProperty(property, value));
|
||||
root.dataset.theme = theme.name;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export default defineConfig({
|
|||
// CommonJS (jest.config.js / babel.config.js are CJS) while still shipping dual ESM/CJS.
|
||||
fixedExtension: true,
|
||||
define,
|
||||
copy: [{ from: 'src/icons/provider/assets', to: 'dist' }],
|
||||
// Extract all component CSS into a single `dist/style.css` (no import left in the JS, so the
|
||||
// CJS output stays valid CommonJS). Consumers import `@librechat/client/style.css` once.
|
||||
css: { inject: false },
|
||||
|
|
|
|||
80
packages/data-provider/specs/providers.spec.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
ProviderId,
|
||||
resolveProviderId,
|
||||
endpointToProvider,
|
||||
knownEndpointToProvider,
|
||||
} from '../src/providers';
|
||||
import { EModelEndpoint } from '../src/schemas';
|
||||
import { KnownEndpoints } from '../src/config';
|
||||
|
||||
describe('ProviderId', () => {
|
||||
it('declares every value as its own lowercase key', () => {
|
||||
for (const [key, value] of Object.entries(ProviderId)) {
|
||||
expect(value).toBe(key);
|
||||
expect(value).toBe(value.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it('contains no dots, so together.ai is together', () => {
|
||||
for (const value of Object.values(ProviderId)) {
|
||||
expect(value).not.toContain('.');
|
||||
}
|
||||
expect(ProviderId.together).toBe('together');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProviderId', () => {
|
||||
it('resolves an exact id', () => {
|
||||
expect(resolveProviderId('openrouter')).toBe(ProviderId.openrouter);
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(resolveProviderId('OpenRouter')).toBe(ProviderId.openrouter);
|
||||
});
|
||||
|
||||
it('ignores spaces, dots, dashes and underscores', () => {
|
||||
expect(resolveProviderId('together.ai')).toBe(ProviderId.together);
|
||||
expect(resolveProviderId('Together AI')).toBe(ProviderId.together);
|
||||
expect(resolveProviderId('hugging-face')).toBe(ProviderId.huggingface);
|
||||
expect(resolveProviderId('open_router')).toBe(ProviderId.openrouter);
|
||||
});
|
||||
|
||||
it('resolves brand aliases to their provider', () => {
|
||||
expect(resolveProviderId('claude')).toBe(ProviderId.anthropic);
|
||||
expect(resolveProviderId('gemini')).toBe(ProviderId.google);
|
||||
expect(resolveProviderId('vertexai')).toBe(ProviderId.google);
|
||||
expect(resolveProviderId('grok')).toBe(ProviderId.xai);
|
||||
expect(resolveProviderId('kimi')).toBe(ProviderId.moonshot);
|
||||
});
|
||||
|
||||
it('returns null for empty and unknown input', () => {
|
||||
expect(resolveProviderId('')).toBeNull();
|
||||
expect(resolveProviderId(null)).toBeNull();
|
||||
expect(resolveProviderId(undefined)).toBeNull();
|
||||
expect(resolveProviderId('totally-unknown-vendor')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapping tables', () => {
|
||||
it('maps the five branded first-class endpoints', () => {
|
||||
expect(endpointToProvider[EModelEndpoint.openAI]).toBe(ProviderId.openai);
|
||||
expect(endpointToProvider[EModelEndpoint.azureOpenAI]).toBe(ProviderId.azure);
|
||||
expect(endpointToProvider[EModelEndpoint.anthropic]).toBe(ProviderId.anthropic);
|
||||
expect(endpointToProvider[EModelEndpoint.google]).toBe(ProviderId.google);
|
||||
expect(endpointToProvider[EModelEndpoint.bedrock]).toBe(ProviderId.bedrock);
|
||||
});
|
||||
|
||||
it('leaves entity endpoints unmapped, since they render entity avatars', () => {
|
||||
expect(endpointToProvider[EModelEndpoint.agents]).toBeUndefined();
|
||||
expect(endpointToProvider[EModelEndpoint.assistants]).toBeUndefined();
|
||||
expect(endpointToProvider[EModelEndpoint.azureAssistants]).toBeUndefined();
|
||||
expect(endpointToProvider[EModelEndpoint.custom]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('covers every KnownEndpoints value', () => {
|
||||
for (const known of Object.values(KnownEndpoints)) {
|
||||
expect(knownEndpointToProvider[known]).toBeDefined();
|
||||
}
|
||||
expect(knownEndpointToProvider[KnownEndpoints['together.ai']]).toBe(ProviderId.together);
|
||||
});
|
||||
});
|
||||
|
|
@ -53,6 +53,8 @@ export {
|
|||
export { default as request } from './request';
|
||||
export { dataService };
|
||||
import * as dataService from './data-service';
|
||||
/* provider identity */
|
||||
export * from './providers';
|
||||
/* general helpers */
|
||||
export * from './utils';
|
||||
export * from './actions';
|
||||
|
|
|
|||
98
packages/data-provider/src/providers.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { EModelEndpoint } from './schemas';
|
||||
import { KnownEndpoints } from './config';
|
||||
|
||||
/** Canonical provider identity used for branding across client and server. */
|
||||
export enum ProviderId {
|
||||
openai = 'openai',
|
||||
anthropic = 'anthropic',
|
||||
google = 'google',
|
||||
azure = 'azure',
|
||||
bedrock = 'bedrock',
|
||||
xai = 'xai',
|
||||
moonshot = 'moonshot',
|
||||
anyscale = 'anyscale',
|
||||
apipie = 'apipie',
|
||||
cohere = 'cohere',
|
||||
deepseek = 'deepseek',
|
||||
fireworks = 'fireworks',
|
||||
groq = 'groq',
|
||||
helicone = 'helicone',
|
||||
huggingface = 'huggingface',
|
||||
mistral = 'mistral',
|
||||
mlx = 'mlx',
|
||||
ollama = 'ollama',
|
||||
openrouter = 'openrouter',
|
||||
perplexity = 'perplexity',
|
||||
qwen = 'qwen',
|
||||
shuttleai = 'shuttleai',
|
||||
together = 'together',
|
||||
unify = 'unify',
|
||||
vercel = 'vercel',
|
||||
}
|
||||
|
||||
export const endpointToProvider: Partial<Record<EModelEndpoint, ProviderId>> = {
|
||||
[EModelEndpoint.openAI]: ProviderId.openai,
|
||||
[EModelEndpoint.azureOpenAI]: ProviderId.azure,
|
||||
[EModelEndpoint.anthropic]: ProviderId.anthropic,
|
||||
[EModelEndpoint.google]: ProviderId.google,
|
||||
[EModelEndpoint.bedrock]: ProviderId.bedrock,
|
||||
};
|
||||
|
||||
export const knownEndpointToProvider: Record<KnownEndpoints, ProviderId> = {
|
||||
[KnownEndpoints.anyscale]: ProviderId.anyscale,
|
||||
[KnownEndpoints.apipie]: ProviderId.apipie,
|
||||
[KnownEndpoints.cohere]: ProviderId.cohere,
|
||||
[KnownEndpoints.fireworks]: ProviderId.fireworks,
|
||||
[KnownEndpoints.deepseek]: ProviderId.deepseek,
|
||||
[KnownEndpoints.moonshot]: ProviderId.moonshot,
|
||||
[KnownEndpoints.groq]: ProviderId.groq,
|
||||
[KnownEndpoints.helicone]: ProviderId.helicone,
|
||||
[KnownEndpoints.huggingface]: ProviderId.huggingface,
|
||||
[KnownEndpoints.mistral]: ProviderId.mistral,
|
||||
[KnownEndpoints.mlx]: ProviderId.mlx,
|
||||
[KnownEndpoints.ollama]: ProviderId.ollama,
|
||||
[KnownEndpoints.openrouter]: ProviderId.openrouter,
|
||||
[KnownEndpoints.perplexity]: ProviderId.perplexity,
|
||||
[KnownEndpoints.shuttleai]: ProviderId.shuttleai,
|
||||
[KnownEndpoints['together.ai']]: ProviderId.together,
|
||||
[KnownEndpoints.unify]: ProviderId.unify,
|
||||
[KnownEndpoints.vercel]: ProviderId.vercel,
|
||||
[KnownEndpoints.xai]: ProviderId.xai,
|
||||
};
|
||||
|
||||
const providerAliases: Record<string, ProviderId> = {
|
||||
chatgpt: ProviderId.openai,
|
||||
gpt: ProviderId.openai,
|
||||
azureopenai: ProviderId.azure,
|
||||
claude: ProviderId.anthropic,
|
||||
gemini: ProviderId.google,
|
||||
gemma: ProviderId.google,
|
||||
vertex: ProviderId.google,
|
||||
vertexai: ProviderId.google,
|
||||
palm: ProviderId.google,
|
||||
awsbedrock: ProviderId.bedrock,
|
||||
grok: ProviderId.xai,
|
||||
kimi: ProviderId.moonshot,
|
||||
moonshotai: ProviderId.moonshot,
|
||||
mistralai: ProviderId.mistral,
|
||||
togetherai: ProviderId.together,
|
||||
};
|
||||
|
||||
const normalize = (input: string): string => input.toLowerCase().replace(/[\s._-]/g, '');
|
||||
|
||||
const providerByNormalizedId = Object.values(ProviderId).reduce<Record<string, ProviderId>>(
|
||||
(acc, id) => {
|
||||
acc[normalize(id)] = id;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
/** Resolves free-form provider text to a canonical id, ignoring case and separators. */
|
||||
export function resolveProviderId(input?: string | null): ProviderId | null {
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
const key = normalize(input);
|
||||
return providerByNormalizedId[key] ?? providerAliases[key] ?? null;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import type { RefillIntervalUnit } from './balance';
|
|||
import type { SettingDefinition } from './generate';
|
||||
import type { TMinimalFeedback } from './feedback';
|
||||
import type { ContentTypes } from './types/runs';
|
||||
import type { ProviderId } from './providers';
|
||||
|
||||
export * from './schemas';
|
||||
export * from './types/subagents';
|
||||
|
|
@ -534,6 +535,8 @@ export type TConfig = {
|
|||
plugins?: Record<string, string>;
|
||||
name?: string;
|
||||
iconURL?: string;
|
||||
/** Canonical provider identity resolved at config load, used for branding. */
|
||||
providerId?: ProviderId;
|
||||
version?: string;
|
||||
modelDisplayLabel?: string;
|
||||
userProvide?: boolean | null;
|
||||
|
|
|
|||