mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
Merge remote-tracking branch 'origin/main' into feature/client-side-image-resize
This commit is contained in:
commit
0417a38a6e
137 changed files with 8953 additions and 2277 deletions
2
.github/workflows/unused-packages.yml
vendored
2
.github/workflows/unused-packages.yml
vendored
|
|
@ -98,6 +98,8 @@ jobs:
|
||||||
cd client
|
cd client
|
||||||
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
|
||||||
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt | sort) || echo "")
|
UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt | sort) || echo "")
|
||||||
|
# Filter out false positives
|
||||||
|
UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
|
||||||
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
|
echo "CLIENT_UNUSED<<EOF" >> $GITHUB_ENV
|
||||||
echo "$UNUSED" >> $GITHUB_ENV
|
echo "$UNUSED" >> $GITHUB_ENV
|
||||||
echo "EOF" >> $GITHUB_ENV
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -55,6 +55,7 @@ bower_components/
|
||||||
# AI
|
# AI
|
||||||
.clineignore
|
.clineignore
|
||||||
.cursor
|
.cursor
|
||||||
|
.aider*
|
||||||
|
|
||||||
# Floobits
|
# Floobits
|
||||||
.floo
|
.floo
|
||||||
|
|
|
||||||
|
|
@ -190,10 +190,11 @@ class AnthropicClient extends BaseClient {
|
||||||
reverseProxyUrl: this.options.reverseProxyUrl,
|
reverseProxyUrl: this.options.reverseProxyUrl,
|
||||||
}),
|
}),
|
||||||
apiKey: this.apiKey,
|
apiKey: this.apiKey,
|
||||||
|
fetchOptions: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.options.proxy) {
|
if (this.options.proxy) {
|
||||||
options.httpAgent = new HttpsProxyAgent(this.options.proxy);
|
options.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.reverseProxyUrl) {
|
if (this.options.reverseProxyUrl) {
|
||||||
|
|
|
||||||
|
|
@ -1159,6 +1159,7 @@ ${convo}
|
||||||
logger.debug('[OpenAIClient] chatCompletion', { baseURL, modelOptions });
|
logger.debug('[OpenAIClient] chatCompletion', { baseURL, modelOptions });
|
||||||
const opts = {
|
const opts = {
|
||||||
baseURL,
|
baseURL,
|
||||||
|
fetchOptions: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.useOpenRouter) {
|
if (this.useOpenRouter) {
|
||||||
|
|
@ -1177,7 +1178,7 @@ ${convo}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.proxy) {
|
if (this.options.proxy) {
|
||||||
opts.httpAgent = new HttpsProxyAgent(this.options.proxy);
|
opts.fetchOptions.agent = new HttpsProxyAgent(this.options.proxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {TAzureConfig | undefined} */
|
/** @type {TAzureConfig | undefined} */
|
||||||
|
|
@ -1395,7 +1396,7 @@ ${convo}
|
||||||
...modelOptions,
|
...modelOptions,
|
||||||
stream: true,
|
stream: true,
|
||||||
};
|
};
|
||||||
const stream = await openai.beta.chat.completions
|
const stream = await openai.chat.completions
|
||||||
.stream(params)
|
.stream(params)
|
||||||
.on('abort', () => {
|
.on('abort', () => {
|
||||||
/* Do nothing here */
|
/* Do nothing here */
|
||||||
|
|
|
||||||
|
|
@ -309,7 +309,7 @@ describe('AnthropicClient', () => {
|
||||||
};
|
};
|
||||||
client.setOptions({ modelOptions, promptCache: true });
|
client.setOptions({ modelOptions, promptCache: true });
|
||||||
const anthropicClient = client.getClient(modelOptions);
|
const anthropicClient = client.getClient(modelOptions);
|
||||||
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
|
expect(anthropicClient._options.defaultHeaders).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not add beta header for other models', () => {
|
it('should not add beta header for other models', () => {
|
||||||
|
|
@ -320,7 +320,7 @@ describe('AnthropicClient', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const anthropicClient = client.getClient();
|
const anthropicClient = client.getClient();
|
||||||
expect(anthropicClient.defaultHeaders).not.toHaveProperty('anthropic-beta');
|
expect(anthropicClient._options.defaultHeaders).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
|
const { mcpToolPattern } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
||||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||||
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
Constants,
|
|
||||||
EToolResources,
|
EToolResources,
|
||||||
loadWebSearchAuth,
|
loadWebSearchAuth,
|
||||||
replaceSpecialVars,
|
replaceSpecialVars,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
|
||||||
const {
|
const {
|
||||||
availableTools,
|
availableTools,
|
||||||
manifestToolMap,
|
manifestToolMap,
|
||||||
|
|
@ -28,11 +28,10 @@ const {
|
||||||
} = require('../');
|
} = require('../');
|
||||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||||
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
||||||
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||||
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
const { createMCPTool } = require('~/server/services/MCP');
|
const { createMCPTool } = require('~/server/services/MCP');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
|
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
|
||||||
|
|
@ -93,7 +92,7 @@ const validateTools = async (user, tools = []) => {
|
||||||
return Array.from(validToolsSet.values());
|
return Array.from(validToolsSet.values());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[validateTools] There was a problem validating tools', err);
|
logger.error('[validateTools] There was a problem validating tools', err);
|
||||||
throw new Error('There was a problem validating tools');
|
throw new Error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -236,7 +235,7 @@ const loadTools = async ({
|
||||||
|
|
||||||
/** @type {Record<string, string>} */
|
/** @type {Record<string, string>} */
|
||||||
const toolContextMap = {};
|
const toolContextMap = {};
|
||||||
const appTools = options.req?.app?.locals?.availableTools ?? {};
|
const appTools = (await getCachedTools({ includeGlobal: true })) ?? {};
|
||||||
|
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
if (tool === Tools.execute_code) {
|
if (tool === Tools.execute_code) {
|
||||||
|
|
@ -299,6 +298,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||||
requestedTools[tool] = async () =>
|
requestedTools[tool] = async () =>
|
||||||
createMCPTool({
|
createMCPTool({
|
||||||
req: options.req,
|
req: options.req,
|
||||||
|
res: options.res,
|
||||||
toolKey: tool,
|
toolKey: tool,
|
||||||
model: agent?.model ?? model,
|
model: agent?.model ?? model,
|
||||||
provider: agent?.provider ?? endpoint,
|
provider: agent?.provider ?? endpoint,
|
||||||
|
|
|
||||||
5
api/cache/getLogStores.js
vendored
5
api/cache/getLogStores.js
vendored
|
|
@ -29,6 +29,10 @@ const roles = isRedisEnabled
|
||||||
? new Keyv({ store: keyvRedis })
|
? new Keyv({ store: keyvRedis })
|
||||||
: new Keyv({ namespace: CacheKeys.ROLES });
|
: new Keyv({ namespace: CacheKeys.ROLES });
|
||||||
|
|
||||||
|
const mcpTools = isRedisEnabled
|
||||||
|
? new Keyv({ store: keyvRedis })
|
||||||
|
: new Keyv({ namespace: CacheKeys.MCP_TOOLS });
|
||||||
|
|
||||||
const audioRuns = isRedisEnabled
|
const audioRuns = isRedisEnabled
|
||||||
? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES })
|
? new Keyv({ store: keyvRedis, ttl: Time.TEN_MINUTES })
|
||||||
: new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: Time.TEN_MINUTES });
|
: new Keyv({ namespace: CacheKeys.AUDIO_RUNS, ttl: Time.TEN_MINUTES });
|
||||||
|
|
@ -67,6 +71,7 @@ const openIdExchangedTokensCache = isRedisEnabled
|
||||||
|
|
||||||
const namespaces = {
|
const namespaces = {
|
||||||
[CacheKeys.ROLES]: roles,
|
[CacheKeys.ROLES]: roles,
|
||||||
|
[CacheKeys.MCP_TOOLS]: mcpTools,
|
||||||
[CacheKeys.CONFIG_STORE]: config,
|
[CacheKeys.CONFIG_STORE]: config,
|
||||||
[CacheKeys.PENDING_REQ]: pending_req,
|
[CacheKeys.PENDING_REQ]: pending_req,
|
||||||
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),
|
[ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ let flowManager = null;
|
||||||
*/
|
*/
|
||||||
function getMCPManager(userId) {
|
function getMCPManager(userId) {
|
||||||
if (!mcpManager) {
|
if (!mcpManager) {
|
||||||
mcpManager = MCPManager.getInstance(logger);
|
mcpManager = MCPManager.getInstance();
|
||||||
} else {
|
} else {
|
||||||
mcpManager.checkIdleConnections(userId);
|
mcpManager.checkIdleConnections(userId);
|
||||||
}
|
}
|
||||||
|
|
@ -30,7 +30,6 @@ function getFlowStateManager(flowsCache) {
|
||||||
if (!flowManager) {
|
if (!flowManager) {
|
||||||
flowManager = new FlowStateManager(flowsCache, {
|
flowManager = new FlowStateManager(flowsCache, {
|
||||||
ttl: Time.ONE_MINUTE * 3,
|
ttl: Time.ONE_MINUTE * 3,
|
||||||
logger,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return flowManager;
|
return flowManager;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { MeiliSearch } = require('meilisearch');
|
const { MeiliSearch } = require('meilisearch');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { FlowStateManager } = require('@librechat/api');
|
||||||
|
const { CacheKeys } = require('librechat-data-provider');
|
||||||
|
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
const Conversation = mongoose.models.Conversation;
|
const Conversation = mongoose.models.Conversation;
|
||||||
const Message = mongoose.models.Message;
|
const Message = mongoose.models.Message;
|
||||||
|
|
@ -28,43 +31,123 @@ class MeiliSearchClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the actual sync operations for messages and conversations
|
||||||
|
*/
|
||||||
|
async function performSync() {
|
||||||
|
const client = MeiliSearchClient.getInstance();
|
||||||
|
|
||||||
|
const { status } = await client.health();
|
||||||
|
if (status !== 'available') {
|
||||||
|
throw new Error('Meilisearch not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indexingDisabled === true) {
|
||||||
|
logger.info('[indexSync] Indexing is disabled, skipping...');
|
||||||
|
return { messagesSync: false, convosSync: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let messagesSync = false;
|
||||||
|
let convosSync = false;
|
||||||
|
|
||||||
|
// Check if we need to sync messages
|
||||||
|
const messageProgress = await Message.getSyncProgress();
|
||||||
|
if (!messageProgress.isComplete) {
|
||||||
|
logger.info(
|
||||||
|
`[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if we should do a full sync or incremental
|
||||||
|
const messageCount = await Message.countDocuments();
|
||||||
|
const messagesIndexed = messageProgress.totalProcessed;
|
||||||
|
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
|
||||||
|
|
||||||
|
if (messageCount - messagesIndexed > syncThreshold) {
|
||||||
|
logger.info('[indexSync] Starting full message sync due to large difference');
|
||||||
|
await Message.syncWithMeili();
|
||||||
|
messagesSync = true;
|
||||||
|
} else if (messageCount !== messagesIndexed) {
|
||||||
|
logger.warn('[indexSync] Messages out of sync, performing incremental sync');
|
||||||
|
await Message.syncWithMeili();
|
||||||
|
messagesSync = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we need to sync conversations
|
||||||
|
const convoProgress = await Conversation.getSyncProgress();
|
||||||
|
if (!convoProgress.isComplete) {
|
||||||
|
logger.info(
|
||||||
|
`[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const convoCount = await Conversation.countDocuments();
|
||||||
|
const convosIndexed = convoProgress.totalProcessed;
|
||||||
|
const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10);
|
||||||
|
|
||||||
|
if (convoCount - convosIndexed > syncThreshold) {
|
||||||
|
logger.info('[indexSync] Starting full conversation sync due to large difference');
|
||||||
|
await Conversation.syncWithMeili();
|
||||||
|
convosSync = true;
|
||||||
|
} else if (convoCount !== convosIndexed) {
|
||||||
|
logger.warn('[indexSync] Convos out of sync, performing incremental sync');
|
||||||
|
await Conversation.syncWithMeili();
|
||||||
|
convosSync = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messagesSync, convosSync };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main index sync function that uses FlowStateManager to prevent concurrent execution
|
||||||
|
*/
|
||||||
async function indexSync() {
|
async function indexSync() {
|
||||||
if (!searchEnabled) {
|
if (!searchEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const client = MeiliSearchClient.getInstance();
|
|
||||||
|
|
||||||
const { status } = await client.health();
|
logger.info('[indexSync] Starting index synchronization check...');
|
||||||
if (status !== 'available') {
|
|
||||||
throw new Error('Meilisearch not available');
|
try {
|
||||||
|
// Get or create FlowStateManager instance
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
if (!flowsCache) {
|
||||||
|
logger.warn('[indexSync] Flows cache not available, falling back to direct sync');
|
||||||
|
return await performSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (indexingDisabled === true) {
|
const flowManager = new FlowStateManager(flowsCache, {
|
||||||
logger.info('[indexSync] Indexing is disabled, skipping...');
|
ttl: 60000 * 10, // 10 minutes TTL for sync operations
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use a unique flow ID for the sync operation
|
||||||
|
const flowId = 'meili-index-sync';
|
||||||
|
const flowType = 'MEILI_SYNC';
|
||||||
|
|
||||||
|
// This will only execute the handler if no other instance is running the sync
|
||||||
|
const result = await flowManager.createFlowWithHandler(flowId, flowType, performSync);
|
||||||
|
|
||||||
|
if (result.messagesSync || result.convosSync) {
|
||||||
|
logger.info('[indexSync] Sync completed successfully');
|
||||||
|
} else {
|
||||||
|
logger.debug('[indexSync] No sync was needed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('flow already exists')) {
|
||||||
|
logger.info('[indexSync] Sync already running on another instance');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageCount = await Message.countDocuments();
|
|
||||||
const convoCount = await Conversation.countDocuments();
|
|
||||||
const messages = await client.index('messages').getStats();
|
|
||||||
const convos = await client.index('convos').getStats();
|
|
||||||
const messagesIndexed = messages.numberOfDocuments;
|
|
||||||
const convosIndexed = convos.numberOfDocuments;
|
|
||||||
|
|
||||||
logger.debug(`[indexSync] There are ${messageCount} messages and ${messagesIndexed} indexed`);
|
|
||||||
logger.debug(`[indexSync] There are ${convoCount} convos and ${convosIndexed} indexed`);
|
|
||||||
|
|
||||||
if (messageCount !== messagesIndexed) {
|
|
||||||
logger.debug('[indexSync] Messages out of sync, indexing');
|
|
||||||
Message.syncWithMeili();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (convoCount !== convosIndexed) {
|
|
||||||
logger.debug('[indexSync] Convos out of sync, indexing');
|
|
||||||
Conversation.syncWithMeili();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err.message.includes('not found')) {
|
if (err.message.includes('not found')) {
|
||||||
logger.debug('[indexSync] Creating indices...');
|
logger.debug('[indexSync] Creating indices...');
|
||||||
currentTimeout = setTimeout(async () => {
|
currentTimeout = setTimeout(async () => {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const {
|
||||||
removeAgentIdsFromProject,
|
removeAgentIdsFromProject,
|
||||||
removeAgentFromAllProjects,
|
removeAgentFromAllProjects,
|
||||||
} = require('./Project');
|
} = require('./Project');
|
||||||
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
const { getActions } = require('./Action');
|
const { getActions } = require('./Action');
|
||||||
const { Agent } = require('~/db/models');
|
const { Agent } = require('~/db/models');
|
||||||
|
|
@ -55,12 +56,12 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
|
||||||
* @param {string} params.agent_id
|
* @param {string} params.agent_id
|
||||||
* @param {string} params.endpoint
|
* @param {string} params.endpoint
|
||||||
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
* @param {import('@librechat/agents').ClientOptions} [params.model_parameters]
|
||||||
* @returns {Agent|null} The agent document as a plain object, or null if not found.
|
* @returns {Promise<Agent|null>} The agent document as a plain object, or null if not found.
|
||||||
*/
|
*/
|
||||||
const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) => {
|
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
|
||||||
const { model, ...model_parameters } = _m;
|
const { model, ...model_parameters } = _m;
|
||||||
/** @type {Record<string, FunctionTool>} */
|
/** @type {Record<string, FunctionTool>} */
|
||||||
const availableTools = req.app.locals.availableTools;
|
const availableTools = await getCachedTools({ includeGlobal: true });
|
||||||
/** @type {TEphemeralAgent | null} */
|
/** @type {TEphemeralAgent | null} */
|
||||||
const ephemeralAgent = req.body.ephemeralAgent;
|
const ephemeralAgent = req.body.ephemeralAgent;
|
||||||
const mcpServers = new Set(ephemeralAgent?.mcp);
|
const mcpServers = new Set(ephemeralAgent?.mcp);
|
||||||
|
|
@ -111,7 +112,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (agent_id === EPHEMERAL_AGENT_ID) {
|
if (agent_id === EPHEMERAL_AGENT_ID) {
|
||||||
return loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
|
return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters });
|
||||||
}
|
}
|
||||||
const agent = await getAgent({
|
const agent = await getAgent({
|
||||||
id: agent_id,
|
id: agent_id,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ const originalEnv = {
|
||||||
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
|
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
|
||||||
process.env.CREDS_IV = '0123456789abcdef';
|
process.env.CREDS_IV = '0123456789abcdef';
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Config', () => ({
|
||||||
|
getCachedTools: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const { agentSchema } = require('@librechat/data-schemas');
|
const { agentSchema } = require('@librechat/data-schemas');
|
||||||
|
|
@ -23,6 +27,7 @@ const {
|
||||||
generateActionMetadataHash,
|
generateActionMetadataHash,
|
||||||
revertAgentVersion,
|
revertAgentVersion,
|
||||||
} = require('./Agent');
|
} = require('./Agent');
|
||||||
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
||||||
|
|
@ -406,6 +411,7 @@ describe('models/Agent', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mongoServer = await MongoMemoryServer.create();
|
mongoServer = await MongoMemoryServer.create();
|
||||||
const mongoUri = mongoServer.getUri();
|
const mongoUri = mongoServer.getUri();
|
||||||
|
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
||||||
await mongoose.connect(mongoUri);
|
await mongoose.connect(mongoUri);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1546,6 +1552,12 @@ describe('models/Agent', () => {
|
||||||
test('should test ephemeral agent loading logic', async () => {
|
test('should test ephemeral agent loading logic', async () => {
|
||||||
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
||||||
|
|
||||||
|
getCachedTools.mockResolvedValue({
|
||||||
|
tool1_mcp_server1: {},
|
||||||
|
tool2_mcp_server2: {},
|
||||||
|
another_tool: {},
|
||||||
|
});
|
||||||
|
|
||||||
const mockReq = {
|
const mockReq = {
|
||||||
user: { id: 'user123' },
|
user: { id: 'user123' },
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -1556,15 +1568,6 @@ describe('models/Agent', () => {
|
||||||
mcp: ['server1', 'server2'],
|
mcp: ['server1', 'server2'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
app: {
|
|
||||||
locals: {
|
|
||||||
availableTools: {
|
|
||||||
tool1_mcp_server1: {},
|
|
||||||
tool2_mcp_server2: {},
|
|
||||||
another_tool: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await loadAgent({
|
const result = await loadAgent({
|
||||||
|
|
@ -1657,6 +1660,8 @@ describe('models/Agent', () => {
|
||||||
test('should handle ephemeral agent with no MCP servers', async () => {
|
test('should handle ephemeral agent with no MCP servers', async () => {
|
||||||
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
||||||
|
|
||||||
|
getCachedTools.mockResolvedValue({});
|
||||||
|
|
||||||
const mockReq = {
|
const mockReq = {
|
||||||
user: { id: 'user123' },
|
user: { id: 'user123' },
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -1667,11 +1672,6 @@ describe('models/Agent', () => {
|
||||||
mcp: [],
|
mcp: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
app: {
|
|
||||||
locals: {
|
|
||||||
availableTools: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await loadAgent({
|
const result = await loadAgent({
|
||||||
|
|
@ -1692,16 +1692,13 @@ describe('models/Agent', () => {
|
||||||
test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => {
|
test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => {
|
||||||
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
||||||
|
|
||||||
|
getCachedTools.mockResolvedValue({});
|
||||||
|
|
||||||
const mockReq = {
|
const mockReq = {
|
||||||
user: { id: 'user123' },
|
user: { id: 'user123' },
|
||||||
body: {
|
body: {
|
||||||
promptPrefix: 'Basic instructions',
|
promptPrefix: 'Basic instructions',
|
||||||
},
|
},
|
||||||
app: {
|
|
||||||
locals: {
|
|
||||||
availableTools: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await loadAgent({
|
const result = await loadAgent({
|
||||||
|
|
@ -1734,6 +1731,13 @@ describe('models/Agent', () => {
|
||||||
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
||||||
|
|
||||||
const largeToolList = Array.from({ length: 100 }, (_, i) => `tool_${i}_mcp_server1`);
|
const largeToolList = Array.from({ length: 100 }, (_, i) => `tool_${i}_mcp_server1`);
|
||||||
|
const availableTools = largeToolList.reduce((acc, tool) => {
|
||||||
|
acc[tool] = {};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
getCachedTools.mockResolvedValue(availableTools);
|
||||||
|
|
||||||
const mockReq = {
|
const mockReq = {
|
||||||
user: { id: 'user123' },
|
user: { id: 'user123' },
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -1744,14 +1748,6 @@ describe('models/Agent', () => {
|
||||||
mcp: ['server1'],
|
mcp: ['server1'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
app: {
|
|
||||||
locals: {
|
|
||||||
availableTools: largeToolList.reduce((acc, tool) => {
|
|
||||||
acc[tool] = {};
|
|
||||||
return acc;
|
|
||||||
}, {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await loadAgent({
|
const result = await loadAgent({
|
||||||
|
|
@ -2272,6 +2268,13 @@ describe('models/Agent', () => {
|
||||||
test('should handle loadEphemeralAgent with malformed MCP tool names', async () => {
|
test('should handle loadEphemeralAgent with malformed MCP tool names', async () => {
|
||||||
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
const { EPHEMERAL_AGENT_ID } = require('librechat-data-provider').Constants;
|
||||||
|
|
||||||
|
getCachedTools.mockResolvedValue({
|
||||||
|
malformed_tool_name: {}, // No mcp delimiter
|
||||||
|
tool__server1: {}, // Wrong delimiter
|
||||||
|
tool_mcp_server1: {}, // Correct format
|
||||||
|
tool_mcp_server2: {}, // Different server
|
||||||
|
});
|
||||||
|
|
||||||
const mockReq = {
|
const mockReq = {
|
||||||
user: { id: 'user123' },
|
user: { id: 'user123' },
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -2282,16 +2285,6 @@ describe('models/Agent', () => {
|
||||||
mcp: ['server1'],
|
mcp: ['server1'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
app: {
|
|
||||||
locals: {
|
|
||||||
availableTools: {
|
|
||||||
malformed_tool_name: {}, // No mcp delimiter
|
|
||||||
tool__server1: {}, // Wrong delimiter
|
|
||||||
tool_mcp_server1: {}, // Correct format
|
|
||||||
tool_mcp_server2: {}, // Different server
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await loadAgent({
|
const result = await loadAgent({
|
||||||
|
|
|
||||||
|
|
@ -1,346 +0,0 @@
|
||||||
const { nanoid } = require('nanoid');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const { Constants } = require('librechat-data-provider');
|
|
||||||
const { Conversation, SharedLink } = require('~/db/models');
|
|
||||||
const { getMessages } = require('./Message');
|
|
||||||
|
|
||||||
class ShareServiceError extends Error {
|
|
||||||
constructor(message, code) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'ShareServiceError';
|
|
||||||
this.code = code;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const memoizedAnonymizeId = (prefix) => {
|
|
||||||
const memo = new Map();
|
|
||||||
return (id) => {
|
|
||||||
if (!memo.has(id)) {
|
|
||||||
memo.set(id, `${prefix}_${nanoid()}`);
|
|
||||||
}
|
|
||||||
return memo.get(id);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const anonymizeConvoId = memoizedAnonymizeId('convo');
|
|
||||||
const anonymizeAssistantId = memoizedAnonymizeId('a');
|
|
||||||
const anonymizeMessageId = (id) =>
|
|
||||||
id === Constants.NO_PARENT ? id : memoizedAnonymizeId('msg')(id);
|
|
||||||
|
|
||||||
function anonymizeConvo(conversation) {
|
|
||||||
if (!conversation) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newConvo = { ...conversation };
|
|
||||||
if (newConvo.assistant_id) {
|
|
||||||
newConvo.assistant_id = anonymizeAssistantId(newConvo.assistant_id);
|
|
||||||
}
|
|
||||||
return newConvo;
|
|
||||||
}
|
|
||||||
|
|
||||||
function anonymizeMessages(messages, newConvoId) {
|
|
||||||
if (!Array.isArray(messages)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const idMap = new Map();
|
|
||||||
return messages.map((message) => {
|
|
||||||
const newMessageId = anonymizeMessageId(message.messageId);
|
|
||||||
idMap.set(message.messageId, newMessageId);
|
|
||||||
|
|
||||||
const anonymizedAttachments = message.attachments?.map((attachment) => {
|
|
||||||
return {
|
|
||||||
...attachment,
|
|
||||||
messageId: newMessageId,
|
|
||||||
conversationId: newConvoId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
messageId: newMessageId,
|
|
||||||
parentMessageId:
|
|
||||||
idMap.get(message.parentMessageId) || anonymizeMessageId(message.parentMessageId),
|
|
||||||
conversationId: newConvoId,
|
|
||||||
model: message.model?.startsWith('asst_')
|
|
||||||
? anonymizeAssistantId(message.model)
|
|
||||||
: message.model,
|
|
||||||
attachments: anonymizedAttachments,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSharedMessages(shareId) {
|
|
||||||
try {
|
|
||||||
const share = await SharedLink.findOne({ shareId, isPublic: true })
|
|
||||||
.populate({
|
|
||||||
path: 'messages',
|
|
||||||
select: '-_id -__v -user',
|
|
||||||
})
|
|
||||||
.select('-_id -__v -user')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
if (!share?.conversationId || !share.isPublic) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newConvoId = anonymizeConvoId(share.conversationId);
|
|
||||||
const result = {
|
|
||||||
...share,
|
|
||||||
conversationId: newConvoId,
|
|
||||||
messages: anonymizeMessages(share.messages, newConvoId),
|
|
||||||
};
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[getShare] Error getting share link', {
|
|
||||||
error: error.message,
|
|
||||||
shareId,
|
|
||||||
});
|
|
||||||
throw new ShareServiceError('Error getting share link', 'SHARE_FETCH_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSharedLinks(user, pageParam, pageSize, isPublic, sortBy, sortDirection, search) {
|
|
||||||
try {
|
|
||||||
const query = { user, isPublic };
|
|
||||||
|
|
||||||
if (pageParam) {
|
|
||||||
if (sortDirection === 'desc') {
|
|
||||||
query[sortBy] = { $lt: pageParam };
|
|
||||||
} else {
|
|
||||||
query[sortBy] = { $gt: pageParam };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search && search.trim()) {
|
|
||||||
try {
|
|
||||||
const searchResults = await Conversation.meiliSearch(search);
|
|
||||||
|
|
||||||
if (!searchResults?.hits?.length) {
|
|
||||||
return {
|
|
||||||
links: [],
|
|
||||||
nextCursor: undefined,
|
|
||||||
hasNextPage: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversationIds = searchResults.hits.map((hit) => hit.conversationId);
|
|
||||||
query['conversationId'] = { $in: conversationIds };
|
|
||||||
} catch (searchError) {
|
|
||||||
logger.error('[getSharedLinks] Meilisearch error', {
|
|
||||||
error: searchError.message,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
links: [],
|
|
||||||
nextCursor: undefined,
|
|
||||||
hasNextPage: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sort = {};
|
|
||||||
sort[sortBy] = sortDirection === 'desc' ? -1 : 1;
|
|
||||||
|
|
||||||
if (Array.isArray(query.conversationId)) {
|
|
||||||
query.conversationId = { $in: query.conversationId };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharedLinks = await SharedLink.find(query)
|
|
||||||
.sort(sort)
|
|
||||||
.limit(pageSize + 1)
|
|
||||||
.select('-__v -user')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
const hasNextPage = sharedLinks.length > pageSize;
|
|
||||||
const links = sharedLinks.slice(0, pageSize);
|
|
||||||
|
|
||||||
const nextCursor = hasNextPage ? links[links.length - 1][sortBy] : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
links: links.map((link) => ({
|
|
||||||
shareId: link.shareId,
|
|
||||||
title: link?.title || 'Untitled',
|
|
||||||
isPublic: link.isPublic,
|
|
||||||
createdAt: link.createdAt,
|
|
||||||
conversationId: link.conversationId,
|
|
||||||
})),
|
|
||||||
nextCursor,
|
|
||||||
hasNextPage,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[getSharedLinks] Error getting shares', {
|
|
||||||
error: error.message,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
throw new ShareServiceError('Error getting shares', 'SHARES_FETCH_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteAllSharedLinks(user) {
|
|
||||||
try {
|
|
||||||
const result = await SharedLink.deleteMany({ user });
|
|
||||||
return {
|
|
||||||
message: 'All shared links deleted successfully',
|
|
||||||
deletedCount: result.deletedCount,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[deleteAllSharedLinks] Error deleting shared links', {
|
|
||||||
error: error.message,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
throw new ShareServiceError('Error deleting shared links', 'BULK_DELETE_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createSharedLink(user, conversationId) {
|
|
||||||
if (!user || !conversationId) {
|
|
||||||
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const [existingShare, conversationMessages] = await Promise.all([
|
|
||||||
SharedLink.findOne({ conversationId, isPublic: true }).select('-_id -__v -user').lean(),
|
|
||||||
getMessages({ conversationId }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (existingShare && existingShare.isPublic) {
|
|
||||||
throw new ShareServiceError('Share already exists', 'SHARE_EXISTS');
|
|
||||||
} else if (existingShare) {
|
|
||||||
await SharedLink.deleteOne({ conversationId });
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversation = await Conversation.findOne({ conversationId }).lean();
|
|
||||||
const title = conversation?.title || 'Untitled';
|
|
||||||
|
|
||||||
const shareId = nanoid();
|
|
||||||
await SharedLink.create({
|
|
||||||
shareId,
|
|
||||||
conversationId,
|
|
||||||
messages: conversationMessages,
|
|
||||||
title,
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { shareId, conversationId };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[createSharedLink] Error creating shared link', {
|
|
||||||
error: error.message,
|
|
||||||
user,
|
|
||||||
conversationId,
|
|
||||||
});
|
|
||||||
throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSharedLink(user, conversationId) {
|
|
||||||
if (!user || !conversationId) {
|
|
||||||
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const share = await SharedLink.findOne({ conversationId, user, isPublic: true })
|
|
||||||
.select('shareId -_id')
|
|
||||||
.lean();
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
return { shareId: null, success: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { shareId: share.shareId, success: true };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[getSharedLink] Error getting shared link', {
|
|
||||||
error: error.message,
|
|
||||||
user,
|
|
||||||
conversationId,
|
|
||||||
});
|
|
||||||
throw new ShareServiceError('Error getting shared link', 'SHARE_FETCH_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateSharedLink(user, shareId) {
|
|
||||||
if (!user || !shareId) {
|
|
||||||
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const share = await SharedLink.findOne({ shareId }).select('-_id -__v -user').lean();
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
throw new ShareServiceError('Share not found', 'SHARE_NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updatedMessages] = await Promise.all([
|
|
||||||
getMessages({ conversationId: share.conversationId }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const newShareId = nanoid();
|
|
||||||
const update = {
|
|
||||||
messages: updatedMessages,
|
|
||||||
user,
|
|
||||||
shareId: newShareId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const updatedShare = await SharedLink.findOneAndUpdate({ shareId, user }, update, {
|
|
||||||
new: true,
|
|
||||||
upsert: false,
|
|
||||||
runValidators: true,
|
|
||||||
}).lean();
|
|
||||||
|
|
||||||
if (!updatedShare) {
|
|
||||||
throw new ShareServiceError('Share update failed', 'SHARE_UPDATE_ERROR');
|
|
||||||
}
|
|
||||||
|
|
||||||
anonymizeConvo(updatedShare);
|
|
||||||
|
|
||||||
return { shareId: newShareId, conversationId: updatedShare.conversationId };
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[updateSharedLink] Error updating shared link', {
|
|
||||||
error: error.message,
|
|
||||||
user,
|
|
||||||
shareId,
|
|
||||||
});
|
|
||||||
throw new ShareServiceError(
|
|
||||||
error.code === 'SHARE_UPDATE_ERROR' ? error.message : 'Error updating shared link',
|
|
||||||
error.code || 'SHARE_UPDATE_ERROR',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSharedLink(user, shareId) {
|
|
||||||
if (!user || !shareId) {
|
|
||||||
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await SharedLink.findOneAndDelete({ shareId, user }).lean();
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
shareId,
|
|
||||||
message: 'Share deleted successfully',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[deleteSharedLink] Error deleting shared link', {
|
|
||||||
error: error.message,
|
|
||||||
user,
|
|
||||||
shareId,
|
|
||||||
});
|
|
||||||
throw new ShareServiceError('Error deleting shared link', 'SHARE_DELETE_ERROR');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getSharedLink,
|
|
||||||
getSharedLinks,
|
|
||||||
createSharedLink,
|
|
||||||
updateSharedLink,
|
|
||||||
deleteSharedLink,
|
|
||||||
getSharedMessages,
|
|
||||||
deleteAllSharedLinks,
|
|
||||||
};
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
const { findToken, updateToken, createToken } = require('~/models');
|
|
||||||
const { encryptV2 } = require('~/server/utils/crypto');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the OAuth token by creating or updating the token.
|
|
||||||
* @param {object} fields
|
|
||||||
* @param {string} fields.userId - The user's ID.
|
|
||||||
* @param {string} fields.token - The full token to store.
|
|
||||||
* @param {string} fields.identifier - Unique, alternative identifier for the token.
|
|
||||||
* @param {number} fields.expiresIn - The number of seconds until the token expires.
|
|
||||||
* @param {object} fields.metadata - Additional metadata to store with the token.
|
|
||||||
* @param {string} [fields.type="oauth"] - The type of token. Default is 'oauth'.
|
|
||||||
*/
|
|
||||||
async function handleOAuthToken({
|
|
||||||
token,
|
|
||||||
userId,
|
|
||||||
identifier,
|
|
||||||
expiresIn,
|
|
||||||
metadata,
|
|
||||||
type = 'oauth',
|
|
||||||
}) {
|
|
||||||
const encrypedToken = await encryptV2(token);
|
|
||||||
const tokenData = {
|
|
||||||
type,
|
|
||||||
userId,
|
|
||||||
metadata,
|
|
||||||
identifier,
|
|
||||||
token: encrypedToken,
|
|
||||||
expiresIn: parseInt(expiresIn, 10) || 3600,
|
|
||||||
};
|
|
||||||
|
|
||||||
const existingToken = await findToken({ userId, identifier });
|
|
||||||
if (existingToken) {
|
|
||||||
return await updateToken({ identifier }, tokenData);
|
|
||||||
} else {
|
|
||||||
return await createToken(tokenData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
handleOAuthToken,
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
|
const { getRandomValues } = require('@librechat/api');
|
||||||
const { logger, hashToken } = require('@librechat/data-schemas');
|
const { logger, hashToken } = require('@librechat/data-schemas');
|
||||||
const { getRandomValues } = require('~/server/utils/crypto');
|
|
||||||
const { createToken, findToken } = require('~/models');
|
const { createToken, findToken } = require('~/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ const tokenValues = Object.assign(
|
||||||
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
|
'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 },
|
||||||
'o4-mini': { prompt: 1.1, completion: 4.4 },
|
'o4-mini': { prompt: 1.1, completion: 4.4 },
|
||||||
'o3-mini': { prompt: 1.1, completion: 4.4 },
|
'o3-mini': { prompt: 1.1, completion: 4.4 },
|
||||||
o3: { prompt: 10, completion: 40 },
|
o3: { prompt: 2, completion: 8 },
|
||||||
'o1-mini': { prompt: 1.1, completion: 4.4 },
|
'o1-mini': { prompt: 1.1, completion: 4.4 },
|
||||||
'o1-preview': { prompt: 15, completion: 60 },
|
'o1-preview': { prompt: 15, completion: 60 },
|
||||||
o1: { prompt: 15, completion: 60 },
|
o1: { prompt: 15, completion: 60 },
|
||||||
|
|
|
||||||
|
|
@ -34,21 +34,21 @@
|
||||||
},
|
},
|
||||||
"homepage": "https://librechat.ai",
|
"homepage": "https://librechat.ai",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.37.0",
|
"@anthropic-ai/sdk": "^0.52.0",
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.758.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
||||||
"@azure/identity": "^4.7.0",
|
"@azure/identity": "^4.7.0",
|
||||||
"@azure/search-documents": "^12.0.0",
|
"@azure/search-documents": "^12.0.0",
|
||||||
"@azure/storage-blob": "^12.27.0",
|
"@azure/storage-blob": "^12.27.0",
|
||||||
"@google/generative-ai": "^0.23.0",
|
"@google/generative-ai": "^0.24.0",
|
||||||
"@googleapis/youtube": "^20.0.0",
|
"@googleapis/youtube": "^20.0.0",
|
||||||
"@keyv/redis": "^4.3.3",
|
"@keyv/redis": "^4.3.3",
|
||||||
"@langchain/community": "^0.3.44",
|
"@langchain/community": "^0.3.47",
|
||||||
"@langchain/core": "^0.3.57",
|
"@langchain/core": "^0.3.60",
|
||||||
"@langchain/google-genai": "^0.2.9",
|
"@langchain/google-genai": "^0.2.13",
|
||||||
"@langchain/google-vertexai": "^0.2.9",
|
"@langchain/google-vertexai": "^0.2.13",
|
||||||
"@langchain/textsplitters": "^0.1.0",
|
"@langchain/textsplitters": "^0.1.0",
|
||||||
"@librechat/agents": "^2.4.38",
|
"@librechat/agents": "^2.4.41",
|
||||||
"@librechat/api": "*",
|
"@librechat/api": "*",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@node-saml/passport-saml": "^5.0.0",
|
"@node-saml/passport-saml": "^5.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys, AuthType } = require('librechat-data-provider');
|
const { CacheKeys, AuthType } = require('librechat-data-provider');
|
||||||
|
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
|
||||||
const { getToolkitKey } = require('~/server/services/ToolService');
|
const { getToolkitKey } = require('~/server/services/ToolService');
|
||||||
const { getCustomConfig } = require('~/server/services/Config');
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
const { availableTools } = require('~/app/clients/tools');
|
const { availableTools } = require('~/app/clients/tools');
|
||||||
const { getMCPManager } = require('~/config');
|
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
const { Constants } = require('librechat-data-provider');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters out duplicate plugins from the list of plugins.
|
* Filters out duplicate plugins from the list of plugins.
|
||||||
|
|
@ -84,6 +86,45 @@ const getAvailablePluginsController = async (req, res) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createServerToolsCallback() {
|
||||||
|
/**
|
||||||
|
* @param {string} serverName
|
||||||
|
* @param {TPlugin[] | null} serverTools
|
||||||
|
*/
|
||||||
|
return async function (serverName, serverTools) {
|
||||||
|
try {
|
||||||
|
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
|
||||||
|
if (!serverName || !mcpToolsCache) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await mcpToolsCache.set(serverName, serverTools);
|
||||||
|
logger.debug(`MCP tools for ${serverName} added to cache.`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrieving MCP tools from cache:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGetServerTools() {
|
||||||
|
/**
|
||||||
|
* Retrieves cached server tools
|
||||||
|
* @param {string} serverName
|
||||||
|
* @returns {Promise<TPlugin[] | null>}
|
||||||
|
*/
|
||||||
|
return async function (serverName) {
|
||||||
|
try {
|
||||||
|
const mcpToolsCache = getLogStores(CacheKeys.MCP_TOOLS);
|
||||||
|
if (!mcpToolsCache) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await mcpToolsCache.get(serverName);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrieving MCP tools from cache:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves and returns a list of available tools, either from a cache or by reading a plugin manifest file.
|
* Retrieves and returns a list of available tools, either from a cache or by reading a plugin manifest file.
|
||||||
*
|
*
|
||||||
|
|
@ -109,7 +150,16 @@ const getAvailableTools = async (req, res) => {
|
||||||
const customConfig = await getCustomConfig();
|
const customConfig = await getCustomConfig();
|
||||||
if (customConfig?.mcpServers != null) {
|
if (customConfig?.mcpServers != null) {
|
||||||
const mcpManager = getMCPManager();
|
const mcpManager = getMCPManager();
|
||||||
pluginManifest = await mcpManager.loadManifestTools(pluginManifest);
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
|
||||||
|
const serverToolsCallback = createServerToolsCallback();
|
||||||
|
const getServerTools = createGetServerTools();
|
||||||
|
const mcpTools = await mcpManager.loadManifestTools({
|
||||||
|
flowManager,
|
||||||
|
serverToolsCallback,
|
||||||
|
getServerTools,
|
||||||
|
});
|
||||||
|
pluginManifest = [...mcpTools, ...pluginManifest];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {TPlugin[]} */
|
/** @type {TPlugin[]} */
|
||||||
|
|
@ -123,17 +173,57 @@ const getAvailableTools = async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolDefinitions = req.app.locals.availableTools;
|
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||||
const tools = authenticatedPlugins.filter(
|
|
||||||
(plugin) =>
|
|
||||||
toolDefinitions[plugin.pluginKey] !== undefined ||
|
|
||||||
(plugin.toolkit === true &&
|
|
||||||
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey)),
|
|
||||||
);
|
|
||||||
|
|
||||||
await cache.set(CacheKeys.TOOLS, tools);
|
const toolsOutput = [];
|
||||||
res.status(200).json(tools);
|
for (const plugin of authenticatedPlugins) {
|
||||||
|
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
|
||||||
|
const isToolkit =
|
||||||
|
plugin.toolkit === true &&
|
||||||
|
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey);
|
||||||
|
|
||||||
|
if (!isToolDefined && !isToolkit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolToAdd = { ...plugin };
|
||||||
|
|
||||||
|
if (!plugin.pluginKey.includes(Constants.mcp_delimiter)) {
|
||||||
|
toolsOutput.push(toolToAdd);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
|
||||||
|
const serverName = parts[parts.length - 1];
|
||||||
|
const serverConfig = customConfig?.mcpServers?.[serverName];
|
||||||
|
|
||||||
|
if (!serverConfig?.customUserVars) {
|
||||||
|
toolsOutput.push(toolToAdd);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||||
|
|
||||||
|
if (customVarKeys.length === 0) {
|
||||||
|
toolToAdd.authConfig = [];
|
||||||
|
toolToAdd.authenticated = true;
|
||||||
|
} else {
|
||||||
|
toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
|
||||||
|
authField: key,
|
||||||
|
label: value.title || key,
|
||||||
|
description: value.description || '',
|
||||||
|
}));
|
||||||
|
toolToAdd.authenticated = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toolsOutput.push(toolToAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalTools = filterUniquePlugins(toolsOutput);
|
||||||
|
await cache.set(CacheKeys.TOOLS, finalTools);
|
||||||
|
res.status(200).json(finalTools);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('[getAvailableTools]', error);
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
const { encryptV3 } = require('@librechat/api');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
verifyTOTP,
|
verifyTOTP,
|
||||||
|
|
@ -7,7 +8,6 @@ const {
|
||||||
generateBackupCodes,
|
generateBackupCodes,
|
||||||
} = require('~/server/services/twoFactorService');
|
} = require('~/server/services/twoFactorService');
|
||||||
const { getUserById, updateUser } = require('~/models');
|
const { getUserById, updateUser } = require('~/models');
|
||||||
const { encryptV3 } = require('~/server/utils/crypto');
|
|
||||||
|
|
||||||
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
|
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
|
Constants,
|
||||||
FileSources,
|
FileSources,
|
||||||
webSearchKeys,
|
webSearchKeys,
|
||||||
extractWebSearchEnvVars,
|
extractWebSearchEnvVars,
|
||||||
|
|
@ -21,8 +22,9 @@ const { verifyEmail, resendVerificationEmail } = require('~/server/services/Auth
|
||||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
const { Transaction, Balance, User } = require('~/db/models');
|
const { Transaction, Balance, User } = require('~/db/models');
|
||||||
const { deleteAllSharedLinks } = require('~/models/Share');
|
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
|
const { deleteAllSharedLinks } = require('~/models');
|
||||||
|
const { getMCPManager } = require('~/config');
|
||||||
|
|
||||||
const getUserController = async (req, res) => {
|
const getUserController = async (req, res) => {
|
||||||
/** @type {MongoUser} */
|
/** @type {MongoUser} */
|
||||||
|
|
@ -102,10 +104,22 @@ const updateUserPluginsController = async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
let keys = Object.keys(auth);
|
let keys = Object.keys(auth);
|
||||||
if (keys.length === 0 && pluginKey !== Tools.web_search) {
|
const values = Object.values(auth); // Used in 'install' block
|
||||||
|
|
||||||
|
const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
|
||||||
|
|
||||||
|
// Early exit condition:
|
||||||
|
// If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
|
||||||
|
// AND it's not web_search (which has special key handling to populate `keys` for uninstall)
|
||||||
|
// AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
|
||||||
|
// THEN return.
|
||||||
|
if (
|
||||||
|
keys.length === 0 &&
|
||||||
|
pluginKey !== Tools.web_search &&
|
||||||
|
!(action === 'uninstall' && isMCPTool)
|
||||||
|
) {
|
||||||
return res.status(200).send();
|
return res.status(200).send();
|
||||||
}
|
}
|
||||||
const values = Object.values(auth);
|
|
||||||
|
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
let status = 200;
|
let status = 200;
|
||||||
|
|
@ -132,16 +146,53 @@ const updateUserPluginsController = async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (action === 'uninstall') {
|
} else if (action === 'uninstall') {
|
||||||
for (let i = 0; i < keys.length; i++) {
|
// const isMCPTool was defined earlier
|
||||||
authService = await deleteUserPluginAuth(user.id, keys[i]);
|
if (isMCPTool && keys.length === 0) {
|
||||||
|
// This handles the case where auth: {} is sent for an MCP tool uninstall.
|
||||||
|
// It means "delete all credentials associated with this MCP pluginKey".
|
||||||
|
authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
|
||||||
if (authService instanceof Error) {
|
if (authService instanceof Error) {
|
||||||
logger.error('[authService]', authService);
|
logger.error(
|
||||||
|
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
|
||||||
|
authService,
|
||||||
|
);
|
||||||
({ status, message } = authService);
|
({ status, message } = authService);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// This handles:
|
||||||
|
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
|
||||||
|
// 2. Other tools uninstall (if keys were provided).
|
||||||
|
// 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
|
||||||
|
// If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
|
||||||
|
if (authService instanceof Error) {
|
||||||
|
logger.error('[authService] Error deleting specific auth key:', authService);
|
||||||
|
({ status, message } = authService);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 200) {
|
if (status === 200) {
|
||||||
|
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
|
||||||
|
if (pluginKey.startsWith(Constants.mcp_prefix)) {
|
||||||
|
try {
|
||||||
|
const mcpManager = getMCPManager(user.id);
|
||||||
|
if (mcpManager) {
|
||||||
|
logger.info(
|
||||||
|
`[updateUserPluginsController] Disconnecting MCP connections for user ${user.id} after plugin auth update for ${pluginKey}.`,
|
||||||
|
);
|
||||||
|
await mcpManager.disconnectUserConnections(user.id);
|
||||||
|
}
|
||||||
|
} catch (disconnectError) {
|
||||||
|
logger.error(
|
||||||
|
`[updateUserPluginsController] Error disconnecting MCP connections for user ${user.id} after plugin auth update:`,
|
||||||
|
disconnectError,
|
||||||
|
);
|
||||||
|
// Do not fail the request for this, but log it.
|
||||||
|
}
|
||||||
|
}
|
||||||
return res.status(status).send();
|
return res.status(status).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,15 @@ const {
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
||||||
const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config');
|
const {
|
||||||
|
getCustomEndpointConfig,
|
||||||
|
createGetMCPAuthMap,
|
||||||
|
checkCapability,
|
||||||
|
} = require('~/server/services/Config');
|
||||||
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
||||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
||||||
const { setMemory, deleteMemory, getFormattedMemories } = require('~/models');
|
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
|
||||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||||
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
|
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
|
||||||
const { checkAccess } = require('~/server/middleware/roles/access');
|
const { checkAccess } = require('~/server/middleware/roles/access');
|
||||||
|
|
@ -679,6 +683,8 @@ class AgentClient extends BaseClient {
|
||||||
version: 'v2',
|
version: 'v2',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUserMCPAuthMap = await createGetMCPAuthMap();
|
||||||
|
|
||||||
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
|
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
|
||||||
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
|
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
|
||||||
payload,
|
payload,
|
||||||
|
|
@ -798,6 +804,20 @@ class AgentClient extends BaseClient {
|
||||||
run.Graph.contentData = contentData;
|
run.Graph.contentData = contentData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (getUserMCPAuthMap) {
|
||||||
|
config.configurable.userMCPAuthMap = await getUserMCPAuthMap({
|
||||||
|
tools: agent.tools,
|
||||||
|
userId: this.options.req.user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent ${agent.id}`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await run.processStream({ messages }, config, {
|
await run.processStream({ messages }, config, {
|
||||||
keepContent: i !== 0,
|
keepContent: i !== 0,
|
||||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
Constants,
|
Constants,
|
||||||
FileContext,
|
|
||||||
FileSources,
|
FileSources,
|
||||||
SystemRoles,
|
SystemRoles,
|
||||||
EToolResources,
|
EToolResources,
|
||||||
|
|
@ -16,16 +16,16 @@ const {
|
||||||
deleteAgent,
|
deleteAgent,
|
||||||
getListAgents,
|
getListAgents,
|
||||||
} = require('~/models/Agent');
|
} = require('~/models/Agent');
|
||||||
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
|
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||||
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
|
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
|
||||||
|
const { filterFile } = require('~/server/services/Files/process');
|
||||||
const { updateAction, getActions } = require('~/models/Action');
|
const { updateAction, getActions } = require('~/models/Action');
|
||||||
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
const { updateAgentProjects } = require('~/models/Agent');
|
const { updateAgentProjects } = require('~/models/Agent');
|
||||||
const { getProjectByName } = require('~/models/Project');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { deleteFileByFilter } = require('~/models/File');
|
|
||||||
const { revertAgentVersion } = require('~/models/Agent');
|
const { revertAgentVersion } = require('~/models/Agent');
|
||||||
const { logger } = require('~/config');
|
const { deleteFileByFilter } = require('~/models/File');
|
||||||
|
|
||||||
const systemTools = {
|
const systemTools = {
|
||||||
[Tools.execute_code]: true,
|
[Tools.execute_code]: true,
|
||||||
|
|
@ -47,8 +47,9 @@ const createAgentHandler = async (req, res) => {
|
||||||
|
|
||||||
agentData.tools = [];
|
agentData.tools = [];
|
||||||
|
|
||||||
|
const availableTools = await getCachedTools({ includeGlobal: true });
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
if (req.app.locals.availableTools[tool]) {
|
if (availableTools[tool]) {
|
||||||
agentData.tools.push(tool);
|
agentData.tools.push(tool);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,7 +446,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(req.file.path);
|
await fs.unlink(req.file.path);
|
||||||
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
|
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
|
||||||
} catch (error) {
|
} catch {
|
||||||
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
|
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { FileContext } = require('librechat-data-provider');
|
const { FileContext } = require('librechat-data-provider');
|
||||||
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
|
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
|
||||||
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
|
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
|
||||||
|
|
@ -6,9 +7,9 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { deleteAssistantActions } = require('~/server/services/ActionService');
|
const { deleteAssistantActions } = require('~/server/services/ActionService');
|
||||||
const { updateAssistantDoc, getAssistants } = require('~/models/Assistant');
|
const { updateAssistantDoc, getAssistants } = require('~/models/Assistant');
|
||||||
const { getOpenAIClient, fetchAssistants } = require('./helpers');
|
const { getOpenAIClient, fetchAssistants } = require('./helpers');
|
||||||
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
const { manifestToolMap } = require('~/app/clients/tools');
|
const { manifestToolMap } = require('~/app/clients/tools');
|
||||||
const { deleteFileByFilter } = require('~/models/File');
|
const { deleteFileByFilter } = require('~/models/File');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an assistant.
|
* Create an assistant.
|
||||||
|
|
@ -30,21 +31,20 @@ const createAssistant = async (req, res) => {
|
||||||
delete assistantData.conversation_starters;
|
delete assistantData.conversation_starters;
|
||||||
delete assistantData.append_current_datetime;
|
delete assistantData.append_current_datetime;
|
||||||
|
|
||||||
|
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||||
|
|
||||||
assistantData.tools = tools
|
assistantData.tools = tools
|
||||||
.map((tool) => {
|
.map((tool) => {
|
||||||
if (typeof tool !== 'string') {
|
if (typeof tool !== 'string') {
|
||||||
return tool;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolDefinitions = req.app.locals.availableTools;
|
|
||||||
const toolDef = toolDefinitions[tool];
|
const toolDef = toolDefinitions[tool];
|
||||||
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
||||||
return (
|
return Object.entries(toolDefinitions)
|
||||||
Object.entries(toolDefinitions)
|
.filter(([key]) => key.startsWith(`${tool}_`))
|
||||||
.filter(([key]) => key.startsWith(`${tool}_`))
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
.map(([_, val]) => val);
|
||||||
.map(([_, val]) => val)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return toolDef;
|
return toolDef;
|
||||||
|
|
@ -135,21 +135,21 @@ const patchAssistant = async (req, res) => {
|
||||||
append_current_datetime,
|
append_current_datetime,
|
||||||
...updateData
|
...updateData
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||||
|
|
||||||
updateData.tools = (updateData.tools ?? [])
|
updateData.tools = (updateData.tools ?? [])
|
||||||
.map((tool) => {
|
.map((tool) => {
|
||||||
if (typeof tool !== 'string') {
|
if (typeof tool !== 'string') {
|
||||||
return tool;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolDefinitions = req.app.locals.availableTools;
|
|
||||||
const toolDef = toolDefinitions[tool];
|
const toolDef = toolDefinitions[tool];
|
||||||
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
||||||
return (
|
return Object.entries(toolDefinitions)
|
||||||
Object.entries(toolDefinitions)
|
.filter(([key]) => key.startsWith(`${tool}_`))
|
||||||
.filter(([key]) => key.startsWith(`${tool}_`))
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
.map(([_, val]) => val);
|
||||||
.map(([_, val]) => val)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return toolDef;
|
return toolDef;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { ToolCallTypes } = require('librechat-data-provider');
|
const { ToolCallTypes } = require('librechat-data-provider');
|
||||||
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
|
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
|
||||||
const { validateAndUpdateTool } = require('~/server/services/ActionService');
|
const { validateAndUpdateTool } = require('~/server/services/ActionService');
|
||||||
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
const { updateAssistantDoc } = require('~/models/Assistant');
|
const { updateAssistantDoc } = require('~/models/Assistant');
|
||||||
const { manifestToolMap } = require('~/app/clients/tools');
|
const { manifestToolMap } = require('~/app/clients/tools');
|
||||||
const { getOpenAIClient } = require('./helpers');
|
const { getOpenAIClient } = require('./helpers');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an assistant.
|
* Create an assistant.
|
||||||
|
|
@ -27,21 +28,20 @@ const createAssistant = async (req, res) => {
|
||||||
delete assistantData.conversation_starters;
|
delete assistantData.conversation_starters;
|
||||||
delete assistantData.append_current_datetime;
|
delete assistantData.append_current_datetime;
|
||||||
|
|
||||||
|
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||||
|
|
||||||
assistantData.tools = tools
|
assistantData.tools = tools
|
||||||
.map((tool) => {
|
.map((tool) => {
|
||||||
if (typeof tool !== 'string') {
|
if (typeof tool !== 'string') {
|
||||||
return tool;
|
return tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolDefinitions = req.app.locals.availableTools;
|
|
||||||
const toolDef = toolDefinitions[tool];
|
const toolDef = toolDefinitions[tool];
|
||||||
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
||||||
return (
|
return Object.entries(toolDefinitions)
|
||||||
Object.entries(toolDefinitions)
|
.filter(([key]) => key.startsWith(`${tool}_`))
|
||||||
.filter(([key]) => key.startsWith(`${tool}_`))
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
.map(([_, val]) => val);
|
||||||
.map(([_, val]) => val)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return toolDef;
|
return toolDef;
|
||||||
|
|
@ -125,13 +125,13 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
|
||||||
|
|
||||||
let hasFileSearch = false;
|
let hasFileSearch = false;
|
||||||
for (const tool of updateData.tools ?? []) {
|
for (const tool of updateData.tools ?? []) {
|
||||||
const toolDefinitions = req.app.locals.availableTools;
|
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||||
let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
|
let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
|
||||||
|
|
||||||
if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
||||||
actualTool = Object.entries(toolDefinitions)
|
actualTool = Object.entries(toolDefinitions)
|
||||||
.filter(([key]) => key.startsWith(`${tool}_`))
|
.filter(([key]) => key.startsWith(`${tool}_`))
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
.map(([_, val]) => val);
|
.map(([_, val]) => val);
|
||||||
} else if (!actualTool) {
|
} else if (!actualTool) {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
require('module-alias')({ base: path.resolve(__dirname, '..') });
|
require('module-alias')({ base: path.resolve(__dirname, '..') });
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const compression = require('compression');
|
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const mongoSanitize = require('express-mongo-sanitize');
|
const compression = require('compression');
|
||||||
const fs = require('fs');
|
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
|
const { isEnabled } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const mongoSanitize = require('express-mongo-sanitize');
|
||||||
const { connectDb, indexSync } = require('~/db');
|
const { connectDb, indexSync } = require('~/db');
|
||||||
|
|
||||||
const { jwtLogin, passportLogin } = require('~/strategies');
|
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const { ldapLogin } = require('~/strategies');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||||
|
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||||
const errorController = require('./controllers/ErrorController');
|
const errorController = require('./controllers/ErrorController');
|
||||||
|
const initializeMCP = require('./services/initializeMCP');
|
||||||
const configureSocialLogins = require('./socialLogins');
|
const configureSocialLogins = require('./socialLogins');
|
||||||
const AppService = require('./services/AppService');
|
const AppService = require('./services/AppService');
|
||||||
const staticCache = require('./utils/staticCache');
|
const staticCache = require('./utils/staticCache');
|
||||||
|
|
@ -39,7 +39,9 @@ const startServer = async () => {
|
||||||
await connectDb();
|
await connectDb();
|
||||||
|
|
||||||
logger.info('Connected to MongoDB');
|
logger.info('Connected to MongoDB');
|
||||||
await indexSync();
|
indexSync().catch((err) => {
|
||||||
|
logger.error('[indexSync] Background sync failed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
app.set('trust proxy', trusted_proxy);
|
app.set('trust proxy', trusted_proxy);
|
||||||
|
|
@ -119,6 +121,7 @@ const startServer = async () => {
|
||||||
app.use('/api/bedrock', routes.bedrock);
|
app.use('/api/bedrock', routes.bedrock);
|
||||||
app.use('/api/memories', routes.memories);
|
app.use('/api/memories', routes.memories);
|
||||||
app.use('/api/tags', routes.tags);
|
app.use('/api/tags', routes.tags);
|
||||||
|
app.use('/api/mcp', routes.mcp);
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.set({
|
res.set({
|
||||||
|
|
@ -142,6 +145,8 @@ const startServer = async () => {
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeMCP(app);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -184,5 +189,5 @@ process.on('uncaughtException', (err) => {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// export app for easier testing purposes
|
/** Export app for easier testing purposes */
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
|
const { isEnabled } = require('@librechat/api');
|
||||||
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
|
const { Constants, ViolationTypes, Time } = require('librechat-data-provider');
|
||||||
const { searchConversation } = require('~/models/Conversation');
|
const { searchConversation } = require('~/models/Conversation');
|
||||||
const denyRequest = require('~/server/middleware/denyRequest');
|
const denyRequest = require('~/server/middleware/denyRequest');
|
||||||
const { logViolation, getLogStores } = require('~/cache');
|
const { logViolation, getLogStores } = require('~/cache');
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
|
|
||||||
const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};
|
const { USE_REDIS, CONVO_ACCESS_VIOLATION_SCORE: score = 0 } = process.env ?? {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { getAccessToken } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys } = require('librechat-data-provider');
|
const { CacheKeys } = require('librechat-data-provider');
|
||||||
const { getAccessToken } = require('~/server/services/TokenService');
|
const { findToken, updateToken, createToken } = require('~/models');
|
||||||
const { logger, getFlowStateManager } = require('~/config');
|
const { getFlowStateManager } = require('~/config');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -28,18 +30,19 @@ router.get('/:action_id/oauth/callback', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
decodedState = jwt.verify(state, JWT_SECRET);
|
decodedState = jwt.verify(state, JWT_SECRET);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
logger.error('Error verifying state parameter:', err);
|
||||||
await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter');
|
await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter');
|
||||||
return res.status(400).send('Invalid or expired state parameter');
|
return res.redirect('/oauth/error?error=invalid_state');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decodedState.action_id !== action_id) {
|
if (decodedState.action_id !== action_id) {
|
||||||
await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter');
|
await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter');
|
||||||
return res.status(400).send('Mismatched action ID in state parameter');
|
return res.redirect('/oauth/error?error=invalid_state');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!decodedState.user) {
|
if (!decodedState.user) {
|
||||||
await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter');
|
await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter');
|
||||||
return res.status(400).send('Invalid user ID in state parameter');
|
return res.redirect('/oauth/error?error=invalid_state');
|
||||||
}
|
}
|
||||||
identifier = `${decodedState.user}:${action_id}`;
|
identifier = `${decodedState.user}:${action_id}`;
|
||||||
const flowState = await flowManager.getFlowState(identifier, 'oauth');
|
const flowState = await flowManager.getFlowState(identifier, 'oauth');
|
||||||
|
|
@ -47,91 +50,34 @@ router.get('/:action_id/oauth/callback', async (req, res) => {
|
||||||
throw new Error('OAuth flow not found');
|
throw new Error('OAuth flow not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = await getAccessToken({
|
const tokenData = await getAccessToken(
|
||||||
code,
|
{
|
||||||
userId: decodedState.user,
|
code,
|
||||||
identifier,
|
userId: decodedState.user,
|
||||||
client_url: flowState.metadata.client_url,
|
identifier,
|
||||||
redirect_uri: flowState.metadata.redirect_uri,
|
client_url: flowState.metadata.client_url,
|
||||||
token_exchange_method: flowState.metadata.token_exchange_method,
|
redirect_uri: flowState.metadata.redirect_uri,
|
||||||
/** Encrypted values */
|
token_exchange_method: flowState.metadata.token_exchange_method,
|
||||||
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
|
/** Encrypted values */
|
||||||
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
|
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
|
||||||
});
|
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
findToken,
|
||||||
|
updateToken,
|
||||||
|
createToken,
|
||||||
|
},
|
||||||
|
);
|
||||||
await flowManager.completeFlow(identifier, 'oauth', tokenData);
|
await flowManager.completeFlow(identifier, 'oauth', tokenData);
|
||||||
res.send(`
|
|
||||||
<!DOCTYPE html>
|
/** Redirect to React success page */
|
||||||
<html>
|
const serverName = flowState.metadata?.action_name || `Action ${action_id}`;
|
||||||
<head>
|
const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
|
||||||
<title>Authentication Successful</title>
|
res.redirect(redirectUrl);
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
|
||||||
background-color: rgb(249, 250, 251);
|
|
||||||
margin: 0;
|
|
||||||
padding: 2rem;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 28rem;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.heading {
|
|
||||||
color: rgb(17, 24, 39);
|
|
||||||
font-size: 1.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.description {
|
|
||||||
color: rgb(75, 85, 99);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.countdown {
|
|
||||||
color: rgb(99, 102, 241);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<h1 class="heading">Authentication Successful</h1>
|
|
||||||
<p class="description">
|
|
||||||
Your authentication was successful. This window will close in
|
|
||||||
<span class="countdown" id="countdown">3</span> seconds.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
let secondsLeft = 3;
|
|
||||||
const countdownElement = document.getElementById('countdown');
|
|
||||||
|
|
||||||
const countdown = setInterval(() => {
|
|
||||||
secondsLeft--;
|
|
||||||
countdownElement.textContent = secondsLeft;
|
|
||||||
|
|
||||||
if (secondsLeft <= 0) {
|
|
||||||
clearInterval(countdown);
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in OAuth callback:', error);
|
logger.error('Error in OAuth callback:', error);
|
||||||
await flowManager.failFlow(identifier, 'oauth', error);
|
await flowManager.failFlow(identifier, 'oauth', error);
|
||||||
res.status(500).send('Authentication failed. Please try again.');
|
res.redirect('/oauth/error?error=callback_failed');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
|
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
|
||||||
|
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
|
||||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||||
const { getProjectByName } = require('~/models/Project');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const emailLoginEnabled =
|
const emailLoginEnabled =
|
||||||
|
|
@ -21,6 +22,7 @@ const publicSharedLinksEnabled =
|
||||||
|
|
||||||
router.get('/', async function (req, res) {
|
router.get('/', async function (req, res) {
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
|
||||||
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
||||||
if (cachedStartupConfig) {
|
if (cachedStartupConfig) {
|
||||||
res.send(cachedStartupConfig);
|
res.send(cachedStartupConfig);
|
||||||
|
|
@ -96,6 +98,18 @@ router.get('/', async function (req, res) {
|
||||||
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
|
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
|
||||||
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
|
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
payload.mcpServers = {};
|
||||||
|
const config = await getCustomConfig();
|
||||||
|
if (config?.mcpServers != null) {
|
||||||
|
for (const serverName in config.mcpServers) {
|
||||||
|
const serverConfig = config.mcpServers[serverName];
|
||||||
|
payload.mcpServers[serverName] = {
|
||||||
|
customUserVars: serverConfig?.customUserVars || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {TCustomConfig['webSearch']} */
|
/** @type {TCustomConfig['webSearch']} */
|
||||||
const webSearchConfig = req.app.locals.webSearch;
|
const webSearchConfig = req.app.locals.webSearch;
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const edit = require('./edit');
|
||||||
const keys = require('./keys');
|
const keys = require('./keys');
|
||||||
const user = require('./user');
|
const user = require('./user');
|
||||||
const ask = require('./ask');
|
const ask = require('./ask');
|
||||||
|
const mcp = require('./mcp');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ask,
|
ask,
|
||||||
|
|
@ -58,4 +59,5 @@ module.exports = {
|
||||||
assistants,
|
assistants,
|
||||||
categories,
|
categories,
|
||||||
staticRoute,
|
staticRoute,
|
||||||
|
mcp,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
205
api/server/routes/mcp.js
Normal file
205
api/server/routes/mcp.js
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
const { Router } = require('express');
|
||||||
|
const { MCPOAuthHandler } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { CacheKeys } = require('librechat-data-provider');
|
||||||
|
const { requireJwtAuth } = require('~/server/middleware');
|
||||||
|
const { getFlowStateManager } = require('~/config');
|
||||||
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate OAuth flow
|
||||||
|
* This endpoint is called when the user clicks the auth link in the UI
|
||||||
|
*/
|
||||||
|
router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { serverName } = req.params;
|
||||||
|
const { userId, flowId } = req.query;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// Verify the userId matches the authenticated user
|
||||||
|
if (userId !== user.id) {
|
||||||
|
return res.status(403).json({ error: 'User mismatch' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[MCP OAuth] Initiate request', { serverName, userId, flowId });
|
||||||
|
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
|
|
||||||
|
/** Flow state to retrieve OAuth config */
|
||||||
|
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||||
|
if (!flowState) {
|
||||||
|
logger.error('[MCP OAuth] Flow state not found', { flowId });
|
||||||
|
return res.status(404).json({ error: 'Flow not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serverUrl, oauth: oauthConfig } = flowState.metadata || {};
|
||||||
|
if (!serverUrl || !oauthConfig) {
|
||||||
|
logger.error('[MCP OAuth] Missing server URL or OAuth config in flow state');
|
||||||
|
return res.status(400).json({ error: 'Invalid flow state' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
|
||||||
|
serverName,
|
||||||
|
serverUrl,
|
||||||
|
userId,
|
||||||
|
oauthConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
|
||||||
|
|
||||||
|
// Redirect user to the authorization URL
|
||||||
|
res.redirect(authorizationUrl);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[MCP OAuth] Failed to initiate OAuth', error);
|
||||||
|
res.status(500).json({ error: 'Failed to initiate OAuth' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth callback handler
|
||||||
|
* This handles the OAuth callback after the user has authorized the application
|
||||||
|
*/
|
||||||
|
router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { serverName } = req.params;
|
||||||
|
const { code, state, error: oauthError } = req.query;
|
||||||
|
|
||||||
|
logger.debug('[MCP OAuth] Callback received', {
|
||||||
|
serverName,
|
||||||
|
code: code ? 'present' : 'missing',
|
||||||
|
state,
|
||||||
|
error: oauthError,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (oauthError) {
|
||||||
|
logger.error('[MCP OAuth] OAuth error received', { error: oauthError });
|
||||||
|
return res.redirect(`/oauth/error?error=${encodeURIComponent(String(oauthError))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || typeof code !== 'string') {
|
||||||
|
logger.error('[MCP OAuth] Missing or invalid code');
|
||||||
|
return res.redirect('/oauth/error?error=missing_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state || typeof state !== 'string') {
|
||||||
|
logger.error('[MCP OAuth] Missing or invalid state');
|
||||||
|
return res.redirect('/oauth/error?error=missing_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract flow ID from state
|
||||||
|
const flowId = state;
|
||||||
|
logger.debug('[MCP OAuth] Using flow ID from state', { flowId });
|
||||||
|
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
|
|
||||||
|
logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
|
||||||
|
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
|
||||||
|
|
||||||
|
if (!flowState) {
|
||||||
|
logger.error('[MCP OAuth] Flow state not found for flowId:', flowId);
|
||||||
|
return res.redirect('/oauth/error?error=invalid_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('[MCP OAuth] Flow state details', {
|
||||||
|
serverName: flowState.serverName,
|
||||||
|
userId: flowState.userId,
|
||||||
|
hasMetadata: !!flowState.metadata,
|
||||||
|
hasClientInfo: !!flowState.clientInfo,
|
||||||
|
hasCodeVerifier: !!flowState.codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Complete the OAuth flow
|
||||||
|
logger.debug('[MCP OAuth] Completing OAuth flow');
|
||||||
|
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
|
||||||
|
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
||||||
|
|
||||||
|
// For system-level OAuth, we need to store the tokens and retry the connection
|
||||||
|
if (flowState.userId === 'system') {
|
||||||
|
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ID of the flow that the tool/connection is waiting for */
|
||||||
|
const toolFlowId = flowState.metadata?.toolFlowId;
|
||||||
|
if (toolFlowId) {
|
||||||
|
logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
|
||||||
|
await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redirect to success page with flowId and serverName */
|
||||||
|
const redirectUrl = `/oauth/success?serverName=${encodeURIComponent(serverName)}`;
|
||||||
|
res.redirect(redirectUrl);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[MCP OAuth] OAuth callback error', error);
|
||||||
|
res.redirect('/oauth/error?error=callback_failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OAuth tokens for a completed flow
|
||||||
|
* This is primarily for user-level OAuth flows
|
||||||
|
*/
|
||||||
|
router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
if (!user?.id) {
|
||||||
|
return res.status(401).json({ error: 'User not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow system flows or user-owned flows
|
||||||
|
if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) {
|
||||||
|
return res.status(403).json({ error: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
|
|
||||||
|
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||||
|
if (!flowState) {
|
||||||
|
return res.status(404).json({ error: 'Flow not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flowState.status !== 'COMPLETED') {
|
||||||
|
return res.status(400).json({ error: 'Flow not completed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ tokens: flowState.result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[MCP OAuth] Failed to get tokens', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get tokens' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check OAuth flow status
|
||||||
|
* This endpoint can be used to poll the status of an OAuth flow
|
||||||
|
*/
|
||||||
|
router.get('/oauth/status/:flowId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { flowId } = req.params;
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
|
|
||||||
|
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||||
|
if (!flowState) {
|
||||||
|
return res.status(404).json({ error: 'Flow not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: flowState.status,
|
||||||
|
completed: flowState.status === 'COMPLETED',
|
||||||
|
failed: flowState.status === 'FAILED',
|
||||||
|
error: flowState.error,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[MCP OAuth] Failed to get flow status', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get flow status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -47,7 +47,9 @@ const oauthHandler = async (req, res) => {
|
||||||
|
|
||||||
router.get('/error', (req, res) => {
|
router.get('/error', (req, res) => {
|
||||||
// A single error message is pushed by passport when authentication fails.
|
// A single error message is pushed by passport when authentication fails.
|
||||||
logger.error('Error in OAuth authentication:', { message: req.session.messages.pop() });
|
logger.error('Error in OAuth authentication:', {
|
||||||
|
message: req.session?.messages?.pop() || 'Unknown error',
|
||||||
|
});
|
||||||
|
|
||||||
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
|
// Redirect to login page with auth_failed parameter to prevent infinite redirect loops
|
||||||
res.redirect(`${domains.client}/login?redirect=false`);
|
res.redirect(`${domains.client}/login?redirect=false`);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { isEnabled } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
getSharedLink,
|
|
||||||
getSharedMessages,
|
getSharedMessages,
|
||||||
createSharedLink,
|
createSharedLink,
|
||||||
updateSharedLink,
|
updateSharedLink,
|
||||||
getSharedLinks,
|
|
||||||
deleteSharedLink,
|
deleteSharedLink,
|
||||||
} = require('~/models/Share');
|
getSharedLinks,
|
||||||
|
getSharedLink,
|
||||||
|
} = require('~/models');
|
||||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -35,6 +35,7 @@ if (allowSharedLinks) {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Error getting shared messages:', error);
|
||||||
res.status(500).json({ message: 'Error getting shared messages' });
|
res.status(500).json({ message: 'Error getting shared messages' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -54,9 +55,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
|
||||||
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
|
sortDirection: ['asc', 'desc'].includes(req.query.sortDirection)
|
||||||
? req.query.sortDirection
|
? req.query.sortDirection
|
||||||
: 'desc',
|
: 'desc',
|
||||||
search: req.query.search
|
search: req.query.search ? decodeURIComponent(req.query.search.trim()) : undefined,
|
||||||
? decodeURIComponent(req.query.search.trim())
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await getSharedLinks(
|
const result = await getSharedLinks(
|
||||||
|
|
@ -75,7 +74,7 @@ router.get('/', requireJwtAuth, async (req, res) => {
|
||||||
hasNextPage: result.hasNextPage,
|
hasNextPage: result.hasNextPage,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting shared links:', error);
|
logger.error('Error getting shared links:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
message: 'Error getting shared links',
|
message: 'Error getting shared links',
|
||||||
error: error.message,
|
error: error.message,
|
||||||
|
|
@ -93,6 +92,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
|
||||||
conversationId: req.params.conversationId,
|
conversationId: req.params.conversationId,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Error getting shared link:', error);
|
||||||
res.status(500).json({ message: 'Error getting shared link' });
|
res.status(500).json({ message: 'Error getting shared link' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -106,6 +106,7 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Error creating shared link:', error);
|
||||||
res.status(500).json({ message: 'Error creating shared link' });
|
res.status(500).json({ message: 'Error creating shared link' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -119,6 +120,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Error updating shared link:', error);
|
||||||
res.status(500).json({ message: 'Error updating shared link' });
|
res.status(500).json({ message: 'Error updating shared link' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -133,7 +135,8 @@ router.delete('/:shareId', requireJwtAuth, async (req, res) => {
|
||||||
|
|
||||||
return res.status(200).json(result);
|
return res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({ message: error.message });
|
logger.error('Error deleting shared link:', error);
|
||||||
|
return res.status(400).json({ message: 'Error deleting shared link' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ const { nanoid } = require('nanoid');
|
||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { GraphEvents, sleep } = require('@librechat/agents');
|
const { GraphEvents, sleep } = require('@librechat/agents');
|
||||||
const { sendEvent, logAxiosError } = require('@librechat/api');
|
const {
|
||||||
|
sendEvent,
|
||||||
|
encryptV2,
|
||||||
|
decryptV2,
|
||||||
|
logAxiosError,
|
||||||
|
refreshAccessToken,
|
||||||
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Time,
|
Time,
|
||||||
CacheKeys,
|
CacheKeys,
|
||||||
|
|
@ -14,13 +20,11 @@ const {
|
||||||
isImageVisionTool,
|
isImageVisionTool,
|
||||||
actionDomainSeparator,
|
actionDomainSeparator,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { refreshAccessToken } = require('~/server/services/TokenService');
|
const { findToken, updateToken, createToken } = require('~/models');
|
||||||
const { encryptV2, decryptV2 } = require('~/server/utils/crypto');
|
|
||||||
const { getActions, deleteActions } = require('~/models/Action');
|
const { getActions, deleteActions } = require('~/models/Action');
|
||||||
const { deleteAssistant } = require('~/models/Assistant');
|
const { deleteAssistant } = require('~/models/Assistant');
|
||||||
const { getFlowStateManager } = require('~/config');
|
const { getFlowStateManager } = require('~/config');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
const { findToken } = require('~/models');
|
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET;
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
const toolNameRegex = /^[a-zA-Z0-9_-]+$/;
|
const toolNameRegex = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
|
@ -258,15 +262,22 @@ async function createActionTool({
|
||||||
try {
|
try {
|
||||||
const refresh_token = await decryptV2(refreshTokenData.token);
|
const refresh_token = await decryptV2(refreshTokenData.token);
|
||||||
const refreshTokens = async () =>
|
const refreshTokens = async () =>
|
||||||
await refreshAccessToken({
|
await refreshAccessToken(
|
||||||
userId,
|
{
|
||||||
identifier,
|
userId,
|
||||||
refresh_token,
|
identifier,
|
||||||
client_url: metadata.auth.client_url,
|
refresh_token,
|
||||||
encrypted_oauth_client_id: encrypted.oauth_client_id,
|
client_url: metadata.auth.client_url,
|
||||||
token_exchange_method: metadata.auth.token_exchange_method,
|
encrypted_oauth_client_id: encrypted.oauth_client_id,
|
||||||
encrypted_oauth_client_secret: encrypted.oauth_client_secret,
|
token_exchange_method: metadata.auth.token_exchange_method,
|
||||||
});
|
encrypted_oauth_client_secret: encrypted.oauth_client_secret,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
findToken,
|
||||||
|
updateToken,
|
||||||
|
createToken,
|
||||||
|
},
|
||||||
|
);
|
||||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
const flowManager = getFlowStateManager(flowsCache);
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
const refreshData = await flowManager.createFlowWithHandler(
|
const refreshData = await flowManager.createFlowWithHandler(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
const {
|
const {
|
||||||
FileSources,
|
FileSources,
|
||||||
loadOCRConfig,
|
loadOCRConfig,
|
||||||
processMCPEnv,
|
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
loadMemoryConfig,
|
loadMemoryConfig,
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
|
|
@ -28,7 +27,7 @@ const { initializeS3 } = require('./Files/S3/initialize');
|
||||||
const { loadAndFormatTools } = require('./ToolService');
|
const { loadAndFormatTools } = require('./ToolService');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { initializeRoles } = require('~/models');
|
const { initializeRoles } = require('~/models');
|
||||||
const { getMCPManager } = require('~/config');
|
const { setCachedTools } = require('./Config');
|
||||||
const paths = require('~/config/paths');
|
const paths = require('~/config/paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -76,11 +75,10 @@ const AppService = async (app) => {
|
||||||
directory: paths.structuredTools,
|
directory: paths.structuredTools,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.mcpServers != null) {
|
await setCachedTools(availableTools, { isGlobal: true });
|
||||||
const mcpManager = getMCPManager();
|
|
||||||
await mcpManager.initializeMCP(config.mcpServers, processMCPEnv);
|
// Store MCP config for later initialization
|
||||||
await mcpManager.mapAvailableTools(availableTools);
|
const mcpConfig = config.mcpServers || null;
|
||||||
}
|
|
||||||
|
|
||||||
const socialLogins =
|
const socialLogins =
|
||||||
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
|
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
|
||||||
|
|
@ -96,11 +94,11 @@ const AppService = async (app) => {
|
||||||
socialLogins,
|
socialLogins,
|
||||||
filteredTools,
|
filteredTools,
|
||||||
includedTools,
|
includedTools,
|
||||||
availableTools,
|
|
||||||
imageOutputType,
|
imageOutputType,
|
||||||
interfaceConfig,
|
interfaceConfig,
|
||||||
turnstileConfig,
|
turnstileConfig,
|
||||||
balance,
|
balance,
|
||||||
|
mcpConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
const agentsDefaults = agentsConfigSetup(config);
|
const agentsDefaults = agentsConfigSetup(config);
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,25 @@ jest.mock('~/models', () => ({
|
||||||
jest.mock('~/models/Role', () => ({
|
jest.mock('~/models/Role', () => ({
|
||||||
updateAccessPermissions: jest.fn(),
|
updateAccessPermissions: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
jest.mock('./Config', () => ({
|
||||||
|
setCachedTools: jest.fn(),
|
||||||
|
getCachedTools: jest.fn().mockResolvedValue({
|
||||||
|
ExampleTool: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
description: 'Example tool function',
|
||||||
|
name: 'exampleFunction',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
param1: { type: 'string', description: 'An example parameter' },
|
||||||
|
},
|
||||||
|
required: ['param1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
jest.mock('./ToolService', () => ({
|
jest.mock('./ToolService', () => ({
|
||||||
loadAndFormatTools: jest.fn().mockReturnValue({
|
loadAndFormatTools: jest.fn().mockReturnValue({
|
||||||
ExampleTool: {
|
ExampleTool: {
|
||||||
|
|
@ -121,22 +140,9 @@ describe('AppService', () => {
|
||||||
sidePanel: true,
|
sidePanel: true,
|
||||||
presets: true,
|
presets: true,
|
||||||
}),
|
}),
|
||||||
|
mcpConfig: null,
|
||||||
turnstileConfig: mockedTurnstileConfig,
|
turnstileConfig: mockedTurnstileConfig,
|
||||||
modelSpecs: undefined,
|
modelSpecs: undefined,
|
||||||
availableTools: {
|
|
||||||
ExampleTool: {
|
|
||||||
type: 'function',
|
|
||||||
function: expect.objectContaining({
|
|
||||||
description: 'Example tool function',
|
|
||||||
name: 'exampleFunction',
|
|
||||||
parameters: expect.objectContaining({
|
|
||||||
type: 'object',
|
|
||||||
properties: expect.any(Object),
|
|
||||||
required: expect.arrayContaining(['param1']),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
paths: expect.anything(),
|
paths: expect.anything(),
|
||||||
ocr: expect.anything(),
|
ocr: expect.anything(),
|
||||||
imageOutputType: expect.any(String),
|
imageOutputType: expect.any(String),
|
||||||
|
|
@ -223,14 +229,41 @@ describe('AppService', () => {
|
||||||
|
|
||||||
it('should load and format tools accurately with defined structure', async () => {
|
it('should load and format tools accurately with defined structure', async () => {
|
||||||
const { loadAndFormatTools } = require('./ToolService');
|
const { loadAndFormatTools } = require('./ToolService');
|
||||||
|
const { setCachedTools, getCachedTools } = require('./Config');
|
||||||
|
|
||||||
await AppService(app);
|
await AppService(app);
|
||||||
|
|
||||||
expect(loadAndFormatTools).toHaveBeenCalledWith({
|
expect(loadAndFormatTools).toHaveBeenCalledWith({
|
||||||
|
adminFilter: undefined,
|
||||||
|
adminIncluded: undefined,
|
||||||
directory: expect.anything(),
|
directory: expect.anything(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(app.locals.availableTools.ExampleTool).toBeDefined();
|
// Verify setCachedTools was called with the tools
|
||||||
expect(app.locals.availableTools.ExampleTool).toEqual({
|
expect(setCachedTools).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
ExampleTool: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
description: 'Example tool function',
|
||||||
|
name: 'exampleFunction',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
param1: { type: 'string', description: 'An example parameter' },
|
||||||
|
},
|
||||||
|
required: ['param1'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ isGlobal: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify we can retrieve the tools from cache
|
||||||
|
const cachedTools = await getCachedTools({ includeGlobal: true });
|
||||||
|
expect(cachedTools.ExampleTool).toBeDefined();
|
||||||
|
expect(cachedTools.ExampleTool).toEqual({
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
description: 'Example tool function',
|
description: 'Example tool function',
|
||||||
|
|
@ -535,7 +568,6 @@ describe('AppService updating app.locals and issuing warnings', () => {
|
||||||
|
|
||||||
expect(app.locals).toBeDefined();
|
expect(app.locals).toBeDefined();
|
||||||
expect(app.locals.paths).toBeDefined();
|
expect(app.locals.paths).toBeDefined();
|
||||||
expect(app.locals.availableTools).toBeDefined();
|
|
||||||
expect(app.locals.fileStrategy).toEqual(FileSources.local);
|
expect(app.locals.fileStrategy).toEqual(FileSources.local);
|
||||||
expect(app.locals.socialLogins).toEqual(defaultSocialLogins);
|
expect(app.locals.socialLogins).toEqual(defaultSocialLogins);
|
||||||
expect(app.locals.balance).toEqual(
|
expect(app.locals.balance).toEqual(
|
||||||
|
|
@ -568,7 +600,6 @@ describe('AppService updating app.locals and issuing warnings', () => {
|
||||||
|
|
||||||
expect(app.locals).toBeDefined();
|
expect(app.locals).toBeDefined();
|
||||||
expect(app.locals.paths).toBeDefined();
|
expect(app.locals.paths).toBeDefined();
|
||||||
expect(app.locals.availableTools).toBeDefined();
|
|
||||||
expect(app.locals.fileStrategy).toEqual(customConfig.fileStrategy);
|
expect(app.locals.fileStrategy).toEqual(customConfig.fileStrategy);
|
||||||
expect(app.locals.socialLogins).toEqual(customConfig.registration.socialLogins);
|
expect(app.locals.socialLogins).toEqual(customConfig.registration.socialLogins);
|
||||||
expect(app.locals.balance).toEqual(customConfig.balance);
|
expect(app.locals.balance).toEqual(customConfig.balance);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { webcrypto } = require('node:crypto');
|
const { webcrypto } = require('node:crypto');
|
||||||
|
const { isEnabled } = require('@librechat/api');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { SystemRoles, errorsToString } = require('librechat-data-provider');
|
const { SystemRoles, errorsToString } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
findUser,
|
findUser,
|
||||||
|
|
@ -17,11 +19,10 @@ const {
|
||||||
deleteUserById,
|
deleteUserById,
|
||||||
generateRefreshToken,
|
generateRefreshToken,
|
||||||
} = require('~/models');
|
} = require('~/models');
|
||||||
const { isEnabled, checkEmailConfig, sendEmail } = require('~/server/utils');
|
|
||||||
const { isEmailDomainAllowed } = require('~/server/services/domains');
|
const { isEmailDomainAllowed } = require('~/server/services/domains');
|
||||||
|
const { checkEmailConfig, sendEmail } = require('~/server/utils');
|
||||||
const { getBalanceConfig } = require('~/server/services/Config');
|
const { getBalanceConfig } = require('~/server/services/Config');
|
||||||
const { registerSchema } = require('~/strategies/validators');
|
const { registerSchema } = require('~/strategies/validators');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const domains = {
|
const domains = {
|
||||||
client: process.env.DOMAIN_CLIENT,
|
client: process.env.DOMAIN_CLIENT,
|
||||||
|
|
|
||||||
258
api/server/services/Config/getCachedTools.js
Normal file
258
api/server/services/Config/getCachedTools.js
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
const { CacheKeys } = require('librechat-data-provider');
|
||||||
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache key generators for different tool access patterns
|
||||||
|
* These will support future permission-based caching
|
||||||
|
*/
|
||||||
|
const ToolCacheKeys = {
|
||||||
|
/** Global tools available to all users */
|
||||||
|
GLOBAL: 'tools:global',
|
||||||
|
/** Tools available to a specific user */
|
||||||
|
USER: (userId) => `tools:user:${userId}`,
|
||||||
|
/** Tools available to a specific role */
|
||||||
|
ROLE: (roleId) => `tools:role:${roleId}`,
|
||||||
|
/** Tools available to a specific group */
|
||||||
|
GROUP: (groupId) => `tools:group:${groupId}`,
|
||||||
|
/** Combined effective tools for a user (computed from all sources) */
|
||||||
|
EFFECTIVE: (userId) => `tools:effective:${userId}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves available tools from cache
|
||||||
|
* @function getCachedTools
|
||||||
|
* @param {Object} options - Options for retrieving tools
|
||||||
|
* @param {string} [options.userId] - User ID for user-specific tools
|
||||||
|
* @param {string[]} [options.roleIds] - Role IDs for role-based tools
|
||||||
|
* @param {string[]} [options.groupIds] - Group IDs for group-based tools
|
||||||
|
* @param {boolean} [options.includeGlobal=true] - Whether to include global tools
|
||||||
|
* @returns {Promise<Object|null>} The available tools object or null if not cached
|
||||||
|
*/
|
||||||
|
async function getCachedTools(options = {}) {
|
||||||
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
const { userId, roleIds = [], groupIds = [], includeGlobal = true } = options;
|
||||||
|
|
||||||
|
// For now, return global tools (current behavior)
|
||||||
|
// This will be expanded to merge tools from different sources
|
||||||
|
if (!userId && includeGlobal) {
|
||||||
|
return await cache.get(ToolCacheKeys.GLOBAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future implementation will merge tools from multiple sources
|
||||||
|
// based on user permissions, roles, and groups
|
||||||
|
if (userId) {
|
||||||
|
// Check if we have pre-computed effective tools for this user
|
||||||
|
const effectiveTools = await cache.get(ToolCacheKeys.EFFECTIVE(userId));
|
||||||
|
if (effectiveTools) {
|
||||||
|
return effectiveTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, compute from individual sources
|
||||||
|
const toolSources = [];
|
||||||
|
|
||||||
|
if (includeGlobal) {
|
||||||
|
const globalTools = await cache.get(ToolCacheKeys.GLOBAL);
|
||||||
|
if (globalTools) {
|
||||||
|
toolSources.push(globalTools);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-specific tools
|
||||||
|
const userTools = await cache.get(ToolCacheKeys.USER(userId));
|
||||||
|
if (userTools) {
|
||||||
|
toolSources.push(userTools);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role-based tools
|
||||||
|
for (const roleId of roleIds) {
|
||||||
|
const roleTools = await cache.get(ToolCacheKeys.ROLE(roleId));
|
||||||
|
if (roleTools) {
|
||||||
|
toolSources.push(roleTools);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group-based tools
|
||||||
|
for (const groupId of groupIds) {
|
||||||
|
const groupTools = await cache.get(ToolCacheKeys.GROUP(groupId));
|
||||||
|
if (groupTools) {
|
||||||
|
toolSources.push(groupTools);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all tool sources (for now, simple merge - future will handle conflicts)
|
||||||
|
if (toolSources.length > 0) {
|
||||||
|
return mergeToolSources(toolSources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets available tools in cache
|
||||||
|
* @function setCachedTools
|
||||||
|
* @param {Object} tools - The tools object to cache
|
||||||
|
* @param {Object} options - Options for caching tools
|
||||||
|
* @param {string} [options.userId] - User ID for user-specific tools
|
||||||
|
* @param {string} [options.roleId] - Role ID for role-based tools
|
||||||
|
* @param {string} [options.groupId] - Group ID for group-based tools
|
||||||
|
* @param {boolean} [options.isGlobal=false] - Whether these are global tools
|
||||||
|
* @param {number} [options.ttl] - Time to live in milliseconds
|
||||||
|
* @returns {Promise<boolean>} Whether the operation was successful
|
||||||
|
*/
|
||||||
|
async function setCachedTools(tools, options = {}) {
|
||||||
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
const { userId, roleId, groupId, isGlobal = false, ttl } = options;
|
||||||
|
|
||||||
|
let cacheKey;
|
||||||
|
if (isGlobal || (!userId && !roleId && !groupId)) {
|
||||||
|
cacheKey = ToolCacheKeys.GLOBAL;
|
||||||
|
} else if (userId) {
|
||||||
|
cacheKey = ToolCacheKeys.USER(userId);
|
||||||
|
} else if (roleId) {
|
||||||
|
cacheKey = ToolCacheKeys.ROLE(roleId);
|
||||||
|
} else if (groupId) {
|
||||||
|
cacheKey = ToolCacheKeys.GROUP(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cacheKey) {
|
||||||
|
throw new Error('Invalid cache key options provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await cache.set(cacheKey, tools, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates cached tools
|
||||||
|
* @function invalidateCachedTools
|
||||||
|
* @param {Object} options - Options for invalidating tools
|
||||||
|
* @param {string} [options.userId] - User ID to invalidate
|
||||||
|
* @param {string} [options.roleId] - Role ID to invalidate
|
||||||
|
* @param {string} [options.groupId] - Group ID to invalidate
|
||||||
|
* @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools
|
||||||
|
* @param {boolean} [options.invalidateEffective=true] - Whether to invalidate effective tools
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function invalidateCachedTools(options = {}) {
|
||||||
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
const { userId, roleId, groupId, invalidateGlobal = false, invalidateEffective = true } = options;
|
||||||
|
|
||||||
|
const keysToDelete = [];
|
||||||
|
|
||||||
|
if (invalidateGlobal) {
|
||||||
|
keysToDelete.push(ToolCacheKeys.GLOBAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
keysToDelete.push(ToolCacheKeys.USER(userId));
|
||||||
|
if (invalidateEffective) {
|
||||||
|
keysToDelete.push(ToolCacheKeys.EFFECTIVE(userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleId) {
|
||||||
|
keysToDelete.push(ToolCacheKeys.ROLE(roleId));
|
||||||
|
// TODO: In future, invalidate all users with this role
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
keysToDelete.push(ToolCacheKeys.GROUP(groupId));
|
||||||
|
// TODO: In future, invalidate all users in this group
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(keysToDelete.map((key) => cache.delete(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes and caches effective tools for a user
|
||||||
|
* @function computeEffectiveTools
|
||||||
|
* @param {string} userId - The user ID
|
||||||
|
* @param {Object} context - Context containing user's roles and groups
|
||||||
|
* @param {string[]} [context.roleIds=[]] - User's role IDs
|
||||||
|
* @param {string[]} [context.groupIds=[]] - User's group IDs
|
||||||
|
* @param {number} [ttl] - Time to live for the computed result
|
||||||
|
* @returns {Promise<Object>} The computed effective tools
|
||||||
|
*/
|
||||||
|
async function computeEffectiveTools(userId, context = {}, ttl) {
|
||||||
|
const { roleIds = [], groupIds = [] } = context;
|
||||||
|
|
||||||
|
// Get all tool sources
|
||||||
|
const tools = await getCachedTools({
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
groupIds,
|
||||||
|
includeGlobal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tools) {
|
||||||
|
// Cache the computed result
|
||||||
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
await cache.set(ToolCacheKeys.EFFECTIVE(userId), tools, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges multiple tool sources into a single tools object
|
||||||
|
* @function mergeToolSources
|
||||||
|
* @param {Object[]} sources - Array of tool objects to merge
|
||||||
|
* @returns {Object} Merged tools object
|
||||||
|
*/
|
||||||
|
function mergeToolSources(sources) {
|
||||||
|
// For now, simple merge that combines all tools
|
||||||
|
// Future implementation will handle:
|
||||||
|
// - Permission precedence (deny > allow)
|
||||||
|
// - Tool property conflicts
|
||||||
|
// - Metadata merging
|
||||||
|
const merged = {};
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
if (!source || typeof source !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [toolId, toolConfig] of Object.entries(source)) {
|
||||||
|
// Simple last-write-wins for now
|
||||||
|
// Future: merge based on permission levels
|
||||||
|
merged[toolId] = toolConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware-friendly function to get tools for a request
|
||||||
|
* @function getToolsForRequest
|
||||||
|
* @param {Object} req - Express request object
|
||||||
|
* @returns {Promise<Object|null>} Available tools for the request
|
||||||
|
*/
|
||||||
|
async function getToolsForRequest(req) {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
// For now, return global tools if no user
|
||||||
|
if (!userId) {
|
||||||
|
return getCachedTools({ includeGlobal: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future: Extract roles and groups from req.user
|
||||||
|
const roleIds = req.user?.roles || [];
|
||||||
|
const groupIds = req.user?.groups || [];
|
||||||
|
|
||||||
|
return getCachedTools({
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
groupIds,
|
||||||
|
includeGlobal: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ToolCacheKeys,
|
||||||
|
getCachedTools,
|
||||||
|
setCachedTools,
|
||||||
|
getToolsForRequest,
|
||||||
|
invalidateCachedTools,
|
||||||
|
computeEffectiveTools,
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { getUserMCPAuthMap } = require('@librechat/api');
|
||||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const { normalizeEndpointName, isEnabled } = require('~/server/utils');
|
const { normalizeEndpointName, isEnabled } = require('~/server/utils');
|
||||||
const loadCustomConfig = require('./loadCustomConfig');
|
const loadCustomConfig = require('./loadCustomConfig');
|
||||||
|
const { getCachedTools } = require('./getCachedTools');
|
||||||
|
const { findPluginAuthsByKeys } = require('~/models');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
const getLogStores = require('~/cache/getLogStores');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -50,4 +54,46 @@ const getCustomEndpointConfig = async (endpoint) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { getCustomConfig, getBalanceConfig, getCustomEndpointConfig };
|
async function createGetMCPAuthMap() {
|
||||||
|
const customConfig = await getCustomConfig();
|
||||||
|
const mcpServers = customConfig?.mcpServers;
|
||||||
|
const hasCustomUserVars = Object.values(mcpServers ?? {}).some((server) => server.customUserVars);
|
||||||
|
if (!hasCustomUserVars) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {GenericTool[]} [params.tools]
|
||||||
|
* @param {string} params.userId
|
||||||
|
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
|
||||||
|
*/
|
||||||
|
return async function ({ tools, userId }) {
|
||||||
|
try {
|
||||||
|
if (!tools || tools.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const appTools = await getCachedTools({
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return await getUserMCPAuthMap({
|
||||||
|
tools,
|
||||||
|
userId,
|
||||||
|
appTools,
|
||||||
|
findPluginAuthsByKeys,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCustomConfig,
|
||||||
|
getBalanceConfig,
|
||||||
|
createGetMCPAuthMap,
|
||||||
|
getCustomEndpointConfig,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const { config } = require('./EndpointService');
|
const { config } = require('./EndpointService');
|
||||||
|
const getCachedTools = require('./getCachedTools');
|
||||||
const getCustomConfig = require('./getCustomConfig');
|
const getCustomConfig = require('./getCustomConfig');
|
||||||
const loadCustomConfig = require('./loadCustomConfig');
|
const loadCustomConfig = require('./loadCustomConfig');
|
||||||
const loadConfigModels = require('./loadConfigModels');
|
const loadConfigModels = require('./loadConfigModels');
|
||||||
|
|
@ -14,6 +15,7 @@ module.exports = {
|
||||||
loadDefaultModels,
|
loadDefaultModels,
|
||||||
loadOverrideConfig,
|
loadOverrideConfig,
|
||||||
loadAsyncEndpoints,
|
loadAsyncEndpoints,
|
||||||
|
...getCachedTools,
|
||||||
...getCustomConfig,
|
...getCustomConfig,
|
||||||
...getEndpointsConfig,
|
...getEndpointsConfig,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -63,11 +63,17 @@ const initializeAgent = async ({
|
||||||
}
|
}
|
||||||
let currentFiles;
|
let currentFiles;
|
||||||
|
|
||||||
if (
|
const _modelOptions = structuredClone(
|
||||||
isInitialAgent &&
|
Object.assign(
|
||||||
conversationId != null &&
|
{ model: agent.model },
|
||||||
(agent.model_parameters?.resendFiles ?? true) === true
|
agent.model_parameters ?? { model: agent.model },
|
||||||
) {
|
isInitialAgent === true ? endpointOption?.model_parameters : {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { resendFiles = true, ...modelOptions } = _modelOptions;
|
||||||
|
|
||||||
|
if (isInitialAgent && conversationId != null && resendFiles) {
|
||||||
const fileIds = (await getConvoFiles(conversationId)) ?? [];
|
const fileIds = (await getConvoFiles(conversationId)) ?? [];
|
||||||
/** @type {Set<EToolResources>} */
|
/** @type {Set<EToolResources>} */
|
||||||
const toolResourceSet = new Set();
|
const toolResourceSet = new Set();
|
||||||
|
|
@ -117,15 +123,11 @@ const initializeAgent = async ({
|
||||||
getOptions = initCustom;
|
getOptions = initCustom;
|
||||||
agent.provider = Providers.OPENAI;
|
agent.provider = Providers.OPENAI;
|
||||||
}
|
}
|
||||||
const model_parameters = Object.assign(
|
|
||||||
{},
|
|
||||||
agent.model_parameters ?? { model: agent.model },
|
|
||||||
isInitialAgent === true ? endpointOption?.model_parameters : {},
|
|
||||||
);
|
|
||||||
const _endpointOption =
|
const _endpointOption =
|
||||||
isInitialAgent === true
|
isInitialAgent === true
|
||||||
? Object.assign({}, endpointOption, { model_parameters })
|
? Object.assign({}, endpointOption, { model_parameters: modelOptions })
|
||||||
: { model_parameters };
|
: { model_parameters: modelOptions };
|
||||||
|
|
||||||
const options = await getOptions({
|
const options = await getOptions({
|
||||||
req,
|
req,
|
||||||
|
|
@ -136,6 +138,20 @@ const initializeAgent = async ({
|
||||||
endpointOption: _endpointOption,
|
endpointOption: _endpointOption,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tokensModel =
|
||||||
|
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : modelOptions.model;
|
||||||
|
const maxTokens = optionalChainWithEmptyCheck(
|
||||||
|
modelOptions.maxOutputTokens,
|
||||||
|
modelOptions.maxTokens,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const maxContextTokens = optionalChainWithEmptyCheck(
|
||||||
|
modelOptions.maxContextTokens,
|
||||||
|
modelOptions.max_context_tokens,
|
||||||
|
getModelMaxTokens(tokensModel, providerEndpointMap[provider]),
|
||||||
|
4096,
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
agent.endpoint === EModelEndpoint.azureOpenAI &&
|
agent.endpoint === EModelEndpoint.azureOpenAI &&
|
||||||
options.llmConfig?.azureOpenAIApiInstanceName == null
|
options.llmConfig?.azureOpenAIApiInstanceName == null
|
||||||
|
|
@ -148,15 +164,11 @@ const initializeAgent = async ({
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('@librechat/agents').ClientOptions} */
|
/** @type {import('@librechat/agents').ClientOptions} */
|
||||||
agent.model_parameters = Object.assign(model_parameters, options.llmConfig);
|
agent.model_parameters = { ...options.llmConfig };
|
||||||
if (options.configOptions) {
|
if (options.configOptions) {
|
||||||
agent.model_parameters.configuration = options.configOptions;
|
agent.model_parameters.configuration = options.configOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!agent.model_parameters.model) {
|
|
||||||
agent.model_parameters.model = agent.model;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.instructions && agent.instructions !== '') {
|
if (agent.instructions && agent.instructions !== '') {
|
||||||
agent.instructions = replaceSpecialVars({
|
agent.instructions = replaceSpecialVars({
|
||||||
text: agent.instructions,
|
text: agent.instructions,
|
||||||
|
|
@ -171,23 +183,11 @@ const initializeAgent = async ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokensModel =
|
|
||||||
agent.provider === EModelEndpoint.azureOpenAI ? agent.model : agent.model_parameters.model;
|
|
||||||
const maxTokens = optionalChainWithEmptyCheck(
|
|
||||||
agent.model_parameters.maxOutputTokens,
|
|
||||||
agent.model_parameters.maxTokens,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const maxContextTokens = optionalChainWithEmptyCheck(
|
|
||||||
agent.model_parameters.maxContextTokens,
|
|
||||||
agent.max_context_tokens,
|
|
||||||
getModelMaxTokens(tokensModel, providerEndpointMap[provider]),
|
|
||||||
4096,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...agent,
|
...agent,
|
||||||
tools,
|
tools,
|
||||||
attachments,
|
attachments,
|
||||||
|
resendFiles,
|
||||||
toolContextMap,
|
toolContextMap,
|
||||||
maxContextTokens: (maxContextTokens - maxTokens) * 0.9,
|
maxContextTokens: (maxContextTokens - maxTokens) * 0.9,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -130,8 +130,8 @@ const initializeClient = async ({ req, res, endpointOption }) => {
|
||||||
iconURL: endpointOption.iconURL,
|
iconURL: endpointOption.iconURL,
|
||||||
attachments: primaryConfig.attachments,
|
attachments: primaryConfig.attachments,
|
||||||
endpointType: endpointOption.endpointType,
|
endpointType: endpointOption.endpointType,
|
||||||
|
resendFiles: primaryConfig.resendFiles ?? true,
|
||||||
maxContextTokens: primaryConfig.maxContextTokens,
|
maxContextTokens: primaryConfig.maxContextTokens,
|
||||||
resendFiles: primaryConfig.model_parameters?.resendFiles ?? true,
|
|
||||||
endpoint:
|
endpoint:
|
||||||
primaryConfig.id === Constants.EPHEMERAL_AGENT_ID
|
primaryConfig.id === Constants.EPHEMERAL_AGENT_ID
|
||||||
? primaryConfig.endpoint
|
? primaryConfig.endpoint
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
const { ProxyAgent } = require('undici');
|
||||||
const { anthropicSettings, removeNullishValues } = require('librechat-data-provider');
|
const { anthropicSettings, removeNullishValues } = require('librechat-data-provider');
|
||||||
const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = require('./helpers');
|
const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = require('./helpers');
|
||||||
|
|
||||||
|
|
@ -67,7 +67,10 @@ function getLLMConfig(apiKey, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.proxy) {
|
if (options.proxy) {
|
||||||
requestOptions.clientOptions.httpAgent = new HttpsProxyAgent(options.proxy);
|
const proxyAgent = new ProxyAgent(options.proxy);
|
||||||
|
requestOptions.clientOptions.fetchOptions = {
|
||||||
|
dispatcher: proxyAgent,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.reverseProxyUrl) {
|
if (options.reverseProxyUrl) {
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,12 @@ describe('getLLMConfig', () => {
|
||||||
proxy: 'http://proxy:8080',
|
proxy: 'http://proxy:8080',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.llmConfig.clientOptions).toHaveProperty('httpAgent');
|
expect(result.llmConfig.clientOptions).toHaveProperty('fetchOptions');
|
||||||
expect(result.llmConfig.clientOptions.httpAgent).toHaveProperty('proxy', 'http://proxy:8080');
|
expect(result.llmConfig.clientOptions.fetchOptions).toHaveProperty('dispatcher');
|
||||||
|
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher).toBeDefined();
|
||||||
|
expect(result.llmConfig.clientOptions.fetchOptions.dispatcher.constructor.name).toBe(
|
||||||
|
'ProxyAgent',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include reverse proxy URL when provided', () => {
|
it('should include reverse proxy URL when provided', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,111 @@
|
||||||
const { z } = require('zod');
|
const { z } = require('zod');
|
||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
const { normalizeServerName } = require('@librechat/api');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { Constants: AgentConstants, Providers } = require('@librechat/agents');
|
const { Time, CacheKeys, StepTypes } = require('librechat-data-provider');
|
||||||
|
const { sendEvent, normalizeServerName, MCPOAuthHandler } = require('@librechat/api');
|
||||||
|
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
|
||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
isAssistantsEndpoint,
|
isAssistantsEndpoint,
|
||||||
convertJsonSchemaToZod,
|
convertJsonSchemaToZod,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { logger, getMCPManager } = require('~/config');
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
|
const { findToken, createToken, updateToken } = require('~/models');
|
||||||
|
const { getCachedTools } = require('./Config');
|
||||||
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} params
|
||||||
|
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||||
|
* @param {string} params.stepId - The ID of the step in the flow.
|
||||||
|
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
|
||||||
|
* @param {string} params.loginFlowId - The ID of the login flow.
|
||||||
|
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
|
||||||
|
*/
|
||||||
|
function createOAuthStart({ res, stepId, toolCall, loginFlowId, flowManager, signal }) {
|
||||||
|
/**
|
||||||
|
* Creates a function to handle OAuth login requests.
|
||||||
|
* @param {string} authURL - The URL to redirect the user for OAuth authentication.
|
||||||
|
* @returns {Promise<boolean>} Returns true to indicate the event was sent successfully.
|
||||||
|
*/
|
||||||
|
return async function (authURL) {
|
||||||
|
/** @type {{ id: string; delta: AgentToolCallDelta }} */
|
||||||
|
const data = {
|
||||||
|
id: stepId,
|
||||||
|
delta: {
|
||||||
|
type: StepTypes.TOOL_CALLS,
|
||||||
|
tool_calls: [{ ...toolCall, args: '' }],
|
||||||
|
auth: authURL,
|
||||||
|
expires_at: Date.now() + Time.TWO_MINUTES,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
/** Used to ensure the handler (use of `sendEvent`) is only invoked once */
|
||||||
|
await flowManager.createFlowWithHandler(
|
||||||
|
loginFlowId,
|
||||||
|
'oauth_login',
|
||||||
|
async () => {
|
||||||
|
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
|
||||||
|
logger.debug('Sent OAuth login request to client');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} params
|
||||||
|
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||||
|
* @param {string} params.stepId - The ID of the step in the flow.
|
||||||
|
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
|
||||||
|
* @param {string} params.loginFlowId - The ID of the login flow.
|
||||||
|
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
|
||||||
|
*/
|
||||||
|
function createOAuthEnd({ res, stepId, toolCall }) {
|
||||||
|
return async function () {
|
||||||
|
/** @type {{ id: string; delta: AgentToolCallDelta }} */
|
||||||
|
const data = {
|
||||||
|
id: stepId,
|
||||||
|
delta: {
|
||||||
|
type: StepTypes.TOOL_CALLS,
|
||||||
|
tool_calls: [{ ...toolCall }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
|
||||||
|
logger.debug('Sent OAuth login success to client');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} params
|
||||||
|
* @param {string} params.userId - The ID of the user.
|
||||||
|
* @param {string} params.serverName - The name of the server.
|
||||||
|
* @param {string} params.toolName - The name of the tool.
|
||||||
|
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
|
||||||
|
*/
|
||||||
|
function createAbortHandler({ userId, serverName, toolName, flowManager }) {
|
||||||
|
return function () {
|
||||||
|
logger.info(`[MCP][User: ${userId}][${serverName}][${toolName}] Tool call aborted`);
|
||||||
|
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
|
||||||
|
flowManager.failFlow(flowId, 'mcp_oauth', new Error('Tool call aborted'));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a general tool for an entire action set.
|
* Creates a general tool for an entire action set.
|
||||||
*
|
*
|
||||||
* @param {Object} params - The parameters for loading action sets.
|
* @param {Object} params - The parameters for loading action sets.
|
||||||
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
|
* @param {ServerRequest} params.req - The Express request object, containing user/request info.
|
||||||
|
* @param {ServerResponse} params.res - The Express response object for sending events.
|
||||||
* @param {string} params.toolKey - The toolKey for the tool.
|
* @param {string} params.toolKey - The toolKey for the tool.
|
||||||
* @param {import('@librechat/agents').Providers | EModelEndpoint} params.provider - The provider for the tool.
|
* @param {import('@librechat/agents').Providers | EModelEndpoint} params.provider - The provider for the tool.
|
||||||
* @param {string} params.model - The model for the tool.
|
* @param {string} params.model - The model for the tool.
|
||||||
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
|
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
|
||||||
*/
|
*/
|
||||||
async function createMCPTool({ req, toolKey, provider: _provider }) {
|
async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||||
const toolDefinition = req.app.locals.availableTools[toolKey]?.function;
|
const availableTools = await getCachedTools({ includeGlobal: true });
|
||||||
|
const toolDefinition = availableTools?.[toolKey]?.function;
|
||||||
if (!toolDefinition) {
|
if (!toolDefinition) {
|
||||||
logger.error(`Tool ${toolKey} not found in available tools`);
|
logger.error(`Tool ${toolKey} not found in available tools`);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -51,10 +135,42 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
|
||||||
/** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise<unknown>} */
|
/** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise<unknown>} */
|
||||||
const _call = async (toolArguments, config) => {
|
const _call = async (toolArguments, config) => {
|
||||||
const userId = config?.configurable?.user?.id || config?.configurable?.user_id;
|
const userId = config?.configurable?.user?.id || config?.configurable?.user_id;
|
||||||
|
/** @type {ReturnType<typeof createAbortHandler>} */
|
||||||
|
let abortHandler = null;
|
||||||
|
/** @type {AbortSignal} */
|
||||||
|
let derivedSignal = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const derivedSignal = config?.signal ? AbortSignal.any([config.signal]) : undefined;
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
|
derivedSignal = config?.signal ? AbortSignal.any([config.signal]) : undefined;
|
||||||
const mcpManager = getMCPManager(userId);
|
const mcpManager = getMCPManager(userId);
|
||||||
const provider = (config?.metadata?.provider || _provider)?.toLowerCase();
|
const provider = (config?.metadata?.provider || _provider)?.toLowerCase();
|
||||||
|
|
||||||
|
const { args: _args, stepId, ...toolCall } = config.toolCall ?? {};
|
||||||
|
const loginFlowId = `${serverName}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`;
|
||||||
|
const oauthStart = createOAuthStart({
|
||||||
|
res,
|
||||||
|
stepId,
|
||||||
|
toolCall,
|
||||||
|
loginFlowId,
|
||||||
|
flowManager,
|
||||||
|
signal: derivedSignal,
|
||||||
|
});
|
||||||
|
const oauthEnd = createOAuthEnd({
|
||||||
|
res,
|
||||||
|
stepId,
|
||||||
|
toolCall,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (derivedSignal) {
|
||||||
|
abortHandler = createAbortHandler({ userId, serverName, toolName, flowManager });
|
||||||
|
derivedSignal.addEventListener('abort', abortHandler, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const customUserVars =
|
||||||
|
config?.configurable?.userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
|
||||||
|
|
||||||
const result = await mcpManager.callTool({
|
const result = await mcpManager.callTool({
|
||||||
serverName,
|
serverName,
|
||||||
toolName,
|
toolName,
|
||||||
|
|
@ -62,8 +178,17 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
|
||||||
toolArguments,
|
toolArguments,
|
||||||
options: {
|
options: {
|
||||||
signal: derivedSignal,
|
signal: derivedSignal,
|
||||||
user: config?.configurable?.user,
|
|
||||||
},
|
},
|
||||||
|
user: config?.configurable?.user,
|
||||||
|
customUserVars,
|
||||||
|
flowManager,
|
||||||
|
tokenMethods: {
|
||||||
|
findToken,
|
||||||
|
createToken,
|
||||||
|
updateToken,
|
||||||
|
},
|
||||||
|
oauthStart,
|
||||||
|
oauthEnd,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
|
if (isAssistantsEndpoint(provider) && Array.isArray(result)) {
|
||||||
|
|
@ -78,9 +203,28 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
|
||||||
`[MCP][User: ${userId}][${serverName}] Error calling "${toolName}" MCP tool:`,
|
`[MCP][User: ${userId}][${serverName}] Error calling "${toolName}" MCP tool:`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** OAuth error, provide a helpful message */
|
||||||
|
const isOAuthError =
|
||||||
|
error.message?.includes('401') ||
|
||||||
|
error.message?.includes('OAuth') ||
|
||||||
|
error.message?.includes('authentication') ||
|
||||||
|
error.message?.includes('Non-200 status code (401)');
|
||||||
|
|
||||||
|
if (isOAuthError) {
|
||||||
|
throw new Error(
|
||||||
|
`OAuth authentication required for ${serverName}. Please check the server logs for the authentication URL.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`"${toolKey}" tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
|
`"${toolKey}" tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
// Clean up abort handler to prevent memory leaks
|
||||||
|
if (abortHandler && derivedSignal) {
|
||||||
|
derivedSignal.removeEventListener('abort', abortHandler);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const { encrypt, decrypt } = require('~/server/utils/crypto');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { PluginAuth } = require('~/db/models');
|
const { encrypt, decrypt } = require('@librechat/api');
|
||||||
const { logger } = require('~/config');
|
const { findOnePluginAuth, updatePluginAuth, deletePluginAuth } = require('~/models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asynchronously retrieves and decrypts the authentication value for a user's plugin, based on a specified authentication field.
|
* Asynchronously retrieves and decrypts the authentication value for a user's plugin, based on a specified authentication field.
|
||||||
|
|
@ -25,7 +25,7 @@ const { logger } = require('~/config');
|
||||||
*/
|
*/
|
||||||
const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
|
const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
|
||||||
try {
|
try {
|
||||||
const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean();
|
const pluginAuth = await findOnePluginAuth({ userId, authField });
|
||||||
if (!pluginAuth) {
|
if (!pluginAuth) {
|
||||||
throw new Error(`No plugin auth ${authField} found for user ${userId}`);
|
throw new Error(`No plugin auth ${authField} found for user ${userId}`);
|
||||||
}
|
}
|
||||||
|
|
@ -79,23 +79,12 @@ const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
|
||||||
const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
|
const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
|
||||||
try {
|
try {
|
||||||
const encryptedValue = await encrypt(value);
|
const encryptedValue = await encrypt(value);
|
||||||
const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean();
|
return await updatePluginAuth({
|
||||||
if (pluginAuth) {
|
userId,
|
||||||
return await PluginAuth.findOneAndUpdate(
|
authField,
|
||||||
{ userId, authField },
|
pluginKey,
|
||||||
{ $set: { value: encryptedValue } },
|
value: encryptedValue,
|
||||||
{ new: true, upsert: true },
|
});
|
||||||
).lean();
|
|
||||||
} else {
|
|
||||||
const newPluginAuth = await new PluginAuth({
|
|
||||||
userId,
|
|
||||||
authField,
|
|
||||||
value: encryptedValue,
|
|
||||||
pluginKey,
|
|
||||||
});
|
|
||||||
await newPluginAuth.save();
|
|
||||||
return newPluginAuth.toObject();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[updateUserPluginAuth]', err);
|
logger.error('[updateUserPluginAuth]', err);
|
||||||
return err;
|
return err;
|
||||||
|
|
@ -105,26 +94,25 @@ const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
|
||||||
/**
|
/**
|
||||||
* @async
|
* @async
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {string} authField
|
* @param {string | null} authField - The specific authField to delete, or null if `all` is true.
|
||||||
* @param {boolean} [all]
|
* @param {boolean} [all=false] - Whether to delete all auths for the user (or for a specific pluginKey if provided).
|
||||||
|
* @param {string} [pluginKey] - Optional. If `all` is true and `pluginKey` is provided, delete all auths for this user and pluginKey.
|
||||||
* @returns {Promise<import('mongoose').DeleteResult>}
|
* @returns {Promise<import('mongoose').DeleteResult>}
|
||||||
* @throws {Error}
|
* @throws {Error}
|
||||||
*/
|
*/
|
||||||
const deleteUserPluginAuth = async (userId, authField, all = false) => {
|
const deleteUserPluginAuth = async (userId, authField, all = false, pluginKey) => {
|
||||||
if (all) {
|
|
||||||
try {
|
|
||||||
const response = await PluginAuth.deleteMany({ userId });
|
|
||||||
return response;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('[deleteUserPluginAuth]', err);
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await PluginAuth.deleteOne({ userId, authField });
|
return await deletePluginAuth({
|
||||||
|
userId,
|
||||||
|
authField,
|
||||||
|
pluginKey,
|
||||||
|
all,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[deleteUserPluginAuth]', err);
|
logger.error(
|
||||||
|
`[deleteUserPluginAuth] Error deleting ${all ? 'all' : 'single'} auth(s) for userId: ${userId}${pluginKey ? ` and pluginKey: ${pluginKey}` : ''}`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
const axios = require('axios');
|
|
||||||
const { logAxiosError } = require('@librechat/api');
|
|
||||||
const { logger } = require('@librechat/data-schemas');
|
|
||||||
const { TokenExchangeMethodEnum } = require('librechat-data-provider');
|
|
||||||
const { handleOAuthToken } = require('~/models/Token');
|
|
||||||
const { decryptV2 } = require('~/server/utils/crypto');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes the access tokens and stores them in the database.
|
|
||||||
* @param {object} tokenData
|
|
||||||
* @param {string} tokenData.access_token
|
|
||||||
* @param {number} tokenData.expires_in
|
|
||||||
* @param {string} [tokenData.refresh_token]
|
|
||||||
* @param {number} [tokenData.refresh_token_expires_in]
|
|
||||||
* @param {object} metadata
|
|
||||||
* @param {string} metadata.userId
|
|
||||||
* @param {string} metadata.identifier
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async function processAccessTokens(tokenData, { userId, identifier }) {
|
|
||||||
const { access_token, expires_in = 3600, refresh_token, refresh_token_expires_in } = tokenData;
|
|
||||||
if (!access_token) {
|
|
||||||
logger.error('Access token not found: ', tokenData);
|
|
||||||
throw new Error('Access token not found');
|
|
||||||
}
|
|
||||||
await handleOAuthToken({
|
|
||||||
identifier,
|
|
||||||
token: access_token,
|
|
||||||
expiresIn: expires_in,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (refresh_token != null) {
|
|
||||||
logger.debug('Processing refresh token');
|
|
||||||
await handleOAuthToken({
|
|
||||||
token: refresh_token,
|
|
||||||
type: 'oauth_refresh',
|
|
||||||
userId,
|
|
||||||
identifier: `${identifier}:refresh`,
|
|
||||||
expiresIn: refresh_token_expires_in ?? null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
logger.debug('Access tokens processed');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refreshes the access token using the refresh token.
|
|
||||||
* @param {object} fields
|
|
||||||
* @param {string} fields.userId - The ID of the user.
|
|
||||||
* @param {string} fields.client_url - The URL of the OAuth provider.
|
|
||||||
* @param {string} fields.identifier - The identifier for the token.
|
|
||||||
* @param {string} fields.refresh_token - The refresh token to use.
|
|
||||||
* @param {string} fields.token_exchange_method - The token exchange method ('default_post' or 'basic_auth_header').
|
|
||||||
* @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider.
|
|
||||||
* @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider.
|
|
||||||
* @returns {Promise<{
|
|
||||||
* access_token: string,
|
|
||||||
* expires_in: number,
|
|
||||||
* refresh_token?: string,
|
|
||||||
* refresh_token_expires_in?: number,
|
|
||||||
* }>}
|
|
||||||
*/
|
|
||||||
const refreshAccessToken = async ({
|
|
||||||
userId,
|
|
||||||
client_url,
|
|
||||||
identifier,
|
|
||||||
refresh_token,
|
|
||||||
token_exchange_method,
|
|
||||||
encrypted_oauth_client_id,
|
|
||||||
encrypted_oauth_client_secret,
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
const oauth_client_id = await decryptV2(encrypted_oauth_client_id);
|
|
||||||
const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret);
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
Accept: 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
refresh_token,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (token_exchange_method === TokenExchangeMethodEnum.BasicAuthHeader) {
|
|
||||||
const basicAuth = Buffer.from(`${oauth_client_id}:${oauth_client_secret}`).toString('base64');
|
|
||||||
headers['Authorization'] = `Basic ${basicAuth}`;
|
|
||||||
} else {
|
|
||||||
params.append('client_id', oauth_client_id);
|
|
||||||
params.append('client_secret', oauth_client_secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios({
|
|
||||||
method: 'POST',
|
|
||||||
url: client_url,
|
|
||||||
headers,
|
|
||||||
data: params.toString(),
|
|
||||||
});
|
|
||||||
await processAccessTokens(response.data, {
|
|
||||||
userId,
|
|
||||||
identifier,
|
|
||||||
});
|
|
||||||
logger.debug(`Access token refreshed successfully for ${identifier}`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = 'Error refreshing OAuth tokens';
|
|
||||||
throw new Error(
|
|
||||||
logAxiosError({
|
|
||||||
message,
|
|
||||||
error,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles the OAuth callback and exchanges the authorization code for tokens.
|
|
||||||
* @param {object} fields
|
|
||||||
* @param {string} fields.code - The authorization code returned by the provider.
|
|
||||||
* @param {string} fields.userId - The ID of the user.
|
|
||||||
* @param {string} fields.identifier - The identifier for the token.
|
|
||||||
* @param {string} fields.client_url - The URL of the OAuth provider.
|
|
||||||
* @param {string} fields.redirect_uri - The redirect URI for the OAuth provider.
|
|
||||||
* @param {string} fields.token_exchange_method - The token exchange method ('default_post' or 'basic_auth_header').
|
|
||||||
* @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider.
|
|
||||||
* @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider.
|
|
||||||
* @returns {Promise<{
|
|
||||||
* access_token: string,
|
|
||||||
* expires_in: number,
|
|
||||||
* refresh_token?: string,
|
|
||||||
* refresh_token_expires_in?: number,
|
|
||||||
* }>}
|
|
||||||
*/
|
|
||||||
const getAccessToken = async ({
|
|
||||||
code,
|
|
||||||
userId,
|
|
||||||
identifier,
|
|
||||||
client_url,
|
|
||||||
redirect_uri,
|
|
||||||
token_exchange_method,
|
|
||||||
encrypted_oauth_client_id,
|
|
||||||
encrypted_oauth_client_secret,
|
|
||||||
}) => {
|
|
||||||
const oauth_client_id = await decryptV2(encrypted_oauth_client_id);
|
|
||||||
const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret);
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
Accept: 'application/json',
|
|
||||||
};
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
code,
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
redirect_uri,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (token_exchange_method === TokenExchangeMethodEnum.BasicAuthHeader) {
|
|
||||||
const basicAuth = Buffer.from(`${oauth_client_id}:${oauth_client_secret}`).toString('base64');
|
|
||||||
headers['Authorization'] = `Basic ${basicAuth}`;
|
|
||||||
} else {
|
|
||||||
params.append('client_id', oauth_client_id);
|
|
||||||
params.append('client_secret', oauth_client_secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios({
|
|
||||||
method: 'POST',
|
|
||||||
url: client_url,
|
|
||||||
headers,
|
|
||||||
data: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await processAccessTokens(response.data, {
|
|
||||||
userId,
|
|
||||||
identifier,
|
|
||||||
});
|
|
||||||
logger.debug(`Access tokens successfully created for ${identifier}`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
const message = 'Error exchanging OAuth code';
|
|
||||||
throw new Error(
|
|
||||||
logAxiosError({
|
|
||||||
message,
|
|
||||||
error,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
getAccessToken,
|
|
||||||
refreshAccessToken,
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { sleep } = require('@librechat/agents');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { zodToJsonSchema } = require('zod-to-json-schema');
|
const { zodToJsonSchema } = require('zod-to-json-schema');
|
||||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||||
const { tool: toolFn, Tool, DynamicStructuredTool } = require('@langchain/core/tools');
|
const { tool: toolFn, Tool, DynamicStructuredTool } = require('@langchain/core/tools');
|
||||||
|
|
@ -31,14 +33,12 @@ const {
|
||||||
toolkits,
|
toolkits,
|
||||||
} = require('~/app/clients/tools');
|
} = require('~/app/clients/tools');
|
||||||
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
|
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
|
||||||
|
const { getEndpointsConfig, getCachedTools } = require('~/server/services/Config');
|
||||||
const { createOnSearchResults } = require('~/server/services/Tools/search');
|
const { createOnSearchResults } = require('~/server/services/Tools/search');
|
||||||
const { isActionDomainAllowed } = require('~/server/services/domains');
|
const { isActionDomainAllowed } = require('~/server/services/domains');
|
||||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
|
||||||
const { recordUsage } = require('~/server/services/Threads');
|
const { recordUsage } = require('~/server/services/Threads');
|
||||||
const { loadTools } = require('~/app/clients/tools/util');
|
const { loadTools } = require('~/app/clients/tools/util');
|
||||||
const { redactMessage } = require('~/config/parsers');
|
const { redactMessage } = require('~/config/parsers');
|
||||||
const { sleep } = require('~/server/utils');
|
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} toolName
|
* @param {string} toolName
|
||||||
|
|
@ -226,7 +226,7 @@ async function processRequiredActions(client, requiredActions) {
|
||||||
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
||||||
requiredActions,
|
requiredActions,
|
||||||
);
|
);
|
||||||
const toolDefinitions = client.req.app.locals.availableTools;
|
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||||
const seenToolkits = new Set();
|
const seenToolkits = new Set();
|
||||||
const tools = requiredActions
|
const tools = requiredActions
|
||||||
.map((action) => {
|
.map((action) => {
|
||||||
|
|
@ -553,6 +553,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
|
||||||
tools: _agentTools,
|
tools: _agentTools,
|
||||||
options: {
|
options: {
|
||||||
req,
|
req,
|
||||||
|
res,
|
||||||
openAIApiKey,
|
openAIApiKey,
|
||||||
tool_resources,
|
tool_resources,
|
||||||
processFileURL,
|
processFileURL,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { encrypt, decrypt } = require('@librechat/api');
|
||||||
const { ErrorTypes } = require('librechat-data-provider');
|
const { ErrorTypes } = require('librechat-data-provider');
|
||||||
const { encrypt, decrypt } = require('~/server/utils/crypto');
|
|
||||||
const { updateUser } = require('~/models');
|
const { updateUser } = require('~/models');
|
||||||
const { Key } = require('~/db/models');
|
const { Key } = require('~/db/models');
|
||||||
|
|
||||||
|
|
@ -70,6 +70,7 @@ const getUserKeyValues = async ({ userId, name }) => {
|
||||||
try {
|
try {
|
||||||
userValues = JSON.parse(userValues);
|
userValues = JSON.parse(userValues);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
logger.error('[getUserKeyValues]', e);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: ErrorTypes.INVALID_USER_KEY,
|
type: ErrorTypes.INVALID_USER_KEY,
|
||||||
|
|
|
||||||
54
api/server/services/initializeMCP.js
Normal file
54
api/server/services/initializeMCP.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { CacheKeys, processMCPEnv } = require('librechat-data-provider');
|
||||||
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
|
const { getCachedTools, setCachedTools } = require('./Config');
|
||||||
|
const { getLogStores } = require('~/cache');
|
||||||
|
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize MCP servers
|
||||||
|
* @param {import('express').Application} app - Express app instance
|
||||||
|
*/
|
||||||
|
async function initializeMCP(app) {
|
||||||
|
const mcpServers = app.locals.mcpConfig;
|
||||||
|
if (!mcpServers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Initializing MCP servers...');
|
||||||
|
const mcpManager = getMCPManager();
|
||||||
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
|
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mcpManager.initializeMCP({
|
||||||
|
mcpServers,
|
||||||
|
flowManager,
|
||||||
|
tokenMethods: {
|
||||||
|
findToken,
|
||||||
|
updateToken,
|
||||||
|
createToken,
|
||||||
|
deleteTokens,
|
||||||
|
},
|
||||||
|
processMCPEnv,
|
||||||
|
});
|
||||||
|
|
||||||
|
delete app.locals.mcpConfig;
|
||||||
|
const availableTools = await getCachedTools();
|
||||||
|
|
||||||
|
if (!availableTools) {
|
||||||
|
logger.warn('No available tools found in cache during MCP initialization');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolsCopy = { ...availableTools };
|
||||||
|
await mcpManager.mapAvailableTools(toolsCopy, flowManager);
|
||||||
|
await setCachedTools(toolsCopy, { isGlobal: true });
|
||||||
|
|
||||||
|
logger.info('MCP servers initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to initialize MCP servers:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = initializeMCP;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
const { webcrypto } = require('node:crypto');
|
const { webcrypto } = require('node:crypto');
|
||||||
const { hashBackupCode, decryptV3, decryptV2 } = require('~/server/utils/crypto');
|
const { hashBackupCode, decryptV3, decryptV2 } = require('@librechat/api');
|
||||||
const { updateUser } = require('~/models');
|
const { updateUser } = require('~/models');
|
||||||
|
|
||||||
// Base32 alphabet for TOTP secret encoding.
|
// Base32 alphabet for TOTP secret encoding.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ const removePorts = require('./removePorts');
|
||||||
const countTokens = require('./countTokens');
|
const countTokens = require('./countTokens');
|
||||||
const handleText = require('./handleText');
|
const handleText = require('./handleText');
|
||||||
const sendEmail = require('./sendEmail');
|
const sendEmail = require('./sendEmail');
|
||||||
const cryptoUtils = require('./crypto');
|
|
||||||
const queue = require('./queue');
|
const queue = require('./queue');
|
||||||
const files = require('./files');
|
const files = require('./files');
|
||||||
const math = require('./math');
|
const math = require('./math');
|
||||||
|
|
@ -31,7 +30,6 @@ function checkEmailConfig() {
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...streamResponse,
|
...streamResponse,
|
||||||
checkEmailConfig,
|
checkEmailConfig,
|
||||||
...cryptoUtils,
|
|
||||||
...handleText,
|
...handleText,
|
||||||
countTokens,
|
countTokens,
|
||||||
removePorts,
|
removePorts,
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,18 @@ jest.mock('~/models', () => ({
|
||||||
createUser: jest.fn(),
|
createUser: jest.fn(),
|
||||||
updateUser: jest.fn(),
|
updateUser: jest.fn(),
|
||||||
}));
|
}));
|
||||||
jest.mock('~/server/utils/crypto', () => ({
|
jest.mock('@librechat/api', () => ({
|
||||||
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
...jest.requireActual('@librechat/api'),
|
||||||
}));
|
|
||||||
jest.mock('~/server/utils', () => ({
|
|
||||||
isEnabled: jest.fn(() => false),
|
isEnabled: jest.fn(() => false),
|
||||||
}));
|
}));
|
||||||
jest.mock('~/config', () => ({
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
...jest.requireActual('@librechat/api'),
|
||||||
logger: {
|
logger: {
|
||||||
info: jest.fn(),
|
info: jest.fn(),
|
||||||
debug: jest.fn(),
|
debug: jest.fn(),
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
warn: jest.fn(),
|
|
||||||
},
|
},
|
||||||
|
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
||||||
}));
|
}));
|
||||||
jest.mock('~/cache/getLogStores', () =>
|
jest.mock('~/cache/getLogStores', () =>
|
||||||
jest.fn(() => ({
|
jest.fn(() => ({
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
|
||||||
const { findUser, createUser, updateUser } = require('~/models');
|
|
||||||
const { setupSaml, getCertificateContent } = require('./samlStrategy');
|
|
||||||
|
|
||||||
// --- Mocks ---
|
// --- Mocks ---
|
||||||
|
jest.mock('tiktoken');
|
||||||
jest.mock('fs');
|
jest.mock('fs');
|
||||||
jest.mock('path');
|
jest.mock('path');
|
||||||
jest.mock('node-fetch');
|
jest.mock('node-fetch');
|
||||||
jest.mock('@node-saml/passport-saml');
|
jest.mock('@node-saml/passport-saml');
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
logger: {
|
||||||
|
info: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
||||||
|
}));
|
||||||
jest.mock('~/models', () => ({
|
jest.mock('~/models', () => ({
|
||||||
findUser: jest.fn(),
|
findUser: jest.fn(),
|
||||||
createUser: jest.fn(),
|
createUser: jest.fn(),
|
||||||
|
|
@ -29,26 +31,26 @@ jest.mock('~/server/services/Config', () => ({
|
||||||
jest.mock('~/server/services/Config/EndpointService', () => ({
|
jest.mock('~/server/services/Config/EndpointService', () => ({
|
||||||
config: {},
|
config: {},
|
||||||
}));
|
}));
|
||||||
jest.mock('~/server/utils', () => ({
|
|
||||||
isEnabled: jest.fn(() => false),
|
|
||||||
isUserProvided: jest.fn(() => false),
|
|
||||||
}));
|
|
||||||
jest.mock('~/server/services/Files/strategies', () => ({
|
jest.mock('~/server/services/Files/strategies', () => ({
|
||||||
getStrategyFunctions: jest.fn(() => ({
|
getStrategyFunctions: jest.fn(() => ({
|
||||||
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
|
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
jest.mock('~/server/utils/crypto', () => ({
|
jest.mock('~/config/paths', () => ({
|
||||||
hashToken: jest.fn().mockResolvedValue('hashed-token'),
|
root: '/fake/root/path',
|
||||||
}));
|
|
||||||
jest.mock('~/config', () => ({
|
|
||||||
logger: {
|
|
||||||
info: jest.fn(),
|
|
||||||
debug: jest.fn(),
|
|
||||||
error: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
||||||
|
const { setupSaml, getCertificateContent } = require('./samlStrategy');
|
||||||
|
|
||||||
|
// Configure fs mock
|
||||||
|
jest.mocked(fs).existsSync = jest.fn();
|
||||||
|
jest.mocked(fs).statSync = jest.fn();
|
||||||
|
jest.mocked(fs).readFileSync = jest.fn();
|
||||||
|
|
||||||
// To capture the verify callback from the strategy, we grab it from the mock constructor
|
// To capture the verify callback from the strategy, we grab it from the mock constructor
|
||||||
let verifyCallback;
|
let verifyCallback;
|
||||||
SamlStrategy.mockImplementation((options, verify) => {
|
SamlStrategy.mockImplementation((options, verify) => {
|
||||||
|
|
|
||||||
|
|
@ -476,11 +476,18 @@
|
||||||
* @memberof typedefs
|
* @memberof typedefs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @exports ToolCallChunk
|
||||||
|
* @typedef {import('librechat-data-provider').Agents.ToolCallChunk} ToolCallChunk
|
||||||
|
* @memberof typedefs
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @exports MessageContentImageUrl
|
* @exports MessageContentImageUrl
|
||||||
* @typedef {import('librechat-data-provider').Agents.MessageContentImageUrl} MessageContentImageUrl
|
* @typedef {import('librechat-data-provider').Agents.MessageContentImageUrl} MessageContentImageUrl
|
||||||
* @memberof typedefs
|
* @memberof typedefs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Web Search */
|
/** Web Search */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@
|
||||||
"export-from-json": "^1.7.2",
|
"export-from-json": "^1.7.2",
|
||||||
"filenamify": "^6.0.0",
|
"filenamify": "^6.0.0",
|
||||||
"framer-motion": "^11.5.4",
|
"framer-motion": "^11.5.4",
|
||||||
|
"heic-to": "^1.1.14",
|
||||||
"html-to-image": "^1.11.11",
|
"html-to-image": "^1.11.11",
|
||||||
"i18next": "^24.2.2",
|
"i18next": "^24.2.2",
|
||||||
"i18next-browser-languagedetector": "^8.0.3",
|
"i18next-browser-languagedetector": "^8.0.3",
|
||||||
|
|
@ -74,6 +75,7 @@
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.394.0",
|
"lucide-react": "^0.394.0",
|
||||||
"match-sorter": "^6.3.4",
|
"match-sorter": "^6.3.4",
|
||||||
|
"micromark-extension-llm-math": "^3.1.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"rc-input-number": "^7.4.2",
|
"rc-input-number": "^7.4.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import React, { createContext, useContext, useState } from 'react';
|
import React, { createContext, useContext, useState } from 'react';
|
||||||
import { Action, MCP, EModelEndpoint } from 'librechat-data-provider';
|
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
import type { TPlugin, AgentToolType, Action, MCP } from 'librechat-data-provider';
|
||||||
import type { AgentPanelContextType } from '~/common';
|
import type { AgentPanelContextType } from '~/common';
|
||||||
import { useGetActionsQuery } from '~/data-provider';
|
import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
import { Panel } from '~/common';
|
import { Panel } from '~/common';
|
||||||
|
|
||||||
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
|
||||||
|
|
@ -16,6 +18,7 @@ export function useAgentPanelContext() {
|
||||||
|
|
||||||
/** Houses relevant state for the Agent Form Panels (formerly 'commonProps') */
|
/** Houses relevant state for the Agent Form Panels (formerly 'commonProps') */
|
||||||
export function AgentPanelProvider({ children }: { children: React.ReactNode }) {
|
export function AgentPanelProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const localize = useLocalize();
|
||||||
const [mcp, setMcp] = useState<MCP | undefined>(undefined);
|
const [mcp, setMcp] = useState<MCP | undefined>(undefined);
|
||||||
const [mcps, setMcps] = useState<MCP[] | undefined>(undefined);
|
const [mcps, setMcps] = useState<MCP[] | undefined>(undefined);
|
||||||
const [action, setAction] = useState<Action | undefined>(undefined);
|
const [action, setAction] = useState<Action | undefined>(undefined);
|
||||||
|
|
@ -26,6 +29,53 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
enabled: !!agent_id,
|
enabled: !!agent_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: pluginTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||||
|
enabled: !!agent_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tools =
|
||||||
|
pluginTools?.map((tool) => ({
|
||||||
|
tool_id: tool.pluginKey,
|
||||||
|
metadata: tool as TPlugin,
|
||||||
|
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,
|
||||||
|
agent_id: agent_id || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>,
|
||||||
|
) || {};
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
action,
|
action,
|
||||||
setAction,
|
setAction,
|
||||||
|
|
@ -37,8 +87,10 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
|
||||||
setActivePanel,
|
setActivePanel,
|
||||||
setCurrentAgentId,
|
setCurrentAgentId,
|
||||||
agent_id,
|
agent_id,
|
||||||
/** Query data for actions */
|
groupedTools,
|
||||||
|
/** Query data for actions and tools */
|
||||||
actions,
|
actions,
|
||||||
|
tools,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
|
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,8 @@ export type AgentPanelContextType = {
|
||||||
mcps?: t.MCP[];
|
mcps?: t.MCP[];
|
||||||
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
|
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
|
||||||
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
|
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
|
||||||
|
groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
|
||||||
|
tools: t.AgentToolType[];
|
||||||
activePanel?: string;
|
activePanel?: string;
|
||||||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||||
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ const defaultType = 'unknown';
|
||||||
const defaultIdentifier = 'lc-no-identifier';
|
const defaultIdentifier = 'lc-no-identifier';
|
||||||
|
|
||||||
export function Artifact({
|
export function Artifact({
|
||||||
node,
|
node: _node,
|
||||||
...props
|
...props
|
||||||
}: Artifact & {
|
}: Artifact & {
|
||||||
children: React.ReactNode | { props: { children: React.ReactNode } };
|
children: React.ReactNode | { props: { children: React.ReactNode } };
|
||||||
|
|
@ -95,7 +95,7 @@ export function Artifact({
|
||||||
setArtifacts((prevArtifacts) => {
|
setArtifacts((prevArtifacts) => {
|
||||||
if (
|
if (
|
||||||
prevArtifacts?.[artifactKey] != null &&
|
prevArtifacts?.[artifactKey] != null &&
|
||||||
prevArtifacts[artifactKey].content === content
|
prevArtifacts[artifactKey]?.content === content
|
||||||
) {
|
) {
|
||||||
return prevArtifacts;
|
return prevArtifacts;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,31 @@
|
||||||
import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react';
|
import React, { memo, useRef, useMemo, useEffect, useCallback, useState } from 'react';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
|
import { Settings2 } from 'lucide-react';
|
||||||
|
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||||
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
|
||||||
|
import type { TPlugin, TPluginAuthConfig, TUpdateUserPlugins } from 'librechat-data-provider';
|
||||||
|
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
|
||||||
import { useAvailableToolsQuery } from '~/data-provider';
|
import { useAvailableToolsQuery } from '~/data-provider';
|
||||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||||
import MultiSelect from '~/components/ui/MultiSelect';
|
import MultiSelect from '~/components/ui/MultiSelect';
|
||||||
import { ephemeralAgentByConvoId } from '~/store';
|
import { ephemeralAgentByConvoId } from '~/store';
|
||||||
|
import { useToastContext } from '~/Providers';
|
||||||
import MCPIcon from '~/components/ui/MCPIcon';
|
import MCPIcon from '~/components/ui/MCPIcon';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
interface McpServerInfo {
|
||||||
|
name: string;
|
||||||
|
pluginKey: string;
|
||||||
|
authConfig?: TPluginAuthConfig[];
|
||||||
|
authenticated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to extract mcp_serverName from a full pluginKey like action_mcp_serverName
|
||||||
|
const getBaseMCPPluginKey = (fullPluginKey: string): string => {
|
||||||
|
const parts = fullPluginKey.split(Constants.mcp_delimiter);
|
||||||
|
return Constants.mcp_prefix + parts[parts.length - 1];
|
||||||
|
};
|
||||||
|
|
||||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||||
if (rawCurrentValue) {
|
if (rawCurrentValue) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -24,20 +42,45 @@ const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||||
|
|
||||||
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
const key = conversationId ?? Constants.NEW_CONVO;
|
const key = conversationId ?? Constants.NEW_CONVO;
|
||||||
const hasSetFetched = useRef<string | null>(null);
|
const hasSetFetched = useRef<string | null>(null);
|
||||||
|
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||||
|
const [selectedToolForConfig, setSelectedToolForConfig] = useState<McpServerInfo | null>(null);
|
||||||
|
|
||||||
const { data: mcpServerSet, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||||
select: (data) => {
|
select: (data: TPlugin[]) => {
|
||||||
const serverNames = new Set<string>();
|
const mcpToolsMap = new Map<string, McpServerInfo>();
|
||||||
data.forEach((tool) => {
|
data.forEach((tool) => {
|
||||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||||
if (isMCP && tool.chatMenu !== false) {
|
if (isMCP && tool.chatMenu !== false) {
|
||||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||||
serverNames.add(parts[parts.length - 1]);
|
const serverName = parts[parts.length - 1];
|
||||||
|
if (!mcpToolsMap.has(serverName)) {
|
||||||
|
mcpToolsMap.set(serverName, {
|
||||||
|
name: serverName,
|
||||||
|
pluginKey: tool.pluginKey,
|
||||||
|
authConfig: tool.authConfig,
|
||||||
|
authenticated: tool.authenticated,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return serverNames;
|
return Array.from(mcpToolsMap.values());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setIsConfigModalOpen(false);
|
||||||
|
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
console.error('Error updating MCP auth:', error);
|
||||||
|
showToast({
|
||||||
|
message: localize('com_nav_mcp_vars_update_error'),
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -76,12 +119,12 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasSetFetched.current = key;
|
hasSetFetched.current = key;
|
||||||
if ((mcpServerSet?.size ?? 0) > 0) {
|
if ((mcpToolDetails?.length ?? 0) > 0) {
|
||||||
setMCPValues(mcpValues.filter((mcp) => mcpServerSet?.has(mcp)));
|
setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMCPValues([]);
|
setMCPValues([]);
|
||||||
}, [isFetched, setMCPValues, mcpServerSet, key, mcpValues]);
|
}, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]);
|
||||||
|
|
||||||
const renderSelectedValues = useCallback(
|
const renderSelectedValues = useCallback(
|
||||||
(values: string[], placeholder?: string) => {
|
(values: string[], placeholder?: string) => {
|
||||||
|
|
@ -96,28 +139,140 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||||
[localize],
|
[localize],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mcpServers = useMemo(() => {
|
const mcpServerNames = useMemo(() => {
|
||||||
return Array.from(mcpServerSet ?? []);
|
return (mcpToolDetails ?? []).map((tool) => tool.name);
|
||||||
}, [mcpServerSet]);
|
}, [mcpToolDetails]);
|
||||||
|
|
||||||
if (!mcpServerSet || mcpServerSet.size === 0) {
|
const handleConfigSave = useCallback(
|
||||||
|
(targetName: string, authData: Record<string, string>) => {
|
||||||
|
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
|
||||||
|
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
|
||||||
|
|
||||||
|
const payload: TUpdateUserPlugins = {
|
||||||
|
pluginKey: basePluginKey,
|
||||||
|
action: 'install',
|
||||||
|
auth: authData,
|
||||||
|
};
|
||||||
|
updateUserPluginsMutation.mutate(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedToolForConfig, updateUserPluginsMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConfigRevoke = useCallback(
|
||||||
|
(targetName: string) => {
|
||||||
|
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
|
||||||
|
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
|
||||||
|
|
||||||
|
const payload: TUpdateUserPlugins = {
|
||||||
|
pluginKey: basePluginKey,
|
||||||
|
action: 'uninstall',
|
||||||
|
auth: {},
|
||||||
|
};
|
||||||
|
updateUserPluginsMutation.mutate(payload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedToolForConfig, updateUserPluginsMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderItemContent = useCallback(
|
||||||
|
(serverName: string, defaultContent: React.ReactNode) => {
|
||||||
|
const tool = mcpToolDetails?.find((t) => t.name === serverName);
|
||||||
|
const hasAuthConfig = tool?.authConfig && tool.authConfig.length > 0;
|
||||||
|
|
||||||
|
// Common wrapper for the main content (check mark + text)
|
||||||
|
// Ensures Check & Text are adjacent and the group takes available space.
|
||||||
|
const mainContentWrapper = (
|
||||||
|
<div className="flex flex-grow items-center">{defaultContent}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tool && hasAuthConfig) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
{mainContentWrapper}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedToolForConfig(tool);
|
||||||
|
setIsConfigModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
aria-label={`Configure ${serverName}`}
|
||||||
|
>
|
||||||
|
<Settings2 className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// For items without a settings icon, return the consistently wrapped main content.
|
||||||
|
return mainContentWrapper;
|
||||||
|
},
|
||||||
|
[mcpToolDetails, setSelectedToolForConfig, setIsConfigModalOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mcpToolDetails || mcpToolDetails.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelect
|
<>
|
||||||
items={mcpServers ?? []}
|
<MultiSelect
|
||||||
selectedValues={mcpValues ?? []}
|
items={mcpServerNames}
|
||||||
setSelectedValues={setMCPValues}
|
selectedValues={mcpValues ?? []}
|
||||||
defaultSelectedValues={mcpValues ?? []}
|
setSelectedValues={setMCPValues}
|
||||||
renderSelectedValues={renderSelectedValues}
|
defaultSelectedValues={mcpValues ?? []}
|
||||||
placeholder={localize('com_ui_mcp_servers')}
|
renderSelectedValues={renderSelectedValues}
|
||||||
popoverClassName="min-w-fit"
|
renderItemContent={renderItemContent}
|
||||||
className="badge-icon min-w-fit"
|
placeholder={localize('com_ui_mcp_servers')}
|
||||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
popoverClassName="min-w-fit"
|
||||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
className="badge-icon min-w-fit"
|
||||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||||
/>
|
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||||
|
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
||||||
|
/>
|
||||||
|
{selectedToolForConfig && (
|
||||||
|
<MCPConfigDialog
|
||||||
|
isOpen={isConfigModalOpen}
|
||||||
|
onOpenChange={setIsConfigModalOpen}
|
||||||
|
serverName={selectedToolForConfig.name}
|
||||||
|
fieldsSchema={(() => {
|
||||||
|
const schema: Record<string, ConfigFieldDetail> = {};
|
||||||
|
if (selectedToolForConfig?.authConfig) {
|
||||||
|
selectedToolForConfig.authConfig.forEach((field) => {
|
||||||
|
schema[field.authField] = {
|
||||||
|
title: field.label,
|
||||||
|
description: field.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
})()}
|
||||||
|
initialValues={(() => {
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
// Note: Actual initial values might need to be fetched if they are stored user-specifically
|
||||||
|
if (selectedToolForConfig?.authConfig) {
|
||||||
|
selectedToolForConfig.authConfig.forEach((field) => {
|
||||||
|
initial[field.authField] = ''; // Or fetched value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
})()}
|
||||||
|
onSave={(authData) => {
|
||||||
|
if (selectedToolForConfig) {
|
||||||
|
handleConfigSave(selectedToolForConfig.name, authData);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRevoke={() => {
|
||||||
|
if (selectedToolForConfig) {
|
||||||
|
handleConfigRevoke(selectedToolForConfig.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,33 @@ const Image = ({
|
||||||
[placeholderDimensions, height, width],
|
[placeholderDimensions, height, width],
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadImage = () => {
|
const downloadImage = async () => {
|
||||||
const link = document.createElement('a');
|
try {
|
||||||
link.href = imagePath;
|
const response = await fetch(imagePath);
|
||||||
link.download = altText;
|
if (!response.ok) {
|
||||||
document.body.appendChild(link);
|
throw new Error(`Failed to fetch image: ${response.status}`);
|
||||||
link.click();
|
}
|
||||||
document.body.removeChild(link);
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = altText || 'image.png';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download failed:', error);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = imagePath;
|
||||||
|
link.download = altText || 'image.png';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
remarkDirective,
|
remarkDirective,
|
||||||
artifactPlugin,
|
artifactPlugin,
|
||||||
[remarkMath, { singleDollarTextMath: true }],
|
[remarkMath, { singleDollarTextMath: false }],
|
||||||
unicodeCitation,
|
unicodeCitation,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const MarkdownLite = memo(
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
supersub,
|
supersub,
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
[remarkMath, { singleDollarTextMath: true }],
|
[remarkMath, { singleDollarTextMath: false }],
|
||||||
]}
|
]}
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,9 @@ const EditTextPart = ({
|
||||||
messages.map((msg) =>
|
messages.map((msg) =>
|
||||||
msg.messageId === messageId
|
msg.messageId === messageId
|
||||||
? {
|
? {
|
||||||
...msg,
|
...msg,
|
||||||
content: updatedContent,
|
content: updatedContent,
|
||||||
}
|
}
|
||||||
: msg,
|
: msg,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ type THoverButtons = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type HoverButtonProps = {
|
type HoverButtonProps = {
|
||||||
|
id?: string;
|
||||||
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
|
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
title: string;
|
title: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
|
|
@ -67,6 +68,7 @@ const extractMessageContent = (message: TMessage): string => {
|
||||||
|
|
||||||
const HoverButton = memo(
|
const HoverButton = memo(
|
||||||
({
|
({
|
||||||
|
id,
|
||||||
onClick,
|
onClick,
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
|
|
@ -89,6 +91,7 @@ const HoverButton = memo(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
id={id}
|
||||||
className={buttonStyle}
|
className={buttonStyle}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -213,6 +216,7 @@ const HoverButtons = ({
|
||||||
{/* Edit Button */}
|
{/* Edit Button */}
|
||||||
{isEditableEndpoint && (
|
{isEditableEndpoint && (
|
||||||
<HoverButton
|
<HoverButton
|
||||||
|
id={`edit-${message.messageId}`}
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
title={localize('com_ui_edit')}
|
title={localize('com_ui_edit')}
|
||||||
icon={<EditIcon size="19" />}
|
icon={<EditIcon size="19" />}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ function MessageAudio(props: TMessageAudio) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const SelectedTTS = TTSComponents[engineTTS];
|
const SelectedTTS = TTSComponents[engineTTS];
|
||||||
|
if (!SelectedTTS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return <SelectedTTS {...props} />;
|
return <SelectedTTS {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
72
client/src/components/OAuth/OAuthError.tsx
Normal file
72
client/src/components/OAuth/OAuthError.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
export default function OAuthError() {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const error = searchParams.get('error') || 'unknown_error';
|
||||||
|
|
||||||
|
const getErrorMessage = (error: string): string => {
|
||||||
|
switch (error) {
|
||||||
|
case 'missing_code':
|
||||||
|
return (
|
||||||
|
localize('com_ui_oauth_error_missing_code') ||
|
||||||
|
'Authorization code is missing. Please try again.'
|
||||||
|
);
|
||||||
|
case 'missing_state':
|
||||||
|
return (
|
||||||
|
localize('com_ui_oauth_error_missing_state') ||
|
||||||
|
'State parameter is missing. Please try again.'
|
||||||
|
);
|
||||||
|
case 'invalid_state':
|
||||||
|
return (
|
||||||
|
localize('com_ui_oauth_error_invalid_state') ||
|
||||||
|
'Invalid state parameter. Please try again.'
|
||||||
|
);
|
||||||
|
case 'callback_failed':
|
||||||
|
return (
|
||||||
|
localize('com_ui_oauth_error_callback_failed') ||
|
||||||
|
'Authentication callback failed. Please try again.'
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return localize('com_ui_oauth_error_generic') || error.replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
|
||||||
|
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||||
|
<svg
|
||||||
|
className="h-6 w-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-4 text-3xl font-bold text-gray-900">
|
||||||
|
{localize('com_ui_oauth_error_title') || 'Authentication Failed'}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-6 text-sm text-gray-600">{getErrorMessage(error)}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.close()}
|
||||||
|
className="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||||
|
aria-label={localize('com_ui_close_window') || 'Close Window'}
|
||||||
|
>
|
||||||
|
{localize('com_ui_close_window') || 'Close Window'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
client/src/components/OAuth/OAuthSuccess.tsx
Normal file
47
client/src/components/OAuth/OAuthSuccess.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
export default function OAuthSuccess() {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(3);
|
||||||
|
const serverName = searchParams.get('serverName');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const countdown = setInterval(() => {
|
||||||
|
setSecondsLeft((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(countdown);
|
||||||
|
window.close();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(countdown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
|
||||||
|
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
|
||||||
|
<h1 className="mb-4 text-3xl font-bold text-gray-900">
|
||||||
|
{localize('com_ui_oauth_success_title') || 'Authentication Successful'}
|
||||||
|
</h1>
|
||||||
|
<p className="mb-2 text-sm text-gray-600">
|
||||||
|
{localize('com_ui_oauth_success_description') ||
|
||||||
|
'Your authentication was successful. This window will close in'}{' '}
|
||||||
|
<span className="font-medium text-indigo-500">{secondsLeft}</span>{' '}
|
||||||
|
{localize('com_ui_seconds') || 'seconds'}.
|
||||||
|
</p>
|
||||||
|
{serverName && (
|
||||||
|
<p className="mt-4 text-xs text-gray-500">
|
||||||
|
{localize('com_ui_oauth_connected_to') || 'Connected to'}:{' '}
|
||||||
|
<span className="font-medium">{serverName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
client/src/components/OAuth/index.ts
Normal file
2
client/src/components/OAuth/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as OAuthSuccess } from './OAuthSuccess';
|
||||||
|
export { default as OAuthError } from './OAuthError';
|
||||||
|
|
@ -143,7 +143,7 @@ export default function VariableForm({
|
||||||
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-surface-tertiary p-4 text-text-secondary dark:bg-surface-primary sm:max-w-full md:max-h-96">
|
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-surface-tertiary p-4 text-text-secondary dark:bg-surface-primary sm:max-w-full md:max-h-96">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
|
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
|
||||||
rehypePlugins={[
|
rehypePlugins={[
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
[rehypeKatex],
|
[rehypeKatex],
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
supersub,
|
supersub,
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
[remarkMath, { singleDollarTextMath: true }],
|
[remarkMath, { singleDollarTextMath: false }],
|
||||||
]}
|
]}
|
||||||
rehypePlugins={[
|
rehypePlugins={[
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
supersub,
|
supersub,
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
[remarkMath, { singleDollarTextMath: true }],
|
[remarkMath, { singleDollarTextMath: false }],
|
||||||
]}
|
]}
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Controller, useWatch, useFormContext } from 'react-hook-form';
|
import { Controller, useWatch, useFormContext } from 'react-hook-form';
|
||||||
import { QueryKeys, EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
|
||||||
import type { TPlugin } from 'librechat-data-provider';
|
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
|
||||||
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
|
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
|
||||||
import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Providers';
|
import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Providers';
|
||||||
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
|
|
||||||
import Action from '~/components/SidePanel/Builder/Action';
|
import Action from '~/components/SidePanel/Builder/Action';
|
||||||
import { ToolSelectDialog } from '~/components/Tools';
|
import { ToolSelectDialog } from '~/components/Tools';
|
||||||
import { icons } from '~/hooks/Endpoint/Icons';
|
import { icons } from '~/hooks/Endpoint/Icons';
|
||||||
|
|
@ -15,7 +13,6 @@ import AgentAvatar from './AgentAvatar';
|
||||||
import FileContext from './FileContext';
|
import FileContext from './FileContext';
|
||||||
import SearchForm from './Search/Form';
|
import SearchForm from './Search/Form';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import MCPSection from './MCPSection';
|
|
||||||
import FileSearch from './FileSearch';
|
import FileSearch from './FileSearch';
|
||||||
import Artifacts from './Artifacts';
|
import Artifacts from './Artifacts';
|
||||||
import AgentTool from './AgentTool';
|
import AgentTool from './AgentTool';
|
||||||
|
|
@ -36,13 +33,10 @@ export default function AgentConfig({
|
||||||
}: Pick<AgentPanelProps, 'agentsConfig' | 'createMutation' | 'endpointsConfig'>) {
|
}: Pick<AgentPanelProps, 'agentsConfig' | 'createMutation' | 'endpointsConfig'>) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const fileMap = useFileMapContext();
|
const fileMap = useFileMapContext();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const methods = useFormContext<AgentForm>();
|
const methods = useFormContext<AgentForm>();
|
||||||
const [showToolDialog, setShowToolDialog] = useState(false);
|
const [showToolDialog, setShowToolDialog] = useState(false);
|
||||||
const { actions, setAction, setActivePanel } = useAgentPanelContext();
|
const { actions, setAction, groupedTools: allTools, setActivePanel } = useAgentPanelContext();
|
||||||
|
|
||||||
const allTools = queryClient.getQueryData<TPlugin[]>([QueryKeys.tools]) ?? [];
|
|
||||||
|
|
||||||
const { control } = methods;
|
const { control } = methods;
|
||||||
const provider = useWatch({ control, name: 'provider' });
|
const provider = useWatch({ control, name: 'provider' });
|
||||||
|
|
@ -169,6 +163,20 @@ export default function AgentConfig({
|
||||||
Icon = icons[iconKey];
|
Icon = icons[iconKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine what to show
|
||||||
|
const selectedToolIds = tools ?? [];
|
||||||
|
const visibleToolIds = new Set(selectedToolIds);
|
||||||
|
|
||||||
|
// Check what group parent tools should be shown if any subtool is present
|
||||||
|
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))) {
|
||||||
|
visibleToolIds.add(toolId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
|
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
|
||||||
|
|
@ -287,28 +295,37 @@ export default function AgentConfig({
|
||||||
${toolsEnabled === true && actionsEnabled === true ? ' + ' : ''}
|
${toolsEnabled === true && actionsEnabled === true ? ' + ' : ''}
|
||||||
${actionsEnabled === true ? localize('com_assistants_actions') : ''}`}
|
${actionsEnabled === true ? localize('com_assistants_actions') : ''}`}
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
{tools?.map((func, i) => (
|
<div className="mb-1">
|
||||||
<AgentTool
|
{/* // Render all visible IDs (including groups with subtools selected) */}
|
||||||
key={`${func}-${i}-${agent_id}`}
|
{[...visibleToolIds].map((toolId, i) => {
|
||||||
tool={func}
|
const tool = allTools[toolId];
|
||||||
allTools={allTools}
|
if (!tool) return null;
|
||||||
agent_id={agent_id}
|
return (
|
||||||
/>
|
<AgentTool
|
||||||
))}
|
key={`${toolId}-${i}-${agent_id}`}
|
||||||
{(actions ?? [])
|
tool={toolId}
|
||||||
.filter((action) => action.agent_id === agent_id)
|
allTools={allTools}
|
||||||
.map((action, i) => (
|
agent_id={agent_id}
|
||||||
<Action
|
/>
|
||||||
key={i}
|
);
|
||||||
action={action}
|
})}
|
||||||
onClick={() => {
|
</div>
|
||||||
setAction(action);
|
<div className="flex flex-col gap-1">
|
||||||
setActivePanel(Panel.actions);
|
{(actions ?? [])
|
||||||
}}
|
.filter((action) => action.agent_id === agent_id)
|
||||||
/>
|
.map((action, i) => (
|
||||||
))}
|
<Action
|
||||||
<div className="flex space-x-2">
|
key={i}
|
||||||
|
action={action}
|
||||||
|
onClick={() => {
|
||||||
|
setAction(action);
|
||||||
|
setActivePanel(Panel.actions);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex space-x-2">
|
||||||
{(toolsEnabled ?? false) && (
|
{(toolsEnabled ?? false) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -343,7 +360,6 @@ export default function AgentConfig({
|
||||||
<ToolSelectDialog
|
<ToolSelectDialog
|
||||||
isOpen={showToolDialog}
|
isOpen={showToolDialog}
|
||||||
setIsOpen={setShowToolDialog}
|
setIsOpen={setShowToolDialog}
|
||||||
toolsFormKey="tools"
|
|
||||||
endpoint={EModelEndpoint.agents}
|
endpoint={EModelEndpoint.agents}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,69 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import * as Ariakit from '@ariakit/react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import type { TPlugin } from 'librechat-data-provider';
|
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||||
import { OGDialog, OGDialogTrigger, Label } from '~/components/ui';
|
import type { AgentToolType } from 'librechat-data-provider';
|
||||||
|
import type { AgentForm } from '~/common';
|
||||||
|
import { Accordion, AccordionItem, AccordionContent } from '~/components/ui/Accordion';
|
||||||
|
import { OGDialog, OGDialogTrigger, Label, Checkbox } from '~/components/ui';
|
||||||
|
import { TrashIcon, CircleHelpIcon } from '~/components/svg';
|
||||||
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||||
import { useToastContext } from '~/Providers';
|
import { useToastContext } from '~/Providers';
|
||||||
import { TrashIcon } from '~/components/svg';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
export default function AgentTool({
|
export default function AgentTool({
|
||||||
tool,
|
tool,
|
||||||
allTools,
|
allTools,
|
||||||
agent_id = '',
|
|
||||||
}: {
|
}: {
|
||||||
tool: string;
|
tool: string;
|
||||||
allTools: TPlugin[];
|
allTools: Record<string, AgentToolType & { tools?: AgentToolType[] }>;
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [hoveredToolId, setHoveredToolId] = useState<string | null>(null);
|
||||||
|
const [accordionValue, setAccordionValue] = useState<string>('');
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||||
const { getValues, setValue } = useFormContext();
|
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||||
const currentTool = allTools.find((t) => t.pluginKey === tool);
|
const currentTool = allTools[tool];
|
||||||
|
|
||||||
|
const getSelectedTools = () => {
|
||||||
|
if (!currentTool?.tools) return [];
|
||||||
|
const formTools = getValues('tools') || [];
|
||||||
|
return currentTool.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFormTools = (newSelectedTools: string[]) => {
|
||||||
|
const currentTools = getValues('tools') || [];
|
||||||
|
const otherTools = currentTools.filter(
|
||||||
|
(t: string) => !currentTool?.tools?.some((st) => st.tool_id === t),
|
||||||
|
);
|
||||||
|
setValue('tools', [...otherTools, ...newSelectedTools]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTool = (toolId: string) => {
|
||||||
|
if (toolId) {
|
||||||
|
const toolIdsToRemove =
|
||||||
|
isGroup && currentTool.tools
|
||||||
|
? [toolId, ...currentTool.tools.map((t) => t.tool_id)]
|
||||||
|
: [toolId];
|
||||||
|
|
||||||
const removeTool = (tool: string) => {
|
|
||||||
if (tool) {
|
|
||||||
updateUserPlugins.mutate(
|
updateUserPlugins.mutate(
|
||||||
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
|
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
|
||||||
{
|
{
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
|
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const tools = getValues('tools').filter((fn: string) => fn !== tool);
|
const remainingToolIds = getValues('tools')?.filter(
|
||||||
setValue('tools', tools);
|
(toolId: string) => !toolIdsToRemove.includes(toolId),
|
||||||
|
);
|
||||||
|
setValue('tools', remainingToolIds);
|
||||||
showToast({ message: 'Tool deleted successfully', status: 'success' });
|
showToast({ message: 'Tool deleted successfully', status: 'success' });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -47,41 +75,309 @@ export default function AgentTool({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const isGroup = currentTool.tools && currentTool.tools.length > 0;
|
||||||
<OGDialog>
|
const selectedTools = getSelectedTools();
|
||||||
<div
|
const isExpanded = accordionValue === currentTool.tool_id;
|
||||||
className={cn('flex w-full items-center rounded-lg text-sm', !agent_id ? 'opacity-40' : '')}
|
|
||||||
onMouseEnter={() => setIsHovering(true)}
|
if (!isGroup) {
|
||||||
onMouseLeave={() => setIsHovering(false)}
|
return (
|
||||||
>
|
<OGDialog>
|
||||||
<div className="flex grow items-center">
|
<div
|
||||||
{currentTool.icon && (
|
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||||
<div className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full">
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
<div
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
onFocus={() => setIsFocused(true)}
|
||||||
style={{ backgroundImage: `url(${currentTool.icon})`, backgroundSize: 'cover' }}
|
onBlur={(e) => {
|
||||||
/>
|
// Check if focus is moving to a child element
|
||||||
</div>
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
)}
|
setIsFocused(false);
|
||||||
<div
|
}
|
||||||
className="h-9 grow px-3 py-2"
|
}}
|
||||||
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
>
|
||||||
>
|
<div className="flex grow items-center">
|
||||||
{currentTool.name}
|
{currentTool.metadata.icon && (
|
||||||
</div>
|
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||||
</div>
|
<div
|
||||||
|
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${currentTool.metadata.icon})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="grow px-2 py-1.5"
|
||||||
|
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{currentTool.metadata.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isHovering && (
|
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="transition-color flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
|
className={cn(
|
||||||
|
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200',
|
||||||
|
'hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
|
'focus:opacity-100',
|
||||||
|
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||||
|
)}
|
||||||
|
aria-label={`Delete ${currentTool.metadata.name}`}
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<TrashIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
)}
|
</div>
|
||||||
</div>
|
<OGDialogTemplate
|
||||||
|
showCloseButton={false}
|
||||||
|
title={localize('com_ui_delete_tool')}
|
||||||
|
mainClassName="px-0"
|
||||||
|
className="max-w-[450px]"
|
||||||
|
main={
|
||||||
|
<Label className="text-left text-sm font-medium">
|
||||||
|
{localize('com_ui_delete_tool_confirm')}
|
||||||
|
</Label>
|
||||||
|
}
|
||||||
|
selection={{
|
||||||
|
selectHandler: () => removeTool(currentTool.tool_id),
|
||||||
|
selectClasses:
|
||||||
|
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
|
||||||
|
selectText: localize('com_ui_delete'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</OGDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group tool with accordion
|
||||||
|
return (
|
||||||
|
<OGDialog>
|
||||||
|
<Accordion type="single" value={accordionValue} onValueChange={setAccordionValue} collapsible>
|
||||||
|
<AccordionItem value={currentTool.tool_id} className="group relative w-full border-none">
|
||||||
|
<div
|
||||||
|
className="relative flex w-full items-center gap-1 rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||||
|
onMouseEnter={() => setIsHovering(true)}
|
||||||
|
onMouseLeave={() => setIsHovering(false)}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={(e) => {
|
||||||
|
// Check if focus is moving to a child element
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
|
setIsFocused(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccordionPrimitive.Header asChild>
|
||||||
|
<AccordionPrimitive.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex grow items-center gap-1 rounded bg-transparent p-0 text-left transition-colors',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{currentTool.metadata.icon && (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
|
||||||
|
<div
|
||||||
|
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${currentTool.metadata.icon})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="grow px-2 py-1.5"
|
||||||
|
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
{currentTool.metadata.name}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{/* Container for grouped checkbox and chevron */}
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
{/* Grouped checkbox and chevron that slide together */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 transition-all duration-300',
|
||||||
|
isHovering || isFocused ? '-translate-x-8' : 'translate-x-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-checkbox-container
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="mt-1"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`select-all-${currentTool.tool_id}`}
|
||||||
|
checked={selectedTools.length === currentTool.tools?.length}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (currentTool.tools) {
|
||||||
|
const newSelectedTools = checked
|
||||||
|
? currentTool.tools.map((t) => t.tool_id)
|
||||||
|
: [];
|
||||||
|
updateFormTools(newSelectedTools);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 rounded border border-gray-300 transition-all duration-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500',
|
||||||
|
isExpanded ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const checkbox = e.currentTarget as HTMLButtonElement;
|
||||||
|
checkbox.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={isExpanded ? 0 : -1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none flex h-4 w-4 items-center justify-center transition-transform duration-300',
|
||||||
|
isExpanded ? 'rotate-180' : '',
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete button slides in from behind */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute right-0 transition-all duration-300',
|
||||||
|
isHovering || isFocused
|
||||||
|
? 'translate-x-0 opacity-100'
|
||||||
|
: 'translate-x-8 opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<OGDialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
|
||||||
|
'hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||||
|
'focus:translate-x-0 focus:opacity-100',
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Delete ${currentTool.metadata.name}`}
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</OGDialogTrigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{currentTool.tools?.map((subTool) => (
|
||||||
|
<label
|
||||||
|
key={subTool.tool_id}
|
||||||
|
htmlFor={subTool.tool_id}
|
||||||
|
className={cn(
|
||||||
|
'border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2',
|
||||||
|
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHoveredToolId(subTool.tool_id)}
|
||||||
|
onMouseLeave={() => setHoveredToolId(null)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={subTool.tool_id}
|
||||||
|
checked={selectedTools.includes(subTool.tool_id)}
|
||||||
|
onCheckedChange={(_checked) => {
|
||||||
|
const newSelectedTools = selectedTools.includes(subTool.tool_id)
|
||||||
|
? selectedTools.filter((t) => t !== subTool.tool_id)
|
||||||
|
: [...selectedTools, subTool.tool_id];
|
||||||
|
updateFormTools(newSelectedTools);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
const checkbox = e.currentTarget as HTMLButtonElement;
|
||||||
|
checkbox.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-gray-300 transition-[border-color] duration-200 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background dark:border-gray-600 dark:hover:border-gray-500"
|
||||||
|
/>
|
||||||
|
<span className="text-token-text-primary">{subTool.metadata.name}</span>
|
||||||
|
{subTool.metadata.description && (
|
||||||
|
<Ariakit.HovercardProvider placement="left-start">
|
||||||
|
<div className="ml-auto flex h-6 w-6 items-center justify-center">
|
||||||
|
<Ariakit.HovercardAnchor
|
||||||
|
render={
|
||||||
|
<Ariakit.Button
|
||||||
|
className={cn(
|
||||||
|
'flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary transition-opacity duration-200',
|
||||||
|
hoveredToolId === subTool.tool_id ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
aria-label={localize('com_ui_tool_info')}
|
||||||
|
>
|
||||||
|
<CircleHelpIcon className="h-4 w-4" />
|
||||||
|
<Ariakit.VisuallyHidden>
|
||||||
|
{localize('com_ui_tool_info')}
|
||||||
|
</Ariakit.VisuallyHidden>
|
||||||
|
</Ariakit.Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Ariakit.HovercardDisclosure
|
||||||
|
className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
aria-label={localize('com_ui_tool_more_info')}
|
||||||
|
aria-expanded={hoveredToolId === subTool.tool_id}
|
||||||
|
aria-controls={`tool-description-${subTool.tool_id}`}
|
||||||
|
>
|
||||||
|
<Ariakit.VisuallyHidden>
|
||||||
|
{localize('com_ui_tool_more_info')}
|
||||||
|
</Ariakit.VisuallyHidden>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Ariakit.HovercardDisclosure>
|
||||||
|
</div>
|
||||||
|
<Ariakit.Hovercard
|
||||||
|
id={`tool-description-${subTool.tool_id}`}
|
||||||
|
gutter={14}
|
||||||
|
shift={40}
|
||||||
|
flip={false}
|
||||||
|
className="z-[999] w-80 scale-95 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary opacity-0 shadow-md transition-all duration-200 data-[enter]:scale-100 data-[leave]:scale-95 data-[enter]:opacity-100 data-[leave]:opacity-0"
|
||||||
|
portal={true}
|
||||||
|
unmountOnHide={true}
|
||||||
|
role="tooltip"
|
||||||
|
aria-label={subTool.metadata.description}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{subTool.metadata.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Ariakit.Hovercard>
|
||||||
|
</Ariakit.HovercardProvider>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
<OGDialogTemplate
|
<OGDialogTemplate
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
title={localize('com_ui_delete_tool')}
|
title={localize('com_ui_delete_tool')}
|
||||||
|
|
@ -93,7 +389,7 @@ export default function AgentTool({
|
||||||
</Label>
|
</Label>
|
||||||
}
|
}
|
||||||
selection={{
|
selection={{
|
||||||
selectHandler: () => removeTool(currentTool.pluginKey),
|
selectHandler: () => removeTool(currentTool.tool_id),
|
||||||
selectClasses:
|
selectClasses:
|
||||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
|
'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'),
|
selectText: localize('com_ui_delete'),
|
||||||
|
|
|
||||||
253
client/src/components/SidePanel/MCP/MCPPanel.tsx
Normal file
253
client/src/components/SidePanel/MCP/MCPPanel.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
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 { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||||
|
import type { TUpdateUserPlugins } from 'librechat-data-provider';
|
||||||
|
import { Button, Input, Label } from '~/components/ui';
|
||||||
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
|
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||||
|
import { useToastContext } from '~/Providers';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
interface ServerConfigWithVars {
|
||||||
|
serverName: string;
|
||||||
|
config: {
|
||||||
|
customUserVars: Record<string, { title: string; description: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MCPPanel() {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
|
||||||
|
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mcpServerDefinitions = useMemo(() => {
|
||||||
|
if (!startupConfig?.mcpServers) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(startupConfig.mcpServers)
|
||||||
|
.filter(
|
||||||
|
([, serverConfig]) =>
|
||||||
|
serverConfig.customUserVars && Object.keys(serverConfig.customUserVars).length > 0,
|
||||||
|
)
|
||||||
|
.map(([serverName, config]) => ({
|
||||||
|
serverName,
|
||||||
|
iconPath: null,
|
||||||
|
config: {
|
||||||
|
...config,
|
||||||
|
customUserVars: config.customUserVars ?? {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, [startupConfig?.mcpServers]);
|
||||||
|
|
||||||
|
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Error updating MCP custom user variables:', error);
|
||||||
|
showToast({
|
||||||
|
message: localize('com_nav_mcp_vars_update_error'),
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveServerVars = useCallback(
|
||||||
|
(serverName: string, updatedValues: Record<string, string>) => {
|
||||||
|
const payload: TUpdateUserPlugins = {
|
||||||
|
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||||
|
action: 'install', // 'install' action is used to set/update credentials/variables
|
||||||
|
auth: updatedValues,
|
||||||
|
};
|
||||||
|
updateUserPluginsMutation.mutate(payload);
|
||||||
|
},
|
||||||
|
[updateUserPluginsMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRevokeServerVars = useCallback(
|
||||||
|
(serverName: string) => {
|
||||||
|
const payload: TUpdateUserPlugins = {
|
||||||
|
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||||
|
action: 'uninstall', // 'uninstall' action clears the variables
|
||||||
|
auth: {}, // Empty auth for uninstall
|
||||||
|
};
|
||||||
|
updateUserPluginsMutation.mutate(payload);
|
||||||
|
},
|
||||||
|
[updateUserPluginsMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleServerClickToEdit = (serverName: string) => {
|
||||||
|
setSelectedServerNameForEditing(serverName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoBackToList = () => {
|
||||||
|
setSelectedServerNameForEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedServerNameForEditing) {
|
||||||
|
// Editing View
|
||||||
|
const serverBeingEdited = mcpServerDefinitions.find(
|
||||||
|
(s) => s.serverName === selectedServerNameForEditing,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!serverBeingEdited) {
|
||||||
|
// Fallback to list view if server not found
|
||||||
|
setSelectedServerNameForEditing(null);
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-center text-sm text-gray-500">
|
||||||
|
{localize('com_ui_error')}: {localize('com_ui_mcp_server_not_found')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleGoBackToList}
|
||||||
|
className="mb-3 flex items-center px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
|
{localize('com_ui_back')}
|
||||||
|
</Button>
|
||||||
|
<h3 className="mb-3 text-lg font-medium">
|
||||||
|
{localize('com_sidepanel_mcp_variables_for', { '0': serverBeingEdited.serverName })}
|
||||||
|
</h3>
|
||||||
|
<MCPVariableEditor
|
||||||
|
server={serverBeingEdited}
|
||||||
|
onSave={handleSaveServerVars}
|
||||||
|
onRevoke={handleRevokeServerVars}
|
||||||
|
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Server List View
|
||||||
|
return (
|
||||||
|
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mcpServerDefinitions.map((server) => (
|
||||||
|
<Button
|
||||||
|
key={server.serverName}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start dark:hover:bg-gray-700"
|
||||||
|
onClick={() => handleServerClickToEdit(server.serverName)}
|
||||||
|
>
|
||||||
|
{server.serverName}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner component for the form - remains the same
|
||||||
|
interface MCPVariableEditorProps {
|
||||||
|
server: ServerConfigWithVars;
|
||||||
|
onSave: (serverName: string, updatedValues: Record<string, string>) => void;
|
||||||
|
onRevoke: (serverName: string) => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariableEditorProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
} = useForm<Record<string, string>>({
|
||||||
|
defaultValues: {}, // Initialize empty, will be reset by useEffect
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Always initialize with empty strings based on the schema
|
||||||
|
const initialFormValues = Object.keys(server.config.customUserVars).reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
acc[key] = '';
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
reset(initialFormValues);
|
||||||
|
}, [reset, server.config.customUserVars]);
|
||||||
|
|
||||||
|
const onFormSubmit = (data: Record<string, string>) => {
|
||||||
|
onSave(server.serverName, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeClick = () => {
|
||||||
|
onRevoke(server.serverName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-4 mt-2 space-y-4">
|
||||||
|
{Object.entries(server.config.customUserVars).map(([key, details]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label htmlFor={`${server.serverName}-${key}`} className="text-sm font-medium">
|
||||||
|
{details.title}
|
||||||
|
</Label>
|
||||||
|
<Controller
|
||||||
|
name={key}
|
||||||
|
control={control}
|
||||||
|
defaultValue={''}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id={`${server.serverName}-${key}`}
|
||||||
|
type="text"
|
||||||
|
{...field}
|
||||||
|
placeholder={localize('com_sidepanel_mcp_enter_value', { '0': details.title })}
|
||||||
|
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{details.description && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: details.description }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
{Object.keys(server.config.customUserVars).length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRevokeClick}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{localize('com_ui_revoke')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-green-500 text-white hover:bg-green-600"
|
||||||
|
disabled={isSubmitting || !isDirty}
|
||||||
|
>
|
||||||
|
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx
Normal file
21
client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Skeleton } from '~/components/ui';
|
||||||
|
|
||||||
|
export default function MCPPanelSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-2">
|
||||||
|
{[1, 2].map((serverIdx) => (
|
||||||
|
<div key={serverIdx} className="space-y-4">
|
||||||
|
<Skeleton className="h-6 w-1/3 rounded-lg" /> {/* Server Name */}
|
||||||
|
{[1, 2].map((varIdx) => (
|
||||||
|
<div key={varIdx} className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-1/4 rounded-lg" /> {/* Variable Title */}
|
||||||
|
<Skeleton className="h-8 w-full rounded-lg" /> {/* Input Field */}
|
||||||
|
<Skeleton className="h-4 w-2/3 rounded-lg" /> {/* Description */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { TPlugin } from 'librechat-data-provider';
|
|
||||||
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
|
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
|
||||||
|
import { AgentToolType } from 'librechat-data-provider';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
type ToolItemProps = {
|
type ToolItemProps = {
|
||||||
tool: TPlugin;
|
tool: AgentToolType;
|
||||||
onAddTool: () => void;
|
onAddTool: () => void;
|
||||||
onRemoveTool: () => void;
|
onRemoveTool: () => void;
|
||||||
isInstalled?: boolean;
|
isInstalled?: boolean;
|
||||||
|
|
@ -19,15 +19,19 @@ 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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">
|
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="h-[70px] w-[70px] shrink-0">
|
<div className="h-[70px] w-[70px] shrink-0">
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{tool.icon != null && tool.icon ? (
|
{icon ? (
|
||||||
<img
|
<img
|
||||||
src={tool.icon}
|
src={icon}
|
||||||
alt={localize('com_ui_logo', { 0: tool.name })}
|
alt={localize('com_ui_logo', { 0: name })}
|
||||||
className="h-full w-full rounded-[5px] bg-white"
|
className="h-full w-full rounded-[5px] bg-white"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -40,12 +44,12 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col items-start justify-between">
|
<div className="flex min-w-0 flex-col items-start justify-between">
|
||||||
<div className="mb-2 line-clamp-1 max-w-full text-lg leading-5 text-text-primary">
|
<div className="mb-2 line-clamp-1 max-w-full text-lg leading-5 text-text-primary">
|
||||||
{tool.name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
{!isInstalled ? (
|
{!isInstalled ? (
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary relative"
|
className="btn btn-primary relative"
|
||||||
aria-label={`${localize('com_ui_add')} ${tool.name}`}
|
aria-label={`${localize('com_ui_add')} ${name}`}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-center gap-2">
|
<div className="flex w-full items-center justify-center gap-2">
|
||||||
|
|
@ -57,7 +61,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
|
||||||
<button
|
<button
|
||||||
className="btn relative bg-gray-300 hover:bg-gray-400 dark:bg-gray-50 dark:hover:bg-gray-200"
|
className="btn relative bg-gray-300 hover:bg-gray-400 dark:bg-gray-50 dark:hover:bg-gray-200"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
aria-label={`${localize('com_nav_tool_remove')} ${tool.name}`}
|
aria-label={`${localize('com_nav_tool_remove')} ${name}`}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-center gap-2">
|
<div className="flex w-full items-center justify-center gap-2">
|
||||||
{localize('com_nav_tool_remove')}
|
{localize('com_nav_tool_remove')}
|
||||||
|
|
@ -67,7 +71,7 @@ function ToolItem({ tool, onAddTool, onRemoveTool, isInstalled = false }: ToolIt
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{tool.description}</div>
|
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Search, X } from 'lucide-react';
|
import { Search, X } from 'lucide-react';
|
||||||
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { isAgentsEndpoint } from 'librechat-data-provider';
|
import { Constants, isAgentsEndpoint } from 'librechat-data-provider';
|
||||||
|
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
|
||||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||||
import type {
|
import type {
|
||||||
AssistantsEndpoint,
|
AssistantsEndpoint,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
TPluginAction,
|
TPluginAction,
|
||||||
|
AgentToolType,
|
||||||
TError,
|
TError,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import type { TPluginStoreDialogProps } from '~/common/types';
|
import type { AgentForm, TPluginStoreDialogProps } from '~/common';
|
||||||
import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store';
|
import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store';
|
||||||
|
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||||
import { useLocalize, usePluginDialogHelpers } from '~/hooks';
|
import { useLocalize, usePluginDialogHelpers } from '~/hooks';
|
||||||
import { useAvailableToolsQuery } from '~/data-provider';
|
import { useAvailableToolsQuery } from '~/data-provider';
|
||||||
import ToolItem from './ToolItem';
|
import ToolItem from './ToolItem';
|
||||||
|
|
@ -20,14 +22,13 @@ function ToolSelectDialog({
|
||||||
isOpen,
|
isOpen,
|
||||||
endpoint,
|
endpoint,
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
toolsFormKey,
|
|
||||||
}: TPluginStoreDialogProps & {
|
}: TPluginStoreDialogProps & {
|
||||||
toolsFormKey: string;
|
|
||||||
endpoint: AssistantsEndpoint | EModelEndpoint.agents;
|
endpoint: AssistantsEndpoint | EModelEndpoint.agents;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { getValues, setValue } = useFormContext();
|
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||||
const { data: tools } = useAvailableToolsQuery(endpoint);
|
const { data: tools } = useAvailableToolsQuery(endpoint);
|
||||||
|
const { groupedTools } = useAgentPanelContext();
|
||||||
const isAgentTools = isAgentsEndpoint(endpoint);
|
const isAgentTools = isAgentsEndpoint(endpoint);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -66,11 +67,23 @@ function ToolSelectDialog({
|
||||||
}, 5000);
|
}, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toolsFormKey = 'tools';
|
||||||
const handleInstall = (pluginAction: TPluginAction) => {
|
const handleInstall = (pluginAction: TPluginAction) => {
|
||||||
const addFunction = () => {
|
const addFunction = () => {
|
||||||
const fns = getValues(toolsFormKey).slice();
|
const installedToolIds: string[] = getValues(toolsFormKey) || [];
|
||||||
fns.push(pluginAction.pluginKey);
|
// Add the parent
|
||||||
setValue(toolsFormKey, fns);
|
installedToolIds.push(pluginAction.pluginKey);
|
||||||
|
|
||||||
|
// If this tool is a group, add subtools too
|
||||||
|
const groupObj = groupedTools[pluginAction.pluginKey];
|
||||||
|
if (groupObj?.tools && groupObj.tools.length > 0) {
|
||||||
|
for (const sub of groupObj.tools) {
|
||||||
|
if (!installedToolIds.includes(sub.tool_id)) {
|
||||||
|
installedToolIds.push(sub.tool_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setValue(toolsFormKey, Array.from(new Set(installedToolIds))); // no duplicates just in case
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!pluginAction.auth) {
|
if (!pluginAction.auth) {
|
||||||
|
|
@ -87,17 +100,21 @@ function ToolSelectDialog({
|
||||||
setShowPluginAuthForm(false);
|
setShowPluginAuthForm(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoveTool = (tool: string) => {
|
const onRemoveTool = (toolId: string) => {
|
||||||
setShowPluginAuthForm(false);
|
const groupObj = groupedTools[toolId];
|
||||||
|
const toolIdsToRemove = [toolId];
|
||||||
|
if (groupObj?.tools && groupObj.tools.length > 0) {
|
||||||
|
toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id));
|
||||||
|
}
|
||||||
|
// Remove these from the formTools
|
||||||
updateUserPlugins.mutate(
|
updateUserPlugins.mutate(
|
||||||
{ pluginKey: tool, action: 'uninstall', auth: null, isEntityTool: true },
|
{ pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true },
|
||||||
{
|
{
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => handleInstallError(error as TError),
|
||||||
handleInstallError(error as TError);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
const fns = getValues(toolsFormKey).filter((fn: string) => fn !== tool);
|
const remainingToolIds =
|
||||||
setValue(toolsFormKey, fns);
|
getValues(toolsFormKey)?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || [];
|
||||||
|
setValue(toolsFormKey, remainingToolIds);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -108,22 +125,45 @@ function ToolSelectDialog({
|
||||||
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
|
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
|
||||||
setSelectedPlugin(getAvailablePluginFromKey);
|
setSelectedPlugin(getAvailablePluginFromKey);
|
||||||
|
|
||||||
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
|
const isMCPTool = pluginKey.includes(Constants.mcp_delimiter);
|
||||||
|
|
||||||
if (authConfig && authConfig.length > 0 && !authenticated) {
|
if (isMCPTool) {
|
||||||
setShowPluginAuthForm(true);
|
// MCP tools have their variables configured elsewhere (e.g., MCPPanel or MCPSelect),
|
||||||
|
// so we directly proceed to install without showing the auth form.
|
||||||
|
handleInstall({ pluginKey, action: 'install', auth: {} });
|
||||||
} else {
|
} else {
|
||||||
handleInstall({ pluginKey, action: 'install', auth: null });
|
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
|
||||||
|
if (authConfig && authConfig.length > 0 && !authenticated) {
|
||||||
|
setShowPluginAuthForm(true);
|
||||||
|
} else {
|
||||||
|
handleInstall({
|
||||||
|
pluginKey,
|
||||||
|
action: 'install',
|
||||||
|
auth: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredTools = tools?.filter((tool) =>
|
const filteredTools = Object.values(groupedTools || {}).filter(
|
||||||
tool.name.toLowerCase().includes(searchValue.toLowerCase()),
|
(tool: AgentToolType & { tools?: AgentToolType[] }) => {
|
||||||
|
// Check if the parent tool matches
|
||||||
|
if (tool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check if any child tools match
|
||||||
|
if (tool.tools) {
|
||||||
|
return tool.tools.some((childTool) =>
|
||||||
|
childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filteredTools) {
|
if (filteredTools) {
|
||||||
setMaxPage(Math.ceil(filteredTools.length / itemsPerPage));
|
setMaxPage(Math.ceil(Object.keys(filteredTools || {}).length / itemsPerPage));
|
||||||
if (searchChanged) {
|
if (searchChanged) {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setSearchChanged(false);
|
setSearchChanged(false);
|
||||||
|
|
@ -155,7 +195,7 @@ function ToolSelectDialog({
|
||||||
{/* Full-screen container to center the panel */}
|
{/* Full-screen container to center the panel */}
|
||||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
<DialogPanel
|
<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"
|
className="relative max-h-[90vh] 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' }}
|
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 justify-between border-b-[1px] border-border-medium px-4 pb-4 pt-5 sm:p-6">
|
||||||
|
|
@ -228,9 +268,9 @@ function ToolSelectDialog({
|
||||||
<ToolItem
|
<ToolItem
|
||||||
key={index}
|
key={index}
|
||||||
tool={tool}
|
tool={tool}
|
||||||
isInstalled={getValues(toolsFormKey).includes(tool.pluginKey)}
|
isInstalled={getValues(toolsFormKey)?.includes(tool.tool_id) || false}
|
||||||
onAddTool={() => onAddTool(tool.pluginKey)}
|
onAddTool={() => onAddTool(tool.tool_id)}
|
||||||
onRemoveTool={() => onRemoveTool(tool.pluginKey)}
|
onRemoveTool={() => onRemoveTool(tool.tool_id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
122
client/src/components/ui/MCPConfigDialog.tsx
Normal file
122
client/src/components/ui/MCPConfigDialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { Input, Label, OGDialog, Button } from '~/components/ui';
|
||||||
|
import OGDialogTemplate from '~/components/ui/OGDialogTemplate';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
export interface ConfigFieldDetail {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPConfigDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
fieldsSchema: Record<string, ConfigFieldDetail>;
|
||||||
|
initialValues: Record<string, string>;
|
||||||
|
onSave: (updatedValues: Record<string, string>) => void;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
onRevoke?: () => void;
|
||||||
|
serverName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MCPConfigDialog({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
fieldsSchema,
|
||||||
|
initialValues,
|
||||||
|
onSave,
|
||||||
|
isSubmitting = false,
|
||||||
|
onRevoke,
|
||||||
|
serverName,
|
||||||
|
}: MCPConfigDialogProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, _ },
|
||||||
|
} = useForm<Record<string, string>>({
|
||||||
|
defaultValues: initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
reset(initialValues);
|
||||||
|
}
|
||||||
|
}, [isOpen, initialValues, reset]);
|
||||||
|
|
||||||
|
const onFormSubmit = (data: Record<string, string>) => {
|
||||||
|
onSave(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevoke = () => {
|
||||||
|
if (onRevoke) {
|
||||||
|
onRevoke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogTitle = localize('com_ui_configure_mcp_variables_for', { 0: serverName });
|
||||||
|
const dialogDescription = localize('com_ui_mcp_dialog_desc');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<OGDialogTemplate
|
||||||
|
className="sm:max-w-lg"
|
||||||
|
title={dialogTitle}
|
||||||
|
description={dialogDescription}
|
||||||
|
headerClassName="px-6 pt-6 pb-4"
|
||||||
|
main={
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4 px-6 pb-2">
|
||||||
|
{Object.entries(fieldsSchema).map(([key, details]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label htmlFor={key} className="text-sm font-medium">
|
||||||
|
{details.title}
|
||||||
|
</Label>
|
||||||
|
<Controller
|
||||||
|
name={key}
|
||||||
|
control={control}
|
||||||
|
defaultValue={initialValues[key] || ''}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
type="text"
|
||||||
|
{...field}
|
||||||
|
placeholder={localize('com_ui_mcp_enter_var', { 0: details.title })}
|
||||||
|
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{details.description && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: details.description }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
selection={{
|
||||||
|
selectHandler: handleSubmit(onFormSubmit),
|
||||||
|
selectClasses: 'bg-green-500 hover:bg-green-600 text-white',
|
||||||
|
selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'),
|
||||||
|
}}
|
||||||
|
buttons={
|
||||||
|
onRevoke && (
|
||||||
|
<Button
|
||||||
|
onClick={handleRevoke}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{localize('com_ui_revoke')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
footerClassName="flex justify-end gap-2 px-6 pb-6 pt-2"
|
||||||
|
showCancelButton={true}
|
||||||
|
/>
|
||||||
|
</OGDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,11 @@ interface MultiSelectProps<T extends string> {
|
||||||
selectItemsClassName?: string;
|
selectItemsClassName?: string;
|
||||||
selectedValues: T[];
|
selectedValues: T[];
|
||||||
setSelectedValues: (values: T[]) => void;
|
setSelectedValues: (values: T[]) => void;
|
||||||
|
renderItemContent?: (
|
||||||
|
value: T,
|
||||||
|
defaultContent: React.ReactNode,
|
||||||
|
isSelected: boolean,
|
||||||
|
) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultRender<T extends string>(values: T[], placeholder?: string) {
|
function defaultRender<T extends string>(values: T[], placeholder?: string) {
|
||||||
|
|
@ -54,9 +59,9 @@ export default function MultiSelect<T extends string>({
|
||||||
selectItemsClassName,
|
selectItemsClassName,
|
||||||
selectedValues = [],
|
selectedValues = [],
|
||||||
setSelectedValues,
|
setSelectedValues,
|
||||||
|
renderItemContent,
|
||||||
}: MultiSelectProps<T>) {
|
}: MultiSelectProps<T>) {
|
||||||
const selectRef = useRef<HTMLButtonElement>(null);
|
const selectRef = useRef<HTMLButtonElement>(null);
|
||||||
// const [selectedValues, setSelectedValues] = React.useState<T[]>(defaultSelectedValues);
|
|
||||||
|
|
||||||
const handleValueChange = (values: T[]) => {
|
const handleValueChange = (values: T[]) => {
|
||||||
setSelectedValues(values);
|
setSelectedValues(values);
|
||||||
|
|
@ -105,23 +110,33 @@ export default function MultiSelect<T extends string>({
|
||||||
popoverClassName,
|
popoverClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{items.map((value) => (
|
{items.map((value) => {
|
||||||
<SelectItem
|
const defaultContent = (
|
||||||
key={value}
|
<>
|
||||||
value={value}
|
<SelectItemCheck className="text-primary" />
|
||||||
className={cn(
|
<span className="truncate">{value}</span>
|
||||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
|
</>
|
||||||
'scroll-m-1 outline-none transition-colors',
|
);
|
||||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
const isCurrentItemSelected = selectedValues.includes(value);
|
||||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
return (
|
||||||
'w-full min-w-0 text-sm',
|
<SelectItem
|
||||||
itemClassName,
|
key={value}
|
||||||
)}
|
value={value}
|
||||||
>
|
className={cn(
|
||||||
<SelectItemCheck className="text-primary" />
|
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
|
||||||
<span className="truncate">{value}</span>
|
'scroll-m-1 outline-none transition-colors',
|
||||||
</SelectItem>
|
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||||
))}
|
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||||
|
'w-full min-w-0 text-sm',
|
||||||
|
itemClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderItemContent
|
||||||
|
? renderItemContent(value, defaultContent, isCurrentItemSelected)
|
||||||
|
: defaultContent}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SelectPopover>
|
</SelectPopover>
|
||||||
</SelectProvider>
|
</SelectProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Constants,
|
Constants,
|
||||||
|
|
@ -51,10 +52,10 @@ export default function useChatFunctions({
|
||||||
getMessages,
|
getMessages,
|
||||||
setMessages,
|
setMessages,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
conversation,
|
|
||||||
latestMessage,
|
latestMessage,
|
||||||
setSubmission,
|
setSubmission,
|
||||||
setLatestMessage,
|
setLatestMessage,
|
||||||
|
conversation: immutableConversation,
|
||||||
}: {
|
}: {
|
||||||
index?: number;
|
index?: number;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
|
@ -77,8 +78,8 @@ export default function useChatFunctions({
|
||||||
const isTemporary = useRecoilValue(store.isTemporary);
|
const isTemporary = useRecoilValue(store.isTemporary);
|
||||||
const codeArtifacts = useRecoilValue(store.codeArtifacts);
|
const codeArtifacts = useRecoilValue(store.codeArtifacts);
|
||||||
const includeShadcnui = useRecoilValue(store.includeShadcnui);
|
const includeShadcnui = useRecoilValue(store.includeShadcnui);
|
||||||
const { getExpiry } = useUserKey(conversation?.endpoint ?? '');
|
|
||||||
const customPromptMode = useRecoilValue(store.customPromptMode);
|
const customPromptMode = useRecoilValue(store.customPromptMode);
|
||||||
|
const { getExpiry } = useUserKey(immutableConversation?.endpoint ?? '');
|
||||||
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
|
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
|
||||||
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
|
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
|
||||||
|
|
||||||
|
|
@ -108,6 +109,8 @@ export default function useChatFunctions({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conversation = cloneDeep(immutableConversation);
|
||||||
|
|
||||||
const endpoint = conversation?.endpoint;
|
const endpoint = conversation?.endpoint;
|
||||||
if (endpoint === null) {
|
if (endpoint === null) {
|
||||||
console.error('No endpoint available');
|
console.error('No endpoint available');
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { useChatContext } from '~/Providers/ChatContext';
|
||||||
import { useToastContext } from '~/Providers/ToastContext';
|
import { useToastContext } from '~/Providers/ToastContext';
|
||||||
import { logger, validateFiles } from '~/utils';
|
import { logger, validateFiles } from '~/utils';
|
||||||
import useClientSideResize from './useClientSideResize';
|
import useClientSideResize from './useClientSideResize';
|
||||||
|
import { processFileForUpload } from '~/utils/heicConverter';
|
||||||
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
import { useDelayedUploadToast } from './useDelayedUploadToast';
|
||||||
import useUpdateFiles from './useUpdateFiles';
|
import useUpdateFiles from './useUpdateFiles';
|
||||||
|
|
||||||
|
|
@ -264,13 +265,61 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||||
for (const originalFile of fileList) {
|
for (const originalFile of fileList) {
|
||||||
const file_id = v4();
|
const file_id = v4();
|
||||||
try {
|
try {
|
||||||
let processedFile = originalFile;
|
// Create initial preview with original file
|
||||||
|
const initialPreview = URL.createObjectURL(originalFile);
|
||||||
|
|
||||||
|
// Create initial ExtendedFile to show immediately
|
||||||
|
const initialExtendedFile: ExtendedFile = {
|
||||||
|
file_id,
|
||||||
|
file: originalFile,
|
||||||
|
type: originalFile.type,
|
||||||
|
preview: initialPreview,
|
||||||
|
progress: 0.1, // Show as processing
|
||||||
|
size: originalFile.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (_toolResource != null && _toolResource !== '') {
|
||||||
|
initialExtendedFile.tool_resource = _toolResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file immediately to show in UI
|
||||||
|
addFile(initialExtendedFile);
|
||||||
|
|
||||||
|
// Check if HEIC conversion is needed and show toast
|
||||||
|
const isHEIC =
|
||||||
|
originalFile.type === 'image/heic' ||
|
||||||
|
originalFile.type === 'image/heif' ||
|
||||||
|
originalFile.name.toLowerCase().match(/\.(heic|heif)$/);
|
||||||
|
|
||||||
|
if (isHEIC) {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_info_heic_converting'),
|
||||||
|
status: 'info',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process file for HEIC conversion if needed
|
||||||
|
const heicProcessedFile = await processFileForUpload(
|
||||||
|
originalFile,
|
||||||
|
0.9,
|
||||||
|
(conversionProgress) => {
|
||||||
|
// Update progress during HEIC conversion (0.1 to 0.5 range for conversion)
|
||||||
|
const adjustedProgress = 0.1 + conversionProgress * 0.4;
|
||||||
|
replaceFile({
|
||||||
|
...initialExtendedFile,
|
||||||
|
progress: adjustedProgress,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let finalProcessedFile = heicProcessedFile;
|
||||||
|
|
||||||
// Apply client-side resizing if available and appropriate
|
// Apply client-side resizing if available and appropriate
|
||||||
if (originalFile.type.startsWith('image/')) {
|
if (heicProcessedFile.type.startsWith('image/')) {
|
||||||
try {
|
try {
|
||||||
const resizeResult = await resizeImageIfNeeded(originalFile);
|
const resizeResult = await resizeImageIfNeeded(heicProcessedFile);
|
||||||
processedFile = resizeResult.file;
|
finalProcessedFile = resizeResult.file;
|
||||||
|
|
||||||
// Show toast notification if image was resized
|
// Show toast notification if image was resized
|
||||||
if (resizeResult.resized && resizeResult.result) {
|
if (resizeResult.resized && resizeResult.result) {
|
||||||
|
|
@ -287,45 +336,66 @@ const useFileHandling = (params?: UseFileHandling) => {
|
||||||
}
|
}
|
||||||
} catch (resizeError) {
|
} catch (resizeError) {
|
||||||
console.warn('Image resize failed, using original:', resizeError);
|
console.warn('Image resize failed, using original:', resizeError);
|
||||||
// Continue with original file if resizing fails
|
// Continue with HEIC processed file if resizing fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = URL.createObjectURL(processedFile);
|
// If file was processed (HEIC converted or resized), update with new file and preview
|
||||||
const extendedFile: ExtendedFile = {
|
if (finalProcessedFile !== originalFile) {
|
||||||
file_id,
|
URL.revokeObjectURL(initialPreview); // Clean up original preview
|
||||||
file: processedFile,
|
const newPreview = URL.createObjectURL(finalProcessedFile);
|
||||||
type: processedFile.type,
|
|
||||||
preview,
|
|
||||||
progress: 0.2,
|
|
||||||
size: processedFile.size,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (_toolResource != null && _toolResource !== '') {
|
const updatedExtendedFile: ExtendedFile = {
|
||||||
extendedFile.tool_resource = _toolResource;
|
...initialExtendedFile,
|
||||||
|
file: finalProcessedFile,
|
||||||
|
type: finalProcessedFile.type,
|
||||||
|
preview: newPreview,
|
||||||
|
progress: 0.5, // Processing complete, ready for upload
|
||||||
|
size: finalProcessedFile.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
replaceFile(updatedExtendedFile);
|
||||||
|
|
||||||
|
const isImage = finalProcessedFile.type.split('/')[0] === 'image';
|
||||||
|
if (isImage) {
|
||||||
|
loadImage(updatedExtendedFile, newPreview);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startUpload(updatedExtendedFile);
|
||||||
|
} else {
|
||||||
|
// File wasn't processed, proceed with original
|
||||||
|
const isImage = originalFile.type.split('/')[0] === 'image';
|
||||||
|
const tool_resource =
|
||||||
|
initialExtendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
|
||||||
|
if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) {
|
||||||
|
/** Note: this needs to be removed when we can support files to providers */
|
||||||
|
setError('com_error_files_unsupported_capability');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress to show ready for upload
|
||||||
|
const readyExtendedFile = {
|
||||||
|
...initialExtendedFile,
|
||||||
|
progress: 0.2,
|
||||||
|
};
|
||||||
|
replaceFile(readyExtendedFile);
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
loadImage(readyExtendedFile, initialPreview);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startUpload(readyExtendedFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImage = processedFile.type.split('/')[0] === 'image';
|
|
||||||
const tool_resource =
|
|
||||||
extendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource;
|
|
||||||
if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) {
|
|
||||||
/** Note: this needs to be removed when we can support files to providers */
|
|
||||||
setError('com_error_files_unsupported_capability');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
addFile(extendedFile);
|
|
||||||
|
|
||||||
if (isImage) {
|
|
||||||
loadImage(extendedFile, preview);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await startUpload(extendedFile);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deleteFileById(file_id);
|
deleteFileById(file_id);
|
||||||
console.log('file handling error', error);
|
console.log('file handling error', error);
|
||||||
setError('com_error_files_process');
|
if (error instanceof Error && error.message.includes('HEIC')) {
|
||||||
|
setError('com_error_heic_conversion');
|
||||||
|
} else {
|
||||||
|
setError('com_error_files_process');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
|
||||||
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
|
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
|
||||||
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
||||||
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
||||||
|
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
||||||
import { Blocks, AttachmentIcon } from '~/components/svg';
|
import { Blocks, AttachmentIcon } from '~/components/svg';
|
||||||
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
|
import MCPIcon from '~/components/ui/MCPIcon';
|
||||||
import { useHasAccess } from '~/hooks';
|
import { useHasAccess } from '~/hooks';
|
||||||
|
|
||||||
export default function useSideNavLinks({
|
export default function useSideNavLinks({
|
||||||
|
|
@ -59,6 +62,7 @@ export default function useSideNavLinks({
|
||||||
permissionType: PermissionTypes.AGENTS,
|
permissionType: PermissionTypes.AGENTS,
|
||||||
permission: Permissions.CREATE,
|
permission: Permissions.CREATE,
|
||||||
});
|
});
|
||||||
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
|
|
||||||
const Links = useMemo(() => {
|
const Links = useMemo(() => {
|
||||||
const links: NavLink[] = [];
|
const links: NavLink[] = [];
|
||||||
|
|
@ -149,6 +153,21 @@ export default function useSideNavLinks({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
startupConfig?.mcpServers &&
|
||||||
|
Object.values(startupConfig.mcpServers).some(
|
||||||
|
(server) => server.customUserVars && Object.keys(server.customUserVars).length > 0,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
links.push({
|
||||||
|
title: 'com_nav_setting_mcp',
|
||||||
|
label: '',
|
||||||
|
icon: MCPIcon,
|
||||||
|
id: 'mcp-settings',
|
||||||
|
Component: MCPPanel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
links.push({
|
links.push({
|
||||||
title: 'com_sidepanel_hide_panel',
|
title: 'com_sidepanel_hide_panel',
|
||||||
label: '',
|
label: '',
|
||||||
|
|
@ -171,6 +190,7 @@ export default function useSideNavLinks({
|
||||||
hasAccessToBookmarks,
|
hasAccessToBookmarks,
|
||||||
hasAccessToCreateAgents,
|
hasAccessToCreateAgents,
|
||||||
hidePanel,
|
hidePanel,
|
||||||
|
startupConfig,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Links;
|
return Links;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@
|
||||||
"com_agents_file_search_disabled": "Agent must be created before uploading files for File Search.",
|
"com_agents_file_search_disabled": "Agent must be created before uploading files for File Search.",
|
||||||
"com_agents_file_search_info": "When enabled, the agent will be informed of the exact filenames listed below, allowing it to retrieve relevant context from these files.",
|
"com_agents_file_search_info": "When enabled, the agent will be informed of the exact filenames listed below, allowing it to retrieve relevant context from these files.",
|
||||||
"com_agents_instructions_placeholder": "The system instructions that the agent uses",
|
"com_agents_instructions_placeholder": "The system instructions that the agent uses",
|
||||||
|
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
|
||||||
|
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px",
|
||||||
|
"com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services",
|
||||||
|
"com_agents_mcp_name_placeholder": "Custom Tool",
|
||||||
|
"com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat",
|
||||||
|
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
|
||||||
"com_agents_missing_provider_model": "Please select a provider and model before creating an agent.",
|
"com_agents_missing_provider_model": "Please select a provider and model before creating an agent.",
|
||||||
"com_agents_name_placeholder": "Optional: The name of the agent",
|
"com_agents_name_placeholder": "Optional: The name of the agent",
|
||||||
"com_agents_no_access": "You don't have access to edit this agent.",
|
"com_agents_no_access": "You don't have access to edit this agent.",
|
||||||
|
|
@ -276,6 +282,7 @@
|
||||||
"com_error_files_upload": "An error occurred while uploading the file.",
|
"com_error_files_upload": "An error occurred while uploading the file.",
|
||||||
"com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.",
|
"com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.",
|
||||||
"com_error_files_validation": "An error occurred while validating the file.",
|
"com_error_files_validation": "An error occurred while validating the file.",
|
||||||
|
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",
|
||||||
"com_error_input_length": "The latest message token count is too long, exceeding the token limit, or your token limit parameters are misconfigured, adversely affecting the context window. More info: {{0}}. Please shorten your message, adjust the max context size from the conversation parameters, or fork the conversation to continue.",
|
"com_error_input_length": "The latest message token count is too long, exceeding the token limit, or your token limit parameters are misconfigured, adversely affecting the context window. More info: {{0}}. Please shorten your message, adjust the max context size from the conversation parameters, or fork the conversation to continue.",
|
||||||
"com_error_invalid_agent_provider": "The \"{{0}}\" provider is not available for use with Agents. Please go to your agent's settings and select a currently available provider.",
|
"com_error_invalid_agent_provider": "The \"{{0}}\" provider is not available for use with Agents. Please go to your agent's settings and select a currently available provider.",
|
||||||
"com_error_invalid_user_key": "Invalid key provided. Please provide a valid key and try again.",
|
"com_error_invalid_user_key": "Invalid key provided. Please provide a valid key and try again.",
|
||||||
|
|
@ -288,6 +295,7 @@
|
||||||
"com_files_table": "something needs to go here. was empty",
|
"com_files_table": "something needs to go here. was empty",
|
||||||
"com_generated_files": "Generated files:",
|
"com_generated_files": "Generated files:",
|
||||||
"com_hide_examples": "Hide Examples",
|
"com_hide_examples": "Hide Examples",
|
||||||
|
"com_info_heic_converting": "Converting HEIC image to JPEG...",
|
||||||
"com_nav_2fa": "Two-Factor Authentication (2FA)",
|
"com_nav_2fa": "Two-Factor Authentication (2FA)",
|
||||||
"com_nav_account_settings": "Account Settings",
|
"com_nav_account_settings": "Account Settings",
|
||||||
"com_nav_always_make_prod": "Always make new versions production",
|
"com_nav_always_make_prod": "Always make new versions production",
|
||||||
|
|
@ -421,6 +429,8 @@
|
||||||
"com_nav_log_out": "Log out",
|
"com_nav_log_out": "Log out",
|
||||||
"com_nav_long_audio_warning": "Longer texts will take longer to process.",
|
"com_nav_long_audio_warning": "Longer texts will take longer to process.",
|
||||||
"com_nav_maximize_chat_space": "Maximize chat space",
|
"com_nav_maximize_chat_space": "Maximize chat space",
|
||||||
|
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables: {{0}}",
|
||||||
|
"com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.",
|
||||||
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
|
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
|
||||||
"com_nav_my_files": "My Files",
|
"com_nav_my_files": "My Files",
|
||||||
"com_nav_not_supported": "Not Supported",
|
"com_nav_not_supported": "Not Supported",
|
||||||
|
|
@ -445,6 +455,7 @@
|
||||||
"com_nav_setting_chat": "Chat",
|
"com_nav_setting_chat": "Chat",
|
||||||
"com_nav_setting_data": "Data controls",
|
"com_nav_setting_data": "Data controls",
|
||||||
"com_nav_setting_general": "General",
|
"com_nav_setting_general": "General",
|
||||||
|
"com_nav_setting_mcp": "MCP Settings",
|
||||||
"com_nav_setting_personalization": "Personalization",
|
"com_nav_setting_personalization": "Personalization",
|
||||||
"com_nav_setting_speech": "Speech",
|
"com_nav_setting_speech": "Speech",
|
||||||
"com_nav_settings": "Settings",
|
"com_nav_settings": "Settings",
|
||||||
|
|
@ -478,6 +489,9 @@
|
||||||
"com_sidepanel_conversation_tags": "Bookmarks",
|
"com_sidepanel_conversation_tags": "Bookmarks",
|
||||||
"com_sidepanel_hide_panel": "Hide Panel",
|
"com_sidepanel_hide_panel": "Hide Panel",
|
||||||
"com_sidepanel_manage_files": "Manage Files",
|
"com_sidepanel_manage_files": "Manage Files",
|
||||||
|
"com_sidepanel_mcp_enter_value": "Enter value for {{0}}",
|
||||||
|
"com_sidepanel_mcp_no_servers_with_vars": "No MCP servers with configurable variables.",
|
||||||
|
"com_sidepanel_mcp_variables_for": "MCP Variables for {{0}}",
|
||||||
"com_sidepanel_parameters": "Parameters",
|
"com_sidepanel_parameters": "Parameters",
|
||||||
"com_sources_image_alt": "Search result image",
|
"com_sources_image_alt": "Search result image",
|
||||||
"com_sources_more_sources": "+{{count}} sources",
|
"com_sources_more_sources": "+{{count}} sources",
|
||||||
|
|
@ -498,6 +512,8 @@
|
||||||
"com_ui_accept": "I accept",
|
"com_ui_accept": "I accept",
|
||||||
"com_ui_action_button": "Action Button",
|
"com_ui_action_button": "Action Button",
|
||||||
"com_ui_add": "Add",
|
"com_ui_add": "Add",
|
||||||
|
"com_ui_add_mcp": "Add MCP",
|
||||||
|
"com_ui_add_mcp_server": "Add MCP Server",
|
||||||
"com_ui_add_model_preset": "Add a model or preset for an additional response",
|
"com_ui_add_model_preset": "Add a model or preset for an additional response",
|
||||||
"com_ui_add_multi_conversation": "Add multi-conversation",
|
"com_ui_add_multi_conversation": "Add multi-conversation",
|
||||||
"com_ui_adding_details": "Adding details",
|
"com_ui_adding_details": "Adding details",
|
||||||
|
|
@ -566,8 +582,10 @@
|
||||||
"com_ui_auth_url": "Authorization URL",
|
"com_ui_auth_url": "Authorization URL",
|
||||||
"com_ui_authentication": "Authentication",
|
"com_ui_authentication": "Authentication",
|
||||||
"com_ui_authentication_type": "Authentication Type",
|
"com_ui_authentication_type": "Authentication Type",
|
||||||
|
"com_ui_available_tools": "Available Tools",
|
||||||
"com_ui_avatar": "Avatar",
|
"com_ui_avatar": "Avatar",
|
||||||
"com_ui_azure": "Azure",
|
"com_ui_azure": "Azure",
|
||||||
|
"com_ui_back": "Back",
|
||||||
"com_ui_back_to_chat": "Back to Chat",
|
"com_ui_back_to_chat": "Back to Chat",
|
||||||
"com_ui_back_to_prompts": "Back to Prompts",
|
"com_ui_back_to_prompts": "Back to Prompts",
|
||||||
"com_ui_backup_codes": "Backup Codes",
|
"com_ui_backup_codes": "Backup Codes",
|
||||||
|
|
@ -607,11 +625,13 @@
|
||||||
"com_ui_client_secret": "Client Secret",
|
"com_ui_client_secret": "Client Secret",
|
||||||
"com_ui_close": "Close",
|
"com_ui_close": "Close",
|
||||||
"com_ui_close_menu": "Close Menu",
|
"com_ui_close_menu": "Close Menu",
|
||||||
|
"com_ui_close_window": "Close Window",
|
||||||
"com_ui_code": "Code",
|
"com_ui_code": "Code",
|
||||||
"com_ui_collapse_chat": "Collapse Chat",
|
"com_ui_collapse_chat": "Collapse Chat",
|
||||||
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
|
"com_ui_command_placeholder": "Optional: Enter a command for the prompt or name will be used",
|
||||||
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",
|
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",
|
||||||
"com_ui_complete_setup": "Complete Setup",
|
"com_ui_complete_setup": "Complete Setup",
|
||||||
|
"com_ui_configure_mcp_variables_for": "Configure Variables for {{0}}",
|
||||||
"com_ui_confirm_action": "Confirm Action",
|
"com_ui_confirm_action": "Confirm Action",
|
||||||
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
|
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
|
||||||
"com_ui_confirm_change": "Confirm Change",
|
"com_ui_confirm_change": "Confirm Change",
|
||||||
|
|
@ -662,6 +682,10 @@
|
||||||
"com_ui_delete_confirm": "This will delete",
|
"com_ui_delete_confirm": "This will delete",
|
||||||
"com_ui_delete_confirm_prompt_version_var": "This will delete the selected version for \"{{0}}.\" If no other versions exist, the prompt will be deleted.",
|
"com_ui_delete_confirm_prompt_version_var": "This will delete the selected version for \"{{0}}.\" If no other versions exist, the prompt will be deleted.",
|
||||||
"com_ui_delete_conversation": "Delete chat?",
|
"com_ui_delete_conversation": "Delete chat?",
|
||||||
|
"com_ui_delete_mcp": "Delete MCP",
|
||||||
|
"com_ui_delete_mcp_confirm": "Are you sure you want to delete this MCP server?",
|
||||||
|
"com_ui_delete_mcp_error": "Failed to delete MCP server",
|
||||||
|
"com_ui_delete_mcp_success": "MCP server deleted successfully",
|
||||||
"com_ui_delete_memory": "Delete Memory",
|
"com_ui_delete_memory": "Delete Memory",
|
||||||
"com_ui_delete_prompt": "Delete Prompt?",
|
"com_ui_delete_prompt": "Delete Prompt?",
|
||||||
"com_ui_delete_shared_link": "Delete shared link?",
|
"com_ui_delete_shared_link": "Delete shared link?",
|
||||||
|
|
@ -671,6 +695,7 @@
|
||||||
"com_ui_descending": "Desc",
|
"com_ui_descending": "Desc",
|
||||||
"com_ui_description": "Description",
|
"com_ui_description": "Description",
|
||||||
"com_ui_description_placeholder": "Optional: Enter a description to display for the prompt",
|
"com_ui_description_placeholder": "Optional: Enter a description to display for the prompt",
|
||||||
|
"com_ui_deselect_all": "Deselect All",
|
||||||
"com_ui_disabling": "Disabling...",
|
"com_ui_disabling": "Disabling...",
|
||||||
"com_ui_download": "Download",
|
"com_ui_download": "Download",
|
||||||
"com_ui_download_artifact": "Download Artifact",
|
"com_ui_download_artifact": "Download Artifact",
|
||||||
|
|
@ -686,6 +711,7 @@
|
||||||
"com_ui_duplication_success": "Successfully duplicated conversation",
|
"com_ui_duplication_success": "Successfully duplicated conversation",
|
||||||
"com_ui_edit": "Edit",
|
"com_ui_edit": "Edit",
|
||||||
"com_ui_edit_editing_image": "Editing image",
|
"com_ui_edit_editing_image": "Editing image",
|
||||||
|
"com_ui_edit_mcp_server": "Edit MCP Server",
|
||||||
"com_ui_edit_memory": "Edit Memory",
|
"com_ui_edit_memory": "Edit Memory",
|
||||||
"com_ui_empty_category": "-",
|
"com_ui_empty_category": "-",
|
||||||
"com_ui_endpoint": "Endpoint",
|
"com_ui_endpoint": "Endpoint",
|
||||||
|
|
@ -765,6 +791,7 @@
|
||||||
"com_ui_hide_image_details": "Hide Image Details",
|
"com_ui_hide_image_details": "Hide Image Details",
|
||||||
"com_ui_hide_qr": "Hide QR Code",
|
"com_ui_hide_qr": "Hide QR Code",
|
||||||
"com_ui_host": "Host",
|
"com_ui_host": "Host",
|
||||||
|
"com_ui_icon": "Icon",
|
||||||
"com_ui_idea": "Ideas",
|
"com_ui_idea": "Ideas",
|
||||||
"com_ui_image_created": "Image created",
|
"com_ui_image_created": "Image created",
|
||||||
"com_ui_image_details": "Image Details",
|
"com_ui_image_details": "Image Details",
|
||||||
|
|
@ -792,7 +819,11 @@
|
||||||
"com_ui_logo": "{{0}} Logo",
|
"com_ui_logo": "{{0}} Logo",
|
||||||
"com_ui_manage": "Manage",
|
"com_ui_manage": "Manage",
|
||||||
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
||||||
|
"com_ui_mcp_dialog_desc": "Please enter the necessary information below.",
|
||||||
|
"com_ui_mcp_enter_var": "Enter value for {{0}}",
|
||||||
|
"com_ui_mcp_server_not_found": "Server not found.",
|
||||||
"com_ui_mcp_servers": "MCP Servers",
|
"com_ui_mcp_servers": "MCP Servers",
|
||||||
|
"com_ui_mcp_url": "MCP Server URL",
|
||||||
"com_ui_memories": "Memories",
|
"com_ui_memories": "Memories",
|
||||||
"com_ui_memories_allow_create": "Allow creating Memories",
|
"com_ui_memories_allow_create": "Allow creating Memories",
|
||||||
"com_ui_memories_allow_opt_out": "Allow users to opt out of Memories",
|
"com_ui_memories_allow_opt_out": "Allow users to opt out of Memories",
|
||||||
|
|
@ -833,10 +864,20 @@
|
||||||
"com_ui_not_used": "Not Used",
|
"com_ui_not_used": "Not Used",
|
||||||
"com_ui_nothing_found": "Nothing found",
|
"com_ui_nothing_found": "Nothing found",
|
||||||
"com_ui_oauth": "OAuth",
|
"com_ui_oauth": "OAuth",
|
||||||
|
"com_ui_oauth_connected_to": "Connected to",
|
||||||
|
"com_ui_oauth_error_callback_failed": "Authentication callback failed. Please try again.",
|
||||||
|
"com_ui_oauth_error_generic": "Authentication failed. Please try again.",
|
||||||
|
"com_ui_oauth_error_invalid_state": "Invalid state parameter. Please try again.",
|
||||||
|
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
|
||||||
|
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
|
||||||
|
"com_ui_oauth_error_title": "Authentication Failed",
|
||||||
|
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
|
||||||
|
"com_ui_oauth_success_title": "Authentication Successful",
|
||||||
"com_ui_of": "of",
|
"com_ui_of": "of",
|
||||||
"com_ui_off": "Off",
|
"com_ui_off": "Off",
|
||||||
"com_ui_on": "On",
|
"com_ui_on": "On",
|
||||||
"com_ui_openai": "OpenAI",
|
"com_ui_openai": "OpenAI",
|
||||||
|
"com_ui_optional": "(optional)",
|
||||||
"com_ui_page": "Page",
|
"com_ui_page": "Page",
|
||||||
"com_ui_preferences_updated": "Preferences updated successfully",
|
"com_ui_preferences_updated": "Preferences updated successfully",
|
||||||
"com_ui_prev": "Prev",
|
"com_ui_prev": "Prev",
|
||||||
|
|
@ -887,11 +928,14 @@
|
||||||
"com_ui_save_badge_changes": "Save badge changes?",
|
"com_ui_save_badge_changes": "Save badge changes?",
|
||||||
"com_ui_save_submit": "Save & Submit",
|
"com_ui_save_submit": "Save & Submit",
|
||||||
"com_ui_saved": "Saved!",
|
"com_ui_saved": "Saved!",
|
||||||
|
"com_ui_saving": "Saving...",
|
||||||
"com_ui_schema": "Schema",
|
"com_ui_schema": "Schema",
|
||||||
"com_ui_scope": "Scope",
|
"com_ui_scope": "Scope",
|
||||||
"com_ui_search": "Search",
|
"com_ui_search": "Search",
|
||||||
|
"com_ui_seconds": "seconds",
|
||||||
"com_ui_secret_key": "Secret Key",
|
"com_ui_secret_key": "Secret Key",
|
||||||
"com_ui_select": "Select",
|
"com_ui_select": "Select",
|
||||||
|
"com_ui_select_all": "Select All",
|
||||||
"com_ui_select_file": "Select a file",
|
"com_ui_select_file": "Select a file",
|
||||||
"com_ui_select_model": "Select a model",
|
"com_ui_select_model": "Select a model",
|
||||||
"com_ui_select_provider": "Select a provider",
|
"com_ui_select_provider": "Select a provider",
|
||||||
|
|
@ -943,13 +987,19 @@
|
||||||
"com_ui_token_exchange_method": "Token Exchange Method",
|
"com_ui_token_exchange_method": "Token Exchange Method",
|
||||||
"com_ui_token_url": "Token URL",
|
"com_ui_token_url": "Token URL",
|
||||||
"com_ui_tokens": "tokens",
|
"com_ui_tokens": "tokens",
|
||||||
|
"com_ui_tool_collection_prefix": "A collection of tools from",
|
||||||
|
"com_ui_tool_info": "Tool Information",
|
||||||
|
"com_ui_tool_more_info": "More information about this tool",
|
||||||
"com_ui_tools": "Tools",
|
"com_ui_tools": "Tools",
|
||||||
"com_ui_travel": "Travel",
|
"com_ui_travel": "Travel",
|
||||||
|
"com_ui_trust_app": "I trust this application",
|
||||||
"com_ui_unarchive": "Unarchive",
|
"com_ui_unarchive": "Unarchive",
|
||||||
"com_ui_unarchive_error": "Failed to unarchive conversation",
|
"com_ui_unarchive_error": "Failed to unarchive conversation",
|
||||||
"com_ui_unknown": "Unknown",
|
"com_ui_unknown": "Unknown",
|
||||||
"com_ui_untitled": "Untitled",
|
"com_ui_untitled": "Untitled",
|
||||||
"com_ui_update": "Update",
|
"com_ui_update": "Update",
|
||||||
|
"com_ui_update_mcp_error": "There was an error creating or updating the MCP.",
|
||||||
|
"com_ui_update_mcp_success": "Successfully created or updated MCP",
|
||||||
"com_ui_upload": "Upload",
|
"com_ui_upload": "Upload",
|
||||||
"com_ui_upload_code_files": "Upload for Code Interpreter",
|
"com_ui_upload_code_files": "Upload for Code Interpreter",
|
||||||
"com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.",
|
"com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.",
|
||||||
|
|
@ -1005,27 +1055,5 @@
|
||||||
"com_ui_yes": "Yes",
|
"com_ui_yes": "Yes",
|
||||||
"com_ui_zoom": "Zoom",
|
"com_ui_zoom": "Zoom",
|
||||||
"com_user_message": "You",
|
"com_user_message": "You",
|
||||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
|
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
|
||||||
"com_ui_add_mcp": "Add MCP",
|
|
||||||
"com_ui_add_mcp_server": "Add MCP Server",
|
|
||||||
"com_ui_edit_mcp_server": "Edit MCP Server",
|
|
||||||
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
|
|
||||||
"com_ui_delete_mcp": "Delete MCP",
|
|
||||||
"com_ui_delete_mcp_confirm": "Are you sure you want to delete this MCP server?",
|
|
||||||
"com_ui_delete_mcp_success": "MCP server deleted successfully",
|
|
||||||
"com_ui_delete_mcp_error": "Failed to delete MCP server",
|
|
||||||
"com_agents_mcp_info": "Add MCP servers to your agent to enable it to perform tasks and interact with external services",
|
|
||||||
"com_ui_update_mcp_error": "There was an error creating or updating the MCP.",
|
|
||||||
"com_ui_update_mcp_success": "Successfully created or updated MCP",
|
|
||||||
"com_ui_available_tools": "Available Tools",
|
|
||||||
"com_ui_select_all": "Select All",
|
|
||||||
"com_ui_deselect_all": "Deselect All",
|
|
||||||
"com_agents_mcp_name_placeholder": "Custom Tool",
|
|
||||||
"com_ui_optional": "(optional)",
|
|
||||||
"com_agents_mcp_description_placeholder": "Explain what it does in a few words",
|
|
||||||
"com_ui_mcp_url": "MCP Server URL",
|
|
||||||
"com_ui_trust_app": "I trust this application",
|
|
||||||
"com_agents_mcp_trust_subtext": "Custom connectors are not verified by LibreChat",
|
|
||||||
"com_ui_icon": "Icon",
|
|
||||||
"com_agents_mcp_icon_size": "Minimum size 128 x 128 px"
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom';
|
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Login,
|
Login,
|
||||||
Registration,
|
|
||||||
RequestPasswordReset,
|
|
||||||
ResetPassword,
|
|
||||||
VerifyEmail,
|
VerifyEmail,
|
||||||
|
Registration,
|
||||||
|
ResetPassword,
|
||||||
ApiErrorWatcher,
|
ApiErrorWatcher,
|
||||||
TwoFactorScreen,
|
TwoFactorScreen,
|
||||||
|
RequestPasswordReset,
|
||||||
} from '~/components/Auth';
|
} from '~/components/Auth';
|
||||||
|
import { OAuthSuccess, OAuthError } from '~/components/OAuth';
|
||||||
import { AuthContextProvider } from '~/hooks/AuthContext';
|
import { AuthContextProvider } from '~/hooks/AuthContext';
|
||||||
import RouteErrorBoundary from './RouteErrorBoundary';
|
import RouteErrorBoundary from './RouteErrorBoundary';
|
||||||
import StartupLayout from './Layouts/Startup';
|
import StartupLayout from './Layouts/Startup';
|
||||||
|
|
@ -31,6 +32,20 @@ export const router = createBrowserRouter([
|
||||||
element: <ShareRoute />,
|
element: <ShareRoute />,
|
||||||
errorElement: <RouteErrorBoundary />,
|
errorElement: <RouteErrorBoundary />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'oauth',
|
||||||
|
errorElement: <RouteErrorBoundary />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'success',
|
||||||
|
element: <OAuthSuccess />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'error',
|
||||||
|
element: <OAuthError />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <StartupLayout />,
|
element: <StartupLayout />,
|
||||||
|
|
|
||||||
79
client/src/utils/heicConverter.ts
Normal file
79
client/src/utils/heicConverter.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { heicTo, isHeic } from 'heic-to';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is in HEIC format
|
||||||
|
* @param file - The file to check
|
||||||
|
* @returns Promise<boolean> - True if the file is HEIC
|
||||||
|
*/
|
||||||
|
export const isHEICFile = async (file: File): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
return await isHeic(file);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Error checking if file is HEIC:', error);
|
||||||
|
// Fallback to mime type check
|
||||||
|
return file.type === 'image/heic' || file.type === 'image/heif';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HEIC file to JPEG
|
||||||
|
* @param file - The HEIC file to convert
|
||||||
|
* @param quality - JPEG quality (0-1), default is 0.9
|
||||||
|
* @param onProgress - Optional callback to track conversion progress
|
||||||
|
* @returns Promise<File> - The converted JPEG file
|
||||||
|
*/
|
||||||
|
export const convertHEICToJPEG = async (
|
||||||
|
file: File,
|
||||||
|
quality: number = 0.9,
|
||||||
|
onProgress?: (progress: number) => void,
|
||||||
|
): Promise<File> => {
|
||||||
|
try {
|
||||||
|
// Report conversion start
|
||||||
|
onProgress?.(0.3);
|
||||||
|
|
||||||
|
const convertedBlob = await heicTo({
|
||||||
|
blob: file,
|
||||||
|
type: 'image/jpeg',
|
||||||
|
quality,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Report conversion completion
|
||||||
|
onProgress?.(0.8);
|
||||||
|
|
||||||
|
// Create a new File object with the converted blob
|
||||||
|
const convertedFile = new File([convertedBlob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
|
||||||
|
type: 'image/jpeg',
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Report file creation completion
|
||||||
|
onProgress?.(1.0);
|
||||||
|
|
||||||
|
return convertedFile;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting HEIC to JPEG:', error);
|
||||||
|
throw new Error('Failed to convert HEIC image to JPEG');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a file, converting it from HEIC to JPEG if necessary
|
||||||
|
* @param file - The file to process
|
||||||
|
* @param quality - JPEG quality for conversion (0-1), default is 0.9
|
||||||
|
* @param onProgress - Optional callback to track conversion progress
|
||||||
|
* @returns Promise<File> - The processed file (converted if it was HEIC, original otherwise)
|
||||||
|
*/
|
||||||
|
export const processFileForUpload = async (
|
||||||
|
file: File,
|
||||||
|
quality: number = 0.9,
|
||||||
|
onProgress?: (progress: number) => void,
|
||||||
|
): Promise<File> => {
|
||||||
|
const isHEIC = await isHEICFile(file);
|
||||||
|
|
||||||
|
if (isHEIC) {
|
||||||
|
console.log('HEIC file detected, converting to JPEG...');
|
||||||
|
return convertHEICToJPEG(file, quality, onProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
|
@ -1,163 +1,92 @@
|
||||||
|
import { preprocessLaTeX } from './latex';
|
||||||
import { processLaTeX, preprocessLaTeX } from './latex';
|
|
||||||
|
|
||||||
describe('processLaTeX', () => {
|
|
||||||
test('returns the same string if no LaTeX patterns are found', () => {
|
|
||||||
const content = 'This is a test string without LaTeX';
|
|
||||||
expect(processLaTeX(content)).toBe(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('converts inline LaTeX expressions correctly', () => {
|
|
||||||
const content = 'This is an inline LaTeX expression: \\(x^2 + y^2 = z^2\\)';
|
|
||||||
const expected = 'This is an inline LaTeX expression: $x^2 + y^2 = z^2$';
|
|
||||||
expect(processLaTeX(content)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('converts block LaTeX expressions correctly', () => {
|
|
||||||
const content = 'This is a block LaTeX expression: \\[E = mc^2\\]';
|
|
||||||
const expected = 'This is a block LaTeX expression: $$E = mc^2$$';
|
|
||||||
expect(processLaTeX(content)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('converts mixed LaTeX expressions correctly', () => {
|
|
||||||
const content = 'Inline \\(a + b = c\\) and block \\[x^2 + y^2 = z^2\\]';
|
|
||||||
const expected = 'Inline $a + b = c$ and block $$x^2 + y^2 = z^2$$';
|
|
||||||
expect(processLaTeX(content)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('escapes dollar signs followed by a digit or space and digit', () => {
|
|
||||||
const content = 'Price is $50 and $ 100';
|
|
||||||
const expected = 'Price is \\$50 and \\$ 100';
|
|
||||||
expect(processLaTeX(content)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles strings with no content', () => {
|
|
||||||
const content = '';
|
|
||||||
expect(processLaTeX(content)).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not alter already valid inline Markdown LaTeX', () => {
|
|
||||||
const content = 'This is a valid inline LaTeX: $x^2 + y^2 = z^2$';
|
|
||||||
expect(processLaTeX(content)).toBe(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not alter already valid block Markdown LaTeX', () => {
|
|
||||||
const content = 'This is a valid block LaTeX: $$E = mc^2$$';
|
|
||||||
expect(processLaTeX(content)).toBe(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('correctly processes a mix of valid Markdown LaTeX and LaTeX patterns', () => {
|
|
||||||
const content = 'Valid $a + b = c$ and LaTeX to convert \\(x^2 + y^2 = z^2\\)';
|
|
||||||
const expected = 'Valid $a + b = c$ and LaTeX to convert $x^2 + y^2 = z^2$';
|
|
||||||
expect(processLaTeX(content)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('correctly handles strings with LaTeX and non-LaTeX dollar signs', () => {
|
|
||||||
const content = 'Price $100 and LaTeX \\(x^2 + y^2 = z^2\\)';
|
|
||||||
const expected = 'Price \\$100 and LaTeX $x^2 + y^2 = z^2$';
|
|
||||||
expect(processLaTeX(content)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ignores non-LaTeX content enclosed in dollar signs', () => {
|
|
||||||
const content = 'This is not LaTeX: $This is just text$';
|
|
||||||
expect(processLaTeX(content)).toBe(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('correctly processes complex block LaTeX with line breaks', () => {
|
|
||||||
const complexBlockLatex = `Certainly! Here's an example of a mathematical formula written in LaTeX:
|
|
||||||
|
|
||||||
\\[
|
|
||||||
\\sum_{i=1}^{n} \\left( \\frac{x_i}{y_i} \\right)^2
|
|
||||||
\\]
|
|
||||||
|
|
||||||
This formula represents the sum of the squares of the ratios of \\(x\\) to \\(y\\) for \\(n\\) terms, where \\(x_i\\) and \\(y_i\\) represent the values of \\(x\\) and \\(y\\) for each term.
|
|
||||||
|
|
||||||
LaTeX is a typesetting system commonly used for mathematical and scientific documents. It provides a wide range of formatting options and symbols for expressing mathematical expressions.`;
|
|
||||||
const expectedOutput = `Certainly! Here's an example of a mathematical formula written in LaTeX:
|
|
||||||
|
|
||||||
$$
|
|
||||||
\\sum_{i=1}^{n} \\left( \\frac{x_i}{y_i} \\right)^2
|
|
||||||
$$
|
|
||||||
|
|
||||||
This formula represents the sum of the squares of the ratios of $x$ to $y$ for $n$ terms, where $x_i$ and $y_i$ represent the values of $x$ and $y$ for each term.
|
|
||||||
|
|
||||||
LaTeX is a typesetting system commonly used for mathematical and scientific documents. It provides a wide range of formatting options and symbols for expressing mathematical expressions.`;
|
|
||||||
expect(processLaTeX(complexBlockLatex)).toBe(expectedOutput);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('processLaTeX with code block exception', () => {
|
|
||||||
test('ignores dollar signs inside inline code', () => {
|
|
||||||
const content = 'This is inline code: `$100`';
|
|
||||||
expect(processLaTeX(content)).toBe(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ignores dollar signs inside multi-line code blocks', () => {
|
|
||||||
const content = '```\n$100\n# $1000\n```';
|
|
||||||
expect(processLaTeX(content)).toBe(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('processes LaTeX outside of code blocks', () => {
|
|
||||||
const content =
|
|
||||||
'Outside \\(x^2 + y^2 = z^2\\) and inside code block: ```\n$100\n# $1000\n```';
|
|
||||||
const expected = 'Outside $x^2 + y^2 = z^2$ and inside code block: ```\n$100\n# $1000\n```';
|
|
||||||
expect(processLaTeX(content)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('preprocessLaTeX', () => {
|
describe('preprocessLaTeX', () => {
|
||||||
test('returns the same string if no LaTeX patterns are found', () => {
|
test('returns the same string if no LaTeX patterns are found', () => {
|
||||||
const content = 'This is a test string without LaTeX';
|
const content = 'This is a test string without LaTeX or dollar signs';
|
||||||
expect(preprocessLaTeX(content)).toBe(content);
|
expect(preprocessLaTeX(content)).toBe(content);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('escapes dollar signs followed by digits', () => {
|
test('returns the same string if no dollar signs are present', () => {
|
||||||
|
const content = 'This has LaTeX \\(x^2\\) and \\[y^2\\] but no dollars';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves valid inline LaTeX delimiters \\(...\\)', () => {
|
||||||
|
const content = 'This is inline LaTeX: \\(x^2 + y^2 = z^2\\)';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves valid block LaTeX delimiters \\[...\\]', () => {
|
||||||
|
const content = 'This is block LaTeX: \\[E = mc^2\\]';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves valid double dollar delimiters', () => {
|
||||||
|
const content = 'This is valid: $$x^2 + y^2 = z^2$$';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts single dollar delimiters to double dollars', () => {
|
||||||
|
const content = 'Inline math: $x^2 + y^2 = z^2$';
|
||||||
|
const expected = 'Inline math: $$x^2 + y^2 = z^2$$';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts multiple single dollar expressions', () => {
|
||||||
|
const content = 'First $a + b = c$ and second $x^2 + y^2 = z^2$';
|
||||||
|
const expected = 'First $$a + b = c$$ and second $$x^2 + y^2 = z^2$$';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escapes currency dollar signs', () => {
|
||||||
const content = 'Price is $50 and $100';
|
const content = 'Price is $50 and $100';
|
||||||
const expected = 'Price is \\$50 and \\$100';
|
const expected = 'Price is \\$50 and \\$100';
|
||||||
expect(preprocessLaTeX(content)).toBe(expected);
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not escape dollar signs not followed by digits', () => {
|
test('escapes currency with spaces', () => {
|
||||||
const content = 'This $variable is not escaped';
|
const content = '$50 is $20 + $30';
|
||||||
expect(preprocessLaTeX(content)).toBe(content);
|
const expected = '\\$50 is \\$20 + \\$30';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves existing LaTeX expressions', () => {
|
test('escapes currency with commas', () => {
|
||||||
const content = 'Inline $x^2 + y^2 = z^2$ and block $$E = mc^2$$';
|
const content = 'The price is $1,000,000 for this item.';
|
||||||
expect(preprocessLaTeX(content)).toBe(content);
|
const expected = 'The price is \\$1,000,000 for this item.';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles mixed LaTeX and currency', () => {
|
test('escapes currency with decimals', () => {
|
||||||
|
const content = 'Total: $29.50 plus tax';
|
||||||
|
const expected = 'Total: \\$29.50 plus tax';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts LaTeX expressions while escaping currency', () => {
|
||||||
const content = 'LaTeX $x^2$ and price $50';
|
const content = 'LaTeX $x^2$ and price $50';
|
||||||
const expected = 'LaTeX $x^2$ and price \\$50';
|
const expected = 'LaTeX $$x^2$$ and price \\$50';
|
||||||
expect(preprocessLaTeX(content)).toBe(expected);
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('converts LaTeX delimiters', () => {
|
test('handles Goldbach Conjecture example', () => {
|
||||||
const content = 'Brackets \\[x^2\\] and parentheses \\(y^2\\)';
|
const content = '- **Goldbach Conjecture**: $2n = p + q$ (every even integer > 2)';
|
||||||
const expected = 'Brackets $$x^2$$ and parentheses $y^2$';
|
const expected = '- **Goldbach Conjecture**: $$2n = p + q$$ (every even integer > 2)';
|
||||||
expect(preprocessLaTeX(content)).toBe(expected);
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not escape already escaped dollar signs', () => {
|
||||||
|
const content = 'Already escaped \\$50 and \\$100';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not convert already escaped single dollars', () => {
|
||||||
|
const content = 'Escaped \\$x^2\\$ should not change';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
test('escapes mhchem commands', () => {
|
test('escapes mhchem commands', () => {
|
||||||
const content = '$\\ce{H2O}$ and $\\pu{123 J}$';
|
const content = '$\\ce{H2O}$ and $\\pu{123 J}$';
|
||||||
const expected = '$\\\\ce{H2O}$ and $\\\\pu{123 J}$';
|
const expected = '$$\\\\ce{H2O}$$ and $$\\\\pu{123 J}$$';
|
||||||
expect(preprocessLaTeX(content)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles complex mixed content', () => {
|
|
||||||
const content = `
|
|
||||||
LaTeX inline $x^2$ and block $$y^2$$
|
|
||||||
Currency $100 and $200
|
|
||||||
Chemical $\\ce{H2O}$
|
|
||||||
Brackets \\[z^2\\]
|
|
||||||
`;
|
|
||||||
const expected = `
|
|
||||||
LaTeX inline $x^2$ and block $$y^2$$
|
|
||||||
Currency \\$100 and \\$200
|
|
||||||
Chemical $\\\\ce{H2O}$
|
|
||||||
Brackets $$z^2$$
|
|
||||||
`;
|
|
||||||
expect(preprocessLaTeX(content)).toBe(expected);
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -165,31 +94,117 @@ describe('preprocessLaTeX', () => {
|
||||||
expect(preprocessLaTeX('')).toBe('');
|
expect(preprocessLaTeX('')).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves code blocks', () => {
|
test('handles complex mixed content', () => {
|
||||||
const content = '```\n$100\n```\nOutside $200';
|
const content = `Valid double $$y^2$$
|
||||||
const expected = '```\n$100\n```\nOutside \\$200';
|
Currency $100 and $200
|
||||||
|
Single dollar math $x^2 + y^2$
|
||||||
|
Chemical $\\ce{H2O}$
|
||||||
|
Valid brackets \\[z^2\\]`;
|
||||||
|
const expected = `Valid double $$y^2$$
|
||||||
|
Currency \\$100 and \\$200
|
||||||
|
Single dollar math $$x^2 + y^2$$
|
||||||
|
Chemical $$\\\\ce{H2O}$$
|
||||||
|
Valid brackets \\[z^2\\]`;
|
||||||
expect(preprocessLaTeX(content)).toBe(expected);
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles multiple currency values in a sentence', () => {
|
test('handles multiple equations with currency', () => {
|
||||||
const content = 'I have $50 in my wallet and $100 in the bank.';
|
const content = `- **Euler's Totient Function**: $\\phi(n) = n \\prod_{p|n} \\left(1 - \\frac{1}{p}\\right)$
|
||||||
const expected = 'I have \\$50 in my wallet and \\$100 in the bank.';
|
- **Total Savings**: $500 + $200 + $150 = $850`;
|
||||||
|
const expected = `- **Euler's Totient Function**: $$\\phi(n) = n \\prod_{p|n} \\left(1 - \\frac{1}{p}\\right)$$
|
||||||
|
- **Total Savings**: \\$500 + \\$200 + \\$150 = \\$850`;
|
||||||
expect(preprocessLaTeX(content)).toBe(expected);
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves LaTeX expressions with numbers', () => {
|
test('handles inline code blocks', () => {
|
||||||
const content = 'The equation is $f(x) = 2x + 3$ where x is a variable.';
|
const content = 'Outside $x^2$ and inside code: `$100`';
|
||||||
expect(preprocessLaTeX(content)).toBe(content);
|
const expected = 'Outside $$x^2$$ and inside code: `$100`';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handles currency values with commas', () => {
|
test('handles multiline code blocks', () => {
|
||||||
const content = 'The price is $1,000,000 for this item.';
|
const content = '```\n$100\n$variable\n```\nOutside $x^2$';
|
||||||
const expected = 'The price is \\$1,000,000 for this item.';
|
const expected = '```\n$100\n$variable\n```\nOutside $$x^2$$';
|
||||||
expect(preprocessLaTeX(content)).toBe(expected);
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('preserves LaTeX expressions with special characters', () => {
|
test('preserves LaTeX expressions with special characters', () => {
|
||||||
const content = 'The set is defined as $\\{x | x > 0\\}$.';
|
const content = 'The set is defined as $\\{x | x > 0\\}$.';
|
||||||
expect(preprocessLaTeX(content)).toBe(content);
|
const expected = 'The set is defined as $$\\{x | x > 0\\}$$.';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles complex physics equations', () => {
|
||||||
|
const content = `- **Schrödinger Equation**: $i\\hbar\\frac{\\partial}{\\partial t}|\\psi\\rangle = \\hat{H}|\\psi\\rangle$
|
||||||
|
- **Einstein Field Equations**: $G_{\\mu\\nu} = \\frac{8\\pi G}{c^4} T_{\\mu\\nu}$`;
|
||||||
|
const expected = `- **Schrödinger Equation**: $$i\\hbar\\frac{\\partial}{\\partial t}|\\psi\\rangle = \\hat{H}|\\psi\\rangle$$
|
||||||
|
- **Einstein Field Equations**: $$G_{\\mu\\nu} = \\frac{8\\pi G}{c^4} T_{\\mu\\nu}$$`;
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles financial calculations with currency', () => {
|
||||||
|
const content = `- **Simple Interest**: $A = P + Prt = $1,000 + ($1,000)(0.05)(2) = $1,100$
|
||||||
|
- **ROI**: $\\text{ROI} = \\frac{$1,200 - $1,000}{$1,000} \\times 100\\% = 20\\%$`;
|
||||||
|
const expected = `- **Simple Interest**: $$A = P + Prt = \\$1,000 + (\\$1,000)(0.05)(2) = \\$1,100$$
|
||||||
|
- **ROI**: $$\\text{ROI} = \\frac{\\$1,200 - \\$1,000}{\\$1,000} \\times 100\\% = 20\\%$$`;
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not convert partial or malformed expressions', () => {
|
||||||
|
const content = 'A single $ sign should not be converted';
|
||||||
|
const expected = 'A single $ sign should not be converted';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles nested parentheses in LaTeX', () => {
|
||||||
|
const content =
|
||||||
|
'Matrix determinant: $\\det(A) = \\sum_{\\sigma \\in S_n} \\text{sgn}(\\sigma) \\prod_{i=1}^n a_{i,\\sigma(i)}$';
|
||||||
|
const expected =
|
||||||
|
'Matrix determinant: $$\\det(A) = \\sum_{\\sigma \\in S_n} \\text{sgn}(\\sigma) \\prod_{i=1}^n a_{i,\\sigma(i)}$$';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves spacing in equations', () => {
|
||||||
|
const content = 'Equation: $f(x) = 2x + 3$ where x is a variable.';
|
||||||
|
const expected = 'Equation: $$f(x) = 2x + 3$$ where x is a variable.';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles LaTeX with newlines inside should not be converted', () => {
|
||||||
|
const content = `This has $x
|
||||||
|
y$ which spans lines`;
|
||||||
|
const expected = `This has $x
|
||||||
|
y$ which spans lines`;
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles multiple dollar signs in text', () => {
|
||||||
|
const content = 'Price $100 then equation $x + y = z$ then another price $50';
|
||||||
|
const expected = 'Price \\$100 then equation $$x + y = z$$ then another price \\$50';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles complex LaTeX with currency in same expression', () => {
|
||||||
|
const content = 'Calculate $\\text{Total} = \\$500 + \\$200$';
|
||||||
|
const expected = 'Calculate $$\\text{Total} = \\$500 + \\$200$$';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves already escaped dollars in LaTeX', () => {
|
||||||
|
const content = 'The formula $f(x) = \\$2x$ represents cost';
|
||||||
|
const expected = 'The formula $$f(x) = \\$2x$$ represents cost';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles adjacent LaTeX and currency', () => {
|
||||||
|
const content = 'Formula $x^2$ costs $25';
|
||||||
|
const expected = 'Formula $$x^2$$ costs \\$25';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles LaTeX with special characters and currency', () => {
|
||||||
|
const content = 'Set $\\{x | x > \\$0\\}$ for positive prices';
|
||||||
|
const expected = 'Set $$\\{x | x > \\$0\\}$$ for positive prices';
|
||||||
|
expect(preprocessLaTeX(content)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,152 @@
|
||||||
// Regex to check if the processed content contains any potential LaTeX patterns
|
// Pre-compile all regular expressions for better performance
|
||||||
const containsLatexRegex =
|
const MHCHEM_CE_REGEX = /\$\\ce\{/g;
|
||||||
/\\\(.*?\\\)|\\\[.*?\\\]|\$.*?\$|\\begin\{equation\}.*?\\end\{equation\}/;
|
const MHCHEM_PU_REGEX = /\$\\pu\{/g;
|
||||||
|
const MHCHEM_CE_ESCAPED_REGEX = /\$\\\\ce\{[^}]*\}\$/g;
|
||||||
// Regex for inline and block LaTeX expressions
|
const MHCHEM_PU_ESCAPED_REGEX = /\$\\\\pu\{[^}]*\}\$/g;
|
||||||
const inlineLatex = new RegExp(/\\\((.+?)\\\)/, 'g');
|
const CURRENCY_REGEX =
|
||||||
const blockLatex = new RegExp(/\\\[(.*?[^\\])\\\]/, 'gs');
|
/(?<![\\$])\$(?!\$)(?=\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?(?:\s|$|[^a-zA-Z\d]))/g;
|
||||||
|
const SINGLE_DOLLAR_REGEX = /(?<!\\)\$(?!\$)((?:[^$\n]|\\[$])+?)(?<!\\)\$(?!\$)/g;
|
||||||
// Function to restore code blocks
|
|
||||||
const restoreCodeBlocks = (content: string, codeBlocks: string[]) => {
|
|
||||||
return content.replace(/<<CODE_BLOCK_(\d+)>>/g, (match, index) => codeBlocks[index]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Regex to identify code blocks and inline code
|
|
||||||
const codeBlockRegex = /(```[\s\S]*?```|`.*?`)/g;
|
|
||||||
|
|
||||||
export const processLaTeX = (_content: string) => {
|
|
||||||
let content = _content;
|
|
||||||
// Temporarily replace code blocks and inline code with placeholders
|
|
||||||
const codeBlocks: string[] = [];
|
|
||||||
let index = 0;
|
|
||||||
content = content.replace(codeBlockRegex, (match) => {
|
|
||||||
codeBlocks[index] = match;
|
|
||||||
return `<<CODE_BLOCK_${index++}>>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Escape dollar signs followed by a digit or space and digit
|
|
||||||
let processedContent = content.replace(/(\$)(?=\s?\d)/g, '\\$');
|
|
||||||
|
|
||||||
// If no LaTeX patterns are found, restore code blocks and return the processed content
|
|
||||||
if (!containsLatexRegex.test(processedContent)) {
|
|
||||||
return restoreCodeBlocks(processedContent, codeBlocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert LaTeX expressions to a markdown compatible format
|
|
||||||
processedContent = processedContent
|
|
||||||
.replace(inlineLatex, (match: string, equation: string) => `$${equation}$`) // Convert inline LaTeX
|
|
||||||
.replace(blockLatex, (match: string, equation: string) => `$$${equation}$$`); // Convert block LaTeX
|
|
||||||
|
|
||||||
// Restore code blocks
|
|
||||||
return restoreCodeBlocks(processedContent, codeBlocks);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preprocesses LaTeX content by replacing delimiters and escaping certain characters.
|
* Escapes mhchem package notation in LaTeX by converting single dollar delimiters to double dollars
|
||||||
|
* and escaping backslashes in mhchem commands.
|
||||||
*
|
*
|
||||||
|
* @param text - The input text containing potential mhchem notation
|
||||||
|
* @returns The processed text with properly escaped mhchem notation
|
||||||
|
*/
|
||||||
|
function escapeMhchem(text: string): string {
|
||||||
|
// First escape the backslashes in mhchem commands
|
||||||
|
let result = text.replace(MHCHEM_CE_REGEX, '$\\\\ce{');
|
||||||
|
result = result.replace(MHCHEM_PU_REGEX, '$\\\\pu{');
|
||||||
|
|
||||||
|
// Then convert single dollar mhchem to double dollar
|
||||||
|
result = result.replace(MHCHEM_CE_ESCAPED_REGEX, (match) => `$${match}$`);
|
||||||
|
result = result.replace(MHCHEM_PU_ESCAPED_REGEX, (match) => `$${match}$`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Efficiently finds all code block regions in the content
|
||||||
|
* @param content The content to analyze
|
||||||
|
* @returns Array of code block regions [start, end]
|
||||||
|
*/
|
||||||
|
function findCodeBlockRegions(content: string): Array<[number, number]> {
|
||||||
|
const regions: Array<[number, number]> = [];
|
||||||
|
let inlineStart = -1;
|
||||||
|
let multilineStart = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const char = content[i];
|
||||||
|
|
||||||
|
// Check for multiline code blocks
|
||||||
|
if (
|
||||||
|
char === '`' &&
|
||||||
|
i + 2 < content.length &&
|
||||||
|
content[i + 1] === '`' &&
|
||||||
|
content[i + 2] === '`'
|
||||||
|
) {
|
||||||
|
if (multilineStart === -1) {
|
||||||
|
multilineStart = i;
|
||||||
|
i += 2; // Skip the next two backticks
|
||||||
|
} else {
|
||||||
|
regions.push([multilineStart, i + 2]);
|
||||||
|
multilineStart = -1;
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for inline code blocks (only if not in multiline)
|
||||||
|
else if (char === '`' && multilineStart === -1) {
|
||||||
|
if (inlineStart === -1) {
|
||||||
|
inlineStart = i;
|
||||||
|
} else {
|
||||||
|
regions.push([inlineStart, i]);
|
||||||
|
inlineStart = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a position is inside any code block region using binary search
|
||||||
|
* @param position The position to check
|
||||||
|
* @param codeRegions Array of code block regions
|
||||||
|
* @returns True if position is inside a code block
|
||||||
|
*/
|
||||||
|
function isInCodeBlock(position: number, codeRegions: Array<[number, number]>): boolean {
|
||||||
|
let left = 0;
|
||||||
|
let right = codeRegions.length - 1;
|
||||||
|
|
||||||
|
while (left <= right) {
|
||||||
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
const [start, end] = codeRegions[mid];
|
||||||
|
|
||||||
|
if (position >= start && position <= end) {
|
||||||
|
return true;
|
||||||
|
} else if (position < start) {
|
||||||
|
right = mid - 1;
|
||||||
|
} else {
|
||||||
|
left = mid + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preprocesses LaTeX content by escaping currency indicators and converting single dollar math delimiters.
|
||||||
|
* Optimized for high-frequency execution.
|
||||||
* @param content The input string containing LaTeX expressions.
|
* @param content The input string containing LaTeX expressions.
|
||||||
* @returns The processed string with replaced delimiters and escaped characters.
|
* @returns The processed string with escaped currency indicators and converted math delimiters.
|
||||||
*/
|
*/
|
||||||
export function preprocessLaTeX(content: string): string {
|
export function preprocessLaTeX(content: string): string {
|
||||||
// Step 1: Protect code blocks
|
// Early return for most common case
|
||||||
const codeBlocks: string[] = [];
|
if (!content.includes('$')) return content;
|
||||||
content = content.replace(/(```[\s\S]*?```|`[^`\n]+`)/g, (match, code) => {
|
|
||||||
codeBlocks.push(code);
|
|
||||||
return `<<CODE_BLOCK_${codeBlocks.length - 1}>>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 2: Protect existing LaTeX expressions
|
// Process mhchem first (usually rare, so check if needed)
|
||||||
const latexExpressions: string[] = [];
|
let processed = content;
|
||||||
content = content.replace(/(\$\$[\s\S]*?\$\$|\\\[[\s\S]*?\\\]|\\\(.*?\\\))/g, (match) => {
|
if (content.includes('\\ce{') || content.includes('\\pu{')) {
|
||||||
latexExpressions.push(match);
|
processed = escapeMhchem(content);
|
||||||
return `<<LATEX_${latexExpressions.length - 1}>>`;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3: Escape dollar signs that are likely currency indicators
|
// Find all code block regions once
|
||||||
content = content.replace(/\$(?=\d)/g, '\\$');
|
const codeRegions = findCodeBlockRegions(processed);
|
||||||
|
|
||||||
// Step 4: Restore LaTeX expressions
|
// First pass: escape currency dollar signs
|
||||||
content = content.replace(/<<LATEX_(\d+)>>/g, (_, index) => latexExpressions[parseInt(index)]);
|
const parts: string[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
// Step 5: Restore code blocks
|
// Reset regex for reuse
|
||||||
content = content.replace(/<<CODE_BLOCK_(\d+)>>/g, (_, index) => codeBlocks[parseInt(index)]);
|
CURRENCY_REGEX.lastIndex = 0;
|
||||||
|
|
||||||
// Step 6: Apply additional escaping functions
|
let match: RegExpExecArray | null;
|
||||||
content = escapeBrackets(content);
|
while ((match = CURRENCY_REGEX.exec(processed)) !== null) {
|
||||||
content = escapeMhchem(content);
|
if (!isInCodeBlock(match.index, codeRegions)) {
|
||||||
|
parts.push(processed.substring(lastIndex, match.index));
|
||||||
|
parts.push('\\$');
|
||||||
|
lastIndex = match.index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(processed.substring(lastIndex));
|
||||||
|
processed = parts.join('');
|
||||||
|
|
||||||
return content;
|
// Second pass: convert single dollar delimiters to double dollars
|
||||||
}
|
const result: string[] = [];
|
||||||
|
lastIndex = 0;
|
||||||
export function escapeBrackets(text: string): string {
|
|
||||||
const pattern = /(```[\S\s]*?```|`.*?`)|\\\[([\S\s]*?[^\\])\\]|\\\((.*?)\\\)/g;
|
// Reset regex for reuse
|
||||||
return text.replace(
|
SINGLE_DOLLAR_REGEX.lastIndex = 0;
|
||||||
pattern,
|
|
||||||
(
|
while ((match = SINGLE_DOLLAR_REGEX.exec(processed)) !== null) {
|
||||||
match: string,
|
if (!isInCodeBlock(match.index, codeRegions)) {
|
||||||
codeBlock: string | undefined,
|
result.push(processed.substring(lastIndex, match.index));
|
||||||
squareBracket: string | undefined,
|
result.push(`$$${match[1]}$$`);
|
||||||
roundBracket: string | undefined,
|
lastIndex = match.index + match[0].length;
|
||||||
): string => {
|
}
|
||||||
if (codeBlock != null) {
|
}
|
||||||
return codeBlock;
|
result.push(processed.substring(lastIndex));
|
||||||
} else if (squareBracket != null) {
|
|
||||||
return `$$${squareBracket}$$`;
|
return result.join('');
|
||||||
} else if (roundBracket != null) {
|
|
||||||
return `$${roundBracket}$`;
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function escapeMhchem(text: string) {
|
|
||||||
return text.replaceAll('$\\ce{', '$\\\\ce{').replaceAll('$\\pu{', '$\\\\pu{');
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import path from 'path';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import path from 'path';
|
||||||
|
import type { Plugin } from 'vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
import { compression } from 'vite-plugin-compression2';
|
import { compression } from 'vite-plugin-compression2';
|
||||||
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||||
import type { Plugin } from 'vite';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ command }) => ({
|
export default defineConfig(({ command }) => ({
|
||||||
|
|
@ -46,7 +46,7 @@ export default defineConfig(({ command }) => ({
|
||||||
'assets/maskable-icon.png',
|
'assets/maskable-icon.png',
|
||||||
'manifest.webmanifest',
|
'manifest.webmanifest',
|
||||||
],
|
],
|
||||||
globIgnores: ['images/**/*', '**/*.map'],
|
globIgnores: ['images/**/*', '**/*.map', 'index.html'],
|
||||||
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
|
||||||
navigateFallbackDenylist: [/^\/oauth/, /^\/api/],
|
navigateFallbackDenylist: [/^\/oauth/, /^\/api/],
|
||||||
},
|
},
|
||||||
|
|
@ -169,6 +169,9 @@ export default defineConfig(({ command }) => ({
|
||||||
if (id.includes('react-select') || id.includes('downshift')) {
|
if (id.includes('react-select') || id.includes('downshift')) {
|
||||||
return 'advanced-inputs';
|
return 'advanced-inputs';
|
||||||
}
|
}
|
||||||
|
if (id.includes('heic-to')) {
|
||||||
|
return 'heic-converter';
|
||||||
|
}
|
||||||
|
|
||||||
// Existing chunks
|
// Existing chunks
|
||||||
if (id.includes('@radix-ui')) {
|
if (id.includes('@radix-ui')) {
|
||||||
|
|
@ -229,6 +232,7 @@ export default defineConfig(({ command }) => ({
|
||||||
alias: {
|
alias: {
|
||||||
'~': path.join(__dirname, 'src/'),
|
'~': path.join(__dirname, 'src/'),
|
||||||
$fonts: path.resolve(__dirname, 'public/fonts'),
|
$fonts: path.resolve(__dirname, 'public/fonts'),
|
||||||
|
'micromark-extension-math': 'micromark-extension-llm-math',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,116 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose'));
|
const mongoose = require(path.resolve(__dirname, '..', 'api', 'node_modules', 'mongoose'));
|
||||||
const { User } = require('@librechat/data-schemas').createModels(mongoose);
|
const {
|
||||||
|
User,
|
||||||
|
Agent,
|
||||||
|
Assistant,
|
||||||
|
Balance,
|
||||||
|
Transaction,
|
||||||
|
ConversationTag,
|
||||||
|
Conversation,
|
||||||
|
Message,
|
||||||
|
File,
|
||||||
|
Key,
|
||||||
|
MemoryEntry,
|
||||||
|
PluginAuth,
|
||||||
|
Prompt,
|
||||||
|
PromptGroup,
|
||||||
|
Preset,
|
||||||
|
Session,
|
||||||
|
SharedLink,
|
||||||
|
ToolCall,
|
||||||
|
Token,
|
||||||
|
} = require('@librechat/data-schemas').createModels(mongoose);
|
||||||
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
||||||
const { askQuestion, silentExit } = require('./helpers');
|
const { askQuestion, silentExit } = require('./helpers');
|
||||||
const connect = require('./connect');
|
const connect = require('./connect');
|
||||||
|
|
||||||
|
async function gracefulExit(code = 0) {
|
||||||
|
try {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error disconnecting from MongoDB:', err);
|
||||||
|
}
|
||||||
|
silentExit(code);
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
await connect();
|
await connect();
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the welcome / help menu
|
|
||||||
*/
|
|
||||||
console.purple('---------------');
|
console.purple('---------------');
|
||||||
console.purple('Deleting a user');
|
console.purple('Deleting a user and all related data');
|
||||||
console.purple('---------------');
|
console.purple('---------------');
|
||||||
|
|
||||||
let email = '';
|
// 1) Get email
|
||||||
if (process.argv.length >= 3) {
|
let email = process.argv[2]?.trim();
|
||||||
email = process.argv[2];
|
if (!email) {
|
||||||
} else {
|
email = (await askQuestion('Email:')).trim();
|
||||||
email = await askQuestion('Email:');
|
|
||||||
}
|
|
||||||
let user = await User.findOne({ email: email });
|
|
||||||
if (user !== null) {
|
|
||||||
if ((await askQuestion(`Delete user ${user}?`)) === 'y') {
|
|
||||||
user = await User.findOneAndDelete({ _id: user._id });
|
|
||||||
if (user !== null) {
|
|
||||||
console.yellow(`Deleted user ${user}`);
|
|
||||||
} else {
|
|
||||||
console.yellow(`Couldn't delete user with email ${email}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.yellow(`Didn't find user with email ${email}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
silentExit(0);
|
// 2) Find user
|
||||||
})();
|
const user = await User.findOne({ email: email.toLowerCase() });
|
||||||
|
if (!user) {
|
||||||
|
console.yellow(`No user found with email "${email}"`);
|
||||||
|
return gracefulExit(0);
|
||||||
|
}
|
||||||
|
|
||||||
process.on('uncaughtException', (err) => {
|
// 3) Confirm full deletion
|
||||||
|
const confirmAll = await askQuestion(
|
||||||
|
`Really delete user ${user.email} (${user._id}) and ALL their data? (y/N)`,
|
||||||
|
);
|
||||||
|
if (confirmAll.toLowerCase() !== 'y') {
|
||||||
|
console.yellow('Aborted.');
|
||||||
|
return gracefulExit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Ask specifically about transactions
|
||||||
|
const confirmTx = await askQuestion('Also delete all transaction history for this user? (y/N)');
|
||||||
|
const deleteTx = confirmTx.toLowerCase() === 'y';
|
||||||
|
|
||||||
|
const uid = user._id.toString();
|
||||||
|
|
||||||
|
// 5) Build and run deletion tasks
|
||||||
|
const tasks = [
|
||||||
|
Agent.deleteMany({ author: uid }),
|
||||||
|
Assistant.deleteMany({ user: uid }),
|
||||||
|
Balance.deleteMany({ user: uid }),
|
||||||
|
ConversationTag.deleteMany({ user: uid }),
|
||||||
|
Conversation.deleteMany({ user: uid }),
|
||||||
|
Message.deleteMany({ user: uid }),
|
||||||
|
File.deleteMany({ user: uid }),
|
||||||
|
Key.deleteMany({ userId: uid }),
|
||||||
|
MemoryEntry.deleteMany({ userId: uid }),
|
||||||
|
PluginAuth.deleteMany({ userId: uid }),
|
||||||
|
Prompt.deleteMany({ author: uid }),
|
||||||
|
PromptGroup.deleteMany({ author: uid }),
|
||||||
|
Preset.deleteMany({ user: uid }),
|
||||||
|
Session.deleteMany({ user: uid }),
|
||||||
|
SharedLink.deleteMany({ user: uid }),
|
||||||
|
ToolCall.deleteMany({ user: uid }),
|
||||||
|
Token.deleteMany({ userId: uid }),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (deleteTx) {
|
||||||
|
tasks.push(Transaction.deleteMany({ user: uid }));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(tasks);
|
||||||
|
|
||||||
|
// 6) Finally delete the user document itself
|
||||||
|
await User.deleteOne({ _id: uid });
|
||||||
|
|
||||||
|
console.green(`✔ Successfully deleted user ${email} and all associated data.`);
|
||||||
|
if (!deleteTx) {
|
||||||
|
console.yellow('⚠️ Transaction history was retained.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return gracefulExit(0);
|
||||||
|
})().catch(async (err) => {
|
||||||
if (!err.message.includes('fetch failed')) {
|
if (!err.message.includes('fetch failed')) {
|
||||||
console.error('There was an uncaught error:');
|
console.error('There was an uncaught error:');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
await mongoose.disconnect();
|
||||||
|
|
||||||
if (!err.message.includes('fetch failed')) {
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
1004
package-lock.json
generated
1004
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -64,6 +64,7 @@
|
||||||
"b:data": "cd packages/data-provider && bun run b:build",
|
"b:data": "cd packages/data-provider && bun run b:build",
|
||||||
"b:mcp": "cd packages/api && bun run b:build",
|
"b:mcp": "cd packages/api && bun run b:build",
|
||||||
"b:data-schemas": "cd packages/data-schemas && bun run b:build",
|
"b:data-schemas": "cd packages/data-schemas && bun run b:build",
|
||||||
|
"b:build:api": "cd packages/api && bun run b:build",
|
||||||
"b:client": "bun --bun run b:data && bun --bun run b:mcp && bun --bun run b:data-schemas && cd client && bun --bun run b:build",
|
"b:client": "bun --bun run b:data && bun --bun run b:mcp && bun --bun run b:data-schemas && cd client && bun --bun run b:build",
|
||||||
"b:client:dev": "cd client && bun run b:dev",
|
"b:client:dev": "cd client && bun run b:dev",
|
||||||
"b:test:client": "cd client && bun run b:test",
|
"b:test:client": "cd client && bun run b:test",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@librechat/api",
|
"name": "@librechat/api",
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"description": "MCP services for LibreChat",
|
"description": "MCP services for LibreChat",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
@ -69,9 +69,9 @@
|
||||||
"registry": "https://registry.npmjs.org/"
|
"registry": "https://registry.npmjs.org/"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@librechat/agents": "^2.4.37",
|
"@librechat/agents": "^2.4.41",
|
||||||
"@librechat/data-schemas": "*",
|
"@librechat/data-schemas": "*",
|
||||||
"@modelcontextprotocol/sdk": "^1.11.2",
|
"@modelcontextprotocol/sdk": "^1.12.3",
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
"eventsource": "^3.0.2",
|
"eventsource": "^3.0.2",
|
||||||
|
|
@ -80,6 +80,7 @@
|
||||||
"librechat-data-provider": "*",
|
"librechat-data-provider": "*",
|
||||||
"node-fetch": "2.7.0",
|
"node-fetch": "2.7.0",
|
||||||
"tiktoken": "^1.0.15",
|
"tiktoken": "^1.0.15",
|
||||||
|
"undici": "^7.10.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,11 @@ const plugins = [
|
||||||
const cjsBuild = {
|
const cjsBuild = {
|
||||||
input: 'src/index.ts',
|
input: 'src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
file: pkg.main,
|
dir: 'dist',
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
exports: 'named',
|
exports: 'named',
|
||||||
|
entryFileNames: '[name].js',
|
||||||
},
|
},
|
||||||
external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})],
|
external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})],
|
||||||
preserveSymlinks: true,
|
preserveSymlinks: true,
|
||||||
|
|
|
||||||
93
packages/api/src/agents/auth.ts
Normal file
93
packages/api/src/agents/auth.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { logger } from '@librechat/data-schemas';
|
||||||
|
import type { IPluginAuth, PluginAuthMethods } from '@librechat/data-schemas';
|
||||||
|
import { decrypt } from '../crypto/encryption';
|
||||||
|
|
||||||
|
export interface GetPluginAuthMapParams {
|
||||||
|
userId: string;
|
||||||
|
pluginKeys: string[];
|
||||||
|
throwError?: boolean;
|
||||||
|
findPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginAuthMap = Record<string, Record<string, string>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and decrypts authentication values for multiple plugins
|
||||||
|
* @returns A map where keys are pluginKeys and values are objects of authField:decryptedValue pairs
|
||||||
|
*/
|
||||||
|
export async function getPluginAuthMap({
|
||||||
|
userId,
|
||||||
|
pluginKeys,
|
||||||
|
throwError = true,
|
||||||
|
findPluginAuthsByKeys,
|
||||||
|
}: GetPluginAuthMapParams): Promise<PluginAuthMap> {
|
||||||
|
try {
|
||||||
|
/** Early return for empty plugin keys */
|
||||||
|
if (!pluginKeys?.length) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All plugin auths for current user query */
|
||||||
|
const pluginAuths = await findPluginAuthsByKeys({ userId, pluginKeys });
|
||||||
|
|
||||||
|
/** Group auth records by pluginKey for efficient lookup */
|
||||||
|
const authsByPlugin = new Map<string, IPluginAuth[]>();
|
||||||
|
for (const auth of pluginAuths) {
|
||||||
|
if (!auth.pluginKey) {
|
||||||
|
logger.warn(`[getPluginAuthMap] Missing pluginKey for userId ${userId}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = authsByPlugin.get(auth.pluginKey) || [];
|
||||||
|
existing.push(auth);
|
||||||
|
authsByPlugin.set(auth.pluginKey, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authMap: PluginAuthMap = {};
|
||||||
|
const decryptionPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
/** Single loop through requested pluginKeys */
|
||||||
|
for (const pluginKey of pluginKeys) {
|
||||||
|
authMap[pluginKey] = {};
|
||||||
|
const auths = authsByPlugin.get(pluginKey) || [];
|
||||||
|
|
||||||
|
for (const auth of auths) {
|
||||||
|
decryptionPromises.push(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const decryptedValue = await decrypt(auth.value);
|
||||||
|
authMap[pluginKey][auth.authField] = decryptedValue;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error(
|
||||||
|
`[getPluginAuthMap] Decryption failed for userId ${userId}, plugin ${pluginKey}, field ${auth.authField}: ${message}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (throwError) {
|
||||||
|
throw new Error(
|
||||||
|
`Decryption failed for plugin ${pluginKey}, field ${auth.authField}: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(decryptionPromises);
|
||||||
|
return authMap;
|
||||||
|
} catch (error) {
|
||||||
|
if (!throwError) {
|
||||||
|
/** Empty objects for each plugin key on error */
|
||||||
|
return pluginKeys.reduce((acc, key) => {
|
||||||
|
acc[key] = {};
|
||||||
|
return acc;
|
||||||
|
}, {} as PluginAuthMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error(
|
||||||
|
`[getPluginAuthMap] Failed to fetch auth values for userId ${userId}, plugins: ${pluginKeys.join(', ')}: ${message}`,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { Run, Providers } from '@librechat/agents';
|
import { Run, Providers } from '@librechat/agents';
|
||||||
import { providerEndpointMap, KnownEndpoints } from 'librechat-data-provider';
|
import { providerEndpointMap, KnownEndpoints } from 'librechat-data-provider';
|
||||||
import type { StandardGraphConfig, EventHandler, GraphEvents, IState } from '@librechat/agents';
|
import type {
|
||||||
|
StandardGraphConfig,
|
||||||
|
EventHandler,
|
||||||
|
GenericTool,
|
||||||
|
GraphEvents,
|
||||||
|
IState,
|
||||||
|
} from '@librechat/agents';
|
||||||
import type { Agent } from 'librechat-data-provider';
|
import type { Agent } from 'librechat-data-provider';
|
||||||
import type * as t from '~/types';
|
import type * as t from '~/types';
|
||||||
|
|
||||||
|
|
@ -32,7 +38,7 @@ export async function createRun({
|
||||||
streaming = true,
|
streaming = true,
|
||||||
streamUsage = true,
|
streamUsage = true,
|
||||||
}: {
|
}: {
|
||||||
agent: Agent;
|
agent: Omit<Agent, 'tools'> & { tools?: GenericTool[] };
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
require('dotenv').config();
|
import 'dotenv/config';
|
||||||
const crypto = require('node:crypto');
|
import crypto from 'node:crypto';
|
||||||
const { webcrypto } = crypto;
|
const { webcrypto } = crypto;
|
||||||
|
|
||||||
// Use hex decoding for both key and IV for legacy methods.
|
// Use hex decoding for both key and IV for legacy methods.
|
||||||
const key = Buffer.from(process.env.CREDS_KEY, 'hex');
|
const key = Buffer.from(process.env.CREDS_KEY ?? '', 'hex');
|
||||||
const iv = Buffer.from(process.env.CREDS_IV, 'hex');
|
const iv = Buffer.from(process.env.CREDS_IV ?? '', 'hex');
|
||||||
const algorithm = 'AES-CBC';
|
const algorithm = 'AES-CBC';
|
||||||
|
|
||||||
// --- Legacy v1/v2 Setup: AES-CBC with fixed key and IV ---
|
// --- Legacy v1/v2 Setup: AES-CBC with fixed key and IV ---
|
||||||
|
|
||||||
async function encrypt(value) {
|
export async function encrypt(value: string) {
|
||||||
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
||||||
'encrypt',
|
'encrypt',
|
||||||
]);
|
]);
|
||||||
|
|
@ -23,7 +23,7 @@ async function encrypt(value) {
|
||||||
return Buffer.from(encryptedBuffer).toString('hex');
|
return Buffer.from(encryptedBuffer).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decrypt(encryptedValue) {
|
export async function decrypt(encryptedValue: string) {
|
||||||
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
||||||
'decrypt',
|
'decrypt',
|
||||||
]);
|
]);
|
||||||
|
|
@ -39,7 +39,7 @@ async function decrypt(encryptedValue) {
|
||||||
|
|
||||||
// --- v2: AES-CBC with a random IV per encryption ---
|
// --- v2: AES-CBC with a random IV per encryption ---
|
||||||
|
|
||||||
async function encryptV2(value) {
|
export async function encryptV2(value: string) {
|
||||||
const gen_iv = webcrypto.getRandomValues(new Uint8Array(16));
|
const gen_iv = webcrypto.getRandomValues(new Uint8Array(16));
|
||||||
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
||||||
'encrypt',
|
'encrypt',
|
||||||
|
|
@ -54,12 +54,12 @@ async function encryptV2(value) {
|
||||||
return Buffer.from(gen_iv).toString('hex') + ':' + Buffer.from(encryptedBuffer).toString('hex');
|
return Buffer.from(gen_iv).toString('hex') + ':' + Buffer.from(encryptedBuffer).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptV2(encryptedValue) {
|
export async function decryptV2(encryptedValue: string) {
|
||||||
const parts = encryptedValue.split(':');
|
const parts = encryptedValue.split(':');
|
||||||
if (parts.length === 1) {
|
if (parts.length === 1) {
|
||||||
return parts[0];
|
return parts[0];
|
||||||
}
|
}
|
||||||
const gen_iv = Buffer.from(parts.shift(), 'hex');
|
const gen_iv = Buffer.from(parts.shift() ?? '', 'hex');
|
||||||
const encrypted = parts.join(':');
|
const encrypted = parts.join(':');
|
||||||
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
const cryptoKey = await webcrypto.subtle.importKey('raw', key, { name: algorithm }, false, [
|
||||||
'decrypt',
|
'decrypt',
|
||||||
|
|
@ -81,10 +81,10 @@ const algorithm_v3 = 'aes-256-ctr';
|
||||||
* Encrypts a value using AES-256-CTR.
|
* Encrypts a value using AES-256-CTR.
|
||||||
* Note: AES-256 requires a 32-byte key. Ensure that process.env.CREDS_KEY is a 64-character hex string.
|
* Note: AES-256 requires a 32-byte key. Ensure that process.env.CREDS_KEY is a 64-character hex string.
|
||||||
*
|
*
|
||||||
* @param {string} value - The plaintext to encrypt.
|
* @param value - The plaintext to encrypt.
|
||||||
* @returns {string} The encrypted string with a "v3:" prefix.
|
* @returns The encrypted string with a "v3:" prefix.
|
||||||
*/
|
*/
|
||||||
function encryptV3(value) {
|
export function encryptV3(value: string) {
|
||||||
if (key.length !== 32) {
|
if (key.length !== 32) {
|
||||||
throw new Error(`Invalid key length: expected 32 bytes, got ${key.length} bytes`);
|
throw new Error(`Invalid key length: expected 32 bytes, got ${key.length} bytes`);
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +94,7 @@ function encryptV3(value) {
|
||||||
return `v3:${iv_v3.toString('hex')}:${encrypted.toString('hex')}`;
|
return `v3:${iv_v3.toString('hex')}:${encrypted.toString('hex')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decryptV3(encryptedValue) {
|
export function decryptV3(encryptedValue: string) {
|
||||||
const parts = encryptedValue.split(':');
|
const parts = encryptedValue.split(':');
|
||||||
if (parts[0] !== 'v3') {
|
if (parts[0] !== 'v3') {
|
||||||
throw new Error('Not a v3 encrypted value');
|
throw new Error('Not a v3 encrypted value');
|
||||||
|
|
@ -106,7 +106,7 @@ function decryptV3(encryptedValue) {
|
||||||
return decrypted.toString('utf8');
|
return decrypted.toString('utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRandomValues(length) {
|
export async function getRandomValues(length: number) {
|
||||||
if (!Number.isInteger(length) || length <= 0) {
|
if (!Number.isInteger(length) || length <= 0) {
|
||||||
throw new Error('Length must be a positive integer');
|
throw new Error('Length must be a positive integer');
|
||||||
}
|
}
|
||||||
|
|
@ -117,24 +117,13 @@ async function getRandomValues(length) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes SHA-256 hash for the given input.
|
* Computes SHA-256 hash for the given input.
|
||||||
* @param {string} input
|
* @param input - The input to hash.
|
||||||
* @returns {Promise<string>}
|
* @returns The SHA-256 hash of the input.
|
||||||
*/
|
*/
|
||||||
async function hashBackupCode(input) {
|
export async function hashBackupCode(input: string) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const data = encoder.encode(input);
|
const data = encoder.encode(input);
|
||||||
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
|
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
encrypt,
|
|
||||||
decrypt,
|
|
||||||
encryptV2,
|
|
||||||
decryptV2,
|
|
||||||
encryptV3,
|
|
||||||
decryptV3,
|
|
||||||
hashBackupCode,
|
|
||||||
getRandomValues,
|
|
||||||
};
|
|
||||||
1
packages/api/src/crypto/index.ts
Normal file
1
packages/api/src/crypto/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './encryption';
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
import { ProxyAgent } from 'undici';
|
||||||
import { KnownEndpoints } from 'librechat-data-provider';
|
import { KnownEndpoints } from 'librechat-data-provider';
|
||||||
import type * as t from '~/types';
|
import type * as t from '~/types';
|
||||||
import { sanitizeModelName, constructAzureURL } from '~/utils/azure';
|
import { sanitizeModelName, constructAzureURL } from '~/utils/azure';
|
||||||
|
|
@ -102,8 +102,10 @@ export function getOpenAIConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proxy) {
|
if (proxy) {
|
||||||
const proxyAgent = new HttpsProxyAgent(proxy);
|
const proxyAgent = new ProxyAgent(proxy);
|
||||||
configOptions.httpAgent = proxyAgent;
|
configOptions.fetchOptions = {
|
||||||
|
dispatcher: proxyAgent,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (azure) {
|
if (azure) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { FlowStateManager } from './manager';
|
|
||||||
import { Keyv } from 'keyv';
|
import { Keyv } from 'keyv';
|
||||||
|
import { FlowStateManager } from './manager';
|
||||||
import type { FlowState } from './types';
|
import type { FlowState } from './types';
|
||||||
|
|
||||||
// Create a mock class without extending Keyv
|
/** Mock class without extending Keyv */
|
||||||
class MockKeyv {
|
class MockKeyv {
|
||||||
private store: Map<string, FlowState<string>>;
|
private store: Map<string, FlowState<string>>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,18 @@
|
||||||
import { Keyv } from 'keyv';
|
import { Keyv } from 'keyv';
|
||||||
|
import { logger } from '@librechat/data-schemas';
|
||||||
import type { StoredDataNoRaw } from 'keyv';
|
import type { StoredDataNoRaw } from 'keyv';
|
||||||
import type { Logger } from 'winston';
|
|
||||||
import type { FlowState, FlowMetadata, FlowManagerOptions } from './types';
|
import type { FlowState, FlowMetadata, FlowManagerOptions } from './types';
|
||||||
|
|
||||||
export class FlowStateManager<T = unknown> {
|
export class FlowStateManager<T = unknown> {
|
||||||
private keyv: Keyv;
|
private keyv: Keyv;
|
||||||
private ttl: number;
|
private ttl: number;
|
||||||
private logger: Logger;
|
|
||||||
private intervals: Set<NodeJS.Timeout>;
|
private intervals: Set<NodeJS.Timeout>;
|
||||||
|
|
||||||
private static getDefaultLogger(): Logger {
|
|
||||||
return {
|
|
||||||
error: console.error,
|
|
||||||
warn: console.warn,
|
|
||||||
info: console.info,
|
|
||||||
debug: console.debug,
|
|
||||||
} as Logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(store: Keyv, options?: FlowManagerOptions) {
|
constructor(store: Keyv, options?: FlowManagerOptions) {
|
||||||
if (!options) {
|
if (!options) {
|
||||||
options = { ttl: 60000 * 3 };
|
options = { ttl: 60000 * 3 };
|
||||||
}
|
}
|
||||||
const { ci = false, ttl, logger } = options;
|
const { ci = false, ttl } = options;
|
||||||
|
|
||||||
if (!ci && !(store instanceof Keyv)) {
|
if (!ci && !(store instanceof Keyv)) {
|
||||||
throw new Error('Invalid store provided to FlowStateManager');
|
throw new Error('Invalid store provided to FlowStateManager');
|
||||||
|
|
@ -30,14 +20,13 @@ export class FlowStateManager<T = unknown> {
|
||||||
|
|
||||||
this.ttl = ttl;
|
this.ttl = ttl;
|
||||||
this.keyv = store;
|
this.keyv = store;
|
||||||
this.logger = logger || FlowStateManager.getDefaultLogger();
|
|
||||||
this.intervals = new Set();
|
this.intervals = new Set();
|
||||||
this.setupCleanupHandlers();
|
this.setupCleanupHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupCleanupHandlers() {
|
private setupCleanupHandlers() {
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
this.logger.info('Cleaning up FlowStateManager intervals...');
|
logger.info('Cleaning up FlowStateManager intervals...');
|
||||||
this.intervals.forEach((interval) => clearInterval(interval));
|
this.intervals.forEach((interval) => clearInterval(interval));
|
||||||
this.intervals.clear();
|
this.intervals.clear();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
@ -66,7 +55,7 @@ export class FlowStateManager<T = unknown> {
|
||||||
|
|
||||||
let existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
let existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
||||||
if (existingState) {
|
if (existingState) {
|
||||||
this.logger.debug(`[${flowKey}] Flow already exists`);
|
logger.debug(`[${flowKey}] Flow already exists`);
|
||||||
return this.monitorFlow(flowKey, type, signal);
|
return this.monitorFlow(flowKey, type, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +63,7 @@ export class FlowStateManager<T = unknown> {
|
||||||
|
|
||||||
existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
||||||
if (existingState) {
|
if (existingState) {
|
||||||
this.logger.debug(`[${flowKey}] Flow exists on 2nd check`);
|
logger.debug(`[${flowKey}] Flow exists on 2nd check`);
|
||||||
return this.monitorFlow(flowKey, type, signal);
|
return this.monitorFlow(flowKey, type, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,7 +74,7 @@ export class FlowStateManager<T = unknown> {
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.debug('Creating initial flow state:', flowKey);
|
logger.debug('Creating initial flow state:', flowKey);
|
||||||
await this.keyv.set(flowKey, initialState, this.ttl);
|
await this.keyv.set(flowKey, initialState, this.ttl);
|
||||||
return this.monitorFlow(flowKey, type, signal);
|
return this.monitorFlow(flowKey, type, signal);
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +91,7 @@ export class FlowStateManager<T = unknown> {
|
||||||
if (!flowState) {
|
if (!flowState) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
this.intervals.delete(intervalId);
|
this.intervals.delete(intervalId);
|
||||||
this.logger.error(`[${flowKey}] Flow state not found`);
|
logger.error(`[${flowKey}] Flow state not found`);
|
||||||
reject(new Error(`${type} Flow state not found`));
|
reject(new Error(`${type} Flow state not found`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +99,7 @@ export class FlowStateManager<T = unknown> {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
this.intervals.delete(intervalId);
|
this.intervals.delete(intervalId);
|
||||||
this.logger.warn(`[${flowKey}] Flow aborted`);
|
logger.warn(`[${flowKey}] Flow aborted`);
|
||||||
const message = `${type} flow aborted`;
|
const message = `${type} flow aborted`;
|
||||||
await this.keyv.delete(flowKey);
|
await this.keyv.delete(flowKey);
|
||||||
reject(new Error(message));
|
reject(new Error(message));
|
||||||
|
|
@ -120,7 +109,7 @@ export class FlowStateManager<T = unknown> {
|
||||||
if (flowState.status !== 'PENDING') {
|
if (flowState.status !== 'PENDING') {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
this.intervals.delete(intervalId);
|
this.intervals.delete(intervalId);
|
||||||
this.logger.debug(`[${flowKey}] Flow completed`);
|
logger.debug(`[${flowKey}] Flow completed`);
|
||||||
|
|
||||||
if (flowState.status === 'COMPLETED' && flowState.result !== undefined) {
|
if (flowState.status === 'COMPLETED' && flowState.result !== undefined) {
|
||||||
resolve(flowState.result);
|
resolve(flowState.result);
|
||||||
|
|
@ -135,17 +124,15 @@ export class FlowStateManager<T = unknown> {
|
||||||
if (elapsedTime >= this.ttl) {
|
if (elapsedTime >= this.ttl) {
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
this.intervals.delete(intervalId);
|
this.intervals.delete(intervalId);
|
||||||
this.logger.error(
|
logger.error(
|
||||||
`[${flowKey}] Flow timed out | Elapsed time: ${elapsedTime} | TTL: ${this.ttl}`,
|
`[${flowKey}] Flow timed out | Elapsed time: ${elapsedTime} | TTL: ${this.ttl}`,
|
||||||
);
|
);
|
||||||
await this.keyv.delete(flowKey);
|
await this.keyv.delete(flowKey);
|
||||||
reject(new Error(`${type} flow timed out`));
|
reject(new Error(`${type} flow timed out`));
|
||||||
}
|
}
|
||||||
this.logger.debug(
|
logger.debug(`[${flowKey}] Flow state elapsed time: ${elapsedTime}, checking again...`);
|
||||||
`[${flowKey}] Flow state elapsed time: ${elapsedTime}, checking again...`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[${flowKey}] Error checking flow state:`, error);
|
logger.error(`[${flowKey}] Error checking flow state:`, error);
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
this.intervals.delete(intervalId);
|
this.intervals.delete(intervalId);
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|
@ -224,7 +211,7 @@ export class FlowStateManager<T = unknown> {
|
||||||
const flowKey = this.getFlowKey(flowId, type);
|
const flowKey = this.getFlowKey(flowId, type);
|
||||||
let existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
let existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
||||||
if (existingState) {
|
if (existingState) {
|
||||||
this.logger.debug(`[${flowKey}] Flow already exists`);
|
logger.debug(`[${flowKey}] Flow already exists`);
|
||||||
return this.monitorFlow(flowKey, type, signal);
|
return this.monitorFlow(flowKey, type, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,7 +219,7 @@ export class FlowStateManager<T = unknown> {
|
||||||
|
|
||||||
existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
existingState = (await this.keyv.get(flowKey)) as FlowState<T> | undefined;
|
||||||
if (existingState) {
|
if (existingState) {
|
||||||
this.logger.debug(`[${flowKey}] Flow exists on 2nd check`);
|
logger.debug(`[${flowKey}] Flow exists on 2nd check`);
|
||||||
return this.monitorFlow(flowKey, type, signal);
|
return this.monitorFlow(flowKey, type, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,7 +229,7 @@ export class FlowStateManager<T = unknown> {
|
||||||
metadata: {},
|
metadata: {},
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
this.logger.debug(`[${flowKey}] Creating initial flow state`);
|
logger.debug(`[${flowKey}] Creating initial flow state`);
|
||||||
await this.keyv.set(flowKey, initialState, this.ttl);
|
await this.keyv.set(flowKey, initialState, this.ttl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
/* MCP */
|
/* MCP */
|
||||||
export * from './mcp/manager';
|
export * from './mcp/manager';
|
||||||
|
export * from './mcp/oauth';
|
||||||
|
export * from './mcp/auth';
|
||||||
/* Utilities */
|
/* Utilities */
|
||||||
export * from './mcp/utils';
|
export * from './mcp/utils';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
/* OAuth */
|
||||||
|
export * from './oauth';
|
||||||
|
/* Crypto */
|
||||||
|
export * from './crypto';
|
||||||
/* Flow */
|
/* Flow */
|
||||||
export * from './flow/manager';
|
export * from './flow/manager';
|
||||||
/* Agents */
|
/* Agents */
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue