🤖 feat: Streamline Endpoints to Agent Framework (#8013)

* refactor(buildEndpointOption): Improve error logging in middleware, consolidate `isAgents` builder logic, remove adding `modelsConfig` to `endpointOption`

* refactor: parameter extraction and organization in agent services, minimize redundancy of shared fields across objects, make clear distinction of parameters processed uniquely by LibreChat vs LLM Provider Configs

* refactor(createPayload): streamline all endpoints to agent route

* fix: add `modelLabel` to response sender options for agent initialization

* chore: correct log message context in EditController abort controller cleanup

* chore: remove unused abortRequest hook

* chore: remove unused addToCache module and its dependencies

* refactor: remove AskController and related routes, update endpoint URLs (now all streamlined to agents route)

* chore: remove unused bedrock route and its related imports

* refactor: simplify response sender logic for Google endpoint

* chore: add `modelDisplayLabel` handling for agents endpoint

* feat: add file search capability to ephemeral agents, update code interpreter selection based of file upload, consolidate main upload menu for all endpoints

* feat: implement useToolToggle hook for managing tool toggle state, refactor CodeInterpreter and WebSearch components to utilize new hook

* feat: add ToolsDropdown component to BadgeRow for enhanced tool options

* feat: introduce BadgeRowContext and BadgeRowProvider for managing conversation state, refactor related components to utilize context

* feat: implement useMCPSelect hook for managing MCP selection state, refactor MCPSelect component to utilize new hook

* feat: enhance BadgeRowContext with MCPSelect and tool toggle functionality, refactor related components to utilize updated context and hooks

* refactor: streamline useToolToggle hook by integrating setEphemeralAgent directly into toggle logic and removing redundant setValue function

* refactor: consolidate codeApiKeyForm and searchApiKeyForm from CodeInterpreter and WebSearch to utilize new context properties

* refactor: update CheckboxButton to support controlled state and enhance ToolsDropdown with permission-based toggles for web search and code interpreter

* refactor: conditionally render CheckboxButton in CodeInterpreter and WebSearch components for improved UI responsiveness

* chore: add jotai dependency to package.json and package-lock.json

* chore: update brace-expansion package to version 2.0.2 in package-lock.json due to CVE-2025-5889

* Revert "chore: add jotai dependency to package.json and package-lock.json"

This reverts commit 69b6997396.

* refactor: add pinning functionality to CodeInterpreter and WebSearch components, and enhance ToolsDropdown with pin toggle for web search and code interpreter

* chore: move MCPIcon to correct location, remove duplicate

* fix: update MCP import to use type-only import from librechat-data-provider

* feat: implement MCPSubMenu component and integrate pinning functionality into ToolsDropdown

* fix: cycling to submenu by using parent menu context

* feat: add FileSearch component and integrate it into BadgeRow and ToolsDropdown

* chore: import order

* chore: remove agent specific logic that would block functionality for streamlined endpoints

* chore: linting for `createContextHandlers`

* chore: ensure ToolsDropdown doesn't show up for agents

* chore: ensure tool resource is selected when dragged to UI

* chore: update file search behavior to simulate legacy functionality

* feat: ToolDialogs with multiple trigger references, add settings to tool dropdown

* refactor: simplify web search and code interpreter settings checks

* chore: simplify local storage key for pinned state in useToolToggle

* refactor: reinstate agent check in AttachFileChat component, as individual providers will ahve different file configurations

* ci: increase timeout for MongoDB connection in Agent tests
This commit is contained in:
Danny Avila 2025-06-23 09:59:05 -04:00 committed by GitHub
parent d835f48307
commit 01e9b196bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 1468 additions and 1433 deletions

View file

@ -1,19 +1,23 @@
import React, {
memo,
useState,
useRef,
useEffect,
useCallback,
useMemo,
useState,
useEffect,
forwardRef,
useReducer,
useCallback,
} from 'react';
import { useRecoilValue, useRecoilCallback } from 'recoil';
import type { LucideIcon } from 'lucide-react';
import CodeInterpreter from './CodeInterpreter';
import { BadgeRowProvider } from '~/Providers';
import ToolsDropdown from './ToolsDropdown';
import type { BadgeItem } from '~/common';
import { useChatBadges } from '~/hooks';
import { Badge } from '~/components/ui';
import ToolDialogs from './ToolDialogs';
import FileSearch from './FileSearch';
import MCPSelect from './MCPSelect';
import WebSearch from './WebSearch';
import store from '~/store';
@ -313,78 +317,83 @@ function BadgeRow({
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
return (
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{tempBadges.map((badge, index) => (
<React.Fragment key={badge.id}>
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
<BadgeWrapper
badge={badge}
isEditing={isEditing}
isInChat={isInChat}
onToggle={handleBadgeToggle}
onDelete={handleDelete}
onMouseDown={handleMouseDown}
badgeRefs={badgeRefs}
/>
</React.Fragment>
))}
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
{showEphemeralBadges === true && (
<>
<WebSearch conversationId={conversationId} />
<CodeInterpreter conversationId={conversationId} />
<MCPSelect conversationId={conversationId} />
</>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
zIndex: 10,
pointerEvents: 'none',
}}
>
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
isEditing
isDragging
/>
</div>
)}
</div>
<BadgeRowProvider conversationId={conversationId}>
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{showEphemeralBadges === true && <ToolsDropdown />}
{tempBadges.map((badge, index) => (
<React.Fragment key={badge.id}>
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
<BadgeWrapper
badge={badge}
isEditing={isEditing}
isInChat={isInChat}
onToggle={handleBadgeToggle}
onDelete={handleDelete}
onMouseDown={handleMouseDown}
badgeRefs={badgeRefs}
/>
</React.Fragment>
))}
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
{showEphemeralBadges === true && (
<>
<WebSearch />
<CodeInterpreter />
<FileSearch />
<MCPSelect />
</>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
zIndex: 10,
pointerEvents: 'none',
}}
>
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
isEditing
isDragging
/>
</div>
)}
</div>
<ToolDialogs />
</BadgeRowProvider>
);
}

View file

@ -1,122 +1,37 @@
import debounce from 'lodash/debounce';
import React, { memo, useMemo, useCallback, useRef } from 'react';
import { useRecoilState } from 'recoil';
import React, { memo } from 'react';
import { TerminalSquareIcon } from 'lucide-react';
import {
Tools,
AuthType,
Constants,
LocalStorageKeys,
PermissionTypes,
Permissions,
} from 'librechat-data-provider';
import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useLocalize, useHasAccess, useCodeApiKeyForm } from '~/hooks';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import CheckboxButton from '~/components/ui/CheckboxButton';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import { useLocalize, useHasAccess } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue === 'true' && value === false) {
return true;
}
} catch (e) {
console.error(e);
}
}
return value !== undefined && value !== null && value !== '' && value !== false;
};
function CodeInterpreter({ conversationId }: { conversationId?: string | null }) {
const triggerRef = useRef<HTMLInputElement>(null);
function CodeInterpreter() {
const localize = useLocalize();
const key = conversationId ?? Constants.NEW_CONVO;
const { codeInterpreter, codeApiKeyForm } = useBadgeRowContext();
const { toggleState: runCode, debouncedChange, isPinned } = codeInterpreter;
const { badgeTriggerRef } = codeApiKeyForm;
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,
permission: Permissions.USE,
});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const isCodeToggleEnabled = useMemo(() => {
return ephemeralAgent?.execute_code ?? false;
}, [ephemeralAgent?.execute_code]);
const { data } = useVerifyAgentToolAuth(
{ toolId: Tools.execute_code },
{
retry: 1,
},
);
const authType = useMemo(() => data?.message ?? false, [data?.message]);
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
useCodeApiKeyForm({});
const setValue = useCallback(
(isChecked: boolean) => {
setEphemeralAgent((prev) => ({
...prev,
execute_code: isChecked,
}));
},
[setEphemeralAgent],
);
const [runCode, setRunCode] = useLocalStorage<boolean>(
`${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`,
isCodeToggleEnabled,
setValue,
storageCondition,
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, isChecked: boolean) => {
if (!isAuthenticated) {
setIsDialogOpen(true);
e.preventDefault();
return;
}
setRunCode(isChecked);
},
[setRunCode, setIsDialogOpen, isAuthenticated],
);
const debouncedChange = useMemo(
() => debounce(handleChange, 50, { leading: true }),
[handleChange],
);
if (!canRunCode) {
return null;
}
return (
<>
(runCode || isPinned) && (
<CheckboxButton
ref={triggerRef}
ref={badgeTriggerRef}
className="max-w-fit"
defaultChecked={runCode}
checked={runCode}
setValue={debouncedChange}
label={localize('com_assistants_code_interpreter')}
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
icon={<TerminalSquareIcon className="icon-md" />}
/>
<ApiKeyDialog
onSubmit={onSubmit}
isOpen={isDialogOpen}
triggerRef={triggerRef}
register={methods.register}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
handleSubmit={methods.handleSubmit}
isToolAuthenticated={isAuthenticated}
isUserProvided={authType === AuthType.USER_PROVIDED}
/>
</>
)
);
}

View file

@ -0,0 +1,28 @@
import React, { memo } from 'react';
import CheckboxButton from '~/components/ui/CheckboxButton';
import { useBadgeRowContext } from '~/Providers';
import { VectorIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
function FileSearch() {
const localize = useLocalize();
const { fileSearch } = useBadgeRowContext();
const { toggleState: fileSearchEnabled, debouncedChange, isPinned } = fileSearch;
return (
<>
{(fileSearchEnabled || isPinned) && (
<CheckboxButton
className="max-w-fit"
checked={fileSearchEnabled}
setValue={debouncedChange}
label={localize('com_assistants_file_search')}
isCheckedClassName="border-green-600/40 bg-green-500/10 hover:bg-green-700/10"
icon={<VectorIcon className="icon-md" />}
/>
)}
</>
);
}
export default memo(FileSearch);

View file

@ -1,31 +1,21 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import {
Constants,
supportsFiles,
mergeFileConfig,
isAgentsEndpoint,
isEphemeralAgent,
EndpointFileConfig,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useChatContext } from '~/Providers';
import { useGetFileConfig } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import AttachFileMenu from './AttachFileMenu';
import AttachFile from './AttachFile';
import { useChatContext } from '~/Providers';
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const { conversation } = useChatContext();
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const key = conversation?.conversationId ?? Constants.NEW_CONVO;
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(key));
const isAgents = useMemo(
() => isAgentsEndpoint(_endpoint) || isEphemeralAgent(_endpoint, ephemeralAgent),
[_endpoint, ephemeralAgent],
);
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
@ -38,11 +28,8 @@ function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
if (isAgents) {
return <AttachFileMenu disabled={disableInputs} />;
}
if (endpointSupportsFiles && !isUploadDisabled) {
return <AttachFile disabled={disableInputs} />;
if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
return <AttachFileMenu disabled={disableInputs} conversationId={conversationId} />;
}
return null;

View file

@ -1,21 +1,25 @@
import { useSetRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo } from 'react';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
import { useLocalize, useFileHandling } from '~/hooks';
import { ephemeralAgentByConvoId } from '~/store';
import { cn } from '~/utils';
interface AttachFileProps {
interface AttachFileMenuProps {
conversationId: string;
disabled?: boolean | null;
}
const AttachFile = ({ disabled }: AttachFileProps) => {
const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling({
@ -69,6 +73,7 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
label: localize('com_ui_upload_file_search'),
onClick: () => {
setToolResource(EToolResources.file_search);
/** File search is not automatically enabled to simulate legacy behavior */
handleUploadClick();
},
icon: <FileSearch className="icon-md" />,
@ -80,6 +85,10 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
label: localize('com_ui_upload_code_files'),
onClick: () => {
setToolResource(EToolResources.execute_code);
setEphemeralAgent((prev) => ({
...prev,
[EToolResources.execute_code]: true,
}));
handleUploadClick();
},
icon: <TerminalSquareIcon className="icon-md" />,
@ -87,7 +96,7 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
}
return items;
}, [capabilities, localize, setToolResource]);
}, [capabilities, localize, setToolResource, setEphemeralAgent]);
const menuTrigger = (
<TooltipAnchor
@ -132,4 +141,4 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
);
};
export default React.memo(AttachFile);
export default React.memo(AttachFileMenu);

View file

@ -7,7 +7,7 @@ import useLocalize from '~/hooks/useLocalize';
import { OGDialog } from '~/components/ui';
interface DragDropModalProps {
onOptionSelect: (option: string | undefined) => void;
onOptionSelect: (option: EToolResources | undefined) => void;
files: File[];
isVisible: boolean;
setShowModal: (showModal: boolean) => void;

View file

@ -1,75 +1,29 @@
import React, { memo, useRef, useMemo, useEffect, useCallback, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Settings2 } from 'lucide-react';
import React, { memo, useCallback, useState } from 'react';
import { SettingsIcon } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import type { TPlugin, TPluginAuthConfig, TUpdateUserPlugins } from 'librechat-data-provider';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import type { McpServerInfo } from '~/hooks/Plugins/useMCPSelect';
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
import { useAvailableToolsQuery } from '~/data-provider';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { useToastContext, useBadgeRowContext } from '~/Providers';
import MultiSelect from '~/components/ui/MultiSelect';
import { ephemeralAgentByConvoId } from '~/store';
import { useToastContext } from '~/Providers';
import MCPIcon from '~/components/ui/MCPIcon';
import { MCPIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
interface McpServerInfo {
name: string;
pluginKey: string;
authConfig?: TPluginAuthConfig[];
authenticated?: boolean;
}
// Helper function to extract mcp_serverName from a full pluginKey like action_mcp_serverName
const getBaseMCPPluginKey = (fullPluginKey: string): string => {
const parts = fullPluginKey.split(Constants.mcp_delimiter);
return Constants.mcp_prefix + parts[parts.length - 1];
};
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;
};
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
function MCPSelect() {
const localize = useLocalize();
const { showToast } = useToastContext();
const key = conversationId ?? Constants.NEW_CONVO;
const hasSetFetched = useRef<string | null>(null);
const { mcpSelect } = useBadgeRowContext();
const { mcpValues, setMCPValues, mcpServerNames, mcpToolDetails, isPinned } = mcpSelect;
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<McpServerInfo | null>(null);
const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
select: (data: TPlugin[]) => {
const mcpToolsMap = new Map<string, McpServerInfo>();
data.forEach((tool) => {
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
if (isMCP && tool.chatMenu !== false) {
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 updateUserPluginsMutation = useUpdateUserPluginsMutation({
onSuccess: () => {
setIsConfigModalOpen(false);
@ -84,48 +38,6 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
},
});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(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, setMCPValues] = useLocalStorage<string[]>(
`${LocalStorageKeys.LAST_MCP_}${key}`,
mcpState,
setSelectedValues,
storageCondition,
);
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 renderSelectedValues = useCallback(
(values: string[], placeholder?: string) => {
if (values.length === 0) {
@ -139,10 +51,6 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
[localize],
);
const mcpServerNames = useMemo(() => {
return (mcpToolDetails ?? []).map((tool) => tool.name);
}, [mcpToolDetails]);
const handleConfigSave = useCallback(
(targetName: string, authData: Record<string, string>) => {
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
@ -198,10 +106,10 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
setSelectedToolForConfig(tool);
setIsConfigModalOpen(true);
}}
className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-black/10 dark:hover:bg-white/10"
className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={`Configure ${serverName}`}
>
<Settings2 className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
<SettingsIcon className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
</button>
</div>
);
@ -212,6 +120,11 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
[mcpToolDetails, setSelectedToolForConfig, setIsConfigModalOpen],
);
// Don't render if no servers are selected and not pinned
if ((!mcpValues || mcpValues.length === 0) && !isPinned) {
return null;
}
if (!mcpToolDetails || mcpToolDetails.length === 0) {
return null;
}

View file

@ -0,0 +1,96 @@
import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MCPSubMenuProps {
isMCPPinned: boolean;
setIsMCPPinned: (value: boolean) => void;
mcpValues?: string[];
mcpServerNames: string[];
handleMCPToggle: (serverName: string) => void;
}
const MCPSubMenu = ({
mcpValues,
isMCPPinned,
mcpServerNames,
setIsMCPPinned,
handleMCPToggle,
...props
}: MCPSubMenuProps) => {
const localize = useLocalize();
const menuStore = Ariakit.useMenuStore({
showTimeout: 100,
placement: 'right',
});
return (
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
render={
<Ariakit.MenuButton className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover" />
}
>
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<span>{localize('com_ui_mcp_servers')}</span>
<ChevronRight className="ml-auto h-3 w-3" />
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsMCPPinned(!isMCPPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isMCPPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isMCPPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isMCPPinned} />
</div>
</button>
</Ariakit.MenuItem>
<Ariakit.Menu
gutter={-4}
shift={-8}
unmountOnHide
portal={true}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary p-1 shadow-lg',
)}
>
{mcpServerNames.map((serverName) => (
<Ariakit.MenuItem
key={serverName}
onClick={(event) => {
event.preventDefault();
handleMCPToggle(serverName);
}}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer',
'scroll-m-1 outline-none transition-colors',
'hover:bg-black/[0.075] dark:hover:bg-white/10',
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
'w-full min-w-0 text-sm',
)}
>
<Ariakit.MenuItemCheck checked={mcpValues?.includes(serverName) ?? false} />
<span>{serverName}</span>
</Ariakit.MenuItem>
))}
</Ariakit.Menu>
</Ariakit.MenuProvider>
);
};
export default React.memo(MCPSubMenu);

View file

@ -0,0 +1,66 @@
import React, { useMemo } from 'react';
import { AuthType } from 'librechat-data-provider';
import SearchApiKeyDialog from '~/components/SidePanel/Agents/Search/ApiKeyDialog';
import CodeApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog';
import { useBadgeRowContext } from '~/Providers';
function ToolDialogs() {
const { webSearch, codeInterpreter, searchApiKeyForm, codeApiKeyForm } = useBadgeRowContext();
const { authData: webSearchAuthData } = webSearch;
const { authData: codeAuthData } = codeInterpreter;
const {
methods: searchMethods,
onSubmit: searchOnSubmit,
isDialogOpen: searchDialogOpen,
setIsDialogOpen: setSearchDialogOpen,
handleRevokeApiKey: searchHandleRevoke,
badgeTriggerRef: searchBadgeTriggerRef,
menuTriggerRef: searchMenuTriggerRef,
} = searchApiKeyForm;
const {
methods: codeMethods,
onSubmit: codeOnSubmit,
isDialogOpen: codeDialogOpen,
setIsDialogOpen: setCodeDialogOpen,
handleRevokeApiKey: codeHandleRevoke,
badgeTriggerRef: codeBadgeTriggerRef,
menuTriggerRef: codeMenuTriggerRef,
} = codeApiKeyForm;
const searchAuthTypes = useMemo(
() => webSearchAuthData?.authTypes ?? [],
[webSearchAuthData?.authTypes],
);
const codeAuthType = useMemo(() => codeAuthData?.message ?? false, [codeAuthData?.message]);
return (
<>
<SearchApiKeyDialog
onSubmit={searchOnSubmit}
authTypes={searchAuthTypes}
isOpen={searchDialogOpen}
onRevoke={searchHandleRevoke}
register={searchMethods.register}
onOpenChange={setSearchDialogOpen}
handleSubmit={searchMethods.handleSubmit}
triggerRefs={[searchMenuTriggerRef, searchBadgeTriggerRef]}
isToolAuthenticated={webSearchAuthData?.authenticated ?? false}
/>
<CodeApiKeyDialog
onSubmit={codeOnSubmit}
isOpen={codeDialogOpen}
onRevoke={codeHandleRevoke}
register={codeMethods.register}
onOpenChange={setCodeDialogOpen}
handleSubmit={codeMethods.handleSubmit}
triggerRefs={[codeMenuTriggerRef, codeBadgeTriggerRef]}
isUserProvided={codeAuthType === AuthType.USER_PROVIDED}
isToolAuthenticated={codeAuthData?.authenticated ?? false}
/>
</>
);
}
export default ToolDialogs;

View file

@ -0,0 +1,322 @@
import React, { useState, useMemo, useCallback } from 'react';
import * as Ariakit from '@ariakit/react';
import { Globe, Settings, Settings2, TerminalSquareIcon } from 'lucide-react';
import type { MenuItemProps } from '~/common';
import { Permissions, PermissionTypes, AuthType } from 'librechat-data-provider';
import { TooltipAnchor, DropdownPopup } from '~/components';
import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu';
import { PinIcon, VectorIcon } from '~/components/svg';
import { useLocalize, useHasAccess } from '~/hooks';
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, codeInterpreter, fileSearch, mcpSelect, searchApiKeyForm, codeApiKeyForm } =
useBadgeRowContext();
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 {
mcpValues,
mcpServerNames,
isPinned: isMCPPinned,
setIsPinned: setIsMCPPinned,
} = mcpSelect;
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
permission: Permissions.USE,
});
const canRunCode = useHasAccess({
permissionType: PermissionTypes.RUN_CODE,
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({ isChecked: newValue });
}, [webSearch]);
const handleCodeInterpreterToggle = useCallback(() => {
const newValue = !codeInterpreter.toggleState;
codeInterpreter.debouncedChange({ isChecked: newValue });
}, [codeInterpreter]);
const handleFileSearchToggle = useCallback(() => {
const newValue = !fileSearch.toggleState;
fileSearch.debouncedChange({ isChecked: newValue });
}, [fileSearch]);
const handleMCPToggle = useCallback(
(serverName: string) => {
const currentValues = mcpSelect.mcpValues ?? [];
const newValues = currentValues.includes(serverName)
? currentValues.filter((v) => v !== serverName)
: [...currentValues, serverName];
mcpSelect.setMCPValues(newValues);
},
[mcpSelect],
);
const dropdownItems = useMemo(() => {
const items: MenuItemProps[] = [
{
render: () => (
<div className="px-3 py-2 text-xs font-semibold text-text-secondary">
{localize('com_ui_tools')}
</div>
),
hideOnClick: false,
},
];
items.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) {
items.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) {
items.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 (mcpServerNames && mcpServerNames.length > 0) {
items.push({
hideOnClick: false,
render: (props) => (
<MCPSubMenu
{...props}
mcpValues={mcpValues}
mcpServerNames={mcpServerNames}
isMCPPinned={isMCPPinned}
setIsMCPPinned={setIsMCPPinned}
handleMCPToggle={handleMCPToggle}
/>
),
});
}
return items;
}, [
localize,
mcpValues,
canRunCode,
isMCPPinned,
isCodePinned,
mcpServerNames,
isSearchPinned,
setIsMCPPinned,
canUseWebSearch,
setIsCodePinned,
handleMCPToggle,
showCodeSettings,
setIsSearchPinned,
isFileSearchPinned,
codeMenuTriggerRef,
setIsCodeDialogOpen,
searchMenuTriggerRef,
showWebSearchSettings,
setIsFileSearchPinned,
handleWebSearchToggle,
setIsSearchDialogOpen,
handleFileSearchToggle,
handleCodeInterpreterToggle,
]);
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 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);

View file

@ -1,122 +1,37 @@
import React, { memo, useRef, useMemo, useCallback } from 'react';
import React, { memo } from 'react';
import { Globe } from 'lucide-react';
import debounce from 'lodash/debounce';
import { useRecoilState } from 'recoil';
import {
Tools,
AuthType,
Constants,
Permissions,
PermissionTypes,
LocalStorageKeys,
} from 'librechat-data-provider';
import ApiKeyDialog from '~/components/SidePanel/Agents/Search/ApiKeyDialog';
import { useLocalize, useHasAccess, useSearchApiKeyForm } from '~/hooks';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import CheckboxButton from '~/components/ui/CheckboxButton';
import useLocalStorage from '~/hooks/useLocalStorageAlt';
import { useVerifyAgentToolAuth } from '~/data-provider';
import { ephemeralAgentByConvoId } from '~/store';
import { useLocalize, useHasAccess } from '~/hooks';
import { useBadgeRowContext } from '~/Providers';
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
if (rawCurrentValue) {
try {
const currentValue = rawCurrentValue?.trim() ?? '';
if (currentValue === 'true' && value === false) {
return true;
}
} catch (e) {
console.error(e);
}
}
return value !== undefined && value !== null && value !== '' && value !== false;
};
function WebSearch({ conversationId }: { conversationId?: string | null }) {
const triggerRef = useRef<HTMLInputElement>(null);
function WebSearch() {
const localize = useLocalize();
const key = conversationId ?? Constants.NEW_CONVO;
const { webSearch: webSearchData, searchApiKeyForm } = useBadgeRowContext();
const { toggleState: webSearch, debouncedChange, isPinned } = webSearchData;
const { badgeTriggerRef } = searchApiKeyForm;
const canUseWebSearch = useHasAccess({
permissionType: PermissionTypes.WEB_SEARCH,
permission: Permissions.USE,
});
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const isWebSearchToggleEnabled = useMemo(() => {
return ephemeralAgent?.web_search ?? false;
}, [ephemeralAgent?.web_search]);
const { data } = useVerifyAgentToolAuth(
{ toolId: Tools.web_search },
{
retry: 1,
},
);
const authTypes = useMemo(() => data?.authTypes ?? [], [data?.authTypes]);
const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]);
const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } =
useSearchApiKeyForm({});
const setValue = useCallback(
(isChecked: boolean) => {
setEphemeralAgent((prev) => ({
...prev,
web_search: isChecked,
}));
},
[setEphemeralAgent],
);
const [webSearch, setWebSearch] = useLocalStorage<boolean>(
`${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`,
isWebSearchToggleEnabled,
setValue,
storageCondition,
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, isChecked: boolean) => {
if (!isAuthenticated) {
setIsDialogOpen(true);
e.preventDefault();
return;
}
setWebSearch(isChecked);
},
[setWebSearch, setIsDialogOpen, isAuthenticated],
);
const debouncedChange = useMemo(
() => debounce(handleChange, 50, { leading: true }),
[handleChange],
);
if (!canUseWebSearch) {
return null;
}
return (
<>
(webSearch || isPinned) && (
<CheckboxButton
ref={triggerRef}
ref={badgeTriggerRef}
className="max-w-fit"
defaultChecked={webSearch}
checked={webSearch}
setValue={debouncedChange}
label={localize('com_ui_search')}
isCheckedClassName="border-blue-600/40 bg-blue-500/10 hover:bg-blue-700/10"
icon={<Globe className="icon-md" />}
/>
<ApiKeyDialog
onSubmit={onSubmit}
authTypes={authTypes}
isOpen={isDialogOpen}
triggerRef={triggerRef}
register={methods.register}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
handleSubmit={methods.handleSubmit}
isToolAuthenticated={isAuthenticated}
/>
</>
)
);
}