mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-21 10:50:14 +01:00
Merge branch 'main' into feature/entra-id-azure-integration
This commit is contained in:
commit
631f4b3703
151 changed files with 3677 additions and 1242 deletions
11
.env.example
11
.env.example
|
|
@ -196,7 +196,7 @@ GOOGLE_KEY=user_provided
|
||||||
#============#
|
#============#
|
||||||
|
|
||||||
OPENAI_API_KEY=user_provided
|
OPENAI_API_KEY=user_provided
|
||||||
# OPENAI_MODELS=o1,o1-mini,o1-preview,gpt-4o,gpt-4.5-preview,chatgpt-4o-latest,gpt-4o-mini,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k
|
# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini
|
||||||
|
|
||||||
DEBUG_OPENAI=false
|
DEBUG_OPENAI=false
|
||||||
|
|
||||||
|
|
@ -459,6 +459,9 @@ OPENID_CALLBACK_URL=/oauth/openid/callback
|
||||||
OPENID_REQUIRED_ROLE=
|
OPENID_REQUIRED_ROLE=
|
||||||
OPENID_REQUIRED_ROLE_TOKEN_KIND=
|
OPENID_REQUIRED_ROLE_TOKEN_KIND=
|
||||||
OPENID_REQUIRED_ROLE_PARAMETER_PATH=
|
OPENID_REQUIRED_ROLE_PARAMETER_PATH=
|
||||||
|
OPENID_ADMIN_ROLE=
|
||||||
|
OPENID_ADMIN_ROLE_PARAMETER_PATH=
|
||||||
|
OPENID_ADMIN_ROLE_TOKEN_KIND=
|
||||||
# Set to determine which user info property returned from OpenID Provider to store as the User's username
|
# Set to determine which user info property returned from OpenID Provider to store as the User's username
|
||||||
OPENID_USERNAME_CLAIM=
|
OPENID_USERNAME_CLAIM=
|
||||||
# Set to determine which user info property returned from OpenID Provider to store as the User's name
|
# Set to determine which user info property returned from OpenID Provider to store as the User's name
|
||||||
|
|
@ -650,6 +653,12 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||||
# Google tag manager id
|
# Google tag manager id
|
||||||
#ANALYTICS_GTM_ID=user provided google tag manager id
|
#ANALYTICS_GTM_ID=user provided google tag manager id
|
||||||
|
|
||||||
|
# limit conversation file imports to a certain number of bytes in size to avoid the container
|
||||||
|
# maxing out memory limitations by unremarking this line and supplying a file size in bytes
|
||||||
|
# such as the below example of 250 mib
|
||||||
|
# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000
|
||||||
|
|
||||||
|
|
||||||
#===============#
|
#===============#
|
||||||
# REDIS Options #
|
# REDIS Options #
|
||||||
#===============#
|
#===============#
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,2 @@
|
||||||
#!/usr/bin/env sh
|
|
||||||
set -e
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
[ -n "$CI" ] && exit 0
|
[ -n "$CI" ] && exit 0
|
||||||
npx lint-staged --config ./.husky/lint-staged.config.js
|
npx lint-staged --config ./.husky/lint-staged.config.js
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const fetch = require('node-fetch');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
getBalanceConfig,
|
getBalanceConfig,
|
||||||
|
extractFileContext,
|
||||||
encodeAndFormatAudios,
|
encodeAndFormatAudios,
|
||||||
encodeAndFormatVideos,
|
encodeAndFormatVideos,
|
||||||
encodeAndFormatDocuments,
|
encodeAndFormatDocuments,
|
||||||
|
|
@ -10,6 +11,7 @@ const {
|
||||||
const {
|
const {
|
||||||
Constants,
|
Constants,
|
||||||
ErrorTypes,
|
ErrorTypes,
|
||||||
|
FileSources,
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
excludedKeys,
|
excludedKeys,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
|
|
@ -21,6 +23,7 @@ const { getMessages, saveMessage, updateMessage, saveConvo, getConvo } = require
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const { checkBalance } = require('~/models/balanceMethods');
|
const { checkBalance } = require('~/models/balanceMethods');
|
||||||
const { truncateToolCallOutputs } = require('./prompts');
|
const { truncateToolCallOutputs } = require('./prompts');
|
||||||
|
const countTokens = require('~/server/utils/countTokens');
|
||||||
const { getFiles } = require('~/models/File');
|
const { getFiles } = require('~/models/File');
|
||||||
const TextStream = require('./TextStream');
|
const TextStream = require('./TextStream');
|
||||||
|
|
||||||
|
|
@ -1245,27 +1248,62 @@ class BaseClient {
|
||||||
return audioResult.files;
|
return audioResult.files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts text context from attachments and sets it on the message.
|
||||||
|
* This handles text that was already extracted from files (OCR, transcriptions, document text, etc.)
|
||||||
|
* @param {TMessage} message - The message to add context to
|
||||||
|
* @param {MongoFile[]} attachments - Array of file attachments
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async addFileContextToMessage(message, attachments) {
|
||||||
|
const fileContext = await extractFileContext({
|
||||||
|
attachments,
|
||||||
|
req: this.options?.req,
|
||||||
|
tokenCountFn: (text) => countTokens(text),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fileContext) {
|
||||||
|
message.fileContext = fileContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async processAttachments(message, attachments) {
|
async processAttachments(message, attachments) {
|
||||||
const categorizedAttachments = {
|
const categorizedAttachments = {
|
||||||
images: [],
|
images: [],
|
||||||
documents: [],
|
|
||||||
videos: [],
|
videos: [],
|
||||||
audios: [],
|
audios: [],
|
||||||
|
documents: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allFiles = [];
|
||||||
|
|
||||||
for (const file of attachments) {
|
for (const file of attachments) {
|
||||||
|
/** @type {FileSources} */
|
||||||
|
const source = file.source ?? FileSources.local;
|
||||||
|
if (source === FileSources.text) {
|
||||||
|
allFiles.push(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.embedded === true || file.metadata?.fileIdentifier != null) {
|
||||||
|
allFiles.push(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
categorizedAttachments.images.push(file);
|
categorizedAttachments.images.push(file);
|
||||||
} else if (file.type === 'application/pdf') {
|
} else if (file.type === 'application/pdf') {
|
||||||
categorizedAttachments.documents.push(file);
|
categorizedAttachments.documents.push(file);
|
||||||
|
allFiles.push(file);
|
||||||
} else if (file.type.startsWith('video/')) {
|
} else if (file.type.startsWith('video/')) {
|
||||||
categorizedAttachments.videos.push(file);
|
categorizedAttachments.videos.push(file);
|
||||||
|
allFiles.push(file);
|
||||||
} else if (file.type.startsWith('audio/')) {
|
} else if (file.type.startsWith('audio/')) {
|
||||||
categorizedAttachments.audios.push(file);
|
categorizedAttachments.audios.push(file);
|
||||||
|
allFiles.push(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [imageFiles, documentFiles, videoFiles, audioFiles] = await Promise.all([
|
const [imageFiles] = await Promise.all([
|
||||||
categorizedAttachments.images.length > 0
|
categorizedAttachments.images.length > 0
|
||||||
? this.addImageURLs(message, categorizedAttachments.images)
|
? this.addImageURLs(message, categorizedAttachments.images)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
|
@ -1280,7 +1318,8 @@ class BaseClient {
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allFiles = [...imageFiles, ...documentFiles, ...videoFiles, ...audioFiles];
|
allFiles.push(...imageFiles);
|
||||||
|
|
||||||
const seenFileIds = new Set();
|
const seenFileIds = new Set();
|
||||||
const uniqueFiles = [];
|
const uniqueFiles = [];
|
||||||
|
|
||||||
|
|
@ -1345,6 +1384,7 @@ class BaseClient {
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.addFileContextToMessage(message, files);
|
||||||
await this.processAttachments(message, files);
|
await this.processAttachments(message, files);
|
||||||
|
|
||||||
this.message_file_map[message.messageId] = files;
|
this.message_file_map[message.messageId] = files;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const { EModelEndpoint, ArtifactModes } = require('librechat-data-provider');
|
||||||
const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate');
|
const { generateShadcnPrompt } = require('~/app/clients/prompts/shadcn-docs/generate');
|
||||||
const { components } = require('~/app/clients/prompts/shadcn-docs/components');
|
const { components } = require('~/app/clients/prompts/shadcn-docs/components');
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations.
|
const artifactsPromptV1 = dedent`The assistant can create and reference artifacts during conversations.
|
||||||
|
|
||||||
|
|
@ -115,6 +116,7 @@ Here are some examples of correct usage of artifacts:
|
||||||
</assistant_response>
|
</assistant_response>
|
||||||
</example>
|
</example>
|
||||||
</examples>`;
|
</examples>`;
|
||||||
|
|
||||||
const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations.
|
const artifactsPrompt = dedent`The assistant can create and reference artifacts during conversations.
|
||||||
|
|
||||||
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
|
Artifacts are for substantial, self-contained content that users might modify or reuse, displayed in a separate UI window for clarity.
|
||||||
|
|
@ -165,6 +167,10 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||||
- SVG: "image/svg+xml"
|
- SVG: "image/svg+xml"
|
||||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||||
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
||||||
|
- Markdown: "text/markdown" or "text/md"
|
||||||
|
- The user interface will render Markdown content placed within the artifact tags.
|
||||||
|
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
|
||||||
|
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
|
||||||
- Mermaid Diagrams: "application/vnd.mermaid"
|
- Mermaid Diagrams: "application/vnd.mermaid"
|
||||||
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
||||||
- React Components: "application/vnd.react"
|
- React Components: "application/vnd.react"
|
||||||
|
|
@ -366,6 +372,10 @@ Artifacts are for substantial, self-contained content that users might modify or
|
||||||
- SVG: "image/svg+xml"
|
- SVG: "image/svg+xml"
|
||||||
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
- The user interface will render the Scalable Vector Graphics (SVG) image within the artifact tags.
|
||||||
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
- The assistant should specify the viewbox of the SVG rather than defining a width/height
|
||||||
|
- Markdown: "text/markdown" or "text/md"
|
||||||
|
- The user interface will render Markdown content placed within the artifact tags.
|
||||||
|
- Supports standard Markdown syntax including headers, lists, links, images, code blocks, tables, and more.
|
||||||
|
- Both "text/markdown" and "text/md" are accepted as valid MIME types for Markdown content.
|
||||||
- Mermaid Diagrams: "application/vnd.mermaid"
|
- Mermaid Diagrams: "application/vnd.mermaid"
|
||||||
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
- The user interface will render Mermaid diagrams placed within the artifact tags.
|
||||||
- React Components: "application/vnd.react"
|
- React Components: "application/vnd.react"
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ const tokenValues = Object.assign(
|
||||||
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||||
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
||||||
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
|
'gemini-2.5-flash': { prompt: 0.3, completion: 2.5 },
|
||||||
'gemini-2.5-flash-lite': { prompt: 0.075, completion: 0.4 },
|
'gemini-2.5-flash-lite': { prompt: 0.1, completion: 0.4 },
|
||||||
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
|
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
|
||||||
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
|
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
|
||||||
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
|
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^6.9.15",
|
"nodemailer": "^7.0.9",
|
||||||
"ollama": "^0.5.0",
|
"ollama": "^0.5.0",
|
||||||
"openai": "^5.10.1",
|
"openai": "^5.10.1",
|
||||||
"openid-client": "^6.5.0",
|
"openid-client": "^6.5.0",
|
||||||
|
|
|
||||||
|
|
@ -327,16 +327,23 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
||||||
const revocationEndpointAuthMethodsSupported =
|
const revocationEndpointAuthMethodsSupported =
|
||||||
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
|
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
|
||||||
clientMetadata.revocation_endpoint_auth_methods_supported;
|
clientMetadata.revocation_endpoint_auth_methods_supported;
|
||||||
|
const oauthHeaders = serverConfig.oauth_headers ?? {};
|
||||||
|
|
||||||
if (tokens?.access_token) {
|
if (tokens?.access_token) {
|
||||||
try {
|
try {
|
||||||
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', {
|
await MCPOAuthHandler.revokeOAuthToken(
|
||||||
serverUrl: serverConfig.url,
|
serverName,
|
||||||
clientId: clientInfo.client_id,
|
tokens.access_token,
|
||||||
clientSecret: clientInfo.client_secret ?? '',
|
'access',
|
||||||
revocationEndpoint,
|
{
|
||||||
revocationEndpointAuthMethodsSupported,
|
serverUrl: serverConfig.url,
|
||||||
});
|
clientId: clientInfo.client_id,
|
||||||
|
clientSecret: clientInfo.client_secret ?? '',
|
||||||
|
revocationEndpoint,
|
||||||
|
revocationEndpointAuthMethodsSupported,
|
||||||
|
},
|
||||||
|
oauthHeaders,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
|
logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -344,13 +351,19 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
||||||
|
|
||||||
if (tokens?.refresh_token) {
|
if (tokens?.refresh_token) {
|
||||||
try {
|
try {
|
||||||
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', {
|
await MCPOAuthHandler.revokeOAuthToken(
|
||||||
serverUrl: serverConfig.url,
|
serverName,
|
||||||
clientId: clientInfo.client_id,
|
tokens.refresh_token,
|
||||||
clientSecret: clientInfo.client_secret ?? '',
|
'refresh',
|
||||||
revocationEndpoint,
|
{
|
||||||
revocationEndpointAuthMethodsSupported,
|
serverUrl: serverConfig.url,
|
||||||
});
|
clientId: clientInfo.client_id,
|
||||||
|
clientSecret: clientInfo.client_secret ?? '',
|
||||||
|
revocationEndpoint,
|
||||||
|
revocationEndpointAuthMethodsSupported,
|
||||||
|
},
|
||||||
|
oauthHeaders,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
|
logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -211,16 +211,13 @@ class AgentClient extends BaseClient {
|
||||||
* @returns {Promise<Array<Partial<MongoFile>>>}
|
* @returns {Promise<Array<Partial<MongoFile>>>}
|
||||||
*/
|
*/
|
||||||
async addImageURLs(message, attachments) {
|
async addImageURLs(message, attachments) {
|
||||||
const { files, text, image_urls } = await encodeAndFormat(
|
const { files, image_urls } = await encodeAndFormat(
|
||||||
this.options.req,
|
this.options.req,
|
||||||
attachments,
|
attachments,
|
||||||
this.options.agent.provider,
|
this.options.agent.provider,
|
||||||
VisionModes.agents,
|
VisionModes.agents,
|
||||||
);
|
);
|
||||||
message.image_urls = image_urls.length ? image_urls : undefined;
|
message.image_urls = image_urls.length ? image_urls : undefined;
|
||||||
if (text && text.length) {
|
|
||||||
message.ocr = text;
|
|
||||||
}
|
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,19 +245,18 @@ class AgentClient extends BaseClient {
|
||||||
|
|
||||||
if (this.options.attachments) {
|
if (this.options.attachments) {
|
||||||
const attachments = await this.options.attachments;
|
const attachments = await this.options.attachments;
|
||||||
|
const latestMessage = orderedMessages[orderedMessages.length - 1];
|
||||||
|
|
||||||
if (this.message_file_map) {
|
if (this.message_file_map) {
|
||||||
this.message_file_map[orderedMessages[orderedMessages.length - 1].messageId] = attachments;
|
this.message_file_map[latestMessage.messageId] = attachments;
|
||||||
} else {
|
} else {
|
||||||
this.message_file_map = {
|
this.message_file_map = {
|
||||||
[orderedMessages[orderedMessages.length - 1].messageId]: attachments,
|
[latestMessage.messageId]: attachments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await this.processAttachments(
|
await this.addFileContextToMessage(latestMessage, attachments);
|
||||||
orderedMessages[orderedMessages.length - 1],
|
const files = await this.processAttachments(latestMessage, attachments);
|
||||||
attachments,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.options.attachments = files;
|
this.options.attachments = files;
|
||||||
}
|
}
|
||||||
|
|
@ -280,21 +276,21 @@ class AgentClient extends BaseClient {
|
||||||
assistantName: this.options?.modelLabel,
|
assistantName: this.options?.modelLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (message.ocr && i !== orderedMessages.length - 1) {
|
if (message.fileContext && i !== orderedMessages.length - 1) {
|
||||||
if (typeof formattedMessage.content === 'string') {
|
if (typeof formattedMessage.content === 'string') {
|
||||||
formattedMessage.content = message.ocr + '\n' + formattedMessage.content;
|
formattedMessage.content = message.fileContext + '\n' + formattedMessage.content;
|
||||||
} else {
|
} else {
|
||||||
const textPart = formattedMessage.content.find((part) => part.type === 'text');
|
const textPart = formattedMessage.content.find((part) => part.type === 'text');
|
||||||
textPart
|
textPart
|
||||||
? (textPart.text = message.ocr + '\n' + textPart.text)
|
? (textPart.text = message.fileContext + '\n' + textPart.text)
|
||||||
: formattedMessage.content.unshift({ type: 'text', text: message.ocr });
|
: formattedMessage.content.unshift({ type: 'text', text: message.fileContext });
|
||||||
}
|
}
|
||||||
} else if (message.ocr && i === orderedMessages.length - 1) {
|
} else if (message.fileContext && i === orderedMessages.length - 1) {
|
||||||
systemContent = [systemContent, message.ocr].join('\n');
|
systemContent = [systemContent, message.fileContext].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsTokenCount =
|
const needsTokenCount =
|
||||||
(this.contextStrategy && !orderedMessages[i].tokenCount) || message.ocr;
|
(this.contextStrategy && !orderedMessages[i].tokenCount) || message.fileContext;
|
||||||
|
|
||||||
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
|
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
|
||||||
if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) {
|
if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) {
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,13 @@ describe('MCP Routes', () => {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockMcpManager = {
|
||||||
|
getRawConfig: jest.fn().mockReturnValue({}),
|
||||||
|
};
|
||||||
|
|
||||||
getLogStores.mockReturnValue({});
|
getLogStores.mockReturnValue({});
|
||||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||||
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||||
|
|
||||||
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
|
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
|
||||||
authorizationUrl: 'https://oauth.example.com/auth',
|
authorizationUrl: 'https://oauth.example.com/auth',
|
||||||
|
|
@ -146,6 +151,7 @@ describe('MCP Routes', () => {
|
||||||
'test-server',
|
'test-server',
|
||||||
'https://test-server.com',
|
'https://test-server.com',
|
||||||
'test-user-id',
|
'test-user-id',
|
||||||
|
{},
|
||||||
{ clientId: 'test-client-id' },
|
{ clientId: 'test-client-id' },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -314,6 +320,7 @@ describe('MCP Routes', () => {
|
||||||
};
|
};
|
||||||
const mockMcpManager = {
|
const mockMcpManager = {
|
||||||
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
||||||
|
getRawConfig: jest.fn().mockReturnValue({}),
|
||||||
};
|
};
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||||
|
|
||||||
|
|
@ -336,6 +343,7 @@ describe('MCP Routes', () => {
|
||||||
'test-flow-id',
|
'test-flow-id',
|
||||||
'test-auth-code',
|
'test-auth-code',
|
||||||
mockFlowManager,
|
mockFlowManager,
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
|
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
@ -392,6 +400,11 @@ describe('MCP Routes', () => {
|
||||||
getLogStores.mockReturnValue({});
|
getLogStores.mockReturnValue({});
|
||||||
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||||
|
|
||||||
|
const mockMcpManager = {
|
||||||
|
getRawConfig: jest.fn().mockReturnValue({}),
|
||||||
|
};
|
||||||
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||||
|
|
||||||
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
||||||
code: 'test-auth-code',
|
code: 'test-auth-code',
|
||||||
state: 'test-flow-id',
|
state: 'test-flow-id',
|
||||||
|
|
@ -427,6 +440,7 @@ describe('MCP Routes', () => {
|
||||||
|
|
||||||
const mockMcpManager = {
|
const mockMcpManager = {
|
||||||
getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
|
getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
|
||||||
|
getRawConfig: jest.fn().mockReturnValue({}),
|
||||||
};
|
};
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||||
|
|
||||||
|
|
@ -1234,6 +1248,7 @@ describe('MCP Routes', () => {
|
||||||
getUserConnection: jest.fn().mockResolvedValue({
|
getUserConnection: jest.fn().mockResolvedValue({
|
||||||
fetchTools: jest.fn().mockResolvedValue([]),
|
fetchTools: jest.fn().mockResolvedValue([]),
|
||||||
}),
|
}),
|
||||||
|
getRawConfig: jest.fn().mockReturnValue({}),
|
||||||
};
|
};
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||||
|
|
||||||
|
|
@ -1281,6 +1296,7 @@ describe('MCP Routes', () => {
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
|
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
|
||||||
}),
|
}),
|
||||||
|
getRawConfig: jest.fn().mockReturnValue({}),
|
||||||
};
|
};
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,9 @@ router.get('/', async function (req, res) {
|
||||||
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
|
sharePointPickerGraphScope: process.env.SHAREPOINT_PICKER_GRAPH_SCOPE,
|
||||||
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
|
sharePointPickerSharePointScope: process.env.SHAREPOINT_PICKER_SHAREPOINT_SCOPE,
|
||||||
openidReuseTokens,
|
openidReuseTokens,
|
||||||
|
conversationImportMaxFileSize: process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES
|
||||||
|
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
|
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, async (req, res) => {
|
||||||
serverName,
|
serverName,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
userId,
|
userId,
|
||||||
|
getOAuthHeaders(serverName),
|
||||||
oauthConfig,
|
oauthConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -132,7 +133,12 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.debug('[MCP OAuth] Completing OAuth flow');
|
logger.debug('[MCP OAuth] Completing OAuth flow');
|
||||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
|
const tokens = await MCPOAuthHandler.completeOAuthFlow(
|
||||||
|
flowId,
|
||||||
|
code,
|
||||||
|
flowManager,
|
||||||
|
getOAuthHeaders(serverName),
|
||||||
|
);
|
||||||
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
||||||
|
|
||||||
/** Persist tokens immediately so reconnection uses fresh credentials */
|
/** Persist tokens immediately so reconnection uses fresh credentials */
|
||||||
|
|
@ -538,4 +544,10 @@ router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getOAuthHeaders(serverName) {
|
||||||
|
const mcpManager = getMCPManager();
|
||||||
|
const serverConfig = mcpManager.getRawConfig(serverName);
|
||||||
|
return serverConfig?.oauth_headers ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,8 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
|
||||||
|
|
||||||
router.post('/:conversationId', requireJwtAuth, async (req, res) => {
|
router.post('/:conversationId', requireJwtAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const created = await createSharedLink(req.user.id, req.params.conversationId);
|
const { targetMessageId } = req.body;
|
||||||
|
const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId);
|
||||||
if (created) {
|
if (created) {
|
||||||
res.status(200).json(created);
|
res.status(200).json(created);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,9 @@ async function loadConfigModels(req) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(models.default)) {
|
if (Array.isArray(models.default)) {
|
||||||
modelsConfig[name] = models.default;
|
modelsConfig[name] = models.default.map((model) =>
|
||||||
|
typeof model === 'string' ? model : model.name,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,8 +254,8 @@ describe('loadConfigModels', () => {
|
||||||
// For groq and ollama, since the apiKey is "user_provided", models should not be fetched
|
// For groq and ollama, since the apiKey is "user_provided", models should not be fetched
|
||||||
// Depending on your implementation's behavior regarding "default" models without fetching,
|
// Depending on your implementation's behavior regarding "default" models without fetching,
|
||||||
// you may need to adjust the following assertions:
|
// you may need to adjust the following assertions:
|
||||||
expect(result.groq).toBe(exampleConfig.endpoints.custom[2].models.default);
|
expect(result.groq).toEqual(exampleConfig.endpoints.custom[2].models.default);
|
||||||
expect(result.ollama).toBe(exampleConfig.endpoints.custom[3].models.default);
|
expect(result.ollama).toEqual(exampleConfig.endpoints.custom[3].models.default);
|
||||||
|
|
||||||
// Verifying fetchModels was not called for groq and ollama
|
// Verifying fetchModels was not called for groq and ollama
|
||||||
expect(fetchModels).not.toHaveBeenCalledWith(
|
expect(fetchModels).not.toHaveBeenCalledWith(
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
|
const { logAxiosError } = require('@librechat/api');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { logAxiosError, processTextWithTokenLimit } = require('@librechat/api');
|
|
||||||
const {
|
const {
|
||||||
FileSources,
|
FileSources,
|
||||||
VisionModes,
|
VisionModes,
|
||||||
ImageDetail,
|
ImageDetail,
|
||||||
ContentTypes,
|
ContentTypes,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
mergeFileConfig,
|
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||||
const countTokens = require('~/server/utils/countTokens');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a readable stream to a base64 encoded string.
|
* Converts a readable stream to a base64 encoded string.
|
||||||
|
|
@ -88,15 +86,14 @@ const blobStorageSources = new Set([FileSources.azure_blob, FileSources.s3]);
|
||||||
* @param {Array<MongoFile>} files - The array of files to encode and format.
|
* @param {Array<MongoFile>} files - The array of files to encode and format.
|
||||||
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
|
* @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image.
|
||||||
* @param {string} [mode] - Optional: The endpoint mode for the image.
|
* @param {string} [mode] - Optional: The endpoint mode for the image.
|
||||||
* @returns {Promise<{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
|
* @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
|
||||||
*/
|
*/
|
||||||
async function encodeAndFormat(req, files, endpoint, mode) {
|
async function encodeAndFormat(req, files, endpoint, mode) {
|
||||||
const promises = [];
|
const promises = [];
|
||||||
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
|
/** @type {Record<FileSources, Pick<ReturnType<typeof getStrategyFunctions>, 'prepareImagePayload' | 'getDownloadStream'>>} */
|
||||||
const encodingMethods = {};
|
const encodingMethods = {};
|
||||||
/** @type {{ text: string; files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */
|
/** @type {{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */
|
||||||
const result = {
|
const result = {
|
||||||
text: '',
|
|
||||||
files: [],
|
files: [],
|
||||||
image_urls: [],
|
image_urls: [],
|
||||||
};
|
};
|
||||||
|
|
@ -105,29 +102,9 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileTokenLimit =
|
|
||||||
req.body?.fileTokenLimit ?? mergeFileConfig(req.config?.fileConfig).fileTokenLimit;
|
|
||||||
|
|
||||||
for (let file of files) {
|
for (let file of files) {
|
||||||
/** @type {FileSources} */
|
/** @type {FileSources} */
|
||||||
const source = file.source ?? FileSources.local;
|
const source = file.source ?? FileSources.local;
|
||||||
if (source === FileSources.text && file.text) {
|
|
||||||
let fileText = file.text;
|
|
||||||
|
|
||||||
const { text: limitedText, wasTruncated } = await processTextWithTokenLimit({
|
|
||||||
text: fileText,
|
|
||||||
tokenLimit: fileTokenLimit,
|
|
||||||
tokenCountFn: (text) => countTokens(text),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (wasTruncated) {
|
|
||||||
logger.debug(
|
|
||||||
`[encodeAndFormat] Text content truncated for file: ${file.filename} due to token limits`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.text += `${!result.text ? 'Attached document(s):\n```md' : '\n\n---\n\n'}# "${file.filename}"\n${limitedText}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.height) {
|
if (!file.height) {
|
||||||
promises.push([file, null]);
|
promises.push([file, null]);
|
||||||
|
|
@ -165,10 +142,6 @@ async function encodeAndFormat(req, files, endpoint, mode) {
|
||||||
promises.push(preparePayload(req, file));
|
promises.push(preparePayload(req, file));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.text) {
|
|
||||||
result.text += '\n```';
|
|
||||||
}
|
|
||||||
|
|
||||||
const detail = req.body.imageDetail ?? ImageDetail.auto;
|
const detail = req.body.imageDetail ?? ImageDetail.auto;
|
||||||
|
|
||||||
/** @type {Array<[MongoFile, string]>} */
|
/** @type {Array<[MongoFile, string]>} */
|
||||||
|
|
|
||||||
|
|
@ -508,7 +508,10 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
||||||
const { file } = req;
|
const { file } = req;
|
||||||
const appConfig = req.config;
|
const appConfig = req.config;
|
||||||
const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata;
|
const { agent_id, tool_resource, file_id, temp_file_id = null } = metadata;
|
||||||
if (agent_id && !tool_resource) {
|
|
||||||
|
let messageAttachment = !!metadata.message_file;
|
||||||
|
|
||||||
|
if (agent_id && !tool_resource && !messageAttachment) {
|
||||||
throw new Error('No tool resource provided for agent file upload');
|
throw new Error('No tool resource provided for agent file upload');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,7 +519,6 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
||||||
throw new Error('Image uploads are not supported for file search tool resources');
|
throw new Error('Image uploads are not supported for file search tool resources');
|
||||||
}
|
}
|
||||||
|
|
||||||
let messageAttachment = !!metadata.message_file;
|
|
||||||
if (!messageAttachment && !agent_id) {
|
if (!messageAttachment && !agent_id) {
|
||||||
throw new Error('No agent ID provided for agent file upload');
|
throw new Error('No agent ID provided for agent file upload');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,15 @@ const importConversations = async (job) => {
|
||||||
const { filepath, requestUserId } = job;
|
const { filepath, requestUserId } = job;
|
||||||
try {
|
try {
|
||||||
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
|
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
|
||||||
|
|
||||||
|
/* error if file is too large */
|
||||||
|
const fileInfo = await fs.stat(filepath);
|
||||||
|
if (fileInfo.size > process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES} bytes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const fileData = await fs.readFile(filepath, 'utf8');
|
const fileData = await fs.readFile(filepath, 'utf8');
|
||||||
const jsonData = JSON.parse(fileData);
|
const jsonData = JSON.parse(fileData);
|
||||||
const importer = getImporter(jsonData);
|
const importer = getImporter(jsonData);
|
||||||
|
|
@ -17,6 +26,7 @@ const importConversations = async (job) => {
|
||||||
logger.debug(`user: ${requestUserId} | Finished importing conversations`);
|
logger.debug(`user: ${requestUserId} | Finished importing conversations`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`user: ${requestUserId} | Failed to import conversation: `, error);
|
logger.error(`user: ${requestUserId} | Failed to import conversation: `, error);
|
||||||
|
throw error; // throw error all the way up so request does not return success
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(filepath);
|
await fs.unlink(filepath);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const undici = require('undici');
|
const undici = require('undici');
|
||||||
|
const { get } = require('lodash');
|
||||||
const fetch = require('node-fetch');
|
const fetch = require('node-fetch');
|
||||||
const passport = require('passport');
|
const passport = require('passport');
|
||||||
const client = require('openid-client');
|
const client = require('openid-client');
|
||||||
|
|
@ -329,6 +330,12 @@ async function setupOpenId() {
|
||||||
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata',
|
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set of env variables that specify how to set if a user is an admin
|
||||||
|
// If not set, all users will be treated as regular users
|
||||||
|
const adminRole = process.env.OPENID_ADMIN_ROLE;
|
||||||
|
const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
|
||||||
|
const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
|
||||||
|
|
||||||
const openidLogin = new CustomOpenIDStrategy(
|
const openidLogin = new CustomOpenIDStrategy(
|
||||||
{
|
{
|
||||||
config: openidConfig,
|
config: openidConfig,
|
||||||
|
|
@ -386,20 +393,19 @@ async function setupOpenId() {
|
||||||
} else if (requiredRoleTokenKind === 'id') {
|
} else if (requiredRoleTokenKind === 'id') {
|
||||||
decodedToken = jwtDecode(tokenset.id_token);
|
decodedToken = jwtDecode(tokenset.id_token);
|
||||||
}
|
}
|
||||||
const pathParts = requiredRoleParameterPath.split('.');
|
|
||||||
let found = true;
|
|
||||||
let roles = pathParts.reduce((o, key) => {
|
|
||||||
if (o === null || o === undefined || !(key in o)) {
|
|
||||||
found = false;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return o[key];
|
|
||||||
}, decodedToken);
|
|
||||||
|
|
||||||
if (!found) {
|
let roles = get(decodedToken, requiredRoleParameterPath);
|
||||||
|
if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
|
`[openidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`,
|
||||||
);
|
);
|
||||||
|
const rolesList =
|
||||||
|
requiredRoles.length === 1
|
||||||
|
? `"${requiredRoles[0]}"`
|
||||||
|
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
|
||||||
|
return done(null, false, {
|
||||||
|
message: `You must have ${rolesList} role to log in.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!requiredRoles.some((role) => roles.includes(role))) {
|
if (!requiredRoles.some((role) => roles.includes(role))) {
|
||||||
|
|
@ -447,6 +453,50 @@ async function setupOpenId() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (adminRole && adminRoleParameterPath && adminRoleTokenKind) {
|
||||||
|
let adminRoleObject;
|
||||||
|
switch (adminRoleTokenKind) {
|
||||||
|
case 'access':
|
||||||
|
adminRoleObject = jwtDecode(tokenset.access_token);
|
||||||
|
break;
|
||||||
|
case 'id':
|
||||||
|
adminRoleObject = jwtDecode(tokenset.id_token);
|
||||||
|
break;
|
||||||
|
case 'userinfo':
|
||||||
|
adminRoleObject = userinfo;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.error(
|
||||||
|
`[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`,
|
||||||
|
);
|
||||||
|
return done(new Error('Invalid admin role token kind'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminRoles = get(adminRoleObject, adminRoleParameterPath);
|
||||||
|
|
||||||
|
// Accept 3 types of values for the object extracted from adminRoleParameterPath:
|
||||||
|
// 1. A boolean value indicating if the user is an admin
|
||||||
|
// 2. A string with a single role name
|
||||||
|
// 3. An array of role names
|
||||||
|
|
||||||
|
if (
|
||||||
|
adminRoles &&
|
||||||
|
(adminRoles === true ||
|
||||||
|
adminRoles === adminRole ||
|
||||||
|
(Array.isArray(adminRoles) && adminRoles.includes(adminRole)))
|
||||||
|
) {
|
||||||
|
user.role = 'ADMIN';
|
||||||
|
logger.info(
|
||||||
|
`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`,
|
||||||
|
);
|
||||||
|
} else if (user.role === 'ADMIN') {
|
||||||
|
user.role = 'USER';
|
||||||
|
logger.info(
|
||||||
|
`[openidStrategy] User ${username} demoted from admin - role no longer present in token`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||||
/** @type {string | undefined} */
|
/** @type {string | undefined} */
|
||||||
const imageUrl = userinfo.picture;
|
const imageUrl = userinfo.picture;
|
||||||
|
|
|
||||||
|
|
@ -125,6 +125,9 @@ describe('setupOpenId', () => {
|
||||||
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
|
process.env.OPENID_REQUIRED_ROLE = 'requiredRole';
|
||||||
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||||||
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';
|
||||||
|
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'permissions';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';
|
||||||
delete process.env.OPENID_USERNAME_CLAIM;
|
delete process.env.OPENID_USERNAME_CLAIM;
|
||||||
delete process.env.OPENID_NAME_CLAIM;
|
delete process.env.OPENID_NAME_CLAIM;
|
||||||
delete process.env.PROXY;
|
delete process.env.PROXY;
|
||||||
|
|
@ -133,6 +136,7 @@ describe('setupOpenId', () => {
|
||||||
// Default jwtDecode mock returns a token that includes the required role.
|
// Default jwtDecode mock returns a token that includes the required role.
|
||||||
jwtDecode.mockReturnValue({
|
jwtDecode.mockReturnValue({
|
||||||
roles: ['requiredRole'],
|
roles: ['requiredRole'],
|
||||||
|
permissions: ['admin'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// By default, assume that no user is found, so createUser will be called
|
// By default, assume that no user is found, so createUser will be called
|
||||||
|
|
@ -441,4 +445,475 @@ describe('setupOpenId', () => {
|
||||||
expect(callOptions.usePKCE).toBe(false);
|
expect(callOptions.usePKCE).toBe(false);
|
||||||
expect(callOptions.params?.code_challenge_method).toBeUndefined();
|
expect(callOptions.params?.code_challenge_method).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set role to "ADMIN" if OPENID_ADMIN_ROLE is set and user has that role', async () => {
|
||||||
|
// Act
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
// Assert – verify that the user role is set to "ADMIN"
|
||||||
|
expect(user.role).toBe('ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set user role if OPENID_ADMIN_ROLE is set but the user does not have that role', async () => {
|
||||||
|
// Arrange – simulate a token without the admin permission
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
permissions: ['not-admin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
// Assert – verify that the user role is not defined
|
||||||
|
expect(user.role).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should demote existing admin user when admin role is removed from token', async () => {
|
||||||
|
// Arrange – simulate an existing user who is currently an admin
|
||||||
|
const existingAdminUser = {
|
||||||
|
_id: 'existingAdminId',
|
||||||
|
provider: 'openid',
|
||||||
|
email: tokenset.claims().email,
|
||||||
|
openidId: tokenset.claims().sub,
|
||||||
|
username: 'adminuser',
|
||||||
|
name: 'Admin User',
|
||||||
|
role: 'ADMIN',
|
||||||
|
};
|
||||||
|
|
||||||
|
findUser.mockImplementation(async (query) => {
|
||||||
|
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||||
|
return existingAdminUser;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token without admin permission
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
permissions: ['not-admin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
// Assert – verify that the user was demoted
|
||||||
|
expect(user.role).toBe('USER');
|
||||||
|
expect(updateUser).toHaveBeenCalledWith(
|
||||||
|
existingAdminUser._id,
|
||||||
|
expect.objectContaining({
|
||||||
|
role: 'USER',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(logger.info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('demoted from admin - role no longer present in token'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT demote admin user when admin role env vars are not configured', async () => {
|
||||||
|
// Arrange – remove admin role env vars
|
||||||
|
delete process.env.OPENID_ADMIN_ROLE;
|
||||||
|
delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
|
||||||
|
delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
// Simulate an existing admin user
|
||||||
|
const existingAdminUser = {
|
||||||
|
_id: 'existingAdminId',
|
||||||
|
provider: 'openid',
|
||||||
|
email: tokenset.claims().email,
|
||||||
|
openidId: tokenset.claims().sub,
|
||||||
|
username: 'adminuser',
|
||||||
|
name: 'Admin User',
|
||||||
|
role: 'ADMIN',
|
||||||
|
};
|
||||||
|
|
||||||
|
findUser.mockImplementation(async (query) => {
|
||||||
|
if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) {
|
||||||
|
return existingAdminUser;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
// Assert – verify that the admin user was NOT demoted
|
||||||
|
expect(user.role).toBe('ADMIN');
|
||||||
|
expect(updateUser).toHaveBeenCalledWith(
|
||||||
|
existingAdminUser._id,
|
||||||
|
expect.objectContaining({
|
||||||
|
role: 'ADMIN',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lodash get - nested path extraction', () => {
|
||||||
|
it('should extract roles from deeply nested token path', async () => {
|
||||||
|
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||||||
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-client.roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
resource_access: {
|
||||||
|
'my-client': {
|
||||||
|
roles: ['app-user', 'viewer'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(user).toBeTruthy();
|
||||||
|
expect(user.email).toBe(tokenset.claims().email);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract roles from three-level nested path', async () => {
|
||||||
|
process.env.OPENID_REQUIRED_ROLE = 'editor';
|
||||||
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.access.permissions.roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
access: {
|
||||||
|
permissions: {
|
||||||
|
roles: ['editor', 'reader'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(user).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log error and reject login when required role path does not exist in token', async () => {
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||||||
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.nonexistent.roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
resource_access: {
|
||||||
|
'my-client': {
|
||||||
|
roles: ['app-user'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user, details } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
"Key 'resource_access.nonexistent.roles' not found or invalid type in id token!",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(user).toBe(false);
|
||||||
|
expect(details.message).toContain('role to log in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing intermediate nested path gracefully', async () => {
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||||||
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'org.team.roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
org: {
|
||||||
|
other: 'value',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Key 'org.team.roles' not found or invalid type in id token!"),
|
||||||
|
);
|
||||||
|
expect(user).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract admin role from nested path in access token', async () => {
|
||||||
|
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'realm_access.roles';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'access';
|
||||||
|
|
||||||
|
jwtDecode.mockImplementation((token) => {
|
||||||
|
if (token === 'fake_access_token') {
|
||||||
|
return {
|
||||||
|
realm_access: {
|
||||||
|
roles: ['admin', 'user'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(user.role).toBe('ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract admin role from nested path in userinfo', async () => {
|
||||||
|
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'organization.permissions';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'userinfo';
|
||||||
|
|
||||||
|
const userinfoWithNestedGroups = {
|
||||||
|
...tokenset.claims(),
|
||||||
|
organization: {
|
||||||
|
permissions: ['admin', 'write'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
require('openid-client').fetchUserInfo.mockResolvedValue({
|
||||||
|
organization: {
|
||||||
|
permissions: ['admin', 'write'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate({
|
||||||
|
...tokenset,
|
||||||
|
claims: () => userinfoWithNestedGroups,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user.role).toBe('ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle boolean admin role value', async () => {
|
||||||
|
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'is_admin';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
is_admin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(user.role).toBe('ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle string admin role value matching exactly', async () => {
|
||||||
|
process.env.OPENID_ADMIN_ROLE = 'super-admin';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
role: 'super-admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(user.role).toBe('ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set admin role when string value does not match', async () => {
|
||||||
|
process.env.OPENID_ADMIN_ROLE = 'super-admin';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'role';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
role: 'regular-user',
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(user.role).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle array admin role value', async () => {
|
||||||
|
process.env.OPENID_ADMIN_ROLE = 'site-admin';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
app_roles: ['user', 'site-admin', 'moderator'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(user.role).toBe('ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set admin when role is not in array', async () => {
|
||||||
|
process.env.OPENID_ADMIN_ROLE = 'site-admin';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'app_roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole'],
|
||||||
|
app_roles: ['user', 'moderator'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(user.role).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested path with special characters in keys', async () => {
|
||||||
|
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||||||
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'resource_access.my-app-123.roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
resource_access: {
|
||||||
|
'my-app-123': {
|
||||||
|
roles: ['app-user'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(user).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty object at nested path', async () => {
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||||||
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'access.roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
access: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Key 'access.roles' not found or invalid type in id token!"),
|
||||||
|
);
|
||||||
|
expect(user).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null value at intermediate path', async () => {
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||||||
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'data.roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Key 'data.roles' not found or invalid type in id token!"),
|
||||||
|
);
|
||||||
|
expect(user).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject login with invalid admin role token kind', async () => {
|
||||||
|
process.env.OPENID_ADMIN_ROLE = 'admin';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'roles';
|
||||||
|
process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'invalid';
|
||||||
|
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: ['requiredRole', 'admin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
await expect(validate(tokenset)).rejects.toThrow('Invalid admin role token kind');
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
"Invalid admin role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject login when roles path returns invalid type (object)', async () => {
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
process.env.OPENID_REQUIRED_ROLE = 'app-user';
|
||||||
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roles: { admin: true, user: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user, details } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Key 'roles' not found or invalid type in id token!"),
|
||||||
|
);
|
||||||
|
expect(user).toBe(false);
|
||||||
|
expect(details.message).toContain('role to log in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject login when roles path returns invalid type (number)', async () => {
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
process.env.OPENID_REQUIRED_ROLE = 'user';
|
||||||
|
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roleCount';
|
||||||
|
|
||||||
|
jwtDecode.mockReturnValue({
|
||||||
|
roleCount: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupOpenId();
|
||||||
|
verifyCallback = require('openid-client/passport').__getVerifyCallback();
|
||||||
|
|
||||||
|
const { user } = await validate(tokenset);
|
||||||
|
|
||||||
|
expect(logger.error).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Key 'roleCount' not found or invalid type in id token!"),
|
||||||
|
);
|
||||||
|
expect(user).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -144,9 +144,10 @@ export const ArtifactCodeEditor = function ({
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...sharedOptions,
|
...sharedOptions,
|
||||||
|
activeFile: '/' + fileKey,
|
||||||
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
||||||
};
|
};
|
||||||
}, [config, template]);
|
}, [config, template, fileKey]);
|
||||||
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
|
const [readOnly, setReadOnly] = useState(isSubmitting ?? false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReadOnly(isSubmitting ?? false);
|
setReadOnly(isSubmitting ?? false);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ export const ArtifactPreview = memo(function ({
|
||||||
files,
|
files,
|
||||||
fileKey,
|
fileKey,
|
||||||
template,
|
template,
|
||||||
isMermaid,
|
|
||||||
sharedProps,
|
sharedProps,
|
||||||
previewRef,
|
previewRef,
|
||||||
currentCode,
|
currentCode,
|
||||||
|
|
@ -21,7 +20,6 @@ export const ArtifactPreview = memo(function ({
|
||||||
}: {
|
}: {
|
||||||
files: ArtifactFiles;
|
files: ArtifactFiles;
|
||||||
fileKey: string;
|
fileKey: string;
|
||||||
isMermaid: boolean;
|
|
||||||
template: SandpackProviderProps['template'];
|
template: SandpackProviderProps['template'];
|
||||||
sharedProps: Partial<SandpackProviderProps>;
|
sharedProps: Partial<SandpackProviderProps>;
|
||||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||||
|
|
@ -56,15 +54,6 @@ export const ArtifactPreview = memo(function ({
|
||||||
return _options;
|
return _options;
|
||||||
}, [startupConfig, template]);
|
}, [startupConfig, template]);
|
||||||
|
|
||||||
const style: PreviewProps['style'] | undefined = useMemo(() => {
|
|
||||||
if (isMermaid) {
|
|
||||||
return {
|
|
||||||
backgroundColor: '#282C34',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}, [isMermaid]);
|
|
||||||
|
|
||||||
if (Object.keys(artifactFiles).length === 0) {
|
if (Object.keys(artifactFiles).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +73,6 @@ export const ArtifactPreview = memo(function ({
|
||||||
showRefreshButton={false}
|
showRefreshButton={false}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
style={style}
|
|
||||||
/>
|
/>
|
||||||
</SandpackProvider>
|
</SandpackProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,14 @@ import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
||||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
import { ArtifactPreview } from './ArtifactPreview';
|
import { ArtifactPreview } from './ArtifactPreview';
|
||||||
import { MermaidMarkdown } from './MermaidMarkdown';
|
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
export default function ArtifactTabs({
|
export default function ArtifactTabs({
|
||||||
artifact,
|
artifact,
|
||||||
isMermaid,
|
|
||||||
editorRef,
|
editorRef,
|
||||||
previewRef,
|
previewRef,
|
||||||
}: {
|
}: {
|
||||||
artifact: Artifact;
|
artifact: Artifact;
|
||||||
isMermaid: boolean;
|
|
||||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -44,26 +41,22 @@ export default function ArtifactTabs({
|
||||||
value="code"
|
value="code"
|
||||||
id="artifacts-code"
|
id="artifacts-code"
|
||||||
className={cn('flex-grow overflow-auto')}
|
className={cn('flex-grow overflow-auto')}
|
||||||
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{isMermaid ? (
|
<ArtifactCodeEditor
|
||||||
<MermaidMarkdown content={content} isSubmitting={isSubmitting} />
|
files={files}
|
||||||
) : (
|
fileKey={fileKey}
|
||||||
<ArtifactCodeEditor
|
template={template}
|
||||||
files={files}
|
artifact={artifact}
|
||||||
fileKey={fileKey}
|
editorRef={editorRef}
|
||||||
template={template}
|
sharedProps={sharedProps}
|
||||||
artifact={artifact}
|
/>
|
||||||
editorRef={editorRef}
|
|
||||||
sharedProps={sharedProps}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="preview" className="flex-grow overflow-auto">
|
<Tabs.Content value="preview" className="flex-grow overflow-auto" tabIndex={-1}>
|
||||||
<ArtifactPreview
|
<ArtifactPreview
|
||||||
files={files}
|
files={files}
|
||||||
fileKey={fileKey}
|
fileKey={fileKey}
|
||||||
template={template}
|
template={template}
|
||||||
isMermaid={isMermaid}
|
|
||||||
previewRef={previewRef}
|
previewRef={previewRef}
|
||||||
sharedProps={sharedProps}
|
sharedProps={sharedProps}
|
||||||
currentCode={currentCode}
|
currentCode={currentCode}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ export default function Artifacts() {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
activeTab,
|
activeTab,
|
||||||
isMermaid,
|
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
cycleArtifact,
|
cycleArtifact,
|
||||||
|
|
@ -116,7 +115,6 @@ export default function Artifacts() {
|
||||||
</div>
|
</div>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<ArtifactTabs
|
<ArtifactTabs
|
||||||
isMermaid={isMermaid}
|
|
||||||
artifact={currentArtifact}
|
artifact={currentArtifact}
|
||||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
|
||||||
}
|
|
||||||
|
|
@ -19,9 +19,11 @@ export function BrowserVoiceDropdown() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const labelId = 'browser-voice-dropdown-label';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_voice_select')}</div>
|
<div id={labelId}>{localize('com_nav_voice_select')}</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
key={`browser-voice-dropdown-${voices.length}`}
|
key={`browser-voice-dropdown-${voices.length}`}
|
||||||
value={voice ?? ''}
|
value={voice ?? ''}
|
||||||
|
|
@ -30,6 +32,7 @@ export function BrowserVoiceDropdown() {
|
||||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||||
testId="BrowserVoiceDropdown"
|
testId="BrowserVoiceDropdown"
|
||||||
className="z-50"
|
className="z-50"
|
||||||
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -48,9 +51,11 @@ export function ExternalVoiceDropdown() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const labelId = 'external-voice-dropdown-label';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_voice_select')}</div>
|
<div id={labelId}>{localize('com_nav_voice_select')}</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
key={`external-voice-dropdown-${voices.length}`}
|
key={`external-voice-dropdown-${voices.length}`}
|
||||||
value={voice ?? ''}
|
value={voice ?? ''}
|
||||||
|
|
@ -59,6 +64,7 @@ export function ExternalVoiceDropdown() {
|
||||||
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
sizeClasses="min-w-[200px] !max-w-[400px] [--anchor-max-width:400px]"
|
||||||
testId="ExternalVoiceDropdown"
|
testId="ExternalVoiceDropdown"
|
||||||
className="z-50"
|
className="z-50"
|
||||||
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ const AttachFileMenu = ({
|
||||||
|
|
||||||
const currentProvider = provider || endpoint;
|
const currentProvider = provider || endpoint;
|
||||||
|
|
||||||
if (isDocumentSupportedProvider(endpointType || currentProvider)) {
|
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
|
||||||
items.push({
|
items.push({
|
||||||
label: localize('com_ui_upload_provider'),
|
label: localize('com_ui_upload_provider'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil';
|
||||||
import { OGDialog, OGDialogTemplate } from '@librechat/client';
|
import { OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||||
import {
|
import {
|
||||||
EToolResources,
|
EToolResources,
|
||||||
|
EModelEndpoint,
|
||||||
defaultAgentCapabilities,
|
defaultAgentCapabilities,
|
||||||
isDocumentSupportedProvider,
|
isDocumentSupportedProvider,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
|
|
@ -56,12 +57,23 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
||||||
const currentProvider = provider || endpoint;
|
const currentProvider = provider || endpoint;
|
||||||
|
|
||||||
// Check if provider supports document upload
|
// Check if provider supports document upload
|
||||||
if (isDocumentSupportedProvider(endpointType || currentProvider)) {
|
if (isDocumentSupportedProvider(currentProvider || endpointType)) {
|
||||||
|
const isGoogleProvider = currentProvider === EModelEndpoint.google;
|
||||||
|
const validFileTypes = isGoogleProvider
|
||||||
|
? files.every(
|
||||||
|
(file) =>
|
||||||
|
file.type?.startsWith('image/') ||
|
||||||
|
file.type?.startsWith('video/') ||
|
||||||
|
file.type?.startsWith('audio/') ||
|
||||||
|
file.type === 'application/pdf',
|
||||||
|
)
|
||||||
|
: files.every((file) => file.type?.startsWith('image/') || file.type === 'application/pdf');
|
||||||
|
|
||||||
_options.push({
|
_options.push({
|
||||||
label: localize('com_ui_upload_provider'),
|
label: localize('com_ui_upload_provider'),
|
||||||
value: undefined,
|
value: undefined,
|
||||||
icon: <FileImageIcon className="icon-md" />,
|
icon: <FileImageIcon className="icon-md" />,
|
||||||
condition: true, // Allow for both images and documents
|
condition: validFileTypes,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Only show image upload option if all files are images and provider doesn't support documents
|
// Only show image upload option if all files are images and provider doesn't support documents
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@ import React, { useMemo } from 'react';
|
||||||
import type { ModelSelectorProps } from '~/common';
|
import type { ModelSelectorProps } from '~/common';
|
||||||
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
|
import { ModelSelectorProvider, useModelSelectorContext } from './ModelSelectorContext';
|
||||||
import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
|
import { ModelSelectorChatProvider } from './ModelSelectorChatContext';
|
||||||
import { renderModelSpecs, renderEndpoints, renderSearchResults } from './components';
|
import {
|
||||||
|
renderModelSpecs,
|
||||||
|
renderEndpoints,
|
||||||
|
renderSearchResults,
|
||||||
|
renderCustomGroups,
|
||||||
|
} from './components';
|
||||||
import { getSelectedIcon, getDisplayValue } from './utils';
|
import { getSelectedIcon, getDisplayValue } from './utils';
|
||||||
import { CustomMenu as Menu } from './CustomMenu';
|
import { CustomMenu as Menu } from './CustomMenu';
|
||||||
import DialogManager from './DialogManager';
|
import DialogManager from './DialogManager';
|
||||||
|
|
@ -86,8 +91,15 @@ function ModelSelectorContent() {
|
||||||
renderSearchResults(searchResults, localize, searchValue)
|
renderSearchResults(searchResults, localize, searchValue)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{renderModelSpecs(modelSpecs, selectedValues.modelSpec || '')}
|
{/* Render ungrouped modelSpecs (no group field) */}
|
||||||
|
{renderModelSpecs(
|
||||||
|
modelSpecs?.filter((spec) => !spec.group) || [],
|
||||||
|
selectedValues.modelSpec || '',
|
||||||
|
)}
|
||||||
|
{/* Render endpoints (will include grouped specs matching endpoint names) */}
|
||||||
{renderEndpoints(mappedEndpoints ?? [])}
|
{renderEndpoints(mappedEndpoints ?? [])}
|
||||||
|
{/* Render custom groups (specs with group field not matching any endpoint) */}
|
||||||
|
{renderCustomGroups(modelSpecs || [], mappedEndpoints ?? [])}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,12 @@ import { useMemo } from 'react';
|
||||||
import { SettingsIcon } from 'lucide-react';
|
import { SettingsIcon } from 'lucide-react';
|
||||||
import { TooltipAnchor, Spinner } from '@librechat/client';
|
import { TooltipAnchor, Spinner } from '@librechat/client';
|
||||||
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||||
|
import type { TModelSpec } from 'librechat-data-provider';
|
||||||
import type { Endpoint } from '~/common';
|
import type { Endpoint } from '~/common';
|
||||||
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
|
import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu';
|
||||||
import { useModelSelectorContext } from '../ModelSelectorContext';
|
import { useModelSelectorContext } from '../ModelSelectorContext';
|
||||||
import { renderEndpointModels } from './EndpointModelItem';
|
import { renderEndpointModels } from './EndpointModelItem';
|
||||||
|
import { ModelSpecItem } from './ModelSpecItem';
|
||||||
import { filterModels } from '../utils';
|
import { filterModels } from '../utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
@ -57,6 +59,7 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
||||||
const {
|
const {
|
||||||
agentsMap,
|
agentsMap,
|
||||||
assistantsMap,
|
assistantsMap,
|
||||||
|
modelSpecs,
|
||||||
selectedValues,
|
selectedValues,
|
||||||
handleOpenKeyDialog,
|
handleOpenKeyDialog,
|
||||||
handleSelectEndpoint,
|
handleSelectEndpoint,
|
||||||
|
|
@ -64,7 +67,19 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
||||||
setEndpointSearchValue,
|
setEndpointSearchValue,
|
||||||
endpointRequiresUserKey,
|
endpointRequiresUserKey,
|
||||||
} = useModelSelectorContext();
|
} = useModelSelectorContext();
|
||||||
const { model: selectedModel, endpoint: selectedEndpoint } = selectedValues;
|
const {
|
||||||
|
model: selectedModel,
|
||||||
|
endpoint: selectedEndpoint,
|
||||||
|
modelSpec: selectedSpec,
|
||||||
|
} = selectedValues;
|
||||||
|
|
||||||
|
// Filter modelSpecs for this endpoint (by group matching endpoint value)
|
||||||
|
const endpointSpecs = useMemo(() => {
|
||||||
|
if (!modelSpecs || !modelSpecs.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return modelSpecs.filter((spec: TModelSpec) => spec.group === endpoint.value);
|
||||||
|
}, [modelSpecs, endpoint.value]);
|
||||||
|
|
||||||
const searchValue = endpointSearchValues[endpoint.value] || '';
|
const searchValue = endpointSearchValues[endpoint.value] || '';
|
||||||
const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]);
|
const isUserProvided = useMemo(() => endpointRequiresUserKey(endpoint.value), [endpoint.value]);
|
||||||
|
|
@ -138,10 +153,17 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
||||||
<div className="flex items-center justify-center p-2">
|
<div className="flex items-center justify-center p-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</div>
|
</div>
|
||||||
) : filteredModels ? (
|
|
||||||
renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
|
|
||||||
) : (
|
) : (
|
||||||
endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)
|
<>
|
||||||
|
{/* Render modelSpecs for this endpoint */}
|
||||||
|
{endpointSpecs.map((spec: TModelSpec) => (
|
||||||
|
<ModelSpecItem key={spec.name} spec={spec} isSelected={selectedSpec === spec.name} />
|
||||||
|
))}
|
||||||
|
{/* Render endpoint models */}
|
||||||
|
{filteredModels
|
||||||
|
? renderEndpointModels(endpoint, endpoint.models || [], selectedModel, filteredModels)
|
||||||
|
: endpoint.models && renderEndpointModels(endpoint, endpoint.models, selectedModel)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -36,38 +36,42 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={modelId}
|
key={modelId}
|
||||||
onClick={() => handleSelectModel(endpoint, modelId ?? '')}
|
onClick={() => handleSelectModel(endpoint, modelId ?? '')}
|
||||||
className="flex h-8 w-full cursor-pointer items-center justify-start rounded-lg px-3 py-2 text-sm"
|
className="flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex w-full min-w-0 items-center gap-2 px-1 py-1">
|
||||||
{avatarUrl ? (
|
{avatarUrl ? (
|
||||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||||
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
|
<img src={avatarUrl} alt={modelName ?? ''} className="h-full w-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
|
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
|
||||||
endpoint.icon ? (
|
endpoint.icon ? (
|
||||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-full">
|
||||||
{endpoint.icon}
|
{endpoint.icon}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<span>{modelName}</span>
|
<span className="truncate text-left">{modelName}</span>
|
||||||
|
{isGlobal && (
|
||||||
|
<EarthIcon className="ml-auto size-4 flex-shrink-0 self-center text-green-400" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<svg
|
<div className="flex-shrink-0 self-center">
|
||||||
width="16"
|
<svg
|
||||||
height="16"
|
width="16"
|
||||||
viewBox="0 0 24 24"
|
height="16"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
fill="none"
|
||||||
className="block"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
className="block"
|
||||||
<path
|
>
|
||||||
fillRule="evenodd"
|
<path
|
||||||
clipRule="evenodd"
|
fillRule="evenodd"
|
||||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
clipRule="evenodd"
|
||||||
fill="currentColor"
|
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||||
/>
|
fill="currentColor"
|
||||||
</svg>
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ export * from './ModelSpecItem';
|
||||||
export * from './EndpointModelItem';
|
export * from './EndpointModelItem';
|
||||||
export * from './EndpointItem';
|
export * from './EndpointItem';
|
||||||
export * from './SearchResults';
|
export * from './SearchResults';
|
||||||
|
export * from './CustomGroup';
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,10 @@ const MessageRender = memo(
|
||||||
() =>
|
() =>
|
||||||
showCardRender && !isLatestMessage
|
showCardRender && !isLatestMessage
|
||||||
? () => {
|
? () => {
|
||||||
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
|
logger.log(
|
||||||
|
'latest_message',
|
||||||
|
`Message Card click: Setting ${msg?.messageId} as latest message`,
|
||||||
|
);
|
||||||
logger.dir(msg);
|
logger.dir(msg);
|
||||||
setLatestMessage(msg!);
|
setLatestMessage(msg!);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ const LoadingSpinner = memo(() => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
LoadingSpinner.displayName = 'LoadingSpinner';
|
||||||
|
|
||||||
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
|
const DateLabel: FC<{ groupName: string }> = memo(({ groupName }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
return (
|
return (
|
||||||
|
|
@ -74,6 +76,7 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
isLoading,
|
isLoading,
|
||||||
isSearchLoading,
|
isSearchLoading,
|
||||||
}) => {
|
}) => {
|
||||||
|
const localize = useLocalize();
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const convoHeight = isSmallScreen ? 44 : 34;
|
const convoHeight = isSmallScreen ? 44 : 34;
|
||||||
|
|
||||||
|
|
@ -181,7 +184,7 @@ const Conversations: FC<ConversationsProps> = ({
|
||||||
{isSearchLoading ? (
|
{isSearchLoading ? (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<Spinner className="text-text-primary" />
|
<Spinner className="text-text-primary" />
|
||||||
<span className="ml-2 text-text-primary">Loading...</span>
|
<span className="ml-2 text-text-primary">{localize('com_ui_loading')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|
|
||||||
|
|
@ -135,8 +135,9 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
||||||
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
|
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
|
||||||
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
||||||
)}
|
)}
|
||||||
role="listitem"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={renaming ? -1 : 0}
|
||||||
|
aria-label={`${title || localize('com_ui_untitled')} conversation`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (renaming) {
|
if (renaming) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -149,7 +150,8 @@ export default function Conversation({ conversation, retainView, toggleNav }: Co
|
||||||
if (renaming) {
|
if (renaming) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
handleNavigation(false);
|
handleNavigation(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,7 @@ const ConvoLink: React.FC<ConvoLinkProps> = ({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRename();
|
onRename();
|
||||||
}}
|
}}
|
||||||
role="button"
|
aria-label={title || localize('com_ui_untitled')}
|
||||||
aria-label={isSmallScreen ? undefined : title || localize('com_ui_untitled')}
|
|
||||||
>
|
>
|
||||||
{title || localize('com_ui_untitled')}
|
{title || localize('com_ui_untitled')}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ function ConvoOptions({
|
||||||
<Menu.MenuButton
|
<Menu.MenuButton
|
||||||
id={`conversation-menu-${conversationId}`}
|
id={`conversation-menu-${conversationId}`}
|
||||||
aria-label={localize('com_nav_convo_menu_options')}
|
aria-label={localize('com_nav_convo_menu_options')}
|
||||||
|
aria-readonly={undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
|
'inline-flex h-7 w-7 items-center justify-center gap-2 rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
|
||||||
isActiveConvo === true || isPopoverActive
|
isActiveConvo === true || isPopoverActive
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { Copy, CopyCheck } from 'lucide-react';
|
import { Copy, CopyCheck } from 'lucide-react';
|
||||||
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
|
import { useGetSharedLinkQuery } from 'librechat-data-provider/react-query';
|
||||||
|
|
@ -6,6 +7,7 @@ import { OGDialogTemplate, Button, Spinner, OGDialog } from '@librechat/client';
|
||||||
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
import { useLocalize, useCopyToClipboard } from '~/hooks';
|
||||||
import SharedLinkButton from './SharedLinkButton';
|
import SharedLinkButton from './SharedLinkButton';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
export default function ShareButton({
|
export default function ShareButton({
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|
@ -24,8 +26,9 @@ export default function ShareButton({
|
||||||
const [showQR, setShowQR] = useState(false);
|
const [showQR, setShowQR] = useState(false);
|
||||||
const [sharedLink, setSharedLink] = useState('');
|
const [sharedLink, setSharedLink] = useState('');
|
||||||
const [isCopying, setIsCopying] = useState(false);
|
const [isCopying, setIsCopying] = useState(false);
|
||||||
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
|
|
||||||
const copyLink = useCopyToClipboard({ text: sharedLink });
|
const copyLink = useCopyToClipboard({ text: sharedLink });
|
||||||
|
const latestMessage = useRecoilValue(store.latestMessageFamily(0));
|
||||||
|
const { data: share, isLoading } = useGetSharedLinkQuery(conversationId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (share?.shareId !== undefined) {
|
if (share?.shareId !== undefined) {
|
||||||
|
|
@ -39,6 +42,7 @@ export default function ShareButton({
|
||||||
<SharedLinkButton
|
<SharedLinkButton
|
||||||
share={share}
|
share={share}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
|
targetMessageId={latestMessage?.messageId}
|
||||||
setShareDialogOpen={onOpenChange}
|
setShareDialogOpen={onOpenChange}
|
||||||
showQR={showQR}
|
showQR={showQR}
|
||||||
setShowQR={setShowQR}
|
setShowQR={setShowQR}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { useLocalize } from '~/hooks';
|
||||||
export default function SharedLinkButton({
|
export default function SharedLinkButton({
|
||||||
share,
|
share,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
targetMessageId,
|
||||||
setShareDialogOpen,
|
setShareDialogOpen,
|
||||||
showQR,
|
showQR,
|
||||||
setShowQR,
|
setShowQR,
|
||||||
|
|
@ -28,6 +29,7 @@ export default function SharedLinkButton({
|
||||||
}: {
|
}: {
|
||||||
share: TSharedLinkGetResponse | undefined;
|
share: TSharedLinkGetResponse | undefined;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
targetMessageId?: string;
|
||||||
setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setShareDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
showQR: boolean;
|
showQR: boolean;
|
||||||
setShowQR: (showQR: boolean) => void;
|
setShowQR: (showQR: boolean) => void;
|
||||||
|
|
@ -86,7 +88,7 @@ export default function SharedLinkButton({
|
||||||
};
|
};
|
||||||
|
|
||||||
const createShareLink = async () => {
|
const createShareLink = async () => {
|
||||||
const share = await mutate({ conversationId });
|
const share = await mutate({ conversationId, targetMessageId });
|
||||||
const newLink = generateShareLink(share.shareId);
|
const newLink = generateShareLink(share.shareId);
|
||||||
setSharedLink(newLink);
|
setSharedLink(newLink);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,33 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useForm, FormProvider } from 'react-hook-form';
|
import { useForm, FormProvider } from 'react-hook-form';
|
||||||
import { OGDialogTemplate, OGDialog, Dropdown, useToastContext } from '@librechat/client';
|
import {
|
||||||
|
OGDialog,
|
||||||
|
OGDialogContent,
|
||||||
|
OGDialogHeader,
|
||||||
|
OGDialogTitle,
|
||||||
|
OGDialogFooter,
|
||||||
|
Dropdown,
|
||||||
|
useToastContext,
|
||||||
|
Button,
|
||||||
|
Label,
|
||||||
|
OGDialogTrigger,
|
||||||
|
Spinner,
|
||||||
|
} from '@librechat/client';
|
||||||
import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider';
|
import { EModelEndpoint, alternateName, isAssistantsEndpoint } from 'librechat-data-provider';
|
||||||
|
import {
|
||||||
|
useRevokeAllUserKeysMutation,
|
||||||
|
useRevokeUserKeyMutation,
|
||||||
|
} from 'librechat-data-provider/react-query';
|
||||||
import type { TDialogProps } from '~/common';
|
import type { TDialogProps } from '~/common';
|
||||||
import { useGetEndpointsQuery } from '~/data-provider';
|
import { useGetEndpointsQuery } from '~/data-provider';
|
||||||
import { RevokeKeysButton } from '~/components/Nav';
|
|
||||||
import { useUserKey, useLocalize } from '~/hooks';
|
import { useUserKey, useLocalize } from '~/hooks';
|
||||||
|
import { NotificationSeverity } from '~/common';
|
||||||
import CustomConfig from './CustomEndpoint';
|
import CustomConfig from './CustomEndpoint';
|
||||||
import GoogleConfig from './GoogleConfig';
|
import GoogleConfig from './GoogleConfig';
|
||||||
import OpenAIConfig from './OpenAIConfig';
|
import OpenAIConfig from './OpenAIConfig';
|
||||||
import OtherConfig from './OtherConfig';
|
import OtherConfig from './OtherConfig';
|
||||||
import HelpText from './HelpText';
|
import HelpText from './HelpText';
|
||||||
|
import { logger } from '~/utils';
|
||||||
|
|
||||||
const endpointComponents = {
|
const endpointComponents = {
|
||||||
[EModelEndpoint.google]: GoogleConfig,
|
[EModelEndpoint.google]: GoogleConfig,
|
||||||
|
|
@ -42,6 +59,94 @@ const EXPIRY = {
|
||||||
NEVER: { label: 'never', value: 0 },
|
NEVER: { label: 'never', value: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RevokeKeysButton = ({
|
||||||
|
endpoint,
|
||||||
|
disabled,
|
||||||
|
setDialogOpen,
|
||||||
|
}: {
|
||||||
|
endpoint: string;
|
||||||
|
disabled: boolean;
|
||||||
|
setDialogOpen: (open: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const revokeKeyMutation = useRevokeUserKeyMutation(endpoint);
|
||||||
|
const revokeKeysMutation = useRevokeAllUserKeysMutation();
|
||||||
|
|
||||||
|
const handleSuccess = () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_revoke_key_success'),
|
||||||
|
status: NotificationSeverity.SUCCESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!setDialogOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_revoke_key_error'),
|
||||||
|
status: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
revokeKeyMutation.mutate(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
onSuccess: handleSuccess,
|
||||||
|
onError: handleError,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = revokeKeyMutation.isLoading || revokeKeysMutation.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<OGDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<OGDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{localize('com_ui_revoke')}
|
||||||
|
</Button>
|
||||||
|
</OGDialogTrigger>
|
||||||
|
<OGDialogContent className="max-w-[450px]">
|
||||||
|
<OGDialogHeader>
|
||||||
|
<OGDialogTitle>{localize('com_ui_revoke_key_endpoint', { 0: endpoint })}</OGDialogTitle>
|
||||||
|
</OGDialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Label className="text-left text-sm font-medium">
|
||||||
|
{localize('com_ui_revoke_key_confirm')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<OGDialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
{localize('com_ui_cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-destructive text-white transition-all duration-200 hover:bg-destructive/80"
|
||||||
|
>
|
||||||
|
{isLoading ? <Spinner /> : localize('com_ui_revoke')}
|
||||||
|
</Button>
|
||||||
|
</OGDialogFooter>
|
||||||
|
</OGDialogContent>
|
||||||
|
</OGDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const SetKeyDialog = ({
|
const SetKeyDialog = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -83,7 +188,7 @@ const SetKeyDialog = ({
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel);
|
const selectedOption = expirationOptions.find((option) => option.label === expiresAtLabel);
|
||||||
let expiresAt;
|
let expiresAt: number | null;
|
||||||
|
|
||||||
if (selectedOption?.value === 0) {
|
if (selectedOption?.value === 0) {
|
||||||
expiresAt = null;
|
expiresAt = null;
|
||||||
|
|
@ -92,8 +197,20 @@ const SetKeyDialog = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveKey = (key: string) => {
|
const saveKey = (key: string) => {
|
||||||
saveUserKey(key, expiresAt);
|
try {
|
||||||
onOpenChange(false);
|
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 ?? '')) {
|
if (formSet.has(endpoint) || formSet.has(endpointType ?? '')) {
|
||||||
|
|
@ -148,6 +265,14 @@ const SetKeyDialog = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!userKey.trim()) {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_key_required'),
|
||||||
|
status: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
saveKey(userKey);
|
saveKey(userKey);
|
||||||
setUserKey('');
|
setUserKey('');
|
||||||
};
|
};
|
||||||
|
|
@ -159,56 +284,54 @@ const SetKeyDialog = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<OGDialogTemplate
|
<OGDialogContent className="w-11/12 max-w-2xl">
|
||||||
title={`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
|
<OGDialogHeader>
|
||||||
className="w-11/12 max-w-2xl"
|
<OGDialogTitle>
|
||||||
showCancelButton={false}
|
{`${localize('com_endpoint_config_key_for')} ${alternateName[endpoint] ?? endpoint}`}
|
||||||
main={
|
</OGDialogTitle>
|
||||||
<div className="grid w-full items-center gap-2">
|
</OGDialogHeader>
|
||||||
<small className="text-red-600">
|
<div className="grid w-full items-center gap-2 py-4">
|
||||||
{expiryTime === 'never'
|
<small className="text-red-600">
|
||||||
? localize('com_endpoint_config_key_never_expires')
|
{expiryTime === 'never'
|
||||||
: `${localize('com_endpoint_config_key_encryption')} ${new Date(
|
? localize('com_endpoint_config_key_never_expires')
|
||||||
expiryTime ?? 0,
|
: `${localize('com_endpoint_config_key_encryption')} ${new Date(
|
||||||
).toLocaleString()}`}
|
expiryTime ?? 0,
|
||||||
</small>
|
).toLocaleString()}`}
|
||||||
<Dropdown
|
</small>
|
||||||
label="Expires "
|
<Dropdown
|
||||||
value={expiresAtLabel}
|
label="Expires "
|
||||||
onChange={handleExpirationChange}
|
value={expiresAtLabel}
|
||||||
options={expirationOptions.map((option) => option.label)}
|
onChange={handleExpirationChange}
|
||||||
sizeClasses="w-[185px]"
|
options={expirationOptions.map((option) => option.label)}
|
||||||
portal={false}
|
sizeClasses="w-[185px]"
|
||||||
|
portal={false}
|
||||||
|
/>
|
||||||
|
<div className="mt-2" />
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<EndpointComponent
|
||||||
|
userKey={userKey}
|
||||||
|
setUserKey={setUserKey}
|
||||||
|
endpoint={
|
||||||
|
endpoint === EModelEndpoint.gptPlugins && (config?.azure ?? false)
|
||||||
|
? EModelEndpoint.azureOpenAI
|
||||||
|
: endpoint
|
||||||
|
}
|
||||||
|
userProvideURL={userProvideURL}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2" />
|
</FormProvider>
|
||||||
<FormProvider {...methods}>
|
<HelpText endpoint={endpoint} />
|
||||||
<EndpointComponent
|
</div>
|
||||||
userKey={userKey}
|
<OGDialogFooter>
|
||||||
setUserKey={setUserKey}
|
|
||||||
endpoint={
|
|
||||||
endpoint === EModelEndpoint.gptPlugins && (config?.azure ?? false)
|
|
||||||
? EModelEndpoint.azureOpenAI
|
|
||||||
: endpoint
|
|
||||||
}
|
|
||||||
userProvideURL={userProvideURL}
|
|
||||||
/>
|
|
||||||
</FormProvider>
|
|
||||||
<HelpText endpoint={endpoint} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
selection={{
|
|
||||||
selectHandler: submit,
|
|
||||||
selectClasses: 'btn btn-primary',
|
|
||||||
selectText: localize('com_ui_submit'),
|
|
||||||
}}
|
|
||||||
leftButtons={
|
|
||||||
<RevokeKeysButton
|
<RevokeKeysButton
|
||||||
endpoint={endpoint}
|
endpoint={endpoint}
|
||||||
disabled={!(expiryTime ?? '')}
|
disabled={!(expiryTime ?? '')}
|
||||||
setDialogOpen={onOpenChange}
|
setDialogOpen={onOpenChange}
|
||||||
/>
|
/>
|
||||||
}
|
<Button variant="submit" onClick={submit}>
|
||||||
/>
|
{localize('com_ui_submit')}
|
||||||
|
</Button>
|
||||||
|
</OGDialogFooter>
|
||||||
|
</OGDialogContent>
|
||||||
</OGDialog>
|
</OGDialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,10 @@ const ContentRender = memo(
|
||||||
() =>
|
() =>
|
||||||
showCardRender && !isLatestMessage
|
showCardRender && !isLatestMessage
|
||||||
? () => {
|
? () => {
|
||||||
logger.log(`Message Card click: Setting ${msg?.messageId} as latest message`);
|
logger.log(
|
||||||
|
'latest_message',
|
||||||
|
`Message Card click: Setting ${msg?.messageId} as latest message`,
|
||||||
|
);
|
||||||
logger.dir(msg);
|
logger.dir(msg);
|
||||||
setLatestMessage(msg!);
|
setLatestMessage(msg!);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||||
<line x1="18" x2="6" y1="6" y2="18"></line>
|
<line x1="18" x2="6" y1="6" y2="18"></line>
|
||||||
<line x1="6" x2="18" y1="6" y2="18"></line>
|
<line x1="6" x2="18" y1="6" y2="18"></line>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="sr-only">{localize('com_ui_close')}</span>
|
<span className="sr-only">{localize('com_ui_close_settings')}</span>
|
||||||
</button>
|
</button>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
|
<div className="max-h-[550px] overflow-auto px-6 md:max-h-[400px] md:min-h-[400px] md:w-[680px]">
|
||||||
|
|
@ -220,35 +220,35 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||||
))}
|
))}
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<div className="overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
|
<div className="overflow-auto sm:w-full sm:max-w-none md:pr-0.5 md:pt-0.5">
|
||||||
<Tabs.Content value={SettingsTabValues.GENERAL}>
|
<Tabs.Content value={SettingsTabValues.GENERAL} tabIndex={-1}>
|
||||||
<General />
|
<General />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value={SettingsTabValues.CHAT}>
|
<Tabs.Content value={SettingsTabValues.CHAT} tabIndex={-1}>
|
||||||
<Chat />
|
<Chat />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value={SettingsTabValues.COMMANDS}>
|
<Tabs.Content value={SettingsTabValues.COMMANDS} tabIndex={-1}>
|
||||||
<Commands />
|
<Commands />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value={SettingsTabValues.SPEECH}>
|
<Tabs.Content value={SettingsTabValues.SPEECH} tabIndex={-1}>
|
||||||
<Speech />
|
<Speech />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{hasAnyPersonalizationFeature && (
|
{hasAnyPersonalizationFeature && (
|
||||||
<Tabs.Content value={SettingsTabValues.PERSONALIZATION}>
|
<Tabs.Content value={SettingsTabValues.PERSONALIZATION} tabIndex={-1}>
|
||||||
<Personalization
|
<Personalization
|
||||||
hasMemoryOptOut={hasMemoryOptOut}
|
hasMemoryOptOut={hasMemoryOptOut}
|
||||||
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
|
hasAnyPersonalizationFeature={hasAnyPersonalizationFeature}
|
||||||
/>
|
/>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
)}
|
)}
|
||||||
<Tabs.Content value={SettingsTabValues.DATA}>
|
<Tabs.Content value={SettingsTabValues.DATA} tabIndex={-1}>
|
||||||
<Data />
|
<Data />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
{startupConfig?.balance?.enabled && (
|
{startupConfig?.balance?.enabled && (
|
||||||
<Tabs.Content value={SettingsTabValues.BALANCE}>
|
<Tabs.Content value={SettingsTabValues.BALANCE} tabIndex={-1}>
|
||||||
<Balance />
|
<Balance />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
)}
|
)}
|
||||||
<Tabs.Content value={SettingsTabValues.ACCOUNT}>
|
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
|
||||||
<Account />
|
<Account />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import React, { useState, useRef, useCallback } from 'react';
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import { useSetRecoilState } from 'recoil';
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
// @ts-ignore - no type definitions available
|
||||||
import AvatarEditor from 'react-avatar-editor';
|
import AvatarEditor from 'react-avatar-editor';
|
||||||
import { FileImage, RotateCw, Upload } from 'lucide-react';
|
import { FileImage, RotateCw, Upload, ZoomIn, ZoomOut, Move, X } from 'lucide-react';
|
||||||
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
|
import { fileConfig as defaultFileConfig, mergeFileConfig } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
|
Label,
|
||||||
Slider,
|
Slider,
|
||||||
Button,
|
Button,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
|
@ -25,14 +27,20 @@ interface AvatarEditorRef {
|
||||||
getImage: () => HTMLImageElement;
|
getImage: () => HTMLImageElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Position {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
function Avatar() {
|
function Avatar() {
|
||||||
const setUser = useSetRecoilState(store.user);
|
const setUser = useSetRecoilState(store.user);
|
||||||
|
|
||||||
const [scale, setScale] = useState<number>(1);
|
const [scale, setScale] = useState<number>(1);
|
||||||
const [rotation, setRotation] = useState<number>(0);
|
const [rotation, setRotation] = useState<number>(0);
|
||||||
|
const [position, setPosition] = useState<Position>({ x: 0.5, y: 0.5 });
|
||||||
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||||
const editorRef = useRef<AvatarEditorRef | null>(null);
|
const editorRef = useRef<AvatarEditorRef | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const openButtonRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
const [image, setImage] = useState<string | File | null>(null);
|
const [image, setImage] = useState<string | File | null>(null);
|
||||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||||
|
|
@ -48,7 +56,6 @@ function Avatar() {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
showToast({ message: localize('com_ui_upload_success') });
|
showToast({ message: localize('com_ui_upload_success') });
|
||||||
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
|
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
|
||||||
openButtonRef.current?.click();
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|
@ -61,29 +68,45 @@ function Avatar() {
|
||||||
handleFile(file);
|
handleFile(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFile = (file: File | undefined) => {
|
const handleFile = useCallback(
|
||||||
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
|
(file: File | undefined) => {
|
||||||
setImage(file);
|
if (fileConfig.avatarSizeLimit != null && file && file.size <= fileConfig.avatarSizeLimit) {
|
||||||
setScale(1);
|
setImage(file);
|
||||||
setRotation(0);
|
setScale(1);
|
||||||
} else {
|
setRotation(0);
|
||||||
const megabytes =
|
setPosition({ x: 0.5, y: 0.5 });
|
||||||
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
|
} else {
|
||||||
showToast({
|
const megabytes =
|
||||||
message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }),
|
fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2;
|
||||||
status: 'error',
|
showToast({
|
||||||
});
|
message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }),
|
||||||
}
|
status: 'error',
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fileConfig.avatarSizeLimit, localize, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const handleScaleChange = (value: number[]) => {
|
const handleScaleChange = (value: number[]) => {
|
||||||
setScale(value[0]);
|
setScale(value[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleZoomIn = () => {
|
||||||
|
setScale((prev) => Math.min(prev + 0.2, 5));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomOut = () => {
|
||||||
|
setScale((prev) => Math.max(prev - 0.2, 1));
|
||||||
|
};
|
||||||
|
|
||||||
const handleRotate = () => {
|
const handleRotate = () => {
|
||||||
setRotation((prev) => (prev + 90) % 360);
|
setRotation((prev) => (prev + 90) % 360);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePositionChange = (position: Position) => {
|
||||||
|
setPosition(position);
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpload = () => {
|
const handleUpload = () => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
const canvas = editorRef.current.getImageScaledToCanvas();
|
const canvas = editorRef.current.getImageScaledToCanvas();
|
||||||
|
|
@ -98,11 +121,14 @@ function Avatar() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
const handleDrop = useCallback(
|
||||||
e.preventDefault();
|
(e: React.DragEvent<HTMLDivElement>) => {
|
||||||
const file = e.dataTransfer.files[0];
|
e.preventDefault();
|
||||||
handleFile(file);
|
const file = e.dataTransfer.files[0];
|
||||||
}, []);
|
handleFile(file);
|
||||||
|
},
|
||||||
|
[handleFile],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -116,8 +142,15 @@ function Avatar() {
|
||||||
setImage(null);
|
setImage(null);
|
||||||
setScale(1);
|
setScale(1);
|
||||||
setRotation(0);
|
setRotation(0);
|
||||||
|
setPosition({ x: 0.5, y: 0.5 });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setScale(1);
|
||||||
|
setRotation(0);
|
||||||
|
setPosition({ x: 0.5, y: 0.5 });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OGDialog
|
<OGDialog
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
|
|
@ -125,90 +158,190 @@ function Avatar() {
|
||||||
setDialogOpen(open);
|
setDialogOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
resetImage();
|
resetImage();
|
||||||
setTimeout(() => {
|
|
||||||
openButtonRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>{localize('com_nav_profile_picture')}</span>
|
<span>{localize('com_nav_profile_picture')}</span>
|
||||||
<OGDialogTrigger ref={openButtonRef}>
|
<OGDialogTrigger asChild>
|
||||||
<Button variant="outline">
|
<Button variant="outline">
|
||||||
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
|
<FileImage className="mr-2 flex w-[22px] items-center" />
|
||||||
<span>{localize('com_nav_change_picture')}</span>
|
<span>{localize('com_nav_change_picture')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OGDialogContent className="w-11/12 max-w-sm" style={{ borderRadius: '12px' }}>
|
<OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
|
||||||
<OGDialogHeader>
|
<OGDialogHeader>
|
||||||
<OGDialogTitle className="text-lg font-medium leading-6 text-text-primary">
|
<OGDialogTitle className="text-lg font-medium leading-6 text-text-primary">
|
||||||
{image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')}
|
{image != null ? localize('com_ui_preview') : localize('com_ui_upload_image')}
|
||||||
</OGDialogTitle>
|
</OGDialogTitle>
|
||||||
</OGDialogHeader>
|
</OGDialogHeader>
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center p-2">
|
||||||
{image != null ? (
|
{image != null ? (
|
||||||
<>
|
<>
|
||||||
<div className="relative overflow-hidden rounded-full">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden rounded-full ring-4 ring-gray-200 transition-all dark:ring-gray-700',
|
||||||
|
isDragging && 'cursor-move ring-blue-500 dark:ring-blue-400',
|
||||||
|
)}
|
||||||
|
onMouseDown={() => setIsDragging(true)}
|
||||||
|
onMouseUp={() => setIsDragging(false)}
|
||||||
|
onMouseLeave={() => setIsDragging(false)}
|
||||||
|
>
|
||||||
<AvatarEditor
|
<AvatarEditor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
image={image}
|
image={image}
|
||||||
width={250}
|
width={280}
|
||||||
height={250}
|
height={280}
|
||||||
border={0}
|
border={0}
|
||||||
borderRadius={125}
|
borderRadius={140}
|
||||||
color={[255, 255, 255, 0.6]}
|
color={[255, 255, 255, 0.6]}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
rotate={rotation}
|
rotate={rotation}
|
||||||
|
position={position}
|
||||||
|
onPositionChange={handlePositionChange}
|
||||||
|
className="cursor-move"
|
||||||
/>
|
/>
|
||||||
|
{!isDragging && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 transition-opacity hover:opacity-100">
|
||||||
|
<div className="rounded-full bg-black/50 p-2">
|
||||||
|
<Move className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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">
|
<div className="mt-6 w-full space-y-6">
|
||||||
<span className="text-sm">{localize('com_ui_zoom')}</span>
|
{/* Zoom Controls */}
|
||||||
<Slider
|
<div className="space-y-2">
|
||||||
value={[scale]}
|
<div className="flex items-center justify-between">
|
||||||
min={1}
|
<Label htmlFor="zoom-slider" className="text-sm font-medium">
|
||||||
max={5}
|
{localize('com_ui_zoom')}
|
||||||
step={0.001}
|
</Label>
|
||||||
onValueChange={handleScaleChange}
|
<span className="text-sm text-text-secondary">{Math.round(scale * 100)}%</span>
|
||||||
className="w-2/3 max-w-xs"
|
</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.1}
|
||||||
|
onValueChange={handleScaleChange}
|
||||||
|
className="flex-1"
|
||||||
|
aria-label={localize('com_ui_zoom_level')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
<button
|
|
||||||
onClick={handleRotate}
|
<div className="flex items-center justify-center space-x-3">
|
||||||
className="rounded-full bg-gray-200 p-2 transition-colors hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500"
|
<Button
|
||||||
>
|
type="button"
|
||||||
<RotateCw className="h-5 w-5" />
|
variant="outline"
|
||||||
</button>
|
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-4 w-4" />
|
||||||
|
)}
|
||||||
|
{localize('com_ui_upload')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
className={cn(
|
|
||||||
'btn btn-primary mt-4 flex w-full hover:bg-green-600',
|
|
||||||
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" />
|
|
||||||
)}
|
|
||||||
{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}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={openFileDialog}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
openFileDialog();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={localize('com_ui_upload_avatar_label')}
|
||||||
>
|
>
|
||||||
<FileImage className="mb-4 size-12 text-gray-400" />
|
<FileImage className="mb-4 size-16 text-gray-400" />
|
||||||
<p className="mb-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
<p className="mb-2 text-center text-sm font-medium text-text-primary">
|
||||||
{localize('com_ui_drag_drop')}
|
{localize('com_ui_drag_drop')}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="secondary" onClick={openFileDialog}>
|
<p className="mb-4 text-center text-xs text-text-secondary">
|
||||||
|
{localize('com_ui_max_file_size', {
|
||||||
|
0:
|
||||||
|
fileConfig.avatarSizeLimit != null
|
||||||
|
? formatBytes(fileConfig.avatarSizeLimit)
|
||||||
|
: '2MB',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<Button type="button" variant="secondary" onClick={openFileDialog}>
|
||||||
{localize('com_ui_select_file')}
|
{localize('com_ui_select_file')}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
|
|
@ -217,6 +350,7 @@ function Avatar() {
|
||||||
className="hidden"
|
className="hidden"
|
||||||
accept=".png, .jpg, .jpeg"
|
accept=".png, .jpg, .jpeg"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
|
aria-label={localize('com_ui_file_input_avatar_label')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { LockIcon, Trash } from 'lucide-react';
|
import { LockIcon, Trash } from 'lucide-react';
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
|
Label,
|
||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
|
@ -45,11 +46,11 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
|
||||||
<>
|
<>
|
||||||
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>{localize('com_nav_delete_account')}</span>
|
<Label id="delete-account-label">{localize('com_nav_delete_account')}</Label>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
aria-labelledby="delete-account-label"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
|
||||||
onClick={() => setDialogOpen(true)}
|
onClick={() => setDialogOpen(true)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Label className="font-light"> {localize('com_nav_2fa')}</Label>
|
<Label> {localize('com_nav_2fa')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export default function DisplayUsernameMessages() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Label className="font-light">{localize('com_nav_user_name_display')}</Label>
|
<Label id="user-name-display-label">{localize('com_nav_user_name_display')}</Label>
|
||||||
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} />
|
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} />
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -24,6 +24,7 @@ export default function DisplayUsernameMessages() {
|
||||||
onCheckedChange={handleCheckedChange}
|
onCheckedChange={handleCheckedChange}
|
||||||
className="ml-4"
|
className="ml-4"
|
||||||
data-testid="UsernameDisplay"
|
data-testid="UsernameDisplay"
|
||||||
|
aria-labelledby="user-name-display-label"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,16 +19,16 @@ const ChatDirection = () => {
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
aria-label="Toggle chat direction"
|
aria-label={`${localize('com_nav_chat_direction')}: ${localize('com_ui_x_selected', {
|
||||||
|
0:
|
||||||
|
direction === 'LTR'
|
||||||
|
? localize('chat_direction_left_to_right')
|
||||||
|
: localize('chat_direction_right_to_left'),
|
||||||
|
})}`}
|
||||||
onClick={toggleChatDirection}
|
onClick={toggleChatDirection}
|
||||||
data-testid="chatDirection"
|
data-testid="chatDirection"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{direction.toLowerCase()}</span>
|
{direction.toLowerCase()}
|
||||||
<span id="chat-direction-status" className="sr-only">
|
|
||||||
{direction === 'LTR'
|
|
||||||
? localize('chat_direction_left_to_right')
|
|
||||||
: localize('chat_direction_right_to_left')}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,11 @@ export default function FontSizeSelector() {
|
||||||
{ value: 'text-xl', label: localize('com_nav_font_size_xl') },
|
{ value: 'text-xl', label: localize('com_nav_font_size_xl') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const labelId = 'font-size-selector-label';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div>{localize('com_nav_font_size')}</div>
|
<div id={labelId}>{localize('com_nav_font_size')}</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
options={options}
|
options={options}
|
||||||
|
|
@ -30,6 +32,7 @@ export default function FontSizeSelector() {
|
||||||
testId="font-size-selector"
|
testId="font-size-selector"
|
||||||
sizeClasses="w-[150px]"
|
sizeClasses="w-[150px]"
|
||||||
className="z-50"
|
className="z-50"
|
||||||
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,14 @@ export const ForkSettings = () => {
|
||||||
<>
|
<>
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div> {localize('com_ui_fork_default')} </div>
|
<div id="remember-default-fork-label"> {localize('com_ui_fork_default')} </div>
|
||||||
<Switch
|
<Switch
|
||||||
id="rememberDefaultFork"
|
id="rememberDefaultFork"
|
||||||
checked={remember}
|
checked={remember}
|
||||||
onCheckedChange={setRemember}
|
onCheckedChange={setRemember}
|
||||||
className="ml-4"
|
className="ml-4"
|
||||||
data-testid="rememberDefaultFork"
|
data-testid="rememberDefaultFork"
|
||||||
|
aria-labelledby="remember-default-fork-label"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -34,7 +35,7 @@ export const ForkSettings = () => {
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div>{localize('com_ui_fork_change_default')}</div>
|
<div id="fork-change-default-label">{localize('com_ui_fork_change_default')}</div>
|
||||||
<InfoHoverCard
|
<InfoHoverCard
|
||||||
side={ESide.Bottom}
|
side={ESide.Bottom}
|
||||||
text={localize('com_nav_info_fork_change_default')}
|
text={localize('com_nav_info_fork_change_default')}
|
||||||
|
|
@ -47,6 +48,7 @@ export const ForkSettings = () => {
|
||||||
sizeClasses="w-[200px]"
|
sizeClasses="w-[200px]"
|
||||||
testId="fork-setting-dropdown"
|
testId="fork-setting-dropdown"
|
||||||
className="z-[50]"
|
className="z-[50]"
|
||||||
|
aria-labelledby="fork-change-default-label"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -54,7 +56,7 @@ export const ForkSettings = () => {
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div>{localize('com_ui_fork_split_target_setting')}</div>
|
<div id="split-at-target-label">{localize('com_ui_fork_split_target_setting')}</div>
|
||||||
<InfoHoverCard
|
<InfoHoverCard
|
||||||
side={ESide.Bottom}
|
side={ESide.Bottom}
|
||||||
text={localize('com_nav_info_fork_split_target_setting')}
|
text={localize('com_nav_info_fork_split_target_setting')}
|
||||||
|
|
@ -66,6 +68,7 @@ export const ForkSettings = () => {
|
||||||
onCheckedChange={setSplitAtTarget}
|
onCheckedChange={setSplitAtTarget}
|
||||||
className="ml-4"
|
className="ml-4"
|
||||||
data-testid="splitAtTarget"
|
data-testid="splitAtTarget"
|
||||||
|
aria-labelledby="split-at-target-label"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,33 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { InfoHoverCard, ESide } from '@librechat/client';
|
import { InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
import SlashCommandSwitch from './SlashCommandSwitch';
|
|
||||||
import { useLocalize, useHasAccess } from '~/hooks';
|
import { useLocalize, useHasAccess } from '~/hooks';
|
||||||
import PlusCommandSwitch from './PlusCommandSwitch';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
import AtCommandSwitch from './AtCommandSwitch';
|
import store from '~/store';
|
||||||
|
|
||||||
|
const commandSwitchConfigs = [
|
||||||
|
{
|
||||||
|
stateAtom: store.atCommand,
|
||||||
|
localizationKey: 'com_nav_at_command_description' as const,
|
||||||
|
switchId: 'atCommand',
|
||||||
|
key: 'atCommand',
|
||||||
|
permissionType: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stateAtom: store.plusCommand,
|
||||||
|
localizationKey: 'com_nav_plus_command_description' as const,
|
||||||
|
switchId: 'plusCommand',
|
||||||
|
key: 'plusCommand',
|
||||||
|
permissionType: PermissionTypes.MULTI_CONVO,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stateAtom: store.slashCommand,
|
||||||
|
localizationKey: 'com_nav_slash_command_description' as const,
|
||||||
|
switchId: 'slashCommand',
|
||||||
|
key: 'slashCommand',
|
||||||
|
permissionType: PermissionTypes.PROMPTS,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
function Commands() {
|
function Commands() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
@ -19,6 +42,19 @@ function Commands() {
|
||||||
permission: Permissions.USE,
|
permission: Permissions.USE,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getShowSwitch = (permissionType?: PermissionTypes) => {
|
||||||
|
if (!permissionType) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (permissionType === PermissionTypes.MULTI_CONVO) {
|
||||||
|
return hasAccessToMultiConvo === true;
|
||||||
|
}
|
||||||
|
if (permissionType === PermissionTypes.PROMPTS) {
|
||||||
|
return hasAccessToPrompts === true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-1">
|
<div className="space-y-4 p-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -28,19 +64,16 @@ function Commands() {
|
||||||
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
|
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||||
<div className="pb-3">
|
{commandSwitchConfigs.map((config) => (
|
||||||
<AtCommandSwitch />
|
<div key={config.key} className="pb-3">
|
||||||
</div>
|
<ToggleSwitch
|
||||||
{hasAccessToMultiConvo === true && (
|
stateAtom={config.stateAtom}
|
||||||
<div className="pb-3">
|
localizationKey={config.localizationKey}
|
||||||
<PlusCommandSwitch />
|
switchId={config.switchId}
|
||||||
|
showSwitch={getShowSwitch(config.permissionType)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
{hasAccessToPrompts === true && (
|
|
||||||
<div className="pb-3">
|
|
||||||
<SlashCommandSwitch />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -31,12 +31,12 @@ export const ClearChats = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="font-light">{localize('com_nav_clear_all_chats')}</Label>
|
<Label id="clear-all-chats-label">{localize('com_nav_clear_all_chats')}</Label>
|
||||||
<OGDialog open={open} onOpenChange={setOpen}>
|
<OGDialog open={open} onOpenChange={setOpen}>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
aria-labelledby="clear-all-chats-label"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
{localize('com_ui_delete')}
|
{localize('com_ui_delete')}
|
||||||
|
|
@ -47,7 +47,7 @@ export const ClearChats = () => {
|
||||||
title={localize('com_nav_confirm_clear')}
|
title={localize('com_nav_confirm_clear')}
|
||||||
className="max-w-[450px]"
|
className="max-w-[450px]"
|
||||||
main={
|
main={
|
||||||
<Label className="text-left text-sm font-medium">
|
<Label className="break-words">
|
||||||
{localize('com_nav_clear_conversation_confirm_message')}
|
{localize('com_nav_clear_conversation_confirm_message')}
|
||||||
</Label>
|
</Label>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { useOnClickOutside } from '@librechat/client';
|
import { useOnClickOutside } from '@librechat/client';
|
||||||
import ImportConversations from './ImportConversations';
|
import ImportConversations from './ImportConversations';
|
||||||
import { RevokeAllKeys } from './RevokeAllKeys';
|
import { RevokeKeys } from './RevokeKeys';
|
||||||
import { DeleteCache } from './DeleteCache';
|
import { DeleteCache } from './DeleteCache';
|
||||||
import { ClearChats } from './ClearChats';
|
import { ClearChats } from './ClearChats';
|
||||||
import SharedLinks from './SharedLinks';
|
import SharedLinks from './SharedLinks';
|
||||||
|
|
@ -20,7 +20,7 @@ function Data() {
|
||||||
<SharedLinks />
|
<SharedLinks />
|
||||||
</div>
|
</div>
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<RevokeAllKeys />
|
<RevokeKeys />
|
||||||
</div>
|
</div>
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<DeleteCache />
|
<DeleteCache />
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,14 @@ export const DeleteCache = ({ disabled = false }: { disabled?: boolean }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="font-light">{localize('com_nav_delete_cache_storage')}</Label>
|
<Label id="delete-cache-label">{localize('com_nav_delete_cache_storage')}</Label>
|
||||||
<OGDialog open={open} onOpenChange={setOpen}>
|
<OGDialog open={open} onOpenChange={setOpen}>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex items-center justify-center rounded-lg transition-colors duration-200"
|
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
disabled={disabled || isCacheEmpty}
|
disabled={disabled || isCacheEmpty}
|
||||||
|
aria-labelledby="delete-cache-label"
|
||||||
>
|
>
|
||||||
{localize('com_ui_delete')}
|
{localize('com_ui_delete')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,130 @@
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useCallback } from 'react';
|
||||||
import { Import } from 'lucide-react';
|
import { Import } from 'lucide-react';
|
||||||
import { Spinner, useToastContext } from '@librechat/client';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import type { TError } from 'librechat-data-provider';
|
import { QueryKeys, TStartupConfig } from 'librechat-data-provider';
|
||||||
|
import { Spinner, useToastContext, Label, Button } from '@librechat/client';
|
||||||
import { useUploadConversationsMutation } from '~/data-provider';
|
import { useUploadConversationsMutation } from '~/data-provider';
|
||||||
|
import { NotificationSeverity } from '~/common';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn, logger } from '~/utils';
|
||||||
|
|
||||||
function ImportConversations() {
|
function ImportConversations() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const [, setErrors] = useState<string[]>([]);
|
|
||||||
const [allowImport, setAllowImport] = useState(true);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
|
|
||||||
|
const handleSuccess = useCallback(() => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_import_conversation_success'),
|
||||||
|
status: NotificationSeverity.SUCCESS,
|
||||||
|
});
|
||||||
|
setIsUploading(false);
|
||||||
|
}, [localize, showToast]);
|
||||||
|
|
||||||
|
const handleError = useCallback(
|
||||||
|
(error: unknown) => {
|
||||||
|
logger.error('Import error:', error);
|
||||||
|
setIsUploading(false);
|
||||||
|
|
||||||
|
const isUnsupportedType = error?.toString().includes('Unsupported import type');
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
message: localize(
|
||||||
|
isUnsupportedType
|
||||||
|
? 'com_ui_import_conversation_file_type_error'
|
||||||
|
: 'com_ui_import_conversation_error',
|
||||||
|
),
|
||||||
|
status: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[localize, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const uploadFile = useUploadConversationsMutation({
|
const uploadFile = useUploadConversationsMutation({
|
||||||
onSuccess: () => {
|
onSuccess: handleSuccess,
|
||||||
showToast({ message: localize('com_ui_import_conversation_success') });
|
onError: handleError,
|
||||||
setAllowImport(true);
|
onMutate: () => setIsUploading(true),
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error('Error: ', error);
|
|
||||||
setAllowImport(true);
|
|
||||||
setError(
|
|
||||||
(error as TError).response?.data?.message ?? 'An error occurred while uploading the file.',
|
|
||||||
);
|
|
||||||
if (error?.toString().includes('Unsupported import type') === true) {
|
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_import_conversation_file_type_error'),
|
|
||||||
status: 'error',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMutate: () => {
|
|
||||||
setAllowImport(false);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const startUpload = async (file: File) => {
|
const handleFileUpload = useCallback(
|
||||||
const formData = new FormData();
|
async (file: File) => {
|
||||||
formData.append('file', file, encodeURIComponent(file.name || 'File'));
|
try {
|
||||||
|
const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize;
|
||||||
|
if (maxFileSize && file.size > maxFileSize) {
|
||||||
|
const size = (maxFileSize / (1024 * 1024)).toFixed(2);
|
||||||
|
showToast({
|
||||||
|
message: localize('com_error_files_upload_too_large', { 0: size }),
|
||||||
|
status: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
|
setIsUploading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
uploadFile.mutate(formData);
|
const formData = new FormData();
|
||||||
};
|
formData.append('file', file, encodeURIComponent(file.name || 'File'));
|
||||||
|
uploadFile.mutate(formData);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('File processing error:', error);
|
||||||
|
setIsUploading(false);
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_import_conversation_upload_error'),
|
||||||
|
status: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[uploadFile, showToast, localize, startupConfig],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFiles = async (_file: File) => {
|
const handleFileChange = useCallback(
|
||||||
try {
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
await startUpload(_file);
|
const file = event.target.files?.[0];
|
||||||
} catch (error) {
|
if (file) {
|
||||||
console.log('file handling error', error);
|
setIsUploading(true);
|
||||||
setError('An error occurred while processing the file.');
|
handleFileUpload(file);
|
||||||
}
|
}
|
||||||
};
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
[handleFileUpload],
|
||||||
|
);
|
||||||
|
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImportClick = useCallback(() => {
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
handleFiles(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImportClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
const handleKeyDown = useCallback(
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
event.preventDefault();
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
handleImportClick();
|
event.preventDefault();
|
||||||
}
|
handleImportClick();
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[handleImportClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isImportDisabled = isUploading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_ui_import_conversation_info')}</div>
|
<Label id="import-conversation-label">{localize('com_ui_import_conversation_info')}</Label>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={handleImportClick}
|
onClick={handleImportClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
disabled={!allowImport}
|
disabled={isImportDisabled}
|
||||||
aria-label={localize('com_ui_import')}
|
aria-label={localize('com_ui_import')}
|
||||||
className="btn btn-neutral relative"
|
aria-labelledby="import-conversation-label"
|
||||||
>
|
>
|
||||||
{allowImport ? (
|
{isUploading ? (
|
||||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
|
||||||
) : (
|
|
||||||
<Spinner className="mr-1 w-4" />
|
<Spinner className="mr-1 w-4" />
|
||||||
|
) : (
|
||||||
|
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||||
)}
|
)}
|
||||||
<span>{localize('com_ui_import')}</span>
|
<span>{localize('com_ui_import')}</span>
|
||||||
</button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
72
client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
Normal file
72
client/src/components/Nav/SettingsTabs/Data/RevokeKeys.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -286,11 +286,13 @@ export default function SharedLinks() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_shared_links')}</div>
|
<Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
|
||||||
|
|
||||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||||
<Button variant="outline">{localize('com_ui_manage')}</Button>
|
<Button aria-labelledby="shared-links-label" variant="outline">
|
||||||
|
{localize('com_ui_manage')}
|
||||||
|
</Button>
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
|
|
||||||
<OGDialogContent
|
<OGDialogContent
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,11 @@ export const ThemeSelector = ({
|
||||||
{ value: 'light', label: localize('com_nav_theme_light') },
|
{ value: 'light', label: localize('com_nav_theme_light') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const labelId = 'theme-selector-label';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_theme')}</div>
|
<div id={labelId}>{localize('com_nav_theme')}</div>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={theme}
|
value={theme}
|
||||||
|
|
@ -57,6 +59,7 @@ export const ThemeSelector = ({
|
||||||
sizeClasses="w-[180px]"
|
sizeClasses="w-[180px]"
|
||||||
testId="theme-selector"
|
testId="theme-selector"
|
||||||
className="z-50"
|
className="z-50"
|
||||||
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -112,9 +115,11 @@ export const LangSelector = ({
|
||||||
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
|
{ value: 'uk-UA', label: localize('com_nav_lang_ukrainian') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const labelId = 'language-selector-label';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_language')}</div>
|
<div id={labelId}>{localize('com_nav_language')}</div>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={langcode}
|
value={langcode}
|
||||||
|
|
@ -122,6 +127,7 @@ export const LangSelector = ({
|
||||||
sizeClasses="[--anchor-max-height:256px]"
|
sizeClasses="[--anchor-max-height:256px]"
|
||||||
options={languageOptions}
|
options={languageOptions}
|
||||||
className="z-50"
|
className="z-50"
|
||||||
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,13 @@ export default function Personalization({
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div id="reference-saved-memories-label" className="flex items-center gap-2">
|
||||||
{localize('com_ui_reference_saved_memories')}
|
{localize('com_ui_reference_saved_memories')}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-text-secondary">
|
<div
|
||||||
|
id="reference-saved-memories-description"
|
||||||
|
className="mt-1 text-xs text-text-secondary"
|
||||||
|
>
|
||||||
{localize('com_ui_reference_saved_memories_description')}
|
{localize('com_ui_reference_saved_memories_description')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,7 +79,8 @@ export default function Personalization({
|
||||||
checked={referenceSavedMemories}
|
checked={referenceSavedMemories}
|
||||||
onCheckedChange={handleMemoryToggle}
|
onCheckedChange={handleMemoryToggle}
|
||||||
disabled={updateMemoryPreferencesMutation.isLoading}
|
disabled={updateMemoryPreferencesMutation.isLoading}
|
||||||
aria-label={localize('com_ui_reference_saved_memories')}
|
aria-labelledby="reference-saved-memories-label"
|
||||||
|
aria-describedby="reference-saved-memories-description"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Switch } from '@librechat/client';
|
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
import { useLocalize } from '~/hooks';
|
import ToggleSwitch from '../ToggleSwitch';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function ConversationModeSwitch({
|
export default function ConversationModeSwitch({
|
||||||
|
|
@ -8,8 +7,6 @@ export default function ConversationModeSwitch({
|
||||||
}: {
|
}: {
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
|
||||||
const [conversationMode, setConversationMode] = useRecoilState<boolean>(store.conversationMode);
|
|
||||||
const speechToText = useRecoilValue(store.speechToText);
|
const speechToText = useRecoilValue(store.speechToText);
|
||||||
const textToSpeech = useRecoilValue(store.textToSpeech);
|
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||||
const [, setAutoSendText] = useRecoilState(store.autoSendText);
|
const [, setAutoSendText] = useRecoilState(store.autoSendText);
|
||||||
|
|
@ -20,27 +17,19 @@ export default function ConversationModeSwitch({
|
||||||
setAutoTranscribeAudio(value);
|
setAutoTranscribeAudio(value);
|
||||||
setAutoSendText(3);
|
setAutoSendText(3);
|
||||||
setDecibelValue(-45);
|
setDecibelValue(-45);
|
||||||
setConversationMode(value);
|
|
||||||
if (onCheckedChange) {
|
if (onCheckedChange) {
|
||||||
onCheckedChange(value);
|
onCheckedChange(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<ToggleSwitch
|
||||||
<div>
|
stateAtom={store.conversationMode}
|
||||||
<strong>{localize('com_nav_conversation_mode')}</strong>
|
localizationKey={'com_nav_conversation_mode' as const}
|
||||||
</div>
|
switchId="ConversationMode"
|
||||||
<div className="flex items-center justify-between">
|
onCheckedChange={handleCheckedChange}
|
||||||
<Switch
|
disabled={!textToSpeech || !speechToText}
|
||||||
id="ConversationMode"
|
strongLabel={true}
|
||||||
checked={conversationMode}
|
/>
|
||||||
onCheckedChange={handleCheckedChange}
|
|
||||||
className="ml-4"
|
|
||||||
data-testid="ConversationMode"
|
|
||||||
disabled={!textToSpeech || !speechToText}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
import { Slider, InputNumber } from '@librechat/client';
|
import { Slider, InputNumber, Switch } from '@librechat/client';
|
||||||
import { cn, defaultTextProps, optionText } from '~/utils/';
|
import { cn, defaultTextProps, optionText } from '~/utils/';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
@ -11,40 +11,104 @@ export default function AutoSendTextSelector() {
|
||||||
const speechToText = useRecoilValue(store.speechToText);
|
const speechToText = useRecoilValue(store.speechToText);
|
||||||
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText);
|
const [autoSendText, setAutoSendText] = useRecoilState(store.autoSendText);
|
||||||
|
|
||||||
|
// Local state for enabled/disabled toggle
|
||||||
|
const [isEnabled, setIsEnabled] = useState(autoSendText !== -1);
|
||||||
|
const [delayValue, setDelayValue] = useState(autoSendText === -1 ? 3 : autoSendText);
|
||||||
|
|
||||||
|
// Sync local state when autoSendText changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEnabled(autoSendText !== -1);
|
||||||
|
if (autoSendText !== -1) {
|
||||||
|
setDelayValue(autoSendText);
|
||||||
|
}
|
||||||
|
}, [autoSendText]);
|
||||||
|
|
||||||
|
const handleToggle = (enabled: boolean) => {
|
||||||
|
setIsEnabled(enabled);
|
||||||
|
if (enabled) {
|
||||||
|
setAutoSendText(delayValue);
|
||||||
|
} else {
|
||||||
|
setAutoSendText(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSliderChange = (value: number[]) => {
|
||||||
|
const newValue = value[0];
|
||||||
|
setDelayValue(newValue);
|
||||||
|
if (isEnabled) {
|
||||||
|
setAutoSendText(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (value: number[] | null) => {
|
||||||
|
const newValue = value ? value[0] : 3;
|
||||||
|
setDelayValue(newValue);
|
||||||
|
if (isEnabled) {
|
||||||
|
setAutoSendText(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelId = 'auto-send-text-label';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_auto_send_text')}</div>
|
<div className="flex items-center space-x-2">
|
||||||
<div className="w-2" />
|
<div id={labelId}>{localize('com_nav_auto_send_text')}</div>
|
||||||
<small className="opacity-40">({localize('com_nav_auto_send_text_disabled')})</small>
|
</div>
|
||||||
</div>
|
<Switch
|
||||||
<div className="flex items-center justify-between">
|
id="autoSendTextToggle"
|
||||||
<Slider
|
checked={isEnabled}
|
||||||
value={[autoSendText ?? -1]}
|
onCheckedChange={handleToggle}
|
||||||
onValueChange={(value) => setAutoSendText(value[0])}
|
className="ml-4"
|
||||||
onDoubleClick={() => setAutoSendText(-1)}
|
data-testid="autoSendTextToggle"
|
||||||
min={-1}
|
aria-labelledby={labelId}
|
||||||
max={60}
|
|
||||||
step={1}
|
|
||||||
className="ml-4 flex h-4 w-24"
|
|
||||||
disabled={!speechToText}
|
disabled={!speechToText}
|
||||||
/>
|
/>
|
||||||
<div className="w-2" />
|
|
||||||
<InputNumber
|
|
||||||
value={`${autoSendText} s`}
|
|
||||||
disabled={!speechToText}
|
|
||||||
onChange={(value) => setAutoSendText(value ? value[0] : 0)}
|
|
||||||
min={-1}
|
|
||||||
max={60}
|
|
||||||
className={cn(
|
|
||||||
defaultTextProps,
|
|
||||||
cn(
|
|
||||||
optionText,
|
|
||||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{isEnabled && (
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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={[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 || !isEnabled}
|
||||||
|
aria-labelledby="auto-send-delay-label"
|
||||||
|
/>
|
||||||
|
<div className="w-2" />
|
||||||
|
<InputNumber
|
||||||
|
value={`${delayValue} s`}
|
||||||
|
disabled={!speechToText || !isEnabled}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
min={0}
|
||||||
|
max={60}
|
||||||
|
aria-labelledby="auto-send-delay-label"
|
||||||
|
className={cn(
|
||||||
|
defaultTextProps,
|
||||||
|
cn(
|
||||||
|
optionText,
|
||||||
|
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { Switch } from '@librechat/client';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import ToggleSwitch from '../../ToggleSwitch';
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function AutoTranscribeAudioSwitch({
|
export default function AutoTranscribeAudioSwitch({
|
||||||
|
|
@ -8,30 +7,15 @@ export default function AutoTranscribeAudioSwitch({
|
||||||
}: {
|
}: {
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
|
||||||
const [autoTranscribeAudio, setAutoTranscribeAudio] = useRecoilState<boolean>(
|
|
||||||
store.autoTranscribeAudio,
|
|
||||||
);
|
|
||||||
const speechToText = useRecoilValue(store.speechToText);
|
const speechToText = useRecoilValue(store.speechToText);
|
||||||
|
|
||||||
const handleCheckedChange = (value: boolean) => {
|
|
||||||
setAutoTranscribeAudio(value);
|
|
||||||
if (onCheckedChange) {
|
|
||||||
onCheckedChange(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<ToggleSwitch
|
||||||
<div>{localize('com_nav_auto_transcribe_audio')}</div>
|
stateAtom={store.autoTranscribeAudio}
|
||||||
<Switch
|
localizationKey={'com_nav_auto_transcribe_audio' as const}
|
||||||
id="AutoTranscribeAudio"
|
switchId="AutoTranscribeAudio"
|
||||||
checked={autoTranscribeAudio}
|
onCheckedChange={onCheckedChange}
|
||||||
onCheckedChange={handleCheckedChange}
|
disabled={!speechToText}
|
||||||
className="ml-4"
|
/>
|
||||||
data-testid="AutoTranscribeAudio"
|
|
||||||
disabled={!speechToText}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default function DecibelSelector() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_db_sensitivity')}</div>
|
<div id="decibel-selector-label">{localize('com_nav_db_sensitivity')}</div>
|
||||||
<div className="w-2" />
|
<div className="w-2" />
|
||||||
<small className="opacity-40">
|
<small className="opacity-40">
|
||||||
({localize('com_endpoint_default_with_num', { 0: '-45' })})
|
({localize('com_endpoint_default_with_num', { 0: '-45' })})
|
||||||
|
|
@ -29,6 +29,7 @@ export default function DecibelSelector() {
|
||||||
step={1}
|
step={1}
|
||||||
className="ml-4 flex h-4 w-24"
|
className="ml-4 flex h-4 w-24"
|
||||||
disabled={!speechToText}
|
disabled={!speechToText}
|
||||||
|
aria-labelledby="decibel-selector-label"
|
||||||
/>
|
/>
|
||||||
<div className="w-2" />
|
<div className="w-2" />
|
||||||
<InputNumber
|
<InputNumber
|
||||||
|
|
@ -37,6 +38,7 @@ export default function DecibelSelector() {
|
||||||
onChange={(value) => setDecibelValue(value ? value[0] : 0)}
|
onChange={(value) => setDecibelValue(value ? value[0] : 0)}
|
||||||
min={-100}
|
min={-100}
|
||||||
max={-30}
|
max={-30}
|
||||||
|
aria-labelledby="decibel-selector-label"
|
||||||
className={cn(
|
className={cn(
|
||||||
defaultTextProps,
|
defaultTextProps,
|
||||||
cn(
|
cn(
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
||||||
setEngineSTT(value);
|
setEngineSTT(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const labelId = 'engine-stt-dropdown-label';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_engine')}</div>
|
<div id={labelId}>{localize('com_nav_engine')}</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={engineSTT}
|
value={engineSTT}
|
||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
|
|
@ -33,6 +35,7 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
||||||
sizeClasses="w-[180px]"
|
sizeClasses="w-[180px]"
|
||||||
testId="EngineSTTDropdown"
|
testId="EngineSTTDropdown"
|
||||||
className="z-50"
|
className="z-50"
|
||||||
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -94,9 +94,11 @@ export default function LanguageSTTDropdown() {
|
||||||
setLanguageSTT(value);
|
setLanguageSTT(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const labelId = 'language-stt-dropdown-label';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_language')}</div>
|
<div id={labelId}>{localize('com_nav_language')}</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={languageSTT}
|
value={languageSTT}
|
||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
|
|
@ -104,6 +106,7 @@ export default function LanguageSTTDropdown() {
|
||||||
sizeClasses="[--anchor-max-height:256px]"
|
sizeClasses="[--anchor-max-height:256px]"
|
||||||
testId="LanguageSTTDropdown"
|
testId="LanguageSTTDropdown"
|
||||||
className="z-50"
|
className="z-50"
|
||||||
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import ToggleSwitch from '../../ToggleSwitch';
|
||||||
import { Switch } from '@librechat/client';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function SpeechToTextSwitch({
|
export default function SpeechToTextSwitch({
|
||||||
|
|
@ -8,28 +6,13 @@ export default function SpeechToTextSwitch({
|
||||||
}: {
|
}: {
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
|
||||||
const [speechToText, setSpeechToText] = useRecoilState<boolean>(store.speechToText);
|
|
||||||
|
|
||||||
const handleCheckedChange = (value: boolean) => {
|
|
||||||
setSpeechToText(value);
|
|
||||||
if (onCheckedChange) {
|
|
||||||
onCheckedChange(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<ToggleSwitch
|
||||||
<div>
|
stateAtom={store.speechToText}
|
||||||
<strong>{localize('com_nav_speech_to_text')}</strong>
|
localizationKey={'com_nav_speech_to_text' as const}
|
||||||
</div>
|
switchId="SpeechToText"
|
||||||
<Switch
|
onCheckedChange={onCheckedChange}
|
||||||
id="SpeechToText"
|
strongLabel={true}
|
||||||
checked={speechToText}
|
/>
|
||||||
onCheckedChange={handleCheckedChange}
|
|
||||||
className="ml-4"
|
|
||||||
data-testid="SpeechToText"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
} from './STT';
|
} from './STT';
|
||||||
import ConversationModeSwitch from './ConversationModeSwitch';
|
import ConversationModeSwitch from './ConversationModeSwitch';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn, logger } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
function Speech() {
|
function Speech() {
|
||||||
|
|
@ -186,7 +186,7 @@ function Speech() {
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs.Content value={'simple'}>
|
<Tabs.Content value={'simple'} tabIndex={-1}>
|
||||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||||
<SpeechToTextSwitch />
|
<SpeechToTextSwitch />
|
||||||
<EngineSTTDropdown external={sttExternal} />
|
<EngineSTTDropdown external={sttExternal} />
|
||||||
|
|
@ -198,7 +198,7 @@ function Speech() {
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value={'advanced'}>
|
<Tabs.Content value={'advanced'} tabIndex={-1}>
|
||||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||||
<ConversationModeSwitch />
|
<ConversationModeSwitch />
|
||||||
<div className="mt-2 h-px bg-border-medium" role="none" />
|
<div className="mt-2 h-px bg-border-medium" role="none" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import ToggleSwitch from '../../ToggleSwitch';
|
||||||
import { Switch } from '@librechat/client';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function AutomaticPlaybackSwitch({
|
export default function AutomaticPlaybackSwitch({
|
||||||
|
|
@ -8,26 +6,12 @@ export default function AutomaticPlaybackSwitch({
|
||||||
}: {
|
}: {
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
|
||||||
const [automaticPlayback, setAutomaticPlayback] = useRecoilState(store.automaticPlayback);
|
|
||||||
|
|
||||||
const handleCheckedChange = (value: boolean) => {
|
|
||||||
setAutomaticPlayback(value);
|
|
||||||
if (onCheckedChange) {
|
|
||||||
onCheckedChange(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<ToggleSwitch
|
||||||
<div>{localize('com_nav_automatic_playback')}</div>
|
stateAtom={store.automaticPlayback}
|
||||||
<Switch
|
localizationKey={'com_nav_automatic_playback' as const}
|
||||||
id="AutomaticPlayback"
|
switchId="AutomaticPlayback"
|
||||||
checked={automaticPlayback}
|
onCheckedChange={onCheckedChange}
|
||||||
onCheckedChange={handleCheckedChange}
|
/>
|
||||||
className="ml-4"
|
|
||||||
data-testid="AutomaticPlayback"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { Switch } from '@librechat/client';
|
import ToggleSwitch from '../../ToggleSwitch';
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function CacheTTSSwitch({
|
export default function CacheTTSSwitch({
|
||||||
|
|
@ -8,28 +7,15 @@ export default function CacheTTSSwitch({
|
||||||
}: {
|
}: {
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||||
const [cacheTTS, setCacheTTS] = useRecoilState<boolean>(store.cacheTTS);
|
|
||||||
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
|
|
||||||
|
|
||||||
const handleCheckedChange = (value: boolean) => {
|
|
||||||
setCacheTTS(value);
|
|
||||||
if (onCheckedChange) {
|
|
||||||
onCheckedChange(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<ToggleSwitch
|
||||||
<div>{localize('com_nav_enable_cache_tts')}</div>
|
stateAtom={store.cacheTTS}
|
||||||
<Switch
|
localizationKey={'com_nav_enable_cache_tts' as const}
|
||||||
id="CacheTTS"
|
switchId="CacheTTS"
|
||||||
checked={cacheTTS}
|
onCheckedChange={onCheckedChange}
|
||||||
onCheckedChange={handleCheckedChange}
|
disabled={!textToSpeech}
|
||||||
className="ml-4"
|
/>
|
||||||
data-testid="CacheTTS"
|
|
||||||
disabled={!textToSpeech}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { Switch } from '@librechat/client';
|
import ToggleSwitch from '../../ToggleSwitch';
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function CloudBrowserVoicesSwitch({
|
export default function CloudBrowserVoicesSwitch({
|
||||||
|
|
@ -8,30 +7,15 @@ export default function CloudBrowserVoicesSwitch({
|
||||||
}: {
|
}: {
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const textToSpeech = useRecoilValue(store.textToSpeech);
|
||||||
const [cloudBrowserVoices, setCloudBrowserVoices] = useRecoilState<boolean>(
|
|
||||||
store.cloudBrowserVoices,
|
|
||||||
);
|
|
||||||
const [textToSpeech] = useRecoilState<boolean>(store.textToSpeech);
|
|
||||||
|
|
||||||
const handleCheckedChange = (value: boolean) => {
|
|
||||||
setCloudBrowserVoices(value);
|
|
||||||
if (onCheckedChange) {
|
|
||||||
onCheckedChange(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<ToggleSwitch
|
||||||
<div>{localize('com_nav_enable_cloud_browser_voice')}</div>
|
stateAtom={store.cloudBrowserVoices}
|
||||||
<Switch
|
localizationKey={'com_nav_enable_cloud_browser_voice' as const}
|
||||||
id="CloudBrowserVoices"
|
switchId="CloudBrowserVoices"
|
||||||
checked={cloudBrowserVoices}
|
onCheckedChange={onCheckedChange}
|
||||||
onCheckedChange={handleCheckedChange}
|
disabled={!textToSpeech}
|
||||||
className="ml-4"
|
/>
|
||||||
data-testid="CloudBrowserVoices"
|
|
||||||
disabled={!textToSpeech}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,11 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
||||||
setEngineTTS(value);
|
setEngineTTS(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const labelId = 'engine-tts-dropdown-label';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_engine')}</div>
|
<div id={labelId}>{localize('com_nav_engine')}</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={engineTTS}
|
value={engineTTS}
|
||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
|
|
@ -33,6 +35,7 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
||||||
sizeClasses="w-[180px]"
|
sizeClasses="w-[180px]"
|
||||||
testId="EngineTTSDropdown"
|
testId="EngineTTSDropdown"
|
||||||
className="z-50"
|
className="z-50"
|
||||||
|
aria-labelledby={labelId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default function DecibelSelector() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_playback_rate')}</div>
|
<div id="playback-rate-label">{localize('com_nav_playback_rate')}</div>
|
||||||
<div className="w-2" />
|
<div className="w-2" />
|
||||||
<small className="opacity-40">
|
<small className="opacity-40">
|
||||||
({localize('com_endpoint_default_with_num', { 0: '1' })})
|
({localize('com_endpoint_default_with_num', { 0: '1' })})
|
||||||
|
|
@ -29,6 +29,7 @@ export default function DecibelSelector() {
|
||||||
step={0.1}
|
step={0.1}
|
||||||
className="ml-4 flex h-4 w-24"
|
className="ml-4 flex h-4 w-24"
|
||||||
disabled={!textToSpeech}
|
disabled={!textToSpeech}
|
||||||
|
aria-labelledby="playback-rate-label"
|
||||||
/>
|
/>
|
||||||
<div className="w-2" />
|
<div className="w-2" />
|
||||||
<InputNumber
|
<InputNumber
|
||||||
|
|
@ -37,6 +38,7 @@ export default function DecibelSelector() {
|
||||||
onChange={(value) => setPlaybackRate(value ? value[0] : 0)}
|
onChange={(value) => setPlaybackRate(value ? value[0] : 0)}
|
||||||
min={0.1}
|
min={0.1}
|
||||||
max={2}
|
max={2}
|
||||||
|
aria-labelledby="playback-rate-label"
|
||||||
className={cn(
|
className={cn(
|
||||||
defaultTextProps,
|
defaultTextProps,
|
||||||
cn(
|
cn(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import ToggleSwitch from '../../ToggleSwitch';
|
||||||
import { Switch } from '@librechat/client';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function TextToSpeechSwitch({
|
export default function TextToSpeechSwitch({
|
||||||
|
|
@ -8,28 +6,13 @@ export default function TextToSpeechSwitch({
|
||||||
}: {
|
}: {
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
|
||||||
const [TextToSpeech, setTextToSpeech] = useRecoilState<boolean>(store.textToSpeech);
|
|
||||||
|
|
||||||
const handleCheckedChange = (value: boolean) => {
|
|
||||||
setTextToSpeech(value);
|
|
||||||
if (onCheckedChange) {
|
|
||||||
onCheckedChange(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<ToggleSwitch
|
||||||
<div>
|
stateAtom={store.textToSpeech}
|
||||||
<strong>{localize('com_nav_text_to_speech')}</strong>
|
localizationKey={'com_nav_text_to_speech' as const}
|
||||||
</div>
|
switchId="TextToSpeech"
|
||||||
<Switch
|
onCheckedChange={onCheckedChange}
|
||||||
id="TextToSpeech"
|
strongLabel={true}
|
||||||
checked={TextToSpeech}
|
/>
|
||||||
onCheckedChange={handleCheckedChange}
|
|
||||||
className="ml-4"
|
|
||||||
data-testid="TextToSpeech"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ interface ToggleSwitchProps {
|
||||||
hoverCardText?: LocalizeKey;
|
hoverCardText?: LocalizeKey;
|
||||||
switchId: string;
|
switchId: string;
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
|
showSwitch?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
strongLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||||
|
|
@ -19,6 +22,9 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||||
hoverCardText,
|
hoverCardText,
|
||||||
switchId,
|
switchId,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
|
showSwitch = true,
|
||||||
|
disabled = false,
|
||||||
|
strongLabel = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [switchState, setSwitchState] = useRecoilState(stateAtom);
|
const [switchState, setSwitchState] = useRecoilState(stateAtom);
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
@ -28,10 +34,18 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||||
onCheckedChange?.(value);
|
onCheckedChange?.(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const labelId = `${switchId}-label`;
|
||||||
|
|
||||||
|
if (!showSwitch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div>{localize(localizationKey)}</div>
|
<div id={labelId}>
|
||||||
|
{strongLabel ? <strong>{localize(localizationKey)}</strong> : localize(localizationKey)}
|
||||||
|
</div>
|
||||||
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -40,6 +54,8 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||||
onCheckedChange={handleCheckedChange}
|
onCheckedChange={handleCheckedChange}
|
||||||
className="ml-4"
|
className="ml-4"
|
||||||
data-testid={switchId}
|
data-testid={switchId}
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
export { default as General } from './General/General';
|
|
||||||
export { default as Chat } from './Chat/Chat';
|
export { default as Chat } from './Chat/Chat';
|
||||||
export { default as Data } from './Data/Data';
|
export { default as Data } from './Data/Data';
|
||||||
export { default as Commands } from './Commands/Commands';
|
|
||||||
export { RevokeKeysButton } from './Data/RevokeKeysButton';
|
|
||||||
export { default as Account } from './Account/Account';
|
|
||||||
export { default as Balance } from './Balance/Balance';
|
|
||||||
export { default as Speech } from './Speech/Speech';
|
export { default as Speech } from './Speech/Speech';
|
||||||
|
export { default as Balance } from './Balance/Balance';
|
||||||
|
export { default as General } from './General/General';
|
||||||
|
export { default as Account } from './Account/Account';
|
||||||
|
export { default as Commands } from './Commands/Commands';
|
||||||
export { default as Personalization } from './Personalization';
|
export { default as Personalization } from './Personalization';
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,12 @@ function ChatGroupItem({
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
id={`prompt-actions-${group._id}`}
|
id={`prompt-actions-${group._id}`}
|
||||||
aria-label={`${group.name} - Actions Menu`}
|
type="button"
|
||||||
aria-expanded="false"
|
aria-label={
|
||||||
aria-controls={`prompt-menu-${group._id}`}
|
localize('com_ui_sr_actions_menu', { 0: group.name }) +
|
||||||
aria-haspopup="menu"
|
' ' +
|
||||||
|
localize('com_ui_prompt')
|
||||||
|
}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
|
|
@ -86,11 +88,6 @@ function ChatGroupItem({
|
||||||
className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
|
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
|
||||||
<span className="sr-only">
|
|
||||||
{localize('com_ui_sr_actions_menu', { 0: group.name }) +
|
|
||||||
' ' +
|
|
||||||
localize('com_ui_prompt')}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
|
|
@ -98,30 +95,29 @@ function ChatGroupItem({
|
||||||
aria-label={`Available actions for ${group.name}`}
|
aria-label={`Available actions for ${group.name}`}
|
||||||
className="z-50 w-fit rounded-xl"
|
className="z-50 w-fit rounded-xl"
|
||||||
collisionPadding={2}
|
collisionPadding={2}
|
||||||
align="end"
|
align="start"
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
role="menuitem"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setPreviewDialogOpen(true);
|
setPreviewDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
className="w-full cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<TextSearch className="mr-2 h-4 w-4" aria-hidden="true" />
|
<TextSearch className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
|
||||||
<span>{localize('com_ui_preview')}</span>
|
<span>{localize('com_ui_preview')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
className="cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
className="cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEditClick(e);
|
onEditClick(e);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EditIcon className="mr-2 h-4 w-4" aria-hidden="true" />
|
<EditIcon className="mr-2 h-4 w-4 text-text-primary" aria-hidden="true" />
|
||||||
<span>{localize('com_ui_edit')}</span>
|
<span>{localize('com_ui_edit')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`${group.name} prompt group`}
|
aria-label={`${group.name} Prompt, ${localize('com_ui_category')}: ${group.category ?? ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<div className="flex items-center gap-2 truncate pr-2">
|
<div className="flex items-center gap-2 truncate pr-2">
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export default function FilterPrompts({ className = '' }: { className?: string }
|
||||||
value={categoryFilter || SystemCategories.ALL}
|
value={categoryFilter || SystemCategories.ALL}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
options={filterOptions}
|
options={filterOptions}
|
||||||
className="bg-transparent"
|
className="rounded-lg bg-transparent"
|
||||||
icon={<ListFilter className="h-4 w-4" />}
|
icon={<ListFilter className="h-4 w-4" />}
|
||||||
label="Filter: "
|
label="Filter: "
|
||||||
ariaLabel={localize('com_ui_filter_prompts')}
|
ariaLabel={localize('com_ui_filter_prompts')}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export default function List({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-grow overflow-y-auto">
|
<div className="flex-grow overflow-y-auto" aria-label={localize('com_ui_prompt_groups')}>
|
||||||
<div className="overflow-y-auto overflow-x-hidden">
|
<div className="overflow-y-auto overflow-x-hidden">
|
||||||
{isLoading && isChatRoute && (
|
{isLoading && isChatRoute && (
|
||||||
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
|
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const PreviewPrompt = ({
|
||||||
return (
|
return (
|
||||||
<OGDialog open={open} onOpenChange={onOpenChange}>
|
<OGDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]">
|
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]">
|
||||||
<div className="p-2">
|
<div>
|
||||||
<PromptDetails group={group} />
|
<PromptDetails group={group} />
|
||||||
</div>
|
</div>
|
||||||
</OGDialogContent>
|
</OGDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -168,18 +168,26 @@ export const useArchiveConvoMutation = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateSharedLinkMutation = (
|
export const useCreateSharedLinkMutation = (
|
||||||
options?: t.MutationOptions<t.TCreateShareLinkRequest, { conversationId: string }>,
|
options?: t.MutationOptions<
|
||||||
): UseMutationResult<t.TSharedLinkResponse, unknown, { conversationId: string }, unknown> => {
|
t.TCreateShareLinkRequest,
|
||||||
|
{ conversationId: string; targetMessageId?: string }
|
||||||
|
>,
|
||||||
|
): UseMutationResult<
|
||||||
|
t.TSharedLinkResponse,
|
||||||
|
unknown,
|
||||||
|
{ conversationId: string; targetMessageId?: string },
|
||||||
|
unknown
|
||||||
|
> => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { onSuccess, ..._options } = options || {};
|
const { onSuccess, ..._options } = options || {};
|
||||||
return useMutation(
|
return useMutation(
|
||||||
({ conversationId }: { conversationId: string }) => {
|
({ conversationId, targetMessageId }: { conversationId: string; targetMessageId?: string }) => {
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
throw new Error('Conversation ID is required');
|
throw new Error('Conversation ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
return dataService.createSharedLink(conversationId);
|
return dataService.createSharedLink(conversationId, targetMessageId);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
|
onSuccess: (_data: t.TSharedLinkResponse, vars, context) => {
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,10 @@ export default function useAgentToolPermissions(
|
||||||
[agentData?.tools, selectedAgent?.tools],
|
[agentData?.tools, selectedAgent?.tools],
|
||||||
);
|
);
|
||||||
|
|
||||||
const provider = useMemo(() => selectedAgent?.provider, [selectedAgent?.provider]);
|
const provider = useMemo(
|
||||||
|
() => agentData?.provider || selectedAgent?.provider,
|
||||||
|
[agentData?.provider, selectedAgent?.provider],
|
||||||
|
);
|
||||||
|
|
||||||
const fileSearchAllowedByAgent = useMemo(() => {
|
const fileSearchAllowedByAgent = useMemo(() => {
|
||||||
// Check ephemeral agent settings
|
// Check ephemeral agent settings
|
||||||
|
|
|
||||||
219
client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts
Normal file
219
client/src/hooks/Artifacts/__tests__/useArtifactProps.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,11 +3,19 @@ import { removeNullishValues } from 'librechat-data-provider';
|
||||||
import type { Artifact } from '~/common';
|
import type { Artifact } from '~/common';
|
||||||
import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts';
|
import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts';
|
||||||
import { getMermaidFiles } from '~/utils/mermaid';
|
import { getMermaidFiles } from '~/utils/mermaid';
|
||||||
|
import { getMarkdownFiles } from '~/utils/markdown';
|
||||||
|
|
||||||
export default function useArtifactProps({ artifact }: { artifact: Artifact }) {
|
export default function useArtifactProps({ artifact }: { artifact: Artifact }) {
|
||||||
const [fileKey, files] = useMemo(() => {
|
const [fileKey, files] = useMemo(() => {
|
||||||
if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) {
|
const key = getKey(artifact.type ?? '', artifact.language);
|
||||||
return ['App.tsx', getMermaidFiles(artifact.content ?? '')];
|
const type = artifact.type ?? '';
|
||||||
|
|
||||||
|
if (key.includes('mermaid')) {
|
||||||
|
return ['diagram.mmd', getMermaidFiles(artifact.content ?? '')];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'text/markdown' || type === 'text/md' || type === 'text/plain') {
|
||||||
|
return ['content.md', getMarkdownFiles(artifact.content ?? '')];
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language);
|
const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language);
|
||||||
|
|
|
||||||
|
|
@ -122,17 +122,8 @@ export default function useArtifacts() {
|
||||||
setCurrentArtifactId(orderedArtifactIds[newIndex]);
|
setCurrentArtifactId(orderedArtifactIds[newIndex]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMermaid = useMemo(() => {
|
|
||||||
if (currentArtifact?.type == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const key = getKey(currentArtifact.type, currentArtifact.language);
|
|
||||||
return key.includes('mermaid');
|
|
||||||
}, [currentArtifact?.type, currentArtifact?.language]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTab,
|
activeTab,
|
||||||
isMermaid,
|
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
cycleArtifact,
|
cycleArtifact,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ const useNavigateToConvo = (index = 0) => {
|
||||||
hasSetConversation.current = true;
|
hasSetConversation.current = true;
|
||||||
setSubmission(null);
|
setSubmission(null);
|
||||||
if (resetLatestMessage) {
|
if (resetLatestMessage) {
|
||||||
|
logger.log('latest_message', 'Clearing all latest messages');
|
||||||
clearAllLatestMessages();
|
clearAllLatestMessages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
||||||
import type { TMessageProps } from '~/common';
|
import type { TMessageProps } from '~/common';
|
||||||
import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
|
import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
|
||||||
|
import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils';
|
||||||
import useCopyToClipboard from './useCopyToClipboard';
|
import useCopyToClipboard from './useCopyToClipboard';
|
||||||
import { getTextKey, logger } from '~/utils';
|
|
||||||
|
|
||||||
export default function useMessageHelpers(props: TMessageProps) {
|
export default function useMessageHelpers(props: TMessageProps) {
|
||||||
const latestText = useRef<string | number>('');
|
const latestText = useRef<string | number>('');
|
||||||
|
|
@ -49,15 +49,27 @@ export default function useMessageHelpers(props: TMessageProps) {
|
||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
convoId,
|
convoId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Extracted convoId from previous textKey (format: messageId|||length|||lastChars|||convoId) */
|
||||||
|
let previousConvoId: string | null = null;
|
||||||
|
if (
|
||||||
|
latestText.current &&
|
||||||
|
typeof latestText.current === 'string' &&
|
||||||
|
latestText.current.length > 0
|
||||||
|
) {
|
||||||
|
const parts = latestText.current.split(TEXT_KEY_DIVIDER);
|
||||||
|
previousConvoId = parts[parts.length - 1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
textKey !== latestText.current ||
|
textKey !== latestText.current ||
|
||||||
(latestText.current && convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2])
|
(convoId != null && previousConvoId != null && convoId !== previousConvoId)
|
||||||
) {
|
) {
|
||||||
logger.log('[useMessageHelpers] Setting latest message: ', logInfo);
|
logger.log('latest_message', '[useMessageHelpers] Setting latest message: ', logInfo);
|
||||||
latestText.current = textKey;
|
latestText.current = textKey;
|
||||||
setLatestMessage({ ...message });
|
setLatestMessage({ ...message });
|
||||||
} else {
|
} else {
|
||||||
logger.log('No change in latest message', logInfo);
|
logger.log('latest_message', 'No change in latest message', logInfo);
|
||||||
}
|
}
|
||||||
}, [isLast, message, setLatestMessage, conversation?.conversationId]);
|
}, [isLast, message, setLatestMessage, conversation?.conversationId]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { useRecoilValue } from 'recoil';
|
||||||
import { Constants } from 'librechat-data-provider';
|
import { Constants } from 'librechat-data-provider';
|
||||||
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
|
import { getTextKey, TEXT_KEY_DIVIDER, logger } from '~/utils';
|
||||||
import { useMessagesViewContext } from '~/Providers';
|
import { useMessagesViewContext } from '~/Providers';
|
||||||
import { getTextKey, logger } from '~/utils';
|
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function useMessageProcess({ message }: { message?: TMessage | null }) {
|
export default function useMessageProcess({ message }: { message?: TMessage | null }) {
|
||||||
|
|
@ -43,11 +43,21 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
|
||||||
messageId: message.messageId,
|
messageId: message.messageId,
|
||||||
convoId,
|
convoId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Extracted convoId from previous textKey (format: messageId|||length|||lastChars|||convoId) */
|
||||||
|
let previousConvoId: string | null = null;
|
||||||
|
if (
|
||||||
|
latestText.current &&
|
||||||
|
typeof latestText.current === 'string' &&
|
||||||
|
latestText.current.length > 0
|
||||||
|
) {
|
||||||
|
const parts = latestText.current.split(TEXT_KEY_DIVIDER);
|
||||||
|
previousConvoId = parts[parts.length - 1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
textKey !== latestText.current ||
|
textKey !== latestText.current ||
|
||||||
(convoId != null &&
|
(convoId != null && previousConvoId != null && convoId !== previousConvoId)
|
||||||
latestText.current &&
|
|
||||||
convoId !== latestText.current.split(Constants.COMMON_DIVIDER)[2])
|
|
||||||
) {
|
) {
|
||||||
logger.log('latest_message', '[useMessageProcess] Setting latest message; logInfo:', logInfo);
|
logger.log('latest_message', '[useMessageProcess] Setting latest message; logInfo:', logInfo);
|
||||||
latestText.current = textKey;
|
latestText.current = textKey;
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,7 @@ export default function useEventHandlers({
|
||||||
|
|
||||||
setShowStopButton(true);
|
setShowStopButton(true);
|
||||||
if (resetLatestMessage) {
|
if (resetLatestMessage) {
|
||||||
|
logger.log('latest_message', 'syncHandler: resetting latest message');
|
||||||
resetLatestMessage();
|
resetLatestMessage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -418,6 +419,7 @@ export default function useEventHandlers({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resetLatestMessage) {
|
if (resetLatestMessage) {
|
||||||
|
logger.log('latest_message', 'createdHandler: resetting latest message');
|
||||||
resetLatestMessage();
|
resetLatestMessage();
|
||||||
}
|
}
|
||||||
scrollToEnd(() => setAbortScroll(false));
|
scrollToEnd(() => setAbortScroll(false));
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ const useNewConvo = (index = 0) => {
|
||||||
}
|
}
|
||||||
setSubmission({} as TSubmission);
|
setSubmission({} as TSubmission);
|
||||||
if (!(keepLatestMessage ?? false)) {
|
if (!(keepLatestMessage ?? false)) {
|
||||||
|
logger.log('latest_message', 'Clearing all latest messages');
|
||||||
clearAllLatestMessages();
|
clearAllLatestMessages();
|
||||||
}
|
}
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,6 @@
|
||||||
"com_error_files_dupe": "تم اكتشاف ملف مكرر.",
|
"com_error_files_dupe": "تم اكتشاف ملف مكرر.",
|
||||||
"com_error_files_empty": "الملفات الفارغة غير مسموح بها",
|
"com_error_files_empty": "الملفات الفارغة غير مسموح بها",
|
||||||
"com_error_files_process": "حدث خطأ أثناء معالجة الملف.",
|
"com_error_files_process": "حدث خطأ أثناء معالجة الملف.",
|
||||||
"com_error_files_unsupported_capability": "لا توجد قدرات مفعّلة تدعم هذا النوع من الملفات.",
|
|
||||||
"com_error_files_upload": "حدث خطأ أثناء رفع الملف.",
|
"com_error_files_upload": "حدث خطأ أثناء رفع الملف.",
|
||||||
"com_error_files_upload_canceled": "تم إلغاء طلب تحميل الملف. ملاحظة: قد تكون عملية تحميل الملف لا تزال قيد المعالجة وستحتاج إلى حذفها يدويًا.",
|
"com_error_files_upload_canceled": "تم إلغاء طلب تحميل الملف. ملاحظة: قد تكون عملية تحميل الملف لا تزال قيد المعالجة وستحتاج إلى حذفها يدويًا.",
|
||||||
"com_error_files_validation": "حدث خطأ أثناء التحقق من صحة الملف.",
|
"com_error_files_validation": "حدث خطأ أثناء التحقق من صحة الملف.",
|
||||||
|
|
@ -269,7 +268,6 @@
|
||||||
"com_nav_auto_scroll": "التمرير التلقائي إلى أحدث عند الفتح",
|
"com_nav_auto_scroll": "التمرير التلقائي إلى أحدث عند الفتح",
|
||||||
"com_nav_auto_send_prompts": "إرسال تلقائي للموجهات",
|
"com_nav_auto_send_prompts": "إرسال تلقائي للموجهات",
|
||||||
"com_nav_auto_send_text": "إرسال النص تلقائيًا",
|
"com_nav_auto_send_text": "إرسال النص تلقائيًا",
|
||||||
"com_nav_auto_send_text_disabled": "اضبط القيمة على -1 للتعطيل",
|
|
||||||
"com_nav_auto_transcribe_audio": "النسخ التلقائي للصوت",
|
"com_nav_auto_transcribe_audio": "النسخ التلقائي للصوت",
|
||||||
"com_nav_automatic_playback": "تشغيل تلقائي لآخر رسالة",
|
"com_nav_automatic_playback": "تشغيل تلقائي لآخر رسالة",
|
||||||
"com_nav_balance": "توازن",
|
"com_nav_balance": "توازن",
|
||||||
|
|
|
||||||
|
|
@ -273,7 +273,6 @@
|
||||||
"com_error_files_dupe": "S'ha detectat un fitxer duplicat.",
|
"com_error_files_dupe": "S'ha detectat un fitxer duplicat.",
|
||||||
"com_error_files_empty": "No es permeten fitxers buits.",
|
"com_error_files_empty": "No es permeten fitxers buits.",
|
||||||
"com_error_files_process": "S'ha produït un error en processar el fitxer.",
|
"com_error_files_process": "S'ha produït un error en processar el fitxer.",
|
||||||
"com_error_files_unsupported_capability": "No hi ha capacitats habilitades que admetin aquest tipus de fitxer.",
|
|
||||||
"com_error_files_upload": "S'ha produït un error en pujar el fitxer.",
|
"com_error_files_upload": "S'ha produït un error en pujar el fitxer.",
|
||||||
"com_error_files_upload_canceled": "La sol·licitud de pujada de fitxer s'ha cancel·lat. Nota: la pujada podria seguir processant-se i s'haurà d'esborrar manualment.",
|
"com_error_files_upload_canceled": "La sol·licitud de pujada de fitxer s'ha cancel·lat. Nota: la pujada podria seguir processant-se i s'haurà d'esborrar manualment.",
|
||||||
"com_error_files_validation": "S'ha produït un error en validar el fitxer.",
|
"com_error_files_validation": "S'ha produït un error en validar el fitxer.",
|
||||||
|
|
@ -303,7 +302,6 @@
|
||||||
"com_nav_auto_scroll": "Desplaçament automàtic al darrer missatge en obrir el xat",
|
"com_nav_auto_scroll": "Desplaçament automàtic al darrer missatge en obrir el xat",
|
||||||
"com_nav_auto_send_prompts": "Envia automàticament els prompts",
|
"com_nav_auto_send_prompts": "Envia automàticament els prompts",
|
||||||
"com_nav_auto_send_text": "Envia text automàticament",
|
"com_nav_auto_send_text": "Envia text automàticament",
|
||||||
"com_nav_auto_send_text_disabled": "estableix -1 per desactivar",
|
|
||||||
"com_nav_auto_transcribe_audio": "Transcriu àudio automàticament",
|
"com_nav_auto_transcribe_audio": "Transcriu àudio automàticament",
|
||||||
"com_nav_automatic_playback": "Reprodueix automàticament el darrer missatge",
|
"com_nav_automatic_playback": "Reprodueix automàticament el darrer missatge",
|
||||||
"com_nav_balance": "Balanç",
|
"com_nav_balance": "Balanç",
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,6 @@
|
||||||
"com_error_files_dupe": "Byl zjištěn duplicitní soubor.",
|
"com_error_files_dupe": "Byl zjištěn duplicitní soubor.",
|
||||||
"com_error_files_empty": "Prázdné soubory nejsou povoleny.",
|
"com_error_files_empty": "Prázdné soubory nejsou povoleny.",
|
||||||
"com_error_files_process": "Při zpracování souboru došlo k chybě.",
|
"com_error_files_process": "Při zpracování souboru došlo k chybě.",
|
||||||
"com_error_files_unsupported_capability": "Nejsou povoleny žádné funkce podporující tento typ souboru.",
|
|
||||||
"com_error_files_upload": "Při nahrávání souboru došlo k chybě.",
|
"com_error_files_upload": "Při nahrávání souboru došlo k chybě.",
|
||||||
"com_error_files_upload_canceled": "Požadavek na nahrání souboru byl zrušen. Poznámka: nahrávání souboru může stále probíhat a bude nutné jej ručně smazat.",
|
"com_error_files_upload_canceled": "Požadavek na nahrání souboru byl zrušen. Poznámka: nahrávání souboru může stále probíhat a bude nutné jej ručně smazat.",
|
||||||
"com_error_files_validation": "Při ověřování souboru došlo k chybě.",
|
"com_error_files_validation": "Při ověřování souboru došlo k chybě.",
|
||||||
|
|
@ -217,7 +216,6 @@
|
||||||
"com_nav_auto_scroll": "Automaticky rolovat na nejnovější zprávu po otevření chatu",
|
"com_nav_auto_scroll": "Automaticky rolovat na nejnovější zprávu po otevření chatu",
|
||||||
"com_nav_auto_send_prompts": "Automatické odesílání výzev",
|
"com_nav_auto_send_prompts": "Automatické odesílání výzev",
|
||||||
"com_nav_auto_send_text": "Automatické odesílání textu",
|
"com_nav_auto_send_text": "Automatické odesílání textu",
|
||||||
"com_nav_auto_send_text_disabled": "nastavte -1 pro deaktivaci",
|
|
||||||
"com_nav_auto_transcribe_audio": "Automaticky přepisovat zvuk",
|
"com_nav_auto_transcribe_audio": "Automaticky přepisovat zvuk",
|
||||||
"com_nav_automatic_playback": "Automatické přehrávání poslední zprávy",
|
"com_nav_automatic_playback": "Automatické přehrávání poslední zprávy",
|
||||||
"com_nav_balance": "Zůstatek",
|
"com_nav_balance": "Zůstatek",
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,6 @@
|
||||||
"com_error_files_dupe": "Duplikatfil fundet.",
|
"com_error_files_dupe": "Duplikatfil fundet.",
|
||||||
"com_error_files_empty": "Tomme filer er ikke tilladt.",
|
"com_error_files_empty": "Tomme filer er ikke tilladt.",
|
||||||
"com_error_files_process": "Der opstod en fejl under behandlingen af filen.",
|
"com_error_files_process": "Der opstod en fejl under behandlingen af filen.",
|
||||||
"com_error_files_unsupported_capability": "Ingen funktioner er aktiveret, der understøtter denne filtype.",
|
|
||||||
"com_error_files_upload": "Der opstod en fejl under upload af filen.",
|
"com_error_files_upload": "Der opstod en fejl under upload af filen.",
|
||||||
"com_error_files_upload_canceled": "Anmodningen om filoverførsel blev annulleret. Bemærk: Filuploaden kan stadig være under behandling og skal slettes manuelt.",
|
"com_error_files_upload_canceled": "Anmodningen om filoverførsel blev annulleret. Bemærk: Filuploaden kan stadig være under behandling og skal slettes manuelt.",
|
||||||
"com_error_files_validation": "Der opstod en fejl under validering af filen.",
|
"com_error_files_validation": "Der opstod en fejl under validering af filen.",
|
||||||
|
|
@ -297,7 +296,6 @@
|
||||||
"com_nav_auto_scroll": "Auto-scroll til seneste besked, når chatten er åben",
|
"com_nav_auto_scroll": "Auto-scroll til seneste besked, når chatten er åben",
|
||||||
"com_nav_auto_send_prompts": "Automatisk afsendelse af prompte",
|
"com_nav_auto_send_prompts": "Automatisk afsendelse af prompte",
|
||||||
"com_nav_auto_send_text": "Send tekst automatisk",
|
"com_nav_auto_send_text": "Send tekst automatisk",
|
||||||
"com_nav_auto_send_text_disabled": "sæt -1 for at deaktivere",
|
|
||||||
"com_nav_auto_transcribe_audio": "Automatisk transskribering af lyd",
|
"com_nav_auto_transcribe_audio": "Automatisk transskribering af lyd",
|
||||||
"com_nav_automatic_playback": "Autoplay Seneste besked",
|
"com_nav_automatic_playback": "Autoplay Seneste besked",
|
||||||
"com_nav_balance": "Balance",
|
"com_nav_balance": "Balance",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"com_agents_category_sales_description": "Agenten mit Fokus auf Vertriebsprozesse und Kundenbeziehungen",
|
"com_agents_category_sales_description": "Agenten mit Fokus auf Vertriebsprozesse und Kundenbeziehungen",
|
||||||
"com_agents_category_tab_label": "Kategorie {{category}}. {{position}} von {{total}}",
|
"com_agents_category_tab_label": "Kategorie {{category}}. {{position}} von {{total}}",
|
||||||
"com_agents_category_tabs_label": "Agenten-Kategorien",
|
"com_agents_category_tabs_label": "Agenten-Kategorien",
|
||||||
|
"com_agents_chat_with": "Chatte mit {{name}}",
|
||||||
"com_agents_clear_search": "Suche löschen",
|
"com_agents_clear_search": "Suche löschen",
|
||||||
"com_agents_code_interpreter": "Wenn aktiviert, ermöglicht es deinem Agenten, die LibreChat Code Interpreter API zu nutzen, um generierten Code sicher auszuführen, einschließlich der Verarbeitung von Dateien. Erfordert einen gültigen API-Schlüssel.",
|
"com_agents_code_interpreter": "Wenn aktiviert, ermöglicht es deinem Agenten, die LibreChat Code Interpreter API zu nutzen, um generierten Code sicher auszuführen, einschließlich der Verarbeitung von Dateien. Erfordert einen gültigen API-Schlüssel.",
|
||||||
"com_agents_code_interpreter_title": "Code-Interpreter-API",
|
"com_agents_code_interpreter_title": "Code-Interpreter-API",
|
||||||
|
|
@ -59,6 +60,7 @@
|
||||||
"com_agents_error_timeout_suggestion": "Bitte überprüfe deine Internetverbindung und versuche es erneut.",
|
"com_agents_error_timeout_suggestion": "Bitte überprüfe deine Internetverbindung und versuche es erneut.",
|
||||||
"com_agents_error_timeout_title": "Verbindungs-Timeout",
|
"com_agents_error_timeout_title": "Verbindungs-Timeout",
|
||||||
"com_agents_error_title": "Es ist ein Fehler aufgetreten",
|
"com_agents_error_title": "Es ist ein Fehler aufgetreten",
|
||||||
|
"com_agents_file_context_description": "Als „Kontext“ hochgeladene Dateien werden als Text analysiert, um die Anweisungen des Agenten zu ergänzen. Wenn OCR verfügbar ist oder für den hochgeladenen Dateityp konfiguriert wurde, wird dieser Prozess zum Extrahieren von Text verwendet. Ideal für Dokumente, Bilder mit Text oder PDFs, bei denen du den vollständigen Textinhalt einer Datei benötigst.",
|
||||||
"com_agents_file_context_disabled": "Der Agent muss vor dem Hochladen von Dateien für den Datei-Kontext erstellt werden.",
|
"com_agents_file_context_disabled": "Der Agent muss vor dem Hochladen von Dateien für den Datei-Kontext erstellt werden.",
|
||||||
"com_agents_file_context_label": "Dateikontext",
|
"com_agents_file_context_label": "Dateikontext",
|
||||||
"com_agents_file_search_disabled": "Der Agent muss erstellt werden, bevor Dateien für die Dateisuche hochgeladen werden können.",
|
"com_agents_file_search_disabled": "Der Agent muss erstellt werden, bevor Dateien für die Dateisuche hochgeladen werden können.",
|
||||||
|
|
@ -361,7 +363,6 @@
|
||||||
"com_error_files_dupe": "Doppelte Datei erkannt.",
|
"com_error_files_dupe": "Doppelte Datei erkannt.",
|
||||||
"com_error_files_empty": "Leere Dateien sind nicht zulässig",
|
"com_error_files_empty": "Leere Dateien sind nicht zulässig",
|
||||||
"com_error_files_process": "Bei der Verarbeitung der Datei ist ein Fehler aufgetreten.",
|
"com_error_files_process": "Bei der Verarbeitung der Datei ist ein Fehler aufgetreten.",
|
||||||
"com_error_files_unsupported_capability": "Keine aktivierten Funktionen unterstützen diesen Dateityp",
|
|
||||||
"com_error_files_upload": "Beim Hochladen der Datei ist ein Fehler aufgetreten",
|
"com_error_files_upload": "Beim Hochladen der Datei ist ein Fehler aufgetreten",
|
||||||
"com_error_files_upload_canceled": "Die Datei-Upload-Anfrage wurde abgebrochen. Hinweis: Der Upload-Vorgang könnte noch im Hintergrund laufen und die Datei muss möglicherweise manuell gelöscht werden.",
|
"com_error_files_upload_canceled": "Die Datei-Upload-Anfrage wurde abgebrochen. Hinweis: Der Upload-Vorgang könnte noch im Hintergrund laufen und die Datei muss möglicherweise manuell gelöscht werden.",
|
||||||
"com_error_files_validation": "Bei der Validierung der Datei ist ein Fehler aufgetreten.",
|
"com_error_files_validation": "Bei der Validierung der Datei ist ein Fehler aufgetreten.",
|
||||||
|
|
@ -406,7 +407,6 @@
|
||||||
"com_nav_auto_scroll": "Automatisch zur neuesten Nachricht scrollen, wenn der Chat geöffnet wird",
|
"com_nav_auto_scroll": "Automatisch zur neuesten Nachricht scrollen, wenn der Chat geöffnet wird",
|
||||||
"com_nav_auto_send_prompts": "Prompts automatisch senden",
|
"com_nav_auto_send_prompts": "Prompts automatisch senden",
|
||||||
"com_nav_auto_send_text": "Text automatisch senden",
|
"com_nav_auto_send_text": "Text automatisch senden",
|
||||||
"com_nav_auto_send_text_disabled": "-1 setzen zum Deaktivieren",
|
|
||||||
"com_nav_auto_transcribe_audio": "Audio automatisch transkribieren",
|
"com_nav_auto_transcribe_audio": "Audio automatisch transkribieren",
|
||||||
"com_nav_automatic_playback": "Automatische Wiedergabe der neuesten Nachricht",
|
"com_nav_automatic_playback": "Automatische Wiedergabe der neuesten Nachricht",
|
||||||
"com_nav_balance": "Guthaben",
|
"com_nav_balance": "Guthaben",
|
||||||
|
|
@ -831,6 +831,7 @@
|
||||||
"com_ui_delete_success": "Erfolgreich gelöscht",
|
"com_ui_delete_success": "Erfolgreich gelöscht",
|
||||||
"com_ui_delete_tool": "Werkzeug löschen",
|
"com_ui_delete_tool": "Werkzeug löschen",
|
||||||
"com_ui_delete_tool_confirm": "Bist du sicher, dass du dieses Werkzeug löschen möchtest?",
|
"com_ui_delete_tool_confirm": "Bist du sicher, dass du dieses Werkzeug löschen möchtest?",
|
||||||
|
"com_ui_delete_tool_save_reminder": "Tool entfernt. Speichere den Agenten, um die Änderungen zu übernehmen.",
|
||||||
"com_ui_deleted": "Gelöscht",
|
"com_ui_deleted": "Gelöscht",
|
||||||
"com_ui_deleting_file": "Lösche Datei...",
|
"com_ui_deleting_file": "Lösche Datei...",
|
||||||
"com_ui_descending": "Absteigend",
|
"com_ui_descending": "Absteigend",
|
||||||
|
|
@ -1023,6 +1024,7 @@
|
||||||
"com_ui_no_category": "Keine Kategorie",
|
"com_ui_no_category": "Keine Kategorie",
|
||||||
"com_ui_no_changes": "Es wurden keine Änderungen vorgenommen",
|
"com_ui_no_changes": "Es wurden keine Änderungen vorgenommen",
|
||||||
"com_ui_no_individual_access": "Keine einzelnen Benutzer oder Gruppen haben Zugriff auf diesen Agenten.",
|
"com_ui_no_individual_access": "Keine einzelnen Benutzer oder Gruppen haben Zugriff auf diesen Agenten.",
|
||||||
|
"com_ui_no_memories": "Keine Erinnerungen. Erstelle sie manuell oder fordere die KI auf, sich etwas zu merken.\n",
|
||||||
"com_ui_no_personalization_available": "Derzeit sind keine Personalisierungsoptionen verfügbar.",
|
"com_ui_no_personalization_available": "Derzeit sind keine Personalisierungsoptionen verfügbar.",
|
||||||
"com_ui_no_read_access": "Du hast keine Berechtigung, Erinnerungen anzuzeigen.",
|
"com_ui_no_read_access": "Du hast keine Berechtigung, Erinnerungen anzuzeigen.",
|
||||||
"com_ui_no_results_found": "Keine Ergebnisse gefunden",
|
"com_ui_no_results_found": "Keine Ergebnisse gefunden",
|
||||||
|
|
@ -1225,6 +1227,7 @@
|
||||||
"com_ui_upload_invalid": "Ungültige Datei zum Hochladen. Muss ein Bild sein und das Limit nicht überschreiten",
|
"com_ui_upload_invalid": "Ungültige Datei zum Hochladen. Muss ein Bild sein und das Limit nicht überschreiten",
|
||||||
"com_ui_upload_invalid_var": "Ungültige Datei zum Hochladen. Muss ein Bild sein und {{0}} MB nicht überschreiten",
|
"com_ui_upload_invalid_var": "Ungültige Datei zum Hochladen. Muss ein Bild sein und {{0}} MB nicht überschreiten",
|
||||||
"com_ui_upload_ocr_text": "Hochladen als Text mit OCR",
|
"com_ui_upload_ocr_text": "Hochladen als Text mit OCR",
|
||||||
|
"com_ui_upload_provider": "Hochladen zum KI-Anbieter",
|
||||||
"com_ui_upload_success": "Datei erfolgreich hochgeladen",
|
"com_ui_upload_success": "Datei erfolgreich hochgeladen",
|
||||||
"com_ui_upload_type": "Upload-Typ auswählen",
|
"com_ui_upload_type": "Upload-Typ auswählen",
|
||||||
"com_ui_usage": "Nutzung",
|
"com_ui_usage": "Nutzung",
|
||||||
|
|
@ -1263,6 +1266,8 @@
|
||||||
"com_ui_web_search_scraper": "Scraper",
|
"com_ui_web_search_scraper": "Scraper",
|
||||||
"com_ui_web_search_scraper_firecrawl": "Firecrawl API\n",
|
"com_ui_web_search_scraper_firecrawl": "Firecrawl API\n",
|
||||||
"com_ui_web_search_scraper_firecrawl_key": "Einen Firecrawl API Schlüssel holen",
|
"com_ui_web_search_scraper_firecrawl_key": "Einen Firecrawl API Schlüssel holen",
|
||||||
|
"com_ui_web_search_scraper_serper": "Serper Scrape API",
|
||||||
|
"com_ui_web_search_scraper_serper_key": "Hole einen Serper API Schlüssel",
|
||||||
"com_ui_web_search_searxng_api_key": "SearXNG API Key (optional) einfügen",
|
"com_ui_web_search_searxng_api_key": "SearXNG API Key (optional) einfügen",
|
||||||
"com_ui_web_search_searxng_instance_url": "SearXNG Instanz URL",
|
"com_ui_web_search_searxng_instance_url": "SearXNG Instanz URL",
|
||||||
"com_ui_web_searching": "Internetsuche läuft",
|
"com_ui_web_searching": "Internetsuche läuft",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"chat_direction_left_to_right": "something needs to go here. was empty",
|
"chat_direction_left_to_right": "Left to Right",
|
||||||
"chat_direction_right_to_left": "something needs to go here. was empty",
|
"chat_direction_right_to_left": "Right to Left",
|
||||||
"com_a11y_ai_composing": "The AI is still composing.",
|
"com_a11y_ai_composing": "The AI is still composing.",
|
||||||
"com_a11y_end": "The AI has finished their reply.",
|
"com_a11y_end": "The AI has finished their reply.",
|
||||||
"com_a11y_start": "The AI has started their reply.",
|
"com_a11y_start": "The AI has started their reply.",
|
||||||
|
|
@ -365,6 +365,7 @@
|
||||||
"com_error_files_process": "An error occurred while processing the file.",
|
"com_error_files_process": "An error occurred while processing the file.",
|
||||||
"com_error_files_upload": "An error occurred while uploading the file.",
|
"com_error_files_upload": "An error occurred while uploading the file.",
|
||||||
"com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.",
|
"com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.",
|
||||||
|
"com_error_files_upload_too_large": "The file is too large. Please upload a file smaller than {{0}} MB",
|
||||||
"com_error_files_validation": "An error occurred while validating the file.",
|
"com_error_files_validation": "An error occurred while validating the file.",
|
||||||
"com_error_google_tool_conflict": "Usage of built-in Google tools are not supported with external tools. Please disable either the built-in tools or the external tools.",
|
"com_error_google_tool_conflict": "Usage of built-in Google tools are not supported with external tools. Please disable either the built-in tools or the external tools.",
|
||||||
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",
|
"com_error_heic_conversion": "Failed to convert HEIC image to JPEG. Please try converting the image manually or use a different format.",
|
||||||
|
|
@ -408,7 +409,6 @@
|
||||||
"com_nav_auto_scroll": "Auto-Scroll to latest message on chat open",
|
"com_nav_auto_scroll": "Auto-Scroll to latest message on chat open",
|
||||||
"com_nav_auto_send_prompts": "Auto-send Prompts",
|
"com_nav_auto_send_prompts": "Auto-send Prompts",
|
||||||
"com_nav_auto_send_text": "Auto send text",
|
"com_nav_auto_send_text": "Auto send text",
|
||||||
"com_nav_auto_send_text_disabled": "set -1 to disable",
|
|
||||||
"com_nav_auto_transcribe_audio": "Auto transcribe audio",
|
"com_nav_auto_transcribe_audio": "Auto transcribe audio",
|
||||||
"com_nav_automatic_playback": "Autoplay Latest Message",
|
"com_nav_automatic_playback": "Autoplay Latest Message",
|
||||||
"com_nav_balance": "Balance",
|
"com_nav_balance": "Balance",
|
||||||
|
|
@ -561,6 +561,7 @@
|
||||||
"com_nav_setting_balance": "Balance",
|
"com_nav_setting_balance": "Balance",
|
||||||
"com_nav_setting_chat": "Chat",
|
"com_nav_setting_chat": "Chat",
|
||||||
"com_nav_setting_data": "Data controls",
|
"com_nav_setting_data": "Data controls",
|
||||||
|
"com_nav_setting_delay": "Delay (s)",
|
||||||
"com_nav_setting_general": "General",
|
"com_nav_setting_general": "General",
|
||||||
"com_nav_setting_mcp": "MCP Settings",
|
"com_nav_setting_mcp": "MCP Settings",
|
||||||
"com_nav_setting_personalization": "Personalization",
|
"com_nav_setting_personalization": "Personalization",
|
||||||
|
|
@ -760,6 +761,7 @@
|
||||||
"com_ui_client_secret": "Client Secret",
|
"com_ui_client_secret": "Client Secret",
|
||||||
"com_ui_close": "Close",
|
"com_ui_close": "Close",
|
||||||
"com_ui_close_menu": "Close Menu",
|
"com_ui_close_menu": "Close Menu",
|
||||||
|
"com_ui_close_settings": "Close Settings",
|
||||||
"com_ui_close_window": "Close Window",
|
"com_ui_close_window": "Close Window",
|
||||||
"com_ui_code": "Code",
|
"com_ui_code": "Code",
|
||||||
"com_ui_collapse_chat": "Collapse Chat",
|
"com_ui_collapse_chat": "Collapse Chat",
|
||||||
|
|
@ -858,6 +860,7 @@
|
||||||
"com_ui_edit_editing_image": "Editing image",
|
"com_ui_edit_editing_image": "Editing image",
|
||||||
"com_ui_edit_mcp_server": "Edit MCP Server",
|
"com_ui_edit_mcp_server": "Edit MCP Server",
|
||||||
"com_ui_edit_memory": "Edit Memory",
|
"com_ui_edit_memory": "Edit Memory",
|
||||||
|
"com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size",
|
||||||
"com_ui_empty_category": "-",
|
"com_ui_empty_category": "-",
|
||||||
"com_ui_endpoint": "Endpoint",
|
"com_ui_endpoint": "Endpoint",
|
||||||
"com_ui_endpoint_menu": "LLM Endpoint Menu",
|
"com_ui_endpoint_menu": "LLM Endpoint Menu",
|
||||||
|
|
@ -892,6 +895,7 @@
|
||||||
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
|
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
|
||||||
"com_ui_field_max_length": "{{field}} must be less than {{length}} characters",
|
"com_ui_field_max_length": "{{field}} must be less than {{length}} characters",
|
||||||
"com_ui_field_required": "This field is required",
|
"com_ui_field_required": "This field is required",
|
||||||
|
"com_ui_file_input_avatar_label": "File input for avatar",
|
||||||
"com_ui_file_size": "File Size",
|
"com_ui_file_size": "File Size",
|
||||||
"com_ui_file_token_limit": "File Token Limit",
|
"com_ui_file_token_limit": "File Token Limit",
|
||||||
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
|
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
|
||||||
|
|
@ -954,11 +958,13 @@
|
||||||
"com_ui_import_conversation_file_type_error": "Unsupported import type",
|
"com_ui_import_conversation_file_type_error": "Unsupported import type",
|
||||||
"com_ui_import_conversation_info": "Import conversations from a JSON file",
|
"com_ui_import_conversation_info": "Import conversations from a JSON file",
|
||||||
"com_ui_import_conversation_success": "Conversations imported successfully",
|
"com_ui_import_conversation_success": "Conversations imported successfully",
|
||||||
|
"com_ui_import_conversation_upload_error": "Error uploading file. Please try again.",
|
||||||
"com_ui_include_shadcnui": "Include shadcn/ui components instructions",
|
"com_ui_include_shadcnui": "Include shadcn/ui components instructions",
|
||||||
"com_ui_initializing": "Initializing...",
|
"com_ui_initializing": "Initializing...",
|
||||||
"com_ui_input": "Input",
|
"com_ui_input": "Input",
|
||||||
"com_ui_instructions": "Instructions",
|
"com_ui_instructions": "Instructions",
|
||||||
"com_ui_key": "Key",
|
"com_ui_key": "Key",
|
||||||
|
"com_ui_key_required": "API key is required",
|
||||||
"com_ui_late_night": "Happy late night",
|
"com_ui_late_night": "Happy late night",
|
||||||
"com_ui_latest_footer": "Every AI for Everyone.",
|
"com_ui_latest_footer": "Every AI for Everyone.",
|
||||||
"com_ui_latest_production_version": "Latest production version",
|
"com_ui_latest_production_version": "Latest production version",
|
||||||
|
|
@ -973,6 +979,7 @@
|
||||||
"com_ui_manage": "Manage",
|
"com_ui_manage": "Manage",
|
||||||
"com_ui_marketplace": "Marketplace",
|
"com_ui_marketplace": "Marketplace",
|
||||||
"com_ui_marketplace_allow_use": "Allow using Marketplace",
|
"com_ui_marketplace_allow_use": "Allow using Marketplace",
|
||||||
|
"com_ui_max_file_size": "PNG, JPG or JPEG (max {{0}})",
|
||||||
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
||||||
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
|
"com_ui_mcp_authenticated_success": "MCP server '{{0}}' authenticated successfully",
|
||||||
"com_ui_mcp_configure_server": "Configure {{0}}",
|
"com_ui_mcp_configure_server": "Configure {{0}}",
|
||||||
|
|
@ -1067,6 +1074,7 @@
|
||||||
"com_ui_privacy_policy": "Privacy policy",
|
"com_ui_privacy_policy": "Privacy policy",
|
||||||
"com_ui_privacy_policy_url": "Privacy Policy URL",
|
"com_ui_privacy_policy_url": "Privacy Policy URL",
|
||||||
"com_ui_prompt": "Prompt",
|
"com_ui_prompt": "Prompt",
|
||||||
|
"com_ui_prompt_groups": "Prompt Groups List",
|
||||||
"com_ui_prompt_name": "Prompt Name",
|
"com_ui_prompt_name": "Prompt Name",
|
||||||
"com_ui_prompt_name_required": "Prompt Name is required",
|
"com_ui_prompt_name_required": "Prompt Name is required",
|
||||||
"com_ui_prompt_preview_not_shared": "The author has not allowed collaboration for this prompt.",
|
"com_ui_prompt_preview_not_shared": "The author has not allowed collaboration for this prompt.",
|
||||||
|
|
@ -1096,6 +1104,8 @@
|
||||||
"com_ui_rename_failed": "Failed to rename conversation",
|
"com_ui_rename_failed": "Failed to rename conversation",
|
||||||
"com_ui_rename_prompt": "Rename Prompt",
|
"com_ui_rename_prompt": "Rename Prompt",
|
||||||
"com_ui_requires_auth": "Requires Authentication",
|
"com_ui_requires_auth": "Requires Authentication",
|
||||||
|
"com_ui_reset": "Reset",
|
||||||
|
"com_ui_reset_adjustments": "Reset adjustments",
|
||||||
"com_ui_reset_var": "Reset {{0}}",
|
"com_ui_reset_var": "Reset {{0}}",
|
||||||
"com_ui_reset_zoom": "Reset Zoom",
|
"com_ui_reset_zoom": "Reset Zoom",
|
||||||
"com_ui_resource": "resource",
|
"com_ui_resource": "resource",
|
||||||
|
|
@ -1104,6 +1114,8 @@
|
||||||
"com_ui_revoke_info": "Revoke all user provided credentials",
|
"com_ui_revoke_info": "Revoke all user provided credentials",
|
||||||
"com_ui_revoke_key_confirm": "Are you sure you want to revoke this key?",
|
"com_ui_revoke_key_confirm": "Are you sure you want to revoke this key?",
|
||||||
"com_ui_revoke_key_endpoint": "Revoke Key for {{0}}",
|
"com_ui_revoke_key_endpoint": "Revoke Key for {{0}}",
|
||||||
|
"com_ui_revoke_key_error": "Failed to revoke API key. Please try again.",
|
||||||
|
"com_ui_revoke_key_success": "API key revoked successfully",
|
||||||
"com_ui_revoke_keys": "Revoke Keys",
|
"com_ui_revoke_keys": "Revoke Keys",
|
||||||
"com_ui_revoke_keys_confirm": "Are you sure you want to revoke all keys?",
|
"com_ui_revoke_keys_confirm": "Are you sure you want to revoke all keys?",
|
||||||
"com_ui_role": "Role",
|
"com_ui_role": "Role",
|
||||||
|
|
@ -1117,11 +1129,15 @@
|
||||||
"com_ui_role_viewer": "Viewer",
|
"com_ui_role_viewer": "Viewer",
|
||||||
"com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it",
|
"com_ui_role_viewer_desc": "Can view and use the agent but cannot modify it",
|
||||||
"com_ui_roleplay": "Roleplay",
|
"com_ui_roleplay": "Roleplay",
|
||||||
|
"com_ui_rotate": "Rotate",
|
||||||
|
"com_ui_rotate_90": "Rotate 90 degrees",
|
||||||
"com_ui_run_code": "Run Code",
|
"com_ui_run_code": "Run Code",
|
||||||
"com_ui_run_code_error": "There was an error running the code",
|
"com_ui_run_code_error": "There was an error running the code",
|
||||||
"com_ui_save": "Save",
|
"com_ui_save": "Save",
|
||||||
"com_ui_save_badge_changes": "Save badge changes?",
|
"com_ui_save_badge_changes": "Save badge changes?",
|
||||||
"com_ui_save_changes": "Save Changes",
|
"com_ui_save_changes": "Save Changes",
|
||||||
|
"com_ui_save_key_error": "Failed to save API key. Please try again.",
|
||||||
|
"com_ui_save_key_success": "API key saved successfully",
|
||||||
"com_ui_save_submit": "Save & Submit",
|
"com_ui_save_submit": "Save & Submit",
|
||||||
"com_ui_saved": "Saved!",
|
"com_ui_saved": "Saved!",
|
||||||
"com_ui_saving": "Saving...",
|
"com_ui_saving": "Saving...",
|
||||||
|
|
@ -1218,6 +1234,7 @@
|
||||||
"com_ui_update_mcp_success": "Successfully created or updated MCP",
|
"com_ui_update_mcp_success": "Successfully created or updated MCP",
|
||||||
"com_ui_upload": "Upload",
|
"com_ui_upload": "Upload",
|
||||||
"com_ui_upload_agent_avatar": "Successfully updated agent avatar",
|
"com_ui_upload_agent_avatar": "Successfully updated agent avatar",
|
||||||
|
"com_ui_upload_avatar_label": "Upload avatar image",
|
||||||
"com_ui_upload_code_files": "Upload for Code Interpreter",
|
"com_ui_upload_code_files": "Upload for Code Interpreter",
|
||||||
"com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.",
|
"com_ui_upload_delay": "Uploading \"{{0}}\" is taking more time than anticipated. Please wait while the file finishes indexing for retrieval.",
|
||||||
"com_ui_upload_error": "There was an error uploading your file",
|
"com_ui_upload_error": "There was an error uploading your file",
|
||||||
|
|
@ -1279,5 +1296,8 @@
|
||||||
"com_ui_x_selected": "{{0}} selected",
|
"com_ui_x_selected": "{{0}} selected",
|
||||||
"com_ui_yes": "Yes",
|
"com_ui_yes": "Yes",
|
||||||
"com_ui_zoom": "Zoom",
|
"com_ui_zoom": "Zoom",
|
||||||
|
"com_ui_zoom_in": "Zoom in",
|
||||||
|
"com_ui_zoom_level": "Zoom level",
|
||||||
|
"com_ui_zoom_out": "Zoom out",
|
||||||
"com_user_message": "You"
|
"com_user_message": "You"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue