Compare commits

..

16 Commits

Author SHA1 Message Date
Dustin Healy
351f30254c feat: Implement MCP Server Management API and UI Components
- Added API endpoints for managing MCP servers: create, read, update, and delete functionalities.
- Introduced new UI components for MCP server configuration, including MCPFormPanel and MCPConfig.
- Updated existing types and data provider to support MCP operations.
- Enhanced the side panel to include MCP server management options.
- Refactored related components and hooks for better integration with the new MCP features.
- Added tests for the new MCP server API functionalities.
2025-06-29 17:55:09 -07:00
Danny Avila
20100e120b 🔑 feat: Set Google Service Key File Path (#8130) 2025-06-29 17:09:37 -04:00
Danny Avila
3f3cfefc52 🗒️ feat: Add Google Vertex AI Mistral OCR Strategy (#8125)
* Implemented new uploadGoogleVertexMistralOCR function for processing OCR using Google Vertex AI.
* Added vertexMistralOCRStrategy to handle file uploads.
* Updated FileSources and OCRStrategy enums to include vertexai_mistral_ocr.
* Introduced helper functions for JWT creation and Google service account configuration loading.
2025-06-28 13:26:03 -04:00
matt burnett
3e1591d404 🤖 fix: Remove versions and __v when Duplicating an Agent (#8115)
Revert "Add tests for agent duplication controller"

This reverts commit 3e7beb1cc336bcfe1c57411e9c151f5e6aa927e4.
2025-06-28 12:35:41 -04:00
Danny Avila
1060ae8040 🐛 fix: Assistants Endpoint Handling in createPayload Function (#8123)
* 📦 chore: bump librechat-data-provider version to 0.7.89

* 🐛 fix: Assistants endpoint handling in createPayload function
2025-06-28 12:33:43 -04:00
Danny Avila
dd67e463e4 📦 chore: bump pbkdf2 to v3.1.3 (#8091) 2025-06-26 19:19:04 -04:00
github-actions[bot]
d60ad61325 🌍 i18n: Update translation.json with latest translations (#8058)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-06-26 19:12:46 -04:00
Danny Avila
452151e408 🐛 fix: RAG API failing with OPENID_REUSE_TOKENS Enabled (#8090)
* feat: Implement Short-Lived JWT Token Generation for RAG API

* fix: Update import paths

* fix: Correct environment variable names for OpenID on behalf flow

* fix: Remove unnecessary spaces in OpenID on behalf flow userinfo scope

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
2025-06-26 19:10:21 -04:00
Danny Avila
33b4a97b42 🔒 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
2025-06-26 18:53:05 -04:00
Sebastien Bruel
9cdc62b655 📂 fix: Prevent Null Reference Errors in File Process (#8084) 2025-06-26 18:51:35 -04:00
Danny Avila
799f0e5810 🐛 fix: Move MemoryEntry and PluginAuth model retrieval inside methods for Runtime Usage 2025-06-25 20:58:34 -04:00
Danny Avila
cbda3cb529 🕐 feat: Configurable Retention Period for Temporary Chats (#8056)
* feat: Add configurable retention period for temporary chats

* Addressing eslint errors

* Fix: failing test due to missing registration

* Update: variable name and use hours instead of days for chat retention

* Addressing comments

* chore: fix import order in Conversation.js

* chore: import order in Message.js

* chore: fix import order in config.ts

* chore: move common methods to packages/api to reduce potential for circular dependencies

* refactor: update temp chat retention config type to Partial<TCustomConfig>

* refactor: remove unused config variable from AppService and update loadCustomConfig tests with logger mock

* refactor: handle model undefined edge case by moving Session model initialization inside methods

---------

Co-authored-by: Rakshit Tiwari <rak1729e@gmail.com>
2025-06-25 17:16:26 -04:00
Karol Potocki
3ab1bd65e5 🐛 fix: Support Bedrock Provider for MCP Image Content Rendering (#8047) 2025-06-25 15:38:24 -04:00
Marlon
c551ba21f5 📜 chore: Update .env.example (#8043)
Update recent Gemini model names and remove deprecated Gemini models from env.example
2025-06-25 15:31:24 -04:00
Danny Avila
c87422a1e0 🧠 feat: Thinking Budget, Include Thoughts, and Dynamic Thinking for Gemini 2.5 (#8055)
* feat: support thinking budget parameter for Gemini 2.5 series (#6949, #7542)

https://ai.google.dev/gemini-api/docs/thinking#set-budget

* refactor: update thinking budget minimum value to -1 for dynamic thinking

- see: https://ai.google.dev/gemini-api/docs/thinking#set-budget

* chore: bump @librechat/agents to v2.4.43

* refactor: rename LLMConfigOptions to OpenAIConfigOptions for clarity and consistency

- Updated type definitions and references in initialize.ts, llm.ts, and openai.ts to reflect the new naming convention.
- Ensured that the OpenAI configuration options are consistently used across the relevant files.

* refactor: port Google LLM methods to TypeScript Package

* chore: update @librechat/agents version to 2.4.43 in package-lock.json and package.json

* refactor: update thinking budget description for clarity and adjust placeholder in parameter settings

* refactor: enhance googleSettings default value for thinking budget to support dynamic adjustment

* chore: update @librechat/agents to v2.4.44 for Vertex Dynamic Thinking workaround

* refactor: rename google config function, update `createRun` types, use `reasoning` as `reasoningKey` for Google

* refactor: simplify placeholder handling in DynamicInput component

* refactor: enhance thinking budget description for clarity and allow automatic decision by setting to "-1"

* refactor: update text styling in OptionHover component for improved readability

* chore: update @librechat/agents dependency to v2.4.46 in package.json and package-lock.json

* chore: update @librechat/api version to 1.2.5 in package.json and package-lock.json

* refactor: enhance `clientOptions` handling by filtering `omitTitleOptions`, add `json` field for Google models

---------

Co-authored-by: ciffelia <15273128+ciffelia@users.noreply.github.com>
2025-06-25 15:14:33 -04:00
Dustin Healy
b169306096 🧪 ci: Add Tests for Custom Endpoint Header Resolution (#8045)
* Enhanced existing tests for the `resolveHeaders` function to cover all user field placeholders and messy scenarios.
* Added basic integration tests for custom endpoints initialization file
2025-06-24 21:11:06 -04:00
340 changed files with 3951 additions and 23324 deletions

View File

@@ -142,10 +142,10 @@ GOOGLE_KEY=user_provided
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash,gemini-2.0-flash-lite
# Vertex AI
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
@@ -453,8 +453,8 @@ OPENID_REUSE_TOKENS=
OPENID_JWKS_URL_CACHE_ENABLED=
OPENID_JWKS_URL_CACHE_TIME= # 600000 ms eq to 10 minutes leave empty to disable caching
#Set to true to trigger token exchange flow to acquire access token for the userinfo endpoint.
OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE = "user.read" # example for Scope Needed for Microsoft Graph API
OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED=
OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE="user.read" # example for Scope Needed for Microsoft Graph API
# Set to true to use the OpenID Connect end session endpoint for logout
OPENID_USE_END_SESSION_ENDPOINT=
@@ -657,4 +657,4 @@ OPENWEATHER_API_KEY=
# Reranker (Required)
# JINA_API_KEY=your_jina_api_key
# or
# COHERE_API_KEY=your_cohere_api_key
# COHERE_API_KEY=your_cohere_api_key

View File

@@ -1,7 +1,7 @@
const { google } = require('googleapis');
const { Tokenizer } = require('@librechat/api');
const { concat } = require('@langchain/core/utils/stream');
const { ChatVertexAI } = require('@langchain/google-vertexai');
const { Tokenizer, getSafetySettings } = require('@librechat/api');
const { ChatGoogleGenerativeAI } = require('@langchain/google-genai');
const { GoogleGenerativeAI: GenAI } = require('@google/generative-ai');
const { HumanMessage, SystemMessage } = require('@langchain/core/messages');
@@ -12,13 +12,13 @@ const {
endpointSettings,
parseTextParts,
EModelEndpoint,
googleSettings,
ContentTypes,
VisionModes,
ErrorTypes,
Constants,
AuthKeys,
} = require('librechat-data-provider');
const { getSafetySettings } = require('~/server/services/Endpoints/google/llm');
const { encodeAndFormat } = require('~/server/services/Files/images');
const { spendTokens } = require('~/models/spendTokens');
const { getModelMaxTokens } = require('~/utils');
@@ -166,6 +166,16 @@ class GoogleClient extends BaseClient {
);
}
// Add thinking configuration
this.modelOptions.thinkingConfig = {
thinkingBudget:
(this.modelOptions.thinking ?? googleSettings.thinking.default)
? this.modelOptions.thinkingBudget
: 0,
};
delete this.modelOptions.thinking;
delete this.modelOptions.thinkingBudget;
this.sender =
this.options.sender ??
getResponseSender({

View File

@@ -1,6 +1,7 @@
const axios = require('axios');
const { isEnabled } = require('~/server/utils');
const { logger } = require('~/config');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const footer = `Use the context as your learned knowledge to better answer the user.
@@ -18,7 +19,7 @@ function createContextHandlers(req, userMessageContent) {
const queryPromises = [];
const processedFiles = [];
const processedIds = new Set();
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
const useFullContext = isEnabled(process.env.RAG_USE_FULL_CONTEXT);
const query = async (file) => {

View File

@@ -1,9 +1,10 @@
const { z } = require('zod');
const axios = require('axios');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { Tools, EToolResources } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getFiles } = require('~/models/File');
const { logger } = require('~/config');
/**
*
@@ -59,7 +60,7 @@ const createFileSearchTool = async ({ req, files, entity_id }) => {
if (files.length === 0) {
return 'No files to search. Instruct the user to add files for the search.';
}
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
if (!jwtToken) {
return 'There was an error authenticating the file search request.';
}

View File

@@ -1,7 +1,8 @@
const { logger } = require('@librechat/data-schemas');
const { isEnabled, math } = require('@librechat/api');
const { ViolationTypes } = require('librechat-data-provider');
const { isEnabled, math, removePorts } = require('~/server/utils');
const { deleteAllUserSessions } = require('~/models');
const { removePorts } = require('~/server/utils');
const getLogStores = require('./getLogStores');
const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {};

View File

@@ -1,7 +1,7 @@
const { Keyv } = require('keyv');
const { isEnabled, math } = require('@librechat/api');
const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider');
const { logFile, violationFile } = require('./keyvFiles');
const { isEnabled, math } = require('~/server/utils');
const keyvRedis = require('./keyvRedis');
const keyvMongo = require('./keyvMongo');

View File

@@ -1,4 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const { getMessages, deleteMessages } = require('./Message');
const { Conversation } = require('~/db/models');
@@ -98,10 +100,15 @@ module.exports = {
update.conversationId = newConversationId;
}
if (req.body.isTemporary) {
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() + 30);
update.expiredAt = expiredAt;
if (req?.body?.isTemporary) {
try {
const customConfig = await getCustomConfig();
update.expiredAt = createTempChatExpirationDate(customConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveConvo\` context: ${metadata?.context}`);
update.expiredAt = null;
}
} else {
update.expiredAt = null;
}

View File

@@ -1,5 +1,7 @@
const { z } = require('zod');
const { logger } = require('@librechat/data-schemas');
const { createTempChatExpirationDate } = require('@librechat/api');
const getCustomConfig = require('~/server/services/Config/loadCustomConfig');
const { Message } = require('~/db/models');
const idSchema = z.string().uuid();
@@ -54,9 +56,14 @@ async function saveMessage(req, params, metadata) {
};
if (req?.body?.isTemporary) {
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() + 30);
update.expiredAt = expiredAt;
try {
const customConfig = await getCustomConfig();
update.expiredAt = createTempChatExpirationDate(customConfig);
} catch (err) {
logger.error('Error creating temporary chat expiration date:', err);
logger.info(`---\`saveMessage\` context: ${metadata?.context}`);
update.expiredAt = null;
}
} else {
update.expiredAt = null;
}

View File

@@ -48,7 +48,7 @@
"@langchain/google-genai": "^0.2.13",
"@langchain/google-vertexai": "^0.2.13",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.42",
"@librechat/agents": "^2.4.46",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@node-saml/passport-saml": "^5.0.0",

View File

@@ -1,17 +1,17 @@
const cookies = require('cookie');
const jwt = require('jsonwebtoken');
const openIdClient = require('openid-client');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
registerUser,
resetPassword,
setAuthTokens,
requestPasswordReset,
setOpenIDAuthTokens,
resetPassword,
setAuthTokens,
registerUser,
} = require('~/server/services/AuthService');
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
const { getOpenIdConfig } = require('~/strategies');
const { isEnabled } = require('~/server/utils');
const registrationController = async (req, res) => {
try {

View File

@@ -1,3 +1,5 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { getResponseSender } = require('librechat-data-provider');
const {
handleAbortError,
@@ -10,9 +12,8 @@ const {
clientRegistry,
requestDataMap,
} = require('~/server/cleanup');
const { sendMessage, createOnProgress } = require('~/server/utils');
const { createOnProgress } = require('~/server/utils');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
const EditController = async (req, res, next, initializeClient) => {
let {
@@ -198,7 +199,7 @@ const EditController = async (req, res, next, initializeClient) => {
const finalUserMessage = reqDataContext.userMessage;
const finalResponseMessage = { ...response };
sendMessage(res, {
sendEvent(res, {
final: true,
conversation,
title: conversation.title,

View File

@@ -0,0 +1,195 @@
const { duplicateAgent } = require('../v1');
const { getAgent, createAgent } = require('~/models/Agent');
const { getActions } = require('~/models/Action');
const { nanoid } = require('nanoid');
jest.mock('~/models/Agent');
jest.mock('~/models/Action');
jest.mock('nanoid');
describe('duplicateAgent', () => {
let req, res;
beforeEach(() => {
req = {
params: { id: 'agent_123' },
user: { id: 'user_456' },
};
res = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
};
jest.clearAllMocks();
});
it('should duplicate an agent successfully', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_789',
versions: [{ name: 'Test Agent', version: 1 }],
__v: 0,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
author: 'user_456',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(getAgent).toHaveBeenCalledWith({ id: 'agent_123' });
expect(getActions).toHaveBeenCalledWith({ agent_id: 'agent_123' }, true);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent_new_123',
author: 'user_456',
name: expect.stringContaining('Test Agent ('),
description: 'Test Description',
instructions: 'Test Instructions',
provider: 'openai',
model: 'gpt-4',
tools: ['file_search'],
actions: [],
}),
);
expect(createAgent).toHaveBeenCalledWith(
expect.not.objectContaining({
versions: expect.anything(),
__v: expect.anything(),
}),
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith({
agent: mockNewAgent,
actions: [],
});
});
it('should ensure duplicated agent has clean versions array without nested fields', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
description: 'Test Description',
versions: [
{
name: 'Test Agent',
versions: [{ name: 'Nested' }],
__v: 1,
},
],
__v: 2,
};
const mockNewAgent = {
id: 'agent_new_123',
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
versions: [
{
name: 'Test Agent (1/2/23, 12:34)',
description: 'Test Description',
createdAt: new Date(),
updatedAt: new Date(),
},
],
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue(mockNewAgent);
await duplicateAgent(req, res);
expect(mockNewAgent.versions).toHaveLength(1);
const firstVersion = mockNewAgent.versions[0];
expect(firstVersion).not.toHaveProperty('versions');
expect(firstVersion).not.toHaveProperty('__v');
expect(mockNewAgent).not.toHaveProperty('__v');
expect(res.status).toHaveBeenCalledWith(201);
});
it('should return 404 if agent not found', async () => {
getAgent.mockResolvedValue(null);
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({
error: 'Agent not found',
status: 'error',
});
});
it('should handle tool_resources.ocr correctly', async () => {
const mockAgent = {
id: 'agent_123',
name: 'Test Agent',
tool_resources: {
ocr: { enabled: true, config: 'test' },
other: { should: 'not be copied' },
},
};
getAgent.mockResolvedValue(mockAgent);
getActions.mockResolvedValue([]);
nanoid.mockReturnValue('new_123');
createAgent.mockResolvedValue({ id: 'agent_new_123' });
await duplicateAgent(req, res);
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
tool_resources: {
ocr: { enabled: true, config: 'test' },
},
}),
);
});
it('should handle errors gracefully', async () => {
getAgent.mockRejectedValue(new Error('Database error'));
await duplicateAgent(req, res);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({ error: 'Database error' });
});
});

View File

@@ -4,6 +4,7 @@ const {
sendEvent,
createRun,
Tokenizer,
checkAccess,
memoryInstructions,
createMemoryProcessor,
} = require('@librechat/api');
@@ -39,11 +40,22 @@ 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');
const omitTitleOptions = new Set([
'stream',
'thinking',
'streaming',
'clientOptions',
'thinkingConfig',
'thinkingBudget',
'includeThoughts',
'maxOutputTokens',
]);
/**
* @param {ServerRequest} req
* @param {Agent} agent
@@ -390,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(
@@ -1038,6 +1055,16 @@ class AgentClient extends BaseClient {
delete clientOptions.maxTokens;
}
clientOptions = Object.assign(
Object.fromEntries(
Object.entries(clientOptions).filter(([key]) => !omitTitleOptions.has(key)),
),
);
if (provider === Providers.GOOGLE) {
clientOptions.json = true;
}
try {
const titleResult = await this.run.generateTitle({
provider,

View File

@@ -1,10 +1,10 @@
// errorHandler.js
const { logger } = require('~/config');
const getLogStores = require('~/cache/getLogStores');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
const { sendResponse } = require('~/server/middleware/error');
const { recordUsage } = require('~/server/services/Threads');
const { getConvo } = require('~/models/Conversation');
const { sendResponse } = require('~/server/utils');
const getLogStores = require('~/cache/getLogStores');
/**
* @typedef {Object} ErrorHandlerContext
@@ -75,7 +75,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants'
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);

View File

@@ -1,3 +1,5 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider');
const {
handleAbortError,
@@ -5,9 +7,7 @@ const {
cleanupAbortController,
} = require('~/server/middleware');
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const { sendMessage } = require('~/server/utils');
const { saveMessage } = require('~/models');
const { logger } = require('~/config');
const AgentController = async (req, res, next, initializeClient, addTitle) => {
let {
@@ -206,7 +206,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
// Create a new response object with minimal copies
const finalResponse = { ...response };
sendMessage(res, {
sendEvent(res, {
final: true,
conversation,
title: conversation.title,

View File

@@ -242,6 +242,8 @@ const duplicateAgentHandler = async (req, res) => {
createdAt: _createdAt,
updatedAt: _updatedAt,
tool_resources: _tool_resources = {},
versions: _versions,
__v: _v,
...cloneData
} = agent;
cloneData.name = `${agent.name} (${new Date().toLocaleString('en-US', {

View File

@@ -1,4 +1,7 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Time,
Constants,
@@ -19,20 +22,20 @@ const {
addThreadMetadata,
saveAssistantMessage,
} = require('~/server/services/Threads');
const { sendResponse, sendMessage, sleep, countTokens } = require('~/server/utils');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { createRunBody } = require('~/server/services/createRunBody');
const { sendResponse } = require('~/server/middleware/error');
const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* @route POST /
@@ -471,7 +474,7 @@ const chatV1 = async (req, res) => {
await Promise.all(promises);
const sendInitialResponse = () => {
sendMessage(res, {
sendEvent(res, {
sync: true,
conversationId,
// messages: previousMessages,
@@ -587,7 +590,7 @@ const chatV1 = async (req, res) => {
iconURL: endpointOption.iconURL,
};
sendMessage(res, {
sendEvent(res, {
final: true,
conversation,
requestMessage: {

View File

@@ -1,4 +1,7 @@
const { v4 } = require('uuid');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Time,
Constants,
@@ -22,15 +25,14 @@ const { createErrorHandler } = require('~/server/controllers/assistants/errors')
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { sendMessage, sleep, countTokens } = require('~/server/utils');
const { createRunBody } = require('~/server/services/createRunBody');
const { getTransactions } = require('~/models/Transaction');
const { checkBalance } = require('~/models/balanceMethods');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { countTokens } = require('~/server/utils');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* @route POST /
@@ -309,7 +311,7 @@ const chatV2 = async (req, res) => {
await Promise.all(promises);
const sendInitialResponse = () => {
sendMessage(res, {
sendEvent(res, {
sync: true,
conversationId,
// messages: previousMessages,
@@ -432,7 +434,7 @@ const chatV2 = async (req, res) => {
iconURL: endpointOption.iconURL,
};
sendMessage(res, {
sendEvent(res, {
final: true,
conversation,
requestMessage: {

View File

@@ -1,10 +1,10 @@
// errorHandler.js
const { sendResponse } = require('~/server/utils');
const { logger } = require('~/config');
const getLogStores = require('~/cache/getLogStores');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
const { getConvo } = require('~/models/Conversation');
const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
const { sendResponse } = require('~/server/middleware/error');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
/**
* @typedef {Object} ErrorHandlerContext
@@ -78,7 +78,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === 'azureAssistants'
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);

View File

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

View File

@@ -1,13 +1,13 @@
// abortMiddleware.js
const { logger } = require('@librechat/data-schemas');
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq');
const { sendError } = require('~/server/middleware/error');
const { spendTokens } = require('~/models/spendTokens');
const abortControllers = require('./abortControllers');
const { saveMessage, getConvo } = require('~/models');
const { abortRun } = require('./abortRun');
const { logger } = require('~/config');
const abortDataMap = new WeakMap();
@@ -101,7 +101,7 @@ async function abortMessage(req, res) {
cleanupAbortController(abortKey);
if (res.headersSent && finalEvent) {
return sendMessage(res, finalEvent);
return sendEvent(res, finalEvent);
}
res.setHeader('Content-Type', 'application/json');
@@ -174,7 +174,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
* @param {string} responseMessageId
*/
const onStart = (userMessage, responseMessageId) => {
sendMessage(res, { message: userMessage, created: true });
sendEvent(res, { message: userMessage, created: true });
const abortKey = userMessage?.conversationId ?? req.user.id;
getReqData({ abortKey });

View File

@@ -1,11 +1,11 @@
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, RunStatus, isUUID } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { checkMessageGaps, recordUsage } = require('~/server/services/Threads');
const { deleteMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { sendMessage } = require('~/server/utils');
const { logger } = require('~/config');
const three_minutes = 1000 * 60 * 3;
@@ -34,7 +34,7 @@ async function abortRun(req, res) {
const [thread_id, run_id] = runValues.split(':');
if (!run_id) {
logger.warn('[abortRun] Couldn\'t find run for cancel request', { thread_id });
logger.warn("[abortRun] Couldn't find run for cancel request", { thread_id });
return res.status(204).send({ message: 'Run not found' });
} else if (run_id === 'cancelled') {
logger.warn('[abortRun] Run already cancelled', { thread_id });
@@ -93,7 +93,7 @@ async function abortRun(req, res) {
};
if (res.headersSent && finalEvent) {
return sendMessage(res, finalEvent);
return sendEvent(res, finalEvent);
}
res.json(finalEvent);

View File

@@ -1,6 +1,7 @@
const crypto = require('crypto');
const { sendEvent } = require('@librechat/api');
const { getResponseSender, Constants } = require('librechat-data-provider');
const { sendMessage, sendError } = require('~/server/utils');
const { sendError } = require('~/server/middleware/error');
const { saveMessage } = require('~/models');
/**
@@ -36,7 +37,7 @@ const denyRequest = async (req, res, errorMessage) => {
isCreatedByUser: true,
text,
};
sendMessage(res, { message: userMessage, created: true });
sendEvent(res, { message: userMessage, created: true });
const shouldSaveMessage = _convoId && parentMessageId && parentMessageId !== Constants.NO_PARENT;

View File

@@ -1,31 +1,9 @@
const crypto = require('crypto');
const { logger } = require('@librechat/data-schemas');
const { parseConvo } = require('librechat-data-provider');
const { sendEvent, handleError } = require('@librechat/api');
const { saveMessage, getMessages } = require('~/models/Message');
const { getConvo } = require('~/models/Conversation');
const { logger } = require('~/config');
/**
* Sends error data in Server Sent Events format and ends the response.
* @param {object} res - The server response.
* @param {string} message - The error message.
*/
const handleError = (res, message) => {
res.write(`event: error\ndata: ${JSON.stringify(message)}\n\n`);
res.end();
};
/**
* Sends message data in Server Sent Events format.
* @param {Express.Response} res - - The server response.
* @param {string | Object} message - The message to be sent.
* @param {'message' | 'error' | 'cancel'} event - [Optional] The type of event. Default is 'message'.
*/
const sendMessage = (res, message, event = 'message') => {
if (typeof message === 'string' && message.length === 0) {
return;
}
res.write(`event: ${event}\ndata: ${JSON.stringify(message)}\n\n`);
};
/**
* Processes an error with provided options, saves the error message and sends a corresponding SSE response
@@ -91,7 +69,7 @@ const sendError = async (req, res, options, callback) => {
convo = parseConvo(errorMessage);
}
return sendMessage(res, {
return sendEvent(res, {
final: true,
requestMessage: query?.[0] ? query[0] : requestMessage,
responseMessage: errorMessage,
@@ -120,12 +98,10 @@ const sendResponse = (req, res, data, errorMessage) => {
if (errorMessage) {
return sendError(req, res, { ...data, text: errorMessage });
}
return sendMessage(res, data);
return sendEvent(res, data);
};
module.exports = {
sendResponse,
handleError,
sendMessage,
sendError,
sendResponse,
};

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 { checkAccess, generateCheckAccess } = require('./access');
module.exports = {
checkAdmin,
checkAccess,
generateCheckAccess,
};

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,13 @@ const { CacheKeys } = require('librechat-data-provider');
const { requireJwtAuth } = require('~/server/middleware');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const {
getMCPServers,
getMCPServer,
createMCPServer,
updateMCPServer,
deleteMCPServer,
} = require('@librechat/api');
const router = Router();
@@ -202,4 +209,44 @@ router.get('/oauth/status/:flowId', async (req, res) => {
}
});
/**
* Get all MCP servers for the authenticated user
* @route GET /api/mcp
* @returns {Array} Array of MCP servers
*/
router.get('/', requireJwtAuth, getMCPServers);
/**
* Get a single MCP server by ID
* @route GET /api/mcp/:mcp_id
* @param {string} mcp_id - The ID of the MCP server to fetch
* @returns {object} MCP server data
*/
router.get('/:mcp_id', requireJwtAuth, getMCPServer);
/**
* Create a new MCP server
* @route POST /api/mcp/add
* @param {object} req.body - MCP server data
* @returns {object} Created MCP server with populated tools
*/
router.post('/add', requireJwtAuth, createMCPServer);
/**
* Update an existing MCP server
* @route PUT /api/mcp/:mcp_id
* @param {string} mcp_id - The ID of the MCP server to update
* @param {object} req.body - Updated MCP server data
* @returns {object} Updated MCP server with populated tools
*/
router.put('/:mcp_id', requireJwtAuth, updateMCPServer);
/**
* Delete an MCP server
* @route DELETE /api/mcp/:mcp_id
* @param {string} mcp_id - The ID of the MCP server to delete
* @returns {object} Deletion confirmation
*/
router.delete('/:mcp_id', requireJwtAuth, deleteMCPServer);
module.exports = router;

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,7 @@
const { klona } = require('klona');
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
StepTypes,
RunStatus,
@@ -11,11 +14,10 @@ const {
} = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { processRequiredActions } = require('~/server/services/ToolService');
const { createOnProgress, sendMessage, sleep } = require('~/server/utils');
const { RunManager, waitForRun } = require('~/server/services/Runs');
const { processMessages } = require('~/server/services/Threads');
const { createOnProgress } = require('~/server/utils');
const { TextStream } = require('~/app/clients');
const { logger } = require('~/config');
/**
* Sorts, processes, and flattens messages to a single string.
@@ -64,7 +66,7 @@ async function createOnTextProgress({
};
logger.debug('Content data:', contentData);
sendMessage(openai.res, contentData);
sendEvent(openai.res, contentData);
};
}

View File

@@ -1,4 +1,5 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { webcrypto } = require('node:crypto');
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
@@ -499,6 +500,18 @@ const resendVerificationEmail = async (req) => {
};
}
};
/**
* Generate a short-lived JWT token
* @param {String} userId - The ID of the user
* @param {String} [expireIn='5m'] - The expiration time for the token (default is 5 minutes)
* @returns {String} - The generated JWT token
*/
const generateShortLivedToken = (userId, expireIn = '5m') => {
return jwt.sign({ id: userId }, process.env.JWT_SECRET, {
expiresIn: expireIn,
algorithm: 'HS256',
});
};
module.exports = {
logoutUser,
@@ -506,7 +519,8 @@ module.exports = {
registerUser,
setAuthTokens,
resetPassword,
setOpenIDAuthTokens,
requestPasswordReset,
resendVerificationEmail,
setOpenIDAuthTokens,
generateShortLivedToken,
};

View File

@@ -1,5 +1,6 @@
const { isUserProvided } = require('@librechat/api');
const { EModelEndpoint } = require('librechat-data-provider');
const { isUserProvided, generateConfig } = require('~/server/utils');
const { generateConfig } = require('~/server/utils/handleText');
const {
OPENAI_API_KEY: openAIApiKey,

View File

@@ -1,3 +1,5 @@
const fs = require('fs');
const path = require('path');
const { EModelEndpoint } = require('librechat-data-provider');
const { isUserProvided } = require('~/server/utils');
const { config } = require('./EndpointService');
@@ -11,9 +13,21 @@ const { openAIApiKey, azureOpenAIApiKey, useAzurePlugins, userProvidedOpenAI, go
async function loadAsyncEndpoints(req) {
let i = 0;
let serviceKey, googleUserProvides;
const serviceKeyPath =
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
path.join(__dirname, '../../..', 'data', 'auth.json');
try {
serviceKey = require('~/data/auth.json');
} catch (e) {
if (process.env.GOOGLE_SERVICE_KEY_FILE_PATH) {
const absolutePath = path.isAbsolute(serviceKeyPath)
? serviceKeyPath
: path.resolve(serviceKeyPath);
const fileContent = fs.readFileSync(absolutePath, 'utf8');
serviceKey = JSON.parse(fileContent);
} else {
serviceKey = require('~/data/auth.json');
}
} catch {
if (i === 0) {
i++;
}
@@ -32,14 +46,14 @@ async function loadAsyncEndpoints(req) {
const gptPlugins =
useAzure || openAIApiKey || azureOpenAIApiKey
? {
availableAgents: ['classic', 'functions'],
userProvide: useAzure ? false : userProvidedOpenAI,
userProvideURL: useAzure
? false
: config[EModelEndpoint.openAI]?.userProvideURL ||
availableAgents: ['classic', 'functions'],
userProvide: useAzure ? false : userProvidedOpenAI,
userProvideURL: useAzure
? false
: config[EModelEndpoint.openAI]?.userProvideURL ||
config[EModelEndpoint.azureOpenAI]?.userProvideURL,
azure: useAzurePlugins || useAzure,
}
azure: useAzurePlugins || useAzure,
}
: false;
return { google, gptPlugins };

View File

@@ -1,18 +1,18 @@
const path = require('path');
const {
CacheKeys,
configSchema,
EImageOutputType,
validateSettingDefinitions,
agentParamSettings,
paramSettings,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml');
const { logger } = require('~/config');
const axios = require('axios');
const yaml = require('js-yaml');
const keyBy = require('lodash/keyBy');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
CacheKeys,
configSchema,
paramSettings,
EImageOutputType,
agentParamSettings,
validateSettingDefinitions,
} = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');

View File

@@ -1,6 +1,9 @@
jest.mock('axios');
jest.mock('~/cache/getLogStores');
jest.mock('~/utils/loadYaml');
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
loadYaml: jest.fn(),
}));
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
return {
@@ -30,11 +33,22 @@ jest.mock('librechat-data-provider', () => {
};
});
jest.mock('@librechat/data-schemas', () => {
return {
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
};
});
const axios = require('axios');
const { loadYaml } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const loadCustomConfig = require('./loadCustomConfig');
const getLogStores = require('~/cache/getLogStores');
const loadYaml = require('~/utils/loadYaml');
const { logger } = require('~/config');
describe('loadCustomConfig', () => {
const mockSet = jest.fn();

View File

@@ -0,0 +1,93 @@
const initializeClient = require('./initialize');
jest.mock('@librechat/api', () => ({
resolveHeaders: jest.fn(),
getOpenAIConfig: jest.fn(),
createHandleLLMNewToken: jest.fn(),
}));
jest.mock('librechat-data-provider', () => ({
CacheKeys: { TOKEN_CONFIG: 'token_config' },
ErrorTypes: { NO_USER_KEY: 'NO_USER_KEY', NO_BASE_URL: 'NO_BASE_URL' },
envVarRegex: /\$\{([^}]+)\}/,
FetchTokenConfig: {},
extractEnvVariable: jest.fn((value) => value),
}));
jest.mock('@librechat/agents', () => ({
Providers: { OLLAMA: 'ollama' },
}));
jest.mock('~/server/services/UserService', () => ({
getUserKeyValues: jest.fn(),
checkUserKeyExpiry: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
getCustomEndpointConfig: jest.fn().mockResolvedValue({
apiKey: 'test-key',
baseURL: 'https://test.com',
headers: { 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
models: { default: ['test-model'] },
}),
}));
jest.mock('~/server/services/ModelService', () => ({
fetchModels: jest.fn(),
}));
jest.mock('~/app/clients/OpenAIClient', () => {
return jest.fn().mockImplementation(() => ({
options: {},
}));
});
jest.mock('~/server/utils', () => ({
isUserProvided: jest.fn().mockReturnValue(false),
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn().mockReturnValue({
get: jest.fn(),
}),
);
describe('custom/initializeClient', () => {
const mockRequest = {
body: { endpoint: 'test-endpoint' },
user: { id: 'user-123', email: 'test@example.com' },
app: { locals: {} },
};
const mockResponse = {};
beforeEach(() => {
jest.clearAllMocks();
});
it('calls resolveHeaders with headers and user', async () => {
const { resolveHeaders } = require('@librechat/api');
await initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true });
expect(resolveHeaders).toHaveBeenCalledWith(
{ 'x-user': '{{LIBRECHAT_USER_ID}}', 'x-email': '{{LIBRECHAT_USER_EMAIL}}' },
{ id: 'user-123', email: 'test@example.com' },
);
});
it('throws if endpoint config is missing', async () => {
const { getCustomEndpointConfig } = require('~/server/services/Config');
getCustomEndpointConfig.mockResolvedValueOnce(null);
await expect(
initializeClient({ req: mockRequest, res: mockResponse, optionsOnly: true }),
).rejects.toThrow('Config not found for the test-endpoint custom endpoint.');
});
it('throws if user is missing', async () => {
await expect(
initializeClient({
req: { ...mockRequest, user: undefined },
res: mockResponse,
optionsOnly: true,
}),
).rejects.toThrow("Cannot read properties of undefined (reading 'id')");
});
});

View File

@@ -1,7 +1,8 @@
const fs = require('fs');
const path = require('path');
const { getGoogleConfig, isEnabled } = require('@librechat/api');
const { EModelEndpoint, AuthKeys } = require('librechat-data-provider');
const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService');
const { getLLMConfig } = require('~/server/services/Endpoints/google/llm');
const { isEnabled } = require('~/server/utils');
const { GoogleClient } = require('~/app');
const initializeClient = async ({ req, res, endpointOption, overrideModel, optionsOnly }) => {
@@ -16,8 +17,20 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
}
let serviceKey = {};
try {
serviceKey = require('~/data/auth.json');
if (process.env.GOOGLE_SERVICE_KEY_FILE_PATH) {
const serviceKeyPath =
process.env.GOOGLE_SERVICE_KEY_FILE_PATH ||
path.join(__dirname, '../../../../..', 'data', 'auth.json');
const absolutePath = path.isAbsolute(serviceKeyPath)
? serviceKeyPath
: path.resolve(serviceKeyPath);
const fileContent = fs.readFileSync(absolutePath, 'utf8');
serviceKey = JSON.parse(fileContent);
} else {
serviceKey = require('~/data/auth.json');
}
} catch (_e) {
// Do nothing
}
@@ -65,7 +78,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
if (overrideModel) {
clientOptions.modelOptions.model = overrideModel;
}
return getLLMConfig(credentials, clientOptions);
return getGoogleConfig(credentials, clientOptions);
}
const client = new GoogleClient(credentials, clientOptions);

View File

@@ -1,10 +1,11 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const { logger } = require('@librechat/data-schemas');
const { EModelEndpoint } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getBufferMetadata } = require('~/server/utils');
const paths = require('~/config/paths');
const { logger } = require('~/config');
/**
* Saves a file to a specified output path with a new filename.
@@ -206,7 +207,7 @@ const deleteLocalFile = async (req, file) => {
const cleanFilepath = file.filepath.split('?')[0];
if (file.embedded && process.env.RAG_API_URL) {
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
axios.delete(`${process.env.RAG_API_URL}/documents`, {
headers: {
Authorization: `Bearer ${jwtToken}`,

View File

@@ -4,6 +4,7 @@ const FormData = require('form-data');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { FileSources } = require('librechat-data-provider');
const { generateShortLivedToken } = require('~/server/services/AuthService');
/**
* Deletes a file from the vector database. This function takes a file object, constructs the full path, and
@@ -23,7 +24,8 @@ const deleteVectors = async (req, file) => {
return;
}
try {
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
return await axios.delete(`${process.env.RAG_API_URL}/documents`, {
headers: {
Authorization: `Bearer ${jwtToken}`,
@@ -70,7 +72,7 @@ async function uploadVectors({ req, file, file_id, entity_id }) {
}
try {
const jwtToken = req.headers.authorization.split(' ')[1];
const jwtToken = generateShortLivedToken(req.user.id);
const formData = new FormData();
formData.append('file_id', file_id);
formData.append('file', fs.createReadStream(file.path));

View File

@@ -55,7 +55,9 @@ const processFiles = async (files, fileIds) => {
}
if (!fileIds) {
return await Promise.all(promises);
const results = await Promise.all(promises);
// Filter out null results from failed updateFileUsage calls
return results.filter((result) => result != null);
}
for (let file_id of fileIds) {
@@ -67,7 +69,9 @@ const processFiles = async (files, fileIds) => {
}
// TODO: calculate token cost when image is first uploaded
return await Promise.all(promises);
const results = await Promise.all(promises);
// Filter out null results from failed updateFileUsage calls
return results.filter((result) => result != null);
};
/**

View File

@@ -0,0 +1,208 @@
// Mock the updateFileUsage function before importing the actual processFiles
jest.mock('~/models/File', () => ({
updateFileUsage: jest.fn(),
}));
// Mock winston and logger configuration to avoid dependency issues
jest.mock('~/config', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
}));
// Mock all other dependencies that might cause issues
jest.mock('librechat-data-provider', () => ({
isUUID: { parse: jest.fn() },
megabyte: 1024 * 1024,
FileContext: { message_attachment: 'message_attachment' },
FileSources: { local: 'local' },
EModelEndpoint: { assistants: 'assistants' },
EToolResources: { file_search: 'file_search' },
mergeFileConfig: jest.fn(),
removeNullishValues: jest.fn((obj) => obj),
isAssistantsEndpoint: jest.fn(),
}));
jest.mock('~/server/services/Files/images', () => ({
convertImage: jest.fn(),
resizeAndConvert: jest.fn(),
resizeImageBuffer: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/v2', () => ({
addResourceFileId: jest.fn(),
deleteResourceFileId: jest.fn(),
}));
jest.mock('~/models/Agent', () => ({
addAgentResourceFile: jest.fn(),
removeAgentResourceFiles: jest.fn(),
}));
jest.mock('~/server/controllers/assistants/helpers', () => ({
getOpenAIClient: jest.fn(),
}));
jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
checkCapability: jest.fn(),
}));
jest.mock('~/server/utils/queue', () => ({
LB_QueueAsyncCall: jest.fn(),
}));
jest.mock('./strategies', () => ({
getStrategyFunctions: jest.fn(),
}));
jest.mock('~/server/utils', () => ({
determineFileType: jest.fn(),
}));
// Import the actual processFiles function after all mocks are set up
const { processFiles } = require('./process');
const { updateFileUsage } = require('~/models/File');
describe('processFiles', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('null filtering functionality', () => {
it('should filter out null results from updateFileUsage when files do not exist', async () => {
const mockFiles = [
{ file_id: 'existing-file-1' },
{ file_id: 'non-existent-file' },
{ file_id: 'existing-file-2' },
];
// Mock updateFileUsage to return null for non-existent files
updateFileUsage.mockImplementation(({ file_id }) => {
if (file_id === 'non-existent-file') {
return Promise.resolve(null); // Simulate file not found in the database
}
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
expect(updateFileUsage).toHaveBeenCalledTimes(3);
expect(result).toEqual([
{ file_id: 'existing-file-1', usage: 1 },
{ file_id: 'existing-file-2', usage: 1 },
]);
// Critical test - ensure no null values in result
expect(result).not.toContain(null);
expect(result).not.toContain(undefined);
expect(result.length).toBe(2); // Only valid files should be returned
});
it('should return empty array when all updateFileUsage calls return null', async () => {
const mockFiles = [{ file_id: 'non-existent-1' }, { file_id: 'non-existent-2' }];
// All updateFileUsage calls return null
updateFileUsage.mockResolvedValue(null);
const result = await processFiles(mockFiles);
expect(updateFileUsage).toHaveBeenCalledTimes(2);
expect(result).toEqual([]);
expect(result).not.toContain(null);
expect(result.length).toBe(0);
});
it('should work correctly when all files exist', async () => {
const mockFiles = [{ file_id: 'file-1' }, { file_id: 'file-2' }];
updateFileUsage.mockImplementation(({ file_id }) => {
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
expect(result).toEqual([
{ file_id: 'file-1', usage: 1 },
{ file_id: 'file-2', usage: 1 },
]);
expect(result).not.toContain(null);
expect(result.length).toBe(2);
});
it('should handle fileIds parameter and filter nulls correctly', async () => {
const mockFiles = [{ file_id: 'file-1' }];
const mockFileIds = ['file-2', 'non-existent-file'];
updateFileUsage.mockImplementation(({ file_id }) => {
if (file_id === 'non-existent-file') {
return Promise.resolve(null);
}
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles, mockFileIds);
expect(result).toEqual([
{ file_id: 'file-1', usage: 1 },
{ file_id: 'file-2', usage: 1 },
]);
expect(result).not.toContain(null);
expect(result).not.toContain(undefined);
expect(result.length).toBe(2);
});
it('should handle duplicate file_ids correctly', async () => {
const mockFiles = [
{ file_id: 'duplicate-file' },
{ file_id: 'duplicate-file' }, // Duplicate should be ignored
{ file_id: 'unique-file' },
];
updateFileUsage.mockImplementation(({ file_id }) => {
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
// Should only call updateFileUsage twice (duplicate ignored)
expect(updateFileUsage).toHaveBeenCalledTimes(2);
expect(result).toEqual([
{ file_id: 'duplicate-file', usage: 1 },
{ file_id: 'unique-file', usage: 1 },
]);
expect(result.length).toBe(2);
});
});
describe('edge cases', () => {
it('should handle empty files array', async () => {
const result = await processFiles([]);
expect(result).toEqual([]);
expect(updateFileUsage).not.toHaveBeenCalled();
});
it('should handle mixed null and undefined returns from updateFileUsage', async () => {
const mockFiles = [{ file_id: 'file-1' }, { file_id: 'file-2' }, { file_id: 'file-3' }];
updateFileUsage.mockImplementation(({ file_id }) => {
if (file_id === 'file-1') return Promise.resolve(null);
if (file_id === 'file-2') return Promise.resolve(undefined);
return Promise.resolve({ file_id, usage: 1 });
});
const result = await processFiles(mockFiles);
expect(result).toEqual([{ file_id: 'file-3', usage: 1 }]);
expect(result).not.toContain(null);
expect(result).not.toContain(undefined);
expect(result.length).toBe(1);
});
});
});

View File

@@ -1,5 +1,9 @@
const { FileSources } = require('librechat-data-provider');
const { uploadMistralOCR, uploadAzureMistralOCR } = require('@librechat/api');
const {
uploadMistralOCR,
uploadAzureMistralOCR,
uploadGoogleVertexMistralOCR,
} = require('@librechat/api');
const {
getFirebaseURL,
prepareImageURL,
@@ -222,6 +226,26 @@ const azureMistralOCRStrategy = () => ({
handleFileUpload: uploadAzureMistralOCR,
});
const vertexMistralOCRStrategy = () => ({
/** @type {typeof saveFileFromURL | null} */
saveURL: null,
/** @type {typeof getLocalFileURL | null} */
getFileURL: null,
/** @type {typeof saveLocalBuffer | null} */
saveBuffer: null,
/** @type {typeof processLocalAvatar | null} */
processAvatar: null,
/** @type {typeof uploadLocalImage | null} */
handleImageUpload: null,
/** @type {typeof prepareImagesLocal | null} */
prepareImagePayload: null,
/** @type {typeof deleteLocalFile | null} */
deleteFile: null,
/** @type {typeof getLocalFileStream | null} */
getDownloadStream: null,
handleFileUpload: uploadGoogleVertexMistralOCR,
});
// Strategy Selector
const getStrategyFunctions = (fileSource) => {
if (fileSource === FileSources.firebase) {
@@ -244,6 +268,8 @@ const getStrategyFunctions = (fileSource) => {
return mistralOCRStrategy();
} else if (fileSource === FileSources.azure_mistral_ocr) {
return azureMistralOCRStrategy();
} else if (fileSource === FileSources.vertexai_mistral_ocr) {
return vertexMistralOCRStrategy();
} else {
throw new Error('Invalid file source');
}

View File

@@ -1,3 +1,6 @@
const { sleep } = require('@librechat/agents');
const { sendEvent } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const {
Constants,
StepTypes,
@@ -8,9 +11,8 @@ const {
} = require('librechat-data-provider');
const { retrieveAndProcessFile } = require('~/server/services/Files/process');
const { processRequiredActions } = require('~/server/services/ToolService');
const { createOnProgress, sendMessage, sleep } = require('~/server/utils');
const { processMessages } = require('~/server/services/Threads');
const { logger } = require('~/config');
const { createOnProgress } = require('~/server/utils');
/**
* Implements the StreamRunManager functionality for managing the streaming
@@ -126,7 +128,7 @@ class StreamRunManager {
conversationId: this.finalMessage.conversationId,
};
sendMessage(this.res, contentData);
sendEvent(this.res, contentData);
}
/* <------------------ Misc. Helpers ------------------> */
@@ -302,7 +304,7 @@ class StreamRunManager {
for (const d of delta[key]) {
if (typeof d === 'object' && !Object.prototype.hasOwnProperty.call(d, 'index')) {
logger.warn('Expected an object with an \'index\' for array updates but got:', d);
logger.warn("Expected an object with an 'index' for array updates but got:", d);
continue;
}

View File

@@ -7,9 +7,9 @@ const {
defaultAssistantsVersion,
defaultAgentCapabilities,
} = require('librechat-data-provider');
const { sendEvent } = require('@librechat/api');
const { Providers } = require('@librechat/agents');
const partialRight = require('lodash/partialRight');
const { sendMessage } = require('./streamResponse');
/** Helper function to escape special characters in regex
* @param {string} string - The string to escape.
@@ -37,7 +37,7 @@ const createOnProgress = (
basePayload.text = basePayload.text + chunk;
const payload = Object.assign({}, basePayload, rest);
sendMessage(res, payload);
sendEvent(res, payload);
if (_onProgress) {
_onProgress(payload);
}
@@ -50,7 +50,7 @@ const createOnProgress = (
const sendIntermediateMessage = (res, payload, extraTokens = '') => {
basePayload.text = basePayload.text + extraTokens;
const message = Object.assign({}, basePayload, payload);
sendMessage(res, message);
sendEvent(res, message);
if (i === 0) {
basePayload.initial = false;
}

View File

@@ -1,11 +1,9 @@
const streamResponse = require('./streamResponse');
const removePorts = require('./removePorts');
const countTokens = require('./countTokens');
const handleText = require('./handleText');
const sendEmail = require('./sendEmail');
const queue = require('./queue');
const files = require('./files');
const math = require('./math');
/**
* Check if email configuration is set
@@ -28,7 +26,6 @@ function checkEmailConfig() {
}
module.exports = {
...streamResponse,
checkEmailConfig,
...handleText,
countTokens,
@@ -36,5 +33,4 @@ module.exports = {
sendEmail,
...files,
...queue,
math,
};

View File

@@ -118,7 +118,7 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
*/
const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFRO_REQUIRED);
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED);
if (onBehalfFlowRequired) {
if (fromCache) {
const cachedToken = await tokensCache.get(sub);
@@ -130,7 +130,7 @@ const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache =
config,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFRO_SCOPE || 'user.read',
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE || 'user.read',
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},

View File

@@ -1,11 +1,9 @@
const loadYaml = require('./loadYaml');
const tokenHelpers = require('./tokens');
const deriveBaseURL = require('./deriveBaseURL');
const extractBaseURL = require('./extractBaseURL');
const findMessageContent = require('./findMessageContent');
module.exports = {
loadYaml,
deriveBaseURL,
extractBaseURL,
...tokenHelpers,

View File

@@ -1,13 +0,0 @@
const fs = require('fs');
const yaml = require('js-yaml');
function loadYaml(filepath) {
try {
let fileContents = fs.readFileSync(filepath, 'utf8');
return yaml.load(fileContents);
} catch (e) {
return e;
}
}
module.exports = loadYaml;

BIN
bun.lockb

Binary file not shown.

View File

@@ -109,9 +109,6 @@
"tailwindcss-radix": "^2.8.0",
"zod": "^3.22.4"
},
"peerDependencies": {
"jotai": "^2.12.5"
},
"devDependencies": {
"@babel/plugin-transform-runtime": "^7.22.15",
"@babel/preset-env": "^7.22.15",

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 || '',
})) || [];
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,

View File

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

View File

@@ -1,26 +1,13 @@
import {
AuthorizationTypeEnum,
AuthTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
import { MCPForm } from '~/common/types';
export const defaultMCPFormValues: MCPForm = {
type: AuthTypeEnum.None,
saved_auth_fields: false,
api_key: '',
authorization_type: AuthorizationTypeEnum.Basic,
custom_auth_header: '',
oauth_client_id: '',
oauth_client_secret: '',
authorization_url: '',
client_url: '',
scope: '',
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
name: '',
description: '',
url: '',
tools: [],
icon: '',
trust: false,
customHeaders: [],
requestTimeout: undefined,
connectionTimeout: undefined,
};

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type RenderProp<
P = React.HTMLAttributes<any> & {
ref?: React.Ref<any>;

View File

@@ -1,5 +1,5 @@
import { RefObject } from 'react';
import { FileSources, EModelEndpoint } from 'librechat-data-provider';
import { FileSources, EModelEndpoint, TPlugin } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type * as InputNumberPrimitive from 'rc-input-number';
import type { SetterOrUpdater, RecoilState } from 'recoil';
@@ -167,13 +167,27 @@ export type ActionAuthForm = {
token_exchange_method: t.TokenExchangeMethodEnum;
};
export type MCPForm = ActionAuthForm & {
name?: string;
export type MCPForm = MCPMetadata;
export type MCP = {
mcp_id: string;
metadata: MCPMetadata;
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string });
export type MCPMetadata = {
name: string;
description?: string;
url?: string;
tools?: string[];
url: string;
tools?: TPlugin[];
icon?: string;
trust?: boolean;
customHeaders?: Array<{
id: string;
name: string;
value: string;
}>;
requestTimeout?: number;
connectionTimeout?: number;
};
export type ActionWithNullableMetadata = Omit<t.Action, 'metadata'> & {
@@ -219,11 +233,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;
};

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { SettingsIcon } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import MCPConfigDialog, { type ConfigFieldDetail } from './MCPConfigDialog';
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
import { useToastContext, useBadgeRowContext } from '~/Providers';
import MultiSelect from '~/components/ui/MultiSelect';
import { MCPIcon } from '~/components/svg';

View File

@@ -12,6 +12,7 @@ import {
InputNumber,
SelectDropDown,
HoverCardTrigger,
MultiSelectDropDown,
} from '~/components/ui';
import {
removeFocusOutlines,
@@ -24,7 +25,6 @@ import {
} from '~/utils';
import OptionHoverAlt from '~/components/SidePanel/Parameters/OptionHover';
import { useLocalize, useDebouncedInput } from '~/hooks';
import MultiSelectDropDown from '~/components/Input/ModelSelect/MultiSelectDropDown';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
import store from '~/store';
@@ -165,7 +165,7 @@ export default function Settings({
)}
className={cn(
defaultTextProps,
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2',
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ',
)}
/>
</div>

View File

@@ -4,10 +4,15 @@ import { useState, useEffect, useMemo } from 'react';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TPlugin } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import { Button, SelectDropDown, SelectDropDownPop, MultiSelectDropDown } from '~/components/ui';
import {
Button,
MultiSelectPop,
SelectDropDown,
SelectDropDownPop,
MultiSelectDropDown,
} from '~/components/ui';
import { useSetIndexOptions, useAuthContext, useMediaQuery, useLocalize } from '~/hooks';
import { cn, cardStyle, selectPlugins, processPlugins } from '~/utils';
import MultiSelectPop from './MultiSelectPop';
import store from '~/store';
export default function PluginsByIndex({

View File

@@ -1,28 +1,26 @@
import { useCallback, useState, useMemo, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { Link } from 'react-router-dom';
import debounce from 'lodash/debounce';
import { TrashIcon, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTemplate,
OGDialogTrigger,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
TooltipAnchor,
DataTable,
Spinner,
Button,
Label,
Spinner,
} from '~/components';
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { useLocalize, useMediaQuery } from '~/hooks';
import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { formatDate } from '~/utils';
import store from '~/store';
const PAGE_SIZE = 25;
@@ -38,7 +36,6 @@ export default function SharedLinks() {
const localize = useLocalize();
const { showToast } = useToastContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const isSearchEnabled = useRecoilValue(store.search);
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
@@ -311,7 +308,6 @@ export default function SharedLinks() {
onFilterChange={debouncedFilterChange}
filterValue={queryParams.search}
isLoading={isLoading}
enableSearch={isSearchEnabled}
/>
</OGDialogContent>
</OGDialog>

View File

@@ -1,6 +1,5 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilValue } from 'recoil';
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
import {
@@ -12,7 +11,6 @@ import {
Label,
TooltipAnchor,
Spinner,
DataTable,
} from '~/components';
import {
useArchiveConvoMutation,
@@ -21,10 +19,10 @@ import {
} from '~/data-provider';
import { useLocalize, useMediaQuery } from '~/hooks';
import { MinimalIcon } from '~/components/Endpoints';
import DataTable from '~/components/ui/DataTable';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { formatDate } from '~/utils';
import store from '~/store';
const DEFAULT_PARAMS: ConversationListParams = {
isArchived: true,
@@ -41,7 +39,7 @@ export default function ArchivedChatsTable({
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { showToast } = useToastContext();
const isSearchEnabled = useRecoilValue(store.search);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
@@ -274,7 +272,6 @@ export default function ArchivedChatsTable({
isFetchingNextPage={isFetchingNextPage}
isLoading={isLoading}
showCheckboxes={false}
enableSearch={isSearchEnabled}
/>
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>

View File

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

View File

@@ -7,7 +7,6 @@ import VersionPanel from './Version/VersionPanel';
import { useChatContext } from '~/Providers';
import ActionsPanel from './ActionsPanel';
import AgentPanel from './AgentPanel';
import MCPPanel from './MCPPanel';
import { Panel } from '~/common';
export default function AgentPanelSwitch() {
@@ -54,8 +53,5 @@ function AgentPanelSwitchWithContext() {
if (activePanel === Panel.version) {
return <VersionPanel />;
}
if (activePanel === Panel.mcp) {
return <MCPPanel />;
}
return <AgentPanel agentsConfig={agentsConfig} endpointsConfig={endpointsConfig} />;
}

View File

@@ -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') || [];

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useLocalize } from '~/hooks';
import { useToastContext } from '~/Providers';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import MCP from '~/components/SidePanel/Builder/MCP';
import { MCPItem } from '~/components/SidePanel/MCP/MCPItem';
import { Panel } from '~/common';
export default function MCPSection() {
@@ -30,7 +30,7 @@ export default function MCPSection() {
{mcps
.filter((mcp) => mcp.agent_id === agent_id)
.map((mcp, i) => (
<MCP
<MCPItem
key={i}
mcp={mcp}
onClick={() => {

View File

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

View File

@@ -1,55 +0,0 @@
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
import {
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
AuthTypeEnum,
} from 'librechat-data-provider';
export default function MCPAuth() {
// Create a separate form for auth
const authMethods = useForm({
defaultValues: {
/* General */
type: AuthTypeEnum.None,
saved_auth_fields: false,
/* API key */
api_key: '',
authorization_type: AuthorizationTypeEnum.Basic,
custom_auth_header: '',
/* OAuth */
oauth_client_id: '',
oauth_client_secret: '',
authorization_url: '',
client_url: '',
scope: '',
token_exchange_method: TokenExchangeMethodEnum.DefaultPost,
},
});
const { watch, setValue } = authMethods;
const type = watch('type');
// Sync form state when auth type changes
useEffect(() => {
if (type === 'none') {
// Reset auth fields when type is none
setValue('api_key', '');
setValue('authorization_type', AuthorizationTypeEnum.Basic);
setValue('custom_auth_header', '');
setValue('oauth_client_id', '');
setValue('oauth_client_secret', '');
setValue('authorization_url', '');
setValue('client_url', '');
setValue('scope', '');
setValue('token_exchange_method', TokenExchangeMethodEnum.DefaultPost);
}
}, [type, setValue]);
return (
<FormProvider {...authMethods}>
<ActionsAuth />
</FormProvider>
);
}

View File

@@ -0,0 +1,222 @@
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Plus, Trash2, CirclePlus } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '~/components/ui/Accordion';
import { DropdownPopup } from '~/components/ui';
import { useLocalize } from '~/hooks';
interface UserInfoPlaceholder {
label: string;
value: string;
description: string;
}
const userInfoPlaceholders: UserInfoPlaceholder[] = [
{ label: 'user-id', value: '{{LIBRECHAT_USER_ID}}', description: 'Current user ID' },
{ label: 'username', value: '{{LIBRECHAT_USER_USERNAME}}', description: 'Current username' },
{ label: 'email', value: '{{LIBRECHAT_USER_EMAIL}}', description: 'Current user email' },
{ label: 'name', value: '{{LIBRECHAT_USER_NAME}}', description: 'Current user name' },
{
label: 'provider',
value: '{{LIBRECHAT_USER_PROVIDER}}',
description: 'Authentication provider',
},
{ label: 'role', value: '{{LIBRECHAT_USER_ROLE}}', description: 'User role' },
];
export function MCPConfig() {
const localize = useLocalize();
const { register, watch, setValue } = useFormContext();
const [isHeadersMenuOpen, setIsHeadersMenuOpen] = useState(false);
const customHeaders = watch('customHeaders') || [];
const addCustomHeader = () => {
const newHeader = {
id: Date.now().toString(),
name: '',
value: '',
};
setValue('customHeaders', [...customHeaders, newHeader]);
};
const removeCustomHeader = (id: string) => {
setValue(
'customHeaders',
customHeaders.filter((header: any) => header.id !== id),
);
};
const updateCustomHeader = (id: string, field: 'name' | 'value', value: string) => {
setValue(
'customHeaders',
customHeaders.map((header: any) =>
header.id === id ? { ...header, [field]: value } : header,
),
);
};
const handleAddPlaceholder = (placeholder: UserInfoPlaceholder) => {
const newHeader = {
id: Date.now().toString(),
name: placeholder.label,
value: placeholder.value,
};
setValue('customHeaders', [...customHeaders, newHeader]);
setIsHeadersMenuOpen(false);
};
const headerMenuItems = [
...userInfoPlaceholders.map((placeholder) => ({
label: `${placeholder.label} - ${placeholder.description}`,
onClick: () => handleAddPlaceholder(placeholder),
})),
];
return (
<div className="space-y-4">
{/* Authentication Accordion */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authentication" className="rounded-lg border border-border-medium">
<AccordionTrigger className="rounded px-4 py-3 text-sm font-medium hover:no-underline focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{localize('com_ui_authentication')}
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-2">
<div className="space-y-4">
{/* Custom Headers Section - Individual Inputs Version */}
<div>
<div className="mb-3 flex items-center justify-between">
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_mcp_custom_headers')}
</label>
<DropdownPopup
menuId="headers-menu"
items={headerMenuItems}
isOpen={isHeadersMenuOpen}
setIsOpen={setIsHeadersMenuOpen}
trigger={
<Menu.MenuButton
onClick={() => setIsHeadersMenuOpen(!isHeadersMenuOpen)}
className="flex h-7 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<CirclePlus className="mr-1 h-3 w-3 text-text-secondary" />
{localize('com_ui_mcp_headers')}
</Menu.MenuButton>
}
/>
</div>
<div className="space-y-2">
{customHeaders.length === 0 ? (
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 flex-1 text-sm text-text-secondary">
{localize('com_ui_mcp_no_custom_headers')}
</p>
<button
type="button"
onClick={addCustomHeader}
className="flex h-7 shrink-0 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Plus className="h-3 w-3" />
{localize('com_ui_mcp_add_header')}
</button>
</div>
) : (
<>
{customHeaders.map((header: any) => (
<div key={header.id} className="flex min-w-0 gap-2">
<input
type="text"
placeholder={localize('com_ui_mcp_header_name')}
value={header.name}
onChange={(e) => updateCustomHeader(header.id, 'name', e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<input
type="text"
placeholder={localize('com_ui_mcp_header_value')}
value={header.value}
onChange={(e) => updateCustomHeader(header.id, 'value', e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<button
type="button"
onClick={() => removeCustomHeader(header.id)}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border-medium bg-surface-primary text-text-secondary hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
{/* Add New Header Button */}
<div className="flex justify-end">
<button
type="button"
onClick={addCustomHeader}
className="flex h-7 shrink-0 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-xs text-text-primary transition-colors duration-200 hover:bg-surface-tertiary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<Plus className="h-3 w-3" />
{localize('com_ui_mcp_add_header')}
</button>
</div>
</>
)}
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Configuration Accordion */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="configuration" className="rounded-lg border border-border-medium">
<AccordionTrigger className="rounded px-4 py-3 text-sm font-medium hover:no-underline focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
{localize('com_ui_mcp_configuration')}
</AccordionTrigger>
<AccordionContent className="px-4 pb-4 pt-2">
<div className="space-y-4">
{/* Request Timeout */}
<div>
<label className="mb-2 block text-sm font-medium text-text-primary">
{localize('com_ui_mcp_request_timeout')}
</label>
<input
type="number"
placeholder="10000"
{...register('requestTimeout')}
className="h-9 w-full rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<p className="mt-1 text-xs text-text-secondary">
{localize('com_ui_mcp_request_timeout_description')}
</p>
</div>
{/* Connection Timeout */}
<div>
<label className="mb-2 block text-sm font-medium text-text-primary">
{localize('com_ui_mcp_connection_timeout')}
</label>
<input
type="number"
placeholder="10000"
{...register('connectionTimeout')}
className="h-9 w-full rounded-md border border-border-medium bg-surface-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
<p className="mt-1 text-xs text-text-secondary">
{localize('com_ui_mcp_connection_timeout_description')}
</p>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -1,66 +1,103 @@
import { useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import type { MCPForm } from '~/common';
import {
useCreateMCPMutation,
useUpdateMCPMutation,
useDeleteMCPMutation,
} from '~/data-provider/MCPs/mutations';
import type { MCP } from 'librechat-data-provider';
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
import { defaultMCPFormValues } from '~/common/mcp';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import useLocalize from '~/hooks/useLocalize';
import { TrashIcon } from '~/components/svg';
import type { MCPForm } from '~/common';
import MCPInput from './MCPInput';
import { Panel } from '~/common';
import {
AuthTypeEnum,
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
// TODO: Add MCP delete (for now mocked for ui)
// import { useDeleteAgentMCP } from '~/data-provider';
function useDeleteAgentMCP({
onSuccess,
onError,
}: {
onSuccess: () => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({ mcp_id, agent_id }: { mcp_id: string; agent_id: string }) => {
try {
console.log('Mock delete MCP:', { mcp_id, agent_id });
onSuccess();
} catch (error) {
onError(error as Error);
}
},
};
interface MCPFormPanelProps {
// Data
mcp?: MCP;
// Actions
onBack: () => void;
// UI customization
title?: string;
subtitle?: string;
showDeleteButton?: boolean;
deleteConfirmMessage?: string;
// Form customization
defaultValues?: Partial<MCPForm>;
}
export default function MCPPanel() {
export default function MCPFormPanel({
mcp,
onBack,
title,
subtitle,
showDeleteButton = true,
deleteConfirmMessage,
defaultValues = defaultMCPFormValues,
}: MCPFormPanelProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mcp, setMcp, agent_id, setActivePanel } = useAgentPanelContext();
const deleteAgentMCP = useDeleteAgentMCP({
const create = useCreateMCPMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
onBack();
},
onError: (error) => {
console.error('Error creating MCP:', error);
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
},
});
const update = useUpdateMCPMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
onBack();
},
onError: (error) => {
console.error('Error updating MCP:', error);
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
},
});
const deleteMCP = useDeleteMCPMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_delete_mcp_success'),
status: 'success',
});
setActivePanel(Panel.builder);
setMcp(undefined);
onBack();
},
onError(error) {
onError: (error) => {
console.error('Error deleting MCP:', error);
showToast({
message: (error as Error).message ?? localize('com_ui_delete_mcp_error'),
message: localize('com_ui_delete_mcp_error'),
status: 'error',
});
},
});
const methods = useForm<MCPForm>({
defaultValues: defaultMCPFormValues,
defaultValues: defaultValues,
});
const { reset } = methods;
@@ -74,55 +111,51 @@ export default function MCPPanel() {
url: mcp.metadata.url ?? '',
tools: mcp.metadata.tools ?? [],
trust: mcp.metadata.trust ?? false,
customHeaders: mcp.metadata.customHeaders ?? [],
requestTimeout: mcp.metadata.requestTimeout,
connectionTimeout: mcp.metadata.connectionTimeout,
};
if (mcp.metadata.auth) {
Object.assign(formData, {
type: mcp.metadata.auth.type || AuthTypeEnum.None,
saved_auth_fields: false,
api_key: mcp.metadata.api_key ?? '',
authorization_type: mcp.metadata.auth.authorization_type || AuthorizationTypeEnum.Basic,
oauth_client_id: mcp.metadata.oauth_client_id ?? '',
oauth_client_secret: mcp.metadata.oauth_client_secret ?? '',
authorization_url: mcp.metadata.auth.authorization_url ?? '',
client_url: mcp.metadata.auth.client_url ?? '',
scope: mcp.metadata.auth.scope ?? '',
token_exchange_method:
mcp.metadata.auth.token_exchange_method ?? TokenExchangeMethodEnum.DefaultPost,
});
}
reset(formData);
}
}, [mcp, reset]);
const handleSave = (mcpData: MCP) => {
if (mcp) {
// Update existing MCP
update.mutate({ mcp_id: mcp.mcp_id, data: mcpData });
} else {
// Create new MCP
create.mutate(mcpData);
}
};
const handleDelete = () => {
if (mcp?.mcp_id) {
deleteMCP.mutate({ mcp_id: mcp.mcp_id });
}
};
return (
<FormProvider {...methods}>
<form className="h-full grow overflow-hidden">
<div className="h-full overflow-auto px-2 pb-12 text-sm">
<div className="relative flex flex-col items-center px-16 py-6 text-center">
<div className="absolute left-0 top-6">
<button
type="button"
className="btn btn-neutral relative"
onClick={() => {
setActivePanel(Panel.builder);
setMcp(undefined);
}}
>
<button type="button" className="btn btn-neutral relative" onClick={onBack}>
<div className="flex w-full items-center justify-center gap-2">
<ChevronLeft />
</div>
</button>
</div>
{!!mcp && (
{!!mcp && showDeleteButton && (
<OGDialog>
<OGDialogTrigger asChild>
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!agent_id || !mcp.mcp_id}
disabled={!mcp.mcp_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
>
<TrashIcon className="text-red-500" />
@@ -135,22 +168,11 @@ export default function MCPPanel() {
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_mcp_confirm')}
{deleteConfirmMessage || localize('com_ui_delete_mcp_confirm')}
</Label>
}
selection={{
selectHandler: () => {
if (!agent_id) {
return showToast({
message: localize('com_agents_no_agent_id_error'),
status: 'error',
});
}
deleteAgentMCP.mutate({
mcp_id: mcp.mcp_id,
agent_id,
});
},
selectHandler: handleDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
@@ -160,11 +182,17 @@ export default function MCPPanel() {
)}
<div className="text-xl font-medium">
{mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')}
{title ||
(mcp ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server'))}
</div>
<div className="text-xs text-text-secondary">{localize('com_agents_mcp_info')}</div>
<div className="text-xs text-text-secondary">{subtitle || ''}</div>
</div>
<MCPInput mcp={mcp} agent_id={agent_id} setMCP={setMcp} />
<MCPInput
mcp={mcp}
agent_id=""
onSave={handleSave}
isLoading={create.isLoading || update.isLoading}
/>
</div>
</form>
</FormProvider>

View File

@@ -1,58 +1,31 @@
import { useState, useEffect } from 'react';
import { Constants } from 'librechat-data-provider';
import { useFormContext, Controller } from 'react-hook-form';
import type { MCP } from 'librechat-data-provider';
import MCPAuth from '~/components/SidePanel/Builder/MCPAuth';
import type { MCP, MCPMetadata } from 'librechat-data-provider';
import { MCPConfig } from '~/components/SidePanel/MCP/MCPConfig';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import { Label, Checkbox } from '~/components/ui';
import useLocalize from '~/hooks/useLocalize';
import { useToastContext } from '~/Providers';
import { Spinner } from '~/components/svg';
import { MCPForm } from '~/common/types';
function useUpdateAgentMCP({
onSuccess,
onError,
}: {
onSuccess: (data: [string, MCP]) => void;
onError: (error: Error) => void;
}) {
return {
mutate: async ({
mcp_id,
metadata,
agent_id,
}: {
mcp_id?: string;
metadata: MCP['metadata'];
agent_id: string;
}) => {
try {
// TODO: Implement MCP endpoint
onSuccess(['success', { mcp_id, metadata, agent_id } as MCP]);
} catch (error) {
onError(error as Error);
}
},
isLoading: false,
};
}
interface MCPInputProps {
mcp?: MCP;
agent_id?: string;
setMCP: React.Dispatch<React.SetStateAction<MCP | undefined>>;
onSave: (mcp: MCP) => void;
isLoading?: boolean;
}
export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
export default function MCPInput({ mcp, agent_id = '', onSave, isLoading = false }: MCPInputProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const {
handleSubmit,
register,
formState: { errors },
control,
setValue,
getValues,
} = useFormContext<MCPForm>();
const [isLoading, setIsLoading] = useState(false);
const [showTools, setShowTools] = useState(false);
const [selectedTools, setSelectedTools] = useState<string[]>([]);
@@ -64,50 +37,20 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
}
}, [mcp]);
const updateAgentMCP = useUpdateAgentMCP({
onSuccess(data) {
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
setMCP(data[1]);
setShowTools(true);
setSelectedTools(data[1].metadata.tools ?? []);
setIsLoading(false);
},
onError(error) {
showToast({
message: (error as Error).message || localize('com_ui_update_mcp_error'),
status: 'error',
});
setIsLoading(false);
},
});
const saveMCP = handleSubmit(async (data: MCPForm) => {
setIsLoading(true);
try {
const response = await updateAgentMCP.mutate({
agent_id: agent_id ?? '',
mcp_id: mcp?.mcp_id,
metadata: {
...data,
tools: selectedTools,
},
});
setMCP(response[1]);
showToast({
message: localize('com_ui_update_mcp_success'),
status: 'success',
});
} catch {
showToast({
message: localize('com_ui_update_mcp_error'),
status: 'error',
});
} finally {
setIsLoading(false);
}
// Generate MCP ID using server name and delimiter for new MCPs
const mcpId =
mcp?.mcp_id || `${data.name.replace(/\s+/g, '_').toLowerCase()}${Constants.mcp_delimiter}`;
const updatedMCP: MCP = {
mcp_id: mcpId,
agent_id: agent_id ?? '',
metadata: {
...data,
tools: selectedTools,
} as MCPMetadata, // Type assertion since form validation ensures required fields
};
onSave(updatedMCP);
});
const handleSelectAll = () => {
@@ -140,14 +83,15 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setMCP({
const updatedMCP: MCP = {
mcp_id: mcp?.mcp_id ?? '',
agent_id: agent_id ?? '',
metadata: {
...mcp?.metadata,
icon: base64String,
},
});
};
onSave(updatedMCP);
};
reader.readAsDataURL(file);
}
@@ -205,25 +149,48 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
</span>
)}
</div>
<MCPAuth />
<div className="my-2 flex items-center gap-2">
<MCPConfig />
<div className="my-2 flex items-center">
<Controller
name="trust"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Checkbox id="trust" checked={field.value} onCheckedChange={field.onChange} />
<Checkbox
{...field}
checked={field.value ?? false}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
value={(field.value ?? false).toString()}
/>
)}
/>
<Label htmlFor="trust" className="flex flex-col">
{localize('com_ui_trust_app')}
<span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')}
</span>
</Label>
<button
type="button"
className="flex items-center space-x-2"
onClick={() =>
setValue('trust', !getValues('trust'), {
shouldDirty: true,
})
}
>
<label
className="form-check-label text-token-text-primary w-full cursor-pointer"
htmlFor="trust"
>
{localize('com_ui_trust_app')}
</label>
</button>
</div>
<div className="-mt-5 ml-6">
<span className="text-xs text-text-secondary">
{localize('com_agents_mcp_trust_subtext')}
</span>
</div>
{errors.trust && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
<div className="ml-6">
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
</div>
)}
</div>
@@ -231,7 +198,7 @@ export default function MCPInput({ mcp, agent_id, setMCP }: MCPInputProps) {
<button
onClick={saveMCP}
disabled={isLoading}
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0 disabled:bg-green-400"
className="focus:shadow-outline mt-1 flex min-w-[100px] items-center justify-center rounded bg-green-500 px-4 py-2 font-semibold text-white transition-colors duration-200 hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:bg-green-400"
type="button"
>
{(() => {

View File

@@ -9,7 +9,7 @@ type MCPProps = {
onClick: () => void;
};
export default function MCP({ mcp, onClick }: MCPProps) {
export function MCPItem({ mcp, onClick }: MCPProps) {
const [isHovering, setIsHovering] = useState(false);
return (

View File

@@ -1,7 +1,7 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { Constants } from 'librechat-data-provider';
import { useForm, Controller } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import { Button, Input, Label } from '~/components/ui';
@@ -9,6 +9,7 @@ import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useToastContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import MCPFormPanel from './MCPFormPanel';
interface ServerConfigWithVars {
serverName: string;
@@ -24,6 +25,7 @@ export default function MCPPanel() {
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null,
);
const [showMCPForm, setShowMCPForm] = useState(false);
const mcpServerDefinitions = useMemo(() => {
if (!startupConfig?.mcpServers) {
@@ -89,14 +91,47 @@ export default function MCPPanel() {
setSelectedServerNameForEditing(null);
};
const handleAddMCP = () => {
setShowMCPForm(true);
};
const handleBackFromForm = () => {
setShowMCPForm(false);
};
if (showMCPForm) {
return (
<MCPFormPanel
onBack={handleBackFromForm}
title={localize('com_ui_add_mcp_server')}
subtitle={localize('com_agents_mcp_info_chat')}
/>
);
}
if (startupConfigLoading) {
return <MCPPanelSkeleton />;
}
if (mcpServerDefinitions.length === 0) {
return (
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_servers_with_vars')}
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_servers_with_vars')}
</div>
<div className="mt-4">
<button
type="button"
onClick={handleAddMCP}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_ui_add_mcp')}
</div>
</button>
</div>
</div>
);
}
@@ -144,15 +179,28 @@ export default function MCPPanel() {
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="space-y-2">
{mcpServerDefinitions.map((server) => (
<Button
<button
key={server.serverName}
variant="outline"
className="w-full justify-start dark:hover:bg-gray-700"
type="button"
onClick={() => handleServerClickToEdit(server.serverName)}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-label={`Configure MCP server ${server.serverName}`}
>
{server.serverName}
</Button>
<div className="flex w-full items-center justify-start gap-2">
{server.serverName}
</div>
</button>
))}
<button
type="button"
onClick={handleAddMCP}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_ui_add_mcp')}
</div>
</button>
</div>
</div>
);
@@ -181,7 +229,7 @@ function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariab
useEffect(() => {
// Always initialize with empty strings based on the schema
const initialFormValues = Object.keys(server.config.customUserVars).reduce(
const initialFormValues = Object.keys(server.config.customUserVars || {}).reduce(
(acc, key) => {
acc[key] = '';
return acc;
@@ -230,7 +278,7 @@ function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariab
</div>
))}
<div className="flex justify-end gap-2 pt-2">
{Object.keys(server.config.customUserVars).length > 0 && (
{Object.keys(server.config.customUserVars || {}).length > 0 && (
<Button
type="button"
onClick={handleRevokeClick}

View File

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

View File

@@ -46,6 +46,10 @@ function DynamicInput({
setInputValue(e, !isNaN(Number(e.target.value)));
};
const placeholderText = placeholderCode
? localize(placeholder as TranslationKeys) || placeholder
: placeholder;
return (
<div
className={`flex flex-col items-center justify-start gap-6 ${
@@ -76,11 +80,7 @@ function DynamicInput({
disabled={readonly}
value={inputValue ?? defaultValue ?? ''}
onChange={handleInputChange}
placeholder={
placeholderCode
? localize(placeholder as TranslationKeys) || placeholder
: placeholder
}
placeholder={placeholderText}
className={cn(
'flex h-10 max-h-10 w-full resize-none border-none bg-surface-secondary px-3 py-2',
)}

View File

@@ -29,7 +29,7 @@ function OptionHover({
<HoverCardPortal>
<HoverCardContent side={side} className={`z-[999] w-80 ${className}`} sideOffset={sideOffset}>
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">{text}</p>
<p className="whitespace-pre-wrap text-sm text-text-secondary">{text}</p>
</div>
</HoverCardContent>
</HoverCardPortal>

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

View File

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

Some files were not shown because too many files have changed in this diff Show More