Compare commits
11 Commits
chart-1.9.
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
088d90cf13 | ||
|
|
5b63aceda9 | ||
|
|
f1dab7f924 | ||
|
|
d5accf55c8 | ||
|
|
f5bb44e652 | ||
|
|
796cb2b1ab | ||
|
|
a50a098a6c | ||
|
|
9ed456ae14 | ||
|
|
33ca25bae3 | ||
|
|
d1d4c2eb27 | ||
|
|
efdad28b70 |
@@ -690,8 +690,8 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# REDIS_PING_INTERVAL=300
|
||||
|
||||
# Force specific cache namespaces to use in-memory storage even when Redis is enabled
|
||||
# Comma-separated list of CacheKeys (e.g., ROLES,MESSAGES)
|
||||
# FORCED_IN_MEMORY_CACHE_NAMESPACES=ROLES,MESSAGES
|
||||
# Comma-separated list of CacheKeys (e.g., STATIC_CONFIG,ROLES,MESSAGES)
|
||||
# FORCED_IN_MEMORY_CACHE_NAMESPACES=STATIC_CONFIG,ROLES
|
||||
|
||||
#==================================================#
|
||||
# Others #
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.8.0-rc4
|
||||
# v0.8.0-rc3
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
@@ -30,7 +30,7 @@ RUN \
|
||||
# Allow mounting of these files, which have no default
|
||||
touch .env ; \
|
||||
# Create directories for the volumes to inherit the correct permissions
|
||||
mkdir -p /app/client/public/images /app/api/logs /app/uploads ; \
|
||||
mkdir -p /app/client/public/images /app/api/logs ; \
|
||||
npm config set fetch-retry-maxtimeout 600000 ; \
|
||||
npm config set fetch-retries 5 ; \
|
||||
npm config set fetch-retry-mintimeout 15000 ; \
|
||||
@@ -44,6 +44,8 @@ RUN \
|
||||
npm prune --production; \
|
||||
npm cache clean --force
|
||||
|
||||
RUN mkdir -p /app/client/public/images /app/api/logs
|
||||
|
||||
# Node API setup
|
||||
EXPOSE 3080
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.8.0-rc4
|
||||
# v0.8.0-rc3
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
- 🔍 **Web Search**:
|
||||
- Search the internet and retrieve relevant information to enhance your AI context
|
||||
- Combines search providers, content scrapers, and result rerankers for optimal results
|
||||
- **Customizable Jina Reranking**: Configure custom Jina API URLs for reranking services
|
||||
- **[Learn More →](https://www.librechat.ai/docs/features/web_search)**
|
||||
|
||||
- 🪄 **Generative UI with Code Artifacts**:
|
||||
|
||||
3
api/cache/cacheConfig.spec.js
vendored
3
api/cache/cacheConfig.spec.js
vendored
@@ -157,11 +157,12 @@ describe('cacheConfig', () => {
|
||||
|
||||
describe('FORCED_IN_MEMORY_CACHE_NAMESPACES validation', () => {
|
||||
test('should parse comma-separated cache keys correctly', () => {
|
||||
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, MESSAGES ';
|
||||
process.env.FORCED_IN_MEMORY_CACHE_NAMESPACES = ' ROLES, STATIC_CONFIG ,MESSAGES ';
|
||||
|
||||
const { cacheConfig } = require('./cacheConfig');
|
||||
expect(cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES).toEqual([
|
||||
'ROLES',
|
||||
'STATIC_CONFIG',
|
||||
'MESSAGES',
|
||||
]);
|
||||
});
|
||||
|
||||
2
api/cache/getLogStores.js
vendored
2
api/cache/getLogStores.js
vendored
@@ -31,8 +31,8 @@ const namespaces = {
|
||||
[CacheKeys.SAML_SESSION]: sessionCache(CacheKeys.SAML_SESSION),
|
||||
|
||||
[CacheKeys.ROLES]: standardCache(CacheKeys.ROLES),
|
||||
[CacheKeys.APP_CONFIG]: standardCache(CacheKeys.APP_CONFIG),
|
||||
[CacheKeys.CONFIG_STORE]: standardCache(CacheKeys.CONFIG_STORE),
|
||||
[CacheKeys.STATIC_CONFIG]: standardCache(CacheKeys.STATIC_CONFIG),
|
||||
[CacheKeys.PENDING_REQ]: standardCache(CacheKeys.PENDING_REQ),
|
||||
[CacheKeys.ENCODED_DOMAINS]: new Keyv({ store: keyvMongo, namespace: CacheKeys.ENCODED_DOMAINS }),
|
||||
[CacheKeys.ABORT_KEYS]: standardCache(CacheKeys.ABORT_KEYS, Time.TEN_MINUTES),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.8.0-rc4",
|
||||
"version": "v0.8.0-rc3",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -49,14 +49,14 @@
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.79",
|
||||
"@librechat/agents": "^2.4.76",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"axios": "^1.12.1",
|
||||
"axios": "^1.8.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.8.1",
|
||||
"connect-redis": "^8.1.0",
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
webSearchKeys,
|
||||
extractWebSearchEnvVars,
|
||||
normalizeHttpError,
|
||||
MCPTokenStorage,
|
||||
} = require('@librechat/api');
|
||||
const { webSearchKeys, extractWebSearchEnvVars, normalizeHttpError } = require('@librechat/api');
|
||||
const {
|
||||
getFiles,
|
||||
updateUser,
|
||||
@@ -21,17 +16,11 @@ const { verifyEmail, resendVerificationEmail } = require('~/server/services/Auth
|
||||
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
|
||||
const { Tools, Constants, FileSources } = require('librechat-data-provider');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { Transaction, Balance, User, Token } = require('~/db/models');
|
||||
const { Transaction, Balance, User } = require('~/db/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||
const { deleteAllSharedLinks } = require('~/models');
|
||||
const { getMCPManager } = require('~/config');
|
||||
const { MCPOAuthHandler } = require('@librechat/api');
|
||||
const { getFlowStateManager } = require('~/config');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { clearMCPServerTools } = require('~/server/services/Config/mcpToolsCache');
|
||||
const { findToken } = require('~/models');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
const appConfig = await getAppConfig({ role: req.user?.role });
|
||||
@@ -173,15 +162,6 @@ const updateUserPluginsController = async (req, res) => {
|
||||
);
|
||||
({ status, message } = normalizeHttpError(authService));
|
||||
}
|
||||
try {
|
||||
// if the MCP server uses OAuth, perform a full cleanup and token revocation
|
||||
await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// This handles:
|
||||
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
|
||||
@@ -289,97 +269,6 @@ const resendVerificationController = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* OAuth MCP specific uninstall logic
|
||||
*/
|
||||
const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
||||
if (!pluginKey.startsWith(Constants.mcp_prefix)) {
|
||||
// this is not an MCP server, so nothing to do here
|
||||
return;
|
||||
}
|
||||
|
||||
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||
const mcpManager = getMCPManager(userId);
|
||||
const serverConfig = mcpManager.getRawConfig(serverName) ?? appConfig?.mcpServers?.[serverName];
|
||||
|
||||
if (!mcpManager.getOAuthServers().has(serverName)) {
|
||||
// this server does not use OAuth, so nothing to do here as well
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. get client info used for revocation (client id, secret)
|
||||
const clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
|
||||
userId,
|
||||
serverName,
|
||||
findToken,
|
||||
});
|
||||
if (clientTokenData == null) {
|
||||
return;
|
||||
}
|
||||
const { clientInfo, clientMetadata } = clientTokenData;
|
||||
|
||||
// 2. get decrypted tokens before deletion
|
||||
const tokens = await MCPTokenStorage.getTokens({
|
||||
userId,
|
||||
serverName,
|
||||
findToken,
|
||||
});
|
||||
|
||||
// 3. revoke OAuth tokens at the provider
|
||||
const revocationEndpoint =
|
||||
serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint;
|
||||
const revocationEndpointAuthMethodsSupported =
|
||||
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
|
||||
clientMetadata.revocation_endpoint_auth_methods_supported;
|
||||
|
||||
if (tokens?.access_token) {
|
||||
try {
|
||||
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', {
|
||||
serverUrl: serverConfig.url,
|
||||
clientId: clientInfo.client_id,
|
||||
clientSecret: clientInfo.client_secret ?? '',
|
||||
revocationEndpoint,
|
||||
revocationEndpointAuthMethodsSupported,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens?.refresh_token) {
|
||||
try {
|
||||
await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', {
|
||||
serverUrl: serverConfig.url,
|
||||
clientId: clientInfo.client_id,
|
||||
clientSecret: clientInfo.client_secret ?? '',
|
||||
revocationEndpoint,
|
||||
revocationEndpointAuthMethodsSupported,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. delete tokens from the DB after revocation attempts
|
||||
await MCPTokenStorage.deleteUserTokens({
|
||||
userId,
|
||||
serverName,
|
||||
deleteToken: async (filter) => {
|
||||
await Token.deleteOne(filter);
|
||||
},
|
||||
});
|
||||
|
||||
// 5. clear the flow state for the OAuth tokens
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = getFlowStateManager(flowsCache);
|
||||
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
|
||||
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
|
||||
await flowManager.deleteFlow(flowId, 'mcp_oauth');
|
||||
|
||||
// 6. clear the tools cache for the server
|
||||
await clearMCPServerTools({ userId, serverName });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getUserController,
|
||||
getTermsStatusController,
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
const { Tools } = require('librechat-data-provider');
|
||||
|
||||
// Mock all dependencies before requiring the module
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-id'),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
sendEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
EnvVar: { CODE_API_KEY: 'CODE_API_KEY' },
|
||||
Providers: { GOOGLE: 'google' },
|
||||
GraphEvents: {},
|
||||
getMessageId: jest.fn(),
|
||||
ToolEndHandler: jest.fn(),
|
||||
handleToolCalls: jest.fn(),
|
||||
ChatModelStreamHandler: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Citations', () => ({
|
||||
processFileCitations: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||
processCodeOutput: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||
loadAuthValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
saveBase64Image: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('createToolEndCallback', () => {
|
||||
let req, res, artifactPromises, createToolEndCallback;
|
||||
let logger;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Get the mocked logger
|
||||
logger = require('@librechat/data-schemas').logger;
|
||||
|
||||
// Now require the module after all mocks are set up
|
||||
const callbacks = require('../callbacks');
|
||||
createToolEndCallback = callbacks.createToolEndCallback;
|
||||
|
||||
req = {
|
||||
user: { id: 'user123' },
|
||||
};
|
||||
res = {
|
||||
headersSent: false,
|
||||
write: jest.fn(),
|
||||
};
|
||||
artifactPromises = [];
|
||||
});
|
||||
|
||||
describe('ui_resources artifact handling', () => {
|
||||
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: {
|
||||
0: { type: 'button', label: 'Click me' },
|
||||
1: { type: 'input', placeholder: 'Enter text' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
|
||||
// Wait for all promises to resolve
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
// When headers are not sent, it returns attachment without writing
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
|
||||
const attachment = results[0];
|
||||
expect(attachment).toEqual({
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'run456',
|
||||
toolCallId: 'tool123',
|
||||
conversationId: 'thread789',
|
||||
[Tools.ui_resources]: {
|
||||
0: { type: 'button', label: 'Click me' },
|
||||
1: { type: 'input', placeholder: 'Enter text' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should write to response when headers are already sent', async () => {
|
||||
res.headersSent = true;
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: {
|
||||
0: { type: 'carousel', items: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
expect(res.write).toHaveBeenCalled();
|
||||
expect(results[0]).toEqual({
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'run456',
|
||||
toolCallId: 'tool123',
|
||||
conversationId: 'thread789',
|
||||
[Tools.ui_resources]: {
|
||||
0: { type: 'carousel', items: [] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors when processing ui_resources', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
// Mock res.write to throw an error
|
||||
res.headersSent = true;
|
||||
res.write.mockImplementation(() => {
|
||||
throw new Error('Write failed');
|
||||
});
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: {
|
||||
0: { type: 'test' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error processing artifact content:',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(results[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle multiple artifacts including ui_resources', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: {
|
||||
0: { type: 'chart', data: [] },
|
||||
},
|
||||
},
|
||||
[Tools.web_search]: {
|
||||
results: ['result1', 'result2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
// Both ui_resources and web_search should be processed
|
||||
expect(artifactPromises).toHaveLength(2);
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
// Check ui_resources attachment
|
||||
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
||||
expect(uiResourceAttachment).toBeTruthy();
|
||||
expect(uiResourceAttachment[Tools.ui_resources]).toEqual({
|
||||
0: { type: 'chart', data: [] },
|
||||
});
|
||||
|
||||
// Check web_search attachment
|
||||
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
||||
expect(webSearchAttachment).toBeTruthy();
|
||||
expect(webSearchAttachment[Tools.web_search]).toEqual({
|
||||
results: ['result1', 'result2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not process artifacts when output has no artifacts', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
content: 'Some regular content',
|
||||
// No artifact property
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
|
||||
expect(artifactPromises).toHaveLength(0);
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty ui_resources data object', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
expect(results[0]).toEqual({
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'run456',
|
||||
toolCallId: 'tool123',
|
||||
conversationId: 'thread789',
|
||||
[Tools.ui_resources]: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ui_resources with complex nested data', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const complexData = {
|
||||
0: {
|
||||
type: 'form',
|
||||
fields: [
|
||||
{ name: 'field1', type: 'text', required: true },
|
||||
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
|
||||
],
|
||||
nested: {
|
||||
deep: {
|
||||
value: 123,
|
||||
array: [1, 2, 3],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: complexData,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
expect(results[0][Tools.ui_resources]).toEqual(complexData);
|
||||
});
|
||||
|
||||
it('should handle when output is undefined', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output: undefined }, metadata);
|
||||
|
||||
expect(artifactPromises).toHaveLength(0);
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle when data parameter is undefined', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback(undefined, metadata);
|
||||
|
||||
expect(artifactPromises).toHaveLength(0);
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -265,30 +265,6 @@ function createToolEndCallback({ req, res, artifactPromises }) {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: a lot of duplicated code in createToolEndCallback
|
||||
// we should refactor this to use a helper function in a follow-up PR
|
||||
if (output.artifact[Tools.ui_resources]) {
|
||||
artifactPromises.push(
|
||||
(async () => {
|
||||
const attachment = {
|
||||
type: Tools.ui_resources,
|
||||
messageId: metadata.run_id,
|
||||
toolCallId: output.tool_call_id,
|
||||
conversationId: metadata.thread_id,
|
||||
[Tools.ui_resources]: output.artifact[Tools.ui_resources].data,
|
||||
};
|
||||
if (!res.headersSent) {
|
||||
return attachment;
|
||||
}
|
||||
res.write(`event: attachment\ndata: ${JSON.stringify(attachment)}\n\n`);
|
||||
return attachment;
|
||||
})().catch((error) => {
|
||||
logger.error('Error processing artifact content:', error);
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (output.artifact[Tools.web_search]) {
|
||||
artifactPromises.push(
|
||||
(async () => {
|
||||
|
||||
@@ -872,10 +872,11 @@ class AgentClient extends BaseClient {
|
||||
if (agent.useLegacyContent === true) {
|
||||
messages = formatContentStrings(messages);
|
||||
}
|
||||
const defaultHeaders =
|
||||
agent.model_parameters?.clientOptions?.defaultHeaders ??
|
||||
agent.model_parameters?.configuration?.defaultHeaders;
|
||||
if (defaultHeaders?.['anthropic-beta']?.includes('prompt-caching')) {
|
||||
if (
|
||||
agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes(
|
||||
'prompt-caching',
|
||||
)
|
||||
) {
|
||||
messages = addCacheControl(messages);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,25 +11,18 @@ const { getAppConfig } = require('~/server/services/Config');
|
||||
* @param {Object} res - Express response object.
|
||||
* @param {Function} next - Next middleware function.
|
||||
*
|
||||
* @returns {Promise<void>} - Calls next middleware if the domain's email is allowed, otherwise redirects to login
|
||||
* @returns {Promise<function|Object>} - Returns a Promise which when resolved calls next middleware if the domain's email is allowed
|
||||
*/
|
||||
const checkDomainAllowed = async (req, res, next) => {
|
||||
try {
|
||||
const email = req?.user?.email;
|
||||
const appConfig = await getAppConfig({
|
||||
role: req?.user?.role,
|
||||
});
|
||||
|
||||
if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
|
||||
res.redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('[checkDomainAllowed] Error checking domain:', error);
|
||||
res.redirect('/login');
|
||||
const checkDomainAllowed = async (req, res, next = () => {}) => {
|
||||
const email = req?.user?.email;
|
||||
const appConfig = await getAppConfig({
|
||||
role: req?.user?.role,
|
||||
});
|
||||
if (email && !isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`);
|
||||
return res.redirect('/login');
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -26,12 +26,9 @@ const domains = {
|
||||
router.use(logHeaders);
|
||||
router.use(loginLimiter);
|
||||
|
||||
const oauthHandler = async (req, res, next) => {
|
||||
const oauthHandler = async (req, res) => {
|
||||
try {
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
await checkDomainAllowed(req, res);
|
||||
await checkBan(req, res);
|
||||
if (req.banned) {
|
||||
return;
|
||||
@@ -49,7 +46,6 @@ const oauthHandler = async (req, res, next) => {
|
||||
res.redirect(domains.client);
|
||||
} catch (err) {
|
||||
logger.error('Error in setting authentication tokens:', err);
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,7 +79,6 @@ router.get(
|
||||
scope: ['openid', 'profile', 'email'],
|
||||
}),
|
||||
setBalanceConfig,
|
||||
checkDomainAllowed,
|
||||
oauthHandler,
|
||||
);
|
||||
|
||||
@@ -109,7 +104,6 @@ router.get(
|
||||
profileFields: ['id', 'email', 'name'],
|
||||
}),
|
||||
setBalanceConfig,
|
||||
checkDomainAllowed,
|
||||
oauthHandler,
|
||||
);
|
||||
|
||||
@@ -131,7 +125,6 @@ router.get(
|
||||
session: false,
|
||||
}),
|
||||
setBalanceConfig,
|
||||
checkDomainAllowed,
|
||||
oauthHandler,
|
||||
);
|
||||
|
||||
@@ -155,7 +148,6 @@ router.get(
|
||||
scope: ['user:email', 'read:user'],
|
||||
}),
|
||||
setBalanceConfig,
|
||||
checkDomainAllowed,
|
||||
oauthHandler,
|
||||
);
|
||||
|
||||
@@ -179,7 +171,6 @@ router.get(
|
||||
scope: ['identify', 'email'],
|
||||
}),
|
||||
setBalanceConfig,
|
||||
checkDomainAllowed,
|
||||
oauthHandler,
|
||||
);
|
||||
|
||||
@@ -201,7 +192,6 @@ router.post(
|
||||
session: false,
|
||||
}),
|
||||
setBalanceConfig,
|
||||
checkDomainAllowed,
|
||||
oauthHandler,
|
||||
);
|
||||
|
||||
|
||||
@@ -152,7 +152,6 @@ describe('AppService', () => {
|
||||
webSearch: expect.objectContaining({
|
||||
safeSearch: 1,
|
||||
jinaApiKey: '${JINA_API_KEY}',
|
||||
jinaApiUrl: '${JINA_API_URL}',
|
||||
cohereApiKey: '${COHERE_API_KEY}',
|
||||
serperApiKey: '${SERPER_API_KEY}',
|
||||
searxngApiKey: '${SEARXNG_API_KEY}',
|
||||
|
||||
@@ -3,12 +3,12 @@ const jwt = require('jsonwebtoken');
|
||||
const { webcrypto } = require('node:crypto');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, checkEmailConfig } = require('@librechat/api');
|
||||
const { ErrorTypes, SystemRoles, errorsToString } = require('librechat-data-provider');
|
||||
const { SystemRoles, errorsToString } = require('librechat-data-provider');
|
||||
const {
|
||||
findUser,
|
||||
findToken,
|
||||
createUser,
|
||||
updateUser,
|
||||
findToken,
|
||||
countUsers,
|
||||
getUserById,
|
||||
findSession,
|
||||
@@ -181,14 +181,6 @@ const registerUser = async (user, additionalData = {}) => {
|
||||
|
||||
let newUserId;
|
||||
try {
|
||||
const appConfig = await getAppConfig();
|
||||
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
const errorMessage =
|
||||
'The email address provided cannot be used. Please use a different email address.';
|
||||
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
|
||||
return { status: 403, message: errorMessage };
|
||||
}
|
||||
|
||||
const existingUser = await findUser({ email }, 'email _id');
|
||||
|
||||
if (existingUser) {
|
||||
@@ -203,6 +195,14 @@ const registerUser = async (user, additionalData = {}) => {
|
||||
return { status: 200, message: genericVerificationMessage };
|
||||
}
|
||||
|
||||
const appConfig = await getAppConfig({ role: user.role });
|
||||
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
const errorMessage =
|
||||
'The email address provided cannot be used. Please use a different email address.';
|
||||
logger.error(`[registerUser] [Registration not allowed] [Email: ${user.email}]`);
|
||||
return { status: 403, message: errorMessage };
|
||||
}
|
||||
|
||||
//determine if this is the first registered user (not counting anonymous_user)
|
||||
const isFirstRegisteredUser = (await countUsers()) === 0;
|
||||
|
||||
@@ -252,13 +252,6 @@ const registerUser = async (user, additionalData = {}) => {
|
||||
*/
|
||||
const requestPasswordReset = async (req) => {
|
||||
const { email } = req.body;
|
||||
const appConfig = await getAppConfig();
|
||||
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
const error = new Error(ErrorTypes.AUTH_FAILED);
|
||||
error.code = ErrorTypes.AUTH_FAILED;
|
||||
error.message = 'Email domain not allowed';
|
||||
return error;
|
||||
}
|
||||
const user = await findUser({ email }, 'email _id');
|
||||
const emailEnabled = checkEmailConfig();
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ const AppService = require('~/server/services/AppService');
|
||||
const { setCachedTools } = require('./getCachedTools');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
const BASE_CONFIG_KEY = '_BASE_';
|
||||
|
||||
/**
|
||||
* Get the app configuration based on user context
|
||||
* @param {Object} [options]
|
||||
@@ -16,8 +14,8 @@ const BASE_CONFIG_KEY = '_BASE_';
|
||||
async function getAppConfig(options = {}) {
|
||||
const { role, refresh } = options;
|
||||
|
||||
const cache = getLogStores(CacheKeys.APP_CONFIG);
|
||||
const cacheKey = role ? role : BASE_CONFIG_KEY;
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const cacheKey = role ? `${CacheKeys.APP_CONFIG}:${role}` : CacheKeys.APP_CONFIG;
|
||||
|
||||
if (!refresh) {
|
||||
const cached = await cache.get(cacheKey);
|
||||
@@ -26,7 +24,7 @@ async function getAppConfig(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
let baseConfig = await cache.get(BASE_CONFIG_KEY);
|
||||
let baseConfig = await cache.get(CacheKeys.APP_CONFIG);
|
||||
if (!baseConfig) {
|
||||
logger.info('[getAppConfig] App configuration not initialized. Initializing AppService...');
|
||||
baseConfig = await AppService();
|
||||
@@ -39,7 +37,7 @@ async function getAppConfig(options = {}) {
|
||||
await setCachedTools(baseConfig.availableTools, { isGlobal: true });
|
||||
}
|
||||
|
||||
await cache.set(BASE_CONFIG_KEY, baseConfig);
|
||||
await cache.set(CacheKeys.APP_CONFIG, baseConfig);
|
||||
}
|
||||
|
||||
// For now, return the base config
|
||||
|
||||
@@ -119,6 +119,10 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
|
||||
.filter((endpoint) => endpoint.customParams)
|
||||
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
|
||||
|
||||
if (customConfig.cache) {
|
||||
const cache = getLogStores(CacheKeys.STATIC_CONFIG);
|
||||
await cache.set(CacheKeys.LIBRECHAT_YAML_CONFIG, customConfig);
|
||||
}
|
||||
|
||||
if (result.data.modelSpecs) {
|
||||
customConfig.modelSpecs = result.data.modelSpecs;
|
||||
|
||||
@@ -48,11 +48,16 @@ const axios = require('axios');
|
||||
const { loadYaml } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const loadCustomConfig = require('./loadCustomConfig');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
describe('loadCustomConfig', () => {
|
||||
const mockSet = jest.fn();
|
||||
const mockCache = { set: mockSet };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
delete process.env.CONFIG_PATH;
|
||||
getLogStores.mockReturnValue(mockCache);
|
||||
});
|
||||
|
||||
it('should return null and log error if remote config fetch fails', async () => {
|
||||
@@ -89,6 +94,7 @@ describe('loadCustomConfig', () => {
|
||||
const result = await loadCustomConfig();
|
||||
|
||||
expect(result).toEqual(mockConfig);
|
||||
expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig);
|
||||
});
|
||||
|
||||
it('should return null and log if config schema validation fails', async () => {
|
||||
@@ -128,6 +134,7 @@ describe('loadCustomConfig', () => {
|
||||
axios.get.mockResolvedValue({ data: mockConfig });
|
||||
const result = await loadCustomConfig();
|
||||
expect(result).toEqual(mockConfig);
|
||||
expect(mockSet).toHaveBeenCalledWith(expect.anything(), mockConfig);
|
||||
});
|
||||
|
||||
it('should return null if the remote config file is not found', async () => {
|
||||
@@ -161,6 +168,7 @@ describe('loadCustomConfig', () => {
|
||||
process.env.CONFIG_PATH = 'validConfig.yaml';
|
||||
loadYaml.mockReturnValueOnce(mockConfig);
|
||||
await loadCustomConfig();
|
||||
expect(mockSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log the loaded custom config', async () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
|
||||
clientOptions = Object.assign(
|
||||
{
|
||||
proxy: PROXY ?? null,
|
||||
userId: req.user.id,
|
||||
reverseProxyUrl: ANTHROPIC_REVERSE_PROXY ?? null,
|
||||
modelOptions: endpointOption?.model_parameters ?? {},
|
||||
},
|
||||
@@ -48,7 +49,6 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio
|
||||
if (overrideModel) {
|
||||
clientOptions.modelOptions.model = overrideModel;
|
||||
}
|
||||
clientOptions.modelOptions.user = req.user.id;
|
||||
return getLLMConfig(anthropicApiKey, clientOptions);
|
||||
}
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
>
|
||||
<!--[if mso]><style>.v-button {background: transparent !important;}</style><![endif]-->
|
||||
<div align='left'>
|
||||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{{verificationLink}}" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
|
||||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="href="{{verificationLink}}"" style="height:37px; v-text-anchor:middle; width:114px;" arcsize="11%" stroke="f" fillcolor="#10a37f"><w:anchorlock/><center style="color:#FFFFFF;"><![endif]-->
|
||||
<a
|
||||
href='{{verificationLink}}'
|
||||
target='_blank'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EModelEndpoint, Constants, openAISettings, CacheKeys } = require('librechat-data-provider');
|
||||
const { createImportBatchBuilder } = require('./importBatchBuilder');
|
||||
const { cloneMessagesWithTimestamps } = require('./fork');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const logger = require('~/config/winston');
|
||||
|
||||
/**
|
||||
* Returns the appropriate importer function based on the provided JSON data.
|
||||
@@ -212,29 +212,6 @@ function processConversation(conv, importBatchBuilder, requestUserId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to find the nearest non-system parent
|
||||
* @param {string} parentId - The ID of the parent message.
|
||||
* @returns {string} The ID of the nearest non-system parent message.
|
||||
*/
|
||||
const findNonSystemParent = (parentId) => {
|
||||
if (!parentId || !messageMap.has(parentId)) {
|
||||
return Constants.NO_PARENT;
|
||||
}
|
||||
|
||||
const parentMapping = conv.mapping[parentId];
|
||||
if (!parentMapping?.message) {
|
||||
return Constants.NO_PARENT;
|
||||
}
|
||||
|
||||
/* If parent is a system message, traverse up to find the nearest non-system parent */
|
||||
if (parentMapping.message.author?.role === 'system') {
|
||||
return findNonSystemParent(parentMapping.parent);
|
||||
}
|
||||
|
||||
return messageMap.get(parentId);
|
||||
};
|
||||
|
||||
// Create and save messages using the mapped IDs
|
||||
const messages = [];
|
||||
for (const [id, mapping] of Object.entries(conv.mapping)) {
|
||||
@@ -243,27 +220,23 @@ function processConversation(conv, importBatchBuilder, requestUserId) {
|
||||
messageMap.delete(id);
|
||||
continue;
|
||||
} else if (role === 'system') {
|
||||
// Skip system messages but keep their ID in messageMap for parent references
|
||||
messageMap.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newMessageId = messageMap.get(id);
|
||||
const parentMessageId = findNonSystemParent(mapping.parent);
|
||||
const parentMessageId =
|
||||
mapping.parent && messageMap.has(mapping.parent)
|
||||
? messageMap.get(mapping.parent)
|
||||
: Constants.NO_PARENT;
|
||||
|
||||
const messageText = formatMessageText(mapping.message);
|
||||
|
||||
const isCreatedByUser = role === 'user';
|
||||
let sender = isCreatedByUser ? 'user' : 'assistant';
|
||||
let sender = isCreatedByUser ? 'user' : 'GPT-3.5';
|
||||
const model = mapping.message.metadata.model_slug || openAISettings.model.default;
|
||||
|
||||
if (!isCreatedByUser) {
|
||||
/** Extracted model name from model slug */
|
||||
const gptMatch = model.match(/gpt-(.+)/i);
|
||||
if (gptMatch) {
|
||||
sender = `GPT-${gptMatch[1]}`;
|
||||
} else {
|
||||
sender = model || 'assistant';
|
||||
}
|
||||
if (model.includes('gpt-4')) {
|
||||
sender = 'GPT-4';
|
||||
}
|
||||
|
||||
messages.push({
|
||||
|
||||
@@ -99,404 +99,6 @@ describe('importChatGptConvo', () => {
|
||||
|
||||
expect(importBatchBuilder.saveBatch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle system messages without breaking parent-child relationships', async () => {
|
||||
/**
|
||||
* Test data that reproduces message graph "breaking" when it encounters a system message
|
||||
*/
|
||||
const testData = [
|
||||
{
|
||||
title: 'System Message Parent Test',
|
||||
create_time: 1714585031.148505,
|
||||
update_time: 1714585060.879308,
|
||||
mapping: {
|
||||
'root-node': {
|
||||
id: 'root-node',
|
||||
message: null,
|
||||
parent: null,
|
||||
children: ['user-msg-1'],
|
||||
},
|
||||
'user-msg-1': {
|
||||
id: 'user-msg-1',
|
||||
message: {
|
||||
id: 'user-msg-1',
|
||||
author: { role: 'user' },
|
||||
create_time: 1714585031.150442,
|
||||
content: { content_type: 'text', parts: ['First user message'] },
|
||||
metadata: { model_slug: 'gpt-4' },
|
||||
},
|
||||
parent: 'root-node',
|
||||
children: ['assistant-msg-1'],
|
||||
},
|
||||
'assistant-msg-1': {
|
||||
id: 'assistant-msg-1',
|
||||
message: {
|
||||
id: 'assistant-msg-1',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585032.150442,
|
||||
content: { content_type: 'text', parts: ['First assistant response'] },
|
||||
metadata: { model_slug: 'gpt-4' },
|
||||
},
|
||||
parent: 'user-msg-1',
|
||||
children: ['system-msg'],
|
||||
},
|
||||
'system-msg': {
|
||||
id: 'system-msg',
|
||||
message: {
|
||||
id: 'system-msg',
|
||||
author: { role: 'system' },
|
||||
create_time: 1714585033.150442,
|
||||
content: { content_type: 'text', parts: ['System message in middle'] },
|
||||
metadata: { model_slug: 'gpt-4' },
|
||||
},
|
||||
parent: 'assistant-msg-1',
|
||||
children: ['user-msg-2'],
|
||||
},
|
||||
'user-msg-2': {
|
||||
id: 'user-msg-2',
|
||||
message: {
|
||||
id: 'user-msg-2',
|
||||
author: { role: 'user' },
|
||||
create_time: 1714585034.150442,
|
||||
content: { content_type: 'text', parts: ['Second user message'] },
|
||||
metadata: { model_slug: 'gpt-4' },
|
||||
},
|
||||
parent: 'system-msg',
|
||||
children: ['assistant-msg-2'],
|
||||
},
|
||||
'assistant-msg-2': {
|
||||
id: 'assistant-msg-2',
|
||||
message: {
|
||||
id: 'assistant-msg-2',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585035.150442,
|
||||
content: { content_type: 'text', parts: ['Second assistant response'] },
|
||||
metadata: { model_slug: 'gpt-4' },
|
||||
},
|
||||
parent: 'user-msg-2',
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const requestUserId = 'user-123';
|
||||
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
|
||||
jest.spyOn(importBatchBuilder, 'saveMessage');
|
||||
|
||||
const importer = getImporter(testData);
|
||||
await importer(testData, requestUserId, () => importBatchBuilder);
|
||||
|
||||
/** 2 user messages + 2 assistant messages (system message should be skipped) */
|
||||
const expectedMessages = 4;
|
||||
expect(importBatchBuilder.saveMessage).toHaveBeenCalledTimes(expectedMessages);
|
||||
|
||||
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
|
||||
|
||||
const messageMap = new Map();
|
||||
savedMessages.forEach((msg) => {
|
||||
messageMap.set(msg.text, msg);
|
||||
});
|
||||
|
||||
const firstUser = messageMap.get('First user message');
|
||||
const firstAssistant = messageMap.get('First assistant response');
|
||||
const secondUser = messageMap.get('Second user message');
|
||||
const secondAssistant = messageMap.get('Second assistant response');
|
||||
|
||||
expect(firstUser).toBeDefined();
|
||||
expect(firstAssistant).toBeDefined();
|
||||
expect(secondUser).toBeDefined();
|
||||
expect(secondAssistant).toBeDefined();
|
||||
expect(firstUser.parentMessageId).toBe(Constants.NO_PARENT);
|
||||
expect(firstAssistant.parentMessageId).toBe(firstUser.messageId);
|
||||
|
||||
// This is the key test: second user message should have first assistant as parent
|
||||
// (not NO_PARENT which would indicate the system message broke the chain)
|
||||
expect(secondUser.parentMessageId).toBe(firstAssistant.messageId);
|
||||
expect(secondAssistant.parentMessageId).toBe(secondUser.messageId);
|
||||
});
|
||||
|
||||
it('should maintain correct sender for user messages regardless of GPT-4 model', async () => {
|
||||
/**
|
||||
* Test data with GPT-4 model to ensure user messages keep 'user' sender
|
||||
*/
|
||||
const testData = [
|
||||
{
|
||||
title: 'GPT-4 Sender Test',
|
||||
create_time: 1714585031.148505,
|
||||
update_time: 1714585060.879308,
|
||||
mapping: {
|
||||
'root-node': {
|
||||
id: 'root-node',
|
||||
message: null,
|
||||
parent: null,
|
||||
children: ['user-msg-1'],
|
||||
},
|
||||
'user-msg-1': {
|
||||
id: 'user-msg-1',
|
||||
message: {
|
||||
id: 'user-msg-1',
|
||||
author: { role: 'user' },
|
||||
create_time: 1714585031.150442,
|
||||
content: { content_type: 'text', parts: ['User message with GPT-4'] },
|
||||
metadata: { model_slug: 'gpt-4' },
|
||||
},
|
||||
parent: 'root-node',
|
||||
children: ['assistant-msg-1'],
|
||||
},
|
||||
'assistant-msg-1': {
|
||||
id: 'assistant-msg-1',
|
||||
message: {
|
||||
id: 'assistant-msg-1',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585032.150442,
|
||||
content: { content_type: 'text', parts: ['Assistant response with GPT-4'] },
|
||||
metadata: { model_slug: 'gpt-4' },
|
||||
},
|
||||
parent: 'user-msg-1',
|
||||
children: ['user-msg-2'],
|
||||
},
|
||||
'user-msg-2': {
|
||||
id: 'user-msg-2',
|
||||
message: {
|
||||
id: 'user-msg-2',
|
||||
author: { role: 'user' },
|
||||
create_time: 1714585033.150442,
|
||||
content: { content_type: 'text', parts: ['Another user message with GPT-4o-mini'] },
|
||||
metadata: { model_slug: 'gpt-4o-mini' },
|
||||
},
|
||||
parent: 'assistant-msg-1',
|
||||
children: ['assistant-msg-2'],
|
||||
},
|
||||
'assistant-msg-2': {
|
||||
id: 'assistant-msg-2',
|
||||
message: {
|
||||
id: 'assistant-msg-2',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585034.150442,
|
||||
content: { content_type: 'text', parts: ['Assistant response with GPT-3.5'] },
|
||||
metadata: { model_slug: 'gpt-3.5-turbo' },
|
||||
},
|
||||
parent: 'user-msg-2',
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const requestUserId = 'user-123';
|
||||
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
|
||||
jest.spyOn(importBatchBuilder, 'saveMessage');
|
||||
|
||||
const importer = getImporter(testData);
|
||||
await importer(testData, requestUserId, () => importBatchBuilder);
|
||||
|
||||
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
|
||||
|
||||
const userMsg1 = savedMessages.find((msg) => msg.text === 'User message with GPT-4');
|
||||
const assistantMsg1 = savedMessages.find((msg) => msg.text === 'Assistant response with GPT-4');
|
||||
const userMsg2 = savedMessages.find(
|
||||
(msg) => msg.text === 'Another user message with GPT-4o-mini',
|
||||
);
|
||||
const assistantMsg2 = savedMessages.find(
|
||||
(msg) => msg.text === 'Assistant response with GPT-3.5',
|
||||
);
|
||||
|
||||
expect(userMsg1.sender).toBe('user');
|
||||
expect(userMsg1.isCreatedByUser).toBe(true);
|
||||
expect(userMsg1.model).toBe('gpt-4');
|
||||
|
||||
expect(userMsg2.sender).toBe('user');
|
||||
expect(userMsg2.isCreatedByUser).toBe(true);
|
||||
expect(userMsg2.model).toBe('gpt-4o-mini');
|
||||
|
||||
expect(assistantMsg1.sender).toBe('GPT-4');
|
||||
expect(assistantMsg1.isCreatedByUser).toBe(false);
|
||||
expect(assistantMsg1.model).toBe('gpt-4');
|
||||
|
||||
expect(assistantMsg2.sender).toBe('GPT-3.5-turbo');
|
||||
expect(assistantMsg2.isCreatedByUser).toBe(false);
|
||||
expect(assistantMsg2.model).toBe('gpt-3.5-turbo');
|
||||
});
|
||||
|
||||
it('should correctly extract and format model names from various model slugs', async () => {
|
||||
/**
|
||||
* Test data with various model slugs to test dynamic model identifier extraction
|
||||
*/
|
||||
const testData = [
|
||||
{
|
||||
title: 'Dynamic Model Identifier Test',
|
||||
create_time: 1714585031.148505,
|
||||
update_time: 1714585060.879308,
|
||||
mapping: {
|
||||
'root-node': {
|
||||
id: 'root-node',
|
||||
message: null,
|
||||
parent: null,
|
||||
children: ['msg-1'],
|
||||
},
|
||||
'msg-1': {
|
||||
id: 'msg-1',
|
||||
message: {
|
||||
id: 'msg-1',
|
||||
author: { role: 'user' },
|
||||
create_time: 1714585031.150442,
|
||||
content: { content_type: 'text', parts: ['Test message'] },
|
||||
metadata: {},
|
||||
},
|
||||
parent: 'root-node',
|
||||
children: ['msg-2', 'msg-3', 'msg-4', 'msg-5', 'msg-6', 'msg-7', 'msg-8', 'msg-9'],
|
||||
},
|
||||
'msg-2': {
|
||||
id: 'msg-2',
|
||||
message: {
|
||||
id: 'msg-2',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585032.150442,
|
||||
content: { content_type: 'text', parts: ['GPT-4 response'] },
|
||||
metadata: { model_slug: 'gpt-4' },
|
||||
},
|
||||
parent: 'msg-1',
|
||||
children: [],
|
||||
},
|
||||
'msg-3': {
|
||||
id: 'msg-3',
|
||||
message: {
|
||||
id: 'msg-3',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585033.150442,
|
||||
content: { content_type: 'text', parts: ['GPT-4o response'] },
|
||||
metadata: { model_slug: 'gpt-4o' },
|
||||
},
|
||||
parent: 'msg-1',
|
||||
children: [],
|
||||
},
|
||||
'msg-4': {
|
||||
id: 'msg-4',
|
||||
message: {
|
||||
id: 'msg-4',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585034.150442,
|
||||
content: { content_type: 'text', parts: ['GPT-4o-mini response'] },
|
||||
metadata: { model_slug: 'gpt-4o-mini' },
|
||||
},
|
||||
parent: 'msg-1',
|
||||
children: [],
|
||||
},
|
||||
'msg-5': {
|
||||
id: 'msg-5',
|
||||
message: {
|
||||
id: 'msg-5',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585035.150442,
|
||||
content: { content_type: 'text', parts: ['GPT-3.5-turbo response'] },
|
||||
metadata: { model_slug: 'gpt-3.5-turbo' },
|
||||
},
|
||||
parent: 'msg-1',
|
||||
children: [],
|
||||
},
|
||||
'msg-6': {
|
||||
id: 'msg-6',
|
||||
message: {
|
||||
id: 'msg-6',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585036.150442,
|
||||
content: { content_type: 'text', parts: ['GPT-4-turbo response'] },
|
||||
metadata: { model_slug: 'gpt-4-turbo' },
|
||||
},
|
||||
parent: 'msg-1',
|
||||
children: [],
|
||||
},
|
||||
'msg-7': {
|
||||
id: 'msg-7',
|
||||
message: {
|
||||
id: 'msg-7',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585037.150442,
|
||||
content: { content_type: 'text', parts: ['GPT-4-1106-preview response'] },
|
||||
metadata: { model_slug: 'gpt-4-1106-preview' },
|
||||
},
|
||||
parent: 'msg-1',
|
||||
children: [],
|
||||
},
|
||||
'msg-8': {
|
||||
id: 'msg-8',
|
||||
message: {
|
||||
id: 'msg-8',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585038.150442,
|
||||
content: { content_type: 'text', parts: ['Claude response'] },
|
||||
metadata: { model_slug: 'claude-3-opus' },
|
||||
},
|
||||
parent: 'msg-1',
|
||||
children: [],
|
||||
},
|
||||
'msg-9': {
|
||||
id: 'msg-9',
|
||||
message: {
|
||||
id: 'msg-9',
|
||||
author: { role: 'assistant' },
|
||||
create_time: 1714585039.150442,
|
||||
content: { content_type: 'text', parts: ['No model slug response'] },
|
||||
metadata: {},
|
||||
},
|
||||
parent: 'msg-1',
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const requestUserId = 'user-123';
|
||||
const importBatchBuilder = new ImportBatchBuilder(requestUserId);
|
||||
jest.spyOn(importBatchBuilder, 'saveMessage');
|
||||
|
||||
const importer = getImporter(testData);
|
||||
await importer(testData, requestUserId, () => importBatchBuilder);
|
||||
|
||||
const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]);
|
||||
|
||||
// Test various GPT model slug formats
|
||||
const gpt4 = savedMessages.find((msg) => msg.text === 'GPT-4 response');
|
||||
expect(gpt4.sender).toBe('GPT-4');
|
||||
expect(gpt4.model).toBe('gpt-4');
|
||||
|
||||
const gpt4o = savedMessages.find((msg) => msg.text === 'GPT-4o response');
|
||||
expect(gpt4o.sender).toBe('GPT-4o');
|
||||
expect(gpt4o.model).toBe('gpt-4o');
|
||||
|
||||
const gpt4oMini = savedMessages.find((msg) => msg.text === 'GPT-4o-mini response');
|
||||
expect(gpt4oMini.sender).toBe('GPT-4o-mini');
|
||||
expect(gpt4oMini.model).toBe('gpt-4o-mini');
|
||||
|
||||
const gpt35Turbo = savedMessages.find((msg) => msg.text === 'GPT-3.5-turbo response');
|
||||
expect(gpt35Turbo.sender).toBe('GPT-3.5-turbo');
|
||||
expect(gpt35Turbo.model).toBe('gpt-3.5-turbo');
|
||||
|
||||
const gpt4Turbo = savedMessages.find((msg) => msg.text === 'GPT-4-turbo response');
|
||||
expect(gpt4Turbo.sender).toBe('GPT-4-turbo');
|
||||
expect(gpt4Turbo.model).toBe('gpt-4-turbo');
|
||||
|
||||
const gpt4Preview = savedMessages.find((msg) => msg.text === 'GPT-4-1106-preview response');
|
||||
expect(gpt4Preview.sender).toBe('GPT-4-1106-preview');
|
||||
expect(gpt4Preview.model).toBe('gpt-4-1106-preview');
|
||||
|
||||
// Test non-GPT model (should use the model slug as sender)
|
||||
const claude = savedMessages.find((msg) => msg.text === 'Claude response');
|
||||
expect(claude.sender).toBe('claude-3-opus');
|
||||
expect(claude.model).toBe('claude-3-opus');
|
||||
|
||||
// Test missing model slug (should default to openAISettings.model.default)
|
||||
const noModel = savedMessages.find((msg) => msg.text === 'No model slug response');
|
||||
// When no model slug is provided, it defaults to gpt-4o-mini which gets formatted to GPT-4o-mini
|
||||
expect(noModel.sender).toBe('GPT-4o-mini');
|
||||
expect(noModel.model).toBe(openAISettings.model.default);
|
||||
|
||||
// Verify user message is unaffected
|
||||
const userMsg = savedMessages.find((msg) => msg.text === 'Test message');
|
||||
expect(userMsg.sender).toBe('user');
|
||||
expect(userMsg.isCreatedByUser).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importLibreChatConvo', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, getBalanceConfig } = require('@librechat/api');
|
||||
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
|
||||
const { createUser, findUser, updateUser, countUsers } = require('~/models');
|
||||
const { isEmailDomainAllowed } = require('~/server/services/domains');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
const {
|
||||
@@ -122,18 +121,9 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
|
||||
);
|
||||
}
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
if (!isEmailDomainAllowed(mail, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`,
|
||||
);
|
||||
return done(null, false, { message: 'Email domain not allowed' });
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const isFirstRegisteredUser = (await countUsers()) === 0;
|
||||
const role = isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER;
|
||||
|
||||
user = {
|
||||
provider: 'ldap',
|
||||
ldapId,
|
||||
@@ -143,6 +133,7 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
|
||||
name: fullName,
|
||||
role,
|
||||
};
|
||||
const appConfig = await getAppConfig({ role: user?.role });
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const userId = await createUser(user, balanceConfig);
|
||||
user._id = userId;
|
||||
|
||||
@@ -15,7 +15,6 @@ const {
|
||||
getBalanceConfig,
|
||||
} = require('@librechat/api');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { isEmailDomainAllowed } = require('~/server/services/domains');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
@@ -340,19 +339,6 @@ async function setupOpenId() {
|
||||
async (tokenset, done) => {
|
||||
try {
|
||||
const claims = tokenset.claims();
|
||||
const userinfo = {
|
||||
...claims,
|
||||
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
|
||||
};
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
if (!isEmailDomainAllowed(userinfo.email, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${userinfo.email}]`,
|
||||
);
|
||||
return done(null, false, { message: 'Email domain not allowed' });
|
||||
}
|
||||
|
||||
const result = await findOpenIDUser({
|
||||
openidId: claims.sub,
|
||||
email: claims.email,
|
||||
@@ -367,7 +353,10 @@ async function setupOpenId() {
|
||||
message: ErrorTypes.AUTH_FAILED,
|
||||
});
|
||||
}
|
||||
|
||||
const userinfo = {
|
||||
...claims,
|
||||
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
|
||||
};
|
||||
const fullName = getFullName(userinfo);
|
||||
|
||||
if (requiredRole) {
|
||||
@@ -409,6 +398,7 @@ async function setupOpenId() {
|
||||
);
|
||||
}
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
if (!user) {
|
||||
user = {
|
||||
provider: 'openid',
|
||||
|
||||
@@ -7,7 +7,6 @@ const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { hashToken, logger } = require('@librechat/data-schemas');
|
||||
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { isEmailDomainAllowed } = require('~/server/services/domains');
|
||||
const { findUser, createUser, updateUser } = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const paths = require('~/config/paths');
|
||||
@@ -193,25 +192,16 @@ async function setupSaml() {
|
||||
logger.info(`[samlStrategy] SAML authentication received for NameID: ${profile.nameID}`);
|
||||
logger.debug('[samlStrategy] SAML profile:', profile);
|
||||
|
||||
const userEmail = getEmail(profile) || '';
|
||||
const appConfig = await getAppConfig();
|
||||
|
||||
if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
|
||||
);
|
||||
return done(null, false, { message: 'Email domain not allowed' });
|
||||
}
|
||||
|
||||
let user = await findUser({ samlId: profile.nameID });
|
||||
logger.info(
|
||||
`[samlStrategy] User ${user ? 'found' : 'not found'} with SAML ID: ${profile.nameID}`,
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
user = await findUser({ email: userEmail });
|
||||
const email = getEmail(profile) || '';
|
||||
user = await findUser({ email });
|
||||
logger.info(
|
||||
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${userEmail}`,
|
||||
`[samlStrategy] User ${user ? 'found' : 'not found'} with email: ${profile.email}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,12 +220,13 @@ async function setupSaml() {
|
||||
getUserName(profile) || getGivenName(profile) || getEmail(profile),
|
||||
);
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
if (!user) {
|
||||
user = {
|
||||
provider: 'saml',
|
||||
samlId: profile.nameID,
|
||||
username,
|
||||
email: userEmail,
|
||||
email: getEmail(profile) || '',
|
||||
emailVerified: true,
|
||||
name: fullName,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { createSocialUser, handleExistingUser } = require('./process');
|
||||
const { isEmailDomainAllowed } = require('~/server/services/domains');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { findUser } = require('~/models');
|
||||
|
||||
@@ -15,18 +14,8 @@ const socialLogin =
|
||||
});
|
||||
|
||||
const appConfig = await getAppConfig();
|
||||
|
||||
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
||||
logger.error(
|
||||
`[${provider}Login] Authentication blocked - email domain not allowed [Email: ${email}]`,
|
||||
);
|
||||
const error = new Error(ErrorTypes.AUTH_FAILED);
|
||||
error.code = ErrorTypes.AUTH_FAILED;
|
||||
error.message = 'Email domain not allowed';
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
const existingUser = await findUser({ email: email.trim() });
|
||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
||||
|
||||
if (existingUser?.provider === provider) {
|
||||
await handleExistingUser(existingUser, avatarUrl, appConfig);
|
||||
@@ -41,29 +30,20 @@ const socialLogin =
|
||||
return cb(error);
|
||||
}
|
||||
|
||||
const ALLOW_SOCIAL_REGISTRATION = isEnabled(process.env.ALLOW_SOCIAL_REGISTRATION);
|
||||
if (!ALLOW_SOCIAL_REGISTRATION) {
|
||||
logger.error(
|
||||
`[${provider}Login] Registration blocked - social registration is disabled [Email: ${email}]`,
|
||||
);
|
||||
const error = new Error(ErrorTypes.AUTH_FAILED);
|
||||
error.code = ErrorTypes.AUTH_FAILED;
|
||||
error.message = 'Social registration is disabled';
|
||||
return cb(error);
|
||||
if (ALLOW_SOCIAL_REGISTRATION) {
|
||||
const newUser = await createSocialUser({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider,
|
||||
providerKey: `${provider}Id`,
|
||||
providerId: id,
|
||||
username,
|
||||
name,
|
||||
emailVerified,
|
||||
appConfig,
|
||||
});
|
||||
return cb(null, newUser);
|
||||
}
|
||||
|
||||
const newUser = await createSocialUser({
|
||||
email,
|
||||
avatarUrl,
|
||||
provider,
|
||||
providerKey: `${provider}Id`,
|
||||
providerId: id,
|
||||
username,
|
||||
name,
|
||||
emailVerified,
|
||||
appConfig,
|
||||
});
|
||||
return cb(null, newUser);
|
||||
} catch (err) {
|
||||
logger.error(`[${provider}Login]`, err);
|
||||
return cb(err);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/** v0.8.0-rc4 */
|
||||
module.exports = {
|
||||
roots: ['<rootDir>/src'],
|
||||
testEnvironment: 'jsdom',
|
||||
@@ -29,8 +28,7 @@ module.exports = {
|
||||
'jest-file-loader',
|
||||
'^test/(.*)$': '<rootDir>/test/$1',
|
||||
'^~/(.*)$': '<rootDir>/src/$1',
|
||||
'^librechat-data-provider/react-query$':
|
||||
'<rootDir>/../node_modules/librechat-data-provider/src/react-query',
|
||||
'^librechat-data-provider/react-query$': '<rootDir>/../node_modules/librechat-data-provider/src/react-query',
|
||||
},
|
||||
restoreMocks: true,
|
||||
testResultsProcessor: 'jest-junit',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.8.0-rc4",
|
||||
"version": "v0.8.0-rc3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -148,8 +148,8 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6.3.6",
|
||||
"vite-plugin-compression2": "^2.2.1",
|
||||
"vite": "^6.3.4",
|
||||
"vite-plugin-compression2": "^1.3.3",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-pwa": "^0.21.2"
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { createContext, useContext, useEffect, useRef } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider';
|
||||
import type { TAgentsEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
useMCPServerManager,
|
||||
useSearchApiKeyForm,
|
||||
useGetAgentsConfig,
|
||||
useCodeApiKeyForm,
|
||||
useGetMCPTools,
|
||||
useToolToggle,
|
||||
} from '~/hooks';
|
||||
import { getTimestampedValue, setTimestamp } from '~/utils/timestamps';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
interface BadgeRowContextType {
|
||||
conversationId?: string | null;
|
||||
mcpServerNames?: string[] | null;
|
||||
agentsConfig?: TAgentsEndpoint | null;
|
||||
webSearch: ReturnType<typeof useToolToggle>;
|
||||
artifacts: ReturnType<typeof useToolToggle>;
|
||||
@@ -21,7 +21,6 @@ interface BadgeRowContextType {
|
||||
codeInterpreter: ReturnType<typeof useToolToggle>;
|
||||
codeApiKeyForm: ReturnType<typeof useCodeApiKeyForm>;
|
||||
searchApiKeyForm: ReturnType<typeof useSearchApiKeyForm>;
|
||||
mcpServerManager: ReturnType<typeof useMCPServerManager>;
|
||||
}
|
||||
|
||||
const BadgeRowContext = createContext<BadgeRowContextType | undefined>(undefined);
|
||||
@@ -47,6 +46,7 @@ export default function BadgeRowProvider({
|
||||
}: BadgeRowProviderProps) {
|
||||
const lastKeyRef = useRef<string>('');
|
||||
const hasInitializedRef = useRef(false);
|
||||
const { mcpToolDetails } = useGetMCPTools();
|
||||
const { agentsConfig } = useGetAgentsConfig();
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
@@ -62,15 +62,16 @@ export default function BadgeRowProvider({
|
||||
hasInitializedRef.current = true;
|
||||
lastKeyRef.current = key;
|
||||
|
||||
// Load all localStorage values
|
||||
const codeToggleKey = `${LocalStorageKeys.LAST_CODE_TOGGLE_}${key}`;
|
||||
const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${key}`;
|
||||
const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${key}`;
|
||||
const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${key}`;
|
||||
|
||||
const codeToggleValue = getTimestampedValue(codeToggleKey);
|
||||
const webSearchToggleValue = getTimestampedValue(webSearchToggleKey);
|
||||
const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey);
|
||||
const artifactsToggleValue = getTimestampedValue(artifactsToggleKey);
|
||||
const codeToggleValue = localStorage.getItem(codeToggleKey);
|
||||
const webSearchToggleValue = localStorage.getItem(webSearchToggleKey);
|
||||
const fileSearchToggleValue = localStorage.getItem(fileSearchToggleKey);
|
||||
const artifactsToggleValue = localStorage.getItem(artifactsToggleKey);
|
||||
|
||||
const initialValues: Record<string, any> = {};
|
||||
|
||||
@@ -106,37 +107,15 @@ export default function BadgeRowProvider({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Always set values for all tools (use defaults if not in `localStorage`)
|
||||
* If `ephemeralAgent` is `null`, create a new object with just our tool values
|
||||
*/
|
||||
const finalValues = {
|
||||
// Always set values for all tools (use defaults if not in localStorage)
|
||||
// If ephemeralAgent is null, create a new object with just our tool values
|
||||
setEphemeralAgent((prev) => ({
|
||||
...(prev || {}),
|
||||
[Tools.execute_code]: initialValues[Tools.execute_code] ?? false,
|
||||
[Tools.web_search]: initialValues[Tools.web_search] ?? false,
|
||||
[Tools.file_search]: initialValues[Tools.file_search] ?? false,
|
||||
[AgentCapabilities.artifacts]: initialValues[AgentCapabilities.artifacts] ?? false,
|
||||
};
|
||||
|
||||
setEphemeralAgent((prev) => ({
|
||||
...(prev || {}),
|
||||
...finalValues,
|
||||
}));
|
||||
|
||||
Object.entries(finalValues).forEach(([toolKey, value]) => {
|
||||
if (value !== false) {
|
||||
let storageKey = artifactsToggleKey;
|
||||
if (toolKey === Tools.execute_code) {
|
||||
storageKey = codeToggleKey;
|
||||
} else if (toolKey === Tools.web_search) {
|
||||
storageKey = webSearchToggleKey;
|
||||
} else if (toolKey === Tools.file_search) {
|
||||
storageKey = fileSearchToggleKey;
|
||||
}
|
||||
// Store the value and set timestamp for existing values
|
||||
localStorage.setItem(storageKey, JSON.stringify(value));
|
||||
setTimestamp(storageKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [key, isSubmitting, setEphemeralAgent]);
|
||||
|
||||
@@ -186,18 +165,20 @@ export default function BadgeRowProvider({
|
||||
isAuthenticated: true,
|
||||
});
|
||||
|
||||
const mcpServerManager = useMCPServerManager({ conversationId });
|
||||
const mcpServerNames = useMemo(() => {
|
||||
return (mcpToolDetails ?? []).map((tool) => tool.name);
|
||||
}, [mcpToolDetails]);
|
||||
|
||||
const value: BadgeRowContextType = {
|
||||
webSearch,
|
||||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
mcpServerNames,
|
||||
conversationId,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
searchApiKeyForm,
|
||||
mcpServerManager,
|
||||
};
|
||||
|
||||
return <BadgeRowContext.Provider value={value}>{children}</BadgeRowContext.Provider>;
|
||||
|
||||
@@ -642,3 +642,10 @@ declare global {
|
||||
google_tag_manager?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export type UIResource = {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
text: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ interface AgentGridProps {
|
||||
category: string; // Currently selected category
|
||||
searchQuery: string; // Current search query
|
||||
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
||||
scrollElementRef?: React.RefObject<HTMLElement>; // Parent scroll container ref for infinite scroll
|
||||
scrollElement?: HTMLElement | null; // Parent scroll container for infinite scroll
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,7 +23,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
|
||||
category,
|
||||
searchQuery,
|
||||
onSelectAgent,
|
||||
scrollElementRef,
|
||||
scrollElement,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
@@ -87,7 +87,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({
|
||||
// Set up infinite scroll
|
||||
const { setScrollElement } = useInfiniteScroll({
|
||||
hasNextPage,
|
||||
isLoading: isFetching || isFetchingNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage: () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
@@ -99,11 +99,10 @@ const AgentGrid: React.FC<AgentGridProps> = ({
|
||||
|
||||
// Connect the scroll element when it's provided
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollElementRef?.current;
|
||||
if (scrollElement) {
|
||||
setScrollElement(scrollElement);
|
||||
}
|
||||
}, [scrollElementRef, setScrollElement]);
|
||||
}, [scrollElement, setScrollElement]);
|
||||
|
||||
/**
|
||||
* Get category display name from API data or use fallback
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/
|
||||
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
|
||||
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
|
||||
import { SidePanelProvider, useChatContext } from '~/Providers';
|
||||
import { MarketplaceProvider } from './MarketplaceContext';
|
||||
import { SidePanelGroup } from '~/components/SidePanel';
|
||||
import { OpenSidebar } from '~/components/Chat/Menus';
|
||||
import CategoryTabs from './CategoryTabs';
|
||||
@@ -197,21 +198,21 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
*/
|
||||
const handleSearch = (query: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
const currentCategory = displayCategory;
|
||||
|
||||
if (query.trim()) {
|
||||
newParams.set('q', query.trim());
|
||||
// Switch to "all" category when starting a new search
|
||||
navigate(`/agents/all?${newParams.toString()}`);
|
||||
} else {
|
||||
newParams.delete('q');
|
||||
}
|
||||
|
||||
// Always preserve current category when searching or clearing search
|
||||
if (currentCategory === 'promoted') {
|
||||
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
|
||||
} else {
|
||||
navigate(
|
||||
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
|
||||
);
|
||||
// Preserve current category when clearing search
|
||||
const currentCategory = displayCategory;
|
||||
if (currentCategory === 'promoted') {
|
||||
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
|
||||
} else {
|
||||
navigate(
|
||||
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -271,176 +272,100 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
}
|
||||
return (
|
||||
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
|
||||
<SidePanelProvider>
|
||||
<SidePanelGroup
|
||||
defaultLayout={defaultLayout}
|
||||
fullPanelCollapse={fullCollapse}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
>
|
||||
<main className="flex h-full flex-col overflow-hidden" role="main">
|
||||
{/* Scrollable container */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||
{!isSmallScreen && (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||
<div className="mx-1 flex items-center gap-2">
|
||||
{!navVisible ? (
|
||||
<>
|
||||
<OpenSidebar setNavVisible={setNavVisible} />
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_chat')}
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
data-testid="agents-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<NewChatIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Invisible placeholder to maintain height
|
||||
<div className="h-10 w-10" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Hero Section - scrolls away */}
|
||||
{!isSmallScreen && (
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<div className={cn('mb-8 text-center', 'mt-12')}>
|
||||
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
|
||||
{localize('com_agents_marketplace')}
|
||||
</h1>
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
|
||||
{localize('com_agents_marketplace_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Sticky wrapper for search bar and categories */}
|
||||
<MarketplaceProvider>
|
||||
<SidePanelProvider>
|
||||
<SidePanelGroup
|
||||
defaultLayout={defaultLayout}
|
||||
fullPanelCollapse={fullCollapse}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
>
|
||||
<main className="flex h-full flex-col overflow-hidden" role="main">
|
||||
{/* Scrollable container */}
|
||||
<div
|
||||
className={cn(
|
||||
'sticky z-10 bg-presentation pb-4',
|
||||
isSmallScreen ? 'top-0' : 'top-14',
|
||||
)}
|
||||
ref={scrollContainerRef}
|
||||
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<div className="container mx-auto max-w-4xl px-4">
|
||||
{/* Search bar */}
|
||||
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
|
||||
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||||
{/* TODO: Remove this once we have a better way to handle admin settings */}
|
||||
{/* Admin Settings */}
|
||||
<MarketplaceAdminSettings />
|
||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||
{!isSmallScreen && (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||
<div className="mx-1 flex items-center gap-2">
|
||||
{!navVisible ? (
|
||||
<>
|
||||
<OpenSidebar setNavVisible={setNavVisible} />
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_chat')}
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
data-testid="agents-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<NewChatIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Invisible placeholder to maintain height
|
||||
<div className="h-10 w-10" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Hero Section - scrolls away */}
|
||||
{!isSmallScreen && (
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<div className={cn('mb-8 text-center', 'mt-12')}>
|
||||
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
|
||||
{localize('com_agents_marketplace')}
|
||||
</h1>
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
|
||||
{localize('com_agents_marketplace_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Sticky wrapper for search bar and categories */}
|
||||
<div
|
||||
className={cn(
|
||||
'sticky z-10 bg-presentation pb-4',
|
||||
isSmallScreen ? 'top-0' : 'top-14',
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto max-w-4xl px-4">
|
||||
{/* Search bar */}
|
||||
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
|
||||
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||||
{/* TODO: Remove this once we have a better way to handle admin settings */}
|
||||
{/* Admin Settings */}
|
||||
<MarketplaceAdminSettings />
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<CategoryTabs
|
||||
categories={categoriesQuery.data || []}
|
||||
activeTab={displayCategory}
|
||||
isLoading={categoriesQuery.isLoading}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Scrollable content area */}
|
||||
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
||||
{/* Two-pane animated container wrapping category header + grid */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Current content pane */}
|
||||
<div
|
||||
className={cn(
|
||||
isTransitioning &&
|
||||
(animationDirection === 'right'
|
||||
? 'motion-safe:animate-slide-out-left'
|
||||
: 'motion-safe:animate-slide-out-right'),
|
||||
)}
|
||||
key={`pane-current-${displayCategory}`}
|
||||
>
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6 mt-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (displayCategory === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
};
|
||||
}
|
||||
if (displayCategory === 'all') {
|
||||
return {
|
||||
name: localize('com_agents_all'),
|
||||
description: localize('com_agents_all_description'),
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === displayCategory,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label?.startsWith('com_')
|
||||
? localize(categoryData.label as TranslationKeys)
|
||||
: categoryData.label,
|
||||
description: categoryData.description?.startsWith('com_')
|
||||
? localize(categoryData.description as TranslationKeys)
|
||||
: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name:
|
||||
displayCategory.charAt(0).toUpperCase() + displayCategory.slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-text-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
key={`grid-${displayCategory}`}
|
||||
category={displayCategory}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
scrollElementRef={scrollContainerRef}
|
||||
{/* Category tabs */}
|
||||
<CategoryTabs
|
||||
categories={categoriesQuery.data || []}
|
||||
activeTab={displayCategory}
|
||||
isLoading={categoriesQuery.isLoading}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next content pane, only during transition */}
|
||||
{isTransitioning && nextCategory && (
|
||||
</div>
|
||||
{/* Scrollable content area */}
|
||||
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
||||
{/* Two-pane animated container wrapping category header + grid */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Current content pane */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0',
|
||||
animationDirection === 'right'
|
||||
? 'motion-safe:animate-slide-in-right'
|
||||
: 'motion-safe:animate-slide-in-left',
|
||||
isTransitioning &&
|
||||
(animationDirection === 'right'
|
||||
? 'motion-safe:animate-slide-out-left'
|
||||
: 'motion-safe:animate-slide-out-right'),
|
||||
)}
|
||||
key={`pane-next-${nextCategory}-${animationDirection}`}
|
||||
key={`pane-current-${displayCategory}`}
|
||||
>
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
@@ -448,13 +373,13 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (nextCategory === 'promoted') {
|
||||
if (displayCategory === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
};
|
||||
}
|
||||
if (nextCategory === 'all') {
|
||||
if (displayCategory === 'all') {
|
||||
return {
|
||||
name: localize('com_agents_all'),
|
||||
description: localize('com_agents_all_description'),
|
||||
@@ -463,7 +388,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === nextCategory,
|
||||
(cat) => cat.value === displayCategory,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
@@ -471,9 +396,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
? localize(categoryData.label as TranslationKeys)
|
||||
: categoryData.label,
|
||||
description: categoryData.description?.startsWith('com_')
|
||||
? localize(
|
||||
categoryData.description as Parameters<typeof localize>[0],
|
||||
)
|
||||
? localize(categoryData.description as TranslationKeys)
|
||||
: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
@@ -481,8 +404,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name:
|
||||
(nextCategory || '').charAt(0).toUpperCase() +
|
||||
(nextCategory || '').slice(1),
|
||||
displayCategory.charAt(0).toUpperCase() +
|
||||
displayCategory.slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
@@ -503,30 +426,113 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
key={`grid-${nextCategory}`}
|
||||
category={nextCategory}
|
||||
key={`grid-${displayCategory}`}
|
||||
category={displayCategory}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
scrollElementRef={scrollContainerRef}
|
||||
scrollElement={scrollContainerRef.current}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
||||
{/* Next content pane, only during transition */}
|
||||
{isTransitioning && nextCategory && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0',
|
||||
animationDirection === 'right'
|
||||
? 'motion-safe:animate-slide-in-right'
|
||||
: 'motion-safe:animate-slide-in-left',
|
||||
)}
|
||||
key={`pane-next-${nextCategory}-${animationDirection}`}
|
||||
>
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6 mt-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (nextCategory === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
};
|
||||
}
|
||||
if (nextCategory === 'all') {
|
||||
return {
|
||||
name: localize('com_agents_all'),
|
||||
description: localize('com_agents_all_description'),
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === nextCategory,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label?.startsWith('com_')
|
||||
? localize(categoryData.label as TranslationKeys)
|
||||
: categoryData.label,
|
||||
description: categoryData.description?.startsWith('com_')
|
||||
? localize(
|
||||
categoryData.description as Parameters<
|
||||
typeof localize
|
||||
>[0],
|
||||
)
|
||||
: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name:
|
||||
(nextCategory || '').charAt(0).toUpperCase() +
|
||||
(nextCategory || '').slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-text-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
key={`grid-${nextCategory}`}
|
||||
category={nextCategory}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
scrollElement={scrollContainerRef.current}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
||||
</div>
|
||||
</div>
|
||||
{/* Agent detail dialog */}
|
||||
{isDetailOpen && selectedAgent && (
|
||||
<AgentDetail
|
||||
agent={selectedAgent}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={handleDetailClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Agent detail dialog */}
|
||||
{isDetailOpen && selectedAgent && (
|
||||
<AgentDetail
|
||||
agent={selectedAgent}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={handleDetailClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidePanelGroup>
|
||||
</SidePanelProvider>
|
||||
</main>
|
||||
</SidePanelGroup>
|
||||
</SidePanelProvider>
|
||||
</MarketplaceProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import AgentGrid from '../AgentGrid';
|
||||
import type t from 'librechat-data-provider';
|
||||
@@ -82,115 +81,6 @@ import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||
|
||||
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
|
||||
|
||||
// Helper to create mock API response
|
||||
const createMockResponse = (
|
||||
agentIds: string[],
|
||||
hasMore: boolean,
|
||||
afterCursor?: string,
|
||||
): t.AgentListResponse => ({
|
||||
object: 'list',
|
||||
data: agentIds.map(
|
||||
(id) =>
|
||||
({
|
||||
id,
|
||||
name: `Agent ${id}`,
|
||||
description: `Description for ${id}`,
|
||||
created_at: Date.now(),
|
||||
model: 'gpt-4',
|
||||
tools: [],
|
||||
instructions: '',
|
||||
avatar: null,
|
||||
provider: 'openai',
|
||||
model_parameters: {
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
maxContextTokens: 2000,
|
||||
max_context_tokens: 2000,
|
||||
max_output_tokens: 2000,
|
||||
},
|
||||
}) as t.Agent,
|
||||
),
|
||||
first_id: agentIds[0] || '',
|
||||
last_id: agentIds[agentIds.length - 1] || '',
|
||||
has_more: hasMore,
|
||||
after: afterCursor,
|
||||
});
|
||||
|
||||
// Helper to setup mock viewport
|
||||
const setupViewport = (scrollHeight: number, clientHeight: number) => {
|
||||
const listeners: { [key: string]: EventListener[] } = {};
|
||||
return {
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
scrollTop: 0,
|
||||
addEventListener: jest.fn((event: string, listener: EventListener) => {
|
||||
if (!listeners[event]) {
|
||||
listeners[event] = [];
|
||||
}
|
||||
listeners[event].push(listener);
|
||||
}),
|
||||
removeEventListener: jest.fn((event: string, listener: EventListener) => {
|
||||
if (listeners[event]) {
|
||||
listeners[event] = listeners[event].filter((l) => l !== listener);
|
||||
}
|
||||
}),
|
||||
dispatchEvent: jest.fn((event: Event) => {
|
||||
const eventListeners = listeners[event.type];
|
||||
if (eventListeners) {
|
||||
eventListeners.forEach((listener) => listener(event));
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
} as unknown as HTMLElement;
|
||||
};
|
||||
|
||||
// Helper to create mock infinite query return value
|
||||
const createMockInfiniteQuery = (
|
||||
pages: t.AgentListResponse[],
|
||||
options?: {
|
||||
isLoading?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage?: jest.Mock;
|
||||
isFetchingNextPage?: boolean;
|
||||
},
|
||||
) =>
|
||||
({
|
||||
data: {
|
||||
pages,
|
||||
pageParams: pages.map((_, i) => (i === 0 ? undefined : `cursor-${i * 6}`)),
|
||||
},
|
||||
isLoading: options?.isLoading ?? false,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
hasNextPage: options?.hasNextPage ?? pages[pages.length - 1]?.has_more ?? false,
|
||||
isFetchingNextPage: options?.isFetchingNextPage ?? false,
|
||||
fetchNextPage: options?.fetchNextPage ?? jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
// Add missing required properties for UseInfiniteQueryResult
|
||||
isError: false,
|
||||
isLoadingError: false,
|
||||
isRefetchError: false,
|
||||
isSuccess: true,
|
||||
status: 'success' as const,
|
||||
dataUpdatedAt: Date.now(),
|
||||
errorUpdateCount: 0,
|
||||
errorUpdatedAt: 0,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
fetchStatus: 'idle' as const,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isInitialLoading: false,
|
||||
isPaused: false,
|
||||
isPlaceholderData: false,
|
||||
isPending: false,
|
||||
isRefetching: false,
|
||||
isStale: false,
|
||||
remove: jest.fn(),
|
||||
}) as any;
|
||||
|
||||
describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||
const mockOnSelectAgent = jest.fn();
|
||||
|
||||
@@ -453,15 +343,6 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||
});
|
||||
|
||||
describe('Infinite Scroll Functionality', () => {
|
||||
beforeEach(() => {
|
||||
// Silence console.log in tests
|
||||
jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should show loading indicator when fetching next page', () => {
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||
...defaultMockQueryResult,
|
||||
@@ -515,358 +396,5 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||
|
||||
expect(screen.queryByText("You've reached the end of the results")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Auto-fetch to fill viewport', () => {
|
||||
it('should NOT auto-fetch when viewport is filled (5 agents, has_more=false)', async () => {
|
||||
const mockResponse = createMockResponse(['1', '2', '3', '4', '5'], false);
|
||||
const fetchNextPage = jest.fn();
|
||||
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue(
|
||||
createMockInfiniteQuery([mockResponse], { fetchNextPage }),
|
||||
);
|
||||
|
||||
const scrollElement = setupViewport(500, 1000); // Content smaller than viewport
|
||||
const scrollElementRef = { current: scrollElement };
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="all"
|
||||
searchQuery=""
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
scrollElementRef={scrollElementRef}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Wait for initial render
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('gridcell')).toHaveLength(5);
|
||||
});
|
||||
|
||||
// Wait to ensure no auto-fetch happens
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
});
|
||||
|
||||
// fetchNextPage should NOT be called since has_more is false
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should auto-fetch when viewport not filled (7 agents, big viewport)', async () => {
|
||||
const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6');
|
||||
const secondPage = createMockResponse(['7'], false);
|
||||
let currentPages = [firstPage];
|
||||
const fetchNextPage = jest.fn();
|
||||
|
||||
// Mock that updates pages when fetchNextPage is called
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() =>
|
||||
createMockInfiniteQuery(currentPages, {
|
||||
fetchNextPage: jest.fn().mockImplementation(() => {
|
||||
fetchNextPage();
|
||||
currentPages = [firstPage, secondPage];
|
||||
return Promise.resolve();
|
||||
}),
|
||||
hasNextPage: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const scrollElement = setupViewport(400, 1200); // Large viewport (content < viewport)
|
||||
const scrollElementRef = { current: scrollElement };
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="all"
|
||||
searchQuery=""
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
scrollElementRef={scrollElementRef}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Wait for initial 6 agents
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
|
||||
});
|
||||
|
||||
// Wait for ResizeObserver and auto-fetch to trigger
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
// Auto-fetch should have been triggered (multiple times due to reliability checks)
|
||||
expect(fetchNextPage).toHaveBeenCalled();
|
||||
expect(fetchNextPage.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Update mock data and re-render
|
||||
currentPages = [firstPage, secondPage];
|
||||
rerender(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="all"
|
||||
searchQuery=""
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
scrollElementRef={scrollElementRef}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Should now show all 7 agents
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('gridcell')).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT auto-fetch when viewport is filled (7 agents, small viewport)', async () => {
|
||||
const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6');
|
||||
const fetchNextPage = jest.fn();
|
||||
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue(
|
||||
createMockInfiniteQuery([firstPage], { fetchNextPage, hasNextPage: true }),
|
||||
);
|
||||
|
||||
const scrollElement = setupViewport(1200, 600); // Small viewport, content fills it
|
||||
const scrollElementRef = { current: scrollElement };
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="all"
|
||||
searchQuery=""
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
scrollElementRef={scrollElementRef}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Wait for initial 6 agents
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
|
||||
});
|
||||
|
||||
// Wait to ensure no auto-fetch happens
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
});
|
||||
|
||||
// Should NOT auto-fetch since viewport is filled
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should auto-fetch once to fill viewport then stop (20 agents)', async () => {
|
||||
const allPages = [
|
||||
createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6'),
|
||||
createMockResponse(['7', '8', '9', '10', '11', '12'], true, 'cursor-12'),
|
||||
createMockResponse(['13', '14', '15', '16', '17', '18'], true, 'cursor-18'),
|
||||
createMockResponse(['19', '20'], false),
|
||||
];
|
||||
|
||||
let currentPages = [allPages[0]];
|
||||
let fetchCount = 0;
|
||||
const fetchNextPage = jest.fn();
|
||||
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() =>
|
||||
createMockInfiniteQuery(currentPages, {
|
||||
fetchNextPage: jest.fn().mockImplementation(() => {
|
||||
fetchCount++;
|
||||
fetchNextPage();
|
||||
if (currentPages.length < 2) {
|
||||
currentPages = allPages.slice(0, 2);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
hasNextPage: currentPages.length < 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const scrollElement = setupViewport(600, 1000); // Viewport fits ~12 agents
|
||||
const scrollElementRef = { current: scrollElement };
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="all"
|
||||
searchQuery=""
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
scrollElementRef={scrollElementRef}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Wait for initial 6 agents
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
|
||||
});
|
||||
|
||||
// Should auto-fetch to fill viewport
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
// Simulate viewport being filled after 12 agents
|
||||
Object.defineProperty(scrollElement, 'scrollHeight', {
|
||||
value: 1200,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
currentPages = allPages.slice(0, 2);
|
||||
rerender(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="all"
|
||||
searchQuery=""
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
scrollElementRef={scrollElementRef}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Should show 12 agents
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('gridcell')).toHaveLength(12);
|
||||
});
|
||||
|
||||
// Wait to ensure no additional auto-fetch
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
});
|
||||
|
||||
// Should only have fetched once (to fill viewport)
|
||||
expect(fetchCount).toBe(1);
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should auto-fetch when viewport resizes to be taller (window resize)', async () => {
|
||||
const firstPage = createMockResponse(['1', '2', '3', '4', '5', '6'], true, 'cursor-6');
|
||||
const secondPage = createMockResponse(['7', '8', '9', '10', '11', '12'], true, 'cursor-12');
|
||||
let currentPages = [firstPage];
|
||||
const fetchNextPage = jest.fn();
|
||||
let resizeObserverCallback: ResizeObserverCallback | null = null;
|
||||
|
||||
// Mock that updates pages when fetchNextPage is called
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockImplementation(() =>
|
||||
createMockInfiniteQuery(currentPages, {
|
||||
fetchNextPage: jest.fn().mockImplementation(() => {
|
||||
fetchNextPage();
|
||||
if (currentPages.length === 1) {
|
||||
currentPages = [firstPage, secondPage];
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
hasNextPage: currentPages.length === 1,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock ResizeObserver to capture the callback
|
||||
const ResizeObserverMock = jest.fn().mockImplementation((callback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
};
|
||||
});
|
||||
global.ResizeObserver = ResizeObserverMock as any;
|
||||
|
||||
// Start with a small viewport that fits the content
|
||||
const scrollElement = setupViewport(800, 600);
|
||||
const scrollElementRef = { current: scrollElement };
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
const { rerender } = render(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="all"
|
||||
searchQuery=""
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
scrollElementRef={scrollElementRef}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Wait for initial 6 agents
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('gridcell')).toHaveLength(6);
|
||||
});
|
||||
|
||||
// Verify ResizeObserver was set up
|
||||
expect(ResizeObserverMock).toHaveBeenCalled();
|
||||
expect(resizeObserverCallback).not.toBeNull();
|
||||
|
||||
// Initially no fetch should happen as viewport is filled
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate window resize - make viewport taller
|
||||
Object.defineProperty(scrollElement, 'clientHeight', {
|
||||
value: 1200, // Now taller than content
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Trigger ResizeObserver callback to simulate resize detection
|
||||
act(() => {
|
||||
if (resizeObserverCallback) {
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: scrollElement,
|
||||
contentRect: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 800,
|
||||
height: 1200,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 1200,
|
||||
left: 0,
|
||||
} as DOMRectReadOnly,
|
||||
borderBoxSize: [],
|
||||
contentBoxSize: [],
|
||||
devicePixelContentBoxSize: [],
|
||||
} as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Should trigger auto-fetch due to viewport now being larger than content
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
// Update the component with new data
|
||||
rerender(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="all"
|
||||
searchQuery=""
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
scrollElementRef={scrollElementRef}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Should now show 12 agents after fetching
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('gridcell')).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('Virtual Scrolling Performance', () => {
|
||||
|
||||
// Performance check: rendering should be fast
|
||||
const renderTime = endTime - startTime;
|
||||
expect(renderTime).toBeLessThan(720);
|
||||
expect(renderTime).toBeLessThan(650);
|
||||
|
||||
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
|
||||
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);
|
||||
|
||||
@@ -368,7 +368,7 @@ function BadgeRow({
|
||||
<CodeInterpreter />
|
||||
<FileSearch />
|
||||
<Artifacts />
|
||||
<MCPSelect />
|
||||
<MCPSelect conversationId={conversationId} />
|
||||
</>
|
||||
)}
|
||||
{ghostBadge && (
|
||||
|
||||
@@ -39,7 +39,6 @@ function AttachFileChat({
|
||||
<AttachFileMenu
|
||||
disabled={disableInputs}
|
||||
conversationId={conversationId}
|
||||
agentId={conversation?.agent_id}
|
||||
endpointFileConfig={endpointFileConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,13 +11,7 @@ import {
|
||||
SharePointIcon,
|
||||
} from '@librechat/client';
|
||||
import type { EndpointFileConfig } from 'librechat-data-provider';
|
||||
import {
|
||||
useAgentToolPermissions,
|
||||
useAgentCapabilities,
|
||||
useGetAgentsConfig,
|
||||
useFileHandling,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import { useLocalize, useGetAgentsConfig, useFileHandling, useAgentCapabilities } from '~/hooks';
|
||||
import useSharePointFileHandling from '~/hooks/Files/useSharePointFileHandling';
|
||||
import { SharePointPickerDialog } from '~/components/SharePoint';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
@@ -27,17 +21,11 @@ import { cn } from '~/utils';
|
||||
|
||||
interface AttachFileMenuProps {
|
||||
conversationId: string;
|
||||
agentId?: string | null;
|
||||
disabled?: boolean | null;
|
||||
endpointFileConfig?: EndpointFileConfig;
|
||||
}
|
||||
|
||||
const AttachFileMenu = ({
|
||||
agentId,
|
||||
disabled,
|
||||
conversationId,
|
||||
endpointFileConfig,
|
||||
}: AttachFileMenuProps) => {
|
||||
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
|
||||
const localize = useLocalize();
|
||||
const isUploadDisabled = disabled ?? false;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -64,8 +52,6 @@ const AttachFileMenu = ({
|
||||
* */
|
||||
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
|
||||
|
||||
const { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(agentId);
|
||||
|
||||
const handleUploadClick = (isImage?: boolean) => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
@@ -100,7 +86,7 @@ const AttachFileMenu = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) {
|
||||
if (capabilities.fileSearchEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_file_search'),
|
||||
onClick: () => {
|
||||
@@ -115,7 +101,7 @@ const AttachFileMenu = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities.codeEnabled && codeAllowedByAgent) {
|
||||
if (capabilities.codeEnabled) {
|
||||
items.push({
|
||||
label: localize('com_ui_upload_code_files'),
|
||||
onClick: () => {
|
||||
@@ -156,8 +142,6 @@ const AttachFileMenu = ({
|
||||
setToolResource,
|
||||
setEphemeralAgent,
|
||||
sharePointEnabled,
|
||||
codeAllowedByAgent,
|
||||
fileSearchAllowedByAgent,
|
||||
setIsSharePointDialogOpen,
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,13 +2,7 @@ import React, { useMemo } from 'react';
|
||||
import { OGDialog, OGDialogTemplate } from '@librechat/client';
|
||||
import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import {
|
||||
useAgentToolPermissions,
|
||||
useAgentCapabilities,
|
||||
useGetAgentsConfig,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { useLocalize, useGetAgentsConfig, useAgentCapabilities } from '~/hooks';
|
||||
|
||||
interface DragDropModalProps {
|
||||
onOptionSelect: (option: EToolResources | undefined) => void;
|
||||
@@ -32,11 +26,6 @@ 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 { fileSearchAllowedByAgent, codeAllowedByAgent } = useAgentToolPermissions(
|
||||
conversation?.agent_id,
|
||||
);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const _options: FileOption[] = [
|
||||
{
|
||||
@@ -46,14 +35,14 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
||||
condition: files.every((file) => file.type?.startsWith('image/')),
|
||||
},
|
||||
];
|
||||
if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) {
|
||||
if (capabilities.fileSearchEnabled) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_file_search'),
|
||||
value: EToolResources.file_search,
|
||||
icon: <FileSearch className="icon-md" />,
|
||||
});
|
||||
}
|
||||
if (capabilities.codeEnabled && codeAllowedByAgent) {
|
||||
if (capabilities.codeEnabled) {
|
||||
_options.push({
|
||||
label: localize('com_ui_upload_code_files'),
|
||||
value: EToolResources.execute_code,
|
||||
@@ -69,7 +58,7 @@ const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragD
|
||||
}
|
||||
|
||||
return _options;
|
||||
}, [capabilities, files, localize, fileSearchAllowedByAgent, codeAllowedByAgent]);
|
||||
}, [capabilities, files, localize]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
|
||||
@@ -1,102 +1,62 @@
|
||||
import { memo } from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface DragDropOverlayProps {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const DragDropOverlay = memo(({ isActive }: DragDropOverlayProps) => {
|
||||
const localize = useLocalize();
|
||||
export default function DragDropOverlay() {
|
||||
return (
|
||||
<>
|
||||
{/** Modal backdrop overlay */}
|
||||
<div
|
||||
className={`fixed inset-0 z-[9998] transition-opacity duration-200 ease-in-out ${
|
||||
isActive
|
||||
? 'pointer-events-auto visible opacity-100'
|
||||
: 'pointer-events-none invisible opacity-0'
|
||||
} `}
|
||||
style={{
|
||||
/** Semi-transparent black overlay that works in both themes */
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
willChange: 'opacity',
|
||||
}}
|
||||
/>
|
||||
{/** Main content overlay */}
|
||||
<div
|
||||
className={`fixed inset-0 z-[9999] flex flex-col items-center justify-center gap-2 text-text-primary transition-all duration-200 ease-in-out ${
|
||||
isActive
|
||||
? 'pointer-events-auto visible opacity-100'
|
||||
: 'pointer-events-none invisible opacity-0'
|
||||
} `}
|
||||
style={{
|
||||
transform: isActive ? 'scale(1)' : 'scale(0.95)',
|
||||
/** Use will-change to hint browser about upcoming changes */
|
||||
willChange: 'opacity, transform',
|
||||
}}
|
||||
<div
|
||||
className="bg-surface-primary/85 fixed inset-0 z-[9999] flex flex-col items-center justify-center
|
||||
gap-2 text-text-primary
|
||||
backdrop-blur-[4px] transition-all duration-200
|
||||
ease-in-out animate-in fade-in
|
||||
zoom-in-95 hover:backdrop-blur-sm"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 132 108"
|
||||
fill="none"
|
||||
width="132"
|
||||
height="108"
|
||||
>
|
||||
{/** Content area with subtle background */}
|
||||
<div className="bg-surface-primary/95 flex flex-col items-center rounded-lg p-8 shadow-xl">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 132 108"
|
||||
fill="none"
|
||||
width="132"
|
||||
height="108"
|
||||
style={{
|
||||
transform: isActive ? 'translateY(0)' : 'translateY(-10px)',
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<g clipPath="url(#clip0_3605_64419)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M25.2025 29.3514C10.778 33.2165 8.51524 37.1357 11.8281 49.4995L13.4846 55.6814C16.7975 68.0453 20.7166 70.308 35.1411 66.443L43.3837 64.2344C57.8082 60.3694 60.0709 56.4502 56.758 44.0864L55.1016 37.9044C51.7887 25.5406 47.8695 23.2778 33.445 27.1428L29.3237 28.2471L25.2025 29.3514ZM18.1944 42.7244C18.8572 41.5764 20.325 41.1831 21.4729 41.8459L27.3517 45.24C28.4996 45.9027 28.8929 47.3706 28.2301 48.5185L24.836 54.3972C24.1733 55.5451 22.7054 55.9384 21.5575 55.2757C20.4096 54.613 20.0163 53.1451 20.6791 51.9972L22.8732 48.1969L19.0729 46.0028C17.925 45.3401 17.5317 43.8723 18.1944 42.7244ZM29.4091 56.3843C29.066 55.104 29.8258 53.7879 31.1062 53.4449L40.3791 50.9602C41.6594 50.6172 42.9754 51.377 43.3184 52.6573C43.6615 53.9376 42.9017 55.2536 41.6214 55.5967L32.3485 58.0813C31.0682 58.4244 29.7522 57.6646 29.4091 56.3843Z"
|
||||
fill="#AFC1FF"
|
||||
/>
|
||||
</g>
|
||||
<g clipPath="url(#clip1_3605_64419)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M86.8124 13.4036C81.0973 11.8722 78.5673 13.2649 77.0144 19.0603L68.7322 49.97C67.1793 55.7656 68.5935 58.2151 74.4696 59.7895L97.4908 65.958C103.367 67.5326 105.816 66.1184 107.406 60.1848L115.393 30.379C115.536 29.8456 115.217 29.2959 114.681 29.16C113.478 28.8544 112.435 28.6195 111.542 28.4183C106.243 27.2253 106.22 27.2201 109.449 20.7159C109.73 20.1507 109.426 19.4638 108.816 19.3004L86.8124 13.4036ZM87.2582 28.4311C86.234 28.1567 85.1812 28.7645 84.9067 29.7888C84.6323 30.813 85.2401 31.8658 86.2644 32.1403L101.101 36.1158C102.125 36.3902 103.178 35.7824 103.453 34.7581C103.727 33.7339 103.119 32.681 102.095 32.4066L87.2582 28.4311ZM82.9189 37.2074C83.1934 36.1831 84.2462 35.5753 85.2704 35.8497L100.107 39.8252C101.131 40.0996 101.739 41.1524 101.465 42.1767C101.19 43.201 100.137 43.8088 99.1132 43.5343L84.2766 39.5589C83.2523 39.2844 82.6445 38.2316 82.9189 37.2074ZM83.2826 43.2683C82.2584 42.9939 81.2056 43.6017 80.9311 44.626C80.6567 45.6502 81.2645 46.703 82.2888 46.9775L89.7071 48.9652C90.7313 49.2396 91.7841 48.6318 92.0586 47.6076C92.333 46.5833 91.7252 45.5305 90.7009 45.256L83.2826 43.2683Z"
|
||||
fill="#7989FF"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M40.4004 71.8426C40.4004 57.2141 44.0575 53.5569 61.1242 53.5569H66.0004H70.8766C87.9432 53.5569 91.6004 57.2141 91.6004 71.8426V79.1569C91.6004 93.7855 87.9432 97.4426 70.8766 97.4426H61.1242C44.0575 97.4426 40.4004 93.7855 40.4004 79.1569V71.8426ZM78.8002 67.4995C78.8002 70.1504 76.6512 72.2995 74.0002 72.2995C71.3492 72.2995 69.2002 70.1504 69.2002 67.4995C69.2002 64.8485 71.3492 62.6995 74.0002 62.6995C76.6512 62.6995 78.8002 64.8485 78.8002 67.4995ZM60.7204 70.8597C60.2672 70.2553 59.5559 69.8997 58.8004 69.8997C58.045 69.8997 57.3337 70.2553 56.8804 70.8597L47.2804 83.6597C46.4851 84.72 46.7 86.2244 47.7604 87.0197C48.8208 87.8149 50.3251 87.6 51.1204 86.5397L58.8004 76.2997L66.4804 86.5397C66.8979 87.0962 67.5363 87.4443 68.2303 87.4936C68.9243 87.5429 69.6055 87.2887 70.0975 86.7967L74.8004 82.0938L79.5034 86.7967C80.4406 87.734 81.9602 87.734 82.8975 86.7967C83.8347 85.8595 83.8347 84.3399 82.8975 83.4026L76.4975 77.0026C75.5602 76.0653 74.0406 76.0653 73.1034 77.0026L68.6601 81.4459L60.7204 70.8597Z"
|
||||
fill="#3C46FF"
|
||||
<g clipPath="url(#clip0_3605_64419)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M25.2025 29.3514C10.778 33.2165 8.51524 37.1357 11.8281 49.4995L13.4846 55.6814C16.7975 68.0453 20.7166 70.308 35.1411 66.443L43.3837 64.2344C57.8082 60.3694 60.0709 56.4502 56.758 44.0864L55.1016 37.9044C51.7887 25.5406 47.8695 23.2778 33.445 27.1428L29.3237 28.2471L25.2025 29.3514ZM18.1944 42.7244C18.8572 41.5764 20.325 41.1831 21.4729 41.8459L27.3517 45.24C28.4996 45.9027 28.8929 47.3706 28.2301 48.5185L24.836 54.3972C24.1733 55.5451 22.7054 55.9384 21.5575 55.2757C20.4096 54.613 20.0163 53.1451 20.6791 51.9972L22.8732 48.1969L19.0729 46.0028C17.925 45.3401 17.5317 43.8723 18.1944 42.7244ZM29.4091 56.3843C29.066 55.104 29.8258 53.7879 31.1062 53.4449L40.3791 50.9602C41.6594 50.6172 42.9754 51.377 43.3184 52.6573C43.6615 53.9376 42.9017 55.2536 41.6214 55.5967L32.3485 58.0813C31.0682 58.4244 29.7522 57.6646 29.4091 56.3843Z"
|
||||
fill="#AFC1FF"
|
||||
/>
|
||||
</g>
|
||||
<g clipPath="url(#clip1_3605_64419)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M86.8124 13.4036C81.0973 11.8722 78.5673 13.2649 77.0144 19.0603L68.7322 49.97C67.1793 55.7656 68.5935 58.2151 74.4696 59.7895L97.4908 65.958C103.367 67.5326 105.816 66.1184 107.406 60.1848L115.393 30.379C115.536 29.8456 115.217 29.2959 114.681 29.16C113.478 28.8544 112.435 28.6195 111.542 28.4183C106.243 27.2253 106.22 27.2201 109.449 20.7159C109.73 20.1507 109.426 19.4638 108.816 19.3004L86.8124 13.4036ZM87.2582 28.4311C86.234 28.1567 85.1812 28.7645 84.9067 29.7888C84.6323 30.813 85.2401 31.8658 86.2644 32.1403L101.101 36.1158C102.125 36.3902 103.178 35.7824 103.453 34.7581C103.727 33.7339 103.119 32.681 102.095 32.4066L87.2582 28.4311ZM82.9189 37.2074C83.1934 36.1831 84.2462 35.5753 85.2704 35.8497L100.107 39.8252C101.131 40.0996 101.739 41.1524 101.465 42.1767C101.19 43.201 100.137 43.8088 99.1132 43.5343L84.2766 39.5589C83.2523 39.2844 82.6445 38.2316 82.9189 37.2074ZM83.2826 43.2683C82.2584 42.9939 81.2056 43.6017 80.9311 44.626C80.6567 45.6502 81.2645 46.703 82.2888 46.9775L89.7071 48.9652C90.7313 49.2396 91.7841 48.6318 92.0586 47.6076C92.333 46.5833 91.7252 45.5305 90.7009 45.256L83.2826 43.2683Z"
|
||||
fill="#7989FF"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M40.4004 71.8426C40.4004 57.2141 44.0575 53.5569 61.1242 53.5569H66.0004H70.8766C87.9432 53.5569 91.6004 57.2141 91.6004 71.8426V79.1569C91.6004 93.7855 87.9432 97.4426 70.8766 97.4426H61.1242C44.0575 97.4426 40.4004 93.7855 40.4004 79.1569V71.8426ZM78.8002 67.4995C78.8002 70.1504 76.6512 72.2995 74.0002 72.2995C71.3492 72.2995 69.2002 70.1504 69.2002 67.4995C69.2002 64.8485 71.3492 62.6995 74.0002 62.6995C76.6512 62.6995 78.8002 64.8485 78.8002 67.4995ZM60.7204 70.8597C60.2672 70.2553 59.5559 69.8997 58.8004 69.8997C58.045 69.8997 57.3337 70.2553 56.8804 70.8597L47.2804 83.6597C46.4851 84.72 46.7 86.2244 47.7604 87.0197C48.8208 87.8149 50.3251 87.6 51.1204 86.5397L58.8004 76.2997L66.4804 86.5397C66.8979 87.0962 67.5363 87.4443 68.2303 87.4936C68.9243 87.5429 69.6055 87.2887 70.0975 86.7967L74.8004 82.0938L79.5034 86.7967C80.4406 87.734 81.9602 87.734 82.8975 86.7967C83.8347 85.8595 83.8347 84.3399 82.8975 83.4026L76.4975 77.0026C75.5602 76.0653 74.0406 76.0653 73.1034 77.0026L68.6601 81.4459L60.7204 70.8597Z"
|
||||
fill="#3C46FF"
|
||||
/>
|
||||
<defs>
|
||||
<clipPath id="clip0_3605_64419">
|
||||
<rect
|
||||
width="56"
|
||||
height="56"
|
||||
fill="white"
|
||||
transform="translate(0 26.9939) rotate(-15)"
|
||||
/>
|
||||
<defs>
|
||||
<clipPath id="clip0_3605_64419">
|
||||
<rect
|
||||
width="56"
|
||||
height="56"
|
||||
fill="white"
|
||||
transform="translate(0 26.9939) rotate(-15)"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_3605_64419">
|
||||
<rect
|
||||
width="64"
|
||||
height="64"
|
||||
fill="white"
|
||||
transform="translate(69.5645 0.5) rotate(15)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h3 className="mt-4 text-lg font-semibold">{localize('com_ui_upload_files')}</h3>
|
||||
<h4 className="text-sm text-text-secondary">{localize('com_ui_drag_drop')}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_3605_64419">
|
||||
<rect
|
||||
width="64"
|
||||
height="64"
|
||||
fill="white"
|
||||
transform="translate(69.5645 0.5) rotate(15)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h3>Add anything</h3>
|
||||
<h4>Drop any file here to add it to the conversation</h4>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DragDropOverlay.displayName = 'DragDropOverlay';
|
||||
|
||||
export default DragDropOverlay;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ export default function DragDropWrapper({ children, className }: DragDropWrapper
|
||||
return (
|
||||
<div ref={drop} className={cn('relative flex h-full w-full', className)}>
|
||||
{children}
|
||||
{/** Always render overlay to avoid mount/unmount overhead */}
|
||||
<DragDropOverlay isActive={isActive} />
|
||||
{isActive && <DragDropOverlay />}
|
||||
<DragDropModal
|
||||
files={draggedFiles}
|
||||
isVisible={showModal}
|
||||
|
||||
@@ -3,19 +3,22 @@ import { MultiSelect, MCPIcon } from '@librechat/client';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { useMCPServerManager } from '~/hooks';
|
||||
|
||||
function MCPSelectContent() {
|
||||
const { conversationId, mcpServerManager } = useBadgeRowContext();
|
||||
type MCPSelectProps = { conversationId?: string | null };
|
||||
|
||||
function MCPSelectContent({ conversationId }: MCPSelectProps) {
|
||||
const {
|
||||
localize,
|
||||
mcpValues,
|
||||
isInitializing,
|
||||
placeholderText,
|
||||
configuredServers,
|
||||
mcpValues,
|
||||
isPinned,
|
||||
placeholderText,
|
||||
batchToggleServers,
|
||||
getConfigDialogProps,
|
||||
getServerStatusIconProps,
|
||||
} = mcpServerManager;
|
||||
getConfigDialogProps,
|
||||
isInitializing,
|
||||
localize,
|
||||
} = useMCPServerManager({ conversationId });
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(values: string[], placeholder?: string) => {
|
||||
@@ -68,6 +71,14 @@ function MCPSelectContent() {
|
||||
[getServerStatusIconProps, isInitializing],
|
||||
);
|
||||
|
||||
if ((!mcpValues || mcpValues.length === 0) && !isPinned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!configuredServers || configuredServers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configDialogProps = getConfigDialogProps();
|
||||
|
||||
return (
|
||||
@@ -92,15 +103,10 @@ function MCPSelectContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function MCPSelect() {
|
||||
const { mcpServerManager } = useBadgeRowContext();
|
||||
const { configuredServers } = mcpServerManager;
|
||||
|
||||
if (!configuredServers || configuredServers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MCPSelectContent />;
|
||||
function MCPSelect(props: MCPSelectProps) {
|
||||
const { mcpServerNames } = useBadgeRowContext();
|
||||
if ((mcpServerNames?.length ?? 0) === 0) return null;
|
||||
return <MCPSelectContent {...props} />;
|
||||
}
|
||||
|
||||
export default memo(MCPSelect);
|
||||
|
||||
@@ -4,27 +4,27 @@ import { ChevronRight } from 'lucide-react';
|
||||
import { PinIcon, MCPIcon } from '@librechat/client';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { useMCPServerManager } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MCPSubMenuProps {
|
||||
placeholder?: string;
|
||||
conversationId?: string | null;
|
||||
}
|
||||
|
||||
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||
({ placeholder, ...props }, ref) => {
|
||||
const { mcpServerManager } = useBadgeRowContext();
|
||||
({ placeholder, conversationId, ...props }, ref) => {
|
||||
const {
|
||||
isPinned,
|
||||
mcpValues,
|
||||
setIsPinned,
|
||||
isInitializing,
|
||||
placeholderText,
|
||||
configuredServers,
|
||||
getConfigDialogProps,
|
||||
mcpValues,
|
||||
isPinned,
|
||||
setIsPinned,
|
||||
placeholderText,
|
||||
toggleServerSelection,
|
||||
getServerStatusIconProps,
|
||||
} = mcpServerManager;
|
||||
getConfigDialogProps,
|
||||
isInitializing,
|
||||
} = useMCPServerManager({ conversationId });
|
||||
|
||||
const menuStore = Ariakit.useMenuStore({
|
||||
focusLoop: true,
|
||||
|
||||
@@ -30,7 +30,8 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
artifacts,
|
||||
fileSearch,
|
||||
agentsConfig,
|
||||
mcpServerManager,
|
||||
mcpServerNames,
|
||||
conversationId,
|
||||
codeApiKeyForm,
|
||||
codeInterpreter,
|
||||
searchApiKeyForm,
|
||||
@@ -286,18 +287,15 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { configuredServers } = mcpServerManager;
|
||||
if (configuredServers && configuredServers.length > 0) {
|
||||
if (mcpServerNames && mcpServerNames.length > 0) {
|
||||
dropdownItems.push({
|
||||
hideOnClick: false,
|
||||
render: (props) => <MCPSubMenu {...props} placeholder={mcpPlaceholder} />,
|
||||
render: (props) => (
|
||||
<MCPSubMenu {...props} placeholder={mcpPlaceholder} conversationId={conversationId} />
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (dropdownItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const menuTrigger = (
|
||||
<TooltipAnchor
|
||||
render={
|
||||
|
||||
@@ -211,7 +211,6 @@ export default function ToolCall({
|
||||
domain={authDomain || (domain ?? '')}
|
||||
function_name={function_name}
|
||||
pendingAuth={authDomain.length > 0 && !cancelled && progress < 1}
|
||||
attachments={attachments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import UIResourceCarousel from './UIResourceCarousel';
|
||||
import type { TAttachment, UIResource } from 'librechat-data-provider';
|
||||
import type { UIResource } from '~/common';
|
||||
|
||||
function OptimizedCodeBlock({ text, maxHeight = 320 }: { text: string; maxHeight?: number }) {
|
||||
return (
|
||||
@@ -28,14 +27,12 @@ export default function ToolCallInfo({
|
||||
domain,
|
||||
function_name,
|
||||
pendingAuth,
|
||||
attachments,
|
||||
}: {
|
||||
input: string;
|
||||
function_name: string;
|
||||
output?: string | null;
|
||||
domain?: string;
|
||||
pendingAuth?: boolean;
|
||||
attachments?: TAttachment[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const formatText = (text: string) => {
|
||||
@@ -57,12 +54,25 @@ export default function ToolCallInfo({
|
||||
: localize('com_assistants_attempt_info');
|
||||
}
|
||||
|
||||
const uiResources: UIResource[] =
|
||||
attachments
|
||||
?.filter((attachment) => attachment.type === Tools.ui_resources)
|
||||
.flatMap((attachment) => {
|
||||
return attachment[Tools.ui_resources] as UIResource[];
|
||||
}) ?? [];
|
||||
// Extract ui_resources from the output to display them in the UI
|
||||
let uiResources: UIResource[] = [];
|
||||
if (output?.includes('ui_resources')) {
|
||||
try {
|
||||
const parsedOutput = JSON.parse(output);
|
||||
const uiResourcesItem = parsedOutput.find(
|
||||
(contentItem) => contentItem.metadata?.type === 'ui_resources',
|
||||
);
|
||||
if (uiResourcesItem?.metadata?.data) {
|
||||
uiResources = uiResourcesItem.metadata.data;
|
||||
output = JSON.stringify(
|
||||
parsedOutput.filter((contentItem) => contentItem.metadata?.type !== 'ui_resources'),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// If JSON parsing fails, keep original output
|
||||
console.error('Failed to parse output:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import type { UIResource } from '~/common';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface UIResourceCarouselProps {
|
||||
uiResources: UIResource[];
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import ToolCall from '../ToolCall';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string, values?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_assistants_function_use: `Used ${values?.[0]}`,
|
||||
com_assistants_completed_function: `Completed ${values?.[0]}`,
|
||||
com_assistants_completed_action: `Completed action on ${values?.[0]}`,
|
||||
com_assistants_running_var: `Running ${values?.[0]}`,
|
||||
com_assistants_running_action: 'Running action',
|
||||
com_ui_sign_in_to_domain: `Sign in to ${values?.[0]}`,
|
||||
com_ui_cancelled: 'Cancelled',
|
||||
com_ui_requires_auth: 'Requires authentication',
|
||||
com_assistants_allow_sites_you_trust: 'Only allow sites you trust',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
useProgress: (initialProgress: number) => (initialProgress >= 1 ? 1 : initialProgress),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/Chat/Messages/Content/MessageContent', () => ({
|
||||
__esModule: true,
|
||||
default: ({ content }: { content: string }) => <div data-testid="message-content">{content}</div>,
|
||||
}));
|
||||
|
||||
jest.mock('../ToolCallInfo', () => ({
|
||||
__esModule: true,
|
||||
default: ({ attachments, ...props }: any) => (
|
||||
<div data-testid="tool-call-info" data-attachments={JSON.stringify(attachments)}>
|
||||
{JSON.stringify(props)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../ProgressText', () => ({
|
||||
__esModule: true,
|
||||
default: ({ onClick, inProgressText, finishedText, _error, _hasInput, _isExpanded }: any) => (
|
||||
<div onClick={onClick}>{finishedText || inProgressText}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../Parts', () => ({
|
||||
AttachmentGroup: ({ attachments }: any) => (
|
||||
<div data-testid="attachment-group">{JSON.stringify(attachments)}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('~/components/ui', () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
ChevronDown: () => <span>{'ChevronDown'}</span>,
|
||||
ChevronUp: () => <span>{'ChevronUp'}</span>,
|
||||
TriangleAlert: () => <span>{'TriangleAlert'}</span>,
|
||||
}));
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
cn: (...classes: any[]) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
describe('ToolCall', () => {
|
||||
const mockProps = {
|
||||
args: '{"test": "input"}',
|
||||
name: 'testFunction',
|
||||
output: 'Test output',
|
||||
initialProgress: 1,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
const renderWithRecoil = (component: React.ReactElement) => {
|
||||
return render(<RecoilRoot>{component}</RecoilRoot>);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('attachments prop passing', () => {
|
||||
it('should pass attachments to ToolCallInfo when provided', () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: {
|
||||
'0': { type: 'button', label: 'Click me' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
expect(toolCallInfo).toBeInTheDocument();
|
||||
|
||||
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
|
||||
expect(attachmentsData).toBe(JSON.stringify(attachments));
|
||||
});
|
||||
|
||||
it('should pass empty array when no attachments', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
|
||||
expect(attachmentsData).toBeNull(); // JSON.stringify(undefined) returns undefined, so attribute is not set
|
||||
});
|
||||
|
||||
it('should pass multiple attachments of different types', () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg1',
|
||||
toolCallId: 'tool1',
|
||||
conversationId: 'conv1',
|
||||
[Tools.ui_resources]: {
|
||||
'0': { type: 'form', fields: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: Tools.web_search,
|
||||
messageId: 'msg2',
|
||||
toolCallId: 'tool2',
|
||||
conversationId: 'conv2',
|
||||
[Tools.web_search]: {
|
||||
results: ['result1', 'result2'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
|
||||
expect(JSON.parse(attachmentsData!)).toEqual(attachments);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachment group rendering', () => {
|
||||
it('should render AttachmentGroup when attachments are provided', () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: {
|
||||
'0': { type: 'chart', data: [] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
renderWithRecoil(<ToolCall {...mockProps} attachments={attachments} />);
|
||||
|
||||
const attachmentGroup = screen.getByTestId('attachment-group');
|
||||
expect(attachmentGroup).toBeInTheDocument();
|
||||
expect(attachmentGroup.textContent).toBe(JSON.stringify(attachments));
|
||||
});
|
||||
|
||||
it('should not render AttachmentGroup when no attachments', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} />);
|
||||
|
||||
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render AttachmentGroup when attachments is empty array', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} attachments={[]} />);
|
||||
|
||||
expect(screen.queryByTestId('attachment-group')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool call info visibility', () => {
|
||||
it('should toggle tool call info when clicking header', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} />);
|
||||
|
||||
// Initially closed
|
||||
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
|
||||
|
||||
// Click to open
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
expect(screen.getByTestId('tool-call-info')).toBeInTheDocument();
|
||||
|
||||
// Click to close
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
expect(screen.queryByTestId('tool-call-info')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass all required props to ToolCallInfo', () => {
|
||||
const attachments = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: {
|
||||
'0': { type: 'button', label: 'Test' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Use a name with domain separator (_action_) and domain separator (---)
|
||||
const propsWithDomain = {
|
||||
...mockProps,
|
||||
name: 'testFunction_action_test---domain---com', // domain will be extracted and --- replaced with dots
|
||||
attachments,
|
||||
};
|
||||
|
||||
renderWithRecoil(<ToolCall {...propsWithDomain} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Completed action on test.domain.com'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const props = JSON.parse(toolCallInfo.textContent!);
|
||||
|
||||
expect(props.input).toBe('{"test": "input"}');
|
||||
expect(props.output).toBe('Test output');
|
||||
expect(props.function_name).toBe('testFunction');
|
||||
// Domain is extracted from name and --- are replaced with dots
|
||||
expect(props.domain).toBe('test.domain.com');
|
||||
expect(props.pendingAuth).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication flow', () => {
|
||||
it('should show sign-in button when auth URL is provided', () => {
|
||||
const originalOpen = window.open;
|
||||
window.open = jest.fn();
|
||||
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
initialProgress={0.5} // Less than 1 so it's not complete
|
||||
auth="https://auth.example.com"
|
||||
isSubmitting={true} // Should be submitting for auth to show
|
||||
/>,
|
||||
);
|
||||
|
||||
const signInButton = screen.getByText('Sign in to auth.example.com');
|
||||
expect(signInButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(signInButton);
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://auth.example.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
|
||||
window.open = originalOpen;
|
||||
});
|
||||
|
||||
it('should pass pendingAuth as true when auth is pending', () => {
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
auth="https://auth.example.com" // Need auth URL to extract domain
|
||||
initialProgress={0.5} // Less than 1
|
||||
isSubmitting={true} // Still submitting
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const props = JSON.parse(toolCallInfo.textContent!);
|
||||
expect(props.pendingAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('should not show auth section when cancelled', () => {
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
auth="https://auth.example.com"
|
||||
authDomain="example.com"
|
||||
progress={0.5}
|
||||
cancelled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show auth section when progress is complete', () => {
|
||||
renderWithRecoil(
|
||||
<ToolCall
|
||||
{...mockProps}
|
||||
auth="https://auth.example.com"
|
||||
authDomain="example.com"
|
||||
progress={1}
|
||||
cancelled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Sign in to example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle undefined args', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} args={undefined} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const props = JSON.parse(toolCallInfo.textContent!);
|
||||
expect(props.input).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null output', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} output={null} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const props = JSON.parse(toolCallInfo.textContent!);
|
||||
expect(props.output).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle missing domain', () => {
|
||||
renderWithRecoil(<ToolCall {...mockProps} domain={undefined} authDomain={undefined} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const props = JSON.parse(toolCallInfo.textContent!);
|
||||
expect(props.domain).toBe('');
|
||||
});
|
||||
|
||||
it('should handle complex nested attachments', () => {
|
||||
const complexAttachments = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: {
|
||||
'0': {
|
||||
type: 'nested',
|
||||
data: {
|
||||
deep: {
|
||||
value: 'test',
|
||||
array: [1, 2, 3],
|
||||
object: { key: 'value' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
renderWithRecoil(<ToolCall {...mockProps} attachments={complexAttachments} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Completed testFunction'));
|
||||
|
||||
const toolCallInfo = screen.getByTestId('tool-call-info');
|
||||
const attachmentsData = toolCallInfo.getAttribute('data-attachments');
|
||||
expect(JSON.parse(attachmentsData!)).toEqual(complexAttachments);
|
||||
|
||||
const attachmentGroup = screen.getByTestId('attachment-group');
|
||||
expect(JSON.parse(attachmentGroup.textContent!)).toEqual(complexAttachments);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
|
||||
import ToolCallInfo from '~/components/Chat/Messages/Content/ToolCallInfo';
|
||||
import ToolCallInfo from '../ToolCallInfo';
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import UIResourceCarousel from '../UIResourceCarousel';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('~/hooks', () => ({
|
||||
@@ -48,25 +46,24 @@ describe('ToolCallInfo', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ui_resources from attachments', () => {
|
||||
it('should render single ui_resource from attachments', () => {
|
||||
describe('ui_resources extraction', () => {
|
||||
it('should extract single ui_resource from output', () => {
|
||||
const uiResource = {
|
||||
type: 'text',
|
||||
data: 'Test resource',
|
||||
};
|
||||
|
||||
const attachments: TAttachment[] = [
|
||||
const output = JSON.stringify([
|
||||
{ type: 'text', text: 'Regular output' },
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: [uiResource],
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [uiResource],
|
||||
},
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
// Need output for ui_resources to render
|
||||
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
// Should render UIResourceRenderer for single resource
|
||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||
@@ -84,33 +81,29 @@ describe('ToolCallInfo', () => {
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render carousel for multiple ui_resources from attachments', () => {
|
||||
// To test multiple resources, we can use a single attachment with multiple resources
|
||||
const attachments: TAttachment[] = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg1',
|
||||
toolCallId: 'tool1',
|
||||
conversationId: 'conv1',
|
||||
[Tools.ui_resources]: [
|
||||
{ type: 'text', data: 'Resource 1' },
|
||||
{ type: 'text', data: 'Resource 2' },
|
||||
{ type: 'text', data: 'Resource 3' },
|
||||
],
|
||||
},
|
||||
it('should extract multiple ui_resources from output', () => {
|
||||
const uiResources = [
|
||||
{ type: 'text', data: 'Resource 1' },
|
||||
{ type: 'text', data: 'Resource 2' },
|
||||
{ type: 'text', data: 'Resource 3' },
|
||||
];
|
||||
|
||||
// Need output for ui_resources to render
|
||||
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||
const output = JSON.stringify([
|
||||
{ type: 'text', text: 'Regular output' },
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: uiResources,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
// Should render carousel for multiple resources
|
||||
expect(UIResourceCarousel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
uiResources: [
|
||||
{ type: 'text', data: 'Resource 1' },
|
||||
{ type: 'text', data: 'Resource 2' },
|
||||
{ type: 'text', data: 'Resource 3' },
|
||||
],
|
||||
uiResources,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
@@ -119,38 +112,34 @@ describe('ToolCallInfo', () => {
|
||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle attachments with normal output', () => {
|
||||
const attachments: TAttachment[] = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: [{ type: 'text', data: 'UI Resource' }],
|
||||
},
|
||||
it('should filter out ui_resources from displayed output', () => {
|
||||
const regularContent = [
|
||||
{ type: 'text', text: 'Regular output 1' },
|
||||
{ type: 'text', text: 'Regular output 2' },
|
||||
];
|
||||
|
||||
const output = JSON.stringify([
|
||||
{ type: 'text', text: 'Regular output 1' },
|
||||
{ type: 'text', text: 'Regular output 2' },
|
||||
...regularContent,
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [{ type: 'text', data: 'UI Resource' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { container } = render(
|
||||
<ToolCallInfo {...mockProps} output={output} attachments={attachments} />,
|
||||
);
|
||||
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
// Check that the output is displayed normally
|
||||
// Check that the displayed output doesn't contain ui_resources
|
||||
const codeBlocks = container.querySelectorAll('code');
|
||||
const outputCode = codeBlocks[1]?.textContent; // Second code block is the output
|
||||
|
||||
expect(outputCode).toContain('Regular output 1');
|
||||
expect(outputCode).toContain('Regular output 2');
|
||||
|
||||
// UI resources should be rendered via attachments
|
||||
expect(UIResourceRenderer).toHaveBeenCalled();
|
||||
expect(outputCode).not.toContain('ui_resources');
|
||||
});
|
||||
|
||||
it('should handle no attachments', () => {
|
||||
it('should handle output without ui_resources', () => {
|
||||
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
@@ -159,56 +148,66 @@ describe('ToolCallInfo', () => {
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty attachments array', () => {
|
||||
const attachments: TAttachment[] = [];
|
||||
it('should handle malformed ui_resources gracefully', () => {
|
||||
const output = JSON.stringify([
|
||||
{
|
||||
metadata: 'ui_resources', // metadata should be an object, not a string
|
||||
text: 'some text content',
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
|
||||
// Component should not throw error and should render without UI resources
|
||||
const { container } = render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||
// Should render the component without crashing
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
// UIResourceCarousel should not be called since the metadata structure is invalid
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle attachments with non-ui_resources type', () => {
|
||||
const attachments: TAttachment[] = [
|
||||
{
|
||||
type: Tools.web_search as any,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.web_search]: {
|
||||
organic: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
it('should handle ui_resources as plain text without breaking', () => {
|
||||
const outputWithTextOnly =
|
||||
'This output contains ui_resources as plain text but not as a proper structure';
|
||||
|
||||
render(<ToolCallInfo {...mockProps} attachments={attachments} />);
|
||||
render(<ToolCallInfo {...mockProps} output={outputWithTextOnly} />);
|
||||
|
||||
// Should not render UI resources components for non-ui_resources attachments
|
||||
// Should render normally without errors
|
||||
expect(screen.getByText(`Used ${mockProps.function_name}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('Result')).toBeInTheDocument();
|
||||
|
||||
// The output text should be displayed in a code block
|
||||
const codeBlocks = screen.getAllByText((content, element) => {
|
||||
return element?.tagName === 'CODE' && content.includes(outputWithTextOnly);
|
||||
});
|
||||
expect(codeBlocks.length).toBeGreaterThan(0);
|
||||
|
||||
// Should not render UI resources components
|
||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendering logic', () => {
|
||||
it('should render UI Resources heading when ui_resources exist in attachments', () => {
|
||||
const attachments: TAttachment[] = [
|
||||
it('should render UI Resources heading when ui_resources exist', () => {
|
||||
const output = JSON.stringify([
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [{ type: 'text', data: 'Test' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
// Need output for ui_resources section to render
|
||||
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
expect(screen.getByText('UI Resources')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render UI Resources heading when no ui_resources in attachments', () => {
|
||||
render(<ToolCallInfo {...mockProps} />);
|
||||
it('should not render UI Resources heading when no ui_resources', () => {
|
||||
const output = JSON.stringify([{ type: 'text', text: 'Regular output' }]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
expect(screen.queryByText('UI Resources')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -219,18 +218,16 @@ describe('ToolCallInfo', () => {
|
||||
data: { fields: [{ name: 'test', type: 'text' }] },
|
||||
};
|
||||
|
||||
const attachments: TAttachment[] = [
|
||||
const output = JSON.stringify([
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: [uiResource],
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [uiResource],
|
||||
},
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
// Need output for ui_resources to render
|
||||
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -247,18 +244,16 @@ describe('ToolCallInfo', () => {
|
||||
it('should console.log when UIAction is triggered', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
|
||||
|
||||
const attachments: TAttachment[] = [
|
||||
const output = JSON.stringify([
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: [{ type: 'text', data: 'Test' }],
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [{ type: 'text', data: 'Test' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
// Need output for ui_resources to render
|
||||
render(<ToolCallInfo {...mockProps} output="Some output" attachments={attachments} />);
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
const mockUIResourceRenderer = UIResourceRenderer as jest.MockedFunction<
|
||||
typeof UIResourceRenderer
|
||||
@@ -275,55 +270,4 @@ describe('ToolCallInfo', () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('should handle output with ui_resources for backward compatibility', () => {
|
||||
const output = JSON.stringify([
|
||||
{ type: 'text', text: 'Regular output' },
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [{ type: 'text', data: 'UI Resource' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} />);
|
||||
|
||||
// Since we now use attachments, ui_resources in output should be ignored
|
||||
expect(UIResourceRenderer).not.toHaveBeenCalled();
|
||||
expect(UIResourceCarousel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prioritize attachments over output ui_resources', () => {
|
||||
const attachments: TAttachment[] = [
|
||||
{
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'msg123',
|
||||
toolCallId: 'tool456',
|
||||
conversationId: 'conv789',
|
||||
[Tools.ui_resources]: [{ type: 'attachment', data: 'From attachments' }],
|
||||
},
|
||||
];
|
||||
|
||||
const output = JSON.stringify([
|
||||
{
|
||||
metadata: {
|
||||
type: 'ui_resources',
|
||||
data: [{ type: 'output', data: 'From output' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolCallInfo {...mockProps} output={output} attachments={attachments} />);
|
||||
|
||||
// Should use attachments, not output
|
||||
expect(UIResourceRenderer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resource: { type: 'attachment', data: 'From attachments' },
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import type { UIResource } from 'librechat-data-provider';
|
||||
import UIResourceCarousel from '~/components/Chat/Messages/Content/UIResourceCarousel';
|
||||
import '@testing-library/jest-dom';
|
||||
import UIResourceCarousel from '../UIResourceCarousel';
|
||||
import type { UIResource } from '~/common';
|
||||
|
||||
// Mock the UIResourceRenderer component
|
||||
jest.mock('@mcp-ui/client', () => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
|
||||
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
|
||||
@@ -8,6 +9,7 @@ import { useGetConversationTags } from '~/data-provider';
|
||||
import BookmarkNavItems from './BookmarkNavItems';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
type BookmarkNavProps = {
|
||||
tags: string[];
|
||||
@@ -18,6 +20,7 @@ type BookmarkNavProps = {
|
||||
const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: BookmarkNavProps) => {
|
||||
const localize = useLocalize();
|
||||
const { data } = useGetConversationTags();
|
||||
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||
const label = useMemo(
|
||||
() => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
|
||||
[tags, localize],
|
||||
@@ -53,9 +56,11 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
|
||||
anchor="bottom"
|
||||
className="absolute left-0 top-full z-[100] mt-1 w-60 translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none"
|
||||
>
|
||||
{data && (
|
||||
{data && conversation && (
|
||||
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
|
||||
<BookmarkNavItems
|
||||
// Currently selected conversation
|
||||
conversation={conversation}
|
||||
// List of selected tags(string)
|
||||
tags={tags}
|
||||
// When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { type FC } from 'react';
|
||||
import { useEffect, useState, type FC } from 'react';
|
||||
import { CrossCircledIcon } from '@radix-ui/react-icons';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import { useBookmarkContext } from '~/Providers/BookmarkContext';
|
||||
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const BookmarkNavItems: FC<{
|
||||
conversation: TConversation;
|
||||
tags: string[];
|
||||
setTags: (tags: string[]) => void;
|
||||
}> = ({ tags = [], setTags }) => {
|
||||
}> = ({ conversation, tags = [], setTags }) => {
|
||||
const [currentConversation, setCurrentConversation] = useState<TConversation>();
|
||||
const { bookmarks } = useBookmarkContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentConversation) {
|
||||
setCurrentConversation(conversation);
|
||||
}
|
||||
}, [conversation, currentConversation]);
|
||||
|
||||
const getUpdatedSelected = (tag: string) => {
|
||||
if (tags.some((selectedTag) => selectedTag === tag)) {
|
||||
return tags.filter((selectedTag) => selectedTag !== tag);
|
||||
|
||||
@@ -47,7 +47,11 @@ export default function AgentPanel() {
|
||||
const { onSelect: onSelectAgent } = useSelectAgent();
|
||||
|
||||
const modelsQuery = useGetModelsQuery();
|
||||
const basicAgentQuery = useGetAgentByIdQuery(current_agent_id);
|
||||
|
||||
// Basic agent query for initial permission check
|
||||
const basicAgentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
|
||||
enabled: !!(current_agent_id ?? '') && current_agent_id !== Constants.EPHEMERAL_AGENT_ID,
|
||||
});
|
||||
|
||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||
ResourceType.AGENT,
|
||||
|
||||
@@ -26,6 +26,10 @@ function AgentPanelSwitchWithContext() {
|
||||
}
|
||||
}, [setCurrentAgentId, conversation?.agent_id]);
|
||||
|
||||
if (!conversation?.endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activePanel === Panel.actions) {
|
||||
return <ActionsPanel />;
|
||||
}
|
||||
|
||||
@@ -91,14 +91,6 @@ export default function ApiKeyDialog({
|
||||
text: localize('com_ui_web_search_reranker_jina_key'),
|
||||
},
|
||||
},
|
||||
jinaApiUrl: {
|
||||
placeholder: localize('com_ui_web_search_jina_url'),
|
||||
type: 'text' as const,
|
||||
link: {
|
||||
url: 'https://api.jina.ai/v1/rerank',
|
||||
text: localize('com_ui_web_search_reranker_jina_url_help'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -16,7 +16,14 @@ export default function VersionPanel() {
|
||||
|
||||
const selectedAgentId = agent_id ?? '';
|
||||
|
||||
const { data: agent, isLoading, error, refetch } = useGetAgentByIdQuery(selectedAgentId);
|
||||
const {
|
||||
data: agent,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetAgentByIdQuery(selectedAgentId, {
|
||||
enabled: !!selectedAgentId && selectedAgentId !== '',
|
||||
});
|
||||
|
||||
const revertAgentVersion = useRevertAgentVersionMutation({
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { ChevronLeft, Trash2 } from 'lucide-react';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Button, useToastContext } from '@librechat/client';
|
||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
||||
@@ -123,7 +123,6 @@ function MCPPanelContent() {
|
||||
}
|
||||
|
||||
const serverStatus = connectionStatus?.[selectedServerNameForEditing];
|
||||
const isConnected = serverStatus?.connectionState === 'connected';
|
||||
|
||||
return (
|
||||
<div className="h-auto max-w-full space-y-4 overflow-x-hidden py-2">
|
||||
@@ -160,17 +159,6 @@ function MCPPanelContent() {
|
||||
Object.keys(serverBeingEdited.config.customUserVars).length > 0
|
||||
}
|
||||
/>
|
||||
{serverStatus?.requiresOAuth && isConnected && (
|
||||
<Button
|
||||
className="w-full"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleConfigRevoke(selectedServerNameForEditing)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{localize('com_ui_oauth_revoke')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Constants,
|
||||
QueryKeys,
|
||||
dataService,
|
||||
EModelEndpoint,
|
||||
PermissionBits,
|
||||
} from 'librechat-data-provider';
|
||||
import { QueryKeys, dataService, EModelEndpoint, PermissionBits } from 'librechat-data-provider';
|
||||
import type {
|
||||
QueryObserverResult,
|
||||
UseQueryOptions,
|
||||
@@ -70,27 +64,20 @@ export const useListAgentsQuery = <TData = t.AgentListResponse>(
|
||||
* Hook for retrieving basic details about a single agent (VIEW permission)
|
||||
*/
|
||||
export const useGetAgentByIdQuery = (
|
||||
agent_id: string | null | undefined,
|
||||
agent_id: string,
|
||||
config?: UseQueryOptions<t.Agent>,
|
||||
): QueryObserverResult<t.Agent> => {
|
||||
const isValidAgentId = !!(
|
||||
agent_id &&
|
||||
agent_id !== '' &&
|
||||
agent_id !== Constants.EPHEMERAL_AGENT_ID
|
||||
);
|
||||
|
||||
return useQuery<t.Agent>(
|
||||
[QueryKeys.agent, agent_id],
|
||||
() =>
|
||||
dataService.getAgentById({
|
||||
agent_id: agent_id as string,
|
||||
agent_id,
|
||||
}),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
retry: false,
|
||||
enabled: isValidAgentId && (config?.enabled ?? true),
|
||||
...config,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import useAgentToolPermissions from '../useAgentToolPermissions';
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetAgentByIdQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useAgentsMapContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseGetAgentByIdQuery = jest.requireMock('~/data-provider').useGetAgentByIdQuery;
|
||||
const mockUseAgentsMapContext = jest.requireMock('~/Providers').useAgentsMapContext;
|
||||
|
||||
describe('useAgentToolPermissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when no agentId is provided', () => {
|
||||
it('should allow all tools for ephemeral agents', () => {
|
||||
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.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow all tools when agentId is undefined', () => {
|
||||
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.tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow all tools when agentId is empty string', () => {
|
||||
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.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when agentId is provided but agent not found', () => {
|
||||
it('should disallow all tools', () => {
|
||||
mockUseAgentsMapContext.mockReturnValue({});
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data: undefined });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions('non-existent-agent'));
|
||||
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(false);
|
||||
expect(result.current.codeAllowedByAgent).toBe(false);
|
||||
expect(result.current.tools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when agent is found with tools', () => {
|
||||
it('should allow tools that are included in the agent tools array', () => {
|
||||
const agentId = 'test-agent';
|
||||
const agent = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search],
|
||||
};
|
||||
|
||||
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
|
||||
mockUseGetAgentByIdQuery.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]);
|
||||
});
|
||||
|
||||
it('should allow both tools when both are included', () => {
|
||||
const agentId = 'test-agent';
|
||||
const agent = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search, Tools.execute_code],
|
||||
};
|
||||
|
||||
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
|
||||
mockUseGetAgentByIdQuery.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]);
|
||||
});
|
||||
|
||||
it('should use data from API query when available', () => {
|
||||
const agentId = 'test-agent';
|
||||
const agentMapData = {
|
||||
id: agentId,
|
||||
tools: [Tools.file_search],
|
||||
};
|
||||
const agentApiData = {
|
||||
id: agentId,
|
||||
tools: [Tools.execute_code, Tools.file_search],
|
||||
};
|
||||
|
||||
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agentMapData });
|
||||
mockUseGetAgentByIdQuery.mockReturnValue({ data: agentApiData });
|
||||
|
||||
const { result } = renderHook(() => useAgentToolPermissions(agentId));
|
||||
|
||||
// API data should take precedence
|
||||
expect(result.current.fileSearchAllowedByAgent).toBe(true);
|
||||
expect(result.current.codeAllowedByAgent).toBe(true);
|
||||
expect(result.current.tools).toEqual([Tools.execute_code, Tools.file_search]);
|
||||
});
|
||||
|
||||
it('should fallback to agent map data when API data is not available', () => {
|
||||
const agentId = 'test-agent';
|
||||
const agentMapData = {
|
||||
id: agentId,
|
||||
tools: [Tools.execute_code],
|
||||
};
|
||||
|
||||
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agentMapData });
|
||||
mockUseGetAgentByIdQuery.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]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when agent has no tools', () => {
|
||||
it('should disallow all tools with empty array', () => {
|
||||
const agentId = 'test-agent';
|
||||
const agent = {
|
||||
id: agentId,
|
||||
tools: [],
|
||||
};
|
||||
|
||||
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
|
||||
mockUseGetAgentByIdQuery.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 disallow all tools with undefined tools', () => {
|
||||
const agentId = 'test-agent';
|
||||
const agent = {
|
||||
id: agentId,
|
||||
tools: undefined,
|
||||
};
|
||||
|
||||
mockUseAgentsMapContext.mockReturnValue({ [agentId]: agent });
|
||||
mockUseGetAgentByIdQuery.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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,4 +5,3 @@ export type { ProcessedAgentCategory } from './useAgentCategories';
|
||||
export { default as useAgentCapabilities } from './useAgentCapabilities';
|
||||
export { default as useGetAgentsConfig } from './useGetAgentsConfig';
|
||||
export { default as useAgentDefaultPermissionLevel } from './useAgentDefaultPermissionLevel';
|
||||
export { default as useAgentToolPermissions } from './useAgentToolPermissions';
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tools } from 'librechat-data-provider';
|
||||
import { useGetAgentByIdQuery } from '~/data-provider';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
||||
interface AgentToolPermissionsResult {
|
||||
fileSearchAllowedByAgent: boolean;
|
||||
codeAllowedByAgent: boolean;
|
||||
tools: string[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* @returns Object with boolean flags for file_search and execute_code permissions, plus the tools array
|
||||
*/
|
||||
export default function useAgentToolPermissions(
|
||||
agentId: string | null | undefined,
|
||||
): AgentToolPermissionsResult {
|
||||
const agentsMap = useAgentsMapContext();
|
||||
|
||||
const selectedAgent = useMemo(() => {
|
||||
return agentId != null && agentId !== '' ? agentsMap?.[agentId] : undefined;
|
||||
}, [agentId, agentsMap]);
|
||||
|
||||
const { data: agentData } = useGetAgentByIdQuery(agentId);
|
||||
|
||||
const tools = useMemo(
|
||||
() =>
|
||||
(agentData?.tools as string[] | undefined) || (selectedAgent?.tools as string[] | undefined),
|
||||
[agentData?.tools, selectedAgent?.tools],
|
||||
);
|
||||
|
||||
const fileSearchAllowedByAgent = useMemo(() => {
|
||||
// If no agentId, allow for ephemeral agents
|
||||
if (!agentId) return true;
|
||||
// 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]);
|
||||
|
||||
const codeAllowedByAgent = useMemo(() => {
|
||||
// If no agentId, allow for ephemeral agents
|
||||
if (!agentId) return true;
|
||||
// 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]);
|
||||
|
||||
return {
|
||||
fileSearchAllowedByAgent,
|
||||
codeAllowedByAgent,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
@@ -22,7 +22,9 @@ export default function useSelectAgent() {
|
||||
conversation?.agent_id ?? null,
|
||||
);
|
||||
|
||||
const agentQuery = useGetAgentByIdQuery(selectedAgentId);
|
||||
const agentQuery = useGetAgentByIdQuery(selectedAgentId ?? '', {
|
||||
enabled: !!(selectedAgentId ?? '') && selectedAgentId !== Constants.EPHEMERAL_AGENT_ID,
|
||||
});
|
||||
|
||||
const updateConversation = useCallback(
|
||||
(agent: Partial<Agent>, template: Partial<TPreset | TConversation>) => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TStartupConfig, TPlugin, TUser } from 'librechat-data-provider';
|
||||
import { mapPlugins, selectPlugins, processPlugins } from '~/utils';
|
||||
import { cleanupTimestampedStorage } from '~/utils/timestamps';
|
||||
import useSpeechSettingsInit from './useSpeechSettingsInit';
|
||||
import store from '~/store';
|
||||
|
||||
@@ -35,11 +34,6 @@ export default function useAppStartup({
|
||||
|
||||
useSpeechSettingsInit(!!user);
|
||||
|
||||
/** Clean up old localStorage entries on startup */
|
||||
useEffect(() => {
|
||||
cleanupTimestampedStorage();
|
||||
}, []);
|
||||
|
||||
/** Set the app title */
|
||||
useEffect(() => {
|
||||
const appTitle = startupConfig?.appTitle ?? '';
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import { NativeTypes } from 'react-dnd-html5-backend';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
Tools,
|
||||
QueryKeys,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
@@ -27,6 +26,19 @@ export default function useDragHelpers() {
|
||||
ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO),
|
||||
);
|
||||
|
||||
const handleOptionSelect = (toolResource: EToolResources | undefined) => {
|
||||
/** File search is not automatically enabled to simulate legacy behavior */
|
||||
if (toolResource && toolResource !== EToolResources.file_search) {
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
[toolResource]: true,
|
||||
}));
|
||||
}
|
||||
handleFiles(draggedFiles, toolResource);
|
||||
setShowModal(false);
|
||||
setDraggedFiles([]);
|
||||
};
|
||||
|
||||
const isAssistants = useMemo(
|
||||
() => isAssistantsEndpoint(conversation?.endpoint),
|
||||
[conversation?.endpoint],
|
||||
@@ -36,95 +48,36 @@ export default function useDragHelpers() {
|
||||
overrideEndpoint: isAssistants ? undefined : EModelEndpoint.agents,
|
||||
});
|
||||
|
||||
const handleOptionSelect = useCallback(
|
||||
(toolResource: EToolResources | undefined) => {
|
||||
/** File search is not automatically enabled to simulate legacy behavior */
|
||||
if (toolResource && toolResource !== EToolResources.file_search) {
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
[toolResource]: true,
|
||||
}));
|
||||
}
|
||||
handleFiles(draggedFiles, toolResource);
|
||||
setShowModal(false);
|
||||
setDraggedFiles([]);
|
||||
},
|
||||
[draggedFiles, handleFiles, setEphemeralAgent],
|
||||
);
|
||||
|
||||
/** Use refs to avoid re-creating the drop handler */
|
||||
const handleFilesRef = useRef(handleFiles);
|
||||
const conversationRef = useRef(conversation);
|
||||
|
||||
handleFilesRef.current = handleFiles;
|
||||
conversationRef.current = conversation;
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(item: { files: File[] }) => {
|
||||
if (isAssistants) {
|
||||
handleFilesRef.current(item.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
|
||||
const agentsConfig = endpointsConfig?.[EModelEndpoint.agents];
|
||||
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;
|
||||
|
||||
/** Get agent permissions at drop time */
|
||||
const agentId = conversationRef.current?.agent_id;
|
||||
let fileSearchAllowedByAgent = true;
|
||||
let codeAllowedByAgent = true;
|
||||
|
||||
if (agentId && agentId !== Constants.EPHEMERAL_AGENT_ID) {
|
||||
/** Agent data from cache */
|
||||
const agent = queryClient.getQueryData<t.Agent>([QueryKeys.agent, agentId]);
|
||||
if (agent) {
|
||||
const agentTools = agent.tools as string[] | undefined;
|
||||
fileSearchAllowedByAgent = agentTools?.includes(Tools.file_search) ?? false;
|
||||
codeAllowedByAgent = agentTools?.includes(Tools.execute_code) ?? false;
|
||||
} else {
|
||||
/** If agent exists but not found, disallow */
|
||||
fileSearchAllowedByAgent = false;
|
||||
codeAllowedByAgent = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine if dragged files are all images (enables the base image option) */
|
||||
const allImages = item.files.every((f) => f.type?.startsWith('image/'));
|
||||
|
||||
const shouldShowModal =
|
||||
allImages ||
|
||||
(fileSearchEnabled && fileSearchAllowedByAgent) ||
|
||||
(codeEnabled && codeAllowedByAgent) ||
|
||||
ocrEnabled;
|
||||
|
||||
if (!shouldShowModal) {
|
||||
// Fallback: directly handle files without showing modal
|
||||
handleFilesRef.current(item.files);
|
||||
return;
|
||||
}
|
||||
setDraggedFiles(item.files);
|
||||
setShowModal(true);
|
||||
},
|
||||
[isAssistants, queryClient],
|
||||
);
|
||||
|
||||
const [{ canDrop, isOver }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: [NativeTypes.FILE],
|
||||
drop: handleDrop,
|
||||
canDrop: () => true,
|
||||
collect: (monitor: DropTargetMonitor) => {
|
||||
/** Optimize collect to reduce re-renders */
|
||||
const isOver = monitor.isOver();
|
||||
const canDrop = monitor.canDrop();
|
||||
return { isOver, canDrop };
|
||||
drop(item: { files: File[] }) {
|
||||
console.log('drop', item.files);
|
||||
if (isAssistants) {
|
||||
handleFiles(item.files);
|
||||
return;
|
||||
}
|
||||
|
||||
const endpointsConfig = queryClient.getQueryData<t.TEndpointsConfig>([QueryKeys.endpoints]);
|
||||
const agentsConfig = endpointsConfig?.[EModelEndpoint.agents];
|
||||
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;
|
||||
if (!codeEnabled && !fileSearchEnabled && !ocrEnabled) {
|
||||
handleFiles(item.files);
|
||||
return;
|
||||
}
|
||||
setDraggedFiles(item.files);
|
||||
setShowModal(true);
|
||||
},
|
||||
canDrop: () => true,
|
||||
collect: (monitor: DropTargetMonitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}),
|
||||
[handleDrop],
|
||||
[handleFiles],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -125,8 +125,13 @@ export default function useQueryParams({
|
||||
const queryClient = useQueryClient();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
|
||||
// Extract agent_id from URL for proactive fetching
|
||||
const urlAgentId = searchParams.get('agent_id') || '';
|
||||
const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId);
|
||||
|
||||
// Use the existing query hook to fetch agent if present in URL
|
||||
const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId, {
|
||||
enabled: !!urlAgentId, // Only fetch if agent_id exists in URL
|
||||
});
|
||||
|
||||
/**
|
||||
* Applies settings from URL query parameters to create a new conversation.
|
||||
|
||||
@@ -1,50 +1,66 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useRef, useCallback, useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
|
||||
import { setTimestamp } from '~/utils/timestamps';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
const currentValue = rawCurrentValue?.trim() ?? '';
|
||||
if (currentValue.length > 2) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
};
|
||||
|
||||
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom);
|
||||
const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key));
|
||||
const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key));
|
||||
|
||||
// Sync Jotai state with ephemeral agent state
|
||||
useEffect(() => {
|
||||
if (ephemeralAgent?.mcp && ephemeralAgent.mcp.length > 0) {
|
||||
setMCPValuesRaw(ephemeralAgent.mcp);
|
||||
}
|
||||
}, [ephemeralAgent?.mcp, setMCPValuesRaw]);
|
||||
const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
|
||||
const mcpState = useMemo(() => {
|
||||
return ephemeralAgent?.mcp ?? [];
|
||||
}, [ephemeralAgent?.mcp]);
|
||||
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
const mcpStorageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
|
||||
if (mcpValues.length > 0) {
|
||||
setTimestamp(mcpStorageKey);
|
||||
}
|
||||
}, [mcpValues, key]);
|
||||
|
||||
/** Stable memoized setter */
|
||||
const setMCPValues = useCallback(
|
||||
(value: string[]) => {
|
||||
if (!Array.isArray(value)) {
|
||||
const setSelectedValues = useCallback(
|
||||
(values: string[] | null | undefined) => {
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
setMCPValuesRaw(value);
|
||||
if (!Array.isArray(values)) {
|
||||
return;
|
||||
}
|
||||
setEphemeralAgent((prev) => ({
|
||||
...prev,
|
||||
mcp: values,
|
||||
}));
|
||||
},
|
||||
[setMCPValuesRaw],
|
||||
[setEphemeralAgent],
|
||||
);
|
||||
|
||||
const [mcpValues, setMCPValuesRaw] = useLocalStorage<string[]>(
|
||||
storageKey,
|
||||
mcpState,
|
||||
setSelectedValues,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
const setMCPValuesRawRef = useRef(setMCPValuesRaw);
|
||||
setMCPValuesRawRef.current = setMCPValuesRaw;
|
||||
|
||||
/** Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop */
|
||||
const setMCPValues = useCallback((value: string[]) => {
|
||||
setMCPValuesRawRef.current(value);
|
||||
}, []);
|
||||
|
||||
const [isPinned, setIsPinned] = useLocalStorage<boolean>(
|
||||
`${LocalStorageKeys.PIN_MCP_}${key}`,
|
||||
true,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -25,8 +25,9 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToastContext();
|
||||
const { mcpToolDetails } = useGetMCPTools();
|
||||
const mcpSelect = useMCPSelect({ conversationId });
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId });
|
||||
const { mcpValues, setMCPValues, isPinned, setIsPinned } = mcpSelect;
|
||||
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
|
||||
|
||||
@@ -15,7 +15,6 @@ export type SearchApiKeyFormData = {
|
||||
firecrawlApiKey: string;
|
||||
firecrawlApiUrl: string;
|
||||
jinaApiKey: string;
|
||||
jinaApiUrl: string;
|
||||
cohereApiKey: string;
|
||||
};
|
||||
|
||||
@@ -55,7 +54,6 @@ const useAuthSearchTool = (options?: { isEntityTool: boolean }) => {
|
||||
firecrawlApiKey: data.firecrawlApiKey,
|
||||
firecrawlApiUrl: data.firecrawlApiUrl,
|
||||
jinaApiKey: data.jinaApiKey,
|
||||
jinaApiUrl: data.jinaApiUrl,
|
||||
cohereApiKey: data.cohereApiKey,
|
||||
}).reduce(
|
||||
(acc, [key, value]) => {
|
||||
|
||||
@@ -5,10 +5,23 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
import type { VerifyToolAuthResponse } from 'librechat-data-provider';
|
||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||
import { useVerifyAgentToolAuth } from '~/data-provider';
|
||||
import { setTimestamp } from '~/utils/timestamps';
|
||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||
import { ephemeralAgentByConvoId } from '~/store';
|
||||
|
||||
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||
if (rawCurrentValue) {
|
||||
try {
|
||||
const currentValue = rawCurrentValue?.trim() ?? '';
|
||||
if (currentValue === 'true' && value === false) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return value !== undefined && value !== null;
|
||||
};
|
||||
|
||||
type ToolValue = boolean | string;
|
||||
|
||||
interface UseToolToggleOptions {
|
||||
@@ -26,7 +39,7 @@ interface UseToolToggleOptions {
|
||||
|
||||
export function useToolToggle({
|
||||
conversationId,
|
||||
toolKey: _toolKey,
|
||||
toolKey,
|
||||
localStorageKey,
|
||||
isAuthenticated: externalIsAuthenticated,
|
||||
setIsDialogOpen,
|
||||
@@ -49,8 +62,13 @@ export function useToolToggle({
|
||||
[externalIsAuthenticated, authConfig, authQuery.data?.authenticated],
|
||||
);
|
||||
|
||||
const toolKey = useMemo(() => _toolKey, [_toolKey]);
|
||||
const storageKey = useMemo(() => `${localStorageKey}${key}`, [localStorageKey, key]);
|
||||
// Keep localStorage in sync
|
||||
const [, setLocalStorageValue] = useLocalStorage<ToolValue>(
|
||||
`${localStorageKey}${key}`,
|
||||
false,
|
||||
undefined,
|
||||
storageCondition,
|
||||
);
|
||||
|
||||
// The actual current value comes from ephemeralAgent
|
||||
const toolValue = useMemo(() => {
|
||||
@@ -65,14 +83,13 @@ export function useToolToggle({
|
||||
return toolValue === true;
|
||||
}, [toolValue]);
|
||||
|
||||
// Sync to localStorage with timestamps when ephemeralAgent changes
|
||||
// Sync to localStorage when ephemeralAgent changes
|
||||
useEffect(() => {
|
||||
const value = ephemeralAgent?.[toolKey];
|
||||
if (value !== undefined) {
|
||||
localStorage.setItem(storageKey, JSON.stringify(value));
|
||||
setTimestamp(storageKey);
|
||||
setLocalStorageValue(value);
|
||||
}
|
||||
}, [ephemeralAgent, toolKey, storageKey]);
|
||||
}, [ephemeralAgent, toolKey, setLocalStorageValue]);
|
||||
|
||||
const [isPinned, setIsPinned] = useLocalStorage<boolean>(`${localStorageKey}pinned`, false);
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { throttle } from 'lodash';
|
||||
|
||||
interface UseInfiniteScrollOptions {
|
||||
hasNextPage?: boolean;
|
||||
isLoading?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
fetchNextPage: () => void;
|
||||
threshold?: number; // Percentage of scroll position to trigger fetch (0-1)
|
||||
throttleMs?: number; // Throttle delay in milliseconds
|
||||
@@ -15,95 +15,77 @@ interface UseInfiniteScrollOptions {
|
||||
*/
|
||||
export const useInfiniteScroll = ({
|
||||
hasNextPage = false,
|
||||
isLoading = false,
|
||||
isFetchingNextPage = false,
|
||||
fetchNextPage,
|
||||
threshold = 0.8, // Trigger when 80% scrolled
|
||||
throttleMs = 200,
|
||||
}: UseInfiniteScrollOptions) => {
|
||||
// Monitor resizing of the scroll container
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const [scrollElement, setScrollElementState] = useState<HTMLElement | null>(null);
|
||||
const scrollElementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Handler to check if we need to fetch more data
|
||||
const handleNeedToFetch = useCallback(() => {
|
||||
if (!scrollElement) return;
|
||||
// Throttled scroll handler to prevent excessive API calls
|
||||
const handleScroll = useCallback(
|
||||
throttle(() => {
|
||||
const element = scrollElementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||
|
||||
// Calculate scroll position as percentage
|
||||
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
|
||||
// Calculate scroll position as percentage
|
||||
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
|
||||
|
||||
// Check if we've scrolled past the threshold and conditions are met
|
||||
const shouldFetch = scrollPosition >= threshold && hasNextPage && !isLoading;
|
||||
// Check if we've scrolled past the threshold and conditions are met
|
||||
const shouldFetch = scrollPosition >= threshold && hasNextPage && !isFetchingNextPage;
|
||||
|
||||
if (shouldFetch) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [scrollElement, hasNextPage, isLoading, fetchNextPage, threshold]);
|
||||
|
||||
// Create a throttled version - using useMemo to ensure it's created synchronously
|
||||
const throttledHandleNeedToFetch = useMemo(
|
||||
() => throttle(handleNeedToFetch, throttleMs),
|
||||
[handleNeedToFetch, throttleMs],
|
||||
if (shouldFetch) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, throttleMs),
|
||||
[hasNextPage, isFetchingNextPage, fetchNextPage, threshold, throttleMs],
|
||||
);
|
||||
|
||||
// Clean up throttled function on unmount
|
||||
// Set up scroll listener
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
throttledHandleNeedToFetch.cancel?.();
|
||||
};
|
||||
}, [throttledHandleNeedToFetch]);
|
||||
|
||||
// Check if we need to fetch more data when loading state changes (useful to fill content on first load)
|
||||
useEffect(() => {
|
||||
if (isLoading === false && scrollElement) {
|
||||
// Use requestAnimationFrame to ensure DOM is ready after loading completes
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
throttledHandleNeedToFetch();
|
||||
});
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}
|
||||
}, [isLoading, scrollElement, throttledHandleNeedToFetch]);
|
||||
|
||||
// Set up scroll listener and ResizeObserver
|
||||
useEffect(() => {
|
||||
const element = scrollElement;
|
||||
const element = scrollElementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
// Add the scroll listener
|
||||
element.addEventListener('scroll', throttledHandleNeedToFetch, { passive: true });
|
||||
// Remove any existing listener first
|
||||
element.removeEventListener('scroll', handleScroll);
|
||||
|
||||
// Set up ResizeObserver to detect size changes
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
}
|
||||
|
||||
resizeObserverRef.current = new ResizeObserver(() => {
|
||||
// Check if we need to fetch more data when container resizes
|
||||
throttledHandleNeedToFetch();
|
||||
});
|
||||
|
||||
resizeObserverRef.current.observe(element);
|
||||
|
||||
// Check immediately when element changes
|
||||
throttledHandleNeedToFetch();
|
||||
// Add the new listener
|
||||
element.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('scroll', throttledHandleNeedToFetch);
|
||||
// Clean up ResizeObserver
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
element.removeEventListener('scroll', handleScroll);
|
||||
// Clean up throttled function
|
||||
handleScroll.cancel?.();
|
||||
};
|
||||
}, [scrollElement, throttledHandleNeedToFetch]);
|
||||
}, [handleScroll]);
|
||||
|
||||
// Additional effect to re-setup listeners when scroll element changes
|
||||
useEffect(() => {
|
||||
const element = scrollElementRef.current;
|
||||
if (!element) return;
|
||||
// Remove any existing listener first
|
||||
element.removeEventListener('scroll', handleScroll);
|
||||
|
||||
// Add the new listener
|
||||
element.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
element.removeEventListener('scroll', handleScroll);
|
||||
// Clean up throttled function
|
||||
handleScroll.cancel?.();
|
||||
};
|
||||
}, [scrollElementRef.current, handleScroll]);
|
||||
|
||||
// Function to manually set the scroll container
|
||||
const setScrollElement = useCallback((element: HTMLElement | null) => {
|
||||
setScrollElementState(element);
|
||||
scrollElementRef.current = element;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
setScrollElement,
|
||||
scrollElementRef,
|
||||
};
|
||||
};
|
||||
|
||||
export default useInfiniteScroll;
|
||||
|
||||
@@ -848,7 +848,7 @@
|
||||
"com_ui_download_backup": "Download Backup Codes",
|
||||
"com_ui_download_backup_tooltip": "Before you continue, download your backup codes. You will need them to regain access if you lose your authenticator device",
|
||||
"com_ui_download_error": "Error downloading file. The file may have been deleted.",
|
||||
"com_ui_drag_drop": "Drop any file here to add it to the conversation",
|
||||
"com_ui_drag_drop": "something needs to go here. was empty",
|
||||
"com_ui_dropdown_variables": "Dropdown variables:",
|
||||
"com_ui_dropdown_variables_info": "Create custom dropdown menus for your prompts: `{{variable_name:option1|option2|option3}}`",
|
||||
"com_ui_duplicate": "Duplicate",
|
||||
@@ -1044,7 +1044,6 @@
|
||||
"com_ui_oauth_error_missing_code": "Authorization code is missing. Please try again.",
|
||||
"com_ui_oauth_error_missing_state": "State parameter is missing. Please try again.",
|
||||
"com_ui_oauth_error_title": "Authentication Failed",
|
||||
"com_ui_oauth_revoke": "Revoke",
|
||||
"com_ui_oauth_success_description": "Your authentication was successful. This window will close in",
|
||||
"com_ui_oauth_success_title": "Authentication Successful",
|
||||
"com_ui_of": "of",
|
||||
@@ -1252,7 +1251,6 @@
|
||||
"com_ui_web_search_cohere_key": "Enter Cohere API Key",
|
||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (optional)",
|
||||
"com_ui_web_search_jina_key": "Enter Jina API Key",
|
||||
"com_ui_web_search_jina_url": "Jina API URL (optional)",
|
||||
"com_ui_web_search_processing": "Processing results",
|
||||
"com_ui_web_search_provider": "Search Provider",
|
||||
"com_ui_web_search_provider_searxng": "SearXNG",
|
||||
@@ -1264,7 +1262,6 @@
|
||||
"com_ui_web_search_reranker_cohere_key": "Get your Cohere API key",
|
||||
"com_ui_web_search_reranker_jina": "Jina AI",
|
||||
"com_ui_web_search_reranker_jina_key": "Get your Jina API key",
|
||||
"com_ui_web_search_reranker_jina_url_help": "Learn about Jina Rerank API",
|
||||
"com_ui_web_search_scraper": "Scraper",
|
||||
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
|
||||
"com_ui_web_search_scraper_firecrawl_key": "Get your Firecrawl API key",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"com_agents_created_by": "izveidojis",
|
||||
"com_agents_description_placeholder": "Pēc izvēles: aprakstiet savu aģentu šeit",
|
||||
"com_agents_empty_state_heading": "Nav atrasts neviens aģents",
|
||||
"com_agents_enable_file_search": "Iespējot faila augšupielādi informācijas iegūšanai no tā",
|
||||
"com_agents_enable_file_search": "Iespējot failu meklēšanu",
|
||||
"com_agents_error_bad_request_message": "Pieprasījumu nevarēja apstrādāt.",
|
||||
"com_agents_error_bad_request_suggestion": "Lūdzu, pārbaudiet ievadītos datus un mēģiniet vēlreiz.",
|
||||
"com_agents_error_category_title": "Kategorija Kļūda",
|
||||
@@ -62,7 +62,7 @@
|
||||
"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_disabled": "Pirms failu augšupielādes failu meklēšanai 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ā",
|
||||
"com_agents_instructions_placeholder": "Sistēmas instrukcijas, ko izmantos aģents",
|
||||
@@ -124,17 +124,17 @@
|
||||
"com_assistants_delete_actions_success": "Darbība veiksmīgi dzēsta no asistenta",
|
||||
"com_assistants_description_placeholder": "Pēc izvēles: Šeit aprakstiet savu asistentu",
|
||||
"com_assistants_domain_info": "Asistents nosūtīja šo informāciju {{0}}",
|
||||
"com_assistants_file_search": "Faila augšupielāde informācijas iegūšanai no tā",
|
||||
"com_assistants_file_search_info": "Faila augšupielāde informācijas iegūšanai no tā ļauj asistentam iegūt zināšanas no failiem, kurus augšupielādējat jūs vai jūsu lietotāji. Kad fails ir augšupielādēts, asistents automātiski izlemj, kad, pamatojoties uz lietotāja pieprasījumiem, izgūt saturu. Failu meklēšanas vektoru krātuvju pievienošana vēl nav atbalstīta. Tos varat pievienot no pakalpojumu sniedzēja testa laukuma vai pievienot failus ziņu failu meklēšanai pēc pavediena.",
|
||||
"com_assistants_file_search": "Failu meklēšana",
|
||||
"com_assistants_file_search_info": "Failu meklēšana ļauj asistentam iegūt zināšanas no failiem, kurus augšupielādējat jūs vai jūsu lietotāji. Kad fails ir augšupielādēts, asistents automātiski izlemj, kad, pamatojoties uz lietotāja pieprasījumiem, izgūt saturu. Failu meklēšanas vektoru krātuvju pievienošana vēl nav atbalstīta. Tos varat pievienot no pakalpojumu sniedzēja testa laukuma vai pievienot failus ziņu failu meklēšanai pēc pavediena.",
|
||||
"com_assistants_function_use": "Izmantotais asistents {{0}}",
|
||||
"com_assistants_image_vision": "Attēla redzējums",
|
||||
"com_assistants_instructions_placeholder": "Sistēmas norādījumi, ko izmanto asistents",
|
||||
"com_assistants_knowledge": "Zināšanas",
|
||||
"com_assistants_knowledge_disabled": "Pirms failu var augšupielādēt zināšanu krātuvē ir jāizveido asistents un jāaktivizē koda interpretētājs vai failu atgūšana.",
|
||||
"com_assistants_knowledge_disabled": "Pirms failu augšupielādes kā Zināšanas ir jāizveido asistents un jāaktivizē un jāsaglabā koda interpretētājs vai failu atgūšana.",
|
||||
"com_assistants_knowledge_info": "Ja augšupielādējat failus sadaļā Zināšanas, sarunās ar asistentu var tikt iekļauts faila saturs.",
|
||||
"com_assistants_max_starters_reached": "Sasniegts maksimālais sarunu uzsākšanas iespēju skaits",
|
||||
"com_assistants_name_placeholder": "Pēc izvēles: Asistenta nosaukums",
|
||||
"com_assistants_non_retrieval_model": "Šajā modelī faila augšupielāde informācijas iegūšana no tā nav iespējota. Lūdzu, izvēlieties citu modeli.",
|
||||
"com_assistants_non_retrieval_model": "Šajā modelī failu meklēšana nav iespējota. Lūdzu, izvēlieties citu modeli.",
|
||||
"com_assistants_retrieval": "Atgūšana",
|
||||
"com_assistants_running_action": "Darbība palaista",
|
||||
"com_assistants_running_var": "Strādā {{0}}",
|
||||
@@ -143,7 +143,7 @@
|
||||
"com_assistants_update_actions_success": "Veiksmīgi izveidota vai atjaunināta darbība",
|
||||
"com_assistants_update_error": "Jūsu asistenta atjaunināšanā notika kļūda.",
|
||||
"com_assistants_update_success": "Veiksmīgi atjaunināts",
|
||||
"com_auth_already_have_account": "Vai jums jau ir konts?",
|
||||
"com_auth_already_have_account": "Jau ir konts?",
|
||||
"com_auth_apple_login": "Pierakstīties, izmantojot Apple",
|
||||
"com_auth_back_to_login": "Atgriezties pie pieteikšanās",
|
||||
"com_auth_click": "Noklikšķiniet uz",
|
||||
@@ -298,16 +298,16 @@
|
||||
"com_endpoint_openai_max_tokens": "Pēc izvēles “max_tokens” lauks, kas norāda maksimālo tokenu skaitu, ko var ģenerēt sarunas pabeigšanas laikā. Ievades tokenu un ģenerēto tokenu kopējo garumu ierobežo modeļa konteksta garums. Ja šis skaitlis pārsniedz maksimālo konteksta tokenu skaitu, var rasties kļūdas.",
|
||||
"com_endpoint_openai_pres": "Skaitlis no -2,0 līdz 2,0. Pozitīvas vērtības soda jaunus tokenus, pamatojoties uz to, vai tie līdz šim parādās tekstā, palielinot modeļa iespējamību runāt par jaunām tēmām.",
|
||||
"com_endpoint_openai_prompt_prefix_placeholder": "Iestatiet pielāgotas instrukcijas, kas jāiekļauj sistēmas ziņā. Noklusējuma vērtība: nav",
|
||||
"com_endpoint_openai_reasoning_effort": "Spriešanas modeļi tikai: ierobežo spriešanas modeļu spriešanas piepūli. Spriešanas piepūles samazināšana var nodrošināt ātrākas atbildes un mazāk spriešanas tokenus izmantošanas atbildē.",
|
||||
"com_endpoint_openai_reasoning_effort": "Tikai o1 un o3 modeļi: ierobežo spriešanas modeļu spriešanas piepūli. Spriešanas piepūles samazināšana var nodrošināt ātrākas atbildes un mazāk spriešanas tokenus izmantošanas atbildē.",
|
||||
"com_endpoint_openai_reasoning_summary": "Tikai atbilžu API: modeļa veiktās spriešanas kopsavilkums. Tas var būt noderīgi atkļūdošanai un modeļa spriešanas procesa izpratnei. Iestatiet vērtību “nav”, “automātiski”, “kodolīgs” vai “detalizēts”.",
|
||||
"com_endpoint_openai_resend": "Nosūtiet vēlreiz visus iepriekš pievienotos attēlus. Piezīme. Tas var ievērojami palielināt tokena izmaksas, un ar daudziem attēlu pielikumiem var rasties kļūdas.",
|
||||
"com_endpoint_openai_resend_files": "Nosūtiet vēlreiz visus iepriekš pievienotos failus. Piezīme. Tas palielinās tokena izmaksas, un ar daudziem pielikumiem var rasties kļūdas.",
|
||||
"com_endpoint_openai_stop": "Līdz 4 sekvencēm, kurās API pārtrauks turpmāku tokenu ģenerēšanu.",
|
||||
"com_endpoint_openai_stop": "Līdz 4 secībām, kurās API pārtrauks turpmāku tokenu ģenerēšanu.",
|
||||
"com_endpoint_openai_temp": "Augstākas vērtības = nejaušāks, savukārt zemākas vērtības = fokusētāks un deterministiskāks. Iesakām mainīt šo vai Top P, bet ne abus.",
|
||||
"com_endpoint_openai_topp": "Alternatīva izlasei ar temperatūru, ko sauc par kodola izlasi, kur modelis ņem vērā tokenu rezultātus ar varbūtības masu top_p. Tātad 0,1 nozīmē, ka tiek ņemti vērā tikai tie tokeni, kas veido augšējo 10% varbūtības masu. Mēs iesakām mainīt šo vai temperatūru, bet ne abus.",
|
||||
"com_endpoint_openai_use_responses_api": "Izmantot Response API sarunas pabeigšanas vietā, kas ietver paplašinātas OpenAI funkcijas. Nepieciešams o1-pro, o3-pro un spriešanas kopsavilkumu iespējošanai.",
|
||||
"com_endpoint_openai_use_web_search": "Iespējojiet tīmekļa meklēšanas funkcionalitāti, izmantojot OpenAI iebūvētās meklēšanas iespējas. Tas ļauj modelim meklēt tīmeklī aktuālu informāciju un sniegt precīzākas, aktuālākas atbildes.",
|
||||
"com_endpoint_openai_verbosity": "Ierobežo modeļa atbildes plašumu. Zemākas vērtības nodrošinās kodolīgākas atbildes, savukārt augstākas vērtības nodrošinās plašākas atbildes. Pašlaik atbalstītās vērtības ir zems, vidējs un augsts.",
|
||||
"com_endpoint_openai_verbosity": "Ierobežo modeļa atbildes plašumu. Zemākas vērtības nodrošinās kodolīgākas atbildes, savukārt augstākas vērtības nodrošinās plašākas atbildes. Pašlaik atbalstītās vērtības ir zema, vidēja un augsta.",
|
||||
"com_endpoint_output": "Izvade",
|
||||
"com_endpoint_plug_image_detail": "Attēla detaļas",
|
||||
"com_endpoint_plug_resend_files": "Atkārtoti nosūtīt failus",
|
||||
@@ -337,8 +337,8 @@
|
||||
"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_summary": "Spriešanas kopsavilkums",
|
||||
"com_endpoint_reasoning_effort": "Domāšanas grūtums",
|
||||
"com_endpoint_reasoning_summary": "Argumentācijas kopsavilkums",
|
||||
"com_endpoint_save_as_preset": "Saglabāt kā iestatījumu",
|
||||
"com_endpoint_search": "Meklēt galapunktu pēc nosaukuma",
|
||||
"com_endpoint_search_endpoint_models": "Meklēt {{0}} modeļos...",
|
||||
@@ -346,7 +346,7 @@
|
||||
"com_endpoint_search_var": "Meklēt {{0}}...",
|
||||
"com_endpoint_set_custom_name": "Iestatiet pielāgotu nosaukumu, ja varat atrast šo iestatījumu",
|
||||
"com_endpoint_skip_hover": "Iespējot pabeigšanas soļa izlaišanu, kurā tiek pārskatīta galīgā atbilde un ģenerētie soļi",
|
||||
"com_endpoint_stop": "Stop sekvences",
|
||||
"com_endpoint_stop": "Apturēt secības",
|
||||
"com_endpoint_stop_placeholder": "Atdaliet vērtības, nospiežot taustiņu `Enter`",
|
||||
"com_endpoint_temperature": "Temperatūra",
|
||||
"com_endpoint_thinking": "Domāšana",
|
||||
@@ -443,7 +443,7 @@
|
||||
"com_nav_clear_cache_confirm_message": "Vai tiešām vēlaties notīrīt kešatmiņu?",
|
||||
"com_nav_clear_conversation": "Skaidras sarunas",
|
||||
"com_nav_clear_conversation_confirm_message": "Vai tiešām vēlaties dzēst visas saglabātās sarunas? Šī darbība ir neatgriezeniska.",
|
||||
"com_nav_close_sidebar": "Aizvērt sāna joslu",
|
||||
"com_nav_close_sidebar": "Aizvērt sānu joslu",
|
||||
"com_nav_commands": "Komandas",
|
||||
"com_nav_confirm_clear": "Apstiprināt dzēšanu",
|
||||
"com_nav_conversation_mode": "Sarunas režīms",
|
||||
@@ -478,7 +478,7 @@
|
||||
"com_nav_font_size_xl": "Īpaši liels",
|
||||
"com_nav_font_size_xs": "Īpaši mazs",
|
||||
"com_nav_help_faq": "Palīdzība un bieži uzdotie jautājumi",
|
||||
"com_nav_hide_panel": "Slēpt labā sāna paneli",
|
||||
"com_nav_hide_panel": "Slēpt labo sāna paneli",
|
||||
"com_nav_info_balance": "Bilance parāda, cik daudz tokenu kredītu jums ir atlicis izmantot. Tokenu kredīti tiek pārvērsti naudas vērtībā (piemēram, 1000 kredīti = 0,001 USD).",
|
||||
"com_nav_info_code_artifacts": "Iespējo eksperimentāla koda artefaktu rādīšanu blakus sarunai",
|
||||
"com_nav_info_code_artifacts_agent": "Iespējo koda artefaktu izmantošanu šim aģentam. Pēc noklusējuma tiek pievienotas papildu instrukcijas, kas attiecas uz artefaktu izmantošanu, ja vien nav iespējots \"Pielāgots uzvednes režīms\".",
|
||||
@@ -534,7 +534,7 @@
|
||||
"com_nav_latex_parsing": "LaTeX parsēšana ziņās (var ietekmēt veiktspēju)",
|
||||
"com_nav_log_out": "Izrakstīties",
|
||||
"com_nav_long_audio_warning": "Garāku tekstu apstrāde prasīs ilgāku laiku.",
|
||||
"com_nav_maximize_chat_space": "Maksimāli izmantot sarunu telpas izmērus",
|
||||
"com_nav_maximize_chat_space": "Maksimāli izmantot sarunas telpas izmērus",
|
||||
"com_nav_mcp_configure_server": "Konfigurēt {{0}}",
|
||||
"com_nav_mcp_status_connecting": "{{0}} - Savienojas",
|
||||
"com_nav_mcp_vars_update_error": "Kļūda atjauninot MCP pielāgotos lietotāja parametrus: {{0}}",
|
||||
@@ -554,7 +554,7 @@
|
||||
"com_nav_profile_picture": "Profila attēls",
|
||||
"com_nav_save_badges_state": "Saglabāt nozīmīšu stāvokli",
|
||||
"com_nav_save_drafts": "Saglabāt melnrakstus lokāli",
|
||||
"com_nav_scroll_button": "Rādīt pogu: pāriet uz pēdējo ierakstu",
|
||||
"com_nav_scroll_button": "Pāriet uz pēdējo ierakstu poga",
|
||||
"com_nav_search_placeholder": "Meklēt ziņas",
|
||||
"com_nav_send_message": "Sūtīt ziņu",
|
||||
"com_nav_setting_account": "Konts",
|
||||
@@ -568,7 +568,7 @@
|
||||
"com_nav_settings": "Iestatījumi",
|
||||
"com_nav_shared_links": "Kopīgotās saites",
|
||||
"com_nav_show_code": "Vienmēr rādīt kodu, izmantojot koda interpretētāju",
|
||||
"com_nav_show_thinking": "Pēc noklusējuma atvērt spriešanas sarakstu",
|
||||
"com_nav_show_thinking": "Pēc noklusējuma atvērt domāšanas nolaižamos sarakstus",
|
||||
"com_nav_slash_command": "/-Komanda",
|
||||
"com_nav_slash_command_description": "Ieslēgt komandu \"/\", lai atlasītu uzvedni izmantojot tastatūru",
|
||||
"com_nav_speech_to_text": "Balss pārvēršana tekstā",
|
||||
@@ -577,7 +577,7 @@
|
||||
"com_nav_theme": "Tēma",
|
||||
"com_nav_theme_dark": "Tumšs",
|
||||
"com_nav_theme_light": "Gaišs",
|
||||
"com_nav_theme_system": "Sistēmas uzstādījums",
|
||||
"com_nav_theme_system": "Sistēma",
|
||||
"com_nav_tool_dialog": "Asistenta rīki",
|
||||
"com_nav_tool_dialog_agents": "Aģenta rīki",
|
||||
"com_nav_tool_dialog_description": "Lai saglabātu rīku atlasi, ir jāsaglabā asistents.",
|
||||
@@ -586,7 +586,7 @@
|
||||
"com_nav_tool_search": "Meklēšanas rīki",
|
||||
"com_nav_user": "LIETOTĀJS",
|
||||
"com_nav_user_msg_markdown": "Atveidot lietotāja ziņas kā Markdown",
|
||||
"com_nav_user_name_display": "Rādīt lietotājvārdu sarakstēs",
|
||||
"com_nav_user_name_display": "Rādīt lietotājvārdu ziņās",
|
||||
"com_nav_voice_select": "Balss",
|
||||
"com_show_agent_settings": "Rādīt aģenta iestatījumus",
|
||||
"com_show_completion_settings": "Rādīt pabeigšanas iestatījumus",
|
||||
@@ -598,7 +598,7 @@
|
||||
"com_sidepanel_hide_panel": "Slēpt paneli",
|
||||
"com_sidepanel_manage_files": "Pārvaldīt failus",
|
||||
"com_sidepanel_mcp_no_servers_with_vars": "Nav MCP serveru ar konfigurējamiem mainīgajiem.",
|
||||
"com_sidepanel_parameters": "Modeļa parametri",
|
||||
"com_sidepanel_parameters": "Parametri",
|
||||
"com_sources_agent_file": "Avota dokuments",
|
||||
"com_sources_agent_files": "Aģentu faili",
|
||||
"com_sources_download_aria_label": "Lejupielādēt {{filename}}{{status}}",
|
||||
@@ -650,7 +650,7 @@
|
||||
"com_ui_agent_category_rd": "Pētniecība un attīstība",
|
||||
"com_ui_agent_category_sales": "Pārdošana",
|
||||
"com_ui_agent_category_selector_aria": "Aģenta kategorijas atlasītājs",
|
||||
"com_ui_agent_chain": "Aģentu ķēde (Vairāki aģenti kopā sasaistīti)",
|
||||
"com_ui_agent_chain": "Aģentu ķēde (aģentu maisījums)",
|
||||
"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.",
|
||||
@@ -730,7 +730,7 @@
|
||||
"com_ui_bearer": "Nesējs",
|
||||
"com_ui_bookmark_delete_confirm": "Vai tiešām vēlaties dzēst šo grāmatzīmi?",
|
||||
"com_ui_bookmarks": "Grāmatzīmes",
|
||||
"com_ui_bookmarks_add": "Pievienot grāmatzīmi",
|
||||
"com_ui_bookmarks_add": "Pievienot grāmatzīmes",
|
||||
"com_ui_bookmarks_add_to_conversation": "Pievienot pašreizējai sarunai",
|
||||
"com_ui_bookmarks_count": "Grāfs",
|
||||
"com_ui_bookmarks_create_error": "Veidojot grāmatzīmi, radās kļūda",
|
||||
@@ -766,7 +766,7 @@
|
||||
"com_ui_command_placeholder": "Pēc izvēles: Ja tiks izmantota komanda uzvednei vai nosaukums, lūdzu ievadiet",
|
||||
"com_ui_command_usage_placeholder": "Atlasiet uzvedni pēc komandas vai nosaukuma",
|
||||
"com_ui_complete_setup": "Pabeigt iestatīšanu",
|
||||
"com_ui_concise": "Kodolīgs",
|
||||
"com_ui_concise": "Īss",
|
||||
"com_ui_configure_mcp_variables_for": "Uzstādīt parametrus {{0}}",
|
||||
"com_ui_confirm": "Apstiprināt",
|
||||
"com_ui_confirm_action": "Apstiprināt darbību",
|
||||
@@ -807,8 +807,8 @@
|
||||
"com_ui_date_may": "Maijs",
|
||||
"com_ui_date_november": "Novembris",
|
||||
"com_ui_date_october": "Oktobris",
|
||||
"com_ui_date_previous_30_days": "Pēdējās 30 dienas",
|
||||
"com_ui_date_previous_7_days": "Pēdējās 7 dienas",
|
||||
"com_ui_date_previous_30_days": "Iepriekšējās 30 dienas",
|
||||
"com_ui_date_previous_7_days": "Iepriekšējās 7 dienas",
|
||||
"com_ui_date_september": "Septembris",
|
||||
"com_ui_date_today": "Šodien",
|
||||
"com_ui_date_yesterday": "Vakar",
|
||||
@@ -848,7 +848,7 @@
|
||||
"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_drag_drop": "Ievietojiet šeit jebkuru failu, lai pievienotu to sarunai",
|
||||
"com_ui_drag_drop": "Nav rezultātu",
|
||||
"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)",
|
||||
"com_ui_duplicate": "Dublicēt",
|
||||
@@ -877,9 +877,9 @@
|
||||
"com_ui_export_convo_modal": "Eksportēt sarunas modālo logu",
|
||||
"com_ui_feedback_more": "Vairāk...",
|
||||
"com_ui_feedback_more_information": "Sniegt papildu atsauksmes",
|
||||
"com_ui_feedback_negative": "Atbildei nepieciešami uzlabojumi",
|
||||
"com_ui_feedback_negative": "Nepieciešami uzlabojumi",
|
||||
"com_ui_feedback_placeholder": "Lūdzu, sniedziet šeit jebkādas papildu atsauksmes.",
|
||||
"com_ui_feedback_positive": "Lieliska atbilde",
|
||||
"com_ui_feedback_positive": "Man tas patīk",
|
||||
"com_ui_feedback_tag_accurate_reliable": "Precīzs un uzticams",
|
||||
"com_ui_feedback_tag_attention_to_detail": "Uzmanība detaļām",
|
||||
"com_ui_feedback_tag_bad_style": "Slikts stils vai tonis",
|
||||
@@ -1046,7 +1046,6 @@
|
||||
"com_ui_oauth_error_missing_code": "Trūkst autorizācijas koda. Lūdzu, mēģiniet vēlreiz.",
|
||||
"com_ui_oauth_error_missing_state": "Trūkst stāvokļa parametrs. Lūdzu, mēģiniet vēlreiz.",
|
||||
"com_ui_oauth_error_title": "Autentifikācija neizdevās",
|
||||
"com_ui_oauth_revoke": "Atsaukt",
|
||||
"com_ui_oauth_success_description": "Jūsu autentifikācija bija veiksmīga. Šis logs aizvērsies pēc",
|
||||
"com_ui_oauth_success_title": "Autentifikācija veiksmīga",
|
||||
"com_ui_of": "no",
|
||||
@@ -1084,8 +1083,8 @@
|
||||
"com_ui_quality": "Kvalitāte",
|
||||
"com_ui_read_aloud": "Lasīt skaļi",
|
||||
"com_ui_redirecting_to_provider": "Pārvirzu uz {{0}}, lūdzu, uzgaidiet...",
|
||||
"com_ui_reference_saved_memories": "References uz saglabātajām atmiņām par lietotāju",
|
||||
"com_ui_reference_saved_memories_description": "Ļaut asistentam atsaukties uz saglabātajām atmiņām par lietotāju un izmantot tās atbildot",
|
||||
"com_ui_reference_saved_memories": "References uz saglabātajām atmiņām",
|
||||
"com_ui_reference_saved_memories_description": "Ļaut asistentam atsaukties uz jūsu saglabātajām atmiņām un izmantot tās atbildot",
|
||||
"com_ui_refresh": "Atsvaidzināt",
|
||||
"com_ui_refresh_link": "Atsvaidzināt saiti",
|
||||
"com_ui_regenerate": "Atjaunot",
|
||||
@@ -1120,7 +1119,7 @@
|
||||
"com_ui_role_viewer": "Skatītājs",
|
||||
"com_ui_role_viewer_desc": "Var skatīt un izmantot aģentu, bet nevar to rediģēt",
|
||||
"com_ui_roleplay": "Lomu spēle",
|
||||
"com_ui_run_code": "Palaist kodu",
|
||||
"com_ui_run_code": "Palaišanas kods",
|
||||
"com_ui_run_code_error": "Radās kļūda, izpildot kodu",
|
||||
"com_ui_save": "Saglabāt",
|
||||
"com_ui_save_badge_changes": "Vai saglabāt emblēmas izmaiņas?",
|
||||
@@ -1188,7 +1187,7 @@
|
||||
"com_ui_support_contact": "Atbalsta kontaktinformācija",
|
||||
"com_ui_support_contact_email": "E-pasts",
|
||||
"com_ui_support_contact_email_invalid": "Lūdzu, ievadiet derīgu e-pasta adresi",
|
||||
"com_ui_support_contact_email_placeholder": "atbalsts@piemers.lv",
|
||||
"com_ui_support_contact_email_placeholder": "atbalsts@piemers.com",
|
||||
"com_ui_support_contact_name": "Vārds",
|
||||
"com_ui_support_contact_name_min_length": "Vārdam jābūt vismaz {{minLength}} rakstu zīmēm",
|
||||
"com_ui_support_contact_name_placeholder": "Atbalsta kontaktpersonas vārds",
|
||||
@@ -1197,7 +1196,7 @@
|
||||
"com_ui_terms_and_conditions": "Noteikumi un nosacījumi",
|
||||
"com_ui_terms_of_service": "Pakalpojumu sniegšanas noteikumi",
|
||||
"com_ui_thinking": "Domā...",
|
||||
"com_ui_thoughts": "Spriešana",
|
||||
"com_ui_thoughts": "Domas",
|
||||
"com_ui_token": "tokens",
|
||||
"com_ui_token_exchange_method": "Tokenu apmaiņas metode",
|
||||
"com_ui_token_url": "Tokena URL",
|
||||
@@ -1221,19 +1220,19 @@
|
||||
"com_ui_update_mcp_success": "Veiksmīgi izveidots vai atjaunināts MCP",
|
||||
"com_ui_upload": "Augšupielādēt",
|
||||
"com_ui_upload_agent_avatar": "Aģenta avatars veiksmīgi atjaunināts",
|
||||
"com_ui_upload_code_files": "Augšupielādēt failu koda interpretētājam",
|
||||
"com_ui_upload_code_files": "Augšupielādēt koda interpretētājam",
|
||||
"com_ui_upload_delay": "Augšupielāde \"{{0}}\" aizņem vairāk laika nekā paredzēts. Lūdzu, uzgaidiet, kamēr faila indeksēšana ir pabeigta izguvei.",
|
||||
"com_ui_upload_error": "Augšupielādējot failu, radās kļūda.",
|
||||
"com_ui_upload_file_context": "Augšupielādēt failu kā kontekstu",
|
||||
"com_ui_upload_file_search": "Augšupielādēt failu informācijas iegūšanai no tā",
|
||||
"com_ui_upload_file_context": "Augšupielādēt failu kontekstā",
|
||||
"com_ui_upload_file_search": "Augšupielādēt failu meklēšanai",
|
||||
"com_ui_upload_files": "Augšupielādēt failus",
|
||||
"com_ui_upload_image": "Augšupielādēt failu kā attēlu",
|
||||
"com_ui_upload_image_input": "Augšupielādēt failu kā attēlu",
|
||||
"com_ui_upload_image": "Augšupielādēt attēlu",
|
||||
"com_ui_upload_image_input": "Augšupielādēt attēlu",
|
||||
"com_ui_upload_invalid": "Nederīgs augšupielādējamais fails. Attēlam jābūt tādam, kas nepārsniedz ierobežojumu.",
|
||||
"com_ui_upload_invalid_var": "Nederīgs augšupielādējams fails. Attēlam jābūt ne lielākam par {{0}} MB",
|
||||
"com_ui_upload_ocr_text": "Augšupielādēt failu kā tekstu",
|
||||
"com_ui_upload_ocr_text": "Augšupielādēt kā tekstu",
|
||||
"com_ui_upload_success": "Fails veiksmīgi augšupielādēts",
|
||||
"com_ui_upload_type": "Izvēlieties augšupielādes veidu",
|
||||
"com_ui_upload_type": "Atlasiet augšupielādes veidu",
|
||||
"com_ui_usage": "Izmantošana",
|
||||
"com_ui_use_2fa_code": "Izmantojiet 2FA kodu",
|
||||
"com_ui_use_backup_code": "Izmantojiet rezerves kodu",
|
||||
@@ -1254,7 +1253,6 @@
|
||||
"com_ui_web_search_cohere_key": "Ievadiet Cohere API atslēgu",
|
||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL (pēc izvēles)",
|
||||
"com_ui_web_search_jina_key": "Ievadiet Jina API atslēgu",
|
||||
"com_ui_web_search_jina_url": "Jina API URL (pēc izvēles)",
|
||||
"com_ui_web_search_processing": "Rezultātu apstrāde",
|
||||
"com_ui_web_search_provider": "Meklēšanas nodrošinātājs",
|
||||
"com_ui_web_search_provider_searxng": "SearXNG",
|
||||
@@ -1266,7 +1264,6 @@
|
||||
"com_ui_web_search_reranker_cohere_key": "Iegūstiet savu Cohere API atslēgu",
|
||||
"com_ui_web_search_reranker_jina": "Jina AI",
|
||||
"com_ui_web_search_reranker_jina_key": "Iegūstiet savu Jina API atslēgu",
|
||||
"com_ui_web_search_reranker_jina_url_help": "Uzzināt par Jina Rerank API",
|
||||
"com_ui_web_search_scraper": "Scraper",
|
||||
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
|
||||
"com_ui_web_search_scraper_firecrawl_key": "Iegūstiet savu Firecrawl API atslēgu",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"com_a11y_ai_composing": "De AI is nog bezig met het formuleren van een antwoord.",
|
||||
"com_a11y_end": "De AI is klaar met het antwoord.",
|
||||
"com_a11y_start": "De AI is begonnen met antwoorden.",
|
||||
"com_agents_all": "Alle Agents\n",
|
||||
"com_agents_all": "Alle Agents\n\n",
|
||||
"com_agents_by_librechat": "door LibreChat",
|
||||
"com_agents_category_empty": "Geen agents gevonden in de {{category}} categorie",
|
||||
"com_agents_category_tab_label": "{{category}} categorie, {{position}} of {{total}}",
|
||||
@@ -215,7 +215,7 @@
|
||||
"com_endpoint_openai_max": "Het max. aantal tokens dat kan worden gegenereerd. De totale lengte van invoer-tokens en gegenereerde tokens is beperkt door de contextlengte van het model.",
|
||||
"com_endpoint_openai_pres": "Getal tussen -2,0 en 2,0. Positieve waarden straffen nieuwe tokens op basis van of ze al voorkomen in de tekst tot nu toe, waardoor de kans dat het model over nieuwe onderwerpen praat toeneemt.",
|
||||
"com_endpoint_openai_prompt_prefix_placeholder": "Stel aangepaste instructies in om op te nemen in Systeembericht. Standaard: geen",
|
||||
"com_endpoint_openai_reasoning_effort": "Alleen redeneermodellen: beperkt de inspanning voor redeneren. Vermindering van redeneerinspanning kan resulteren in snellere reacties en minder tokens gebruikt voor redeneren in een reactie. Minimaal' produceert zeer weinig redeneertokens voor de snelste tijd-tot-eerste-tokens, vooral geschikt voor codering en het volgen van instructies.",
|
||||
"com_endpoint_openai_reasoning_effort": "Alleen voor redeneringsmodellen: beperkt de inspanning voor redeneren. Het verminderen van de redeneringsinspanning kan leiden tot snellere antwoorden en minder tokens die worden gebruikt voor redeneren in een antwoord. 'Minimaal' produceert zeer weinig redeneringstokens voor de snelste tijd tot het eerste token, vooral geschikt voor coderen en instructies volgen.",
|
||||
"com_endpoint_openai_temp": "Hogere waarden = meer willekeurig, terwijl lagere waarden = meer gericht en deterministisch. We raden aan dit of Top P te wijzigen, maar niet beide.",
|
||||
"com_endpoint_openai_topp": "Een alternatief voor sampling met temperatuur, genaamd nucleus sampling, waarbij het model de resultaten van de tokens met de top_p waarschijnlijkheidsmassa in overweging neemt. Dus 0,1 betekent dat alleen de tokens die de bovenste 10% waarschijnlijkheidsmassa omvatten, in overweging worden genomen. We raden aan dit of temperatuur te wijzigen, maar niet beide.",
|
||||
"com_endpoint_output": "Uitvoer",
|
||||
|
||||
@@ -848,7 +848,7 @@
|
||||
"com_ui_download_backup": "下载备份代码",
|
||||
"com_ui_download_backup_tooltip": "在继续之前,请下载备份代码。如果您丢失了身份验证设备,您将需要该代码来重新获得访问权限",
|
||||
"com_ui_download_error": "下载文件时出现错误,该文件可能已被删除。",
|
||||
"com_ui_drag_drop": "将任意文件拖放到此处以添加到对话中",
|
||||
"com_ui_drag_drop": "这里需要放点东西,当前是空的",
|
||||
"com_ui_dropdown_variables": "下拉变量:",
|
||||
"com_ui_dropdown_variables_info": "为您的提示词创建自定义下拉菜单:`{{variable_name:option1|option2|option3}}`",
|
||||
"com_ui_duplicate": "复制",
|
||||
@@ -1044,7 +1044,6 @@
|
||||
"com_ui_oauth_error_missing_code": "缺少身份验证代码,请重试。",
|
||||
"com_ui_oauth_error_missing_state": "缺少状态参数,请重试。",
|
||||
"com_ui_oauth_error_title": "认证失败",
|
||||
"com_ui_oauth_revoke": "撤销",
|
||||
"com_ui_oauth_success_description": "您的身份验证成功。此窗口将在以下时间后关闭:",
|
||||
"com_ui_oauth_success_title": "认证成功",
|
||||
"com_ui_of": "/",
|
||||
@@ -1253,7 +1252,6 @@
|
||||
"com_ui_web_search_cohere_key": "输入 Cohere API Key",
|
||||
"com_ui_web_search_firecrawl_url": "Firecrawl API URL(可选)",
|
||||
"com_ui_web_search_jina_key": "输入 Jina API Key",
|
||||
"com_ui_web_search_jina_url": "Jina API URL(可选)",
|
||||
"com_ui_web_search_processing": "正在处理结果",
|
||||
"com_ui_web_search_provider": "搜索提供商",
|
||||
"com_ui_web_search_provider_searxng": "SearXNG",
|
||||
@@ -1265,7 +1263,6 @@
|
||||
"com_ui_web_search_reranker_cohere_key": "获取您的 Cohere API Key",
|
||||
"com_ui_web_search_reranker_jina": "Jina AI",
|
||||
"com_ui_web_search_reranker_jina_key": "获取您的 Jina API Key",
|
||||
"com_ui_web_search_reranker_jina_url_help": "了解 Jina Rerank API",
|
||||
"com_ui_web_search_scraper": "抓取器",
|
||||
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
|
||||
"com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key",
|
||||
|
||||
@@ -6,21 +6,8 @@
|
||||
"com_a11y_start": "AI 已開始回覆。",
|
||||
"com_agents_agent_card_label": "{{name}} agent。{{description}}",
|
||||
"com_agents_all": "全部 Agent",
|
||||
"com_agents_all_category": "全部",
|
||||
"com_agents_all_description": "瀏覽所有類別中的共用 agent",
|
||||
"com_agents_by_librechat": "由 LibreChat 提供",
|
||||
"com_agents_category_aftersales": "售後",
|
||||
"com_agents_category_aftersales_description": "售後支援、維護與客戶服務 agent",
|
||||
"com_agents_category_empty": "在 {{category}} 類別中找不到 agent",
|
||||
"com_agents_category_finance": "財務",
|
||||
"com_agents_category_finance_description": "財務分析、預算與會計 agent",
|
||||
"com_agents_category_general": "通用",
|
||||
"com_agents_category_general_description": "通用型 agent(處理常見任務與詢問)",
|
||||
"com_agents_category_hr": "人資",
|
||||
"com_agents_category_hr_description": "人力資源流程、政策與員工支援 agent",
|
||||
"com_agents_category_it": "IT",
|
||||
"com_agents_category_it_description": "IT 支援、技術排障與系統管理 agent",
|
||||
"com_agents_category_rd": "研發",
|
||||
"com_agents_category_tab_label": "{{category}} 類別,{{position}} / {{total}}",
|
||||
"com_agents_category_tabs_label": "Agent 類別",
|
||||
"com_agents_clear_search": "清除搜尋",
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
TwoFactorScreen,
|
||||
RequestPasswordReset,
|
||||
} from '~/components/Auth';
|
||||
import { MarketplaceProvider } from '~/components/Agents/MarketplaceContext';
|
||||
import AgentMarketplace from '~/components/Agents/Marketplace';
|
||||
import { OAuthSuccess, OAuthError } from '~/components/OAuth';
|
||||
import { AuthContextProvider } from '~/hooks/AuthContext';
|
||||
@@ -113,19 +112,11 @@ export const router = createBrowserRouter(
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
element: (
|
||||
<MarketplaceProvider>
|
||||
<AgentMarketplace />
|
||||
</MarketplaceProvider>
|
||||
),
|
||||
element: <AgentMarketplace />,
|
||||
},
|
||||
{
|
||||
path: 'agents/:category',
|
||||
element: (
|
||||
<MarketplaceProvider>
|
||||
<AgentMarketplace />
|
||||
</MarketplaceProvider>
|
||||
),
|
||||
element: <AgentMarketplace />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ import settings from './settings';
|
||||
import misc from './misc';
|
||||
import isTemporary from './temporary';
|
||||
export * from './agents';
|
||||
export * from './mcp';
|
||||
|
||||
export default {
|
||||
...artifacts,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { atomFamily, atomWithStorage } from 'jotai/utils';
|
||||
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
|
||||
|
||||
/**
|
||||
* Creates a storage atom for MCP values per conversation
|
||||
* Uses atomFamily to create unique atoms for each conversation ID
|
||||
*/
|
||||
export const mcpValuesAtomFamily = atomFamily((conversationId: string | null) => {
|
||||
const key = conversationId ?? Constants.NEW_CONVO;
|
||||
const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`;
|
||||
|
||||
return atomWithStorage<string[]>(storageKey, [], undefined, { getOnInit: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Global storage atom for MCP pinned state (shared across all conversations)
|
||||
*/
|
||||
export const mcpPinnedAtom = atomWithStorage<boolean>(LocalStorageKeys.PIN_MCP_, true, undefined, {
|
||||
getOnInit: true,
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import { LocalStorageKeys } from 'librechat-data-provider';
|
||||
import {
|
||||
setTimestamp,
|
||||
setTimestampedValue,
|
||||
getTimestampedValue,
|
||||
removeTimestampedValue,
|
||||
cleanupTimestampedStorage,
|
||||
migrateExistingEntries,
|
||||
} from '../timestamps';
|
||||
|
||||
describe('timestamps', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('setTimestamp', () => {
|
||||
it('should set only timestamp in localStorage', () => {
|
||||
const key = 'test-key';
|
||||
|
||||
setTimestamp(key);
|
||||
|
||||
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeTruthy();
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTimestampedValue', () => {
|
||||
it('should set value and timestamp in localStorage', () => {
|
||||
const key = 'test-key';
|
||||
const value = { data: 'test' };
|
||||
|
||||
setTimestampedValue(key, value);
|
||||
|
||||
expect(localStorage.getItem(key)).toBe(JSON.stringify(value));
|
||||
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimestampedValue', () => {
|
||||
it('should return value if timestamp is valid', () => {
|
||||
const key = 'test-key';
|
||||
const value = { data: 'test' };
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
localStorage.setItem(`${key}_TIMESTAMP`, Date.now().toString());
|
||||
|
||||
const result = getTimestampedValue(key);
|
||||
expect(result).toBe(JSON.stringify(value));
|
||||
});
|
||||
|
||||
it('should return null and clean up if timestamp is too old', () => {
|
||||
const key = 'test-key';
|
||||
const value = { data: 'test' };
|
||||
const oldTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000; // 3 days ago
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
localStorage.setItem(`${key}_TIMESTAMP`, oldTimestamp.toString());
|
||||
|
||||
const result = getTimestampedValue(key);
|
||||
expect(result).toBeNull();
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return value if no timestamp exists (backward compatibility)', () => {
|
||||
const key = 'test-key';
|
||||
const value = { data: 'test' };
|
||||
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
|
||||
const result = getTimestampedValue(key);
|
||||
expect(result).toBe(JSON.stringify(value));
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTimestampedValue', () => {
|
||||
it('should remove both value and timestamp', () => {
|
||||
const key = 'test-key';
|
||||
|
||||
localStorage.setItem(key, 'value');
|
||||
localStorage.setItem(`${key}_TIMESTAMP`, Date.now().toString());
|
||||
|
||||
removeTimestampedValue(key);
|
||||
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupTimestampedStorage', () => {
|
||||
it('should remove entries without timestamps', () => {
|
||||
const key = `${LocalStorageKeys.LAST_MCP_}convo-123`;
|
||||
localStorage.setItem(key, 'value');
|
||||
|
||||
cleanupTimestampedStorage();
|
||||
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
});
|
||||
|
||||
it('should remove old entries with timestamps', () => {
|
||||
const key = `${LocalStorageKeys.LAST_CODE_TOGGLE_}convo-456`;
|
||||
const oldTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000; // 3 days ago
|
||||
|
||||
localStorage.setItem(key, 'true');
|
||||
localStorage.setItem(`${key}_TIMESTAMP`, oldTimestamp.toString());
|
||||
|
||||
cleanupTimestampedStorage();
|
||||
|
||||
expect(localStorage.getItem(key)).toBeNull();
|
||||
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBeNull();
|
||||
});
|
||||
|
||||
it('should keep recent entries', () => {
|
||||
const key = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}convo-789`;
|
||||
const recentTimestamp = Date.now() - 1 * 60 * 60 * 1000; // 1 hour ago
|
||||
|
||||
localStorage.setItem(key, 'false');
|
||||
localStorage.setItem(`${key}_TIMESTAMP`, recentTimestamp.toString());
|
||||
|
||||
cleanupTimestampedStorage();
|
||||
|
||||
expect(localStorage.getItem(key)).toBe('false');
|
||||
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBe(recentTimestamp.toString());
|
||||
});
|
||||
|
||||
it('should not affect non-timestamped keys', () => {
|
||||
const regularKey = 'regular-key';
|
||||
localStorage.setItem(regularKey, 'value');
|
||||
|
||||
cleanupTimestampedStorage();
|
||||
|
||||
expect(localStorage.getItem(regularKey)).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateExistingEntries', () => {
|
||||
it('should add timestamps to existing timestamped keys', () => {
|
||||
const key1 = `${LocalStorageKeys.LAST_MCP_}convo-111`;
|
||||
const key2 = `${LocalStorageKeys.PIN_MCP_}convo-222`;
|
||||
|
||||
localStorage.setItem(key1, '["mcp1", "mcp2"]');
|
||||
localStorage.setItem(key2, 'true');
|
||||
|
||||
migrateExistingEntries();
|
||||
|
||||
expect(localStorage.getItem(`${key1}_TIMESTAMP`)).toBeTruthy();
|
||||
expect(localStorage.getItem(`${key2}_TIMESTAMP`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not overwrite existing timestamps', () => {
|
||||
const key = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}convo-333`;
|
||||
const existingTimestamp = '1234567890';
|
||||
|
||||
localStorage.setItem(key, 'true');
|
||||
localStorage.setItem(`${key}_TIMESTAMP`, existingTimestamp);
|
||||
|
||||
migrateExistingEntries();
|
||||
|
||||
expect(localStorage.getItem(`${key}_TIMESTAMP`)).toBe(existingTimestamp);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ export * from './roles';
|
||||
export * from './localStorage';
|
||||
export * from './promptGroups';
|
||||
export * from './email';
|
||||
export * from './timestamps';
|
||||
export { default as cn } from './cn';
|
||||
export { default as logger } from './logger';
|
||||
export { default as scaleImage } from './scaleImage';
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { LocalStorageKeys } from 'librechat-data-provider';
|
||||
|
||||
/** Suffix for timestamp entries */
|
||||
const TIMESTAMP_SUFFIX = '_TIMESTAMP';
|
||||
|
||||
/** Duration in milliseconds (2 days) */
|
||||
const CLEANUP_THRESHOLD = 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Storage keys that should be cleaned up based on timestamps
|
||||
* These are conversation-specific keys that can accumulate over time
|
||||
*/
|
||||
const TIMESTAMPED_KEYS = [
|
||||
LocalStorageKeys.LAST_MCP_,
|
||||
LocalStorageKeys.LAST_CODE_TOGGLE_,
|
||||
LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_,
|
||||
LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_,
|
||||
LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_,
|
||||
LocalStorageKeys.PIN_MCP_,
|
||||
];
|
||||
|
||||
/**
|
||||
* Set only a timestamp for a key (when the value is handled elsewhere)
|
||||
*/
|
||||
export function setTimestamp(key: string): void {
|
||||
localStorage.setItem(`${key}${TIMESTAMP_SUFFIX}`, Date.now().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in localStorage with an associated timestamp
|
||||
*/
|
||||
export function setTimestampedValue(key: string, value: any): void {
|
||||
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
localStorage.setItem(`${key}${TIMESTAMP_SUFFIX}`, Date.now().toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from localStorage, checking if it has a valid timestamp
|
||||
* Returns null if the value is too old or has no timestamp
|
||||
*/
|
||||
export function getTimestampedValue(key: string): string | null {
|
||||
const timestampKey = `${key}${TIMESTAMP_SUFFIX}`;
|
||||
const timestamp = localStorage.getItem(timestampKey);
|
||||
|
||||
if (!timestamp) {
|
||||
// No timestamp exists, return the value but it will be cleaned up on next startup
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
const age = Date.now() - parseInt(timestamp, 10);
|
||||
if (age > CLEANUP_THRESHOLD) {
|
||||
// Value is too old, clean it up
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem(timestampKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a value and its timestamp from localStorage
|
||||
*/
|
||||
export function removeTimestampedValue(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem(`${key}${TIMESTAMP_SUFFIX}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old localStorage entries based on timestamps
|
||||
* This should be called on app startup
|
||||
*/
|
||||
export function cleanupTimestampedStorage(): void {
|
||||
try {
|
||||
const keysToRemove: string[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
// Iterate through all localStorage keys
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key) continue;
|
||||
if (key === LocalStorageKeys.PIN_MCP_) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this key should be timestamped
|
||||
const isTimestampedKey = TIMESTAMPED_KEYS.some((prefix) => key.startsWith(prefix));
|
||||
|
||||
if (isTimestampedKey && !key.endsWith(TIMESTAMP_SUFFIX)) {
|
||||
const timestampKey = `${key}${TIMESTAMP_SUFFIX}`;
|
||||
const timestamp = localStorage.getItem(timestampKey);
|
||||
|
||||
if (!timestamp) {
|
||||
// No timestamp exists for a key that should have one - mark for cleanup
|
||||
keysToRemove.push(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
const age = now - parseInt(timestamp, 10);
|
||||
if (age > CLEANUP_THRESHOLD) {
|
||||
// Entry is too old - mark for cleanup
|
||||
keysToRemove.push(key);
|
||||
keysToRemove.push(timestampKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
|
||||
if (keysToRemove.length > 0) {
|
||||
console.log(`Cleaned up ${keysToRemove.length} old localStorage entries`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during cleanup of timestamped storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration function to add timestamps to existing entries
|
||||
* This ensures existing entries don't get immediately cleaned up
|
||||
*/
|
||||
export function migrateExistingEntries(): void {
|
||||
const now = Date.now().toString();
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (!key) continue;
|
||||
|
||||
const isTimestampedKey = TIMESTAMPED_KEYS.some((prefix) => key.startsWith(prefix));
|
||||
|
||||
if (isTimestampedKey && !key.endsWith(TIMESTAMP_SUFFIX)) {
|
||||
const timestampKey = `${key}${TIMESTAMP_SUFFIX}`;
|
||||
const hasTimestamp = localStorage.getItem(timestampKey);
|
||||
|
||||
if (!hasTimestamp) {
|
||||
// Add timestamp to existing entry
|
||||
localStorage.setItem(timestampKey, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,116 +102,97 @@ export default defineConfig(({ command }) => ({
|
||||
preserveEntrySignatures: 'strict',
|
||||
output: {
|
||||
manualChunks(id: string) {
|
||||
const normalizedId = id.replace(/\\/g, '/');
|
||||
if (normalizedId.includes('node_modules')) {
|
||||
if (id.includes('node_modules')) {
|
||||
// High-impact chunking for large libraries
|
||||
if (normalizedId.includes('@codesandbox/sandpack')) {
|
||||
if (id.includes('@codesandbox/sandpack')) {
|
||||
return 'sandpack';
|
||||
}
|
||||
if (normalizedId.includes('react-virtualized')) {
|
||||
if (id.includes('react-virtualized')) {
|
||||
return 'virtualization';
|
||||
}
|
||||
if (normalizedId.includes('i18next') || normalizedId.includes('react-i18next')) {
|
||||
if (id.includes('i18next') || id.includes('react-i18next')) {
|
||||
return 'i18n';
|
||||
}
|
||||
if (normalizedId.includes('lodash')) {
|
||||
if (id.includes('lodash')) {
|
||||
return 'utilities';
|
||||
}
|
||||
if (normalizedId.includes('date-fns')) {
|
||||
if (id.includes('date-fns')) {
|
||||
return 'date-utils';
|
||||
}
|
||||
if (normalizedId.includes('@dicebear')) {
|
||||
if (id.includes('@dicebear')) {
|
||||
return 'avatars';
|
||||
}
|
||||
if (normalizedId.includes('react-dnd') || normalizedId.includes('react-flip-toolkit')) {
|
||||
if (id.includes('react-dnd') || id.includes('react-flip-toolkit')) {
|
||||
return 'react-interactions';
|
||||
}
|
||||
if (normalizedId.includes('react-hook-form')) {
|
||||
if (id.includes('react-hook-form')) {
|
||||
return 'forms';
|
||||
}
|
||||
if (normalizedId.includes('react-router-dom')) {
|
||||
if (id.includes('react-router-dom')) {
|
||||
return 'routing';
|
||||
}
|
||||
if (
|
||||
normalizedId.includes('qrcode.react') ||
|
||||
normalizedId.includes('@marsidev/react-turnstile')
|
||||
) {
|
||||
if (id.includes('qrcode.react') || id.includes('@marsidev/react-turnstile')) {
|
||||
return 'security-ui';
|
||||
}
|
||||
|
||||
if (normalizedId.includes('@codemirror/view')) {
|
||||
if (id.includes('@codemirror/view')) {
|
||||
return 'codemirror-view';
|
||||
}
|
||||
if (normalizedId.includes('@codemirror/state')) {
|
||||
if (id.includes('@codemirror/state')) {
|
||||
return 'codemirror-state';
|
||||
}
|
||||
if (normalizedId.includes('@codemirror/language')) {
|
||||
if (id.includes('@codemirror/language')) {
|
||||
return 'codemirror-language';
|
||||
}
|
||||
if (normalizedId.includes('@codemirror')) {
|
||||
if (id.includes('@codemirror')) {
|
||||
return 'codemirror-core';
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedId.includes('react-markdown') ||
|
||||
normalizedId.includes('remark-') ||
|
||||
normalizedId.includes('rehype-')
|
||||
) {
|
||||
if (id.includes('react-markdown') || id.includes('remark-') || id.includes('rehype-')) {
|
||||
return 'markdown-processing';
|
||||
}
|
||||
if (normalizedId.includes('monaco-editor') || normalizedId.includes('@monaco-editor')) {
|
||||
if (id.includes('monaco-editor') || id.includes('@monaco-editor')) {
|
||||
return 'code-editor';
|
||||
}
|
||||
if (normalizedId.includes('react-window') || normalizedId.includes('react-virtual')) {
|
||||
if (id.includes('react-window') || id.includes('react-virtual')) {
|
||||
return 'virtualization';
|
||||
}
|
||||
if (
|
||||
normalizedId.includes('zod') ||
|
||||
normalizedId.includes('yup') ||
|
||||
normalizedId.includes('joi')
|
||||
) {
|
||||
if (id.includes('zod') || id.includes('yup') || id.includes('joi')) {
|
||||
return 'validation';
|
||||
}
|
||||
if (
|
||||
normalizedId.includes('axios') ||
|
||||
normalizedId.includes('ky') ||
|
||||
normalizedId.includes('fetch')
|
||||
) {
|
||||
if (id.includes('axios') || id.includes('ky') || id.includes('fetch')) {
|
||||
return 'http-client';
|
||||
}
|
||||
if (
|
||||
normalizedId.includes('react-spring') ||
|
||||
normalizedId.includes('react-transition-group')
|
||||
) {
|
||||
if (id.includes('react-spring') || id.includes('react-transition-group')) {
|
||||
return 'animations';
|
||||
}
|
||||
if (normalizedId.includes('react-select') || normalizedId.includes('downshift')) {
|
||||
if (id.includes('react-select') || id.includes('downshift')) {
|
||||
return 'advanced-inputs';
|
||||
}
|
||||
if (normalizedId.includes('heic-to')) {
|
||||
if (id.includes('heic-to')) {
|
||||
return 'heic-converter';
|
||||
}
|
||||
|
||||
// Existing chunks
|
||||
if (normalizedId.includes('@radix-ui')) {
|
||||
if (id.includes('@radix-ui')) {
|
||||
return 'radix-ui';
|
||||
}
|
||||
if (normalizedId.includes('framer-motion')) {
|
||||
if (id.includes('framer-motion')) {
|
||||
return 'framer-motion';
|
||||
}
|
||||
if (normalizedId.includes('node_modules/highlight.js')) {
|
||||
if (id.includes('node_modules/highlight.js')) {
|
||||
return 'markdown_highlight';
|
||||
}
|
||||
if (normalizedId.includes('katex') || normalizedId.includes('node_modules/katex')) {
|
||||
if (id.includes('katex') || id.includes('node_modules/katex')) {
|
||||
return 'math-katex';
|
||||
}
|
||||
if (normalizedId.includes('node_modules/hast-util-raw')) {
|
||||
if (id.includes('node_modules/hast-util-raw')) {
|
||||
return 'markdown_large';
|
||||
}
|
||||
if (normalizedId.includes('@tanstack')) {
|
||||
if (id.includes('@tanstack')) {
|
||||
return 'tanstack-vendor';
|
||||
}
|
||||
if (normalizedId.includes('@headlessui')) {
|
||||
if (id.includes('@headlessui')) {
|
||||
return 'headlessui';
|
||||
}
|
||||
|
||||
@@ -219,7 +200,7 @@ export default defineConfig(({ command }) => ({
|
||||
return 'vendor';
|
||||
}
|
||||
// Create a separate chunk for all locale files under src/locales.
|
||||
if (normalizedId.includes('/src/locales/')) {
|
||||
if (id.includes(path.join('src', 'locales'))) {
|
||||
return 'locales';
|
||||
}
|
||||
// Let Rollup decide automatically for any other files.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const path = require('path');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ensureRequiredCollectionsExist } = require('@librechat/api');
|
||||
const { AccessRoleIds, ResourceType, PrincipalType } = require('librechat-data-provider');
|
||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||
|
||||
@@ -17,11 +16,36 @@ async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100
|
||||
|
||||
logger.info('Starting Enhanced Agent Permissions Migration', { dryRun, batchSize });
|
||||
|
||||
/** Ensurse `aclentries` collection exists for DocumentDB compatibility
|
||||
* @param {import('mongoose').mongo.Db} db
|
||||
* @param {string} collectionName
|
||||
*/
|
||||
async function ensureCollectionExists(db, collectionName) {
|
||||
try {
|
||||
const collections = await db.listCollections({ name: collectionName }).toArray();
|
||||
if (collections.length === 0) {
|
||||
await db.createCollection(collectionName);
|
||||
logger.info(`Created collection: ${collectionName}`);
|
||||
} else {
|
||||
logger.info(`Collection already exists: ${collectionName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`'Failed to check/create "${collectionName}" collection:`, error);
|
||||
// If listCollections fails, try alternative approach
|
||||
try {
|
||||
// Try to access the collection directly - this will create it in MongoDB if it doesn't exist
|
||||
await db.collection(collectionName).findOne({}, { projection: { _id: 1 } });
|
||||
} catch (createError) {
|
||||
logger.error(`Could not ensure collection ${collectionName} exists:`, createError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
/** @type {import('mongoose').mongo.Db | undefined} */
|
||||
const db = mongoose.connection.db;
|
||||
if (db) {
|
||||
await ensureRequiredCollectionsExist(db);
|
||||
await ensureCollectionExists(db, 'aclentries');
|
||||
}
|
||||
|
||||
// Verify required roles exist
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const path = require('path');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ensureRequiredCollectionsExist } = require('@librechat/api');
|
||||
const { AccessRoleIds, ResourceType, PrincipalType } = require('librechat-data-provider');
|
||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||
|
||||
@@ -17,11 +16,36 @@ async function migrateToPromptGroupPermissions({ dryRun = true, batchSize = 100
|
||||
|
||||
logger.info('Starting PromptGroup Permissions Migration', { dryRun, batchSize });
|
||||
|
||||
/** Ensurse `aclentries` collection exists for DocumentDB compatibility
|
||||
* @param {import('mongoose').mongo.Db} db
|
||||
* @param {string} collectionName
|
||||
*/
|
||||
async function ensureCollectionExists(db, collectionName) {
|
||||
try {
|
||||
const collections = await db.listCollections({ name: collectionName }).toArray();
|
||||
if (collections.length === 0) {
|
||||
await db.createCollection(collectionName);
|
||||
logger.info(`Created collection: ${collectionName}`);
|
||||
} else {
|
||||
logger.info(`Collection already exists: ${collectionName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`'Failed to check/create "${collectionName}" collection:`, error);
|
||||
// If listCollections fails, try alternative approach
|
||||
try {
|
||||
// Try to access the collection directly - this will create it in MongoDB if it doesn't exist
|
||||
await db.collection(collectionName).findOne({}, { projection: { _id: 1 } });
|
||||
} catch (createError) {
|
||||
logger.error(`Could not ensure collection ${collectionName} exists:`, createError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
/** @type {import('mongoose').mongo.Db | undefined} */
|
||||
const db = mongoose.connection.db;
|
||||
if (db) {
|
||||
await ensureRequiredCollectionsExist(db);
|
||||
await ensureCollectionExists(db, 'aclentries');
|
||||
}
|
||||
|
||||
// Verify required roles exist
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// v0.8.0-rc4
|
||||
// v0.8.0-rc3
|
||||
// See .env.test.example for an example of the '.env.test' file.
|
||||
require('dotenv').config({ path: './e2e/.env.test' });
|
||||
|
||||
@@ -57,7 +57,7 @@ spec:
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.postgresql.auth.existingSecret }}
|
||||
key: {{ .Values.postgresql.auth.secretKeys.userPasswordKey }}
|
||||
key: {{ .Values.postgresql.auth.existingSecretKey }}
|
||||
{{- end }}
|
||||
{{- if .Values.global.librechat.existingSecretName }}
|
||||
- name: OPENAI_API_KEY
|
||||
|
||||
@@ -14,7 +14,6 @@ image:
|
||||
# Overrides the image tag whose default is the chart appVersion.
|
||||
tag: latest
|
||||
|
||||
# See https://github.com/bitnami/charts/blob/main/bitnami/postgresql/values.yaml for more details.
|
||||
postgresql:
|
||||
enabled: true
|
||||
# nameOverride: vectordb
|
||||
@@ -28,10 +27,7 @@ postgresql:
|
||||
# define a secret with values for "postgres-password", "password" (user Password) and "replication-password" or add values directly
|
||||
existingSecret: librechat-vectordb
|
||||
# postgres-password is needed to enable pgvector extension. If you enable it manually you can use "password" and user "librechat"
|
||||
secretKeys:
|
||||
userPasswordKey: postgres-password
|
||||
adminPasswordKey: postgres-password
|
||||
replicationPasswordKey: postgres-password
|
||||
existingSecretKey: postgres-password
|
||||
|
||||
imagePullSecrets: []
|
||||
nameOverride: ''
|
||||
|
||||
@@ -23,7 +23,7 @@ version: 1.8.10
|
||||
# It is recommended to use it with quotes.
|
||||
|
||||
# renovate: image=ghcr.io/danny-avila/librechat
|
||||
appVersion: "v0.8.0-rc4"
|
||||
appVersion: "v0.8.0-rc3"
|
||||
|
||||
home: https://www.librechat.ai
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ spec:
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "librechat.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "librechat.serviceAccountName" . }}
|
||||
labels:
|
||||
{{- include "librechat.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
|
||||
{{- end }}
|
||||
@@ -115,17 +115,6 @@ imagePullSecrets: []
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# Automatically mount a ServiceAccount's API credentials?
|
||||
automount: true
|
||||
# Annotations to add to the service account
|
||||
annotations: {}
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name: ""
|
||||
|
||||
lifecycle: {}
|
||||
# # base for adding a custom banner // see https://github.com/danny-avila/LibreChat/pull/3952 for an example
|
||||
|
||||
@@ -350,24 +350,6 @@ endpoints:
|
||||
# # See the Custom Configuration Guide for more information on Assistants Config:
|
||||
# # https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/assistants_endpoint
|
||||
|
||||
# Web Search Configuration (optional)
|
||||
# webSearch:
|
||||
# # Jina Reranking Configuration
|
||||
# jinaApiKey: '${JINA_API_KEY}' # Your Jina API key
|
||||
# jinaApiUrl: '${JINA_API_URL}' # Custom Jina API URL (optional, defaults to https://api.jina.ai/v1/rerank)
|
||||
#
|
||||
# # Other rerankers
|
||||
# cohereApiKey: '${COHERE_API_KEY}'
|
||||
#
|
||||
# # Search providers
|
||||
# serperApiKey: '${SERPER_API_KEY}'
|
||||
# searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}'
|
||||
# searxngApiKey: '${SEARXNG_API_KEY}'
|
||||
#
|
||||
# # Content scrapers
|
||||
# firecrawlApiKey: '${FIRECRAWL_API_KEY}'
|
||||
# firecrawlApiUrl: '${FIRECRAWL_API_URL}'
|
||||
|
||||
# Memory configuration for user memories
|
||||
# memory:
|
||||
# # (optional) Disable memory functionality
|
||||
|
||||
726
package-lock.json
generated
726
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "LibreChat",
|
||||
"version": "v0.8.0-rc4",
|
||||
"version": "v0.8.0-rc3",
|
||||
"description": "",
|
||||
"workspaces": [
|
||||
"api",
|
||||
@@ -100,7 +100,6 @@
|
||||
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/react-virtualized": "^9.22.0",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
"cross-env": "^7.0.3",
|
||||
"elliptic": "^6.6.1",
|
||||
"eslint": "^9.20.1",
|
||||
@@ -125,7 +124,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@langchain/openai": "0.5.18",
|
||||
"axios": "1.12.1",
|
||||
"axios": "1.8.2",
|
||||
"elliptic": "^6.6.1",
|
||||
"form-data": "^4.0.4",
|
||||
"mdast-util-gfm-autolink-literal": "2.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/api",
|
||||
"version": "1.4.0",
|
||||
"version": "1.3.4",
|
||||
"type": "commonjs",
|
||||
"description": "MCP services for LibreChat",
|
||||
"main": "dist/index.js",
|
||||
@@ -64,6 +64,7 @@
|
||||
"mongoose": "^8.12.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"rollup": "^4.22.4",
|
||||
"rollup-plugin-generate-package-json": "^3.2.0",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.0.4"
|
||||
@@ -73,10 +74,10 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@librechat/agents": "^2.4.79",
|
||||
"@librechat/agents": "^2.4.76",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"axios": "^1.12.1",
|
||||
"axios": "^1.8.2",
|
||||
"diff": "^7.0.0",
|
||||
"eventsource": "^3.0.2",
|
||||
"express": "^4.21.2",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { AccessRoleIds, ResourceType, PrincipalType, Constants } from 'librechat-data-provider';
|
||||
import { ensureRequiredCollectionsExist } from '../db/utils';
|
||||
import type { AccessRoleMethods, IAgent } from '@librechat/data-schemas';
|
||||
import type { Model, Mongoose } from 'mongoose';
|
||||
import type { Model, Mongoose, mongo } from 'mongoose';
|
||||
|
||||
const { GLOBAL_PROJECT_NAME } = Constants;
|
||||
|
||||
@@ -55,9 +54,31 @@ export async function checkAgentPermissionsMigration({
|
||||
logger.debug('Checking if agent permissions migration is needed');
|
||||
|
||||
try {
|
||||
/** Ensurse `aclentries` collection exists for DocumentDB compatibility */
|
||||
async function ensureCollectionExists(db: mongo.Db, collectionName: string) {
|
||||
try {
|
||||
const collections = await db.listCollections({ name: collectionName }).toArray();
|
||||
if (collections.length === 0) {
|
||||
await db.createCollection(collectionName);
|
||||
logger.info(`Created collection: ${collectionName}`);
|
||||
} else {
|
||||
logger.debug(`Collection already exists: ${collectionName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`'Failed to check/create "${collectionName}" collection:`, error);
|
||||
// If listCollections fails, try alternative approach
|
||||
try {
|
||||
// Try to access the collection directly - this will create it in MongoDB if it doesn't exist
|
||||
await db.collection(collectionName).findOne({}, { projection: { _id: 1 } });
|
||||
} catch (createError) {
|
||||
logger.error(`Could not ensure collection ${collectionName} exists:`, createError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const db = mongoose.connection.db;
|
||||
if (db) {
|
||||
await ensureRequiredCollectionsExist(db);
|
||||
await ensureCollectionExists(db, 'aclentries');
|
||||
}
|
||||
|
||||
// Verify required roles exist
|
||||
|
||||
@@ -7,6 +7,10 @@ import { loadDefaultInterface } from './interface';
|
||||
const mockUpdateAccessPermissions = jest.fn();
|
||||
const mockGetRoleByName = jest.fn();
|
||||
|
||||
jest.mock('~/memory', () => ({
|
||||
isMemoryEnabled: jest.fn((config) => config?.enable === true),
|
||||
}));
|
||||
|
||||
describe('updateInterfacePermissions - permissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -54,9 +58,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -83,9 +84,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -162,9 +160,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: false,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
||||
@@ -191,9 +186,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: false,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: false },
|
||||
@@ -270,9 +262,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -299,9 +288,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -391,9 +377,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -420,9 +403,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -499,9 +479,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -528,9 +505,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -613,9 +587,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -636,9 +607,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -734,9 +702,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
// All other permissions that don't exist in the database
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -760,9 +725,6 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
// All other permissions that don't exist in the database
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
@@ -964,17 +926,11 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
// Check MEMORIES permissions use role defaults
|
||||
expect(userCall[1][PermissionTypes.MEMORIES]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
});
|
||||
|
||||
expect(adminCall[1][PermissionTypes.MEMORIES]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: undefined,
|
||||
});
|
||||
});
|
||||
@@ -1200,202 +1156,4 @@ describe('updateInterfacePermissions - permissions', () => {
|
||||
expect(userCall[1]).toHaveProperty(PermissionTypes.AGENTS);
|
||||
expect(userCall[1]).toHaveProperty(PermissionTypes.MULTI_CONVO);
|
||||
});
|
||||
|
||||
it('should disable all memory permissions when memory.disabled is true', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
// Even if memories is not explicitly set to false in interface
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
},
|
||||
memory: {
|
||||
disabled: true,
|
||||
// Other memory config doesn't matter when disabled
|
||||
agent: {
|
||||
id: 'test-agent-id',
|
||||
},
|
||||
personalize: true,
|
||||
} as unknown as TCustomConfig['memory'],
|
||||
};
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
memories: true, // Default is true
|
||||
},
|
||||
} as TConfigDefaults;
|
||||
const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
|
||||
const appConfig = { config, interfaceConfig } as unknown as AppConfig;
|
||||
|
||||
await updateInterfacePermissions({
|
||||
appConfig,
|
||||
getRoleByName: mockGetRoleByName,
|
||||
updateAccessPermissions: mockUpdateAccessPermissions,
|
||||
});
|
||||
|
||||
const expectedMemoryPermissions = {
|
||||
[Permissions.USE]: false,
|
||||
[Permissions.CREATE]: false,
|
||||
[Permissions.READ]: false,
|
||||
[Permissions.UPDATE]: false,
|
||||
[Permissions.OPT_OUT]: false, // Even OPT_OUT should be false when memory is disabled
|
||||
};
|
||||
|
||||
// Check USER role call
|
||||
const userCall = mockUpdateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.USER,
|
||||
);
|
||||
expect(userCall[1][PermissionTypes.MEMORIES]).toEqual(expectedMemoryPermissions);
|
||||
|
||||
// Check ADMIN role call
|
||||
const adminCall = mockUpdateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.ADMIN,
|
||||
);
|
||||
expect(adminCall[1][PermissionTypes.MEMORIES]).toEqual(expectedMemoryPermissions);
|
||||
});
|
||||
|
||||
it('should enable memory permissions based on role defaults when memory is configured without disabled flag', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
memories: true,
|
||||
},
|
||||
memory: {
|
||||
// Memory is configured with an agent but not disabled
|
||||
agent: {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
},
|
||||
personalize: true,
|
||||
} as unknown as TCustomConfig['memory'],
|
||||
};
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
memories: true,
|
||||
},
|
||||
} as TConfigDefaults;
|
||||
const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
|
||||
const appConfig = { config, interfaceConfig } as unknown as AppConfig;
|
||||
|
||||
await updateInterfacePermissions({
|
||||
appConfig,
|
||||
getRoleByName: mockGetRoleByName,
|
||||
updateAccessPermissions: mockUpdateAccessPermissions,
|
||||
});
|
||||
|
||||
// Check USER role call - should use role defaults for non-USE permissions
|
||||
const userCall = mockUpdateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.USER,
|
||||
);
|
||||
expect(userCall[1][PermissionTypes.MEMORIES]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]:
|
||||
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.MEMORIES]?.[Permissions.CREATE],
|
||||
[Permissions.READ]:
|
||||
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.MEMORIES]?.[Permissions.READ],
|
||||
[Permissions.UPDATE]:
|
||||
roleDefaults[SystemRoles.USER].permissions[PermissionTypes.MEMORIES]?.[Permissions.UPDATE],
|
||||
[Permissions.OPT_OUT]: true, // Should be true when personalize is enabled
|
||||
});
|
||||
|
||||
// Check ADMIN role call
|
||||
const adminCall = mockUpdateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.ADMIN,
|
||||
);
|
||||
expect(adminCall[1][PermissionTypes.MEMORIES]).toEqual({
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]:
|
||||
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.MEMORIES]?.[Permissions.CREATE],
|
||||
[Permissions.READ]:
|
||||
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.MEMORIES]?.[Permissions.READ],
|
||||
[Permissions.UPDATE]:
|
||||
roleDefaults[SystemRoles.ADMIN].permissions[PermissionTypes.MEMORIES]?.[Permissions.UPDATE],
|
||||
[Permissions.OPT_OUT]: true, // Should be true when personalize is enabled
|
||||
});
|
||||
});
|
||||
|
||||
it('should override existing memory permissions when memory.disabled is true', async () => {
|
||||
// Mock existing memory permissions that are enabled
|
||||
mockGetRoleByName.mockResolvedValue({
|
||||
permissions: {
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.READ]: true,
|
||||
[Permissions.UPDATE]: true,
|
||||
[Permissions.OPT_OUT]: true,
|
||||
},
|
||||
// Other existing permissions
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
|
||||
},
|
||||
});
|
||||
|
||||
const config = {
|
||||
interface: {
|
||||
// Not explicitly configuring memories in interface
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
},
|
||||
memory: {
|
||||
disabled: true, // Memory is explicitly disabled
|
||||
agent: {
|
||||
id: 'test-agent-id',
|
||||
},
|
||||
personalize: true,
|
||||
} as unknown as TCustomConfig['memory'],
|
||||
};
|
||||
const configDefaults = {
|
||||
interface: {
|
||||
memories: true, // Default would be true
|
||||
prompts: true,
|
||||
bookmarks: true,
|
||||
},
|
||||
} as TConfigDefaults;
|
||||
const interfaceConfig = await loadDefaultInterface({ config, configDefaults });
|
||||
const appConfig = { config, interfaceConfig } as unknown as AppConfig;
|
||||
|
||||
await updateInterfacePermissions({
|
||||
appConfig,
|
||||
getRoleByName: mockGetRoleByName,
|
||||
updateAccessPermissions: mockUpdateAccessPermissions,
|
||||
});
|
||||
|
||||
const expectedMemoryPermissions = {
|
||||
[Permissions.USE]: false,
|
||||
[Permissions.CREATE]: false,
|
||||
[Permissions.READ]: false,
|
||||
[Permissions.UPDATE]: false,
|
||||
[Permissions.OPT_OUT]: false,
|
||||
};
|
||||
|
||||
// Check USER role call
|
||||
const userCall = mockUpdateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.USER,
|
||||
);
|
||||
// Memory permissions should be updated even though they already exist
|
||||
expect(userCall[1][PermissionTypes.MEMORIES]).toEqual(expectedMemoryPermissions);
|
||||
// Prompts should be updated (explicitly configured)
|
||||
expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true });
|
||||
// Bookmarks should be updated (explicitly configured)
|
||||
expect(userCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true });
|
||||
|
||||
// Check ADMIN role call
|
||||
const adminCall = mockUpdateAccessPermissions.mock.calls.find(
|
||||
(call) => call[0] === SystemRoles.ADMIN,
|
||||
);
|
||||
// Memory permissions should be updated even though they already exist
|
||||
expect(adminCall[1][PermissionTypes.MEMORIES]).toEqual(expectedMemoryPermissions);
|
||||
expect(adminCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true });
|
||||
expect(adminCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true });
|
||||
|
||||
// Verify the existing role data was passed to updateAccessPermissions
|
||||
expect(userCall[2]).toMatchObject({
|
||||
permissions: expect.objectContaining({
|
||||
[PermissionTypes.MEMORIES]: expect.any(Object),
|
||||
}),
|
||||
});
|
||||
expect(adminCall[2]).toMatchObject({
|
||||
permissions: expect.objectContaining({
|
||||
[PermissionTypes.MEMORIES]: expect.any(Object),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,8 +69,6 @@ export async function updateInterfacePermissions({
|
||||
const interfaceConfig = appConfig?.config?.interface;
|
||||
const memoryConfig = appConfig?.config?.memory;
|
||||
const memoryEnabled = isMemoryEnabled(memoryConfig);
|
||||
/** Check if memory is explicitly disabled */
|
||||
const isMemoryExplicitlyDisabled = memoryConfig && !memoryEnabled;
|
||||
/** Check if personalization is enabled (defaults to true if memory is configured and enabled) */
|
||||
const isPersonalizationEnabled =
|
||||
memoryConfig && memoryEnabled && memoryConfig.personalize !== false;
|
||||
@@ -111,20 +109,14 @@ export async function updateInterfacePermissions({
|
||||
const permTypeExists = existingPermissions?.[permType];
|
||||
const isExplicitlyConfigured =
|
||||
interfaceConfig && hasExplicitConfig(interfaceConfig, permType);
|
||||
const isMemoryDisabled =
|
||||
permType === PermissionTypes.MEMORIES && isMemoryExplicitlyDisabled === true;
|
||||
|
||||
// Only update if: doesn't exist OR explicitly configured
|
||||
if (!permTypeExists || isExplicitlyConfigured || isMemoryDisabled) {
|
||||
if (!permTypeExists || isExplicitlyConfigured) {
|
||||
permissionsToUpdate[permType] = permissions;
|
||||
if (!permTypeExists) {
|
||||
logger.debug(`Role '${roleName}': Setting up default permissions for '${permType}'`);
|
||||
} else if (isExplicitlyConfigured) {
|
||||
logger.debug(`Role '${roleName}': Applying explicit config for '${permType}'`);
|
||||
} else if (isMemoryDisabled) {
|
||||
logger.debug(
|
||||
`Role '${roleName}': Disabling memories as it is explicitly disabled in config`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`Role '${roleName}': Preserving existing permissions for '${permType}'`);
|
||||
@@ -147,28 +139,11 @@ export async function updateInterfacePermissions({
|
||||
),
|
||||
},
|
||||
[PermissionTypes.MEMORIES]: {
|
||||
[Permissions.USE]: isMemoryExplicitlyDisabled
|
||||
? false
|
||||
: getPermissionValue(
|
||||
loadedInterface.memories,
|
||||
defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.USE],
|
||||
defaults.memories,
|
||||
),
|
||||
...(defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.CREATE] !== undefined && {
|
||||
[Permissions.CREATE]: isMemoryExplicitlyDisabled
|
||||
? false
|
||||
: defaultPerms[PermissionTypes.MEMORIES][Permissions.CREATE],
|
||||
}),
|
||||
...(defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.READ] !== undefined && {
|
||||
[Permissions.READ]: isMemoryExplicitlyDisabled
|
||||
? false
|
||||
: defaultPerms[PermissionTypes.MEMORIES][Permissions.READ],
|
||||
}),
|
||||
...(defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.UPDATE] !== undefined && {
|
||||
[Permissions.UPDATE]: isMemoryExplicitlyDisabled
|
||||
? false
|
||||
: defaultPerms[PermissionTypes.MEMORIES][Permissions.UPDATE],
|
||||
}),
|
||||
[Permissions.USE]: getPermissionValue(
|
||||
loadedInterface.memories,
|
||||
defaultPerms[PermissionTypes.MEMORIES]?.[Permissions.USE],
|
||||
defaults.memories,
|
||||
),
|
||||
[Permissions.OPT_OUT]: isPersonalizationEnabled,
|
||||
},
|
||||
[PermissionTypes.MULTI_CONVO]: {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import type { mongo } from 'mongoose';
|
||||
|
||||
/**
|
||||
* Ensures that a collection exists in the database.
|
||||
* For DocumentDB compatibility, it tries multiple approaches.
|
||||
* @param db - The MongoDB database instance
|
||||
* @param collectionName - The name of the collection to ensure exists
|
||||
*/
|
||||
export async function ensureCollectionExists(db: mongo.Db, collectionName: string): Promise<void> {
|
||||
try {
|
||||
const collections = await db.listCollections({ name: collectionName }).toArray();
|
||||
if (collections.length === 0) {
|
||||
await db.createCollection(collectionName);
|
||||
logger.info(`Created collection: ${collectionName}`);
|
||||
} else {
|
||||
logger.debug(`Collection already exists: ${collectionName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check/create "${collectionName}" collection:`, error);
|
||||
// If listCollections fails, try alternative approach
|
||||
try {
|
||||
// Try to access the collection directly - this will create it in MongoDB if it doesn't exist
|
||||
await db.collection(collectionName).findOne({}, { projection: { _id: 1 } });
|
||||
} catch (createError) {
|
||||
logger.error(`Could not ensure collection ${collectionName} exists:`, createError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that all required collections exist for the permission system.
|
||||
* This includes aclentries, groups, accessroles, and any other collections
|
||||
* needed for migrations and permission checks.
|
||||
* @param db - The MongoDB database instance
|
||||
*/
|
||||
export async function ensureRequiredCollectionsExist(db: mongo.Db): Promise<void> {
|
||||
const requiredCollections = [
|
||||
'aclentries', // ACL permission entries
|
||||
'groups', // User groups
|
||||
'accessroles', // Access roles for permissions
|
||||
'agents', // Agents collection
|
||||
'promptgroups', // Prompt groups collection
|
||||
'projects', // Projects collection
|
||||
];
|
||||
|
||||
logger.debug('Ensuring required collections exist for permission system');
|
||||
|
||||
for (const collectionName of requiredCollections) {
|
||||
await ensureCollectionExists(db, collectionName);
|
||||
}
|
||||
|
||||
logger.debug('All required collections have been checked/created');
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getLLMConfig } from './llm';
|
||||
import type * as t from '~/types';
|
||||
|
||||
jest.mock('https-proxy-agent', () => ({
|
||||
HttpsProxyAgent: jest.fn().mockImplementation((proxy) => ({ proxy })),
|
||||
@@ -241,7 +240,7 @@ describe('getLLMConfig', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-opus',
|
||||
maxOutputTokens: undefined,
|
||||
maxOutputTokens: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -328,7 +327,7 @@ describe('getLLMConfig', () => {
|
||||
it('should handle all nullish values removal', () => {
|
||||
const result = getLLMConfig('test-api-key', {
|
||||
modelOptions: {
|
||||
temperature: undefined,
|
||||
temperature: null,
|
||||
topP: undefined,
|
||||
topK: 0,
|
||||
stop: [],
|
||||
@@ -341,807 +340,4 @@ describe('getLLMConfig', () => {
|
||||
expect(result.llmConfig).toHaveProperty('stopSequences', []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real Usage Integration Tests', () => {
|
||||
describe('Initialize.js Simulation', () => {
|
||||
it('should handle basic Anthropic endpoint configuration like initialize.js', () => {
|
||||
// Simulate the configuration from Anthropic initialize.js
|
||||
const anthropicApiKey = 'sk-ant-api-key-123';
|
||||
const endpointOption = {
|
||||
model_parameters: {
|
||||
model: 'claude-3-5-sonnet-latest',
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
stop: ['\\n\\n', 'Human:', 'Assistant:'],
|
||||
stream: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Simulate clientOptions from initialize.js
|
||||
const clientOptions = {
|
||||
proxy: null,
|
||||
reverseProxyUrl: null,
|
||||
modelOptions: {
|
||||
...endpointOption.model_parameters,
|
||||
user: 'test-user-id-123',
|
||||
},
|
||||
streamRate: 25,
|
||||
titleModel: 'claude-3-haiku',
|
||||
};
|
||||
|
||||
const result = getLLMConfig(anthropicApiKey, clientOptions);
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
apiKey: anthropicApiKey,
|
||||
model: 'claude-3-5-sonnet-latest',
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
stopSequences: ['\\n\\n', 'Human:', 'Assistant:'],
|
||||
stream: true,
|
||||
invocationKwargs: {
|
||||
metadata: {
|
||||
user_id: 'test-user-id-123',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle Anthropic with proxy configuration like initialize.js', () => {
|
||||
const anthropicApiKey = 'sk-ant-proxy-key';
|
||||
const clientOptions = {
|
||||
proxy: 'http://corporate-proxy:8080',
|
||||
reverseProxyUrl: null,
|
||||
modelOptions: {
|
||||
model: 'claude-3-opus',
|
||||
temperature: 0.3,
|
||||
maxOutputTokens: 2048,
|
||||
user: 'proxy-user-456',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getLLMConfig(anthropicApiKey, clientOptions);
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
apiKey: anthropicApiKey,
|
||||
model: 'claude-3-opus',
|
||||
temperature: 0.3,
|
||||
maxTokens: 2048,
|
||||
invocationKwargs: {
|
||||
metadata: {
|
||||
user_id: 'proxy-user-456',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions).toHaveProperty('dispatcher');
|
||||
expect(result.llmConfig.clientOptions?.fetchOptions?.dispatcher.constructor.name).toBe(
|
||||
'ProxyAgent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Anthropic with reverse proxy like initialize.js', () => {
|
||||
const anthropicApiKey = 'sk-ant-reverse-proxy';
|
||||
const reverseProxyUrl = 'https://api.custom-anthropic.com/v1';
|
||||
const clientOptions = {
|
||||
proxy: null,
|
||||
reverseProxyUrl: reverseProxyUrl,
|
||||
modelOptions: {
|
||||
model: 'claude-3-5-haiku',
|
||||
temperature: 0.5,
|
||||
stream: false,
|
||||
user: 'reverse-proxy-user',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getLLMConfig(anthropicApiKey, clientOptions);
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
apiKey: anthropicApiKey,
|
||||
model: 'claude-3-5-haiku',
|
||||
temperature: 0.5,
|
||||
stream: false,
|
||||
anthropicApiUrl: reverseProxyUrl,
|
||||
});
|
||||
expect(result.llmConfig.clientOptions).toMatchObject({
|
||||
baseURL: reverseProxyUrl,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model-Specific Real Usage Scenarios', () => {
|
||||
it('should handle Claude-3.7 with thinking enabled like production', () => {
|
||||
const clientOptions = {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
temperature: 0.4,
|
||||
maxOutputTokens: 8192,
|
||||
topP: 0.95,
|
||||
topK: 50,
|
||||
thinking: true,
|
||||
thinkingBudget: 3000,
|
||||
promptCache: true,
|
||||
user: 'thinking-user-789',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getLLMConfig('sk-ant-thinking-key', clientOptions);
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'claude-3-7-sonnet',
|
||||
temperature: 0.4,
|
||||
maxTokens: 8192,
|
||||
stream: true, // default
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budget_tokens: 3000,
|
||||
},
|
||||
});
|
||||
// topP and topK should NOT be included for Claude-3.7 with thinking enabled
|
||||
expect(result.llmConfig).not.toHaveProperty('topP');
|
||||
expect(result.llmConfig).not.toHaveProperty('topK');
|
||||
// Should have appropriate headers for Claude-3.7 with prompt cache
|
||||
expect(result.llmConfig.clientOptions?.defaultHeaders).toEqual({
|
||||
'anthropic-beta':
|
||||
'token-efficient-tools-2025-02-19,output-128k-2025-02-19,prompt-caching-2024-07-31',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle web search functionality like production', () => {
|
||||
const clientOptions = {
|
||||
modelOptions: {
|
||||
model: 'claude-3-5-sonnet-latest',
|
||||
temperature: 0.6,
|
||||
maxOutputTokens: 4096,
|
||||
web_search: true,
|
||||
user: 'websearch-user-303',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getLLMConfig('sk-ant-websearch-key', clientOptions);
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'claude-3-5-sonnet-latest',
|
||||
temperature: 0.6,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
expect(result.tools).toEqual([
|
||||
{
|
||||
type: 'web_search_20250305',
|
||||
name: 'web_search',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Production-like Configuration Scenarios', () => {
|
||||
it('should handle complex production configuration', () => {
|
||||
const clientOptions = {
|
||||
proxy: 'http://prod-proxy.company.com:3128',
|
||||
reverseProxyUrl: 'https://anthropic-gateway.company.com/v1',
|
||||
modelOptions: {
|
||||
model: 'claude-3-opus-20240229',
|
||||
temperature: 0.2, // Conservative for production
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 10,
|
||||
stop: ['\\n\\nHuman:', '\\n\\nAssistant:', 'END_CONVERSATION'],
|
||||
stream: true,
|
||||
promptCache: true,
|
||||
user: 'prod-user-enterprise-404',
|
||||
},
|
||||
streamRate: 15, // Conservative stream rate
|
||||
titleModel: 'claude-3-haiku-20240307',
|
||||
};
|
||||
|
||||
const result = getLLMConfig('sk-ant-prod-enterprise-key', clientOptions);
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
apiKey: 'sk-ant-prod-enterprise-key',
|
||||
model: 'claude-3-opus-20240229',
|
||||
temperature: 0.2,
|
||||
maxTokens: 4096,
|
||||
topP: 0.95,
|
||||
topK: 10,
|
||||
stopSequences: ['\\n\\nHuman:', '\\n\\nAssistant:', 'END_CONVERSATION'],
|
||||
stream: true,
|
||||
anthropicApiUrl: 'https://anthropic-gateway.company.com/v1',
|
||||
invocationKwargs: {
|
||||
metadata: {
|
||||
user_id: 'prod-user-enterprise-404',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.llmConfig.clientOptions).toMatchObject({
|
||||
baseURL: 'https://anthropic-gateway.company.com/v1',
|
||||
fetchOptions: {
|
||||
dispatcher: expect.any(Object),
|
||||
},
|
||||
});
|
||||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple system options removal from modelOptions', () => {
|
||||
const modelOptions = {
|
||||
model: 'claude-3-5-sonnet',
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
// System options that should be removed
|
||||
thinking: true,
|
||||
promptCache: true,
|
||||
thinkingBudget: 2500,
|
||||
// Regular options that should remain
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
user: 'system-options-user',
|
||||
};
|
||||
|
||||
const clientOptions = {
|
||||
modelOptions,
|
||||
};
|
||||
|
||||
getLLMConfig('sk-ant-system-key', clientOptions);
|
||||
|
||||
// System options should be removed from original modelOptions
|
||||
expect(modelOptions).not.toHaveProperty('thinking');
|
||||
expect(modelOptions).not.toHaveProperty('promptCache');
|
||||
expect(modelOptions).not.toHaveProperty('thinkingBudget');
|
||||
// Regular options should remain
|
||||
expect(modelOptions).toHaveProperty('temperature', 0.7);
|
||||
expect(modelOptions).toHaveProperty('topP', 0.9);
|
||||
expect(modelOptions).toHaveProperty('topK', 40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Edge Cases from Real Usage', () => {
|
||||
it('should handle missing `user` ID string gracefully', () => {
|
||||
const clientOptions = {
|
||||
modelOptions: {
|
||||
model: 'claude-3-haiku',
|
||||
temperature: 0.5,
|
||||
// `user` is missing
|
||||
},
|
||||
};
|
||||
|
||||
const result = getLLMConfig('sk-ant-no-user-key', clientOptions);
|
||||
|
||||
expect(result.llmConfig.invocationKwargs?.metadata).toMatchObject({
|
||||
user_id: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle large parameter sets without performance issues', () => {
|
||||
const largeModelOptions: Record<string, string | number | boolean> = {
|
||||
model: 'claude-3-opus',
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 4096,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
user: 'performance-test-user',
|
||||
};
|
||||
|
||||
// Add many additional properties to test performance
|
||||
for (let i = 0; i < 100; i++) {
|
||||
largeModelOptions[`custom_param_${i}`] = `value_${i}`;
|
||||
}
|
||||
|
||||
const clientOptions = {
|
||||
modelOptions: largeModelOptions,
|
||||
proxy: 'http://performance-proxy:8080',
|
||||
reverseProxyUrl: 'https://performance-reverse-proxy.com',
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = getLLMConfig('sk-ant-performance-key', clientOptions);
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(endTime - startTime).toBeLessThan(50); // Should be very fast
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'claude-3-opus',
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle model name variations and edge cases', () => {
|
||||
const modelVariations = [
|
||||
'claude-3-7-sonnet',
|
||||
'claude-3.7-sonnet',
|
||||
'anthropic/claude-3-opus-20240229',
|
||||
'claude-sonnet-4-latest',
|
||||
'claude-3-5-sonnet-latest',
|
||||
];
|
||||
|
||||
modelVariations.forEach((model) => {
|
||||
const clientOptions = {
|
||||
modelOptions: {
|
||||
model,
|
||||
temperature: 0.5,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
thinking: true,
|
||||
promptCache: true,
|
||||
user: 'model-variation-user',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getLLMConfig('sk-ant-variation-key', clientOptions);
|
||||
|
||||
expect(result.llmConfig).toHaveProperty('model', model);
|
||||
expect(result.llmConfig).toHaveProperty('temperature', 0.5);
|
||||
// The specific behavior (thinking, topP/topK inclusion) depends on model pattern
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comprehensive Parameter Logic Tests', () => {
|
||||
describe('Default Values and Fallbacks', () => {
|
||||
it('should apply correct default values from anthropicSettings', () => {
|
||||
const result = getLLMConfig('test-key', { modelOptions: {} });
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'claude-3-5-sonnet-latest', // default model
|
||||
stream: true, // default stream
|
||||
maxTokens: 8192, // DEFAULT_MAX_OUTPUT for claude-3-5-sonnet
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle maxOutputTokens reset logic for different models', () => {
|
||||
const testCases = [
|
||||
{ model: 'claude-3-5-sonnet', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-3.5-sonnet-20241022', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-3-7-sonnet', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-3.7-sonnet-20250109', expectedMaxTokens: 8192 },
|
||||
{ model: 'claude-3-opus', expectedMaxTokens: 4096 },
|
||||
{ model: 'claude-3-haiku', expectedMaxTokens: 4096 },
|
||||
{ model: 'claude-2.1', expectedMaxTokens: 4096 },
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, expectedMaxTokens }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model, maxOutputTokens: null }, // Force reset
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(expectedMaxTokens);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle system options defaults correctly', () => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
// Don't specify thinking, promptCache, thinkingBudget - should use defaults
|
||||
},
|
||||
});
|
||||
|
||||
// Should have thinking enabled by default for claude-3-7
|
||||
expect(result.llmConfig.thinking).toMatchObject({
|
||||
type: 'enabled',
|
||||
budget_tokens: 2000, // default thinkingBudget
|
||||
});
|
||||
// Should have prompt cache headers by default
|
||||
expect(result.llmConfig.clientOptions?.defaultHeaders).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter Boundary and Validation Logic', () => {
|
||||
it('should handle temperature boundary values', () => {
|
||||
const testCases = [
|
||||
{ temperature: 0, expected: 0 }, // min
|
||||
{ temperature: 1, expected: 1 }, // max
|
||||
{ temperature: 0.5, expected: 0.5 }, // middle
|
||||
{ temperature: -0.1, expected: -0.1 }, // below min (should pass through)
|
||||
{ temperature: 1.1, expected: 1.1 }, // above max (should pass through)
|
||||
];
|
||||
|
||||
testCases.forEach(({ temperature, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { temperature },
|
||||
});
|
||||
expect(result.llmConfig.temperature).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle topP boundary values', () => {
|
||||
const testCases = [
|
||||
{ topP: 0, expected: 0 }, // min
|
||||
{ topP: 1, expected: 1 }, // max
|
||||
{ topP: 0.7, expected: 0.7 }, // default
|
||||
{ topP: -0.1, expected: -0.1 }, // below min
|
||||
{ topP: 1.1, expected: 1.1 }, // above max
|
||||
];
|
||||
|
||||
testCases.forEach(({ topP, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model: 'claude-3-opus', topP },
|
||||
});
|
||||
expect(result.llmConfig.topP).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle topK boundary values', () => {
|
||||
const testCases = [
|
||||
{ topK: 1, expected: 1 }, // min
|
||||
{ topK: 40, expected: 40 }, // max
|
||||
{ topK: 5, expected: 5 }, // default
|
||||
{ topK: 0, expected: 0 }, // below min
|
||||
{ topK: 50, expected: 50 }, // above max
|
||||
];
|
||||
|
||||
testCases.forEach(({ topK, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model: 'claude-3-opus', topK },
|
||||
});
|
||||
expect(result.llmConfig.topK).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle maxOutputTokens boundary values', () => {
|
||||
const testCases = [
|
||||
{ model: 'claude-3-opus', maxOutputTokens: 1, expected: 1 }, // min
|
||||
{ model: 'claude-3-opus', maxOutputTokens: 4096, expected: 4096 }, // max for legacy
|
||||
{ model: 'claude-3-5-sonnet', maxOutputTokens: 1, expected: 1 }, // min
|
||||
{ model: 'claude-3-5-sonnet', maxOutputTokens: 200000, expected: 200000 }, // max for new
|
||||
{ model: 'claude-3-7-sonnet', maxOutputTokens: 8192, expected: 8192 }, // default
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, maxOutputTokens, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model, maxOutputTokens },
|
||||
});
|
||||
expect(result.llmConfig.maxTokens).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle thinkingBudget boundary values', () => {
|
||||
const testCases = [
|
||||
{ thinkingBudget: 1024, expected: 1024 }, // min
|
||||
{ thinkingBudget: 2000, expected: 2000 }, // default
|
||||
{ thinkingBudget: 7000, expected: 7000 }, // within max tokens (8192)
|
||||
{ thinkingBudget: 500, expected: 500 }, // below min
|
||||
{ thinkingBudget: 200000, expected: 7372 }, // above max tokens, constrained to 90% of 8192
|
||||
];
|
||||
|
||||
testCases.forEach(({ thinkingBudget, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
thinking: true,
|
||||
thinkingBudget,
|
||||
},
|
||||
});
|
||||
expect((result.llmConfig.thinking as t.ThinkingConfigEnabled)?.budget_tokens).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Parameter Interactions', () => {
|
||||
it('should handle thinking budget vs maxTokens constraints', () => {
|
||||
const testCases = [
|
||||
// Budget within maxTokens - should keep original
|
||||
{ maxOutputTokens: 4096, thinkingBudget: 2000, expectedBudget: 2000 },
|
||||
// Budget exceeds maxTokens - should constrain to 90%
|
||||
{ maxOutputTokens: 4096, thinkingBudget: 5000, expectedBudget: 3686 }, // 90% of 4096
|
||||
// Budget equals maxTokens - should keep original (not constrained unless it exceeds)
|
||||
{ maxOutputTokens: 2000, thinkingBudget: 2000, expectedBudget: 2000 },
|
||||
// Budget slightly exceeds maxTokens - should constrain to 90%
|
||||
{ maxOutputTokens: 2000, thinkingBudget: 2001, expectedBudget: 1800 }, // 90% of 2000
|
||||
// Very small maxTokens
|
||||
{ maxOutputTokens: 1000, thinkingBudget: 3000, expectedBudget: 900 }, // 90% of 1000
|
||||
];
|
||||
|
||||
testCases.forEach(({ maxOutputTokens, thinkingBudget, expectedBudget }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
maxOutputTokens,
|
||||
thinking: true,
|
||||
thinkingBudget,
|
||||
},
|
||||
});
|
||||
expect((result.llmConfig.thinking as t.ThinkingConfigEnabled)?.budget_tokens).toBe(
|
||||
expectedBudget,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle topP/topK exclusion logic for Claude-3.7 models', () => {
|
||||
const testCases: (t.AnthropicModelOptions & { shouldInclude: boolean })[] = [
|
||||
// Claude-3.7 with thinking = true - should exclude topP/topK
|
||||
{ model: 'claude-3-7-sonnet', thinking: true, shouldInclude: false },
|
||||
{ model: 'claude-3.7-sonnet', thinking: true, shouldInclude: false },
|
||||
// Claude-3.7 with thinking = false - should include topP/topK
|
||||
{ model: 'claude-3-7-sonnet', thinking: false, shouldInclude: true },
|
||||
{ model: 'claude-3.7-sonnet', thinking: false, shouldInclude: true },
|
||||
// Claude-3.7 with thinking = null - thinking defaults to true, so should exclude topP/topK
|
||||
{ model: 'claude-3-7-sonnet', thinking: null, shouldInclude: false },
|
||||
// Non-Claude-3.7 models - should always include topP/topK (thinking doesn't affect them)
|
||||
{ model: 'claude-3-5-sonnet', thinking: true, shouldInclude: true },
|
||||
{ model: 'claude-3-opus', thinking: true, shouldInclude: true },
|
||||
{ model: 'claude-sonnet-4', thinking: true, shouldInclude: true },
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, thinking, shouldInclude }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model,
|
||||
thinking,
|
||||
topP: 0.9,
|
||||
topK: 40,
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldInclude) {
|
||||
expect(result.llmConfig).toHaveProperty('topP', 0.9);
|
||||
expect(result.llmConfig).toHaveProperty('topK', 40);
|
||||
} else {
|
||||
expect(result.llmConfig).not.toHaveProperty('topP');
|
||||
expect(result.llmConfig).not.toHaveProperty('topK');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle prompt cache support logic for different models', () => {
|
||||
const testCases = [
|
||||
// Models that support prompt cache
|
||||
{ model: 'claude-3-5-sonnet', promptCache: true, shouldHaveHeaders: true },
|
||||
{ model: 'claude-3.5-sonnet-20241022', promptCache: true, shouldHaveHeaders: true },
|
||||
{ model: 'claude-3-7-sonnet', promptCache: true, shouldHaveHeaders: true },
|
||||
{ model: 'claude-3.7-sonnet-20250109', promptCache: true, shouldHaveHeaders: true },
|
||||
{ model: 'claude-3-opus', promptCache: true, shouldHaveHeaders: true },
|
||||
{ model: 'claude-sonnet-4-20250514', promptCache: true, shouldHaveHeaders: true },
|
||||
// Models that don't support prompt cache
|
||||
{ model: 'claude-3-5-sonnet-latest', promptCache: true, shouldHaveHeaders: false },
|
||||
{ model: 'claude-3.5-sonnet-latest', promptCache: true, shouldHaveHeaders: false },
|
||||
// Prompt cache disabled
|
||||
{ model: 'claude-3-5-sonnet', promptCache: false, shouldHaveHeaders: false },
|
||||
];
|
||||
|
||||
testCases.forEach(({ model, promptCache, shouldHaveHeaders }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model, promptCache },
|
||||
});
|
||||
|
||||
const headers = result.llmConfig.clientOptions?.defaultHeaders;
|
||||
|
||||
if (shouldHaveHeaders) {
|
||||
expect(headers).toBeDefined();
|
||||
expect((headers as Record<string, string>)['anthropic-beta']).toContain(
|
||||
'prompt-caching',
|
||||
);
|
||||
} else {
|
||||
expect(headers).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter Type Handling', () => {
|
||||
it('should handle different data types for numeric parameters', () => {
|
||||
const testCases = [
|
||||
{ temperature: '0.5', expected: '0.5' }, // string
|
||||
{ temperature: 0.5, expected: 0.5 }, // number
|
||||
{ topP: '0.9', expected: '0.9' }, // string
|
||||
{ topP: 0.9, expected: 0.9 }, // number
|
||||
{ topK: '20', expected: '20' }, // string
|
||||
{ topK: 20, expected: 20 }, // number
|
||||
{ maxOutputTokens: '4096', expected: '4096' }, // string
|
||||
{ maxOutputTokens: 4096, expected: 4096 }, // number
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
const key = Object.keys(testCase)[0] as keyof t.AnthropicModelOptions;
|
||||
const value = (testCase as unknown as t.AnthropicModelOptions)[key];
|
||||
const expected = testCase.expected;
|
||||
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model: 'claude-3-opus', [key]: value },
|
||||
});
|
||||
|
||||
const outputKey = key === 'maxOutputTokens' ? 'maxTokens' : key;
|
||||
expect(result.llmConfig[outputKey as keyof typeof result.llmConfig]).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array parameters correctly', () => {
|
||||
const testCases = [
|
||||
{ stop: [], expected: [] }, // empty array
|
||||
{ stop: ['\\n'], expected: ['\\n'] }, // single item
|
||||
{ stop: ['\\n', 'Human:', 'Assistant:'], expected: ['\\n', 'Human:', 'Assistant:'] }, // multiple items
|
||||
{ stop: null, expected: null }, // null
|
||||
{ stop: undefined, expected: undefined }, // undefined
|
||||
];
|
||||
|
||||
testCases.forEach(({ stop, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model: 'claude-3-opus', stop } as t.AnthropicModelOptions,
|
||||
});
|
||||
|
||||
if (expected === null || expected === undefined) {
|
||||
expect(result.llmConfig).not.toHaveProperty('stopSequences');
|
||||
} else {
|
||||
expect(result.llmConfig.stopSequences).toEqual(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle boolean parameters correctly', () => {
|
||||
const testCases = [
|
||||
{ stream: true, expected: true },
|
||||
{ stream: false, expected: false },
|
||||
{ stream: 'true', expected: 'true' }, // string boolean
|
||||
{ stream: 'false', expected: 'false' }, // string boolean
|
||||
{ stream: 1, expected: 1 }, // truthy number
|
||||
{ stream: 0, expected: 0 }, // falsy number
|
||||
{ thinking: true, expected: true },
|
||||
{ thinking: false, expected: false },
|
||||
{ promptCache: true, expected: true },
|
||||
{ promptCache: false, expected: false },
|
||||
{ web_search: true, expected: true },
|
||||
{ web_search: false, expected: false },
|
||||
];
|
||||
|
||||
testCases.forEach((testCase) => {
|
||||
const key = Object.keys(testCase)[0] as keyof t.AnthropicModelOptions;
|
||||
const value = (testCase as unknown as t.AnthropicModelOptions)[key];
|
||||
const expected = testCase.expected;
|
||||
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model: 'claude-3-opus', [key]: value },
|
||||
});
|
||||
|
||||
if (key === 'stream') {
|
||||
expect(result.llmConfig.stream).toBe(expected);
|
||||
} else if (key === 'web_search' && expected) {
|
||||
expect(result.tools).toEqual([{ type: 'web_search_20250305', name: 'web_search' }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parameter Precedence and Override Logic', () => {
|
||||
it('should handle modelOptions vs defaultOptions precedence', () => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-opus', // override default
|
||||
maxOutputTokens: 2048, // override default
|
||||
stream: false, // override default
|
||||
temperature: 0.3, // new parameter
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.llmConfig).toMatchObject({
|
||||
model: 'claude-3-opus', // overridden
|
||||
maxTokens: 2048, // overridden
|
||||
stream: false, // overridden
|
||||
temperature: 0.3, // added
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle system options extraction and defaults', () => {
|
||||
const modelOptions = {
|
||||
model: 'claude-3-7-sonnet',
|
||||
temperature: 0.5,
|
||||
// Missing system options should use defaults
|
||||
};
|
||||
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions,
|
||||
});
|
||||
|
||||
// System options should be removed from modelOptions
|
||||
expect(modelOptions).not.toHaveProperty('thinking');
|
||||
expect(modelOptions).not.toHaveProperty('promptCache');
|
||||
expect(modelOptions).not.toHaveProperty('thinkingBudget');
|
||||
|
||||
// Should use defaults for system options
|
||||
expect(result.llmConfig.thinking).toMatchObject({
|
||||
type: 'enabled',
|
||||
budget_tokens: 2000, // default
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial system options with defaults', () => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-7-sonnet',
|
||||
thinking: false, // explicit false
|
||||
// promptCache and thinkingBudget should use defaults
|
||||
},
|
||||
});
|
||||
|
||||
// thinking is false, so no thinking object should be created
|
||||
expect(result.llmConfig.thinking).toBeUndefined();
|
||||
// promptCache default is true, so should have headers
|
||||
expect(result.llmConfig.clientOptions?.defaultHeaders).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Error Conditions', () => {
|
||||
it('should handle extremely large numbers', () => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
temperature: Number.MAX_SAFE_INTEGER,
|
||||
topP: Number.MAX_VALUE,
|
||||
topK: 999999,
|
||||
maxOutputTokens: Number.MAX_SAFE_INTEGER,
|
||||
thinkingBudget: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
});
|
||||
|
||||
// Should pass through without crashing
|
||||
expect(result.llmConfig.temperature).toBe(Number.MAX_SAFE_INTEGER);
|
||||
expect(result.llmConfig.topP).toBe(Number.MAX_VALUE);
|
||||
expect(result.llmConfig.topK).toBe(999999);
|
||||
expect(result.llmConfig.maxTokens).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
temperature: -1,
|
||||
topP: -0.5,
|
||||
topK: -10,
|
||||
maxOutputTokens: -1000,
|
||||
thinkingBudget: -500,
|
||||
},
|
||||
});
|
||||
|
||||
// Should pass through negative values (API will handle validation)
|
||||
expect(result.llmConfig.temperature).toBe(-1);
|
||||
expect(result.llmConfig.topP).toBe(-0.5);
|
||||
expect(result.llmConfig.topK).toBe(-10);
|
||||
expect(result.llmConfig.maxTokens).toBe(-1000);
|
||||
});
|
||||
|
||||
it('should handle special numeric values', () => {
|
||||
const testCases = [
|
||||
{ value: NaN, shouldBeRemoved: false }, // NaN passes through removeNullishValues
|
||||
{ value: Infinity, shouldBeRemoved: false },
|
||||
{ value: -Infinity, shouldBeRemoved: false },
|
||||
{ value: 0, shouldBeRemoved: false },
|
||||
{ value: -0, shouldBeRemoved: false },
|
||||
];
|
||||
|
||||
testCases.forEach(({ value, shouldBeRemoved }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: {
|
||||
model: 'claude-3-opus',
|
||||
temperature: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldBeRemoved) {
|
||||
expect(result.llmConfig).not.toHaveProperty('temperature');
|
||||
} else {
|
||||
expect(result.llmConfig.temperature).toBe(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle malformed stop sequences', () => {
|
||||
const testCases = [
|
||||
{ stop: 'string', expected: 'string' }, // single string instead of array
|
||||
{ stop: [null, undefined, ''], expected: [null, undefined, ''] }, // mixed values
|
||||
{ stop: [123, true, false], expected: [123, true, false] }, // non-string values
|
||||
{ stop: {}, expected: {} }, // object instead of array
|
||||
];
|
||||
|
||||
testCases.forEach(({ stop, expected }) => {
|
||||
const result = getLLMConfig('test-key', {
|
||||
modelOptions: { model: 'claude-3-opus', stop } as t.AnthropicModelOptions,
|
||||
});
|
||||
|
||||
expect(result.llmConfig.stopSequences).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user