mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
🤖 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:
parent
d835f48307
commit
01e9b196bc
67 changed files with 1468 additions and 1433 deletions
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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' });
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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({
|
||||
|
|
83
client/src/Providers/BadgeRowContext.tsx
Normal file
83
client/src/Providers/BadgeRowContext.tsx
Normal 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>;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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,7 +317,9 @@ function BadgeRow({
|
|||
}, [dragState.draggedBadge, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
|
@ -355,9 +361,10 @@ function BadgeRow({
|
|||
)}
|
||||
{showEphemeralBadges === true && (
|
||||
<>
|
||||
<WebSearch conversationId={conversationId} />
|
||||
<CodeInterpreter conversationId={conversationId} />
|
||||
<MCPSelect conversationId={conversationId} />
|
||||
<WebSearch />
|
||||
<CodeInterpreter />
|
||||
<FileSearch />
|
||||
<MCPSelect />
|
||||
</>
|
||||
)}
|
||||
{ghostBadge && (
|
||||
|
@ -385,6 +392,8 @@ function BadgeRow({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ToolDialogs />
|
||||
</BadgeRowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
28
client/src/components/Chat/Input/FileSearch.tsx
Normal file
28
client/src/components/Chat/Input/FileSearch.tsx
Normal 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);
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
96
client/src/components/Chat/Input/MCPSubMenu.tsx
Normal file
96
client/src/components/Chat/Input/MCPSubMenu.tsx
Normal 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);
|
66
client/src/components/Chat/Input/ToolDialogs.tsx
Normal file
66
client/src/components/Chat/Input/ToolDialogs.tsx
Normal 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;
|
322
client/src/components/Chat/Input/ToolsDropdown.tsx
Normal file
322
client/src/components/Chat/Input/ToolsDropdown.tsx
Normal 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);
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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=""
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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=""
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
15
client/src/components/svg/VectorIcon.tsx
Normal file
15
client/src/components/svg/VectorIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
121
client/src/hooks/Plugins/useMCPSelect.ts
Normal file
121
client/src/hooks/Plugins/useMCPSelect.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
119
client/src/hooks/Plugins/useToolToggle.ts
Normal file
119
client/src/hooks/Plugins/useToolToggle.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
let textIndex = null;
|
||||
|
||||
|
|
|
@ -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
41
package-lock.json
generated
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
189
packages/api/src/utils/llm.test.ts
Normal file
189
packages/api/src/utils/llm.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
47
packages/api/src/utils/llm.ts
Normal file
47
packages/api/src/utils/llm.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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> = {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -98,6 +98,7 @@ export type TEndpointOption = Pick<
|
|||
export type TEphemeralAgent = {
|
||||
mcp?: string[];
|
||||
web_search?: boolean;
|
||||
file_search?: boolean;
|
||||
execute_code?: boolean;
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue