diff --git a/client/src/components/Agents/AgentCard.tsx b/client/src/components/Agents/AgentCard.tsx index 7e9dd6da10..fdf737ac85 100644 --- a/client/src/components/Agents/AgentCard.tsx +++ b/client/src/components/Agents/AgentCard.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react'; import { Label, OGDialog, OGDialogTrigger } from '@librechat/client'; import type t from 'librechat-data-provider'; import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks'; -import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils'; +import { cn, AgentAvatar, getContactDisplayName } from '~/utils'; import AgentDetailContent from './AgentDetailContent'; interface AgentCardProps { @@ -78,7 +78,7 @@ const AgentCard: React.FC = ({ agent, onSelect, className = '' } {/* Avatar */}
- {renderAgentAvatar(agent, { size: 'sm', showBorder: false })} +
diff --git a/client/src/components/Agents/AgentDetail.tsx b/client/src/components/Agents/AgentDetail.tsx index 1820eed8b9..b87424a07d 100644 --- a/client/src/components/Agents/AgentDetail.tsx +++ b/client/src/components/Agents/AgentDetail.tsx @@ -12,7 +12,7 @@ import { } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks'; -import { renderAgentAvatar, clearMessagesCache } from '~/utils'; +import { AgentAvatar, clearMessagesCache } from '~/utils'; import { useChatContext } from '~/Providers'; interface SupportContact { @@ -142,7 +142,9 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => !open && onClose()}> {/* Agent avatar */} -
{renderAgentAvatar(agent, { size: 'xl' })}
+
+ +
{/* Agent name */}
diff --git a/client/src/components/Agents/AgentDetailContent.tsx b/client/src/components/Agents/AgentDetailContent.tsx index 1e06d8230f..d76524c4ae 100644 --- a/client/src/components/Agents/AgentDetailContent.tsx +++ b/client/src/components/Agents/AgentDetailContent.tsx @@ -12,7 +12,7 @@ import { } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks'; -import { renderAgentAvatar, clearMessagesCache } from '~/utils'; +import { AgentAvatar, clearMessagesCache } from '~/utils'; import { useChatContext } from '~/Providers'; interface SupportContact { @@ -140,7 +140,9 @@ const AgentDetailContent: React.FC = ({ agent }) => { return ( {/* Agent avatar */} -
{renderAgentAvatar(agent, { size: 'xl' })}
+
+ +
{/* Agent name */}
diff --git a/client/src/components/Agents/tests/Accessibility.spec.tsx b/client/src/components/Agents/tests/Accessibility.spec.tsx index 8d9a02a982..a06c559067 100644 --- a/client/src/components/Agents/tests/Accessibility.spec.tsx +++ b/client/src/components/Agents/tests/Accessibility.spec.tsx @@ -126,7 +126,7 @@ jest.mock('~/data-provider/Agents', () => ({ // Mock utility functions jest.mock('~/utils/agents', () => ({ - renderAgentAvatar: jest.fn(() =>
), + AgentAvatar: jest.fn(() =>
), getContactDisplayName: jest.fn((agent) => agent.authorName), })); diff --git a/client/src/components/Agents/tests/AgentDetail.spec.tsx b/client/src/components/Agents/tests/AgentDetail.spec.tsx index 0a1afffea7..5ce1418438 100644 --- a/client/src/components/Agents/tests/AgentDetail.spec.tsx +++ b/client/src/components/Agents/tests/AgentDetail.spec.tsx @@ -46,9 +46,7 @@ jest.mock('@librechat/client', () => ({ })); jest.mock('~/utils/agents', () => ({ - renderAgentAvatar: jest.fn((agent, options) => ( -
- )), + AgentAvatar: jest.fn(({ agent, size }) =>
), })); jest.mock('~/Providers', () => ({ diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 108755a291..3b56a4f2e2 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -2,8 +2,9 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react' import { useRecoilValue } from 'recoil'; import { useParams } from 'react-router-dom'; import { Constants } from 'librechat-data-provider'; -import { useToastContext, useMediaQuery } from '@librechat/client'; +import { useToastContext, useMediaQuery, Spinner } from '@librechat/client'; import type { TConversation } from 'librechat-data-provider'; +import { useAgentsMapContext } from '~/Providers/AgentsMapContext'; import { useUpdateConversationMutation } from '~/data-provider'; import EndpointIcon from '~/components/Endpoints/EndpointIcon'; import { useNavigateToConvo, useLocalize, useShiftKey } from '~/hooks'; @@ -31,6 +32,7 @@ export default function Conversation({ const params = useParams(); const localize = useLocalize(); const { showToast } = useToastContext(); + const agentsMap = useAgentsMapContext(); const { navigateToConvo } = useNavigateToConvo(); const { data: endpointsConfig } = useGetEndpointsQuery(); const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]); @@ -236,30 +238,12 @@ export default function Conversation({ localize={localize} > {isGenerating ? ( - - - - + ) : ( diff --git a/client/src/components/Endpoints/EndpointIcon.tsx b/client/src/components/Endpoints/EndpointIcon.tsx index c32ea12369..ba2cbf6993 100644 --- a/client/src/components/Endpoints/EndpointIcon.tsx +++ b/client/src/components/Endpoints/EndpointIcon.tsx @@ -1,19 +1,21 @@ -import { getEndpointField, isAssistantsEndpoint } from 'librechat-data-provider'; +import { getEndpointField, isAssistantsEndpoint, EModelEndpoint } from 'librechat-data-provider'; import type { TPreset, + TAgentsMap, TConversation, TAssistantsMap, TEndpointsConfig, } from 'librechat-data-provider'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; -import { getIconEndpoint } from '~/utils'; +import { getIconEndpoint, getAgentAvatarUrl } from '~/utils'; export default function EndpointIcon({ conversation, endpointsConfig, className = 'mr-0', assistantMap, + agentsMap, context, }: { conversation: TConversation | TPreset | null; @@ -21,6 +23,7 @@ export default function EndpointIcon({ containerClassName?: string; context?: 'message' | 'nav' | 'landing' | 'menu-item'; assistantMap?: TAssistantsMap; + agentsMap?: TAgentsMap; className?: string; size?: number; }) { @@ -37,7 +40,13 @@ export default function EndpointIcon({ const assistantAvatar = (assistant && (assistant.metadata?.avatar as string)) || ''; const assistantName = assistant && (assistant.name ?? ''); - const iconURL = assistantAvatar || convoIconURL; + const agent = + endpoint === EModelEndpoint.agents && conversation?.agent_id + ? agentsMap?.[conversation.agent_id] + : null; + const agentAvatar = getAgentAvatarUrl(agent) ?? ''; + + const iconURL = assistantAvatar || agentAvatar || convoIconURL; if (iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'))) { return ( diff --git a/client/src/components/Endpoints/URLIcon.tsx b/client/src/components/Endpoints/URLIcon.tsx index ddb62aaded..366289a6e4 100644 --- a/client/src/components/Endpoints/URLIcon.tsx +++ b/client/src/components/Endpoints/URLIcon.tsx @@ -1,6 +1,8 @@ -import React, { memo, useState } from 'react'; +import React, { memo, useState, useEffect } from 'react'; import { AlertCircle } from 'lucide-react'; +import { Skeleton } from '@librechat/client'; import { icons } from '~/hooks/Endpoint/Icons'; +import { isImageCached } from '~/utils'; export const URLIcon = memo( ({ @@ -19,9 +21,21 @@ export const URLIcon = memo( endpoint?: string; }) => { const [imageError, setImageError] = useState(false); + const [isLoaded, setIsLoaded] = useState(() => isImageCached(iconURL)); + + useEffect(() => { + if (isImageCached(iconURL)) { + setIsLoaded(true); + setImageError(false); + } else { + setIsLoaded(false); + setImageError(false); + } + }, [iconURL]); const handleImageError = () => { setImageError(true); + setIsLoaded(false); }; const DefaultIcon: React.ElementType = @@ -46,18 +60,29 @@ export const URLIcon = memo( } return ( -
+
{altName setIsLoaded(true)} onError={handleImageError} - loading="lazy" decoding="async" width={Number(containerStyle.width) || 20} height={Number(containerStyle.height) || 20} /> + {!isLoaded && !imageError && ( +
); }, diff --git a/client/src/components/Nav/Favorites/FavoriteItem.tsx b/client/src/components/Nav/Favorites/FavoriteItem.tsx index 248008869d..365eae2da9 100644 --- a/client/src/components/Nav/Favorites/FavoriteItem.tsx +++ b/client/src/components/Nav/Favorites/FavoriteItem.tsx @@ -7,7 +7,7 @@ import type { FavoriteModel } from '~/store/favorites'; import type t from 'librechat-data-provider'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; import { useFavorites, useLocalize } from '~/hooks'; -import { renderAgentAvatar, cn } from '~/utils'; +import { AgentAvatar, cn } from '~/utils'; type Kwargs = { model?: string; @@ -73,7 +73,7 @@ export default function FavoriteItem({ const renderIcon = () => { if (type === 'agent') { - return renderAgentAvatar(item as t.Agent, { size: 'icon', className: 'mr-2' }); + return ; } const model = item as FavoriteModel; return ( diff --git a/client/src/utils/__tests__/agents.spec.tsx b/client/src/utils/__tests__/agents.spec.tsx index dee58cdf84..30590863fb 100644 --- a/client/src/utils/__tests__/agents.spec.tsx +++ b/client/src/utils/__tests__/agents.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { getAgentAvatarUrl, renderAgentAvatar, getContactDisplayName } from '../agents'; +import { getAgentAvatarUrl, AgentAvatar, getContactDisplayName } from '../agents'; import type t from 'librechat-data-provider'; // Mock the Feather icon from lucide-react @@ -61,7 +61,7 @@ describe('Agent Utilities', () => { }); }); - describe('renderAgentAvatar', () => { + describe('AgentAvatar', () => { it('should render image when avatar URL exists', () => { const agent = { id: '1', @@ -69,7 +69,7 @@ describe('Agent Utilities', () => { avatar: '/test-avatar.png', } as unknown as t.Agent; - render(
{renderAgentAvatar(agent)}
); + render(); const img = screen.getByAltText('Test Agent avatar'); expect(img).toBeInTheDocument(); @@ -83,7 +83,7 @@ describe('Agent Utilities', () => { name: 'Test Agent', } as t.Agent; - render(
{renderAgentAvatar(agent)}
); + render(); const featherIcon = screen.getByTestId('feather-icon'); expect(featherIcon).toBeInTheDocument(); @@ -97,13 +97,13 @@ describe('Agent Utilities', () => { avatar: '/test-avatar.png', } as unknown as t.Agent; - const { rerender } = render(
{renderAgentAvatar(agent, { size: 'sm' })}
); + const { rerender } = render(); expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-12', 'w-12'); - rerender(
{renderAgentAvatar(agent, { size: 'lg' })}
); + rerender(); expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-20', 'w-20'); - rerender(
{renderAgentAvatar(agent, { size: 'xl' })}
); + rerender(); expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-24', 'w-24'); }); @@ -114,7 +114,7 @@ describe('Agent Utilities', () => { avatar: '/test-avatar.png', } as unknown as t.Agent; - render(
{renderAgentAvatar(agent, { className: 'custom-class' })}
); + render(); const container = screen.getByAltText('Test Agent avatar').parentElement; expect(container).toHaveClass('custom-class'); @@ -127,10 +127,10 @@ describe('Agent Utilities', () => { avatar: '/test-avatar.png', } as unknown as t.Agent; - const { rerender } = render(
{renderAgentAvatar(agent, { showBorder: true })}
); + const { rerender } = render(); expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-1'); - rerender(
{renderAgentAvatar(agent, { showBorder: false })}
); + rerender(); expect(screen.getByAltText('Test Agent avatar')).not.toHaveClass('border-1'); }); }); diff --git a/client/src/utils/agents.tsx b/client/src/utils/agents.tsx index e83a94c1aa..1951f7549c 100644 --- a/client/src/utils/agents.tsx +++ b/client/src/utils/agents.tsx @@ -3,6 +3,19 @@ import { Feather } from 'lucide-react'; import { Skeleton } from '@librechat/client'; import type t from 'librechat-data-provider'; +/** + * Checks if an image is already cached in the browser + * Returns true if image is complete and has valid dimensions + */ +export const isImageCached = (url: string | null | undefined): boolean => { + if (typeof window === 'undefined' || !url) { + return false; + } + const img = new Image(); + img.src = url; + return img.complete && img.naturalWidth > 0; +}; + /** * Extracts the avatar URL from an agent's avatar property * Handles both string and object formats @@ -32,10 +45,17 @@ const LazyAgentAvatar = ({ alt: string; imgClass: string; }) => { - const [isLoaded, setIsLoaded] = useState(false); + const [isLoaded, setIsLoaded] = useState(() => isImageCached(url)); + const [hasError, setHasError] = useState(false); useEffect(() => { - setIsLoaded(false); + if (isImageCached(url)) { + setIsLoaded(true); + setHasError(false); + } else { + setIsLoaded(false); + setHasError(false); + } }, [url]); return ( @@ -44,15 +64,19 @@ const LazyAgentAvatar = ({ src={url} alt={alt} className={imgClass} - loading="lazy" onLoad={() => setIsLoaded(true)} - onError={() => setIsLoaded(false)} + onError={() => { + setIsLoaded(false); + setHasError(true); + }} style={{ opacity: isLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out', }} /> - {!isLoaded &&