{children} diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index a134379497..a4d72d081e 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import { motion } from 'framer-motion'; +import { useRecoilValue } from 'recoil'; import { TooltipAnchor } from '@librechat/client'; import { MessageCircleDashed } from 'lucide-react'; import { useRecoilState, useRecoilCallback } from 'recoil'; -import { useChatContext } from '~/Providers'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -11,15 +10,8 @@ import store from '~/store'; export function TemporaryChat() { const localize = useLocalize(); const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary); - const { conversation, isSubmitting } = useChatContext(); - - const temporaryBadge = { - id: 'temporary', - icon: MessageCircleDashed, - label: 'com_ui_temporary' as const, - atom: store.isTemporary, - isAvailable: true, - }; + const conversation = useRecoilValue(store.conversationByIndex(0)); + const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); const handleBadgeToggle = useRecoilCallback( () => () => { @@ -38,24 +30,20 @@ export function TemporaryChat() { return (
- {temporaryBadge.icon && ( - - )} +
+
{isSearchLoading ? (
- Loading... + {localize('com_ui_loading')}
) : (
{({ width, height }) => ( } + ref={containerRef} width={width} height={height} deferredMeasurementCache={cache} @@ -196,12 +378,13 @@ const Conversations: FC = ({ rowHeight={getRowHeight} rowRenderer={rowRenderer} overscanRowCount={10} + aria-readonly={false} className="outline-none" - style={{ outline: 'none' }} - role="list" aria-label="Conversations" onRowsRendered={handleRowsRendered} tabIndex={-1} + style={{ outline: 'none', scrollbarGutter: 'stable' }} + containerRole="rowgroup" /> )} diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 190cef2a4e..108755a291 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +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'; @@ -6,7 +6,7 @@ import { useToastContext, useMediaQuery } from '@librechat/client'; import type { TConversation } from 'librechat-data-provider'; import { useUpdateConversationMutation } from '~/data-provider'; import EndpointIcon from '~/components/Endpoints/EndpointIcon'; -import { useNavigateToConvo, useLocalize } from '~/hooks'; +import { useNavigateToConvo, useLocalize, useShiftKey } from '~/hooks'; import { useGetEndpointsQuery } from '~/data-provider'; import { NotificationSeverity } from '~/common'; import { ConvoOptions } from './ConvoOptions'; @@ -19,9 +19,15 @@ interface ConversationProps { conversation: TConversation; retainView: () => void; toggleNav: () => void; + isGenerating?: boolean; } -export default function Conversation({ conversation, retainView, toggleNav }: ConversationProps) { +export default function Conversation({ + conversation, + retainView, + toggleNav, + isGenerating = false, +}: ConversationProps) { const params = useParams(); const localize = useLocalize(); const { showToast } = useToastContext(); @@ -31,13 +37,17 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? ''); const activeConvos = useRecoilValue(store.allConversationsSelector); const isSmallScreen = useMediaQuery('(max-width: 768px)'); + const isShiftHeld = useShiftKey(); const { conversationId, title = '' } = conversation; const [titleInput, setTitleInput] = useState(title || ''); const [renaming, setRenaming] = useState(false); const [isPopoverActive, setIsPopoverActive] = useState(false); + // Lazy-load ConvoOptions to avoid running heavy hooks for all conversations + const [hasInteracted, setHasInteracted] = useState(false); const previousTitle = useRef(title); + const containerRef = useRef(null); useEffect(() => { if (title !== previousTitle.current) { @@ -94,6 +104,43 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co setRenaming(false); }; + const handleMouseEnter = useCallback(() => { + if (!hasInteracted) { + setHasInteracted(true); + } + }, [hasInteracted]); + + const handleMouseLeave = useCallback(() => { + if (!isPopoverActive) { + setHasInteracted(false); + } + }, [isPopoverActive]); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + // Don't reset if focus is moving to a child element within this container + if (e.currentTarget.contains(e.relatedTarget as Node)) { + return; + } + if (!isPopoverActive) { + setHasInteracted(false); + } + }, + [isPopoverActive], + ); + + const handlePopoverOpenChange = useCallback((open: boolean) => { + setIsPopoverActive(open); + if (!open) { + requestAnimationFrame(() => { + const container = containerRef.current; + if (container && !container.contains(document.activeElement)) { + setHasInteracted(false); + } + }); + } + }, []); + const handleNavigation = (ctrlOrMetaKey: boolean) => { if (ctrlOrMetaKey) { toggleNav(); @@ -126,17 +173,28 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co isActiveConvo, conversationId, isPopoverActive, - setIsPopoverActive, + setIsPopoverActive: handlePopoverOpenChange, + isShiftHeld: isActiveConvo ? isShiftHeld : false, }; return (
{ if (renaming) { return; @@ -149,7 +207,11 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co if (renaming) { return; } - if (e.key === 'Enter') { + if (e.target !== e.currentTarget) { + return; + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); handleNavigation(false); } }} @@ -167,29 +229,57 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co ) : ( - + {isGenerating ? ( + + + + + ) : ( + + )} )}
); diff --git a/client/src/components/Conversations/ConvoLink.tsx b/client/src/components/Conversations/ConvoLink.tsx index 1667cf0980..543407501e 100644 --- a/client/src/components/Conversations/ConvoLink.tsx +++ b/client/src/components/Conversations/ConvoLink.tsx @@ -3,6 +3,7 @@ import { cn } from '~/utils'; interface ConvoLinkProps { isActiveConvo: boolean; + isPopoverActive: boolean; title: string | null; onRename: () => void; isSmallScreen: boolean; @@ -12,6 +13,7 @@ interface ConvoLinkProps { const ConvoLink: React.FC = ({ isActiveConvo, + isPopoverActive, title, onRename, isSmallScreen, @@ -22,7 +24,7 @@ const ConvoLink: React.FC = ({
= ({ e.stopPropagation(); onRename(); }} - role="button" - aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')} + aria-label={title || localize('com_ui_untitled')} > {title || localize('com_ui_untitled')}
void; renameHandler: (e: MouseEvent) => void; isPopoverActive: boolean; - setIsPopoverActive: React.Dispatch>; + setIsPopoverActive: (open: boolean) => void; isActiveConvo: boolean; + isShiftHeld?: boolean; }) { const localize = useLocalize(); + const queryClient = useQueryClient(); const { index } = useChatContext(); const { data: startupConfig } = useGetStartupConfig(); const { navigateToConvo } = useNavigateToConvo(index); @@ -43,13 +50,37 @@ function ConvoOptions({ const { conversationId: currentConvoId } = useParams(); const { newConversation } = useNewConvo(); + const menuId = useId(); const shareButtonRef = useRef(null); const deleteButtonRef = useRef(null); const [showShareDialog, setShowShareDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [announcement, setAnnouncement] = useState(''); const archiveConvoMutation = useArchiveConvoMutation(); + const deleteMutation = useDeleteConversationMutation({ + onSuccess: () => { + if (currentConvoId === conversationId || currentConvoId === 'new') { + newConversation(); + navigate('/c/new', { replace: true }); + } + retainView(); + showToast({ + message: localize('com_ui_convo_delete_success'), + severity: NotificationSeverity.SUCCESS, + showIcon: true, + }); + }, + onError: () => { + showToast({ + message: localize('com_ui_convo_delete_error'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }); + const duplicateConversation = useDuplicateConversationMutation({ onSuccess: (data) => { navigateToConvo(data.conversation); @@ -75,52 +106,76 @@ function ConvoOptions({ const isDuplicateLoading = duplicateConversation.isLoading; const isArchiveLoading = archiveConvoMutation.isLoading; + const isDeleteLoading = deleteMutation.isLoading; - const handleShareClick = useCallback(() => { + const shareHandler = useCallback(() => { setShowShareDialog(true); }, []); - const handleDeleteClick = useCallback(() => { + const deleteHandler = useCallback(() => { setShowDeleteDialog(true); }, []); - const handleArchiveClick = useCallback(async () => { - const convoId = conversationId ?? ''; - if (!convoId) { - return; - } + const handleInstantDelete = useCallback( + (e: MouseEvent) => { + e.stopPropagation(); + const convoId = conversationId ?? ''; + if (!convoId) { + return; + } + const messages = queryClient.getQueryData([QueryKeys.messages, convoId]); + const thread_id = messages?.[messages.length - 1]?.thread_id; + const endpoint = messages?.[messages.length - 1]?.endpoint; + deleteMutation.mutate({ conversationId: convoId, thread_id, endpoint, source: 'button' }); + }, + [conversationId, deleteMutation, queryClient], + ); - archiveConvoMutation.mutate( - { conversationId: convoId, isArchived: true }, - { - onSuccess: () => { - if (currentConvoId === convoId || currentConvoId === 'new') { - newConversation(); - navigate('/c/new', { replace: true }); - } - retainView(); - setIsPopoverActive(false); + const handleArchiveClick = useCallback( + async (e?: MouseEvent) => { + e?.stopPropagation(); + const convoId = conversationId ?? ''; + if (!convoId) { + return; + } + + archiveConvoMutation.mutate( + { conversationId: convoId, isArchived: true }, + { + onSuccess: () => { + setAnnouncement(localize('com_ui_convo_archived')); + setTimeout(() => { + setAnnouncement(''); + }, 10000); + if (currentConvoId === convoId || currentConvoId === 'new') { + newConversation(); + navigate('/c/new', { replace: true }); + } + retainView(); + setIsPopoverActive(false); + }, + onError: () => { + showToast({ + message: localize('com_ui_archive_error'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, }, - onError: () => { - showToast({ - message: localize('com_ui_archive_error'), - severity: NotificationSeverity.ERROR, - showIcon: true, - }); - }, - }, - ); - }, [ - conversationId, - currentConvoId, - archiveConvoMutation, - navigate, - newConversation, - retainView, - setIsPopoverActive, - showToast, - localize, - ]); + ); + }, + [ + conversationId, + currentConvoId, + archiveConvoMutation, + navigate, + newConversation, + retainView, + setIsPopoverActive, + showToast, + localize, + ], + ); const handleDuplicateClick = useCallback(() => { duplicateConversation.mutate({ @@ -132,9 +187,12 @@ function ConvoOptions({ () => [ { label: localize('com_ui_share'), - onClick: handleShareClick, - icon: , + onClick: shareHandler, + icon:
+ ); + } return ( <> + + {announcement} + - + } items={dropdownItems} - menuId={menuId} - className="z-30" /> {showShareDialog && ( { prevProps.conversationId === nextProps.conversationId && prevProps.title === nextProps.title && prevProps.isPopoverActive === nextProps.isPopoverActive && - prevProps.isActiveConvo === nextProps.isActiveConvo + prevProps.isActiveConvo === nextProps.isActiveConvo && + prevProps.isShiftHeld === nextProps.isShiftHeld ); }); diff --git a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx index 3c34cb8c3c..17691b4a08 100644 --- a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; +import { Trans } from 'react-i18next'; import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -6,6 +7,7 @@ import { Button, Spinner, OGDialog, + OGDialogClose, OGDialogTitle, OGDialogHeader, OGDialogContent, @@ -23,7 +25,7 @@ type DeleteButtonProps = { showDeleteDialog?: boolean; setShowDeleteDialog?: (value: boolean) => void; triggerRef?: React.RefObject; - setMenuOpen?: React.Dispatch>; + setMenuOpen?: (open: boolean) => void; }; export function DeleteConversationDialog({ @@ -33,7 +35,7 @@ export function DeleteConversationDialog({ retainView, title, }: { - setMenuOpen?: React.Dispatch>; + setMenuOpen?: (open: boolean) => void; setShowDeleteDialog: (value: boolean) => void; conversationId: string; retainView: () => void; @@ -55,6 +57,11 @@ export function DeleteConversationDialog({ } setMenuOpen?.(false); retainView(); + showToast({ + message: localize('com_ui_convo_delete_success'), + severity: NotificationSeverity.SUCCESS, + showIcon: true, + }); }, onError: () => { showToast({ @@ -75,20 +82,26 @@ export function DeleteConversationDialog({ return ( {localize('com_ui_delete_conversation')} -
- {localize('com_ui_delete_confirm')} {title} ? +
+ }} + />
- + + + diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index 177dd5ae5f..0bf2cb093b 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -1,11 +1,13 @@ import React, { useState, useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; import { QRCodeSVG } from 'qrcode.react'; import { Copy, CopyCheck } from 'lucide-react'; import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client'; import { useLocalize, useCopyToClipboard } from '~/hooks'; import SharedLinkButton from './SharedLinkButton'; -import { cn } from '~/utils'; +import { buildShareLinkUrl, cn } from '~/utils'; +import store from '~/store'; export default function ShareButton({ conversationId, @@ -24,13 +26,21 @@ export default function ShareButton({ const [showQR, setShowQR] = useState(false); const [sharedLink, setSharedLink] = useState(''); const [isCopying, setIsCopying] = useState(false); - const { data: share, isLoading } = useGetSharedLinkQuery(conversationId); + const [announcement, setAnnouncement] = useState(''); const copyLink = useCopyToClipboard({ text: sharedLink }); + const copyLinkAndAnnounce = (setIsCopying: React.Dispatch>) => { + setAnnouncement(localize('com_ui_link_copied')); + copyLink(setIsCopying); + setTimeout(() => { + setAnnouncement(''); + }, 1000); + }; + const latestMessage = useRecoilValue(store.latestMessageFamily(0)); + const { data: share, isLoading } = useGetSharedLinkQuery(conversationId); useEffect(() => { if (share?.shareId !== undefined) { - const link = `${window.location.protocol}//${window.location.host}/share/${share.shareId}`; - setSharedLink(link); + setSharedLink(buildShareLinkUrl(share.shareId)); } }, [share]); @@ -39,7 +49,7 @@ export default function ShareButton({ +
{(() => { if (isLoading === true) { @@ -70,28 +80,42 @@ export default function ShareButton({ : localize('com_ui_share_create_message'); })()}
-
+
{showQR && (
- +
)} {shareId && (
{sharedLink}
+ + {announcement} +
)} diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx index 35609ba5ec..7c53cab64c 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -1,13 +1,17 @@ -import { useState, useCallback } from 'react'; +import { useState, useRef } from 'react'; +import { Trans } from 'react-i18next'; import { QrCode, RotateCw, Trash2 } from 'lucide-react'; import { - Button, - OGDialog, - Spinner, - TooltipAnchor, Label, - OGDialogTemplate, + Button, + Spinner, + OGDialog, + OGDialogClose, + TooltipAnchor, + OGDialogTitle, + OGDialogHeader, useToastContext, + OGDialogContent, } from '@librechat/client'; import type { TSharedLinkGetResponse } from 'librechat-data-provider'; import { @@ -16,26 +20,29 @@ import { useDeleteSharedLinkMutation, } from '~/data-provider'; import { NotificationSeverity } from '~/common'; +import { buildShareLinkUrl } from '~/utils'; import { useLocalize } from '~/hooks'; export default function SharedLinkButton({ share, conversationId, - setShareDialogOpen, + targetMessageId, showQR, setShowQR, setSharedLink, }: { share: TSharedLinkGetResponse | undefined; conversationId: string; - setShareDialogOpen: React.Dispatch>; + targetMessageId?: string; showQR: boolean; setShowQR: (showQR: boolean) => void; setSharedLink: (sharedLink: string) => void; }) { const localize = useLocalize(); const { showToast } = useToastContext(); + const deleteButtonRef = useRef(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [announcement, setAnnouncement] = useState(''); const shareId = share?.shareId ?? ''; const { mutateAsync: mutate, isLoading: isCreateLoading } = useCreateSharedLinkMutation({ @@ -59,9 +66,16 @@ export default function SharedLinkButton({ }); const deleteMutation = useDeleteSharedLinkMutation({ - onSuccess: async () => { + onSuccess: () => { setShowDeleteDialog(false); - setShareDialogOpen(false); + setTimeout(() => { + const dialog = document + .getElementById('share-conversation-dialog') + ?.closest('[role="dialog"]'); + if (dialog instanceof HTMLElement) { + dialog.focus(); + } + }, 0); }, onError: (error) => { console.error('Delete error:', error); @@ -72,9 +86,7 @@ export default function SharedLinkButton({ }, }); - const generateShareLink = useCallback((shareId: string) => { - return `${window.location.protocol}//${window.location.host}/share/${shareId}`; - }, []); + const generateShareLink = (shareId: string) => buildShareLinkUrl(shareId); const updateSharedLink = async () => { if (!shareId) { @@ -83,10 +95,14 @@ export default function SharedLinkButton({ const updateShare = await mutateAsync({ shareId }); const newLink = generateShareLink(updateShare.shareId); setSharedLink(newLink); + setAnnouncement(localize('com_ui_link_refreshed')); + setTimeout(() => { + setAnnouncement(''); + }, 1000); }; const createShareLink = async () => { - const share = await mutate({ conversationId }); + const share = await mutate({ conversationId, targetMessageId }); const newLink = generateShareLink(share.shareId); setSharedLink(newLink); }; @@ -111,6 +127,8 @@ export default function SharedLinkButton({ } }; + const qrCodeLabel = showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr'); + return ( <>
@@ -125,26 +143,37 @@ export default function SharedLinkButton({ ( - + <> + + {announcement} + + + )} /> ( - )} /> @@ -152,39 +181,56 @@ export default function SharedLinkButton({ ( - )} />
)} - - -
-
- -
-
- - } - selection={{ - selectHandler: handleDelete, - selectClasses: - 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white', - selectText: localize('com_ui_delete'), - }} - /> + + + + {localize('com_ui_delete_shared_link_heading')} + +
+
+ +
+
+
+ + + + +
+
diff --git a/client/src/components/Conversations/RenameForm.tsx b/client/src/components/Conversations/RenameForm.tsx index e392215dc4..641b2a6484 100644 --- a/client/src/components/Conversations/RenameForm.tsx +++ b/client/src/components/Conversations/RenameForm.tsx @@ -34,6 +34,8 @@ const RenameForm: React.FC = ({ case 'Enter': onSubmit(titleInput); break; + case 'Tab': + break; } }; @@ -50,22 +52,23 @@ const RenameForm: React.FC = ({ value={titleInput} onChange={(e) => setTitleInput(e.target.value)} onKeyDown={handleKeyDown} - onBlur={() => onSubmit(titleInput)} maxLength={100} aria-label={localize('com_ui_new_conversation_title')} />
diff --git a/client/src/components/Endpoints/ConvoIcon.tsx b/client/src/components/Endpoints/ConvoIcon.tsx index faed70a9b9..0ac36924a6 100644 --- a/client/src/components/Endpoints/ConvoIcon.tsx +++ b/client/src/components/Endpoints/ConvoIcon.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; +import { getEndpointField } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; -import { getEndpointField, getIconKey, getEntity, getIconEndpoint } from '~/utils'; +import { getIconKey, getEntity, getIconEndpoint } from '~/utils'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import { icons } from '~/hooks/Endpoint/Icons'; diff --git a/client/src/components/Endpoints/EndpointIcon.tsx b/client/src/components/Endpoints/EndpointIcon.tsx index f635388f0e..c32ea12369 100644 --- a/client/src/components/Endpoints/EndpointIcon.tsx +++ b/client/src/components/Endpoints/EndpointIcon.tsx @@ -1,13 +1,13 @@ -import { isAssistantsEndpoint } from 'librechat-data-provider'; +import { getEndpointField, isAssistantsEndpoint } from 'librechat-data-provider'; import type { - TConversation, - TEndpointsConfig, TPreset, + TConversation, TAssistantsMap, + TEndpointsConfig, } from 'librechat-data-provider'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; -import { getEndpointField, getIconEndpoint } from '~/utils'; +import { getIconEndpoint } from '~/utils'; export default function EndpointIcon({ conversation, diff --git a/client/src/components/Endpoints/EndpointSettings.tsx b/client/src/components/Endpoints/EndpointSettings.tsx index ce8be90634..d470f53062 100644 --- a/client/src/components/Endpoints/EndpointSettings.tsx +++ b/client/src/components/Endpoints/EndpointSettings.tsx @@ -1,10 +1,11 @@ import { useRecoilValue } from 'recoil'; -import { SettingsViews, TConversation } from 'librechat-data-provider'; import { useGetModelsQuery } from 'librechat-data-provider/react-query'; +import { getEndpointField, SettingsViews } from 'librechat-data-provider'; +import type { TConversation } from 'librechat-data-provider'; import type { TSettingsProps } from '~/common'; import { useGetEndpointsQuery } from '~/data-provider'; -import { cn, getEndpointField } from '~/utils'; import { getSettings } from './Settings'; +import { cn } from '~/utils'; import store from '~/store'; export default function Settings({ diff --git a/client/src/components/Endpoints/Icon.tsx b/client/src/components/Endpoints/Icon.tsx index 3256145bfb..fae0f286d3 100644 --- a/client/src/components/Endpoints/Icon.tsx +++ b/client/src/components/Endpoints/Icon.tsx @@ -1,64 +1,102 @@ -import React, { memo, useState } from 'react'; +import React, { memo } from 'react'; import { UserIcon, useAvatar } from '@librechat/client'; -import type { TUser } from 'librechat-data-provider'; import type { IconProps } from '~/common'; import MessageEndpointIcon from './MessageEndpointIcon'; import { useAuthContext } from '~/hooks/AuthContext'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; +type ResolvedAvatar = { type: 'image'; src: string } | { type: 'fallback' }; + +/** + * Caches the resolved avatar decision per user ID. + * Invalidated when `user.avatar` changes (e.g., settings upload). + * Tracks failed image URLs so they fall back to SVG permanently for the session. + */ +const avatarCache = new Map< + string, + { avatar: string; avatarSrc: string; resolved: ResolvedAvatar } +>(); +const failedUrls = new Set(); + +function resolveAvatar(userId: string, userAvatar: string, avatarSrc: string): ResolvedAvatar { + if (!userId) { + const imgSrc = userAvatar || avatarSrc; + return imgSrc && !failedUrls.has(imgSrc) + ? { type: 'image', src: imgSrc } + : { type: 'fallback' }; + } + + const cached = avatarCache.get(userId); + if (cached && cached.avatar === userAvatar && cached.avatarSrc === avatarSrc) { + return cached.resolved; + } + + const imgSrc = userAvatar || avatarSrc; + const resolved: ResolvedAvatar = + imgSrc && !failedUrls.has(imgSrc) ? { type: 'image', src: imgSrc } : { type: 'fallback' }; + + avatarCache.set(userId, { avatar: userAvatar, avatarSrc, resolved }); + return resolved; +} + +function markAvatarFailed(userId: string, src: string): ResolvedAvatar { + failedUrls.add(src); + const fallback: ResolvedAvatar = { type: 'fallback' }; + const cached = avatarCache.get(userId); + if (cached) { + avatarCache.set(userId, { ...cached, resolved: fallback }); + } + return fallback; +} + type UserAvatarProps = { size: number; - user?: TUser; + avatar: string; avatarSrc: string; + userId: string; username: string; className?: string; }; -const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => { - const [imageError, setImageError] = useState(false); +const UserAvatar = memo( + ({ size, avatar, avatarSrc, userId, username, className }: UserAvatarProps) => { + const [resolved, setResolved] = React.useState(() => resolveAvatar(userId, avatar, avatarSrc)); - const handleImageError = () => { - setImageError(true); - }; + React.useEffect(() => { + setResolved(resolveAvatar(userId, avatar, avatarSrc)); + }, [userId, avatar, avatarSrc]); - const renderDefaultAvatar = () => ( -
- -
- ); - - return ( -
- {(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) || - imageError ? ( - renderDefaultAvatar() - ) : ( - avatar - )} -
- ); -}); + return ( +
+ {resolved.type === 'image' ? ( + avatar setResolved(markAvatarFailed(userId, resolved.src))} + /> + ) : ( +
+ +
+ )} +
+ ); + }, +); UserAvatar.displayName = 'UserAvatar'; @@ -74,9 +112,10 @@ const Icon: React.FC = memo((props) => { return ( ); diff --git a/client/src/components/Endpoints/MessageEndpointIcon.tsx b/client/src/components/Endpoints/MessageEndpointIcon.tsx index 0a9782ce99..d3539fd78d 100644 --- a/client/src/components/Endpoints/MessageEndpointIcon.tsx +++ b/client/src/components/Endpoints/MessageEndpointIcon.tsx @@ -25,7 +25,7 @@ type EndpointIcon = { function getOpenAIColor(_model: string | null | undefined) { const model = _model?.toLowerCase() ?? ''; - if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9]\b/i.test(model))) { + 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'; @@ -57,16 +57,7 @@ function getGoogleModelName(model: string | null | undefined) { } const MessageEndpointIcon: React.FC = (props) => { - const { - error, - button, - iconURL = '', - endpoint, - size = 30, - model = '', - assistantName, - agentName, - } = props; + const { error, iconURL = '', endpoint, size = 30, model = '', assistantName, agentName } = props; const assistantsIcon = { icon: iconURL ? ( @@ -119,7 +110,7 @@ const MessageEndpointIcon: React.FC = (props) => { ) : (
- +
), @@ -142,11 +133,6 @@ const MessageEndpointIcon: React.FC = (props) => { bg: getOpenAIColor(model), name: 'ChatGPT', }, - [EModelEndpoint.gptPlugins]: { - icon: , - bg: `rgba(69, 89, 164, ${button === true ? 0.75 : 1})`, - name: 'Plugins', - }, [EModelEndpoint.google]: { icon: getGoogleIcon(model, size), name: getGoogleModelName(model), diff --git a/client/src/components/Endpoints/MinimalIcon.tsx b/client/src/components/Endpoints/MinimalIcon.tsx index f21cad1e08..4a85eb09ab 100644 --- a/client/src/components/Endpoints/MinimalIcon.tsx +++ b/client/src/components/Endpoints/MinimalIcon.tsx @@ -1,15 +1,13 @@ import { Feather } from 'lucide-react'; import { EModelEndpoint, alternateName } from 'librechat-data-provider'; import { + Sparkles, + BedrockIcon, + AnthropicIcon, AzureMinimalIcon, OpenAIMinimalIcon, - LightningIcon, - MinimalPlugin, GoogleMinimalIcon, CustomMinimalIcon, - AnthropicIcon, - BedrockIcon, - Sparkles, } from '@librechat/client'; import UnknownIcon from '~/hooks/Endpoint/UnknownIcon'; import { IconProps } from '~/common'; @@ -33,7 +31,6 @@ const MinimalIcon: React.FC = (props) => { icon: , name: props.chatGptLabel ?? 'ChatGPT', }, - [EModelEndpoint.gptPlugins]: { icon: , name: 'Plugins' }, [EModelEndpoint.google]: { icon: , name: props.modelLabel ?? 'Google' }, [EModelEndpoint.anthropic]: { icon: , @@ -43,11 +40,10 @@ const MinimalIcon: React.FC = (props) => { icon: , name: 'Custom', }, - [EModelEndpoint.chatGPTBrowser]: { icon: , name: 'ChatGPT' }, [EModelEndpoint.assistants]: { icon: , name: 'Assistant' }, [EModelEndpoint.azureAssistants]: { icon: , name: 'Assistant' }, [EModelEndpoint.agents]: { - icon: , + icon: