diff --git a/.env.example b/.env.example index d4007651fb..f1666fb763 100644 --- a/.env.example +++ b/.env.example @@ -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/.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/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 32c76523f7..5c6561396e 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -3,6 +3,7 @@ const fetch = require('node-fetch'); const { logger } = require('@librechat/data-schemas'); const { getBalanceConfig, + extractFileContext, encodeAndFormatAudios, encodeAndFormatVideos, encodeAndFormatDocuments, @@ -10,6 +11,7 @@ const { const { Constants, ErrorTypes, + FileSources, ContentTypes, excludedKeys, EModelEndpoint, @@ -21,6 +23,7 @@ const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require 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'); @@ -1245,27 +1248,62 @@ class BaseClient { 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: [], - documents: [], 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, documentFiles, videoFiles, audioFiles] = await Promise.all([ + const [imageFiles] = await Promise.all([ categorizedAttachments.images.length > 0 ? this.addImageURLs(message, categorizedAttachments.images) : Promise.resolve([]), @@ -1280,7 +1318,8 @@ class BaseClient { : Promise.resolve([]), ]); - const allFiles = [...imageFiles, ...documentFiles, ...videoFiles, ...audioFiles]; + allFiles.push(...imageFiles); + const seenFileIds = new Set(); const uniqueFiles = []; @@ -1345,6 +1384,7 @@ class BaseClient { {}, ); + await this.addFileContextToMessage(message, files); await this.processAttachments(message, files); this.message_file_map[message.messageId] = files; 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/models/tx.js b/api/models/tx.js index 282d58c8fc..462396d860 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -125,7 +125,7 @@ const tokenValues = Object.assign( '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.3, completion: 2.5 }, - 'gemini-2.5-flash-lite': { prompt: 0.075, completion: 0.4 }, + 'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 }, '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 }, diff --git a/api/package.json b/api/package.json index 73cb0633e9..f0b654b1af 100644 --- a/api/package.json +++ b/api/package.json @@ -93,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/UserController.js b/api/server/controllers/UserController.js index dc38c59721..31295387ed 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -327,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); } @@ -344,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 a9f5543a61..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.processAttachments( - 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))) { 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/config.js b/api/server/routes/config.js index 8674769643..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); 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/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/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/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 f7220715f6..5e945f0e36 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,7 +519,6 @@ 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'); } 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/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/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/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index fa56cdf08b..a3e5a8d304 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -118,7 +118,7 @@ const AttachFileMenu = ({ const currentProvider = provider || endpoint; - if (isDocumentSupportedProvider(endpointType || currentProvider)) { + if (isDocumentSupportedProvider(currentProvider || endpointType)) { items.push({ label: localize('com_ui_upload_provider'), onClick: () => { diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index 209972e4a8..d9003de3dc 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil'; import { OGDialog, OGDialogTemplate } from '@librechat/client'; import { EToolResources, + EModelEndpoint, defaultAgentCapabilities, isDocumentSupportedProvider, } from 'librechat-data-provider'; @@ -56,12 +57,23 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD const currentProvider = provider || endpoint; // Check if provider supports document upload - if (isDocumentSupportedProvider(endpointType || currentProvider)) { + 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: true, // Allow for both images and documents + condition: validFileTypes, }); } else { // Only show image upload option if all files are images and provider doesn't support documents 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/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index ad38f1ee40..f056fccc98 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -97,7 +97,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..ce88687d23 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -96,7 +96,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..82fa2e746b 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/FontSizeSelector.tsx @@ -20,9 +20,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/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 eea549d7a6..cff9e9635b 100644 --- a/client/src/hooks/Agents/useAgentToolPermissions.ts +++ b/client/src/hooks/Agents/useAgentToolPermissions.ts @@ -37,7 +37,10 @@ export default function useAgentToolPermissions( [agentData?.tools, selectedAgent?.tools], ); - const provider = useMemo(() => selectedAgent?.provider, [selectedAgent?.provider]); + const provider = useMemo( + () => agentData?.provider || selectedAgent?.provider, + [agentData?.provider, selectedAgent?.provider], + ); const fileSearchAllowedByAgent = useMemo(() => { // Check ephemeral agent settings 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/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index dd8ce11aa5..55f43fa820 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -51,6 +51,7 @@ const useNavigateToConvo = (index = 0) => { hasSetConversation.current = true; setSubmission(null); if (resetLatestMessage) { + logger.log('latest_message', 'Clearing all latest messages'); clearAllLatestMessages(); } 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 f4624a156b..2ede5df176 100644 --- a/client/src/locales/ca/translation.json +++ b/client/src/locales/ca/translation.json @@ -273,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.", @@ -303,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ç", diff --git a/client/src/locales/cs/translation.json b/client/src/locales/cs/translation.json index cd838f4015..027027f156 100644 --- a/client/src/locales/cs/translation.json +++ b/client/src/locales/cs/translation.json @@ -189,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ě.", @@ -217,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 2aeada60bc..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,6 +60,7 @@ "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.", @@ -361,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.", @@ -406,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", @@ -831,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", @@ -1023,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", @@ -1225,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", @@ -1263,6 +1266,8 @@ "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 c4b8481398..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.", @@ -365,6 +365,7 @@ "com_error_files_process": "An error occurred while processing the file.", "com_error_files_upload": "An error occurred while uploading the file.", "com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.", + "com_error_files_upload_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.", @@ -408,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", @@ -561,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", @@ -760,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", @@ -858,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", @@ -892,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", @@ -954,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", @@ -973,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}}", @@ -1067,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.", @@ -1096,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", @@ -1104,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", @@ -1117,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...", @@ -1218,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", @@ -1279,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 66da09910a..bc914e50b6 100644 --- a/client/src/locales/es/translation.json +++ b/client/src/locales/es/translation.json @@ -309,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.", @@ -337,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", 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 8ed9bd27e9..2321acfdd1 100644 --- a/client/src/locales/fr/translation.json +++ b/client/src/locales/fr/translation.json @@ -1,35 +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", @@ -41,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", @@ -211,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", @@ -297,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...", @@ -333,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", @@ -455,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", @@ -499,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", @@ -516,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", @@ -534,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", @@ -546,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.", @@ -553,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", @@ -571,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", @@ -591,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 :", @@ -600,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", @@ -612,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", @@ -668,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", @@ -708,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", @@ -725,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", @@ -735,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", @@ -810,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", @@ -833,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", @@ -845,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", @@ -879,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.", @@ -998,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", @@ -1041,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 bfc8219f97..5f4181a07c 100644 --- a/client/src/locales/it/translation.json +++ b/client/src/locales/it/translation.json @@ -330,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.", @@ -357,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 bdaccdd764..e7968fd719 100644 --- a/client/src/locales/pt-BR/translation.json +++ b/client/src/locales/pt-BR/translation.json @@ -354,7 +354,6 @@ "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.", @@ -382,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", diff --git a/client/src/locales/pt-PT/translation.json b/client/src/locales/pt-PT/translation.json index a9b63cfa05..eebebdd270 100644 --- a/client/src/locales/pt-PT/translation.json +++ b/client/src/locales/pt-PT/translation.json @@ -333,7 +333,6 @@ "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.", @@ -379,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", diff --git a/client/src/locales/ru/translation.json b/client/src/locales/ru/translation.json index f8a2a1878b..4025d4a95c 100644 --- a/client/src/locales/ru/translation.json +++ b/client/src/locales/ru/translation.json @@ -4,7 +4,6 @@ "com_a11y_ai_composing": "ИИ продолжает составлять ответ", "com_a11y_end": "ИИ закончил свой ответ", "com_a11y_start": "ИИ начал отвечать", - "com_agents_agent_card_label": "{{name}} агент. {{description}}", "com_agents_all": "Все агенты", "com_agents_all_category": "Все", "com_agents_all_description": "Посмотреть всех общих агентов по всем категориям", @@ -362,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": "Произошла ошибка при проверке файла", @@ -408,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": "Баланс", @@ -1026,6 +1023,7 @@ "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": "Результаты не найдены", @@ -1255,7 +1253,6 @@ "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": "Cohere", "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", diff --git a/client/src/locales/sv/translation.json b/client/src/locales/sv/translation.json index ec31e5b793..abb227704b 100644 --- a/client/src/locales/sv/translation.json +++ b/client/src/locales/sv/translation.json @@ -542,21 +542,5 @@ "com_ui_terms_and_conditions": "Villkor för användning", "com_ui_unarchive": "Avarkivera", "com_ui_unarchive_error": "Kunde inte avarkivera chatt", - "com_ui_upload_success": "Uppladdningen av filen lyckades", - "com_ui_web_search_reranker": "Reranker", - "com_ui_web_search_reranker_cohere": "Cohere", - "com_ui_web_search_reranker_cohere_key": "Hämta din Cohere API-nyckel", - "com_ui_web_search_reranker_jina": "Jina AI", - "com_ui_web_search_reranker_jina_key": "Hämta din Jina API-nyckel", - "com_ui_web_search_scraper": "Skrapare", - "com_ui_web_search_scraper_firecrawl": "Firecrawl API", - "com_ui_web_search_scraper_firecrawl_key": "Hämta din Firecrawl API-nyckel", - "com_ui_web_search_searxng_api_key": "Ange SearXNG API-nyckel (valfritt)", - "com_ui_web_searching_again": "Söker på webben igen", - "com_ui_weekend_morning": "Trevlig helg", - "com_ui_write": "Skrift", - "com_ui_x_selected": "{{0}} utvalda", - "com_ui_yes": "Ja", - "com_ui_zoom": "Zoom", - "com_user_message": "Du" + "com_ui_upload_success": "Uppladdningen av filen lyckades" } 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 beba21af65..736caa7dba 100644 --- a/client/src/locales/zh-Hans/translation.json +++ b/client/src/locales/zh-Hans/translation.json @@ -363,7 +363,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": "验证文件时出错。", @@ -409,7 +408,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-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/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}` : ''}`; } @@ -109,19 +95,34 @@ const standardDependencies = { 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/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 caae46d923..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) { @@ -65,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/helm/librechat/templates/deployment.yaml b/helm/librechat/templates/deployment.yaml index 5dc28a0484..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 }} diff --git a/helm/librechat/values.yaml b/helm/librechat/values.yaml index 7dacb1386b..2560535504 100755 --- a/helm/librechat/values.yaml +++ b/helm/librechat/values.yaml @@ -153,6 +153,8 @@ lifecycle: {} podAnnotations: {} podLabels: {} +deploymentAnnotations: {} +deploymentLabels: {} podSecurityContext: fsGroup: 2000 diff --git a/librechat.example.yaml b/librechat.example.yaml index 6f034910dc..04e088aa38 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -229,13 +229,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' @@ -320,6 +318,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 72bc3a7af6..1a789de54d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -109,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", @@ -2491,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", @@ -41973,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", diff --git a/package.json b/package.json index 458f4d2985..4a69267be2 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "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", 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/index.ts b/packages/api/src/files/index.ts index 3d1a3118e3..9111b8d5e3 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -1,4 +1,5 @@ export * from './audio'; +export * from './context'; export * from './encode'; export * from './mistral/crud'; export * from './ocr'; 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/__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__/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/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/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/data-provider/src/config.ts b/packages/data-provider/src/config.ts index d212f559fe..c3f872eaec 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -214,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 */ @@ -239,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(), }) @@ -299,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(), }), @@ -636,6 +644,7 @@ export type TStartupConfig = { helpAndFaqURL: string; customFooter?: string; modelSpecs?: TSpecsConfig; + modelDescriptions?: Record>; sharedLinksEnabled: boolean; publicSharedLinksEnabled: boolean; analyticsGtmId?: string; @@ -669,6 +678,7 @@ export type TStartupConfig = { } >; mcpPlaceholder?: string; + conversationImportMaxFileSize?: number; }; export enum OCRStrategy { 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/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-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/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/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;