🤖 feat: Streamline Endpoints to Agent Framework (#8013)

* 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 69b6997396.

* 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
This commit is contained in:
Danny Avila 2025-06-23 09:59:05 -04:00 committed by GitHub
parent d835f48307
commit 01e9b196bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 1468 additions and 1433 deletions

View file

@ -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({

View file

@ -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>${file.filename}</filename>
<context>${currentContext}
</context>
</file>`;
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 `
<contextItem>
<![CDATA[${pageContent?.trim()}]]>
</contextItem>`;
})
.join('');
})
.join('');
return generateContext(contextItems);
})
.join('');
return generateContext(contextItems);
})
.join('');
if (useFullContext) {
const prompt = `${header}

View file

@ -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);
}

View file

@ -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();

View file

@ -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;

View file

@ -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;
}

View file

@ -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);

View file

@ -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' });
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,

View file

@ -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,
};
};

View file

@ -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 };

View file

@ -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({

View file

@ -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<typeof useMCPSelect>;
webSearch: ReturnType<typeof useToolToggle>;
codeInterpreter: ReturnType<typeof useToolToggle>;
fileSearch: ReturnType<typeof useToolToggle>;
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
}
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(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 <BadgeRowContext.Provider value={value}>{children}</BadgeRowContext.Provider>;
}

View file

@ -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';

View file

@ -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 (
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{tempBadges.map((badge, index) => (
<React.Fragment key={badge.id}>
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
<BadgeWrapper
badge={badge}
isEditing={isEditing}
isInChat={isInChat}
onToggle={handleBadgeToggle}
onDelete={handleDelete}
onMouseDown={handleMouseDown}
badgeRefs={badgeRefs}
/>
</React.Fragment>
))}
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
{showEphemeralBadges === true && (
<>
<WebSearch conversationId={conversationId} />
<CodeInterpreter conversationId={conversationId} />
<MCPSelect conversationId={conversationId} />
</>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
zIndex: 10,
pointerEvents: 'none',
}}
>
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
isEditing
isDragging
/>
</div>
)}
</div>
<BadgeRowProvider conversationId={conversationId}>
<div ref={containerRef} className="relative flex flex-wrap items-center gap-2">
{showEphemeralBadges === true && <ToolsDropdown />}
{tempBadges.map((badge, index) => (
<React.Fragment key={badge.id}>
{dragState.draggedBadge && dragState.insertIndex === index && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
<BadgeWrapper
badge={badge}
isEditing={isEditing}
isInChat={isInChat}
onToggle={handleBadgeToggle}
onDelete={handleDelete}
onMouseDown={handleMouseDown}
badgeRefs={badgeRefs}
/>
</React.Fragment>
))}
{dragState.draggedBadge && dragState.insertIndex === tempBadges.length && ghostBadge && (
<div className="badge-icon h-full">
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isEditing={isEditing}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
/>
</div>
)}
{showEphemeralBadges === true && (
<>
<WebSearch />
<CodeInterpreter />
<FileSearch />
<MCPSelect />
</>
)}
{ghostBadge && (
<div
className="ghost-badge h-full"
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translateX(${dragState.mouseX - dragState.offsetX - (containerRectRef.current?.left || 0)}px)`,
zIndex: 10,
pointerEvents: 'none',
}}
>
<Badge
id={ghostBadge.id}
icon={ghostBadge.icon as LucideIcon}
label={ghostBadge.label}
isActive={dragState.draggedBadgeActive}
isAvailable={ghostBadge.isAvailable}
isInChat={isInChat}
isEditing
isDragging
/>
</div>
)}
</div>
<ToolDialogs />
</BadgeRowProvider>
);
}

View file

@ -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<HTMLInputElement>(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<boolean>(
`${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`,
isCodeToggleEnabled,
setValue,
storageCondition,
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, 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) && (
<CheckboxButton
ref={triggerRef}
ref={badgeTriggerRef}
className="max-w-fit"
defaultChecked={runCode}
checked={runCode}
setValue={debouncedChange}
label={localize('com_assistants_code_interpreter')}
isCheckedClassName="border-purple-600/40 bg-purple-500/10 hover:bg-purple-700/10"
icon={<TerminalSquareIcon className="icon-md" />}
/>
<ApiKeyDialog
onSubmit={onSubmit}
isOpen={isDialogOpen}
triggerRef={triggerRef}
register={methods.register}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
handleSubmit={methods.handleSubmit}
isToolAuthenticated={isAuthenticated}
isUserProvided={authType === AuthType.USER_PROVIDED}
/>
</>
)
);
}

View file

@ -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) && (
<CheckboxButton
className="max-w-fit"
checked={fileSearchEnabled}
setValue={debouncedChange}
label={localize('com_assistants_file_search')}
isCheckedClassName="border-green-600/40 bg-green-500/10 hover:bg-green-700/10"
icon={<VectorIcon className="icon-md" />}
/>
)}
</>
);
}
export default memo(FileSearch);

View file

@ -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 <AttachFileMenu disabled={disableInputs} />;
}
if (endpointSupportsFiles && !isUploadDisabled) {
return <AttachFile disabled={disableInputs} />;
if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
return <AttachFileMenu disabled={disableInputs} conversationId={conversationId} />;
}
return null;

View file

@ -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<HTMLInputElement>(null);
const [isPopoverActive, setIsPopoverActive] = useState(false);
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
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: <FileSearch className="icon-md" />,
@ -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: <TerminalSquareIcon className="icon-md" />,
@ -87,7 +96,7 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
}
return items;
}, [capabilities, localize, setToolResource]);
}, [capabilities, localize, setToolResource, setEphemeralAgent]);
const menuTrigger = (
<TooltipAnchor
@ -132,4 +141,4 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
);
};
export default React.memo(AttachFile);
export default React.memo(AttachFileMenu);

View file

@ -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;

View file

@ -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<string | null>(null);
const { mcpSelect } = useBadgeRowContext();
const { mcpValues, setMCPValues, mcpServerNames, mcpToolDetails, isPinned } = mcpSelect;
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<McpServerInfo | null>(null);
const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
select: (data: TPlugin[]) => {
const mcpToolsMap = new Map<string, McpServerInfo>();
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<string[]>(
`${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<string, string>) => {
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}`}
>
<Settings2 className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
<SettingsIcon className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
</button>
</div>
);
@ -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;
}

View file

@ -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 (
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
render={
<Ariakit.MenuButton className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover" />
}
>
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<span>{localize('com_ui_mcp_servers')}</span>
<ChevronRight className="ml-auto h-3 w-3" />
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsMCPPinned(!isMCPPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isMCPPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isMCPPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isMCPPinned} />
</div>
</button>
</Ariakit.MenuItem>
<Ariakit.Menu
gutter={-4}
shift={-8}
unmountOnHide
portal={true}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary p-1 shadow-lg',
)}
>
{mcpServerNames.map((serverName) => (
<Ariakit.MenuItem
key={serverName}
onClick={(event) => {
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',
)}
>
<Ariakit.MenuItemCheck checked={mcpValues?.includes(serverName) ?? false} />
<span>{serverName}</span>
</Ariakit.MenuItem>
))}
</Ariakit.Menu>
</Ariakit.MenuProvider>
);
};
export default React.memo(MCPSubMenu);

View file

@ -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 (
<>
<SearchApiKeyDialog
onSubmit={searchOnSubmit}
authTypes={searchAuthTypes}
isOpen={searchDialogOpen}
onRevoke={searchHandleRevoke}
register={searchMethods.register}
onOpenChange={setSearchDialogOpen}
handleSubmit={searchMethods.handleSubmit}
triggerRefs={[searchMenuTriggerRef, searchBadgeTriggerRef]}
isToolAuthenticated={webSearchAuthData?.authenticated ?? false}
/>
<CodeApiKeyDialog
onSubmit={codeOnSubmit}
isOpen={codeDialogOpen}
onRevoke={codeHandleRevoke}
register={codeMethods.register}
onOpenChange={setCodeDialogOpen}
handleSubmit={codeMethods.handleSubmit}
triggerRefs={[codeMenuTriggerRef, codeBadgeTriggerRef]}
isUserProvided={codeAuthType === AuthType.USER_PROVIDED}
isToolAuthenticated={codeAuthData?.authenticated ?? false}
/>
</>
);
}
export default ToolDialogs;

View file

@ -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: () => (
<div className="px-3 py-2 text-xs font-semibold text-text-secondary">
{localize('com_ui_tools')}
</div>
),
hideOnClick: false,
},
];
items.push({
onClick: handleFileSearchToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<VectorIcon className="icon-md" />
<span>{localize('com_assistants_file_search')}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsFileSearchPinned(!isFileSearchPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isFileSearchPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isFileSearchPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isFileSearchPinned} />
</div>
</button>
</div>
),
});
if (canUseWebSearch) {
items.push({
onClick: handleWebSearchToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<Globe className="icon-md" />
<span>{localize('com_ui_web_search')}</span>
</div>
<div className="flex items-center gap-1">
{showWebSearchSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchDialogOpen(true);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
'text-text-secondary hover:text-text-primary',
)}
aria-label="Configure web search"
ref={searchMenuTriggerRef}
>
<div className="h-4 w-4">
<Settings className="h-4 w-4" />
</div>
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsSearchPinned(!isSearchPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isSearchPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isSearchPinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isSearchPinned} />
</div>
</button>
</div>
</div>
),
});
}
if (canRunCode) {
items.push({
onClick: handleCodeInterpreterToggle,
hideOnClick: false,
render: (props) => (
<div {...props}>
<div className="flex items-center gap-2">
<TerminalSquareIcon className="icon-md" />
<span>{localize('com_assistants_code_interpreter')}</span>
</div>
<div className="flex items-center gap-1">
{showCodeSettings && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodeDialogOpen(true);
}}
ref={codeMenuTriggerRef}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
'text-text-secondary hover:text-text-primary',
)}
aria-label="Configure code interpreter"
>
<div className="h-4 w-4">
<Settings className="h-4 w-4" />
</div>
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsCodePinned(!isCodePinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-secondary hover:shadow-sm',
!isCodePinned && 'text-text-primary hover:text-text-primary',
)}
aria-label={isCodePinned ? 'Unpin' : 'Pin'}
>
<div className="h-4 w-4">
<PinIcon unpin={isCodePinned} />
</div>
</button>
</div>
</div>
),
});
}
if (mcpServerNames && mcpServerNames.length > 0) {
items.push({
hideOnClick: false,
render: (props) => (
<MCPSubMenu
{...props}
mcpValues={mcpValues}
mcpServerNames={mcpServerNames}
isMCPPinned={isMCPPinned}
setIsMCPPinned={setIsMCPPinned}
handleMCPToggle={handleMCPToggle}
/>
),
});
}
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 = (
<TooltipAnchor
render={
<Ariakit.MenuButton
disabled={isDisabled}
id="tools-dropdown-button"
aria-label="Tools Options"
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
)}
>
<div className="flex w-full items-center justify-center gap-2">
<Settings2 className="icon-md" />
</div>
</Ariakit.MenuButton>
}
id="tools-dropdown-button"
description={localize('com_ui_tools')}
disabled={isDisabled}
/>
);
return (
<DropdownPopup
itemClassName="flex w-full cursor-pointer items-center justify-between hover:bg-surface-hover gap-5"
menuId="tools-dropdown-menu"
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
unmountOnHide={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"
/>
);
};
export default React.memo(ToolsDropdown);

View file

@ -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<HTMLInputElement>(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<boolean>(
`${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`,
isWebSearchToggleEnabled,
setValue,
storageCondition,
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>, 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) && (
<CheckboxButton
ref={triggerRef}
ref={badgeTriggerRef}
className="max-w-fit"
defaultChecked={webSearch}
checked={webSearch}
setValue={debouncedChange}
label={localize('com_ui_search')}
isCheckedClassName="border-blue-600/40 bg-blue-500/10 hover:bg-blue-700/10"
icon={<Globe className="icon-md" />}
/>
<ApiKeyDialog
onSubmit={onSubmit}
authTypes={authTypes}
isOpen={isDialogOpen}
triggerRef={triggerRef}
register={methods.register}
onRevoke={handleRevokeApiKey}
onOpenChange={setIsDialogOpen}
handleSubmit={methods.handleSubmit}
isToolAuthenticated={isAuthenticated}
/>
</>
)
);
}

View file

@ -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 &&

View file

@ -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<ApiKeyFormData>;
handleSubmit: UseFormHandleSubmit<ApiKeyFormData>;
triggerRef?: RefObject<HTMLInputElement>;
triggerRef?: RefObject<HTMLInputElement | HTMLButtonElement>;
triggerRefs?: RefObject<HTMLInputElement | HTMLButtonElement>[];
}) {
const localize = useLocalize();
const languageIcons = [
@ -41,7 +43,12 @@ export default function ApiKeyDialog({
];
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<OGDialog
open={isOpen}
onOpenChange={onOpenChange}
triggerRef={triggerRef}
triggerRefs={triggerRefs}
>
<OGDialogTemplate
className="w-11/12 sm:w-[450px]"
title=""

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { MCP } from 'librechat-data-provider/dist/types/types/assistants';
import type { MCP } from 'librechat-data-provider';
import MCPAuth from '~/components/SidePanel/Builder/MCPAuth';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import { Label, Checkbox } from '~/components/ui';

View file

@ -21,6 +21,7 @@ export default function ApiKeyDialog({
register,
handleSubmit,
triggerRef,
triggerRefs,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
@ -30,7 +31,8 @@ export default function ApiKeyDialog({
isToolAuthenticated: boolean;
register: UseFormRegister<SearchApiKeyFormData>;
handleSubmit: UseFormHandleSubmit<SearchApiKeyFormData>;
triggerRef?: React.RefObject<HTMLInputElement>;
triggerRef?: React.RefObject<HTMLInputElement | HTMLButtonElement>;
triggerRefs?: React.RefObject<HTMLInputElement | HTMLButtonElement>[];
}) {
const localize = useLocalize();
const { data: config } = useGetStartupConfig();
@ -181,7 +183,12 @@ export default function ApiKeyDialog({
}
return (
<OGDialog open={isOpen} onOpenChange={onOpenChange} triggerRef={triggerRef}>
<OGDialog
open={isOpen}
onOpenChange={onOpenChange}
triggerRef={triggerRef}
triggerRefs={triggerRefs}
>
<OGDialogTemplate
className="w-11/12 sm:w-[500px]"
title=""

View file

@ -1,15 +1,31 @@
export default function MCPIcon() {
export default function MCPIcon({ className }: { className?: string }) {
return (
<svg
width="195"
height="195"
viewBox="0 2 195 195"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
className="h-4 w-4"
className={className}
>
<path d="M11.016 2.099a3.998 3.998 0 0 1 5.58.072l.073.074a3.991 3.991 0 0 1 1.058 3.318 3.994 3.994 0 0 1 3.32 1.06l.073.071.048.047.071.075a3.998 3.998 0 0 1 0 5.506l-.071.074-8.183 8.182-.034.042a.267.267 0 0 0 .034.335l1.68 1.68a.8.8 0 0 1-1.131 1.13l-1.68-1.679a1.866 1.866 0 0 1-.034-2.604l8.26-8.261a2.4 2.4 0 0 0-.044-3.349l-.047-.047-.044-.043a2.4 2.4 0 0 0-3.349.043l-6.832 6.832-.03.029a.8.8 0 0 1-1.1-1.16l6.876-6.875a2.4 2.4 0 0 0-.044-3.35l-.179-.161a2.399 2.399 0 0 0-3.169.119l-.045.043-9.047 9.047-.03.028a.8.8 0 0 1-1.1-1.16l9.046-9.046.074-.072Z" />
<path d="M13.234 4.404a.8.8 0 0 1 1.1 1.16l-6.69 6.691a2.399 2.399 0 1 0 3.393 3.393l6.691-6.692a.8.8 0 0 1 1.131 1.131l-6.691 6.692a4 4 0 0 1-5.581.07l-.073-.07a3.998 3.998 0 0 1 0-5.655l6.69-6.691.03-.029Z" />
<path
d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177"
stroke="currentColor"
strokeWidth="12"
strokeLinecap="round"
/>
<path
d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52"
stroke="currentColor"
strokeWidth="12"
strokeLinecap="round"
/>
<path
d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822"
stroke="currentColor"
strokeWidth="12"
strokeLinecap="round"
/>
</svg>
);
}

View file

@ -0,0 +1,15 @@
export default function VectorIcon({ className }: { className?: string }) {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
aria-label=""
>
<path d="M7.45996 14.375C7.45996 13.3616 6.63844 12.54 5.625 12.54C4.61156 12.54 3.79004 13.3616 3.79004 14.375C3.79004 15.3884 4.61156 16.21 5.625 16.21C6.63844 16.21 7.45996 15.3884 7.45996 14.375ZM16.21 14.375C16.21 13.3616 15.3884 12.54 14.375 12.54C13.3616 12.54 12.54 13.3616 12.54 14.375C12.54 15.3884 13.3616 16.21 14.375 16.21C15.3884 16.21 16.21 15.3884 16.21 14.375ZM7.45996 5.625C7.45996 4.61156 6.63844 3.79004 5.625 3.79004C4.61156 3.79004 3.79004 4.61156 3.79004 5.625C3.79004 6.63844 4.61156 7.45996 5.625 7.45996C6.63844 7.45996 7.45996 6.63844 7.45996 5.625ZM16.21 5.625C16.21 4.61156 15.3884 3.79004 14.375 3.79004C13.3616 3.79004 12.54 4.61156 12.54 5.625C12.54 6.63844 13.3616 7.45996 14.375 7.45996C15.3884 7.45996 16.21 6.63844 16.21 5.625ZM17.54 14.375C17.54 16.123 16.123 17.54 14.375 17.54C12.627 17.54 11.21 16.123 11.21 14.375C11.21 12.627 12.627 11.21 14.375 11.21C16.123 11.21 17.54 12.627 17.54 14.375ZM8.79004 5.625C8.79004 7.37298 7.37298 8.79004 5.625 8.79004C3.87702 8.79004 2.45996 7.37298 2.45996 5.625C2.45996 3.87702 3.87702 2.45996 5.625 2.45996C7.37298 2.45996 8.79004 3.87702 8.79004 5.625ZM17.54 5.625C17.54 7.37298 16.123 8.79004 14.375 8.79004C13.7416 8.79004 13.153 8.60173 12.6582 8.28125L8.28125 12.6582C8.60173 13.153 8.79004 13.7416 8.79004 14.375C8.79004 16.123 7.37298 17.54 5.625 17.54C3.87702 17.54 2.45996 16.123 2.45996 14.375C2.45996 12.627 3.87702 11.21 5.625 11.21C6.25794 11.21 6.84623 11.3977 7.34082 11.7178L11.7178 7.34082C11.3977 6.84623 11.21 6.25794 11.21 5.625C11.21 3.87702 12.627 2.45996 14.375 2.45996C16.123 2.45996 17.54 3.87702 17.54 5.625Z" />
</svg>
);
}

View file

@ -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';

View file

@ -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<HTMLInputElement>, isChecked: boolean) => void;
setValue?: (values: { e?: React.ChangeEvent<HTMLInputElement>; 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<HTMLInputElement>) => {
@ -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 (
<Checkbox
ref={ref}
store={checkbox}
onChange={onChange}
defaultChecked={defaultChecked}
className={cn(
// Base styling from MultiSelect's selectClassName
'group relative inline-flex items-center justify-center gap-1.5',

View file

@ -1,31 +0,0 @@
export default function MCPIcon({ className }: { className?: string }) {
return (
<svg
width="195"
height="195"
viewBox="0 2 195 195"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M25 97.8528L92.8823 29.9706C102.255 20.598 117.451 20.598 126.823 29.9706V29.9706C136.196 39.3431 136.196 54.5391 126.823 63.9117L75.5581 115.177"
stroke="currentColor"
strokeWidth="12"
strokeLinecap="round"
/>
<path
d="M76.2653 114.47L126.823 63.9117C136.196 54.5391 151.392 54.5391 160.765 63.9117L161.118 64.2652C170.491 73.6378 170.491 88.8338 161.118 98.2063L99.7248 159.6C96.6006 162.724 96.6006 167.789 99.7248 170.913L112.331 183.52"
stroke="currentColor"
strokeWidth="12"
strokeLinecap="round"
/>
<path
d="M109.853 46.9411L59.6482 97.1457C50.2757 106.518 50.2757 121.714 59.6482 131.087V131.087C69.0208 140.459 84.2168 140.459 93.5894 131.087L143.794 80.8822"
stroke="currentColor"
strokeWidth="12"
strokeLinecap="round"
/>
</svg>
);
}

View file

@ -5,16 +5,26 @@ import { cn } from '~/utils';
interface OGDialogProps extends DialogPrimitive.DialogProps {
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>;
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | null>[];
}
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
({ 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);
};

View file

@ -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';

View file

@ -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<File[]>([]);
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({

View file

@ -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({

View file

@ -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';

View file

@ -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<ApiKeyFormData>();
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const badgeTriggerRef = useRef<HTMLInputElement>(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,
};
}

View file

@ -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<string | null>(null);
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
select: (data: TPlugin[]) => {
const mcpToolsMap = new Map<string, McpServerInfo>();
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<string[]>(
`${LocalStorageKeys.LAST_MCP_}${key}`,
mcpState,
setSelectedValues,
storageCondition,
);
const [isPinned, setIsPinned] = useLocalStorage<boolean>(
`${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,
};
}

View file

@ -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<SearchApiKeyFormData>();
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const badgeTriggerRef = useRef<HTMLInputElement>(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,
};
}

View file

@ -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<VerifyToolAuthResponse>;
};
}
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<boolean>(
`${localStorageKey}${key}`,
isToolEnabled,
undefined,
storageCondition,
);
const [isPinned, setIsPinned] = useLocalStorage<boolean>(`${localStorageKey}pinned`, false);
const handleChange = useCallback(
({ e, isChecked }: { e?: React.ChangeEvent<HTMLInputElement>; 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,
};
}

View file

@ -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;

View file

@ -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;
};

41
package-lock.json generated
View file

@ -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"
}

View file

@ -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';

View file

@ -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'],
});
});
});

View file

@ -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<NonNullable<DynamicSettingProps['conversation']>, 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<NonNullable<DynamicSettingProps['conversation']>, 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<DynamicSettingProps['conversation']>,
LibreChatKeys
>,
maxContextTokens,
promptPrefix,
resendFiles,
modelLabel,
};
}

View file

@ -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) => {

View file

@ -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<string, string> = {
[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<EModelEndpoint | string>([
@ -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 {

View file

@ -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,

View file

@ -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<void> {
return request.post(endpoints.abortRequest(endpoint), { arg: { abortKey, message } });
}
export function revokeUserKey(name: string): Promise<unknown> {
return request.delete(endpoints.revokeUserKey(name));
}

View file

@ -83,7 +83,7 @@ const createDefinition = (
return { ...base, ...overrides } as SettingDefinition;
};
const librechat: Record<string, SettingDefinition> = {
export const librechat = {
modelLabel: {
key: 'modelLabel',
label: 'com_endpoint_custom_name',
@ -94,7 +94,7 @@ const librechat: Record<string, SettingDefinition> = {
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<string, SettingDefinition> = {
descriptionCode: true,
optionType: 'model',
columnSpan: 2,
},
} as const,
resendFiles: {
key: 'resendFiles',
label: 'com_endpoint_plug_resend_files',
@ -120,7 +120,7 @@ const librechat: Record<string, SettingDefinition> = {
optionType: 'conversation',
showDefault: false,
columnSpan: 2,
},
} as const,
promptPrefix: {
key: 'promptPrefix',
label: 'com_endpoint_prompt_prefix',
@ -131,7 +131,7 @@ const librechat: Record<string, SettingDefinition> = {
placeholder: 'com_endpoint_openai_prompt_prefix_placeholder',
placeholderCode: true,
optionType: 'model',
},
} as const,
};
const openAIParams: Record<string, SettingDefinition> = {

View file

@ -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) {

View file

@ -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<t.TSharedMessagesResponse>,

View file

@ -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,

View file

@ -98,6 +98,7 @@ export type TEndpointOption = Pick<
export type TEphemeralAgent = {
mcp?: string[];
web_search?: boolean;
file_search?: boolean;
execute_code?: boolean;
};