Compare commits

...

24 Commits

Author SHA1 Message Date
Danny Avila
e1ad235f17 🌍 i18n: Add missing com_ui_no_changes 2025-08-25 17:59:46 -04:00
Danny Avila
4a0b329e3e v0.8.0-rc3 (#9269)
* chore: bump data-provider to v0.8.004

* 📦 chore: bump @librechat/data-schemas version to 0.0.20

* 📦 chore: bump @librechat/api version to 1.3.4

*  v0.8.0-rc3

* docs: update README

* docs: update README

* docs: enhance multilingual UI section in README
2025-08-25 17:35:21 -04:00
github-actions[bot]
a22359de5e 🌍 i18n: Update translation.json with latest translations (#9267)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-25 17:14:51 -04:00
Danny Avila
bbfe4002eb 🏷️ chore: Add Missing Localizations for Agents, Categories, Bookmarks (#9266)
* fix: error when updating bookmarks if no query data

* feat: localize bookmark dialog, form labels and validation messages, also improve validation

* feat: add localization for EmptyPromptPreview component and update translation.json

* chore: add missing localizations for static UI text

* chore: update AgentPanelContextType and useGetAgentsConfig to support null configurations

* refactor: update agent categories to support localization and custom properties, improve related typing

* ci: add localization for 'All' category and update tab names in accessibility tests

* chore: remove unused AgentCategoryDisplay component and its tests

* chore: add localization handling for agent category selector

* chore: enhance AgentCard to support localized category labels and add related tests

* chore: enhance i18n unused keys detection to include additional source directories and improve handling for agent category keys
2025-08-25 13:54:13 -04:00
Marco Beretta
94426a3cae 🎭 refactor: Avatar Loading UX and Fix Initials Rendering Bugs (#9261)
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-08-25 12:06:00 -04:00
Danny Avila
e559f0f4dc 📜 chore: Add Timestamp to Error logs (#9262) 2025-08-25 11:05:15 -04:00
github-actions[bot]
15c9c7e1f4 🌍 i18n: Update translation.json with latest translations (#9250)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-25 10:52:24 -04:00
Danny Avila
ac641e7cba 🗄️ refactor: Resource Migration Scripts for DocumentDB Compatibility (#9249)
* refactor: Resource Migration Scripts for DocumentDB compatibility

* fix: Correct type annotation for `db` parameter in ensureCollectionExists function
2025-08-25 03:01:50 -04:00
Danny Avila
1915d7b195 🧮 fix: Properly Escape Currency and Prevent Code Block LaTeX Bugs (#9248)
* fix(latex): prevent LaTeX conversion when closing $ is preceded by backtick

When text contained patterns like "$lookup namespace" followed by "`$lookup`",
the regex would match from the first $ to the backtick's $, treating the entire
span as a LaTeX expression. This caused programming constructs to be incorrectly
converted to double dollars.

- Added negative lookbehind (?<!`) to single dollar regex
- Prevents matching when closing $ immediately follows a backtick
- Fixes issues with inline code blocks containing $ symbols

* fix(latex): detect currency amounts with 4+ digits without commas

The currency regex pattern \d{1,3} only matched amounts with 1-3 initial digits,
causing amounts like $1157.90 to be interpreted as LaTeX instead of currency.
This resulted in text like "$1157.90 (text) + $500 (text) = $1657.90" being
incorrectly converted to a single LaTeX expression.

- Changed pattern from \d{1,3} to \d+ to match any number of initial digits
- Now properly escapes $1000, $10000, $123456, etc. without requiring commas
- Maintains support for comma-formatted amounts like $1,234.56

* fix(latex): support currency with unlimited decimal places

The currency regex limited decimal places to 1-2 digits (\.\d{1,2}), which
failed to properly escape amounts with more precision like cryptocurrency
values ($0.00001234), gas prices ($3.999), or exchange rates ($1.23456).

- Changed decimal pattern from \.\d{1,2} to \.\d+
- Now supports any number of decimal places
- Handles edge cases like scientific calculations and high-precision values
2025-08-25 02:44:13 -04:00
github-actions[bot]
c2f4b383f2 🌍 i18n: Update translation.json with latest translations (#9228)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-24 12:42:57 -04:00
Danny Avila
939af59950 🏄‍♂️ refactor: Improve Cancelled Stream Handling for Pending Authentication (#9235) 2025-08-24 12:42:34 -04:00
Danny Avila
7d08da1a8a 🪜 fix: userMCPAuthMap Sequential Agents assignment 2025-08-24 11:37:51 -04:00
Danny Avila
543b617e1c 📦 chore: bump cipher-base and sha.js (#9227) 2025-08-23 03:32:40 -04:00
Danny Avila
c827fdd10e 🚦 feat: Auto-reinitialize MCP Servers on Request (#9226) 2025-08-23 03:27:05 -04:00
Marco Beretta
ac608ded46 feat: Add cursor pagination utilities and refine user/group/role types in @librechat/data-schemas (#9218)
* feat: Add pagination interfaces and update user and group types for better data handling

* fix: Update data-schemas version to 0.0.19
2025-08-23 00:18:31 -04:00
mattmueller-stripe
0e00f357a6 🔗 chore: Remove <link href='#' /> from index.html (#9222) 2025-08-23 00:16:32 -04:00
Danny Avila
c465d7b732 🪪 fix: Preserve Existing Interface Permissions When Updating Config (#9199) 2025-08-21 12:49:45 -04:00
Danny Avila
aba0a93d1d 🧰 fix: Available Tools Retrieval with correct MCP Caching (#9181)
* fix: available tools retrieval with correct mcp caching and conversion

* test: Enhance PluginController tests with MCP tool mocking and conversion

* refactor: Simplify PluginController tests by removing unused mocks and enhancing test clarity
2025-08-20 22:33:54 -04:00
Danny Avila
a49b2b2833 🛣️ feat: directEndpoint Fetch Override for Custom Endpoints (#9179)
* feat: Add directEndpoint option to OpenAIConfigOptions and update fetch logic to override /chat/completions URL

* feat: Add directEndpoint support to fetchModels and update loadConfigModels logic
2025-08-20 15:55:32 -04:00
Danny Avila
e0ebb7097e fix: AbortSignal Cleanup Logic for New Chats (#9177)
* chore: import paths for isEnabled and logger in title.js

*  fix: `AbortSignal` Cleanup Logic for New Chats

* test: Add `isNewConvo` parameter to onStart expectation in BaseClient tests
2025-08-20 14:56:07 -04:00
Dustin Healy
9a79635012 🌍 i18n: Add Bosnian and Norsk Bokmål Languages (#9176) 2025-08-20 14:17:23 -04:00
Danny Avila
ce19abc968 🆔 feat: Add User ID to Anthropic API Payload as Metadata (#9174) 2025-08-20 13:42:08 -04:00
Danny Avila
49cd3894aa 🛠️ fix: Restrict Editable Content Types & Consolidate Typing (#9173)
* fix: only allow editing expected content types & align typing app-wide

* chore: update TPayload to use TEditedContent type for editedContent
2025-08-20 13:21:47 -04:00
Danny Avila
da4aa37493 🛠️ refactor: Consolidate MCP Tool Caching (#9172)
* 🛠️ refactor: Consolidate MCP Tool Caching

* 🐍 fix: Correctly mock and utilize updateMCPUserTools in MCP route tests
2025-08-20 12:19:29 -04:00
115 changed files with 2975 additions and 1221 deletions

View File

@@ -1,5 +1,10 @@
name: Detect Unused i18next Strings
# This workflow checks for unused i18n keys in translation files.
# It has special handling for:
# - com_ui_special_var_* keys that are dynamically constructed
# - com_agents_category_* keys that are stored in the database and used dynamically
on:
pull_request:
paths:
@@ -7,6 +12,7 @@ on:
- "api/**"
- "packages/data-provider/src/**"
- "packages/client/**"
- "packages/data-schemas/src/**"
jobs:
detect-unused-i18n-keys:
@@ -24,7 +30,7 @@ jobs:
# Define paths
I18N_FILE="client/src/locales/en/translation.json"
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client")
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src")
# Check if translation file exists
if [[ ! -f "$I18N_FILE" ]]; then
@@ -52,6 +58,31 @@ jobs:
fi
done
# Also check if the key is directly used somewhere
if [[ "$FOUND" == false ]]; then
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
FOUND=true
break
fi
done
fi
# Special case for agent category keys that are dynamically used from database
elif [[ "$KEY" == com_agents_category_* ]]; then
# Check if agent category localization is being used
for DIR in "${SOURCE_DIRS[@]}"; do
# Check for dynamic category label/description usage
if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \
# Check for the method that defines these keys
grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \
# Check for direct usage in agentCategory.ts
grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \
grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; then
FOUND=true
break
fi
done
# Also check if the key is directly used somewhere
if [[ "$FOUND" == false ]]; then
for DIR in "${SOURCE_DIRS[@]}"; do

View File

@@ -1,4 +1,4 @@
# v0.8.0-rc2
# v0.8.0-rc3
# Base node image
FROM node:20-alpine AS node

View File

@@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.8.0-rc2
# v0.8.0-rc3
# Base for all builds
FROM node:20-alpine AS base-min

View File

@@ -65,8 +65,10 @@
- 🔦 **Agents & Tools Integration**:
- **[LibreChat Agents](https://www.librechat.ai/docs/features/agents)**:
- No-Code Custom Assistants: Build specialized, AI-driven helpers without coding
- Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more
- No-Code Custom Assistants: Build specialized, AI-driven helpers
- Agent Marketplace: Discover and deploy community-built agents
- Collaborative Sharing: Share agents with specific users and groups
- Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more
- Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more
- [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
@@ -87,15 +89,18 @@
- Create, Save, & Share Custom Presets
- Switch between AI Endpoints and Presets mid-chat
- Edit, Resubmit, and Continue Messages with Conversation branching
- Create and share prompts with specific users and groups
- [Fork Messages & Conversations](https://www.librechat.ai/docs/features/fork) for Advanced Context control
- 💬 **Multimodal & File Interactions**:
- Upload and analyze images with Claude 3, GPT-4.5, GPT-4o, o1, Llama-Vision, and Gemini 📸
- Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, & Google 🗃️
- 🌎 **Multilingual UI**:
- English, 中文, Deutsch, Español, Français, Italiano, Polski, Português Brasileiro
- Русский, 日本語, Svenska, 한국어, Tiếng Việt, 繁體中文, العربية, Türkçe, Nederlands, עברית
- 🌎 **Multilingual UI**:
- English, 中文 (简体), 中文 (繁體), العربية, Deutsch, Español, Français, Italiano
- Polski, Português (PT), Português (BR), Русский, 日本語, Svenska, 한국어, Tiếng Việt
- Türkçe, Nederlands, עברית, Català, Čeština, Dansk, Eesti, فارسی
- Suomi, Magyar, Հայերեն, Bahasa Indonesia, ქართული, Latviešu, ไทย, ئۇيغۇرچە
- 🧠 **Reasoning UI**:
- Dynamic Reasoning UI for Chain-of-Thought/Reasoning AI models like DeepSeek-R1

View File

@@ -187,7 +187,8 @@ class BaseClient {
this.user = user;
const saveOptions = this.getSaveOptions();
this.abortController = opts.abortController ?? new AbortController();
const conversationId = overrideConvoId ?? opts.conversationId ?? crypto.randomUUID();
const requestConvoId = overrideConvoId ?? opts.conversationId;
const conversationId = requestConvoId ?? crypto.randomUUID();
const parentMessageId = opts.parentMessageId ?? Constants.NO_PARENT;
const userMessageId =
overrideUserMessageId ?? opts.overrideParentMessageId ?? crypto.randomUUID();
@@ -212,11 +213,12 @@ class BaseClient {
...opts,
user,
head,
saveOptions,
userMessageId,
requestConvoId,
conversationId,
parentMessageId,
userMessageId,
responseMessageId,
saveOptions,
};
}
@@ -235,11 +237,12 @@ class BaseClient {
const {
user,
head,
saveOptions,
userMessageId,
requestConvoId,
conversationId,
parentMessageId,
userMessageId,
responseMessageId,
saveOptions,
} = await this.setMessageOptions(opts);
const userMessage = opts.isEdited
@@ -261,7 +264,8 @@ class BaseClient {
}
if (typeof opts?.onStart === 'function') {
opts.onStart(userMessage, responseMessageId);
const isNewConvo = !requestConvoId && parentMessageId === Constants.NO_PARENT;
opts.onStart(userMessage, responseMessageId, isNewConvo);
}
return {

View File

@@ -579,6 +579,8 @@ describe('BaseClient', () => {
expect(onStart).toHaveBeenCalledWith(
expect.objectContaining({ text: 'Hello, world!' }),
expect.any(String),
/** `isNewConvo` */
true,
);
});

View File

@@ -3,7 +3,7 @@ const { SerpAPI } = require('@langchain/community/tools/serpapi');
const { Calculator } = require('@langchain/community/tools/calculator');
const { mcpToolPattern, loadWebSearchAuth } = require('@librechat/api');
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
const { Tools, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
const { Tools, Constants, EToolResources, replaceSpecialVars } = require('librechat-data-provider');
const {
availableTools,
manifestToolMap,
@@ -24,9 +24,9 @@ const {
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { createMCPTool, createMCPTools } = require('~/server/services/MCP');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { getCachedTools } = require('~/server/services/Config');
const { createMCPTool } = require('~/server/services/MCP');
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
@@ -123,6 +123,8 @@ const getAuthFields = (toolKey) => {
*
* @param {object} object
* @param {string} object.user
* @param {Record<string, Record<string, string>>} [object.userMCPAuthMap]
* @param {AbortSignal} [object.signal]
* @param {Pick<Agent, 'id' | 'provider' | 'model'>} [object.agent]
* @param {string} [object.model]
* @param {EModelEndpoint} [object.endpoint]
@@ -137,7 +139,9 @@ const loadTools = async ({
user,
agent,
model,
signal,
endpoint,
userMCPAuthMap,
tools = [],
options = {},
functions = true,
@@ -231,6 +235,7 @@ const loadTools = async ({
/** @type {Record<string, string>} */
const toolContextMap = {};
const cachedTools = (await getCachedTools({ userId: user, includeGlobal: true })) ?? {};
const requestedMCPTools = {};
for (const tool of tools) {
if (tool === Tools.execute_code) {
@@ -299,14 +304,35 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
};
continue;
} else if (tool && cachedTools && mcpToolPattern.test(tool)) {
requestedTools[tool] = async () =>
const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
if (toolName === Constants.mcp_all) {
const currentMCPGenerator = async (index) =>
createMCPTools({
req: options.req,
res: options.res,
index,
serverName,
userMCPAuthMap,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,
signal,
});
requestedMCPTools[serverName] = [currentMCPGenerator];
continue;
}
const currentMCPGenerator = async (index) =>
createMCPTool({
index,
req: options.req,
res: options.res,
toolKey: tool,
userMCPAuthMap,
model: agent?.model ?? model,
provider: agent?.provider ?? endpoint,
signal,
});
requestedMCPTools[serverName] = requestedMCPTools[serverName] || [];
requestedMCPTools[serverName].push(currentMCPGenerator);
continue;
}
@@ -346,6 +372,34 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
}
const loadedTools = (await Promise.all(toolPromises)).flatMap((plugin) => plugin || []);
const mcpToolPromises = [];
/** MCP server tools are initialized sequentially by server */
let index = -1;
for (const [serverName, generators] of Object.entries(requestedMCPTools)) {
index++;
for (const generator of generators) {
try {
if (generator && generators.length === 1) {
mcpToolPromises.push(
generator(index).catch((error) => {
logger.error(`Error loading ${serverName} tools:`, error);
return null;
}),
);
continue;
}
const mcpTool = await generator(index);
if (Array.isArray(mcpTool)) {
loadedTools.push(...mcpTool);
} else if (mcpTool) {
loadedTools.push(mcpTool);
}
} catch (error) {
logger.error(`Error loading MCP tool for server ${serverName}:`, error);
}
}
}
loadedTools.push(...(await Promise.all(mcpToolPromises)).flatMap((plugin) => plugin || []));
return { loadedTools, toolContextMap };
};

View File

@@ -31,7 +31,6 @@ const namespaces = {
[CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION),
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
[CacheKeys.MCP_TOOLS]: standardCache(CacheKeys.MCP_TOOLS),
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
[CacheKeys.STATIC_CONFIG]: standardCache(CacheKeys.STATIC_CONFIG),
[CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),

View File

@@ -2,7 +2,7 @@ const mongoose = require('mongoose');
const crypto = require('node:crypto');
const { logger } = require('@librechat/data-schemas');
const { ResourceType, SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_all, mcp_delimiter } =
require('librechat-data-provider').Constants;
const {
removeAgentFromAllProjects,
@@ -78,6 +78,7 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
tools.push(Tools.web_search);
}
const addedServers = new Set();
if (mcpServers.size > 0) {
for (const toolName of Object.keys(availableTools)) {
if (!toolName.includes(mcp_delimiter)) {
@@ -85,9 +86,17 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _
}
const mcpServer = toolName.split(mcp_delimiter)?.[1];
if (mcpServer && mcpServers.has(mcpServer)) {
addedServers.add(mcpServer);
tools.push(toolName);
}
}
for (const mcpServer of mcpServers) {
if (addedServers.has(mcpServer)) {
continue;
}
tools.push(`${mcp_all}${mcp_delimiter}${mcpServer}`);
}
}
const instructions = req.body.promptPrefix;

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.8.0-rc2",
"version": "v0.8.0-rc3",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",

View File

@@ -4,11 +4,18 @@ const {
getToolkitKey,
checkPluginAuth,
filterUniquePlugins,
convertMCPToolToPlugin,
convertMCPToolsToPlugins,
} = require('@librechat/api');
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
const {
getCachedTools,
setCachedTools,
mergeUserTools,
getCustomConfig,
} = require('~/server/services/Config');
const { loadAndFormatTools } = require('~/server/services/ToolService');
const { availableTools, toolkits } = require('~/app/clients/tools');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const getAvailablePluginsController = async (req, res) => {
@@ -22,6 +29,7 @@ const getAvailablePluginsController = async (req, res) => {
/** @type {{ filteredTools: string[], includedTools: string[] }} */
const { filteredTools = [], includedTools = [] } = req.app.locals;
/** @type {import('@librechat/api').LCManifestTool[]} */
const pluginManifest = availableTools;
const uniquePlugins = filterUniquePlugins(pluginManifest);
@@ -47,45 +55,6 @@ 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.
*
@@ -101,11 +70,18 @@ function createGetServerTools() {
const getAvailableTools = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
logger.warn('[getAvailableTools] User ID not found in request');
return res.status(401).json({ message: 'Unauthorized' });
}
const customConfig = await getCustomConfig();
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
const cachedUserTools = await getCachedTools({ userId });
const userPlugins = convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig });
const userPlugins =
cachedUserTools != null
? convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig })
: undefined;
if (cachedToolsArray != null && userPlugins != null) {
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
@@ -113,31 +89,51 @@ const getAvailableTools = async (req, res) => {
return;
}
/** @type {Record<string, FunctionTool> | null} Get tool definitions to filter which tools are actually available */
let toolDefinitions = await getCachedTools({ includeGlobal: true });
let prelimCachedTools;
// TODO: this is a temp fix until app config is refactored
if (!toolDefinitions) {
toolDefinitions = loadAndFormatTools({
adminFilter: req.app.locals?.filteredTools,
adminIncluded: req.app.locals?.includedTools,
directory: req.app.locals?.paths.structuredTools,
});
prelimCachedTools = toolDefinitions;
}
/** @type {import('@librechat/api').LCManifestTool[]} */
let pluginManifest = availableTools;
if (customConfig?.mcpServers != null) {
try {
const mcpManager = getMCPManager();
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];
const mcpTools = await mcpManager.getAllToolFunctions(userId);
prelimCachedTools = prelimCachedTools ?? {};
for (const [toolKey, toolData] of Object.entries(mcpTools)) {
const plugin = convertMCPToolToPlugin({
toolKey,
toolData,
customConfig,
});
if (plugin) {
pluginManifest.push(plugin);
}
prelimCachedTools[toolKey] = toolData;
}
await mergeUserTools({ userId, cachedUserTools, userTools: prelimCachedTools });
} catch (error) {
logger.error(
'[getAvailableTools] Error loading MCP Tools, servers may still be initializing:',
error,
);
}
} else if (prelimCachedTools != null) {
await setCachedTools(prelimCachedTools, { isGlobal: true });
}
/** @type {TPlugin[]} */
/** @type {TPlugin[]} Deduplicate and authenticate plugins */
const uniquePlugins = filterUniquePlugins(pluginManifest);
const authenticatedPlugins = uniquePlugins.map((plugin) => {
if (checkPluginAuth(plugin)) {
return { ...plugin, authenticated: true };
@@ -146,8 +142,7 @@ const getAvailableTools = async (req, res) => {
}
});
const toolDefinitions = (await getCachedTools({ includeGlobal: true })) || {};
/** Filter plugins based on availability and add MCP-specific auth config */
const toolsOutput = [];
for (const plugin of authenticatedPlugins) {
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
@@ -163,41 +158,36 @@ const getAvailableTools = async (req, res) => {
const toolToAdd = { ...plugin };
if (!plugin.pluginKey.includes(Constants.mcp_delimiter)) {
toolsOutput.push(toolToAdd);
continue;
}
if (plugin.pluginKey.includes(Constants.mcp_delimiter)) {
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
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;
if (serverConfig?.customUserVars) {
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);
const dedupedTools = filterUniquePlugins([...userPlugins, ...finalTools]);
const dedupedTools = filterUniquePlugins([...(userPlugins ?? []), ...finalTools]);
res.status(200).json(dedupedTools);
} catch (error) {
logger.error('[getAvailableTools]', error);

View File

@@ -13,15 +13,18 @@ jest.mock('@librechat/data-schemas', () => ({
jest.mock('~/server/services/Config', () => ({
getCustomConfig: jest.fn(),
getCachedTools: jest.fn(),
setCachedTools: jest.fn(),
mergeUserTools: jest.fn(),
}));
jest.mock('~/server/services/ToolService', () => ({
getToolkitKey: jest.fn(),
loadAndFormatTools: jest.fn(),
}));
jest.mock('~/config', () => ({
getMCPManager: jest.fn(() => ({
loadManifestTools: jest.fn().mockResolvedValue([]),
loadAllManifestTools: jest.fn().mockResolvedValue([]),
})),
getFlowStateManager: jest.fn(),
}));
@@ -35,31 +38,31 @@ jest.mock('~/cache', () => ({
getLogStores: jest.fn(),
}));
jest.mock('@librechat/api', () => ({
getToolkitKey: jest.fn(),
checkPluginAuth: jest.fn(),
filterUniquePlugins: jest.fn(),
convertMCPToolsToPlugins: jest.fn(),
}));
// Import the actual module with the function we want to test
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
const {
filterUniquePlugins,
checkPluginAuth,
convertMCPToolsToPlugins,
getToolkitKey,
} = require('@librechat/api');
const { loadAndFormatTools } = require('~/server/services/ToolService');
describe('PluginController', () => {
let mockReq, mockRes, mockCache;
beforeEach(() => {
jest.clearAllMocks();
mockReq = { user: { id: 'test-user-id' } };
mockReq = {
user: { id: 'test-user-id' },
app: {
locals: {
paths: { structuredTools: '/mock/path' },
filteredTools: null,
includedTools: null,
},
},
};
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
mockCache = { get: jest.fn(), set: jest.fn() };
getLogStores.mockReturnValue(mockCache);
// Clear availableTools and toolkits arrays before each test
require('~/app/clients/tools').availableTools.length = 0;
require('~/app/clients/tools').toolkits.length = 0;
});
describe('getAvailablePluginsController', () => {
@@ -68,38 +71,39 @@ describe('PluginController', () => {
});
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
// Add plugins with duplicates to availableTools
const mockPlugins = [
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
{ name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue(mockPlugins);
checkPluginAuth.mockReturnValue(true);
await getAvailablePluginsController(mockReq, mockRes);
expect(filterUniquePlugins).toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(200);
// The response includes authenticated: true for each plugin when checkPluginAuth returns true
expect(mockRes.json).toHaveBeenCalledWith([
{ name: 'Plugin1', pluginKey: 'key1', description: 'First', authenticated: true },
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second', authenticated: true },
]);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData).toHaveLength(2);
expect(responseData[0].pluginKey).toBe('key1');
expect(responseData[1].pluginKey).toBe('key2');
});
it('should use checkPluginAuth to verify plugin authentication', async () => {
// checkPluginAuth returns false for plugins without authConfig
// so authenticated property won't be added
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue([mockPlugin]);
checkPluginAuth.mockReturnValueOnce(true);
await getAvailablePluginsController(mockReq, mockRes);
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
const responseData = mockRes.json.mock.calls[0][0];
expect(responseData[0].authenticated).toBe(true);
// checkPluginAuth returns false, so authenticated property is not added
expect(responseData[0].authenticated).toBeUndefined();
});
it('should return cached plugins when available', async () => {
@@ -111,8 +115,7 @@ describe('PluginController', () => {
await getAvailablePluginsController(mockReq, mockRes);
expect(filterUniquePlugins).not.toHaveBeenCalled();
expect(checkPluginAuth).not.toHaveBeenCalled();
// When cache is hit, we return immediately without processing
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
});
@@ -122,10 +125,9 @@ describe('PluginController', () => {
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
];
require('~/app/clients/tools').availableTools.push(...mockPlugins);
mockReq.app.locals.includedTools = ['key1'];
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue(mockPlugins);
checkPluginAuth.mockReturnValue(false);
await getAvailablePluginsController(mockReq, mockRes);
@@ -139,70 +141,102 @@ describe('PluginController', () => {
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
const mockUserTools = {
[`tool1${Constants.mcp_delimiter}server1`]: {
function: { name: 'tool1', description: 'Tool 1' },
type: 'function',
function: {
name: `tool1${Constants.mcp_delimiter}server1`,
description: 'Tool 1',
parameters: { type: 'object', properties: {} },
},
},
};
const mockConvertedPlugins = [
{
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}server1`,
description: 'Tool 1',
},
];
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce(mockUserTools);
convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins);
filterUniquePlugins.mockImplementation((plugins) => plugins);
getCustomConfig.mockResolvedValue(null);
// Mock second call to return tool definitions
getCachedTools.mockResolvedValueOnce(mockUserTools);
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: mockUserTools,
customConfig: null,
});
const responseData = mockRes.json.mock.calls[0][0];
// convertMCPToolsToPlugins should have converted the tool
expect(responseData.length).toBeGreaterThan(0);
const convertedTool = responseData.find(
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`,
);
expect(convertedTool).toBeDefined();
expect(convertedTool.name).toBe('tool1');
});
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
const mockUserPlugins = [
{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' },
];
const mockManifestPlugins = [
const mockUserTools = {
'user-tool': {
type: 'function',
function: {
name: 'user-tool',
description: 'User tool',
parameters: { type: 'object', properties: {} },
},
},
};
const mockCachedPlugins = [
{ name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
];
mockCache.get.mockResolvedValue(mockManifestPlugins);
getCachedTools.mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins);
filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]);
mockCache.get.mockResolvedValue(mockCachedPlugins);
getCachedTools.mockResolvedValueOnce(mockUserTools);
getCustomConfig.mockResolvedValue(null);
// Mock second call to return tool definitions
getCachedTools.mockResolvedValueOnce(mockUserTools);
await getAvailableTools(mockReq, mockRes);
// Should be called to deduplicate the combined array
expect(filterUniquePlugins).toHaveBeenLastCalledWith([
...mockUserPlugins,
...mockManifestPlugins,
]);
const responseData = mockRes.json.mock.calls[0][0];
// Should have deduplicated tools with same pluginKey
const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
expect(userToolCount).toBe(1);
});
it('should use checkPluginAuth to verify authentication status', async () => {
const mockPlugin = { name: 'Tool1', pluginKey: 'tool1', description: 'Tool 1' };
// Add a plugin to availableTools that will be checked
const mockPlugin = {
name: 'Tool1',
pluginKey: 'tool1',
description: 'Tool 1',
// No authConfig means checkPluginAuth returns false
};
require('~/app/clients/tools').availableTools.push(mockPlugin);
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockPlugin]);
checkPluginAuth.mockReturnValue(true);
getCachedTools.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true });
// Mock loadAndFormatTools to return tool definitions including our tool
loadAndFormatTools.mockReturnValue({
tool1: {
type: 'function',
function: {
name: 'tool1',
description: 'Tool 1',
parameters: {},
},
},
});
await getAvailableTools(mockReq, mockRes);
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
const tool = responseData.find((t) => t.pluginKey === 'tool1');
expect(tool).toBeDefined();
// checkPluginAuth returns false, so authenticated property is not added
expect(tool.authenticated).toBeUndefined();
});
it('should use getToolkitKey for toolkit validation', async () => {
@@ -213,22 +247,38 @@ describe('PluginController', () => {
toolkit: true,
};
require('~/app/clients/tools').availableTools.push(mockToolkit);
// Mock toolkits to have a mapping
require('~/app/clients/tools').toolkits.push({
name: 'Toolkit1',
pluginKey: 'toolkit1',
tools: ['toolkit1_function'],
});
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue('toolkit1');
getCachedTools.mockResolvedValue(null);
getCustomConfig.mockResolvedValue(null);
// Mock getCachedTools second call to return tool definitions
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({
toolkit1_function: true,
// Mock loadAndFormatTools to return tool definitions
loadAndFormatTools.mockReturnValue({
toolkit1_function: {
type: 'function',
function: {
name: 'toolkit1_function',
description: 'Toolkit function',
parameters: {},
},
},
});
await getAvailableTools(mockReq, mockRes);
expect(getToolkitKey).toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(200);
const responseData = mockRes.json.mock.calls[0][0];
expect(Array.isArray(responseData)).toBe(true);
const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
expect(toolkit).toBeDefined();
});
});
@@ -239,32 +289,33 @@ describe('PluginController', () => {
const functionTools = {
[`test-tool${Constants.mcp_delimiter}test-server`]: {
function: { name: 'test-tool', description: 'A test tool' },
type: 'function',
function: {
name: `test-tool${Constants.mcp_delimiter}test-server`,
description: 'A test tool',
parameters: { type: 'object', properties: {} },
},
},
};
const mockConvertedPlugin = {
name: 'test-tool',
pluginKey: `test-tool${Constants.mcp_delimiter}test-server`,
description: 'A test tool',
icon: mcpServers['test-server']?.iconPath,
authenticated: true,
authConfig: [],
// Mock the MCP manager to return tools
const mockMCPManager = {
getAllToolFunctions: jest.fn().mockResolvedValue(functionTools),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
getCachedTools.mockResolvedValueOnce({});
// Mock loadAndFormatTools to return empty object since these are MCP tools
loadAndFormatTools.mockReturnValue({});
getCachedTools.mockResolvedValueOnce(functionTools);
convertMCPToolsToPlugins.mockReturnValue([mockConvertedPlugin]);
filterUniquePlugins.mockImplementation((plugins) => plugins);
checkPluginAuth.mockReturnValue(true);
getToolkitKey.mockReturnValue(undefined);
getCachedTools.mockResolvedValueOnce({
[`test-tool${Constants.mcp_delimiter}test-server`]: true,
});
await getAvailableTools(mockReq, mockRes);
const responseData = mockRes.json.mock.calls[0][0];
return responseData.find((tool) => tool.name === 'test-tool');
return responseData.find(
(tool) => tool.pluginKey === `test-tool${Constants.mcp_delimiter}test-server`,
);
};
it('should set plugin.icon when iconPath is defined', async () => {
@@ -298,19 +349,21 @@ describe('PluginController', () => {
},
};
// We need to test the actual flow where MCP manager tools are included
const mcpManagerTools = [
{
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
authenticated: true,
// Mock MCP tools returned by getAllToolFunctions
const mcpToolFunctions = {
[`tool1${Constants.mcp_delimiter}test-server`]: {
type: 'function',
function: {
name: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
parameters: {},
},
},
];
};
// Mock the MCP manager to return tools
const mockMCPManager = {
loadManifestTools: jest.fn().mockResolvedValue(mcpManagerTools),
getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions),
};
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
@@ -320,19 +373,11 @@ describe('PluginController', () => {
// First call returns user tools (empty in this case)
getCachedTools.mockResolvedValueOnce({});
// Mock convertMCPToolsToPlugins to return empty array for user tools
convertMCPToolsToPlugins.mockReturnValue([]);
// Mock loadAndFormatTools to return empty object for MCP tools
loadAndFormatTools.mockReturnValue({});
// Mock filterUniquePlugins to pass through
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
// Mock checkPluginAuth
checkPluginAuth.mockReturnValue(true);
// Second call returns tool definitions
getCachedTools.mockResolvedValueOnce({
[`tool1${Constants.mcp_delimiter}test-server`]: true,
});
// Second call returns tool definitions including our MCP tool
getCachedTools.mockResolvedValueOnce(mcpToolFunctions);
await getAvailableTools(mockReq, mockRes);
@@ -373,25 +418,23 @@ describe('PluginController', () => {
it('should handle null cachedTools and cachedUserTools', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(null);
convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
// Mock loadAndFormatTools to return empty object when getCachedTools returns null
loadAndFormatTools.mockReturnValue({});
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: null,
customConfig: null,
});
// Should handle null values gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
});
it('should handle when getCachedTools returns undefined', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue(undefined);
convertMCPToolsToPlugins.mockReturnValue(undefined);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(false);
// Mock loadAndFormatTools to return empty object when getCachedTools returns undefined
loadAndFormatTools.mockReturnValue({});
// Mock getCachedTools to return undefined for both calls
getCachedTools.mockReset();
@@ -399,37 +442,40 @@ describe('PluginController', () => {
await getAvailableTools(mockReq, mockRes);
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
functionTools: undefined,
customConfig: null,
});
// Should handle undefined values gracefully
expect(mockRes.status).toHaveBeenCalledWith(200);
});
it('should handle cachedToolsArray and userPlugins both being defined', async () => {
const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
// Use MCP delimiter for the user tool so convertMCPToolsToPlugins works
const userTools = {
'user-tool': { function: { name: 'user-tool', description: 'User tool' } },
[`user-tool${Constants.mcp_delimiter}server1`]: {
type: 'function',
function: {
name: `user-tool${Constants.mcp_delimiter}server1`,
description: 'User tool',
parameters: {},
},
},
};
const userPlugins = [{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' }];
mockCache.get.mockResolvedValue(cachedTools);
getCachedTools.mockResolvedValue(userTools);
convertMCPToolsToPlugins.mockReturnValue(userPlugins);
filterUniquePlugins.mockReturnValue([...userPlugins, ...cachedTools]);
getCustomConfig.mockResolvedValue(null);
await getAvailableTools(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith([...userPlugins, ...cachedTools]);
const responseData = mockRes.json.mock.calls[0][0];
// Should have both cached and user tools
expect(responseData.length).toBeGreaterThanOrEqual(2);
});
it('should handle empty toolDefinitions object', async () => {
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
getCustomConfig.mockResolvedValue(null);
checkPluginAuth.mockReturnValue(true);
await getAvailableTools(mockReq, mockRes);
@@ -456,18 +502,6 @@ describe('PluginController', () => {
getCustomConfig.mockResolvedValue(customConfig);
getCachedTools.mockResolvedValueOnce(mockUserTools);
const mockPlugin = {
name: 'tool1',
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
description: 'Tool 1',
authenticated: true,
authConfig: [],
};
convertMCPToolsToPlugins.mockReturnValue([mockPlugin]);
filterUniquePlugins.mockImplementation((plugins) => plugins);
checkPluginAuth.mockReturnValue(true);
getCachedTools.mockResolvedValueOnce({
[`tool1${Constants.mcp_delimiter}test-server`]: true,
});
@@ -483,8 +517,6 @@ describe('PluginController', () => {
it('should handle req.app.locals with undefined filteredTools and includedTools', async () => {
mockReq.app = { locals: {} };
mockCache.get.mockResolvedValue(null);
filterUniquePlugins.mockReturnValue([]);
checkPluginAuth.mockReturnValue(false);
await getAvailablePluginsController(mockReq, mockRes);
@@ -500,14 +532,25 @@ describe('PluginController', () => {
toolkit: true,
};
// Ensure req.app.locals is properly mocked
mockReq.app = {
locals: {
filteredTools: [],
includedTools: [],
paths: { structuredTools: '/mock/path' },
},
};
// Add the toolkit to availableTools
require('~/app/clients/tools').availableTools.push(mockToolkit);
mockCache.get.mockResolvedValue(null);
getCachedTools.mockResolvedValue({});
convertMCPToolsToPlugins.mockReturnValue([]);
filterUniquePlugins.mockReturnValue([mockToolkit]);
checkPluginAuth.mockReturnValue(false);
getToolkitKey.mockReturnValue(undefined);
getCustomConfig.mockResolvedValue(null);
// Mock loadAndFormatTools to return an empty object when toolDefinitions is null
loadAndFormatTools.mockReturnValue({});
// Mock getCachedTools second call to return null
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null);

View File

@@ -33,18 +33,13 @@ const {
bedrockInputSchema,
removeNullishValues,
} = require('librechat-data-provider');
const {
findPluginAuthsByKeys,
getFormattedMemories,
deleteMemory,
setMemory,
} = require('~/models');
const { getMCPAuthMap, checkCapability, hasCustomUserVars } = require('~/server/services/Config');
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
const { getProviderConfig } = require('~/server/services/Endpoints');
const { checkCapability } = require('~/server/services/Config');
const BaseClient = require('~/app/clients/BaseClient');
const { getRoleByName } = require('~/models/Role');
const { loadAgent } = require('~/models/Agent');
@@ -615,6 +610,7 @@ class AgentClient extends BaseClient {
await this.chatCompletion({
payload,
onProgress: opts.onProgress,
userMCPAuthMap: opts.userMCPAuthMap,
abortController: opts.abortController,
});
return this.contentParts;
@@ -747,7 +743,13 @@ class AgentClient extends BaseClient {
return currentMessageTokens > 0 ? currentMessageTokens : originalEstimate;
}
async chatCompletion({ payload, abortController = null }) {
/**
* @param {object} params
* @param {string | ChatCompletionMessageParam[]} params.payload
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @param {AbortController} [params.abortController]
*/
async chatCompletion({ payload, userMCPAuthMap, abortController = null }) {
/** @type {Partial<GraphRunnableConfig>} */
let config;
/** @type {ReturnType<createRun>} */
@@ -903,21 +905,9 @@ class AgentClient extends BaseClient {
run.Graph.contentData = contentData;
}
try {
if (await hasCustomUserVars()) {
config.configurable.userMCPAuthMap = await getMCPAuthMap({
tools: agent.tools,
userId: this.options.req.user.id,
findPluginAuthsByKeys,
});
}
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent ${agent.id}`,
err,
);
if (userMCPAuthMap != null) {
config.configurable.userMCPAuthMap = userMCPAuthMap;
}
await run.processStream({ messages }, config, {
keepContent: i !== 0,
tokenCounter: createTokenCounter(this.getEncoding()),

View File

@@ -9,6 +9,24 @@ const {
const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup');
const { saveMessage } = require('~/models');
function createCloseHandler(abortController) {
return function (manual) {
if (!manual) {
logger.debug('[AgentController] Request closed');
}
if (!abortController) {
return;
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
return;
}
abortController.abort();
logger.debug('[AgentController] Request aborted on close');
};
}
const AgentController = async (req, res, next, initializeClient, addTitle) => {
let {
text,
@@ -31,7 +49,6 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
let userMessagePromise;
let getAbortData;
let client = null;
// Initialize as an array
let cleanupHandlers = [];
const newConvo = !conversationId;
@@ -62,9 +79,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
// Create a function to handle final cleanup
const performCleanup = () => {
logger.debug('[AgentController] Performing cleanup');
// Make sure cleanupHandlers is an array before iterating
if (Array.isArray(cleanupHandlers)) {
// Execute all cleanup handlers
for (const handler of cleanupHandlers) {
try {
if (typeof handler === 'function') {
@@ -105,8 +120,33 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
};
try {
/** @type {{ client: TAgentClient }} */
const result = await initializeClient({ req, res, endpointOption });
let prelimAbortController = new AbortController();
const prelimCloseHandler = createCloseHandler(prelimAbortController);
res.on('close', prelimCloseHandler);
const removePrelimHandler = (manual) => {
try {
prelimCloseHandler(manual);
res.removeListener('close', prelimCloseHandler);
} catch (e) {
logger.error('[AgentController] Error removing close listener', e);
}
};
cleanupHandlers.push(removePrelimHandler);
/** @type {{ client: TAgentClient; userMCPAuthMap?: Record<string, Record<string, string>> }} */
const result = await initializeClient({
req,
res,
endpointOption,
signal: prelimAbortController.signal,
});
if (prelimAbortController.signal?.aborted) {
prelimAbortController = null;
throw new Error('Request was aborted before initialization could complete');
} else {
prelimAbortController = null;
removePrelimHandler(true);
cleanupHandlers.pop();
}
client = result.client;
// Register client with finalization registry if available
@@ -138,22 +178,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
};
const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData);
// Simple handler to avoid capturing scope
const closeHandler = () => {
logger.debug('[AgentController] Request closed');
if (!abortController) {
return;
} else if (abortController.signal.aborted) {
return;
} else if (abortController.requestCompleted) {
return;
}
abortController.abort();
logger.debug('[AgentController] Request aborted on close');
};
const closeHandler = createCloseHandler(abortController);
res.on('close', closeHandler);
cleanupHandlers.push(() => {
try {
@@ -175,6 +200,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
abortController,
overrideParentMessageId,
isEdited: !!editedContent,
userMCPAuthMap: result.userMCPAuthMap,
responseMessageId: editedResponseMessageId,
progressOptions: {
res,

View File

@@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { countTokens, isEnabled, sendEvent } = require('@librechat/api');
const { isAssistantsEndpoint, ErrorTypes } = require('librechat-data-provider');
const { isAssistantsEndpoint, ErrorTypes, Constants } = require('librechat-data-provider');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const clearPendingReq = require('~/cache/clearPendingReq');
const { sendError } = require('~/server/middleware/error');
@@ -11,6 +11,10 @@ const { abortRun } = require('./abortRun');
const abortDataMap = new WeakMap();
/**
* @param {string} abortKey
* @returns {boolean}
*/
function cleanupAbortController(abortKey) {
if (!abortControllers.has(abortKey)) {
return false;
@@ -71,6 +75,20 @@ function cleanupAbortController(abortKey) {
return true;
}
/**
* @param {string} abortKey
* @returns {function(): void}
*/
function createCleanUpHandler(abortKey) {
return function () {
try {
cleanupAbortController(abortKey);
} catch {
// Ignore cleanup errors
}
};
}
async function abortMessage(req, res) {
let { abortKey, endpoint } = req.body;
@@ -172,11 +190,15 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
/**
* @param {TMessage} userMessage
* @param {string} responseMessageId
* @param {boolean} [isNewConvo]
*/
const onStart = (userMessage, responseMessageId) => {
const onStart = (userMessage, responseMessageId, isNewConvo) => {
sendEvent(res, { message: userMessage, created: true });
const abortKey = userMessage?.conversationId ?? req.user.id;
const prelimAbortKey = userMessage?.conversationId ?? req.user.id;
const abortKey = isNewConvo
? `${prelimAbortKey}${Constants.COMMON_DIVIDER}${Constants.NEW_CONVO}`
: prelimAbortKey;
getReqData({ abortKey });
const prevRequest = abortControllers.get(abortKey);
const { overrideUserMessageId } = req?.body ?? {};
@@ -194,16 +216,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
};
abortControllers.set(addedAbortKey, { abortController, ...minimalOptions });
// Use a simple function for cleanup to avoid capturing context
const cleanupHandler = () => {
try {
cleanupAbortController(addedAbortKey);
} catch (e) {
// Ignore cleanup errors
}
};
const cleanupHandler = createCleanUpHandler(addedAbortKey);
res.on('finish', cleanupHandler);
return;
}
@@ -216,16 +229,7 @@ const createAbortController = (req, res, getAbortData, getReqData) => {
};
abortControllers.set(abortKey, { abortController, ...minimalOptions });
// Use a simple function for cleanup to avoid capturing context
const cleanupHandler = () => {
try {
cleanupAbortController(abortKey);
} catch (e) {
// Ignore cleanup errors
}
};
const cleanupHandler = createCleanUpHandler(abortKey);
res.on('finish', cleanupHandler);
};
@@ -364,15 +368,7 @@ const handleAbortError = async (res, req, error, data) => {
};
}
// Create a simple callback without capturing parent scope
const callback = async () => {
try {
cleanupAbortController(conversationId);
} catch (e) {
// Ignore cleanup errors
}
};
const callback = createCleanUpHandler(conversationId);
await sendError(req, res, options, callback);
};

View File

@@ -1,15 +1,17 @@
const { MongoMemoryServer } = require('mongodb-memory-server');
const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const express = require('express');
const { MongoMemoryServer } = require('mongodb-memory-server');
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
MCPOAuthHandler: {
initiateOAuthFlow: jest.fn(),
getFlowState: jest.fn(),
completeOAuthFlow: jest.fn(),
generateFlowId: jest.fn(),
},
getUserMCPAuthMap: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
@@ -36,6 +38,7 @@ jest.mock('~/models', () => ({
updateToken: jest.fn(),
createToken: jest.fn(),
deleteTokens: jest.fn(),
findPluginAuthsByKeys: jest.fn(),
}));
jest.mock('~/server/services/Config', () => ({
@@ -44,6 +47,10 @@ jest.mock('~/server/services/Config', () => ({
loadCustomConfig: jest.fn(),
}));
jest.mock('~/server/services/Config/mcpToolsCache', () => ({
updateMCPUserTools: jest.fn(),
}));
jest.mock('~/server/services/MCP', () => ({
getMCPSetupData: jest.fn(),
getServerConnectionStatus: jest.fn(),
@@ -66,6 +73,10 @@ jest.mock('~/server/middleware', () => ({
requireJwtAuth: (req, res, next) => next(),
}));
jest.mock('~/server/services/Tools/mcp', () => ({
reinitMCPServer: jest.fn(),
}));
describe('MCP Routes', () => {
let app;
let mongoServer;
@@ -677,6 +688,13 @@ describe('MCP Routes', () => {
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
success: true,
message: "MCP server 'oauth-server' ready for OAuth authentication",
serverName: 'oauth-server',
oauthRequired: true,
oauthUrl: 'https://oauth.example.com/auth',
});
const response = await request(app).post('/api/mcp/oauth-server/reinitialize');
@@ -701,6 +719,7 @@ describe('MCP Routes', () => {
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue(null);
const response = await request(app).post('/api/mcp/error-server/reinitialize');
@@ -759,8 +778,18 @@ describe('MCP Routes', () => {
require('~/cache').getLogStores.mockReturnValue({});
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
getCachedTools.mockResolvedValue({});
setCachedTools.mockResolvedValue();
updateMCPUserTools.mockResolvedValue();
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
success: true,
message: "MCP server 'test-server' reinitialized successfully",
serverName: 'test-server',
oauthRequired: false,
oauthUrl: null,
});
const response = await request(app).post('/api/mcp/test-server/reinitialize');
@@ -776,7 +805,6 @@ describe('MCP Routes', () => {
'test-user-id',
'test-server',
);
expect(setCachedTools).toHaveBeenCalled();
});
it('should handle server with custom user variables', async () => {
@@ -798,21 +826,38 @@ describe('MCP Routes', () => {
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
require('~/config').getFlowStateManager.mockReturnValue({});
require('~/cache').getLogStores.mockReturnValue({});
require('~/server/services/PluginService').getUserPluginAuthValue.mockResolvedValue(
'api-key-value',
);
require('@librechat/api').getUserMCPAuthMap.mockResolvedValue({
'mcp:test-server': {
API_KEY: 'api-key-value',
},
});
require('~/models').findPluginAuthsByKeys.mockResolvedValue([
{ key: 'API_KEY', value: 'api-key-value' },
]);
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
getCachedTools.mockResolvedValue({});
setCachedTools.mockResolvedValue();
updateMCPUserTools.mockResolvedValue();
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
success: true,
message: "MCP server 'test-server' reinitialized successfully",
serverName: 'test-server',
oauthRequired: false,
oauthUrl: null,
});
const response = await request(app).post('/api/mcp/test-server/reinitialize');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(
require('~/server/services/PluginService').getUserPluginAuthValue,
).toHaveBeenCalledWith('test-user-id', 'API_KEY', false);
expect(require('@librechat/api').getUserMCPAuthMap).toHaveBeenCalledWith({
userId: 'test-user-id',
servers: ['test-server'],
findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys,
});
});
});

View File

@@ -46,7 +46,7 @@ router.use('/tools', tools);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
* @route GET /agents/categories
*/
router.get('/categories', v1.getAgentCategories);
/**

View File

@@ -1,13 +1,15 @@
const { logger } = require('@librechat/data-schemas');
const { MCPOAuthHandler } = require('@librechat/api');
const { Router } = require('express');
const { logger } = require('@librechat/data-schemas');
const { MCPOAuthHandler, getUserMCPAuthMap } = require('@librechat/api');
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
const { setCachedTools, getCachedTools } = require('~/server/services/Config');
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { requireJwtAuth } = require('~/server/middleware');
const { findPluginAuthsByKeys } = require('~/models');
const { getLogStores } = require('~/cache');
const router = Router();
@@ -142,33 +144,12 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
);
const userTools = (await getCachedTools({ userId: flowState.userId })) || {};
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete userTools[key];
}
}
const tools = await userConnection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
userTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema,
},
};
}
await setCachedTools(userTools, { userId: flowState.userId });
logger.debug(
`[MCP OAuth] Cached ${tools.length} tools for ${serverName} user ${flowState.userId}`,
);
await updateMCPUserTools({
userId: flowState.userId,
serverName,
tools,
});
} else {
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
}
@@ -323,124 +304,39 @@ router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
});
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
await mcpManager.disconnectUserConnection(user.id, serverName);
logger.info(
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
);
let customUserVars = {};
/** @type {Record<string, Record<string, string>> | undefined} */
let userMCPAuthMap;
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
for (const varName of Object.keys(serverConfig.customUserVars)) {
try {
const value = await getUserPluginAuthValue(user.id, varName, false);
customUserVars[varName] = value;
} catch (err) {
logger.error(`[MCP Reinitialize] Error fetching ${varName} for user ${user.id}:`, err);
}
}
}
let userConnection = null;
let oauthRequired = false;
let oauthUrl = null;
try {
userConnection = await mcpManager.getUserConnection({
user,
serverName,
flowManager,
customUserVars,
tokenMethods: {
findToken,
updateToken,
createToken,
deleteTokens,
},
returnOnOAuth: true,
oauthStart: async (authURL) => {
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
oauthUrl = authURL;
oauthRequired = true;
},
userMCPAuthMap = await getUserMCPAuthMap({
userId: user.id,
servers: [serverName],
findPluginAuthsByKeys,
});
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
} catch (err) {
logger.info(`[MCP Reinitialize] getUserConnection threw error: ${err.message}`);
logger.info(
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const isOAuthError =
err.message?.includes('OAuth') ||
err.message?.includes('authentication') ||
err.message?.includes('401');
const isOAuthFlowInitiated = err.message === 'OAuth flow initiated - return early';
if (isOAuthError || oauthRequired || isOAuthFlowInitiated) {
logger.info(
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
);
oauthRequired = true;
} else {
logger.error(
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
err,
);
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
}
}
if (userConnection && !oauthRequired) {
const userTools = (await getCachedTools({ userId: user.id })) || {};
const result = await reinitMCPServer({
req,
serverName,
userMCPAuthMap,
});
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete userTools[key];
}
}
const tools = await userConnection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
userTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema,
},
};
}
await setCachedTools(userTools, { userId: user.id });
if (!result) {
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
}
logger.debug(
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const getResponseMessage = () => {
if (oauthRequired) {
return `MCP server '${serverName}' ready for OAuth authentication`;
}
if (userConnection) {
return `MCP server '${serverName}' reinitialized successfully`;
}
return `Failed to reinitialize MCP server '${serverName}'`;
};
const { success, message, oauthRequired, oauthUrl } = result;
res.json({
success: Boolean((userConnection && !oauthRequired) || (oauthRequired && oauthUrl)),
message: getResponseMessage(),
success,
message,
oauthUrl,
serverName,
oauthRequired,
oauthUrl,
});
} catch (error) {
logger.error('[MCP Reinitialize] Unexpected error', error);

View File

@@ -26,7 +26,7 @@ const ToolCacheKeys = {
* @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
* @returns {Promise<LCAvailableTools|null>} The available tools object or null if not cached
*/
async function getCachedTools(options = {}) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
@@ -41,13 +41,13 @@ async function getCachedTools(options = {}) {
// 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
/** @type {LCAvailableTools | null} 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
/** @type {LCAvailableTools | null} Otherwise, compute from individual sources */
const toolSources = [];
if (includeGlobal) {

View File

@@ -1,5 +1,4 @@
const { logger } = require('@librechat/data-schemas');
const { isEnabled, getUserMCPAuthMap } = require('@librechat/api');
const { isEnabled } = require('@librechat/api');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
const { normalizeEndpointName } = require('~/server/utils');
const loadCustomConfig = require('./loadCustomConfig');
@@ -53,31 +52,6 @@ const getCustomEndpointConfig = async (endpoint) => {
);
};
/**
* @param {Object} params
* @param {string} params.userId
* @param {GenericTool[]} [params.tools]
* @param {import('@librechat/data-schemas').PluginAuthMethods['findPluginAuthsByKeys']} params.findPluginAuthsByKeys
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
*/
async function getMCPAuthMap({ userId, tools, findPluginAuthsByKeys }) {
try {
if (!tools || tools.length === 0) {
return;
}
return await getUserMCPAuthMap({
tools,
userId,
findPluginAuthsByKeys,
});
} catch (err) {
logger.error(
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
err,
);
}
}
/**
* @returns {Promise<boolean>}
*/
@@ -88,7 +62,6 @@ async function hasCustomUserVars() {
}
module.exports = {
getMCPAuthMap,
getCustomConfig,
getBalanceConfig,
hasCustomUserVars,

View File

@@ -1,6 +1,7 @@
const { config } = require('./EndpointService');
const getCachedTools = require('./getCachedTools');
const getCustomConfig = require('./getCustomConfig');
const mcpToolsCache = require('./mcpToolsCache');
const loadCustomConfig = require('./loadCustomConfig');
const loadConfigModels = require('./loadConfigModels');
const loadDefaultModels = require('./loadDefaultModels');
@@ -17,5 +18,6 @@ module.exports = {
loadAsyncEndpoints,
...getCachedTools,
...getCustomConfig,
...mcpToolsCache,
...getEndpointsConfig,
};

View File

@@ -76,10 +76,11 @@ async function loadConfigModels(req) {
fetchPromisesMap[uniqueKey] =
fetchPromisesMap[uniqueKey] ||
fetchModels({
user: req.user.id,
baseURL: BASE_URL,
apiKey: API_KEY,
name,
apiKey: API_KEY,
baseURL: BASE_URL,
user: req.user.id,
direct: endpoint.directEndpoint,
userIdQuery: models.userIdQuery,
});
uniqueKeyToEndpointsMap[uniqueKey] = uniqueKeyToEndpointsMap[uniqueKey] || [];

View File

@@ -0,0 +1,143 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { getCachedTools, setCachedTools } = require('./getCachedTools');
const { getLogStores } = require('~/cache');
/**
* Updates MCP tools in the cache for a specific server and user
* @param {Object} params - Parameters for updating MCP tools
* @param {string} params.userId - User ID
* @param {string} params.serverName - MCP server name
* @param {Array} params.tools - Array of tool objects from MCP server
* @returns {Promise<LCAvailableTools>}
*/
async function updateMCPUserTools({ userId, serverName, tools }) {
try {
const userTools = await getCachedTools({ userId });
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete userTools[key];
}
}
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
userTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema,
},
};
}
await setCachedTools(userTools, { userId });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`[MCP Cache] Updated ${tools.length} tools for ${serverName} user ${userId}`);
return userTools;
} catch (error) {
logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error);
throw error;
}
}
/**
* Merges app-level tools with global tools
* @param {import('@librechat/api').LCAvailableTools} appTools
* @returns {Promise<void>}
*/
async function mergeAppTools(appTools) {
try {
const count = Object.keys(appTools).length;
if (!count) {
return;
}
const cachedTools = await getCachedTools({ includeGlobal: true });
const mergedTools = { ...cachedTools, ...appTools };
await setCachedTools(mergedTools, { isGlobal: true });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`Merged ${count} app-level tools`);
} catch (error) {
logger.error('Failed to merge app-level tools:', error);
throw error;
}
}
/**
* Merges user-level tools with global tools
* @param {object} params
* @param {string} params.userId
* @param {Record<string, FunctionTool>} params.cachedUserTools
* @param {import('@librechat/api').LCAvailableTools} params.userTools
* @returns {Promise<void>}
*/
async function mergeUserTools({ userId, cachedUserTools, userTools }) {
try {
if (!userId) {
return;
}
const count = Object.keys(userTools).length;
if (!count) {
return;
}
const cachedTools = cachedUserTools ?? (await getCachedTools({ userId }));
const mergedTools = { ...cachedTools, ...userTools };
await setCachedTools(mergedTools, { userId });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(`Merged ${count} user-level tools`);
} catch (error) {
logger.error('Failed to merge user-level tools:', error);
throw error;
}
}
/**
* Clears all MCP tools for a specific server
* @param {Object} params - Parameters for clearing MCP tools
* @param {string} [params.userId] - User ID (if clearing user-specific tools)
* @param {string} params.serverName - MCP server name
* @returns {Promise<void>}
*/
async function clearMCPServerTools({ userId, serverName }) {
try {
const tools = await getCachedTools({ userId, includeGlobal: !userId });
// Remove all tools for this server
const mcpDelimiter = Constants.mcp_delimiter;
let removedCount = 0;
for (const key of Object.keys(tools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete tools[key];
removedCount++;
}
}
if (removedCount > 0) {
await setCachedTools(tools, userId ? { userId } : { isGlobal: true });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug(
`[MCP Cache] Removed ${removedCount} tools for ${serverName}${userId ? ` user ${userId}` : ' (global)'}`,
);
}
} catch (error) {
logger.error(`[MCP Cache] Failed to clear tools for ${serverName}:`, error);
throw error;
}
}
module.exports = {
mergeAppTools,
mergeUserTools,
updateMCPUserTools,
clearMCPServerTools,
};

View File

@@ -30,7 +30,13 @@ const { getModelMaxTokens } = require('~/utils');
* @param {TEndpointOption} [params.endpointOption]
* @param {Set<string>} [params.allowedProviders]
* @param {boolean} [params.isInitialAgent]
* @returns {Promise<Agent & { tools: StructuredTool[], attachments: Array<MongoFile>, toolContextMap: Record<string, unknown>, maxContextTokens: number }>}
* @returns {Promise<Agent & {
* tools: StructuredTool[],
* attachments: Array<MongoFile>,
* toolContextMap: Record<string, unknown>,
* maxContextTokens: number,
* userMCPAuthMap?: Record<string, Record<string, string>>
* }>}
*/
const initializeAgent = async ({
req,
@@ -91,16 +97,19 @@ const initializeAgent = async ({
});
const provider = agent.provider;
const { tools: structuredTools, toolContextMap } =
(await loadTools?.({
req,
res,
provider,
agentId: agent.id,
tools: agent.tools,
model: agent.model,
tool_resources,
})) ?? {};
const {
tools: structuredTools,
toolContextMap,
userMCPAuthMap,
} = (await loadTools?.({
req,
res,
provider,
agentId: agent.id,
tools: agent.tools,
model: agent.model,
tool_resources,
})) ?? {};
agent.endpoint = provider;
const { getOptions, overrideProvider } = await getProviderConfig(provider);
@@ -189,6 +198,7 @@ const initializeAgent = async ({
tools,
attachments,
resendFiles,
userMCPAuthMap,
toolContextMap,
useLegacyContent: !!options.useLegacyContent,
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),

View File

@@ -19,7 +19,10 @@ const AgentClient = require('~/server/controllers/agents/client');
const { getAgent } = require('~/models/Agent');
const { logViolation } = require('~/cache');
function createToolLoader() {
/**
* @param {AbortSignal} signal
*/
function createToolLoader(signal) {
/**
* @param {object} params
* @param {ServerRequest} params.req
@@ -29,7 +32,11 @@ function createToolLoader() {
* @param {string} params.provider
* @param {string} params.model
* @param {AgentToolResources} params.tool_resources
* @returns {Promise<{ tools: StructuredTool[], toolContextMap: Record<string, unknown> } | undefined>}
* @returns {Promise<{
* tools: StructuredTool[],
* toolContextMap: Record<string, unknown>,
* userMCPAuthMap?: Record<string, Record<string, string>>
* } | undefined>}
*/
return async function loadTools({ req, res, agentId, tools, provider, model, tool_resources }) {
const agent = { id: agentId, tools, provider, model };
@@ -38,6 +45,7 @@ function createToolLoader() {
req,
res,
agent,
signal,
tool_resources,
});
} catch (error) {
@@ -46,7 +54,7 @@ function createToolLoader() {
};
}
const initializeClient = async ({ req, res, endpointOption }) => {
const initializeClient = async ({ req, res, signal, endpointOption }) => {
if (!endpointOption) {
throw new Error('Endpoint option not provided');
}
@@ -92,7 +100,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
/** @type {Set<string>} */
const allowedProviders = new Set(req?.app?.locals?.[EModelEndpoint.agents]?.allowedProviders);
const loadTools = createToolLoader();
const loadTools = createToolLoader(signal);
/** @type {Array<MongoFile>} */
const requestFiles = req.body.files ?? [];
/** @type {string} */
@@ -111,6 +119,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
});
const agent_ids = primaryConfig.agent_ids;
let userMCPAuthMap = primaryConfig.userMCPAuthMap;
if (agent_ids?.length) {
for (const agentId of agent_ids) {
const agent = await getAgent({ id: agentId });
@@ -140,6 +149,11 @@ const initializeClient = async ({ req, res, endpointOption }) => {
endpointOption,
allowedProviders,
});
if (userMCPAuthMap != null) {
Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {});
} else {
userMCPAuthMap = config.userMCPAuthMap;
}
agentConfigs.set(agentId, config);
}
}
@@ -188,7 +202,7 @@ const initializeClient = async ({ req, res, endpointOption }) => {
: EModelEndpoint.agents,
});
return { client };
return { client, userMCPAuthMap };
};
module.exports = { initializeClient };

View File

@@ -1,8 +1,8 @@
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
const getLogStores = require('~/cache/getLogStores');
const { isEnabled } = require('~/server/utils');
const { saveConvo } = require('~/models');
const { logger } = require('~/config');
/**
* Add title to conversation in a way that avoids memory retention

View File

@@ -39,8 +39,9 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
if (optionsOnly) {
clientOptions = Object.assign(
{
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
proxy: PROXY ?? null,
userId: req.user.id,
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
modelOptions: endpointOption?.model_parameters ?? {},
},
clientOptions,

View File

@@ -15,6 +15,7 @@ const { checkPromptCacheSupport, getClaudeHeaders, configureReasoning } = requir
* @param {number} [options.modelOptions.topK] - Controls the number of top tokens to consider.
* @param {string[]} [options.modelOptions.stop] - Sequences where the API will stop generating further tokens.
* @param {boolean} [options.modelOptions.stream] - Whether to stream the response.
* @param {string} options.userId - The user ID for tracking and personalization.
* @param {string} [options.proxy] - Proxy server URL.
* @param {string} [options.reverseProxyUrl] - URL for a reverse proxy, if used.
*
@@ -47,6 +48,11 @@ function getLLMConfig(apiKey, options = {}) {
maxTokens:
mergedOptions.maxOutputTokens || anthropicSettings.maxOutputTokens.reset(mergedOptions.model),
clientOptions: {},
invocationKwargs: {
metadata: {
user_id: options.userId,
},
},
};
requestOptions = configureReasoning(requestOptions, systemOptions);

View File

@@ -1,7 +1,12 @@
const { z } = require('zod');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { Constants: AgentConstants, Providers, GraphEvents } = require('@librechat/agents');
const {
Providers,
StepTypes,
GraphEvents,
Constants: AgentConstants,
} = require('@librechat/agents');
const {
sendEvent,
MCPOAuthHandler,
@@ -11,14 +16,14 @@ const {
const {
Time,
CacheKeys,
StepTypes,
Constants,
ContentTypes,
isAssistantsEndpoint,
} = require('librechat-data-provider');
const { getCachedTools, loadCustomConfig } = require('./Config');
const { findToken, createToken, updateToken } = require('~/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { getCachedTools, loadCustomConfig } = require('./Config');
const { reinitMCPServer } = require('./Tools/mcp');
const { getLogStores } = require('~/cache');
/**
@@ -26,16 +31,13 @@ const { getLogStores } = require('~/cache');
* @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 }) {
function createRunStepDeltaEmitter({ res, stepId, toolCall }) {
/**
* 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.
* @returns {void}
*/
return async function (authURL) {
return function (authURL) {
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
@@ -46,17 +48,54 @@ function createOAuthStart({ res, stepId, toolCall, loginFlowId, flowManager, sig
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;
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
};
}
/**
* @param {object} params
* @param {ServerResponse} params.res - The Express response object for sending events.
* @param {string} params.runId - The Run ID, i.e. message ID
* @param {string} params.stepId - The ID of the step in the flow.
* @param {ToolCallChunk} params.toolCall - The tool call object containing tool information.
* @param {number} [params.index]
*/
function createRunStepEmitter({ res, runId, stepId, toolCall, index }) {
return function () {
/** @type {import('@librechat/agents').RunStep} */
const data = {
runId: runId ?? Constants.USE_PRELIM_RESPONSE_MESSAGE_ID,
id: stepId,
type: StepTypes.TOOL_CALLS,
index: index ?? 0,
stepDetails: {
type: StepTypes.TOOL_CALLS,
tool_calls: [toolCall],
},
signal,
);
};
sendEvent(res, { event: GraphEvents.ON_RUN_STEP, data });
};
}
/**
* Creates a function used to ensure the flow handler is only invoked once
* @param {object} params
* @param {string} params.flowId - The ID of the login flow.
* @param {FlowStateManager<any>} params.flowManager - The flow manager instance.
* @param {(authURL: string) => void} [params.callback]
*/
function createOAuthStart({ flowId, flowManager, callback }) {
/**
* 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) {
await flowManager.createFlowWithHandler(flowId, 'oauth_login', async () => {
callback?.(authURL);
logger.debug('Sent OAuth login request to client');
return true;
});
};
}
@@ -99,23 +138,166 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) {
}
/**
* Creates a general tool for an entire action set.
* @param {Object} params
* @param {() => void} params.runStepEmitter
* @param {(authURL: string) => void} params.runStepDeltaEmitter
* @returns {(authURL: string) => void}
*/
function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) {
return function (authURL) {
runStepEmitter();
runStepDeltaEmitter(authURL);
};
}
/**
* @param {Object} params
* @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.serverName
* @param {AbortSignal} params.signal
* @param {string} params.model
* @param {number} [params.index]
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @returns { Promise<Array<typeof tool | { _call: (toolInput: Object | string) => unknown}>> } An object with `_call` method to execute the tool input.
*/
async function reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap }) {
const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
const flowId = `${req.user?.id}:${serverName}:${Date.now()}`;
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const stepId = 'step_oauth_login_' + serverName;
const toolCall = {
id: flowId,
name: serverName,
type: 'tool_call_chunk',
};
const runStepEmitter = createRunStepEmitter({
res,
index,
runId,
stepId,
toolCall,
});
const runStepDeltaEmitter = createRunStepDeltaEmitter({
res,
stepId,
toolCall,
});
const callback = createOAuthCallback({ runStepEmitter, runStepDeltaEmitter });
const oauthStart = createOAuthStart({
res,
flowId,
callback,
flowManager,
});
return await reinitMCPServer({
req,
signal,
serverName,
oauthStart,
flowManager,
userMCPAuthMap,
forceNew: true,
returnOnOAuth: false,
connectionTimeout: Time.TWO_MINUTES,
});
}
/**
* Creates all tools from the specified MCP Server via `toolKey`.
*
* @param {Object} params - The parameters for loading action sets.
* This function assumes tools could not be aggregated from the cache of tool definitions,
* i.e. `availableTools`, and will reinitialize the MCP server to ensure all tools are generated.
*
* @param {Object} params
* @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.serverName
* @param {string} params.model
* @param {Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {number} [params.index]
* @param {AbortSignal} [params.signal]
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @returns { Promise<Array<typeof tool | { _call: (toolInput: Object | string) => unknown}>> } An object with `_call` method to execute the tool input.
*/
async function createMCPTools({ req, res, index, signal, serverName, provider, userMCPAuthMap }) {
const result = await reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap });
if (!result || !result.tools) {
logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`);
return;
}
const serverTools = [];
for (const tool of result.tools) {
const toolInstance = await createMCPTool({
req,
res,
provider,
userMCPAuthMap,
availableTools: result.availableTools,
toolKey: `${tool.name}${Constants.mcp_delimiter}${serverName}`,
});
if (toolInstance) {
serverTools.push(toolInstance);
}
}
return serverTools;
}
/**
* Creates a single tool from the specified MCP Server via `toolKey`.
* @param {Object} params
* @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 {import('@librechat/agents').Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {string} params.model - The model for the tool.
* @param {number} [params.index]
* @param {AbortSignal} [params.signal]
* @param {Providers | EModelEndpoint} params.provider - The provider for the tool.
* @param {LCAvailableTools} [params.availableTools]
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/
async function createMCPTool({ req, res, toolKey, provider: _provider }) {
const availableTools = await getCachedTools({ userId: req.user?.id, includeGlobal: true });
const toolDefinition = availableTools?.[toolKey]?.function;
async function createMCPTool({
req,
res,
index,
signal,
toolKey,
provider,
userMCPAuthMap,
availableTools: tools,
}) {
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
const availableTools =
tools ?? (await getCachedTools({ userId: req.user?.id, includeGlobal: true }));
/** @type {LCTool | undefined} */
let toolDefinition = availableTools?.[toolKey]?.function;
if (!toolDefinition) {
logger.error(`Tool ${toolKey} not found in available tools`);
return null;
logger.warn(
`[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`,
);
const result = await reconnectServer({ req, res, index, signal, serverName, userMCPAuthMap });
toolDefinition = result?.availableTools?.[toolKey]?.function;
}
if (!toolDefinition) {
logger.warn(`[MCP][${serverName}][${toolName}] Tool definition not found, cannot create tool.`);
return;
}
return createToolInstance({
res,
provider,
toolName,
serverName,
toolDefinition,
});
}
function createToolInstance({ res, toolName, serverName, toolDefinition, provider: _provider }) {
/** @type {LCTool} */
const { description, parameters } = toolDefinition;
const isGoogle = _provider === Providers.VERTEXAI || _provider === Providers.GOOGLE;
@@ -128,16 +310,8 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
schema = z.object({ input: z.string().optional() });
}
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
if (!req.user?.id) {
logger.error(
`[MCP][${serverName}][${toolName}] User ID not found on request. Cannot create tool.`,
);
throw new Error(`User ID not found on request. Cannot create tool for ${toolKey}.`);
}
/** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise<unknown>} */
const _call = async (toolArguments, config) => {
const userId = config?.configurable?.user?.id || config?.configurable?.user_id;
@@ -154,14 +328,16 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
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({
const flowId = `${serverName}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`;
const runStepDeltaEmitter = createRunStepDeltaEmitter({
res,
stepId,
toolCall,
loginFlowId,
});
const oauthStart = createOAuthStart({
flowId,
flowManager,
signal: derivedSignal,
callback: runStepDeltaEmitter,
});
const oauthEnd = createOAuthEnd({
res,
@@ -207,7 +383,7 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
return result;
} catch (error) {
logger.error(
`[MCP][User: ${userId}][${serverName}] Error calling "${toolName}" MCP tool:`,
`[MCP][${serverName}][${toolName}][User: ${userId}] Error calling MCP tool:`,
error,
);
@@ -220,12 +396,12 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
if (isOAuthError) {
throw new Error(
`OAuth authentication required for ${serverName}. Please check the server logs for the authentication URL.`,
`[MCP][${serverName}][${toolName}] OAuth authentication required. Please check the server logs for the authentication URL.`,
);
}
throw new Error(
`"${toolKey}" tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
`[MCP][${serverName}][${toolName}] tool call failed${error?.message ? `: ${error?.message}` : '.'}`,
);
} finally {
// Clean up abort handler to prevent memory leaks
@@ -380,6 +556,7 @@ async function getServerConnectionStatus(
module.exports = {
createMCPTool,
createMCPTools,
getMCPSetupData,
checkOAuthFlowStatus,
getServerConnectionStatus,

View File

@@ -34,6 +34,7 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
* @param {string} params.apiKey - The API key for authentication with the API.
* @param {string} params.baseURL - The base path URL for the API.
* @param {string} [params.name='OpenAI'] - The name of the API; defaults to 'OpenAI'.
* @param {boolean} [params.direct=false] - Whether `directEndpoint` was configured
* @param {boolean} [params.azure=false] - Whether to fetch models from Azure.
* @param {boolean} [params.userIdQuery=false] - Whether to send the user ID as a query parameter.
* @param {boolean} [params.createTokenConfig=true] - Whether to create a token configuration from the API response.
@@ -44,14 +45,16 @@ const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService')
const fetchModels = async ({
user,
apiKey,
baseURL,
baseURL: _baseURL,
name = EModelEndpoint.openAI,
direct,
azure = false,
userIdQuery = false,
createTokenConfig = true,
tokenKey,
}) => {
let models = [];
const baseURL = direct ? extractBaseURL(_baseURL) : _baseURL;
if (!baseURL && !azure) {
return models;

View File

@@ -1,9 +1,9 @@
const fs = require('fs');
const path = require('path');
const { sleep } = require('@librechat/agents');
const { getToolkitKey } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { zodToJsonSchema } = require('zod-to-json-schema');
const { getToolkitKey, getUserMCPAuthMap } = require('@librechat/api');
const { Calculator } = require('@langchain/community/tools/calculator');
const { tool: toolFn, Tool, DynamicStructuredTool } = require('@langchain/core/tools');
const {
@@ -33,12 +33,17 @@ const {
toolkits,
} = require('~/app/clients/tools');
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
const { getEndpointsConfig, getCachedTools } = require('~/server/services/Config');
const {
getEndpointsConfig,
hasCustomUserVars,
getCachedTools,
} = require('~/server/services/Config');
const { createOnSearchResults } = require('~/server/services/Tools/search');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { recordUsage } = require('~/server/services/Threads');
const { loadTools } = require('~/app/clients/tools/util');
const { redactMessage } = require('~/config/parsers');
const { findPluginAuthsByKeys } = require('~/models');
/**
* Loads and formats tools from the specified tool directory.
@@ -469,11 +474,12 @@ async function processRequiredActions(client, requiredActions) {
* @param {Object} params - Run params containing user and request information.
* @param {ServerRequest} params.req - The request object.
* @param {ServerResponse} params.res - The request object.
* @param {AbortSignal} params.signal
* @param {Pick<Agent, 'id' | 'provider' | 'model' | 'tools'} params.agent - The agent to load tools for.
* @param {string | undefined} [params.openAIApiKey] - The OpenAI API key.
* @returns {Promise<{ tools?: StructuredTool[] }>} The agent tools.
* @returns {Promise<{ tools?: StructuredTool[]; userMCPAuthMap?: Record<string, Record<string, string>> }>} The agent tools.
*/
async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey }) {
async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIApiKey }) {
if (!agent.tools || agent.tools.length === 0) {
return {};
} else if (agent.tools && agent.tools.length === 1 && agent.tools[0] === AgentCapabilities.ocr) {
@@ -523,8 +529,20 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
webSearchCallbacks = createOnSearchResults(res);
}
/** @type {Record<string, Record<string, string>>} */
let userMCPAuthMap;
if (await hasCustomUserVars()) {
userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools,
userId: req.user.id,
findPluginAuthsByKeys,
});
}
const { loadedTools, toolContextMap } = await loadTools({
agent,
signal,
userMCPAuthMap,
functions: true,
user: req.user.id,
tools: _agentTools,
@@ -588,6 +606,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
if (!checkCapability(AgentCapabilities.actions)) {
return {
tools: agentTools,
userMCPAuthMap,
toolContextMap,
};
}
@@ -599,6 +618,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
}
return {
tools: agentTools,
userMCPAuthMap,
toolContextMap,
};
}
@@ -707,6 +727,7 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
return {
tools: agentTools,
toolContextMap,
userMCPAuthMap,
};
}

View File

@@ -0,0 +1,142 @@
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { updateMCPUserTools } = require('~/server/services/Config');
const { getLogStores } = require('~/cache');
/**
* @param {Object} params
* @param {ServerRequest} params.req
* @param {string} params.serverName - The name of the MCP server
* @param {boolean} params.returnOnOAuth - Whether to initiate OAuth and return, or wait for OAuth flow to finish
* @param {AbortSignal} [params.signal] - The abort signal to handle cancellation.
* @param {boolean} [params.forceNew]
* @param {number} [params.connectionTimeout]
* @param {FlowStateManager<any>} [params.flowManager]
* @param {(authURL: string) => Promise<boolean>} [params.oauthStart]
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap]
*/
async function reinitMCPServer({
req,
signal,
forceNew,
serverName,
userMCPAuthMap,
connectionTimeout,
returnOnOAuth = true,
oauthStart: _oauthStart,
flowManager: _flowManager,
}) {
/** @type {MCPConnection | null} */
let userConnection = null;
/** @type {LCAvailableTools | null} */
let availableTools = null;
/** @type {ReturnType<MCPConnection['fetchTools']> | null} */
let tools = null;
let oauthRequired = false;
let oauthUrl = null;
try {
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const mcpManager = getMCPManager();
const oauthStart =
_oauthStart ??
(async (authURL) => {
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
oauthUrl = authURL;
oauthRequired = true;
});
try {
userConnection = await mcpManager.getUserConnection({
user: req.user,
signal,
forceNew,
oauthStart,
serverName,
flowManager,
returnOnOAuth,
customUserVars,
connectionTimeout,
tokenMethods: {
findToken,
updateToken,
createToken,
deleteTokens,
},
});
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
} catch (err) {
logger.info(`[MCP Reinitialize] getUserConnection threw error: ${err.message}`);
logger.info(
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const isOAuthError =
err.message?.includes('OAuth') ||
err.message?.includes('authentication') ||
err.message?.includes('401');
const isOAuthFlowInitiated = err.message === 'OAuth flow initiated - return early';
if (isOAuthError || oauthRequired || isOAuthFlowInitiated) {
logger.info(
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
);
oauthRequired = true;
} else {
logger.error(
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
err,
);
}
}
if (userConnection && !oauthRequired) {
tools = await userConnection.fetchTools();
availableTools = await updateMCPUserTools({
userId: req.user.id,
serverName,
tools,
});
}
logger.debug(
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
);
const getResponseMessage = () => {
if (oauthRequired) {
return `MCP server '${serverName}' ready for OAuth authentication`;
}
if (userConnection) {
return `MCP server '${serverName}' reinitialized successfully`;
}
return `Failed to reinitialize MCP server '${serverName}'`;
};
const result = {
availableTools,
success: Boolean((userConnection && !oauthRequired) || (oauthRequired && oauthUrl)),
message: getResponseMessage(),
oauthRequired,
serverName,
oauthUrl,
tools,
};
logger.debug(`[MCP Reinitialize] Response for ${serverName}:`, result);
return result;
} catch (error) {
logger.error(
'[MCP Reinitialize] Error loading MCP Tools, servers may still be initializing:',
error,
);
}
}
module.exports = {
reinitMCPServer,
};

View File

@@ -1,8 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { getCachedTools, setCachedTools } = require('./Config');
const { CacheKeys } = require('librechat-data-provider');
const { createMCPManager } = require('~/config');
const { getLogStores } = require('~/cache');
const { mergeAppTools } = require('./Config');
/**
* Initialize MCP servers
@@ -18,21 +16,12 @@ async function initializeMCPs(app) {
try {
delete app.locals.mcpConfig;
const cachedTools = await getCachedTools();
const mcpTools = mcpManager.getAppToolFunctions() || {};
await mergeAppTools(mcpTools);
if (!cachedTools) {
logger.warn('No available tools found in cache during MCP initialization');
return;
}
const mcpTools = mcpManager.getAppToolFunctions() ?? {};
await setCachedTools({ ...cachedTools, ...mcpTools }, { isGlobal: true });
const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS);
logger.debug('Cleared tools array cache after MCP initialization');
logger.info('MCP servers initialized successfully');
logger.info(
`MCP servers initialized successfully. Added ${Object.keys(mcpTools).length} MCP tools.`,
);
} catch (error) {
logger.error('Failed to initialize MCP servers:', error);
}

View File

@@ -138,9 +138,6 @@ async function loadDefaultInterface(config, configDefaults) {
defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.USE],
defaults.prompts,
),
[Permissions.SHARED_GLOBAL]:
defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.SHARED_GLOBAL],
[Permissions.CREATE]: defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.CREATE],
},
[PermissionTypes.BOOKMARKS]: {
[Permissions.USE]: getPermissionValue(
@@ -155,9 +152,6 @@ async function loadDefaultInterface(config, configDefaults) {
defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.USE],
defaults.memories,
),
[Permissions.CREATE]: defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.CREATE],
[Permissions.UPDATE]: defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.UPDATE],
[Permissions.READ]: defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.READ],
[Permissions.OPT_OUT]: isPersonalizationEnabled,
},
[PermissionTypes.MULTI_CONVO]: {
@@ -173,9 +167,6 @@ async function loadDefaultInterface(config, configDefaults) {
defaultPerms[PermissionTypes.AGENTS]?.[Permissions.USE],
defaults.agents,
),
[Permissions.SHARED_GLOBAL]:
defaultPerms[PermissionTypes.AGENTS]?.[Permissions.SHARED_GLOBAL],
[Permissions.CREATE]: defaultPerms[PermissionTypes.AGENTS]?.[Permissions.CREATE],
},
[PermissionTypes.TEMPORARY_CHAT]: {
[Permissions.USE]: getPermissionValue(

View File

@@ -54,22 +54,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -87,22 +80,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -164,22 +150,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: false,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
@@ -197,22 +176,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: false,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: false,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
@@ -274,22 +246,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -307,22 +272,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -397,22 +355,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
@@ -430,22 +381,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
@@ -507,22 +451,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -540,22 +477,15 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
@@ -627,9 +557,6 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
@@ -650,9 +577,6 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
@@ -738,15 +662,10 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForUser = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
}, // Explicitly configured
// All other permissions that don't exist in the database
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
@@ -766,15 +685,10 @@ describe('loadDefaultInterface', () => {
const expectedPermissionsForAdmin = {
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
}, // Explicitly configured
// All other permissions that don't exist in the database
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
@@ -888,43 +802,29 @@ describe('loadDefaultInterface', () => {
// Check PROMPTS permissions use role defaults
expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
});
expect(adminCall[1][PermissionTypes.PROMPTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
});
// Check AGENTS permissions use role defaults
expect(userCall[1][PermissionTypes.AGENTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: false,
[Permissions.CREATE]: true,
});
expect(adminCall[1][PermissionTypes.AGENTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]: true,
[Permissions.CREATE]: true,
});
// Check MEMORIES permissions use role defaults
expect(userCall[1][PermissionTypes.MEMORIES]).toEqual({
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
});
expect(adminCall[1][PermissionTypes.MEMORIES]).toEqual({
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: undefined,
});
});
@@ -1164,12 +1064,6 @@ describe('loadDefaultInterface', () => {
// Explicitly configured permissions should be updated
expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({
[Permissions.USE]: true,
[Permissions.SHARED_GLOBAL]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS][
Permissions.SHARED_GLOBAL
],
[Permissions.CREATE]:
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.PROMPTS][Permissions.CREATE],
});
expect(userCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true });
expect(userCall[1][PermissionTypes.MARKETPLACE]).toEqual({ [Permissions.USE]: true });

View File

@@ -1,3 +1,4 @@
const mongoose = require('mongoose');
const { logger } = require('@librechat/data-schemas');
const {
logAgentMigrationWarning,
@@ -16,7 +17,8 @@ const { findRoleByIdentifier } = require('~/models');
async function checkMigrations() {
try {
const agentMigrationResult = await checkAgentPermissionsMigration({
db: {
mongoose,
methods: {
findRoleByIdentifier,
getProjectByName,
},
@@ -28,7 +30,8 @@ async function checkMigrations() {
}
try {
const promptMigrationResult = await checkPromptPermissionsMigration({
db: {
mongoose,
methods: {
findRoleByIdentifier,
getProjectByName,
},

View File

@@ -1115,6 +1115,18 @@
* @memberof typedefs
*/
/**
* @exports MCPConnection
* @typedef {import('@librechat/api').MCPConnection} MCPConnection
* @memberof typedefs
*/
/**
* @exports LCFunctionTool
* @typedef {import('@librechat/api').LCFunctionTool} LCFunctionTool
* @memberof typedefs
*/
/**
* @exports FlowStateManager
* @typedef {import('@librechat/api').FlowStateManager} FlowStateManager
@@ -1825,6 +1837,7 @@
* @param {object} opts - Options for the completion
* @param {onTokenProgress} opts.onProgress - Callback function to handle token progress
* @param {AbortController} opts.abortController - AbortController instance
* @param {Record<string, Record<string, string>>} [opts.userMCPAuthMap]
* @returns {Promise<string>}
* @memberof typedefs
*/

View File

@@ -8,7 +8,6 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="LibreChat - An open source chat application with support for multiple AI models" />
<title>LibreChat</title>
<link rel="shortcut icon" href="#" />
<link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/assets/apple-touch-icon-180x180.png" />

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.8.0-rc2",
"version": "v0.8.0-rc3",
"description": "",
"type": "module",
"scripts": {

View File

@@ -225,7 +225,8 @@ export type AgentPanelContextType = {
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
agent_id?: string;
agentsConfig?: t.TAgentsEndpoint;
agentsConfig?: t.TAgentsEndpoint | null;
endpointsConfig?: t.TEndpointsConfig | null;
};
export type AgentModelPanelProps = {
@@ -336,6 +337,7 @@ export type TAskProps = {
export type TOptions = {
editedMessageId?: string | null;
editedContent?: t.TEditedContent;
editedText?: string | null;
isRegenerate?: boolean;
isContinued?: boolean;

View File

@@ -1,8 +1,8 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Label } from '@librechat/client';
import type t from 'librechat-data-provider';
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
import { useLocalize } from '~/hooks';
interface AgentCardProps {
agent: t.Agent; // The agent data to display
@@ -15,6 +15,21 @@ interface AgentCardProps {
*/
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
const localize = useLocalize();
const { categories } = useAgentCategories();
const categoryLabel = useMemo(() => {
if (!agent.category) return '';
const category = categories.find((cat) => cat.value === agent.category);
if (category) {
if (category.label && category.label.startsWith('com_')) {
return localize(category.label as TranslationKeys);
}
return category.label;
}
return agent.category.charAt(0).toUpperCase() + agent.category.slice(1);
}, [agent.category, categories, localize]);
return (
<div
@@ -49,9 +64,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
{/* Category tag */}
{agent.category && (
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
<Label className="line-clamp-1 font-normal">
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
</Label>
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
</div>
)}
</div>

View File

@@ -1,62 +0,0 @@
import React from 'react';
import { useAgentCategories } from '~/hooks/Agents';
import { cn } from '~/utils';
interface AgentCategoryDisplayProps {
category?: string;
className?: string;
showIcon?: boolean;
iconClassName?: string;
showEmptyFallback?: boolean;
}
/**
* Component to display an agent category with proper translation
*
* @param category - The category value (e.g., "general", "hr", etc.)
* @param className - Optional className for the container
* @param showIcon - Whether to show the category icon
* @param iconClassName - Optional className for the icon
* @param showEmptyFallback - Whether to show a fallback for empty categories
*/
const AgentCategoryDisplay: React.FC<AgentCategoryDisplayProps> = ({
category,
className = '',
showIcon = true,
iconClassName = 'h-4 w-4 mr-2',
showEmptyFallback = false,
}) => {
const { categories, emptyCategory } = useAgentCategories();
// Find the category in our processed categories list
const categoryItem = categories.find((c) => c.value === category);
// Handle empty string case differently than undefined/null
if (category === '') {
if (!showEmptyFallback) {
return null;
}
// Show the empty category placeholder
return (
<div className={cn('flex items-center text-gray-400', className)}>
<span>{emptyCategory.label}</span>
</div>
);
}
// No category or unknown category
if (!category || !categoryItem) {
return null;
}
return (
<div className={cn('flex items-center', className)}>
{showIcon && categoryItem.icon && (
<span className={cn('flex-shrink-0', iconClassName)}>{categoryItem.icon}</span>
)}
<span>{categoryItem.label}</span>
</div>
);
};
export default AgentCategoryDisplay;

View File

@@ -1,8 +1,8 @@
import React from 'react';
import type t from 'librechat-data-provider';
import { useMediaQuery } from '@librechat/client';
import type t from 'librechat-data-provider';
import { useLocalize, TranslationKeys } from '~/hooks';
import { SmartLoader } from './SmartLoader';
import { useLocalize } from '~/hooks/';
import { cn } from '~/utils';
/**
@@ -36,14 +36,17 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
// Helper function to get category display name from database data
/** Helper function to get category display name from database data */
const getCategoryDisplayName = (category: t.TCategory) => {
// Special cases for system categories
if (category.value === 'promoted') {
return localize('com_agents_top_picks');
}
if (category.value === 'all') {
return 'All';
return localize('com_agents_all_category');
}
if (category.label && category.label.startsWith('com_')) {
return localize(category.label as TranslationKeys);
}
// Use database label or fallback to capitalized value
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
@@ -158,7 +161,11 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
aria-selected={activeTab === category.value}
aria-controls={`tabpanel-${category.value}`}
tabIndex={activeTab === category.value ? 0 : -1}
aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`}
aria-label={localize('com_agents_category_tab_label', {
category: getCategoryDisplayName(category),
position: index + 1,
total: categories.length,
})}
>
{getCategoryDisplayName(category)}
{/* Underline for active tab */}

View File

@@ -7,8 +7,8 @@ import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/cl
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import { useDocumentTitle, useHasAccess, useLocalize } from '~/hooks';
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelProvider, useChatContext } from '~/Providers';
import { MarketplaceProvider } from './MarketplaceContext';
@@ -381,8 +381,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}
if (displayCategory === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
@@ -392,8 +392,12 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(categoryData.description as TranslationKeys)
: categoryData.description || '',
};
}
@@ -455,8 +459,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}
if (nextCategory === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
@@ -466,8 +470,16 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(
categoryData.description as Parameters<
typeof localize
>[0],
)
: categoryData.description || '',
};
}

View File

@@ -61,6 +61,7 @@ const mockLocalize = jest.fn((key: string, options?: any) => {
com_agents_search_empty_heading: 'No search results',
com_agents_created_by: 'by',
com_agents_top_picks: 'Top Picks',
com_agents_all_category: 'All',
// ErrorDisplay translations
com_agents_error_suggestion_generic: 'Try refreshing the page or check your network connection',
com_agents_error_network_title: 'Network Error',
@@ -199,7 +200,7 @@ describe('Accessibility Improvements', () => {
/>,
);
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
const promotedTab = screen.getByRole('tab', { name: /Top Picks category/ });
// Test arrow key navigation
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
@@ -226,8 +227,8 @@ describe('Accessibility Improvements', () => {
/>,
);
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
const allTab = screen.getByRole('tab', { name: /All tab/ });
const promotedTab = screen.getByRole('tab', { name: /Top Picks category/ });
const allTab = screen.getByRole('tab', { name: /All category/ });
// Active tab should be focusable
expect(promotedTab).toHaveAttribute('tabIndex', '0');

View File

@@ -8,10 +8,42 @@ import type t from 'librechat-data-provider';
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
};
return mockTranslations[key] || key;
});
// Mock useAgentCategories hook
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: Record<string, string>) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
};
let translation = mockTranslations[key] || key;
// Replace placeholders with actual values
if (values) {
Object.entries(values).forEach(([placeholder, value]) => {
translation = translation.replace(new RegExp(`{{${placeholder}}}`, 'g'), value);
});
}
return translation;
},
useAgentCategories: () => ({
categories: [
{ value: 'general', label: 'com_agents_category_general' },
{ value: 'hr', label: 'com_agents_category_hr' },
{ value: 'custom', label: 'Custom Category' }, // Non-localized custom category
],
}),
}));
describe('AgentCard', () => {
const mockAgent: t.Agent = {
id: '1',
@@ -200,6 +232,49 @@ describe('AgentCard', () => {
const card = screen.getByRole('button');
expect(card).toHaveAttribute('tabIndex', '0');
expect(card).toHaveAttribute('aria-label', 'com_agents_agent_card_label');
expect(card).toHaveAttribute(
'aria-label',
'Test Agent agent. A test agent for testing purposes',
);
});
it('displays localized category label', () => {
const agentWithCategory = {
...mockAgent,
category: 'general',
};
render(<AgentCard agent={agentWithCategory} onClick={mockOnClick} />);
expect(screen.getByText('General')).toBeInTheDocument();
});
it('displays custom category label', () => {
const agentWithCustomCategory = {
...mockAgent,
category: 'custom',
};
render(<AgentCard agent={agentWithCustomCategory} onClick={mockOnClick} />);
expect(screen.getByText('Custom Category')).toBeInTheDocument();
});
it('displays capitalized fallback for unknown category', () => {
const agentWithUnknownCategory = {
...mockAgent,
category: 'unknown',
};
render(<AgentCard agent={agentWithUnknownCategory} onClick={mockOnClick} />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('does not display category tag when category is not provided', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
expect(screen.queryByText('General')).not.toBeInTheDocument();
expect(screen.queryByText('Unknown')).not.toBeInTheDocument();
});
});

View File

@@ -1,90 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import AgentCategoryDisplay from '../AgentCategoryDisplay';
// Mock the useAgentCategories hook
jest.mock('~/hooks/Agents', () => ({
useAgentCategories: () => ({
categories: [
{
value: 'general',
label: 'General',
icon: <span data-testid="icon-general">{''}</span>,
className: 'w-full',
},
{
value: 'hr',
label: 'HR',
icon: <span data-testid="icon-hr">{''}</span>,
className: 'w-full',
},
{
value: 'rd',
label: 'R&D',
icon: <span data-testid="icon-rd">{''}</span>,
className: 'w-full',
},
{
value: 'finance',
label: 'Finance',
icon: <span data-testid="icon-finance">{''}</span>,
className: 'w-full',
},
],
emptyCategory: {
value: '',
label: 'General',
className: 'w-full',
},
}),
}));
describe('AgentCategoryDisplay', () => {
it('should display the proper label for a category', () => {
render(<AgentCategoryDisplay category="rd" />);
expect(screen.getByText('R&D')).toBeInTheDocument();
});
it('should display the icon when showIcon is true', () => {
render(<AgentCategoryDisplay category="finance" showIcon={true} />);
expect(screen.getByTestId('icon-finance')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
});
it('should not display the icon when showIcon is false', () => {
render(<AgentCategoryDisplay category="hr" showIcon={false} />);
expect(screen.queryByTestId('icon-hr')).not.toBeInTheDocument();
expect(screen.getByText('HR')).toBeInTheDocument();
});
it('should apply custom classnames', () => {
render(<AgentCategoryDisplay category="general" className="test-class" />);
expect(screen.getByText('General').parentElement).toHaveClass('test-class');
});
it('should not render anything for unknown categories', () => {
const { container } = render(<AgentCategoryDisplay category="unknown" />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything when no category is provided', () => {
const { container } = render(<AgentCategoryDisplay />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything for empty category when showEmptyFallback is false', () => {
const { container } = render(<AgentCategoryDisplay category="" />);
expect(container).toBeEmptyDOMElement();
});
it('should render empty category placeholder when showEmptyFallback is true', () => {
render(<AgentCategoryDisplay category="" showEmptyFallback={true} />);
expect(screen.getByText('General')).toBeInTheDocument();
});
it('should apply custom iconClassName to the icon', () => {
render(<AgentCategoryDisplay category="general" iconClassName="custom-icon-class" />);
const iconElement = screen.getByTestId('icon-general').parentElement;
expect(iconElement).toHaveClass('custom-icon-class');
});
});

View File

@@ -9,7 +9,8 @@ import type t from 'librechat-data-provider';
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks',
com_agents_all: 'All',
com_agents_all: 'All Agents',
com_agents_all_category: 'All',
com_ui_no_categories: 'No categories available',
com_agents_category_tabs_label: 'Agent Categories',
com_ui_agent_category_general: 'General',

View File

@@ -89,7 +89,7 @@ const BookmarkEditDialog = ({
<OGDialog open={open} onOpenChange={setOpen} triggerRef={triggerRef}>
{children}
<OGDialogTemplate
title="Bookmark"
title={bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')}
showCloseButton={false}
className="w-11/12 md:max-w-2xl"
main={

View File

@@ -38,6 +38,8 @@ const BookmarkForm = ({
control,
formState: { errors },
} = useForm<TConversationTagRequest>({
mode: 'onBlur',
reValidateMode: 'onChange',
defaultValues: {
tag: bookmark?.tag ?? '',
description: bookmark?.description ?? '',
@@ -98,23 +100,30 @@ const BookmarkForm = ({
<Input
type="text"
id="bookmark-tag"
aria-label="Bookmark"
aria-label={
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
}
{...register('tag', {
required: 'tag is required',
required: localize('com_ui_field_required'),
maxLength: {
value: 128,
message: localize('com_auth_password_max_length'),
message: localize('com_ui_field_max_length', {
field: localize('com_ui_bookmarks_title'),
length: 128,
}),
},
validate: (value) => {
return (
value === bookmark?.tag ||
bookmarks.every((bookmark) => bookmark.tag !== value) ||
'tag must be unique'
localize('com_ui_bookmarks_tag_exists')
);
},
})}
aria-invalid={!!errors.tag}
placeholder="Bookmark"
placeholder={
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
}
/>
{errors.tag && <span className="text-sm text-red-500">{errors.tag.message}</span>}
</div>
@@ -127,7 +136,10 @@ const BookmarkForm = ({
{...register('description', {
maxLength: {
value: 1048,
message: 'Maximum 1048 characters',
message: localize('com_ui_field_max_length', {
field: localize('com_ui_bookmarks_description'),
length: 1048,
}),
},
})}
id="bookmark-description"

View File

@@ -94,6 +94,12 @@ const ContentParts = memo(
return null;
}
const isToolCall =
part.type === ContentTypes.TOOL_CALL || part['tool_call_ids'] != null;
if (isToolCall) {
return null;
}
return (
<EditTextPart
index={idx}

View File

@@ -62,17 +62,26 @@ const EditTextPart = ({
const messages = getMessages();
const parentMessage = messages?.find((msg) => msg.messageId === message?.parentMessageId);
const editedContent =
part.type === ContentTypes.THINK
? {
index,
type: ContentTypes.THINK as const,
[ContentTypes.THINK]: data.text,
}
: {
index,
type: ContentTypes.TEXT as const,
[ContentTypes.TEXT]: data.text,
};
if (!parentMessage) {
return;
}
ask(
{ ...parentMessage },
{
editedContent: {
index,
text: data.text,
type: part.type,
},
editedContent,
editedMessageId: messageId,
isRegenerate: true,
isEdited: true,

View File

@@ -1,10 +1,9 @@
import React, { memo, useState } from 'react';
import { UserIcon } from '@librechat/client';
import { UserIcon, useAvatar } from '@librechat/client';
import type { TUser } from 'librechat-data-provider';
import type { IconProps } from '~/common';
import MessageEndpointIcon from './MessageEndpointIcon';
import { useAuthContext } from '~/hooks/AuthContext';
import useAvatar from '~/hooks/Messages/useAvatar';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';

View File

@@ -2,11 +2,10 @@ import { useState, memo } from 'react';
import { useRecoilState } from 'recoil';
import * as Select from '@ariakit/react/select';
import { FileText, LogOut } from 'lucide-react';
import { LinkIcon, GearIcon, DropdownMenuSeparator, UserIcon } from '@librechat/client';
import { LinkIcon, GearIcon, DropdownMenuSeparator, Avatar } from '@librechat/client';
import { useGetStartupConfig, useGetUserBalance } from '~/data-provider';
import FilesView from '~/components/Chat/Input/Files/FilesView';
import { useAuthContext } from '~/hooks/AuthContext';
import useAvatar from '~/hooks/Messages/useAvatar';
import { useLocalize } from '~/hooks';
import Settings from './Settings';
import store from '~/store';
@@ -21,9 +20,6 @@ function AccountSettings() {
const [showSettings, setShowSettings] = useState(false);
const [showFiles, setShowFiles] = useRecoilState(store.showFiles);
const avatarSrc = useAvatar(user);
const avatarSeed = user?.avatar || user?.name || user?.username || '';
return (
<Select.SelectProvider>
<Select.Select
@@ -33,26 +29,7 @@ function AccountSettings() {
>
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex">
{avatarSeed.length === 0 ? (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: '32px',
height: '32px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className="relative flex items-center justify-center rounded-full p-1 text-text-primary"
aria-hidden="true"
>
<UserIcon />
</div>
) : (
<img
className="rounded-full"
src={(user?.avatar ?? '') || avatarSrc}
alt={`${user?.name || user?.username || user?.email || ''}'s avatar`}
/>
)}
<Avatar user={user} size={32} />
</div>
</div>
<div

View File

@@ -77,6 +77,7 @@ export const LangSelector = ({
{ value: 'zh-Hans', label: localize('com_nav_lang_chinese') },
{ value: 'zh-Hant', label: localize('com_nav_lang_traditional_chinese') },
{ value: 'ar-EG', label: localize('com_nav_lang_arabic') },
{ value: 'bs', label: localize('com_nav_lang_bosnian') },
{ value: 'da-DK', label: localize('com_nav_lang_danish') },
{ value: 'de-DE', label: localize('com_nav_lang_german') },
{ value: 'es-ES', label: localize('com_nav_lang_spanish') },
@@ -88,6 +89,7 @@ export const LangSelector = ({
{ value: 'hu-HU', label: localize('com_nav_lang_hungarian') },
{ value: 'hy-AM', label: localize('com_nav_lang_armenian') },
{ value: 'it-IT', label: localize('com_nav_lang_italian') },
{ value: 'nb', label: localize('com_nav_lang_norwegian_bokmal') },
{ value: 'pl-PL', label: localize('com_nav_lang_polish') },
{ value: 'pt-BR', label: localize('com_nav_lang_brazilian_portuguese') },
{ value: 'pt-PT', label: localize('com_nav_lang_portuguese') },

View File

@@ -1,9 +1,12 @@
import React from 'react';
import { useLocalize } from '~/hooks';
export default function EmptyPromptPreview() {
const localize = useLocalize();
return (
<div className="h-full w-full content-center text-center font-bold dark:text-gray-200">
Select or Create a Prompt
<div className="h-full w-full content-center text-center font-bold text-text-secondary">
{localize('com_ui_select_or_create_prompt')}
</div>
);
}

View File

@@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ControlCombobox } from '@librechat/client';
import {
useWatch,
@@ -9,7 +8,7 @@ import {
useFormContext,
ControllerRenderProps,
} from 'react-hook-form';
import { useAgentCategories } from '~/hooks/Agents';
import { TranslationKeys, useLocalize, useAgentCategories } from '~/hooks';
import { cn } from '~/utils';
/**
@@ -35,22 +34,25 @@ const useCategorySync = (agent_id: string | null) => {
* A component for selecting agent categories with form validation
*/
const AgentCategorySelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useTranslation();
const localize = useLocalize();
const formContext = useFormContext();
const { categories } = useAgentCategories();
// Always call useWatch
const agent_id = useWatch({
name: 'id',
control: formContext.control,
});
// Use custom hook for category sync
const { syncCategory } = useCategorySync(agent_id);
const getCategoryLabel = (category: { label: string; value: string }) => {
if (category.label && category.label.startsWith('com_')) {
return localize(category.label as TranslationKeys);
}
return category.label;
};
// Transform categories to the format expected by ControlCombobox
const comboboxItems = categories.map((category) => ({
label: category.label,
label: getCategoryLabel(category),
value: category.value,
}));
@@ -59,8 +61,8 @@ const AgentCategorySelector: React.FC<{ className?: string }> = ({ className })
return categoryItem?.label || comboboxItems.find((c) => c.value === 'general')?.label;
};
const searchPlaceholder = t('com_ui_search_agent_category', 'Search categories...');
const ariaLabel = t('com_ui_agent_category_selector_aria', "Agent's category selector");
const searchPlaceholder = localize('com_ui_search_agent_category');
const ariaLabel = localize('com_ui_agent_category_selector_aria');
return (
<Controller

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { EModelEndpoint, AgentCapabilities } from 'librechat-data-provider';
import type { TAgentsEndpoint, TEndpointsConfig, TConfig } from 'librechat-data-provider';
import type { TAgentsEndpoint, TEndpointsConfig } from 'librechat-data-provider';
import { useGetEndpointsQuery } from '~/data-provider';
interface UseGetAgentsConfigOptions {
@@ -20,11 +20,12 @@ export default function useGetAgentsConfig(options?: UseGetAgentsConfigOptions):
const endpointsConfig = providedConfig || queriedConfig;
const agentsConfig = useMemo<TAgentsEndpoint | null>(() => {
const config = endpointsConfig?.[EModelEndpoint.agents] ?? null;
const config: TAgentsEndpoint | null =
(endpointsConfig?.[EModelEndpoint.agents] as TAgentsEndpoint | null) ?? null;
if (!config) return null;
return {
...(config as TConfig),
...config,
capabilities: Array.isArray(config.capabilities)
? config.capabilities.map((cap) => cap as unknown as AgentCapabilities)
: ([] as AgentCapabilities[]),

View File

@@ -230,15 +230,19 @@ export default function useChatFunctions({
const responseMessageId =
editedMessageId ??
(latestMessage?.messageId && isRegenerate ? latestMessage?.messageId + '_' : null) ??
(latestMessage?.messageId && isRegenerate
? latestMessage.messageId.replace(/_+$/, '') + '_'
: null) ??
null;
const initialResponseId =
responseMessageId ?? `${isRegenerate ? messageId : intermediateId}`.replace(/_+$/, '') + '_';
const initialResponse: TMessage = {
sender: responseSender,
text: '',
endpoint: endpoint ?? '',
parentMessageId: isRegenerate ? messageId : intermediateId,
messageId: responseMessageId ?? `${isRegenerate ? messageId : intermediateId}_`,
messageId: initialResponseId,
thread_id,
conversationId,
unfinished: false,
@@ -267,13 +271,13 @@ export default function useChatFunctions({
if (editedContent && latestMessage?.content) {
initialResponse.content = cloneDeep(latestMessage.content);
const { index, text, type } = editedContent;
const { index, type, ...part } = editedContent;
if (initialResponse.content && index >= 0 && index < initialResponse.content.length) {
const contentPart = initialResponse.content[index];
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
contentPart[ContentTypes.THINK] = text;
contentPart[ContentTypes.THINK] = part[ContentTypes.THINK];
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
contentPart[ContentTypes.TEXT] = text;
contentPart[ContentTypes.TEXT] = part[ContentTypes.TEXT];
}
}
} else {

View File

@@ -3,7 +3,6 @@ import { QueryKeys } from 'librechat-data-provider';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { InfiniteData } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { updateConvoFieldsInfinite } from '~/utils/convos';
const useUpdateTagsInConvo = () => {
const queryClient = useQueryClient();
@@ -53,30 +52,31 @@ const useUpdateTagsInConvo = () => {
QueryKeys.allConversations,
]);
const conversationIdsWithTag = [] as string[];
// update tag to newTag in all conversations
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
const page = newData.pages[pageIndex];
page.conversations = page.conversations.map((conversation) => {
if (
conversation.conversationId &&
'tags' in conversation &&
Array.isArray((conversation as { tags?: string[] }).tags) &&
(conversation as { tags?: string[] }).tags?.includes(tag)
) {
(conversation as { tags: string[] }).tags = (conversation as { tags: string[] }).tags.map(
(t: string) => (t === tag ? newTag : t),
);
}
return conversation;
});
if (data) {
const newData = JSON.parse(JSON.stringify(data)) as InfiniteData<ConversationListResponse>;
for (let pageIndex = 0; pageIndex < newData.pages.length; pageIndex++) {
const page = newData.pages[pageIndex];
page.conversations = page.conversations.map((conversation) => {
if (
conversation.conversationId &&
'tags' in conversation &&
Array.isArray((conversation as { tags?: string[] }).tags) &&
(conversation as { tags?: string[] }).tags?.includes(tag)
) {
(conversation as { tags: string[] }).tags = (
conversation as { tags: string[] }
).tags.map((t: string) => (t === tag ? newTag : t));
}
return conversation;
});
}
queryClient.setQueryData<InfiniteData<ConversationListResponse>>(
[QueryKeys.allConversations],
newData,
);
}
queryClient.setQueryData<InfiniteData<ConversationListResponse>>(
[QueryKeys.allConversations],
newData,
);
const conversationIdsWithTag = [] as string[];
// update the tag to newTag from the cache of each conversation
for (let i = 0; i < conversationIdsWithTag.length; i++) {

View File

@@ -1,4 +1,3 @@
export { default as useAvatar } from './useAvatar';
export { default as useProgress } from './useProgress';
export { default as useAttachments } from './useAttachments';
export { default as useSubmitMessage } from './useSubmitMessage';

View File

@@ -182,7 +182,7 @@ export default function useEventHandlers({
const { token } = useAuthContext();
const contentHandler = useContentHandler({ setMessages, getMessages });
const stepHandler = useStepHandler({
const { stepHandler, clearStepMaps } = useStepHandler({
setMessages,
getMessages,
announcePolite,
@@ -480,12 +480,13 @@ export default function useEventHandlers({
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, id], _messages);
};
/** Handle edge case where stream is cancelled before any response, which creates a blank page */
if (
!conversation.conversationId &&
const hasNoResponse =
responseMessage?.content?.[0]?.['text']?.value ===
submission.initialResponse?.content?.[0]?.['text']?.value
) {
submission.initialResponse?.content?.[0]?.['text']?.value ||
!!responseMessage?.content?.[0]?.['tool_call']?.auth;
/** Handle edge case where stream is cancelled before any response, which creates a blank page */
if (!conversation.conversationId && hasNoResponse) {
const currentConvoId =
(submissionConvo.conversationId ?? conversation.conversationId) || Constants.NEW_CONVO;
if (isNewConvo && submissionConvo.conversationId) {
@@ -806,6 +807,7 @@ export default function useEventHandlers({
);
return {
clearStepMaps,
stepHandler,
syncHandler,
finalHandler,

View File

@@ -62,6 +62,7 @@ export default function useSSE(
} = chatHelpers;
const {
clearStepMaps,
stepHandler,
syncHandler,
finalHandler,
@@ -101,6 +102,7 @@ export default function useSSE(
payload = removeNullishValues(payload) as TPayload;
let textIndex = null;
clearStepMaps();
const sse = new SSE(payloadData.server, {
payload: JSON.stringify(payload),

View File

@@ -1,5 +1,11 @@
import { useCallback, useRef } from 'react';
import { StepTypes, ContentTypes, ToolCallTypes, getNonEmptyValue } from 'librechat-data-provider';
import {
Constants,
StepTypes,
ContentTypes,
ToolCallTypes,
getNonEmptyValue,
} from 'librechat-data-provider';
import type {
Agents,
TMessage,
@@ -178,11 +184,12 @@ export default function useStepHandler({
return { ...message, content: updatedContent as TMessageContentParts[] };
};
return useCallback(
const stepHandler = useCallback(
({ event, data }: TStepEvent, submission: EventSubmission) => {
const messages = getMessages() || [];
const { userMessage } = submission;
setIsSubmitting(true);
let parentMessageId = userMessage.messageId;
const currentTime = Date.now();
if (currentTime - lastAnnouncementTimeRef.current > MESSAGE_UPDATE_INTERVAL) {
@@ -197,7 +204,11 @@ export default function useStepHandler({
if (event === 'on_run_step') {
const runStep = data as Agents.RunStep;
const responseMessageId = runStep.runId ?? '';
let responseMessageId = runStep.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!responseMessageId) {
console.warn('No message id found in run step event');
return;
@@ -211,7 +222,7 @@ export default function useStepHandler({
response = {
...responseMessage,
parentMessageId: userMessage.messageId,
parentMessageId,
conversationId: userMessage.conversationId,
messageId: responseMessageId,
content: initialContent,
@@ -246,14 +257,18 @@ export default function useStepHandler({
messageMap.current.set(responseMessageId, updatedResponse);
const updatedMessages = messages.map((msg) =>
msg.messageId === runStep.runId ? updatedResponse : msg,
msg.messageId === responseMessageId ? updatedResponse : msg,
);
setMessages(updatedMessages);
}
} else if (event === 'on_agent_update') {
const { agent_update } = data as Agents.AgentUpdate;
const responseMessageId = agent_update.runId || '';
let responseMessageId = agent_update.runId || '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!responseMessageId) {
console.warn('No message id found in agent update event');
return;
@@ -271,7 +286,11 @@ export default function useStepHandler({
} else if (event === 'on_message_delta') {
const messageDelta = data as Agents.MessageDeltaEvent;
const runStep = stepMap.current.get(messageDelta.id);
const responseMessageId = runStep?.runId ?? '';
let responseMessageId = runStep?.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!runStep || !responseMessageId) {
console.warn('No run step or runId found for message delta event');
@@ -299,7 +318,11 @@ export default function useStepHandler({
} else if (event === 'on_reasoning_delta') {
const reasoningDelta = data as Agents.ReasoningDeltaEvent;
const runStep = stepMap.current.get(reasoningDelta.id);
const responseMessageId = runStep?.runId ?? '';
let responseMessageId = runStep?.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!runStep || !responseMessageId) {
console.warn('No run step or runId found for reasoning delta event');
@@ -327,7 +350,11 @@ export default function useStepHandler({
} else if (event === 'on_run_step_delta') {
const runStepDelta = data as Agents.RunStepDeltaEvent;
const runStep = stepMap.current.get(runStepDelta.id);
const responseMessageId = runStep?.runId ?? '';
let responseMessageId = runStep?.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!runStep || !responseMessageId) {
console.warn('No run step or runId found for run step delta event');
@@ -366,7 +393,7 @@ export default function useStepHandler({
messageMap.current.set(responseMessageId, updatedResponse);
const updatedMessages = messages.map((msg) =>
msg.messageId === runStep.runId ? updatedResponse : msg,
msg.messageId === responseMessageId ? updatedResponse : msg,
);
setMessages(updatedMessages);
@@ -377,7 +404,11 @@ export default function useStepHandler({
const { id: stepId } = result;
const runStep = stepMap.current.get(stepId);
const responseMessageId = runStep?.runId ?? '';
let responseMessageId = runStep?.runId ?? '';
if (responseMessageId === Constants.USE_PRELIM_RESPONSE_MESSAGE_ID) {
responseMessageId = submission?.initialResponse?.messageId ?? '';
parentMessageId = submission?.initialResponse?.parentMessageId ?? '';
}
if (!runStep || !responseMessageId) {
console.warn('No run step or runId found for completed tool call event');
@@ -399,7 +430,7 @@ export default function useStepHandler({
messageMap.current.set(responseMessageId, updatedResponse);
const updatedMessages = messages.map((msg) =>
msg.messageId === runStep.runId ? updatedResponse : msg,
msg.messageId === responseMessageId ? updatedResponse : msg,
);
setMessages(updatedMessages);
@@ -414,4 +445,11 @@ export default function useStepHandler({
},
[getMessages, setIsSubmitting, lastAnnouncementTimeRef, announcePolite, setMessages],
);
const clearStepMaps = useCallback(() => {
toolCallIdMap.current.clear();
messageMap.current.clear();
stepMap.current.clear();
}, []);
return { stepHandler, clearStepMaps };
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -6,8 +6,24 @@
"com_a11y_start": "The AI has started their reply.",
"com_agents_agent_card_label": "{{name}} agent. {{description}}",
"com_agents_all": "All Agents",
"com_agents_all_category": "All",
"com_agents_all_description": "Browse all shared agents across all categories",
"com_agents_by_librechat": "by LibreChat",
"com_agents_category_aftersales": "After Sales",
"com_agents_category_aftersales_description": "Agents specialized in post-sale support, maintenance, and customer service",
"com_agents_category_empty": "No agents found in the {{category}} category",
"com_agents_category_finance": "Finance",
"com_agents_category_finance_description": "Agents specialized in financial analysis, budgeting, and accounting",
"com_agents_category_general": "General",
"com_agents_category_general_description": "General purpose agents for common tasks and inquiries",
"com_agents_category_hr": "Human Resources",
"com_agents_category_hr_description": "Agents specialized in HR processes, policies, and employee support",
"com_agents_category_it": "IT",
"com_agents_category_it_description": "Agents for IT support, technical troubleshooting, and system administration",
"com_agents_category_rd": "Research & Development",
"com_agents_category_rd_description": "Agents focused on R&D processes, innovation, and technical research",
"com_agents_category_sales": "Sales",
"com_agents_category_sales_description": "Agents focused on sales processes, customer relations",
"com_agents_category_tab_label": "{{category}} category, {{position}} of {{total}}",
"com_agents_category_tabs_label": "Agent Categories",
"com_agents_clear_search": "Clear search",
@@ -478,6 +494,7 @@
"com_nav_lang_arabic": "العربية",
"com_nav_lang_armenian": "Հայերեն",
"com_nav_lang_auto": "Auto detect",
"com_nav_lang_bosnian": "Босански",
"com_nav_lang_brazilian_portuguese": "Português Brasileiro",
"com_nav_lang_catalan": "Català",
"com_nav_lang_chinese": "中文",
@@ -497,6 +514,7 @@
"com_nav_lang_japanese": "日本語",
"com_nav_lang_korean": "한국어",
"com_nav_lang_latvian": "Latviski",
"com_nav_lang_norwegian_bokmal": "Norsk Bokmål",
"com_nav_lang_persian": "فارسی",
"com_nav_lang_polish": "Polski",
"com_nav_lang_portuguese": "Português",
@@ -722,6 +740,7 @@
"com_ui_bookmarks_edit": "Edit Bookmark",
"com_ui_bookmarks_filter": "Filter bookmarks...",
"com_ui_bookmarks_new": "New Bookmark",
"com_ui_bookmarks_tag_exists": "A bookmark with this title already exists",
"com_ui_bookmarks_title": "Title",
"com_ui_bookmarks_update_error": "There was an error updating the bookmark",
"com_ui_bookmarks_update_success": "Bookmark updated successfully",
@@ -866,6 +885,7 @@
"com_ui_feedback_tag_not_matched": "Didn't match my request",
"com_ui_feedback_tag_other": "Other issue",
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
"com_ui_field_max_length": "{{field}} must be less than {{length}} characters",
"com_ui_field_required": "This field is required",
"com_ui_file_size": "File Size",
"com_ui_files": "Files",
@@ -994,6 +1014,7 @@
"com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one",
"com_ui_no_categories": "No categories available",
"com_ui_no_category": "No category",
"com_ui_no_changes": "No changes were made",
"com_ui_no_data": "something needs to go here. was empty",
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
"com_ui_no_personalization_available": "No personalization options are currently available",
@@ -1109,6 +1130,7 @@
"com_ui_select_file": "Select a file",
"com_ui_select_model": "Select a model",
"com_ui_select_options": "Select options...",
"com_ui_select_or_create_prompt": "Select or Create a Prompt",
"com_ui_select_provider": "Select a provider",
"com_ui_select_provider_first": "Select a provider first",
"com_ui_select_region": "Select a region",

View File

@@ -37,12 +37,15 @@ import translationZh_Hans from './zh-Hans/translation.json';
import translationZh_Hant from './zh-Hant/translation.json';
import translationBo from './bo/translation.json';
import translationUk from './uk/translation.json';
import translationBs from './bs/translation.json';
import translationNb from './nb/translation.json';
export const defaultNS = 'translation';
export const resources = {
en: { translation: translationEn },
ar: { translation: translationAr },
bs: { translation: translationBs },
ca: { translation: translationCa },
cs: { translation: translationCs },
'zh-Hans': { translation: translationZh_Hans },
@@ -54,6 +57,7 @@ export const resources = {
fa: { translation: translationFa },
fr: { translation: translationFr },
it: { translation: translationIt },
nb: { translation: translationNb },
pl: { translation: translationPl },
'pt-BR': { translation: translationPt_BR },
'pt-PT': { translation: translationPt_PT },

View File

@@ -4,31 +4,83 @@
"com_a11y_ai_composing": "Mākslīgais intelekts joprojām veido.",
"com_a11y_end": "Mākslīgais intelekts ir pabeidzis atbildi.",
"com_a11y_start": "Mākslīgais intelekts ir sācis savu atbildi.",
"com_agents_agent_card_label": "{{name}} aģents. {{description}}",
"com_agents_all": "Visi aģenti",
"com_agents_by_librechat": "no LibreChat",
"com_agents_category_empty": "Nav atrasts neviens aģents {{category}} kategorijā",
"com_agents_category_tab_label": "{{category}} kategorija, {{position}} no {{total}}",
"com_agents_category_tabs_label": "Aģentu kategorijas",
"com_agents_clear_search": "Notīrīt meklēšanu",
"com_agents_code_interpreter": "Ja šī opcija ir iespējota, jūsu aģents var izmantot LibreChat koda interpretētāja API, lai droši palaistu ģenerēto kodu, tostarp failu apstrādi. Nepieciešama derīga API atslēga.",
"com_agents_code_interpreter_title": "Koda interpretētāja API",
"com_agents_contact": "Sazinieties ar",
"com_agents_copy_link": "Kopēt saiti",
"com_agents_create_error": "Izveidojot jūsu aģentu, radās kļūda.",
"com_agents_created_by": "izveidojis",
"com_agents_description_placeholder": "Pēc izvēles: aprakstiet savu aģentu šeit",
"com_agents_empty_state_heading": "Nav atrasts neviens aģents",
"com_agents_enable_file_search": "Iespējot failu meklēšanu",
"com_agents_error_bad_request_message": "Pieprasījumu nevarēja apstrādāt.",
"com_agents_error_bad_request_suggestion": "Lūdzu, pārbaudiet ievadītos datus un mēģiniet vēlreiz.",
"com_agents_error_category_title": "Kategorija Kļūda",
"com_agents_error_generic": "Satura ielādēšanas laikā radās problēma.",
"com_agents_error_invalid_request": "Nepareizs pieprasījums",
"com_agents_error_loading": "Kļūda aģentu ielādēšanā",
"com_agents_error_network_message": "Nevar izveidot savienojumu ar serveri.",
"com_agents_error_network_suggestion": "Pārbaudiet interneta savienojumu un mēģiniet vēlreiz.",
"com_agents_error_network_title": "Savienojuma problēmas",
"com_agents_error_not_found_message": "Pieprasīto saturu neizdevās atrast.",
"com_agents_error_not_found_suggestion": "Mēģiniet pārlūkot citas iespējas vai atgriezieties katalogā.",
"com_agents_error_not_found_title": "Nav atrasts",
"com_agents_error_retry": "Mēģiniet vēlreiz",
"com_agents_error_search_title": "Meklēšanas kļūda",
"com_agents_error_searching": "Kļūda meklējot aģentus",
"com_agents_error_server_message": "Serveris uz laiku nav pieejams.",
"com_agents_error_server_suggestion": "Lūdzu, mēģiniet vēlreiz pēc dažām minūtēm.",
"com_agents_error_server_title": "Servera kļūda",
"com_agents_error_suggestion_generic": "Lūdzu, mēģiniet atsvaidzināt lapu vai mēģiniet vēlreiz vēlāk.",
"com_agents_error_timeout_message": "Pieprasījuma izpildei bija nepieciešams pārāk ilgs laiks.",
"com_agents_error_timeout_suggestion": "Lūdzu, pārbaudiet interneta savienojumu un mēģiniet vēlreiz.",
"com_agents_error_timeout_title": "Savienojumu neizdevās izveidot",
"com_agents_error_title": "Kaut kas nogāja greizi",
"com_agents_file_context": "Failu konteksts (OCR)",
"com_agents_file_context_disabled": "Pirms failu augšupielādes failu kontekstam ir jāizveido aģents.",
"com_agents_file_context_info": "Faili, kas augšupielādēti kā “Konteksts”, tiek apstrādāti, izmantojot OCR, lai iegūtu tekstu, kas pēc tam tiek pievienots aģenta norādījumiem. Ideāli piemērots dokumentiem, attēliem ar tekstu vai PDF failiem, kuriem nepieciešams pilns faila teksta saturs.",
"com_agents_file_search_disabled": "Pirms failu augšupielādes failu meklēšanai ir jāizveido aģents.",
"com_agents_file_search_info": "Kad šī opcija ir iespējota, aģents tiks informēts par precīziem tālāk norādītajiem failu nosaukumiem, ļaujot tam izgūt atbilstošu kontekstu no šiem failiem.",
"com_agents_grid_announcement": "Rādu {{count}} aģentus {{category}} kategorijā",
"com_agents_instructions_placeholder": "Sistēmas instrukcijas, ko izmantos aģents",
"com_agents_link_copied": "Saite nokopēta",
"com_agents_link_copy_failed": "Neizdevās nokopēt saiti",
"com_agents_load_more_label": "Ielādēt vairāk aģentus no {{category}} kategorijas",
"com_agents_loading": "Notiek ielāde...",
"com_agents_marketplace": "Aģentu katalogs",
"com_agents_marketplace_subtitle": "Atklājiet un izmantojiet jaudīgus mākslīgā intelekta aģentus, lai uzlabotu darba plūsmu un produktivitāti.",
"com_agents_mcp_description_placeholder": "Dažos vārdos paskaidrojiet, ko tas dara.",
"com_agents_mcp_icon_size": "Minimālais izmērs 128 x 128 px",
"com_agents_mcp_info": "MCP serveru pievienošana savam aģentam, lai tas varētu veikt uzdevumus un mijiedarboties ar ārējiem pakalpojumiem.",
"com_agents_mcp_name_placeholder": "Pielāgotais rīks",
"com_agents_mcp_trust_subtext": "LibreChat nav validējis neoficiālus savienotājus",
"com_agents_mcps_disabled": "Pirms MCP pievienošanas ir jāizveido aģents.",
"com_agents_missing_name": "Pirms aģenta izveidošanas ievadiet aģenta nosaukumu.",
"com_agents_missing_provider_model": "Pirms aģenta izveides izvēlieties pakalpojumu sniedzēju un modeli.",
"com_agents_name_placeholder": "Pēc izvēles: aģenta nosaukums",
"com_agents_no_access": "Jums nav piekļuves šī aģenta rediģēšanai.",
"com_agents_no_agent_id_error": "Nav atrasts aģenta ID. Lūdzu, pārliecinieties, ka aģents ir izveidots.",
"com_agents_no_more_results": "Jūs esat sasniedzis rezultātu beigas",
"com_agents_not_available": "Aģents nav pieejams",
"com_agents_recommended": "Mūsu rekomendētie aģenti",
"com_agents_results_for": "Rezultāti par '{{query}}'",
"com_agents_search_aria": "Meklēt aģentus",
"com_agents_search_empty_heading": "Nav meklēšanas rezultātu",
"com_agents_search_info": "Ja šī opcija ir iespējota, jūsu aģents var meklēt jaunāko informāciju tīmeklī. Nepieciešama derīga API atslēga.",
"com_agents_search_instructions": "Rakstiet lai meklētu aģentus pēc vārda vai apraksta",
"com_agents_search_name": "Meklēt aģentus pēc nosaukuma",
"com_agents_search_no_results": "Nav atrasts neviens aģents \"{{query}}\"",
"com_agents_search_placeholder": "Meklēt aģentus...",
"com_agents_see_more": "Skatīt vairāk",
"com_agents_start_chat": "Sākt sarunu",
"com_agents_top_picks": "Labākās izvēles",
"com_agents_update_error": "Jūsu aģenta atjaunināšanā ir kļūda.",
"com_assistants_action_attempt": "Asistents vēlas runāt ar {{0}}",
"com_assistants_actions": "Darbības",
@@ -308,10 +360,21 @@
"com_error_moderation": "Šķiet, ka mūsu moderācijas sistēma ir atzīmējusi iesniegto saturu kā neatbilstošu mūsu vadlīnijām. Mēs nevaram turpināt darbu ar šo konkrēto tēmu. Ja jums ir vēl kādi jautājumi vai tēmas, kuras vēlaties izpētīt, lūdzu, rediģējiet savu ziņu vai izveidojiet jaunu sarunu.",
"com_error_no_base_url": "Nav atrasts bāzes URL. Lūdzu, norādiet to un mēģiniet vēlreiz.",
"com_error_no_user_key": "Atslēga nav atrasta. Lūdzu, norādiet atslēgu un mēģiniet vēlreiz.",
"com_file_pages": "Lapas: {{pages}}",
"com_file_source": "Fails",
"com_file_unknown": "Nezināms fails",
"com_files_download_failed": "{{0}} failu lejuplāde neizdevās",
"com_files_download_percent_complete": "{{0}}% pabeigts",
"com_files_download_progress": "{{0}} no {{1}} failiem",
"com_files_downloading": "Notiek failu lejuplādēšana",
"com_files_filter": "Filtrēt failus...",
"com_files_no_results": "Nav rezultātu.",
"com_files_number_selected": "{{0}} no {{1}} atlasīti faili",
"com_files_preparing_download": "Sagatavošanās lejupielādei...",
"com_files_sharepoint_picker_title": "Izvēlieties failus",
"com_files_table": "kaut kam šeit ir jānotiek. bija tukšs",
"com_files_upload_local_machine": "No lokālā datora",
"com_files_upload_sharepoint": "No SharePoint",
"com_generated_files": "Ģenerētie faili:",
"com_hide_examples": "Slēpt piemērus",
"com_info_heic_converting": "Konvertēju HEIC attēlu uz JPEG...",
@@ -347,7 +410,7 @@
"com_nav_balance_month": "mēnesis",
"com_nav_balance_months": "mēneši",
"com_nav_balance_next_refill": "Nākamā bilances papildināšana:",
"com_nav_balance_next_refill_info": "Nākamā bilances papildināšana notiks automātiski tikai tad, ja būs izpildīti abi nosacījumi: kopš pēdējās bilances papildināšanas ir pagājis norādītais laika atjaunošanas biežums un uzaicinājuma nosūtīšana izraisītu jūsu atlikuma samazināšanos zem nulles.",
"com_nav_balance_next_refill_info": "Nākamā bilances papildināšana notiks automātiski tikai tad, ja būs izpildīti abi nosacījumi: kopš pēdējās bilances papildināšanas ir pagājis norādītais laika atjaunošanas biežums un vaicājuma nosūtīšana izraisītu jūsu atlikuma samazināšanos zem nulles.",
"com_nav_balance_refill_amount": "Bilances papildināšanas apjoms:",
"com_nav_balance_second": "otrais",
"com_nav_balance_seconds": "sekundes",
@@ -415,6 +478,7 @@
"com_nav_lang_arabic": "العربية",
"com_nav_lang_armenian": "Հայերեն",
"com_nav_lang_auto": "Automātiska noteikšana",
"com_nav_lang_bosnian": "Босански",
"com_nav_lang_brazilian_portuguese": "Brazīlijas portugāļu",
"com_nav_lang_catalan": "Katalā",
"com_nav_lang_chinese": "中文",
@@ -434,6 +498,7 @@
"com_nav_lang_japanese": "日本語",
"com_nav_lang_korean": "한국어",
"com_nav_lang_latvian": "Latviešu",
"com_nav_lang_norwegian_bokmal": "Norsk Bokmål",
"com_nav_lang_persian": "فارسی",
"com_nav_lang_polish": "Poļu",
"com_nav_lang_portuguese": "Portugāļu",
@@ -515,9 +580,21 @@
"com_sidepanel_manage_files": "Pārvaldīt failus",
"com_sidepanel_mcp_no_servers_with_vars": "Nav MCP serveru ar konfigurējamiem mainīgajiem.",
"com_sidepanel_parameters": "Parametri",
"com_sources_agent_file": "Avota dokuments",
"com_sources_agent_files": "Aģentu faili",
"com_sources_download_aria_label": "Lejupielādēt {{filename}}{{status}}",
"com_sources_download_failed": "Lejupielādē neizdevās",
"com_sources_download_local_unavailable": "Neizdevās lejupielādēt: Faili nav saglabāti",
"com_sources_downloading_status": " (lejupielādē...)",
"com_sources_error_fallback": "Nevar ielādēt avotus",
"com_sources_image_alt": "Meklēšanas rezultāta attēls",
"com_sources_more_files": "+{{count}} faili",
"com_sources_more_sources": "+{{count}} avoti",
"com_sources_pages": "Lapas",
"com_sources_region_label": "Meklēšanas rezultāti un avoti",
"com_sources_reload_page": "Atkārtoti ielādēt lapu",
"com_sources_tab_all": "Visi",
"com_sources_tab_files": "Faili",
"com_sources_tab_images": "Attēli",
"com_sources_tab_news": "Ziņas",
"com_sources_title": "Avoti",
@@ -546,6 +623,14 @@
"com_ui_advanced": "Advancēts",
"com_ui_advanced_settings": "Advancētie iestatījumi",
"com_ui_agent": "Aģents",
"com_ui_agent_category_aftersales": "Pēcpārdošanas pakalpojumi",
"com_ui_agent_category_finance": "Finanses",
"com_ui_agent_category_general": "Vispārīgi",
"com_ui_agent_category_hr": "Personāla daļa",
"com_ui_agent_category_it": "IT",
"com_ui_agent_category_rd": "Pētniecība un attīstība",
"com_ui_agent_category_sales": "Pārdošana",
"com_ui_agent_category_selector_aria": "Aģenta kategorijas atlasītājs",
"com_ui_agent_chain": "Aģentu ķēde (aģentu maisījums)",
"com_ui_agent_chain_info": "Ļauj izveidot aģentu secību ķēdes. Katrs aģents var piekļūt iepriekšējo ķēdē esošo aģentu izvades datiem. Balstīts uz \"Aģentu sajaukuma\" arhitektūru, kurā aģenti izmanto iepriekšējos izvades datus kā palīginformāciju.",
"com_ui_agent_chain_max": "Jūs esat sasniedzis maksimālo skaitu {{0}} aģentu.",
@@ -553,8 +638,10 @@
"com_ui_agent_deleted": "Aģents veiksmīgi izdzēsts",
"com_ui_agent_duplicate_error": "Dublējot aģentu, radās kļūda.",
"com_ui_agent_duplicated": "Aģents veiksmīgi dublēts",
"com_ui_agent_name_is_required": "Obligāti jānorāda aģenta nosaukums",
"com_ui_agent_recursion_limit": "Maksimālais aģenta soļu skaits",
"com_ui_agent_recursion_limit_info": "Ierobežo, cik soļus aģents var veikt vienā izpildes reizē, pirms sniedz galīgo atbildi. Noklusējuma vērtība ir 25 soļi. Solis ir vai nu AI API pieprasījums, vai rīka lietošanas kārta. Piemēram, pamata rīka mijiedarbība ietver 3 soļus: sākotnējo pieprasījumu, rīka lietošanu un turpmāko pieprasījumu.",
"com_ui_agent_url_copied": "Aģenta URL kopēts starpliktuvē",
"com_ui_agent_var": "{{0}} aģents",
"com_ui_agent_version": "Versija",
"com_ui_agent_version_active": "Aktīvā versija",
@@ -571,6 +658,7 @@
"com_ui_agent_version_unknown_date": "Nezināms datums",
"com_ui_agents": "Aģenti",
"com_ui_agents_allow_create": "Atļaut aģentu izveidi",
"com_ui_agents_allow_share": "Atļaut aģentu koplietošanu",
"com_ui_agents_allow_use": "Atļaut aģentu izmantošanu",
"com_ui_all": "visi",
"com_ui_all_proper": "Visi",
@@ -591,6 +679,7 @@
"com_ui_assistant_deleted": "Asistents ir veiksmīgi izdzēsts.",
"com_ui_assistants": "Asistenti",
"com_ui_assistants_output": "Asistentu izvade",
"com_ui_at_least_one_owner_required": "Nepieciešams vismaz viens īpašnieks",
"com_ui_attach_error": "Nevar pievienot failu. Izveidojiet vai atlasiet sarunu vai mēģiniet atsvaidzināt lapu.",
"com_ui_attach_error_openai": "Nevar pievienot asistenta failus citiem galapunktiem",
"com_ui_attach_error_size": "Galapunkta faila lieluma ierobežojums ir pārsniegts:",
@@ -607,12 +696,16 @@
"com_ui_available_tools": "Pieejamie rīki",
"com_ui_avatar": "Avatars",
"com_ui_azure": "Azure",
"com_ui_azure_ad": "Azure Entra ID",
"com_ui_back": "Atpakaļ",
"com_ui_back_to_chat": "Atpakaļ uz sarunu",
"com_ui_back_to_prompts": "Atpakaļ pie uzvednēm",
"com_ui_backup_code_number": "Kods #{{number}}",
"com_ui_backup_codes": "Rezerves kodi",
"com_ui_backup_codes_regenerate_error": "Atkārtoti ģenerējot rezerves kodus, radās kļūda.",
"com_ui_backup_codes_regenerated": "Rezerves kodi ir veiksmīgi atjaunoti",
"com_ui_backup_codes_security_info": "Drošības apsvērumu dēļ rezerves kodi tiek parādīti tikai vienu reizi. Lūdzu, saglabājiet tos drošā vietā.",
"com_ui_backup_codes_status": "Rezerves kodu statuss",
"com_ui_basic": "Pamata",
"com_ui_basic_auth_header": "Pamata autorizācijas galvene",
"com_ui_bearer": "Nesējs",
@@ -669,6 +762,7 @@
"com_ui_copy_code": "Kopēt kodu",
"com_ui_copy_link": "Kopēt saiti",
"com_ui_copy_to_clipboard": "Kopēt starpliktuvē",
"com_ui_copy_url_to_clipboard": "URL kopēšana uz starpliktuvi",
"com_ui_create": "Izveidot",
"com_ui_create_link": "Izveidot saiti",
"com_ui_create_memory": "Izveidot atmiņu",
@@ -754,6 +848,7 @@
"com_ui_error_connection": "Kļūda, izveidojot savienojumu ar serveri, mēģiniet atsvaidzināt lapu.",
"com_ui_error_save_admin_settings": "Saglabājot administratora iestatījumus, radās kļūda.",
"com_ui_error_updating_preferences": "Kļūda, atjauninot preferences",
"com_ui_everyone_permission_level": "Visu lietotāju atļaujas līmenis",
"com_ui_examples": "Piemēri",
"com_ui_expand_chat": "Izvērst sarunu",
"com_ui_export_convo_modal": "Eksportēt sarunas modālo logu",
@@ -818,6 +913,7 @@
"com_ui_good_afternoon": "Labdien",
"com_ui_good_evening": "Labvakar",
"com_ui_good_morning": "Labrīt",
"com_ui_group": "Grupa",
"com_ui_happy_birthday": "Man šodien ir pirmā dzimšanas diena!",
"com_ui_hide_image_details": "Slēpt attēla detaļas",
"com_ui_hide_password": "Paslēpt paroli",
@@ -851,6 +947,8 @@
"com_ui_logo": "{{0}} Logotips",
"com_ui_low": "Zems",
"com_ui_manage": "Pārvaldīt",
"com_ui_marketplace": "Katalogs",
"com_ui_marketplace_allow_use": "Atļaut izmantot katalogu",
"com_ui_max_tags": "Maksimālais atļautais skaits ir {{0}}, izmantojot jaunākās vērtības.",
"com_ui_mcp_authenticated_success": "MCP serveris '{{0}}' veiksmīgi autentificēts",
"com_ui_mcp_enter_var": "Ievadiet vērtību {{0}}",
@@ -898,10 +996,13 @@
"com_ui_next": "Nākamais",
"com_ui_no": "Nē",
"com_ui_no_bookmarks": "Šķiet, ka jums vēl nav grāmatzīmju. Noklikšķiniet uz sarunas un pievienojiet jaunu.",
"com_ui_no_categories": "Nav pieejamas nevienas kategorijas",
"com_ui_no_category": "Nav kategorijas",
"com_ui_no_data": "kaut kam šeit ir jānotiek. bija tukšs",
"com_ui_no_individual_access": "Aatsevišķiem lietotājiem vai grupām nav pieejas pie šī aģenta",
"com_ui_no_personalization_available": "Pašlaik nav pieejamas personalizācijas opcijas",
"com_ui_no_read_access": "Jums nav atļaujas skatīt atmiņas",
"com_ui_no_results_found": "Nav atrastu rezultātu",
"com_ui_no_terms_content": "Nav noteikumu un nosacījumu satura, ko parādīt",
"com_ui_no_valid_items": "kaut kam šeit ir jānotiek. bija tukšs",
"com_ui_none": "Neviens",
@@ -924,6 +1025,14 @@
"com_ui_openai": "OpenAI",
"com_ui_optional": "(pēc izvēles)",
"com_ui_page": "Lapa",
"com_ui_people": "cilvēki",
"com_ui_people_picker": "Cilvēku atlasītājs",
"com_ui_people_picker_allow_view_groups": "Atļaut skatīt grupas",
"com_ui_people_picker_allow_view_roles": "Atļaut lomu skatīšanu",
"com_ui_people_picker_allow_view_users": "Atļaut skatīt lietotājus",
"com_ui_permissions_failed_load": "Neizdevās ielādēt pieejas tiesības. Lūdzu, mēģiniet vēlreiz.",
"com_ui_permissions_failed_update": "Neizdevās atjaunināt pieejas tiesības. Lūdzu, mēģiniet vēlreiz.",
"com_ui_permissions_updated_success": "Pieejas tiesības ir veiksmīgi atjauninātas.",
"com_ui_preferences_updated": "Preferences veiksmīgi atjauninātas",
"com_ui_prev": "Iepriekšējais",
"com_ui_preview": "Priekšskatījums",
@@ -938,6 +1047,7 @@
"com_ui_prompt_update_error": "Atjauninot uzvedni, radās kļūda.",
"com_ui_prompts": "Uzvednes",
"com_ui_prompts_allow_create": "Atļaut uzvedņu izveidi",
"com_ui_prompts_allow_share": "Atļaut kopīgošanas uzvednes",
"com_ui_prompts_allow_use": "Atļaut izmantot uzvednes",
"com_ui_provider": "Pakalpojumu sniedzējs",
"com_ui_quality": "Kvalitāte",
@@ -945,12 +1055,14 @@
"com_ui_redirecting_to_provider": "Pārvirzu uz {{0}}, lūdzu, uzgaidiet...",
"com_ui_reference_saved_memories": "References uz saglabātajām atmiņām",
"com_ui_reference_saved_memories_description": "Ļaut asistentam atsaukties uz jūsu saglabātajām atmiņām un izmantot tās atbildot",
"com_ui_refresh": "Atsvaidzināt",
"com_ui_refresh_link": "Atsvaidzināt saiti",
"com_ui_regenerate": "Atjaunot",
"com_ui_regenerate_backup": "Atjaunot rezerves kodus",
"com_ui_regenerating": "Atjaunojas...",
"com_ui_region": "Reģions",
"com_ui_reinitialize": "Reinicializēt",
"com_ui_remove_user": "Noņemt {{0}}",
"com_ui_rename": "Pārdēvēt",
"com_ui_rename_conversation": "Pārdēvēt sarunu",
"com_ui_rename_failed": "Neizdevās pārdēvēt sarunu",
@@ -958,6 +1070,7 @@
"com_ui_requires_auth": "Nepieciešama autentifikācija",
"com_ui_reset_var": "Atiestatīt {{0}}",
"com_ui_reset_zoom": "Atiestatīt tālummaiņu",
"com_ui_resource": "resurss",
"com_ui_result": "Rezultāts",
"com_ui_revoke": "Atsaukt",
"com_ui_revoke_info": "Atcelt visus lietotāja sniegtos lietotāja datus",
@@ -965,24 +1078,41 @@
"com_ui_revoke_key_endpoint": "Atsaukt atslēgu priekš {{0}}",
"com_ui_revoke_keys": "Atsaukt atslēgas",
"com_ui_revoke_keys_confirm": "Vai tiešām vēlaties atsaukt visas atslēgas?",
"com_ui_role": "Loma",
"com_ui_role_editor": "Redaktors",
"com_ui_role_editor_desc": "Var skatīt un rediģēt aģentu",
"com_ui_role_manager": "Vadītājs",
"com_ui_role_manager_desc": "Var skatīt, rediģēt un dzēst aģentu",
"com_ui_role_owner": "Īpašnieks",
"com_ui_role_owner_desc": "Pilnīga kontrole pār aģentu, tostarp tā koplietošana",
"com_ui_role_select": "Loma",
"com_ui_role_viewer": "Skatītājs",
"com_ui_role_viewer_desc": "Var skatīt un izmantot aģentu, bet nevar to rediģēt",
"com_ui_roleplay": "Lomu spēle",
"com_ui_run_code": "Palaišanas kods",
"com_ui_run_code_error": "Radās kļūda, izpildot kodu",
"com_ui_save": "Saglabāt",
"com_ui_save_badge_changes": "Vai saglabāt emblēmas izmaiņas?",
"com_ui_save_changes": "Saglabāt izmaiņas",
"com_ui_save_submit": "Saglabāt un iesniegt",
"com_ui_saved": "Saglabāts!",
"com_ui_saving": "Saglabā...",
"com_ui_schema": "Shēma",
"com_ui_scope": "Mērogs",
"com_ui_search": "Meklēt",
"com_ui_search_above_to_add": "Meklēt augstāk, lai pievienotu lietotājus vai grupas",
"com_ui_search_above_to_add_all": "Meklēt augstāk, lai pievienotu lietotājus, grupas vai lomas",
"com_ui_search_above_to_add_people": "Meklēt augstāk, lai pievienotu personas",
"com_ui_search_agent_category": "Meklēt kategorijas...",
"com_ui_search_default_placeholder": "Meklēt pēc vārda vai e-pasta adreses (vismaz 2 rakstu zīmes)",
"com_ui_search_people_placeholder": "Meklēt personas vai grupas pēc vārda vai e-pasta adreses",
"com_ui_seconds": "sekundes",
"com_ui_secret_key": "Slepenā atslēga",
"com_ui_select": "Atlasīt",
"com_ui_select_all": "Atlasīt visu",
"com_ui_select_file": "Atlasiet failu",
"com_ui_select_model": "Izvēlieties modeli",
"com_ui_select_options": "Izvēlieties opcijas...",
"com_ui_select_provider": "Izvēlieties pakalpojumu sniedzēju",
"com_ui_select_provider_first": "Vispirms izvēlieties pakalpojumu sniedzēju",
"com_ui_select_region": "Izvēlieties reģionu",
@@ -995,6 +1125,8 @@
"com_ui_share_create_message": "Jūsu vārds un visas ziņas, ko pievienojat pēc kopīgošanas, paliek privātas.",
"com_ui_share_delete_error": "Dzēšot koplietoto saiti, radās kļūda.",
"com_ui_share_error": "Kopīgojot sarunas saiti, radās kļūda.",
"com_ui_share_everyone": "Koplietot ar visiem",
"com_ui_share_everyone_description_var": "Šis {{resource}} būs pieejams ikvienam. Lūdzu, pārliecinieties, ka {{resource}} patiesībā ir paredzēts koplietošanai ar visiem. Esiet uzmanīgi ar saviem datiem.",
"com_ui_share_link_to_chat": "Kopīgot saiti sarunai",
"com_ui_share_update_message": "Jūsu vārds, pielāgotie norādījumi un visas ziņas, ko pievienojat pēc kopīgošanas, paliek privātas.",
"com_ui_share_var": "Kopīgot {{0}}",
@@ -1021,6 +1153,13 @@
"com_ui_stop": "Apstāties",
"com_ui_storage": "Uzglabāšana",
"com_ui_submit": "Iesniegt",
"com_ui_support_contact": "Atbalsta kontaktinformācija",
"com_ui_support_contact_email": "E-pasts",
"com_ui_support_contact_email_invalid": "Lūdzu, ievadiet derīgu e-pasta adresi",
"com_ui_support_contact_email_placeholder": "atbalsts@piemers.com",
"com_ui_support_contact_name": "Vārds",
"com_ui_support_contact_name_min_length": "Vārdam jābūt vismaz {{minLength}} rakstu zīmēm",
"com_ui_support_contact_name_placeholder": "Atbalsta kontaktpersonas vārds",
"com_ui_teach_or_explain": "Mācīšanās",
"com_ui_temporary": "Pagaidu saruna",
"com_ui_terms_and_conditions": "Noteikumi un nosacījumi",
@@ -1037,6 +1176,7 @@
"com_ui_tools": "Rīki",
"com_ui_travel": "Ceļošana",
"com_ui_trust_app": "Es uzticos šai lietotnei",
"com_ui_try_adjusting_search": "Mēģiniet pielāgot meklēšanas vaicājumus",
"com_ui_unarchive": "Atarhivēt",
"com_ui_unarchive_error": "Neizdevās atarhivēt sarunu",
"com_ui_unknown": "Nezināms",
@@ -1046,6 +1186,7 @@
"com_ui_update_mcp_error": "Izveidojot vai atjauninot MCP, radās kļūda.",
"com_ui_update_mcp_success": "Veiksmīgi izveidots vai atjaunināts MCP",
"com_ui_upload": "Augšupielādēt",
"com_ui_upload_agent_avatar": "Aģenta avatars veiksmīgi atjaunināts",
"com_ui_upload_code_files": "Augšupielādēt koda interpretētājam",
"com_ui_upload_delay": "Augšupielāde \"{{0}}\" aizņem vairāk laika nekā paredzēts. Lūdzu, uzgaidiet, kamēr faila indeksēšana ir pabeigta izguvei.",
"com_ui_upload_error": "Augšupielādējot failu, radās kļūda.",
@@ -1065,6 +1206,8 @@
"com_ui_use_memory": "Izmantot atmiņu",
"com_ui_use_micrphone": "Izmantot mikrofonu",
"com_ui_used": "Lietots",
"com_ui_user": "Lietotājs",
"com_ui_user_group_permissions": "Lietotāju un grupu atļaujas",
"com_ui_value": "Vērtība",
"com_ui_variables": "Mainīgie",
"com_ui_variables_info": "Mainīgo veidošanai tekstā izmantot dubultās iekavas, piemēram, `{{example variable}}`, lai vēlāk aizpildītu, izmantojot uzvedni.",

View File

@@ -0,0 +1 @@
{}

View File

@@ -4,6 +4,7 @@
"com_a11y_ai_composing": "De AI is nog bezig met het formuleren van een antwoord.",
"com_a11y_end": "De AI is klaar met het antwoord.",
"com_a11y_start": "De AI is begonnen met antwoorden.",
"com_agents_all": "Alle Agents",
"com_agents_by_librechat": "door LibreChat",
"com_agents_code_interpreter": "Indien ingeschakeld, kan je agent de LibreChat Code Interpreter API gebruiken om gegenereerde code, inclusief het verwerken van bestanden, veilig uit te voeren. Vereist een geldige API-sleutel.",
"com_agents_code_interpreter_title": "Code Interpreter API",

View File

@@ -478,6 +478,7 @@
"com_nav_lang_arabic": "العربية",
"com_nav_lang_armenian": "Հայերեն",
"com_nav_lang_auto": "自动检测语言",
"com_nav_lang_bosnian": "Босански",
"com_nav_lang_brazilian_portuguese": "Português Brasileiro",
"com_nav_lang_catalan": "Català",
"com_nav_lang_chinese": "中文",
@@ -497,6 +498,7 @@
"com_nav_lang_japanese": "日本語",
"com_nav_lang_korean": "한국어",
"com_nav_lang_latvian": "Latviski",
"com_nav_lang_norwegian_bokmal": "Norsk Bokmål",
"com_nav_lang_persian": "فارسی",
"com_nav_lang_polish": "Polski",
"com_nav_lang_portuguese": "Português",

View File

@@ -4,12 +4,24 @@
"com_a11y_ai_composing": "AI 仍在撰寫中",
"com_a11y_end": "AI 已完成回覆",
"com_a11y_start": "AI 已開始回覆。",
"com_agents_agent_card_label": "{{name}} agent。{{description}}",
"com_agents_all": "全部 Agent",
"com_agents_by_librechat": "由 LibreChat 提供",
"com_agents_category_empty": "在 {{category}} 類別中找不到 agent",
"com_agents_category_tab_label": "{{category}} 類別,{{position}} / {{total}}",
"com_agents_category_tabs_label": "Agent 類別",
"com_agents_clear_search": "清除搜尋",
"com_agents_code_interpreter": "啟用後,您的代理可以安全地使用 LibreChat 程式碼解譯器 API 來執行產生的程式碼,包括檔案處理功能。需要有效的 API 金鑰。",
"com_agents_code_interpreter_title": "程式碼解譯器 API",
"com_agents_contact": "關聯",
"com_agents_copy_link": "複製連結",
"com_agents_create_error": "建立您的代理時發生錯誤。",
"com_agents_created_by": "來自",
"com_agents_description_placeholder": "選填:在此描述您的代理程式",
"com_agents_empty_state_heading": "未找到 agent",
"com_agents_enable_file_search": "啟用檔案搜尋",
"com_agents_error_bad_request_message": "無法處理該請求",
"com_agents_error_bad_request_suggestion": "請檢查您的輸入並再試一次。",
"com_agents_file_context": "文件內容 (OCR)",
"com_agents_file_context_disabled": "在為檔案上下文上傳檔案之前,必須先建立 Agent。",
"com_agents_file_context_info": "以「Context」標記上傳的檔案會使用 OCR 擷取文字,擷取後的文字會被加入到 Agent 的指示中。適合用於文件、含文字的圖片或需要取得檔案完整文字內容的 PDF。",
@@ -229,7 +241,7 @@
"com_endpoint_openai_max_tokens": "可選的 `max_tokens` 欄位,代表在對話完成中可以生成的最大 token 數。\n\n輸入 token 和生成 token 的總長度受限於模型的上下文長度。如果此數字超過最大上下文 token 數,您可能會遇到錯誤。",
"com_endpoint_openai_pres": "數值範圍介於 -2.0 和 2.0 之間。正值會根據該 token 是否在目前的文字中出現來進行懲罰,增加模型談及新主題的可能性。",
"com_endpoint_openai_prompt_prefix_placeholder": "在系統訊息中設定自訂提示。",
"com_endpoint_openai_reasoning_effort": "僅適用於推理模型:限制推理的投入。降低推理投入可以使回應更快,且在回應中使用較少的推理 token。「Minimal」會產生非常少的推理 token以達到最快的首次 token 產生時間,特別適合程式碼與指令遵循。",
"com_endpoint_openai_reasoning_effort": "僅適用於推理模型:限制推理的投入。降低推理投入可以使回應更快,且在回應中使用較少的推理 token。最小值會產生非常少的推理 token以達到最快的首次 token 產生時間,特別適合程式碼與指令遵循。",
"com_endpoint_openai_reasoning_summary": "僅限 Responses API模型執行推理的摘要。這有助於除錯並理解模型的推理過程。可設定為 無、自動、簡潔或詳細。",
"com_endpoint_openai_resend": "重新傳送之前所有附加的圖片。注意:這可能會大幅增加 token 成本,如果附加了太多圖片,您可能會遇到錯誤。",
"com_endpoint_openai_resend_files": "重新傳送之前附加的所有檔案。注意:這將增加 token 成本,如果附件過多,您可能會遇到錯誤。",
@@ -238,7 +250,7 @@
"com_endpoint_openai_topp": "與溫度取樣的替代方法,稱為核心取樣,其中模型考慮 top_p 機率質量的 token 結果。所以 0.1 表示只考慮佔 top 10% 機率質量的 token。我們建議修改這個或溫度但不建議兩者都修改。",
"com_endpoint_openai_use_responses_api": "使用 Responses API 取代 Chat CompletionsResponses API 包含來自 OpenAI 的延伸功能。使用 o1-pro、o3-pro 或要啟用推理摘要時,必須使用 Responses API。",
"com_endpoint_openai_use_web_search": "啟用 OpenAI 內建的網路搜尋功能,使模型能夠在網路上搜尋最新資訊,從而提供更準確、即時的回應。",
"com_endpoint_openai_verbosity": "限制模型回應的冗長程度。較低的值會得到較簡潔的回應,較高的值會得到較冗長的回應。目前支援的值為 low、medium 與 high。",
"com_endpoint_openai_verbosity": "限制模型回應的詳盡程度。數值較低會產生較精簡的回應,數值較高會產生較詳盡的回應。目前支援的值為低/中/高。",
"com_endpoint_output": "輸出",
"com_endpoint_plug_image_detail": "影像詳細資訊",
"com_endpoint_plug_resend_files": "重新傳送檔案",
@@ -280,6 +292,7 @@
"com_endpoint_top_k": "Top K",
"com_endpoint_top_p": "Top P",
"com_endpoint_use_active_assistant": "使用活躍助理",
"com_endpoint_verbosity": "詳盡程度",
"com_error_expired_user_key": "提供給 {{0}} 的金鑰已於 {{1}} 到期。請提供一個新的金鑰並重試。",
"com_error_files_dupe": "偵測到重複的檔案。",
"com_error_files_empty": "不允許空白檔案。",
@@ -318,6 +331,7 @@
"com_nav_balance": "餘額",
"com_nav_balance_day": "日",
"com_nav_balance_days": "日",
"com_nav_balance_every": "每",
"com_nav_balance_hour": "小時",
"com_nav_balance_hours": "小時",
"com_nav_balance_interval": "間隔",
@@ -386,9 +400,14 @@
"com_nav_info_save_draft": "啟用後,您在聊天表單中輸入的文字和附件將自動儲存為本地草稿。即使重新載入頁面或切換至其他對話,這些草稿仍會保留。草稿僅儲存在您的裝置上,並會在訊息送出後自動刪除。",
"com_nav_info_user_name_display": "啟用時,每則您發送的訊息上方都會顯示您的使用者名稱。停用時,您的訊息上方只會顯示「您」。",
"com_nav_lang_arabic": "العربية",
"com_nav_lang_armenian": "Հայերեն",
"com_nav_lang_auto": "自動偵測",
"com_nav_lang_bosnian": "Босански",
"com_nav_lang_brazilian_portuguese": "Português Brasileiro",
"com_nav_lang_catalan": "Català",
"com_nav_lang_chinese": "中文",
"com_nav_lang_czech": "Čeština",
"com_nav_lang_danish": "Dansk",
"com_nav_lang_dutch": "Nederlands",
"com_nav_lang_english": "English",
"com_nav_lang_estonian": "Eesti keel",
@@ -397,18 +416,25 @@
"com_nav_lang_georgian": "ქართული",
"com_nav_lang_german": "Deutsch",
"com_nav_lang_hebrew": "עברית",
"com_nav_lang_hungarian": "Magyar",
"com_nav_lang_indonesia": "Indonesia",
"com_nav_lang_italian": "Italiano",
"com_nav_lang_japanese": "日本語",
"com_nav_lang_korean": "한국어",
"com_nav_lang_latvian": "Latviski",
"com_nav_lang_norwegian_bokmal": "Norsk Bokmål",
"com_nav_lang_persian": "فارسی",
"com_nav_lang_polish": "Polski",
"com_nav_lang_portuguese": "Português",
"com_nav_lang_russian": "Русский",
"com_nav_lang_spanish": "Español",
"com_nav_lang_swedish": "Svenska",
"com_nav_lang_thai": "ไทย",
"com_nav_lang_tibetan": "བོད་སྐད་",
"com_nav_lang_traditional_chinese": "繁體中文",
"com_nav_lang_turkish": "Türkçe",
"com_nav_lang_ukrainian": "Українська",
"com_nav_lang_uyghur": "Uyƣur tili",
"com_nav_lang_vietnamese": "Tiếng Việt",
"com_nav_language": "語言",
"com_nav_latex_parsing": "解析訊息中的 LaTeX 內容(可能影響效能)",
@@ -531,6 +557,7 @@
"com_ui_attachment": "附件",
"com_ui_auth_type": "認證類型",
"com_ui_authentication": "驗證",
"com_ui_auto": "自動",
"com_ui_avatar": "大頭照",
"com_ui_back_to_chat": "返回對話",
"com_ui_back_to_prompts": "返回提示",
@@ -562,6 +589,7 @@
"com_ui_collapse_chat": "收合對話",
"com_ui_command_placeholder": "選填:輸入指令,若未填寫將使用名稱",
"com_ui_command_usage_placeholder": "透過指令或名稱選擇提示",
"com_ui_concise": "簡潔",
"com_ui_confirm_action": "確認操作",
"com_ui_context": "情境",
"com_ui_continue": "繼續",
@@ -613,6 +641,7 @@
"com_ui_description": "描述",
"com_ui_description_placeholder": "選填:輸入要顯示的提示描述",
"com_ui_deselect_all": "全部取消選取",
"com_ui_detailed": "詳細",
"com_ui_download_error": "下載檔案時發生錯誤。該檔案可能已被刪除。",
"com_ui_dropdown_variables": "下拉式變數:",
"com_ui_dropdown_variables_info": "為您的提示建立自訂下拉選單:`{{variable_name:選項1|選項2|選項3}}`",
@@ -621,6 +650,7 @@
"com_ui_duplication_processing": "正在複製對話...",
"com_ui_duplication_success": "已成功複製對話",
"com_ui_edit": "編輯",
"com_ui_empty_category": "-",
"com_ui_endpoint": "端點",
"com_ui_endpoint_menu": "語言模型端點選單",
"com_ui_enter": "輸入",
@@ -658,7 +688,10 @@
"com_ui_generate_qrcode": "產生 QR 碼",
"com_ui_generating": "產生中...",
"com_ui_go_to_conversation": "前往對話",
"com_ui_good_evening": "晚安",
"com_ui_good_morning": "早安",
"com_ui_happy_birthday": "這是我的第一個生日!",
"com_ui_high": "高",
"com_ui_host": "主機",
"com_ui_image_gen": "影像生成",
"com_ui_import_conversation_error": "匯入對話時發生錯誤",
@@ -668,17 +701,20 @@
"com_ui_include_shadcnui": "包含 shadcn/ui 元件說明",
"com_ui_input": "輸入",
"com_ui_instructions": "說明",
"com_ui_key": "鍵",
"com_ui_latest_footer": "讓每個人都能使用 AI",
"com_ui_librechat_code_api_key": "取得你的 LibreChat 程式碼解譯器 API 金鑰",
"com_ui_librechat_code_api_subtitle": "安全性高。多語言支援。檔案輸入/輸出。",
"com_ui_librechat_code_api_title": "執行 AI 程式碼",
"com_ui_locked": "已鎖定",
"com_ui_logo": "{{0}} 標誌",
"com_ui_low": "低",
"com_ui_manage": "管理",
"com_ui_max_tags": "允許的最大數量為 {{0}},已使用最新值。",
"com_ui_mcp_enter_var": "請輸入 {{0}} 的值",
"com_ui_mcp_server_not_found": "找不到伺服器。",
"com_ui_mcp_url": "MCP 伺服器",
"com_ui_medium": "中",
"com_ui_memories": "記憶",
"com_ui_memories_allow_create": "允許建立記憶",
"com_ui_memories_allow_opt_out": "允許用戶選擇不使用記憶功能",
@@ -700,21 +736,25 @@
"com_ui_memory_would_exceed": "無法儲存——會超出限制 {{tokens}} 個 token。請刪除現有記憶以釋放空間。",
"com_ui_mention": "提及端點、助理或預設設定以快速切換",
"com_ui_min_tags": "無法再移除更多值,至少需要 {{0}} 個。",
"com_ui_minimal": "最小值",
"com_ui_model": "模型",
"com_ui_model_parameters": "模型參數",
"com_ui_more_info": "更多資訊",
"com_ui_my_prompts": "我的提示",
"com_ui_name": "名稱",
"com_ui_new": "新",
"com_ui_new_chat": "新對話",
"com_ui_next": "下一個",
"com_ui_no": "否",
"com_ui_no_bookmarks": "看來您還沒有任何書籤。請點選對話並新增一個書籤",
"com_ui_no_category": "無分類",
"com_ui_no_terms_content": "沒有條款和條件內容顯示",
"com_ui_none": "無",
"com_ui_nothing_found": "找不到任何內容",
"com_ui_of": "的",
"com_ui_off": "關閉",
"com_ui_on": "開啟",
"com_ui_openai": "OpenAI",
"com_ui_page": "頁面",
"com_ui_prev": "上一個",
"com_ui_preview": "預覽",
@@ -763,6 +803,7 @@
"com_ui_select_search_plugin": "依名稱搜尋外掛程式",
"com_ui_select_search_provider": "依名稱搜尋供應商",
"com_ui_select_search_region": "依名稱搜尋區域",
"com_ui_set": "設置",
"com_ui_share": "分享",
"com_ui_share_create_message": "您的姓名以及您在共享後新增的任何訊息都會保密。",
"com_ui_share_delete_error": "刪除共享連結時發生錯誤。",
@@ -780,8 +821,11 @@
"com_ui_stop": "停止",
"com_ui_storage": "儲存空間",
"com_ui_submit": "送出",
"com_ui_support_contact_email": "Email",
"com_ui_terms_and_conditions": "條款和條件",
"com_ui_terms_of_service": "服務條款",
"com_ui_thoughts": "思考過程",
"com_ui_token": "token",
"com_ui_tools": "工具",
"com_ui_unarchive": "取消封存",
"com_ui_unarchive_error": "取消封存對話時發生錯誤",
@@ -825,6 +869,7 @@
"com_ui_web_search_searxng_instance_url": "SearXNG Instance URL",
"com_ui_web_searching": "正在搜尋網路",
"com_ui_web_searching_again": "正在重新搜尋網路",
"com_ui_weekend_morning": "週末愉快",
"com_ui_yes": "是",
"com_ui_zoom": "縮放",
"com_user_message": "您"

View File

@@ -207,4 +207,36 @@ y$ which spans lines`;
const expected = 'Set $$\\{x | x > \\$0\\}$$ for positive prices';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('does not convert when closing dollar is preceded by backtick', () => {
const content = 'The error "invalid $lookup namespace" occurs when using `$lookup` operator';
const expected = 'The error "invalid $lookup namespace" occurs when using `$lookup` operator';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles mixed backtick and non-backtick cases', () => {
const content = 'Use $x + y$ in math but `$lookup` in code';
const expected = 'Use $$x + y$$ in math but `$lookup` in code';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('escapes currency amounts without commas', () => {
const content =
'The total amount invested is $1157.90 (existing amount) + $500 (new investment) = $1657.90.';
const expected =
'The total amount invested is \\$1157.90 (existing amount) + \\$500 (new investment) = \\$1657.90.';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('handles large currency amounts', () => {
const content = 'You can win $1000000 or even $9999999.99!';
const expected = 'You can win \\$1000000 or even \\$9999999.99!';
expect(preprocessLaTeX(content)).toBe(expected);
});
test('escapes currency with many decimal places', () => {
const content = 'Bitcoin: $0.00001234, Gas: $3.999, Rate: $1.234567890';
const expected = 'Bitcoin: \\$0.00001234, Gas: \\$3.999, Rate: \\$1.234567890';
expect(preprocessLaTeX(content)).toBe(expected);
});
});

View File

@@ -3,9 +3,8 @@ const MHCHEM_CE_REGEX = /\$\\ce\{/g;
const MHCHEM_PU_REGEX = /\$\\pu\{/g;
const MHCHEM_CE_ESCAPED_REGEX = /\$\\\\ce\{[^}]*\}\$/g;
const MHCHEM_PU_ESCAPED_REGEX = /\$\\\\pu\{[^}]*\}\$/g;
const CURRENCY_REGEX =
/(?<![\\$])\$(?!\$)(?=\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?(?:\s|$|[^a-zA-Z\d]))/g;
const SINGLE_DOLLAR_REGEX = /(?<!\\)\$(?!\$)((?:[^$\n]|\\[$])+?)(?<!\\)\$(?!\$)/g;
const CURRENCY_REGEX = /(?<![\\$])\$(?!\$)(?=\d+(?:,\d{3})*(?:\.\d+)?(?:\s|$|[^a-zA-Z\d]))/g;
const SINGLE_DOLLAR_REGEX = /(?<!\\)\$(?!\$)((?:[^$\n]|\\[$])+?)(?<!\\)(?<!`)\$(?!\$)/g;
/**
* Escapes mhchem package notation in LaTeX by converting single dollar delimiters to double dollars

View File

@@ -16,6 +16,38 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
logger.info('Starting Enhanced Agent Permissions Migration', { dryRun, batchSize });
/** Ensurse `aclentries` collection exists for DocumentDB compatibility
* @param {import('mongoose').mongo.Db} db
* @param {string} collectionName
*/
async function ensureCollectionExists(db, collectionName) {
try {
const collections = await db.listCollections({ name: collectionName }).toArray();
if (collections.length === 0) {
await db.createCollection(collectionName);
logger.info(`Created collection: ${collectionName}`);
} else {
logger.info(`Collection already exists: ${collectionName}`);
}
} catch (error) {
logger.error(`'Failed to check/create "${collectionName}" collection:`, error);
// If listCollections fails, try alternative approach
try {
// Try to access the collection directly - this will create it in MongoDB if it doesn't exist
await db.collection(collectionName).findOne({}, { projection: { _id: 1 } });
} catch (createError) {
logger.error(`Could not ensure collection ${collectionName} exists:`, createError);
}
}
}
const mongoose = require('mongoose');
/** @type {import('mongoose').mongo.Db | undefined} */
const db = mongoose.connection.db;
if (db) {
await ensureCollectionExists(db, 'aclentries');
}
// Verify required roles exist
const ownerRole = await findRoleByIdentifier(AccessRoleIds.AGENT_OWNER);
const viewerRole = await findRoleByIdentifier(AccessRoleIds.AGENT_VIEWER);
@@ -100,12 +132,19 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
}
});
logger.info('Agent categorization:', {
globalEditAccess: categories.globalEditAccess.length,
globalViewAccess: categories.globalViewAccess.length,
privateAgents: categories.privateAgents.length,
total: agentsToMigrate.length,
});
logger.info(
'Agent categorization:\n' +
JSON.stringify(
{
globalEditAccess: categories.globalEditAccess.length,
globalViewAccess: categories.globalViewAccess.length,
privateAgents: categories.privateAgents.length,
total: agentsToMigrate.length,
},
null,
2,
),
);
if (dryRun) {
return {

View File

@@ -16,6 +16,38 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
logger.info('Starting PromptGroup Permissions Migration', { dryRun, batchSize });
/** Ensurse `aclentries` collection exists for DocumentDB compatibility
* @param {import('mongoose').mongo.Db} db
* @param {string} collectionName
*/
async function ensureCollectionExists(db, collectionName) {
try {
const collections = await db.listCollections({ name: collectionName }).toArray();
if (collections.length === 0) {
await db.createCollection(collectionName);
logger.info(`Created collection: ${collectionName}`);
} else {
logger.info(`Collection already exists: ${collectionName}`);
}
} catch (error) {
logger.error(`'Failed to check/create "${collectionName}" collection:`, error);
// If listCollections fails, try alternative approach
try {
// Try to access the collection directly - this will create it in MongoDB if it doesn't exist
await db.collection(collectionName).findOne({}, { projection: { _id: 1 } });
} catch (createError) {
logger.error(`Could not ensure collection ${collectionName} exists:`, createError);
}
}
}
const mongoose = require('mongoose');
/** @type {import('mongoose').mongo.Db | undefined} */
const db = mongoose.connection.db;
if (db) {
await ensureCollectionExists(db, 'aclentries');
}
// Verify required roles exist
const ownerRole = await findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_OWNER);
const viewerRole = await findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_VIEWER);
@@ -91,11 +123,18 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
}
});
logger.info('PromptGroup categorization:', {
globalViewAccess: categories.globalViewAccess.length,
privateGroups: categories.privateGroups.length,
total: promptGroupsToMigrate.length,
});
logger.info(
'PromptGroup categorization:\n' +
JSON.stringify(
{
globalViewAccess: categories.globalViewAccess.length,
privateGroups: categories.privateGroups.length,
total: promptGroupsToMigrate.length,
},
null,
2,
),
);
if (dryRun) {
return {

View File

@@ -1,3 +1,3 @@
// v0.8.0-rc2
// v0.8.0-rc3
// See .env.test.example for an example of the '.env.test' file.
require('dotenv').config({ path: './e2e/.env.test' });

View File

@@ -23,7 +23,7 @@ version: 1.8.10
# It is recommended to use it with quotes.
# renovate: image=ghcr.io/danny-avila/librechat
appVersion: "v0.8.0-rc2"
appVersion: "v0.8.0-rc3"
home: https://www.librechat.ai

498
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "LibreChat",
"version": "v0.8.0-rc2",
"version": "v0.8.0-rc3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "LibreChat",
"version": "v0.8.0-rc2",
"version": "v0.8.0-rc3",
"license": "ISC",
"workspaces": [
"api",
@@ -46,7 +46,7 @@
},
"api": {
"name": "@librechat/backend",
"version": "v0.8.0-rc2",
"version": "v0.8.0-rc3",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0",
@@ -2623,7 +2623,7 @@
},
"client": {
"name": "@librechat/frontend",
"version": "v0.8.0-rc2",
"version": "v0.8.0-rc3",
"license": "ISC",
"dependencies": {
"@ariakit/react": "^0.4.15",
@@ -17654,6 +17654,454 @@
"kuler": "^2.0.0"
}
},
"node_modules/@dicebear/adventurer": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.2.4.tgz",
"integrity": "sha512-Xvboay3VH1qe7lH17T+bA3qPawf5EjccssDiyhCX/VT0P21c65JyjTIUJV36Nsv08HKeyDscyP0kgt9nPTRKvA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/adventurer-neutral": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.2.4.tgz",
"integrity": "sha512-I9IrB4ZYbUHSOUpWoUbfX3vG8FrjcW8htoQ4bEOR7TYOKKE11Mo1nrGMuHZ7GPfwN0CQeK1YVJhWqLTmtYn7Pg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/avataaars": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.2.4.tgz",
"integrity": "sha512-QKNBtA/1QGEzR+JjS4XQyrFHYGbzdOp0oa6gjhGhUDrMegDFS8uyjdRfDQsFTebVkyLWjgBQKZEiDqKqHptB6A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/avataaars-neutral": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.2.4.tgz",
"integrity": "sha512-HtBvA7elRv50QTOOsBdtYB1GVimCpGEDlDgWsu1snL5Z3d1+3dIESoXQd3mXVvKTVT8Z9ciA4TEaF09WfxDjAA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/big-ears": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.2.4.tgz",
"integrity": "sha512-U33tbh7Io6wG6ViUMN5fkWPER7hPKMaPPaYgafaYQlCT4E7QPKF2u8X1XGag3jCKm0uf4SLXfuZ8v+YONcHmNQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/big-ears-neutral": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.2.4.tgz",
"integrity": "sha512-pPjYu80zMFl43A9sa5+tAKPkhp4n9nd7eN878IOrA1HAowh/XePh5JN8PTkNFS9eM+rnN9m8WX08XYFe30kLYw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/big-smile": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.2.4.tgz",
"integrity": "sha512-zeEfXOOXy7j9tfkPLzfQdLBPyQsctBetTdEfKRArc1k3RUliNPxfJG9j88+cXQC6GXrVW2pcT2X50NSPtugCFQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/bottts": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.2.4.tgz",
"integrity": "sha512-4CTqrnVg+NQm6lZ4UuCJish8gGWe8EqSJrzvHQRO5TEyAKjYxbTdVqejpkycG1xkawha4FfxsYgtlSx7UwoVMw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/bottts-neutral": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.2.4.tgz",
"integrity": "sha512-eMVdofdD/udHsKIaeWEXShDRtiwk7vp4FjY7l0f79vIzfhkIsXKEhPcnvHKOl/yoArlDVS3Uhgjj0crWTO9RJA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/collection": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.2.4.tgz",
"integrity": "sha512-I1wCUp0yu5qSIeMQHmDYXQIXKkKjcja/SYBxppPkYFXpR2alxb0k9/swFDdMbkY6a1c9AT1kI1y+Pg6ywQ2rTA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dicebear/adventurer": "9.2.4",
"@dicebear/adventurer-neutral": "9.2.4",
"@dicebear/avataaars": "9.2.4",
"@dicebear/avataaars-neutral": "9.2.4",
"@dicebear/big-ears": "9.2.4",
"@dicebear/big-ears-neutral": "9.2.4",
"@dicebear/big-smile": "9.2.4",
"@dicebear/bottts": "9.2.4",
"@dicebear/bottts-neutral": "9.2.4",
"@dicebear/croodles": "9.2.4",
"@dicebear/croodles-neutral": "9.2.4",
"@dicebear/dylan": "9.2.4",
"@dicebear/fun-emoji": "9.2.4",
"@dicebear/glass": "9.2.4",
"@dicebear/icons": "9.2.4",
"@dicebear/identicon": "9.2.4",
"@dicebear/initials": "9.2.4",
"@dicebear/lorelei": "9.2.4",
"@dicebear/lorelei-neutral": "9.2.4",
"@dicebear/micah": "9.2.4",
"@dicebear/miniavs": "9.2.4",
"@dicebear/notionists": "9.2.4",
"@dicebear/notionists-neutral": "9.2.4",
"@dicebear/open-peeps": "9.2.4",
"@dicebear/personas": "9.2.4",
"@dicebear/pixel-art": "9.2.4",
"@dicebear/pixel-art-neutral": "9.2.4",
"@dicebear/rings": "9.2.4",
"@dicebear/shapes": "9.2.4",
"@dicebear/thumbs": "9.2.4"
},
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/core": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.4.tgz",
"integrity": "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.11"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@dicebear/croodles": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.2.4.tgz",
"integrity": "sha512-CqT0NgVfm+5kd+VnjGY4WECNFeOrj5p7GCPTSEA7tCuN72dMQOX47P9KioD3wbExXYrIlJgOcxNrQeb/FMGc3A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/croodles-neutral": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.2.4.tgz",
"integrity": "sha512-8vAS9lIEKffSUVx256GSRAlisB8oMX38UcPWw72venO/nitLVsyZ6hZ3V7eBdII0Onrjqw1RDndslQODbVcpTw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/dylan": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.2.4.tgz",
"integrity": "sha512-tiih1358djAq0jDDzmW3N3S4C3ynC2yn4hhlTAq/MaUAQtAi47QxdHdFGdxH0HBMZKqA4ThLdVk3yVgN4xsukg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/fun-emoji": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.2.4.tgz",
"integrity": "sha512-Od729skczse1HvHekgEFv+mSuJKMC4sl5hENGi/izYNe6DZDqJrrD0trkGT/IVh/SLXUFbq1ZFY9I2LoUGzFZg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/glass": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.2.4.tgz",
"integrity": "sha512-5lxbJode1t99eoIIgW0iwZMoZU4jNMJv/6vbsgYUhAslYFX5zP0jVRscksFuo89TTtS7YKqRqZAL3eNhz4bTDw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/icons": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.2.4.tgz",
"integrity": "sha512-bRsK1qj8u9Z76xs8XhXlgVr/oHh68tsHTJ/1xtkX9DeTQTSamo2tS26+r231IHu+oW3mePtFnwzdG9LqEPRd4A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/identicon": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.2.4.tgz",
"integrity": "sha512-R9nw/E8fbu9HltHOqI9iL/o9i7zM+2QauXWMreQyERc39oGR9qXiwgBxsfYGcIS4C85xPyuL5B3I2RXrLBlJPg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/initials": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.4.tgz",
"integrity": "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/lorelei": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.2.4.tgz",
"integrity": "sha512-eS4mPYUgDpo89HvyFAx/kgqSSKh8W4zlUA8QJeIUCWTB0WpQmeqkSgIyUJjGDYSrIujWi+zEhhckksM5EwW0Dg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/lorelei-neutral": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.2.4.tgz",
"integrity": "sha512-bWq2/GonbcJULtT+B/MGcM2UnA7kBQoH+INw8/oW83WI3GNTZ6qEwe3/W4QnCgtSOhUsuwuiSULguAFyvtkOZQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/micah": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.2.4.tgz",
"integrity": "sha512-XNWJ8Mx+pncIV8Ye0XYc/VkMiax8kTxcP3hLTC5vmELQyMSLXzg/9SdpI+W/tCQghtPZRYTT3JdY9oU9IUlP2g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/miniavs": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.2.4.tgz",
"integrity": "sha512-k7IYTAHE/4jSO6boMBRrNlqPT3bh7PLFM1atfe0nOeCDwmz/qJUBP3HdONajbf3fmo8f2IZYhELrNWTOE7Ox3Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/notionists": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.2.4.tgz",
"integrity": "sha512-zcvpAJ93EfC0xQffaPZQuJPShwPhnu9aTcoPsaYGmw0oEDLcv2XYmDhUUdX84QYCn6LtCZH053rHLVazRW+OGw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/notionists-neutral": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.2.4.tgz",
"integrity": "sha512-fskWzBVxQzJhCKqY24DGZbYHSBaauoRa1DgXM7+7xBuksH7mfbTmZTvnUAsAqJYBkla8IPb4ERKduDWtlWYYjQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/open-peeps": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.2.4.tgz",
"integrity": "sha512-s6nwdjXFsplqEI7imlsel4Gt6kFVJm6YIgtZSpry0UdwDoxUUudei5bn957j9lXwVpVUcRjJW+TuEKztYjXkKQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/personas": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.2.4.tgz",
"integrity": "sha512-JNim8RfZYwb0MfxW6DLVfvreCFIevQg+V225Xe5tDfbFgbcYEp4OU/KaiqqO2476OBjCw7i7/8USbv2acBhjwA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/pixel-art": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.2.4.tgz",
"integrity": "sha512-4Ao45asieswUdlCTBZqcoF/0zHR3OWUWB0Mvhlu9b1Fbc6IlPBiOfx2vsp6bnVGVnMag58tJLecx2omeXdECBQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/pixel-art-neutral": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.2.4.tgz",
"integrity": "sha512-ZITPLD1cPN4GjKkhWi80s7e5dcbXy34ijWlvmxbc4eb/V7fZSsyRa9EDUW3QStpo+xrCJLcLR+3RBE5iz0PC/A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/rings": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.2.4.tgz",
"integrity": "sha512-teZxELYyV2ogzgb5Mvtn/rHptT0HXo9SjUGS4A52mOwhIdHSGGU71MqA1YUzfae9yJThsw6K7Z9kzuY2LlZZHA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/shapes": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.2.4.tgz",
"integrity": "sha512-MhK9ZdFm1wUnH4zWeKPRMZ98UyApolf5OLzhCywfu38tRN6RVbwtBRHc/42ZwoN1JU1JgXr7hzjYucMqISHtbA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@dicebear/thumbs": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.2.4.tgz",
"integrity": "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"@dicebear/core": "^9.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.0.tgz",
@@ -31411,13 +31859,17 @@
}
},
"node_modules/cipher-base": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
"integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.6.tgz",
"integrity": "sha512-3Ek9H3X6pj5TgenXYtNWdaBon1tgYCaebd+XPg0keyjEbEfkD4KkmAxkQ/i1vYvxdcT5nscLBfq9VJRmCBcFSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"inherits": "^2.0.1",
"safe-buffer": "^5.0.1"
"inherits": "^2.0.4",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cjs-module-lexer": {
@@ -47184,16 +47636,24 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/sha.js": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
"version": "2.4.12",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz",
"integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
"dev": true,
"license": "(MIT AND BSD-3-Clause)",
"dependencies": {
"inherits": "^2.0.1",
"safe-buffer": "^5.0.1"
"inherits": "^2.0.4",
"safe-buffer": "^5.2.1",
"to-buffer": "^1.2.0"
},
"bin": {
"sha.js": "bin.js"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/shebang-command": {
@@ -51346,7 +51806,7 @@
},
"packages/api": {
"name": "@librechat/api",
"version": "1.3.3",
"version": "1.3.4",
"license": "ISC",
"devDependencies": {
"@babel/preset-env": "^7.21.5",
@@ -51472,7 +51932,7 @@
},
"packages/client": {
"name": "@librechat/client",
"version": "0.2.6",
"version": "0.2.7",
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^25.0.2",
@@ -51500,6 +51960,8 @@
"peerDependencies": {
"@ariakit/react": "^0.4.16",
"@ariakit/react-core": "^0.4.17",
"@dicebear/collection": "^9.2.2",
"@dicebear/core": "^9.2.2",
"@headlessui/react": "^2.1.2",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.0.2",
@@ -51771,7 +52233,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
"version": "0.8.003",
"version": "0.8.004",
"license": "ISC",
"dependencies": {
"axios": "^1.8.2",
@@ -51876,7 +52338,7 @@
},
"packages/data-schemas": {
"name": "@librechat/data-schemas",
"version": "0.0.18",
"version": "0.0.20",
"license": "MIT",
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "LibreChat",
"version": "v0.8.0-rc2",
"version": "v0.8.0-rc3",
"description": "",
"workspaces": [
"api",

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/api",
"version": "1.3.3",
"version": "1.3.4",
"type": "commonjs",
"description": "MCP services for LibreChat",
"main": "dist/index.js",

View File

@@ -1,7 +1,7 @@
import { logger } from '@librechat/data-schemas';
import { AccessRoleIds, ResourceType, PrincipalType, Constants } from 'librechat-data-provider';
import type { AccessRoleMethods, IAgent } from '@librechat/data-schemas';
import type { Model } from 'mongoose';
import type { Model, Mongoose, mongo } from 'mongoose';
const { GLOBAL_PROJECT_NAME } = Constants;
@@ -17,7 +17,8 @@ export interface MigrationCheckDbMethods {
}
export interface MigrationCheckParams {
db: MigrationCheckDbMethods;
mongoose: Mongoose;
methods: MigrationCheckDbMethods;
AgentModel: Model<IAgent>;
}
@@ -46,16 +47,44 @@ export interface MigrationCheckResult {
* This performs a dry-run check similar to the migration script
*/
export async function checkAgentPermissionsMigration({
db,
methods,
mongoose,
AgentModel,
}: MigrationCheckParams): Promise<MigrationCheckResult> {
logger.debug('Checking if agent permissions migration is needed');
try {
/** Ensurse `aclentries` collection exists for DocumentDB compatibility */
async function ensureCollectionExists(db: mongo.Db, collectionName: string) {
try {
const collections = await db.listCollections({ name: collectionName }).toArray();
if (collections.length === 0) {
await db.createCollection(collectionName);
logger.info(`Created collection: ${collectionName}`);
} else {
logger.debug(`Collection already exists: ${collectionName}`);
}
} catch (error) {
logger.error(`'Failed to check/create "${collectionName}" collection:`, error);
// If listCollections fails, try alternative approach
try {
// Try to access the collection directly - this will create it in MongoDB if it doesn't exist
await db.collection(collectionName).findOne({}, { projection: { _id: 1 } });
} catch (createError) {
logger.error(`Could not ensure collection ${collectionName} exists:`, createError);
}
}
}
const db = mongoose.connection.db;
if (db) {
await ensureCollectionExists(db, 'aclentries');
}
// Verify required roles exist
const ownerRole = await db.findRoleByIdentifier(AccessRoleIds.AGENT_OWNER);
const viewerRole = await db.findRoleByIdentifier(AccessRoleIds.AGENT_VIEWER);
const editorRole = await db.findRoleByIdentifier(AccessRoleIds.AGENT_EDITOR);
const ownerRole = await methods.findRoleByIdentifier(AccessRoleIds.AGENT_OWNER);
const viewerRole = await methods.findRoleByIdentifier(AccessRoleIds.AGENT_VIEWER);
const editorRole = await methods.findRoleByIdentifier(AccessRoleIds.AGENT_EDITOR);
if (!ownerRole || !viewerRole || !editorRole) {
logger.warn(
@@ -70,7 +99,7 @@ export async function checkAgentPermissionsMigration({
}
// Get global project agent IDs
const globalProject = await db.getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
const globalProject = await methods.getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
const globalAgentIds = new Set(globalProject?.agentIds || []);
// Find agents without ACL entries (no batching for efficiency on startup)

View File

@@ -6,8 +6,11 @@ import type { AzureOpenAIInput } from '@langchain/openai';
import type { OpenAI } from 'openai';
import type * as t from '~/types';
import { sanitizeModelName, constructAzureURL } from '~/utils/azure';
import { createFetch } from '~/utils/generators';
import { isEnabled } from '~/utils/common';
type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
export const knownOpenAIParams = new Set([
// Constructor/Instance Parameters
'model',
@@ -92,6 +95,7 @@ export function getOpenAIConfig(
const {
modelOptions: _modelOptions = {},
reverseProxyUrl,
directEndpoint,
defaultQuery,
headers,
proxy,
@@ -311,6 +315,13 @@ export function getOpenAIConfig(
llmConfig.modelKwargs = modelKwargs;
}
if (directEndpoint === true && configOptions?.baseURL != null) {
configOptions.fetch = createFetch({
directEndpoint: directEndpoint,
reverseProxyUrl: configOptions?.baseURL,
}) as unknown as Fetch;
}
const result: t.LLMConfigResult = {
llmConfig,
configOptions,

View File

@@ -1,5 +1,6 @@
/* MCP */
export * from './mcp/MCPManager';
export * from './mcp/connection';
export * from './mcp/oauth';
export * from './mcp/auth';
export * from './mcp/zod';

View File

@@ -28,6 +28,7 @@ export class MCPConnectionFactory {
protected readonly oauthStart?: (authURL: string) => Promise<void>;
protected readonly oauthEnd?: () => Promise<void>;
protected readonly returnOnOAuth?: boolean;
protected readonly connectionTimeout?: number;
/** Creates a new MCP connection with optional OAuth support */
static async create(
@@ -47,6 +48,7 @@ export class MCPConnectionFactory {
});
this.serverName = basic.serverName;
this.useOAuth = !!oauth?.useOAuth;
this.connectionTimeout = oauth?.connectionTimeout;
this.logPrefix = oauth?.user
? `[MCP][${basic.serverName}][${oauth.user.id}]`
: `[MCP][${basic.serverName}]`;
@@ -82,8 +84,9 @@ export class MCPConnectionFactory {
if (!this.tokenMethods?.findToken) return null;
try {
const flowId = MCPOAuthHandler.generateFlowId(this.userId!, this.serverName);
const tokens = await this.flowManager!.createFlowWithHandler(
`tokens:${this.userId}:${this.serverName}`,
flowId,
'mcp_get_tokens',
async () => {
return await MCPTokenStorage.getTokens({
@@ -203,7 +206,7 @@ export class MCPConnectionFactory {
/** Attempts to establish connection with timeout handling */
protected async attemptToConnect(connection: MCPConnection): Promise<void> {
const connectTimeout = this.serverConfig.initTimeout ?? 30000;
const connectTimeout = this.connectionTimeout ?? this.serverConfig.initTimeout ?? 30000;
const connectionTimeout = new Promise<void>((_, reject) =>
setTimeout(
() => reject(new Error(`Connection timeout after ${connectTimeout}ms`)),
@@ -347,6 +350,7 @@ export class MCPConnectionFactory {
newFlowId,
'mcp_oauth',
flowMetadata as FlowMetadata,
this.signal,
);
if (typeof this.oauthEnd === 'function') {
await this.oauthEnd();

View File

@@ -58,6 +58,21 @@ export class MCPManager extends UserConnectionManager {
public getAppToolFunctions(): t.LCAvailableTools | null {
return this.serversRegistry.toolFunctions!;
}
/** Returns all available tool functions from all connections available to user */
public async getAllToolFunctions(userId: string): Promise<t.LCAvailableTools | null> {
const allToolFunctions: t.LCAvailableTools = this.getAppToolFunctions() ?? {};
const userConnections = this.getUserConnections(userId);
if (!userConnections || userConnections.size === 0) {
return allToolFunctions;
}
for (const [serverName, connection] of userConnections.entries()) {
const toolFunctions = await this.serversRegistry.getToolFunctions(serverName, connection);
Object.assign(allToolFunctions, toolFunctions);
}
return allToolFunctions;
}
/**
* Get instructions for MCP servers
@@ -101,34 +116,36 @@ ${formattedInstructions}
Please follow these instructions when using tools from the respective MCP servers.`;
}
/** Loads tools from all app-level connections into the manifest. */
public async loadManifestTools({
serverToolsCallback,
getServerTools,
}: {
flowManager: FlowStateManager<MCPOAuthTokens | null>;
serverToolsCallback?: (serverName: string, tools: t.LCManifestTool[]) => Promise<void>;
getServerTools?: (serverName: string) => Promise<t.LCManifestTool[] | undefined>;
}): Promise<t.LCToolManifest> {
const mcpTools: t.LCManifestTool[] = [];
private async loadAppManifestTools(): Promise<t.LCManifestTool[]> {
const connections = await this.appConnections!.getAll();
return await this.loadManifestTools(connections);
}
private async loadUserManifestTools(userId: string): Promise<t.LCManifestTool[]> {
const connections = this.getUserConnections(userId);
return await this.loadManifestTools(connections);
}
public async loadAllManifestTools(userId: string): Promise<t.LCManifestTool[]> {
const appTools = await this.loadAppManifestTools();
const userTools = await this.loadUserManifestTools(userId);
return [...appTools, ...userTools];
}
/** Loads tools from all app-level connections into the manifest. */
private async loadManifestTools(
connections?: Map<string, MCPConnection> | null,
): Promise<t.LCToolManifest> {
const mcpTools: t.LCManifestTool[] = [];
if (!connections || connections.size === 0) {
return mcpTools;
}
for (const [serverName, connection] of connections.entries()) {
try {
if (!(await connection.isConnected())) {
logger.warn(
`[MCP][${serverName}] Connection not available for ${serverName} manifest tools.`,
);
if (typeof getServerTools !== 'function') {
logger.warn(
`[MCP][${serverName}] No \`getServerTools\` function provided, skipping tool loading.`,
);
continue;
}
const serverTools = await getServerTools(serverName);
if (serverTools && serverTools.length > 0) {
logger.info(`[MCP][${serverName}] Loaded tools from cache for manifest`);
mcpTools.push(...serverTools);
}
continue;
}
@@ -157,9 +174,6 @@ Please follow these instructions when using tools from the respective MCP server
mcpTools.push(manifestTool);
serverTools.push(manifestTool);
}
if (typeof serverToolsCallback === 'function') {
await serverToolsCallback(serverName, serverTools);
}
} catch (error) {
logger.error(`[MCP][${serverName}] Error fetching tools for manifest:`, error);
}

View File

@@ -118,7 +118,7 @@ export class MCPServersRegistry {
}
/** Converts server tools to LibreChat-compatible tool functions format */
private async getToolFunctions(
public async getToolFunctions(
serverName: string,
conn: MCPConnection,
): Promise<t.LCAvailableTools> {

View File

@@ -1,13 +1,8 @@
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { logger } from '@librechat/data-schemas';
import type { TokenMethods } from '@librechat/data-schemas';
import type { TUser } from 'librechat-data-provider';
import type { FlowStateManager } from '~/flow/manager';
import type { MCPOAuthTokens } from '~/mcp/oauth';
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory';
import { MCPServersRegistry } from '~/mcp/MCPServersRegistry';
import { MCPConnection } from './connection';
import type { RequestBody } from '~/types';
import type * as t from './types';
/**
@@ -44,8 +39,9 @@ export abstract class UserConnectionManager {
/** Gets or creates a connection for a specific user */
public async getUserConnection({
user,
serverName,
forceNew,
user,
flowManager,
customUserVars,
requestBody,
@@ -54,25 +50,18 @@ export abstract class UserConnectionManager {
oauthEnd,
signal,
returnOnOAuth = false,
connectionTimeout,
}: {
user: TUser;
serverName: string;
flowManager: FlowStateManager<MCPOAuthTokens | null>;
customUserVars?: Record<string, string>;
requestBody?: RequestBody;
tokenMethods?: TokenMethods;
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
signal?: AbortSignal;
returnOnOAuth?: boolean;
}): Promise<MCPConnection> {
forceNew?: boolean;
} & Omit<t.OAuthConnectionOptions, 'useOAuth'>): Promise<MCPConnection> {
const userId = user.id;
if (!userId) {
throw new McpError(ErrorCode.InvalidRequest, `[MCP] User object missing id property`);
}
const userServerMap = this.userConnections.get(userId);
let connection = userServerMap?.get(serverName);
let connection = forceNew ? undefined : userServerMap?.get(serverName);
const now = Date.now();
// Check if user is idle
@@ -131,6 +120,7 @@ export abstract class UserConnectionManager {
oauthEnd: oauthEnd,
returnOnOAuth: returnOnOAuth,
requestBody: requestBody,
connectionTimeout: connectionTimeout,
},
);

View File

@@ -45,7 +45,7 @@ describe('getUserMCPAuthMap', () => {
},
];
const tools = testCases.map((testCase) =>
const toolInstances = testCases.map((testCase) =>
createMockTool(testCase.normalizedToolName, testCase.originalName),
);
@@ -54,7 +54,7 @@ describe('getUserMCPAuthMap', () => {
await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
@@ -69,7 +69,7 @@ describe('getUserMCPAuthMap', () => {
describe('Edge Cases', () => {
it('should return empty object when no tools have mcpRawServerName', async () => {
const tools = [
const toolInstances = [
createMockTool('regular_tool', undefined, false),
createMockTool('another_tool', undefined, false),
createMockTool('test_mcp_Server_no_raw_name', undefined),
@@ -77,7 +77,7 @@ describe('getUserMCPAuthMap', () => {
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
@@ -104,14 +104,14 @@ describe('getUserMCPAuthMap', () => {
});
it('should handle database errors gracefully', async () => {
const tools = [createMockTool('test_mcp_Server1', 'Server1')];
const toolInstances = [createMockTool('test_mcp_Server1', 'Server1')];
const dbError = new Error('Database connection failed');
mockGetPluginAuthMap.mockRejectedValue(dbError);
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
@@ -119,18 +119,119 @@ describe('getUserMCPAuthMap', () => {
});
it('should handle non-Error exceptions gracefully', async () => {
const tools = [createMockTool('test_mcp_Server1', 'Server1')];
const toolInstances = [createMockTool('test_mcp_Server1', 'Server1')];
mockGetPluginAuthMap.mockRejectedValue('String error');
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({});
});
it('should handle mixed null/undefined values in tools array', async () => {
const tools = [
'test_mcp_Server1',
null,
'test_mcp_Server2',
undefined,
'regular_tool',
'test_mcp_Server3',
];
mockGetPluginAuthMap.mockResolvedValue({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
const result = await getUserMCPAuthMap({
userId: 'user123',
tools: tools as (string | undefined)[],
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(mockGetPluginAuthMap).toHaveBeenCalledWith({
userId: 'user123',
pluginKeys: ['mcp_Server1', 'mcp_Server2', 'mcp_Server3'],
throwError: false,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
});
it('should handle mixed null/undefined values in servers array', async () => {
const servers = ['Server1', null, 'Server2', undefined, 'Server3'];
mockGetPluginAuthMap.mockResolvedValue({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
const result = await getUserMCPAuthMap({
userId: 'user123',
servers: servers as (string | undefined)[],
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(mockGetPluginAuthMap).toHaveBeenCalledWith({
userId: 'user123',
pluginKeys: ['mcp_Server1', 'mcp_Server2', 'mcp_Server3'],
throwError: false,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
});
it('should handle mixed null/undefined values in toolInstances array', async () => {
const toolInstances = [
createMockTool('test_mcp_Server1', 'Server1'),
null,
createMockTool('test_mcp_Server2', 'Server2'),
undefined,
createMockTool('regular_tool', undefined, false),
createMockTool('test_mcp_Server3', 'Server3'),
];
mockGetPluginAuthMap.mockResolvedValue({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
const result = await getUserMCPAuthMap({
userId: 'user123',
toolInstances: toolInstances as (GenericTool | null)[],
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(mockGetPluginAuthMap).toHaveBeenCalledWith({
userId: 'user123',
pluginKeys: ['mcp_Server1', 'mcp_Server2', 'mcp_Server3'],
throwError: false,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});
expect(result).toEqual({
mcp_Server1: { API_KEY: 'key1' },
mcp_Server2: { API_KEY: 'key2' },
mcp_Server3: { API_KEY: 'key3' },
});
});
});
describe('Integration', () => {
@@ -138,7 +239,7 @@ describe('getUserMCPAuthMap', () => {
const originalServerName = 'Connector: Company';
const toolName = 'test_auth_mcp_Connector__Company';
const tools = [createMockTool(toolName, originalServerName)];
const toolInstances = [createMockTool(toolName, originalServerName)];
const mockCustomUserVars = {
'mcp_Connector: Company': {
@@ -151,7 +252,7 @@ describe('getUserMCPAuthMap', () => {
const result = await getUserMCPAuthMap({
userId: 'user123',
tools,
toolInstances,
findPluginAuthsByKeys: mockFindPluginAuthsByKeys,
});

View File

@@ -7,33 +7,56 @@ import { getPluginAuthMap } from '~/agents/auth';
export async function getUserMCPAuthMap({
userId,
tools,
servers,
toolInstances,
findPluginAuthsByKeys,
}: {
userId: string;
tools: GenericTool[] | undefined;
tools?: (string | undefined)[];
servers?: (string | undefined)[];
toolInstances?: (GenericTool | null)[];
findPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys'];
}) {
if (!tools || tools.length === 0) {
return {};
}
const uniqueMcpServers = new Set<string>();
for (const tool of tools) {
const mcpTool = tool as GenericTool & { mcpRawServerName?: string };
if (mcpTool.mcpRawServerName) {
uniqueMcpServers.add(`${Constants.mcp_prefix}${mcpTool.mcpRawServerName}`);
}
}
if (uniqueMcpServers.size === 0) {
return {};
}
const mcpPluginKeysToFetch = Array.from(uniqueMcpServers);
let allMcpCustomUserVars: Record<string, Record<string, string>> = {};
let mcpPluginKeysToFetch: string[] = [];
try {
const uniqueMcpServers = new Set<string>();
if (servers != null && servers.length) {
for (const serverName of servers) {
if (!serverName) {
continue;
}
uniqueMcpServers.add(`${Constants.mcp_prefix}${serverName}`);
}
} else if (tools != null && tools.length) {
for (const toolName of tools) {
if (!toolName) {
continue;
}
const delimiterIndex = toolName.indexOf(Constants.mcp_delimiter);
if (delimiterIndex === -1) continue;
const mcpServer = toolName.slice(delimiterIndex + Constants.mcp_delimiter.length);
if (!mcpServer) continue;
uniqueMcpServers.add(`${Constants.mcp_prefix}${mcpServer}`);
}
} else if (toolInstances != null && toolInstances.length) {
for (const tool of toolInstances) {
if (!tool) {
continue;
}
const mcpTool = tool as GenericTool & { mcpRawServerName?: string };
if (mcpTool.mcpRawServerName) {
uniqueMcpServers.add(`${Constants.mcp_prefix}${mcpTool.mcpRawServerName}`);
}
}
}
if (uniqueMcpServers.size === 0) {
return {};
}
mcpPluginKeysToFetch = Array.from(uniqueMcpServers);
allMcpCustomUserVars = await getPluginAuthMap({
userId,
pluginKeys: mcpPluginKeysToFetch,

View File

@@ -446,7 +446,7 @@ export class MCPConnection extends EventEmitter {
const serverUrl = this.url;
logger.debug(`${this.getLogPrefix()} Server URL for OAuth: ${serverUrl}`);
const oauthTimeout = this.options.initTimeout ?? 60000;
const oauthTimeout = this.options.initTimeout ?? 60000 * 2;
/** Promise that will resolve when OAuth is handled */
const oauthHandledPromise = new Promise<void>((resolve, reject) => {
let timeoutId: NodeJS.Timeout | null = null;

View File

@@ -134,4 +134,5 @@ export interface OAuthConnectionOptions {
oauthStart?: (authURL: string) => Promise<void>;
oauthEnd?: () => Promise<void>;
returnOnOAuth?: boolean;
connectionTimeout?: number;
}

View File

@@ -1,7 +1,7 @@
import { logger } from '@librechat/data-schemas';
import { AccessRoleIds, ResourceType, PrincipalType, Constants } from 'librechat-data-provider';
import type { AccessRoleMethods, IPromptGroupDocument } from '@librechat/data-schemas';
import type { Model } from 'mongoose';
import type { Model, Mongoose, mongo } from 'mongoose';
const { GLOBAL_PROJECT_NAME } = Constants;
@@ -17,7 +17,8 @@ export interface PromptMigrationCheckDbMethods {
}
export interface PromptMigrationCheckParams {
db: PromptMigrationCheckDbMethods;
mongoose: Mongoose;
methods: PromptMigrationCheckDbMethods;
PromptGroupModel: Model<IPromptGroupDocument>;
}
@@ -44,16 +45,45 @@ export interface PromptMigrationCheckResult {
* This performs a dry-run check similar to the migration script
*/
export async function checkPromptPermissionsMigration({
db,
methods,
mongoose,
PromptGroupModel,
}: PromptMigrationCheckParams): Promise<PromptMigrationCheckResult> {
logger.debug('Checking if prompt permissions migration is needed');
try {
/** Ensurse `aclentries` collection exists for DocumentDB compatibility */
async function ensureCollectionExists(db: mongo.Db, collectionName: string) {
try {
const collections = await db.listCollections({ name: collectionName }).toArray();
if (collections.length === 0) {
await db.createCollection(collectionName);
logger.info(`Created collection: ${collectionName}`);
} else {
logger.debug(`Collection already exists: ${collectionName}`);
}
} catch (error) {
logger.error(`'Failed to check/create "${collectionName}" collection:`, error);
// If listCollections fails, try alternative approach
try {
// Try to access the collection directly - this will create it in MongoDB if it doesn't exist
await db.collection(collectionName).findOne({}, { projection: { _id: 1 } });
} catch (createError) {
logger.error(`Could not ensure collection ${collectionName} exists:`, createError);
}
}
}
/** Native MongoDB database instance */
const db = mongoose.connection.db;
if (db) {
await ensureCollectionExists(db, 'aclentries');
}
// Verify required roles exist
const ownerRole = await db.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_OWNER);
const viewerRole = await db.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_VIEWER);
const editorRole = await db.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_EDITOR);
const ownerRole = await methods.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_OWNER);
const viewerRole = await methods.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_VIEWER);
const editorRole = await methods.findRoleByIdentifier(AccessRoleIds.PROMPTGROUP_EDITOR);
if (!ownerRole || !viewerRole || !editorRole) {
logger.warn(
@@ -66,8 +96,8 @@ export async function checkPromptPermissionsMigration({
};
}
// Get global project prompt group IDs
const globalProject = await db.getProjectByName(GLOBAL_PROJECT_NAME, ['promptGroupIds']);
/** Global project prompt group IDs */
const globalProject = await methods.getProjectByName(GLOBAL_PROJECT_NAME, ['promptGroupIds']);
const globalPromptGroupIds = new Set(
(globalProject?.promptGroupIds || []).map((id) => id.toString()),
);

View File

@@ -1,5 +1,6 @@
import { AuthType, Constants, EToolResources } from 'librechat-data-provider';
import type { TCustomConfig, TPlugin, FunctionTool } from 'librechat-data-provider';
import type { TCustomConfig, TPlugin } from 'librechat-data-provider';
import { LCAvailableTools, LCFunctionTool } from '~/mcp/types';
/**
* Filters out duplicate plugins from the list of plugins.
@@ -46,6 +47,62 @@ export const checkPluginAuth = (plugin?: TPlugin): boolean => {
});
};
/**
* Converts MCP function format tool to plugin format
* @param params
* @param params.toolKey
* @param params.toolData
* @param params.customConfig
* @returns
*/
export function convertMCPToolToPlugin({
toolKey,
toolData,
customConfig,
}: {
toolKey: string;
toolData: LCFunctionTool;
customConfig?: Partial<TCustomConfig> | null;
}): TPlugin | undefined {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
return;
}
const functionData = toolData.function;
const parts = toolKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
const plugin: TPlugin = {
/** Tool name without server suffix */
name: parts[0],
pluginKey: toolKey,
description: functionData.description || '',
authenticated: true,
icon: serverConfig?.iconPath,
};
if (!serverConfig?.customUserVars) {
/** `authConfig` for MCP tools */
plugin.authConfig = [];
return plugin;
}
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
plugin.authConfig = [];
} else {
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
}
return plugin;
}
/**
* Converts MCP function format tools to plugin format
* @param functionTools - Object with function format tools
@@ -56,7 +113,7 @@ export function convertMCPToolsToPlugins({
functionTools,
customConfig,
}: {
functionTools?: Record<string, FunctionTool>;
functionTools?: LCAvailableTools;
customConfig?: Partial<TCustomConfig> | null;
}): TPlugin[] | undefined {
if (!functionTools || typeof functionTools !== 'object') {
@@ -65,44 +122,10 @@ export function convertMCPToolsToPlugins({
const plugins: TPlugin[] = [];
for (const [toolKey, toolData] of Object.entries(functionTools)) {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
continue;
}
const functionData = toolData.function;
const parts = toolKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const serverConfig = customConfig?.mcpServers?.[serverName];
const plugin: TPlugin = {
/** Tool name without server suffix */
name: parts[0],
pluginKey: toolKey,
description: functionData.description || '',
authenticated: true,
icon: serverConfig?.iconPath,
};
if (!serverConfig?.customUserVars) {
/** `authConfig` for MCP tools */
plugin.authConfig = [];
const plugin = convertMCPToolToPlugin({ toolKey, toolData, customConfig });
if (plugin) {
plugins.push(plugin);
continue;
}
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
plugin.authConfig = [];
} else {
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
}
plugins.push(plugin);
}
return plugins;

View File

@@ -12,6 +12,7 @@ export type OpenAIParameters = z.infer<typeof openAISchema>;
*/
export interface OpenAIConfigOptions {
modelOptions?: Partial<OpenAIParameters>;
directEndpoint?: boolean;
reverseProxyUrl?: string;
defaultQuery?: Record<string, string | undefined>;
headers?: Record<string, string>;

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/client",
"version": "0.2.6",
"version": "0.2.7",
"description": "React components for LibreChat",
"main": "dist/index.js",
"module": "dist/index.es.js",
@@ -64,7 +64,9 @@
"react-hook-form": "^7.56.4",
"react-resizable-panels": "^3.0.2",
"react-textarea-autosize": "^8.4.0",
"tailwind-merge": "^1.9.1"
"tailwind-merge": "^1.9.1",
"@dicebear/core": "^9.2.2",
"@dicebear/collection": "^9.2.2"
},
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",

View File

@@ -0,0 +1,102 @@
import React, { useState, useMemo, useCallback } from 'react';
import type { TUser } from 'librechat-data-provider';
import { Skeleton } from './Skeleton';
import { useAvatar } from '~/hooks';
import { UserIcon } from '~/svgs';
export interface AvatarProps {
user?: TUser;
size?: number;
className?: string;
alt?: string;
showDefaultWhenEmpty?: boolean;
}
const Avatar: React.FC<AvatarProps> = ({
user,
size = 32,
className = '',
alt,
showDefaultWhenEmpty = true,
}) => {
const avatarSrc = useAvatar(user);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const avatarSeed = useMemo(
() => user?.avatar || user?.username || user?.email || '',
[user?.avatar, user?.username, user?.email],
);
const altText = useMemo(
() => alt || `${user?.name || user?.username || user?.email || ''}'s avatar`,
[alt, user?.name, user?.username, user?.email],
);
const imageSrc = useMemo(() => {
if (!avatarSeed || imageError) return '';
return (user?.avatar ?? '') || avatarSrc || '';
}, [user?.avatar, avatarSrc, avatarSeed, imageError]);
const handleImageLoad = useCallback(() => {
setImageLoaded(true);
}, []);
const handleImageError = useCallback(() => {
setImageError(true);
setImageLoaded(false);
}, []);
const DefaultAvatar = useCallback(
() => (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: `${size}px`,
height: `${size}px`,
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className={`relative flex items-center justify-center rounded-full p-1 text-text-primary ${className}`}
aria-hidden="true"
>
<UserIcon />
</div>
),
[size, className],
);
if (avatarSeed.length === 0 && showDefaultWhenEmpty) {
return <DefaultAvatar />;
}
if (avatarSeed.length > 0 && !imageError) {
return (
<div className="relative" style={{ width: `${size}px`, height: `${size}px` }}>
{!imageLoaded && (
<Skeleton className="rounded-full" style={{ width: `${size}px`, height: `${size}px` }} />
)}
<img
style={{
width: `${size}px`,
height: `${size}px`,
display: imageLoaded ? 'block' : 'none',
}}
className={`rounded-full ${className}`}
src={imageSrc}
alt={altText}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
);
}
if (imageError && showDefaultWhenEmpty) {
return <DefaultAvatar />;
}
return null;
};
export default Avatar;

View File

@@ -33,6 +33,7 @@ export * from './Resizable';
export * from './Select';
export { default as Radio } from './Radio';
export { default as Badge } from './Badge';
export { default as Avatar } from './Avatar';
export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown';
export { default as SplitText } from './SplitText';

View File

@@ -3,6 +3,7 @@
export type { TranslationKeys } from './useLocalize';
export { default as useToast } from './useToast';
export { default as useAvatar } from './useAvatar';
export { default as useCombobox } from './useCombobox';
export { default as useLocalize } from './useLocalize';
export { default as useMediaQuery } from './useMediaQuery';

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