feat: Enhance Agent Panel with Tool Grouping (#7951)

*  feat: Enhance Agent Panel with Tool Grouping

* 🧰 feat: Added support for grouping tools in the Agent Panel, allowing for better organization and management of related tools.
* 💡 feat: Added hovercards for tools belonging to a group which display their tool descriptions when their help icon is hovered over.
* 🧹 chore: Updated the AgentPanelContext to include grouped tools and their metadata.
* 🔨 refactor: Refactored AgentConfig and AgentTool components to utilize the new tool structure and enhance rendering logic.
* 🔍 feat: Improved the ToolSelectDialog to filter and display tools based on user input, including searching for tools within a group, and limits viewport height to prevent overflowing vertically on smaller screens.

This update enhances the overall functionality and usability of the Agent Panel, making it easier for users to interact with tools.

* Potential fix for code scanning alert no. 6217: Disallow unused variables

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: Agent tool type mismatches

* fix: accessibility issues and mcp tool overflow issue

* fix: enhance keyboard accessibility and prevent event propagation in AgentTool

* chore: WIP types

* chore: address comments and fix accordian collapse bug

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2025-06-19 07:01:50 -07:00 committed by GitHub
parent c7e4523d7c
commit 8b15bb2ed6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 602 additions and 195 deletions

View file

@ -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<AgentPanelContextType | undefined>(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<MCP | undefined>(undefined);
const [mcps, setMcps] = useState<MCP[] | undefined>(undefined);
const [action, setAction] = useState<Action | undefined>(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<string, AgentToolType & { tools?: AgentToolType[] }>,
) || {};
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 <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;

View file

@ -219,6 +219,8 @@ export type AgentPanelContextType = {
mcps?: t.MCP[];
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
tools: t.AgentToolType[];
activePanel?: string;
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;

View file

@ -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<AgentPanelProps, 'agentsConfig' | 'createMutation' | 'endpointsConfig'>) {
const localize = useLocalize();
const fileMap = useFileMapContext();
const queryClient = useQueryClient();
const { showToast } = useToastContext();
const methods = useFormContext<AgentForm>();
const [showToolDialog, setShowToolDialog] = useState(false);
const { actions, setAction, setActivePanel } = useAgentPanelContext();
const allTools = queryClient.getQueryData<TPlugin[]>([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 (
<>
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
@ -287,28 +295,37 @@ export default function AgentConfig({
${toolsEnabled === true && actionsEnabled === true ? ' + ' : ''}
${actionsEnabled === true ? localize('com_assistants_actions') : ''}`}
</label>
<div className="space-y-2">
{tools?.map((func, i) => (
<AgentTool
key={`${func}-${i}-${agent_id}`}
tool={func}
allTools={allTools}
agent_id={agent_id}
/>
))}
{(actions ?? [])
.filter((action) => action.agent_id === agent_id)
.map((action, i) => (
<Action
key={i}
action={action}
onClick={() => {
setAction(action);
setActivePanel(Panel.actions);
}}
/>
))}
<div className="flex space-x-2">
<div>
<div className="mb-1">
{/* // Render all visible IDs (including groups with subtools selected) */}
{[...visibleToolIds].map((toolId, i) => {
const tool = allTools[toolId];
if (!tool) return null;
return (
<AgentTool
key={`${toolId}-${i}-${agent_id}`}
tool={toolId}
allTools={allTools}
agent_id={agent_id}
/>
);
})}
</div>
<div className="flex flex-col gap-1">
{(actions ?? [])
.filter((action) => action.agent_id === agent_id)
.map((action, i) => (
<Action
key={i}
action={action}
onClick={() => {
setAction(action);
setActivePanel(Panel.actions);
}}
/>
))}
</div>
<div className="mt-2 flex space-x-2">
{(toolsEnabled ?? false) && (
<button
type="button"
@ -343,7 +360,6 @@ export default function AgentConfig({
<ToolSelectDialog
isOpen={showToolDialog}
setIsOpen={setShowToolDialog}
toolsFormKey="tools"
endpoint={EModelEndpoint.agents}
/>
</>

View file

@ -1,41 +1,69 @@
import React, { useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import type { TPlugin } from 'librechat-data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import type { AgentToolType } from 'librechat-data-provider';
import type { AgentForm } from '~/common';
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion';
import { OGDialog, OGDialogTrigger, Label, Checkbox } from '~/components/ui';
import { TrashIcon, CircleHelpIcon } from '~/components/svg';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useToastContext } from '~/Providers';
import { TrashIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function AgentTool({
tool,
allTools,
agent_id = '',
}: {
tool: string;
allTools: TPlugin[];
allTools: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
agent_id?: string;
}) {
const [isHovering, setIsHovering] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [hoveredToolId, setHoveredToolId] = useState<string | null>(null);
const [accordionValue, setAccordionValue] = useState<string>('');
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext();
const currentTool = allTools.find((t) => t.pluginKey === tool);
const { getValues, setValue } = useFormContext<AgentForm>();
const currentTool = allTools[tool];
const getSelectedTools = () => {
if (!currentTool?.tools) return [];
const formTools = getValues('tools') || [];
return currentTool.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
};
const updateFormTools = (newSelectedTools: string[]) => {
const currentTools = getValues('tools') || [];
const otherTools = currentTools.filter(
(t: string) => !currentTool?.tools?.some((st) => st.tool_id === t),
);
setValue('tools', [...otherTools, ...newSelectedTools]);
};
const removeTool = (toolId: string) => {
if (toolId) {
const toolIdsToRemove =
isGroup && currentTool.tools
? [toolId, ...currentTool.tools.map((t) => t.tool_id)]
: [toolId];
const removeTool = (tool: string) => {
if (tool) {
updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
{
onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
},
onSuccess: () => {
const tools = getValues('tools').filter((fn: string) => fn !== tool);
setValue('tools', tools);
const remainingToolIds = getValues('tools')?.filter(
(toolId: string) => !toolIdsToRemove.includes(toolId),
);
setValue('tools', remainingToolIds);
showToast({ message: 'Tool deleted successfully', status: 'success' });
},
},
@ -47,41 +75,309 @@ export default function AgentTool({
return null;
}
return (
<OGDialog>
<div
className={cn('flex w-full items-center rounded-lg text-sm', !agent_id ? 'opacity-40' : '')}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<div className="flex grow items-center">
{currentTool.icon && (
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{ backgroundImage: `url(${currentTool.icon})`, backgroundSize: 'cover' }}
/>
</div>
)}
<div
className="h-9 grow px-3 py-2"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.name}
</div>
</div>
const isGroup = currentTool.tools && currentTool.tools.length > 0;
const selectedTools = getSelectedTools();
const isExpanded = accordionValue === currentTool.tool_id;
if (!isGroup) {
return (
<OGDialog>
<div
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800/50"
onMouseEnter={() => 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);
}
}}
>
<div className="flex grow items-center">
{currentTool.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${currentTool.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.metadata.name}
</div>
</div>
{isHovering && (
<OGDialogTrigger asChild>
<button
type="button"
className="transition-color flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200',
'hover:bg-gray-200 dark:hover:bg-gray-700',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'focus:opacity-100',
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
aria-label={`Delete ${currentTool.metadata.name}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon />
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
)}
</div>
</div>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
mainClassName="px-0"
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
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'),
}}
/>
</OGDialog>
);
}
// Group tool with accordion
return (
<OGDialog>
<Accordion type="single" value={accordionValue} onValueChange={setAccordionValue} collapsible>
<AccordionItem value={currentTool.tool_id} className="group relative w-full border-none">
<div
className="relative flex w-full items-center gap-1 rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800/50"
onMouseEnter={() => 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);
}
}}
>
<AccordionPrimitive.Header asChild>
<AccordionPrimitive.Trigger asChild>
<button
type="button"
className={cn(
'flex grow items-center gap-1 rounded bg-transparent p-0 text-left transition-colors',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
)}
>
{currentTool.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${currentTool.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentTool.metadata.name}
</div>
<div className="flex items-center">
{/* Container for grouped checkbox and chevron */}
<div className="relative flex items-center">
{/* Grouped checkbox and chevron that slide together */}
<div
className={cn(
'flex items-center gap-2 transition-all duration-300',
isHovering || isFocused ? '-translate-x-8' : 'translate-x-0',
)}
>
<div
data-checkbox-container
onClick={(e) => e.stopPropagation()}
className="mt-1"
>
<Checkbox
id={`select-all-${currentTool.tool_id}`}
checked={selectedTools.length === currentTool.tools?.length}
onCheckedChange={(checked) => {
if (currentTool.tools) {
const newSelectedTools = checked
? currentTool.tools.map((t) => t.tool_id)
: [];
updateFormTools(newSelectedTools);
}
}}
className={cn(
'h-4 w-4 rounded border border-gray-300 transition-all duration-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500',
isExpanded ? 'opacity-100' : 'opacity-0',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
tabIndex={isExpanded ? 0 : -1}
/>
</div>
<div
className={cn(
'pointer-events-none flex h-4 w-4 items-center justify-center transition-transform duration-300',
isExpanded ? 'rotate-180' : '',
)}
aria-hidden="true"
>
<ChevronDown className="h-4 w-4" />
</div>
</div>
{/* Delete button slides in from behind */}
<div
className={cn(
'absolute right-0 transition-all duration-300',
isHovering || isFocused
? 'translate-x-0 opacity-100'
: 'translate-x-8 opacity-0',
)}
>
<OGDialogTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
'hover:bg-gray-200 dark:hover:bg-gray-700',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'focus:translate-x-0 focus:opacity-100',
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Delete ${currentTool.metadata.name}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</div>
</div>
</div>
</button>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
</div>
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
<div className="space-y-1">
{currentTool.tools?.map((subTool) => (
<label
key={subTool.tool_id}
htmlFor={subTool.tool_id}
className={cn(
'border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2',
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
}}
onMouseEnter={() => setHoveredToolId(subTool.tool_id)}
onMouseLeave={() => setHoveredToolId(null)}
>
<Checkbox
id={subTool.tool_id}
checked={selectedTools.includes(subTool.tool_id)}
onCheckedChange={(_checked) => {
const newSelectedTools = selectedTools.includes(subTool.tool_id)
? selectedTools.filter((t) => t !== subTool.tool_id)
: [...selectedTools, subTool.tool_id];
updateFormTools(newSelectedTools);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
onClick={(e) => e.stopPropagation()}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-gray-300 transition-[border-color] duration-200 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background dark:border-gray-600 dark:hover:border-gray-500"
/>
<span className="text-token-text-primary">{subTool.metadata.name}</span>
{subTool.metadata.description && (
<Ariakit.HovercardProvider placement="left-start">
<div className="ml-auto flex h-6 w-6 items-center justify-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
className={cn(
'flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary transition-opacity duration-200',
hoveredToolId === subTool.tool_id ? 'opacity-100' : 'opacity-0',
)}
aria-label={localize('com_ui_tool_info')}
>
<CircleHelpIcon className="h-4 w-4" />
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_info')}
</Ariakit.VisuallyHidden>
</Ariakit.Button>
}
/>
<Ariakit.HovercardDisclosure
className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_tool_more_info')}
aria-expanded={hoveredToolId === subTool.tool_id}
aria-controls={`tool-description-${subTool.tool_id}`}
>
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_more_info')}
</Ariakit.VisuallyHidden>
<ChevronDown className="h-4 w-4" />
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
id={`tool-description-${subTool.tool_id}`}
gutter={14}
shift={40}
flip={false}
className="z-[999] w-80 scale-95 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary opacity-0 shadow-md transition-all duration-200 data-[enter]:scale-100 data-[leave]:scale-95 data-[enter]:opacity-100 data-[leave]:opacity-0"
portal={true}
unmountOnHide={true}
role="tooltip"
aria-label={subTool.metadata.description}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{subTool.metadata.description}
</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
)}
</label>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
@ -93,7 +389,7 @@ export default function AgentTool({
</Label>
}
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'),

View file

@ -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 (
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">
<div className="flex gap-4">
<div className="h-[70px] w-[70px] shrink-0">
<div className="relative h-full w-full">
{tool.icon != null && tool.icon ? (
{icon ? (
<img
src={tool.icon}
alt={localize('com_ui_logo', { 0: tool.name })}
src={icon}
alt={localize('com_ui_logo', { 0: name })}
className="h-full w-full rounded-[5px] bg-white"
/>
) : (
@ -40,12 +44,12 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
</div>
<div className="flex min-w-0 flex-col items-start justify-between">
<div className="mb-2 line-clamp-1 max-w-full text-lg leading-5 text-text-primary">
{tool.name}
{name}
</div>
{!isInstalled ? (
<button
className="btn btn-primary relative"
aria-label={`${localize('com_ui_add')} ${tool.name}`}
aria-label={`${localize('com_ui_add')} ${name}`}
onClick={handleClick}
>
<div className="flex w-full items-center justify-center gap-2">
@ -57,7 +61,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
<button
className="btn relative bg-gray-300 hover:bg-gray-400 dark:bg-gray-50 dark:hover:bg-gray-200"
onClick={handleClick}
aria-label={`${localize('com_nav_tool_remove')} ${tool.name}`}
aria-label={`${localize('com_nav_tool_remove')} ${name}`}
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_nav_tool_remove')}
@ -67,7 +71,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
)}
</div>
</div>
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{tool.description}</div>
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{description}</div>
</div>
);
}

View file

@ -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<AgentForm>();
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 */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel
className="relative w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
className="relative max-h-[90vh] w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
style={{ minHeight: '610px' }}
>
<div className="flex items-center justify-between border-b-[1px] border-border-medium px-4 pb-4 pt-5 sm:p-6">
@ -228,9 +261,9 @@ function ToolSelectDialog({
<ToolItem
key={index}
tool={tool}
isInstalled={getValues(toolsFormKey).includes(tool.pluginKey)}
onAddTool={() => 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)}
/>
))}
</div>

View file

@ -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"
}

View file

@ -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;

View file

@ -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<m.UpdateAct
);
};
export function getActions(): Promise<a.Action[]> {
export function getActions(): Promise<ag.Action[]> {
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`,

View file

@ -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(),

View file

@ -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<string, unknown>;
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<ActionMetadata, 'auth'> & {
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;

View file

@ -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<ActionMetadata, 'auth'> & {
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<string, unknown>;
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;

View file

@ -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,