mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
Merge b40123f607 into 8ed0bcf5ca
This commit is contained in:
commit
8548e1043c
11 changed files with 110 additions and 65 deletions
|
|
@ -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<AgentCardProps> = ({ agent, onSelect, className = '' }
|
|||
{/* Avatar */}
|
||||
<div className="flex-shrink-0 self-center">
|
||||
<div className="overflow-hidden rounded-full shadow-[0_0_15px_rgba(0,0,0,0.3)] dark:shadow-[0_0_15px_rgba(0,0,0,0.5)]">
|
||||
{renderAgentAvatar(agent, { size: 'sm', showBorder: false })}
|
||||
<AgentAvatar agent={agent} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
|||
<OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<OGDialogContent ref={dialogRef} className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
|
||||
{/* Agent avatar */}
|
||||
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<AgentAvatar agent={agent} size="xl" />
|
||||
</div>
|
||||
|
||||
{/* Agent name */}
|
||||
<div className="mt-3 text-center">
|
||||
|
|
|
|||
|
|
@ -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<AgentDetailContentProps> = ({ agent }) => {
|
|||
return (
|
||||
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
|
||||
{/* Agent avatar */}
|
||||
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<AgentAvatar agent={agent} size="xl" />
|
||||
</div>
|
||||
|
||||
{/* Agent name */}
|
||||
<div className="mt-3 text-center">
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ jest.mock('~/data-provider/Agents', () => ({
|
|||
|
||||
// Mock utility functions
|
||||
jest.mock('~/utils/agents', () => ({
|
||||
renderAgentAvatar: jest.fn(() => <div data-testid="agent-avatar" />),
|
||||
AgentAvatar: jest.fn(() => <div data-testid="agent-avatar" />),
|
||||
getContactDisplayName: jest.fn((agent) => agent.authorName),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -46,9 +46,7 @@ jest.mock('@librechat/client', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('~/utils/agents', () => ({
|
||||
renderAgentAvatar: jest.fn((agent, options) => (
|
||||
<div data-testid="agent-avatar" data-size={options?.size} />
|
||||
)),
|
||||
AgentAvatar: jest.fn(({ agent, size }) => <div data-testid="agent-avatar" data-size={size} />),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<svg
|
||||
className="h-5 w-5 flex-shrink-0 animate-spin text-text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-label={localize('com_ui_generating')}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
<EndpointIcon
|
||||
conversation={conversation}
|
||||
endpointsConfig={endpointsConfig}
|
||||
agentsMap={agentsMap}
|
||||
size={20}
|
||||
context="menu-item"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={className} style={containerStyle}>
|
||||
<div className={`${className} relative`} style={containerStyle}>
|
||||
<img
|
||||
src={iconURL}
|
||||
alt={altName ?? 'Icon'}
|
||||
style={imageStyle}
|
||||
style={{
|
||||
...imageStyle,
|
||||
opacity: isLoaded ? 1 : 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
}}
|
||||
className="object-cover"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
onError={handleImageError}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={Number(containerStyle.width) || 20}
|
||||
height={Number(containerStyle.height) || 20}
|
||||
/>
|
||||
{!isLoaded && !imageError && (
|
||||
<Skeleton
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{ width: containerStyle.width, height: containerStyle.height }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 <AgentAvatar agent={item as t.Agent} size="icon" className="mr-2" />;
|
||||
}
|
||||
const model = item as FavoriteModel;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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(<div>{renderAgentAvatar(agent)}</div>);
|
||||
render(<AgentAvatar agent={agent} />);
|
||||
|
||||
const img = screen.getByAltText('Test Agent avatar');
|
||||
expect(img).toBeInTheDocument();
|
||||
|
|
@ -83,7 +83,7 @@ describe('Agent Utilities', () => {
|
|||
name: 'Test Agent',
|
||||
} as t.Agent;
|
||||
|
||||
render(<div>{renderAgentAvatar(agent)}</div>);
|
||||
render(<AgentAvatar agent={agent} />);
|
||||
|
||||
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(<div>{renderAgentAvatar(agent, { size: 'sm' })}</div>);
|
||||
const { rerender } = render(<AgentAvatar agent={agent} size="sm" />);
|
||||
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-12', 'w-12');
|
||||
|
||||
rerender(<div>{renderAgentAvatar(agent, { size: 'lg' })}</div>);
|
||||
rerender(<AgentAvatar agent={agent} size="lg" />);
|
||||
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('h-20', 'w-20');
|
||||
|
||||
rerender(<div>{renderAgentAvatar(agent, { size: 'xl' })}</div>);
|
||||
rerender(<AgentAvatar agent={agent} size="xl" />);
|
||||
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(<div>{renderAgentAvatar(agent, { className: 'custom-class' })}</div>);
|
||||
render(<AgentAvatar agent={agent} className="custom-class" />);
|
||||
|
||||
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(<div>{renderAgentAvatar(agent, { showBorder: true })}</div>);
|
||||
const { rerender } = render(<AgentAvatar agent={agent} showBorder={true} />);
|
||||
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-1');
|
||||
|
||||
rerender(<div>{renderAgentAvatar(agent, { showBorder: false })}</div>);
|
||||
rerender(<AgentAvatar agent={agent} showBorder={false} />);
|
||||
expect(screen.getByAltText('Test Agent avatar')).not.toHaveClass('border-1');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && <Skeleton className="absolute inset-0 rounded-full" aria-hidden="true" />}
|
||||
{!isLoaded && !hasError && (
|
||||
<Skeleton className="absolute inset-0 rounded-full" aria-hidden="true" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -61,16 +85,17 @@ const LazyAgentAvatar = ({
|
|||
* Renders an agent avatar with fallback to Bot icon
|
||||
* Consistent across all agent displays
|
||||
*/
|
||||
export const renderAgentAvatar = (
|
||||
agent: t.Agent | null | undefined,
|
||||
options: {
|
||||
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
showBorder?: boolean;
|
||||
} = {},
|
||||
): React.ReactElement => {
|
||||
const { size = 'md', className = '', showBorder = true } = options;
|
||||
|
||||
export const AgentAvatar = ({
|
||||
agent,
|
||||
size = 'md',
|
||||
className = '',
|
||||
showBorder = true,
|
||||
}: {
|
||||
agent: t.Agent | null | undefined;
|
||||
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
showBorder?: boolean;
|
||||
}) => {
|
||||
const avatarUrl = getAgentAvatarUrl(agent);
|
||||
|
||||
// Size mappings for responsive design
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue