Merge branch 'main' into feature/entra-id-azure-integration

This commit is contained in:
victorbjor 2025-10-14 08:46:46 +02:00 committed by GitHub
commit 631f4b3703
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
151 changed files with 3677 additions and 1242 deletions

View file

@ -196,7 +196,7 @@ GOOGLE_KEY=user_provided
#============# #============#
OPENAI_API_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 DEBUG_OPENAI=false
@ -459,6 +459,9 @@ OPENID_CALLBACK_URL=/oauth/openid/callback
OPENID_REQUIRED_ROLE= OPENID_REQUIRED_ROLE=
OPENID_REQUIRED_ROLE_TOKEN_KIND= OPENID_REQUIRED_ROLE_TOKEN_KIND=
OPENID_REQUIRED_ROLE_PARAMETER_PATH= 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 # Set to determine which user info property returned from OpenID Provider to store as the User's username
OPENID_USERNAME_CLAIM= OPENID_USERNAME_CLAIM=
# Set to determine which user info property returned from OpenID Provider to store as the User's name # 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 # Google tag manager id
#ANALYTICS_GTM_ID=user provided 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 # # REDIS Options #
#===============# #===============#

View file

@ -1,5 +1,2 @@
#!/usr/bin/env sh
set -e
. "$(dirname -- "$0")/_/husky.sh"
[ -n "$CI" ] && exit 0 [ -n "$CI" ] && exit 0
npx lint-staged --config ./.husky/lint-staged.config.js npx lint-staged --config ./.husky/lint-staged.config.js

View file

@ -3,6 +3,7 @@ const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { const {
getBalanceConfig, getBalanceConfig,
extractFileContext,
encodeAndFormatAudios, encodeAndFormatAudios,
encodeAndFormatVideos, encodeAndFormatVideos,
encodeAndFormatDocuments, encodeAndFormatDocuments,
@ -10,6 +11,7 @@ const {
const { const {
Constants, Constants,
ErrorTypes, ErrorTypes,
FileSources,
ContentTypes, ContentTypes,
excludedKeys, excludedKeys,
EModelEndpoint, EModelEndpoint,
@ -21,6 +23,7 @@ const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { checkBalance } = require('~/models/balanceMethods'); const { checkBalance } = require('~/models/balanceMethods');
const { truncateToolCallOutputs } = require('./prompts'); const { truncateToolCallOutputs } = require('./prompts');
const countTokens = require('~/server/utils/countTokens');
const { getFiles } = require('~/models/File'); const { getFiles } = require('~/models/File');
const TextStream = require('./TextStream'); const TextStream = require('./TextStream');
@ -1245,27 +1248,62 @@ class BaseClient {
return audioResult.files; 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<void>}
*/
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) { async processAttachments(message, attachments) {
const categorizedAttachments = { const categorizedAttachments = {
images: [], images: [],
documents: [],
videos: [], videos: [],
audios: [], audios: [],
documents: [],
}; };
const allFiles = [];
for (const file of attachments) { 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/')) { if (file.type.startsWith('image/')) {
categorizedAttachments.images.push(file); categorizedAttachments.images.push(file);
} else if (file.type === 'application/pdf') { } else if (file.type === 'application/pdf') {
categorizedAttachments.documents.push(file); categorizedAttachments.documents.push(file);
allFiles.push(file);
} else if (file.type.startsWith('video/')) { } else if (file.type.startsWith('video/')) {
categorizedAttachments.videos.push(file); categorizedAttachments.videos.push(file);
allFiles.push(file);
} else if (file.type.startsWith('audio/')) { } else if (file.type.startsWith('audio/')) {
categorizedAttachments.audios.push(file); categorizedAttachments.audios.push(file);
allFiles.push(file);
} }
} }
const [imageFiles, documentFiles, videoFiles, audioFiles] = await Promise.all([ const [imageFiles] = await Promise.all([
categorizedAttachments.images.length > 0 categorizedAttachments.images.length > 0
? this.addImageURLs(message, categorizedAttachments.images) ? this.addImageURLs(message, categorizedAttachments.images)
: Promise.resolve([]), : Promise.resolve([]),
@ -1280,7 +1318,8 @@ class BaseClient {
: Promise.resolve([]), : Promise.resolve([]),
]); ]);
const allFiles = [...imageFiles, ...documentFiles, ...videoFiles, ...audioFiles]; allFiles.push(...imageFiles);
const seenFileIds = new Set(); const seenFileIds = new Set();
const uniqueFiles = []; const uniqueFiles = [];
@ -1345,6 +1384,7 @@ class BaseClient {
{}, {},
); );
await this.addFileContextToMessage(message, files);
await this.processAttachments(message, files); await this.processAttachments(message, files);
this.message_file_map[message.messageId] = files; this.message_file_map[message.messageId] = files;

View file

@ -3,6 +3,7 @@ const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider');
const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate'); const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate');
const { components } = require('~/app/clients/prompts/shadcn-docs/components'); const { components } = require('~/app/clients/prompts/shadcn-docs/components');
/** @deprecated */
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations. 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:
</assistant_response> </assistant_response>
</example> </example>
</examples>`; </examples>`;
const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations. 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. 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" - SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags. - 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 - 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" - Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags. - The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react" - 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" - SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags. - 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 - 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" - Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags. - The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react" - React Components: "application/vnd.react"

View file

@ -125,7 +125,7 @@ const tokenValues = Object.assign(
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing 'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemini-2.5-pro': { prompt: 1.25, completion: 10 }, 'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 }, '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-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-8b': { prompt: 0.075, completion: 0.3 },
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 }, 'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },

View file

@ -93,7 +93,7 @@
"multer": "^2.0.2", "multer": "^2.0.2",
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^6.9.15", "nodemailer": "^7.0.9",
"ollama": "^0.5.0", "ollama": "^0.5.0",
"openai": "^5.10.1", "openai": "^5.10.1",
"openid-client": "^6.5.0", "openid-client": "^6.5.0",

View file

@ -327,16 +327,23 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
const revocationEndpointAuthMethodsSupported = const revocationEndpointAuthMethodsSupported =
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ?? serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
clientMetadata.revocation_endpoint_auth_methods_supported; clientMetadata.revocation_endpoint_auth_methods_supported;
const oauthHeaders = serverConfig.oauth_headers ?? {};
if (tokens?.access_token) { if (tokens?.access_token) {
try { try {
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', { await MCPOAuthHandler.revokeOAuthToken(
serverName,
tokens.access_token,
'access',
{
serverUrl: serverConfig.url, serverUrl: serverConfig.url,
clientId: clientInfo.client_id, clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '', clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint, revocationEndpoint,
revocationEndpointAuthMethodsSupported, revocationEndpointAuthMethodsSupported,
}); },
oauthHeaders,
);
} catch (error) { } catch (error) {
logger.error(`Error revoking OAuth access token for ${serverName}:`, 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) { if (tokens?.refresh_token) {
try { try {
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', { await MCPOAuthHandler.revokeOAuthToken(
serverName,
tokens.refresh_token,
'refresh',
{
serverUrl: serverConfig.url, serverUrl: serverConfig.url,
clientId: clientInfo.client_id, clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '', clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint, revocationEndpoint,
revocationEndpointAuthMethodsSupported, revocationEndpointAuthMethodsSupported,
}); },
oauthHeaders,
);
} catch (error) { } catch (error) {
logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error); logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
} }

View file

@ -211,16 +211,13 @@ class AgentClient extends BaseClient {
* @returns {Promise<Array<Partial<MongoFile>>>} * @returns {Promise<Array<Partial<MongoFile>>>}
*/ */
async addImageURLs(message, attachments) { async addImageURLs(message, attachments) {
const { files, text, image_urls } = await encodeAndFormat( const { files, image_urls } = await encodeAndFormat(
this.options.req, this.options.req,
attachments, attachments,
this.options.agent.provider, this.options.agent.provider,
VisionModes.agents, VisionModes.agents,
); );
message.image_urls = image_urls.length ? image_urls : undefined; message.image_urls = image_urls.length ? image_urls : undefined;
if (text && text.length) {
message.ocr = text;
}
return files; return files;
} }
@ -248,19 +245,18 @@ class AgentClient extends BaseClient {
if (this.options.attachments) { if (this.options.attachments) {
const attachments = await this.options.attachments; const attachments = await this.options.attachments;
const latestMessage = orderedMessages[orderedMessages.length - 1];
if (this.message_file_map) { if (this.message_file_map) {
this.message_file_map[orderedMessages[orderedMessages.length - 1].messageId] = attachments; this.message_file_map[latestMessage.messageId] = attachments;
} else { } else {
this.message_file_map = { this.message_file_map = {
[orderedMessages[orderedMessages.length - 1].messageId]: attachments, [latestMessage.messageId]: attachments,
}; };
} }
const files = await this.processAttachments( await this.addFileContextToMessage(latestMessage, attachments);
orderedMessages[orderedMessages.length - 1], const files = await this.processAttachments(latestMessage, attachments);
attachments,
);
this.options.attachments = files; this.options.attachments = files;
} }
@ -280,21 +276,21 @@ class AgentClient extends BaseClient {
assistantName: this.options?.modelLabel, assistantName: this.options?.modelLabel,
}); });
if (message.ocr && i !== orderedMessages.length - 1) { if (message.fileContext && i !== orderedMessages.length - 1) {
if (typeof formattedMessage.content === 'string') { if (typeof formattedMessage.content === 'string') {
formattedMessage.content = message.ocr + '\n' + formattedMessage.content; formattedMessage.content = message.fileContext + '\n' + formattedMessage.content;
} else { } else {
const textPart = formattedMessage.content.find((part) => part.type === 'text'); const textPart = formattedMessage.content.find((part) => part.type === 'text');
textPart textPart
? (textPart.text = message.ocr + '\n' + textPart.text) ? (textPart.text = message.fileContext + '\n' + textPart.text)
: formattedMessage.content.unshift({ type: 'text', text: message.ocr }); : formattedMessage.content.unshift({ type: 'text', text: message.fileContext });
} }
} else if (message.ocr && i === orderedMessages.length - 1) { } else if (message.fileContext && i === orderedMessages.length - 1) {
systemContent = [systemContent, message.ocr].join('\n'); systemContent = [systemContent, message.fileContext].join('\n');
} }
const needsTokenCount = 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 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))) { if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) {

View file

@ -127,8 +127,13 @@ describe('MCP Routes', () => {
}), }),
}; };
const mockMcpManager = {
getRawConfig: jest.fn().mockReturnValue({}),
};
getLogStores.mockReturnValue({}); getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({ MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
authorizationUrl: 'https://oauth.example.com/auth', authorizationUrl: 'https://oauth.example.com/auth',
@ -146,6 +151,7 @@ describe('MCP Routes', () => {
'test-server', 'test-server',
'https://test-server.com', 'https://test-server.com',
'test-user-id', 'test-user-id',
{},
{ clientId: 'test-client-id' }, { clientId: 'test-client-id' },
); );
}); });
@ -314,6 +320,7 @@ describe('MCP Routes', () => {
}; };
const mockMcpManager = { const mockMcpManager = {
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection), getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
getRawConfig: jest.fn().mockReturnValue({}),
}; };
require('~/config').getMCPManager.mockReturnValue(mockMcpManager); require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -336,6 +343,7 @@ describe('MCP Routes', () => {
'test-flow-id', 'test-flow-id',
'test-auth-code', 'test-auth-code',
mockFlowManager, mockFlowManager,
{},
); );
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith( expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -392,6 +400,11 @@ describe('MCP Routes', () => {
getLogStores.mockReturnValue({}); getLogStores.mockReturnValue({});
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); 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({ const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
code: 'test-auth-code', code: 'test-auth-code',
state: 'test-flow-id', state: 'test-flow-id',
@ -427,6 +440,7 @@ describe('MCP Routes', () => {
const mockMcpManager = { const mockMcpManager = {
getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')), getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
getRawConfig: jest.fn().mockReturnValue({}),
}; };
require('~/config').getMCPManager.mockReturnValue(mockMcpManager); require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -1234,6 +1248,7 @@ describe('MCP Routes', () => {
getUserConnection: jest.fn().mockResolvedValue({ getUserConnection: jest.fn().mockResolvedValue({
fetchTools: jest.fn().mockResolvedValue([]), fetchTools: jest.fn().mockResolvedValue([]),
}), }),
getRawConfig: jest.fn().mockReturnValue({}),
}; };
require('~/config').getMCPManager.mockReturnValue(mockMcpManager); require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
@ -1281,6 +1296,7 @@ describe('MCP Routes', () => {
.fn() .fn()
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]), .mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
}), }),
getRawConfig: jest.fn().mockReturnValue({}),
}; };
require('~/config').getMCPManager.mockReturnValue(mockMcpManager); require('~/config').getMCPManager.mockReturnValue(mockMcpManager);

View file

@ -115,6 +115,9 @@ router.get('/', async function (req, res) {
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE, sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE, sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
openidReuseTokens, 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); const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);

View file

@ -65,6 +65,7 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
serverName, serverName,
serverUrl, serverUrl,
userId, userId,
getOAuthHeaders(serverName),
oauthConfig, oauthConfig,
); );
@ -132,7 +133,12 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
}); });
logger.debug('[MCP OAuth] Completing OAuth flow'); 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'); logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
/** Persist tokens immediately so reconnection uses fresh credentials */ /** 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; module.exports = router;

View file

@ -99,7 +99,8 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
router.post('/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => {
try { 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) { if (created) {
res.status(200).json(created); res.status(200).json(created);
} else { } else {

View file

@ -85,7 +85,9 @@ async function loadConfigModels(req) {
} }
if (Array.isArray(models.default)) { if (Array.isArray(models.default)) {
modelsConfig[name] = models.default; modelsConfig[name] = models.default.map((model) =>
typeof model === 'string' ? model : model.name,
);
} }
} }

View file

@ -254,8 +254,8 @@ describe('loadConfigModels', () => {
// For groq and ollama, since the apiKey is "user_provided", models should not be fetched // 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, // Depending on your implementation's behavior regarding "default" models without fetching,
// you may need to adjust the following assertions: // you may need to adjust the following assertions:
expect(result.groq).toBe(exampleConfig.endpoints.custom[2].models.default); expect(result.groq).toEqual(exampleConfig.endpoints.custom[2].models.default);
expect(result.ollama).toBe(exampleConfig.endpoints.custom[3].models.default); expect(result.ollama).toEqual(exampleConfig.endpoints.custom[3].models.default);
// Verifying fetchModels was not called for groq and ollama // Verifying fetchModels was not called for groq and ollama
expect(fetchModels).not.toHaveBeenCalledWith( expect(fetchModels).not.toHaveBeenCalledWith(

View file

@ -1,16 +1,14 @@
const axios = require('axios'); const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { logAxiosError, processTextWithTokenLimit } = require('@librechat/api');
const { const {
FileSources, FileSources,
VisionModes, VisionModes,
ImageDetail, ImageDetail,
ContentTypes, ContentTypes,
EModelEndpoint, EModelEndpoint,
mergeFileConfig,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const countTokens = require('~/server/utils/countTokens');
/** /**
* Converts a readable stream to a base64 encoded string. * Converts a readable stream to a base64 encoded string.
@ -88,15 +86,14 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
* @param {Array<MongoFile>} files - The array of files to encode and format. * @param {Array<MongoFile>} files - The array of files to encode and format.
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image. * @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
* @param {string} [mode] - Optional: The endpoint mode 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) { async function encodeAndFormat(req, files, endpoint, mode) {
const promises = []; const promises = [];
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */ /** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
const encodingMethods = {}; const encodingMethods = {};
/** @type {{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */ /** @type {{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */
const result = { const result = {
text: '',
files: [], files: [],
image_urls: [], image_urls: [],
}; };
@ -105,29 +102,9 @@ async function encodeAndFormat(req, files, endpoint, mode) {
return result; return result;
} }
const fileTokenLimit =
req.body?.fileTokenLimit ?? mergeFileConfig(req.config?.fileConfig).fileTokenLimit;
for (let file of files) { for (let file of files) {
/** @type {FileSources} */ /** @type {FileSources} */
const source = file.source ?? FileSources.local; 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) { if (!file.height) {
promises.push([file, null]); promises.push([file, null]);
@ -165,10 +142,6 @@ async function encodeAndFormat(req, files, endpoint, mode) {
promises.push(preparePayload(req, file)); promises.push(preparePayload(req, file));
} }
if (result.text) {
result.text += '\n```';
}
const detail = req.body.imageDetail ?? ImageDetail.auto; const detail = req.body.imageDetail ?? ImageDetail.auto;
/** @type {Array<[MongoFile, string]>} */ /** @type {Array<[MongoFile, string]>} */

View file

@ -508,7 +508,10 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
const { file } = req; const { file } = req;
const appConfig = req.config; const appConfig = req.config;
const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata; 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'); 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'); throw new Error('Image uploads are not supported for file search tool resources');
} }
let messageAttachment = !!metadata.message_file;
if (!messageAttachment && !agent_id) { if (!messageAttachment && !agent_id) {
throw new Error('No agent ID provided for agent file upload'); throw new Error('No agent ID provided for agent file upload');
} }

View file

@ -10,6 +10,15 @@ const importConversations = async (job) => {
const { filepath, requestUserId } = job; const { filepath, requestUserId } = job;
try { try {
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`); 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 fileData = await fs.readFile(filepath, 'utf8');
const jsonData = JSON.parse(fileData); const jsonData = JSON.parse(fileData);
const importer = getImporter(jsonData); const importer = getImporter(jsonData);
@ -17,6 +26,7 @@ const importConversations = async (job) => {
logger.debug(`user: ${requestUserId} | Finished importing conversations`); logger.debug(`user: ${requestUserId} | Finished importing conversations`);
} catch (error) { } catch (error) {
logger.error(`user: ${requestUserId} | Failed to import conversation: `, 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 { } finally {
try { try {
await fs.unlink(filepath); await fs.unlink(filepath);

View file

@ -1,4 +1,5 @@
const undici = require('undici'); const undici = require('undici');
const { get } = require('lodash');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const passport = require('passport'); const passport = require('passport');
const client = require('openid-client'); const client = require('openid-client');
@ -329,6 +330,12 @@ async function setupOpenId() {
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata', : '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( const openidLogin = new CustomOpenIDStrategy(
{ {
config: openidConfig, config: openidConfig,
@ -386,20 +393,19 @@ async function setupOpenId() {
} else if (requiredRoleTokenKind === 'id') { } else if (requiredRoleTokenKind === 'id') {
decodedToken = jwtDecode(tokenset.id_token); 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( 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))) { 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')) { if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */ /** @type {string | undefined} */
const imageUrl = userinfo.picture; const imageUrl = userinfo.picture;

View file

@ -125,6 +125,9 @@ describe('setupOpenId', () => {
process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id'; 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_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM; delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY; delete process.env.PROXY;
@ -133,6 +136,7 @@ describe('setupOpenId', () => {
// Default jwtDecode mock returns a token that includes the required role. // Default jwtDecode mock returns a token that includes the required role.
jwtDecode.mockReturnValue({ jwtDecode.mockReturnValue({
roles: ['requiredRole'], roles: ['requiredRole'],
permissions: ['admin'],
}); });
// By default, assume that no user is found, so createUser will be called // 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.usePKCE).toBe(false);
expect(callOptions.params?.code_challenge_method).toBeUndefined(); 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);
});
});
}); });

View file

@ -144,9 +144,10 @@ export const ArtifactCodeEditor = function ({
} }
return { return {
...sharedOptions, ...sharedOptions,
activeFile: '/' + fileKey,
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL, bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
}; };
}, [config, template]); }, [config, template, fileKey]);
const [readOnly, setReadOnly] = useState(isSubmitting ?? false); const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
useEffect(() => { useEffect(() => {
setReadOnly(isSubmitting ?? false); setReadOnly(isSubmitting ?? false);

View file

@ -13,7 +13,6 @@ export const ArtifactPreview = memo(function ({
files, files,
fileKey, fileKey,
template, template,
isMermaid,
sharedProps, sharedProps,
previewRef, previewRef,
currentCode, currentCode,
@ -21,7 +20,6 @@ export const ArtifactPreview = memo(function ({
}: { }: {
files: ArtifactFiles; files: ArtifactFiles;
fileKey: string; fileKey: string;
isMermaid: boolean;
template: SandpackProviderProps['template']; template: SandpackProviderProps['template'];
sharedProps: Partial<SandpackProviderProps>; sharedProps: Partial<SandpackProviderProps>;
previewRef: React.MutableRefObject<SandpackPreviewRef>; previewRef: React.MutableRefObject<SandpackPreviewRef>;
@ -56,15 +54,6 @@ export const ArtifactPreview = memo(function ({
return _options; return _options;
}, [startupConfig, template]); }, [startupConfig, template]);
const style: PreviewProps['style'] | undefined = useMemo(() => {
if (isMermaid) {
return {
backgroundColor: '#282C34',
};
}
return;
}, [isMermaid]);
if (Object.keys(artifactFiles).length === 0) { if (Object.keys(artifactFiles).length === 0) {
return null; return null;
} }
@ -84,7 +73,6 @@ export const ArtifactPreview = memo(function ({
showRefreshButton={false} showRefreshButton={false}
tabIndex={0} tabIndex={0}
ref={previewRef} ref={previewRef}
style={style}
/> />
</SandpackProvider> </SandpackProvider>
); );

View file

@ -8,17 +8,14 @@ import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
import { ArtifactCodeEditor } from './ArtifactCodeEditor'; import { ArtifactCodeEditor } from './ArtifactCodeEditor';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
import { ArtifactPreview } from './ArtifactPreview'; import { ArtifactPreview } from './ArtifactPreview';
import { MermaidMarkdown } from './MermaidMarkdown';
import { cn } from '~/utils'; import { cn } from '~/utils';
export default function ArtifactTabs({ export default function ArtifactTabs({
artifact, artifact,
isMermaid,
editorRef, editorRef,
previewRef, previewRef,
}: { }: {
artifact: Artifact; artifact: Artifact;
isMermaid: boolean;
editorRef: React.MutableRefObject<CodeEditorRef>; editorRef: React.MutableRefObject<CodeEditorRef>;
previewRef: React.MutableRefObject<SandpackPreviewRef>; previewRef: React.MutableRefObject<SandpackPreviewRef>;
}) { }) {
@ -44,10 +41,8 @@ export default function ArtifactTabs({
value="code" value="code"
id="artifacts-code" id="artifacts-code"
className={cn('flex-grow overflow-auto')} className={cn('flex-grow overflow-auto')}
tabIndex={-1}
> >
{isMermaid ? (
<MermaidMarkdown content={content} isSubmitting={isSubmitting} />
) : (
<ArtifactCodeEditor <ArtifactCodeEditor
files={files} files={files}
fileKey={fileKey} fileKey={fileKey}
@ -56,14 +51,12 @@ export default function ArtifactTabs({
editorRef={editorRef} editorRef={editorRef}
sharedProps={sharedProps} sharedProps={sharedProps}
/> />
)}
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="preview" className="flex-grow overflow-auto"> <Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
<ArtifactPreview <ArtifactPreview
files={files} files={files}
fileKey={fileKey} fileKey={fileKey}
template={template} template={template}
isMermaid={isMermaid}
previewRef={previewRef} previewRef={previewRef}
sharedProps={sharedProps} sharedProps={sharedProps}
currentCode={currentCode} currentCode={currentCode}

View file

@ -27,7 +27,6 @@ export default function Artifacts() {
const { const {
activeTab, activeTab,
isMermaid,
setActiveTab, setActiveTab,
currentIndex, currentIndex,
cycleArtifact, cycleArtifact,
@ -116,7 +115,6 @@ export default function Artifacts() {
</div> </div>
{/* Content */} {/* Content */}
<ArtifactTabs <ArtifactTabs
isMermaid={isMermaid}
artifact={currentArtifact} artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>} editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>} previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}

View file

@ -1,11 +0,0 @@
import { CodeMarkdown } from './Code';
export function MermaidMarkdown({
content,
isSubmitting,
}: {
content: string;
isSubmitting: boolean;
}) {
return <CodeMarkdown content={`\`\`\`mermaid\n${content}\`\`\``} isSubmitting={isSubmitting} />;
}

View file

@ -19,9 +19,11 @@ export function BrowserVoiceDropdown() {
} }
}; };
const labelId = 'browser-voice-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_voice_select')}</div> <div id={labelId}>{localize('com_nav_voice_select')}</div>
<Dropdown <Dropdown
key={`browser-voice-dropdown-${voices.length}`} key={`browser-voice-dropdown-${voices.length}`}
value={voice ?? ''} value={voice ?? ''}
@ -30,6 +32,7 @@ export function BrowserVoiceDropdown() {
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]" sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="BrowserVoiceDropdown" testId="BrowserVoiceDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );
@ -48,9 +51,11 @@ export function ExternalVoiceDropdown() {
} }
}; };
const labelId = 'external-voice-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_voice_select')}</div> <div id={labelId}>{localize('com_nav_voice_select')}</div>
<Dropdown <Dropdown
key={`external-voice-dropdown-${voices.length}`} key={`external-voice-dropdown-${voices.length}`}
value={voice ?? ''} value={voice ?? ''}
@ -59,6 +64,7 @@ export function ExternalVoiceDropdown() {
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]" sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="ExternalVoiceDropdown" testId="ExternalVoiceDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -118,7 +118,7 @@ const AttachFileMenu = ({
const currentProvider = provider || endpoint; const currentProvider = provider || endpoint;
if (isDocumentSupportedProvider(endpointType || currentProvider)) { if (isDocumentSupportedProvider(currentProvider || endpointType)) {
items.push({ items.push({
label: localize('com_ui_upload_provider'), label: localize('com_ui_upload_provider'),
onClick: () => { onClick: () => {

View file

@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil';
import { OGDialog, OGDialogTemplate } from '@librechat/client'; import { OGDialog, OGDialogTemplate } from '@librechat/client';
import { import {
EToolResources, EToolResources,
EModelEndpoint,
defaultAgentCapabilities, defaultAgentCapabilities,
isDocumentSupportedProvider, isDocumentSupportedProvider,
} from 'librechat-data-provider'; } from 'librechat-data-provider';
@ -56,12 +57,23 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
const currentProvider = provider || endpoint; const currentProvider = provider || endpoint;
// Check if provider supports document upload // 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({ _options.push({
label: localize('com_ui_upload_provider'), label: localize('com_ui_upload_provider'),
value: undefined, value: undefined,
icon: <FileImageIcon className="icon-md" />, icon: <FileImageIcon className="icon-md" />,
condition: true, // Allow for both images and documents condition: validFileTypes,
}); });
} else { } else {
// Only show image upload option if all files are images and provider doesn't support documents // Only show image upload option if all files are images and provider doesn't support documents

View file

@ -2,7 +2,12 @@ import React, { useMemo } from 'react';
import type { ModelSelectorProps } from '~/common'; import type { ModelSelectorProps } from '~/common';
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext'; import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
import { ModelSelectorChatProvider } from './ModelSelectorChatContext'; import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components'; import {
renderModelSpecs,
renderEndpoints,
renderSearchResults,
renderCustomGroups,
} from './components';
import { getSelectedIcon, getDisplayValue } from './utils'; import { getSelectedIcon, getDisplayValue } from './utils';
import { CustomMenu as Menu } from './CustomMenu'; import { CustomMenu as Menu } from './CustomMenu';
import DialogManager from './DialogManager'; import DialogManager from './DialogManager';
@ -86,8 +91,15 @@ function ModelSelectorContent() {
renderSearchResults(searchResults, localize, searchValue) 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 ?? [])} {renderEndpoints(mappedEndpoints ?? [])}
{/* Render custom groups (specs with group field not matching any endpoint) */}
{renderCustomGroups(modelSpecs || [], mappedEndpoints ?? [])}
</> </>
)} )}
</Menu> </Menu>

View file

@ -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 (
<Menu
id={`custom-group-${groupName}-menu`}
key={`custom-group-${groupName}`}
className="transition-opacity duration-200 ease-in-out"
label={
<div className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm">
<div className="flex items-center gap-2">
<span className="truncate text-left">{groupName}</span>
</div>
</div>
}
>
{specs.map((spec: TModelSpec) => (
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
))}
</Menu>
);
}
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<string, TModelSpec[]>,
);
// Render each custom group
return Object.entries(customGroups).map(([groupName, specs]) => (
<CustomGroup key={groupName} groupName={groupName} specs={specs} />
));
}

View file

@ -2,10 +2,12 @@ import { useMemo } from 'react';
import { SettingsIcon } from 'lucide-react'; import { SettingsIcon } from 'lucide-react';
import { TooltipAnchor, Spinner } from '@librechat/client'; import { TooltipAnchor, Spinner } from '@librechat/client';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common'; import type { Endpoint } from '~/common';
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu'; import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
import { useModelSelectorContext } from '../ModelSelectorContext'; import { useModelSelectorContext } from '../ModelSelectorContext';
import { renderEndpointModels } from './EndpointModelItem'; import { renderEndpointModels } from './EndpointModelItem';
import { ModelSpecItem } from './ModelSpecItem';
import { filterModels } from '../utils'; import { filterModels } from '../utils';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
@ -57,6 +59,7 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
const { const {
agentsMap, agentsMap,
assistantsMap, assistantsMap,
modelSpecs,
selectedValues, selectedValues,
handleOpenKeyDialog, handleOpenKeyDialog,
handleSelectEndpoint, handleSelectEndpoint,
@ -64,7 +67,19 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
setEndpointSearchValue, setEndpointSearchValue,
endpointRequiresUserKey, endpointRequiresUserKey,
} = useModelSelectorContext(); } = 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 searchValue = endpointSearchValues[endpoint.value] || '';
const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]); const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]);
@ -138,10 +153,17 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
<div className="flex items-center justify-center p-2"> <div className="flex items-center justify-center p-2">
<Spinner /> <Spinner />
</div> </div>
) : filteredModels ? (
renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
) : ( ) : (
endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel) <>
{/* Render modelSpecs for this endpoint */}
{endpointSpecs.map((spec: TModelSpec) => (
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
))}
{/* Render endpoint models */}
{filteredModels
? renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
: endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)}
</>
)} )}
</Menu> </Menu>
); );

View file

@ -36,23 +36,26 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
<MenuItem <MenuItem
key={modelId} key={modelId}
onClick={() => handleSelectModel(endpoint, modelId ?? '')} onClick={() => 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"
> >
<div className="flex items-center gap-2"> <div className="flex w-full min-w-0 items-center gap-2 px-1 py-1">
{avatarUrl ? ( {avatarUrl ? (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full"> <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" /> <img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
</div> </div>
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) && ) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
endpoint.icon ? ( endpoint.icon ? (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full"> <div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
{endpoint.icon} {endpoint.icon}
</div> </div>
) : null} ) : null}
<span>{modelName}</span> <span className="truncate text-left">{modelName}</span>
{isGlobal && (
<EarthIcon className="ml-auto size-4 flex-shrink-0 self-center text-green-400" />
)}
</div> </div>
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
{isSelected && ( {isSelected && (
<div className="flex-shrink-0 self-center">
<svg <svg
width="16" width="16"
height="16" height="16"
@ -68,6 +71,7 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
</div>
)} )}
</MenuItem> </MenuItem>
); );

View file

@ -2,3 +2,4 @@ export * from './ModelSpecItem';
export * from './EndpointModelItem'; export * from './EndpointModelItem';
export * from './EndpointItem'; export * from './EndpointItem';
export * from './SearchResults'; export * from './SearchResults';
export * from './CustomGroup';

View file

@ -97,7 +97,10 @@ const MessageRender = memo(
() => () =>
showCardRender && !isLatestMessage 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); logger.dir(msg);
setLatestMessage(msg!); setLatestMessage(msg!);
} }

View file

@ -28,6 +28,8 @@ const LoadingSpinner = memo(() => {
); );
}); });
LoadingSpinner.displayName = 'LoadingSpinner';
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => { const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
const localize = useLocalize(); const localize = useLocalize();
return ( return (
@ -74,6 +76,7 @@ const Conversations: FC<ConversationsProps> = ({
isLoading, isLoading,
isSearchLoading, isSearchLoading,
}) => { }) => {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)'); const isSmallScreen = useMediaQuery('(max-width: 768px)');
const convoHeight = isSmallScreen ? 44 : 34; const convoHeight = isSmallScreen ? 44 : 34;
@ -181,7 +184,7 @@ const Conversations: FC<ConversationsProps> = ({
{isSearchLoading ? ( {isSearchLoading ? (
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<Spinner className="text-text-primary" /> <Spinner className="text-text-primary" />
<span className="ml-2 text-text-primary">Loading...</span> <span className="ml-2 text-text-primary">{localize('com_ui_loading')}</span>
</div> </div>
) : ( ) : (
<div className="flex-1"> <div className="flex-1">

View file

@ -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', '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', isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
)} )}
role="listitem" role="button"
tabIndex={0} tabIndex={renaming ? -1 : 0}
aria-label={`${title || localize('com_ui_untitled')} conversation`}
onClick={(e) => { onClick={(e) => {
if (renaming) { if (renaming) {
return; return;
@ -149,7 +150,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
if (renaming) { if (renaming) {
return; return;
} }
if (e.key === 'Enter') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleNavigation(false); handleNavigation(false);
} }
}} }}

View file

@ -40,8 +40,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
e.stopPropagation(); e.stopPropagation();
onRename(); onRename();
}} }}
role="button" aria-label={title || localize('com_ui_untitled')}
aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')}
> >
{title || localize('com_ui_untitled')} {title || localize('com_ui_untitled')}
</div> </div>

View file

@ -201,6 +201,7 @@ function ConvoOptions({
<Menu.MenuButton <Menu.MenuButton
id={`conversation-menu-${conversationId}`} id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')} aria-label={localize('com_nav_convo_menu_options')}
aria-readonly={undefined}
className={cn( className={cn(
'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50', 'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
isActiveConvo === true || isPopoverActive isActiveConvo === true || isPopoverActive

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { Copy, CopyCheck } from 'lucide-react'; import { Copy, CopyCheck } from 'lucide-react';
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query'; import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
@ -6,6 +7,7 @@ import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client';
import { useLocalize, useCopyToClipboard } from '~/hooks'; import { useLocalize, useCopyToClipboard } from '~/hooks';
import SharedLinkButton from './SharedLinkButton'; import SharedLinkButton from './SharedLinkButton';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store';
export default function ShareButton({ export default function ShareButton({
conversationId, conversationId,
@ -24,8 +26,9 @@ export default function ShareButton({
const [showQR, setShowQR] = useState(false); const [showQR, setShowQR] = useState(false);
const [sharedLink, setSharedLink] = useState(''); const [sharedLink, setSharedLink] = useState('');
const [isCopying, setIsCopying] = useState(false); const [isCopying, setIsCopying] = useState(false);
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
const copyLink = useCopyToClipboard({ text: sharedLink }); const copyLink = useCopyToClipboard({ text: sharedLink });
const latestMessage = useRecoilValue(store.latestMessageFamily(0));
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
useEffect(() => { useEffect(() => {
if (share?.shareId !== undefined) { if (share?.shareId !== undefined) {
@ -39,6 +42,7 @@ export default function ShareButton({
<SharedLinkButton <SharedLinkButton
share={share} share={share}
conversationId={conversationId} conversationId={conversationId}
targetMessageId={latestMessage?.messageId}
setShareDialogOpen={onOpenChange} setShareDialogOpen={onOpenChange}
showQR={showQR} showQR={showQR}
setShowQR={setShowQR} setShowQR={setShowQR}

View file

@ -21,6 +21,7 @@ import { useLocalize } from '~/hooks';
export default function SharedLinkButton({ export default function SharedLinkButton({
share, share,
conversationId, conversationId,
targetMessageId,
setShareDialogOpen, setShareDialogOpen,
showQR, showQR,
setShowQR, setShowQR,
@ -28,6 +29,7 @@ export default function SharedLinkButton({
}: { }: {
share: TSharedLinkGetResponse | undefined; share: TSharedLinkGetResponse | undefined;
conversationId: string; conversationId: string;
targetMessageId?: string;
setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>; setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
showQR: boolean; showQR: boolean;
setShowQR: (showQR: boolean) => void; setShowQR: (showQR: boolean) => void;
@ -86,7 +88,7 @@ export default function SharedLinkButton({
}; };
const createShareLink = async () => { const createShareLink = async () => {
const share = await mutate({ conversationId }); const share = await mutate({ conversationId, targetMessageId });
const newLink = generateShareLink(share.shareId); const newLink = generateShareLink(share.shareId);
setSharedLink(newLink); setSharedLink(newLink);
}; };

View file

@ -1,16 +1,33 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form'; 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 { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider';
import {
useRevokeAllUserKeysMutation,
useRevokeUserKeyMutation,
} from 'librechat-data-provider/react-query';
import type { TDialogProps } from '~/common'; import type { TDialogProps } from '~/common';
import { useGetEndpointsQuery } from '~/data-provider'; import { useGetEndpointsQuery } from '~/data-provider';
import { RevokeKeysButton } from '~/components/Nav';
import { useUserKey, useLocalize } from '~/hooks'; import { useUserKey, useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import CustomConfig from './CustomEndpoint'; import CustomConfig from './CustomEndpoint';
import GoogleConfig from './GoogleConfig'; import GoogleConfig from './GoogleConfig';
import OpenAIConfig from './OpenAIConfig'; import OpenAIConfig from './OpenAIConfig';
import OtherConfig from './OtherConfig'; import OtherConfig from './OtherConfig';
import HelpText from './HelpText'; import HelpText from './HelpText';
import { logger } from '~/utils';
const endpointComponents = { const endpointComponents = {
[EModelEndpoint.google]: GoogleConfig, [EModelEndpoint.google]: GoogleConfig,
@ -42,6 +59,94 @@ const EXPIRY = {
NEVER: { label: 'never', value: 0 }, 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 (
<div className="flex items-center justify-between">
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
disabled={disabled}
>
{localize('com_ui_revoke')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="max-w-[450px]">
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_revoke_key_endpoint', { 0: endpoint })}</OGDialogTitle>
</OGDialogHeader>
<div className="py-4">
<Label className="text-left text-sm font-medium">
{localize('com_ui_revoke_key_confirm')}
</Label>
</div>
<OGDialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{localize('com_ui_cancel')}
</Button>
<Button
variant="destructive"
onClick={onClick}
disabled={isLoading}
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
>
{isLoading ? <Spinner /> : localize('com_ui_revoke')}
</Button>
</OGDialogFooter>
</OGDialogContent>
</OGDialog>
</div>
);
};
const SetKeyDialog = ({ const SetKeyDialog = ({
open, open,
onOpenChange, onOpenChange,
@ -83,7 +188,7 @@ const SetKeyDialog = ({
const submit = () => { const submit = () => {
const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel); const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel);
let expiresAt; let expiresAt: number | null;
if (selectedOption?.value === 0) { if (selectedOption?.value === 0) {
expiresAt = null; expiresAt = null;
@ -92,8 +197,20 @@ const SetKeyDialog = ({
} }
const saveKey = (key: string) => { const saveKey = (key: string) => {
try {
saveUserKey(key, expiresAt); saveUserKey(key, expiresAt);
showToast({
message: localize('com_ui_save_key_success'),
status: NotificationSeverity.SUCCESS,
});
onOpenChange(false); 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 ?? '')) { if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) {
@ -148,6 +265,14 @@ const SetKeyDialog = ({
return; return;
} }
if (!userKey.trim()) {
showToast({
message: localize('com_ui_key_required'),
status: NotificationSeverity.ERROR,
});
return;
}
saveKey(userKey); saveKey(userKey);
setUserKey(''); setUserKey('');
}; };
@ -159,12 +284,13 @@ const SetKeyDialog = ({
return ( return (
<OGDialog open={open} onOpenChange={onOpenChange}> <OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogTemplate <OGDialogContent className="w-11/12 max-w-2xl">
title={`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`} <OGDialogHeader>
className="w-11/12 max-w-2xl" <OGDialogTitle>
showCancelButton={false} {`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
main={ </OGDialogTitle>
<div className="grid w-full items-center gap-2"> </OGDialogHeader>
<div className="grid w-full items-center gap-2 py-4">
<small className="text-red-600"> <small className="text-red-600">
{expiryTime === 'never' {expiryTime === 'never'
? localize('com_endpoint_config_key_never_expires') ? localize('com_endpoint_config_key_never_expires')
@ -195,20 +321,17 @@ const SetKeyDialog = ({
</FormProvider> </FormProvider>
<HelpText endpoint={endpoint} /> <HelpText endpoint={endpoint} />
</div> </div>
} <OGDialogFooter>
selection={{
selectHandler: submit,
selectClasses: 'btn btn-primary',
selectText: localize('com_ui_submit'),
}}
leftButtons={
<RevokeKeysButton <RevokeKeysButton
endpoint={endpoint} endpoint={endpoint}
disabled={!(expiryTime ?? '')} disabled={!(expiryTime ?? '')}
setDialogOpen={onOpenChange} setDialogOpen={onOpenChange}
/> />
} <Button variant="submit" onClick={submit}>
/> {localize('com_ui_submit')}
</Button>
</OGDialogFooter>
</OGDialogContent>
</OGDialog> </OGDialog>
); );
}; };

View file

@ -96,7 +96,10 @@ const ContentRender = memo(
() => () =>
showCardRender && !isLatestMessage 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); logger.dir(msg);
setLatestMessage(msg!); setLatestMessage(msg!);
} }

View file

@ -182,7 +182,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
<line x1="18" x2="6" y1="6" y2="18"></line> <line x1="18" x2="6" y1="6" y2="18"></line>
<line x1="6" x2="18" y1="6" y2="18"></line> <line x1="6" x2="18" y1="6" y2="18"></line>
</svg> </svg>
<span className="sr-only">{localize('com_ui_close')}</span> <span className="sr-only">{localize('com_ui_close_settings')}</span>
</button> </button>
</DialogTitle> </DialogTitle>
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]"> <div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
@ -220,35 +220,35 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
))} ))}
</Tabs.List> </Tabs.List>
<div className="overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5"> <div className="overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
<Tabs.Content value={SettingsTabValues.GENERAL}> <Tabs.Content value={SettingsTabValues.GENERAL} tabIndex={-1}>
<General /> <General />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value={SettingsTabValues.CHAT}> <Tabs.Content value={SettingsTabValues.CHAT} tabIndex={-1}>
<Chat /> <Chat />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value={SettingsTabValues.COMMANDS}> <Tabs.Content value={SettingsTabValues.COMMANDS} tabIndex={-1}>
<Commands /> <Commands />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value={SettingsTabValues.SPEECH}> <Tabs.Content value={SettingsTabValues.SPEECH} tabIndex={-1}>
<Speech /> <Speech />
</Tabs.Content> </Tabs.Content>
{hasAnyPersonalizationFeature && ( {hasAnyPersonalizationFeature && (
<Tabs.Content value={SettingsTabValues.PERSONALIZATION}> <Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
<Personalization <Personalization
hasMemoryOptOut={hasMemoryOptOut} hasMemoryOptOut={hasMemoryOptOut}
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature} hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
/> />
</Tabs.Content> </Tabs.Content>
)} )}
<Tabs.Content value={SettingsTabValues.DATA}> <Tabs.Content value={SettingsTabValues.DATA} tabIndex={-1}>
<Data /> <Data />
</Tabs.Content> </Tabs.Content>
{startupConfig?.balance?.enabled && ( {startupConfig?.balance?.enabled && (
<Tabs.Content value={SettingsTabValues.BALANCE}> <Tabs.Content value={SettingsTabValues.BALANCE} tabIndex={-1}>
<Balance /> <Balance />
</Tabs.Content> </Tabs.Content>
)} )}
<Tabs.Content value={SettingsTabValues.ACCOUNT}> <Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
<Account /> <Account />
</Tabs.Content> </Tabs.Content>
</div> </div>

View file

@ -1,9 +1,11 @@
import React, { useState, useRef, useCallback } from 'react'; import React, { useState, useRef, useCallback } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
// @ts-ignore - no type definitions available
import AvatarEditor from 'react-avatar-editor'; 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 { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
import { import {
Label,
Slider, Slider,
Button, Button,
Spinner, Spinner,
@ -25,14 +27,20 @@ interface AvatarEditorRef {
getImage: () => HTMLImageElement; getImage: () => HTMLImageElement;
} }
interface Position {
x: number;
y: number;
}
function Avatar() { function Avatar() {
const setUser = useSetRecoilState(store.user); const setUser = useSetRecoilState(store.user);
const [scale, setScale] = useState<number>(1); const [scale, setScale] = useState<number>(1);
const [rotation, setRotation] = useState<number>(0); const [rotation, setRotation] = useState<number>(0);
const [position, setPosition] = useState<Position>({ x: 0.5, y: 0.5 });
const [isDragging, setIsDragging] = useState<boolean>(false);
const editorRef = useRef<AvatarEditorRef | null>(null); const editorRef = useRef<AvatarEditorRef | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const openButtonRef = useRef<HTMLButtonElement>(null);
const [image, setImage] = useState<string | File | null>(null); const [image, setImage] = useState<string | File | null>(null);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false); const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
@ -48,7 +56,6 @@ function Avatar() {
onSuccess: (data) => { onSuccess: (data) => {
showToast({ message: localize('com_ui_upload_success') }); showToast({ message: localize('com_ui_upload_success') });
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser); setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
openButtonRef.current?.click();
}, },
onError: (error) => { onError: (error) => {
console.error('Error:', error); console.error('Error:', error);
@ -61,11 +68,13 @@ function Avatar() {
handleFile(file); handleFile(file);
}; };
const handleFile = (file: File | undefined) => { const handleFile = useCallback(
(file: File | undefined) => {
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) { if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
setImage(file); setImage(file);
setScale(1); setScale(1);
setRotation(0); setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
} else { } else {
const megabytes = const megabytes =
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2; fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
@ -74,16 +83,30 @@ function Avatar() {
status: 'error', status: 'error',
}); });
} }
}; },
[fileConfig.avatarSizeLimit, localize, showToast],
);
const handleScaleChange = (value: number[]) => { const handleScaleChange = (value: number[]) => {
setScale(value[0]); 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 = () => { const handleRotate = () => {
setRotation((prev) => (prev + 90) % 360); setRotation((prev) => (prev + 90) % 360);
}; };
const handlePositionChange = (position: Position) => {
setPosition(position);
};
const handleUpload = () => { const handleUpload = () => {
if (editorRef.current) { if (editorRef.current) {
const canvas = editorRef.current.getImageScaledToCanvas(); const canvas = editorRef.current.getImageScaledToCanvas();
@ -98,11 +121,14 @@ function Avatar() {
} }
}; };
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => { const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
handleFile(file); handleFile(file);
}, []); },
[handleFile],
);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
@ -116,8 +142,15 @@ function Avatar() {
setImage(null); setImage(null);
setScale(1); setScale(1);
setRotation(0); setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
}, []); }, []);
const handleReset = () => {
setScale(1);
setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
};
return ( return (
<OGDialog <OGDialog
open={isDialogOpen} open={isDialogOpen}
@ -125,90 +158,190 @@ function Avatar() {
setDialogOpen(open); setDialogOpen(open);
if (!open) { if (!open) {
resetImage(); resetImage();
setTimeout(() => {
openButtonRef.current?.focus();
}, 0);
} }
}} }}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{localize('com_nav_profile_picture')}</span> <span>{localize('com_nav_profile_picture')}</span>
<OGDialogTrigger ref={openButtonRef}> <OGDialogTrigger asChild>
<Button variant="outline"> <Button variant="outline">
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" /> <FileImage className="mr-2 flex w-[22px] items-center" />
<span>{localize('com_nav_change_picture')}</span> <span>{localize('com_nav_change_picture')}</span>
</Button> </Button>
</OGDialogTrigger> </OGDialogTrigger>
</div> </div>
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}> <OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
<OGDialogHeader> <OGDialogHeader>
<OGDialogTitle className="text-lg font-medium leading-6 text-text-primary"> <OGDialogTitle className="text-lg font-medium leading-6 text-text-primary">
{image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')} {image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')}
</OGDialogTitle> </OGDialogTitle>
</OGDialogHeader> </OGDialogHeader>
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center p-2">
{image != null ? ( {image != null ? (
<> <>
<div className="relative overflow-hidden rounded-full"> <div
className={cn(
'relative overflow-hidden rounded-full ring-4 ring-gray-200 transition-all dark:ring-gray-700',
isDragging && 'cursor-move ring-blue-500 dark:ring-blue-400',
)}
onMouseDown={() => setIsDragging(true)}
onMouseUp={() => setIsDragging(false)}
onMouseLeave={() => setIsDragging(false)}
>
<AvatarEditor <AvatarEditor
ref={editorRef} ref={editorRef}
image={image} image={image}
width={250} width={280}
height={250} height={280}
border={0} border={0}
borderRadius={125} borderRadius={140}
color={[255, 255, 255, 0.6]} color={[255, 255, 255, 0.6]}
scale={scale} scale={scale}
rotate={rotation} rotate={rotation}
position={position}
onPositionChange={handlePositionChange}
className="cursor-move"
/> />
{!isDragging && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-opacity hover:opacity-100">
<div className="rounded-full bg-black/50 p-2">
<Move className="h-6 w-6 text-white" />
</div> </div>
<div className="mt-4 flex w-full flex-col items-center space-y-4"> </div>
<div className="flex w-full items-center justify-center space-x-4"> )}
<span className="text-sm">{localize('com_ui_zoom')}</span> </div>
<div className="mt-6 w-full space-y-6">
{/* Zoom Controls */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="zoom-slider" className="text-sm font-medium">
{localize('com_ui_zoom')}
</Label>
<span className="text-sm text-text-secondary">{Math.round(scale * 100)}%</span>
</div>
<div className="flex items-center space-x-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleZoomOut}
disabled={scale <= 1}
aria-label={localize('com_ui_zoom_out')}
className="shrink-0"
>
<ZoomOut className="h-4 w-4" />
</Button>
<Slider <Slider
id="zoom-slider"
value={[scale]} value={[scale]}
min={1} min={1}
max={5} max={5}
step={0.001} step={0.1}
onValueChange={handleScaleChange} onValueChange={handleScaleChange}
className="w-2/3 max-w-xs" className="flex-1"
aria-label={localize('com_ui_zoom_level')}
/> />
</div>
<button
onClick={handleRotate}
className="rounded-full bg-gray-200 p-2 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500"
>
<RotateCw className="h-5 w-5" />
</button>
</div>
<Button <Button
className={cn( type="button"
'btn btn-primary mt-4 flex w-full hover:bg-green-600', variant="outline"
isUploading ? 'cursor-not-allowed opacity-90' : '', size="sm"
)} onClick={handleZoomIn}
disabled={scale >= 5}
aria-label={localize('com_ui_zoom_in')}
className="shrink-0"
>
<ZoomIn className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-center space-x-3">
<Button
type="button"
variant="outline"
onClick={handleRotate}
className="flex items-center space-x-2"
aria-label={localize('com_ui_rotate_90')}
>
<RotateCw className="h-4 w-4" />
<span className="text-sm">{localize('com_ui_rotate')}</span>
</Button>
<Button
type="button"
variant="outline"
onClick={handleReset}
className="flex items-center space-x-2"
aria-label={localize('com_ui_reset_adjustments')}
>
<X className="h-4 w-4" />
<span className="text-sm">{localize('com_ui_reset')}</span>
</Button>
</div>
{/* Helper Text */}
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
{localize('com_ui_editor_instructions')}
</p>
</div>
{/* Action Buttons */}
<div className="mt-6 flex w-full space-x-3">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={resetImage}
disabled={isUploading}
>
{localize('com_ui_cancel')}
</Button>
<Button
variant="submit"
type="button"
className={cn('w-full', isUploading ? 'cursor-not-allowed opacity-90' : '')}
onClick={handleUpload} onClick={handleUpload}
disabled={isUploading} disabled={isUploading}
> >
{isUploading ? ( {isUploading ? (
<Spinner className="icon-sm mr-2" /> <Spinner className="icon-sm mr-2" />
) : ( ) : (
<Upload className="mr-2 h-5 w-5" /> <Upload className="mr-2 h-4 w-4" />
)} )}
{localize('com_ui_upload')} {localize('com_ui_upload')}
</Button> </Button>
</div>
</> </>
) : ( ) : (
<div <div
className="flex h-64 w-11/12 flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-transparent dark:border-gray-600" className="flex h-72 w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-transparent transition-colors hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500"
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
role="button"
tabIndex={0}
onClick={openFileDialog}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFileDialog();
}
}}
aria-label={localize('com_ui_upload_avatar_label')}
> >
<FileImage className="mb-4 size-12 text-gray-400" /> <FileImage className="mb-4 size-16 text-gray-400" />
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400"> <p className="mb-2 text-center text-sm font-medium text-text-primary">
{localize('com_ui_drag_drop')} {localize('com_ui_drag_drop')}
</p> </p>
<Button variant="secondary" onClick={openFileDialog}> <p className="mb-4 text-center text-xs text-text-secondary">
{localize('com_ui_max_file_size', {
0:
fileConfig.avatarSizeLimit != null
? formatBytes(fileConfig.avatarSizeLimit)
: '2MB',
})}
</p>
<Button type="button" variant="secondary" onClick={openFileDialog}>
{localize('com_ui_select_file')} {localize('com_ui_select_file')}
</Button> </Button>
<input <input
@ -217,6 +350,7 @@ function Avatar() {
className="hidden" className="hidden"
accept=".png, .jpg, .jpeg" accept=".png, .jpg, .jpeg"
onChange={handleFileChange} onChange={handleFileChange}
aria-label={localize('com_ui_file_input_avatar_label')}
/> />
</div> </div>
)} )}

View file

@ -1,6 +1,7 @@
import { LockIcon, Trash } from 'lucide-react'; import { LockIcon, Trash } from 'lucide-react';
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { import {
Label,
Input, Input,
Button, Button,
Spinner, Spinner,
@ -45,11 +46,11 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
<> <>
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}> <OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{localize('com_nav_delete_account')}</span> <Label id="delete-account-label">{localize('com_nav_delete_account')}</Label>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
aria-labelledby="delete-account-label"
variant="destructive" variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setDialogOpen(true)} onClick={() => setDialogOpen(true)}
disabled={disabled} disabled={disabled}
> >

View file

@ -20,7 +20,7 @@ export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Label className="font-light"> {localize('com_nav_2fa')}</Label> <Label> {localize('com_nav_2fa')}</Label>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button <Button

View file

@ -15,7 +15,7 @@ export default function DisplayUsernameMessages() {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Label className="font-light">{localize('com_nav_user_name_display')}</Label> <Label id="user-name-display-label">{localize('com_nav_user_name_display')}</Label>
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} /> <InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} />
</div> </div>
<Switch <Switch
@ -24,6 +24,7 @@ export default function DisplayUsernameMessages() {
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4" className="ml-4"
data-testid="UsernameDisplay" data-testid="UsernameDisplay"
aria-labelledby="user-name-display-label"
/> />
</div> </div>
); );

View file

@ -19,16 +19,16 @@ const ChatDirection = () => {
</div> </div>
<Button <Button
variant="outline" variant="outline"
aria-label="Toggle chat direction" aria-label={`${localize('com_nav_chat_direction')}: ${localize('com_ui_x_selected', {
0:
direction === 'LTR'
? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left'),
})}`}
onClick={toggleChatDirection} onClick={toggleChatDirection}
data-testid="chatDirection" data-testid="chatDirection"
> >
<span aria-hidden="true">{direction.toLowerCase()}</span> {direction.toLowerCase()}
<span id="chat-direction-status" className="sr-only">
{direction === 'LTR'
? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left')}
</span>
</Button> </Button>
</div> </div>
); );

View file

@ -20,9 +20,11 @@ export default function FontSizeSelector() {
{ value: 'text-xl', label: localize('com_nav_font_size_xl') }, { value: 'text-xl', label: localize('com_nav_font_size_xl') },
]; ];
const labelId = 'font-size-selector-label';
return ( return (
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div>{localize('com_nav_font_size')}</div> <div id={labelId}>{localize('com_nav_font_size')}</div>
<Dropdown <Dropdown
value={fontSize} value={fontSize}
options={options} options={options}
@ -30,6 +32,7 @@ export default function FontSizeSelector() {
testId="font-size-selector" testId="font-size-selector"
sizeClasses="w-[150px]" sizeClasses="w-[150px]"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -20,13 +20,14 @@ export const ForkSettings = () => {
<> <>
<div className="pb-3"> <div className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> {localize('com_ui_fork_default')} </div> <div id="remember-default-fork-label"> {localize('com_ui_fork_default')} </div>
<Switch <Switch
id="rememberDefaultFork" id="rememberDefaultFork"
checked={remember} checked={remember}
onCheckedChange={setRemember} onCheckedChange={setRemember}
className="ml-4" className="ml-4"
data-testid="rememberDefaultFork" data-testid="rememberDefaultFork"
aria-labelledby="remember-default-fork-label"
/> />
</div> </div>
</div> </div>
@ -34,7 +35,7 @@ export const ForkSettings = () => {
<div className="pb-3"> <div className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_change_default')}</div> <div id="fork-change-default-label">{localize('com_ui_fork_change_default')}</div>
<InfoHoverCard <InfoHoverCard
side={ESide.Bottom} side={ESide.Bottom}
text={localize('com_nav_info_fork_change_default')} text={localize('com_nav_info_fork_change_default')}
@ -47,6 +48,7 @@ export const ForkSettings = () => {
sizeClasses="w-[200px]" sizeClasses="w-[200px]"
testId="fork-setting-dropdown" testId="fork-setting-dropdown"
className="z-[50]" className="z-[50]"
aria-labelledby="fork-change-default-label"
/> />
</div> </div>
</div> </div>
@ -54,7 +56,7 @@ export const ForkSettings = () => {
<div className="pb-3"> <div className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div>{localize('com_ui_fork_split_target_setting')}</div> <div id="split-at-target-label">{localize('com_ui_fork_split_target_setting')}</div>
<InfoHoverCard <InfoHoverCard
side={ESide.Bottom} side={ESide.Bottom}
text={localize('com_nav_info_fork_split_target_setting')} text={localize('com_nav_info_fork_split_target_setting')}
@ -66,6 +68,7 @@ export const ForkSettings = () => {
onCheckedChange={setSplitAtTarget} onCheckedChange={setSplitAtTarget}
className="ml-4" className="ml-4"
data-testid="splitAtTarget" data-testid="splitAtTarget"
aria-labelledby="split-at-target-label"
/> />
</div> </div>
</div> </div>

View file

@ -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<boolean>(store.atCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setAtCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_at_command_description')}</div>
<Switch
id="atCommand"
checked={atCommand}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="atCommand"
/>
</div>
);
}

View file

@ -1,10 +1,33 @@
import { memo } from 'react'; import { memo } from 'react';
import { InfoHoverCard, ESide } from '@librechat/client'; import { InfoHoverCard, ESide } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider'; import { PermissionTypes, Permissions } from 'librechat-data-provider';
import SlashCommandSwitch from './SlashCommandSwitch';
import { useLocalize, useHasAccess } from '~/hooks'; import { useLocalize, useHasAccess } from '~/hooks';
import PlusCommandSwitch from './PlusCommandSwitch'; import ToggleSwitch from '../ToggleSwitch';
import AtCommandSwitch from './AtCommandSwitch'; 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() { function Commands() {
const localize = useLocalize(); const localize = useLocalize();
@ -19,6 +42,19 @@ function Commands() {
permission: Permissions.USE, 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 ( return (
<div className="space-y-4 p-1"> <div className="space-y-4 p-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -28,19 +64,16 @@ function Commands() {
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} /> <InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
</div> </div>
<div className="flex flex-col gap-3 text-sm text-text-primary"> <div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="pb-3"> {commandSwitchConfigs.map((config) => (
<AtCommandSwitch /> <div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
switchId={config.switchId}
showSwitch={getShowSwitch(config.permissionType)}
/>
</div> </div>
{hasAccessToMultiConvo === true && ( ))}
<div className="pb-3">
<PlusCommandSwitch />
</div>
)}
{hasAccessToPrompts === true && (
<div className="pb-3">
<SlashCommandSwitch />
</div>
)}
</div> </div>
</div> </div>
); );

View file

@ -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<boolean>(store.plusCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setPlusCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_plus_command_description')}</div>
<Switch
id="plusCommand"
checked={plusCommand}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="plusCommand"
/>
</div>
);
}

View file

@ -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<boolean>(store.slashCommand);
const localize = useLocalize();
const handleCheckedChange = (value: boolean) => {
setSlashCommand(value);
};
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_slash_command_description')}</div>
<Switch
id="slashCommand"
checked={slashCommand}
onCheckedChange={handleCheckedChange}
data-testid="slashCommand"
/>
</div>
);
}

View file

@ -31,12 +31,12 @@ export const ClearChats = () => {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="font-light">{localize('com_nav_clear_all_chats')}</Label> <Label id="clear-all-chats-label">{localize('com_nav_clear_all_chats')}</Label>
<OGDialog open={open} onOpenChange={setOpen}> <OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
aria-labelledby="clear-all-chats-label"
variant="destructive" variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
{localize('com_ui_delete')} {localize('com_ui_delete')}
@ -47,7 +47,7 @@ export const ClearChats = () => {
title={localize('com_nav_confirm_clear')} title={localize('com_nav_confirm_clear')}
className="max-w-[450px]" className="max-w-[450px]"
main={ main={
<Label className="text-left text-sm font-medium"> <Label className="break-words">
{localize('com_nav_clear_conversation_confirm_message')} {localize('com_nav_clear_conversation_confirm_message')}
</Label> </Label>
} }

View file

@ -1,7 +1,7 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { useOnClickOutside } from '@librechat/client'; import { useOnClickOutside } from '@librechat/client';
import ImportConversations from './ImportConversations'; import ImportConversations from './ImportConversations';
import { RevokeAllKeys } from './RevokeAllKeys'; import { RevokeKeys } from './RevokeKeys';
import { DeleteCache } from './DeleteCache'; import { DeleteCache } from './DeleteCache';
import { ClearChats } from './ClearChats'; import { ClearChats } from './ClearChats';
import SharedLinks from './SharedLinks'; import SharedLinks from './SharedLinks';
@ -20,7 +20,7 @@ function Data() {
<SharedLinks /> <SharedLinks />
</div> </div>
<div className="pb-3"> <div className="pb-3">
<RevokeAllKeys /> <RevokeKeys />
</div> </div>
<div className="pb-3"> <div className="pb-3">
<DeleteCache /> <DeleteCache />

View file

@ -38,14 +38,14 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="font-light">{localize('com_nav_delete_cache_storage')}</Label> <Label id="delete-cache-label">{localize('com_nav_delete_cache_storage')}</Label>
<OGDialog open={open} onOpenChange={setOpen}> <OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild> <OGDialogTrigger asChild>
<Button <Button
variant="destructive" variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
disabled={disabled || isCacheEmpty} disabled={disabled || isCacheEmpty}
aria-labelledby="delete-cache-label"
> >
{localize('com_ui_delete')} {localize('com_ui_delete')}
</Button> </Button>

View file

@ -1,96 +1,130 @@
import { useState, useRef } from 'react'; import { useState, useRef, useCallback } from 'react';
import { Import } from 'lucide-react'; import { Import } from 'lucide-react';
import { Spinner, useToastContext } from '@librechat/client'; import { useQueryClient } from '@tanstack/react-query';
import type { TError } from 'librechat-data-provider'; import { QueryKeys, TStartupConfig } from 'librechat-data-provider';
import { Spinner, useToastContext, Label, Button } from '@librechat/client';
import { useUploadConversationsMutation } from '~/data-provider'; import { useUploadConversationsMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn, logger } from '~/utils';
function ImportConversations() { function ImportConversations() {
const queryClient = useQueryClient();
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const localize = useLocalize(); const localize = useLocalize();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [, setErrors] = useState<string[]>([]);
const [allowImport, setAllowImport] = useState(true); const [isUploading, setIsUploading] = useState(false);
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
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({ const uploadFile = useUploadConversationsMutation({
onSuccess: () => { onSuccess: handleSuccess,
showToast({ message: localize('com_ui_import_conversation_success') }); onError: handleError,
setAllowImport(true); onMutate: () => setIsUploading(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);
},
}); });
const startUpload = async (file: 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;
}
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, encodeURIComponent(file.name || 'File')); formData.append('file', file, encodeURIComponent(file.name || 'File'));
uploadFile.mutate(formData); uploadFile.mutate(formData);
};
const handleFiles = async (_file: File) => {
try {
await startUpload(_file);
} catch (error) { } catch (error) {
console.log('file handling error', error); logger.error('File processing error:', error);
setError('An error occurred while processing the file.'); setIsUploading(false);
showToast({
message: localize('com_ui_import_conversation_upload_error'),
status: NotificationSeverity.ERROR,
});
} }
}; },
[uploadFile, showToast, localize, startupConfig],
);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
handleFiles(file); setIsUploading(true);
handleFileUpload(file);
} }
}; event.target.value = '';
},
[handleFileUpload],
);
const handleImportClick = () => { const handleImportClick = useCallback(() => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}; }, []);
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => { const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
handleImportClick(); handleImportClick();
} }
}; },
[handleImportClick],
);
const isImportDisabled = isUploading;
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_ui_import_conversation_info')}</div> <Label id="import-conversation-label">{localize('com_ui_import_conversation_info')}</Label>
<button <Button
variant="outline"
onClick={handleImportClick} onClick={handleImportClick}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
disabled={!allowImport} disabled={isImportDisabled}
aria-label={localize('com_ui_import')} aria-label={localize('com_ui_import')}
className="btn btn-neutral relative" aria-labelledby="import-conversation-label"
> >
{allowImport ? ( {isUploading ? (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
) : (
<Spinner className="mr-1 w-4" /> <Spinner className="mr-1 w-4" />
) : (
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
)} )}
<span>{localize('com_ui_import')}</span> <span>{localize('com_ui_import')}</span>
</button> </Button>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"

View file

@ -1,15 +0,0 @@
import React from 'react';
import { Label } from '@librechat/client';
import { RevokeKeysButton } from './RevokeKeysButton';
import { useLocalize } from '~/hooks';
export const RevokeAllKeys = () => {
const localize = useLocalize();
return (
<div className="flex items-center justify-between">
<Label className="font-light">{localize('com_ui_revoke_info')}</Label>
<RevokeKeysButton all={true} />
</div>
);
};

View file

@ -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 (
<div className="flex items-center justify-between">
<Label id="revoke-info-label">{localize('com_ui_revoke_info')}</Label>
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
onClick={() => setOpen(true)}
disabled={disabled}
aria-labelledby="revoke-info-label"
>
{localize('com_ui_revoke')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_revoke_keys')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_revoke_keys_confirm')}
</Label>
}
selection={{
selectHandler: onClick,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isLoading ? <Spinner /> : localize('com_ui_revoke'),
}}
/>
</OGDialog>
</div>
);
};

View file

@ -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 (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
disabled={disabled}
>
{localize('com_ui_revoke')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={dialogTitle}
className="max-w-[450px]"
main={<Label className="text-left text-sm font-medium">{dialogMessage}</Label>}
selection={{
selectHandler: onClick,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isLoading ? <Spinner /> : localize('com_ui_revoke'),
}}
/>
</OGDialog>
);
};

View file

@ -286,11 +286,13 @@ export default function SharedLinks() {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_shared_links')}</div> <Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
<OGDialog open={isOpen} onOpenChange={setIsOpen}> <OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}> <OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
<Button variant="outline">{localize('com_ui_manage')}</Button> <Button aria-labelledby="shared-links-label" variant="outline">
{localize('com_ui_manage')}
</Button>
</OGDialogTrigger> </OGDialogTrigger>
<OGDialogContent <OGDialogContent

View file

@ -46,9 +46,11 @@ export const ThemeSelector = ({
{ value: 'light', label: localize('com_nav_theme_light') }, { value: 'light', label: localize('com_nav_theme_light') },
]; ];
const labelId = 'theme-selector-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_theme')}</div> <div id={labelId}>{localize('com_nav_theme')}</div>
<Dropdown <Dropdown
value={theme} value={theme}
@ -57,6 +59,7 @@ export const ThemeSelector = ({
sizeClasses="w-[180px]" sizeClasses="w-[180px]"
testId="theme-selector" testId="theme-selector"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );
@ -112,9 +115,11 @@ export const LangSelector = ({
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') }, { value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
]; ];
const labelId = 'language-selector-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_language')}</div> <div id={labelId}>{localize('com_nav_language')}</div>
<Dropdown <Dropdown
value={langcode} value={langcode}
@ -122,6 +127,7 @@ export const LangSelector = ({
sizeClasses="[--anchor-max-height:256px]" sizeClasses="[--anchor-max-height:256px]"
options={languageOptions} options={languageOptions}
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -65,10 +65,13 @@ export default function Personalization({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="flex items-center gap-2"> <div id="reference-saved-memories-label" className="flex items-center gap-2">
{localize('com_ui_reference_saved_memories')} {localize('com_ui_reference_saved_memories')}
</div> </div>
<div className="mt-1 text-xs text-text-secondary"> <div
id="reference-saved-memories-description"
className="mt-1 text-xs text-text-secondary"
>
{localize('com_ui_reference_saved_memories_description')} {localize('com_ui_reference_saved_memories_description')}
</div> </div>
</div> </div>
@ -76,7 +79,8 @@ export default function Personalization({
checked={referenceSavedMemories} checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle} onCheckedChange={handleMemoryToggle}
disabled={updateMemoryPreferencesMutation.isLoading} disabled={updateMemoryPreferencesMutation.isLoading}
aria-label={localize('com_ui_reference_saved_memories')} aria-labelledby="reference-saved-memories-label"
aria-describedby="reference-saved-memories-description"
/> />
</div> </div>
</> </>

View file

@ -1,6 +1,5 @@
import { Switch } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import { useLocalize } from '~/hooks'; import ToggleSwitch from '../ToggleSwitch';
import store from '~/store'; import store from '~/store';
export default function ConversationModeSwitch({ export default function ConversationModeSwitch({
@ -8,8 +7,6 @@ export default function ConversationModeSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [conversationMode, setConversationMode] = useRecoilState<boolean>(store.conversationMode);
const speechToText = useRecoilValue(store.speechToText); const speechToText = useRecoilValue(store.speechToText);
const textToSpeech = useRecoilValue(store.textToSpeech); const textToSpeech = useRecoilValue(store.textToSpeech);
const [, setAutoSendText] = useRecoilState(store.autoSendText); const [, setAutoSendText] = useRecoilState(store.autoSendText);
@ -20,27 +17,19 @@ export default function ConversationModeSwitch({
setAutoTranscribeAudio(value); setAutoTranscribeAudio(value);
setAutoSendText(3); setAutoSendText(3);
setDecibelValue(-45); setDecibelValue(-45);
setConversationMode(value);
if (onCheckedChange) { if (onCheckedChange) {
onCheckedChange(value); onCheckedChange(value);
} }
}; };
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div> stateAtom={store.conversationMode}
<strong>{localize('com_nav_conversation_mode')}</strong> localizationKey={'com_nav_conversation_mode' as const}
</div> switchId="ConversationMode"
<div className="flex items-center justify-between">
<Switch
id="ConversationMode"
checked={conversationMode}
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="ConversationMode"
disabled={!textToSpeech || !speechToText} disabled={!textToSpeech || !speechToText}
strongLabel={true}
/> />
</div>
</div>
); );
} }

View file

@ -1,6 +1,6 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil'; 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 { cn, defaultTextProps, optionText } from '~/utils/';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
@ -11,31 +11,93 @@ export default function AutoSendTextSelector() {
const speechToText = useRecoilValue(store.speechToText); const speechToText = useRecoilValue(store.speechToText);
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText); 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 ( return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div id={labelId}>{localize('com_nav_auto_send_text')}</div>
</div>
<Switch
id="autoSendTextToggle"
checked={isEnabled}
onCheckedChange={handleToggle}
className="ml-4"
data-testid="autoSendTextToggle"
aria-labelledby={labelId}
disabled={!speechToText}
/>
</div>
{isEnabled && (
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_auto_send_text')}</div> <div id="auto-send-delay-label" className="text-sm text-text-secondary">
<div className="w-2" /> {localize('com_nav_setting_delay')}
<small className="opacity-40">({localize('com_nav_auto_send_text_disabled')})</small> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Slider <Slider
value={[autoSendText ?? -1]} value={[delayValue]}
onValueChange={(value) => setAutoSendText(value[0])} onValueChange={handleSliderChange}
onDoubleClick={() => setAutoSendText(-1)} onDoubleClick={() => {
min={-1} setDelayValue(3);
if (isEnabled) {
setAutoSendText(3);
}
}}
min={0}
max={60} max={60}
step={1} step={1}
className="ml-4 flex h-4 w-24" className="ml-4 flex h-4 w-24"
disabled={!speechToText} disabled={!speechToText || !isEnabled}
aria-labelledby="auto-send-delay-label"
/> />
<div className="w-2" /> <div className="w-2" />
<InputNumber <InputNumber
value={`${autoSendText} s`} value={`${delayValue} s`}
disabled={!speechToText} disabled={!speechToText || !isEnabled}
onChange={(value) => setAutoSendText(value ? value[0] : 0)} onChange={handleInputChange}
min={-1} min={0}
max={60} max={60}
aria-labelledby="auto-send-delay-label"
className={cn( className={cn(
defaultTextProps, defaultTextProps,
cn( cn(
@ -46,5 +108,7 @@ export default function AutoSendTextSelector() {
/> />
</div> </div>
</div> </div>
)}
</div>
); );
} }

View file

@ -1,6 +1,5 @@
import { Switch } from '@librechat/client'; import { useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil'; import ToggleSwitch from '../../ToggleSwitch';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function AutoTranscribeAudioSwitch({ export default function AutoTranscribeAudioSwitch({
@ -8,30 +7,15 @@ export default function AutoTranscribeAudioSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState<boolean>(
store.autoTranscribeAudio,
);
const speechToText = useRecoilValue(store.speechToText); const speechToText = useRecoilValue(store.speechToText);
const handleCheckedChange = (value: boolean) => {
setAutoTranscribeAudio(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div>{localize('com_nav_auto_transcribe_audio')}</div> stateAtom={store.autoTranscribeAudio}
<Switch localizationKey={'com_nav_auto_transcribe_audio' as const}
id="AutoTranscribeAudio" switchId="AutoTranscribeAudio"
checked={autoTranscribeAudio} onCheckedChange={onCheckedChange}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="AutoTranscribeAudio"
disabled={!speechToText} disabled={!speechToText}
/> />
</div>
); );
} }

View file

@ -13,7 +13,7 @@ export default function DecibelSelector() {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_db_sensitivity')}</div> <div id="decibel-selector-label">{localize('com_nav_db_sensitivity')}</div>
<div className="w-2" /> <div className="w-2" />
<small className="opacity-40"> <small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '-45' })}) ({localize('com_endpoint_default_with_num', { 0: '-45' })})
@ -29,6 +29,7 @@ export default function DecibelSelector() {
step={1} step={1}
className="ml-4 flex h-4 w-24" className="ml-4 flex h-4 w-24"
disabled={!speechToText} disabled={!speechToText}
aria-labelledby="decibel-selector-label"
/> />
<div className="w-2" /> <div className="w-2" />
<InputNumber <InputNumber
@ -37,6 +38,7 @@ export default function DecibelSelector() {
onChange={(value) => setDecibelValue(value ? value[0] : 0)} onChange={(value) => setDecibelValue(value ? value[0] : 0)}
min={-100} min={-100}
max={-30} max={-30}
aria-labelledby="decibel-selector-label"
className={cn( className={cn(
defaultTextProps, defaultTextProps,
cn( cn(

View file

@ -23,9 +23,11 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
setEngineSTT(value); setEngineSTT(value);
}; };
const labelId = 'engine-stt-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_engine')}</div> <div id={labelId}>{localize('com_nav_engine')}</div>
<Dropdown <Dropdown
value={engineSTT} value={engineSTT}
onChange={handleSelect} onChange={handleSelect}
@ -33,6 +35,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
sizeClasses="w-[180px]" sizeClasses="w-[180px]"
testId="EngineSTTDropdown" testId="EngineSTTDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -94,9 +94,11 @@ export default function LanguageSTTDropdown() {
setLanguageSTT(value); setLanguageSTT(value);
}; };
const labelId = 'language-stt-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_language')}</div> <div id={labelId}>{localize('com_nav_language')}</div>
<Dropdown <Dropdown
value={languageSTT} value={languageSTT}
onChange={handleSelect} onChange={handleSelect}
@ -104,6 +106,7 @@ export default function LanguageSTTDropdown() {
sizeClasses="[--anchor-max-height:256px]" sizeClasses="[--anchor-max-height:256px]"
testId="LanguageSTTDropdown" testId="LanguageSTTDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -1,6 +1,4 @@
import { useRecoilState } from 'recoil'; import ToggleSwitch from '../../ToggleSwitch';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function SpeechToTextSwitch({ export default function SpeechToTextSwitch({
@ -8,28 +6,13 @@ export default function SpeechToTextSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [speechToText, setSpeechToText] = useRecoilState<boolean>(store.speechToText);
const handleCheckedChange = (value: boolean) => {
setSpeechToText(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div> stateAtom={store.speechToText}
<strong>{localize('com_nav_speech_to_text')}</strong> localizationKey={'com_nav_speech_to_text' as const}
</div> switchId="SpeechToText"
<Switch onCheckedChange={onCheckedChange}
id="SpeechToText" strongLabel={true}
checked={speechToText}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="SpeechToText"
/> />
</div>
); );
} }

View file

@ -23,7 +23,7 @@ import {
} from './STT'; } from './STT';
import ConversationModeSwitch from './ConversationModeSwitch'; import ConversationModeSwitch from './ConversationModeSwitch';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn, logger } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
function Speech() { function Speech() {
@ -186,7 +186,7 @@ function Speech() {
</Tabs.List> </Tabs.List>
</div> </div>
<Tabs.Content value={'simple'}> <Tabs.Content value={'simple'} tabIndex={-1}>
<div className="flex flex-col gap-3 text-sm text-text-primary"> <div className="flex flex-col gap-3 text-sm text-text-primary">
<SpeechToTextSwitch /> <SpeechToTextSwitch />
<EngineSTTDropdown external={sttExternal} /> <EngineSTTDropdown external={sttExternal} />
@ -198,7 +198,7 @@ function Speech() {
</div> </div>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value={'advanced'}> <Tabs.Content value={'advanced'} tabIndex={-1}>
<div className="flex flex-col gap-3 text-sm text-text-primary"> <div className="flex flex-col gap-3 text-sm text-text-primary">
<ConversationModeSwitch /> <ConversationModeSwitch />
<div className="mt-2 h-px bg-border-medium" role="none" /> <div className="mt-2 h-px bg-border-medium" role="none" />

View file

@ -1,6 +1,4 @@
import { useRecoilState } from 'recoil'; import ToggleSwitch from '../../ToggleSwitch';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function AutomaticPlaybackSwitch({ export default function AutomaticPlaybackSwitch({
@ -8,26 +6,12 @@ export default function AutomaticPlaybackSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback);
const handleCheckedChange = (value: boolean) => {
setAutomaticPlayback(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div>{localize('com_nav_automatic_playback')}</div> stateAtom={store.automaticPlayback}
<Switch localizationKey={'com_nav_automatic_playback' as const}
id="AutomaticPlayback" switchId="AutomaticPlayback"
checked={automaticPlayback} onCheckedChange={onCheckedChange}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="AutomaticPlayback"
/> />
</div>
); );
} }

View file

@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Switch } from '@librechat/client'; import ToggleSwitch from '../../ToggleSwitch';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function CacheTTSSwitch({ export default function CacheTTSSwitch({
@ -8,28 +7,15 @@ export default function CacheTTSSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize(); const textToSpeech = useRecoilValue(store.textToSpeech);
const [cacheTTS, setCacheTTS] = useRecoilState<boolean>(store.cacheTTS);
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const handleCheckedChange = (value: boolean) => {
setCacheTTS(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div>{localize('com_nav_enable_cache_tts')}</div> stateAtom={store.cacheTTS}
<Switch localizationKey={'com_nav_enable_cache_tts' as const}
id="CacheTTS" switchId="CacheTTS"
checked={cacheTTS} onCheckedChange={onCheckedChange}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="CacheTTS"
disabled={!textToSpeech} disabled={!textToSpeech}
/> />
</div>
); );
} }

View file

@ -1,6 +1,5 @@
import { useRecoilState } from 'recoil'; import { useRecoilValue } from 'recoil';
import { Switch } from '@librechat/client'; import ToggleSwitch from '../../ToggleSwitch';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function CloudBrowserVoicesSwitch({ export default function CloudBrowserVoicesSwitch({
@ -8,30 +7,15 @@ export default function CloudBrowserVoicesSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize(); const textToSpeech = useRecoilValue(store.textToSpeech);
const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState<boolean>(
store.cloudBrowserVoices,
);
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const handleCheckedChange = (value: boolean) => {
setCloudBrowserVoices(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div>{localize('com_nav_enable_cloud_browser_voice')}</div> stateAtom={store.cloudBrowserVoices}
<Switch localizationKey={'com_nav_enable_cloud_browser_voice' as const}
id="CloudBrowserVoices" switchId="CloudBrowserVoices"
checked={cloudBrowserVoices} onCheckedChange={onCheckedChange}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="CloudBrowserVoices"
disabled={!textToSpeech} disabled={!textToSpeech}
/> />
</div>
); );
} }

View file

@ -23,9 +23,11 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
setEngineTTS(value); setEngineTTS(value);
}; };
const labelId = 'engine-tts-dropdown-label';
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_engine')}</div> <div id={labelId}>{localize('com_nav_engine')}</div>
<Dropdown <Dropdown
value={engineTTS} value={engineTTS}
onChange={handleSelect} onChange={handleSelect}
@ -33,6 +35,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
sizeClasses="w-[180px]" sizeClasses="w-[180px]"
testId="EngineTTSDropdown" testId="EngineTTSDropdown"
className="z-50" className="z-50"
aria-labelledby={labelId}
/> />
</div> </div>
); );

View file

@ -13,7 +13,7 @@ export default function DecibelSelector() {
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>{localize('com_nav_playback_rate')}</div> <div id="playback-rate-label">{localize('com_nav_playback_rate')}</div>
<div className="w-2" /> <div className="w-2" />
<small className="opacity-40"> <small className="opacity-40">
({localize('com_endpoint_default_with_num', { 0: '1' })}) ({localize('com_endpoint_default_with_num', { 0: '1' })})
@ -29,6 +29,7 @@ export default function DecibelSelector() {
step={0.1} step={0.1}
className="ml-4 flex h-4 w-24" className="ml-4 flex h-4 w-24"
disabled={!textToSpeech} disabled={!textToSpeech}
aria-labelledby="playback-rate-label"
/> />
<div className="w-2" /> <div className="w-2" />
<InputNumber <InputNumber
@ -37,6 +38,7 @@ export default function DecibelSelector() {
onChange={(value) => setPlaybackRate(value ? value[0] : 0)} onChange={(value) => setPlaybackRate(value ? value[0] : 0)}
min={0.1} min={0.1}
max={2} max={2}
aria-labelledby="playback-rate-label"
className={cn( className={cn(
defaultTextProps, defaultTextProps,
cn( cn(

View file

@ -1,6 +1,4 @@
import { useRecoilState } from 'recoil'; import ToggleSwitch from '../../ToggleSwitch';
import { Switch } from '@librechat/client';
import { useLocalize } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function TextToSpeechSwitch({ export default function TextToSpeechSwitch({
@ -8,28 +6,13 @@ export default function TextToSpeechSwitch({
}: { }: {
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
}) { }) {
const localize = useLocalize();
const [TextToSpeech, setTextToSpeech] = useRecoilState<boolean>(store.textToSpeech);
const handleCheckedChange = (value: boolean) => {
setTextToSpeech(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return ( return (
<div className="flex items-center justify-between"> <ToggleSwitch
<div> stateAtom={store.textToSpeech}
<strong>{localize('com_nav_text_to_speech')}</strong> localizationKey={'com_nav_text_to_speech' as const}
</div> switchId="TextToSpeech"
<Switch onCheckedChange={onCheckedChange}
id="TextToSpeech" strongLabel={true}
checked={TextToSpeech}
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="TextToSpeech"
/> />
</div>
); );
} }

View file

@ -11,6 +11,9 @@ interface ToggleSwitchProps {
hoverCardText?: LocalizeKey; hoverCardText?: LocalizeKey;
switchId: string; switchId: string;
onCheckedChange?: (value: boolean) => void; onCheckedChange?: (value: boolean) => void;
showSwitch?: boolean;
disabled?: boolean;
strongLabel?: boolean;
} }
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
@ -19,6 +22,9 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
hoverCardText, hoverCardText,
switchId, switchId,
onCheckedChange, onCheckedChange,
showSwitch = true,
disabled = false,
strongLabel = false,
}) => { }) => {
const [switchState, setSwitchState] = useRecoilState(stateAtom); const [switchState, setSwitchState] = useRecoilState(stateAtom);
const localize = useLocalize(); const localize = useLocalize();
@ -28,10 +34,18 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
onCheckedChange?.(value); onCheckedChange?.(value);
}; };
const labelId = `${switchId}-label`;
if (!showSwitch) {
return null;
}
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div>{localize(localizationKey)}</div> <div id={labelId}>
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
</div>
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />} {hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
</div> </div>
<Switch <Switch
@ -40,6 +54,8 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
onCheckedChange={handleCheckedChange} onCheckedChange={handleCheckedChange}
className="ml-4" className="ml-4"
data-testid={switchId} data-testid={switchId}
aria-labelledby={labelId}
disabled={disabled}
/> />
</div> </div>
); );

View file

@ -1,9 +1,8 @@
export { default as General } from './General/General';
export { default as Chat } from './Chat/Chat'; export { default as Chat } from './Chat/Chat';
export { default as Data } from './Data/Data'; 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 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'; export { default as Personalization } from './Personalization';

View file

@ -71,10 +71,12 @@ function ChatGroupItem({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
id={`prompt-actions-${group._id}`} id={`prompt-actions-${group._id}`}
aria-label={`${group.name} - Actions Menu`} type="button"
aria-expanded="false" aria-label={
aria-controls={`prompt-menu-${group._id}`} localize('com_ui_sr_actions_menu', { 0: group.name }) +
aria-haspopup="menu" ' ' +
localize('com_ui_prompt')
}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
@ -86,11 +88,6 @@ function ChatGroupItem({
className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
> >
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" /> <MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
<span className="sr-only">
{localize('com_ui_sr_actions_menu', { 0: group.name }) +
' ' +
localize('com_ui_prompt')}
</span>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@ -98,30 +95,29 @@ function ChatGroupItem({
aria-label={`Available actions for ${group.name}`} aria-label={`Available actions for ${group.name}`}
className="z-50 w-fit rounded-xl" className="z-50 w-fit rounded-xl"
collisionPadding={2} collisionPadding={2}
align="end" align="start"
> >
<DropdownMenuItem <DropdownMenuItem
role="menuitem"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setPreviewDialogOpen(true); 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"
> >
<TextSearch className="mr-2 h-4 w-4" aria-hidden="true" /> <TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_preview')}</span> <span>{localize('com_ui_preview')}</span>
</DropdownMenuItem> </DropdownMenuItem>
{canEdit && ( {canEdit && (
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem <DropdownMenuItem
disabled={!canEdit} disabled={!canEdit}
className="cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" className="cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEditClick(e); onEditClick(e);
}} }}
> >
<EditIcon className="mr-2 h-4 w-4" aria-hidden="true" /> <EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
<span>{localize('com_ui_edit')}</span> <span>{localize('com_ui_edit')}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>

View file

@ -89,7 +89,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-label={`${group.name} prompt group`} aria-label={`${group.name} Prompt, ${localize('com_ui_category')}: ${group.category ?? ''}`}
> >
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2 truncate pr-2"> <div className="flex items-center gap-2 truncate pr-2">

View file

@ -87,7 +87,7 @@ export default function FilterPrompts({ className = '' }: { className?: string }
value={categoryFilter || SystemCategories.ALL} value={categoryFilter || SystemCategories.ALL}
onChange={onSelect} onChange={onSelect}
options={filterOptions} options={filterOptions}
className="bg-transparent" className="rounded-lg bg-transparent"
icon={<ListFilter className="h-4 w-4" />} icon={<ListFilter className="h-4 w-4" />}
label="Filter: " label="Filter: "
ariaLabel={localize('com_ui_filter_prompts')} ariaLabel={localize('com_ui_filter_prompts')}

View file

@ -40,7 +40,7 @@ export default function List({
</Button> </Button>
</div> </div>
)} )}
<div className="flex-grow overflow-y-auto"> <div className="flex-grow overflow-y-auto" aria-label={localize('com_ui_prompt_groups')}>
<div className="overflow-y-auto overflow-x-hidden"> <div className="overflow-y-auto overflow-x-hidden">
{isLoading && isChatRoute && ( {isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" /> <Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />

View file

@ -14,7 +14,7 @@ const PreviewPrompt = ({
return ( return (
<OGDialog open={open} onOpenChange={onOpenChange}> <OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]"> <OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]">
<div className="p-2"> <div>
<PromptDetails group={group} /> <PromptDetails group={group} />
</div> </div>
</OGDialogContent> </OGDialogContent>

View file

@ -168,18 +168,26 @@ export const useArchiveConvoMutation = (
}; };
export const useCreateSharedLinkMutation = ( export const useCreateSharedLinkMutation = (
options?: t.MutationOptions<t.TCreateShareLinkRequest, { conversationId: string }>, options?: t.MutationOptions<
): UseMutationResult<t.TSharedLinkResponse, unknown, { conversationId: string }, unknown> => { t.TCreateShareLinkRequest,
{ conversationId: string; targetMessageId?: string }
>,
): UseMutationResult<
t.TSharedLinkResponse,
unknown,
{ conversationId: string; targetMessageId?: string },
unknown
> => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { onSuccess, ..._options } = options || {}; const { onSuccess, ..._options } = options || {};
return useMutation( return useMutation(
({ conversationId }: { conversationId: string }) => { ({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => {
if (!conversationId) { if (!conversationId) {
throw new Error('Conversation ID is required'); throw new Error('Conversation ID is required');
} }
return dataService.createSharedLink(conversationId); return dataService.createSharedLink(conversationId, targetMessageId);
}, },
{ {
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {

View file

@ -37,7 +37,10 @@ export default function useAgentToolPermissions(
[agentData?.tools, selectedAgent?.tools], [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(() => { const fileSearchAllowedByAgent = useMemo(() => {
// Check ephemeral agent settings // Check ephemeral agent settings

View file

@ -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>): 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 () => <div>Test</div>',
});
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: '<html><body>Test</body></html>',
});
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');
});
});
});

View file

@ -3,11 +3,19 @@ import { removeNullishValues } from 'librechat-data-provider';
import type { Artifact } from '~/common'; import type { Artifact } from '~/common';
import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts'; import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts';
import { getMermaidFiles } from '~/utils/mermaid'; import { getMermaidFiles } from '~/utils/mermaid';
import { getMarkdownFiles } from '~/utils/markdown';
export default function useArtifactProps({ artifact }: { artifact: Artifact }) { export default function useArtifactProps({ artifact }: { artifact: Artifact }) {
const [fileKey, files] = useMemo(() => { const [fileKey, files] = useMemo(() => {
if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) { const key = getKey(artifact.type ?? '', artifact.language);
return ['App.tsx', getMermaidFiles(artifact.content ?? '')]; 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); const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language);

View file

@ -122,17 +122,8 @@ export default function useArtifacts() {
setCurrentArtifactId(orderedArtifactIds[newIndex]); 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 { return {
activeTab, activeTab,
isMermaid,
setActiveTab, setActiveTab,
currentIndex, currentIndex,
cycleArtifact, cycleArtifact,

View file

@ -51,6 +51,7 @@ const useNavigateToConvo = (index = 0) => {
hasSetConversation.current = true; hasSetConversation.current = true;
setSubmission(null); setSubmission(null);
if (resetLatestMessage) { if (resetLatestMessage) {
logger.log('latest_message', 'Clearing all latest messages');
clearAllLatestMessages(); clearAllLatestMessages();
} }

View file

@ -3,8 +3,8 @@ import { useEffect, useRef, useCallback, useMemo } from 'react';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type { TMessageProps } from '~/common'; import type { TMessageProps } from '~/common';
import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils';
import useCopyToClipboard from './useCopyToClipboard'; import useCopyToClipboard from './useCopyToClipboard';
import { getTextKey, logger } from '~/utils';
export default function useMessageHelpers(props: TMessageProps) { export default function useMessageHelpers(props: TMessageProps) {
const latestText = useRef<string | number>(''); const latestText = useRef<string | number>('');
@ -49,15 +49,27 @@ export default function useMessageHelpers(props: TMessageProps) {
messageId: message.messageId, messageId: message.messageId,
convoId, 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 ( if (
textKey !== latestText.current || 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; latestText.current = textKey;
setLatestMessage({ ...message }); setLatestMessage({ ...message });
} else { } else {
logger.log('No change in latest message', logInfo); logger.log('latest_message', 'No change in latest message', logInfo);
} }
}, [isLast, message, setLatestMessage, conversation?.conversationId]); }, [isLast, message, setLatestMessage, conversation?.conversationId]);

View file

@ -3,8 +3,8 @@ import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider'; import { Constants } from 'librechat-data-provider';
import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils';
import { useMessagesViewContext } from '~/Providers'; import { useMessagesViewContext } from '~/Providers';
import { getTextKey, logger } from '~/utils';
import store from '~/store'; import store from '~/store';
export default function useMessageProcess({ message }: { message?: TMessage | null }) { export default function useMessageProcess({ message }: { message?: TMessage | null }) {
@ -43,11 +43,21 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
messageId: message.messageId, messageId: message.messageId,
convoId, 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 ( if (
textKey !== latestText.current || textKey !== latestText.current ||
(convoId != null && (convoId != null && previousConvoId != null && convoId !== previousConvoId)
latestText.current &&
convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2])
) { ) {
logger.log('latest_message', '[useMessageProcess] Setting latest message; logInfo:', logInfo); logger.log('latest_message', '[useMessageProcess] Setting latest message; logInfo:', logInfo);
latestText.current = textKey; latestText.current = textKey;

View file

@ -339,6 +339,7 @@ export default function useEventHandlers({
setShowStopButton(true); setShowStopButton(true);
if (resetLatestMessage) { if (resetLatestMessage) {
logger.log('latest_message', 'syncHandler: resetting latest message');
resetLatestMessage(); resetLatestMessage();
} }
}, },
@ -418,6 +419,7 @@ export default function useEventHandlers({
} }
if (resetLatestMessage) { if (resetLatestMessage) {
logger.log('latest_message', 'createdHandler: resetting latest message');
resetLatestMessage(); resetLatestMessage();
} }
scrollToEnd(() => setAbortScroll(false)); scrollToEnd(() => setAbortScroll(false));

View file

@ -179,6 +179,7 @@ const useNewConvo = (index = 0) => {
} }
setSubmission({} as TSubmission); setSubmission({} as TSubmission);
if (!(keepLatestMessage ?? false)) { if (!(keepLatestMessage ?? false)) {
logger.log('latest_message', 'Clearing all latest messages');
clearAllLatestMessages(); clearAllLatestMessages();
} }
if (isCancelled) { if (isCancelled) {

View file

@ -243,7 +243,6 @@
"com_error_files_dupe": "تم اكتشاف ملف مكرر.", "com_error_files_dupe": "تم اكتشاف ملف مكرر.",
"com_error_files_empty": "الملفات الفارغة غير مسموح بها", "com_error_files_empty": "الملفات الفارغة غير مسموح بها",
"com_error_files_process": "حدث خطأ أثناء معالجة الملف.", "com_error_files_process": "حدث خطأ أثناء معالجة الملف.",
"com_error_files_unsupported_capability": "لا توجد قدرات مفعّلة تدعم هذا النوع من الملفات.",
"com_error_files_upload": "حدث خطأ أثناء رفع الملف.", "com_error_files_upload": "حدث خطأ أثناء رفع الملف.",
"com_error_files_upload_canceled": "تم إلغاء طلب تحميل الملف. ملاحظة: قد تكون عملية تحميل الملف لا تزال قيد المعالجة وستحتاج إلى حذفها يدويًا.", "com_error_files_upload_canceled": "تم إلغاء طلب تحميل الملف. ملاحظة: قد تكون عملية تحميل الملف لا تزال قيد المعالجة وستحتاج إلى حذفها يدويًا.",
"com_error_files_validation": "حدث خطأ أثناء التحقق من صحة الملف.", "com_error_files_validation": "حدث خطأ أثناء التحقق من صحة الملف.",
@ -269,7 +268,6 @@
"com_nav_auto_scroll": "التمرير التلقائي إلى أحدث عند الفتح", "com_nav_auto_scroll": "التمرير التلقائي إلى أحدث عند الفتح",
"com_nav_auto_send_prompts": "إرسال تلقائي للموجهات", "com_nav_auto_send_prompts": "إرسال تلقائي للموجهات",
"com_nav_auto_send_text": "إرسال النص تلقائيًا", "com_nav_auto_send_text": "إرسال النص تلقائيًا",
"com_nav_auto_send_text_disabled": "اضبط القيمة على -1 للتعطيل",
"com_nav_auto_transcribe_audio": "النسخ التلقائي للصوت", "com_nav_auto_transcribe_audio": "النسخ التلقائي للصوت",
"com_nav_automatic_playback": "تشغيل تلقائي لآخر رسالة", "com_nav_automatic_playback": "تشغيل تلقائي لآخر رسالة",
"com_nav_balance": "توازن", "com_nav_balance": "توازن",

View file

@ -273,7 +273,6 @@
"com_error_files_dupe": "S'ha detectat un fitxer duplicat.", "com_error_files_dupe": "S'ha detectat un fitxer duplicat.",
"com_error_files_empty": "No es permeten fitxers buits.", "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_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": "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_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.", "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_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_prompts": "Envia automàticament els prompts",
"com_nav_auto_send_text": "Envia text automàticament", "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_auto_transcribe_audio": "Transcriu àudio automàticament",
"com_nav_automatic_playback": "Reprodueix automàticament el darrer missatge", "com_nav_automatic_playback": "Reprodueix automàticament el darrer missatge",
"com_nav_balance": "Balanç", "com_nav_balance": "Balanç",

View file

@ -189,7 +189,6 @@
"com_error_files_dupe": "Byl zjištěn duplicitní soubor.", "com_error_files_dupe": "Byl zjištěn duplicitní soubor.",
"com_error_files_empty": "Prázdné soubory nejsou povoleny.", "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_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": "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_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ě.", "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_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_prompts": "Automatické odesílání výzev",
"com_nav_auto_send_text": "Automatické odesílání textu", "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_auto_transcribe_audio": "Automaticky přepisovat zvuk",
"com_nav_automatic_playback": "Automatické přehrávání poslední zprávy", "com_nav_automatic_playback": "Automatické přehrávání poslední zprávy",
"com_nav_balance": "Zůstatek", "com_nav_balance": "Zůstatek",

View file

@ -268,7 +268,6 @@
"com_error_files_dupe": "Duplikatfil fundet.", "com_error_files_dupe": "Duplikatfil fundet.",
"com_error_files_empty": "Tomme filer er ikke tilladt.", "com_error_files_empty": "Tomme filer er ikke tilladt.",
"com_error_files_process": "Der opstod en fejl under behandlingen af filen.", "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": "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_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.", "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_scroll": "Auto-scroll til seneste besked, når chatten er åben",
"com_nav_auto_send_prompts": "Automatisk afsendelse af prompte", "com_nav_auto_send_prompts": "Automatisk afsendelse af prompte",
"com_nav_auto_send_text": "Send tekst automatisk", "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_auto_transcribe_audio": "Automatisk transskribering af lyd",
"com_nav_automatic_playback": "Autoplay Seneste besked", "com_nav_automatic_playback": "Autoplay Seneste besked",
"com_nav_balance": "Balance", "com_nav_balance": "Balance",

View file

@ -26,6 +26,7 @@
"com_agents_category_sales_description": "Agenten mit Fokus auf Vertriebsprozesse und Kundenbeziehungen", "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_tab_label": "Kategorie {{category}}. {{position}} von {{total}}",
"com_agents_category_tabs_label": "Agenten-Kategorien", "com_agents_category_tabs_label": "Agenten-Kategorien",
"com_agents_chat_with": "Chatte mit {{name}}",
"com_agents_clear_search": "Suche löschen", "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": "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", "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_suggestion": "Bitte überprüfe deine Internetverbindung und versuche es erneut.",
"com_agents_error_timeout_title": "Verbindungs-Timeout", "com_agents_error_timeout_title": "Verbindungs-Timeout",
"com_agents_error_title": "Es ist ein Fehler aufgetreten", "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_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_context_label": "Dateikontext",
"com_agents_file_search_disabled": "Der Agent muss erstellt werden, bevor Dateien für die Dateisuche hochgeladen werden können.", "com_agents_file_search_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_dupe": "Doppelte Datei erkannt.",
"com_error_files_empty": "Leere Dateien sind nicht zulässig", "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_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": "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_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.", "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_scroll": "Automatisch zur neuesten Nachricht scrollen, wenn der Chat geöffnet wird",
"com_nav_auto_send_prompts": "Prompts automatisch senden", "com_nav_auto_send_prompts": "Prompts automatisch senden",
"com_nav_auto_send_text": "Text 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_auto_transcribe_audio": "Audio automatisch transkribieren",
"com_nav_automatic_playback": "Automatische Wiedergabe der neuesten Nachricht", "com_nav_automatic_playback": "Automatische Wiedergabe der neuesten Nachricht",
"com_nav_balance": "Guthaben", "com_nav_balance": "Guthaben",
@ -831,6 +831,7 @@
"com_ui_delete_success": "Erfolgreich gelöscht", "com_ui_delete_success": "Erfolgreich gelöscht",
"com_ui_delete_tool": "Werkzeug löschen", "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_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_deleted": "Gelöscht",
"com_ui_deleting_file": "Lösche Datei...", "com_ui_deleting_file": "Lösche Datei...",
"com_ui_descending": "Absteigend", "com_ui_descending": "Absteigend",
@ -1023,6 +1024,7 @@
"com_ui_no_category": "Keine Kategorie", "com_ui_no_category": "Keine Kategorie",
"com_ui_no_changes": "Es wurden keine Änderungen vorgenommen", "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_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_personalization_available": "Derzeit sind keine Personalisierungsoptionen verfügbar.",
"com_ui_no_read_access": "Du hast keine Berechtigung, Erinnerungen anzuzeigen.", "com_ui_no_read_access": "Du hast keine Berechtigung, Erinnerungen anzuzeigen.",
"com_ui_no_results_found": "Keine Ergebnisse gefunden", "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": "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_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_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_success": "Datei erfolgreich hochgeladen",
"com_ui_upload_type": "Upload-Typ auswählen", "com_ui_upload_type": "Upload-Typ auswählen",
"com_ui_usage": "Nutzung", "com_ui_usage": "Nutzung",
@ -1263,6 +1266,8 @@
"com_ui_web_search_scraper": "Scraper", "com_ui_web_search_scraper": "Scraper",
"com_ui_web_search_scraper_firecrawl": "Firecrawl API\n", "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_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_api_key": "SearXNG API Key (optional) einfügen",
"com_ui_web_search_searxng_instance_url": "SearXNG Instanz URL", "com_ui_web_search_searxng_instance_url": "SearXNG Instanz URL",
"com_ui_web_searching": "Internetsuche läuft", "com_ui_web_searching": "Internetsuche läuft",

View file

@ -1,6 +1,6 @@
{ {
"chat_direction_left_to_right": "something needs to go here. was empty", "chat_direction_left_to_right": "Left to Right",
"chat_direction_right_to_left": "something needs to go here. was empty", "chat_direction_right_to_left": "Right to Left",
"com_a11y_ai_composing": "The AI is still composing.", "com_a11y_ai_composing": "The AI is still composing.",
"com_a11y_end": "The AI has finished their reply.", "com_a11y_end": "The AI has finished their reply.",
"com_a11y_start": "The AI has started 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_process": "An error occurred while processing the file.",
"com_error_files_upload": "An error occurred while uploading the file.", "com_error_files_upload": "An error occurred while uploading the file.",
"com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.", "com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.",
"com_error_files_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_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_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.", "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_scroll": "Auto-Scroll to latest message on chat open",
"com_nav_auto_send_prompts": "Auto-send Prompts", "com_nav_auto_send_prompts": "Auto-send Prompts",
"com_nav_auto_send_text": "Auto send text", "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_auto_transcribe_audio": "Auto transcribe audio",
"com_nav_automatic_playback": "Autoplay Latest Message", "com_nav_automatic_playback": "Autoplay Latest Message",
"com_nav_balance": "Balance", "com_nav_balance": "Balance",
@ -561,6 +561,7 @@
"com_nav_setting_balance": "Balance", "com_nav_setting_balance": "Balance",
"com_nav_setting_chat": "Chat", "com_nav_setting_chat": "Chat",
"com_nav_setting_data": "Data controls", "com_nav_setting_data": "Data controls",
"com_nav_setting_delay": "Delay (s)",
"com_nav_setting_general": "General", "com_nav_setting_general": "General",
"com_nav_setting_mcp": "MCP Settings", "com_nav_setting_mcp": "MCP Settings",
"com_nav_setting_personalization": "Personalization", "com_nav_setting_personalization": "Personalization",
@ -760,6 +761,7 @@
"com_ui_client_secret": "Client Secret", "com_ui_client_secret": "Client Secret",
"com_ui_close": "Close", "com_ui_close": "Close",
"com_ui_close_menu": "Close Menu", "com_ui_close_menu": "Close Menu",
"com_ui_close_settings": "Close Settings",
"com_ui_close_window": "Close Window", "com_ui_close_window": "Close Window",
"com_ui_code": "Code", "com_ui_code": "Code",
"com_ui_collapse_chat": "Collapse Chat", "com_ui_collapse_chat": "Collapse Chat",
@ -858,6 +860,7 @@
"com_ui_edit_editing_image": "Editing image", "com_ui_edit_editing_image": "Editing image",
"com_ui_edit_mcp_server": "Edit MCP Server", "com_ui_edit_mcp_server": "Edit MCP Server",
"com_ui_edit_memory": "Edit Memory", "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_empty_category": "-",
"com_ui_endpoint": "Endpoint", "com_ui_endpoint": "Endpoint",
"com_ui_endpoint_menu": "LLM Endpoint Menu", "com_ui_endpoint_menu": "LLM Endpoint Menu",
@ -892,6 +895,7 @@
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason", "com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
"com_ui_field_max_length": "{{field}} must be less than {{length}} characters", "com_ui_field_max_length": "{{field}} must be less than {{length}} characters",
"com_ui_field_required": "This field is required", "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_size": "File Size",
"com_ui_file_token_limit": "File Token Limit", "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", "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_file_type_error": "Unsupported import type",
"com_ui_import_conversation_info": "Import conversations from a JSON file", "com_ui_import_conversation_info": "Import conversations from a JSON file",
"com_ui_import_conversation_success": "Conversations imported successfully", "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_include_shadcnui": "Include shadcn/ui components instructions",
"com_ui_initializing": "Initializing...", "com_ui_initializing": "Initializing...",
"com_ui_input": "Input", "com_ui_input": "Input",
"com_ui_instructions": "Instructions", "com_ui_instructions": "Instructions",
"com_ui_key": "Key", "com_ui_key": "Key",
"com_ui_key_required": "API key is required",
"com_ui_late_night": "Happy late night", "com_ui_late_night": "Happy late night",
"com_ui_latest_footer": "Every AI for Everyone.", "com_ui_latest_footer": "Every AI for Everyone.",
"com_ui_latest_production_version": "Latest production version", "com_ui_latest_production_version": "Latest production version",
@ -973,6 +979,7 @@
"com_ui_manage": "Manage", "com_ui_manage": "Manage",
"com_ui_marketplace": "Marketplace", "com_ui_marketplace": "Marketplace",
"com_ui_marketplace_allow_use": "Allow using 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_max_tags": "Maximum number allowed is {{0}}, using latest values.",
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully", "com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
"com_ui_mcp_configure_server": "Configure {{0}}", "com_ui_mcp_configure_server": "Configure {{0}}",
@ -1067,6 +1074,7 @@
"com_ui_privacy_policy": "Privacy policy", "com_ui_privacy_policy": "Privacy policy",
"com_ui_privacy_policy_url": "Privacy Policy URL", "com_ui_privacy_policy_url": "Privacy Policy URL",
"com_ui_prompt": "Prompt", "com_ui_prompt": "Prompt",
"com_ui_prompt_groups": "Prompt Groups List",
"com_ui_prompt_name": "Prompt Name", "com_ui_prompt_name": "Prompt Name",
"com_ui_prompt_name_required": "Prompt Name is required", "com_ui_prompt_name_required": "Prompt Name is required",
"com_ui_prompt_preview_not_shared": "The author has not allowed collaboration for this prompt.", "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_failed": "Failed to rename conversation",
"com_ui_rename_prompt": "Rename Prompt", "com_ui_rename_prompt": "Rename Prompt",
"com_ui_requires_auth": "Requires Authentication", "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_var": "Reset {{0}}",
"com_ui_reset_zoom": "Reset Zoom", "com_ui_reset_zoom": "Reset Zoom",
"com_ui_resource": "resource", "com_ui_resource": "resource",
@ -1104,6 +1114,8 @@
"com_ui_revoke_info": "Revoke all user provided credentials", "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_confirm": "Are you sure you want to revoke this key?",
"com_ui_revoke_key_endpoint": "Revoke Key for {{0}}", "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": "Revoke Keys",
"com_ui_revoke_keys_confirm": "Are you sure you want to revoke all keys?", "com_ui_revoke_keys_confirm": "Are you sure you want to revoke all keys?",
"com_ui_role": "Role", "com_ui_role": "Role",
@ -1117,11 +1129,15 @@
"com_ui_role_viewer": "Viewer", "com_ui_role_viewer": "Viewer",
"com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it", "com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it",
"com_ui_roleplay": "Roleplay", "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": "Run Code",
"com_ui_run_code_error": "There was an error running the code", "com_ui_run_code_error": "There was an error running the code",
"com_ui_save": "Save", "com_ui_save": "Save",
"com_ui_save_badge_changes": "Save badge changes?", "com_ui_save_badge_changes": "Save badge changes?",
"com_ui_save_changes": "Save 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_save_submit": "Save & Submit",
"com_ui_saved": "Saved!", "com_ui_saved": "Saved!",
"com_ui_saving": "Saving...", "com_ui_saving": "Saving...",
@ -1218,6 +1234,7 @@
"com_ui_update_mcp_success": "Successfully created or updated MCP", "com_ui_update_mcp_success": "Successfully created or updated MCP",
"com_ui_upload": "Upload", "com_ui_upload": "Upload",
"com_ui_upload_agent_avatar": "Successfully updated agent avatar", "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_code_files": "Upload for Code Interpreter",
"com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.", "com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.",
"com_ui_upload_error": "There was an error uploading your file", "com_ui_upload_error": "There was an error uploading your file",
@ -1279,5 +1296,8 @@
"com_ui_x_selected": "{{0}} selected", "com_ui_x_selected": "{{0}} selected",
"com_ui_yes": "Yes", "com_ui_yes": "Yes",
"com_ui_zoom": "Zoom", "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" "com_user_message": "You"
} }

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