🔒 fix: Agents Config/Permission Checks after Streamline Change (#8089)

* refactor: access control logic to TypeScript

* chore: Change EndpointURLs to a constant object for improved type safety

* 🐛 fix: Enhance agent access control by adding skipAgentCheck functionality

* 🐛 fix: Add endpointFileConfig prop to AttachFileMenu and update file handling logic

* 🐛 fix: Update tool handling logic to support optional groupedTools and improve null checks, add dedicated tool dialog for Assistants

* chore: Export Accordion component from UI index for improved modularity

* feat: Add ActivePanelContext for managing active panel state across components

* chore: Replace string IDs with EModelEndpoint constants for assistants and agents in useSideNavLinks

* fix: Integrate access checks for agent creation and deletion routes in actions.js
This commit is contained in:
Danny Avila 2025-06-26 18:50:15 -04:00 committed by Dustin Healy
parent d18b2c3f1f
commit 02c7f744ba
31 changed files with 672 additions and 242 deletions

View file

@ -4,6 +4,7 @@ const {
sendEvent, sendEvent,
createRun, createRun,
Tokenizer, Tokenizer,
checkAccess,
memoryInstructions, memoryInstructions,
createMemoryProcessor, createMemoryProcessor,
} = require('@librechat/api'); } = require('@librechat/api');
@ -39,8 +40,8 @@ const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models'); const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { getProviderConfig } = require('~/server/services/Endpoints'); const { getProviderConfig } = require('~/server/services/Endpoints');
const { checkAccess } = require('~/server/middleware/roles/access');
const BaseClient = require('~/app/clients/BaseClient'); const BaseClient = require('~/app/clients/BaseClient');
const { getRoleByName } = require('~/models/Role');
const { loadAgent } = require('~/models/Agent'); const { loadAgent } = require('~/models/Agent');
const { getMCPManager } = require('~/config'); const { getMCPManager } = require('~/config');
@ -401,7 +402,12 @@ class AgentClient extends BaseClient {
if (user.personalization?.memories === false) { if (user.personalization?.memories === false) {
return; return;
} }
const hasAccess = await checkAccess(user, PermissionTypes.MEMORIES, [Permissions.USE]); const hasAccess = await checkAccess({
user,
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE],
getRoleByName,
});
if (!hasAccess) { if (!hasAccess) {
logger.debug( logger.debug(

View file

@ -1,5 +1,7 @@
const { nanoid } = require('nanoid'); const { nanoid } = require('nanoid');
const { EnvVar } = require('@librechat/agents'); const { EnvVar } = require('@librechat/agents');
const { checkAccess } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { const {
Tools, Tools,
AuthType, AuthType,
@ -13,9 +15,8 @@ const { processCodeOutput } = require('~/server/services/Files/Code/process');
const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall'); const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall');
const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { loadTools } = require('~/app/clients/tools/util'); const { loadTools } = require('~/app/clients/tools/util');
const { checkAccess } = require('~/server/middleware'); const { getRoleByName } = require('~/models/Role');
const { getMessage } = require('~/models/Message'); const { getMessage } = require('~/models/Message');
const { logger } = require('~/config');
const fieldsMap = { const fieldsMap = {
[Tools.execute_code]: [EnvVar.CODE_API_KEY], [Tools.execute_code]: [EnvVar.CODE_API_KEY],
@ -79,6 +80,7 @@ const verifyToolAuth = async (req, res) => {
throwError: false, throwError: false,
}); });
} catch (error) { } catch (error) {
logger.error('Error loading auth values', error);
res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED }); res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
return; return;
} }
@ -132,7 +134,12 @@ const callTool = async (req, res) => {
logger.debug(`[${toolId}/call] User: ${req.user.id}`); logger.debug(`[${toolId}/call] User: ${req.user.id}`);
let hasAccess = true; let hasAccess = true;
if (toolAccessPermType[toolId]) { if (toolAccessPermType[toolId]) {
hasAccess = await checkAccess(req.user, toolAccessPermType[toolId], [Permissions.USE]); hasAccess = await checkAccess({
user: req.user,
permissionType: toolAccessPermType[toolId],
permissions: [Permissions.USE],
getRoleByName,
});
} }
if (!hasAccess) { if (!hasAccess) {
logger.warn( logger.warn(

View file

@ -1,78 +0,0 @@
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
/**
* Core function to check if a user has one or more required permissions
*
* @param {object} user - The user object
* @param {PermissionTypes} permissionType - The type of permission to check
* @param {Permissions[]} permissions - The list of specific permissions to check
* @param {Record<Permissions, string[]>} [bodyProps] - An optional object where keys are permissions and values are arrays of properties to check
* @param {object} [checkObject] - The object to check properties against
* @returns {Promise<boolean>} Whether the user has the required permissions
*/
const checkAccess = async (user, permissionType, permissions, bodyProps = {}, checkObject = {}) => {
if (!user) {
return false;
}
const role = await getRoleByName(user.role);
if (role && role.permissions && role.permissions[permissionType]) {
const hasAnyPermission = permissions.some((permission) => {
if (role.permissions[permissionType][permission]) {
return true;
}
if (bodyProps[permission] && checkObject) {
return bodyProps[permission].some((prop) =>
Object.prototype.hasOwnProperty.call(checkObject, prop),
);
}
return false;
});
return hasAnyPermission;
}
return false;
};
/**
* Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties.
*
* @param {PermissionTypes} permissionType - The type of permission to check.
* @param {Permissions[]} permissions - The list of specific permissions to check.
* @param {Record<Permissions, string[]>} [bodyProps] - An optional object where keys are permissions and values are arrays of `req.body` properties to check.
* @returns {(req: ServerRequest, res: ServerResponse, next: NextFunction) => Promise<void>} Express middleware function.
*/
const generateCheckAccess = (permissionType, permissions, bodyProps = {}) => {
return async (req, res, next) => {
try {
const hasAccess = await checkAccess(
req.user,
permissionType,
permissions,
bodyProps,
req.body,
);
if (hasAccess) {
return next();
}
logger.warn(
`[${permissionType}] Forbidden: Insufficient permissions for User ${req.user.id}: ${permissions.join(', ')}`,
);
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
} catch (error) {
logger.error(error);
return res.status(500).json({ message: `Server error: ${error.message}` });
}
};
};
module.exports = {
checkAccess,
generateCheckAccess,
};

View file

@ -1,8 +1,5 @@
const checkAdmin = require('./admin'); const checkAdmin = require('./admin');
const { checkAccess, generateCheckAccess } = require('./access');
module.exports = { module.exports = {
checkAdmin, checkAdmin,
checkAccess,
generateCheckAccess,
}; };

View file

@ -1,14 +1,28 @@
const express = require('express'); const express = require('express');
const { nanoid } = require('nanoid'); const { nanoid } = require('nanoid');
const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider'); const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const {
SystemRoles,
Permissions,
PermissionTypes,
actionDelimiter,
removeNullishValues,
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains'); const { isActionDomainAllowed } = require('~/server/services/domains');
const { getAgent, updateAgent } = require('~/models/Agent'); const { getAgent, updateAgent } = require('~/models/Agent');
const { logger } = require('~/config'); const { getRoleByName } = require('~/models/Role');
const router = express.Router(); const router = express.Router();
const checkAgentCreate = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
// If the user has ADMIN role // If the user has ADMIN role
// then action edition is possible even if not owner of the assistant // then action edition is possible even if not owner of the assistant
const isAdmin = (req) => { const isAdmin = (req) => {
@ -41,7 +55,7 @@ router.get('/', async (req, res) => {
* @param {ActionMetadata} req.body.metadata - Metadata for the action. * @param {ActionMetadata} req.body.metadata - Metadata for the action.
* @returns {Object} 200 - success response - application/json * @returns {Object} 200 - success response - application/json
*/ */
router.post('/:agent_id', async (req, res) => { router.post('/:agent_id', checkAgentCreate, async (req, res) => {
try { try {
const { agent_id } = req.params; const { agent_id } = req.params;
@ -149,7 +163,7 @@ router.post('/:agent_id', async (req, res) => {
* @param {string} req.params.action_id - The ID of the action to delete. * @param {string} req.params.action_id - The ID of the action to delete.
* @returns {Object} 200 - success response - application/json * @returns {Object} 200 - success response - application/json
*/ */
router.delete('/:agent_id/:action_id', async (req, res) => { router.delete('/:agent_id/:action_id', checkAgentCreate, async (req, res) => {
try { try {
const { agent_id, action_id } = req.params; const { agent_id, action_id } = req.params;
const admin = isAdmin(req); const admin = isAdmin(req);

View file

@ -1,22 +1,28 @@
const express = require('express'); const express = require('express');
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { const {
setHeaders, setHeaders,
moderateText, moderateText,
// validateModel, // validateModel,
generateCheckAccess,
validateConvoAccess, validateConvoAccess,
buildEndpointOption, buildEndpointOption,
} = require('~/server/middleware'); } = require('~/server/middleware');
const { initializeClient } = require('~/server/services/Endpoints/agents'); const { initializeClient } = require('~/server/services/Endpoints/agents');
const AgentController = require('~/server/controllers/agents/request'); const AgentController = require('~/server/controllers/agents/request');
const addTitle = require('~/server/services/Endpoints/agents/title'); const addTitle = require('~/server/services/Endpoints/agents/title');
const { getRoleByName } = require('~/models/Role');
const router = express.Router(); const router = express.Router();
router.use(moderateText); router.use(moderateText);
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); const checkAgentAccess = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
skipCheck: skipAgentCheck,
getRoleByName,
});
router.use(checkAgentAccess); router.use(checkAgentAccess);
router.use(validateConvoAccess); router.use(validateConvoAccess);

View file

@ -1,29 +1,36 @@
const express = require('express'); const express = require('express');
const { generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); const { requireJwtAuth } = require('~/server/middleware');
const v1 = require('~/server/controllers/agents/v1'); const v1 = require('~/server/controllers/agents/v1');
const { getRoleByName } = require('~/models/Role');
const actions = require('./actions'); const actions = require('./actions');
const tools = require('./tools'); const tools = require('./tools');
const router = express.Router(); const router = express.Router();
const avatar = express.Router(); const avatar = express.Router();
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]); const checkAgentAccess = generateCheckAccess({
const checkAgentCreate = generateCheckAccess(PermissionTypes.AGENTS, [ permissionType: PermissionTypes.AGENTS,
Permissions.USE, permissions: [Permissions.USE],
Permissions.CREATE, getRoleByName,
]); });
const checkAgentCreate = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkGlobalAgentShare = generateCheckAccess( const checkGlobalAgentShare = generateCheckAccess({
PermissionTypes.AGENTS, permissionType: PermissionTypes.AGENTS,
[Permissions.USE, Permissions.CREATE], permissions: [Permissions.USE, Permissions.CREATE],
{ bodyProps: {
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'], [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
}, },
); getRoleByName,
});
router.use(requireJwtAuth); router.use(requireJwtAuth);
router.use(checkAgentAccess);
/** /**
* Agent actions route. * Agent actions route.

View file

@ -1,37 +1,43 @@
const express = require('express'); const express = require('express');
const { Tokenizer } = require('@librechat/api'); const { Tokenizer, generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { const {
getAllUserMemories, getAllUserMemories,
toggleUserMemories, toggleUserMemories,
createMemory, createMemory,
setMemory,
deleteMemory, deleteMemory,
setMemory,
} = require('~/models'); } = require('~/models');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); const { requireJwtAuth } = require('~/server/middleware');
const { getRoleByName } = require('~/models/Role');
const router = express.Router(); const router = express.Router();
const checkMemoryRead = generateCheckAccess(PermissionTypes.MEMORIES, [ const checkMemoryRead = generateCheckAccess({
Permissions.USE, permissionType: PermissionTypes.MEMORIES,
Permissions.READ, permissions: [Permissions.USE, Permissions.READ],
]); getRoleByName,
const checkMemoryCreate = generateCheckAccess(PermissionTypes.MEMORIES, [ });
Permissions.USE, const checkMemoryCreate = generateCheckAccess({
Permissions.CREATE, permissionType: PermissionTypes.MEMORIES,
]); permissions: [Permissions.USE, Permissions.CREATE],
const checkMemoryUpdate = generateCheckAccess(PermissionTypes.MEMORIES, [ getRoleByName,
Permissions.USE, });
Permissions.UPDATE, const checkMemoryUpdate = generateCheckAccess({
]); permissionType: PermissionTypes.MEMORIES,
const checkMemoryDelete = generateCheckAccess(PermissionTypes.MEMORIES, [ permissions: [Permissions.USE, Permissions.UPDATE],
Permissions.USE, getRoleByName,
Permissions.UPDATE, });
]); const checkMemoryDelete = generateCheckAccess({
const checkMemoryOptOut = generateCheckAccess(PermissionTypes.MEMORIES, [ permissionType: PermissionTypes.MEMORIES,
Permissions.USE, permissions: [Permissions.USE, Permissions.UPDATE],
Permissions.OPT_OUT, getRoleByName,
]); });
const checkMemoryOptOut = generateCheckAccess({
permissionType: PermissionTypes.MEMORIES,
permissions: [Permissions.USE, Permissions.OPT_OUT],
getRoleByName,
});
router.use(requireJwtAuth); router.use(requireJwtAuth);

View file

@ -1,5 +1,7 @@
const express = require('express'); const express = require('express');
const { PermissionTypes, Permissions, SystemRoles } = require('librechat-data-provider'); const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { Permissions, SystemRoles, PermissionTypes } = require('librechat-data-provider');
const { const {
getPrompt, getPrompt,
getPrompts, getPrompts,
@ -14,24 +16,30 @@ const {
// updatePromptLabels, // updatePromptLabels,
makePromptProduction, makePromptProduction,
} = require('~/models/Prompt'); } = require('~/models/Prompt');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); const { requireJwtAuth } = require('~/server/middleware');
const { logger } = require('~/config'); const { getRoleByName } = require('~/models/Role');
const router = express.Router(); const router = express.Router();
const checkPromptAccess = generateCheckAccess(PermissionTypes.PROMPTS, [Permissions.USE]); const checkPromptAccess = generateCheckAccess({
const checkPromptCreate = generateCheckAccess(PermissionTypes.PROMPTS, [ permissionType: PermissionTypes.PROMPTS,
Permissions.USE, permissions: [Permissions.USE],
Permissions.CREATE, getRoleByName,
]); });
const checkPromptCreate = generateCheckAccess({
permissionType: PermissionTypes.PROMPTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName,
});
const checkGlobalPromptShare = generateCheckAccess( const checkGlobalPromptShare = generateCheckAccess({
PermissionTypes.PROMPTS, permissionType: PermissionTypes.PROMPTS,
[Permissions.USE, Permissions.CREATE], permissions: [Permissions.USE, Permissions.CREATE],
{ bodyProps: {
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'], [Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
}, },
); getRoleByName,
});
router.use(requireJwtAuth); router.use(requireJwtAuth);
router.use(checkPromptAccess); router.use(checkPromptAccess);

View file

@ -1,18 +1,24 @@
const express = require('express'); const express = require('express');
const { logger } = require('@librechat/data-schemas');
const { generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { const {
getConversationTags, updateTagsForConversation,
updateConversationTag, updateConversationTag,
createConversationTag, createConversationTag,
deleteConversationTag, deleteConversationTag,
updateTagsForConversation, getConversationTags,
} = require('~/models/ConversationTag'); } = require('~/models/ConversationTag');
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware'); const { requireJwtAuth } = require('~/server/middleware');
const { logger } = require('~/config'); const { getRoleByName } = require('~/models/Role');
const router = express.Router(); const router = express.Router();
const checkBookmarkAccess = generateCheckAccess(PermissionTypes.BOOKMARKS, [Permissions.USE]); const checkBookmarkAccess = generateCheckAccess({
permissionType: PermissionTypes.BOOKMARKS,
permissions: [Permissions.USE],
getRoleByName,
});
router.use(requireJwtAuth); router.use(requireJwtAuth);
router.use(checkBookmarkAccess); router.use(checkBookmarkAccess);

View file

@ -0,0 +1,37 @@
import { createContext, useContext, useState, ReactNode } from 'react';
interface ActivePanelContextType {
active: string | undefined;
setActive: (id: string) => void;
}
const ActivePanelContext = createContext<ActivePanelContextType | undefined>(undefined);
export function ActivePanelProvider({
children,
defaultActive,
}: {
children: ReactNode;
defaultActive?: string;
}) {
const [active, _setActive] = useState<string | undefined>(defaultActive);
const setActive = (id: string) => {
localStorage.setItem('side:active-panel', id);
_setActive(id);
};
return (
<ActivePanelContext.Provider value={{ active, setActive }}>
{children}
</ActivePanelContext.Provider>
);
}
export function useActivePanel() {
const context = useContext(ActivePanelContext);
if (context === undefined) {
throw new Error('useActivePanel must be used within an ActivePanelProvider');
}
return context;
}

View file

@ -40,41 +40,40 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
agent_id: agent_id || '', agent_id: agent_id || '',
})) || []; })) || [];
const groupedTools = const groupedTools = tools?.reduce(
tools?.reduce( (acc, tool) => {
(acc, tool) => { if (tool.tool_id.includes(Constants.mcp_delimiter)) {
if (tool.tool_id.includes(Constants.mcp_delimiter)) { const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter); const groupKey = `${serverName.toLowerCase()}`;
const groupKey = `${serverName.toLowerCase()}`; if (!acc[groupKey]) {
if (!acc[groupKey]) { acc[groupKey] = {
acc[groupKey] = { tool_id: groupKey,
tool_id: groupKey, metadata: {
metadata: { name: `${serverName}`,
name: `${serverName}`, pluginKey: groupKey,
pluginKey: groupKey, description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, icon: tool.metadata.icon || '',
icon: tool.metadata.icon || '', } as TPlugin,
} as TPlugin,
agent_id: agent_id || '',
tools: [],
};
}
acc[groupKey].tools?.push({
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
});
} else {
acc[tool.tool_id] = {
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '', agent_id: agent_id || '',
tools: [],
}; };
} }
return acc; acc[groupKey].tools?.push({
}, tool_id: tool.tool_id,
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>, metadata: tool.metadata,
) || {}; agent_id: agent_id || '',
});
} else {
acc[tool.tool_id] = {
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
};
}
return acc;
},
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
);
const value = { const value = {
action, action,

View file

@ -1,6 +1,7 @@
export { default as AssistantsProvider } from './AssistantsContext'; export { default as AssistantsProvider } from './AssistantsContext';
export { default as AgentsProvider } from './AgentsContext'; export { default as AgentsProvider } from './AgentsContext';
export { default as ToastProvider } from './ToastContext'; export { default as ToastProvider } from './ToastContext';
export * from './ActivePanelContext';
export * from './AgentPanelContext'; export * from './AgentPanelContext';
export * from './ChatContext'; export * from './ChatContext';
export * from './ShareContext'; export * from './ShareContext';

View file

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

View file

@ -4,9 +4,9 @@ import {
supportsFiles, supportsFiles,
mergeFileConfig, mergeFileConfig,
isAgentsEndpoint, isAgentsEndpoint,
EndpointFileConfig,
fileConfig as defaultFileConfig, fileConfig as defaultFileConfig,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { useGetFileConfig } from '~/data-provider'; import { useGetFileConfig } from '~/data-provider';
import AttachFileMenu from './AttachFileMenu'; import AttachFileMenu from './AttachFileMenu';
import { useChatContext } from '~/Providers'; import { useChatContext } from '~/Providers';
@ -14,22 +14,25 @@ import { useChatContext } from '~/Providers';
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) { function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
const { conversation } = useChatContext(); const { conversation } = useChatContext();
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO; const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null }; const { endpoint, endpointType } = conversation ?? { endpoint: null };
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]); const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data), select: (data) => mergeFileConfig(data),
}); });
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined;
| EndpointFileConfig const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
| undefined;
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false; const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) { if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
return <AttachFileMenu disabled={disableInputs} conversationId={conversationId} />; return (
<AttachFileMenu
disabled={disableInputs}
conversationId={conversationId}
endpointFileConfig={endpointFileConfig}
/>
);
} }
return null; return null;

View file

@ -2,6 +2,7 @@ import { useSetRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react'; import * as Ariakit from '@ariakit/react';
import React, { useRef, useState, useMemo } from 'react'; import React, { useRef, useState, useMemo } from 'react';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import type { EndpointFileConfig } from 'librechat-data-provider';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components'; import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
import { EToolResources, EModelEndpoint } from 'librechat-data-provider'; import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider'; import { useGetEndpointsQuery } from '~/data-provider';
@ -12,9 +13,10 @@ import { cn } from '~/utils';
interface AttachFileMenuProps { interface AttachFileMenuProps {
conversationId: string; conversationId: string;
disabled?: boolean | null; disabled?: boolean | null;
endpointFileConfig?: EndpointFileConfig;
} }
const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => { const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
const localize = useLocalize(); const localize = useLocalize();
const isUploadDisabled = disabled ?? false; const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -24,6 +26,7 @@ const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => {
const { data: endpointsConfig } = useGetEndpointsQuery(); const { data: endpointsConfig } = useGetEndpointsQuery();
const { handleFileChange } = useFileHandling({ const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents, overrideEndpoint: EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
}); });
/** TODO: Ephemeral Agent Capabilities /** TODO: Ephemeral Agent Capabilities

View file

@ -169,7 +169,7 @@ export default function AgentConfig({
const visibleToolIds = new Set(selectedToolIds); const visibleToolIds = new Set(selectedToolIds);
// Check what group parent tools should be shown if any subtool is present // Check what group parent tools should be shown if any subtool is present
Object.entries(allTools).forEach(([toolId, toolObj]) => { Object.entries(allTools ?? {}).forEach(([toolId, toolObj]) => {
if (toolObj.tools?.length) { if (toolObj.tools?.length) {
// if any subtool of this group is selected, ensure group parent tool rendered // if any subtool of this group is selected, ensure group parent tool rendered
if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) { if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) {
@ -300,6 +300,7 @@ export default function AgentConfig({
<div className="mb-1"> <div className="mb-1">
{/* // Render all visible IDs (including groups with subtools selected) */} {/* // Render all visible IDs (including groups with subtools selected) */}
{[...visibleToolIds].map((toolId, i) => { {[...visibleToolIds].map((toolId, i) => {
if (!allTools) return null;
const tool = allTools[toolId]; const tool = allTools[toolId];
if (!tool) return null; if (!tool) return null;
return ( return (

View file

@ -19,7 +19,7 @@ export default function AgentTool({
allTools, allTools,
}: { }: {
tool: string; tool: string;
allTools: Record<string, AgentToolType & { tools?: AgentToolType[] }>; allTools?: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
agent_id?: string; agent_id?: string;
}) { }) {
const [isHovering, setIsHovering] = useState(false); const [isHovering, setIsHovering] = useState(false);
@ -30,8 +30,10 @@ export default function AgentTool({
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation(); const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>(); const { getValues, setValue } = useFormContext<AgentForm>();
if (!allTools) {
return null;
}
const currentTool = allTools[tool]; const currentTool = allTools[tool];
const getSelectedTools = () => { const getSelectedTools = () => {
if (!currentTool?.tools) return []; if (!currentTool?.tools) return [];
const formTools = getValues('tools') || []; const formTools = getValues('tools') || [];

View file

@ -17,9 +17,9 @@ import {
} from '~/data-provider'; } from '~/data-provider';
import { cn, cardStyle, defaultTextProps, removeFocusOutlines } from '~/utils'; import { cn, cardStyle, defaultTextProps, removeFocusOutlines } from '~/utils';
import AssistantConversationStarters from './AssistantConversationStarters'; import AssistantConversationStarters from './AssistantConversationStarters';
import AssistantToolsDialog from '~/components/Tools/AssistantToolsDialog';
import { useAssistantsMapContext, useToastContext } from '~/Providers'; import { useAssistantsMapContext, useToastContext } from '~/Providers';
import { useSelectAssistant, useLocalize } from '~/hooks'; import { useSelectAssistant, useLocalize } from '~/hooks';
import { ToolSelectDialog } from '~/components/Tools';
import AppendDateCheckbox from './AppendDateCheckbox'; import AppendDateCheckbox from './AppendDateCheckbox';
import CapabilitiesForm from './CapabilitiesForm'; import CapabilitiesForm from './CapabilitiesForm';
import { SelectDropDown } from '~/components/ui'; import { SelectDropDown } from '~/components/ui';
@ -468,11 +468,10 @@ export default function AssistantPanel({
</button> </button>
</div> </div>
</div> </div>
<ToolSelectDialog <AssistantToolsDialog
endpoint={endpoint}
isOpen={showToolDialog} isOpen={showToolDialog}
setIsOpen={setShowToolDialog} setIsOpen={setShowToolDialog}
toolsFormKey="functions"
endpoint={endpoint}
/> />
</form> </form>
</FormProvider> </FormProvider>

View file

@ -1,21 +1,15 @@
import { useState } from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion'; import * as AccordionPrimitive from '@radix-ui/react-accordion';
import type { NavLink, NavProps } from '~/common'; import type { NavLink, NavProps } from '~/common';
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion'; import { AccordionContent, AccordionItem, TooltipAnchor, Accordion, Button } from '~/components/ui';
import { TooltipAnchor, Button } from '~/components'; import { ActivePanelProvider, useActivePanel } from '~/Providers';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) { function NavContent({ links, isCollapsed, resize }: Omit<NavProps, 'defaultActive'>) {
const localize = useLocalize(); const localize = useLocalize();
const [active, _setActive] = useState<string | undefined>(defaultActive); const { active, setActive } = useActivePanel();
const getVariant = (link: NavLink) => (link.id === active ? 'default' : 'ghost'); const getVariant = (link: NavLink) => (link.id === active ? 'default' : 'ghost');
const setActive = (id: string) => {
localStorage.setItem('side:active-panel', id + '');
_setActive(id);
};
return ( return (
<div <div
data-collapsed={isCollapsed} data-collapsed={isCollapsed}
@ -105,3 +99,11 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
</div> </div>
); );
} }
export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) {
return (
<ActivePanelProvider defaultActive={defaultActive}>
<NavContent links={links} isCollapsed={isCollapsed} resize={resize} />
</ActivePanelProvider>
);
}

View file

@ -0,0 +1,254 @@
import { useEffect } from 'react';
import { Search, X } from 'lucide-react';
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
import { useFormContext } from 'react-hook-form';
import { isAgentsEndpoint } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type {
AssistantsEndpoint,
EModelEndpoint,
TPluginAction,
TError,
} from 'librechat-data-provider';
import type { TPluginStoreDialogProps } from '~/common/types';
import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store';
import { useLocalize, usePluginDialogHelpers } from '~/hooks';
import { useAvailableToolsQuery } from '~/data-provider';
import ToolItem from './ToolItem';
function AssistantToolsDialog({
isOpen,
endpoint,
setIsOpen,
}: TPluginStoreDialogProps & {
endpoint: AssistantsEndpoint | EModelEndpoint.agents;
}) {
const localize = useLocalize();
const { getValues, setValue } = useFormContext();
const { data: tools } = useAvailableToolsQuery(endpoint);
const isAgentTools = isAgentsEndpoint(endpoint);
const {
maxPage,
setMaxPage,
currentPage,
setCurrentPage,
itemsPerPage,
searchChanged,
setSearchChanged,
searchValue,
setSearchValue,
gridRef,
handleSearch,
handleChangePage,
error,
setError,
errorMessage,
setErrorMessage,
showPluginAuthForm,
setShowPluginAuthForm,
selectedPlugin,
setSelectedPlugin,
} = usePluginDialogHelpers();
const updateUserPlugins = useUpdateUserPluginsMutation();
const handleInstallError = (error: TError) => {
setError(true);
const errorMessage = error.response?.data?.message ?? '';
if (errorMessage) {
setErrorMessage(errorMessage);
}
setTimeout(() => {
setError(false);
setErrorMessage('');
}, 5000);
};
const handleInstall = (pluginAction: TPluginAction) => {
const addFunction = () => {
const fns = getValues('functions').slice();
fns.push(pluginAction.pluginKey);
setValue('functions', fns);
};
if (!pluginAction.auth) {
return addFunction();
}
updateUserPlugins.mutate(pluginAction, {
onError: (error: unknown) => {
handleInstallError(error as TError);
},
onSuccess: addFunction,
});
setShowPluginAuthForm(false);
};
const onRemoveTool = (tool: string) => {
setShowPluginAuthForm(false);
updateUserPlugins.mutate(
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
{
onError: (error: unknown) => {
handleInstallError(error as TError);
},
onSuccess: () => {
const fns = getValues('functions').filter((fn: string) => fn !== tool);
setValue('functions', fns);
},
},
);
};
const onAddTool = (pluginKey: string) => {
setShowPluginAuthForm(false);
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
setSelectedPlugin(getAvailablePluginFromKey);
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
if (authConfig && authConfig.length > 0 && !authenticated) {
setShowPluginAuthForm(true);
} else {
handleInstall({ pluginKey, action: 'install', auth: null });
}
};
const filteredTools = tools?.filter((tool) =>
tool.name.toLowerCase().includes(searchValue.toLowerCase()),
);
useEffect(() => {
if (filteredTools) {
setMaxPage(Math.ceil(filteredTools.length / itemsPerPage));
if (searchChanged) {
setCurrentPage(1);
setSearchChanged(false);
}
}
}, [
tools,
itemsPerPage,
searchValue,
filteredTools,
searchChanged,
setMaxPage,
setCurrentPage,
setSearchChanged,
]);
return (
<Dialog
open={isOpen}
onClose={() => {
setIsOpen(false);
setCurrentPage(1);
setSearchValue('');
}}
className="relative z-[102]"
>
{/* The backdrop, rendered as a fixed sibling to the panel container */}
<div className="fixed inset-0 bg-surface-primary opacity-60 transition-opacity dark:opacity-80" />
{/* Full-screen container to center the panel */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel
className="relative w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
style={{ minHeight: '610px' }}
>
<div className="flex items-center justify-between border-b-[1px] border-border-medium px-4 pb-4 pt-5 sm:p-6">
<div className="flex items-center">
<div className="text-center sm:text-left">
<DialogTitle className="text-lg font-medium leading-6 text-text-primary">
{isAgentTools
? localize('com_nav_tool_dialog_agents')
: localize('com_nav_tool_dialog')}
</DialogTitle>
<Description className="text-sm text-text-secondary">
{localize('com_nav_tool_dialog_description')}
</Description>
</div>
</div>
<div>
<div className="sm:mt-0">
<button
onClick={() => {
setIsOpen(false);
setCurrentPage(1);
}}
className="inline-block rounded-full text-text-secondary transition-colors hover:text-text-primary"
aria-label="Close dialog"
type="button"
>
<X aria-hidden="true" />
</button>
</div>
</div>
</div>
{error && (
<div
className="relative m-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700"
role="alert"
>
{localize('com_nav_plugin_auth_error')} {errorMessage}
</div>
)}
{showPluginAuthForm && (
<div className="p-4 sm:p-6 sm:pt-4">
<PluginAuthForm
plugin={selectedPlugin}
onSubmit={(installActionData: TPluginAction) => handleInstall(installActionData)}
isEntityTool={true}
/>
</div>
)}
<div className="p-4 sm:p-6 sm:pt-4">
<div className="mt-4 flex flex-col gap-4">
<div className="flex items-center justify-center space-x-4">
<Search className="h-6 w-6 text-text-tertiary" />
<input
type="text"
value={searchValue}
onChange={handleSearch}
placeholder={localize('com_nav_tool_search')}
className="w-64 rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary focus:outline-none"
/>
</div>
<div
ref={gridRef}
className="grid grid-cols-1 grid-rows-2 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
style={{ minHeight: '410px' }}
>
{filteredTools &&
filteredTools
.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
.map((tool, index) => (
<ToolItem
key={index}
tool={tool}
isInstalled={getValues('functions').includes(tool.pluginKey)}
onAddTool={() => onAddTool(tool.pluginKey)}
onRemoveTool={() => onRemoveTool(tool.pluginKey)}
/>
))}
</div>
</div>
<div className="mt-2 flex flex-col items-center gap-2 sm:flex-row sm:justify-between">
{maxPage > 0 ? (
<PluginPagination
currentPage={currentPage}
maxPage={maxPage}
onChangePage={handleChangePage}
/>
) : (
<div style={{ height: '21px' }}></div>
)}
</div>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
export default AssistantToolsDialog;

View file

@ -1,9 +1,9 @@
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react'; import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
import { AgentToolType } from 'librechat-data-provider'; import type { TPlugin, AgentToolType } from 'librechat-data-provider';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
type ToolItemProps = { type ToolItemProps = {
tool: AgentToolType; tool: TPlugin | AgentToolType;
onAddTool: () => void; onAddTool: () => void;
onRemoveTool: () => void; onRemoveTool: () => void;
isInstalled?: boolean; isInstalled?: boolean;
@ -19,9 +19,13 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
} }
}; };
const name = tool.metadata?.name || tool.tool_id; const name =
const description = tool.metadata?.description || ''; (tool as AgentToolType).metadata?.name ||
const icon = tool.metadata?.icon; (tool as AgentToolType).tool_id ||
(tool as TPlugin).name;
const description =
(tool as AgentToolType).metadata?.description || (tool as TPlugin).description || '';
const icon = (tool as AgentToolType).metadata?.icon || (tool as TPlugin).icon;
return ( return (
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6"> <div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">

View file

@ -67,15 +67,14 @@ function ToolSelectDialog({
}, 5000); }, 5000);
}; };
const toolsFormKey = 'tools';
const handleInstall = (pluginAction: TPluginAction) => { const handleInstall = (pluginAction: TPluginAction) => {
const addFunction = () => { const addFunction = () => {
const installedToolIds: string[] = getValues(toolsFormKey) || []; const installedToolIds: string[] = getValues('tools') || [];
// Add the parent // Add the parent
installedToolIds.push(pluginAction.pluginKey); installedToolIds.push(pluginAction.pluginKey);
// If this tool is a group, add subtools too // If this tool is a group, add subtools too
const groupObj = groupedTools[pluginAction.pluginKey]; const groupObj = groupedTools?.[pluginAction.pluginKey];
if (groupObj?.tools && groupObj.tools.length > 0) { if (groupObj?.tools && groupObj.tools.length > 0) {
for (const sub of groupObj.tools) { for (const sub of groupObj.tools) {
if (!installedToolIds.includes(sub.tool_id)) { if (!installedToolIds.includes(sub.tool_id)) {
@ -83,7 +82,7 @@ function ToolSelectDialog({
} }
} }
} }
setValue(toolsFormKey, Array.from(new Set(installedToolIds))); // no duplicates just in case setValue('tools', Array.from(new Set(installedToolIds))); // no duplicates just in case
}; };
if (!pluginAction.auth) { if (!pluginAction.auth) {
@ -101,7 +100,7 @@ function ToolSelectDialog({
}; };
const onRemoveTool = (toolId: string) => { const onRemoveTool = (toolId: string) => {
const groupObj = groupedTools[toolId]; const groupObj = groupedTools?.[toolId];
const toolIdsToRemove = [toolId]; const toolIdsToRemove = [toolId];
if (groupObj?.tools && groupObj.tools.length > 0) { if (groupObj?.tools && groupObj.tools.length > 0) {
toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id)); toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id));
@ -113,8 +112,8 @@ function ToolSelectDialog({
onError: (error: unknown) => handleInstallError(error as TError), onError: (error: unknown) => handleInstallError(error as TError),
onSuccess: () => { onSuccess: () => {
const remainingToolIds = const remainingToolIds =
getValues(toolsFormKey)?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || []; getValues('tools')?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || [];
setValue(toolsFormKey, remainingToolIds); setValue('tools', remainingToolIds);
}, },
}, },
); );
@ -268,7 +267,7 @@ function ToolSelectDialog({
<ToolItem <ToolItem
key={index} key={index}
tool={tool} tool={tool}
isInstalled={getValues(toolsFormKey)?.includes(tool.tool_id) || false} isInstalled={getValues('tools')?.includes(tool.tool_id) || false}
onAddTool={() => onAddTool(tool.tool_id)} onAddTool={() => onAddTool(tool.tool_id)}
onRemoveTool={() => onRemoveTool(tool.tool_id)} onRemoveTool={() => onRemoveTool(tool.tool_id)}
/> />

View file

@ -1,3 +1,4 @@
export * from './Accordion';
export * from './AnimatedTabs'; export * from './AnimatedTabs';
export * from './AlertDialog'; export * from './AlertDialog';
export * from './Breadcrumb'; export * from './Breadcrumb';

View file

@ -1,33 +1,34 @@
import { useQueryClient } from '@tanstack/react-query';
import type { TEndpointsConfig, TError } from 'librechat-data-provider';
import {
defaultAssistantsVersion,
fileConfig as defaultFileConfig,
EModelEndpoint,
isAgentsEndpoint,
isAssistantsEndpoint,
mergeFileConfig,
QueryKeys,
} from 'librechat-data-provider';
import debounce from 'lodash/debounce';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { useQueryClient } from '@tanstack/react-query';
import {
QueryKeys,
EModelEndpoint,
mergeFileConfig,
isAgentsEndpoint,
isAssistantsEndpoint,
defaultAssistantsVersion,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import debounce from 'lodash/debounce';
import type { EndpointFileConfig, TEndpointsConfig, TError } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter } from '~/common'; import type { ExtendedFile, FileSetter } from '~/common';
import { useGetFileConfig, useUploadFileMutation } from '~/data-provider'; import { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; import useLocalize, { TranslationKeys } from '~/hooks/useLocalize';
import { useChatContext } from '~/Providers/ChatContext'; import { useDelayedUploadToast } from './useDelayedUploadToast';
import { processFileForUpload } from '~/utils/heicConverter';
import { useToastContext } from '~/Providers/ToastContext'; import { useToastContext } from '~/Providers/ToastContext';
import { useChatContext } from '~/Providers/ChatContext';
import { logger, validateFiles } from '~/utils'; import { logger, validateFiles } from '~/utils';
import useClientResize from './useClientResize'; import useClientResize from './useClientResize';
import { processFileForUpload } from '~/utils/heicConverter';
import { useDelayedUploadToast } from './useDelayedUploadToast';
import useUpdateFiles from './useUpdateFiles'; import useUpdateFiles from './useUpdateFiles';
type UseFileHandling = { type UseFileHandling = {
overrideEndpoint?: EModelEndpoint;
fileSetter?: FileSetter; fileSetter?: FileSetter;
fileFilter?: (file: File) => boolean; fileFilter?: (file: File) => boolean;
additionalMetadata?: Record<string, string | undefined>; additionalMetadata?: Record<string, string | undefined>;
overrideEndpoint?: EModelEndpoint;
overrideEndpointFileConfig?: EndpointFileConfig;
}; };
const useFileHandling = (params?: UseFileHandling) => { const useFileHandling = (params?: UseFileHandling) => {
@ -246,8 +247,9 @@ const useFileHandling = (params?: UseFileHandling) => {
fileList, fileList,
setError, setError,
endpointFileConfig: endpointFileConfig:
fileConfig?.endpoints[endpoint] ?? params?.overrideEndpointFileConfig ??
fileConfig?.endpoints.default ?? fileConfig?.endpoints?.[endpoint] ??
fileConfig?.endpoints?.default ??
defaultFileConfig.endpoints[endpoint] ?? defaultFileConfig.endpoints[endpoint] ??
defaultFileConfig.endpoints.default, defaultFileConfig.endpoints.default,
}); });

View file

@ -77,7 +77,7 @@ export default function useSideNavLinks({
title: 'com_sidepanel_assistant_builder', title: 'com_sidepanel_assistant_builder',
label: '', label: '',
icon: Blocks, icon: Blocks,
id: 'assistants', id: EModelEndpoint.assistants,
Component: PanelSwitch, Component: PanelSwitch,
}); });
} }
@ -92,7 +92,7 @@ export default function useSideNavLinks({
title: 'com_sidepanel_agent_builder', title: 'com_sidepanel_agent_builder',
label: '', label: '',
icon: Blocks, icon: Blocks,
id: 'agents', id: EModelEndpoint.agents,
Component: AgentPanelSwitch, Component: AgentPanelSwitch,
}); });
} }

View file

@ -12,6 +12,8 @@ export * from './oauth';
export * from './crypto'; export * from './crypto';
/* Flow */ /* Flow */
export * from './flow/manager'; export * from './flow/manager';
/* Middleware */
export * from './middleware';
/* Agents */ /* Agents */
export * from './agents'; export * from './agents';
/* Endpoints */ /* Endpoints */

View file

@ -0,0 +1,141 @@
import { logger } from '@librechat/data-schemas';
import {
Permissions,
EndpointURLs,
EModelEndpoint,
PermissionTypes,
isAgentsEndpoint,
} from 'librechat-data-provider';
import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express';
import type { IRole, IUser } from '@librechat/data-schemas';
export function skipAgentCheck(req?: ServerRequest): boolean {
if (!req || !req?.body?.endpoint) {
return false;
}
if (req.method !== 'POST') {
return false;
}
if (!req.originalUrl?.includes(EndpointURLs[EModelEndpoint.agents])) {
return false;
}
return !isAgentsEndpoint(req.body.endpoint);
}
/**
* Core function to check if a user has one or more required permissions
* @param user - The user object
* @param permissionType - The type of permission to check
* @param permissions - The list of specific permissions to check
* @param bodyProps - An optional object where keys are permissions and values are arrays of properties to check
* @param checkObject - The object to check properties against
* @param skipCheck - An optional function that takes the checkObject and returns true to skip permission checking
* @returns Whether the user has the required permissions
*/
export const checkAccess = async ({
req,
user,
permissionType,
permissions,
getRoleByName,
bodyProps = {} as Record<Permissions, string[]>,
checkObject = {},
skipCheck,
}: {
user: IUser;
req?: ServerRequest;
permissionType: PermissionTypes;
permissions: Permissions[];
bodyProps?: Record<Permissions, string[]>;
checkObject?: object;
/** If skipCheck function is provided and returns true, skip permission checking */
skipCheck?: (req?: ServerRequest) => boolean;
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
}): Promise<boolean> => {
if (skipCheck && skipCheck(req)) {
return true;
}
if (!user || !user.role) {
return false;
}
const role = await getRoleByName(user.role);
if (role && role.permissions && role.permissions[permissionType]) {
const hasAnyPermission = permissions.some((permission) => {
if (
role.permissions?.[permissionType as keyof typeof role.permissions]?.[
permission as keyof (typeof role.permissions)[typeof permissionType]
]
) {
return true;
}
if (bodyProps[permission] && checkObject) {
return bodyProps[permission].some((prop) =>
Object.prototype.hasOwnProperty.call(checkObject, prop),
);
}
return false;
});
return hasAnyPermission;
}
return false;
};
/**
* Middleware to check if a user has one or more required permissions, optionally based on `req.body` properties.
* @param permissionType - The type of permission to check.
* @param permissions - The list of specific permissions to check.
* @param bodyProps - An optional object where keys are permissions and values are arrays of `req.body` properties to check.
* @param skipCheck - An optional function that takes req.body and returns true to skip permission checking.
* @param getRoleByName - A function to get the role by name.
* @returns Express middleware function.
*/
export const generateCheckAccess = ({
permissionType,
permissions,
bodyProps = {} as Record<Permissions, string[]>,
skipCheck,
getRoleByName,
}: {
permissionType: PermissionTypes;
permissions: Permissions[];
bodyProps?: Record<Permissions, string[]>;
skipCheck?: (req?: ServerRequest) => boolean;
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
}): ((req: ServerRequest, res: ServerResponse, next: NextFunction) => Promise<unknown>) => {
return async (req, res, next) => {
try {
const hasAccess = await checkAccess({
req,
user: req.user as IUser,
permissionType,
permissions,
bodyProps,
checkObject: req.body,
skipCheck,
getRoleByName,
});
if (hasAccess) {
return next();
}
logger.warn(
`[${permissionType}] Forbidden: "${req.originalUrl}" - Insufficient permissions for User ${req.user?.id}: ${permissions.join(', ')}`,
);
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
} catch (error) {
logger.error(error);
return res.status(500).json({
message: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}`,
});
}
};
};

View file

@ -0,0 +1 @@
export * from './access';

View file

@ -949,11 +949,11 @@ export const initialModelsConfig: TModelsConfig = {
[EModelEndpoint.bedrock]: defaultModels[EModelEndpoint.bedrock], [EModelEndpoint.bedrock]: defaultModels[EModelEndpoint.bedrock],
}; };
export const EndpointURLs: Record<string, string> = { export const EndpointURLs = {
[EModelEndpoint.assistants]: '/api/assistants/v2/chat', [EModelEndpoint.assistants]: '/api/assistants/v2/chat',
[EModelEndpoint.azureAssistants]: '/api/assistants/v1/chat', [EModelEndpoint.azureAssistants]: '/api/assistants/v1/chat',
[EModelEndpoint.agents]: `/api/${EModelEndpoint.agents}/chat`, [EModelEndpoint.agents]: `/api/${EModelEndpoint.agents}/chat`,
}; } as const;
export const modularEndpoints = new Set<EModelEndpoint | string>([ export const modularEndpoints = new Set<EModelEndpoint | string>([
EModelEndpoint.gptPlugins, EModelEndpoint.gptPlugins,

View file

@ -134,7 +134,7 @@ export type EventSubmission = Omit<TSubmission, 'initialResponse'> & { initialRe
export type TPluginAction = { export type TPluginAction = {
pluginKey: string; pluginKey: string;
action: 'install' | 'uninstall'; action: 'install' | 'uninstall';
auth?: Partial<Record<string, string>>; auth?: Partial<Record<string, string>> | null;
isEntityTool?: boolean; isEntityTool?: boolean;
}; };
@ -144,7 +144,7 @@ export type TUpdateUserPlugins = {
isEntityTool?: boolean; isEntityTool?: boolean;
pluginKey: string; pluginKey: string;
action: string; action: string;
auth?: Partial<Record<string, string | null>>; auth?: Partial<Record<string, string | null>> | null;
}; };
// TODO `label` needs to be changed to the proper `TranslationKeys` // TODO `label` needs to be changed to the proper `TranslationKeys`