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