mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🔒 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:
parent
9cdc62b655
commit
33b4a97b42
31 changed files with 672 additions and 242 deletions
|
@ -4,6 +4,7 @@ const {
|
|||
sendEvent,
|
||||
createRun,
|
||||
Tokenizer,
|
||||
checkAccess,
|
||||
memoryInstructions,
|
||||
createMemoryProcessor,
|
||||
} = require('@librechat/api');
|
||||
|
@ -39,8 +40,8 @@ const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
|||
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { getProviderConfig } = require('~/server/services/Endpoints');
|
||||
const { checkAccess } = require('~/server/middleware/roles/access');
|
||||
const BaseClient = require('~/app/clients/BaseClient');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
const { loadAgent } = require('~/models/Agent');
|
||||
const { getMCPManager } = require('~/config');
|
||||
|
||||
|
@ -401,7 +402,12 @@ class AgentClient extends BaseClient {
|
|||
if (user.personalization?.memories === false) {
|
||||
return;
|
||||
}
|
||||
const hasAccess = await checkAccess(user, PermissionTypes.MEMORIES, [Permissions.USE]);
|
||||
const hasAccess = await checkAccess({
|
||||
user,
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
logger.debug(
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
const { nanoid } = require('nanoid');
|
||||
const { EnvVar } = require('@librechat/agents');
|
||||
const { checkAccess } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
Tools,
|
||||
AuthType,
|
||||
|
@ -13,9 +15,8 @@ const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
|||
const { createToolCall, getToolCallsByConvo } = require('~/models/ToolCall');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { loadTools } = require('~/app/clients/tools/util');
|
||||
const { checkAccess } = require('~/server/middleware');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
const { getMessage } = require('~/models/Message');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const fieldsMap = {
|
||||
[Tools.execute_code]: [EnvVar.CODE_API_KEY],
|
||||
|
@ -79,6 +80,7 @@ const verifyToolAuth = async (req, res) => {
|
|||
throwError: false,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error loading auth values', error);
|
||||
res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED });
|
||||
return;
|
||||
}
|
||||
|
@ -132,7 +134,12 @@ const callTool = async (req, res) => {
|
|||
logger.debug(`[${toolId}/call] User: ${req.user.id}`);
|
||||
let hasAccess = true;
|
||||
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) {
|
||||
logger.warn(
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -1,8 +1,5 @@
|
|||
const checkAdmin = require('./admin');
|
||||
const { checkAccess, generateCheckAccess } = require('./access');
|
||||
|
||||
module.exports = {
|
||||
checkAdmin,
|
||||
checkAccess,
|
||||
generateCheckAccess,
|
||||
};
|
||||
|
|
|
@ -1,14 +1,28 @@
|
|||
const express = require('express');
|
||||
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 { updateAction, getActions, deleteAction } = require('~/models/Action');
|
||||
const { isActionDomainAllowed } = require('~/server/services/domains');
|
||||
const { getAgent, updateAgent } = require('~/models/Agent');
|
||||
const { logger } = require('~/config');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const checkAgentCreate = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE, Permissions.CREATE],
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
// If the user has ADMIN role
|
||||
// then action edition is possible even if not owner of the assistant
|
||||
const isAdmin = (req) => {
|
||||
|
@ -41,7 +55,7 @@ router.get('/', async (req, res) => {
|
|||
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
|
||||
* @returns {Object} 200 - success response - application/json
|
||||
*/
|
||||
router.post('/:agent_id', async (req, res) => {
|
||||
router.post('/:agent_id', checkAgentCreate, async (req, res) => {
|
||||
try {
|
||||
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.
|
||||
* @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 {
|
||||
const { agent_id, action_id } = req.params;
|
||||
const admin = isAdmin(req);
|
||||
|
|
|
@ -1,22 +1,28 @@
|
|||
const express = require('express');
|
||||
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const {
|
||||
setHeaders,
|
||||
moderateText,
|
||||
// validateModel,
|
||||
generateCheckAccess,
|
||||
validateConvoAccess,
|
||||
buildEndpointOption,
|
||||
} = require('~/server/middleware');
|
||||
const { initializeClient } = require('~/server/services/Endpoints/agents');
|
||||
const AgentController = require('~/server/controllers/agents/request');
|
||||
const addTitle = require('~/server/services/Endpoints/agents/title');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
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(validateConvoAccess);
|
||||
|
|
|
@ -1,29 +1,36 @@
|
|||
const express = require('express');
|
||||
const { generateCheckAccess } = require('@librechat/api');
|
||||
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 { getRoleByName } = require('~/models/Role');
|
||||
const actions = require('./actions');
|
||||
const tools = require('./tools');
|
||||
|
||||
const router = express.Router();
|
||||
const avatar = express.Router();
|
||||
|
||||
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
const checkAgentCreate = generateCheckAccess(PermissionTypes.AGENTS, [
|
||||
Permissions.USE,
|
||||
Permissions.CREATE,
|
||||
]);
|
||||
const checkAgentAccess = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
const checkAgentCreate = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE, Permissions.CREATE],
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
const checkGlobalAgentShare = generateCheckAccess(
|
||||
PermissionTypes.AGENTS,
|
||||
[Permissions.USE, Permissions.CREATE],
|
||||
{
|
||||
const checkGlobalAgentShare = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE, Permissions.CREATE],
|
||||
bodyProps: {
|
||||
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
|
||||
},
|
||||
);
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkAgentAccess);
|
||||
|
||||
/**
|
||||
* Agent actions route.
|
||||
|
|
|
@ -1,37 +1,43 @@
|
|||
const express = require('express');
|
||||
const { Tokenizer } = require('@librechat/api');
|
||||
const { Tokenizer, generateCheckAccess } = require('@librechat/api');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const {
|
||||
getAllUserMemories,
|
||||
toggleUserMemories,
|
||||
createMemory,
|
||||
setMemory,
|
||||
deleteMemory,
|
||||
setMemory,
|
||||
} = require('~/models');
|
||||
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const checkMemoryRead = generateCheckAccess(PermissionTypes.MEMORIES, [
|
||||
Permissions.USE,
|
||||
Permissions.READ,
|
||||
]);
|
||||
const checkMemoryCreate = generateCheckAccess(PermissionTypes.MEMORIES, [
|
||||
Permissions.USE,
|
||||
Permissions.CREATE,
|
||||
]);
|
||||
const checkMemoryUpdate = generateCheckAccess(PermissionTypes.MEMORIES, [
|
||||
Permissions.USE,
|
||||
Permissions.UPDATE,
|
||||
]);
|
||||
const checkMemoryDelete = generateCheckAccess(PermissionTypes.MEMORIES, [
|
||||
Permissions.USE,
|
||||
Permissions.UPDATE,
|
||||
]);
|
||||
const checkMemoryOptOut = generateCheckAccess(PermissionTypes.MEMORIES, [
|
||||
Permissions.USE,
|
||||
Permissions.OPT_OUT,
|
||||
]);
|
||||
const checkMemoryRead = generateCheckAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permissions: [Permissions.USE, Permissions.READ],
|
||||
getRoleByName,
|
||||
});
|
||||
const checkMemoryCreate = generateCheckAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permissions: [Permissions.USE, Permissions.CREATE],
|
||||
getRoleByName,
|
||||
});
|
||||
const checkMemoryUpdate = generateCheckAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permissions: [Permissions.USE, Permissions.UPDATE],
|
||||
getRoleByName,
|
||||
});
|
||||
const checkMemoryDelete = generateCheckAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permissions: [Permissions.USE, Permissions.UPDATE],
|
||||
getRoleByName,
|
||||
});
|
||||
const checkMemoryOptOut = generateCheckAccess({
|
||||
permissionType: PermissionTypes.MEMORIES,
|
||||
permissions: [Permissions.USE, Permissions.OPT_OUT],
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
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 {
|
||||
getPrompt,
|
||||
getPrompts,
|
||||
|
@ -14,24 +16,30 @@ const {
|
|||
// updatePromptLabels,
|
||||
makePromptProduction,
|
||||
} = require('~/models/Prompt');
|
||||
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
|
||||
const { logger } = require('~/config');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const checkPromptAccess = generateCheckAccess(PermissionTypes.PROMPTS, [Permissions.USE]);
|
||||
const checkPromptCreate = generateCheckAccess(PermissionTypes.PROMPTS, [
|
||||
Permissions.USE,
|
||||
Permissions.CREATE,
|
||||
]);
|
||||
const checkPromptAccess = generateCheckAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
const checkPromptCreate = generateCheckAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permissions: [Permissions.USE, Permissions.CREATE],
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
const checkGlobalPromptShare = generateCheckAccess(
|
||||
PermissionTypes.PROMPTS,
|
||||
[Permissions.USE, Permissions.CREATE],
|
||||
{
|
||||
const checkGlobalPromptShare = generateCheckAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permissions: [Permissions.USE, Permissions.CREATE],
|
||||
bodyProps: {
|
||||
[Permissions.SHARED_GLOBAL]: ['projectIds', 'removeProjectIds'],
|
||||
},
|
||||
);
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkPromptAccess);
|
||||
|
|
|
@ -1,18 +1,24 @@
|
|||
const express = require('express');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { generateCheckAccess } = require('@librechat/api');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const {
|
||||
getConversationTags,
|
||||
updateTagsForConversation,
|
||||
updateConversationTag,
|
||||
createConversationTag,
|
||||
deleteConversationTag,
|
||||
updateTagsForConversation,
|
||||
getConversationTags,
|
||||
} = require('~/models/ConversationTag');
|
||||
const { requireJwtAuth, generateCheckAccess } = require('~/server/middleware');
|
||||
const { logger } = require('~/config');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
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(checkBookmarkAccess);
|
||||
|
|
37
client/src/Providers/ActivePanelContext.tsx
Normal file
37
client/src/Providers/ActivePanelContext.tsx
Normal 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;
|
||||
}
|
|
@ -40,41 +40,40 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
|||
agent_id: agent_id || '',
|
||||
})) || [];
|
||||
|
||||
const groupedTools =
|
||||
tools?.reduce(
|
||||
(acc, tool) => {
|
||||
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
|
||||
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
|
||||
const groupKey = `${serverName.toLowerCase()}`;
|
||||
if (!acc[groupKey]) {
|
||||
acc[groupKey] = {
|
||||
tool_id: groupKey,
|
||||
metadata: {
|
||||
name: `${serverName}`,
|
||||
pluginKey: groupKey,
|
||||
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
|
||||
icon: tool.metadata.icon || '',
|
||||
} 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,
|
||||
const groupedTools = tools?.reduce(
|
||||
(acc, tool) => {
|
||||
if (tool.tool_id.includes(Constants.mcp_delimiter)) {
|
||||
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
|
||||
const groupKey = `${serverName.toLowerCase()}`;
|
||||
if (!acc[groupKey]) {
|
||||
acc[groupKey] = {
|
||||
tool_id: groupKey,
|
||||
metadata: {
|
||||
name: `${serverName}`,
|
||||
pluginKey: groupKey,
|
||||
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
|
||||
icon: tool.metadata.icon || '',
|
||||
} as TPlugin,
|
||||
agent_id: agent_id || '',
|
||||
tools: [],
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
|
||||
) || {};
|
||||
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 || '',
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
|
||||
);
|
||||
|
||||
const value = {
|
||||
action,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
export { default as AssistantsProvider } from './AssistantsContext';
|
||||
export { default as AgentsProvider } from './AgentsContext';
|
||||
export { default as ToastProvider } from './ToastContext';
|
||||
export * from './ActivePanelContext';
|
||||
export * from './AgentPanelContext';
|
||||
export * from './ChatContext';
|
||||
export * from './ShareContext';
|
||||
|
|
|
@ -219,11 +219,11 @@ export type AgentPanelContextType = {
|
|||
mcps?: t.MCP[];
|
||||
setMcp: 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[];
|
||||
activePanel?: string;
|
||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
groupedTools?: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
|
||||
agent_id?: string;
|
||||
};
|
||||
|
||||
|
|
|
@ -4,9 +4,9 @@ import {
|
|||
supportsFiles,
|
||||
mergeFileConfig,
|
||||
isAgentsEndpoint,
|
||||
EndpointFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import AttachFileMenu from './AttachFileMenu';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
@ -14,22 +14,25 @@ import { useChatContext } from '~/Providers';
|
|||
function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
|
||||
const { conversation } = useChatContext();
|
||||
const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO;
|
||||
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]);
|
||||
const { endpoint, endpointType } = conversation ?? { endpoint: null };
|
||||
const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]);
|
||||
|
||||
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
|
||||
select: (data) => mergeFileConfig(data),
|
||||
});
|
||||
|
||||
const endpointFileConfig = fileConfig.endpoints[_endpoint ?? ''] as
|
||||
| EndpointFileConfig
|
||||
| undefined;
|
||||
|
||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false;
|
||||
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined;
|
||||
const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false;
|
||||
const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false;
|
||||
|
||||
if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
|
||||
return <AttachFileMenu disabled={disableInputs} conversationId={conversationId} />;
|
||||
return (
|
||||
<AttachFileMenu
|
||||
disabled={disableInputs}
|
||||
conversationId={conversationId}
|
||||
endpointFileConfig={endpointFileConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useSetRecoilState } from 'recoil';
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components';
|
||||
import { EToolResources, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
|
@ -12,9 +13,10 @@ import { cn } from '~/utils';
|
|||
interface AttachFileMenuProps {
|
||||
conversationId: string;
|
||||
disabled?: boolean | null;
|
||||
endpointFileConfig?: EndpointFileConfig;
|
||||
}
|
||||
|
||||
const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => {
|
||||
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
|
||||
const localize = useLocalize();
|
||||
const isUploadDisabled = disabled ?? false;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
@ -24,6 +26,7 @@ const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => {
|
|||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
overrideEndpointFileConfig: endpointFileConfig,
|
||||
});
|
||||
|
||||
/** TODO: Ephemeral Agent Capabilities
|
||||
|
|
|
@ -168,7 +168,7 @@ export default function AgentConfig({
|
|||
const visibleToolIds = new Set(selectedToolIds);
|
||||
|
||||
// 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 any subtool of this group is selected, ensure group parent tool rendered
|
||||
if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) {
|
||||
|
@ -299,6 +299,7 @@ export default function AgentConfig({
|
|||
<div className="mb-1">
|
||||
{/* // Render all visible IDs (including groups with subtools selected) */}
|
||||
{[...visibleToolIds].map((toolId, i) => {
|
||||
if (!allTools) return null;
|
||||
const tool = allTools[toolId];
|
||||
if (!tool) return null;
|
||||
return (
|
||||
|
|
|
@ -19,7 +19,7 @@ export default function AgentTool({
|
|||
allTools,
|
||||
}: {
|
||||
tool: string;
|
||||
allTools: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
|
||||
allTools?: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
|
||||
agent_id?: string;
|
||||
}) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
@ -30,8 +30,10 @@ export default function AgentTool({
|
|||
const { showToast } = useToastContext();
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
if (!allTools) {
|
||||
return null;
|
||||
}
|
||||
const currentTool = allTools[tool];
|
||||
|
||||
const getSelectedTools = () => {
|
||||
if (!currentTool?.tools) return [];
|
||||
const formTools = getValues('tools') || [];
|
||||
|
|
|
@ -17,9 +17,9 @@ import {
|
|||
} from '~/data-provider';
|
||||
import { cn, cardStyle, defaultTextProps, removeFocusOutlines } from '~/utils';
|
||||
import AssistantConversationStarters from './AssistantConversationStarters';
|
||||
import AssistantToolsDialog from '~/components/Tools/AssistantToolsDialog';
|
||||
import { useAssistantsMapContext, useToastContext } from '~/Providers';
|
||||
import { useSelectAssistant, useLocalize } from '~/hooks';
|
||||
import { ToolSelectDialog } from '~/components/Tools';
|
||||
import AppendDateCheckbox from './AppendDateCheckbox';
|
||||
import CapabilitiesForm from './CapabilitiesForm';
|
||||
import { SelectDropDown } from '~/components/ui';
|
||||
|
@ -468,11 +468,10 @@ export default function AssistantPanel({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolSelectDialog
|
||||
<AssistantToolsDialog
|
||||
endpoint={endpoint}
|
||||
isOpen={showToolDialog}
|
||||
setIsOpen={setShowToolDialog}
|
||||
toolsFormKey="functions"
|
||||
endpoint={endpoint}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
|
|
@ -1,21 +1,15 @@
|
|||
import { useState } from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import type { NavLink, NavProps } from '~/common';
|
||||
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion';
|
||||
import { TooltipAnchor, Button } from '~/components';
|
||||
import { AccordionContent, AccordionItem, TooltipAnchor, Accordion, Button } from '~/components/ui';
|
||||
import { ActivePanelProvider, useActivePanel } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
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 [active, _setActive] = useState<string | undefined>(defaultActive);
|
||||
const { active, setActive } = useActivePanel();
|
||||
const getVariant = (link: NavLink) => (link.id === active ? 'default' : 'ghost');
|
||||
|
||||
const setActive = (id: string) => {
|
||||
localStorage.setItem('side:active-panel', id + '');
|
||||
_setActive(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-collapsed={isCollapsed}
|
||||
|
@ -105,3 +99,11 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) {
|
||||
return (
|
||||
<ActivePanelProvider defaultActive={defaultActive}>
|
||||
<NavContent links={links} isCollapsed={isCollapsed} resize={resize} />
|
||||
</ActivePanelProvider>
|
||||
);
|
||||
}
|
||||
|
|
254
client/src/components/Tools/AssistantToolsDialog.tsx
Normal file
254
client/src/components/Tools/AssistantToolsDialog.tsx
Normal 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;
|
|
@ -1,9 +1,9 @@
|
|||
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';
|
||||
|
||||
type ToolItemProps = {
|
||||
tool: AgentToolType;
|
||||
tool: TPlugin | AgentToolType;
|
||||
onAddTool: () => void;
|
||||
onRemoveTool: () => void;
|
||||
isInstalled?: boolean;
|
||||
|
@ -19,9 +19,13 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
|
|||
}
|
||||
};
|
||||
|
||||
const name = tool.metadata?.name || tool.tool_id;
|
||||
const description = tool.metadata?.description || '';
|
||||
const icon = tool.metadata?.icon;
|
||||
const name =
|
||||
(tool as AgentToolType).metadata?.name ||
|
||||
(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 (
|
||||
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">
|
||||
|
|
|
@ -67,15 +67,14 @@ function ToolSelectDialog({
|
|||
}, 5000);
|
||||
};
|
||||
|
||||
const toolsFormKey = 'tools';
|
||||
const handleInstall = (pluginAction: TPluginAction) => {
|
||||
const addFunction = () => {
|
||||
const installedToolIds: string[] = getValues(toolsFormKey) || [];
|
||||
const installedToolIds: string[] = getValues('tools') || [];
|
||||
// Add the parent
|
||||
installedToolIds.push(pluginAction.pluginKey);
|
||||
|
||||
// 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) {
|
||||
for (const sub of groupObj.tools) {
|
||||
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) {
|
||||
|
@ -101,7 +100,7 @@ function ToolSelectDialog({
|
|||
};
|
||||
|
||||
const onRemoveTool = (toolId: string) => {
|
||||
const groupObj = groupedTools[toolId];
|
||||
const groupObj = groupedTools?.[toolId];
|
||||
const toolIdsToRemove = [toolId];
|
||||
if (groupObj?.tools && groupObj.tools.length > 0) {
|
||||
toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id));
|
||||
|
@ -113,8 +112,8 @@ function ToolSelectDialog({
|
|||
onError: (error: unknown) => handleInstallError(error as TError),
|
||||
onSuccess: () => {
|
||||
const remainingToolIds =
|
||||
getValues(toolsFormKey)?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || [];
|
||||
setValue(toolsFormKey, remainingToolIds);
|
||||
getValues('tools')?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || [];
|
||||
setValue('tools', remainingToolIds);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
@ -268,7 +267,7 @@ function ToolSelectDialog({
|
|||
<ToolItem
|
||||
key={index}
|
||||
tool={tool}
|
||||
isInstalled={getValues(toolsFormKey)?.includes(tool.tool_id) || false}
|
||||
isInstalled={getValues('tools')?.includes(tool.tool_id) || false}
|
||||
onAddTool={() => onAddTool(tool.tool_id)}
|
||||
onRemoveTool={() => onRemoveTool(tool.tool_id)}
|
||||
/>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './Accordion';
|
||||
export * from './AnimatedTabs';
|
||||
export * from './AlertDialog';
|
||||
export * from './Breadcrumb';
|
||||
|
|
|
@ -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 { 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 { useGetFileConfig, useUploadFileMutation } from '~/data-provider';
|
||||
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 { useChatContext } from '~/Providers/ChatContext';
|
||||
import { logger, validateFiles } from '~/utils';
|
||||
import useClientResize from './useClientResize';
|
||||
import { processFileForUpload } from '~/utils/heicConverter';
|
||||
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
||||
import useUpdateFiles from './useUpdateFiles';
|
||||
|
||||
type UseFileHandling = {
|
||||
overrideEndpoint?: EModelEndpoint;
|
||||
fileSetter?: FileSetter;
|
||||
fileFilter?: (file: File) => boolean;
|
||||
additionalMetadata?: Record<string, string | undefined>;
|
||||
overrideEndpoint?: EModelEndpoint;
|
||||
overrideEndpointFileConfig?: EndpointFileConfig;
|
||||
};
|
||||
|
||||
const useFileHandling = (params?: UseFileHandling) => {
|
||||
|
@ -246,8 +247,9 @@ const useFileHandling = (params?: UseFileHandling) => {
|
|||
fileList,
|
||||
setError,
|
||||
endpointFileConfig:
|
||||
fileConfig?.endpoints[endpoint] ??
|
||||
fileConfig?.endpoints.default ??
|
||||
params?.overrideEndpointFileConfig ??
|
||||
fileConfig?.endpoints?.[endpoint] ??
|
||||
fileConfig?.endpoints?.default ??
|
||||
defaultFileConfig.endpoints[endpoint] ??
|
||||
defaultFileConfig.endpoints.default,
|
||||
});
|
||||
|
|
|
@ -79,7 +79,7 @@ export default function useSideNavLinks({
|
|||
title: 'com_sidepanel_assistant_builder',
|
||||
label: '',
|
||||
icon: Blocks,
|
||||
id: 'assistants',
|
||||
id: EModelEndpoint.assistants,
|
||||
Component: PanelSwitch,
|
||||
});
|
||||
}
|
||||
|
@ -94,7 +94,7 @@ export default function useSideNavLinks({
|
|||
title: 'com_sidepanel_agent_builder',
|
||||
label: '',
|
||||
icon: Blocks,
|
||||
id: 'agents',
|
||||
id: EModelEndpoint.agents,
|
||||
Component: AgentPanelSwitch,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ export * from './oauth';
|
|||
export * from './crypto';
|
||||
/* Flow */
|
||||
export * from './flow/manager';
|
||||
/* Middleware */
|
||||
export * from './middleware';
|
||||
/* Agents */
|
||||
export * from './agents';
|
||||
/* Endpoints */
|
||||
|
|
141
packages/api/src/middleware/access.ts
Normal file
141
packages/api/src/middleware/access.ts
Normal 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'}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
1
packages/api/src/middleware/index.ts
Normal file
1
packages/api/src/middleware/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './access';
|
|
@ -949,11 +949,11 @@ export const initialModelsConfig: TModelsConfig = {
|
|||
[EModelEndpoint.bedrock]: defaultModels[EModelEndpoint.bedrock],
|
||||
};
|
||||
|
||||
export const EndpointURLs: Record<string, string> = {
|
||||
export const EndpointURLs = {
|
||||
[EModelEndpoint.assistants]: '/api/assistants/v2/chat',
|
||||
[EModelEndpoint.azureAssistants]: '/api/assistants/v1/chat',
|
||||
[EModelEndpoint.agents]: `/api/${EModelEndpoint.agents}/chat`,
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const modularEndpoints = new Set<EModelEndpoint | string>([
|
||||
EModelEndpoint.gptPlugins,
|
||||
|
|
|
@ -134,7 +134,7 @@ export type EventSubmission = Omit<TSubmission, 'initialResponse'> & { initialRe
|
|||
export type TPluginAction = {
|
||||
pluginKey: string;
|
||||
action: 'install' | 'uninstall';
|
||||
auth?: Partial<Record<string, string>>;
|
||||
auth?: Partial<Record<string, string>> | null;
|
||||
isEntityTool?: boolean;
|
||||
};
|
||||
|
||||
|
@ -144,7 +144,7 @@ export type TUpdateUserPlugins = {
|
|||
isEntityTool?: boolean;
|
||||
pluginKey: 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`
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue