From 01e9b196bc2ebd665decf9be3c768f196a73c5d8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 23 Jun 2025 09:59:05 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20Streamline=20Endpoints?= =?UTF-8?q?=20to=20Agent=20Framework=20(#8013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(buildEndpointOption): Improve error logging in middleware, consolidate `isAgents` builder logic, remove adding `modelsConfig` to `endpointOption` * refactor: parameter extraction and organization in agent services, minimize redundancy of shared fields across objects, make clear distinction of parameters processed uniquely by LibreChat vs LLM Provider Configs * refactor(createPayload): streamline all endpoints to agent route * fix: add `modelLabel` to response sender options for agent initialization * chore: correct log message context in EditController abort controller cleanup * chore: remove unused abortRequest hook * chore: remove unused addToCache module and its dependencies * refactor: remove AskController and related routes, update endpoint URLs (now all streamlined to agents route) * chore: remove unused bedrock route and its related imports * refactor: simplify response sender logic for Google endpoint * chore: add `modelDisplayLabel` handling for agents endpoint * feat: add file search capability to ephemeral agents, update code interpreter selection based of file upload, consolidate main upload menu for all endpoints * feat: implement useToolToggle hook for managing tool toggle state, refactor CodeInterpreter and WebSearch components to utilize new hook * feat: add ToolsDropdown component to BadgeRow for enhanced tool options * feat: introduce BadgeRowContext and BadgeRowProvider for managing conversation state, refactor related components to utilize context * feat: implement useMCPSelect hook for managing MCP selection state, refactor MCPSelect component to utilize new hook * feat: enhance BadgeRowContext with MCPSelect and tool toggle functionality, refactor related components to utilize updated context and hooks * refactor: streamline useToolToggle hook by integrating setEphemeralAgent directly into toggle logic and removing redundant setValue function * refactor: consolidate codeApiKeyForm and searchApiKeyForm from CodeInterpreter and WebSearch to utilize new context properties * refactor: update CheckboxButton to support controlled state and enhance ToolsDropdown with permission-based toggles for web search and code interpreter * refactor: conditionally render CheckboxButton in CodeInterpreter and WebSearch components for improved UI responsiveness * chore: add jotai dependency to package.json and package-lock.json * chore: update brace-expansion package to version 2.0.2 in package-lock.json due to CVE-2025-5889 * Revert "chore: add jotai dependency to package.json and package-lock.json" This reverts commit 69b699739671e740820f570ddb83d1caa0dbd6e4. * refactor: add pinning functionality to CodeInterpreter and WebSearch components, and enhance ToolsDropdown with pin toggle for web search and code interpreter * chore: move MCPIcon to correct location, remove duplicate * fix: update MCP import to use type-only import from librechat-data-provider * feat: implement MCPSubMenu component and integrate pinning functionality into ToolsDropdown * fix: cycling to submenu by using parent menu context * feat: add FileSearch component and integrate it into BadgeRow and ToolsDropdown * chore: import order * chore: remove agent specific logic that would block functionality for streamlined endpoints * chore: linting for `createContextHandlers` * chore: ensure ToolsDropdown doesn't show up for agents * chore: ensure tool resource is selected when dragged to UI * chore: update file search behavior to simulate legacy functionality * feat: ToolDialogs with multiple trigger references, add settings to tool dropdown * refactor: simplify web search and code interpreter settings checks * chore: simplify local storage key for pinned state in useToolToggle * refactor: reinstate agent check in AttachFileChat component, as individual providers will ahve different file configurations * ci: increase timeout for MongoDB connection in Agent tests --- api/app/clients/BaseClient.js | 6 +- .../clients/prompts/createContextHandlers.js | 34 +- api/models/Agent.js | 3 + api/models/Agent.spec.js | 14 +- api/server/controllers/AskController.js | 282 --------------- api/server/controllers/EditController.js | 2 +- api/server/index.js | 2 - api/server/middleware/buildEndpointOption.js | 23 +- api/server/routes/ask/addToCache.js | 63 ---- api/server/routes/ask/anthropic.js | 25 -- api/server/routes/ask/custom.js | 25 -- api/server/routes/ask/google.js | 24 -- api/server/routes/ask/gptPlugins.js | 241 ------------- api/server/routes/ask/index.js | 47 --- api/server/routes/ask/openAI.js | 27 -- api/server/routes/bedrock/chat.js | 37 -- api/server/routes/bedrock/index.js | 35 -- api/server/routes/index.js | 4 - api/server/services/Endpoints/agents/agent.js | 18 +- api/server/services/Endpoints/agents/build.js | 12 +- .../services/Endpoints/agents/initialize.js | 25 +- client/src/Providers/BadgeRowContext.tsx | 83 +++++ client/src/Providers/index.ts | 2 + client/src/components/Chat/Input/BadgeRow.tsx | 159 +++++---- .../components/Chat/Input/CodeInterpreter.tsx | 109 +----- .../src/components/Chat/Input/FileSearch.tsx | 28 ++ .../Chat/Input/Files/AttachFileChat.tsx | 23 +- .../Chat/Input/Files/AttachFileMenu.tsx | 19 +- .../Chat/Input/Files/DragDropModal.tsx | 2 +- .../src/components/Chat/Input/MCPSelect.tsx | 123 +------ .../src/components/Chat/Input/MCPSubMenu.tsx | 96 ++++++ .../src/components/Chat/Input/ToolDialogs.tsx | 66 ++++ .../components/Chat/Input/ToolsDropdown.tsx | 322 ++++++++++++++++++ .../src/components/Chat/Input/WebSearch.tsx | 109 +----- .../components/Chat/Menus/Endpoints/utils.ts | 2 +- .../SidePanel/Agents/Code/ApiKeyDialog.tsx | 11 +- .../components/SidePanel/Agents/MCPInput.tsx | 2 +- .../SidePanel/Agents/Search/ApiKeyDialog.tsx | 11 +- client/src/components/svg/MCPIcon.tsx | 32 +- client/src/components/svg/VectorIcon.tsx | 15 + client/src/components/svg/index.ts | 2 + client/src/components/ui/CheckboxButton.tsx | 21 +- client/src/components/ui/MCPIcon.tsx | 31 -- client/src/components/ui/OriginalDialog.tsx | 12 +- client/src/components/ui/index.ts | 1 - client/src/hooks/Files/useDragHelpers.ts | 33 +- client/src/hooks/Nav/useSideNavLinks.ts | 3 +- client/src/hooks/Plugins/index.ts | 2 + client/src/hooks/Plugins/useCodeApiKeyForm.ts | 6 +- client/src/hooks/Plugins/useMCPSelect.ts | 121 +++++++ .../src/hooks/Plugins/useSearchApiKeyForm.ts | 6 +- client/src/hooks/Plugins/useToolToggle.ts | 119 +++++++ client/src/hooks/SSE/useSSE.ts | 6 +- e2e/specs/messages.spec.ts | 4 +- package-lock.json | 41 ++- packages/api/src/utils/index.ts | 1 + packages/api/src/utils/llm.test.ts | 189 ++++++++++ packages/api/src/utils/llm.ts | 47 +++ packages/data-provider/src/api-endpoints.ts | 2 - packages/data-provider/src/config.ts | 20 +- packages/data-provider/src/createPayload.ts | 12 +- packages/data-provider/src/data-service.ts | 8 - .../data-provider/src/parameterSettings.ts | 10 +- packages/data-provider/src/parsers.ts | 6 +- .../src/react-query/react-query-service.ts | 17 - packages/data-provider/src/schemas.ts | 17 - packages/data-provider/src/types.ts | 1 + 67 files changed, 1468 insertions(+), 1433 deletions(-) delete mode 100644 api/server/controllers/AskController.js delete mode 100644 api/server/routes/ask/addToCache.js delete mode 100644 api/server/routes/ask/anthropic.js delete mode 100644 api/server/routes/ask/custom.js delete mode 100644 api/server/routes/ask/google.js delete mode 100644 api/server/routes/ask/gptPlugins.js delete mode 100644 api/server/routes/ask/index.js delete mode 100644 api/server/routes/ask/openAI.js delete mode 100644 api/server/routes/bedrock/chat.js delete mode 100644 api/server/routes/bedrock/index.js create mode 100644 client/src/Providers/BadgeRowContext.tsx create mode 100644 client/src/components/Chat/Input/FileSearch.tsx create mode 100644 client/src/components/Chat/Input/MCPSubMenu.tsx create mode 100644 client/src/components/Chat/Input/ToolDialogs.tsx create mode 100644 client/src/components/Chat/Input/ToolsDropdown.tsx create mode 100644 client/src/components/svg/VectorIcon.tsx delete mode 100644 client/src/components/ui/MCPIcon.tsx create mode 100644 client/src/hooks/Plugins/useMCPSelect.ts create mode 100644 client/src/hooks/Plugins/useToolToggle.ts create mode 100644 packages/api/src/utils/llm.test.ts create mode 100644 packages/api/src/utils/llm.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 55b878018..c8f4228f1 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -792,7 +792,8 @@ class BaseClient { userMessage.tokenCount = userMessageTokenCount; /* - Note: `AskController` saves the user message, so we update the count of its `userMessage` reference + Note: `AgentController` saves the user message if not saved here + (noted by `savedMessageIds`), so we update the count of its `userMessage` reference */ if (typeof opts?.getReqData === 'function') { opts.getReqData({ @@ -801,7 +802,8 @@ class BaseClient { } /* Note: we update the user message to be sure it gets the calculated token count; - though `AskController` saves the user message, EditController does not + though `AgentController` saves the user message if not saved here + (noted by `savedMessageIds`), EditController does not */ await userMessagePromise; await this.updateMessageInDatabase({ diff --git a/api/app/clients/prompts/createContextHandlers.js b/api/app/clients/prompts/createContextHandlers.js index 4dcfaf68e..57847bea3 100644 --- a/api/app/clients/prompts/createContextHandlers.js +++ b/api/app/clients/prompts/createContextHandlers.js @@ -96,35 +96,35 @@ function createContextHandlers(req, userMessageContent) { resolvedQueries.length === 0 ? '\n\tThe semantic search did not return any results.' : resolvedQueries - .map((queryResult, index) => { - const file = processedFiles[index]; - let contextItems = queryResult.data; + .map((queryResult, index) => { + const file = processedFiles[index]; + let contextItems = queryResult.data; - const generateContext = (currentContext) => - ` + const generateContext = (currentContext) => + ` ${file.filename} ${currentContext} `; - if (useFullContext) { - return generateContext(`\n${contextItems}`); - } + if (useFullContext) { + return generateContext(`\n${contextItems}`); + } - contextItems = queryResult.data - .map((item) => { - const pageContent = item[0].page_content; - return ` + contextItems = queryResult.data + .map((item) => { + const pageContent = item[0].page_content; + return ` `; - }) - .join(''); + }) + .join(''); - return generateContext(contextItems); - }) - .join(''); + return generateContext(contextItems); + }) + .join(''); if (useFullContext) { const prompt = `${header} diff --git a/api/models/Agent.js b/api/models/Agent.js index d33ca8a8b..04ba8b020 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -70,6 +70,9 @@ const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _ if (ephemeralAgent?.execute_code === true) { tools.push(Tools.execute_code); } + if (ephemeralAgent?.file_search === true) { + tools.push(Tools.file_search); + } if (ephemeralAgent?.web_search === true) { tools.push(Tools.web_search); } diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js index 0b0646f52..8953ae048 100644 --- a/api/models/Agent.spec.js +++ b/api/models/Agent.spec.js @@ -43,7 +43,7 @@ describe('models/Agent', () => { const mongoUri = mongoServer.getUri(); Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); - }); + }, 20000); afterAll(async () => { await mongoose.disconnect(); @@ -413,7 +413,7 @@ describe('models/Agent', () => { const mongoUri = mongoServer.getUri(); Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); - }); + }, 20000); afterAll(async () => { await mongoose.disconnect(); @@ -670,7 +670,7 @@ describe('models/Agent', () => { const mongoUri = mongoServer.getUri(); Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); - }); + }, 20000); afterAll(async () => { await mongoose.disconnect(); @@ -1332,7 +1332,7 @@ describe('models/Agent', () => { const mongoUri = mongoServer.getUri(); Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); - }); + }, 20000); afterAll(async () => { await mongoose.disconnect(); @@ -1514,7 +1514,7 @@ describe('models/Agent', () => { const mongoUri = mongoServer.getUri(); Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); - }); + }, 20000); afterAll(async () => { await mongoose.disconnect(); @@ -1798,7 +1798,7 @@ describe('models/Agent', () => { const mongoUri = mongoServer.getUri(); Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); - }); + }, 20000); afterAll(async () => { await mongoose.disconnect(); @@ -2350,7 +2350,7 @@ describe('models/Agent', () => { const mongoUri = mongoServer.getUri(); Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); await mongoose.connect(mongoUri); - }); + }, 20000); afterAll(async () => { await mongoose.disconnect(); diff --git a/api/server/controllers/AskController.js b/api/server/controllers/AskController.js deleted file mode 100644 index 40b209ef3..000000000 --- a/api/server/controllers/AskController.js +++ /dev/null @@ -1,282 +0,0 @@ -const { getResponseSender, Constants } = require('librechat-data-provider'); -const { - handleAbortError, - createAbortController, - cleanupAbortController, -} = require('~/server/middleware'); -const { - disposeClient, - processReqData, - clientRegistry, - requestDataMap, -} = require('~/server/cleanup'); -const { sendMessage, createOnProgress } = require('~/server/utils'); -const { saveMessage } = require('~/models'); -const { logger } = require('~/config'); - -const AskController = async (req, res, next, initializeClient, addTitle) => { - let { - text, - endpointOption, - conversationId, - modelDisplayLabel, - parentMessageId = null, - overrideParentMessageId = null, - } = req.body; - - let client = null; - let abortKey = null; - let cleanupHandlers = []; - let clientRef = null; - - logger.debug('[AskController]', { - text, - conversationId, - ...endpointOption, - modelsConfig: endpointOption?.modelsConfig ? 'exists' : '', - }); - - let userMessage = null; - let userMessagePromise = null; - let promptTokens = null; - let userMessageId = null; - let responseMessageId = null; - let getAbortData = null; - - const sender = getResponseSender({ - ...endpointOption, - model: endpointOption.modelOptions.model, - modelDisplayLabel, - }); - const initialConversationId = conversationId; - const newConvo = !initialConversationId; - const userId = req.user.id; - - let reqDataContext = { - userMessage, - userMessagePromise, - responseMessageId, - promptTokens, - conversationId, - userMessageId, - }; - - const updateReqData = (data = {}) => { - reqDataContext = processReqData(data, reqDataContext); - abortKey = reqDataContext.abortKey; - userMessage = reqDataContext.userMessage; - userMessagePromise = reqDataContext.userMessagePromise; - responseMessageId = reqDataContext.responseMessageId; - promptTokens = reqDataContext.promptTokens; - conversationId = reqDataContext.conversationId; - userMessageId = reqDataContext.userMessageId; - }; - - let { onProgress: progressCallback, getPartialText } = createOnProgress(); - - const performCleanup = () => { - logger.debug('[AskController] Performing cleanup'); - if (Array.isArray(cleanupHandlers)) { - for (const handler of cleanupHandlers) { - try { - if (typeof handler === 'function') { - handler(); - } - } catch (e) { - // Ignore - } - } - } - - if (abortKey) { - logger.debug('[AskController] Cleaning up abort controller'); - cleanupAbortController(abortKey); - abortKey = null; - } - - if (client) { - disposeClient(client); - client = null; - } - - reqDataContext = null; - userMessage = null; - userMessagePromise = null; - promptTokens = null; - getAbortData = null; - progressCallback = null; - endpointOption = null; - cleanupHandlers = null; - addTitle = null; - - if (requestDataMap.has(req)) { - requestDataMap.delete(req); - } - logger.debug('[AskController] Cleanup completed'); - }; - - try { - ({ client } = await initializeClient({ req, res, endpointOption })); - if (clientRegistry && client) { - clientRegistry.register(client, { userId }, client); - } - - if (client) { - requestDataMap.set(req, { client }); - } - - clientRef = new WeakRef(client); - - getAbortData = () => { - const currentClient = clientRef?.deref(); - const currentText = - currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText(); - - return { - sender, - conversationId, - messageId: reqDataContext.responseMessageId, - parentMessageId: overrideParentMessageId ?? userMessageId, - text: currentText, - userMessage: userMessage, - userMessagePromise: userMessagePromise, - promptTokens: reqDataContext.promptTokens, - }; - }; - - const { onStart, abortController } = createAbortController( - req, - res, - getAbortData, - updateReqData, - ); - - const closeHandler = () => { - logger.debug('[AskController] Request closed'); - if (!abortController || abortController.signal.aborted || abortController.requestCompleted) { - return; - } - abortController.abort(); - logger.debug('[AskController] Request aborted on close'); - }; - - res.on('close', closeHandler); - cleanupHandlers.push(() => { - try { - res.removeListener('close', closeHandler); - } catch (e) { - // Ignore - } - }); - - const messageOptions = { - user: userId, - parentMessageId, - conversationId: reqDataContext.conversationId, - overrideParentMessageId, - getReqData: updateReqData, - onStart, - abortController, - progressCallback, - progressOptions: { - res, - }, - }; - - /** @type {TMessage} */ - let response = await client.sendMessage(text, messageOptions); - response.endpoint = endpointOption.endpoint; - - const databasePromise = response.databasePromise; - delete response.databasePromise; - - const { conversation: convoData = {} } = await databasePromise; - const conversation = { ...convoData }; - conversation.title = - conversation && !conversation.title ? null : conversation?.title || 'New Chat'; - - const latestUserMessage = reqDataContext.userMessage; - - if (client?.options?.attachments && latestUserMessage) { - latestUserMessage.files = client.options.attachments; - if (endpointOption?.modelOptions?.model) { - conversation.model = endpointOption.modelOptions.model; - } - delete latestUserMessage.image_urls; - } - - if (!abortController.signal.aborted) { - const finalResponseMessage = { ...response }; - - sendMessage(res, { - final: true, - conversation, - title: conversation.title, - requestMessage: latestUserMessage, - responseMessage: finalResponseMessage, - }); - res.end(); - - if (client?.savedMessageIds && !client.savedMessageIds.has(response.messageId)) { - await saveMessage( - req, - { ...finalResponseMessage, user: userId }, - { context: 'api/server/controllers/AskController.js - response end' }, - ); - } - } - - if (!client?.skipSaveUserMessage && latestUserMessage) { - await saveMessage(req, latestUserMessage, { - context: "api/server/controllers/AskController.js - don't skip saving user message", - }); - } - - if (typeof addTitle === 'function' && parentMessageId === Constants.NO_PARENT && newConvo) { - addTitle(req, { - text, - response: { ...response }, - client, - }) - .then(() => { - logger.debug('[AskController] Title generation started'); - }) - .catch((err) => { - logger.error('[AskController] Error in title generation', err); - }) - .finally(() => { - logger.debug('[AskController] Title generation completed'); - performCleanup(); - }); - } else { - performCleanup(); - } - } catch (error) { - logger.error('[AskController] Error handling request', error); - let partialText = ''; - try { - const currentClient = clientRef?.deref(); - partialText = - currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText(); - } catch (getTextError) { - logger.error('[AskController] Error calling getText() during error handling', getTextError); - } - - handleAbortError(res, req, error, { - sender, - partialText, - conversationId: reqDataContext.conversationId, - messageId: reqDataContext.responseMessageId, - parentMessageId: overrideParentMessageId ?? reqDataContext.userMessageId ?? parentMessageId, - userMessageId: reqDataContext.userMessageId, - }) - .catch((err) => { - logger.error('[AskController] Error in `handleAbortError` during catch block', err); - }) - .finally(() => { - performCleanup(); - }); - } -}; - -module.exports = AskController; diff --git a/api/server/controllers/EditController.js b/api/server/controllers/EditController.js index d142d474d..574111abf 100644 --- a/api/server/controllers/EditController.js +++ b/api/server/controllers/EditController.js @@ -84,7 +84,7 @@ const EditController = async (req, res, next, initializeClient) => { } if (abortKey) { - logger.debug('[AskController] Cleaning up abort controller'); + logger.debug('[EditController] Cleaning up abort controller'); cleanupAbortController(abortKey); abortKey = null; } diff --git a/api/server/index.js b/api/server/index.js index 8c7db3e22..ac79a627e 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -97,7 +97,6 @@ const startServer = async () => { app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/user', routes.user); - app.use('/api/ask', routes.ask); app.use('/api/search', routes.search); app.use('/api/edit', routes.edit); app.use('/api/messages', routes.messages); @@ -118,7 +117,6 @@ const startServer = async () => { app.use('/api/roles', routes.roles); app.use('/api/agents', routes.agents); app.use('/api/banner', routes.banner); - app.use('/api/bedrock', routes.bedrock); app.use('/api/memories', routes.memories); app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index 8394223b5..f3138bf6e 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -1,11 +1,11 @@ +const { logger } = require('@librechat/data-schemas'); const { - parseCompactConvo, + EndpointURLs, EModelEndpoint, isAgentsEndpoint, - EndpointURLs, + parseCompactConvo, } = require('librechat-data-provider'); const azureAssistants = require('~/server/services/Endpoints/azureAssistants'); -const { getModelsConfig } = require('~/server/controllers/ModelController'); const assistants = require('~/server/services/Endpoints/assistants'); const gptPlugins = require('~/server/services/Endpoints/gptPlugins'); const { processFiles } = require('~/server/services/Files/process'); @@ -36,6 +36,9 @@ async function buildEndpointOption(req, res, next) { try { parsedBody = parseCompactConvo({ endpoint, endpointType, conversation: req.body }); } catch (error) { + logger.warn( + `Error parsing conversation for endpoint ${endpoint}${error?.message ? `: ${error.message}` : ''}`, + ); return handleError(res, { text: 'Error parsing conversation' }); } @@ -77,6 +80,7 @@ async function buildEndpointOption(req, res, next) { conversation: currentModelSpec.preset, }); } catch (error) { + logger.error(`Error parsing model spec for endpoint ${endpoint}`, error); return handleError(res, { text: 'Error parsing model spec' }); } } @@ -84,20 +88,23 @@ async function buildEndpointOption(req, res, next) { try { const isAgents = isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]); - const endpointFn = buildFunction[isAgents ? EModelEndpoint.agents : (endpointType ?? endpoint)]; - const builder = isAgents ? (...args) => endpointFn(req, ...args) : endpointFn; + const builder = isAgents + ? (...args) => buildFunction[EModelEndpoint.agents](req, ...args) + : buildFunction[endpointType ?? endpoint]; // TODO: use object params req.body.endpointOption = await builder(endpoint, parsedBody, endpointType); - // TODO: use `getModelsConfig` only when necessary - const modelsConfig = await getModelsConfig(req); - req.body.endpointOption.modelsConfig = modelsConfig; if (req.body.files && !isAgents) { req.body.endpointOption.attachments = processFiles(req.body.files); } + next(); } catch (error) { + logger.error( + `Error building endpoint option for endpoint ${endpoint} with type ${endpointType}`, + error, + ); return handleError(res, { text: 'Error building endpoint option' }); } } diff --git a/api/server/routes/ask/addToCache.js b/api/server/routes/ask/addToCache.js deleted file mode 100644 index a2f427098..000000000 --- a/api/server/routes/ask/addToCache.js +++ /dev/null @@ -1,63 +0,0 @@ -const { Keyv } = require('keyv'); -const { KeyvFile } = require('keyv-file'); -const { logger } = require('~/config'); - -const addToCache = async ({ endpoint, endpointOption, userMessage, responseMessage }) => { - try { - const conversationsCache = new Keyv({ - store: new KeyvFile({ filename: './data/cache.json' }), - namespace: 'chatgpt', // should be 'bing' for bing/sydney - }); - - const { - conversationId, - messageId: userMessageId, - parentMessageId: userParentMessageId, - text: userText, - } = userMessage; - const { - messageId: responseMessageId, - parentMessageId: responseParentMessageId, - text: responseText, - } = responseMessage; - - let conversation = await conversationsCache.get(conversationId); - // used to generate a title for the conversation if none exists - // let isNewConversation = false; - if (!conversation) { - conversation = { - messages: [], - createdAt: Date.now(), - }; - // isNewConversation = true; - } - - const roles = (options) => { - if (endpoint === 'openAI') { - return options?.chatGptLabel || 'ChatGPT'; - } - }; - - let _userMessage = { - id: userMessageId, - parentMessageId: userParentMessageId, - role: 'User', - message: userText, - }; - - let _responseMessage = { - id: responseMessageId, - parentMessageId: responseParentMessageId, - role: roles(endpointOption), - message: responseText, - }; - - conversation.messages.push(_userMessage, _responseMessage); - - await conversationsCache.set(conversationId, conversation); - } catch (error) { - logger.error('[addToCache] Error adding conversation to cache', error); - } -}; - -module.exports = addToCache; diff --git a/api/server/routes/ask/anthropic.js b/api/server/routes/ask/anthropic.js deleted file mode 100644 index afe1720d8..000000000 --- a/api/server/routes/ask/anthropic.js +++ /dev/null @@ -1,25 +0,0 @@ -const express = require('express'); -const AskController = require('~/server/controllers/AskController'); -const { addTitle, initializeClient } = require('~/server/services/Endpoints/anthropic'); -const { - setHeaders, - handleAbort, - validateModel, - validateEndpoint, - buildEndpointOption, -} = require('~/server/middleware'); - -const router = express.Router(); - -router.post( - '/', - validateEndpoint, - validateModel, - buildEndpointOption, - setHeaders, - async (req, res, next) => { - await AskController(req, res, next, initializeClient, addTitle); - }, -); - -module.exports = router; diff --git a/api/server/routes/ask/custom.js b/api/server/routes/ask/custom.js deleted file mode 100644 index 8fc343cf1..000000000 --- a/api/server/routes/ask/custom.js +++ /dev/null @@ -1,25 +0,0 @@ -const express = require('express'); -const AskController = require('~/server/controllers/AskController'); -const { initializeClient } = require('~/server/services/Endpoints/custom'); -const { addTitle } = require('~/server/services/Endpoints/openAI'); -const { - setHeaders, - validateModel, - validateEndpoint, - buildEndpointOption, -} = require('~/server/middleware'); - -const router = express.Router(); - -router.post( - '/', - validateEndpoint, - validateModel, - buildEndpointOption, - setHeaders, - async (req, res, next) => { - await AskController(req, res, next, initializeClient, addTitle); - }, -); - -module.exports = router; diff --git a/api/server/routes/ask/google.js b/api/server/routes/ask/google.js deleted file mode 100644 index 16c7e265f..000000000 --- a/api/server/routes/ask/google.js +++ /dev/null @@ -1,24 +0,0 @@ -const express = require('express'); -const AskController = require('~/server/controllers/AskController'); -const { initializeClient, addTitle } = require('~/server/services/Endpoints/google'); -const { - setHeaders, - validateModel, - validateEndpoint, - buildEndpointOption, -} = require('~/server/middleware'); - -const router = express.Router(); - -router.post( - '/', - validateEndpoint, - validateModel, - buildEndpointOption, - setHeaders, - async (req, res, next) => { - await AskController(req, res, next, initializeClient, addTitle); - }, -); - -module.exports = router; diff --git a/api/server/routes/ask/gptPlugins.js b/api/server/routes/ask/gptPlugins.js deleted file mode 100644 index a40022848..000000000 --- a/api/server/routes/ask/gptPlugins.js +++ /dev/null @@ -1,241 +0,0 @@ -const express = require('express'); -const { getResponseSender, Constants } = require('librechat-data-provider'); -const { initializeClient } = require('~/server/services/Endpoints/gptPlugins'); -const { sendMessage, createOnProgress } = require('~/server/utils'); -const { addTitle } = require('~/server/services/Endpoints/openAI'); -const { saveMessage, updateMessage } = require('~/models'); -const { - handleAbort, - createAbortController, - handleAbortError, - setHeaders, - validateModel, - validateEndpoint, - buildEndpointOption, - moderateText, -} = require('~/server/middleware'); -const { validateTools } = require('~/app'); -const { logger } = require('~/config'); - -const router = express.Router(); - -router.use(moderateText); - -router.post( - '/', - validateEndpoint, - validateModel, - buildEndpointOption, - setHeaders, - async (req, res) => { - let { - text, - endpointOption, - conversationId, - parentMessageId = null, - overrideParentMessageId = null, - } = req.body; - - logger.debug('[/ask/gptPlugins]', { text, conversationId, ...endpointOption }); - - let userMessage; - let userMessagePromise; - let promptTokens; - let userMessageId; - let responseMessageId; - const sender = getResponseSender({ - ...endpointOption, - model: endpointOption.modelOptions.model, - }); - const newConvo = !conversationId; - const user = req.user.id; - - const plugins = []; - - const getReqData = (data = {}) => { - for (let key in data) { - if (key === 'userMessage') { - userMessage = data[key]; - userMessageId = data[key].messageId; - } else if (key === 'userMessagePromise') { - userMessagePromise = data[key]; - } else if (key === 'responseMessageId') { - responseMessageId = data[key]; - } else if (key === 'promptTokens') { - promptTokens = data[key]; - } else if (!conversationId && key === 'conversationId') { - conversationId = data[key]; - } - } - }; - - let streaming = null; - let timer = null; - - const { - onProgress: progressCallback, - sendIntermediateMessage, - getPartialText, - } = createOnProgress({ - onProgress: () => { - if (timer) { - clearTimeout(timer); - } - - streaming = new Promise((resolve) => { - timer = setTimeout(() => { - resolve(); - }, 250); - }); - }, - }); - - const pluginMap = new Map(); - const onAgentAction = async (action, runId) => { - pluginMap.set(runId, action.tool); - sendIntermediateMessage(res, { - plugins, - parentMessageId: userMessage.messageId, - messageId: responseMessageId, - }); - }; - - const onToolStart = async (tool, input, runId, parentRunId) => { - const pluginName = pluginMap.get(parentRunId); - const latestPlugin = { - runId, - loading: true, - inputs: [input], - latest: pluginName, - outputs: null, - }; - - if (streaming) { - await streaming; - } - const extraTokens = ':::plugin:::\n'; - plugins.push(latestPlugin); - sendIntermediateMessage( - res, - { plugins, parentMessageId: userMessage.messageId, messageId: responseMessageId }, - extraTokens, - ); - }; - - const onToolEnd = async (output, runId) => { - if (streaming) { - await streaming; - } - - const pluginIndex = plugins.findIndex((plugin) => plugin.runId === runId); - - if (pluginIndex !== -1) { - plugins[pluginIndex].loading = false; - plugins[pluginIndex].outputs = output; - } - }; - - const getAbortData = () => ({ - sender, - conversationId, - userMessagePromise, - messageId: responseMessageId, - parentMessageId: overrideParentMessageId ?? userMessageId, - text: getPartialText(), - plugins: plugins.map((p) => ({ ...p, loading: false })), - userMessage, - promptTokens, - }); - const { abortController, onStart } = createAbortController(req, res, getAbortData, getReqData); - - try { - endpointOption.tools = await validateTools(user, endpointOption.tools); - const { client } = await initializeClient({ req, res, endpointOption }); - - const onChainEnd = () => { - if (!client.skipSaveUserMessage) { - saveMessage( - req, - { ...userMessage, user }, - { context: 'api/server/routes/ask/gptPlugins.js - onChainEnd' }, - ); - } - sendIntermediateMessage(res, { - plugins, - parentMessageId: userMessage.messageId, - messageId: responseMessageId, - }); - }; - - let response = await client.sendMessage(text, { - user, - conversationId, - parentMessageId, - overrideParentMessageId, - getReqData, - onAgentAction, - onChainEnd, - onToolStart, - onToolEnd, - onStart, - getPartialText, - ...endpointOption, - progressCallback, - progressOptions: { - res, - // parentMessageId: overrideParentMessageId || userMessageId, - plugins, - }, - abortController, - }); - - if (overrideParentMessageId) { - response.parentMessageId = overrideParentMessageId; - } - - logger.debug('[/ask/gptPlugins]', response); - - const { conversation = {} } = await response.databasePromise; - delete response.databasePromise; - conversation.title = - conversation && !conversation.title ? null : conversation?.title || 'New Chat'; - - sendMessage(res, { - title: conversation.title, - final: true, - conversation, - requestMessage: userMessage, - responseMessage: response, - }); - res.end(); - - if (parentMessageId === Constants.NO_PARENT && newConvo) { - addTitle(req, { - text, - response, - client, - }); - } - - response.plugins = plugins.map((p) => ({ ...p, loading: false })); - if (response.plugins?.length > 0) { - await updateMessage( - req, - { ...response, user }, - { context: 'api/server/routes/ask/gptPlugins.js - save plugins used' }, - ); - } - } catch (error) { - const partialText = getPartialText(); - handleAbortError(res, req, error, { - partialText, - conversationId, - sender, - messageId: responseMessageId, - parentMessageId: userMessageId ?? parentMessageId, - }); - } - }, -); - -module.exports = router; diff --git a/api/server/routes/ask/index.js b/api/server/routes/ask/index.js deleted file mode 100644 index 525bd8e29..000000000 --- a/api/server/routes/ask/index.js +++ /dev/null @@ -1,47 +0,0 @@ -const express = require('express'); -const { EModelEndpoint } = require('librechat-data-provider'); -const { - uaParser, - checkBan, - requireJwtAuth, - messageIpLimiter, - concurrentLimiter, - messageUserLimiter, - validateConvoAccess, -} = require('~/server/middleware'); -const { isEnabled } = require('~/server/utils'); -const gptPlugins = require('./gptPlugins'); -const anthropic = require('./anthropic'); -const custom = require('./custom'); -const google = require('./google'); -const openAI = require('./openAI'); - -const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {}; - -const router = express.Router(); - -router.use(requireJwtAuth); -router.use(checkBan); -router.use(uaParser); - -if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) { - router.use(concurrentLimiter); -} - -if (isEnabled(LIMIT_MESSAGE_IP)) { - router.use(messageIpLimiter); -} - -if (isEnabled(LIMIT_MESSAGE_USER)) { - router.use(messageUserLimiter); -} - -router.use(validateConvoAccess); - -router.use([`/${EModelEndpoint.azureOpenAI}`, `/${EModelEndpoint.openAI}`], openAI); -router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins); -router.use(`/${EModelEndpoint.anthropic}`, anthropic); -router.use(`/${EModelEndpoint.google}`, google); -router.use(`/${EModelEndpoint.custom}`, custom); - -module.exports = router; diff --git a/api/server/routes/ask/openAI.js b/api/server/routes/ask/openAI.js deleted file mode 100644 index dadf00def..000000000 --- a/api/server/routes/ask/openAI.js +++ /dev/null @@ -1,27 +0,0 @@ -const express = require('express'); -const AskController = require('~/server/controllers/AskController'); -const { addTitle, initializeClient } = require('~/server/services/Endpoints/openAI'); -const { - handleAbort, - setHeaders, - validateModel, - validateEndpoint, - buildEndpointOption, - moderateText, -} = require('~/server/middleware'); - -const router = express.Router(); -router.use(moderateText); - -router.post( - '/', - validateEndpoint, - validateModel, - buildEndpointOption, - setHeaders, - async (req, res, next) => { - await AskController(req, res, next, initializeClient, addTitle); - }, -); - -module.exports = router; diff --git a/api/server/routes/bedrock/chat.js b/api/server/routes/bedrock/chat.js deleted file mode 100644 index 263ca9600..000000000 --- a/api/server/routes/bedrock/chat.js +++ /dev/null @@ -1,37 +0,0 @@ -const express = require('express'); - -const router = express.Router(); -const { - setHeaders, - handleAbort, - moderateText, - // validateModel, - // validateEndpoint, - buildEndpointOption, -} = require('~/server/middleware'); -const { initializeClient } = require('~/server/services/Endpoints/bedrock'); -const AgentController = require('~/server/controllers/agents/request'); -const addTitle = require('~/server/services/Endpoints/agents/title'); - -router.use(moderateText); - -/** - * @route POST / - * @desc Chat with an assistant - * @access Public - * @param {express.Request} req - The request object, containing the request data. - * @param {express.Response} res - The response object, used to send back a response. - * @returns {void} - */ -router.post( - '/', - // validateModel, - // validateEndpoint, - buildEndpointOption, - setHeaders, - async (req, res, next) => { - await AgentController(req, res, next, initializeClient, addTitle); - }, -); - -module.exports = router; diff --git a/api/server/routes/bedrock/index.js b/api/server/routes/bedrock/index.js deleted file mode 100644 index ce440a7c0..000000000 --- a/api/server/routes/bedrock/index.js +++ /dev/null @@ -1,35 +0,0 @@ -const express = require('express'); -const { - uaParser, - checkBan, - requireJwtAuth, - messageIpLimiter, - concurrentLimiter, - messageUserLimiter, -} = require('~/server/middleware'); -const { isEnabled } = require('~/server/utils'); -const chat = require('./chat'); - -const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {}; - -const router = express.Router(); - -router.use(requireJwtAuth); -router.use(checkBan); -router.use(uaParser); - -if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) { - router.use(concurrentLimiter); -} - -if (isEnabled(LIMIT_MESSAGE_IP)) { - router.use(messageIpLimiter); -} - -if (isEnabled(LIMIT_MESSAGE_USER)) { - router.use(messageUserLimiter); -} - -router.use('/chat', chat); - -module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 7c1b5de0f..ec97ba398 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -9,7 +9,6 @@ const presets = require('./presets'); const prompts = require('./prompts'); const balance = require('./balance'); const plugins = require('./plugins'); -const bedrock = require('./bedrock'); const actions = require('./actions'); const banner = require('./banner'); const search = require('./search'); @@ -26,11 +25,9 @@ const auth = require('./auth'); const edit = require('./edit'); const keys = require('./keys'); const user = require('./user'); -const ask = require('./ask'); const mcp = require('./mcp'); module.exports = { - ask, edit, auth, keys, @@ -46,7 +43,6 @@ module.exports = { search, config, models, - bedrock, prompts, plugins, actions, diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js index e13540146..506670eca 100644 --- a/api/server/services/Endpoints/agents/agent.js +++ b/api/server/services/Endpoints/agents/agent.js @@ -1,5 +1,9 @@ const { Providers } = require('@librechat/agents'); -const { primeResources, optionalChainWithEmptyCheck } = require('@librechat/api'); +const { + primeResources, + extractLibreChatParams, + optionalChainWithEmptyCheck, +} = require('@librechat/api'); const { ErrorTypes, EModelEndpoint, @@ -15,10 +19,9 @@ const initGoogle = require('~/server/services/Endpoints/google/initialize'); const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); const { getCustomEndpointConfig } = require('~/server/services/Config'); const { processFiles } = require('~/server/services/Files/process'); +const { getFiles, getToolFilesByIds } = require('~/models/File'); const { getConvoFiles } = require('~/models/Conversation'); -const { getToolFilesByIds } = require('~/models/File'); const { getModelMaxTokens } = require('~/utils'); -const { getFiles } = require('~/models/File'); const providerConfigMap = { [Providers.XAI]: initCustom, @@ -71,7 +74,7 @@ const initializeAgent = async ({ ), ); - const { resendFiles = true, ...modelOptions } = _modelOptions; + const { resendFiles, maxContextTokens, modelOptions } = extractLibreChatParams(_modelOptions); if (isInitialAgent && conversationId != null && resendFiles) { const fileIds = (await getConvoFiles(conversationId)) ?? []; @@ -145,9 +148,8 @@ const initializeAgent = async ({ modelOptions.maxTokens, 0, ); - const maxContextTokens = optionalChainWithEmptyCheck( - modelOptions.maxContextTokens, - modelOptions.max_context_tokens, + const agentMaxContextTokens = optionalChainWithEmptyCheck( + maxContextTokens, getModelMaxTokens(tokensModel, providerEndpointMap[provider]), 4096, ); @@ -189,7 +191,7 @@ const initializeAgent = async ({ attachments, resendFiles, toolContextMap, - maxContextTokens: (maxContextTokens - maxTokens) * 0.9, + maxContextTokens: (agentMaxContextTokens - maxTokens) * 0.9, }; }; diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js index 77ebbc58d..143dde945 100644 --- a/api/server/services/Endpoints/agents/build.js +++ b/api/server/services/Endpoints/agents/build.js @@ -1,10 +1,9 @@ -const { isAgentsEndpoint, Constants } = require('librechat-data-provider'); +const { isAgentsEndpoint, removeNullishValues, Constants } = require('librechat-data-provider'); const { loadAgent } = require('~/models/Agent'); const { logger } = require('~/config'); const buildOptions = (req, endpoint, parsedBody, endpointType) => { - const { spec, iconURL, agent_id, instructions, maxContextTokens, ...model_parameters } = - parsedBody; + const { spec, iconURL, agent_id, instructions, ...model_parameters } = parsedBody; const agentPromise = loadAgent({ req, agent_id: isAgentsEndpoint(endpoint) ? agent_id : Constants.EPHEMERAL_AGENT_ID, @@ -15,19 +14,16 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => { return undefined; }); - const endpointOption = { + return removeNullishValues({ spec, iconURL, endpoint, agent_id, endpointType, instructions, - maxContextTokens, model_parameters, agent: agentPromise, - }; - - return endpointOption; + }); }; module.exports = { buildOptions }; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index e4ffcf473..94af3bdd3 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,11 +1,17 @@ const { logger } = require('@librechat/data-schemas'); const { createContentAggregator } = require('@librechat/agents'); -const { Constants, EModelEndpoint, getResponseSender } = require('librechat-data-provider'); const { - getDefaultHandlers, + Constants, + EModelEndpoint, + isAgentsEndpoint, + getResponseSender, +} = require('librechat-data-provider'); +const { createToolEndCallback, + getDefaultHandlers, } = require('~/server/controllers/agents/callbacks'); const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); +const { getCustomEndpointConfig } = require('~/server/services/Config'); const { loadAgentTools } = require('~/server/services/ToolService'); const AgentClient = require('~/server/controllers/agents/client'); const { getAgent } = require('~/models/Agent'); @@ -61,6 +67,7 @@ const initializeClient = async ({ req, res, endpointOption }) => { } const primaryAgent = await endpointOption.agent; + delete endpointOption.agent; if (!primaryAgent) { throw new Error('Agent not found'); } @@ -108,11 +115,25 @@ const initializeClient = async ({ req, res, endpointOption }) => { } } + let endpointConfig = req.app.locals[primaryConfig.endpoint]; + if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) { + try { + endpointConfig = await getCustomEndpointConfig(primaryConfig.endpoint); + } catch (err) { + logger.error( + '[api/server/controllers/agents/client.js #titleConvo] Error getting custom endpoint config', + err, + ); + } + } + const sender = primaryAgent.name ?? getResponseSender({ ...endpointOption, model: endpointOption.model_parameters.model, + modelDisplayLabel: endpointConfig?.modelDisplayLabel, + modelLabel: endpointOption.model_parameters.modelLabel, }); const client = new AgentClient({ diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx new file mode 100644 index 000000000..860c59da4 --- /dev/null +++ b/client/src/Providers/BadgeRowContext.tsx @@ -0,0 +1,83 @@ +import React, { createContext, useContext } from 'react'; +import { Tools, LocalStorageKeys } from 'librechat-data-provider'; +import { useMCPSelect, useToolToggle, useCodeApiKeyForm, useSearchApiKeyForm } from '~/hooks'; + +interface BadgeRowContextType { + conversationId?: string | null; + mcpSelect: ReturnType; + webSearch: ReturnType; + codeInterpreter: ReturnType; + fileSearch: ReturnType; + codeApiKeyForm: ReturnType; + searchApiKeyForm: ReturnType; +} + +const BadgeRowContext = createContext(undefined); + +export function useBadgeRowContext() { + const context = useContext(BadgeRowContext); + if (context === undefined) { + throw new Error('useBadgeRowContext must be used within a BadgeRowProvider'); + } + return context; +} + +interface BadgeRowProviderProps { + children: React.ReactNode; + conversationId?: string | null; +} + +export default function BadgeRowProvider({ children, conversationId }: BadgeRowProviderProps) { + /** MCPSelect hook */ + const mcpSelect = useMCPSelect({ conversationId }); + + /** CodeInterpreter hooks */ + const codeApiKeyForm = useCodeApiKeyForm({}); + const { setIsDialogOpen: setCodeDialogOpen } = codeApiKeyForm; + + const codeInterpreter = useToolToggle({ + conversationId, + setIsDialogOpen: setCodeDialogOpen, + toolKey: Tools.execute_code, + localStorageKey: LocalStorageKeys.LAST_CODE_TOGGLE_, + authConfig: { + toolId: Tools.execute_code, + queryOptions: { retry: 1 }, + }, + }); + + /** WebSearch hooks */ + const searchApiKeyForm = useSearchApiKeyForm({}); + const { setIsDialogOpen: setWebSearchDialogOpen } = searchApiKeyForm; + + const webSearch = useToolToggle({ + conversationId, + toolKey: Tools.web_search, + localStorageKey: LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_, + setIsDialogOpen: setWebSearchDialogOpen, + authConfig: { + toolId: Tools.web_search, + queryOptions: { retry: 1 }, + }, + }); + + /** FileSearch hook */ + const fileSearch = useToolToggle({ + conversationId, + toolKey: Tools.file_search, + localStorageKey: LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_, + isAuthenticated: true, + }); + + const value: BadgeRowContextType = { + mcpSelect, + webSearch, + fileSearch, + conversationId, + codeApiKeyForm, + codeInterpreter, + searchApiKeyForm, + }; + + return {children}; +} diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 41c9cdceb..8809532b4 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -22,3 +22,5 @@ export * from './CodeBlockContext'; export * from './ToolCallsMapContext'; export * from './SetConvoContext'; export * from './SearchContext'; +export * from './BadgeRowContext'; +export { default as BadgeRowProvider } from './BadgeRowContext'; diff --git a/client/src/components/Chat/Input/BadgeRow.tsx b/client/src/components/Chat/Input/BadgeRow.tsx index ed9f4b82c..14f98b452 100644 --- a/client/src/components/Chat/Input/BadgeRow.tsx +++ b/client/src/components/Chat/Input/BadgeRow.tsx @@ -1,19 +1,23 @@ import React, { memo, - useState, useRef, - useEffect, - useCallback, useMemo, + useState, + useEffect, forwardRef, useReducer, + useCallback, } from 'react'; import { useRecoilValue, useRecoilCallback } from 'recoil'; import type { LucideIcon } from 'lucide-react'; import CodeInterpreter from './CodeInterpreter'; +import { BadgeRowProvider } from '~/Providers'; +import ToolsDropdown from './ToolsDropdown'; import type { BadgeItem } from '~/common'; import { useChatBadges } from '~/hooks'; import { Badge } from '~/components/ui'; +import ToolDialogs from './ToolDialogs'; +import FileSearch from './FileSearch'; import MCPSelect from './MCPSelect'; import WebSearch from './WebSearch'; import store from '~/store'; @@ -313,78 +317,83 @@ function BadgeRow({ }, [dragState.draggedBadge, handleMouseMove, handleMouseUp]); return ( -
- {tempBadges.map((badge, index) => ( - - {dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && ( -
- -
- )} - -
- ))} - {dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && ( -
- -
- )} - {showEphemeralBadges === true && ( - <> - - - - - )} - {ghostBadge && ( -
- -
- )} -
+ +
+ {showEphemeralBadges === true && } + {tempBadges.map((badge, index) => ( + + {dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && ( +
+ +
+ )} + +
+ ))} + {dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && ( +
+ +
+ )} + {showEphemeralBadges === true && ( + <> + + + + + + )} + {ghostBadge && ( +
+ +
+ )} +
+ +
); } diff --git a/client/src/components/Chat/Input/CodeInterpreter.tsx b/client/src/components/Chat/Input/CodeInterpreter.tsx index 411f1e27b..f2d9760cc 100644 --- a/client/src/components/Chat/Input/CodeInterpreter.tsx +++ b/client/src/components/Chat/Input/CodeInterpreter.tsx @@ -1,122 +1,37 @@ -import debounce from 'lodash/debounce'; -import React, { memo, useMemo, useCallback, useRef } from 'react'; -import { useRecoilState } from 'recoil'; +import React, { memo } from 'react'; import { TerminalSquareIcon } from 'lucide-react'; -import { - Tools, - AuthType, - Constants, - LocalStorageKeys, - PermissionTypes, - Permissions, -} from 'librechat-data-provider'; -import ApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog'; -import { useLocalize, useHasAccess, useCodeApiKeyForm } from '~/hooks'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; import CheckboxButton from '~/components/ui/CheckboxButton'; -import useLocalStorage from '~/hooks/useLocalStorageAlt'; -import { useVerifyAgentToolAuth } from '~/data-provider'; -import { ephemeralAgentByConvoId } from '~/store'; +import { useLocalize, useHasAccess } from '~/hooks'; +import { useBadgeRowContext } from '~/Providers'; -const storageCondition = (value: unknown, rawCurrentValue?: string | null) => { - if (rawCurrentValue) { - try { - const currentValue = rawCurrentValue?.trim() ?? ''; - if (currentValue === 'true' && value === false) { - return true; - } - } catch (e) { - console.error(e); - } - } - return value !== undefined && value !== null && value !== '' && value !== false; -}; - -function CodeInterpreter({ conversationId }: { conversationId?: string | null }) { - const triggerRef = useRef(null); +function CodeInterpreter() { const localize = useLocalize(); - const key = conversationId ?? Constants.NEW_CONVO; + const { codeInterpreter, codeApiKeyForm } = useBadgeRowContext(); + const { toggleState: runCode, debouncedChange, isPinned } = codeInterpreter; + const { badgeTriggerRef } = codeApiKeyForm; const canRunCode = useHasAccess({ permissionType: PermissionTypes.RUN_CODE, permission: Permissions.USE, }); - const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key)); - const isCodeToggleEnabled = useMemo(() => { - return ephemeralAgent?.execute_code ?? false; - }, [ephemeralAgent?.execute_code]); - - const { data } = useVerifyAgentToolAuth( - { toolId: Tools.execute_code }, - { - retry: 1, - }, - ); - const authType = useMemo(() => data?.message ?? false, [data?.message]); - const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]); - const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } = - useCodeApiKeyForm({}); - - const setValue = useCallback( - (isChecked: boolean) => { - setEphemeralAgent((prev) => ({ - ...prev, - execute_code: isChecked, - })); - }, - [setEphemeralAgent], - ); - - const [runCode, setRunCode] = useLocalStorage( - `${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`, - isCodeToggleEnabled, - setValue, - storageCondition, - ); - - const handleChange = useCallback( - (e: React.ChangeEvent, isChecked: boolean) => { - if (!isAuthenticated) { - setIsDialogOpen(true); - e.preventDefault(); - return; - } - setRunCode(isChecked); - }, - [setRunCode, setIsDialogOpen, isAuthenticated], - ); - - const debouncedChange = useMemo( - () => debounce(handleChange, 50, { leading: true }), - [handleChange], - ); if (!canRunCode) { return null; } return ( - <> + (runCode || isPinned) && ( } /> - - + ) ); } diff --git a/client/src/components/Chat/Input/FileSearch.tsx b/client/src/components/Chat/Input/FileSearch.tsx new file mode 100644 index 000000000..a4952d1fd --- /dev/null +++ b/client/src/components/Chat/Input/FileSearch.tsx @@ -0,0 +1,28 @@ +import React, { memo } from 'react'; +import CheckboxButton from '~/components/ui/CheckboxButton'; +import { useBadgeRowContext } from '~/Providers'; +import { VectorIcon } from '~/components/svg'; +import { useLocalize } from '~/hooks'; + +function FileSearch() { + const localize = useLocalize(); + const { fileSearch } = useBadgeRowContext(); + const { toggleState: fileSearchEnabled, debouncedChange, isPinned } = fileSearch; + + return ( + <> + {(fileSearchEnabled || isPinned) && ( + } + /> + )} + + ); +} + +export default memo(FileSearch); diff --git a/client/src/components/Chat/Input/Files/AttachFileChat.tsx b/client/src/components/Chat/Input/Files/AttachFileChat.tsx index 11bca082f..746c3d9c1 100644 --- a/client/src/components/Chat/Input/Files/AttachFileChat.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileChat.tsx @@ -1,31 +1,21 @@ import { memo, useMemo } from 'react'; -import { useRecoilValue } from 'recoil'; import { Constants, supportsFiles, mergeFileConfig, isAgentsEndpoint, - isEphemeralAgent, EndpointFileConfig, fileConfig as defaultFileConfig, } from 'librechat-data-provider'; -import { useChatContext } from '~/Providers'; import { useGetFileConfig } from '~/data-provider'; -import { ephemeralAgentByConvoId } from '~/store'; import AttachFileMenu from './AttachFileMenu'; -import AttachFile from './AttachFile'; +import { useChatContext } from '~/Providers'; function AttachFileChat({ disableInputs }: { disableInputs: boolean }) { const { conversation } = useChatContext(); - + const conversationId = conversation?.conversationId ?? Constants.NEW_CONVO; const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null }; - - const key = conversation?.conversationId ?? Constants.NEW_CONVO; - const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(key)); - const isAgents = useMemo( - () => isAgentsEndpoint(_endpoint) || isEphemeralAgent(_endpoint, ephemeralAgent), - [_endpoint, ephemeralAgent], - ); + const isAgents = useMemo(() => isAgentsEndpoint(_endpoint), [_endpoint]); const { data: fileConfig = defaultFileConfig } = useGetFileConfig({ select: (data) => mergeFileConfig(data), @@ -38,11 +28,8 @@ function AttachFileChat({ disableInputs }: { disableInputs: boolean }) { const endpointSupportsFiles: boolean = supportsFiles[endpointType ?? _endpoint ?? ''] ?? false; const isUploadDisabled = (disableInputs || endpointFileConfig?.disabled) ?? false; - if (isAgents) { - return ; - } - if (endpointSupportsFiles && !isUploadDisabled) { - return ; + if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) { + return ; } return null; diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 85df07f24..2bffa4f50 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -1,21 +1,25 @@ +import { useSetRecoilState } from 'recoil'; import * as Ariakit from '@ariakit/react'; import React, { useRef, useState, useMemo } from 'react'; import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react'; -import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider'; import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '~/components'; +import { EToolResources, EModelEndpoint } from 'librechat-data-provider'; import { useGetEndpointsQuery } from '~/data-provider'; import { useLocalize, useFileHandling } from '~/hooks'; +import { ephemeralAgentByConvoId } from '~/store'; import { cn } from '~/utils'; -interface AttachFileProps { +interface AttachFileMenuProps { + conversationId: string; disabled?: boolean | null; } -const AttachFile = ({ disabled }: AttachFileProps) => { +const AttachFileMenu = ({ disabled, conversationId }: AttachFileMenuProps) => { const localize = useLocalize(); const isUploadDisabled = disabled ?? false; const inputRef = useRef(null); const [isPopoverActive, setIsPopoverActive] = useState(false); + const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId)); const [toolResource, setToolResource] = useState(); const { data: endpointsConfig } = useGetEndpointsQuery(); const { handleFileChange } = useFileHandling({ @@ -69,6 +73,7 @@ const AttachFile = ({ disabled }: AttachFileProps) => { label: localize('com_ui_upload_file_search'), onClick: () => { setToolResource(EToolResources.file_search); + /** File search is not automatically enabled to simulate legacy behavior */ handleUploadClick(); }, icon: , @@ -80,6 +85,10 @@ const AttachFile = ({ disabled }: AttachFileProps) => { label: localize('com_ui_upload_code_files'), onClick: () => { setToolResource(EToolResources.execute_code); + setEphemeralAgent((prev) => ({ + ...prev, + [EToolResources.execute_code]: true, + })); handleUploadClick(); }, icon: , @@ -87,7 +96,7 @@ const AttachFile = ({ disabled }: AttachFileProps) => { } return items; - }, [capabilities, localize, setToolResource]); + }, [capabilities, localize, setToolResource, setEphemeralAgent]); const menuTrigger = ( { ); }; -export default React.memo(AttachFile); +export default React.memo(AttachFileMenu); diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index 784116dc6..5606b4d30 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -7,7 +7,7 @@ import useLocalize from '~/hooks/useLocalize'; import { OGDialog } from '~/components/ui'; interface DragDropModalProps { - onOptionSelect: (option: string | undefined) => void; + onOptionSelect: (option: EToolResources | undefined) => void; files: File[]; isVisible: boolean; setShowModal: (showModal: boolean) => void; diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx index ebe56c802..13c1a4a26 100644 --- a/client/src/components/Chat/Input/MCPSelect.tsx +++ b/client/src/components/Chat/Input/MCPSelect.tsx @@ -1,75 +1,29 @@ -import React, { memo, useRef, useMemo, useEffect, useCallback, useState } from 'react'; -import { useRecoilState } from 'recoil'; -import { Settings2 } from 'lucide-react'; +import React, { memo, useCallback, useState } from 'react'; +import { SettingsIcon } from 'lucide-react'; +import { Constants } from 'librechat-data-provider'; import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; -import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider'; -import type { TPlugin, TPluginAuthConfig, TUpdateUserPlugins } from 'librechat-data-provider'; +import type { TUpdateUserPlugins } from 'librechat-data-provider'; +import type { McpServerInfo } from '~/hooks/Plugins/useMCPSelect'; import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog'; -import { useAvailableToolsQuery } from '~/data-provider'; -import useLocalStorage from '~/hooks/useLocalStorageAlt'; +import { useToastContext, useBadgeRowContext } from '~/Providers'; import MultiSelect from '~/components/ui/MultiSelect'; -import { ephemeralAgentByConvoId } from '~/store'; -import { useToastContext } from '~/Providers'; -import MCPIcon from '~/components/ui/MCPIcon'; +import { MCPIcon } from '~/components/svg'; import { useLocalize } from '~/hooks'; -interface McpServerInfo { - name: string; - pluginKey: string; - authConfig?: TPluginAuthConfig[]; - authenticated?: boolean; -} - -// Helper function to extract mcp_serverName from a full pluginKey like action_mcp_serverName const getBaseMCPPluginKey = (fullPluginKey: string): string => { const parts = fullPluginKey.split(Constants.mcp_delimiter); return Constants.mcp_prefix + parts[parts.length - 1]; }; -const storageCondition = (value: unknown, rawCurrentValue?: string | null) => { - if (rawCurrentValue) { - try { - const currentValue = rawCurrentValue?.trim() ?? ''; - if (currentValue.length > 2) { - return true; - } - } catch (e) { - console.error(e); - } - } - return Array.isArray(value) && value.length > 0; -}; - -function MCPSelect({ conversationId }: { conversationId?: string | null }) { +function MCPSelect() { const localize = useLocalize(); const { showToast } = useToastContext(); - const key = conversationId ?? Constants.NEW_CONVO; - const hasSetFetched = useRef(null); + const { mcpSelect } = useBadgeRowContext(); + const { mcpValues, setMCPValues, mcpServerNames, mcpToolDetails, isPinned } = mcpSelect; + const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [selectedToolForConfig, setSelectedToolForConfig] = useState(null); - const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, { - select: (data: TPlugin[]) => { - const mcpToolsMap = new Map(); - data.forEach((tool) => { - const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter); - if (isMCP && tool.chatMenu !== false) { - const parts = tool.pluginKey.split(Constants.mcp_delimiter); - const serverName = parts[parts.length - 1]; - if (!mcpToolsMap.has(serverName)) { - mcpToolsMap.set(serverName, { - name: serverName, - pluginKey: tool.pluginKey, - authConfig: tool.authConfig, - authenticated: tool.authenticated, - }); - } - } - }); - return Array.from(mcpToolsMap.values()); - }, - }); - const updateUserPluginsMutation = useUpdateUserPluginsMutation({ onSuccess: () => { setIsConfigModalOpen(false); @@ -84,48 +38,6 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) { }, }); - const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key)); - const mcpState = useMemo(() => { - return ephemeralAgent?.mcp ?? []; - }, [ephemeralAgent?.mcp]); - - const setSelectedValues = useCallback( - (values: string[] | null | undefined) => { - if (!values) { - return; - } - if (!Array.isArray(values)) { - return; - } - setEphemeralAgent((prev) => ({ - ...prev, - mcp: values, - })); - }, - [setEphemeralAgent], - ); - const [mcpValues, setMCPValues] = useLocalStorage( - `${LocalStorageKeys.LAST_MCP_}${key}`, - mcpState, - setSelectedValues, - storageCondition, - ); - - useEffect(() => { - if (hasSetFetched.current === key) { - return; - } - if (!isFetched) { - return; - } - hasSetFetched.current = key; - if ((mcpToolDetails?.length ?? 0) > 0) { - setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp))); - return; - } - setMCPValues([]); - }, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]); - const renderSelectedValues = useCallback( (values: string[], placeholder?: string) => { if (values.length === 0) { @@ -139,10 +51,6 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) { [localize], ); - const mcpServerNames = useMemo(() => { - return (mcpToolDetails ?? []).map((tool) => tool.name); - }, [mcpToolDetails]); - const handleConfigSave = useCallback( (targetName: string, authData: Record) => { if (selectedToolForConfig && selectedToolForConfig.name === targetName) { @@ -198,10 +106,10 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) { setSelectedToolForConfig(tool); setIsConfigModalOpen(true); }} - className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-black/10 dark:hover:bg-white/10" + className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary" aria-label={`Configure ${serverName}`} > - + ); @@ -212,6 +120,11 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) { [mcpToolDetails, setSelectedToolForConfig, setIsConfigModalOpen], ); + // Don't render if no servers are selected and not pinned + if ((!mcpValues || mcpValues.length === 0) && !isPinned) { + return null; + } + if (!mcpToolDetails || mcpToolDetails.length === 0) { return null; } diff --git a/client/src/components/Chat/Input/MCPSubMenu.tsx b/client/src/components/Chat/Input/MCPSubMenu.tsx new file mode 100644 index 000000000..a955f2bd9 --- /dev/null +++ b/client/src/components/Chat/Input/MCPSubMenu.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import * as Ariakit from '@ariakit/react'; +import { ChevronRight } from 'lucide-react'; +import { PinIcon, MCPIcon } from '~/components/svg'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +interface MCPSubMenuProps { + isMCPPinned: boolean; + setIsMCPPinned: (value: boolean) => void; + mcpValues?: string[]; + mcpServerNames: string[]; + handleMCPToggle: (serverName: string) => void; +} + +const MCPSubMenu = ({ + mcpValues, + isMCPPinned, + mcpServerNames, + setIsMCPPinned, + handleMCPToggle, + ...props +}: MCPSubMenuProps) => { + const localize = useLocalize(); + + const menuStore = Ariakit.useMenuStore({ + showTimeout: 100, + placement: 'right', + }); + + return ( + + + } + > +
+ + {localize('com_ui_mcp_servers')} + +
+ +
+ + {mcpServerNames.map((serverName) => ( + { + event.preventDefault(); + handleMCPToggle(serverName); + }} + className={cn( + 'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer', + 'scroll-m-1 outline-none transition-colors', + 'hover:bg-black/[0.075] dark:hover:bg-white/10', + 'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10', + 'w-full min-w-0 text-sm', + )} + > + + {serverName} + + ))} + +
+ ); +}; + +export default React.memo(MCPSubMenu); diff --git a/client/src/components/Chat/Input/ToolDialogs.tsx b/client/src/components/Chat/Input/ToolDialogs.tsx new file mode 100644 index 000000000..d9f2122fc --- /dev/null +++ b/client/src/components/Chat/Input/ToolDialogs.tsx @@ -0,0 +1,66 @@ +import React, { useMemo } from 'react'; +import { AuthType } from 'librechat-data-provider'; +import SearchApiKeyDialog from '~/components/SidePanel/Agents/Search/ApiKeyDialog'; +import CodeApiKeyDialog from '~/components/SidePanel/Agents/Code/ApiKeyDialog'; +import { useBadgeRowContext } from '~/Providers'; + +function ToolDialogs() { + const { webSearch, codeInterpreter, searchApiKeyForm, codeApiKeyForm } = useBadgeRowContext(); + const { authData: webSearchAuthData } = webSearch; + const { authData: codeAuthData } = codeInterpreter; + + const { + methods: searchMethods, + onSubmit: searchOnSubmit, + isDialogOpen: searchDialogOpen, + setIsDialogOpen: setSearchDialogOpen, + handleRevokeApiKey: searchHandleRevoke, + badgeTriggerRef: searchBadgeTriggerRef, + menuTriggerRef: searchMenuTriggerRef, + } = searchApiKeyForm; + + const { + methods: codeMethods, + onSubmit: codeOnSubmit, + isDialogOpen: codeDialogOpen, + setIsDialogOpen: setCodeDialogOpen, + handleRevokeApiKey: codeHandleRevoke, + badgeTriggerRef: codeBadgeTriggerRef, + menuTriggerRef: codeMenuTriggerRef, + } = codeApiKeyForm; + + const searchAuthTypes = useMemo( + () => webSearchAuthData?.authTypes ?? [], + [webSearchAuthData?.authTypes], + ); + const codeAuthType = useMemo(() => codeAuthData?.message ?? false, [codeAuthData?.message]); + + return ( + <> + + + + ); +} + +export default ToolDialogs; diff --git a/client/src/components/Chat/Input/ToolsDropdown.tsx b/client/src/components/Chat/Input/ToolsDropdown.tsx new file mode 100644 index 000000000..5fa3d9eaa --- /dev/null +++ b/client/src/components/Chat/Input/ToolsDropdown.tsx @@ -0,0 +1,322 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import * as Ariakit from '@ariakit/react'; +import { Globe, Settings, Settings2, TerminalSquareIcon } from 'lucide-react'; +import type { MenuItemProps } from '~/common'; +import { Permissions, PermissionTypes, AuthType } from 'librechat-data-provider'; +import { TooltipAnchor, DropdownPopup } from '~/components'; +import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu'; +import { PinIcon, VectorIcon } from '~/components/svg'; +import { useLocalize, useHasAccess } from '~/hooks'; +import { useBadgeRowContext } from '~/Providers'; +import { cn } from '~/utils'; + +interface ToolsDropdownProps { + disabled?: boolean; +} + +const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { + const localize = useLocalize(); + const isDisabled = disabled ?? false; + const [isPopoverActive, setIsPopoverActive] = useState(false); + const { webSearch, codeInterpreter, fileSearch, mcpSelect, searchApiKeyForm, codeApiKeyForm } = + useBadgeRowContext(); + const { setIsDialogOpen: setIsCodeDialogOpen, menuTriggerRef: codeMenuTriggerRef } = + codeApiKeyForm; + const { setIsDialogOpen: setIsSearchDialogOpen, menuTriggerRef: searchMenuTriggerRef } = + searchApiKeyForm; + const { + isPinned: isSearchPinned, + setIsPinned: setIsSearchPinned, + authData: webSearchAuthData, + } = webSearch; + const { + isPinned: isCodePinned, + setIsPinned: setIsCodePinned, + authData: codeAuthData, + } = codeInterpreter; + const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch; + const { + mcpValues, + mcpServerNames, + isPinned: isMCPPinned, + setIsPinned: setIsMCPPinned, + } = mcpSelect; + + const canUseWebSearch = useHasAccess({ + permissionType: PermissionTypes.WEB_SEARCH, + permission: Permissions.USE, + }); + + const canRunCode = useHasAccess({ + permissionType: PermissionTypes.RUN_CODE, + permission: Permissions.USE, + }); + + const showWebSearchSettings = useMemo(() => { + const authTypes = webSearchAuthData?.authTypes ?? []; + if (authTypes.length === 0) return true; + return !authTypes.every(([, authType]) => authType === AuthType.SYSTEM_DEFINED); + }, [webSearchAuthData?.authTypes]); + + const showCodeSettings = useMemo( + () => codeAuthData?.message !== AuthType.SYSTEM_DEFINED, + [codeAuthData?.message], + ); + + const handleWebSearchToggle = useCallback(() => { + const newValue = !webSearch.toggleState; + webSearch.debouncedChange({ isChecked: newValue }); + }, [webSearch]); + + const handleCodeInterpreterToggle = useCallback(() => { + const newValue = !codeInterpreter.toggleState; + codeInterpreter.debouncedChange({ isChecked: newValue }); + }, [codeInterpreter]); + + const handleFileSearchToggle = useCallback(() => { + const newValue = !fileSearch.toggleState; + fileSearch.debouncedChange({ isChecked: newValue }); + }, [fileSearch]); + + const handleMCPToggle = useCallback( + (serverName: string) => { + const currentValues = mcpSelect.mcpValues ?? []; + const newValues = currentValues.includes(serverName) + ? currentValues.filter((v) => v !== serverName) + : [...currentValues, serverName]; + mcpSelect.setMCPValues(newValues); + }, + [mcpSelect], + ); + + const dropdownItems = useMemo(() => { + const items: MenuItemProps[] = [ + { + render: () => ( +
+ {localize('com_ui_tools')} +
+ ), + hideOnClick: false, + }, + ]; + + items.push({ + onClick: handleFileSearchToggle, + hideOnClick: false, + render: (props) => ( +
+
+ + {localize('com_assistants_file_search')} +
+ +
+ ), + }); + + if (canUseWebSearch) { + items.push({ + onClick: handleWebSearchToggle, + hideOnClick: false, + render: (props) => ( +
+
+ + {localize('com_ui_web_search')} +
+
+ {showWebSearchSettings && ( + + )} + +
+
+ ), + }); + } + + if (canRunCode) { + items.push({ + onClick: handleCodeInterpreterToggle, + hideOnClick: false, + render: (props) => ( +
+
+ + {localize('com_assistants_code_interpreter')} +
+
+ {showCodeSettings && ( + + )} + +
+
+ ), + }); + } + + if (mcpServerNames && mcpServerNames.length > 0) { + items.push({ + hideOnClick: false, + render: (props) => ( + + ), + }); + } + + return items; + }, [ + localize, + mcpValues, + canRunCode, + isMCPPinned, + isCodePinned, + mcpServerNames, + isSearchPinned, + setIsMCPPinned, + canUseWebSearch, + setIsCodePinned, + handleMCPToggle, + showCodeSettings, + setIsSearchPinned, + isFileSearchPinned, + codeMenuTriggerRef, + setIsCodeDialogOpen, + searchMenuTriggerRef, + showWebSearchSettings, + setIsFileSearchPinned, + handleWebSearchToggle, + setIsSearchDialogOpen, + handleFileSearchToggle, + handleCodeInterpreterToggle, + ]); + + const menuTrigger = ( + +
+ +
+ + } + id="tools-dropdown-button" + description={localize('com_ui_tools')} + disabled={isDisabled} + /> + ); + + return ( + + ); +}; + +export default React.memo(ToolsDropdown); diff --git a/client/src/components/Chat/Input/WebSearch.tsx b/client/src/components/Chat/Input/WebSearch.tsx index 6844ee1da..44b5c4a28 100644 --- a/client/src/components/Chat/Input/WebSearch.tsx +++ b/client/src/components/Chat/Input/WebSearch.tsx @@ -1,122 +1,37 @@ -import React, { memo, useRef, useMemo, useCallback } from 'react'; +import React, { memo } from 'react'; import { Globe } from 'lucide-react'; -import debounce from 'lodash/debounce'; -import { useRecoilState } from 'recoil'; -import { - Tools, - AuthType, - Constants, - Permissions, - PermissionTypes, - LocalStorageKeys, -} from 'librechat-data-provider'; -import ApiKeyDialog from '~/components/SidePanel/Agents/Search/ApiKeyDialog'; -import { useLocalize, useHasAccess, useSearchApiKeyForm } from '~/hooks'; +import { Permissions, PermissionTypes } from 'librechat-data-provider'; import CheckboxButton from '~/components/ui/CheckboxButton'; -import useLocalStorage from '~/hooks/useLocalStorageAlt'; -import { useVerifyAgentToolAuth } from '~/data-provider'; -import { ephemeralAgentByConvoId } from '~/store'; +import { useLocalize, useHasAccess } from '~/hooks'; +import { useBadgeRowContext } from '~/Providers'; -const storageCondition = (value: unknown, rawCurrentValue?: string | null) => { - if (rawCurrentValue) { - try { - const currentValue = rawCurrentValue?.trim() ?? ''; - if (currentValue === 'true' && value === false) { - return true; - } - } catch (e) { - console.error(e); - } - } - return value !== undefined && value !== null && value !== '' && value !== false; -}; - -function WebSearch({ conversationId }: { conversationId?: string | null }) { - const triggerRef = useRef(null); +function WebSearch() { const localize = useLocalize(); - const key = conversationId ?? Constants.NEW_CONVO; + const { webSearch: webSearchData, searchApiKeyForm } = useBadgeRowContext(); + const { toggleState: webSearch, debouncedChange, isPinned } = webSearchData; + const { badgeTriggerRef } = searchApiKeyForm; const canUseWebSearch = useHasAccess({ permissionType: PermissionTypes.WEB_SEARCH, permission: Permissions.USE, }); - const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key)); - const isWebSearchToggleEnabled = useMemo(() => { - return ephemeralAgent?.web_search ?? false; - }, [ephemeralAgent?.web_search]); - - const { data } = useVerifyAgentToolAuth( - { toolId: Tools.web_search }, - { - retry: 1, - }, - ); - const authTypes = useMemo(() => data?.authTypes ?? [], [data?.authTypes]); - const isAuthenticated = useMemo(() => data?.authenticated ?? false, [data?.authenticated]); - const { methods, onSubmit, isDialogOpen, setIsDialogOpen, handleRevokeApiKey } = - useSearchApiKeyForm({}); - - const setValue = useCallback( - (isChecked: boolean) => { - setEphemeralAgent((prev) => ({ - ...prev, - web_search: isChecked, - })); - }, - [setEphemeralAgent], - ); - - const [webSearch, setWebSearch] = useLocalStorage( - `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`, - isWebSearchToggleEnabled, - setValue, - storageCondition, - ); - - const handleChange = useCallback( - (e: React.ChangeEvent, isChecked: boolean) => { - if (!isAuthenticated) { - setIsDialogOpen(true); - e.preventDefault(); - return; - } - setWebSearch(isChecked); - }, - [setWebSearch, setIsDialogOpen, isAuthenticated], - ); - - const debouncedChange = useMemo( - () => debounce(handleChange, 50, { leading: true }), - [handleChange], - ); if (!canUseWebSearch) { return null; } return ( - <> + (webSearch || isPinned) && ( } /> - - + ) ); } diff --git a/client/src/components/Chat/Menus/Endpoints/utils.ts b/client/src/components/Chat/Menus/Endpoints/utils.ts index 87c0133cf..5ed155c6a 100644 --- a/client/src/components/Chat/Menus/Endpoints/utils.ts +++ b/client/src/components/Chat/Menus/Endpoints/utils.ts @@ -83,7 +83,7 @@ export function filterModels( let modelName = modelId; if (isAgentsEndpoint(endpoint.value) && agentsMap && agentsMap[modelId]) { - modelName = agentsMap[modelId].name || modelId; + modelName = agentsMap[modelId]?.name || modelId; } else if ( isAssistantsEndpoint(endpoint.value) && assistantsMap && diff --git a/client/src/components/SidePanel/Agents/Code/ApiKeyDialog.tsx b/client/src/components/SidePanel/Agents/Code/ApiKeyDialog.tsx index 2fa271c73..b2fcac1b5 100644 --- a/client/src/components/SidePanel/Agents/Code/ApiKeyDialog.tsx +++ b/client/src/components/SidePanel/Agents/Code/ApiKeyDialog.tsx @@ -15,6 +15,7 @@ export default function ApiKeyDialog({ register, handleSubmit, triggerRef, + triggerRefs, }: { isOpen: boolean; onOpenChange: (open: boolean) => void; @@ -24,7 +25,8 @@ export default function ApiKeyDialog({ isToolAuthenticated: boolean; register: UseFormRegister; handleSubmit: UseFormHandleSubmit; - triggerRef?: RefObject; + triggerRef?: RefObject; + triggerRefs?: RefObject[]; }) { const localize = useLocalize(); const languageIcons = [ @@ -41,7 +43,12 @@ export default function ApiKeyDialog({ ]; return ( - + void; @@ -30,7 +31,8 @@ export default function ApiKeyDialog({ isToolAuthenticated: boolean; register: UseFormRegister; handleSubmit: UseFormHandleSubmit; - triggerRef?: React.RefObject; + triggerRef?: React.RefObject; + triggerRefs?: React.RefObject[]; }) { const localize = useLocalize(); const { data: config } = useGetStartupConfig(); @@ -181,7 +183,12 @@ export default function ApiKeyDialog({ } return ( - + - - + + + ); } diff --git a/client/src/components/svg/VectorIcon.tsx b/client/src/components/svg/VectorIcon.tsx new file mode 100644 index 000000000..209e9b912 --- /dev/null +++ b/client/src/components/svg/VectorIcon.tsx @@ -0,0 +1,15 @@ +export default function VectorIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/client/src/components/svg/index.ts b/client/src/components/svg/index.ts index 73aad266d..9efffb91c 100644 --- a/client/src/components/svg/index.ts +++ b/client/src/components/svg/index.ts @@ -62,3 +62,5 @@ export { default as ThumbUpIcon } from './ThumbUpIcon'; export { default as ThumbDownIcon } from './ThumbDownIcon'; export { default as XAIcon } from './XAIcon'; export { default as PersonalizationIcon } from './PersonalizationIcon'; +export { default as MCPIcon } from './MCPIcon'; +export { default as VectorIcon } from './VectorIcon'; diff --git a/client/src/components/ui/CheckboxButton.tsx b/client/src/components/ui/CheckboxButton.tsx index 7985b7374..c664a6132 100644 --- a/client/src/components/ui/CheckboxButton.tsx +++ b/client/src/components/ui/CheckboxButton.tsx @@ -9,11 +9,12 @@ const CheckboxButton = React.forwardRef< icon?: React.ReactNode; label: string; className?: string; + checked?: boolean; defaultChecked?: boolean; isCheckedClassName?: string; - setValue?: (e: React.ChangeEvent, isChecked: boolean) => void; + setValue?: (values: { e?: React.ChangeEvent; isChecked: boolean }) => void; } ->(({ icon, label, setValue, className, defaultChecked, isCheckedClassName }, ref) => { +>(({ icon, label, setValue, className, checked, defaultChecked, isCheckedClassName }, ref) => { const checkbox = useCheckboxStore(); const isChecked = useStoreState(checkbox, (state) => state?.value); const onChange = (e: React.ChangeEvent) => { @@ -21,20 +22,28 @@ const CheckboxButton = React.forwardRef< if (typeof isChecked !== 'boolean') { return; } - setValue?.(e, !isChecked); + setValue?.({ e, isChecked: !isChecked }); }; + + // Sync with controlled checked prop useEffect(() => { - if (defaultChecked) { + if (checked !== undefined) { + checkbox.setValue(checked); + } + }, [checked, checkbox]); + + // Set initial value from defaultChecked + useEffect(() => { + if (defaultChecked !== undefined && checked === undefined) { checkbox.setValue(defaultChecked); } - }, [defaultChecked, checkbox]); + }, [defaultChecked, checked, checkbox]); return ( - - - - - ); -} diff --git a/client/src/components/ui/OriginalDialog.tsx b/client/src/components/ui/OriginalDialog.tsx index 4e1ff3bd4..cfb2fe226 100644 --- a/client/src/components/ui/OriginalDialog.tsx +++ b/client/src/components/ui/OriginalDialog.tsx @@ -5,16 +5,26 @@ import { cn } from '~/utils'; interface OGDialogProps extends DialogPrimitive.DialogProps { triggerRef?: React.RefObject; + triggerRefs?: React.RefObject[]; } const Dialog = React.forwardRef( - ({ children, triggerRef, onOpenChange, ...props }, _ref) => { + ({ children, triggerRef, triggerRefs, onOpenChange, ...props }, _ref) => { const handleOpenChange = (open: boolean) => { if (!open && triggerRef?.current) { setTimeout(() => { triggerRef.current?.focus(); }, 0); } + if (triggerRefs?.length) { + triggerRefs.forEach((ref) => { + if (ref?.current) { + setTimeout(() => { + ref.current?.focus(); + }, 0); + } + }); + } onOpenChange?.(open); }; diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts index 5edc18bd1..31443c900 100644 --- a/client/src/components/ui/index.ts +++ b/client/src/components/ui/index.ts @@ -28,7 +28,6 @@ export * from './Pagination'; export * from './Progress'; export * from './InputOTP'; export { default as Badge } from './Badge'; -export { default as MCPIcon } from './MCPIcon'; export { default as Combobox } from './Combobox'; export { default as Dropdown } from './Dropdown'; export { default as SplitText } from './SplitText'; diff --git a/client/src/hooks/Files/useDragHelpers.ts b/client/src/hooks/Files/useDragHelpers.ts index af4530e62..3968e2bb0 100644 --- a/client/src/hooks/Files/useDragHelpers.ts +++ b/client/src/hooks/Files/useDragHelpers.ts @@ -1,43 +1,46 @@ import { useState, useMemo } from 'react'; import { useDrop } from 'react-dnd'; -import { useRecoilValue } from 'recoil'; import { NativeTypes } from 'react-dnd-html5-backend'; import { useQueryClient } from '@tanstack/react-query'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { - Constants, QueryKeys, + Constants, EModelEndpoint, - isAgentsEndpoint, - isEphemeralAgent, + EToolResources, AgentCapabilities, + isAssistantsEndpoint, } from 'librechat-data-provider'; -import type * as t from 'librechat-data-provider'; import type { DropTargetMonitor } from 'react-dnd'; -import useFileHandling from './useFileHandling'; +import type * as t from 'librechat-data-provider'; import store, { ephemeralAgentByConvoId } from '~/store'; +import useFileHandling from './useFileHandling'; export default function useDragHelpers() { const queryClient = useQueryClient(); const [showModal, setShowModal] = useState(false); const [draggedFiles, setDraggedFiles] = useState([]); const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined; - const key = useMemo( - () => conversation?.conversationId ?? Constants.NEW_CONVO, - [conversation?.conversationId], + const setEphemeralAgent = useSetRecoilState( + ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO), ); - const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(key)); - const handleOptionSelect = (toolResource: string | undefined) => { + const handleOptionSelect = (toolResource: EToolResources | undefined) => { + /** File search is not automatically enabled to simulate legacy behavior */ + if (toolResource && toolResource !== EToolResources.file_search) { + setEphemeralAgent((prev) => ({ + ...prev, + [toolResource]: true, + })); + } handleFiles(draggedFiles, toolResource); setShowModal(false); setDraggedFiles([]); }; const isAgents = useMemo( - () => - isAgentsEndpoint(conversation?.endpoint) || - isEphemeralAgent(conversation?.endpoint, ephemeralAgent), - [conversation?.endpoint, ephemeralAgent], + () => !isAssistantsEndpoint(conversation?.endpoint), + [conversation?.endpoint], ); const { handleFiles } = useFileHandling({ diff --git a/client/src/hooks/Nav/useSideNavLinks.ts b/client/src/hooks/Nav/useSideNavLinks.ts index abc4688f7..13657c058 100644 --- a/client/src/hooks/Nav/useSideNavLinks.ts +++ b/client/src/hooks/Nav/useSideNavLinks.ts @@ -15,12 +15,11 @@ import BookmarkPanel from '~/components/SidePanel/Bookmarks/BookmarkPanel'; import MemoryViewer from '~/components/SidePanel/Memories/MemoryViewer'; import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch'; import PromptsAccordion from '~/components/Prompts/PromptsAccordion'; +import { Blocks, MCPIcon, AttachmentIcon } from '~/components/svg'; import Parameters from '~/components/SidePanel/Parameters/Panel'; import FilesPanel from '~/components/SidePanel/Files/Panel'; import MCPPanel from '~/components/SidePanel/MCP/MCPPanel'; -import { Blocks, AttachmentIcon } from '~/components/svg'; import { useGetStartupConfig } from '~/data-provider'; -import MCPIcon from '~/components/ui/MCPIcon'; import { useHasAccess } from '~/hooks'; export default function useSideNavLinks({ diff --git a/client/src/hooks/Plugins/index.ts b/client/src/hooks/Plugins/index.ts index c2a0ffe97..85b6c7186 100644 --- a/client/src/hooks/Plugins/index.ts +++ b/client/src/hooks/Plugins/index.ts @@ -1,3 +1,5 @@ +export * from './useMCPSelect'; +export * from './useToolToggle'; export { default as useAuthCodeTool } from './useAuthCodeTool'; export { default as usePluginInstall } from './usePluginInstall'; export { default as useCodeApiKeyForm } from './useCodeApiKeyForm'; diff --git a/client/src/hooks/Plugins/useCodeApiKeyForm.ts b/client/src/hooks/Plugins/useCodeApiKeyForm.ts index 4b1f9814a..32120c8ab 100644 --- a/client/src/hooks/Plugins/useCodeApiKeyForm.ts +++ b/client/src/hooks/Plugins/useCodeApiKeyForm.ts @@ -1,5 +1,5 @@ // client/src/hooks/Plugins/useCodeApiKeyForm.ts -import { useState, useCallback } from 'react'; +import { useRef, useState, useCallback } from 'react'; import { useForm } from 'react-hook-form'; import type { ApiKeyFormData } from '~/common'; import useAuthCodeTool from '~/hooks/Plugins/useAuthCodeTool'; @@ -12,6 +12,8 @@ export default function useCodeApiKeyForm({ onRevoke?: () => void; }) { const methods = useForm(); + const menuTriggerRef = useRef(null); + const badgeTriggerRef = useRef(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const { installTool, removeTool } = useAuthCodeTool({ isEntityTool: true }); const { reset } = methods; @@ -39,5 +41,7 @@ export default function useCodeApiKeyForm({ setIsDialogOpen, handleRevokeApiKey, onSubmit: onSubmitHandler, + badgeTriggerRef, + menuTriggerRef, }; } diff --git a/client/src/hooks/Plugins/useMCPSelect.ts b/client/src/hooks/Plugins/useMCPSelect.ts new file mode 100644 index 000000000..113f0011b --- /dev/null +++ b/client/src/hooks/Plugins/useMCPSelect.ts @@ -0,0 +1,121 @@ +import { useRef, useEffect, useCallback, useMemo } from 'react'; +import { useRecoilState } from 'recoil'; +import { Constants, LocalStorageKeys, EModelEndpoint } from 'librechat-data-provider'; +import type { TPlugin, TPluginAuthConfig } from 'librechat-data-provider'; +import { useAvailableToolsQuery } from '~/data-provider'; +import useLocalStorage from '~/hooks/useLocalStorageAlt'; +import { ephemeralAgentByConvoId } from '~/store'; + +const storageCondition = (value: unknown, rawCurrentValue?: string | null) => { + if (rawCurrentValue) { + try { + const currentValue = rawCurrentValue?.trim() ?? ''; + if (currentValue.length > 2) { + return true; + } + } catch (e) { + console.error(e); + } + } + return Array.isArray(value) && value.length > 0; +}; + +interface UseMCPSelectOptions { + conversationId?: string | null; +} + +export interface McpServerInfo { + name: string; + pluginKey: string; + authConfig?: TPluginAuthConfig[]; + authenticated?: boolean; +} + +export function useMCPSelect({ conversationId }: UseMCPSelectOptions) { + const key = conversationId ?? Constants.NEW_CONVO; + const hasSetFetched = useRef(null); + const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key)); + const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, { + select: (data: TPlugin[]) => { + const mcpToolsMap = new Map(); + data.forEach((tool) => { + const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter); + if (isMCP && tool.chatMenu !== false) { + const parts = tool.pluginKey.split(Constants.mcp_delimiter); + const serverName = parts[parts.length - 1]; + if (!mcpToolsMap.has(serverName)) { + mcpToolsMap.set(serverName, { + name: serverName, + pluginKey: tool.pluginKey, + authConfig: tool.authConfig, + authenticated: tool.authenticated, + }); + } + } + }); + return Array.from(mcpToolsMap.values()); + }, + }); + + const mcpState = useMemo(() => { + return ephemeralAgent?.mcp ?? []; + }, [ephemeralAgent?.mcp]); + + const setSelectedValues = useCallback( + (values: string[] | null | undefined) => { + if (!values) { + return; + } + if (!Array.isArray(values)) { + return; + } + setEphemeralAgent((prev) => ({ + ...prev, + mcp: values, + })); + }, + [setEphemeralAgent], + ); + + const [mcpValues, setMCPValues] = useLocalStorage( + `${LocalStorageKeys.LAST_MCP_}${key}`, + mcpState, + setSelectedValues, + storageCondition, + ); + + const [isPinned, setIsPinned] = useLocalStorage( + `${LocalStorageKeys.PIN_MCP_}${key}`, + true, + ); + + useEffect(() => { + if (hasSetFetched.current === key) { + return; + } + if (!isFetched) { + return; + } + hasSetFetched.current = key; + if ((mcpToolDetails?.length ?? 0) > 0) { + setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp))); + return; + } + setMCPValues([]); + }, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]); + + const mcpServerNames = useMemo(() => { + return (mcpToolDetails ?? []).map((tool) => tool.name); + }, [mcpToolDetails]); + + return { + mcpValues, + setMCPValues, + mcpServerNames, + ephemeralAgent, + mcpToolDetails, + setEphemeralAgent, + isPinned, + setIsPinned, + }; +} diff --git a/client/src/hooks/Plugins/useSearchApiKeyForm.ts b/client/src/hooks/Plugins/useSearchApiKeyForm.ts index 0044f1c0d..86543e303 100644 --- a/client/src/hooks/Plugins/useSearchApiKeyForm.ts +++ b/client/src/hooks/Plugins/useSearchApiKeyForm.ts @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useRef, useState, useCallback } from 'react'; import { useForm } from 'react-hook-form'; import useAuthSearchTool from '~/hooks/Plugins/useAuthSearchTool'; import type { SearchApiKeyFormData } from '~/hooks/Plugins/useAuthSearchTool'; @@ -11,6 +11,8 @@ export default function useSearchApiKeyForm({ onRevoke?: () => void; }) { const methods = useForm(); + const menuTriggerRef = useRef(null); + const badgeTriggerRef = useRef(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const { installTool, removeTool } = useAuthSearchTool({ isEntityTool: true }); const { reset } = methods; @@ -38,5 +40,7 @@ export default function useSearchApiKeyForm({ setIsDialogOpen, handleRevokeApiKey, onSubmit: onSubmitHandler, + badgeTriggerRef, + menuTriggerRef, }; } diff --git a/client/src/hooks/Plugins/useToolToggle.ts b/client/src/hooks/Plugins/useToolToggle.ts new file mode 100644 index 000000000..27b1ff284 --- /dev/null +++ b/client/src/hooks/Plugins/useToolToggle.ts @@ -0,0 +1,119 @@ +import { useRef, useEffect, useCallback, useMemo } from 'react'; +import { useRecoilState } from 'recoil'; +import debounce from 'lodash/debounce'; +import { Constants, LocalStorageKeys } from 'librechat-data-provider'; +import type { VerifyToolAuthResponse } from 'librechat-data-provider'; +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useVerifyAgentToolAuth } from '~/data-provider'; +import useLocalStorage from '~/hooks/useLocalStorageAlt'; +import { ephemeralAgentByConvoId } from '~/store'; + +const storageCondition = (value: unknown, rawCurrentValue?: string | null) => { + if (rawCurrentValue) { + try { + const currentValue = rawCurrentValue?.trim() ?? ''; + if (currentValue === 'true' && value === false) { + return true; + } + } catch (e) { + console.error(e); + } + } + return value !== undefined && value !== null && value !== '' && value !== false; +}; + +interface UseToolToggleOptions { + conversationId?: string | null; + toolKey: string; + localStorageKey: LocalStorageKeys; + isAuthenticated?: boolean; + setIsDialogOpen?: (open: boolean) => void; + /** Options for auth verification */ + authConfig?: { + toolId: string; + queryOptions?: UseQueryOptions; + }; +} + +export function useToolToggle({ + conversationId, + toolKey, + localStorageKey, + isAuthenticated: externalIsAuthenticated, + setIsDialogOpen, + authConfig, +}: UseToolToggleOptions) { + const key = conversationId ?? Constants.NEW_CONVO; + const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key)); + + const authQuery = useVerifyAgentToolAuth( + { toolId: authConfig?.toolId || '' }, + { + enabled: !!authConfig?.toolId, + ...authConfig?.queryOptions, + }, + ); + + const isAuthenticated = useMemo( + () => + externalIsAuthenticated ?? (authConfig ? (authQuery?.data?.authenticated ?? false) : false), + [externalIsAuthenticated, authConfig, authQuery.data?.authenticated], + ); + + const isToolEnabled = useMemo(() => { + return ephemeralAgent?.[toolKey] ?? false; + }, [ephemeralAgent, toolKey]); + + /** Track previous value to prevent infinite loops */ + const prevIsToolEnabled = useRef(isToolEnabled); + + const [toggleState, setToggleState] = useLocalStorage( + `${localStorageKey}${key}`, + isToolEnabled, + undefined, + storageCondition, + ); + + const [isPinned, setIsPinned] = useLocalStorage(`${localStorageKey}pinned`, false); + + const handleChange = useCallback( + ({ e, isChecked }: { e?: React.ChangeEvent; isChecked: boolean }) => { + if (isAuthenticated !== undefined && !isAuthenticated && setIsDialogOpen) { + setIsDialogOpen(true); + e?.preventDefault?.(); + return; + } + setToggleState(isChecked); + setEphemeralAgent((prev) => ({ + ...prev, + [toolKey]: isChecked, + })); + }, + [setToggleState, setIsDialogOpen, isAuthenticated, setEphemeralAgent, toolKey], + ); + + const debouncedChange = useMemo( + () => debounce(handleChange, 50, { leading: true }), + [handleChange], + ); + + useEffect(() => { + if (prevIsToolEnabled.current !== isToolEnabled) { + setToggleState(isToolEnabled); + } + prevIsToolEnabled.current = isToolEnabled; + }, [isToolEnabled, setToggleState]); + + return { + toggleState, + handleChange, + isToolEnabled, + setToggleState, + ephemeralAgent, + debouncedChange, + setEphemeralAgent, + authData: authQuery?.data, + isPinned, + setIsPinned, + }; +} diff --git a/client/src/hooks/SSE/useSSE.ts b/client/src/hooks/SSE/useSSE.ts index 7faad07e3..9e1cdf1d1 100644 --- a/client/src/hooks/SSE/useSSE.ts +++ b/client/src/hooks/SSE/useSSE.ts @@ -7,10 +7,8 @@ import { Constants, /* @ts-ignore */ createPayload, - isAgentsEndpoint, LocalStorageKeys, removeNullishValues, - isAssistantsEndpoint, } from 'librechat-data-provider'; import type { TMessage, TPayload, TSubmission, EventSubmission } from 'librechat-data-provider'; import type { EventHandlerParams } from './useEventHandlers'; @@ -100,9 +98,7 @@ export default function useSSE( const payloadData = createPayload(submission); let { payload } = payloadData; - if (isAssistantsEndpoint(payload.endpoint) || isAgentsEndpoint(payload.endpoint)) { - payload = removeNullishValues(payload) as TPayload; - } + payload = removeNullishValues(payload) as TPayload; let textIndex = null; diff --git a/e2e/specs/messages.spec.ts b/e2e/specs/messages.spec.ts index c418a6f49..a19295bcd 100644 --- a/e2e/specs/messages.spec.ts +++ b/e2e/specs/messages.spec.ts @@ -12,9 +12,7 @@ function isUUID(uuid: string) { } const waitForServerStream = async (response: Response) => { - const endpointCheck = - response.url().includes(`/api/ask/${endpoint}`) || - response.url().includes(`/api/edit/${endpoint}`); + const endpointCheck = response.url().includes(`/api/agents`); return endpointCheck && response.status() === 200; }; diff --git a/package-lock.json b/package-lock.json index 0301bdce1..b9718bb56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26085,9 +26085,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -27308,10 +27308,11 @@ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -31075,10 +31076,11 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -31801,10 +31803,11 @@ "peer": true }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -43426,9 +43429,10 @@ } }, "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -46686,10 +46690,11 @@ } }, "packages/data-provider/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 7e08343ea..e2cc1ab51 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -4,5 +4,6 @@ export * from './common'; export * from './events'; export * from './files'; export * from './generators'; +export * from './llm'; export * from './openid'; export { default as Tokenizer } from './tokenizer'; diff --git a/packages/api/src/utils/llm.test.ts b/packages/api/src/utils/llm.test.ts new file mode 100644 index 000000000..a7d18e0cf --- /dev/null +++ b/packages/api/src/utils/llm.test.ts @@ -0,0 +1,189 @@ +import { extractLibreChatParams } from './llm'; + +describe('extractLibreChatParams', () => { + it('should return defaults when options is undefined', () => { + const result = extractLibreChatParams(undefined); + + expect(result.resendFiles).toBe(true); + expect(result.promptPrefix).toBeUndefined(); + expect(result.maxContextTokens).toBeUndefined(); + expect(result.modelLabel).toBeUndefined(); + expect(result.modelOptions).toEqual({}); + }); + + it('should return defaults when options is null', () => { + const result = extractLibreChatParams(); + + expect(result.resendFiles).toBe(true); + expect(result.promptPrefix).toBeUndefined(); + expect(result.maxContextTokens).toBeUndefined(); + expect(result.modelLabel).toBeUndefined(); + expect(result.modelOptions).toEqual({}); + }); + + it('should extract all LibreChat params and leave model options', () => { + const options = { + resendFiles: false, + promptPrefix: 'You are a helpful assistant', + maxContextTokens: 4096, + modelLabel: 'GPT-4', + model: 'gpt-4', + temperature: 0.7, + max_tokens: 1000, + }; + + const result = extractLibreChatParams(options); + + expect(result.resendFiles).toBe(false); + expect(result.promptPrefix).toBe('You are a helpful assistant'); + expect(result.maxContextTokens).toBe(4096); + expect(result.modelLabel).toBe('GPT-4'); + expect(result.modelOptions).toEqual({ + model: 'gpt-4', + temperature: 0.7, + max_tokens: 1000, + }); + }); + + it('should handle null values for LibreChat params', () => { + const options = { + resendFiles: true, + promptPrefix: null, + maxContextTokens: 2048, + modelLabel: null, + model: 'claude-3', + }; + + const result = extractLibreChatParams(options); + + expect(result.resendFiles).toBe(true); + expect(result.promptPrefix).toBeNull(); + expect(result.maxContextTokens).toBe(2048); + expect(result.modelLabel).toBeNull(); + expect(result.modelOptions).toEqual({ + model: 'claude-3', + }); + }); + + it('should use default for resendFiles when not provided', () => { + const options = { + promptPrefix: 'Test prefix', + model: 'gpt-3.5-turbo', + temperature: 0.5, + }; + + const result = extractLibreChatParams(options); + + expect(result.resendFiles).toBe(true); // Should use default + expect(result.promptPrefix).toBe('Test prefix'); + expect(result.maxContextTokens).toBeUndefined(); + expect(result.modelLabel).toBeUndefined(); + expect(result.modelOptions).toEqual({ + model: 'gpt-3.5-turbo', + temperature: 0.5, + }); + }); + + it('should handle empty options object', () => { + const result = extractLibreChatParams({}); + + expect(result.resendFiles).toBe(true); // Should use default + expect(result.promptPrefix).toBeUndefined(); + expect(result.maxContextTokens).toBeUndefined(); + expect(result.modelLabel).toBeUndefined(); + expect(result.modelOptions).toEqual({}); + }); + + it('should only extract known LibreChat params', () => { + const options = { + resendFiles: false, + promptPrefix: 'Custom prompt', + maxContextTokens: 8192, + modelLabel: 'Custom Model', + // Model options + model: 'gpt-4', + temperature: 0.9, + top_p: 0.95, + frequency_penalty: 0.5, + presence_penalty: 0.5, + // Unknown params should stay in modelOptions + unknownParam: 'should remain', + customSetting: 123, + }; + + const result = extractLibreChatParams(options); + + // LibreChat params extracted + expect(result.resendFiles).toBe(false); + expect(result.promptPrefix).toBe('Custom prompt'); + expect(result.maxContextTokens).toBe(8192); + expect(result.modelLabel).toBe('Custom Model'); + + // Model options should include everything else + expect(result.modelOptions).toEqual({ + model: 'gpt-4', + temperature: 0.9, + top_p: 0.95, + frequency_penalty: 0.5, + presence_penalty: 0.5, + unknownParam: 'should remain', + customSetting: 123, + }); + }); + + it('should not mutate the original options object', () => { + const options = { + resendFiles: false, + promptPrefix: 'Test', + model: 'gpt-4', + temperature: 0.7, + }; + const originalOptions = { ...options }; + + extractLibreChatParams(options); + + // Original object should remain unchanged + expect(options).toEqual(originalOptions); + }); + + it('should handle undefined values for optional LibreChat params', () => { + const options = { + resendFiles: false, + promptPrefix: undefined, + maxContextTokens: undefined, + modelLabel: undefined, + model: 'claude-2', + }; + + const result = extractLibreChatParams(options); + + expect(result.resendFiles).toBe(false); + expect(result.promptPrefix).toBeUndefined(); + expect(result.maxContextTokens).toBeUndefined(); + expect(result.modelLabel).toBeUndefined(); + expect(result.modelOptions).toEqual({ + model: 'claude-2', + }); + }); + + it('should handle mixed null and undefined values', () => { + const options = { + promptPrefix: null, + maxContextTokens: undefined, + modelLabel: null, + model: 'gpt-3.5-turbo', + stop: ['\\n', '\\n\\n'], + }; + + const result = extractLibreChatParams(options); + + expect(result.resendFiles).toBe(true); // default + expect(result.promptPrefix).toBeNull(); + expect(result.maxContextTokens).toBeUndefined(); + expect(result.modelLabel).toBeNull(); + expect(result.modelOptions).toEqual({ + model: 'gpt-3.5-turbo', + stop: ['\\n', '\\n\\n'], + }); + }); +}); diff --git a/packages/api/src/utils/llm.ts b/packages/api/src/utils/llm.ts new file mode 100644 index 000000000..65475c805 --- /dev/null +++ b/packages/api/src/utils/llm.ts @@ -0,0 +1,47 @@ +import { librechat } from 'librechat-data-provider'; +import type { DynamicSettingProps } from 'librechat-data-provider'; + +type LibreChatKeys = keyof typeof librechat; + +type LibreChatParams = { + modelOptions: Omit, LibreChatKeys>; + resendFiles: boolean; + promptPrefix?: string | null; + maxContextTokens?: number; + modelLabel?: string | null; +}; + +/** + * Separates LibreChat-specific parameters from model options + * @param options - The combined options object + */ +export function extractLibreChatParams( + options?: DynamicSettingProps['conversation'], +): LibreChatParams { + if (!options) { + return { + modelOptions: {} as Omit, LibreChatKeys>, + resendFiles: librechat.resendFiles.default as boolean, + }; + } + + const modelOptions = { ...options }; + + const resendFiles = + (delete modelOptions.resendFiles, options.resendFiles) ?? + (librechat.resendFiles.default as boolean); + const promptPrefix = (delete modelOptions.promptPrefix, options.promptPrefix); + const maxContextTokens = (delete modelOptions.maxContextTokens, options.maxContextTokens); + const modelLabel = (delete modelOptions.modelLabel, options.modelLabel); + + return { + modelOptions: modelOptions as Omit< + NonNullable, + LibreChatKeys + >, + maxContextTokens, + promptPrefix, + resendFiles, + modelLabel, + }; +} diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 88a9d4a14..3d396b0ba 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -70,8 +70,6 @@ export const revokeUserKey = (name: string) => `${keysEndpoint}/${name}`; export const revokeAllUserKeys = () => `${keysEndpoint}?all=true`; -export const abortRequest = (endpoint: string) => `/api/ask/${endpoint}/abort`; - export const conversationsRoot = '/api/convos'; export const conversations = (params: q.ConversationListParams) => { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 4d1c95b69..c18deef1e 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -940,18 +940,10 @@ export const initialModelsConfig: TModelsConfig = { [EModelEndpoint.bedrock]: defaultModels[EModelEndpoint.bedrock], }; -export const EndpointURLs: { [key in EModelEndpoint]: string } = { - [EModelEndpoint.openAI]: `/api/ask/${EModelEndpoint.openAI}`, - [EModelEndpoint.google]: `/api/ask/${EModelEndpoint.google}`, - [EModelEndpoint.custom]: `/api/ask/${EModelEndpoint.custom}`, - [EModelEndpoint.anthropic]: `/api/ask/${EModelEndpoint.anthropic}`, - [EModelEndpoint.gptPlugins]: `/api/ask/${EModelEndpoint.gptPlugins}`, - [EModelEndpoint.azureOpenAI]: `/api/ask/${EModelEndpoint.azureOpenAI}`, - [EModelEndpoint.chatGPTBrowser]: `/api/ask/${EModelEndpoint.chatGPTBrowser}`, - [EModelEndpoint.azureAssistants]: '/api/assistants/v1/chat', +export const EndpointURLs: Record = { [EModelEndpoint.assistants]: '/api/assistants/v2/chat', + [EModelEndpoint.azureAssistants]: '/api/assistants/v1/chat', [EModelEndpoint.agents]: `/api/${EModelEndpoint.agents}/chat`, - [EModelEndpoint.bedrock]: `/api/${EModelEndpoint.bedrock}/chat`, }; export const modularEndpoints = new Set([ @@ -1451,10 +1443,18 @@ export enum LocalStorageKeys { LAST_CODE_TOGGLE_ = 'LAST_CODE_TOGGLE_', /** Last checked toggle for Web Search per conversation ID */ LAST_WEB_SEARCH_TOGGLE_ = 'LAST_WEB_SEARCH_TOGGLE_', + /** Last checked toggle for File Search per conversation ID */ + LAST_FILE_SEARCH_TOGGLE_ = 'LAST_FILE_SEARCH_TOGGLE_', /** Key for the last selected agent provider */ LAST_AGENT_PROVIDER = 'lastAgentProvider', /** Key for the last selected agent model */ LAST_AGENT_MODEL = 'lastAgentModel', + /** Pin state for MCP tools per conversation ID */ + PIN_MCP_ = 'PIN_MCP_', + /** Pin state for Web Search per conversation ID */ + PIN_WEB_SEARCH_ = 'PIN_WEB_SEARCH_', + /** Pin state for Code Interpreter per conversation ID */ + PIN_CODE_INTERPRETER_ = 'PIN_CODE_INTERPRETER_', } export enum ForkOptions { diff --git a/packages/data-provider/src/createPayload.ts b/packages/data-provider/src/createPayload.ts index 1640d877d..d00ac45ff 100644 --- a/packages/data-provider/src/createPayload.ts +++ b/packages/data-provider/src/createPayload.ts @@ -13,27 +13,23 @@ export default function createPayload(submission: t.TSubmission) { ephemeralAgent, } = submission; const { conversationId } = s.tConvoUpdateSchema.parse(conversation); - const { endpoint: _e, endpointType } = endpointOption as { + const { endpoint: _e } = endpointOption as { endpoint: s.EModelEndpoint; endpointType?: s.EModelEndpoint; }; + const endpoint = _e as s.EModelEndpoint; - let server = EndpointURLs[endpointType ?? endpoint]; - const isEphemeral = s.isEphemeralAgent(endpoint, ephemeralAgent); + let server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`; if (isEdited && s.isAssistantsEndpoint(endpoint)) { server += '/modify'; - } else if (isEdited) { - server = server.replace('/ask/', '/edit/'); - } else if (isEphemeral) { - server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`; } const payload: t.TPayload = { ...userMessage, ...endpointOption, endpoint, - ephemeralAgent: isEphemeral ? ephemeralAgent : undefined, + ephemeralAgent: s.isAssistantsEndpoint(endpoint) ? undefined : ephemeralAgent, isContinued: !!(isEdited && isContinued), conversationId, isTemporary, diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 08a666dd7..c76efbac8 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -11,14 +11,6 @@ import request from './request'; import * as s from './schemas'; import * as r from './roles'; -export function abortRequestWithMessage( - endpoint: string, - abortKey: string, - message: string, -): Promise { - return request.post(endpoints.abortRequest(endpoint), { arg: { abortKey, message } }); -} - export function revokeUserKey(name: string): Promise { return request.delete(endpoints.revokeUserKey(name)); } diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index 1098bbbb7..af79eb2c1 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -83,7 +83,7 @@ const createDefinition = ( return { ...base, ...overrides } as SettingDefinition; }; -const librechat: Record = { +export const librechat = { modelLabel: { key: 'modelLabel', label: 'com_endpoint_custom_name', @@ -94,7 +94,7 @@ const librechat: Record = { placeholder: 'com_endpoint_openai_custom_name_placeholder', placeholderCode: true, optionType: 'conversation', - }, + } as const, maxContextTokens: { key: 'maxContextTokens', label: 'com_endpoint_context_tokens', @@ -107,7 +107,7 @@ const librechat: Record = { descriptionCode: true, optionType: 'model', columnSpan: 2, - }, + } as const, resendFiles: { key: 'resendFiles', label: 'com_endpoint_plug_resend_files', @@ -120,7 +120,7 @@ const librechat: Record = { optionType: 'conversation', showDefault: false, columnSpan: 2, - }, + } as const, promptPrefix: { key: 'promptPrefix', label: 'com_endpoint_prompt_prefix', @@ -131,7 +131,7 @@ const librechat: Record = { placeholder: 'com_endpoint_openai_prompt_prefix_placeholder', placeholderCode: true, optionType: 'model', - }, + } as const, }; const openAIParams: Record = { diff --git a/packages/data-provider/src/parsers.ts b/packages/data-provider/src/parsers.ts index 1c3eb511d..774bc5617 100644 --- a/packages/data-provider/src/parsers.ts +++ b/packages/data-provider/src/parsers.ts @@ -275,15 +275,11 @@ export const getResponseSender = (endpointOption: t.TEndpointOption): string => if (endpoint === EModelEndpoint.google) { if (modelLabel) { return modelLabel; - } else if (model && (model.includes('gemini') || model.includes('learnlm'))) { - return 'Gemini'; } else if (model?.toLowerCase().includes('gemma') === true) { return 'Gemma'; - } else if (model && model.includes('code')) { - return 'Codey'; } - return 'PaLM2'; + return 'Gemini'; } if (endpoint === EModelEndpoint.custom || endpointType === EModelEndpoint.custom) { diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 6e33f7ce5..7e3fe549a 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -12,23 +12,6 @@ import { QueryKeys } from '../keys'; import * as s from '../schemas'; import * as t from '../types'; -export const useAbortRequestWithMessage = (): UseMutationResult< - void, - Error, - { endpoint: string; abortKey: string; message: string } -> => { - const queryClient = useQueryClient(); - return useMutation( - ({ endpoint, abortKey, message }) => - dataService.abortRequestWithMessage(endpoint, abortKey, message), - { - onSuccess: () => { - queryClient.invalidateQueries([QueryKeys.balance]); - }, - }, - ); -}; - export const useGetSharedMessages = ( shareId: string, config?: UseQueryOptions, diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 149b10062..89048cf15 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -3,7 +3,6 @@ import { Tools } from './types/assistants'; import type { TMessageContentParts, FunctionTool, FunctionToolCall } from './types/assistants'; import { TFeedback, feedbackSchema } from './feedback'; import type { SearchResultData } from './types/web'; -import type { TEphemeralAgent } from './types'; import type { TFile } from './types/files'; export const isUUID = z.string().uuid(); @@ -91,22 +90,6 @@ export const isAgentsEndpoint = (_endpoint?: EModelEndpoint.agents | null | stri return endpoint === EModelEndpoint.agents; }; -export const isEphemeralAgent = ( - endpoint?: EModelEndpoint.agents | null | string, - ephemeralAgent?: TEphemeralAgent | null, -) => { - if (!ephemeralAgent) { - return false; - } - if (isAgentsEndpoint(endpoint)) { - return false; - } - const hasMCPSelected = (ephemeralAgent?.mcp?.length ?? 0) > 0; - const hasCodeSelected = (ephemeralAgent?.execute_code ?? false) === true; - const hasSearchSelected = (ephemeralAgent?.web_search ?? false) === true; - return hasMCPSelected || hasCodeSelected || hasSearchSelected; -}; - export const isParamEndpoint = ( endpoint: EModelEndpoint | string, endpointType?: EModelEndpoint | string, diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 275c405c1..469c378ab 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -98,6 +98,7 @@ export type TEndpointOption = Pick< export type TEphemeralAgent = { mcp?: string[]; web_search?: boolean; + file_search?: boolean; execute_code?: boolean; };