{children} diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index a134379497..75799516c0 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { motion } from 'framer-motion'; import { TooltipAnchor } from '@librechat/client'; import { MessageCircleDashed } from 'lucide-react'; import { useRecoilState, useRecoilCallback } from 'recoil'; @@ -43,6 +42,7 @@ export function TemporaryChat() { diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 3a04f558f9..64b804b2d6 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,22 +1,62 @@ -import { useMemo, memo, type FC, useCallback } from 'react'; +import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react'; import throttle from 'lodash/throttle'; +import { ChevronDown } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; import { Spinner, useMediaQuery } from '@librechat/client'; import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; -import { TConversation } from 'librechat-data-provider'; -import { useLocalize, TranslationKeys } from '~/hooks'; -import { groupConversationsByDate } from '~/utils'; +import type { TConversation } from 'librechat-data-provider'; +import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks'; +import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; +import { groupConversationsByDate, cn } from '~/utils'; import Convo from './Convo'; +import store from '~/store'; + +export type CellPosition = { + columnIndex: number; + rowIndex: number; +}; + +export type MeasuredCellParent = { + invalidateCellSizeAfterRender?: ((cell: CellPosition) => void) | undefined; + recomputeGridSize?: ((cell: CellPosition) => void) | undefined; +}; interface ConversationsProps { conversations: Array; moveToTop: () => void; toggleNav: () => void; - containerRef: React.RefObject; + containerRef: React.RefObject; loadMoreConversations: () => void; isLoading: boolean; isSearchLoading: boolean; + isChatsExpanded: boolean; + setIsChatsExpanded: (expanded: boolean) => void; } +interface MeasuredRowProps { + cache: CellMeasurerCache; + rowKey: string; + parent: MeasuredCellParent; + index: number; + style: React.CSSProperties; + children: React.ReactNode; +} + +/** Reusable wrapper for virtualized row measurement */ +const MeasuredRow: FC = memo( + ({ cache, rowKey, parent, index, style, children }) => ( + + {({ registerChild }) => ( +
} style={style}> + {children} +
+ )} +
+ ), +); + +MeasuredRow.displayName = 'MeasuredRow'; + const LoadingSpinner = memo(() => { const localize = useLocalize(); @@ -28,18 +68,49 @@ const LoadingSpinner = memo(() => { ); }); -const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { +LoadingSpinner.displayName = 'LoadingSpinner'; + +interface ChatsHeaderProps { + isExpanded: boolean; + onToggle: () => void; +} + +/** Collapsible header for the Chats section */ +const ChatsHeader: FC = memo(({ isExpanded, onToggle }) => { const localize = useLocalize(); return ( -
+ + ); +}); + +ChatsHeader.displayName = 'ChatsHeader'; + +const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => { + const localize = useLocalize(); + return ( +

{localize(groupName as TranslationKeys) || groupName} -

+ ); }); DateLabel.displayName = 'DateLabel'; type FlattenedItem = + | { type: 'favorites' } + | { type: 'chats-header' } | { type: 'header'; groupName: string } | { type: 'convo'; convo: TConversation } | { type: 'loading' }; @@ -73,9 +144,19 @@ 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(); + + // Determine if FavoritesList will render content + const shouldShowFavorites = + !search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace); const filteredConversations = useMemo( () => rawConversations.filter(Boolean) as TConversation[], @@ -89,72 +170,148 @@ const Conversations: FC = ({ const flattenedItems = useMemo(() => { const items: FlattenedItem[] = []; - groupedConversations.forEach(([groupName, convos]) => { - items.push({ type: 'header', groupName }); - items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); - }); + // Only include favorites row if FavoritesList will render content + if (shouldShowFavorites) { + items.push({ type: 'favorites' }); + } + items.push({ type: 'chats-header' }); - if (isLoading) { - items.push({ type: 'loading' } as any); + 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); + } } return items; - }, [groupedConversations, isLoading]); + }, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]); + // 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 = flattenedItems[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'; + } if (item.type === 'header') { - return `header-${index}`; + return `header-${item.groupName}`; } if (item.type === 'convo') { return `convo-${item.convo.conversationId}`; } if (item.type === 'loading') { - return `loading-${index}`; + return 'loading'; } return `unknown-${index}`; }, }), - [flattenedItems, convoHeight], + [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 }) => ( -
- -
- )} -
+ + + ); } - let rendering: JSX.Element; + + if (item.type === 'favorites') { + return ( + + + + ); + } + + if (item.type === 'chats-header') { + return ( + + setIsChatsExpanded(!isChatsExpanded)} + /> + + ); + } + if (item.type === 'header') { - rendering = ; - } else if (item.type === 'convo') { - rendering = ( - + // 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 ( + + + ); } - return ( - - {({ registerChild }) => ( -
- {rendering} -
- )} -
- ); + + if (item.type === 'convo') { + return ( + + + + ); + } + + return null; }, - [cache, flattenedItems, moveToTop, toggleNav], + [ + cache, + flattenedItems, + moveToTop, + toggleNav, + clearFavoritesCache, + isSmallScreen, + isChatsExpanded, + setIsChatsExpanded, + shouldShowFavorites, + ], ); const getRowHeight = useCallback( @@ -177,18 +334,18 @@ const Conversations: FC = ({ ); return ( -
+
{isSearchLoading ? (
- Loading... + {localize('com_ui_loading')}
) : (
{({ width, height }) => ( } + ref={containerRef} width={width} height={height} deferredMeasurementCache={cache} @@ -196,12 +353,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' }} /> )} diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 190cef2a4e..e85b341d5e 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -132,11 +132,16 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co return (
{ if (renaming) { return; @@ -149,7 +154,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,6 +176,7 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co ) : (
diff --git a/client/src/components/Conversations/ConvoLink.tsx b/client/src/components/Conversations/ConvoLink.tsx index 1667cf0980..9eb0764a70 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')}
(null); const deleteButtonRef = useRef(null); const [showShareDialog, setShowShareDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [announcement, setAnnouncement] = useState(''); const archiveConvoMutation = useArchiveConvoMutation(); @@ -76,11 +78,11 @@ function ConvoOptions({ const isDuplicateLoading = duplicateConversation.isLoading; const isArchiveLoading = archiveConvoMutation.isLoading; - const handleShareClick = useCallback(() => { + const shareHandler = useCallback(() => { setShowShareDialog(true); }, []); - const handleDeleteClick = useCallback(() => { + const deleteHandler = useCallback(() => { setShowDeleteDialog(true); }, []); @@ -94,6 +96,10 @@ function ConvoOptions({ { 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 }); @@ -132,9 +138,12 @@ function ConvoOptions({ () => [ { label: localize('com_ui_share'), - onClick: handleShareClick, - icon: , + onClick: shareHandler, + icon:
{(() => { if (isLoading === true) { @@ -70,28 +81,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..0f89c62666 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, useCallback, 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 { @@ -21,21 +25,23 @@ 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 +65,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); @@ -83,10 +96,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 +128,8 @@ export default function SharedLinkButton({ } }; + const qrCodeLabel = showQR ? localize('com_ui_hide_qr') : localize('com_ui_show_qr'); + return ( <>
@@ -125,26 +144,37 @@ export default function SharedLinkButton({ ( - + <> + + {announcement} + + + )} /> ( - )} /> @@ -152,39 +182,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/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:

- Files + {localize('com_ui_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 b93e2e7e2a..318da5b066 100644 --- a/client/src/components/Input/ModelSelect/options.ts +++ b/client/src/components/Input/ModelSelect/options.ts @@ -4,9 +4,7 @@ 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, @@ -15,10 +13,8 @@ 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 dc71e87f8b..7fec25e4a5 100644 --- a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx +++ b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx @@ -1,23 +1,38 @@ 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, @@ -27,7 +42,6 @@ const formSet: Set = new Set([ EModelEndpoint.openAI, EModelEndpoint.custom, EModelEndpoint.azureOpenAI, - EModelEndpoint.gptPlugins, EModelEndpoint.assistants, EModelEndpoint.azureAssistants, ]); @@ -42,6 +56,94 @@ 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, @@ -69,7 +171,6 @@ 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(); @@ -83,7 +184,7 @@ const SetKeyDialog = ({ const submit = () => { const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel); - let expiresAt; + let expiresAt: number | null; if (selectedOption?.value === 0) { expiresAt = null; @@ -92,8 +193,20 @@ const SetKeyDialog = ({ } const saveKey = (key: string) => { - saveUserKey(key, expiresAt); - onOpenChange(false); + 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, + }); + } }; if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) { @@ -101,10 +214,7 @@ const SetKeyDialog = ({ methods.handleSubmit((data) => { const isAzure = endpoint === EModelEndpoint.azureOpenAI; const isOpenAIBase = - isAzure || - endpoint === EModelEndpoint.openAI || - endpoint === EModelEndpoint.gptPlugins || - isAssistantsEndpoint(endpoint); + isAzure || endpoint === EModelEndpoint.openAI || isAssistantsEndpoint(endpoint); if (isAzure) { data.apiKey = 'n/a'; } @@ -148,6 +258,14 @@ const SetKeyDialog = ({ return; } + if (!userKey.trim()) { + showToast({ + message: localize('com_ui_key_required'), + status: NotificationSeverity.ERROR, + }); + return; + } + saveKey(userKey); setUserKey(''); }; @@ -155,60 +273,53 @@ const SetKeyDialog = ({ const EndpointComponent = endpointComponents[endpointType ?? endpoint] ?? endpointComponents['default']; const expiryTime = getExpiry(); - const config = endpointsConfig?.[endpoint]; return ( - - - {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} + + + + {`${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} + /> +
+ + -
- - - - -
- } - 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 9c2f0870d4..987613e24c 100644 --- a/client/src/components/MCP/CustomUserVarsSection.tsx +++ b/client/src/components/MCP/CustomUserVarsSection.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; +import DOMPurify from 'dompurify'; import { useForm, Controller } from 'react-hook-form'; -import { Input, Label, Button, TooltipAnchor, CircleHelpIcon } from '@librechat/client'; +import { Input, Label, Button } from '@librechat/client'; import { useMCPAuthValuesQuery } from '~/data-provider/Tools/queries'; import { useLocalize } from '~/hooks'; @@ -26,33 +27,55 @@ interface 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 (
- - - + +