mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 08:12:00 +02:00
Merge branch 'wppsearch' of https://github.com/i-cuboid/WppAgentLayer into wppsearch
This commit is contained in:
commit
1a4fdce5c3
134 changed files with 5876 additions and 631 deletions
25
api/cache/cacheConfig.js
vendored
25
api/cache/cacheConfig.js
vendored
|
@ -1,4 +1,5 @@
|
|||
const fs = require('fs');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { math, isEnabled } = require('@librechat/api');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
|
||||
|
@ -34,13 +35,35 @@ if (FORCED_IN_MEMORY_CACHE_NAMESPACES.length > 0) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Helper function to safely read Redis CA certificate from file
|
||||
* @returns {string|null} The contents of the CA certificate file, or null if not set or on error
|
||||
*/
|
||||
const getRedisCA = () => {
|
||||
const caPath = process.env.REDIS_CA;
|
||||
if (!caPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(caPath)) {
|
||||
return fs.readFileSync(caPath, 'utf8');
|
||||
} else {
|
||||
logger.warn(`Redis CA certificate file not found: ${caPath}`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read Redis CA certificate file '${caPath}':`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const cacheConfig = {
|
||||
FORCED_IN_MEMORY_CACHE_NAMESPACES,
|
||||
USE_REDIS,
|
||||
REDIS_URI: process.env.REDIS_URI,
|
||||
REDIS_USERNAME: process.env.REDIS_USERNAME,
|
||||
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
|
||||
REDIS_CA: process.env.REDIS_CA ? fs.readFileSync(process.env.REDIS_CA, 'utf8') : null,
|
||||
REDIS_CA: getRedisCA(),
|
||||
REDIS_KEY_PREFIX: process.env[REDIS_KEY_PREFIX_VAR] || REDIS_KEY_PREFIX || '',
|
||||
REDIS_MAX_LISTENERS: math(process.env.REDIS_MAX_LISTENERS, 40),
|
||||
REDIS_PING_INTERVAL: math(process.env.REDIS_PING_INTERVAL, 0),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
const { MCPManager, FlowStateManager } = require('@librechat/api');
|
||||
const { EventSource } = require('eventsource');
|
||||
const { Time } = require('librechat-data-provider');
|
||||
const { MCPManager, FlowStateManager, OAuthReconnectionManager } = require('@librechat/api');
|
||||
const logger = require('./winston');
|
||||
|
||||
global.EventSource = EventSource;
|
||||
|
@ -26,4 +26,6 @@ module.exports = {
|
|||
createMCPManager: MCPManager.createInstance,
|
||||
getMCPManager: MCPManager.getInstance,
|
||||
getFlowStateManager,
|
||||
createOAuthReconnectionManager: OAuthReconnectionManager.createInstance,
|
||||
getOAuthReconnectionManager: OAuthReconnectionManager.getInstance,
|
||||
};
|
||||
|
|
|
@ -49,6 +49,14 @@ const createAgent = async (agentData) => {
|
|||
*/
|
||||
const getAgent = async (searchParameter) => await Agent.findOne(searchParameter).lean();
|
||||
|
||||
/**
|
||||
* Get multiple agent documents based on the provided search parameters.
|
||||
*
|
||||
* @param {Object} searchParameter - The search parameters to find agents.
|
||||
* @returns {Promise<Agent[]>} Array of agent documents as plain objects.
|
||||
*/
|
||||
const getAgents = async (searchParameter) => await Agent.find(searchParameter).lean();
|
||||
|
||||
/**
|
||||
* Load an agent based on the provided ID
|
||||
*
|
||||
|
@ -835,6 +843,7 @@ const countPromotedAgents = async () => {
|
|||
|
||||
module.exports = {
|
||||
getAgent,
|
||||
getAgents,
|
||||
loadAgent,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
|
|
|
@ -42,7 +42,7 @@ const getToolFilesByIds = async (fileIds, toolResourceSet) => {
|
|||
$or: [],
|
||||
};
|
||||
|
||||
if (toolResourceSet.has(EToolResources.ocr)) {
|
||||
if (toolResourceSet.has(EToolResources.context)) {
|
||||
filter.$or.push({ text: { $exists: true, $ne: null }, context: FileContext.agents });
|
||||
}
|
||||
if (toolResourceSet.has(EToolResources.file_search)) {
|
||||
|
|
|
@ -11,8 +11,9 @@ const {
|
|||
registerUser,
|
||||
} = require('~/server/services/AuthService');
|
||||
const { findUser, getUserById, deleteAllUserSessions, findSession } = require('~/models');
|
||||
const { getOpenIdConfig } = require('~/strategies');
|
||||
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
||||
const { getOAuthReconnectionManager } = require('~/config');
|
||||
const { getOpenIdConfig } = require('~/strategies');
|
||||
|
||||
const registrationController = async (req, res) => {
|
||||
try {
|
||||
|
@ -96,14 +97,25 @@ const refreshController = async (req, res) => {
|
|||
return res.status(200).send({ token, user });
|
||||
}
|
||||
|
||||
// Find the session with the hashed refresh token
|
||||
const session = await findSession({
|
||||
userId: userId,
|
||||
refreshToken: refreshToken,
|
||||
});
|
||||
/** Session with the hashed refresh token */
|
||||
const session = await findSession(
|
||||
{
|
||||
userId: userId,
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
{ lean: false },
|
||||
);
|
||||
|
||||
if (session && session.expiration > new Date()) {
|
||||
const token = await setAuthTokens(userId, res, session._id);
|
||||
const token = await setAuthTokens(userId, res, session);
|
||||
|
||||
// trigger OAuth MCP server reconnection asynchronously (best effort)
|
||||
void getOAuthReconnectionManager()
|
||||
.reconnectServers(userId)
|
||||
.catch((err) => {
|
||||
logger.error('Error reconnecting OAuth MCP servers:', err);
|
||||
});
|
||||
|
||||
res.status(200).send({ token, user });
|
||||
} else if (req?.query?.retry) {
|
||||
// Retrying from a refresh token request that failed (401)
|
||||
|
|
|
@ -99,6 +99,12 @@ const getAvailableTools = async (req, res) => {
|
|||
let toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||
let prelimCachedTools;
|
||||
|
||||
if (toolDefinitions == null && appConfig?.availableTools != null) {
|
||||
logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config');
|
||||
await setCachedTools(appConfig.availableTools, { isGlobal: true });
|
||||
toolDefinitions = appConfig.availableTools;
|
||||
}
|
||||
|
||||
/** @type {import('@librechat/api').LCManifestTool[]} */
|
||||
let pluginManifest = availableTools;
|
||||
|
||||
|
@ -142,10 +148,10 @@ const getAvailableTools = async (req, res) => {
|
|||
/** Filter plugins based on availability and add MCP-specific auth config */
|
||||
const toolsOutput = [];
|
||||
for (const plugin of authenticatedPlugins) {
|
||||
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
|
||||
const isToolDefined = toolDefinitions?.[plugin.pluginKey] !== undefined;
|
||||
const isToolkit =
|
||||
plugin.toolkit === true &&
|
||||
Object.keys(toolDefinitions).some(
|
||||
Object.keys(toolDefinitions ?? {}).some(
|
||||
(key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
|
||||
);
|
||||
|
||||
|
|
|
@ -682,5 +682,122 @@ describe('PluginController', () => {
|
|||
// Should handle null toolDefinitions gracefully
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should handle undefined toolDefinitions when checking isToolDefined (traversaal_search bug)', async () => {
|
||||
// This test reproduces the bug where toolDefinitions is undefined
|
||||
// and accessing toolDefinitions[plugin.pluginKey] causes a TypeError
|
||||
const mockPlugin = {
|
||||
name: 'Traversaal Search',
|
||||
pluginKey: 'traversaal_search',
|
||||
description: 'Search plugin',
|
||||
};
|
||||
|
||||
// Add the plugin to availableTools
|
||||
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
||||
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
|
||||
// First call returns null for user tools
|
||||
getCachedTools.mockResolvedValueOnce(null);
|
||||
|
||||
mockReq.config = {
|
||||
mcpConfig: null,
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
};
|
||||
|
||||
// CRITICAL: Second call (with includeGlobal: true) returns undefined
|
||||
// This is what causes the bug when trying to access toolDefinitions[plugin.pluginKey]
|
||||
getCachedTools.mockResolvedValueOnce(undefined);
|
||||
|
||||
// This should not throw an error with the optional chaining fix
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
// Should handle undefined toolDefinitions gracefully and return empty array
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should re-initialize tools from appConfig when cache returns null', async () => {
|
||||
// Setup: Initial state with tools in appConfig
|
||||
const mockAppTools = {
|
||||
tool1: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'tool1',
|
||||
description: 'Tool 1',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
tool2: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'tool2',
|
||||
description: 'Tool 2',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Add matching plugins to availableTools
|
||||
require('~/app/clients/tools').availableTools.push(
|
||||
{ name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' },
|
||||
{ name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
|
||||
);
|
||||
|
||||
// First call: Simulate cache cleared state (returns null for both global and user tools)
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValueOnce(null); // User tools
|
||||
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
|
||||
|
||||
mockReq.config = {
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
availableTools: mockAppTools,
|
||||
};
|
||||
|
||||
// Mock setCachedTools to verify it's called to re-initialize
|
||||
const { setCachedTools } = require('~/server/services/Config');
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
// Should have re-initialized the cache with tools from appConfig
|
||||
expect(setCachedTools).toHaveBeenCalledWith(mockAppTools, { isGlobal: true });
|
||||
|
||||
// Should still return tools successfully
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData).toHaveLength(2);
|
||||
expect(responseData.find((t) => t.pluginKey === 'tool1')).toBeDefined();
|
||||
expect(responseData.find((t) => t.pluginKey === 'tool2')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle cache clear without appConfig.availableTools gracefully', async () => {
|
||||
// Setup: appConfig without availableTools
|
||||
getAppConfig.mockResolvedValue({
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
// No availableTools property
|
||||
});
|
||||
|
||||
// Clear availableTools array
|
||||
require('~/app/clients/tools').availableTools.length = 0;
|
||||
|
||||
// Cache returns null (cleared state)
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCachedTools.mockResolvedValueOnce(null); // User tools
|
||||
getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared)
|
||||
|
||||
mockReq.config = {
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
// No availableTools
|
||||
};
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
// Should handle gracefully without crashing
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -158,7 +158,7 @@ describe('duplicateAgent', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should handle tool_resources.ocr correctly', async () => {
|
||||
it('should convert `tool_resources.ocr` to `tool_resources.context`', async () => {
|
||||
const mockAgent = {
|
||||
id: 'agent_123',
|
||||
name: 'Test Agent',
|
||||
|
@ -178,7 +178,7 @@ describe('duplicateAgent', () => {
|
|||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tool_resources: {
|
||||
ocr: { enabled: true, config: 'test' },
|
||||
context: { enabled: true, config: 'test' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
@ -2,7 +2,12 @@ const { z } = require('zod');
|
|||
const fs = require('fs').promises;
|
||||
const { nanoid } = require('nanoid');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
|
||||
const {
|
||||
agentCreateSchema,
|
||||
agentUpdateSchema,
|
||||
mergeAgentOcrConversion,
|
||||
convertOcrToContextInPlace,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
Constants,
|
||||
|
@ -198,19 +203,32 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
|
|||
* @param {object} req.params - Request params
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @param {AgentUpdateParams} req.body - The Agent update parameters.
|
||||
* @returns {Agent} 200 - success response - application/json
|
||||
* @returns {Promise<Agent>} 200 - success response - application/json
|
||||
*/
|
||||
const updateAgentHandler = async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const validatedData = agentUpdateSchema.parse(req.body);
|
||||
const { _id, ...updateData } = removeNullishValues(validatedData);
|
||||
|
||||
// Convert OCR to context in incoming updateData
|
||||
convertOcrToContextInPlace(updateData);
|
||||
|
||||
const existingAgent = await getAgent({ id });
|
||||
|
||||
if (!existingAgent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
// Convert legacy OCR tool resource to context format in existing agent
|
||||
const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
|
||||
if (ocrConversion.tool_resources) {
|
||||
updateData.tool_resources = ocrConversion.tool_resources;
|
||||
}
|
||||
if (ocrConversion.tools) {
|
||||
updateData.tools = ocrConversion.tools;
|
||||
}
|
||||
|
||||
let updatedAgent =
|
||||
Object.keys(updateData).length > 0
|
||||
? await updateAgent({ id }, updateData, {
|
||||
|
@ -255,7 +273,7 @@ const updateAgentHandler = async (req, res) => {
|
|||
* @param {object} req - Express Request
|
||||
* @param {object} req.params - Request params
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 201 - success response - application/json
|
||||
* @returns {Promise<Agent>} 201 - success response - application/json
|
||||
*/
|
||||
const duplicateAgentHandler = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
@ -288,9 +306,19 @@ const duplicateAgentHandler = async (req, res) => {
|
|||
hour12: false,
|
||||
})})`;
|
||||
|
||||
if (_tool_resources?.[EToolResources.context]) {
|
||||
cloneData.tool_resources = {
|
||||
[EToolResources.context]: _tool_resources[EToolResources.context],
|
||||
};
|
||||
}
|
||||
|
||||
if (_tool_resources?.[EToolResources.ocr]) {
|
||||
cloneData.tool_resources = {
|
||||
[EToolResources.ocr]: _tool_resources[EToolResources.ocr],
|
||||
/** Legacy conversion from `ocr` to `context` */
|
||||
[EToolResources.context]: {
|
||||
...(_tool_resources[EToolResources.context] ?? {}),
|
||||
..._tool_resources[EToolResources.ocr],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -382,7 +410,7 @@ const duplicateAgentHandler = async (req, res) => {
|
|||
* @param {object} req - Express Request
|
||||
* @param {object} req.params - Request params
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 200 - success response - application/json
|
||||
* @returns {Promise<Agent>} 200 - success response - application/json
|
||||
*/
|
||||
const deleteAgentHandler = async (req, res) => {
|
||||
try {
|
||||
|
@ -484,7 +512,7 @@ const getListAgentsHandler = async (req, res) => {
|
|||
* @param {Express.Multer.File} req.file - The avatar image file.
|
||||
* @param {object} req.body - Request body
|
||||
* @param {string} [req.body.avatar] - Optional avatar for the agent's avatar.
|
||||
* @returns {Object} 200 - success response - application/json
|
||||
* @returns {Promise<void>} 200 - success response - application/json
|
||||
*/
|
||||
const uploadAgentAvatarHandler = async (req, res) => {
|
||||
try {
|
||||
|
|
|
@ -512,6 +512,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tool_resources: {
|
||||
/** Legacy conversion from `ocr` to `context` */
|
||||
ocr: {
|
||||
file_ids: ['ocr1', 'ocr2'],
|
||||
},
|
||||
|
@ -531,7 +532,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.tool_resources).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.ocr).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.ocr).toBeUndefined();
|
||||
expect(updatedAgent.tool_resources.context).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.execute_code).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.invalid_tool).toBeUndefined();
|
||||
});
|
||||
|
|
|
@ -12,6 +12,7 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const { isEnabled, ErrorController } = require('@librechat/api');
|
||||
const { connectDb, indexSync } = require('~/db');
|
||||
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
|
||||
const createValidateImageRequest = require('./middleware/validateImageRequest');
|
||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||
const { updateInterfacePermissions } = require('~/models/interface');
|
||||
|
@ -154,7 +155,7 @@ const startServer = async () => {
|
|||
res.send(updatedIndexHtml);
|
||||
});
|
||||
|
||||
app.listen(port, host, () => {
|
||||
app.listen(port, host, async () => {
|
||||
if (host === '0.0.0.0') {
|
||||
logger.info(
|
||||
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
|
||||
|
@ -163,7 +164,9 @@ const startServer = async () => {
|
|||
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
||||
}
|
||||
|
||||
initializeMCPs().then(() => checkMigrations());
|
||||
await initializeMCPs();
|
||||
await initializeOAuthReconnectManager();
|
||||
await checkMigrations();
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { PermissionBits, hasPermissions, ResourceType } = require('librechat-data-provider');
|
||||
const { getEffectivePermissions } = require('~/server/services/PermissionService');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { getAgents } = require('~/models/Agent');
|
||||
const { getFiles } = require('~/models/File');
|
||||
|
||||
/**
|
||||
|
@ -10,11 +10,12 @@ const { getFiles } = require('~/models/File');
|
|||
*/
|
||||
const checkAgentBasedFileAccess = async ({ userId, role, fileId }) => {
|
||||
try {
|
||||
// Find agents that have this file in their tool_resources
|
||||
const agentsWithFile = await getAgent({
|
||||
/** Agents that have this file in their tool_resources */
|
||||
const agentsWithFile = await getAgents({
|
||||
$or: [
|
||||
{ 'tool_resources.file_search.file_ids': fileId },
|
||||
{ 'tool_resources.execute_code.file_ids': fileId },
|
||||
{ 'tool_resources.file_search.file_ids': fileId },
|
||||
{ 'tool_resources.context.file_ids': fileId },
|
||||
{ 'tool_resources.ocr.file_ids': fileId },
|
||||
],
|
||||
});
|
||||
|
@ -24,7 +25,7 @@ const checkAgentBasedFileAccess = async ({ userId, role, fileId }) => {
|
|||
}
|
||||
|
||||
// Check if user has access to any of these agents
|
||||
for (const agent of Array.isArray(agentsWithFile) ? agentsWithFile : [agentsWithFile]) {
|
||||
for (const agent of agentsWithFile) {
|
||||
// Check if user is the agent author
|
||||
if (agent.author && agent.author.toString() === userId) {
|
||||
logger.debug(`[fileAccess] User is author of agent ${agent.id}`);
|
||||
|
@ -83,7 +84,6 @@ const fileAccess = async (req, res, next) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Get the file
|
||||
const [file] = await getFiles({ file_id: fileId });
|
||||
if (!file) {
|
||||
return res.status(404).json({
|
||||
|
@ -92,20 +92,18 @@ const fileAccess = async (req, res, next) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Check if user owns the file
|
||||
if (file.user && file.user.toString() === userId) {
|
||||
req.fileAccess = { file };
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check agent-based access (file inherits agent permissions)
|
||||
/** Agent-based access (file inherits agent permissions) */
|
||||
const hasAgentAccess = await checkAgentBasedFileAccess({ userId, role: userRole, fileId });
|
||||
if (hasAgentAccess) {
|
||||
req.fileAccess = { file };
|
||||
return next();
|
||||
}
|
||||
|
||||
// No access
|
||||
logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`);
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
|
|
483
api/server/middleware/accessResources/fileAccess.spec.js
Normal file
483
api/server/middleware/accessResources/fileAccess.spec.js
Normal file
|
@ -0,0 +1,483 @@
|
|||
const mongoose = require('mongoose');
|
||||
const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { fileAccess } = require('./fileAccess');
|
||||
const { User, Role, AclEntry } = require('~/db/models');
|
||||
const { createAgent } = require('~/models/Agent');
|
||||
const { createFile } = require('~/models/File');
|
||||
|
||||
describe('fileAccess middleware', () => {
|
||||
let mongoServer;
|
||||
let req, res, next;
|
||||
let testUser, otherUser, thirdUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
|
||||
// Create test role
|
||||
await Role.create({
|
||||
name: 'test-role',
|
||||
permissions: {
|
||||
AGENTS: {
|
||||
USE: true,
|
||||
CREATE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create test users
|
||||
testUser = await User.create({
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
name: 'Other User',
|
||||
username: 'otheruser',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
thirdUser = await User.create({
|
||||
email: 'third@example.com',
|
||||
name: 'Third User',
|
||||
username: 'thirduser',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
// Setup request/response objects
|
||||
req = {
|
||||
user: { id: testUser._id.toString(), role: testUser.role },
|
||||
params: {},
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
next = jest.fn();
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('basic file access', () => {
|
||||
test('should allow access when user owns the file', async () => {
|
||||
// Create a file owned by testUser
|
||||
await createFile({
|
||||
user: testUser._id.toString(),
|
||||
file_id: 'file_owned_by_user',
|
||||
filepath: '/test/file.txt',
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
req.params.file_id = 'file_owned_by_user';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.fileAccess).toBeDefined();
|
||||
expect(req.fileAccess.file).toBeDefined();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should deny access when user does not own the file and no agent access', async () => {
|
||||
// Create a file owned by otherUser
|
||||
await createFile({
|
||||
user: otherUser._id.toString(),
|
||||
file_id: 'file_owned_by_other',
|
||||
filepath: '/test/file.txt',
|
||||
filename: 'file.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
req.params.file_id = 'file_owned_by_other';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Forbidden',
|
||||
message: 'Insufficient permissions to access this file',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 404 when file does not exist', async () => {
|
||||
req.params.file_id = 'non_existent_file';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Not Found',
|
||||
message: 'File not found',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 400 when file_id is missing', async () => {
|
||||
// Don't set file_id in params
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Bad Request',
|
||||
message: 'file_id is required',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 401 when user is not authenticated', async () => {
|
||||
req.user = null;
|
||||
req.params.file_id = 'some_file';
|
||||
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent-based file access', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a file owned by otherUser (not testUser)
|
||||
await createFile({
|
||||
user: otherUser._id.toString(),
|
||||
file_id: 'shared_file_via_agent',
|
||||
filepath: '/test/shared.txt',
|
||||
filename: 'shared.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test('should allow access when user is author of agent with file', async () => {
|
||||
// Create agent owned by testUser with the file
|
||||
await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Test Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
tool_resources: {
|
||||
file_search: {
|
||||
file_ids: ['shared_file_via_agent'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
req.params.file_id = 'shared_file_via_agent';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.fileAccess).toBeDefined();
|
||||
expect(req.fileAccess.file).toBeDefined();
|
||||
});
|
||||
|
||||
test('should allow access when user has VIEW permission on agent with file', async () => {
|
||||
// Create agent owned by otherUser
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Shared Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
tool_resources: {
|
||||
execute_code: {
|
||||
file_ids: ['shared_file_via_agent'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Grant VIEW permission to testUser
|
||||
await AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: testUser._id,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: 1, // VIEW permission
|
||||
grantedBy: otherUser._id,
|
||||
});
|
||||
|
||||
req.params.file_id = 'shared_file_via_agent';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.fileAccess).toBeDefined();
|
||||
});
|
||||
|
||||
test('should check file in ocr tool_resources', async () => {
|
||||
await createAgent({
|
||||
id: `agent_ocr_${Date.now()}`,
|
||||
name: 'OCR Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
tool_resources: {
|
||||
ocr: {
|
||||
file_ids: ['shared_file_via_agent'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
req.params.file_id = 'shared_file_via_agent';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.fileAccess).toBeDefined();
|
||||
});
|
||||
|
||||
test('should deny access when user has no permission on agent with file', async () => {
|
||||
// Create agent owned by otherUser without granting permission to testUser
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Private Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
tool_resources: {
|
||||
file_search: {
|
||||
file_ids: ['shared_file_via_agent'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create ACL entry for otherUser only (owner)
|
||||
await AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: otherUser._id,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions
|
||||
grantedBy: otherUser._id,
|
||||
});
|
||||
|
||||
req.params.file_id = 'shared_file_via_agent';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple agents with same file', () => {
|
||||
/**
|
||||
* This test suite verifies that when multiple agents have the same file,
|
||||
* all agents are checked for permissions, not just the first one found.
|
||||
* This ensures users can access files through any agent they have permission for.
|
||||
*/
|
||||
|
||||
test('should check ALL agents with file, not just first one', async () => {
|
||||
// Create a file owned by someone else
|
||||
await createFile({
|
||||
user: otherUser._id.toString(),
|
||||
file_id: 'multi_agent_file',
|
||||
filepath: '/test/multi.txt',
|
||||
filename: 'multi.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
// Create first agent (owned by otherUser, no access for testUser)
|
||||
const agent1 = await createAgent({
|
||||
id: 'agent_no_access',
|
||||
name: 'No Access Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
tool_resources: {
|
||||
file_search: {
|
||||
file_ids: ['multi_agent_file'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create ACL for agent1 - only otherUser has access
|
||||
await AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: otherUser._id,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent1._id,
|
||||
permBits: 15,
|
||||
grantedBy: otherUser._id,
|
||||
});
|
||||
|
||||
// Create second agent (owned by thirdUser, but testUser has VIEW access)
|
||||
const agent2 = await createAgent({
|
||||
id: 'agent_with_access',
|
||||
name: 'Accessible Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: thirdUser._id,
|
||||
tool_resources: {
|
||||
file_search: {
|
||||
file_ids: ['multi_agent_file'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Grant testUser VIEW access to agent2
|
||||
await AclEntry.create({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: testUser._id,
|
||||
principalModel: PrincipalModel.USER,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent2._id,
|
||||
permBits: 1, // VIEW permission
|
||||
grantedBy: thirdUser._id,
|
||||
});
|
||||
|
||||
req.params.file_id = 'multi_agent_file';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
/**
|
||||
* Should succeed because testUser has access to agent2,
|
||||
* even though they don't have access to agent1.
|
||||
* The fix ensures all agents are checked, not just the first one.
|
||||
*/
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.fileAccess).toBeDefined();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should find file in any agent tool_resources type', async () => {
|
||||
// Create a file
|
||||
await createFile({
|
||||
user: otherUser._id.toString(),
|
||||
file_id: 'multi_tool_file',
|
||||
filepath: '/test/tool.txt',
|
||||
filename: 'tool.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
// Agent 1: file in file_search (no access for testUser)
|
||||
await createAgent({
|
||||
id: 'agent_file_search',
|
||||
name: 'File Search Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
tool_resources: {
|
||||
file_search: {
|
||||
file_ids: ['multi_tool_file'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Agent 2: same file in execute_code (testUser has access)
|
||||
await createAgent({
|
||||
id: 'agent_execute_code',
|
||||
name: 'Execute Code Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: thirdUser._id,
|
||||
tool_resources: {
|
||||
execute_code: {
|
||||
file_ids: ['multi_tool_file'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Agent 3: same file in ocr (testUser also has access)
|
||||
await createAgent({
|
||||
id: 'agent_ocr',
|
||||
name: 'OCR Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id, // testUser owns this one
|
||||
tool_resources: {
|
||||
ocr: {
|
||||
file_ids: ['multi_tool_file'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
req.params.file_id = 'multi_tool_file';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
/**
|
||||
* Should succeed because testUser owns agent3,
|
||||
* even if other agents with the file are found first.
|
||||
*/
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.fileAccess).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('should handle agent with empty tool_resources', async () => {
|
||||
await createFile({
|
||||
user: otherUser._id.toString(),
|
||||
file_id: 'orphan_file',
|
||||
filepath: '/test/orphan.txt',
|
||||
filename: 'orphan.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
// Create agent with no files in tool_resources
|
||||
await createAgent({
|
||||
id: `agent_empty_${Date.now()}`,
|
||||
name: 'Empty Resources Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
tool_resources: {},
|
||||
});
|
||||
|
||||
req.params.file_id = 'orphan_file';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
test('should handle agent with null tool_resources', async () => {
|
||||
await createFile({
|
||||
user: otherUser._id.toString(),
|
||||
file_id: 'another_orphan_file',
|
||||
filepath: '/test/orphan2.txt',
|
||||
filename: 'orphan2.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
});
|
||||
|
||||
// Create agent with null tool_resources
|
||||
await createAgent({
|
||||
id: `agent_null_${Date.now()}`,
|
||||
name: 'Null Resources Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
tool_resources: null,
|
||||
});
|
||||
|
||||
req.params.file_id = 'another_orphan_file';
|
||||
await fileAccess(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,12 +1,12 @@
|
|||
const { Router } = require('express');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const { MCPOAuthHandler, getUserMCPAuthMap } = require('@librechat/api');
|
||||
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
|
||||
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
||||
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
||||
const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache');
|
||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { findPluginAuthsByKeys } = require('~/models');
|
||||
|
@ -144,6 +144,10 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
|||
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
|
||||
);
|
||||
|
||||
// clear any reconnection attempts
|
||||
const oauthReconnectionManager = getOAuthReconnectionManager();
|
||||
oauthReconnectionManager.clearReconnection(flowState.userId, serverName);
|
||||
|
||||
const tools = await userConnection.fetchTools();
|
||||
await updateMCPUserTools({
|
||||
userId: flowState.userId,
|
||||
|
|
|
@ -357,23 +357,18 @@ const resetPassword = async (userId, token, password) => {
|
|||
|
||||
/**
|
||||
* Set Auth Tokens
|
||||
*
|
||||
* @param {String | ObjectId} userId
|
||||
* @param {Object} res
|
||||
* @param {String} sessionId
|
||||
* @param {ServerResponse} res
|
||||
* @param {ISession | null} [session=null]
|
||||
* @returns
|
||||
*/
|
||||
const setAuthTokens = async (userId, res, sessionId = null) => {
|
||||
const setAuthTokens = async (userId, res, _session = null) => {
|
||||
try {
|
||||
const user = await getUserById(userId);
|
||||
const token = await generateToken(user);
|
||||
|
||||
let session;
|
||||
let session = _session;
|
||||
let refreshToken;
|
||||
let refreshTokenExpires;
|
||||
|
||||
if (sessionId) {
|
||||
session = await findSession({ sessionId: sessionId }, { lean: false });
|
||||
if (session && session._id && session.expiration != null) {
|
||||
refreshTokenExpires = session.expiration.getTime();
|
||||
refreshToken = await generateRefreshToken(session);
|
||||
} else {
|
||||
|
@ -383,6 +378,9 @@ const setAuthTokens = async (userId, res, sessionId = null) => {
|
|||
refreshTokenExpires = session.expiration.getTime();
|
||||
}
|
||||
|
||||
const user = await getUserById(userId);
|
||||
const token = await generateToken(user);
|
||||
|
||||
res.cookie('refreshToken', refreshToken, {
|
||||
expires: new Date(refreshTokenExpires),
|
||||
httpOnly: true,
|
||||
|
|
|
@ -552,7 +552,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
|
|||
throw new Error('File search is not enabled for Agents');
|
||||
}
|
||||
// Note: File search processing continues to dual storage logic below
|
||||
} else if (tool_resource === EToolResources.ocr) {
|
||||
} else if (tool_resource === EToolResources.context) {
|
||||
const { file_id, temp_file_id = null } = metadata;
|
||||
|
||||
/**
|
||||
|
|
|
@ -20,8 +20,8 @@ const {
|
|||
ContentTypes,
|
||||
isAssistantsEndpoint,
|
||||
} = require('librechat-data-provider');
|
||||
const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config');
|
||||
const { findToken, createToken, updateToken } = require('~/models');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { getCachedTools, getAppConfig } = require('./Config');
|
||||
const { reinitMCPServer } = require('./Tools/mcp');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
@ -538,13 +538,20 @@ async function getServerConnectionStatus(
|
|||
const baseConnectionState = getConnectionState();
|
||||
let finalConnectionState = baseConnectionState;
|
||||
|
||||
// connection state overrides specific to OAuth servers
|
||||
if (baseConnectionState === 'disconnected' && oauthServers.has(serverName)) {
|
||||
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
|
||||
|
||||
if (hasFailedFlow) {
|
||||
finalConnectionState = 'error';
|
||||
} else if (hasActiveFlow) {
|
||||
// check if server is actively being reconnected
|
||||
const oauthReconnectionManager = getOAuthReconnectionManager();
|
||||
if (oauthReconnectionManager.isReconnecting(userId, serverName)) {
|
||||
finalConnectionState = 'connecting';
|
||||
} else {
|
||||
const { hasActiveFlow, hasFailedFlow } = await checkOAuthFlowStatus(userId, serverName);
|
||||
|
||||
if (hasFailedFlow) {
|
||||
finalConnectionState = 'error';
|
||||
} else if (hasActiveFlow) {
|
||||
finalConnectionState = 'connecting';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,6 +31,7 @@ jest.mock('./Config', () => ({
|
|||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn(),
|
||||
getFlowStateManager: jest.fn(),
|
||||
getOAuthReconnectionManager: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
|
@ -48,6 +49,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
let mockGetMCPManager;
|
||||
let mockGetFlowStateManager;
|
||||
let mockGetLogStores;
|
||||
let mockGetOAuthReconnectionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
@ -56,6 +58,7 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
mockGetMCPManager = require('~/config').getMCPManager;
|
||||
mockGetFlowStateManager = require('~/config').getFlowStateManager;
|
||||
mockGetLogStores = require('~/cache').getLogStores;
|
||||
mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager;
|
||||
});
|
||||
|
||||
describe('getMCPSetupData', () => {
|
||||
|
@ -354,6 +357,12 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
const userConnections = new Map();
|
||||
const oauthServers = new Set([mockServerName]);
|
||||
|
||||
// Mock OAuthReconnectionManager
|
||||
const mockOAuthReconnectionManager = {
|
||||
isReconnecting: jest.fn(() => false),
|
||||
};
|
||||
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
||||
|
||||
const result = await getServerConnectionStatus(
|
||||
mockUserId,
|
||||
mockServerName,
|
||||
|
@ -370,6 +379,12 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
const userConnections = new Map();
|
||||
const oauthServers = new Set([mockServerName]);
|
||||
|
||||
// Mock OAuthReconnectionManager
|
||||
const mockOAuthReconnectionManager = {
|
||||
isReconnecting: jest.fn(() => false),
|
||||
};
|
||||
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
||||
|
||||
// Mock flow state to return failed flow
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(() => ({
|
||||
|
@ -401,6 +416,12 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
const userConnections = new Map();
|
||||
const oauthServers = new Set([mockServerName]);
|
||||
|
||||
// Mock OAuthReconnectionManager
|
||||
const mockOAuthReconnectionManager = {
|
||||
isReconnecting: jest.fn(() => false),
|
||||
};
|
||||
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
||||
|
||||
// Mock flow state to return active flow
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(() => ({
|
||||
|
@ -432,6 +453,12 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
const userConnections = new Map();
|
||||
const oauthServers = new Set([mockServerName]);
|
||||
|
||||
// Mock OAuthReconnectionManager
|
||||
const mockOAuthReconnectionManager = {
|
||||
isReconnecting: jest.fn(() => false),
|
||||
};
|
||||
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
||||
|
||||
// Mock flow state to return no flow
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(() => null),
|
||||
|
@ -454,6 +481,35 @@ describe('tests for the new helper functions used by the MCP connection status e
|
|||
});
|
||||
});
|
||||
|
||||
it('should return connecting state when OAuth server is reconnecting', async () => {
|
||||
const appConnections = new Map();
|
||||
const userConnections = new Map();
|
||||
const oauthServers = new Set([mockServerName]);
|
||||
|
||||
// Mock OAuthReconnectionManager to return true for isReconnecting
|
||||
const mockOAuthReconnectionManager = {
|
||||
isReconnecting: jest.fn(() => true),
|
||||
};
|
||||
mockGetOAuthReconnectionManager.mockReturnValue(mockOAuthReconnectionManager);
|
||||
|
||||
const result = await getServerConnectionStatus(
|
||||
mockUserId,
|
||||
mockServerName,
|
||||
appConnections,
|
||||
userConnections,
|
||||
oauthServers,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
requiresOAuth: true,
|
||||
connectionState: 'connecting',
|
||||
});
|
||||
expect(mockOAuthReconnectionManager.isReconnecting).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
mockServerName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not check OAuth flow status when server is connected', async () => {
|
||||
const mockFlowManager = {
|
||||
getFlowState: jest.fn(),
|
||||
|
|
|
@ -313,7 +313,7 @@ const ensurePrincipalExists = async function (principal) {
|
|||
idOnTheSource: principal.idOnTheSource,
|
||||
};
|
||||
|
||||
const userId = await createUser(userData, true, false);
|
||||
const userId = await createUser(userData, true, true);
|
||||
return userId.toString();
|
||||
}
|
||||
|
||||
|
|
|
@ -353,7 +353,12 @@ async function processRequiredActions(client, requiredActions) {
|
|||
async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIApiKey }) {
|
||||
if (!agent.tools || agent.tools.length === 0) {
|
||||
return {};
|
||||
} else if (agent.tools && agent.tools.length === 1 && agent.tools[0] === AgentCapabilities.ocr) {
|
||||
} else if (
|
||||
agent.tools &&
|
||||
agent.tools.length === 1 &&
|
||||
/** Legacy handling for `ocr` as may still exist in existing Agents */
|
||||
(agent.tools[0] === AgentCapabilities.context || agent.tools[0] === AgentCapabilities.ocr)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ async function reinitMCPServer({
|
|||
const oauthStart =
|
||||
_oauthStart ??
|
||||
(async (authURL) => {
|
||||
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
|
||||
logger.info(`[MCP Reinitialize] OAuth URL received for ${serverName}`);
|
||||
oauthUrl = authURL;
|
||||
oauthRequired = true;
|
||||
});
|
||||
|
|
26
api/server/services/initializeOAuthReconnectManager.js
Normal file
26
api/server/services/initializeOAuthReconnectManager.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { createOAuthReconnectionManager, getFlowStateManager } = require('~/config');
|
||||
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
/**
|
||||
* Initialize OAuth reconnect manager
|
||||
*/
|
||||
async function initializeOAuthReconnectManager() {
|
||||
try {
|
||||
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
||||
const tokenMethods = {
|
||||
findToken,
|
||||
updateToken,
|
||||
createToken,
|
||||
deleteTokens,
|
||||
};
|
||||
await createOAuthReconnectionManager(flowManager, tokenMethods);
|
||||
logger.info(`OAuth reconnect manager initialized successfully.`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize OAuth reconnect manager:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = initializeOAuthReconnectManager;
|
|
@ -873,6 +873,13 @@
|
|||
* @typedef {import('@librechat/data-schemas').IMongoFile} MongoFile
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ISession
|
||||
* @typedef {import('@librechat/data-schemas').ISession} ISession
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports IBalance
|
||||
* @typedef {import('@librechat/data-schemas').IBalance} IBalance
|
||||
|
|
32
client/src/Providers/DragDropContext.tsx
Normal file
32
client/src/Providers/DragDropContext.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useChatContext } from './ChatContext';
|
||||
|
||||
interface DragDropContextValue {
|
||||
conversationId: string | null | undefined;
|
||||
agentId: string | null | undefined;
|
||||
}
|
||||
|
||||
const DragDropContext = createContext<DragDropContextValue | undefined>(undefined);
|
||||
|
||||
export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
||||
const { conversation } = useChatContext();
|
||||
|
||||
/** Context value only created when conversation fields change */
|
||||
const contextValue = useMemo<DragDropContextValue>(
|
||||
() => ({
|
||||
conversationId: conversation?.conversationId,
|
||||
agentId: conversation?.agent_id,
|
||||
}),
|
||||
[conversation?.conversationId, conversation?.agent_id],
|
||||
);
|
||||
|
||||
return <DragDropContext.Provider value={contextValue}>{children}</DragDropContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDragDropContext() {
|
||||
const context = useContext(DragDropContext);
|
||||
if (!context) {
|
||||
throw new Error('useDragDropContext must be used within DragDropProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
|
@ -1,10 +1,15 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
|
||||
type MessageContext = {
|
||||
messageId: string;
|
||||
nextType?: string;
|
||||
partIndex?: number;
|
||||
isExpanded: boolean;
|
||||
conversationId?: string | null;
|
||||
/** Submission state for cursor display - only true for latest message when submitting */
|
||||
isSubmitting?: boolean;
|
||||
/** Whether this is the latest message in the conversation */
|
||||
isLatestMessage?: boolean;
|
||||
};
|
||||
|
||||
export const MessageContext = createContext<MessageContext>({} as MessageContext);
|
||||
|
|
150
client/src/Providers/MessagesViewContext.tsx
Normal file
150
client/src/Providers/MessagesViewContext.tsx
Normal file
|
@ -0,0 +1,150 @@
|
|||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { useAddedChatContext } from './AddedChatContext';
|
||||
import { useChatContext } from './ChatContext';
|
||||
|
||||
interface MessagesViewContextValue {
|
||||
/** Core conversation data */
|
||||
conversation: ReturnType<typeof useChatContext>['conversation'];
|
||||
conversationId: string | null | undefined;
|
||||
|
||||
/** Submission and control states */
|
||||
isSubmitting: ReturnType<typeof useChatContext>['isSubmitting'];
|
||||
isSubmittingFamily: boolean;
|
||||
abortScroll: ReturnType<typeof useChatContext>['abortScroll'];
|
||||
setAbortScroll: ReturnType<typeof useChatContext>['setAbortScroll'];
|
||||
|
||||
/** Message operations */
|
||||
ask: ReturnType<typeof useChatContext>['ask'];
|
||||
regenerate: ReturnType<typeof useChatContext>['regenerate'];
|
||||
handleContinue: ReturnType<typeof useChatContext>['handleContinue'];
|
||||
|
||||
/** Message state management */
|
||||
index: ReturnType<typeof useChatContext>['index'];
|
||||
latestMessage: ReturnType<typeof useChatContext>['latestMessage'];
|
||||
setLatestMessage: ReturnType<typeof useChatContext>['setLatestMessage'];
|
||||
getMessages: ReturnType<typeof useChatContext>['getMessages'];
|
||||
setMessages: ReturnType<typeof useChatContext>['setMessages'];
|
||||
}
|
||||
|
||||
const MessagesViewContext = createContext<MessagesViewContextValue | undefined>(undefined);
|
||||
|
||||
export function MessagesViewProvider({ children }: { children: React.ReactNode }) {
|
||||
const chatContext = useChatContext();
|
||||
const addedChatContext = useAddedChatContext();
|
||||
|
||||
const {
|
||||
ask,
|
||||
index,
|
||||
regenerate,
|
||||
isSubmitting: isSubmittingRoot,
|
||||
conversation,
|
||||
latestMessage,
|
||||
setAbortScroll,
|
||||
handleContinue,
|
||||
setLatestMessage,
|
||||
abortScroll,
|
||||
getMessages,
|
||||
setMessages,
|
||||
} = chatContext;
|
||||
|
||||
const { isSubmitting: isSubmittingAdditional } = addedChatContext;
|
||||
|
||||
/** Memoize conversation-related values */
|
||||
const conversationValues = useMemo(
|
||||
() => ({
|
||||
conversation,
|
||||
conversationId: conversation?.conversationId,
|
||||
}),
|
||||
[conversation],
|
||||
);
|
||||
|
||||
/** Memoize submission states */
|
||||
const submissionStates = useMemo(
|
||||
() => ({
|
||||
isSubmitting: isSubmittingRoot,
|
||||
isSubmittingFamily: isSubmittingRoot || isSubmittingAdditional,
|
||||
abortScroll,
|
||||
setAbortScroll,
|
||||
}),
|
||||
[isSubmittingRoot, isSubmittingAdditional, abortScroll, setAbortScroll],
|
||||
);
|
||||
|
||||
/** Memoize message operations (these are typically stable references) */
|
||||
const messageOperations = useMemo(
|
||||
() => ({
|
||||
ask,
|
||||
regenerate,
|
||||
getMessages,
|
||||
setMessages,
|
||||
handleContinue,
|
||||
}),
|
||||
[ask, regenerate, handleContinue, getMessages, setMessages],
|
||||
);
|
||||
|
||||
/** Memoize message state values */
|
||||
const messageState = useMemo(
|
||||
() => ({
|
||||
index,
|
||||
latestMessage,
|
||||
setLatestMessage,
|
||||
}),
|
||||
[index, latestMessage, setLatestMessage],
|
||||
);
|
||||
|
||||
/** Combine all values into final context value */
|
||||
const contextValue = useMemo<MessagesViewContextValue>(
|
||||
() => ({
|
||||
...conversationValues,
|
||||
...submissionStates,
|
||||
...messageOperations,
|
||||
...messageState,
|
||||
}),
|
||||
[conversationValues, submissionStates, messageOperations, messageState],
|
||||
);
|
||||
|
||||
return (
|
||||
<MessagesViewContext.Provider value={contextValue}>{children}</MessagesViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMessagesViewContext() {
|
||||
const context = useContext(MessagesViewContext);
|
||||
if (!context) {
|
||||
throw new Error('useMessagesViewContext must be used within MessagesViewProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Hook for components that only need conversation data */
|
||||
export function useMessagesConversation() {
|
||||
const { conversation, conversationId } = useMessagesViewContext();
|
||||
return useMemo(() => ({ conversation, conversationId }), [conversation, conversationId]);
|
||||
}
|
||||
|
||||
/** Hook for components that only need submission states */
|
||||
export function useMessagesSubmission() {
|
||||
const { isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll } =
|
||||
useMessagesViewContext();
|
||||
return useMemo(
|
||||
() => ({ isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll }),
|
||||
[isSubmitting, isSubmittingFamily, abortScroll, setAbortScroll],
|
||||
);
|
||||
}
|
||||
|
||||
/** Hook for components that only need message operations */
|
||||
export function useMessagesOperations() {
|
||||
const { ask, regenerate, handleContinue, getMessages, setMessages } = useMessagesViewContext();
|
||||
return useMemo(
|
||||
() => ({ ask, regenerate, handleContinue, getMessages, setMessages }),
|
||||
[ask, regenerate, handleContinue, getMessages, setMessages],
|
||||
);
|
||||
}
|
||||
|
||||
/** Hook for components that only need message state */
|
||||
export function useMessagesState() {
|
||||
const { index, latestMessage, setLatestMessage } = useMessagesViewContext();
|
||||
return useMemo(
|
||||
() => ({ index, latestMessage, setLatestMessage }),
|
||||
[index, latestMessage, setLatestMessage],
|
||||
);
|
||||
}
|
|
@ -1,9 +1,10 @@
|
|||
import React, { createContext, useContext, ReactNode, useMemo } from 'react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import type { PromptOption } from '~/common';
|
||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||
import { usePromptGroupsNav, useHasAccess } from '~/hooks';
|
||||
import { useGetAllPromptGroups } from '~/data-provider';
|
||||
import { usePromptGroupsNav } from '~/hooks';
|
||||
import { mapPromptGroups } from '~/utils';
|
||||
|
||||
type AllPromptGroupsData =
|
||||
|
@ -19,14 +20,21 @@ type PromptGroupsContextType =
|
|||
data: AllPromptGroupsData;
|
||||
isLoading: boolean;
|
||||
};
|
||||
hasAccess: boolean;
|
||||
})
|
||||
| null;
|
||||
|
||||
const PromptGroupsContext = createContext<PromptGroupsContextType>(null);
|
||||
|
||||
export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const promptGroupsNav = usePromptGroupsNav();
|
||||
const hasAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const promptGroupsNav = usePromptGroupsNav(hasAccess);
|
||||
const { data: allGroupsData, isLoading: isLoadingAll } = useGetAllPromptGroups(undefined, {
|
||||
enabled: hasAccess,
|
||||
select: (data) => {
|
||||
const mappedArray: PromptOption[] = data.map((group) => ({
|
||||
id: group._id ?? '',
|
||||
|
@ -55,11 +63,12 @@ export const PromptGroupsProvider = ({ children }: { children: ReactNode }) => {
|
|||
() => ({
|
||||
...promptGroupsNav,
|
||||
allPromptGroups: {
|
||||
data: allGroupsData,
|
||||
isLoading: isLoadingAll,
|
||||
data: hasAccess ? allGroupsData : undefined,
|
||||
isLoading: hasAccess ? isLoadingAll : false,
|
||||
},
|
||||
hasAccess,
|
||||
}),
|
||||
[promptGroupsNav, allGroupsData, isLoadingAll],
|
||||
[promptGroupsNav, allGroupsData, isLoadingAll, hasAccess],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
@ -23,7 +23,9 @@ export * from './SetConvoContext';
|
|||
export * from './SearchContext';
|
||||
export * from './BadgeRowContext';
|
||||
export * from './SidePanelContext';
|
||||
export * from './DragDropContext';
|
||||
export * from './MCPPanelContext';
|
||||
export * from './ArtifactsContext';
|
||||
export * from './PromptGroupsContext';
|
||||
export * from './MessagesViewContext';
|
||||
export { default as BadgeRowProvider } from './BadgeRowContext';
|
||||
|
|
|
@ -11,9 +11,9 @@ import {
|
|||
AgentListResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useLocalize, useDefaultConvo } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { renderAgentAvatar } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface SupportContact {
|
||||
name?: string;
|
||||
|
@ -34,11 +34,11 @@ interface AgentDetailProps {
|
|||
*/
|
||||
const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) => {
|
||||
const localize = useLocalize();
|
||||
// const navigate = useNavigate();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const getDefaultConversation = useDefaultConvo();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
|
||||
/**
|
||||
* Navigate to chat with the selected agent
|
||||
|
@ -62,13 +62,22 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
|||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
|
||||
/** Template with agent configuration */
|
||||
const template = {
|
||||
conversationId: Constants.NEW_CONVO as string,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: agent.id,
|
||||
title: localize('com_agents_chat_with', { name: agent.name || localize('com_ui_agent') }),
|
||||
};
|
||||
|
||||
const currentConvo = getDefaultConversation({
|
||||
conversation: { ...(conversation ?? {}), ...template },
|
||||
preset: template,
|
||||
});
|
||||
|
||||
newConversation({
|
||||
template: {
|
||||
conversationId: Constants.NEW_CONVO as string,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: agent.id,
|
||||
title: `Chat with ${agent.name || 'Agent'}`,
|
||||
},
|
||||
template: currentConvo,
|
||||
preset: template,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -20,6 +20,7 @@ jest.mock('react-router-dom', () => ({
|
|||
jest.mock('~/hooks', () => ({
|
||||
useMediaQuery: jest.fn(() => false), // Mock as desktop by default
|
||||
useLocalize: jest.fn(),
|
||||
useDefaultConvo: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
|
@ -47,7 +48,12 @@ const mockWriteText = jest.fn();
|
|||
|
||||
const mockNavigate = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
const mockLocalize = jest.fn((key: string) => key);
|
||||
const mockLocalize = jest.fn((key: string, values?: Record<string, any>) => {
|
||||
if (key === 'com_agents_chat_with' && values?.name) {
|
||||
return `Chat with ${values.name}`;
|
||||
}
|
||||
return key;
|
||||
});
|
||||
|
||||
const mockAgent: t.Agent = {
|
||||
id: 'test-agent-id',
|
||||
|
@ -106,8 +112,12 @@ describe('AgentDetail', () => {
|
|||
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
|
||||
const { useToastContext } = require('@librechat/client');
|
||||
(useToastContext as jest.Mock).mockReturnValue({ showToast: mockShowToast });
|
||||
const { useLocalize } = require('~/hooks');
|
||||
const { useLocalize, useDefaultConvo } = require('~/hooks');
|
||||
(useLocalize as jest.Mock).mockReturnValue(mockLocalize);
|
||||
(useDefaultConvo as jest.Mock).mockReturnValue(() => ({
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
}));
|
||||
|
||||
// Mock useChatContext
|
||||
const { useChatContext } = require('~/Providers');
|
||||
|
@ -227,6 +237,10 @@ describe('AgentDetail', () => {
|
|||
template: {
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
},
|
||||
preset: {
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: 'test-agent-id',
|
||||
title: 'Chat with Test Agent',
|
||||
},
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import {
|
||||
|
@ -42,7 +42,9 @@ const AttachFileMenu = ({
|
|||
const isUploadDisabled = disabled ?? false;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isPopoverActive, setIsPopoverActive] = useState(false);
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(
|
||||
ephemeralAgentByConvoId(conversationId),
|
||||
);
|
||||
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
|
@ -64,7 +66,10 @@ const AttachFileMenu = ({
|
|||
* */
|
||||
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
|
||||
const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(agentId);
|
||||
const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(
|
||||
agentId,
|
||||
ephemeralAgent,
|
||||
);
|
||||
|
||||
const handleUploadClick = (isImage?: boolean) => {
|
||||
if (!inputRef.current) {
|
||||
|
@ -89,11 +94,11 @@ const AttachFileMenu = ({
|
|||
},
|
||||
];
|
||||
|
||||
if (capabilities.ocrEnabled) {
|
||||
if (capabilities.contextEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
onClick: () => {
|
||||
setToolResource(EToolResources.ocr);
|
||||
setToolResource(EToolResources.context);
|
||||
onAction();
|
||||
},
|
||||
icon: <FileType2Icon className="icon-md" />,
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||
import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import {
|
||||
useAgentToolPermissions,
|
||||
useAgentCapabilities,
|
||||
useGetAgentsConfig,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { useDragDropContext } from '~/Providers';
|
||||
|
||||
interface DragDropModalProps {
|
||||
onOptionSelect: (option: EToolResources | undefined) => void;
|
||||
|
@ -32,9 +34,11 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
|||
* Use definition for agents endpoint for ephemeral agents
|
||||
* */
|
||||
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
const { conversation } = useChatContext();
|
||||
const { conversationId, agentId } = useDragDropContext();
|
||||
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId ?? ''));
|
||||
const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(
|
||||
conversation?.agent_id,
|
||||
agentId,
|
||||
ephemeralAgent,
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
|
@ -60,10 +64,10 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
|||
icon: <TerminalSquareIcon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
if (capabilities.ocrEnabled) {
|
||||
if (capabilities.contextEnabled) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_ocr_text'),
|
||||
value: EToolResources.ocr,
|
||||
value: EToolResources.context,
|
||||
icon: <FileType2Icon className="icon-md" />,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useDragHelpers } from '~/hooks';
|
||||
import DragDropOverlay from '~/components/Chat/Input/Files/DragDropOverlay';
|
||||
import DragDropModal from '~/components/Chat/Input/Files/DragDropModal';
|
||||
import { DragDropProvider } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface DragDropWrapperProps {
|
||||
|
@ -19,12 +20,14 @@ export default function DragDropWrapper({ children, className }: DragDropWrapper
|
|||
{children}
|
||||
{/** Always render overlay to avoid mount/unmount overhead */}
|
||||
<DragDropOverlay isActive={isActive} />
|
||||
<DragDropModal
|
||||
files={draggedFiles}
|
||||
isVisible={showModal}
|
||||
setShowModal={setShowModal}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
/>
|
||||
<DragDropProvider>
|
||||
<DragDropModal
|
||||
files={draggedFiles}
|
||||
isVisible={showModal}
|
||||
setShowModal={setShowModal}
|
||||
onOptionSelect={handleOptionSelect}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,14 +2,13 @@ import { useState, useRef, useEffect, useMemo, memo, useCallback } from 'react';
|
|||
import { AutoSizer, List } from 'react-virtualized';
|
||||
import { Spinner, useCombobox } from '@librechat/client';
|
||||
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { TPromptGroup } from 'librechat-data-provider';
|
||||
import type { PromptOption } from '~/common';
|
||||
import { removeCharIfLast, detectVariables } from '~/utils';
|
||||
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import MentionItem from './MentionItem';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
const commandChar = '/';
|
||||
|
@ -54,12 +53,7 @@ function PromptsCommand({
|
|||
submitPrompt: (textPrompt: string) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const hasAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const { allPromptGroups } = usePromptGroupsContext();
|
||||
const { allPromptGroups, hasAccess } = usePromptGroupsContext();
|
||||
const { data, isLoading } = allPromptGroups;
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
|
|
@ -26,6 +26,7 @@ type ContentPartsProps = {
|
|||
isCreatedByUser: boolean;
|
||||
isLast: boolean;
|
||||
isSubmitting: boolean;
|
||||
isLatestMessage?: boolean;
|
||||
edit?: boolean;
|
||||
enterEdit?: (cancel?: boolean) => void | null | undefined;
|
||||
siblingIdx?: number;
|
||||
|
@ -45,6 +46,7 @@ const ContentParts = memo(
|
|||
isCreatedByUser,
|
||||
isLast,
|
||||
isSubmitting,
|
||||
isLatestMessage,
|
||||
edit,
|
||||
enterEdit,
|
||||
siblingIdx,
|
||||
|
@ -55,6 +57,8 @@ const ContentParts = memo(
|
|||
const [isExpanded, setIsExpanded] = useState(showThinking);
|
||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const hasReasoningParts = useMemo(() => {
|
||||
const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false;
|
||||
const allThinkPartsHaveContent =
|
||||
|
@ -134,7 +138,9 @@ const ContentParts = memo(
|
|||
})
|
||||
}
|
||||
label={
|
||||
isSubmitting && isLast ? localize('com_ui_thinking') : localize('com_ui_thoughts')
|
||||
effectiveIsSubmitting && isLast
|
||||
? localize('com_ui_thinking')
|
||||
: localize('com_ui_thoughts')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
@ -155,12 +161,14 @@ const ContentParts = memo(
|
|||
conversationId,
|
||||
partIndex: idx,
|
||||
nextType: content[idx + 1]?.type,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
<Part
|
||||
part={part}
|
||||
attachments={attachments}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
key={`part-${messageId}-${idx}`}
|
||||
isCreatedByUser={isCreatedByUser}
|
||||
isLast={idx === content.length - 1}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
|
|||
import { TextareaAutosize, TooltipAnchor } from '@librechat/client';
|
||||
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TEditProps } from '~/common';
|
||||
import { useChatContext, useAddedChatContext } from '~/Providers';
|
||||
import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
|
||||
import { cn, removeFocusRings } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import Container from './Container';
|
||||
|
@ -22,7 +22,8 @@ const EditMessage = ({
|
|||
const { addedIndex } = useAddedChatContext();
|
||||
const saveButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const submitButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const { getMessages, setMessages, conversation } = useChatContext();
|
||||
const { conversation } = useMessagesConversation();
|
||||
const { getMessages, setMessages } = useMessagesOperations();
|
||||
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
|
||||
store.latestMessageFamily(addedIndex),
|
||||
);
|
||||
|
|
|
@ -5,7 +5,7 @@ import type { TMessage } from 'librechat-data-provider';
|
|||
import type { TMessageContentProps, TDisplayProps } from '~/common';
|
||||
import Error from '~/components/Messages/Content/Error';
|
||||
import Thinking from '~/components/Artifacts/Thinking';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import EditMessage from './EditMessage';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
@ -70,16 +70,12 @@ export const ErrorMessage = ({
|
|||
};
|
||||
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||
const { isSubmitting, latestMessage } = useChatContext();
|
||||
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
const showCursorState = useMemo(
|
||||
() => showCursor === true && isSubmitting,
|
||||
[showCursor, isSubmitting],
|
||||
);
|
||||
const isLatestMessage = useMemo(
|
||||
() => message.messageId === latestMessage?.messageId,
|
||||
[message.messageId, latestMessage?.messageId],
|
||||
);
|
||||
|
||||
let content: React.ReactElement;
|
||||
if (!isCreatedByUser) {
|
||||
|
|
|
@ -85,13 +85,14 @@ const Part = memo(
|
|||
|
||||
const isToolCall =
|
||||
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
|
||||
if (isToolCall && toolCall.name === Tools.execute_code && toolCall.args) {
|
||||
if (isToolCall && toolCall.name === Tools.execute_code) {
|
||||
return (
|
||||
<ExecuteCode
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
attachments={attachments}
|
||||
isSubmitting={isSubmitting}
|
||||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
attachments={attachments}
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
|
|
|
@ -6,8 +6,8 @@ import { useRecoilState, useRecoilValue } from 'recoil';
|
|||
import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
|
||||
import type { Agents } from 'librechat-data-provider';
|
||||
import type { TEditProps } from '~/common';
|
||||
import { useMessagesOperations, useMessagesConversation, useAddedChatContext } from '~/Providers';
|
||||
import Container from '~/components/Chat/Messages/Content/Container';
|
||||
import { useChatContext, useAddedChatContext } from '~/Providers';
|
||||
import { cn, removeFocusRings } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
@ -25,7 +25,8 @@ const EditTextPart = ({
|
|||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { addedIndex } = useAddedChatContext();
|
||||
const { ask, getMessages, setMessages, conversation } = useChatContext();
|
||||
const { conversation } = useMessagesConversation();
|
||||
const { ask, getMessages, setMessages } = useMessagesOperations();
|
||||
const [latestMultiMessage, setLatestMultiMessage] = useRecoilState(
|
||||
store.latestMessageFamily(addedIndex),
|
||||
);
|
||||
|
|
|
@ -45,26 +45,28 @@ export function useParseArgs(args?: string): ParsedArgs | null {
|
|||
}
|
||||
|
||||
export default function ExecuteCode({
|
||||
isSubmitting,
|
||||
initialProgress = 0.1,
|
||||
args,
|
||||
output = '',
|
||||
attachments,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
isSubmitting: boolean;
|
||||
args?: string;
|
||||
output?: string;
|
||||
attachments?: TAttachment[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const showAnalysisCode = useRecoilValue(store.showCode);
|
||||
const [showCode, setShowCode] = useState(showAnalysisCode);
|
||||
const codeContentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const hasOutput = output.length > 0;
|
||||
const outputRef = useRef<string>(output);
|
||||
const prevShowCodeRef = useRef<boolean>(showCode);
|
||||
const codeContentRef = useRef<HTMLDivElement>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const showAnalysisCode = useRecoilValue(store.showCode);
|
||||
const [showCode, setShowCode] = useState(showAnalysisCode);
|
||||
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
|
||||
|
||||
const prevShowCodeRef = useRef<boolean>(showCode);
|
||||
const { lang, code } = useParseArgs(args) ?? ({} as ParsedArgs);
|
||||
const progress = useProgress(initialProgress);
|
||||
|
||||
|
@ -136,6 +138,8 @@ export default function ExecuteCode({
|
|||
};
|
||||
}, [showCode, isAnimating]);
|
||||
|
||||
const cancelled = !isSubmitting && progress < 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
|
@ -143,9 +147,12 @@ export default function ExecuteCode({
|
|||
progress={progress}
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
inProgressText={localize('com_ui_analyzing')}
|
||||
finishedText={localize('com_ui_analyzing_finished')}
|
||||
finishedText={
|
||||
cancelled ? localize('com_ui_cancelled') : localize('com_ui_analyzing_finished')
|
||||
}
|
||||
hasInput={!!code?.length}
|
||||
isExpanded={showCode}
|
||||
error={cancelled}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
|
|
@ -2,7 +2,7 @@ import { memo, useMemo, ReactElement } from 'react';
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
||||
import Markdown from '~/components/Chat/Messages/Content/Markdown';
|
||||
import { useChatContext, useMessageContext } from '~/Providers';
|
||||
import { useMessageContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
@ -18,14 +18,9 @@ type ContentType =
|
|||
| ReactElement;
|
||||
|
||||
const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => {
|
||||
const { messageId } = useMessageContext();
|
||||
const { isSubmitting, latestMessage } = useChatContext();
|
||||
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||
const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]);
|
||||
const isLatestMessage = useMemo(
|
||||
() => messageId === latestMessage?.messageId,
|
||||
[messageId, latestMessage?.messageId],
|
||||
);
|
||||
|
||||
const content: ContentType = useMemo(() => {
|
||||
if (!isCreatedByUser) {
|
||||
|
|
|
@ -21,7 +21,7 @@ type THoverButtons = {
|
|||
latestMessage: TMessage | null;
|
||||
isLast: boolean;
|
||||
index: number;
|
||||
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void;
|
||||
handleFeedback?: ({ feedback }: { feedback: TFeedback | undefined }) => void;
|
||||
};
|
||||
|
||||
type HoverButtonProps = {
|
||||
|
@ -238,7 +238,7 @@ const HoverButtons = ({
|
|||
/>
|
||||
|
||||
{/* Feedback Buttons */}
|
||||
{!isCreatedByUser && (
|
||||
{!isCreatedByUser && handleFeedback != null && (
|
||||
<Feedback handleFeedback={handleFeedback} feedback={message.feedback} isLast={isLast} />
|
||||
)}
|
||||
|
||||
|
|
|
@ -73,7 +73,7 @@ export default function Message(props: TMessageProps) {
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
|
||||
<div className="m-auto justify-center p-4 py-2 md:gap-6">
|
||||
<MessageRender {...props} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -125,6 +125,7 @@ export default function Message(props: TMessageProps) {
|
|||
setSiblingIdx={setSiblingIdx}
|
||||
isCreatedByUser={message.isCreatedByUser}
|
||||
conversationId={conversation?.conversationId}
|
||||
isLatestMessage={messageId === latestMessage?.messageId}
|
||||
content={message.content as Array<TMessageContentParts | undefined>}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -4,11 +4,12 @@ import { CSSTransition } from 'react-transition-group';
|
|||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useScreenshot, useMessageScrolling, useLocalize } from '~/hooks';
|
||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||
import { MessagesViewProvider } from '~/Providers';
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function MessagesView({
|
||||
function MessagesViewContent({
|
||||
messagesTree: _messagesTree,
|
||||
}: {
|
||||
messagesTree?: TMessage[] | null;
|
||||
|
@ -92,3 +93,11 @@ export default function MessagesView({
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MessagesView({ messagesTree }: { messagesTree?: TMessage[] | null }) {
|
||||
return (
|
||||
<MessagesViewProvider>
|
||||
<MessagesViewContent messagesTree={messagesTree} />
|
||||
</MessagesViewProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ export default function MultiMessage({
|
|||
useEffect(() => {
|
||||
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
|
||||
setSiblingIdx(0);
|
||||
}, [messagesTree?.length]);
|
||||
}, [messagesTree?.length, setSiblingIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesTree?.length && siblingIdx >= messagesTree.length) {
|
||||
|
|
|
@ -71,6 +71,9 @@ const MessageRender = memo(
|
|||
const showCardRender = isLast && !isSubmittingFamily && isCard;
|
||||
const isLatestCard = isCard && !isSubmittingFamily && isLatestMessage;
|
||||
|
||||
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
|
||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||
|
||||
const iconData: TMessageIcon = useMemo(
|
||||
() => ({
|
||||
endpoint: msg?.endpoint ?? conversation?.endpoint,
|
||||
|
@ -166,6 +169,8 @@ const MessageRender = memo(
|
|||
messageId: msg.messageId,
|
||||
conversationId: conversation?.conversationId,
|
||||
isExpanded: false,
|
||||
isSubmitting: effectiveIsSubmitting,
|
||||
isLatestMessage,
|
||||
}}
|
||||
>
|
||||
{msg.plugin && <Plugin plugin={msg.plugin} />}
|
||||
|
@ -177,7 +182,7 @@ const MessageRender = memo(
|
|||
message={msg}
|
||||
enterEdit={enterEdit}
|
||||
error={!!(msg.error ?? false)}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitting={effectiveIsSubmitting}
|
||||
unfinished={msg.unfinished ?? false}
|
||||
isCreatedByUser={msg.isCreatedByUser ?? true}
|
||||
siblingIdx={siblingIdx ?? 0}
|
||||
|
@ -186,7 +191,7 @@ const MessageRender = memo(
|
|||
</MessageContext.Provider>
|
||||
</div>
|
||||
|
||||
{hasNoChildren && (isSubmittingFamily === true || isSubmitting) ? (
|
||||
{hasNoChildren && (isSubmittingFamily === true || effectiveIsSubmitting) ? (
|
||||
<PlaceholderRow isCard={isCard} />
|
||||
) : (
|
||||
<SubRow classes="text-xs">
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { useMemo, memo, type FC, useCallback } from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { parseISO, isToday } from 'date-fns';
|
||||
import { Spinner, useMediaQuery } from '@librechat/client';
|
||||
import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
|
||||
import { TConversation } from 'librechat-data-provider';
|
||||
|
@ -50,27 +49,17 @@ const MemoizedConvo = memo(
|
|||
conversation,
|
||||
retainView,
|
||||
toggleNav,
|
||||
isLatestConvo,
|
||||
}: {
|
||||
conversation: TConversation;
|
||||
retainView: () => void;
|
||||
toggleNav: () => void;
|
||||
isLatestConvo: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<Convo
|
||||
conversation={conversation}
|
||||
retainView={retainView}
|
||||
toggleNav={toggleNav}
|
||||
isLatestConvo={isLatestConvo}
|
||||
/>
|
||||
);
|
||||
return <Convo conversation={conversation} retainView={retainView} toggleNav={toggleNav} />;
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.conversation.conversationId === nextProps.conversation.conversationId &&
|
||||
prevProps.conversation.title === nextProps.conversation.title &&
|
||||
prevProps.isLatestConvo === nextProps.isLatestConvo &&
|
||||
prevProps.conversation.endpoint === nextProps.conversation.endpoint
|
||||
);
|
||||
},
|
||||
|
@ -98,13 +87,6 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
[filteredConversations],
|
||||
);
|
||||
|
||||
const firstTodayConvoId = useMemo(
|
||||
() =>
|
||||
filteredConversations.find((convo) => convo.updatedAt && isToday(parseISO(convo.updatedAt)))
|
||||
?.conversationId ?? undefined,
|
||||
[filteredConversations],
|
||||
);
|
||||
|
||||
const flattenedItems = useMemo(() => {
|
||||
const items: FlattenedItem[] = [];
|
||||
groupedConversations.forEach(([groupName, convos]) => {
|
||||
|
@ -154,26 +136,25 @@ const Conversations: FC<ConversationsProps> = ({
|
|||
</CellMeasurer>
|
||||
);
|
||||
}
|
||||
let rendering: JSX.Element;
|
||||
if (item.type === 'header') {
|
||||
rendering = <DateLabel groupName={item.groupName} />;
|
||||
} else if (item.type === 'convo') {
|
||||
rendering = (
|
||||
<MemoizedConvo conversation={item.convo} retainView={moveToTop} toggleNav={toggleNav} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<CellMeasurer cache={cache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
|
||||
{({ registerChild }) => (
|
||||
<div ref={registerChild} style={style}>
|
||||
{item.type === 'header' ? (
|
||||
<DateLabel groupName={item.groupName} />
|
||||
) : item.type === 'convo' ? (
|
||||
<MemoizedConvo
|
||||
conversation={item.convo}
|
||||
retainView={moveToTop}
|
||||
toggleNav={toggleNav}
|
||||
isLatestConvo={item.convo.conversationId === firstTodayConvoId}
|
||||
/>
|
||||
) : null}
|
||||
{rendering}
|
||||
</div>
|
||||
)}
|
||||
</CellMeasurer>
|
||||
);
|
||||
},
|
||||
[cache, flattenedItems, firstTodayConvoId, moveToTop, toggleNav],
|
||||
[cache, flattenedItems, moveToTop, toggleNav],
|
||||
);
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
|
|
|
@ -11,23 +11,17 @@ import { useGetEndpointsQuery } from '~/data-provider';
|
|||
import { NotificationSeverity } from '~/common';
|
||||
import { ConvoOptions } from './ConvoOptions';
|
||||
import RenameForm from './RenameForm';
|
||||
import { cn, logger } from '~/utils';
|
||||
import ConvoLink from './ConvoLink';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface ConversationProps {
|
||||
conversation: TConversation;
|
||||
retainView: () => void;
|
||||
toggleNav: () => void;
|
||||
isLatestConvo: boolean;
|
||||
}
|
||||
|
||||
export default function Conversation({
|
||||
conversation,
|
||||
retainView,
|
||||
toggleNav,
|
||||
isLatestConvo,
|
||||
}: ConversationProps) {
|
||||
export default function Conversation({ conversation, retainView, toggleNav }: ConversationProps) {
|
||||
const params = useParams();
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
@ -84,6 +78,7 @@ export default function Conversation({
|
|||
});
|
||||
setRenaming(false);
|
||||
} catch (error) {
|
||||
logger.error('Error renaming conversation', error);
|
||||
setTitleInput(title as string);
|
||||
showToast({
|
||||
message: localize('com_ui_rename_failed'),
|
||||
|
|
|
@ -173,6 +173,7 @@ const ContentRender = memo(
|
|||
isSubmitting={isSubmitting}
|
||||
searchResults={searchResults}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
isLatestMessage={isLatestMessage}
|
||||
isCreatedByUser={msg.isCreatedByUser}
|
||||
conversationId={conversation?.conversationId}
|
||||
content={msg.content as Array<TMessageContentParts | undefined>}
|
||||
|
|
|
@ -24,35 +24,45 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
|
|||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [showClearIcon, setShowClearIcon] = useState(false);
|
||||
|
||||
const { newConversation } = useNewConvo();
|
||||
const { newConversation: newConvo } = useNewConvo();
|
||||
const [search, setSearchState] = useRecoilState(store.search);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
if (location.pathname.includes('/search')) {
|
||||
newConversation({ disableFocus: true });
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
}, [newConversation, location.pathname, navigate]);
|
||||
const clearSearch = useCallback(
|
||||
(pathname?: string) => {
|
||||
if (pathname?.includes('/search') || pathname === '/c/new') {
|
||||
queryClient.removeQueries([QueryKeys.messages]);
|
||||
newConvo({ disableFocus: true });
|
||||
navigate('/c/new');
|
||||
}
|
||||
},
|
||||
[newConvo, navigate, queryClient],
|
||||
);
|
||||
|
||||
const clearText = useCallback(() => {
|
||||
setShowClearIcon(false);
|
||||
setText('');
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
query: '',
|
||||
debouncedQuery: '',
|
||||
isTyping: false,
|
||||
}));
|
||||
clearSearch();
|
||||
inputRef.current?.focus();
|
||||
}, [setSearchState, clearSearch]);
|
||||
const clearText = useCallback(
|
||||
(pathname?: string) => {
|
||||
setShowClearIcon(false);
|
||||
setText('');
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
query: '',
|
||||
debouncedQuery: '',
|
||||
isTyping: false,
|
||||
}));
|
||||
clearSearch(pathname);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[setSearchState, clearSearch],
|
||||
);
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target as HTMLInputElement;
|
||||
if (e.key === 'Backspace' && value === '') {
|
||||
clearText();
|
||||
}
|
||||
};
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target as HTMLInputElement;
|
||||
if (e.key === 'Backspace' && value === '') {
|
||||
clearText(location.pathname);
|
||||
}
|
||||
},
|
||||
[clearText, location.pathname],
|
||||
);
|
||||
|
||||
const sendRequest = useCallback(
|
||||
(value: string) => {
|
||||
|
@ -85,8 +95,6 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
|
|||
debouncedSetDebouncedQuery(value);
|
||||
if (value.length > 0 && location.pathname !== '/search') {
|
||||
navigate('/search', { replace: true });
|
||||
} else if (value.length === 0 && location.pathname === '/search') {
|
||||
navigate('/c/new', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -132,7 +140,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
|
|||
showClearIcon ? 'opacity-100' : 'opacity-0',
|
||||
isSmallScreen === true ? 'right-[16px]' : '',
|
||||
)}
|
||||
onClick={clearText}
|
||||
onClick={() => clearText(location.pathname)}
|
||||
tabIndex={showClearIcon ? 0 : -1}
|
||||
disabled={!showClearIcon}
|
||||
>
|
||||
|
|
|
@ -6,6 +6,7 @@ import { LocalStorageKeys } from 'librechat-data-provider';
|
|||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import type { MenuItemProps } from '@librechat/client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import { useCategories } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
|
@ -22,8 +23,9 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
|
|||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const formContext = useFormContext();
|
||||
const { categories, emptyCategory } = useCategories();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { hasAccess } = usePromptGroupsContext();
|
||||
const { categories, emptyCategory } = useCategories({ hasAccess });
|
||||
|
||||
const control = formContext?.control;
|
||||
const watch = formContext?.watch;
|
||||
|
|
|
@ -7,6 +7,7 @@ import CategorySelector from '~/components/Prompts/Groups/CategorySelector';
|
|||
import VariablesDropdown from '~/components/Prompts/VariablesDropdown';
|
||||
import PromptVariables from '~/components/Prompts/PromptVariables';
|
||||
import Description from '~/components/Prompts/Description';
|
||||
import { usePromptGroupsContext } from '~/Providers';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import Command from '~/components/Prompts/Command';
|
||||
import { useCreatePrompt } from '~/data-provider';
|
||||
|
@ -37,10 +38,12 @@ const CreatePromptForm = ({
|
|||
}) => {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const hasAccess = useHasAccess({
|
||||
const { hasAccess: hasUseAccess } = usePromptGroupsContext();
|
||||
const hasCreateAccess = useHasAccess({
|
||||
permissionType: PermissionTypes.PROMPTS,
|
||||
permission: Permissions.CREATE,
|
||||
});
|
||||
const hasAccess = hasUseAccess && hasCreateAccess;
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
|
|
@ -11,8 +11,8 @@ import store from '~/store';
|
|||
|
||||
export default function FilterPrompts({ className = '' }: { className?: string }) {
|
||||
const localize = useLocalize();
|
||||
const { name, setName } = usePromptGroupsContext();
|
||||
const { categories } = useCategories('h-4 w-4');
|
||||
const { name, setName, hasAccess } = usePromptGroupsContext();
|
||||
const { categories } = useCategories({ className: 'h-4 w-4', hasAccess });
|
||||
const [displayName, setDisplayName] = useState(name || '');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [categoryFilter, setCategory] = useRecoilState(store.promptsCategory);
|
||||
|
|
|
@ -167,6 +167,7 @@ const PromptForm = () => {
|
|||
const params = useParams();
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { hasAccess } = usePromptGroupsContext();
|
||||
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
|
||||
const promptId = params.promptId || '';
|
||||
|
||||
|
@ -179,10 +180,12 @@ const PromptForm = () => {
|
|||
const [showSidePanel, setShowSidePanel] = useState(false);
|
||||
const sidePanelWidth = '320px';
|
||||
|
||||
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId);
|
||||
const { data: group, isLoading: isLoadingGroup } = useGetPromptGroup(promptId, {
|
||||
enabled: hasAccess && !!promptId,
|
||||
});
|
||||
const { data: prompts = [], isLoading: isLoadingPrompts } = useGetPrompts(
|
||||
{ groupId: promptId },
|
||||
{ enabled: !!promptId },
|
||||
{ enabled: hasAccess && !!promptId },
|
||||
);
|
||||
|
||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||
|
|
|
@ -76,6 +76,8 @@ export default function Message(props: TMessageProps) {
|
|||
messageId,
|
||||
isExpanded: false,
|
||||
conversationId: conversation?.conversationId,
|
||||
isSubmitting: false, // Share view is always read-only
|
||||
isLatestMessage: false, // No concept of latest message in share view
|
||||
}}
|
||||
>
|
||||
{/* Legacy Plugins */}
|
||||
|
|
|
@ -79,9 +79,9 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
|||
}, [fileMap, agentFiles]);
|
||||
|
||||
const {
|
||||
ocrEnabled,
|
||||
codeEnabled,
|
||||
toolsEnabled,
|
||||
contextEnabled,
|
||||
actionsEnabled,
|
||||
artifactsEnabled,
|
||||
webSearchEnabled,
|
||||
|
@ -291,7 +291,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
|||
{(codeEnabled ||
|
||||
fileSearchEnabled ||
|
||||
artifactsEnabled ||
|
||||
ocrEnabled ||
|
||||
contextEnabled ||
|
||||
webSearchEnabled) && (
|
||||
<div className="mb-4 flex w-full flex-col items-start gap-3">
|
||||
<label className="text-token-text-primary block font-medium">
|
||||
|
@ -301,8 +301,8 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
|||
{codeEnabled && <CodeForm agent_id={agent_id} files={code_files} />}
|
||||
{/* Web Search */}
|
||||
{webSearchEnabled && <SearchForm />}
|
||||
{/* File Context (OCR) */}
|
||||
{ocrEnabled && <FileContext agent_id={agent_id} files={context_files} />}
|
||||
{/* File Context */}
|
||||
{contextEnabled && <FileContext agent_id={agent_id} files={context_files} />}
|
||||
{/* Artifacts */}
|
||||
{artifactsEnabled && <Artifacts />}
|
||||
{/* File Search */}
|
||||
|
|
|
@ -47,7 +47,7 @@ export default function FileContext({
|
|||
|
||||
const { handleFileChange } = useFileHandling({
|
||||
overrideEndpoint: EModelEndpoint.agents,
|
||||
additionalMetadata: { agent_id, tool_resource: EToolResources.ocr },
|
||||
additionalMetadata: { agent_id, tool_resource: EToolResources.context },
|
||||
fileSetter: setFiles,
|
||||
});
|
||||
const { handleSharePointFiles, isProcessing, downloadProgress } = useSharePointFileHandling({
|
||||
|
@ -113,7 +113,7 @@ export default function FileContext({
|
|||
<HoverCardTrigger asChild>
|
||||
<span className="flex items-center gap-2">
|
||||
<label className="text-token-text-primary block font-medium">
|
||||
{localize('com_agents_file_context')}
|
||||
{localize('com_agents_file_context_label')}
|
||||
</label>
|
||||
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
|
||||
</span>
|
||||
|
@ -122,7 +122,7 @@ export default function FileContext({
|
|||
<HoverCardContent side={ESide.Top} className="w-80">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">
|
||||
{localize('com_agents_file_context_info')}
|
||||
{localize('com_agents_file_context_description')}
|
||||
</p>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
|
@ -130,13 +130,13 @@ export default function FileContext({
|
|||
</div>
|
||||
</HoverCard>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* File Context (OCR) Files */}
|
||||
{/* File Context Files */}
|
||||
<FileRow
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
setFilesLoading={setFilesLoading}
|
||||
agent_id={agent_id}
|
||||
tool_resource={EToolResources.ocr}
|
||||
tool_resource={EToolResources.context}
|
||||
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
|
||||
/>
|
||||
<div>
|
||||
|
|
|
@ -4,7 +4,6 @@ import { ChevronDown } from 'lucide-react';
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
Label,
|
||||
Checkbox,
|
||||
|
@ -14,20 +13,18 @@ import {
|
|||
AccordionItem,
|
||||
CircleHelpIcon,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
AccordionContent,
|
||||
OGDialogTemplate,
|
||||
} from '@librechat/client';
|
||||
import type { AgentForm, MCPServerInfo } from '~/common';
|
||||
import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useLocalize, useMCPServerManager } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||
const { removeTool } = useRemoveMCPTool();
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
|
||||
|
||||
|
@ -56,36 +53,6 @@ export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo })
|
|||
setValue('tools', [...otherTools, ...newSelectedTools]);
|
||||
};
|
||||
|
||||
const removeTool = (serverName: string) => {
|
||||
if (!serverName) {
|
||||
return;
|
||||
}
|
||||
updateUserPlugins.mutate(
|
||||
{
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
action: 'uninstall',
|
||||
auth: {},
|
||||
isEntityTool: true,
|
||||
},
|
||||
{
|
||||
onError: (error: unknown) => {
|
||||
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
|
||||
},
|
||||
onSuccess: () => {
|
||||
const currentTools = getValues('tools');
|
||||
const remainingToolIds =
|
||||
currentTools?.filter(
|
||||
(currentToolId) =>
|
||||
currentToolId !== serverName &&
|
||||
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
|
||||
) || [];
|
||||
setValue('tools', remainingToolIds);
|
||||
showToast({ message: 'Tool deleted successfully', status: 'success' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const selectedTools = getSelectedTools();
|
||||
const isExpanded = accordionValue === currentServerName;
|
||||
|
||||
|
|
|
@ -1,25 +1,12 @@
|
|||
import React, { useState } from 'react';
|
||||
import { CircleX } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
Label,
|
||||
OGDialog,
|
||||
TrashIcon,
|
||||
useToastContext,
|
||||
OGDialogTrigger,
|
||||
OGDialogTemplate,
|
||||
} from '@librechat/client';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client';
|
||||
import { useLocalize, useRemoveMCPTool } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function UnconfiguredMCPTool({ serverName }: { serverName?: string }) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { removeTool } = useRemoveMCPTool();
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
@ -28,36 +15,6 @@ export default function UnconfiguredMCPTool({ serverName }: { serverName?: strin
|
|||
return null;
|
||||
}
|
||||
|
||||
const removeTool = () => {
|
||||
updateUserPlugins.mutate(
|
||||
{
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
action: 'uninstall',
|
||||
auth: {},
|
||||
isEntityTool: true,
|
||||
},
|
||||
{
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
message: localize('com_ui_delete_tool_error', { error: String(error) }),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
const currentTools = getValues('tools');
|
||||
const remainingToolIds =
|
||||
currentTools?.filter(
|
||||
(currentToolId) =>
|
||||
currentToolId !== serverName &&
|
||||
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
|
||||
) || [];
|
||||
setValue('tools', remainingToolIds);
|
||||
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<div
|
||||
|
@ -116,7 +73,7 @@ export default function UnconfiguredMCPTool({ serverName }: { serverName?: strin
|
|||
</Label>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: () => removeTool(),
|
||||
selectHandler: () => removeTool(serverName || ''),
|
||||
selectClasses:
|
||||
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
|
|
|
@ -1,29 +1,18 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
Label,
|
||||
OGDialog,
|
||||
TrashIcon,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
OGDialogTemplate,
|
||||
} from '@librechat/client';
|
||||
import type { AgentForm, MCPServerInfo } from '~/common';
|
||||
import { Label, OGDialog, TrashIcon, OGDialogTrigger, OGDialogTemplate } from '@librechat/client';
|
||||
import type { MCPServerInfo } from '~/common';
|
||||
import { useLocalize, useMCPServerManager, useRemoveMCPTool } from '~/hooks';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useLocalize, useMCPServerManager } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
|
||||
const localize = useLocalize();
|
||||
const { removeTool } = useRemoveMCPTool();
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
const { initializeServer, isInitializing, getServerStatusIconProps, getConfigDialogProps } =
|
||||
useMCPServerManager();
|
||||
|
||||
|
@ -31,39 +20,6 @@ export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPS
|
|||
return null;
|
||||
}
|
||||
|
||||
const removeTool = (serverName: string) => {
|
||||
if (!serverName) {
|
||||
return;
|
||||
}
|
||||
updateUserPlugins.mutate(
|
||||
{
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
action: 'uninstall',
|
||||
auth: {},
|
||||
isEntityTool: true,
|
||||
},
|
||||
{
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
message: localize('com_ui_delete_tool_error', { error: String(error) }),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
const currentTools = getValues('tools');
|
||||
const remainingToolIds =
|
||||
currentTools?.filter(
|
||||
(currentToolId) =>
|
||||
currentToolId !== serverName &&
|
||||
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
|
||||
) || [];
|
||||
setValue('tools', remainingToolIds);
|
||||
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const serverName = serverInfo.serverName;
|
||||
const isServerInitializing = isInitializing(serverName);
|
||||
const statusIconProps = getServerStatusIconProps(serverName);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { ChevronLeft, Trash2 } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
|
@ -12,6 +12,8 @@ import { useLocalize, useMCPConnectionStatus } from '~/hooks';
|
|||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import MCPPanelSkeleton from './MCPPanelSkeleton';
|
||||
|
||||
const POLL_FOR_CONNECTION_STATUS_INTERVAL = 2_000; // ms
|
||||
|
||||
function MCPPanelContent() {
|
||||
const localize = useLocalize();
|
||||
const queryClient = useQueryClient();
|
||||
|
@ -26,6 +28,29 @@ function MCPPanelContent() {
|
|||
null,
|
||||
);
|
||||
|
||||
// Check if any connections are in 'connecting' state
|
||||
const hasConnectingServers = useMemo(() => {
|
||||
if (!connectionStatus) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(connectionStatus).some(
|
||||
(status) => status?.connectionState === 'connecting',
|
||||
);
|
||||
}, [connectionStatus]);
|
||||
|
||||
// Set up polling when servers are connecting
|
||||
useEffect(() => {
|
||||
if (!hasConnectingServers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
|
||||
}, POLL_FOR_CONNECTION_STATUS_INTERVAL);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [hasConnectingServers, queryClient]);
|
||||
|
||||
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||
onSuccess: async () => {
|
||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||
|
|
|
@ -0,0 +1,558 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import { Tools, Constants, EToolResources } from 'librechat-data-provider';
|
||||
import type { TEphemeralAgent } from 'librechat-data-provider';
|
||||
import useAgentToolPermissions from '../useAgentToolPermissions';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetAgentByIdQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useAgentsMapContext: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked functions after mocking
|
||||
import { useGetAgentByIdQuery } from '~/data-provider';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
||||
type HookProps = {
|
||||
agentId?: string | null;
|
||||
ephemeralAgent?: TEphemeralAgent | null;
|
||||
};
|
||||
|
||||
describe('useAgentToolPermissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Ephemeral Agent Scenarios (without ephemeralAgent parameter)', () => {
|
||||
it('should return false for all tools when agentId is null and no ephemeralAgent provided', () => {
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(null));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false for all tools when agentId is undefined and no ephemeralAgent provided', () => {
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(undefined));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false for all tools when agentId is empty string and no ephemeralAgent provided', () => {
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(''));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false for all tools when agentId is EPHEMERAL_AGENT_ID and no ephemeralAgent provided', () => {
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(Constants.EPHEMERAL_AGENT_ID));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ephemeral Agent with Tool Settings', () => {
|
||||
it('should return true for file_search when ephemeralAgent has file_search enabled', () => {
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.file_search]: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(null, ephemeralAgent));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true for execute_code when ephemeralAgent has execute_code enabled', () => {
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.execute_code]: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(undefined, ephemeralAgent));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return true for both tools when ephemeralAgent has both enabled', () => {
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.file_search]: true,
|
||||
[EToolResources.execute_code]: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions('', ephemeralAgent));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false for tools when ephemeralAgent has them explicitly disabled', () => {
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.file_search]: false,
|
||||
[EToolResources.execute_code]: false,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAgentToolPermissions(Constants.EPHEMERAL_AGENT_ID, ephemeralAgent),
|
||||
);
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle ephemeralAgent with ocr property without affecting other tools', () => {
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.file_search]: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(null, ephemeralAgent));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect regular agents when ephemeralAgent is provided', () => {
|
||||
const agentId = 'regular-agent';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search],
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.execute_code]: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId, ephemeralAgent));
|
||||
|
||||
// Should use regular agent's tools, not ephemeralAgent
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toEqual([Tools.file_search]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regular Agent with Tools', () => {
|
||||
it('should allow file_search when agent has the tool', () => {
|
||||
const agentId = 'agent-123';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search, 'other_tool'],
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toEqual([Tools.file_search, 'other_tool']);
|
||||
});
|
||||
|
||||
it('should allow execute_code when agent has the tool', () => {
|
||||
const agentId = 'agent-456';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: [Tools.execute_code, 'another_tool'],
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.tools).toEqual([Tools.execute_code, 'another_tool']);
|
||||
});
|
||||
|
||||
it('should allow both tools when agent has both', () => {
|
||||
const agentId = 'agent-789';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search, Tools.execute_code, 'custom_tool'],
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.tools).toEqual([Tools.file_search, Tools.execute_code, 'custom_tool']);
|
||||
});
|
||||
|
||||
it('should disallow both tools when agent has neither', () => {
|
||||
const agentId = 'agent-no-tools';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: ['custom_tool1', 'custom_tool2'],
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toEqual(['custom_tool1', 'custom_tool2']);
|
||||
});
|
||||
|
||||
it('should handle agent with empty tools array', () => {
|
||||
const agentId = 'agent-empty-tools';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: [],
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle agent with undefined tools', () => {
|
||||
const agentId = 'agent-undefined-tools';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: undefined,
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Data from Query', () => {
|
||||
it('should prioritize agentData tools over selectedAgent tools', () => {
|
||||
const agentId = 'agent-with-query-data';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: ['old_tool'],
|
||||
};
|
||||
const mockAgentData = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search, Tools.execute_code],
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: mockAgentData });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.tools).toEqual([Tools.file_search, Tools.execute_code]);
|
||||
});
|
||||
|
||||
it('should fallback to selectedAgent tools when agentData has no tools', () => {
|
||||
const agentId = 'agent-fallback';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search],
|
||||
};
|
||||
const mockAgentData = {
|
||||
id: agentId,
|
||||
tools: undefined,
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: mockAgentData });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toEqual([Tools.file_search]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Not Found Scenarios', () => {
|
||||
it('should disallow all tools when agent is not found in map', () => {
|
||||
const agentId = 'non-existent-agent';
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should disallow all tools when agentsMap is null', () => {
|
||||
const agentId = 'agent-with-null-map';
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue(null);
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should disallow all tools when agentsMap is undefined', () => {
|
||||
const agentId = 'agent-with-undefined-map';
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue(undefined);
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memoization and Performance', () => {
|
||||
it('should memoize results when inputs do not change', () => {
|
||||
const agentId = 'memoized-agent';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search],
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result, rerender } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
const firstResult = result.current;
|
||||
|
||||
// Rerender without changing inputs
|
||||
rerender();
|
||||
|
||||
const secondResult = result.current;
|
||||
|
||||
// The hook returns a new object each time, but the values should be equal
|
||||
expect(firstResult.fileSearchAllowedByAgent).toBe(secondResult.fileSearchAllowedByAgent);
|
||||
expect(firstResult.codeAllowedByAgent).toBe(secondResult.codeAllowedByAgent);
|
||||
// Tools array reference should be the same since it comes from useMemo
|
||||
expect(firstResult.tools).toBe(secondResult.tools);
|
||||
|
||||
// Verify the actual values are correct
|
||||
expect(secondResult.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(secondResult.codeAllowedByAgent).toBe(false);
|
||||
expect(secondResult.tools).toEqual([Tools.file_search]);
|
||||
});
|
||||
|
||||
it('should recompute when agentId changes', () => {
|
||||
const agentId1 = 'agent-1';
|
||||
const agentId2 = 'agent-2';
|
||||
const mockAgents = {
|
||||
[agentId1]: { id: agentId1, tools: [Tools.file_search] },
|
||||
[agentId2]: { id: agentId2, tools: [Tools.execute_code] },
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue(mockAgents);
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result, rerender } = renderHook(({ agentId }) => useAgentToolPermissions(agentId), {
|
||||
initialProps: { agentId: agentId1 },
|
||||
});
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
|
||||
// Change agentId
|
||||
rerender({ agentId: agentId2 });
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle switching between ephemeral and regular agents', () => {
|
||||
const regularAgentId = 'regular-agent';
|
||||
const mockAgent = {
|
||||
id: regularAgentId,
|
||||
tools: [],
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[regularAgentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.file_search]: true,
|
||||
[EToolResources.execute_code]: true,
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ agentId, ephemeralAgent }) => useAgentToolPermissions(agentId, ephemeralAgent),
|
||||
{ initialProps: { agentId: null, ephemeralAgent } as HookProps },
|
||||
);
|
||||
|
||||
// Start with ephemeral agent (null) with tools enabled
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
|
||||
// Switch to regular agent
|
||||
rerender({ agentId: regularAgentId, ephemeralAgent });
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
|
||||
// Switch back to ephemeral
|
||||
rerender({ agentId: '', ephemeralAgent });
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
|
||||
// Switch to ephemeral without tools
|
||||
rerender({ agentId: null, ephemeralAgent: undefined });
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle agents with null tools gracefully', () => {
|
||||
const agentId = 'agent-null-tools';
|
||||
const mockAgent = {
|
||||
id: agentId,
|
||||
tools: null as any,
|
||||
};
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({
|
||||
[agentId]: mockAgent,
|
||||
});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle whitespace-only agentId as ephemeral', () => {
|
||||
// Note: Based on the current implementation, only empty string is treated as ephemeral
|
||||
// Whitespace-only strings would be treated as regular agent IDs
|
||||
const whitespaceId = ' ';
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(whitespaceId));
|
||||
|
||||
// Whitespace ID is not considered ephemeral in current implementation
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle query loading state', () => {
|
||||
const agentId = 'loading-agent';
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
// During loading, should return false for non-ephemeral agents
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle query error state', () => {
|
||||
const agentId = 'error-agent';
|
||||
|
||||
(useAgentsMapContext as jest.Mock).mockReturnValue({});
|
||||
(useGetAgentByIdQuery as jest.Mock).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error('Failed to fetch agent'),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
// On error, should return false for non-ephemeral agents
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { renderHook } from '@testing-library/react';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import { Tools, EToolResources } from 'librechat-data-provider';
|
||||
import useAgentToolPermissions from '../useAgentToolPermissions';
|
||||
|
||||
// Mock the dependencies
|
||||
|
@ -20,36 +20,36 @@ describe('useAgentToolPermissions', () => {
|
|||
});
|
||||
|
||||
describe('when no agentId is provided', () => {
|
||||
it('should allow all tools for ephemeral agents', () => {
|
||||
it('should disallow all tools for ephemeral agents when no ephemeralAgent settings provided', () => {
|
||||
mockUseAgentsMapContext.mockReturnValue({});
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(null));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow all tools when agentId is undefined', () => {
|
||||
it('should disallow all tools when agentId is undefined and no ephemeralAgent settings', () => {
|
||||
mockUseAgentsMapContext.mockReturnValue({});
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(undefined));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow all tools when agentId is empty string', () => {
|
||||
it('should disallow all tools when agentId is empty string and no ephemeralAgent settings', () => {
|
||||
mockUseAgentsMapContext.mockReturnValue({});
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(''));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
@ -177,4 +177,74 @@ describe('useAgentToolPermissions', () => {
|
|||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when ephemeralAgent settings are provided', () => {
|
||||
it('should allow file_search when ephemeralAgent has file_search enabled', () => {
|
||||
mockUseAgentsMapContext.mockReturnValue({});
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.file_search]: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(null, ephemeralAgent));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow execute_code when ephemeralAgent has execute_code enabled', () => {
|
||||
mockUseAgentsMapContext.mockReturnValue({});
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.execute_code]: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(undefined, ephemeralAgent));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow both tools when ephemeralAgent has both enabled', () => {
|
||||
mockUseAgentsMapContext.mockReturnValue({});
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.file_search]: true,
|
||||
[EToolResources.execute_code]: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions('', ephemeralAgent));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect regular agents when ephemeralAgent is provided', () => {
|
||||
const agentId = 'regular-agent';
|
||||
const agent = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search],
|
||||
};
|
||||
|
||||
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
|
||||
|
||||
const ephemeralAgent = {
|
||||
[EToolResources.execute_code]: true,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId, ephemeralAgent));
|
||||
|
||||
// Should use regular agent's tools, not ephemeralAgent
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toEqual([Tools.file_search]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,6 +6,7 @@ interface AgentCapabilitiesResult {
|
|||
actionsEnabled: boolean;
|
||||
artifactsEnabled: boolean;
|
||||
ocrEnabled: boolean;
|
||||
contextEnabled: boolean;
|
||||
fileSearchEnabled: boolean;
|
||||
webSearchEnabled: boolean;
|
||||
codeEnabled: boolean;
|
||||
|
@ -34,6 +35,11 @@ export default function useAgentCapabilities(
|
|||
[capabilities],
|
||||
);
|
||||
|
||||
const contextEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.context) ?? false,
|
||||
[capabilities],
|
||||
);
|
||||
|
||||
const fileSearchEnabled = useMemo(
|
||||
() => capabilities?.includes(AgentCapabilities.file_search) ?? false,
|
||||
[capabilities],
|
||||
|
@ -54,6 +60,7 @@ export default function useAgentCapabilities(
|
|||
codeEnabled,
|
||||
toolsEnabled,
|
||||
actionsEnabled,
|
||||
contextEnabled,
|
||||
artifactsEnabled,
|
||||
webSearchEnabled,
|
||||
fileSearchEnabled,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import { Tools, Constants, EToolResources } from 'librechat-data-provider';
|
||||
import type { TEphemeralAgent } from 'librechat-data-provider';
|
||||
import { useGetAgentByIdQuery } from '~/data-provider';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
||||
|
@ -9,14 +10,20 @@ interface AgentToolPermissionsResult {
|
|||
tools: string[] | undefined;
|
||||
}
|
||||
|
||||
function isEphemeralAgent(agentId: string | null | undefined): boolean {
|
||||
return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to determine whether specific tools are allowed for a given agent.
|
||||
*
|
||||
* @param agentId - The ID of the agent. If null/undefined/empty, returns true for all tools (ephemeral agent behavior)
|
||||
* @param agentId - The ID of the agent. If null/undefined/empty, checks ephemeralAgent settings
|
||||
* @param ephemeralAgent - Optional ephemeral agent settings for tool permissions
|
||||
* @returns Object with boolean flags for file_search and execute_code permissions, plus the tools array
|
||||
*/
|
||||
export default function useAgentToolPermissions(
|
||||
agentId: string | null | undefined,
|
||||
ephemeralAgent?: TEphemeralAgent | null,
|
||||
): AgentToolPermissionsResult {
|
||||
const agentsMap = useAgentsMapContext();
|
||||
|
||||
|
@ -33,22 +40,26 @@ export default function useAgentToolPermissions(
|
|||
);
|
||||
|
||||
const fileSearchAllowedByAgent = useMemo(() => {
|
||||
// If no agentId, allow for ephemeral agents
|
||||
if (!agentId) return true;
|
||||
// Check ephemeral agent settings
|
||||
if (isEphemeralAgent(agentId)) {
|
||||
return ephemeralAgent?.[EToolResources.file_search] ?? false;
|
||||
}
|
||||
// If agentId exists but agent not found, disallow
|
||||
if (!selectedAgent) return false;
|
||||
// Check if the agent has the file_search tool
|
||||
return tools?.includes(Tools.file_search) ?? false;
|
||||
}, [agentId, selectedAgent, tools]);
|
||||
}, [agentId, selectedAgent, tools, ephemeralAgent]);
|
||||
|
||||
const codeAllowedByAgent = useMemo(() => {
|
||||
// If no agentId, allow for ephemeral agents
|
||||
if (!agentId) return true;
|
||||
// Check ephemeral agent settings
|
||||
if (isEphemeralAgent(agentId)) {
|
||||
return ephemeralAgent?.[EToolResources.execute_code] ?? false;
|
||||
}
|
||||
// If agentId exists but agent not found, disallow
|
||||
if (!selectedAgent) return false;
|
||||
// Check if the agent has the execute_code tool
|
||||
return tools?.includes(Tools.execute_code) ?? false;
|
||||
}, [agentId, selectedAgent, tools]);
|
||||
}, [agentId, selectedAgent, tools, ephemeralAgent]);
|
||||
|
||||
return {
|
||||
fileSearchAllowedByAgent,
|
||||
|
|
|
@ -71,7 +71,7 @@ export default function useDragHelpers() {
|
|||
const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities;
|
||||
const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true;
|
||||
const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true;
|
||||
const ocrEnabled = capabilities.includes(AgentCapabilities.ocr) === true;
|
||||
const contextEnabled = capabilities.includes(AgentCapabilities.context) === true;
|
||||
|
||||
/** Get agent permissions at drop time */
|
||||
const agentId = conversationRef.current?.agent_id;
|
||||
|
@ -99,7 +99,7 @@ export default function useDragHelpers() {
|
|||
allImages ||
|
||||
(fileSearchEnabled && fileSearchAllowedByAgent) ||
|
||||
(codeEnabled && codeAllowedByAgent) ||
|
||||
ocrEnabled;
|
||||
contextEnabled;
|
||||
|
||||
if (!shouldShowModal) {
|
||||
// Fallback: directly handle files without showing modal
|
||||
|
|
|
@ -105,11 +105,33 @@ export const useAutoSave = ({
|
|||
return;
|
||||
}
|
||||
|
||||
const handleInput = debounce((value: string) => setDraft({ id: conversationId, value }), 750);
|
||||
/** Use shorter debounce for saving text (65ms) to capture rapid typing */
|
||||
const handleInputFast = debounce(
|
||||
(value: string) => setDraft({ id: conversationId, value }),
|
||||
65,
|
||||
);
|
||||
|
||||
/** Use longer debounce for clearing empty values (850ms) to prevent accidental draft loss */
|
||||
const handleInputSlow = debounce(
|
||||
(value: string) => setDraft({ id: conversationId, value }),
|
||||
850,
|
||||
);
|
||||
|
||||
const eventListener = (e: Event) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
handleInput(target.value);
|
||||
const value = target.value;
|
||||
|
||||
/** Cancel any pending operations to avoid conflicts */
|
||||
handleInputFast.cancel();
|
||||
handleInputSlow.cancel();
|
||||
|
||||
/** If empty, use long delay to prevent accidental clearing
|
||||
* Otherwise use short delay to capture rapid typing */
|
||||
if (value === '') {
|
||||
handleInputSlow(value);
|
||||
} else {
|
||||
handleInputFast(value);
|
||||
}
|
||||
};
|
||||
|
||||
const textArea = textAreaRef?.current;
|
||||
|
@ -121,7 +143,8 @@ export const useAutoSave = ({
|
|||
if (textArea) {
|
||||
textArea.removeEventListener('input', eventListener);
|
||||
}
|
||||
handleInput.cancel();
|
||||
handleInputFast.cancel();
|
||||
handleInputSlow.cancel();
|
||||
};
|
||||
}, [conversationId, saveDrafts, textAreaRef]);
|
||||
|
||||
|
|
495
client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx
Normal file
495
client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx
Normal file
|
@ -0,0 +1,495 @@
|
|||
import React from 'react';
|
||||
import { Provider, createStore } from 'jotai';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { RecoilRoot, useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
import { setTimestamp } from '~/utils/timestamps';
|
||||
import { useMCPSelect } from '../useMCPSelect';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('~/utils/timestamps', () => ({
|
||||
setTimestamp: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lodash/isEqual', () => jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)));
|
||||
|
||||
const createWrapper = () => {
|
||||
// Create a new Jotai store for each test to ensure clean state
|
||||
const store = createStore();
|
||||
|
||||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<RecoilRoot>
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</RecoilRoot>
|
||||
);
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
describe('useMCPSelect', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('Basic Functionality', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
expect(result.current.isPinned).toBe(true); // Default value from mcpPinnedAtom is true
|
||||
expect(typeof result.current.setMCPValues).toBe('function');
|
||||
expect(typeof result.current.setIsPinned).toBe('function');
|
||||
});
|
||||
|
||||
it('should use conversationId when provided', () => {
|
||||
const conversationId = 'test-convo-123';
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
});
|
||||
|
||||
it('should use NEW_CONVO constant when conversationId is null', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId: null }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Updates', () => {
|
||||
it('should update mcpValues when setMCPValues is called', async () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const newValues = ['value1', 'value2'];
|
||||
|
||||
act(() => {
|
||||
result.current.setMCPValues(newValues);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpValues).toEqual(newValues);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update mcpValues if non-array is passed', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
// @ts-ignore - Testing invalid input
|
||||
result.current.setMCPValues('not-an-array');
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
});
|
||||
|
||||
it('should update isPinned state', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Default is true
|
||||
expect(result.current.isPinned).toBe(true);
|
||||
|
||||
// Toggle to false
|
||||
act(() => {
|
||||
result.current.setIsPinned(false);
|
||||
});
|
||||
|
||||
expect(result.current.isPinned).toBe(false);
|
||||
|
||||
// Toggle back to true
|
||||
act(() => {
|
||||
result.current.setIsPinned(true);
|
||||
});
|
||||
|
||||
expect(result.current.isPinned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timestamp Management', () => {
|
||||
it('should set timestamp when mcpValues is updated with values', async () => {
|
||||
const conversationId = 'test-convo';
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const newValues = ['value1', 'value2'];
|
||||
|
||||
act(() => {
|
||||
result.current.setMCPValues(newValues);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const expectedKey = `${LocalStorageKeys.LAST_MCP_}${conversationId}`;
|
||||
expect(setTimestamp).toHaveBeenCalledWith(expectedKey);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set timestamp when mcpValues is empty', async () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setMCPValues([]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setTimestamp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Race Conditions and Infinite Loops Prevention', () => {
|
||||
it('should not create infinite loop when syncing between Jotai and Recoil states', async () => {
|
||||
const { result, rerender } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
let renderCount = 0;
|
||||
const maxRenders = 10;
|
||||
|
||||
// Track renders to detect infinite loops
|
||||
const trackRender = () => {
|
||||
renderCount++;
|
||||
if (renderCount > maxRenders) {
|
||||
throw new Error('Potential infinite loop detected');
|
||||
}
|
||||
};
|
||||
|
||||
// Set initial value
|
||||
act(() => {
|
||||
trackRender();
|
||||
result.current.setMCPValues(['initial']);
|
||||
});
|
||||
|
||||
// Trigger multiple rerenders
|
||||
for (let i = 0; i < 3; i++) {
|
||||
rerender();
|
||||
trackRender();
|
||||
}
|
||||
|
||||
// Should not exceed max renders
|
||||
expect(renderCount).toBeLessThanOrEqual(maxRenders);
|
||||
});
|
||||
|
||||
it('should handle rapid consecutive updates without race conditions', async () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const updates = [
|
||||
['value1'],
|
||||
['value1', 'value2'],
|
||||
['value1', 'value2', 'value3'],
|
||||
['value4'],
|
||||
[],
|
||||
];
|
||||
|
||||
// Rapid fire updates
|
||||
act(() => {
|
||||
updates.forEach((update) => {
|
||||
result.current.setMCPValues(update);
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should settle on the last update
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain stable setter function reference', () => {
|
||||
const { result, rerender } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const firstSetMCPValues = result.current.setMCPValues;
|
||||
|
||||
// Trigger multiple rerenders
|
||||
rerender();
|
||||
rerender();
|
||||
rerender();
|
||||
|
||||
// Setter should remain the same reference (memoized)
|
||||
expect(result.current.setMCPValues).toBe(firstSetMCPValues);
|
||||
});
|
||||
|
||||
it('should handle switching conversation IDs without issues', async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ conversationId }) => useMCPSelect({ conversationId }),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
initialProps: { conversationId: 'convo1' },
|
||||
},
|
||||
);
|
||||
|
||||
// Set values for first conversation
|
||||
act(() => {
|
||||
result.current.setMCPValues(['convo1-value']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpValues).toEqual(['convo1-value']);
|
||||
});
|
||||
|
||||
// Switch to different conversation
|
||||
rerender({ conversationId: 'convo2' });
|
||||
|
||||
// Should have different state for new conversation
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
|
||||
// Set values for second conversation
|
||||
act(() => {
|
||||
result.current.setMCPValues(['convo2-value']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpValues).toEqual(['convo2-value']);
|
||||
});
|
||||
|
||||
// Switch back to first conversation
|
||||
rerender({ conversationId: 'convo1' });
|
||||
|
||||
// Should maintain separate state
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpValues).toEqual(['convo1-value']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ephemeral Agent Synchronization', () => {
|
||||
it('should sync mcpValues when ephemeralAgent is updated externally', async () => {
|
||||
// Create a shared wrapper for both hooks to share the same Recoil/Jotai context
|
||||
const wrapper = createWrapper();
|
||||
|
||||
// Create a component that uses both hooks to ensure they share state
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(
|
||||
ephemeralAgentByConvoId(Constants.NEW_CONVO),
|
||||
);
|
||||
return { mcpHook, ephemeralAgent, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
|
||||
// Simulate external update to ephemeralAgent (e.g., from another component)
|
||||
const externalMcpValues = ['external-value1', 'external-value2'];
|
||||
act(() => {
|
||||
result.current.setEphemeralAgent({
|
||||
mcp: externalMcpValues,
|
||||
});
|
||||
});
|
||||
|
||||
// The hook should sync with the external update
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpHook.mcpValues).toEqual(externalMcpValues);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update ephemeralAgent when mcpValues changes through hook', async () => {
|
||||
// Create a shared wrapper for both hooks
|
||||
const wrapper = createWrapper();
|
||||
|
||||
// Create a component that uses both the hook and accesses Recoil state
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, ephemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
|
||||
const newValues = ['hook-value1', 'hook-value2'];
|
||||
|
||||
act(() => {
|
||||
result.current.mcpHook.setMCPValues(newValues);
|
||||
});
|
||||
|
||||
// Verify both mcpValues and ephemeralAgent are updated
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpHook.mcpValues).toEqual(newValues);
|
||||
expect(result.current.ephemeralAgent?.mcp).toEqual(newValues);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty ephemeralAgent.mcp array correctly', async () => {
|
||||
// Create a shared wrapper
|
||||
const wrapper = createWrapper();
|
||||
|
||||
// Create a component that uses both hooks
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
|
||||
// Set initial values
|
||||
act(() => {
|
||||
result.current.mcpHook.setMCPValues(['initial-value']);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpHook.mcpValues).toEqual(['initial-value']);
|
||||
});
|
||||
|
||||
// Try to set empty array externally
|
||||
act(() => {
|
||||
result.current.setEphemeralAgent({
|
||||
mcp: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Values should remain unchanged since empty mcp array doesn't trigger update
|
||||
// (due to the condition: ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0)
|
||||
expect(result.current.mcpHook.mcpValues).toEqual(['initial-value']);
|
||||
});
|
||||
|
||||
it('should properly sync non-empty arrays from ephemeralAgent', async () => {
|
||||
// Additional test to ensure non-empty arrays DO sync
|
||||
const wrapper = createWrapper();
|
||||
|
||||
const TestComponent = () => {
|
||||
const mcpHook = useMCPSelect({});
|
||||
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
|
||||
return { mcpHook, setEphemeralAgent };
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => TestComponent(), { wrapper });
|
||||
|
||||
// Set initial values through ephemeralAgent with non-empty array
|
||||
const initialValues = ['value1', 'value2'];
|
||||
act(() => {
|
||||
result.current.setEphemeralAgent({
|
||||
mcp: initialValues,
|
||||
});
|
||||
});
|
||||
|
||||
// Should sync since it's non-empty
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpHook.mcpValues).toEqual(initialValues);
|
||||
});
|
||||
|
||||
// Update with different non-empty values
|
||||
const updatedValues = ['value3', 'value4', 'value5'];
|
||||
act(() => {
|
||||
result.current.setEphemeralAgent({
|
||||
mcp: updatedValues,
|
||||
});
|
||||
});
|
||||
|
||||
// Should sync the new values
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpHook.mcpValues).toEqual(updatedValues);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined conversationId', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId: undefined }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
result.current.setMCPValues(['test']);
|
||||
});
|
||||
|
||||
expect(() => result.current).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle empty string conversationId', () => {
|
||||
const { result } = renderHook(() => useMCPSelect({ conversationId: '' }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.mcpValues).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle very large arrays without performance issues', async () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`);
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
act(() => {
|
||||
result.current.setMCPValues(largeArray);
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
const executionTime = endTime - startTime;
|
||||
|
||||
// Should complete within reasonable time (< 100ms)
|
||||
expect(executionTime).toBeLessThan(100);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpValues).toEqual(largeArray);
|
||||
});
|
||||
});
|
||||
|
||||
it('should cleanup properly on unmount', () => {
|
||||
const { unmount } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Should unmount without errors
|
||||
expect(() => unmount()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Leak Prevention', () => {
|
||||
it('should not leak memory on repeated updates', async () => {
|
||||
const { result } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Perform many updates to test for memory leaks
|
||||
for (let i = 0; i < 100; i++) {
|
||||
act(() => {
|
||||
result.current.setMCPValues([`value-${i}`]);
|
||||
});
|
||||
}
|
||||
|
||||
// If we get here without crashing, memory management is likely OK
|
||||
expect(result.current.mcpValues).toEqual(['value-99']);
|
||||
});
|
||||
|
||||
it('should handle component remounting', () => {
|
||||
const { result, unmount } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.setMCPValues(['before-unmount']);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
// Remount
|
||||
const { result: newResult } = renderHook(() => useMCPSelect({}), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Should handle remounting gracefully
|
||||
expect(newResult.current.mcpValues).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -3,3 +3,4 @@ export * from './useMCPConnectionStatus';
|
|||
export * from './useMCPSelect';
|
||||
export * from './useVisibleTools';
|
||||
export { useMCPServerManager } from './useMCPServerManager';
|
||||
export { useRemoveMCPTool } from './useRemoveMCPTool';
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
|
||||
|
@ -19,15 +20,14 @@ export function useMCPSelect({ conversationId }: { conversationId?: string | nul
|
|||
}
|
||||
}, [ephemeralAgent?.mcp, setMCPValuesRaw]);
|
||||
|
||||
// Update ephemeral agent when Jotai state changes
|
||||
useEffect(() => {
|
||||
if (mcpValues.length > 0 && JSON.stringify(mcpValues) !== JSON.stringify(ephemeralAgent?.mcp)) {
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
mcp: mcpValues,
|
||||
}));
|
||||
}
|
||||
}, [mcpValues, ephemeralAgent?.mcp, setEphemeralAgent]);
|
||||
setEphemeralAgent((prev) => {
|
||||
if (!isEqual(prev?.mcp, mcpValues)) {
|
||||
return { ...(prev ?? {}), mcp: mcpValues };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [mcpValues, setEphemeralAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
const mcpStorageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
|
||||
|
|
61
client/src/hooks/MCP/useRemoveMCPTool.ts
Normal file
61
client/src/hooks/MCP/useRemoveMCPTool.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useToastContext } from '@librechat/client';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Hook for removing MCP tools/servers from an agent
|
||||
* Provides unified logic for MCPTool, UninitializedMCPTool, and UnconfiguredMCPTool components
|
||||
*/
|
||||
export function useRemoveMCPTool() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const updateUserPlugins = useUpdateUserPluginsMutation();
|
||||
const { getValues, setValue } = useFormContext<AgentForm>();
|
||||
|
||||
const removeTool = useCallback(
|
||||
(serverName: string) => {
|
||||
if (!serverName) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateUserPlugins.mutate(
|
||||
{
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
action: 'uninstall',
|
||||
auth: {},
|
||||
isEntityTool: true,
|
||||
},
|
||||
{
|
||||
onError: (error: unknown) => {
|
||||
showToast({
|
||||
message: localize('com_ui_delete_tool_error', { error: String(error) }),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
const currentTools = getValues('tools');
|
||||
const remainingToolIds =
|
||||
currentTools?.filter(
|
||||
(currentToolId) =>
|
||||
currentToolId !== serverName &&
|
||||
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
|
||||
) || [];
|
||||
setValue('tools', remainingToolIds, { shouldDirty: true });
|
||||
|
||||
showToast({
|
||||
message: localize('com_ui_delete_tool_save_reminder'),
|
||||
status: 'warning',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[getValues, setValue, updateUserPlugins, showToast, localize],
|
||||
);
|
||||
|
||||
return { removeTool };
|
||||
}
|
|
@ -2,7 +2,7 @@ import throttle from 'lodash/throttle';
|
|||
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import { useChatContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
|
||||
import { useMessagesViewContext, useAssistantsMapContext, useAgentsMapContext } from '~/Providers';
|
||||
import useCopyToClipboard from './useCopyToClipboard';
|
||||
import { getTextKey, logger } from '~/utils';
|
||||
|
||||
|
@ -20,9 +20,9 @@ export default function useMessageHelpers(props: TMessageProps) {
|
|||
setAbortScroll,
|
||||
handleContinue,
|
||||
setLatestMessage,
|
||||
} = useChatContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
} = useMessagesViewContext();
|
||||
const agentsMap = useAgentsMapContext();
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
|
||||
const { text, content, children, messageId = null, isCreatedByUser } = message ?? {};
|
||||
const edit = messageId === currentEditId;
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil';
|
|||
import { Constants } from 'librechat-data-provider';
|
||||
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useChatContext, useAddedChatContext } from '~/Providers';
|
||||
import { useMessagesViewContext } from '~/Providers';
|
||||
import { getTextKey, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
@ -18,14 +18,9 @@ export default function useMessageProcess({ message }: { message?: TMessage | nu
|
|||
latestMessage,
|
||||
setAbortScroll,
|
||||
setLatestMessage,
|
||||
isSubmitting: isSubmittingRoot,
|
||||
} = useChatContext();
|
||||
const { isSubmitting: isSubmittingAdditional } = useAddedChatContext();
|
||||
isSubmittingFamily,
|
||||
} = useMessagesViewContext();
|
||||
const latestMultiMessage = useRecoilValue(store.latestMessageFamily(index + 1));
|
||||
const isSubmittingFamily = useMemo(
|
||||
() => isSubmittingRoot || isSubmittingAdditional,
|
||||
[isSubmittingRoot, isSubmittingAdditional],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const convoId = conversation?.conversationId;
|
||||
|
|
|
@ -2,8 +2,8 @@ import { useRecoilValue } from 'recoil';
|
|||
import { Constants } from 'librechat-data-provider';
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useMessagesConversation, useMessagesSubmission } from '~/Providers';
|
||||
import useScrollToRef from '~/hooks/useScrollToRef';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
||||
const threshold = 0.85;
|
||||
|
@ -15,8 +15,8 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
|
|||
const scrollableRef = useRef<HTMLDivElement | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const { conversation, setAbortScroll, isSubmitting, abortScroll } = useChatContext();
|
||||
const { conversationId } = conversation ?? {};
|
||||
const { conversation, conversationId } = useMessagesConversation();
|
||||
const { setAbortScroll, isSubmitting, abortScroll } = useMessagesSubmission();
|
||||
|
||||
const timeoutIdRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useGetCategories } from '~/data-provider';
|
||||
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { useGetCategories } from '~/data-provider';
|
||||
|
||||
const loadingCategories: { label: TranslationKeys; value: string }[] = [
|
||||
{
|
||||
|
@ -14,9 +14,17 @@ const emptyCategory: { label: TranslationKeys; value: string } = {
|
|||
value: '',
|
||||
};
|
||||
|
||||
const useCategories = (className = '') => {
|
||||
const useCategories = ({
|
||||
className = '',
|
||||
hasAccess = true,
|
||||
}: {
|
||||
className?: string;
|
||||
hasAccess?: boolean;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
const { data: categories = loadingCategories } = useGetCategories({
|
||||
enabled: hasAccess,
|
||||
select: (data) =>
|
||||
data.map((category) => ({
|
||||
label: localize(category.label as TranslationKeys),
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useRecoilState } from 'recoil';
|
|||
import { usePromptGroupsInfiniteQuery } from '~/data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
export default function usePromptGroupsNav() {
|
||||
export default function usePromptGroupsNav(hasAccess = true) {
|
||||
const [pageSize] = useRecoilState(store.promptsPageSize);
|
||||
const [category] = useRecoilState(store.promptsCategory);
|
||||
const [name, setName] = useRecoilState(store.promptsName);
|
||||
|
@ -14,21 +14,26 @@ export default function usePromptGroupsNav() {
|
|||
|
||||
const prevFiltersRef = useRef({ name, category });
|
||||
|
||||
const groupsQuery = usePromptGroupsInfiniteQuery({
|
||||
name,
|
||||
pageSize,
|
||||
category,
|
||||
});
|
||||
const groupsQuery = usePromptGroupsInfiniteQuery(
|
||||
{
|
||||
name,
|
||||
pageSize,
|
||||
category,
|
||||
},
|
||||
{
|
||||
enabled: hasAccess,
|
||||
},
|
||||
);
|
||||
|
||||
// Get the current page data
|
||||
const currentPageData = useMemo(() => {
|
||||
if (!groupsQuery.data?.pages || groupsQuery.data.pages.length === 0) {
|
||||
if (!hasAccess || !groupsQuery.data?.pages || groupsQuery.data.pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Ensure we don't go out of bounds
|
||||
const pageIndex = Math.min(currentPageIndex, groupsQuery.data.pages.length - 1);
|
||||
return groupsQuery.data.pages[pageIndex];
|
||||
}, [groupsQuery.data?.pages, currentPageIndex]);
|
||||
}, [hasAccess, groupsQuery.data?.pages, currentPageIndex]);
|
||||
|
||||
// Get prompt groups for current page
|
||||
const promptGroups = useMemo(() => {
|
||||
|
@ -54,7 +59,7 @@ export default function usePromptGroupsNav() {
|
|||
|
||||
// Navigate to next page
|
||||
const nextPage = useCallback(async () => {
|
||||
if (!hasNextPage) return;
|
||||
if (!hasAccess || !hasNextPage) return;
|
||||
|
||||
const nextPageIndex = currentPageIndex + 1;
|
||||
|
||||
|
@ -72,16 +77,18 @@ export default function usePromptGroupsNav() {
|
|||
}
|
||||
|
||||
setCurrentPageIndex(nextPageIndex);
|
||||
}, [currentPageIndex, hasNextPage, groupsQuery]);
|
||||
}, [hasAccess, currentPageIndex, hasNextPage, groupsQuery]);
|
||||
|
||||
// Navigate to previous page
|
||||
const prevPage = useCallback(() => {
|
||||
if (!hasPreviousPage) return;
|
||||
if (!hasAccess || !hasPreviousPage) return;
|
||||
setCurrentPageIndex(currentPageIndex - 1);
|
||||
}, [currentPageIndex, hasPreviousPage]);
|
||||
}, [hasAccess, currentPageIndex, hasPreviousPage]);
|
||||
|
||||
// Reset when filters change
|
||||
useEffect(() => {
|
||||
if (!hasAccess) return;
|
||||
|
||||
const filtersChanged =
|
||||
prevFiltersRef.current.name !== name || prevFiltersRef.current.category !== category;
|
||||
|
||||
|
@ -90,18 +97,18 @@ export default function usePromptGroupsNav() {
|
|||
cursorHistoryRef.current = [null];
|
||||
prevFiltersRef.current = { name, category };
|
||||
}
|
||||
}, [name, category]);
|
||||
}, [hasAccess, name, category]);
|
||||
|
||||
return {
|
||||
promptGroups,
|
||||
promptGroups: hasAccess ? promptGroups : [],
|
||||
groupsQuery,
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
hasNextPage: hasAccess && hasNextPage,
|
||||
hasPreviousPage: hasAccess && hasPreviousPage,
|
||||
nextPage,
|
||||
prevPage,
|
||||
isFetching: groupsQuery.isFetching,
|
||||
isFetching: hasAccess ? groupsQuery.isFetching : false,
|
||||
name,
|
||||
setName,
|
||||
};
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
"com_agents_create_error": "حدث خطأ أثناء إنشاء الوكيل الخاص بك",
|
||||
"com_agents_description_placeholder": "اختياري: اشرح عميلك هنا",
|
||||
"com_agents_enable_file_search": "تمكين البحث عن الملفات",
|
||||
"com_agents_file_context": "سياق الملف (قارئ الحروف البصري)",
|
||||
"com_agents_file_context_disabled": "يحب أولاً إنشاء الوكيل قبل رفع الملف لمحلل سياق الملف",
|
||||
"com_agents_file_context_info": "الملفات المرفوعة كـ \"سياق\" تتم معالجتها باستخدام قارئ الحروف البصري (OCR) لاستخراج النص، والذي يُضاف بعد ذلك إلى التعليمات الموجِهة للوكيل. مثالية للوثائق والصور التي تحتوي على نص أو ملفات PDF حيث تحتاج إلى المحتوى النصي الكامل للملف.",
|
||||
"com_agents_file_search_disabled": "يجب إنشاء الوكيل قبل تحميل الملفات للبحث في الملفات.",
|
||||
"com_agents_file_search_info": "عند التمكين، سيتم إعلام الوكيل بأسماء الملفات المدرجة أدناه بالضبط، مما يتيح له استرجاع السياق ذي الصلة من هذه الملفات.",
|
||||
"com_agents_instructions_placeholder": "التعليمات النظامية التي يستخدمها الوكيل",
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
"com_agents_create_error": "S'ha produït un error en crear el teu agent.",
|
||||
"com_agents_description_placeholder": "Opcional: Descriu el teu Agent aquí",
|
||||
"com_agents_enable_file_search": "Habilita la Cerca de Fitxers",
|
||||
"com_agents_file_context": "Context de Fitxer (OCR)",
|
||||
"com_agents_file_context_disabled": "Cal crear l'agent abans de pujar fitxers per al Context de Fitxer.",
|
||||
"com_agents_file_context_info": "Els fitxers pujats com a \"Context\" es processen amb OCR per extreure'n el text, que s'afegeix a les instruccions de l'Agent. Ideal per a documents, imatges amb text o PDFs on cal el contingut complet del fitxer.",
|
||||
"com_agents_file_search_disabled": "Cal crear l'agent abans de pujar fitxers per a la Cerca de Fitxers.",
|
||||
"com_agents_file_search_info": "Quan està habilitat, l'agent serà informat dels noms exactes dels fitxers llistats a continuació, i podrà recuperar-ne el context rellevant.",
|
||||
"com_agents_instructions_placeholder": "Les instruccions de sistema que utilitza l'agent",
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
"com_agents_create_error": "Der opstod en fejl ved oprettelsen af din agent.",
|
||||
"com_agents_description_placeholder": "Valgfrit: Beskriv din agent her",
|
||||
"com_agents_enable_file_search": "Aktivér filsøgning",
|
||||
"com_agents_file_context": "Filkontekst (OCR)",
|
||||
"com_agents_file_context_disabled": "Agenten skal oprettes, før der uploades filer til File Context.",
|
||||
"com_agents_file_context_info": "Filer, der uploades som \"Context\", behandles ved hjælp af OCR for at udtrække tekst, som derefter føjes til agentens instruktioner. Ideel til dokumenter, billeder med tekst eller PDF'er, hvor du har brug for det fulde tekstindhold i en fil.",
|
||||
"com_agents_file_search_disabled": "Agent skal oprettes inden uploading af filer til filsøgning.",
|
||||
"com_agents_file_search_info": "Når den er aktiveret, får agenten besked om de nøjagtige filnavne, der er anført nedenfor, så den kan hente relevant kontekst fra disse filer.",
|
||||
"com_agents_instructions_placeholder": "De systeminstruktioner, som agenten bruger",
|
||||
|
|
|
@ -59,9 +59,7 @@
|
|||
"com_agents_error_timeout_suggestion": "Bitte überprüfe deine Internetverbindung und versuche es erneut.",
|
||||
"com_agents_error_timeout_title": "Verbindungs-Timeout",
|
||||
"com_agents_error_title": "Es ist ein Fehler aufgetreten",
|
||||
"com_agents_file_context": "Datei-Kontext (OCR)",
|
||||
"com_agents_file_context_disabled": "Der Agent muss vor dem Hochladen von Dateien für den Datei-Kontext erstellt werden.",
|
||||
"com_agents_file_context_info": "Als „Kontext“ hochgeladene Dateien werden mit OCR verarbeitet, um Text zu extrahieren, der dann den Anweisungen des Agenten hinzugefügt wird. Ideal für Dokumente, Bilder mit Text oder PDFs, wenn Sie den vollständigen Textinhalt einer Datei benötigen",
|
||||
"com_agents_file_search_disabled": "Der Agent muss erstellt werden, bevor Dateien für die Dateisuche hochgeladen werden können.",
|
||||
"com_agents_file_search_info": "Wenn aktiviert, wird der Agent über die unten aufgelisteten exakten Dateinamen informiert und kann dadurch relevante Informationen aus diesen Dateien abrufen",
|
||||
"com_agents_grid_announcement": "Zeige {{count}} Agenten in der Kategorie {{category}}",
|
||||
|
@ -833,7 +831,6 @@
|
|||
"com_ui_delete_tool": "Werkzeug löschen",
|
||||
"com_ui_delete_tool_confirm": "Bist du sicher, dass du dieses Werkzeug löschen möchtest?",
|
||||
"com_ui_delete_tool_error": "Fehler beim Löschen des Tools: {{error}}",
|
||||
"com_ui_delete_tool_success": "Tool erfolgreich gelöscht",
|
||||
"com_ui_deleted": "Gelöscht",
|
||||
"com_ui_deleting_file": "Lösche Datei...",
|
||||
"com_ui_descending": "Absteigend",
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
"com_agents_category_sales_description": "Agents focused on sales processes, customer relations",
|
||||
"com_agents_category_tab_label": "{{category}} category, {{position}} of {{total}}",
|
||||
"com_agents_category_tabs_label": "Agent Categories",
|
||||
"com_agents_chat_with": "Chat with {{name}}",
|
||||
"com_agents_clear_search": "Clear search",
|
||||
"com_agents_code_interpreter": "When enabled, allows your agent to leverage the LibreChat Code Interpreter API to run generated code, including file processing, securely. Requires a valid API key.",
|
||||
"com_agents_code_interpreter_title": "Code Interpreter API",
|
||||
|
@ -59,9 +60,9 @@
|
|||
"com_agents_error_timeout_suggestion": "Please check your internet connection and try again.",
|
||||
"com_agents_error_timeout_title": "Connection Timeout",
|
||||
"com_agents_error_title": "Something went wrong",
|
||||
"com_agents_file_context": "File Context (OCR)",
|
||||
"com_agents_file_context_description": "Files uploaded as \"Context\" are parsed as text to supplement the Agent's instructions. If OCR is available, or if configured for the uploaded filetype, the process is used to extract text. Ideal for documents, images with text, or PDFs where you need the full text content of a file",
|
||||
"com_agents_file_context_disabled": "Agent must be created before uploading files for File Context.",
|
||||
"com_agents_file_context_info": "Files uploaded as \"Context\" are processed using OCR to extract text, which is then added to the Agent's instructions. Ideal for documents, images with text, or PDFs where you need the full text content of a file",
|
||||
"com_agents_file_context_label": "File Context",
|
||||
"com_agents_file_search_disabled": "Agent must be created before uploading files for File Search.",
|
||||
"com_agents_file_search_info": "When enabled, the agent will be informed of the exact filenames listed below, allowing it to retrieve relevant context from these files.",
|
||||
"com_agents_grid_announcement": "Showing {{count}} agents in {{category}} category",
|
||||
|
@ -834,7 +835,7 @@
|
|||
"com_ui_delete_tool": "Delete Tool",
|
||||
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
||||
"com_ui_delete_tool_error": "Error while deleting the tool: {{error}}",
|
||||
"com_ui_delete_tool_success": "Tool deleted successfully",
|
||||
"com_ui_delete_tool_save_reminder": "Tool removed. Save the agent to apply changes.",
|
||||
"com_ui_deleted": "Deleted",
|
||||
"com_ui_deleting_file": "Deleting file...",
|
||||
"com_ui_descending": "Desc",
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
"com_agents_create_error": "Hubo un error al crear su agente.",
|
||||
"com_agents_description_placeholder": "Opcional: Describa su Agente aquí",
|
||||
"com_agents_enable_file_search": "Habilitar búsqueda de archivos",
|
||||
"com_agents_file_context": "Archivo de contexto (OCR)",
|
||||
"com_agents_file_context_disabled": "Es necesario crear el Agente antes de subir archivos.",
|
||||
"com_agents_file_search_disabled": "Es necesario crear el Agente antes de subir archivos para la Búsqueda de Archivos.",
|
||||
"com_agents_file_search_info": "Cuando está habilitado, se informará al agente sobre los nombres exactos de los archivos listados a continuación, permitiéndole recuperar el contexto relevante de estos archivos.",
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
"com_agents_create_error": "Agendi loomisel tekkis viga.",
|
||||
"com_agents_description_placeholder": "Valikuline: Kirjelda oma agenti siin",
|
||||
"com_agents_enable_file_search": "Luba failiotsing",
|
||||
"com_agents_file_context": "Faili kontekst (OCR)",
|
||||
"com_agents_file_context_disabled": "Agent tuleb luua enne failide üleslaadimist failikontekstiks.",
|
||||
"com_agents_file_context_info": "Failid, mis on laetud \"konteksti\", töödeldakse OCR-iga tekstiks ja lisatakse seejärel agendi juhistesse. See on eriti kasulik dokumentide, tekstiga piltide või PDF-ide puhul, kui vaja läheb kogu faili tekstilist sisu.",
|
||||
"com_agents_file_search_disabled": "Agent tuleb luua enne failide üleslaadimist failiotsinguks.",
|
||||
"com_agents_file_search_info": "Kui see on lubatud, teavitatakse agenti täpselt allpool loetletud failinimedest, mis võimaldab tal nendest failidest asjakohast konteksti hankida.",
|
||||
"com_agents_instructions_placeholder": "Süsteemijuhised, mida agent kasutab",
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
"com_agents_create_error": "در ایجاد کارگزار شما خطایی روی داد.",
|
||||
"com_agents_description_placeholder": "اختیاری: کارگزار خود را در اینجا شرح دهید",
|
||||
"com_agents_enable_file_search": "جستجوی فایل را فعال کنید",
|
||||
"com_agents_file_context": "زمینه فایل (OCR)",
|
||||
"com_agents_file_context_disabled": "کارگزار باید قبل از آپلود فایل ها برای File Context ایجاد شود.",
|
||||
"com_agents_file_context_info": "فایلهای آپلود شده بهعنوان «Context» با استفاده از OCR برای استخراج متن پردازش میشوند، که سپس به دستورالعملهای کارگزار اضافه میشود. ایده آل برای اسناد، تصاویر با متن یا PDF که در آن به محتوای متن کامل یک فایل نیاز دارید",
|
||||
"com_agents_file_search_disabled": "کارگزار باید قبل از آپلود فایل ها برای جستجوی فایل ایجاد شود.",
|
||||
"com_agents_file_search_info": "وقتی فعال باشد، کارگزار از نامهای دقیق فایلهای فهرستشده در زیر مطلع میشود و به او اجازه میدهد متن مربوطه را از این فایلها بازیابی کند.",
|
||||
"com_agents_instructions_placeholder": "دستورالعمل های سیستمی که کارگزار استفاده می کند",
|
||||
|
|
|
@ -4,9 +4,7 @@
|
|||
"com_agents_create_error": "Agentin luonnissa tapahtui virhe.",
|
||||
"com_agents_description_placeholder": "Valinnainen: Lisää tähän agentin kuvaus",
|
||||
"com_agents_enable_file_search": "Käytä Tiedostohakua",
|
||||
"com_agents_file_context": "Tiedostokonteksti (OCR)",
|
||||
"com_agents_file_context_disabled": "Agentti täytyy luoda ennen tiedostojen lataamista Tiedostokontekstiin",
|
||||
"com_agents_file_context_info": "\"Kontekstiksi\" ladatuista tiedostoista luetaan sisältö tekstintunnistuksen (OCR) avulla agentin ohjeisiin lisättäväksi. Soveltuu erityisesti asiakirjojen, tekstiä sisältävien kuvien tai PDF-tiedostojen käsittelyyn, kun haluat hyödyntää tiedoston koko tekstisisällön.",
|
||||
"com_agents_file_search_disabled": "Agentti täytyy luoda ennen tiedostojen lataamista Tiedostohakuun",
|
||||
"com_agents_file_search_info": "Asetuksen ollessa päällä agentti saa tiedoksi alla luetellut tiedostonimet, jolloin se voi hakea vastausten pohjaksi asiayhteyksiä tiedostojen sisällöistä.",
|
||||
"com_agents_instructions_placeholder": "Agentin käyttämät järjestelmäohjeet",
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
"com_agents_create_error": "Une erreur s'est produite lors de la création de votre agent.",
|
||||
"com_agents_description_placeholder": "Décrivez votre Agent ici (facultatif)",
|
||||
"com_agents_enable_file_search": "Activer la recherche de fichiers",
|
||||
"com_agents_file_context": "Contexte du fichier (OCR)",
|
||||
"com_agents_file_context_disabled": "L'agent doit être créé avant de charger des fichiers pour le contexte de fichiers.",
|
||||
"com_agents_file_context_info": "Les fichiers téléchargés en tant que \"Contexte\" sont traités à l'aide de l'OCR pour en extraire le texte, qui est ensuite ajouté aux instructions de l'agent. Idéal pour les documents, les images contenant du texte ou les PDF pour lesquels vous avez besoin du contenu textuel complet d'un fichier.",
|
||||
"com_agents_file_search_disabled": "L'agent doit être créé avant de pouvoir télécharger des fichiers pour la Recherche de Fichiers.",
|
||||
"com_agents_file_search_info": "Lorsque cette option est activée, l'agent sera informé des noms exacts des fichiers listés ci-dessous, lui permettant d'extraire le contexte pertinent de ces fichiers.",
|
||||
"com_agents_instructions_placeholder": "Les instructions système que l'agent utilise",
|
||||
|
|
|
@ -57,9 +57,7 @@
|
|||
"com_agents_error_timeout_suggestion": "אנא בדוק את חיבור האינטרנט שלך ונסה שוב",
|
||||
"com_agents_error_timeout_title": "זמן התפוגה של החיבור",
|
||||
"com_agents_error_title": "משהו השתבש",
|
||||
"com_agents_file_context": "קבצי הקשר (OCR)",
|
||||
"com_agents_file_context_disabled": "יש ליצור סוכן לפני שמעלים קבצים עבור הקשר קבצים",
|
||||
"com_agents_file_context_info": "קבצים שהועלו כ\"הקשר\" מעובדים באמצעות OCR (זיהוי אופטי של תווים) כדי להפיק טקסט אשר לאחר מכן מתווסף להוראות הסוכן. אידיאלי עבור מסמכים, תמונות עם טקסט או קובצי PDF בהם אתה צריך את התוכן הטקסטואלי המלא של הקובץ.",
|
||||
"com_agents_file_search_disabled": "יש ליצור את הסוכן לפני העלאת קבצים לחיפוש",
|
||||
"com_agents_file_search_info": "כאשר הסוכן מופעל הוא יקבל מידע על שמות הקבצים המפורטים להלן, כדי שהוא יוכל לאחזר את הקשר רלוונטי.",
|
||||
"com_agents_grid_announcement": "מציג {{count}} סוכנים מהקטגוריה {{category}}",
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
"com_agents_create_error": "Hiba történt az ügynök létrehozása során.",
|
||||
"com_agents_description_placeholder": "Opcionális: Itt írja le az ügynökét",
|
||||
"com_agents_enable_file_search": "Fájlkeresés engedélyezése",
|
||||
"com_agents_file_context": "Fájlkontextus (OCR)",
|
||||
"com_agents_file_context_disabled": "Az ügynököt először létre kell hozni, mielőtt fájlokat tölthet fel a fájlkontextushoz.",
|
||||
"com_agents_file_context_info": "A „Kontextusként” feltöltött fájlokat OCR-rel dolgozzuk fel a szöveg kinyeréséhez, amelyet aztán az ügynök utasításaihoz adunk. Ideális dokumentumokhoz, szöveges képekhez vagy PDF-ekhez, ahol a teljes szövegtartalomra szükség van.",
|
||||
"com_agents_file_search_disabled": "Az ügynököt először létre kell hozni, mielőtt fájlokat tölthet fel a fájlkereséshez.",
|
||||
"com_agents_file_search_info": "Ha engedélyezve van, az ügynök értesül az alább felsorolt pontos fájlnevekről, lehetővé téve számára, hogy releváns kontextust szerezzen ezekből a fájlokból.",
|
||||
"com_agents_instructions_placeholder": "Az ügynök által használt rendszerutasítások",
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
"com_agents_create_error": "Ձեր գործակալը ստեղծելիս սխալ է տեղի ունեցել։",
|
||||
"com_agents_description_placeholder": "Կամընտրական: Այստեղ նկարագրեք ձեր գործակալին",
|
||||
"com_agents_enable_file_search": "Միացնել ֆայլերի որոնումը",
|
||||
"com_agents_file_context": "Ֆայլի ճանաչում (OCR)",
|
||||
"com_agents_file_context_disabled": "Գործակալը պետք է ստեղծվի ֆայլերը վերբեռնելուց առաջ ֆայլերի ճանաչման համար:",
|
||||
"com_agents_mcp_icon_size": "Նվազագույն չափը՝ 128 x 128 px",
|
||||
"com_agents_mcp_name_placeholder": "Անհատական գործիք",
|
||||
|
|
|
@ -8,9 +8,7 @@
|
|||
"com_agents_create_error": "Si è verificato un errore durante la creazione del tuo agente.",
|
||||
"com_agents_description_placeholder": "Opzionale: Descrivi qui il tuo Agente",
|
||||
"com_agents_enable_file_search": "Abilita Ricerca File",
|
||||
"com_agents_file_context": "Contesto del File (OCR)",
|
||||
"com_agents_file_context_disabled": "L'agente deve essere creato prima di caricare i file per il Contesto del File.",
|
||||
"com_agents_file_context_info": "I file caricati come \"Contesto\" vengono elaborati tramite OCR per estrarre il testo, che viene poi aggiunto alle istruzioni dell'Agente. Ideale per documenti, immagini con testo o PDF in cui è necessario il contenuto di testo completo di un file",
|
||||
"com_agents_file_search_disabled": "L'Agente deve essere creato prima di caricare file per la Ricerca File.",
|
||||
"com_agents_file_search_info": "Quando abilitato, l'agente verrà informato dei nomi esatti dei file elencati di seguito, permettendogli di recuperare il contesto pertinente da questi file.",
|
||||
"com_agents_instructions_placeholder": "Le istruzioni di sistema utilizzate dall'agente",
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
"com_agents_create_error": "エージェントの作成中にエラーが発生しました。",
|
||||
"com_agents_description_placeholder": "オプション: エージェントの説明を入力してください",
|
||||
"com_agents_enable_file_search": "ファイル検索を有効にする",
|
||||
"com_agents_file_context": "ファイルコンテキスト(OCR)",
|
||||
"com_agents_file_context_disabled": "ファイル検索用のファイルをアップロードする前に、エージェントを作成する必要があります。",
|
||||
"com_agents_file_context_info": "「コンテキスト」としてアップロードされたファイルは、OCR処理によってテキストが抽出され、エージェントの指示に追加されます。ファイルの全文コンテンツが必要な文書、テキストを含む画像、PDFに最適です。",
|
||||
"com_agents_file_search_disabled": "ファイル検索用のファイルをアップロードする前に、エージェントを作成する必要があります。",
|
||||
"com_agents_file_search_info": "有効にすると、エージェントは以下に表示されているファイル名を正確に認識し、それらのファイルから関連する情報を取得することができます。",
|
||||
"com_agents_instructions_placeholder": "エージェントが使用するシステムの指示",
|
||||
|
|
|
@ -9,9 +9,7 @@
|
|||
"com_agents_create_error": "თქვენი აგენტის შექმნისასდაფიქსირდა შეცდომა",
|
||||
"com_agents_description_placeholder": "არასავალდებულო: აღწერეთ თქვენი აგენტი",
|
||||
"com_agents_enable_file_search": "ფაილების ძიების ჩართვა",
|
||||
"com_agents_file_context": "ფაილის კონტექსტი (OCR)",
|
||||
"com_agents_file_context_disabled": "ფაილის კონტექსტისთვის, ატვირთვამდე უნდა შეიქმნას აგენტი.",
|
||||
"com_agents_file_context_info": "„კონტექსტის“ სახით ატვირთული ფაილები დამუშავდება OCR-ის გამოყენებით ტექსტის ამოსაღებად, რომელიც შემდეგ დაემატება აგენტის ინსტრუქციებს. იდეალურია დოკუმენტებისთვის, ტექსტიანი სურათებისთვის ან PDF ფაილებისთვის, სადაც გჭირდებათ ფაილის შინაარსი.",
|
||||
"com_agents_instructions_placeholder": "სისტემის ინსტრუქციები, რომლებსაც გამოიყენებს აგენტი",
|
||||
"com_agents_mcp_description_placeholder": "რამდენიმე სიტყვით ახსენით დავალება",
|
||||
"com_agents_mcp_icon_size": "მინიმალური ზომა 128 x 128 პიქსელი",
|
||||
|
|
|
@ -10,9 +10,7 @@
|
|||
"com_agents_create_error": "에이전트 생성 중 오류가 발생했습니다",
|
||||
"com_agents_description_placeholder": "선택 사항: 여기에 에이전트를 설명하세요",
|
||||
"com_agents_enable_file_search": "파일 검색 활성화",
|
||||
"com_agents_file_context": "파일 컨텍스트 (OCR)",
|
||||
"com_agents_file_context_disabled": "파일 컨텍스트를 위해 파일을 업로드하기 전에, 에이전트가 먼저 생성되어야 합니다.",
|
||||
"com_agents_file_context_info": "컨텍스트(Context)로 업로드 된 파일은 OCR을 사용하여 텍스트를 추출하고, 이 텍스트는 에이전트의 지시사항에 추가됩니다. 문서, 텍스트가 포함된 이미지, 또는 파일의 전체 내용이 필요한 PDF에 이상적입니다.",
|
||||
"com_agents_file_search_disabled": "파일 검색을 위해 파일을 업로드하기 전에 에이전트를 먼저 생성해야 합니다",
|
||||
"com_agents_file_search_info": "활성화하면 에이전트가 아래 나열된 파일명들을 정확히 인식하여 해당 파일들에서 관련 내용을 검색할 수 있습니다.",
|
||||
"com_agents_instructions_placeholder": "에이전트가 사용하는 시스템 지침",
|
||||
|
|
|
@ -59,9 +59,7 @@
|
|||
"com_agents_error_timeout_suggestion": "Lūdzu, pārbaudiet interneta savienojumu un mēģiniet vēlreiz.",
|
||||
"com_agents_error_timeout_title": "Savienojumu neizdevās izveidot",
|
||||
"com_agents_error_title": "Kaut kas nogāja greizi",
|
||||
"com_agents_file_context": "Failu konteksts (OCR)",
|
||||
"com_agents_file_context_disabled": "Pirms failu augšupielādes failu kontekstam ir jāizveido aģents.",
|
||||
"com_agents_file_context_info": "Faili, kas augšupielādēti kā “Konteksts”, tiek apstrādāti, izmantojot OCR, lai iegūtu tekstu, kas pēc tam tiek pievienots aģenta norādījumiem. Ideāli piemērots dokumentiem, attēliem ar tekstu vai PDF failiem, kuriem nepieciešams pilns faila teksta saturs.",
|
||||
"com_agents_file_search_disabled": "Lai varētu iespējot faila augšupielādi informācijas iegūšanai no tā ir jāizveido aģents.",
|
||||
"com_agents_file_search_info": "Kad šī opcija ir iespējota, aģents tiks informēts par precīziem tālāk norādītajiem failu nosaukumiem, ļaujot tam izgūt atbilstošu kontekstu no šiem failiem.",
|
||||
"com_agents_grid_announcement": "Rādu {{count}} aģentus {{category}} kategorijā",
|
||||
|
@ -337,7 +335,7 @@
|
|||
"com_endpoint_prompt_prefix_assistants": "Papildu instrukcijas",
|
||||
"com_endpoint_prompt_prefix_assistants_placeholder": "Iestatiet papildu norādījumus vai kontekstu virs Asistenta galvenajiem norādījumiem. Ja lauks ir tukšs, tas tiek ignorēts.",
|
||||
"com_endpoint_prompt_prefix_placeholder": "Iestatiet pielāgotas instrukcijas vai kontekstu. Ja lauks ir tukšs, tas tiek ignorēts.",
|
||||
"com_endpoint_reasoning_effort": "Spriešanas piepūle",
|
||||
"com_endpoint_reasoning_effort": "Spriešanas līmenis",
|
||||
"com_endpoint_reasoning_summary": "Spriešanas kopsavilkums",
|
||||
"com_endpoint_save_as_preset": "Saglabāt kā iestatījumu",
|
||||
"com_endpoint_search": "Meklēt galapunktu pēc nosaukuma",
|
||||
|
@ -654,7 +652,7 @@
|
|||
"com_ui_agent_chain_info": "Ļauj izveidot aģentu secību ķēdes. Katrs aģents var piekļūt iepriekšējo ķēdē esošo aģentu izvades datiem. Balstīts uz \"Aģentu sajaukuma\" arhitektūru, kurā aģenti izmanto iepriekšējos izvades datus kā palīginformāciju.",
|
||||
"com_ui_agent_chain_max": "Jūs esat sasniedzis maksimālo skaitu {{0}} aģentu.",
|
||||
"com_ui_agent_delete_error": "Dzēšot aģentu, radās kļūda.",
|
||||
"com_ui_agent_deleted": "Aģents veiksmīgi izdzēsts",
|
||||
"com_ui_agent_deleted": "Aģents veiksmīgi dzēsts",
|
||||
"com_ui_agent_duplicate_error": "Dublējot aģentu, radās kļūda.",
|
||||
"com_ui_agent_duplicated": "Aģents veiksmīgi dublēts",
|
||||
"com_ui_agent_name_is_required": "Obligāti jānorāda aģenta nosaukums",
|
||||
|
@ -695,7 +693,7 @@
|
|||
"com_ui_ascending": "Augošā",
|
||||
"com_ui_assistant": "Asistents",
|
||||
"com_ui_assistant_delete_error": "Dzēšot asistentu, radās kļūda.",
|
||||
"com_ui_assistant_deleted": "Asistents ir veiksmīgi izdzēsts.",
|
||||
"com_ui_assistant_deleted": "Asistents ir veiksmīgi dzēsts.",
|
||||
"com_ui_assistants": "Asistenti",
|
||||
"com_ui_assistants_output": "Asistentu izvade",
|
||||
"com_ui_at_least_one_owner_required": "Nepieciešams vismaz viens īpašnieks",
|
||||
|
@ -738,7 +736,7 @@
|
|||
"com_ui_bookmarks_create_success": "Grāmatzīme veiksmīgi izveidota",
|
||||
"com_ui_bookmarks_delete": "Dzēst grāmatzīmi",
|
||||
"com_ui_bookmarks_delete_error": "Dzēšot grāmatzīmi, radās kļūda.",
|
||||
"com_ui_bookmarks_delete_success": "Grāmatzīme veiksmīgi izdzēsta",
|
||||
"com_ui_bookmarks_delete_success": "Grāmatzīme veiksmīgi dzēsta",
|
||||
"com_ui_bookmarks_description": "Apraksts",
|
||||
"com_ui_bookmarks_edit": "Rediģēt grāmatzīmi",
|
||||
"com_ui_bookmarks_filter": "Filtrēt grāmatzīmes...",
|
||||
|
@ -825,7 +823,7 @@
|
|||
"com_ui_delete_mcp": "Dzēst MCP",
|
||||
"com_ui_delete_mcp_confirm": "Vai tiešām vēlaties dzēst šo MCP serveri?",
|
||||
"com_ui_delete_mcp_error": "Neizdevās izdzēst MCP serveri.",
|
||||
"com_ui_delete_mcp_success": "MCP serveris veiksmīgi izdzēsts",
|
||||
"com_ui_delete_mcp_success": "MCP serveris veiksmīgi dzēsts",
|
||||
"com_ui_delete_memory": "Dzēst atmiņu",
|
||||
"com_ui_delete_not_allowed": "Dzēšanas darbība nav atļauta",
|
||||
"com_ui_delete_prompt": "Vai dzēst uzvedni?",
|
||||
|
@ -834,7 +832,7 @@
|
|||
"com_ui_delete_tool": "Dzēst rīku",
|
||||
"com_ui_delete_tool_confirm": "Vai tiešām vēlaties dzēst šo rīku?",
|
||||
"com_ui_delete_tool_error": "Kļūda, dzēšot rīku: {{error}}",
|
||||
"com_ui_delete_tool_success": "Rīks veiksmīgi izdzēsts",
|
||||
"com_ui_delete_tool_save_reminder": "Rīks noņemts. Saglabājiet aģentu, lai piemērotu izmaiņas.",
|
||||
"com_ui_deleted": "Dzēsts",
|
||||
"com_ui_deleting_file": "Dzēšu failu...",
|
||||
"com_ui_descending": "Dilstošs",
|
||||
|
@ -847,7 +845,7 @@
|
|||
"com_ui_download_artifact": "Lejupielādēt artefaktu",
|
||||
"com_ui_download_backup": "Lejupielādēt rezerves kodus",
|
||||
"com_ui_download_backup_tooltip": "Pirms turpināt, lejupielādējiet rezerves kodus. Tie būs nepieciešami, lai atgūtu piekļuvi, ja pazaudēsiet autentifikatora ierīci.",
|
||||
"com_ui_download_error": "Kļūda, lejupielādējot failu. Iespējams, fails ir izdzēsts.",
|
||||
"com_ui_download_error": "Kļūda, lejupielādējot failu. Iespējams, fails ir dzēsts.",
|
||||
"com_ui_drag_drop": "Ievietojiet šeit jebkuru failu, lai pievienotu to sarunai",
|
||||
"com_ui_dropdown_variables": "Nolaižamās izvēlnes mainīgie:",
|
||||
"com_ui_dropdown_variables_info": "Izveidojiet pielāgotas nolaižamās izvēlnes savām uzvednēm:{{variable_name:option1|option2|option3}}` (mainīgā_nosakums:opcija1|opcija2|opcija3)",
|
||||
|
@ -1012,7 +1010,7 @@
|
|||
"com_ui_memory_would_exceed": "Nevar saglabāt - pārsniegtu tokenu limitu par {{tokens}}. Izdzēsiet esošās atmiņas, lai atbrīvotu vietu.",
|
||||
"com_ui_mention": "Pieminiet galapunktu, assistentu vai iestatījumu, lai ātri uz to pārslēgtos",
|
||||
"com_ui_min_tags": "Nevar noņemt vairāk vērtību, vismaz {{0}} ir nepieciešamas.",
|
||||
"com_ui_minimal": "Minimāla",
|
||||
"com_ui_minimal": "Minimāls",
|
||||
"com_ui_misc": "Dažādi",
|
||||
"com_ui_model": "Modelis",
|
||||
"com_ui_model_parameters": "Modeļa Parametrus",
|
||||
|
@ -1035,7 +1033,7 @@
|
|||
"com_ui_no_results_found": "Nav atrastu rezultātu",
|
||||
"com_ui_no_terms_content": "Nav noteikumu un nosacījumu satura, ko parādīt",
|
||||
"com_ui_no_valid_items": "Nav rezultātu",
|
||||
"com_ui_none": "Neviens",
|
||||
"com_ui_none": "Nekāds",
|
||||
"com_ui_not_used": "Nav izmantots",
|
||||
"com_ui_nothing_found": "Nekas nav atrasts",
|
||||
"com_ui_oauth": "OAuth",
|
||||
|
@ -1162,8 +1160,8 @@
|
|||
"com_ui_share_link_to_chat": "Kopīgot saiti sarunai",
|
||||
"com_ui_share_update_message": "Jūsu vārds, pielāgotie norādījumi un visas ziņas, ko pievienojat pēc kopīgošanas, paliek privātas.",
|
||||
"com_ui_share_var": "Kopīgot {{0}}",
|
||||
"com_ui_shared_link_bulk_delete_success": "Koplietotās saites ir veiksmīgi izdzēstas.",
|
||||
"com_ui_shared_link_delete_success": "Koplietotā saite ir veiksmīgi izdzēsta.",
|
||||
"com_ui_shared_link_bulk_delete_success": "Koplietotās saites ir veiksmīgi dzēstas.",
|
||||
"com_ui_shared_link_delete_success": "Koplietotā saite ir veiksmīgi dzēsta.",
|
||||
"com_ui_shared_link_not_found": "Kopīgotā saite nav atrasta",
|
||||
"com_ui_shared_prompts": "Koplietotas uzvednes",
|
||||
"com_ui_shop": "Iepirkšanās",
|
||||
|
|
|
@ -59,9 +59,7 @@
|
|||
"com_agents_error_timeout_suggestion": "Sjekk internettforbindelsen din og prøv igjen.",
|
||||
"com_agents_error_timeout_title": "Tidsavbrudd for tilkobling",
|
||||
"com_agents_error_title": "Noe gikk galt",
|
||||
"com_agents_file_context": "Filkontekst (OCR)",
|
||||
"com_agents_file_context_disabled": "Agenten må være opprettet før du kan laste opp filer for filkontekst.",
|
||||
"com_agents_file_context_info": "Filer lastet opp som \"kontekst\" behandles med OCR for å trekke ut tekst, som deretter legges til i agentens instruksjoner. Dette er ideelt for dokumenter, bilder med tekst eller PDF-er der du trenger hele tekstinnholdet.",
|
||||
"com_agents_file_search_disabled": "Agenten må være opprettet før du kan laste opp filer for filsøk.",
|
||||
"com_agents_file_search_info": "Når dette er aktivert, vil agenten bruke de eksakte filnavnene listet nedenfor for å hente relevant kontekst fra disse filene.",
|
||||
"com_agents_grid_announcement": "Viser {{count}} agenter i kategorien {{category}}.",
|
||||
|
@ -834,7 +832,6 @@
|
|||
"com_ui_delete_tool": "Slett verktøy",
|
||||
"com_ui_delete_tool_confirm": "Er du sikker på at du vil slette dette verktøyet?",
|
||||
"com_ui_delete_tool_error": "En feil oppstod ved sletting av verktøyet: {{error}}",
|
||||
"com_ui_delete_tool_success": "Verktøyet ble slettet",
|
||||
"com_ui_deleted": "Slettet",
|
||||
"com_ui_deleting_file": "Sletter fil ...",
|
||||
"com_ui_descending": "Synkende",
|
||||
|
|
|
@ -21,12 +21,17 @@
|
|||
"com_agents_error_bad_request_message": "De aanvraag kon niet worden verwerkt.",
|
||||
"com_agents_error_bad_request_suggestion": "Controleer uw invoer en probeer het opnieuw.",
|
||||
"com_agents_error_invalid_request": "Ongeldige aanvraag",
|
||||
"com_agents_file_context": "File Context (OCR)",
|
||||
"com_agents_file_context_disabled": "Agent moet worden aangemaakt voordat bestanden worden geüpload voor File Context",
|
||||
"com_agents_file_context_info": "Bestanden die als \"Context\" worden geüpload, worden verwerkt met OCR voor tekstherkenning. De tekst wordt daarna toegevoegd aan de instructies van de Agent. Ideaal voor documenten, afbeeldingen met tekst of PDF's waarvan je de volledige tekstinhoud nodig hebt.\"",
|
||||
"com_agents_file_search_disabled": "Maak eerst een Agent aan voordat je bestanden uploadt voor File Search.",
|
||||
"com_agents_file_search_info": "Als deze functie is ingeschakeld, krijgt de agent informatie over de exacte bestandsnamen die hieronder staan vermeld, zodat deze relevante context uit deze bestanden kan ophalen.",
|
||||
"com_agents_instructions_placeholder": "De systeeminstructies die de agent gebruikt",
|
||||
"com_agents_link_copied": "Link gekopieerd",
|
||||
"com_agents_link_copy_failed": "Link niet gekopieerd",
|
||||
"com_agents_load_more_label": "Laad meer agenten van {{category}} categorie",
|
||||
"com_agents_loading": "Aan het laden...",
|
||||
"com_agents_mcp_icon_size": "Minimum formaat 128 x 128 px",
|
||||
"com_agents_mcp_info": "MCP-servers toevoegen aan je agent zodat deze taken kan uitvoeren en kan communiceren met externe services",
|
||||
"com_agents_mcp_name_placeholder": "Aangepast hulpmiddel",
|
||||
"com_agents_missing_provider_model": "Selecteer een provider en model voordat je een agent aanmaakt.",
|
||||
"com_agents_name_placeholder": "De naam van de agent",
|
||||
"com_agents_no_access": "Je hebt geen toegang om deze agent te bewerken.",
|
||||
|
|
|
@ -9,9 +9,7 @@
|
|||
"com_agents_create_error": "Wystąpił błąd podczas tworzenia agenta.",
|
||||
"com_agents_description_placeholder": "Opcjonalnie: Opisz swojego agenta tutaj",
|
||||
"com_agents_enable_file_search": "Włącz wyszukiwanie plików",
|
||||
"com_agents_file_context": "Kontest Pliku (OCR)",
|
||||
"com_agents_file_context_disabled": "Agent musi zostać utworzony przed przesłaniem plików dla Kontekstu Plików",
|
||||
"com_agents_file_context_info": "Pliki przesłane jako \"Kontekst\" są przetworzone przez OCR by wydobyć tekst, który potem jest dodany do instrukcji Agenta. Jest to idealne dla dokumentów, obrazów z tekstem oraz plików PDF, gdzie potrzebujesz całego tekstu z pliku.",
|
||||
"com_agents_file_search_disabled": "Agent musi zostać utworzony przed przesłaniem plików do wyszukiwania.",
|
||||
"com_agents_file_search_info": "Po włączeniu agent zostanie poinformowany o dokładnych nazwach plików wymienionych poniżej, co pozwoli mu na pobranie odpowiedniego kontekstu z tych plików.",
|
||||
"com_agents_instructions_placeholder": "Instrukcje systemowe używane przez agenta",
|
||||
|
|
|
@ -37,9 +37,7 @@
|
|||
"com_agents_error_server_title": "Erro no servidor",
|
||||
"com_agents_error_timeout_title": "A conexão expirou",
|
||||
"com_agents_error_title": "Algo deu errado",
|
||||
"com_agents_file_context": "Contexto de arquivo (OCR)",
|
||||
"com_agents_file_context_disabled": "O agente deve ser criado antes de carregar arquivos para o Contexto de Arquivo.",
|
||||
"com_agents_file_context_info": "Os arquivos carregados como \"Contexto\" são processados usando OCR para extrair texto, que é então adicionado às instruções do Agente. Ideal para documentos, imagens com texto ou PDFs onde você precisa do conteúdo de texto completo de um arquivo",
|
||||
"com_agents_file_search_disabled": "O agente deve ser criado antes de carregar arquivos para Pesquisa de Arquivos.",
|
||||
"com_agents_file_search_info": "Quando ativado, o agente será informado dos nomes exatos dos arquivos listados abaixo, permitindo que ele recupere o contexto relevante desses arquivos.",
|
||||
"com_agents_instructions_placeholder": "As instruções do sistema que o agente usa",
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
"com_agents_create_error": "Houve um erro ao criar seu agente.",
|
||||
"com_agents_description_placeholder": "Opcional: Descreva seu Agente aqui",
|
||||
"com_agents_enable_file_search": "Permitir Pesquisa de Ficheiros.",
|
||||
"com_agents_file_context": "Contexto de Ficheiro (OCR)",
|
||||
"com_agents_file_context_disabled": "Um agente deve ser criado antes de tentar fazer upload para contexto.",
|
||||
"com_agents_file_search_disabled": "O Agente deve ser criado antes carregar ficheiros para Pesquisar.",
|
||||
"com_agents_file_search_info": "Quando ativo, os agentes serão informados dos nomes de ficheiros listados abaixo, permitindo aos mesmos a extração de contexto relevante.",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue