diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 5c6561396ef9..661b3163ee36 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -371,6 +371,47 @@ class BaseClient { }, ''); } + /** + * Assigns priority scores to messages based on content type and recency + * @param {TMessage[]} messages - Array of messages + * @returns {Array<{message: TMessage, priority: number, index: number}>} + */ + assignMessagePriorities(messages) { + return messages.map((message, index) => { + let priority = 1.0; // Base priority + + // Recency bonus (more recent = higher priority) + // Note: messages are ordered oldest to newest, so higher index = more recent + const recencyFactor = index / Math.max(messages.length - 1, 1); + priority += recencyFactor * 0.3; + + // Check for tool calls + if (message.content && Array.isArray(message.content)) { + const hasToolCalls = message.content.some((item) => item.type === 'tool_call'); + const hasMCPToolCalls = message.content.some( + (item) => + item.type === 'tool_call' && + item.tool_call?.name?.includes?.(Constants.mcp_delimiter), + ); + + if (hasToolCalls) { + priority += 0.5; // Tool calls are important + } + + if (hasMCPToolCalls) { + priority += 0.3; // MCP tool calls are extra important + } + } + + // User messages slightly higher priority than assistant + if (message.role === 'user') { + priority += 0.2; + } + + return { message, priority, index }; + }); + } + /** * This method processes an array of messages and returns a context of messages that fit within a specified token limit. * It iterates over the messages from newest to oldest, adding them to the context until the token limit is reached. @@ -395,42 +436,66 @@ class BaseClient { // start with 3 tokens for the label after all messages have been counted. let currentTokenCount = 3; const instructionsTokenCount = instructions?.tokenCount ?? 0; - let remainingContextTokens = + const maxTokensForContext = (maxContextTokens ?? this.maxContextTokens) - instructionsTokenCount; - const messages = [..._messages]; - - const context = []; - if (currentTokenCount < remainingContextTokens) { - while (messages.length > 0 && currentTokenCount < remainingContextTokens) { - if (messages.length === 1 && instructions) { - break; - } - const poppedMessage = messages.pop(); - const { tokenCount } = poppedMessage; - - if (poppedMessage && currentTokenCount + tokenCount <= remainingContextTokens) { - context.push(poppedMessage); - currentTokenCount += tokenCount; - } else { - messages.push(poppedMessage); - break; - } + // Combine messages with instructions if instructions exist + const orderedMessages = instructions + ? this.addInstructions(_messages, instructions) + : [..._messages]; + + // Assign priorities to messages (excluding instructions from priority calculation) + // Map original indices when instructions are present + const messagesToPrioritize = instructions ? orderedMessages.slice(1) : orderedMessages; + const prioritizedMessages = this.assignMessagePriorities(messagesToPrioritize).map((item) => ({ + ...item, + originalIndex: instructions ? item.index + 1 : item.index, // Adjust index if instructions were added + })); + + // Sort by priority (descending) but keep track of original order + const sortedByPriority = [...prioritizedMessages].sort((a, b) => b.priority - a.priority); + + const selectedMessages = []; + const selectedOriginalIndices = new Set(); + + // Select messages based on priority until we hit token limit + for (const { message, originalIndex } of sortedByPriority) { + const tokenCount = message.tokenCount || 0; + if (currentTokenCount + tokenCount <= maxTokensForContext) { + selectedMessages.push({ message, originalIndex }); + selectedOriginalIndices.add(originalIndex); + currentTokenCount += tokenCount; } } + // Re-sort by original order + selectedMessages.sort((a, b) => a.originalIndex - b.originalIndex); + + const context = selectedMessages.map(({ message }) => message); + const messagesToRefine = prioritizedMessages + .filter(({ originalIndex }) => !selectedOriginalIndices.has(originalIndex)) + .map(({ message }) => message); + + // Add instructions back to context if they exist if (instructions) { - context.push(_messages[0]); - messages.shift(); + context.unshift(instructions); } - const prunedMemory = messages; - remainingContextTokens -= currentTokenCount; + const remainingContextTokens = maxTokensForContext - currentTokenCount; + + logger.debug('[BaseClient] Priority-based context selection:', { + total: _messages.length, + selected: context.length - (instructions ? 1 : 0), + refined: messagesToRefine.length, + tokenCount: currentTokenCount, + maxTokens: maxTokensForContext, + instructionsTokens: instructionsTokenCount, + }); return { - context: context.reverse(), + context, remainingContextTokens, - messagesToRefine: prunedMemory, + messagesToRefine, }; } @@ -456,14 +521,25 @@ class BaseClient { } if (this.clientName === EModelEndpoint.agents) { + const hasMCPTools = this.options?.agent?.tools?.some(tool => + tool.name?.includes?.(Constants.mcp_delimiter) + ); + const { dbMessages, editedIndices } = truncateToolCallOutputs( orderedMessages, this.maxContextTokens, this.getTokenCountForMessage.bind(this), + { + threshold: 0.75, + mcpPriorityBoost: hasMCPTools + } ); if (editedIndices.length > 0) { - logger.debug('[BaseClient] Truncated tool call outputs:', editedIndices); + logger.debug('[BaseClient] Truncated tool call outputs:', { + indices: editedIndices, + stats + }); for (const index of editedIndices) { formattedMessages[index].content = dbMessages[index].content; } diff --git a/api/app/clients/memory/summaryBuffer.js b/api/app/clients/memory/summaryBuffer.js index 84ff34d151c3..ce67114c7cb9 100644 --- a/api/app/clients/memory/summaryBuffer.js +++ b/api/app/clients/memory/summaryBuffer.js @@ -22,11 +22,45 @@ const summaryBuffer = async ({ previous_summary = '', prompt = SUMMARY_PROMPT, signal, + preserveToolCalls = false, }) => { if (previous_summary) { logger.debug('[summaryBuffer]', { previous_summary }); } + let toolCallSummary = ''; + if(preserveToolCalls) { + const Constants = require('librechat-data-provider').Constants; + const toolCalls = context + .filter(msg => msg.tool_calls || msg.content?.some?.(c => c,type === + 'tool_call')) + .map(msg => { + const calls = msg.tool_calls || + msg.content?.filter?.(c => c.type === 'tool_call') || []; + + return calls.map(call => { + const isMCP = call.name?.includes?.(Constants.mcp_delimiter); + return { + name: call.name, + isMCP, + input: call.input, + output: call.output ? + (typeof call.output === 'string' ? call.output.slice(0, 200) : + JSON.stringify(call.output).slice(0, 200)) : null + }; + }); + }) + .flat() + .filter(Boolean); + + if(toolCalls.length > 0) { + toolCallSummary = '\n\nTool calls in this conversation:\n' + + toolCalls.map(tc => + `-${tc.name}${tc.isMCP ? ' (MCP)' : ''}: ${tc.output || 'pending'}` + ).join('\n'); + } + } + const formattedMessages = formatLangChainMessages(context, formatOptions); const memoryOptions = { llm, @@ -60,7 +94,9 @@ const summaryBuffer = async ({ logger.debug('[summaryBuffer]', { summary: predictSummary }); } - return { role: 'system', content: predictSummary }; + const finalSummary = predictSummary + toolCallSummary; + + return { role: 'system', content: finalSummary }; }; module.exports = { createSummaryBufferMemory, summaryBuffer }; diff --git a/api/app/clients/prompts/truncate.js b/api/app/clients/prompts/truncate.js index 564b39efeb7a..8f5d2e403dab 100644 --- a/api/app/clients/prompts/truncate.js +++ b/api/app/clients/prompts/truncate.js @@ -1,4 +1,6 @@ +const MCPTool = require('@librechat/frontend/src/components/SidePanel/Agents/MCPTool'); const MAX_CHAR = 255; +const MAX_TOOL_OUTPUT_TOKENS = 500; /** * Truncates a given text to a specified maximum length, appending ellipsis and a notification @@ -38,17 +40,31 @@ function smartTruncateText(text, maxLength = MAX_CHAR) { } /** + * Enhanced version with progressive truncation and MCP awareness * @param {TMessage[]} _messages * @param {number} maxContextTokens * @param {function({role: string, content: TMessageContent[]}): number} getTokenCountForMessage + * @param {object} options - Additional options + * @param {number} options.threshold - Threshold percentage (default 0.75) + * @param {boolean} options.mcpPriorityBoost - Give MCP tools higher threshold * * @returns {{ * dbMessages: TMessage[], - * editedIndices: number[] + * editedIndices: number[], + * stats: { + * totalTruncated: number, + * mcpTruncated: number, + * tokensSaved: number + * } * }} */ -function truncateToolCallOutputs(_messages, maxContextTokens, getTokenCountForMessage) { - const THRESHOLD_PERCENTAGE = 0.5; +function truncateToolCallOutputs( + _messages, + maxContextTokens, + getTokenCountForMessage, + options = {}) { + const THRESHOLD_PERCENTAGE = options.threshold || 0.75; + const MCP_PRIORITY_BOOST = options.mcpPriorityBoost !== false ? 0.15 : 0; const targetTokenLimit = maxContextTokens * THRESHOLD_PERCENTAGE; let currentTokenCount = 3; @@ -56,11 +72,27 @@ function truncateToolCallOutputs(_messages, maxContextTokens, getTokenCountForMe const processedMessages = []; let currentIndex = messages.length; const editedIndices = new Set(); + const stats = { + totalTruncated: 0, + mcpTruncated: 0, + tokensSaved: 0 + }; + while (messages.length > 0) { currentIndex--; const message = messages.pop(); - currentTokenCount += message.tokenCount; - if (currentTokenCount < targetTokenLimit) { + const originalTokenCount = message.tokenCount; + currentTokenCount += originalTokenCount; + + const isMCP = message.content?.some(item => + item.type === 'tool_call' && isMCPTool(item.tool_call) + ); + + const effectiveThreshold = isMCP + ? targetTokenLimit * (1 + MCP_PRIORITY_BOOST) + : targetTokenLimit; + + if (currentTokenCount < effectiveThreshold) { processedMessages.push(message); continue; } @@ -81,6 +113,7 @@ function truncateToolCallOutputs(_messages, maxContextTokens, getTokenCountForMe } const newContent = [...message.content]; + let messageModified = false; // Truncate all tool outputs since we're over threshold for (const index of toolCallIndices) { @@ -89,27 +122,102 @@ function truncateToolCallOutputs(_messages, maxContextTokens, getTokenCountForMe continue; } - editedIndices.add(currentIndex); + const outputLength = typeof toolCall.output === 'string' + ? toolCall.output.length + : JSON.stringify(toolCall.output).length; + + if(outputLength > MAX_TOOL_OUTPUT_TOKENS * 4){ + editedIndices.add(currentIndex); + messageModified = true; + + const isMCPTool = toolCall.name?.includes?.( + require('librechat-data-provider').Constants.mcp_delimiter + ); + + const truncateOutput = smartTruncateToolOutput( + toolCall.output, + isMCPTool ? MAX_TOOL_OUTPUT_TOKENS * 1.5 : MAX_TOOL_OUTPUT_TOKENS + ); + + newContent[index] = { + ...newContent[index], + tool_call: { + ...toolCall, + output: '[OUTPUT_OMITTED_FOR_BREVITY]', + }, + }; + + stats.totalTruncated++; + if(!isMCPTool) stats.mcpTruncated++; + stats.tokensSaved += Math.max(0, outputLength - truncateOutput.length); + } + } - newContent[index] = { - ...newContent[index], - tool_call: { - ...toolCall, - output: '[OUTPUT_OMITTED_FOR_BREVITY]', - }, + if(messageModified){ + const truncatedMessage = { + ...message, + content: newContent, + tokenCount: getTokenCountForMessage({ role: 'assistant', content: newContent }), }; - } - const truncatedMessage = { - ...message, - content: newContent, - tokenCount: getTokenCountForMessage({ role: 'assistant', content: newContent }), - }; + currentTokenCount = currentTokenCount - originalTokenCount + truncatedMessage.tokenCount; - processedMessages.push(truncatedMessage); + processedMessages.push(truncatedMessage); + } else { + processedMessages.push(message); + } } return { dbMessages: processedMessages.reverse(), editedIndices: Array.from(editedIndices) }; } +/** + * @param {string|object} output - The tool output to truncate + * @param {number} maxTokens - Maximum tokens allowed + * @returns {string} Truncated output + */ +function smartTruncateToolOutput(output, maxTokens = MAX_TOOL_OUTPUT_TOKENS) { + //if output is already small enough, return as is + if(typeof output === 'string' && output.length <= maxTokens * 4) { + return output; + } + + //for structured outputs (object/arrays), preserve structure + if(typeof output === 'object') { + try { + const jsonStr = JSON.stringify(output, null, 2); + if(jsonStr.length <= maxTokens * 4) { + return jsonStr; + } + + //Truncate JSON intelligently + const summary = { + _truncated: true, + _originalLength: jsonStr.length, + _preview: jsonStr.slice(0, maxTokens * 3), + _type: Array.isArray(output) ? 'array' : 'object', + _keys: Array.isArray(output) ? output.length : Object.keys(output).length + }; + + return JSON.stringify(summary, null, 2); + } catch (e) { + return '[Complex object - truncated for brevity]'; + } + } + + return smartTruncateText(output, maxTokens * 4); +} + +/** + * Check if a tool is an MCP tool + * @param {object} toolCall - The tool call object + * @returns {boolean} + */ +function isMCPTool(toolCall) { + const Constants = require('librechat-data-provider').Constants; + return toolCall?.name?.includes?.(Constants.mcp_delimiter); +} + + + module.exports = { truncateText, smartTruncateText, truncateToolCallOutputs }; diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js index 9a2a047bb100..35eeb32ffe9b 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -5,6 +5,7 @@ const FormData = require('form-data'); const { ProxyAgent } = require('undici'); const { tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); +const { HttpsProxyAgent } = require('https-proxy-agent'); const { logAxiosError, oaiToolkit } = require('@librechat/api'); const { ContentTypes, EImageOutputType } = require('librechat-data-provider'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); @@ -348,16 +349,7 @@ Error Message: ${error.message}`); }; if (process.env.PROXY) { - try { - const url = new URL(process.env.PROXY); - axiosConfig.proxy = { - host: url.hostname.replace(/^\[|\]$/g, ''), - port: url.port ? parseInt(url.port, 10) : undefined, - protocol: url.protocol.replace(':', ''), - }; - } catch (error) { - logger.error('Error parsing proxy URL:', error); - } + axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY); } if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) { diff --git a/api/config/agentConfig.js b/api/config/agentConfig.js new file mode 100644 index 000000000000..6e1a163b0340 --- /dev/null +++ b/api/config/agentConfig.js @@ -0,0 +1,35 @@ +/** + * Parses agent context management configuration from librechat.yaml + * @param {Object} config - The loaded custom configuration object + * @returns {Object} Parsed agent context management configuration + */ +function parseAgentContextConfig(config) { + const defaults = { + toolTruncationThreshold: 0.75, + mcpPriorityBoost: 0.15, + autoEnableSummarization: true, + maxToolOutputTokens: 500, + preserveToolCallsInSummary: true, + }; + + if (!config?.agents?.contextManagement) { + return defaults; + } + + const cm = config.agents.contextManagement; + + return { + toolTruncationThreshold: cm.toolTruncationThreshold ?? defaults.toolTruncationThreshold, + mcpPriorityBoost: cm.mcpPriorityBoost ?? defaults.mcpPriorityBoost, + autoEnableSummarization: + cm.autoEnableSummarization ?? defaults.autoEnableSummarization, + maxToolOutputTokens: cm.maxToolOutputTokens ?? defaults.maxToolOutputTokens, + preserveToolCallsInSummary: + cm.preserveToolCallsInSummary ?? defaults.preserveToolCallsInSummary, + }; +} + +module.exports = { + parseAgentContextConfig, +}; + diff --git a/api/config/index.js b/api/config/index.js index 0ddbee1661d0..ab4d9f7a0e27 100644 --- a/api/config/index.js +++ b/api/config/index.js @@ -2,6 +2,7 @@ const { EventSource } = require('eventsource'); const { Time } = require('librechat-data-provider'); const { MCPManager, FlowStateManager, OAuthReconnectionManager } = require('@librechat/api'); const logger = require('./winston'); +const { parseAgentContextConfig } = require('./agentConfig'); global.EventSource = EventSource; @@ -28,4 +29,5 @@ module.exports = { getFlowStateManager, createOAuthReconnectionManager: OAuthReconnectionManager.createInstance, getOAuthReconnectionManager: OAuthReconnectionManager.getInstance, + parseAgentContextConfig, }; diff --git a/api/models/Agent.js b/api/models/Agent.js index 5468293523d7..f5f740ba7bf9 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -62,25 +62,37 @@ const getAgents = async (searchParameter) => await Agent.find(searchParameter).l * * @param {Object} params * @param {ServerRequest} params.req + * @param {string} params.spec * @param {string} params.agent_id * @param {string} params.endpoint * @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @returns {Promise} The agent document as a plain object, or null if not found. */ -const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => { +const loadEphemeralAgent = async ({ req, spec, agent_id, endpoint, model_parameters: _m }) => { const { model, ...model_parameters } = _m; + const modelSpecs = req.config?.modelSpecs?.list; + /** @type {TModelSpec | null} */ + let modelSpec = null; + if (spec != null && spec !== '') { + modelSpec = modelSpecs?.find((s) => s.name === spec) || null; + } /** @type {TEphemeralAgent | null} */ const ephemeralAgent = req.body.ephemeralAgent; const mcpServers = new Set(ephemeralAgent?.mcp); + if (modelSpec?.mcpServers) { + for (const mcpServer of modelSpec.mcpServers) { + mcpServers.add(mcpServer); + } + } /** @type {string[]} */ const tools = []; - if (ephemeralAgent?.execute_code === true) { + if (ephemeralAgent?.execute_code === true || modelSpec?.executeCode === true) { tools.push(Tools.execute_code); } - if (ephemeralAgent?.file_search === true) { + if (ephemeralAgent?.file_search === true || modelSpec?.fileSearch === true) { tools.push(Tools.file_search); } - if (ephemeralAgent?.web_search === true) { + if (ephemeralAgent?.web_search === true || modelSpec?.webSearch === true) { tools.push(Tools.web_search); } @@ -122,17 +134,18 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _ * * @param {Object} params * @param {ServerRequest} params.req + * @param {string} params.spec * @param {string} params.agent_id * @param {string} params.endpoint * @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @returns {Promise} The agent document as a plain object, or null if not found. */ -const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => { +const loadAgent = async ({ req, spec, agent_id, endpoint, model_parameters }) => { if (!agent_id) { return null; } if (agent_id === EPHEMERAL_AGENT_ID) { - return await loadEphemeralAgent({ req, agent_id, endpoint, model_parameters }); + return await loadEphemeralAgent({ req, spec, agent_id, endpoint, model_parameters }); } const agent = await getAgent({ id: agent_id, diff --git a/api/package.json b/api/package.json index f0b654b1afa9..44cc252216bb 100644 --- a/api/package.json +++ b/api/package.json @@ -48,7 +48,7 @@ "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.85", + "@librechat/agents": "^2.4.86", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index a648488d1478..dd02a5a20b91 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -1,4 +1,4 @@ -require('events').EventEmitter.defaultMaxListeners = 100; + require('events').EventEmitter.defaultMaxListeners = 100; const { logger } = require('@librechat/data-schemas'); const { DynamicStructuredTool } = require('@langchain/core/tools'); const { getBufferString, HumanMessage } = require('@langchain/core/messages'); @@ -102,9 +102,18 @@ class AgentClient extends BaseClient { * @type {string} */ this.clientName = EModelEndpoint.agents; + //Detect if MCP tools are in use + const hasMCPTools = options?.agent?.tools?.some(tool => { + tool.name?.includes?.(Constants.mcp_delimiter) + }); + /** @type {'discard' | 'summarize'} */ this.contextStrategy = 'discard'; + if(hasMCPTools) { + logger.debug('[AgentClient] MCP tools detected, using summarize context strategy'); + } + /** @deprecated @type {true} - Is a Chat Completion Request */ this.isChatCompletion = true; @@ -243,6 +252,65 @@ class AgentClient extends BaseClient { .join('\n') .trim(); + // Calculate and reserve tokens for MCP instructions FIRST + let mcpInstructionsTokens = 0; + let mcpInstructions = ''; + + const ephemeralAgent = this.options.req.body.ephemeralAgent; + let mcpServers = []; + + // Check for ephemeral agent MCP servers + if (ephemeralAgent && ephemeralAgent.mcp && ephemeralAgent.mcp.length > 0) { + mcpServers = ephemeralAgent.mcp; + } + // Check for regular agent MCP tools + else if (this.options.agent && this.options.agent.tools) { + mcpServers = this.options.agent.tools + .filter( + (tool) => + tool instanceof DynamicStructuredTool && tool.name.includes(Constants.mcp_delimiter), + ) + .map((tool) => tool.name.split(Constants.mcp_delimiter).pop()) + .filter(Boolean); + } + + if (mcpServers.length > 0) { + try { + mcpInstructions = getMCPManager().formatInstructionsForContext(mcpServers); + if (mcpInstructions) { + // Calculate token cost of MCP instructions + mcpInstructionsTokens = this.getTokenCountForMessage({ + role: 'system', + content: mcpInstructions + }); + + logger.debug('[AgentClient] MCP instructions token cost:', { + servers: mcpServers, + tokens: mcpInstructionsTokens + }); + + // Add to system content + systemContent = mcpInstructions + '\n\n' + systemContent; + } + } catch (error) { + logger.error('[AgentClient] Error formatting MCP instructions:', error); + } + } + + // Adjust maxContextTokens to account for MCP instructions + const effectiveMaxContextTokens = this.maxContextTokens - mcpInstructionsTokens; + + logger.debug('[AgentClient] Context token allocation:', { + original: this.maxContextTokens, + mcpInstructions: mcpInstructionsTokens, + available: effectiveMaxContextTokens + }); + + // Use effectiveMaxContextTokens for truncation decisions + // Store original for restoration + const originalMaxContextTokens = this.maxContextTokens; + this.maxContextTokens = effectiveMaxContextTokens; + if (this.options.attachments) { const attachments = await this.options.attachments; const latestMessage = orderedMessages[orderedMessages.length - 1]; @@ -324,36 +392,6 @@ class AgentClient extends BaseClient { systemContent = this.augmentedPrompt + systemContent; } - // Inject MCP server instructions if available - const ephemeralAgent = this.options.req.body.ephemeralAgent; - let mcpServers = []; - - // Check for ephemeral agent MCP servers - if (ephemeralAgent && ephemeralAgent.mcp && ephemeralAgent.mcp.length > 0) { - mcpServers = ephemeralAgent.mcp; - } - // Check for regular agent MCP tools - else if (this.options.agent && this.options.agent.tools) { - mcpServers = this.options.agent.tools - .filter( - (tool) => - tool instanceof DynamicStructuredTool && tool.name.includes(Constants.mcp_delimiter), - ) - .map((tool) => tool.name.split(Constants.mcp_delimiter).pop()) - .filter(Boolean); - } - - if (mcpServers.length > 0) { - try { - const mcpInstructions = getMCPManager().formatInstructionsForContext(mcpServers); - if (mcpInstructions) { - systemContent = [systemContent, mcpInstructions].filter(Boolean).join('\n\n'); - logger.debug('[AgentClient] Injected MCP instructions for servers:', mcpServers); - } - } catch (error) { - logger.error('[AgentClient] Failed to inject MCP instructions:', error); - } - } if (systemContent) { this.options.agent.instructions = systemContent; @@ -393,6 +431,9 @@ class AgentClient extends BaseClient { this.options.agent.instructions = systemContent; } + // Restore original maxContextTokens after processing + this.maxContextTokens = originalMaxContextTokens; + return result; } diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js index 3bf90e8d821e..34fcaf4be4e1 100644 --- a/api/server/services/Endpoints/agents/build.js +++ b/api/server/services/Endpoints/agents/build.js @@ -3,9 +3,10 @@ const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat- const { loadAgent } = require('~/models/Agent'); const buildOptions = (req, endpoint, parsedBody, endpointType) => { - const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody; + const { spec, iconURL, agent_id, ...model_parameters } = parsedBody; const agentPromise = loadAgent({ req, + spec, agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID, endpoint, model_parameters, @@ -20,7 +21,6 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => { endpoint, agent_id, endpointType, - instructions, model_parameters, agent: agentPromise, }); diff --git a/api/server/services/Files/Audio/getCustomConfigSpeech.js b/api/server/services/Files/Audio/getCustomConfigSpeech.js index b4bc8f704f26..d0d0b51ac23f 100644 --- a/api/server/services/Files/Audio/getCustomConfigSpeech.js +++ b/api/server/services/Files/Audio/getCustomConfigSpeech.js @@ -42,18 +42,26 @@ async function getCustomConfigSpeech(req, res) { settings.advancedMode = speechTab.advancedMode; } - if (speechTab.speechToText) { - for (const key in speechTab.speechToText) { - if (speechTab.speechToText[key] !== undefined) { - settings[key] = speechTab.speechToText[key]; + if (speechTab.speechToText !== undefined) { + if (typeof speechTab.speechToText === 'boolean') { + settings.speechToText = speechTab.speechToText; + } else { + for (const key in speechTab.speechToText) { + if (speechTab.speechToText[key] !== undefined) { + settings[key] = speechTab.speechToText[key]; + } } } } - if (speechTab.textToSpeech) { - for (const key in speechTab.textToSpeech) { - if (speechTab.textToSpeech[key] !== undefined) { - settings[key] = speechTab.textToSpeech[key]; + if (speechTab.textToSpeech !== undefined) { + if (typeof speechTab.textToSpeech === 'boolean') { + settings.textToSpeech = speechTab.textToSpeech; + } else { + for (const key in speechTab.textToSpeech) { + if (speechTab.textToSpeech[key] !== undefined) { + settings[key] = speechTab.textToSpeech[key]; + } } } } diff --git a/api/server/services/GraphApiService.js b/api/server/services/GraphApiService.js index 82fa245d5805..08ca2539640c 100644 --- a/api/server/services/GraphApiService.js +++ b/api/server/services/GraphApiService.js @@ -159,7 +159,7 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li /** * Get current user's Entra ID group memberships from Microsoft Graph - * Uses /me/memberOf endpoint to get groups the user is a member of + * Uses /me/getMemberGroups endpoint to get transitive groups the user is a member of * @param {string} accessToken - OpenID Connect access token * @param {string} sub - Subject identifier * @returns {Promise>} Array of group ID strings (GUIDs) @@ -167,10 +167,12 @@ const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', li const getUserEntraGroups = async (accessToken, sub) => { try { const graphClient = await createGraphClient(accessToken, sub); + const response = await graphClient + .api('/me/getMemberGroups') + .post({ securityEnabledOnly: false }); - const groupsResponse = await graphClient.api('/me/memberOf').select('id').get(); - - return (groupsResponse.value || []).map((group) => group.id); + const groupIds = Array.isArray(response?.value) ? response.value : []; + return [...new Set(groupIds.map((groupId) => String(groupId)))]; } catch (error) { logger.error('[getUserEntraGroups] Error fetching user groups:', error); return []; @@ -187,13 +189,22 @@ const getUserEntraGroups = async (accessToken, sub) => { const getUserOwnedEntraGroups = async (accessToken, sub) => { try { const graphClient = await createGraphClient(accessToken, sub); + const allGroupIds = []; + let nextLink = '/me/ownedObjects/microsoft.graph.group'; - const groupsResponse = await graphClient - .api('/me/ownedObjects/microsoft.graph.group') - .select('id') - .get(); + while (nextLink) { + const response = await graphClient.api(nextLink).select('id').top(999).get(); + const groups = response?.value || []; + allGroupIds.push(...groups.map((group) => group.id)); + + nextLink = response['@odata.nextLink'] + ? response['@odata.nextLink'] + .replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '') + .trim() || null + : null; + } - return (groupsResponse.value || []).map((group) => group.id); + return allGroupIds; } catch (error) { logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error); return []; @@ -211,21 +222,27 @@ const getUserOwnedEntraGroups = async (accessToken, sub) => { const getGroupMembers = async (accessToken, sub, groupId) => { try { const graphClient = await createGraphClient(accessToken, sub); - const allMembers = []; - let nextLink = `/groups/${groupId}/members`; + const allMembers = new Set(); + let nextLink = `/groups/${groupId}/transitiveMembers`; while (nextLink) { const membersResponse = await graphClient.api(nextLink).select('id').top(999).get(); - const members = membersResponse.value || []; - allMembers.push(...members.map((member) => member.id)); + const members = membersResponse?.value || []; + members.forEach((member) => { + if (typeof member?.id === 'string' && member['@odata.type'] === '#microsoft.graph.user') { + allMembers.add(member.id); + } + }); nextLink = membersResponse['@odata.nextLink'] - ? membersResponse['@odata.nextLink'].split('/v1.0')[1] + ? membersResponse['@odata.nextLink'] + .replace(/^https:\/\/graph\.microsoft\.com\/v1\.0/, '') + .trim() || null : null; } - return allMembers; + return Array.from(allMembers); } catch (error) { logger.error('[getGroupMembers] Error fetching group members:', error); return []; diff --git a/api/server/services/GraphApiService.spec.js b/api/server/services/GraphApiService.spec.js index 5d8dd62cf5a6..fa11190cc3e6 100644 --- a/api/server/services/GraphApiService.spec.js +++ b/api/server/services/GraphApiService.spec.js @@ -73,6 +73,7 @@ describe('GraphApiService', () => { header: jest.fn().mockReturnThis(), top: jest.fn().mockReturnThis(), get: jest.fn(), + post: jest.fn(), }; Client.init.mockReturnValue(mockGraphClient); @@ -514,31 +515,33 @@ describe('GraphApiService', () => { }); describe('getUserEntraGroups', () => { - it('should fetch user groups from memberOf endpoint', async () => { + it('should fetch user groups using getMemberGroups endpoint', async () => { const mockGroupsResponse = { - value: [ - { - id: 'group-1', - }, - { - id: 'group-2', - }, - ], + value: ['group-1', 'group-2'], }; - mockGraphClient.get.mockResolvedValue(mockGroupsResponse); + mockGraphClient.post.mockResolvedValue(mockGroupsResponse); const result = await GraphApiService.getUserEntraGroups('token', 'user'); - expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf'); - expect(mockGraphClient.select).toHaveBeenCalledWith('id'); + expect(mockGraphClient.api).toHaveBeenCalledWith('/me/getMemberGroups'); + expect(mockGraphClient.post).toHaveBeenCalledWith({ securityEnabledOnly: false }); + + expect(result).toEqual(['group-1', 'group-2']); + }); + + it('should deduplicate returned group ids', async () => { + mockGraphClient.post.mockResolvedValue({ + value: ['group-1', 'group-2', 'group-1'], + }); + + const result = await GraphApiService.getUserEntraGroups('token', 'user'); - expect(result).toHaveLength(2); expect(result).toEqual(['group-1', 'group-2']); }); it('should return empty array on error', async () => { - mockGraphClient.get.mockRejectedValue(new Error('API error')); + mockGraphClient.post.mockRejectedValue(new Error('API error')); const result = await GraphApiService.getUserEntraGroups('token', 'user'); @@ -550,7 +553,7 @@ describe('GraphApiService', () => { value: [], }; - mockGraphClient.get.mockResolvedValue(mockGroupsResponse); + mockGraphClient.post.mockResolvedValue(mockGroupsResponse); const result = await GraphApiService.getUserEntraGroups('token', 'user'); @@ -558,7 +561,7 @@ describe('GraphApiService', () => { }); it('should handle missing value property', async () => { - mockGraphClient.get.mockResolvedValue({}); + mockGraphClient.post.mockResolvedValue({}); const result = await GraphApiService.getUserEntraGroups('token', 'user'); @@ -566,6 +569,89 @@ describe('GraphApiService', () => { }); }); + describe('getUserOwnedEntraGroups', () => { + it('should fetch owned groups with pagination support', async () => { + const firstPage = { + value: [ + { + id: 'owned-group-1', + }, + ], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz', + }; + + const secondPage = { + value: [ + { + id: 'owned-group-2', + }, + ], + }; + + mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage); + + const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user'); + + expect(mockGraphClient.api).toHaveBeenNthCalledWith( + 1, + '/me/ownedObjects/microsoft.graph.group', + ); + expect(mockGraphClient.api).toHaveBeenNthCalledWith( + 2, + '/me/ownedObjects/microsoft.graph.group?$skiptoken=xyz', + ); + expect(mockGraphClient.top).toHaveBeenCalledWith(999); + expect(mockGraphClient.get).toHaveBeenCalledTimes(2); + + expect(result).toEqual(['owned-group-1', 'owned-group-2']); + }); + + it('should return empty array on error', async () => { + mockGraphClient.get.mockRejectedValue(new Error('API error')); + + const result = await GraphApiService.getUserOwnedEntraGroups('token', 'user'); + + expect(result).toEqual([]); + }); + }); + + describe('getGroupMembers', () => { + it('should fetch transitive members and include only users', async () => { + const firstPage = { + value: [ + { id: 'user-1', '@odata.type': '#microsoft.graph.user' }, + { id: 'child-group', '@odata.type': '#microsoft.graph.group' }, + ], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/groups/group-id/transitiveMembers?$skiptoken=abc', + }; + const secondPage = { + value: [{ id: 'user-2', '@odata.type': '#microsoft.graph.user' }], + }; + + mockGraphClient.get.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage); + + const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id'); + + expect(mockGraphClient.api).toHaveBeenNthCalledWith(1, '/groups/group-id/transitiveMembers'); + expect(mockGraphClient.api).toHaveBeenNthCalledWith( + 2, + '/groups/group-id/transitiveMembers?$skiptoken=abc', + ); + expect(mockGraphClient.top).toHaveBeenCalledWith(999); + expect(result).toEqual(['user-1', 'user-2']); + }); + + it('should return empty array on error', async () => { + mockGraphClient.get.mockRejectedValue(new Error('API error')); + + const result = await GraphApiService.getGroupMembers('token', 'user', 'group-id'); + + expect(result).toEqual([]); + }); + }); + describe('testGraphApiAccess', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/client/package.json b/client/package.json index b46f77cbd8be..dcd5f637a1c2 100644 --- a/client/package.json +++ b/client/package.json @@ -149,7 +149,7 @@ "tailwindcss": "^3.4.1", "ts-jest": "^29.2.5", "typescript": "^5.3.3", - "vite": "^6.3.6", + "vite": "^6.4.1", "vite-plugin-compression2": "^2.2.1", "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-pwa": "^0.21.2" diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 39254925343a..4effd7d679d7 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -35,9 +35,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) enabled: !isEphemeralAgent(agent_id), }); - const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, { - enabled: !isEphemeralAgent(agent_id), - }); + const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents); const { data: mcpData } = useMCPToolsQuery({ enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null, diff --git a/client/src/components/Agents/AgentDetail.tsx b/client/src/components/Agents/AgentDetail.tsx index 3cbfe330caeb..ef77734e30e3 100644 --- a/client/src/components/Agents/AgentDetail.tsx +++ b/client/src/components/Agents/AgentDetail.tsx @@ -11,9 +11,9 @@ import { AgentListResponse, } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; +import { renderAgentAvatar, clearMessagesCache } from '~/utils'; import { useLocalize, useDefaultConvo } from '~/hooks'; import { useChatContext } from '~/Providers'; -import { renderAgentAvatar } from '~/utils'; interface SupportContact { name?: string; @@ -56,10 +56,7 @@ const AgentDetail: React.FC = ({ agent, isOpen, onClose }) => localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id); - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); /** Template with agent configuration */ diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index 97cf1b20cca8..ef882142e2b5 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -4,7 +4,7 @@ import { useOutletContext } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { useSearchParams, useParams, useNavigate } from 'react-router-dom'; import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client'; -import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider'; +import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider'; import type t from 'librechat-data-provider'; import type { ContextType } from '~/common'; import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks'; @@ -13,11 +13,11 @@ import MarketplaceAdminSettings from './MarketplaceAdminSettings'; import { SidePanelProvider, useChatContext } from '~/Providers'; import { SidePanelGroup } from '~/components/SidePanel'; import { OpenSidebar } from '~/components/Chat/Menus'; +import { cn, clearMessagesCache } from '~/utils'; import CategoryTabs from './CategoryTabs'; import AgentDetail from './AgentDetail'; import SearchBar from './SearchBar'; import AgentGrid from './AgentGrid'; -import { cn } from '~/utils'; import store from '~/store'; interface AgentMarketplaceProps { @@ -224,10 +224,7 @@ const AgentMarketplace: React.FC = ({ className = '' }) = window.open('/c/new', '_blank'); return; } - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); newConversation(); }; diff --git a/client/src/components/Agents/MarketplaceAdminSettings.tsx b/client/src/components/Agents/MarketplaceAdminSettings.tsx index fa5fa34fbc80..e09f168afee7 100644 --- a/client/src/components/Agents/MarketplaceAdminSettings.tsx +++ b/client/src/components/Agents/MarketplaceAdminSettings.tsx @@ -58,6 +58,7 @@ const LabelController: React.FC = ({ checked={field.value} onCheckedChange={field.onChange} value={field.value.toString()} + aria-label={label} /> )} /> diff --git a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx index 1efb239308d8..1e1b7d1e4b03 100644 --- a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx +++ b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx @@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => { // Performance check: rendering should be fast const renderTime = endTime - startTime; - expect(renderTime).toBeLessThan(720); + expect(renderTime).toBeLessThan(740); console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`); console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`); diff --git a/client/src/components/Bookmarks/BookmarkForm.tsx b/client/src/components/Bookmarks/BookmarkForm.tsx index 3b2633485bbf..23e94dbfb127 100644 --- a/client/src/components/Bookmarks/BookmarkForm.tsx +++ b/client/src/components/Bookmarks/BookmarkForm.tsx @@ -129,7 +129,11 @@ const BookmarkForm = ({
-
{conversationId != null && conversationId && ( @@ -161,6 +166,7 @@ const BookmarkForm = ({ onCheckedChange={field.onChange} className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" value={field.value?.toString()} + aria-label={localize('com_ui_bookmarks_add_to_conversation')} /> )} /> diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 0736c7dc61f9..f1dc1ef076ac 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -12,6 +12,7 @@ import { import { useTextarea, useAutoSave, + useLocalize, useRequiresKey, useHandleKeyUp, useQueryParams, @@ -38,6 +39,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => { const submitButtonRef = useRef(null); const textAreaRef = useRef(null); useFocusChatEffect(textAreaRef); + const localize = useLocalize(); const [isCollapsed, setIsCollapsed] = useState(false); const [, setIsScrollable] = useState(false); @@ -220,6 +222,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
{showPlusPopover && !isAssistantsEndpoint(endpoint) && ( { )} {showMentionPopover && ( { setIsTextAreaFocused(true); }} onBlur={setIsTextAreaFocused.bind(null, false)} + aria-label={localize('com_ui_message_input')} onClick={handleFocusOrClick} style={{ height: 44, overflowY: 'auto' }} className={cn( diff --git a/client/src/components/Chat/Input/Files/FileUpload.tsx b/client/src/components/Chat/Input/Files/FileUpload.tsx index 723fa32e86f8..718c8c1f5dee 100644 --- a/client/src/components/Chat/Input/Files/FileUpload.tsx +++ b/client/src/components/Chat/Input/Files/FileUpload.tsx @@ -62,17 +62,28 @@ const FileUpload: React.FC = ({ statusText = invalidText ?? localize('com_ui_upload_invalid'); } + const handleClick = () => { + const fileInput = document.getElementById(`file-upload-${id}`) as HTMLInputElement; + if (fileInput) { + fileInput.click(); + } + }; + return ( - + ); }; diff --git a/client/src/components/Chat/Input/Files/Table/DataTable.tsx b/client/src/components/Chat/Input/Files/Table/DataTable.tsx index ffb3e2825bf2..70459b2d6663 100644 --- a/client/src/components/Chat/Input/Files/Table/DataTable.tsx +++ b/client/src/components/Chat/Input/Files/Table/DataTable.tsx @@ -122,7 +122,11 @@ export default function DataTable({ columns, data }: DataTablePro /> - diff --git a/client/src/components/Chat/Input/Mention.tsx b/client/src/components/Chat/Input/Mention.tsx index e3472a2aa0e4..2defcc762311 100644 --- a/client/src/components/Chat/Input/Mention.tsx +++ b/client/src/components/Chat/Input/Mention.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from 'react'; import { useCombobox } from '@librechat/client'; import { AutoSizer, List } from 'react-virtualized'; import { EModelEndpoint } from 'librechat-data-provider'; +import type { TConversation } from 'librechat-data-provider'; import type { MentionOption, ConvoGenerator } from '~/common'; import type { SetterOrUpdater } from 'recoil'; import useSelectMention from '~/hooks/Input/useSelectMention'; @@ -14,6 +15,7 @@ import MentionItem from './MentionItem'; const ROW_HEIGHT = 40; export default function Mention({ + conversation, setShowMentionPopover, newConversation, textAreaRef, @@ -21,6 +23,7 @@ export default function Mention({ placeholder = 'com_ui_mention', includeAssistants = true, }: { + conversation: TConversation | null; setShowMentionPopover: SetterOrUpdater; newConversation: ConvoGenerator; textAreaRef: React.MutableRefObject; @@ -42,6 +45,7 @@ export default function Mention({ const { onSelectMention } = useSelectMention({ presets, modelSpecs, + conversation, assistantsMap, endpointsConfig, newConversation, diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx index bd639523d869..eac3bb200cd0 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorChatContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, useMemo } from 'react'; -import type { EModelEndpoint } from 'librechat-data-provider'; +import type { EModelEndpoint, TConversation } from 'librechat-data-provider'; import { useChatContext } from '~/Providers/ChatContext'; interface ModelSelectorChatContextValue { @@ -8,6 +8,7 @@ interface ModelSelectorChatContextValue { spec?: string | null; agent_id?: string | null; assistant_id?: string | null; + conversation: TConversation | null; newConversation: ReturnType['newConversation']; } @@ -26,16 +27,10 @@ export function ModelSelectorChatProvider({ children }: { children: React.ReactN spec: conversation?.spec, agent_id: conversation?.agent_id, assistant_id: conversation?.assistant_id, + conversation, newConversation, }), - [ - conversation?.endpoint, - conversation?.model, - conversation?.spec, - conversation?.agent_id, - conversation?.assistant_id, - newConversation, - ], + [conversation, newConversation], ); return ( diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index a4527d56e755..e79d9a2d2139 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -57,7 +57,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const agentsMap = useAgentsMapContext(); const assistantsMap = useAssistantsMapContext(); const { data: endpointsConfig } = useGetEndpointsQuery(); - const { endpoint, model, spec, agent_id, assistant_id, newConversation } = + const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } = useModelSelectorChatContext(); const modelSpecs = useMemo(() => { const specs = startupConfig?.modelSpecs?.list ?? []; @@ -96,6 +96,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector const { onSelectEndpoint, onSelectSpec } = useSelectMention({ // presets, modelSpecs, + conversation, assistantsMap, endpointsConfig, newConversation, diff --git a/client/src/components/Chat/Menus/HeaderNewChat.tsx b/client/src/components/Chat/Menus/HeaderNewChat.tsx index b2dc6416abc0..5245ccbf130d 100644 --- a/client/src/components/Chat/Menus/HeaderNewChat.tsx +++ b/client/src/components/Chat/Menus/HeaderNewChat.tsx @@ -1,8 +1,8 @@ +import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; -import { QueryKeys, Constants } from 'librechat-data-provider'; import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client'; -import type { TMessage } from 'librechat-data-provider'; import { useChatContext } from '~/Providers'; +import { clearMessagesCache } from '~/utils'; import { useLocalize } from '~/hooks'; export default function HeaderNewChat() { @@ -15,10 +15,7 @@ export default function HeaderNewChat() { window.open('/c/new', '_blank'); return; } - queryClient.setQueryData( - [QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO], - [], - ); + clearMessagesCache(queryClient, conversation?.conversationId); queryClient.invalidateQueries([QueryKeys.messages]); newConversation(); }; diff --git a/client/src/components/Chat/Menus/Presets/PresetItems.tsx b/client/src/components/Chat/Menus/Presets/PresetItems.tsx index 4e7710e0a786..a0c65bc04cb0 100644 --- a/client/src/components/Chat/Menus/Presets/PresetItems.tsx +++ b/client/src/components/Chat/Menus/Presets/PresetItems.tsx @@ -59,9 +59,10 @@ const PresetItems: FC<{ - +
diff --git a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx b/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx index 242b13765e8b..5422d9733df0 100644 --- a/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx @@ -170,6 +170,7 @@ const EditTextPart = ({ 'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4', removeFocusRings, )} + aria-label={localize('com_ui_editable_message')} dir={isRTL ? 'rtl' : 'ltr'} /> diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index b16c6458c71e..b6a7032e9f9c 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -201,7 +201,6 @@ const Conversations: FC = ({ overscanRowCount={10} className="outline-none" style={{ outline: 'none' }} - role="list" aria-label="Conversations" onRowsRendered={handleRowsRendered} tabIndex={-1} diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index 46310268f06b..cbbb612251b9 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -77,7 +77,13 @@ export default function ShareButton({
{showQR && (
- +
)} @@ -87,6 +93,7 @@ export default function ShareButton({ diff --git a/client/src/components/Endpoints/Settings/Advanced.tsx b/client/src/components/Endpoints/Settings/Advanced.tsx index d0beaa902090..504e6cd94d39 100644 --- a/client/src/components/Endpoints/Settings/Advanced.tsx +++ b/client/src/components/Endpoints/Settings/Advanced.tsx @@ -151,6 +151,7 @@ export default function Settings({ min={0} step={0.01} className="flex h-4 w-full" + aria-labelledby="temp-int" /> @@ -160,7 +161,9 @@ export default function Settings({
@@ -199,7 +203,9 @@ export default function Settings({
@@ -238,7 +245,9 @@ export default function Settings({
@@ -306,6 +316,7 @@ export default function Settings({ onCheckedChange={(checked: boolean) => setResendFiles(checked)} disabled={readonly} className="flex" + aria-label={localize('com_endpoint_plug_resend_files')} /> @@ -323,6 +334,7 @@ export default function Settings({ max={2} min={0} step={1} + aria-label={localize('com_endpoint_plug_image_detail')} /> diff --git a/client/src/components/Endpoints/Settings/AgentSettings.tsx b/client/src/components/Endpoints/Settings/AgentSettings.tsx index f41a8bc19ea9..f4425a4db4aa 100644 --- a/client/src/components/Endpoints/Settings/AgentSettings.tsx +++ b/client/src/components/Endpoints/Settings/AgentSettings.tsx @@ -53,7 +53,9 @@ export default function Settings({ conversation, setOption, models, readonly }:
@@ -101,6 +104,7 @@ export default function Settings({ conversation, setOption, models, readonly }: onCheckedChange={onCheckedChangeAgent} disabled={readonly} className="ml-4 mt-2" + aria-label={localize('com_endpoint_plug_use_functions')} /> @@ -119,6 +123,7 @@ export default function Settings({ conversation, setOption, models, readonly }: onCheckedChange={onCheckedChangeSkip} disabled={readonly} className="ml-4 mt-2" + aria-label={localize('com_endpoint_plug_skip_completion')} /> diff --git a/client/src/components/Endpoints/Settings/Google.tsx b/client/src/components/Endpoints/Settings/Google.tsx index 6e513c179118..18bf95a1d0ee 100644 --- a/client/src/components/Endpoints/Settings/Google.tsx +++ b/client/src/components/Endpoints/Settings/Google.tsx @@ -171,6 +171,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.temperature.min} step={google.temperature.step} className="flex h-4 w-full" + aria-labelledby="temp-int" /> @@ -211,6 +212,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.topP.min} step={google.topP.step} className="flex h-4 w-full" + aria-labelledby="top-p-int" /> @@ -252,6 +254,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.topK.min} step={google.topK.step} className="flex h-4 w-full" + aria-labelledby="top-k-int" /> @@ -296,6 +299,7 @@ export default function Settings({ conversation, setOption, models, readonly }: min={google.maxOutputTokens.min} step={google.maxOutputTokens.step} className="flex h-4 w-full" + aria-labelledby="max-tokens-int" /> @@ -296,6 +297,7 @@ export default function Settings({ min={0} step={0.01} className="flex h-4 w-full" + aria-labelledby="top-p-int" /> @@ -337,6 +339,7 @@ export default function Settings({ min={-2} step={0.01} className="flex h-4 w-full" + aria-labelledby="freq-penalty-int" /> @@ -378,6 +381,7 @@ export default function Settings({ min={-2} step={0.01} className="flex h-4 w-full" + aria-labelledby="pres-penalty-int" /> diff --git a/client/src/components/Nav/ExportConversation/ExportModal.tsx b/client/src/components/Nav/ExportConversation/ExportModal.tsx index 642b5bbc8118..2083ddec1a73 100644 --- a/client/src/components/Nav/ExportConversation/ExportModal.tsx +++ b/client/src/components/Nav/ExportConversation/ExportModal.tsx @@ -124,13 +124,15 @@ export default function ExportModal({ disabled={!exportOptionsSupport} checked={includeOptions} onCheckedChange={setIncludeOptions} + aria-labelledby="includeOptions-label" />
@@ -146,13 +148,15 @@ export default function ExportModal({ disabled={!exportBranchesSupport} checked={exportBranches} onCheckedChange={setExportBranches} + aria-labelledby="exportBranches-label" />
@@ -163,8 +167,14 @@ export default function ExportModal({ {localize('com_nav_export_recursive_or_sequential')}
- +
); diff --git a/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx b/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx index 02a5ee256e62..949453cb5c34 100644 --- a/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx +++ b/client/src/components/Nav/SettingsTabs/Chat/ShowThinking.tsx @@ -30,6 +30,7 @@ export default function SaveDraft({ onCheckedChange={handleCheckedChange} className="ml-4" data-testid="showThinking" + aria-label={localize('com_nav_show_thinking')} />
); diff --git a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx index 2d06b7439251..816c5a2deb25 100644 --- a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx @@ -9,12 +9,10 @@ import { useLocalize } from '~/hooks'; import { cn, logger } from '~/utils'; function ImportConversations() { - const queryClient = useQueryClient(); - const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); const localize = useLocalize(); - const fileInputRef = useRef(null); + const queryClient = useQueryClient(); const { showToast } = useToastContext(); - + const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const handleSuccess = useCallback(() => { @@ -53,7 +51,8 @@ function ImportConversations() { const handleFileUpload = useCallback( async (file: File) => { try { - const maxFileSize = (startupConfig as any)?.conversationImportMaxFileSize; + const startupConfig = queryClient.getQueryData([QueryKeys.startupConfig]); + const maxFileSize = startupConfig?.conversationImportMaxFileSize; if (maxFileSize && file.size > maxFileSize) { const size = (maxFileSize / (1024 * 1024)).toFixed(2); showToast({ @@ -76,7 +75,7 @@ function ImportConversations() { }); } }, - [uploadFile, showToast, localize, startupConfig], + [uploadFile, showToast, localize, queryClient], ); const handleFileChange = useCallback( diff --git a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx index ae25223a9bf5..bcc6a4af9c11 100644 --- a/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/SharedLinks.tsx @@ -13,7 +13,6 @@ import { useMediaQuery, OGDialogHeader, OGDialogTitle, - TooltipAnchor, DataTable, Spinner, Button, @@ -246,37 +245,27 @@ export default function SharedLinks() { }, cell: ({ row }) => (
- { - window.open(`/c/${row.original.conversationId}`, '_blank'); - }} - title={localize('com_ui_view_source')} - > - - - } - /> - { - setDeleteRow(row.original); - setIsDeleteOpen(true); - }} - title={localize('com_ui_delete')} - > - - - } - /> + +
), }, diff --git a/client/src/components/Prompts/AdminSettings.tsx b/client/src/components/Prompts/AdminSettings.tsx index 6f1580800e79..7b25db721c18 100644 --- a/client/src/components/Prompts/AdminSettings.tsx +++ b/client/src/components/Prompts/AdminSettings.tsx @@ -53,6 +53,7 @@ const LabelController: React.FC = ({ } }} value={field.value.toString()} + aria-label={label} /> )} /> @@ -216,7 +217,12 @@ const AdminSettings = () => { ))}
-
diff --git a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx index 17c82c648d45..64d6bd60ec0a 100644 --- a/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx +++ b/client/src/components/Prompts/Groups/AlwaysMakeProd.tsx @@ -28,7 +28,7 @@ export default function AlwaysMakeProd({ checked={alwaysMakeProd} onCheckedChange={handleCheckedChange} data-testid="alwaysMakeProd" - aria-label="Always make prompt production" + aria-label={localize('com_nav_always_make_prod')} />
{localize('com_nav_always_make_prod')}
diff --git a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx b/client/src/components/Prompts/Groups/AutoSendPrompt.tsx index 430506a74865..182580a49c63 100644 --- a/client/src/components/Prompts/Groups/AutoSendPrompt.tsx +++ b/client/src/components/Prompts/Groups/AutoSendPrompt.tsx @@ -30,7 +30,7 @@ export default function AutoSendPrompt({ >
{localize('com_nav_auto_send_prompts')}
{ + e.stopPropagation(); + }} className="w-full cursor-pointer rounded-lg text-text-primary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed" >