{children} diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index a4d72d081e..a134379497 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { useRecoilValue } from 'recoil'; +import { motion } from 'framer-motion'; 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'; @@ -10,8 +11,15 @@ import store from '~/store'; export function TemporaryChat() { const localize = useLocalize(); const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary); - const conversation = useRecoilValue(store.conversationByIndex(0)); - const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); + const { conversation, isSubmitting } = useChatContext(); + + const temporaryBadge = { + id: 'temporary', + icon: MessageCircleDashed, + label: 'com_ui_temporary' as const, + atom: store.isTemporary, + isAvailable: true, + }; const handleBadgeToggle = useRecoilCallback( () => () => { @@ -30,20 +38,24 @@ export function TemporaryChat() { return (
-
); }); DateLabel.displayName = 'DateLabel'; type FlattenedItem = - | { type: 'favorites' } - | { type: 'chats-header' } | { type: 'header'; groupName: string } | { type: 'convo'; convo: TConversation } | { type: 'loading' }; @@ -121,28 +49,18 @@ const MemoizedConvo = memo( conversation, retainView, toggleNav, - isGenerating, }: { conversation: TConversation; retainView: () => void; toggleNav: () => void; - isGenerating: boolean; }) => { - return ( - - ); + return ; }, (prevProps, nextProps) => { return ( prevProps.conversation.conversationId === nextProps.conversation.conversationId && prevProps.conversation.title === nextProps.conversation.title && - prevProps.conversation.endpoint === nextProps.conversation.endpoint && - prevProps.isGenerating === nextProps.isGenerating + prevProps.conversation.endpoint === nextProps.conversation.endpoint ); }, ); @@ -155,26 +73,9 @@ const Conversations: FC = ({ loadMoreConversations, isLoading, isSearchLoading, - isChatsExpanded, - setIsChatsExpanded, }) => { - const localize = useLocalize(); - const search = useRecoilValue(store.search); - const { favorites, isLoading: isFavoritesLoading } = useFavorites(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; - const showAgentMarketplace = useShowMarketplace(); - - // Fetch active job IDs for showing generation indicators - const { data: activeJobsData } = useActiveJobs(); - const activeJobIds = useMemo( - () => new Set(activeJobsData?.activeJobIds ?? []), - [activeJobsData?.activeJobIds], - ); - - // Determine if FavoritesList will render content - const shouldShowFavorites = - !search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace); const filteredConversations = useMemo( () => rawConversations.filter(Boolean) as TConversation[], @@ -188,155 +89,72 @@ const Conversations: FC = ({ const flattenedItems = useMemo(() => { const items: FlattenedItem[] = []; - // Only include favorites row if FavoritesList will render content - if (shouldShowFavorites) { - items.push({ type: 'favorites' }); - } - items.push({ type: 'chats-header' }); + groupedConversations.forEach(([groupName, convos]) => { + items.push({ type: 'header', groupName }); + items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); + }); - if (isChatsExpanded) { - groupedConversations.forEach(([groupName, convos]) => { - items.push({ type: 'header', groupName }); - items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); - }); - - if (isLoading) { - items.push({ type: 'loading' } as any); - } + if (isLoading) { + items.push({ type: 'loading' } as any); } return items; - }, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]); + }, [groupedConversations, isLoading]); - // Store flattenedItems in a ref for keyMapper to access without recreating cache - const flattenedItemsRef = useRef(flattenedItems); - flattenedItemsRef.current = flattenedItems; - - // Create a stable cache that doesn't depend on flattenedItems const cache = useMemo( () => new CellMeasurerCache({ fixedWidth: true, defaultHeight: convoHeight, keyMapper: (index) => { - const item = flattenedItemsRef.current[index]; - if (!item) { - return `unknown-${index}`; - } - if (item.type === 'favorites') { - return 'favorites'; - } - if (item.type === 'chats-header') { - return 'chats-header'; - } + const item = flattenedItems[index]; if (item.type === 'header') { - return `header-${item.groupName}`; + return `header-${index}`; } if (item.type === 'convo') { return `convo-${item.convo.conversationId}`; } if (item.type === 'loading') { - return 'loading'; + return `loading-${index}`; } return `unknown-${index}`; }, }), - [convoHeight], + [flattenedItems, convoHeight], ); - // Debounced function to clear cache and recompute heights - const clearFavoritesCache = useCallback(() => { - if (cache) { - cache.clear(0, 0); - if (containerRef.current && 'recomputeRowHeights' in containerRef.current) { - containerRef.current.recomputeRowHeights(0); - } - } - }, [cache, containerRef]); - - // Clear cache when favorites change - useEffect(() => { - const frameId = requestAnimationFrame(() => { - clearFavoritesCache(); - }); - return () => cancelAnimationFrame(frameId); - }, [favorites.length, isFavoritesLoading, clearFavoritesCache]); - const rowRenderer = useCallback( ({ index, key, parent, style }) => { const item = flattenedItems[index]; - const rowProps = { cache, rowKey: key, parent, index, style }; - if (item.type === 'loading') { return ( - - - + + {({ registerChild }) => ( +
+ +
+ )} +
); } - - if (item.type === 'favorites') { - return ( - - - - ); - } - - if (item.type === 'chats-header') { - return ( - - setIsChatsExpanded(!isChatsExpanded)} - /> - - ); - } - + let rendering: JSX.Element; if (item.type === 'header') { - // First date header index depends on whether favorites row is included - // With favorites: [favorites, chats-header, first-header] → index 2 - // Without favorites: [chats-header, first-header] → index 1 - const firstHeaderIndex = shouldShowFavorites ? 2 : 1; - return ( - - - + rendering = ; + } else if (item.type === 'convo') { + rendering = ( + ); } - - if (item.type === 'convo') { - const isGenerating = activeJobIds.has(item.convo.conversationId ?? ''); - return ( - - - - ); - } - - return null; + return ( + + {({ registerChild }) => ( +
+ {rendering} +
+ )} +
+ ); }, - [ - cache, - flattenedItems, - moveToTop, - toggleNav, - clearFavoritesCache, - isSmallScreen, - isChatsExpanded, - setIsChatsExpanded, - shouldShowFavorites, - activeJobIds, - ], + [cache, flattenedItems, moveToTop, toggleNav], ); const getRowHeight = useCallback( @@ -359,18 +177,18 @@ const Conversations: FC = ({ ); return ( -
+
{isSearchLoading ? (
- {localize('com_ui_loading')} + Loading...
) : (
{({ width, height }) => ( } width={width} height={height} deferredMeasurementCache={cache} @@ -378,13 +196,12 @@ 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 108755a291..190cef2a4e 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, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useMemo } 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, useShiftKey } from '~/hooks'; +import { useNavigateToConvo, useLocalize } from '~/hooks'; import { useGetEndpointsQuery } from '~/data-provider'; import { NotificationSeverity } from '~/common'; import { ConvoOptions } from './ConvoOptions'; @@ -19,15 +19,9 @@ interface ConversationProps { conversation: TConversation; retainView: () => void; toggleNav: () => void; - isGenerating?: boolean; } -export default function Conversation({ - conversation, - retainView, - toggleNav, - isGenerating = false, -}: ConversationProps) { +export default function Conversation({ conversation, retainView, toggleNav }: ConversationProps) { const params = useParams(); const localize = useLocalize(); const { showToast } = useToastContext(); @@ -37,17 +31,13 @@ export default function Conversation({ 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) { @@ -104,43 +94,6 @@ export default function Conversation({ 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(); @@ -173,28 +126,17 @@ export default function Conversation({ isActiveConvo, conversationId, isPopoverActive, - setIsPopoverActive: handlePopoverOpenChange, - isShiftHeld: isActiveConvo ? isShiftHeld : false, + setIsPopoverActive, }; return (
{ if (renaming) { return; @@ -207,11 +149,7 @@ export default function Conversation({ if (renaming) { return; } - if (e.target !== e.currentTarget) { - return; - } - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); + if (e.key === 'Enter') { handleNavigation(false); } }} @@ -229,57 +167,29 @@ export default function Conversation({ ) : ( - {isGenerating ? ( - - - - - ) : ( - - )} + )}
); diff --git a/client/src/components/Conversations/ConvoLink.tsx b/client/src/components/Conversations/ConvoLink.tsx index 543407501e..1667cf0980 100644 --- a/client/src/components/Conversations/ConvoLink.tsx +++ b/client/src/components/Conversations/ConvoLink.tsx @@ -3,7 +3,6 @@ import { cn } from '~/utils'; interface ConvoLinkProps { isActiveConvo: boolean; - isPopoverActive: boolean; title: string | null; onRename: () => void; isSmallScreen: boolean; @@ -13,7 +12,6 @@ interface ConvoLinkProps { const ConvoLink: React.FC = ({ isActiveConvo, - isPopoverActive, title, onRename, isSmallScreen, @@ -24,7 +22,7 @@ const ConvoLink: React.FC = ({
= ({ e.stopPropagation(); onRename(); }} - aria-label={title || localize('com_ui_untitled')} + role="button" + aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')} > {title || localize('com_ui_untitled')}
void; renameHandler: (e: MouseEvent) => void; isPopoverActive: boolean; - setIsPopoverActive: (open: boolean) => void; + setIsPopoverActive: React.Dispatch>; isActiveConvo: boolean; - isShiftHeld?: boolean; }) { const localize = useLocalize(); - const queryClient = useQueryClient(); const { index } = useChatContext(); const { data: startupConfig } = useGetStartupConfig(); const { navigateToConvo } = useNavigateToConvo(index); @@ -50,37 +43,13 @@ 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); @@ -106,76 +75,52 @@ function ConvoOptions({ const isDuplicateLoading = duplicateConversation.isLoading; const isArchiveLoading = archiveConvoMutation.isLoading; - const isDeleteLoading = deleteMutation.isLoading; - const shareHandler = useCallback(() => { + const handleShareClick = useCallback(() => { setShowShareDialog(true); }, []); - const deleteHandler = useCallback(() => { + const handleDeleteClick = useCallback(() => { setShowDeleteDialog(true); }, []); - 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], - ); + const handleArchiveClick = useCallback(async () => { + const convoId = conversationId ?? ''; + if (!convoId) { + return; + } - 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, - }); - }, + archiveConvoMutation.mutate( + { conversationId: convoId, isArchived: true }, + { + onSuccess: () => { + if (currentConvoId === convoId || currentConvoId === 'new') { + newConversation(); + navigate('/c/new', { replace: true }); + } + retainView(); + setIsPopoverActive(false); }, - ); - }, - [ - conversationId, - currentConvoId, - archiveConvoMutation, - navigate, - newConversation, - retainView, - setIsPopoverActive, - showToast, - localize, - ], - ); + onError: () => { + showToast({ + message: localize('com_ui_archive_error'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }, + ); + }, [ + conversationId, + currentConvoId, + archiveConvoMutation, + navigate, + newConversation, + retainView, + setIsPopoverActive, + showToast, + localize, + ]); const handleDuplicateClick = useCallback(() => { duplicateConversation.mutate({ @@ -187,12 +132,9 @@ function ConvoOptions({ () => [ { label: localize('com_ui_share'), - onClick: shareHandler, - icon:
- ); - } + const menuId = useId(); 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.isShiftHeld === nextProps.isShiftHeld + prevProps.isActiveConvo === nextProps.isActiveConvo ); }); diff --git a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx index 17691b4a08..3c34cb8c3c 100644 --- a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx @@ -1,5 +1,4 @@ -import React, { useCallback } from 'react'; -import { Trans } from 'react-i18next'; +import React, { useCallback, useState } from 'react'; import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -7,7 +6,6 @@ import { Button, Spinner, OGDialog, - OGDialogClose, OGDialogTitle, OGDialogHeader, OGDialogContent, @@ -25,7 +23,7 @@ type DeleteButtonProps = { showDeleteDialog?: boolean; setShowDeleteDialog?: (value: boolean) => void; triggerRef?: React.RefObject; - setMenuOpen?: (open: boolean) => void; + setMenuOpen?: React.Dispatch>; }; export function DeleteConversationDialog({ @@ -35,7 +33,7 @@ export function DeleteConversationDialog({ retainView, title, }: { - setMenuOpen?: (open: boolean) => void; + setMenuOpen?: React.Dispatch>; setShowDeleteDialog: (value: boolean) => void; conversationId: string; retainView: () => void; @@ -57,11 +55,6 @@ export function DeleteConversationDialog({ } setMenuOpen?.(false); retainView(); - showToast({ - message: localize('com_ui_convo_delete_success'), - severity: NotificationSeverity.SUCCESS, - showIcon: true, - }); }, onError: () => { showToast({ @@ -82,26 +75,20 @@ 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 0bf2cb093b..177dd5ae5f 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -1,13 +1,11 @@ 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 { buildShareLinkUrl, cn } from '~/utils'; -import store from '~/store'; +import { cn } from '~/utils'; export default function ShareButton({ conversationId, @@ -26,21 +24,13 @@ export default function ShareButton({ const [showQR, setShowQR] = useState(false); const [sharedLink, setSharedLink] = useState(''); const [isCopying, setIsCopying] = useState(false); - 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); + const copyLink = useCopyToClipboard({ text: sharedLink }); useEffect(() => { if (share?.shareId !== undefined) { - setSharedLink(buildShareLinkUrl(share.shareId)); + const link = `${window.location.protocol}//${window.location.host}/share/${share.shareId}`; + setSharedLink(link); } }, [share]); @@ -49,7 +39,7 @@ export default function ShareButton({ +
{(() => { if (isLoading === true) { @@ -80,42 +70,28 @@ 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 7c53cab64c..35609ba5ec 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -1,17 +1,13 @@ -import { useState, useRef } from 'react'; -import { Trans } from 'react-i18next'; +import { useState, useCallback } from 'react'; import { QrCode, RotateCw, Trash2 } from 'lucide-react'; import { - Label, Button, - Spinner, OGDialog, - OGDialogClose, + Spinner, TooltipAnchor, - OGDialogTitle, - OGDialogHeader, + Label, + OGDialogTemplate, useToastContext, - OGDialogContent, } from '@librechat/client'; import type { TSharedLinkGetResponse } from 'librechat-data-provider'; import { @@ -20,29 +16,26 @@ import { useDeleteSharedLinkMutation, } from '~/data-provider'; import { NotificationSeverity } from '~/common'; -import { buildShareLinkUrl } from '~/utils'; import { useLocalize } from '~/hooks'; export default function SharedLinkButton({ share, conversationId, - targetMessageId, + setShareDialogOpen, showQR, setShowQR, setSharedLink, }: { share: TSharedLinkGetResponse | undefined; conversationId: string; - targetMessageId?: string; + setShareDialogOpen: React.Dispatch>; 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({ @@ -66,16 +59,9 @@ export default function SharedLinkButton({ }); const deleteMutation = useDeleteSharedLinkMutation({ - onSuccess: () => { + onSuccess: async () => { setShowDeleteDialog(false); - setTimeout(() => { - const dialog = document - .getElementById('share-conversation-dialog') - ?.closest('[role="dialog"]'); - if (dialog instanceof HTMLElement) { - dialog.focus(); - } - }, 0); + setShareDialogOpen(false); }, onError: (error) => { console.error('Delete error:', error); @@ -86,7 +72,9 @@ export default function SharedLinkButton({ }, }); - const generateShareLink = (shareId: string) => buildShareLinkUrl(shareId); + const generateShareLink = useCallback((shareId: string) => { + return `${window.location.protocol}//${window.location.host}/share/${shareId}`; + }, []); const updateSharedLink = async () => { if (!shareId) { @@ -95,14 +83,10 @@ 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, targetMessageId }); + const share = await mutate({ conversationId }); const newLink = generateShareLink(share.shareId); setSharedLink(newLink); }; @@ -127,8 +111,6 @@ export default function SharedLinkButton({ } }; - const qrCodeLabel = showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr'); - return ( <>
@@ -143,37 +125,26 @@ export default function SharedLinkButton({ ( - <> - - {announcement} - - - + )} /> ( - )} /> @@ -181,56 +152,39 @@ export default function SharedLinkButton({ ( - )} />
)} - - - - {localize('com_ui_delete_shared_link_heading')} - -
-
- -
-
-
- - - - -
-
+ + +
+
+ +
+
+ + } + 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'), + }} + />
diff --git a/client/src/components/Conversations/RenameForm.tsx b/client/src/components/Conversations/RenameForm.tsx index 641b2a6484..e392215dc4 100644 --- a/client/src/components/Conversations/RenameForm.tsx +++ b/client/src/components/Conversations/RenameForm.tsx @@ -34,8 +34,6 @@ const RenameForm: React.FC = ({ case 'Enter': onSubmit(titleInput); break; - case 'Tab': - break; } }; @@ -52,23 +50,22 @@ 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 0ac36924a6..faed70a9b9 100644 --- a/client/src/components/Endpoints/ConvoIcon.tsx +++ b/client/src/components/Endpoints/ConvoIcon.tsx @@ -1,7 +1,6 @@ import React, { useMemo } from 'react'; -import { getEndpointField } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; -import { getIconKey, getEntity, getIconEndpoint } from '~/utils'; +import { getEndpointField, 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 c32ea12369..f635388f0e 100644 --- a/client/src/components/Endpoints/EndpointIcon.tsx +++ b/client/src/components/Endpoints/EndpointIcon.tsx @@ -1,13 +1,13 @@ -import { getEndpointField, isAssistantsEndpoint } from 'librechat-data-provider'; +import { isAssistantsEndpoint } from 'librechat-data-provider'; import type { - TPreset, TConversation, - TAssistantsMap, TEndpointsConfig, + TPreset, + TAssistantsMap, } from 'librechat-data-provider'; import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; import MinimalIcon from '~/components/Endpoints/MinimalIcon'; -import { getIconEndpoint } from '~/utils'; +import { getEndpointField, 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 d470f53062..ce8be90634 100644 --- a/client/src/components/Endpoints/EndpointSettings.tsx +++ b/client/src/components/Endpoints/EndpointSettings.tsx @@ -1,11 +1,10 @@ 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 fae0f286d3..3256145bfb 100644 --- a/client/src/components/Endpoints/Icon.tsx +++ b/client/src/components/Endpoints/Icon.tsx @@ -1,102 +1,64 @@ -import React, { memo } from 'react'; +import React, { memo, useState } 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; - avatar: string; + user?: TUser; avatarSrc: string; - userId: string; username: string; className?: string; }; -const UserAvatar = memo( - ({ size, avatar, avatarSrc, userId, username, className }: UserAvatarProps) => { - const [resolved, setResolved] = React.useState(() => resolveAvatar(userId, avatar, avatarSrc)); +const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => { + const [imageError, setImageError] = useState(false); - React.useEffect(() => { - setResolved(resolveAvatar(userId, avatar, avatarSrc)); - }, [userId, avatar, avatarSrc]); + const handleImageError = () => { + setImageError(true); + }; - return ( -
- {resolved.type === 'image' ? ( - avatar setResolved(markAvatarFailed(userId, resolved.src))} - /> - ) : ( -
- -
- )} -
- ); - }, -); + const renderDefaultAvatar = () => ( +
+ +
+ ); + + return ( +
+ {(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) || + imageError ? ( + renderDefaultAvatar() + ) : ( + avatar + )} +
+ ); +}); UserAvatar.displayName = 'UserAvatar'; @@ -112,10 +74,9 @@ const Icon: React.FC = memo((props) => { return ( ); diff --git a/client/src/components/Endpoints/MessageEndpointIcon.tsx b/client/src/components/Endpoints/MessageEndpointIcon.tsx index d3539fd78d..0a9782ce99 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](?:\.\d+)?\b/i.test(model))) { + if (model && (/\b(o\d)\b/i.test(model) || /\bgpt-[5-9]\b/i.test(model))) { return '#000000'; } return model.includes('gpt-4') ? '#AB68FF' : '#19C37D'; @@ -57,7 +57,16 @@ function getGoogleModelName(model: string | null | undefined) { } const MessageEndpointIcon: React.FC = (props) => { - const { error, iconURL = '', endpoint, size = 30, model = '', assistantName, agentName } = props; + const { + error, + button, + iconURL = '', + endpoint, + size = 30, + model = '', + assistantName, + agentName, + } = props; const assistantsIcon = { icon: iconURL ? ( @@ -110,7 +119,7 @@ const MessageEndpointIcon: React.FC = (props) => { ) : (
-
), @@ -133,6 +142,11 @@ 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 4a85eb09ab..f21cad1e08 100644 --- a/client/src/components/Endpoints/MinimalIcon.tsx +++ b/client/src/components/Endpoints/MinimalIcon.tsx @@ -1,13 +1,15 @@ 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'; @@ -31,6 +33,7 @@ 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: , @@ -40,10 +43,11 @@ 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:

- {localize('com_ui_files')} + Files

(true); + const isSmallScreen = useMediaQuery('(max-width: 640px)'); + const availableTools = useRecoilValue(store.availableTools); + const { checkPluginSelection, setTools } = useSetIndexOptions(); + + const { data: allPlugins } = useAvailablePluginsQuery({ + enabled: !!user?.plugins, + select: selectPlugins, + }); + + useEffect(() => { + if (isSmallScreen) { + setVisibility(false); + } + }, [isSmallScreen]); + + const conversationTools: TPlugin[] = useMemo(() => { + if (!conversation?.tools) { + return []; + } + return processPlugins(conversation.tools, allPlugins?.map); + }, [conversation, allPlugins]); + + const availablePlugins = useMemo(() => { + if (!availableTools) { + return []; + } + + return Object.values(availableTools); + }, [availableTools]); + + if (!conversation) { + return null; + } + + const Menu = popover ? SelectDropDownPop : SelectDropDown; + const PluginsMenu = popover ? MultiSelectPop : MultiSelectDropDown; + + return ( + <> + + {visible && ( + <> + + + + )} + + ); +} diff --git a/client/src/components/Input/ModelSelect/options.ts b/client/src/components/Input/ModelSelect/options.ts index 318da5b066..b93e2e7e2a 100644 --- a/client/src/components/Input/ModelSelect/options.ts +++ b/client/src/components/Input/ModelSelect/options.ts @@ -4,7 +4,9 @@ import type { FC } from 'react'; import OpenAI from './OpenAI'; import Google from './Google'; +import ChatGPT from './ChatGPT'; import Anthropic from './Anthropic'; +import PluginsByIndex from './PluginsByIndex'; export const options: { [key: string]: FC } = { [EModelEndpoint.openAI]: OpenAI, @@ -13,8 +15,10 @@ export const options: { [key: string]: FC } = { [EModelEndpoint.azureOpenAI]: OpenAI, [EModelEndpoint.google]: Google, [EModelEndpoint.anthropic]: Anthropic, + [EModelEndpoint.chatGPTBrowser]: ChatGPT, }; export const multiChatOptions = { ...options, + [EModelEndpoint.gptPlugins]: PluginsByIndex, }; diff --git a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx index 7fec25e4a5..dc71e87f8b 100644 --- a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx +++ b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx @@ -1,38 +1,23 @@ import React, { useState } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; +import { OGDialogTemplate, OGDialog, Dropdown, useToastContext } from '@librechat/client'; import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider'; -import { - useRevokeUserKeyMutation, - useRevokeAllUserKeysMutation, -} from 'librechat-data-provider/react-query'; -import { - Label, - Button, - Spinner, - OGDialog, - Dropdown, - OGDialogTitle, - OGDialogHeader, - OGDialogFooter, - OGDialogContent, - useToastContext, - OGDialogTrigger, -} from '@librechat/client'; import type { TDialogProps } from '~/common'; +import { useGetEndpointsQuery } from '~/data-provider'; +import { RevokeKeysButton } from '~/components/Nav'; import { useUserKey, useLocalize } from '~/hooks'; -import { NotificationSeverity } from '~/common'; import CustomConfig from './CustomEndpoint'; import GoogleConfig from './GoogleConfig'; import OpenAIConfig from './OpenAIConfig'; import OtherConfig from './OtherConfig'; import HelpText from './HelpText'; -import { logger } from '~/utils'; const endpointComponents = { [EModelEndpoint.google]: GoogleConfig, [EModelEndpoint.openAI]: OpenAIConfig, [EModelEndpoint.custom]: CustomConfig, [EModelEndpoint.azureOpenAI]: OpenAIConfig, + [EModelEndpoint.gptPlugins]: OpenAIConfig, [EModelEndpoint.assistants]: OpenAIConfig, [EModelEndpoint.azureAssistants]: OpenAIConfig, default: OtherConfig, @@ -42,6 +27,7 @@ const formSet: Set = new Set([ EModelEndpoint.openAI, EModelEndpoint.custom, EModelEndpoint.azureOpenAI, + EModelEndpoint.gptPlugins, EModelEndpoint.assistants, EModelEndpoint.azureAssistants, ]); @@ -56,94 +42,6 @@ const EXPIRY = { NEVER: { label: 'never', value: 0 }, }; -const RevokeKeysButton = ({ - endpoint, - disabled, - setDialogOpen, -}: { - endpoint: string; - disabled: boolean; - setDialogOpen: (open: boolean) => void; -}) => { - const localize = useLocalize(); - const [open, setOpen] = useState(false); - const { showToast } = useToastContext(); - const revokeKeyMutation = useRevokeUserKeyMutation(endpoint); - const revokeKeysMutation = useRevokeAllUserKeysMutation(); - - const handleSuccess = () => { - showToast({ - message: localize('com_ui_revoke_key_success'), - status: NotificationSeverity.SUCCESS, - }); - - if (!setDialogOpen) { - return; - } - - setDialogOpen(false); - }; - - const handleError = () => { - showToast({ - message: localize('com_ui_revoke_key_error'), - status: NotificationSeverity.ERROR, - }); - }; - - const onClick = () => { - revokeKeyMutation.mutate( - {}, - { - onSuccess: handleSuccess, - onError: handleError, - }, - ); - }; - - const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading; - - return ( -
- - - - - - - {localize('com_ui_revoke_key_endpoint', { 0: endpoint })} - -
- -
- - - - -
-
-
- ); -}; - const SetKeyDialog = ({ open, onOpenChange, @@ -171,6 +69,7 @@ const SetKeyDialog = ({ }); const [userKey, setUserKey] = useState(''); + const { data: endpointsConfig } = useGetEndpointsQuery(); const [expiresAtLabel, setExpiresAtLabel] = useState(EXPIRY.TWELVE_HOURS.label); const { getExpiry, saveUserKey } = useUserKey(endpoint); const { showToast } = useToastContext(); @@ -184,7 +83,7 @@ const SetKeyDialog = ({ const submit = () => { const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel); - let expiresAt: number | null; + let expiresAt; if (selectedOption?.value === 0) { expiresAt = null; @@ -193,20 +92,8 @@ const SetKeyDialog = ({ } const saveKey = (key: string) => { - try { - saveUserKey(key, expiresAt); - showToast({ - message: localize('com_ui_save_key_success'), - status: NotificationSeverity.SUCCESS, - }); - onOpenChange(false); - } catch (error) { - logger.error('Error saving user key:', error); - showToast({ - message: localize('com_ui_save_key_error'), - status: NotificationSeverity.ERROR, - }); - } + saveUserKey(key, expiresAt); + onOpenChange(false); }; if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) { @@ -214,7 +101,10 @@ const SetKeyDialog = ({ methods.handleSubmit((data) => { const isAzure = endpoint === EModelEndpoint.azureOpenAI; const isOpenAIBase = - isAzure || endpoint === EModelEndpoint.openAI || isAssistantsEndpoint(endpoint); + isAzure || + endpoint === EModelEndpoint.openAI || + endpoint === EModelEndpoint.gptPlugins || + isAssistantsEndpoint(endpoint); if (isAzure) { data.apiKey = 'n/a'; } @@ -258,14 +148,6 @@ const SetKeyDialog = ({ return; } - if (!userKey.trim()) { - showToast({ - message: localize('com_ui_key_required'), - status: NotificationSeverity.ERROR, - }); - return; - } - saveKey(userKey); setUserKey(''); }; @@ -273,53 +155,60 @@ const SetKeyDialog = ({ const EndpointComponent = endpointComponents[endpointType ?? endpoint] ?? endpointComponents['default']; const expiryTime = getExpiry(); + const config = endpointsConfig?.[endpoint]; return ( - - - - {`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`} - - -
- - {expiryTime === 'never' - ? localize('com_endpoint_config_key_never_expires') - : `${localize('com_endpoint_config_key_encryption')} ${new Date( - expiryTime ?? 0, - ).toLocaleString()}`} - - option.label)} - sizeClasses="w-[185px]" - portal={false} - /> -
- - + + {expiryTime === 'never' + ? localize('com_endpoint_config_key_never_expires') + : `${localize('com_endpoint_config_key_encryption')} ${new Date( + expiryTime ?? 0, + ).toLocaleString()}`} + + option.label)} + sizeClasses="w-[185px]" + portal={false} /> - - -
- +
+ + + + +
+ } + selection={{ + selectHandler: submit, + selectClasses: 'btn btn-primary', + selectText: localize('com_ui_submit'), + }} + leftButtons={ - -
- + } + /> ); }; diff --git a/client/src/components/MCP/CustomUserVarsSection.tsx b/client/src/components/MCP/CustomUserVarsSection.tsx index 339b78f6b9..9c2f0870d4 100644 --- a/client/src/components/MCP/CustomUserVarsSection.tsx +++ b/client/src/components/MCP/CustomUserVarsSection.tsx @@ -1,7 +1,6 @@ import React, { useMemo } from 'react'; -import DOMPurify from 'dompurify'; import { useForm, Controller } from 'react-hook-form'; -import { Input, Label, Button } from '@librechat/client'; +import { Input, Label, Button, TooltipAnchor, CircleHelpIcon } from '@librechat/client'; import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries'; import { useLocalize } from '~/hooks'; @@ -23,60 +22,37 @@ interface AuthFieldProps { hasValue: boolean; control: any; errors: any; - autoFocus?: boolean; } -function AuthField({ name, config, hasValue, control, errors, autoFocus }: AuthFieldProps) { +function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps) { const localize = useLocalize(); - const statusText = hasValue ? localize('com_ui_set') : localize('com_ui_unset'); - - const sanitizer = useMemo(() => { - const instance = DOMPurify(); - instance.addHook('afterSanitizeAttributes', (node) => { - if (node.tagName && node.tagName === 'A') { - node.setAttribute('target', '_blank'); - node.setAttribute('rel', 'noopener noreferrer'); - } - }); - return instance; - }, []); - - const sanitizedDescription = useMemo(() => { - if (!config.description) { - return ''; - } - try { - return sanitizer.sanitize(config.description, { - ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code'], - ALLOWED_ATTR: ['href', 'class', 'target', 'rel'], - ALLOW_DATA_ATTR: false, - ALLOW_ARIA_ATTR: false, - }); - } catch (error) { - console.error('Sanitization failed', error); - return config.description; - } - }, [config.description, sanitizer]); return (
- -