diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx new file mode 100644 index 0000000000..628eda00f2 --- /dev/null +++ b/client/src/Providers/AgentPanelContext.tsx @@ -0,0 +1,45 @@ +import React, { createContext, useContext, useState } from 'react'; +import { Action, MCP, EModelEndpoint } from 'librechat-data-provider'; +import type { AgentPanelContextType } from '~/common'; +import { useGetActionsQuery } from '~/data-provider'; +import { Panel } from '~/common'; + +const AgentPanelContext = createContext(undefined); + +export function useAgentPanelContext() { + const context = useContext(AgentPanelContext); + if (context === undefined) { + throw new Error('useAgentPanelContext must be used within an AgentPanelProvider'); + } + return context; +} + +/** Houses relevant state for the Agent Form Panels (formerly 'commonProps') */ +export function AgentPanelProvider({ children }: { children: React.ReactNode }) { + const [mcp, setMcp] = useState(undefined); + const [mcps, setMcps] = useState(undefined); + const [action, setAction] = useState(undefined); + const [activePanel, setActivePanel] = useState(Panel.builder); + const [agent_id, setCurrentAgentId] = useState(undefined); + + const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, { + enabled: !!agent_id, + }); + + const value = { + action, + setAction, + mcp, + setMcp, + mcps, + setMcps, + activePanel, + setActivePanel, + setCurrentAgentId, + agent_id, + /** Query data for actions */ + actions, + }; + + return {children}; +} diff --git a/client/src/Providers/AgentsContext.tsx b/client/src/Providers/AgentsContext.tsx index e793a3f087..a90a53ecb5 100644 --- a/client/src/Providers/AgentsContext.tsx +++ b/client/src/Providers/AgentsContext.tsx @@ -1,8 +1,8 @@ import { useForm, FormProvider } from 'react-hook-form'; import { createContext, useContext } from 'react'; -import { defaultAgentFormValues } from 'librechat-data-provider'; import type { UseFormReturn } from 'react-hook-form'; import type { AgentForm } from '~/common'; +import { getDefaultAgentFormValues } from '~/utils'; type AgentsContextType = UseFormReturn; @@ -20,7 +20,7 @@ export function useAgentsContext() { export default function AgentsProvider({ children }) { const methods = useForm({ - defaultValues: defaultAgentFormValues, + defaultValues: getDefaultAgentFormValues(), }); return {children}; diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 00191318e0..41c9cdceb3 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -1,6 +1,7 @@ -export { default as ToastProvider } from './ToastContext'; export { default as AssistantsProvider } from './AssistantsContext'; export { default as AgentsProvider } from './AgentsContext'; +export { default as ToastProvider } from './ToastContext'; +export * from './AgentPanelContext'; export * from './ChatContext'; export * from './ShareContext'; export * from './ToastContext'; diff --git a/client/src/common/mcp.ts b/client/src/common/mcp.ts new file mode 100644 index 0000000000..b4f44a1f94 --- /dev/null +++ b/client/src/common/mcp.ts @@ -0,0 +1,26 @@ +import { + AuthorizationTypeEnum, + AuthTypeEnum, + TokenExchangeMethodEnum, +} from 'librechat-data-provider'; +import { MCPForm } from '~/common/types'; + +export const defaultMCPFormValues: MCPForm = { + type: AuthTypeEnum.None, + saved_auth_fields: false, + api_key: '', + authorization_type: AuthorizationTypeEnum.Basic, + custom_auth_header: '', + oauth_client_id: '', + oauth_client_secret: '', + authorization_url: '', + client_url: '', + scope: '', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + name: '', + description: '', + url: '', + tools: [], + icon: '', + trust: false, +}; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 0ac6387c33..6fe4784fbc 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -143,6 +143,7 @@ export enum Panel { actions = 'actions', model = 'model', version = 'version', + mcp = 'mcp', } export type FileSetter = @@ -166,6 +167,15 @@ export type ActionAuthForm = { token_exchange_method: t.TokenExchangeMethodEnum; }; +export type MCPForm = ActionAuthForm & { + name?: string; + description?: string; + url?: string; + tools?: string[]; + icon?: string; + trust?: boolean; +}; + export type ActionWithNullableMetadata = Omit & { metadata: t.ActionMetadata | null; }; @@ -188,16 +198,33 @@ export type AgentPanelProps = { index?: number; agent_id?: string; activePanel?: string; + mcp?: t.MCP; + mcps?: t.MCP[]; action?: t.Action; actions?: t.Action[]; createMutation: UseMutationResult; setActivePanel: React.Dispatch>; + setMcp: React.Dispatch>; setAction: React.Dispatch>; endpointsConfig?: t.TEndpointsConfig; setCurrentAgentId: React.Dispatch>; agentsConfig?: t.TAgentsEndpoint | null; }; +export type AgentPanelContextType = { + action?: t.Action; + actions?: t.Action[]; + setAction: React.Dispatch>; + mcp?: t.MCP; + mcps?: t.MCP[]; + setMcp: React.Dispatch>; + setMcps: React.Dispatch>; + activePanel?: string; + setActivePanel: React.Dispatch>; + setCurrentAgentId: React.Dispatch>; + agent_id?: string; +}; + export type AgentModelPanelProps = { agent_id?: string; providers: Option[]; diff --git a/client/src/components/SidePanel/Agents/ActionsPanel.tsx b/client/src/components/SidePanel/Agents/ActionsPanel.tsx index 0ae564b05a..89b9a87f92 100644 --- a/client/src/components/SidePanel/Agents/ActionsPanel.tsx +++ b/client/src/components/SidePanel/Agents/ActionsPanel.tsx @@ -1,31 +1,27 @@ import { useEffect } from 'react'; +import { ChevronLeft } from 'lucide-react'; import { useForm, FormProvider } from 'react-hook-form'; import { AuthTypeEnum, AuthorizationTypeEnum, TokenExchangeMethodEnum, } from 'librechat-data-provider'; -import { ChevronLeft } from 'lucide-react'; -import type { AgentPanelProps, ActionAuthForm } from '~/common'; import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth'; +import { useAgentPanelContext } from '~/Providers/AgentPanelContext'; import { OGDialog, OGDialogTrigger, Label } from '~/components/ui'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import { useDeleteAgentAction } from '~/data-provider'; +import type { ActionAuthForm } from '~/common'; import useLocalize from '~/hooks/useLocalize'; import { useToastContext } from '~/Providers'; import { TrashIcon } from '~/components/svg'; import ActionsInput from './ActionsInput'; import { Panel } from '~/common'; -export default function ActionsPanel({ - // activePanel, - action, - setAction, - agent_id, - setActivePanel, -}: AgentPanelProps) { +export default function ActionsPanel() { const localize = useLocalize(); const { showToast } = useToastContext(); + const { setActivePanel, action, setAction, agent_id } = useAgentPanelContext(); const deleteAgentAction = useDeleteAgentAction({ onSuccess: () => { showToast({ @@ -62,7 +58,7 @@ export default function ActionsPanel({ }, }); - const { reset, watch } = methods; + const { reset } = methods; useEffect(() => { if (action?.metadata.auth) { diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index c04018e69a..2b056a5181 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -1,11 +1,11 @@ -import React, { useState, useMemo, useCallback } from 'react'; 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 type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common'; import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils'; -import { useToastContext, useFileMapContext } from '~/Providers'; +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,6 +15,7 @@ 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'; @@ -29,23 +30,19 @@ const inputClass = cn( ); export default function AgentConfig({ - setAction, - actions = [], agentsConfig, createMutation, - setActivePanel, endpointsConfig, -}: AgentPanelProps) { +}: 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 { showToast } = useToastContext(); - const localize = useLocalize(); - - const [showToolDialog, setShowToolDialog] = useState(false); - - const methods = useFormContext(); const { control } = methods; const provider = useWatch({ control, name: 'provider' }); @@ -299,7 +296,7 @@ export default function AgentConfig({ agent_id={agent_id} /> ))} - {actions + {(actions ?? []) .filter((action) => action.agent_id === agent_id) .map((action, i) => ( + {/* MCP Section */} + {/* */} modelsQuery.data ?? {}, [modelsQuery.data]); const methods = useForm({ - defaultValues: defaultAgentFormValues, + defaultValues: getDefaultAgentFormValues(), }); const { control, handleSubmit, reset } = methods; @@ -277,7 +282,7 @@ export default function AgentPanel({ variant="outline" className="w-full justify-center" onClick={() => { - reset(defaultAgentFormValues); + reset(getDefaultAgentFormValues()); setCurrentAgentId(undefined); }} disabled={agentQuery.isInitialLoading} @@ -315,22 +320,13 @@ export default function AgentPanel({ )} {canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.model && ( - + )} {canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.builder && ( )} {canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.advanced && ( diff --git a/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx b/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx index 4dc54c9b60..495b047b51 100644 --- a/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanelSwitch.tsx @@ -1,22 +1,29 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider'; -import type { ActionsEndpoint } from '~/common'; -import type { Action, TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider'; -import { useGetActionsQuery, useGetEndpointsQuery, useCreateAgentMutation } from '~/data-provider'; +import type { TConfig, TEndpointsConfig, TAgentsEndpoint } from 'librechat-data-provider'; +import { AgentPanelProvider, useAgentPanelContext } from '~/Providers/AgentPanelContext'; +import { useGetEndpointsQuery } from '~/data-provider'; +import VersionPanel from './Version/VersionPanel'; import { useChatContext } from '~/Providers'; import ActionsPanel from './ActionsPanel'; import AgentPanel from './AgentPanel'; -import VersionPanel from './Version/VersionPanel'; +import MCPPanel from './MCPPanel'; import { Panel } from '~/common'; export default function AgentPanelSwitch() { - const { conversation, index } = useChatContext(); - const [activePanel, setActivePanel] = useState(Panel.builder); - const [action, setAction] = useState(undefined); - const [currentAgentId, setCurrentAgentId] = useState(conversation?.agent_id); - const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint); + return ( + + + + ); +} + +function AgentPanelSwitchWithContext() { + const { conversation } = useChatContext(); + const { activePanel, setCurrentAgentId } = useAgentPanelContext(); + + // TODO: Implement MCP endpoint const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); - const createMutation = useCreateAgentMutation(); const agentsConfig = useMemo(() => { const config = endpointsConfig?.[EModelEndpoint.agents] ?? null; @@ -35,39 +42,20 @@ export default function AgentPanelSwitch() { if (agent_id) { setCurrentAgentId(agent_id); } - }, [conversation?.agent_id]); + }, [setCurrentAgentId, conversation?.agent_id]); if (!conversation?.endpoint) { return null; } - const commonProps = { - index, - action, - actions, - setAction, - activePanel, - setActivePanel, - setCurrentAgentId, - agent_id: currentAgentId, - createMutation, - }; - if (activePanel === Panel.actions) { - return ; + return ; } - if (activePanel === Panel.version) { - return ( - - ); + return ; } - - return ( - - ); + if (activePanel === Panel.mcp) { + return ; + } + return ; } diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index d265ba201f..496dd4ee6d 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -5,8 +5,8 @@ import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provid import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query'; import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { TAgentCapabilities, AgentForm } from '~/common'; +import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils'; import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider'; -import { cn, createProviderOption, processAgentOption } from '~/utils'; import ControlCombobox from '~/components/ui/ControlCombobox'; import { useLocalize } from '~/hooks'; @@ -32,7 +32,10 @@ export default function AgentSelect({ select: (res) => res.data.map((agent) => processAgentOption({ - agent, + agent: { + ...agent, + name: agent.name || agent.id, + }, instanceProjectId: startupConfig?.instanceProjectId, }), ), @@ -124,9 +127,7 @@ export default function AgentSelect({ createMutation.reset(); if (!agentExists) { setCurrentAgentId(undefined); - return reset({ - ...defaultAgentFormValues, - }); + return reset(getDefaultAgentFormValues()); } setCurrentAgentId(selectedId); @@ -179,7 +180,7 @@ export default function AgentSelect({ containerClassName="px-0" selectedValue={(field?.value?.value ?? '') + ''} displayValue={field?.value?.label ?? ''} - selectPlaceholder={createAgent} + selectPlaceholder={field?.value?.value ?? createAgent} iconSide="right" searchPlaceholder={localize('com_agents_search_name')} SelectIcon={field?.value?.icon} diff --git a/client/src/components/SidePanel/Agents/DeleteButton.tsx b/client/src/components/SidePanel/Agents/DeleteButton.tsx index 388d8f25e0..bfe08c666f 100644 --- a/client/src/components/SidePanel/Agents/DeleteButton.tsx +++ b/client/src/components/SidePanel/Agents/DeleteButton.tsx @@ -1,12 +1,11 @@ import { useFormContext } from 'react-hook-form'; -import { defaultAgentFormValues } from 'librechat-data-provider'; import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; +import { cn, logger, removeFocusOutlines, getDefaultAgentFormValues } from '~/utils'; import { OGDialog, OGDialogTrigger, Label } from '~/components/ui'; -import { useChatContext, useToastContext } from '~/Providers'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; +import { useChatContext, useToastContext } from '~/Providers'; import { useLocalize, useSetIndexOptions } from '~/hooks'; -import { cn, removeFocusOutlines, logger } from '~/utils'; import { useDeleteAgentMutation } from '~/data-provider'; import { TrashIcon } from '~/components/svg'; @@ -45,9 +44,7 @@ export default function DeleteButton({ const firstAgent = updatedList[0] as Agent | undefined; if (!firstAgent) { setCurrentAgentId(undefined); - reset({ - ...defaultAgentFormValues, - }); + reset(getDefaultAgentFormValues()); return setOption('agent_id')(''); } diff --git a/client/src/components/SidePanel/Agents/MCPIcon.tsx b/client/src/components/SidePanel/Agents/MCPIcon.tsx new file mode 100644 index 0000000000..101abb4c9e --- /dev/null +++ b/client/src/components/SidePanel/Agents/MCPIcon.tsx @@ -0,0 +1,64 @@ +import { useState, useEffect, useRef } from 'react'; +import SquirclePlusIcon from '~/components/svg/SquirclePlusIcon'; +import { useLocalize } from '~/hooks'; + +interface MCPIconProps { + icon?: string; + onIconChange: (e: React.ChangeEvent) => void; +} + +export default function MCPIcon({ icon, onIconChange }: MCPIconProps) { + const [previewUrl, setPreviewUrl] = useState(''); + const fileInputRef = useRef(null); + const localize = useLocalize(); + + useEffect(() => { + if (icon) { + setPreviewUrl(icon); + } else { + setPreviewUrl(''); + } + }, [icon]); + + const handleClick = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + fileInputRef.current.click(); + } + }; + + return ( +
+
+ {previewUrl ? ( + MCP Icon + ) : ( + + )} +
+
+ + {localize('com_ui_icon')} {localize('com_ui_optional')} + + {localize('com_agents_mcp_icon_size')} +
+ +
+ ); +} diff --git a/client/src/components/SidePanel/Agents/MCPInput.tsx b/client/src/components/SidePanel/Agents/MCPInput.tsx new file mode 100644 index 0000000000..078f4109dc --- /dev/null +++ b/client/src/components/SidePanel/Agents/MCPInput.tsx @@ -0,0 +1,288 @@ +import { useState, useEffect } from 'react'; +import { useFormContext, Controller } from 'react-hook-form'; +import { MCP } from 'librechat-data-provider/dist/types/types/assistants'; +import MCPAuth from '~/components/SidePanel/Builder/MCPAuth'; +import MCPIcon from '~/components/SidePanel/Agents/MCPIcon'; +import { Label, Checkbox } from '~/components/ui'; +import useLocalize from '~/hooks/useLocalize'; +import { useToastContext } from '~/Providers'; +import { Spinner } from '~/components/svg'; +import { MCPForm } from '~/common/types'; + +function useUpdateAgentMCP({ + onSuccess, + onError, +}: { + onSuccess: (data: [string, MCP]) => void; + onError: (error: Error) => void; +}) { + return { + mutate: async ({ + mcp_id, + metadata, + agent_id, + }: { + mcp_id?: string; + metadata: MCP['metadata']; + agent_id: string; + }) => { + try { + // TODO: Implement MCP endpoint + onSuccess(['success', { mcp_id, metadata, agent_id } as MCP]); + } catch (error) { + onError(error as Error); + } + }, + isLoading: false, + }; +} + +interface MCPInputProps { + mcp?: MCP; + agent_id?: string; + setMCP: React.Dispatch>; +} + +export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { + handleSubmit, + register, + formState: { errors }, + control, + } = useFormContext(); + const [isLoading, setIsLoading] = useState(false); + const [showTools, setShowTools] = useState(false); + const [selectedTools, setSelectedTools] = useState([]); + + // Initialize tools list if editing existing MCP + useEffect(() => { + if (mcp?.mcp_id && mcp.metadata.tools) { + setShowTools(true); + setSelectedTools(mcp.metadata.tools); + } + }, [mcp]); + + const updateAgentMCP = useUpdateAgentMCP({ + onSuccess(data) { + showToast({ + message: localize('com_ui_update_mcp_success'), + status: 'success', + }); + setMCP(data[1]); + setShowTools(true); + setSelectedTools(data[1].metadata.tools ?? []); + setIsLoading(false); + }, + onError(error) { + showToast({ + message: (error as Error).message || localize('com_ui_update_mcp_error'), + status: 'error', + }); + setIsLoading(false); + }, + }); + + const saveMCP = handleSubmit(async (data: MCPForm) => { + setIsLoading(true); + try { + const response = await updateAgentMCP.mutate({ + agent_id: agent_id ?? '', + mcp_id: mcp?.mcp_id, + metadata: { + ...data, + tools: selectedTools, + }, + }); + setMCP(response[1]); + showToast({ + message: localize('com_ui_update_mcp_success'), + status: 'success', + }); + } catch { + showToast({ + message: localize('com_ui_update_mcp_error'), + status: 'error', + }); + } finally { + setIsLoading(false); + } + }); + + const handleSelectAll = () => { + if (mcp?.metadata.tools) { + setSelectedTools(mcp.metadata.tools); + } + }; + + const handleDeselectAll = () => { + setSelectedTools([]); + }; + + const handleToolToggle = (tool: string) => { + setSelectedTools((prev) => + prev.includes(tool) ? prev.filter((t) => t !== tool) : [...prev, tool], + ); + }; + + const handleToggleAll = () => { + if (selectedTools.length === mcp?.metadata.tools?.length) { + handleDeselectAll(); + } else { + handleSelectAll(); + } + }; + + const handleIconChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onloadend = () => { + const base64String = reader.result as string; + setMCP({ + mcp_id: mcp?.mcp_id ?? '', + agent_id: agent_id ?? '', + metadata: { + ...mcp?.metadata, + icon: base64String, + }, + }); + }; + reader.readAsDataURL(file); + } + }; + + return ( +
+ {/* Icon Picker */} +
+ +
+ {/* name, description, url */} +
+
+ + + {errors.name && ( + {localize('com_ui_field_required')} + )} +
+
+ + +
+
+ + + {errors.url && ( + + {errors.url.type === 'required' + ? localize('com_ui_field_required') + : errors.url.message} + + )} +
+ +
+ ( + + )} + /> + +
+ {errors.trust && ( + {localize('com_ui_field_required')} + )} +
+ +
+ +
+ + {showTools && mcp?.metadata.tools && ( +
+
+

+ {localize('com_ui_available_tools')} +

+ +
+
+ {mcp.metadata.tools.map((tool) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/client/src/components/SidePanel/Agents/MCPPanel.tsx b/client/src/components/SidePanel/Agents/MCPPanel.tsx new file mode 100644 index 0000000000..016e445e4f --- /dev/null +++ b/client/src/components/SidePanel/Agents/MCPPanel.tsx @@ -0,0 +1,172 @@ +import { useEffect } from 'react'; +import { ChevronLeft } from 'lucide-react'; +import { useForm, FormProvider } from 'react-hook-form'; +import { useAgentPanelContext } from '~/Providers/AgentPanelContext'; +import { OGDialog, OGDialogTrigger, Label } from '~/components/ui'; +import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; +import { defaultMCPFormValues } from '~/common/mcp'; +import useLocalize from '~/hooks/useLocalize'; +import { useToastContext } from '~/Providers'; +import { TrashIcon } from '~/components/svg'; +import type { MCPForm } from '~/common'; +import MCPInput from './MCPInput'; +import { Panel } from '~/common'; +import { + AuthTypeEnum, + AuthorizationTypeEnum, + TokenExchangeMethodEnum, +} from 'librechat-data-provider'; +// TODO: Add MCP delete (for now mocked for ui) +// import { useDeleteAgentMCP } from '~/data-provider'; + +function useDeleteAgentMCP({ + onSuccess, + onError, +}: { + onSuccess: () => void; + onError: (error: Error) => void; +}) { + return { + mutate: async ({ mcp_id, agent_id }: { mcp_id: string; agent_id: string }) => { + try { + console.log('Mock delete MCP:', { mcp_id, agent_id }); + onSuccess(); + } catch (error) { + onError(error as Error); + } + }, + }; +} + +export default function MCPPanel() { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { mcp, setMcp, agent_id, setActivePanel } = useAgentPanelContext(); + const deleteAgentMCP = useDeleteAgentMCP({ + onSuccess: () => { + showToast({ + message: localize('com_ui_delete_mcp_success'), + status: 'success', + }); + setActivePanel(Panel.builder); + setMcp(undefined); + }, + onError(error) { + showToast({ + message: (error as Error).message ?? localize('com_ui_delete_mcp_error'), + status: 'error', + }); + }, + }); + + const methods = useForm({ + defaultValues: defaultMCPFormValues, + }); + + const { reset } = methods; + + useEffect(() => { + if (mcp) { + const formData = { + icon: mcp.metadata.icon ?? '', + name: mcp.metadata.name ?? '', + description: mcp.metadata.description ?? '', + url: mcp.metadata.url ?? '', + tools: mcp.metadata.tools ?? [], + trust: mcp.metadata.trust ?? false, + }; + + if (mcp.metadata.auth) { + Object.assign(formData, { + type: mcp.metadata.auth.type || AuthTypeEnum.None, + saved_auth_fields: false, + api_key: mcp.metadata.api_key ?? '', + authorization_type: mcp.metadata.auth.authorization_type || AuthorizationTypeEnum.Basic, + oauth_client_id: mcp.metadata.oauth_client_id ?? '', + oauth_client_secret: mcp.metadata.oauth_client_secret ?? '', + authorization_url: mcp.metadata.auth.authorization_url ?? '', + client_url: mcp.metadata.auth.client_url ?? '', + scope: mcp.metadata.auth.scope ?? '', + token_exchange_method: + mcp.metadata.auth.token_exchange_method ?? TokenExchangeMethodEnum.DefaultPost, + }); + } + + reset(formData); + } + }, [mcp, reset]); + + return ( + +
+
+
+
+ +
+ + {!!mcp && ( + + +
+ +
+
+ + {localize('com_ui_delete_mcp_confirm')} + + } + selection={{ + selectHandler: () => { + if (!agent_id) { + return showToast({ + message: localize('com_agents_no_agent_id_error'), + status: 'error', + }); + } + deleteAgentMCP.mutate({ + mcp_id: mcp.mcp_id, + agent_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'), + }} + /> +
+ )} + +
+ {mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')} +
+
{localize('com_agents_mcp_info')}
+
+ +
+
+
+ ); +} diff --git a/client/src/components/SidePanel/Agents/MCPSection.tsx b/client/src/components/SidePanel/Agents/MCPSection.tsx new file mode 100644 index 0000000000..4575eb8f25 --- /dev/null +++ b/client/src/components/SidePanel/Agents/MCPSection.tsx @@ -0,0 +1,57 @@ +import { useCallback } from 'react'; +import { useLocalize } from '~/hooks'; +import { useToastContext } from '~/Providers'; +import { useAgentPanelContext } from '~/Providers/AgentPanelContext'; +import MCP from '~/components/SidePanel/Builder/MCP'; +import { Panel } from '~/common'; + +export default function MCPSection() { + const { showToast } = useToastContext(); + const localize = useLocalize(); + const { mcps = [], agent_id, setMcp, setActivePanel } = useAgentPanelContext(); + + const handleAddMCP = useCallback(() => { + if (!agent_id) { + showToast({ + message: localize('com_agents_mcps_disabled'), + status: 'warning', + }); + return; + } + setActivePanel(Panel.mcp); + }, [agent_id, setActivePanel, showToast, localize]); + + return ( +
+ +
+ {mcps + .filter((mcp) => mcp.agent_id === agent_id) + .map((mcp, i) => ( + { + setMcp(mcp); + setActivePanel(Panel.mcp); + }} + /> + ))} +
+ +
+
+
+ ); +} diff --git a/client/src/components/SidePanel/Agents/ModelPanel.tsx b/client/src/components/SidePanel/Agents/ModelPanel.tsx index 9b4b12cf67..3987f24dcf 100644 --- a/client/src/components/SidePanel/Agents/ModelPanel.tsx +++ b/client/src/components/SidePanel/Agents/ModelPanel.tsx @@ -1,27 +1,28 @@ +import keyBy from 'lodash/keyBy'; import React, { useMemo, useEffect } from 'react'; import { ChevronLeft, RotateCcw } from 'lucide-react'; import { useFormContext, useWatch, Controller } from 'react-hook-form'; +import { componentMapping } from '~/components/SidePanel/Parameters/components'; import { alternateName, getSettingsKeys, + LocalStorageKeys, SettingDefinition, agentParamSettings, } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common'; -import { componentMapping } from '~/components/SidePanel/Parameters/components'; import ControlCombobox from '~/components/ui/ControlCombobox'; import { useGetEndpointsQuery } from '~/data-provider'; import { getEndpointField, cn } from '~/utils'; import { useLocalize } from '~/hooks'; import { Panel } from '~/common'; -import keyBy from 'lodash/keyBy'; export default function ModelPanel({ - setActivePanel, providers, + setActivePanel, models: modelsData, -}: AgentModelPanelProps) { +}: Pick) { const localize = useLocalize(); const { control, setValue } = useFormContext(); @@ -50,6 +51,8 @@ export default function ModelPanel({ const newModels = modelsData[provider] ?? []; setValue('model', newModels[0] ?? ''); } + localStorage.setItem(LocalStorageKeys.LAST_AGENT_MODEL, _model); + localStorage.setItem(LocalStorageKeys.LAST_AGENT_PROVIDER, provider); } if (provider && !_model) { diff --git a/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx index 9d76aba75a..0f89199216 100644 --- a/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx +++ b/client/src/components/SidePanel/Agents/Version/VersionPanel.tsx @@ -1,12 +1,12 @@ -import type { Agent, TAgentsEndpoint } from 'librechat-data-provider'; import { ChevronLeft } from 'lucide-react'; import { useCallback, useMemo } from 'react'; -import type { AgentPanelProps } from '~/common'; -import { Panel } from '~/common'; import { useGetAgentByIdQuery, useRevertAgentVersionMutation } from '~/data-provider'; +import type { Agent } from 'librechat-data-provider'; +import { isActiveVersion } from './isActiveVersion'; +import { useAgentPanelContext } from '~/Providers'; import { useLocalize, useToast } from '~/hooks'; import VersionContent from './VersionContent'; -import { isActiveVersion } from './isActiveVersion'; +import { Panel } from '~/common'; export type VersionRecord = Record; @@ -39,15 +39,13 @@ export interface AgentWithVersions extends Agent { versions?: Array; } -export type VersionPanelProps = { - agentsConfig: TAgentsEndpoint | null; - setActivePanel: AgentPanelProps['setActivePanel']; - selectedAgentId?: string; -}; - -export default function VersionPanel({ setActivePanel, selectedAgentId = '' }: VersionPanelProps) { +export default function VersionPanel() { const localize = useLocalize(); const { showToast } = useToast(); + const { agent_id, setActivePanel } = useAgentPanelContext(); + + const selectedAgentId = agent_id ?? ''; + const { data: agent, isLoading, diff --git a/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx index d9cf31eff3..3258de3d66 100644 --- a/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx +++ b/client/src/components/SidePanel/Agents/Version/__tests__/VersionPanel.spec.tsx @@ -55,13 +55,18 @@ jest.mock('~/hooks', () => ({ useToast: jest.fn(() => ({ showToast: jest.fn() })), })); +// Mock the AgentPanelContext +jest.mock('~/Providers/AgentPanelContext', () => ({ + ...jest.requireActual('~/Providers/AgentPanelContext'), + useAgentPanelContext: jest.fn(), +})); + describe('VersionPanel', () => { const mockSetActivePanel = jest.fn(); - const defaultProps = { - agentsConfig: null, - setActivePanel: mockSetActivePanel, - selectedAgentId: 'agent-123', - }; + const mockUseAgentPanelContext = jest.requireMock( + '~/Providers/AgentPanelContext', + ).useAgentPanelContext; + const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery; beforeEach(() => { @@ -72,10 +77,17 @@ describe('VersionPanel', () => { error: null, refetch: jest.fn(), }); + + // Set up the default context mock + mockUseAgentPanelContext.mockReturnValue({ + setActivePanel: mockSetActivePanel, + agent_id: 'agent-123', + activePanel: Panel.version, + }); }); test('renders panel UI and handles navigation', () => { - render(); + render(); expect(screen.getByText('com_ui_agent_version_history')).toBeInTheDocument(); expect(screen.getByTestId('version-content')).toBeInTheDocument(); @@ -84,7 +96,7 @@ describe('VersionPanel', () => { }); test('VersionContent receives correct props', () => { - render(); + render(); expect(VersionContent).toHaveBeenCalledWith( expect.objectContaining({ selectedAgentId: 'agent-123', @@ -101,19 +113,31 @@ describe('VersionPanel', () => { }); test('handles data state variations', () => { - render(); + // Test with empty agent_id + mockUseAgentPanelContext.mockReturnValueOnce({ + setActivePanel: mockSetActivePanel, + agent_id: '', + activePanel: Panel.version, + }); + render(); expect(VersionContent).toHaveBeenCalledWith( expect.objectContaining({ selectedAgentId: '' }), expect.anything(), ); + // Test with null data mockUseGetAgentByIdQuery.mockReturnValueOnce({ data: null, isLoading: false, error: null, refetch: jest.fn(), }); - render(); + mockUseAgentPanelContext.mockReturnValueOnce({ + setActivePanel: mockSetActivePanel, + agent_id: 'agent-123', + activePanel: Panel.version, + }); + render(); expect(VersionContent).toHaveBeenCalledWith( expect.objectContaining({ versionContext: expect.objectContaining({ @@ -125,13 +149,14 @@ describe('VersionPanel', () => { expect.anything(), ); + // 3. versions is undefined mockUseGetAgentByIdQuery.mockReturnValueOnce({ data: { ...mockAgentData, versions: undefined }, isLoading: false, error: null, refetch: jest.fn(), }); - render(); + render(); expect(VersionContent).toHaveBeenCalledWith( expect.objectContaining({ versionContext: expect.objectContaining({ versions: [] }), @@ -139,18 +164,20 @@ describe('VersionPanel', () => { expect.anything(), ); + // 4. loading state mockUseGetAgentByIdQuery.mockReturnValueOnce({ data: null, isLoading: true, error: null, refetch: jest.fn(), }); - render(); + render(); expect(VersionContent).toHaveBeenCalledWith( expect.objectContaining({ isLoading: true }), expect.anything(), ); + // 5. error state const testError = new Error('Test error'); mockUseGetAgentByIdQuery.mockReturnValueOnce({ data: null, @@ -158,7 +185,7 @@ describe('VersionPanel', () => { error: testError, refetch: jest.fn(), }); - render(); + render(); expect(VersionContent).toHaveBeenCalledWith( expect.objectContaining({ error: testError }), expect.anything(), @@ -173,7 +200,7 @@ describe('VersionPanel', () => { refetch: jest.fn(), }); - render(); + render(); expect(VersionContent).toHaveBeenCalledWith( expect.objectContaining({ versionContext: expect.objectContaining({ diff --git a/client/src/components/SidePanel/Builder/MCP.tsx b/client/src/components/SidePanel/Builder/MCP.tsx new file mode 100644 index 0000000000..f632b6c00d --- /dev/null +++ b/client/src/components/SidePanel/Builder/MCP.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import type { MCP } from 'librechat-data-provider'; +import GearIcon from '~/components/svg/GearIcon'; +import MCPIcon from '~/components/svg/MCPIcon'; +import { cn } from '~/utils'; + +type MCPProps = { + mcp: MCP; + onClick: () => void; +}; + +export default function MCP({ mcp, onClick }: MCPProps) { + const [isHovering, setIsHovering] = useState(false); + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + onClick(); + } + }} + className="group flex w-full rounded-lg border border-border-medium text-sm hover:cursor-pointer focus:outline-none focus:ring-2 focus:ring-text-primary" + onMouseEnter={() => setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + aria-label={`MCP for ${mcp.metadata.name}`} + > +
+ {mcp.metadata.icon ? ( + {`${mcp.metadata.name} + ) : ( +
+ +
+ )} +
+ {mcp.metadata.name} +
+
+
+
+
+ ); +} diff --git a/client/src/components/SidePanel/Builder/MCPAuth.tsx b/client/src/components/SidePanel/Builder/MCPAuth.tsx new file mode 100644 index 0000000000..4ea3faae61 --- /dev/null +++ b/client/src/components/SidePanel/Builder/MCPAuth.tsx @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth'; +import { + AuthorizationTypeEnum, + TokenExchangeMethodEnum, + AuthTypeEnum, +} from 'librechat-data-provider'; + +export default function MCPAuth() { + // Create a separate form for auth + const authMethods = useForm({ + defaultValues: { + /* General */ + type: AuthTypeEnum.None, + saved_auth_fields: false, + /* API key */ + api_key: '', + authorization_type: AuthorizationTypeEnum.Basic, + custom_auth_header: '', + /* OAuth */ + oauth_client_id: '', + oauth_client_secret: '', + authorization_url: '', + client_url: '', + scope: '', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + }); + + const { watch, setValue } = authMethods; + const type = watch('type'); + + // Sync form state when auth type changes + useEffect(() => { + if (type === 'none') { + // Reset auth fields when type is none + setValue('api_key', ''); + setValue('authorization_type', AuthorizationTypeEnum.Basic); + setValue('custom_auth_header', ''); + setValue('oauth_client_id', ''); + setValue('oauth_client_secret', ''); + setValue('authorization_url', ''); + setValue('client_url', ''); + setValue('scope', ''); + setValue('token_exchange_method', TokenExchangeMethodEnum.DefaultPost); + } + }, [type, setValue]); + + return ( + + + + ); +} diff --git a/client/src/components/svg/MCPIcon.tsx b/client/src/components/svg/MCPIcon.tsx new file mode 100644 index 0000000000..50214b539a --- /dev/null +++ b/client/src/components/svg/MCPIcon.tsx @@ -0,0 +1,15 @@ +export default function MCPIcon() { + return ( + + + + + ); +} diff --git a/client/src/components/svg/SquirclePlusIcon.tsx b/client/src/components/svg/SquirclePlusIcon.tsx new file mode 100644 index 0000000000..dee4238aaf --- /dev/null +++ b/client/src/components/svg/SquirclePlusIcon.tsx @@ -0,0 +1,19 @@ +export default function SquirclePlusIcon() { + return ( + + + + + ); +} diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts index 24af4ee898..8fd3a842a8 100644 --- a/client/src/data-provider/Agents/mutations.ts +++ b/client/src/data-provider/Agents/mutations.ts @@ -224,18 +224,20 @@ export const useUpdateAgentAction = ( }); queryClient.setQueryData([QueryKeys.actions], (prev) => { - return prev - ?.map((action) => { + if (!prev) { + return [updateAgentActionResponse[1]]; + } + + if (variables.action_id) { + return prev.map((action) => { if (action.action_id === variables.action_id) { return updateAgentActionResponse[1]; } return action; - }) - .concat( - variables.action_id != null && variables.action_id - ? [] - : [updateAgentActionResponse[1]], - ); + }); + } + + return [...prev, updateAgentActionResponse[1]]; }); queryClient.setQueryData([QueryKeys.agent, variables.agent_id], updatedAgent); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 6c4000beef..5791ff7713 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1005,5 +1005,27 @@ "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", "com_user_message": "You", - "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint." + "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.", + "com_ui_add_mcp": "Add MCP", + "com_ui_add_mcp_server": "Add MCP Server", + "com_ui_edit_mcp_server": "Edit MCP Server", + "com_agents_mcps_disabled": "You need to create an agent before adding MCPs.", + "com_ui_delete_mcp": "Delete MCP", + "com_ui_delete_mcp_confirm": "Are you sure you want to delete this MCP server?", + "com_ui_delete_mcp_success": "MCP server deleted successfully", + "com_ui_delete_mcp_error": "Failed to delete MCP server", + "com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services", + "com_ui_update_mcp_error": "There was an error creating or updating the MCP.", + "com_ui_update_mcp_success": "Successfully created or updated MCP", + "com_ui_available_tools": "Available Tools", + "com_ui_select_all": "Select All", + "com_ui_deselect_all": "Deselect All", + "com_agents_mcp_name_placeholder": "Custom Tool", + "com_ui_optional": "(optional)", + "com_agents_mcp_description_placeholder": "Explain what it does in a few words", + "com_ui_mcp_url": "MCP Server URL", + "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" } \ No newline at end of file diff --git a/client/src/utils/forms.tsx b/client/src/utils/forms.tsx index cfd99f9d7b..fc1ce0c078 100644 --- a/client/src/utils/forms.tsx +++ b/client/src/utils/forms.tsx @@ -4,6 +4,8 @@ import { alternateName, EModelEndpoint, EToolResources, + LocalStorageKeys, + defaultAgentFormValues, } from 'librechat-data-provider'; import type { Agent, TFile } from 'librechat-data-provider'; import type { DropdownValueSetter, TAgentOption, ExtendedFile } from '~/common'; @@ -42,6 +44,16 @@ export const createProviderOption = (provider: string) => ({ value: provider, }); +/** + * Gets default agent form values with localStorage values for model and provider. + * This is used to initialize agent forms with the last used model and provider. + **/ +export const getDefaultAgentFormValues = () => ({ + ...defaultAgentFormValues, + model: localStorage.getItem(LocalStorageKeys.LAST_AGENT_MODEL) ?? '', + provider: createProviderOption(localStorage.getItem(LocalStorageKeys.LAST_AGENT_PROVIDER) ?? ''), +}); + export const processAgentOption = ({ agent: _agent, fileMap, diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index c7484b8b86..d851891ac4 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1434,6 +1434,10 @@ export enum LocalStorageKeys { LAST_CODE_TOGGLE_ = 'LAST_CODE_TOGGLE_', /** Last checked toggle for Web Search per conversation ID */ LAST_WEB_SEARCH_TOGGLE_ = 'LAST_WEB_SEARCH_TOGGLE_', + /** Key for the last selected agent provider */ + LAST_AGENT_PROVIDER = 'lastAgentProvider', + /** Key for the last selected agent model */ + LAST_AGENT_MODEL = 'lastAgentModel', } export enum ForkOptions { diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index cbcc51687b..c1e6f16965 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -515,6 +515,8 @@ export type ActionAuth = { token_exchange_method?: TokenExchangeMethodEnum; }; +export type MCPAuth = ActionAuth; + export type ActionMetadata = { api_key?: string; auth?: ActionAuth; @@ -525,6 +527,16 @@ export type ActionMetadata = { 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; @@ -541,6 +553,11 @@ export type Action = { 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;