| import React, { useState, useMemo, useCallback } from 'react'; |
| import * as Ariakit from '@ariakit/react'; |
| import { Globe, Settings, Settings2, TerminalSquareIcon } from 'lucide-react'; |
| import { TooltipAnchor, DropdownPopup, PinIcon, VectorIcon } from '@librechat/client'; |
| import type { MenuItemProps } from '~/common'; |
| import { |
| AuthType, |
| Permissions, |
| ArtifactModes, |
| PermissionTypes, |
| defaultAgentCapabilities, |
| } from 'librechat-data-provider'; |
| 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'; |
|
|
| interface ToolsDropdownProps { |
| disabled?: boolean; |
| } |
|
|
| const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { |
| const localize = useLocalize(); |
| const isDisabled = disabled ?? false; |
| const [isPopoverActive, setIsPopoverActive] = useState(false); |
| const { |
| webSearch, |
| artifacts, |
| fileSearch, |
| agentsConfig, |
| mcpServerManager, |
| codeApiKeyForm, |
| codeInterpreter, |
| searchApiKeyForm, |
| } = useBadgeRowContext(); |
| const { data: startupConfig } = useGetStartupConfig(); |
|
|
| const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } = |
| useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); |
|
|
| const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } = |
| codeApiKeyForm; |
| const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } = |
| searchApiKeyForm; |
| const { |
| isPinned: isSearchPinned, |
| setIsPinned: setIsSearchPinned, |
| authData: webSearchAuthData, |
| } = webSearch; |
| const { |
| isPinned: isCodePinned, |
| setIsPinned: setIsCodePinned, |
| authData: codeAuthData, |
| } = codeInterpreter; |
| const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch; |
| const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts; |
|
|
| const canUseWebSearch = useHasAccess({ |
| permissionType: PermissionTypes.WEB_SEARCH, |
| permission: Permissions.USE, |
| }); |
|
|
| const canRunCode = useHasAccess({ |
| permissionType: PermissionTypes.RUN_CODE, |
| permission: Permissions.USE, |
| }); |
|
|
| const canUseFileSearch = useHasAccess({ |
| permissionType: PermissionTypes.FILE_SEARCH, |
| permission: Permissions.USE, |
| }); |
|
|
| const showWebSearchSettings = useMemo(() => { |
| const authTypes = webSearchAuthData?.authTypes ?? []; |
| if (authTypes.length === 0) return true; |
| return !authTypes.every(([, authType]) => authType === AuthType.SYSTEM_DEFINED); |
| }, [webSearchAuthData?.authTypes]); |
|
|
| const showCodeSettings = useMemo( |
| () => codeAuthData?.message !== AuthType.SYSTEM_DEFINED, |
| [codeAuthData?.message], |
| ); |
|
|
| const handleWebSearchToggle = useCallback(() => { |
| const newValue = !webSearch.toggleState; |
| webSearch.debouncedChange({ value: newValue }); |
| }, [webSearch]); |
|
|
| const handleCodeInterpreterToggle = useCallback(() => { |
| const newValue = !codeInterpreter.toggleState; |
| codeInterpreter.debouncedChange({ value: newValue }); |
| }, [codeInterpreter]); |
|
|
| const handleFileSearchToggle = useCallback(() => { |
| const newValue = !fileSearch.toggleState; |
| fileSearch.debouncedChange({ value: newValue }); |
| }, [fileSearch]); |
|
|
| const handleArtifactsToggle = useCallback(() => { |
| const currentState = artifacts.toggleState; |
| if (!currentState || currentState === '') { |
| artifacts.debouncedChange({ value: ArtifactModes.DEFAULT }); |
| } else { |
| artifacts.debouncedChange({ value: '' }); |
| } |
| }, [artifacts]); |
|
|
| const handleShadcnToggle = useCallback(() => { |
| const currentState = artifacts.toggleState; |
| if (currentState === ArtifactModes.SHADCNUI) { |
| artifacts.debouncedChange({ value: ArtifactModes.DEFAULT }); |
| } else { |
| artifacts.debouncedChange({ value: ArtifactModes.SHADCNUI }); |
| } |
| }, [artifacts]); |
|
|
| const handleCustomToggle = useCallback(() => { |
| const currentState = artifacts.toggleState; |
| if (currentState === ArtifactModes.CUSTOM) { |
| artifacts.debouncedChange({ value: ArtifactModes.DEFAULT }); |
| } else { |
| artifacts.debouncedChange({ value: ArtifactModes.CUSTOM }); |
| } |
| }, [artifacts]); |
|
|
| const mcpPlaceholder = startupConfig?.interface?.mcpServers?.placeholder; |
|
|
| const dropdownItems: MenuItemProps[] = []; |
|
|
| if (fileSearchEnabled && canUseFileSearch) { |
| dropdownItems.push({ |
| onClick: handleFileSearchToggle, |
| hideOnClick: false, |
| render: (props) => ( |
| <div {...props}> |
| <div className="flex items-center gap-2"> |
| <VectorIcon className="icon-md" /> |
| <span>{localize('com_assistants_file_search')}</span> |
| </div> |
| <button |
| type="button" |
| onClick={(e) => { |
| e.stopPropagation(); |
| setIsFileSearchPinned(!isFileSearchPinned); |
| }} |
| className={cn( |
| 'rounded p-1 transition-all duration-200', |
| 'hover:bg-surface-secondary hover:shadow-sm', |
| !isFileSearchPinned && 'text-text-secondary hover:text-text-primary', |
| )} |
| aria-label={isFileSearchPinned ? 'Unpin' : 'Pin'} |
| > |
| <div className="h-4 w-4"> |
| <PinIcon unpin={isFileSearchPinned} /> |
| </div> |
| </button> |
| </div> |
| ), |
| }); |
| } |
|
|
| if (canUseWebSearch && webSearchEnabled) { |
| dropdownItems.push({ |
| onClick: handleWebSearchToggle, |
| hideOnClick: false, |
| render: (props) => ( |
| <div {...props}> |
| <div className="flex items-center gap-2"> |
| <Globe className="icon-md" /> |
| <span>{localize('com_ui_web_search')}</span> |
| </div> |
| <div className="flex items-center gap-1"> |
| {showWebSearchSettings && ( |
| <button |
| type="button" |
| onClick={(e) => { |
| e.stopPropagation(); |
| setIsSearchDialogOpen(true); |
| }} |
| className={cn( |
| 'rounded p-1 transition-all duration-200', |
| 'hover:bg-surface-secondary hover:shadow-sm', |
| 'text-text-secondary hover:text-text-primary', |
| )} |
| aria-label="Configure web search" |
| ref={searchMenuTriggerRef} |
| > |
| <div className="h-4 w-4"> |
| <Settings className="h-4 w-4" /> |
| </div> |
| </button> |
| )} |
| <button |
| type="button" |
| onClick={(e) => { |
| e.stopPropagation(); |
| setIsSearchPinned(!isSearchPinned); |
| }} |
| className={cn( |
| 'rounded p-1 transition-all duration-200', |
| 'hover:bg-surface-secondary hover:shadow-sm', |
| !isSearchPinned && 'text-text-secondary hover:text-text-primary', |
| )} |
| aria-label={isSearchPinned ? 'Unpin' : 'Pin'} |
| > |
| <div className="h-4 w-4"> |
| <PinIcon unpin={isSearchPinned} /> |
| </div> |
| </button> |
| </div> |
| </div> |
| ), |
| }); |
| } |
|
|
| if (canRunCode && codeEnabled) { |
| dropdownItems.push({ |
| onClick: handleCodeInterpreterToggle, |
| hideOnClick: false, |
| render: (props) => ( |
| <div {...props}> |
| <div className="flex items-center gap-2"> |
| <TerminalSquareIcon className="icon-md" /> |
| <span>{localize('com_assistants_code_interpreter')}</span> |
| </div> |
| <div className="flex items-center gap-1"> |
| {showCodeSettings && ( |
| <button |
| type="button" |
| onClick={(e) => { |
| e.stopPropagation(); |
| setIsCodeDialogOpen(true); |
| }} |
| ref={codeMenuTriggerRef} |
| className={cn( |
| 'rounded p-1 transition-all duration-200', |
| 'hover:bg-surface-secondary hover:shadow-sm', |
| 'text-text-secondary hover:text-text-primary', |
| )} |
| aria-label="Configure code interpreter" |
| > |
| <div className="h-4 w-4"> |
| <Settings className="h-4 w-4" /> |
| </div> |
| </button> |
| )} |
| <button |
| type="button" |
| onClick={(e) => { |
| e.stopPropagation(); |
| setIsCodePinned(!isCodePinned); |
| }} |
| className={cn( |
| 'rounded p-1 transition-all duration-200', |
| 'hover:bg-surface-secondary hover:shadow-sm', |
| !isCodePinned && 'text-text-primary hover:text-text-primary', |
| )} |
| aria-label={isCodePinned ? 'Unpin' : 'Pin'} |
| > |
| <div className="h-4 w-4"> |
| <PinIcon unpin={isCodePinned} /> |
| </div> |
| </button> |
| </div> |
| </div> |
| ), |
| }); |
| } |
|
|
| if (artifactsEnabled) { |
| dropdownItems.push({ |
| hideOnClick: false, |
| render: (props) => ( |
| <ArtifactsSubMenu |
| {...props} |
| isArtifactsPinned={isArtifactsPinned} |
| setIsArtifactsPinned={setIsArtifactsPinned} |
| artifactsMode={artifacts.toggleState as string} |
| handleArtifactsToggle={handleArtifactsToggle} |
| handleShadcnToggle={handleShadcnToggle} |
| handleCustomToggle={handleCustomToggle} |
| /> |
| ), |
| }); |
| } |
|
|
| const { configuredServers } = mcpServerManager; |
| if (configuredServers && configuredServers.length > 0) { |
| dropdownItems.push({ |
| hideOnClick: false, |
| render: (props) => <MCPSubMenu {...props} placeholder={mcpPlaceholder} />, |
| }); |
| } |
|
|
| if (dropdownItems.length === 0) { |
| return null; |
| } |
|
|
| const menuTrigger = ( |
| <TooltipAnchor |
| render={ |
| <Ariakit.MenuButton |
| disabled={isDisabled} |
| id="tools-dropdown-button" |
| aria-label="Tools Options" |
| className={cn( |
| 'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50', |
| )} |
| > |
| <div className="flex w-full items-center justify-center gap-2"> |
| <Settings2 className="icon-md" /> |
| </div> |
| </Ariakit.MenuButton> |
| } |
| id="tools-dropdown-button" |
| description={localize('com_ui_tools')} |
| disabled={isDisabled} |
| /> |
| ); |
|
|
| return ( |
| <DropdownPopup |
| itemClassName="flex w-full cursor-pointer rounded-lg items-center justify-between hover:bg-surface-hover gap-5" |
| menuId="tools-dropdown-menu" |
| isOpen={isPopoverActive} |
| setIsOpen={setIsPopoverActive} |
| modal={true} |
| unmountOnHide={true} |
| trigger={menuTrigger} |
| items={dropdownItems} |
| iconClassName="mr-0" |
| /> |
| ); |
| }; |
|
|
| export default React.memo(ToolsDropdown); |
|
|