feat: Agent Panel UI Enhancements (#7800)

* feat: add MCP Panel to Agent Builder

- Add MCP server panel and configuration UI
- Implement MCP input forms and tool lists
- Add MCP icon and metadata support
- Integrate MCP with agent configuration
- Add localization support for MCP features
- Refactor components for better reusability
- Update types and add MCP-related mutations
- Fix small issues with Actions and AgentSelect
- Refactor AgentPanelSwitch and related components to use new
  AgentPanelContext to reduce prop drilling

* chore: import order

* chore: clean up import statements and unused var in ActionsPanel component

* refactor: AgentPanelContext with actions query, remove unnecessary `actions` state

- Added actions query using `useGetActionsQuery` to fetch actions based on the current agent ID.
- Removed now unused `setActions` state and related logic from `AgentPanelContext` and `AgentPanelSwitch` components.
- Updated `AgentPanelContextType` to reflect the removal of `setActions`.

* chore: re-order import statements in AgentConfig component

* chore: re-order import statements in ModelPanel component

* chore: update ModelPanel props to consolidated props to avoid passing unnecessary props

* chore: update import statements in Providers index file to include ToastProvider and AgentPanelContext exports

* chore: clean up import statements in VersionPanel component

* refactor: streamline AgentConfig and AgentPanel components

- Consolidated props in AgentConfig to only include necessary fields.
- Updated AgentPanel to remove unused state and props, enhancing clarity and maintainability.
- Reorganized import statements for better structure and readability.

* refactor: replace default agent form values with utility function

- Updated AgentsProvider, AgentPanel, AgentSelect, and DeleteButton components to use getDefaultAgentFormValues utility function instead of directly importing defaultAgentFormValues.
- Enhanced the initialization of agent forms by incorporating localStorage values for model and provider in the new utility function.

* chore: comment out rendering MCPSection

---------

Co-authored-by: Dustin Healy <54083382+dustinhealy@users.noreply.github.com>
This commit is contained in:
Danny Avila 2025-06-13 15:47:41 -04:00 committed by GitHub
parent 5f2d1c5dc9
commit 4419e2c294
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1027 additions and 136 deletions

View file

@ -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<AgentPanelContextType | undefined>(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<MCP | undefined>(undefined);
const [mcps, setMcps] = useState<MCP[] | undefined>(undefined);
const [action, setAction] = useState<Action | undefined>(undefined);
const [activePanel, setActivePanel] = useState<Panel>(Panel.builder);
const [agent_id, setCurrentAgentId] = useState<string | undefined>(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 <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
}

View file

@ -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<AgentForm>;
@ -20,7 +20,7 @@ export function useAgentsContext() {
export default function AgentsProvider({ children }) {
const methods = useForm<AgentForm>({
defaultValues: defaultAgentFormValues,
defaultValues: getDefaultAgentFormValues(),
});
return <FormProvider {...methods}>{children}</FormProvider>;

View file

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

26
client/src/common/mcp.ts Normal file
View file

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

View file

@ -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<t.Action, 'metadata'> & {
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<t.Agent, Error, t.AgentCreateParams>;
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setAction: React.Dispatch<React.SetStateAction<t.Action | undefined>>;
endpointsConfig?: t.TEndpointsConfig;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
agentsConfig?: t.TAgentsEndpoint | null;
};
export type AgentPanelContextType = {
action?: t.Action;
actions?: t.Action[];
setAction: React.Dispatch<React.SetStateAction<t.Action | undefined>>;
mcp?: t.MCP;
mcps?: t.MCP[];
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
activePanel?: string;
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
agent_id?: string;
};
export type AgentModelPanelProps = {
agent_id?: string;
providers: Option[];

View file

@ -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) {

View file

@ -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<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 { showToast } = useToastContext();
const localize = useLocalize();
const [showToolDialog, setShowToolDialog] = useState(false);
const methods = useFormContext<AgentForm>();
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) => (
<Action
@ -340,6 +337,8 @@ export default function AgentConfig({
</div>
</div>
</div>
{/* MCP Section */}
{/* <MCPSection /> */}
</div>
<ToolSelectDialog
isOpen={showToolDialog}

View file

@ -7,20 +7,22 @@ import {
Constants,
SystemRoles,
EModelEndpoint,
TAgentsEndpoint,
TEndpointsConfig,
isAssistantsEndpoint,
defaultAgentFormValues,
} from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps, StringOption } from '~/common';
import type { AgentForm, StringOption } from '~/common';
import {
useCreateAgentMutation,
useUpdateAgentMutation,
useGetAgentByIdQuery,
} from '~/data-provider';
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import AgentPanelSkeleton from './AgentPanelSkeleton';
import { createProviderOption } from '~/utils';
import { useToastContext } from '~/Providers';
import AdvancedPanel from './Advanced/AdvancedPanel';
import { useToastContext } from '~/Providers';
import AgentConfig from './AgentConfig';
import AgentSelect from './AgentSelect';
import AgentFooter from './AgentFooter';
@ -29,18 +31,21 @@ import ModelPanel from './ModelPanel';
import { Panel } from '~/common';
export default function AgentPanel({
setAction,
activePanel,
actions = [],
setActivePanel,
agent_id: current_agent_id,
setCurrentAgentId,
agentsConfig,
endpointsConfig,
}: AgentPanelProps) {
}: {
agentsConfig: TAgentsEndpoint | null;
endpointsConfig: TEndpointsConfig;
}) {
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
const {
activePanel,
setActivePanel,
setCurrentAgentId,
agent_id: current_agent_id,
} = useAgentPanelContext();
const { onSelect: onSelectAgent } = useSelectAgent();
@ -51,7 +56,7 @@ export default function AgentPanel({
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);
const methods = useForm<AgentForm>({
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({
</div>
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.model && (
<ModelPanel
setActivePanel={setActivePanel}
agent_id={agent_id}
providers={providers}
models={models}
/>
<ModelPanel models={models} providers={providers} setActivePanel={setActivePanel} />
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.builder && (
<AgentConfig
actions={actions}
setAction={setAction}
createMutation={create}
agentsConfig={agentsConfig}
setActivePanel={setActivePanel}
endpointsConfig={endpointsConfig}
setCurrentAgentId={setCurrentAgentId}
/>
)}
{canEditAgent && !agentQuery.isInitialLoading && activePanel === Panel.advanced && (

View file

@ -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<Action | undefined>(undefined);
const [currentAgentId, setCurrentAgentId] = useState<string | undefined>(conversation?.agent_id);
const { data: actions = [] } = useGetActionsQuery(conversation?.endpoint as ActionsEndpoint);
return (
<AgentPanelProvider>
<AgentPanelSwitchWithContext />
</AgentPanelProvider>
);
}
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<TAgentsEndpoint | null>(() => {
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 <ActionsPanel {...commonProps} />;
return <ActionsPanel />;
}
if (activePanel === Panel.version) {
return (
<VersionPanel
setActivePanel={setActivePanel}
agentsConfig={agentsConfig}
selectedAgentId={currentAgentId}
/>
);
return <VersionPanel />;
}
return (
<AgentPanel {...commonProps} agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />
);
if (activePanel === Panel.mcp) {
return <MCPPanel />;
}
return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />;
}

View file

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

View file

@ -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')('');
}

View file

@ -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<HTMLInputElement>) => void;
}
export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
const [previewUrl, setPreviewUrl] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const localize = useLocalize();
useEffect(() => {
if (icon) {
setPreviewUrl(icon);
} else {
setPreviewUrl('');
}
}, [icon]);
const handleClick = () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.click();
}
};
return (
<div className="flex items-center gap-4">
<div
onClick={handleClick}
className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-[1.5rem] border-2 border-dashed"
>
{previewUrl ? (
<img
src={previewUrl}
className="h-full w-full rounded-[1.5rem] object-cover"
alt="MCP Icon"
width="64"
height="64"
/>
) : (
<SquirclePlusIcon />
)}
</div>
<div className="flex flex-col gap-1">
<span className="token-text-secondary text-sm">
{localize('com_ui_icon')} {localize('com_ui_optional')}
</span>
<span className="token-text-tertiary text-xs">{localize('com_agents_mcp_icon_size')}</span>
</div>
<input
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
multiple={false}
type="file"
style={{ display: 'none' }}
onChange={onIconChange}
ref={fileInputRef}
/>
</div>
);
}

View file

@ -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<React.SetStateAction<MCP | undefined>>;
}
export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const {
handleSubmit,
register,
formState: { errors },
control,
} = useFormContext<MCPForm>();
const [isLoading, setIsLoading] = useState(false);
const [showTools, setShowTools] = useState(false);
const [selectedTools, setSelectedTools] = useState<string[]>([]);
// 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<HTMLInputElement>) => {
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 (
<div className="flex flex-col gap-4">
{/* Icon Picker */}
<div className="mb-4">
<MCPIcon icon={mcp?.metadata.icon} onIconChange={handleIconChange} />
</div>
{/* name, description, url */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="name">{localize('com_ui_name')}</Label>
<input
id="name"
{...register('name', { required: true })}
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
placeholder={localize('com_agents_mcp_name_placeholder')}
/>
{errors.name && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="description">
{localize('com_ui_description')}
<span className="ml-1 text-xs text-text-secondary-alt">
{localize('com_ui_optional')}
</span>
</Label>
<input
id="description"
{...register('description')}
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
placeholder={localize('com_agents_mcp_description_placeholder')}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="url">{localize('com_ui_mcp_url')}</Label>
<input
id="url"
{...register('url', {
required: true,
})}
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
placeholder={'https://mcp.example.com'}
/>
{errors.url && (
<span className="text-xs text-red-500">
{errors.url.type === 'required'
? localize('com_ui_field_required')
: errors.url.message}
</span>
)}
</div>
<MCPAuth />
<div className="my-2 flex items-center gap-2">
<Controller
name="trust"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Checkbox id="trust" checked={field.value} onCheckedChange={field.onChange} />
)}
/>
<Label htmlFor="trust" className="flex flex-col">
{localize('com_ui_trust_app')}
<span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')}
</span>
</Label>
</div>
{errors.trust && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
</div>
<div className="flex items-center justify-end">
<button
onClick={saveMCP}
disabled={isLoading}
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
type="button"
>
{(() => {
if (isLoading) {
return <Spinner className="icon-md" />;
}
return mcp?.mcp_id ? localize('com_ui_update') : localize('com_ui_create');
})()}
</button>
</div>
{showTools && mcp?.metadata.tools && (
<div className="mt-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-token-text-primary block font-medium">
{localize('com_ui_available_tools')}
</h3>
<button
onClick={handleToggleAll}
type="button"
className="btn btn-neutral border-token-border-light relative h-8 rounded-full px-4 font-medium"
>
{selectedTools.length === mcp.metadata.tools.length
? localize('com_ui_deselect_all')
: localize('com_ui_select_all')}
</button>
</div>
<div className="flex flex-col gap-2">
{mcp.metadata.tools.map((tool) => (
<label
key={tool}
htmlFor={tool}
className="border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2"
>
<Checkbox
id={tool}
checked={selectedTools.includes(tool)}
onCheckedChange={() => handleToolToggle(tool)}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
/>
<span className="text-token-text-primary">
{tool
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')}
</span>
</label>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -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<MCPForm>({
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 (
<FormProvider {...methods}>
<form className="h-full grow overflow-hidden">
<div className="h-full overflow-auto px-2 pb-12 text-sm">
<div className="relative flex flex-col items-center px-16 py-6 text-center">
<div className="absolute left-0 top-6">
<button
type="button"
className="btn btn-neutral relative"
onClick={() => {
setActivePanel(Panel.builder);
setMcp(undefined);
}}
>
<div className="flex w-full items-center justify-center gap-2">
<ChevronLeft />
</div>
</button>
</div>
{!!mcp && (
<OGDialog>
<OGDialogTrigger asChild>
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!agent_id || !mcp.mcp_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
>
<TrashIcon className="text-red-500" />
</button>
</div>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_mcp')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_mcp_confirm')}
</Label>
}
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'),
}}
/>
</OGDialog>
)}
<div className="text-xl font-medium">
{mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')}
</div>
<div className="text-xs text-text-secondary">{localize('com_agents_mcp_info')}</div>
</div>
<MCPInput mcp={mcp} agent_id={agent_id} setMCP={setMcp} />
</div>
</form>
</FormProvider>
);
}

View file

@ -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 (
<div className="mb-4">
<label className="text-token-text-primary mb-2 block font-medium">
{localize('com_ui_mcp_servers')}
</label>
<div className="space-y-2">
{mcps
.filter((mcp) => mcp.agent_id === agent_id)
.map((mcp, i) => (
<MCP
key={i}
mcp={mcp}
onClick={() => {
setMcp(mcp);
setActivePanel(Panel.mcp);
}}
/>
))}
<div className="flex space-x-2">
<button
type="button"
onClick={handleAddMCP}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_ui_add_mcp')}
</div>
</button>
</div>
</div>
</div>
);
}

View file

@ -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<AgentModelPanelProps, 'models' | 'providers' | 'setActivePanel'>) {
const localize = useLocalize();
const { control, setValue } = useFormContext<AgentForm>();
@ -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) {

View file

@ -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<string, any>;
@ -39,15 +39,13 @@ export interface AgentWithVersions extends Agent {
versions?: Array<VersionRecord>;
}
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,

View file

@ -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(<VersionPanel {...defaultProps} />);
render(<VersionPanel />);
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(<VersionPanel {...defaultProps} />);
render(<VersionPanel />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({
selectedAgentId: 'agent-123',
@ -101,19 +113,31 @@ describe('VersionPanel', () => {
});
test('handles data state variations', () => {
render(<VersionPanel {...defaultProps} selectedAgentId="" />);
// Test with empty agent_id
mockUseAgentPanelContext.mockReturnValueOnce({
setActivePanel: mockSetActivePanel,
agent_id: '',
activePanel: Panel.version,
});
render(<VersionPanel />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({ selectedAgentId: '' }),
expect.anything(),
);
// Test with null data
mockUseGetAgentByIdQuery.mockReturnValueOnce({
data: null,
isLoading: false,
error: null,
refetch: jest.fn(),
});
render(<VersionPanel {...defaultProps} />);
mockUseAgentPanelContext.mockReturnValueOnce({
setActivePanel: mockSetActivePanel,
agent_id: 'agent-123',
activePanel: Panel.version,
});
render(<VersionPanel />);
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(<VersionPanel {...defaultProps} />);
render(<VersionPanel />);
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(<VersionPanel {...defaultProps} />);
render(<VersionPanel />);
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(<VersionPanel {...defaultProps} />);
render(<VersionPanel />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({ error: testError }),
expect.anything(),
@ -173,7 +200,7 @@ describe('VersionPanel', () => {
refetch: jest.fn(),
});
render(<VersionPanel {...defaultProps} />);
render(<VersionPanel />);
expect(VersionContent).toHaveBeenCalledWith(
expect.objectContaining({
versionContext: expect.objectContaining({

View file

@ -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 (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
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}`}
>
<div className="flex h-9 items-center gap-2 px-3">
{mcp.metadata.icon ? (
<img
src={mcp.metadata.icon}
alt={`${mcp.metadata.name} icon`}
className="h-6 w-6 rounded-md object-cover"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-surface-secondary">
<MCPIcon />
</div>
)}
<div
className="grow overflow-hidden text-ellipsis whitespace-nowrap"
style={{ wordBreak: 'break-all' }}
>
{mcp.metadata.name}
</div>
</div>
<div
className={cn(
'ml-auto h-9 w-9 min-w-9 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-text-primary group-focus:flex',
isHovering ? 'flex' : 'hidden',
)}
aria-label="Settings"
>
<GearIcon className="icon-sm" aria-hidden="true" />
</div>
</div>
);
}

View file

@ -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 (
<FormProvider {...authMethods}>
<ActionsAuth />
</FormProvider>
);
}

View file

@ -0,0 +1,15 @@
export default function MCPIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
className="h-4 w-4"
>
<path d="M11.016 2.099a3.998 3.998 0 0 1 5.58.072l.073.074a3.991 3.991 0 0 1 1.058 3.318 3.994 3.994 0 0 1 3.32 1.06l.073.071.048.047.071.075a3.998 3.998 0 0 1 0 5.506l-.071.074-8.183 8.182-.034.042a.267.267 0 0 0 .034.335l1.68 1.68a.8.8 0 0 1-1.131 1.13l-1.68-1.679a1.866 1.866 0 0 1-.034-2.604l8.26-8.261a2.4 2.4 0 0 0-.044-3.349l-.047-.047-.044-.043a2.4 2.4 0 0 0-3.349.043l-6.832 6.832-.03.029a.8.8 0 0 1-1.1-1.16l6.876-6.875a2.4 2.4 0 0 0-.044-3.35l-.179-.161a2.399 2.399 0 0 0-3.169.119l-.045.043-9.047 9.047-.03.028a.8.8 0 0 1-1.1-1.16l9.046-9.046.074-.072Z" />
<path d="M13.234 4.404a.8.8 0 0 1 1.1 1.16l-6.69 6.691a2.399 2.399 0 1 0 3.393 3.393l6.691-6.692a.8.8 0 0 1 1.131 1.131l-6.691 6.692a4 4 0 0 1-5.581.07l-.073-.07a3.998 3.998 0 0 1 0-5.655l6.69-6.691.03-.029Z" />
</svg>
);
}

View file

@ -0,0 +1,19 @@
export default function SquirclePlusIcon() {
return (
<svg
stroke="currentColor"
fill="none"
strokeWidth="2"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="text-3xl"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
}

View file

@ -224,18 +224,20 @@ export const useUpdateAgentAction = (
});
queryClient.setQueryData<t.Action[]>([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<t.Agent>([QueryKeys.agent, variables.agent_id], updatedAgent);

View file

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

View file

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

View file

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

View file

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