diff --git a/.env.example b/.env.example index 1f300f2554..f1666fb763 100644 --- a/.env.example +++ b/.env.example @@ -163,10 +163,10 @@ GOOGLE_KEY=user_provided # GOOGLE_AUTH_HEADER=true # Gemini API (AI Studio) -# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash,gemini-2.0-flash-lite +# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite # Vertex AI -# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite-preview-06-17,gemini-2.0-flash-001,gemini-2.0-flash-lite-001 +# GOOGLE_MODELS=gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001 # GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001 @@ -196,7 +196,7 @@ GOOGLE_KEY=user_provided #============# OPENAI_API_KEY=user_provided -# OPENAI_MODELS=o1,o1-mini,o1-preview,gpt-4o,gpt-4.5-preview,chatgpt-4o-latest,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k +# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini DEBUG_OPENAI=false @@ -459,6 +459,9 @@ OPENID_CALLBACK_URL=/oauth/openid/callback OPENID_REQUIRED_ROLE= OPENID_REQUIRED_ROLE_TOKEN_KIND= OPENID_REQUIRED_ROLE_PARAMETER_PATH= +OPENID_ADMIN_ROLE= +OPENID_ADMIN_ROLE_PARAMETER_PATH= +OPENID_ADMIN_ROLE_TOKEN_KIND= # Set to determine which user info property returned from OpenID Provider to store as the User's username OPENID_USERNAME_CLAIM= # Set to determine which user info property returned from OpenID Provider to store as the User's name @@ -650,6 +653,12 @@ HELP_AND_FAQ_URL=https://librechat.ai # Google tag manager id #ANALYTICS_GTM_ID=user provided google tag manager id +# limit conversation file imports to a certain number of bytes in size to avoid the container +# maxing out memory limitations by unremarking this line and supplying a file size in bytes +# such as the below example of 250 mib +# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000 + + #===============# # REDIS Options # #===============# diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml new file mode 100644 index 0000000000..2afe68287e --- /dev/null +++ b/.github/workflows/cache-integration-tests.yml @@ -0,0 +1,78 @@ +name: Cache Integration Tests + +on: + pull_request: + branches: + - main + - dev + - release/* + paths: + - 'packages/api/src/cache/**' + - 'redis-config/**' + - '.github/workflows/cache-integration-tests.yml' + +jobs: + cache_integration_tests: + name: Run Cache Integration Tests + timeout-minutes: 30 + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install Redis tools + run: | + sudo apt-get update + sudo apt-get install -y redis-server redis-tools + + - name: Start Single Redis Instance + run: | + redis-server --daemonize yes --port 6379 + sleep 2 + # Verify single Redis is running + redis-cli -p 6379 ping || exit 1 + + - name: Start Redis Cluster + working-directory: redis-config + run: | + chmod +x start-cluster.sh stop-cluster.sh + ./start-cluster.sh + sleep 10 + # Verify cluster is running + redis-cli -p 7001 cluster info || exit 1 + redis-cli -p 7002 cluster info || exit 1 + redis-cli -p 7003 cluster info || exit 1 + + - name: Install dependencies + run: npm ci + + - name: Build packages + run: | + npm run build:data-provider + npm run build:data-schemas + npm run build:api + + - name: Run cache integration tests + working-directory: packages/api + env: + NODE_ENV: test + USE_REDIS: true + REDIS_URI: redis://127.0.0.1:6379 + REDIS_CLUSTER_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 + run: npm run test:cache:integration + + - name: Stop Redis Cluster + if: always() + working-directory: redis-config + run: ./stop-cluster.sh || true + + - name: Stop Single Redis Instance + if: always() + run: redis-cli -p 6379 shutdown || true \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 67f5b00272..23c736d1de 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,2 @@ -#!/usr/bin/env sh -set -e -. "$(dirname -- "$0")/_/husky.sh" [ -n "$CI" ] && exit 0 npx lint-staged --config ./.husky/lint-staged.config.js diff --git a/Dockerfile b/Dockerfile index 68f0b1cf52..138ed08f33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.8.0-rc4 +# v0.8.0 # Base node image FROM node:20-alpine AS node diff --git a/Dockerfile.multi b/Dockerfile.multi index 8013a3c659..41ff375c9b 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.8.0-rc4 +# v0.8.0 # Base for all builds FROM node:20-alpine AS base-min diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js index 834877bb42..43e546a0a3 100644 --- a/api/app/clients/AnthropicClient.js +++ b/api/app/clients/AnthropicClient.js @@ -1,4 +1,5 @@ const Anthropic = require('@anthropic-ai/sdk'); +const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { Constants, @@ -9,7 +10,7 @@ const { getResponseSender, validateVisionModel, } = require('librechat-data-provider'); -const { SplitStreamHandler: _Handler } = require('@librechat/agents'); +const { sleep, SplitStreamHandler: _Handler } = require('@librechat/agents'); const { Tokenizer, createFetch, @@ -31,9 +32,7 @@ const { } = require('./prompts'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); -const { sleep } = require('~/server/utils'); const BaseClient = require('./BaseClient'); -const { logger } = require('~/config'); const HUMAN_PROMPT = '\n\nHuman:'; const AI_PROMPT = '\n\nAssistant:'; diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 2458dc0ab3..5c6561396e 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -1,20 +1,29 @@ const crypto = require('crypto'); const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); -const { getBalanceConfig } = require('@librechat/api'); const { - supportsBalanceCheck, - isAgentsEndpoint, - isParamEndpoint, - EModelEndpoint, + getBalanceConfig, + extractFileContext, + encodeAndFormatAudios, + encodeAndFormatVideos, + encodeAndFormatDocuments, +} = require('@librechat/api'); +const { + Constants, + ErrorTypes, + FileSources, ContentTypes, excludedKeys, - ErrorTypes, - Constants, + EModelEndpoint, + isParamEndpoint, + isAgentsEndpoint, + supportsBalanceCheck, } = require('librechat-data-provider'); const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require('~/models'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { checkBalance } = require('~/models/balanceMethods'); const { truncateToolCallOutputs } = require('./prompts'); +const countTokens = require('~/server/utils/countTokens'); const { getFiles } = require('~/models/File'); const TextStream = require('./TextStream'); @@ -1198,8 +1207,135 @@ class BaseClient { return await this.sendCompletion(payload, opts); } + async addDocuments(message, attachments) { + const documentResult = await encodeAndFormatDocuments( + this.options.req, + attachments, + { + provider: this.options.agent?.provider, + useResponsesApi: this.options.agent?.model_parameters?.useResponsesApi, + }, + getStrategyFunctions, + ); + message.documents = + documentResult.documents && documentResult.documents.length + ? documentResult.documents + : undefined; + return documentResult.files; + } + + async addVideos(message, attachments) { + const videoResult = await encodeAndFormatVideos( + this.options.req, + attachments, + this.options.agent.provider, + getStrategyFunctions, + ); + message.videos = + videoResult.videos && videoResult.videos.length ? videoResult.videos : undefined; + return videoResult.files; + } + + async addAudios(message, attachments) { + const audioResult = await encodeAndFormatAudios( + this.options.req, + attachments, + this.options.agent.provider, + getStrategyFunctions, + ); + message.audios = + audioResult.audios && audioResult.audios.length ? audioResult.audios : undefined; + return audioResult.files; + } + + /** + * Extracts text context from attachments and sets it on the message. + * This handles text that was already extracted from files (OCR, transcriptions, document text, etc.) + * @param {TMessage} message - The message to add context to + * @param {MongoFile[]} attachments - Array of file attachments + * @returns {Promise} + */ + async addFileContextToMessage(message, attachments) { + const fileContext = await extractFileContext({ + attachments, + req: this.options?.req, + tokenCountFn: (text) => countTokens(text), + }); + + if (fileContext) { + message.fileContext = fileContext; + } + } + + async processAttachments(message, attachments) { + const categorizedAttachments = { + images: [], + videos: [], + audios: [], + documents: [], + }; + + const allFiles = []; + + for (const file of attachments) { + /** @type {FileSources} */ + const source = file.source ?? FileSources.local; + if (source === FileSources.text) { + allFiles.push(file); + continue; + } + if (file.embedded === true || file.metadata?.fileIdentifier != null) { + allFiles.push(file); + continue; + } + + if (file.type.startsWith('image/')) { + categorizedAttachments.images.push(file); + } else if (file.type === 'application/pdf') { + categorizedAttachments.documents.push(file); + allFiles.push(file); + } else if (file.type.startsWith('video/')) { + categorizedAttachments.videos.push(file); + allFiles.push(file); + } else if (file.type.startsWith('audio/')) { + categorizedAttachments.audios.push(file); + allFiles.push(file); + } + } + + const [imageFiles] = await Promise.all([ + categorizedAttachments.images.length > 0 + ? this.addImageURLs(message, categorizedAttachments.images) + : Promise.resolve([]), + categorizedAttachments.documents.length > 0 + ? this.addDocuments(message, categorizedAttachments.documents) + : Promise.resolve([]), + categorizedAttachments.videos.length > 0 + ? this.addVideos(message, categorizedAttachments.videos) + : Promise.resolve([]), + categorizedAttachments.audios.length > 0 + ? this.addAudios(message, categorizedAttachments.audios) + : Promise.resolve([]), + ]); + + allFiles.push(...imageFiles); + + const seenFileIds = new Set(); + const uniqueFiles = []; + + for (const file of allFiles) { + if (file.file_id && !seenFileIds.has(file.file_id)) { + seenFileIds.add(file.file_id); + uniqueFiles.push(file); + } else if (!file.file_id) { + uniqueFiles.push(file); + } + } + + return uniqueFiles; + } + /** - * * @param {TMessage[]} _messages * @returns {Promise} */ @@ -1248,7 +1384,8 @@ class BaseClient { {}, ); - await this.addImageURLs(message, files, this.visionMode); + await this.addFileContextToMessage(message, files); + await this.processAttachments(message, files); this.message_file_map[message.messageId] = files; return message; diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index 715f48ff5a..9322778e73 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -1,4 +1,6 @@ const { google } = require('googleapis'); +const { sleep } = require('@librechat/agents'); +const { logger } = require('@librechat/data-schemas'); const { getModelMaxTokens } = require('@librechat/api'); const { concat } = require('@langchain/core/utils/stream'); const { ChatVertexAI } = require('@langchain/google-vertexai'); @@ -22,8 +24,6 @@ const { } = require('librechat-data-provider'); const { encodeAndFormat } = require('~/server/services/Files/images'); const { spendTokens } = require('~/models/spendTokens'); -const { sleep } = require('~/server/utils'); -const { logger } = require('~/config'); const { formatMessage, createContextHandlers, diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 2b254036c5..1194474674 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -1,6 +1,6 @@ -const { OllamaClient } = require('./OllamaClient'); +const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); -const { SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents'); +const { sleep, SplitStreamHandler, CustomOpenAIClient: OpenAI } = require('@librechat/agents'); const { isEnabled, Tokenizer, @@ -34,16 +34,15 @@ const { createContextHandlers, } = require('./prompts'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); -const { addSpaceIfNeeded, sleep } = require('~/server/utils'); const { spendTokens } = require('~/models/spendTokens'); +const { addSpaceIfNeeded } = require('~/server/utils'); const { handleOpenAIErrors } = require('./tools/util'); +const { OllamaClient } = require('./OllamaClient'); const { summaryBuffer } = require('./memory'); const { runTitleChain } = require('./chains'); const { extractBaseURL } = require('~/utils'); const { tokenSplit } = require('./document'); const BaseClient = require('./BaseClient'); -const { createLLM } = require('./llm'); -const { logger } = require('~/config'); class OpenAIClient extends BaseClient { constructor(apiKey, options = {}) { @@ -614,65 +613,8 @@ class OpenAIClient extends BaseClient { return (reply ?? '').trim(); } - initializeLLM({ - model = openAISettings.model.default, - modelName, - temperature = 0.2, - max_tokens, - streaming, - }) { - const modelOptions = { - modelName: modelName ?? model, - temperature, - user: this.user, - }; - - if (max_tokens) { - modelOptions.max_tokens = max_tokens; - } - - const configOptions = {}; - - if (this.langchainProxy) { - configOptions.basePath = this.langchainProxy; - } - - if (this.useOpenRouter) { - configOptions.basePath = 'https://openrouter.ai/api/v1'; - configOptions.baseOptions = { - headers: { - 'HTTP-Referer': 'https://librechat.ai', - 'X-Title': 'LibreChat', - }, - }; - } - - const { headers } = this.options; - if (headers && typeof headers === 'object' && !Array.isArray(headers)) { - configOptions.baseOptions = { - headers: resolveHeaders({ - headers: { - ...headers, - ...configOptions?.baseOptions?.headers, - }, - }), - }; - } - - if (this.options.proxy) { - configOptions.httpAgent = new HttpsProxyAgent(this.options.proxy); - configOptions.httpsAgent = new HttpsProxyAgent(this.options.proxy); - } - - const llm = createLLM({ - modelOptions, - configOptions, - openAIApiKey: this.apiKey, - azure: this.azure, - streaming, - }); - - return llm; + initializeLLM() { + throw new Error('Deprecated'); } /** diff --git a/api/app/clients/TextStream.js b/api/app/clients/TextStream.js index 01809e87fa..ea3d8980e9 100644 --- a/api/app/clients/TextStream.js +++ b/api/app/clients/TextStream.js @@ -1,5 +1,5 @@ const { Readable } = require('stream'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); class TextStream extends Readable { constructor(text, options = {}) { diff --git a/api/app/clients/agents/CustomAgent/outputParser.js b/api/app/clients/agents/CustomAgent/outputParser.js index 9d849519f5..f75200e692 100644 --- a/api/app/clients/agents/CustomAgent/outputParser.js +++ b/api/app/clients/agents/CustomAgent/outputParser.js @@ -1,5 +1,5 @@ +const { logger } = require('@librechat/data-schemas'); const { ZeroShotAgentOutputParser } = require('langchain/agents'); -const { logger } = require('~/config'); class CustomOutputParser extends ZeroShotAgentOutputParser { constructor(fields) { diff --git a/api/app/clients/chains/runTitleChain.js b/api/app/clients/chains/runTitleChain.js index a020ffb8e3..5c3d9738e1 100644 --- a/api/app/clients/chains/runTitleChain.js +++ b/api/app/clients/chains/runTitleChain.js @@ -1,7 +1,7 @@ const { z } = require('zod'); +const { logger } = require('@librechat/data-schemas'); const { langPrompt, createTitlePrompt, escapeBraces, getSnippet } = require('../prompts'); const { createStructuredOutputChainFromZod } = require('langchain/chains/openai_functions'); -const { logger } = require('~/config'); const langSchema = z.object({ language: z.string().describe('The language of the input text (full noun, no abbreviations).'), diff --git a/api/app/clients/llm/createLLM.js b/api/app/clients/llm/createLLM.js deleted file mode 100644 index 846c4d8e9c..0000000000 --- a/api/app/clients/llm/createLLM.js +++ /dev/null @@ -1,81 +0,0 @@ -const { ChatOpenAI } = require('@langchain/openai'); -const { isEnabled, sanitizeModelName, constructAzureURL } = require('@librechat/api'); - -/** - * Creates a new instance of a language model (LLM) for chat interactions. - * - * @param {Object} options - The options for creating the LLM. - * @param {ModelOptions} options.modelOptions - The options specific to the model, including modelName, temperature, presence_penalty, frequency_penalty, and other model-related settings. - * @param {ConfigOptions} options.configOptions - Configuration options for the API requests, including proxy settings and custom headers. - * @param {Callbacks} [options.callbacks] - Callback functions for managing the lifecycle of the LLM, including token buffers, context, and initial message count. - * @param {boolean} [options.streaming=false] - Determines if the LLM should operate in streaming mode. - * @param {string} options.openAIApiKey - The API key for OpenAI, used for authentication. - * @param {AzureOptions} [options.azure={}] - Optional Azure-specific configurations. If provided, Azure configurations take precedence over OpenAI configurations. - * - * @returns {ChatOpenAI} An instance of the ChatOpenAI class, configured with the provided options. - * - * @example - * const llm = createLLM({ - * modelOptions: { modelName: 'gpt-4o-mini', temperature: 0.2 }, - * configOptions: { basePath: 'https://example.api/path' }, - * callbacks: { onMessage: handleMessage }, - * openAIApiKey: 'your-api-key' - * }); - */ -function createLLM({ - modelOptions, - configOptions, - callbacks, - streaming = false, - openAIApiKey, - azure = {}, -}) { - let credentials = { openAIApiKey }; - let configuration = { - apiKey: openAIApiKey, - ...(configOptions.basePath && { baseURL: configOptions.basePath }), - }; - - /** @type {AzureOptions} */ - let azureOptions = {}; - if (azure) { - const useModelName = isEnabled(process.env.AZURE_USE_MODEL_AS_DEPLOYMENT_NAME); - - credentials = {}; - configuration = {}; - azureOptions = azure; - - azureOptions.azureOpenAIApiDeploymentName = useModelName - ? sanitizeModelName(modelOptions.modelName) - : azureOptions.azureOpenAIApiDeploymentName; - } - - if (azure && process.env.AZURE_OPENAI_DEFAULT_MODEL) { - modelOptions.modelName = process.env.AZURE_OPENAI_DEFAULT_MODEL; - } - - if (azure && configOptions.basePath) { - const azureURL = constructAzureURL({ - baseURL: configOptions.basePath, - azureOptions, - }); - azureOptions.azureOpenAIBasePath = azureURL.split( - `/${azureOptions.azureOpenAIApiDeploymentName}`, - )[0]; - } - - return new ChatOpenAI( - { - streaming, - credentials, - configuration, - ...azureOptions, - ...modelOptions, - ...credentials, - callbacks, - }, - configOptions, - ); -} - -module.exports = createLLM; diff --git a/api/app/clients/llm/index.js b/api/app/clients/llm/index.js index d03e1cda4d..c7770ce103 100644 --- a/api/app/clients/llm/index.js +++ b/api/app/clients/llm/index.js @@ -1,7 +1,5 @@ -const createLLM = require('./createLLM'); const createCoherePayload = require('./createCoherePayload'); module.exports = { - createLLM, createCoherePayload, }; diff --git a/api/app/clients/memory/summaryBuffer.demo.js b/api/app/clients/memory/summaryBuffer.demo.js deleted file mode 100644 index fc575c3032..0000000000 --- a/api/app/clients/memory/summaryBuffer.demo.js +++ /dev/null @@ -1,31 +0,0 @@ -require('dotenv').config(); -const { ChatOpenAI } = require('@langchain/openai'); -const { getBufferString, ConversationSummaryBufferMemory } = require('langchain/memory'); - -const chatPromptMemory = new ConversationSummaryBufferMemory({ - llm: new ChatOpenAI({ modelName: 'gpt-4o-mini', temperature: 0 }), - maxTokenLimit: 10, - returnMessages: true, -}); - -(async () => { - await chatPromptMemory.saveContext({ input: 'hi my name\'s Danny' }, { output: 'whats up' }); - await chatPromptMemory.saveContext({ input: 'not much you' }, { output: 'not much' }); - await chatPromptMemory.saveContext( - { input: 'are you excited for the olympics?' }, - { output: 'not really' }, - ); - - // We can also utilize the predict_new_summary method directly. - const messages = await chatPromptMemory.chatHistory.getMessages(); - console.log('MESSAGES\n\n'); - console.log(JSON.stringify(messages)); - const previous_summary = ''; - const predictSummary = await chatPromptMemory.predictNewSummary(messages, previous_summary); - console.log('SUMMARY\n\n'); - console.log(JSON.stringify(getBufferString([{ role: 'system', content: predictSummary }]))); - - // const { history } = await chatPromptMemory.loadMemoryVariables({}); - // console.log('HISTORY\n\n'); - // console.log(JSON.stringify(history)); -})(); diff --git a/api/app/clients/memory/summaryBuffer.js b/api/app/clients/memory/summaryBuffer.js index 0555fc214e..84ff34d151 100644 --- a/api/app/clients/memory/summaryBuffer.js +++ b/api/app/clients/memory/summaryBuffer.js @@ -1,7 +1,7 @@ +const { logger } = require('@librechat/data-schemas'); const { ConversationSummaryBufferMemory, ChatMessageHistory } = require('langchain/memory'); const { formatLangChainMessages, SUMMARY_PROMPT } = require('../prompts'); const { predictNewSummary } = require('../chains'); -const { logger } = require('~/config'); const createSummaryBufferMemory = ({ llm, prompt, messages, ...rest }) => { const chatHistory = new ChatMessageHistory(messages); diff --git a/api/app/clients/output_parsers/addImages.js b/api/app/clients/output_parsers/addImages.js index 7bef60259c..4b5019279a 100644 --- a/api/app/clients/output_parsers/addImages.js +++ b/api/app/clients/output_parsers/addImages.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); /** * The `addImages` function corrects any erroneous image URLs in the `responseMessage.text` diff --git a/api/app/clients/prompts/artifacts.js b/api/app/clients/prompts/artifacts.js index b907a16b56..915ccae629 100644 --- a/api/app/clients/prompts/artifacts.js +++ b/api/app/clients/prompts/artifacts.js @@ -3,6 +3,7 @@ const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider'); const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate'); const { components } = require('~/app/clients/prompts/shadcn-docs/components'); +/** @deprecated */ // eslint-disable-next-line no-unused-vars const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations. @@ -115,6 +116,7 @@ Here are some examples of correct usage of artifacts: `; + const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations. Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity. @@ -165,6 +167,10 @@ Artifacts are for substantial, self-contained content that users might modify or - SVG: "image/svg+xml" - The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags. - The assistant should specify the viewbox of the SVG rather than defining a width/height + - Markdown: "text/markdown" or "text/md" + - The user interface will render Markdown content placed within the artifact tags. + - Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more. + - Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content. - Mermaid Diagrams: "application/vnd.mermaid" - The user interface will render Mermaid diagrams placed within the artifact tags. - React Components: "application/vnd.react" @@ -366,6 +372,10 @@ Artifacts are for substantial, self-contained content that users might modify or - SVG: "image/svg+xml" - The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags. - The assistant should specify the viewbox of the SVG rather than defining a width/height + - Markdown: "text/markdown" or "text/md" + - The user interface will render Markdown content placed within the artifact tags. + - Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more. + - Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content. - Mermaid Diagrams: "application/vnd.mermaid" - The user interface will render Mermaid diagrams placed within the artifact tags. - React Components: "application/vnd.react" diff --git a/api/app/clients/tools/structured/AzureAISearch.js b/api/app/clients/tools/structured/AzureAISearch.js index e25da94426..55af3cdff5 100644 --- a/api/app/clients/tools/structured/AzureAISearch.js +++ b/api/app/clients/tools/structured/AzureAISearch.js @@ -1,7 +1,7 @@ const { z } = require('zod'); const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { SearchClient, AzureKeyCredential } = require('@azure/search-documents'); -const { logger } = require('~/config'); class AzureAISearch extends Tool { // Constants for default values @@ -18,7 +18,7 @@ class AzureAISearch extends Tool { super(); this.name = 'azure-ai-search'; this.description = - 'Use the \'azure-ai-search\' tool to retrieve search results relevant to your input'; + "Use the 'azure-ai-search' tool to retrieve search results relevant to your input"; /* Used to initialize the Tool without necessary variables. */ this.override = fields.override ?? false; diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index db6b4e63e4..d92388b320 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -1,9 +1,8 @@ const { z } = require('zod'); const path = require('path'); const OpenAI = require('openai'); -const fetch = require('node-fetch'); const { v4: uuidv4 } = require('uuid'); -const { ProxyAgent } = require('undici'); +const { ProxyAgent, fetch } = require('undici'); const { Tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); const { getImageBasename } = require('@librechat/api'); diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js index 80f9772200..9fa08a0343 100644 --- a/api/app/clients/tools/structured/FluxAPI.js +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -3,12 +3,12 @@ const axios = require('axios'); const fetch = require('node-fetch'); const { v4: uuidv4 } = require('uuid'); const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { FileContext, ContentTypes } = require('librechat-data-provider'); -const { logger } = require('~/config'); const displayMessage = - 'Flux displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.'; + "Flux displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user."; /** * FluxAPI - A tool for generating high-quality images from text prompts using the Flux API. diff --git a/api/app/clients/tools/structured/StableDiffusion.js b/api/app/clients/tools/structured/StableDiffusion.js index b08e42e7c1..05687923e6 100644 --- a/api/app/clients/tools/structured/StableDiffusion.js +++ b/api/app/clients/tools/structured/StableDiffusion.js @@ -6,9 +6,9 @@ const axios = require('axios'); const sharp = require('sharp'); const { v4: uuidv4 } = require('uuid'); const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { FileContext, ContentTypes } = require('librechat-data-provider'); const paths = require('~/config/paths'); -const { logger } = require('~/config'); const displayMessage = "Stable Diffusion displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user."; diff --git a/api/app/clients/tools/structured/TraversaalSearch.js b/api/app/clients/tools/structured/TraversaalSearch.js index e8ceeda134..d2ccc35c75 100644 --- a/api/app/clients/tools/structured/TraversaalSearch.js +++ b/api/app/clients/tools/structured/TraversaalSearch.js @@ -1,7 +1,7 @@ const { z } = require('zod'); const { Tool } = require('@langchain/core/tools'); +const { logger } = require('@librechat/data-schemas'); const { getEnvironmentVariable } = require('@langchain/core/utils/env'); -const { logger } = require('~/config'); /** * Tool for the Traversaal AI search API, Ares. @@ -21,7 +21,7 @@ class TraversaalSearch extends Tool { query: z .string() .describe( - 'A properly written sentence to be interpreted by an AI to search the web according to the user\'s request.', + "A properly written sentence to be interpreted by an AI to search the web according to the user's request.", ), }); @@ -38,7 +38,6 @@ class TraversaalSearch extends Tool { return apiKey; } - // eslint-disable-next-line no-unused-vars async _call({ query }, _runManager) { const body = { query: [query], diff --git a/api/app/clients/tools/structured/Wolfram.js b/api/app/clients/tools/structured/Wolfram.js index 1b426298cc..1f7fe6b1b7 100644 --- a/api/app/clients/tools/structured/Wolfram.js +++ b/api/app/clients/tools/structured/Wolfram.js @@ -1,8 +1,8 @@ /* eslint-disable no-useless-escape */ -const axios = require('axios'); const { z } = require('zod'); +const axios = require('axios'); const { Tool } = require('@langchain/core/tools'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); class WolframAlphaAPI extends Tool { constructor(fields) { diff --git a/api/app/clients/tools/util/handleOpenAIErrors.js b/api/app/clients/tools/util/handleOpenAIErrors.js index 490f3882a8..b3a7c2bfdc 100644 --- a/api/app/clients/tools/util/handleOpenAIErrors.js +++ b/api/app/clients/tools/util/handleOpenAIErrors.js @@ -1,5 +1,5 @@ const OpenAI = require('openai'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); /** * Handles errors that may occur when making requests to OpenAI's API. diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index b01aa2f7ef..b25372653c 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -30,7 +30,6 @@ jest.mock('~/server/services/Config', () => ({ }), })); -const { BaseLLM } = require('@langchain/openai'); const { Calculator } = require('@langchain/community/tools/calculator'); const { User } = require('~/db/models'); @@ -172,7 +171,6 @@ describe('Tool Handlers', () => { beforeAll(async () => { const toolMap = await loadTools({ user: fakeUser._id, - model: BaseLLM, tools: sampleTools, returnMap: true, useSpecs: true, @@ -266,7 +264,6 @@ describe('Tool Handlers', () => { it('returns an empty object when no tools are requested', async () => { toolFunctions = await loadTools({ user: fakeUser._id, - model: BaseLLM, returnMap: true, useSpecs: true, }); @@ -276,7 +273,6 @@ describe('Tool Handlers', () => { process.env.SD_WEBUI_URL = mockCredential; toolFunctions = await loadTools({ user: fakeUser._id, - model: BaseLLM, tools: ['stable-diffusion'], functions: true, returnMap: true, diff --git a/api/cache/cacheFactory.js b/api/cache/cacheFactory.js deleted file mode 100644 index bc361d6615..0000000000 --- a/api/cache/cacheFactory.js +++ /dev/null @@ -1,108 +0,0 @@ -const KeyvRedis = require('@keyv/redis').default; -const { Keyv } = require('keyv'); -const { RedisStore } = require('rate-limit-redis'); -const { Time } = require('librechat-data-provider'); -const { logger } = require('@librechat/data-schemas'); -const { RedisStore: ConnectRedis } = require('connect-redis'); -const MemoryStore = require('memorystore')(require('express-session')); -const { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } = require('./redisClients'); -const { cacheConfig } = require('./cacheConfig'); -const { violationFile } = require('./keyvFiles'); - -/** - * Creates a cache instance using Redis or a fallback store. Suitable for general caching needs. - * @param {string} namespace - The cache namespace. - * @param {number} [ttl] - Time to live for cache entries. - * @param {object} [fallbackStore] - Optional fallback store if Redis is not used. - * @returns {Keyv} Cache instance. - */ -const standardCache = (namespace, ttl = undefined, fallbackStore = undefined) => { - if ( - cacheConfig.USE_REDIS && - !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace) - ) { - try { - const keyvRedis = new KeyvRedis(keyvRedisClient); - const cache = new Keyv(keyvRedis, { namespace, ttl }); - keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX; - keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR; - - cache.on('error', (err) => { - logger.error(`Cache error in namespace ${namespace}:`, err); - }); - - return cache; - } catch (err) { - logger.error(`Failed to create Redis cache for namespace ${namespace}:`, err); - throw err; - } - } - if (fallbackStore) return new Keyv({ store: fallbackStore, namespace, ttl }); - return new Keyv({ namespace, ttl }); -}; - -/** - * Creates a cache instance for storing violation data. - * Uses a file-based fallback store if Redis is not enabled. - * @param {string} namespace - The cache namespace for violations. - * @param {number} [ttl] - Time to live for cache entries. - * @returns {Keyv} Cache instance for violations. - */ -const violationCache = (namespace, ttl = undefined) => { - return standardCache(`violations:${namespace}`, ttl, violationFile); -}; - -/** - * Creates a session cache instance using Redis or in-memory store. - * @param {string} namespace - The session namespace. - * @param {number} [ttl] - Time to live for session entries. - * @returns {MemoryStore | ConnectRedis} Session store instance. - */ -const sessionCache = (namespace, ttl = undefined) => { - namespace = namespace.endsWith(':') ? namespace : `${namespace}:`; - if (!cacheConfig.USE_REDIS) return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY }); - const store = new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace }); - if (ioredisClient) { - ioredisClient.on('error', (err) => { - logger.error(`Session store Redis error for namespace ${namespace}:`, err); - }); - } - return store; -}; - -/** - * Creates a rate limiter cache using Redis. - * @param {string} prefix - The key prefix for rate limiting. - * @returns {RedisStore|undefined} RedisStore instance or undefined if Redis is not used. - */ -const limiterCache = (prefix) => { - if (!prefix) throw new Error('prefix is required'); - if (!cacheConfig.USE_REDIS) return undefined; - prefix = prefix.endsWith(':') ? prefix : `${prefix}:`; - - try { - if (!ioredisClient) { - logger.warn(`Redis client not available for rate limiter with prefix ${prefix}`); - return undefined; - } - - return new RedisStore({ sendCommand, prefix }); - } catch (err) { - logger.error(`Failed to create Redis rate limiter for prefix ${prefix}:`, err); - return undefined; - } -}; - -const sendCommand = (...args) => { - if (!ioredisClient) { - logger.warn('Redis client not available for command execution'); - return Promise.reject(new Error('Redis client not available')); - } - - return ioredisClient.call(...args).catch((err) => { - logger.error('Redis command execution failed:', err); - throw err; - }); -}; - -module.exports = { standardCache, sessionCache, violationCache, limiterCache }; diff --git a/api/cache/cacheFactory.spec.js b/api/cache/cacheFactory.spec.js deleted file mode 100644 index ce364a4a32..0000000000 --- a/api/cache/cacheFactory.spec.js +++ /dev/null @@ -1,432 +0,0 @@ -const { Time } = require('librechat-data-provider'); - -// Mock dependencies first -const mockKeyvRedis = { - namespace: '', - keyPrefixSeparator: '', -}; - -const mockKeyv = jest.fn().mockReturnValue({ - mock: 'keyv', - on: jest.fn(), -}); -const mockConnectRedis = jest.fn().mockReturnValue({ mock: 'connectRedis' }); -const mockMemoryStore = jest.fn().mockReturnValue({ mock: 'memoryStore' }); -const mockRedisStore = jest.fn().mockReturnValue({ mock: 'redisStore' }); - -const mockIoredisClient = { - call: jest.fn(), - on: jest.fn(), -}; - -const mockKeyvRedisClient = {}; -const mockViolationFile = {}; - -// Mock modules before requiring the main module -jest.mock('@keyv/redis', () => ({ - default: jest.fn().mockImplementation(() => mockKeyvRedis), -})); - -jest.mock('keyv', () => ({ - Keyv: mockKeyv, -})); - -jest.mock('./cacheConfig', () => ({ - cacheConfig: { - USE_REDIS: false, - REDIS_KEY_PREFIX: 'test', - FORCED_IN_MEMORY_CACHE_NAMESPACES: [], - }, -})); - -jest.mock('./redisClients', () => ({ - keyvRedisClient: mockKeyvRedisClient, - ioredisClient: mockIoredisClient, - GLOBAL_PREFIX_SEPARATOR: '::', -})); - -jest.mock('./keyvFiles', () => ({ - violationFile: mockViolationFile, -})); - -jest.mock('connect-redis', () => ({ RedisStore: mockConnectRedis })); - -jest.mock('memorystore', () => jest.fn(() => mockMemoryStore)); - -jest.mock('rate-limit-redis', () => ({ - RedisStore: mockRedisStore, -})); - -jest.mock('@librechat/data-schemas', () => ({ - logger: { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - }, -})); - -// Import after mocking -const { standardCache, sessionCache, violationCache, limiterCache } = require('./cacheFactory'); -const { cacheConfig } = require('./cacheConfig'); - -describe('cacheFactory', () => { - beforeEach(() => { - jest.clearAllMocks(); - - // Reset cache config mock - cacheConfig.USE_REDIS = false; - cacheConfig.REDIS_KEY_PREFIX = 'test'; - cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = []; - }); - - describe('redisCache', () => { - it('should create Redis cache when USE_REDIS is true', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'test-namespace'; - const ttl = 3600; - - standardCache(namespace, ttl); - - expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient); - expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl }); - expect(mockKeyvRedis.namespace).toBe(cacheConfig.REDIS_KEY_PREFIX); - expect(mockKeyvRedis.keyPrefixSeparator).toBe('::'); - }); - - it('should create Redis cache with undefined ttl when not provided', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'test-namespace'; - - standardCache(namespace); - - expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl: undefined }); - }); - - it('should use fallback store when USE_REDIS is false and fallbackStore is provided', () => { - cacheConfig.USE_REDIS = false; - const namespace = 'test-namespace'; - const ttl = 3600; - const fallbackStore = { some: 'store' }; - - standardCache(namespace, ttl, fallbackStore); - - expect(mockKeyv).toHaveBeenCalledWith({ store: fallbackStore, namespace, ttl }); - }); - - it('should create default Keyv instance when USE_REDIS is false and no fallbackStore', () => { - cacheConfig.USE_REDIS = false; - const namespace = 'test-namespace'; - const ttl = 3600; - - standardCache(namespace, ttl); - - expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl }); - }); - - it('should handle namespace and ttl as undefined', () => { - cacheConfig.USE_REDIS = false; - - standardCache(); - - expect(mockKeyv).toHaveBeenCalledWith({ namespace: undefined, ttl: undefined }); - }); - - it('should use fallback when namespace is in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => { - cacheConfig.USE_REDIS = true; - cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['forced-memory']; - const namespace = 'forced-memory'; - const ttl = 3600; - - standardCache(namespace, ttl); - - expect(require('@keyv/redis').default).not.toHaveBeenCalled(); - expect(mockKeyv).toHaveBeenCalledWith({ namespace, ttl }); - }); - - it('should use Redis when namespace is not in FORCED_IN_MEMORY_CACHE_NAMESPACES', () => { - cacheConfig.USE_REDIS = true; - cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = ['other-namespace']; - const namespace = 'test-namespace'; - const ttl = 3600; - - standardCache(namespace, ttl); - - expect(require('@keyv/redis').default).toHaveBeenCalledWith(mockKeyvRedisClient); - expect(mockKeyv).toHaveBeenCalledWith(mockKeyvRedis, { namespace, ttl }); - }); - - it('should throw error when Redis cache creation fails', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'test-namespace'; - const ttl = 3600; - const testError = new Error('Redis connection failed'); - - const KeyvRedis = require('@keyv/redis').default; - KeyvRedis.mockImplementationOnce(() => { - throw testError; - }); - - expect(() => standardCache(namespace, ttl)).toThrow('Redis connection failed'); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.error).toHaveBeenCalledWith( - `Failed to create Redis cache for namespace ${namespace}:`, - testError, - ); - - expect(mockKeyv).not.toHaveBeenCalled(); - }); - }); - - describe('violationCache', () => { - it('should create violation cache with prefixed namespace', () => { - const namespace = 'test-violations'; - const ttl = 7200; - - // We can't easily mock the internal redisCache call since it's in the same module - // But we can test that the function executes without throwing - expect(() => violationCache(namespace, ttl)).not.toThrow(); - }); - - it('should create violation cache with undefined ttl', () => { - const namespace = 'test-violations'; - - violationCache(namespace); - - // The function should call redisCache with violations: prefixed namespace - // Since we can't easily mock the internal redisCache call, we test the behavior - expect(() => violationCache(namespace)).not.toThrow(); - }); - - it('should handle undefined namespace', () => { - expect(() => violationCache(undefined)).not.toThrow(); - }); - }); - - describe('sessionCache', () => { - it('should return MemoryStore when USE_REDIS is false', () => { - cacheConfig.USE_REDIS = false; - const namespace = 'sessions'; - const ttl = 86400; - - const result = sessionCache(namespace, ttl); - - expect(mockMemoryStore).toHaveBeenCalledWith({ ttl, checkPeriod: Time.ONE_DAY }); - expect(result).toBe(mockMemoryStore()); - }); - - it('should return ConnectRedis when USE_REDIS is true', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - const ttl = 86400; - - const result = sessionCache(namespace, ttl); - - expect(mockConnectRedis).toHaveBeenCalledWith({ - client: mockIoredisClient, - ttl, - prefix: `${namespace}:`, - }); - expect(result).toBe(mockConnectRedis()); - }); - - it('should add colon to namespace if not present', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - - sessionCache(namespace); - - expect(mockConnectRedis).toHaveBeenCalledWith({ - client: mockIoredisClient, - ttl: undefined, - prefix: 'sessions:', - }); - }); - - it('should not add colon to namespace if already present', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions:'; - - sessionCache(namespace); - - expect(mockConnectRedis).toHaveBeenCalledWith({ - client: mockIoredisClient, - ttl: undefined, - prefix: 'sessions:', - }); - }); - - it('should handle undefined ttl', () => { - cacheConfig.USE_REDIS = false; - const namespace = 'sessions'; - - sessionCache(namespace); - - expect(mockMemoryStore).toHaveBeenCalledWith({ - ttl: undefined, - checkPeriod: Time.ONE_DAY, - }); - }); - - it('should throw error when ConnectRedis constructor fails', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - const ttl = 86400; - - // Mock ConnectRedis to throw an error during construction - const redisError = new Error('Redis connection failed'); - mockConnectRedis.mockImplementationOnce(() => { - throw redisError; - }); - - // The error should propagate up, not be caught - expect(() => sessionCache(namespace, ttl)).toThrow('Redis connection failed'); - - // Verify that MemoryStore was NOT used as fallback - expect(mockMemoryStore).not.toHaveBeenCalled(); - }); - - it('should register error handler but let errors propagate to Express', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - - // Create a mock session store with middleware methods - const mockSessionStore = { - get: jest.fn(), - set: jest.fn(), - destroy: jest.fn(), - }; - mockConnectRedis.mockReturnValue(mockSessionStore); - - const store = sessionCache(namespace); - - // Verify error handler was registered - expect(mockIoredisClient.on).toHaveBeenCalledWith('error', expect.any(Function)); - - // Get the error handler - const errorHandler = mockIoredisClient.on.mock.calls.find((call) => call[0] === 'error')[1]; - - // Simulate an error from Redis during a session operation - const redisError = new Error('Socket closed unexpectedly'); - - // The error handler should log but not swallow the error - const { logger } = require('@librechat/data-schemas'); - errorHandler(redisError); - - expect(logger.error).toHaveBeenCalledWith( - `Session store Redis error for namespace ${namespace}::`, - redisError, - ); - - // Now simulate what happens when session middleware tries to use the store - const callback = jest.fn(); - mockSessionStore.get.mockImplementation((sid, cb) => { - cb(new Error('Redis connection lost')); - }); - - // Call the store's get method (as Express session would) - store.get('test-session-id', callback); - - // The error should be passed to the callback, not swallowed - expect(callback).toHaveBeenCalledWith(new Error('Redis connection lost')); - }); - - it('should handle null ioredisClient gracefully', () => { - cacheConfig.USE_REDIS = true; - const namespace = 'sessions'; - - // Temporarily set ioredisClient to null (simulating connection not established) - const originalClient = require('./redisClients').ioredisClient; - require('./redisClients').ioredisClient = null; - - // ConnectRedis might accept null client but would fail on first use - // The important thing is it doesn't throw uncaught exceptions during construction - const store = sessionCache(namespace); - expect(store).toBeDefined(); - - // Restore original client - require('./redisClients').ioredisClient = originalClient; - }); - }); - - describe('limiterCache', () => { - it('should return undefined when USE_REDIS is false', () => { - cacheConfig.USE_REDIS = false; - const result = limiterCache('prefix'); - - expect(result).toBeUndefined(); - }); - - it('should return RedisStore when USE_REDIS is true', () => { - cacheConfig.USE_REDIS = true; - const result = limiterCache('rate-limit'); - - expect(mockRedisStore).toHaveBeenCalledWith({ - sendCommand: expect.any(Function), - prefix: `rate-limit:`, - }); - expect(result).toBe(mockRedisStore()); - }); - - it('should add colon to prefix if not present', () => { - cacheConfig.USE_REDIS = true; - limiterCache('rate-limit'); - - expect(mockRedisStore).toHaveBeenCalledWith({ - sendCommand: expect.any(Function), - prefix: 'rate-limit:', - }); - }); - - it('should not add colon to prefix if already present', () => { - cacheConfig.USE_REDIS = true; - limiterCache('rate-limit:'); - - expect(mockRedisStore).toHaveBeenCalledWith({ - sendCommand: expect.any(Function), - prefix: 'rate-limit:', - }); - }); - - it('should pass sendCommand function that calls ioredisClient.call', async () => { - cacheConfig.USE_REDIS = true; - mockIoredisClient.call.mockResolvedValue('test-value'); - - limiterCache('rate-limit'); - - const sendCommandCall = mockRedisStore.mock.calls[0][0]; - const sendCommand = sendCommandCall.sendCommand; - - // Test that sendCommand properly delegates to ioredisClient.call - const args = ['GET', 'test-key']; - const result = await sendCommand(...args); - - expect(mockIoredisClient.call).toHaveBeenCalledWith(...args); - expect(result).toBe('test-value'); - }); - - it('should handle sendCommand errors properly', async () => { - cacheConfig.USE_REDIS = true; - - // Mock the call method to reject with an error - const testError = new Error('Redis error'); - mockIoredisClient.call.mockRejectedValue(testError); - - limiterCache('rate-limit'); - - const sendCommandCall = mockRedisStore.mock.calls[0][0]; - const sendCommand = sendCommandCall.sendCommand; - - // Test that sendCommand properly handles errors - const args = ['GET', 'test-key']; - - await expect(sendCommand(...args)).rejects.toThrow('Redis error'); - expect(mockIoredisClient.call).toHaveBeenCalledWith(...args); - }); - - it('should handle undefined prefix', () => { - cacheConfig.USE_REDIS = true; - expect(() => limiterCache()).toThrow('prefix is required'); - }); - }); -}); diff --git a/api/cache/clearPendingReq.js b/api/cache/clearPendingReq.js index 54db8e9690..b4329b1f06 100644 --- a/api/cache/clearPendingReq.js +++ b/api/cache/clearPendingReq.js @@ -1,5 +1,5 @@ +const { isEnabled } = require('@librechat/api'); const { Time, CacheKeys } = require('librechat-data-provider'); -const { isEnabled } = require('~/server/utils'); const getLogStores = require('./getLogStores'); const { USE_REDIS, LIMIT_CONCURRENT_MESSAGES } = process.env ?? {}; diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index aebe9e6c20..40aac08ee6 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -1,9 +1,13 @@ -const { cacheConfig } = require('./cacheConfig'); const { Keyv } = require('keyv'); -const { CacheKeys, ViolationTypes, Time } = require('librechat-data-provider'); -const { logFile } = require('./keyvFiles'); -const keyvMongo = require('./keyvMongo'); -const { standardCache, sessionCache, violationCache } = require('./cacheFactory'); +const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider'); +const { + logFile, + keyvMongo, + cacheConfig, + sessionCache, + standardCache, + violationCache, +} = require('@librechat/api'); const namespaces = { [ViolationTypes.GENERAL]: new Keyv({ store: logFile, namespace: 'violations' }), diff --git a/api/cache/index.js b/api/cache/index.js index bb1e774183..1bad242f13 100644 --- a/api/cache/index.js +++ b/api/cache/index.js @@ -1,5 +1,4 @@ -const keyvFiles = require('./keyvFiles'); const getLogStores = require('./getLogStores'); const logViolation = require('./logViolation'); -module.exports = { ...keyvFiles, getLogStores, logViolation }; +module.exports = { getLogStores, logViolation }; diff --git a/api/cache/keyvFiles.js b/api/cache/keyvFiles.js deleted file mode 100644 index 1476b60cb8..0000000000 --- a/api/cache/keyvFiles.js +++ /dev/null @@ -1,9 +0,0 @@ -const { KeyvFile } = require('keyv-file'); - -const logFile = new KeyvFile({ filename: './data/logs.json' }).setMaxListeners(20); -const violationFile = new KeyvFile({ filename: './data/violations.json' }).setMaxListeners(20); - -module.exports = { - logFile, - violationFile, -}; diff --git a/api/cache/logViolation.js b/api/cache/logViolation.js index 16dc2e4eae..1ff65c6ccd 100644 --- a/api/cache/logViolation.js +++ b/api/cache/logViolation.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('~/server/utils'); +const { isEnabled } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const getLogStores = require('./getLogStores'); const banViolation = require('./banViolation'); diff --git a/api/db/indexSync.js b/api/db/indexSync.js index 9a9ed9507a..c86598d108 100644 --- a/api/db/indexSync.js +++ b/api/db/indexSync.js @@ -29,12 +29,64 @@ class MeiliSearchClient { } } +/** + * Deletes documents from MeiliSearch index that are missing the user field + * @param {import('meilisearch').Index} index - MeiliSearch index instance + * @param {string} indexName - Name of the index for logging + * @returns {Promise} - Number of documents deleted + */ +async function deleteDocumentsWithoutUserField(index, indexName) { + let deletedCount = 0; + let offset = 0; + const batchSize = 1000; + + try { + while (true) { + const searchResult = await index.search('', { + limit: batchSize, + offset: offset, + }); + + if (searchResult.hits.length === 0) { + break; + } + + const idsToDelete = searchResult.hits.filter((hit) => !hit.user).map((hit) => hit.id); + + if (idsToDelete.length > 0) { + logger.info( + `[indexSync] Deleting ${idsToDelete.length} documents without user field from ${indexName} index`, + ); + await index.deleteDocuments(idsToDelete); + deletedCount += idsToDelete.length; + } + + if (searchResult.hits.length < batchSize) { + break; + } + + offset += batchSize; + } + + if (deletedCount > 0) { + logger.info(`[indexSync] Deleted ${deletedCount} orphaned documents from ${indexName} index`); + } + } catch (error) { + logger.error(`[indexSync] Error deleting documents from ${indexName}:`, error); + } + + return deletedCount; +} + /** * Ensures indexes have proper filterable attributes configured and checks if documents have user field * @param {MeiliSearch} client - MeiliSearch client instance - * @returns {Promise} - true if configuration was updated or re-sync is needed + * @returns {Promise<{settingsUpdated: boolean, orphanedDocsFound: boolean}>} - Status of what was done */ async function ensureFilterableAttributes(client) { + let settingsUpdated = false; + let hasOrphanedDocs = false; + try { // Check and update messages index try { @@ -47,16 +99,17 @@ async function ensureFilterableAttributes(client) { filterableAttributes: ['user'], }); logger.info('[indexSync] Messages index configured for user filtering'); - logger.info('[indexSync] Index configuration updated. Full re-sync will be triggered.'); - return true; + settingsUpdated = true; } // Check if existing documents have user field indexed try { const searchResult = await messagesIndex.search('', { limit: 1 }); if (searchResult.hits.length > 0 && !searchResult.hits[0].user) { - logger.info('[indexSync] Existing messages missing user field, re-sync needed'); - return true; + logger.info( + '[indexSync] Existing messages missing user field, will clean up orphaned documents...', + ); + hasOrphanedDocs = true; } } catch (searchError) { logger.debug('[indexSync] Could not check message documents:', searchError.message); @@ -78,16 +131,17 @@ async function ensureFilterableAttributes(client) { filterableAttributes: ['user'], }); logger.info('[indexSync] Convos index configured for user filtering'); - logger.info('[indexSync] Index configuration updated. Full re-sync will be triggered.'); - return true; + settingsUpdated = true; } // Check if existing documents have user field indexed try { const searchResult = await convosIndex.search('', { limit: 1 }); if (searchResult.hits.length > 0 && !searchResult.hits[0].user) { - logger.info('[indexSync] Existing conversations missing user field, re-sync needed'); - return true; + logger.info( + '[indexSync] Existing conversations missing user field, will clean up orphaned documents...', + ); + hasOrphanedDocs = true; } } catch (searchError) { logger.debug('[indexSync] Could not check conversation documents:', searchError.message); @@ -97,101 +151,143 @@ async function ensureFilterableAttributes(client) { logger.warn('[indexSync] Could not check/update convos index settings:', error.message); } } + + // If either index has orphaned documents, clean them up (but don't force resync) + if (hasOrphanedDocs) { + try { + const messagesIndex = client.index('messages'); + await deleteDocumentsWithoutUserField(messagesIndex, 'messages'); + } catch (error) { + logger.debug('[indexSync] Could not clean up messages:', error.message); + } + + try { + const convosIndex = client.index('convos'); + await deleteDocumentsWithoutUserField(convosIndex, 'convos'); + } catch (error) { + logger.debug('[indexSync] Could not clean up convos:', error.message); + } + + logger.info('[indexSync] Orphaned documents cleaned up without forcing resync.'); + } + + if (settingsUpdated) { + logger.info('[indexSync] Index settings updated. Full re-sync will be triggered.'); + } } catch (error) { logger.error('[indexSync] Error ensuring filterable attributes:', error); } - return false; + return { settingsUpdated, orphanedDocsFound: hasOrphanedDocs }; } /** * Performs the actual sync operations for messages and conversations + * @param {FlowStateManager} flowManager - Flow state manager instance + * @param {string} flowId - Flow identifier + * @param {string} flowType - Flow type */ -async function performSync() { - const client = MeiliSearchClient.getInstance(); +async function performSync(flowManager, flowId, flowType) { + try { + const client = MeiliSearchClient.getInstance(); - const { status } = await client.health(); - if (status !== 'available') { - throw new Error('Meilisearch not available'); - } - - if (indexingDisabled === true) { - logger.info('[indexSync] Indexing is disabled, skipping...'); - return { messagesSync: false, convosSync: false }; - } - - /** Ensures indexes have proper filterable attributes configured */ - const configUpdated = await ensureFilterableAttributes(client); - - let messagesSync = false; - let convosSync = false; - - // If configuration was just updated or documents are missing user field, force a full re-sync - if (configUpdated) { - logger.info('[indexSync] Forcing full re-sync to ensure user field is properly indexed...'); - - // Reset sync flags to force full re-sync - await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } }); - await Conversation.collection.updateMany( - { _meiliIndex: true }, - { $set: { _meiliIndex: false } }, - ); - } - - // Check if we need to sync messages - const messageProgress = await Message.getSyncProgress(); - if (!messageProgress.isComplete || configUpdated) { - logger.info( - `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`, - ); - - // Check if we should do a full sync or incremental - const messageCount = await Message.countDocuments(); - const messagesIndexed = messageProgress.totalProcessed; - const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); - - if (messageCount - messagesIndexed > syncThreshold) { - logger.info('[indexSync] Starting full message sync due to large difference'); - await Message.syncWithMeili(); - messagesSync = true; - } else if (messageCount !== messagesIndexed) { - logger.warn('[indexSync] Messages out of sync, performing incremental sync'); - await Message.syncWithMeili(); - messagesSync = true; + const { status } = await client.health(); + if (status !== 'available') { + throw new Error('Meilisearch not available'); } - } else { - logger.info( - `[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`, - ); - } - // Check if we need to sync conversations - const convoProgress = await Conversation.getSyncProgress(); - if (!convoProgress.isComplete || configUpdated) { - logger.info( - `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`, - ); - - const convoCount = await Conversation.countDocuments(); - const convosIndexed = convoProgress.totalProcessed; - const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); - - if (convoCount - convosIndexed > syncThreshold) { - logger.info('[indexSync] Starting full conversation sync due to large difference'); - await Conversation.syncWithMeili(); - convosSync = true; - } else if (convoCount !== convosIndexed) { - logger.warn('[indexSync] Convos out of sync, performing incremental sync'); - await Conversation.syncWithMeili(); - convosSync = true; + if (indexingDisabled === true) { + logger.info('[indexSync] Indexing is disabled, skipping...'); + return { messagesSync: false, convosSync: false }; } - } else { - logger.info( - `[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`, - ); - } - return { messagesSync, convosSync }; + /** Ensures indexes have proper filterable attributes configured */ + const { settingsUpdated, orphanedDocsFound: _orphanedDocsFound } = + await ensureFilterableAttributes(client); + + let messagesSync = false; + let convosSync = false; + + // Only reset flags if settings were actually updated (not just for orphaned doc cleanup) + if (settingsUpdated) { + logger.info( + '[indexSync] Settings updated. Forcing full re-sync to reindex with new configuration...', + ); + + // Reset sync flags to force full re-sync + await Message.collection.updateMany({ _meiliIndex: true }, { $set: { _meiliIndex: false } }); + await Conversation.collection.updateMany( + { _meiliIndex: true }, + { $set: { _meiliIndex: false } }, + ); + } + + // Check if we need to sync messages + const messageProgress = await Message.getSyncProgress(); + if (!messageProgress.isComplete || settingsUpdated) { + logger.info( + `[indexSync] Messages need syncing: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments} indexed`, + ); + + // Check if we should do a full sync or incremental + const messageCount = await Message.countDocuments(); + const messagesIndexed = messageProgress.totalProcessed; + const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); + + if (messageCount - messagesIndexed > syncThreshold) { + logger.info('[indexSync] Starting full message sync due to large difference'); + await Message.syncWithMeili(); + messagesSync = true; + } else if (messageCount !== messagesIndexed) { + logger.warn('[indexSync] Messages out of sync, performing incremental sync'); + await Message.syncWithMeili(); + messagesSync = true; + } + } else { + logger.info( + `[indexSync] Messages are fully synced: ${messageProgress.totalProcessed}/${messageProgress.totalDocuments}`, + ); + } + + // Check if we need to sync conversations + const convoProgress = await Conversation.getSyncProgress(); + if (!convoProgress.isComplete || settingsUpdated) { + logger.info( + `[indexSync] Conversations need syncing: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments} indexed`, + ); + + const convoCount = await Conversation.countDocuments(); + const convosIndexed = convoProgress.totalProcessed; + const syncThreshold = parseInt(process.env.MEILI_SYNC_THRESHOLD || '1000', 10); + + if (convoCount - convosIndexed > syncThreshold) { + logger.info('[indexSync] Starting full conversation sync due to large difference'); + await Conversation.syncWithMeili(); + convosSync = true; + } else if (convoCount !== convosIndexed) { + logger.warn('[indexSync] Convos out of sync, performing incremental sync'); + await Conversation.syncWithMeili(); + convosSync = true; + } + } else { + logger.info( + `[indexSync] Conversations are fully synced: ${convoProgress.totalProcessed}/${convoProgress.totalDocuments}`, + ); + } + + return { messagesSync, convosSync }; + } finally { + if (indexingDisabled === true) { + logger.info('[indexSync] Indexing is disabled, skipping cleanup...'); + } else if (flowManager && flowId && flowType) { + try { + await flowManager.deleteFlow(flowId, flowType); + logger.debug('[indexSync] Flow state cleaned up'); + } catch (cleanupErr) { + logger.debug('[indexSync] Could not clean up flow state:', cleanupErr.message); + } + } + } } /** @@ -204,24 +300,26 @@ async function indexSync() { logger.info('[indexSync] Starting index synchronization check...'); + // Get or create FlowStateManager instance + const flowsCache = getLogStores(CacheKeys.FLOWS); + if (!flowsCache) { + logger.warn('[indexSync] Flows cache not available, falling back to direct sync'); + return await performSync(null, null, null); + } + + const flowManager = new FlowStateManager(flowsCache, { + ttl: 60000 * 10, // 10 minutes TTL for sync operations + }); + + // Use a unique flow ID for the sync operation + const flowId = 'meili-index-sync'; + const flowType = 'MEILI_SYNC'; + try { - // Get or create FlowStateManager instance - const flowsCache = getLogStores(CacheKeys.FLOWS); - if (!flowsCache) { - logger.warn('[indexSync] Flows cache not available, falling back to direct sync'); - return await performSync(); - } - - const flowManager = new FlowStateManager(flowsCache, { - ttl: 60000 * 10, // 10 minutes TTL for sync operations - }); - - // Use a unique flow ID for the sync operation - const flowId = 'meili-index-sync'; - const flowType = 'MEILI_SYNC'; - // This will only execute the handler if no other instance is running the sync - const result = await flowManager.createFlowWithHandler(flowId, flowType, performSync); + const result = await flowManager.createFlowWithHandler(flowId, flowType, () => + performSync(flowManager, flowId, flowType), + ); if (result.messagesSync || result.convosSync) { logger.info('[indexSync] Sync completed successfully'); diff --git a/api/models/Categories.js b/api/models/Categories.js index 5da1f4b2da..34bd2d8ed2 100644 --- a/api/models/Categories.js +++ b/api/models/Categories.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const options = [ { diff --git a/api/models/spendTokens.js b/api/models/spendTokens.js index 65fadd7896..cfd983f6bb 100644 --- a/api/models/spendTokens.js +++ b/api/models/spendTokens.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const { createTransaction, createStructuredTransaction } = require('./Transaction'); /** * Creates up to two transactions to record the spending of tokens. diff --git a/api/models/tx.js b/api/models/tx.js index 66a807999c..92f2432d0e 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -1,4 +1,4 @@ -const { matchModelName } = require('@librechat/api'); +const { matchModelName, findMatchingPattern } = require('@librechat/api'); const defaultRate = 6; /** @@ -6,44 +6,58 @@ const defaultRate = 6; * source: https://aws.amazon.com/bedrock/pricing/ * */ const bedrockValues = { - // Basic llama2 patterns + // Basic llama2 patterns (base defaults to smallest variant) + llama2: { prompt: 0.75, completion: 1.0 }, + 'llama-2': { prompt: 0.75, completion: 1.0 }, 'llama2-13b': { prompt: 0.75, completion: 1.0 }, - 'llama2:13b': { prompt: 0.75, completion: 1.0 }, 'llama2:70b': { prompt: 1.95, completion: 2.56 }, 'llama2-70b': { prompt: 1.95, completion: 2.56 }, - // Basic llama3 patterns + // Basic llama3 patterns (base defaults to smallest variant) + llama3: { prompt: 0.3, completion: 0.6 }, + 'llama-3': { prompt: 0.3, completion: 0.6 }, 'llama3-8b': { prompt: 0.3, completion: 0.6 }, 'llama3:8b': { prompt: 0.3, completion: 0.6 }, 'llama3-70b': { prompt: 2.65, completion: 3.5 }, 'llama3:70b': { prompt: 2.65, completion: 3.5 }, - // llama3-x-Nb pattern + // llama3-x-Nb pattern (base defaults to smallest variant) + 'llama3-1': { prompt: 0.22, completion: 0.22 }, 'llama3-1-8b': { prompt: 0.22, completion: 0.22 }, 'llama3-1-70b': { prompt: 0.72, completion: 0.72 }, 'llama3-1-405b': { prompt: 2.4, completion: 2.4 }, + 'llama3-2': { prompt: 0.1, completion: 0.1 }, 'llama3-2-1b': { prompt: 0.1, completion: 0.1 }, 'llama3-2-3b': { prompt: 0.15, completion: 0.15 }, 'llama3-2-11b': { prompt: 0.16, completion: 0.16 }, 'llama3-2-90b': { prompt: 0.72, completion: 0.72 }, + 'llama3-3': { prompt: 2.65, completion: 3.5 }, + 'llama3-3-70b': { prompt: 2.65, completion: 3.5 }, - // llama3.x:Nb pattern + // llama3.x:Nb pattern (base defaults to smallest variant) + 'llama3.1': { prompt: 0.22, completion: 0.22 }, 'llama3.1:8b': { prompt: 0.22, completion: 0.22 }, 'llama3.1:70b': { prompt: 0.72, completion: 0.72 }, 'llama3.1:405b': { prompt: 2.4, completion: 2.4 }, + 'llama3.2': { prompt: 0.1, completion: 0.1 }, 'llama3.2:1b': { prompt: 0.1, completion: 0.1 }, 'llama3.2:3b': { prompt: 0.15, completion: 0.15 }, 'llama3.2:11b': { prompt: 0.16, completion: 0.16 }, 'llama3.2:90b': { prompt: 0.72, completion: 0.72 }, + 'llama3.3': { prompt: 2.65, completion: 3.5 }, + 'llama3.3:70b': { prompt: 2.65, completion: 3.5 }, - // llama-3.x-Nb pattern + // llama-3.x-Nb pattern (base defaults to smallest variant) + 'llama-3.1': { prompt: 0.22, completion: 0.22 }, 'llama-3.1-8b': { prompt: 0.22, completion: 0.22 }, 'llama-3.1-70b': { prompt: 0.72, completion: 0.72 }, 'llama-3.1-405b': { prompt: 2.4, completion: 2.4 }, + 'llama-3.2': { prompt: 0.1, completion: 0.1 }, 'llama-3.2-1b': { prompt: 0.1, completion: 0.1 }, 'llama-3.2-3b': { prompt: 0.15, completion: 0.15 }, 'llama-3.2-11b': { prompt: 0.16, completion: 0.16 }, 'llama-3.2-90b': { prompt: 0.72, completion: 0.72 }, + 'llama-3.3': { prompt: 2.65, completion: 3.5 }, 'llama-3.3-70b': { prompt: 2.65, completion: 3.5 }, 'mistral-7b': { prompt: 0.15, completion: 0.2 }, 'mistral-small': { prompt: 0.15, completion: 0.2 }, @@ -52,15 +66,19 @@ const bedrockValues = { 'mistral-large-2407': { prompt: 3.0, completion: 9.0 }, 'command-text': { prompt: 1.5, completion: 2.0 }, 'command-light': { prompt: 0.3, completion: 0.6 }, - 'ai21.j2-mid-v1': { prompt: 12.5, completion: 12.5 }, - 'ai21.j2-ultra-v1': { prompt: 18.8, completion: 18.8 }, - 'ai21.jamba-instruct-v1:0': { prompt: 0.5, completion: 0.7 }, - 'amazon.titan-text-lite-v1': { prompt: 0.15, completion: 0.2 }, - 'amazon.titan-text-express-v1': { prompt: 0.2, completion: 0.6 }, - 'amazon.titan-text-premier-v1:0': { prompt: 0.5, completion: 1.5 }, - 'amazon.nova-micro-v1:0': { prompt: 0.035, completion: 0.14 }, - 'amazon.nova-lite-v1:0': { prompt: 0.06, completion: 0.24 }, - 'amazon.nova-pro-v1:0': { prompt: 0.8, completion: 3.2 }, + // AI21 models + 'j2-mid': { prompt: 12.5, completion: 12.5 }, + 'j2-ultra': { prompt: 18.8, completion: 18.8 }, + 'jamba-instruct': { prompt: 0.5, completion: 0.7 }, + // Amazon Titan models + 'titan-text-lite': { prompt: 0.15, completion: 0.2 }, + 'titan-text-express': { prompt: 0.2, completion: 0.6 }, + 'titan-text-premier': { prompt: 0.5, completion: 1.5 }, + // Amazon Nova models + 'nova-micro': { prompt: 0.035, completion: 0.14 }, + 'nova-lite': { prompt: 0.06, completion: 0.24 }, + 'nova-pro': { prompt: 0.8, completion: 3.2 }, + 'nova-premier': { prompt: 2.5, completion: 12.5 }, 'deepseek.r1': { prompt: 1.35, completion: 5.4 }, }; @@ -71,88 +89,136 @@ const bedrockValues = { */ const tokenValues = Object.assign( { + // Legacy token size mappings (generic patterns - check LAST) '8k': { prompt: 30, completion: 60 }, '32k': { prompt: 60, completion: 120 }, '4k': { prompt: 1.5, completion: 2 }, '16k': { prompt: 3, completion: 4 }, + // Generic fallback patterns (check LAST) + 'claude-': { prompt: 0.8, completion: 2.4 }, + deepseek: { prompt: 0.28, completion: 0.42 }, + command: { prompt: 0.38, completion: 0.38 }, + gemma: { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing) + gemini: { prompt: 0.5, completion: 1.5 }, + 'gpt-oss': { prompt: 0.05, completion: 0.2 }, + // Specific model variants (check FIRST - more specific patterns at end) 'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 }, - 'o4-mini': { prompt: 1.1, completion: 4.4 }, - 'o3-mini': { prompt: 1.1, completion: 4.4 }, - o3: { prompt: 2, completion: 8 }, - 'o1-mini': { prompt: 1.1, completion: 4.4 }, - 'o1-preview': { prompt: 15, completion: 60 }, - o1: { prompt: 15, completion: 60 }, + 'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 }, + 'gpt-4-1106': { prompt: 10, completion: 30 }, + 'gpt-4.1': { prompt: 2, completion: 8 }, 'gpt-4.1-nano': { prompt: 0.1, completion: 0.4 }, 'gpt-4.1-mini': { prompt: 0.4, completion: 1.6 }, - 'gpt-4.1': { prompt: 2, completion: 8 }, 'gpt-4.5': { prompt: 75, completion: 150 }, - 'gpt-4o-mini': { prompt: 0.15, completion: 0.6 }, - 'gpt-5': { prompt: 1.25, completion: 10 }, - 'gpt-5-mini': { prompt: 0.25, completion: 2 }, - 'gpt-5-nano': { prompt: 0.05, completion: 0.4 }, 'gpt-4o': { prompt: 2.5, completion: 10 }, 'gpt-4o-2024-05-13': { prompt: 5, completion: 15 }, - 'gpt-4-1106': { prompt: 10, completion: 30 }, - 'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 }, - 'claude-3-opus': { prompt: 15, completion: 75 }, + 'gpt-4o-mini': { prompt: 0.15, completion: 0.6 }, + 'gpt-5': { prompt: 1.25, completion: 10 }, + 'gpt-5-nano': { prompt: 0.05, completion: 0.4 }, + 'gpt-5-mini': { prompt: 0.25, completion: 2 }, + 'gpt-5-pro': { prompt: 15, completion: 120 }, + o1: { prompt: 15, completion: 60 }, + 'o1-mini': { prompt: 1.1, completion: 4.4 }, + 'o1-preview': { prompt: 15, completion: 60 }, + o3: { prompt: 2, completion: 8 }, + 'o3-mini': { prompt: 1.1, completion: 4.4 }, + 'o4-mini': { prompt: 1.1, completion: 4.4 }, + 'claude-instant': { prompt: 0.8, completion: 2.4 }, + 'claude-2': { prompt: 8, completion: 24 }, + 'claude-2.1': { prompt: 8, completion: 24 }, + 'claude-3-haiku': { prompt: 0.25, completion: 1.25 }, 'claude-3-sonnet': { prompt: 3, completion: 15 }, + 'claude-3-opus': { prompt: 15, completion: 75 }, + 'claude-3-5-haiku': { prompt: 0.8, completion: 4 }, + 'claude-3.5-haiku': { prompt: 0.8, completion: 4 }, 'claude-3-5-sonnet': { prompt: 3, completion: 15 }, 'claude-3.5-sonnet': { prompt: 3, completion: 15 }, 'claude-3-7-sonnet': { prompt: 3, completion: 15 }, 'claude-3.7-sonnet': { prompt: 3, completion: 15 }, - 'claude-3-5-haiku': { prompt: 0.8, completion: 4 }, - 'claude-3.5-haiku': { prompt: 0.8, completion: 4 }, - 'claude-3-haiku': { prompt: 0.25, completion: 1.25 }, - 'claude-sonnet-4': { prompt: 3, completion: 15 }, + 'claude-haiku-4-5': { prompt: 1, completion: 5 }, 'claude-opus-4': { prompt: 15, completion: 75 }, - 'claude-2.1': { prompt: 8, completion: 24 }, - 'claude-2': { prompt: 8, completion: 24 }, - 'claude-instant': { prompt: 0.8, completion: 2.4 }, - 'claude-': { prompt: 0.8, completion: 2.4 }, - 'command-r-plus': { prompt: 3, completion: 15 }, + 'claude-sonnet-4': { prompt: 3, completion: 15 }, 'command-r': { prompt: 0.5, completion: 1.5 }, - 'deepseek-reasoner': { prompt: 0.55, completion: 2.19 }, - deepseek: { prompt: 0.14, completion: 0.28 }, - /* cohere doesn't have rates for the older command models, - so this was from https://artificialanalysis.ai/models/command-light/providers */ - command: { prompt: 0.38, completion: 0.38 }, - gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 }, - 'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 }, - 'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing - 'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, - 'gemini-2.5-flash': { prompt: 0.15, completion: 3.5 }, - 'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time - 'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 }, - 'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 }, + 'command-r-plus': { prompt: 3, completion: 15 }, + 'command-text': { prompt: 1.5, completion: 2.0 }, + 'deepseek-reasoner': { prompt: 0.28, completion: 0.42 }, + 'deepseek-r1': { prompt: 0.4, completion: 2.0 }, + 'deepseek-v3': { prompt: 0.2, completion: 0.8 }, + 'gemma-2': { prompt: 0.01, completion: 0.03 }, // Base pattern (using gemma-2-9b pricing) + 'gemma-3': { prompt: 0.02, completion: 0.04 }, // Base pattern (using gemma-3n-e4b pricing) + 'gemma-3-27b': { prompt: 0.09, completion: 0.16 }, 'gemini-1.5': { prompt: 2.5, completion: 10 }, + 'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 }, + 'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 }, + 'gemini-2.0': { prompt: 0.1, completion: 0.4 }, // Base pattern (using 2.0-flash pricing) + 'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 }, + 'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 }, + 'gemini-2.5': { prompt: 0.3, completion: 2.5 }, // Base pattern (using 2.5-flash pricing) + 'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 }, + 'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 }, + 'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 }, - gemini: { prompt: 0.5, completion: 1.5 }, - 'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 }, - 'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 }, - 'grok-2-vision': { prompt: 2.0, completion: 10.0 }, + grok: { prompt: 2.0, completion: 10.0 }, // Base pattern defaults to grok-2 + 'grok-beta': { prompt: 5.0, completion: 15.0 }, 'grok-vision-beta': { prompt: 5.0, completion: 15.0 }, + 'grok-2': { prompt: 2.0, completion: 10.0 }, 'grok-2-1212': { prompt: 2.0, completion: 10.0 }, 'grok-2-latest': { prompt: 2.0, completion: 10.0 }, - 'grok-2': { prompt: 2.0, completion: 10.0 }, - 'grok-3-mini-fast': { prompt: 0.6, completion: 4 }, - 'grok-3-mini': { prompt: 0.3, completion: 0.5 }, - 'grok-3-fast': { prompt: 5.0, completion: 25.0 }, + 'grok-2-vision': { prompt: 2.0, completion: 10.0 }, + 'grok-2-vision-1212': { prompt: 2.0, completion: 10.0 }, + 'grok-2-vision-latest': { prompt: 2.0, completion: 10.0 }, 'grok-3': { prompt: 3.0, completion: 15.0 }, + 'grok-3-fast': { prompt: 5.0, completion: 25.0 }, + 'grok-3-mini': { prompt: 0.3, completion: 0.5 }, + 'grok-3-mini-fast': { prompt: 0.6, completion: 4 }, 'grok-4': { prompt: 3.0, completion: 15.0 }, - 'grok-beta': { prompt: 5.0, completion: 15.0 }, - 'mistral-large': { prompt: 2.0, completion: 6.0 }, - 'pixtral-large': { prompt: 2.0, completion: 6.0 }, - 'mistral-saba': { prompt: 0.2, completion: 0.6 }, codestral: { prompt: 0.3, completion: 0.9 }, - 'ministral-8b': { prompt: 0.1, completion: 0.1 }, 'ministral-3b': { prompt: 0.04, completion: 0.04 }, - // GPT-OSS models + 'ministral-8b': { prompt: 0.1, completion: 0.1 }, + 'mistral-nemo': { prompt: 0.15, completion: 0.15 }, + 'mistral-saba': { prompt: 0.2, completion: 0.6 }, + 'pixtral-large': { prompt: 2.0, completion: 6.0 }, + 'mistral-large': { prompt: 2.0, completion: 6.0 }, + 'mixtral-8x22b': { prompt: 0.65, completion: 0.65 }, + kimi: { prompt: 0.14, completion: 2.49 }, // Base pattern (using kimi-k2 pricing) + // GPT-OSS models (specific sizes) + 'gpt-oss:20b': { prompt: 0.05, completion: 0.2 }, 'gpt-oss-20b': { prompt: 0.05, completion: 0.2 }, + 'gpt-oss:120b': { prompt: 0.15, completion: 0.6 }, 'gpt-oss-120b': { prompt: 0.15, completion: 0.6 }, + // GLM models (Zhipu AI) - general to specific + glm4: { prompt: 0.1, completion: 0.1 }, + 'glm-4': { prompt: 0.1, completion: 0.1 }, + 'glm-4-32b': { prompt: 0.1, completion: 0.1 }, + 'glm-4.5': { prompt: 0.35, completion: 1.55 }, + 'glm-4.5-air': { prompt: 0.14, completion: 0.86 }, + 'glm-4.5v': { prompt: 0.6, completion: 1.8 }, + 'glm-4.6': { prompt: 0.5, completion: 1.75 }, + // Qwen models + qwen: { prompt: 0.08, completion: 0.33 }, // Qwen base pattern (using qwen2.5-72b pricing) + 'qwen2.5': { prompt: 0.08, completion: 0.33 }, // Qwen 2.5 base pattern + 'qwen-turbo': { prompt: 0.05, completion: 0.2 }, + 'qwen-plus': { prompt: 0.4, completion: 1.2 }, + 'qwen-max': { prompt: 1.6, completion: 6.4 }, + 'qwq-32b': { prompt: 0.15, completion: 0.4 }, + // Qwen3 models + qwen3: { prompt: 0.035, completion: 0.138 }, // Qwen3 base pattern (using qwen3-4b pricing) + 'qwen3-8b': { prompt: 0.035, completion: 0.138 }, + 'qwen3-14b': { prompt: 0.05, completion: 0.22 }, + 'qwen3-30b-a3b': { prompt: 0.06, completion: 0.22 }, + 'qwen3-32b': { prompt: 0.05, completion: 0.2 }, + 'qwen3-235b-a22b': { prompt: 0.08, completion: 0.55 }, + // Qwen3 VL (Vision-Language) models + 'qwen3-vl-8b-thinking': { prompt: 0.18, completion: 2.1 }, + 'qwen3-vl-8b-instruct': { prompt: 0.18, completion: 0.69 }, + 'qwen3-vl-30b-a3b': { prompt: 0.29, completion: 1.0 }, + 'qwen3-vl-235b-a22b': { prompt: 0.3, completion: 1.2 }, + // Qwen3 specialized models + 'qwen3-max': { prompt: 1.2, completion: 6 }, + 'qwen3-coder': { prompt: 0.22, completion: 0.95 }, + 'qwen3-coder-30b-a3b': { prompt: 0.06, completion: 0.25 }, + 'qwen3-coder-plus': { prompt: 1, completion: 5 }, + 'qwen3-coder-flash': { prompt: 0.3, completion: 1.5 }, + 'qwen3-next-80b-a3b': { prompt: 0.1, completion: 0.8 }, }, bedrockValues, ); @@ -183,67 +249,39 @@ const cacheTokenValues = { * @returns {string|undefined} The key corresponding to the model name, or undefined if no match is found. */ const getValueKey = (model, endpoint) => { + if (!model || typeof model !== 'string') { + return undefined; + } + + // Use findMatchingPattern directly against tokenValues for efficient lookup + if (!endpoint || (typeof endpoint === 'string' && !tokenValues[endpoint])) { + const matchedKey = findMatchingPattern(model, tokenValues); + if (matchedKey) { + return matchedKey; + } + } + + // Fallback: use matchModelName for edge cases and legacy handling const modelName = matchModelName(model, endpoint); if (!modelName) { return undefined; } + // Legacy token size mappings and aliases for older models if (modelName.includes('gpt-3.5-turbo-16k')) { return '16k'; - } else if (modelName.includes('gpt-3.5-turbo-0125')) { - return 'gpt-3.5-turbo-0125'; - } else if (modelName.includes('gpt-3.5-turbo-1106')) { - return 'gpt-3.5-turbo-1106'; } else if (modelName.includes('gpt-3.5')) { return '4k'; - } else if (modelName.includes('o4-mini')) { - return 'o4-mini'; - } else if (modelName.includes('o4')) { - return 'o4'; - } else if (modelName.includes('o3-mini')) { - return 'o3-mini'; - } else if (modelName.includes('o3')) { - return 'o3'; - } else if (modelName.includes('o1-preview')) { - return 'o1-preview'; - } else if (modelName.includes('o1-mini')) { - return 'o1-mini'; - } else if (modelName.includes('o1')) { - return 'o1'; - } else if (modelName.includes('gpt-4.5')) { - return 'gpt-4.5'; - } else if (modelName.includes('gpt-4.1-nano')) { - return 'gpt-4.1-nano'; - } else if (modelName.includes('gpt-4.1-mini')) { - return 'gpt-4.1-mini'; - } else if (modelName.includes('gpt-4.1')) { - return 'gpt-4.1'; - } else if (modelName.includes('gpt-4o-2024-05-13')) { - return 'gpt-4o-2024-05-13'; - } else if (modelName.includes('gpt-5-nano')) { - return 'gpt-5-nano'; - } else if (modelName.includes('gpt-5-mini')) { - return 'gpt-5-mini'; - } else if (modelName.includes('gpt-5')) { - return 'gpt-5'; - } else if (modelName.includes('gpt-4o-mini')) { - return 'gpt-4o-mini'; - } else if (modelName.includes('gpt-4o')) { - return 'gpt-4o'; } else if (modelName.includes('gpt-4-vision')) { - return 'gpt-4-1106'; - } else if (modelName.includes('gpt-4-1106')) { - return 'gpt-4-1106'; + return 'gpt-4-1106'; // Alias for gpt-4-vision } else if (modelName.includes('gpt-4-0125')) { - return 'gpt-4-1106'; + return 'gpt-4-1106'; // Alias for gpt-4-0125 } else if (modelName.includes('gpt-4-turbo')) { - return 'gpt-4-1106'; + return 'gpt-4-1106'; // Alias for gpt-4-turbo } else if (modelName.includes('gpt-4-32k')) { return '32k'; } else if (modelName.includes('gpt-4')) { return '8k'; - } else if (tokenValues[modelName]) { - return modelName; } return undefined; diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index d315d58622..670ea9d5ec 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -1,3 +1,4 @@ +const { maxTokensMap } = require('@librechat/api'); const { EModelEndpoint } = require('librechat-data-provider'); const { defaultRate, @@ -113,6 +114,14 @@ describe('getValueKey', () => { expect(getValueKey('gpt-5-nano-2025-01-30-0130')).toBe('gpt-5-nano'); }); + it('should return "gpt-5-pro" for model type of "gpt-5-pro"', () => { + expect(getValueKey('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro'); + expect(getValueKey('openai/gpt-5-pro')).toBe('gpt-5-pro'); + expect(getValueKey('gpt-5-pro-0130')).toBe('gpt-5-pro'); + expect(getValueKey('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro'); + expect(getValueKey('gpt-5-pro-preview')).toBe('gpt-5-pro'); + }); + it('should return "gpt-4o" for model type of "gpt-4o"', () => { expect(getValueKey('gpt-4o-2024-08-06')).toBe('gpt-4o'); expect(getValueKey('gpt-4o-2024-08-06-0718')).toBe('gpt-4o'); @@ -184,6 +193,16 @@ describe('getValueKey', () => { expect(getValueKey('claude-3.5-haiku-turbo')).toBe('claude-3.5-haiku'); expect(getValueKey('claude-3.5-haiku-0125')).toBe('claude-3.5-haiku'); }); + + it('should return expected value keys for "gpt-oss" models', () => { + expect(getValueKey('openai/gpt-oss-120b')).toBe('gpt-oss-120b'); + expect(getValueKey('openai/gpt-oss:120b')).toBe('gpt-oss:120b'); + expect(getValueKey('openai/gpt-oss-570b')).toBe('gpt-oss'); + expect(getValueKey('gpt-oss-570b')).toBe('gpt-oss'); + expect(getValueKey('groq/gpt-oss-1080b')).toBe('gpt-oss'); + expect(getValueKey('gpt-oss-20b')).toBe('gpt-oss-20b'); + expect(getValueKey('oai/gpt-oss:20b')).toBe('gpt-oss:20b'); + }); }); describe('getMultiplier', () => { @@ -278,6 +297,20 @@ describe('getMultiplier', () => { ); }); + it('should return the correct multiplier for gpt-5-pro', () => { + const valueKey = getValueKey('gpt-5-pro-2025-01-30'); + expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-5-pro'].prompt); + expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe( + tokenValues['gpt-5-pro'].completion, + ); + expect(getMultiplier({ model: 'gpt-5-pro-preview', tokenType: 'prompt' })).toBe( + tokenValues['gpt-5-pro'].prompt, + ); + expect(getMultiplier({ model: 'openai/gpt-5-pro', tokenType: 'completion' })).toBe( + tokenValues['gpt-5-pro'].completion, + ); + }); + it('should return the correct multiplier for gpt-4o', () => { const valueKey = getValueKey('gpt-4o-2024-08-06'); expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt); @@ -394,6 +427,18 @@ describe('getMultiplier', () => { expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion); }); }); + + it('should return correct multipliers for GLM models', () => { + const models = ['glm-4.6', 'glm-4.5v', 'glm-4.5-air', 'glm-4.5', 'glm-4-32b', 'glm-4', 'glm4']; + models.forEach((key) => { + const expectedPrompt = tokenValues[key].prompt; + const expectedCompletion = tokenValues[key].completion; + expect(getMultiplier({ valueKey: key, tokenType: 'prompt' })).toBe(expectedPrompt); + expect(getMultiplier({ valueKey: key, tokenType: 'completion' })).toBe(expectedCompletion); + expect(getMultiplier({ model: key, tokenType: 'prompt' })).toBe(expectedPrompt); + expect(getMultiplier({ model: key, tokenType: 'completion' })).toBe(expectedCompletion); + }); + }); }); describe('AWS Bedrock Model Tests', () => { @@ -449,6 +494,249 @@ describe('AWS Bedrock Model Tests', () => { }); }); +describe('Amazon Model Tests', () => { + describe('Amazon Nova Models', () => { + it('should return correct pricing for nova-premier', () => { + expect(getMultiplier({ model: 'nova-premier', tokenType: 'prompt' })).toBe( + tokenValues['nova-premier'].prompt, + ); + expect(getMultiplier({ model: 'nova-premier', tokenType: 'completion' })).toBe( + tokenValues['nova-premier'].completion, + ); + expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['nova-premier'].prompt, + ); + expect(getMultiplier({ model: 'amazon.nova-premier-v1:0', tokenType: 'completion' })).toBe( + tokenValues['nova-premier'].completion, + ); + }); + + it('should return correct pricing for nova-pro', () => { + expect(getMultiplier({ model: 'nova-pro', tokenType: 'prompt' })).toBe( + tokenValues['nova-pro'].prompt, + ); + expect(getMultiplier({ model: 'nova-pro', tokenType: 'completion' })).toBe( + tokenValues['nova-pro'].completion, + ); + expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['nova-pro'].prompt, + ); + expect(getMultiplier({ model: 'amazon.nova-pro-v1:0', tokenType: 'completion' })).toBe( + tokenValues['nova-pro'].completion, + ); + }); + + it('should return correct pricing for nova-lite', () => { + expect(getMultiplier({ model: 'nova-lite', tokenType: 'prompt' })).toBe( + tokenValues['nova-lite'].prompt, + ); + expect(getMultiplier({ model: 'nova-lite', tokenType: 'completion' })).toBe( + tokenValues['nova-lite'].completion, + ); + expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['nova-lite'].prompt, + ); + expect(getMultiplier({ model: 'amazon.nova-lite-v1:0', tokenType: 'completion' })).toBe( + tokenValues['nova-lite'].completion, + ); + }); + + it('should return correct pricing for nova-micro', () => { + expect(getMultiplier({ model: 'nova-micro', tokenType: 'prompt' })).toBe( + tokenValues['nova-micro'].prompt, + ); + expect(getMultiplier({ model: 'nova-micro', tokenType: 'completion' })).toBe( + tokenValues['nova-micro'].completion, + ); + expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['nova-micro'].prompt, + ); + expect(getMultiplier({ model: 'amazon.nova-micro-v1:0', tokenType: 'completion' })).toBe( + tokenValues['nova-micro'].completion, + ); + }); + + it('should match both short and full model names to the same pricing', () => { + const models = ['nova-micro', 'nova-lite', 'nova-pro', 'nova-premier']; + const fullModels = [ + 'amazon.nova-micro-v1:0', + 'amazon.nova-lite-v1:0', + 'amazon.nova-pro-v1:0', + 'amazon.nova-premier-v1:0', + ]; + + models.forEach((shortModel, i) => { + const fullModel = fullModels[i]; + const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' }); + const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' }); + const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' }); + const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' }); + + expect(shortPrompt).toBe(fullPrompt); + expect(shortCompletion).toBe(fullCompletion); + expect(shortPrompt).toBe(tokenValues[shortModel].prompt); + expect(shortCompletion).toBe(tokenValues[shortModel].completion); + }); + }); + }); + + describe('Amazon Titan Models', () => { + it('should return correct pricing for titan-text-premier', () => { + expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-premier'].prompt, + ); + expect(getMultiplier({ model: 'titan-text-premier', tokenType: 'completion' })).toBe( + tokenValues['titan-text-premier'].completion, + ); + expect(getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-premier'].prompt, + ); + expect( + getMultiplier({ model: 'amazon.titan-text-premier-v1:0', tokenType: 'completion' }), + ).toBe(tokenValues['titan-text-premier'].completion); + }); + + it('should return correct pricing for titan-text-express', () => { + expect(getMultiplier({ model: 'titan-text-express', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-express'].prompt, + ); + expect(getMultiplier({ model: 'titan-text-express', tokenType: 'completion' })).toBe( + tokenValues['titan-text-express'].completion, + ); + expect(getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-express'].prompt, + ); + expect( + getMultiplier({ model: 'amazon.titan-text-express-v1', tokenType: 'completion' }), + ).toBe(tokenValues['titan-text-express'].completion); + }); + + it('should return correct pricing for titan-text-lite', () => { + expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-lite'].prompt, + ); + expect(getMultiplier({ model: 'titan-text-lite', tokenType: 'completion' })).toBe( + tokenValues['titan-text-lite'].completion, + ); + expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'prompt' })).toBe( + tokenValues['titan-text-lite'].prompt, + ); + expect(getMultiplier({ model: 'amazon.titan-text-lite-v1', tokenType: 'completion' })).toBe( + tokenValues['titan-text-lite'].completion, + ); + }); + + it('should match both short and full model names to the same pricing', () => { + const models = ['titan-text-lite', 'titan-text-express', 'titan-text-premier']; + const fullModels = [ + 'amazon.titan-text-lite-v1', + 'amazon.titan-text-express-v1', + 'amazon.titan-text-premier-v1:0', + ]; + + models.forEach((shortModel, i) => { + const fullModel = fullModels[i]; + const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' }); + const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' }); + const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' }); + const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' }); + + expect(shortPrompt).toBe(fullPrompt); + expect(shortCompletion).toBe(fullCompletion); + expect(shortPrompt).toBe(tokenValues[shortModel].prompt); + expect(shortCompletion).toBe(tokenValues[shortModel].completion); + }); + }); + }); +}); + +describe('AI21 Model Tests', () => { + describe('AI21 J2 Models', () => { + it('should return correct pricing for j2-mid', () => { + expect(getMultiplier({ model: 'j2-mid', tokenType: 'prompt' })).toBe( + tokenValues['j2-mid'].prompt, + ); + expect(getMultiplier({ model: 'j2-mid', tokenType: 'completion' })).toBe( + tokenValues['j2-mid'].completion, + ); + expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'prompt' })).toBe( + tokenValues['j2-mid'].prompt, + ); + expect(getMultiplier({ model: 'ai21.j2-mid-v1', tokenType: 'completion' })).toBe( + tokenValues['j2-mid'].completion, + ); + }); + + it('should return correct pricing for j2-ultra', () => { + expect(getMultiplier({ model: 'j2-ultra', tokenType: 'prompt' })).toBe( + tokenValues['j2-ultra'].prompt, + ); + expect(getMultiplier({ model: 'j2-ultra', tokenType: 'completion' })).toBe( + tokenValues['j2-ultra'].completion, + ); + expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'prompt' })).toBe( + tokenValues['j2-ultra'].prompt, + ); + expect(getMultiplier({ model: 'ai21.j2-ultra-v1', tokenType: 'completion' })).toBe( + tokenValues['j2-ultra'].completion, + ); + }); + + it('should match both short and full model names to the same pricing', () => { + const models = ['j2-mid', 'j2-ultra']; + const fullModels = ['ai21.j2-mid-v1', 'ai21.j2-ultra-v1']; + + models.forEach((shortModel, i) => { + const fullModel = fullModels[i]; + const shortPrompt = getMultiplier({ model: shortModel, tokenType: 'prompt' }); + const fullPrompt = getMultiplier({ model: fullModel, tokenType: 'prompt' }); + const shortCompletion = getMultiplier({ model: shortModel, tokenType: 'completion' }); + const fullCompletion = getMultiplier({ model: fullModel, tokenType: 'completion' }); + + expect(shortPrompt).toBe(fullPrompt); + expect(shortCompletion).toBe(fullCompletion); + expect(shortPrompt).toBe(tokenValues[shortModel].prompt); + expect(shortCompletion).toBe(tokenValues[shortModel].completion); + }); + }); + }); + + describe('AI21 Jamba Models', () => { + it('should return correct pricing for jamba-instruct', () => { + expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' })).toBe( + tokenValues['jamba-instruct'].prompt, + ); + expect(getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' })).toBe( + tokenValues['jamba-instruct'].completion, + ); + expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'prompt' })).toBe( + tokenValues['jamba-instruct'].prompt, + ); + expect(getMultiplier({ model: 'ai21.jamba-instruct-v1:0', tokenType: 'completion' })).toBe( + tokenValues['jamba-instruct'].completion, + ); + }); + + it('should match both short and full model names to the same pricing', () => { + const shortPrompt = getMultiplier({ model: 'jamba-instruct', tokenType: 'prompt' }); + const fullPrompt = getMultiplier({ + model: 'ai21.jamba-instruct-v1:0', + tokenType: 'prompt', + }); + const shortCompletion = getMultiplier({ model: 'jamba-instruct', tokenType: 'completion' }); + const fullCompletion = getMultiplier({ + model: 'ai21.jamba-instruct-v1:0', + tokenType: 'completion', + }); + + expect(shortPrompt).toBe(fullPrompt); + expect(shortCompletion).toBe(fullCompletion); + expect(shortPrompt).toBe(tokenValues['jamba-instruct'].prompt); + expect(shortCompletion).toBe(tokenValues['jamba-instruct'].completion); + }); + }); +}); + describe('Deepseek Model Tests', () => { const deepseekModels = ['deepseek-chat', 'deepseek-coder', 'deepseek-reasoner', 'deepseek.r1']; @@ -480,6 +768,187 @@ describe('Deepseek Model Tests', () => { }); }); +describe('Qwen3 Model Tests', () => { + describe('Qwen3 Base Models', () => { + it('should return correct pricing for qwen3 base pattern', () => { + expect(getMultiplier({ model: 'qwen3', tokenType: 'prompt' })).toBe( + tokenValues['qwen3'].prompt, + ); + expect(getMultiplier({ model: 'qwen3', tokenType: 'completion' })).toBe( + tokenValues['qwen3'].completion, + ); + }); + + it('should return correct pricing for qwen3-4b (falls back to qwen3)', () => { + expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-4b', tokenType: 'completion' })).toBe( + tokenValues['qwen3'].completion, + ); + }); + + it('should return correct pricing for qwen3-8b', () => { + expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-8b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-8b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-8b'].completion, + ); + }); + + it('should return correct pricing for qwen3-14b', () => { + expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-14b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-14b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-14b'].completion, + ); + }); + + it('should return correct pricing for qwen3-235b-a22b', () => { + expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-235b-a22b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-235b-a22b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-235b-a22b'].completion, + ); + }); + + it('should handle model name variations with provider prefixes', () => { + const models = [ + { input: 'qwen3', expected: 'qwen3' }, + { input: 'qwen3-4b', expected: 'qwen3' }, + { input: 'qwen3-8b', expected: 'qwen3-8b' }, + { input: 'qwen3-32b', expected: 'qwen3-32b' }, + ]; + models.forEach(({ input, expected }) => { + const withPrefix = `alibaba/${input}`; + expect(getMultiplier({ model: withPrefix, tokenType: 'prompt' })).toBe( + tokenValues[expected].prompt, + ); + expect(getMultiplier({ model: withPrefix, tokenType: 'completion' })).toBe( + tokenValues[expected].completion, + ); + }); + }); + }); + + describe('Qwen3 VL (Vision-Language) Models', () => { + it('should return correct pricing for qwen3-vl-8b-thinking', () => { + expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-vl-8b-thinking'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-vl-8b-thinking', tokenType: 'completion' })).toBe( + tokenValues['qwen3-vl-8b-thinking'].completion, + ); + }); + + it('should return correct pricing for qwen3-vl-8b-instruct', () => { + expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-vl-8b-instruct'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-vl-8b-instruct', tokenType: 'completion' })).toBe( + tokenValues['qwen3-vl-8b-instruct'].completion, + ); + }); + + it('should return correct pricing for qwen3-vl-30b-a3b', () => { + expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-vl-30b-a3b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-vl-30b-a3b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-vl-30b-a3b'].completion, + ); + }); + + it('should return correct pricing for qwen3-vl-235b-a22b', () => { + expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-vl-235b-a22b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-vl-235b-a22b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-vl-235b-a22b'].completion, + ); + }); + }); + + describe('Qwen3 Specialized Models', () => { + it('should return correct pricing for qwen3-max', () => { + expect(getMultiplier({ model: 'qwen3-max', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-max'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-max', tokenType: 'completion' })).toBe( + tokenValues['qwen3-max'].completion, + ); + }); + + it('should return correct pricing for qwen3-coder', () => { + expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-coder'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-coder', tokenType: 'completion' })).toBe( + tokenValues['qwen3-coder'].completion, + ); + }); + + it('should return correct pricing for qwen3-coder-plus', () => { + expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-coder-plus'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-coder-plus', tokenType: 'completion' })).toBe( + tokenValues['qwen3-coder-plus'].completion, + ); + }); + + it('should return correct pricing for qwen3-coder-flash', () => { + expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-coder-flash'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-coder-flash', tokenType: 'completion' })).toBe( + tokenValues['qwen3-coder-flash'].completion, + ); + }); + + it('should return correct pricing for qwen3-next-80b-a3b', () => { + expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'prompt' })).toBe( + tokenValues['qwen3-next-80b-a3b'].prompt, + ); + expect(getMultiplier({ model: 'qwen3-next-80b-a3b', tokenType: 'completion' })).toBe( + tokenValues['qwen3-next-80b-a3b'].completion, + ); + }); + }); + + describe('Qwen3 Model Variations', () => { + it('should handle all qwen3 models with provider prefixes', () => { + const models = ['qwen3', 'qwen3-8b', 'qwen3-max', 'qwen3-coder', 'qwen3-vl-8b-instruct']; + const prefixes = ['alibaba', 'qwen', 'openrouter']; + + models.forEach((model) => { + prefixes.forEach((prefix) => { + const fullModel = `${prefix}/${model}`; + expect(getMultiplier({ model: fullModel, tokenType: 'prompt' })).toBe( + tokenValues[model].prompt, + ); + expect(getMultiplier({ model: fullModel, tokenType: 'completion' })).toBe( + tokenValues[model].completion, + ); + }); + }); + }); + + it('should handle qwen3-4b falling back to qwen3 base pattern', () => { + const testCases = ['qwen3-4b', 'alibaba/qwen3-4b', 'qwen/qwen3-4b-preview']; + testCases.forEach((model) => { + expect(getMultiplier({ model, tokenType: 'prompt' })).toBe(tokenValues['qwen3'].prompt); + expect(getMultiplier({ model, tokenType: 'completion' })).toBe( + tokenValues['qwen3'].completion, + ); + }); + }); + }); +}); + describe('getCacheMultiplier', () => { it('should return the correct cache multiplier for a given valueKey and cacheType', () => { expect(getCacheMultiplier({ valueKey: 'claude-3-5-sonnet', cacheType: 'write' })).toBe( @@ -571,6 +1040,9 @@ describe('getCacheMultiplier', () => { describe('Google Model Tests', () => { const googleModels = [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', 'gemini-2.5-pro-preview-05-06', 'gemini-2.5-flash-preview-04-17', 'gemini-2.5-exp', @@ -611,6 +1083,9 @@ describe('Google Model Tests', () => { it('should map to the correct model keys', () => { const expected = { + 'gemini-2.5-pro': 'gemini-2.5-pro', + 'gemini-2.5-flash': 'gemini-2.5-flash', + 'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite', 'gemini-2.5-pro-preview-05-06': 'gemini-2.5-pro', 'gemini-2.5-flash-preview-04-17': 'gemini-2.5-flash', 'gemini-2.5-exp': 'gemini-2.5', @@ -766,6 +1241,110 @@ describe('Grok Model Tests - Pricing', () => { }); }); +describe('GLM Model Tests', () => { + it('should return expected value keys for GLM models', () => { + expect(getValueKey('glm-4.6')).toBe('glm-4.6'); + expect(getValueKey('glm-4.5')).toBe('glm-4.5'); + expect(getValueKey('glm-4.5v')).toBe('glm-4.5v'); + expect(getValueKey('glm-4.5-air')).toBe('glm-4.5-air'); + expect(getValueKey('glm-4-32b')).toBe('glm-4-32b'); + expect(getValueKey('glm-4')).toBe('glm-4'); + expect(getValueKey('glm4')).toBe('glm4'); + }); + + it('should match GLM model variations with provider prefixes', () => { + expect(getValueKey('z-ai/glm-4.6')).toBe('glm-4.6'); + expect(getValueKey('z-ai/glm-4.5')).toBe('glm-4.5'); + expect(getValueKey('z-ai/glm-4.5-air')).toBe('glm-4.5-air'); + expect(getValueKey('z-ai/glm-4.5v')).toBe('glm-4.5v'); + expect(getValueKey('z-ai/glm-4-32b')).toBe('glm-4-32b'); + + expect(getValueKey('zai/glm-4.6')).toBe('glm-4.6'); + expect(getValueKey('zai/glm-4.5')).toBe('glm-4.5'); + expect(getValueKey('zai/glm-4.5-air')).toBe('glm-4.5-air'); + expect(getValueKey('zai/glm-4.5v')).toBe('glm-4.5v'); + + expect(getValueKey('zai-org/GLM-4.6')).toBe('glm-4.6'); + expect(getValueKey('zai-org/GLM-4.5')).toBe('glm-4.5'); + expect(getValueKey('zai-org/GLM-4.5-Air')).toBe('glm-4.5-air'); + expect(getValueKey('zai-org/GLM-4.5V')).toBe('glm-4.5v'); + expect(getValueKey('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b'); + }); + + it('should match GLM model variations with suffixes', () => { + expect(getValueKey('glm-4.6-fp8')).toBe('glm-4.6'); + expect(getValueKey('zai-org/GLM-4.6-FP8')).toBe('glm-4.6'); + expect(getValueKey('zai-org/GLM-4.5-Air-FP8')).toBe('glm-4.5-air'); + }); + + it('should prioritize more specific GLM model patterns', () => { + expect(getValueKey('glm-4.5-air-something')).toBe('glm-4.5-air'); + expect(getValueKey('glm-4.5-something')).toBe('glm-4.5'); + expect(getValueKey('glm-4.5v-something')).toBe('glm-4.5v'); + }); + + it('should return correct multipliers for all GLM models', () => { + expect(getMultiplier({ model: 'glm-4.6', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.6'].prompt, + ); + expect(getMultiplier({ model: 'glm-4.6', tokenType: 'completion' })).toBe( + tokenValues['glm-4.6'].completion, + ); + + expect(getMultiplier({ model: 'glm-4.5v', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.5v'].prompt, + ); + expect(getMultiplier({ model: 'glm-4.5v', tokenType: 'completion' })).toBe( + tokenValues['glm-4.5v'].completion, + ); + + expect(getMultiplier({ model: 'glm-4.5-air', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.5-air'].prompt, + ); + expect(getMultiplier({ model: 'glm-4.5-air', tokenType: 'completion' })).toBe( + tokenValues['glm-4.5-air'].completion, + ); + + expect(getMultiplier({ model: 'glm-4.5', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.5'].prompt, + ); + expect(getMultiplier({ model: 'glm-4.5', tokenType: 'completion' })).toBe( + tokenValues['glm-4.5'].completion, + ); + + expect(getMultiplier({ model: 'glm-4-32b', tokenType: 'prompt' })).toBe( + tokenValues['glm-4-32b'].prompt, + ); + expect(getMultiplier({ model: 'glm-4-32b', tokenType: 'completion' })).toBe( + tokenValues['glm-4-32b'].completion, + ); + + expect(getMultiplier({ model: 'glm-4', tokenType: 'prompt' })).toBe( + tokenValues['glm-4'].prompt, + ); + expect(getMultiplier({ model: 'glm-4', tokenType: 'completion' })).toBe( + tokenValues['glm-4'].completion, + ); + + expect(getMultiplier({ model: 'glm4', tokenType: 'prompt' })).toBe(tokenValues['glm4'].prompt); + expect(getMultiplier({ model: 'glm4', tokenType: 'completion' })).toBe( + tokenValues['glm4'].completion, + ); + }); + + it('should return correct multipliers for GLM models with provider prefixes', () => { + expect(getMultiplier({ model: 'z-ai/glm-4.6', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.6'].prompt, + ); + expect(getMultiplier({ model: 'zai/glm-4.5-air', tokenType: 'completion' })).toBe( + tokenValues['glm-4.5-air'].completion, + ); + expect(getMultiplier({ model: 'zai-org/GLM-4.5V', tokenType: 'prompt' })).toBe( + tokenValues['glm-4.5v'].prompt, + ); + }); +}); + describe('Claude Model Tests', () => { it('should return correct prompt and completion rates for Claude 4 models', () => { expect(getMultiplier({ model: 'claude-sonnet-4', tokenType: 'prompt' })).toBe( @@ -782,6 +1361,37 @@ describe('Claude Model Tests', () => { ); }); + it('should return correct prompt and completion rates for Claude Haiku 4.5', () => { + expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'prompt' })).toBe( + tokenValues['claude-haiku-4-5'].prompt, + ); + expect(getMultiplier({ model: 'claude-haiku-4-5', tokenType: 'completion' })).toBe( + tokenValues['claude-haiku-4-5'].completion, + ); + }); + + it('should handle Claude Haiku 4.5 model name variations', () => { + const modelVariations = [ + 'claude-haiku-4-5', + 'claude-haiku-4-5-20250420', + 'claude-haiku-4-5-latest', + 'anthropic/claude-haiku-4-5', + 'claude-haiku-4-5/anthropic', + 'claude-haiku-4-5-preview', + ]; + + modelVariations.forEach((model) => { + const valueKey = getValueKey(model); + expect(valueKey).toBe('claude-haiku-4-5'); + expect(getMultiplier({ model, tokenType: 'prompt' })).toBe( + tokenValues['claude-haiku-4-5'].prompt, + ); + expect(getMultiplier({ model, tokenType: 'completion' })).toBe( + tokenValues['claude-haiku-4-5'].completion, + ); + }); + }); + it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { const modelVariations = [ 'claude-sonnet-4', @@ -859,3 +1469,119 @@ describe('Claude Model Tests', () => { }); }); }); + +describe('tokens.ts and tx.js sync validation', () => { + it('should resolve all models in maxTokensMap to pricing via getValueKey', () => { + const tokensKeys = Object.keys(maxTokensMap[EModelEndpoint.openAI]); + const txKeys = Object.keys(tokenValues); + + const unresolved = []; + + tokensKeys.forEach((key) => { + // Skip legacy token size mappings (e.g., '4k', '8k', '16k', '32k') + if (/^\d+k$/.test(key)) return; + + // Skip generic pattern keys (end with '-' or ':') + if (key.endsWith('-') || key.endsWith(':')) return; + + // Try to resolve via getValueKey + const resolvedKey = getValueKey(key); + + // If it resolves and the resolved key has pricing, success + if (resolvedKey && txKeys.includes(resolvedKey)) return; + + // If it resolves to a legacy key (4k, 8k, etc), also OK + if (resolvedKey && /^\d+k$/.test(resolvedKey)) return; + + // If we get here, this model can't get pricing - flag it + unresolved.push({ + key, + resolvedKey: resolvedKey || 'undefined', + context: maxTokensMap[EModelEndpoint.openAI][key], + }); + }); + + if (unresolved.length > 0) { + console.log('\nModels that cannot resolve to pricing via getValueKey:'); + unresolved.forEach(({ key, resolvedKey, context }) => { + console.log(` - '${key}' → '${resolvedKey}' (context: ${context})`); + }); + } + + expect(unresolved).toEqual([]); + }); + + it('should not have redundant dated variants with same pricing and context as base model', () => { + const txKeys = Object.keys(tokenValues); + const redundant = []; + + txKeys.forEach((key) => { + // Check if this is a dated variant (ends with -YYYY-MM-DD) + if (key.match(/.*-\d{4}-\d{2}-\d{2}$/)) { + const baseKey = key.replace(/-\d{4}-\d{2}-\d{2}$/, ''); + + if (txKeys.includes(baseKey)) { + const variantPricing = tokenValues[key]; + const basePricing = tokenValues[baseKey]; + const variantContext = maxTokensMap[EModelEndpoint.openAI][key]; + const baseContext = maxTokensMap[EModelEndpoint.openAI][baseKey]; + + const samePricing = + variantPricing.prompt === basePricing.prompt && + variantPricing.completion === basePricing.completion; + const sameContext = variantContext === baseContext; + + if (samePricing && sameContext) { + redundant.push({ + key, + baseKey, + pricing: `${variantPricing.prompt}/${variantPricing.completion}`, + context: variantContext, + }); + } + } + } + }); + + if (redundant.length > 0) { + console.log('\nRedundant dated variants found (same pricing and context as base):'); + redundant.forEach(({ key, baseKey, pricing, context }) => { + console.log(` - '${key}' → '${baseKey}' (pricing: ${pricing}, context: ${context})`); + console.log(` Can be removed - pattern matching will handle it`); + }); + } + + expect(redundant).toEqual([]); + }); + + it('should have context windows in tokens.ts for all models with pricing in tx.js (openAI catch-all)', () => { + const txKeys = Object.keys(tokenValues); + const missingContext = []; + + txKeys.forEach((key) => { + // Skip legacy token size mappings (4k, 8k, 16k, 32k) + if (/^\d+k$/.test(key)) return; + + // Check if this model has a context window defined + const context = maxTokensMap[EModelEndpoint.openAI][key]; + + if (!context) { + const pricing = tokenValues[key]; + missingContext.push({ + key, + pricing: `${pricing.prompt}/${pricing.completion}`, + }); + } + }); + + if (missingContext.length > 0) { + console.log('\nModels with pricing but missing context in tokens.ts:'); + missingContext.forEach(({ key, pricing }) => { + console.log(` - '${key}' (pricing: ${pricing})`); + console.log(` Add to tokens.ts openAIModels/bedrockModels/etc.`); + }); + } + + expect(missingContext).toEqual([]); + }); +}); diff --git a/api/package.json b/api/package.json index 212a979612..44cc252216 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.0-rc4", + "version": "v0.8.0", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -47,9 +47,8 @@ "@langchain/core": "^0.3.62", "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", - "@langchain/openai": "^0.5.18", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.81", + "@librechat/agents": "^2.4.86", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -94,7 +93,7 @@ "multer": "^2.0.2", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", - "nodemailer": "^6.9.15", + "nodemailer": "^7.0.9", "ollama": "^0.5.0", "openai": "^5.10.1", "openid-client": "^6.5.0", diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 249817610e..096727e977 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -116,11 +116,15 @@ const refreshController = async (req, res) => { const token = await setAuthTokens(userId, res, session); // trigger OAuth MCP server reconnection asynchronously (best effort) - void getOAuthReconnectionManager() - .reconnectServers(userId) - .catch((err) => { - logger.error('Error reconnecting OAuth MCP servers:', err); - }); + try { + void getOAuthReconnectionManager() + .reconnectServers(userId) + .catch((err) => { + logger.error('[refreshController] Error reconnecting OAuth MCP servers:', err); + }); + } catch (err) { + logger.warn(`[refreshController] Cannot attempt OAuth MCP servers reconnection:`, err); + } res.status(200).send({ token, user }); } else if (req?.query?.retry) { diff --git a/api/server/controllers/ModelController.js b/api/server/controllers/ModelController.js index 7430655df3..805d9eef27 100644 --- a/api/server/controllers/ModelController.js +++ b/api/server/controllers/ModelController.js @@ -1,7 +1,7 @@ +const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); -const { logger } = require('~/config'); /** * @param {ServerRequest} req diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index c7051f4608..31295387ed 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -1,7 +1,6 @@ -const { logger } = require('@librechat/data-schemas'); +const { logger, webSearchKeys } = require('@librechat/data-schemas'); const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider'); const { - webSearchKeys, MCPOAuthHandler, MCPTokenStorage, normalizeHttpError, @@ -328,16 +327,23 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { const revocationEndpointAuthMethodsSupported = serverConfig.oauth?.revocation_endpoint_auth_methods_supported ?? clientMetadata.revocation_endpoint_auth_methods_supported; + const oauthHeaders = serverConfig.oauth_headers ?? {}; if (tokens?.access_token) { try { - await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', { - serverUrl: serverConfig.url, - clientId: clientInfo.client_id, - clientSecret: clientInfo.client_secret ?? '', - revocationEndpoint, - revocationEndpointAuthMethodsSupported, - }); + await MCPOAuthHandler.revokeOAuthToken( + serverName, + tokens.access_token, + 'access', + { + serverUrl: serverConfig.url, + clientId: clientInfo.client_id, + clientSecret: clientInfo.client_secret ?? '', + revocationEndpoint, + revocationEndpointAuthMethodsSupported, + }, + oauthHeaders, + ); } catch (error) { logger.error(`Error revoking OAuth access token for ${serverName}:`, error); } @@ -345,13 +351,19 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { if (tokens?.refresh_token) { try { - await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', { - serverUrl: serverConfig.url, - clientId: clientInfo.client_id, - clientSecret: clientInfo.client_secret ?? '', - revocationEndpoint, - revocationEndpointAuthMethodsSupported, - }); + await MCPOAuthHandler.revokeOAuthToken( + serverName, + tokens.refresh_token, + 'refresh', + { + serverUrl: serverConfig.url, + clientId: clientInfo.client_id, + clientSecret: clientInfo.client_secret ?? '', + revocationEndpoint, + revocationEndpointAuthMethodsSupported, + }, + oauthHeaders, + ); } catch (error) { logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error); } diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 5825257257..a648488d14 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -211,16 +211,13 @@ class AgentClient extends BaseClient { * @returns {Promise>>} */ async addImageURLs(message, attachments) { - const { files, text, image_urls } = await encodeAndFormat( + const { files, image_urls } = await encodeAndFormat( this.options.req, attachments, this.options.agent.provider, VisionModes.agents, ); message.image_urls = image_urls.length ? image_urls : undefined; - if (text && text.length) { - message.ocr = text; - } return files; } @@ -248,19 +245,18 @@ class AgentClient extends BaseClient { if (this.options.attachments) { const attachments = await this.options.attachments; + const latestMessage = orderedMessages[orderedMessages.length - 1]; if (this.message_file_map) { - this.message_file_map[orderedMessages[orderedMessages.length - 1].messageId] = attachments; + this.message_file_map[latestMessage.messageId] = attachments; } else { this.message_file_map = { - [orderedMessages[orderedMessages.length - 1].messageId]: attachments, + [latestMessage.messageId]: attachments, }; } - const files = await this.addImageURLs( - orderedMessages[orderedMessages.length - 1], - attachments, - ); + await this.addFileContextToMessage(latestMessage, attachments); + const files = await this.processAttachments(latestMessage, attachments); this.options.attachments = files; } @@ -280,21 +276,21 @@ class AgentClient extends BaseClient { assistantName: this.options?.modelLabel, }); - if (message.ocr && i !== orderedMessages.length - 1) { + if (message.fileContext && i !== orderedMessages.length - 1) { if (typeof formattedMessage.content === 'string') { - formattedMessage.content = message.ocr + '\n' + formattedMessage.content; + formattedMessage.content = message.fileContext + '\n' + formattedMessage.content; } else { const textPart = formattedMessage.content.find((part) => part.type === 'text'); textPart - ? (textPart.text = message.ocr + '\n' + textPart.text) - : formattedMessage.content.unshift({ type: 'text', text: message.ocr }); + ? (textPart.text = message.fileContext + '\n' + textPart.text) + : formattedMessage.content.unshift({ type: 'text', text: message.fileContext }); } - } else if (message.ocr && i === orderedMessages.length - 1) { - systemContent = [systemContent, message.ocr].join('\n'); + } else if (message.fileContext && i === orderedMessages.length - 1) { + systemContent = [systemContent, message.fileContext].join('\n'); } const needsTokenCount = - (this.contextStrategy && !orderedMessages[i].tokenCount) || message.ocr; + (this.contextStrategy && !orderedMessages[i].tokenCount) || message.fileContext; /* If tokens were never counted, or, is a Vision request and the message has files, count again */ if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) { @@ -1116,8 +1112,8 @@ class AgentClient extends BaseClient { appConfig.endpoints?.[endpoint] ?? titleProviderConfig.customEndpointConfig; if (!endpointConfig) { - logger.warn( - '[api/server/controllers/agents/client.js #titleConvo] Error getting endpoint config', + logger.debug( + `[api/server/controllers/agents/client.js #titleConvo] No endpoint config for "${endpoint}"`, ); } diff --git a/api/server/controllers/auth/LoginController.js b/api/server/controllers/auth/LoginController.js index 226b5605cc..930eee86be 100644 --- a/api/server/controllers/auth/LoginController.js +++ b/api/server/controllers/auth/LoginController.js @@ -1,6 +1,6 @@ +const { logger } = require('@librechat/data-schemas'); const { generate2FATempToken } = require('~/server/services/twoFactorService'); const { setAuthTokens } = require('~/server/services/AuthService'); -const { logger } = require('~/config'); const loginController = async (req, res) => { try { diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 1d18e4a94d..02d3d0302d 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -1,8 +1,8 @@ const cookies = require('cookie'); -const { getOpenIdConfig } = require('~/strategies'); +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { logoutUser } = require('~/server/services/AuthService'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); +const { getOpenIdConfig } = require('~/strategies'); const logoutController = async (req, res) => { const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null; diff --git a/api/server/index.js b/api/server/index.js index e458b0349e..c084267ad1 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -10,7 +10,12 @@ const compression = require('compression'); const cookieParser = require('cookie-parser'); const { logger } = require('@librechat/data-schemas'); const mongoSanitize = require('express-mongo-sanitize'); -const { isEnabled, ErrorController } = require('@librechat/api'); +const { + isEnabled, + ErrorController, + performStartupChecks, + initializeFileStorage, +} = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); const createValidateImageRequest = require('./middleware/validateImageRequest'); @@ -49,9 +54,11 @@ const startServer = async () => { app.set('trust proxy', trusted_proxy); await seedDatabase(); - const appConfig = await getAppConfig(); + initializeFileStorage(appConfig); + await performStartupChecks(appConfig); await updateInterfacePermissions(appConfig); + const indexPath = path.join(appConfig.paths.dist, 'index.html'); let indexHTML = fs.readFileSync(indexPath, 'utf8'); diff --git a/api/server/middleware/canDeleteAccount.js b/api/server/middleware/canDeleteAccount.js index 5f2479fb54..a913495287 100644 --- a/api/server/middleware/canDeleteAccount.js +++ b/api/server/middleware/canDeleteAccount.js @@ -1,6 +1,6 @@ +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { SystemRoles } = require('librechat-data-provider'); -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); /** * Checks if the user can delete their account diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js index ad4e4c86ec..b8e680cb94 100644 --- a/api/server/middleware/checkBan.js +++ b/api/server/middleware/checkBan.js @@ -1,9 +1,9 @@ const { Keyv } = require('keyv'); const uap = require('ua-parser-js'); const { logger } = require('@librechat/data-schemas'); +const { isEnabled, keyvMongo } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { isEnabled, removePorts } = require('~/server/utils'); -const keyvMongo = require('~/cache/keyvMongo'); +const { removePorts } = require('~/server/utils'); const denyRequest = require('./denyRequest'); const { getLogStores } = require('~/cache'); const { findUser } = require('~/models'); diff --git a/api/server/middleware/checkPeoplePickerAccess.js b/api/server/middleware/checkPeoplePickerAccess.js index 3f462362a1..0e604272db 100644 --- a/api/server/middleware/checkPeoplePickerAccess.js +++ b/api/server/middleware/checkPeoplePickerAccess.js @@ -1,6 +1,6 @@ +const { logger } = require('@librechat/data-schemas'); const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider'); const { getRoleByName } = require('~/models/Role'); -const { logger } = require('~/config'); /** * Middleware to check if user has permission to access people picker functionality diff --git a/api/server/middleware/checkPeoplePickerAccess.spec.js b/api/server/middleware/checkPeoplePickerAccess.spec.js index ddbf6f86a9..52bf0e6724 100644 --- a/api/server/middleware/checkPeoplePickerAccess.spec.js +++ b/api/server/middleware/checkPeoplePickerAccess.spec.js @@ -1,10 +1,11 @@ +const { logger } = require('@librechat/data-schemas'); const { PrincipalType, PermissionTypes, Permissions } = require('librechat-data-provider'); const { checkPeoplePickerAccess } = require('./checkPeoplePickerAccess'); const { getRoleByName } = require('~/models/Role'); -const { logger } = require('~/config'); jest.mock('~/models/Role'); -jest.mock('~/config', () => ({ +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), logger: { error: jest.fn(), }, diff --git a/api/server/middleware/concurrentLimiter.js b/api/server/middleware/concurrentLimiter.js index 79de88609b..96885e2fd4 100644 --- a/api/server/middleware/concurrentLimiter.js +++ b/api/server/middleware/concurrentLimiter.js @@ -1,7 +1,7 @@ +const { isEnabled } = require('@librechat/api'); const { Time, CacheKeys, ViolationTypes } = require('librechat-data-provider'); const clearPendingReq = require('~/cache/clearPendingReq'); const { logViolation, getLogStores } = require('~/cache'); -const { isEnabled } = require('~/server/utils'); const denyRequest = require('./denyRequest'); const { diff --git a/api/server/middleware/limiters/forkLimiters.js b/api/server/middleware/limiters/forkLimiters.js index 35fda10e94..e0aa65700c 100644 --- a/api/server/middleware/limiters/forkLimiters.js +++ b/api/server/middleware/limiters/forkLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { diff --git a/api/server/middleware/limiters/importLimiters.js b/api/server/middleware/limiters/importLimiters.js index 0d8204393f..f383e99563 100644 --- a/api/server/middleware/limiters/importLimiters.js +++ b/api/server/middleware/limiters/importLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { diff --git a/api/server/middleware/limiters/loginLimiter.js b/api/server/middleware/limiters/loginLimiter.js index cc21f68792..eef0c56bfc 100644 --- a/api/server/middleware/limiters/loginLimiter.js +++ b/api/server/middleware/limiters/loginLimiter.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { removePorts } = require('~/server/utils'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { LOGIN_WINDOW = 5, LOGIN_MAX = 7, LOGIN_VIOLATION_SCORE: score } = process.env; diff --git a/api/server/middleware/limiters/messageLimiters.js b/api/server/middleware/limiters/messageLimiters.js index 553d39959b..50f4dbc644 100644 --- a/api/server/middleware/limiters/messageLimiters.js +++ b/api/server/middleware/limiters/messageLimiters.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const denyRequest = require('~/server/middleware/denyRequest'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { diff --git a/api/server/middleware/limiters/registerLimiter.js b/api/server/middleware/limiters/registerLimiter.js index 15c91eba37..eeebebdb42 100644 --- a/api/server/middleware/limiters/registerLimiter.js +++ b/api/server/middleware/limiters/registerLimiter.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { removePorts } = require('~/server/utils'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { REGISTER_WINDOW = 60, REGISTER_MAX = 5, REGISTRATION_VIOLATION_SCORE: score } = process.env; diff --git a/api/server/middleware/limiters/resetPasswordLimiter.js b/api/server/middleware/limiters/resetPasswordLimiter.js index 1905d5f2bc..d1dfe52a98 100644 --- a/api/server/middleware/limiters/resetPasswordLimiter.js +++ b/api/server/middleware/limiters/resetPasswordLimiter.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { removePorts } = require('~/server/utils'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { diff --git a/api/server/middleware/limiters/sttLimiters.js b/api/server/middleware/limiters/sttLimiters.js index 138e68caa7..f2f47cf680 100644 --- a/api/server/middleware/limiters/sttLimiters.js +++ b/api/server/middleware/limiters/sttLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { diff --git a/api/server/middleware/limiters/toolCallLimiter.js b/api/server/middleware/limiters/toolCallLimiter.js index 28c1f78912..56dbfff1cf 100644 --- a/api/server/middleware/limiters/toolCallLimiter.js +++ b/api/server/middleware/limiters/toolCallLimiter.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const { TOOL_CALL_VIOLATION_SCORE: score } = process.env; diff --git a/api/server/middleware/limiters/ttsLimiters.js b/api/server/middleware/limiters/ttsLimiters.js index 89742c88a8..41dd9a6ba5 100644 --- a/api/server/middleware/limiters/ttsLimiters.js +++ b/api/server/middleware/limiters/ttsLimiters.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const logViolation = require('~/cache/logViolation'); -const { limiterCache } = require('~/cache/cacheFactory'); const getEnvironmentVariables = () => { const TTS_IP_MAX = parseInt(process.env.TTS_IP_MAX) || 100; diff --git a/api/server/middleware/limiters/uploadLimiters.js b/api/server/middleware/limiters/uploadLimiters.js index 0ec4bde8d1..df6987877c 100644 --- a/api/server/middleware/limiters/uploadLimiters.js +++ b/api/server/middleware/limiters/uploadLimiters.js @@ -1,6 +1,6 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); -const { limiterCache } = require('~/cache/cacheFactory'); const logViolation = require('~/cache/logViolation'); const getEnvironmentVariables = () => { diff --git a/api/server/middleware/limiters/verifyEmailLimiter.js b/api/server/middleware/limiters/verifyEmailLimiter.js index 0025c041fd..006c4df656 100644 --- a/api/server/middleware/limiters/verifyEmailLimiter.js +++ b/api/server/middleware/limiters/verifyEmailLimiter.js @@ -1,7 +1,7 @@ const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); const { ViolationTypes } = require('librechat-data-provider'); const { removePorts } = require('~/server/utils'); -const { limiterCache } = require('~/cache/cacheFactory'); const { logViolation } = require('~/cache'); const { diff --git a/api/server/middleware/logHeaders.js b/api/server/middleware/logHeaders.js index 26ca04da38..398542747a 100644 --- a/api/server/middleware/logHeaders.js +++ b/api/server/middleware/logHeaders.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); /** * Middleware to log Forwarded Headers diff --git a/api/server/middleware/moderateText.js b/api/server/middleware/moderateText.js index ff1a9de856..775afbafbf 100644 --- a/api/server/middleware/moderateText.js +++ b/api/server/middleware/moderateText.js @@ -1,8 +1,8 @@ const axios = require('axios'); +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { ErrorTypes } = require('librechat-data-provider'); -const { isEnabled } = require('~/server/utils'); const denyRequest = require('./denyRequest'); -const { logger } = require('~/config'); async function moderateText(req, res, next) { if (!isEnabled(process.env.OPENAI_MODERATION)) { diff --git a/api/server/middleware/optionalJwtAuth.js b/api/server/middleware/optionalJwtAuth.js index 953bc54029..2f59fdda4a 100644 --- a/api/server/middleware/optionalJwtAuth.js +++ b/api/server/middleware/optionalJwtAuth.js @@ -1,6 +1,6 @@ const cookies = require('cookie'); -const { isEnabled } = require('~/server/utils'); const passport = require('passport'); +const { isEnabled } = require('@librechat/api'); // This middleware does not require authentication, // but if the user is authenticated, it will set the user object. diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js index c00b37ee0b..ed83c4773e 100644 --- a/api/server/middleware/requireJwtAuth.js +++ b/api/server/middleware/requireJwtAuth.js @@ -1,6 +1,6 @@ -const passport = require('passport'); const cookies = require('cookie'); -const { isEnabled } = require('~/server/utils'); +const passport = require('passport'); +const { isEnabled } = require('@librechat/api'); /** * Custom Middleware to handle JWT authentication, with support for OpenID token reuse diff --git a/api/server/middleware/requireLocalAuth.js b/api/server/middleware/requireLocalAuth.js index a71bd6c5b0..e82388f298 100644 --- a/api/server/middleware/requireLocalAuth.js +++ b/api/server/middleware/requireLocalAuth.js @@ -1,5 +1,5 @@ const passport = require('passport'); -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const requireLocalAuth = (req, res, next) => { passport.authenticate('local', (err, user, info) => { diff --git a/api/server/middleware/validatePasswordReset.js b/api/server/middleware/validatePasswordReset.js index 7f5616722a..b4acd66bb1 100644 --- a/api/server/middleware/validatePasswordReset.js +++ b/api/server/middleware/validatePasswordReset.js @@ -1,5 +1,5 @@ -const { isEnabled } = require('~/server/utils'); -const { logger } = require('~/config'); +const { isEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); function validatePasswordReset(req, res, next) { if (isEnabled(process.env.ALLOW_PASSWORD_RESET)) { diff --git a/api/server/middleware/validateRegistration.js b/api/server/middleware/validateRegistration.js index 07911bd9c7..79b5caca2d 100644 --- a/api/server/middleware/validateRegistration.js +++ b/api/server/middleware/validateRegistration.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('~/server/utils'); +const { isEnabled } = require('@librechat/api'); function validateRegistration(req, res, next) { if (req.invite) { diff --git a/api/server/routes/__tests__/ldap.spec.js b/api/server/routes/__tests__/ldap.spec.js index 6e0a95bfe4..bc7efa7859 100644 --- a/api/server/routes/__tests__/ldap.spec.js +++ b/api/server/routes/__tests__/ldap.spec.js @@ -1,10 +1,13 @@ -const request = require('supertest'); const express = require('express'); +const request = require('supertest'); +const { isEnabled } = require('@librechat/api'); const { getLdapConfig } = require('~/server/services/Config/ldap'); -const { isEnabled } = require('~/server/utils'); jest.mock('~/server/services/Config/ldap'); -jest.mock('~/server/utils'); +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + isEnabled: jest.fn(), +})); const app = express(); diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 0df28d7b10..64c95c58ee 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -127,8 +127,13 @@ describe('MCP Routes', () => { }), }; + const mockMcpManager = { + getRawConfig: jest.fn().mockReturnValue({}), + }; + getLogStores.mockReturnValue({}); require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + require('~/config').getMCPManager.mockReturnValue(mockMcpManager); MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({ authorizationUrl: 'https://oauth.example.com/auth', @@ -146,6 +151,7 @@ describe('MCP Routes', () => { 'test-server', 'https://test-server.com', 'test-user-id', + {}, { clientId: 'test-client-id' }, ); }); @@ -314,6 +320,7 @@ describe('MCP Routes', () => { }; const mockMcpManager = { getUserConnection: jest.fn().mockResolvedValue(mockUserConnection), + getRawConfig: jest.fn().mockReturnValue({}), }; require('~/config').getMCPManager.mockReturnValue(mockMcpManager); @@ -336,6 +343,7 @@ describe('MCP Routes', () => { 'test-flow-id', 'test-auth-code', mockFlowManager, + {}, ); expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith( expect.objectContaining({ @@ -392,6 +400,11 @@ describe('MCP Routes', () => { getLogStores.mockReturnValue({}); require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + const mockMcpManager = { + getRawConfig: jest.fn().mockReturnValue({}), + }; + require('~/config').getMCPManager.mockReturnValue(mockMcpManager); + const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({ code: 'test-auth-code', state: 'test-flow-id', @@ -427,6 +440,7 @@ describe('MCP Routes', () => { const mockMcpManager = { getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')), + getRawConfig: jest.fn().mockReturnValue({}), }; require('~/config').getMCPManager.mockReturnValue(mockMcpManager); @@ -1234,6 +1248,7 @@ describe('MCP Routes', () => { getUserConnection: jest.fn().mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]), }), + getRawConfig: jest.fn().mockReturnValue({}), }; require('~/config').getMCPManager.mockReturnValue(mockMcpManager); @@ -1281,6 +1296,7 @@ describe('MCP Routes', () => { .fn() .mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]), }), + getRawConfig: jest.fn().mockReturnValue({}), }; require('~/config').getMCPManager.mockReturnValue(mockMcpManager); diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index a1cf5f751a..b5e249b059 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -1,4 +1,5 @@ const express = require('express'); +const { isEnabled } = require('@librechat/api'); const { uaParser, checkBan, @@ -8,7 +9,6 @@ const { concurrentLimiter, messageUserLimiter, } = require('~/server/middleware'); -const { isEnabled } = require('~/server/utils'); const { v1 } = require('./v1'); const chat = require('./chat'); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index ec38593fe8..bae5f764b0 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -115,6 +115,9 @@ router.get('/', async function (req, res) { sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE, sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE, openidReuseTokens, + conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES + ? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10) + : 0, }; const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10); @@ -156,7 +159,7 @@ router.get('/', async function (req, res) { if ( webSearchConfig != null && (webSearchConfig.searchProvider || - webSearchConfig.scraperType || + webSearchConfig.scraperProvider || webSearchConfig.rerankerType) ) { payload.webSearch = {}; @@ -165,8 +168,8 @@ router.get('/', async function (req, res) { if (webSearchConfig?.searchProvider) { payload.webSearch.searchProvider = webSearchConfig.searchProvider; } - if (webSearchConfig?.scraperType) { - payload.webSearch.scraperType = webSearchConfig.scraperType; + if (webSearchConfig?.scraperProvider) { + payload.webSearch.scraperProvider = webSearchConfig.scraperProvider; } if (webSearchConfig?.rerankerType) { payload.webSearch.rerankerType = webSearchConfig.rerankerType; diff --git a/api/server/routes/edit/index.js b/api/server/routes/edit/index.js index 92a1e63f63..2ebc57a13f 100644 --- a/api/server/routes/edit/index.js +++ b/api/server/routes/edit/index.js @@ -1,19 +1,19 @@ +const { isEnabled } = require('@librechat/api'); +const { EModelEndpoint } = require('librechat-data-provider'); +const { + validateConvoAccess, + messageUserLimiter, + concurrentLimiter, + messageIpLimiter, + requireJwtAuth, + checkBan, + uaParser, +} = require('~/server/middleware'); +const anthropic = require('./anthropic'); const express = require('express'); const openAI = require('./openAI'); const custom = require('./custom'); const google = require('./google'); -const anthropic = require('./anthropic'); -const { isEnabled } = require('~/server/utils'); -const { EModelEndpoint } = require('librechat-data-provider'); -const { - checkBan, - uaParser, - requireJwtAuth, - messageIpLimiter, - concurrentLimiter, - messageUserLimiter, - validateConvoAccess, -} = require('~/server/middleware'); const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {}; diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index 5d6e72ff2d..5d9761fc6d 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -1,6 +1,7 @@ const fs = require('fs').promises; const express = require('express'); const { EnvVar } = require('@librechat/agents'); +const { logger } = require('@librechat/data-schemas'); const { Time, isUUID, @@ -30,7 +31,6 @@ const { cleanFileName } = require('~/server/utils/files'); const { getAssistant } = require('~/models/Assistant'); const { getAgent } = require('~/models/Agent'); const { getLogStores } = require('~/cache'); -const { logger } = require('~/config'); const { Readable } = require('stream'); const router = express.Router(); diff --git a/api/server/routes/files/speech/tts.js b/api/server/routes/files/speech/tts.js index 1ee540874f..ab1de205b7 100644 --- a/api/server/routes/files/speech/tts.js +++ b/api/server/routes/files/speech/tts.js @@ -1,9 +1,9 @@ const multer = require('multer'); const express = require('express'); +const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); const { getVoices, streamAudio, textToSpeech } = require('~/server/services/Files/Audio'); const { getLogStores } = require('~/cache'); -const { logger } = require('~/config'); const router = express.Router(); const upload = multer(); diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index b1022136e3..e8415fd801 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -65,6 +65,7 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => { serverName, serverUrl, userId, + getOAuthHeaders(serverName), oauthConfig, ); @@ -132,7 +133,12 @@ router.get('/:serverName/oauth/callback', async (req, res) => { }); logger.debug('[MCP OAuth] Completing OAuth flow'); - const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager); + const tokens = await MCPOAuthHandler.completeOAuthFlow( + flowId, + code, + flowManager, + getOAuthHeaders(serverName), + ); logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route'); /** Persist tokens immediately so reconnection uses fresh credentials */ @@ -538,4 +544,10 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => { } }); +function getOAuthHeaders(serverName) { + const mcpManager = getMCPManager(); + const serverConfig = mcpManager.getRawConfig(serverName); + return serverConfig?.oauth_headers ?? {}; +} + module.exports = router; diff --git a/api/server/routes/presets.js b/api/server/routes/presets.js index 19214a3a7d..b8be1b8f3a 100644 --- a/api/server/routes/presets.js +++ b/api/server/routes/presets.js @@ -1,8 +1,8 @@ -const express = require('express'); const crypto = require('crypto'); +const express = require('express'); +const { logger } = require('@librechat/data-schemas'); const { getPresets, savePreset, deletePresets } = require('~/models'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); -const { logger } = require('~/config'); const router = express.Router(); router.use(requireJwtAuth); diff --git a/api/server/routes/search.js b/api/server/routes/search.js index 5c7846aee1..2cd2fc3534 100644 --- a/api/server/routes/search.js +++ b/api/server/routes/search.js @@ -1,7 +1,7 @@ const express = require('express'); const { MeiliSearch } = require('meilisearch'); +const { isEnabled } = require('@librechat/api'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); -const { isEnabled } = require('~/server/utils'); const router = express.Router(); diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 14c25271fc..6400b8b637 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -99,7 +99,8 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => { try { - const created = await createSharedLink(req.user.id, req.params.conversationId); + const { targetMessageId } = req.body; + const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId); if (created) { res.status(200).json(created); } else { diff --git a/api/server/routes/static.js b/api/server/routes/static.js index 952ca82fb2..12dfc0ed49 100644 --- a/api/server/routes/static.js +++ b/api/server/routes/static.js @@ -1,7 +1,7 @@ const express = require('express'); +const { isEnabled } = require('@librechat/api'); const staticCache = require('../utils/staticCache'); const paths = require('~/config/paths'); -const { isEnabled } = require('~/server/utils'); const skipGzipScan = !isEnabled(process.env.ENABLE_IMAGE_OUTPUT_GZIP_SCAN); diff --git a/api/server/routes/tokenizer.js b/api/server/routes/tokenizer.js index e12a86bde1..62eb31b70e 100644 --- a/api/server/routes/tokenizer.js +++ b/api/server/routes/tokenizer.js @@ -1,8 +1,9 @@ const express = require('express'); -const router = express.Router(); +const { logger } = require('@librechat/data-schemas'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const { countTokens } = require('~/server/utils'); -const { logger } = require('~/config'); + +const router = express.Router(); router.post('/', requireJwtAuth, async (req, res) => { try { diff --git a/api/server/services/AppService.interface.spec.js b/api/server/services/AppService.interface.spec.js deleted file mode 100644 index f4b1c67c38..0000000000 --- a/api/server/services/AppService.interface.spec.js +++ /dev/null @@ -1,198 +0,0 @@ -jest.mock('@librechat/data-schemas', () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - }, -})); - -jest.mock('@librechat/api', () => ({ - ...jest.requireActual('@librechat/api'), - loadDefaultInterface: jest.fn(), -})); -jest.mock('./start/tools', () => ({ - loadAndFormatTools: jest.fn().mockReturnValue({}), -})); -jest.mock('./start/checks', () => ({ - checkVariables: jest.fn(), - checkHealth: jest.fn(), - checkConfig: jest.fn(), - checkAzureVariables: jest.fn(), - checkWebSearchConfig: jest.fn(), -})); - -jest.mock('./Config/loadCustomConfig', () => jest.fn()); - -const AppService = require('./AppService'); -const { loadDefaultInterface } = require('@librechat/api'); - -describe('AppService interface configuration', () => { - let mockLoadCustomConfig; - - beforeEach(() => { - jest.resetModules(); - jest.clearAllMocks(); - mockLoadCustomConfig = require('./Config/loadCustomConfig'); - }); - - it('should set prompts and bookmarks to true when loadDefaultInterface returns true for both', async () => { - mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: true }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - prompts: true, - bookmarks: true, - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should set prompts and bookmarks to false when loadDefaultInterface returns false for both', async () => { - mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: false, bookmarks: false } }); - loadDefaultInterface.mockResolvedValue({ prompts: false, bookmarks: false }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - prompts: false, - bookmarks: false, - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should not set prompts and bookmarks when loadDefaultInterface returns undefined for both', async () => { - mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({}); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.anything(), - }), - ); - - // Verify that prompts and bookmarks are undefined when not provided - expect(result.interfaceConfig.prompts).toBeUndefined(); - expect(result.interfaceConfig.bookmarks).toBeUndefined(); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should set prompts and bookmarks to different values when loadDefaultInterface returns different values', async () => { - mockLoadCustomConfig.mockResolvedValue({ interface: { prompts: true, bookmarks: false } }); - loadDefaultInterface.mockResolvedValue({ prompts: true, bookmarks: false }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - prompts: true, - bookmarks: false, - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should correctly configure peoplePicker permissions including roles', async () => { - mockLoadCustomConfig.mockResolvedValue({ - interface: { - peoplePicker: { - users: true, - groups: true, - roles: true, - }, - }, - }); - loadDefaultInterface.mockResolvedValue({ - peoplePicker: { - users: true, - groups: true, - roles: true, - }, - }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - peoplePicker: expect.objectContaining({ - users: true, - groups: true, - roles: true, - }), - }), - }), - ); - expect(loadDefaultInterface).toHaveBeenCalled(); - }); - - it('should handle mixed peoplePicker permissions', async () => { - mockLoadCustomConfig.mockResolvedValue({ - interface: { - peoplePicker: { - users: true, - groups: false, - roles: true, - }, - }, - }); - loadDefaultInterface.mockResolvedValue({ - peoplePicker: { - users: true, - groups: false, - roles: true, - }, - }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - peoplePicker: expect.objectContaining({ - users: true, - groups: false, - roles: true, - }), - }), - }), - ); - }); - - it('should set default peoplePicker permissions when not provided', async () => { - mockLoadCustomConfig.mockResolvedValue({}); - loadDefaultInterface.mockResolvedValue({ - peoplePicker: { - users: true, - groups: true, - roles: true, - }, - }); - - const result = await AppService(); - - expect(result).toEqual( - expect.objectContaining({ - interfaceConfig: expect.objectContaining({ - peoplePicker: expect.objectContaining({ - users: true, - groups: true, - roles: true, - }), - }), - }), - ); - }); -}); diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 1792de66db..0098e54124 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -129,7 +129,7 @@ const verifyEmail = async (req) => { return { message: 'Email already verified', status: 'success' }; } - let emailVerificationData = await findToken({ email: decodedEmail }); + let emailVerificationData = await findToken({ email: decodedEmail }, { sort: { createdAt: -1 } }); if (!emailVerificationData) { logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`); @@ -319,9 +319,12 @@ const requestPasswordReset = async (req) => { * @returns */ const resetPassword = async (userId, token, password) => { - let passwordResetToken = await findToken({ - userId, - }); + let passwordResetToken = await findToken( + { + userId, + }, + { sort: { createdAt: -1 } }, + ); if (!passwordResetToken) { return new Error('Invalid or expired password reset token'); diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index ec6af77432..75a5cbe56d 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -1,11 +1,25 @@ -const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); -const AppService = require('~/server/services/AppService'); +const { logger, AppService } = require('@librechat/data-schemas'); +const { loadAndFormatTools } = require('~/server/services/start/tools'); +const loadCustomConfig = require('./loadCustomConfig'); const { setCachedTools } = require('./getCachedTools'); const getLogStores = require('~/cache/getLogStores'); +const paths = require('~/config/paths'); const BASE_CONFIG_KEY = '_BASE_'; +const loadBaseConfig = async () => { + /** @type {TCustomConfig} */ + const config = (await loadCustomConfig()) ?? {}; + /** @type {Record} */ + const systemTools = loadAndFormatTools({ + adminFilter: config.filteredTools, + adminIncluded: config.includedTools, + directory: paths.structuredTools, + }); + return AppService({ config, paths, systemTools }); +}; + /** * Get the app configuration based on user context * @param {Object} [options] @@ -29,7 +43,7 @@ async function getAppConfig(options = {}) { let baseConfig = await cache.get(BASE_CONFIG_KEY); if (!baseConfig) { logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...'); - baseConfig = await AppService(); + baseConfig = await loadBaseConfig(); if (!baseConfig) { throw new Error('Failed to initialize app configuration through AppService.'); diff --git a/api/server/services/Config/handleRateLimits.js b/api/server/services/Config/handleRateLimits.js deleted file mode 100644 index 5e81c5f68d..0000000000 --- a/api/server/services/Config/handleRateLimits.js +++ /dev/null @@ -1,48 +0,0 @@ -const { RateLimitPrefix } = require('librechat-data-provider'); - -/** - * - * @param {TCustomConfig['rateLimits'] | undefined} rateLimits - */ -const handleRateLimits = (rateLimits) => { - if (!rateLimits) { - return; - } - - const rateLimitKeys = { - fileUploads: RateLimitPrefix.FILE_UPLOAD, - conversationsImport: RateLimitPrefix.IMPORT, - tts: RateLimitPrefix.TTS, - stt: RateLimitPrefix.STT, - }; - - Object.entries(rateLimitKeys).forEach(([key, prefix]) => { - const rateLimit = rateLimits[key]; - if (rateLimit) { - setRateLimitEnvVars(prefix, rateLimit); - } - }); -}; - -/** - * Set environment variables for rate limit configurations - * - * @param {string} prefix - Prefix for environment variable names - * @param {object} rateLimit - Rate limit configuration object - */ -const setRateLimitEnvVars = (prefix, rateLimit) => { - const envVarsMapping = { - ipMax: `${prefix}_IP_MAX`, - ipWindowInMinutes: `${prefix}_IP_WINDOW`, - userMax: `${prefix}_USER_MAX`, - userWindowInMinutes: `${prefix}_USER_WINDOW`, - }; - - Object.entries(envVarsMapping).forEach(([key, envVar]) => { - if (rateLimit[key] !== undefined) { - process.env[envVar] = rateLimit[key]; - } - }); -}; - -module.exports = handleRateLimits; diff --git a/api/server/services/Config/ldap.js b/api/server/services/Config/ldap.js index 96386d0426..09091b78a4 100644 --- a/api/server/services/Config/ldap.js +++ b/api/server/services/Config/ldap.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('~/server/utils'); +const { isEnabled } = require('@librechat/api'); /** @returns {TStartupConfig['ldap'] | undefined} */ const getLdapConfig = () => { diff --git a/api/server/services/Config/loadConfigModels.js b/api/server/services/Config/loadConfigModels.js index 9ef8994241..840d957fa1 100644 --- a/api/server/services/Config/loadConfigModels.js +++ b/api/server/services/Config/loadConfigModels.js @@ -85,7 +85,9 @@ async function loadConfigModels(req) { } if (Array.isArray(models.default)) { - modelsConfig[name] = models.default; + modelsConfig[name] = models.default.map((model) => + typeof model === 'string' ? model : model.name, + ); } } diff --git a/api/server/services/Config/loadConfigModels.spec.js b/api/server/services/Config/loadConfigModels.spec.js index b8d577667a..1e0e8780a7 100644 --- a/api/server/services/Config/loadConfigModels.spec.js +++ b/api/server/services/Config/loadConfigModels.spec.js @@ -254,8 +254,8 @@ describe('loadConfigModels', () => { // For groq and ollama, since the apiKey is "user_provided", models should not be fetched // Depending on your implementation's behavior regarding "default" models without fetching, // you may need to adjust the following assertions: - expect(result.groq).toBe(exampleConfig.endpoints.custom[2].models.default); - expect(result.ollama).toBe(exampleConfig.endpoints.custom[3].models.default); + expect(result.groq).toEqual(exampleConfig.endpoints.custom[2].models.default); + expect(result.ollama).toEqual(exampleConfig.endpoints.custom[3].models.default); // Verifying fetchModels was not called for groq and ollama expect(fetchModels).not.toHaveBeenCalledWith( diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 5ff15715e8..ad428657a1 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -5,14 +5,12 @@ const keyBy = require('lodash/keyBy'); const { loadYaml } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { - CacheKeys, configSchema, paramSettings, EImageOutputType, agentParamSettings, validateSettingDefinitions, } = require('librechat-data-provider'); -const getLogStores = require('~/cache/getLogStores'); const { syncCategories } = require('~/server/utils/agentCategory'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); @@ -138,7 +136,6 @@ https://www.librechat.ai/docs/configuration/stt_tts`); .filter((endpoint) => endpoint.customParams) .forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams)); - if (result.data.modelSpecs) { customConfig.modelSpecs = result.data.modelSpecs; } diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js index 1966834ed4..ec9d56d026 100644 --- a/api/server/services/Endpoints/agents/agent.js +++ b/api/server/services/Endpoints/agents/agent.js @@ -143,7 +143,7 @@ const initializeAgent = async ({ const agentMaxContextTokens = optionalChainWithEmptyCheck( maxContextTokens, getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig), - 4096, + 18000, ); if ( diff --git a/api/server/services/Endpoints/anthropic/title.js b/api/server/services/Endpoints/anthropic/title.js index 0f9a5e97d0..cac39fa2be 100644 --- a/api/server/services/Endpoints/anthropic/title.js +++ b/api/server/services/Endpoints/anthropic/title.js @@ -1,6 +1,6 @@ +const { isEnabled } = require('@librechat/api'); const { CacheKeys } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); -const { isEnabled } = require('~/server/utils'); const { saveConvo } = require('~/models'); const addTitle = async (req, { text, response, client }) => { diff --git a/api/server/services/Endpoints/assistants/title.js b/api/server/services/Endpoints/assistants/title.js index 605d174130..223d3badc6 100644 --- a/api/server/services/Endpoints/assistants/title.js +++ b/api/server/services/Endpoints/assistants/title.js @@ -1,7 +1,7 @@ +const { isEnabled } = require('@librechat/api'); const { CacheKeys } = require('librechat-data-provider'); const { saveConvo } = require('~/models/Conversation'); const getLogStores = require('~/cache/getLogStores'); -const { isEnabled } = require('~/server/utils'); const addTitle = async (req, { text, responseText, conversationId, client }) => { const { TITLE_CONVO = 'true' } = process.env ?? {}; diff --git a/api/server/services/Endpoints/openAI/title.js b/api/server/services/Endpoints/openAI/title.js index 3b9e9c82b7..f8624ef657 100644 --- a/api/server/services/Endpoints/openAI/title.js +++ b/api/server/services/Endpoints/openAI/title.js @@ -1,6 +1,6 @@ +const { isEnabled } = require('@librechat/api'); const { CacheKeys } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); -const { isEnabled } = require('~/server/utils'); const { saveConvo } = require('~/models'); const addTitle = async (req, { text, response, client }) => { diff --git a/api/server/services/Files/Azure/crud.js b/api/server/services/Files/Azure/crud.js index cb52de8317..25bd749276 100644 --- a/api/server/services/Files/Azure/crud.js +++ b/api/server/services/Files/Azure/crud.js @@ -3,8 +3,8 @@ const path = require('path'); const mime = require('mime'); const axios = require('axios'); const fetch = require('node-fetch'); -const { logger } = require('~/config'); -const { getAzureContainerClient } = require('./initialize'); +const { logger } = require('@librechat/data-schemas'); +const { getAzureContainerClient } = require('@librechat/api'); const defaultBasePath = 'images'; const { AZURE_STORAGE_PUBLIC_ACCESS = 'true', AZURE_CONTAINER_NAME = 'files' } = process.env; @@ -30,7 +30,7 @@ async function saveBufferToAzure({ containerName, }) { try { - const containerClient = getAzureContainerClient(containerName); + const containerClient = await getAzureContainerClient(containerName); const access = AZURE_STORAGE_PUBLIC_ACCESS?.toLowerCase() === 'true' ? 'blob' : undefined; // Create the container if it doesn't exist. This is done per operation. await containerClient.createIfNotExists({ access }); @@ -84,7 +84,7 @@ async function saveURLToAzure({ */ async function getAzureURL({ fileName, basePath = defaultBasePath, userId, containerName }) { try { - const containerClient = getAzureContainerClient(containerName); + const containerClient = await getAzureContainerClient(containerName); const blobPath = userId ? `${basePath}/${userId}/${fileName}` : `${basePath}/${fileName}`; const blockBlobClient = containerClient.getBlockBlobClient(blobPath); return blockBlobClient.url; @@ -103,7 +103,7 @@ async function getAzureURL({ fileName, basePath = defaultBasePath, userId, conta */ async function deleteFileFromAzure(req, file) { try { - const containerClient = getAzureContainerClient(AZURE_CONTAINER_NAME); + const containerClient = await getAzureContainerClient(AZURE_CONTAINER_NAME); const blobPath = file.filepath.split(`${AZURE_CONTAINER_NAME}/`)[1]; if (!blobPath.includes(req.user.id)) { throw new Error('User ID not found in blob path'); @@ -140,7 +140,7 @@ async function streamFileToAzure({ containerName, }) { try { - const containerClient = getAzureContainerClient(containerName); + const containerClient = await getAzureContainerClient(containerName); const access = AZURE_STORAGE_PUBLIC_ACCESS?.toLowerCase() === 'true' ? 'blob' : undefined; // Create the container if it doesn't exist diff --git a/api/server/services/Files/Azure/index.js b/api/server/services/Files/Azure/index.js index 27ad97a852..21e2f2ba7d 100644 --- a/api/server/services/Files/Azure/index.js +++ b/api/server/services/Files/Azure/index.js @@ -1,9 +1,7 @@ const crud = require('./crud'); const images = require('./images'); -const initialize = require('./initialize'); module.exports = { ...crud, ...images, - ...initialize, }; diff --git a/api/server/services/Files/Firebase/crud.js b/api/server/services/Files/Firebase/crud.js index 35e327148c..8e7a191609 100644 --- a/api/server/services/Files/Firebase/crud.js +++ b/api/server/services/Files/Firebase/crud.js @@ -3,9 +3,9 @@ const path = require('path'); const axios = require('axios'); const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); +const { getFirebaseStorage } = require('@librechat/api'); const { ref, uploadBytes, getDownloadURL, deleteObject } = require('firebase/storage'); const { getBufferMetadata } = require('~/server/utils'); -const { getFirebaseStorage } = require('./initialize'); /** * Deletes a file from Firebase Storage. diff --git a/api/server/services/Files/Firebase/index.js b/api/server/services/Files/Firebase/index.js index 27ad97a852..21e2f2ba7d 100644 --- a/api/server/services/Files/Firebase/index.js +++ b/api/server/services/Files/Firebase/index.js @@ -1,9 +1,7 @@ const crud = require('./crud'); const images = require('./images'); -const initialize = require('./initialize'); module.exports = { ...crud, ...images, - ...initialize, }; diff --git a/api/server/services/Files/Firebase/initialize.js b/api/server/services/Files/Firebase/initialize.js deleted file mode 100644 index 67d923c44f..0000000000 --- a/api/server/services/Files/Firebase/initialize.js +++ /dev/null @@ -1,39 +0,0 @@ -const firebase = require('firebase/app'); -const { getStorage } = require('firebase/storage'); -const { logger } = require('~/config'); - -let i = 0; -let firebaseApp = null; - -const initializeFirebase = () => { - // Return existing instance if already initialized - if (firebaseApp) { - return firebaseApp; - } - - const firebaseConfig = { - apiKey: process.env.FIREBASE_API_KEY, - authDomain: process.env.FIREBASE_AUTH_DOMAIN, - projectId: process.env.FIREBASE_PROJECT_ID, - storageBucket: process.env.FIREBASE_STORAGE_BUCKET, - messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, - appId: process.env.FIREBASE_APP_ID, - }; - - if (Object.values(firebaseConfig).some((value) => !value)) { - i === 0 && logger.info('[Optional] CDN not initialized.'); - i++; - return null; - } - - firebaseApp = firebase.initializeApp(firebaseConfig); - logger.info('Firebase CDN initialized'); - return firebaseApp; -}; - -const getFirebaseStorage = () => { - const app = initializeFirebase(); - return app ? getStorage(app) : null; -}; - -module.exports = { initializeFirebase, getFirebaseStorage }; diff --git a/api/server/services/Files/Local/crud.js b/api/server/services/Files/Local/crud.js index 16e75ba200..d3a3a21538 100644 --- a/api/server/services/Files/Local/crud.js +++ b/api/server/services/Files/Local/crud.js @@ -4,6 +4,7 @@ const axios = require('axios'); const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint } = require('librechat-data-provider'); const { generateShortLivedToken } = require('@librechat/api'); +const { resizeImageBuffer } = require('~/server/services/Files/images/resize'); const { getBufferMetadata } = require('~/server/utils'); const paths = require('~/config/paths'); @@ -286,7 +287,18 @@ async function uploadLocalFile({ req, file, file_id }) { await fs.promises.writeFile(newPath, inputBuffer); const filepath = path.posix.join('/', 'uploads', req.user.id, path.basename(newPath)); - return { filepath, bytes }; + let height, width; + if (file.mimetype && file.mimetype.startsWith('image/')) { + try { + const { width: imgWidth, height: imgHeight } = await resizeImageBuffer(inputBuffer, 'high'); + height = imgHeight; + width = imgWidth; + } catch (error) { + logger.warn('[uploadLocalFile] Could not get image dimensions:', error.message); + } + } + + return { filepath, bytes, height, width }; } /** diff --git a/api/server/services/Files/OpenAI/crud.js b/api/server/services/Files/OpenAI/crud.js index 9afe217f60..674c493a34 100644 --- a/api/server/services/Files/OpenAI/crud.js +++ b/api/server/services/Files/OpenAI/crud.js @@ -1,7 +1,7 @@ const fs = require('fs'); +const { sleep } = require('@librechat/agents'); +const { logger } = require('@librechat/data-schemas'); const { FilePurpose } = require('librechat-data-provider'); -const { sleep } = require('~/server/utils'); -const { logger } = require('~/config'); /** * Uploads a file that can be used across various OpenAI services. diff --git a/api/server/services/Files/S3/crud.js b/api/server/services/Files/S3/crud.js index 78220ed30b..8dac767aa2 100644 --- a/api/server/services/Files/S3/crud.js +++ b/api/server/services/Files/S3/crud.js @@ -1,15 +1,15 @@ const fs = require('fs'); const fetch = require('node-fetch'); +const { initializeS3 } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { FileSources } = require('librechat-data-provider'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { PutObjectCommand, GetObjectCommand, HeadObjectCommand, DeleteObjectCommand, } = require('@aws-sdk/client-s3'); -const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); -const { initializeS3 } = require('./initialize'); -const { logger } = require('~/config'); const bucketName = process.env.AWS_BUCKET_NAME; const defaultBasePath = 'images'; diff --git a/api/server/services/Files/S3/index.js b/api/server/services/Files/S3/index.js index 27ad97a852..21e2f2ba7d 100644 --- a/api/server/services/Files/S3/index.js +++ b/api/server/services/Files/S3/index.js @@ -1,9 +1,7 @@ const crud = require('./crud'); const images = require('./images'); -const initialize = require('./initialize'); module.exports = { ...crud, ...images, - ...initialize, }; diff --git a/api/server/services/Files/images/avatar.js b/api/server/services/Files/images/avatar.js index 8e81dea26c..9ce11c04ec 100644 --- a/api/server/services/Files/images/avatar.js +++ b/api/server/services/Files/images/avatar.js @@ -1,9 +1,9 @@ const sharp = require('sharp'); const fs = require('fs').promises; const fetch = require('node-fetch'); +const { logger } = require('@librechat/data-schemas'); const { EImageOutputType } = require('librechat-data-provider'); const { resizeAndConvert } = require('./resize'); -const { logger } = require('~/config'); /** * Uploads an avatar image for a user. This function can handle various types of input (URL, Buffer, or File object), diff --git a/api/server/services/Files/images/convert.js b/api/server/services/Files/images/convert.js index 446de5ba1c..d5f5c2893b 100644 --- a/api/server/services/Files/images/convert.js +++ b/api/server/services/Files/images/convert.js @@ -1,9 +1,9 @@ const fs = require('fs'); const path = require('path'); const sharp = require('sharp'); +const { logger } = require('@librechat/data-schemas'); const { getStrategyFunctions } = require('../strategies'); const { resizeImageBuffer } = require('./resize'); -const { logger } = require('~/config'); /** * Converts an image file or buffer to target output type with specified resolution. diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js index 34128e3152..7609ed388a 100644 --- a/api/server/services/Files/images/encode.js +++ b/api/server/services/Files/images/encode.js @@ -1,16 +1,14 @@ const axios = require('axios'); +const { logAxiosError } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); -const { logAxiosError, processTextWithTokenLimit } = require('@librechat/api'); const { FileSources, VisionModes, ImageDetail, ContentTypes, EModelEndpoint, - mergeFileConfig, } = require('librechat-data-provider'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); -const countTokens = require('~/server/utils/countTokens'); /** * Converts a readable stream to a base64 encoded string. @@ -88,15 +86,14 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]); * @param {Array} files - The array of files to encode and format. * @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image. * @param {string} [mode] - Optional: The endpoint mode for the image. - * @returns {Promise<{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details. + * @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details. */ async function encodeAndFormat(req, files, endpoint, mode) { const promises = []; /** @type {Record, 'prepareImagePayload' | 'getDownloadStream'>>} */ const encodingMethods = {}; - /** @type {{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */ + /** @type {{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */ const result = { - text: '', files: [], image_urls: [], }; @@ -105,29 +102,9 @@ async function encodeAndFormat(req, files, endpoint, mode) { return result; } - const fileTokenLimit = - req.body?.fileTokenLimit ?? mergeFileConfig(req.config?.fileConfig).fileTokenLimit; - for (let file of files) { /** @type {FileSources} */ const source = file.source ?? FileSources.local; - if (source === FileSources.text && file.text) { - let fileText = file.text; - - const { text: limitedText, wasTruncated } = await processTextWithTokenLimit({ - text: fileText, - tokenLimit: fileTokenLimit, - tokenCountFn: (text) => countTokens(text), - }); - - if (wasTruncated) { - logger.debug( - `[encodeAndFormat] Text content truncated for file: ${file.filename} due to token limits`, - ); - } - - result.text += `${!result.text ? 'Attached document(s):\n```md' : '\n\n---\n\n'}# "${file.filename}"\n${limitedText}\n`; - } if (!file.height) { promises.push([file, null]); @@ -165,10 +142,6 @@ async function encodeAndFormat(req, files, endpoint, mode) { promises.push(preparePayload(req, file)); } - if (result.text) { - result.text += '\n```'; - } - const detail = req.body.imageDetail ?? ImageDetail.auto; /** @type {Array<[MongoFile, string]>} */ diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index c8221a6de5..701412523d 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -508,7 +508,10 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { const { file } = req; const appConfig = req.config; const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata; - if (agent_id && !tool_resource) { + + let messageAttachment = !!metadata.message_file; + + if (agent_id && !tool_resource && !messageAttachment) { throw new Error('No tool resource provided for agent file upload'); } @@ -516,17 +519,11 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { throw new Error('Image uploads are not supported for file search tool resources'); } - let messageAttachment = !!metadata.message_file; if (!messageAttachment && !agent_id) { throw new Error('No agent ID provided for agent file upload'); } const isImage = file.mimetype.startsWith('image'); - if (!isImage && !tool_resource) { - /** Note: this needs to be removed when we can support files to providers */ - throw new Error('No tool resource provided for non-image agent file upload'); - } - let fileInfoMetadata; const entity_id = messageAttachment === true ? undefined : agent_id; const basePath = mime.getType(file.originalname)?.startsWith('image') ? 'images' : 'uploads'; @@ -601,11 +598,22 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { if (shouldUseOCR && !(await checkCapability(req, AgentCapabilities.ocr))) { throw new Error('OCR capability is not enabled for Agents'); } else if (shouldUseOCR) { - const { handleFileUpload: uploadOCR } = getStrategyFunctions( - appConfig?.ocr?.strategy ?? FileSources.mistral_ocr, - ); - const { text, bytes, filepath: ocrFileURL } = await uploadOCR({ req, file, loadAuthValues }); - return await createTextFile({ text, bytes, filepath: ocrFileURL }); + try { + const { handleFileUpload: uploadOCR } = getStrategyFunctions( + appConfig?.ocr?.strategy ?? FileSources.mistral_ocr, + ); + const { + text, + bytes, + filepath: ocrFileURL, + } = await uploadOCR({ req, file, loadAuthValues }); + return await createTextFile({ text, bytes, filepath: ocrFileURL }); + } catch (ocrError) { + logger.error( + `[processAgentFileUpload] OCR processing failed for file "${file.originalname}", falling back to text extraction:`, + ocrError, + ); + } } const shouldUseSTT = fileConfig.checkType( diff --git a/api/server/services/GraphTokenService.js b/api/server/services/GraphTokenService.js index d70e7c01c5..d5cd6a94f2 100644 --- a/api/server/services/GraphTokenService.js +++ b/api/server/services/GraphTokenService.js @@ -1,8 +1,8 @@ -const { getOpenIdConfig } = require('~/strategies/openidStrategy'); -const { logger } = require('~/config'); -const { CacheKeys } = require('librechat-data-provider'); -const getLogStores = require('~/cache/getLogStores'); const client = require('openid-client'); +const { logger } = require('@librechat/data-schemas'); +const { CacheKeys } = require('librechat-data-provider'); +const { getOpenIdConfig } = require('~/strategies/openidStrategy'); +const getLogStores = require('~/cache/getLogStores'); /** * Get Microsoft Graph API token using existing token exchange mechanism diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 162e02d91e..b7975b12fa 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -450,7 +450,7 @@ async function getMCPSetupData(userId) { logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error); } const userConnections = mcpManager.getUserConnections(userId) || new Map(); - const oauthServers = mcpManager.getOAuthServers() || new Set(); + const oauthServers = mcpManager.getOAuthServers(); return { mcpConfig, diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 8b9f7b675d..7b192995e3 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -170,7 +170,7 @@ describe('tests for the new helper functions used by the MCP connection status e const mockMCPManager = { appConnections: { getAll: jest.fn(() => null) }, getUserConnections: jest.fn(() => null), - getOAuthServers: jest.fn(() => null), + getOAuthServers: jest.fn(() => new Set()), }; mockGetMCPManager.mockReturnValue(mockMCPManager); diff --git a/api/server/services/Runs/RunManager.js b/api/server/services/Runs/RunManager.js index 39e577fb51..7a3ec808df 100644 --- a/api/server/services/Runs/RunManager.js +++ b/api/server/services/Runs/RunManager.js @@ -1,5 +1,5 @@ +const { logger } = require('@librechat/data-schemas'); const { ToolCallTypes } = require('librechat-data-provider'); -const { logger } = require('~/config'); /** * @typedef {import('openai').OpenAI} OpenAI diff --git a/api/server/services/Runs/handle.js b/api/server/services/Runs/handle.js index abe933f92a..c515fabfe4 100644 --- a/api/server/services/Runs/handle.js +++ b/api/server/services/Runs/handle.js @@ -1,9 +1,9 @@ +const { sleep } = require('@librechat/agents'); +const { logger } = require('@librechat/data-schemas'); const { RunStatus, defaultOrderQuery, CacheKeys } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); const { retrieveRun } = require('./methods'); -const { sleep } = require('~/server/utils'); const RunManager = require('./RunManager'); -const { logger } = require('~/config'); async function withTimeout(promise, timeoutMs, timeoutMessage) { let timeoutHandle; diff --git a/api/server/services/cleanup.js b/api/server/services/cleanup.js index 814c0ecc94..7d3dfdec12 100644 --- a/api/server/services/cleanup.js +++ b/api/server/services/cleanup.js @@ -1,5 +1,6 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); const { deleteNullOrEmptyConversations } = require('~/models/Conversation'); + const cleanup = async () => { try { await deleteNullOrEmptyConversations(); diff --git a/api/server/services/start/modelSpecs.js b/api/server/services/start/modelSpecs.js deleted file mode 100644 index 057255010f..0000000000 --- a/api/server/services/start/modelSpecs.js +++ /dev/null @@ -1,75 +0,0 @@ -const { logger } = require('@librechat/data-schemas'); -const { normalizeEndpointName } = require('@librechat/api'); -const { EModelEndpoint } = require('librechat-data-provider'); - -/** - * Sets up Model Specs from the config (`librechat.yaml`) file. - * @param {TCustomConfig['endpoints']} [endpoints] - The loaded custom configuration for endpoints. - * @param {TCustomConfig['modelSpecs'] | undefined} [modelSpecs] - The loaded custom configuration for model specs. - * @param {TCustomConfig['interface'] | undefined} [interfaceConfig] - The loaded interface configuration. - * @returns {TCustomConfig['modelSpecs'] | undefined} The processed model specs, if any. - */ -function processModelSpecs(endpoints, _modelSpecs, interfaceConfig) { - if (!_modelSpecs) { - return undefined; - } - - /** @type {TCustomConfig['modelSpecs']['list']} */ - const modelSpecs = []; - /** @type {TCustomConfig['modelSpecs']['list']} */ - const list = _modelSpecs.list; - - const customEndpoints = endpoints?.[EModelEndpoint.custom] ?? []; - - if (interfaceConfig.modelSelect !== true && (_modelSpecs.addedEndpoints?.length ?? 0) > 0) { - logger.warn( - `To utilize \`addedEndpoints\`, which allows provider/model selections alongside model specs, set \`modelSelect: true\` in the interface configuration. - - Example: - \`\`\`yaml - interface: - modelSelect: true - \`\`\` - `, - ); - } - - for (const spec of list) { - if (EModelEndpoint[spec.preset.endpoint] && spec.preset.endpoint !== EModelEndpoint.custom) { - modelSpecs.push(spec); - continue; - } else if (spec.preset.endpoint === EModelEndpoint.custom) { - logger.warn( - `Model Spec with endpoint "${spec.preset.endpoint}" is not supported. You must specify the name of the custom endpoint (case-sensitive, as defined in your config). Skipping model spec...`, - ); - continue; - } - - const normalizedName = normalizeEndpointName(spec.preset.endpoint); - const endpoint = customEndpoints.find( - (customEndpoint) => normalizedName === normalizeEndpointName(customEndpoint.name), - ); - - if (!endpoint) { - logger.warn(`Model spec with endpoint "${spec.preset.endpoint}" was skipped: Endpoint not found in configuration. The \`endpoint\` value must exactly match either a system-defined endpoint or a custom endpoint defined by the user. - -For more information, see the documentation at https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/model_specs#endpoint`); - continue; - } - - modelSpecs.push({ - ...spec, - preset: { - ...spec.preset, - endpoint: normalizedName, - }, - }); - } - - return { - ..._modelSpecs, - list: modelSpecs, - }; -} - -module.exports = { processModelSpecs }; diff --git a/api/server/services/start/turnstile.js b/api/server/services/start/turnstile.js deleted file mode 100644 index 9309e680c7..0000000000 --- a/api/server/services/start/turnstile.js +++ /dev/null @@ -1,44 +0,0 @@ -const { logger } = require('@librechat/data-schemas'); -const { removeNullishValues } = require('librechat-data-provider'); - -/** - * Loads and maps the Cloudflare Turnstile configuration. - * - * Expected config structure: - * - * turnstile: - * siteKey: "your-site-key-here" - * options: - * language: "auto" // "auto" or an ISO 639-1 language code (e.g. en) - * size: "normal" // Options: "normal", "compact", "flexible", or "invisible" - * - * @param {TCustomConfig | undefined} config - The loaded custom configuration. - * @param {TConfigDefaults} configDefaults - The custom configuration default values. - * @returns {TCustomConfig['turnstile']} The mapped Turnstile configuration. - */ -function loadTurnstileConfig(config, configDefaults) { - const { turnstile: customTurnstile = {} } = config ?? {}; - const { turnstile: defaults = {} } = configDefaults; - - /** @type {TCustomConfig['turnstile']} */ - const loadedTurnstile = removeNullishValues({ - siteKey: customTurnstile.siteKey ?? defaults.siteKey, - options: customTurnstile.options ?? defaults.options, - }); - - const enabled = Boolean(loadedTurnstile.siteKey); - - if (enabled) { - logger.info( - 'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2), - ); - } else { - logger.info('Turnstile is DISABLED (no siteKey provided).'); - } - - return loadedTurnstile; -} - -module.exports = { - loadTurnstileConfig, -}; diff --git a/api/server/utils/countTokens.js b/api/server/utils/countTokens.js index 641e386101..504de26a5e 100644 --- a/api/server/utils/countTokens.js +++ b/api/server/utils/countTokens.js @@ -1,7 +1,7 @@ const { Tiktoken } = require('tiktoken/lite'); +const { logger } = require('@librechat/data-schemas'); const p50k_base = require('tiktoken/encoders/p50k_base.json'); const cl100k_base = require('tiktoken/encoders/cl100k_base.json'); -const logger = require('~/config/winston'); /** * Counts the number of tokens in a given text using a specified encoding model. diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index 91c2ff25ec..15c2db3fcc 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -125,34 +125,6 @@ function formatAction(action) { return formattedAction; } -/** - * Checks if the given value is truthy by being either the boolean `true` or a string - * that case-insensitively matches 'true'. - * - * @function - * @param {string|boolean|null|undefined} value - The value to check. - * @returns {boolean} Returns `true` if the value is the boolean `true` or a case-insensitive - * match for the string 'true', otherwise returns `false`. - * @example - * - * isEnabled("True"); // returns true - * isEnabled("TRUE"); // returns true - * isEnabled(true); // returns true - * isEnabled("false"); // returns false - * isEnabled(false); // returns false - * isEnabled(null); // returns false - * isEnabled(); // returns false - */ -function isEnabled(value) { - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'string') { - return value.toLowerCase().trim() === 'true'; - } - return false; -} - /** * Checks if the provided value is 'user_provided'. * @@ -207,7 +179,6 @@ function generateConfig(key, baseURL, endpoint) { } module.exports = { - isEnabled, handleText, formatSteps, escapeRegExp, diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index 5aa2599fe3..c4ce8cb5d4 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -1,10 +1,10 @@ const { v4: uuidv4 } = require('uuid'); +const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider'); const { createImportBatchBuilder } = require('./importBatchBuilder'); const BaseClient = require('~/app/clients/BaseClient'); const { getConvo } = require('~/models/Conversation'); const { getMessages } = require('~/models/Message'); -const logger = require('~/config/winston'); /** * Helper function to clone messages with proper parent-child relationships and timestamps diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index d20f200939..5e499043d2 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -1,9 +1,9 @@ const { v4: uuidv4 } = require('uuid'); +const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); const { bulkIncrementTagCounts } = require('~/models/ConversationTag'); const { bulkSaveConvos } = require('~/models/Conversation'); const { bulkSaveMessages } = require('~/models/Message'); -const { logger } = require('~/config'); /** * Factory function for creating an instance of ImportBatchBuilder. diff --git a/api/server/utils/import/importConversations.js b/api/server/utils/import/importConversations.js index 4d2bc4c333..d9e4d4332d 100644 --- a/api/server/utils/import/importConversations.js +++ b/api/server/utils/import/importConversations.js @@ -10,6 +10,15 @@ const importConversations = async (job) => { const { filepath, requestUserId } = job; try { logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`); + + /* error if file is too large */ + const fileInfo = await fs.stat(filepath); + if (fileInfo.size > process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES) { + throw new Error( + `File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES} bytes.`, + ); + } + const fileData = await fs.readFile(filepath, 'utf8'); const jsonData = JSON.parse(fileData); const importer = getImporter(jsonData); @@ -17,6 +26,7 @@ const importConversations = async (job) => { logger.debug(`user: ${requestUserId} | Finished importing conversations`); } catch (error) { logger.error(`user: ${requestUserId} | Failed to import conversation: `, error); + throw error; // throw error all the way up so request does not return success } finally { try { await fs.unlink(filepath); diff --git a/api/server/utils/queue.js b/api/server/utils/queue.js index c32adaeffd..73d819205c 100644 --- a/api/server/utils/queue.js +++ b/api/server/utils/queue.js @@ -53,17 +53,6 @@ function LB_QueueAsyncCall(asyncFunc, args, callback) { } } -/** - * Delays the execution for a specified number of milliseconds. - * - * @param {number} ms - The number of milliseconds to delay. - * @return {Promise} A promise that resolves after the specified delay. - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - module.exports = { - sleep, LB_QueueAsyncCall, }; diff --git a/api/strategies/appleStrategy.js b/api/strategies/appleStrategy.js index 4dbac2e364..fbba2a1f41 100644 --- a/api/strategies/appleStrategy.js +++ b/api/strategies/appleStrategy.js @@ -1,7 +1,7 @@ -const socialLogin = require('./socialLogin'); -const { Strategy: AppleStrategy } = require('passport-apple'); -const { logger } = require('~/config'); const jwt = require('jsonwebtoken'); +const { logger } = require('@librechat/data-schemas'); +const { Strategy: AppleStrategy } = require('passport-apple'); +const socialLogin = require('./socialLogin'); /** * Extract profile details from the decoded idToken diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index ce564fc655..079bed9e10 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -1,4 +1,5 @@ const undici = require('undici'); +const { get } = require('lodash'); const fetch = require('node-fetch'); const passport = require('passport'); const client = require('openid-client'); @@ -329,6 +330,12 @@ async function setupOpenId() { : 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata', }); + // Set of env variables that specify how to set if a user is an admin + // If not set, all users will be treated as regular users + const adminRole = process.env.OPENID_ADMIN_ROLE; + const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + const openidLogin = new CustomOpenIDStrategy( { config: openidConfig, @@ -386,20 +393,19 @@ async function setupOpenId() { } else if (requiredRoleTokenKind === 'id') { decodedToken = jwtDecode(tokenset.id_token); } - const pathParts = requiredRoleParameterPath.split('.'); - let found = true; - let roles = pathParts.reduce((o, key) => { - if (o === null || o === undefined || !(key in o)) { - found = false; - return []; - } - return o[key]; - }, decodedToken); - if (!found) { + let roles = get(decodedToken, requiredRoleParameterPath); + if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) { logger.error( - `[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`, + `[openidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`, ); + const rolesList = + requiredRoles.length === 1 + ? `"${requiredRoles[0]}"` + : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; + return done(null, false, { + message: `You must have ${rolesList} role to log in.`, + }); } if (!requiredRoles.some((role) => roles.includes(role))) { @@ -447,6 +453,50 @@ async function setupOpenId() { } } + if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { + let adminRoleObject; + switch (adminRoleTokenKind) { + case 'access': + adminRoleObject = jwtDecode(tokenset.access_token); + break; + case 'id': + adminRoleObject = jwtDecode(tokenset.id_token); + break; + case 'userinfo': + adminRoleObject = userinfo; + break; + default: + logger.error( + `[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, + ); + return done(new Error('Invalid admin role token kind')); + } + + const adminRoles = get(adminRoleObject, adminRoleParameterPath); + + // Accept 3 types of values for the object extracted from adminRoleParameterPath: + // 1. A boolean value indicating if the user is an admin + // 2. A string with a single role name + // 3. An array of role names + + if ( + adminRoles && + (adminRoles === true || + adminRoles === adminRole || + (Array.isArray(adminRoles) && adminRoles.includes(adminRole))) + ) { + user.role = 'ADMIN'; + logger.info( + `[openidStrategy] User ${username} is an admin based on role: ${adminRole}`, + ); + } else if (user.role === 'ADMIN') { + user.role = 'USER'; + logger.info( + `[openidStrategy] User ${username} demoted from admin - role no longer present in token`, + ); + } + } + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { /** @type {string | undefined} */ const imageUrl = userinfo.picture; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index e668e078de..fa6af7f40f 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -125,6 +125,9 @@ describe('setupOpenId', () => { process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'permissions'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id'; delete process.env.OPENID_USERNAME_CLAIM; delete process.env.OPENID_NAME_CLAIM; delete process.env.PROXY; @@ -133,6 +136,7 @@ describe('setupOpenId', () => { // Default jwtDecode mock returns a token that includes the required role. jwtDecode.mockReturnValue({ roles: ['requiredRole'], + permissions: ['admin'], }); // By default, assume that no user is found, so createUser will be called @@ -441,4 +445,475 @@ describe('setupOpenId', () => { expect(callOptions.usePKCE).toBe(false); expect(callOptions.params?.code_challenge_method).toBeUndefined(); }); + + it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => { + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the user role is set to "ADMIN" + expect(user.role).toBe('ADMIN'); + }); + + it('should not set user role if OPENID_ADMIN_ROLE is set but the user does not have that role', async () => { + // Arrange – simulate a token without the admin permission + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + permissions: ['not-admin'], + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the user role is not defined + expect(user.role).toBeUndefined(); + }); + + it('should demote existing admin user when admin role is removed from token', async () => { + // Arrange – simulate an existing user who is currently an admin + const existingAdminUser = { + _id: 'existingAdminId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'adminuser', + name: 'Admin User', + role: 'ADMIN', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingAdminUser; + } + return null; + }); + + // Token without admin permission + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + permissions: ['not-admin'], + }); + + const { logger } = require('@librechat/data-schemas'); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the user was demoted + expect(user.role).toBe('USER'); + expect(updateUser).toHaveBeenCalledWith( + existingAdminUser._id, + expect.objectContaining({ + role: 'USER', + }), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('demoted from admin - role no longer present in token'), + ); + }); + + it('should NOT demote admin user when admin role env vars are not configured', async () => { + // Arrange – remove admin role env vars + delete process.env.OPENID_ADMIN_ROLE; + delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + // Simulate an existing admin user + const existingAdminUser = { + _id: 'existingAdminId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'adminuser', + name: 'Admin User', + role: 'ADMIN', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingAdminUser; + } + return null; + }); + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + }); + + // Act + const { user } = await validate(tokenset); + + // Assert – verify that the admin user was NOT demoted + expect(user.role).toBe('ADMIN'); + expect(updateUser).toHaveBeenCalledWith( + existingAdminUser._id, + expect.objectContaining({ + role: 'ADMIN', + }), + ); + }); + + describe('lodash get - nested path extraction', () => { + it('should extract roles from deeply nested token path', async () => { + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-client.roles'; + + jwtDecode.mockReturnValue({ + resource_access: { + 'my-client': { + roles: ['app-user', 'viewer'], + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + expect(user.email).toBe(tokenset.claims().email); + }); + + it('should extract roles from three-level nested path', async () => { + process.env.OPENID_REQUIRED_ROLE = 'editor'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.access.permissions.roles'; + + jwtDecode.mockReturnValue({ + data: { + access: { + permissions: { + roles: ['editor', 'reader'], + }, + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + }); + + it('should log error and reject login when required role path does not exist in token', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.nonexistent.roles'; + + jwtDecode.mockReturnValue({ + resource_access: { + 'my-client': { + roles: ['app-user'], + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user, details } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + "Key 'resource_access.nonexistent.roles' not found or invalid type in id token!", + ), + ); + expect(user).toBe(false); + expect(details.message).toContain('role to log in'); + }); + + it('should handle missing intermediate nested path gracefully', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'org.team.roles'; + + jwtDecode.mockReturnValue({ + org: { + other: 'value', + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'org.team.roles' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + }); + + it('should extract admin role from nested path in access token', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'realm_access.roles'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access'; + + jwtDecode.mockImplementation((token) => { + if (token === 'fake_access_token') { + return { + realm_access: { + roles: ['admin', 'user'], + }, + }; + } + return { + roles: ['requiredRole'], + }; + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should extract admin role from nested path in userinfo', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'organization.permissions'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'userinfo'; + + const userinfoWithNestedGroups = { + ...tokenset.claims(), + organization: { + permissions: ['admin', 'write'], + }, + }; + + require('openid-client').fetchUserInfo.mockResolvedValue({ + organization: { + permissions: ['admin', 'write'], + }, + }); + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate({ + ...tokenset, + claims: () => userinfoWithNestedGroups, + }); + + expect(user.role).toBe('ADMIN'); + }); + + it('should handle boolean admin role value', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'is_admin'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + is_admin: true, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should handle string admin role value matching exactly', async () => { + process.env.OPENID_ADMIN_ROLE = 'super-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + role: 'super-admin', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should not set admin role when string value does not match', async () => { + process.env.OPENID_ADMIN_ROLE = 'super-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + role: 'regular-user', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBeUndefined(); + }); + + it('should handle array admin role value', async () => { + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: ['user', 'site-admin', 'moderator'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('should not set admin when role is not in array', async () => { + process.env.OPENID_ADMIN_ROLE = 'site-admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + app_roles: ['user', 'moderator'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user.role).toBeUndefined(); + }); + + it('should handle nested path with special characters in keys', async () => { + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles'; + + jwtDecode.mockReturnValue({ + resource_access: { + 'my-app-123': { + roles: ['app-user'], + }, + }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + }); + + it('should handle empty object at nested path', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'access.roles'; + + jwtDecode.mockReturnValue({ + access: {}, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'access.roles' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + }); + + it('should handle null value at intermediate path', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.roles'; + + jwtDecode.mockReturnValue({ + data: null, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'data.roles' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + }); + + it('should reject login with invalid admin role token kind', async () => { + process.env.OPENID_ADMIN_ROLE = 'admin'; + process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'roles'; + process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'invalid'; + + const { logger } = require('@librechat/data-schemas'); + + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'admin'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid admin role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'", + ), + ); + }); + + it('should reject login when roles path returns invalid type (object)', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'app-user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; + + jwtDecode.mockReturnValue({ + roles: { admin: true, user: false }, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user, details } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'roles' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + expect(details.message).toContain('role to log in'); + }); + + it('should reject login when roles path returns invalid type (number)', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'user'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roleCount'; + + jwtDecode.mockReturnValue({ + roleCount: 5, + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallback(); + + const { user } = await validate(tokenset); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'roleCount' not found or invalid type in id token!"), + ); + expect(user).toBe(false); + }); + }); }); diff --git a/api/typedefs.js b/api/typedefs.js index 6cf87ef61b..a51e0ebaff 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -1054,7 +1054,7 @@ /** * @exports TWebSearchKeys - * @typedef {import('librechat-data-provider').TWebSearchKeys} TWebSearchKeys + * @typedef {import('@librechat/data-schemas').TWebSearchKeys} TWebSearchKeys * @memberof typedefs */ @@ -1103,13 +1103,13 @@ /** * @exports AppConfig - * @typedef {import('@librechat/api').AppConfig} AppConfig + * @typedef {import('@librechat/data-schemas').AppConfig} AppConfig * @memberof typedefs */ /** * @exports JsonSchemaType - * @typedef {import('@librechat/api').JsonSchemaType} JsonSchemaType + * @typedef {import('@librechat/data-schemas').JsonSchemaType} JsonSchemaType * @memberof typedefs */ @@ -1371,12 +1371,7 @@ /** * @exports FunctionTool - * @typedef {Object} FunctionTool - * @property {'function'} type - The type of tool, 'function'. - * @property {Object} function - The function definition. - * @property {string} function.description - A description of what the function does. - * @property {string} function.name - The name of the function to be called. - * @property {Object} function.parameters - The parameters the function accepts, described as a JSON Schema object. + * @typedef {import('@librechat/data-schemas').FunctionTool} FunctionTool * @memberof typedefs */ diff --git a/api/utils/deriveBaseURL.js b/api/utils/deriveBaseURL.js index c377ddf874..982c2c8c2e 100644 --- a/api/utils/deriveBaseURL.js +++ b/api/utils/deriveBaseURL.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); /** * Extracts the base URL from the provided URL. diff --git a/api/utils/findMessageContent.js b/api/utils/findMessageContent.js index 6ee5166348..6aeed1a395 100644 --- a/api/utils/findMessageContent.js +++ b/api/utils/findMessageContent.js @@ -1,4 +1,4 @@ -const { logger } = require('~/config'); +const { logger } = require('@librechat/data-schemas'); function findContent(obj) { if (obj && typeof obj === 'object') { diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 338322e0e2..12daf64e47 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -186,6 +186,19 @@ describe('getModelMaxTokens', () => { ); }); + test('should return correct tokens for gpt-5-pro matches', () => { + expect(getModelMaxTokens('gpt-5-pro')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro']); + expect(getModelMaxTokens('gpt-5-pro-preview')).toBe( + maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'], + ); + expect(getModelMaxTokens('openai/gpt-5-pro')).toBe( + maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'], + ); + expect(getModelMaxTokens('gpt-5-pro-2025-01-30')).toBe( + maxTokensMap[EModelEndpoint.openAI]['gpt-5-pro'], + ); + }); + test('should return correct tokens for Anthropic models', () => { const models = [ 'claude-2.1', @@ -262,6 +275,15 @@ describe('getModelMaxTokens', () => { expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-1.5'], ); + expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'], + ); + expect(getModelMaxTokens('gemini-2.5-flash', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-2.5-flash'], + ); + expect(getModelMaxTokens('gemini-2.5-flash-lite', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-2.5-flash-lite'], + ); expect(getModelMaxTokens('gemini-pro-vision', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-pro-vision'], ); @@ -387,15 +409,80 @@ describe('getModelMaxTokens', () => { }); test('should return correct tokens for GPT-OSS models', () => { - const expected = maxTokensMap[EModelEndpoint.openAI]['gpt-oss-20b']; - ['gpt-oss-20b', 'gpt-oss-120b', 'openai/gpt-oss-20b', 'openai/gpt-oss-120b'].forEach((name) => { + const expected = maxTokensMap[EModelEndpoint.openAI]['gpt-oss']; + [ + 'gpt-oss:20b', + 'gpt-oss-20b', + 'gpt-oss-120b', + 'openai/gpt-oss-20b', + 'openai/gpt-oss-120b', + 'openai/gpt-oss:120b', + ].forEach((name) => { expect(getModelMaxTokens(name)).toBe(expected); }); }); + test('should return correct tokens for GLM models', () => { + expect(getModelMaxTokens('glm-4.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']); + expect(getModelMaxTokens('glm-4.5v')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5v']); + expect(getModelMaxTokens('glm-4.5-air')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + expect(getModelMaxTokens('glm-4.5')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5']); + expect(getModelMaxTokens('glm-4-32b')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4-32b']); + expect(getModelMaxTokens('glm-4')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4']); + expect(getModelMaxTokens('glm4')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm4']); + }); + + test('should return correct tokens for GLM models with provider prefixes', () => { + expect(getModelMaxTokens('z-ai/glm-4.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']); + expect(getModelMaxTokens('z-ai/glm-4.5')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5']); + expect(getModelMaxTokens('z-ai/glm-4.5-air')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + expect(getModelMaxTokens('z-ai/glm-4.5v')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5v'], + ); + expect(getModelMaxTokens('z-ai/glm-4-32b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4-32b'], + ); + + expect(getModelMaxTokens('zai/glm-4.6')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']); + expect(getModelMaxTokens('zai/glm-4.5-air')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + expect(getModelMaxTokens('zai/glm-4.5v')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.5v']); + + expect(getModelMaxTokens('zai-org/GLM-4.6')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.6'], + ); + expect(getModelMaxTokens('zai-org/GLM-4.5')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5'], + ); + expect(getModelMaxTokens('zai-org/GLM-4.5-Air')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + expect(getModelMaxTokens('zai-org/GLM-4.5V')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5v'], + ); + expect(getModelMaxTokens('zai-org/GLM-4-32B-0414')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4-32b'], + ); + }); + + test('should return correct tokens for GLM models with suffixes', () => { + expect(getModelMaxTokens('glm-4.6-fp8')).toBe(maxTokensMap[EModelEndpoint.openAI]['glm-4.6']); + expect(getModelMaxTokens('zai-org/GLM-4.6-FP8')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.6'], + ); + expect(getModelMaxTokens('zai-org/GLM-4.5-Air-FP8')).toBe( + maxTokensMap[EModelEndpoint.openAI]['glm-4.5-air'], + ); + }); + test('should return correct max output tokens for GPT-5 models', () => { const { getModelMaxOutputTokens } = require('@librechat/api'); - ['gpt-5', 'gpt-5-mini', 'gpt-5-nano'].forEach((model) => { + ['gpt-5', 'gpt-5-mini', 'gpt-5-nano', 'gpt-5-pro'].forEach((model) => { expect(getModelMaxOutputTokens(model)).toBe(maxOutputTokensMap[EModelEndpoint.openAI][model]); expect(getModelMaxOutputTokens(model, EModelEndpoint.openAI)).toBe( maxOutputTokensMap[EModelEndpoint.openAI][model], @@ -508,6 +595,13 @@ describe('matchModelName', () => { expect(matchModelName('gpt-5-nano-2025-01-30')).toBe('gpt-5-nano'); }); + it('should return the closest matching key for gpt-5-pro matches', () => { + expect(matchModelName('openai/gpt-5-pro')).toBe('gpt-5-pro'); + expect(matchModelName('gpt-5-pro-preview')).toBe('gpt-5-pro'); + expect(matchModelName('gpt-5-pro-2025-01-30')).toBe('gpt-5-pro'); + expect(matchModelName('gpt-5-pro-2025-01-30-0130')).toBe('gpt-5-pro'); + }); + // Tests for Google models it('should return the exact model name if it exists in maxTokensMap - Google models', () => { expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k'); @@ -758,6 +852,49 @@ describe('Claude Model Tests', () => { ); }); + it('should return correct context length for Claude Haiku 4.5', () => { + expect(getModelMaxTokens('claude-haiku-4-5', EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'], + ); + expect(getModelMaxTokens('claude-haiku-4-5')).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'], + ); + }); + + it('should handle Claude Haiku 4.5 model name variations', () => { + const modelVariations = [ + 'claude-haiku-4-5', + 'claude-haiku-4-5-20250420', + 'claude-haiku-4-5-latest', + 'anthropic/claude-haiku-4-5', + 'claude-haiku-4-5/anthropic', + 'claude-haiku-4-5-preview', + ]; + + modelVariations.forEach((model) => { + const modelKey = findMatchingPattern(model, maxTokensMap[EModelEndpoint.anthropic]); + expect(modelKey).toBe('claude-haiku-4-5'); + expect(getModelMaxTokens(model, EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'], + ); + }); + }); + + it('should match model names correctly for Claude Haiku 4.5', () => { + const modelVariations = [ + 'claude-haiku-4-5', + 'claude-haiku-4-5-20250420', + 'claude-haiku-4-5-latest', + 'anthropic/claude-haiku-4-5', + 'claude-haiku-4-5/anthropic', + 'claude-haiku-4-5-preview', + ]; + + modelVariations.forEach((model) => { + expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-haiku-4-5'); + }); + }); + it('should handle Claude 4 model name variations with different prefixes and suffixes', () => { const modelVariations = [ 'claude-sonnet-4', @@ -849,3 +986,206 @@ describe('Kimi Model Tests', () => { }); }); }); + +describe('Qwen3 Model Tests', () => { + describe('getModelMaxTokens', () => { + test('should return correct tokens for Qwen3 base pattern', () => { + expect(getModelMaxTokens('qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']); + }); + + test('should return correct tokens for qwen3-4b (falls back to qwen3)', () => { + expect(getModelMaxTokens('qwen3-4b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']); + }); + + test('should return correct tokens for Qwen3 base models', () => { + expect(getModelMaxTokens('qwen3-8b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-8b']); + expect(getModelMaxTokens('qwen3-14b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-14b']); + expect(getModelMaxTokens('qwen3-32b')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-32b']); + expect(getModelMaxTokens('qwen3-235b-a22b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-235b-a22b'], + ); + }); + + test('should return correct tokens for Qwen3 VL (Vision-Language) models', () => { + expect(getModelMaxTokens('qwen3-vl-8b-thinking')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-thinking'], + ); + expect(getModelMaxTokens('qwen3-vl-8b-instruct')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'], + ); + expect(getModelMaxTokens('qwen3-vl-30b-a3b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-30b-a3b'], + ); + expect(getModelMaxTokens('qwen3-vl-235b-a22b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-235b-a22b'], + ); + }); + + test('should return correct tokens for Qwen3 specialized models', () => { + expect(getModelMaxTokens('qwen3-max')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3-max']); + expect(getModelMaxTokens('qwen3-coder')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'], + ); + expect(getModelMaxTokens('qwen3-coder-30b-a3b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-30b-a3b'], + ); + expect(getModelMaxTokens('qwen3-coder-plus')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-plus'], + ); + expect(getModelMaxTokens('qwen3-coder-flash')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder-flash'], + ); + expect(getModelMaxTokens('qwen3-next-80b-a3b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-next-80b-a3b'], + ); + }); + + test('should handle Qwen3 models with provider prefixes', () => { + expect(getModelMaxTokens('alibaba/qwen3')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']); + expect(getModelMaxTokens('alibaba/qwen3-4b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3'], + ); + expect(getModelMaxTokens('qwen/qwen3-8b')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'], + ); + expect(getModelMaxTokens('openrouter/qwen3-max')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-max'], + ); + expect(getModelMaxTokens('alibaba/qwen3-vl-8b-instruct')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-vl-8b-instruct'], + ); + expect(getModelMaxTokens('qwen/qwen3-coder')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-coder'], + ); + }); + + test('should handle Qwen3 models with suffixes', () => { + expect(getModelMaxTokens('qwen3-preview')).toBe(maxTokensMap[EModelEndpoint.openAI]['qwen3']); + expect(getModelMaxTokens('qwen3-4b-preview')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3'], + ); + expect(getModelMaxTokens('qwen3-8b-latest')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-8b'], + ); + expect(getModelMaxTokens('qwen3-max-2024')).toBe( + maxTokensMap[EModelEndpoint.openAI]['qwen3-max'], + ); + }); + }); + + describe('matchModelName', () => { + test('should match exact Qwen3 model names', () => { + expect(matchModelName('qwen3')).toBe('qwen3'); + expect(matchModelName('qwen3-4b')).toBe('qwen3'); + expect(matchModelName('qwen3-8b')).toBe('qwen3-8b'); + expect(matchModelName('qwen3-vl-8b-thinking')).toBe('qwen3-vl-8b-thinking'); + expect(matchModelName('qwen3-max')).toBe('qwen3-max'); + expect(matchModelName('qwen3-coder')).toBe('qwen3-coder'); + }); + + test('should match Qwen3 model variations with provider prefixes', () => { + expect(matchModelName('alibaba/qwen3')).toBe('qwen3'); + expect(matchModelName('alibaba/qwen3-4b')).toBe('qwen3'); + expect(matchModelName('qwen/qwen3-8b')).toBe('qwen3-8b'); + expect(matchModelName('openrouter/qwen3-max')).toBe('qwen3-max'); + expect(matchModelName('alibaba/qwen3-vl-8b-instruct')).toBe('qwen3-vl-8b-instruct'); + expect(matchModelName('qwen/qwen3-coder')).toBe('qwen3-coder'); + }); + + test('should match Qwen3 model variations with suffixes', () => { + expect(matchModelName('qwen3-preview')).toBe('qwen3'); + expect(matchModelName('qwen3-4b-preview')).toBe('qwen3'); + expect(matchModelName('qwen3-8b-latest')).toBe('qwen3-8b'); + expect(matchModelName('qwen3-max-2024')).toBe('qwen3-max'); + expect(matchModelName('qwen3-coder-v1')).toBe('qwen3-coder'); + }); + }); +}); + +describe('GLM Model Tests (Zhipu AI)', () => { + describe('getModelMaxTokens', () => { + test('should return correct tokens for GLM models', () => { + expect(getModelMaxTokens('glm-4.6')).toBe(200000); + expect(getModelMaxTokens('glm-4.5v')).toBe(66000); + expect(getModelMaxTokens('glm-4.5-air')).toBe(131000); + expect(getModelMaxTokens('glm-4.5')).toBe(131000); + expect(getModelMaxTokens('glm-4-32b')).toBe(128000); + expect(getModelMaxTokens('glm-4')).toBe(128000); + expect(getModelMaxTokens('glm4')).toBe(128000); + }); + + test('should handle partial matches for GLM models with provider prefixes', () => { + expect(getModelMaxTokens('z-ai/glm-4.6')).toBe(200000); + expect(getModelMaxTokens('z-ai/glm-4.5')).toBe(131000); + expect(getModelMaxTokens('z-ai/glm-4.5-air')).toBe(131000); + expect(getModelMaxTokens('z-ai/glm-4.5v')).toBe(66000); + expect(getModelMaxTokens('z-ai/glm-4-32b')).toBe(128000); + + expect(getModelMaxTokens('zai/glm-4.6')).toBe(200000); + expect(getModelMaxTokens('zai/glm-4.5')).toBe(131000); + expect(getModelMaxTokens('zai/glm-4.5-air')).toBe(131000); + expect(getModelMaxTokens('zai/glm-4.5v')).toBe(66000); + + expect(getModelMaxTokens('zai-org/GLM-4.6')).toBe(200000); + expect(getModelMaxTokens('zai-org/GLM-4.5')).toBe(131000); + expect(getModelMaxTokens('zai-org/GLM-4.5-Air')).toBe(131000); + expect(getModelMaxTokens('zai-org/GLM-4.5V')).toBe(66000); + expect(getModelMaxTokens('zai-org/GLM-4-32B-0414')).toBe(128000); + }); + + test('should handle GLM model variations with suffixes', () => { + expect(getModelMaxTokens('glm-4.6-fp8')).toBe(200000); + expect(getModelMaxTokens('zai-org/GLM-4.6-FP8')).toBe(200000); + expect(getModelMaxTokens('zai-org/GLM-4.5-Air-FP8')).toBe(131000); + }); + + test('should prioritize more specific GLM patterns', () => { + expect(getModelMaxTokens('glm-4.5-air-custom')).toBe(131000); + expect(getModelMaxTokens('glm-4.5-custom')).toBe(131000); + expect(getModelMaxTokens('glm-4.5v-custom')).toBe(66000); + }); + }); + + describe('matchModelName', () => { + test('should match exact GLM model names', () => { + expect(matchModelName('glm-4.6')).toBe('glm-4.6'); + expect(matchModelName('glm-4.5v')).toBe('glm-4.5v'); + expect(matchModelName('glm-4.5-air')).toBe('glm-4.5-air'); + expect(matchModelName('glm-4.5')).toBe('glm-4.5'); + expect(matchModelName('glm-4-32b')).toBe('glm-4-32b'); + expect(matchModelName('glm-4')).toBe('glm-4'); + expect(matchModelName('glm4')).toBe('glm4'); + }); + + test('should match GLM model variations with provider prefixes', () => { + expect(matchModelName('z-ai/glm-4.6')).toBe('glm-4.6'); + expect(matchModelName('z-ai/glm-4.5')).toBe('glm-4.5'); + expect(matchModelName('z-ai/glm-4.5-air')).toBe('glm-4.5-air'); + expect(matchModelName('z-ai/glm-4.5v')).toBe('glm-4.5v'); + expect(matchModelName('z-ai/glm-4-32b')).toBe('glm-4-32b'); + + expect(matchModelName('zai/glm-4.6')).toBe('glm-4.6'); + expect(matchModelName('zai/glm-4.5')).toBe('glm-4.5'); + expect(matchModelName('zai/glm-4.5-air')).toBe('glm-4.5-air'); + expect(matchModelName('zai/glm-4.5v')).toBe('glm-4.5v'); + + expect(matchModelName('zai-org/GLM-4.6')).toBe('glm-4.6'); + expect(matchModelName('zai-org/GLM-4.5')).toBe('glm-4.5'); + expect(matchModelName('zai-org/GLM-4.5-Air')).toBe('glm-4.5-air'); + expect(matchModelName('zai-org/GLM-4.5V')).toBe('glm-4.5v'); + expect(matchModelName('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b'); + }); + + test('should match GLM model variations with suffixes', () => { + expect(matchModelName('glm-4.6-fp8')).toBe('glm-4.6'); + expect(matchModelName('zai-org/GLM-4.6-FP8')).toBe('glm-4.6'); + expect(matchModelName('zai-org/GLM-4.5-Air-FP8')).toBe('glm-4.5-air'); + }); + + test('should handle case-insensitive matching for GLM models', () => { + expect(matchModelName('zai-org/GLM-4.6')).toBe('glm-4.6'); + expect(matchModelName('zai-org/GLM-4.5V')).toBe('glm-4.5v'); + expect(matchModelName('zai-org/GLM-4-32B-0414')).toBe('glm-4-32b'); + }); + }); +}); diff --git a/client/jest.config.cjs b/client/jest.config.cjs index b9369464e1..654eed722d 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,4 +1,4 @@ -/** v0.8.0-rc4 */ +/** v0.8.0 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', diff --git a/client/package.json b/client/package.json index ac79ab6211..b46f77cbd8 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.0-rc4", + "version": "v0.8.0", "description": "", "type": "module", "scripts": { @@ -136,6 +136,7 @@ "babel-plugin-transform-import-meta": "^2.3.2", "babel-plugin-transform-vite-meta-env": "^1.0.3", "eslint-plugin-jest": "^28.11.0", + "fs-extra": "^11.3.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-canvas-mock": "^2.5.2", diff --git a/client/src/App.jsx b/client/src/App.jsx index decad9392b..eda775bc71 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { RecoilRoot } from 'recoil'; import { DndProvider } from 'react-dnd'; import { RouterProvider } from 'react-router-dom'; @@ -8,6 +9,7 @@ import { Toast, ThemeProvider, ToastProvider } from '@librechat/client'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; import { getThemeFromEnv } from './utils/getThemeFromEnv'; +import { initializeFontSize } from '~/store/fontSize'; import { LiveAnnouncer } from '~/a11y'; import { router } from './routes'; @@ -24,6 +26,10 @@ const App = () => { }), }); + useEffect(() => { + initializeFontSize(); + }, []); + // Load theme from environment variables if available const envTheme = getThemeFromEnv(); diff --git a/client/src/Providers/DragDropContext.tsx b/client/src/Providers/DragDropContext.tsx index a86af6510b..3a5fe2924c 100644 --- a/client/src/Providers/DragDropContext.tsx +++ b/client/src/Providers/DragDropContext.tsx @@ -1,23 +1,38 @@ import React, { createContext, useContext, useMemo } from 'react'; +import type { EModelEndpoint } from 'librechat-data-provider'; +import { useGetEndpointsQuery } from '~/data-provider'; +import { getEndpointField } from '~/utils/endpoints'; import { useChatContext } from './ChatContext'; interface DragDropContextValue { conversationId: string | null | undefined; agentId: string | null | undefined; + endpoint: string | null | undefined; + endpointType?: EModelEndpoint | undefined; } const DragDropContext = createContext(undefined); export function DragDropProvider({ children }: { children: React.ReactNode }) { const { conversation } = useChatContext(); + const { data: endpointsConfig } = useGetEndpointsQuery(); + + const endpointType = useMemo(() => { + return ( + getEndpointField(endpointsConfig, conversation?.endpoint, 'type') || + (conversation?.endpoint as EModelEndpoint | undefined) + ); + }, [conversation?.endpoint, endpointsConfig]); /** Context value only created when conversation fields change */ const contextValue = useMemo( () => ({ conversationId: conversation?.conversationId, agentId: conversation?.agent_id, + endpoint: conversation?.endpoint, + endpointType: endpointType, }), - [conversation?.conversationId, conversation?.agent_id], + [conversation?.conversationId, conversation?.agent_id, conversation?.endpoint, endpointType], ); return {children}; diff --git a/client/src/components/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx index 62f2dc4e85..f4f25280f9 100644 --- a/client/src/components/Artifacts/ArtifactCodeEditor.tsx +++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx @@ -144,9 +144,10 @@ export const ArtifactCodeEditor = function ({ } return { ...sharedOptions, + activeFile: '/' + fileKey, bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL, }; - }, [config, template]); + }, [config, template, fileKey]); const [readOnly, setReadOnly] = useState(isSubmitting ?? false); useEffect(() => { setReadOnly(isSubmitting ?? false); diff --git a/client/src/components/Artifacts/ArtifactPreview.tsx b/client/src/components/Artifacts/ArtifactPreview.tsx index d5114ceafc..3764119f3a 100644 --- a/client/src/components/Artifacts/ArtifactPreview.tsx +++ b/client/src/components/Artifacts/ArtifactPreview.tsx @@ -13,7 +13,6 @@ export const ArtifactPreview = memo(function ({ files, fileKey, template, - isMermaid, sharedProps, previewRef, currentCode, @@ -21,7 +20,6 @@ export const ArtifactPreview = memo(function ({ }: { files: ArtifactFiles; fileKey: string; - isMermaid: boolean; template: SandpackProviderProps['template']; sharedProps: Partial; previewRef: React.MutableRefObject; @@ -56,15 +54,6 @@ export const ArtifactPreview = memo(function ({ return _options; }, [startupConfig, template]); - const style: PreviewProps['style'] | undefined = useMemo(() => { - if (isMermaid) { - return { - backgroundColor: '#282C34', - }; - } - return; - }, [isMermaid]); - if (Object.keys(artifactFiles).length === 0) { return null; } @@ -84,7 +73,6 @@ export const ArtifactPreview = memo(function ({ showRefreshButton={false} tabIndex={0} ref={previewRef} - style={style} /> ); diff --git a/client/src/components/Artifacts/ArtifactTabs.tsx b/client/src/components/Artifacts/ArtifactTabs.tsx index cd8c441ad7..8a5b14d556 100644 --- a/client/src/components/Artifacts/ArtifactTabs.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.tsx @@ -8,17 +8,14 @@ import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll'; import { ArtifactCodeEditor } from './ArtifactCodeEditor'; import { useGetStartupConfig } from '~/data-provider'; import { ArtifactPreview } from './ArtifactPreview'; -import { MermaidMarkdown } from './MermaidMarkdown'; import { cn } from '~/utils'; export default function ArtifactTabs({ artifact, - isMermaid, editorRef, previewRef, }: { artifact: Artifact; - isMermaid: boolean; editorRef: React.MutableRefObject; previewRef: React.MutableRefObject; }) { @@ -44,26 +41,22 @@ export default function ArtifactTabs({ value="code" id="artifacts-code" className={cn('flex-grow overflow-auto')} + tabIndex={-1} > - {isMermaid ? ( - - ) : ( - - )} + - + {/* Content */} } previewRef={previewRef as React.MutableRefObject} diff --git a/client/src/components/Artifacts/MermaidMarkdown.tsx b/client/src/components/Artifacts/MermaidMarkdown.tsx deleted file mode 100644 index 780b0d74da..0000000000 --- a/client/src/components/Artifacts/MermaidMarkdown.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { CodeMarkdown } from './Code'; - -export function MermaidMarkdown({ - content, - isSubmitting, -}: { - content: string; - isSubmitting: boolean; -}) { - return ; -} diff --git a/client/src/components/Audio/Voices.tsx b/client/src/components/Audio/Voices.tsx index 6064a16624..f41d57ac26 100644 --- a/client/src/components/Audio/Voices.tsx +++ b/client/src/components/Audio/Voices.tsx @@ -19,9 +19,11 @@ export function BrowserVoiceDropdown() { } }; + const labelId = 'browser-voice-dropdown-label'; + return (
-
{localize('com_nav_voice_select')}
+
{localize('com_nav_voice_select')}
); @@ -48,9 +51,11 @@ export function ExternalVoiceDropdown() { } }; + const labelId = 'external-voice-dropdown-label'; + return (
-
{localize('com_nav_voice_select')}
+
{localize('com_nav_voice_select')}
); diff --git a/client/src/components/Chat/Input/Files/AttachFileChat.tsx b/client/src/components/Chat/Input/Files/AttachFileChat.tsx index f80b8aef82..4757c598a5 100644 --- a/client/src/components/Chat/Input/Files/AttachFileChat.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileChat.tsx @@ -2,13 +2,15 @@ import { memo, useMemo } from 'react'; import { Constants, supportsFiles, + EModelEndpoint, mergeFileConfig, isAgentsEndpoint, isAssistantsEndpoint, fileConfig as defaultFileConfig, } from 'librechat-data-provider'; import type { EndpointFileConfig, TConversation } from 'librechat-data-provider'; -import { useGetFileConfig } from '~/data-provider'; +import { useGetFileConfig, useGetEndpointsQuery } from '~/data-provider'; +import { getEndpointField } from '~/utils/endpoints'; import AttachFileMenu from './AttachFileMenu'; import AttachFile from './AttachFile'; @@ -20,7 +22,7 @@ function AttachFileChat({ conversation: TConversation | null; }) { const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO; - const { endpoint, endpointType } = conversation ?? { endpoint: null }; + const { endpoint } = conversation ?? { endpoint: null }; const isAgents = useMemo(() => isAgentsEndpoint(endpoint), [endpoint]); const isAssistants = useMemo(() => isAssistantsEndpoint(endpoint), [endpoint]); @@ -28,6 +30,15 @@ function AttachFileChat({ select: (data) => mergeFileConfig(data), }); + const { data: endpointsConfig } = useGetEndpointsQuery(); + + const endpointType = useMemo(() => { + return ( + getEndpointField(endpointsConfig, endpoint, 'type') || + (endpoint as EModelEndpoint | undefined) + ); + }, [endpoint, endpointsConfig]); + const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''] as EndpointFileConfig | undefined; const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? endpoint ?? ''] ?? false; const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false; @@ -37,7 +48,9 @@ function AttachFileChat({ } else if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) { return ( { @@ -55,44 +70,75 @@ const AttachFileMenu = ({ overrideEndpointFileConfig: endpointFileConfig, toolResource, }); + + const { agentsConfig } = useGetAgentsConfig(); const { data: startupConfig } = useGetStartupConfig(); const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled; const [isSharePointDialogOpen, setIsSharePointDialogOpen] = useState(false); - const { agentsConfig } = useGetAgentsConfig(); + /** TODO: Ephemeral Agent Capabilities * Allow defining agent capabilities on a per-endpoint basis * Use definition for agents endpoint for ephemeral agents * */ const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); - const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions( + const { fileSearchAllowedByAgent, codeAllowedByAgent, provider } = useAgentToolPermissions( agentId, ephemeralAgent, ); - const handleUploadClick = (isImage?: boolean) => { + const handleUploadClick = ( + fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal', + ) => { if (!inputRef.current) { return; } inputRef.current.value = ''; - inputRef.current.accept = isImage === true ? 'image/*' : ''; + if (fileType === 'image') { + inputRef.current.accept = 'image/*'; + } else if (fileType === 'document') { + inputRef.current.accept = '.pdf,application/pdf'; + } else if (fileType === 'multimodal') { + inputRef.current.accept = 'image/*,.pdf,application/pdf'; + } else if (fileType === 'google_multimodal') { + inputRef.current.accept = 'image/*,.pdf,application/pdf,video/*,audio/*'; + } else { + inputRef.current.accept = ''; + } inputRef.current.click(); inputRef.current.accept = ''; }; const dropdownItems = useMemo(() => { - const createMenuItems = (onAction: (isImage?: boolean) => void) => { - const items: MenuItemProps[] = [ - { + const createMenuItems = ( + onAction: (fileType?: 'image' | 'document' | 'multimodal' | 'google_multimodal') => void, + ) => { + const items: MenuItemProps[] = []; + + const currentProvider = provider || endpoint; + + if (isDocumentSupportedProvider(currentProvider || endpointType)) { + items.push({ + label: localize('com_ui_upload_provider'), + onClick: () => { + setToolResource(undefined); + onAction( + (provider || endpoint) === EModelEndpoint.google ? 'google_multimodal' : 'multimodal', + ); + }, + icon: , + }); + } else { + items.push({ label: localize('com_ui_upload_image_input'), onClick: () => { setToolResource(undefined); - onAction(true); + onAction('image'); }, icon: , - }, - ]; + }); + } if (capabilities.contextEnabled) { items.push({ @@ -156,8 +202,11 @@ const AttachFileMenu = ({ return localItems; }, [ - capabilities, localize, + endpoint, + provider, + endpointType, + capabilities, setToolResource, setEphemeralAgent, sharePointEnabled, diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index e9992c4dcb..d9003de3dc 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -1,8 +1,19 @@ import React, { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { OGDialog, OGDialogTemplate } from '@librechat/client'; -import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider'; -import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; +import { + EToolResources, + EModelEndpoint, + defaultAgentCapabilities, + isDocumentSupportedProvider, +} from 'librechat-data-provider'; +import { + ImageUpIcon, + FileSearch, + FileType2Icon, + FileImageIcon, + TerminalSquareIcon, +} from 'lucide-react'; import { useAgentToolPermissions, useAgentCapabilities, @@ -34,22 +45,45 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD * Use definition for agents endpoint for ephemeral agents * */ const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); - const { conversationId, agentId } = useDragDropContext(); + const { conversationId, agentId, endpoint, endpointType } = useDragDropContext(); const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId ?? '')); - const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions( + const { fileSearchAllowedByAgent, codeAllowedByAgent, provider } = useAgentToolPermissions( agentId, ephemeralAgent, ); const options = useMemo(() => { - const _options: FileOption[] = [ - { + const _options: FileOption[] = []; + const currentProvider = provider || endpoint; + + // Check if provider supports document upload + if (isDocumentSupportedProvider(currentProvider || endpointType)) { + const isGoogleProvider = currentProvider === EModelEndpoint.google; + const validFileTypes = isGoogleProvider + ? files.every( + (file) => + file.type?.startsWith('image/') || + file.type?.startsWith('video/') || + file.type?.startsWith('audio/') || + file.type === 'application/pdf', + ) + : files.every((file) => file.type?.startsWith('image/') || file.type === 'application/pdf'); + + _options.push({ + label: localize('com_ui_upload_provider'), + value: undefined, + icon: , + condition: validFileTypes, + }); + } else { + // Only show image upload option if all files are images and provider doesn't support documents + _options.push({ label: localize('com_ui_upload_image_input'), value: undefined, icon: , condition: files.every((file) => file.type?.startsWith('image/')), - }, - ]; + }); + } if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) { _options.push({ label: localize('com_ui_upload_file_search'), @@ -73,7 +107,16 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD } return _options; - }, [capabilities, files, localize, fileSearchAllowedByAgent, codeAllowedByAgent]); + }, [ + files, + localize, + provider, + endpoint, + endpointType, + capabilities, + codeAllowedByAgent, + fileSearchAllowedByAgent, + ]); if (!isVisible) { return null; diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx index 01d96432a7..d9464182b9 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelector.tsx @@ -2,7 +2,12 @@ import React, { useMemo } from 'react'; import type { ModelSelectorProps } from '~/common'; import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext'; import { ModelSelectorChatProvider } from './ModelSelectorChatContext'; -import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components'; +import { + renderModelSpecs, + renderEndpoints, + renderSearchResults, + renderCustomGroups, +} from './components'; import { getSelectedIcon, getDisplayValue } from './utils'; import { CustomMenu as Menu } from './CustomMenu'; import DialogManager from './DialogManager'; @@ -86,8 +91,15 @@ function ModelSelectorContent() { renderSearchResults(searchResults, localize, searchValue) ) : ( <> - {renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')} + {/* Render ungrouped modelSpecs (no group field) */} + {renderModelSpecs( + modelSpecs?.filter((spec) => !spec.group) || [], + selectedValues.modelSpec || '', + )} + {/* Render endpoints (will include grouped specs matching endpoint names) */} {renderEndpoints(mappedEndpoints ?? [])} + {/* Render custom groups (specs with group field not matching any endpoint) */} + {renderCustomGroups(modelSpecs || [], mappedEndpoints ?? [])} )} diff --git a/client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx b/client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx new file mode 100644 index 0000000000..80d049cce7 --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/CustomGroup.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import type { TModelSpec } from 'librechat-data-provider'; +import { CustomMenu as Menu } from '../CustomMenu'; +import { ModelSpecItem } from './ModelSpecItem'; +import { useModelSelectorContext } from '../ModelSelectorContext'; + +interface CustomGroupProps { + groupName: string; + specs: TModelSpec[]; +} + +export function CustomGroup({ groupName, specs }: CustomGroupProps) { + const { selectedValues } = useModelSelectorContext(); + const { modelSpec: selectedSpec } = selectedValues; + + if (!specs || specs.length === 0) { + return null; + } + + return ( + +
+ {groupName} +
+ + } + > + {specs.map((spec: TModelSpec) => ( + + ))} +
+ ); +} + +export function renderCustomGroups( + modelSpecs: TModelSpec[], + mappedEndpoints: Array<{ value: string }>, +) { + // Get all endpoint values to exclude them from custom groups + const endpointValues = new Set(mappedEndpoints.map((ep) => ep.value)); + + // Group specs by their group field (excluding endpoint-matched groups and ungrouped) + const customGroups = modelSpecs.reduce( + (acc, spec) => { + if (!spec.group || endpointValues.has(spec.group)) { + return acc; + } + if (!acc[spec.group]) { + acc[spec.group] = []; + } + acc[spec.group].push(spec); + return acc; + }, + {} as Record, + ); + + // Render each custom group + return Object.entries(customGroups).map(([groupName, specs]) => ( + + )); +} diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx index 6541383f39..52c3fc8367 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx @@ -2,10 +2,12 @@ import { useMemo } from 'react'; import { SettingsIcon } from 'lucide-react'; import { TooltipAnchor, Spinner } from '@librechat/client'; import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; +import type { TModelSpec } from 'librechat-data-provider'; import type { Endpoint } from '~/common'; import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { renderEndpointModels } from './EndpointModelItem'; +import { ModelSpecItem } from './ModelSpecItem'; import { filterModels } from '../utils'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -57,6 +59,7 @@ export function EndpointItem({ endpoint }: EndpointItemProps) { const { agentsMap, assistantsMap, + modelSpecs, selectedValues, handleOpenKeyDialog, handleSelectEndpoint, @@ -64,7 +67,19 @@ export function EndpointItem({ endpoint }: EndpointItemProps) { setEndpointSearchValue, endpointRequiresUserKey, } = useModelSelectorContext(); - const { model: selectedModel, endpoint: selectedEndpoint } = selectedValues; + const { + model: selectedModel, + endpoint: selectedEndpoint, + modelSpec: selectedSpec, + } = selectedValues; + + // Filter modelSpecs for this endpoint (by group matching endpoint value) + const endpointSpecs = useMemo(() => { + if (!modelSpecs || !modelSpecs.length) { + return []; + } + return modelSpecs.filter((spec: TModelSpec) => spec.group === endpoint.value); + }, [modelSpecs, endpoint.value]); const searchValue = endpointSearchValues[endpoint.value] || ''; const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]); @@ -138,10 +153,17 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
- ) : filteredModels ? ( - renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels) ) : ( - endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel) + <> + {/* Render modelSpecs for this endpoint */} + {endpointSpecs.map((spec: TModelSpec) => ( + + ))} + {/* Render endpoint models */} + {filteredModels + ? renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels) + : endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)} + )} ); diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx index 6a9b6fd336..eeefdba598 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointModelItem.tsx @@ -36,38 +36,42 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod handleSelectModel(endpoint, modelId ?? '')} - className="flex h-8 w-full cursor-pointer items-center justify-start rounded-lg px-3 py-2 text-sm" + className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm" > -
+
{avatarUrl ? ( -
+
{modelName
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && endpoint.icon ? ( -
+
{endpoint.icon}
) : null} - {modelName} + {modelName} + {isGlobal && ( + + )}
- {isGlobal && } {isSelected && ( - - - +
+ + + +
)} ); diff --git a/client/src/components/Chat/Menus/Endpoints/components/index.ts b/client/src/components/Chat/Menus/Endpoints/components/index.ts index d39ad4276f..bc08e6a8a1 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/index.ts +++ b/client/src/components/Chat/Menus/Endpoints/components/index.ts @@ -2,3 +2,4 @@ export * from './ModelSpecItem'; export * from './EndpointModelItem'; export * from './EndpointItem'; export * from './SearchResults'; +export * from './CustomGroup'; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 5d98b5aca0..aa9f4da82d 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -57,7 +57,7 @@ const Part = memo( ); } else if (part.type === ContentTypes.TEXT) { - const text = typeof part.text === 'string' ? part.text : part.text.value; + const text = typeof part.text === 'string' ? part.text : part.text?.value; if (typeof text !== 'string') { return null; @@ -71,7 +71,7 @@ const Part = memo( ); } else if (part.type === ContentTypes.THINK) { - const reasoning = typeof part.think === 'string' ? part.think : part.think.value; + const reasoning = typeof part.think === 'string' ? part.think : part.think?.value; if (typeof reasoning !== 'string') { return null; } diff --git a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx index 0d53fb50ee..d2a303f49f 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx @@ -37,7 +37,7 @@ const LogContent: React.FC = ({ output = '', renderImages, atta attachments?.forEach((attachment) => { const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; const isImage = - imageExtRegex.test(attachment.filename) && + imageExtRegex.test(attachment.filename ?? '') && width != null && height != null && filepath != null; @@ -56,21 +56,25 @@ const LogContent: React.FC = ({ output = '', renderImages, atta const renderAttachment = (file: TAttachment) => { const now = new Date(); - const expiresAt = typeof file.expiresAt === 'number' ? new Date(file.expiresAt) : null; + const expiresAt = + 'expiresAt' in file && typeof file.expiresAt === 'number' ? new Date(file.expiresAt) : null; const isExpired = expiresAt ? isAfter(now, expiresAt) : false; + const filename = file.filename || ''; if (isExpired) { - return `${file.filename} ${localize('com_download_expired')}`; + return `${filename} ${localize('com_download_expired')}`; } + const filepath = file.filepath || ''; + // const expirationText = expiresAt // ? ` ${localize('com_download_expires', { 0: format(expiresAt, 'MM/dd/yy HH:mm') })}` // : ` ${localize('com_click_to_download')}`; return ( - + {'- '} - {file.filename} {localize('com_click_to_download')} + {filename} {localize('com_click_to_download')} ); }; diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index a79f0985d9..a993009915 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -1,10 +1,12 @@ import React, { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import { useMessageHelpers, useLocalize, useAttachments } from '~/hooks'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import ContentParts from './Content/ContentParts'; +import { fontSizeAtom } from '~/store/fontSize'; import SiblingSwitch from './SiblingSwitch'; import MultiMessage from './MultiMessage'; import HoverButtons from './HoverButtons'; @@ -36,7 +38,7 @@ export default function Message(props: TMessageProps) { regenerateMessage, } = useMessageHelpers(props); - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); const { children, messageId = null, isCreatedByUser } = message ?? {}; diff --git a/client/src/components/Chat/Messages/MessagesView.tsx b/client/src/components/Chat/Messages/MessagesView.tsx index 01459203f0..bea6554ff1 100644 --- a/client/src/components/Chat/Messages/MessagesView.tsx +++ b/client/src/components/Chat/Messages/MessagesView.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { CSSTransition } from 'react-transition-group'; import type { TMessage } from 'librechat-data-provider'; import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks'; import ScrollToBottom from '~/components/Messages/ScrollToBottom'; import { MessagesViewProvider } from '~/Providers'; +import { fontSizeAtom } from '~/store/fontSize'; import MultiMessage from './MultiMessage'; import { cn } from '~/utils'; import store from '~/store'; @@ -15,7 +17,7 @@ function MessagesViewContent({ messagesTree?: TMessage[] | null; }) { const localize = useLocalize(); - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const { screenshotTargetRef } = useScreenshot(); const scrollButtonPreference = useRecoilValue(store.showScrollButton); const [currentEditId, setCurrentEditId] = useState(-1); diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx index c7ac2c69c3..982aee06ce 100644 --- a/client/src/components/Chat/Messages/SearchMessage.tsx +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { useAuthContext, useLocalize } from '~/hooks'; import type { TMessageProps, TMessageIcon } from '~/common'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import Icon from '~/components/Chat/Messages/MessageIcon'; import SearchContent from './Content/SearchContent'; +import { fontSizeAtom } from '~/store/fontSize'; import SearchButtons from './SearchButtons'; import SubRow from './SubRow'; import { cn } from '~/utils'; @@ -34,8 +36,8 @@ const MessageBody = ({ message, messageLabel, fontSize }) => ( ); export default function SearchMessage({ message }: Pick) { + const fontSize = useAtomValue(fontSizeAtom); const UsernameDisplay = useRecoilValue(store.UsernameDisplay); - const fontSize = useRecoilValue(store.fontSize); const { user } = useAuthContext(); const localize = useLocalize(); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index ad38f1ee40..179da5942d 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useMemo, memo } from 'react'; +import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; import { type TMessage } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; @@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import { Plugin } from '~/components/Messages/Content'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { MessageContext } from '~/Providers'; import { useMessageActions } from '~/hooks'; import { cn, logger } from '~/utils'; @@ -58,8 +60,8 @@ const MessageRender = memo( isMultiMessage, setCurrentEditId, }); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - const fontSize = useRecoilValue(store.fontSize); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const hasNoChildren = !(msg?.children?.length ?? 0); @@ -97,7 +99,10 @@ const MessageRender = memo( () => showCardRender && !isLatestMessage ? () => { - logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`); + logger.log( + 'latest_message', + `Message Card click: Setting ${msg?.messageId} as latest message`, + ); logger.dir(msg); setLatestMessage(msg!); } diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 3a04f558f9..b16c6458c7 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -28,6 +28,8 @@ const LoadingSpinner = memo(() => { ); }); +LoadingSpinner.displayName = 'LoadingSpinner'; + const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { const localize = useLocalize(); return ( @@ -74,6 +76,7 @@ const Conversations: FC = ({ isLoading, isSearchLoading, }) => { + const localize = useLocalize(); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; @@ -181,7 +184,7 @@ const Conversations: FC = ({ {isSearchLoading ? (
- Loading... + {localize('com_ui_loading')}
) : (
diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 190cef2a4e..048c2f129d 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -135,8 +135,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co 'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9', isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt', )} - role="listitem" - tabIndex={0} + role="button" + tabIndex={renaming ? -1 : 0} + aria-label={`${title || localize('com_ui_untitled')} conversation`} onClick={(e) => { if (renaming) { return; @@ -149,7 +150,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co if (renaming) { return; } - if (e.key === 'Enter') { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); handleNavigation(false); } }} diff --git a/client/src/components/Conversations/ConvoLink.tsx b/client/src/components/Conversations/ConvoLink.tsx index 1667cf0980..68c16594a5 100644 --- a/client/src/components/Conversations/ConvoLink.tsx +++ b/client/src/components/Conversations/ConvoLink.tsx @@ -40,8 +40,7 @@ const ConvoLink: React.FC = ({ e.stopPropagation(); onRename(); }} - role="button" - aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')} + aria-label={title || localize('com_ui_untitled')} > {title || localize('com_ui_untitled')}
diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index 7affbd8e93..9cf1a109d3 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -201,6 +201,7 @@ function ConvoOptions({ { if (share?.shareId !== undefined) { @@ -39,6 +42,7 @@ export default function ShareButton({ >; showQR: boolean; setShowQR: (showQR: boolean) => void; @@ -86,7 +88,7 @@ export default function SharedLinkButton({ }; const createShareLink = async () => { - const share = await mutate({ conversationId }); + const share = await mutate({ conversationId, targetMessageId }); const newLink = generateShareLink(share.shareId); setSharedLink(newLink); }; diff --git a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx index dc71e87f8b..9ea4249129 100644 --- a/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx +++ b/client/src/components/Input/SetKeyDialog/SetKeyDialog.tsx @@ -1,16 +1,33 @@ import React, { useState } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; -import { OGDialogTemplate, OGDialog, Dropdown, useToastContext } from '@librechat/client'; +import { + OGDialog, + OGDialogContent, + OGDialogHeader, + OGDialogTitle, + OGDialogFooter, + Dropdown, + useToastContext, + Button, + Label, + OGDialogTrigger, + Spinner, +} from '@librechat/client'; import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider'; +import { + useRevokeAllUserKeysMutation, + useRevokeUserKeyMutation, +} from 'librechat-data-provider/react-query'; import type { TDialogProps } from '~/common'; import { useGetEndpointsQuery } from '~/data-provider'; -import { RevokeKeysButton } from '~/components/Nav'; import { useUserKey, useLocalize } from '~/hooks'; +import { NotificationSeverity } from '~/common'; import CustomConfig from './CustomEndpoint'; import GoogleConfig from './GoogleConfig'; import OpenAIConfig from './OpenAIConfig'; import OtherConfig from './OtherConfig'; import HelpText from './HelpText'; +import { logger } from '~/utils'; const endpointComponents = { [EModelEndpoint.google]: GoogleConfig, @@ -42,6 +59,94 @@ const EXPIRY = { NEVER: { label: 'never', value: 0 }, }; +const RevokeKeysButton = ({ + endpoint, + disabled, + setDialogOpen, +}: { + endpoint: string; + disabled: boolean; + setDialogOpen: (open: boolean) => void; +}) => { + const localize = useLocalize(); + const [open, setOpen] = useState(false); + const { showToast } = useToastContext(); + const revokeKeyMutation = useRevokeUserKeyMutation(endpoint); + const revokeKeysMutation = useRevokeAllUserKeysMutation(); + + const handleSuccess = () => { + showToast({ + message: localize('com_ui_revoke_key_success'), + status: NotificationSeverity.SUCCESS, + }); + + if (!setDialogOpen) { + return; + } + + setDialogOpen(false); + }; + + const handleError = () => { + showToast({ + message: localize('com_ui_revoke_key_error'), + status: NotificationSeverity.ERROR, + }); + }; + + const onClick = () => { + revokeKeyMutation.mutate( + {}, + { + onSuccess: handleSuccess, + onError: handleError, + }, + ); + }; + + const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading; + + return ( +
+ + + + + + + {localize('com_ui_revoke_key_endpoint', { 0: endpoint })} + +
+ +
+ + + + +
+
+
+ ); +}; + const SetKeyDialog = ({ open, onOpenChange, @@ -83,7 +188,7 @@ const SetKeyDialog = ({ const submit = () => { const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel); - let expiresAt; + let expiresAt: number | null; if (selectedOption?.value === 0) { expiresAt = null; @@ -92,8 +197,20 @@ const SetKeyDialog = ({ } const saveKey = (key: string) => { - saveUserKey(key, expiresAt); - onOpenChange(false); + try { + saveUserKey(key, expiresAt); + showToast({ + message: localize('com_ui_save_key_success'), + status: NotificationSeverity.SUCCESS, + }); + onOpenChange(false); + } catch (error) { + logger.error('Error saving user key:', error); + showToast({ + message: localize('com_ui_save_key_error'), + status: NotificationSeverity.ERROR, + }); + } }; if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) { @@ -148,6 +265,14 @@ const SetKeyDialog = ({ return; } + if (!userKey.trim()) { + showToast({ + message: localize('com_ui_key_required'), + status: NotificationSeverity.ERROR, + }); + return; + } + saveKey(userKey); setUserKey(''); }; @@ -159,56 +284,54 @@ const SetKeyDialog = ({ return ( - - - {expiryTime === 'never' - ? localize('com_endpoint_config_key_never_expires') - : `${localize('com_endpoint_config_key_encryption')} ${new Date( - expiryTime ?? 0, - ).toLocaleString()}`} - - option.label)} - sizeClasses="w-[185px]" - portal={false} + + + + {`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`} + + +
+ + {expiryTime === 'never' + ? localize('com_endpoint_config_key_never_expires') + : `${localize('com_endpoint_config_key_encryption')} ${new Date( + expiryTime ?? 0, + ).toLocaleString()}`} + + option.label)} + sizeClasses="w-[185px]" + portal={false} + /> +
+ + -
- - - - -
- } - selection={{ - selectHandler: submit, - selectClasses: 'btn btn-primary', - selectText: localize('com_ui_submit'), - }} - leftButtons={ +
+ +
+ - } - /> + + + ); }; diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index bdf051453e..565dadde11 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -1,5 +1,6 @@ -import { useRecoilValue } from 'recoil'; import { useCallback, useMemo, memo } from 'react'; +import { useAtomValue } from 'jotai'; +import { useRecoilValue } from 'recoil'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; @@ -9,6 +10,7 @@ import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import { useAttachments, useMessageActions } from '~/hooks'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { cn, logger } from '~/utils'; import store from '~/store'; @@ -60,8 +62,8 @@ const ContentRender = memo( isMultiMessage, setCurrentEditId, }); + const fontSize = useAtomValue(fontSizeAtom); const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace); - const fontSize = useRecoilValue(store.fontSize); const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]); const isLast = useMemo( @@ -96,7 +98,10 @@ const ContentRender = memo( () => showCardRender && !isLatestMessage ? () => { - logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`); + logger.log( + 'latest_message', + `Message Card click: Setting ${msg?.messageId} as latest message`, + ); logger.dir(msg); setLatestMessage(msg!); } diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx index d1c05b7bbc..868c987070 100644 --- a/client/src/components/Nav/Settings.tsx +++ b/client/src/components/Nav/Settings.tsx @@ -182,7 +182,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { - {localize('com_ui_close')} + {localize('com_ui_close_settings')}
@@ -220,35 +220,35 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { ))}
- + - + - + - + {hasAnyPersonalizationFeature && ( - + )} - + {startupConfig?.balance?.enabled && ( - + )} - +
diff --git a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx index 0ce86231a7..ed677f771a 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx @@ -1,9 +1,11 @@ import React, { useState, useRef, useCallback } from 'react'; import { useSetRecoilState } from 'recoil'; +// @ts-ignore - no type definitions available import AvatarEditor from 'react-avatar-editor'; -import { FileImage, RotateCw, Upload } from 'lucide-react'; +import { FileImage, RotateCw, Upload, ZoomIn, ZoomOut, Move, X } from 'lucide-react'; import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider'; import { + Label, Slider, Button, Spinner, @@ -25,14 +27,20 @@ interface AvatarEditorRef { getImage: () => HTMLImageElement; } +interface Position { + x: number; + y: number; +} + function Avatar() { const setUser = useSetRecoilState(store.user); const [scale, setScale] = useState(1); const [rotation, setRotation] = useState(0); + const [position, setPosition] = useState({ x: 0.5, y: 0.5 }); + const [isDragging, setIsDragging] = useState(false); const editorRef = useRef(null); const fileInputRef = useRef(null); - const openButtonRef = useRef(null); const [image, setImage] = useState(null); const [isDialogOpen, setDialogOpen] = useState(false); @@ -48,7 +56,6 @@ function Avatar() { onSuccess: (data) => { showToast({ message: localize('com_ui_upload_success') }); setUser((prev) => ({ ...prev, avatar: data.url }) as TUser); - openButtonRef.current?.click(); }, onError: (error) => { console.error('Error:', error); @@ -61,29 +68,45 @@ function Avatar() { handleFile(file); }; - const handleFile = (file: File | undefined) => { - if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) { - setImage(file); - setScale(1); - setRotation(0); - } else { - const megabytes = - fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2; - showToast({ - message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }), - status: 'error', - }); - } - }; + const handleFile = useCallback( + (file: File | undefined) => { + if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) { + setImage(file); + setScale(1); + setRotation(0); + setPosition({ x: 0.5, y: 0.5 }); + } else { + const megabytes = + fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2; + showToast({ + message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }), + status: 'error', + }); + } + }, + [fileConfig.avatarSizeLimit, localize, showToast], + ); const handleScaleChange = (value: number[]) => { setScale(value[0]); }; + const handleZoomIn = () => { + setScale((prev) => Math.min(prev + 0.2, 5)); + }; + + const handleZoomOut = () => { + setScale((prev) => Math.max(prev - 0.2, 1)); + }; + const handleRotate = () => { setRotation((prev) => (prev + 90) % 360); }; + const handlePositionChange = (position: Position) => { + setPosition(position); + }; + const handleUpload = () => { if (editorRef.current) { const canvas = editorRef.current.getImageScaledToCanvas(); @@ -98,11 +121,14 @@ function Avatar() { } }; - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const file = e.dataTransfer.files[0]; - handleFile(file); - }, []); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + handleFile(file); + }, + [handleFile], + ); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -116,8 +142,15 @@ function Avatar() { setImage(null); setScale(1); setRotation(0); + setPosition({ x: 0.5, y: 0.5 }); }, []); + const handleReset = () => { + setScale(1); + setRotation(0); + setPosition({ x: 0.5, y: 0.5 }); + }; + return ( { - openButtonRef.current?.focus(); - }, 0); } }} >
{localize('com_nav_profile_picture')} - +
- + {image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')} -
+
{image != null ? ( <> -
+
setIsDragging(true)} + onMouseUp={() => setIsDragging(false)} + onMouseLeave={() => setIsDragging(false)} + > + {!isDragging && ( +
+
+ +
+
+ )}
-
-
- {localize('com_ui_zoom')} - + +
+ {/* Zoom Controls */} +
+
+ + {Math.round(scale * 100)}% +
+
+ + + +
- + +
+ + +
+ + {/* Helper Text */} +

+ {localize('com_ui_editor_instructions')} +

+
+ + {/* Action Buttons */} +
+ +
- ) : (
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openFileDialog(); + } + }} + aria-label={localize('com_ui_upload_avatar_label')} > - -

+ +

{localize('com_ui_drag_drop')}

-
)} diff --git a/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx index bd2e36c45f..29d1608b46 100644 --- a/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/DeleteAccount.tsx @@ -1,6 +1,7 @@ import { LockIcon, Trash } from 'lucide-react'; import React, { useState, useCallback } from 'react'; import { + Label, Input, Button, Spinner, @@ -45,11 +46,11 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea <>
- {localize('com_nav_delete_account')} +
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/ChatDirection.tsx b/client/src/components/Nav/SettingsTabs/Chat/ChatDirection.tsx index 7676617d32..a639d0ca42 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/ChatDirection.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/ChatDirection.tsx @@ -19,16 +19,16 @@ const ChatDirection = () => {
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx b/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx index c638244bcf..66b3f832ab 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx @@ -1,15 +1,14 @@ -import { useRecoilState } from 'recoil'; -import { Dropdown, applyFontSize } from '@librechat/client'; +import { useAtom } from 'jotai'; +import { Dropdown } from '@librechat/client'; +import { fontSizeAtom } from '~/store/fontSize'; import { useLocalize } from '~/hooks'; -import store from '~/store'; export default function FontSizeSelector() { - const [fontSize, setFontSize] = useRecoilState(store.fontSize); const localize = useLocalize(); + const [fontSize, setFontSize] = useAtom(fontSizeAtom); const handleChange = (val: string) => { setFontSize(val); - applyFontSize(val); }; const options = [ @@ -20,9 +19,11 @@ export default function FontSizeSelector() { { value: 'text-xl', label: localize('com_nav_font_size_xl') }, ]; + const labelId = 'font-size-selector-label'; + return (
-
{localize('com_nav_font_size')}
+
{localize('com_nav_font_size')}
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/ForkSettings.tsx b/client/src/components/Nav/SettingsTabs/Chat/ForkSettings.tsx index a81d4f4f50..e1145fc3ca 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/ForkSettings.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/ForkSettings.tsx @@ -20,13 +20,14 @@ export const ForkSettings = () => { <>
-
{localize('com_ui_fork_default')}
+
{localize('com_ui_fork_default')}
@@ -34,7 +35,7 @@ export const ForkSettings = () => {
-
{localize('com_ui_fork_change_default')}
+
{localize('com_ui_fork_change_default')}
{ sizeClasses="w-[200px]" testId="fork-setting-dropdown" className="z-[50]" + aria-labelledby="fork-change-default-label" />
@@ -54,7 +56,7 @@ export const ForkSettings = () => {
-
{localize('com_ui_fork_split_target_setting')}
+
{localize('com_ui_fork_split_target_setting')}
{ onCheckedChange={setSplitAtTarget} className="ml-4" data-testid="splitAtTarget" + aria-labelledby="split-at-target-label" />
diff --git a/client/src/components/Nav/SettingsTabs/Commands/AtCommandSwitch.tsx b/client/src/components/Nav/SettingsTabs/Commands/AtCommandSwitch.tsx deleted file mode 100644 index ab30c44dc0..0000000000 --- a/client/src/components/Nav/SettingsTabs/Commands/AtCommandSwitch.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -export default function AtCommandSwitch() { - const [atCommand, setAtCommand] = useRecoilState(store.atCommand); - const localize = useLocalize(); - - const handleCheckedChange = (value: boolean) => { - setAtCommand(value); - }; - - return ( -
-
{localize('com_nav_at_command_description')}
- -
- ); -} diff --git a/client/src/components/Nav/SettingsTabs/Commands/Commands.tsx b/client/src/components/Nav/SettingsTabs/Commands/Commands.tsx index c06733f21a..ff04c9087b 100644 --- a/client/src/components/Nav/SettingsTabs/Commands/Commands.tsx +++ b/client/src/components/Nav/SettingsTabs/Commands/Commands.tsx @@ -1,10 +1,33 @@ import { memo } from 'react'; import { InfoHoverCard, ESide } from '@librechat/client'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import SlashCommandSwitch from './SlashCommandSwitch'; import { useLocalize, useHasAccess } from '~/hooks'; -import PlusCommandSwitch from './PlusCommandSwitch'; -import AtCommandSwitch from './AtCommandSwitch'; +import ToggleSwitch from '../ToggleSwitch'; +import store from '~/store'; + +const commandSwitchConfigs = [ + { + stateAtom: store.atCommand, + localizationKey: 'com_nav_at_command_description' as const, + switchId: 'atCommand', + key: 'atCommand', + permissionType: undefined, + }, + { + stateAtom: store.plusCommand, + localizationKey: 'com_nav_plus_command_description' as const, + switchId: 'plusCommand', + key: 'plusCommand', + permissionType: PermissionTypes.MULTI_CONVO, + }, + { + stateAtom: store.slashCommand, + localizationKey: 'com_nav_slash_command_description' as const, + switchId: 'slashCommand', + key: 'slashCommand', + permissionType: PermissionTypes.PROMPTS, + }, +] as const; function Commands() { const localize = useLocalize(); @@ -19,6 +42,19 @@ function Commands() { permission: Permissions.USE, }); + const getShowSwitch = (permissionType?: PermissionTypes) => { + if (!permissionType) { + return true; + } + if (permissionType === PermissionTypes.MULTI_CONVO) { + return hasAccessToMultiConvo === true; + } + if (permissionType === PermissionTypes.PROMPTS) { + return hasAccessToPrompts === true; + } + return true; + }; + return (
@@ -28,19 +64,16 @@ function Commands() {
-
- -
- {hasAccessToMultiConvo === true && ( -
- + {commandSwitchConfigs.map((config) => ( +
+
- )} - {hasAccessToPrompts === true && ( -
- -
- )} + ))}
); diff --git a/client/src/components/Nav/SettingsTabs/Commands/PlusCommandSwitch.tsx b/client/src/components/Nav/SettingsTabs/Commands/PlusCommandSwitch.tsx deleted file mode 100644 index 2125f94a19..0000000000 --- a/client/src/components/Nav/SettingsTabs/Commands/PlusCommandSwitch.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -export default function PlusCommandSwitch() { - const [plusCommand, setPlusCommand] = useRecoilState(store.plusCommand); - const localize = useLocalize(); - - const handleCheckedChange = (value: boolean) => { - setPlusCommand(value); - }; - - return ( -
-
{localize('com_nav_plus_command_description')}
- -
- ); -} diff --git a/client/src/components/Nav/SettingsTabs/Commands/SlashCommandSwitch.tsx b/client/src/components/Nav/SettingsTabs/Commands/SlashCommandSwitch.tsx deleted file mode 100644 index 68b4636365..0000000000 --- a/client/src/components/Nav/SettingsTabs/Commands/SlashCommandSwitch.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; -import store from '~/store'; - -export default function SlashCommandSwitch() { - const [slashCommand, setSlashCommand] = useRecoilState(store.slashCommand); - const localize = useLocalize(); - - const handleCheckedChange = (value: boolean) => { - setSlashCommand(value); - }; - - return ( -
-
{localize('com_nav_slash_command_description')}
- -
- ); -} diff --git a/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx b/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx index bd745dcee8..44535e0a54 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx @@ -31,12 +31,12 @@ export const ClearChats = () => { return (
- +
- +
diff --git a/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx b/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx index 573a87e7a4..48c7f3a434 100644 --- a/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/DeleteCache.tsx @@ -38,14 +38,14 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => { return (
- + diff --git a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx index 7837a052c0..2d06b74392 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx @@ -1,96 +1,130 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useCallback } from 'react'; import { Import } from 'lucide-react'; -import { Spinner, useToastContext } from '@librechat/client'; -import type { TError } from 'librechat-data-provider'; +import { useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, TStartupConfig } from 'librechat-data-provider'; +import { Spinner, useToastContext, Label, Button } from '@librechat/client'; import { useUploadConversationsMutation } from '~/data-provider'; +import { NotificationSeverity } from '~/common'; import { useLocalize } from '~/hooks'; -import { cn } from '~/utils'; +import { cn, logger } from '~/utils'; function ImportConversations() { + const queryClient = useQueryClient(); + const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); const localize = useLocalize(); const fileInputRef = useRef(null); - const { showToast } = useToastContext(); - const [, setErrors] = useState([]); - const [allowImport, setAllowImport] = useState(true); - const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]); + + const [isUploading, setIsUploading] = useState(false); + + const handleSuccess = useCallback(() => { + showToast({ + message: localize('com_ui_import_conversation_success'), + status: NotificationSeverity.SUCCESS, + }); + setIsUploading(false); + }, [localize, showToast]); + + const handleError = useCallback( + (error: unknown) => { + logger.error('Import error:', error); + setIsUploading(false); + + const isUnsupportedType = error?.toString().includes('Unsupported import type'); + + showToast({ + message: localize( + isUnsupportedType + ? 'com_ui_import_conversation_file_type_error' + : 'com_ui_import_conversation_error', + ), + status: NotificationSeverity.ERROR, + }); + }, + [localize, showToast], + ); const uploadFile = useUploadConversationsMutation({ - onSuccess: () => { - showToast({ message: localize('com_ui_import_conversation_success') }); - setAllowImport(true); - }, - onError: (error) => { - console.error('Error: ', error); - setAllowImport(true); - setError( - (error as TError).response?.data?.message ?? 'An error occurred while uploading the file.', - ); - if (error?.toString().includes('Unsupported import type') === true) { - showToast({ - message: localize('com_ui_import_conversation_file_type_error'), - status: 'error', - }); - } else { - showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' }); - } - }, - onMutate: () => { - setAllowImport(false); - }, + onSuccess: handleSuccess, + onError: handleError, + onMutate: () => setIsUploading(true), }); - const startUpload = async (file: File) => { - const formData = new FormData(); - formData.append('file', file, encodeURIComponent(file.name || 'File')); + const handleFileUpload = useCallback( + async (file: File) => { + try { + const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize; + if (maxFileSize && file.size > maxFileSize) { + const size = (maxFileSize / (1024 * 1024)).toFixed(2); + showToast({ + message: localize('com_error_files_upload_too_large', { 0: size }), + status: NotificationSeverity.ERROR, + }); + setIsUploading(false); + return; + } - uploadFile.mutate(formData); - }; + const formData = new FormData(); + formData.append('file', file, encodeURIComponent(file.name || 'File')); + uploadFile.mutate(formData); + } catch (error) { + logger.error('File processing error:', error); + setIsUploading(false); + showToast({ + message: localize('com_ui_import_conversation_upload_error'), + status: NotificationSeverity.ERROR, + }); + } + }, + [uploadFile, showToast, localize, startupConfig], + ); - const handleFiles = async (_file: File) => { - try { - await startUpload(_file); - } catch (error) { - console.log('file handling error', error); - setError('An error occurred while processing the file.'); - } - }; + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + setIsUploading(true); + handleFileUpload(file); + } + event.target.value = ''; + }, + [handleFileUpload], + ); - const handleFileChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - handleFiles(file); - } - }; - - const handleImportClick = () => { + const handleImportClick = useCallback(() => { fileInputRef.current?.click(); - }; + }, []); - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleImportClick(); - } - }; + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleImportClick(); + } + }, + [handleImportClick], + ); + + const isImportDisabled = isUploading; return (
-
{localize('com_ui_import_conversation_info')}
- + { - const localize = useLocalize(); - - return ( -
- - -
- ); -}; diff --git a/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx b/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx new file mode 100644 index 0000000000..25147146ba --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { useRevokeAllUserKeysMutation } from 'librechat-data-provider/react-query'; +import { + OGDialogTemplate, + Button, + Label, + OGDialog, + OGDialogTrigger, + Spinner, +} from '@librechat/client'; +import { useLocalize } from '~/hooks'; + +export const RevokeKeys = ({ + disabled = false, + setDialogOpen, +}: { + disabled?: boolean; + setDialogOpen?: (open: boolean) => void; +}) => { + const localize = useLocalize(); + const [open, setOpen] = useState(false); + const revokeKeysMutation = useRevokeAllUserKeysMutation(); + + const handleSuccess = () => { + if (!setDialogOpen) { + return; + } + + setDialogOpen(false); + }; + + const onClick = () => { + revokeKeysMutation.mutate({}, { onSuccess: handleSuccess }); + }; + + const isLoading = revokeKeysMutation.isLoading; + + return ( +
+ + + + + + + + {localize('com_ui_revoke_keys_confirm')} + + } + selection={{ + selectHandler: onClick, + selectClasses: + 'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80', + selectText: isLoading ? : localize('com_ui_revoke'), + }} + /> + +
+ ); +}; diff --git a/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx b/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx deleted file mode 100644 index 51cf386a5d..0000000000 --- a/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useState } from 'react'; -import { - useRevokeAllUserKeysMutation, - useRevokeUserKeyMutation, -} from 'librechat-data-provider/react-query'; -import { - OGDialogTemplate, - Button, - Label, - OGDialog, - OGDialogTrigger, - Spinner, -} from '@librechat/client'; -import { useLocalize } from '~/hooks'; - -export const RevokeKeysButton = ({ - endpoint = '', - all = false, - disabled = false, - setDialogOpen, -}: { - endpoint?: string; - all?: boolean; - disabled?: boolean; - setDialogOpen?: (open: boolean) => void; -}) => { - const localize = useLocalize(); - const [open, setOpen] = useState(false); - const revokeKeyMutation = useRevokeUserKeyMutation(endpoint); - const revokeKeysMutation = useRevokeAllUserKeysMutation(); - - const handleSuccess = () => { - if (!setDialogOpen) { - return; - } - - setDialogOpen(false); - }; - - const onClick = () => { - if (all) { - revokeKeysMutation.mutate({}); - } else { - revokeKeyMutation.mutate({}, { onSuccess: handleSuccess }); - } - }; - - const dialogTitle = all - ? localize('com_ui_revoke_keys') - : localize('com_ui_revoke_key_endpoint', { 0: endpoint }); - - const dialogMessage = all - ? localize('com_ui_revoke_keys_confirm') - : localize('com_ui_revoke_key_confirm'); - - const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading; - - return ( - - - - - {dialogMessage}} - selection={{ - selectHandler: onClick, - selectClasses: - 'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80', - selectText: isLoading ? : localize('com_ui_revoke'), - }} - /> - - ); -}; diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index 5f24a5770c..ae25223a9b 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -286,11 +286,13 @@ export default function SharedLinks() { return (
-
{localize('com_nav_shared_links')}
+ setIsOpen(true)}> - + -
{localize('com_nav_theme')}
+
{localize('com_nav_theme')}
); @@ -112,9 +115,11 @@ export const LangSelector = ({ { value: 'uk-UA', label: localize('com_nav_lang_ukrainian') }, ]; + const labelId = 'language-selector-label'; + return (
-
{localize('com_nav_language')}
+
{localize('com_nav_language')}
); diff --git a/client/src/components/Nav/SettingsTabs/Personalization.tsx b/client/src/components/Nav/SettingsTabs/Personalization.tsx index 50ce452783..f9e43dc6f5 100644 --- a/client/src/components/Nav/SettingsTabs/Personalization.tsx +++ b/client/src/components/Nav/SettingsTabs/Personalization.tsx @@ -65,10 +65,13 @@ export default function Personalization({
-
+
{localize('com_ui_reference_saved_memories')}
-
+
{localize('com_ui_reference_saved_memories_description')}
@@ -76,7 +79,8 @@ export default function Personalization({ checked={referenceSavedMemories} onCheckedChange={handleMemoryToggle} disabled={updateMemoryPreferencesMutation.isLoading} - aria-label={localize('com_ui_reference_saved_memories')} + aria-labelledby="reference-saved-memories-label" + aria-describedby="reference-saved-memories-description" />
diff --git a/client/src/components/Nav/SettingsTabs/Speech/ConversationModeSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/ConversationModeSwitch.tsx index 5745943e57..3a3df8efab 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/ConversationModeSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/ConversationModeSwitch.tsx @@ -1,6 +1,5 @@ -import { Switch } from '@librechat/client'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { useLocalize } from '~/hooks'; +import ToggleSwitch from '../ToggleSwitch'; import store from '~/store'; export default function ConversationModeSwitch({ @@ -8,8 +7,6 @@ export default function ConversationModeSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [conversationMode, setConversationMode] = useRecoilState(store.conversationMode); const speechToText = useRecoilValue(store.speechToText); const textToSpeech = useRecoilValue(store.textToSpeech); const [, setAutoSendText] = useRecoilState(store.autoSendText); @@ -20,27 +17,19 @@ export default function ConversationModeSwitch({ setAutoTranscribeAudio(value); setAutoSendText(3); setDecibelValue(-45); - setConversationMode(value); if (onCheckedChange) { onCheckedChange(value); } }; return ( -
-
- {localize('com_nav_conversation_mode')} -
-
- -
-
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/AutoSendTextSelector.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/AutoSendTextSelector.tsx index 5c5e8e3da3..a033ed322c 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/AutoSendTextSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/AutoSendTextSelector.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; -import { Slider, InputNumber } from '@librechat/client'; +import { Slider, InputNumber, Switch } from '@librechat/client'; import { cn, defaultTextProps, optionText } from '~/utils/'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -11,40 +11,104 @@ export default function AutoSendTextSelector() { const speechToText = useRecoilValue(store.speechToText); const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText); + // Local state for enabled/disabled toggle + const [isEnabled, setIsEnabled] = useState(autoSendText !== -1); + const [delayValue, setDelayValue] = useState(autoSendText === -1 ? 3 : autoSendText); + + // Sync local state when autoSendText changes externally + useEffect(() => { + setIsEnabled(autoSendText !== -1); + if (autoSendText !== -1) { + setDelayValue(autoSendText); + } + }, [autoSendText]); + + const handleToggle = (enabled: boolean) => { + setIsEnabled(enabled); + if (enabled) { + setAutoSendText(delayValue); + } else { + setAutoSendText(-1); + } + }; + + const handleSliderChange = (value: number[]) => { + const newValue = value[0]; + setDelayValue(newValue); + if (isEnabled) { + setAutoSendText(newValue); + } + }; + + const handleInputChange = (value: number[] | null) => { + const newValue = value ? value[0] : 3; + setDelayValue(newValue); + if (isEnabled) { + setAutoSendText(newValue); + } + }; + + const labelId = 'auto-send-text-label'; + return ( -
+
-
{localize('com_nav_auto_send_text')}
-
- ({localize('com_nav_auto_send_text_disabled')}) -
-
- setAutoSendText(value[0])} - onDoubleClick={() => setAutoSendText(-1)} - min={-1} - max={60} - step={1} - className="ml-4 flex h-4 w-24" +
+
{localize('com_nav_auto_send_text')}
+
+ -
- setAutoSendText(value ? value[0] : 0)} - min={-1} - max={60} - className={cn( - defaultTextProps, - cn( - optionText, - 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200', - ), - )} - />
+ {isEnabled && ( +
+
+
+ {localize('com_nav_setting_delay')} +
+
+
+ { + setDelayValue(3); + if (isEnabled) { + setAutoSendText(3); + } + }} + min={0} + max={60} + step={1} + className="ml-4 flex h-4 w-24" + disabled={!speechToText || !isEnabled} + aria-labelledby="auto-send-delay-label" + /> +
+ +
+
+ )}
); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/AutoTranscribeAudioSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/AutoTranscribeAudioSwitch.tsx index 1304f35c63..00a8c9f833 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/AutoTranscribeAudioSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/AutoTranscribeAudioSwitch.tsx @@ -1,6 +1,5 @@ -import { Switch } from '@librechat/client'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import { useLocalize } from '~/hooks'; +import { useRecoilValue } from 'recoil'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function AutoTranscribeAudioSwitch({ @@ -8,30 +7,15 @@ export default function AutoTranscribeAudioSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState( - store.autoTranscribeAudio, - ); const speechToText = useRecoilValue(store.speechToText); - const handleCheckedChange = (value: boolean) => { - setAutoTranscribeAudio(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; - return ( -
-
{localize('com_nav_auto_transcribe_audio')}
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx index f23de198c6..b311355108 100755 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx @@ -13,7 +13,7 @@ export default function DecibelSelector() { return (
-
{localize('com_nav_db_sensitivity')}
+
{localize('com_nav_db_sensitivity')}
({localize('com_endpoint_default_with_num', { 0: '-45' })}) @@ -29,6 +29,7 @@ export default function DecibelSelector() { step={1} className="ml-4 flex h-4 w-24" disabled={!speechToText} + aria-labelledby="decibel-selector-label" />
setDecibelValue(value ? value[0] : 0)} min={-100} max={-30} + aria-labelledby="decibel-selector-label" className={cn( defaultTextProps, cn( diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/EngineSTTDropdown.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/EngineSTTDropdown.tsx index f6bc4b91e7..8fc3dd8352 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/EngineSTTDropdown.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/EngineSTTDropdown.tsx @@ -23,9 +23,11 @@ const EngineSTTDropdown: React.FC = ({ external }) => { setEngineSTT(value); }; + const labelId = 'engine-stt-dropdown-label'; + return (
-
{localize('com_nav_engine')}
+
{localize('com_nav_engine')}
= ({ external }) => { sizeClasses="w-[180px]" testId="EngineSTTDropdown" className="z-50" + aria-labelledby={labelId} />
); diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/LanguageSTTDropdown.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/LanguageSTTDropdown.tsx index c3bb37ceef..53da4e7989 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/LanguageSTTDropdown.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/LanguageSTTDropdown.tsx @@ -94,9 +94,11 @@ export default function LanguageSTTDropdown() { setLanguageSTT(value); }; + const labelId = 'language-stt-dropdown-label'; + return (
-
{localize('com_nav_language')}
+
{localize('com_nav_language')}
); diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/SpeechToTextSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/SpeechToTextSwitch.tsx index 99c81f60e5..e06f3392d0 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/SpeechToTextSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/SpeechToTextSwitch.tsx @@ -1,6 +1,4 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function SpeechToTextSwitch({ @@ -8,28 +6,13 @@ export default function SpeechToTextSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [speechToText, setSpeechToText] = useRecoilState(store.speechToText); - - const handleCheckedChange = (value: boolean) => { - setSpeechToText(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; - return ( -
-
- {localize('com_nav_speech_to_text')} -
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx b/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx index acd87fa233..ee4c1eb09d 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/Speech.tsx @@ -23,7 +23,7 @@ import { } from './STT'; import ConversationModeSwitch from './ConversationModeSwitch'; import { useLocalize } from '~/hooks'; -import { cn, logger } from '~/utils'; +import { cn } from '~/utils'; import store from '~/store'; function Speech() { @@ -186,7 +186,7 @@ function Speech() {
- +
@@ -198,7 +198,7 @@ function Speech() {
- +
diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/AutomaticPlaybackSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/AutomaticPlaybackSwitch.tsx index 67537a8d65..916d38f5af 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/AutomaticPlaybackSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/AutomaticPlaybackSwitch.tsx @@ -1,6 +1,4 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function AutomaticPlaybackSwitch({ @@ -8,26 +6,12 @@ export default function AutomaticPlaybackSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback); - - const handleCheckedChange = (value: boolean) => { - setAutomaticPlayback(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; - return ( -
-
{localize('com_nav_automatic_playback')}
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/CacheTTSSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/CacheTTSSwitch.tsx index b3268e1a4b..6ebc54e3da 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/CacheTTSSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/CacheTTSSwitch.tsx @@ -1,6 +1,5 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import { useRecoilValue } from 'recoil'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function CacheTTSSwitch({ @@ -8,28 +7,15 @@ export default function CacheTTSSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [cacheTTS, setCacheTTS] = useRecoilState(store.cacheTTS); - const [textToSpeech] = useRecoilState(store.textToSpeech); - - const handleCheckedChange = (value: boolean) => { - setCacheTTS(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; + const textToSpeech = useRecoilValue(store.textToSpeech); return ( -
-
{localize('com_nav_enable_cache_tts')}
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx index 6a10806baa..9fa57bf90a 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/CloudBrowserVoicesSwitch.tsx @@ -1,6 +1,5 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import { useRecoilValue } from 'recoil'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function CloudBrowserVoicesSwitch({ @@ -8,30 +7,15 @@ export default function CloudBrowserVoicesSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState( - store.cloudBrowserVoices, - ); - const [textToSpeech] = useRecoilState(store.textToSpeech); - - const handleCheckedChange = (value: boolean) => { - setCloudBrowserVoices(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; + const textToSpeech = useRecoilValue(store.textToSpeech); return ( -
-
{localize('com_nav_enable_cloud_browser_voice')}
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/EngineTTSDropdown.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/EngineTTSDropdown.tsx index 95d45671b3..a5a576ba92 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/EngineTTSDropdown.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/EngineTTSDropdown.tsx @@ -23,9 +23,11 @@ const EngineTTSDropdown: React.FC = ({ external }) => { setEngineTTS(value); }; + const labelId = 'engine-tts-dropdown-label'; + return (
-
{localize('com_nav_engine')}
+
{localize('com_nav_engine')}
= ({ external }) => { sizeClasses="w-[180px]" testId="EngineTTSDropdown" className="z-50" + aria-labelledby={labelId} />
); diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/PlaybackRate.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/PlaybackRate.tsx index 571055a377..fee956e2f2 100755 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/PlaybackRate.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/PlaybackRate.tsx @@ -13,7 +13,7 @@ export default function DecibelSelector() { return (
-
{localize('com_nav_playback_rate')}
+
{localize('com_nav_playback_rate')}
({localize('com_endpoint_default_with_num', { 0: '1' })}) @@ -29,6 +29,7 @@ export default function DecibelSelector() { step={0.1} className="ml-4 flex h-4 w-24" disabled={!textToSpeech} + aria-labelledby="playback-rate-label" />
setPlaybackRate(value ? value[0] : 0)} min={0.1} max={2} + aria-labelledby="playback-rate-label" className={cn( defaultTextProps, cn( diff --git a/client/src/components/Nav/SettingsTabs/Speech/TTS/TextToSpeechSwitch.tsx b/client/src/components/Nav/SettingsTabs/Speech/TTS/TextToSpeechSwitch.tsx index b9a4ad1665..f4c499eb78 100644 --- a/client/src/components/Nav/SettingsTabs/Speech/TTS/TextToSpeechSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/TTS/TextToSpeechSwitch.tsx @@ -1,6 +1,4 @@ -import { useRecoilState } from 'recoil'; -import { Switch } from '@librechat/client'; -import { useLocalize } from '~/hooks'; +import ToggleSwitch from '../../ToggleSwitch'; import store from '~/store'; export default function TextToSpeechSwitch({ @@ -8,28 +6,13 @@ export default function TextToSpeechSwitch({ }: { onCheckedChange?: (value: boolean) => void; }) { - const localize = useLocalize(); - const [TextToSpeech, setTextToSpeech] = useRecoilState(store.textToSpeech); - - const handleCheckedChange = (value: boolean) => { - setTextToSpeech(value); - if (onCheckedChange) { - onCheckedChange(value); - } - }; - return ( -
-
- {localize('com_nav_text_to_speech')} -
- -
+ ); } diff --git a/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx b/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx index 64c8062ca7..391ab0a494 100644 --- a/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx +++ b/client/src/components/Nav/SettingsTabs/ToggleSwitch.tsx @@ -11,6 +11,9 @@ interface ToggleSwitchProps { hoverCardText?: LocalizeKey; switchId: string; onCheckedChange?: (value: boolean) => void; + showSwitch?: boolean; + disabled?: boolean; + strongLabel?: boolean; } const ToggleSwitch: React.FC = ({ @@ -19,6 +22,9 @@ const ToggleSwitch: React.FC = ({ hoverCardText, switchId, onCheckedChange, + showSwitch = true, + disabled = false, + strongLabel = false, }) => { const [switchState, setSwitchState] = useRecoilState(stateAtom); const localize = useLocalize(); @@ -28,10 +34,18 @@ const ToggleSwitch: React.FC = ({ onCheckedChange?.(value); }; + const labelId = `${switchId}-label`; + + if (!showSwitch) { + return null; + } + return (
-
{localize(localizationKey)}
+
+ {strongLabel ? {localize(localizationKey)} : localize(localizationKey)} +
{hoverCardText && }
= ({ onCheckedChange={handleCheckedChange} className="ml-4" data-testid={switchId} + aria-labelledby={labelId} + disabled={disabled} />
); diff --git a/client/src/components/Nav/SettingsTabs/index.ts b/client/src/components/Nav/SettingsTabs/index.ts index 9eab047c86..911cbaa98a 100644 --- a/client/src/components/Nav/SettingsTabs/index.ts +++ b/client/src/components/Nav/SettingsTabs/index.ts @@ -1,9 +1,8 @@ -export { default as General } from './General/General'; export { default as Chat } from './Chat/Chat'; export { default as Data } from './Data/Data'; -export { default as Commands } from './Commands/Commands'; -export { RevokeKeysButton } from './Data/RevokeKeysButton'; -export { default as Account } from './Account/Account'; -export { default as Balance } from './Balance/Balance'; export { default as Speech } from './Speech/Speech'; +export { default as Balance } from './Balance/Balance'; +export { default as General } from './General/General'; +export { default as Account } from './Account/Account'; +export { default as Commands } from './Commands/Commands'; export { default as Personalization } from './Personalization'; diff --git a/client/src/components/Prompts/Groups/ChatGroupItem.tsx b/client/src/components/Prompts/Groups/ChatGroupItem.tsx index a69ff16be3..586535f7cd 100644 --- a/client/src/components/Prompts/Groups/ChatGroupItem.tsx +++ b/client/src/components/Prompts/Groups/ChatGroupItem.tsx @@ -71,10 +71,12 @@ function ChatGroupItem({ { e.stopPropagation(); setPreviewDialogOpen(true); }} - className="w-full cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" + className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" > - {canEdit && ( { e.stopPropagation(); onEditClick(e); }} > - diff --git a/client/src/components/Prompts/Groups/DashGroupItem.tsx b/client/src/components/Prompts/Groups/DashGroupItem.tsx index 3e906fba8c..d10def66c6 100644 --- a/client/src/components/Prompts/Groups/DashGroupItem.tsx +++ b/client/src/components/Prompts/Groups/DashGroupItem.tsx @@ -89,7 +89,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps onKeyDown={handleKeyDown} role="button" tabIndex={0} - aria-label={`${group.name} prompt group`} + aria-label={`${group.name} Prompt, ${localize('com_ui_category')}: ${group.category ?? ''}`} >
diff --git a/client/src/components/Prompts/Groups/FilterPrompts.tsx b/client/src/components/Prompts/Groups/FilterPrompts.tsx index 7f2b4d301d..f74800bdf8 100644 --- a/client/src/components/Prompts/Groups/FilterPrompts.tsx +++ b/client/src/components/Prompts/Groups/FilterPrompts.tsx @@ -87,7 +87,7 @@ export default function FilterPrompts({ className = '' }: { className?: string } value={categoryFilter || SystemCategories.ALL} onChange={onSelect} options={filterOptions} - className="bg-transparent" + className="rounded-lg bg-transparent" icon={} label="Filter: " ariaLabel={localize('com_ui_filter_prompts')} diff --git a/client/src/components/Prompts/Groups/List.tsx b/client/src/components/Prompts/Groups/List.tsx index dca07b07b0..2dc8c5265e 100644 --- a/client/src/components/Prompts/Groups/List.tsx +++ b/client/src/components/Prompts/Groups/List.tsx @@ -40,7 +40,7 @@ export default function List({
)} -
+
{isLoading && isChatRoute && ( diff --git a/client/src/components/Prompts/PreviewPrompt.tsx b/client/src/components/Prompts/PreviewPrompt.tsx index b16443cb75..6193bd9e5d 100644 --- a/client/src/components/Prompts/PreviewPrompt.tsx +++ b/client/src/components/Prompts/PreviewPrompt.tsx @@ -14,7 +14,7 @@ const PreviewPrompt = ({ return ( -
+
diff --git a/client/src/components/Share/Message.tsx b/client/src/components/Share/Message.tsx index eddd5060e5..e556145481 100644 --- a/client/src/components/Share/Message.tsx +++ b/client/src/components/Share/Message.tsx @@ -1,4 +1,4 @@ -import { useRecoilValue } from 'recoil'; +import { useAtomValue } from 'jotai'; import type { TMessageProps } from '~/common'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; @@ -6,16 +6,16 @@ import SearchContent from '~/components/Chat/Messages/Content/SearchContent'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import { Plugin } from '~/components/Messages/Content'; import SubRow from '~/components/Chat/Messages/SubRow'; +import { fontSizeAtom } from '~/store/fontSize'; import { MessageContext } from '~/Providers'; import { useAttachments } from '~/hooks'; import MultiMessage from './MultiMessage'; import { cn } from '~/utils'; -import store from '~/store'; import Icon from './MessageIcon'; export default function Message(props: TMessageProps) { - const fontSize = useRecoilValue(store.fontSize); + const fontSize = useAtomValue(fontSizeAtom); const { message, siblingIdx, diff --git a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx index 84d0a30a9b..63b7e9af7b 100644 --- a/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx +++ b/client/src/components/SidePanel/Agents/Search/ApiKeyDialog.tsx @@ -2,10 +2,10 @@ import { useState } from 'react'; import { Button, OGDialog, OGDialogTemplate } from '@librechat/client'; import { AuthType, - SearchCategories, RerankerTypes, SearchProviders, - ScraperTypes, + ScraperProviders, + SearchCategories, } from 'librechat-data-provider'; import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool'; import type { UseFormRegister, UseFormHandleSubmit } from 'react-hook-form'; @@ -45,7 +45,9 @@ export default function ApiKeyDialog({ const [selectedReranker, setSelectedReranker] = useState( config?.webSearch?.rerankerType || RerankerTypes.JINA, ); - const [selectedScraper, setSelectedScraper] = useState(ScraperTypes.FIRECRAWL); + const [selectedScraper, setSelectedScraper] = useState( + config?.webSearch?.scraperProvider || ScraperProviders.FIRECRAWL, + ); const providerOptions: DropdownOption[] = [ { @@ -119,7 +121,7 @@ export default function ApiKeyDialog({ const scraperOptions: DropdownOption[] = [ { - key: ScraperTypes.FIRECRAWL, + key: ScraperProviders.FIRECRAWL, label: localize('com_ui_web_search_scraper_firecrawl'), inputs: { firecrawlApiUrl: { @@ -136,6 +138,20 @@ export default function ApiKeyDialog({ }, }, }, + { + key: ScraperProviders.SERPER, + label: localize('com_ui_web_search_scraper_serper'), + inputs: { + serperApiKey: { + placeholder: localize('com_ui_enter_api_key'), + type: 'password' as const, + link: { + url: 'https://serper.dev/api-keys', + text: localize('com_ui_web_search_scraper_serper_key'), + }, + }, + }, + }, ]; const [dropdownOpen, setDropdownOpen] = useState({ @@ -157,7 +173,7 @@ export default function ApiKeyDialog({ }; const handleScraperChange = (key: string) => { - setSelectedScraper(key as ScraperTypes); + setSelectedScraper(key as ScraperProviders); }; return ( @@ -198,7 +214,7 @@ export default function ApiKeyDialog({ selectedKey={selectedScraper} onSelectionChange={handleScraperChange} dropdownOptions={scraperOptions} - showDropdown={!config?.webSearch?.scraperType} + showDropdown={!config?.webSearch?.scraperProvider} register={register} dropdownOpen={dropdownOpen.scraper} setDropdownOpen={(open) => diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 21fb765fb1..7abea71187 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -168,18 +168,26 @@ export const useArchiveConvoMutation = ( }; export const useCreateSharedLinkMutation = ( - options?: t.MutationOptions, -): UseMutationResult => { + options?: t.MutationOptions< + t.TCreateShareLinkRequest, + { conversationId: string; targetMessageId?: string } + >, +): UseMutationResult< + t.TSharedLinkResponse, + unknown, + { conversationId: string; targetMessageId?: string }, + unknown +> => { const queryClient = useQueryClient(); const { onSuccess, ..._options } = options || {}; return useMutation( - ({ conversationId }: { conversationId: string }) => { + ({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => { if (!conversationId) { throw new Error('Conversation ID is required'); } - return dataService.createSharedLink(conversationId); + return dataService.createSharedLink(conversationId, targetMessageId); }, { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { diff --git a/client/src/hooks/Agents/useAgentToolPermissions.ts b/client/src/hooks/Agents/useAgentToolPermissions.ts index 90f2bc88e5..cff9e9635b 100644 --- a/client/src/hooks/Agents/useAgentToolPermissions.ts +++ b/client/src/hooks/Agents/useAgentToolPermissions.ts @@ -9,6 +9,7 @@ interface AgentToolPermissionsResult { fileSearchAllowedByAgent: boolean; codeAllowedByAgent: boolean; tools: string[] | undefined; + provider?: string; } /** @@ -36,6 +37,11 @@ export default function useAgentToolPermissions( [agentData?.tools, selectedAgent?.tools], ); + const provider = useMemo( + () => agentData?.provider || selectedAgent?.provider, + [agentData?.provider, selectedAgent?.provider], + ); + const fileSearchAllowedByAgent = useMemo(() => { // Check ephemeral agent settings if (isEphemeralAgent(agentId)) { @@ -61,6 +67,7 @@ export default function useAgentToolPermissions( return { fileSearchAllowedByAgent, codeAllowedByAgent, + provider, tools, }; } diff --git a/client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts b/client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts new file mode 100644 index 0000000000..f9f29e0c56 --- /dev/null +++ b/client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts @@ -0,0 +1,219 @@ +import { renderHook } from '@testing-library/react'; +import useArtifactProps from '../useArtifactProps'; +import type { Artifact } from '~/common'; + +describe('useArtifactProps', () => { + const createArtifact = (partial: Partial): Artifact => ({ + id: 'test-id', + lastUpdateTime: Date.now(), + ...partial, + }); + + describe('markdown artifacts', () => { + it('should handle text/markdown type with content.md as fileKey', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Hello World\n\nThis is markdown.', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('content.md'); + expect(result.current.template).toBe('react-ts'); + }); + + it('should handle text/plain type with content.md as fileKey', () => { + const artifact = createArtifact({ + type: 'text/plain', + content: '# Plain text as markdown', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('content.md'); + expect(result.current.template).toBe('react-ts'); + }); + + it('should include content.md in files with original markdown', () => { + const markdownContent = '# Test\n\n- Item 1\n- Item 2'; + const artifact = createArtifact({ + type: 'text/markdown', + content: markdownContent, + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.files['content.md']).toBe(markdownContent); + }); + + it('should include App.tsx with wrapped markdown renderer', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.files['App.tsx']).toContain('MarkdownRenderer'); + expect(result.current.files['App.tsx']).toContain('import React from'); + }); + + it('should include all required markdown files', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + // Check all required files are present + expect(result.current.files['content.md']).toBeDefined(); + expect(result.current.files['App.tsx']).toBeDefined(); + expect(result.current.files['index.tsx']).toBeDefined(); + expect(result.current.files['/components/ui/MarkdownRenderer.tsx']).toBeDefined(); + expect(result.current.files['markdown.css']).toBeDefined(); + }); + + it('should escape special characters in markdown content', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: 'Code: `const x = 1;`\nPath: C:\\Users', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + // Original content should be preserved in content.md + expect(result.current.files['content.md']).toContain('`const x = 1;`'); + expect(result.current.files['content.md']).toContain('C:\\Users'); + + // App.tsx should have escaped content + expect(result.current.files['App.tsx']).toContain('\\`'); + expect(result.current.files['App.tsx']).toContain('\\\\'); + }); + + it('should handle empty markdown content', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.files['content.md']).toBe('# No content provided'); + }); + + it('should handle undefined markdown content', () => { + const artifact = createArtifact({ + type: 'text/markdown', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.files['content.md']).toBe('# No content provided'); + }); + + it('should provide marked-react dependency', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.sharedProps.customSetup?.dependencies).toHaveProperty('marked-react'); + }); + + it('should update files when content changes', () => { + const artifact = createArtifact({ + type: 'text/markdown', + content: '# Original', + }); + + const { result, rerender } = renderHook(({ artifact }) => useArtifactProps({ artifact }), { + initialProps: { artifact }, + }); + + expect(result.current.files['content.md']).toBe('# Original'); + + // Update the artifact content + const updatedArtifact = createArtifact({ + ...artifact, + content: '# Updated', + }); + + rerender({ artifact: updatedArtifact }); + + expect(result.current.files['content.md']).toBe('# Updated'); + }); + }); + + describe('mermaid artifacts', () => { + it('should handle mermaid type with content.md as fileKey', () => { + const artifact = createArtifact({ + type: 'application/vnd.mermaid', + content: 'graph TD\n A-->B', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('diagram.mmd'); + expect(result.current.template).toBe('react-ts'); + }); + }); + + describe('react artifacts', () => { + it('should handle react type with App.tsx as fileKey', () => { + const artifact = createArtifact({ + type: 'application/vnd.react', + content: 'export default () =>
Test
', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('App.tsx'); + expect(result.current.template).toBe('react-ts'); + }); + }); + + describe('html artifacts', () => { + it('should handle html type with index.html as fileKey', () => { + const artifact = createArtifact({ + type: 'text/html', + content: 'Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + expect(result.current.fileKey).toBe('index.html'); + expect(result.current.template).toBe('static'); + }); + }); + + describe('edge cases', () => { + it('should handle artifact with language parameter', () => { + const artifact = createArtifact({ + type: 'text/markdown', + language: 'en', + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + // Language parameter should not affect markdown handling + // It checks the type directly, not the key + expect(result.current.fileKey).toBe('content.md'); + expect(result.current.files['content.md']).toBe('# Test'); + }); + + it('should handle artifact with undefined type', () => { + const artifact = createArtifact({ + content: '# Test', + }); + + const { result } = renderHook(() => useArtifactProps({ artifact })); + + // Should use default behavior + expect(result.current.template).toBe('static'); + }); + }); +}); diff --git a/client/src/hooks/Artifacts/useArtifactProps.ts b/client/src/hooks/Artifacts/useArtifactProps.ts index 6de90f5893..2b898934c4 100644 --- a/client/src/hooks/Artifacts/useArtifactProps.ts +++ b/client/src/hooks/Artifacts/useArtifactProps.ts @@ -3,11 +3,19 @@ import { removeNullishValues } from 'librechat-data-provider'; import type { Artifact } from '~/common'; import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts'; import { getMermaidFiles } from '~/utils/mermaid'; +import { getMarkdownFiles } from '~/utils/markdown'; export default function useArtifactProps({ artifact }: { artifact: Artifact }) { const [fileKey, files] = useMemo(() => { - if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) { - return ['App.tsx', getMermaidFiles(artifact.content ?? '')]; + const key = getKey(artifact.type ?? '', artifact.language); + const type = artifact.type ?? ''; + + if (key.includes('mermaid')) { + return ['diagram.mmd', getMermaidFiles(artifact.content ?? '')]; + } + + if (type === 'text/markdown' || type === 'text/md' || type === 'text/plain') { + return ['content.md', getMarkdownFiles(artifact.content ?? '')]; } const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language); diff --git a/client/src/hooks/Artifacts/useArtifacts.ts b/client/src/hooks/Artifacts/useArtifacts.ts index 55248ffa44..5eb0d4ee73 100644 --- a/client/src/hooks/Artifacts/useArtifacts.ts +++ b/client/src/hooks/Artifacts/useArtifacts.ts @@ -122,17 +122,8 @@ export default function useArtifacts() { setCurrentArtifactId(orderedArtifactIds[newIndex]); }; - const isMermaid = useMemo(() => { - if (currentArtifact?.type == null) { - return false; - } - const key = getKey(currentArtifact.type, currentArtifact.language); - return key.includes('mermaid'); - }, [currentArtifact?.type, currentArtifact?.language]); - return { activeTab, - isMermaid, setActiveTab, currentIndex, cycleArtifact, diff --git a/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx b/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx index a3d3b8d67d..e0dbac5a1e 100644 --- a/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useFocusChatEffect.spec.tsx @@ -21,8 +21,8 @@ describe('useFocusChatEffect', () => { (useNavigate as jest.Mock).mockReturnValue(mockNavigate); // Mock window.matchMedia - window.matchMedia = jest.fn().mockImplementation(() => ({ - matches: false, + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query === '(hover: hover)', // Desktop has hover capability media: '', onchange: null, addListener: jest.fn(), @@ -83,8 +83,8 @@ describe('useFocusChatEffect', () => { }); test('should not focus textarea on touchscreen devices', () => { - window.matchMedia = jest.fn().mockImplementation(() => ({ - matches: true, // This indicates a touchscreen + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: query === '(pointer: coarse)', // Touchscreen has coarse pointer media: '', onchange: null, addListener: jest.fn(), diff --git a/client/src/hooks/Chat/useFocusChatEffect.ts b/client/src/hooks/Chat/useFocusChatEffect.ts index fefb2c9584..17c41fef01 100644 --- a/client/src/hooks/Chat/useFocusChatEffect.ts +++ b/client/src/hooks/Chat/useFocusChatEffect.ts @@ -12,12 +12,22 @@ export default function useFocusChatEffect(textAreaRef: React.RefObject { hasSetConversation.current = true; setSubmission(null); if (resetLatestMessage) { + logger.log('latest_message', 'Clearing all latest messages'); clearAllLatestMessages(); } diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 9deaa78f24..7825888985 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -392,13 +392,6 @@ const useFileHandling = (params?: UseFileHandling) => { } else { // File wasn't processed, proceed with original const isImage = originalFile.type.split('/')[0] === 'image'; - const tool_resource = - initialExtendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource; - if (isAgentsEndpoint(endpoint) && !isImage && tool_resource == null) { - /** Note: this needs to be removed when we can support files to providers */ - setError('com_error_files_unsupported_capability'); - continue; - } // Update progress to show ready for upload const readyExtendedFile = { diff --git a/client/src/hooks/Messages/useMessageHelpers.tsx b/client/src/hooks/Messages/useMessageHelpers.tsx index 264fe666d6..8343e97756 100644 --- a/client/src/hooks/Messages/useMessageHelpers.tsx +++ b/client/src/hooks/Messages/useMessageHelpers.tsx @@ -3,8 +3,8 @@ import { useEffect, useRef, useCallback, useMemo } from 'react'; import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; import type { TMessageProps } from '~/common'; import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; +import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils'; import useCopyToClipboard from './useCopyToClipboard'; -import { getTextKey, logger } from '~/utils'; export default function useMessageHelpers(props: TMessageProps) { const latestText = useRef(''); @@ -49,15 +49,27 @@ export default function useMessageHelpers(props: TMessageProps) { messageId: message.messageId, convoId, }; + + /* Extracted convoId from previous textKey (format: messageId|||length|||lastChars|||convoId) */ + let previousConvoId: string | null = null; + if ( + latestText.current && + typeof latestText.current === 'string' && + latestText.current.length > 0 + ) { + const parts = latestText.current.split(TEXT_KEY_DIVIDER); + previousConvoId = parts[parts.length - 1] || null; + } + if ( textKey !== latestText.current || - (latestText.current && convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2]) + (convoId != null && previousConvoId != null && convoId !== previousConvoId) ) { - logger.log('[useMessageHelpers] Setting latest message: ', logInfo); + logger.log('latest_message', '[useMessageHelpers] Setting latest message: ', logInfo); latestText.current = textKey; setLatestMessage({ ...message }); } else { - logger.log('No change in latest message', logInfo); + logger.log('latest_message', 'No change in latest message', logInfo); } }, [isLast, message, setLatestMessage, conversation?.conversationId]); diff --git a/client/src/hooks/Messages/useMessageProcess.tsx b/client/src/hooks/Messages/useMessageProcess.tsx index ea5779a69f..30bec90d17 100644 --- a/client/src/hooks/Messages/useMessageProcess.tsx +++ b/client/src/hooks/Messages/useMessageProcess.tsx @@ -3,8 +3,8 @@ import { useRecoilValue } from 'recoil'; import { Constants } from 'librechat-data-provider'; import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import type { TMessage } from 'librechat-data-provider'; +import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils'; import { useMessagesViewContext } from '~/Providers'; -import { getTextKey, logger } from '~/utils'; import store from '~/store'; export default function useMessageProcess({ message }: { message?: TMessage | null }) { @@ -43,11 +43,21 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu messageId: message.messageId, convoId, }; + + /* Extracted convoId from previous textKey (format: messageId|||length|||lastChars|||convoId) */ + let previousConvoId: string | null = null; + if ( + latestText.current && + typeof latestText.current === 'string' && + latestText.current.length > 0 + ) { + const parts = latestText.current.split(TEXT_KEY_DIVIDER); + previousConvoId = parts[parts.length - 1] || null; + } + if ( textKey !== latestText.current || - (convoId != null && - latestText.current && - convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2]) + (convoId != null && previousConvoId != null && convoId !== previousConvoId) ) { logger.log('latest_message', '[useMessageProcess] Setting latest message; logInfo:', logInfo); latestText.current = textKey; diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 1d860fbb7a..83c1ff1ad9 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -339,6 +339,7 @@ export default function useEventHandlers({ setShowStopButton(true); if (resetLatestMessage) { + logger.log('latest_message', 'syncHandler: resetting latest message'); resetLatestMessage(); } }, @@ -418,6 +419,7 @@ export default function useEventHandlers({ } if (resetLatestMessage) { + logger.log('latest_message', 'createdHandler: resetting latest message'); resetLatestMessage(); } scrollToEnd(() => setAbortScroll(false)); diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts index ab2d177428..22ea5f327c 100644 --- a/client/src/hooks/useNewConvo.ts +++ b/client/src/hooks/useNewConvo.ts @@ -179,6 +179,7 @@ const useNewConvo = (index = 0) => { } setSubmission({} as TSubmission); if (!(keepLatestMessage ?? false)) { + logger.log('latest_message', 'Clearing all latest messages'); clearAllLatestMessages(); } if (isCancelled) { diff --git a/client/src/locales/ar/translation.json b/client/src/locales/ar/translation.json index 0ac4053c1f..8aba0b49c1 100644 --- a/client/src/locales/ar/translation.json +++ b/client/src/locales/ar/translation.json @@ -243,7 +243,6 @@ "com_error_files_dupe": "تم اكتشاف ملف مكرر.", "com_error_files_empty": "الملفات الفارغة غير مسموح بها", "com_error_files_process": "حدث خطأ أثناء معالجة الملف.", - "com_error_files_unsupported_capability": "لا توجد قدرات مفعّلة تدعم هذا النوع من الملفات.", "com_error_files_upload": "حدث خطأ أثناء رفع الملف.", "com_error_files_upload_canceled": "تم إلغاء طلب تحميل الملف. ملاحظة: قد تكون عملية تحميل الملف لا تزال قيد المعالجة وستحتاج إلى حذفها يدويًا.", "com_error_files_validation": "حدث خطأ أثناء التحقق من صحة الملف.", @@ -269,7 +268,6 @@ "com_nav_auto_scroll": "التمرير التلقائي إلى أحدث عند الفتح", "com_nav_auto_send_prompts": "إرسال تلقائي للموجهات", "com_nav_auto_send_text": "إرسال النص تلقائيًا", - "com_nav_auto_send_text_disabled": "اضبط القيمة على -1 للتعطيل", "com_nav_auto_transcribe_audio": "النسخ التلقائي للصوت", "com_nav_automatic_playback": "تشغيل تلقائي لآخر رسالة", "com_nav_balance": "توازن", diff --git a/client/src/locales/ca/translation.json b/client/src/locales/ca/translation.json index f58e5a1daf..2ede5df176 100644 --- a/client/src/locales/ca/translation.json +++ b/client/src/locales/ca/translation.json @@ -4,9 +4,15 @@ "com_a11y_ai_composing": "La IA encara està escrivint.", "com_a11y_end": "La IA ha acabat la seva resposta.", "com_a11y_start": "La IA ha començat la seva resposta.", + "com_agents_all": "Tots els agents", + "com_agents_all_category": "Tot", "com_agents_by_librechat": "per LibreChat", + "com_agents_category_finance": "Finances", + "com_agents_category_hr": "Recursos Humans", + "com_agents_category_sales": "Vendes", "com_agents_code_interpreter": "Quan està habilitat, permet que el teu agent utilitzi la API de l'Intèrpret de Codi de LibreChat per executar codi generat, incloent-hi el processament de fitxers, de forma segura. Es requereix una clau API vàlida.", "com_agents_code_interpreter_title": "API d'Intèrpret de Codi", + "com_agents_contact": "Contacte", "com_agents_create_error": "S'ha produït un error en crear el teu agent.", "com_agents_description_placeholder": "Opcional: Descriu el teu Agent aquí", "com_agents_enable_file_search": "Habilita la Cerca de Fitxers", @@ -267,7 +273,6 @@ "com_error_files_dupe": "S'ha detectat un fitxer duplicat.", "com_error_files_empty": "No es permeten fitxers buits.", "com_error_files_process": "S'ha produït un error en processar el fitxer.", - "com_error_files_unsupported_capability": "No hi ha capacitats habilitades que admetin aquest tipus de fitxer.", "com_error_files_upload": "S'ha produït un error en pujar el fitxer.", "com_error_files_upload_canceled": "La sol·licitud de pujada de fitxer s'ha cancel·lat. Nota: la pujada podria seguir processant-se i s'haurà d'esborrar manualment.", "com_error_files_validation": "S'ha produït un error en validar el fitxer.", @@ -277,6 +282,7 @@ "com_error_moderation": "Sembla que el contingut enviat ha estat marcat pel nostre sistema de moderació per no complir les directrius de la comunitat. No podem continuar amb aquest tema concret. Si tens altres preguntes o temes, edita el missatge o crea una conversa nova.", "com_error_no_base_url": "No s'ha trobat cap URL base. Proporciona'n una i torna-ho a provar.", "com_error_no_user_key": "No s'ha trobat cap clau. Proporciona'n una i torna-ho a provar.", + "com_file_source": "Arxiu", "com_files_filter": "Filtra fitxers...", "com_files_no_results": "Cap resultat.", "com_files_number_selected": "{{0}} de {{1}} elements seleccionats", @@ -296,7 +302,6 @@ "com_nav_auto_scroll": "Desplaçament automàtic al darrer missatge en obrir el xat", "com_nav_auto_send_prompts": "Envia automàticament els prompts", "com_nav_auto_send_text": "Envia text automàticament", - "com_nav_auto_send_text_disabled": "estableix -1 per desactivar", "com_nav_auto_transcribe_audio": "Transcriu àudio automàticament", "com_nav_automatic_playback": "Reprodueix automàticament el darrer missatge", "com_nav_balance": "Balanç", @@ -471,6 +476,7 @@ "com_ui_2fa_setup": "Configura 2FA", "com_ui_2fa_verified": "Autenticació en dos passos verificada amb èxit", "com_ui_accept": "Accepto", + "com_ui_active": "Actiu", "com_ui_add": "Afegeix", "com_ui_add_model_preset": "Afegeix un model o predefinit per una resposta addicional", "com_ui_add_multi_conversation": "Afegeix multi-conversa", @@ -480,6 +486,7 @@ "com_ui_advanced": "Avançat", "com_ui_advanced_settings": "Configuració avançada", "com_ui_agent": "Agent", + "com_ui_agent_category_sales": "Vendes", "com_ui_agent_chain": "Cadena d'agents (mixtura d'agents)", "com_ui_agent_chain_info": "Permet crear seqüències d'agents. Cada agent pot accedir als resultats dels agents anteriors a la cadena. Basat en l'arquitectura \"Mixture-of-Agents\" on els agents utilitzen els resultats previs com a informació auxiliar.", "com_ui_agent_chain_max": "Has arribat al màxim de {{0}} agents.", diff --git a/client/src/locales/cs/translation.json b/client/src/locales/cs/translation.json index 913d455a55..027027f156 100644 --- a/client/src/locales/cs/translation.json +++ b/client/src/locales/cs/translation.json @@ -13,6 +13,8 @@ "com_agents_file_search_disabled": "Než nahrajete soubory pro vyhledávání, musíte vytvořit agenta.", "com_agents_file_search_info": "Při povolení bude agent informován o přesných názvech souborů uvedených níže, což mu umožní získat relevantní kontext z těchto souborů.", "com_agents_instructions_placeholder": "Systémové instrukce, které agent používá", + "com_agents_link_copied": "Odkaz zkopírován", + "com_agents_link_copy_failed": "Kopírování odkazu se nezdařilo", "com_agents_missing_provider_model": "Před vytvořením agenta vyberte poskytovatele a model.", "com_agents_name_placeholder": "Volitelné: Název agenta", "com_agents_no_access": "Nemáte oprávnění upravovat tohoto agenta.", @@ -187,7 +189,6 @@ "com_error_files_dupe": "Byl zjištěn duplicitní soubor.", "com_error_files_empty": "Prázdné soubory nejsou povoleny.", "com_error_files_process": "Při zpracování souboru došlo k chybě.", - "com_error_files_unsupported_capability": "Nejsou povoleny žádné funkce podporující tento typ souboru.", "com_error_files_upload": "Při nahrávání souboru došlo k chybě.", "com_error_files_upload_canceled": "Požadavek na nahrání souboru byl zrušen. Poznámka: nahrávání souboru může stále probíhat a bude nutné jej ručně smazat.", "com_error_files_validation": "Při ověřování souboru došlo k chybě.", @@ -215,7 +216,6 @@ "com_nav_auto_scroll": "Automaticky rolovat na nejnovější zprávu po otevření chatu", "com_nav_auto_send_prompts": "Automatické odesílání výzev", "com_nav_auto_send_text": "Automatické odesílání textu", - "com_nav_auto_send_text_disabled": "nastavte -1 pro deaktivaci", "com_nav_auto_transcribe_audio": "Automaticky přepisovat zvuk", "com_nav_automatic_playback": "Automatické přehrávání poslední zprávy", "com_nav_balance": "Zůstatek", diff --git a/client/src/locales/da/translation.json b/client/src/locales/da/translation.json index a664f6b435..9879e3c618 100644 --- a/client/src/locales/da/translation.json +++ b/client/src/locales/da/translation.json @@ -268,7 +268,6 @@ "com_error_files_dupe": "Duplikatfil fundet.", "com_error_files_empty": "Tomme filer er ikke tilladt.", "com_error_files_process": "Der opstod en fejl under behandlingen af filen.", - "com_error_files_unsupported_capability": "Ingen funktioner er aktiveret, der understøtter denne filtype.", "com_error_files_upload": "Der opstod en fejl under upload af filen.", "com_error_files_upload_canceled": "Anmodningen om filoverførsel blev annulleret. Bemærk: Filuploaden kan stadig være under behandling og skal slettes manuelt.", "com_error_files_validation": "Der opstod en fejl under validering af filen.", @@ -297,7 +296,6 @@ "com_nav_auto_scroll": "Auto-scroll til seneste besked, når chatten er åben", "com_nav_auto_send_prompts": "Automatisk afsendelse af prompte", "com_nav_auto_send_text": "Send tekst automatisk", - "com_nav_auto_send_text_disabled": "sæt -1 for at deaktivere", "com_nav_auto_transcribe_audio": "Automatisk transskribering af lyd", "com_nav_automatic_playback": "Autoplay Seneste besked", "com_nav_balance": "Balance", diff --git a/client/src/locales/de/translation.json b/client/src/locales/de/translation.json index 24a8fc10a5..58e89591b6 100644 --- a/client/src/locales/de/translation.json +++ b/client/src/locales/de/translation.json @@ -26,6 +26,7 @@ "com_agents_category_sales_description": "Agenten mit Fokus auf Vertriebsprozesse und Kundenbeziehungen", "com_agents_category_tab_label": "Kategorie {{category}}. {{position}} von {{total}}", "com_agents_category_tabs_label": "Agenten-Kategorien", + "com_agents_chat_with": "Chatte mit {{name}}", "com_agents_clear_search": "Suche löschen", "com_agents_code_interpreter": "Wenn aktiviert, ermöglicht es deinem Agenten, die LibreChat Code Interpreter API zu nutzen, um generierten Code sicher auszuführen, einschließlich der Verarbeitung von Dateien. Erfordert einen gültigen API-Schlüssel.", "com_agents_code_interpreter_title": "Code-Interpreter-API", @@ -59,7 +60,9 @@ "com_agents_error_timeout_suggestion": "Bitte überprüfe deine Internetverbindung und versuche es erneut.", "com_agents_error_timeout_title": "Verbindungs-Timeout", "com_agents_error_title": "Es ist ein Fehler aufgetreten", + "com_agents_file_context_description": "Als „Kontext“ hochgeladene Dateien werden als Text analysiert, um die Anweisungen des Agenten zu ergänzen. Wenn OCR verfügbar ist oder für den hochgeladenen Dateityp konfiguriert wurde, wird dieser Prozess zum Extrahieren von Text verwendet. Ideal für Dokumente, Bilder mit Text oder PDFs, bei denen du den vollständigen Textinhalt einer Datei benötigst.", "com_agents_file_context_disabled": "Der Agent muss vor dem Hochladen von Dateien für den Datei-Kontext erstellt werden.", + "com_agents_file_context_label": "Dateikontext", "com_agents_file_search_disabled": "Der Agent muss erstellt werden, bevor Dateien für die Dateisuche hochgeladen werden können.", "com_agents_file_search_info": "Wenn aktiviert, wird der Agent über die unten aufgelisteten exakten Dateinamen informiert und kann dadurch relevante Informationen aus diesen Dateien abrufen", "com_agents_grid_announcement": "Zeige {{count}} Agenten in der Kategorie {{category}}", @@ -360,7 +363,6 @@ "com_error_files_dupe": "Doppelte Datei erkannt.", "com_error_files_empty": "Leere Dateien sind nicht zulässig", "com_error_files_process": "Bei der Verarbeitung der Datei ist ein Fehler aufgetreten.", - "com_error_files_unsupported_capability": "Keine aktivierten Funktionen unterstützen diesen Dateityp", "com_error_files_upload": "Beim Hochladen der Datei ist ein Fehler aufgetreten", "com_error_files_upload_canceled": "Die Datei-Upload-Anfrage wurde abgebrochen. Hinweis: Der Upload-Vorgang könnte noch im Hintergrund laufen und die Datei muss möglicherweise manuell gelöscht werden.", "com_error_files_validation": "Bei der Validierung der Datei ist ein Fehler aufgetreten.", @@ -405,7 +407,6 @@ "com_nav_auto_scroll": "Automatisch zur neuesten Nachricht scrollen, wenn der Chat geöffnet wird", "com_nav_auto_send_prompts": "Prompts automatisch senden", "com_nav_auto_send_text": "Text automatisch senden", - "com_nav_auto_send_text_disabled": "-1 setzen zum Deaktivieren", "com_nav_auto_transcribe_audio": "Audio automatisch transkribieren", "com_nav_automatic_playback": "Automatische Wiedergabe der neuesten Nachricht", "com_nav_balance": "Guthaben", @@ -830,6 +831,7 @@ "com_ui_delete_success": "Erfolgreich gelöscht", "com_ui_delete_tool": "Werkzeug löschen", "com_ui_delete_tool_confirm": "Bist du sicher, dass du dieses Werkzeug löschen möchtest?", + "com_ui_delete_tool_save_reminder": "Tool entfernt. Speichere den Agenten, um die Änderungen zu übernehmen.", "com_ui_deleted": "Gelöscht", "com_ui_deleting_file": "Lösche Datei...", "com_ui_descending": "Absteigend", @@ -843,9 +845,9 @@ "com_ui_download_backup": "Backup-Codes herunterladen", "com_ui_download_backup_tooltip": "Bevor Sie fortfahren, laden Sie bitte Ihre Backup-Codes herunter. Sie benötigen sie, um den Zugang wiederherzustellen, falls Sie Ihr Authentifizierungsgerät verlieren.", "com_ui_download_error": "Fehler beim Herunterladen der Datei. Die Datei wurde möglicherweise gelöscht.", - "com_ui_drag_drop": "Ziehen und Ablegen", + "com_ui_drag_drop": "Ziehe eine beliebige Datei hierher, um sie zur Unterhaltung hinzuzufügen.", "com_ui_dropdown_variables": "Dropdown-Variablen:", - "com_ui_dropdown_variables_info": "Erstelle benutzerdefinierte Dropdown-Menüs für deine Prompts:: `{{variable_name:option1|option2|option3}}`", + "com_ui_dropdown_variables_info": "Erstelle benutzerdefinierte Dropdown-Menüs für deine Prompts: `{{variable_name:option1|option2|option3}}`", "com_ui_duplicate": "Duplizieren", "com_ui_duplication_error": "Beim Duplizieren der Konversation ist ein Fehler aufgetreten", "com_ui_duplication_processing": "Konversation wird dupliziert...", @@ -1022,6 +1024,7 @@ "com_ui_no_category": "Keine Kategorie", "com_ui_no_changes": "Es wurden keine Änderungen vorgenommen", "com_ui_no_individual_access": "Keine einzelnen Benutzer oder Gruppen haben Zugriff auf diesen Agenten.", + "com_ui_no_memories": "Keine Erinnerungen. Erstelle sie manuell oder fordere die KI auf, sich etwas zu merken.\n", "com_ui_no_personalization_available": "Derzeit sind keine Personalisierungsoptionen verfügbar.", "com_ui_no_read_access": "Du hast keine Berechtigung, Erinnerungen anzuzeigen.", "com_ui_no_results_found": "Keine Ergebnisse gefunden", @@ -1038,6 +1041,7 @@ "com_ui_oauth_error_missing_code": "Autorisierungscode fehlt. Bitte versuchen Sie es erneut.", "com_ui_oauth_error_missing_state": "Statusparameter fehlt. Bitte versuchen Sie es erneut.", "com_ui_oauth_error_title": "Authentifizierung fehlgeschlagen", + "com_ui_oauth_revoke": "Widerrufen", "com_ui_oauth_success_description": "Ihre Authentifizierung war erfolgreich. Dieses Fenster schliesst sich in", "com_ui_oauth_success_title": "Authentifizierung erfolgreich", "com_ui_of": "von", @@ -1223,6 +1227,7 @@ "com_ui_upload_invalid": "Ungültige Datei zum Hochladen. Muss ein Bild sein und das Limit nicht überschreiten", "com_ui_upload_invalid_var": "Ungültige Datei zum Hochladen. Muss ein Bild sein und {{0}} MB nicht überschreiten", "com_ui_upload_ocr_text": "Hochladen als Text mit OCR", + "com_ui_upload_provider": "Hochladen zum KI-Anbieter", "com_ui_upload_success": "Datei erfolgreich hochgeladen", "com_ui_upload_type": "Upload-Typ auswählen", "com_ui_usage": "Nutzung", @@ -1245,6 +1250,7 @@ "com_ui_web_search_cohere_key": "Cohere API-Key eingeben", "com_ui_web_search_firecrawl_url": "Firecrawl API URL (optional)\n", "com_ui_web_search_jina_key": "Den Jina API Schlüssel eingeben", + "com_ui_web_search_jina_url": "Jina API-URL (optional)", "com_ui_web_search_processing": "Ergebnisse verarbeiten", "com_ui_web_search_provider": "Anbieter der Suche", "com_ui_web_search_provider_searxng": "SearXNG", @@ -1256,9 +1262,12 @@ "com_ui_web_search_reranker_cohere_key": "Einen Cohere API-Schlüssel holen", "com_ui_web_search_reranker_jina": "Jina AI\n", "com_ui_web_search_reranker_jina_key": "Einen Jina API Schlüssel holen", + "com_ui_web_search_reranker_jina_url_help": "Mehr Infos über die Jina Rerank API", "com_ui_web_search_scraper": "Scraper", "com_ui_web_search_scraper_firecrawl": "Firecrawl API\n", "com_ui_web_search_scraper_firecrawl_key": "Einen Firecrawl API Schlüssel holen", + "com_ui_web_search_scraper_serper": "Serper Scrape API", + "com_ui_web_search_scraper_serper_key": "Hole einen Serper API Schlüssel", "com_ui_web_search_searxng_api_key": "SearXNG API Key (optional) einfügen", "com_ui_web_search_searxng_instance_url": "SearXNG Instanz URL", "com_ui_web_searching": "Internetsuche läuft", diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b1bc9ba96e..b686580376 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1,6 +1,6 @@ { - "chat_direction_left_to_right": "something needs to go here. was empty", - "chat_direction_right_to_left": "something needs to go here. was empty", + "chat_direction_left_to_right": "Left to Right", + "chat_direction_right_to_left": "Right to Left", "com_a11y_ai_composing": "The AI is still composing.", "com_a11y_end": "The AI has finished their reply.", "com_a11y_start": "The AI has started their reply.", @@ -363,9 +363,9 @@ "com_error_files_dupe": "Duplicate file detected.", "com_error_files_empty": "Empty files are not allowed.", "com_error_files_process": "An error occurred while processing the file.", - "com_error_files_unsupported_capability": "No capabilities enabled that support this file type.", "com_error_files_upload": "An error occurred while uploading the file.", "com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.", + "com_error_files_upload_too_large": "The file is too large. Please upload a file smaller than {{0}} MB", "com_error_files_validation": "An error occurred while validating the file.", "com_error_google_tool_conflict": "Usage of built-in Google tools are not supported with external tools. Please disable either the built-in tools or the external tools.", "com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.", @@ -409,7 +409,6 @@ "com_nav_auto_scroll": "Auto-Scroll to latest message on chat open", "com_nav_auto_send_prompts": "Auto-send Prompts", "com_nav_auto_send_text": "Auto send text", - "com_nav_auto_send_text_disabled": "set -1 to disable", "com_nav_auto_transcribe_audio": "Auto transcribe audio", "com_nav_automatic_playback": "Autoplay Latest Message", "com_nav_balance": "Balance", @@ -562,6 +561,7 @@ "com_nav_setting_balance": "Balance", "com_nav_setting_chat": "Chat", "com_nav_setting_data": "Data controls", + "com_nav_setting_delay": "Delay (s)", "com_nav_setting_general": "General", "com_nav_setting_mcp": "MCP Settings", "com_nav_setting_personalization": "Personalization", @@ -761,6 +761,7 @@ "com_ui_client_secret": "Client Secret", "com_ui_close": "Close", "com_ui_close_menu": "Close Menu", + "com_ui_close_settings": "Close Settings", "com_ui_close_window": "Close Window", "com_ui_code": "Code", "com_ui_collapse_chat": "Collapse Chat", @@ -859,6 +860,7 @@ "com_ui_edit_editing_image": "Editing image", "com_ui_edit_mcp_server": "Edit MCP Server", "com_ui_edit_memory": "Edit Memory", + "com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size", "com_ui_empty_category": "-", "com_ui_endpoint": "Endpoint", "com_ui_endpoint_menu": "LLM Endpoint Menu", @@ -893,6 +895,7 @@ "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_input_avatar_label": "File input for avatar", "com_ui_file_size": "File Size", "com_ui_file_token_limit": "File Token Limit", "com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage", @@ -955,11 +958,13 @@ "com_ui_import_conversation_file_type_error": "Unsupported import type", "com_ui_import_conversation_info": "Import conversations from a JSON file", "com_ui_import_conversation_success": "Conversations imported successfully", + "com_ui_import_conversation_upload_error": "Error uploading file. Please try again.", "com_ui_include_shadcnui": "Include shadcn/ui components instructions", "com_ui_initializing": "Initializing...", "com_ui_input": "Input", "com_ui_instructions": "Instructions", "com_ui_key": "Key", + "com_ui_key_required": "API key is required", "com_ui_late_night": "Happy late night", "com_ui_latest_footer": "Every AI for Everyone.", "com_ui_latest_production_version": "Latest production version", @@ -974,6 +979,7 @@ "com_ui_manage": "Manage", "com_ui_marketplace": "Marketplace", "com_ui_marketplace_allow_use": "Allow using Marketplace", + "com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})", "com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.", "com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully", "com_ui_mcp_configure_server": "Configure {{0}}", @@ -1068,6 +1074,7 @@ "com_ui_privacy_policy": "Privacy policy", "com_ui_privacy_policy_url": "Privacy Policy URL", "com_ui_prompt": "Prompt", + "com_ui_prompt_groups": "Prompt Groups List", "com_ui_prompt_name": "Prompt Name", "com_ui_prompt_name_required": "Prompt Name is required", "com_ui_prompt_preview_not_shared": "The author has not allowed collaboration for this prompt.", @@ -1097,6 +1104,8 @@ "com_ui_rename_failed": "Failed to rename conversation", "com_ui_rename_prompt": "Rename Prompt", "com_ui_requires_auth": "Requires Authentication", + "com_ui_reset": "Reset", + "com_ui_reset_adjustments": "Reset adjustments", "com_ui_reset_var": "Reset {{0}}", "com_ui_reset_zoom": "Reset Zoom", "com_ui_resource": "resource", @@ -1105,6 +1114,8 @@ "com_ui_revoke_info": "Revoke all user provided credentials", "com_ui_revoke_key_confirm": "Are you sure you want to revoke this key?", "com_ui_revoke_key_endpoint": "Revoke Key for {{0}}", + "com_ui_revoke_key_error": "Failed to revoke API key. Please try again.", + "com_ui_revoke_key_success": "API key revoked successfully", "com_ui_revoke_keys": "Revoke Keys", "com_ui_revoke_keys_confirm": "Are you sure you want to revoke all keys?", "com_ui_role": "Role", @@ -1118,11 +1129,15 @@ "com_ui_role_viewer": "Viewer", "com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it", "com_ui_roleplay": "Roleplay", + "com_ui_rotate": "Rotate", + "com_ui_rotate_90": "Rotate 90 degrees", "com_ui_run_code": "Run Code", "com_ui_run_code_error": "There was an error running the code", "com_ui_save": "Save", "com_ui_save_badge_changes": "Save badge changes?", "com_ui_save_changes": "Save Changes", + "com_ui_save_key_error": "Failed to save API key. Please try again.", + "com_ui_save_key_success": "API key saved successfully", "com_ui_save_submit": "Save & Submit", "com_ui_saved": "Saved!", "com_ui_saving": "Saving...", @@ -1219,6 +1234,7 @@ "com_ui_update_mcp_success": "Successfully created or updated MCP", "com_ui_upload": "Upload", "com_ui_upload_agent_avatar": "Successfully updated agent avatar", + "com_ui_upload_avatar_label": "Upload avatar image", "com_ui_upload_code_files": "Upload for Code Interpreter", "com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.", "com_ui_upload_error": "There was an error uploading your file", @@ -1230,6 +1246,7 @@ "com_ui_upload_invalid": "Invalid file for upload. Must be an image not exceeding the limit", "com_ui_upload_invalid_var": "Invalid file for upload. Must be an image not exceeding {{0}} MB", "com_ui_upload_ocr_text": "Upload as Text", + "com_ui_upload_provider": "Upload to Provider", "com_ui_upload_success": "Successfully uploaded file", "com_ui_upload_type": "Select Upload Type", "com_ui_usage": "Usage", @@ -1268,6 +1285,8 @@ "com_ui_web_search_scraper": "Scraper", "com_ui_web_search_scraper_firecrawl": "Firecrawl API", "com_ui_web_search_scraper_firecrawl_key": "Get your Firecrawl API key", + "com_ui_web_search_scraper_serper": "Serper Scrape API", + "com_ui_web_search_scraper_serper_key": "Get your Serper API key", "com_ui_web_search_searxng_api_key": "Enter SearXNG API Key (optional)", "com_ui_web_search_searxng_instance_url": "SearXNG Instance URL", "com_ui_web_searching": "Searching the web", @@ -1277,5 +1296,8 @@ "com_ui_x_selected": "{{0}} selected", "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", + "com_ui_zoom_in": "Zoom in", + "com_ui_zoom_level": "Zoom level", + "com_ui_zoom_out": "Zoom out", "com_user_message": "You" } diff --git a/client/src/locales/es/translation.json b/client/src/locales/es/translation.json index 576688a9b9..bc914e50b6 100644 --- a/client/src/locales/es/translation.json +++ b/client/src/locales/es/translation.json @@ -4,12 +4,59 @@ "com_a11y_ai_composing": "La IA está componiendo la respuesta", "com_a11y_end": "La IA ha finalizado su respuesta", "com_a11y_start": "La IA ha comenzado su respuesta", + "com_agents_agent_card_label": "{{name}} agente. {{description}}", + "com_agents_all": "Todos los Agentes", + "com_agents_all_category": "Todos", + "com_agents_all_description": "Explora todos los agentes compartidos en todas las categorías", "com_agents_by_librechat": "por LibreChat", + "com_agents_category_aftersales": "Posventa", + "com_agents_category_aftersales_description": "Agentes especializados en soporte postventa, mantenimiento y atención al cliente", + "com_agents_category_empty": "No se encontraron agentes en la categoría {{category}}", + "com_agents_category_finance": "Finanzas", + "com_agents_category_finance_description": "Agentes especializados en análisis financiero, presupuestos y contabilidad.", + "com_agents_category_general": "Genera", + "com_agents_category_general_description": "Agentes de uso general para tareas y consultas comunes", + "com_agents_category_hr": "Recursos Humanos", + "com_agents_category_hr_description": "Agentes especializados en procesos de recursos humanos, políticas y apoyo de los empleados", + "com_agents_category_it": "TI", + "com_agents_category_it_description": "Agentes de soporte de TI, resolución de problemas técnicos y administración de sistemas", + "com_agents_category_rd": "Investigación y desarrollo", + "com_agents_category_rd_description": "Agentes centrados en procesos de I+D, innovación e investigación técnica", + "com_agents_category_sales": "Ventas", + "com_agents_category_sales_description": "Agentes enfocados en procesos de venta, relaciones con el cliente", + "com_agents_category_tab_label": "{{category}} categoría, {{position}} de {{total}}", + "com_agents_category_tabs_label": "Categorías de agentes", "com_agents_code_interpreter": "Cuando está habilitado, permite que su agente utilice la API del Intérprete de Código de LibreChat para ejecutar código generado de manera segura, incluyendo el procesamiento de archivos. Requiere una clave de API válida.", "com_agents_code_interpreter_title": "API del Intérprete de Código", + "com_agents_contact": "Contacto", + "com_agents_copy_link": "Copiar enlace", "com_agents_create_error": "Hubo un error al crear su agente.", + "com_agents_created_by": "por", "com_agents_description_placeholder": "Opcional: Describa su Agente aquí", + "com_agents_empty_state_heading": "No se encontró ningún agente", "com_agents_enable_file_search": "Habilitar búsqueda de archivos", + "com_agents_error_bad_request_message": "No se ha podido procesar la solicitud.", + "com_agents_error_category_title": "Error de categoría", + "com_agents_error_generic": "Hemos encontrado un problema al cargar el contenido.", + "com_agents_error_invalid_request": "Petición inválida", + "com_agents_error_loading": "Error al cargar los agentes", + "com_agents_error_network_message": "No se puede conectar con el servidor.", + "com_agents_error_network_suggestion": "Comprueba tu conexión a Internet y vuelve a intentarlo.", + "com_agents_error_network_title": "Problema de conexión", + "com_agents_error_not_found_message": "No se ha podido encontrar el contenido solicitado.", + "com_agents_error_not_found_suggestion": "Prueba a buscar otras opciones o regresa a la tienda.", + "com_agents_error_not_found_title": "No se ha encontrado", + "com_agents_error_retry": "Inténtelo de nuevo", + "com_agents_error_search_title": "Error de búsqueda", + "com_agents_error_searching": "Error al buscar agentes", + "com_agents_error_server_message": "El servidor no está disponible temporalmente.", + "com_agents_error_server_suggestion": "Por favor, inténtelo de nuevo en unos momentos.", + "com_agents_error_server_title": "Error del servidor", + "com_agents_error_suggestion_generic": "Por favor, intente actualizar la página o inténtelo de nuevo más tarde.", + "com_agents_error_timeout_message": "La solicitud tardó demasiado en completarse.", + "com_agents_error_timeout_suggestion": "Por favor verifique su conexión a Internet y vuelva a intentarlo.", + "com_agents_error_timeout_title": "Tiempo de espera de conexión", + "com_agents_error_title": "Algo salió mal", "com_agents_file_context_disabled": "Es necesario crear el Agente antes de subir archivos.", "com_agents_file_search_disabled": "Es necesario crear el Agente antes de subir archivos para la Búsqueda de Archivos.", "com_agents_file_search_info": "Cuando está habilitado, se informará al agente sobre los nombres exactos de los archivos listados a continuación, permitiéndole recuperar el contexto relevante de estos archivos.", @@ -262,7 +309,6 @@ "com_error_files_dupe": "Se detectó un archivo duplicado", "com_error_files_empty": "No se permiten archivos vacíos.", "com_error_files_process": "Se produjo un error al procesar el archivo.", - "com_error_files_unsupported_capability": "No hay capacidades habilitadas que admitan este tipo de archivo.", "com_error_files_upload": "Se produjo un error durante la subida del archivo", "com_error_files_upload_canceled": "La solicitud de carga del archivo fue cancelada. Nota: es posible que la carga del archivo aún esté en proceso y necesite ser eliminada manualmente.", "com_error_files_validation": "Se produjo un error durante la validación del archivo.", @@ -290,7 +336,6 @@ "com_nav_auto_scroll": "Desplazamiento automático al más reciente al abrir", "com_nav_auto_send_prompts": "Envío automático de mensajes", "com_nav_auto_send_text": "Envío automático de texto", - "com_nav_auto_send_text_disabled": "Establecer -1 para deshabilitar", "com_nav_auto_transcribe_audio": "Transcribir audio automáticamente", "com_nav_automatic_playback": "Reproducción automática del último mensaje", "com_nav_balance": "Balance", @@ -451,24 +496,32 @@ "com_ui_agent_duplicate_error": "Se produjo un error al duplicar el asistente", "com_ui_agent_duplicated": "Agente duplicado exitosamente", "com_ui_agent_var": "{{0}} agente", + "com_ui_agent_version_restore_success": "Versión restaurada correctamente", + "com_ui_agent_version_unknown_date": "Fecha desconocida", "com_ui_agents": "Agentes", "com_ui_agents_allow_create": "Permitir la creación de Agentes", + "com_ui_agents_allow_share": "Permitir compartir Agentes", "com_ui_agents_allow_use": "Permitir el uso de Agentes", "com_ui_all": "todas", "com_ui_all_proper": "Todos", "com_ui_analyzing": "Analizando", "com_ui_analyzing_finished": "Acabando el análisis", + "com_ui_api_key": "Clave API", "com_ui_archive": "Archivar", + "com_ui_archive_delete_error": "Error al borrar conversación archivada", "com_ui_archive_error": "Error al archivar la conversación", "com_ui_artifact_click": "Haga clic para abrir", "com_ui_artifacts": "Artefactos", + "com_ui_artifacts_options": "Opciones de artefactos", "com_ui_artifacts_toggle": "Alternar Interfaz de Artefactos", + "com_ui_artifacts_toggle_agent": "Habilitar artefactos", "com_ui_ascending": "Asc", "com_ui_assistant": "Asistente", "com_ui_assistant_delete_error": "Hubo un error al eliminar el asistente", "com_ui_assistant_deleted": "Asistente eliminado con éxito", "com_ui_assistants": "Asistentes", "com_ui_assistants_output": "Salida de Asistentes", + "com_ui_at_least_one_owner_required": "Se requiere al menos un propietario", "com_ui_attach_error": "No se puede adjuntar el archivo. Cree o seleccione una conversación, o intente actualizar la página.", "com_ui_attach_error_openai": "No se pueden adjuntar archivos del Asistente a otros puntos de conexión", "com_ui_attach_error_size": "Se excedió el límite de tamaño de archivo para el endpoint:", @@ -476,6 +529,7 @@ "com_ui_attach_remove": "Eliminar archivo", "com_ui_attach_warn_endpoint": "Es posible que los archivos no compatibles con la herramienta sean ignorados", "com_ui_attachment": "Adjunto", + "com_ui_auth_type": "Tipo de autorización", "com_ui_authentication": "Autenticación", "com_ui_avatar": "Avatar", "com_ui_back_to_chat": "Volver al Chat", diff --git a/client/src/locales/et/translation.json b/client/src/locales/et/translation.json index b6f843ac9e..f7907d27d3 100644 --- a/client/src/locales/et/translation.json +++ b/client/src/locales/et/translation.json @@ -268,7 +268,6 @@ "com_error_files_dupe": "Leiti duplikaatfail.", "com_error_files_empty": "Tühjad failid pole lubatud.", "com_error_files_process": "Faili töötlemisel tekkis viga.", - "com_error_files_unsupported_capability": "Ühtegi seda failitüüpi toetavat võimalust pole lubatud.", "com_error_files_upload": "Faili üleslaadimisel tekkis viga.", "com_error_files_upload_canceled": "Faili üleslaadimise taotlus tühistati. Märkus: faili üleslaadimine võib endiselt olla pooleli ja see tuleb käsitsi kustutada.", "com_error_files_validation": "Faili valideerimisel tekkis viga.", @@ -297,7 +296,6 @@ "com_nav_auto_scroll": "Automaatne kerimine vestluse avamisel viimase sõnumini", "com_nav_auto_send_prompts": "Saada vihjed automaatselt", "com_nav_auto_send_text": "Saada tekst automaatselt", - "com_nav_auto_send_text_disabled": "Keelamiseks määra -1", "com_nav_auto_transcribe_audio": "Transkribeeri heli automaatselt", "com_nav_automatic_playback": "Esita viimane sõnum automaatselt", "com_nav_balance": "Saldo", diff --git a/client/src/locales/fa/translation.json b/client/src/locales/fa/translation.json index 6ab7e338e9..f194209d2c 100644 --- a/client/src/locales/fa/translation.json +++ b/client/src/locales/fa/translation.json @@ -263,7 +263,6 @@ "com_error_files_dupe": "فایل تکراری شناسایی شد.", "com_error_files_empty": "فایل های خالی مجاز نیستند.", "com_error_files_process": "هنگام پردازش فایل خطایی روی داد.", - "com_error_files_unsupported_capability": "هیچ قابلیتی فعال نیست که از این نوع فایل پشتیبانی کند.", "com_error_files_upload": "هنگام آپلود فایل خطایی روی داد.", "com_error_files_upload_canceled": "درخواست آپلود فایل لغو شد. توجه: ممکن است فایل آپلود هنوز در حال پردازش باشد و باید به صورت دستی حذف شود.", "com_error_files_validation": "هنگام اعتبارسنجی فایل خطایی روی داد.", @@ -292,7 +291,6 @@ "com_nav_auto_scroll": "اسکرول خودکار به آخرین پیام در گپ باز است", "com_nav_auto_send_prompts": "درخواست ارسال خودکار", "com_nav_auto_send_text": "ارسال خودکار متن", - "com_nav_auto_send_text_disabled": "-1 را برای غیرفعال کردن تنظیم کنید", "com_nav_auto_transcribe_audio": "رونویسی خودکار صدا", "com_nav_automatic_playback": "پخش خودکار آخرین پیام", "com_nav_balance": "تعادل", diff --git a/client/src/locales/fr/translation.json b/client/src/locales/fr/translation.json index 1f8ad3bcb5..2321acfdd1 100644 --- a/client/src/locales/fr/translation.json +++ b/client/src/locales/fr/translation.json @@ -1,19 +1,60 @@ { - "chat_direction_left_to_right": "Zone de saisie orientée de gauche à droite", - "chat_direction_right_to_left": "Zone de saisie orientée de droite à gauche", + "chat_direction_left_to_right": "il faut mettre quelque chose ici. c'était vide.", + "chat_direction_right_to_left": "Il faut mettre quelque chose ici. C'était vide.", "com_a11y_ai_composing": "L'IA est en train de composer", "com_a11y_end": "L'IA a terminé sa réponse", "com_a11y_start": "L'IA a commencé sa réponse", + "com_agents_agent_card_label": "{{nom}} agent. {{description}}", + "com_agents_all": "Tous les agents", + "com_agents_all_category": "Tous", + "com_agents_all_description": "Parcourir tous les agents partagés à travers toutes les catégories", "com_agents_by_librechat": "par LibreChat", + "com_agents_category_aftersales_description": "Agents spécialisés en support après-vente, maintenance et service clients", + "com_agents_category_finance": "Finance", + "com_agents_category_finance_description": "Agents spécialisés dans l'analyse financière, la budgétisation et la comptabilité", + "com_agents_category_general": "Générale", + "com_agents_category_general_description": "Agents génériques pour tâches et requêtes standards", + "com_agents_category_hr": "Ressources Humaines", + "com_agents_category_it_description": "Agents pour le support informatique, le dépannage technique et l'administration des systèmes", + "com_agents_category_rd": "Recherche & Développement", + "com_agents_category_rd_description": "Agent dédié au process R&D, à l'innovation et à la recherche", + "com_agents_category_sales": "Ventes", + "com_agents_category_sales_description": "Agents dédié au process de vente et à la relation client", + "com_agents_category_tab_label": "{{category}} catégorie, {{position}} sur {{total}}", + "com_agents_category_tabs_label": "Catégories d'agent", + "com_agents_chat_with": "Chatter avec {{name}}", + "com_agents_clear_search": "Effacer la recherche", "com_agents_code_interpreter": "Lorsqu'activé, permet à votre agent d'utiliser l'API d'interpréteur de code LibreChat pour exécuter du code généré de manière sécurisée, y compris le traitement de fichiers. Nécessite une clé API valide.", "com_agents_code_interpreter_title": "API d'interpréteur de code", + "com_agents_copy_link": "Copier le lien", "com_agents_create_error": "Une erreur s'est produite lors de la création de votre agent.", + "com_agents_created_by": "par", "com_agents_description_placeholder": "Décrivez votre Agent ici (facultatif)", + "com_agents_empty_state_heading": "Aucun agent trouvé", "com_agents_enable_file_search": "Activer la recherche de fichiers", + "com_agents_error_bad_request_message": "La demande ne peut pas être traitée", + "com_agents_error_bad_request_suggestion": "Vérifiez votre connexion Internet et réessayez.", + "com_agents_error_category_title": "Erreur de catégorie", + "com_agents_error_generic": "Nous avons rencontrez un problème lors du chargement du contenu", + "com_agents_error_invalid_request": "Requête invalide", + "com_agents_error_loading": "Error lors du chargement des agents", + "com_agents_error_network_message": "Impossible de se connecter au serveur", + "com_agents_error_network_suggestion": "Vérifier votre connexion internet et réessayer.", + "com_agents_error_network_title": "Problème de connexion", + "com_agents_error_not_found_message": "Le contenu demandé ne peut être trouvé", + "com_agents_error_not_found_title": "Non trouvé", + "com_agents_error_retry": "Essayer encore", + "com_agents_error_searching": "Erreur lors de la recherche d'agents\n", + "com_agents_error_server_title": "Erreur serveur", + "com_agents_error_title": "Une erreur est survenue", "com_agents_file_context_disabled": "L'agent doit être créé avant de charger des fichiers pour le contexte de fichiers.", "com_agents_file_search_disabled": "L'agent doit être créé avant de pouvoir télécharger des fichiers pour la Recherche de Fichiers.", "com_agents_file_search_info": "Lorsque cette option est activée, l'agent sera informé des noms exacts des fichiers listés ci-dessous, lui permettant d'extraire le contexte pertinent de ces fichiers.", "com_agents_instructions_placeholder": "Les instructions système que l'agent utilise", + "com_agents_link_copied": "Lien copié", + "com_agents_loading": "Chargement...", + "com_agents_marketplace": "Marketplace d'Agents", + "com_agents_marketplace_subtitle": "Découvres et profites d'agents IA puissants pour améliorer tes processus et ta productivité", "com_agents_mcp_description_placeholder": "Décrivez ce qu'il fait en quelques mots", "com_agents_mcp_icon_size": "Taille minimale de 128 x 128 pixels", "com_agents_mcp_info": "Ajoutez des serveurs MCP à votre agent pour lui permettre d'accomplir des tâches et d'interagir avec des services externes", @@ -25,14 +66,19 @@ "com_agents_no_access": "Vous n'avez pas l'autorisation de modifier cet agent.", "com_agents_no_agent_id_error": "Aucun identifiant (ID) d'agent trouvé. Assurez-vous que l'agent existe.", "com_agents_not_available": "Agent non disponible", + "com_agents_results_for": "Résultats pour '{{query}}'", + "com_agents_search_aria": "Rechercher un agent", "com_agents_search_info": "Lorsque cette option est activée, votre agent est autorisé à rechercher des informations récentes sur le web. Nécessite une clé API valide.", "com_agents_search_name": "Rechercher des agents par nom", + "com_agents_start_chat": "Débuter la conversation", + "com_agents_top_picks": "Meilleurs choix", "com_agents_update_error": "Une erreur s'est produite lors de la mise à jour de votre agent", "com_assistants_action_attempt": "L'assistant souhaite échanger avec {{0}}", "com_assistants_actions": "Actions", "com_assistants_actions_disabled": "Vous devez créer un assistant avant d'ajouter des actions.", "com_assistants_actions_info": "Permettez à votre Assistant de récupérer des informations ou d'effectuer des actions via des API", "com_assistants_add_actions": "Ajouter des actions", + "com_assistants_add_mcp_server_tools": "Ajouter des outils MCP Server", "com_assistants_add_tools": "Ajouter des outils", "com_assistants_allow_sites_you_trust": "Autoriser seulement les sites de confiance.", "com_assistants_append_date": "Ajouter la date et l'heure actuelles", @@ -195,6 +241,7 @@ "com_endpoint_deprecated": "Obsolète", "com_endpoint_deprecated_info": "Ce point de terminaison est obsolète et pourrait être supprimé dans les versions futures, veuillez utiliser le point de terminaison de l'agent à la place.", "com_endpoint_deprecated_info_a11y": "Le point de terminaison du plugin est obsolète et pourrait être supprimé dans les versions futures, veuillez utiliser le point de terminaison de l'agent à la place.", + "com_endpoint_disable_streaming_label": "Désactiver le streaming", "com_endpoint_examples": " Exemples", "com_endpoint_export": "Exporter", "com_endpoint_export_share": "Exporter/Partager", @@ -281,26 +328,38 @@ "com_endpoint_use_active_assistant": "Utiliser l'assistant actif", "com_endpoint_use_responses_api": "Utilise l'API de Réponses", "com_endpoint_use_search_grounding": "Ancrage avec les recherches Google", + "com_endpoint_verbosity": "Verbosité", + "com_error_endpoint_models_not_loaded": "Les modeles pour {{0}} ne peuvent pas être chargés, merci de rafraichir la page et réessayer.", "com_error_expired_user_key": "La clé fournie pour {{0}} a expiré à {{1}}. Veuillez fournir une clé et réessayer.", "com_error_files_dupe": "Fichier en double détecté.", "com_error_files_empty": "Les fichiers vides ne sont pas autorisés", "com_error_files_process": "Une erreur s'est produite lors du traitement du fichier.", - "com_error_files_unsupported_capability": "Aucune capacité activée ne prend en charge ce type de fichier.", "com_error_files_upload": "Une erreur s'est produite lors du téléversement du fichier", "com_error_files_upload_canceled": "La demande de téléversement du fichier a été annulée. Remarque : le téléversement peut être toujours en cours de traitement et devra être supprimé manuellement.", "com_error_files_validation": "Une erreur s'est produite lors de la validation du fichier.", "com_error_google_tool_conflict": "L'utilisation combinée des outils intégrés de Google et d'outils externes n'est pas prise en charge. Veuillez désactiver soit les outils intégrés soit les outils externes.", "com_error_heic_conversion": "La conversion de l'image HEIC en JPEG a échoué. Essayez de convertir l'image manuellement ou utilisez un autre format.", + "com_error_illegal_model_request": "Le modèle \"{{0}}\" n'est pas disponible pour {{1}}. Veuillez sélectionner un autre modèle.", "com_error_input_length": "Le nombre de jetons du dernier message est trop élevé et dépasse la limite autorisée ({{0}}). Veuillez raccourcir votre message, ajuster la taille maximale du contexte dans les paramètres de conversation, ou créer une nouvelle conversation pour continuer.", "com_error_invalid_agent_provider": "Le \"fournisseur {{0}} \" n'est pas disponible pour les agents. Veuillez vous rendre dans les paramètres de votre agent et sélectionner un fournisseur actuellement disponible.", "com_error_invalid_user_key": "Clé fournie non valide. Veuillez fournir une clé valide et réessayer.", + "com_error_missing_model": "Aucun modele pour {{0}}, Veuillez sélectionner un modèle et réessayer. ", "com_error_moderation": "Il semble que le contenu soumis ait été signalé par notre système de modération pour ne pas être conforme à nos lignes directrices communautaires. Nous ne pouvons pas procéder avec ce sujet spécifique. Si vous avez d'autres questions ou sujets que vous souhaitez explorer, veuillez modifier votre message ou créer une nouvelle conversation.", "com_error_no_base_url": "Aucune URL de base trouvée. Veuillez en fournir une et réessayer.", "com_error_no_user_key": "Aucune clé trouvée. Veuillez fournir une clé et réessayer.", + "com_file_source": "Fichier", + "com_file_unknown": "Fichier Inconnu\n", + "com_files_download_failed": "{{0}} fichiers échouées", + "com_files_download_percent_complete": "Terminé à {{0}}%", + "com_files_download_progress": "{{0}} fichiers sur {{1}}", "com_files_filter": "Filtrer les fichiers...", "com_files_no_results": "Aucun résultat.", "com_files_number_selected": "{{0}} sur {{1}} fichier(s) sélectionné(s)", + "com_files_preparing_download": "Préparation du téléchargement", + "com_files_sharepoint_picker_title": "Sélectionner un fichier", "com_files_table": "quelquechose doit être renseigné ici. c'était vide", + "com_files_upload_local_machine": "Depuis ordinateur local", + "com_files_upload_sharepoint": "Depuis Sharepoint", "com_generated_files": "Fichiers générés :", "com_hide_examples": "Masquer les exemples", "com_info_heic_converting": "Convertir les images HEIC en JPEG...", @@ -317,7 +376,6 @@ "com_nav_auto_scroll": "Défilement automatique jusqu'au plus récent à l'ouverture", "com_nav_auto_send_prompts": "Envoi automatique des prompts", "com_nav_auto_send_text": "Envoi automatique du texte (après 3 sec)", - "com_nav_auto_send_text_disabled": "définir sur -1 pour désactiver", "com_nav_auto_transcribe_audio": "Transcription audio automatique", "com_nav_automatic_playback": "Lecture automatique du dernier message (externe seulement)", "com_nav_balance": "Équilibre", @@ -439,7 +497,7 @@ "com_nav_log_out": "Se déconnecter", "com_nav_long_audio_warning": "Les textes plus longs prendront plus de temps à traiter.", "com_nav_maximize_chat_space": "Maximiser l'espace de discussion", - "com_nav_mcp_vars_update_error": "Erreur lors de l'actualisation des variables de l'utilisateur MCP personnalisé : {{0}}", + "com_nav_mcp_vars_update_error": "Erreur lors de l'actualisation des variables MCP de l'utilisateur", "com_nav_mcp_vars_updated": "Actualisation réussie des variables de l'utilisateur MCP personnalisé.", "com_nav_modular_chat": "Activer le changement de points de terminaison en cours de conversation", "com_nav_my_files": "Mes fichiers", @@ -483,6 +541,7 @@ "com_nav_tool_dialog": "Outils de l'assistant", "com_nav_tool_dialog_agents": "Outils de l'agent", "com_nav_tool_dialog_description": "L'assistant doit être sauvegardé pour conserver les sélections d'outils.", + "com_nav_tool_dialog_mcp_server_tools": "Outils MCP Server", "com_nav_tool_remove": "Supprimer", "com_nav_tool_search": "Outils de recherche", "com_nav_user": "UTILISATEUR", @@ -500,9 +559,14 @@ "com_sidepanel_manage_files": "Gérer les fichiers", "com_sidepanel_mcp_no_servers_with_vars": "Aucun serveur MCP dont les variables sont configurables.", "com_sidepanel_parameters": "Paramètres", + "com_sources_agent_file": "Document source", + "com_sources_error_fallback": "Impossible de charger la source", "com_sources_image_alt": "Image de résultat de recherche", + "com_sources_more_files": "+ {{count}} fichiers", "com_sources_more_sources": "+{{count}} sources", + "com_sources_reload_page": "Recharger la page", "com_sources_tab_all": "Tous", + "com_sources_tab_files": "Fichiers", "com_sources_tab_images": "Images", "com_sources_tab_news": "Actualités", "com_sources_title": "Sources", @@ -518,6 +582,7 @@ "com_ui_2fa_verified": "Vérification de l'authentification à deux facteurs réussie", "com_ui_accept": "J'accepte", "com_ui_action_button": "Bouton d'action", + "com_ui_active": "Actif", "com_ui_add": "Ajouter", "com_ui_add_mcp": "Ajouter MCP", "com_ui_add_mcp_server": "Ajouter un server MCP", @@ -530,6 +595,9 @@ "com_ui_advanced": "Avancé", "com_ui_advanced_settings": "Réglages avancés", "com_ui_agent": "Agent", + "com_ui_agent_category_finance": "Finance", + "com_ui_agent_category_general": "Général", + "com_ui_agent_category_rd": "R&D", "com_ui_agent_chain": "Chaîne d'agents (mélange d'agents)", "com_ui_agent_chain_info": "Active la création des séquences d'agents. Chaque agent peut accéder aux résultats des agents précédents de la chaîne. Basé sur l'architecture \"mélange d'agents\" où les agents utilisent les résultats précédents comme information auxiliaire.", "com_ui_agent_chain_max": "Vous avez atteint le maximum de {{0}} d'agents.", @@ -537,8 +605,10 @@ "com_ui_agent_deleted": "Agent supprimé avec succès", "com_ui_agent_duplicate_error": "Une erreur s'est produite lors de la duplication de l'agent", "com_ui_agent_duplicated": "Agent dupliqué avec succès", + "com_ui_agent_name_is_required": "Le nom de l'agent est obligatoire.", "com_ui_agent_recursion_limit": "Nombre maximal d'étapes de l'agent", "com_ui_agent_recursion_limit_info": "Limite le nombre d'étapes que l'agent peut exécuter avant de donner son résultat final. Par défaut, la limite est de 25 étapes. Une étape est soit une requête API soit une utilisation d'un outil. Par exemple, une utilisation simple d'un outil demande 3 étapes : requête initiale, utilisation de l'outil et envoi de la réponse.", + "com_ui_agent_url_copied": "URL de l'agent copiée dans le presse-papiers", "com_ui_agent_var": "agent {{0}}", "com_ui_agent_version": "Version", "com_ui_agent_version_active": "Version active", @@ -555,6 +625,7 @@ "com_ui_agent_version_unknown_date": "Date inconnue", "com_ui_agents": "Agents", "com_ui_agents_allow_create": "Autoriser la création d'Agents", + "com_ui_agents_allow_share": "Autoriser le partage Agents", "com_ui_agents_allow_use": "Autoriser l'utilisation des Agents", "com_ui_all": "tout", "com_ui_all_proper": "Tout", @@ -575,6 +646,7 @@ "com_ui_assistant_deleted": "Assistant supprimé avec succès", "com_ui_assistants": "Assistants virtuels", "com_ui_assistants_output": "Sortie des assistants", + "com_ui_at_least_one_owner_required": "Au moins un propriétaire est requis.", "com_ui_attach_error": "Impossible de joindre le fichier. Créez ou sélectionnez une conversation, ou essayez d'actualiser la page.", "com_ui_attach_error_openai": "Impossible de joindre les fichiers de l'Assistant à d'autres points d'accès", "com_ui_attach_error_size": "Limite de taille de fichier dépassée pour le point de terminaison :", @@ -584,6 +656,7 @@ "com_ui_attachment": "Pièce jointe", "com_ui_auth_type": "Type d'auth", "com_ui_auth_url": "Adresse URL d'authentification", + "com_ui_authenticate": "Authentifier", "com_ui_authentication": "Authentification", "com_ui_authentication_type": "Type d'authentification", "com_ui_auto": "Automatique", @@ -596,6 +669,7 @@ "com_ui_backup_codes": "Codes de sauvegarde", "com_ui_backup_codes_regenerate_error": "Une erreur est survenue lors du renouvellement des codes de sauvegarde", "com_ui_backup_codes_regenerated": "Codes de sauvegarde renouvelé avec succès", + "com_ui_backup_codes_security_info": "Pour des raisons de sécurité, les codes de secours ne s'affichent qu'une seule fois lorsqu'ils sont générés. Veuillez les conserver dans un endroit sûr.", "com_ui_basic": "Simple", "com_ui_basic_auth_header": "En-tête d'autorisation simple", "com_ui_bearer": "Porteur", @@ -652,7 +726,7 @@ "com_ui_copy_to_clipboard": "Copier dans le presse-papier", "com_ui_create": "Créer", "com_ui_create_link": "Créer un lien", - "com_ui_create_memory": "Créer un Souvenir", + "com_ui_create_memory": "Créer une Mémoire", "com_ui_create_prompt": "Créer un prompt", "com_ui_creating_image": "Création de l'image en cours. Cela peut prendre un moment", "com_ui_current": "Actuel", @@ -692,7 +766,7 @@ "com_ui_delete_mcp_confirm": "Êtes-vous sûr de vouloir supprimer ce serveur MCP ?", "com_ui_delete_mcp_error": "Suppression de serveur MCP échouée", "com_ui_delete_mcp_success": "Suppression de serveur MCP réussie", - "com_ui_delete_memory": "Supprimer les Souvenirs", + "com_ui_delete_memory": "Supprimer les données mémorisées ", "com_ui_delete_prompt": "Supprimer le Prompt?", "com_ui_delete_shared_link": "Supprimer le lien partagé ?", "com_ui_delete_tool": "Supprimer l'outil", @@ -709,7 +783,7 @@ "com_ui_download_backup": "Télécharger les codes de sauvegarde", "com_ui_download_backup_tooltip": "Avant de continuer, téléchargez vos codes de sauvegarde. Vous en aurez besoin pour récupérer un accès si vous perdez votre appareil authenticator", "com_ui_download_error": "Erreur lors du téléchargement du fichier. Le fichier a peut-être été supprimé.", - "com_ui_drag_drop": "Glisser et déposer", + "com_ui_drag_drop": "Déposer des fichiers ici afin de les ajouter à la conversation", "com_ui_dropdown_variables": "Variables déroulantes :", "com_ui_dropdown_variables_info": "Créez des menus déroulants personnalisés pour vos prompts : `{{nom_variable:option1|option2|option3}}`", "com_ui_duplicate": "Dupliquer", @@ -719,7 +793,7 @@ "com_ui_edit": "Modifier", "com_ui_edit_editing_image": "Edition de l'image", "com_ui_edit_mcp_server": "Editer le serveur MCP", - "com_ui_edit_memory": "Editer les Souvenirs", + "com_ui_edit_memory": "Editer la Mémoire", "com_ui_empty_category": "-", "com_ui_endpoint": "Point de terminaison", "com_ui_endpoint_menu": "Menu des points de terminaison LLM", @@ -794,7 +868,7 @@ "com_ui_go_back": "Revenir en arrière", "com_ui_go_to_conversation": "Aller à la conversation", "com_ui_good_afternoon": "Bon après-midi", - "com_ui_good_evening": "Bonne soirée", + "com_ui_good_evening": "Bonsoir", "com_ui_good_morning": "Bonjour", "com_ui_happy_birthday": "C'est mon premier anniversaire !", "com_ui_hide_image_details": "Cacher les informations de l'images", @@ -817,7 +891,7 @@ "com_ui_input": "Entrée", "com_ui_instructions": "Instructions", "com_ui_key": "Clé", - "com_ui_late_night": "Bonne nocturne", + "com_ui_late_night": "Bonne fin de nuit", "com_ui_latest_footer": "Chaque IA pour tout le monde.", "com_ui_latest_production_version": "Dernière version de production", "com_ui_latest_version": "Dernière version", @@ -829,28 +903,32 @@ "com_ui_logo": "Logo {{0}}", "com_ui_low": "Faible", "com_ui_manage": "Gérer", + "com_ui_marketplace": "Marketplace", + "com_ui_marketplace_allow_use": "Autoriser l'utilisation de la Marketplace", "com_ui_max_tags": "Le nombre maximum autorisé est {{0}}, en utilisant les dernières valeurs.", "com_ui_mcp_enter_var": "Saisissez la valeur de {{0}}", "com_ui_mcp_server_not_found": "Le serveur n'a pas été trouvé.", "com_ui_mcp_servers": "Serveurs MCP", "com_ui_mcp_url": "Adresse URL du serveur MCP", "com_ui_medium": "Modéré", - "com_ui_memories": "Souvenirs", + "com_ui_memories": "Mémoires", "com_ui_memories_allow_create": "Autoriser la création de Souvenirs", "com_ui_memories_allow_opt_out": "Autoriser les utilisateurs à désactiver les Souvenirs", - "com_ui_memories_allow_read": "Autoriser la lecture des Souvenirs", + "com_ui_memories_allow_read": "Autoriser la lecture de la Mémoire", "com_ui_memories_allow_update": "Autoriser la mise à jour des Souvenirs", "com_ui_memories_allow_use": "Autoriser l'utilisation des Souvenirs", "com_ui_memories_filter": "Filtrer les Souvenirs", - "com_ui_memory": "Souvenir", - "com_ui_memory_created": "Souvenir créé avec succès", - "com_ui_memory_deleted": "Souvenir supprimé", - "com_ui_memory_deleted_items": "Souvenirs supprimés", - "com_ui_memory_key_exists": "Un Souvenir existe déjà avec cette clé. Veuillez utiliser une autre clé.", - "com_ui_memory_updated": "Actualiser le Souvenir enregistré", + "com_ui_memory": "Mémoire", + "com_ui_memory_created": "Mémoire créée avec succès", + "com_ui_memory_deleted": "Mémoire supprimée", + "com_ui_memory_deleted_items": "Mémoires supprimées", + "com_ui_memory_key_exists": "Une Mémoire existe déjà avec cette clé. Veuillez utiliser une autre clé.", + "com_ui_memory_storage_full": "Stockage memory plein", + "com_ui_memory_updated": "Actualiser la Mémoire", "com_ui_memory_updated_items": "Souvenirs enregistrés", "com_ui_mention": "Mentionnez un point de terminaison, un assistant ou un préréglage pour basculer rapidement vers celui-ci", "com_ui_min_tags": "Impossible de supprimer plus de valeurs, un minimum de {{0}} est requis.", + "com_ui_minimal": "Minimal", "com_ui_misc": "Divers", "com_ui_model": "Modèle", "com_ui_model_parameters": "Paramètres du modèle", @@ -863,6 +941,7 @@ "com_ui_next": "Suivant", "com_ui_no": "Non", "com_ui_no_bookmarks": "Il semble que vous n'ayez pas encore de favoris. Cliquez sur une discussion pour en ajouter un", + "com_ui_no_categories": "Aucune catégorie disponible", "com_ui_no_category": "Aucune catégorie", "com_ui_no_personalization_available": "Aucune personnalisation disponible", "com_ui_no_read_access": "Vous n'avez pas l'authorisation de voir les Souvenirs.", @@ -982,6 +1061,9 @@ "com_ui_stop": "Arrêt ", "com_ui_storage": "Stockage", "com_ui_submit": "Soumettre", + "com_ui_support_contact_email": "Email", + "com_ui_support_contact_email_invalid": "Veuillez entrer un email valide", + "com_ui_support_contact_name": "Nom", "com_ui_teach_or_explain": "Apprendre", "com_ui_temporary": "Message éphémère", "com_ui_terms_and_conditions": "Conditions d'utilisation", @@ -1025,13 +1107,14 @@ "com_ui_use_memory": "Utiliser le Souvenir", "com_ui_use_micrphone": "Utiliser le microphone", "com_ui_used": "Déjà utilisé", + "com_ui_user": "Utilisateur", "com_ui_value": "Valeur", "com_ui_variables": "Variables", "com_ui_variables_info": "Utilisez des doubles accolades dans votre texte pour créer des variables, par exemple {{exemple de variable}}, à remplir ultérieurement lors de l'utilisation du prompt.", "com_ui_verify": "Vérifier", "com_ui_version_var": "Version {{0}}", "com_ui_versions": "Versions", - "com_ui_view_memory": "Voir le Souvenir", + "com_ui_view_memory": "Voir la Mémoire", "com_ui_view_source": "Voir le message d'origine", "com_ui_web_search": "Recherche web", "com_ui_web_search_cohere_key": "Entrez la clé API de Cohere", diff --git a/client/src/locales/he/translation.json b/client/src/locales/he/translation.json index 104fd4819a..6425f444ad 100644 --- a/client/src/locales/he/translation.json +++ b/client/src/locales/he/translation.json @@ -24,6 +24,7 @@ "com_agents_category_sales_description": "סוכנים המתמקדים בתהליכי מכירה וקשרי לקוחות", "com_agents_category_tab_label": "{{category}} קטגוריות {{position}} מתוך {{total}}", "com_agents_category_tabs_label": "קטגוריות סוכנים", + "com_agents_chat_with": "צ׳אט עם {{name}}", "com_agents_clear_search": "נקה חיפוש", "com_agents_code_interpreter": "כאשר מופעל, מאפשר לסוכן שלך למנף את ה-API של מפענח הקוד כדי להריץ את הקוד שנוצר, כולל עיבוד קבצים, בצורה מאובטחת. דורש מפתח API חוקי.", "com_agents_code_interpreter_title": "מפענח קוד API", @@ -57,7 +58,9 @@ "com_agents_error_timeout_suggestion": "אנא בדוק את חיבור האינטרנט שלך ונסה שוב", "com_agents_error_timeout_title": "זמן התפוגה של החיבור", "com_agents_error_title": "משהו השתבש", + "com_agents_file_context_description": "קבצים שהועלו כ\"הקשר\" (Context) מעובדים כטקסט כדי להשלים את ההוראות של הסוכן. אם OCR זמין, או אם הוא הוגדר עבור סוג הקובץ שהועלה, התהליך משמש לחילוץ טקסט. אידיאלי עבור מסמכים, תמונות עם טקסט, או קבצי PDF שבהם נדרש תוכן הטקסט המלא של הקובץ.", "com_agents_file_context_disabled": "יש ליצור סוכן לפני שמעלים קבצים עבור הקשר קבצים", + "com_agents_file_context_label": "קובץ הקשר", "com_agents_file_search_disabled": "יש ליצור את הסוכן לפני העלאת קבצים לחיפוש", "com_agents_file_search_info": "כאשר הסוכן מופעל הוא יקבל מידע על שמות הקבצים המפורטים להלן, כדי שהוא יוכל לאחזר את הקשר רלוונטי.", "com_agents_grid_announcement": "מציג {{count}} סוכנים מהקטגוריה {{category}}", @@ -99,6 +102,7 @@ "com_assistants_actions_disabled": "עליך ליצור סייען לפני הוספת פעולות.", "com_assistants_actions_info": "אפשר לסייען לאחזר מידע או לבצע פעולות באמצעות API", "com_assistants_add_actions": "הוסף פעולות", + "com_assistants_add_mcp_server_tools": "הוסף כלים משרתי MCP", "com_assistants_add_tools": "הוסף כלים", "com_assistants_allow_sites_you_trust": "אפשר רק אתרים שאתה סומך עליהם.", "com_assistants_append_date": "הוסף תאריך ושעה נוכחיים", @@ -357,7 +361,6 @@ "com_error_files_dupe": "זוהה קובץ כפול", "com_error_files_empty": "אין אפשרות לקבצים ריקים", "com_error_files_process": "אירעה שגיאה במהלך עיבוד הקובץ.", - "com_error_files_unsupported_capability": "לא הופעלו התכונות התומכות בסוג קובץ זה.", "com_error_files_upload": "אירעה שגיאה בעת העלאת הקובץ", "com_error_files_upload_canceled": "בקשת העלאת הקובץ בוטלה. הערה: ייתכן שהעלאת הקובץ עדיין בעיבוד ותצטרך למחוק אותו בצורה ידנית.", "com_error_files_validation": "אירעה שגיאה במהלך אימות הקובץ.", @@ -403,7 +406,6 @@ "com_nav_auto_scroll": "בפתיחת צ׳אט גלול אוטומטית להודעה האחרונה", "com_nav_auto_send_prompts": "שליחת הנחיות (פרומפטים) אוטומטית", "com_nav_auto_send_text": "טקסט לשליחה אוטומטית", - "com_nav_auto_send_text_disabled": "הגדר -1 כדי להשבית", "com_nav_auto_transcribe_audio": "תמלול אוטומטי של אודיו", "com_nav_automatic_playback": "הפעלה אוטומטית של ההודעה האחרונה", "com_nav_balance": "לְאַזֵן", @@ -511,8 +513,11 @@ "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 בהודעות (עשוי להשפיע על הביצועים)", @@ -560,11 +565,12 @@ "com_nav_text_to_speech": "טקסט לדיבור", "com_nav_theme": "ערכת נושא (בהיר/כהה)", "com_nav_theme_dark": "כהה", - "com_nav_theme_light": "אור", + "com_nav_theme_light": "בהיר", "com_nav_theme_system": "מערכת", "com_nav_tool_dialog": "כלי סייען", "com_nav_tool_dialog_agents": "כלי סוכנים", "com_nav_tool_dialog_description": "יש לשמור את האסיסטנט כדי להמשיך בבחירת הכלים.", + "com_nav_tool_dialog_mcp_server_tools": "כלי שרתי MCP", "com_nav_tool_remove": "הסר", "com_nav_tool_search": "כלי חיפוש", "com_nav_user": "משתמש", @@ -748,6 +754,7 @@ "com_ui_complete_setup": "ההגדרה הושלמה", "com_ui_concise": "תמציתי", "com_ui_configure_mcp_variables_for": "הגדרת משתנים עבור {{0}}", + "com_ui_confirm": "אישור", "com_ui_confirm_action": "אשר פעולה", "com_ui_confirm_admin_use_change": "שינוי הגדרה זו יחסום גישה למנהלים, כולל אותך. האם אתה בטוח שברצונך להמשיך?", "com_ui_confirm_change": "אשר את השינוי", @@ -812,6 +819,7 @@ "com_ui_delete_success": "נמחק בהצלחה", "com_ui_delete_tool": "מחק כלי", "com_ui_delete_tool_confirm": "האת אתה בטוח שאתה רוצה למחוק את הכלי הזה?", + "com_ui_delete_tool_save_reminder": "הכלי הוסר. שמור את הסוכן כדי להחיל את השינויים", "com_ui_deleted": "נמחק", "com_ui_deleting_file": "מוחק קובץ...", "com_ui_descending": "תיאור", @@ -825,7 +833,7 @@ "com_ui_download_backup": "הורד קודי גיבוי", "com_ui_download_backup_tooltip": "לפני שתמשיך, הורד את קודי הגיבוי שלך. תזדקק להם כדי לשחזר גישה במקרה שתאבד את מכשיר האימות שלך", "com_ui_download_error": "וזה: שגיאה בהורדת הקובץ. ייתכן שהקובץ נמחק", - "com_ui_drag_drop": "השדה חייב להכיל תוכן, הוא אינו יכול להישאר ריק", + "com_ui_drag_drop": "גרור קובץ לכאן כדי להוסיף אותו לשיחה", "com_ui_dropdown_variables": "רשימה נפתחת של משתנים", "com_ui_dropdown_variables_info": "צור תפריטי רשימה נפתחת מותאמים אישית עבור ההנחיות שלך:\n{{variable_name:option1|option2|option3}}", "com_ui_duplicate": "שכפל", @@ -871,6 +879,8 @@ "com_ui_field_max_length": "{{field}} חייב להיות קצר מ-{{length}} תווים", "com_ui_field_required": "שדה זה נדרש", "com_ui_file_size": "גודל הקובץ", + "com_ui_file_token_limit": "מגבלת טוקנים לקובץ", + "com_ui_file_token_limit_desc": "הגדר מגבלת טוקנים מקסימלית לעיבוד קבצים כדי לשלוט בעלויות ובשימוש במשאבים", "com_ui_files": "קבצים", "com_ui_filter_prompts": "סינון הנחיות (פרומפטים)", "com_ui_filter_prompts_name": "סינון הנחיות (פרומפטים) לפי שם", @@ -910,8 +920,8 @@ "com_ui_go_back": "חזור", "com_ui_go_to_conversation": "חזור לצ'אט", "com_ui_good_afternoon": "צהריים טובים", - "com_ui_good_evening": "ערב ", - "com_ui_good_morning": "ערב טוב", + "com_ui_good_evening": "ערב טוב", + "com_ui_good_morning": "בוקר טוב", "com_ui_group": "קבוצה", "com_ui_happy_birthday": "זה יום ההולדת הראשון שלי!", "com_ui_hide_image_details": "הסתר פרטי תמונה", @@ -931,6 +941,7 @@ "com_ui_import_conversation_info": "ייבא שיחות מקובץ JSON", "com_ui_import_conversation_success": "השיחות יובאו בהצלחה", "com_ui_include_shadcnui": "יש לכלול הוראות לשימוש ברכיבי ממשק המשתמש של shadcn/ui", + "com_ui_initializing": "מאתחל...", "com_ui_input": "קלט", "com_ui_instructions": "הוראות", "com_ui_key": "מפתח", @@ -950,7 +961,10 @@ "com_ui_marketplace_allow_use": "אפשר שימוש במרכז הסוכנים", "com_ui_max_tags": "המספר המקסימלי המותר על פי הערכים העדכניים הוא {{0}}.", "com_ui_mcp_authenticated_success": "{{0}} שרתי MCP אומתו בהצלחה", + "com_ui_mcp_configure_server": "הגדר את {{0}}", + "com_ui_mcp_configure_server_description": "הגדר משתנים מותאמים אישית עבור {{0}}", "com_ui_mcp_enter_var": "הזן ערך עבור {{0}}", + "com_ui_mcp_init_failed": "אתחול שרת MCP נכשל", "com_ui_mcp_initialize": "אתחול", "com_ui_mcp_initialized_success": "{{0}} שרתי MCP אותחלו בהצלחה", "com_ui_mcp_oauth_cancelled": "התחברות באמצעות OAuth בוטלה עבור {{0}}", @@ -998,6 +1012,7 @@ "com_ui_no_category": "אין קטגוריה", "com_ui_no_changes": "לא בוצע שום שינוי", "com_ui_no_individual_access": "אין גישה לסוכן זה למשתמשים או לקבוצות בודדות", + "com_ui_no_memories": "אין זיכרונות. ניתן ליצור אותם ידנית או לבקש מה-AI לזכור משהו", "com_ui_no_personalization_available": "אין אפשרויות התאמה אישית זמינות כרגע", "com_ui_no_read_access": "אין לך הרשאה לצפות בזיכרונות", "com_ui_no_results_found": "לא נמצאו תוצאות", @@ -1014,6 +1029,7 @@ "com_ui_oauth_error_missing_code": "פרמטר המצב (state) חסר. אנא נסה שוב.", "com_ui_oauth_error_missing_state": "פרמטר המצב (state) חסר. אנא נסה שוב.", "com_ui_oauth_error_title": "האימות נכשל", + "com_ui_oauth_revoke": "בטל", "com_ui_oauth_success_description": "האימות בוצע בהצלחה. חלון זה ייסגר בעוד", "com_ui_oauth_success_title": "האימות בוצע בהצלחה", "com_ui_of": "של", @@ -1084,6 +1100,7 @@ "com_ui_role_owner_desc": "בעל שליטה מלאה על הסוכן כולל שיתוף", "com_ui_role_select": "תפקיד", "com_ui_role_viewer": "צופה", + "com_ui_role_viewer_desc": "אפשר לצפות ולהשתמש בסוכן אך לא לשנות אותו", "com_ui_roleplay": "משחק תפקידים", "com_ui_run_code": "הרץ קוד", "com_ui_run_code_error": "אירעה שגיאה בהרצת הקוד", @@ -1096,6 +1113,9 @@ "com_ui_schema": "סכמה", "com_ui_scope": "תחום", "com_ui_search": "חיפוש", + "com_ui_search_above_to_add": "חפש למעלה כדי להוסיף משתמשים או קבוצות", + "com_ui_search_above_to_add_all": "חפש למעלה כדי להוסיף משתמשים, קבוצות או תפקידים", + "com_ui_search_above_to_add_people": "חפש למעלה כדי להוסיף אנשים", "com_ui_search_agent_category": "חיפוש קטגוריות...", "com_ui_search_default_placeholder": "חיפוש לפי שם או דוא\"ל (מינימום 2 תווים)", "com_ui_search_people_placeholder": "חיפוש אנשים או קבוצות לפי שם או דוא\"ל", @@ -1151,6 +1171,8 @@ "com_ui_support_contact_email": "דוא\"ל", "com_ui_support_contact_email_invalid": "אנא הזן כתובת דוא\"ל חוקית", "com_ui_support_contact_name": "שם", + "com_ui_support_contact_name_min_length": "השם חייב להכיל לפחות {{minLength}} תווים", + "com_ui_support_contact_name_placeholder": "שם איש קשר לתמיכה", "com_ui_teach_or_explain": "למידה", "com_ui_temporary": "צ'אט זמני", "com_ui_terms_and_conditions": "תנאים והגבלות", @@ -1168,8 +1190,10 @@ "com_ui_travel": "מסע", "com_ui_trust_app": "אני סומך על האפליקציה הזו", "com_ui_try_adjusting_search": "נסה להתאים את מונחי החיפוש שלך", + "com_ui_ui_resources": "רכיבי UI", "com_ui_unarchive": "הוצא מהארכיון", "com_ui_unarchive_error": "הוצאת השיחה מהארכיון נכשלה", + "com_ui_unavailable": "לא זמין", "com_ui_unknown": "לא ידוע", "com_ui_unset": "בטל הגדרה", "com_ui_untitled": "ללא כותר", @@ -1211,13 +1235,16 @@ "com_ui_web_search_cohere_key": "הכנס מפתח API של Cohere", "com_ui_web_search_firecrawl_url": "כתובת URL של ממשק ה-API של Firecrawl (אופציונלי)", "com_ui_web_search_jina_key": "הזן את מפתח ה-API של Jina", + "com_ui_web_search_jina_url": "כתובת URL של מפתח API של Jina (אופציונלי)", "com_ui_web_search_processing": "עיבוד התוצאות", "com_ui_web_search_provider": "ספק החיפוש", "com_ui_web_search_provider_serper": "ממשק ה-API של Serper", "com_ui_web_search_provider_serper_key": "קבל מפתח API של Serper ", "com_ui_web_search_reading": "קריאת התוצאות", + "com_ui_web_search_reranker": "למד עוד על דירוג מחדש באמצעות Jina", "com_ui_web_search_reranker_cohere_key": "קבל מפתח API של Cohere", "com_ui_web_search_reranker_jina_key": "קבל מפתח API של Jina", + "com_ui_web_search_reranker_jina_url_help": "למד עוד על דירוג מחדש באמצעות Jina", "com_ui_web_search_scraper_firecrawl_key": "קבל מפתח API של Firecrawl", "com_ui_web_searching": "חיפוש ברשת", "com_ui_web_searching_again": "חיפוש נוסף ברשת", diff --git a/client/src/locales/hu/translation.json b/client/src/locales/hu/translation.json index bbe74ecef9..2293e657a2 100644 --- a/client/src/locales/hu/translation.json +++ b/client/src/locales/hu/translation.json @@ -263,7 +263,6 @@ "com_error_files_dupe": "Duplikált fájl észlelve.", "com_error_files_empty": "Üres fájlok nem engedélyezettek.", "com_error_files_process": "Hiba történt a fájl feldolgozása során.", - "com_error_files_unsupported_capability": "Nincs engedélyezve olyan képesség, amely támogatja ezt a fájltípust.", "com_error_files_upload": "Hiba történt a fájl feltöltése során.", "com_error_files_upload_canceled": "A fájlfeltöltési kérés megszakítva. Megjegyzés: a fájlfeltöltés még folyamatban lehet, és manuálisan kell törölni.", "com_error_files_validation": "Hiba történt a fájl ellenőrzése során.", @@ -292,7 +291,6 @@ "com_nav_auto_scroll": "Automatikus görgetés a legutóbbi üzenetre a csevegés megnyitásakor", "com_nav_auto_send_prompts": "Promptok automatikus küldése", "com_nav_auto_send_text": "Szöveg automatikus küldése", - "com_nav_auto_send_text_disabled": "-1 beállítása a letiltáshoz", "com_nav_auto_transcribe_audio": "Audió automatikus átírása", "com_nav_automatic_playback": "Legutóbbi üzenet automatikus lejátszása", "com_nav_balance": "Egyenleg", diff --git a/client/src/locales/it/translation.json b/client/src/locales/it/translation.json index ef934dc950..5f4181a07c 100644 --- a/client/src/locales/it/translation.json +++ b/client/src/locales/it/translation.json @@ -2,29 +2,86 @@ "com_a11y_ai_composing": "L'IA sta ancora componendo", "com_a11y_end": "L'IA ha terminato la sua risposta", "com_a11y_start": "L'IA ha iniziato la sua risposta", + "com_agents_all_category": "Tutti", + "com_agents_all_description": "Sfoglia tutti gli agenti condivisi in tutte le categorie", "com_agents_by_librechat": "da LibreChat", + "com_agents_category_empty": "Non sono stati trovati agenti nella categoria {{category}}", + "com_agents_category_finance": "Finanza", + "com_agents_category_finance_description": "Agenti specializzati in analisi finanziaria, budgeting e contabilità", + "com_agents_category_general": "Generale", + "com_agents_category_general_description": "Agenti di uso generale per compiti e richieste comuni", + "com_agents_category_rd": "Ricerca e sviluppo", + "com_agents_category_rd_description": "Agenti focalizzati sui processi di R&S, sull'innovazione e sulla ricerca tecnica", + "com_agents_category_tabs_label": "Categorie di agenti", + "com_agents_clear_search": "Pulisci ricerca", "com_agents_code_interpreter": "Quando abilitato, permette al tuo agente di utilizzare l'API LibreChat Code Interpreter per eseguire codice generato in modo sicuro, inclusa l'elaborazione dei file. Richiede una chiave API valida.", "com_agents_code_interpreter_title": "API Interprete Codice", + "com_agents_copy_link": "Copia Link", "com_agents_create_error": "Si è verificato un errore durante la creazione del tuo agente.", + "com_agents_created_by": "da", "com_agents_description_placeholder": "Opzionale: Descrivi qui il tuo Agente", + "com_agents_empty_state_heading": "Nessun agente trovato", "com_agents_enable_file_search": "Abilita Ricerca File", + "com_agents_error_bad_request_message": "Non è stato possibile elaborare la richiesta.", + "com_agents_error_category_title": "Errore Categoria", + "com_agents_error_generic": "Abbiamo riscontrato un problema durante il caricamento del contenuto.", + "com_agents_error_invalid_request": "Richiesta non valida", + "com_agents_error_loading": "Errore nel caricamento degli agenti", + "com_agents_error_network_message": "Impossibile connettersi al server.", + "com_agents_error_network_suggestion": "Controllare la connessione a Internet e riprovare.", + "com_agents_error_network_title": "Problema di connessione", + "com_agents_error_not_found_message": "Il contenuto richiesto non è stato trovato.", + "com_agents_error_not_found_title": "Non trovato", + "com_agents_error_retry": "Riprova", + "com_agents_error_search_title": "Errore di ricerca", + "com_agents_error_searching": "Errore nella ricerca degli agenti", + "com_agents_error_server_message": "Il server è temporaneamente non disponibile.", + "com_agents_error_server_suggestion": "Riprova tra qualche istante.", + "com_agents_error_timeout_message": "La richiesta ha richiesto troppo tempo per essere completata.", + "com_agents_error_timeout_suggestion": "Controllare la connessione a Internet e riprovare.", + "com_agents_error_timeout_title": "Timeout della connessione", + "com_agents_error_title": "Qualcosa è andato storto", "com_agents_file_context_disabled": "L'agente deve essere creato prima di caricare i file per il Contesto del File.", "com_agents_file_search_disabled": "L'Agente deve essere creato prima di caricare file per la Ricerca File.", "com_agents_file_search_info": "Quando abilitato, l'agente verrà informato dei nomi esatti dei file elencati di seguito, permettendogli di recuperare il contesto pertinente da questi file.", + "com_agents_grid_announcement": " Mostrando {{count}} agenti nella categoria {{category}}", "com_agents_instructions_placeholder": "Le istruzioni di sistema utilizzate dall'agente", + "com_agents_link_copied": "Link copiato", + "com_agents_link_copy_failed": "Impossibile copiare il link", + "com_agents_load_more_label": "Caricare altri agenti dalla categoria {{category}}", + "com_agents_loading": "Caricamento...", + "com_agents_marketplace": "Mercato degli agenti", + "com_agents_marketplace_subtitle": "Scoprite e utilizzate potenti agenti AI per migliorare i vostri flussi di lavoro e la vostra produttività.", "com_agents_mcp_description_placeholder": "Spiegate cosa fa in poche parole", "com_agents_mcp_icon_size": "Dimensione minima 128 x 128 px", + "com_agents_mcp_trust_subtext": "I connettori personalizzati non sono verificati da LibreChat", + "com_agents_mcps_disabled": "È necessario creare un agente prima di aggiungere gli MCP.", + "com_agents_missing_name": "Inserire un nome prima di creare un agente.", "com_agents_missing_provider_model": "Seleziona un provider e un modello prima di creare un agente.", "com_agents_name_placeholder": "Opzionale: Il nome dell'agente", "com_agents_no_access": "Non hai l'autorizzazione per modificare questo agente.", + "com_agents_no_agent_id_error": "Non è stato trovato alcun ID agente. Assicurarsi che l'agente sia stato prima creato.", + "com_agents_no_more_results": "Avete raggiunto la fine dei risultati", "com_agents_not_available": "Agente Non Disponibile", + "com_agents_recommended": "I nostri agenti consigliati", + "com_agents_results_for": "Risultati per '{{query}}'", + "com_agents_search_aria": "Cerca agenti", + "com_agents_search_empty_heading": "Nessun risultato di ricerca", + "com_agents_search_info": "Se abilitato, consente all'agente di cercare informazioni aggiornate sul web. Richiede una chiave API valida.", + "com_agents_search_instructions": "Digitare per cercare gli agenti in base al nome o alla descrizione", "com_agents_search_name": "Cerca agenti per nome", + "com_agents_search_no_results": "Nessun agente trovato per \"{{query}}\"", + "com_agents_search_placeholder": "Ricerca agenti...", + "com_agents_see_more": "Vedi di più", + "com_agents_start_chat": "Avvia la chat", + "com_agents_top_picks": "Scelte principali", "com_agents_update_error": "Si è verificato un errore durante l'aggiornamento del tuo agente.", "com_assistants_action_attempt": "L'assistente vuole parlare con {{0}}", "com_assistants_actions": "Azioni", "com_assistants_actions_disabled": "Devi prima creare un assistente prima di aggiungere azioni.", "com_assistants_actions_info": "Permetti al tuo Assistente di recuperare informazioni o eseguire azioni tramite API", "com_assistants_add_actions": "Aggiungi Azioni", + "com_assistants_add_mcp_server_tools": "Aggiungere gli strumenti del server MCP", "com_assistants_add_tools": "Aggiungi Strumenti", "com_assistants_allow_sites_you_trust": "Consenti solo i siti di cui ti fidati.", "com_assistants_append_date": "Aggiungi Data e Ora Attuali", @@ -95,6 +152,7 @@ "com_auth_error_login_rl": "Troppi tentativi di accesso in un breve periodo di tempo. Riprova più tardi.", "com_auth_error_login_server": "Si è verificato un errore interno del server. Attendi qualche istante e riprova.", "com_auth_error_login_unverified": "Il tuo account non è stato verificato. Controlla la tua email per il link di verifica.", + "com_auth_error_oauth_failed": "Autenticazione fallita. Controllare il metodo di accesso e riprovare.", "com_auth_facebook_login": "Continua con Facebook", "com_auth_full_name": "Nome completo", "com_auth_github_login": "Continua con Github", @@ -119,6 +177,7 @@ "com_auth_reset_password_if_email_exists": "Se esiste un account associato a questa email, ti abbiamo inviato un'email con le istruzioni per reimpostare la password. Ricordati di controllare anche la cartella spam.", "com_auth_reset_password_link_sent": "Email inviata", "com_auth_reset_password_success": "Reset della password avvenuto con successo", + "com_auth_saml_login": "Continuare con SAML", "com_auth_sign_in": "Accedi", "com_auth_sign_up": "Registrati", "com_auth_submit_registration": "Invia registrazione", @@ -130,6 +189,8 @@ "com_auth_username_min_length": "Il nome utente deve essere di almeno 2 caratteri", "com_auth_verify_your_identity": "Verifica la propria identità", "com_auth_welcome_back": "Ben tornato", + "com_citation_more_details": "Ulteriori dettagli su {{label}}", + "com_citation_source": "Fonte", "com_click_to_download": "clicca qui per scaricare", "com_download_expired": "download scaduto", "com_download_expires": "(clicca qui per scaricare - scade il {{0}})", @@ -145,6 +206,7 @@ "com_endpoint_anthropic_thinking_budget": "Determina il numero massimo di token che Claude può utilizzare per il suo processo di ragionamento interno. Un budget maggiore può migliorare la qualità della risposta, consentendo un'analisi più approfondita di problemi complessi, anche se Claude potrebbe non utilizzare l'intero budget assegnato, soprattutto con intervalli superiori a 32K. Questa impostazione deve essere inferiore a \"Massimo Output Token\".", "com_endpoint_anthropic_topk": "Top-k cambia il modo in cui il modello seleziona i token per l'output. Un top-k di 1 significa che il token selezionato è il più probabile tra tutti i token nel vocabolario del modello (anche chiamato greedy decoding), mentre un top-k di 3 significa che il prossimo token è selezionato tra i 3 più probabili (usando la temperatura).", "com_endpoint_anthropic_topp": "Top-p cambia il modo in cui il modello seleziona i token per l'output. I token vengono selezionati dai più probabili K (vedi parametro topK) ai meno probabili fino a quando la somma delle loro probabilità eguaglia il valore top-p.", + "com_endpoint_anthropic_use_web_search": "Abilitare la funzionalità di ricerca sul web utilizzando le capacità di ricerca integrate di Anthropic. In questo modo il modello può cercare informazioni aggiornate sul web e fornire risposte più precise e attuali.", "com_endpoint_assistant": "Assistente", "com_endpoint_assistant_model": "Modello Assistente", "com_endpoint_assistant_placeholder": "Seleziona un Assistente dal Pannello laterale destro", @@ -179,6 +241,11 @@ "com_endpoint_default_blank": "predefinito: vuoto", "com_endpoint_default_empty": "predefinito: vuoto", "com_endpoint_default_with_num": "predefinito: {{0}}", + "com_endpoint_deprecated": "Deprecato", + "com_endpoint_deprecated_info": "Questo endpoint è deprecato e potrebbe essere rimosso nelle versioni future; utilizzare invece l'endpoint degli Agenti", + "com_endpoint_deprecated_info_a11y": "L'endpoint del plugin è deprecato e potrebbe essere rimosso nelle versioni future; utilizzare invece l'endpoint degli agenti", + "com_endpoint_disable_streaming": "Disattivare le risposte in streaming e ricevere la risposta completa in una sola volta. Utile per modelli come o3 che richiedono la verifica dell'organizzazione per lo streaming.", + "com_endpoint_disable_streaming_label": "Disattiva lo streaming", "com_endpoint_examples": "Preimpostazioni", "com_endpoint_export": "Esporta", "com_endpoint_export_share": "Esporta/Condividi", @@ -187,6 +254,8 @@ "com_endpoint_google_custom_name_placeholder": "Imposta un nome personalizzato per Google", "com_endpoint_google_maxoutputtokens": "Numero massimo di token che possono essere generati nella risposta. Specifica un valore più basso per risposte più brevi e un valore più alto per risposte più lunghe.", "com_endpoint_google_temp": "Valori più alti = più casualità, mentre valori più bassi = più focalizzati e deterministici. Consigliamo di modificare questo o Top P ma non entrambi.", + "com_endpoint_google_thinking": "Abilita o disabilita il ragionamento. Questa impostazione è supportata solo da alcuni modelli (serie 2.5). Per i modelli più vecchi, questa impostazione potrebbe non avere alcun effetto.", + "com_endpoint_google_thinking_budget": "Indica il numero di token di riflessione utilizzati dal modello. La quantità effettiva può essere superiore o inferiore a questo valore, a seconda della richiesta.\n\nQuesta impostazione è supportata solo da alcuni modelli (serie 2.5). Gemini 2.5 Pro supporta 128-32.768 token. Gemini 2.5 Flash supporta 0-24.576 token. Gemini 2.5 Flash Lite supporta 512-24.576 token.\n\nLasciare vuoto o impostare \"-1\" per lasciare che il modello decida automaticamente quando e quanto pensare. Per impostazione predefinita, Gemini 2.5 Flash Lite non pensa.", "com_endpoint_google_topk": "Top-k cambia il modo in cui il modello seleziona i token per l'output. Un top-k di 1 significa che il token selezionato è il più probabile tra tutti i token nel vocabolario del modello (anche chiamato greedy decoding), mentre un top-k di 3 significa che il prossimo token è selezionato tra i 3 più probabili (usando la temperatura).", "com_endpoint_google_topp": "Top-p cambia il modo in cui il modello seleziona i token per l'output. I token vengono selezionati dai più probabili K (vedi parametro topK) ai meno probabili fino a quando la somma delle loro probabilità eguaglia il valore top-p.", "com_endpoint_instructions_assistants": "Sovrascrivi istruzioni", @@ -206,11 +275,13 @@ "com_endpoint_openai_pres": "Numero compreso tra -2.0 e 2.0. Valori positivi penalizzano i nuovi token in base a se compaiono nel testo fino a quel momento, aumentando la probabilità del modello di parlare di nuovi argomenti.", "com_endpoint_openai_prompt_prefix_placeholder": "Imposta istruzioni personalizzate da includere nel Messaggio di Sistema. Predefinito: nessuno", "com_endpoint_openai_reasoning_effort": "solo per modelli o1: limita lo sforzo sul ragionamento per i modelli di ragionamento. Ridurre lo sforzo di ragionamento può portare a risposte più veloci e a un minor numero di token utilizzati per il ragionamento in una risposta", + "com_endpoint_openai_reasoning_summary": "Solo per Responses API: Un riassunto del ragionamento eseguito dal modello. Può essere utile per il debug e la comprensione del processo di ragionamento del modello. Impostare su nessuno, auto, conciso o dettagliato.", "com_endpoint_openai_resend": "Invia nuovamente tutte le immagini allegate in precedenza. Nota: questo può aumentare significativamente il costo dei token e potresti incontrare errori con molti allegati di immagini.", "com_endpoint_openai_resend_files": "Invia nuovamente tutti i file allegati in precedenza. Nota: questo aumenterà il costo dei token e potresti incontrare errori con molti allegati.", "com_endpoint_openai_stop": "Fino a 4 sequenze in cui l'API smetterà di generare ulteriori token.", "com_endpoint_openai_temp": "Valori più alti = più casualità, mentre valori più bassi = più focalizzati e deterministici. Consigliamo di modificare questo o Top P ma non entrambi.", "com_endpoint_openai_topp": "Un'alternativa al campionamento con temperatura, chiamata nucleus sampling, in cui il modello considera i risultati dei token con probabilità di massa top_p. Quindi 0,1 significa che vengono considerati solo i token che compongono la massa di probabilità superiore al 10%. Consigliamo di modificare questo o la temperatura ma non entrambi.", + "com_endpoint_openai_use_responses_api": "Utilizzare il Responses API invece di Chat Completions, che include funzioni estese di OpenAI. Richiesto per o1-pro, o3-pro e per abilitare i riassunti dei ragionamenti.", "com_endpoint_output": "Output", "com_endpoint_plug_image_detail": "Dettaglio immagine", "com_endpoint_plug_resend_files": "Reinvia file", @@ -259,7 +330,6 @@ "com_error_files_dupe": "File duplicato rilevato.", "com_error_files_empty": "I file vuoti non sono consentiti.", "com_error_files_process": "Si è verificato un errore durante l'elaborazione del file.", - "com_error_files_unsupported_capability": "Nessuna funzionalità abilitata che supporti questo tipo di file.", "com_error_files_upload": "Si è verificato un errore durante il caricamento del file.", "com_error_files_upload_canceled": "La richiesta di caricamento del file è stata annullata. Nota: il caricamento del file potrebbe essere ancora in corso e potrebbe essere necessario eliminarlo manualmente.", "com_error_files_validation": "Si è verificato un errore durante la validazione del file.", @@ -286,7 +356,6 @@ "com_nav_auto_scroll": "Scorri automaticamente ai nuovi messaggi all'apertura", "com_nav_auto_send_prompts": "Invio automatico dei prompt", "com_nav_auto_send_text": "Invio automatico del testo", - "com_nav_auto_send_text_disabled": "imposta -1 per disabilitare", "com_nav_auto_transcribe_audio": "Trascrivi audio automaticamente", "com_nav_automatic_playback": "Riproduzione automatica ultimo messaggio", "com_nav_balance": "Bilancio", diff --git a/client/src/locales/ja/translation.json b/client/src/locales/ja/translation.json index 0741a749a5..33f0d7d768 100644 --- a/client/src/locales/ja/translation.json +++ b/client/src/locales/ja/translation.json @@ -291,7 +291,6 @@ "com_error_files_dupe": "重複したファイルが検出されました。", "com_error_files_empty": "空のファイルはアップロードできません", "com_error_files_process": "ファイルの処理中にエラーが発生しました。", - "com_error_files_unsupported_capability": "このファイル形式に対応する機能が有効になっていません", "com_error_files_upload": "ファイルのアップロード中にエラーが発生しました。", "com_error_files_upload_canceled": "ファイルのアップロードがキャンセルされました。注意:アップロード処理が継続している可能性があるため、手動でファイルを削除する必要があるかもしれません。", "com_error_files_validation": "ファイルの検証中にエラーが発生しました。", @@ -326,7 +325,6 @@ "com_nav_auto_scroll": "チャットを開いたときに最新まで自動でスクロール", "com_nav_auto_send_prompts": "プロンプト自動送信", "com_nav_auto_send_text": "テキストを自動送信", - "com_nav_auto_send_text_disabled": "無効にするには-1を設定", "com_nav_auto_transcribe_audio": "オーディオを自動で書き起こす", "com_nav_automatic_playback": "最新メッセージを自動再生", "com_nav_balance": "バランス", diff --git a/client/src/locales/ko/translation.json b/client/src/locales/ko/translation.json index 835306cbc9..d99a4d09f6 100644 --- a/client/src/locales/ko/translation.json +++ b/client/src/locales/ko/translation.json @@ -287,7 +287,6 @@ "com_error_files_dupe": "중복된 파일이 감지되었습니다", "com_error_files_empty": "빈 파일은 허용되지 않습니다", "com_error_files_process": "파일 처리 중 오류가 발생했습니다", - "com_error_files_unsupported_capability": "이 파일 형식을 지원하는 기능이 활성화되어 있지 않습니다", "com_error_files_upload": "파일 업로드 중 오류가 발생했습니다", "com_error_files_upload_canceled": "파일 업로드가 취소되었습니다. 참고: 업로드 처리가 아직 진행 중일 수 있으며 수동으로 삭제해야 할 수 있습니다.", "com_error_files_validation": "파일 유효성 검사 중 오류가 발생했습니다", @@ -319,7 +318,6 @@ "com_nav_auto_scroll": "채팅 열렸을 때 최신 메시지로 자동 스크롤", "com_nav_auto_send_prompts": "프롬프트 자동 전송", "com_nav_auto_send_text": "자동 메시지 전송", - "com_nav_auto_send_text_disabled": "자동 전송 비활성화는 -1로 설정", "com_nav_auto_transcribe_audio": "오디오 자동 변환", "com_nav_automatic_playback": "최신 메시지 자동 재생", "com_nav_balance": "잔고", diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index 8c020c3b75..ac3fe25876 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -1,6 +1,6 @@ { - "chat_direction_left_to_right": "Nav rezultātu", - "chat_direction_right_to_left": "Nav rezultātu", + "chat_direction_left_to_right": "No kreisās uz labo", + "chat_direction_right_to_left": "No labās uz kreiso", "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.", @@ -363,9 +363,9 @@ "com_error_files_dupe": "Atrasts dublikāta fails.", "com_error_files_empty": "Tukši faili nav atļauti.", "com_error_files_process": "Apstrādājot failu, radās kļūda.", - "com_error_files_unsupported_capability": "Šis faila tips nav atbalstīts.", "com_error_files_upload": "Augšupielādējot failu, radās kļūda.", "com_error_files_upload_canceled": "Faila augšupielādes pieprasījums tika atcelts. Piezīme. Iespējams, ka faila augšupielāde joprojām tiek apstrādāta, un tā būs manuāli jādzēš.", + "com_error_files_upload_too_large": "Faili ir pārāk lieli. Lūdzu, augšupielādējiet failu, kas ir mazāks par {{0}} MB", "com_error_files_validation": "Validējot failu, radās kļūda.", "com_error_google_tool_conflict": "Iebūvēto Google rīku lietošana netiek atbalstīta ar ārējiem rīkiem. Lūdzu, atspējojiet vai nu iebūvētos rīkus, vai ārējos rīkus.", "com_error_heic_conversion": "Neizdevās konvertēt HEIC attēlu uz JPEG. Lūdzu, mēģiniet konvertēt attēlu manuāli vai izmantojiet citu formātu.", @@ -409,7 +409,6 @@ "com_nav_auto_scroll": "Automātiski iet uz jaunāko ziņu, atverot sarunu", "com_nav_auto_send_prompts": "Automātiski sūtīt uzvednes", "com_nav_auto_send_text": "Automātiski nosūtīt tekstu", - "com_nav_auto_send_text_disabled": "iestatiet -1, lai atspējotu", "com_nav_auto_transcribe_audio": "Automātiski transkribēt audio", "com_nav_automatic_playback": "Automātiski atskaņot jaunāko ziņu", "com_nav_balance": "Bilance", @@ -562,6 +561,7 @@ "com_nav_setting_balance": "Bilance", "com_nav_setting_chat": "Saruna", "com_nav_setting_data": "Datu kontrole", + "com_nav_setting_delay": "Aizkave (-es)", "com_nav_setting_general": "Vispārīgi", "com_nav_setting_mcp": "MCP iestatījumi", "com_nav_setting_personalization": "Personalizācija", @@ -761,6 +761,7 @@ "com_ui_client_secret": "Klienta noslēpums", "com_ui_close": "Aizvērt", "com_ui_close_menu": "Aizvērt izvēlni", + "com_ui_close_settings": "Aizvērt iestatījumus", "com_ui_close_window": "Aizvērt logu", "com_ui_code": "Kods", "com_ui_collapse_chat": "Sakļaut sarunas logu", @@ -859,6 +860,7 @@ "com_ui_edit_editing_image": "Attēla rediģēšana", "com_ui_edit_mcp_server": "Rediģēt MCP serveri", "com_ui_edit_memory": "Rediģēt atmiņu", + "com_ui_editor_instructions": "Velciet attēlu, lai mainītu tā atrašanās vietu - Izmantojiet tālummaiņas slīdni vai pogas, lai pielāgotu izmēru.", "com_ui_empty_category": "-", "com_ui_endpoint": "Galapunkts", "com_ui_endpoint_menu": "LLM galapunkta izvēlne", @@ -895,6 +897,7 @@ "com_ui_feedback_tag_zero": "Cita problēma", "com_ui_field_max_length": "{{field}} jābūt mazākam par {{length}} rakstzīmēm", "com_ui_field_required": "Šis lauks ir obligāts", + "com_ui_file_input_avatar_label": "Faila ievade avatāram", "com_ui_file_size": "Faila lielums", "com_ui_file_token_limit": "Failu tokenu ierobežojums", "com_ui_file_token_limit_desc": "Iestatiet maksimālo tokenu ierobežojumu failu apstrādei, lai kontrolētu izmaksas un resursu izmantošanu", @@ -957,11 +960,13 @@ "com_ui_import_conversation_file_type_error": "Neatbalstīts importēšanas veids", "com_ui_import_conversation_info": "Sarunu importēšana no JSON faila", "com_ui_import_conversation_success": "Sarunas ir veiksmīgi importētas", + "com_ui_import_conversation_upload_error": "Kļūda augšupielādējot failu. Lūdzu, mēģiniet vēlreiz.", "com_ui_include_shadcnui": "Iekļaujiet shadcn/ui komponentu instrukcijas", "com_ui_initializing": "Inicializē...", "com_ui_input": "Ievade", "com_ui_instructions": "Instrukcijas", "com_ui_key": "Atslēga", + "com_ui_key_required": "Nepieciešama API atslēga", "com_ui_late_night": "Priecīgu vēlu nakti", "com_ui_latest_footer": "Mākslīgais intelekts ikvienam.", "com_ui_latest_production_version": "Jaunākā produkcijas versija", @@ -976,6 +981,7 @@ "com_ui_manage": "Pārvaldīt", "com_ui_marketplace": "Katalogs", "com_ui_marketplace_allow_use": "Atļaut izmantot katalogu", + "com_ui_max_file_size": "PNG, JPG vai JPEG (maks. {{0}})", "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_configure_server": "Konfigurēt {{0}}", @@ -1070,6 +1076,7 @@ "com_ui_privacy_policy": "Privātuma politika", "com_ui_privacy_policy_url": "Privātuma politika web adrese", "com_ui_prompt": "Uzvedne", + "com_ui_prompt_groups": "Uzvedņu grupu saraksts", "com_ui_prompt_name": "Uzvednes nosaukums", "com_ui_prompt_name_required": "Uzvednes nosaukums ir obligāts", "com_ui_prompt_preview_not_shared": "Autors nav atļāvis sadarbību šajā uzvednē.", @@ -1099,6 +1106,8 @@ "com_ui_rename_failed": "Neizdevās pārdēvēt sarunu", "com_ui_rename_prompt": "Pārdēvēt uzvedni", "com_ui_requires_auth": "Nepieciešama autentifikācija", + "com_ui_reset": "Attiestatīt", + "com_ui_reset_adjustments": "Atiestatīt korekcijas", "com_ui_reset_var": "Atiestatīt {{0}}", "com_ui_reset_zoom": "Atiestatīt tālummaiņu", "com_ui_resource": "resurss", @@ -1107,6 +1116,8 @@ "com_ui_revoke_info": "Atcelt visus lietotāja sniegtos lietotāja datus", "com_ui_revoke_key_confirm": "Vai tiešām vēlaties atsaukt šo atslēgu?", "com_ui_revoke_key_endpoint": "Atsaukt atslēgu priekš {{0}}", + "com_ui_revoke_key_error": "Neizdevās atsaukt API atslēgu. Lūdzu, mēģiniet vēlreiz.", + "com_ui_revoke_key_success": "API atslēga veiksmīgi atsaukta", "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", @@ -1120,11 +1131,15 @@ "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_rotate": "Pagriezt", + "com_ui_rotate_90": "Pagriezt par 90 grādiem", "com_ui_run_code": "Palaist kodu", "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_key_error": "Neizdevās saglabāt API atslēgu. Lūdzu, mēģiniet vēlreiz.", + "com_ui_save_key_success": "API atslēga veiksmīgi saglabāta", "com_ui_save_submit": "Saglabāt un nosūtīt", "com_ui_saved": "Saglabāts!", "com_ui_saving": "Saglabā...", @@ -1221,6 +1236,7 @@ "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_avatar_label": "Augšupielādēt avatāra attēlu", "com_ui_upload_code_files": "Augšupielādēt failu 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.", @@ -1232,6 +1248,7 @@ "com_ui_upload_invalid": "Nederīgs augšupielādējamais fails. Attēlam jābūt tādam, kas nepārsniedz ierobežojumu.", "com_ui_upload_invalid_var": "Nederīgs augšupielādējams fails. Attēlam jābūt ne lielākam par {{0}} MB", "com_ui_upload_ocr_text": "Augšupielādēt failu kā tekstu", + "com_ui_upload_provider": "Augšupielādēt pakalpojumu sniedzējam", "com_ui_upload_success": "Fails veiksmīgi augšupielādēts", "com_ui_upload_type": "Izvēlieties augšupielādes veidu", "com_ui_usage": "Izmantošana", @@ -1270,6 +1287,8 @@ "com_ui_web_search_scraper": "Scraper", "com_ui_web_search_scraper_firecrawl": "Firecrawl API", "com_ui_web_search_scraper_firecrawl_key": "Iegūstiet savu Firecrawl API atslēgu", + "com_ui_web_search_scraper_serper": "Serper Scrape API", + "com_ui_web_search_scraper_serper_key": "Iegūst savu Serper API atslēgu", "com_ui_web_search_searxng_api_key": "Ievadiet SearXNG API atslēgu (pēc izvēles)", "com_ui_web_search_searxng_instance_url": "SearXNG Instance URL", "com_ui_web_searching": "Meklēšana tīmeklī", @@ -1279,5 +1298,8 @@ "com_ui_x_selected": "{{0}} atlasīts", "com_ui_yes": "Jā", "com_ui_zoom": "Tālummaiņa", + "com_ui_zoom_in": "Pietuvināt", + "com_ui_zoom_level": "Pietuvināšanas līmenis", + "com_ui_zoom_out": "Attālināt", "com_user_message": "Tu" } diff --git a/client/src/locales/nb/translation.json b/client/src/locales/nb/translation.json index 0674b28c7f..c34347a2df 100644 --- a/client/src/locales/nb/translation.json +++ b/client/src/locales/nb/translation.json @@ -360,7 +360,6 @@ "com_error_files_dupe": "Duplikatfil oppdaget.", "com_error_files_empty": "Tomme filer er ikke tillatt.", "com_error_files_process": "Det oppstod en feil under behandling av filen.", - "com_error_files_unsupported_capability": "Ingen kapabiliteter aktivert som støtter denne filtypen.", "com_error_files_upload": "Det oppstod en feil under opplasting av filen.", "com_error_files_upload_canceled": "Forespørselen om filopplasting ble avbrutt. Merk: Filopplastingen kan fortsatt behandles og må slettes manuelt.", "com_error_files_validation": "Det oppstod en feil under validering av filen.", @@ -406,7 +405,6 @@ "com_nav_auto_scroll": "Rull automatisk til siste melding når samtalen åpnes", "com_nav_auto_send_prompts": "Send prompter automatisk", "com_nav_auto_send_text": "Send tekst automatisk", - "com_nav_auto_send_text_disabled": "sett -1 for å deaktivere", "com_nav_auto_transcribe_audio": "Transkriber lyd automatisk", "com_nav_automatic_playback": "Spill av siste melding automatisk", "com_nav_balance": "Saldo", diff --git a/client/src/locales/pl/translation.json b/client/src/locales/pl/translation.json index 43ae7d9d3c..8c69ea9cab 100644 --- a/client/src/locales/pl/translation.json +++ b/client/src/locales/pl/translation.json @@ -266,7 +266,6 @@ "com_nav_auto_scroll": "Automatyczne przewijanie do najnowszej wiadomości przy otwarciu czatu", "com_nav_auto_send_prompts": "Automatycznie wysyłaj prompty", "com_nav_auto_send_text": "Automatycznie wysyłaj tekst", - "com_nav_auto_send_text_disabled": "ustaw -1 aby wyłączyć", "com_nav_auto_transcribe_audio": "Automatycznie transkrybuj audio", "com_nav_automatic_playback": "Automatyczne odtwarzanie najnowszej wiadomości", "com_nav_balance": "Balansować", diff --git a/client/src/locales/pt-BR/translation.json b/client/src/locales/pt-BR/translation.json index ff5aebad99..e7968fd719 100644 --- a/client/src/locales/pt-BR/translation.json +++ b/client/src/locales/pt-BR/translation.json @@ -4,17 +4,27 @@ "com_a11y_ai_composing": "A IA ainda está compondo.", "com_a11y_end": "A IA terminou de responder.", "com_a11y_start": "A IA começou a responder.", + "com_agents_agent_card_label": "Agente {{name}}. {{description}}", "com_agents_all": "Todos os Agentes", "com_agents_all_category": "Todos", "com_agents_all_description": "Navegar por todos os agentes compartilhados em todas as categorias", "com_agents_by_librechat": "por LibreChat", "com_agents_category_aftersales": "Pós-vendas", + "com_agents_category_aftersales_description": "Agentes especializados em atendimento pós-venda, manutenção e atendimento ao cliente", + "com_agents_category_empty": "Não foram encontrados agentes na categoria {{category}}", "com_agents_category_finance": "Financeiro", + "com_agents_category_finance_description": "Agentes especializados em análise financeira, orçamento e contabilidade", "com_agents_category_general": "Geral", + "com_agents_category_general_description": "Agentes de propósito geral para tarefas comuns e consultas", "com_agents_category_hr": "Recursos Humanos", + "com_agents_category_hr_description": "Agentes especializados em processos de RH, políticas e suporte ao funcionário", "com_agents_category_it": "TI", + "com_agents_category_it_description": "Agentes para suporte em TI, diagnóstico de problemas técnicos e administração de sistemas", "com_agents_category_rd": "Pesquisa e Desenvolvimento", + "com_agents_category_rd_description": "Agentes focados em processos de P&D, inovação e pesquisa técnica", "com_agents_category_sales": "Vendas", + "com_agents_category_sales_description": "Agentes focados em processos de vendas, relações com o cliente", + "com_agents_category_tab_label": "Categoria {{category}}, {{position}} de {{total}}", "com_agents_category_tabs_label": "Categorias de Agentes", "com_agents_clear_search": "Limpar pesquisa", "com_agents_code_interpreter": "Quando ativado, permite que seu agente aproveite a API do interpretador de código LibreChat para executar o código gerado, incluindo o processamento de arquivos, com segurança. Requer uma chave de API válida.", @@ -26,23 +36,37 @@ "com_agents_description_placeholder": "Opcional: Descreva seu Agente aqui", "com_agents_empty_state_heading": "Nenhum agente encontrado", "com_agents_enable_file_search": "Permitir Pesquisa de Ficheiros", + "com_agents_error_bad_request_message": "A solicitação não pôde ser processada.", + "com_agents_error_bad_request_suggestion": "Por favor verifique o conteúdo inserido e tente novamente.", "com_agents_error_category_title": "Erro na categoria", "com_agents_error_generic": "Houve um problema ao carregar o contexto.", + "com_agents_error_invalid_request": "Solicitação inválida", "com_agents_error_loading": "Erro ao carregar agentes", + "com_agents_error_network_message": "Não foi possível conectar-se ao servidor.", + "com_agents_error_network_suggestion": "Consulte a sua conexão com a internet e tente novamente.", "com_agents_error_network_title": "Há um problema de conexão", + "com_agents_error_not_found_message": "O conteúdo solicitado não pôde ser encontrado.", + "com_agents_error_not_found_suggestion": "Tente buscar outras opções ou volte ao marketplace.", + "com_agents_error_not_found_title": "Não encontrado", "com_agents_error_retry": "Tente novamente", "com_agents_error_search_title": "Erro na pesquisa", "com_agents_error_searching": "Erro ao procurar agentes", "com_agents_error_server_message": "O servidor esta temporariamente indisponível", + "com_agents_error_server_suggestion": "Por favor tente novamente em alguns instantes.", "com_agents_error_server_title": "Erro no servidor", + "com_agents_error_suggestion_generic": "Por favor tente atualizar a página ou tente novamente mais tarde.", + "com_agents_error_timeout_message": "A requisição demorou muito para ser concluída.", + "com_agents_error_timeout_suggestion": "Por favor verifique sua conexão com a internet e tente novamente.", "com_agents_error_timeout_title": "A conexão expirou", "com_agents_error_title": "Algo deu errado", "com_agents_file_context_disabled": "O agente deve ser criado antes de carregar arquivos para o Contexto de Arquivo.", "com_agents_file_search_disabled": "O agente deve ser criado antes de carregar arquivos para Pesquisa de Arquivos.", "com_agents_file_search_info": "Quando ativado, o agente será informado dos nomes exatos dos arquivos listados abaixo, permitindo que ele recupere o contexto relevante desses arquivos.", + "com_agents_grid_announcement": "Mostrando {{count}} agentes na categoria {{category}}", "com_agents_instructions_placeholder": "As instruções do sistema que o agente usa", "com_agents_link_copied": "Link copiado", "com_agents_link_copy_failed": "Falha ao copiar o link", + "com_agents_load_more_label": "Carregar mais agentes da categoria {{category}}", "com_agents_loading": "Carregando...", "com_agents_marketplace": "Marketplace de Agentes", "com_agents_mcp_description_placeholder": "Explique o que ele faz em poucas palavras", @@ -65,15 +89,18 @@ "com_agents_search_info": "Quando ativado, permite seu agente buscar informações atualizadas na web. Requer uma chave de API válida.", "com_agents_search_instructions": "Utilize nome ou descrição para pesquisar agentes", "com_agents_search_name": "Pesquisar agentes por nome", + "com_agents_search_no_results": "Nenhum agente encontrado para \"{{query}}\"", "com_agents_search_placeholder": "Pesquisar agentes...", "com_agents_see_more": "Ver mais", "com_agents_start_chat": "Iniciar chat", + "com_agents_top_picks": "Principais escolhas", "com_agents_update_error": "Houve um erro ao atualizar seu agente.", "com_assistants_action_attempt": "Assistente quer falar com {{0}}", "com_assistants_actions": "Ações", "com_assistants_actions_disabled": "Você precisa criar um assistente antes de adicionar ações.", "com_assistants_actions_info": "Permita que seu Assistente recupere informações ou execute ações via API's", "com_assistants_add_actions": "Adicionar Ações", + "com_assistants_add_mcp_server_tools": "Adicionar ferramentas do Servidor MCP\n", "com_assistants_add_tools": "Adicionar Ferramentas", "com_assistants_allow_sites_you_trust": "Permitir apenas sites em que confia.", "com_assistants_append_date": "Anexar Data e Hora Atual", @@ -145,6 +172,7 @@ "com_auth_error_login_rl": "Muitas tentativas de login em um curto período de tempo. Por favor, tente novamente mais tarde.", "com_auth_error_login_server": "Houve um erro interno no servidor. Por favor, aguarde alguns momentos e tente novamente.", "com_auth_error_login_unverified": "Sua conta não foi verificada. Por favor, verifique seu e-mail para um link de verificação.", + "com_auth_error_oauth_failed": "Autenticação falhou. Por favor verifique seu método de login e tente novamente.", "com_auth_facebook_login": "Continuar com Facebook", "com_auth_full_name": "Nome completo", "com_auth_github_login": "Continuar com Github", @@ -236,6 +264,8 @@ "com_endpoint_deprecated": "Obsoleto", "com_endpoint_deprecated_info": "Este ponto de extremidade está obsoleto e pode ser removido em versões futuras. Em vez disso, use o ponto de extremidade do agente.", "com_endpoint_deprecated_info_a11y": "O ponto de extremidade do plugin está obsoleto e pode ser removido em versões futuras. Em vez disso, use o ponto de extremidade do agente.", + "com_endpoint_disable_streaming": "Desativar o streaming de respostas e receber a resposta de uma vez. Útil para modelos como o GPT-o3 que precisam de verificação da organização para streaming", + "com_endpoint_disable_streaming_label": "Desativar Streaming", "com_endpoint_examples": "Presets", "com_endpoint_export": "Exportar", "com_endpoint_export_share": "Exportar/Compartilhar", @@ -245,8 +275,10 @@ "com_endpoint_google_maxoutputtokens": "Número máximo de tokens que podem ser gerados na resposta. Especifique um valor mais baixo para respostas mais curtas e um valor mais alto para respostas mais longas. Nota: os modelos podem parar antes de atingir esse máximo.", "com_endpoint_google_temp": "Valores mais altos = mais aleatório, enquanto valores mais baixos = mais focado e determinístico. Recomendamos alterar isso ou Top P, mas não ambos.", "com_endpoint_google_thinking": "Habilita ou desabilita o pensamento. Essa opção é suportada apenas por certos modelos (série 2.5). Para modelos antigos, esta opção pode não ter efeito.", + "com_endpoint_google_thinking_budget": "Dita o número de tokens de pensamento que o modelo usa. A quantidade atual pode exceder ou ficar abaixo desse valor dependendo do prompt.\n\nEssa configuração é suportada apenas por certos modelos (2.5 series). Gemini 2.5 Pro suporta 128-32,768 tokens. Gemini 2.5 Flash suporta 0-24,576 tokens. Gemini 2.5 Flash Lite suporta 512-24,576 tokens.\n\nDeixe em branco ou defina como \"-1\" para deixar que o modelo decida automaticamente quando e quanto pensar. Por padrão, o Gemini 2.5 Flash Lite não pensa.", "com_endpoint_google_topk": "Top-k altera como o modelo seleciona tokens para saída. Um top-k de 1 significa que o token selecionado é o mais provável entre todos os tokens no vocabulário do modelo (também chamado de decodificação gananciosa), enquanto um top-k de 3 significa que o próximo token é selecionado entre os 3 tokens mais prováveis (usando temperatura).", "com_endpoint_google_topp": "Top-p altera como o modelo seleciona tokens para saída. Os tokens são selecionados dos mais prováveis (veja o parâmetro topK) até os menos prováveis até que a soma de suas probabilidades atinja o valor top-p.", + "com_endpoint_google_use_search_grounding": "Use o recurso de base de pesquisa do Google para aprimorar as respostas com resultados de pesquisa na web em tempo real. Isso permite que os modelos acessem informações atuais e forneçam respostas mais precisas e atualizadas.", "com_endpoint_instructions_assistants": "Substituir Instruções", "com_endpoint_instructions_assistants_placeholder": "Substitui as instruções do assistente. Isso é útil para modificar o comportamento em uma base por execução.", "com_endpoint_max_output_tokens": "Máximo de Tokens de Saída", @@ -264,12 +296,15 @@ "com_endpoint_openai_pres": "Número entre -2.0 e 2.0. Valores positivos penalizam novos tokens com base em sua presença no texto até agora, aumentando a probabilidade do modelo de falar sobre novos tópicos.", "com_endpoint_openai_prompt_prefix_placeholder": "Defina instruções personalizadas para incluir na Mensagem do Sistema. Padrão: nenhuma", "com_endpoint_openai_reasoning_effort": "somente modelos o1 e o3: restringe o esforço de raciocínio para modelos de raciocínio. Reduzir o esforço de raciocínio pode resultar em respostas mais rápidas e menos tokens usados ​​no raciocínio em uma resposta.", + "com_endpoint_openai_reasoning_summary": "Somente Responses API: Um resumo da linha de raciocínio feita pelo modelo. Isso pode ser útil para depurar e entender a linha de raciocínio do modelo. Defina como none, auto, concise ou detailed.", "com_endpoint_openai_resend": "Reenviar todas as imagens anexadas anteriormente. Nota: isso pode aumentar significativamente o custo de tokens e você pode experimentar erros com muitos anexos de imagem.", "com_endpoint_openai_resend_files": "Reenviar todos os arquivos anexados anteriormente. Nota: isso aumentará o custo de tokens e você pode experimentar erros com muitos anexos.", "com_endpoint_openai_stop": "Até 4 sequências onde a API parará de gerar mais tokens.", "com_endpoint_openai_temp": "Valores mais altos = mais aleatório, enquanto valores mais baixos = mais focado e determinístico. Recomendamos alterar isso ou Top P, mas não ambos.", "com_endpoint_openai_topp": "Uma alternativa à amostragem com temperatura, chamada amostragem de núcleo, onde o modelo considera os resultados dos tokens com massa de probabilidade top_p. Então, 0.1 significa que apenas os tokens que compreendem os 10% principais da massa de probabilidade são considerados. Recomendamos alterar isso ou a temperatura, mas não ambos.", "com_endpoint_openai_use_responses_api": "Usa a API de Respostas ao invés de Conclusões de Chat, que inclui funcionalidades extendidas da OpenAI. Requerida para o1-pro, o3-pro, e para habilitar resumos de raciocínio.", + "com_endpoint_openai_use_web_search": "Ative a funcionalidade de pesquisa na web usando as ferramentas nativas de busca da OpenAI. Isso permite que o modelo pesquise na web para obter informações atualizadas e responder de forma mais precisa e embasada.", + "com_endpoint_openai_verbosity": "Limita a verbosidade da resposta do modelo. Valores mais baixos resultarão em respostas mais concisas, enquanto que valores mais altos resultarão em respostas mais verbosas. Atualmente os valores suportados são low, medium e high.", "com_endpoint_output": "Saída", "com_endpoint_plug_image_detail": "Detalhe da Imagem", "com_endpoint_plug_resend_files": "Reenviar Arquivos", @@ -300,6 +335,7 @@ "com_endpoint_prompt_prefix_assistants_placeholder": "Defina instruções ou contexto adicionais além das instruções principais do Assistente. Ignorado se vazio.", "com_endpoint_prompt_prefix_placeholder": "Defina instruções ou contexto personalizados. Ignorado se vazio.", "com_endpoint_reasoning_effort": "Esforço de raciocínio", + "com_endpoint_reasoning_summary": "Resumo do raciocínio", "com_endpoint_save_as_preset": "Salvar Como Preset", "com_endpoint_search": "Procurar endpoint por nome", "com_endpoint_search_models": "Buscar modelos...", @@ -313,11 +349,11 @@ "com_endpoint_top_k": "Top K", "com_endpoint_top_p": "Top P", "com_endpoint_use_active_assistant": "Usar Assistente Ativo", + "com_endpoint_use_responses_api": "Usar Responses API", "com_error_expired_user_key": "A chave fornecida para {{0}} expirou em {{1}}. Por favor, forneça uma nova chave e tente novamente.", "com_error_files_dupe": "Foi detectado um arquivo duplicado.", "com_error_files_empty": "Pensamento", "com_error_files_process": "Ocorreu um erro ao processar o arquivo.", - "com_error_files_unsupported_capability": "Não existem capacidades ativadas que suportem este tipo de arquivo.", "com_error_files_upload": "Ocorreu um erro ao carregar o arquivo.", "com_error_files_upload_canceled": "O pedido de carregamento de arquivos foi cancelado. Nota: o carregamento de arquivo pode ainda estar a ser processado e terá de ser eliminado manualmente.", "com_error_files_validation": "Ocorreu um erro durante a validação do arquivo.", @@ -345,7 +381,6 @@ "com_nav_auto_scroll": "Rolagem Automática para a última mensagem ao abrir o chat", "com_nav_auto_send_prompts": "Enviar prompts automaticamente", "com_nav_auto_send_text": "Enviar texto automaticamente", - "com_nav_auto_send_text_disabled": "definir -1 para desativar", "com_nav_auto_transcribe_audio": "Transcrever áudio automaticamente", "com_nav_automatic_playback": "Reprodução Automática da Última Mensagem", "com_nav_balance": "Crédito", @@ -357,6 +392,7 @@ "com_nav_balance_minutes": "minutos", "com_nav_balance_month": "mês", "com_nav_balance_months": "meses", + "com_nav_balance_next_refill": "Próximo Reabastecimento:", "com_nav_balance_second": "segundo", "com_nav_balance_seconds": "segundos", "com_nav_browser": "Navegador", @@ -465,6 +501,7 @@ "com_nav_search_placeholder": "Buscar mensagens", "com_nav_send_message": "Enviar mensagem", "com_nav_setting_account": "Conta", + "com_nav_setting_balance": "Créditos", "com_nav_setting_chat": "Chat", "com_nav_setting_data": "Controles de dados", "com_nav_setting_general": "Geral", diff --git a/client/src/locales/pt-PT/translation.json b/client/src/locales/pt-PT/translation.json index 0388e5e4eb..eebebdd270 100644 --- a/client/src/locales/pt-PT/translation.json +++ b/client/src/locales/pt-PT/translation.json @@ -6,31 +6,74 @@ "com_agents_all": "Todos os Agentes", "com_agents_all_category": "Todos", "com_agents_by_librechat": "por LibreChat", + "com_agents_category_finance": "Finanças", + "com_agents_category_finance_description": "Agentes especializados em análise financeira, orçamento e contabilidade", + "com_agents_category_general": "Geral", + "com_agents_category_hr": "Recursos Humanos", + "com_agents_category_it": "TI", + "com_agents_category_sales": "Vendas", "com_agents_code_interpreter": "Quando ativo, permite que os seus agentes usem a API de Interpretação de código do LibreChat para correr código gerado, inclusivé processamento de ficheiros em segurança. Requer uma chave API válida.", "com_agents_code_interpreter_title": "API de Interpretação de Código", + "com_agents_contact": "Contacto", "com_agents_create_error": "Houve um erro ao criar seu agente.", + "com_agents_created_by": "por", "com_agents_description_placeholder": "Opcional: Descreva seu Agente aqui", + "com_agents_empty_state_heading": "Nenhum agente encontrado", "com_agents_enable_file_search": "Permitir Pesquisa de Ficheiros.", + "com_agents_error_bad_request_message": "Não foi possível processar o pedido.", + "com_agents_error_generic": "Ocorreu um problema ao carregar o conteúdo.", + "com_agents_error_loading": "Erro ao carregar agentes", + "com_agents_error_server_message": "O servidor está temporariamente indisponível.", + "com_agents_error_server_suggestion": "Tente novamente dentro de momentos.", + "com_agents_error_server_title": "Erro do Servidor", + "com_agents_error_suggestion_generic": "Tente atualizar a página ou tente novamente mais tarde.", + "com_agents_error_timeout_message": "O pedido demorou demasiado tempo a completar.", + "com_agents_error_timeout_suggestion": "Verifique a sua ligação à Internet e tente novamente.", + "com_agents_error_timeout_title": "Tempo de Ligação Esgotado", + "com_agents_error_title": "Algo correu mal", "com_agents_file_context_disabled": "Um agente deve ser criado antes de tentar fazer upload para contexto.", "com_agents_file_search_disabled": "O Agente deve ser criado antes carregar ficheiros para Pesquisar.", "com_agents_file_search_info": "Quando ativo, os agentes serão informados dos nomes de ficheiros listados abaixo, permitindo aos mesmos a extração de contexto relevante.", + "com_agents_grid_announcement": "A mostrar {{count}} agentes na categoria {{category}}", "com_agents_instructions_placeholder": "As instruções do sistema que o agente usa", + "com_agents_link_copied": "Ligação copiada", + "com_agents_link_copy_failed": "Falhou ao copiar ligação", + "com_agents_load_more_label": "Carregar mais agentes da categoria {{category}}", + "com_agents_loading": "A carregar...", + "com_agents_marketplace": "Mercado de Agentes", + "com_agents_marketplace_subtitle": "Descubra e utilize agentes de IA poderosos para melhorar os seus fluxos de trabalho e produtividade", "com_agents_mcp_description_placeholder": "Em poucas palavras explica o que faz", "com_agents_mcp_icon_size": "Tamanho mínimo é 128 x 128 px", + "com_agents_mcp_info": "Adicione servidores MCP ao seu agente para lhe permitir realizar tarefas e interagir com serviços externos", "com_agents_mcp_name_placeholder": "Ferramenta Costumizada", + "com_agents_mcp_trust_subtext": "Os conectores personalizados não são verificados pelo LibreChat", "com_agents_mcps_disabled": "Precisas de criar um agente antes de adicionar MCPs.", + "com_agents_missing_name": "Por favor, introduza um nome antes de criar um agente.", "com_agents_missing_provider_model": "Por favor, escolhe um provedor e modelo antes de criar um agente.", "com_agents_name_placeholder": "Opcional: O nome do agente", "com_agents_no_access": "Não tens permissões para editar este agente.", "com_agents_no_agent_id_error": "Nenhum ID de Agente Encontrado. Por favor, garante que tens um agente criado.", + "com_agents_no_more_results": "Chegou ao fim dos resultados", "com_agents_not_available": "Agente não disponível.", + "com_agents_recommended": "Os nossos agentes recomendados", + "com_agents_results_for": "Resultados para '{{query}}'", + "com_agents_search_aria": "Pesquisar agentes", + "com_agents_search_empty_heading": "Sem resultados de pesquisa", + "com_agents_search_info": "Quando ativado, permite que o seu agente pesquise na web por informações atualizadas. Requer uma chave de API válida.", + "com_agents_search_instructions": "Digite para pesquisar agentes por nome ou descrição", "com_agents_search_name": "Pesquisar agentes por nome", + "com_agents_search_no_results": "Nenhum agente encontrado para \"{{query}}\"", + "com_agents_search_placeholder": "Pesquisar agentes...", + "com_agents_see_more": "Ver mais", + "com_agents_start_chat": "Iniciar Conversa", + "com_agents_top_picks": "Principais Escolhas", "com_agents_update_error": "Houve um erro ao atualizar seu agente.", "com_assistants_action_attempt": "Assistente quer falar com {{0}}", "com_assistants_actions": "Ações", "com_assistants_actions_disabled": "Você precisa criar um assistente antes de adicionar ações.", "com_assistants_actions_info": "Permita que seu Assistente recupere informações ou execute ações via API's", "com_assistants_add_actions": "Adicionar Ações", + "com_assistants_add_mcp_server_tools": "Adicionar Ferramentas do Servidor MCP", "com_assistants_add_tools": "Adicionar Ferramentas", "com_assistants_allow_sites_you_trust": "Apenas permitir sites que confia.", "com_assistants_append_date": "Anexar Data e Hora Atual", @@ -102,6 +145,7 @@ "com_auth_error_login_rl": "Muitas tentativas de login em um curto período de tempo. Por favor, tente novamente mais tarde.", "com_auth_error_login_server": "Houve um erro interno no servidor. Por favor, aguarde alguns momentos e tente novamente.", "com_auth_error_login_unverified": "Sua conta não foi verificada. Por favor, verifique seu e-mail para um link de verificação.", + "com_auth_error_oauth_failed": "Falha na autenticação. Verifique o seu método de início de sessão e tente novamente.", "com_auth_facebook_login": "Continuar com Facebook", "com_auth_full_name": "Nome completo", "com_auth_github_login": "Continuar com Github", @@ -132,6 +176,7 @@ "com_auth_submit_registration": "Enviar registro", "com_auth_to_reset_your_password": "para redefinir sua senha.", "com_auth_to_try_again": "para tentar novamente.", + "com_auth_two_factor": "Verifique a sua aplicação de palavra-passe única preferida para obter um código", "com_auth_username": "Nome de usuário (opcional)", "com_auth_username_max_length": "O nome de usuário deve ter menos de 20 caracteres", "com_auth_username_min_length": "O nome de usuário deve ter pelo menos 2 caracteres", @@ -150,8 +195,11 @@ "com_endpoint_anthropic_maxoutputtokens": "Número máximo de tokens que podem ser gerados na resposta. Especifique um valor mais baixo para respostas mais curtas e um valor mais alto para respostas mais longas. Nota: os modelos podem parar antes de atingir esse máximo.", "com_endpoint_anthropic_prompt_cache": "O cache de prompt permite reutilizar um grande contexto ou instruções em chamadas de API, reduzindo custos e latência", "com_endpoint_anthropic_temp": "Varia de 0 a 1. Use temperatura mais próxima de 0 para tarefas analíticas / de múltipla escolha, e mais próxima de 1 para tarefas criativas e generativas. Recomendamos alterar isso ou Top P, mas não ambos.", + "com_endpoint_anthropic_thinking": "Activa o raciocínio interno para modelos Claude suportados (3.7 Sonnet). Nota: exige que o \"Orçamento de Raciocínio\" esteja definido e seja inferior aos \"Tokens de Saída Máximos\"", + "com_endpoint_anthropic_thinking_budget": "Determina o número máximo de tokens que o Claude pode utilizar no seu processo de raciocínio interno. Orçamentos maiores podem melhorar a qualidade da resposta ao permitir uma análise mais aprofundada de problemas complexos, embora o Claude possa não utilizar todo o orçamento atribuído, especialmente em intervalos acima de 32K. Esta definição deve ser inferior aos \"Tokens de Saída Máximos\".", "com_endpoint_anthropic_topk": "Top-k altera como o modelo seleciona tokens para saída. Um top-k de 1 significa que o token selecionado é o mais provável entre todos os tokens no vocabulário do modelo (também chamado de decodificação gananciosa), enquanto um top-k de 3 significa que o próximo token é selecionado entre os 3 tokens mais prováveis (usando temperatura).", "com_endpoint_anthropic_topp": "Top-p altera como o modelo seleciona tokens para saída. Os tokens são selecionados dos mais prováveis (veja o parâmetro topK) até os menos prováveis até que a soma de suas probabilidades atinja o valor top-p.", + "com_endpoint_anthropic_use_web_search": "Activa a funcionalidade de pesquisa na web utilizando as capacidades de pesquisa integradas da Anthropic. Isto permite ao modelo pesquisar na web informações actualizadas e fornecer respostas mais precisas e actuais.", "com_endpoint_assistant": "Assistente", "com_endpoint_assistant_model": "Modelo de Assistente", "com_endpoint_assistant_placeholder": "Por favor, selecione um Assistente no Painel Lateral Direito", @@ -187,6 +235,9 @@ "com_endpoint_default_empty": "padrão: vazio", "com_endpoint_default_with_num": "padrão: {{0}}", "com_endpoint_deprecated": "Deprecado", + "com_endpoint_deprecated_info": "Este endpoint está depreciado e pode ser removido em versões futuras, por favor utilize o endpoint de agente em vez disso", + "com_endpoint_deprecated_info_a11y": "O endpoint de plugin está depreciado e pode ser removido em versões futuras, por favor utilize o endpoint de agente em vez disso", + "com_endpoint_disable_streaming": "Desactiva respostas em streaming e recebe a resposta completa de uma só vez. Útil para modelos como o o3 que exigem verificação organizacional para streaming", "com_endpoint_disable_streaming_label": "Desligar Streaming", "com_endpoint_examples": "Presets", "com_endpoint_export": "Exportar", @@ -196,8 +247,11 @@ "com_endpoint_google_custom_name_placeholder": "Defina um nome personalizado para o Google", "com_endpoint_google_maxoutputtokens": "Número máximo de tokens que podem ser gerados na resposta. Especifique um valor mais baixo para respostas mais curtas e um valor mais alto para respostas mais longas. Nota: os modelos podem parar antes de atingir esse máximo.", "com_endpoint_google_temp": "Valores mais altos = mais aleatório, enquanto valores mais baixos = mais focado e determinístico. Recomendamos alterar isso ou Top P, mas não ambos.", + "com_endpoint_google_thinking": "Activa ou desactiva o raciocínio. Esta definição é apenas suportada por determinados modelos (série 2.5). Para modelos mais antigos, esta definição pode não ter efeito.", + "com_endpoint_google_thinking_budget": "Orienta o número de tokens de raciocínio que o modelo utiliza. A quantidade real pode exceder ou ficar abaixo deste valor dependendo da solicitação.\n\nEsta definição é apenas suportada por determinados modelos (série 2.5). O Gemini 2.5 Pro suporta 128-32.768 tokens. O Gemini 2.5 Flash suporta 0-24.576 tokens. O Gemini 2.5 Flash Lite suporta 512-24.576 tokens.\n\nDeixe em branco ou defina como \"-1\" para permitir que o modelo decida automaticamente quando e quanto pensar. Por defeito, o Gemini 2.5 Flash Lite não pensa.", "com_endpoint_google_topk": "Top-k altera como o modelo seleciona tokens para saída. Um top-k de 1 significa que o token selecionado é o mais provável entre todos os tokens no vocabulário do modelo (também chamado de decodificação gananciosa), enquanto um top-k de 3 significa que o próximo token é selecionado entre os 3 tokens mais prováveis (usando temperatura).", "com_endpoint_google_topp": "Top-p altera como o modelo seleciona tokens para saída. Os tokens são selecionados dos mais prováveis (veja o parâmetro topK) até os menos prováveis até que a soma de suas probabilidades atinja o valor top-p.", + "com_endpoint_google_use_search_grounding": "Utiliza a funcionalidade de fundamentação de pesquisa do Google para melhorar as respostas com resultados de pesquisa web em tempo real. Isto permite que os modelos acedam a informações atuais e forneçam respostas mais precisas e actualizadas.", "com_endpoint_instructions_assistants": "Substituir Instruções", "com_endpoint_instructions_assistants_placeholder": "Substitui as instruções do assistente. Isso é útil para modificar o comportamento em uma base por execução.", "com_endpoint_max_output_tokens": "Máximo de Tokens de Saída", @@ -214,12 +268,16 @@ "com_endpoint_openai_max_tokens": "Campo opcional `max_tokens`, representando o número máximo de tokens que podem ser gerados na conclusão do chat. O comprimento total dos tokens de entrada e dos tokens gerados é limitado pelo comprimento do contexto dos modelos. Você pode experimentar erros se esse número exceder o máximo de tokens de contexto.", "com_endpoint_openai_pres": "Número entre -2.0 e 2.0. Valores positivos penalizam novos tokens com base em sua presença no texto até agora, aumentando a probabilidade do modelo de falar sobre novos tópicos.", "com_endpoint_openai_prompt_prefix_placeholder": "Defina instruções personalizadas para incluir na Mensagem do Sistema. Padrão: nenhuma", - "com_endpoint_openai_reasoning_effort": "Apenas para modelos o1: Restringir o esforço de raciocínio. Isto poderá resultar em respostas mais rápidas e menos tokens usados na lógica da resposta.", + "com_endpoint_openai_reasoning_effort": "Apenas modelos de raciocínio: restringe o esforço no raciocínio. Reduzir o esforço de raciocínio pode resultar em respostas mais rápidas e menos tokens utilizados no raciocínio numa resposta. 'Mínimo' produz muito poucos tokens de raciocínio para o tempo mais rápido até ao primeiro token, especialmente adequado para programação e seguimento de instruções.", + "com_endpoint_openai_reasoning_summary": "Apenas API de Respostas: Um resumo do raciocínio efectuado pelo modelo. Isto pode ser útil para depuração e compreensão do processo de raciocínio do modelo. Defina como none, auto, concise, ou detailed.", "com_endpoint_openai_resend": "Reenviar todas as imagens anexadas anteriormente. Nota: isso pode aumentar significativamente o custo de tokens e você pode experimentar erros com muitos anexos de imagem.", "com_endpoint_openai_resend_files": "Reenviar todos os arquivos anexados anteriormente. Nota: isso aumentará o custo de tokens e você pode experimentar erros com muitos anexos.", "com_endpoint_openai_stop": "Até 4 sequências onde a API parará de gerar mais tokens.", "com_endpoint_openai_temp": "Valores mais altos = mais aleatório, enquanto valores mais baixos = mais focado e determinístico. Recomendamos alterar isso ou Top P, mas não ambos.", "com_endpoint_openai_topp": "Uma alternativa à amostragem com temperatura, chamada amostragem de núcleo, onde o modelo considera os resultados dos tokens com massa de probabilidade top_p. Então, 0.1 significa que apenas os tokens que compreendem os 10% principais da massa de probabilidade são considerados. Recomendamos alterar isso ou a temperatura, mas não ambos.", + "com_endpoint_openai_use_responses_api": "Utiliza a API de Respostas em vez do Chat Completions, que inclui funcionalidades expandidas do OpenAI. Obrigatório para o1-pro, o3-pro e para activar resumos de raciocínio.", + "com_endpoint_openai_use_web_search": "Activa a funcionalidade de pesquisa web utilizando as capacidades de pesquisa integradas do OpenAI. Isto permite ao modelo pesquisar na web por informações actualizadas e fornecer respostas mais precisas e actuais.", + "com_endpoint_openai_verbosity": "Limita a verbosidade da resposta do modelo. Valores mais baixos resultarão em respostas mais concisas, enquanto valores mais altos resultarão em respostas mais verbosas. Os valores actualmente suportados são low, medium e high.", "com_endpoint_output": "Saída", "com_endpoint_plug_image_detail": "Detalhe da Imagem", "com_endpoint_plug_resend_files": "Reenviar Arquivos", @@ -228,6 +286,7 @@ "com_endpoint_plug_use_functions": "Usar Funções", "com_endpoint_presence_penalty": "Penalidade de Presença", "com_endpoint_preset": "preset", + "com_endpoint_preset_custom_name_placeholder": "Introduza um nome personalizado", "com_endpoint_preset_default": "é agora o preset padrão.", "com_endpoint_preset_default_item": "Padrão:", "com_endpoint_preset_default_none": "Nenhum preset padrão ativo.", @@ -249,6 +308,7 @@ "com_endpoint_prompt_prefix_assistants_placeholder": "Defina instruções ou contexto adicionais além das instruções principais do Assistente. Ignorado se vazio.", "com_endpoint_prompt_prefix_placeholder": "Defina instruções ou contexto personalizados. Ignorado se vazio.", "com_endpoint_reasoning_effort": "Esforço de raciocínio", + "com_endpoint_reasoning_summary": "Resumo do Raciocínio", "com_endpoint_save_as_preset": "Salvar Como Preset", "com_endpoint_search": "Procurar endereço por nome", "com_endpoint_search_endpoint_models": "Procurar {{0}} modelos....", @@ -266,22 +326,42 @@ "com_endpoint_top_p": "Top P", "com_endpoint_use_active_assistant": "Usar Assistente Ativo", "com_endpoint_use_responses_api": "Usar Respostas da API", + "com_endpoint_use_search_grounding": "Fundamentação com Pesquisa Google", + "com_endpoint_verbosity": "Verbosidade", + "com_error_endpoint_models_not_loaded": "Não foi possível carregar os modelos para {{0}}. Por favor, recarregue a página e tente novamente.", "com_error_expired_user_key": "A chave fornecida para {{0}} expirou em {{1}}. Por favor, forneça uma nova chave e tente novamente.", "com_error_files_dupe": "Ficheiro duplicado detectado", "com_error_files_empty": "Ficheiros vazios não são permitidos.", "com_error_files_process": "Ocorreu um erro ao processar o ficheiro.", - "com_error_files_unsupported_capability": "Não existem funcionalidades ativas que suportem este tipo de ficheiro.", "com_error_files_upload": "Ocorreu um erro ao enviar o ficheiro.", "com_error_files_upload_canceled": "O enviar do ficheiro foi cancelado. Nota: O envio pode estar ainda a ser processado e poderá necessitar de ser apagado manualmente.", "com_error_files_validation": "Ocorreu um erro ao validar o ficheiro.", - "com_error_input_length": "A contagem de tokens da última mensagem é muito longa, excedendo o limite de tokens ({{0}} respectivamente). Por favor, encurte sua mensagem, ajuste o tamanho máximo do contexto nos parâmetros da conversa ou divida a conversa para continuar.", + "com_error_google_tool_conflict": "O uso de ferramentas integradas do Google não é suportado com ferramentas externas. Por favor, desative as ferramentas integradas ou as ferramentas externas.", + "com_error_heic_conversion": "Falha ao converter imagem HEIC para JPEG. Por favor, converta a imagem manualmente ou use um formato diferente.", + "com_error_illegal_model_request": "O modelo \"{{0}}\" não está disponível para {{1}}. Por favor, selecione um modelo diferente.", + "com_error_input_length": "A contagem de tokens da última mensagem é demasiado longa, excedendo o limite de tokens, ou os parâmetros do limite de tokens estão mal configurados, afectando negativamente a janela de contexto. Mais informações: {{0}}. Por favor, encurte a sua mensagem, ajuste o tamanho máximo do contexto a partir dos parâmetros da conversação, ou bifurque a conversação para continuar.", + "com_error_invalid_agent_provider": "O fornecedor \"{{0}}\" não está disponível para uso com Agentes. Por favor, vá às configurações do seu agente e selecione um fornecedor atualmente disponível.", "com_error_invalid_user_key": "Chave fornecida inválida. Por favor, forneça uma chave válida e tente novamente.", + "com_error_missing_model": "Nenhum modelo selecionado para {{0}}. Por favor, selecione um modelo e tente novamente.", + "com_error_models_not_loaded": "A configuração dos modelos não pôde ser carregada. Por favor, atualize a página e tente novamente.", "com_error_moderation": "Parece que o conteúdo enviado foi sinalizado pelo nosso sistema de moderação por não estar alinhado com nossas diretrizes da comunidade. Não podemos prosseguir com este tópico específico. Se você tiver outras perguntas ou tópicos que gostaria de explorar, edite sua mensagem ou crie uma nova conversa.", "com_error_no_base_url": "Nenhuma URL base encontrada. Por favor, forneça uma e tente novamente.", "com_error_no_user_key": "Nenhuma chave encontrada. Por favor, forneça uma chave e tente novamente.", + "com_file_pages": "Páginas: {{pages}}", + "com_file_source": "Ficheiro", + "com_file_unknown": "Ficheiro Desconhecido", + "com_files_download_failed": "{{0}} ficheiros falharam", + "com_files_download_percent_complete": "{{0}}% concluído", + "com_files_download_progress": "{{0}} de {{1}} ficheiros", + "com_files_downloading": "A descarregar ficheiros", "com_files_filter": "Filtrar arquivos...", "com_files_no_results": "Nenhum resultado.", "com_files_number_selected": "{{0}} de {{1}} arquivo(s) selecionado(s)", + "com_files_preparing_download": "A preparar descarga...", + "com_files_sharepoint_picker_title": "Selecionar Ficheiros", + "com_files_table": "Tabela de Ficheiros", + "com_files_upload_local_machine": "Do Computador Local", + "com_files_upload_sharepoint": "Do SharePoint", "com_generated_files": "Ficheiros gerados:", "com_hide_examples": "Ocultar Exemplos", "com_info_heic_converting": "Converter imagem HEIC para JPEG...", @@ -298,7 +378,6 @@ "com_nav_auto_scroll": "Rolagem Automática para a última mensagem ao abrir o chat", "com_nav_auto_send_prompts": "Enviar prompts automaticamente", "com_nav_auto_send_text": "Enviar texto automaticamente", - "com_nav_auto_send_text_disabled": "definir -1 para desativar", "com_nav_auto_transcribe_audio": "Transcrever áudio automaticamente", "com_nav_automatic_playback": "Reprodução Automática da Última Mensagem", "com_nav_balance": "Equilíbrio", @@ -317,6 +396,8 @@ "com_nav_balance_month": "mês", "com_nav_balance_months": "meses", "com_nav_balance_next_refill": "Próximo Carregamento:", + "com_nav_balance_next_refill_info": "A próxima recarga ocorrerá automaticamente apenas quando ambas as condições forem cumpridas: o intervalo de tempo definido tiver decorrido desde a última recarga e o envio de um prompt fizer com que o seu saldo fique abaixo de zero.", + "com_nav_balance_refill_amount": "Valor de Recarregamento:", "com_nav_balance_second": "segundo", "com_nav_balance_seconds": "segundos", "com_nav_balance_week": "semana", @@ -367,6 +448,7 @@ "com_nav_font_size_xs": "Extra Pequeno", "com_nav_help_faq": "Ajuda & FAQ", "com_nav_hide_panel": "Ocultar painel mais à direita", + "com_nav_info_balance": "O saldo mostra quantos créditos de tokens tem disponíveis para usar. Os créditos de tokens traduzem-se em valor monetário (por exemplo, 1000 créditos = $0.001 USD)", "com_nav_info_code_artifacts": "Habilita a exibição de artefatos de código experimental ao lado do chat", "com_nav_info_code_artifacts_agent": "Permitir o uso de artefactos de código por este agente. Por defeito, instruções adicionais específicas ao uso de artefactos são adicionadas, caso o \"Modo de comando personalizado\" esteja ativo.", "com_nav_info_custom_prompt_mode": "Quando habilitado, o prompt padrão do sistema de artefatos não será incluído. Todas as instruções de geração de artefatos devem ser fornecidas manualmente neste modo.", @@ -375,11 +457,14 @@ "com_nav_info_fork_split_target_setting": "Quando habilitado, a bifurcação começará da mensagem alvo até a última mensagem na conversa, de acordo com o comportamento selecionado.", "com_nav_info_include_shadcnui": "Quando habilitado, as instruções para usar componentes shadcn/ui serão incluídas. shadcn/ui é uma coleção de componentes reutilizáveis construídos usando Radix UI e Tailwind CSS. Nota: estas são instruções longas, você deve habilitar apenas se for importante informar o LLM sobre as importações e componentes corretos. Para mais informações sobre esses componentes, visite: https://ui.shadcn.com/", "com_nav_info_latex_parsing": "Quando habilitado, o código LaTeX nas mensagens será renderizado como equações matemáticas. Desabilitar isso pode melhorar o desempenho se você não precisar de renderização LaTeX.", + "com_nav_info_save_badges_state": "Quando ativado, o estado dos distintivos do chat será guardado. Isto significa que se criar um novo chat, os distintivos permanecerão no mesmo estado do chat anterior. Se desativar esta opção, os distintivos serão repostos no seu estado padrão sempre que criar um novo chat", "com_nav_info_save_draft": "Quando habilitado, o texto e os anexos que você inserir no formulário de chat serão salvos automaticamente localmente como rascunhos. Esses rascunhos estarão disponíveis mesmo se você recarregar a página ou mudar para uma conversa diferente. Os rascunhos são armazenados localmente no seu dispositivo e são excluídos uma vez que a mensagem é enviada.", + "com_nav_info_show_thinking": "Quando ativado, o chat mostrará as listas pendentes de raciocínio abertas por predefinição, permitindo-lhe ver o raciocínio da IA em tempo real. Quando desativado, as listas pendentes de raciocínio permanecerão fechadas por predefinição para uma interface mais limpa e simplificada", "com_nav_info_user_name_display": "Quando habilitado, o nome de usuário do remetente será mostrado acima de cada mensagem que você enviar. Quando desabilitado, você verá apenas \"Você\" acima de suas mensagens.", "com_nav_lang_arabic": "العربية", "com_nav_lang_armenian": "Armênio", "com_nav_lang_auto": "Detecção automática", + "com_nav_lang_bosnian": "Bosníaco", "com_nav_lang_brazilian_portuguese": "Português Brasileiro", "com_nav_lang_catalan": "Catalão", "com_nav_lang_chinese": "中文", @@ -398,15 +483,21 @@ "com_nav_lang_italian": "Italiano", "com_nav_lang_japanese": "日本語", "com_nav_lang_korean": "한국어", + "com_nav_lang_latvian": "Letão", + "com_nav_lang_norwegian_bokmal": "Norueguês Bokmål", "com_nav_lang_persian": "Persa", "com_nav_lang_polish": "Polski", "com_nav_lang_portuguese": "Português", "com_nav_lang_russian": "Русский", + "com_nav_lang_slovenian": "Esloveno", "com_nav_lang_spanish": "Español", "com_nav_lang_swedish": "Svenska", "com_nav_lang_thai": "ไทย", + "com_nav_lang_tibetan": "Tibetano", "com_nav_lang_traditional_chinese": "繁體中文", "com_nav_lang_turkish": "Türkçe", + "com_nav_lang_ukrainian": "Ucraniano", + "com_nav_lang_uyghur": "Uigur", "com_nav_lang_vietnamese": "Tiếng Việt", "com_nav_language": "Idioma", "com_nav_latex_parsing": "Análise de LaTeX em mensagens (pode afetar o desempenho)", @@ -415,6 +506,8 @@ "com_nav_maximize_chat_space": "Maximizar espaço de conversa", "com_nav_mcp_configure_server": "Configurar {{0}}", "com_nav_mcp_status_connecting": "{{0}} - A ligar", + "com_nav_mcp_vars_update_error": "Erro ao atualizar variáveis personalizadas do utilizador MCP", + "com_nav_mcp_vars_updated": "Variáveis personalizadas do utilizador MCP atualizadas com sucesso.", "com_nav_modular_chat": "Habilitar troca de Endpoints no meio da conversa", "com_nav_my_files": "Meus Arquivos", "com_nav_not_supported": "Não Suportado", @@ -428,6 +521,7 @@ "com_nav_plus_command": "Comando +", "com_nav_plus_command_description": "Alternar comando \"+\" para adicionar uma configuração de resposta múltipla", "com_nav_profile_picture": "Foto de Perfil", + "com_nav_save_badges_state": "Guardar estado dos distintivos", "com_nav_save_drafts": "Salvar rascunhos localmente", "com_nav_scroll_button": "Botão de ir para o fim", "com_nav_search_placeholder": "Buscar mensagens", @@ -456,6 +550,7 @@ "com_nav_tool_dialog": "Ferramentas do Assistente", "com_nav_tool_dialog_agents": "Ferramentas do Agente", "com_nav_tool_dialog_description": "O assistente deve ser salvo para persistir as seleções de ferramentas.", + "com_nav_tool_dialog_mcp_server_tools": "Ferramentas do Servidor MCP", "com_nav_tool_remove": "Remover", "com_nav_tool_search": "Buscar ferramentas", "com_nav_user": "USUÁRIO", @@ -471,13 +566,27 @@ "com_sidepanel_conversation_tags": "Marcadores", "com_sidepanel_hide_panel": "Ocultar Painel", "com_sidepanel_manage_files": "Gerenciar Arquivos", + "com_sidepanel_mcp_no_servers_with_vars": "Não há servidores MCP com variáveis configuráveis.", "com_sidepanel_parameters": "Parâmetros", + "com_sources_agent_file": "Documento de Origem", + "com_sources_agent_files": "Ficheiros do Agente", + "com_sources_download_aria_label": "Descarregar {{filename}}{{status}}", + "com_sources_download_failed": "Falha no descarregamento", + "com_sources_download_local_unavailable": "Não é possível descarregar: O ficheiro não está guardado", + "com_sources_downloading_status": "(a descarregar...)", + "com_sources_error_fallback": "Não foi possível carregar as origens", "com_sources_image_alt": "Resultado da pesquisa de imagem", + "com_sources_more_files": "+{{count}} ficheiros", "com_sources_more_sources": "+{{count}} fontes", + "com_sources_pages": "Páginas", + "com_sources_region_label": "Resultados da pesquisa e fontes", + "com_sources_reload_page": "Recarregar página", "com_sources_tab_all": "Todos", + "com_sources_tab_files": "Ficheiros", "com_sources_tab_images": "Imagens", "com_sources_tab_news": "Notícias", "com_sources_title": "Fontes", + "com_ui_2fa_account_security": "A autenticação de dois fatores adiciona uma camada extra de segurança à sua conta", "com_ui_2fa_disable": "Desativar 2FA", "com_ui_2fa_disable_error": "Houve um erro ao desativar a autenticação de dois fatores", "com_ui_2fa_disabled": "2FA foi desativado", @@ -502,24 +611,42 @@ "com_ui_advanced": "Avançado", "com_ui_advanced_settings": "Configurações avançadas", "com_ui_agent": "Agente", + "com_ui_agent_category_aftersales": "Pós-Venda", + "com_ui_agent_category_finance": "Finanças", + "com_ui_agent_category_general": "Geral", + "com_ui_agent_category_hr": "RH", + "com_ui_agent_category_it": "TI", + "com_ui_agent_category_rd": "I&D", + "com_ui_agent_category_sales": "Vendas", + "com_ui_agent_category_selector_aria": "Seletor de categoria do agente", "com_ui_agent_chain": "Corrente de Agentes (Mixture-of-Agents)", + "com_ui_agent_chain_info": "Permite criar sequências de agentes. Cada agente pode aceder às saídas de agentes anteriores na cadeia. Baseado na arquitetura \"Mixture-of-Agents\" onde os agentes usam saídas anteriores como informação auxiliar.", "com_ui_agent_chain_max": "Atingiu o limite máximo de {{0}} agentes.", "com_ui_agent_delete_error": "Houve um erro ao excluir o agente", "com_ui_agent_deleted": "Agente excluído com sucesso", "com_ui_agent_duplicate_error": "Ocorreu um erro ao duplicar o agente.", "com_ui_agent_duplicated": "Agente duplicado com sucesso", + "com_ui_agent_name_is_required": "Nome do agente é obrigatório", "com_ui_agent_recursion_limit": "Máximo de passos de agentes", + "com_ui_agent_recursion_limit_info": "Limita quantos passos o agente pode dar numa execução antes de dar uma resposta final. O padrão são 25 passos. Um passo é um pedido à API de IA ou uma ronda de utilização de ferramenta. Por exemplo, uma interação básica com ferramentas leva 3 passos: pedido inicial, utilização da ferramenta e pedido de seguimento.", + "com_ui_agent_url_copied": "URL do agente copiado para a área de transferência", "com_ui_agent_var": "{{0}} agente", "com_ui_agent_version": "Versão", "com_ui_agent_version_active": "Versão ativa", "com_ui_agent_version_empty": "Sem versões disponíveis", + "com_ui_agent_version_error": "Erro ao obter versões", "com_ui_agent_version_history": "Histórico de versões", + "com_ui_agent_version_no_agent": "Nenhum agente selecionado. Por favor, selecione um agente para ver o histórico de versões.", "com_ui_agent_version_no_date": "Data não disponível", "com_ui_agent_version_restore": "Restaurar", + "com_ui_agent_version_restore_confirm": "Tem a certeza de que quer restaurar esta versão?", + "com_ui_agent_version_restore_error": "Falha ao restaurar versão", + "com_ui_agent_version_restore_success": "Versão restaurada com sucesso", "com_ui_agent_version_title": "Versão {{versionNumber}}", "com_ui_agent_version_unknown_date": "Data desconhecida", "com_ui_agents": "Agentes", "com_ui_agents_allow_create": "Permitir a criação de Agentes", + "com_ui_agents_allow_share": "Permitir partilhar Agentes", "com_ui_agents_allow_use": "Permitir o uso de Agentes", "com_ui_all": "todos", "com_ui_all_proper": "Todos", @@ -527,9 +654,11 @@ "com_ui_analyzing_finished": "Análise concluída", "com_ui_api_key": "Chave da API", "com_ui_archive": "Arquivar", + "com_ui_archive_delete_error": "Falha ao eliminar conversa arquivada", "com_ui_archive_error": "Falha ao arquivar conversa", "com_ui_artifact_click": "Clique para abrir", "com_ui_artifacts": "Artefatos", + "com_ui_artifacts_options": "Opções de Artefactos", "com_ui_artifacts_toggle": "Alternar UI de Artefatos", "com_ui_artifacts_toggle_agent": "Permitir Artefactos", "com_ui_ascending": "Asc", @@ -538,6 +667,7 @@ "com_ui_assistant_deleted": "Assistente excluído com sucesso", "com_ui_assistants": "Assistentes", "com_ui_assistants_output": "Saída dos Assistentes", + "com_ui_at_least_one_owner_required": "É obrigatório pelo menos um proprietário", "com_ui_attach_error": "Não é possível anexar o arquivo. Crie ou selecione uma conversa, ou tente atualizar a página.", "com_ui_attach_error_openai": "Não é possível anexar arquivos de Assistente a outros endpoints", "com_ui_attach_error_size": "Limite de tamanho de arquivo excedido para o endpoint:", @@ -551,13 +681,19 @@ "com_ui_authentication": "Autenticação", "com_ui_authentication_type": "Tipo de Autenticação", "com_ui_auto": "Automático", + "com_ui_available_tools": "Ferramentas Disponíveis", "com_ui_avatar": "Avatar", "com_ui_azure": "Azure", + "com_ui_azure_ad": "Entra ID", + "com_ui_back": "Voltar", "com_ui_back_to_chat": "Voltar ao Chat", "com_ui_back_to_prompts": "Voltar aos Prompts", + "com_ui_backup_code_number": "Código #{{number}}", "com_ui_backup_codes": "Copia de segurança dos Códigos", "com_ui_backup_codes_regenerate_error": "Ocorreu um erro ao regenerar a copia de segurança dos códigos", "com_ui_backup_codes_regenerated": "Copia de segurança dos códigos foi gerada com sucesso", + "com_ui_backup_codes_security_info": "Por motivos de segurança, os códigos de segurança são apresentados apenas uma vez quando gerados. Guarde-os num local seguro.", + "com_ui_backup_codes_status": "Estado dos Códigos de Segurança", "com_ui_basic": "Basic", "com_ui_basic_auth_header": "Cabeçalho de Autorização Basic", "com_ui_bearer": "Bearer", @@ -576,6 +712,7 @@ "com_ui_bookmarks_edit": "Editar Favorito", "com_ui_bookmarks_filter": "Filtrar favoritos...", "com_ui_bookmarks_new": "Novo Favorito", + "com_ui_bookmarks_tag_exists": "Já existe um marcador com este título", "com_ui_bookmarks_title": "Título", "com_ui_bookmarks_update_error": "Houve um erro ao atualizar o favorito", "com_ui_bookmarks_update_success": "Favorito atualizado com sucesso", @@ -598,6 +735,9 @@ "com_ui_command_placeholder": "Opcional: Insira um comando para o prompt ou o nome será usado.", "com_ui_command_usage_placeholder": "Selecione um Prompt por comando ou nome", "com_ui_complete_setup": "Acabar Configuração", + "com_ui_concise": "Conciso", + "com_ui_configure_mcp_variables_for": "Configurar Variáveis para {{0}}", + "com_ui_confirm": "Confirmar", "com_ui_confirm_action": "Confirmar Ação", "com_ui_confirm_admin_use_change": "Mudar esta configuração irá bloquear acessos para administradores, você inclusivé. Tem a certeza que pretende avançar?", "com_ui_confirm_change": "Confirmar alteração", @@ -606,15 +746,19 @@ "com_ui_continue": "Continuar", "com_ui_continue_oauth": "Continuar com OAuth", "com_ui_controls": "Controles", + "com_ui_convo_delete_error": "Falha ao eliminar conversa", "com_ui_copied": "Copiado!", "com_ui_copied_to_clipboard": "Copiado para a área de transferência", "com_ui_copy_code": "Copiar código", "com_ui_copy_link": "Copiar link", "com_ui_copy_to_clipboard": "Copiar para a área de transferência", + "com_ui_copy_url_to_clipboard": "Copiar URL para a área de transferência", "com_ui_create": "Criar", "com_ui_create_link": "Criar link", "com_ui_create_memory": "Criar memória", "com_ui_create_prompt": "Criar Prompt", + "com_ui_creating_image": "A criar imagem. Pode demorar um momento", + "com_ui_current": "Atual", "com_ui_currently_production": "Atualmente em produção", "com_ui_custom": "Costumizar", "com_ui_custom_header_name": "Nome de Cabeçalho Customizado", @@ -648,18 +792,32 @@ "com_ui_delete_confirm_prompt_version_var": "Isso excluirá a versão selecionada para \"{{0}}\". Se não houver outras versões, o prompt será excluído.", "com_ui_delete_conversation": "Excluir chat?", "com_ui_delete_mcp": "Apagar MCP", + "com_ui_delete_mcp_confirm": "Tem a certeza de que pretende eliminar este servidor MCP?", + "com_ui_delete_mcp_error": "Falha ao eliminar o servidor MCP", + "com_ui_delete_mcp_success": "Servidor MCP eliminado com sucesso", + "com_ui_delete_memory": "Eliminar Memória", + "com_ui_delete_not_allowed": "Operação de eliminação não permitida", "com_ui_delete_prompt": "Excluir Prompt?", "com_ui_delete_shared_link": "Apagar endereço partilhado?", + "com_ui_delete_success": "Eliminado com sucesso", "com_ui_delete_tool": "Excluir Ferramenta", "com_ui_delete_tool_confirm": "Tem certeza de que deseja excluir esta ferramenta?", + "com_ui_delete_tool_error": "Erro ao eliminar a ferramenta: {{error}}", + "com_ui_delete_tool_success": "Ferramenta eliminada com sucesso", + "com_ui_deleted": "Eliminado", + "com_ui_deleting_file": "A eliminar ficheiro...", "com_ui_descending": "Desc", "com_ui_description": "Descrição", "com_ui_description_placeholder": "Opcional: Insira uma descrição para exibir para o prompt", + "com_ui_deselect_all": "Desselecionar Tudo", + "com_ui_detailed": "Detalhado", "com_ui_disabling": "A desativar...", "com_ui_download": "Descarregar", "com_ui_download_artifact": "Descarregar Artefacto", "com_ui_download_backup": "Descarregar cópia de segurança dos códigos", + "com_ui_download_backup_tooltip": "Antes de continuar, transfira os seus códigos de segurança. Vai precisar deles para recuperar o acesso se perder o seu dispositivo autenticador", "com_ui_download_error": "Erro ao baixar o arquivo. O arquivo pode ter sido excluído.", + "com_ui_drag_drop": "algo precisa ir aqui. estava vazio", "com_ui_dropdown_variables": "Variáveis de dropdown:", "com_ui_dropdown_variables_info": "Crie menus dropdown personalizados para seus prompts: `{{nome_da_variável:opção1|opção2|opção3}}`", "com_ui_duplicate": "Duplicar", @@ -668,21 +826,30 @@ "com_ui_duplication_success": "Conversa duplicada com sucesso", "com_ui_edit": "Editar", "com_ui_edit_editing_image": "A Editar Imagem", + "com_ui_edit_mcp_server": "Editar Servidor MCP", + "com_ui_edit_memory": "Editar Memória", "com_ui_empty_category": "-", "com_ui_endpoint": "Endpoint", "com_ui_endpoint_menu": "Menu de Endereços do LLM", "com_ui_enter": "Entrar", "com_ui_enter_api_key": "Inserir Chave da API", + "com_ui_enter_key": "Tecla Enter", "com_ui_enter_openapi_schema": "Insira o seu schema da OpenAPI aqui", + "com_ui_enter_value": "Introduzir valor", "com_ui_error": "Erro", "com_ui_error_connection": "Erro ao conectar ao servidor, tente atualizar a página.", "com_ui_error_save_admin_settings": "Houve um erro ao salvar suas configurações de admin.", + "com_ui_error_updating_preferences": "Erro ao actualizar preferências", + "com_ui_everyone_permission_level": "Nível de permissão de todos", "com_ui_examples": "Exemplos", "com_ui_expand_chat": "Expandir conversa", "com_ui_export_convo_modal": "Exportar Modal da Conversa", "com_ui_feedback_more": "Mais...", + "com_ui_feedback_more_information": "Fornecer feedback adicional", + "com_ui_feedback_negative": "Necessita melhoramento", "com_ui_feedback_placeholder": "Por favor dá mais feedback aqui", "com_ui_feedback_positive": "Adoro isto", + "com_ui_feedback_tag_accurate_reliable": "Preciso e confiável", "com_ui_feedback_tag_attention_to_detail": "Atenção ao detalhe", "com_ui_feedback_tag_bad_style": "Estilo pobre", "com_ui_feedback_tag_clear_well_written": "Bem escrito", @@ -690,11 +857,16 @@ "com_ui_feedback_tag_inaccurate": "Resposta errada ou imprecisa", "com_ui_feedback_tag_many": "vários", "com_ui_feedback_tag_missing_image": "Imagem Esperada", + "com_ui_feedback_tag_not_helpful": "Faltou informação útil", "com_ui_feedback_tag_not_matched": "Não corresponde ao meu pedido", "com_ui_feedback_tag_one": "um", "com_ui_feedback_tag_other": "outros", "com_ui_feedback_tag_unjustified_refusal": "Recusado sem razão", + "com_ui_field_max_length": "{{field}} deve ter menos de {{length}} caracteres", "com_ui_field_required": "Este campo é obrigatório", + "com_ui_file_size": "Tamanho do ficheiro", + "com_ui_file_token_limit": "Limite de tokens do ficheiro", + "com_ui_file_token_limit_desc": "Definir limite máximo de tokens para processamento de ficheiros para controlar custos e uso de recursos", "com_ui_files": "Ficheiros", "com_ui_filter_prompts": "Filtro de Comando", "com_ui_filter_prompts_name": "Filtrar prompts por nome", @@ -706,15 +878,19 @@ "com_ui_fork_change_default": "Opção de bifurcação padrão", "com_ui_fork_default": "Usar opção de bifurcação padrão", "com_ui_fork_error": "Houve um erro ao bifurcar a conversa", + "com_ui_fork_error_rate_limit": "Demasiados pedidos de derivação. Tente novamente mais tarde", "com_ui_fork_from_message": "Selecione uma opção de bifurcação", "com_ui_fork_info_1": "Use esta configuração para bifurcar mensagens com o comportamento desejado.", "com_ui_fork_info_2": "\"Bifurcação\" refere-se à criação de uma nova conversa que começa/termina a partir de mensagens específicas na conversa atual, criando uma cópia de acordo com as opções selecionadas.", "com_ui_fork_info_3": "A \"mensagem alvo\" refere-se à mensagem da qual este popup foi aberto, ou, se você marcar \"{{0}}\", a última mensagem na conversa.", "com_ui_fork_info_branches": "Esta opção bifurca as mensagens visíveis, junto com ramificações relacionadas; em outras palavras, o caminho direto para a mensagem alvo, incluindo ramificações ao longo do caminho.", + "com_ui_fork_info_button_label": "Ver informações sobre derivação de conversações", "com_ui_fork_info_remember": "Marque isto para lembrar as opções que você seleciona para uso futuro, tornando mais rápido bifurcar conversas conforme preferido.", "com_ui_fork_info_start": "Se marcado, a bifurcação começará desta mensagem até a última mensagem na conversa, de acordo com o comportamento selecionado acima.", "com_ui_fork_info_target": "Esta opção bifurca todas as mensagens até a mensagem alvo, incluindo seus vizinhos; em outras palavras, todos os ramos de mensagens, estejam ou não visíveis ou ao longo do mesmo caminho, estão incluídos.", "com_ui_fork_info_visible": "Esta opção bifurca apenas as mensagens visíveis; em outras palavras, o caminho direto para a mensagem alvo, sem quaisquer ramificações.", + "com_ui_fork_more_details_about": "Ver informações adicionais e detalhes sobre a opção de derivação \"{{0}}\"", + "com_ui_fork_more_info_options": "Ver explicação detalhada de todas as opções de derivação e seus comportamentos", "com_ui_fork_processing": "Bifurcando conversa...", "com_ui_fork_remember": "Lembrar", "com_ui_fork_remember_checked": "Sua seleção será lembrada após o uso. Altere isso a qualquer momento nas configurações.", @@ -724,15 +900,26 @@ "com_ui_fork_visible": "Apenas mensagens visíveis", "com_ui_generate_qrcode": "Gerar Código QR", "com_ui_generating": "A gerar...", + "com_ui_generation_settings": "Definições de Geração", + "com_ui_getting_started": "Primeiros Passos", + "com_ui_global_group": "algo precisa de ir aqui. estava vazio", "com_ui_go_back": "Para trás", "com_ui_go_to_conversation": "Ir para a conversa", "com_ui_good_afternoon": "Boa tarde", "com_ui_good_evening": "Boa noite", "com_ui_good_morning": "Bom dia", + "com_ui_group": "Grupo", "com_ui_happy_birthday": "É meu 1º aniversário!", + "com_ui_hide_image_details": "Ocultar Detalhes da Imagem", + "com_ui_hide_password": "Ocultar palavra-passe", "com_ui_hide_qr": "Esconder QR Code", + "com_ui_high": "Alto", "com_ui_host": "Host", + "com_ui_icon": "Ícone", "com_ui_idea": "Ideias", + "com_ui_image_created": "Imagem criada", + "com_ui_image_details": "Detalhes da Imagem", + "com_ui_image_edited": "Imagem editada", "com_ui_image_gen": "Geração de Imagem", "com_ui_import": "Importar", "com_ui_import_conversation_error": "Houve um erro ao importar suas conversas", @@ -740,8 +927,10 @@ "com_ui_import_conversation_info": "Importar conversas de um arquivo JSON", "com_ui_import_conversation_success": "Conversas importadas com sucesso", "com_ui_include_shadcnui": "Incluir instruções de componentes shadcn/ui", + "com_ui_initializing": "A inicializar...", "com_ui_input": "Entrada", "com_ui_instructions": "Instruções", + "com_ui_key": "Chave", "com_ui_late_night": "Boa madrugada", "com_ui_latest_footer": "Toda IA para Todos.", "com_ui_latest_production_version": "Última versão produtiva", @@ -752,11 +941,46 @@ "com_ui_loading": "A Carregar....", "com_ui_locked": "Bloqueado", "com_ui_logo": "Logotipo {{0}}", + "com_ui_low": "Baixo", "com_ui_manage": "Gerenciar", + "com_ui_marketplace_allow_use": "Permitir utilização do Mercado", "com_ui_max_tags": "O número máximo permitido é {{0}}, usando os valores mais recentes.", + "com_ui_mcp_authenticated_success": "Servidor MCP '{{0}}' autenticado com sucesso", + "com_ui_mcp_configure_server": "Configurar {{0}}", + "com_ui_mcp_configure_server_description": "Configurar variáveis personalizadas para {{0}}", + "com_ui_mcp_enter_var": "Introduzir valor para {{0}}", + "com_ui_mcp_init_failed": "Falha ao inicializar servidor MCP", + "com_ui_mcp_initialize": "Inicializar", + "com_ui_mcp_initialized_success": "Servidor MCP '{{0}}' inicializado com sucesso", + "com_ui_mcp_oauth_cancelled": "Login OAuth cancelado para {{0}}", + "com_ui_mcp_oauth_timeout": "Login OAuth expirou para {{0}}", + "com_ui_mcp_server_not_found": "Servidor não encontrado.", "com_ui_mcp_servers": "Servidores MCP", + "com_ui_mcp_update_var": "Atualizar {{0}}", + "com_ui_mcp_url": "URL do Servidor MCP", + "com_ui_medium": "Médio", + "com_ui_memories": "Memórias", + "com_ui_memories_allow_create": "Permitir criar Memórias", + "com_ui_memories_allow_opt_out": "Permitir que os utilizadores desativem as Memórias", + "com_ui_memories_allow_read": "Permitir ler Memórias", + "com_ui_memories_allow_update": "Permitir atualizar Memórias", + "com_ui_memories_allow_use": "Permitir utilizar Memórias", + "com_ui_memories_filter": "Filtrar memórias...", + "com_ui_memory": "Memória", + "com_ui_memory_already_exceeded": "Armazenamento de memória já cheio - excedido em {{tokens}} tokens. Elimine memórias existentes antes de adicionar novas.", + "com_ui_memory_created": "Memória criada com sucesso", + "com_ui_memory_deleted": "Memória eliminada", + "com_ui_memory_deleted_items": "Memórias Eliminadas", + "com_ui_memory_error": "Erro de Memória", + "com_ui_memory_key_exists": "Uma memória com esta chave já existe. Por favor, utilize uma chave diferente.", + "com_ui_memory_key_validation": "A chave da memória deve conter apenas letras minúsculas e sublinhados.", + "com_ui_memory_storage_full": "Armazenamento de Memória Cheio", + "com_ui_memory_updated": "Memória guardada atualizada", + "com_ui_memory_updated_items": "Memórias Atualizadas", + "com_ui_memory_would_exceed": "Não é possível guardar - excederia o limite em {{tokens}} tokens. Elimine memórias existentes para criar espaço.", "com_ui_mention": "Mencione um endpoint, assistente ou predefinição para alternar rapidamente para ele", "com_ui_min_tags": "Não é possível remover mais valores, um mínimo de {{0}} é necessário.", + "com_ui_minimal": "Mínimo", "com_ui_misc": "Outros", "com_ui_model": "Modelo", "com_ui_model_parameters": "Parâmetros do Modelo", @@ -769,17 +993,45 @@ "com_ui_next": "Próximo", "com_ui_no": "Não", "com_ui_no_bookmarks": "Parece que você ainda não tem favoritos. Clique em um chat e adicione um novo", + "com_ui_no_categories": "Nenhuma categoria disponível", "com_ui_no_category": "Sem categoria", + "com_ui_no_changes": "Não foram feitas alterações", + "com_ui_no_data": "algo precisa estar aqui. estava vazio", + "com_ui_no_individual_access": "Nenhum utilizador individual ou grupo tem acesso a este agente", + "com_ui_no_personalization_available": "Não existem opções de personalização disponíveis atualmente", + "com_ui_no_read_access": "Não tem permissão para ver memórias", + "com_ui_no_results_found": "Nenhum resultado encontrado", "com_ui_no_terms_content": "Nenhum conteúdo de termos e condições para exibir", + "com_ui_no_valid_items": "algo precisa estar aqui. estava vazio", "com_ui_none": "Nenhum", "com_ui_not_used": "Não usado", "com_ui_nothing_found": "Nada encontrado", "com_ui_oauth": "OAuth", + "com_ui_oauth_connected_to": "Ligado a", + "com_ui_oauth_error_callback_failed": "Falha na chamada de autenticação. Tente novamente.", + "com_ui_oauth_error_generic": "Falha na autenticação. Tente novamente.", + "com_ui_oauth_error_invalid_state": "Parâmetro de estado inválido. Tente novamente.", + "com_ui_oauth_error_missing_code": "Código de autorização em falta. Tente novamente.", + "com_ui_oauth_error_missing_state": "Parâmetro de estado em falta. Tente novamente.", + "com_ui_oauth_error_title": "Falha na Autenticação", + "com_ui_oauth_success_description": "A sua autenticação foi bem-sucedida. Esta janela fechará em", + "com_ui_oauth_success_title": "Autenticação Bem-sucedida", "com_ui_of": "de", "com_ui_off": "Desligado", + "com_ui_offline": "Offline", "com_ui_on": "Ligado", "com_ui_openai": "OpenAI", + "com_ui_optional": "(opcional)", "com_ui_page": "Página", + "com_ui_people": "pessoas", + "com_ui_people_picker": "Seletor de Pessoas", + "com_ui_people_picker_allow_view_groups": "Permitir visualizar grupos", + "com_ui_people_picker_allow_view_roles": "Permitir visualizar funções", + "com_ui_people_picker_allow_view_users": "Permitir visualizar utilizadores", + "com_ui_permissions_failed_load": "Falha ao carregar permissões. Por favor, tente novamente.", + "com_ui_permissions_failed_update": "Falha ao atualizar permissões. Por favor, tente novamente.", + "com_ui_permissions_updated_success": "Permissões atualizadas com sucesso", + "com_ui_preferences_updated": "Preferências atualizadas com sucesso", "com_ui_prev": "Anterior", "com_ui_preview": "Pré-visualizar", "com_ui_privacy_policy": "Política de Privacidade", @@ -793,18 +1045,30 @@ "com_ui_prompt_update_error": "Houve um erro ao atualizar o prompt", "com_ui_prompts": "Prompts", "com_ui_prompts_allow_create": "Permitir criação de Prompts", + "com_ui_prompts_allow_share": "Permitir partilhar Prompts", "com_ui_prompts_allow_use": "Permitir uso de Prompts", "com_ui_provider": "Provedor", + "com_ui_quality": "Qualidade", "com_ui_read_aloud": "Ler em voz alta", "com_ui_redirecting_to_provider": "A redirecionar para {{0}}, por favor aguarde...", + "com_ui_reference_saved_memories": "Referenciar memórias guardadas", + "com_ui_reference_saved_memories_description": "Permitir que o assistente referencie e use as suas memórias guardadas ao responder", + "com_ui_refresh": "Atualizar", "com_ui_refresh_link": "Atualizar endereço", "com_ui_regenerate": "Regenerar", + "com_ui_regenerate_backup": "Regenerar Códigos de Cópia de Segurança", "com_ui_regenerating": "A Regenerar...", "com_ui_region": "Região", + "com_ui_reinitialize": "Reinicializar", + "com_ui_remove_user": "Remover {{0}}", "com_ui_rename": "Renomear", + "com_ui_rename_conversation": "Renomear Conversa", + "com_ui_rename_failed": "Falhou ao renomear conversa", "com_ui_rename_prompt": "Renomear comando", "com_ui_requires_auth": "Requer autenticação", "com_ui_reset_var": "Reiniciar {{0}}", + "com_ui_reset_zoom": "Repor Zoom", + "com_ui_resource": "recurso", "com_ui_result": "Resultado", "com_ui_revoke": "Revogar", "com_ui_revoke_info": "Revogar todas as credenciais fornecidas pelo usuário", @@ -812,21 +1076,42 @@ "com_ui_revoke_key_endpoint": "Revogar chave para {{0}}", "com_ui_revoke_keys": "Revogar chaves", "com_ui_revoke_keys_confirm": "Tem a certeza que pretende revogar todas as chaves?", + "com_ui_role": "Função", + "com_ui_role_editor": "Editor", + "com_ui_role_editor_desc": "Pode ver e modificar o agente", + "com_ui_role_manager": "Gestor", + "com_ui_role_manager_desc": "Pode visualizar, modificar e eliminar o agente", + "com_ui_role_owner": "Proprietário", + "com_ui_role_owner_desc": "Tem controlo total sobre o agente, incluindo partilhá-lo", "com_ui_role_select": "Papel", + "com_ui_role_viewer": "Visualizador", + "com_ui_role_viewer_desc": "Pode visualizar e utilizar o agente, mas não o pode modificar", "com_ui_roleplay": "Roleplay", "com_ui_run_code": "Correr código", "com_ui_run_code_error": "Ocorreu um erro ao correr o código", "com_ui_save": "Salvar", "com_ui_save_badge_changes": "Guardar alterações ao crachá?", + "com_ui_save_changes": "Guardar Alterações", "com_ui_save_submit": "Salvar & Enviar", "com_ui_saved": "Salvo!", + "com_ui_saving": "A guardar...", "com_ui_schema": "Schema", "com_ui_scope": "Scope", "com_ui_search": "Procurar", + "com_ui_search_above_to_add": "Pesquise acima para adicionar utilizadores ou grupos", + "com_ui_search_above_to_add_all": "Pesquise acima para adicionar utilizadores, grupos ou funções", + "com_ui_search_above_to_add_people": "Pesquise acima para adicionar pessoas", + "com_ui_search_agent_category": "Categorias de pesquisa...", + "com_ui_search_default_placeholder": "Pesquisar por nome ou email (mín. 2 caracteres)", + "com_ui_search_people_placeholder": "Pesquisar por pessoas ou grupos por nome ou email", + "com_ui_seconds": "segundos", "com_ui_secret_key": "Chave Secreta", "com_ui_select": "Selecionar", + "com_ui_select_all": "Selecionar Tudo", "com_ui_select_file": "Selecionar um arquivo", "com_ui_select_model": "Selecionar um modelo", + "com_ui_select_options": "Selecionar opções...", + "com_ui_select_or_create_prompt": "Selecionar ou Criar um Prompt", "com_ui_select_provider": "Selecionar um provedor", "com_ui_select_provider_first": "Selecione um provedor primeiro", "com_ui_select_region": "Escolha uma região", @@ -834,10 +1119,13 @@ "com_ui_select_search_plugin": "Pesquisar plugin por nome", "com_ui_select_search_provider": "Procurar provedor por nome", "com_ui_select_search_region": "Procurar região por nome", + "com_ui_set": "Definir", "com_ui_share": "Compartilhar", "com_ui_share_create_message": "Seu nome e quaisquer mensagens que você adicionar após o compartilhamento permanecerão privadas.", "com_ui_share_delete_error": "Houve um erro ao excluir o link compartilhado", "com_ui_share_error": "Houve um erro ao compartilhar o link do chat", + "com_ui_share_everyone": "Partilhar com todos", + "com_ui_share_everyone_description_var": "Este {{resource}} estará disponível para todos. Certifique-se de que o {{resource}} realmente se destina a ser partilhado com todos. Tenha cuidado com os seus dados.", "com_ui_share_link_to_chat": "Compartilhar link para o chat", "com_ui_share_update_message": "Seu nome, instruções personalizadas e quaisquer mensagens que você adicionar após o compartilhamento permanecerão privadas.", "com_ui_share_var": "Compartilhar {{0}}", @@ -847,30 +1135,59 @@ "com_ui_shared_prompts": "Prompts Compartilhados", "com_ui_shop": "Compras", "com_ui_show_all": "Mostrar Todos", + "com_ui_show_image_details": "Mostrar Detalhes da Imagem", + "com_ui_show_password": "Mostrar palavra-passe", "com_ui_show_qr": "Mostrar QR Code", "com_ui_sign_in_to_domain": "Autenticar em {{0}}", "com_ui_simple": "Simples", "com_ui_size": "Tamanho", + "com_ui_special_var_current_date": "Data Atual", + "com_ui_special_var_current_datetime": "Data e Hora Atual", + "com_ui_special_var_current_user": "Utilizador Atual", + "com_ui_special_var_iso_datetime": "Data e Hora ISO UTC", "com_ui_special_variables": "Variáveis especiais:", + "com_ui_special_variables_more_info": "Pode selecionar variáveis especiais a partir da lista pendente: `{{current_date}}` (data atual e dia da semana), `{{current_datetime}}` (data e hora local), `{{utc_iso_datetime}}` (data e hora ISO UTC), e `{{current_user}}` (nome da sua conta).", "com_ui_speech_while_submitting": "Não é possível submeter fala enquanto a resposta está a ser gerada.", + "com_ui_sr_actions_menu": "Abrir menu de ações para \"{{0}}\"", "com_ui_stop": "Parar", "com_ui_storage": "Armazenamento", "com_ui_submit": "Enviar", + "com_ui_support_contact": "Contacto de Suporte", + "com_ui_support_contact_email": "Email", + "com_ui_support_contact_email_invalid": "Por favor, introduza um endereço de email válido", + "com_ui_support_contact_email_placeholder": "suporte@exemplo.com", + "com_ui_support_contact_name": "Nome", + "com_ui_support_contact_name_min_length": "O nome deve ter pelo menos {{minLength}} caracteres", + "com_ui_support_contact_name_placeholder": "Nome do contacto de apoio", "com_ui_teach_or_explain": "A aprender", "com_ui_temporary": "Temporário", "com_ui_terms_and_conditions": "Termos e Condições", "com_ui_terms_of_service": "Termos de Serviço", "com_ui_thinking": "A pensar...", "com_ui_thoughts": "Raciocínio", + "com_ui_token": "token", "com_ui_token_exchange_method": "Método de troca de Token", "com_ui_token_url": "Endereço do Token", + "com_ui_tokens": "tokens", + "com_ui_tool_collection_prefix": "Uma coleção de ferramentas de", + "com_ui_tool_info": "Informações da Ferramenta", + "com_ui_tool_more_info": "Mais informações sobre esta ferramenta", "com_ui_tools": "Ferramentas", "com_ui_travel": "Viajar", + "com_ui_trust_app": "Confio nesta aplicação", + "com_ui_try_adjusting_search": "Tente ajustar os seus termos de pesquisa", + "com_ui_ui_resources": "Recursos da Interface", "com_ui_unarchive": "Desarquivar", "com_ui_unarchive_error": "Falha ao desarquivar conversa", + "com_ui_unavailable": "Indisponível", "com_ui_unknown": "Desconhecido", + "com_ui_unset": "Não definido", + "com_ui_untitled": "Sem título", "com_ui_update": "Atualizar", + "com_ui_update_mcp_error": "Houve um erro ao criar ou atualizar o MCP.", + "com_ui_update_mcp_success": "MCP criado ou atualizado com sucesso", "com_ui_upload": "Carregar", + "com_ui_upload_agent_avatar": "Avatar do agente atualizado com sucesso", "com_ui_upload_code_files": "Enviar para o interpretador de código", "com_ui_upload_delay": "O upload de \"{{0}}\" está demorando mais do que o esperado. Por favor, aguarde enquanto o arquivo termina de ser indexado para recuperação.", "com_ui_upload_error": "Houve um erro ao carregar seu arquivo", @@ -884,23 +1201,44 @@ "com_ui_upload_ocr_text": "Carregar como texto", "com_ui_upload_success": "Arquivo carregado com sucesso", "com_ui_upload_type": "Escolher tipo de Carregamento", + "com_ui_usage": "Utilização", "com_ui_use_2fa_code": "Usar Código 2FA", "com_ui_use_backup_code": "Usar Código da cópia de segurança", + "com_ui_use_memory": "Usar memória", "com_ui_use_micrphone": "Usar microfone", "com_ui_used": "Usado", + "com_ui_user": "Utilizador", + "com_ui_user_group_permissions": "Permissões de Utilizador e Grupo", + "com_ui_value": "Valor", "com_ui_variables": "Variáveis", "com_ui_variables_info": "Use chaves duplas no seu texto para criar variáveis, por exemplo, `{{exemplo de variável}}`, para preencher posteriormente ao usar o prompt.", "com_ui_verify": "Verificar", "com_ui_version_var": "Versão {{0}}", "com_ui_versions": "Versões", + "com_ui_view_memory": "Ver Memória", "com_ui_view_source": "Ver fonte da conversa", + "com_ui_web_search": "Pesquisa Web", + "com_ui_web_search_cohere_key": "Introduzir Chave API do Cohere", + "com_ui_web_search_firecrawl_url": "URL da API Firecrawl (opcional)", + "com_ui_web_search_jina_key": "Introduza a chave de API da Jina", + "com_ui_web_search_jina_url": "Jina API URL (opcional)", "com_ui_web_search_processing": "A Processar resultados", "com_ui_web_search_provider": "Procurar provedor", + "com_ui_web_search_provider_searxng": "SearXNG", + "com_ui_web_search_provider_serper": "API Serper", + "com_ui_web_search_provider_serper_key": "Obtenha a sua chave da API Serper", "com_ui_web_search_reading": "A ler resultados", + "com_ui_web_search_reranker": "Reordenador", + "com_ui_web_search_reranker_cohere": "Cohere", + "com_ui_web_search_reranker_cohere_key": "Obtenha a sua chave da API Cohere", "com_ui_web_search_reranker_jina": "Jina AI", "com_ui_web_search_reranker_jina_key": "Obter a tua chave Jina AI", + "com_ui_web_search_reranker_jina_url_help": "Saiba mais sobre a API Jina Rerank", + "com_ui_web_search_scraper": "Extractor", "com_ui_web_search_scraper_firecrawl": "API Firecrawl", "com_ui_web_search_scraper_firecrawl_key": "Obter a tua chave da API Firecrawl", + "com_ui_web_search_searxng_api_key": "Introduza a chave da API SearXNG (opcional)", + "com_ui_web_search_searxng_instance_url": "URL da instância SearXNG", "com_ui_web_searching": "A procurar na web", "com_ui_web_searching_again": "A procurar na web novamente", "com_ui_weekend_morning": "Bom fim de semana", diff --git a/client/src/locales/ru/translation.json b/client/src/locales/ru/translation.json index 3d6a6f59fa..4025d4a95c 100644 --- a/client/src/locales/ru/translation.json +++ b/client/src/locales/ru/translation.json @@ -1,29 +1,109 @@ { "chat_direction_left_to_right": "Здесь пока пусто.", + "chat_direction_right_to_left": "Здесь должно быть что-то. Пока здесь пусто.", "com_a11y_ai_composing": "ИИ продолжает составлять ответ", "com_a11y_end": "ИИ закончил свой ответ", "com_a11y_start": "ИИ начал отвечать", + "com_agents_all": "Все агенты", + "com_agents_all_category": "Все", + "com_agents_all_description": "Посмотреть всех общих агентов по всем категориям", "com_agents_by_librechat": "от LibreChat", + "com_agents_category_aftersales": "После продажи", + "com_agents_category_aftersales_description": "Агенты специализирующиеся на послепродажной поддержке, техническом обслуживании и обслуживании клиентов.", + "com_agents_category_empty": "Не найдены агенты в {{category}} категории", + "com_agents_category_finance": "Фининсы", + "com_agents_category_finance_description": "Агенты специализирующиеся на финансовом анализе, бюджетировании и бухгалтерском учете", + "com_agents_category_general": "Общий", + "com_agents_category_general_description": "Агенты общего назначения для выполнения обычных задач и запросов", + "com_agents_category_hr": "Кадры", + "com_agents_category_hr_description": "Агенты, специализирующиеся на кадровых процессах, политиках и поддержке сотрудников", + "com_agents_category_it": "Информационные технологии", + "com_agents_category_it_description": "Агенты по поддержке информационных технологий, устранению неполадок и системному администрированию", + "com_agents_category_rd": "Исследование и разработка", + "com_agents_category_rd_description": "Агенты, специализирующиеся на процессах НИОКР, инновациях и технических исследованиях", + "com_agents_category_sales": "Продажи", + "com_agents_category_sales_description": "Агенты, специализирующиеся на процессах продаж и отношениях с клиентами", + "com_agents_category_tab_label": "{{category}} категория, {{position}} в {{total}}", + "com_agents_category_tabs_label": "Категории агентов", + "com_agents_chat_with": "Чат с {{name}}", + "com_agents_clear_search": "Очистить поиск", "com_agents_code_interpreter": "При включении позволяет агенту использовать API интерпретатора кода LibreChat для безопасного выполнения сгенерированного кода, включая обработку файлов. Требуется действующий ключ 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": "Агенты не найдены", "com_agents_enable_file_search": "Включить поиск файлов", + "com_agents_error_bad_request_message": "Запрос не может быть обработан.", + "com_agents_error_bad_request_suggestion": "Пожалуйста проверьте свои ввод и попробуйте снова", + "com_agents_error_category_title": "Ошибка категрии", + "com_agents_error_generic": "Возникла проблема при загрузке контента.", + "com_agents_error_invalid_request": "Неверный запрос", + "com_agents_error_loading": "Ошибка загрузки агентов", + "com_agents_error_network_message": "Невозможно подключиться к серверу.", + "com_agents_error_network_suggestion": "Проверьте ваше подключение к интернету и попробуйте снова", + "com_agents_error_network_title": "Проблемы с подключением", + "com_agents_error_not_found_message": "Запрашиваемый контент не найден.", + "com_agents_error_not_found_suggestion": "Попробуйте просмотреть другие варианты или вернитесь на торговую площадку.", + "com_agents_error_not_found_title": "Не найдено", + "com_agents_error_retry": "Попробовать снова", + "com_agents_error_search_title": "Найти ошибку", + "com_agents_error_searching": "Ошибка поиска агентов", + "com_agents_error_server_message": "Сервер временно недоступен.", + "com_agents_error_server_suggestion": "Пожалуйста попробуйте снова через несколько минут.", + "com_agents_error_server_title": "Ошибка сервера", + "com_agents_error_suggestion_generic": "Пожалуйста попробуйте перезагрузить страницу или попробуйте позже", + "com_agents_error_timeout_message": "Запрос занял слишком много времени.", + "com_agents_error_timeout_suggestion": "Проверьте ваше интернет соединение и попробуйте", + "com_agents_error_timeout_title": "Время подключения истекло", + "com_agents_error_title": "Что-то пошло не так", + "com_agents_file_context_description": "Файлы, загруженные как «Контекст», анализируются как текст для дополнения инструкций агента. Если доступна функция OCR или она настроена для загруженного типа файла, этот процесс используется для извлечения текста. Идеально подходит для документов, изображений с текстом или PDF-файлов, где требуется полное текстовое содержание файла.", "com_agents_file_context_disabled": "Агент должен быть создан перед загрузкой файлов для контекста файла", + "com_agents_file_context_label": "Контекст файла", "com_agents_file_search_disabled": "Для загрузки файлов в Поиск необходимо сначала создать агента", "com_agents_file_search_info": "При включении агент получит доступ к точным названиям файлов, перечисленным ниже, что позволит ему извлекать из них релевантный контекст.", + "com_agents_grid_announcement": "Показано {{count}} агентов в категории {{category}}", "com_agents_instructions_placeholder": "Системные инструкции, используемые агентом", + "com_agents_link_copied": "Ссылка скопирована", + "com_agents_link_copy_failed": "Ошибка копирования ссылки", + "com_agents_load_more_label": "Загрузить больше агентов из категории {{category}}", + "com_agents_loading": "Загрузка", + "com_agents_marketplace": "Торговая площадка агентов", + "com_agents_marketplace_subtitle": "Откройте для себя и используйте мощные ИИ-агенты для улучшения рабочих процессов и повышения производительности", + "com_agents_mcp_description_placeholder": "Объясните, что он делает, в нескольких словах.", + "com_agents_mcp_icon_size": "Минимальный размер 128 x 128 px", + "com_agents_mcp_info": "Добавьте серверы MCP к вашему агенту, чтобы он мог выполнять задачи и взаимодействовать с внешними службами.", + "com_agents_mcp_name_placeholder": "Пользовательский инструмент", + "com_agents_mcp_trust_subtext": "Пользовательские коннекторы не проверяются LibreChat.", + "com_agents_mcps_disabled": "Вам нужно сперва создать агента перед добавлением MCP", + "com_agents_missing_name": "Пожалуйста введите имя перед созданием агента", "com_agents_missing_provider_model": "Выберите провайдера и модель перед созданием агента", "com_agents_name_placeholder": "Необязательно: имя агента", "com_agents_no_access": "У вас нет прав для редактирования этого агента", + "com_agents_no_agent_id_error": "Идентификатор агента не найден. Убедитесь, что агент сначала создан.", + "com_agents_no_more_results": "Вы дошли до конца результатов", "com_agents_not_available": "Агент недоступен", + "com_agents_recommended": "Наши рекомендуемые агенты", + "com_agents_results_for": "Результат для '{{query}}'", + "com_agents_search_aria": "Поиск агентов", + "com_agents_search_empty_heading": "Нет результата поиска", + "com_agents_search_info": "При включении позволяет вашему агенту искать в Интернете актуальную информацию. Требуется действительный ключ API.", + "com_agents_search_instructions": "Введите название или описание агента для поиска", "com_agents_search_name": "Поиск агентов по имени", + "com_agents_search_no_results": "Не найдено агентов для \"{{query}}\"", + "com_agents_search_placeholder": "Поиск агентов...", + "com_agents_see_more": "Посмотреть больше", + "com_agents_start_chat": "Начать чат", + "com_agents_top_picks": "Лучший выбор", "com_agents_update_error": "Произошла ошибка при обновлении вашего агента.", "com_assistants_action_attempt": "Ассистент хочет поговорить с {{0}}", "com_assistants_actions": "Действия", "com_assistants_actions_disabled": "Вам нужно сохранить ассистента, прежде чем добавлять Actions.", "com_assistants_actions_info": "Позвольте вашему ассистенту получать информацию или выполнять действия через API", "com_assistants_add_actions": "Добавить действия", + "com_assistants_add_mcp_server_tools": "Добавить инструменты сервера MCP", "com_assistants_add_tools": "Добавить инструменты", "com_assistants_allow_sites_you_trust": "Разрешайте только сайты, которым доверяете.", "com_assistants_append_date": "Добавить текущую дату и время", @@ -94,6 +174,7 @@ "com_auth_error_login_rl": "Слишком много попыток входа в систему за короткий промежуток времени. Пожалуйста, повторите попытку позже.", "com_auth_error_login_server": "Произошла внутренняя ошибка сервера. Пожалуйста, подождите несколько минут и повторите попытку.", "com_auth_error_login_unverified": "Ваша учетная запись не подтверждена. Пожалуйста, проверьте вашу электронную почту и перейдите по ссылке для подтверждения.", + "com_auth_error_oauth_failed": "Аутентификация не удалась. Проверьте способ входа в систему и повторите попытку.", "com_auth_facebook_login": "Войти с помощью Facebook", "com_auth_full_name": "Полное имя", "com_auth_github_login": "Войти с помощью Github", @@ -118,6 +199,7 @@ "com_auth_reset_password_if_email_exists": "Если аккаунт с указанным адресом существует, мы отправили на него инструкции по сбросу пароля. Пожалуйста, проверьте также папку \"Спам\".", "com_auth_reset_password_link_sent": "Письмо отправлено", "com_auth_reset_password_success": "Сброс пароля успешно выполнен", + "com_auth_saml_login": "Продолжим с SAML", "com_auth_sign_in": "Войти", "com_auth_sign_up": "Зарегистрироваться", "com_auth_submit_registration": "Отправить регистрацию", @@ -129,6 +211,8 @@ "com_auth_username_min_length": "Имя пользователя должно содержать не менее 2 символов", "com_auth_verify_your_identity": "Подтвердите ваши идентификационные данные.", "com_auth_welcome_back": "Добро пожаловать", + "com_citation_more_details": "Больше деталей о {{label}}", + "com_citation_source": "Источник", "com_click_to_download": "(нажмите для скачивания)", "com_download_expired": "срок скачивания истек", "com_download_expires": "(нажмите здесь для скачивания - срок действия до {{0}})", @@ -144,6 +228,7 @@ "com_endpoint_anthropic_thinking_budget": "Определяет максимальное количество токенов, которое Claude может использовать для режима \"Рассуждение\". Более высокий бюджет может повысить качество ответов за счёт более глубокого анализа сложных задач, хотя Claude может использовать не весь выделенный бюджет, особенно при значениях выше 32K. Этот параметр должен быть меньше, чем «Максимальное число выводимых токенов».", "com_endpoint_anthropic_topk": "Top K изменяет то, как модель выбирает токены для вывода. Top K равное 1 означает, что выбирается наиболее вероятный токен из всего словаря модели (так называемое жадное декодирование), а Top K равное 3 означает, что следующий токен выбирается из трех наиболее вероятных токенов (с использованием температуры).", "com_endpoint_anthropic_topp": "Top P изменяет то, как модель выбирает токены для вывода. Токены выбираются из наиболее вероятных (см. параметр topK) до наименее вероятных, пока сумма их вероятностей не достигнет значения top-p.", + "com_endpoint_anthropic_use_web_search": "Включите функцию веб-поиска с помощью встроенных поисковых возможностей Anthropic. Это позволит модели искать в Интернете актуальную информацию и предоставлять более точные и актуальные ответы.", "com_endpoint_assistant": "Ассистент", "com_endpoint_assistant_model": "Модель ассистента", "com_endpoint_assistant_placeholder": "Выберите ассистента в правой боковой панели", @@ -181,6 +266,8 @@ "com_endpoint_deprecated": "Больше не поддерживается", "com_endpoint_deprecated_info": "Эта конечная точка устарела и может быть удалена в будущих версиях. Пожалуйста, используйте вместо этого конечную точку агента.", "com_endpoint_deprecated_info_a11y": "Конечная точка плагина больше не поддерживается и может быть удалена в будущих версиях. Рекомендуем использовать конечную точку агента вместо этой", + "com_endpoint_disable_streaming": "Отключите потоковые ответы и получайте полный ответ сразу. Полезно для моделей типа o3, которые требуют проверки организации для потоковой передачи.", + "com_endpoint_disable_streaming_label": "Отключить потоковую передачу", "com_endpoint_examples": "Примеры", "com_endpoint_export": "Экспорт", "com_endpoint_export_share": "Экспорт/Поделиться", @@ -188,9 +275,12 @@ "com_endpoint_func_hover": "Включить использование плагинов как функции OpenAI", "com_endpoint_google_custom_name_placeholder": "Задайте кастомное имя для Google", "com_endpoint_google_maxoutputtokens": "Максимальное количество токенов, которые могут быть сгенерированы в ответе. Укажите меньшее значение для более коротких ответов и большее значение для более длинных ответов.", - "com_endpoint_google_temp": "Более высокие значения = более случайные результаты, более низкие значения = более фокусированные и детерминированные результаты. Мы рекомендуем изменять это или Top P, но не оба значения одновременно.", + "com_endpoint_google_temp": "Более высокие значения = более случайные результаты, более низкие значения = более сфокусированные и детерминированные результаты. Мы рекомендуем изменять этот параметр или Top P, но не оба одновременно.", + "com_endpoint_google_thinking": "Включает или отключает рассуждения. Эта настройка поддерживается только некоторыми моделями (серия 2.5). Для более старых моделей эта настройка может не иметь эффекта.", + "com_endpoint_google_thinking_budget": "Управляет количеством токенов мышления, используемых моделью. Фактическое количество может превышать или быть меньше этого значения в зависимости от подсказки.\n\nЭта настройка поддерживается только некоторыми моделями (серия 2.5). Gemini 2.5 Pro поддерживает 128–32 768 токенов. Gemini 2.5 Flash поддерживает 0–24 576 токенов. Gemini 2.5 Flash Lite поддерживает 512–24 576 токенов.\n\nОставьте поле пустым или установите значение «-1», чтобы модель автоматически решала, когда и сколько думать. По умолчанию Gemini 2.5 Flash Lite не думает.", "com_endpoint_google_topk": "Top-k изменяет то, как модель выбирает токены для вывода. Top-k равное 1 означает, что выбирается наиболее вероятный токен из всего словаря модели (так называемое жадное декодирование), а Top-k равное 3 означает, что следующий токен выбирается из трех наиболее вероятных токенов (с использованием температуры).", "com_endpoint_google_topp": "Top-p изменяет то, как модель выбирает токены для вывода. Токены выбираются из наиболее вероятных K (см. параметр topK) до наименее вероятных, пока сумма их вероятностей не достигнет значения top-p.", + "com_endpoint_google_use_search_grounding": "Используйте функцию Google Search Grounding для улучшения ответов с помощью результатов веб-поиска в режиме реального времени. Это позволяет моделям получать доступ к актуальной информации и предоставлять более точные и свежие ответы.", "com_endpoint_instructions_assistants": "Инструкции для ассистентов", "com_endpoint_instructions_assistants_placeholder": "Переопределяет инструкции для ассистента. Это полезно для изменения поведения для отдельного запуска.", "com_endpoint_max_output_tokens": "Максимальное количество выводимых токенов", @@ -207,7 +297,7 @@ "com_endpoint_openai_max_tokens": "Необязательное поле `max_tokens`, задающее максимальное количество токенов, которое может быть сгенерировано в ответе чата. Общая длина входных токенов и сгенерированных токенов ограничена длиной контекста модели. Вы можете получить ошибку, если это число превысит максимальную длину контекста.", "com_endpoint_openai_pres": "Число от -2.0 до 2.0. Положительные значения штрафуют новые токены на основе того, появляются ли они в тексте до сих пор, увеличивая вероятность модели говорить о новых темах.", "com_endpoint_openai_prompt_prefix_placeholder": "Задайте кастомные промпты для включения в системное сообщение. По умолчанию: нет", - "com_endpoint_openai_reasoning_effort": "Только для моделей o1: ограничивает затраты на рассуждение для моделей с поддержкой рассуждения. Снижение усилий на рассуждение может ускорить ответы и уменьшить количество токенов, используемых для размышлений.", + "com_endpoint_openai_reasoning_effort": "Только модели рассуждений: ограничивают усилия по рассуждению. Сокращение усилий по рассуждению может привести к более быстрым ответам и меньшему количеству токенов, используемых для рассуждения в ответе. «Минимальный» вариант производит очень мало токенов рассуждения для максимально быстрого времени до первого токена, что особенно хорошо подходит для кодирования и выполнения инструкций.", "com_endpoint_openai_resend": "Повторно отправить все ранее прикрепленные изображения. Примечание: это может значительно увеличить стоимость токенов, и при большом количестве прикрепленных изображений могут возникнуть ошибки.", "com_endpoint_openai_resend_files": "Повторно отправить все ранее прикрепленные файлы. Примечание: это увеличит расход токенов, и при большом количестве вложений могут возникнуть ошибки.", "com_endpoint_openai_stop": "До 4 последовательностей, после которых API прекратит генерировать дальнейшие токены.", @@ -215,6 +305,7 @@ "com_endpoint_openai_topp": "Альтернатива выбору с использованием температуры, называемая выбором по ядру, при которой модель учитывает результаты токенов с наибольшей вероятностью top_p. Таким образом, значение 0,1 означает, что рассматриваются только токены, составляющие верхние 10% вероятностной массы. Мы рекомендуем изменять это или температуру, но не оба значения одновременно.", "com_endpoint_openai_use_responses_api": "Используйте Responses API вместо завершения чата, который включает расширенные функции от OpenAI. Требуется для o1-pro, o3-pro и для включения обобщений рассуждений.", "com_endpoint_openai_use_web_search": "Включите функцию веб-поиска с помощью встроенных поисковых возможностей OpenAI. Это позволит модели искать в Интернете актуальную информацию и предоставлять более точные и свежие ответы.", + "com_endpoint_openai_verbosity": "Ограничивает подробность ответа модели. Более низкие значения приводят к более лаконичным ответам, а более высокие — к более подробным. В настоящее время поддерживаются значения «низкий», «средний» и «высокий».", "com_endpoint_output": "Вывод", "com_endpoint_plug_image_detail": "Детали изображения", "com_endpoint_plug_resend_files": "Повторить отправку файлов", @@ -223,6 +314,7 @@ "com_endpoint_plug_use_functions": "Использовать функции", "com_endpoint_presence_penalty": "Штраф за присутствие", "com_endpoint_preset": "пресет", + "com_endpoint_preset_custom_name_placeholder": "здесь должно быть что-то. было пусто", "com_endpoint_preset_default": "теперь пресет \"По умолчаанию\".", "com_endpoint_preset_default_item": "По умолчанию:", "com_endpoint_preset_default_none": "Активных пресетов по умолчанию нет.", @@ -262,25 +354,45 @@ "com_endpoint_top_p": "Top P", "com_endpoint_use_active_assistant": "Использовать активного ассистента", "com_endpoint_use_responses_api": "Использовать Responses API", + "com_endpoint_use_search_grounding": "Проверка через Google Поиск", + "com_endpoint_verbosity": "Многословность", + "com_error_endpoint_models_not_loaded": "Не удалось загрузить модели для {{0}}. Обновите страницу и попробуйте еще раз.", "com_error_expired_user_key": "Предоставленный ключ для {{0}} истек {{1}}. Пожалуйста, укажите новый ключ и повторите попытку.", "com_error_files_dupe": "Обнаружен дублирующийся файл", "com_error_files_empty": "Пустые файлы не допускаются", "com_error_files_process": "Произошла ошибка при обработке файла", - "com_error_files_unsupported_capability": "Отсутствуют разрешения для работы с данным типом файлов", "com_error_files_upload": "При загрузке файла произошла ошибка", "com_error_files_upload_canceled": "Запрос на загрузку файла был отменен. Примечание: файл все еще может обрабатываться и потребуется удалить его вручную.", "com_error_files_validation": "Произошла ошибка при проверке файла", - "com_error_input_length": "Последнее сообщение слишком длинное и превышает допустимый лимит токенов ({{0}}). Пожалуйста, сократите сообщение, измените максимальный размер контекста в параметрах беседы или создайте ответвление беседы для продолжения.", + "com_error_google_tool_conflict": "Использование встроенных инструментов Google не поддерживается с внешними инструментами. Пожалуйста, отключите либо встроенные инструменты, либо внешние инструменты.", + "com_error_heic_conversion": "Не удалось преобразовать изображение HEIC в JPEG. Попробуйте преобразовать изображение вручную или используйте другой формат.", + "com_error_illegal_model_request": "Модель «{{0}}» недоступна для {{1}}. Выберите другую модель.", + "com_error_input_length": "Количество токенов в последнем сообщении слишком велико, превышает ограничение по токенам, либо параметры ограничения по токенам настроены неверно, что негативно влияет на окно контекста. Дополнительная информация: {{0}}. Сократите сообщение, настройте максимальный размер контекста в параметрах разговора или разделите разговор, чтобы продолжить.", "com_error_invalid_agent_provider": "Провайдер «{{0}}» недоступен для использования с агентами. Перейдите в настройки агента и выберите доступного провайдера.", "com_error_invalid_user_key": "Предоставлен некорректный ключ. Пожалуйста, укажите действительный ключ и повторите попытку.", + "com_error_missing_model": "Модель {{0}} не выбрана. Выберите модель и повторите попытку.", + "com_error_models_not_loaded": "Не удалось загрузить конфигурацию моделей. Обновите страницу и повторите попытку.", "com_error_moderation": "К сожалению, отправленный вами контент был помечен нашей системой модерации как не соответствующий правилам сообщества. Мы не можем продолжить обсуждение этой конкретной темы. Если у вас есть другие вопросы или темы, которые вы хотели бы обсудить, пожалуйста, отредактируйте сообщение или начните новый диалог.", "com_error_no_base_url": "Базовый URL не найден. Пожалуйста, укажите его и повторите попытку.", "com_error_no_user_key": "Ключ не найден. Пожалуйста, укажите ключ и повторите попытку.", + "com_file_pages": "Страниц: {{pages}}", + "com_file_source": "Файл", + "com_file_unknown": "Неизвестный фал", + "com_files_download_failed": "{{0}} файлов не удалось обработать", + "com_files_download_percent_complete": "{{0}}% выполенено", + "com_files_download_progress": "{{0}} из {{1}} файлов", + "com_files_downloading": "Загрузка файла", "com_files_filter": "Фильтр файлов", "com_files_no_results": "Нет результатов", "com_files_number_selected": "Выбрано {{0}} из {{1}} файл(а/ов)", + "com_files_preparing_download": "Подготовка загрузки...", + "com_files_sharepoint_picker_title": "Выбрать файлы", + "com_files_table": "здесь должно быть что-то. было пусто", + "com_files_upload_local_machine": "С локального компьютера", + "com_files_upload_sharepoint": "Из SharePoint", "com_generated_files": "Сгенерированные файлы:", "com_hide_examples": "Скрыть примеры", + "com_info_heic_converting": "Конвертирование изображения HEIC в JPEG...", "com_nav_2fa": "Двухфакторная аутентификация (2FA)", "com_nav_account_settings": "Настройки аккаунта", "com_nav_always_make_prod": "Автоматически публиковать новые версии", @@ -294,7 +406,6 @@ "com_nav_auto_scroll": "Автоматически проматывать к самым новым сообщениям при открытии", "com_nav_auto_send_prompts": "Автоотправка промптов", "com_nav_auto_send_text": "Автоотправка сообщений", - "com_nav_auto_send_text_disabled": "установите -1 для отключения", "com_nav_auto_transcribe_audio": "Автоматическая транскрипция", "com_nav_automatic_playback": "Автовоспроизведение последнего сообщения", "com_nav_balance": "Баланс", @@ -363,9 +474,10 @@ "com_nav_font_size_xs": "Очень мелкий", "com_nav_help_faq": "Помощь и Вопросы", "com_nav_hide_panel": "Скрыть правую боковую панель", + "com_nav_info_balance": "Баланс показывает, сколько токенов у вас осталось. Токены переводятся в денежную стоимость (например, 1000 токенов = 0,001 доллара США).", "com_nav_info_code_artifacts": "Включает отображение экспериментального программного кода рядом с чатом", "com_nav_info_code_artifacts_agent": "Включает использование артефактов кода для этого агента. По умолчанию добавляются дополнительные инструкции, связанные с использованием артефактов, если не включен режим «Пользовательский промт».", - "com_nav_info_custom_prompt_mode": "При включении этого режима системный промт по умолчанию для создания артефактов не будет использоваться. Все инструкции для генерации артефактов должны задаваться вручную.", + "com_nav_info_custom_prompt_mode": "При включении этого режима системный промт для создания артефактов по умолчанию не будет использован. Все инструкции для генерации артефактов должны задаваться вручную.", "com_nav_info_enter_to_send": "Если включено, нажатие клавиши Enter отправит ваше сообщение. Если отключено, Enter добавит новую строку, а для отправки сообщения нужно будет нажать CTRL + Enter или ⌘ + Enter.", "com_nav_info_fork_change_default": "«Только видимые сообщения» включает лишь прямой путь к выбранному сообщению. «Включить связанные ветки» добавляет ответвления вдоль этого пути. «Включить все сообщения до/от этой точки» охватывает все связанные сообщения и ветки.", "com_nav_info_fork_split_target_setting": "Если включено, ветвление будет выполняться от целевого сообщения до последнего сообщения в диалоге в соответствии с выбранным поведением.", @@ -376,7 +488,9 @@ "com_nav_info_show_thinking": "Если включено, выпадающие блоки рассуждений в чате будут открыты по умолчанию, позволяя видеть ход рассуждений ИИ в реальном времени. Если отключено, блоки рассуждений будут скрыты по умолчанию для более упрощённого интерфейса.", "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": "Каталанский", "com_nav_lang_chinese": "中文", @@ -395,21 +509,31 @@ "com_nav_lang_italian": "Italiano", "com_nav_lang_japanese": "日本語", "com_nav_lang_korean": "한국어", + "com_nav_lang_latvian": "Латышский язык", + "com_nav_lang_norwegian_bokmal": "Норвежский язык (букмол)", "com_nav_lang_persian": "Фарси", "com_nav_lang_polish": "Polski", "com_nav_lang_portuguese": "Português", "com_nav_lang_russian": "Русский", + "com_nav_lang_slovenian": "Словенский язык", "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": "Уйгурский язык", "com_nav_lang_vietnamese": "Tiếng Việt", "com_nav_language": "Локализация", "com_nav_latex_parsing": "Обработка LaTeX в сообщениях (может повлиять на производительность)", "com_nav_log_out": "Выйти", "com_nav_long_audio_warning": "Обработка длинных текстов займет больше времени", "com_nav_maximize_chat_space": "Развернуть чат", + "com_nav_mcp_configure_server": "Настроить {{0}}", + "com_nav_mcp_status_connecting": "{{0}} - подключение", + "com_nav_mcp_vars_update_error": "Ошибка при обновлении пользовательских переменных MCP", + "com_nav_mcp_vars_updated": "Пользовательские переменные MCP успешно обновлены.", "com_nav_modular_chat": "Разрешить менять точки подключения в середине разговора", "com_nav_my_files": "Мои файлы", "com_nav_not_supported": "Не поддерживается", @@ -433,6 +557,7 @@ "com_nav_setting_chat": "Чат", "com_nav_setting_data": "Управление данными", "com_nav_setting_general": "Общие", + "com_nav_setting_mcp": "Настройки MCP", "com_nav_setting_personalization": "Персонализация", "com_nav_setting_speech": "Голос", "com_nav_settings": "Настройки", @@ -451,6 +576,7 @@ "com_nav_tool_dialog": "Инструменты ассистента", "com_nav_tool_dialog_agents": "Инструменты агента", "com_nav_tool_dialog_description": "Ассистент должен быть сохранен для применения выбранных инструментов.", + "com_nav_tool_dialog_mcp_server_tools": "Инструменты сервера MCP", "com_nav_tool_remove": "Удалить", "com_nav_tool_search": "Поиск инструментов", "com_nav_user": "ПОЛЬЗОВАТЕЛЬ", @@ -466,7 +592,26 @@ "com_sidepanel_conversation_tags": "Закладки", "com_sidepanel_hide_panel": "Скрыть панель", "com_sidepanel_manage_files": "Управление файлами", + "com_sidepanel_mcp_no_servers_with_vars": "Нет серверов MCP с настраиваемыми переменными.", "com_sidepanel_parameters": "Параметры", + "com_sources_agent_file": "Исходный документ", + "com_sources_agent_files": "Файлы агента", + "com_sources_download_aria_label": "Загрузка {{filename}}{{status}}", + "com_sources_download_failed": "Ошибка загрузки", + "com_sources_download_local_unavailable": "Невозможно загрузить: файл не сохранен", + "com_sources_downloading_status": "(загрузка...)", + "com_sources_error_fallback": "Невозможно загрузить источники", + "com_sources_image_alt": "Изображение результата поиска", + "com_sources_more_files": "+{{count}} файлов", + "com_sources_more_sources": "+{{count}} источников", + "com_sources_pages": "Страниц", + "com_sources_region_label": "Результаты поиска и источники", + "com_sources_reload_page": "Перезагрузка страницы", + "com_sources_tab_all": "Все", + "com_sources_tab_files": "Файлы", + "com_sources_tab_images": "Изображения", + "com_sources_tab_news": "Новости", + "com_sources_title": "Источники", "com_ui_2fa_account_security": "Двухфакторная аутентификация добавляет дополнительный уровень защиты вашему аккаунту.", "com_ui_2fa_disable": "Отключить 2FA", "com_ui_2fa_disable_error": "Произошла ошибка при отключении двухфакторной аутентификации.", @@ -479,7 +624,10 @@ "com_ui_2fa_verified": "Двухфакторная аутентификация успешно подтверждена", "com_ui_accept": "Принимаю", "com_ui_action_button": "Кнопка действия", + "com_ui_active": "Активный", "com_ui_add": "Добавить", + "com_ui_add_mcp": "Добавить MCP", + "com_ui_add_mcp_server": "Добавить MCP сервер", "com_ui_add_model_preset": "Добавить модель или пресет для дополнительного ответа", "com_ui_add_multi_conversation": "Добавить несколько бесед", "com_ui_adding_details": "Добавление деталей", @@ -489,6 +637,14 @@ "com_ui_advanced": "Расширенные", "com_ui_advanced_settings": "Дополнительные настройки", "com_ui_agent": "Агент", + "com_ui_agent_category_aftersales": "Послепродажное обслуживание", + "com_ui_agent_category_finance": "Финансы", + "com_ui_agent_category_general": "Общее", + "com_ui_agent_category_hr": "Кадры", + "com_ui_agent_category_it": "Информационные технологии", + "com_ui_agent_category_rd": "Научные исследования и разработки", + "com_ui_agent_category_sales": "Продажи", + "com_ui_agent_category_selector_aria": "Селектор категории агента", "com_ui_agent_chain": "Цепочка агентов (\"Mixture-of-Agents\")", "com_ui_agent_chain_info": "Позволяет создавать последовательности агентов, где каждый агент может использовать результаты работы предыдущих агентов в цепочке. Основано на архитектуре «Смешение агентов» (Mixture-of-Agents), в которой агенты используют предыдущие результаты в качестве вспомогательной информации.", "com_ui_agent_chain_max": "Вы достигли максимального количества агентов: {{0}}.", @@ -496,11 +652,27 @@ "com_ui_agent_deleted": "Ассистент успешно удален", "com_ui_agent_duplicate_error": "Произошла ошибка при дублировании ассистента", "com_ui_agent_duplicated": "Ассистент успешно скопирован", + "com_ui_agent_name_is_required": "Имя агента является обязательным", "com_ui_agent_recursion_limit": "Максимальное количество шагов агента", "com_ui_agent_recursion_limit_info": "Ограничивает количество шагов, которые агент может выполнить за один запуск перед выдачей окончательного ответа. Значение по умолчанию — 25 шагов. Шагом считается либо запрос к API ИИ, либо использование инструмента. Например, базовое взаимодействие с инструментом включает 3 шага: исходный запрос, использование инструмента и последующий запрос.", + "com_ui_agent_url_copied": "URL агента скопирован в буфер обмена", "com_ui_agent_var": "{{0}} агент", + "com_ui_agent_version": "Версия", + "com_ui_agent_version_active": "Активная версия", + "com_ui_agent_version_empty": "Нет доступных версий", + "com_ui_agent_version_error": "Ошибка при получении версий", + "com_ui_agent_version_history": "История версий", + "com_ui_agent_version_no_agent": "Агент не выбран. Выберите агента, чтобы просмотреть историю версий.", + "com_ui_agent_version_no_date": "Дата не доступна", + "com_ui_agent_version_restore": "Восстановить", + "com_ui_agent_version_restore_confirm": "Вы уверены что хотите восстановить данную версию?", + "com_ui_agent_version_restore_error": "Ошбика восстановления версии", + "com_ui_agent_version_restore_success": "Версия восстановлена успешно", + "com_ui_agent_version_title": "Версия {{versionNumber}}", + "com_ui_agent_version_unknown_date": "Неизвестная дата", "com_ui_agents": "Агенты", "com_ui_agents_allow_create": "Разрешить создание ассистентов", + "com_ui_agents_allow_share": "Разрешить совместное использование агентов", "com_ui_agents_allow_use": "Разрешить использование ассистентов", "com_ui_all": "все", "com_ui_all_proper": "Все", @@ -512,6 +684,7 @@ "com_ui_archive_error": "Не удалось заархивировать чат", "com_ui_artifact_click": "Нажмите, чтобы открыть", "com_ui_artifacts": "Артефакты", + "com_ui_artifacts_options": "Параметры артефактов", "com_ui_artifacts_toggle": "Показать/скрыть артефакты", "com_ui_artifacts_toggle_agent": "Включить артефакты", "com_ui_ascending": "По возрастанию", @@ -520,6 +693,7 @@ "com_ui_assistant_deleted": "Ассистент успешно удален", "com_ui_assistants": "Ассистенты", "com_ui_assistants_output": "Вывод ассистентов", + "com_ui_at_least_one_owner_required": "Требуется как минимум один владелец", "com_ui_attach_error": "Невозможно прикрепить файл. Создайте новый или выберите разговор, или попробуйте обновить страницу.", "com_ui_attach_error_openai": "Невозможно прикрепить файлы ассистента к другим режимам", "com_ui_attach_error_size": "Превышен лимит размера файла для этого режима:", @@ -529,16 +703,24 @@ "com_ui_attachment": "Вложение", "com_ui_auth_type": "Тип аутентификации", "com_ui_auth_url": "URL авторизации", + "com_ui_authenticate": "Аутентификация", "com_ui_authentication": "Аутентификация", "com_ui_authentication_type": "Тип аутентификации", "com_ui_auto": "Авто", + "com_ui_available_tools": "Доступные инструменты", "com_ui_avatar": "Аватар", "com_ui_azure": "Azure", + "com_ui_azure_ad": "Entra ID", + "com_ui_back": "Назад", "com_ui_back_to_chat": "Вернуться к чату", "com_ui_back_to_prompts": "Вернуться к промтам", + "com_ui_backup_code_number": "Код #{{number}}", "com_ui_backup_codes": "Резервные коды", "com_ui_backup_codes_regenerate_error": "Произошла ошибка при повторной генерации резервных кодов", "com_ui_backup_codes_regenerated": "Резервные коды успешно сгенерированы повторно", + "com_ui_backup_codes_security_info": "В целях безопасности резервные коды отображаются только один раз после их генерации. Сохраните их в надежном месте.", + "com_ui_backup_codes_status": "Статус резервных кодов", + "com_ui_basic": "Базовый", "com_ui_basic_auth_header": "Заголовок базовой авторизации", "com_ui_bearer": "Токен на предъявителя", "com_ui_bookmark_delete_confirm": "Вы уверены, что хотите удалить эту закладку?", @@ -556,12 +738,14 @@ "com_ui_bookmarks_edit": "Редактировать закладку", "com_ui_bookmarks_filter": "Поиск в закладках...", "com_ui_bookmarks_new": "Новая закладка", + "com_ui_bookmarks_tag_exists": "Закладка с таким названием уже существует.", "com_ui_bookmarks_title": "Заголовок", "com_ui_bookmarks_update_error": "Произошла ошибка при обновлении закладки", "com_ui_bookmarks_update_success": "Закладка успешно обновлена", "com_ui_bulk_delete_error": "Не удалось удалить общие ссылки", "com_ui_callback_url": "URL обратного вызова", "com_ui_cancel": "Отмена", + "com_ui_cancelled": "Отменен", "com_ui_category": "Категория", "com_ui_chat": "Чат", "com_ui_chat_history": "История чатов", @@ -571,17 +755,22 @@ "com_ui_client_secret": "Секрет клиента", "com_ui_close": "Закрыть", "com_ui_close_menu": "Закрыть меню", + "com_ui_close_window": "Закрыть окно\n", "com_ui_code": "Код", "com_ui_collapse_chat": "Свернуть чат", "com_ui_command_placeholder": "Необязательно: введите команду для промта или будет использовано название", "com_ui_command_usage_placeholder": "Выберите промпт по команде или названию", "com_ui_complete_setup": "Завершить настройку", "com_ui_concise": "Кратко", + "com_ui_configure_mcp_variables_for": "Настроить переменные для {{0}}", + "com_ui_confirm": "Подтвердить", "com_ui_confirm_action": "Подтвердить действие", "com_ui_confirm_admin_use_change": "Изменение этого параметра заблокирует доступ для администраторов, включая вас. Вы уверены, что хотите продолжить?", "com_ui_confirm_change": "Подтвердить изменения", + "com_ui_connecting": "Соединение", "com_ui_context": "Контекст", "com_ui_continue": "Продолжить", + "com_ui_continue_oauth": "Продолжить с OAuth", "com_ui_controls": "Управление", "com_ui_convo_delete_error": "Не удалось удалить чат", "com_ui_copied": "Скопировано", @@ -589,10 +778,13 @@ "com_ui_copy_code": "Копировать код", "com_ui_copy_link": "Скопировать ссылку", "com_ui_copy_to_clipboard": "Копировать в буфер обмена", + "com_ui_copy_url_to_clipboard": "Скопировать ссылку в буфер обмена", "com_ui_create": "Создать", "com_ui_create_link": "Создать ссылку", + "com_ui_create_memory": "Очистить память", "com_ui_create_prompt": "Создать промт", "com_ui_creating_image": "Создается изображение. Подождите немного.", + "com_ui_current": "Текущий", "com_ui_currently_production": "В настоящее время в продакшене", "com_ui_custom": "Настраиваемый", "com_ui_custom_header_name": "Настраиваемое имя заголовка", @@ -625,13 +817,25 @@ "com_ui_delete_confirm": "Будет удален следующий чат: ", "com_ui_delete_confirm_prompt_version_var": "Это действие удалит выбранную версию для '{{0}}'. Если других версий не существует, промт будет полностью удален.", "com_ui_delete_conversation": "Удалить чат?", + "com_ui_delete_mcp": "Удалить MCP", + "com_ui_delete_mcp_confirm": "Вы уверены что хотите удалить данный MCP сервер?", + "com_ui_delete_mcp_error": "Не удалось удалить сервер MCP", + "com_ui_delete_mcp_success": "Сервер MCP удален успешно", + "com_ui_delete_memory": "Удалить память", + "com_ui_delete_not_allowed": "Операция удаления не допускается", "com_ui_delete_prompt": "Удалить промт?", "com_ui_delete_shared_link": "Удалить общую ссылку?", + "com_ui_delete_success": "Успешное удаление", "com_ui_delete_tool": "Удалить инструмент", "com_ui_delete_tool_confirm": "Вы действительно хотите удалить этот инструмент?", + "com_ui_delete_tool_error": "Ошибка при удалении инструмента: {{error}}", + "com_ui_delete_tool_save_reminder": "Инструмент удален. Сохраните агент, чтобы применить изменения.", + "com_ui_deleted": "Удалено", + "com_ui_deleting_file": "Удаление файла...", "com_ui_descending": "По убыванию", "com_ui_description": "Описание", "com_ui_description_placeholder": "Дополнительно: введите описание для промта", + "com_ui_deselect_all": "Отменить выбор всего", "com_ui_detailed": "Подробно", "com_ui_disabling": "Отключение...", "com_ui_download": "Скачать", @@ -639,6 +843,7 @@ "com_ui_download_backup": "Скачать резервные коды", "com_ui_download_backup_tooltip": "Прежде чем продолжить, скачайте ваши резервные коды. Они понадобятся вам для восстановления доступа в случае утери устройства аутентификации", "com_ui_download_error": "Ошибка загрузки файла. Возможно, файл был удален.", + "com_ui_drag_drop": "Перетащите любой файл сюда, чтобы добавить его в разговор", "com_ui_dropdown_variables": "Выпадающие переменные:", "com_ui_dropdown_variables_info": "Создавайте пользовательские выпадающие списки для ваших промптов: `{{название_переменной:вариант1|вариант2|вариант3}}`", "com_ui_duplicate": "Дублировать", @@ -646,18 +851,46 @@ "com_ui_duplication_processing": "Создание копии беседы...", "com_ui_duplication_success": "Разговор успешно скопирован", "com_ui_edit": "Редактировать", + "com_ui_edit_editing_image": "Редактирование изображения", + "com_ui_edit_mcp_server": "Редактировать MCP сервер", + "com_ui_edit_memory": "Редактировать память", + "com_ui_empty_category": "-", "com_ui_endpoint": "Эндпоинт", "com_ui_endpoint_menu": "Меню настроек LLM", "com_ui_enter": "Ввести", "com_ui_enter_api_key": "Введите API-ключ", + "com_ui_enter_key": "Вставьте ключ", "com_ui_enter_openapi_schema": "Введите вашу OpenAPI схему", + "com_ui_enter_value": "Вставьте значение", "com_ui_error": "Ошибка", "com_ui_error_connection": "Ошибка подключения к серверу. Попробуйте обновить страницу.", "com_ui_error_save_admin_settings": "Произошла ошибка при сохранении настроек администратора", + "com_ui_error_updating_preferences": "Ошибка при обновлении настроек\n", + "com_ui_everyone_permission_level": "Уровень разрешений для всех", "com_ui_examples": "Примеры", "com_ui_expand_chat": "Развернуть чат", "com_ui_export_convo_modal": "Экспорт беседы", + "com_ui_feedback_more": "Больше...", + "com_ui_feedback_more_information": "Предоставить дополнительную обратную связь", + "com_ui_feedback_negative": "Требует улучшения", + "com_ui_feedback_placeholder": "Пожалуйста, оставьте здесь дополнительные отзывы", + "com_ui_feedback_positive": "Обожаю это", + "com_ui_feedback_tag_accurate_reliable": "Точный и надежный", + "com_ui_feedback_tag_attention_to_detail": "Внимание к деталям", + "com_ui_feedback_tag_bad_style": "Плохой стиль или тон", + "com_ui_feedback_tag_clear_well_written": "Ясно и хорошо написано", + "com_ui_feedback_tag_creative_solution": "Креативное решение", + "com_ui_feedback_tag_inaccurate": "Неточный или неправильный ответ", + "com_ui_feedback_tag_missing_image": "Ожидаемое изображение", + "com_ui_feedback_tag_not_helpful": "Не хватало полезной информации", + "com_ui_feedback_tag_not_matched": "Не соответствовало моему запросу", + "com_ui_feedback_tag_other": "Другой вопрос", + "com_ui_feedback_tag_unjustified_refusal": "Отказ без объяснения причин", + "com_ui_field_max_length": "{{field}} должно содержать менее {{length}} символов", "com_ui_field_required": "Это поле обязательно для заполнения", + "com_ui_file_size": "Размер файла", + "com_ui_file_token_limit": "Ограничение на количество токенов файла", + "com_ui_file_token_limit_desc": "Установите максимальный лимит токенов для обработки файлов, чтобы контролировать затраты и использование ресурсов.", "com_ui_files": "Файлы", "com_ui_filter_prompts": "Фильтр промтов", "com_ui_filter_prompts_name": "Фильтровать промты по названию", @@ -669,6 +902,7 @@ "com_ui_fork_change_default": "Изменить вариант ветвления по умолчанию", "com_ui_fork_default": "Использовать вариант по умолчанию", "com_ui_fork_error": "Произошла ошибка при создании ответвления разговора", + "com_ui_fork_error_rate_limit": "Слишком много запросов на разветвление. Повторите попытку позже.", "com_ui_fork_from_message": "Выберите вариант ответвления", "com_ui_fork_info_1": "Используйте эту настройку для разделения сообщений с нужным поведением.", "com_ui_fork_info_2": "\"Форкинг\" означает создание новой ветви разговора, которая начинается или заканчивается на определенных сообщениях текущего разговора, создавая копию в соответствии с выбранными параметрами.", @@ -690,18 +924,25 @@ "com_ui_fork_visible": "Только видимые сообщения", "com_ui_generate_qrcode": "Сгенерировать QR-код", "com_ui_generating": "Генерация...", + "com_ui_generation_settings": "Генерация настроек", "com_ui_getting_started": "Начало работы", + "com_ui_global_group": "здесь должно быть что-то. было пусто", "com_ui_go_back": "Назад", "com_ui_go_to_conversation": "Перейти к беседе", "com_ui_good_afternoon": "Добрый день", "com_ui_good_evening": "Добрый вечер", "com_ui_good_morning": "Доброе утро", + "com_ui_group": "Группа", "com_ui_happy_birthday": "Это мой первый день рождения!", + "com_ui_hide_image_details": "Скрыть детали изображения", + "com_ui_hide_password": "Скрыть пароль", "com_ui_hide_qr": "Скрыть QR код", "com_ui_high": "Высокое", "com_ui_host": "Хост", + "com_ui_icon": "Иконка", "com_ui_idea": "Идеи", "com_ui_image_created": "Изображение создано", + "com_ui_image_details": "Сведения об изображении", "com_ui_image_edited": "Изображение обновлено", "com_ui_image_gen": "Генератор изображений", "com_ui_import": "Импорт", @@ -710,8 +951,10 @@ "com_ui_import_conversation_info": "Импортировать беседы из файла JSON", "com_ui_import_conversation_success": "Беседы успешно импортированы", "com_ui_include_shadcnui": "Включить компоненты shadcn/ui", + "com_ui_initializing": "Инициализация...", "com_ui_input": "Ввод", "com_ui_instructions": "Инструкции", + "com_ui_key": "Ключ", "com_ui_late_night": "Доброй ночи", "com_ui_latest_footer": "Искусственный интеллект для каждого", "com_ui_latest_production_version": "Последняя рабочая версия", @@ -724,17 +967,45 @@ "com_ui_logo": "Логотип {{0}}", "com_ui_low": "Низкое", "com_ui_manage": "Управление", + "com_ui_marketplace": "Торговая площадка", + "com_ui_marketplace_allow_use": "Разрешить использование Marketplace\n", "com_ui_max_tags": "Максимально допустимое количество - {{0}}, используются последние значения.", + "com_ui_mcp_authenticated_success": "Сервер MCP «{{0}}» успешно прошел аутентификацию", + "com_ui_mcp_configure_server": "Настроить {{0}}", + "com_ui_mcp_configure_server_description": "Настройте пользовательские переменные для {{0}}", + "com_ui_mcp_enter_var": "Введите значение для {{0}}", + "com_ui_mcp_init_failed": "Не удалось инициализировать сервер MCP", + "com_ui_mcp_initialize": "Инициализировать", + "com_ui_mcp_initialized_success": "Сервер MCP «{{0}}» успешно инициализирован", + "com_ui_mcp_oauth_cancelled": "Вход через OAuth отменен для {{0}}", + "com_ui_mcp_oauth_timeout": "Время ожидания входа в систему OAuth истекло для {{0}}", + "com_ui_mcp_server_not_found": "Сервер не найден.", "com_ui_mcp_servers": "MCP серверы", + "com_ui_mcp_update_var": "Обновление {{0}}", + "com_ui_mcp_url": "URL-адрес сервера MCP", "com_ui_medium": "Средний", + "com_ui_memories": "Воспоминания", "com_ui_memories_allow_create": "Разрешить создание памяти", "com_ui_memories_allow_opt_out": "Разрешить пользователям отказаться от памяти", + "com_ui_memories_allow_read": "Разрешить чтение воспоминаний", "com_ui_memories_allow_update": "Разрешить обновление памяти", "com_ui_memories_allow_use": "Разрешить использование памяти", "com_ui_memories_filter": "Отфильтровать память", "com_ui_memory": "Память", + "com_ui_memory_already_exceeded": "Память уже заполнена — превышено {{tokens}} токенов. Удалите существующие воспоминания, прежде чем добавлять новые.", + "com_ui_memory_created": "Память создана успешно", + "com_ui_memory_deleted": "Память удалена", + "com_ui_memory_deleted_items": "Удаленные воспоминания", + "com_ui_memory_error": "Ошибка памяти", + "com_ui_memory_key_exists": "Память с этим ключом уже существует. Пожалуйста, используйте другой ключ.", + "com_ui_memory_key_validation": "Ключ памяти должен содержать только строчные буквы и символы подчеркивания.", + "com_ui_memory_storage_full": "Память заполнена", + "com_ui_memory_updated": "Обновленная сохраненная память", + "com_ui_memory_updated_items": "Обновленные воспоминания", + "com_ui_memory_would_exceed": "Невозможно сохранить — превышение лимита на {{tokens}} токенов. Удалите существующие воспоминания, чтобы освободить место.", "com_ui_mention": "Упомянуть конечную точку, помощника или предустановку для быстрого переключения", "com_ui_min_tags": "Нельзя удалить больше значений, требуется минимум {{0}}.", + "com_ui_minimal": "Минимальный", "com_ui_misc": "Разное", "com_ui_model": "Модель", "com_ui_model_parameters": "Параметры модели", @@ -747,17 +1018,47 @@ "com_ui_next": "Следующий", "com_ui_no": "Нет", "com_ui_no_bookmarks": "Похоже, у вас пока нет закладок. Выберите чат и добавьте новую закладку", + "com_ui_no_categories": "Нет доступных категорий", "com_ui_no_category": "Без категории", + "com_ui_no_changes": "Изменения не вносились", + "com_ui_no_data": "здесь должно быть что-то. было пусто", + "com_ui_no_individual_access": "Ни один отдельный пользователь или группа не имеют доступа к этому агенту.", + "com_ui_no_memories": "Нет воспоминаний. Создайте их вручную или попросите ИИ что-нибудь запомнить.", + "com_ui_no_personalization_available": "В настоящее время нет доступных опций персонализации.", + "com_ui_no_read_access": "У вас нет разрешения на просмотр воспоминаний", + "com_ui_no_results_found": "Результаты не найдены", "com_ui_no_terms_content": "Нет содержания условий использования для отображения", + "com_ui_no_valid_items": "здесь должно быть что-то. было пусто", "com_ui_none": "Пусто", "com_ui_not_used": "Не используется", "com_ui_nothing_found": "Ничего не найдено", "com_ui_oauth": "OAuth", + "com_ui_oauth_connected_to": "Подключено к", + "com_ui_oauth_error_callback_failed": "Ошибка обратного вызова аутентификации. Повторите попытку.", + "com_ui_oauth_error_generic": "Аутентификация не удалась. Повторите попытку.", + "com_ui_oauth_error_invalid_state": "Недопустимый параметр состояния. Повторите попытку.", + "com_ui_oauth_error_missing_code": "Отсутствует код авторизации. Повторите попытку.", + "com_ui_oauth_error_missing_state": "Параметр состояния отсутствует. Повторите попытку.", + "com_ui_oauth_error_title": "Аутентификация не удалась", + "com_ui_oauth_revoke": "Отменить", + "com_ui_oauth_success_description": "Ваша аутентификация прошла успешно. Это окно закроется через", + "com_ui_oauth_success_title": "Аутентификация прошла успешно", "com_ui_of": "из", "com_ui_off": "Выкл.", + "com_ui_offline": "В автономном режиме", "com_ui_on": "Вкл.", "com_ui_openai": "OpenAI", + "com_ui_optional": "(по желанию)", "com_ui_page": "Страница", + "com_ui_people": "люди", + "com_ui_people_picker": "Выбор людей", + "com_ui_people_picker_allow_view_groups": "Разрешить просмотр групп", + "com_ui_people_picker_allow_view_roles": "Разрешить просмотр ролей", + "com_ui_people_picker_allow_view_users": "Разрешить просмотр пользователям", + "com_ui_permissions_failed_load": "Не удалось загрузить разрешения. Повторите попытку.", + "com_ui_permissions_failed_update": "Не удалось обновить разрешения. Повторите попытку.", + "com_ui_permissions_updated_success": "Разрешения успешно обновлены", + "com_ui_preferences_updated": "Настройки успешно обновлены", "com_ui_prev": "Предыдущий", "com_ui_preview": "Предпросмотр", "com_ui_privacy_policy": "Политика конфиденциальности", @@ -771,23 +1072,30 @@ "com_ui_prompt_update_error": "Произошла ошибка при обновлении промта", "com_ui_prompts": "Промты", "com_ui_prompts_allow_create": "Разрешить создание промтов", + "com_ui_prompts_allow_share": "Разрешить совместное использование подсказок", "com_ui_prompts_allow_use": "Разрешить использование промтов", "com_ui_provider": "Провайдер", + "com_ui_quality": "Качество", "com_ui_read_aloud": "Прочитать вслух", "com_ui_redirecting_to_provider": "Перенаправление на {{0}}, пожалуйста, подождите...", "com_ui_reference_saved_memories": "Ссылка на сохраненную память", "com_ui_reference_saved_memories_description": "Разрешить помощнику ссылаться на сохраненную память и использовать её при ответе", + "com_ui_refresh": "Обновить", "com_ui_refresh_link": "Обновить ссылку", "com_ui_regenerate": "Повторная генерация", "com_ui_regenerate_backup": "Сгенерировать резервные коды заново", "com_ui_regenerating": "Повторная генерация...", "com_ui_region": "Регион", + "com_ui_reinitialize": "Переинициализировать", + "com_ui_remove_user": "Удалить {{0}}", "com_ui_rename": "Переименовать", "com_ui_rename_conversation": "Переименовать чат", "com_ui_rename_failed": "Не удалось переименовать чат", "com_ui_rename_prompt": "Переименовать промт", "com_ui_requires_auth": "Требуется аутентификация", "com_ui_reset_var": "Сбросить {{0}}", + "com_ui_reset_zoom": "Сбросить масштаб", + "com_ui_resource": "ресурс", "com_ui_result": "Результат", "com_ui_revoke": "Отозвать", "com_ui_revoke_info": "Отозвать все предоставленные учетные данные", @@ -795,20 +1103,42 @@ "com_ui_revoke_key_endpoint": "Отозвать ключ для {{0}}", "com_ui_revoke_keys": "Отозвать ключи", "com_ui_revoke_keys_confirm": "Вы действительно хотите отозвать все ключи?", + "com_ui_role": "Роль", + "com_ui_role_editor": "Редактор", + "com_ui_role_editor_desc": "Может просматривать и изменять агента", + "com_ui_role_manager": "Менеджер", + "com_ui_role_manager_desc": "Может просматривать, изменять и удалять агента", + "com_ui_role_owner": "Владелец", + "com_ui_role_owner_desc": "Имеет полный контроль над агентом, включая его совместное использование", "com_ui_role_select": "Роль", + "com_ui_role_viewer": "Зритель", + "com_ui_role_viewer_desc": "Может просматривать и использовать агент, но не может его изменять", "com_ui_roleplay": "Ролевой режим", "com_ui_run_code": "Выполнить код", "com_ui_run_code_error": "Произошла ошибка при выполнении кода", "com_ui_save": "Сохранить", "com_ui_save_badge_changes": "Сохранить изменения значков?", + "com_ui_save_changes": "Сохранить изменения", "com_ui_save_submit": "Сохранить и отправить", "com_ui_saved": "Сохранено!", + "com_ui_saving": "Сохранение...", "com_ui_schema": "Схема", + "com_ui_scope": "Объем", "com_ui_search": "Поиск", + "com_ui_search_above_to_add": "Выполните поиск выше, чтобы добавить пользователей или группы.", + "com_ui_search_above_to_add_all": "Выполните поиск выше, чтобы добавить пользователей, группы или роли.", + "com_ui_search_above_to_add_people": "Ищите выше, чтобы добавить людей", + "com_ui_search_agent_category": "Поиск категории", + "com_ui_search_default_placeholder": "Поиск по имени или адресу электронной почты (минимум 2 символа)", + "com_ui_search_people_placeholder": "Поиск людей или групп по имени или адресу электронной почты", + "com_ui_seconds": "секунды", "com_ui_secret_key": "Секретный ключ", "com_ui_select": "Выбрать", + "com_ui_select_all": "Выбрать все", "com_ui_select_file": "Выберите файл", "com_ui_select_model": "Выберите модель", + "com_ui_select_options": "Выберите параметры...", + "com_ui_select_or_create_prompt": "Выбрать или создать подсказку", "com_ui_select_provider": "Выберите провайдера", "com_ui_select_provider_first": "Сначала выберите провайдера", "com_ui_select_region": "Выберите регион", @@ -820,6 +1150,8 @@ "com_ui_share_create_message": "Ваше имя и любые сообщения, которые вы добавите после обмена, останутся конфиденциальными.", "com_ui_share_delete_error": "Произошла ошибка при удалении общей ссылки.", "com_ui_share_error": "Произошла ошибка при попытке поделиться ссылкой на чат", + "com_ui_share_everyone": "Поделиться со всеми", + "com_ui_share_everyone_description_var": "Этот {{ресурс}} будет доступен всем. Убедитесь, что {{ресурс}} действительно предназначен для общего доступа. Будьте осторожны с вашими данными.", "com_ui_share_link_to_chat": "Поделиться ссылкой в чате", "com_ui_share_update_message": "Ваше имя, пользовательские инструкции и любые сообщения, которые вы добавите после обмена, останутся конфиденциальными.", "com_ui_share_var": "Поделиться {{0}}", @@ -829,6 +1161,8 @@ "com_ui_shared_prompts": "Общие промты", "com_ui_shop": "Покупки", "com_ui_show_all": "Показать все", + "com_ui_show_image_details": "Показать детали изображения", + "com_ui_show_password": "Показать пароль", "com_ui_show_qr": "Показать QR код", "com_ui_sign_in_to_domain": "Вход в {{0}}", "com_ui_simple": "Простой", @@ -844,22 +1178,41 @@ "com_ui_stop": "Остановить генерацию", "com_ui_storage": "Хранилище", "com_ui_submit": "Отправить", + "com_ui_support_contact": "Контакты службы поддержки", + "com_ui_support_contact_email": "Электронная почта", + "com_ui_support_contact_email_invalid": "Пожалуйста введите верный адрес электронной почты", + "com_ui_support_contact_email_placeholder": "support@example.com", + "com_ui_support_contact_name": "Имя", + "com_ui_support_contact_name_min_length": "Имя должно состоять как минимум из {{minLength}} символов", + "com_ui_support_contact_name_placeholder": "Имя контактного лица службы поддержки", "com_ui_teach_or_explain": "Обучение", "com_ui_temporary": "Временный чат", "com_ui_terms_and_conditions": "Условия использования", "com_ui_terms_of_service": "Условия использования", "com_ui_thinking": "Рассуждаю...", "com_ui_thoughts": "Мысли", + "com_ui_token": "токен", "com_ui_token_exchange_method": "Метод обмена токена", "com_ui_token_url": "URL токена", + "com_ui_tokens": "токены", + "com_ui_tool_collection_prefix": "Набор инструментов от", + "com_ui_tool_info": "Tool Information", + "com_ui_tool_more_info": "Дополнительная информация об этом инструменте", "com_ui_tools": "Инструменты", "com_ui_travel": "Путешествия", + "com_ui_trust_app": "Я доверяю приложению", + "com_ui_try_adjusting_search": "Попробуйте изменить условия поиска", + "com_ui_ui_resources": "Ресурсы пользовательского интерфейса", "com_ui_unarchive": "разархивировать", "com_ui_unarchive_error": "Не удалось восстановить чат из архива", + "com_ui_unavailable": "Недоступно", "com_ui_unknown": "Неизвестно", "com_ui_untitled": "Без названия", "com_ui_update": "Обновить", + "com_ui_update_mcp_error": "Произошла ошибка при создании или обновлении MCP.", + "com_ui_update_mcp_success": "Успешное создание или обновление MCP", "com_ui_upload": "Загрузить", + "com_ui_upload_agent_avatar": "Успешное обновление аватара агента", "com_ui_upload_code_files": "Загрузить для Интерпретатора кода", "com_ui_upload_delay": "Загрузка \"{{0}}\" занимает больше времени, чем ожидалось. Пожалуйста, подождите, пока файл полностью проиндексируется для доступа.", "com_ui_upload_error": "Произошла ошибка при загрузке вашего файла", @@ -873,18 +1226,43 @@ "com_ui_upload_ocr_text": "Загрузить как текст", "com_ui_upload_success": "Файл успешно загружен", "com_ui_upload_type": "Выберите тип загрузки", + "com_ui_usage": "Использование", "com_ui_use_2fa_code": "Использовать код 2FA вместо этого", "com_ui_use_backup_code": "Использовать резервный код вместо этого", "com_ui_use_memory": "Использовать память", "com_ui_use_micrphone": "Использовать микрофон", "com_ui_used": "Использован", + "com_ui_user": "Пользователь", + "com_ui_user_group_permissions": "Права пользователей и групп", + "com_ui_value": "Значение", "com_ui_variables": "Переменные", "com_ui_variables_info": "Используйте двойные фигурные скобки в тексте для создания переменных, например `{{пример переменной}}`, чтобы заполнить их позже при использовании промта.", "com_ui_verify": "Проверить", "com_ui_version_var": "Версия {{0}}", "com_ui_versions": "Версии", + "com_ui_view_memory": "Просмотр памяти", "com_ui_view_source": "Просмотреть исходный чат", "com_ui_web_search": "Веб-поиск", + "com_ui_web_search_cohere_key": "Введите ключ API Cohere", + "com_ui_web_search_firecrawl_url": "URL-адрес API Firecrawl (необязательно)", + "com_ui_web_search_jina_key": "Введите ключ API Jina", + "com_ui_web_search_jina_url": "URL-адрес API Jina (необязательно)", + "com_ui_web_search_processing": "Результаты обработки", + "com_ui_web_search_provider": "Поиск провайдера", + "com_ui_web_search_provider_searxng": "SearXNG", + "com_ui_web_search_provider_serper": "Serper API", + "com_ui_web_search_provider_serper_key": "Получите свой ключ API Serper", + "com_ui_web_search_reading": "Результаты чтения", + "com_ui_web_search_reranker_cohere_key": "Получите свой ключ API Cohere", + "com_ui_web_search_reranker_jina": "Jina AI", + "com_ui_web_search_reranker_jina_key": "Получите свой ключ API Jina", + "com_ui_web_search_reranker_jina_url_help": "Узнайте о Jina Rerank API", + "com_ui_web_search_scraper_firecrawl": "Firecrawl API", + "com_ui_web_search_scraper_firecrawl_key": "Получите ключ API Firecrawl", + "com_ui_web_search_searxng_api_key": "Введите SearXNG API ключ (необязательно)", + "com_ui_web_search_searxng_instance_url": "URL-адрес экземпляра SearXNG", + "com_ui_web_searching": "Поиск в сети", + "com_ui_web_searching_again": "Повторный поиск в Интернете", "com_ui_weekend_morning": "Хороших выходных", "com_ui_write": "Письмо", "com_ui_x_selected": "{{0}} выбрано", diff --git a/client/src/locales/th/translation.json b/client/src/locales/th/translation.json index bb7e411096..a5afdb1283 100644 --- a/client/src/locales/th/translation.json +++ b/client/src/locales/th/translation.json @@ -275,7 +275,6 @@ "com_error_files_dupe": "ตรวจพบไฟล์ซ้ำ", "com_error_files_empty": "ไม่อนุญาตให้ใช้ไฟล์ว่างเปล่า", "com_error_files_process": "เกิดข้อผิดพลาดขณะประมวลผลไฟล์", - "com_error_files_unsupported_capability": "ไม่มีความสามารถที่เปิดใช้งานที่รองรับประเภทไฟล์นี้", "com_error_files_upload": "เกิดข้อผิดพลาดขณะอัปโหลดไฟล์", "com_error_files_upload_canceled": "คำขออัปโหลดไฟล์ถูกยกเลิกแล้ว หมายเหตุ: การอัปโหลดไฟล์อาจยังคงประมวลผลอยู่และจะต้องลบด้วยตนเอง", "com_error_files_validation": "เกิดข้อผิดพลาดขณะตรวจสอบไฟล์", @@ -302,7 +301,6 @@ "com_nav_auto_scroll": "เลื่อนอัตโนมัติไปที่ข้อความล่าสุดเมื่อเปิดแชท", "com_nav_auto_send_prompts": "ส่งพรอมต์อัตโนมัติ", "com_nav_auto_send_text": "ส่งข้อความอัตโนมัติ", - "com_nav_auto_send_text_disabled": "ตั้งค่าเป็น -1 เพื่อปิดใช้งาน", "com_nav_auto_transcribe_audio": "ถอดเสียงอัตโนมัติ", "com_nav_automatic_playback": "เล่นข้อความล่าสุดอัตโนมัติ", "com_nav_balance": "ยอดคงเหลือ", diff --git a/client/src/locales/tr/translation.json b/client/src/locales/tr/translation.json index 932ebffd4e..8cdf3c6d0b 100644 --- a/client/src/locales/tr/translation.json +++ b/client/src/locales/tr/translation.json @@ -243,7 +243,6 @@ "com_error_files_dupe": "Yinelenen dosya tespit edildi.", "com_error_files_empty": "Boş dosyalara izin verilmez.", "com_error_files_process": "Dosya işlenirken bir hata oluştu.", - "com_error_files_unsupported_capability": "Bu dosya türünü destekleyen hiçbir yetenek etkin değil.", "com_error_files_upload": "Dosya yüklenirken bir hata oluştu.", "com_error_files_upload_canceled": "Dosya yükleme isteği iptal edildi. Not: dosya yüklemesi hala işleniyor olabilir ve manuel olarak silinmesi gerekecektir.", "com_error_files_validation": "Dosya doğrulanırken bir hata oluştu.", @@ -269,7 +268,6 @@ "com_nav_auto_scroll": "Sohbet açıldığında otomatik olarak son mesaja kaydır", "com_nav_auto_send_prompts": "İstemleri Otomatik Gönder", "com_nav_auto_send_text": "Metni otomatik gönder (3 sn sonra)", - "com_nav_auto_send_text_disabled": "devre dışı bırakmak için -1 ayarlayın", "com_nav_auto_transcribe_audio": "Sesi otomatik olarak yazıya dök", "com_nav_automatic_playback": "Son Mesajı Otomatik Çal (yalnızca dış)", "com_nav_balance": "Denge", diff --git a/client/src/locales/uk/translation.json b/client/src/locales/uk/translation.json index 8e50c1e162..c786ebc7ef 100644 --- a/client/src/locales/uk/translation.json +++ b/client/src/locales/uk/translation.json @@ -360,7 +360,6 @@ "com_error_files_dupe": "Виявлено дубльований файл", "com_error_files_empty": "Порожні файли не дозволяються", "com_error_files_process": "Сталася помилка під час обробки файлу", - "com_error_files_unsupported_capability": "Відсутні дозволи для роботи з даним типом файлів", "com_error_files_upload": "Під час завантаження файлу сталася помилка", "com_error_files_upload_canceled": "Запит на завантаження файлу було скасовано. Примітка: файл все ще може оброблятися, і його доведеться видалити вручну.", "com_error_files_validation": "Сталася помилка під час перевірки файлу", @@ -406,7 +405,6 @@ "com_nav_auto_scroll": "Автоматично прокручувати до найновіших повідомлень при відкритті", "com_nav_auto_send_prompts": "Автовідправка підказок", "com_nav_auto_send_text": "Автовідправка повідомлень", - "com_nav_auto_send_text_disabled": "встановіть -1 для вимкнення", "com_nav_auto_transcribe_audio": "Автоматична транскрипція", "com_nav_automatic_playback": "Автовідтворення останнього повідомлення", "com_nav_balance": "Баланс", diff --git a/client/src/locales/zh-Hans/translation.json b/client/src/locales/zh-Hans/translation.json index 8333f55791..8ffa0c5f0c 100644 --- a/client/src/locales/zh-Hans/translation.json +++ b/client/src/locales/zh-Hans/translation.json @@ -301,8 +301,8 @@ "com_endpoint_openai_prompt_prefix_placeholder": "设置自定义指令以包含在系统消息中,默认为空", "com_endpoint_openai_reasoning_effort": "仅限推理模型:限制推理工作量。减少推理工作量可以获取更快的响应并在响应中使用更少的词元进行推理。设为 “最小值” 时将生成非常少的推理词元,能够以最快速度生成首个词元,特别适合编码和指令执行任务。", "com_endpoint_openai_reasoning_summary": "仅限 Responses API:模型执行推理的摘要。这对于调试和理解模型的推理过程非常有帮助。可以设置为无、自动、简洁或详细。", - "com_endpoint_openai_resend": "重新发送所有先前附加的图片。注意:这会显着增加词元成本,并且可能会遇到很多关于图片附件的错误。", - "com_endpoint_openai_resend_files": "重新发送所有先前附加的文件。注意:这会显着增加词元成本,并且可能会遇到很多关于图像附件的错误。", + "com_endpoint_openai_resend": "重新发送所有先前附加的图片。注意:这可能会大幅增加词元消耗,且在图片附件较多时可能会出现错误。", + "com_endpoint_openai_resend_files": "重新发送所有先前附加的文件。注意:这将会增加词元消耗,且在文件附件较多时可能会出现错误。", "com_endpoint_openai_stop": "最多 4 个序列,API 将停止生成更多词元。", "com_endpoint_openai_temp": "值越高表示输出越随机,值越低表示输出越确定。建议不要同时改变此值和 Top P。", "com_endpoint_openai_topp": "相较于随机性的另一个取样方法,称为核采样,模型选取输出词元中大于 top_p(概率密度在整个概率分布中的比例)的结果。比如 top_p=0.1 表示只有概率占比为前 10% 的词元才会被考虑作为输出。建议不要同时改变此值和随机性。", @@ -311,7 +311,7 @@ "com_endpoint_openai_verbosity": "限制模型响应的冗长程度。较低的值将生成更简洁的响应,而较高的值将生成更冗长的响应。目前支持的值包括低、中和高。", "com_endpoint_output": "输出", "com_endpoint_plug_image_detail": "图片细节", - "com_endpoint_plug_resend_files": "重发文件", + "com_endpoint_plug_resend_files": "文件重发", "com_endpoint_plug_set_custom_instructions_for_gpt_placeholder": "设置自定义指令以包含在系统消息中,默认为空", "com_endpoint_plug_skip_completion": "跳过补全", "com_endpoint_plug_use_functions": "使用函数", @@ -350,7 +350,7 @@ "com_endpoint_stop": "停止序列", "com_endpoint_stop_placeholder": "按 `Enter` 键分隔多个值", "com_endpoint_temperature": "随机性", - "com_endpoint_thinking": "思考中", + "com_endpoint_thinking": "深度思考", "com_endpoint_thinking_budget": "思考预算", "com_endpoint_top_k": "Top K", "com_endpoint_top_p": "Top P", @@ -363,9 +363,9 @@ "com_error_files_dupe": "检测到重复文件", "com_error_files_empty": "不允许上传空文件", "com_error_files_process": "处理文件时发生错误", - "com_error_files_unsupported_capability": "未启用支持此类文件的功能", "com_error_files_upload": "上传文件时发生错误", "com_error_files_upload_canceled": "文件上传请求已取消。注意:文件上传可能仍在进行中,需要手动删除。", + "com_error_files_upload_too_large": "文件过大,请上传小于 {{0}} MB 的文件", "com_error_files_validation": "验证文件时出错。", "com_error_google_tool_conflict": "内置的 Google 工具与外部工具不兼容。请禁用内置工具或外部工具。", "com_error_heic_conversion": "将 HEIC 图片转换为 JPEG 失败。请尝试手动转换图像或使用其他格式。", @@ -409,7 +409,6 @@ "com_nav_auto_scroll": "打开对话时自动滚动到最新消息", "com_nav_auto_send_prompts": "自动发送提示词", "com_nav_auto_send_text": "自动发送文本", - "com_nav_auto_send_text_disabled": "设置为 -1 以禁用", "com_nav_auto_transcribe_audio": "自动转录音频", "com_nav_automatic_playback": "自动播放最新消息", "com_nav_balance": "余额", @@ -562,6 +561,7 @@ "com_nav_setting_balance": "余额", "com_nav_setting_chat": "对话", "com_nav_setting_data": "数据管理", + "com_nav_setting_delay": "延迟(秒)", "com_nav_setting_general": "通用", "com_nav_setting_mcp": "MCP 设置", "com_nav_setting_personalization": "个性化", @@ -761,6 +761,7 @@ "com_ui_client_secret": "Client Secret", "com_ui_close": "关闭", "com_ui_close_menu": "关闭菜单", + "com_ui_close_settings": "关闭设置", "com_ui_close_window": "关闭窗口", "com_ui_code": "代码", "com_ui_collapse_chat": "收起对话", @@ -859,6 +860,7 @@ "com_ui_edit_editing_image": "编辑图片", "com_ui_edit_mcp_server": "编辑 MCP 服务器", "com_ui_edit_memory": "编辑记忆", + "com_ui_editor_instructions": "拖动图片调整位置 • 使用缩放滑块或按钮调整大小", "com_ui_empty_category": "-", "com_ui_endpoint": "端点", "com_ui_endpoint_menu": "LLM 端点菜单", @@ -893,6 +895,7 @@ "com_ui_feedback_tag_unjustified_refusal": "无故拒绝回答", "com_ui_field_max_length": "{{field}} 最多 {{length}} 个字符", "com_ui_field_required": "此字段为必填项", + "com_ui_file_input_avatar_label": "上传文件用作头像", "com_ui_file_size": "文件大小", "com_ui_file_token_limit": "文件词元数限制", "com_ui_file_token_limit_desc": "为文件处理设定最大词元数限制,以控制成本和资源使用", @@ -955,11 +958,13 @@ "com_ui_import_conversation_file_type_error": "不支持的导入类型", "com_ui_import_conversation_info": "从 JSON 文件导入对话", "com_ui_import_conversation_success": "对话导入成功", + "com_ui_import_conversation_upload_error": "上传文件时出错,请重试。", "com_ui_include_shadcnui": "包含 shadcn/ui 组件指令", "com_ui_initializing": "初始化中...", "com_ui_input": "输入", "com_ui_instructions": "指令", "com_ui_key": "键", + "com_ui_key_required": "API Key 为必填项", "com_ui_late_night": "夜深了", "com_ui_latest_footer": "Every AI for Everyone.", "com_ui_latest_production_version": "最新在用版本", @@ -974,6 +979,7 @@ "com_ui_manage": "管理", "com_ui_marketplace": "市场", "com_ui_marketplace_allow_use": "允许使用市场", + "com_ui_max_file_size": "PNG、JPG 或 JPEG(最大 {{0}})", "com_ui_max_tags": "最多允许 {{0}} 个,用最新值。", "com_ui_mcp_authenticated_success": "MCP 服务器 “{{0}}” 认证成功", "com_ui_mcp_configure_server": "配置 {{0}}", @@ -1027,6 +1033,7 @@ "com_ui_no_category": "无类别", "com_ui_no_changes": "未做任何修改", "com_ui_no_individual_access": "任何个人用户或群组都无法访问该智能体", + "com_ui_no_memories": "暂无记忆,请手动创建或提示 AI 记住一些内容", "com_ui_no_personalization_available": "当前没有可用的个性化选项", "com_ui_no_read_access": "您没有权限查看记忆", "com_ui_no_results_found": "未找到结果", @@ -1067,6 +1074,7 @@ "com_ui_privacy_policy": "隐私政策", "com_ui_privacy_policy_url": "隐私政策链接", "com_ui_prompt": "提示词", + "com_ui_prompt_groups": "提示词组列表", "com_ui_prompt_name": "提示词名称", "com_ui_prompt_name_required": "提示词名称为必填项", "com_ui_prompt_preview_not_shared": "作者未允许对此提示词进行协作。", @@ -1096,6 +1104,8 @@ "com_ui_rename_failed": "重命名对话失败", "com_ui_rename_prompt": "重命名 Prompt", "com_ui_requires_auth": "需要认证", + "com_ui_reset": "重置", + "com_ui_reset_adjustments": "重置调整", "com_ui_reset_var": "重置 {{0}}", "com_ui_reset_zoom": "重置缩放", "com_ui_resource": "资源", @@ -1104,6 +1114,8 @@ "com_ui_revoke_info": "撤销所有用户提供的凭据", "com_ui_revoke_key_confirm": "您确定要撤销此密钥吗?", "com_ui_revoke_key_endpoint": "撤销 {{0}} 的密钥", + "com_ui_revoke_key_error": "撤销 API Key 失败,请重试。", + "com_ui_revoke_key_success": "API Key 撤销成功", "com_ui_revoke_keys": "撤销密钥", "com_ui_revoke_keys_confirm": "您确定要撤销所有密钥吗?", "com_ui_role": "角色", @@ -1117,11 +1129,15 @@ "com_ui_role_viewer": "查看者", "com_ui_role_viewer_desc": "可以查看和使用智能体,但无法修改智能体", "com_ui_roleplay": "角色扮演", + "com_ui_rotate": "旋转", + "com_ui_rotate_90": "旋转 90 度", "com_ui_run_code": "运行代码", "com_ui_run_code_error": "代码运行出错", "com_ui_save": "保存", "com_ui_save_badge_changes": "保存徽章更改?", "com_ui_save_changes": "保存修改", + "com_ui_save_key_error": "保存 API Key 失败,请重试。", + "com_ui_save_key_success": "API Key 保存成功", "com_ui_save_submit": "保存并提交", "com_ui_saved": "保存成功!", "com_ui_saving": "保存中...", @@ -1218,6 +1234,7 @@ "com_ui_update_mcp_success": "已成功创建或更新 MCP", "com_ui_upload": "上传", "com_ui_upload_agent_avatar": "成功更新智能体头像", + "com_ui_upload_avatar_label": "上传头像图片", "com_ui_upload_code_files": "上传代码解释器文件", "com_ui_upload_delay": "上传 “{{0}}” 时比预期花了更长时间。文件正在进行检索索引,请稍候。", "com_ui_upload_error": "上传文件错误", @@ -1229,6 +1246,7 @@ "com_ui_upload_invalid": "上传的文件无效。必须是图片,且不得超过大小限制", "com_ui_upload_invalid_var": "上传的文件无效。必须是图片,且不得超过 {{0}} MB。", "com_ui_upload_ocr_text": "作为文本上传", + "com_ui_upload_provider": "上传至提供商", "com_ui_upload_success": "上传文件成功", "com_ui_upload_type": "选择上传类型", "com_ui_usage": "用量", @@ -1268,6 +1286,8 @@ "com_ui_web_search_scraper": "抓取器", "com_ui_web_search_scraper_firecrawl": "Firecrawl API", "com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key", + "com_ui_web_search_scraper_serper": "Serper Scrape API", + "com_ui_web_search_scraper_serper_key": "获取您的 Serper API Key", "com_ui_web_search_searxng_api_key": "输入 SearXNG API Key(可选)", "com_ui_web_search_searxng_instance_url": "SearXNG 实例 URL", "com_ui_web_searching": "正在搜索网络", @@ -1277,5 +1297,8 @@ "com_ui_x_selected": "{{0}} 已选择", "com_ui_yes": "是的", "com_ui_zoom": "缩放", + "com_ui_zoom_in": "放大", + "com_ui_zoom_level": "缩放级别", + "com_ui_zoom_out": "缩小", "com_user_message": "您" } diff --git a/client/src/locales/zh-Hant/translation.json b/client/src/locales/zh-Hant/translation.json index 5aa57ea7d8..2de11c381e 100644 --- a/client/src/locales/zh-Hant/translation.json +++ b/client/src/locales/zh-Hant/translation.json @@ -308,7 +308,6 @@ "com_error_files_dupe": "偵測到重複的檔案。", "com_error_files_empty": "不允許空白檔案。", "com_error_files_process": "處理檔案時發生錯誤。", - "com_error_files_unsupported_capability": "未啟用支援此檔案類型的功能。", "com_error_files_upload": "上傳檔案時發生錯誤", "com_error_files_upload_canceled": "檔案上傳請求已取消。注意:檔案上傳可能仍在處理中,需要手動刪除。", "com_error_files_validation": "驗證檔案時發生錯誤。", @@ -336,7 +335,6 @@ "com_nav_auto_scroll": "開啟時自動捲動至最新內容", "com_nav_auto_send_prompts": "自動傳送提示", "com_nav_auto_send_text": "自動傳送訊息", - "com_nav_auto_send_text_disabled": "設定為 -1 以停用", "com_nav_auto_transcribe_audio": "自動轉錄語音", "com_nav_automatic_playback": "自動播放最新訊息", "com_nav_balance": "餘額", diff --git a/client/src/store/fontSize.ts b/client/src/store/fontSize.ts new file mode 100644 index 0000000000..4b1a0666f3 --- /dev/null +++ b/client/src/store/fontSize.ts @@ -0,0 +1,54 @@ +import { atom } from 'jotai'; +import { atomWithStorage } from 'jotai/utils'; +import { applyFontSize } from '@librechat/client'; + +const DEFAULT_FONT_SIZE = 'text-base'; + +/** + * Base storage atom for font size + */ +const fontSizeStorageAtom = atomWithStorage('fontSize', DEFAULT_FONT_SIZE, undefined, { + getOnInit: true, +}); + +/** + * Derived atom that applies font size changes to the DOM + * Read: returns the current font size + * Write: updates storage and applies the font size to the DOM + */ +export const fontSizeAtom = atom( + (get) => get(fontSizeStorageAtom), + (get, set, newValue: string) => { + set(fontSizeStorageAtom, newValue); + if (typeof window !== 'undefined' && typeof document !== 'undefined') { + applyFontSize(newValue); + } + }, +); + +/** + * Initialize font size on app load + */ +export const initializeFontSize = () => { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return; + } + + const savedValue = localStorage.getItem('fontSize'); + + if (savedValue !== null) { + try { + const parsedValue = JSON.parse(savedValue); + applyFontSize(parsedValue); + } catch (error) { + console.error( + 'Error parsing localStorage key "fontSize", resetting to default. Error:', + error, + ); + localStorage.setItem('fontSize', JSON.stringify(DEFAULT_FONT_SIZE)); + applyFontSize(DEFAULT_FONT_SIZE); + } + } else { + applyFontSize(DEFAULT_FONT_SIZE); + } +}; diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index 0fe4dccd2c..4e9c2f5cad 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -21,7 +21,6 @@ const localStorageAtoms = { // General settings autoScroll: atomWithLocalStorage('autoScroll', false), hideSidePanel: atomWithLocalStorage('hideSidePanel', false), - fontSize: atomWithLocalStorage('fontSize', 'text-base'), enableUserMsgMarkdown: atomWithLocalStorage( LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN, true, diff --git a/client/src/style.css b/client/src/style.css index 5f3b2df311..1a3747c80c 100644 --- a/client/src/style.css +++ b/client/src/style.css @@ -1243,8 +1243,12 @@ pre { Ubuntu Mono, monospace !important; } -code[class='language-plaintext'] { - white-space: pre-line; +code.language-text, +code.language-txt, +code.language-plaintext, +code.language-markdown, +code.language-md { + white-space: pre-wrap !important; } code.hljs, code[class*='language-'], diff --git a/client/src/utils/__tests__/markdown.test.ts b/client/src/utils/__tests__/markdown.test.ts new file mode 100644 index 0000000000..fcc0f169e6 --- /dev/null +++ b/client/src/utils/__tests__/markdown.test.ts @@ -0,0 +1,185 @@ +import { getMarkdownFiles } from '../markdown'; + +describe('markdown artifacts', () => { + describe('getMarkdownFiles', () => { + it('should return content.md with the original markdown content', () => { + const markdown = '# Hello World\n\nThis is a test.'; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + + it('should return default content when markdown is empty', () => { + const files = getMarkdownFiles(''); + + expect(files['content.md']).toBe('# No content provided'); + }); + + it('should include App.tsx with MarkdownRenderer component', () => { + const markdown = '# Test'; + const files = getMarkdownFiles(markdown); + + expect(files['App.tsx']).toContain('import React from'); + expect(files['App.tsx']).toContain( + "import MarkdownRenderer from '/components/ui/MarkdownRenderer'", + ); + expect(files['App.tsx']).toContain(' { + const markdown = '# Test'; + const files = getMarkdownFiles(markdown); + + expect(files['index.tsx']).toContain('import App from "./App"'); + expect(files['index.tsx']).toContain('import "./styles.css"'); + expect(files['index.tsx']).toContain('import "./markdown.css"'); + expect(files['index.tsx']).toContain('createRoot'); + }); + + it('should include MarkdownRenderer component file', () => { + const markdown = '# Test'; + const files = getMarkdownFiles(markdown); + + expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('import Markdown from'); + expect(files['/components/ui/MarkdownRenderer.tsx']).toContain('MarkdownRendererProps'); + expect(files['/components/ui/MarkdownRenderer.tsx']).toContain( + 'export default MarkdownRenderer', + ); + }); + + it('should include markdown.css with styling', () => { + const markdown = '# Test'; + const files = getMarkdownFiles(markdown); + + expect(files['markdown.css']).toContain('.markdown-body'); + expect(files['markdown.css']).toContain('list-style-type: disc'); + expect(files['markdown.css']).toContain('prefers-color-scheme: dark'); + }); + + describe('content escaping', () => { + it('should escape backticks in markdown content', () => { + const markdown = 'Here is some `inline code`'; + const files = getMarkdownFiles(markdown); + + expect(files['App.tsx']).toContain('\\`'); + }); + + it('should escape backslashes in markdown content', () => { + const markdown = 'Path: C:\\Users\\Test'; + const files = getMarkdownFiles(markdown); + + expect(files['App.tsx']).toContain('\\\\'); + }); + + it('should escape dollar signs in markdown content', () => { + const markdown = 'Price: $100'; + const files = getMarkdownFiles(markdown); + + expect(files['App.tsx']).toContain('\\$'); + }); + + it('should handle code blocks with backticks', () => { + const markdown = '```js\nconsole.log("test");\n```'; + const files = getMarkdownFiles(markdown); + + // Should be escaped + expect(files['App.tsx']).toContain('\\`\\`\\`'); + }); + }); + + describe('list indentation normalization', () => { + it('should normalize 2-space indented lists to 4-space', () => { + const markdown = '- Item 1\n - Subitem 1\n - Subitem 2'; + const files = getMarkdownFiles(markdown); + + // The indentation normalization happens in wrapMarkdownRenderer + // It converts 2 spaces before list markers to 4 spaces + // Check that content.md preserves the original, but App.tsx has normalized content + expect(files['content.md']).toBe(markdown); + expect(files['App.tsx']).toContain('- Item 1'); + expect(files['App.tsx']).toContain('Subitem 1'); + }); + + it('should handle numbered lists with 2-space indents', () => { + const markdown = '1. First\n 2. Second nested'; + const files = getMarkdownFiles(markdown); + + // Verify normalization occurred + expect(files['content.md']).toBe(markdown); + expect(files['App.tsx']).toContain('1. First'); + expect(files['App.tsx']).toContain('2. Second nested'); + }); + + it('should not affect already 4-space indented lists', () => { + const markdown = '- Item 1\n - Subitem 1'; + const files = getMarkdownFiles(markdown); + + // Already normalized, should be preserved + expect(files['content.md']).toBe(markdown); + expect(files['App.tsx']).toContain('- Item 1'); + expect(files['App.tsx']).toContain('Subitem 1'); + }); + }); + + describe('edge cases', () => { + it('should handle very long markdown content', () => { + const longMarkdown = '# Test\n\n' + 'Lorem ipsum '.repeat(1000); + const files = getMarkdownFiles(longMarkdown); + + expect(files['content.md']).toBe(longMarkdown); + expect(files['App.tsx']).toContain('Lorem ipsum'); + }); + + it('should handle markdown with special characters', () => { + const markdown = '# Test & < > " \''; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + + it('should handle markdown with unicode characters', () => { + const markdown = '# 你好 世界 🌍'; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + + it('should handle markdown with only whitespace', () => { + const markdown = ' \n\n '; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + + it('should handle markdown with mixed line endings', () => { + const markdown = '# Line 1\r\n## Line 2\n### Line 3'; + const files = getMarkdownFiles(markdown); + + expect(files['content.md']).toBe(markdown); + }); + }); + }); + + describe('markdown component structure', () => { + it('should generate a MarkdownRenderer component that uses marked-react', () => { + const files = getMarkdownFiles('# Test'); + const rendererCode = files['/components/ui/MarkdownRenderer.tsx']; + + // Verify the component imports and uses Markdown from marked-react + expect(rendererCode).toContain("import Markdown from 'marked-react'"); + expect(rendererCode).toContain('{content}'); + }); + + it('should pass markdown content to the Markdown component', () => { + const testContent = '# Heading\n- List item'; + const files = getMarkdownFiles(testContent); + const appCode = files['App.tsx']; + + // The App.tsx should pass the content to MarkdownRenderer + expect(appCode).toContain(' = { 'text/html': 'static', 'application/vnd.react': 'react-ts', 'application/vnd.mermaid': 'react-ts', 'application/vnd.code-html': 'static', + 'text/markdown': 'react-ts', + 'text/md': 'react-ts', + 'text/plain': 'react-ts', default: 'static', // 'css': 'css', // 'javascript': 'js', @@ -34,27 +41,6 @@ const artifactTemplate: Record< // 'tsx': 'tsx', }; -export function getFileExtension(language?: string): string { - switch (language) { - case 'application/vnd.react': - return 'tsx'; - case 'application/vnd.mermaid': - return 'mermaid'; - case 'text/html': - return 'html'; - // case 'jsx': - // return 'jsx'; - // case 'tsx': - // return 'tsx'; - // case 'html': - // return 'html'; - // case 'css': - // return 'css'; - default: - return 'txt'; - } -} - export function getKey(type: string, language?: string): string { return `${type}${(language?.length ?? 0) > 0 ? `-${language}` : ''}`; } @@ -103,25 +89,41 @@ const standardDependencies = { '@radix-ui/react-slot': '^1.1.0', '@radix-ui/react-toggle': '^1.1.0', '@radix-ui/react-toggle-group': '^1.1.0', + '@radix-ui/react-tooltip': '^1.2.8', 'embla-carousel-react': '^8.2.0', 'react-day-picker': '^9.0.8', 'dat.gui': '^0.7.9', vaul: '^0.9.1', }; -const mermaidDependencies = Object.assign( - { - mermaid: '^11.4.1', - 'react-zoom-pan-pinch': '^3.6.1', - }, - standardDependencies, -); +const mermaidDependencies = { + mermaid: '^11.4.1', + 'react-zoom-pan-pinch': '^3.6.1', + 'class-variance-authority': '^0.6.0', + clsx: '^1.2.1', + 'tailwind-merge': '^1.9.1', + '@radix-ui/react-slot': '^1.1.0', +}; -const dependenciesMap: Record = { +const markdownDependencies = { + 'marked-react': '^2.0.0', +}; + +const dependenciesMap: Record< + | keyof typeof artifactFilename + | 'application/vnd.mermaid' + | 'text/markdown' + | 'text/md' + | 'text/plain', + Record +> = { 'application/vnd.mermaid': mermaidDependencies, 'application/vnd.react': standardDependencies, 'text/html': standardDependencies, 'application/vnd.code-html': standardDependencies, + 'text/markdown': markdownDependencies, + 'text/md': markdownDependencies, + 'text/plain': markdownDependencies, default: standardDependencies, }; diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts index 81830c64ff..0959ba91bb 100644 --- a/client/src/utils/files.ts +++ b/client/src/utils/files.ts @@ -1,4 +1,11 @@ -import { SheetPaths, TextPaths, FilePaths, CodePaths } from '@librechat/client'; +import { + TextPaths, + FilePaths, + CodePaths, + AudioPaths, + VideoPaths, + SheetPaths, +} from '@librechat/client'; import { megabyte, QueryKeys, @@ -38,6 +45,18 @@ const artifact = { title: 'Code', }; +const audioFile = { + paths: AudioPaths, + fill: '#FF6B35', + title: 'Audio', +}; + +const videoFile = { + paths: VideoPaths, + fill: '#8B5CF6', + title: 'Video', +}; + export const fileTypes = { /* Category matches */ file: { @@ -47,6 +66,8 @@ export const fileTypes = { }, text: textDocument, txt: textDocument, + audio: audioFile, + video: videoFile, // application:, /* Partial matches */ diff --git a/client/src/utils/markdown.ts b/client/src/utils/markdown.ts new file mode 100644 index 0000000000..12556c1a24 --- /dev/null +++ b/client/src/utils/markdown.ts @@ -0,0 +1,256 @@ +import dedent from 'dedent'; + +const markdownRenderer = dedent(`import React, { useEffect, useState } from 'react'; +import Markdown from 'marked-react'; + +interface MarkdownRendererProps { + content: string; +} + +const MarkdownRenderer: React.FC = ({ content }) => { + return ( +
+ {content} +
+ ); +}; + +export default MarkdownRenderer;`); + +const wrapMarkdownRenderer = (content: string) => { + // Normalize indentation: convert 2-space indents to 4-space for proper nesting + const normalizedContent = content.replace(/^( {2})(-|\d+\.)/gm, ' $2'); + + // Escape backticks, backslashes, and dollar signs in the content + const escapedContent = normalizedContent + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$/g, '\\$'); + + return dedent(`import React from 'react'; +import MarkdownRenderer from '/components/ui/MarkdownRenderer'; + +const App = () => { + return ; +}; + +export default App; +`); +}; + +const markdownCSS = ` +/* GitHub Markdown CSS - Light theme base */ +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + line-height: 1.5; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; + color: #24292f; + background-color: #ffffff; +} + +.markdown-body h1, .markdown-body h2 { + border-bottom: 1px solid #d0d7de; + margin: 0.6em 0; +} + +.markdown-body h1 { font-size: 2em; margin: 0.67em 0; } +.markdown-body h2 { font-size: 1.5em; } +.markdown-body h3 { font-size: 1.25em; } +.markdown-body h4 { font-size: 1em; } +.markdown-body h5 { font-size: 0.875em; } +.markdown-body h6 { font-size: 0.85em; } + +.markdown-body ul, .markdown-body ol { + list-style: revert !important; + padding-left: 2em !important; + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body ul { list-style-type: disc !important; } +.markdown-body ol { list-style-type: decimal !important; } +.markdown-body ul ul { list-style-type: circle !important; } +.markdown-body ul ul ul { list-style-type: square !important; } + +.markdown-body li { margin-top: 0.25em; } + +.markdown-body li:has(> input[type="checkbox"]) { + list-style-type: none !important; +} + +.markdown-body li > input[type="checkbox"] { + margin-right: 0.75em; + margin-left: -1.5em; + vertical-align: middle; + pointer-events: none; + width: 16px; + height: 16px; +} + +.markdown-body .task-list-item { + list-style-type: none !important; +} + +.markdown-body .task-list-item > input[type="checkbox"] { + margin-right: 0.75em; + margin-left: -1.5em; + vertical-align: middle; + pointer-events: none; + width: 16px; + height: 16px; +} + +.markdown-body code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + border-radius: 6px; + background-color: rgba(175, 184, 193, 0.2); + color: #24292f; + font-family: ui-monospace, monospace; + white-space: pre-wrap; +} + +.markdown-body pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + border-radius: 6px; + margin-top: 0; + margin-bottom: 16px; + background-color: #f6f8fa; + color: #24292f; +} + +.markdown-body pre code { + display: inline-block; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: transparent; + border: 0; +} + +.markdown-body a { + text-decoration: none; + color: #0969da; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; +} + +.markdown-body table thead { + background-color: #f6f8fa; +} + +.markdown-body table th, .markdown-body table td { + padding: 6px 13px; + border: 1px solid #d0d7de; +} + +.markdown-body blockquote { + padding: 0 1em; + border-left: 0.25em solid #d0d7de; + margin: 0 0 16px 0; + color: #57606a; +} + +.markdown-body hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + border: 0; + background-color: #d0d7de; +} + +.markdown-body img { + max-width: 100%; + box-sizing: content-box; +} + +/* Dark theme */ +@media (prefers-color-scheme: dark) { + .markdown-body { + color: #c9d1d9; + background-color: #0d1117; + } + + .markdown-body h1, .markdown-body h2 { + border-bottom-color: #21262d; + } + + .markdown-body code { + background-color: rgba(110, 118, 129, 0.4); + color: #c9d1d9; + } + + .markdown-body pre { + background-color: #161b22; + color: #c9d1d9; + } + + .markdown-body a { + color: #58a6ff; + } + + .markdown-body table thead { + background-color: #161b22; + } + + .markdown-body table th, .markdown-body table td { + border-color: #30363d; + } + + .markdown-body blockquote { + border-left-color: #3b434b; + color: #8b949e; + } + + .markdown-body hr { + background-color: #21262d; + } +} +`; + +export const getMarkdownFiles = (content: string) => { + return { + 'content.md': content || '# No content provided', + 'App.tsx': wrapMarkdownRenderer(content), + 'index.tsx': dedent(`import React, { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./styles.css"; +import "./markdown.css"; + +import App from "./App"; + +const root = createRoot(document.getElementById("root")); +root.render(); +;`), + '/components/ui/MarkdownRenderer.tsx': markdownRenderer, + 'markdown.css': markdownCSS, + }; +}; diff --git a/client/src/utils/mermaid.ts b/client/src/utils/mermaid.ts index bc95eb1f1d..7930d9ab1e 100644 --- a/client/src/utils/mermaid.ts +++ b/client/src/utils/mermaid.ts @@ -7,9 +7,34 @@ import { ReactZoomPanPinchRef, } from "react-zoom-pan-pinch"; import mermaid from "mermaid"; -import { ZoomIn, ZoomOut, RefreshCw } from "lucide-react"; import { Button } from "/components/ui/button"; +const ZoomIn = () => ( + + + + + + +); + +const ZoomOut = () => ( + + + + + +); + +const RefreshCw = () => ( + + + + + + +); + interface MermaidDiagramProps { content: string; } @@ -181,21 +206,21 @@ const MermaidDiagram: React.FC = ({ content }) => {
@@ -217,12 +242,20 @@ export default App = () => ( `); }; +const mermaidCSS = ` +body { + background-color: #282C34; +} +`; + export const getMermaidFiles = (content: string) => { return { + 'diagram.mmd': content || '# No mermaid diagram content provided', 'App.tsx': wrapMermaidDiagram(content), 'index.tsx': dedent(`import React, { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./styles.css"; +import "./mermaid.css"; import App from "./App"; @@ -230,5 +263,6 @@ const root = createRoot(document.getElementById("root")); root.render(); ;`), '/components/ui/MermaidDiagram.tsx': mermaid, + 'mermaid.css': mermaidCSS, }; }; diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 7a52ff4106..fe8ec36499 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -1,15 +1,7 @@ -import { ContentTypes, Constants } from 'librechat-data-provider'; +import { ContentTypes } from 'librechat-data-provider'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; -export const getLengthAndLastTenChars = (str?: string): string => { - if (typeof str !== 'string' || str.length === 0) { - return '0'; - } - - const length = str.length; - const lastTenChars = str.slice(-10); - return `${length}${lastTenChars}`; -}; +export const TEXT_KEY_DIVIDER = '|||'; export const getLatestText = (message?: TMessage | null, includeIndex?: boolean): string => { if (!message) { @@ -25,7 +17,7 @@ export const getLatestText = (message?: TMessage | null, includeIndex?: boolean) continue; } - const text = (typeof part?.text === 'string' ? part.text : part?.text.value) ?? ''; + const text = (typeof part?.text === 'string' ? part.text : part?.text?.value) ?? ''; if (text.length > 0) { if (includeIndex === true) { return `${text}-${i}`; @@ -52,7 +44,12 @@ export const getAllContentText = (message?: TMessage | null): string => { if (message.content && message.content.length > 0) { return message.content .filter((part) => part.type === ContentTypes.TEXT) - .map((part) => (typeof part.text === 'string' ? part.text : part.text.value) || '') + .map((part) => { + if (!('text' in part)) return ''; + const text = part.text; + if (typeof text === 'string') return text; + return text?.value || ''; + }) .filter((text) => text.length > 0) .join('\n'); } @@ -60,16 +57,84 @@ export const getAllContentText = (message?: TMessage | null): string => { return ''; }; +const getLatestContentForKey = (message: TMessage): string => { + const formatText = (str: string, index: number): string => { + if (str.length === 0) { + return '0'; + } + const length = str.length; + const lastChars = str.slice(-16); + return `${length}${TEXT_KEY_DIVIDER}${lastChars}${TEXT_KEY_DIVIDER}${index}`; + }; + + if (message.text) { + return formatText(message.text, -1); + } + + if (!message.content || message.content.length === 0) { + return ''; + } + + for (let i = message.content.length - 1; i >= 0; i--) { + const part = message.content[i] as TMessageContentParts | undefined; + if (!part?.type) { + continue; + } + + const type = part.type; + let text = ''; + + // Handle THINK type - extract think content + if (type === ContentTypes.THINK && 'think' in part) { + text = typeof part.think === 'string' ? part.think : (part.think?.value ?? ''); + } + // Handle TEXT type + else if (type === ContentTypes.TEXT && 'text' in part) { + text = typeof part.text === 'string' ? part.text : (part.text?.value ?? ''); + } + // Handle ERROR type + else if (type === ContentTypes.ERROR && 'error' in part) { + text = String(part.error || 'err').slice(0, 30); + } + // Handle TOOL_CALL - use simple marker with type + else if (type === ContentTypes.TOOL_CALL && 'tool_call' in part) { + const tcType = part.tool_call?.type || 'x'; + const tcName = String(part.tool_call?.['name'] || 'unknown').slice(0, 20); + const tcArgs = String(part.tool_call?.['args'] || 'none').slice(0, 20); + const tcOutput = String(part.tool_call?.['output'] || 'none').slice(0, 20); + text = `tc_${tcType}_${tcName}_${tcArgs}_${tcOutput}`; + } + // Handle IMAGE_FILE - use simple marker with file_id suffix + else if (type === ContentTypes.IMAGE_FILE && 'image_file' in part) { + const fileId = part.image_file?.file_id || 'x'; + text = `if_${fileId.slice(-8)}`; + } + // Handle IMAGE_URL - use simple marker + else if (type === ContentTypes.IMAGE_URL) { + text = 'iu'; + } + // Handle AGENT_UPDATE - use simple marker with agentId suffix + else if (type === ContentTypes.AGENT_UPDATE && 'agent_update' in part) { + const agentId = String(part.agent_update?.agentId || 'x').slice(0, 30); + text = `au_${agentId}`; + } else { + text = type; + } + + if (text.length > 0) { + return formatText(text, i); + } + } + + return ''; +}; + export const getTextKey = (message?: TMessage | null, convoId?: string | null) => { if (!message) { return ''; } - const text = getLatestText(message, true); - return `${(message.messageId as string | null) ?? ''}${ - Constants.COMMON_DIVIDER - }${getLengthAndLastTenChars(text)}${Constants.COMMON_DIVIDER}${ - message.conversationId ?? convoId - }`; + const contentKey = getLatestContentForKey(message); + return `${(message.messageId as string | null) ?? ''}${TEXT_KEY_DIVIDER}${contentKey}${TEXT_KEY_DIVIDER}${message.conversationId ?? convoId}`; }; export const scrollToEnd = (callback?: () => void) => { diff --git a/config/flush-cache.js b/config/flush-cache.js index b65339ad18..07c744ca4e 100644 --- a/config/flush-cache.js +++ b/config/flush-cache.js @@ -18,11 +18,38 @@ const fs = require('fs'); // Set up environment require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); -const { USE_REDIS, REDIS_URI, REDIS_KEY_PREFIX } = process.env; +const { + USE_REDIS, + REDIS_URI, + REDIS_USERNAME, + REDIS_PASSWORD, + REDIS_CA, + REDIS_KEY_PREFIX, + USE_REDIS_CLUSTER, + REDIS_USE_ALTERNATIVE_DNS_LOOKUP, +} = process.env; // Simple utility function const isEnabled = (value) => value === 'true' || value === true; +// Helper function to read Redis CA certificate +const getRedisCA = () => { + if (!REDIS_CA) { + return null; + } + try { + if (fs.existsSync(REDIS_CA)) { + return fs.readFileSync(REDIS_CA, 'utf8'); + } else { + console.warn(`⚠️ Redis CA certificate file not found: ${REDIS_CA}`); + return null; + } + } catch (error) { + console.error(`❌ Failed to read Redis CA certificate file '${REDIS_CA}':`, error.message); + return null; + } +}; + async function showHelp() { console.log(` LibreChat Cache Flush Utility @@ -67,21 +94,67 @@ async function flushRedisCache(dryRun = false, verbose = false) { console.log(` Prefix: ${REDIS_KEY_PREFIX || 'None'}`); } - // Create Redis client directly - const Redis = require('ioredis'); + // Create Redis client using same pattern as main app + const IoRedis = require('ioredis'); let redis; - // Handle cluster vs single Redis - if (process.env.USE_REDIS_CLUSTER === 'true') { - const hosts = REDIS_URI.split(',').map((uri) => { - const [host, port] = uri.split(':'); - return { host, port: parseInt(port) || 6379 }; - }); - redis = new Redis.Cluster(hosts); + // Parse credentials from URI or use environment variables (same as redisClients.ts) + const urls = (REDIS_URI || '').split(',').map((uri) => new URL(uri)); + const username = urls[0]?.username || REDIS_USERNAME; + const password = urls[0]?.password || REDIS_PASSWORD; + const ca = getRedisCA(); + + // Redis options (matching redisClients.ts configuration) + const redisOptions = { + username: username, + password: password, + tls: ca ? { ca } : undefined, + connectTimeout: 10000, + maxRetriesPerRequest: 3, + enableOfflineQueue: true, + lazyConnect: false, + }; + + // Handle cluster vs single Redis (same logic as redisClients.ts) + const useCluster = urls.length > 1 || isEnabled(USE_REDIS_CLUSTER); + + if (useCluster) { + const clusterOptions = { + redisOptions, + enableOfflineQueue: true, + }; + + // Add DNS lookup for AWS ElastiCache if needed (same as redisClients.ts) + if (isEnabled(REDIS_USE_ALTERNATIVE_DNS_LOOKUP)) { + clusterOptions.dnsLookup = (address, callback) => callback(null, address); + } + + redis = new IoRedis.Cluster( + urls.map((url) => ({ host: url.hostname, port: parseInt(url.port, 10) || 6379 })), + clusterOptions, + ); } else { - redis = new Redis(REDIS_URI); + // @ts-ignore - ioredis default export is constructable despite linter warning + redis = new IoRedis(REDIS_URI, redisOptions); } + // Wait for connection + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Connection timeout')); + }, 10000); + + redis.once('ready', () => { + clearTimeout(timeout); + resolve(undefined); + }); + + redis.once('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + if (dryRun) { console.log('🔍 [DRY RUN] Would flush Redis cache'); try { @@ -105,7 +178,7 @@ async function flushRedisCache(dryRun = false, verbose = false) { try { const keys = await redis.keys('*'); keyCount = keys.length; - } catch (error) { + } catch (_error) { // Continue with flush even if we can't count keys } @@ -209,7 +282,7 @@ async function main() { } let success = true; - const isRedisEnabled = isEnabled(USE_REDIS) && REDIS_URI; + const isRedisEnabled = isEnabled(USE_REDIS) || (REDIS_URI != null && REDIS_URI !== ''); // Flush the appropriate cache type if (isRedisEnabled) { diff --git a/docker-compose.yml b/docker-compose.yml index dfd11af09f..9a3e4bbd5c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,7 +26,7 @@ services: target: /app/.env - ./images:/app/client/public/images - ./uploads:/app/uploads - - ./logs:/app/api/logs + - ./logs:/app/logs mongodb: container_name: chat-mongodb image: mongo diff --git a/e2e/jestSetup.js b/e2e/jestSetup.js index d7ac4b0b72..8414a041ed 100644 --- a/e2e/jestSetup.js +++ b/e2e/jestSetup.js @@ -1,3 +1,3 @@ -// v0.8.0-rc4 +// v0.8.0 // See .env.test.example for an example of the '.env.test' file. require('dotenv').config({ path: './e2e/.env.test' }); diff --git a/helm/librechat/Chart.yaml b/helm/librechat/Chart.yaml index a7a26d1465..3667333ded 100755 --- a/helm/librechat/Chart.yaml +++ b/helm/librechat/Chart.yaml @@ -23,7 +23,7 @@ version: 1.9.1 # It is recommended to use it with quotes. # renovate: image=ghcr.io/danny-avila/librechat -appVersion: "v0.8.0-rc4" +appVersion: "v0.8.0" home: https://www.librechat.ai diff --git a/helm/librechat/templates/deployment.yaml b/helm/librechat/templates/deployment.yaml index 1d1deec3ac..e046bc507a 100755 --- a/helm/librechat/templates/deployment.yaml +++ b/helm/librechat/templates/deployment.yaml @@ -4,6 +4,13 @@ metadata: name: {{ include "librechat.fullname" $ }} labels: {{- include "librechat.labels" . | nindent 4 }} + {{- with .Values.deploymentLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + annotations: + {{- with .Values.deploymentAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} spec: replicas: {{ .Values.replicaCount }} {{- if .Values.updateStrategy }} @@ -84,6 +91,10 @@ spec: name: {{ .Values.global.librechat.existingSecretName }} optional: true {{- end }} + {{- with .Values.global.librechat.env }} + env: + {{- toYaml . | nindent 12 }} + {{- end }} volumes: {{- if or .Values.librechat.configYamlContent .Values.librechat.existingConfigYaml }} - name: config-yaml diff --git a/helm/librechat/values.yaml b/helm/librechat/values.yaml index bef2075308..2560535504 100755 --- a/helm/librechat/values.yaml +++ b/helm/librechat/values.yaml @@ -7,14 +7,30 @@ replicaCount: 1 global: - # existing Secret for all envs/ only Passwords. Can be locally generated with: kubectl create secret generic librechat-secret-envs --from-env-file=.env.example --dry-run=client -o yaml > secret-envs.yaml + # default Secret for envs/ only Passwords. Can be locally generated with: kubectl create secret generic librechat-secret-envs --from-env-file=.env.example --dry-run=client -o yaml > secret-envs.yaml # For better maintainabillity, you can put all vars directly in the config Section and only overwrite Secrets with this if nessesary. # Required Values: + # - CREDS_KEY + # - CREDS_IV + # - JWT_SECRET + # - JWT_REFRESH_SECRET # - MEILI_MASTER_KEY librechat: existingSecretName: "librechat-credentials-env" # Used for Setting the Right Key, can be something like AZURE_API_KEY, if Azure OpenAI is used existingSecretApiKey: OPENAI_API_KEY + # Optionally add extra globally accessible environment variables here. These can be referenced under ConfigEnv to make them accessible inside LibreChat + # env: + # - name: API_KEY + # valueFrom: + # secretKeyRef: + # name: api_access + # key: api_key + # - name: CLIENT_ID + # valueFrom: + # secretKeyRef: + # name: credentials + # key: client_id librechat: configEnv: @@ -137,6 +153,8 @@ lifecycle: {} podAnnotations: {} podLabels: {} +deploymentAnnotations: {} +deploymentLabels: {} podSecurityContext: fsGroup: 2000 diff --git a/librechat.example.yaml b/librechat.example.yaml index 810f1ac376..a3b66daeba 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -237,13 +237,11 @@ endpoints: baseURL: 'https://api.groq.com/openai/v1/' models: default: - [ - 'llama3-70b-8192', - 'llama3-8b-8192', - 'llama2-70b-4096', - 'mixtral-8x7b-32768', - 'gemma-7b-it', - ] + - 'llama3-70b-8192' + - 'llama3-8b-8192' + - 'llama2-70b-4096' + - 'mixtral-8x7b-32768' + - 'gemma-7b-it' fetch: false titleConvo: true titleModel: 'mixtral-8x7b-32768' @@ -328,6 +326,60 @@ endpoints: forcePrompt: false modelDisplayLabel: 'Portkey' iconURL: https://images.crunchbase.com/image/upload/c_pad,f_auto,q_auto:eco,dpr_1/rjqy7ghvjoiu4cd1xjbf +# Example modelSpecs configuration showing grouping options +# The 'group' field organizes model specs in the UI selector: +# - If 'group' matches an endpoint name (e.g., "openAI", "groq"), the spec appears nested under that endpoint +# - If 'group' is a custom name (doesn't match any endpoint), it creates a separate collapsible section +# - If 'group' is omitted, the spec appears as a standalone item at the top level +# modelSpecs: +# list: +# # Example 1: Nested under an endpoint (grouped with openAI endpoint) +# - name: "gpt-4o" +# label: "GPT-4 Optimized" +# description: "Most capable GPT-4 model with multimodal support" +# group: "openAI" # String value matching the endpoint name +# preset: +# endpoint: "openAI" +# model: "gpt-4o" +# +# # Example 2: Nested under a custom endpoint (grouped with groq endpoint) +# - name: "llama3-70b-8192" +# label: "Llama 3 70B" +# description: "Fastest inference available - great for quick responses" +# group: "groq" # String value matching your custom endpoint name from endpoints.custom +# preset: +# endpoint: "groq" +# model: "llama3-70b-8192" +# +# # Example 3: Custom group (creates a separate collapsible section) +# - name: "coding-assistant" +# label: "Coding Assistant" +# description: "Specialized for coding tasks" +# group: "my-assistants" # Custom string - doesn't match any endpoint, so creates its own group +# preset: +# endpoint: "openAI" +# model: "gpt-4o" +# instructions: "You are an expert coding assistant..." +# temperature: 0.3 +# +# - name: "writing-assistant" +# label: "Writing Assistant" +# description: "Specialized for creative writing" +# group: "my-assistants" # Same custom group name - both specs appear in same section +# preset: +# endpoint: "anthropic" +# model: "claude-sonnet-4" +# instructions: "You are a creative writing expert..." +# +# # Example 4: Standalone (no group - appears at top level) +# - name: "general-assistant" +# label: "General Assistant" +# description: "General purpose assistant" +# # No 'group' field - appears as standalone item at top level (not nested) +# preset: +# endpoint: "openAI" +# model: "gpt-4o-mini" + # fileConfig: # endpoints: # assistants: diff --git a/package-lock.json b/package-lock.json index cce2652ca6..2575fdfb6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "LibreChat", - "version": "v0.8.0-rc4", + "version": "v0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "LibreChat", - "version": "v0.8.0-rc4", + "version": "v0.8.0", "license": "ISC", "workspaces": [ "api", @@ -47,7 +47,7 @@ }, "api": { "name": "@librechat/backend", - "version": "v0.8.0-rc4", + "version": "v0.8.0", "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.52.0", @@ -63,9 +63,8 @@ "@langchain/core": "^0.3.62", "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", - "@langchain/openai": "^0.5.18", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.81", + "@librechat/agents": "^2.4.86", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -110,7 +109,7 @@ "multer": "^2.0.2", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", - "nodemailer": "^6.9.15", + "nodemailer": "^7.0.9", "ollama": "^0.5.0", "openai": "^5.10.1", "openid-client": "^6.5.0", @@ -799,19 +798,6 @@ "node": ">=18.0.0" } }, - "api/node_modules/@keyv/redis": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-4.3.3.tgz", - "integrity": "sha512-J/uhvKu/Qfh11yMUs+9KdcGCLmWFd3vMxtDVQh2j9cOcnrpnM5jE1xU+K1/kI89czSVEdeMyqTC9gGNtwi3JEQ==", - "dependencies": { - "cluster-key-slot": "^1.1.2", - "keyv": "^5.3.2", - "redis": "^4.7.0" - }, - "engines": { - "node": ">= 18" - } - }, "api/node_modules/@langchain/community": { "version": "0.3.47", "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.3.47.tgz", @@ -2212,18 +2198,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "api/node_modules/connect-redis": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-8.1.0.tgz", - "integrity": "sha512-Km0EYLDlmExF52UCss5gLGTtrukGC57G6WCC2aqEMft5Vr4xNWuM4tL+T97kWrw+vp40SXFteb6Xk/7MxgpwdA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "express-session": ">=1" - } - }, "api/node_modules/cookie-parser": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", @@ -2236,46 +2210,6 @@ "node": ">= 0.8.0" } }, - "api/node_modules/express-session": { - "version": "1.18.2", - "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", - "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", - "license": "MIT", - "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.7", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-headers": "~1.1.0", - "parseurl": "~1.3.3", - "safe-buffer": "5.2.1", - "uid-safe": "~2.1.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "api/node_modules/express-session/node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "api/node_modules/express-session/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "api/node_modules/express-session/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "api/node_modules/express/node_modules/cookie": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", @@ -2305,6 +2239,17 @@ "fxparser": "src/cli/cli.js" } }, + "api/node_modules/fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, "api/node_modules/gaxios": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.0.tgz", @@ -2424,10 +2369,29 @@ "url": "https://github.com/sponsors/panva" } }, + "api/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "api/node_modules/keyv": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.3.2.tgz", + "integrity": "sha512-Lji2XRxqqa5Wg+CHLVfFKBImfJZ4pCSccu9eVWK6w4c2SDFLd8JAn1zqTuSFnsxb7ope6rMsnIHfp+eBbRBRZQ==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.0.3" + } + }, "api/node_modules/keyv-file": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.1.2.tgz", "integrity": "sha512-Sx5W55HeSbmsX4BfanJwaJZd3xePoQKxfuysvIhZ3JTPoSeZjApFO1QnuXGVy9hDXpmztS5mm39wlBFOUalVgw==", + "license": "MIT", "dependencies": { "@keyv/serialize": "^1.0.1", "fs-extra": "^4.0.1", @@ -2437,7 +2401,8 @@ "api/node_modules/keyv-file/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" }, "api/node_modules/mkdirp": { "version": "0.5.6", @@ -2451,6 +2416,54 @@ "mkdirp": "bin/cmd.js" } }, + "api/node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "api/node_modules/multer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", @@ -2478,6 +2491,15 @@ "node": ">= 0.6" } }, + "api/node_modules/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "api/node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -2487,15 +2509,6 @@ "node": ">= 6" } }, - "api/node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "api/node_modules/openai": { "version": "5.10.1", "resolved": "https://registry.npmjs.org/openai/-/openai-5.10.1.tgz", @@ -2582,6 +2595,15 @@ "optional": true, "peer": true }, + "api/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "api/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -2623,7 +2645,7 @@ }, "client": { "name": "@librechat/frontend", - "version": "v0.8.0-rc4", + "version": "v0.8.0", "license": "ISC", "dependencies": { "@ariakit/react": "^0.4.15", @@ -2733,6 +2755,7 @@ "babel-plugin-transform-import-meta": "^2.3.2", "babel-plugin-transform-vite-meta-env": "^1.0.3", "eslint-plugin-jest": "^28.11.0", + "fs-extra": "^11.3.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-canvas-mock": "^2.5.2", @@ -4067,390 +4090,6 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, - "client/node_modules/@dicebear/adventurer": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.2.2.tgz", - "integrity": "sha512-WjBXCP9EXbUul2zC3BS2/R3/4diw1uh/lU4jTEnujK1mhqwIwanFboIMzQsasNNL/xf+m3OHN7MUNJfHZ1fLZA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/adventurer-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.2.2.tgz", - "integrity": "sha512-XVAjhUWjav6luTZ7txz8zVJU/H0DiUy4uU1Z7IO5MDO6kWvum+If1+0OUgEWYZwM+RDI7rt2CgVP910DyZGd1w==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/avataaars": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.2.2.tgz", - "integrity": "sha512-WqJPQEt0OhBybTpI0TqU1uD1pSk9M2+VPIwvBye/dXo46b+0jHGpftmxjQwk6tX8z0+mRko8pwV5n+cWht1/+w==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/avataaars-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.2.2.tgz", - "integrity": "sha512-pRj16P27dFDBI3LtdiHUDwIXIGndHAbZf5AxaMkn6/+0X93mVQ/btVJDXyW0G96WCsyC88wKAWr6/KJotPxU6Q==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/big-ears": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.2.2.tgz", - "integrity": "sha512-hz4UXdPq4qqZpu0YVvlqM4RDFhk5i0WgPcuwj/MOLlgTjuj63uHUhCQSk6ZiW1DQOs12qpwUBMGWVHxBRBas9g==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/big-ears-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.2.2.tgz", - "integrity": "sha512-IPHt8fi3dv9cyfBJBZ4s8T+PhFCrQvOCf91iRHBT3iOLNPdyZpI5GNLmGiV0XMAvIDP5NvA5+f6wdoBLhYhbDA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/big-smile": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.2.2.tgz", - "integrity": "sha512-D4td0GL8or1nTNnXvZqkEXlzyqzGPWs3znOnm1HIohtFTeIwXm72Ob2lNDsaQJSJvXmVlwaQQ0CCTvyCl8Stjw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/bottts": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.2.2.tgz", - "integrity": "sha512-wugFkzw8JNWV1nftq/Wp/vmQsLAXDxrMtRK3AoMODuUpSVoP3EHRUfKS043xggOsQFvoj0HZ7kadmhn0AMLf5A==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/bottts-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.2.2.tgz", - "integrity": "sha512-lSgpqmSJtlnyxVuUgNdBwyzuA0O9xa5zRJtz7x2KyWbicXir5iYdX0MVMCkp1EDvlcxm9rGJsclktugOyakTlw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/collection": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.2.2.tgz", - "integrity": "sha512-vZAmXhPWCK3sf8Fj9/QflFC6XOLroJOT5K1HdnzHaPboEvffUQideGCrrEamnJtlH0iF0ZDXh8gqmwy2fu+yHA==", - "dependencies": { - "@dicebear/adventurer": "9.2.2", - "@dicebear/adventurer-neutral": "9.2.2", - "@dicebear/avataaars": "9.2.2", - "@dicebear/avataaars-neutral": "9.2.2", - "@dicebear/big-ears": "9.2.2", - "@dicebear/big-ears-neutral": "9.2.2", - "@dicebear/big-smile": "9.2.2", - "@dicebear/bottts": "9.2.2", - "@dicebear/bottts-neutral": "9.2.2", - "@dicebear/croodles": "9.2.2", - "@dicebear/croodles-neutral": "9.2.2", - "@dicebear/dylan": "9.2.2", - "@dicebear/fun-emoji": "9.2.2", - "@dicebear/glass": "9.2.2", - "@dicebear/icons": "9.2.2", - "@dicebear/identicon": "9.2.2", - "@dicebear/initials": "9.2.2", - "@dicebear/lorelei": "9.2.2", - "@dicebear/lorelei-neutral": "9.2.2", - "@dicebear/micah": "9.2.2", - "@dicebear/miniavs": "9.2.2", - "@dicebear/notionists": "9.2.2", - "@dicebear/notionists-neutral": "9.2.2", - "@dicebear/open-peeps": "9.2.2", - "@dicebear/personas": "9.2.2", - "@dicebear/pixel-art": "9.2.2", - "@dicebear/pixel-art-neutral": "9.2.2", - "@dicebear/rings": "9.2.2", - "@dicebear/shapes": "9.2.2", - "@dicebear/thumbs": "9.2.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/core": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.2.tgz", - "integrity": "sha512-ROhgHG249dPtcXgBHcqPEsDeAPRPRD/9d+tZCjLYyueO+cXDlIA8dUlxpwIVcOuZFvCyW6RJtqo8BhNAi16pIQ==", - "dependencies": { - "@types/json-schema": "^7.0.11" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "client/node_modules/@dicebear/croodles": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.2.2.tgz", - "integrity": "sha512-OzvAXQWsOgMwL3Sl+lBxCubqSOWoBJpC78c4TKnNTS21rR63TtXUyVdLLzgKVN4YHRnvMgtPf8F/W9YAgIDK4w==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/croodles-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.2.2.tgz", - "integrity": "sha512-/4mNirxoQ+z1kHXnpDRbJ1JV1ZgXogeTeNp0MaFYxocCgHfJ7ckNM23EE1I7akoo9pqPxrKlaeNzGAjKHdS9vA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/dylan": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.2.2.tgz", - "integrity": "sha512-s7e3XliC1YXP+Wykj+j5kwdOWFRXFzYHYk/PB4oZ1F3sJandXiG0HS4chaNu4EoP0yZgKyFMUVTGZx+o6tMaYg==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/fun-emoji": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.2.2.tgz", - "integrity": "sha512-M+rYTpB3lfwz18f+/i+ggNwNWUoEj58SJqXJ1wr7Jh/4E5uL+NmJg9JGwYNaVtGbCFrKAjSaILNUWGQSFgMfog==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/glass": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.2.2.tgz", - "integrity": "sha512-imCMxcg+XScHYtQq2MUv1lCzhQSCUglMlPSezKEpXhTxgbgUpmGlSGVkOfmX5EEc7SQowKkF1W/1gNk6CXvBaQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/icons": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.2.2.tgz", - "integrity": "sha512-Tqq2OVCdS7J02DNw58xwlgLGl40sWEckbqXT3qRvIF63FfVq+wQZBGuhuiyAURcSgvsc3h2oQeYFi9iXh7HTOA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/identicon": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.2.2.tgz", - "integrity": "sha512-POVKFulIrcuZf3rdAgxYaSm2XUg/TJg3tg9zq9150reEGPpzWR7ijyJ03dzAADPzS3DExfdYVT9+z3JKwwJnTQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/initials": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.2.tgz", - "integrity": "sha512-/xNnsEmsstWjmF77htAOuwOMhFlP6eBVXgcgFlTl/CCH/Oc6H7t0vwX1he8KLQBBzjGpvJcvIAn4Wh9rE4D5/A==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/lorelei": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.2.2.tgz", - "integrity": "sha512-koXqVr/vcWUPo00VP5H6Czsit+uF1tmwd2NK7Q/e34/9Sd1f4QLLxHjjBNm/iNjCI1+UNTOvZ2Qqu0N5eo7Flw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/lorelei-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.2.2.tgz", - "integrity": "sha512-Eys9Os6nt2Xll7Mvu66CfRR2YggTopWcmFcRZ9pPdohS96kT0MsLI2iTcfZXQ51K8hvT3IbwoGc86W8n0cDxAQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/micah": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.2.2.tgz", - "integrity": "sha512-NCajcJV5yw8uMKiACp694w1T/UyYme2CUEzyTzWHgWnQ+drAuCcH8gpAoLWd67viNdQB/MTpNlaelUgTjmI4AQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/miniavs": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.2.2.tgz", - "integrity": "sha512-vvkWXttdw+KHF3j+9qcUFzK+P0nbNnImGjvN48wwkPIh2h08WWFq0MnoOls4IHwUJC4GXBjWtiyVoCxz6hhtOA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/notionists": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.2.2.tgz", - "integrity": "sha512-Z9orRaHoj7Y9Ap4wEu8XOrFACsG1KbbBQUPV1R50uh6AHwsyNrm4cS84ICoGLvxgLNHHOae3YCjd8aMu2z19zg==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/notionists-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.2.2.tgz", - "integrity": "sha512-AhOzk+lz6kB4uxGun8AJhV+W1nttnMlxmxd+5KbQ/txCIziYIaeD3il44wsAGegEpGFvAZyMYtR/jjfHcem3TA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/open-peeps": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.2.2.tgz", - "integrity": "sha512-6PeQDHYyjvKrGSl/gP+RE5dSYAQGKpcGnM65HorgyTIugZK7STo0W4hvEycedupZ3MCCEH8x/XyiChKM2sHXog==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/personas": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.2.2.tgz", - "integrity": "sha512-705+ObNLC0w1fcgE/Utav+8bqO+Esu53TXegpX5j7trGEoIMf2bThqJGHuhknZ3+T2az3Wr89cGyOGlI0KLzLA==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/pixel-art": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.2.2.tgz", - "integrity": "sha512-BvbFdrpzQl04+Y9UsWP63YGug+ENGC7GMG88qbEFWxb/IqRavGa4H3D0T4Zl2PSLiw7f2Ctv98bsCQZ1PtCznQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/pixel-art-neutral": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.2.2.tgz", - "integrity": "sha512-CdUY77H6Aj7dKLW3hdkv7tu0XQJArUjaWoXihQxlhl3oVYplWaoyu9omYy5pl8HTqs8YgVTGljjMXYoFuK0JUw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/rings": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.2.2.tgz", - "integrity": "sha512-eD1J1k364Arny+UlvGrk12HP/XGG6WxPSm4BarFqdJGSV45XOZlwqoi7FlcMr9r9yvE/nGL8OizbwMYusEEdjw==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/shapes": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.2.2.tgz", - "integrity": "sha512-e741NNWBa7fg0BjomxXa0fFPME2XCIR0FA+VHdq9AD2taTGHEPsg5x1QJhCRdK6ww85yeu3V3ucpZXdSrHVw5Q==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, - "client/node_modules/@dicebear/thumbs": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.2.2.tgz", - "integrity": "sha512-FkPLDNu7n5kThLSk7lR/0cz/NkUqgGdZGfLZv6fLkGNGtv6W+e2vZaO7HCXVwIgJ+II+kImN41zVIZ6Jlll7pQ==", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@dicebear/core": "^9.0.0" - } - }, "client/node_modules/@react-spring/web": { "version": "9.7.5", "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", @@ -17660,7 +17299,6 @@ "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" }, @@ -17673,7 +17311,6 @@ "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" }, @@ -17686,7 +17323,6 @@ "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" }, @@ -17699,7 +17335,6 @@ "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" }, @@ -17712,7 +17347,6 @@ "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" }, @@ -17725,7 +17359,6 @@ "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" }, @@ -17738,7 +17371,6 @@ "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" }, @@ -17751,7 +17383,6 @@ "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" }, @@ -17764,7 +17395,6 @@ "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" }, @@ -17777,7 +17407,6 @@ "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", @@ -17822,7 +17451,6 @@ "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" }, @@ -17835,7 +17463,6 @@ "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" }, @@ -17848,7 +17475,6 @@ "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" }, @@ -17861,7 +17487,6 @@ "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" }, @@ -17874,7 +17499,6 @@ "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" }, @@ -17887,7 +17511,6 @@ "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" }, @@ -17900,7 +17523,6 @@ "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" }, @@ -17913,7 +17535,6 @@ "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" }, @@ -17926,7 +17547,6 @@ "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.4.tgz", "integrity": "sha512-4SzHG5WoQZl1TGcpEZR4bdsSkUVqwNQCOwWSPAoBJa3BNxbVsvL08LF7I97BMgrCoknWZjQHUYt05amwTPTKtg==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -17939,7 +17559,6 @@ "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" }, @@ -17952,7 +17571,6 @@ "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" }, @@ -17965,7 +17583,6 @@ "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" }, @@ -17978,7 +17595,6 @@ "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" }, @@ -17991,7 +17607,6 @@ "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" }, @@ -18004,7 +17619,6 @@ "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" }, @@ -18017,7 +17631,6 @@ "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" }, @@ -18030,7 +17643,6 @@ "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" }, @@ -18043,7 +17655,6 @@ "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" }, @@ -18056,7 +17667,6 @@ "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" }, @@ -18069,7 +17679,6 @@ "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" }, @@ -18082,7 +17691,6 @@ "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" }, @@ -18095,7 +17703,6 @@ "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" }, @@ -20452,6 +20059,20 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@keyv/redis": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-4.3.3.tgz", + "integrity": "sha512-J/uhvKu/Qfh11yMUs+9KdcGCLmWFd3vMxtDVQh2j9cOcnrpnM5jE1xU+K1/kI89czSVEdeMyqTC9gGNtwi3JEQ==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "^1.1.2", + "keyv": "^5.3.2", + "redis": "^4.7.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@keyv/serialize": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz", @@ -21910,9 +21531,9 @@ } }, "node_modules/@librechat/agents": { - "version": "2.4.81", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.81.tgz", - "integrity": "sha512-uPepwOepQS03NJg9jzLvYGonyewy33QDB7iENKHooO8+6eIOv2QC4gm1k/fYKgsIfBfng2caRmn532UTrkE3rQ==", + "version": "2.4.86", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.86.tgz", + "integrity": "sha512-Z3v+vMfFEyrDWrlPvgY9dUlhzYvtLXYYULEzkxUM1QpITuI3DsXr3xb1kXHAYOx3NmBGxiN9R/gjZN0tGBEo1g==", "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.26", @@ -22484,6 +22105,54 @@ "node": ">= 14" } }, + "node_modules/@librechat/agents/node_modules/mongodb": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, "node_modules/@librechat/agents/node_modules/openai": { "version": "5.11.0", "resolved": "https://registry.npmjs.org/openai/-/openai-5.11.0.tgz", @@ -23132,9 +22801,10 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", - "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz", + "integrity": "sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg==", + "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" } @@ -29647,6 +29317,16 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -32505,6 +32185,18 @@ "source-map": "^0.6.1" } }, + "node_modules/connect-redis": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-8.1.0.tgz", + "integrity": "sha512-Km0EYLDlmExF52UCss5gLGTtrukGC57G6WCC2aqEMft5Vr4xNWuM4tL+T97kWrw+vp40SXFteb6Xk/7MxgpwdA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "express-session": ">=1" + } + }, "node_modules/console-browserify": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.2.0.tgz", @@ -35058,6 +34750,46 @@ "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/express-static-gzip": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/express-static-gzip/-/express-static-gzip-2.2.0.tgz", @@ -35776,13 +35508,18 @@ } }, "node_modules/fs-extra": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", - "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, + "license": "MIT", "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" } }, "node_modules/fs.realpath": { @@ -36716,6 +36453,13 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/hookified": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.1.tgz", + "integrity": "sha512-xnKGl+iMIlhrZmGHB729MqlmPoWBznctSQTYCpFKqNsCgimJQmithcW0xSQMMFzYnV2iKUh25alswn6epgxS0Q==", + "license": "MIT", + "peer": true + }, "node_modules/htm": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", @@ -37236,6 +36980,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", + "license": "MIT", "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", @@ -38968,9 +38713,14 @@ } }, "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -39130,13 +38880,38 @@ } }, "node_modules/keyv": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.3.2.tgz", - "integrity": "sha512-Lji2XRxqqa5Wg+CHLVfFKBImfJZ4pCSccu9eVWK6w4c2SDFLd8JAn1zqTuSFnsxb7ope6rMsnIHfp+eBbRBRZQ==", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.2.tgz", + "integrity": "sha512-TXcFHbmm/z7MGd1u9ASiCSfTS+ei6Z8B3a5JHzx3oPa/o7QzWVtPRpc4KGER5RR469IC+/nfg4U5YLIuDUua2g==", + "license": "MIT", "dependencies": { - "@keyv/serialize": "^1.0.3" + "@keyv/serialize": "^1.1.1" } }, + "node_modules/keyv-file": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.2.0.tgz", + "integrity": "sha512-5JEBqQiDzjGCQHtf7KLReJdHKchaJyUZW+9TvBu+4dc+uuTqUG9KcdA3ICMXlwky3qjKc0ecNCNefbgjyDtlAg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@keyv/serialize": "^1.0.1", + "tslib": "^1.14.1" + } + }, + "node_modules/keyv-file/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD", + "peer": true + }, + "node_modules/keyv/node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -41727,14 +41502,13 @@ } }, "node_modules/mongodb": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz", - "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==", - "devOptional": true, + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz", + "integrity": "sha512-kMEHNo0F3P6QKDq17zcDuPeaywK/YaJVCEQRzPF3TOM/Bl9MFg64YE5Tu7ifj37qZJMhwU1tl2Ioivws5gRG5Q==", "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.1.9", - "bson": "^6.10.4", + "bson": "^6.10.3", "mongodb-connection-string-url": "^3.0.0" }, "engines": { @@ -41890,52 +41664,6 @@ "url": "https://opencollective.com/mongoose" } }, - "node_modules/mongoose/node_modules/mongodb": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.14.2.tgz", - "integrity": "sha512-kMEHNo0F3P6QKDq17zcDuPeaywK/YaJVCEQRzPF3TOM/Bl9MFg64YE5Tu7ifj37qZJMhwU1tl2Ioivws5gRG5Q==", - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.1.9", - "bson": "^6.10.3", - "mongodb-connection-string-url": "^3.0.0" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.2.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, "node_modules/moo-color": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", @@ -42254,14 +41982,6 @@ "dev": true, "license": "MIT" }, - "node_modules/nodemailer": { - "version": "6.9.15", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.15.tgz", - "integrity": "sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/nodemon": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.3.tgz", @@ -42552,6 +42272,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -45476,6 +45205,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.0.tgz", "integrity": "sha512-wV450NQyKC24NmPosJb2131RoczLdfIJdKCReNwtVpm5998U8SgKrAZrIHaN/NfQgqOHaan8Uq++B4sa5REwjA==", + "license": "MIT", "engines": { "node": ">= 16" }, @@ -47256,27 +46986,6 @@ "node": ">=12" } }, - "node_modules/rollup-plugin-typescript2/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/rollup-plugin-typescript2/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/rollup-pluginutils": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", @@ -49878,11 +49587,13 @@ } }, "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">= 4.0.0" + "node": ">= 10.0.0" } }, "node_modules/unpipe": { @@ -51006,18 +50717,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/workbox-build/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/workbox-build/node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -51075,15 +50774,6 @@ "punycode": "^2.1.0" } }, - "node_modules/workbox-build/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/workbox-build/node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -51608,7 +51298,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.4.0", + "version": "1.4.1", "license": "ISC", "devDependencies": { "@babel/preset-env": "^7.21.5", @@ -51623,6 +51313,7 @@ "@types/bun": "^1.2.15", "@types/diff": "^6.0.0", "@types/express": "^5.0.0", + "@types/express-session": "^1.18.2", "@types/jest": "^29.5.2", "@types/jsonwebtoken": "^9.0.0", "@types/multer": "^1.4.13", @@ -51632,7 +51323,7 @@ "jest": "^29.5.0", "jest-junit": "^16.0.0", "librechat-data-provider": "*", - "mongoose": "^8.12.1", + "mongodb": "^6.14.2", "rimraf": "^5.0.1", "rollup": "^4.22.4", "rollup-plugin-peer-deps-external": "^2.2.4", @@ -51640,25 +51331,56 @@ "typescript": "^5.0.4" }, "peerDependencies": { + "@aws-sdk/client-s3": "^3.758.0", + "@azure/identity": "^4.7.0", + "@azure/search-documents": "^12.0.0", + "@azure/storage-blob": "^12.27.0", + "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.81", + "@librechat/agents": "^2.4.86", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", + "connect-redis": "^8.1.0", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2", + "express-session": "^1.18.2", + "firebase": "^11.0.2", "form-data": "^4.0.4", + "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "keyv": "^5.3.2", + "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "memorystore": "^1.6.7", + "mongoose": "^8.12.1", "node-fetch": "2.7.0", + "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", "undici": "^7.10.0", "zod": "^3.22.4" } }, + "packages/api/node_modules/@keyv/redis": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-4.6.0.tgz", + "integrity": "sha512-FP3FP42RiQ3j0UC6f4Maf7ISTLAIivm37/SdfG5xvhqceMMq3kabtC6T4a2h5byMnh4S8PjP51DY/9CpyrcfsQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@redis/client": "^1.6.0", + "cluster-key-slot": "^1.1.2", + "hookified": "^1.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.3.4" + } + }, "packages/api/node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -51736,7 +51458,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.3.0", + "version": "0.3.1", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.2", @@ -52038,7 +51760,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.010", + "version": "0.8.020", "license": "ISC", "dependencies": { "axios": "^1.12.1", @@ -52142,7 +51864,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.22", + "version": "0.0.23", "license": "MIT", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", diff --git a/package.json b/package.json index 4b7788bcf6..4a69267be2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "LibreChat", - "version": "v0.8.0-rc4", + "version": "v0.8.0", "description": "", "workspaces": [ "api", @@ -41,7 +41,9 @@ "build:data-provider": "cd packages/data-provider && npm run build", "build:api": "cd packages/api && npm run build", "build:data-schemas": "cd packages/data-schemas && npm run build", + "build:client": "cd client && npm run build", "build:client-package": "cd packages/client && npm run build", + "build:packages": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package", "frontend": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package && cd client && npm run build", "frontend:ci": "npm run build:data-provider && npm run build:client-package && cd client && npm run build:ci", "frontend:dev": "cd client && npm run dev", diff --git a/packages/api/package.json b/packages/api/package.json index c5b48d948a..a642cf6e23 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/api", - "version": "1.4.0", + "version": "1.4.1", "type": "commonjs", "description": "MCP services for LibreChat", "main": "dist/index.js", @@ -18,8 +18,9 @@ "build:dev": "npm run clean && NODE_ENV=development rollup -c --bundleConfigAsCjs", "build:watch": "NODE_ENV=development rollup -c -w --bundleConfigAsCjs", "build:watch:prod": "rollup -c -w --bundleConfigAsCjs", - "test": "jest --coverage --watch", - "test:ci": "jest --coverage --ci", + "test": "jest --coverage --watch --testPathIgnorePatterns=\"\\.integration\\.\"", + "test:ci": "jest --coverage --ci --testPathIgnorePatterns=\"\\.integration\\.\"", + "test:cache:integration": "jest --testPathPattern=\"src/cache/.*\\.integration\\.spec\\.ts$\" --coverage=false", "verify": "npm run test:ci", "b:clean": "bun run rimraf dist", "b:build": "bun run b:clean && bun run rollup -c --silent --bundleConfigAsCjs", @@ -52,6 +53,7 @@ "@types/bun": "^1.2.15", "@types/diff": "^6.0.0", "@types/express": "^5.0.0", + "@types/express-session": "^1.18.2", "@types/jest": "^29.5.2", "@types/jsonwebtoken": "^9.0.0", "@types/multer": "^1.4.13", @@ -61,7 +63,7 @@ "jest": "^29.5.0", "jest-junit": "^16.0.0", "librechat-data-provider": "*", - "mongoose": "^8.12.1", + "mongodb": "^6.14.2", "rimraf": "^5.0.1", "rollup": "^4.22.4", "rollup-plugin-peer-deps-external": "^2.2.4", @@ -72,20 +74,33 @@ "registry": "https://registry.npmjs.org/" }, "peerDependencies": { + "@aws-sdk/client-s3": "^3.758.0", + "@azure/identity": "^4.7.0", + "@azure/search-documents": "^12.0.0", + "@azure/storage-blob": "^12.27.0", + "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.62", - "@librechat/agents": "^2.4.81", + "@librechat/agents": "^2.4.86", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.17.1", "axios": "^1.12.1", + "connect-redis": "^8.1.0", "diff": "^7.0.0", "eventsource": "^3.0.2", "express": "^4.21.2", + "express-session": "^1.18.2", + "firebase": "^11.0.2", "form-data": "^4.0.4", + "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "keyv": "^5.3.2", + "keyv-file": "^5.1.2", "librechat-data-provider": "*", + "memorystore": "^1.6.7", + "mongoose": "^8.12.1", "node-fetch": "2.7.0", + "rate-limit-redis": "^4.2.0", "tiktoken": "^1.0.15", "undici": "^7.10.0", "zod": "^3.22.4" diff --git a/packages/api/rollup.config.js b/packages/api/rollup.config.js index 38f2df1559..9a9de35e8f 100644 --- a/packages/api/rollup.config.js +++ b/packages/api/rollup.config.js @@ -18,6 +18,7 @@ const plugins = [ peerDepsExternal(), resolve({ preferBuiltins: true, + skipSelf: true, }), replace({ __IS_DEV__: isDevelopment, diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 0814e80f31..c22dc0fbf4 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -1,4 +1,3 @@ -export * from './config'; export * from './memory'; export * from './migration'; export * from './legacy'; diff --git a/packages/api/src/agents/resources.test.ts b/packages/api/src/agents/resources.test.ts index b594924934..bfd2327764 100644 --- a/packages/api/src/agents/resources.test.ts +++ b/packages/api/src/agents/resources.test.ts @@ -2,10 +2,9 @@ import { primeResources } from './resources'; import { logger } from '@librechat/data-schemas'; import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider'; import type { TAgentsEndpoint, TFile } from 'librechat-data-provider'; +import type { IUser, AppConfig } from '@librechat/data-schemas'; import type { Request as ServerRequest } from 'express'; -import type { IUser } from '@librechat/data-schemas'; import type { TGetFiles } from './resources'; -import type { AppConfig } from '~/types'; // Mock logger jest.mock('@librechat/data-schemas', () => ({ diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index d746e3c19f..9c32638a9c 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -1,10 +1,9 @@ import { logger } from '@librechat/data-schemas'; import { EModelEndpoint, EToolResources, AgentCapabilities } from 'librechat-data-provider'; import type { AgentToolResources, TFile, AgentBaseResource } from 'librechat-data-provider'; +import type { IMongoFile, AppConfig, IUser } from '@librechat/data-schemas'; import type { FilterQuery, QueryOptions, ProjectionType } from 'mongoose'; -import type { IMongoFile, IUser } from '@librechat/data-schemas'; import type { Request as ServerRequest } from 'express'; -import type { AppConfig } from '~/types/'; /** * Function type for retrieving files from the database diff --git a/packages/api/src/app/AppService.interface.spec.ts b/packages/api/src/app/AppService.interface.spec.ts new file mode 100644 index 0000000000..e15d57e329 --- /dev/null +++ b/packages/api/src/app/AppService.interface.spec.ts @@ -0,0 +1,157 @@ +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +import { AppService } from '@librechat/data-schemas'; + +describe('AppService interface configuration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should set prompts to true when config specifies prompts as true', async () => { + const config = { + interface: { + prompts: true, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: true, + }), + }), + ); + }); + + it('should set prompts and bookmarks to false when config specifies them as false', async () => { + const config = { + interface: { + prompts: false, + bookmarks: false, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: false, + bookmarks: false, + }), + }), + ); + }); + + it('should not set prompts and bookmarks when not provided in config', async () => { + const config = {}; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.anything(), + }), + ); + + // Verify that prompts and bookmarks are undefined when not provided + expect(result.interfaceConfig?.prompts).toBeUndefined(); + expect(result.interfaceConfig?.bookmarks).toBeUndefined(); + }); + + it('should set prompts and bookmarks to different values when specified differently in config', async () => { + const config = { + interface: { + prompts: true, + bookmarks: false, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + prompts: true, + bookmarks: false, + }), + }), + ); + }); + + it('should correctly configure peoplePicker permissions including roles', async () => { + const config = { + interface: { + peoplePicker: { + users: true, + groups: true, + roles: true, + }, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: true, + roles: true, + }), + }), + }), + ); + }); + + it('should handle mixed peoplePicker permissions', async () => { + const config = { + interface: { + peoplePicker: { + users: true, + groups: false, + roles: true, + }, + }, + }; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.objectContaining({ + peoplePicker: expect.objectContaining({ + users: true, + groups: false, + roles: true, + }), + }), + }), + ); + }); + + it('should not set peoplePicker when not provided in config', async () => { + const config = {}; + + const result = await AppService({ config }); + + expect(result).toEqual( + expect.objectContaining({ + interfaceConfig: expect.anything(), + }), + ); + + // Verify that peoplePicker is undefined when not provided + expect(result.interfaceConfig?.peoplePicker).toBeUndefined(); + }); +}); diff --git a/api/server/services/AppService.spec.js b/packages/api/src/app/AppService.spec.ts similarity index 58% rename from api/server/services/AppService.spec.js rename to packages/api/src/app/AppService.spec.ts index 1b540c96c0..9c771b4bd6 100644 --- a/api/server/services/AppService.spec.js +++ b/packages/api/src/app/AppService.spec.ts @@ -1,4 +1,5 @@ -const { +import { + OCRStrategy, FileSources, EModelEndpoint, EImageOutputType, @@ -6,9 +7,8 @@ const { defaultSocialLogins, validateAzureGroups, defaultAgentCapabilities, - deprecatedAzureVariables, - conflictingAzureVariables, -} = require('librechat-data-provider'); +} from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/data-schemas'), @@ -20,48 +20,7 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); -const AppService = require('./AppService'); - -jest.mock('./Files/Firebase/initialize', () => ({ - initializeFirebase: jest.fn(), -})); - -jest.mock('./Config/loadCustomConfig', () => - jest.fn(() => - Promise.resolve({ - registration: { socialLogins: ['testLogin'] }, - fileStrategy: 'testStrategy', - balance: { - enabled: true, - }, - }), - ), -); - -jest.mock('./start/tools', () => ({ - loadAndFormatTools: jest.fn().mockReturnValue({ - ExampleTool: { - type: 'function', - function: { - description: 'Example tool function', - name: 'exampleFunction', - parameters: { - type: 'object', - properties: { - param1: { type: 'string', description: 'An example parameter' }, - }, - required: ['param1'], - }, - }, - }, - }), -})); -jest.mock('./start/turnstile', () => ({ - loadTurnstileConfig: jest.fn(() => ({ - siteKey: 'default-site-key', - options: {}, - })), -})); +import { AppService } from '@librechat/data-schemas'; const azureGroups = [ { @@ -97,20 +56,26 @@ const azureGroups = [ models: { 'gpt-4-turbo': true, }, - }, + } as const, ]; -jest.mock('./start/checks', () => ({ - ...jest.requireActual('./start/checks'), - checkHealth: jest.fn(), -})); - describe('AppService', () => { - const mockedTurnstileConfig = { - siteKey: 'default-site-key', - options: {}, + const mockSystemTools = { + ExampleTool: { + type: 'function', + function: { + description: 'Example tool function', + name: 'exampleFunction', + parameters: { + type: 'object', + properties: { + param1: { type: 'string', description: 'An example parameter' }, + }, + required: ['param1'], + }, + }, + }, }; - const loadCustomConfig = require('./Config/loadCustomConfig'); beforeEach(() => { process.env.CDN_PROVIDER = undefined; @@ -118,7 +83,15 @@ describe('AppService', () => { }); it('should correctly assign process.env and initialize app config based on custom config', async () => { - const result = await AppService(); + const config: Partial = { + registration: { socialLogins: ['testLogin'] }, + fileStrategy: 'testStrategy' as FileSources, + balance: { + enabled: true, + }, + }; + + const result = await AppService({ config, systemTools: mockSystemTools }); expect(process.env.CDN_PROVIDER).toEqual('testStrategy'); @@ -139,9 +112,6 @@ describe('AppService', () => { presets: true, }), mcpConfig: null, - turnstileConfig: mockedTurnstileConfig, - modelSpecs: undefined, - paths: expect.anything(), imageOutputType: expect.any(String), fileConfig: undefined, secureImageLinks: undefined, @@ -173,30 +143,13 @@ describe('AppService', () => { ); }); - it('should log a warning if the config version is outdated', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - version: '0.9.0', // An outdated version for this test - registration: { socialLogins: ['testLogin'] }, - fileStrategy: 'testStrategy', - }), - ); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Outdated Config version')); - }); - it('should change the `imageOutputType` based on config value', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - version: '0.10.0', - imageOutputType: EImageOutputType.WEBP, - }), - ); + const config = { + version: '0.10.0', + imageOutputType: EImageOutputType.WEBP, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ imageOutputType: EImageOutputType.WEBP, @@ -205,13 +158,11 @@ describe('AppService', () => { }); it('should default to `PNG` `imageOutputType` with no provided type', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - version: '0.10.0', - }), - ); + const config = { + version: '0.10.0', + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ imageOutputType: EImageOutputType.PNG, @@ -220,9 +171,9 @@ describe('AppService', () => { }); it('should default to `PNG` `imageOutputType` with no provided config', async () => { - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); + const config = {}; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ imageOutputType: EImageOutputType.PNG, @@ -230,35 +181,14 @@ describe('AppService', () => { ); }); - it('should initialize Firebase when fileStrategy is firebase', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - fileStrategy: FileSources.firebase, - }), - ); - - await AppService(); - - const { initializeFirebase } = require('./Files/Firebase/initialize'); - expect(initializeFirebase).toHaveBeenCalled(); - - expect(process.env.CDN_PROVIDER).toEqual(FileSources.firebase); - }); - it('should load and format tools accurately with defined structure', async () => { - const { loadAndFormatTools } = require('./start/tools'); + const config = {}; - const result = await AppService(); - - expect(loadAndFormatTools).toHaveBeenCalledWith({ - adminFilter: undefined, - adminIncluded: undefined, - directory: expect.anything(), - }); + const result = await AppService({ config, systemTools: mockSystemTools }); // Verify tools are included in the returned config expect(result.availableTools).toBeDefined(); - expect(result.availableTools.ExampleTool).toEqual({ + expect(result.availableTools?.ExampleTool).toEqual({ type: 'function', function: { description: 'Example tool function', @@ -275,21 +205,19 @@ describe('AppService', () => { }); it('should correctly configure Assistants endpoint based on custom config', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.assistants]: { - disableBuilder: true, - pollIntervalMs: 5000, - timeoutMs: 30000, - supportedIds: ['id1', 'id2'], - privateAssistants: false, - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.assistants]: { + disableBuilder: true, + pollIntervalMs: 5000, + timeoutMs: 30000, + supportedIds: ['id1', 'id2'], + privateAssistants: false, }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -307,21 +235,19 @@ describe('AppService', () => { }); it('should correctly configure Agents endpoint based on custom config', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.agents]: { - disableBuilder: true, - recursionLimit: 10, - maxRecursionLimit: 20, - allowedProviders: ['openai', 'anthropic'], - capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.agents]: { + disableBuilder: true, + recursionLimit: 10, + maxRecursionLimit: 20, + allowedProviders: ['openai', 'anthropic'], + capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -342,9 +268,9 @@ describe('AppService', () => { }); it('should configure Agents endpoint with defaults when no config is provided', async () => { - loadCustomConfig.mockImplementationOnce(() => Promise.resolve({})); + const config = {}; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -359,17 +285,15 @@ describe('AppService', () => { }); it('should configure Agents endpoint with defaults when endpoints exist but agents is not defined', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - }, + const config = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -388,21 +312,19 @@ describe('AppService', () => { it('should correctly configure minimum Azure OpenAI Assistant values', async () => { const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }]; - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: assistantGroups, - assistants: true, - }, + const config = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + groups: assistantGroups, + assistants: true, }, - }), - ); + }, + }; process.env.WESTUS_API_KEY = 'westus-key'; process.env.EASTUS_API_KEY = 'eastus-key'; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ endpoints: expect.objectContaining({ @@ -419,20 +341,18 @@ describe('AppService', () => { }); it('should correctly configure Azure OpenAI endpoint based on custom config', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + groups: azureGroups, }, - }), - ); + }, + }; process.env.WESTUS_API_KEY = 'westus-key'; process.env.EASTUS_API_KEY = 'eastus-key'; - const result = await AppService(); + const result = await AppService({ config }); const { modelNames, modelGroupMap, groupMap } = validateAzureGroups(azureGroups); expect(result).toEqual( @@ -456,8 +376,9 @@ describe('AppService', () => { process.env.FILE_UPLOAD_USER_WINDOW = '20'; const initialEnv = { ...process.env }; + const config = {}; - await AppService(); + await AppService({ config }); // Expect environment variables to remain unchanged expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX); @@ -466,38 +387,15 @@ describe('AppService', () => { expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual(initialEnv.FILE_UPLOAD_USER_WINDOW); }); - it('should correctly set FILE_UPLOAD environment variables based on rate limits', async () => { - // Define and mock a custom configuration with rate limits - const rateLimitsConfig = { - rateLimits: { - fileUploads: { - ipMax: '100', - ipWindowInMinutes: '60', - userMax: '50', - userWindowInMinutes: '30', - }, - }, - }; - - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(rateLimitsConfig)); - - await AppService(); - - // Verify that process.env has been updated according to the rate limits config - expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100'); - expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('60'); - expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('50'); - expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual('30'); - }); - it('should fallback to default FILE_UPLOAD environment variables when rate limits are unspecified', async () => { // Setup initial environment variables to non-default values process.env.FILE_UPLOAD_IP_MAX = 'initialMax'; process.env.FILE_UPLOAD_IP_WINDOW = 'initialWindow'; process.env.FILE_UPLOAD_USER_MAX = 'initialUserMax'; process.env.FILE_UPLOAD_USER_WINDOW = 'initialUserWindow'; + const config = {}; - await AppService(); + await AppService({ config }); // Verify that process.env falls back to the initial values expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initialMax'); @@ -514,8 +412,9 @@ describe('AppService', () => { process.env.IMPORT_USER_WINDOW = '20'; const initialEnv = { ...process.env }; + const config = {}; - await AppService(); + await AppService({ config }); // Expect environment variables to remain unchanged expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); @@ -524,38 +423,15 @@ describe('AppService', () => { expect(process.env.IMPORT_USER_WINDOW).toEqual(initialEnv.IMPORT_USER_WINDOW); }); - it('should correctly set IMPORT environment variables based on rate limits', async () => { - // Define and mock a custom configuration with rate limits - const importLimitsConfig = { - rateLimits: { - conversationsImport: { - ipMax: '150', - ipWindowInMinutes: '60', - userMax: '50', - userWindowInMinutes: '30', - }, - }, - }; - - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(importLimitsConfig)); - - await AppService(); - - // Verify that process.env has been updated according to the rate limits config - expect(process.env.IMPORT_IP_MAX).toEqual('150'); - expect(process.env.IMPORT_IP_WINDOW).toEqual('60'); - expect(process.env.IMPORT_USER_MAX).toEqual('50'); - expect(process.env.IMPORT_USER_WINDOW).toEqual('30'); - }); - it('should fallback to default IMPORT environment variables when rate limits are unspecified', async () => { // Setup initial environment variables to non-default values process.env.IMPORT_IP_MAX = 'initialMax'; process.env.IMPORT_IP_WINDOW = 'initialWindow'; process.env.IMPORT_USER_MAX = 'initialUserMax'; process.env.IMPORT_USER_WINDOW = 'initialUserWindow'; + const config = {}; - await AppService(); + await AppService({ config }); // Verify that process.env falls back to the initial values expect(process.env.IMPORT_IP_MAX).toEqual('initialMax'); @@ -565,34 +441,32 @@ describe('AppService', () => { }); it('should correctly configure endpoint with titlePrompt, titleMethod, and titlePromptTemplate', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - titleMethod: 'structured', - titlePrompt: 'Custom title prompt for conversation', - titlePromptTemplate: 'Summarize this conversation: {{conversation}}', - }, - [EModelEndpoint.assistants]: { - titleMethod: 'functions', - titlePrompt: 'Generate a title for this assistant conversation', - titlePromptTemplate: 'Assistant conversation template: {{messages}}', - }, - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - titleConvo: true, - titleMethod: 'completion', - titleModel: 'gpt-4', - titlePrompt: 'Azure title prompt', - titlePromptTemplate: 'Azure conversation: {{context}}', - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + titleMethod: 'structured', + titlePrompt: 'Custom title prompt for conversation', + titlePromptTemplate: 'Summarize this conversation: {{conversation}}', }, - }), - ); + [EModelEndpoint.assistants]: { + titleMethod: 'functions', + titlePrompt: 'Generate a title for this assistant conversation', + titlePromptTemplate: 'Assistant conversation template: {{messages}}', + }, + [EModelEndpoint.azureOpenAI]: { + groups: azureGroups, + titleConvo: true, + titleMethod: 'completion', + titleModel: 'gpt-4', + titlePrompt: 'Azure title prompt', + titlePromptTemplate: 'Azure conversation: {{context}}', + }, + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -625,24 +499,25 @@ describe('AppService', () => { }); it('should configure Agent endpoint with title generation settings', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.agents]: { - disableBuilder: false, - titleConvo: true, - titleModel: 'gpt-4', - titleMethod: 'structured', - titlePrompt: 'Generate a descriptive title for this agent conversation', - titlePromptTemplate: 'Agent conversation summary: {{content}}', - recursionLimit: 15, - capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.agents]: { + disableBuilder: false, + titleConvo: true, + titleModel: 'gpt-4', + titleMethod: 'structured', + titlePrompt: 'Generate a descriptive title for this agent conversation', + titlePromptTemplate: 'Agent conversation summary: {{content}}', + recursionLimit: 15, + capabilities: [AgentCapabilities.tools, AgentCapabilities.actions], + maxCitations: 30, + maxCitationsPerFile: 7, + minRelevanceScore: 0.45, }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -666,18 +541,16 @@ describe('AppService', () => { }); it('should handle missing title configuration options with defaults', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - // titlePrompt and titlePromptTemplate are not provided - }, + const config = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, + // titlePrompt and titlePromptTemplate are not provided }, - }), - ); + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -696,24 +569,27 @@ describe('AppService', () => { }); it('should correctly configure titleEndpoint when specified', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.openAI]: { - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - titleEndpoint: EModelEndpoint.anthropic, - titlePrompt: 'Generate a concise title', - }, - [EModelEndpoint.agents]: { - titleEndpoint: 'custom-provider', - titleMethod: 'structured', - }, + const config: Partial = { + endpoints: { + [EModelEndpoint.openAI]: { + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + titleEndpoint: EModelEndpoint.anthropic, + titlePrompt: 'Generate a concise title', }, - }), - ); + [EModelEndpoint.agents]: { + disableBuilder: false, + capabilities: [AgentCapabilities.tools], + maxCitations: 30, + maxCitationsPerFile: 7, + minRelevanceScore: 0.45, + titleEndpoint: 'custom-provider', + titleMethod: 'structured', + }, + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -736,27 +612,25 @@ describe('AppService', () => { }); it('should correctly configure all endpoint when specified', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - all: { - titleConvo: true, - titleModel: 'gpt-4o-mini', - titleMethod: 'structured', - titlePrompt: 'Default title prompt for all endpoints', - titlePromptTemplate: 'Default template: {{conversation}}', - titleEndpoint: EModelEndpoint.anthropic, - streamRate: 50, - }, - [EModelEndpoint.openAI]: { - titleConvo: true, - titleModel: 'gpt-3.5-turbo', - }, + const config: Partial = { + endpoints: { + all: { + titleConvo: true, + titleModel: 'gpt-4o-mini', + titleMethod: 'structured', + titlePrompt: 'Default title prompt for all endpoints', + titlePromptTemplate: 'Default template: {{conversation}}', + titleEndpoint: EModelEndpoint.anthropic, + streamRate: 50, }, - }), - ); + [EModelEndpoint.openAI]: { + titleConvo: true, + titleModel: 'gpt-3.5-turbo', + }, + }, + }; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -783,8 +657,7 @@ describe('AppService', () => { }); describe('AppService updating app config and issuing warnings', () => { - let initialEnv; - const loadCustomConfig = require('./Config/loadCustomConfig'); + let initialEnv: NodeJS.ProcessEnv; beforeEach(() => { // Store initial environment variables to restore them after each test @@ -799,15 +672,13 @@ describe('AppService updating app config and issuing warnings', () => { process.env = { ...initialEnv }; }); - it('should initialize app config with default values if loadCustomConfig returns undefined', async () => { - // Mock loadCustomConfig to return undefined - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(undefined)); + it('should initialize app config with default values if config is empty', async () => { + const config = {}; - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ - paths: expect.anything(), config: {}, fileStrategy: FileSources.local, registration: expect.objectContaining({ @@ -821,10 +692,10 @@ describe('AppService updating app config and issuing warnings', () => { ); }); - it('should initialize app config with values from loadCustomConfig', async () => { + it('should initialize app config with values from config', async () => { // Mock loadCustomConfig to return a specific config object with a complete balance config - const customConfig = { - fileStrategy: 'firebase', + const config: Partial = { + fileStrategy: FileSources.firebase, registration: { socialLogins: ['testLogin'] }, balance: { enabled: false, @@ -835,27 +706,28 @@ describe('AppService updating app config and issuing warnings', () => { refillAmount: 5000, }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(customConfig)); - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ - paths: expect.anything(), - config: customConfig, - fileStrategy: customConfig.fileStrategy, + config, + fileStrategy: config.fileStrategy, registration: expect.objectContaining({ - socialLogins: customConfig.registration.socialLogins, + socialLogins: config.registration?.socialLogins, }), - balance: customConfig.balance, + balance: config.balance, }), ); }); it('should apply the assistants endpoint configuration correctly to app config', async () => { - const mockConfig = { + const config: Partial = { endpoints: { assistants: { + version: 'v2', + retrievalModels: ['gpt-4', 'gpt-3.5-turbo'], + capabilities: [], disableBuilder: true, pollIntervalMs: 5000, timeoutMs: 30000, @@ -863,9 +735,8 @@ describe('AppService updating app config and issuing warnings', () => { }, }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - const result = await AppService(); + const result = await AppService({ config }); expect(result).toEqual( expect.objectContaining({ @@ -884,119 +755,22 @@ describe('AppService updating app config and issuing warnings', () => { expect(result.endpoints.assistants.excludedIds).toBeUndefined(); }); - it('should log a warning when both supportedIds and excludedIds are provided', async () => { - const mockConfig = { - endpoints: { - assistants: { - disableBuilder: false, - pollIntervalMs: 3000, - timeoutMs: 20000, - supportedIds: ['id1', 'id2'], - excludedIds: ['id3'], - }, - }, - }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - "The 'assistants' endpoint has both 'supportedIds' and 'excludedIds' defined.", - ), - ); - }); - - it('should log a warning when privateAssistants and supportedIds or excludedIds are provided', async () => { - const mockConfig = { - endpoints: { - assistants: { - privateAssistants: true, - supportedIds: ['id1'], - }, - }, - }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining( - "The 'assistants' endpoint has both 'privateAssistants' and 'supportedIds' or 'excludedIds' defined.", - ), - ); - }); - - it('should issue expected warnings when loading Azure Groups with deprecated Environment Variables', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, - }, - }), - ); - - deprecatedAzureVariables.forEach((varInfo) => { - process.env[varInfo.key] = 'test'; - }); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - deprecatedAzureVariables.forEach(({ key, description }) => { - expect(logger.warn).toHaveBeenCalledWith( - `The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`, - ); - }); - }); - - it('should issue expected warnings when loading conflicting Azure Envrionment Variables', async () => { - loadCustomConfig.mockImplementationOnce(() => - Promise.resolve({ - endpoints: { - [EModelEndpoint.azureOpenAI]: { - groups: azureGroups, - }, - }, - }), - ); - - conflictingAzureVariables.forEach((varInfo) => { - process.env[varInfo.key] = 'test'; - }); - - await AppService(); - - const { logger } = require('@librechat/data-schemas'); - conflictingAzureVariables.forEach(({ key }) => { - expect(logger.warn).toHaveBeenCalledWith( - `The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`, - ); - }); - }); - it('should not parse environment variable references in OCR config', async () => { // Mock custom configuration with env variable references in OCR config - const mockConfig = { + const config: Partial = { ocr: { apiKey: '${OCR_API_KEY_CUSTOM_VAR_NAME}', baseURL: '${OCR_BASEURL_CUSTOM_VAR_NAME}', - strategy: 'mistral_ocr', + strategy: OCRStrategy.MISTRAL_OCR, mistralModel: 'mistral-medium', }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - // Set actual environment variables with different values process.env.OCR_API_KEY_CUSTOM_VAR_NAME = 'actual-api-key'; process.env.OCR_BASEURL_CUSTOM_VAR_NAME = 'https://actual-ocr-url.com'; - const result = await AppService(); + const result = await AppService({ config }); // Verify that the raw string references were preserved and not interpolated expect(result).toEqual( @@ -1012,7 +786,7 @@ describe('AppService updating app config and issuing warnings', () => { }); it('should correctly configure peoplePicker permissions when specified', async () => { - const mockConfig = { + const config = { interface: { peoplePicker: { users: true, @@ -1022,9 +796,7 @@ describe('AppService updating app config and issuing warnings', () => { }, }; - loadCustomConfig.mockImplementationOnce(() => Promise.resolve(mockConfig)); - - const result = await AppService(); + const result = await AppService({ config }); // Check that interface config includes the permissions expect(result).toEqual( diff --git a/packages/api/src/app/cdn.ts b/packages/api/src/app/cdn.ts new file mode 100644 index 0000000000..451eeb8897 --- /dev/null +++ b/packages/api/src/app/cdn.ts @@ -0,0 +1,26 @@ +import { logger } from '@librechat/data-schemas'; +import { FileSources } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { initializeAzureBlobService } from '~/cdn/azure'; +import { initializeFirebase } from '~/cdn/firebase'; +import { initializeS3 } from '~/cdn/s3'; + +/** + * Initializes file storage clients based on the configured file strategy. + * This should be called after loading the app configuration. + * @param {Object} options + * @param {AppConfig} options.appConfig - The application configuration + */ +export function initializeFileStorage(appConfig: AppConfig) { + const { fileStrategy } = appConfig; + + if (fileStrategy === FileSources.firebase) { + initializeFirebase(); + } else if (fileStrategy === FileSources.azure_blob) { + initializeAzureBlobService().catch((error) => { + logger.error('Error initializing Azure Blob Service:', error); + }); + } else if (fileStrategy === FileSources.s3) { + initializeS3(); + } +} diff --git a/api/server/services/start/checks.spec.js b/packages/api/src/app/checks.spec.ts similarity index 52% rename from api/server/services/start/checks.spec.js rename to packages/api/src/app/checks.spec.ts index 1281331266..cab5b727f9 100644 --- a/api/server/services/start/checks.spec.js +++ b/packages/api/src/app/checks.spec.ts @@ -11,12 +11,15 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); -const { checkWebSearchConfig } = require('./checks'); -const { logger } = require('@librechat/data-schemas'); -const { extractVariableName } = require('librechat-data-provider'); +import { handleRateLimits } from './limits'; +import { checkWebSearchConfig } from './checks'; +import { logger } from '@librechat/data-schemas'; +import { extractVariableName as extract } from 'librechat-data-provider'; + +const extractVariableName = extract as jest.MockedFunction; describe('checkWebSearchConfig', () => { - let originalEnv; + let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { // Clear all mocks @@ -178,6 +181,8 @@ describe('checkWebSearchConfig', () => { anotherKey: '${SOME_VAR}', }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + /** @ts-expect-error */ checkWebSearchConfig(config); expect(extractVariableName).not.toHaveBeenCalled(); @@ -200,3 +205,154 @@ describe('checkWebSearchConfig', () => { }); }); }); + +describe('handleRateLimits', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Store original environment + originalEnv = process.env; + + // Reset process.env + process.env = { ...originalEnv }; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + }); + + it('should correctly set FILE_UPLOAD environment variables based on rate limits', () => { + const rateLimits = { + fileUploads: { + ipMax: 100, + ipWindowInMinutes: 60, + userMax: 50, + userWindowInMinutes: 30, + }, + }; + + handleRateLimits(rateLimits); + + // Verify that process.env has been updated according to the rate limits config + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('100'); + expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual('60'); + expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('50'); + expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual('30'); + }); + + it('should correctly set IMPORT environment variables based on rate limits', () => { + const rateLimits = { + conversationsImport: { + ipMax: 150, + ipWindowInMinutes: 60, + userMax: 50, + userWindowInMinutes: 30, + }, + }; + + handleRateLimits(rateLimits); + + // Verify that process.env has been updated according to the rate limits config + expect(process.env.IMPORT_IP_MAX).toEqual('150'); + expect(process.env.IMPORT_IP_WINDOW).toEqual('60'); + expect(process.env.IMPORT_USER_MAX).toEqual('50'); + expect(process.env.IMPORT_USER_WINDOW).toEqual('30'); + }); + + it('should not modify FILE_UPLOAD environment variables without rate limits', () => { + // Setup initial environment variables + process.env.FILE_UPLOAD_IP_MAX = '10'; + process.env.FILE_UPLOAD_IP_WINDOW = '15'; + process.env.FILE_UPLOAD_USER_MAX = '5'; + process.env.FILE_UPLOAD_USER_WINDOW = '20'; + + const initialEnv = { ...process.env }; + + handleRateLimits({}); + + // Expect environment variables to remain unchanged + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual(initialEnv.FILE_UPLOAD_IP_MAX); + expect(process.env.FILE_UPLOAD_IP_WINDOW).toEqual(initialEnv.FILE_UPLOAD_IP_WINDOW); + expect(process.env.FILE_UPLOAD_USER_MAX).toEqual(initialEnv.FILE_UPLOAD_USER_MAX); + expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual(initialEnv.FILE_UPLOAD_USER_WINDOW); + }); + + it('should not modify IMPORT environment variables without rate limits', () => { + // Setup initial environment variables + process.env.IMPORT_IP_MAX = '10'; + process.env.IMPORT_IP_WINDOW = '15'; + process.env.IMPORT_USER_MAX = '5'; + process.env.IMPORT_USER_WINDOW = '20'; + + const initialEnv = { ...process.env }; + + handleRateLimits({}); + + // Expect environment variables to remain unchanged + expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); + expect(process.env.IMPORT_IP_WINDOW).toEqual(initialEnv.IMPORT_IP_WINDOW); + expect(process.env.IMPORT_USER_MAX).toEqual(initialEnv.IMPORT_USER_MAX); + expect(process.env.IMPORT_USER_WINDOW).toEqual(initialEnv.IMPORT_USER_WINDOW); + }); + + it('should handle undefined rateLimits parameter', () => { + // Setup initial environment variables + process.env.FILE_UPLOAD_IP_MAX = 'initial'; + process.env.IMPORT_IP_MAX = 'initial'; + + handleRateLimits(undefined); + + // Should not modify any environment variables + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('initial'); + expect(process.env.IMPORT_IP_MAX).toEqual('initial'); + }); + + it('should handle partial rate limit configurations', () => { + const rateLimits = { + fileUploads: { + ipMax: 200, + // Only setting ipMax, other properties undefined + }, + }; + + handleRateLimits(rateLimits); + + expect(process.env.FILE_UPLOAD_IP_MAX).toEqual('200'); + // Other FILE_UPLOAD env vars should not be set + expect(process.env.FILE_UPLOAD_IP_WINDOW).toBeUndefined(); + expect(process.env.FILE_UPLOAD_USER_MAX).toBeUndefined(); + expect(process.env.FILE_UPLOAD_USER_WINDOW).toBeUndefined(); + }); + + it('should correctly set TTS and STT environment variables based on rate limits', () => { + const rateLimits = { + tts: { + ipMax: 75, + ipWindowInMinutes: 45, + userMax: 25, + userWindowInMinutes: 15, + }, + stt: { + ipMax: 80, + ipWindowInMinutes: 50, + userMax: 30, + userWindowInMinutes: 20, + }, + }; + + handleRateLimits(rateLimits); + + // Verify TTS environment variables + expect(process.env.TTS_IP_MAX).toEqual('75'); + expect(process.env.TTS_IP_WINDOW).toEqual('45'); + expect(process.env.TTS_USER_MAX).toEqual('25'); + expect(process.env.TTS_USER_WINDOW).toEqual('15'); + + // Verify STT environment variables + expect(process.env.STT_IP_MAX).toEqual('80'); + expect(process.env.STT_IP_WINDOW).toEqual('50'); + expect(process.env.STT_USER_MAX).toEqual('30'); + expect(process.env.STT_USER_WINDOW).toEqual('20'); + }); +}); diff --git a/api/server/services/start/checks.js b/packages/api/src/app/checks.ts similarity index 53% rename from api/server/services/start/checks.js rename to packages/api/src/app/checks.ts index 5b13d41d59..66f5b620e6 100644 --- a/api/server/services/start/checks.js +++ b/packages/api/src/app/checks.ts @@ -1,11 +1,9 @@ -const { logger } = require('@librechat/data-schemas'); -const { isEnabled, webSearchKeys, checkEmailConfig } = require('@librechat/api'); -const { - Constants, - extractVariableName, - deprecatedAzureVariables, - conflictingAzureVariables, -} = require('librechat-data-provider'); +import { logger, webSearchKeys } from '@librechat/data-schemas'; +import { Constants, extractVariableName } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { isEnabled, checkEmailConfig } from '~/utils'; +import { handleRateLimits } from './limits'; const secretDefaults = { CREDS_KEY: 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0', @@ -32,17 +30,84 @@ const deprecatedVariables = [ }, ]; +export const deprecatedAzureVariables = [ + /* "related to" precedes description text */ + { key: 'AZURE_OPENAI_DEFAULT_MODEL', description: 'setting a default model' }, + { key: 'AZURE_OPENAI_MODELS', description: 'setting models' }, + { + key: 'AZURE_USE_MODEL_AS_DEPLOYMENT_NAME', + description: 'using model names as deployment names', + }, + { key: 'AZURE_API_KEY', description: 'setting a single Azure API key' }, + { key: 'AZURE_OPENAI_API_INSTANCE_NAME', description: 'setting a single Azure instance name' }, + { + key: 'AZURE_OPENAI_API_DEPLOYMENT_NAME', + description: 'setting a single Azure deployment name', + }, + { key: 'AZURE_OPENAI_API_VERSION', description: 'setting a single Azure API version' }, + { + key: 'AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME', + description: 'setting a single Azure completions deployment name', + }, + { + key: 'AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME', + description: 'setting a single Azure embeddings deployment name', + }, + { + key: 'PLUGINS_USE_AZURE', + description: 'using Azure for Plugins', + }, +]; + +export const conflictingAzureVariables = [ + { + key: 'INSTANCE_NAME', + }, + { + key: 'DEPLOYMENT_NAME', + }, +]; + +/** + * Checks the password reset configuration for security issues. + */ +function checkPasswordReset() { + const emailEnabled = checkEmailConfig(); + const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET); + + if (!emailEnabled && passwordResetAllowed) { + logger.warn( + `❗❗❗ + + Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured. + + This setup is insecure as password reset links will be issued with a recognized email. + + Please configure email service for secure password reset functionality. + + https://www.librechat.ai/docs/configuration/authentication/email + + ❗❗❗`, + ); + } +} + /** * Checks environment variables for default secrets and deprecated variables. - * Logs warnings for any default secret values being used and for usage of deprecated `GOOGLE_API_KEY`. + * Logs warnings for any default secret values being used and for usage of deprecated variables. * Advises on replacing default secrets and updating deprecated variables. + * @param {Object} options + * @param {Function} options.isEnabled - Function to check if a feature is enabled + * @param {Function} options.checkEmailConfig - Function to check email configuration */ -function checkVariables() { +export function checkVariables() { let hasDefaultSecrets = false; for (const [key, value] of Object.entries(secretDefaults)) { if (process.env[key] === value) { logger.warn(`Default value for ${key} is being used.`); - !hasDefaultSecrets && (hasDefaultSecrets = true); + if (!hasDefaultSecrets) { + hasDefaultSecrets = true; + } } } @@ -69,7 +134,7 @@ function checkVariables() { * Checks the health of auxiliary API's by attempting a fetch request to their respective `/health` endpoints. * Logs information or warning based on the API's availability and response. */ -async function checkHealth() { +export async function checkHealth() { try { const response = await fetch(`${process.env.RAG_API_URL}/health`); if (response?.ok && response?.status === 200) { @@ -104,11 +169,85 @@ function checkAzureVariables() { }); } +export function checkInterfaceConfig(appConfig: AppConfig) { + const interfaceConfig = appConfig.interfaceConfig; + let i = 0; + const logSettings = () => { + // log interface object and model specs object (without list) for reference + logger.warn(`\`interface\` settings:\n${JSON.stringify(interfaceConfig, null, 2)}`); + logger.warn( + `\`modelSpecs\` settings:\n${JSON.stringify( + { ...(appConfig?.modelSpecs ?? {}), list: undefined }, + null, + 2, + )}`, + ); + }; + + // warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs. + if (appConfig?.modelSpecs?.prioritize && interfaceConfig?.presets) { + logger.warn( + "Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.", + ); + if (i === 0) i++; + } + + // warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options. + if ( + appConfig?.modelSpecs?.enforce && + (interfaceConfig?.endpointsMenu || + interfaceConfig?.modelSelect || + interfaceConfig?.presets || + interfaceConfig?.parameters) + ) { + logger.warn( + "Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.", + ); + if (i === 0) i++; + } + // warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior. + if (appConfig?.modelSpecs?.enforce && !appConfig?.modelSpecs?.prioritize) { + logger.warn( + "Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.", + ); + if (i === 0) i++; + } + + if (i > 0) { + logSettings(); + } +} + +/** + * Performs startup checks including environment variable validation and health checks. + * This should be called during application startup before initializing services. + * @param [appConfig] - The application configuration object. + */ +export async function performStartupChecks(appConfig?: AppConfig) { + checkVariables(); + if (appConfig?.endpoints?.azureOpenAI) { + checkAzureVariables(); + } + if (appConfig) { + checkInterfaceConfig(appConfig); + } + if (appConfig?.config) { + checkConfig(appConfig.config); + } + if (appConfig?.config?.webSearch) { + checkWebSearchConfig(appConfig.config.webSearch); + } + if (appConfig?.config?.rateLimits) { + handleRateLimits(appConfig.config.rateLimits); + } + await checkHealth(); +} + /** * Performs basic checks on the loaded config object. - * @param {TCustomConfig} config - The loaded custom configuration. + * @param config - The loaded custom configuration. */ -function checkConfig(config) { +export function checkConfig(config: Partial) { if (config.version !== Constants.CONFIG_VERSION) { logger.info( `\nOutdated Config version: ${config.version} @@ -121,40 +260,19 @@ Latest version: ${Constants.CONFIG_VERSION} } } -function checkPasswordReset() { - const emailEnabled = checkEmailConfig(); - const passwordResetAllowed = isEnabled(process.env.ALLOW_PASSWORD_RESET); - - if (!emailEnabled && passwordResetAllowed) { - logger.warn( - `❗❗❗ - - Password reset is enabled with \`ALLOW_PASSWORD_RESET\` but email service is not configured. - - This setup is insecure as password reset links will be issued with a recognized email. - - Please configure email service for secure password reset functionality. - - https://www.librechat.ai/docs/configuration/authentication/email - - ❗❗❗`, - ); - } -} - /** * Checks web search configuration values to ensure they are environment variable references. * Warns if actual API keys or URLs are used instead of environment variable references. * Logs debug information for properly configured environment variable references. - * @param {Object} webSearchConfig - The loaded web search configuration object. + * @param webSearchConfig - The loaded web search configuration object. */ -function checkWebSearchConfig(webSearchConfig) { +export function checkWebSearchConfig(webSearchConfig?: Partial | null) { if (!webSearchConfig) { return; } webSearchKeys.forEach((key) => { - const value = webSearchConfig[key]; + const value = webSearchConfig[key as keyof typeof webSearchConfig]; if (typeof value === 'string') { const varName = extractVariableName(value); @@ -187,11 +305,3 @@ function checkWebSearchConfig(webSearchConfig) { } }); } - -module.exports = { - checkHealth, - checkConfig, - checkVariables, - checkAzureVariables, - checkWebSearchConfig, -}; diff --git a/packages/api/src/app/config.test.ts b/packages/api/src/app/config.test.ts index 82f8b3e8cd..d55f9977fe 100644 --- a/packages/api/src/app/config.test.ts +++ b/packages/api/src/app/config.test.ts @@ -1,8 +1,8 @@ import { getTransactionsConfig, getBalanceConfig } from './config'; import { logger } from '@librechat/data-schemas'; import { FileSources } from 'librechat-data-provider'; -import type { AppConfig } from '~/types'; import type { TCustomConfig } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; // Helper function to create a minimal AppConfig for testing const createTestAppConfig = (overrides: Partial = {}): AppConfig => { diff --git a/packages/api/src/app/config.ts b/packages/api/src/app/config.ts index 8bc9c9af26..8a2a681e65 100644 --- a/packages/api/src/app/config.ts +++ b/packages/api/src/app/config.ts @@ -1,8 +1,8 @@ +import { logger } from '@librechat/data-schemas'; import { EModelEndpoint, removeNullishValues } from 'librechat-data-provider'; import type { TCustomConfig, TEndpoint, TTransactionsConfig } from 'librechat-data-provider'; -import type { AppConfig } from '~/types'; +import type { AppConfig } from '@librechat/data-schemas'; import { isEnabled, normalizeEndpointName } from '~/utils'; -import { logger } from '@librechat/data-schemas'; /** * Retrieves the balance configuration object @@ -24,7 +24,7 @@ export function getBalanceConfig(appConfig?: AppConfig): Partial { const defaultConfig: TTransactionsConfig = { enabled: true }; if (!appConfig) { @@ -66,5 +66,5 @@ export const getCustomEndpointConfig = ({ export function hasCustomUserVars(appConfig?: AppConfig): boolean { const mcpServers = appConfig?.mcpConfig; - return Object.values(mcpServers ?? {}).some((server) => server.customUserVars); + return Object.values(mcpServers ?? {}).some((server) => server?.customUserVars); } diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index 74ae27abf3..b95193e943 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -1,3 +1,4 @@ export * from './config'; -export * from './interface'; export * from './permissions'; +export * from './cdn'; +export * from './checks'; diff --git a/packages/api/src/app/limits.ts b/packages/api/src/app/limits.ts new file mode 100644 index 0000000000..a38a3c6310 --- /dev/null +++ b/packages/api/src/app/limits.ts @@ -0,0 +1,55 @@ +import { RateLimitPrefix } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; + +/** + * + * @param rateLimits + */ +export const handleRateLimits = (rateLimits?: TCustomConfig['rateLimits']) => { + if (!rateLimits) { + return; + } + + const rateLimitKeys = { + fileUploads: RateLimitPrefix.FILE_UPLOAD, + conversationsImport: RateLimitPrefix.IMPORT, + tts: RateLimitPrefix.TTS, + stt: RateLimitPrefix.STT, + }; + + Object.entries(rateLimitKeys).forEach(([key, prefix]) => { + const rateLimit = rateLimits[key as keyof typeof rateLimitKeys]; + if (rateLimit) { + setRateLimitEnvVars(prefix, rateLimit); + } + }); +}; + +type RateLimitConfig = { + ipMax?: number | undefined; + ipWindowInMinutes?: number | undefined; + userMax?: number | undefined; + userWindowInMinutes?: number | undefined; +}; + +/** + * Set environment variables for rate limit configurations + * + * @param prefix - Prefix for environment variable names + * @param rateLimit - Rate limit configuration object + */ +const setRateLimitEnvVars = (prefix: string, rateLimit: RateLimitConfig) => { + const envVarsMapping = { + ipMax: `${prefix}_IP_MAX`, + ipWindowInMinutes: `${prefix}_IP_WINDOW`, + userMax: `${prefix}_USER_MAX`, + userWindowInMinutes: `${prefix}_USER_WINDOW`, + }; + + Object.entries(envVarsMapping).forEach(([key, envVar]) => { + const value = rateLimit[key as keyof RateLimitConfig]; + if (value !== undefined) { + process.env[envVar] = value.toString(); + } + }); +}; diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index e3816d4013..6eeb70d7d7 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -1,8 +1,8 @@ +import { loadDefaultInterface } from '@librechat/data-schemas'; import { SystemRoles, Permissions, PermissionTypes, roleDefaults } from 'librechat-data-provider'; import type { TConfigDefaults, TCustomConfig } from 'librechat-data-provider'; -import type { AppConfig } from '~/types/config'; +import type { AppConfig } from '@librechat/data-schemas'; import { updateInterfacePermissions } from './permissions'; -import { loadDefaultInterface } from './interface'; const mockUpdateAccessPermissions = jest.fn(); const mockGetRoleByName = jest.fn(); diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index a039a1503e..eaaa5c9705 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -6,8 +6,7 @@ import { PermissionTypes, getConfigDefaults, } from 'librechat-data-provider'; -import type { IRole } from '@librechat/data-schemas'; -import type { AppConfig } from '~/types/config'; +import type { IRole, AppConfig } from '@librechat/data-schemas'; import { isMemoryEnabled } from '~/memory/config'; /** diff --git a/api/cache/cacheConfig.spec.js b/packages/api/src/cache/__tests__/cacheConfig.spec.ts similarity index 59% rename from api/cache/cacheConfig.spec.js rename to packages/api/src/cache/__tests__/cacheConfig.spec.ts index a86acc5512..24f12f1d57 100644 --- a/api/cache/cacheConfig.spec.js +++ b/packages/api/src/cache/__tests__/cacheConfig.spec.ts @@ -1,12 +1,8 @@ -const fs = require('fs'); - describe('cacheConfig', () => { - let originalEnv; - let originalReadFileSync; + let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { originalEnv = { ...process.env }; - originalReadFileSync = fs.readFileSync; // Clear all related env vars first delete process.env.REDIS_URI; @@ -18,116 +14,116 @@ describe('cacheConfig', () => { delete process.env.REDIS_PING_INTERVAL; delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES; - // Clear require cache + // Clear module cache jest.resetModules(); }); afterEach(() => { process.env = originalEnv; - fs.readFileSync = originalReadFileSync; jest.resetModules(); }); describe('REDIS_KEY_PREFIX validation and resolution', () => { - test('should throw error when both REDIS_KEY_PREFIX_VAR and REDIS_KEY_PREFIX are set', () => { + test('should throw error when both REDIS_KEY_PREFIX_VAR and REDIS_KEY_PREFIX are set', async () => { process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID'; process.env.REDIS_KEY_PREFIX = 'manual-prefix'; - expect(() => { - require('./cacheConfig'); - }).toThrow('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.'); + await expect(async () => { + await import('../cacheConfig'); + }).rejects.toThrow('Only either REDIS_KEY_PREFIX_VAR or REDIS_KEY_PREFIX can be set.'); }); - test('should resolve REDIS_KEY_PREFIX from variable reference', () => { + test('should resolve REDIS_KEY_PREFIX from variable reference', async () => { process.env.REDIS_KEY_PREFIX_VAR = 'DEPLOYMENT_ID'; process.env.DEPLOYMENT_ID = 'test-deployment-123'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe('test-deployment-123'); }); - test('should use direct REDIS_KEY_PREFIX value', () => { + test('should use direct REDIS_KEY_PREFIX value', async () => { process.env.REDIS_KEY_PREFIX = 'direct-prefix'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe('direct-prefix'); }); - test('should default to empty string when no prefix is configured', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should default to empty string when no prefix is configured', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); }); - test('should handle empty variable reference', () => { + test('should handle empty variable reference', async () => { process.env.REDIS_KEY_PREFIX_VAR = 'EMPTY_VAR'; process.env.EMPTY_VAR = ''; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); }); - test('should handle undefined variable reference', () => { + test('should handle undefined variable reference', async () => { process.env.REDIS_KEY_PREFIX_VAR = 'UNDEFINED_VAR'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_KEY_PREFIX).toBe(''); }); }); describe('USE_REDIS and REDIS_URI validation', () => { - test('should throw error when USE_REDIS is enabled but REDIS_URI is not set', () => { + test('should throw error when USE_REDIS is enabled but REDIS_URI is not set', async () => { process.env.USE_REDIS = 'true'; - expect(() => { - require('./cacheConfig'); - }).toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); + await expect(async () => { + await import('../cacheConfig'); + }).rejects.toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); }); - test('should not throw error when USE_REDIS is enabled and REDIS_URI is set', () => { + test('should not throw error when USE_REDIS is enabled and REDIS_URI is set', async () => { process.env.USE_REDIS = 'true'; process.env.REDIS_URI = 'redis://localhost:6379'; - expect(() => { - require('./cacheConfig'); - }).not.toThrow(); + const importModule = async () => { + await import('../cacheConfig'); + }; + await expect(importModule()).resolves.not.toThrow(); }); - test('should handle empty REDIS_URI when USE_REDIS is enabled', () => { + test('should handle empty REDIS_URI when USE_REDIS is enabled', async () => { process.env.USE_REDIS = 'true'; process.env.REDIS_URI = ''; - expect(() => { - require('./cacheConfig'); - }).toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); + await expect(async () => { + await import('../cacheConfig'); + }).rejects.toThrow('USE_REDIS is enabled but REDIS_URI is not set.'); }); }); describe('USE_REDIS_CLUSTER configuration', () => { - test('should default to false when USE_REDIS_CLUSTER is not set', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should default to false when USE_REDIS_CLUSTER is not set', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.USE_REDIS_CLUSTER).toBe(false); }); - test('should be false when USE_REDIS_CLUSTER is set to false', () => { + test('should be false when USE_REDIS_CLUSTER is set to false', async () => { process.env.USE_REDIS_CLUSTER = 'false'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.USE_REDIS_CLUSTER).toBe(false); }); - test('should be true when USE_REDIS_CLUSTER is set to true', () => { + test('should be true when USE_REDIS_CLUSTER is set to true', async () => { process.env.USE_REDIS_CLUSTER = 'true'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.USE_REDIS_CLUSTER).toBe(true); }); - test('should work with USE_REDIS enabled and REDIS_URI set', () => { + test('should work with USE_REDIS enabled and REDIS_URI set', async () => { process.env.USE_REDIS_CLUSTER = 'true'; process.env.USE_REDIS = 'true'; process.env.REDIS_URI = 'redis://localhost:6379'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.USE_REDIS_CLUSTER).toBe(true); expect(cacheConfig.USE_REDIS).toBe(true); expect(cacheConfig.REDIS_URI).toBe('redis://localhost:6379'); @@ -135,54 +131,51 @@ describe('cacheConfig', () => { }); describe('REDIS_CA file reading', () => { - test('should be null when REDIS_CA is not set', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should be null when REDIS_CA is not set', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_CA).toBeNull(); }); }); describe('REDIS_PING_INTERVAL configuration', () => { - test('should default to 0 when REDIS_PING_INTERVAL is not set', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should default to 0 when REDIS_PING_INTERVAL is not set', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_PING_INTERVAL).toBe(0); }); - test('should use provided REDIS_PING_INTERVAL value', () => { + test('should use provided REDIS_PING_INTERVAL value', async () => { process.env.REDIS_PING_INTERVAL = '300'; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.REDIS_PING_INTERVAL).toBe(300); }); }); describe('FORCED_IN_MEMORY_CACHE_NAMESPACES validation', () => { - test('should parse comma-separated cache keys correctly', () => { + test('should parse comma-separated cache keys correctly', async () => { process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, MESSAGES '; - const { cacheConfig } = require('./cacheConfig'); - expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([ - 'ROLES', - 'MESSAGES', - ]); + const { cacheConfig } = await import('../cacheConfig'); + expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual(['ROLES', 'MESSAGES']); }); - test('should throw error for invalid cache keys', () => { + test('should throw error for invalid cache keys', async () => { process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = 'INVALID_KEY,ROLES'; - expect(() => { - require('./cacheConfig'); - }).toThrow('Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: INVALID_KEY'); + await expect(async () => { + await import('../cacheConfig'); + }).rejects.toThrow('Invalid cache keys in FORCED_IN_MEMORY_CACHE_NAMESPACES: INVALID_KEY'); }); - test('should handle empty string gracefully', () => { + test('should handle empty string gracefully', async () => { process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ''; - const { cacheConfig } = require('./cacheConfig'); + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]); }); - test('should handle undefined env var gracefully', () => { - const { cacheConfig } = require('./cacheConfig'); + test('should handle undefined env var gracefully', async () => { + const { cacheConfig } = await import('../cacheConfig'); expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([]); }); }); diff --git a/packages/api/src/cache/__tests__/cacheFactory/limiterCache.integration.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/limiterCache.integration.spec.ts new file mode 100644 index 0000000000..ab30ec3d1f --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/limiterCache.integration.spec.ts @@ -0,0 +1,113 @@ +import type { RedisStore } from 'rate-limit-redis'; + +describe('limiterCache', () => { + let originalEnv: NodeJS.ProcessEnv; + let testStore: RedisStore | undefined = undefined; + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear cache-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + + // Set test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Cache-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + + // Clear require cache to reload modules + jest.resetModules(); + }); + + afterEach(async () => { + process.env = originalEnv; + jest.resetModules(); + }); + + test('should throw error when prefix is not provided', async () => { + const cacheFactory = await import('../../cacheFactory'); + expect(() => cacheFactory.limiterCache('')).toThrow('prefix is required'); + }); + + test('should return undefined when USE_REDIS is false', async () => { + process.env.USE_REDIS = 'false'; + + const cacheFactory = await import('../../cacheFactory'); + testStore = cacheFactory.limiterCache('test-limiter'); + + expect(testStore).toBeUndefined(); + }); + + test('should return RedisStore with sendCommand when USE_REDIS is true', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + testStore = cacheFactory.limiterCache('test-limiter'); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + // Verify it returns a RedisStore instance + expect(testStore).toBeDefined(); + expect(testStore!.constructor.name).toBe('RedisStore'); + expect(testStore!.prefix).toBe('test-limiter:'); + expect(typeof testStore!.sendCommand).toBe('function'); + + const testKey = 'user:123'; + + // SET operation + await testStore!.sendCommand('SET', testKey, '1', 'EX', '60'); + + // Verify the key was created WITHOUT prefix using ioredis + // Note: Using call method since get method seems to have issues in test environment + // Type assertion for ioredis call method + type RedisClientWithCall = typeof ioredisClient & { + call: (command: string, key: string) => Promise; + }; + const directValue = await (ioredisClient as RedisClientWithCall).call('GET', testKey); + + expect(directValue).toBe('1'); + + // GET operation + const value = await testStore!.sendCommand('GET', testKey); + expect(value).toBe('1'); + + // INCR operation + const incremented = await testStore!.sendCommand('INCR', testKey); + expect(incremented).toBe(2); + + // Verify increment worked with ioredis + const incrementedValue = await (ioredisClient as RedisClientWithCall).call('GET', testKey); + expect(incrementedValue).toBe('2'); + + // TTL operation + const ttl = (await testStore!.sendCommand('TTL', testKey)) as number; + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(60); + + // DEL operation + const deleted = await testStore!.sendCommand('DEL', testKey); + expect(deleted).toBe(1); + + // Verify deletion + const afterDelete = await testStore!.sendCommand('GET', testKey); + expect(afterDelete).toBeNull(); + const directAfterDelete = await ioredisClient!.get(testKey); + expect(directAfterDelete).toBeNull(); + + // Test error handling + await expect(testStore!.sendCommand('INVALID_COMMAND')).rejects.toThrow(); + }); +}); diff --git a/packages/api/src/cache/__tests__/cacheFactory/sessionCache.integration.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/sessionCache.integration.spec.ts new file mode 100644 index 0000000000..144eee557d --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/sessionCache.integration.spec.ts @@ -0,0 +1,211 @@ +interface SessionData { + [key: string]: unknown; + cookie?: { maxAge: number }; + user?: { id: string; name: string }; + userId?: string; +} + +interface SessionStore { + prefix?: string; + set: (id: string, data: SessionData, callback?: (err?: Error) => void) => void; + get: (id: string, callback: (err: Error | null, data?: SessionData | null) => void) => void; + destroy: (id: string, callback?: (err?: Error) => void) => void; + touch: (id: string, data: SessionData, callback?: (err?: Error) => void) => void; + on?: (event: string, handler: (...args: unknown[]) => void) => void; +} + +describe('sessionCache', () => { + let originalEnv: NodeJS.ProcessEnv; + + // Helper to make session stores async + const asyncStore = (store: SessionStore) => ({ + set: (id: string, data: SessionData) => + new Promise((resolve) => store.set(id, data, () => resolve())), + get: (id: string) => + new Promise((resolve) => + store.get(id, (_, data) => resolve(data)), + ), + destroy: (id: string) => new Promise((resolve) => store.destroy(id, () => resolve())), + touch: (id: string, data: SessionData) => + new Promise((resolve) => store.touch(id, data, () => resolve())), + }); + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear cache-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + + // Set test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Cache-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + + // Clear require cache to reload modules + jest.resetModules(); + }); + + afterEach(async () => { + process.env = originalEnv; + jest.resetModules(); + }); + + test('should return ConnectRedis store when USE_REDIS is true', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const store = cacheFactory.sessionCache('test-sessions', 3600); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + // Verify it returns a ConnectRedis instance + expect(store).toBeDefined(); + expect(store.constructor.name).toBe('RedisStore'); + expect(store.prefix).toBe('test-sessions:'); + + // Test session operations + const sessionId = 'sess:123456'; + const sessionData: SessionData = { + user: { id: 'user123', name: 'Test User' }, + cookie: { maxAge: 3600000 }, + }; + + const async = asyncStore(store); + + // Set session + await async.set(sessionId, sessionData); + + // Get session + const retrieved = await async.get(sessionId); + expect(retrieved).toEqual(sessionData); + + // Touch session (update expiry) + await async.touch(sessionId, sessionData); + + // Destroy session + await async.destroy(sessionId); + + // Verify deletion + const afterDelete = await async.get(sessionId); + expect(afterDelete).toBeNull(); + }); + + test('should return MemoryStore when USE_REDIS is false', async () => { + process.env.USE_REDIS = 'false'; + + const cacheFactory = await import('../../cacheFactory'); + const store = cacheFactory.sessionCache('test-sessions', 3600); + + // Verify it returns a MemoryStore instance + expect(store).toBeDefined(); + expect(store.constructor.name).toBe('MemoryStore'); + + // Test session operations + const sessionId = 'mem:789012'; + const sessionData: SessionData = { + user: { id: 'user456', name: 'Memory User' }, + cookie: { maxAge: 3600000 }, + }; + + const async = asyncStore(store); + + // Set session + await async.set(sessionId, sessionData); + + // Get session + const retrieved = await async.get(sessionId); + expect(retrieved).toEqual(sessionData); + + // Destroy session + await async.destroy(sessionId); + + // Verify deletion + const afterDelete = await async.get(sessionId); + expect(afterDelete).toBeUndefined(); + }); + + test('should handle namespace with and without trailing colon', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + + const store1 = cacheFactory.sessionCache('namespace1'); + const store2 = cacheFactory.sessionCache('namespace2:'); + + expect(store1.prefix).toBe('namespace1:'); + expect(store2.prefix).toBe('namespace2:'); + }); + + test('should register error handler for Redis connection', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + + // Spy on ioredisClient.on + const onSpy = jest.spyOn(ioredisClient!, 'on'); + + // Create session store + cacheFactory.sessionCache('error-test'); + + // Verify error handler was registered + expect(onSpy).toHaveBeenCalledWith('error', expect.any(Function)); + + onSpy.mockRestore(); + }); + + test('should handle session expiration with TTL', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const ttl = 1; // 1 second TTL + const store = cacheFactory.sessionCache('ttl-sessions', ttl); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + const sessionId = 'ttl:12345'; + const sessionData: SessionData = { userId: 'ttl-user' }; + const async = asyncStore(store); + + // Set session with short TTL + await async.set(sessionId, sessionData); + + // Verify session exists immediately + const immediate = await async.get(sessionId); + expect(immediate).toEqual(sessionData); + + // Wait for TTL to expire + await new Promise((resolve) => setTimeout(resolve, (ttl + 0.5) * 1000)); + + // Verify session has expired + const expired = await async.get(sessionId); + expect(expired).toBeNull(); + }); +}); diff --git a/packages/api/src/cache/__tests__/cacheFactory/standardCache.integration.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/standardCache.integration.spec.ts new file mode 100644 index 0000000000..db40ad636c --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/standardCache.integration.spec.ts @@ -0,0 +1,185 @@ +import type { Keyv } from 'keyv'; + +// Mock GLOBAL_PREFIX_SEPARATOR +jest.mock('../../redisClients', () => { + const originalModule = jest.requireActual('../../redisClients'); + return { + ...originalModule, + GLOBAL_PREFIX_SEPARATOR: '>>', + }; +}); + +describe('standardCache', () => { + let originalEnv: NodeJS.ProcessEnv; + let testCache: Keyv | null = null; + + // Helper function to verify Redis keys exist + const expectRedisKeysExist = async (expectedKeys: string[]) => { + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + if (!ioredisClient) throw new Error('ioredisClient is null'); + const allKeys = await ioredisClient.keys('Cache-Integration-Test*'); + expectedKeys.forEach((expectedKey) => { + expect(allKeys).toContain(expectedKey); + }); + }; + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear cache-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + delete process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES; + + // Set test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Cache-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + + // Clear require cache to reload modules + jest.resetModules(); + }); + + afterEach(async () => { + // Clean up test keys using prefix and test namespaces + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + if (ioredisClient && ioredisClient.status === 'ready') { + try { + const patterns = [ + 'Cache-Integration-Test>>*', + 'Cache-Integration-Test>>test-namespace:*', + 'Cache-Integration-Test>>another-namespace:*', + ]; + + for (const pattern of patterns) { + const keys = await ioredisClient.keys(pattern); + if (keys.length > 0) { + await ioredisClient.del(...keys); + } + } + } catch (error: unknown) { + if (error instanceof Error) { + console.warn('Error cleaning up test keys:', error.message); + } + } + } + + // Clean up cache instance + if (testCache) { + try { + await testCache.clear(); + } catch (error: unknown) { + if (error instanceof Error) { + console.warn('Error clearing cache:', error.message); + } + } + testCache = null; + } + + process.env = originalEnv; + jest.resetModules(); + }); + + describe('when USE_REDIS is false', () => { + test('should create in-memory cache', async () => { + process.env.USE_REDIS = 'false'; + + const cacheFactory = await import('../../cacheFactory'); + testCache = cacheFactory.standardCache('test-namespace'); + + expect(testCache).toBeDefined(); + expect(testCache.constructor.name).toBe('Keyv'); + }); + + test('should use fallback store when provided', async () => { + process.env.USE_REDIS = 'false'; + const fallbackStore = new Map(); + + const cacheFactory = await import('../../cacheFactory'); + testCache = cacheFactory.standardCache('test-namespace', 200, fallbackStore); + + expect(testCache).toBeDefined(); + // Type assertion to access internal options + const cacheWithOpts = testCache as Keyv & { + opts: { store: unknown; namespace: string; ttl: number }; + }; + expect(cacheWithOpts.opts.store).toBe(fallbackStore); + expect(cacheWithOpts.opts.namespace).toBe('test-namespace'); + expect(cacheWithOpts.opts.ttl).toBe(200); + }); + }); + + describe('when connecting to a Redis server', () => { + test('should handle different namespaces with correct prefixes', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + + const cache1 = cacheFactory.standardCache('namespace-one'); + const cache2 = cacheFactory.standardCache('namespace-two'); + + await cache1.set('key1', 'value1'); + await cache2.set('key2', 'value2'); + + // Verify both caches work independently + expect(await cache1.get('key1')).toBe('value1'); + expect(await cache2.get('key2')).toBe('value2'); + expect(await cache1.get('key2')).toBeUndefined(); + expect(await cache2.get('key1')).toBeUndefined(); + + // Verify Redis keys have correct prefixes for different namespaces + await expectRedisKeysExist([ + 'Cache-Integration-Test>>namespace-one:key1', + 'Cache-Integration-Test>>namespace-two:key2', + ]); + + await cache1.clear(); + await cache2.clear(); + }); + + test('should respect FORCED_IN_MEMORY_CACHE_NAMESPACES', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = 'ROLES'; // Use a valid cache key + + const cacheFactory = await import('../../cacheFactory'); + + // This should create an in-memory cache despite USE_REDIS being true + testCache = cacheFactory.standardCache('ROLES', 5000); + + expect(testCache).toBeDefined(); + expect(testCache.constructor.name).toBe('Keyv'); + // Type assertion to access internal options + const cacheWithOpts = testCache as Keyv & { opts: { namespace: string; ttl: number } }; + expect(cacheWithOpts.opts.namespace).toBe('ROLES'); + expect(cacheWithOpts.opts.ttl).toBe(5000); + }); + + test('should handle TTL correctly', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + testCache = cacheFactory.standardCache('ttl-test', 1000); // 1 second TTL + + const testKey = 'ttl-key'; + const testValue = 'ttl-value'; + + await testCache.set(testKey, testValue); + expect(await testCache.get(testKey)).toBe(testValue); + + // Wait for TTL to expire + await new Promise((resolve) => setTimeout(resolve, 1100)); + expect(await testCache.get(testKey)).toBeUndefined(); + }); + }); +}); diff --git a/packages/api/src/cache/__tests__/cacheFactory/violationCache.integration.spec.ts b/packages/api/src/cache/__tests__/cacheFactory/violationCache.integration.spec.ts new file mode 100644 index 0000000000..7ffec8d74e --- /dev/null +++ b/packages/api/src/cache/__tests__/cacheFactory/violationCache.integration.spec.ts @@ -0,0 +1,241 @@ +interface ViolationData { + count?: number; + timestamp?: number; + namespace?: number; + data?: string; + userId?: string; + violations?: Array<{ + type: string; + timestamp: number; + severity: string; + }>; + metadata?: { + ip: string; + userAgent: string; + nested: { + deep: { + value: string; + }; + }; + }; +} + +describe('violationCache', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear cache-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + + // Set test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Cache-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + + // Clear require cache to reload modules + jest.resetModules(); + }); + + afterEach(async () => { + process.env = originalEnv; + jest.resetModules(); + }); + + test('should create violation cache with Redis when USE_REDIS is true', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const cache = cacheFactory.violationCache('test-violations', 60000); // 60 second TTL + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + // Verify it returns a Keyv instance + expect(cache).toBeDefined(); + expect(cache.constructor.name).toBe('Keyv'); + + // Test basic cache operations + const testKey = 'user:456:violation'; + const testValue: ViolationData = { count: 1, timestamp: Date.now() }; + + // SET operation + await cache.set(testKey, testValue); + + // GET operation + const retrievedValue = await cache.get(testKey); + expect(retrievedValue).toEqual(testValue); + + // DELETE operation + const deleted = await cache.delete(testKey); + expect(deleted).toBe(true); + + // Verify deletion + const afterDelete = await cache.get(testKey); + expect(afterDelete).toBeUndefined(); + }); + + test('should use fallback store when USE_REDIS is false', async () => { + process.env.USE_REDIS = 'false'; + + const cacheFactory = await import('../../cacheFactory'); + const cache = cacheFactory.violationCache('test-violations'); + + // Verify it returns a Keyv instance + expect(cache).toBeDefined(); + expect(cache.constructor.name).toBe('Keyv'); + + // Test basic operations with fallback store + const testKey = 'user:789:violation'; + const testValue: ViolationData = { count: 2, timestamp: Date.now() }; + + // SET operation + await cache.set(testKey, testValue); + + // GET operation + const retrievedValue = await cache.get(testKey); + expect(retrievedValue).toEqual(testValue); + + // DELETE operation + const deleted = await cache.delete(testKey); + expect(deleted).toBe(true); + + // Verify deletion + const afterDelete = await cache.get(testKey); + expect(afterDelete).toBeUndefined(); + }); + + test('should respect namespace prefixing', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const cache1 = cacheFactory.violationCache('namespace1'); + const cache2 = cacheFactory.violationCache('namespace2'); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + const testKey = 'shared-key'; + const value1: ViolationData = { namespace: 1 }; + const value2: ViolationData = { namespace: 2 }; + + // Set same key in different namespaces + await cache1.set(testKey, value1); + await cache2.set(testKey, value2); + + // Verify namespace isolation + const retrieved1 = await cache1.get(testKey); + const retrieved2 = await cache2.get(testKey); + + expect(retrieved1).toEqual(value1); + expect(retrieved2).toEqual(value2); + + // Clean up + await cache1.delete(testKey); + await cache2.delete(testKey); + }); + + test('should respect TTL settings', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const ttl = 1000; // 1 second TTL + const cache = cacheFactory.violationCache('ttl-test', ttl); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + const testKey = 'ttl-key'; + const testValue: ViolationData = { data: 'expires soon' }; + + // Set value with TTL + await cache.set(testKey, testValue); + + // Verify value exists immediately + const immediate = await cache.get(testKey); + expect(immediate).toEqual(testValue); + + // Wait for TTL to expire + await new Promise((resolve) => setTimeout(resolve, ttl + 100)); + + // Verify value has expired + const expired = await cache.get(testKey); + expect(expired).toBeUndefined(); + }); + + test('should handle complex violation data structures', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const cacheFactory = await import('../../cacheFactory'); + const redisClients = await import('../../redisClients'); + const { ioredisClient } = redisClients; + const cache = cacheFactory.violationCache('complex-violations'); + + // Wait for Redis connection to be ready + if (ioredisClient && ioredisClient.status !== 'ready') { + await new Promise((resolve) => { + ioredisClient.once('ready', resolve); + }); + } + + const complexData: ViolationData = { + userId: 'user123', + violations: [ + { type: 'rate_limit', timestamp: Date.now(), severity: 'warning' }, + { type: 'spam', timestamp: Date.now() - 1000, severity: 'critical' }, + ], + metadata: { + ip: '192.168.1.1', + userAgent: 'Mozilla/5.0', + nested: { + deep: { + value: 'test', + }, + }, + }, + }; + + const key = 'complex-violation-data'; + + // Store complex data + await cache.set(key, complexData); + + // Retrieve and verify + const retrieved = await cache.get(key); + expect(retrieved).toEqual(complexData); + + // Clean up + await cache.delete(key); + }); +}); diff --git a/packages/api/src/cache/__tests__/redisClients.integration.spec.ts b/packages/api/src/cache/__tests__/redisClients.integration.spec.ts new file mode 100644 index 0000000000..e2bbda7224 --- /dev/null +++ b/packages/api/src/cache/__tests__/redisClients.integration.spec.ts @@ -0,0 +1,168 @@ +import type { Redis, Cluster } from 'ioredis'; +import type { RedisClientType, RedisClusterType } from '@redis/client'; + +type RedisClient = RedisClientType | RedisClusterType | Redis | Cluster; + +describe('redisClients Integration Tests', () => { + let originalEnv: NodeJS.ProcessEnv; + let ioredisClient: Redis | Cluster | null = null; + let keyvRedisClient: RedisClientType | RedisClusterType | null = null; + + // Helper function to test set/get/delete operations + const testRedisOperations = async (client: RedisClient, keyPrefix: string): Promise => { + // Wait cluster to fully initialize + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const testKey = `${keyPrefix}-test-key`; + const testValue = `${keyPrefix}-test-value`; + + // Test set operation + await client.set(testKey, testValue); + + // Test get operation + const result = await client.get(testKey); + expect(result).toBe(testValue); + + // Test delete operation + const deleteResult = await client.del(testKey); + expect(deleteResult).toBe(1); + + // Verify key is deleted + const deletedResult = await client.get(testKey); + expect(deletedResult).toBeNull(); + }; + + beforeEach(() => { + originalEnv = { ...process.env }; + + // Clear Redis-related env vars + delete process.env.USE_REDIS; + delete process.env.REDIS_URI; + delete process.env.USE_REDIS_CLUSTER; + delete process.env.REDIS_PING_INTERVAL; + delete process.env.REDIS_KEY_PREFIX; + + // Set common test configuration + process.env.REDIS_PING_INTERVAL = '0'; + process.env.REDIS_KEY_PREFIX = 'Redis-Integration-Test'; + process.env.REDIS_RETRY_MAX_ATTEMPTS = '5'; + process.env.REDIS_PING_INTERVAL = '1000'; + + // Clear module cache to reload module + jest.resetModules(); + }); + + afterEach(async () => { + // Clean up test keys using the prefix + if (ioredisClient && ioredisClient.status === 'ready') { + try { + const keys = await ioredisClient.keys('Redis-Integration-Test::*'); + if (keys.length > 0) { + await ioredisClient.del(...keys); + } + } catch (error: any) { + console.warn('Error cleaning up test keys:', error.message); + } + } + + // Cleanup Redis connections + if (ioredisClient) { + try { + if (ioredisClient.status === 'ready') { + ioredisClient.disconnect(); + } + } catch (error: any) { + console.warn('Error disconnecting ioredis client:', error.message); + } + ioredisClient = null; + } + + if (keyvRedisClient) { + try { + // Try to disconnect - keyv/redis client doesn't have an isReady property + await keyvRedisClient.disconnect(); + } catch (error: any) { + console.warn('Error disconnecting keyv redis client:', error.message); + } + keyvRedisClient = null; + } + + process.env = originalEnv; + jest.resetModules(); + }); + + describe('ioredis Client Tests', () => { + describe('when USE_REDIS is false', () => { + test('should have null client', async () => { + process.env.USE_REDIS = 'false'; + + const clients = await import('../redisClients'); + ioredisClient = clients.ioredisClient; + + expect(ioredisClient).toBeNull(); + }); + }); + + describe('when connecting to a Redis instance', () => { + test('should connect and perform set/get/delete operations', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const clients = await import('../redisClients'); + ioredisClient = clients.ioredisClient; + await testRedisOperations(ioredisClient!, 'ioredis-single'); + }); + }); + + describe('when connecting to a Redis cluster', () => { + test('should connect to cluster and perform set/get/delete operations', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'true'; + process.env.REDIS_URI = + 'redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003'; + + const clients = await import('../redisClients'); + ioredisClient = clients.ioredisClient; + await testRedisOperations(ioredisClient!, 'ioredis-cluster'); + }); + }); + }); + + describe('keyvRedisClient Tests', () => { + describe('when USE_REDIS is false', () => { + test('should have null client', async () => { + process.env.USE_REDIS = 'false'; + + const clients = await import('../redisClients'); + keyvRedisClient = clients.keyvRedisClient; + expect(keyvRedisClient).toBeNull(); + }); + }); + + describe('when connecting to a Redis instance', () => { + test('should connect and perform set/get/delete operations', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'false'; + process.env.REDIS_URI = 'redis://127.0.0.1:6379'; + + const clients = await import('../redisClients'); + keyvRedisClient = clients.keyvRedisClient; + await testRedisOperations(keyvRedisClient!, 'keyv-single'); + }); + }); + + describe('when connecting to a Redis cluster', () => { + test('should connect to cluster and perform set/get/delete operations', async () => { + process.env.USE_REDIS = 'true'; + process.env.USE_REDIS_CLUSTER = 'true'; + process.env.REDIS_URI = + 'redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003'; + + const clients = await import('../redisClients'); + keyvRedisClient = clients.keyvRedisClient; + await testRedisOperations(keyvRedisClient!, 'keyv-cluster'); + }); + }); + }); +}); diff --git a/api/cache/cacheConfig.js b/packages/api/src/cache/cacheConfig.ts similarity index 87% rename from api/cache/cacheConfig.js rename to packages/api/src/cache/cacheConfig.ts index 4a5fea113b..aebfeef3bd 100644 --- a/api/cache/cacheConfig.js +++ b/packages/api/src/cache/cacheConfig.ts @@ -1,7 +1,7 @@ -const fs = require('fs'); -const { logger } = require('@librechat/data-schemas'); -const { math, isEnabled } = require('@librechat/api'); -const { CacheKeys } = require('librechat-data-provider'); +import { readFileSync, existsSync } from 'fs'; +import { logger } from '@librechat/data-schemas'; +import { CacheKeys } from 'librechat-data-provider'; +import { math, isEnabled } from '~/utils'; // To ensure that different deployments do not interfere with each other's cache, we use a prefix for the Redis keys. // This prefix is usually the deployment ID, which is often passed to the container or pod as an env var. @@ -25,7 +25,7 @@ const FORCED_IN_MEMORY_CACHE_NAMESPACES = process.env.FORCED_IN_MEMORY_CACHE_NAM // Validate against CacheKeys enum if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) { - const validKeys = Object.values(CacheKeys); + const validKeys = Object.values(CacheKeys) as string[]; const invalidKeys = FORCED_IN_MEMORY_CACHE_NAMESPACES.filter((key) => !validKeys.includes(key)); if (invalidKeys.length > 0) { @@ -38,15 +38,15 @@ if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) { /** Helper function to safely read Redis CA certificate from file * @returns {string|null} The contents of the CA certificate file, or null if not set or on error */ -const getRedisCA = () => { +const getRedisCA = (): string | null => { const caPath = process.env.REDIS_CA; if (!caPath) { return null; } try { - if (fs.existsSync(caPath)) { - return fs.readFileSync(caPath, 'utf8'); + if (existsSync(caPath)) { + return readFileSync(caPath, 'utf8'); } else { logger.warn(`Redis CA certificate file not found: ${caPath}`); return null; @@ -64,7 +64,7 @@ const cacheConfig = { REDIS_USERNAME: process.env.REDIS_USERNAME, REDIS_PASSWORD: process.env.REDIS_PASSWORD, REDIS_CA: getRedisCA(), - REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '', + REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR ?? ''] || REDIS_KEY_PREFIX || '', REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40), REDIS_PING_INTERVAL: math(process.env.REDIS_PING_INTERVAL, 0), /** Max delay between reconnection attempts in ms */ @@ -86,4 +86,4 @@ const cacheConfig = { BAN_DURATION: math(process.env.BAN_DURATION, 7200000), // 2 hours }; -module.exports = { cacheConfig }; +export { cacheConfig }; diff --git a/packages/api/src/cache/cacheFactory.ts b/packages/api/src/cache/cacheFactory.ts new file mode 100644 index 0000000000..427b1b38ad --- /dev/null +++ b/packages/api/src/cache/cacheFactory.ts @@ -0,0 +1,116 @@ +/** + * @keyv/redis exports its default class in a non-standard way: + * module.exports = { default: KeyvRedis, ... } instead of module.exports = KeyvRedis + * This breaks ES6 imports when the module is marked as external in rollup. + * We must use require() to access the .default property directly. + */ +// eslint-disable-next-line @typescript-eslint/no-require-imports +const KeyvRedis = require('@keyv/redis').default as typeof import('@keyv/redis').default; +import { Keyv } from 'keyv'; +import createMemoryStore from 'memorystore'; +import { RedisStore } from 'rate-limit-redis'; +import { Time } from 'librechat-data-provider'; +import { logger } from '@librechat/data-schemas'; +import session, { MemoryStore } from 'express-session'; +import { RedisStore as ConnectRedis } from 'connect-redis'; +import type { SendCommandFn } from 'rate-limit-redis'; +import { keyvRedisClient, ioredisClient, GLOBAL_PREFIX_SEPARATOR } from './redisClients'; +import { cacheConfig } from './cacheConfig'; +import { violationFile } from './keyvFiles'; + +/** + * Creates a cache instance using Redis or a fallback store. Suitable for general caching needs. + * @param namespace - The cache namespace. + * @param ttl - Time to live for cache entries. + * @param fallbackStore - Optional fallback store if Redis is not used. + * @returns Cache instance. + */ +export const standardCache = (namespace: string, ttl?: number, fallbackStore?: object): Keyv => { + if (keyvRedisClient && !cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(namespace)) { + try { + const keyvRedis = new KeyvRedis(keyvRedisClient); + const cache = new Keyv(keyvRedis, { namespace, ttl }); + keyvRedis.namespace = cacheConfig.REDIS_KEY_PREFIX; + keyvRedis.keyPrefixSeparator = GLOBAL_PREFIX_SEPARATOR; + + cache.on('error', (err) => { + logger.error(`Cache error in namespace ${namespace}:`, err); + }); + + return cache; + } catch (err) { + logger.error(`Failed to create Redis cache for namespace ${namespace}:`, err); + throw err; + } + } + if (fallbackStore) { + return new Keyv({ store: fallbackStore, namespace, ttl }); + } + return new Keyv({ namespace, ttl }); +}; + +/** + * Creates a cache instance for storing violation data. + * Uses a file-based fallback store if Redis is not enabled. + * @param namespace - The cache namespace for violations. + * @param ttl - Time to live for cache entries. + * @returns Cache instance for violations. + */ +export const violationCache = (namespace: string, ttl?: number): Keyv => { + return standardCache(`violations:${namespace}`, ttl, violationFile); +}; + +/** + * Creates a session cache instance using Redis or in-memory store. + * @param namespace - The session namespace. + * @param ttl - Time to live for session entries. + * @returns Session store instance. + */ +export const sessionCache = (namespace: string, ttl?: number): MemoryStore | ConnectRedis => { + namespace = namespace.endsWith(':') ? namespace : `${namespace}:`; + if (!cacheConfig.USE_REDIS) { + const MemoryStore = createMemoryStore(session); + return new MemoryStore({ ttl, checkPeriod: Time.ONE_DAY }); + } + const store = new ConnectRedis({ client: ioredisClient, ttl, prefix: namespace }); + if (ioredisClient) { + ioredisClient.on('error', (err) => { + logger.error(`Session store Redis error for namespace ${namespace}:`, err); + }); + } + return store; +}; + +/** + * Creates a rate limiter cache using Redis. + * @param prefix - The key prefix for rate limiting. + * @returns RedisStore instance or undefined if Redis is not used. + */ +export const limiterCache = (prefix: string): RedisStore | undefined => { + if (!prefix) { + throw new Error('prefix is required'); + } + if (!cacheConfig.USE_REDIS) { + return undefined; + } + // TODO: The prefix is not actually applied. Also needs to account for global prefix. + prefix = prefix.endsWith(':') ? prefix : `${prefix}:`; + + try { + const sendCommand: SendCommandFn = (async (...args: string[]) => { + if (ioredisClient == null) { + throw new Error('Redis client not available'); + } + try { + return await ioredisClient.call(args[0], ...args.slice(1)); + } catch (err) { + logger.error('Redis command execution failed:', err); + throw err; + } + }) as SendCommandFn; + return new RedisStore({ sendCommand, prefix }); + } catch (err) { + logger.error(`Failed to create Redis rate limiter for prefix ${prefix}:`, err); + return undefined; + } +}; diff --git a/packages/api/src/cache/index.ts b/packages/api/src/cache/index.ts new file mode 100644 index 0000000000..de1076e803 --- /dev/null +++ b/packages/api/src/cache/index.ts @@ -0,0 +1,5 @@ +export * from './cacheConfig'; +export * from './redisClients'; +export * from './keyvFiles'; +export { default as keyvMongo } from './keyvMongo'; +export * from './cacheFactory'; diff --git a/packages/api/src/cache/keyvFiles.ts b/packages/api/src/cache/keyvFiles.ts new file mode 100644 index 0000000000..94fe9b7cab --- /dev/null +++ b/packages/api/src/cache/keyvFiles.ts @@ -0,0 +1,6 @@ +import { KeyvFile } from 'keyv-file'; + +export const logFile = new KeyvFile({ filename: './data/logs.json' }).setMaxListeners(20); +export const violationFile = new KeyvFile({ filename: './data/violations.json' }).setMaxListeners( + 20, +); diff --git a/api/cache/keyvMongo.js b/packages/api/src/cache/keyvMongo.ts similarity index 66% rename from api/cache/keyvMongo.js rename to packages/api/src/cache/keyvMongo.ts index 1606e98eb8..4c0a08bf63 100644 --- a/api/cache/keyvMongo.js +++ b/packages/api/src/cache/keyvMongo.ts @@ -1,65 +1,69 @@ -// api/cache/keyvMongo.js -const mongoose = require('mongoose'); -const EventEmitter = require('events'); -const { GridFSBucket } = require('mongodb'); -const { logger } = require('~/config'); +import mongoose from 'mongoose'; +import { EventEmitter } from 'events'; +import { GridFSBucket } from 'mongodb'; +import { logger } from '@librechat/data-schemas'; +import type { Db, ReadPreference, Collection } from 'mongodb'; -const storeMap = new Map(); +interface KeyvMongoOptions { + url?: string; + collection?: string; + useGridFS?: boolean; + readPreference?: ReadPreference; +} + +interface GridFSClient { + bucket: GridFSBucket; + store: Collection; + db: Db; +} + +interface CollectionClient { + store: Collection; + db: Db; +} + +type Client = GridFSClient | CollectionClient; + +const storeMap = new Map(); class KeyvMongoCustom extends EventEmitter { - constructor(url, options = {}) { - super(); + private opts: KeyvMongoOptions; + public ttlSupport: boolean; + public namespace?: string; - url = url || {}; - if (typeof url === 'string') { - url = { url }; - } - if (url.uri) { - url = { url: url.uri, ...url }; - } + constructor(options: KeyvMongoOptions = {}) { + super(); this.opts = { url: 'mongodb://127.0.0.1:27017', collection: 'keyv', - ...url, ...options, }; this.ttlSupport = false; - - // Filter valid options - const keyvMongoKeys = new Set([ - 'url', - 'collection', - 'namespace', - 'serialize', - 'deserialize', - 'uri', - 'useGridFS', - 'dialect', - ]); - this.opts = Object.fromEntries(Object.entries(this.opts).filter(([k]) => keyvMongoKeys.has(k))); } // Helper to access the store WITHOUT storing a promise on the instance - _getClient() { + private async _getClient(): Promise { const storeKey = `${this.opts.collection}:${this.opts.useGridFS ? 'gridfs' : 'collection'}`; // If we already have the store initialized, return it directly if (storeMap.has(storeKey)) { - return Promise.resolve(storeMap.get(storeKey)); + return storeMap.get(storeKey)!; } // Check mongoose connection state if (mongoose.connection.readyState !== 1) { - return Promise.reject( - new Error('Mongoose connection not ready. Ensure connectDb() is called first.'), - ); + throw new Error('Mongoose connection not ready. Ensure connectDb() is called first.'); } try { - const db = mongoose.connection.db; - let client; + const db = mongoose.connection.db as unknown as Db | undefined; + if (!db) { + throw new Error('MongoDB database not available'); + } + + let client: Client; if (this.opts.useGridFS) { const bucket = new GridFSBucket(db, { @@ -75,17 +79,17 @@ class KeyvMongoCustom extends EventEmitter { } storeMap.set(storeKey, client); - return Promise.resolve(client); + return client; } catch (error) { this.emit('error', error); - return Promise.reject(error); + throw error; } } - async get(key) { + async get(key: string): Promise { const client = await this._getClient(); - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { await client.store.updateOne( { filename: key, @@ -100,7 +104,7 @@ class KeyvMongoCustom extends EventEmitter { const stream = client.bucket.openDownloadStreamByName(key); return new Promise((resolve) => { - const resp = []; + const resp: Uint8Array[] = []; stream.on('error', () => { resolve(undefined); }); @@ -110,7 +114,7 @@ class KeyvMongoCustom extends EventEmitter { resolve(data); }); - stream.on('data', (chunk) => { + stream.on('data', (chunk: Uint8Array) => { resp.push(chunk); }); }); @@ -125,7 +129,7 @@ class KeyvMongoCustom extends EventEmitter { return document.value; } - async getMany(keys) { + async getMany(keys: string[]): Promise { const client = await this._getClient(); if (this.opts.useGridFS) { @@ -135,9 +139,9 @@ class KeyvMongoCustom extends EventEmitter { } const values = await Promise.allSettled(promises); - const data = []; + const data: unknown[] = []; for (const value of values) { - data.push(value.value); + data.push(value.status === 'fulfilled' ? value.value : undefined); } return data; @@ -148,7 +152,7 @@ class KeyvMongoCustom extends EventEmitter { .project({ _id: 0, value: 1, key: 1 }) .toArray(); - const results = [...keys]; + const results: unknown[] = [...keys]; let i = 0; for (const key of keys) { const rowIndex = values.findIndex((row) => row.key === key); @@ -159,11 +163,11 @@ class KeyvMongoCustom extends EventEmitter { return results; } - async set(key, value, ttl) { + async set(key: string, value: string, ttl?: number): Promise { const client = await this._getClient(); const expiresAt = typeof ttl === 'number' ? new Date(Date.now() + ttl) : null; - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { const stream = client.bucket.openUploadStream(key, { metadata: { expiresAt, @@ -186,20 +190,18 @@ class KeyvMongoCustom extends EventEmitter { ); } - async delete(key) { - if (typeof key !== 'string') { - return false; - } - + async delete(key: string): Promise { const client = await this._getClient(); - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { try { const bucket = new GridFSBucket(client.db, { bucketName: this.opts.collection, }); const files = await bucket.find({ filename: key }).toArray(); - await client.bucket.delete(files[0]._id); + if (files.length > 0) { + await client.bucket.delete(files[0]._id); + } return true; } catch { return false; @@ -210,10 +212,10 @@ class KeyvMongoCustom extends EventEmitter { return object.deletedCount > 0; } - async deleteMany(keys) { + async deleteMany(keys: string[]): Promise { const client = await this._getClient(); - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { const bucket = new GridFSBucket(client.db, { bucketName: this.opts.collection, }); @@ -230,15 +232,17 @@ class KeyvMongoCustom extends EventEmitter { return object.deletedCount > 0; } - async clear() { + async clear(): Promise { const client = await this._getClient(); - if (this.opts.useGridFS) { + if (this.opts.useGridFS && this.isGridFSClient(client)) { try { await client.bucket.drop(); - } catch (error) { + } catch (error: unknown) { // Throw error if not "namespace not found" error - if (!(error.code === 26)) { + const errorCode = + error instanceof Error && 'code' in error ? (error as { code?: number }).code : undefined; + if (errorCode !== 26) { throw error; } } @@ -249,7 +253,7 @@ class KeyvMongoCustom extends EventEmitter { }); } - async has(key) { + async has(key: string): Promise { const client = await this._getClient(); const filter = { [this.opts.useGridFS ? 'filename' : 'key']: { $eq: key } }; const document = await client.store.countDocuments(filter, { limit: 1 }); @@ -257,10 +261,14 @@ class KeyvMongoCustom extends EventEmitter { } // No-op disconnect - async disconnect() { + async disconnect(): Promise { // This is a no-op since we don't want to close the shared mongoose connection return true; } + + private isGridFSClient(client: Client): client is GridFSClient { + return (client as GridFSClient).bucket != null; + } } const keyvMongo = new KeyvMongoCustom({ @@ -269,4 +277,4 @@ const keyvMongo = new KeyvMongoCustom({ keyvMongo.on('error', (err) => logger.error('KeyvMongo connection error:', err)); -module.exports = keyvMongo; +export default keyvMongo; diff --git a/api/cache/redisClients.js b/packages/api/src/cache/redisClients.ts similarity index 73% rename from api/cache/redisClients.js rename to packages/api/src/cache/redisClients.ts index ab59656add..6c11c1a0a8 100644 --- a/api/cache/redisClients.js +++ b/packages/api/src/cache/redisClients.ts @@ -1,26 +1,26 @@ -const IoRedis = require('ioredis'); -const { logger } = require('@librechat/data-schemas'); -const { createClient, createCluster } = require('@keyv/redis'); -const { cacheConfig } = require('./cacheConfig'); +import IoRedis from 'ioredis'; +import type { Redis, Cluster } from 'ioredis'; +import { logger } from '@librechat/data-schemas'; +import { createClient, createCluster } from '@keyv/redis'; +import type { RedisClientType, RedisClusterType } from '@redis/client'; +import { cacheConfig } from './cacheConfig'; const GLOBAL_PREFIX_SEPARATOR = '::'; -const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri)); -const username = urls?.[0].username || cacheConfig.REDIS_USERNAME; -const password = urls?.[0].password || cacheConfig.REDIS_PASSWORD; +const urls = cacheConfig.REDIS_URI?.split(',').map((uri) => new URL(uri)) || []; +const username = urls?.[0]?.username || cacheConfig.REDIS_USERNAME; +const password = urls?.[0]?.password || cacheConfig.REDIS_PASSWORD; const ca = cacheConfig.REDIS_CA; -/** @type {import('ioredis').Redis | import('ioredis').Cluster | null} */ -let ioredisClient = null; +let ioredisClient: Redis | Cluster | null = null; if (cacheConfig.USE_REDIS) { - /** @type {import('ioredis').RedisOptions | import('ioredis').ClusterOptions} */ - const redisOptions = { + const redisOptions: Record = { username: username, password: password, tls: ca ? { ca } : undefined, keyPrefix: `${cacheConfig.REDIS_KEY_PREFIX}${GLOBAL_PREFIX_SEPARATOR}`, maxListeners: cacheConfig.REDIS_MAX_LISTENERS, - retryStrategy: (times) => { + retryStrategy: (times: number) => { if ( cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 && times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS @@ -34,7 +34,7 @@ if (cacheConfig.USE_REDIS) { logger.info(`ioredis reconnecting... attempt ${times}, delay ${delay}ms`); return delay; }, - reconnectOnError: (err) => { + reconnectOnError: (err: Error) => { const targetError = 'READONLY'; if (err.message.includes(targetError)) { logger.warn('ioredis reconnecting due to READONLY error'); @@ -49,15 +49,20 @@ if (cacheConfig.USE_REDIS) { ioredisClient = urls.length === 1 && !cacheConfig.USE_REDIS_CLUSTER - ? new IoRedis(cacheConfig.REDIS_URI, redisOptions) + ? new IoRedis(cacheConfig.REDIS_URI!, redisOptions) : new IoRedis.Cluster( urls.map((url) => ({ host: url.hostname, port: parseInt(url.port, 10) || 6379 })), { ...(cacheConfig.REDIS_USE_ALTERNATIVE_DNS_LOOKUP - ? { dnsLookup: (address, callback) => callback(null, address) } + ? { + dnsLookup: ( + address: string, + callback: (err: Error | null, address: string) => void, + ) => callback(null, address), + } : {}), redisOptions, - clusterRetryStrategy: (times) => { + clusterRetryStrategy: (times: number) => { if ( cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 && times > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS @@ -87,7 +92,7 @@ if (cacheConfig.USE_REDIS) { logger.info('ioredis client ready'); }); - ioredisClient.on('reconnecting', (delay) => { + ioredisClient.on('reconnecting', (delay: number) => { logger.info(`ioredis client reconnecting in ${delay}ms`); }); @@ -96,7 +101,7 @@ if (cacheConfig.USE_REDIS) { }); /** Ping Interval to keep the Redis server connection alive (if enabled) */ - let pingInterval = null; + let pingInterval: NodeJS.Timeout | null = null; const clearPingInterval = () => { if (pingInterval) { clearInterval(pingInterval); @@ -117,22 +122,20 @@ if (cacheConfig.USE_REDIS) { } } -/** @type {import('@keyv/redis').RedisClient | import('@keyv/redis').RedisCluster | null} */ -let keyvRedisClient = null; +let keyvRedisClient: RedisClientType | RedisClusterType | null = null; if (cacheConfig.USE_REDIS) { /** * ** WARNING ** Keyv Redis client does not support Prefix like ioredis above. * The prefix feature will be handled by the Keyv-Redis store in cacheFactory.js - * @type {import('@keyv/redis').RedisClientOptions | import('@keyv/redis').RedisClusterOptions} */ - const redisOptions = { + const redisOptions: Record = { username, password, socket: { tls: ca != null, ca, connectTimeout: cacheConfig.REDIS_CONNECT_TIMEOUT, - reconnectStrategy: (retries) => { + reconnectStrategy: (retries: number) => { if ( cacheConfig.REDIS_RETRY_MAX_ATTEMPTS > 0 && retries > cacheConfig.REDIS_RETRY_MAX_ATTEMPTS @@ -148,6 +151,9 @@ if (cacheConfig.USE_REDIS) { }, }, disableOfflineQueue: !cacheConfig.REDIS_ENABLE_OFFLINE_QUEUE, + ...(cacheConfig.REDIS_PING_INTERVAL > 0 + ? { pingInterval: cacheConfig.REDIS_PING_INTERVAL * 1000 } + : {}), }; keyvRedisClient = @@ -184,27 +190,6 @@ if (cacheConfig.USE_REDIS) { logger.error('@keyv/redis initial connection failed:', err); throw err; }); - - /** Ping Interval to keep the Redis server connection alive (if enabled) */ - let pingInterval = null; - const clearPingInterval = () => { - if (pingInterval) { - clearInterval(pingInterval); - pingInterval = null; - } - }; - - if (cacheConfig.REDIS_PING_INTERVAL > 0) { - pingInterval = setInterval(() => { - if (keyvRedisClient && keyvRedisClient.isReady) { - keyvRedisClient.ping().catch((err) => { - logger.error('@keyv/redis ping failed:', err); - }); - } - }, cacheConfig.REDIS_PING_INTERVAL * 1000); - keyvRedisClient.on('disconnect', clearPingInterval); - keyvRedisClient.on('end', clearPingInterval); - } } -module.exports = { ioredisClient, keyvRedisClient, GLOBAL_PREFIX_SEPARATOR }; +export { ioredisClient, keyvRedisClient, GLOBAL_PREFIX_SEPARATOR }; diff --git a/api/server/services/Files/Azure/initialize.js b/packages/api/src/cdn/azure.ts similarity index 63% rename from api/server/services/Files/Azure/initialize.js rename to packages/api/src/cdn/azure.ts index 56df24d04a..247530286c 100644 --- a/api/server/services/Files/Azure/initialize.js +++ b/packages/api/src/cdn/azure.ts @@ -1,7 +1,8 @@ -const { BlobServiceClient } = require('@azure/storage-blob'); -const { logger } = require('~/config'); +import { logger } from '@librechat/data-schemas'; +import { DefaultAzureCredential } from '@azure/identity'; +import type { ContainerClient, BlobServiceClient } from '@azure/storage-blob'; -let blobServiceClient = null; +let blobServiceClient: BlobServiceClient | null = null; let azureWarningLogged = false; /** @@ -9,18 +10,18 @@ let azureWarningLogged = false; * This function establishes a connection by checking if a connection string is provided. * If available, the connection string is used; otherwise, Managed Identity (via DefaultAzureCredential) is utilized. * Note: Container creation (and its public access settings) is handled later in the CRUD functions. - * @returns {BlobServiceClient|null} The initialized client, or null if the required configuration is missing. + * @returns The initialized client, or null if the required configuration is missing. */ -const initializeAzureBlobService = () => { +export const initializeAzureBlobService = async (): Promise => { if (blobServiceClient) { return blobServiceClient; } const connectionString = process.env.AZURE_STORAGE_CONNECTION_STRING; if (connectionString) { + const { BlobServiceClient } = await import('@azure/storage-blob'); blobServiceClient = BlobServiceClient.fromConnectionString(connectionString); logger.info('Azure Blob Service initialized using connection string'); } else { - const { DefaultAzureCredential } = require('@azure/identity'); const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME; if (!accountName) { if (!azureWarningLogged) { @@ -33,6 +34,7 @@ const initializeAzureBlobService = () => { } const url = `https://${accountName}.blob.core.windows.net`; const credential = new DefaultAzureCredential(); + const { BlobServiceClient } = await import('@azure/storage-blob'); blobServiceClient = new BlobServiceClient(url, credential); logger.info('Azure Blob Service initialized using Managed Identity'); } @@ -41,15 +43,12 @@ const initializeAzureBlobService = () => { /** * Retrieves the Azure ContainerClient for the given container name. - * @param {string} [containerName=process.env.AZURE_CONTAINER_NAME || 'files'] - The container name. - * @returns {ContainerClient|null} The Azure ContainerClient. + * @param [containerName=process.env.AZURE_CONTAINER_NAME || 'files'] - The container name. + * @returns The Azure ContainerClient. */ -const getAzureContainerClient = (containerName = process.env.AZURE_CONTAINER_NAME || 'files') => { - const serviceClient = initializeAzureBlobService(); +export const getAzureContainerClient = async ( + containerName = process.env.AZURE_CONTAINER_NAME || 'files', +): Promise => { + const serviceClient = await initializeAzureBlobService(); return serviceClient ? serviceClient.getContainerClient(containerName) : null; }; - -module.exports = { - initializeAzureBlobService, - getAzureContainerClient, -}; diff --git a/packages/api/src/cdn/firebase.ts b/packages/api/src/cdn/firebase.ts new file mode 100644 index 0000000000..30fca65b1b --- /dev/null +++ b/packages/api/src/cdn/firebase.ts @@ -0,0 +1,42 @@ +import firebase from 'firebase/app'; +import { getStorage } from 'firebase/storage'; +import { logger } from '@librechat/data-schemas'; +import type { FirebaseStorage } from 'firebase/storage'; +import type { FirebaseApp } from 'firebase/app'; + +let firebaseInitCount = 0; +let firebaseApp: FirebaseApp | null = null; + +export const initializeFirebase = () => { + if (firebaseApp) { + return firebaseApp; + } + + const firebaseConfig = { + apiKey: process.env.FIREBASE_API_KEY, + authDomain: process.env.FIREBASE_AUTH_DOMAIN, + projectId: process.env.FIREBASE_PROJECT_ID, + storageBucket: process.env.FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.FIREBASE_APP_ID, + }; + + if (Object.values(firebaseConfig).some((value) => !value)) { + if (firebaseInitCount === 0) { + logger.info( + '[Optional] Firebase CDN not initialized. To enable, set FIREBASE_API_KEY, FIREBASE_AUTH_DOMAIN, FIREBASE_PROJECT_ID, FIREBASE_STORAGE_BUCKET, FIREBASE_MESSAGING_SENDER_ID, and FIREBASE_APP_ID environment variables.', + ); + } + firebaseInitCount++; + return null; + } + + firebaseApp = firebase.initializeApp(firebaseConfig); + logger.info('Firebase CDN initialized'); + return firebaseApp; +}; + +export const getFirebaseStorage = (): FirebaseStorage | null => { + const app = initializeFirebase(); + return app ? getStorage(app) : null; +}; diff --git a/packages/api/src/cdn/index.ts b/packages/api/src/cdn/index.ts new file mode 100644 index 0000000000..04be850216 --- /dev/null +++ b/packages/api/src/cdn/index.ts @@ -0,0 +1,3 @@ +export * from './azure'; +export * from './firebase'; +export * from './s3'; diff --git a/api/server/services/Files/S3/initialize.js b/packages/api/src/cdn/s3.ts similarity index 83% rename from api/server/services/Files/S3/initialize.js rename to packages/api/src/cdn/s3.ts index 2daec25235..683a7887fa 100644 --- a/api/server/services/Files/S3/initialize.js +++ b/packages/api/src/cdn/s3.ts @@ -1,7 +1,7 @@ -const { S3Client } = require('@aws-sdk/client-s3'); -const { logger } = require('~/config'); +import { S3Client } from '@aws-sdk/client-s3'; +import { logger } from '@librechat/data-schemas'; -let s3 = null; +let s3: S3Client | null = null; /** * Initializes and returns an instance of the AWS S3 client. @@ -11,9 +11,9 @@ let s3 = null; * * If AWS_ENDPOINT_URL is provided, it will be used as the endpoint. * - * @returns {S3Client|null} An instance of S3Client if the region is provided; otherwise, null. + * @returns An instance of S3Client if the region is provided; otherwise, null. */ -const initializeS3 = () => { +export const initializeS3 = (): S3Client | null => { if (s3) { return s3; } @@ -49,5 +49,3 @@ const initializeS3 = () => { return s3; }; - -module.exports = { initializeS3 }; diff --git a/packages/api/src/files/context.ts b/packages/api/src/files/context.ts new file mode 100644 index 0000000000..24418ce49d --- /dev/null +++ b/packages/api/src/files/context.ts @@ -0,0 +1,68 @@ +import { logger } from '@librechat/data-schemas'; +import { FileSources, mergeFileConfig } from 'librechat-data-provider'; +import type { fileConfigSchema } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { z } from 'zod'; +import { processTextWithTokenLimit } from '~/utils/text'; + +/** + * Extracts text context from attachments and returns formatted text. + * This handles text that was already extracted from files (OCR, transcriptions, document text, etc.) + * @param params - The parameters object + * @param params.attachments - Array of file attachments + * @param params.req - Express request object for config access + * @param params.tokenCountFn - Function to count tokens in text + * @returns The formatted file context text, or undefined if no text found + */ +export async function extractFileContext({ + attachments, + req, + tokenCountFn, +}: { + attachments: IMongoFile[]; + req?: { + body?: { fileTokenLimit?: number }; + config?: { fileConfig?: z.infer }; + }; + tokenCountFn: (text: string) => number; +}): Promise { + if (!attachments || attachments.length === 0) { + return undefined; + } + + const fileConfig = mergeFileConfig(req?.config?.fileConfig); + const fileTokenLimit = req?.body?.fileTokenLimit ?? fileConfig.fileTokenLimit; + + if (!fileTokenLimit) { + // If no token limit, return undefined (no processing) + return undefined; + } + + let resultText = ''; + + for (const file of attachments) { + const source = file.source ?? FileSources.local; + if (source === FileSources.text && file.text) { + const { text: limitedText, wasTruncated } = await processTextWithTokenLimit({ + text: file.text, + tokenLimit: fileTokenLimit, + tokenCountFn, + }); + + if (wasTruncated) { + logger.debug( + `[extractFileContext] Text content truncated for file: ${file.filename} due to token limits`, + ); + } + + resultText += `${!resultText ? 'Attached document(s):\n```md' : '\n\n---\n\n'}# "${file.filename}"\n${limitedText}\n`; + } + } + + if (resultText) { + resultText += '\n```'; + return resultText; + } + + return undefined; +} diff --git a/packages/api/src/files/encode/audio.ts b/packages/api/src/files/encode/audio.ts new file mode 100644 index 0000000000..d411e61640 --- /dev/null +++ b/packages/api/src/files/encode/audio.ts @@ -0,0 +1,74 @@ +import { Providers } from '@librechat/agents'; +import { isDocumentSupportedProvider } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { Request } from 'express'; +import type { StrategyFunctions, AudioResult } from '~/types/files'; +import { validateAudio } from '~/files/validation'; +import { getFileStream } from './utils'; + +/** + * Encodes and formats audio files for different providers + * @param req - The request object + * @param files - Array of audio files + * @param provider - The provider to format for (currently only google is supported) + * @param getStrategyFunctions - Function to get strategy functions + * @returns Promise that resolves to audio and file metadata + */ +export async function encodeAndFormatAudios( + req: Request, + files: IMongoFile[], + provider: Providers, + getStrategyFunctions: (source: string) => StrategyFunctions, +): Promise { + if (!files?.length) { + return { audios: [], files: [] }; + } + + const encodingMethods: Record = {}; + const result: AudioResult = { audios: [], files: [] }; + + const results = await Promise.allSettled( + files.map((file) => getFileStream(req, file, encodingMethods, getStrategyFunctions)), + ); + + for (const settledResult of results) { + if (settledResult.status === 'rejected') { + console.error('Audio processing failed:', settledResult.reason); + continue; + } + + const processed = settledResult.value; + if (!processed) continue; + + const { file, content, metadata } = processed; + + if (!content || !file) { + if (metadata) result.files.push(metadata); + continue; + } + + if (!file.type.startsWith('audio/') || !isDocumentSupportedProvider(provider)) { + result.files.push(metadata); + continue; + } + + const audioBuffer = Buffer.from(content, 'base64'); + const validation = await validateAudio(audioBuffer, audioBuffer.length, provider); + + if (!validation.isValid) { + throw new Error(`Audio validation failed: ${validation.error}`); + } + + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + result.audios.push({ + type: 'audio', + mimeType: file.type, + data: content, + }); + } + + result.files.push(metadata); + } + + return result; +} diff --git a/packages/api/src/files/encode/document.ts b/packages/api/src/files/encode/document.ts new file mode 100644 index 0000000000..338a4ddea6 --- /dev/null +++ b/packages/api/src/files/encode/document.ts @@ -0,0 +1,113 @@ +import { Providers } from '@librechat/agents'; +import { isOpenAILikeProvider, isDocumentSupportedProvider } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { Request } from 'express'; +import type { StrategyFunctions, DocumentResult, AnthropicDocumentBlock } from '~/types/files'; +import { validatePdf } from '~/files/validation'; +import { getFileStream } from './utils'; + +/** + * Processes and encodes document files for various providers + * @param req - Express request object + * @param files - Array of file objects to process + * @param provider - The provider name + * @param getStrategyFunctions - Function to get strategy functions + * @returns Promise that resolves to documents and file metadata + */ +export async function encodeAndFormatDocuments( + req: Request, + files: IMongoFile[], + { provider, useResponsesApi }: { provider: Providers; useResponsesApi?: boolean }, + getStrategyFunctions: (source: string) => StrategyFunctions, +): Promise { + if (!files?.length) { + return { documents: [], files: [] }; + } + + const encodingMethods: Record = {}; + const result: DocumentResult = { documents: [], files: [] }; + + const documentFiles = files.filter( + (file) => file.type === 'application/pdf' || file.type?.startsWith('application/'), + ); + + if (!documentFiles.length) { + return result; + } + + const results = await Promise.allSettled( + documentFiles.map((file) => { + if (file.type !== 'application/pdf' || !isDocumentSupportedProvider(provider)) { + return Promise.resolve(null); + } + return getFileStream(req, file, encodingMethods, getStrategyFunctions); + }), + ); + + for (const settledResult of results) { + if (settledResult.status === 'rejected') { + console.error('Document processing failed:', settledResult.reason); + continue; + } + + const processed = settledResult.value; + if (!processed) continue; + + const { file, content, metadata } = processed; + + if (!content || !file) { + if (metadata) result.files.push(metadata); + continue; + } + + if (file.type === 'application/pdf' && isDocumentSupportedProvider(provider)) { + const pdfBuffer = Buffer.from(content, 'base64'); + const validation = await validatePdf(pdfBuffer, pdfBuffer.length, provider); + + if (!validation.isValid) { + throw new Error(`PDF validation failed: ${validation.error}`); + } + + if (provider === Providers.ANTHROPIC) { + const document: AnthropicDocumentBlock = { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: content, + }, + citations: { enabled: true }, + }; + + if (file.filename) { + document.context = `File: "${file.filename}"`; + } + + result.documents.push(document); + } else if (useResponsesApi) { + result.documents.push({ + type: 'input_file', + filename: file.filename, + file_data: `data:application/pdf;base64,${content}`, + }); + } else if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + result.documents.push({ + type: 'document', + mimeType: 'application/pdf', + data: content, + }); + } else if (isOpenAILikeProvider(provider) && provider != Providers.AZURE) { + result.documents.push({ + type: 'file', + file: { + filename: file.filename, + file_data: `data:application/pdf;base64,${content}`, + }, + }); + } + result.files.push(metadata); + } + } + + return result; +} diff --git a/packages/api/src/files/encode/index.ts b/packages/api/src/files/encode/index.ts new file mode 100644 index 0000000000..a0708596f3 --- /dev/null +++ b/packages/api/src/files/encode/index.ts @@ -0,0 +1,3 @@ +export * from './audio'; +export * from './document'; +export * from './video'; diff --git a/packages/api/src/files/encode/utils.ts b/packages/api/src/files/encode/utils.ts new file mode 100644 index 0000000000..3664e074c9 --- /dev/null +++ b/packages/api/src/files/encode/utils.ts @@ -0,0 +1,46 @@ +import getStream from 'get-stream'; +import { FileSources } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { Request } from 'express'; +import type { StrategyFunctions, ProcessedFile } from '~/types/files'; + +/** + * Processes a file by downloading and encoding it to base64 + * @param req - Express request object + * @param file - File object to process + * @param encodingMethods - Cache of encoding methods by source + * @param getStrategyFunctions - Function to get strategy functions for a source + * @returns Processed file with content and metadata, or null if filepath missing + */ +export async function getFileStream( + req: Request, + file: IMongoFile, + encodingMethods: Record, + getStrategyFunctions: (source: string) => StrategyFunctions, +): Promise { + if (!file?.filepath) { + return null; + } + + const source = file.source ?? FileSources.local; + if (!encodingMethods[source]) { + encodingMethods[source] = getStrategyFunctions(source); + } + + const { getDownloadStream } = encodingMethods[source]; + const stream = await getDownloadStream(req, file.filepath); + const buffer = await getStream.buffer(stream); + + return { + file, + content: buffer.toString('base64'), + metadata: { + file_id: file.file_id, + temp_file_id: file.temp_file_id, + filepath: file.filepath, + source: file.source, + filename: file.filename, + type: file.type, + }, + }; +} diff --git a/packages/api/src/files/encode/video.ts b/packages/api/src/files/encode/video.ts new file mode 100644 index 0000000000..10fd4c691f --- /dev/null +++ b/packages/api/src/files/encode/video.ts @@ -0,0 +1,74 @@ +import { Providers } from '@librechat/agents'; +import { isDocumentSupportedProvider } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { Request } from 'express'; +import type { StrategyFunctions, VideoResult } from '~/types/files'; +import { validateVideo } from '~/files/validation'; +import { getFileStream } from './utils'; + +/** + * Encodes and formats video files for different providers + * @param req - The request object + * @param files - Array of video files + * @param provider - The provider to format for + * @param getStrategyFunctions - Function to get strategy functions + * @returns Promise that resolves to videos and file metadata + */ +export async function encodeAndFormatVideos( + req: Request, + files: IMongoFile[], + provider: Providers, + getStrategyFunctions: (source: string) => StrategyFunctions, +): Promise { + if (!files?.length) { + return { videos: [], files: [] }; + } + + const encodingMethods: Record = {}; + const result: VideoResult = { videos: [], files: [] }; + + const results = await Promise.allSettled( + files.map((file) => getFileStream(req, file, encodingMethods, getStrategyFunctions)), + ); + + for (const settledResult of results) { + if (settledResult.status === 'rejected') { + console.error('Video processing failed:', settledResult.reason); + continue; + } + + const processed = settledResult.value; + if (!processed) continue; + + const { file, content, metadata } = processed; + + if (!content || !file) { + if (metadata) result.files.push(metadata); + continue; + } + + if (!file.type.startsWith('video/') || !isDocumentSupportedProvider(provider)) { + result.files.push(metadata); + continue; + } + + const videoBuffer = Buffer.from(content, 'base64'); + const validation = await validateVideo(videoBuffer, videoBuffer.length, provider); + + if (!validation.isValid) { + throw new Error(`Video validation failed: ${validation.error}`); + } + + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + result.videos.push({ + type: 'video', + mimeType: file.type, + data: content, + }); + } + + result.files.push(metadata); + } + + return result; +} diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts index 49e5bb4151..9111b8d5e3 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -1,5 +1,8 @@ export * from './audio'; +export * from './context'; +export * from './encode'; export * from './mistral/crud'; export * from './ocr'; export * from './parse'; +export * from './validation'; export * from './text'; diff --git a/packages/api/src/files/validation.ts b/packages/api/src/files/validation.ts new file mode 100644 index 0000000000..a8394fd8f6 --- /dev/null +++ b/packages/api/src/files/validation.ts @@ -0,0 +1,186 @@ +import { Providers } from '@librechat/agents'; +import { mbToBytes, isOpenAILikeProvider } from 'librechat-data-provider'; + +export interface PDFValidationResult { + isValid: boolean; + error?: string; +} + +export interface VideoValidationResult { + isValid: boolean; + error?: string; +} + +export interface AudioValidationResult { + isValid: boolean; + error?: string; +} + +export async function validatePdf( + pdfBuffer: Buffer, + fileSize: number, + provider: Providers, +): Promise { + if (provider === Providers.ANTHROPIC) { + return validateAnthropicPdf(pdfBuffer, fileSize); + } + + if (isOpenAILikeProvider(provider)) { + return validateOpenAIPdf(fileSize); + } + + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + return validateGooglePdf(fileSize); + } + + return { isValid: true }; +} + +/** + * Validates if a PDF meets Anthropic's requirements + * @param pdfBuffer - The PDF file as a buffer + * @param fileSize - The file size in bytes + * @returns Promise that resolves to validation result + */ +async function validateAnthropicPdf( + pdfBuffer: Buffer, + fileSize: number, +): Promise { + try { + if (fileSize > mbToBytes(32)) { + return { + isValid: false, + error: `PDF file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Anthropic's 32MB limit`, + }; + } + + if (!pdfBuffer || pdfBuffer.length < 5) { + return { + isValid: false, + error: 'Invalid PDF file: too small or corrupted', + }; + } + + const pdfHeader = pdfBuffer.subarray(0, 5).toString(); + if (!pdfHeader.startsWith('%PDF-')) { + return { + isValid: false, + error: 'Invalid PDF file: missing PDF header', + }; + } + + const pdfContent = pdfBuffer.toString('binary'); + if ( + pdfContent.includes('/Encrypt ') || + pdfContent.includes('/U (') || + pdfContent.includes('/O (') + ) { + return { + isValid: false, + error: 'PDF is password-protected or encrypted. Anthropic requires unencrypted PDFs.', + }; + } + + const pageMatches = pdfContent.match(/\/Type[\s]*\/Page[^s]/g); + const estimatedPages = pageMatches ? pageMatches.length : 1; + + if (estimatedPages > 100) { + return { + isValid: false, + error: `PDF has approximately ${estimatedPages} pages, exceeding Anthropic's 100-page limit`, + }; + } + + return { isValid: true }; + } catch (error) { + console.error('PDF validation error:', error); + return { + isValid: false, + error: 'Failed to validate PDF file', + }; + } +} + +async function validateOpenAIPdf(fileSize: number): Promise { + if (fileSize > 10 * 1024 * 1024) { + return { + isValid: false, + error: "PDF file size exceeds OpenAI's 10MB limit", + }; + } + + return { isValid: true }; +} + +async function validateGooglePdf(fileSize: number): Promise { + if (fileSize > 20 * 1024 * 1024) { + return { + isValid: false, + error: "PDF file size exceeds Google's 20MB limit", + }; + } + + return { isValid: true }; +} + +/** + * Validates video files for different providers + * @param videoBuffer - The video file as a buffer + * @param fileSize - The file size in bytes + * @param provider - The provider to validate for + * @returns Promise that resolves to validation result + */ +export async function validateVideo( + videoBuffer: Buffer, + fileSize: number, + provider: Providers, +): Promise { + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + if (fileSize > 20 * 1024 * 1024) { + return { + isValid: false, + error: `Video file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Google's 20MB limit`, + }; + } + } + + if (!videoBuffer || videoBuffer.length < 10) { + return { + isValid: false, + error: 'Invalid video file: too small or corrupted', + }; + } + + return { isValid: true }; +} + +/** + * Validates audio files for different providers + * @param audioBuffer - The audio file as a buffer + * @param fileSize - The file size in bytes + * @param provider - The provider to validate for + * @returns Promise that resolves to validation result + */ +export async function validateAudio( + audioBuffer: Buffer, + fileSize: number, + provider: Providers, +): Promise { + if (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) { + if (fileSize > 20 * 1024 * 1024) { + return { + isValid: false, + error: `Audio file size (${Math.round(fileSize / (1024 * 1024))}MB) exceeds Google's 20MB limit`, + }; + } + } + + if (!audioBuffer || audioBuffer.length < 10) { + return { + isValid: false, + error: 'Invalid audio file: too small or corrupted', + }; + } + + return { isValid: true }; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 6cfdc9bcc0..e839a335a4 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,5 @@ export * from './app'; +export * from './cdn'; /* Auth */ export * from './auth'; /* MCP */ @@ -35,6 +36,8 @@ export * from './files'; export * from './tools'; /* web search */ export * from './web'; +/* Cache */ +export * from './cache'; /* types */ export type * from './mcp/types'; export type * from './flow/types'; diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts index 6785de748f..5f4447b2bd 100644 --- a/packages/api/src/mcp/MCPConnectionFactory.ts +++ b/packages/api/src/mcp/MCPConnectionFactory.ts @@ -142,6 +142,7 @@ export class MCPConnectionFactory { serverName: metadata.serverName, clientInfo: metadata.clientInfo, }, + this.serverConfig.oauth_headers ?? {}, this.serverConfig.oauth, ); }; @@ -161,6 +162,7 @@ export class MCPConnectionFactory { this.serverName, data.serverUrl || '', this.userId!, + config?.oauth_headers ?? {}, config?.oauth, ); @@ -358,6 +360,7 @@ export class MCPConnectionFactory { this.serverName, serverUrl, this.userId!, + this.serverConfig.oauth_headers ?? {}, this.serverConfig.oauth, ); diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index 9d3145c632..c6bfe77b8f 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -38,7 +38,7 @@ export class MCPManager extends UserConnectionManager { /** Initializes the MCPManager by setting up server registry and app connections */ public async initialize() { await this.serversRegistry.initialize(); - this.appConnections = new ConnectionsRepository(this.serversRegistry.appServerConfigs!); + this.appConnections = new ConnectionsRepository(this.serversRegistry.appServerConfigs); } /** Retrieves an app-level or user-specific connection based on provided arguments */ @@ -63,22 +63,23 @@ export class MCPManager extends UserConnectionManager { } /** Get servers that require OAuth */ - public getOAuthServers(): Set | null { - return this.serversRegistry.oauthServers!; + public getOAuthServers(): Set { + return this.serversRegistry.oauthServers; } /** Get all servers */ - public getAllServers(): t.MCPServers | null { - return this.serversRegistry.rawConfigs!; + public getAllServers(): t.MCPServers { + return this.serversRegistry.rawConfigs; } /** Returns all available tool functions from app-level connections */ - public getAppToolFunctions(): t.LCAvailableTools | null { - return this.serversRegistry.toolFunctions!; + public getAppToolFunctions(): t.LCAvailableTools { + return this.serversRegistry.toolFunctions; } + /** Returns all available tool functions from all connections available to user */ public async getAllToolFunctions(userId: string): Promise { - const allToolFunctions: t.LCAvailableTools = this.getAppToolFunctions() ?? {}; + const allToolFunctions: t.LCAvailableTools = this.getAppToolFunctions(); const userConnections = this.getUserConnections(userId); if (!userConnections || userConnections.size === 0) { return allToolFunctions; @@ -96,22 +97,30 @@ export class MCPManager extends UserConnectionManager { userId: string, serverName: string, ): Promise { - if (this.appConnections?.has(serverName)) { - return this.serversRegistry.getToolFunctions( - serverName, - await this.appConnections.get(serverName), + try { + if (this.appConnections?.has(serverName)) { + return this.serversRegistry.getToolFunctions( + serverName, + await this.appConnections.get(serverName), + ); + } + + const userConnections = this.getUserConnections(userId); + if (!userConnections || userConnections.size === 0) { + return null; + } + if (!userConnections.has(serverName)) { + return null; + } + + return this.serversRegistry.getToolFunctions(serverName, userConnections.get(serverName)!); + } catch (error) { + logger.warn( + `[getServerToolFunctions] Error getting tool functions for server ${serverName}`, + error, ); - } - - const userConnections = this.getUserConnections(userId); - if (!userConnections || userConnections.size === 0) { return null; } - if (!userConnections.has(serverName)) { - return null; - } - - return this.serversRegistry.getToolFunctions(serverName, userConnections.get(serverName)!); } /** @@ -120,7 +129,7 @@ export class MCPManager extends UserConnectionManager { * @returns Object mapping server names to their instructions */ public getInstructions(serverNames?: string[]): Record { - const instructions = this.serversRegistry.serverInstructions!; + const instructions = this.serversRegistry.serverInstructions; if (!serverNames) return instructions; return pick(instructions, serverNames); } diff --git a/packages/api/src/mcp/MCPServersRegistry.ts b/packages/api/src/mcp/MCPServersRegistry.ts index 905a62bef8..668ad7d2c0 100644 --- a/packages/api/src/mcp/MCPServersRegistry.ts +++ b/packages/api/src/mcp/MCPServersRegistry.ts @@ -1,16 +1,22 @@ -import pick from 'lodash/pick'; -import pickBy from 'lodash/pickBy'; import mapValues from 'lodash/mapValues'; import { logger } from '@librechat/data-schemas'; import { Constants } from 'librechat-data-provider'; +import type { JsonSchemaType } from '@librechat/data-schemas'; import type { MCPConnection } from '~/mcp/connection'; -import type { JsonSchemaType } from '~/types'; import type * as t from '~/mcp/types'; import { ConnectionsRepository } from '~/mcp/ConnectionsRepository'; import { detectOAuthRequirement } from '~/mcp/oauth'; import { sanitizeUrlForLogging } from '~/mcp/utils'; import { processMCPEnv, isEnabled } from '~/utils'; +const DEFAULT_MCP_INIT_TIMEOUT_MS = 30_000; + +function getMCPInitTimeout(): number { + return process.env.MCP_INIT_TIMEOUT_MS != null + ? parseInt(process.env.MCP_INIT_TIMEOUT_MS) + : DEFAULT_MCP_INIT_TIMEOUT_MS; +} + /** * Manages MCP server configurations and metadata discovery. * Fetches server capabilities, OAuth requirements, and tool definitions for registry. @@ -20,19 +26,21 @@ import { processMCPEnv, isEnabled } from '~/utils'; export class MCPServersRegistry { private initialized: boolean = false; private connections: ConnectionsRepository; + private initTimeoutMs: number; public readonly rawConfigs: t.MCPServers; public readonly parsedConfigs: Record; - public oauthServers: Set | null = null; - public serverInstructions: Record | null = null; - public toolFunctions: t.LCAvailableTools | null = null; - public appServerConfigs: t.MCPServers | null = null; + public oauthServers: Set = new Set(); + public serverInstructions: Record = {}; + public toolFunctions: t.LCAvailableTools = {}; + public appServerConfigs: t.MCPServers = {}; constructor(configs: t.MCPServers) { this.rawConfigs = configs; this.parsedConfigs = mapValues(configs, (con) => processMCPEnv({ options: con })); this.connections = new ConnectionsRepository(configs); + this.initTimeoutMs = getMCPInitTimeout(); } /** Initializes all startup-enabled servers by gathering their metadata asynchronously */ @@ -42,21 +50,43 @@ export class MCPServersRegistry { const serverNames = Object.keys(this.parsedConfigs); - await Promise.allSettled(serverNames.map((serverName) => this.gatherServerInfo(serverName))); - - this.setOAuthServers(); - this.setServerInstructions(); - this.setAppServerConfigs(); - await this.setAppToolFunctions(); - - this.connections.disconnectAll(); + await Promise.allSettled( + serverNames.map((serverName) => this.initializeServerWithTimeout(serverName)), + ); } - /** Fetches all metadata for a single server in parallel */ - private async gatherServerInfo(serverName: string): Promise { + /** Wraps server initialization with a timeout to prevent hanging */ + private async initializeServerWithTimeout(serverName: string): Promise { + let timeoutId: NodeJS.Timeout | null = null; + + try { + await Promise.race([ + this.initializeServer(serverName), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error('Server initialization timed out')); + }, this.initTimeoutMs); + }), + ]); + } catch (error) { + logger.warn(`${this.prefix(serverName)} Server initialization failed:`, error); + throw error; + } finally { + if (timeoutId != null) { + clearTimeout(timeoutId); + } + } + } + + /** Initializes a single server with all its metadata and adds it to appropriate collections */ + private async initializeServer(serverName: string): Promise { + const start = Date.now(); + + const config = this.parsedConfigs[serverName]; + + // 1. Detect OAuth requirements if not already specified try { await this.fetchOAuthRequirement(serverName); - const config = this.parsedConfigs[serverName]; if (config.startup !== false && !config.requiresOAuth) { await Promise.allSettled([ @@ -68,54 +98,49 @@ export class MCPServersRegistry { ), ]); } - - this.logUpdatedConfig(serverName); } catch (error) { logger.warn(`${this.prefix(serverName)} Failed to initialize server:`, error); } - } - /** Sets app-level server configs (startup enabled, non-OAuth servers) */ - private setAppServerConfigs(): void { - const appServers = Object.keys( - pickBy( - this.parsedConfigs, - (config) => config.startup !== false && config.requiresOAuth === false, - ), - ); - this.appServerConfigs = pick(this.rawConfigs, appServers); - } - - /** Creates set of server names that require OAuth authentication */ - private setOAuthServers(): Set { - if (this.oauthServers) return this.oauthServers; - this.oauthServers = new Set( - Object.keys(pickBy(this.parsedConfigs, (config) => config.requiresOAuth)), - ); - return this.oauthServers; - } - - /** Collects server instructions from all configured servers */ - private setServerInstructions(): void { - this.serverInstructions = mapValues( - pickBy(this.parsedConfigs, (config) => config.serverInstructions), - (config) => config.serverInstructions as string, - ); - } - - /** Builds registry of all available tool functions from loaded connections */ - private async setAppToolFunctions(): Promise { - const connections = (await this.connections.getLoaded()).entries(); - const allToolFunctions: t.LCAvailableTools = {}; - for (const [serverName, conn] of connections) { + // 2. Fetch tool functions for this server if a connection was established + const getToolFunctions = async (): Promise => { try { - const toolFunctions = await this.getToolFunctions(serverName, conn); - Object.assign(allToolFunctions, toolFunctions); + const loadedConns = await this.connections.getLoaded(); + const conn = loadedConns.get(serverName); + if (conn == null) { + return null; + } + return this.getToolFunctions(serverName, conn); } catch (error) { logger.warn(`${this.prefix(serverName)} Error fetching tool functions:`, error); + return null; } + }; + const toolFunctions = await getToolFunctions(); + + // 3. Disconnect this server's connection if it was established (fire-and-forget) + void this.connections.disconnect(serverName); + + // 4. Side effects + // 4.1 Add to OAuth servers if needed + if (config.requiresOAuth) { + this.oauthServers.add(serverName); } - this.toolFunctions = allToolFunctions; + // 4.2 Add server instructions if available + if (config.serverInstructions != null) { + this.serverInstructions[serverName] = config.serverInstructions as string; + } + // 4.3 Add to app server configs if eligible (startup enabled, non-OAuth servers) + if (config.startup !== false && config.requiresOAuth === false) { + this.appServerConfigs[serverName] = this.rawConfigs[serverName]; + } + // 4.4 Add tool functions if available + if (toolFunctions != null) { + Object.assign(this.toolFunctions, toolFunctions); + } + + const duration = Date.now() - start; + this.logUpdatedConfig(serverName, duration); } /** Converts server tools to LibreChat-compatible tool functions format */ @@ -185,7 +210,7 @@ export class MCPServersRegistry { } // Logs server configuration summary after initialization - private logUpdatedConfig(serverName: string): void { + private logUpdatedConfig(serverName: string, initDuration: number): void { const prefix = this.prefix(serverName); const config = this.parsedConfigs[serverName]; logger.info(`${prefix} -------------------------------------------------┐`); @@ -194,6 +219,7 @@ export class MCPServersRegistry { logger.info(`${prefix} Capabilities: ${config.capabilities}`); logger.info(`${prefix} Tools: ${config.tools}`); logger.info(`${prefix} Server Instructions: ${config.serverInstructions}`); + logger.info(`${prefix} Initialized in: ${initDuration}ms`); logger.info(`${prefix} -------------------------------------------------┘`); } diff --git a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts index e96d207f29..9ee05dfb27 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts @@ -255,6 +255,7 @@ describe('MCPConnectionFactory', () => { 'test-server', 'https://api.example.com', 'user123', + {}, undefined, ); expect(oauthOptions.oauthStart).toHaveBeenCalledWith('https://auth.example.com'); diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts new file mode 100644 index 0000000000..4d60a16954 --- /dev/null +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -0,0 +1,169 @@ +import { logger } from '@librechat/data-schemas'; +import type * as t from '~/mcp/types'; +import { MCPManager } from '~/mcp/MCPManager'; +import { MCPServersRegistry } from '~/mcp/MCPServersRegistry'; +import { ConnectionsRepository } from '~/mcp/ConnectionsRepository'; +import { MCPConnection } from '../connection'; + +// Mock external dependencies +jest.mock('@librechat/data-schemas', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +jest.mock('~/mcp/MCPServersRegistry'); +jest.mock('~/mcp/ConnectionsRepository'); + +const mockLogger = logger as jest.Mocked; + +describe('MCPManager', () => { + const userId = 'test-user-123'; + const serverName = 'test_server'; + + beforeEach(() => { + // Reset MCPManager singleton state + (MCPManager as unknown as { instance: null }).instance = null; + jest.clearAllMocks(); + }); + + function mockRegistry( + registryConfig: Partial, + ): jest.MockedClass { + const mock = { + initialize: jest.fn().mockResolvedValue(undefined), + getToolFunctions: jest.fn().mockResolvedValue(null), + ...registryConfig, + }; + return (MCPServersRegistry as jest.MockedClass).mockImplementation( + () => mock as unknown as MCPServersRegistry, + ); + } + + function mockAppConnections( + appConnectionsConfig: Partial, + ): jest.MockedClass { + const mock = { + has: jest.fn().mockReturnValue(false), + get: jest.fn().mockResolvedValue({} as unknown as MCPConnection), + ...appConnectionsConfig, + }; + return ( + ConnectionsRepository as jest.MockedClass + ).mockImplementation(() => mock as unknown as ConnectionsRepository); + } + + function newMCPServersConfig(serverNameOverride?: string): t.MCPServers { + return { + [serverNameOverride ?? serverName]: { + type: 'stdio', + command: 'test', + args: [], + }, + }; + } + + describe('getServerToolFunctions', () => { + it('should catch and handle errors gracefully', async () => { + mockRegistry({ + getToolFunctions: jest.fn(() => { + throw new Error('Connection failed'); + }), + }); + + mockAppConnections({ + has: jest.fn().mockReturnValue(true), + }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + const result = await manager.getServerToolFunctions(userId, serverName); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + `[getServerToolFunctions] Error getting tool functions for server ${serverName}`, + expect.any(Error), + ); + }); + + it('should catch synchronous errors from getUserConnections', async () => { + mockRegistry({ + getToolFunctions: jest.fn().mockResolvedValue({}), + }); + + mockAppConnections({ + has: jest.fn().mockReturnValue(false), + }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + const spy = jest.spyOn(manager, 'getUserConnections').mockImplementation(() => { + throw new Error('Failed to get user connections'); + }); + + const result = await manager.getServerToolFunctions(userId, serverName); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + `[getServerToolFunctions] Error getting tool functions for server ${serverName}`, + expect.any(Error), + ); + expect(spy).toHaveBeenCalled(); + }); + + it('should return tools successfully when no errors occur', async () => { + const expectedTools: t.LCAvailableTools = { + [`test_tool_mcp_${serverName}`]: { + type: 'function', + function: { + name: `test_tool_mcp_${serverName}`, + description: 'Test tool', + parameters: { type: 'object' }, + }, + }, + }; + + mockRegistry({ + getToolFunctions: jest.fn().mockResolvedValue(expectedTools), + }); + + mockAppConnections({ + has: jest.fn().mockReturnValue(true), + }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + + const result = await manager.getServerToolFunctions(userId, serverName); + + expect(result).toEqual(expectedTools); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should include specific server name in error messages', async () => { + const specificServerName = 'github_mcp_server'; + + mockRegistry({ + getToolFunctions: jest.fn(() => { + throw new Error('Server specific error'); + }), + }); + + mockAppConnections({ + has: jest.fn().mockReturnValue(true), + }); + + const manager = await MCPManager.createInstance(newMCPServersConfig(specificServerName)); + + const result = await manager.getServerToolFunctions(userId, specificServerName); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + `[getServerToolFunctions] Error getting tool functions for server ${specificServerName}`, + expect.any(Error), + ); + }); + }); +}); diff --git a/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts b/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts index 9a276e3713..ade8eab32c 100644 --- a/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts +++ b/packages/api/src/mcp/__tests__/MCPServersRegistry.test.ts @@ -113,6 +113,7 @@ describe('MCPServersRegistry - Initialize Function', () => { get: jest.fn(), getLoaded: jest.fn(), disconnectAll: jest.fn(), + disconnect: jest.fn().mockResolvedValue(undefined), } as unknown as jest.Mocked; mockConnectionsRepo.get.mockImplementation((serverName: string) => { @@ -160,6 +161,7 @@ describe('MCPServersRegistry - Initialize Function', () => { }); afterEach(() => { + delete process.env.MCP_INIT_TIMEOUT_MS; jest.clearAllMocks(); }); @@ -179,15 +181,14 @@ describe('MCPServersRegistry - Initialize Function', () => { const registry = new MCPServersRegistry(rawConfigs); // Verify initial state - expect(registry.oauthServers).toBeNull(); - expect(registry.serverInstructions).toBeNull(); - expect(registry.toolFunctions).toBeNull(); - expect(registry.appServerConfigs).toBeNull(); + expect(registry.oauthServers.size).toBe(0); + expect(registry.serverInstructions).toEqual({}); + expect(registry.toolFunctions).toEqual({}); + expect(registry.appServerConfigs).toEqual({}); await registry.initialize(); // Test oauthServers Set - expect(registry.oauthServers).toBeInstanceOf(Set); expect(registry.oauthServers).toEqual( new Set(['oauth_server', 'oauth_predefined', 'oauth_startup_enabled']), ); @@ -228,18 +229,49 @@ describe('MCPServersRegistry - Initialize Function', () => { expect(registry.toolFunctions).toEqual(expectedToolFunctions); }); - it('should handle errors gracefully and continue initialization', async () => { + it('should handle errors gracefully and continue initialization of other servers', async () => { const registry = new MCPServersRegistry(rawConfigs); - // Make one server throw an error - mockDetectOAuthRequirement.mockRejectedValueOnce(new Error('OAuth detection failed')); + // Make one specific server throw an error during OAuth detection + mockDetectOAuthRequirement.mockImplementation((url: string) => { + if (url === 'https://api.github.com/mcp') { + return Promise.reject(new Error('OAuth detection failed')); + } + // Return normal responses for other servers + const oauthResults: Record = { + 'https://api.disabled.com/mcp': { + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }, + 'https://api.public.com/mcp': { + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }, + }; + return Promise.resolve( + oauthResults[url] ?? { + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }, + ); + }); await registry.initialize(); - // Should still initialize successfully + // Should still initialize successfully for other servers expect(registry.oauthServers).toBeInstanceOf(Set); expect(registry.toolFunctions).toBeDefined(); + // The failed server should not be in oauthServers (since it failed OAuth detection) + expect(registry.oauthServers.has('oauth_server')).toBe(false); + + // But other servers should still be processed successfully + expect(registry.appServerConfigs).toHaveProperty('stdio_server'); + expect(registry.appServerConfigs).toHaveProperty('non_oauth_server'); + // Error should be logged as a warning at the higher level expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('[MCP][oauth_server] Failed to initialize server:'), @@ -247,12 +279,15 @@ describe('MCPServersRegistry - Initialize Function', () => { ); }); - it('should disconnect all connections after initialization', async () => { + it('should disconnect individual connections after each server initialization', async () => { const registry = new MCPServersRegistry(rawConfigs); await registry.initialize(); - expect(mockConnectionsRepo.disconnectAll).toHaveBeenCalledTimes(1); + // Verify disconnect was called for each server during initialization + // All servers attempt to connect during initialization for metadata gathering + const serverNames = Object.keys(rawConfigs); + expect(mockConnectionsRepo.disconnect).toHaveBeenCalledTimes(serverNames.length); }); it('should log configuration updates for each startup-enabled server', async () => { @@ -357,5 +392,204 @@ describe('MCPServersRegistry - Initialize Function', () => { // Verify getInstructions was called for both "true" cases expect(mockClient.getInstructions).toHaveBeenCalledTimes(2); }); + + it('should use Promise.allSettled for individual server initialization', async () => { + const registry = new MCPServersRegistry(rawConfigs); + + // Spy on Promise.allSettled to verify it's being used + const allSettledSpy = jest.spyOn(Promise, 'allSettled'); + + await registry.initialize(); + + // Verify Promise.allSettled was called with an array of server initialization promises + expect(allSettledSpy).toHaveBeenCalledWith(expect.arrayContaining([expect.any(Promise)])); + + // Verify it was called with the correct number of server promises + const serverNames = Object.keys(rawConfigs); + expect(allSettledSpy).toHaveBeenCalledWith( + expect.arrayContaining(new Array(serverNames.length).fill(expect.any(Promise))), + ); + + allSettledSpy.mockRestore(); + }); + + it('should isolate server failures and not affect other servers', async () => { + const registry = new MCPServersRegistry(rawConfigs); + + // Make multiple servers fail in different ways + mockConnectionsRepo.get.mockImplementation((serverName: string) => { + if (serverName === 'stdio_server') { + // First server fails + throw new Error('Connection failed for stdio_server'); + } + if (serverName === 'websocket_server') { + // Second server fails + throw new Error('Connection failed for websocket_server'); + } + // Other servers succeed + const connection = mockConnections.get(serverName); + if (!connection) { + throw new Error(`Connection not found for server: ${serverName}`); + } + return Promise.resolve(connection); + }); + + await registry.initialize(); + + // Despite failures, initialization should complete + expect(registry.oauthServers).toBeInstanceOf(Set); + expect(registry.toolFunctions).toBeDefined(); + + // Successful servers should still be processed + expect(registry.appServerConfigs).toHaveProperty('non_oauth_server'); + + // Failed servers should not crash the whole initialization + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('[MCP][stdio_server] Failed to fetch server capabilities:'), + expect.any(Error), + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('[MCP][websocket_server] Failed to fetch server capabilities:'), + expect.any(Error), + ); + }); + + it('should properly clean up connections even when some servers fail', async () => { + const registry = new MCPServersRegistry(rawConfigs); + + // Track disconnect failures but suppress unhandled rejections + const disconnectErrors: Error[] = []; + mockConnectionsRepo.disconnect.mockImplementation((serverName: string) => { + if (serverName === 'stdio_server') { + const error = new Error('Disconnect failed'); + disconnectErrors.push(error); + return Promise.reject(error).catch(() => {}); // Suppress unhandled rejection + } + return Promise.resolve(); + }); + + await registry.initialize(); + + // Should still attempt to disconnect all servers during initialization + const serverNames = Object.keys(rawConfigs); + expect(mockConnectionsRepo.disconnect).toHaveBeenCalledTimes(serverNames.length); + expect(disconnectErrors).toHaveLength(1); + }); + + it('should timeout individual server initialization after configured timeout', async () => { + const timeout = 2000; + // Create registry with a short timeout for testing + process.env.MCP_INIT_TIMEOUT_MS = `${timeout}`; + + const registry = new MCPServersRegistry(rawConfigs); + + // Make one server hang indefinitely during OAuth detection + mockDetectOAuthRequirement.mockImplementation((url: string) => { + if (url === 'https://api.github.com/mcp') { + // Slow init + return new Promise((res) => setTimeout(res, timeout * 2)); + } + // Return normal responses for other servers + return Promise.resolve({ + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }); + }); + + const start = Date.now(); + await registry.initialize(); + const duration = Date.now() - start; + + // Should complete within reasonable time despite one server hanging + // Allow some buffer for test execution overhead + expect(duration).toBeLessThan(timeout * 1.5); + + // The timeout should prevent the hanging server from blocking initialization + // Other servers should still be processed successfully + expect(registry.appServerConfigs).toHaveProperty('stdio_server'); + expect(registry.appServerConfigs).toHaveProperty('non_oauth_server'); + }, 10_000); // 10 second Jest timeout + + it('should skip tool function fetching if connection was not established', async () => { + const testConfig: t.MCPServers = { + server_with_connection: { + type: 'stdio', + args: [], + command: 'test-command', + }, + server_without_connection: { + type: 'stdio', + args: [], + command: 'failing-command', + }, + }; + + const registry = new MCPServersRegistry(testConfig); + + const mockClient = { + listTools: jest.fn().mockResolvedValue({ + tools: [ + { + name: 'test_tool', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + }, + ], + }), + getInstructions: jest.fn().mockReturnValue(undefined), + getServerCapabilities: jest.fn().mockReturnValue({ tools: {} }), + }; + const mockConnection = { + client: mockClient, + } as unknown as jest.Mocked; + + mockConnectionsRepo.get.mockImplementation((serverName: string) => { + if (serverName === 'server_with_connection') { + return Promise.resolve(mockConnection); + } + throw new Error('Connection failed'); + }); + + // Mock getLoaded to return connections map - the real implementation returns all loaded connections at once + mockConnectionsRepo.getLoaded.mockResolvedValue( + new Map([['server_with_connection', mockConnection]]), + ); + + mockDetectOAuthRequirement.mockResolvedValue({ + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }); + + await registry.initialize(); + + expect(registry.toolFunctions).toHaveProperty('test_tool_mcp_server_with_connection'); + expect(Object.keys(registry.toolFunctions)).toHaveLength(1); + }); + + it('should handle getLoaded returning empty map gracefully', async () => { + const testConfig: t.MCPServers = { + test_server: { + type: 'stdio', + args: [], + command: 'test-command', + }, + }; + + const registry = new MCPServersRegistry(testConfig); + + mockConnectionsRepo.get.mockRejectedValue(new Error('All connections failed')); + mockConnectionsRepo.getLoaded.mockResolvedValue(new Map()); + mockDetectOAuthRequirement.mockResolvedValue({ + requiresOAuth: false, + method: 'no-metadata-found', + metadata: null, + }); + + await registry.initialize(); + + expect(registry.toolFunctions).toEqual({}); + }); }); }); diff --git a/packages/api/src/mcp/__tests__/handler.test.ts b/packages/api/src/mcp/__tests__/handler.test.ts index 01794fe4db..24e8c5ddb4 100644 --- a/packages/api/src/mcp/__tests__/handler.test.ts +++ b/packages/api/src/mcp/__tests__/handler.test.ts @@ -1,6 +1,6 @@ import type { MCPOptions } from 'librechat-data-provider'; import type { AuthorizationServerMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'; -import { MCPOAuthHandler } from '~/mcp/oauth'; +import { MCPOAuthFlowMetadata, MCPOAuthHandler, MCPOAuthTokens } from '~/mcp/oauth'; jest.mock('@librechat/data-schemas', () => ({ logger: { @@ -14,18 +14,33 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ startAuthorization: jest.fn(), discoverAuthorizationServerMetadata: jest.fn(), + discoverOAuthProtectedResourceMetadata: jest.fn(), + registerClient: jest.fn(), + exchangeAuthorization: jest.fn(), })); import { startAuthorization, discoverAuthorizationServerMetadata, + discoverOAuthProtectedResourceMetadata, + registerClient, + exchangeAuthorization, } from '@modelcontextprotocol/sdk/client/auth.js'; +import { FlowStateManager } from '../../flow/manager'; const mockStartAuthorization = startAuthorization as jest.MockedFunction; const mockDiscoverAuthorizationServerMetadata = discoverAuthorizationServerMetadata as jest.MockedFunction< typeof discoverAuthorizationServerMetadata >; +const mockDiscoverOAuthProtectedResourceMetadata = + discoverOAuthProtectedResourceMetadata as jest.MockedFunction< + typeof discoverOAuthProtectedResourceMetadata + >; +const mockRegisterClient = registerClient as jest.MockedFunction; +const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< + typeof exchangeAuthorization +>; describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { const mockServerName = 'test-server'; @@ -60,6 +75,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { mockServerName, mockServerUrl, mockUserId, + {}, baseConfig, ); @@ -82,7 +98,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { grant_types_supported: ['authorization_code'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -100,7 +122,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { token_endpoint_auth_methods_supported: ['client_secret_post'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -118,7 +146,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { response_types_supported: ['code', 'token'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -136,7 +170,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { code_challenge_methods_supported: ['S256'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -157,7 +197,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { code_challenge_methods_supported: ['S256'], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -181,7 +227,13 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { code_challenge_methods_supported: [], }; - await MCPOAuthHandler.initiateOAuthFlow(mockServerName, mockServerUrl, mockUserId, config); + await MCPOAuthHandler.initiateOAuthFlow( + mockServerName, + mockServerUrl, + mockUserId, + {}, + config, + ); expect(mockStartAuthorization).toHaveBeenCalledWith( mockServerUrl, @@ -251,7 +303,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - const result = await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + const result = await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); // Verify the call was made without Authorization header expect(mockFetch).toHaveBeenCalledWith( @@ -314,7 +366,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`; expect(mockFetch).toHaveBeenCalledWith( @@ -363,7 +415,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`; expect(mockFetch).toHaveBeenCalledWith( @@ -410,7 +462,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); const expectedAuth = `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`; expect(mockFetch).toHaveBeenCalledWith( @@ -457,7 +509,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { }), } as Response); - await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata); + await MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}); // Verify the call was made without Authorization header expect(mockFetch).toHaveBeenCalledWith( @@ -498,6 +550,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { await MCPOAuthHandler.refreshOAuthTokens( mockRefreshToken, { serverName: 'test-server' }, + {}, config, ); @@ -539,6 +592,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { await MCPOAuthHandler.refreshOAuthTokens( mockRefreshToken, { serverName: 'test-server' }, + {}, config, ); @@ -575,6 +629,7 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { await MCPOAuthHandler.refreshOAuthTokens( mockRefreshToken, { serverName: 'test-server' }, + {}, config, ); @@ -617,7 +672,9 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { '{"error":"invalid_request","error_description":"refresh_token.client_id: Field required"}', } as Response); - await expect(MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata)).rejects.toThrow( + await expect( + MCPOAuthHandler.refreshOAuthTokens(mockRefreshToken, metadata, {}, {}), + ).rejects.toThrow( 'Token refresh failed: 400 Bad Request - {"error":"invalid_request","error_description":"refresh_token.client_id: Field required"}', ); }); @@ -813,4 +870,126 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { ); }); }); + + describe('Custom OAuth Headers', () => { + const originalFetch = global.fetch; + const mockFetch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = mockFetch as unknown as typeof fetch; + mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) } as Response); + mockDiscoverAuthorizationServerMetadata.mockResolvedValue({ + issuer: 'http://example.com', + authorization_endpoint: 'http://example.com/auth', + token_endpoint: 'http://example.com/token', + response_types_supported: ['code'], + } as AuthorizationServerMetadata); + mockStartAuthorization.mockResolvedValue({ + authorizationUrl: new URL('http://example.com/auth'), + codeVerifier: 'test-verifier', + }); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('passes headers to client registration', async () => { + mockRegisterClient.mockImplementation(async (_, options) => { + await options.fetchFn?.('http://example.com/register', {}); + return { client_id: 'test', redirect_uris: [] }; + }); + + await MCPOAuthHandler.initiateOAuthFlow( + 'test-server', + 'http://example.com', + 'user-123', + { foo: 'bar' }, + {}, + ); + + const headers = mockFetch.mock.calls[0][1]?.headers as Headers; + expect(headers.get('foo')).toBe('bar'); + }); + + it('passes headers to discovery operations', async () => { + mockDiscoverOAuthProtectedResourceMetadata.mockImplementation(async (_, __, fetchFn) => { + await fetchFn?.('http://example.com/.well-known/oauth-protected-resource', {}); + return { + resource: 'http://example.com', + authorization_servers: ['http://auth.example.com'], + }; + }); + + await MCPOAuthHandler.initiateOAuthFlow( + 'test-server', + 'http://example.com', + 'user-123', + { foo: 'bar' }, + {}, + ); + + const allHaveHeader = mockFetch.mock.calls.every((call) => { + const headers = call[1]?.headers as Headers; + return headers?.get('foo') === 'bar'; + }); + expect(allHaveHeader).toBe(true); + }); + + it('passes headers to token exchange', async () => { + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ + status: 'PENDING', + metadata: { + serverName: 'test-server', + codeVerifier: 'test-verifier', + clientInfo: {}, + metadata: {}, + } as MCPOAuthFlowMetadata, + }), + completeFlow: jest.fn(), + } as unknown as FlowStateManager; + + mockExchangeAuthorization.mockImplementation(async (_, options) => { + await options.fetchFn?.('http://example.com/token', {}); + return { access_token: 'test-token', token_type: 'Bearer', expires_in: 3600 }; + }); + + await MCPOAuthHandler.completeOAuthFlow('test-flow-id', 'test-auth-code', mockFlowManager, { + foo: 'bar', + }); + + const headers = mockFetch.mock.calls[0][1]?.headers as Headers; + expect(headers.get('foo')).toBe('bar'); + }); + + it('passes headers to token refresh', async () => { + mockDiscoverAuthorizationServerMetadata.mockImplementation(async (_, options) => { + await options?.fetchFn?.('http://example.com/.well-known/oauth-authorization-server', {}); + return { + issuer: 'http://example.com', + token_endpoint: 'http://example.com/token', + } as AuthorizationServerMetadata; + }); + + await MCPOAuthHandler.refreshOAuthTokens( + 'test-refresh-token', + { + serverName: 'test-server', + serverUrl: 'http://example.com', + clientInfo: { client_id: 'test-client', client_secret: 'test-secret' }, + }, + { foo: 'bar' }, + {}, + ); + + const discoveryCall = mockFetch.mock.calls.find((call) => + call[0].toString().includes('.well-known'), + ); + expect(discoveryCall).toBeDefined(); + const headers = discoveryCall![1]?.headers as Headers; + expect(headers.get('foo')).toBe('bar'); + }); + }); }); diff --git a/packages/api/src/mcp/__tests__/zod.spec.ts b/packages/api/src/mcp/__tests__/zod.spec.ts index bc579f0166..07e62cf5ae 100644 --- a/packages/api/src/mcp/__tests__/zod.spec.ts +++ b/packages/api/src/mcp/__tests__/zod.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ // zod.spec.ts import { z } from 'zod'; -import type { JsonSchemaType } from '~/types'; +import type { JsonSchemaType } from '@librechat/data-schemas'; import { resolveJsonSchemaRefs, convertJsonSchemaToZod, convertWithResolvedRefs } from '../zod'; describe('convertJsonSchemaToZod', () => { diff --git a/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts b/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts index 9e84ef1483..09abb2b048 100644 --- a/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts +++ b/packages/api/src/mcp/oauth/OAuthReconnectionManager.ts @@ -72,7 +72,7 @@ export class OAuthReconnectionManager { // 1. derive the servers to reconnect const serversToReconnect = []; - for (const serverName of this.mcpManager.getOAuthServers() ?? []) { + for (const serverName of this.mcpManager.getOAuthServers()) { const canReconnect = await this.canReconnect(userId, serverName); if (canReconnect) { serversToReconnect.push(serverName); diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index a96dae8442..896d199b6d 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -18,6 +18,7 @@ import type { OAuthMetadata, } from './types'; import { sanitizeUrlForLogging } from '~/mcp/utils'; +import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport'; /** Type for the OAuth metadata from the SDK */ type SDKOAuthMetadata = Parameters[1]['metadata']; @@ -26,10 +27,29 @@ export class MCPOAuthHandler { private static readonly FLOW_TYPE = 'mcp_oauth'; private static readonly FLOW_TTL = 10 * 60 * 1000; // 10 minutes + /** + * Creates a fetch function with custom headers injected + */ + private static createOAuthFetch(headers: Record): FetchLike { + return async (url: string | URL, init?: RequestInit): Promise => { + const newHeaders = new Headers(init?.headers ?? {}); + for (const [key, value] of Object.entries(headers)) { + newHeaders.set(key, value); + } + return fetch(url, { + ...init, + headers: newHeaders, + }); + }; + } + /** * Discovers OAuth metadata from the server */ - private static async discoverMetadata(serverUrl: string): Promise<{ + private static async discoverMetadata( + serverUrl: string, + oauthHeaders: Record, + ): Promise<{ metadata: OAuthMetadata; resourceMetadata?: OAuthProtectedResourceMetadata; authServerUrl: URL; @@ -41,12 +61,14 @@ export class MCPOAuthHandler { let authServerUrl = new URL(serverUrl); let resourceMetadata: OAuthProtectedResourceMetadata | undefined; + const fetchFn = this.createOAuthFetch(oauthHeaders); + try { // Try to discover resource metadata first logger.debug( `[MCPOAuth] Attempting to discover protected resource metadata from ${serverUrl}`, ); - resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl); + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {}, fetchFn); if (resourceMetadata?.authorization_servers?.length) { authServerUrl = new URL(resourceMetadata.authorization_servers[0]); @@ -66,7 +88,9 @@ export class MCPOAuthHandler { logger.debug( `[MCPOAuth] Discovering OAuth metadata from ${sanitizeUrlForLogging(authServerUrl)}`, ); - const rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl); + const rawMetadata = await discoverAuthorizationServerMetadata(authServerUrl, { + fetchFn, + }); if (!rawMetadata) { logger.error( @@ -92,6 +116,7 @@ export class MCPOAuthHandler { private static async registerOAuthClient( serverUrl: string, metadata: OAuthMetadata, + oauthHeaders: Record, resourceMetadata?: OAuthProtectedResourceMetadata, redirectUri?: string, ): Promise { @@ -159,6 +184,7 @@ export class MCPOAuthHandler { const clientInfo = await registerClient(serverUrl, { metadata: metadata as unknown as SDKOAuthMetadata, clientMetadata, + fetchFn: this.createOAuthFetch(oauthHeaders), }); logger.debug( @@ -181,7 +207,8 @@ export class MCPOAuthHandler { serverName: string, serverUrl: string, userId: string, - config: MCPOptions['oauth'] | undefined, + oauthHeaders: Record, + config?: MCPOptions['oauth'], ): Promise<{ authorizationUrl: string; flowId: string; flowMetadata: MCPOAuthFlowMetadata }> { logger.debug( `[MCPOAuth] initiateOAuthFlow called for ${serverName} with URL: ${sanitizeUrlForLogging(serverUrl)}`, @@ -259,7 +286,10 @@ export class MCPOAuthHandler { logger.debug( `[MCPOAuth] Starting auto-discovery of OAuth metadata from ${sanitizeUrlForLogging(serverUrl)}`, ); - const { metadata, resourceMetadata, authServerUrl } = await this.discoverMetadata(serverUrl); + const { metadata, resourceMetadata, authServerUrl } = await this.discoverMetadata( + serverUrl, + oauthHeaders, + ); logger.debug( `[MCPOAuth] OAuth metadata discovered, auth server URL: ${sanitizeUrlForLogging(authServerUrl)}`, @@ -272,6 +302,7 @@ export class MCPOAuthHandler { const clientInfo = await this.registerOAuthClient( authServerUrl.toString(), metadata, + oauthHeaders, resourceMetadata, redirectUri, ); @@ -365,6 +396,7 @@ export class MCPOAuthHandler { flowId: string, authorizationCode: string, flowManager: FlowStateManager, + oauthHeaders: Record, ): Promise { try { /** Flow state which contains our metadata */ @@ -404,6 +436,7 @@ export class MCPOAuthHandler { codeVerifier: metadata.codeVerifier, authorizationCode, resource, + fetchFn: this.createOAuthFetch(oauthHeaders), }); logger.debug('[MCPOAuth] Raw tokens from exchange:', { @@ -476,6 +509,7 @@ export class MCPOAuthHandler { static async refreshOAuthTokens( refreshToken: string, metadata: { serverName: string; serverUrl?: string; clientInfo?: OAuthClientInformation }, + oauthHeaders: Record, config?: MCPOptions['oauth'], ): Promise { logger.debug(`[MCPOAuth] Refreshing tokens for ${metadata.serverName}`); @@ -509,7 +543,9 @@ export class MCPOAuthHandler { throw new Error('No token URL available for refresh'); } else { /** Auto-discover OAuth configuration for refresh */ - const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl); + const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl, { + fetchFn: this.createOAuthFetch(oauthHeaders), + }); if (!oauthMetadata) { throw new Error('Failed to discover OAuth metadata for token refresh'); } @@ -533,6 +569,7 @@ export class MCPOAuthHandler { const headers: HeadersInit = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', + ...oauthHeaders, }; /** Handle authentication based on server's advertised methods */ @@ -613,6 +650,7 @@ export class MCPOAuthHandler { const headers: HeadersInit = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', + ...oauthHeaders, }; /** Handle authentication based on configured methods */ @@ -684,7 +722,9 @@ export class MCPOAuthHandler { } /** Auto-discover OAuth configuration for refresh */ - const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl); + const oauthMetadata = await discoverAuthorizationServerMetadata(metadata.serverUrl, { + fetchFn: this.createOAuthFetch(oauthHeaders), + }); if (!oauthMetadata?.token_endpoint) { throw new Error('No token endpoint found in OAuth metadata'); @@ -700,6 +740,7 @@ export class MCPOAuthHandler { const headers: HeadersInit = { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', + ...oauthHeaders, }; const response = await fetch(tokenUrl, { @@ -742,6 +783,7 @@ export class MCPOAuthHandler { revocationEndpoint?: string; revocationEndpointAuthMethodsSupported?: string[]; }, + oauthHeaders: Record = {}, ): Promise { // build the revoke URL, falling back to the server URL + /revoke if no revocation endpoint is provided const revokeUrl: URL = @@ -759,6 +801,7 @@ export class MCPOAuthHandler { // init the request headers const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded', + ...oauthHeaders, }; // init the request body diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 7d137afd0b..5cf003b9f5 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -10,9 +10,8 @@ import { } from 'librechat-data-provider'; import type { SearchResultData, UIResource, TPlugin, TUser } from 'librechat-data-provider'; import type * as t from '@modelcontextprotocol/sdk/types.js'; -import type { TokenMethods } from '@librechat/data-schemas'; +import type { TokenMethods, JsonSchemaType } from '@librechat/data-schemas'; import type { FlowStateManager } from '~/flow/manager'; -import type { JsonSchemaType } from '~/types/zod'; import type { RequestBody } from '~/types/http'; import type * as o from '~/mcp/oauth/types'; diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index 305765dfa6..ea9d17c0b2 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '~/types'; +import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '@librechat/data-schemas'; function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean { return ( diff --git a/packages/api/src/middleware/balance.ts b/packages/api/src/middleware/balance.ts index f51666a155..e3eb1e7ae1 100644 --- a/packages/api/src/middleware/balance.ts +++ b/packages/api/src/middleware/balance.ts @@ -1,8 +1,8 @@ import { logger } from '@librechat/data-schemas'; import type { NextFunction, Request as ServerRequest, Response as ServerResponse } from 'express'; -import type { IBalance, IUser, BalanceConfig, ObjectId } from '@librechat/data-schemas'; +import type { IBalance, IUser, BalanceConfig, ObjectId, AppConfig } from '@librechat/data-schemas'; import type { Model } from 'mongoose'; -import type { AppConfig, BalanceUpdateFields } from '~/types'; +import type { BalanceUpdateFields } from '~/types'; import { getBalanceConfig } from '~/app/config'; export interface BalanceMiddlewareOptions { diff --git a/packages/api/src/types/files.ts b/packages/api/src/types/files.ts index 4bfcc23e46..5b25f0b3e9 100644 --- a/packages/api/src/types/files.ts +++ b/packages/api/src/types/files.ts @@ -1,4 +1,7 @@ +import type { IMongoFile } from '@librechat/data-schemas'; import type { ServerRequest } from './http'; +import type { Readable } from 'stream'; +import type { Request } from 'express'; export interface STTService { getInstance(): Promise; getProviderSchema(req: ServerRequest): Promise<[string, object]>; @@ -26,3 +29,107 @@ export interface AudioProcessingResult { text: string; bytes: number; } + +export interface VideoResult { + videos: Array<{ + type: string; + mimeType: string; + data: string; + }>; + files: Array<{ + file_id?: string; + temp_file_id?: string; + filepath: string; + source?: string; + filename: string; + type: string; + }>; +} + +/** Anthropic document block format */ +export interface AnthropicDocumentBlock { + type: 'document'; + source: { + type: string; + media_type: string; + data: string; + }; + context?: string; + title?: string; + cache_control?: { type: string }; + citations?: { enabled: boolean }; +} + +/** Google document block format */ +export interface GoogleDocumentBlock { + type: 'document'; + mimeType: string; + data: string; +} + +/** OpenAI file block format */ +export interface OpenAIFileBlock { + type: 'file'; + file: { + filename: string; + file_data: string; + }; +} + +/** OpenAI Responses API file format */ +export interface OpenAIInputFileBlock { + type: 'input_file'; + filename: string; + file_data: string; +} + +export type DocumentBlock = + | AnthropicDocumentBlock + | GoogleDocumentBlock + | OpenAIFileBlock + | OpenAIInputFileBlock; + +export interface DocumentResult { + documents: DocumentBlock[]; + files: Array<{ + file_id?: string; + temp_file_id?: string; + filepath: string; + source?: string; + filename: string; + type: string; + }>; +} + +export interface AudioResult { + audios: Array<{ + type: string; + mimeType: string; + data: string; + }>; + files: Array<{ + file_id?: string; + temp_file_id?: string; + filepath: string; + source?: string; + filename: string; + type: string; + }>; +} + +export interface ProcessedFile { + file: IMongoFile; + content: string; + metadata: { + file_id: string; + temp_file_id?: string; + filepath: string; + source?: string; + filename: string; + type: string; + }; +} + +export interface StrategyFunctions { + getDownloadStream: (req: Request, filepath: string) => Promise; +} diff --git a/packages/api/src/types/http.ts b/packages/api/src/types/http.ts index 585797fe9c..f57e4674f9 100644 --- a/packages/api/src/types/http.ts +++ b/packages/api/src/types/http.ts @@ -1,6 +1,5 @@ import type { Request } from 'express'; -import type { IUser } from '@librechat/data-schemas'; -import type { AppConfig } from './config'; +import type { IUser, AppConfig } from '@librechat/data-schemas'; /** * LibreChat-specific request body type that extends Express Request body diff --git a/packages/api/src/types/index.ts b/packages/api/src/types/index.ts index 5603c09a56..92a46bb064 100644 --- a/packages/api/src/types/index.ts +++ b/packages/api/src/types/index.ts @@ -1,4 +1,3 @@ -export * from './config'; export * from './azure'; export * from './balance'; export * from './endpoints'; @@ -11,6 +10,4 @@ export * from './mistral'; export * from './openai'; export * from './prompts'; export * from './run'; -export * from './tools'; -export * from './zod'; export * from './anthropic'; diff --git a/packages/api/src/types/openai.ts b/packages/api/src/types/openai.ts index 3389314937..d0bbffe194 100644 --- a/packages/api/src/types/openai.ts +++ b/packages/api/src/types/openai.ts @@ -3,8 +3,8 @@ import { openAISchema, EModelEndpoint } from 'librechat-data-provider'; import type { TEndpointOption, TAzureConfig, TEndpoint, TConfig } from 'librechat-data-provider'; import type { BindToolsInput } from '@langchain/core/language_models/chat_models'; import type { OpenAIClientOptions, Providers } from '@librechat/agents'; +import type { AppConfig } from '@librechat/data-schemas'; import type { AzureOptions } from './azure'; -import type { AppConfig } from './config'; export type OpenAIParameters = z.infer; diff --git a/packages/api/src/types/tools.ts b/packages/api/src/types/tools.ts deleted file mode 100644 index 591c10da8a..0000000000 --- a/packages/api/src/types/tools.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { JsonSchemaType } from './zod'; - -export interface FunctionTool { - type: 'function'; - function: { - description: string; - name: string; - parameters: JsonSchemaType; - }; -} diff --git a/packages/api/src/types/zod.ts b/packages/api/src/types/zod.ts deleted file mode 100644 index 75d1a0b4e6..0000000000 --- a/packages/api/src/types/zod.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type JsonSchemaType = { - type: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object'; - enum?: string[]; - items?: JsonSchemaType; - properties?: Record; - required?: string[]; - description?: string; - additionalProperties?: boolean | JsonSchemaType; -}; - -export type ConvertJsonSchemaToZodOptions = { - allowEmptyObject?: boolean; - dropFields?: string[]; - transformOneOfAnyOf?: boolean; -}; diff --git a/packages/api/src/utils/math.ts b/packages/api/src/utils/math.ts index 08ae04f7eb..7201880ce3 100644 --- a/packages/api/src/utils/math.ts +++ b/packages/api/src/utils/math.ts @@ -12,8 +12,8 @@ * * @throws Throws an error if the input is not a string or number, contains invalid characters, or does not evaluate to a number. */ -export function math(str: string | number, fallbackValue?: number): number { - const fallback = typeof fallbackValue !== 'undefined' && typeof fallbackValue === 'number'; +export function math(str: string | number | undefined, fallbackValue?: number): number { + const fallback = fallbackValue != null; if (typeof str !== 'string' && typeof str === 'number') { return str; } else if (typeof str !== 'string') { diff --git a/packages/api/src/utils/tempChatRetention.spec.ts b/packages/api/src/utils/tempChatRetention.spec.ts index 8e924183c5..ef029cdde5 100644 --- a/packages/api/src/utils/tempChatRetention.spec.ts +++ b/packages/api/src/utils/tempChatRetention.spec.ts @@ -1,4 +1,4 @@ -import type { AppConfig } from '~/types'; +import type { AppConfig } from '@librechat/data-schemas'; import { createTempChatExpirationDate, getTempChatRetentionHours, @@ -92,14 +92,16 @@ describe('tempChatRetention', () => { describe('createTempChatExpirationDate', () => { it('should create expiration date with default retention period', () => { + const beforeCall = Date.now(); const result = createTempChatExpirationDate(); + const afterCall = Date.now(); - const expectedDate = new Date(); - expectedDate.setHours(expectedDate.getHours() + DEFAULT_RETENTION_HOURS); + const expectedMin = beforeCall + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000; + const expectedMax = afterCall + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000; - // Allow for small time differences in test execution - const timeDiff = Math.abs(result.getTime() - expectedDate.getTime()); - expect(timeDiff).toBeLessThan(1000); // Less than 1 second difference + // Result should be between expectedMin and expectedMax + expect(result.getTime()).toBeGreaterThanOrEqual(expectedMin); + expect(result.getTime()).toBeLessThanOrEqual(expectedMax); }); it('should create expiration date with custom retention period', () => { @@ -109,14 +111,16 @@ describe('tempChatRetention', () => { }, }; + const beforeCall = Date.now(); const result = createTempChatExpirationDate(config?.interfaceConfig); + const afterCall = Date.now(); - const expectedDate = new Date(); - expectedDate.setHours(expectedDate.getHours() + 12); + const expectedMin = beforeCall + 12 * 60 * 60 * 1000; + const expectedMax = afterCall + 12 * 60 * 60 * 1000; - // Allow for small time differences in test execution - const timeDiff = Math.abs(result.getTime() - expectedDate.getTime()); - expect(timeDiff).toBeLessThan(1000); // Less than 1 second difference + // Result should be between expectedMin and expectedMax + expect(result.getTime()).toBeGreaterThanOrEqual(expectedMin); + expect(result.getTime()).toBeLessThanOrEqual(expectedMax); }); it('should return a Date object', () => { diff --git a/packages/api/src/utils/tempChatRetention.ts b/packages/api/src/utils/tempChatRetention.ts index 6b78681891..eaa6ad2029 100644 --- a/packages/api/src/utils/tempChatRetention.ts +++ b/packages/api/src/utils/tempChatRetention.ts @@ -1,5 +1,5 @@ import { logger } from '@librechat/data-schemas'; -import type { AppConfig } from '~/types'; +import type { AppConfig } from '@librechat/data-schemas'; /** * Default retention period for temporary chats in hours @@ -73,7 +73,5 @@ export function getTempChatRetentionHours( */ export function createTempChatExpirationDate(interfaceConfig?: AppConfig['interfaceConfig']): Date { const retentionHours = getTempChatRetentionHours(interfaceConfig); - const expiredAt = new Date(); - expiredAt.setHours(expiredAt.getHours() + retentionHours); - return expiredAt; + return new Date(Date.now() + retentionHours * 60 * 60 * 1000); } diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index 48958c21e0..32921ca851 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -40,10 +40,10 @@ const openAIModels = { 'gpt-5': 400000, 'gpt-5-mini': 400000, 'gpt-5-nano': 400000, + 'gpt-5-pro': 400000, 'gpt-4o': 127500, // -500 from max 'gpt-4o-mini': 127500, // -500 from max 'gpt-4o-2024-05-13': 127500, // -500 from max - 'gpt-4o-2024-08-06': 127500, // -500 from max 'gpt-4-turbo': 127500, // -500 from max 'gpt-4-vision': 127500, // -500 from max 'gpt-3.5-turbo': 16375, // -10 from max @@ -60,9 +60,11 @@ const mistralModels = { 'mistral-7b': 31990, // -10 from max 'mistral-small': 31990, // -10 from max 'mixtral-8x7b': 31990, // -10 from max + 'mixtral-8x22b': 65536, 'mistral-large': 131000, 'mistral-large-2402': 127500, 'mistral-large-2407': 127500, + 'mistral-nemo': 131000, 'pixtral-large': 131000, 'mistral-saba': 32000, codestral: 256000, @@ -75,6 +77,7 @@ const cohereModels = { 'command-light-nightly': 8182, // -10 from max command: 4086, // -10 from max 'command-nightly': 8182, // -10 from max + 'command-text': 4086, // -10 from max 'command-r': 127500, // -500 from max 'command-r-plus': 127500, // -500 from max }; @@ -91,6 +94,7 @@ const googleModels = { 'gemini-2.5': 1000000, // 1M input tokens, 64k output tokens 'gemini-2.5-pro': 1000000, 'gemini-2.5-flash': 1000000, + 'gemini-2.5-flash-lite': 1000000, 'gemini-2.0': 2000000, 'gemini-2.0-flash': 1000000, 'gemini-2.0-flash-lite': 1000000, @@ -126,15 +130,18 @@ const anthropicModels = { 'claude-3.7-sonnet': 200000, 'claude-3-5-sonnet-latest': 200000, 'claude-3.5-sonnet-latest': 200000, + 'claude-haiku-4-5': 200000, 'claude-sonnet-4': 1000000, 'claude-opus-4': 200000, 'claude-4': 200000, }; const deepseekModels = { - 'deepseek-reasoner': 63000, // -1000 from max (API) - deepseek: 63000, // -1000 from max (API) - 'deepseek.r1': 127500, + deepseek: 128000, + 'deepseek-reasoner': 128000, + 'deepseek-r1': 128000, + 'deepseek-v3': 128000, + 'deepseek.r1': 128000, }; const metaModels = { @@ -199,32 +206,57 @@ const metaModels = { 'llama2:70b': 4000, }; -const ollamaModels = { +const qwenModels = { + qwen: 32000, 'qwen2.5': 32000, + 'qwen-turbo': 1000000, + 'qwen-plus': 131000, + 'qwen-max': 32000, + 'qwq-32b': 32000, + // Qwen3 models + qwen3: 40960, // Qwen3 base pattern (using qwen3-4b context) + 'qwen3-8b': 128000, + 'qwen3-14b': 40960, + 'qwen3-30b-a3b': 40960, + 'qwen3-32b': 40960, + 'qwen3-235b-a22b': 40960, + // Qwen3 VL (Vision-Language) models + 'qwen3-vl-8b-thinking': 256000, + 'qwen3-vl-8b-instruct': 262144, + 'qwen3-vl-30b-a3b': 262144, + 'qwen3-vl-235b-a22b': 131072, + // Qwen3 specialized models + 'qwen3-max': 256000, + 'qwen3-coder': 262144, + 'qwen3-coder-30b-a3b': 262144, + 'qwen3-coder-plus': 128000, + 'qwen3-coder-flash': 128000, + 'qwen3-next-80b-a3b': 262144, }; const ai21Models = { - 'ai21.j2-mid-v1': 8182, // -10 from max - 'ai21.j2-ultra-v1': 8182, // -10 from max - 'ai21.jamba-instruct-v1:0': 255500, // -500 from max + 'j2-mid': 8182, // -10 from max + 'j2-ultra': 8182, // -10 from max + 'jamba-instruct': 255500, // -500 from max }; const amazonModels = { - 'amazon.titan-text-lite-v1': 4000, - 'amazon.titan-text-express-v1': 8000, - 'amazon.titan-text-premier-v1:0': 31500, // -500 from max + // Amazon Titan models + 'titan-text-lite': 4000, + 'titan-text-express': 8000, + 'titan-text-premier': 31500, // -500 from max + // Amazon Nova models // https://aws.amazon.com/ai/generative-ai/nova/ - 'amazon.nova-micro-v1:0': 127000, // -1000 from max, - 'amazon.nova-lite-v1:0': 295000, // -5000 from max, - 'amazon.nova-pro-v1:0': 295000, // -5000 from max, - 'amazon.nova-premier-v1:0': 995000, // -5000 from max, + 'nova-micro': 127000, // -1000 from max + 'nova-lite': 295000, // -5000 from max + 'nova-pro': 295000, // -5000 from max + 'nova-premier': 995000, // -5000 from max }; const bedrockModels = { ...anthropicModels, ...mistralModels, ...cohereModels, - ...ollamaModels, ...deepseekModels, ...metaModels, ...ai21Models, @@ -253,11 +285,23 @@ const aggregateModels = { ...googleModels, ...bedrockModels, ...xAIModels, + ...qwenModels, // misc. kimi: 131000, // GPT-OSS + 'gpt-oss': 131000, + 'gpt-oss:20b': 131000, 'gpt-oss-20b': 131000, + 'gpt-oss:120b': 131000, 'gpt-oss-120b': 131000, + // GLM models (Zhipu AI) + glm4: 128000, + 'glm-4': 128000, + 'glm-4-32b': 128000, + 'glm-4.5': 131000, + 'glm-4.5-air': 131000, + 'glm-4.5v': 66000, + 'glm-4.6': 200000, }; export const maxTokensMap = { @@ -277,6 +321,7 @@ export const modelMaxOutputs = { 'gpt-5': 128000, 'gpt-5-mini': 128000, 'gpt-5-nano': 128000, + 'gpt-5-pro': 128000, 'gpt-oss-20b': 131000, 'gpt-oss-120b': 131000, system_default: 32000, @@ -287,6 +332,7 @@ const anthropicMaxOutputs = { 'claude-3-haiku': 4096, 'claude-3-sonnet': 4096, 'claude-3-opus': 4096, + 'claude-haiku-4-5': 64000, 'claude-opus-4': 32000, 'claude-sonnet-4': 64000, 'claude-3.5-sonnet': 8192, @@ -313,9 +359,10 @@ export function findMatchingPattern( tokensMap: Record | EndpointTokenConfig, ): string | null { const keys = Object.keys(tokensMap); + const lowerModelName = modelName.toLowerCase(); for (let i = keys.length - 1; i >= 0; i--) { const modelKey = keys[i]; - if (modelName.includes(modelKey)) { + if (lowerModelName.includes(modelKey)) { return modelKey; } } diff --git a/packages/api/src/web/web.spec.ts b/packages/api/src/web/web.spec.ts index b91b534908..c7bb3f4962 100644 --- a/packages/api/src/web/web.spec.ts +++ b/packages/api/src/web/web.spec.ts @@ -1,12 +1,13 @@ +import { webSearchAuth } from '@librechat/data-schemas'; +import { SafeSearchTypes, AuthType } from 'librechat-data-provider'; import type { - ScraperTypes, + ScraperProviders, + TWebSearchConfig, + SearchProviders, TCustomConfig, RerankerTypes, - SearchProviders, - TWebSearchConfig, } from 'librechat-data-provider'; -import { webSearchAuth, loadWebSearchAuth, extractWebSearchEnvVars } from './web'; -import { SafeSearchTypes, AuthType } from 'librechat-data-provider'; +import { loadWebSearchAuth, extractWebSearchEnvVars } from './web'; // Mock the extractVariableName function jest.mock('../utils', () => ({ @@ -118,7 +119,7 @@ describe('web.ts', () => { } expect(result.authResult).toHaveProperty('searchProvider', 'serper'); - expect(result.authResult).toHaveProperty('scraperType', 'firecrawl'); + expect(result.authResult).toHaveProperty('scraperProvider', 'firecrawl'); expect(['jina', 'cohere']).toContain(result.authResult.rerankerType as string); }); @@ -287,7 +288,7 @@ describe('web.ts', () => { // Check that the correct service types are set expect(result.authResult.searchProvider).toBe('serper' as SearchProviders); - expect(result.authResult.scraperType).toBe('firecrawl' as ScraperTypes); + expect(result.authResult.scraperProvider).toBe('firecrawl' as ScraperProviders); // One of the rerankers should be set expect(['jina', 'cohere']).toContain(result.authResult.rerankerType as string); }); @@ -329,7 +330,7 @@ describe('web.ts', () => { // Should have set values for all categories expect(result.authResult.searchProvider).toBeDefined(); - expect(result.authResult.scraperType).toBeDefined(); + expect(result.authResult.scraperProvider).toBeDefined(); expect(result.authResult.rerankerType).toBeDefined(); }); @@ -358,7 +359,7 @@ describe('web.ts', () => { safeSearch: SafeSearchTypes.MODERATE, // Specify which services to use searchProvider: 'serper' as SearchProviders, - scraperType: 'firecrawl' as ScraperTypes, + scraperProvider: 'firecrawl' as ScraperProviders, rerankerType: 'jina' as RerankerTypes, }; @@ -393,7 +394,7 @@ describe('web.ts', () => { expect(result.authResult).toHaveProperty('firecrawlApiUrl'); expect(result.authResult).toHaveProperty('jinaApiKey'); expect(result.authResult).toHaveProperty('searchProvider'); - expect(result.authResult).toHaveProperty('scraperType'); + expect(result.authResult).toHaveProperty('scraperProvider'); expect(result.authResult).toHaveProperty('rerankerType'); expect(result.authenticated).toBe(true); @@ -418,7 +419,7 @@ describe('web.ts', () => { expect(result.authResult).toHaveProperty('firecrawlApiUrl', 'https://api.firecrawl.dev'); expect(result.authResult).toHaveProperty('jinaApiKey', 'system-jina-key'); expect(result.authResult).toHaveProperty('searchProvider', 'serper'); - expect(result.authResult).toHaveProperty('scraperType', 'firecrawl'); + expect(result.authResult).toHaveProperty('scraperProvider', 'firecrawl'); expect(result.authResult).toHaveProperty('rerankerType', 'jina'); // Restore original env @@ -451,7 +452,7 @@ describe('web.ts', () => { safeSearch: SafeSearchTypes.MODERATE, // Specify which services to use searchProvider: 'serper' as SearchProviders, - scraperType: 'firecrawl' as ScraperTypes, + scraperProvider: 'firecrawl' as ScraperProviders, rerankerType: 'jina' as RerankerTypes, // Only Jina will be checked }; @@ -491,7 +492,7 @@ describe('web.ts', () => { // Verify the service types are set correctly expect(result.authResult).toHaveProperty('searchProvider', 'serper'); - expect(result.authResult).toHaveProperty('scraperType', 'firecrawl'); + expect(result.authResult).toHaveProperty('scraperProvider', 'firecrawl'); expect(result.authResult).toHaveProperty('rerankerType', 'jina'); // Restore original env @@ -721,8 +722,8 @@ describe('web.ts', () => { expect(providerCalls.length).toBe(1); }); - it('should only check the specified scraperType', async () => { - // Initialize a webSearchConfig with a specific scraperType + it('should only check the specified scraperProvider', async () => { + // Initialize a webSearchConfig with a specific scraperProvider const webSearchConfig: TCustomConfig['webSearch'] = { serperApiKey: '${SERPER_API_KEY}', searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}', @@ -733,7 +734,7 @@ describe('web.ts', () => { jinaApiUrl: '${JINA_API_URL}', cohereApiKey: '${COHERE_API_KEY}', safeSearch: SafeSearchTypes.MODERATE, - scraperType: 'firecrawl' as ScraperTypes, + scraperProvider: 'firecrawl' as ScraperProviders, }; // Mock successful authentication @@ -753,7 +754,7 @@ describe('web.ts', () => { }); expect(result.authenticated).toBe(true); - expect(result.authResult.scraperType).toBe('firecrawl'); + expect(result.authResult.scraperProvider).toBe('firecrawl'); // Verify that only FIRECRAWL_API_KEY and FIRECRAWL_API_URL were requested for the scrapers category const scraperCalls = mockLoadAuthValues.mock.calls.filter((call) => @@ -932,7 +933,7 @@ describe('web.ts', () => { // Should have set values for all categories expect(result.authResult.searchProvider).toBeDefined(); - expect(result.authResult.scraperType).toBeDefined(); + expect(result.authResult.scraperProvider).toBeDefined(); expect(result.authResult.rerankerType).toBeDefined(); }); diff --git a/packages/api/src/web/web.ts b/packages/api/src/web/web.ts index 681a42e34b..ad172e187f 100644 --- a/packages/api/src/web/web.ts +++ b/packages/api/src/web/web.ts @@ -1,112 +1,18 @@ +import { + AuthType, + SafeSearchTypes, + SearchCategories, + extractVariableName, +} from 'librechat-data-provider'; +import { webSearchAuth } from '@librechat/data-schemas'; import type { - ScraperTypes, RerankerTypes, TCustomConfig, SearchProviders, + ScraperProviders, TWebSearchConfig, } from 'librechat-data-provider'; -import { - SearchCategories, - SafeSearchTypes, - extractVariableName, - AuthType, -} from 'librechat-data-provider'; - -export function loadWebSearchConfig( - config: TCustomConfig['webSearch'], -): TCustomConfig['webSearch'] { - const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}'; - const searxngInstanceUrl = config?.searxngInstanceUrl ?? '${SEARXNG_INSTANCE_URL}'; - const searxngApiKey = config?.searxngApiKey ?? '${SEARXNG_API_KEY}'; - const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}'; - const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}'; - const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}'; - const jinaApiUrl = config?.jinaApiUrl ?? '${JINA_API_URL}'; - const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}'; - const safeSearch = config?.safeSearch ?? SafeSearchTypes.MODERATE; - - return { - ...config, - safeSearch, - jinaApiKey, - jinaApiUrl, - cohereApiKey, - serperApiKey, - searxngInstanceUrl, - searxngApiKey, - firecrawlApiKey, - firecrawlApiUrl, - }; -} - -export type TWebSearchKeys = - | 'serperApiKey' - | 'searxngInstanceUrl' - | 'searxngApiKey' - | 'firecrawlApiKey' - | 'firecrawlApiUrl' - | 'jinaApiKey' - | 'jinaApiUrl' - | 'cohereApiKey'; - -export type TWebSearchCategories = - | SearchCategories.PROVIDERS - | SearchCategories.SCRAPERS - | SearchCategories.RERANKERS; - -export const webSearchAuth = { - providers: { - serper: { - serperApiKey: 1 as const, - }, - searxng: { - searxngInstanceUrl: 1 as const, - /** Optional (0) */ - searxngApiKey: 0 as const, - }, - }, - scrapers: { - firecrawl: { - firecrawlApiKey: 1 as const, - /** Optional (0) */ - firecrawlApiUrl: 0 as const, - }, - }, - rerankers: { - jina: { - jinaApiKey: 1 as const, - /** Optional (0) */ - jinaApiUrl: 0 as const, - }, - cohere: { cohereApiKey: 1 as const }, - }, -}; - -/** - * Extracts all API keys from the webSearchAuth configuration object - */ -export function getWebSearchKeys(): TWebSearchKeys[] { - const keys: TWebSearchKeys[] = []; - - // Iterate through each category (providers, scrapers, rerankers) - for (const category of Object.keys(webSearchAuth)) { - const categoryObj = webSearchAuth[category as TWebSearchCategories]; - - // Iterate through each service within the category - for (const service of Object.keys(categoryObj)) { - const serviceObj = categoryObj[service as keyof typeof categoryObj]; - - // Extract the API keys from the service - for (const key of Object.keys(serviceObj)) { - keys.push(key as TWebSearchKeys); - } - } - } - - return keys; -} - -export const webSearchKeys: TWebSearchKeys[] = getWebSearchKeys(); +import type { TWebSearchKeys, TWebSearchCategories } from '@librechat/data-schemas'; export function extractWebSearchEnvVars({ keys, @@ -182,8 +88,8 @@ export async function loadWebSearchAuth({ let specificService: ServiceType | undefined; if (category === SearchCategories.PROVIDERS && webSearchConfig?.searchProvider) { specificService = webSearchConfig.searchProvider as unknown as ServiceType; - } else if (category === SearchCategories.SCRAPERS && webSearchConfig?.scraperType) { - specificService = webSearchConfig.scraperType as unknown as ServiceType; + } else if (category === SearchCategories.SCRAPERS && webSearchConfig?.scraperProvider) { + specificService = webSearchConfig.scraperProvider as unknown as ServiceType; } else if (category === SearchCategories.RERANKERS && webSearchConfig?.rerankerType) { specificService = webSearchConfig.rerankerType as unknown as ServiceType; } @@ -259,7 +165,7 @@ export async function loadWebSearchAuth({ if (category === SearchCategories.PROVIDERS) { authResult.searchProvider = service as SearchProviders; } else if (category === SearchCategories.SCRAPERS) { - authResult.scraperType = service as ScraperTypes; + authResult.scraperProvider = service as ScraperProviders; } else if (category === SearchCategories.RERANKERS) { authResult.rerankerType = service as RerankerTypes; } diff --git a/packages/client/package.json b/packages/client/package.json index 6d1a83cdfc..71aac0c294 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/client", - "version": "0.3.0", + "version": "0.3.1", "description": "React components for LibreChat", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/client/src/components/Dropdown.tsx b/packages/client/src/components/Dropdown.tsx index 5a4d3f2b20..536bbc5829 100644 --- a/packages/client/src/components/Dropdown.tsx +++ b/packages/client/src/components/Dropdown.tsx @@ -16,6 +16,7 @@ interface DropdownProps { iconOnly?: boolean; renderValue?: (option: Option) => React.ReactNode; ariaLabel?: string; + 'aria-labelledby'?: string; portal?: boolean; } @@ -37,6 +38,7 @@ const Dropdown: React.FC = ({ iconOnly = false, renderValue, ariaLabel, + 'aria-labelledby': ariaLabelledBy, portal = true, }) => { const handleChange = (value: string) => { @@ -77,6 +79,7 @@ const Dropdown: React.FC = ({ )} data-testid={testId} aria-label={ariaLabel} + aria-labelledby={ariaLabelledBy} >
{icon} diff --git a/packages/client/src/components/DropdownMenu.tsx b/packages/client/src/components/DropdownMenu.tsx index b317806826..4c050a2713 100644 --- a/packages/client/src/components/DropdownMenu.tsx +++ b/packages/client/src/components/DropdownMenu.tsx @@ -1,191 +1,225 @@ import * as React from 'react'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { Check, ChevronRight, Circle } from 'lucide-react'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; import { cn } from '~/utils'; -const DropdownMenu = DropdownMenuPrimitive.Root; +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ; +} -const DropdownMenuGroup = DropdownMenuPrimitive.Group; +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} -const DropdownMenuSub = DropdownMenuPrimitive.Sub; +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className = '', inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', sideOffset = 4, ...props }, ref) => ( - - & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className = '', inset, ...props }, ref) => ( - -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', children, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className = '', inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className = '', ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ - className = '', - ...props -}: React.HTMLAttributes) => { - return ( - ); -}; -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} export { DropdownMenu, + DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, DropdownMenuRadioItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, DropdownMenuSub, - DropdownMenuSubContent, DropdownMenuSubTrigger, - DropdownMenuRadioGroup, + DropdownMenuSubContent, }; diff --git a/packages/client/src/components/InfoHoverCard.tsx b/packages/client/src/components/InfoHoverCard.tsx index ab43b6dd18..5b45666807 100644 --- a/packages/client/src/components/InfoHoverCard.tsx +++ b/packages/client/src/components/InfoHoverCard.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { CircleHelpIcon } from 'lucide-react'; import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from './HoverCard'; import { ESide } from '~/common'; @@ -8,15 +9,23 @@ type InfoHoverCardProps = { }; const InfoHoverCard = ({ side, text }: InfoHoverCardProps) => { + const [isOpen, setIsOpen] = useState(false); + return ( - - - {' '} + + setIsOpen(true)} + onBlur={() => setIsOpen(false)} + aria-label={text} + > +
-

{text}

+ {text}
diff --git a/packages/client/src/components/Label.tsx b/packages/client/src/components/Label.tsx index 54f75fb2a8..d250e47e3f 100644 --- a/packages/client/src/components/Label.tsx +++ b/packages/client/src/components/Label.tsx @@ -13,7 +13,7 @@ const Label = React.forwardRef< {...props} {...{ className: cn( - 'block w-full break-all text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200', + 'block w-full break-all text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-200', className, ), }} diff --git a/packages/client/src/components/Slider.tsx b/packages/client/src/components/Slider.tsx index 4be0f20039..3845b8901f 100644 --- a/packages/client/src/components/Slider.tsx +++ b/packages/client/src/components/Slider.tsx @@ -2,37 +2,56 @@ import * as React from 'react'; import * as SliderPrimitive from '@radix-ui/react-slider'; import { cn } from '~/utils'; -const Slider = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - className?: string; - onDoubleClick?: () => void; - } ->(({ className, onDoubleClick, ...props }, ref) => ( - & { + className?: string; + onDoubleClick?: () => void; + 'aria-describedby'?: string; +} & ( + | { 'aria-label': string; 'aria-labelledby'?: never } + | { 'aria-labelledby': string; 'aria-label'?: never } + | { 'aria-label': string; 'aria-labelledby': string } + ); + +const Slider = React.forwardRef, SliderProps>( + ( + { + className, onDoubleClick, - }} - > - - - - ( + - -)); + > + + + + + + ), +); Slider.displayName = SliderPrimitive.Root.displayName; export { Slider }; diff --git a/packages/client/src/svgs/AudioPaths.tsx b/packages/client/src/svgs/AudioPaths.tsx new file mode 100644 index 0000000000..874f54328d --- /dev/null +++ b/packages/client/src/svgs/AudioPaths.tsx @@ -0,0 +1,41 @@ +export default function AudioPaths() { + return ( + <> + + + + + + + ); +} diff --git a/packages/client/src/svgs/VideoPaths.tsx b/packages/client/src/svgs/VideoPaths.tsx new file mode 100644 index 0000000000..6876824e42 --- /dev/null +++ b/packages/client/src/svgs/VideoPaths.tsx @@ -0,0 +1,10 @@ +export default function VideoPaths() { + return ( + <> + {/* Video container - rounded rectangle (not filled) */} + + {/* Play button - centered and pointing right */} + + + ); +} diff --git a/packages/client/src/svgs/index.ts b/packages/client/src/svgs/index.ts index 13a5a1cc0a..d3f8c6e45b 100644 --- a/packages/client/src/svgs/index.ts +++ b/packages/client/src/svgs/index.ts @@ -65,9 +65,11 @@ export { default as PersonalizationIcon } from './PersonalizationIcon'; export { default as MCPIcon } from './MCPIcon'; export { default as VectorIcon } from './VectorIcon'; export { default as SquirclePlusIcon } from './SquirclePlusIcon'; +export { default as AudioPaths } from './AudioPaths'; export { default as CodePaths } from './CodePaths'; export { default as FileIcon } from './FileIcon'; export { default as FilePaths } from './FilePaths'; export { default as SheetPaths } from './SheetPaths'; export { default as TextPaths } from './TextPaths'; +export { default as VideoPaths } from './VideoPaths'; export { default as SharePointIcon } from './SharePointIcon'; diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 027e014834..90170ade4d 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.8.010", + "version": "0.8.020", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/src/azure.ts b/packages/data-provider/src/azure.ts index 17188ec551..99d7032177 100644 --- a/packages/data-provider/src/azure.ts +++ b/packages/data-provider/src/azure.ts @@ -10,44 +10,6 @@ import { extractEnvVariable, envVarRegex } from '../src/utils'; import { azureGroupConfigsSchema } from '../src/config'; import { errorsToString } from '../src/parsers'; -export const deprecatedAzureVariables = [ - /* "related to" precedes description text */ - { key: 'AZURE_OPENAI_DEFAULT_MODEL', description: 'setting a default model' }, - { key: 'AZURE_OPENAI_MODELS', description: 'setting models' }, - { - key: 'AZURE_USE_MODEL_AS_DEPLOYMENT_NAME', - description: 'using model names as deployment names', - }, - { key: 'AZURE_API_KEY', description: 'setting a single Azure API key' }, - { key: 'AZURE_OPENAI_API_INSTANCE_NAME', description: 'setting a single Azure instance name' }, - { - key: 'AZURE_OPENAI_API_DEPLOYMENT_NAME', - description: 'setting a single Azure deployment name', - }, - { key: 'AZURE_OPENAI_API_VERSION', description: 'setting a single Azure API version' }, - { - key: 'AZURE_OPENAI_API_COMPLETIONS_DEPLOYMENT_NAME', - description: 'setting a single Azure completions deployment name', - }, - { - key: 'AZURE_OPENAI_API_EMBEDDINGS_DEPLOYMENT_NAME', - description: 'setting a single Azure embeddings deployment name', - }, - { - key: 'PLUGINS_USE_AZURE', - description: 'using Azure for Plugins', - }, -]; - -export const conflictingAzureVariables = [ - { - key: 'INSTANCE_NAME', - }, - { - key: 'DEPLOYMENT_NAME', - }, -]; - export function validateAzureGroups(configs: TAzureGroups): TAzureConfigValidationResult { let isValid = true; const modelNames: string[] = []; @@ -239,13 +201,13 @@ export function mapModelToAzureConfig({ const { deploymentName = '', version = '' } = typeof modelDetails === 'object' ? { - deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, - version: modelDetails.version ?? groupConfig.version, - } + deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, + version: modelDetails.version ?? groupConfig.version, + } : { - deploymentName: groupConfig.deploymentName, - version: groupConfig.version, - }; + deploymentName: groupConfig.deploymentName, + version: groupConfig.version, + }; if (!deploymentName || !version) { throw new Error( @@ -335,13 +297,13 @@ export function mapGroupToAzureConfig({ const { deploymentName = '', version = '' } = typeof modelDetails === 'object' ? { - deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, - version: modelDetails.version ?? groupConfig.version, - } + deploymentName: modelDetails.deploymentName ?? groupConfig.deploymentName, + version: modelDetails.version ?? groupConfig.version, + } : { - deploymentName: groupConfig.deploymentName, - version: groupConfig.version, - }; + deploymentName: groupConfig.deploymentName, + version: groupConfig.version, + }; if (!deploymentName || !version) { throw new Error( diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index a79846b3de..c3f872eaec 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -155,8 +155,10 @@ export type TAzureGroupMap = Record< export type TValidatedAzureConfig = { modelNames: string[]; - modelGroupMap: TAzureModelGroupMap; groupMap: TAzureGroupMap; + assistantModels?: string[]; + assistantGroups?: string[]; + modelGroupMap: TAzureModelGroupMap; }; export type TAzureConfigValidationResult = TValidatedAzureConfig & { @@ -212,6 +214,14 @@ export const bedrockEndpointSchema = baseEndpointSchema.merge( }), ); +const modelItemSchema = z.union([ + z.string(), + z.object({ + name: z.string(), + description: z.string().optional(), + }), +]); + export const assistantEndpointSchema = baseEndpointSchema.merge( z.object({ /* assistants specific */ @@ -237,7 +247,7 @@ export const assistantEndpointSchema = baseEndpointSchema.merge( apiKey: z.string().optional(), models: z .object({ - default: z.array(z.string()).min(1), + default: z.array(modelItemSchema).min(1), fetch: z.boolean().optional(), userIdQuery: z.boolean().optional(), }) @@ -297,7 +307,7 @@ export const endpointSchema = baseEndpointSchema.merge( apiKey: z.string(), baseURL: z.string(), models: z.object({ - default: z.array(z.string()).min(1), + default: z.array(modelItemSchema).min(1), fetch: z.boolean().optional(), userIdQuery: z.boolean().optional(), }), @@ -634,6 +644,7 @@ export type TStartupConfig = { helpAndFaqURL: string; customFooter?: string; modelSpecs?: TSpecsConfig; + modelDescriptions?: Record>; sharedLinksEnabled: boolean; publicSharedLinksEnabled: boolean; analyticsGtmId?: string; @@ -648,7 +659,7 @@ export type TStartupConfig = { minPasswordLength?: number; webSearch?: { searchProvider?: SearchProviders; - scraperType?: ScraperTypes; + scraperProvider?: ScraperProviders; rerankerType?: RerankerTypes; }; mcpServers?: Record< @@ -667,6 +678,7 @@ export type TStartupConfig = { } >; mcpPlaceholder?: string; + conversationImportMaxFileSize?: number; }; export enum OCRStrategy { @@ -687,7 +699,7 @@ export enum SearchProviders { SEARXNG = 'searxng', } -export enum ScraperTypes { +export enum ScraperProviders { FIRECRAWL = 'firecrawl', SERPER = 'serper', } @@ -709,11 +721,12 @@ export const webSearchSchema = z.object({ searxngApiKey: z.string().optional().default('${SEARXNG_API_KEY}'), firecrawlApiKey: z.string().optional().default('${FIRECRAWL_API_KEY}'), firecrawlApiUrl: z.string().optional().default('${FIRECRAWL_API_URL}'), + firecrawlVersion: z.string().optional().default('${FIRECRAWL_VERSION}'), jinaApiKey: z.string().optional().default('${JINA_API_KEY}'), jinaApiUrl: z.string().optional().default('${JINA_API_URL}'), cohereApiKey: z.string().optional().default('${COHERE_API_KEY}'), searchProvider: z.nativeEnum(SearchProviders).optional(), - scraperType: z.nativeEnum(ScraperTypes).optional(), + scraperProvider: z.nativeEnum(ScraperProviders).optional(), rerankerType: z.nativeEnum(RerankerTypes).optional(), scraperTimeout: z.number().optional(), safeSearch: z.nativeEnum(SafeSearchTypes).default(SafeSearchTypes.MODERATE), @@ -752,7 +765,7 @@ export const webSearchSchema = z.object({ .optional(), }); -export type TWebSearchConfig = z.infer; +export type TWebSearchConfig = DeepPartial>; export const ocrSchema = z.object({ mistralModel: z.string().optional(), @@ -799,7 +812,7 @@ export const memorySchema = z.object({ .optional(), }); -export type TMemoryConfig = z.infer; +export type TMemoryConfig = DeepPartial>; const customEndpointsSchema = z.array(endpointSchema.partial()).optional(); @@ -862,9 +875,27 @@ export const configSchema = z.object({ .optional(), }); -export const getConfigDefaults = () => getSchemaDefaults(configSchema); +/** + * Recursively makes all properties of T optional, including nested objects. + * Handles arrays, primitives, functions, and Date objects correctly. + */ +export type DeepPartial = T extends (infer U)[] + ? DeepPartial[] + : T extends ReadonlyArray + ? ReadonlyArray> + : // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + T extends Function + ? T + : T extends Date + ? T + : T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; -export type TCustomConfig = z.infer; +export const getConfigDefaults = () => getSchemaDefaults(configSchema); +export type TCustomConfig = DeepPartial>; export type TCustomEndpoints = z.infer; export type TProviderSchema = @@ -930,6 +961,13 @@ export const alternateName = { }; const sharedOpenAIModels = [ + 'gpt-5', + 'gpt-5-mini', + 'gpt-5-nano', + 'gpt-5-chat-latest', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', 'gpt-4o-mini', 'gpt-4o', 'gpt-4.5-preview', @@ -952,10 +990,14 @@ const sharedOpenAIModels = [ ]; const sharedAnthropicModels = [ + 'claude-sonnet-4-5', + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-1', + 'claude-opus-4-1-20250805', 'claude-sonnet-4-20250514', - 'claude-sonnet-4-latest', + 'claude-sonnet-4-0', 'claude-opus-4-20250514', - 'claude-opus-4-latest', + 'claude-opus-4-0', 'claude-3-7-sonnet-latest', 'claude-3-7-sonnet-20250219', 'claude-3-5-haiku-20241022', @@ -1013,18 +1055,13 @@ export const defaultModels = { [EModelEndpoint.assistants]: [...sharedOpenAIModels, 'chatgpt-4o-latest'], [EModelEndpoint.agents]: sharedOpenAIModels, // TODO: Add agent models (agentsModels) [EModelEndpoint.google]: [ + // Gemini 2.5 Models + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', // Gemini 2.0 Models 'gemini-2.0-flash-001', - 'gemini-2.0-flash-exp', 'gemini-2.0-flash-lite', - 'gemini-2.0-pro-exp-02-05', - // Gemini 1.5 Models - 'gemini-1.5-flash-001', - 'gemini-1.5-flash-002', - 'gemini-1.5-pro-001', - 'gemini-1.5-pro-002', - // Gemini 1.0 Models - 'gemini-1.0-pro-001', ], [EModelEndpoint.anthropic]: sharedAnthropicModels, [EModelEndpoint.openAI]: [ @@ -1107,6 +1144,7 @@ export const visionModels = [ 'gemini-exp', 'gemini-1.5', 'gemini-2', + 'gemini-2.5', 'gemini-3', 'moondream', 'llama3.2-vision', @@ -1530,9 +1568,9 @@ export enum TTSProviders { /** Enum for app-wide constants */ export enum Constants { /** Key for the app's version. */ - VERSION = 'v0.8.0-rc4', + VERSION = 'v0.8.0', /** Key for the Custom Config's version (librechat.yaml). */ - CONFIG_VERSION = '1.2.9', + CONFIG_VERSION = '1.3.0', /** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */ NO_PARENT = '00000000-0000-0000-0000-000000000000', /** Standard value to use whatever the submission prelim. `responseMessageId` is */ diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index c45ab4e0b4..c7d1a1c052 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -42,8 +42,11 @@ export function getSharedLink(conversationId: string): Promise { - return request.post(endpoints.createSharedLink(conversationId)); +export function createSharedLink( + conversationId: string, + targetMessageId?: string, +): Promise { + return request.post(endpoints.createSharedLink(conversationId), { targetMessageId }); } export function updateSharedLink(shareId: string): Promise { diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index d43308373f..3ad24da67a 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -57,6 +57,27 @@ export const fullMimeTypesList = [ 'application/zip', 'image/svg', 'image/svg+xml', + // Video formats + 'video/mp4', + 'video/avi', + 'video/mov', + 'video/wmv', + 'video/flv', + 'video/webm', + 'video/mkv', + 'video/m4v', + 'video/3gp', + 'video/ogv', + // Audio formats + 'audio/mp3', + 'audio/wav', + 'audio/ogg', + 'audio/m4a', + 'audio/aac', + 'audio/flac', + 'audio/wma', + 'audio/opus', + 'audio/mpeg', ...excelFileTypes, ]; @@ -115,7 +136,7 @@ export const excelMimeTypes = /^application\/(vnd\.ms-excel|msexcel|x-msexcel|x-ms-excel|x-excel|x-dos_ms_excel|xls|x-xls|vnd\.openxmlformats-officedocument\.spreadsheetml\.sheet)$/; export const textMimeTypes = - /^(text\/(x-c|x-csharp|tab-separated-values|x-c\+\+|x-h|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv))$/; + /^(text\/(x-c|x-csharp|tab-separated-values|x-c\+\+|x-h|x-java|html|markdown|x-php|x-python|x-script\.python|x-ruby|x-tex|plain|css|vtt|javascript|csv|xml))$/; export const applicationMimeTypes = /^(application\/(epub\+zip|csv|json|pdf|x-tar|typescript|vnd\.openxmlformats-officedocument\.(wordprocessingml\.document|presentationml\.presentation|spreadsheetml\.sheet)|xml|zip))$/; @@ -123,7 +144,9 @@ export const applicationMimeTypes = export const imageMimeTypes = /^image\/(jpeg|gif|png|webp|heic|heif)$/; export const audioMimeTypes = - /^audio\/(mp3|mpeg|mpeg3|wav|wave|x-wav|ogg|vorbis|mp4|x-m4a|flac|x-flac|webm)$/; + /^audio\/(mp3|mpeg|mpeg3|wav|wave|x-wav|ogg|vorbis|mp4|m4a|x-m4a|flac|x-flac|webm|aac|wma|opus)$/; + +export const videoMimeTypes = /^video\/(mp4|avi|mov|wmv|flv|webm|mkv|m4v|3gp|ogv)$/; export const defaultOCRMimeTypes = [ imageMimeTypes, @@ -142,8 +165,9 @@ export const supportedMimeTypes = [ excelMimeTypes, applicationMimeTypes, imageMimeTypes, + videoMimeTypes, audioMimeTypes, - /** Supported by LC Code Interpreter PAI */ + /** Supported by LC Code Interpreter API */ /^image\/(svg|svg\+xml)$/, ]; @@ -199,6 +223,13 @@ export const fileConfig = { [EModelEndpoint.assistants]: assistantsFileConfig, [EModelEndpoint.azureAssistants]: assistantsFileConfig, [EModelEndpoint.agents]: assistantsFileConfig, + [EModelEndpoint.anthropic]: { + fileLimit: 10, + fileSizeLimit: defaultSizeLimit, + totalSizeLimit: defaultSizeLimit, + supportedMimeTypes, + disabled: false, + }, default: { fileLimit: 10, fileSizeLimit: defaultSizeLimit, diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 58d70ac118..72299e96a5 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -62,6 +62,8 @@ const BaseOptionsSchema = z.object({ revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), }) .optional(), + /** Custom headers to send with OAuth requests (registration, discovery, token exchange, etc.) */ + oauth_headers: z.record(z.string(), z.string()).optional(), customUserVars: z .record( z.string(), diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index c925781bff..78ba1237fc 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -15,6 +15,13 @@ export type TModelSpec = { order?: number; default?: boolean; description?: string; + /** + * Optional group name for organizing specs in the UI selector. + * - If it matches an endpoint name (e.g., "openAI", "groq"), the spec appears nested under that endpoint + * - If it's a custom name (doesn't match any endpoint), it creates a separate collapsible group + * - If omitted, the spec appears as a standalone item at the top level + */ + group?: string; showIconInMenu?: boolean; showIconInHeader?: boolean; iconURL?: string | EModelEndpoint; // Allow using project-included icons @@ -28,6 +35,7 @@ export const tModelSpecSchema = z.object({ order: z.number().optional(), default: z.boolean().optional(), description: z.string().optional(), + group: z.string().optional(), showIconInMenu: z.boolean().optional(), showIconInHeader: z.boolean().optional(), iconURL: z.union([z.string(), eModelEndpointSchema]).optional(), diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 7d4016449a..61616a57a8 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -369,7 +369,7 @@ export function parseTextParts( continue; } if (part.type === ContentTypes.TEXT) { - const textValue = typeof part.text === 'string' ? part.text : part.text.value; + const textValue = (typeof part.text === 'string' ? part.text : part.text?.value) || ''; if ( result.length > 0 && diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 8ae503ef25..a279f4f84d 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -31,6 +31,61 @@ export enum EModelEndpoint { gptPlugins = 'gptPlugins', } +/** Mirrors `@librechat/agents` providers */ +export enum Providers { + OPENAI = 'openAI', + ANTHROPIC = 'anthropic', + AZURE = 'azureOpenAI', + GOOGLE = 'google', + VERTEXAI = 'vertexai', + BEDROCK = 'bedrock', + BEDROCK_LEGACY = 'bedrock_legacy', + MISTRALAI = 'mistralai', + MISTRAL = 'mistral', + OLLAMA = 'ollama', + DEEPSEEK = 'deepseek', + OPENROUTER = 'openrouter', + XAI = 'xai', +} + +/** + * Endpoints that support direct PDF processing in the agent system + */ +export const documentSupportedProviders = new Set([ + EModelEndpoint.anthropic, + EModelEndpoint.openAI, + EModelEndpoint.custom, + EModelEndpoint.azureOpenAI, + EModelEndpoint.google, + Providers.VERTEXAI, + Providers.MISTRALAI, + Providers.MISTRAL, + Providers.OLLAMA, + Providers.DEEPSEEK, + Providers.OPENROUTER, + Providers.XAI, +]); + +const openAILikeProviders = new Set([ + Providers.OPENAI, + Providers.AZURE, + EModelEndpoint.custom, + Providers.MISTRALAI, + Providers.MISTRAL, + Providers.OLLAMA, + Providers.DEEPSEEK, + Providers.OPENROUTER, + Providers.XAI, +]); + +export const isOpenAILikeProvider = (provider?: string | null): boolean => { + return openAILikeProviders.has(provider ?? ''); +}; + +export const isDocumentSupportedProvider = (provider?: string | null): boolean => { + return documentSupportedProviders.has(provider ?? ''); +}; + export const paramEndpoints = new Set([ EModelEndpoint.agents, EModelEndpoint.openAI, diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index beb1e10701..246c60a5c3 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -475,10 +475,20 @@ export type ContentPart = ( ) & PartMetadata; +export type TextData = (Text & PartMetadata) | undefined; + export type TMessageContentParts = - | { type: ContentTypes.ERROR; text?: string | (Text & PartMetadata); error?: string } - | { type: ContentTypes.THINK; think: string | (Text & PartMetadata) } - | { type: ContentTypes.TEXT; text: string | (Text & PartMetadata); tool_call_ids?: string[] } + | { + type: ContentTypes.ERROR; + text?: string | TextData; + error?: string; + } + | { type: ContentTypes.THINK; think?: string | TextData } + | { + type: ContentTypes.TEXT; + text?: string | TextData; + tool_call_ids?: string[]; + } | { type: ContentTypes.TOOL_CALL; tool_call: ( diff --git a/packages/data-schemas/package.json b/packages/data-schemas/package.json index f6591111e4..b8edae19a6 100644 --- a/packages/data-schemas/package.json +++ b/packages/data-schemas/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/data-schemas", - "version": "0.0.22", + "version": "0.0.23", "description": "Mongoose schemas and models for LibreChat", "type": "module", "main": "dist/index.cjs", diff --git a/packages/api/src/agents/config.ts b/packages/data-schemas/src/app/agents.ts similarity index 91% rename from packages/api/src/agents/config.ts rename to packages/data-schemas/src/app/agents.ts index 36b1f600eb..c75546f588 100644 --- a/packages/api/src/agents/config.ts +++ b/packages/data-schemas/src/app/agents.ts @@ -10,8 +10,8 @@ import type { TCustomConfig, TAgentsEndpoint } from 'librechat-data-provider'; * @returns The Agents endpoint configuration. */ export function agentsConfigSetup( - config: TCustomConfig, - defaultConfig: Partial, + config: Partial, + defaultConfig?: Partial, ): Partial { const agentsConfig = config?.endpoints?.[EModelEndpoint.agents]; diff --git a/api/server/services/start/assistants.js b/packages/data-schemas/src/app/assistants.ts similarity index 64% rename from api/server/services/start/assistants.js rename to packages/data-schemas/src/app/assistants.ts index febc170a95..c41a8d603e 100644 --- a/api/server/services/start/assistants.js +++ b/packages/data-schemas/src/app/assistants.ts @@ -1,15 +1,20 @@ -const { logger } = require('@librechat/data-schemas'); -const { +import logger from '~/config/winston'; +import { Capabilities, + EModelEndpoint, assistantEndpointSchema, defaultAssistantsVersion, -} = require('librechat-data-provider'); +} from 'librechat-data-provider'; +import type { TCustomConfig, TAssistantEndpoint } from 'librechat-data-provider'; /** * Sets up the minimum, default Assistants configuration if Azure OpenAI Assistants option is enabled. - * @returns {Partial} The Assistants endpoint configuration. + * @returns The Assistants endpoint configuration. */ -function azureAssistantsDefaults() { +export function azureAssistantsDefaults(): { + capabilities: TAssistantEndpoint['capabilities']; + version: TAssistantEndpoint['version']; +} { return { capabilities: [Capabilities.tools, Capabilities.actions, Capabilities.code_interpreter], version: defaultAssistantsVersion.azureAssistants, @@ -18,22 +23,26 @@ function azureAssistantsDefaults() { /** * Sets up the Assistants configuration from the config (`librechat.yaml`) file. - * @param {TCustomConfig} config - The loaded custom configuration. - * @param {EModelEndpoint.assistants|EModelEndpoint.azureAssistants} assistantsEndpoint - The Assistants endpoint name. + * @param config - The loaded custom configuration. + * @param assistantsEndpoint - The Assistants endpoint name. * - The previously loaded assistants configuration from Azure OpenAI Assistants option. - * @param {Partial} [prevConfig] - * @returns {Partial} The Assistants endpoint configuration. + * @param [prevConfig] + * @returns The Assistants endpoint configuration. */ -function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) { - const assistantsConfig = config.endpoints[assistantsEndpoint]; +export function assistantsConfigSetup( + config: Partial, + assistantsEndpoint: EModelEndpoint.assistants | EModelEndpoint.azureAssistants, + prevConfig: Partial = {}, +): Partial { + const assistantsConfig = config.endpoints?.[assistantsEndpoint]; const parsedConfig = assistantEndpointSchema.parse(assistantsConfig); - if (assistantsConfig.supportedIds?.length && assistantsConfig.excludedIds?.length) { + if (assistantsConfig?.supportedIds?.length && assistantsConfig.excludedIds?.length) { logger.warn( `Configuration conflict: The '${assistantsEndpoint}' endpoint has both 'supportedIds' and 'excludedIds' defined. The 'excludedIds' will be ignored.`, ); } if ( - assistantsConfig.privateAssistants && + assistantsConfig?.privateAssistants && (assistantsConfig.supportedIds?.length || assistantsConfig.excludedIds?.length) ) { logger.warn( @@ -59,5 +68,3 @@ function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) { titlePromptTemplate: parsedConfig.titlePromptTemplate, }; } - -module.exports = { azureAssistantsDefaults, assistantsConfigSetup }; diff --git a/api/server/services/start/azureOpenAI.js b/packages/data-schemas/src/app/azure.ts similarity index 65% rename from api/server/services/start/azureOpenAI.js rename to packages/data-schemas/src/app/azure.ts index 1598b28ba9..35d855ba43 100644 --- a/api/server/services/start/azureOpenAI.js +++ b/packages/data-schemas/src/app/azure.ts @@ -1,18 +1,22 @@ -const { logger } = require('@librechat/data-schemas'); -const { +import logger from '~/config/winston'; +import { EModelEndpoint, validateAzureGroups, mapModelToAzureConfig, -} = require('librechat-data-provider'); +} from 'librechat-data-provider'; +import type { TCustomConfig, TAzureConfig } from 'librechat-data-provider'; /** * Sets up the Azure OpenAI configuration from the config (`librechat.yaml`) file. - * @param {TCustomConfig} config - The loaded custom configuration. - * @returns {TAzureConfig} The Azure OpenAI configuration. + * @param config - The loaded custom configuration. + * @returns The Azure OpenAI configuration. */ -function azureConfigSetup(config) { - const { groups, ...azureConfiguration } = config.endpoints[EModelEndpoint.azureOpenAI]; - /** @type {TAzureConfigValidationResult} */ +export function azureConfigSetup(config: Partial): TAzureConfig { + const azureConfig = config.endpoints?.[EModelEndpoint.azureOpenAI]; + if (!azureConfig) { + throw new Error('Azure OpenAI configuration is missing.'); + } + const { groups, ...azureConfiguration } = azureConfig; const { isValid, modelNames, modelGroupMap, groupMap, errors } = validateAzureGroups(groups); if (!isValid) { @@ -22,16 +26,18 @@ function azureConfigSetup(config) { throw new Error(errorMessage); } - const assistantModels = []; - const assistantGroups = new Set(); + const assistantModels: string[] = []; + const assistantGroups = new Set(); for (const modelName of modelNames) { mapModelToAzureConfig({ modelName, modelGroupMap, groupMap }); const groupName = modelGroupMap?.[modelName]?.group; const modelGroup = groupMap?.[groupName]; - let supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants; + const supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants; if (supportsAssistants) { assistantModels.push(modelName); - !assistantGroups.has(groupName) && assistantGroups.add(groupName); + if (!assistantGroups.has(groupName)) { + assistantGroups.add(groupName); + } } } @@ -53,13 +59,13 @@ function azureConfigSetup(config) { } return { + errors, + isValid, + groupMap, modelNames, modelGroupMap, - groupMap, assistantModels, assistantGroups: Array.from(assistantGroups), ...azureConfiguration, }; } - -module.exports = { azureConfigSetup }; diff --git a/api/server/services/start/endpoints.js b/packages/data-schemas/src/app/endpoints.ts similarity index 63% rename from api/server/services/start/endpoints.js rename to packages/data-schemas/src/app/endpoints.ts index 3e9bd0df82..b4bdf2985b 100644 --- a/api/server/services/start/endpoints.js +++ b/packages/data-schemas/src/app/endpoints.ts @@ -1,22 +1,24 @@ -const { agentsConfigSetup } = require('@librechat/api'); -const { EModelEndpoint } = require('librechat-data-provider'); -const { azureAssistantsDefaults, assistantsConfigSetup } = require('./assistants'); -const { azureConfigSetup } = require('./azureOpenAI'); -const { checkAzureVariables } = require('./checks'); +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TCustomConfig, TAgentsEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '~/types'; +import { azureAssistantsDefaults, assistantsConfigSetup } from './assistants'; +import { agentsConfigSetup } from './agents'; +import { azureConfigSetup } from './azure'; /** * Loads custom config endpoints - * @param {TCustomConfig} [config] - * @param {TCustomConfig['endpoints']['agents']} [agentsDefaults] + * @param [config] + * @param [agentsDefaults] */ -const loadEndpoints = (config, agentsDefaults) => { - /** @type {AppConfig['endpoints']} */ - const loadedEndpoints = {}; +export const loadEndpoints = ( + config: Partial, + agentsDefaults?: Partial, +) => { + const loadedEndpoints: AppConfig['endpoints'] = {}; const endpoints = config?.endpoints; if (endpoints?.[EModelEndpoint.azureOpenAI]) { loadedEndpoints[EModelEndpoint.azureOpenAI] = azureConfigSetup(config); - checkAzureVariables(); } if (endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { @@ -50,8 +52,9 @@ const loadEndpoints = (config, agentsDefaults) => { ]; endpointKeys.forEach((key) => { - if (endpoints?.[key]) { - loadedEndpoints[key] = endpoints[key]; + const currentKey = key as keyof typeof endpoints; + if (endpoints?.[currentKey]) { + loadedEndpoints[currentKey] = endpoints[currentKey]; } }); @@ -61,7 +64,3 @@ const loadEndpoints = (config, agentsDefaults) => { return loadedEndpoints; }; - -module.exports = { - loadEndpoints, -}; diff --git a/packages/data-schemas/src/app/index.ts b/packages/data-schemas/src/app/index.ts new file mode 100644 index 0000000000..4912946329 --- /dev/null +++ b/packages/data-schemas/src/app/index.ts @@ -0,0 +1,6 @@ +export * from './agents'; +export * from './interface'; +export * from './service'; +export * from './specs'; +export * from './turnstile'; +export * from './web'; diff --git a/packages/api/src/app/interface.ts b/packages/data-schemas/src/app/interface.ts similarity index 55% rename from packages/api/src/app/interface.ts rename to packages/data-schemas/src/app/interface.ts index 3a03d09434..f8afdefd33 100644 --- a/packages/api/src/app/interface.ts +++ b/packages/data-schemas/src/app/interface.ts @@ -1,8 +1,7 @@ -import { logger } from '@librechat/data-schemas'; import { removeNullishValues } from 'librechat-data-provider'; import type { TCustomConfig, TConfigDefaults } from 'librechat-data-provider'; -import type { AppConfig } from '~/types/config'; -import { isMemoryEnabled } from '~/memory/config'; +import type { AppConfig } from '~/types/app'; +import { isMemoryEnabled } from './memory'; /** * Loads the default interface object. @@ -58,51 +57,5 @@ export async function loadDefaultInterface({ marketplace: interfaceConfig?.marketplace, }); - let i = 0; - const logSettings = () => { - // log interface object and model specs object (without list) for reference - logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`); - logger.warn( - `\`modelSpecs\` settings:\n${JSON.stringify( - { ...(config?.modelSpecs ?? {}), list: undefined }, - null, - 2, - )}`, - ); - }; - - // warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs. - if (config?.modelSpecs?.prioritize && loadedInterface.presets) { - logger.warn( - "Note: Prioritizing model specs can conflict with default presets if a default preset is set. It's recommended to disable presets from the interface or disable use of a default preset.", - ); - if (i === 0) i++; - } - - // warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options. - if ( - config?.modelSpecs?.enforce && - (loadedInterface.endpointsMenu || - loadedInterface.modelSelect || - loadedInterface.presets || - loadedInterface.parameters) - ) { - logger.warn( - "Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It's recommended to disable these options from the interface or disable enforcing model specs.", - ); - if (i === 0) i++; - } - // warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior. - if (config?.modelSpecs?.enforce && !config?.modelSpecs?.prioritize) { - logger.warn( - "Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It's recommended to enable prioritizing model specs if enforcing them.", - ); - if (i === 0) i++; - } - - if (i > 0) { - logSettings(); - } - return loadedInterface; } diff --git a/packages/data-schemas/src/app/memory.ts b/packages/data-schemas/src/app/memory.ts new file mode 100644 index 0000000000..8c27047cf6 --- /dev/null +++ b/packages/data-schemas/src/app/memory.ts @@ -0,0 +1,28 @@ +import { memorySchema } from 'librechat-data-provider'; +import type { TCustomConfig, TMemoryConfig } from 'librechat-data-provider'; + +const hasValidAgent = (agent: TMemoryConfig['agent']) => + !!agent && + (('id' in agent && !!agent.id) || + ('provider' in agent && 'model' in agent && !!agent.provider && !!agent.model)); + +const isDisabled = (config?: TMemoryConfig | TCustomConfig['memory']) => + !config || config.disabled === true; + +export function loadMemoryConfig(config: TCustomConfig['memory']): TMemoryConfig | undefined { + if (!config) return undefined; + if (isDisabled(config)) return config as TMemoryConfig; + + if (!hasValidAgent(config.agent)) { + return { ...config, disabled: true } as TMemoryConfig; + } + + const charLimit = memorySchema.shape.charLimit.safeParse(config.charLimit).data ?? 10000; + + return { ...config, charLimit }; +} + +export function isMemoryEnabled(config: TMemoryConfig | undefined): boolean { + if (isDisabled(config)) return false; + return hasValidAgent(config!.agent); +} diff --git a/packages/data-schemas/src/app/ocr.ts b/packages/data-schemas/src/app/ocr.ts new file mode 100644 index 0000000000..02060a858f --- /dev/null +++ b/packages/data-schemas/src/app/ocr.ts @@ -0,0 +1,15 @@ +import { OCRStrategy } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; + +export function loadOCRConfig(config?: TCustomConfig['ocr']): TCustomConfig['ocr'] | undefined { + if (!config) return; + const baseURL = config?.baseURL ?? ''; + const apiKey = config?.apiKey ?? ''; + const mistralModel = config?.mistralModel ?? ''; + return { + apiKey, + baseURL, + mistralModel, + strategy: config?.strategy ?? OCRStrategy.MISTRAL_OCR, + }; +} diff --git a/api/server/services/AppService.js b/packages/data-schemas/src/app/service.ts similarity index 52% rename from api/server/services/AppService.js rename to packages/data-schemas/src/app/service.ts index 49f9e324fb..aef2472d5f 100644 --- a/api/server/services/AppService.js +++ b/packages/data-schemas/src/app/service.ts @@ -1,48 +1,56 @@ -const { FileSources, EModelEndpoint, getConfigDefaults } = require('librechat-data-provider'); -const { - isEnabled, - loadOCRConfig, - loadMemoryConfig, - agentsConfigSetup, - loadWebSearchConfig, - loadDefaultInterface, -} = require('@librechat/api'); -const { - checkWebSearchConfig, - checkVariables, - checkHealth, - checkConfig, -} = require('./start/checks'); -const { initializeAzureBlobService } = require('./Files/Azure/initialize'); -const { initializeFirebase } = require('./Files/Firebase/initialize'); -const handleRateLimits = require('./Config/handleRateLimits'); -const loadCustomConfig = require('./Config/loadCustomConfig'); -const { loadTurnstileConfig } = require('./start/turnstile'); -const { processModelSpecs } = require('./start/modelSpecs'); -const { initializeS3 } = require('./Files/S3/initialize'); -const { loadAndFormatTools } = require('./start/tools'); -const { loadEndpoints } = require('./start/endpoints'); -const paths = require('~/config/paths'); +import { EModelEndpoint, getConfigDefaults } from 'librechat-data-provider'; +import type { TCustomConfig, FileSources, DeepPartial } from 'librechat-data-provider'; +import type { AppConfig, FunctionTool } from '~/types/app'; +import { loadDefaultInterface } from './interface'; +import { loadTurnstileConfig } from './turnstile'; +import { agentsConfigSetup } from './agents'; +import { loadWebSearchConfig } from './web'; +import { processModelSpecs } from './specs'; +import { loadMemoryConfig } from './memory'; +import { loadEndpoints } from './endpoints'; +import { loadOCRConfig } from './ocr'; + +export type Paths = { + root: string; + uploads: string; + clientPath: string; + dist: string; + publicPath: string; + fonts: string; + assets: string; + imageOutput: string; + structuredTools: string; + pluginManifest: string; +}; /** * Loads custom config and initializes app-wide variables. * @function AppService */ -const AppService = async () => { - /** @type {TCustomConfig} */ - const config = (await loadCustomConfig()) ?? {}; +export const AppService = async (params?: { + config: DeepPartial; + paths?: Paths; + systemTools?: Record; +}): Promise => { + const { config, paths, systemTools } = params || {}; + if (!config) { + throw new Error('Config is required'); + } const configDefaults = getConfigDefaults(); const ocr = loadOCRConfig(config.ocr); const webSearch = loadWebSearchConfig(config.webSearch); - checkWebSearchConfig(webSearch); const memory = loadMemoryConfig(config.memory); const filteredTools = config.filteredTools; const includedTools = config.includedTools; - const fileStrategy = config.fileStrategy ?? configDefaults.fileStrategy; + const fileStrategy = (config.fileStrategy ?? configDefaults.fileStrategy) as + | FileSources.local + | FileSources.s3 + | FileSources.firebase + | FileSources.azure_blob; const startBalance = process.env.START_BALANCE; const balance = config.balance ?? { - enabled: isEnabled(process.env.CHECK_BALANCE), + enabled: process.env.CHECK_BALANCE?.toLowerCase().trim() === 'true', startBalance: startBalance ? parseInt(startBalance, 10) : undefined, }; const transactions = config.transactions ?? configDefaults.transactions; @@ -50,23 +58,7 @@ const AppService = async () => { process.env.CDN_PROVIDER = fileStrategy; - checkVariables(); - await checkHealth(); - - if (fileStrategy === FileSources.firebase) { - initializeFirebase(); - } else if (fileStrategy === FileSources.azure_blob) { - initializeAzureBlobService(); - } else if (fileStrategy === FileSources.s3) { - initializeS3(); - } - - /** @type {Record} */ - const availableTools = loadAndFormatTools({ - adminFilter: filteredTools, - adminIncluded: includedTools, - directory: paths.structuredTools, - }); + const availableTools = systemTools; const mcpConfig = config.mcpServers || null; const registration = config.registration ?? configDefaults.registration; @@ -107,8 +99,6 @@ const AppService = async () => { return appConfig; } - checkConfig(config); - handleRateLimits(config?.rateLimits); const loadedEndpoints = loadEndpoints(config, agentsDefaults); const appConfig = { @@ -121,5 +111,3 @@ const AppService = async () => { return appConfig; }; - -module.exports = AppService; diff --git a/packages/data-schemas/src/app/specs.ts b/packages/data-schemas/src/app/specs.ts new file mode 100644 index 0000000000..4fa82d37a4 --- /dev/null +++ b/packages/data-schemas/src/app/specs.ts @@ -0,0 +1,94 @@ +import logger from '~/config/winston'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; + +/** + * Normalize the endpoint name to system-expected value. + * @param name + */ +function normalizeEndpointName(name = ''): string { + return name.toLowerCase() === 'ollama' ? 'ollama' : name; +} + +/** + * Sets up Model Specs from the config (`librechat.yaml`) file. + * @param [endpoints] - The loaded custom configuration for endpoints. + * @param [modelSpecs] - The loaded custom configuration for model specs. + * @param [interfaceConfig] - The loaded interface configuration. + * @returns The processed model specs, if any. + */ +export function processModelSpecs( + endpoints?: TCustomConfig['endpoints'], + _modelSpecs?: TCustomConfig['modelSpecs'], + interfaceConfig?: TCustomConfig['interface'], +): TCustomConfig['modelSpecs'] | undefined { + if (!_modelSpecs) { + return undefined; + } + + const list = _modelSpecs.list; + const modelSpecs: typeof list = []; + + const customEndpoints = endpoints?.[EModelEndpoint.custom] ?? []; + + if (interfaceConfig?.modelSelect !== true && (_modelSpecs.addedEndpoints?.length ?? 0) > 0) { + logger.warn( + `To utilize \`addedEndpoints\`, which allows provider/model selections alongside model specs, set \`modelSelect: true\` in the interface configuration. + + Example: + \`\`\`yaml + interface: + modelSelect: true + \`\`\` + `, + ); + } + + if (!list || list.length === 0) { + return undefined; + } + + for (const spec of list) { + const currentEndpoint = spec.preset?.endpoint as EModelEndpoint | undefined; + if (!currentEndpoint) { + logger.warn( + 'A model spec is missing the `endpoint` field within its `preset`. Skipping model spec...', + ); + continue; + } + if (EModelEndpoint[currentEndpoint] && currentEndpoint !== EModelEndpoint.custom) { + modelSpecs.push(spec); + continue; + } else if (currentEndpoint === EModelEndpoint.custom) { + logger.warn( + `Model Spec with endpoint "${currentEndpoint}" is not supported. You must specify the name of the custom endpoint (case-sensitive, as defined in your config). Skipping model spec...`, + ); + continue; + } + + const normalizedName = normalizeEndpointName(currentEndpoint); + const endpoint = customEndpoints.find( + (customEndpoint) => normalizedName === normalizeEndpointName(customEndpoint.name), + ); + + if (!endpoint) { + logger.warn(`Model spec with endpoint "${currentEndpoint}" was skipped: Endpoint not found in configuration. The \`endpoint\` value must exactly match either a system-defined endpoint or a custom endpoint defined by the user. + +For more information, see the documentation at https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/model_specs#endpoint`); + continue; + } + + modelSpecs.push({ + ...spec, + preset: { + ...spec.preset, + endpoint: normalizedName, + }, + }); + } + + return { + ..._modelSpecs, + list: modelSpecs, + }; +} diff --git a/packages/data-schemas/src/app/turnstile.ts b/packages/data-schemas/src/app/turnstile.ts new file mode 100644 index 0000000000..df0ae5a998 --- /dev/null +++ b/packages/data-schemas/src/app/turnstile.ts @@ -0,0 +1,45 @@ +import logger from '~/config/winston'; +import { removeNullishValues } from 'librechat-data-provider'; +import type { TCustomConfig, TConfigDefaults } from 'librechat-data-provider'; + +/** + * Loads and maps the Cloudflare Turnstile configuration. + * + * Expected config structure: + * + * turnstile: + * siteKey: "your-site-key-here" + * options: + * language: "auto" // "auto" or an ISO 639-1 language code (e.g. en) + * size: "normal" // Options: "normal", "compact", "flexible", or "invisible" + * + * @param config - The loaded custom configuration. + * @param configDefaults - The custom configuration default values. + * @returns The mapped Turnstile configuration. + */ +export function loadTurnstileConfig( + config: Partial | undefined, + configDefaults: TConfigDefaults, +): Partial { + const { turnstile: customTurnstile } = config ?? {}; + const { turnstile: defaults } = configDefaults; + + const loadedTurnstile = removeNullishValues({ + siteKey: + customTurnstile?.siteKey ?? (defaults as TCustomConfig['turnstile'] | undefined)?.siteKey, + options: + customTurnstile?.options ?? (defaults as TCustomConfig['turnstile'] | undefined)?.options, + }); + + const enabled = Boolean(loadedTurnstile.siteKey); + + if (enabled) { + logger.debug( + 'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2), + ); + } else { + logger.debug('Turnstile is DISABLED (no siteKey provided).'); + } + + return loadedTurnstile; +} diff --git a/packages/data-schemas/src/app/web.spec.ts b/packages/data-schemas/src/app/web.spec.ts new file mode 100644 index 0000000000..787d7809a3 --- /dev/null +++ b/packages/data-schemas/src/app/web.spec.ts @@ -0,0 +1,173 @@ +import { SafeSearchTypes, SearchProviders, ScraperProviders } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import { loadWebSearchConfig } from './web'; + +describe('loadWebSearchConfig', () => { + describe('firecrawlVersion', () => { + it('should use provided firecrawlVersion when specified', () => { + const config: TCustomConfig['webSearch'] = { + firecrawlVersion: 'v2', + }; + + const result = loadWebSearchConfig(config); + + expect(result?.firecrawlVersion).toBe('v2'); + }); + + it('should default to ${FIRECRAWL_VERSION} when not provided', () => { + const config: TCustomConfig['webSearch'] = {}; + + const result = loadWebSearchConfig(config); + + expect(result?.firecrawlVersion).toBe('${FIRECRAWL_VERSION}'); + }); + + it('should default to ${FIRECRAWL_VERSION} when config is undefined', () => { + const result = loadWebSearchConfig(undefined); + + expect(result?.firecrawlVersion).toBe('${FIRECRAWL_VERSION}'); + }); + + it('should preserve custom firecrawlVersion value', () => { + const config: TCustomConfig['webSearch'] = { + firecrawlVersion: 'v1', + }; + + const result = loadWebSearchConfig(config); + + expect(result?.firecrawlVersion).toBe('v1'); + }); + }); + + describe('all config fields', () => { + it('should apply defaults for all fields when config is empty', () => { + const config: TCustomConfig['webSearch'] = {}; + + const result = loadWebSearchConfig(config); + + expect(result).toEqual({ + serperApiKey: '${SERPER_API_KEY}', + searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}', + searxngApiKey: '${SEARXNG_API_KEY}', + firecrawlApiKey: '${FIRECRAWL_API_KEY}', + firecrawlApiUrl: '${FIRECRAWL_API_URL}', + firecrawlVersion: '${FIRECRAWL_VERSION}', + jinaApiKey: '${JINA_API_KEY}', + jinaApiUrl: '${JINA_API_URL}', + cohereApiKey: '${COHERE_API_KEY}', + safeSearch: SafeSearchTypes.MODERATE, + }); + }); + + it('should preserve provided config values and merge with defaults', () => { + const config: TCustomConfig['webSearch'] = { + serperApiKey: 'custom-serper-key', + firecrawlApiKey: 'custom-firecrawl-key', + firecrawlVersion: 'v2', + safeSearch: SafeSearchTypes.STRICT, + }; + + const result = loadWebSearchConfig(config); + + expect(result?.serperApiKey).toBe('custom-serper-key'); + expect(result?.firecrawlApiKey).toBe('custom-firecrawl-key'); + expect(result?.firecrawlVersion).toBe('v2'); + expect(result?.safeSearch).toBe(SafeSearchTypes.STRICT); + expect(result?.jinaApiKey).toBe('${JINA_API_KEY}'); + }); + + it('should preserve additional fields from input config', () => { + const config: TCustomConfig['webSearch'] = { + serperApiKey: 'test-key', + scraperProvider: ScraperProviders.SERPER, + searchProvider: SearchProviders.SERPER, + }; + + const result = loadWebSearchConfig(config); + + expect(result?.scraperProvider).toBe('serper'); + expect(result?.searchProvider).toBe('serper'); + expect(result?.serperApiKey).toBe('test-key'); + }); + }); + + describe('safeSearch', () => { + it('should default to MODERATE when not provided', () => { + const config: TCustomConfig['webSearch'] = {}; + + const result = loadWebSearchConfig(config); + + expect(result?.safeSearch).toBe(SafeSearchTypes.MODERATE); + }); + + it('should preserve OFF value', () => { + const config: TCustomConfig['webSearch'] = { + safeSearch: SafeSearchTypes.OFF, + }; + + const result = loadWebSearchConfig(config); + + expect(result?.safeSearch).toBe(SafeSearchTypes.OFF); + }); + + it('should preserve STRICT value', () => { + const config: TCustomConfig['webSearch'] = { + safeSearch: SafeSearchTypes.STRICT, + }; + + const result = loadWebSearchConfig(config); + + expect(result?.safeSearch).toBe(SafeSearchTypes.STRICT); + }); + }); + + describe('API keys', () => { + it('should apply default placeholders for all API keys', () => { + const result = loadWebSearchConfig({}); + + expect(result?.serperApiKey).toBe('${SERPER_API_KEY}'); + expect(result?.searxngApiKey).toBe('${SEARXNG_API_KEY}'); + expect(result?.firecrawlApiKey).toBe('${FIRECRAWL_API_KEY}'); + expect(result?.jinaApiKey).toBe('${JINA_API_KEY}'); + expect(result?.cohereApiKey).toBe('${COHERE_API_KEY}'); + }); + + it('should preserve custom API keys', () => { + const config: TCustomConfig['webSearch'] = { + serperApiKey: 'actual-serper-key', + jinaApiKey: 'actual-jina-key', + cohereApiKey: 'actual-cohere-key', + }; + + const result = loadWebSearchConfig(config); + + expect(result?.serperApiKey).toBe('actual-serper-key'); + expect(result?.jinaApiKey).toBe('actual-jina-key'); + expect(result?.cohereApiKey).toBe('actual-cohere-key'); + }); + }); + + describe('URLs', () => { + it('should apply default placeholders for URLs', () => { + const result = loadWebSearchConfig({}); + + expect(result?.searxngInstanceUrl).toBe('${SEARXNG_INSTANCE_URL}'); + expect(result?.firecrawlApiUrl).toBe('${FIRECRAWL_API_URL}'); + expect(result?.jinaApiUrl).toBe('${JINA_API_URL}'); + }); + + it('should preserve custom URLs', () => { + const config: TCustomConfig['webSearch'] = { + searxngInstanceUrl: 'https://custom-searxng.com', + firecrawlApiUrl: 'https://custom-firecrawl.com', + jinaApiUrl: 'https://custom-jina.com', + }; + + const result = loadWebSearchConfig(config); + + expect(result?.searxngInstanceUrl).toBe('https://custom-searxng.com'); + expect(result?.firecrawlApiUrl).toBe('https://custom-firecrawl.com'); + expect(result?.jinaApiUrl).toBe('https://custom-jina.com'); + }); + }); +}); diff --git a/packages/data-schemas/src/app/web.ts b/packages/data-schemas/src/app/web.ts new file mode 100644 index 0000000000..a61e1f1611 --- /dev/null +++ b/packages/data-schemas/src/app/web.ts @@ -0,0 +1,90 @@ +import { SafeSearchTypes } from 'librechat-data-provider'; +import type { TCustomConfig } from 'librechat-data-provider'; +import type { TWebSearchKeys, TWebSearchCategories } from '~/types/web'; + +export const webSearchAuth = { + providers: { + serper: { + serperApiKey: 1 as const, + }, + searxng: { + searxngInstanceUrl: 1 as const, + /** Optional (0) */ + searxngApiKey: 0 as const, + }, + }, + scrapers: { + firecrawl: { + firecrawlApiKey: 1 as const, + /** Optional (0) */ + firecrawlApiUrl: 0 as const, + firecrawlVersion: 0 as const, + }, + serper: { + serperApiKey: 1 as const, + }, + }, + rerankers: { + jina: { + jinaApiKey: 1 as const, + /** Optional (0) */ + jinaApiUrl: 0 as const, + }, + cohere: { cohereApiKey: 1 as const }, + }, +}; + +/** + * Extracts all unique API keys from the webSearchAuth configuration object + */ +export function getWebSearchKeys(): TWebSearchKeys[] { + const keysSet = new Set(); + + // Iterate through each category (providers, scrapers, rerankers) + for (const category of Object.keys(webSearchAuth)) { + const categoryObj = webSearchAuth[category as TWebSearchCategories]; + + // Iterate through each service within the category + for (const service of Object.keys(categoryObj)) { + const serviceObj = categoryObj[service as keyof typeof categoryObj]; + + // Extract the API keys from the service and add to set for deduplication + for (const key of Object.keys(serviceObj)) { + keysSet.add(key as TWebSearchKeys); + } + } + } + + return Array.from(keysSet); +} + +export const webSearchKeys: TWebSearchKeys[] = getWebSearchKeys(); + +export function loadWebSearchConfig( + config: TCustomConfig['webSearch'], +): TCustomConfig['webSearch'] { + const serperApiKey = config?.serperApiKey ?? '${SERPER_API_KEY}'; + const searxngInstanceUrl = config?.searxngInstanceUrl ?? '${SEARXNG_INSTANCE_URL}'; + const searxngApiKey = config?.searxngApiKey ?? '${SEARXNG_API_KEY}'; + const firecrawlApiKey = config?.firecrawlApiKey ?? '${FIRECRAWL_API_KEY}'; + const firecrawlApiUrl = config?.firecrawlApiUrl ?? '${FIRECRAWL_API_URL}'; + const firecrawlVersion = config?.firecrawlVersion ?? '${FIRECRAWL_VERSION}'; + const jinaApiKey = config?.jinaApiKey ?? '${JINA_API_KEY}'; + const jinaApiUrl = config?.jinaApiUrl ?? '${JINA_API_URL}'; + const cohereApiKey = config?.cohereApiKey ?? '${COHERE_API_KEY}'; + const safeSearch = config?.safeSearch ?? SafeSearchTypes.MODERATE; + + return { + ...config, + safeSearch, + jinaApiKey, + jinaApiUrl, + cohereApiKey, + serperApiKey, + searxngApiKey, + firecrawlApiKey, + firecrawlApiUrl, + firecrawlVersion, + searxngInstanceUrl, + }; +} diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 503c99385f..0754dfe258 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -1,3 +1,4 @@ +export * from './app'; export * from './common'; export * from './crypto'; export * from './schema'; diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 8ff71fd718..11e893ff9c 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -82,6 +82,77 @@ function anonymizeMessages(messages: t.IMessage[], newConvoId: string): t.IMessa }); } +/** + * Filter messages up to and including the target message (branch-specific) + * Similar to getMessagesUpToTargetLevel from fork utilities + */ +function getMessagesUpToTarget(messages: t.IMessage[], targetMessageId: string): t.IMessage[] { + if (!messages || messages.length === 0) { + return []; + } + + // If only one message and it's the target, return it + if (messages.length === 1 && messages[0]?.messageId === targetMessageId) { + return messages; + } + + // Create a map of parentMessageId to children messages + const parentToChildrenMap = new Map(); + for (const message of messages) { + const parentId = message.parentMessageId || Constants.NO_PARENT; + if (!parentToChildrenMap.has(parentId)) { + parentToChildrenMap.set(parentId, []); + } + parentToChildrenMap.get(parentId)?.push(message); + } + + // Find the target message + const targetMessage = messages.find((msg) => msg.messageId === targetMessageId); + if (!targetMessage) { + // If target not found, return all messages for backwards compatibility + return messages; + } + + const visited = new Set(); + const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || []; + let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage]; + const results = new Set(currentLevel); + + // Check if the target message is at the root level + if ( + currentLevel.some((msg) => msg.messageId === targetMessageId) && + targetMessage.parentMessageId === Constants.NO_PARENT + ) { + return Array.from(results); + } + + // Iterate level by level until the target is found + let targetFound = false; + while (!targetFound && currentLevel.length > 0) { + const nextLevel: t.IMessage[] = []; + for (const node of currentLevel) { + if (visited.has(node.messageId)) { + continue; + } + visited.add(node.messageId); + const children = parentToChildrenMap.get(node.messageId) || []; + for (const child of children) { + if (visited.has(child.messageId)) { + continue; + } + nextLevel.push(child); + results.add(child); + if (child.messageId === targetMessageId) { + targetFound = true; + } + } + } + currentLevel = nextLevel; + } + + return Array.from(results); +} + /** Factory function that takes mongoose instance and returns the methods */ export function createShareMethods(mongoose: typeof import('mongoose')) { /** @@ -102,6 +173,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { return null; } + // Filter messages based on targetMessageId if present (branch-specific sharing) + let messagesToShare = share.messages; + if (share.targetMessageId) { + messagesToShare = getMessagesUpToTarget(share.messages, share.targetMessageId); + } + const newConvoId = anonymizeConvoId(share.conversationId); const result: t.SharedMessagesResult = { shareId: share.shareId || shareId, @@ -110,7 +187,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { createdAt: share.createdAt, updatedAt: share.updatedAt, conversationId: newConvoId, - messages: anonymizeMessages(share.messages, newConvoId), + messages: anonymizeMessages(messagesToShare, newConvoId), }; return result; @@ -239,6 +316,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { async function createSharedLink( user: string, conversationId: string, + targetMessageId?: string, ): Promise { if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -249,7 +327,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods; const [existingShare, conversationMessages] = await Promise.all([ - SharedLink.findOne({ conversationId, user, isPublic: true }) + SharedLink.findOne({ + conversationId, + user, + isPublic: true, + ...(targetMessageId && { targetMessageId }), + }) .select('-_id -__v -user') .lean() as Promise, Message.find({ conversationId, user }).sort({ createdAt: 1 }).lean(), @@ -259,10 +342,15 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { logger.error('[createSharedLink] Share already exists', { user, conversationId, + targetMessageId, }); throw new ShareServiceError('Share already exists', 'SHARE_EXISTS'); } else if (existingShare) { - await SharedLink.deleteOne({ conversationId, user }); + await SharedLink.deleteOne({ + conversationId, + user, + ...(targetMessageId && { targetMessageId }), + }); } const conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { @@ -291,6 +379,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { messages: conversationMessages, title, user, + ...(targetMessageId && { targetMessageId }), }); return { shareId, conversationId }; @@ -302,6 +391,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { error: error instanceof Error ? error.message : 'Unknown error', user, conversationId, + targetMessageId, }); throw new ShareServiceError('Error creating shared link', 'SHARE_CREATE_ERROR'); } diff --git a/packages/data-schemas/src/methods/token.spec.ts b/packages/data-schemas/src/methods/token.spec.ts index 2480619984..bec18a419e 100644 --- a/packages/data-schemas/src/methods/token.spec.ts +++ b/packages/data-schemas/src/methods/token.spec.ts @@ -181,6 +181,197 @@ describe('Token Methods - Detailed Tests', () => { expect(found).toBeNull(); }); + + test('should find most recent token with sort option', async () => { + const recentUserId = new mongoose.Types.ObjectId(); + + // Create tokens with different timestamps + const oldDate = new Date(Date.now() - 7200000); // 2 hours ago + const midDate = new Date(Date.now() - 3600000); // 1 hour ago + const newDate = new Date(); // now + + await Token.create([ + { + token: 'old-token', + userId: recentUserId, + email: 'recent@example.com', + createdAt: oldDate, + expiresAt: new Date(oldDate.getTime() + 86400000), + }, + { + token: 'mid-token', + userId: recentUserId, + email: 'recent@example.com', + createdAt: midDate, + expiresAt: new Date(midDate.getTime() + 86400000), + }, + { + token: 'new-token', + userId: recentUserId, + email: 'recent@example.com', + createdAt: newDate, + expiresAt: new Date(newDate.getTime() + 86400000), + }, + ]); + + // Find most recent token for the user with sort option + const found = await methods.findToken( + { userId: recentUserId.toString() }, + { sort: { createdAt: -1 } }, + ); + + expect(found).toBeDefined(); + expect(found?.token).toBe('new-token'); + expect(found?.createdAt.getTime()).toBe(newDate.getTime()); + }); + + test('should find oldest token with ascending sort', async () => { + const sortUserId = new mongoose.Types.ObjectId(); + + const oldDate = new Date(Date.now() - 7200000); + const midDate = new Date(Date.now() - 3600000); + const newDate = new Date(); + + await Token.create([ + { + token: 'sort-old', + userId: sortUserId, + email: 'sort@example.com', + createdAt: oldDate, + expiresAt: new Date(oldDate.getTime() + 86400000), + }, + { + token: 'sort-mid', + userId: sortUserId, + email: 'sort@example.com', + createdAt: midDate, + expiresAt: new Date(midDate.getTime() + 86400000), + }, + { + token: 'sort-new', + userId: sortUserId, + email: 'sort@example.com', + createdAt: newDate, + expiresAt: new Date(newDate.getTime() + 86400000), + }, + ]); + + // Find oldest token with ascending sort + const found = await methods.findToken( + { userId: sortUserId.toString() }, + { sort: { createdAt: 1 } }, + ); + + expect(found).toBeDefined(); + expect(found?.token).toBe('sort-old'); + expect(found?.createdAt.getTime()).toBe(oldDate.getTime()); + }); + + test('should handle multiple sort criteria', async () => { + const multiSortUserId = new mongoose.Types.ObjectId(); + const sameDate = new Date(); + + await Token.create([ + { + token: 'token-a', + userId: multiSortUserId, + email: 'z@example.com', + createdAt: sameDate, + expiresAt: new Date(sameDate.getTime() + 86400000), + }, + { + token: 'token-b', + userId: multiSortUserId, + email: 'a@example.com', + createdAt: sameDate, + expiresAt: new Date(sameDate.getTime() + 86400000), + }, + { + token: 'token-c', + userId: multiSortUserId, + email: 'm@example.com', + createdAt: new Date(Date.now() - 1000), // slightly older + expiresAt: new Date(Date.now() + 86400000), + }, + ]); + + // Sort by createdAt descending, then by email ascending + const found = await methods.findToken( + { userId: multiSortUserId.toString() }, + { sort: { createdAt: -1, email: 1 } }, + ); + + expect(found).toBeDefined(); + // Should get token-b (same recent date but 'a@example.com' comes first alphabetically) + expect(found?.token).toBe('token-b'); + expect(found?.email).toBe('a@example.com'); + }); + + test('should find token with projection option', async () => { + const projectionUserId = new mongoose.Types.ObjectId(); + + await Token.create({ + token: 'projection-token', + userId: projectionUserId, + email: 'projection@example.com', + identifier: 'oauth-projection', + createdAt: new Date(), + expiresAt: new Date(Date.now() + 86400000), + }); + + // Find token with projection to only include specific fields + const found = await methods.findToken( + { userId: projectionUserId.toString() }, + { projection: { token: 1, email: 1 } }, + ); + + expect(found).toBeDefined(); + expect(found?.token).toBe('projection-token'); + expect(found?.email).toBe('projection@example.com'); + // Note: _id is usually included by default unless explicitly excluded + }); + + test('should respect combined query options', async () => { + const combinedUserId = new mongoose.Types.ObjectId(); + + // Create multiple tokens with different attributes + await Token.create([ + { + token: 'combined-1', + userId: combinedUserId, + email: 'combined1@example.com', + createdAt: new Date(Date.now() - 7200000), + expiresAt: new Date(Date.now() + 86400000), + }, + { + token: 'combined-2', + userId: combinedUserId, + email: 'combined2@example.com', + createdAt: new Date(Date.now() - 3600000), + expiresAt: new Date(Date.now() + 86400000), + }, + { + token: 'combined-3', + userId: combinedUserId, + email: 'combined3@example.com', + createdAt: new Date(), + expiresAt: new Date(Date.now() + 86400000), + }, + ]); + + // Use multiple query options together + const found = await methods.findToken( + { userId: combinedUserId.toString() }, + { + sort: { createdAt: -1 }, + projection: { token: 1, createdAt: 1 }, + }, + ); + + expect(found).toBeDefined(); + expect(found?.token).toBe('combined-3'); // Most recent + expect(found?.createdAt).toBeDefined(); + }); }); describe('updateToken', () => { diff --git a/packages/data-schemas/src/methods/token.ts b/packages/data-schemas/src/methods/token.ts index c3c862b96a..5dfee869c0 100644 --- a/packages/data-schemas/src/methods/token.ts +++ b/packages/data-schemas/src/methods/token.ts @@ -1,3 +1,4 @@ +import type { QueryOptions } from 'mongoose'; import { IToken, TokenCreateData, TokenQuery, TokenUpdateData, TokenDeleteResult } from '~/types'; import logger from '~/config/winston'; @@ -81,7 +82,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) { /** * Finds a Token document that matches the provided query. */ - async function findToken(query: TokenQuery): Promise { + async function findToken(query: TokenQuery, options?: QueryOptions): Promise { try { const Token = mongoose.models.Token; const conditions = []; @@ -99,9 +100,7 @@ export function createTokenMethods(mongoose: typeof import('mongoose')) { conditions.push({ identifier: query.identifier }); } - const token = await Token.findOne({ - $and: conditions, - }).lean(); + const token = await Token.findOne({ $and: conditions }, null, options).lean(); return token as IToken | null; } catch (error) { diff --git a/packages/data-schemas/src/models/pluginAuth.ts b/packages/data-schemas/src/models/pluginAuth.ts index cf466a145f..5075fe6f43 100644 --- a/packages/data-schemas/src/models/pluginAuth.ts +++ b/packages/data-schemas/src/models/pluginAuth.ts @@ -1,4 +1,5 @@ -import pluginAuthSchema, { IPluginAuth } from '~/schema/pluginAuth'; +import pluginAuthSchema from '~/schema/pluginAuth'; +import type { IPluginAuth } from '~/types/pluginAuth'; /** * Creates or returns the PluginAuth model using the provided mongoose instance and schema diff --git a/packages/data-schemas/src/models/prompt.ts b/packages/data-schemas/src/models/prompt.ts index 74cc4ea2da..87edfa1ef8 100644 --- a/packages/data-schemas/src/models/prompt.ts +++ b/packages/data-schemas/src/models/prompt.ts @@ -1,4 +1,5 @@ -import promptSchema, { IPrompt } from '~/schema/prompt'; +import promptSchema from '~/schema/prompt'; +import type { IPrompt } from '~/types/prompts'; /** * Creates or returns the Prompt model using the provided mongoose instance and schema diff --git a/packages/data-schemas/src/models/promptGroup.ts b/packages/data-schemas/src/models/promptGroup.ts index 41e3d2e347..8de3dc9e16 100644 --- a/packages/data-schemas/src/models/promptGroup.ts +++ b/packages/data-schemas/src/models/promptGroup.ts @@ -1,4 +1,5 @@ -import promptGroupSchema, { IPromptGroupDocument } from '~/schema/promptGroup'; +import promptGroupSchema from '~/schema/promptGroup'; +import type { IPromptGroupDocument } from '~/types/prompts'; /** * Creates or returns the PromptGroup model using the provided mongoose instance and schema diff --git a/packages/data-schemas/src/schema/share.ts b/packages/data-schemas/src/schema/share.ts index 62347beb56..987dd10fc2 100644 --- a/packages/data-schemas/src/schema/share.ts +++ b/packages/data-schemas/src/schema/share.ts @@ -6,6 +6,7 @@ export interface ISharedLink extends Document { user?: string; messages?: Types.ObjectId[]; shareId?: string; + targetMessageId?: string; isPublic: boolean; createdAt?: Date; updatedAt?: Date; @@ -30,6 +31,11 @@ const shareSchema: Schema = new Schema( type: String, index: true, }, + targetMessageId: { + type: String, + required: false, + index: true, + }, isPublic: { type: Boolean, default: true, @@ -38,4 +44,6 @@ const shareSchema: Schema = new Schema( { timestamps: true }, ); +shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1 }); + export default shareSchema; diff --git a/packages/api/src/types/config.ts b/packages/data-schemas/src/types/app.ts similarity index 69% rename from packages/api/src/types/config.ts rename to packages/data-schemas/src/types/app.ts index ff38cbd8d1..1078cb3f92 100644 --- a/packages/api/src/types/config.ts +++ b/packages/data-schemas/src/types/app.ts @@ -9,7 +9,31 @@ import type { TCustomEndpoints, TAssistantEndpoint, } from 'librechat-data-provider'; -import type { FunctionTool } from './tools'; + +export type JsonSchemaType = { + type: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object'; + enum?: string[]; + items?: JsonSchemaType; + properties?: Record; + required?: string[]; + description?: string; + additionalProperties?: boolean | JsonSchemaType; +}; + +export type ConvertJsonSchemaToZodOptions = { + allowEmptyObject?: boolean; + dropFields?: string[]; + transformOneOfAnyOf?: boolean; +}; + +export interface FunctionTool { + type: 'function'; + function: { + description: string; + name: string; + parameters: JsonSchemaType; + }; +} /** * Application configuration object @@ -17,11 +41,11 @@ import type { FunctionTool } from './tools'; */ export interface AppConfig { /** The main custom configuration */ - config: TCustomConfig; + config: Partial; /** OCR configuration */ ocr?: TCustomConfig['ocr']; /** File paths configuration */ - paths: { + paths?: { uploads: string; imageOutput: string; publicPath: string; @@ -34,7 +58,7 @@ export interface AppConfig { /** File storage strategy ('local', 's3', 'firebase', 'azure_blob') */ fileStrategy: FileSources.local | FileSources.s3 | FileSources.firebase | FileSources.azure_blob; /** File strategies configuration */ - fileStrategies: TCustomConfig['fileStrategies']; + fileStrategies?: TCustomConfig['fileStrategies']; /** Registration configurations */ registration?: TCustomConfig['registration']; /** Actions configurations */ @@ -48,9 +72,9 @@ export interface AppConfig { /** Interface configuration */ interfaceConfig?: TCustomConfig['interface']; /** Turnstile configuration */ - turnstileConfig?: TCustomConfig['turnstile']; + turnstileConfig?: Partial; /** Balance configuration */ - balance?: TCustomConfig['balance']; + balance?: Partial; /** Transactions configuration */ transactions?: TCustomConfig['transactions']; /** Speech configuration */ @@ -67,26 +91,26 @@ export interface AppConfig { availableTools?: Record; endpoints?: { /** OpenAI endpoint configuration */ - openAI?: TEndpoint; + openAI?: Partial; /** Google endpoint configuration */ - google?: TEndpoint; + google?: Partial; /** Bedrock endpoint configuration */ - bedrock?: TEndpoint; + bedrock?: Partial; /** Anthropic endpoint configuration */ - anthropic?: TEndpoint; + anthropic?: Partial; /** GPT plugins endpoint configuration */ - gptPlugins?: TEndpoint; + gptPlugins?: Partial; /** Azure OpenAI endpoint configuration */ azureOpenAI?: TAzureConfig; /** Assistants endpoint configuration */ - assistants?: TAssistantEndpoint; + assistants?: Partial; /** Azure assistants endpoint configuration */ - azureAssistants?: TAssistantEndpoint; + azureAssistants?: Partial; /** Agents endpoint configuration */ - [EModelEndpoint.agents]?: TAgentsEndpoint; + [EModelEndpoint.agents]?: Partial; /** Custom endpoints configuration */ [EModelEndpoint.custom]?: TCustomEndpoints; /** Global endpoint configuration */ - all?: TEndpoint; + all?: Partial; }; } diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 4a755beda7..58122cbc55 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -1,6 +1,7 @@ import type { Types } from 'mongoose'; export type ObjectId = Types.ObjectId; +export * from './app'; export * from './user'; export * from './token'; export * from './convo'; @@ -24,3 +25,5 @@ export * from './prompts'; export * from './accessRole'; export * from './aclEntry'; export * from './group'; +/* Web */ +export * from './web'; diff --git a/packages/data-schemas/src/types/role.ts b/packages/data-schemas/src/types/role.ts index b1cb4b87c3..a672e3fae2 100644 --- a/packages/data-schemas/src/types/role.ts +++ b/packages/data-schemas/src/types/role.ts @@ -1,4 +1,5 @@ import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import type { DeepPartial } from 'librechat-data-provider'; import type { Document } from 'mongoose'; import { CursorPaginationParams } from '~/common'; @@ -54,9 +55,6 @@ export interface IRole extends Document { } export type RolePermissions = IRole['permissions']; -type DeepPartial = { - [K in keyof T]?: T[K] extends object ? DeepPartial : T[K]; -}; export type RolePermissionsInput = DeepPartial; export interface CreateRoleRequest { diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts index 3db1a360c6..8b54990cf4 100644 --- a/packages/data-schemas/src/types/share.ts +++ b/packages/data-schemas/src/types/share.ts @@ -8,6 +8,7 @@ export interface ISharedLink { user?: string; messages?: Types.ObjectId[]; shareId?: string; + targetMessageId?: string; isPublic: boolean; createdAt?: Date; updatedAt?: Date; diff --git a/packages/data-schemas/src/types/web.ts b/packages/data-schemas/src/types/web.ts new file mode 100644 index 0000000000..a9cc1f0cc6 --- /dev/null +++ b/packages/data-schemas/src/types/web.ts @@ -0,0 +1,17 @@ +import type { SearchCategories } from 'librechat-data-provider'; + +export type TWebSearchKeys = + | 'serperApiKey' + | 'searxngInstanceUrl' + | 'searxngApiKey' + | 'firecrawlApiKey' + | 'firecrawlApiUrl' + | 'firecrawlVersion' + | 'jinaApiKey' + | 'jinaApiUrl' + | 'cohereApiKey'; + +export type TWebSearchCategories = + | SearchCategories.PROVIDERS + | SearchCategories.SCRAPERS + | SearchCategories.RERANKERS; diff --git a/redis-config/start-cluster.sh b/redis-config/start-cluster.sh index d46227c348..fd8622fcd9 100755 --- a/redis-config/start-cluster.sh +++ b/redis-config/start-cluster.sh @@ -35,7 +35,7 @@ redis-server redis-7002.conf --daemonize yes redis-server redis-7003.conf --daemonize yes # Wait for nodes to start -sleep 3 +sleep 5 # Check if all nodes are running NODES_RUNNING=0 @@ -66,10 +66,14 @@ fi # Initialize the cluster echo "🔧 Initializing cluster..." -echo "yes" | redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 > /dev/null +echo "yes" | redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 2>&1 | tee /tmp/cluster-init.log || { + echo "❌ Cluster creation command failed. Output:" + cat /tmp/cluster-init.log + exit 1 +} # Wait for cluster to stabilize -sleep 3 +sleep 5 # Verify cluster status if redis-cli -p 7001 cluster info | grep -q "cluster_state:ok"; then @@ -80,5 +84,10 @@ if redis-cli -p 7001 cluster info | grep -q "cluster_state:ok"; then echo " Stop: ./stop-cluster.sh" else echo "❌ Cluster initialization failed!" + echo "📊 Cluster info from node 7001:" + redis-cli -p 7001 cluster info + echo "" + echo "📊 Cluster nodes from node 7001:" + redis-cli -p 7001 cluster nodes exit 1 fi \ No newline at end of file