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_MODELS=o1,o1-mini,o1-preview,gpt-4o,gpt-4.5-preview,chatgpt-4o-latest,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k
# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini
DEBUG_OPENAI=false
@ -459,6 +459,9 @@ OPENID_CALLBACK_URL=/oauth/openid/callback
OPENID_REQUIRED_ROLE=
OPENID_REQUIRED_ROLE_TOKEN_KIND=
OPENID_REQUIRED_ROLE_PARAMETER_PATH=
OPENID_ADMIN_ROLE=
OPENID_ADMIN_ROLE_PARAMETER_PATH=
OPENID_ADMIN_ROLE_TOKEN_KIND=
# Set to determine which user info property returned from OpenID Provider to store as the User's username
OPENID_USERNAME_CLAIM=
# Set to determine which user info property returned from OpenID Provider to store as the User's name
@ -650,6 +653,12 @@ HELP_AND_FAQ_URL=https://librechat.ai
# Google tag manager id
#ANALYTICS_GTM_ID=user provided google tag manager id
# limit conversation file imports to a certain number of bytes in size to avoid the container
# maxing out memory limitations by unremarking this line and supplying a file size in bytes
# such as the below example of 250 mib
# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000
#===============#
# REDIS Options #
#===============#

View file

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

View file

@ -3,6 +3,7 @@ const fetch = require('node-fetch');
const { logger } = require('@librechat/data-schemas');
const {
getBalanceConfig,
extractFileContext,
encodeAndFormatAudios,
encodeAndFormatVideos,
encodeAndFormatDocuments,
@ -10,6 +11,7 @@ const {
const {
Constants,
ErrorTypes,
FileSources,
ContentTypes,
excludedKeys,
EModelEndpoint,
@ -21,6 +23,7 @@ const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { checkBalance } = require('~/models/balanceMethods');
const { truncateToolCallOutputs } = require('./prompts');
const countTokens = require('~/server/utils/countTokens');
const { getFiles } = require('~/models/File');
const TextStream = require('./TextStream');
@ -1245,27 +1248,62 @@ class BaseClient {
return audioResult.files;
}
/**
* Extracts text context from attachments and sets it on the message.
* This handles text that was already extracted from files (OCR, transcriptions, document text, etc.)
* @param {TMessage} message - The message to add context to
* @param {MongoFile[]} attachments - Array of file attachments
* @returns {Promise<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) {
const categorizedAttachments = {
images: [],
documents: [],
videos: [],
audios: [],
documents: [],
};
const allFiles = [];
for (const file of attachments) {
/** @type {FileSources} */
const source = file.source ?? FileSources.local;
if (source === FileSources.text) {
allFiles.push(file);
continue;
}
if (file.embedded === true || file.metadata?.fileIdentifier != null) {
allFiles.push(file);
continue;
}
if (file.type.startsWith('image/')) {
categorizedAttachments.images.push(file);
} else if (file.type === 'application/pdf') {
categorizedAttachments.documents.push(file);
allFiles.push(file);
} else if (file.type.startsWith('video/')) {
categorizedAttachments.videos.push(file);
allFiles.push(file);
} else if (file.type.startsWith('audio/')) {
categorizedAttachments.audios.push(file);
allFiles.push(file);
}
}
const [imageFiles, documentFiles, videoFiles, audioFiles] = await Promise.all([
const [imageFiles] = await Promise.all([
categorizedAttachments.images.length > 0
? this.addImageURLs(message, categorizedAttachments.images)
: Promise.resolve([]),
@ -1280,7 +1318,8 @@ class BaseClient {
: Promise.resolve([]),
]);
const allFiles = [...imageFiles, ...documentFiles, ...videoFiles, ...audioFiles];
allFiles.push(...imageFiles);
const seenFileIds = new Set();
const uniqueFiles = [];
@ -1345,6 +1384,7 @@ class BaseClient {
{},
);
await this.addFileContextToMessage(message, files);
await this.processAttachments(message, files);
this.message_file_map[message.messageId] = files;

View file

@ -3,6 +3,7 @@ const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider');
const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate');
const { components } = require('~/app/clients/prompts/shadcn-docs/components');
/** @deprecated */
// eslint-disable-next-line no-unused-vars
const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations.
@ -115,6 +116,7 @@ Here are some examples of correct usage of artifacts:
</assistant_response>
</example>
</examples>`;
const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations.
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
@ -165,6 +167,10 @@ Artifacts are for substantial, self-contained content that users might modify or
- SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
- The assistant should specify the viewbox of the SVG rather than defining a width/height
- Markdown: "text/markdown" or "text/md"
- The user interface will render Markdown content placed within the artifact tags.
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
- Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react"
@ -366,6 +372,10 @@ Artifacts are for substantial, self-contained content that users might modify or
- SVG: "image/svg+xml"
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
- The assistant should specify the viewbox of the SVG rather than defining a width/height
- Markdown: "text/markdown" or "text/md"
- The user interface will render Markdown content placed within the artifact tags.
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
- Mermaid Diagrams: "application/vnd.mermaid"
- The user interface will render Mermaid diagrams placed within the artifact tags.
- React Components: "application/vnd.react"

View file

@ -125,7 +125,7 @@ const tokenValues = Object.assign(
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
'gemini-2.5-flash-lite': { prompt: 0.075, completion: 0.4 },
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },

View file

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

View file

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

View file

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

View file

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

View file

@ -115,6 +115,9 @@ router.get('/', async function (req, res) {
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
openidReuseTokens,
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
};
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);

View file

@ -65,6 +65,7 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
serverName,
serverUrl,
userId,
getOAuthHeaders(serverName),
oauthConfig,
);
@ -132,7 +133,12 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
});
logger.debug('[MCP OAuth] Completing OAuth flow');
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
const tokens = await MCPOAuthHandler.completeOAuthFlow(
flowId,
code,
flowManager,
getOAuthHeaders(serverName),
);
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
/** Persist tokens immediately so reconnection uses fresh credentials */
@ -538,4 +544,10 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
}
});
function getOAuthHeaders(serverName) {
const mcpManager = getMCPManager();
const serverConfig = mcpManager.getRawConfig(serverName);
return serverConfig?.oauth_headers ?? {};
}
module.exports = router;

View file

@ -99,7 +99,8 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
router.post('/:conversationId', requireJwtAuth, async (req, res) => {
try {
const created = await createSharedLink(req.user.id, req.params.conversationId);
const { targetMessageId } = req.body;
const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId);
if (created) {
res.status(200).json(created);
} else {

View file

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

View file

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

View file

@ -1,16 +1,14 @@
const axios = require('axios');
const { logAxiosError } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { logAxiosError, processTextWithTokenLimit } = require('@librechat/api');
const {
FileSources,
VisionModes,
ImageDetail,
ContentTypes,
EModelEndpoint,
mergeFileConfig,
} = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const countTokens = require('~/server/utils/countTokens');
/**
* Converts a readable stream to a base64 encoded string.
@ -88,15 +86,14 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
* @param {Array<MongoFile>} files - The array of files to encode and format.
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
* @param {string} [mode] - Optional: The endpoint mode for the image.
* @returns {Promise<{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
*/
async function encodeAndFormat(req, files, endpoint, mode) {
const promises = [];
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
const encodingMethods = {};
/** @type {{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */
/** @type {{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */
const result = {
text: '',
files: [],
image_urls: [],
};
@ -105,29 +102,9 @@ async function encodeAndFormat(req, files, endpoint, mode) {
return result;
}
const fileTokenLimit =
req.body?.fileTokenLimit ?? mergeFileConfig(req.config?.fileConfig).fileTokenLimit;
for (let file of files) {
/** @type {FileSources} */
const source = file.source ?? FileSources.local;
if (source === FileSources.text && file.text) {
let fileText = file.text;
const { text: limitedText, wasTruncated } = await processTextWithTokenLimit({
text: fileText,
tokenLimit: fileTokenLimit,
tokenCountFn: (text) => countTokens(text),
});
if (wasTruncated) {
logger.debug(
`[encodeAndFormat] Text content truncated for file: ${file.filename} due to token limits`,
);
}
result.text += `${!result.text ? 'Attached document(s):\n```md' : '\n\n---\n\n'}# "${file.filename}"\n${limitedText}\n`;
}
if (!file.height) {
promises.push([file, null]);
@ -165,10 +142,6 @@ async function encodeAndFormat(req, files, endpoint, mode) {
promises.push(preparePayload(req, file));
}
if (result.text) {
result.text += '\n```';
}
const detail = req.body.imageDetail ?? ImageDetail.auto;
/** @type {Array<[MongoFile, string]>} */

View file

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

View file

@ -10,6 +10,15 @@ const importConversations = async (job) => {
const { filepath, requestUserId } = job;
try {
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
/* error if file is too large */
const fileInfo = await fs.stat(filepath);
if (fileInfo.size > process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES) {
throw new Error(
`File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES} bytes.`,
);
}
const fileData = await fs.readFile(filepath, 'utf8');
const jsonData = JSON.parse(fileData);
const importer = getImporter(jsonData);
@ -17,6 +26,7 @@ const importConversations = async (job) => {
logger.debug(`user: ${requestUserId} | Finished importing conversations`);
} catch (error) {
logger.error(`user: ${requestUserId} | Failed to import conversation: `, error);
throw error; // throw error all the way up so request does not return success
} finally {
try {
await fs.unlink(filepath);

View file

@ -1,4 +1,5 @@
const undici = require('undici');
const { get } = require('lodash');
const fetch = require('node-fetch');
const passport = require('passport');
const client = require('openid-client');
@ -329,6 +330,12 @@ async function setupOpenId() {
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata',
});
// Set of env variables that specify how to set if a user is an admin
// If not set, all users will be treated as regular users
const adminRole = process.env.OPENID_ADMIN_ROLE;
const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
const openidLogin = new CustomOpenIDStrategy(
{
config: openidConfig,
@ -386,20 +393,19 @@ async function setupOpenId() {
} else if (requiredRoleTokenKind === 'id') {
decodedToken = jwtDecode(tokenset.id_token);
}
const pathParts = requiredRoleParameterPath.split('.');
let found = true;
let roles = pathParts.reduce((o, key) => {
if (o === null || o === undefined || !(key in o)) {
found = false;
return [];
}
return o[key];
}, decodedToken);
if (!found) {
let roles = get(decodedToken, requiredRoleParameterPath);
if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) {
logger.error(
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
`[openidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`,
);
const rolesList =
requiredRoles.length === 1
? `"${requiredRoles[0]}"`
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
return done(null, false, {
message: `You must have ${rolesList} role to log in.`,
});
}
if (!requiredRoles.some((role) => roles.includes(role))) {
@ -447,6 +453,50 @@ async function setupOpenId() {
}
}
if (adminRole && adminRoleParameterPath && adminRoleTokenKind) {
let adminRoleObject;
switch (adminRoleTokenKind) {
case 'access':
adminRoleObject = jwtDecode(tokenset.access_token);
break;
case 'id':
adminRoleObject = jwtDecode(tokenset.id_token);
break;
case 'userinfo':
adminRoleObject = userinfo;
break;
default:
logger.error(
`[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`,
);
return done(new Error('Invalid admin role token kind'));
}
const adminRoles = get(adminRoleObject, adminRoleParameterPath);
// Accept 3 types of values for the object extracted from adminRoleParameterPath:
// 1. A boolean value indicating if the user is an admin
// 2. A string with a single role name
// 3. An array of role names
if (
adminRoles &&
(adminRoles === true ||
adminRoles === adminRole ||
(Array.isArray(adminRoles) && adminRoles.includes(adminRole)))
) {
user.role = 'ADMIN';
logger.info(
`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`,
);
} else if (user.role === 'ADMIN') {
user.role = 'USER';
logger.info(
`[openidStrategy] User ${username} demoted from admin - role no longer present in token`,
);
}
}
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
/** @type {string | undefined} */
const imageUrl = userinfo.picture;

View file

@ -125,6 +125,9 @@ describe('setupOpenId', () => {
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
process.env.OPENID_ADMIN_ROLE = 'admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'permissions';
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.PROXY;
@ -133,6 +136,7 @@ describe('setupOpenId', () => {
// Default jwtDecode mock returns a token that includes the required role.
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
permissions: ['admin'],
});
// By default, assume that no user is found, so createUser will be called
@ -441,4 +445,475 @@ describe('setupOpenId', () => {
expect(callOptions.usePKCE).toBe(false);
expect(callOptions.params?.code_challenge_method).toBeUndefined();
});
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
// Act
const { user } = await validate(tokenset);
// Assert verify that the user role is set to "ADMIN"
expect(user.role).toBe('ADMIN');
});
it('should not set user role if OPENID_ADMIN_ROLE is set but the user does not have that role', async () => {
// Arrange simulate a token without the admin permission
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
permissions: ['not-admin'],
});
// Act
const { user } = await validate(tokenset);
// Assert verify that the user role is not defined
expect(user.role).toBeUndefined();
});
it('should demote existing admin user when admin role is removed from token', async () => {
// Arrange simulate an existing user who is currently an admin
const existingAdminUser = {
_id: 'existingAdminId',
provider: 'openid',
email: tokenset.claims().email,
openidId: tokenset.claims().sub,
username: 'adminuser',
name: 'Admin User',
role: 'ADMIN',
};
findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingAdminUser;
}
return null;
});
// Token without admin permission
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
permissions: ['not-admin'],
});
const { logger } = require('@librechat/data-schemas');
// Act
const { user } = await validate(tokenset);
// Assert verify that the user was demoted
expect(user.role).toBe('USER');
expect(updateUser).toHaveBeenCalledWith(
existingAdminUser._id,
expect.objectContaining({
role: 'USER',
}),
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('demoted from admin - role no longer present in token'),
);
});
it('should NOT demote admin user when admin role env vars are not configured', async () => {
// Arrange remove admin role env vars
delete process.env.OPENID_ADMIN_ROLE;
delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
// Simulate an existing admin user
const existingAdminUser = {
_id: 'existingAdminId',
provider: 'openid',
email: tokenset.claims().email,
openidId: tokenset.claims().sub,
username: 'adminuser',
name: 'Admin User',
role: 'ADMIN',
};
findUser.mockImplementation(async (query) => {
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
return existingAdminUser;
}
return null;
});
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
});
// Act
const { user } = await validate(tokenset);
// Assert verify that the admin user was NOT demoted
expect(user.role).toBe('ADMIN');
expect(updateUser).toHaveBeenCalledWith(
existingAdminUser._id,
expect.objectContaining({
role: 'ADMIN',
}),
);
});
describe('lodash get - nested path extraction', () => {
it('should extract roles from deeply nested token path', async () => {
process.env.OPENID_REQUIRED_ROLE = 'app-user';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-client.roles';
jwtDecode.mockReturnValue({
resource_access: {
'my-client': {
roles: ['app-user', 'viewer'],
},
},
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(user).toBeTruthy();
expect(user.email).toBe(tokenset.claims().email);
});
it('should extract roles from three-level nested path', async () => {
process.env.OPENID_REQUIRED_ROLE = 'editor';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.access.permissions.roles';
jwtDecode.mockReturnValue({
data: {
access: {
permissions: {
roles: ['editor', 'reader'],
},
},
},
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(user).toBeTruthy();
});
it('should log error and reject login when required role path does not exist in token', async () => {
const { logger } = require('@librechat/data-schemas');
process.env.OPENID_REQUIRED_ROLE = 'app-user';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.nonexistent.roles';
jwtDecode.mockReturnValue({
resource_access: {
'my-client': {
roles: ['app-user'],
},
},
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user, details } = await validate(tokenset);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
"Key 'resource_access.nonexistent.roles' not found or invalid type in id token!",
),
);
expect(user).toBe(false);
expect(details.message).toContain('role to log in');
});
it('should handle missing intermediate nested path gracefully', async () => {
const { logger } = require('@librechat/data-schemas');
process.env.OPENID_REQUIRED_ROLE = 'user';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'org.team.roles';
jwtDecode.mockReturnValue({
org: {
other: 'value',
},
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining("Key 'org.team.roles' not found or invalid type in id token!"),
);
expect(user).toBe(false);
});
it('should extract admin role from nested path in access token', async () => {
process.env.OPENID_ADMIN_ROLE = 'admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'realm_access.roles';
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access';
jwtDecode.mockImplementation((token) => {
if (token === 'fake_access_token') {
return {
realm_access: {
roles: ['admin', 'user'],
},
};
}
return {
roles: ['requiredRole'],
};
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(user.role).toBe('ADMIN');
});
it('should extract admin role from nested path in userinfo', async () => {
process.env.OPENID_ADMIN_ROLE = 'admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'organization.permissions';
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'userinfo';
const userinfoWithNestedGroups = {
...tokenset.claims(),
organization: {
permissions: ['admin', 'write'],
},
};
require('openid-client').fetchUserInfo.mockResolvedValue({
organization: {
permissions: ['admin', 'write'],
},
});
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate({
...tokenset,
claims: () => userinfoWithNestedGroups,
});
expect(user.role).toBe('ADMIN');
});
it('should handle boolean admin role value', async () => {
process.env.OPENID_ADMIN_ROLE = 'admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'is_admin';
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
is_admin: true,
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(user.role).toBe('ADMIN');
});
it('should handle string admin role value matching exactly', async () => {
process.env.OPENID_ADMIN_ROLE = 'super-admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
role: 'super-admin',
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(user.role).toBe('ADMIN');
});
it('should not set admin role when string value does not match', async () => {
process.env.OPENID_ADMIN_ROLE = 'super-admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
role: 'regular-user',
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(user.role).toBeUndefined();
});
it('should handle array admin role value', async () => {
process.env.OPENID_ADMIN_ROLE = 'site-admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
app_roles: ['user', 'site-admin', 'moderator'],
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(user.role).toBe('ADMIN');
});
it('should not set admin when role is not in array', async () => {
process.env.OPENID_ADMIN_ROLE = 'site-admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
app_roles: ['user', 'moderator'],
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(user.role).toBeUndefined();
});
it('should handle nested path with special characters in keys', async () => {
process.env.OPENID_REQUIRED_ROLE = 'app-user';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles';
jwtDecode.mockReturnValue({
resource_access: {
'my-app-123': {
roles: ['app-user'],
},
},
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(user).toBeTruthy();
});
it('should handle empty object at nested path', async () => {
const { logger } = require('@librechat/data-schemas');
process.env.OPENID_REQUIRED_ROLE = 'user';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'access.roles';
jwtDecode.mockReturnValue({
access: {},
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining("Key 'access.roles' not found or invalid type in id token!"),
);
expect(user).toBe(false);
});
it('should handle null value at intermediate path', async () => {
const { logger } = require('@librechat/data-schemas');
process.env.OPENID_REQUIRED_ROLE = 'user';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.roles';
jwtDecode.mockReturnValue({
data: null,
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining("Key 'data.roles' not found or invalid type in id token!"),
);
expect(user).toBe(false);
});
it('should reject login with invalid admin role token kind', async () => {
process.env.OPENID_ADMIN_ROLE = 'admin';
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'roles';
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'invalid';
const { logger } = require('@librechat/data-schemas');
jwtDecode.mockReturnValue({
roles: ['requiredRole', 'admin'],
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind');
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
"Invalid admin role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'",
),
);
});
it('should reject login when roles path returns invalid type (object)', async () => {
const { logger } = require('@librechat/data-schemas');
process.env.OPENID_REQUIRED_ROLE = 'app-user';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
jwtDecode.mockReturnValue({
roles: { admin: true, user: false },
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user, details } = await validate(tokenset);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining("Key 'roles' not found or invalid type in id token!"),
);
expect(user).toBe(false);
expect(details.message).toContain('role to log in');
});
it('should reject login when roles path returns invalid type (number)', async () => {
const { logger } = require('@librechat/data-schemas');
process.env.OPENID_REQUIRED_ROLE = 'user';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roleCount';
jwtDecode.mockReturnValue({
roleCount: 5,
});
await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallback();
const { user } = await validate(tokenset);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining("Key 'roleCount' not found or invalid type in id token!"),
);
expect(user).toBe(false);
});
});
});

View file

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

View file

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

View file

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

View file

@ -27,7 +27,6 @@ export default function Artifacts() {
const {
activeTab,
isMermaid,
setActiveTab,
currentIndex,
cycleArtifact,
@ -116,7 +115,6 @@ export default function Artifacts() {
</div>
{/* Content */}
<ArtifactTabs
isMermaid={isMermaid}
artifact={currentArtifact}
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
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 (
<div className="flex items-center justify-between">
<div>{localize('com_nav_voice_select')}</div>
<div id={labelId}>{localize('com_nav_voice_select')}</div>
<Dropdown
key={`browser-voice-dropdown-${voices.length}`}
value={voice ?? ''}
@ -30,6 +32,7 @@ export function BrowserVoiceDropdown() {
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="BrowserVoiceDropdown"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);
@ -48,9 +51,11 @@ export function ExternalVoiceDropdown() {
}
};
const labelId = 'external-voice-dropdown-label';
return (
<div className="flex items-center justify-between">
<div>{localize('com_nav_voice_select')}</div>
<div id={labelId}>{localize('com_nav_voice_select')}</div>
<Dropdown
key={`external-voice-dropdown-${voices.length}`}
value={voice ?? ''}
@ -59,6 +64,7 @@ export function ExternalVoiceDropdown() {
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
testId="ExternalVoiceDropdown"
className="z-50"
aria-labelledby={labelId}
/>
</div>
);

View file

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

View file

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

View file

@ -2,7 +2,12 @@ import React, { useMemo } from 'react';
import type { ModelSelectorProps } from '~/common';
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
import {
renderModelSpecs,
renderEndpoints,
renderSearchResults,
renderCustomGroups,
} from './components';
import { getSelectedIcon, getDisplayValue } from './utils';
import { CustomMenu as Menu } from './CustomMenu';
import DialogManager from './DialogManager';
@ -86,8 +91,15 @@ function ModelSelectorContent() {
renderSearchResults(searchResults, localize, searchValue)
) : (
<>
{renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')}
{/* Render ungrouped modelSpecs (no group field) */}
{renderModelSpecs(
modelSpecs?.filter((spec) => !spec.group) || [],
selectedValues.modelSpec || '',
)}
{/* Render endpoints (will include grouped specs matching endpoint names) */}
{renderEndpoints(mappedEndpoints ?? [])}
{/* Render custom groups (specs with group field not matching any endpoint) */}
{renderCustomGroups(modelSpecs || [], mappedEndpoints ?? [])}
</>
)}
</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 { TooltipAnchor, Spinner } from '@librechat/client';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { renderEndpointModels } from './EndpointModelItem';
import { ModelSpecItem } from './ModelSpecItem';
import { filterModels } from '../utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -57,6 +59,7 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
const {
agentsMap,
assistantsMap,
modelSpecs,
selectedValues,
handleOpenKeyDialog,
handleSelectEndpoint,
@ -64,7 +67,19 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
setEndpointSearchValue,
endpointRequiresUserKey,
} = useModelSelectorContext();
const { model: selectedModel, endpoint: selectedEndpoint } = selectedValues;
const {
model: selectedModel,
endpoint: selectedEndpoint,
modelSpec: selectedSpec,
} = selectedValues;
// Filter modelSpecs for this endpoint (by group matching endpoint value)
const endpointSpecs = useMemo(() => {
if (!modelSpecs || !modelSpecs.length) {
return [];
}
return modelSpecs.filter((spec: TModelSpec) => spec.group === endpoint.value);
}, [modelSpecs, endpoint.value]);
const searchValue = endpointSearchValues[endpoint.value] || '';
const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]);
@ -138,10 +153,17 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
<div className="flex items-center justify-center p-2">
<Spinner />
</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>
);

View file

@ -36,23 +36,26 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
<MenuItem
key={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 ? (
<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" />
</div>
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
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}
</div>
) : 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>
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
{isSelected && (
<div className="flex-shrink-0 self-center">
<svg
width="16"
height="16"
@ -68,6 +71,7 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
fill="currentColor"
/>
</svg>
</div>
)}
</MenuItem>
);

View file

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

View file

@ -97,7 +97,10 @@ const MessageRender = memo(
() =>
showCardRender && !isLatestMessage
? () => {
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
logger.log(
'latest_message',
`Message Card click: Setting ${msg?.messageId} as latest message`,
);
logger.dir(msg);
setLatestMessage(msg!);
}

View file

@ -28,6 +28,8 @@ const LoadingSpinner = memo(() => {
);
});
LoadingSpinner.displayName = 'LoadingSpinner';
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
const localize = useLocalize();
return (
@ -74,6 +76,7 @@ const Conversations: FC<ConversationsProps> = ({
isLoading,
isSearchLoading,
}) => {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const convoHeight = isSmallScreen ? 44 : 34;
@ -181,7 +184,7 @@ const Conversations: FC<ConversationsProps> = ({
{isSearchLoading ? (
<div className="flex flex-1 items-center justify-center">
<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 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',
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
)}
role="listitem"
tabIndex={0}
role="button"
tabIndex={renaming ? -1 : 0}
aria-label={`${title || localize('com_ui_untitled')} conversation`}
onClick={(e) => {
if (renaming) {
return;
@ -149,7 +150,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
if (renaming) {
return;
}
if (e.key === 'Enter') {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleNavigation(false);
}
}}

View file

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

View file

@ -201,6 +201,7 @@ function ConvoOptions({
<Menu.MenuButton
id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')}
aria-readonly={undefined}
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',
isActiveConvo === true || isPopoverActive

View file

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

View file

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

View file

@ -1,16 +1,33 @@
import React, { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { OGDialogTemplate, OGDialog, Dropdown, useToastContext } from '@librechat/client';
import {
OGDialog,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
OGDialogFooter,
Dropdown,
useToastContext,
Button,
Label,
OGDialogTrigger,
Spinner,
} from '@librechat/client';
import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider';
import {
useRevokeAllUserKeysMutation,
useRevokeUserKeyMutation,
} from 'librechat-data-provider/react-query';
import type { TDialogProps } from '~/common';
import { useGetEndpointsQuery } from '~/data-provider';
import { RevokeKeysButton } from '~/components/Nav';
import { useUserKey, useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import CustomConfig from './CustomEndpoint';
import GoogleConfig from './GoogleConfig';
import OpenAIConfig from './OpenAIConfig';
import OtherConfig from './OtherConfig';
import HelpText from './HelpText';
import { logger } from '~/utils';
const endpointComponents = {
[EModelEndpoint.google]: GoogleConfig,
@ -42,6 +59,94 @@ const EXPIRY = {
NEVER: { label: 'never', value: 0 },
};
const RevokeKeysButton = ({
endpoint,
disabled,
setDialogOpen,
}: {
endpoint: string;
disabled: boolean;
setDialogOpen: (open: boolean) => void;
}) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const { showToast } = useToastContext();
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
const revokeKeysMutation = useRevokeAllUserKeysMutation();
const handleSuccess = () => {
showToast({
message: localize('com_ui_revoke_key_success'),
status: NotificationSeverity.SUCCESS,
});
if (!setDialogOpen) {
return;
}
setDialogOpen(false);
};
const handleError = () => {
showToast({
message: localize('com_ui_revoke_key_error'),
status: NotificationSeverity.ERROR,
});
};
const onClick = () => {
revokeKeyMutation.mutate(
{},
{
onSuccess: handleSuccess,
onError: handleError,
},
);
};
const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading;
return (
<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 = ({
open,
onOpenChange,
@ -83,7 +188,7 @@ const SetKeyDialog = ({
const submit = () => {
const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel);
let expiresAt;
let expiresAt: number | null;
if (selectedOption?.value === 0) {
expiresAt = null;
@ -92,8 +197,20 @@ const SetKeyDialog = ({
}
const saveKey = (key: string) => {
try {
saveUserKey(key, expiresAt);
showToast({
message: localize('com_ui_save_key_success'),
status: NotificationSeverity.SUCCESS,
});
onOpenChange(false);
} catch (error) {
logger.error('Error saving user key:', error);
showToast({
message: localize('com_ui_save_key_error'),
status: NotificationSeverity.ERROR,
});
}
};
if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) {
@ -148,6 +265,14 @@ const SetKeyDialog = ({
return;
}
if (!userKey.trim()) {
showToast({
message: localize('com_ui_key_required'),
status: NotificationSeverity.ERROR,
});
return;
}
saveKey(userKey);
setUserKey('');
};
@ -159,12 +284,13 @@ const SetKeyDialog = ({
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogTemplate
title={`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
className="w-11/12 max-w-2xl"
showCancelButton={false}
main={
<div className="grid w-full items-center gap-2">
<OGDialogContent className="w-11/12 max-w-2xl">
<OGDialogHeader>
<OGDialogTitle>
{`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
</OGDialogTitle>
</OGDialogHeader>
<div className="grid w-full items-center gap-2 py-4">
<small className="text-red-600">
{expiryTime === 'never'
? localize('com_endpoint_config_key_never_expires')
@ -195,20 +321,17 @@ const SetKeyDialog = ({
</FormProvider>
<HelpText endpoint={endpoint} />
</div>
}
selection={{
selectHandler: submit,
selectClasses: 'btn btn-primary',
selectText: localize('com_ui_submit'),
}}
leftButtons={
<OGDialogFooter>
<RevokeKeysButton
endpoint={endpoint}
disabled={!(expiryTime ?? '')}
setDialogOpen={onOpenChange}
/>
}
/>
<Button variant="submit" onClick={submit}>
{localize('com_ui_submit')}
</Button>
</OGDialogFooter>
</OGDialogContent>
</OGDialog>
);
};

View file

@ -96,7 +96,10 @@ const ContentRender = memo(
() =>
showCardRender && !isLatestMessage
? () => {
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
logger.log(
'latest_message',
`Message Card click: Setting ${msg?.messageId} as latest message`,
);
logger.dir(msg);
setLatestMessage(msg!);
}

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="6" x2="18" y1="6" y2="18"></line>
</svg>
<span className="sr-only">{localize('com_ui_close')}</span>
<span className="sr-only">{localize('com_ui_close_settings')}</span>
</button>
</DialogTitle>
<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>
<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 />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.CHAT}>
<Tabs.Content value={SettingsTabValues.CHAT} tabIndex={-1}>
<Chat />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.COMMANDS}>
<Tabs.Content value={SettingsTabValues.COMMANDS} tabIndex={-1}>
<Commands />
</Tabs.Content>
<Tabs.Content value={SettingsTabValues.SPEECH}>
<Tabs.Content value={SettingsTabValues.SPEECH} tabIndex={-1}>
<Speech />
</Tabs.Content>
{hasAnyPersonalizationFeature && (
<Tabs.Content value={SettingsTabValues.PERSONALIZATION}>
<Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
<Personalization
hasMemoryOptOut={hasMemoryOptOut}
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
/>
</Tabs.Content>
)}
<Tabs.Content value={SettingsTabValues.DATA}>
<Tabs.Content value={SettingsTabValues.DATA} tabIndex={-1}>
<Data />
</Tabs.Content>
{startupConfig?.balance?.enabled && (
<Tabs.Content value={SettingsTabValues.BALANCE}>
<Tabs.Content value={SettingsTabValues.BALANCE} tabIndex={-1}>
<Balance />
</Tabs.Content>
)}
<Tabs.Content value={SettingsTabValues.ACCOUNT}>
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
<Account />
</Tabs.Content>
</div>

View file

@ -1,9 +1,11 @@
import React, { useState, useRef, useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
// @ts-ignore - no type definitions available
import AvatarEditor from 'react-avatar-editor';
import { FileImage, RotateCw, Upload } from 'lucide-react';
import { FileImage, RotateCw, Upload, ZoomIn, ZoomOut, Move, X } from 'lucide-react';
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
import {
Label,
Slider,
Button,
Spinner,
@ -25,14 +27,20 @@ interface AvatarEditorRef {
getImage: () => HTMLImageElement;
}
interface Position {
x: number;
y: number;
}
function Avatar() {
const setUser = useSetRecoilState(store.user);
const [scale, setScale] = useState<number>(1);
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 fileInputRef = useRef<HTMLInputElement>(null);
const openButtonRef = useRef<HTMLButtonElement>(null);
const [image, setImage] = useState<string | File | null>(null);
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
@ -48,7 +56,6 @@ function Avatar() {
onSuccess: (data) => {
showToast({ message: localize('com_ui_upload_success') });
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
openButtonRef.current?.click();
},
onError: (error) => {
console.error('Error:', error);
@ -61,11 +68,13 @@ function Avatar() {
handleFile(file);
};
const handleFile = (file: File | undefined) => {
const handleFile = useCallback(
(file: File | undefined) => {
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
setImage(file);
setScale(1);
setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
} else {
const megabytes =
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
@ -74,16 +83,30 @@ function Avatar() {
status: 'error',
});
}
};
},
[fileConfig.avatarSizeLimit, localize, showToast],
);
const handleScaleChange = (value: number[]) => {
setScale(value[0]);
};
const handleZoomIn = () => {
setScale((prev) => Math.min(prev + 0.2, 5));
};
const handleZoomOut = () => {
setScale((prev) => Math.max(prev - 0.2, 1));
};
const handleRotate = () => {
setRotation((prev) => (prev + 90) % 360);
};
const handlePositionChange = (position: Position) => {
setPosition(position);
};
const handleUpload = () => {
if (editorRef.current) {
const canvas = editorRef.current.getImageScaledToCanvas();
@ -98,11 +121,14 @@ function Avatar() {
}
};
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
handleFile(file);
}, []);
},
[handleFile],
);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
@ -116,8 +142,15 @@ function Avatar() {
setImage(null);
setScale(1);
setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
}, []);
const handleReset = () => {
setScale(1);
setRotation(0);
setPosition({ x: 0.5, y: 0.5 });
};
return (
<OGDialog
open={isDialogOpen}
@ -125,90 +158,190 @@ function Avatar() {
setDialogOpen(open);
if (!open) {
resetImage();
setTimeout(() => {
openButtonRef.current?.focus();
}, 0);
}
}}
>
<div className="flex items-center justify-between">
<span>{localize('com_nav_profile_picture')}</span>
<OGDialogTrigger ref={openButtonRef}>
<OGDialogTrigger asChild>
<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>
</Button>
</OGDialogTrigger>
</div>
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
<OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
<OGDialogHeader>
<OGDialogTitle className="text-lg font-medium leading-6 text-text-primary">
{image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')}
</OGDialogTitle>
</OGDialogHeader>
<div className="flex flex-col items-center justify-center">
<div className="flex flex-col items-center justify-center p-2">
{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
ref={editorRef}
image={image}
width={250}
height={250}
width={280}
height={280}
border={0}
borderRadius={125}
borderRadius={140}
color={[255, 255, 255, 0.6]}
scale={scale}
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 className="mt-4 flex w-full flex-col items-center space-y-4">
<div className="flex w-full items-center justify-center space-x-4">
<span className="text-sm">{localize('com_ui_zoom')}</span>
</div>
)}
</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
id="zoom-slider"
value={[scale]}
min={1}
max={5}
step={0.001}
step={0.1}
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
className={cn(
'btn btn-primary mt-4 flex w-full hover:bg-green-600',
isUploading ? 'cursor-not-allowed opacity-90' : '',
)}
type="button"
variant="outline"
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}
disabled={isUploading}
>
{isUploading ? (
<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')}
</Button>
</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}
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" />
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
<FileImage className="mb-4 size-16 text-gray-400" />
<p className="mb-2 text-center text-sm font-medium text-text-primary">
{localize('com_ui_drag_drop')}
</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')}
</Button>
<input
@ -217,6 +350,7 @@ function Avatar() {
className="hidden"
accept=".png, .jpg, .jpeg"
onChange={handleFileChange}
aria-label={localize('com_ui_file_input_avatar_label')}
/>
</div>
)}

View file

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

View file

@ -20,7 +20,7 @@ export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
return (
<div className="flex items-center justify-between">
<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 className="flex items-center gap-3">
<Button

View file

@ -15,7 +15,7 @@ export default function DisplayUsernameMessages() {
return (
<div className="flex items-center justify-between">
<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')} />
</div>
<Switch
@ -24,6 +24,7 @@ export default function DisplayUsernameMessages() {
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="UsernameDisplay"
aria-labelledby="user-name-display-label"
/>
</div>
);

View file

@ -19,16 +19,16 @@ const ChatDirection = () => {
</div>
<Button
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}
data-testid="chatDirection"
>
<span aria-hidden="true">{direction.toLowerCase()}</span>
<span id="chat-direction-status" className="sr-only">
{direction === 'LTR'
? localize('chat_direction_left_to_right')
: localize('chat_direction_right_to_left')}
</span>
{direction.toLowerCase()}
</Button>
</div>
);

View file

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

View file

@ -20,13 +20,14 @@ export const ForkSettings = () => {
<>
<div className="pb-3">
<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
id="rememberDefaultFork"
checked={remember}
onCheckedChange={setRemember}
className="ml-4"
data-testid="rememberDefaultFork"
aria-labelledby="remember-default-fork-label"
/>
</div>
</div>
@ -34,7 +35,7 @@ export const ForkSettings = () => {
<div className="pb-3">
<div className="flex items-center justify-between">
<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
side={ESide.Bottom}
text={localize('com_nav_info_fork_change_default')}
@ -47,6 +48,7 @@ export const ForkSettings = () => {
sizeClasses="w-[200px]"
testId="fork-setting-dropdown"
className="z-[50]"
aria-labelledby="fork-change-default-label"
/>
</div>
</div>
@ -54,7 +56,7 @@ export const ForkSettings = () => {
<div className="pb-3">
<div className="flex items-center justify-between">
<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
side={ESide.Bottom}
text={localize('com_nav_info_fork_split_target_setting')}
@ -66,6 +68,7 @@ export const ForkSettings = () => {
onCheckedChange={setSplitAtTarget}
className="ml-4"
data-testid="splitAtTarget"
aria-labelledby="split-at-target-label"
/>
</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 { InfoHoverCard, ESide } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import SlashCommandSwitch from './SlashCommandSwitch';
import { useLocalize, useHasAccess } from '~/hooks';
import PlusCommandSwitch from './PlusCommandSwitch';
import AtCommandSwitch from './AtCommandSwitch';
import ToggleSwitch from '../ToggleSwitch';
import store from '~/store';
const commandSwitchConfigs = [
{
stateAtom: store.atCommand,
localizationKey: 'com_nav_at_command_description' as const,
switchId: 'atCommand',
key: 'atCommand',
permissionType: undefined,
},
{
stateAtom: store.plusCommand,
localizationKey: 'com_nav_plus_command_description' as const,
switchId: 'plusCommand',
key: 'plusCommand',
permissionType: PermissionTypes.MULTI_CONVO,
},
{
stateAtom: store.slashCommand,
localizationKey: 'com_nav_slash_command_description' as const,
switchId: 'slashCommand',
key: 'slashCommand',
permissionType: PermissionTypes.PROMPTS,
},
] as const;
function Commands() {
const localize = useLocalize();
@ -19,6 +42,19 @@ function Commands() {
permission: Permissions.USE,
});
const getShowSwitch = (permissionType?: PermissionTypes) => {
if (!permissionType) {
return true;
}
if (permissionType === PermissionTypes.MULTI_CONVO) {
return hasAccessToMultiConvo === true;
}
if (permissionType === PermissionTypes.PROMPTS) {
return hasAccessToPrompts === true;
}
return true;
};
return (
<div className="space-y-4 p-1">
<div className="flex items-center gap-2">
@ -28,19 +64,16 @@ function Commands() {
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
</div>
<div className="flex flex-col gap-3 text-sm text-text-primary">
<div className="pb-3">
<AtCommandSwitch />
{commandSwitchConfigs.map((config) => (
<div key={config.key} className="pb-3">
<ToggleSwitch
stateAtom={config.stateAtom}
localizationKey={config.localizationKey}
switchId={config.switchId}
showSwitch={getShowSwitch(config.permissionType)}
/>
</div>
{hasAccessToMultiConvo === true && (
<div className="pb-3">
<PlusCommandSwitch />
</div>
)}
{hasAccessToPrompts === true && (
<div className="pb-3">
<SlashCommandSwitch />
</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 (
<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}>
<OGDialogTrigger asChild>
<Button
aria-labelledby="clear-all-chats-label"
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
>
{localize('com_ui_delete')}
@ -47,7 +47,7 @@ export const ClearChats = () => {
title={localize('com_nav_confirm_clear')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
<Label className="break-words">
{localize('com_nav_clear_conversation_confirm_message')}
</Label>
}

View file

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

View file

@ -38,14 +38,14 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
return (
<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}>
<OGDialogTrigger asChild>
<Button
variant="destructive"
className="flex items-center justify-center rounded-lg transition-colors duration-200"
onClick={() => setOpen(true)}
disabled={disabled || isCacheEmpty}
aria-labelledby="delete-cache-label"
>
{localize('com_ui_delete')}
</Button>

View file

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

View file

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

View file

@ -65,10 +65,13 @@ export default function Personalization({
<div className="flex items-center justify-between">
<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')}
</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')}
</div>
</div>
@ -76,7 +79,8 @@ export default function Personalization({
checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle}
disabled={updateMemoryPreferencesMutation.isLoading}
aria-label={localize('com_ui_reference_saved_memories')}
aria-labelledby="reference-saved-memories-label"
aria-describedby="reference-saved-memories-description"
/>
</div>
</>

View file

@ -1,6 +1,5 @@
import { Switch } from '@librechat/client';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useLocalize } from '~/hooks';
import ToggleSwitch from '../ToggleSwitch';
import store from '~/store';
export default function ConversationModeSwitch({
@ -8,8 +7,6 @@ export default function ConversationModeSwitch({
}: {
onCheckedChange?: (value: boolean) => void;
}) {
const localize = useLocalize();
const [conversationMode, setConversationMode] = useRecoilState<boolean>(store.conversationMode);
const speechToText = useRecoilValue(store.speechToText);
const textToSpeech = useRecoilValue(store.textToSpeech);
const [, setAutoSendText] = useRecoilState(store.autoSendText);
@ -20,27 +17,19 @@ export default function ConversationModeSwitch({
setAutoTranscribeAudio(value);
setAutoSendText(3);
setDecibelValue(-45);
setConversationMode(value);
if (onCheckedChange) {
onCheckedChange(value);
}
};
return (
<div className="flex items-center justify-between">
<div>
<strong>{localize('com_nav_conversation_mode')}</strong>
</div>
<div className="flex items-center justify-between">
<Switch
id="ConversationMode"
checked={conversationMode}
<ToggleSwitch
stateAtom={store.conversationMode}
localizationKey={'com_nav_conversation_mode' as const}
switchId="ConversationMode"
onCheckedChange={handleCheckedChange}
className="ml-4"
data-testid="ConversationMode"
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 { Slider, InputNumber } from '@librechat/client';
import { Slider, InputNumber, Switch } from '@librechat/client';
import { cn, defaultTextProps, optionText } from '~/utils/';
import { useLocalize } from '~/hooks';
import store from '~/store';
@ -11,31 +11,93 @@ export default function AutoSendTextSelector() {
const speechToText = useRecoilValue(store.speechToText);
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText);
// Local state for enabled/disabled toggle
const [isEnabled, setIsEnabled] = useState(autoSendText !== -1);
const [delayValue, setDelayValue] = useState(autoSendText === -1 ? 3 : autoSendText);
// Sync local state when autoSendText changes externally
useEffect(() => {
setIsEnabled(autoSendText !== -1);
if (autoSendText !== -1) {
setDelayValue(autoSendText);
}
}, [autoSendText]);
const handleToggle = (enabled: boolean) => {
setIsEnabled(enabled);
if (enabled) {
setAutoSendText(delayValue);
} else {
setAutoSendText(-1);
}
};
const handleSliderChange = (value: number[]) => {
const newValue = value[0];
setDelayValue(newValue);
if (isEnabled) {
setAutoSendText(newValue);
}
};
const handleInputChange = (value: number[] | null) => {
const newValue = value ? value[0] : 3;
setDelayValue(newValue);
if (isEnabled) {
setAutoSendText(newValue);
}
};
const labelId = 'auto-send-text-label';
return (
<div className="flex flex-col gap-3">
<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>{localize('com_nav_auto_send_text')}</div>
<div className="w-2" />
<small className="opacity-40">({localize('com_nav_auto_send_text_disabled')})</small>
<div id="auto-send-delay-label" className="text-sm text-text-secondary">
{localize('com_nav_setting_delay')}
</div>
</div>
<div className="flex items-center justify-between">
<Slider
value={[autoSendText ?? -1]}
onValueChange={(value) => setAutoSendText(value[0])}
onDoubleClick={() => setAutoSendText(-1)}
min={-1}
value={[delayValue]}
onValueChange={handleSliderChange}
onDoubleClick={() => {
setDelayValue(3);
if (isEnabled) {
setAutoSendText(3);
}
}}
min={0}
max={60}
step={1}
className="ml-4 flex h-4 w-24"
disabled={!speechToText}
disabled={!speechToText || !isEnabled}
aria-labelledby="auto-send-delay-label"
/>
<div className="w-2" />
<InputNumber
value={`${autoSendText} s`}
disabled={!speechToText}
onChange={(value) => setAutoSendText(value ? value[0] : 0)}
min={-1}
value={`${delayValue} s`}
disabled={!speechToText || !isEnabled}
onChange={handleInputChange}
min={0}
max={60}
aria-labelledby="auto-send-delay-label"
className={cn(
defaultTextProps,
cn(
@ -46,5 +108,7 @@ export default function AutoSendTextSelector() {
/>
</div>
</div>
)}
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,8 @@
export { default as General } from './General/General';
export { default as Chat } from './Chat/Chat';
export { default as Data } from './Data/Data';
export { default as Commands } from './Commands/Commands';
export { RevokeKeysButton } from './Data/RevokeKeysButton';
export { default as Account } from './Account/Account';
export { default as Balance } from './Balance/Balance';
export { default as Speech } from './Speech/Speech';
export { default as Balance } from './Balance/Balance';
export { default as General } from './General/General';
export { default as Account } from './Account/Account';
export { default as Commands } from './Commands/Commands';
export { default as Personalization } from './Personalization';

View file

@ -71,10 +71,12 @@ function ChatGroupItem({
<DropdownMenuTrigger asChild>
<button
id={`prompt-actions-${group._id}`}
aria-label={`${group.name} - Actions Menu`}
aria-expanded="false"
aria-controls={`prompt-menu-${group._id}`}
aria-haspopup="menu"
type="button"
aria-label={
localize('com_ui_sr_actions_menu', { 0: group.name }) +
' ' +
localize('com_ui_prompt')
}
onClick={(e) => {
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"
>
<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>
</DropdownMenuTrigger>
<DropdownMenuContent
@ -98,30 +95,29 @@ function ChatGroupItem({
aria-label={`Available actions for ${group.name}`}
className="z-50 w-fit rounded-xl"
collisionPadding={2}
align="end"
align="start"
>
<DropdownMenuItem
role="menuitem"
onClick={(e) => {
e.stopPropagation();
setPreviewDialogOpen(true);
}}
className="w-full cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
>
<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>
</DropdownMenuItem>
{canEdit && (
<DropdownMenuGroup>
<DropdownMenuItem
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) => {
e.stopPropagation();
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>
</DropdownMenuItem>
</DropdownMenuGroup>

View file

@ -89,7 +89,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
onKeyDown={handleKeyDown}
role="button"
tabIndex={0}
aria-label={`${group.name} prompt group`}
aria-label={`${group.name} Prompt, ${localize('com_ui_category')}: ${group.category ?? ''}`}
>
<div className="flex w-full items-center justify-between">
<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}
onChange={onSelect}
options={filterOptions}
className="bg-transparent"
className="rounded-lg bg-transparent"
icon={<ListFilter className="h-4 w-4" />}
label="Filter: "
ariaLabel={localize('com_ui_filter_prompts')}

View file

@ -40,7 +40,7 @@ export default function List({
</Button>
</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">
{isLoading && isChatRoute && (
<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 (
<OGDialog open={open} onOpenChange={onOpenChange}>
<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} />
</div>
</OGDialogContent>

View file

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

View file

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

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 { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts';
import { getMermaidFiles } from '~/utils/mermaid';
import { getMarkdownFiles } from '~/utils/markdown';
export default function useArtifactProps({ artifact }: { artifact: Artifact }) {
const [fileKey, files] = useMemo(() => {
if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) {
return ['App.tsx', getMermaidFiles(artifact.content ?? '')];
const key = getKey(artifact.type ?? '', artifact.language);
const type = artifact.type ?? '';
if (key.includes('mermaid')) {
return ['diagram.mmd', getMermaidFiles(artifact.content ?? '')];
}
if (type === 'text/markdown' || type === 'text/md' || type === 'text/plain') {
return ['content.md', getMarkdownFiles(artifact.content ?? '')];
}
const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language);

View file

@ -122,17 +122,8 @@ export default function useArtifacts() {
setCurrentArtifactId(orderedArtifactIds[newIndex]);
};
const isMermaid = useMemo(() => {
if (currentArtifact?.type == null) {
return false;
}
const key = getKey(currentArtifact.type, currentArtifact.language);
return key.includes('mermaid');
}, [currentArtifact?.type, currentArtifact?.language]);
return {
activeTab,
isMermaid,
setActiveTab,
currentIndex,
cycleArtifact,

View file

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

View file

@ -3,8 +3,8 @@ import { useEffect, useRef, useCallback, useMemo } from 'react';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils';
import useCopyToClipboard from './useCopyToClipboard';
import { getTextKey, logger } from '~/utils';
export default function useMessageHelpers(props: TMessageProps) {
const latestText = useRef<string | number>('');
@ -49,15 +49,27 @@ export default function useMessageHelpers(props: TMessageProps) {
messageId: message.messageId,
convoId,
};
/* Extracted convoId from previous textKey (format: messageId|||length|||lastChars|||convoId) */
let previousConvoId: string | null = null;
if (
latestText.current &&
typeof latestText.current === 'string' &&
latestText.current.length > 0
) {
const parts = latestText.current.split(TEXT_KEY_DIVIDER);
previousConvoId = parts[parts.length - 1] || null;
}
if (
textKey !== latestText.current ||
(latestText.current && convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2])
(convoId != null && previousConvoId != null && convoId !== previousConvoId)
) {
logger.log('[useMessageHelpers] Setting latest message: ', logInfo);
logger.log('latest_message', '[useMessageHelpers] Setting latest message: ', logInfo);
latestText.current = textKey;
setLatestMessage({ ...message });
} else {
logger.log('No change in latest message', logInfo);
logger.log('latest_message', 'No change in latest message', logInfo);
}
}, [isLast, message, setLatestMessage, conversation?.conversationId]);

View file

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

View file

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

View file

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

View file

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

View file

@ -273,7 +273,6 @@
"com_error_files_dupe": "S'ha detectat un fitxer duplicat.",
"com_error_files_empty": "No es permeten fitxers buits.",
"com_error_files_process": "S'ha produït un error en processar el fitxer.",
"com_error_files_unsupported_capability": "No hi ha capacitats habilitades que admetin aquest tipus de fitxer.",
"com_error_files_upload": "S'ha produït un error en pujar el fitxer.",
"com_error_files_upload_canceled": "La sol·licitud de pujada de fitxer s'ha cancel·lat. Nota: la pujada podria seguir processant-se i s'haurà d'esborrar manualment.",
"com_error_files_validation": "S'ha produït un error en validar el fitxer.",
@ -303,7 +302,6 @@
"com_nav_auto_scroll": "Desplaçament automàtic al darrer missatge en obrir el xat",
"com_nav_auto_send_prompts": "Envia automàticament els prompts",
"com_nav_auto_send_text": "Envia text automàticament",
"com_nav_auto_send_text_disabled": "estableix -1 per desactivar",
"com_nav_auto_transcribe_audio": "Transcriu àudio automàticament",
"com_nav_automatic_playback": "Reprodueix automàticament el darrer missatge",
"com_nav_balance": "Balanç",

View file

@ -189,7 +189,6 @@
"com_error_files_dupe": "Byl zjištěn duplicitní soubor.",
"com_error_files_empty": "Prázdné soubory nejsou povoleny.",
"com_error_files_process": "Při zpracování souboru došlo k chybě.",
"com_error_files_unsupported_capability": "Nejsou povoleny žádné funkce podporující tento typ souboru.",
"com_error_files_upload": "Při nahrávání souboru došlo k chybě.",
"com_error_files_upload_canceled": "Požadavek na nahrání souboru byl zrušen. Poznámka: nahrávání souboru může stále probíhat a bude nutné jej ručně smazat.",
"com_error_files_validation": "Při ověřování souboru došlo k chybě.",
@ -217,7 +216,6 @@
"com_nav_auto_scroll": "Automaticky rolovat na nejnovější zprávu po otevření chatu",
"com_nav_auto_send_prompts": "Automatické odesílání výzev",
"com_nav_auto_send_text": "Automatické odesílání textu",
"com_nav_auto_send_text_disabled": "nastavte -1 pro deaktivaci",
"com_nav_auto_transcribe_audio": "Automaticky přepisovat zvuk",
"com_nav_automatic_playback": "Automatické přehrávání poslední zprávy",
"com_nav_balance": "Zůstatek",

View file

@ -268,7 +268,6 @@
"com_error_files_dupe": "Duplikatfil fundet.",
"com_error_files_empty": "Tomme filer er ikke tilladt.",
"com_error_files_process": "Der opstod en fejl under behandlingen af filen.",
"com_error_files_unsupported_capability": "Ingen funktioner er aktiveret, der understøtter denne filtype.",
"com_error_files_upload": "Der opstod en fejl under upload af filen.",
"com_error_files_upload_canceled": "Anmodningen om filoverførsel blev annulleret. Bemærk: Filuploaden kan stadig være under behandling og skal slettes manuelt.",
"com_error_files_validation": "Der opstod en fejl under validering af filen.",
@ -297,7 +296,6 @@
"com_nav_auto_scroll": "Auto-scroll til seneste besked, når chatten er åben",
"com_nav_auto_send_prompts": "Automatisk afsendelse af prompte",
"com_nav_auto_send_text": "Send tekst automatisk",
"com_nav_auto_send_text_disabled": "sæt -1 for at deaktivere",
"com_nav_auto_transcribe_audio": "Automatisk transskribering af lyd",
"com_nav_automatic_playback": "Autoplay Seneste besked",
"com_nav_balance": "Balance",

View file

@ -26,6 +26,7 @@
"com_agents_category_sales_description": "Agenten mit Fokus auf Vertriebsprozesse und Kundenbeziehungen",
"com_agents_category_tab_label": "Kategorie {{category}}. {{position}} von {{total}}",
"com_agents_category_tabs_label": "Agenten-Kategorien",
"com_agents_chat_with": "Chatte mit {{name}}",
"com_agents_clear_search": "Suche löschen",
"com_agents_code_interpreter": "Wenn aktiviert, ermöglicht es deinem Agenten, die LibreChat Code Interpreter API zu nutzen, um generierten Code sicher auszuführen, einschließlich der Verarbeitung von Dateien. Erfordert einen gültigen API-Schlüssel.",
"com_agents_code_interpreter_title": "Code-Interpreter-API",
@ -59,6 +60,7 @@
"com_agents_error_timeout_suggestion": "Bitte überprüfe deine Internetverbindung und versuche es erneut.",
"com_agents_error_timeout_title": "Verbindungs-Timeout",
"com_agents_error_title": "Es ist ein Fehler aufgetreten",
"com_agents_file_context_description": "Als „Kontext“ hochgeladene Dateien werden als Text analysiert, um die Anweisungen des Agenten zu ergänzen. Wenn OCR verfügbar ist oder für den hochgeladenen Dateityp konfiguriert wurde, wird dieser Prozess zum Extrahieren von Text verwendet. Ideal für Dokumente, Bilder mit Text oder PDFs, bei denen du den vollständigen Textinhalt einer Datei benötigst.",
"com_agents_file_context_disabled": "Der Agent muss vor dem Hochladen von Dateien für den Datei-Kontext erstellt werden.",
"com_agents_file_context_label": "Dateikontext",
"com_agents_file_search_disabled": "Der Agent muss erstellt werden, bevor Dateien für die Dateisuche hochgeladen werden können.",
@ -361,7 +363,6 @@
"com_error_files_dupe": "Doppelte Datei erkannt.",
"com_error_files_empty": "Leere Dateien sind nicht zulässig",
"com_error_files_process": "Bei der Verarbeitung der Datei ist ein Fehler aufgetreten.",
"com_error_files_unsupported_capability": "Keine aktivierten Funktionen unterstützen diesen Dateityp",
"com_error_files_upload": "Beim Hochladen der Datei ist ein Fehler aufgetreten",
"com_error_files_upload_canceled": "Die Datei-Upload-Anfrage wurde abgebrochen. Hinweis: Der Upload-Vorgang könnte noch im Hintergrund laufen und die Datei muss möglicherweise manuell gelöscht werden.",
"com_error_files_validation": "Bei der Validierung der Datei ist ein Fehler aufgetreten.",
@ -406,7 +407,6 @@
"com_nav_auto_scroll": "Automatisch zur neuesten Nachricht scrollen, wenn der Chat geöffnet wird",
"com_nav_auto_send_prompts": "Prompts automatisch senden",
"com_nav_auto_send_text": "Text automatisch senden",
"com_nav_auto_send_text_disabled": "-1 setzen zum Deaktivieren",
"com_nav_auto_transcribe_audio": "Audio automatisch transkribieren",
"com_nav_automatic_playback": "Automatische Wiedergabe der neuesten Nachricht",
"com_nav_balance": "Guthaben",
@ -831,6 +831,7 @@
"com_ui_delete_success": "Erfolgreich gelöscht",
"com_ui_delete_tool": "Werkzeug löschen",
"com_ui_delete_tool_confirm": "Bist du sicher, dass du dieses Werkzeug löschen möchtest?",
"com_ui_delete_tool_save_reminder": "Tool entfernt. Speichere den Agenten, um die Änderungen zu übernehmen.",
"com_ui_deleted": "Gelöscht",
"com_ui_deleting_file": "Lösche Datei...",
"com_ui_descending": "Absteigend",
@ -1023,6 +1024,7 @@
"com_ui_no_category": "Keine Kategorie",
"com_ui_no_changes": "Es wurden keine Änderungen vorgenommen",
"com_ui_no_individual_access": "Keine einzelnen Benutzer oder Gruppen haben Zugriff auf diesen Agenten.",
"com_ui_no_memories": "Keine Erinnerungen. Erstelle sie manuell oder fordere die KI auf, sich etwas zu merken.\n",
"com_ui_no_personalization_available": "Derzeit sind keine Personalisierungsoptionen verfügbar.",
"com_ui_no_read_access": "Du hast keine Berechtigung, Erinnerungen anzuzeigen.",
"com_ui_no_results_found": "Keine Ergebnisse gefunden",
@ -1225,6 +1227,7 @@
"com_ui_upload_invalid": "Ungültige Datei zum Hochladen. Muss ein Bild sein und das Limit nicht überschreiten",
"com_ui_upload_invalid_var": "Ungültige Datei zum Hochladen. Muss ein Bild sein und {{0}} MB nicht überschreiten",
"com_ui_upload_ocr_text": "Hochladen als Text mit OCR",
"com_ui_upload_provider": "Hochladen zum KI-Anbieter",
"com_ui_upload_success": "Datei erfolgreich hochgeladen",
"com_ui_upload_type": "Upload-Typ auswählen",
"com_ui_usage": "Nutzung",
@ -1263,6 +1266,8 @@
"com_ui_web_search_scraper": "Scraper",
"com_ui_web_search_scraper_firecrawl": "Firecrawl API\n",
"com_ui_web_search_scraper_firecrawl_key": "Einen Firecrawl API Schlüssel holen",
"com_ui_web_search_scraper_serper": "Serper Scrape API",
"com_ui_web_search_scraper_serper_key": "Hole einen Serper API Schlüssel",
"com_ui_web_search_searxng_api_key": "SearXNG API Key (optional) einfügen",
"com_ui_web_search_searxng_instance_url": "SearXNG Instanz URL",
"com_ui_web_searching": "Internetsuche läuft",

View file

@ -1,6 +1,6 @@
{
"chat_direction_left_to_right": "something needs to go here. was empty",
"chat_direction_right_to_left": "something needs to go here. was empty",
"chat_direction_left_to_right": "Left to Right",
"chat_direction_right_to_left": "Right to Left",
"com_a11y_ai_composing": "The AI is still composing.",
"com_a11y_end": "The AI has finished their reply.",
"com_a11y_start": "The AI has started their reply.",
@ -365,6 +365,7 @@
"com_error_files_process": "An error occurred while processing the file.",
"com_error_files_upload": "An error occurred while uploading the file.",
"com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.",
"com_error_files_upload_too_large": "The file is too large. Please upload a file smaller than {{0}} MB",
"com_error_files_validation": "An error occurred while validating the file.",
"com_error_google_tool_conflict": "Usage of built-in Google tools are not supported with external tools. Please disable either the built-in tools or the external tools.",
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",
@ -408,7 +409,6 @@
"com_nav_auto_scroll": "Auto-Scroll to latest message on chat open",
"com_nav_auto_send_prompts": "Auto-send Prompts",
"com_nav_auto_send_text": "Auto send text",
"com_nav_auto_send_text_disabled": "set -1 to disable",
"com_nav_auto_transcribe_audio": "Auto transcribe audio",
"com_nav_automatic_playback": "Autoplay Latest Message",
"com_nav_balance": "Balance",
@ -561,6 +561,7 @@
"com_nav_setting_balance": "Balance",
"com_nav_setting_chat": "Chat",
"com_nav_setting_data": "Data controls",
"com_nav_setting_delay": "Delay (s)",
"com_nav_setting_general": "General",
"com_nav_setting_mcp": "MCP Settings",
"com_nav_setting_personalization": "Personalization",
@ -760,6 +761,7 @@
"com_ui_client_secret": "Client Secret",
"com_ui_close": "Close",
"com_ui_close_menu": "Close Menu",
"com_ui_close_settings": "Close Settings",
"com_ui_close_window": "Close Window",
"com_ui_code": "Code",
"com_ui_collapse_chat": "Collapse Chat",
@ -858,6 +860,7 @@
"com_ui_edit_editing_image": "Editing image",
"com_ui_edit_mcp_server": "Edit MCP Server",
"com_ui_edit_memory": "Edit Memory",
"com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size",
"com_ui_empty_category": "-",
"com_ui_endpoint": "Endpoint",
"com_ui_endpoint_menu": "LLM Endpoint Menu",
@ -892,6 +895,7 @@
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
"com_ui_field_max_length": "{{field}} must be less than {{length}} characters",
"com_ui_field_required": "This field is required",
"com_ui_file_input_avatar_label": "File input for avatar",
"com_ui_file_size": "File Size",
"com_ui_file_token_limit": "File Token Limit",
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
@ -954,11 +958,13 @@
"com_ui_import_conversation_file_type_error": "Unsupported import type",
"com_ui_import_conversation_info": "Import conversations from a JSON file",
"com_ui_import_conversation_success": "Conversations imported successfully",
"com_ui_import_conversation_upload_error": "Error uploading file. Please try again.",
"com_ui_include_shadcnui": "Include shadcn/ui components instructions",
"com_ui_initializing": "Initializing...",
"com_ui_input": "Input",
"com_ui_instructions": "Instructions",
"com_ui_key": "Key",
"com_ui_key_required": "API key is required",
"com_ui_late_night": "Happy late night",
"com_ui_latest_footer": "Every AI for Everyone.",
"com_ui_latest_production_version": "Latest production version",
@ -973,6 +979,7 @@
"com_ui_manage": "Manage",
"com_ui_marketplace": "Marketplace",
"com_ui_marketplace_allow_use": "Allow using Marketplace",
"com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})",
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
"com_ui_mcp_configure_server": "Configure {{0}}",
@ -1067,6 +1074,7 @@
"com_ui_privacy_policy": "Privacy policy",
"com_ui_privacy_policy_url": "Privacy Policy URL",
"com_ui_prompt": "Prompt",
"com_ui_prompt_groups": "Prompt Groups List",
"com_ui_prompt_name": "Prompt Name",
"com_ui_prompt_name_required": "Prompt Name is required",
"com_ui_prompt_preview_not_shared": "The author has not allowed collaboration for this prompt.",
@ -1096,6 +1104,8 @@
"com_ui_rename_failed": "Failed to rename conversation",
"com_ui_rename_prompt": "Rename Prompt",
"com_ui_requires_auth": "Requires Authentication",
"com_ui_reset": "Reset",
"com_ui_reset_adjustments": "Reset adjustments",
"com_ui_reset_var": "Reset {{0}}",
"com_ui_reset_zoom": "Reset Zoom",
"com_ui_resource": "resource",
@ -1104,6 +1114,8 @@
"com_ui_revoke_info": "Revoke all user provided credentials",
"com_ui_revoke_key_confirm": "Are you sure you want to revoke this key?",
"com_ui_revoke_key_endpoint": "Revoke Key for {{0}}",
"com_ui_revoke_key_error": "Failed to revoke API key. Please try again.",
"com_ui_revoke_key_success": "API key revoked successfully",
"com_ui_revoke_keys": "Revoke Keys",
"com_ui_revoke_keys_confirm": "Are you sure you want to revoke all keys?",
"com_ui_role": "Role",
@ -1117,11 +1129,15 @@
"com_ui_role_viewer": "Viewer",
"com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it",
"com_ui_roleplay": "Roleplay",
"com_ui_rotate": "Rotate",
"com_ui_rotate_90": "Rotate 90 degrees",
"com_ui_run_code": "Run Code",
"com_ui_run_code_error": "There was an error running the code",
"com_ui_save": "Save",
"com_ui_save_badge_changes": "Save badge changes?",
"com_ui_save_changes": "Save Changes",
"com_ui_save_key_error": "Failed to save API key. Please try again.",
"com_ui_save_key_success": "API key saved successfully",
"com_ui_save_submit": "Save & Submit",
"com_ui_saved": "Saved!",
"com_ui_saving": "Saving...",
@ -1218,6 +1234,7 @@
"com_ui_update_mcp_success": "Successfully created or updated MCP",
"com_ui_upload": "Upload",
"com_ui_upload_agent_avatar": "Successfully updated agent avatar",
"com_ui_upload_avatar_label": "Upload avatar image",
"com_ui_upload_code_files": "Upload for Code Interpreter",
"com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.",
"com_ui_upload_error": "There was an error uploading your file",
@ -1279,5 +1296,8 @@
"com_ui_x_selected": "{{0}} selected",
"com_ui_yes": "Yes",
"com_ui_zoom": "Zoom",
"com_ui_zoom_in": "Zoom in",
"com_ui_zoom_level": "Zoom level",
"com_ui_zoom_out": "Zoom out",
"com_user_message": "You"
}

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