From 178be2d4c7acf19bf31c0c0e7167a1431b76ad33 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 30 Jun 2026 04:12:52 +0200 Subject: [PATCH] feat: refine agent builder tools, actions, and MCP sections --- client/src/Providers/AgentPanelContext.tsx | 6 +- client/src/common/types.ts | 3 + .../Plugins/Store/PluginAuthForm.tsx | 239 ++++++++++-------- .../SidePanel/Agents/ActionsInput.tsx | 114 ++++----- .../SidePanel/Agents/ActionsTable/Columns.tsx | 70 ++--- .../Agents/ActionsTable/Skeleton.tsx | 45 ++++ .../SidePanel/Agents/ActionsTable/Table.tsx | 27 +- .../SidePanel/Agents/ActionsTable/index.ts | 1 + .../SidePanel/Agents/AgentConfig.tsx | 22 +- .../SidePanel/Agents/FileContext.tsx | 148 ++++++----- .../SidePanel/Agents/Instructions.tsx | 12 +- .../SidePanel/Agents/MCPToolItem.tsx | 225 ++++++++--------- .../SidePanel/Agents/Tools/ActionEditor.tsx | 35 +-- .../Agents/Tools/ItemDialog/ItemDialog.tsx | 9 +- .../ItemDialog/__tests__/McpSection.spec.tsx | 16 +- .../ItemDialog/sections/ActionSection.tsx | 4 +- .../Tools/ItemDialog/sections/McpSection.tsx | 170 ++++++++++--- .../Tools/ItemDialog/sections/ToolSection.tsx | 67 ++++- .../Agents/Tools/MarketplaceSidebar.tsx | 5 +- .../SidePanel/Agents/Tools/ToolCard.tsx | 22 +- .../SidePanel/Agents/Tools/ToolRow.tsx | 15 +- .../Agents/Tools/ToolsMarketplaceDialog.tsx | 49 +++- .../SidePanel/Agents/Tools/ToolsSection.tsx | 91 +++++-- .../__tests__/ToolsMarketplaceDialog.spec.tsx | 5 + .../Tools/__tests__/ToolsSection.spec.tsx | 8 +- .../SidePanel/Agents/Tools/hooks.ts | 53 ++++ .../SidePanel/Agents/Tools/items/catalog.ts | 9 +- .../Agents/Tools/items/configurable.ts | 24 ++ .../SidePanel/Agents/UploadDropzone.tsx | 6 +- .../Agents/__tests__/FileContext.spec.tsx | 1 + .../Agents/__tests__/MCPToolItem.spec.tsx | 94 +++++++ .../SidePanel/Builder/ActionCallback.tsx | 80 +++--- .../SidePanel/Builder/ActionsAuth.tsx | 174 ++++++------- client/src/locales/en/translation.json | 10 +- client/src/style.css | 2 +- packages/client/src/components/Input.tsx | 2 +- packages/client/src/components/Radio.tsx | 53 ++-- packages/client/src/components/Tooltip.css | 2 +- 38 files changed, 1214 insertions(+), 704 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/ActionsTable/Skeleton.tsx create mode 100644 client/src/components/SidePanel/Agents/Tools/hooks.ts create mode 100644 client/src/components/SidePanel/Agents/Tools/items/configurable.ts create mode 100644 client/src/components/SidePanel/Agents/__tests__/MCPToolItem.spec.tsx diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index b0d74374b4..f465165e28 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -42,13 +42,16 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents); - const { data: mcpData } = useMCPToolsQuery({ + const { data: mcpData, isFetching: mcpToolsFetching } = useMCPToolsQuery({ enabled: !isEphemeralAgent(agent_id) && !isLoading && availableMCPServers != null && availableMCPServers.length > 0, }); + /** Tools are still arriving when the query is in flight and nothing is cached + * yet (e.g., right after a hard refresh). Lets the MCP dialog show a skeleton. */ + const mcpToolsLoading = mcpToolsFetching && mcpData == null; const { agentsConfig, endpointsConfig } = useGetAgentsConfig(); const mcpServerNames = useMemo( @@ -148,6 +151,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) agentsConfig, startupConfig, mcpServersMap, + mcpToolsLoading, setActivePanel, endpointsConfig, setCurrentAgentId, diff --git a/client/src/common/types.ts b/client/src/common/types.ts index a27743a8f0..66e145fa61 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -233,6 +233,9 @@ export type AgentPanelContextType = { endpointsConfig?: t.TEndpointsConfig | null; /** Pre-computed MCP server information indexed by server key */ mcpServersMap: Map; + /** True while the MCP tools list is being fetched and no data has arrived yet, + * so consumers can show a skeleton instead of an empty "no tools" state. */ + mcpToolsLoading: boolean; availableMCPServers: MCPServerDefinition[]; availableMCPServersMap: t.MCPServersListResponse | undefined; }; diff --git a/client/src/components/Plugins/Store/PluginAuthForm.tsx b/client/src/components/Plugins/Store/PluginAuthForm.tsx index 63e4888668..91f46aaf39 100644 --- a/client/src/components/Plugins/Store/PluginAuthForm.tsx +++ b/client/src/components/Plugins/Store/PluginAuthForm.tsx @@ -1,7 +1,7 @@ -import { Save } from 'lucide-react'; -import { useForm } from 'react-hook-form'; -import { HoverCard, HoverCardTrigger, SecretInput } from '@librechat/client'; -import { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider'; +import { useForm, useWatch } from 'react-hook-form'; +import { HoverCard, HoverCardTrigger, SecretInput, Input, Label, Button } from '@librechat/client'; +import type { TPlugin, TPluginAuthConfig, TPluginAction } from 'librechat-data-provider'; +import type { RegisterOptions } from 'react-hook-form'; import PluginTooltip from './PluginTooltip'; import { useLocalize } from '~/hooks'; @@ -34,6 +34,38 @@ const AUTH_PLACEHOLDERS: Record = { AZURE_AI_SEARCH_INDEX_NAME: 'my-index', }; +/** + * Endpoint/URL fields, keyed by primary `authField`. These get a hard validation + * rule: a value that isn't a valid http(s) URL blocks submission. + */ +const URL_FIELDS = new Set(['SD_WEBUI_URL', 'AZURE_AI_SEARCH_SERVICE_ENDPOINT']); + +/** + * Known credential prefixes per primary `authField`. A value missing its expected + * prefix surfaces a non-blocking hint, never an error — key formats drift over + * time, so we guide without trapping a key that just doesn't match. + */ +const AUTH_PREFIXES: Record = { + OPENAI_API_KEY: 'sk-', + DALLE_API_KEY: 'sk-', + DALLE2_API_KEY: 'sk-', + DALLE3_API_KEY: 'sk-', + IMAGE_GEN_OAI_API_KEY: 'sk-', + GEMINI_API_KEY: 'AIza', + GOOGLE_KEY: 'AIza', + GOOGLE_SEARCH_API_KEY: 'AIza', + TAVILY_API_KEY: 'tvly-', +}; + +const isValidUrl = (value: string): boolean => { + try { + const { protocol } = new URL(value); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +}; + type TPluginAuthFormProps = { plugin: TPlugin | undefined; onSubmit: (installActionData: TPluginAction) => void; @@ -43,113 +75,120 @@ type TPluginAuthFormProps = { function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps) { const { register, + control, handleSubmit, formState: { errors, isDirty, isValid, isSubmitting }, - } = useForm(); + } = useForm({ mode: 'onChange' }); const localize = useLocalize(); + const watchedValues = useWatch({ control }); const authConfig = plugin?.authConfig ?? []; const allFieldsOptional = authConfig.length > 0 && authConfig.every((c) => c.optional === true); + const submit = handleSubmit((auth) => + onSubmit({ + pluginKey: plugin?.pluginKey ?? '', + action: 'install', + auth, + isEntityTool, + }), + ); + return ( -
-
-
- onSubmit({ - pluginKey: plugin?.pluginKey ?? '', - action: 'install', - auth, - isEntityTool, - }), - )} - > - {authConfig.map((config: TPluginAuthConfig, i: number) => { - const authField = config.authField.split('||')[0]; - const isOptional = config.optional === true; - const inputClassName = - 'flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0'; - const sharedProps = { - id: authField, - placeholder: AUTH_PLACEHOLDERS[authField], - 'aria-invalid': !!errors[authField], - 'aria-describedby': `${authField}-error`, - 'aria-label': config.label, - 'aria-required': !isOptional, - /* autoFocus is generally disorienting, but here the required field must be navigated to - * anyway, and the form emulates a modal opening where users expect focus to shift. */ - autoFocus: i === 0, - className: inputClassName, - ...register( - authField, - isOptional - ? {} - : { - required: `${config.label} is required.`, - minLength: { - value: 1, - message: `${config.label} must be at least 1 character long`, - }, - }, - ), + +
+ {authConfig.map((config: TPluginAuthConfig, i: number) => { + const authField = config.authField.split('||')[0]; + const isOptional = config.optional === true; + const rules: RegisterOptions = {}; + if (!isOptional) { + rules.required = `${config.label} is required.`; + rules.minLength = { + value: 1, + message: `${config.label} must be at least 1 character long`, }; - return ( -
- - - - {config.sensitive === false ? ( - - ) : ( - - )} - - - - {errors[authField] && ( - - {String(errors?.[authField]?.message ?? '')} - - )} -
- ); - })} - - + ); + })}
-
+
+ +
+ ); } diff --git a/client/src/components/SidePanel/Agents/ActionsInput.tsx b/client/src/components/SidePanel/Agents/ActionsInput.tsx index 128b0d3865..4311311734 100644 --- a/client/src/components/SidePanel/Agents/ActionsInput.tsx +++ b/client/src/components/SidePanel/Agents/ActionsInput.tsx @@ -2,24 +2,22 @@ import { useState, useEffect } from 'react'; import debounce from 'lodash/debounce'; import { Maximize2 } from 'lucide-react'; import { useFormContext } from 'react-hook-form'; +import { + validateAndParseOpenAPISpec, + openapiToFunction, + AuthTypeEnum, +} from 'librechat-data-provider'; import { Button, Spinner, Textarea, - Skeleton, OGDialog, - OGDialogClose, OGDialogTitle, OGDialogHeader, OGDialogContent, OGDialogDescription, useToastContext, } from '@librechat/client'; -import { - validateAndParseOpenAPISpec, - openapiToFunction, - AuthTypeEnum, -} from 'librechat-data-provider'; import type { Action, FunctionTool, @@ -28,8 +26,8 @@ import type { } from 'librechat-data-provider'; import type { ActionAuthForm } from '~/common'; import type { Spec } from './ActionsTable'; +import { ActionsTable, ActionsTableSkeleton, columns } from './ActionsTable'; import ActionCallback from '~/components/SidePanel/Builder/ActionCallback'; -import { ActionsTable, columns } from './ActionsTable'; import { useUpdateAgentAction } from '~/data-provider'; import { useLocalize } from '~/hooks'; import { logger } from '~/utils'; @@ -43,20 +41,6 @@ const debouncedValidation = debounce( ); /** Placeholder rows shaped like the "Available actions" table (Name / Method / Path). */ -function ActionsTableSkeleton() { - return ( -
- {Array.from({ length: 3 }, (_, i) => ( -
- - - -
- ))} -
- ); -} - export default function ActionsInput({ action, agent_id, @@ -97,7 +81,7 @@ export default function ActionsInput({ } setInputValue(rawSpec); setIsValidating(true); - debouncedValidation(rawSpec, handleResult); + handleResult(validateAndParseOpenAPISpec(rawSpec)); }, [action?.metadata.raw_spec]); useEffect(() => { @@ -251,7 +235,7 @@ export default function ActionsInput({ return ( <> -
+
-
-
-