diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 628eda00f2..2cc64ba3ed 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -1,7 +1,9 @@ import React, { createContext, useContext, useState } from 'react'; -import { Action, MCP, EModelEndpoint } from 'librechat-data-provider'; +import { Constants, EModelEndpoint } from 'librechat-data-provider'; +import type { TPlugin, AgentToolType, Action, MCP } from 'librechat-data-provider'; import type { AgentPanelContextType } from '~/common'; -import { useGetActionsQuery } from '~/data-provider'; +import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider'; +import { useLocalize } from '~/hooks'; import { Panel } from '~/common'; const AgentPanelContext = createContext(undefined); @@ -16,6 +18,7 @@ export function useAgentPanelContext() { /** Houses relevant state for the Agent Form Panels (formerly 'commonProps') */ export function AgentPanelProvider({ children }: { children: React.ReactNode }) { + const localize = useLocalize(); const [mcp, setMcp] = useState(undefined); const [mcps, setMcps] = useState(undefined); const [action, setAction] = useState(undefined); @@ -26,6 +29,53 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) enabled: !!agent_id, }); + const { data: pluginTools } = useAvailableToolsQuery(EModelEndpoint.agents, { + enabled: !!agent_id, + }); + + const tools = + pluginTools?.map((tool) => ({ + tool_id: tool.pluginKey, + metadata: tool as TPlugin, + agent_id: agent_id || '', + })) || []; + + const groupedTools = + tools?.reduce( + (acc, tool) => { + if (tool.tool_id.includes(Constants.mcp_delimiter)) { + const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter); + const groupKey = `${serverName.toLowerCase()}`; + if (!acc[groupKey]) { + acc[groupKey] = { + tool_id: groupKey, + metadata: { + name: `${serverName}`, + pluginKey: groupKey, + description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, + icon: tool.metadata.icon || '', + } as TPlugin, + agent_id: agent_id || '', + tools: [], + }; + } + acc[groupKey].tools?.push({ + tool_id: tool.tool_id, + metadata: tool.metadata, + agent_id: agent_id || '', + }); + } else { + acc[tool.tool_id] = { + tool_id: tool.tool_id, + metadata: tool.metadata, + agent_id: agent_id || '', + }; + } + return acc; + }, + {} as Record, + ) || {}; + const value = { action, setAction, @@ -37,8 +87,10 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) setActivePanel, setCurrentAgentId, agent_id, - /** Query data for actions */ + groupedTools, + /** Query data for actions and tools */ actions, + tools, }; return {children}; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 6fe4784fbc..214dc349b5 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -219,6 +219,8 @@ export type AgentPanelContextType = { mcps?: t.MCP[]; setMcp: React.Dispatch>; setMcps: React.Dispatch>; + groupedTools: Record; + tools: t.AgentToolType[]; activePanel?: string; setActivePanel: React.Dispatch>; setCurrentAgentId: React.Dispatch>; diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 2b056a5181..2afa56601c 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -1,11 +1,9 @@ -import { useQueryClient } from '@tanstack/react-query'; import React, { useState, useMemo, useCallback } from 'react'; import { Controller, useWatch, useFormContext } from 'react-hook-form'; -import { QueryKeys, EModelEndpoint, AgentCapabilities } from 'librechat-data-provider'; -import type { TPlugin } from 'librechat-data-provider'; +import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider'; +import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common'; import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils'; import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Providers'; -import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common'; import Action from '~/components/SidePanel/Builder/Action'; import { ToolSelectDialog } from '~/components/Tools'; import { icons } from '~/hooks/Endpoint/Icons'; @@ -15,7 +13,6 @@ import AgentAvatar from './AgentAvatar'; import FileContext from './FileContext'; import SearchForm from './Search/Form'; import { useLocalize } from '~/hooks'; -import MCPSection from './MCPSection'; import FileSearch from './FileSearch'; import Artifacts from './Artifacts'; import AgentTool from './AgentTool'; @@ -36,13 +33,10 @@ export default function AgentConfig({ }: Pick) { const localize = useLocalize(); const fileMap = useFileMapContext(); - const queryClient = useQueryClient(); const { showToast } = useToastContext(); const methods = useFormContext(); const [showToolDialog, setShowToolDialog] = useState(false); - const { actions, setAction, setActivePanel } = useAgentPanelContext(); - - const allTools = queryClient.getQueryData([QueryKeys.tools]) ?? []; + const { actions, setAction, groupedTools: allTools, setActivePanel } = useAgentPanelContext(); const { control } = methods; const provider = useWatch({ control, name: 'provider' }); @@ -169,6 +163,20 @@ export default function AgentConfig({ Icon = icons[iconKey]; } + // Determine what to show + const selectedToolIds = tools ?? []; + const visibleToolIds = new Set(selectedToolIds); + + // Check what group parent tools should be shown if any subtool is present + Object.entries(allTools).forEach(([toolId, toolObj]) => { + if (toolObj.tools?.length) { + // if any subtool of this group is selected, ensure group parent tool rendered + if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) { + visibleToolIds.add(toolId); + } + } + }); + return ( <>
@@ -287,28 +295,37 @@ export default function AgentConfig({ ${toolsEnabled === true && actionsEnabled === true ? ' + ' : ''} ${actionsEnabled === true ? localize('com_assistants_actions') : ''}`} -
- {tools?.map((func, i) => ( - - ))} - {(actions ?? []) - .filter((action) => action.agent_id === agent_id) - .map((action, i) => ( - { - setAction(action); - setActivePanel(Panel.actions); - }} - /> - ))} -
+
+
+ {/* // Render all visible IDs (including groups with subtools selected) */} + {[...visibleToolIds].map((toolId, i) => { + const tool = allTools[toolId]; + if (!tool) return null; + return ( + + ); + })} +
+
+ {(actions ?? []) + .filter((action) => action.agent_id === agent_id) + .map((action, i) => ( + { + setAction(action); + setActivePanel(Panel.actions); + }} + /> + ))} +
+
{(toolsEnabled ?? false) && ( - )} -
+
+ + {localize('com_ui_delete_tool_confirm')} + + } + selection={{ + selectHandler: () => removeTool(currentTool.tool_id), + selectClasses: + 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white', + selectText: localize('com_ui_delete'), + }} + /> + + ); + } + + // Group tool with accordion + return ( + + + +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onFocus={() => setIsFocused(true)} + onBlur={(e) => { + // Check if focus is moving to a child element + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsFocused(false); + } + }} + > + + + + +
+
+
+ + + +
+ + +
+ {currentTool.tools?.map((subTool) => ( + + ))} +
+
+ + } selection={{ - selectHandler: () => removeTool(currentTool.pluginKey), + selectHandler: () => removeTool(currentTool.tool_id), selectClasses: 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white', selectText: localize('com_ui_delete'), diff --git a/client/src/components/Tools/ToolItem.tsx b/client/src/components/Tools/ToolItem.tsx index cfb318a898..0b16b0ba42 100644 --- a/client/src/components/Tools/ToolItem.tsx +++ b/client/src/components/Tools/ToolItem.tsx @@ -1,9 +1,9 @@ -import { TPlugin } from 'librechat-data-provider'; import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react'; +import { AgentToolType } from 'librechat-data-provider'; import { useLocalize } from '~/hooks'; type ToolItemProps = { - tool: TPlugin; + tool: AgentToolType; onAddTool: () => void; onRemoveTool: () => void; isInstalled?: boolean; @@ -19,15 +19,19 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt } }; + const name = tool.metadata?.name || tool.tool_id; + const description = tool.metadata?.description || ''; + const icon = tool.metadata?.icon; + return (
- {tool.icon != null && tool.icon ? ( + {icon ? ( {localize('com_ui_logo', ) : ( @@ -40,12 +44,12 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
- {tool.name} + {name}
{!isInstalled ? (
-
{tool.description}
+
{description}
); } diff --git a/client/src/components/Tools/ToolSelectDialog.tsx b/client/src/components/Tools/ToolSelectDialog.tsx index e5c18eda47..b3bc558405 100644 --- a/client/src/components/Tools/ToolSelectDialog.tsx +++ b/client/src/components/Tools/ToolSelectDialog.tsx @@ -1,17 +1,19 @@ import { useEffect } from 'react'; import { Search, X } from 'lucide-react'; -import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react'; import { useFormContext } from 'react-hook-form'; import { isAgentsEndpoint } from 'librechat-data-provider'; +import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react'; import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; import type { AssistantsEndpoint, EModelEndpoint, TPluginAction, + AgentToolType, TError, } from 'librechat-data-provider'; -import type { TPluginStoreDialogProps } from '~/common/types'; +import type { AgentForm, TPluginStoreDialogProps } from '~/common'; import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store'; +import { useAgentPanelContext } from '~/Providers/AgentPanelContext'; import { useLocalize, usePluginDialogHelpers } from '~/hooks'; import { useAvailableToolsQuery } from '~/data-provider'; import ToolItem from './ToolItem'; @@ -20,14 +22,13 @@ function ToolSelectDialog({ isOpen, endpoint, setIsOpen, - toolsFormKey, }: TPluginStoreDialogProps & { - toolsFormKey: string; endpoint: AssistantsEndpoint | EModelEndpoint.agents; }) { const localize = useLocalize(); - const { getValues, setValue } = useFormContext(); + const { getValues, setValue } = useFormContext(); const { data: tools } = useAvailableToolsQuery(endpoint); + const { groupedTools } = useAgentPanelContext(); const isAgentTools = isAgentsEndpoint(endpoint); const { @@ -66,11 +67,23 @@ function ToolSelectDialog({ }, 5000); }; + const toolsFormKey = 'tools'; const handleInstall = (pluginAction: TPluginAction) => { const addFunction = () => { - const fns = getValues(toolsFormKey).slice(); - fns.push(pluginAction.pluginKey); - setValue(toolsFormKey, fns); + const installedToolIds: string[] = getValues(toolsFormKey) || []; + // Add the parent + installedToolIds.push(pluginAction.pluginKey); + + // If this tool is a group, add subtools too + const groupObj = groupedTools[pluginAction.pluginKey]; + if (groupObj?.tools && groupObj.tools.length > 0) { + for (const sub of groupObj.tools) { + if (!installedToolIds.includes(sub.tool_id)) { + installedToolIds.push(sub.tool_id); + } + } + } + setValue(toolsFormKey, Array.from(new Set(installedToolIds))); // no duplicates just in case }; if (!pluginAction.auth) { @@ -87,17 +100,21 @@ function ToolSelectDialog({ setShowPluginAuthForm(false); }; - const onRemoveTool = (tool: string) => { - setShowPluginAuthForm(false); + const onRemoveTool = (toolId: string) => { + const groupObj = groupedTools[toolId]; + const toolIdsToRemove = [toolId]; + if (groupObj?.tools && groupObj.tools.length > 0) { + toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id)); + } + // Remove these from the formTools updateUserPlugins.mutate( - { pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true }, + { pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true }, { - onError: (error: unknown) => { - handleInstallError(error as TError); - }, + onError: (error: unknown) => handleInstallError(error as TError), onSuccess: () => { - const fns = getValues(toolsFormKey).filter((fn: string) => fn !== tool); - setValue(toolsFormKey, fns); + const remainingToolIds = + getValues(toolsFormKey)?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || []; + setValue(toolsFormKey, remainingToolIds); }, }, ); @@ -113,17 +130,33 @@ function ToolSelectDialog({ if (authConfig && authConfig.length > 0 && !authenticated) { setShowPluginAuthForm(true); } else { - handleInstall({ pluginKey, action: 'install', auth: null }); + handleInstall({ + pluginKey, + action: 'install', + auth: {}, + }); } }; - const filteredTools = tools?.filter((tool) => - tool.name.toLowerCase().includes(searchValue.toLowerCase()), + const filteredTools = Object.values(groupedTools || {}).filter( + (tool: AgentToolType & { tools?: AgentToolType[] }) => { + // Check if the parent tool matches + if (tool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) { + return true; + } + // Check if any child tools match + if (tool.tools) { + return tool.tools.some((childTool) => + childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()), + ); + } + return false; + }, ); useEffect(() => { if (filteredTools) { - setMaxPage(Math.ceil(filteredTools.length / itemsPerPage)); + setMaxPage(Math.ceil(Object.keys(filteredTools || {}).length / itemsPerPage)); if (searchChanged) { setCurrentPage(1); setSearchChanged(false); @@ -155,7 +188,7 @@ function ToolSelectDialog({ {/* Full-screen container to center the panel */}
@@ -228,9 +261,9 @@ function ToolSelectDialog({ onAddTool(tool.pluginKey)} - onRemoveTool={() => onRemoveTool(tool.pluginKey)} + isInstalled={getValues(toolsFormKey)?.includes(tool.tool_id) || false} + onAddTool={() => onAddTool(tool.tool_id)} + onRemoveTool={() => onRemoveTool(tool.tool_id)} /> ))}
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index c314329edf..0d6d40f398 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1040,5 +1040,8 @@ "com_ui_trust_app": "I trust this application", "com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat", "com_ui_icon": "Icon", - "com_agents_mcp_icon_size": "Minimum size 128 x 128 px" + "com_agents_mcp_icon_size": "Minimum size 128 x 128 px", + "com_ui_tool_collection_prefix": "A collection of tools from", + "com_ui_tool_info": "Tool Information", + "com_ui_tool_more_info": "More information about this tool" } diff --git a/packages/data-provider/src/actions.ts b/packages/data-provider/src/actions.ts index e0e9b10d7b..cc768154e5 100644 --- a/packages/data-provider/src/actions.ts +++ b/packages/data-provider/src/actions.ts @@ -3,15 +3,11 @@ import _axios from 'axios'; import { URL } from 'url'; import crypto from 'crypto'; import { load } from 'js-yaml'; -import type { - FunctionTool, - Schema, - Reference, - ActionMetadata, - ActionMetadataRuntime, -} from './types/assistants'; +import type { ActionMetadata, ActionMetadataRuntime } from './types/agents'; +import type { FunctionTool, Schema, Reference } from './types/assistants'; +import { AuthTypeEnum, AuthorizationTypeEnum } from './types/agents'; import type { OpenAPIV3 } from 'openapi-types'; -import { Tools, AuthTypeEnum, AuthorizationTypeEnum } from './types/assistants'; +import { Tools } from './types/assistants'; export type ParametersSchema = { type: string; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index c78553f3a3..b956364835 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -2,6 +2,7 @@ import type { AxiosResponse } from 'axios'; import type * as t from './types'; import * as endpoints from './api-endpoints'; import * as a from './types/assistants'; +import * as ag from './types/agents'; import * as m from './types/mutations'; import * as q from './types/queries'; import * as f from './types/files'; @@ -351,7 +352,7 @@ export const updateAction = (data: m.UpdateActionVariables): Promise { +export function getActions(): Promise { return request.get( endpoints.agents({ path: 'actions', @@ -407,7 +408,7 @@ export const updateAgent = ({ export const duplicateAgent = ({ agent_id, -}: m.DuplicateAgentBody): Promise<{ agent: a.Agent; actions: a.Action[] }> => { +}: m.DuplicateAgentBody): Promise<{ agent: a.Agent; actions: ag.Action[] }> => { return request.post( endpoints.agents({ path: `${agent_id}/duplicate`, diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index ffcb65cda1..990b46e511 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { TUser } from './types'; import { extractEnvVariable } from './utils'; -import { TokenExchangeMethodEnum } from './types/assistants'; +import { TokenExchangeMethodEnum } from './types/agents'; const BaseOptionsSchema = z.object({ iconPath: z.string().optional(), diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index d4218440c6..ff286c21f4 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ import { StepTypes, ContentTypes, ToolCallTypes } from './runs'; +import type { TAttachment, TPlugin } from 'src/schemas'; import type { FunctionToolCall } from './assistants'; -import type { TAttachment } from 'src/schemas'; export namespace Agents { export type MessageType = 'human' | 'ai' | 'generic' | 'system' | 'function' | 'tool' | 'remove'; @@ -279,3 +279,79 @@ export type ToolCallResult = { conversationId: string; attachments?: TAttachment[]; }; + +export enum AuthTypeEnum { + ServiceHttp = 'service_http', + OAuth = 'oauth', + None = 'none', +} + +export enum AuthorizationTypeEnum { + Bearer = 'bearer', + Basic = 'basic', + Custom = 'custom', +} + +export enum TokenExchangeMethodEnum { + DefaultPost = 'default_post', + BasicAuthHeader = 'basic_auth_header', +} + +export type Action = { + action_id: string; + type?: string; + settings?: Record; + metadata: ActionMetadata; + version: number | string; +} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string }); + +export type ActionMetadata = { + api_key?: string; + auth?: ActionAuth; + domain?: string; + privacy_policy_url?: string; + raw_spec?: string; + oauth_client_id?: string; + oauth_client_secret?: string; +}; + +export type ActionAuth = { + authorization_type?: AuthorizationTypeEnum; + custom_auth_header?: string; + type?: AuthTypeEnum; + authorization_content_type?: string; + authorization_url?: string; + client_url?: string; + scope?: string; + token_exchange_method?: TokenExchangeMethodEnum; +}; + +export type ActionMetadataRuntime = ActionMetadata & { + oauth_access_token?: string; + oauth_refresh_token?: string; + oauth_token_expires_at?: Date; +}; + +export type MCP = { + mcp_id: string; + metadata: MCPMetadata; +} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string }); + +export type MCPMetadata = Omit & { + name?: string; + description?: string; + url?: string; + tools?: string[]; + auth?: MCPAuth; + icon?: string; + trust?: boolean; +}; + +export type MCPAuth = ActionAuth; + +export type AgentToolType = { + tool_id: string; + metadata: ToolMetadata; +} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string }); + +export type ToolMetadata = TPlugin; diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index c1e6f16965..c5dadc382b 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -487,77 +487,6 @@ export const actionDomainSeparator = '---'; export const hostImageIdSuffix = '_host_copy'; export const hostImageNamePrefix = 'host_copy_'; -export enum AuthTypeEnum { - ServiceHttp = 'service_http', - OAuth = 'oauth', - None = 'none', -} - -export enum AuthorizationTypeEnum { - Bearer = 'bearer', - Basic = 'basic', - Custom = 'custom', -} - -export enum TokenExchangeMethodEnum { - DefaultPost = 'default_post', - BasicAuthHeader = 'basic_auth_header', -} - -export type ActionAuth = { - authorization_type?: AuthorizationTypeEnum; - custom_auth_header?: string; - type?: AuthTypeEnum; - authorization_content_type?: string; - authorization_url?: string; - client_url?: string; - scope?: string; - token_exchange_method?: TokenExchangeMethodEnum; -}; - -export type MCPAuth = ActionAuth; - -export type ActionMetadata = { - api_key?: string; - auth?: ActionAuth; - domain?: string; - privacy_policy_url?: string; - raw_spec?: string; - oauth_client_id?: string; - oauth_client_secret?: string; -}; - -export type MCPMetadata = Omit & { - name?: string; - description?: string; - url?: string; - tools?: string[]; - auth?: MCPAuth; - icon?: string; - trust?: boolean; -}; - -export type ActionMetadataRuntime = ActionMetadata & { - oauth_access_token?: string; - oauth_refresh_token?: string; - oauth_token_expires_at?: Date; -}; - -/* Assistant types */ - -export type Action = { - action_id: string; - type?: string; - settings?: Record; - metadata: ActionMetadata; - version: number | string; -} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string }); - -export type MCP = { - mcp_id: string; - metadata: MCPMetadata; -} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string }); - export type AssistantAvatar = { filepath: string; source: string; diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index 4e1f01debe..cd6cae75c8 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -6,14 +6,13 @@ import { Assistant, AssistantCreateParams, AssistantUpdateParams, - ActionMetadata, FunctionTool, AssistantDocument, - Action, Agent, AgentCreateParams, AgentUpdateParams, } from './assistants'; +import { Action, ActionMetadata } from './agents'; export type MutationOptions< Response,