mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🔧 fix: MCP Selection Persist and UI Flicker Issues (#9324)
* refactor: useMCPSelect - Add useGetMCPTools to use in useMCPSelect and elsewhere hooks for fetching MCP tools - remove memoized key - remove use of `useChatContext` and require conversationId as prop * feat: Add MCPPanelContext and integrate conversationId as prop for useMCPSelect across components - Introduced MCPPanelContext to manage conversationId state. - Updated MCPSelect, MCPSubMenu, and MCPConfigDialog to accept conversationId as a prop. - Modified ToolsDropdown and BadgeRow to pass conversationId to relevant components. - Refactored MCPPanel to utilize MCPPanelProvider for context management. * fix: remove nested ternary in ServerInitializationSection - Replaced conditional operator with if-else statements for better readability in determining button text based on server initialization state and reinitialization status. * refactor: wrap setValueWrap in useCallback for performance optimization * refactor: streamline useMCPSelect by consolidating storageKey definition * fix: prevent clearing selections on page refresh by tracking initial load completion * refactor: simplify concern of useMCPSelect hook * refactor: move ConfigFieldDetail interface to common types for better reusability, isolate usage of `useGetMCPTools` * refactor: integrate mcpServerNames into BadgeRowContext and update ToolsDropdown and MCPSelect components
This commit is contained in:
parent
2483623c88
commit
c0511b9a5f
19 changed files with 270 additions and 206 deletions
|
@ -1,12 +1,19 @@
|
|||
import React, { createContext, useContext, useEffect, useRef } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import { useSearchApiKeyForm, useGetAgentsConfig, useCodeApiKeyForm, useToolToggle } from '~/hooks';
|
||||
import {
|
||||
useSearchApiKeyForm,
|
||||
useGetAgentsConfig,
|
||||
useCodeApiKeyForm,
|
||||
useGetMCPTools,
|
||||
useToolToggle,
|
||||
} from '~/hooks';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
interface BadgeRowContextType {
|
||||
conversationId?: string | null;
|
||||
mcpServerNames?: string[] | null;
|
||||
agentsConfig?: TAgentsEndpoint | null;
|
||||
webSearch: ReturnType<typeof useToolToggle>;
|
||||
artifacts: ReturnType<typeof useToolToggle>;
|
||||
|
@ -37,10 +44,12 @@ export default function BadgeRowProvider({
|
|||
isSubmitting,
|
||||
conversationId,
|
||||
}: BadgeRowProviderProps) {
|
||||
const hasInitializedRef = useRef(false);
|
||||
const lastKeyRef = useRef<string>('');
|
||||
const hasInitializedRef = useRef(false);
|
||||
const { mcpToolDetails } = useGetMCPTools();
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(key));
|
||||
|
||||
/** Initialize ephemeralAgent from localStorage on mount and when conversation changes */
|
||||
|
@ -156,11 +165,16 @@ export default function BadgeRowProvider({
|
|||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
const mcpServerNames = useMemo(() => {
|
||||
return (mcpToolDetails ?? []).map((tool) => tool.name);
|
||||
}, [mcpToolDetails]);
|
||||
|
||||
const value: BadgeRowContextType = {
|
||||
webSearch,
|
||||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
mcpServerNames,
|
||||
conversationId,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
|
|
31
client/src/Providers/MCPPanelContext.tsx
Normal file
31
client/src/Providers/MCPPanelContext.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useChatContext } from './ChatContext';
|
||||
|
||||
interface MCPPanelContextValue {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
const MCPPanelContext = createContext<MCPPanelContextValue | undefined>(undefined);
|
||||
|
||||
export function MCPPanelProvider({ children }: { children: React.ReactNode }) {
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
/** Context value only created when conversationId changes */
|
||||
const contextValue = useMemo<MCPPanelContextValue>(
|
||||
() => ({
|
||||
conversationId: conversation?.conversationId ?? Constants.NEW_CONVO,
|
||||
}),
|
||||
[conversation?.conversationId],
|
||||
);
|
||||
|
||||
return <MCPPanelContext.Provider value={contextValue}>{children}</MCPPanelContext.Provider>;
|
||||
}
|
||||
|
||||
export function useMCPPanelContext() {
|
||||
const context = useContext(MCPPanelContext);
|
||||
if (!context) {
|
||||
throw new Error('useMCPPanelContext must be used within MCPPanelProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
|
@ -23,6 +23,7 @@ export * from './SetConvoContext';
|
|||
export * from './SearchContext';
|
||||
export * from './BadgeRowContext';
|
||||
export * from './SidePanelContext';
|
||||
export * from './MCPPanelContext';
|
||||
export * from './ArtifactsContext';
|
||||
export * from './PromptGroupsContext';
|
||||
export { default as BadgeRowProvider } from './BadgeRowContext';
|
||||
|
|
|
@ -8,6 +8,11 @@ import type * as t from 'librechat-data-provider';
|
|||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type CodeBarProps = {
|
||||
lang: string;
|
||||
error?: boolean;
|
||||
|
|
|
@ -368,7 +368,7 @@ function BadgeRow({
|
|||
<CodeInterpreter />
|
||||
<FileSearch />
|
||||
<Artifacts />
|
||||
<MCPSelect />
|
||||
<MCPSelect conversationId={conversationId} />
|
||||
</>
|
||||
)}
|
||||
{ghostBadge && (
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Button, Input, Label, OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||
import type { ConfigFieldDetail } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MCPConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
|
@ -34,7 +30,7 @@ export default function MCPConfigDialog({
|
|||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, _ },
|
||||
formState: { errors },
|
||||
} = useForm<Record<string, string>>({
|
||||
defaultValues: initialValues,
|
||||
});
|
||||
|
@ -56,14 +52,12 @@ export default function MCPConfigDialog({
|
|||
};
|
||||
|
||||
const dialogTitle = localize('com_ui_configure_mcp_variables_for', { 0: serverName });
|
||||
const dialogDescription = localize('com_ui_mcp_dialog_desc');
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogTemplate
|
||||
className="sm:max-w-lg"
|
||||
title={dialogTitle}
|
||||
description={dialogDescription}
|
||||
headerClassName="px-6 pt-6 pb-4"
|
||||
main={
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4 px-6 pb-2">
|
||||
|
|
|
@ -3,8 +3,11 @@ import { MultiSelect, MCPIcon } from '@librechat/client';
|
|||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
|
||||
function MCPSelect() {
|
||||
type MCPSelectProps = { conversationId?: string | null };
|
||||
|
||||
function MCPSelectContent({ conversationId }: MCPSelectProps) {
|
||||
const {
|
||||
configuredServers,
|
||||
mcpValues,
|
||||
|
@ -15,7 +18,7 @@ function MCPSelect() {
|
|||
getConfigDialogProps,
|
||||
isInitializing,
|
||||
localize,
|
||||
} = useMCPServerManager();
|
||||
} = useMCPServerManager({ conversationId });
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(values: string[], placeholder?: string) => {
|
||||
|
@ -93,9 +96,17 @@ function MCPSelect() {
|
|||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||
/>
|
||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||
{configDialogProps && (
|
||||
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MCPSelect(props: MCPSelectProps) {
|
||||
const { mcpServerNames } = useBadgeRowContext();
|
||||
if ((mcpServerNames?.length ?? 0) === 0) return null;
|
||||
return <MCPSelectContent {...props} />;
|
||||
}
|
||||
|
||||
export default memo(MCPSelect);
|
||||
|
|
|
@ -9,10 +9,11 @@ import { cn } from '~/utils';
|
|||
|
||||
interface MCPSubMenuProps {
|
||||
placeholder?: string;
|
||||
conversationId?: string | null;
|
||||
}
|
||||
|
||||
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||
({ placeholder, ...props }, ref) => {
|
||||
({ placeholder, conversationId, ...props }, ref) => {
|
||||
const {
|
||||
configuredServers,
|
||||
mcpValues,
|
||||
|
@ -23,7 +24,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
|||
getServerStatusIconProps,
|
||||
getConfigDialogProps,
|
||||
isInitializing,
|
||||
} = useMCPServerManager();
|
||||
} = useMCPServerManager({ conversationId });
|
||||
|
||||
const menuStore = Ariakit.useMenuStore({
|
||||
focusLoop: true,
|
||||
|
|
|
@ -10,12 +10,12 @@ import {
|
|||
PermissionTypes,
|
||||
defaultAgentCapabilities,
|
||||
} from 'librechat-data-provider';
|
||||
import { useLocalize, useHasAccess, useAgentCapabilities, useMCPSelect } from '~/hooks';
|
||||
import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks';
|
||||
import ArtifactsSubMenu from '~/components/Chat/Input/ArtifactsSubMenu';
|
||||
import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
|
||||
interface ToolsDropdownProps {
|
||||
disabled?: boolean;
|
||||
|
@ -30,11 +30,12 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
mcpServerNames,
|
||||
conversationId,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
searchApiKeyForm,
|
||||
} = useBadgeRowContext();
|
||||
const mcpSelect = useMCPSelect();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } =
|
||||
|
@ -56,7 +57,6 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
} = codeInterpreter;
|
||||
const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch;
|
||||
const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts;
|
||||
const { mcpServerNames } = mcpSelect;
|
||||
|
||||
const canUseWebSearch = useHasAccess({
|
||||
permissionType: PermissionTypes.WEB_SEARCH,
|
||||
|
@ -290,7 +290,9 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
|||
if (mcpServerNames && mcpServerNames.length > 0) {
|
||||
dropdownItems.push({
|
||||
hideOnClick: false,
|
||||
render: (props) => <MCPSubMenu {...props} placeholder={mcpPlaceholder} />,
|
||||
render: (props) => (
|
||||
<MCPSubMenu {...props} placeholder={mcpPlaceholder} conversationId={conversationId} />
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -8,15 +8,11 @@ import {
|
|||
OGDialogContent,
|
||||
} from '@librechat/client';
|
||||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import type { ConfigFieldDetail } from '~/common';
|
||||
import ServerInitializationSection from './ServerInitializationSection';
|
||||
import CustomUserVarsSection from './CustomUserVarsSection';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export interface ConfigFieldDetail {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface MCPConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
|
@ -27,6 +23,7 @@ interface MCPConfigDialogProps {
|
|||
onRevoke?: () => void;
|
||||
serverName: string;
|
||||
serverStatus?: MCPServerStatus;
|
||||
conversationId?: string | null;
|
||||
}
|
||||
|
||||
export default function MCPConfigDialog({
|
||||
|
@ -38,6 +35,7 @@ export default function MCPConfigDialog({
|
|||
onRevoke,
|
||||
serverName,
|
||||
serverStatus,
|
||||
conversationId,
|
||||
}: MCPConfigDialogProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
|
@ -126,6 +124,7 @@ export default function MCPConfigDialog({
|
|||
{/* Server Initialization Section */}
|
||||
<ServerInitializationSection
|
||||
serverName={serverName}
|
||||
conversationId={conversationId}
|
||||
requiresOAuth={serverStatus?.requiresOAuth || false}
|
||||
hasCustomUserVars={fieldsSchema && Object.keys(fieldsSchema).length > 0}
|
||||
/>
|
||||
|
|
|
@ -9,12 +9,14 @@ interface ServerInitializationSectionProps {
|
|||
serverName: string;
|
||||
requiresOAuth: boolean;
|
||||
hasCustomUserVars?: boolean;
|
||||
conversationId?: string | null;
|
||||
}
|
||||
|
||||
export default function ServerInitializationSection({
|
||||
sidePanel = false,
|
||||
serverName,
|
||||
requiresOAuth,
|
||||
conversationId,
|
||||
sidePanel = false,
|
||||
hasCustomUserVars = false,
|
||||
}: ServerInitializationSectionProps) {
|
||||
const localize = useLocalize();
|
||||
|
@ -26,7 +28,7 @@ export default function ServerInitializationSection({
|
|||
isInitializing,
|
||||
isCancellable,
|
||||
getOAuthUrl,
|
||||
} = useMCPServerManager();
|
||||
} = useMCPServerManager({ conversationId });
|
||||
|
||||
const serverStatus = connectionStatus[serverName];
|
||||
const isConnected = serverStatus?.connectionState === 'connected';
|
||||
|
@ -69,13 +71,18 @@ export default function ServerInitializationSection({
|
|||
const isReinit = shouldShowReinit;
|
||||
const outerClass = isReinit ? 'flex justify-start' : 'flex justify-end';
|
||||
const buttonVariant = isReinit ? undefined : 'default';
|
||||
const buttonText = isServerInitializing
|
||||
? localize('com_ui_loading')
|
||||
: isReinit
|
||||
? localize('com_ui_reinitialize')
|
||||
: requiresOAuth
|
||||
? localize('com_ui_authenticate')
|
||||
: localize('com_ui_mcp_initialize');
|
||||
|
||||
let buttonText = '';
|
||||
if (isServerInitializing) {
|
||||
buttonText = localize('com_ui_loading');
|
||||
} else if (isReinit) {
|
||||
buttonText = localize('com_ui_reinitialize');
|
||||
} else if (requiresOAuth) {
|
||||
buttonText = localize('com_ui_authenticate');
|
||||
} else {
|
||||
buttonText = localize('com_ui_mcp_initialize');
|
||||
}
|
||||
|
||||
const icon = isServerInitializing ? (
|
||||
<Spinner className="h-4 w-4" />
|
||||
) : (
|
||||
|
|
|
@ -8,15 +8,16 @@ import type { TUpdateUserPlugins } from 'librechat-data-provider';
|
|||
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
|
||||
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
|
||||
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
|
||||
import BadgeRowProvider from '~/Providers/BadgeRowContext';
|
||||
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
function MCPPanelContent() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { conversationId } = useMCPPanelContext();
|
||||
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
|
||||
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
|
||||
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
||||
|
@ -153,6 +154,7 @@ function MCPPanelContent() {
|
|||
|
||||
<ServerInitializationSection
|
||||
sidePanel={true}
|
||||
conversationId={conversationId}
|
||||
serverName={selectedServerNameForEditing}
|
||||
requiresOAuth={serverStatus?.requiresOAuth || false}
|
||||
hasCustomUserVars={
|
||||
|
@ -204,8 +206,8 @@ function MCPPanelContent() {
|
|||
|
||||
export default function MCPPanel() {
|
||||
return (
|
||||
<BadgeRowProvider>
|
||||
<MCPPanelProvider>
|
||||
<MCPPanelContent />
|
||||
</BadgeRowProvider>
|
||||
</MCPPanelProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
export * from './useMCPSelect';
|
||||
export * from './useGetMCPTools';
|
||||
export { useMCPServerManager } from './useMCPServerManager';
|
||||
|
|
43
client/src/hooks/MCP/useGetMCPTools.ts
Normal file
43
client/src/hooks/MCP/useGetMCPTools.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import { useAvailableToolsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
|
||||
export function useGetMCPTools() {
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { data: rawMcpTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data: TPlugin[]) => {
|
||||
const mcpToolsMap = new Map<string, TPlugin>();
|
||||
data.forEach((tool) => {
|
||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||
if (isMCP) {
|
||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
if (!mcpToolsMap.has(serverName)) {
|
||||
mcpToolsMap.set(serverName, {
|
||||
name: serverName,
|
||||
pluginKey: tool.pluginKey,
|
||||
authConfig: tool.authConfig,
|
||||
authenticated: tool.authenticated,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(mcpToolsMap.values());
|
||||
},
|
||||
});
|
||||
|
||||
const mcpToolDetails = useMemo(() => {
|
||||
if (!rawMcpTools || !startupConfig?.mcpServers) {
|
||||
return rawMcpTools;
|
||||
}
|
||||
return rawMcpTools.filter((tool) => {
|
||||
const serverConfig = startupConfig?.mcpServers?.[tool.name];
|
||||
return serverConfig?.chatMenu !== false;
|
||||
});
|
||||
}, [rawMcpTools, startupConfig?.mcpServers]);
|
||||
|
||||
return {
|
||||
mcpToolDetails,
|
||||
};
|
||||
}
|
72
client/src/hooks/MCP/useMCPSelect.ts
Normal file
72
client/src/hooks/MCP/useMCPSelect.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
const currentValue = rawCurrentValue?.trim() ?? '';
|
||||
if (currentValue.length > 2) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
};
|
||||
|
||||
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
|
||||
const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
|
||||
const mcpState = useMemo(() => {
|
||||
return ephemeralAgent?.mcp ?? [];
|
||||
}, [ephemeralAgent?.mcp]);
|
||||
|
||||
const setSelectedValues = useCallback(
|
||||
(values: string[] | null | undefined) => {
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
mcp: values,
|
||||
}));
|
||||
},
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
|
||||
const [mcpValues, setMCPValuesRaw] = useLocalStorage<string[]>(
|
||||
storageKey,
|
||||
mcpState,
|
||||
setSelectedValues,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
const setMCPValuesRawRef = useRef(setMCPValuesRaw);
|
||||
setMCPValuesRawRef.current = setMCPValuesRaw;
|
||||
|
||||
/** Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop */
|
||||
const setMCPValues = useCallback((value: string[]) => {
|
||||
setMCPValuesRawRef.current(value);
|
||||
}, []);
|
||||
|
||||
const [isPinned, setIsPinned] = useLocalStorage<boolean>(
|
||||
`${LocalStorageKeys.PIN_MCP_}${key}`,
|
||||
true,
|
||||
);
|
||||
|
||||
return {
|
||||
isPinned,
|
||||
mcpValues,
|
||||
setIsPinned,
|
||||
setMCPValues,
|
||||
};
|
||||
}
|
|
@ -8,10 +8,10 @@ import {
|
|||
useReinitializeMCPServerMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
|
||||
import type { ConfigFieldDetail } from '~/components/MCP/MCPConfigDialog';
|
||||
import type { ConfigFieldDetail } from '~/common';
|
||||
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
|
||||
import { useLocalize, useMCPSelect, useGetMCPTools } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize, useMCPSelect } from '~/hooks';
|
||||
|
||||
interface ServerState {
|
||||
isInitializing: boolean;
|
||||
|
@ -21,13 +21,14 @@ interface ServerState {
|
|||
pollInterval: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
export function useMCPServerManager() {
|
||||
export function useMCPServerManager({ conversationId }: { conversationId?: string | null }) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const mcpSelect = useMCPSelect();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { mcpValues, setMCPValues, mcpToolDetails, isPinned, setIsPinned } = mcpSelect;
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { mcpToolDetails } = useGetMCPTools();
|
||||
const mcpSelect = useMCPSelect({ conversationId });
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { mcpValues, setMCPValues, isPinned, setIsPinned } = mcpSelect;
|
||||
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
|
||||
|
@ -90,7 +91,21 @@ export function useMCPServerManager() {
|
|||
[connectionStatusData?.connectionStatus],
|
||||
);
|
||||
|
||||
/** Filter disconnected servers when values change, but only after initial load
|
||||
This prevents clearing selections on page refresh when servers haven't connected yet
|
||||
*/
|
||||
const hasInitialLoadCompleted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectionStatusData || Object.keys(connectionStatus).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasInitialLoadCompleted.current) {
|
||||
hasInitialLoadCompleted.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mcpValues?.length) return;
|
||||
|
||||
const connectedSelected = mcpValues.filter(
|
||||
|
@ -100,7 +115,7 @@ export function useMCPServerManager() {
|
|||
if (connectedSelected.length !== mcpValues.length) {
|
||||
setMCPValues(connectedSelected);
|
||||
}
|
||||
}, [connectionStatus, mcpValues, setMCPValues]);
|
||||
}, [connectionStatus, connectionStatusData, mcpValues, setMCPValues]);
|
||||
|
||||
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
|
||||
setServerStates((prev) => {
|
||||
|
@ -486,12 +501,12 @@ export function useMCPServerManager() {
|
|||
};
|
||||
},
|
||||
[
|
||||
isCancellable,
|
||||
mcpToolDetails,
|
||||
isInitializing,
|
||||
cancelOAuthFlow,
|
||||
connectionStatus,
|
||||
startupConfig?.mcpServers,
|
||||
isInitializing,
|
||||
isCancellable,
|
||||
cancelOAuthFlow,
|
||||
],
|
||||
);
|
||||
|
||||
|
@ -547,7 +562,6 @@ export function useMCPServerManager() {
|
|||
mcpValues,
|
||||
setMCPValues,
|
||||
|
||||
mcpToolDetails,
|
||||
isPinned,
|
||||
setIsPinned,
|
||||
placeholderText,
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
export * from './useMCPSelect';
|
||||
export * from './useToolToggle';
|
||||
export { default as useAuthCodeTool } from './useAuthCodeTool';
|
||||
export { default as usePluginInstall } from './usePluginInstall';
|
||||
|
|
|
@ -1,136 +0,0 @@
|
|||
import { useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, LocalStorageKeys, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import { useAvailableToolsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
const currentValue = rawCurrentValue?.trim() ?? '';
|
||||
if (currentValue.length > 2) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
};
|
||||
|
||||
export function useMCPSelect() {
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
const key = useMemo(
|
||||
() => conversation?.conversationId ?? Constants.NEW_CONVO,
|
||||
[conversation?.conversationId],
|
||||
);
|
||||
|
||||
const hasSetFetched = useRef<string | null>(null);
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { data: rawMcpTools, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||
select: (data: TPlugin[]) => {
|
||||
const mcpToolsMap = new Map<string, TPlugin>();
|
||||
data.forEach((tool) => {
|
||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||
if (isMCP) {
|
||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
if (!mcpToolsMap.has(serverName)) {
|
||||
mcpToolsMap.set(serverName, {
|
||||
name: serverName,
|
||||
pluginKey: tool.pluginKey,
|
||||
authConfig: tool.authConfig,
|
||||
authenticated: tool.authenticated,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(mcpToolsMap.values());
|
||||
},
|
||||
});
|
||||
|
||||
const mcpToolDetails = useMemo(() => {
|
||||
if (!rawMcpTools || !startupConfig?.mcpServers) {
|
||||
return rawMcpTools;
|
||||
}
|
||||
return rawMcpTools.filter((tool) => {
|
||||
const serverConfig = startupConfig?.mcpServers?.[tool.name];
|
||||
return serverConfig?.chatMenu !== false;
|
||||
});
|
||||
}, [rawMcpTools, startupConfig?.mcpServers]);
|
||||
|
||||
const mcpState = useMemo(() => {
|
||||
return ephemeralAgent?.mcp ?? [];
|
||||
}, [ephemeralAgent?.mcp]);
|
||||
|
||||
const setSelectedValues = useCallback(
|
||||
(values: string[] | null | undefined) => {
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
mcp: values,
|
||||
}));
|
||||
},
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
|
||||
const [mcpValues, setMCPValuesRaw] = useLocalStorage<string[]>(
|
||||
`${LocalStorageKeys.LAST_MCP_}${key}`,
|
||||
mcpState,
|
||||
setSelectedValues,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
const setMCPValuesRawRef = useRef(setMCPValuesRaw);
|
||||
setMCPValuesRawRef.current = setMCPValuesRaw;
|
||||
|
||||
// Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop
|
||||
const setMCPValues = useCallback((value: string[]) => {
|
||||
setMCPValuesRawRef.current(value);
|
||||
}, []);
|
||||
|
||||
const [isPinned, setIsPinned] = useLocalStorage<boolean>(
|
||||
`${LocalStorageKeys.PIN_MCP_}${key}`,
|
||||
true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSetFetched.current === key) {
|
||||
return;
|
||||
}
|
||||
if (!isFetched) {
|
||||
return;
|
||||
}
|
||||
hasSetFetched.current = key;
|
||||
if ((mcpToolDetails?.length ?? 0) > 0) {
|
||||
setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp)));
|
||||
return;
|
||||
}
|
||||
setMCPValues([]);
|
||||
}, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]);
|
||||
|
||||
const mcpServerNames = useMemo(() => {
|
||||
return (mcpToolDetails ?? []).map((tool) => tool.name);
|
||||
}, [mcpToolDetails]);
|
||||
|
||||
return {
|
||||
isPinned,
|
||||
mcpValues,
|
||||
setIsPinned,
|
||||
setMCPValues,
|
||||
mcpServerNames,
|
||||
ephemeralAgent,
|
||||
mcpToolDetails,
|
||||
setEphemeralAgent,
|
||||
};
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
* - Also value will be updated everywhere, when value updated (via `storage` event)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
export default function useLocalStorage<T>(
|
||||
key: string,
|
||||
|
@ -47,7 +47,8 @@ export default function useLocalStorage<T>(
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key, globalSetState]);
|
||||
|
||||
const setValueWrap = (value: T) => {
|
||||
const setValueWrap = useCallback(
|
||||
(value: T) => {
|
||||
try {
|
||||
setValue(value);
|
||||
const storeLocal = () => {
|
||||
|
@ -63,7 +64,9 @@ export default function useLocalStorage<T>(
|
|||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
},
|
||||
[key, globalSetState, storageCondition],
|
||||
);
|
||||
|
||||
return [value, setValueWrap];
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue